diff --git a/docs/orchestration/domain_scenario_loop_repo_adapter.md b/docs/orchestration/domain_scenario_loop_repo_adapter.md index 1a4c2f9..57e2b5b 100644 --- a/docs/orchestration/domain_scenario_loop_repo_adapter.md +++ b/docs/orchestration/domain_scenario_loop_repo_adapter.md @@ -171,7 +171,7 @@ The status payload also exposes `effective_stage_status`, `effective_stage_statu Use `python scripts/stage_agent_loop.py continue --manifest docs/orchestration/.json` as the safe one-command continuation layer. From a cold start it materializes `domain_pack_loop.command.txt` without launching the long live loop; after a GUI review it can prepare a repair iteration and materialize `run-repair --dry-run` automatically; it will not run the real coder pass unless `--execute-repair` is passed, and it waits for a `--run-id assistant-stage1-` when the next required step is post-repair rerun/ingest validation. -It also writes `stage_repair_handoff.md/json` next to the stage summary. That handoff is the preferred input for the next coder pass: it lists primary repair targets and sample user-facing failures without forcing the coder to reread the entire GUI conversation first. +It also writes `stage_repair_handoff.md/json` next to the stage summary. That handoff is the preferred input for the next coder pass: it lists primary repair targets, detector result signals, and sample user-facing failures without forcing the coder to reread the entire GUI conversation first. For live stage-pack failures, prefer `lead_coder_handoff.md` over immediately preparing a coder pass. The intent is: strong business audit first, Lead Codex code repair second, same replay/GUI validation third. diff --git a/scripts/stage_agent_loop.py b/scripts/stage_agent_loop.py index d35d76b..f4b243a 100644 --- a/scripts/stage_agent_loop.py +++ b/scripts/stage_agent_loop.py @@ -1354,6 +1354,52 @@ def load_gui_review_detector_gate(review_dir: Path) -> dict[str, Any]: } +def load_detector_results_for_stage_handoff(latest_gui_review: dict[str, Any]) -> dict[str, Any]: + raw_path = str(latest_gui_review.get("detector_results_json") or "").strip() + if not raw_path: + return {} + path = repo_path(raw_path) + if not path.exists(): + return {} + try: + return load_json_object(path, "Stage GUI detector_results.json") + except (OSError, json.JSONDecodeError, RuntimeError): + return {} + + +def build_detector_result_signals( + detector_results: dict[str, Any], + *, + issue_codes: list[str], + limit: int = 8, +) -> list[dict[str, Any]]: + results = detector_results.get("results") if isinstance(detector_results.get("results"), list) else [] + wanted_issue_codes = set(issue_codes) + signals: list[dict[str, Any]] = [] + for item in results: + if not isinstance(item, dict): + continue + status = str(item.get("status") or "").strip() + if status not in {"fail", "review"}: + continue + item_issue_codes = dcl.normalize_string_list(item.get("issue_codes")) + if wanted_issue_codes and not wanted_issue_codes.intersection(item_issue_codes): + continue + evidence = item.get("evidence") if isinstance(item.get("evidence"), list) else [] + signals.append( + { + "detector": item.get("detector"), + "status": status, + "issue_codes": item_issue_codes, + "message": item.get("message"), + "evidence_preview": evidence[:3], + } + ) + if len(signals) >= limit: + break + return signals + + def unique_strings(values: list[str]) -> list[str]: result: list[str] = [] for value in values: @@ -1682,7 +1728,10 @@ def build_stage_repair_handoff(summary: dict[str, Any], review: dict[str, Any]) detector_results_summary = ( latest.get("detector_results_summary") if isinstance(latest.get("detector_results_summary"), dict) else {} ) - detector_results_for_gate = {"summary": detector_results_summary, "results": []} + detector_results = load_detector_results_for_stage_handoff(latest) + if detector_results: + detector_results_summary = dcl.summarize_detector_results(detector_results) + detector_results_for_gate = detector_results if detector_results else {"summary": detector_results_summary, "results": []} assigned_focus = ( repair_contract.get("priority_foci", [None])[0] if isinstance(repair_contract.get("priority_foci"), list) and repair_contract.get("priority_foci") @@ -1714,6 +1763,10 @@ def build_stage_repair_handoff(summary: dict[str, Any], review: dict[str, Any]) "candidate_files": repair_contract.get("candidate_files"), "repair_contract": repair_contract, "auto_propose_gate": auto_propose_gate, + "detector_result_signals": build_detector_result_signals( + detector_results_for_gate, + issue_codes=dcl.normalize_string_list(repair_contract.get("issue_codes")), + ), "primary_repair_targets": primary_targets, "sample_findings": sample_findings, } @@ -1748,6 +1801,22 @@ def build_stage_repair_handoff_markdown(handoff: dict[str, Any]) -> str: "", ] ) + detector_signals = ( + handoff.get("detector_result_signals") if isinstance(handoff.get("detector_result_signals"), list) else [] + ) + if detector_signals: + lines.extend(["## Detector Result Signals"]) + for signal in detector_signals: + if not isinstance(signal, dict): + continue + lines.extend( + [ + f"- `{signal.get('status')}` `{signal.get('detector')}` " + f"`{', '.join(dcl.normalize_string_list(signal.get('issue_codes'))) or 'n/a'}`", + f" message: {str(signal.get('message') or '').strip()}", + ] + ) + lines.append("") auto_gate = handoff.get("auto_propose_gate") if isinstance(handoff.get("auto_propose_gate"), dict) else {} if auto_gate: lines.extend( diff --git a/scripts/test_stage_agent_loop.py b/scripts/test_stage_agent_loop.py index 4438356..944917e 100644 --- a/scripts/test_stage_agent_loop.py +++ b/scripts/test_stage_agent_loop.py @@ -761,6 +761,80 @@ class StageAgentLoopTests(unittest.TestCase): self.assertTrue(handoff["auto_propose_gate"]["allowed"]) self.assertEqual(handoff["auto_propose_gate"]["detector_results_summary"]["status"], "fail") + def test_stage_repair_handoff_loads_detector_result_signals_from_json(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + detector_results_path = Path(tmp) / "detector_results.json" + write_json( + detector_results_path, + { + "schema_version": "agent_detector_results_v1", + "created_at": "2026-06-03T00:00:00+00:00", + "artifact_dir": str(Path(tmp)), + "registry_path": "docs/orchestration/detector_registry.json", + "selected_detectors": ["business_direct_answer_missing_signal"], + "artifact_counts": {}, + "summary": { + "status": "fail", + "detector_count": 1, + "pass": 0, + "fail": 1, + "review": 0, + "skipped": 0, + }, + "results": [ + { + "detector": "business_direct_answer_missing_signal", + "kind": "semantic", + "automation_level": "deterministic", + "status": "fail", + "issue_codes": ["business_direct_answer_missing"], + "message": "Direct answer is missing on the first line.", + "evidence": [{"turn_index": 19, "first_line": "Коротко: общий обзор"}], + } + ], + }, + ) + summary = { + "stage_id": "agent_loop", + "next_action": "continue_repair_from_gui_review_p0", + "latest_gui_review": { + "run_id": "assistant-stage1-test", + "overall_business_status": "fail", + "p0_findings": 1, + "p1_findings": 0, + "question_quality_score": 100, + "review_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/run_review.json", + "review_markdown": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/run_review.md", + "repair_targets_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/repair_targets.json", + "conversation_pairs_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/conversation_pairs.json", + "detector_results_json": str(detector_results_path), + "detector_results_status": "fail", + "detector_results_summary": {"status": "fail", "detector_count": 1, "fail": 1}, + }, + } + review = { + "run_id": "assistant-stage1-test", + "repair_targets": [ + { + "problem_layer": "answer_shape_mismatch", + "issue_code": "business_direct_answer_missing", + "severity": "P0", + "occurrences": 1, + } + ], + "findings": [], + } + + handoff = stage_loop.build_stage_repair_handoff(summary, review) + + self.assertTrue(handoff["auto_propose_gate"]["allowed"]) + self.assertEqual( + handoff["auto_propose_gate"]["detector_results_summary"]["failed_detectors"], + ["business_direct_answer_missing_signal"], + ) + self.assertEqual(handoff["detector_result_signals"][0]["detector"], "business_direct_answer_missing_signal") + self.assertIn("Direct answer is missing", handoff["detector_result_signals"][0]["message"]) + def test_stage_repair_handoff_blocks_auto_propose_for_non_allowlisted_gui_issue(self) -> None: summary = { "stage_id": "agent_loop",