diff --git a/src/cli.py b/src/cli.py index 6fe360c..171d97f 100644 --- a/src/cli.py +++ b/src/cli.py @@ -2213,6 +2213,37 @@ def _write_control_center_artifacts( return json_path, md_path, weekly_json, weekly_md, payload +def _enrich_control_center_snapshot_from_report( + report_data: dict, + snapshot: dict, + args, +) -> dict: + report = _report_from_dict( + { + **report_data, + "operator_summary": snapshot.get("operator_summary", {}), + "operator_queue": snapshot.get("operator_queue", []), + } + ) + if any(audit.portfolio_catalog for audit in report.audits): + audit_lookup = {audit.metadata.name: audit.portfolio_catalog for audit in report.audits} + for item in report.operator_queue: + repo_name = str(item.get("repo") or item.get("repo_name") or "").strip() + catalog_entry = audit_lookup.get(repo_name, {}) + if catalog_entry: + item["portfolio_catalog"] = dict(catalog_entry) + item["catalog_line"] = catalog_entry.get("catalog_line", "") + item["intent_alignment"] = catalog_entry.get("intent_alignment", "missing-contract") + item["intent_alignment_reason"] = catalog_entry.get("intent_alignment_reason", "") + else: + report = _apply_portfolio_catalog(report, args) + report = _apply_scorecards(report, args) + report = _apply_operating_paths(report) + snapshot["operator_summary"] = report.operator_summary + snapshot["operator_queue"] = report.operator_queue + return snapshot + + def _write_approval_receipt( output_dir: Path, username: str, @@ -2385,6 +2416,7 @@ def _run_control_center_mode(args, parser) -> None: output_dir=output_dir, triage_view=args.triage_view, ) + snapshot = _enrich_control_center_snapshot_from_report(normalized, snapshot, args) artifact_generated_at = _report_artifact_datetime( report_path, _parse_iso_dt(normalized.get("generated_at")) or datetime.now(timezone.utc), @@ -2584,6 +2616,7 @@ def _run_acknowledgment_capture_mode(args, parser) -> None: build_acknowledgment_record, find_matching_change, find_sibling_changes, + load_acknowledgments, save_acknowledgment, ) from src.recurring_review import MATERIALITY_THRESHOLDS, evaluate_material_changes @@ -2615,10 +2648,12 @@ def _run_acknowledgment_capture_mode(args, parser) -> None: diff_data=diff_dict, thresholds=MATERIALITY_THRESHOLDS["standard"], ) + acknowledgments = load_acknowledgments(output_dir, args.username) matched = find_matching_change( repo_name=args.acknowledge_target, change_kind=args.acknowledge_kind, material_changes=material_changes, + acknowledgments=acknowledgments, ) if not matched: parser.error( diff --git a/src/operator_acknowledgments.py b/src/operator_acknowledgments.py index a6b9a19..6fc1c3b 100644 --- a/src/operator_acknowledgments.py +++ b/src/operator_acknowledgments.py @@ -125,12 +125,15 @@ def find_matching_change( repo_name: str, change_kind: str, material_changes: list[dict], + acknowledgments: list[dict] | None = None, ) -> dict | None: for change in material_changes: if change.get("change_type") != change_kind: continue if change.get("repo_name") != repo_name: continue + if is_change_acknowledged(change, acknowledgments or []): + continue return change return None diff --git a/tests/test_cli_hardening.py b/tests/test_cli_hardening.py index 5ece5ec..2b05d59 100644 --- a/tests/test_cli_hardening.py +++ b/tests/test_cli_hardening.py @@ -459,6 +459,91 @@ def test_apply_scorecards_populates_report(monkeypatch, sample_metadata, tmp_pat assert updated.operator_queue[0]["scorecard_line"].startswith("Scorecard: Maintain") +def test_control_center_snapshot_rehydrates_portfolio_context( + tmp_path, + sample_metadata, +) -> None: + catalog_path = tmp_path / "portfolio-catalog.yaml" + catalog_path.write_text( + """ +repos: + test-repo: + owner: d + lifecycle_state: active + review_cadence: monthly + intended_disposition: maintain + maturity_program: maintain + target_maturity: operating +""" + ) + scorecards_path = tmp_path / "scorecards.yaml" + scorecards_path.write_text( + """ +programs: + maintain: + label: Maintain + target_maturity: operating + rules: + - key: testing + label: Testing + check: dimension_at_least + dimension: testing + threshold: 0.80 + partial_threshold: 0.60 + weight: 1.0 +""" + ) + audit = RepoAudit( + metadata=sample_metadata, + analyzer_results=[AnalyzerResult("testing", 1.0, 1.0, [], {})], + overall_score=0.9, + completeness_tier="shipped", + flags=[], + lenses={"ship_readiness": {"score": 0.9}}, + security_posture={"score": 1.0}, + portfolio_catalog={ + "has_explicit_entry": True, + "intended_disposition": "maintain", + "maturity_program": "maintain", + "target_maturity": "operating", + "operating_path": "maintain", + "path_confidence": "high", + "intent_alignment": "aligned", + "intent_alignment_reason": "The repo is holding a maintain posture.", + "catalog_line": "d | lifecycle active | disposition maintain | program maintain", + }, + ) + report = AuditReport.from_audits("testuser", [audit], [], 1) + report.operator_summary = {"headline": "Review current movement."} + report.operator_queue = [ + { + "repo": "test-repo", + "lane": "urgent", + "title": "Review test-repo", + "portfolio_catalog": { + "path_confidence": "low", + "intent_alignment": "needs-review", + }, + } + ] + snapshot = { + "operator_summary": dict(report.operator_summary), + "operator_queue": [dict(report.operator_queue[0])], + } + + updated = cli._enrich_control_center_snapshot_from_report( + report.to_dict(), + snapshot, + _make_args(catalog=catalog_path, scorecards=scorecards_path), + ) + + item = updated["operator_queue"][0] + assert item["portfolio_catalog"]["intent_alignment"] == "aligned" + assert item["portfolio_catalog"]["path_confidence"] == "high" + assert item["portfolio_catalog"]["operating_path"] == "maintain" + assert item["scorecard"]["status"] == "on-track" + + def test_main_doctor_writes_artifact_and_exits_cleanly(monkeypatch, tmp_path, capsys): args = _make_args(doctor=True, output_dir=str(tmp_path)) artifact_path = tmp_path / "diagnostics-testuser-2026-03-29.json" diff --git a/tests/test_operator_acknowledgments.py b/tests/test_operator_acknowledgments.py index 64164b4..023b780 100644 --- a/tests/test_operator_acknowledgments.py +++ b/tests/test_operator_acknowledgments.py @@ -185,6 +185,32 @@ def test_is_change_acknowledged_rejects_unrelated_change(): assert is_change_acknowledged(other, [ack]) is False +def test_find_matching_change_skips_already_acknowledged_change(): + first = _make_change( + change_type="lens-delta", + title="RepoA shifted on maintenance risk", + details={"lens": "maintenance_risk", "delta": -0.1}, + ) + second = { + **_make_change( + change_type="lens-delta", + title="RepoA shifted on momentum", + details={"lens": "momentum", "delta": 0.1}, + ), + "change_key": "key-lens-delta-RepoA-momentum", + } + ack = build_acknowledgment_record(first, reviewer="alice", note="reviewed") + + match = find_matching_change( + repo_name="RepoA", + change_kind="lens-delta", + material_changes=[first, second], + acknowledgments=[ack], + ) + + assert match == second + + def test_is_change_acknowledged_handles_empty_acknowledgments(): change = _make_change() assert is_change_acknowledged(change, []) is False