#!/usr/bin/env python3 import argparse import json from pathlib import Path from typing import Any, Dict, Iterable, List, Tuple FIRST_PASS_GATES = { "prereq_op_selector", "target_file_selection", "acceptance_command_selection", "required_tool_selection", } REPO_ROOT = Path(__file__).resolve().parent.parent def load_json(path: Path) -> Dict[str, Any]: with path.open("r", encoding="utf-8") as fh: return json.load(fh) def iter_run_dirs(root: Path) -> Iterable[Path]: for child in sorted(root.iterdir()): if not child.is_dir(): continue if (child / "01_intake.json").exists() and (child / "02_generate_taskitems.json").exists(): yield child def nonempty_list(value: Any) -> List[Any]: if isinstance(value, list): return value return [] def task_context(intake: Dict[str, Any], task: Dict[str, Any]) -> Dict[str, Any]: return { "normalized_requirements": intake.get("normalizedRequirements", []), "parsed_spec": intake.get("parsedSpec", {}), "conflict_signals": intake.get("conflictSignals", []), "ambiguity_signals": intake.get("ambiguitySignals", []), "task_id": task.get("taskId"), "title": task.get("title"), "milestone_id": task.get("milestoneId"), "dependency_task_ids": nonempty_list(task.get("dependencyTaskIds")), "confidence": task.get("confidence"), "ambiguity_count": task.get("ambiguityCount"), "reasons": nonempty_list(task.get("reasons")), } def build_sibling_maps(tasks: List[Dict[str, Any]]) -> Dict[str, Dict[str, int]]: target_counts: Dict[str, int] = {} acceptance_counts: Dict[str, int] = {} tool_counts: Dict[str, int] = {} for task in tasks: contract = task.get("executionContract", {}) targets = tuple(nonempty_list(contract.get("targetFiles"))) acceptance = tuple(nonempty_list(contract.get("acceptanceCommands"))) tools = tuple(nonempty_list(contract.get("requiredTools"))) if targets: target_counts[json.dumps(targets)] = target_counts.get(json.dumps(targets), 0) + 1 if acceptance: acceptance_counts[json.dumps(acceptance)] = acceptance_counts.get(json.dumps(acceptance), 0) + 1 if tools: tool_counts[json.dumps(tools)] = tool_counts.get(json.dumps(tools), 0) + 1 return { "targets": target_counts, "acceptance": acceptance_counts, "tools": tool_counts, } def base_row( gate_id: str, run_dir: Path, task: Dict[str, Any], contract_path: Path, provenance: str, ) -> Dict[str, Any]: return { "gate_id": gate_id, "run_id": run_dir.name, "task_id": task.get("taskId"), "decision_context": {}, "decision_label": {}, "decision_provenance": provenance, "source_artifacts": [ str(run_dir / "01_intake.json"), str(run_dir / "02_generate_taskitems.json"), str(contract_path), ], "rejection_flags": [], } def extract_prereq_row( run_dir: Path, intake: Dict[str, Any], task: Dict[str, Any], contract_path: Path, ) -> Dict[str, Any]: row = base_row("prereq_op_selector", run_dir, task, contract_path, "direct_bundled") prereqs = nonempty_list(task.get("prerequisiteOps")) accepted = {"validate-intake", "architect-review", "resolve-dependencies"} invalid = sorted(op for op in prereqs if op not in accepted) row["decision_context"] = task_context(intake, task) row["decision_label"] = { "needs_validate_intake": "validate-intake" in prereqs, "needs_architect_review": "architect-review" in prereqs, "needs_resolve_dependencies": "resolve-dependencies" in prereqs, "source_values": prereqs, } if invalid: row["rejection_flags"].append(f"invalid_prerequisite_values:{','.join(invalid)}") if not row["decision_context"]["title"]: row["rejection_flags"].append("missing_task_title") if row["decision_context"]["confidence"] is None: row["rejection_flags"].append("missing_confidence") return row def extract_target_files_row( run_dir: Path, intake: Dict[str, Any], task: Dict[str, Any], contract_path: Path, sibling_maps: Dict[str, Dict[str, int]], sibling_count: int, ) -> Dict[str, Any]: row = base_row("target_file_selection", run_dir, task, contract_path, "direct_bundled") execution_contract = task.get("executionContract", {}) targets = nonempty_list(execution_contract.get("targetFiles")) row["decision_context"] = task_context(intake, task) row["decision_label"] = { "target_files": targets, } if not targets: row["rejection_flags"].append("empty_target_files") if sibling_count > 1 and targets: key = json.dumps(tuple(targets)) if sibling_maps["targets"].get(key, 0) == sibling_count: row["rejection_flags"].append("sibling_uniform_target_files") return row def extract_acceptance_row( run_dir: Path, intake: Dict[str, Any], task: Dict[str, Any], contract_path: Path, sibling_maps: Dict[str, Dict[str, int]], sibling_count: int, ) -> Dict[str, Any]: row = base_row("acceptance_command_selection", run_dir, task, contract_path, "direct_bundled") execution_contract = task.get("executionContract", {}) commands = nonempty_list(execution_contract.get("acceptanceCommands")) row["decision_context"] = task_context(intake, task) row["decision_context"]["target_files"] = nonempty_list(execution_contract.get("targetFiles")) row["decision_label"] = { "acceptance_commands": commands, } if not commands: row["rejection_flags"].append("empty_acceptance_commands") if not row["decision_context"]["target_files"]: row["rejection_flags"].append("missing_target_files_context") if sibling_count > 1 and commands: key = json.dumps(tuple(commands)) if sibling_maps["acceptance"].get(key, 0) == sibling_count: row["rejection_flags"].append("sibling_uniform_acceptance_commands") return row def extract_required_tools_row( run_dir: Path, intake: Dict[str, Any], task: Dict[str, Any], contract_path: Path, sibling_maps: Dict[str, Dict[str, int]], sibling_count: int, ) -> Dict[str, Any]: row = base_row("required_tool_selection", run_dir, task, contract_path, "direct_bundled") execution_contract = task.get("executionContract", {}) tools = nonempty_list(execution_contract.get("requiredTools")) row["decision_context"] = task_context(intake, task) row["decision_context"]["prerequisite_ops"] = nonempty_list(task.get("prerequisiteOps")) row["decision_context"]["target_files"] = nonempty_list(execution_contract.get("targetFiles")) row["decision_label"] = { "required_tools": tools, } if not tools: row["rejection_flags"].append("empty_required_tools") if sibling_count > 1 and tools: key = json.dumps(tuple(tools)) if sibling_maps["tools"].get(key, 0) == sibling_count: row["rejection_flags"].append("sibling_uniform_required_tools") return row def extract_rows_from_run(run_dir: Path, contract_path: Path) -> Tuple[List[Dict[str, Any]], Dict[str, int]]: intake = load_json(run_dir / "01_intake.json") generated = load_json(run_dir / "02_generate_taskitems.json") tasks = nonempty_list(generated.get("tasks")) sibling_maps = build_sibling_maps(tasks) rows: List[Dict[str, Any]] = [] stats = {"runs": 1, "tasks": len(tasks), "rows": 0, "rejected_rows": 0} sibling_count = len(tasks) for task in tasks: gate_rows = [ extract_prereq_row(run_dir, intake, task, contract_path), extract_target_files_row(run_dir, intake, task, contract_path, sibling_maps, sibling_count), extract_acceptance_row(run_dir, intake, task, contract_path, sibling_maps, sibling_count), extract_required_tools_row(run_dir, intake, task, contract_path, sibling_maps, sibling_count), ] for row in gate_rows: rows.append(row) stats["rows"] += 1 if row["rejection_flags"]: stats["rejected_rows"] += 1 return rows, stats def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Extract RSA gate rows from whetstone_DSL taskitem runs.") parser.add_argument( "--contracts", default="semantic/rsa_gate_extraction_contracts_v0.json", help="Path to the extraction contract JSON.", ) parser.add_argument( "--taskitem-runs-root", required=True, help="Root directory containing whetstone_DSL taskitem run folders.", ) parser.add_argument( "--output", required=True, help="Output NDJSON path for extracted rows.", ) parser.add_argument( "--limit-runs", type=int, default=0, help="Optional limit on how many run directories to process.", ) return parser.parse_args() def main() -> int: args = parse_args() contract_path = Path(args.contracts) if not contract_path.is_absolute(): contract_path = (REPO_ROOT / contract_path).resolve() contracts = load_json(contract_path) missing = FIRST_PASS_GATES.difference(contracts.get("contracts", {}).keys()) if missing: raise SystemExit(f"missing first-pass gate contracts: {sorted(missing)}") runs_root = Path(args.taskitem_runs_root).resolve() output_path = Path(args.output).resolve() output_path.parent.mkdir(parents=True, exist_ok=True) rows: List[Dict[str, Any]] = [] totals = {"runs": 0, "tasks": 0, "rows": 0, "rejected_rows": 0} for index, run_dir in enumerate(iter_run_dirs(runs_root), start=1): if args.limit_runs and index > args.limit_runs: break run_rows, run_stats = extract_rows_from_run(run_dir, contract_path) rows.extend(run_rows) for key, value in run_stats.items(): totals[key] += value with output_path.open("w", encoding="utf-8") as fh: for row in rows: fh.write(json.dumps(row, sort_keys=True) + "\n") summary = { "contracts_path": str(contract_path), "runs_root": str(runs_root), "output_path": str(output_path), "runs_processed": totals["runs"], "tasks_processed": totals["tasks"], "rows_emitted": totals["rows"], "rows_with_rejection_flags": totals["rejected_rows"], } print(json.dumps(summary, indent=2, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())