From f43b2ebc81b0d19795268f98029af0b2f9f2a950 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sat, 6 Jun 2026 03:36:09 -0700 Subject: [PATCH 1/3] feat(automation): content-gen + orchestrator wiring (Arc D phase 3b library) Wire the gated executor (3a) to the recovery content primitives and drive the load -> filter-approved -> plan -> execute -> mark-executed -> save loop. - build_context_pr_plan: ExecutionPlan whose apply_change regenerates the managed context block via recovery's _build_context_sections + render/upsert_managed_context_block (byte-identical to the direct-apply path). - build_catalog_seeds_for: reuses _suggested_catalog_seed, keyed by catalog key. - execute_approved_proposals: routes only APPROVED proposals by action_type; real apply transitions to EXECUTED + persists; skips context-PR with empty repo_full_name (no fabricated slug); resolves by slug then display_name. 13 tests, FakeRunner-injected (no real git/network). --- src/automation_workflow.py | 232 ++++++++++++++++++ tests/test_automation_workflow.py | 375 ++++++++++++++++++++++++++++++ 2 files changed, 607 insertions(+) create mode 100644 src/automation_workflow.py create mode 100644 tests/test_automation_workflow.py diff --git a/src/automation_workflow.py b/src/automation_workflow.py new file mode 100644 index 0000000..d44f282 --- /dev/null +++ b/src/automation_workflow.py @@ -0,0 +1,232 @@ +"""Content-generation + orchestration wiring for bounded automation (Arc D, phase 3b). + +This is the last mile of Safe Automation: it binds the gated executor (phase 3a, +``automation_executor.py``) to the existing context-recovery content primitives, +and drives the full lifecycle loop — +``load -> filter-approved -> plan -> execute -> mark-executed -> save``. + +Design rules carried from the earlier phases: + +* Content is derived FRESH at execution time (recovery's ``_build_context_sections`` + + ``_suggested_catalog_seed``), so nothing goes stale between proposal and run. +* A context-PR plan produces the SAME managed block as the direct-apply recovery + path — it reuses recovery's section builder rather than reimplementing it, so + the two render paths can never diverge. +* The executor owns every git/gh safety rail (approval gate, never-default-branch, + never-force, skip-dirty, dry-run-default, fail-fast). This module only builds + the plan/seed and routes approved proposals to it. +* A context-PR is skipped when the repo has no GitHub slug — we never fabricate + a head/base ref from the local display name. +""" + +from __future__ import annotations + +import re +from functools import partial +from pathlib import Path + +from src.automation_executor import ( + CommandRunner, + ExecutionPlan, + ExecutionResult, + default_command_runner, + execute_catalog_seed, + execute_context_pr, +) +from src.automation_proposals import ( + ACTION_CATALOG_SEED, + ACTION_CONTEXT_PR, + AutomationProposal, + executable_proposals, + load_proposals, + mark_executed, + save_proposals, +) +from src.portfolio_context_contract import ( + render_managed_context_block, + upsert_managed_context_block, +) +from src.portfolio_context_recovery import ( + _build_context_sections, + _catalog_repo_key, + _suggested_catalog_seed, +) +from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot + +CONTRACT_VERSION = "automation_workflow_v1" + +# Auto-PR branch defaults. ``default_branch`` is the PR base / the branch the +# executor refuses to commit onto; it is a parameter (not a buried literal) so +# callers can override per-repo, defaulting to the portfolio convention. +DEFAULT_BRANCH_PREFIX = "auto/context-" +DEFAULT_DEFAULT_BRANCH = "main" + +# Collapse any run of characters git/gh would dislike into a single hyphen. +_BRANCH_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") + + +def _branch_slug(project: PortfolioTruthProject) -> str: + """A safe, lowercased branch fragment from the repo slug (never flag-like).""" + full = project.identity.repo_full_name + raw = full.split("/")[-1] if full else project.identity.display_name + slug = _BRANCH_UNSAFE.sub("-", raw).strip("-_.").lower() + return slug or "repo" + + +def _apply_managed_context(project: PortfolioTruthProject, repo_path: Path) -> None: + """Write/refresh the managed context block in the primary context file. + + Mirrors ``apply_context_recovery_plan`` exactly so the auto-PR content stays + byte-identical to the direct-apply recovery path. + """ + target_file = repo_path / project.derived.primary_context_file + existing = target_file.read_text(errors="replace") if target_file.exists() else "" + block = render_managed_context_block(_build_context_sections(project, repo_path)) + target_file.write_text(upsert_managed_context_block(existing, block)) + + +def build_context_pr_plan( + project: PortfolioTruthProject, + *, + workspace_root: Path, + branch_prefix: str = DEFAULT_BRANCH_PREFIX, + default_branch: str = DEFAULT_DEFAULT_BRANCH, +) -> ExecutionPlan: + """Build the ``ExecutionPlan`` for a context-improvement auto-PR. + + The plan's ``apply_change`` regenerates the managed context block from the + project's current repository signals; the executor handles all git/gh + mechanics (and every safety rail) around it. + """ + repo_path = workspace_root / project.identity.path + display = project.identity.display_name + commit_message = f"docs(context): refresh managed context block for {display}" + pr_body = ( + f"Automated managed-context refresh for **{display}**.\n\n" + f"Regenerates the managed context block in " + f"`{project.derived.primary_context_file}` from current repository " + "signals. Generated by the GithubRepoAuditor bounded-automation " + "executor (Arc D) — review before merge." + ) + return ExecutionPlan( + repo_path=repo_path, + default_branch=default_branch, + branch_name=f"{branch_prefix}{_branch_slug(project)}", + commit_message=commit_message, + pr_title=commit_message, + pr_body=pr_body, + apply_change=partial(_apply_managed_context, project), + has_git=project.identity.has_git, + ) + + +def build_catalog_seeds_for( + project: PortfolioTruthProject, +) -> dict[str, dict[str, str]]: + """Build the catalog-seed payload (keyed by catalog repo key) for one project.""" + seed = _suggested_catalog_seed(project) + if not seed: + return {} + return {_catalog_repo_key(project): seed} + + +def _index_projects( + snapshot: PortfolioTruthSnapshot, +) -> tuple[dict[str, PortfolioTruthProject], dict[str, PortfolioTruthProject]]: + """Index projects by GitHub slug and by display name (mirrors risk keying).""" + by_slug: dict[str, PortfolioTruthProject] = {} + by_name: dict[str, PortfolioTruthProject] = {} + for project in snapshot.projects: + if project.identity.repo_full_name: + by_slug[project.identity.repo_full_name] = project + by_name[project.identity.display_name] = project + return by_slug, by_name + + +def _resolve_project( + proposal: AutomationProposal, + by_slug: dict[str, PortfolioTruthProject], + by_name: dict[str, PortfolioTruthProject], +) -> PortfolioTruthProject | None: + if proposal.repo_full_name and proposal.repo_full_name in by_slug: + return by_slug[proposal.repo_full_name] + return by_name.get(proposal.display_name) + + +def execute_approved_proposals( + *, + proposals_path: Path, + snapshot: PortfolioTruthSnapshot, + workspace_root: Path, + catalog_path: Path, + executed_at: str, + dry_run: bool = True, + runner: CommandRunner = default_command_runner, + branch_prefix: str = DEFAULT_BRANCH_PREFIX, + default_branch: str = DEFAULT_DEFAULT_BRANCH, +) -> list[ExecutionResult]: + """Execute every APPROVED proposal in the queue, behind the executor's rails. + + Only proposals an operator has approved are acted on. Each one is routed by + ``action_type`` to the right executor entry point with freshly-derived + content. A real apply (``outcome == "applied"``) transitions the proposal to + EXECUTED and the queue is persisted; dry runs, skips, and failures leave the + queue untouched so the operator can re-run. + """ + proposals = load_proposals(proposals_path) + by_slug, by_name = _index_projects(snapshot) + + working = list(proposals) + results: list[ExecutionResult] = [] + applied_any = False + + for proposal in executable_proposals(proposals): + project = _resolve_project(proposal, by_slug, by_name) + if project is None: + results.append(ExecutionResult(proposal.proposal_id, "skipped", "project-not-found")) + continue + + if proposal.action_type == ACTION_CONTEXT_PR: + # A context-PR needs a real GitHub slug for the head/base refs; we + # never fabricate one from the local display name (decided 2026-06-06). + if not proposal.repo_full_name: + results.append( + ExecutionResult(proposal.proposal_id, "skipped", "no-repo-full-name") + ) + continue + plan = build_context_pr_plan( + project, + workspace_root=workspace_root, + branch_prefix=branch_prefix, + default_branch=default_branch, + ) + result = execute_context_pr(proposal, plan, dry_run=dry_run, runner=runner) + elif proposal.action_type == ACTION_CATALOG_SEED: + result = execute_catalog_seed( + proposal, + catalog_path=catalog_path, + seeds=build_catalog_seeds_for(project), + dry_run=dry_run, + ) + else: + results.append( + ExecutionResult( + proposal.proposal_id, "skipped", f"unknown-action:{proposal.action_type}" + ) + ) + continue + + results.append(result) + if result.outcome == "applied": + working = mark_executed( + working, + proposal.proposal_id, + executed_at=executed_at, + execution_ref=result.reference, + ) + applied_any = True + + if applied_any: + save_proposals(proposals_path, working) + + return results diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py new file mode 100644 index 0000000..fef1ca8 --- /dev/null +++ b/tests/test_automation_workflow.py @@ -0,0 +1,375 @@ +"""Tests for the Arc D phase-3b automation workflow (content-gen + orchestrator). + +This is the wiring layer: it binds the gated executor (phase 3a) to the +recovery content primitives and drives the +load -> filter-approved -> plan -> execute -> mark-executed -> save loop. + +The executor's own safety rails are covered in ``test_automation_executor.py``; +here we verify the *wiring* — that approved proposals get a correct plan/seed, +that only real applies transition + persist, and that the slug / resolution +policies hold. No real git or network is touched: every command goes through an +injected ``FakeRunner``. +""" + +from __future__ import annotations + +from pathlib import Path + +from src.automation_executor import CommandResult +from src.automation_proposals import ( + ACTION_CATALOG_SEED, + ACTION_CONTEXT_PR, + STATUS_APPROVED, + STATUS_EXECUTED, + STATUS_PENDING, + STATUS_REJECTED, + AutomationProposal, + load_proposals, + save_proposals, +) +from src.automation_workflow import ( + build_catalog_seeds_for, + build_context_pr_plan, + execute_approved_proposals, +) +from src.portfolio_context_contract import MANAGED_CONTEXT_START +from src.portfolio_truth_types import ( + DeclaredFields, + DerivedFields, + IdentityFields, + PortfolioTruthProject, + PortfolioTruthSnapshot, +) + +NOW = "2026-04-14T12:00:00+00:00" + + +class FakeRunner: + """Records every command, returns canned results keyed by the first 2 args.""" + + def __init__(self, results: dict[tuple[str, ...], CommandResult] | None = None) -> None: + self.calls: list[tuple[list[str], Path]] = [] + self._results = results or {} + + def __call__(self, args: list[str], cwd: Path) -> CommandResult: + self.calls.append((list(args), cwd)) + return self._results.get(tuple(args[:2]), CommandResult(returncode=0)) + + @property + def command_args(self) -> list[list[str]]: + return [args for args, _ in self.calls] + + +def _project( + *, + display_name: str = "MyRepo", + path: str = "MyRepo", + repo_full_name: str = "owner/MyRepo", + has_git: bool = True, + primary_context_file: str = "AGENTS.md", +) -> PortfolioTruthProject: + return PortfolioTruthProject( + identity=IdentityFields( + project_key=display_name.lower(), + display_name=display_name, + path=path, + top_level_dir=path, + group_key="g", + group_label="G", + section_marker="m", + section_label="M", + has_git=has_git, + repo_full_name=repo_full_name, + ), + declared=DeclaredFields(purpose=f"{display_name} does things."), + derived=DerivedFields( + context_quality="minimum-viable", + primary_context_file=primary_context_file, + activity_status="active", + registry_status="active", + path_confidence="high", + ), + ) + + +def _snapshot(*projects: PortfolioTruthProject) -> PortfolioTruthSnapshot: + from datetime import datetime, timezone + + return PortfolioTruthSnapshot( + schema_version="0.5.0", + generated_at=datetime(2026, 4, 14, tzinfo=timezone.utc), + workspace_root="/ws", + source_summary={}, + precedence_matrix={}, + warnings=[], + projects=list(projects), + ) + + +def _proposal( + *, + action_type: str = ACTION_CONTEXT_PR, + display_name: str = "MyRepo", + repo_full_name: str = "owner/MyRepo", + status: str = STATUS_APPROVED, +) -> AutomationProposal: + return AutomationProposal( + proposal_id=f"{action_type}:{repo_full_name or display_name}", + action_type=action_type, + display_name=display_name, + repo_full_name=repo_full_name, + description="d", + status=status, + created_at=NOW, + ) + + +def _write(proposals_path: Path, *proposals: AutomationProposal) -> None: + save_proposals(proposals_path, proposals) + + +# --- build_context_pr_plan ------------------------------------------------- + + +def test_context_pr_plan_carries_repo_path_and_safe_branch() -> None: + project = _project(path="nested/MyRepo", repo_full_name="owner/My Repo") + plan = build_context_pr_plan(project, workspace_root=Path("/ws")) + + assert plan.repo_path == Path("/ws/nested/MyRepo") + assert plan.default_branch == "main" + assert plan.has_git is True + # Spaced/odd slug is sanitized to a safe, lowercased, non-flag branch name. + assert plan.branch_name == "auto/context-my-repo" + assert not plan.branch_name.startswith("-") + + +def test_context_pr_plan_respects_branch_prefix_and_default_branch() -> None: + plan = build_context_pr_plan( + _project(), + workspace_root=Path("/ws"), + branch_prefix="bot/ctx-", + default_branch="trunk", + ) + assert plan.branch_name == "bot/ctx-myrepo" + assert plan.default_branch == "trunk" + + +def test_context_pr_plan_apply_change_writes_managed_block(tmp_path: Path) -> None: + project = _project(path="MyRepo") + repo_path = tmp_path / "MyRepo" + repo_path.mkdir() + plan = build_context_pr_plan(project, workspace_root=tmp_path) + + plan.apply_change(repo_path) + + written = (repo_path / "AGENTS.md").read_text() + assert MANAGED_CONTEXT_START in written + + +def test_context_pr_plan_apply_change_preserves_existing_text(tmp_path: Path) -> None: + project = _project(path="MyRepo") + repo_path = tmp_path / "MyRepo" + repo_path.mkdir() + (repo_path / "AGENTS.md").write_text("# Hand-written intro\n\nKeep me.\n") + plan = build_context_pr_plan(project, workspace_root=tmp_path) + + plan.apply_change(repo_path) + + written = (repo_path / "AGENTS.md").read_text() + assert "Keep me." in written + assert MANAGED_CONTEXT_START in written + + +# --- build_catalog_seeds_for ----------------------------------------------- + + +def test_catalog_seeds_keyed_by_display_name() -> None: + project = _project(display_name="Signal & Noise", repo_full_name="owner/signal-noise") + seeds = build_catalog_seeds_for(project) + + assert set(seeds) == {"Signal & Noise"} + assert seeds["Signal & Noise"]["owner"] # seed carries the derived owner field + + +# --- execute_approved_proposals: gate + dry-run ---------------------------- + + +def test_execute_ignores_non_approved(tmp_path: Path) -> None: + proposals_path = tmp_path / "pending-proposals.json" + _write( + proposals_path, + _proposal(status=STATUS_PENDING), + _proposal(action_type=ACTION_CATALOG_SEED, status=STATUS_REJECTED), + ) + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(_project()), + workspace_root=tmp_path, + catalog_path=tmp_path / "catalog.yaml", + executed_at=NOW, + ) + assert results == [] + + +def test_execute_dry_run_does_not_mutate_queue(tmp_path: Path) -> None: + proposals_path = tmp_path / "pending-proposals.json" + _write(proposals_path, _proposal()) + (tmp_path / "MyRepo").mkdir() + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(_project()), + workspace_root=tmp_path, + catalog_path=tmp_path / "catalog.yaml", + executed_at=NOW, + runner=FakeRunner(), + ) + + assert [r.outcome for r in results] == ["dry-run"] + # Queue is untouched — proposal stays APPROVED, never transitions. + assert load_proposals(proposals_path)[0].status == STATUS_APPROVED + + +# --- execute_approved_proposals: real context-PR run ----------------------- + + +def test_execute_context_pr_applies_and_persists_executed(tmp_path: Path) -> None: + proposals_path = tmp_path / "pending-proposals.json" + _write(proposals_path, _proposal()) + (tmp_path / "MyRepo").mkdir() + runner = FakeRunner( + {("gh", "pr"): CommandResult(0, "https://github.com/owner/MyRepo/pull/1\n")} + ) + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(_project()), + workspace_root=tmp_path, + catalog_path=tmp_path / "catalog.yaml", + executed_at=NOW, + dry_run=False, + runner=runner, + ) + + assert [r.outcome for r in results] == ["applied"] + assert results[0].reference == "https://github.com/owner/MyRepo/pull/1" + # Managed block actually written on the new branch. + assert MANAGED_CONTEXT_START in (tmp_path / "MyRepo" / "AGENTS.md").read_text() + # Real apply transitions + persists EXECUTED with the PR ref + timestamp. + persisted = load_proposals(proposals_path)[0] + assert persisted.status == STATUS_EXECUTED + assert persisted.executed_at == NOW + assert persisted.execution_ref == "https://github.com/owner/MyRepo/pull/1" + # Never commits onto the default branch — checks out a feature branch first. + assert ["git", "checkout", "-b", "auto/context-myrepo"] in runner.command_args + + +def test_execute_failed_push_keeps_proposal_approved(tmp_path: Path) -> None: + proposals_path = tmp_path / "pending-proposals.json" + _write(proposals_path, _proposal()) + (tmp_path / "MyRepo").mkdir() + runner = FakeRunner({("git", "push"): CommandResult(1, "", "remote rejected")}) + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(_project()), + workspace_root=tmp_path, + catalog_path=tmp_path / "catalog.yaml", + executed_at=NOW, + dry_run=False, + runner=runner, + ) + + assert results[0].outcome == "failed" + # Failed apply never transitions — operator can retry. + assert load_proposals(proposals_path)[0].status == STATUS_APPROVED + + +# --- execute_approved_proposals: catalog-seed real run --------------------- + + +def test_execute_catalog_seed_applies_and_persists_executed(tmp_path: Path) -> None: + proposals_path = tmp_path / "pending-proposals.json" + _write(proposals_path, _proposal(action_type=ACTION_CATALOG_SEED)) + catalog_path = tmp_path / "catalog.yaml" + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(_project()), + workspace_root=tmp_path, + catalog_path=catalog_path, + executed_at=NOW, + dry_run=False, + ) + + assert results[0].outcome == "applied" + assert catalog_path.exists() # seed merged into a real (reversible) catalog file + persisted = load_proposals(proposals_path)[0] + assert persisted.status == STATUS_EXECUTED + assert persisted.execution_ref == str(catalog_path) + + +# --- execute_approved_proposals: resolution + slug policy ------------------ + + +def test_execute_resolves_spaced_repo_by_slug(tmp_path: Path) -> None: + project = _project( + display_name="Signal & Noise", path="Signal & Noise", repo_full_name="owner/signal-noise" + ) + (tmp_path / "Signal & Noise").mkdir() + proposals_path = tmp_path / "pending-proposals.json" + _write( + proposals_path, + _proposal(display_name="Signal & Noise", repo_full_name="owner/signal-noise"), + ) + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(project), + workspace_root=tmp_path, + catalog_path=tmp_path / "catalog.yaml", + executed_at=NOW, + dry_run=False, + runner=FakeRunner(), + ) + + assert results[0].outcome == "applied" + + +def test_execute_skips_context_pr_without_slug(tmp_path: Path) -> None: + project = _project(repo_full_name="") + (tmp_path / "MyRepo").mkdir() + proposals_path = tmp_path / "pending-proposals.json" + _write(proposals_path, _proposal(repo_full_name="")) + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(project), + workspace_root=tmp_path, + catalog_path=tmp_path / "catalog.yaml", + executed_at=NOW, + dry_run=False, + runner=FakeRunner(), + ) + + assert results[0].outcome == "skipped" + assert results[0].detail == "no-repo-full-name" + assert load_proposals(proposals_path)[0].status == STATUS_APPROVED + + +def test_execute_skips_proposal_with_no_matching_project(tmp_path: Path) -> None: + proposals_path = tmp_path / "pending-proposals.json" + _write(proposals_path, _proposal(display_name="Ghost", repo_full_name="owner/ghost")) + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(_project()), # different repo + workspace_root=tmp_path, + catalog_path=tmp_path / "catalog.yaml", + executed_at=NOW, + dry_run=False, + runner=FakeRunner(), + ) + + assert results[0].outcome == "skipped" + assert results[0].detail == "project-not-found" From b3066180283338d40696cdb186826a06cc0829b8 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sat, 6 Jun 2026 03:45:03 -0700 Subject: [PATCH 2/3] feat(automation): triage CLI flags for proposal queue (Arc D phase 3b) Wire the bounded-automation lifecycle into the triage/report CLI: --propose-automation, --list-proposals, --approve-proposal , --reject-proposal , --execute-proposals [--apply] (dry-run unless --apply). - _add_automation_proposal_flags shared helper on both report subparser + legacy parser; mode inference + legacy-argv rewrite updated to route the flags. - _run_automation_proposals_mode handler; execute short-circuits before building the (expensive) portfolio snapshot when nothing is approved. - Output uses 'status: id' form (no [brackets]) so Rich markup never eats it. 5 CLI integration tests (drive main() through list/approve/reject); report flag-count guardrail raised 35->41 for the 6 new flags. --- src/cli.py | 205 +++++++++++++++++++++++++ tests/test_cli_automation_proposals.py | 93 +++++++++++ tests/test_cli_subcommands.py | 4 +- 3 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_automation_proposals.py diff --git a/src/cli.py b/src/cli.py index 92fe1a5..8a0751f 100644 --- a/src/cli.py +++ b/src/cli.py @@ -377,6 +377,49 @@ def _build_triage_subparser(subparsers: argparse._SubParsersAction) -> None: # ) +def _add_automation_proposal_flags(parser: argparse.ArgumentParser) -> None: + """Arc D phase-3b bounded-automation proposal triage flags. + + Added to both the ``report`` subparser and the legacy parser so the queue + can be driven from either invocation form, mirroring the context-recovery + flags. Execution is dry-run unless ``--apply`` is also given. + """ + parser.add_argument( + "--propose-automation", + action="store_true", + help="Generate/refresh bounded-automation proposals for eligible repos", + ) + parser.add_argument( + "--list-proposals", + action="store_true", + help="List the durable bounded-automation proposal queue", + ) + parser.add_argument( + "--approve-proposal", + type=str, + default=None, + metavar="ID", + help="Approve a pending bounded-automation proposal by id", + ) + parser.add_argument( + "--reject-proposal", + type=str, + default=None, + metavar="ID", + help="Reject a pending bounded-automation proposal by id", + ) + parser.add_argument( + "--execute-proposals", + action="store_true", + help="Execute approved bounded-automation proposals (dry-run unless --apply)", + ) + parser.add_argument( + "--apply", + action="store_true", + help="With --execute-proposals: actually apply (open PRs / write catalog seeds)", + ) + + def _build_report_subparser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] """Subcommand: `audit report` — generate exports, packets, and writebacks.""" p = subparsers.add_parser( @@ -409,6 +452,7 @@ def _build_report_subparser(subparsers: argparse._SubParsersAction) -> None: # action="store_true", help="Run context quality triage across the portfolio", ) + _add_automation_proposal_flags(p) p.add_argument( "--excel-mode", choices=["template", "standard"], @@ -660,6 +704,7 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Allow context recovery to apply to repos with uncommitted changes", ) + _add_automation_proposal_flags(parser) parser.add_argument( "--workspace-root", type=Path, @@ -5394,6 +5439,146 @@ def _run_portfolio_context_recovery_mode(args) -> None: print_info(f"Portfolio audit compatibility output: {truth_result.portfolio_report_output}") +def _run_automation_proposals_mode(args) -> None: + """Triage the durable bounded-automation proposal queue (Arc D phase 3b). + + Handles --propose-automation / --list-proposals / --approve-proposal / + --reject-proposal / --execute-proposals. The approval gate and every git/gh + safety rail live in the executor + proposal layers; this is thin dispatch. + """ + from datetime import datetime, timezone + + from src.automation_proposals import ( + ACTION_CATALOG_SEED, + ACTION_CONTEXT_PR, + ProposalApprovalError, + ProposalNotFoundError, + approve_proposal, + build_automation_proposals, + load_proposals, + reject_proposal, + save_proposals, + ) + from src.portfolio_automation import select_automation_candidates + + output_dir = Path(args.output_dir) + proposals_path = output_dir / "pending-proposals.json" + now = datetime.now(timezone.utc).isoformat() + + if getattr(args, "approve_proposal", None) or getattr(args, "reject_proposal", None): + try: + proposals = load_proposals(proposals_path) + if getattr(args, "approve_proposal", None): + updated = approve_proposal( + proposals, + args.approve_proposal, + approved_by="local-operator", + approved_at=now, + ) + label = f"Approved proposal {args.approve_proposal!r}." + else: + updated = reject_proposal(proposals, args.reject_proposal, rejected_at=now) + label = f"Rejected proposal {args.reject_proposal!r}." + except (ProposalNotFoundError, ProposalApprovalError, ValueError) as exc: + print_warning(str(exc)) + return + save_proposals(proposals_path, updated) + print_info(label) + return + + if getattr(args, "list_proposals", False): + proposals = load_proposals(proposals_path) + if not proposals: + print_info("No bounded-automation proposals in the queue.") + return + print_info(f"Bounded-automation proposal queue ({len(proposals)} total):") + for proposal in proposals: + print_info(f" {proposal.status}: {proposal.proposal_id} — {proposal.description}") + return + + if getattr(args, "propose_automation", False): + from src.weekly_command_center import load_latest_portfolio_truth + + _truth_path, truth = load_latest_portfolio_truth(output_dir) + if not truth: + print_warning("No portfolio truth snapshot found. Run --portfolio-truth first.") + return + try: + _report_path, _diff, report = _refresh_latest_report_state(output_dir, args) + decision_quality_status = ( + (report.operator_summary or {}) + .get("decision_quality_v1", {}) + .get("decision_quality_status", "") + ) + except FileNotFoundError: + decision_quality_status = "" + candidates = select_automation_candidates( + truth, decision_quality_status=decision_quality_status + ) + existing = load_proposals(proposals_path) + merged = build_automation_proposals( + candidates, action_type=ACTION_CONTEXT_PR, created_at=now, existing=existing + ) + merged = build_automation_proposals( + candidates, action_type=ACTION_CATALOG_SEED, created_at=now, existing=merged + ) + save_proposals(proposals_path, merged) + print_info( + f"Proposal queue: {len(merged)} total ({len(merged) - len(existing)} new) " + f"from {len(candidates)} eligible candidate(s)." + ) + return + + if getattr(args, "execute_proposals", False): + from src.automation_proposals import executable_proposals + from src.automation_workflow import execute_approved_proposals + from src.portfolio_truth_reconcile import build_portfolio_truth_snapshot + + # Building a fresh portfolio snapshot scans the whole workspace (+ Notion); + # skip that entirely when nothing is approved to execute. + if not executable_proposals(load_proposals(proposals_path)): + print_info("No approved bounded-automation proposals to execute.") + return + + workspace_root = Path(getattr(args, "workspace_root", None) or DEFAULT_PORTFOLIO_WORKSPACE) + catalog_path = ( + Path(args.catalog) + if getattr(args, "catalog", None) + else Path("config/portfolio-catalog.yaml") + ) + registry_output = ( + Path(args.registry_output) + if getattr(args, "registry_output", None) + else workspace_root / "project-registry.md" + ) + legacy_registry_path = ( + Path(args.registry) if getattr(args, "registry", None) else registry_output + ) + build_result = build_portfolio_truth_snapshot( + workspace_root=workspace_root, + catalog_path=catalog_path if catalog_path.exists() else None, + legacy_registry_path=legacy_registry_path, + include_notion=True, + ) + apply = bool(getattr(args, "apply", False)) + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=build_result.snapshot, + workspace_root=workspace_root, + catalog_path=catalog_path, + executed_at=now, + dry_run=not apply, + ) + if not results: + print_info("No approved bounded-automation proposals to execute.") + return + for result in results: + print_info(f" {result.outcome}: {result.proposal_id} — {result.detail}") + mode = "apply" if apply else "dry-run" + print_info(f"Execute proposals ({mode}): {len(results)} approved proposal(s) processed.") + return + + def _run_tier_recalibration_report_mode(args) -> None: """Generate tier distribution report and flag bunching (Arc H A4).""" from datetime import date, datetime, timezone @@ -6615,6 +6800,9 @@ def _infer_subcommand_from_flags(args: argparse.Namespace) -> str: "tier_gaps", "tier_recalibration_report", "context_triage", + "propose_automation", + "list_proposals", + "execute_proposals", ) for flag in report_flags: if getattr(args, flag, False): @@ -6623,6 +6811,8 @@ def _infer_subcommand_from_flags(args: argparse.Namespace) -> str: return "report" if getattr(args, "writeback_target", None): return "report" + if getattr(args, "approve_proposal", None) or getattr(args, "reject_proposal", None): + return "report" return "run" @@ -6711,10 +6901,15 @@ def _rewrite_legacy_argv(argv: list[str]) -> tuple[list[str], bool]: "--apply-readmes", "--upload-badges", "--notion-sync", + "--propose-automation", + "--list-proposals", + "--execute-proposals", ] ) or "--campaign" in rest or "--writeback-target" in rest + or "--approve-proposal" in rest + or "--reject-proposal" in rest ): inferred = "report" else: @@ -6899,6 +7094,16 @@ def main() -> None: _run_portfolio_context_recovery_mode(args) return + if ( + getattr(args, "propose_automation", False) + or getattr(args, "list_proposals", False) + or getattr(args, "execute_proposals", False) + or getattr(args, "approve_proposal", None) + or getattr(args, "reject_proposal", None) + ): + _run_automation_proposals_mode(args) + return + if args.doctor: _run_doctor_mode(args, config_inspection) return diff --git a/tests/test_cli_automation_proposals.py b/tests/test_cli_automation_proposals.py new file mode 100644 index 0000000..55af871 --- /dev/null +++ b/tests/test_cli_automation_proposals.py @@ -0,0 +1,93 @@ +"""CLI integration for the Arc D phase-3b bounded-automation proposal flags. + +Drives ``main()`` end-to-end (legacy flat form) for the queue-only triage flags +— list / approve / reject — proving arg parsing, subcommand inference, dispatch, +and the handler all wire together. The execute orchestration itself is covered +by ``test_automation_workflow.py`` (snapshot + runner injected); here we only +exercise the CLI seams that don't require building a real portfolio snapshot. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from src.automation_proposals import ( + ACTION_CONTEXT_PR, + STATUS_APPROVED, + STATUS_PENDING, + STATUS_REJECTED, + AutomationProposal, + load_proposals, + save_proposals, +) + +NOW = "2026-04-14T12:00:00+00:00" + + +def _seed(output_dir: Path, status: str = STATUS_PENDING) -> Path: + proposals_path = output_dir / "pending-proposals.json" + save_proposals( + proposals_path, + [ + AutomationProposal( + proposal_id="context-pr:owner/MyRepo", + action_type=ACTION_CONTEXT_PR, + display_name="MyRepo", + repo_full_name="owner/MyRepo", + description="Open an auto-PR improving the managed context block for MyRepo.", + status=status, + created_at=NOW, + ) + ], + ) + return proposals_path + + +def _run(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *flags: str) -> None: + from src.cli import main + + monkeypatch.setattr(sys, "argv", ["audit", "--output-dir", str(tmp_path), "user", *flags]) + main() + + +def test_list_proposals_empty(monkeypatch, tmp_path, capsys) -> None: + _run(monkeypatch, tmp_path, "--list-proposals") + assert "No bounded-automation proposals in the queue." in capsys.readouterr().err + + +def test_list_proposals_populated(monkeypatch, tmp_path, capsys) -> None: + _seed(tmp_path) + _run(monkeypatch, tmp_path, "--list-proposals") + out = capsys.readouterr().err + assert "context-pr:owner/MyRepo" in out + assert "pending" in out + + +def test_approve_proposal_transitions_and_persists(monkeypatch, tmp_path, capsys) -> None: + proposals_path = _seed(tmp_path) + _run(monkeypatch, tmp_path, "--approve-proposal", "context-pr:owner/MyRepo") + + assert "Approved proposal" in capsys.readouterr().err + persisted = load_proposals(proposals_path)[0] + assert persisted.status == STATUS_APPROVED + assert persisted.approved_by == "local-operator" + + +def test_reject_proposal_transitions_and_persists(monkeypatch, tmp_path, capsys) -> None: + proposals_path = _seed(tmp_path) + _run(monkeypatch, tmp_path, "--reject-proposal", "context-pr:owner/MyRepo") + + assert "Rejected proposal" in capsys.readouterr().err + assert load_proposals(proposals_path)[0].status == STATUS_REJECTED + + +def test_approve_unknown_proposal_warns_and_leaves_queue(monkeypatch, tmp_path, capsys) -> None: + proposals_path = _seed(tmp_path) + _run(monkeypatch, tmp_path, "--approve-proposal", "context-pr:owner/Ghost") + + # Unknown id is surfaced, not a crash; the queue is untouched. + capsys.readouterr() + assert load_proposals(proposals_path)[0].status == STATUS_PENDING diff --git a/tests/test_cli_subcommands.py b/tests/test_cli_subcommands.py index f6f3df5..f15767c 100644 --- a/tests/test_cli_subcommands.py +++ b/tests/test_cli_subcommands.py @@ -305,8 +305,8 @@ def test_triage_help_flag_count(self): def test_report_help_flag_count(self): text = _help_text("report") count = _count_flags_in_help(text) - assert count <= 35, ( - f"audit report --help shows {count} non-global flags (limit 35, raised in Arc H for --tier-recalibration-report/--context-triage).\n" + assert count <= 41, ( + f"audit report --help shows {count} non-global flags (limit 41, raised in Arc D phase-3b for the 6 bounded-automation proposal flags: --propose-automation/--list-proposals/--approve-proposal/--reject-proposal/--execute-proposals/--apply).\n" f"Flags found: {sorted(set(re.findall(r' (--[a-z][a-z0-9-]*)', text)))}" ) From 04dc31bf982a690964c584e000df67a7d68ff63b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sat, 6 Jun 2026 03:56:46 -0700 Subject: [PATCH 3/3] refactor(automation): code-review fixes for phase 3b - execute_approved_proposals: isolate each proposal's execution (extract _dispatch_proposal); an AutomationExecutionError or catalog OSError now becomes a 'failed' result for that proposal instead of aborting the whole batch and silently dropping the rest. New test pins the guarantee. - Extract write_managed_context_block in portfolio_context_recovery and reuse it from both apply_context_recovery_plan and the context-PR plan, so the two render paths share one source of truth (kills the byte-identical-by-hope dup). - Derive the save condition from results instead of a parallel applied_any flag. --- src/automation_workflow.py | 110 +++++++++++++++++------------- src/portfolio_context_recovery.py | 20 ++++-- tests/test_automation_workflow.py | 30 ++++++++ 3 files changed, 105 insertions(+), 55 deletions(-) diff --git a/src/automation_workflow.py b/src/automation_workflow.py index d44f282..6feed06 100644 --- a/src/automation_workflow.py +++ b/src/automation_workflow.py @@ -26,6 +26,7 @@ from pathlib import Path from src.automation_executor import ( + AutomationExecutionError, CommandRunner, ExecutionPlan, ExecutionResult, @@ -42,14 +43,10 @@ mark_executed, save_proposals, ) -from src.portfolio_context_contract import ( - render_managed_context_block, - upsert_managed_context_block, -) from src.portfolio_context_recovery import ( - _build_context_sections, _catalog_repo_key, _suggested_catalog_seed, + write_managed_context_block, ) from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot @@ -73,18 +70,6 @@ def _branch_slug(project: PortfolioTruthProject) -> str: return slug or "repo" -def _apply_managed_context(project: PortfolioTruthProject, repo_path: Path) -> None: - """Write/refresh the managed context block in the primary context file. - - Mirrors ``apply_context_recovery_plan`` exactly so the auto-PR content stays - byte-identical to the direct-apply recovery path. - """ - target_file = repo_path / project.derived.primary_context_file - existing = target_file.read_text(errors="replace") if target_file.exists() else "" - block = render_managed_context_block(_build_context_sections(project, repo_path)) - target_file.write_text(upsert_managed_context_block(existing, block)) - - def build_context_pr_plan( project: PortfolioTruthProject, *, @@ -115,7 +100,7 @@ def build_context_pr_plan( commit_message=commit_message, pr_title=commit_message, pr_body=pr_body, - apply_change=partial(_apply_managed_context, project), + apply_change=partial(write_managed_context_block, project), has_git=project.identity.has_git, ) @@ -178,43 +163,25 @@ def execute_approved_proposals( working = list(proposals) results: list[ExecutionResult] = [] - applied_any = False for proposal in executable_proposals(proposals): project = _resolve_project(proposal, by_slug, by_name) - if project is None: - results.append(ExecutionResult(proposal.proposal_id, "skipped", "project-not-found")) - continue - - if proposal.action_type == ACTION_CONTEXT_PR: - # A context-PR needs a real GitHub slug for the head/base refs; we - # never fabricate one from the local display name (decided 2026-06-06). - if not proposal.repo_full_name: - results.append( - ExecutionResult(proposal.proposal_id, "skipped", "no-repo-full-name") - ) - continue - plan = build_context_pr_plan( + # One proposal's failure must never abandon the rest of the batch: a + # safety-rail violation or a catalog I/O error becomes a `failed` result + # for that proposal, and the loop continues. + try: + result = _dispatch_proposal( + proposal, project, workspace_root=workspace_root, - branch_prefix=branch_prefix, - default_branch=default_branch, - ) - result = execute_context_pr(proposal, plan, dry_run=dry_run, runner=runner) - elif proposal.action_type == ACTION_CATALOG_SEED: - result = execute_catalog_seed( - proposal, catalog_path=catalog_path, - seeds=build_catalog_seeds_for(project), dry_run=dry_run, + runner=runner, + branch_prefix=branch_prefix, + default_branch=default_branch, ) - else: - results.append( - ExecutionResult( - proposal.proposal_id, "skipped", f"unknown-action:{proposal.action_type}" - ) - ) - continue + except (AutomationExecutionError, OSError) as error: + result = ExecutionResult(proposal.proposal_id, "failed", str(error).strip()) results.append(result) if result.outcome == "applied": @@ -224,9 +191,54 @@ def execute_approved_proposals( executed_at=executed_at, execution_ref=result.reference, ) - applied_any = True - if applied_any: + if any(result.outcome == "applied" for result in results): save_proposals(proposals_path, working) return results + + +def _dispatch_proposal( + proposal: AutomationProposal, + project: PortfolioTruthProject | None, + *, + workspace_root: Path, + catalog_path: Path, + dry_run: bool, + runner: CommandRunner, + branch_prefix: str, + default_branch: str, +) -> ExecutionResult: + """Route one APPROVED proposal to the right executor entry point. + + Returns a ``skipped`` result for the policy cases (no matching project, a + context-PR without a GitHub slug, an unknown action); otherwise it builds + fresh content and calls the executor, whose own result/exception it surfaces. + """ + if project is None: + return ExecutionResult(proposal.proposal_id, "skipped", "project-not-found") + + if proposal.action_type == ACTION_CONTEXT_PR: + # A context-PR needs a real GitHub slug for the head/base refs; we never + # fabricate one from the local display name (decided 2026-06-06). + if not proposal.repo_full_name: + return ExecutionResult(proposal.proposal_id, "skipped", "no-repo-full-name") + plan = build_context_pr_plan( + project, + workspace_root=workspace_root, + branch_prefix=branch_prefix, + default_branch=default_branch, + ) + return execute_context_pr(proposal, plan, dry_run=dry_run, runner=runner) + + if proposal.action_type == ACTION_CATALOG_SEED: + return execute_catalog_seed( + proposal, + catalog_path=catalog_path, + seeds=build_catalog_seeds_for(project), + dry_run=dry_run, + ) + + return ExecutionResult( + proposal.proposal_id, "skipped", f"unknown-action:{proposal.action_type}" + ) diff --git a/src/portfolio_context_recovery.py b/src/portfolio_context_recovery.py index e185dd0..0af7c3c 100644 --- a/src/portfolio_context_recovery.py +++ b/src/portfolio_context_recovery.py @@ -138,6 +138,19 @@ def write_context_recovery_plan_artifacts( return json_path, markdown_path +def write_managed_context_block(project: PortfolioTruthProject, project_path: Path) -> None: + """Refresh the managed context block in a project's primary context file. + + The single source of truth for the read -> render -> upsert -> write sequence, + shared by ``apply_context_recovery_plan`` (direct apply) and the Arc D + automation executor's context-PR path so the two render byte-identical blocks. + """ + target_file = project_path / project.derived.primary_context_file + existing_text = target_file.read_text(errors="replace") if target_file.exists() else "" + managed_block = render_managed_context_block(_build_context_sections(project, project_path)) + target_file.write_text(upsert_managed_context_block(existing_text, managed_block)) + + def apply_context_recovery_plan( snapshot: PortfolioTruthSnapshot, plan: ContextRecoveryPlan, @@ -159,13 +172,8 @@ def apply_context_recovery_plan( for target in eligible_targets: project = project_index[target.project_key] project_path = workspace_root / project.identity.path - target_file = project_path / target.primary_context_file try: - existing_text = target_file.read_text(errors="replace") if target_file.exists() else "" - managed_block = render_managed_context_block( - _build_context_sections(project, project_path) - ) - target_file.write_text(upsert_managed_context_block(existing_text, managed_block)) + write_managed_context_block(project, project_path) updated.append(target.project_key) if target.suggested_catalog_seed: catalog_seeds[_catalog_repo_key(project)] = target.suggested_catalog_seed diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py index fef1ca8..c54be49 100644 --- a/tests/test_automation_workflow.py +++ b/tests/test_automation_workflow.py @@ -373,3 +373,33 @@ def test_execute_skips_proposal_with_no_matching_project(tmp_path: Path) -> None assert results[0].outcome == "skipped" assert results[0].detail == "project-not-found" + + +def test_execute_isolates_one_failure_from_the_rest_of_the_batch(tmp_path: Path) -> None: + # A catalog-seed pointed at a path whose parent is missing raises OSError + # inside the merge; that must become a `failed` result for THAT proposal and + # leave the following context-PR proposal free to apply. + catalog = _project(display_name="Cat", path="Cat", repo_full_name="owner/cat") + pr = _project(display_name="MyRepo", path="MyRepo", repo_full_name="owner/MyRepo") + (tmp_path / "MyRepo").mkdir() + proposals_path = tmp_path / "pending-proposals.json" + _write( + proposals_path, + _proposal(action_type=ACTION_CATALOG_SEED, display_name="Cat", repo_full_name="owner/cat"), + _proposal(), # context-pr for MyRepo + ) + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(catalog, pr), + workspace_root=tmp_path, + catalog_path=tmp_path / "missing-dir" / "catalog.yaml", # parent absent -> OSError + executed_at=NOW, + dry_run=False, + runner=FakeRunner(), + ) + + assert [r.outcome for r in results] == ["failed", "applied"] + by_id = {p.proposal_id: p for p in load_proposals(proposals_path)} + assert by_id["catalog-seed:owner/cat"].status == STATUS_APPROVED # failed -> retryable + assert by_id["context-pr:owner/MyRepo"].status == STATUS_EXECUTED