diff --git a/config/portfolio-catalog.yaml b/config/portfolio-catalog.yaml index 5fe19a4..be253a5 100644 --- a/config/portfolio-catalog.yaml +++ b/config/portfolio-catalog.yaml @@ -164,6 +164,17 @@ repos: lifecycle_state: active review_cadence: weekly intended_disposition: experiment + engraph: + owner: d + lifecycle_state: archived + review_cadence: quarterly + intended_disposition: archive + category: vanity + tool_provenance: claude-code + maturity_program: archive + target_maturity: operating + automation_eligible: false + notes: Settled portfolio-recovery exclusion; keep manual-only unless the operator explicitly reactivates it. EvolutionSandbox: owner: d lifecycle_state: active @@ -183,6 +194,17 @@ repos: intended_disposition: maintain category: vanity tool_provenance: claude-code + reliability-vault: + owner: d + lifecycle_state: archived + review_cadence: quarterly + intended_disposition: archive + category: vanity + tool_provenance: unknown + maturity_program: archive + target_maturity: operating + automation_eligible: false + notes: Settled portfolio-recovery exclusion; keep manual-only unless the operator explicitly reactivates it. JSMTicketAnalyticsExport: owner: d lifecycle_state: active diff --git a/src/portfolio_decision_queue.py b/src/portfolio_decision_queue.py index 3691ffe..9c859d2 100644 --- a/src/portfolio_decision_queue.py +++ b/src/portfolio_decision_queue.py @@ -7,10 +7,14 @@ from __future__ import annotations +import argparse +import json from dataclasses import dataclass +from pathlib import Path from typing import Any CONTRACT_VERSION = "decision_queue_v1" +DIGEST_CONTRACT_VERSION = "portfolio_decision_digest_v1" MAX_DECISION_QUEUE_ITEMS = 5 NON_DEFAULT_STATES = frozenset( @@ -145,3 +149,94 @@ def summarize_decision_queue(items: list[dict[str, Any]]) -> dict[str, Any]: "decision_queue_count": len(items), "decision_queue_type_counts": type_counts, } + + +def build_decision_digest(portfolio_truth: dict[str, Any]) -> dict[str, Any]: + """Build a deterministic, truth-native operator digest. + + The digest deliberately contains only the decision queue. It does not + reintroduce stale-repo, weak-context, or prose-based "unshipped" watch + lists that the portfolio attention contract excludes from decisions. + """ + decision_queue = build_decision_queue(portfolio_truth) + summary = summarize_decision_queue(decision_queue) + return { + "contract_version": DIGEST_CONTRACT_VERSION, + "source": { + "schema_version": _text(portfolio_truth.get("schema_version")) or "unknown", + "generated_at": _text(portfolio_truth.get("generated_at")) or "unknown", + }, + "decision_queue": decision_queue, + "summary": summary, + } + + +def render_decision_digest_markdown(digest: dict[str, Any]) -> str: + """Render the compact nightly decision digest as deterministic Markdown.""" + source = _mapping(digest.get("source")) + generated_at = _text(source.get("generated_at")) or "unknown" + schema_version = _text(source.get("schema_version")) or "unknown" + date_label = generated_at[:10] if generated_at != "unknown" else "unknown" + decision_queue = [ + item for item in digest.get("decision_queue") or [] if isinstance(item, dict) + ] + summary = _mapping(digest.get("summary")) + count = int(summary.get("decision_queue_count") or len(decision_queue)) + + lines = [ + f"## Portfolio Decision Digest — {date_label}", + "", + "### Decision Queue", + ] + if not decision_queue: + lines.append("- No portfolio decisions clear the current evidence bar.") + else: + for item in decision_queue: + project = _text(item.get("project")) or "Unknown project" + decision_type = _text(item.get("decision_type")) or "unknown" + why_now = _text(item.get("why_now")) or "No current rationale recorded." + next_action = ( + _text(item.get("recommended_action")) or "Resolve the current decision." + ) + lines.append( + f"- **{project}** [{decision_type}]: {why_now} Next: {next_action}" + ) + + lines.extend( + [ + "", + "### Source Freshness", + f"- PortfolioTruthV1 schema `{schema_version}`, generated `{generated_at}`.", + "", + "### Summary", + f"{count} decision{'s' if count != 1 else ''} | contract `{CONTRACT_VERSION}`", + ] + ) + return "\n".join(lines) + "\n" + + +def _load_portfolio_truth(path: Path) -> dict[str, Any]: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("portfolio truth root must be an object") + return raw + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Render the DecisionQueueV1 digest from PortfolioTruthV1." + ) + parser.add_argument("--truth", type=Path, required=True) + parser.add_argument("--format", choices=("json", "markdown"), default="markdown") + args = parser.parse_args(argv) + + digest = build_decision_digest(_load_portfolio_truth(args.truth)) + if args.format == "json": + print(json.dumps(digest, indent=2, sort_keys=True)) + else: + print(render_decision_digest_markdown(digest), end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_portfolio_catalog.py b/tests/test_portfolio_catalog.py index 376f9e6..622f8b2 100644 --- a/tests/test_portfolio_catalog.py +++ b/tests/test_portfolio_catalog.py @@ -148,6 +148,18 @@ def test_catalog_entry_for_repo_defaults_automation_eligible_false(): assert entry["automation_eligible"] is False +def test_live_catalog_keeps_settled_recovery_exclusions_archived() -> None: + catalog_path = Path(__file__).parents[1] / "config" / "portfolio-catalog.yaml" + catalog = load_portfolio_catalog(catalog_path) + + for repo_name in ("engraph", "reliability-vault"): + entry = catalog["repos"][repo_name] + assert entry["lifecycle_state"] == "archived" + assert entry["intended_disposition"] == "archive" + assert entry["maturity_program"] == "archive" + assert entry["automation_eligible"] is False + + def test_catalog_entry_matches_full_name_then_bare_name(): catalog = { "repos": { @@ -388,4 +400,4 @@ def test_catalog_line_and_summaries_cover_missing_contracts(): assert catalog_summary["cataloged_repo_count"] == 1 assert catalog_summary["missing_contract_count"] == 1 assert alignment_summary["counts"]["aligned"] == 1 - assert alignment_summary["counts"]["missing-contract"] == 1 \ No newline at end of file + assert alignment_summary["counts"]["missing-contract"] == 1 diff --git a/tests/test_portfolio_decision_queue.py b/tests/test_portfolio_decision_queue.py index 67aac0f..11463c7 100644 --- a/tests/test_portfolio_decision_queue.py +++ b/tests/test_portfolio_decision_queue.py @@ -1,6 +1,17 @@ from __future__ import annotations -from src.portfolio_decision_queue import build_decision_queue, summarize_decision_queue +import json +from pathlib import Path + +import pytest + +from src.portfolio_decision_queue import ( + build_decision_digest, + build_decision_queue, + main, + render_decision_digest_markdown, + summarize_decision_queue, +) def _project( @@ -94,3 +105,70 @@ def test_archived_security_risk_stays_out_of_queue() -> None: } assert build_decision_queue(truth) == [] + + +def test_decision_digest_zero_state_is_truth_native() -> None: + truth = { + "schema_version": "0.8.0", + "generated_at": "2026-07-11T09:00:10+00:00", + "projects": [_project("Infra", attention_state="active-infra")], + } + + digest = build_decision_digest(truth) + rendered = render_decision_digest_markdown(digest) + + assert digest["contract_version"] == "portfolio_decision_digest_v1" + assert digest["decision_queue"] == [] + assert digest["summary"]["decision_queue_count"] == 0 + assert "No portfolio decisions clear the current evidence bar." in rendered + assert "PortfolioTruthV1 schema `0.8.0`" in rendered + assert "stale" not in rendered.lower() + assert "weak context" not in rendered.lower() + assert "unshipped" not in rendered.lower() + + +def test_decision_digest_renders_ranked_decisions_and_caps_at_five() -> None: + truth = { + "schema_version": "0.8.0", + "generated_at": "2026-07-11T09:00:10+00:00", + "projects": [ + _project(f"Decision-{index}", attention_state="decision-needed") + for index in range(7) + ], + } + + digest = build_decision_digest(truth) + rendered = render_decision_digest_markdown(digest) + + assert len(digest["decision_queue"]) == 5 + assert digest["summary"]["decision_queue_count"] == 5 + assert rendered.count("[owner or human decision]") == 5 + assert "Decision-0" in rendered + assert "Decision-5" not in rendered + + +def test_cli_json_and_markdown_are_deterministic( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + truth_path = tmp_path / "portfolio-truth.json" + truth_path.write_text( + json.dumps( + { + "schema_version": "0.8.0", + "generated_at": "2026-07-11T09:00:10+00:00", + "projects": [_project("NeedsDecision", attention_state="decision-needed")], + } + ), + encoding="utf-8", + ) + + assert main(["--truth", str(truth_path), "--format", "json"]) == 0 + json_first = capsys.readouterr().out + assert main(["--truth", str(truth_path), "--format", "json"]) == 0 + json_second = capsys.readouterr().out + assert json_first == json_second + + assert main(["--truth", str(truth_path), "--format", "markdown"]) == 0 + markdown = capsys.readouterr().out + assert "## Portfolio Decision Digest — 2026-07-11" in markdown + assert "**NeedsDecision** [owner or human decision]" in markdown