phase-loop-runtime 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phase_loop_runtime/__init__.py +20 -0
- phase_loop_runtime/_contract_docs/phase-loop/extraction-readiness.md +90 -0
- phase_loop_runtime/_contract_docs/phase-loop/granular-execution-policy.md +137 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-capability-matrix.md +510 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-skill-matrix.md +125 -0
- phase_loop_runtime/_contract_docs/phase-loop/harness-substrate-manifest.md +232 -0
- phase_loop_runtime/_contract_docs/phase-loop/pi-loop-control.md +247 -0
- phase_loop_runtime/_contract_docs/phase-loop/protocol.md +2759 -0
- phase_loop_runtime/_contract_docs/phase-loop/runtime-boundary.md +289 -0
- phase_loop_runtime/_contract_docs/phase-loop/spec-discovery-roots.md +47 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-governed-pipeline.md +42 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-portal-projection.md +34 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-regenesis.md +29 -0
- phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-report.md +32 -0
- phase_loop_runtime/_contract_docs/runtime/verification-evidence-contract.md +52 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/commands.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/mixed.json +5 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/timeout-note.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/redacted-summary.json +6 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/sample.json +7 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/edge.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-a.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-b.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-c.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/claimed-results.json +5 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/metrics.json +9 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/findings.md +10 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/scores.json +4 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/summary.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/bundle-check.json +8 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/operator-note.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/events-sample.jsonl +3 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/receipt.txt +1 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/evidence.json +13 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/summary.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/matrix.json +9 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/notes.md +4 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/console-summary.txt +2 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/manifest.json +16 -0
- phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/screenshot-standin.ppm +5 -0
- phase_loop_runtime/adoption_bundle.py +273 -0
- phase_loop_runtime/advisor_board/CONTRACTS.md +257 -0
- phase_loop_runtime/advisor_board/__init__.py +288 -0
- phase_loop_runtime/advisor_board/backing.py +137 -0
- phase_loop_runtime/advisor_board/backing_omnigent.py +498 -0
- phase_loop_runtime/advisor_board/config.py +225 -0
- phase_loop_runtime/advisor_board/events.py +115 -0
- phase_loop_runtime/advisor_board/fixtures/advisor-boards.example.toml +59 -0
- phase_loop_runtime/advisor_board/fixtures.py +91 -0
- phase_loop_runtime/advisor_board/harness_mapping.py +125 -0
- phase_loop_runtime/advisor_board/matrix.py +146 -0
- phase_loop_runtime/advisor_board/observability.py +481 -0
- phase_loop_runtime/advisor_board/presets.py +188 -0
- phase_loop_runtime/advisor_board/registries.py +309 -0
- phase_loop_runtime/advisor_board/resolver.py +253 -0
- phase_loop_runtime/advisor_board/schema.py +269 -0
- phase_loop_runtime/advisor_board/standin.py +166 -0
- phase_loop_runtime/advisor_board/validation.py +159 -0
- phase_loop_runtime/agent_runtime_provider.py +676 -0
- phase_loop_runtime/baml_modular.py +656 -0
- phase_loop_runtime/baml_src/dotfiles_adoption_manifest.baml +37 -0
- phase_loop_runtime/baml_src/dotfiles_c4_document.baml +14 -0
- phase_loop_runtime/baml_src/dotfiles_plan_manifest.baml +50 -0
- phase_loop_runtime/baml_src/dotfiles_runtime_projection.baml +16 -0
- phase_loop_runtime/baml_src/dotfiles_task_catalog.baml +20 -0
- phase_loop_runtime/baml_src/emit_phase_closeout.baml +86 -0
- phase_loop_runtime/baml_src/evaluate_suspected_fake_evidence.baml +36 -0
- phase_loop_runtime/baml_src/verification_evidence.baml +40 -0
- phase_loop_runtime/broker.py +181 -0
- phase_loop_runtime/build_bundle.py +294 -0
- phase_loop_runtime/capability_registry.py +835 -0
- phase_loop_runtime/classifier.py +87 -0
- phase_loop_runtime/claude_agent_view.py +625 -0
- phase_loop_runtime/claude_channel_sidecar.py +1086 -0
- phase_loop_runtime/cli.py +2599 -0
- phase_loop_runtime/closeout.py +865 -0
- phase_loop_runtime/closeout_classifier.py +120 -0
- phase_loop_runtime/closeout_evidence_audit.py +112 -0
- phase_loop_runtime/closeout_validation.py +144 -0
- phase_loop_runtime/closeout_validators.py +203 -0
- phase_loop_runtime/consiliency_gates.py +374 -0
- phase_loop_runtime/consiliency_ingest.py +399 -0
- phase_loop_runtime/consiliency_layout.py +176 -0
- phase_loop_runtime/consiliency_scaffold.py +305 -0
- phase_loop_runtime/cross_repo_channel.py +331 -0
- phase_loop_runtime/discovery.py +2368 -0
- phase_loop_runtime/dispatch_lock.py +167 -0
- phase_loop_runtime/doc_delta_validator.py +98 -0
- phase_loop_runtime/docs_audit.py +285 -0
- phase_loop_runtime/docs_freshness.py +468 -0
- phase_loop_runtime/docs_surfaces.py +150 -0
- phase_loop_runtime/dotfiles_profile_plugin.py +96 -0
- phase_loop_runtime/events.py +154 -0
- phase_loop_runtime/events_migration.py +96 -0
- phase_loop_runtime/evidence_audit.py +1082 -0
- phase_loop_runtime/evidence_audit_config.py +172 -0
- phase_loop_runtime/fleet_map.py +426 -0
- phase_loop_runtime/fleet_metrics.py +298 -0
- phase_loop_runtime/fleet_metrics_export.py +196 -0
- phase_loop_runtime/git_discipline.py +479 -0
- phase_loop_runtime/git_ops.py +122 -0
- phase_loop_runtime/git_topology.py +252 -0
- phase_loop_runtime/governed_bundle.py +137 -0
- phase_loop_runtime/governed_premerge.py +219 -0
- phase_loop_runtime/governed_review.py +264 -0
- phase_loop_runtime/handoff.py +932 -0
- phase_loop_runtime/handoff_path.py +14 -0
- phase_loop_runtime/injection.py +740 -0
- phase_loop_runtime/install_status.py +88 -0
- phase_loop_runtime/lane_scheduler.py +317 -0
- phase_loop_runtime/launcher.py +2760 -0
- phase_loop_runtime/lease_store.py +553 -0
- phase_loop_runtime/maintenance.py +401 -0
- phase_loop_runtime/migrate_handoffs.py +201 -0
- phase_loop_runtime/models.py +2144 -0
- phase_loop_runtime/observability.py +1290 -0
- phase_loop_runtime/panel_invoker.py +1638 -0
- phase_loop_runtime/phase_loop_drift_audit.py +388 -0
- phase_loop_runtime/phase_worktree_executor.py +433 -0
- phase_loop_runtime/pipeline_adapter/__init__.py +12 -0
- phase_loop_runtime/pipeline_adapter/branch_ops.py +274 -0
- phase_loop_runtime/pipeline_adapter/flag.py +55 -0
- phase_loop_runtime/pipeline_adapter/markers.py +13 -0
- phase_loop_runtime/pipeline_adapter/merge_policy.py +72 -0
- phase_loop_runtime/pipeline_adapter/ratification.py +100 -0
- phase_loop_runtime/pipeline_adapter/sibling_matcher.py +123 -0
- phase_loop_runtime/plan_ir.py +523 -0
- phase_loop_runtime/plan_manifest.py +415 -0
- phase_loop_runtime/planner_validation.py +176 -0
- phase_loop_runtime/profiles.py +462 -0
- phase_loop_runtime/prompts.py +365 -0
- phase_loop_runtime/provenance.py +165 -0
- phase_loop_runtime/publishing.py +339 -0
- phase_loop_runtime/py.typed +1 -0
- phase_loop_runtime/reconcile.py +1550 -0
- phase_loop_runtime/redaction.py +184 -0
- phase_loop_runtime/reflection_sync.py +293 -0
- phase_loop_runtime/release_guard.py +186 -0
- phase_loop_runtime/render.py +432 -0
- phase_loop_runtime/repo_validation.py +427 -0
- phase_loop_runtime/review_summary.py +150 -0
- phase_loop_runtime/roadmap_lint.py +425 -0
- phase_loop_runtime/route_log.py +59 -0
- phase_loop_runtime/route_policy/__init__.py +11 -0
- phase_loop_runtime/route_policy/fixtures/route_selection.golden.json +263 -0
- phase_loop_runtime/runner.py +8400 -0
- phase_loop_runtime/runtime_paths.py +225 -0
- phase_loop_runtime/runtime_projection.py +113 -0
- phase_loop_runtime/runtime_resources.py +36 -0
- phase_loop_runtime/schema_export.py +292 -0
- phase_loop_runtime/schemas/phase_loop_closeout.schema.json +366 -0
- phase_loop_runtime/skill_install.py +176 -0
- phase_loop_runtime/skill_inventory.py +662 -0
- phase_loop_runtime/skill_paths.py +42 -0
- phase_loop_runtime/skill_sources_plugin.py +37 -0
- phase_loop_runtime/skills_bundle/claude-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/claude-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/claude-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-execute-detailed/SKILL.md +152 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/SKILL.md +745 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/claude-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-phase-loop/SKILL.md +115 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/SKILL.md +439 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/SKILL.md +334 -0
- phase_loop_runtime/skills_bundle/claude-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/SKILL.md +776 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/claude-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/claude-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/SKILL.md +243 -0
- phase_loop_runtime/skills_bundle/claude-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/SKILL.md +249 -0
- phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/SKILL.md +74 -0
- phase_loop_runtime/skills_bundle/claude-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/codex-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/codex-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/codex-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-execute-detailed/SKILL.md +154 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/SKILL.md +231 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/codex-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-phase-loop/SKILL.md +248 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/SKILL.md +150 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/codex-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/SKILL.md +307 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/codex-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/codex-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/SKILL.md +94 -0
- phase_loop_runtime/skills_bundle/codex-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/SKILL.md +114 -0
- phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/SKILL.md +102 -0
- phase_loop_runtime/skills_bundle/codex-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/gemini-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-execute-detailed/SKILL.md +144 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/SKILL.md +198 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/gemini-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-phase-loop/SKILL.md +111 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/SKILL.md +147 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/gemini-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/SKILL.md +217 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/gemini-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/gemini-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/SKILL.md +87 -0
- phase_loop_runtime/skills_bundle/gemini-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/SKILL.md +109 -0
- phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/SKILL.md +98 -0
- phase_loop_runtime/skills_bundle/gemini-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-board/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-board/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-panel/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-advisor-panel/SKILL.md +38 -0
- phase_loop_runtime/skills_bundle/opencode-execute-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-execute-detailed/SKILL.md +144 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/SKILL.md +196 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/allocate_worktree_name.sh +33 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/audit_lane_file_touches.py +197 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/state.py +274 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/team_teardown.sh +32 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/validate_plan_doc.py +642 -0
- phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/verify_harness.sh +96 -0
- phase_loop_runtime/skills_bundle/opencode-phase-loop/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-phase-loop/SKILL.md +113 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/SKILL.md +147 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/assets/review_prompt.md +19 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/roadmap-template.md +193 -0
- phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/SKILL.md +119 -0
- phase_loop_runtime/skills_bundle/opencode-plan-detailed/assets/review_prompt.md +25 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/SKILL.md +211 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/assets/review_prompt.md +17 -0
- phase_loop_runtime/skills_bundle/opencode-plan-phase/scripts/validate_plan_doc.py +1092 -0
- phase_loop_runtime/skills_bundle/opencode-run-train/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-run-train/SKILL.md +75 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/SKILL.md +87 -0
- phase_loop_runtime/skills_bundle/opencode-skill-editor/assets/editor_prompt.md +72 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/SKILL.md +109 -0
- phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/README.md +1 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/SKILL.md +98 -0
- phase_loop_runtime/skills_bundle/opencode-task-contextualizer/references/task-templates.md +103 -0
- phase_loop_runtime/state.py +156 -0
- phase_loop_runtime/state_degradation.py +136 -0
- phase_loop_runtime/state_ops.py +676 -0
- phase_loop_runtime/train_ledger.py +215 -0
- phase_loop_runtime/train_roadmap.py +339 -0
- phase_loop_runtime/train_runner.py +1197 -0
- phase_loop_runtime/verification_evidence.py +650 -0
- phase_loop_runtime/verification_evidence_validator.py +75 -0
- phase_loop_runtime/visual_evidence_validator.py +36 -0
- phase_loop_runtime/worker_pool.py +123 -0
- phase_loop_runtime/worktree_index.py +270 -0
- phase_loop_runtime-0.2.0.data/data/share/phase-loop-runtime/protocol/protocol.md +2475 -0
- phase_loop_runtime-0.2.0.dist-info/METADATA +106 -0
- phase_loop_runtime-0.2.0.dist-info/RECORD +370 -0
- phase_loop_runtime-0.2.0.dist-info/WHEEL +5 -0
- phase_loop_runtime-0.2.0.dist-info/entry_points.txt +9 -0
- phase_loop_runtime-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2599 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger("phase_loop_runtime.cli")
|
|
13
|
+
|
|
14
|
+
from .closeout import build_phase_loop_closeout
|
|
15
|
+
from .consiliency_ingest import ingest
|
|
16
|
+
from .consiliency_layout import ARCHETYPE_IDS, MODIFIER_IDS
|
|
17
|
+
from .consiliency_scaffold import ScaffoldError, scaffold
|
|
18
|
+
from .docs_freshness import scan_docs_freshness
|
|
19
|
+
from .discovery import find_plan_artifact, phase_source_bundle_diagnostic, resolve_repo, resolve_suite_command, select_roadmap
|
|
20
|
+
from .events import append_event, read_events
|
|
21
|
+
from .git_topology import collect_git_topology
|
|
22
|
+
from .handoff import handoff_metadata, write_tui_handoff
|
|
23
|
+
from .install_status import build_install_status
|
|
24
|
+
from .models import CLAUDE_EXECUTION_MODES, CLOSEOUT_MODES, EXECUTORS, LANE_IR_DIAGNOSTIC_KINDS, LANE_SCHEDULER_MODES, LoopEvent, PHASE_SCHEDULER_MODES, PipelinePlanMetadata, StateSnapshot, utc_now
|
|
25
|
+
from .events_migration import MigrationError, migrate_ledger
|
|
26
|
+
from .migrate_handoffs import migrate_handoffs, records_to_json
|
|
27
|
+
from .observability import append_work_unit_metric, build_notification_payload, build_terminal_summary, build_work_unit_metric, hotfix_run_artifacts, run_notification_command
|
|
28
|
+
from .pipeline_adapter.flag import allow_lane_ir_override_enabled, dispatch_lock_enabled, parallel_dispatch_enabled
|
|
29
|
+
from .profiles import DEFAULT_PROFILES
|
|
30
|
+
from .provenance import ValidationFinding, event_provenance, snapshot_provenance, validate_roadmap_phase_headings
|
|
31
|
+
from .reconcile import reconcile
|
|
32
|
+
from . import repo_validation
|
|
33
|
+
from .render import render_archive_result, render_skill_sync_result, render_state_inspection, render_status
|
|
34
|
+
from .runner import run_loop, status_snapshot
|
|
35
|
+
from .skill_install import actions_to_json, install_skills
|
|
36
|
+
# DECOUPLE SL-1: the dotfiles-domain modules (adoption_bundle, build_bundle,
|
|
37
|
+
# maintenance) and runtime_projection are NOT imported at module level. The
|
|
38
|
+
# generic CLI registers no dotfiles-domain command at import; those commands are
|
|
39
|
+
# registered by the dotfiles-profile plugin (loaded via the
|
|
40
|
+
# phase_loop_runtime.profile_commands entry-point group or the
|
|
41
|
+
# PHASE_LOOP_PROFILE_PLUGINS opt-in), and the handlers that remain in this module
|
|
42
|
+
# import their dotfiles dependencies lazily.
|
|
43
|
+
from .state import write_state
|
|
44
|
+
from .state_degradation import clear as clear_degradation
|
|
45
|
+
from .state_ops import archive_state, inspect_state
|
|
46
|
+
from .verification_evidence import ARTIFACT_NAME, LOG_NAME, detect_changed_dependency_manifests, resolve_install_command, run_verification, validate_verification_artifact
|
|
47
|
+
from . import __version__
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _add_common_subparser_args(sub: argparse.ArgumentParser, *, name: str) -> None:
|
|
51
|
+
"""Add the shared per-subcommand arguments.
|
|
52
|
+
|
|
53
|
+
Factored out of build_parser() so the dotfiles-profile plugin (DECOUPLE SL-1)
|
|
54
|
+
can attach the identical common args to the commands it registers.
|
|
55
|
+
"""
|
|
56
|
+
if name in {"closeout-drift-audit", "fleet-map"}:
|
|
57
|
+
sub.add_argument("--repo", action="append", help="Repo to audit. Repeat for cross-repo aggregation.")
|
|
58
|
+
else:
|
|
59
|
+
sub.add_argument("--repo")
|
|
60
|
+
sub.add_argument("--roadmap")
|
|
61
|
+
sub.add_argument("--phase")
|
|
62
|
+
sub.add_argument(
|
|
63
|
+
"--max-phases",
|
|
64
|
+
type=int,
|
|
65
|
+
help="Maximum dispatched actions by default; combine with --full-phase to count complete phase cycles.",
|
|
66
|
+
)
|
|
67
|
+
sub.add_argument("--model-profile", choices=tuple(DEFAULT_PROFILES))
|
|
68
|
+
sub.add_argument("--model")
|
|
69
|
+
sub.add_argument("--effort")
|
|
70
|
+
sub.add_argument("--executor", choices=EXECUTORS)
|
|
71
|
+
sub.add_argument("--command-name")
|
|
72
|
+
sub.add_argument("--command-template")
|
|
73
|
+
sub.add_argument("--claude-execution-mode", choices=CLAUDE_EXECUTION_MODES)
|
|
74
|
+
sub.add_argument("--allow-executor", action="append", default=[])
|
|
75
|
+
sub.add_argument("--fallback-executor", action="append", default=[])
|
|
76
|
+
sub.add_argument("--disable-executor", action="append", default=[])
|
|
77
|
+
sub.add_argument("--require-capability", action="append", default=[])
|
|
78
|
+
sub.add_argument("--json", action="store_true")
|
|
79
|
+
sub.add_argument("--dry-run", action="store_true")
|
|
80
|
+
sub.add_argument("--observe", action="store_true", help="Accepted for compatibility; launch artifacts are written by default.")
|
|
81
|
+
sub.add_argument("--no-observe", action="store_true", help="Disable launch log and heartbeat artifacts.")
|
|
82
|
+
sub.add_argument("--stream-output", action="store_true")
|
|
83
|
+
sub.add_argument("--bypass-approvals", action="store_true")
|
|
84
|
+
sub.add_argument("--heartbeat-interval-seconds", type=int)
|
|
85
|
+
sub.add_argument("--quiet-warning-seconds", type=int)
|
|
86
|
+
sub.add_argument("--quiet-blocker-seconds", type=int)
|
|
87
|
+
sub.add_argument("--no-heartbeat", action="store_true")
|
|
88
|
+
sub.add_argument("--work-unit-mode", action="store_true")
|
|
89
|
+
sub.add_argument("--source-bundle")
|
|
90
|
+
sub.add_argument("--pipeline-mode", choices=("standalone", "pipeline_optional", "pipeline_required"), default=argparse.SUPPRESS)
|
|
91
|
+
sub.add_argument(
|
|
92
|
+
"--lane-scheduler",
|
|
93
|
+
choices=LANE_SCHEDULER_MODES,
|
|
94
|
+
dest="lane_scheduler_mode",
|
|
95
|
+
default=argparse.SUPPRESS,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _profile_command_registrars():
|
|
100
|
+
"""Yield profile-command registrar callables.
|
|
101
|
+
|
|
102
|
+
Sources (DECOUPLE SL-1):
|
|
103
|
+
1. the ``phase_loop_runtime.profile_commands`` entry-point group (declared by
|
|
104
|
+
an installed profile distribution); and
|
|
105
|
+
2. the explicit ``PHASE_LOOP_PROFILE_PLUGINS`` opt-in -- a comma-separated
|
|
106
|
+
list of ``module:callable`` specs -- used in source-mode runs and tests
|
|
107
|
+
where no distribution metadata declares the group. (Comma, not the path
|
|
108
|
+
separator, since ``module:callable`` already contains a colon.)
|
|
109
|
+
|
|
110
|
+
With neither configured (e.g. a clean wheel install with no profile plugin),
|
|
111
|
+
this yields nothing and the generic CLI exposes no dotfiles-domain command.
|
|
112
|
+
"""
|
|
113
|
+
import importlib
|
|
114
|
+
import importlib.metadata
|
|
115
|
+
|
|
116
|
+
# Dedupe by the loaded callable's identity: the same registrar can be reachable
|
|
117
|
+
# via BOTH the entry-point group (installed dist-info) and the
|
|
118
|
+
# PHASE_LOOP_PROFILE_PLUGINS opt-in (e.g. the in-tree dotfiles profile is
|
|
119
|
+
# registered under the group AND opted in by the test conftest). Without this,
|
|
120
|
+
# the registrar would run twice and add each subparser twice.
|
|
121
|
+
registrars: list = []
|
|
122
|
+
seen: set = set()
|
|
123
|
+
|
|
124
|
+
def _add(registrar) -> None:
|
|
125
|
+
key = id(registrar)
|
|
126
|
+
if key in seen:
|
|
127
|
+
return
|
|
128
|
+
seen.add(key)
|
|
129
|
+
registrars.append(registrar)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
entry_points = importlib.metadata.entry_points(group="phase_loop_runtime.profile_commands")
|
|
133
|
+
except TypeError: # pragma: no cover - py<3.10 selectable API
|
|
134
|
+
entry_points = importlib.metadata.entry_points().get("phase_loop_runtime.profile_commands", [])
|
|
135
|
+
for entry_point in entry_points:
|
|
136
|
+
try:
|
|
137
|
+
_add(entry_point.load())
|
|
138
|
+
except Exception as exc: # a broken plugin must not break the CLI -- but be loud
|
|
139
|
+
_LOGGER.warning("failed to load profile-command plugin %r: %s", getattr(entry_point, "name", entry_point), exc)
|
|
140
|
+
|
|
141
|
+
opt_in = os.environ.get("PHASE_LOOP_PROFILE_PLUGINS", "")
|
|
142
|
+
for spec in opt_in.split(","):
|
|
143
|
+
spec = spec.strip()
|
|
144
|
+
if not spec or ":" not in spec:
|
|
145
|
+
continue
|
|
146
|
+
module_name, _, attr = spec.partition(":")
|
|
147
|
+
try:
|
|
148
|
+
module = importlib.import_module(module_name)
|
|
149
|
+
_add(getattr(module, attr))
|
|
150
|
+
except Exception as exc: # a bad opt-in spec must not break the CLI -- but be loud
|
|
151
|
+
_LOGGER.warning("failed to load profile-command plugin from opt-in %r: %s", spec, exc)
|
|
152
|
+
return tuple(registrars)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _register_profile_commands(subparsers) -> None:
|
|
156
|
+
for registrar in _profile_command_registrars():
|
|
157
|
+
registrar(subparsers)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_parser_with_profile(opt_in: str) -> argparse.ArgumentParser:
|
|
161
|
+
"""Build a parser with a specific profile plugin opt-in (test/tooling helper)."""
|
|
162
|
+
previous = os.environ.get("PHASE_LOOP_PROFILE_PLUGINS")
|
|
163
|
+
os.environ["PHASE_LOOP_PROFILE_PLUGINS"] = opt_in
|
|
164
|
+
try:
|
|
165
|
+
return build_parser()
|
|
166
|
+
finally:
|
|
167
|
+
if previous is None:
|
|
168
|
+
os.environ.pop("PHASE_LOOP_PROFILE_PLUGINS", None)
|
|
169
|
+
else:
|
|
170
|
+
os.environ["PHASE_LOOP_PROFILE_PLUGINS"] = previous
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _branchgov_parent_parser() -> argparse.ArgumentParser:
|
|
174
|
+
"""Shared parent carrying --allow-branchgov (issue #83). Used by BOTH the
|
|
175
|
+
top-level parser and the run/resume/dry-run subparsers via `parents=[...]`
|
|
176
|
+
with `default=argparse.SUPPRESS`, so the flag works in either position
|
|
177
|
+
(`phase-loop --allow-branchgov run` AND `phase-loop run --allow-branchgov`)
|
|
178
|
+
without a subparser default clobbering a value set before the subcommand."""
|
|
179
|
+
parent = argparse.ArgumentParser(add_help=False)
|
|
180
|
+
parent.add_argument(
|
|
181
|
+
"--allow-branchgov",
|
|
182
|
+
action="store_true",
|
|
183
|
+
default=argparse.SUPPRESS,
|
|
184
|
+
help=(
|
|
185
|
+
"Issue #83 opt-in: switch to the convention branch even when it would orphan a "
|
|
186
|
+
"locally-committed roadmap (exports PHASE_LOOP_BRANCHGOV_ENABLE=true). Without it, "
|
|
187
|
+
"the runtime refuses cleanly (branch_sync_conflict) rather than crashing."
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
return parent
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
194
|
+
branchgov_parent = _branchgov_parent_parser()
|
|
195
|
+
parser = argparse.ArgumentParser(
|
|
196
|
+
prog="phase-loop",
|
|
197
|
+
description="Neutral phase-loop runner. codex-phase-loop remains a Codex bridge alias.",
|
|
198
|
+
allow_abbrev=False,
|
|
199
|
+
parents=[branchgov_parent],
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument("--version", action="version", version=f"phase-loop {__version__}")
|
|
202
|
+
parser.add_argument("--repo", default=".")
|
|
203
|
+
|
|
204
|
+
parser.add_argument("--roadmap")
|
|
205
|
+
parser.add_argument("--phase")
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--max-phases",
|
|
208
|
+
type=int,
|
|
209
|
+
help="Maximum dispatched actions by default; combine with --full-phase to count complete phase cycles.",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument("--full-phase", action="store_true", help="Count --max-phases as complete plan-plus-execute phase cycles.")
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"--phase-scheduler",
|
|
214
|
+
dest="phase_scheduler_mode",
|
|
215
|
+
choices=PHASE_SCHEDULER_MODES,
|
|
216
|
+
default="off",
|
|
217
|
+
help="Cross-phase scheduling mode (v45): 'off'/'serialized' dispatch one ready phase at a time; 'concurrent' dispatches the full ready wave.",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument("--no-deprecation-hints", action="store_true", help="Suppress legacy --max-phases action-count hints.")
|
|
220
|
+
parser.add_argument("--model-profile", choices=tuple(DEFAULT_PROFILES))
|
|
221
|
+
parser.add_argument("--model")
|
|
222
|
+
parser.add_argument("--effort")
|
|
223
|
+
parser.add_argument("--executor", choices=EXECUTORS)
|
|
224
|
+
parser.add_argument("--command-name")
|
|
225
|
+
parser.add_argument("--command-template")
|
|
226
|
+
parser.add_argument("--claude-execution-mode", choices=CLAUDE_EXECUTION_MODES)
|
|
227
|
+
parser.add_argument("--allow-executor", action="append", default=[])
|
|
228
|
+
parser.add_argument("--fallback-executor", action="append", default=[])
|
|
229
|
+
parser.add_argument("--disable-executor", action="append", default=[])
|
|
230
|
+
parser.add_argument("--require-capability", action="append", default=[])
|
|
231
|
+
parser.add_argument("--json", action="store_true")
|
|
232
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
233
|
+
parser.add_argument("--observe", action="store_true", help="Accepted for compatibility; launch artifacts are written by default.")
|
|
234
|
+
parser.add_argument("--no-observe", action="store_true", help="Disable launch log and heartbeat artifacts.")
|
|
235
|
+
parser.add_argument("--stream-output", action="store_true")
|
|
236
|
+
parser.add_argument("--bypass-approvals", action="store_true")
|
|
237
|
+
parser.add_argument("--heartbeat-interval-seconds", type=int, default=30)
|
|
238
|
+
parser.add_argument("--quiet-warning-seconds", type=int, default=600)
|
|
239
|
+
parser.add_argument("--quiet-blocker-seconds", type=int, default=1800)
|
|
240
|
+
parser.add_argument("--no-heartbeat", action="store_true")
|
|
241
|
+
parser.add_argument("--closeout-mode", choices=CLOSEOUT_MODES, default="manual")
|
|
242
|
+
parser.add_argument("--work-unit-mode", action="store_true")
|
|
243
|
+
parser.add_argument("--rotate-executors")
|
|
244
|
+
parser.add_argument("--rotation-mode", choices=("phase", "work_unit"), default="phase")
|
|
245
|
+
parser.add_argument("--rotation-on-policy-pin", choices=("skip", "fallback-next"), default="skip")
|
|
246
|
+
parser.add_argument("--lane-scheduler", choices=LANE_SCHEDULER_MODES, dest="lane_scheduler_mode")
|
|
247
|
+
parser.add_argument("--source-bundle")
|
|
248
|
+
parser.add_argument("--pipeline-mode", choices=("standalone", "pipeline_optional", "pipeline_required"), default="standalone")
|
|
249
|
+
parser.add_argument("--force-replan", action="store_true")
|
|
250
|
+
parser.add_argument(
|
|
251
|
+
"--allow-cross-phase-dirty",
|
|
252
|
+
help="Explicitly bypass the cross-phase dirty start gate. Requires a non-empty operator reason.",
|
|
253
|
+
)
|
|
254
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
255
|
+
# DECOUPLE SL-1: the dotfiles-domain commands (adoption-bundle, sync-skills,
|
|
256
|
+
# build-bundle, hotfix) are NOT in this loop. They are registered only by the
|
|
257
|
+
# dotfiles-profile plugin (see _register_profile_commands below), so the
|
|
258
|
+
# generic CLI exposes none of them at import.
|
|
259
|
+
for name in ("run", "resume", "status", "dry-run", "maintain-skills", "install", "state", "handoff", "archive-state", "monitor", "version", "execute", "reconcile", "reopen", "migrate-handoffs", "migrate-events", "init", "evidence-audit", "closeout-drift-audit", "validate-roadmap", "docs-audit", "export-schema", "fleet-map", "worktree-index", "consiliency-scaffold", "consiliency-ingest", "consiliency-lease"):
|
|
260
|
+
# #83: run/resume/dry-run inherit --allow-branchgov via the shared parent so
|
|
261
|
+
# the flag works after the subcommand too (the top-level parser owns the
|
|
262
|
+
# before-subcommand position); SUPPRESS keeps neither default clobbering.
|
|
263
|
+
sub_parents = [branchgov_parent] if name in {"run", "resume", "dry-run"} else []
|
|
264
|
+
sub = subparsers.add_parser(name, parents=sub_parents)
|
|
265
|
+
if name == "execute":
|
|
266
|
+
sub.add_argument("phase_arg", metavar="phase", help="The phase alias to execute.")
|
|
267
|
+
sub.add_argument("--bundle", help="Path to a phase-source-bundle.v1 artifact.")
|
|
268
|
+
sub.add_argument("--output", help="Path where exactly one closeout JSON file must be written.")
|
|
269
|
+
sub.add_argument("--mode", help="The execution mode: execute, repair, or review.")
|
|
270
|
+
_add_common_subparser_args(sub, name=name)
|
|
271
|
+
if name in {"run", "resume", "dry-run"}:
|
|
272
|
+
sub.add_argument("--closeout-mode", choices=CLOSEOUT_MODES)
|
|
273
|
+
sub.add_argument(
|
|
274
|
+
"--governed",
|
|
275
|
+
action="store_true",
|
|
276
|
+
help="Opt into governed mode: a bounded pre-merge panel review before each "
|
|
277
|
+
"implementation phase commits (model-routing-v2). Default is autonomous (no panel).",
|
|
278
|
+
)
|
|
279
|
+
sub.add_argument("--force-replan", action="store_true")
|
|
280
|
+
sub.add_argument("--no-dispatch-lock", action="store_true", help="Disable the per-roadmap dispatch lock for this run.")
|
|
281
|
+
if parallel_dispatch_enabled():
|
|
282
|
+
sub.add_argument("--parallel-dispatch", action="store_true", help="Run roadmap phases through the serial coordinator wave loop.")
|
|
283
|
+
sub.add_argument(
|
|
284
|
+
"--allow-cross-phase-dirty",
|
|
285
|
+
help="Explicitly bypass the cross-phase dirty start gate. Requires a non-empty operator reason.",
|
|
286
|
+
)
|
|
287
|
+
sub.add_argument(
|
|
288
|
+
"--closeout-allow-unowned",
|
|
289
|
+
help=(
|
|
290
|
+
"Break-glass: force-commit the verified UNSAFE-unowned closeout remainder "
|
|
291
|
+
"(source/ci/lockfile) under the supplied operator reason, recorded as a "
|
|
292
|
+
"break_glass exception. secrets are NEVER break-glassable. Requires a "
|
|
293
|
+
"non-empty reason; pair with --phase to bound the blast radius."
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
if allow_lane_ir_override_enabled():
|
|
297
|
+
sub.add_argument(
|
|
298
|
+
"--allow-lane-ir-override",
|
|
299
|
+
help="Comma-separated lane-IR diagnostic kinds to override. Requires --reason.",
|
|
300
|
+
)
|
|
301
|
+
sub.add_argument("--reason", help="Operator-supplied reason for the lane-IR override audit trail.")
|
|
302
|
+
sub.add_argument("--reset-capability", action="store_true")
|
|
303
|
+
sub.add_argument("--rotate-executors")
|
|
304
|
+
sub.add_argument("--rotation-mode", choices=("phase", "work_unit"))
|
|
305
|
+
sub.add_argument("--rotation-on-policy-pin", choices=("skip", "fallback-next"))
|
|
306
|
+
sub.add_argument("--enable-tier-3", action="store_true", help="Enable default-off closeout-time Tier 3 evidence audit.")
|
|
307
|
+
sub.add_argument("--tier-3-budget", type=int, default=3, help="Maximum Tier 3 evidence-audit calls per closeout. Default 3.")
|
|
308
|
+
if name in {"run", "resume"}:
|
|
309
|
+
sub.add_argument("--full-phase", action="store_true", help="Count --max-phases as complete plan-plus-execute phase cycles.")
|
|
310
|
+
sub.add_argument("--no-deprecation-hints", action="store_true", help="Suppress legacy --max-phases action-count hints.")
|
|
311
|
+
if name == "maintain-skills":
|
|
312
|
+
sub.description = "Skill Maintenance: planner-only by default; edits require --apply-skill-edits and --allow-skill."
|
|
313
|
+
sub.add_argument("--min-reflections", type=int, default=2)
|
|
314
|
+
sub.add_argument("--apply-skill-edits", action="store_true")
|
|
315
|
+
sub.add_argument("--allow-skill", action="append", default=())
|
|
316
|
+
sub.add_argument("--improvement-plan")
|
|
317
|
+
if name == "validate-roadmap":
|
|
318
|
+
sub.description = "Mechanically lint a phase-plan roadmap spec (headings, aliases, IF-gates, DAG, lane hints). Pass --train for cross-repo release-train roadmaps."
|
|
319
|
+
sub.add_argument("roadmap_path", nargs="?", help="Path to the roadmap spec. Falls back to --roadmap / auto-detection.")
|
|
320
|
+
sub.add_argument("--train", action="store_true", default=False, help="Validate as a cross-repo release-train roadmap (P2 train mode).")
|
|
321
|
+
if name == "docs-audit":
|
|
322
|
+
sub.description = "Pipeline-independent docs-freshness backstop over a git diff (no .phase-loop state); fails loud on a release surface changed without its required doc."
|
|
323
|
+
sub.add_argument("--base", help="Diff base ref (auto-resolved from CI env if omitted: PR base / prior tag / push before-SHA).")
|
|
324
|
+
sub.add_argument("--decisions", help="Path to the repo-visible doc-decisions artifact (default: .doc-decisions.json).")
|
|
325
|
+
if name == "worktree-index":
|
|
326
|
+
sub.description = (
|
|
327
|
+
"Read-only, git-derived freshness pointer: which active worktree (or origin/main) "
|
|
328
|
+
"holds the freshest copy of a path, and whether origin/main is behind on it. "
|
|
329
|
+
"Never writes repo state."
|
|
330
|
+
)
|
|
331
|
+
sub.add_argument("--path", help="Report freshness holders for a single repo-relative path. Omit to report every path touched by an active worktree.")
|
|
332
|
+
sub.add_argument("--base", help="Diff base ref (default: origin/<default-branch>, falling back to origin/main).")
|
|
333
|
+
if name == "consiliency-lease":
|
|
334
|
+
sub.description = (
|
|
335
|
+
"CS-0.10c: local-file LeaseStore -- soft, TTL+heartbeat path-set leases so parallel "
|
|
336
|
+
"local agents don't collide. SOFT MODE ONLY (hard degrades to soft: no cross-machine "
|
|
337
|
+
"atomic acquire locally). Give-way = reroute: a conflicting acquire never blocks, it "
|
|
338
|
+
"returns the blocking lease so the caller can go work something else. The current-lease "
|
|
339
|
+
"view is a projection of the append-only .consiliency/leases/events.jsonl log only."
|
|
340
|
+
)
|
|
341
|
+
sub.add_argument("action", choices=("acquire", "renew", "release", "query"), help="The LeaseStore operation to perform.")
|
|
342
|
+
sub.add_argument("--lease-id", help="Required for acquire/renew/release.")
|
|
343
|
+
sub.add_argument("--holder", help="Required for acquire/renew/release.")
|
|
344
|
+
sub.add_argument("--ttl-seconds", type=int, default=300, help="acquire only. Default 300.")
|
|
345
|
+
sub.add_argument("--mode", choices=("soft", "hard"), default="soft", help="acquire only. Always degrades to soft on this backend.")
|
|
346
|
+
sub.add_argument(
|
|
347
|
+
"--granularity", choices=("repo", "path-set", "symbol"), default="path-set", help="acquire/query scope granularity."
|
|
348
|
+
)
|
|
349
|
+
sub.add_argument("--scope", action="append", default=[], help="acquire/query. Repeatable path-set/symbol selector entry.")
|
|
350
|
+
sub.add_argument("--lease-phase", default="", help="acquire only. The declaring phase/step label. (--phase is the roadmap-phase common arg.)")
|
|
351
|
+
sub.add_argument("--path", help="query only. Shorthand for --granularity path-set --scope <path>.")
|
|
352
|
+
sub.add_argument("--now", help="Override the current time (ISO 8601 UTC, e.g. 2026-01-01T00:00:00Z) for deterministic testing.")
|
|
353
|
+
if name == "install":
|
|
354
|
+
sub.description = "Install harness-prefixed workflow skills from a harness-neutral phase-loop skills bundle."
|
|
355
|
+
sub.add_argument("--harness", choices=("codex", "claude", "gemini", "opencode"))
|
|
356
|
+
sub.add_argument("--source", default="vendor/phase-loop-skills")
|
|
357
|
+
sub.add_argument("--destination")
|
|
358
|
+
sub.add_argument("--status", action="store_true")
|
|
359
|
+
mode = sub.add_mutually_exclusive_group()
|
|
360
|
+
mode.add_argument("--symlink", action="store_true")
|
|
361
|
+
mode.add_argument("--copy", action="store_true")
|
|
362
|
+
sub.add_argument("--apply", action="store_true")
|
|
363
|
+
if name == "status":
|
|
364
|
+
sub.add_argument("--runtime-projection", action="store_true")
|
|
365
|
+
sub.add_argument("--tier-3-history", action="store_true", help="Print recent Tier 3 evidence-audit summaries without raw prompts or responses.")
|
|
366
|
+
sub.add_argument("--ledger-debug", action="store_true", help="Print redacted rejected ledger event diagnostics.")
|
|
367
|
+
if name == "migrate-handoffs":
|
|
368
|
+
sub.description = "Move current-repo legacy skill handoffs into repo-local .dev-skills storage."
|
|
369
|
+
sub.add_argument("--apply", action="store_true")
|
|
370
|
+
if name == "migrate-events":
|
|
371
|
+
sub.description = "Migrate legacy DEF-4 executor closeout event action tokens in .phase-loop/events.jsonl."
|
|
372
|
+
sub.add_argument("--backup-suffix", default=".bak-before-def4-migrate")
|
|
373
|
+
if name == "init":
|
|
374
|
+
sub.add_argument("--install-hooks", action="store_true", help="Install opt-in local git hooks for this repo.")
|
|
375
|
+
if name == "consiliency-scaffold":
|
|
376
|
+
sub.description = (
|
|
377
|
+
"CS-0.5: first-writer scaffolder for a schema-valid `.consiliency/` layout "
|
|
378
|
+
"(manifest, contract-version status, interface declaration, and L0 presence-stub "
|
|
379
|
+
"docs for the declared archetype). Additive only: never touches `.phase-loop/` or "
|
|
380
|
+
"`.pipeline/`, and never overwrites a file that already exists."
|
|
381
|
+
)
|
|
382
|
+
sub.add_argument(
|
|
383
|
+
"--archetype",
|
|
384
|
+
action="append",
|
|
385
|
+
default=[],
|
|
386
|
+
choices=ARCHETYPE_IDS,
|
|
387
|
+
help="Repeatable. Declares an archetype (product/service/library/infra/tooling-meta/experiment/document).",
|
|
388
|
+
)
|
|
389
|
+
sub.add_argument(
|
|
390
|
+
"--modifier",
|
|
391
|
+
action="append",
|
|
392
|
+
default=[],
|
|
393
|
+
choices=MODIFIER_IDS,
|
|
394
|
+
help="Repeatable. Declares a modifier (data-bearing/public/regulated/user-facing).",
|
|
395
|
+
)
|
|
396
|
+
sub.add_argument(
|
|
397
|
+
"--baseline-only",
|
|
398
|
+
action="store_true",
|
|
399
|
+
help="Declare baseline-only mode (no archetype). Mutually exclusive with --archetype/--modifier.",
|
|
400
|
+
)
|
|
401
|
+
sub.add_argument("--repo-id", help="Override the manifest's repo.id (default: derived from the repo directory name).")
|
|
402
|
+
sub.add_argument("--display-name", help="Override the manifest's repo.display_name (default: the repo directory name).")
|
|
403
|
+
if name == "consiliency-ingest":
|
|
404
|
+
sub.description = (
|
|
405
|
+
"CS-0.11: brownfield ingestion for an existing repo. Shape-to-conform on the "
|
|
406
|
+
"first pass (delegates the base `.consiliency/` layout to consiliency-scaffold, "
|
|
407
|
+
"then adds a CS-0.12 adoption profile and a proposed governed-set allowlist) -- "
|
|
408
|
+
"verify-only on every subsequent pass (never rewrites; runs the CS-0.6 L0 gates "
|
|
409
|
+
"and labels declared documents governed/foreign/present-nonconforming). "
|
|
410
|
+
"A repo with no `.consiliency/manifest` is untouched unless --adopt is passed."
|
|
411
|
+
)
|
|
412
|
+
sub.add_argument(
|
|
413
|
+
"--adopt",
|
|
414
|
+
action="store_true",
|
|
415
|
+
help="Consent to shape an unmanaged repo (no-op without this flag when no manifest exists yet).",
|
|
416
|
+
)
|
|
417
|
+
sub.add_argument(
|
|
418
|
+
"--archetype",
|
|
419
|
+
action="append",
|
|
420
|
+
default=[],
|
|
421
|
+
choices=ARCHETYPE_IDS,
|
|
422
|
+
help="Repeatable. Declares an archetype for the shape pass (ignored on a verify pass).",
|
|
423
|
+
)
|
|
424
|
+
sub.add_argument(
|
|
425
|
+
"--modifier",
|
|
426
|
+
action="append",
|
|
427
|
+
default=[],
|
|
428
|
+
choices=MODIFIER_IDS,
|
|
429
|
+
help="Repeatable. Declares a modifier for the shape pass (ignored on a verify pass).",
|
|
430
|
+
)
|
|
431
|
+
sub.add_argument(
|
|
432
|
+
"--baseline-only",
|
|
433
|
+
action="store_true",
|
|
434
|
+
help="Declare baseline-only mode for the shape pass. Mutually exclusive with --archetype/--modifier.",
|
|
435
|
+
)
|
|
436
|
+
sub.add_argument("--repo-id", help="Override the manifest's repo.id (shape pass only).")
|
|
437
|
+
sub.add_argument("--display-name", help="Override the manifest's repo.display_name (shape pass only).")
|
|
438
|
+
if name == "archive-state":
|
|
439
|
+
sub.add_argument("--reason")
|
|
440
|
+
if name == "reconcile":
|
|
441
|
+
sub.description = (
|
|
442
|
+
"Synthesize a v28-shape manual_repair completion event, or recover a blocked "
|
|
443
|
+
"dirty-state phase to planned/unplanned without marking verification passed."
|
|
444
|
+
)
|
|
445
|
+
sub.add_argument("--closeout-commit", help="Commit SHA to record as the closeout commit. Defaults to current HEAD.")
|
|
446
|
+
sub.add_argument("--repair-summary", help="Optional human-authored note explaining the repair.")
|
|
447
|
+
reconcile_transition = sub.add_mutually_exclusive_group()
|
|
448
|
+
reconcile_transition.add_argument(
|
|
449
|
+
"--to-status",
|
|
450
|
+
choices=("planned",),
|
|
451
|
+
help=(
|
|
452
|
+
"Recover a dirty-state-derived blocked phase back to planned when a current "
|
|
453
|
+
"plan exists, or unplanned when no plan exists. Requires --reason and records "
|
|
454
|
+
"verification_status=not_run."
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
# phase-loop reconcile always records status=complete (line 756);
|
|
458
|
+
# per the field-pair invariants in BAML/closeout schema, complete
|
|
459
|
+
# requires verification_status=passed. Other values are silently
|
|
460
|
+
# accepted today and create state-machine contradictions (see #11
|
|
461
|
+
# part B4). Restrict to passed to reject invalid combos at parse time.
|
|
462
|
+
reconcile_transition.add_argument(
|
|
463
|
+
"--verification-status",
|
|
464
|
+
choices=("passed",),
|
|
465
|
+
help=(
|
|
466
|
+
"Must be 'passed'. phase-loop reconcile records status=complete; "
|
|
467
|
+
"the field-pair invariants require verification_status=passed. "
|
|
468
|
+
"If your phase is a dirty-state blocked recovery, use "
|
|
469
|
+
"--to-status planned --reason <text> instead."
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
sub.add_argument("--verification-log", help="Path to the runner-owned verification artifact required with --verification-status passed.")
|
|
473
|
+
sub.add_argument("--reason", help="Required with --to-status planned. Recorded on manual_recovery.")
|
|
474
|
+
sub.add_argument("--allow-dirty", action="store_true", help="Override the refuse-if-dirty guard. Not recommended.")
|
|
475
|
+
sub.add_argument("--recovery-mode", action="store_true", help="Allow dirty recovery-state reconciliation with explicit audit fields.")
|
|
476
|
+
sub.add_argument(
|
|
477
|
+
"--force",
|
|
478
|
+
action="store_true",
|
|
479
|
+
help=(
|
|
480
|
+
"With --to-status planned: bypass the sticky-blocker allowlist on operator "
|
|
481
|
+
"attestation. Recorded as forced_recovery=true in the manual_recovery event. "
|
|
482
|
+
"Required --reason becomes the audit trail. Use only when you know the underlying "
|
|
483
|
+
"blocker condition has been resolved."
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
if name == "reopen":
|
|
487
|
+
sub.description = (
|
|
488
|
+
"Reverse a spurious closeout: append a typed phase_reopen event so the reducer "
|
|
489
|
+
"flips the named phase from complete back to planned. Use when an executor reported "
|
|
490
|
+
"complete + verification_status=passed but the underlying IF gates were not actually "
|
|
491
|
+
"satisfied (e.g., zero-diff repair iteration that did not produce the phase's work)."
|
|
492
|
+
)
|
|
493
|
+
sub.add_argument("--reason", required=True, help="Operator-supplied reason for reopening. Recorded on the event.")
|
|
494
|
+
sub.add_argument("--allow-dirty", action="store_true", help="Override the refuse-if-dirty guard. Not recommended.")
|
|
495
|
+
if name == "monitor":
|
|
496
|
+
sub.add_argument("--poll-seconds", type=int, default=60)
|
|
497
|
+
sub.add_argument("--timeout-seconds", type=int)
|
|
498
|
+
sub.add_argument("--notify-command")
|
|
499
|
+
sub.add_argument(
|
|
500
|
+
"--notify-on",
|
|
501
|
+
action="append",
|
|
502
|
+
default=[],
|
|
503
|
+
choices=("blocked", "stale", "complete", "awaiting_phase_closeout", "operator_halt", "terminal_exit"),
|
|
504
|
+
)
|
|
505
|
+
sub.add_argument("--once", action="store_true")
|
|
506
|
+
if name == "evidence-audit":
|
|
507
|
+
sub.description = (
|
|
508
|
+
"Operator-callable spot-check for fake-evidence patterns in dirty-tree artifacts. "
|
|
509
|
+
"Detects: (1) duplicate-content — N or more files share the same sha256 "
|
|
510
|
+
"(e.g., 19 \"distinct\" PNGs all the same placeholder); (2) uniform-numeric — "
|
|
511
|
+
"numeric arrays >= 4 elements where all values are within epsilon "
|
|
512
|
+
"(e.g., 19/19 similarity scores at 0.999999); (3) missing-references — JSON "
|
|
513
|
+
"artifacts cite path-shaped strings that don't exist on disk. "
|
|
514
|
+
"Run before `phase-loop reconcile` on phases producing comparison/verdict evidence. "
|
|
515
|
+
"Exits 0 if clean, 5 if suspect findings."
|
|
516
|
+
)
|
|
517
|
+
sub.add_argument("--dirty-only", action="store_true", default=True, help="Audit only currently-modified/untracked paths (default).")
|
|
518
|
+
sub.add_argument("--full-tree", dest="dirty_only", action="store_false", help="Audit every tracked file in the repo with strict missing-reference scanning (slow).")
|
|
519
|
+
sub.add_argument("--full-tree-loose", action="store_true", help="Audit every tracked file and use loose forensic missing-reference scanning.")
|
|
520
|
+
sub.add_argument("--min-duplicates", type=int, default=3, help="Min number of files sharing a sha256 before flagging. Default 3.")
|
|
521
|
+
sub.add_argument("--uniform-epsilon", type=float, default=1e-6, help="Numeric uniformity tolerance. Default 1e-6.")
|
|
522
|
+
sub.add_argument("--uniform-min-length", type=int, default=4, help="Min array length to check for uniformity. Default 4.")
|
|
523
|
+
sub.add_argument("--tier-2", action="store_true", help="Enable Tier 2 fuzzy detectors: loose-uniform, boilerplate-text, and size-distribution.")
|
|
524
|
+
sub.add_argument("--loose-uniform-stdev-threshold", type=float, default=1e-3, help="Tier 2 coefficient-of-variation threshold for near-uniform numeric arrays. Default 1e-3.")
|
|
525
|
+
sub.add_argument("--boilerplate-token-overlap-threshold", type=float, default=0.80, help="Tier 2 token overlap threshold for boilerplate text groups. Default 0.80.")
|
|
526
|
+
sub.add_argument("--boilerplate-min-group-size", type=int, default=3, help="Tier 2 minimum text file group size for boilerplate detection. Default 3.")
|
|
527
|
+
sub.add_argument("--size-distribution-variance-threshold", type=float, default=0.05, help="Tier 2 coefficient-of-variation threshold for sibling file sizes. Default 0.05.")
|
|
528
|
+
sub.add_argument("--size-distribution-min-group-size", type=int, default=3, help="Tier 2 minimum sibling file group size for size-distribution detection. Default 3.")
|
|
529
|
+
sub.add_argument("--enable-tier-3", action="store_true", help="Enable default-off Tier 3 LLM judgment for Tier 2 uncertain findings only.")
|
|
530
|
+
if name == "closeout-drift-audit":
|
|
531
|
+
sub.description = "Audit phase-loop closeout literals for drift from runtime allowlists."
|
|
532
|
+
sub.add_argument("--days", type=int, default=7, help="Lookback window in days. Default 7.")
|
|
533
|
+
sub.add_argument("--scope", choices=("closeout", "all-events"), default="closeout", help="Audit closeout payloads by default; use all-events for forensic scans.")
|
|
534
|
+
if name == "fleet-map":
|
|
535
|
+
sub.description = (
|
|
536
|
+
"CS-0.7: extract the realized cross-repo interface graph (git+ref pins, "
|
|
537
|
+
"copied-literal contract/schema drift, hard-coded host-path refs) across "
|
|
538
|
+
"--repo paths — NOT a package-lockfile scan. Also reports the lockfile-only "
|
|
539
|
+
"baseline (typically empty) alongside the realized edges for comparison."
|
|
540
|
+
)
|
|
541
|
+
if name == "export-schema":
|
|
542
|
+
sub.description = (
|
|
543
|
+
"Emit (or --check) the canonical phase-loop closeout schema derived from "
|
|
544
|
+
"PhaseLoopCloseout. Repo-independent; the bundled artifact is the parity "
|
|
545
|
+
"source consumers (gp) diff against."
|
|
546
|
+
)
|
|
547
|
+
sub.add_argument("--output", help="Path to write the emitted schema/field-list. Defaults to stdout.")
|
|
548
|
+
sub.add_argument(
|
|
549
|
+
"--check",
|
|
550
|
+
metavar="PATH",
|
|
551
|
+
help="Compare a supplied artifact against the in-package canonical schema; exit non-zero on any divergence.",
|
|
552
|
+
)
|
|
553
|
+
sub.add_argument(
|
|
554
|
+
"--format",
|
|
555
|
+
choices=("json-schema", "field-list"),
|
|
556
|
+
default="json-schema",
|
|
557
|
+
help="Output format: a declared JSON-Schema (default) or the flat field-list gp consumes.",
|
|
558
|
+
)
|
|
559
|
+
# run-train: cross-repo release-train coordinator (P3, #29).
|
|
560
|
+
# Registered outside the common-args loop because it has its own argument
|
|
561
|
+
# set (--train, --governed, --workspace-root, --ledger-dir) and does NOT
|
|
562
|
+
# use the per-repo --repo/--roadmap/--phase/--max-phases args.
|
|
563
|
+
run_train_sub = subparsers.add_parser(
|
|
564
|
+
"run-train",
|
|
565
|
+
help=(
|
|
566
|
+
"Run a cross-repo release train: topo-sort, preflight, per-node "
|
|
567
|
+
"draft-PR execution via the unchanged per-repo run_loop. Without "
|
|
568
|
+
"--governed, opens all draft PRs and stops. With --governed, holds "
|
|
569
|
+
"for a train-level review then merges sequentially, re-verifying "
|
|
570
|
+
"each downstream against the upstream merged SHA."
|
|
571
|
+
),
|
|
572
|
+
)
|
|
573
|
+
run_train_sub.add_argument(
|
|
574
|
+
"--train",
|
|
575
|
+
dest="train_file",
|
|
576
|
+
required=False,
|
|
577
|
+
metavar="FILE",
|
|
578
|
+
help="Path to the cross-repo release-train roadmap (train-roadmap format).",
|
|
579
|
+
)
|
|
580
|
+
run_train_sub.add_argument(
|
|
581
|
+
"--governed",
|
|
582
|
+
action="store_true",
|
|
583
|
+
default=False,
|
|
584
|
+
help=(
|
|
585
|
+
"Pass run_mode='governed' to each per-repo run_loop "
|
|
586
|
+
"(per-repo governed panel review before merge)."
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
run_train_sub.add_argument(
|
|
590
|
+
"--workspace-root",
|
|
591
|
+
default=".",
|
|
592
|
+
metavar="DIR",
|
|
593
|
+
help=(
|
|
594
|
+
"Root directory under which each node's repo is found. "
|
|
595
|
+
"A node with repo='my-service' resolves to <workspace-root>/my-service. "
|
|
596
|
+
"Default: current directory."
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
run_train_sub.add_argument(
|
|
600
|
+
"--ledger-dir",
|
|
601
|
+
default=None,
|
|
602
|
+
metavar="DIR",
|
|
603
|
+
help=(
|
|
604
|
+
"Directory for the coordinator-side train ledger. "
|
|
605
|
+
"Must not be inside any repo's .phase-loop/. "
|
|
606
|
+
"Default: <train-file-parent>/.train-ledger/."
|
|
607
|
+
),
|
|
608
|
+
)
|
|
609
|
+
run_train_sub.add_argument(
|
|
610
|
+
"--json",
|
|
611
|
+
action="store_true",
|
|
612
|
+
help="Emit the result as JSON.",
|
|
613
|
+
)
|
|
614
|
+
# repo-validate: the harness-neutral local-first validation contract resolver
|
|
615
|
+
# (docs/repo-validation-contract.md). Registered outside the common-args loop
|
|
616
|
+
# because it takes a single positional target and reuses only the top-level
|
|
617
|
+
# --repo/--json; it is a NEUTRAL capability (not a dotfiles-domain command),
|
|
618
|
+
# so it belongs in the base CLI, not the profile plugin.
|
|
619
|
+
repo_validate_sub = subparsers.add_parser(
|
|
620
|
+
"repo-validate",
|
|
621
|
+
help=(
|
|
622
|
+
"Resolve and run a repo's EXPLICIT agent validation contract "
|
|
623
|
+
"(just agent::<t> or package.json agent:<t>); fail closed on "
|
|
624
|
+
"unmigrated repos. Never guesses npm test / pytest / make test."
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
repo_validate_sub.add_argument(
|
|
628
|
+
"repo_validate_target",
|
|
629
|
+
metavar="target",
|
|
630
|
+
choices=repo_validation.ALL_TARGET_TOKENS,
|
|
631
|
+
help="One of: fast, gate, full, fix, affected, doctor (check == doctor).",
|
|
632
|
+
)
|
|
633
|
+
# train-status: non-mutating inspection of the cross-repo train ledger (#45).
|
|
634
|
+
# Reads the SAME default ledger path as run-train; opens no PRs, writes nothing.
|
|
635
|
+
train_status_sub = subparsers.add_parser(
|
|
636
|
+
"train-status",
|
|
637
|
+
help=(
|
|
638
|
+
"Inspect the cross-repo release-train ledger WITHOUT modifying state: "
|
|
639
|
+
"per-node status, branch, PR URL, merge order, and merged SHA."
|
|
640
|
+
),
|
|
641
|
+
)
|
|
642
|
+
train_status_sub.add_argument(
|
|
643
|
+
"--train",
|
|
644
|
+
dest="train_file",
|
|
645
|
+
required=False,
|
|
646
|
+
metavar="FILE",
|
|
647
|
+
help="Path to the cross-repo release-train roadmap (same file as run-train).",
|
|
648
|
+
)
|
|
649
|
+
train_status_sub.add_argument(
|
|
650
|
+
"--ledger-dir",
|
|
651
|
+
default=None,
|
|
652
|
+
metavar="DIR",
|
|
653
|
+
help=(
|
|
654
|
+
"Directory of the coordinator-side train ledger. "
|
|
655
|
+
"Default: <train-file-parent>/.train-ledger/ (same as run-train)."
|
|
656
|
+
),
|
|
657
|
+
)
|
|
658
|
+
train_status_sub.add_argument(
|
|
659
|
+
"--json",
|
|
660
|
+
action="store_true",
|
|
661
|
+
help="Emit the ledger state as JSON.",
|
|
662
|
+
)
|
|
663
|
+
# DECOUPLE SL-1: dotfiles-domain commands are added here, only when a profile
|
|
664
|
+
# plugin is installed/opted-in. A clean wheel registers none.
|
|
665
|
+
_register_profile_commands(subparsers)
|
|
666
|
+
return parser
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def main(argv: list[str] | None = None) -> int:
|
|
670
|
+
parser = build_parser()
|
|
671
|
+
args = parser.parse_args(argv)
|
|
672
|
+
command = args.command or ("dry-run" if args.dry_run else "run")
|
|
673
|
+
# Issue #83: --allow-branchgov opts into the convention-branch switch even when
|
|
674
|
+
# it would orphan a locally-committed roadmap, by exporting the explicit
|
|
675
|
+
# override the runtime preflight reads (flag.branchgov_override_explicit). Scope
|
|
676
|
+
# the env mutation to this invocation (restore the prior value) so it does not
|
|
677
|
+
# leak process-globally — the override applies for THIS run, not the process.
|
|
678
|
+
if getattr(args, "allow_branchgov", False):
|
|
679
|
+
_previous_branchgov = os.environ.get("PHASE_LOOP_BRANCHGOV_ENABLE")
|
|
680
|
+
os.environ["PHASE_LOOP_BRANCHGOV_ENABLE"] = "true"
|
|
681
|
+
try:
|
|
682
|
+
return _main(parser, args, command)
|
|
683
|
+
finally:
|
|
684
|
+
if _previous_branchgov is None:
|
|
685
|
+
os.environ.pop("PHASE_LOOP_BRANCHGOV_ENABLE", None)
|
|
686
|
+
else:
|
|
687
|
+
os.environ["PHASE_LOOP_BRANCHGOV_ENABLE"] = _previous_branchgov
|
|
688
|
+
return _main(parser, args, command)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _main(parser: argparse.ArgumentParser, args: argparse.Namespace, command: str) -> int:
|
|
692
|
+
allow_cross_phase_dirty_reason = getattr(args, "allow_cross_phase_dirty", None)
|
|
693
|
+
if allow_cross_phase_dirty_reason is not None:
|
|
694
|
+
allow_cross_phase_dirty_reason = str(allow_cross_phase_dirty_reason).strip()
|
|
695
|
+
if not allow_cross_phase_dirty_reason:
|
|
696
|
+
parser.error("--allow-cross-phase-dirty requires a non-empty reason")
|
|
697
|
+
if command not in {"run", "resume", "dry-run"} and allow_cross_phase_dirty_reason is not None:
|
|
698
|
+
parser.error("--allow-cross-phase-dirty is only valid for run, resume, and dry-run")
|
|
699
|
+
|
|
700
|
+
allow_unowned_reason = getattr(args, "closeout_allow_unowned", None)
|
|
701
|
+
if allow_unowned_reason is not None:
|
|
702
|
+
allow_unowned_reason = str(allow_unowned_reason).strip()
|
|
703
|
+
if not allow_unowned_reason:
|
|
704
|
+
parser.error(
|
|
705
|
+
"--closeout-allow-unowned requires a non-empty reason "
|
|
706
|
+
"(blocker_class=operator_override_missing_reason)"
|
|
707
|
+
)
|
|
708
|
+
if command not in {"run", "resume", "dry-run"} and allow_unowned_reason is not None:
|
|
709
|
+
parser.error("--closeout-allow-unowned is only valid for run, resume, and dry-run")
|
|
710
|
+
lane_ir_override_kinds = _parse_lane_ir_override(parser, args, command)
|
|
711
|
+
if command not in {"run", "resume"} and (
|
|
712
|
+
bool(getattr(args, "full_phase", False)) or bool(getattr(args, "no_deprecation_hints", False))
|
|
713
|
+
):
|
|
714
|
+
parser.error("--full-phase and --no-deprecation-hints are only valid for run and resume")
|
|
715
|
+
if command == "version":
|
|
716
|
+
print(f"phase-loop {__version__}")
|
|
717
|
+
return 0
|
|
718
|
+
if command == "repo-validate":
|
|
719
|
+
# Neutral local-first validation contract resolver. Resolves the git
|
|
720
|
+
# work-tree root itself (worktree-aware) rather than via resolve_repo, so
|
|
721
|
+
# it honors the same --show-toplevel semantics as the dotfiles wrapper.
|
|
722
|
+
return repo_validation.cli_main(
|
|
723
|
+
target=args.repo_validate_target,
|
|
724
|
+
cwd=args.repo or ".",
|
|
725
|
+
as_json=bool(getattr(args, "json", False)),
|
|
726
|
+
)
|
|
727
|
+
if command == "export-schema":
|
|
728
|
+
return _export_schema_command(args=args)
|
|
729
|
+
if command == "validate-roadmap":
|
|
730
|
+
from . import roadmap_lint
|
|
731
|
+
|
|
732
|
+
candidate = getattr(args, "roadmap_path", None) or args.roadmap
|
|
733
|
+
if not candidate:
|
|
734
|
+
repo = resolve_repo(args.repo or ".")
|
|
735
|
+
candidate = select_roadmap(repo, None)
|
|
736
|
+
if not candidate:
|
|
737
|
+
parser.error("validate-roadmap requires a roadmap path (positional, --roadmap, or auto-detectable)")
|
|
738
|
+
argv_extra = ["--train"] if getattr(args, "train", False) else []
|
|
739
|
+
return roadmap_lint.main(["validate-roadmap"] + argv_extra + [str(candidate)])
|
|
740
|
+
if command == "run-train":
|
|
741
|
+
return _run_train_command(parser=parser, args=args)
|
|
742
|
+
if command == "train-status":
|
|
743
|
+
return _run_train_status_command(parser=parser, args=args)
|
|
744
|
+
if command == "docs-audit":
|
|
745
|
+
from . import docs_audit
|
|
746
|
+
|
|
747
|
+
repo_arg = args.repo or "."
|
|
748
|
+
if isinstance(repo_arg, list):
|
|
749
|
+
repo_arg = repo_arg[0] if repo_arg else "."
|
|
750
|
+
repo = resolve_repo(repo_arg)
|
|
751
|
+
report = docs_audit.run_audit(repo, getattr(args, "base", None), getattr(args, "decisions", None))
|
|
752
|
+
if bool(args.json):
|
|
753
|
+
print(json.dumps(report.to_json(), indent=2))
|
|
754
|
+
else:
|
|
755
|
+
print(f"docs_freshness: {report.docs_freshness}")
|
|
756
|
+
for finding in report.findings:
|
|
757
|
+
print(f" [{finding['klass']}] {finding.get('surface') or '-'}: {finding['reason']}")
|
|
758
|
+
if report.docs_freshness == "blocked":
|
|
759
|
+
print(
|
|
760
|
+
"\nRemediation: update the required doc surface(s), or record a doc decision in "
|
|
761
|
+
".doc-decisions.json (a release-class surface needs a real, relevant doc change)."
|
|
762
|
+
)
|
|
763
|
+
return report.exit_code
|
|
764
|
+
if command == "worktree-index":
|
|
765
|
+
from . import worktree_index
|
|
766
|
+
|
|
767
|
+
repo_arg = args.repo or "."
|
|
768
|
+
if isinstance(repo_arg, list):
|
|
769
|
+
repo_arg = repo_arg[0] if repo_arg else "."
|
|
770
|
+
repo = resolve_repo(repo_arg)
|
|
771
|
+
report = worktree_index.build_index(repo, base_ref=getattr(args, "base", None), path=getattr(args, "path", None))
|
|
772
|
+
if bool(args.json):
|
|
773
|
+
print(json.dumps(report.to_json(), indent=2))
|
|
774
|
+
else:
|
|
775
|
+
print(worktree_index.render_human(report))
|
|
776
|
+
return 0
|
|
777
|
+
if command == "consiliency-lease":
|
|
778
|
+
repo_arg = args.repo or "."
|
|
779
|
+
if isinstance(repo_arg, list):
|
|
780
|
+
repo_arg = repo_arg[0] if repo_arg else "."
|
|
781
|
+
repo = resolve_repo(repo_arg)
|
|
782
|
+
return _consiliency_lease_command(repo=repo, args=args, as_json=bool(args.json))
|
|
783
|
+
as_json = bool(args.json)
|
|
784
|
+
if command == "closeout-drift-audit":
|
|
785
|
+
if args.roadmap:
|
|
786
|
+
repo_args = args.repo or ["."]
|
|
787
|
+
if isinstance(repo_args, str):
|
|
788
|
+
repo_args = [repo_args]
|
|
789
|
+
for repo_arg in repo_args:
|
|
790
|
+
audit_repo = resolve_repo(repo_arg)
|
|
791
|
+
_warn_roadmap_validation(select_roadmap(audit_repo, args.roadmap))
|
|
792
|
+
return _closeout_drift_audit_command(args=args, as_json=as_json)
|
|
793
|
+
if command == "fleet-map":
|
|
794
|
+
return _fleet_map_command(args=args, as_json=as_json)
|
|
795
|
+
repo = resolve_repo(args.repo or ".")
|
|
796
|
+
# DECOUPLE SL-1: profile-plugin commands (adoption-bundle, sync-skills,
|
|
797
|
+
# build-bundle, hotfix) register a `func` default and are dispatched here,
|
|
798
|
+
# so this generic dispatcher never names a dotfiles-domain command.
|
|
799
|
+
profile_func = getattr(args, "func", None)
|
|
800
|
+
if profile_func is not None:
|
|
801
|
+
return profile_func(repo=repo, args=args, as_json=as_json)
|
|
802
|
+
if command == "init":
|
|
803
|
+
return _init_command(repo=repo, dry_run=bool(args.dry_run), as_json=as_json, install_hooks=bool(getattr(args, "install_hooks", False)))
|
|
804
|
+
if command == "consiliency-scaffold":
|
|
805
|
+
return _consiliency_scaffold_command(repo=repo, args=args, as_json=as_json)
|
|
806
|
+
if command == "consiliency-ingest":
|
|
807
|
+
return _consiliency_ingest_command(repo=repo, args=args, as_json=as_json)
|
|
808
|
+
if command == "evidence-audit":
|
|
809
|
+
if args.roadmap:
|
|
810
|
+
_warn_roadmap_validation(select_roadmap(repo, args.roadmap))
|
|
811
|
+
return _evidence_audit_command(repo=repo, args=args, as_json=as_json)
|
|
812
|
+
if command in {"run", "resume", "dry-run"} and bool(getattr(args, "reset_capability", False)):
|
|
813
|
+
clear_degradation(repo)
|
|
814
|
+
|
|
815
|
+
if command == "execute":
|
|
816
|
+
phase = args.phase_arg
|
|
817
|
+
output_path = args.output
|
|
818
|
+
execute_roadmap = select_roadmap(repo, args.roadmap)
|
|
819
|
+
_warn_roadmap_validation(execute_roadmap)
|
|
820
|
+
if not output_path:
|
|
821
|
+
snapshot = StateSnapshot(
|
|
822
|
+
timestamp=utc_now(),
|
|
823
|
+
repo=str(repo),
|
|
824
|
+
roadmap=str(args.roadmap or ""),
|
|
825
|
+
phases={},
|
|
826
|
+
current_phase=phase,
|
|
827
|
+
last_action=command,
|
|
828
|
+
human_required=False,
|
|
829
|
+
blocker_class="contract_bug",
|
|
830
|
+
blocker_summary="Direct invocation 'execute' requires --output <path>.",
|
|
831
|
+
**snapshot_provenance(execute_roadmap),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
write_state(repo, snapshot)
|
|
835
|
+
print(render_status(snapshot, as_json=as_json))
|
|
836
|
+
return 1
|
|
837
|
+
source_bundle_path = args.bundle or args.source_bundle
|
|
838
|
+
mode = args.mode or "execute"
|
|
839
|
+
if mode not in {"execute", "repair", "review"}:
|
|
840
|
+
return _direct_invocation_blocker(
|
|
841
|
+
repo=repo,
|
|
842
|
+
args=args,
|
|
843
|
+
phase=phase,
|
|
844
|
+
output_path=Path(output_path),
|
|
845
|
+
blocker_summary=f"Direct invocation 'execute' does not support --mode {mode!r}.",
|
|
846
|
+
diagnostic_kind="invalid_pipeline_mode",
|
|
847
|
+
as_json=as_json,
|
|
848
|
+
)
|
|
849
|
+
effective_pipeline_mode = args.pipeline_mode or ("pipeline_optional" if source_bundle_path else "standalone")
|
|
850
|
+
bundle_diagnostic = phase_source_bundle_diagnostic(
|
|
851
|
+
repo,
|
|
852
|
+
source_bundle_path,
|
|
853
|
+
phase=phase,
|
|
854
|
+
roadmap=execute_roadmap,
|
|
855
|
+
pipeline_mode=effective_pipeline_mode,
|
|
856
|
+
)
|
|
857
|
+
if bundle_diagnostic is not None:
|
|
858
|
+
return _direct_invocation_blocker(
|
|
859
|
+
repo=repo,
|
|
860
|
+
args=args,
|
|
861
|
+
phase=phase,
|
|
862
|
+
output_path=Path(output_path),
|
|
863
|
+
blocker_summary=f"Pipeline source bundle validation failed: {bundle_diagnostic.kind}",
|
|
864
|
+
diagnostic_kind=bundle_diagnostic.kind,
|
|
865
|
+
as_json=as_json,
|
|
866
|
+
)
|
|
867
|
+
# Map 'execute' subcommand to the requested mode action
|
|
868
|
+
command = mode
|
|
869
|
+
|
|
870
|
+
if command == "install":
|
|
871
|
+
if bool(getattr(args, "status", False)):
|
|
872
|
+
payload = build_install_status(repo, harnesses=(args.harness,) if args.harness else None)
|
|
873
|
+
if as_json:
|
|
874
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
875
|
+
else:
|
|
876
|
+
print(f"install_status: {payload.get('summary', 'unknown')}")
|
|
877
|
+
return 0
|
|
878
|
+
if not args.harness:
|
|
879
|
+
print("phase-loop install: --harness is required unless --status is used", file=sys.stderr)
|
|
880
|
+
return 2
|
|
881
|
+
source = Path(args.source)
|
|
882
|
+
if not source.is_absolute():
|
|
883
|
+
source = repo / source
|
|
884
|
+
destination = Path(args.destination).expanduser() if args.destination else None
|
|
885
|
+
mode = "copy" if bool(args.copy) else "symlink"
|
|
886
|
+
actions = install_skills(
|
|
887
|
+
harness=args.harness,
|
|
888
|
+
source=source,
|
|
889
|
+
destination=destination,
|
|
890
|
+
mode=mode,
|
|
891
|
+
apply=bool(args.apply),
|
|
892
|
+
)
|
|
893
|
+
if as_json:
|
|
894
|
+
print(actions_to_json(actions))
|
|
895
|
+
else:
|
|
896
|
+
verb = "applied" if args.apply else "planned"
|
|
897
|
+
for action in actions:
|
|
898
|
+
print(
|
|
899
|
+
f"{verb}\t{action.harness}\t{action.installed_name}\t"
|
|
900
|
+
f"{action.mode}\t{action.action}\t{action.destination}"
|
|
901
|
+
)
|
|
902
|
+
return 0
|
|
903
|
+
if command == "migrate-handoffs":
|
|
904
|
+
records = migrate_handoffs(repo, apply=bool(getattr(args, "apply", False)))
|
|
905
|
+
if as_json:
|
|
906
|
+
print(records_to_json(records))
|
|
907
|
+
else:
|
|
908
|
+
for record in records:
|
|
909
|
+
print(f"{record.status}\t{record.action}\t{record.skill_name}\t{record.source}\t{record.target}")
|
|
910
|
+
return 1 if any(record.action == "blocked" for record in records) else 0
|
|
911
|
+
if command == "migrate-events":
|
|
912
|
+
return _migrate_events_command(repo=repo, dry_run=bool(args.dry_run), backup_suffix=args.backup_suffix)
|
|
913
|
+
if command == "archive-state":
|
|
914
|
+
print(render_archive_result(
|
|
915
|
+
archive_state(repo, reason=getattr(args, "reason", None), dry_run=bool(getattr(args, "dry_run", False))),
|
|
916
|
+
as_json=as_json,
|
|
917
|
+
))
|
|
918
|
+
return 0
|
|
919
|
+
try:
|
|
920
|
+
roadmap = select_roadmap(repo, args.roadmap)
|
|
921
|
+
_warn_roadmap_validation(roadmap)
|
|
922
|
+
except RuntimeError as exc:
|
|
923
|
+
if "ambiguous roadmap selection" not in str(exc):
|
|
924
|
+
raise
|
|
925
|
+
if command == "state":
|
|
926
|
+
print(render_state_inspection(inspect_state(repo), as_json=as_json))
|
|
927
|
+
return 0
|
|
928
|
+
snapshot = StateSnapshot(
|
|
929
|
+
timestamp=utc_now(),
|
|
930
|
+
repo=str(repo),
|
|
931
|
+
roadmap=str(args.roadmap or ""),
|
|
932
|
+
phases={},
|
|
933
|
+
current_phase=None,
|
|
934
|
+
last_action=command,
|
|
935
|
+
human_required=True,
|
|
936
|
+
blocker_class="ambiguous_roadmap_selection",
|
|
937
|
+
blocker_summary="Multiple roadmap artifacts are plausible; pass --roadmap or create valid active state/handoff evidence.",
|
|
938
|
+
required_human_inputs=("explicit roadmap path or valid roadmap handoff",),
|
|
939
|
+
)
|
|
940
|
+
write_state(repo, snapshot)
|
|
941
|
+
print(render_status(snapshot, as_json=as_json))
|
|
942
|
+
return 2
|
|
943
|
+
|
|
944
|
+
if command == "state":
|
|
945
|
+
print(render_state_inspection(inspect_state(repo, roadmap), as_json=as_json))
|
|
946
|
+
return 0
|
|
947
|
+
if command == "monitor":
|
|
948
|
+
result = monitor_loop(
|
|
949
|
+
repo,
|
|
950
|
+
roadmap,
|
|
951
|
+
poll_seconds=getattr(args, "poll_seconds", 60) or 60,
|
|
952
|
+
timeout_seconds=getattr(args, "timeout_seconds", None),
|
|
953
|
+
notify_command=getattr(args, "notify_command", None),
|
|
954
|
+
notify_on=tuple(getattr(args, "notify_on", ()) or ()),
|
|
955
|
+
once=bool(getattr(args, "once", False)),
|
|
956
|
+
as_json=as_json,
|
|
957
|
+
)
|
|
958
|
+
print(result["rendered"])
|
|
959
|
+
return int(result["returncode"])
|
|
960
|
+
if command == "status":
|
|
961
|
+
snapshot = status_snapshot(repo, roadmap, pipeline_mode=args.pipeline_mode or "standalone")
|
|
962
|
+
write_state(repo, snapshot)
|
|
963
|
+
write_tui_handoff(repo, roadmap, snapshot, action="status")
|
|
964
|
+
if bool(getattr(args, "tier_3_history", False)):
|
|
965
|
+
print(_tier_3_history(repo, as_json=as_json))
|
|
966
|
+
return 0
|
|
967
|
+
if bool(getattr(args, "runtime_projection", False)):
|
|
968
|
+
# DECOUPLE SL-1: lazy import keeps runtime_projection out of
|
|
969
|
+
# `import phase_loop_runtime.cli`.
|
|
970
|
+
from .runtime_projection import build_runtime_projection
|
|
971
|
+
|
|
972
|
+
projection = build_runtime_projection(
|
|
973
|
+
repo,
|
|
974
|
+
roadmap,
|
|
975
|
+
snapshot=snapshot,
|
|
976
|
+
pipeline_mode=args.pipeline_mode or "standalone",
|
|
977
|
+
)
|
|
978
|
+
print(json.dumps(projection, indent=2, sort_keys=True) if as_json else json.dumps(projection, sort_keys=True))
|
|
979
|
+
return 0
|
|
980
|
+
print(render_status(snapshot, as_json=as_json, ledger_debug=bool(getattr(args, "ledger_debug", False))))
|
|
981
|
+
return 0
|
|
982
|
+
if command == "handoff":
|
|
983
|
+
snapshot = reconcile(repo, roadmap)
|
|
984
|
+
write_state(repo, snapshot)
|
|
985
|
+
path = write_tui_handoff(repo, roadmap, snapshot, action="handoff")
|
|
986
|
+
if as_json:
|
|
987
|
+
print(render_handoff_json(path, repo))
|
|
988
|
+
else:
|
|
989
|
+
print(path.read_text(encoding="utf-8"), end="")
|
|
990
|
+
return 0
|
|
991
|
+
if command == "reconcile":
|
|
992
|
+
return _reconcile_command(repo=repo, roadmap=roadmap, args=args, as_json=as_json)
|
|
993
|
+
if command == "reopen":
|
|
994
|
+
return _reopen_command(repo=repo, roadmap=roadmap, args=args, as_json=as_json)
|
|
995
|
+
|
|
996
|
+
dry_run = command == "dry-run" or bool(args.dry_run)
|
|
997
|
+
model_profile = args.model_profile or ("skill-maintenance" if command == "maintain-skills" else None)
|
|
998
|
+
|
|
999
|
+
# Use local variables for parameters that might come from the 'execute' shim
|
|
1000
|
+
effective_phase = getattr(args, "phase_arg", None) or args.phase
|
|
1001
|
+
effective_source_bundle = getattr(args, "bundle", None) or args.source_bundle
|
|
1002
|
+
effective_output_path = getattr(args, "output", None)
|
|
1003
|
+
if lane_ir_override_kinds:
|
|
1004
|
+
if not effective_phase:
|
|
1005
|
+
parser.error("--allow-lane-ir-override requires --phase")
|
|
1006
|
+
_append_lane_ir_override_event(
|
|
1007
|
+
repo=repo,
|
|
1008
|
+
roadmap=roadmap,
|
|
1009
|
+
phase=str(effective_phase).upper(),
|
|
1010
|
+
diagnostic_kinds=lane_ir_override_kinds,
|
|
1011
|
+
reason=str(getattr(args, "reason", "")).strip(),
|
|
1012
|
+
)
|
|
1013
|
+
if allow_unowned_reason:
|
|
1014
|
+
# Bound the blast radius structurally: break-glass is single-phase only, and the
|
|
1015
|
+
# attestation event reconcile reads is phase-scoped.
|
|
1016
|
+
if not effective_phase:
|
|
1017
|
+
parser.error("--closeout-allow-unowned requires --phase (single-phase break-glass scope)")
|
|
1018
|
+
_append_closeout_allow_unowned_event(
|
|
1019
|
+
repo=repo,
|
|
1020
|
+
roadmap=roadmap,
|
|
1021
|
+
phase=str(effective_phase).upper(),
|
|
1022
|
+
reason=allow_unowned_reason,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
# --parallel-dispatch is the legacy serial coordinator-wave control; it maps
|
|
1026
|
+
# onto --phase-scheduler serialized so the two controls don't overlap.
|
|
1027
|
+
effective_phase_scheduler_mode = getattr(args, "phase_scheduler_mode", "off") or "off"
|
|
1028
|
+
if bool(getattr(args, "parallel_dispatch", False)):
|
|
1029
|
+
print(
|
|
1030
|
+
"warning: --parallel-dispatch is deprecated; use --phase-scheduler serialized.",
|
|
1031
|
+
file=sys.stderr,
|
|
1032
|
+
)
|
|
1033
|
+
if effective_phase_scheduler_mode == "off":
|
|
1034
|
+
effective_phase_scheduler_mode = "serialized"
|
|
1035
|
+
|
|
1036
|
+
# DECOUPLE SL-1: lazy import keeps the maintenance module out of
|
|
1037
|
+
# `import phase_loop_runtime.cli` (used here only to carry skill-maintenance
|
|
1038
|
+
# options through to the run loop).
|
|
1039
|
+
from .maintenance import MaintenanceOptions
|
|
1040
|
+
from .governed_review import resolve_run_mode
|
|
1041
|
+
|
|
1042
|
+
# model-routing-v2 P1: operator surfacing of run_mode. `--governed` (or
|
|
1043
|
+
# PHASE_LOOP_RUN_MODE=governed) opts into governed pre-merge review; default
|
|
1044
|
+
# is autonomous (no panel, byte-identical to today).
|
|
1045
|
+
run_mode = "governed" if bool(getattr(args, "governed", False)) else resolve_run_mode()
|
|
1046
|
+
|
|
1047
|
+
snapshot, results = run_loop(
|
|
1048
|
+
repo=repo,
|
|
1049
|
+
roadmap=roadmap,
|
|
1050
|
+
phase=effective_phase,
|
|
1051
|
+
max_phases=args.max_phases or 1,
|
|
1052
|
+
max_phases_explicit=args.max_phases is not None,
|
|
1053
|
+
full_phase=bool(getattr(args, "full_phase", False)),
|
|
1054
|
+
no_deprecation_hints=bool(getattr(args, "no_deprecation_hints", False)),
|
|
1055
|
+
model_profile=model_profile,
|
|
1056
|
+
model=args.model,
|
|
1057
|
+
effort=args.effort,
|
|
1058
|
+
dry_run=dry_run,
|
|
1059
|
+
json_output=as_json,
|
|
1060
|
+
action=command,
|
|
1061
|
+
executor=args.executor,
|
|
1062
|
+
allowed_executors=tuple(args.allow_executor or ()),
|
|
1063
|
+
fallback_executors=tuple(args.fallback_executor or ()),
|
|
1064
|
+
disabled_executors=tuple(args.disable_executor or ()),
|
|
1065
|
+
required_capabilities=tuple(args.require_capability or ()),
|
|
1066
|
+
observe=not bool(args.no_observe),
|
|
1067
|
+
stream_output=bool(args.stream_output),
|
|
1068
|
+
bypass_approvals=bool(args.bypass_approvals),
|
|
1069
|
+
heartbeat_interval_seconds=args.heartbeat_interval_seconds or 30,
|
|
1070
|
+
quiet_warning_seconds=args.quiet_warning_seconds or 600,
|
|
1071
|
+
quiet_blocker_seconds=args.quiet_blocker_seconds or 1800,
|
|
1072
|
+
heartbeat_enabled=not bool(args.no_heartbeat),
|
|
1073
|
+
closeout_mode=args.closeout_mode or "manual",
|
|
1074
|
+
enable_tier_3=bool(getattr(args, "enable_tier_3", False)),
|
|
1075
|
+
tier_3_budget=3 if getattr(args, "tier_3_budget", 3) is None else getattr(args, "tier_3_budget", 3),
|
|
1076
|
+
command_adapter_name=args.command_name,
|
|
1077
|
+
command_template=args.command_template,
|
|
1078
|
+
claude_execution_mode=args.claude_execution_mode,
|
|
1079
|
+
work_unit_mode=bool(args.work_unit_mode),
|
|
1080
|
+
rotate_executors=getattr(args, "rotate_executors", None),
|
|
1081
|
+
rotation_mode=getattr(args, "rotation_mode", None) or "phase",
|
|
1082
|
+
rotation_on_policy_pin=getattr(args, "rotation_on_policy_pin", None) or "skip",
|
|
1083
|
+
lane_scheduler_mode=args.lane_scheduler_mode or "off",
|
|
1084
|
+
source_bundle_path=effective_source_bundle,
|
|
1085
|
+
pipeline_mode=getattr(args, "pipeline_mode", None),
|
|
1086
|
+
output_path=effective_output_path,
|
|
1087
|
+
stuck_loop_iterations=getattr(args, "stuck_loop_iterations", 5),
|
|
1088
|
+
stuck_loop_minutes=getattr(args, "stuck_loop_minutes", 30),
|
|
1089
|
+
force_replan=bool(getattr(args, "force_replan", False)),
|
|
1090
|
+
dispatch_lock_enabled=dispatch_lock_enabled() and not bool(getattr(args, "no_dispatch_lock", False)),
|
|
1091
|
+
parallel_dispatch=bool(getattr(args, "parallel_dispatch", False)),
|
|
1092
|
+
phase_scheduler_mode=effective_phase_scheduler_mode,
|
|
1093
|
+
allow_cross_phase_dirty_reason=allow_cross_phase_dirty_reason,
|
|
1094
|
+
allow_unowned_reason=allow_unowned_reason,
|
|
1095
|
+
run_mode=run_mode,
|
|
1096
|
+
product_action_override=command if command in {"execute", "repair", "review"} else None,
|
|
1097
|
+
maintenance_options=MaintenanceOptions(
|
|
1098
|
+
min_reflections=getattr(args, "min_reflections", 2) or 2,
|
|
1099
|
+
apply_skill_edits=bool(getattr(args, "apply_skill_edits", False)),
|
|
1100
|
+
allow_skills=tuple(getattr(args, "allow_skill", ()) or ()),
|
|
1101
|
+
improvement_plan=Path(args.improvement_plan).expanduser().resolve() if getattr(args, "improvement_plan", None) else None,
|
|
1102
|
+
),
|
|
1103
|
+
)
|
|
1104
|
+
if as_json:
|
|
1105
|
+
print(render_status(snapshot, as_json=True))
|
|
1106
|
+
else:
|
|
1107
|
+
for result in results:
|
|
1108
|
+
if result.executor:
|
|
1109
|
+
print("Executor:", result.executor)
|
|
1110
|
+
if result.injection_mode:
|
|
1111
|
+
print("Injection mode:", result.injection_mode)
|
|
1112
|
+
if result.expected_skill_pack:
|
|
1113
|
+
print("Expected skill pack:", ", ".join(result.expected_skill_pack))
|
|
1114
|
+
print("Command:", " ".join(result.command))
|
|
1115
|
+
if result.log_path:
|
|
1116
|
+
print("Log:", result.log_path)
|
|
1117
|
+
print(render_status(snapshot, as_json=False))
|
|
1118
|
+
return _run_returncode(snapshot, results)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _parse_lane_ir_override(parser: argparse.ArgumentParser, args: argparse.Namespace, command: str) -> tuple[str, ...]:
|
|
1122
|
+
raw = getattr(args, "allow_lane_ir_override", None)
|
|
1123
|
+
if raw is None:
|
|
1124
|
+
return ()
|
|
1125
|
+
if command not in {"run", "resume", "dry-run"}:
|
|
1126
|
+
parser.error("--allow-lane-ir-override is only valid for run, resume, and dry-run")
|
|
1127
|
+
reason = str(getattr(args, "reason", "") or "").strip()
|
|
1128
|
+
if not reason:
|
|
1129
|
+
parser.error("--allow-lane-ir-override requires --reason (blocker_class=operator_override_missing_reason)")
|
|
1130
|
+
kinds = tuple(kind.strip() for kind in str(raw).split(",") if kind.strip())
|
|
1131
|
+
if not kinds:
|
|
1132
|
+
parser.error("--allow-lane-ir-override requires at least one diagnostic kind")
|
|
1133
|
+
unsupported = tuple(kind for kind in kinds if kind not in LANE_IR_DIAGNOSTIC_KINDS)
|
|
1134
|
+
if unsupported:
|
|
1135
|
+
parser.error(f"unsupported lane-IR diagnostic override kind(s): {', '.join(unsupported)}")
|
|
1136
|
+
return tuple(dict.fromkeys(kinds))
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _append_lane_ir_override_event(
|
|
1140
|
+
*,
|
|
1141
|
+
repo: Path,
|
|
1142
|
+
roadmap: Path,
|
|
1143
|
+
phase: str,
|
|
1144
|
+
diagnostic_kinds: tuple[str, ...],
|
|
1145
|
+
reason: str,
|
|
1146
|
+
) -> None:
|
|
1147
|
+
plan = find_plan_artifact(repo, phase, roadmap=roadmap)
|
|
1148
|
+
append_event(
|
|
1149
|
+
repo,
|
|
1150
|
+
LoopEvent(
|
|
1151
|
+
timestamp=utc_now(),
|
|
1152
|
+
repo=str(repo),
|
|
1153
|
+
roadmap=str(roadmap),
|
|
1154
|
+
phase=phase,
|
|
1155
|
+
action="lane_ir_override",
|
|
1156
|
+
status="planned",
|
|
1157
|
+
model="operator",
|
|
1158
|
+
reasoning_effort="manual",
|
|
1159
|
+
source="cli",
|
|
1160
|
+
override_reason=reason,
|
|
1161
|
+
metadata={
|
|
1162
|
+
"runner.lane_ir_override_invoked": {
|
|
1163
|
+
"diagnostic_kinds_overridden": list(diagnostic_kinds),
|
|
1164
|
+
"plan_path": str(plan) if plan else None,
|
|
1165
|
+
"operator_reason": reason,
|
|
1166
|
+
}
|
|
1167
|
+
},
|
|
1168
|
+
**event_provenance(roadmap, phase),
|
|
1169
|
+
),
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _append_closeout_allow_unowned_event(
|
|
1174
|
+
*,
|
|
1175
|
+
repo: Path,
|
|
1176
|
+
roadmap: Path,
|
|
1177
|
+
phase: str,
|
|
1178
|
+
reason: str,
|
|
1179
|
+
) -> None:
|
|
1180
|
+
# BREAKGLASS attestation (IF-0-BREAKGLASS-1c): reconcile (SL-2) reads this to lift
|
|
1181
|
+
# the unowned_dirty_paths bail, scoped by roadmap_sha256/phase_sha256 (via
|
|
1182
|
+
# event_provenance) + phase + non-empty operator_reason — exactly like the lane-IR
|
|
1183
|
+
# override. A stale attestation (content drifted) no longer matches and does not
|
|
1184
|
+
# authorize a later closeout.
|
|
1185
|
+
plan = find_plan_artifact(repo, phase, roadmap=roadmap)
|
|
1186
|
+
append_event(
|
|
1187
|
+
repo,
|
|
1188
|
+
LoopEvent(
|
|
1189
|
+
timestamp=utc_now(),
|
|
1190
|
+
repo=str(repo),
|
|
1191
|
+
roadmap=str(roadmap),
|
|
1192
|
+
phase=phase,
|
|
1193
|
+
action="closeout_allow_unowned",
|
|
1194
|
+
status="planned",
|
|
1195
|
+
model="operator",
|
|
1196
|
+
reasoning_effort="manual",
|
|
1197
|
+
source="cli",
|
|
1198
|
+
override_reason=reason,
|
|
1199
|
+
metadata={
|
|
1200
|
+
"runner.closeout_allow_unowned_invoked": {
|
|
1201
|
+
"plan_path": str(plan) if plan else None,
|
|
1202
|
+
"operator_reason": reason,
|
|
1203
|
+
}
|
|
1204
|
+
},
|
|
1205
|
+
**event_provenance(roadmap, phase),
|
|
1206
|
+
),
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _format_roadmap_validation_warning(finding: ValidationFinding) -> str:
|
|
1211
|
+
return (
|
|
1212
|
+
"phase-loop roadmap warning: "
|
|
1213
|
+
f"line {finding.line_number}: {finding.reason}; "
|
|
1214
|
+
f"raw heading: {finding.raw_text!r}; "
|
|
1215
|
+
f"suggested fix: {finding.suggested_fix}"
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _warn_roadmap_validation(roadmap: Path) -> None:
|
|
1220
|
+
for finding in validate_roadmap_phase_headings(roadmap):
|
|
1221
|
+
print(_format_roadmap_validation_warning(finding), file=sys.stderr)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _tier_3_history(repo: Path, *, as_json: bool = False, limit: int = 10) -> str:
|
|
1225
|
+
records: list[dict[str, object]] = []
|
|
1226
|
+
for event in read_events(repo):
|
|
1227
|
+
if event.get("action") != "evidence_audit_tier3":
|
|
1228
|
+
continue
|
|
1229
|
+
metadata = event.get("metadata")
|
|
1230
|
+
if not isinstance(metadata, dict):
|
|
1231
|
+
metadata = {}
|
|
1232
|
+
records.append(
|
|
1233
|
+
{
|
|
1234
|
+
"timestamp": event.get("timestamp"),
|
|
1235
|
+
"phase": event.get("phase"),
|
|
1236
|
+
"verdict": metadata.get("verdict"),
|
|
1237
|
+
"confidence": metadata.get("confidence"),
|
|
1238
|
+
"estimated_cost_usd": metadata.get("estimated_cost_usd"),
|
|
1239
|
+
"latency_ms": metadata.get("latency_ms"),
|
|
1240
|
+
}
|
|
1241
|
+
)
|
|
1242
|
+
records = records[-limit:]
|
|
1243
|
+
if as_json:
|
|
1244
|
+
return json.dumps({"tier_3_history": records}, indent=2, sort_keys=True)
|
|
1245
|
+
if not records:
|
|
1246
|
+
return "Tier 3 history: no evidence_audit_tier3 events recorded."
|
|
1247
|
+
lines = ["Tier 3 history:"]
|
|
1248
|
+
for record in records:
|
|
1249
|
+
cost = record["estimated_cost_usd"]
|
|
1250
|
+
cost_text = "unknown" if cost is None else str(cost)
|
|
1251
|
+
latency = record["latency_ms"]
|
|
1252
|
+
latency_text = "unknown" if latency is None else f"{latency}ms"
|
|
1253
|
+
lines.append(
|
|
1254
|
+
" "
|
|
1255
|
+
f"{record['timestamp']} phase={record['phase']} verdict={record['verdict']} "
|
|
1256
|
+
f"confidence={record['confidence']} cost_usd={cost_text} latency={latency_text}"
|
|
1257
|
+
)
|
|
1258
|
+
return "\n".join(lines)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _run_returncode(snapshot: StateSnapshot, results: list) -> int:
|
|
1262
|
+
"""Exit-code policy for `run` / `dry-run` / `execute` / `repair` / `review`.
|
|
1263
|
+
|
|
1264
|
+
0 = success (all phases advanced cleanly).
|
|
1265
|
+
1 = at least one child launch failed (auth, sandbox, exec error).
|
|
1266
|
+
5 = no launch failure, but the run terminated with a blocker — distinct
|
|
1267
|
+
so bash rotation wrappers can stop polling stdout for
|
|
1268
|
+
`Terminal status: blocked`.
|
|
1269
|
+
"""
|
|
1270
|
+
if any(result.failed for result in results):
|
|
1271
|
+
return 1
|
|
1272
|
+
terminal_summary = snapshot.terminal_summary if isinstance(snapshot.terminal_summary, dict) else None
|
|
1273
|
+
if terminal_summary and terminal_summary.get("terminal_status") == "blocked":
|
|
1274
|
+
return 5
|
|
1275
|
+
if snapshot.blocker_class:
|
|
1276
|
+
return 5
|
|
1277
|
+
return 0
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _adoption_bundle_command(*, repo: Path, action: str, as_json: bool) -> int:
|
|
1281
|
+
# DECOUPLE SL-1: dotfiles-domain import is lazy so `import phase_loop_runtime.cli`
|
|
1282
|
+
# does not pull in adoption_bundle.
|
|
1283
|
+
from .adoption_bundle import adoption_bundle_status, refresh_adoption_bundle
|
|
1284
|
+
|
|
1285
|
+
try:
|
|
1286
|
+
if action == "status":
|
|
1287
|
+
payload = adoption_bundle_status(repo)
|
|
1288
|
+
code = 0 if payload["status"] == "fresh" else 1
|
|
1289
|
+
elif action == "refresh":
|
|
1290
|
+
payload = refresh_adoption_bundle(repo)
|
|
1291
|
+
code = 0
|
|
1292
|
+
else:
|
|
1293
|
+
raise ValueError(f"unknown adoption-bundle action: {action}")
|
|
1294
|
+
except Exception as exc:
|
|
1295
|
+
payload = {
|
|
1296
|
+
"status": "error",
|
|
1297
|
+
"bundle": "docs/adoption/dotfiles-adoption-bundle.json",
|
|
1298
|
+
"error": str(exc),
|
|
1299
|
+
}
|
|
1300
|
+
code = 2 if action == "status" else 1
|
|
1301
|
+
if as_json:
|
|
1302
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1303
|
+
else:
|
|
1304
|
+
print(f"adoption-bundle {action}: {payload['status']} {payload['bundle']}")
|
|
1305
|
+
if payload.get("error"):
|
|
1306
|
+
print(f"error: {payload['error']}", file=sys.stderr)
|
|
1307
|
+
return code
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _sync_skills_command(*, repo: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
1311
|
+
# DECOUPLE SL-1: lazy dotfiles-domain import.
|
|
1312
|
+
from .maintenance import SyncSkillsOptions, sync_bridge_skills
|
|
1313
|
+
|
|
1314
|
+
harnesses = tuple(args.harness or ("codex", "claude", "gemini", "opencode"))
|
|
1315
|
+
summary = sync_bridge_skills(repo, SyncSkillsOptions(harnesses=harnesses, apply=bool(args.apply)))
|
|
1316
|
+
print(render_skill_sync_result(summary, as_json=as_json))
|
|
1317
|
+
blocker = summary.get("blocker")
|
|
1318
|
+
if isinstance(blocker, dict) and blocker.get("blocker_class"):
|
|
1319
|
+
return 1
|
|
1320
|
+
# #14: `--apply` must never mimic `--check` with a silent exit 0. When it
|
|
1321
|
+
# could not repair some bridge skills, always print the loud remediation (the
|
|
1322
|
+
# per-skill listing is already emitted by render_skill_sync_result above);
|
|
1323
|
+
# fail loud (exit 1) only on a genuine TOTAL no-op — nothing repaired — so a
|
|
1324
|
+
# partial repair doesn't hard-fail pipelines on a host that uses only some of
|
|
1325
|
+
# the default harnesses.
|
|
1326
|
+
unrepaired = summary.get("unrepaired") or []
|
|
1327
|
+
if bool(args.apply) and isinstance(unrepaired, list) and unrepaired:
|
|
1328
|
+
repaired_n = len(summary.get("changed") or [])
|
|
1329
|
+
print(
|
|
1330
|
+
f"sync-skills --apply: repaired {repaired_n}, could NOT repair {len(unrepaired)} "
|
|
1331
|
+
"bridge skill(s) (see the listing above). A normal pinned `pip install` ships the "
|
|
1332
|
+
"assembled skill bundle and resolves it automatically. Otherwise re-run "
|
|
1333
|
+
"`bootstrap.sh`, or `pip install -e ~/code/agent-harness/phase-loop-runtime`, or "
|
|
1334
|
+
"set PHASE_LOOP_SKILL_SOURCE_PLUGINS together with PHASE_LOOP_RUNNER_REPO_ROOT "
|
|
1335
|
+
"(the anchor for the built-in provider's relative roots).",
|
|
1336
|
+
file=sys.stderr,
|
|
1337
|
+
)
|
|
1338
|
+
if repaired_n == 0:
|
|
1339
|
+
return 1
|
|
1340
|
+
return 0
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _build_bundle_command(*, repo: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
1344
|
+
# DECOUPLE SL-1: lazy dotfiles-domain import.
|
|
1345
|
+
from .build_bundle import DEFAULT_SOURCES, build_bundle
|
|
1346
|
+
|
|
1347
|
+
sources = list(args.source or DEFAULT_SOURCES.values())
|
|
1348
|
+
resolved_sources = [repo / source if not Path(source).is_absolute() else Path(source) for source in sources]
|
|
1349
|
+
destination = Path(args.destination)
|
|
1350
|
+
if not destination.is_absolute():
|
|
1351
|
+
destination = repo / destination
|
|
1352
|
+
dry_run = bool(args.dry_run or not args.apply)
|
|
1353
|
+
result = build_bundle(
|
|
1354
|
+
resolved_sources,
|
|
1355
|
+
destination,
|
|
1356
|
+
dry_run=dry_run,
|
|
1357
|
+
apply=bool(args.apply),
|
|
1358
|
+
force=bool(args.force),
|
|
1359
|
+
)
|
|
1360
|
+
if as_json or dry_run:
|
|
1361
|
+
print(result.to_json())
|
|
1362
|
+
else:
|
|
1363
|
+
verb = "applied" if result.applied else "planned"
|
|
1364
|
+
print(f"build-bundle {verb}: {len(result.files_written)} file changes")
|
|
1365
|
+
for skill in result.skills_regenerated:
|
|
1366
|
+
print(f"regenerated\t{skill}")
|
|
1367
|
+
for path in result.overrides_written:
|
|
1368
|
+
print(f"override\t{path}")
|
|
1369
|
+
for skipped in result.skills_skipped:
|
|
1370
|
+
print(f"skipped\t{skipped.skill}\tmissing={','.join(skipped.missing_harnesses)}")
|
|
1371
|
+
for warning in result.warnings:
|
|
1372
|
+
print(f"warning\t{warning.skill}\t{warning.message}")
|
|
1373
|
+
return 0
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _hotfix_command(*, repo: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
1377
|
+
init_stub = getattr(args, "init_stub", None)
|
|
1378
|
+
if init_stub:
|
|
1379
|
+
path = _repo_relative_path(repo, init_stub)
|
|
1380
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1381
|
+
path.write_text(
|
|
1382
|
+
"objective: TODO\n"
|
|
1383
|
+
"verification_command: TODO\n",
|
|
1384
|
+
encoding="utf-8",
|
|
1385
|
+
)
|
|
1386
|
+
payload = {"status": "stub_initialized", "plan_stub": str(path), "executed": False}
|
|
1387
|
+
print(json.dumps(payload, indent=2, sort_keys=True) if as_json else f"hotfix stub initialized: {path}")
|
|
1388
|
+
return 0
|
|
1389
|
+
reason = str(getattr(args, "reason", "") or "").strip()
|
|
1390
|
+
if not reason:
|
|
1391
|
+
print("phase-loop hotfix: --reason is required unless --init-stub is used", file=sys.stderr)
|
|
1392
|
+
return 2
|
|
1393
|
+
plan_arg = getattr(args, "plan", None)
|
|
1394
|
+
if not plan_arg:
|
|
1395
|
+
print("phase-loop hotfix: --plan is required unless --init-stub is used", file=sys.stderr)
|
|
1396
|
+
return 2
|
|
1397
|
+
plan_stub = _repo_relative_path(repo, plan_arg)
|
|
1398
|
+
if not plan_stub.exists():
|
|
1399
|
+
print(f"phase-loop hotfix: plan stub not found: {plan_stub}", file=sys.stderr)
|
|
1400
|
+
return 2
|
|
1401
|
+
roadmap = select_roadmap(repo, args.roadmap)
|
|
1402
|
+
_warn_roadmap_validation(roadmap)
|
|
1403
|
+
commands = _hotfix_verification_commands(plan_stub)
|
|
1404
|
+
artifacts = hotfix_run_artifacts(repo, reason, plan_stub)
|
|
1405
|
+
manifests = detect_changed_dependency_manifests(repo, "HEAD")
|
|
1406
|
+
install_argv = resolve_install_command(repo, manifests) if manifests else None
|
|
1407
|
+
env_refresh = (
|
|
1408
|
+
{"triggered": True, "manifests": manifests, "install_argv": install_argv or [], "exit_code": 127}
|
|
1409
|
+
if manifests and install_argv is None
|
|
1410
|
+
else ({"triggered": True, "manifests": manifests, "install_argv": install_argv} if manifests else None)
|
|
1411
|
+
)
|
|
1412
|
+
suite_command = resolve_suite_command(repo, roadmap, None)
|
|
1413
|
+
run_verification(
|
|
1414
|
+
repo,
|
|
1415
|
+
artifacts["root"],
|
|
1416
|
+
commands,
|
|
1417
|
+
suite_command,
|
|
1418
|
+
env_refresh,
|
|
1419
|
+
float(os.environ.get("PHASE_LOOP_VERIFY_TIMEOUT_SECONDS", "1200")),
|
|
1420
|
+
)
|
|
1421
|
+
validation = validate_verification_artifact(artifacts["verification_artifact"])
|
|
1422
|
+
validation_json = validation.to_json()
|
|
1423
|
+
status = "complete" if validation.ok else "blocked"
|
|
1424
|
+
verification_status = "passed" if validation.ok else "blocked"
|
|
1425
|
+
blocker = None if validation.ok else {"blocker_class": "verification_evidence_missing", "blocker_summary": f"Hotfix verification failed: {validation.code}"}
|
|
1426
|
+
terminal_summary = build_terminal_summary(
|
|
1427
|
+
terminal_status=status,
|
|
1428
|
+
terminal_blocker=blocker,
|
|
1429
|
+
verification_status=verification_status,
|
|
1430
|
+
next_action="hotfix verification complete" if validation.ok else "repair hotfix verification failure",
|
|
1431
|
+
artifact_paths={
|
|
1432
|
+
"root": str(artifacts["root"]),
|
|
1433
|
+
"verification_artifact_path": str(artifacts["verification_artifact"]),
|
|
1434
|
+
"verification_log_path": str(artifacts["verification_log"]),
|
|
1435
|
+
},
|
|
1436
|
+
work_unit={"work_unit": "hotfix", "plan_stub": str(plan_stub)},
|
|
1437
|
+
)
|
|
1438
|
+
append_event(
|
|
1439
|
+
repo,
|
|
1440
|
+
LoopEvent(
|
|
1441
|
+
timestamp=utc_now(),
|
|
1442
|
+
repo=str(repo),
|
|
1443
|
+
roadmap=str(roadmap),
|
|
1444
|
+
phase="HOTFIX",
|
|
1445
|
+
action="hotfix.closeout",
|
|
1446
|
+
status=status,
|
|
1447
|
+
model="command",
|
|
1448
|
+
reasoning_effort="manual",
|
|
1449
|
+
source="cli",
|
|
1450
|
+
selected_executor="command",
|
|
1451
|
+
blocker=blocker,
|
|
1452
|
+
metadata={
|
|
1453
|
+
"work_unit": "hotfix",
|
|
1454
|
+
"hotfix_closeout": {
|
|
1455
|
+
"work_unit": "hotfix",
|
|
1456
|
+
"reason": _redact_hotfix_reason(reason),
|
|
1457
|
+
"plan_stub": str(plan_stub),
|
|
1458
|
+
"verification_artifact_path": str(artifacts["verification_artifact"]),
|
|
1459
|
+
"verification_log_path": str(artifacts["verification_log"]),
|
|
1460
|
+
"verification_exit_summary": validation_json.get("exit_summary", {}),
|
|
1461
|
+
"artifact_validation": validation_json,
|
|
1462
|
+
},
|
|
1463
|
+
"terminal_summary": terminal_summary,
|
|
1464
|
+
},
|
|
1465
|
+
**event_provenance(roadmap, "HOTFIX"),
|
|
1466
|
+
),
|
|
1467
|
+
)
|
|
1468
|
+
metric = build_work_unit_metric(
|
|
1469
|
+
repo=repo,
|
|
1470
|
+
phase="HOTFIX",
|
|
1471
|
+
action="execute",
|
|
1472
|
+
launch_metadata={
|
|
1473
|
+
"executor": "command",
|
|
1474
|
+
"selected_model": "phase-loop",
|
|
1475
|
+
"execution_policy": {"work_unit_kind": "lane_execute", "effort": "medium", "execution_policy_source": "hotfix cli"},
|
|
1476
|
+
},
|
|
1477
|
+
terminal_summary=terminal_summary,
|
|
1478
|
+
artifact_paths={
|
|
1479
|
+
"verification_artifact_path": str(artifacts["verification_artifact"]),
|
|
1480
|
+
"verification_log_path": str(artifacts["verification_log"]),
|
|
1481
|
+
},
|
|
1482
|
+
)
|
|
1483
|
+
append_work_unit_metric(repo, metric)
|
|
1484
|
+
payload = {
|
|
1485
|
+
"status": status,
|
|
1486
|
+
"verification_status": verification_status,
|
|
1487
|
+
"work_unit": "hotfix",
|
|
1488
|
+
"run_root": str(artifacts["root"]),
|
|
1489
|
+
"plan_stub": str(plan_stub),
|
|
1490
|
+
"verification_artifact_path": str(artifacts["verification_artifact"]),
|
|
1491
|
+
"verification_log_path": str(artifacts["verification_log"]),
|
|
1492
|
+
"artifact_validation": validation_json,
|
|
1493
|
+
}
|
|
1494
|
+
print(json.dumps(payload, indent=2, sort_keys=True) if as_json else f"hotfix {status}: {artifacts['root']}")
|
|
1495
|
+
return 0 if validation.ok else 1
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _repo_relative_path(repo: Path, value: str) -> Path:
|
|
1499
|
+
path = Path(value)
|
|
1500
|
+
return path if path.is_absolute() else repo / path
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _hotfix_verification_commands(plan_stub: Path) -> list[list[str]]:
|
|
1504
|
+
commands: list[list[str]] = []
|
|
1505
|
+
in_list = False
|
|
1506
|
+
for raw in plan_stub.read_text(encoding="utf-8").splitlines():
|
|
1507
|
+
stripped = raw.strip()
|
|
1508
|
+
if not stripped or stripped.startswith("#"):
|
|
1509
|
+
continue
|
|
1510
|
+
if stripped.startswith("verification_command:"):
|
|
1511
|
+
command = stripped.split(":", 1)[1].strip()
|
|
1512
|
+
if command and command != "TODO":
|
|
1513
|
+
commands.append(shlex.split(command))
|
|
1514
|
+
in_list = False
|
|
1515
|
+
continue
|
|
1516
|
+
if stripped.startswith("verification_commands:"):
|
|
1517
|
+
in_list = True
|
|
1518
|
+
continue
|
|
1519
|
+
if in_list and stripped.startswith("- "):
|
|
1520
|
+
command = stripped[2:].strip()
|
|
1521
|
+
if command:
|
|
1522
|
+
commands.append(shlex.split(command))
|
|
1523
|
+
return commands
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _redact_hotfix_reason(reason: str) -> str:
|
|
1527
|
+
return " ".join(reason.split())[:200]
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _migrate_events_command(*, repo: Path, dry_run: bool, backup_suffix: str) -> int:
|
|
1531
|
+
try:
|
|
1532
|
+
result = migrate_ledger(repo, dry_run=dry_run, backup_suffix=backup_suffix)
|
|
1533
|
+
except MigrationError as exc:
|
|
1534
|
+
print(f"phase-loop migrate-events: {exc}", file=sys.stderr)
|
|
1535
|
+
return 2
|
|
1536
|
+
print(json.dumps(result.to_json(), sort_keys=True))
|
|
1537
|
+
return 0
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _init_command(*, repo: Path, dry_run: bool, as_json: bool, install_hooks: bool) -> int:
|
|
1541
|
+
gitignore = repo / ".gitignore"
|
|
1542
|
+
handoffs = repo / ".dev-skills" / "handoffs"
|
|
1543
|
+
hook_source = repo / ".githooks" / "pre-commit-adoption-bundle"
|
|
1544
|
+
hook_target = repo / ".git" / "hooks" / "pre-commit"
|
|
1545
|
+
entry = "/.dev-skills/"
|
|
1546
|
+
existing = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.exists() else []
|
|
1547
|
+
needs_entry = entry not in existing
|
|
1548
|
+
needs_handoffs = not handoffs.is_dir()
|
|
1549
|
+
hook_installable = hook_source.exists()
|
|
1550
|
+
needs_hook = install_hooks and (not hook_target.exists() or hook_target.read_text(encoding="utf-8") != hook_source.read_text(encoding="utf-8"))
|
|
1551
|
+
actions = {
|
|
1552
|
+
"repo": str(repo),
|
|
1553
|
+
"dry_run": dry_run,
|
|
1554
|
+
"gitignore": str(gitignore),
|
|
1555
|
+
"gitignore_entry": entry,
|
|
1556
|
+
"gitignore_changed": needs_entry,
|
|
1557
|
+
"handoffs": str(handoffs),
|
|
1558
|
+
"handoffs_created": needs_handoffs,
|
|
1559
|
+
"install_hooks": install_hooks,
|
|
1560
|
+
"hook_source": str(hook_source),
|
|
1561
|
+
"hook_target": str(hook_target),
|
|
1562
|
+
"hook_installable": hook_installable,
|
|
1563
|
+
"hook_changed": needs_hook,
|
|
1564
|
+
}
|
|
1565
|
+
if install_hooks and not hook_installable:
|
|
1566
|
+
actions["error"] = f"hook source not found: {hook_source}"
|
|
1567
|
+
if as_json:
|
|
1568
|
+
print(json.dumps(actions, indent=2, sort_keys=True))
|
|
1569
|
+
else:
|
|
1570
|
+
print(f"phase-loop init: hook source not found: {hook_source}", file=sys.stderr)
|
|
1571
|
+
return 2
|
|
1572
|
+
if not dry_run:
|
|
1573
|
+
if needs_entry:
|
|
1574
|
+
lines = list(existing)
|
|
1575
|
+
lines.append(entry)
|
|
1576
|
+
gitignore.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
1577
|
+
handoffs.mkdir(parents=True, exist_ok=True)
|
|
1578
|
+
if needs_hook:
|
|
1579
|
+
hook_target.parent.mkdir(parents=True, exist_ok=True)
|
|
1580
|
+
hook_target.write_text(hook_source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
1581
|
+
hook_target.chmod(0o755)
|
|
1582
|
+
if as_json:
|
|
1583
|
+
print(json.dumps(actions, indent=2, sort_keys=True))
|
|
1584
|
+
else:
|
|
1585
|
+
mode = "would update" if dry_run else "updated"
|
|
1586
|
+
if not needs_entry and not needs_handoffs and not needs_hook:
|
|
1587
|
+
mode = "already initialized"
|
|
1588
|
+
print(f"phase-loop init: {mode} {repo}")
|
|
1589
|
+
print(f"gitignore_entry: {entry} ({'needed' if needs_entry else 'present'})")
|
|
1590
|
+
print(f"handoffs: {handoffs} ({'needed' if needs_handoffs else 'present'})")
|
|
1591
|
+
print(f"hooks: {hook_target} ({'needed' if needs_hook else 'not requested' if not install_hooks else 'present'})")
|
|
1592
|
+
return 0
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def _consiliency_scaffold_command(*, repo: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
1596
|
+
archetypes = tuple(dict.fromkeys(getattr(args, "archetype", None) or ()))
|
|
1597
|
+
modifiers = tuple(dict.fromkeys(getattr(args, "modifier", None) or ()))
|
|
1598
|
+
baseline_only = bool(getattr(args, "baseline_only", False))
|
|
1599
|
+
if baseline_only and (archetypes or modifiers):
|
|
1600
|
+
print("phase-loop consiliency-scaffold: --baseline-only is mutually exclusive with --archetype/--modifier", file=sys.stderr)
|
|
1601
|
+
return 2
|
|
1602
|
+
if not baseline_only and not archetypes:
|
|
1603
|
+
print("phase-loop consiliency-scaffold: pass --archetype <name> (repeatable) or --baseline-only", file=sys.stderr)
|
|
1604
|
+
return 2
|
|
1605
|
+
try:
|
|
1606
|
+
result = scaffold(
|
|
1607
|
+
repo,
|
|
1608
|
+
mode="baseline-only" if baseline_only else "archetyped",
|
|
1609
|
+
archetypes=archetypes,
|
|
1610
|
+
modifiers=modifiers,
|
|
1611
|
+
repo_id=getattr(args, "repo_id", None),
|
|
1612
|
+
display_name=getattr(args, "display_name", None),
|
|
1613
|
+
dry_run=bool(args.dry_run),
|
|
1614
|
+
)
|
|
1615
|
+
except ScaffoldError as exc:
|
|
1616
|
+
print(f"phase-loop consiliency-scaffold: {exc}", file=sys.stderr)
|
|
1617
|
+
return 2
|
|
1618
|
+
payload = result.to_json()
|
|
1619
|
+
if as_json:
|
|
1620
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1621
|
+
else:
|
|
1622
|
+
mode = "would scaffold" if result.dry_run else ("already present" if result.already_present else "scaffolded")
|
|
1623
|
+
print(f"phase-loop consiliency-scaffold: {mode} {result.manifest_path}")
|
|
1624
|
+
for created in result.created_paths:
|
|
1625
|
+
print(f" created: {created}")
|
|
1626
|
+
for referenced in result.referenced_paths:
|
|
1627
|
+
print(f" referenced (already existed): {referenced}")
|
|
1628
|
+
for missing in result.declared_missing_paths:
|
|
1629
|
+
print(f" declared, not authored (no fake content -- see presence gate): {missing}")
|
|
1630
|
+
return 0
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def _consiliency_ingest_command(*, repo: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
1634
|
+
archetypes = tuple(dict.fromkeys(getattr(args, "archetype", None) or ()))
|
|
1635
|
+
modifiers = tuple(dict.fromkeys(getattr(args, "modifier", None) or ()))
|
|
1636
|
+
baseline_only = bool(getattr(args, "baseline_only", False))
|
|
1637
|
+
if baseline_only and (archetypes or modifiers):
|
|
1638
|
+
print("phase-loop consiliency-ingest: --baseline-only is mutually exclusive with --archetype/--modifier", file=sys.stderr)
|
|
1639
|
+
return 2
|
|
1640
|
+
try:
|
|
1641
|
+
result = ingest(
|
|
1642
|
+
repo,
|
|
1643
|
+
adopt=bool(getattr(args, "adopt", False)),
|
|
1644
|
+
mode="baseline-only" if baseline_only else "archetyped",
|
|
1645
|
+
archetypes=archetypes,
|
|
1646
|
+
modifiers=modifiers,
|
|
1647
|
+
repo_id=getattr(args, "repo_id", None),
|
|
1648
|
+
display_name=getattr(args, "display_name", None),
|
|
1649
|
+
dry_run=bool(args.dry_run),
|
|
1650
|
+
)
|
|
1651
|
+
except ScaffoldError as exc:
|
|
1652
|
+
print(f"phase-loop consiliency-ingest: {exc}", file=sys.stderr)
|
|
1653
|
+
return 2
|
|
1654
|
+
payload = result.to_json()
|
|
1655
|
+
if as_json:
|
|
1656
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1657
|
+
else:
|
|
1658
|
+
print(f"phase-loop consiliency-ingest: {result.mode} {result.manifest_path}")
|
|
1659
|
+
if result.mode == "skipped":
|
|
1660
|
+
print(" no .consiliency/manifest and --adopt was not passed; repo left untouched")
|
|
1661
|
+
if result.mode == "shape":
|
|
1662
|
+
created = (result.scaffold or {}).get("created_paths", [])
|
|
1663
|
+
print(f" scaffolded {len(created)} doc(s); proposed governed_set entries: {len(result.governed_set)}")
|
|
1664
|
+
if result.mode == "verify" and result.gate_scan is not None:
|
|
1665
|
+
print(f" gate scan: {result.gate_scan.get('status')}")
|
|
1666
|
+
for finding in result.findings:
|
|
1667
|
+
print(f" finding: {finding.get('code')} ({finding.get('path')})")
|
|
1668
|
+
return 0
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _consiliency_lease_command(*, repo: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
1672
|
+
from .lease_store import LeaseStore
|
|
1673
|
+
|
|
1674
|
+
action = args.action
|
|
1675
|
+
store = LeaseStore(repo)
|
|
1676
|
+
now = getattr(args, "now", None)
|
|
1677
|
+
|
|
1678
|
+
if action in ("acquire", "renew", "release") and not (args.lease_id and args.holder):
|
|
1679
|
+
print(f"phase-loop consiliency-lease {action}: --lease-id and --holder are required", file=sys.stderr)
|
|
1680
|
+
return 2
|
|
1681
|
+
|
|
1682
|
+
if action == "acquire":
|
|
1683
|
+
scope = {"granularity": args.granularity, "selector": list(args.scope)}
|
|
1684
|
+
result = store.acquire(
|
|
1685
|
+
lease_id=args.lease_id,
|
|
1686
|
+
holder=args.holder,
|
|
1687
|
+
ttl_seconds=args.ttl_seconds,
|
|
1688
|
+
mode=args.mode,
|
|
1689
|
+
scope=scope,
|
|
1690
|
+
phase=args.lease_phase,
|
|
1691
|
+
now=now,
|
|
1692
|
+
)
|
|
1693
|
+
payload = result.to_json()
|
|
1694
|
+
if as_json:
|
|
1695
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1696
|
+
elif result.granted:
|
|
1697
|
+
note = " (degraded to soft)" if result.degraded else ""
|
|
1698
|
+
print(f"phase-loop consiliency-lease acquire: granted{note} {args.lease_id} to {args.holder}")
|
|
1699
|
+
else:
|
|
1700
|
+
conflict = result.conflict or {}
|
|
1701
|
+
print(
|
|
1702
|
+
f"phase-loop consiliency-lease acquire: conflict -- give_way={result.give_way} "
|
|
1703
|
+
f"held by {conflict.get('holder')} (lease {conflict.get('lease_id')})"
|
|
1704
|
+
)
|
|
1705
|
+
return 0 if result.granted else 1
|
|
1706
|
+
|
|
1707
|
+
if action == "renew":
|
|
1708
|
+
result = store.renew(lease_id=args.lease_id, holder=args.holder, now=now)
|
|
1709
|
+
payload = result.to_json()
|
|
1710
|
+
if as_json:
|
|
1711
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1712
|
+
elif result.renewed:
|
|
1713
|
+
print(f"phase-loop consiliency-lease renew: renewed {args.lease_id}")
|
|
1714
|
+
else:
|
|
1715
|
+
print(f"phase-loop consiliency-lease renew: rejected ({result.reason})")
|
|
1716
|
+
return 0 if result.renewed else 1
|
|
1717
|
+
|
|
1718
|
+
if action == "release":
|
|
1719
|
+
result = store.release(lease_id=args.lease_id, holder=args.holder, now=now)
|
|
1720
|
+
payload = result.to_json()
|
|
1721
|
+
if as_json:
|
|
1722
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1723
|
+
elif result.released:
|
|
1724
|
+
print(f"phase-loop consiliency-lease release: released {args.lease_id}")
|
|
1725
|
+
else:
|
|
1726
|
+
print(f"phase-loop consiliency-lease release: rejected ({result.reason})")
|
|
1727
|
+
return 0 if result.released else 1
|
|
1728
|
+
|
|
1729
|
+
# action == "query"
|
|
1730
|
+
scope = {"granularity": args.granularity, "selector": list(args.scope)} if args.scope else None
|
|
1731
|
+
if not (args.lease_id or args.path or scope):
|
|
1732
|
+
print("phase-loop consiliency-lease query: pass --lease-id, --path, or --scope", file=sys.stderr)
|
|
1733
|
+
return 2
|
|
1734
|
+
current = store.query(lease_id=args.lease_id, path=args.path, scope=scope, now=now)
|
|
1735
|
+
if as_json:
|
|
1736
|
+
print(json.dumps(current, indent=2, sort_keys=True))
|
|
1737
|
+
elif current is None:
|
|
1738
|
+
print("phase-loop consiliency-lease query: free")
|
|
1739
|
+
else:
|
|
1740
|
+
print(f"phase-loop consiliency-lease query: held by {current['holder']} (lease {current['lease_id']}, mode {current['mode']})")
|
|
1741
|
+
return 0
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def _reconcile_command(*, repo: Path, roadmap: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
1745
|
+
"""Synthesize a v28-shape manual_repair event for --phase from current git state.
|
|
1746
|
+
|
|
1747
|
+
Refuses by default if the working tree is dirty (override with --allow-dirty).
|
|
1748
|
+
The closeout commit defaults to HEAD; phase ownership/dirty fields are read
|
|
1749
|
+
from `git status --short` at invocation time, so the operator's recovery
|
|
1750
|
+
commit needs to be in place before calling.
|
|
1751
|
+
"""
|
|
1752
|
+
phase = (args.phase or "").strip().upper()
|
|
1753
|
+
if not phase:
|
|
1754
|
+
print("phase-loop reconcile: --phase is required", file=sys.stderr)
|
|
1755
|
+
return 2
|
|
1756
|
+
|
|
1757
|
+
topology = collect_git_topology(repo)
|
|
1758
|
+
if not topology.get("available"):
|
|
1759
|
+
print(f"phase-loop reconcile: {topology.get('reason') or 'git topology unavailable'}", file=sys.stderr)
|
|
1760
|
+
return 2
|
|
1761
|
+
|
|
1762
|
+
if getattr(args, "to_status", None) == "planned":
|
|
1763
|
+
return _reconcile_to_planned_command(
|
|
1764
|
+
repo=repo,
|
|
1765
|
+
roadmap=roadmap,
|
|
1766
|
+
phase=phase,
|
|
1767
|
+
args=args,
|
|
1768
|
+
topology=topology,
|
|
1769
|
+
as_json=as_json,
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
recovery_mode = bool(getattr(args, "recovery_mode", False))
|
|
1773
|
+
allow_dirty = bool(getattr(args, "allow_dirty", False)) or recovery_mode
|
|
1774
|
+
if not topology.get("clean") and not allow_dirty:
|
|
1775
|
+
print(
|
|
1776
|
+
"phase-loop reconcile: working tree is dirty. Commit or stash recovery work "
|
|
1777
|
+
"before reconciling (or pass --allow-dirty to override).",
|
|
1778
|
+
file=sys.stderr,
|
|
1779
|
+
)
|
|
1780
|
+
return 2
|
|
1781
|
+
|
|
1782
|
+
if recovery_mode:
|
|
1783
|
+
missing = []
|
|
1784
|
+
if not getattr(args, "closeout_commit", None):
|
|
1785
|
+
missing.append("--closeout-commit")
|
|
1786
|
+
if not getattr(args, "repair_summary", None):
|
|
1787
|
+
missing.append("--repair-summary")
|
|
1788
|
+
if not getattr(args, "verification_status", None):
|
|
1789
|
+
missing.append("--verification-status")
|
|
1790
|
+
if missing:
|
|
1791
|
+
print(f"phase-loop reconcile: --recovery-mode requires {', '.join(missing)}", file=sys.stderr)
|
|
1792
|
+
return 2
|
|
1793
|
+
|
|
1794
|
+
verification_evidence = None
|
|
1795
|
+
if getattr(args, "verification_status", None) == "passed":
|
|
1796
|
+
verification_log = getattr(args, "verification_log", None)
|
|
1797
|
+
if verification_log or _reconcile_verification_log_required(repo, roadmap, phase):
|
|
1798
|
+
verification_evidence = _validate_reconcile_verification_log(repo, verification_log)
|
|
1799
|
+
if not verification_evidence.get("ok"):
|
|
1800
|
+
print(
|
|
1801
|
+
"phase-loop reconcile: verification evidence invalid "
|
|
1802
|
+
f"(code={verification_evidence.get('code')}, artifact={verification_evidence.get('artifact_path')})",
|
|
1803
|
+
file=sys.stderr,
|
|
1804
|
+
)
|
|
1805
|
+
return 2
|
|
1806
|
+
|
|
1807
|
+
closeout_commit = getattr(args, "closeout_commit", None) or topology.get("head")
|
|
1808
|
+
if not isinstance(closeout_commit, str) or not closeout_commit:
|
|
1809
|
+
print("phase-loop reconcile: cannot resolve closeout commit SHA", file=sys.stderr)
|
|
1810
|
+
return 2
|
|
1811
|
+
|
|
1812
|
+
snapshot_before = reconcile(repo, roadmap)
|
|
1813
|
+
if phase not in snapshot_before.phases:
|
|
1814
|
+
print(f"phase-loop reconcile: phase {phase!r} not found in roadmap {roadmap}", file=sys.stderr)
|
|
1815
|
+
return 2
|
|
1816
|
+
|
|
1817
|
+
manual_repair = {
|
|
1818
|
+
"clears_blocker": True,
|
|
1819
|
+
"closeout_commit": closeout_commit,
|
|
1820
|
+
"closeout_policy": "commit",
|
|
1821
|
+
"dirty_paths": [],
|
|
1822
|
+
"phase_owned_dirty": False,
|
|
1823
|
+
"phase_owned_dirty_paths": [],
|
|
1824
|
+
"previous_phase_owned_paths": [],
|
|
1825
|
+
"pre_existing_dirty_paths": [],
|
|
1826
|
+
"unowned_dirty_paths": [],
|
|
1827
|
+
"verification_status": getattr(args, "verification_status", None) or "not_run",
|
|
1828
|
+
}
|
|
1829
|
+
repair_summary = getattr(args, "repair_summary", None)
|
|
1830
|
+
if repair_summary:
|
|
1831
|
+
manual_repair["repair_summary"] = repair_summary
|
|
1832
|
+
if verification_evidence is not None:
|
|
1833
|
+
manual_repair["verification_evidence"] = verification_evidence
|
|
1834
|
+
if recovery_mode:
|
|
1835
|
+
manual_repair["recovery_mode"] = True
|
|
1836
|
+
|
|
1837
|
+
event = LoopEvent(
|
|
1838
|
+
timestamp=utc_now(),
|
|
1839
|
+
repo=str(repo),
|
|
1840
|
+
roadmap=str(roadmap),
|
|
1841
|
+
phase=phase,
|
|
1842
|
+
action="manual_repair",
|
|
1843
|
+
status="complete",
|
|
1844
|
+
model="manual",
|
|
1845
|
+
reasoning_effort="manual",
|
|
1846
|
+
source="reconcile",
|
|
1847
|
+
metadata={"manual_repair": manual_repair},
|
|
1848
|
+
git_topology=dict(topology),
|
|
1849
|
+
**event_provenance(roadmap, phase),
|
|
1850
|
+
)
|
|
1851
|
+
append_event(repo, event)
|
|
1852
|
+
|
|
1853
|
+
snapshot = reconcile(repo, roadmap)
|
|
1854
|
+
write_state(repo, snapshot)
|
|
1855
|
+
write_tui_handoff(repo, roadmap, snapshot, action="reconcile")
|
|
1856
|
+
print(render_status(snapshot, as_json=as_json))
|
|
1857
|
+
return 0
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
def _validate_reconcile_verification_log(repo: Path, value: str | None) -> dict[str, object]:
|
|
1861
|
+
if not value:
|
|
1862
|
+
return {"ok": False, "code": "missing_verification_log", "artifact_path": None}
|
|
1863
|
+
raw_path = Path(value)
|
|
1864
|
+
artifact_path = raw_path if raw_path.is_absolute() else repo / raw_path
|
|
1865
|
+
if artifact_path.name == LOG_NAME:
|
|
1866
|
+
artifact_path = artifact_path.parent / ARTIFACT_NAME
|
|
1867
|
+
artifact_path = artifact_path.resolve()
|
|
1868
|
+
repo_path = repo.resolve()
|
|
1869
|
+
phase_loop_runs = (repo_path / ".phase-loop" / "runs").resolve()
|
|
1870
|
+
try:
|
|
1871
|
+
inside_repo = artifact_path.is_relative_to(repo_path)
|
|
1872
|
+
inside_runs = artifact_path.is_relative_to(phase_loop_runs)
|
|
1873
|
+
except AttributeError: # pragma: no cover - py3.8 compatibility for downstream packagers
|
|
1874
|
+
inside_repo = str(artifact_path).startswith(str(repo_path) + "/")
|
|
1875
|
+
inside_runs = str(artifact_path).startswith(str(phase_loop_runs) + "/")
|
|
1876
|
+
if not (inside_repo or inside_runs):
|
|
1877
|
+
return {"ok": False, "code": "artifact_outside_repo", "artifact_path": str(artifact_path)}
|
|
1878
|
+
validation = validate_verification_artifact(artifact_path).to_json()
|
|
1879
|
+
return validation
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _reconcile_verification_log_required(repo: Path, roadmap: Path, phase: str) -> bool:
|
|
1883
|
+
if phase.upper() == "RG":
|
|
1884
|
+
return True
|
|
1885
|
+
plan = find_plan_artifact(repo, phase, roadmap=roadmap)
|
|
1886
|
+
if plan is None:
|
|
1887
|
+
return False
|
|
1888
|
+
try:
|
|
1889
|
+
text = plan.read_text(encoding="utf-8")
|
|
1890
|
+
except OSError:
|
|
1891
|
+
return False
|
|
1892
|
+
return "--verification-log" in text or "IF-0-RG-1" in text
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def _reconcile_to_planned_command(
|
|
1896
|
+
*,
|
|
1897
|
+
repo: Path,
|
|
1898
|
+
roadmap: Path,
|
|
1899
|
+
phase: str,
|
|
1900
|
+
args: argparse.Namespace,
|
|
1901
|
+
topology: dict,
|
|
1902
|
+
as_json: bool,
|
|
1903
|
+
) -> int:
|
|
1904
|
+
reason = (getattr(args, "reason", None) or "").strip()
|
|
1905
|
+
if not reason:
|
|
1906
|
+
print("phase-loop reconcile: --to-status planned requires --reason", file=sys.stderr)
|
|
1907
|
+
return 2
|
|
1908
|
+
if bool(getattr(args, "recovery_mode", False)):
|
|
1909
|
+
print("phase-loop reconcile: --to-status planned cannot be combined with --recovery-mode", file=sys.stderr)
|
|
1910
|
+
return 2
|
|
1911
|
+
if not topology.get("clean") and not bool(getattr(args, "allow_dirty", False)):
|
|
1912
|
+
print(
|
|
1913
|
+
"phase-loop reconcile: working tree is dirty. Commit or stash current work "
|
|
1914
|
+
"before blocked-state recovery (or pass --allow-dirty to override).",
|
|
1915
|
+
file=sys.stderr,
|
|
1916
|
+
)
|
|
1917
|
+
return 2
|
|
1918
|
+
|
|
1919
|
+
snapshot_before = reconcile(repo, roadmap)
|
|
1920
|
+
if phase not in snapshot_before.phases:
|
|
1921
|
+
print(f"phase-loop reconcile: phase {phase!r} not found in roadmap {roadmap}", file=sys.stderr)
|
|
1922
|
+
return 2
|
|
1923
|
+
prior_status = snapshot_before.phases.get(phase)
|
|
1924
|
+
if prior_status != "blocked":
|
|
1925
|
+
print(
|
|
1926
|
+
f"phase-loop reconcile: phase {phase!r} is currently {prior_status!r}, not 'blocked'. "
|
|
1927
|
+
"--to-status planned only recovers blocked phases.",
|
|
1928
|
+
file=sys.stderr,
|
|
1929
|
+
)
|
|
1930
|
+
return 2
|
|
1931
|
+
|
|
1932
|
+
forced = bool(getattr(args, "force", False))
|
|
1933
|
+
allowed, refusal = _dirty_blocker_recovery_allowed(snapshot_before)
|
|
1934
|
+
if not allowed and not forced:
|
|
1935
|
+
print(
|
|
1936
|
+
f"phase-loop reconcile: cannot recover sticky blocker {refusal}. "
|
|
1937
|
+
"Pass --force if the underlying blocker condition has been resolved; "
|
|
1938
|
+
"your --reason will be the audit trail.",
|
|
1939
|
+
file=sys.stderr,
|
|
1940
|
+
)
|
|
1941
|
+
return 2
|
|
1942
|
+
|
|
1943
|
+
target_status = "planned" if find_plan_artifact(repo, phase, roadmap=roadmap) is not None else "unplanned"
|
|
1944
|
+
current_dirty_paths = _topology_dirty_paths(topology)
|
|
1945
|
+
manual_recovery = {
|
|
1946
|
+
"from": "blocked",
|
|
1947
|
+
"to": target_status,
|
|
1948
|
+
"reason": reason,
|
|
1949
|
+
"trigger": "cli",
|
|
1950
|
+
"clears_blocker": True,
|
|
1951
|
+
"verification_status": "not_run",
|
|
1952
|
+
"blocker_class": snapshot_before.blocker_class,
|
|
1953
|
+
"current_dirty_paths": current_dirty_paths,
|
|
1954
|
+
"phase_owned_dirty_paths": list(snapshot_before.phase_owned_dirty_paths),
|
|
1955
|
+
"previous_phase_owned_paths": list(snapshot_before.previous_phase_owned_paths),
|
|
1956
|
+
"dirty_paths": list(snapshot_before.dirty_paths),
|
|
1957
|
+
"forced_recovery": forced,
|
|
1958
|
+
}
|
|
1959
|
+
event = LoopEvent(
|
|
1960
|
+
timestamp=utc_now(),
|
|
1961
|
+
repo=str(repo),
|
|
1962
|
+
roadmap=str(roadmap),
|
|
1963
|
+
phase=phase,
|
|
1964
|
+
action="manual_recovery",
|
|
1965
|
+
status=target_status,
|
|
1966
|
+
model="manual",
|
|
1967
|
+
reasoning_effort="manual",
|
|
1968
|
+
source="reconcile",
|
|
1969
|
+
metadata={"manual_recovery": manual_recovery},
|
|
1970
|
+
git_topology=dict(topology),
|
|
1971
|
+
**event_provenance(roadmap, phase),
|
|
1972
|
+
)
|
|
1973
|
+
append_event(repo, event)
|
|
1974
|
+
|
|
1975
|
+
snapshot = reconcile(repo, roadmap)
|
|
1976
|
+
write_state(repo, snapshot)
|
|
1977
|
+
write_tui_handoff(repo, roadmap, snapshot, action="reconcile")
|
|
1978
|
+
print(render_status(snapshot, as_json=as_json))
|
|
1979
|
+
return 0
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def _dirty_blocker_recovery_allowed(snapshot: StateSnapshot) -> tuple[bool, str]:
|
|
1983
|
+
blocker_class = snapshot.blocker_class or "unknown"
|
|
1984
|
+
if snapshot.human_required:
|
|
1985
|
+
return False, blocker_class
|
|
1986
|
+
sticky_blockers = {
|
|
1987
|
+
"missing_secret",
|
|
1988
|
+
"account_or_billing_setup",
|
|
1989
|
+
"admin_approval",
|
|
1990
|
+
"product_decision_missing",
|
|
1991
|
+
"destructive_operation",
|
|
1992
|
+
}
|
|
1993
|
+
if blocker_class in sticky_blockers:
|
|
1994
|
+
return False, blocker_class
|
|
1995
|
+
explicit_dirty_evidence = bool(
|
|
1996
|
+
snapshot.phase_owned_dirty_paths
|
|
1997
|
+
or snapshot.previous_phase_owned_paths
|
|
1998
|
+
or snapshot.phase_owned_dirty
|
|
1999
|
+
or snapshot.dirty_paths
|
|
2000
|
+
)
|
|
2001
|
+
if blocker_class == "dirty_worktree_conflict" or explicit_dirty_evidence:
|
|
2002
|
+
return True, ""
|
|
2003
|
+
# Issue #12 (filed post-v30 by sister session): the recovery command
|
|
2004
|
+
# excluded blocker_class='unknown' from its allowlist, leaving phases
|
|
2005
|
+
# that hit the pre-v29/v30 issue-#11 cascade quarantined. The v29/v30
|
|
2006
|
+
# runtime fixes prevent NEW genuine 'unknown' blockers from accumulating,
|
|
2007
|
+
# so legacy 'unknown' from pre-fix runs IS recoverable. Treat as
|
|
2008
|
+
# dirty-state-derived for recovery purposes; operator attestation via
|
|
2009
|
+
# --reason becomes the audit trail.
|
|
2010
|
+
if blocker_class == "unknown":
|
|
2011
|
+
return True, ""
|
|
2012
|
+
return False, blocker_class
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
def _topology_dirty_paths(topology: dict) -> list[str]:
|
|
2016
|
+
status = topology.get("status_short_branch")
|
|
2017
|
+
if not isinstance(status, str):
|
|
2018
|
+
return []
|
|
2019
|
+
paths: list[str] = []
|
|
2020
|
+
for line in status.splitlines():
|
|
2021
|
+
if not line or line.startswith("##"):
|
|
2022
|
+
continue
|
|
2023
|
+
path = line[3:].strip() if len(line) > 3 else line.strip()
|
|
2024
|
+
if " -> " in path:
|
|
2025
|
+
path = path.rsplit(" -> ", 1)[-1]
|
|
2026
|
+
if path:
|
|
2027
|
+
paths.append(path)
|
|
2028
|
+
return paths
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def _reopen_command(*, repo: Path, roadmap: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
2032
|
+
"""Reverse a spurious closeout OR recover a blocked phase: append a typed
|
|
2033
|
+
phase_reopen event for --phase.
|
|
2034
|
+
|
|
2035
|
+
Two recovery scenarios:
|
|
2036
|
+
1. Spurious complete: an executor reported complete + verification_status=passed
|
|
2037
|
+
but the IF gates were not actually satisfied.
|
|
2038
|
+
2. Recoverable blocked: an executor self-blocked (e.g., missing_secret) and the
|
|
2039
|
+
blocker has since been resolved (e.g., AWS SSO refreshed). Without reopen,
|
|
2040
|
+
blocked phases stick because `phase-loop run` reuses the prior terminal
|
|
2041
|
+
summary instead of re-dispatching.
|
|
2042
|
+
|
|
2043
|
+
In both cases, the phase_reopen event flips the phase back to planned in the
|
|
2044
|
+
reducer; the next `phase-loop run` will re-execute it.
|
|
2045
|
+
|
|
2046
|
+
Refuses by default if the working tree is dirty (override with --allow-dirty)
|
|
2047
|
+
so the recorded prior_closeout_commit corresponds to a clean state.
|
|
2048
|
+
"""
|
|
2049
|
+
phase = (args.phase or "").strip().upper()
|
|
2050
|
+
if not phase:
|
|
2051
|
+
print("phase-loop reopen: --phase is required", file=sys.stderr)
|
|
2052
|
+
return 2
|
|
2053
|
+
reason = (args.reason or "").strip()
|
|
2054
|
+
if not reason:
|
|
2055
|
+
print("phase-loop reopen: --reason is required", file=sys.stderr)
|
|
2056
|
+
return 2
|
|
2057
|
+
|
|
2058
|
+
topology = collect_git_topology(repo)
|
|
2059
|
+
if not topology.get("available"):
|
|
2060
|
+
print(f"phase-loop reopen: {topology.get('reason') or 'git topology unavailable'}", file=sys.stderr)
|
|
2061
|
+
return 2
|
|
2062
|
+
|
|
2063
|
+
if not topology.get("clean") and not bool(getattr(args, "allow_dirty", False)):
|
|
2064
|
+
print(
|
|
2065
|
+
"phase-loop reopen: working tree is dirty. Commit or stash work "
|
|
2066
|
+
"before reopening (or pass --allow-dirty to override).",
|
|
2067
|
+
file=sys.stderr,
|
|
2068
|
+
)
|
|
2069
|
+
return 2
|
|
2070
|
+
|
|
2071
|
+
snapshot_before = reconcile(repo, roadmap)
|
|
2072
|
+
if phase not in snapshot_before.phases:
|
|
2073
|
+
print(f"phase-loop reopen: phase {phase!r} not found in roadmap {roadmap}", file=sys.stderr)
|
|
2074
|
+
return 2
|
|
2075
|
+
prior_status = snapshot_before.phases.get(phase)
|
|
2076
|
+
reopen_allowed_statuses = ("complete", "blocked")
|
|
2077
|
+
if prior_status not in reopen_allowed_statuses:
|
|
2078
|
+
print(
|
|
2079
|
+
f"phase-loop reopen: phase {phase!r} is currently {prior_status!r}, "
|
|
2080
|
+
f"not one of {reopen_allowed_statuses}. Only complete or blocked phases "
|
|
2081
|
+
"can be reopened.",
|
|
2082
|
+
file=sys.stderr,
|
|
2083
|
+
)
|
|
2084
|
+
return 2
|
|
2085
|
+
|
|
2086
|
+
head = topology.get("head")
|
|
2087
|
+
prior_closeout = snapshot_before.closeout_summary.get("closeout_commit") if isinstance(snapshot_before.closeout_summary, dict) else None
|
|
2088
|
+
|
|
2089
|
+
phase_reopen = {
|
|
2090
|
+
"reason": reason,
|
|
2091
|
+
"prior_status": prior_status,
|
|
2092
|
+
"prior_closeout_commit": prior_closeout,
|
|
2093
|
+
"reopen_commit": head,
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
event = LoopEvent(
|
|
2097
|
+
timestamp=utc_now(),
|
|
2098
|
+
repo=str(repo),
|
|
2099
|
+
roadmap=str(roadmap),
|
|
2100
|
+
phase=phase,
|
|
2101
|
+
action="phase_reopen",
|
|
2102
|
+
status="planned",
|
|
2103
|
+
model="manual",
|
|
2104
|
+
reasoning_effort="manual",
|
|
2105
|
+
source="reopen",
|
|
2106
|
+
metadata={"phase_reopen": phase_reopen},
|
|
2107
|
+
git_topology=dict(topology),
|
|
2108
|
+
**event_provenance(roadmap, phase),
|
|
2109
|
+
)
|
|
2110
|
+
append_event(repo, event)
|
|
2111
|
+
|
|
2112
|
+
snapshot = reconcile(repo, roadmap)
|
|
2113
|
+
write_state(repo, snapshot)
|
|
2114
|
+
write_tui_handoff(repo, roadmap, snapshot, action="reopen")
|
|
2115
|
+
print(render_status(snapshot, as_json=as_json))
|
|
2116
|
+
return 0
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
def _evidence_audit_command(*, repo: Path, args: argparse.Namespace, as_json: bool) -> int:
|
|
2120
|
+
"""Spot-check dirty-tree artifacts for fake-evidence patterns.
|
|
2121
|
+
|
|
2122
|
+
Operator-callable helper that codifies the v20 spot-check protocol:
|
|
2123
|
+
detects duplicate-content (sha256 dup across "distinct" files),
|
|
2124
|
+
uniform-numeric (epsilon-tight value arrays), and missing-references
|
|
2125
|
+
(cited paths that don't exist on disk).
|
|
2126
|
+
"""
|
|
2127
|
+
from .evidence_audit import run_evidence_audit, render_text
|
|
2128
|
+
|
|
2129
|
+
result = run_evidence_audit(
|
|
2130
|
+
repo,
|
|
2131
|
+
dirty_only=False if getattr(args, "full_tree_loose", False) else getattr(args, "dirty_only", True),
|
|
2132
|
+
min_duplicates=getattr(args, "min_duplicates", 3),
|
|
2133
|
+
uniform_epsilon=getattr(args, "uniform_epsilon", 1e-6),
|
|
2134
|
+
uniform_min_length=getattr(args, "uniform_min_length", 4),
|
|
2135
|
+
tier2_enabled=bool(getattr(args, "tier_2", False) or getattr(args, "enable_tier_3", False)),
|
|
2136
|
+
loose_uniform_stdev_threshold=getattr(args, "loose_uniform_stdev_threshold", 1e-3),
|
|
2137
|
+
boilerplate_token_overlap_threshold=getattr(args, "boilerplate_token_overlap_threshold", 0.80),
|
|
2138
|
+
boilerplate_min_group_size=getattr(args, "boilerplate_min_group_size", 3),
|
|
2139
|
+
size_distribution_variance_threshold=getattr(args, "size_distribution_variance_threshold", 0.05),
|
|
2140
|
+
size_distribution_min_group_size=getattr(args, "size_distribution_min_group_size", 3),
|
|
2141
|
+
enable_tier_3=getattr(args, "enable_tier_3", False),
|
|
2142
|
+
missing_references_strict=not getattr(args, "full_tree_loose", False),
|
|
2143
|
+
)
|
|
2144
|
+
if as_json:
|
|
2145
|
+
print(json.dumps(result.to_json(), indent=2))
|
|
2146
|
+
else:
|
|
2147
|
+
print(render_text(result))
|
|
2148
|
+
return 0 if result.is_clean() else 5
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
def _export_schema_command(*, args: argparse.Namespace) -> int:
|
|
2152
|
+
"""Emit or --check the canonical closeout schema. Repo-independent."""
|
|
2153
|
+
from . import schema_export
|
|
2154
|
+
|
|
2155
|
+
check_path = getattr(args, "check", None)
|
|
2156
|
+
if check_path:
|
|
2157
|
+
diffs = schema_export.check(Path(check_path))
|
|
2158
|
+
if diffs:
|
|
2159
|
+
print(f"export-schema --check FAILED: {check_path}", file=sys.stderr)
|
|
2160
|
+
for diff in diffs:
|
|
2161
|
+
print(f" - {diff}", file=sys.stderr)
|
|
2162
|
+
return 1
|
|
2163
|
+
print(f"export-schema --check OK: {check_path}")
|
|
2164
|
+
return 0
|
|
2165
|
+
|
|
2166
|
+
fmt = getattr(args, "format", "json-schema")
|
|
2167
|
+
if fmt == "field-list":
|
|
2168
|
+
payload = schema_export.build_field_list()
|
|
2169
|
+
else:
|
|
2170
|
+
payload = schema_export.build_schema()
|
|
2171
|
+
rendered = schema_export.render(payload)
|
|
2172
|
+
|
|
2173
|
+
output = getattr(args, "output", None)
|
|
2174
|
+
if output:
|
|
2175
|
+
# utf-8 + explicit "\n" so the emitted artifact is byte-stable cross-platform.
|
|
2176
|
+
Path(output).write_text(rendered, encoding="utf-8", newline="\n")
|
|
2177
|
+
else:
|
|
2178
|
+
sys.stdout.write(rendered)
|
|
2179
|
+
return 0
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def _closeout_drift_audit_command(*, args: argparse.Namespace, as_json: bool) -> int:
|
|
2183
|
+
from .phase_loop_drift_audit import run_drift_audit
|
|
2184
|
+
|
|
2185
|
+
repo_args = args.repo or ["."]
|
|
2186
|
+
if isinstance(repo_args, str):
|
|
2187
|
+
repos = [repo_args]
|
|
2188
|
+
else:
|
|
2189
|
+
repos = repo_args
|
|
2190
|
+
result = run_drift_audit(repos, days=getattr(args, "days", 7), scope=getattr(args, "scope", "closeout"))
|
|
2191
|
+
if as_json:
|
|
2192
|
+
print(json.dumps(result.to_json(), indent=2))
|
|
2193
|
+
else:
|
|
2194
|
+
print(result.render_text())
|
|
2195
|
+
if result.has_setup_errors():
|
|
2196
|
+
return 2
|
|
2197
|
+
return 1 if result.has_drift() else 0
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def _fleet_map_command(*, args: argparse.Namespace, as_json: bool) -> int:
|
|
2201
|
+
from .fleet_map import build_fleet_map
|
|
2202
|
+
|
|
2203
|
+
repo_args = args.repo or ["."]
|
|
2204
|
+
if isinstance(repo_args, str):
|
|
2205
|
+
repos = [repo_args]
|
|
2206
|
+
else:
|
|
2207
|
+
repos = repo_args
|
|
2208
|
+
result = build_fleet_map(repos)
|
|
2209
|
+
if as_json:
|
|
2210
|
+
print(json.dumps(result.to_json(), indent=2))
|
|
2211
|
+
else:
|
|
2212
|
+
print(result.render_text())
|
|
2213
|
+
# Informational extractor, not a gate: edges are the expected, useful
|
|
2214
|
+
# output, so only a setup problem (missing repo path) is an error.
|
|
2215
|
+
return 2 if result.has_setup_errors() else 0
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
def _run_train_command(*, parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
|
|
2219
|
+
"""Handle the 'run-train' subcommand (#29 P3+P4).
|
|
2220
|
+
|
|
2221
|
+
Topo-sorts the release train, preflights ALL repos, then per node (in
|
|
2222
|
+
order): injects upstream draft ref → runs the unchanged per-repo
|
|
2223
|
+
run_loop → publishes a draft PR → appends to the coordinator ledger.
|
|
2224
|
+
|
|
2225
|
+
After all draft PRs are open:
|
|
2226
|
+
- ``autonomous`` (default): stops at ``drafts_open`` terminal. Cross-repo
|
|
2227
|
+
merges are never auto-merged; the operator reviews and re-runs with
|
|
2228
|
+
``--governed``.
|
|
2229
|
+
- ``governed`` (``--governed`` flag): runs the train-level governed review
|
|
2230
|
+
panel (one round), then merges sequentially in topo order with downstream
|
|
2231
|
+
re-verify against each upstream's merged SHA before merging.
|
|
2232
|
+
"""
|
|
2233
|
+
from .train_roadmap import load_train_roadmap
|
|
2234
|
+
from .train_ledger import default_ledger_path
|
|
2235
|
+
from . import train_runner
|
|
2236
|
+
|
|
2237
|
+
train_file = getattr(args, "train_file", None)
|
|
2238
|
+
if not train_file:
|
|
2239
|
+
parser.error("run-train requires --train <file>")
|
|
2240
|
+
return 1 # unreachable but makes mypy happy
|
|
2241
|
+
|
|
2242
|
+
train_path = Path(train_file)
|
|
2243
|
+
if not train_path.exists():
|
|
2244
|
+
print(f"run-train: train file not found: {train_path}", file=sys.stderr)
|
|
2245
|
+
return 1
|
|
2246
|
+
|
|
2247
|
+
try:
|
|
2248
|
+
roadmap = load_train_roadmap(train_path)
|
|
2249
|
+
except ValueError as exc:
|
|
2250
|
+
print(f"run-train: failed to parse train roadmap: {exc}", file=sys.stderr)
|
|
2251
|
+
return 1
|
|
2252
|
+
|
|
2253
|
+
# Validate train schema (T-A/B/C/D) before touching any repo.
|
|
2254
|
+
# A malformed train (e.g. a none-channel dependency edge) must open zero
|
|
2255
|
+
# PRs. run_train also validates internally, but running it here gives a
|
|
2256
|
+
# cleaner CLI error message.
|
|
2257
|
+
from .train_roadmap import validate_train_loud
|
|
2258
|
+
try:
|
|
2259
|
+
validate_train_loud(roadmap)
|
|
2260
|
+
except ValueError as exc:
|
|
2261
|
+
print(
|
|
2262
|
+
f"run-train: train validation failed — zero PRs will be opened:\n{exc}",
|
|
2263
|
+
file=sys.stderr,
|
|
2264
|
+
)
|
|
2265
|
+
return 1
|
|
2266
|
+
|
|
2267
|
+
# run_mode mirrors how 'run' handles --governed (cli.py:796)
|
|
2268
|
+
run_mode = "governed" if bool(getattr(args, "governed", False)) else "autonomous"
|
|
2269
|
+
|
|
2270
|
+
# Workspace resolution: <workspace-root>/<node.repo>
|
|
2271
|
+
workspace_root = Path(getattr(args, "workspace_root", None) or ".")
|
|
2272
|
+
def _resolve_workspace(node) -> Path:
|
|
2273
|
+
return workspace_root / node.repo
|
|
2274
|
+
|
|
2275
|
+
# Ledger path — must not be inside any repo's .phase-loop/
|
|
2276
|
+
ledger_dir_arg = getattr(args, "ledger_dir", None)
|
|
2277
|
+
if ledger_dir_arg:
|
|
2278
|
+
ledger_dir = Path(ledger_dir_arg)
|
|
2279
|
+
else:
|
|
2280
|
+
ledger_dir = train_path.parent / ".train-ledger"
|
|
2281
|
+
ledger_path = default_ledger_path(ledger_dir, train_path.stem)
|
|
2282
|
+
|
|
2283
|
+
as_json = bool(getattr(args, "json", False))
|
|
2284
|
+
|
|
2285
|
+
result = train_runner.run_train(
|
|
2286
|
+
roadmap,
|
|
2287
|
+
ledger_path,
|
|
2288
|
+
run_mode=run_mode,
|
|
2289
|
+
resolve_workspace=_resolve_workspace,
|
|
2290
|
+
_merge_phase_enabled=True, # P4 gate: autonomous→drafts_open, governed→merge
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
if as_json:
|
|
2294
|
+
print(json.dumps(result, indent=2))
|
|
2295
|
+
|
|
2296
|
+
if result["status"] == "preflight_failed":
|
|
2297
|
+
print("run-train: preflight failed — zero PRs opened:", file=sys.stderr)
|
|
2298
|
+
for err in result.get("errors", []):
|
|
2299
|
+
print(f" {err}", file=sys.stderr)
|
|
2300
|
+
return 1
|
|
2301
|
+
|
|
2302
|
+
if result["status"] == "blocked":
|
|
2303
|
+
node_id = result.get("node_id", "?")
|
|
2304
|
+
detail = result.get("detail", {})
|
|
2305
|
+
print(
|
|
2306
|
+
f"run-train: blocked at node '{node_id}': {detail.get('reason', detail)}",
|
|
2307
|
+
file=sys.stderr,
|
|
2308
|
+
)
|
|
2309
|
+
print(
|
|
2310
|
+
" Prior nodes' draft PRs remain open. Re-run to resume.",
|
|
2311
|
+
file=sys.stderr,
|
|
2312
|
+
)
|
|
2313
|
+
return 1
|
|
2314
|
+
|
|
2315
|
+
if result["status"] == "drafts_open":
|
|
2316
|
+
# Autonomous mode terminal: all draft PRs open, awaiting governed review.
|
|
2317
|
+
nodes = result.get("nodes", {})
|
|
2318
|
+
if not as_json:
|
|
2319
|
+
print(
|
|
2320
|
+
f"run-train: {len(nodes)} draft PR(s) open — "
|
|
2321
|
+
f"train held for governed review. Re-run with --governed to merge."
|
|
2322
|
+
)
|
|
2323
|
+
for node_id, info in nodes.items():
|
|
2324
|
+
print(f" {node_id}: {info.get('pr_url', '?')}")
|
|
2325
|
+
return 0
|
|
2326
|
+
|
|
2327
|
+
if result["status"] == "review_halted":
|
|
2328
|
+
# Panel rejected the train — ZERO merges (partial-merge-disaster guard).
|
|
2329
|
+
blocker = result.get("terminal_blocker") or {}
|
|
2330
|
+
reason = result.get("reason", "unknown")
|
|
2331
|
+
print(
|
|
2332
|
+
f"run-train: train-level review rejected — ZERO merges "
|
|
2333
|
+
f"(reason: {reason}; human_required: {blocker.get('human_required', False)})",
|
|
2334
|
+
file=sys.stderr,
|
|
2335
|
+
)
|
|
2336
|
+
return 1
|
|
2337
|
+
|
|
2338
|
+
if result["status"] == "merge_halted":
|
|
2339
|
+
# Downstream re-verify failed — upstream stays merged (forward-only).
|
|
2340
|
+
node_id = result.get("node_id", "?")
|
|
2341
|
+
reason = result.get("reason", "unknown")
|
|
2342
|
+
print(
|
|
2343
|
+
f"run-train: merge halted at node '{node_id}': {reason}",
|
|
2344
|
+
file=sys.stderr,
|
|
2345
|
+
)
|
|
2346
|
+
print(
|
|
2347
|
+
" Upstream nodes remain merged (forward-only). "
|
|
2348
|
+
"Use expand/contract upstream contracts to prevent this.",
|
|
2349
|
+
file=sys.stderr,
|
|
2350
|
+
)
|
|
2351
|
+
return 1
|
|
2352
|
+
|
|
2353
|
+
if result["status"] == "merged":
|
|
2354
|
+
# All nodes merged successfully in topo order.
|
|
2355
|
+
nodes = result.get("nodes", {})
|
|
2356
|
+
if not as_json:
|
|
2357
|
+
print(f"run-train: merged — {len(nodes)} node(s) landed on main")
|
|
2358
|
+
for node_id, info in nodes.items():
|
|
2359
|
+
print(f" {node_id}: {info.get('merged_sha', '?')}")
|
|
2360
|
+
return 0
|
|
2361
|
+
|
|
2362
|
+
# status == "completed" (legacy / direct run_train calls with _merge_phase_enabled=False)
|
|
2363
|
+
nodes = result.get("nodes", {})
|
|
2364
|
+
if not as_json:
|
|
2365
|
+
print(f"run-train: completed — {len(nodes)} draft PR(s) open")
|
|
2366
|
+
for node_id, info in nodes.items():
|
|
2367
|
+
print(f" {node_id}: {info.get('pr_url', '?')}")
|
|
2368
|
+
return 0
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
def _direct_invocation_blocker(
|
|
2372
|
+
*,
|
|
2373
|
+
repo: Path,
|
|
2374
|
+
args: argparse.Namespace,
|
|
2375
|
+
phase: str,
|
|
2376
|
+
output_path: Path,
|
|
2377
|
+
blocker_summary: str,
|
|
2378
|
+
diagnostic_kind: str,
|
|
2379
|
+
as_json: bool,
|
|
2380
|
+
) -> int:
|
|
2381
|
+
roadmap = select_roadmap(repo, args.roadmap)
|
|
2382
|
+
blocker = {
|
|
2383
|
+
"human_required": False,
|
|
2384
|
+
"blocker_class": "contract_bug",
|
|
2385
|
+
"blocker_summary": blocker_summary,
|
|
2386
|
+
"required_human_inputs": (),
|
|
2387
|
+
"access_attempts": (),
|
|
2388
|
+
}
|
|
2389
|
+
snapshot = StateSnapshot(
|
|
2390
|
+
timestamp=utc_now(),
|
|
2391
|
+
repo=str(repo),
|
|
2392
|
+
roadmap=str(roadmap),
|
|
2393
|
+
phases={phase: "blocked"},
|
|
2394
|
+
current_phase=phase,
|
|
2395
|
+
last_action="execute",
|
|
2396
|
+
human_required=False,
|
|
2397
|
+
blocker_class="contract_bug",
|
|
2398
|
+
blocker_summary=blocker_summary,
|
|
2399
|
+
**snapshot_provenance(roadmap),
|
|
2400
|
+
)
|
|
2401
|
+
write_state(repo, snapshot)
|
|
2402
|
+
plan = find_plan_artifact(repo, phase, roadmap=roadmap)
|
|
2403
|
+
closeout = build_phase_loop_closeout(
|
|
2404
|
+
phase_alias=phase,
|
|
2405
|
+
plan_path=plan or "",
|
|
2406
|
+
docs_freshness=scan_docs_freshness(repo, plan_path=plan),
|
|
2407
|
+
plan_metadata=PipelinePlanMetadata(
|
|
2408
|
+
source_bundle=str(args.bundle or args.source_bundle or "none"),
|
|
2409
|
+
source_bundle_sha256="0" * 64,
|
|
2410
|
+
pipeline_phase_id=phase,
|
|
2411
|
+
pipeline_mode=args.pipeline_mode or "standalone",
|
|
2412
|
+
),
|
|
2413
|
+
terminal_summary={
|
|
2414
|
+
"terminal_status": "blocked",
|
|
2415
|
+
"terminal_blocker": blocker,
|
|
2416
|
+
"verification_status": "blocked",
|
|
2417
|
+
},
|
|
2418
|
+
automation={
|
|
2419
|
+
"status": "blocked",
|
|
2420
|
+
"next_skill": "none",
|
|
2421
|
+
"next_command": f"none - {diagnostic_kind}",
|
|
2422
|
+
"next_model_hint": "none",
|
|
2423
|
+
"human_required": False,
|
|
2424
|
+
"blocker_class": "contract_bug",
|
|
2425
|
+
"blocker_summary": blocker_summary,
|
|
2426
|
+
"required_human_inputs": (),
|
|
2427
|
+
"verification_status": "blocked",
|
|
2428
|
+
"artifact": str(plan) if plan else "none",
|
|
2429
|
+
"artifact_state": "tracked" if plan else "none",
|
|
2430
|
+
},
|
|
2431
|
+
blocker=blocker,
|
|
2432
|
+
)
|
|
2433
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2434
|
+
output_path.write_text(json.dumps(closeout, indent=2, sort_keys=True), encoding="utf-8")
|
|
2435
|
+
print(render_status(snapshot, as_json=as_json))
|
|
2436
|
+
return 1
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
def _run_train_status_command(*, parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
|
|
2440
|
+
"""train-status (#45): non-mutating inspection of the cross-repo train ledger.
|
|
2441
|
+
|
|
2442
|
+
Reads the SAME default ledger path as ``run-train`` and prints per-node status
|
|
2443
|
+
(status, branch, PR URL, merge order, merged SHA). Never writes — safe to run
|
|
2444
|
+
between draft-PR creation, review, and merge/reverify.
|
|
2445
|
+
"""
|
|
2446
|
+
import json as _json
|
|
2447
|
+
|
|
2448
|
+
from .train_ledger import default_ledger_path, read_ledger
|
|
2449
|
+
|
|
2450
|
+
train_file = getattr(args, "train_file", None)
|
|
2451
|
+
if not train_file:
|
|
2452
|
+
parser.error("train-status requires --train <file>")
|
|
2453
|
+
return 2
|
|
2454
|
+
train_path = Path(train_file)
|
|
2455
|
+
if not train_path.exists():
|
|
2456
|
+
print(f"train-status: train file not found: {train_path}", file=sys.stderr)
|
|
2457
|
+
return 1
|
|
2458
|
+
|
|
2459
|
+
ledger_dir_arg = getattr(args, "ledger_dir", None)
|
|
2460
|
+
ledger_dir = Path(ledger_dir_arg) if ledger_dir_arg else train_path.parent / ".train-ledger"
|
|
2461
|
+
ledger_path = default_ledger_path(ledger_dir, train_path.stem)
|
|
2462
|
+
|
|
2463
|
+
try:
|
|
2464
|
+
state = read_ledger(ledger_path)
|
|
2465
|
+
except Exception as exc:
|
|
2466
|
+
print(f"train-status: failed to read ledger at {ledger_path}: {exc}", file=sys.stderr)
|
|
2467
|
+
return 1
|
|
2468
|
+
|
|
2469
|
+
# Prefer the train roadmap's topo order (also surfaces not-yet-run nodes as
|
|
2470
|
+
# 'pending'); fall back to ledger order if the train can't be parsed.
|
|
2471
|
+
node_order: list[str] = []
|
|
2472
|
+
try:
|
|
2473
|
+
from .train_roadmap import parse_train_roadmap
|
|
2474
|
+
|
|
2475
|
+
roadmap = parse_train_roadmap(train_path.read_text(encoding="utf-8"))
|
|
2476
|
+
node_order = [node.node_id for node in roadmap.topo_order()]
|
|
2477
|
+
except Exception:
|
|
2478
|
+
node_order = sorted(
|
|
2479
|
+
state.keys(),
|
|
2480
|
+
key=lambda nid: (
|
|
2481
|
+
state[nid].merge_order if state[nid].merge_order is not None else 1 << 30,
|
|
2482
|
+
nid,
|
|
2483
|
+
),
|
|
2484
|
+
)
|
|
2485
|
+
for nid in state: # defensive: any ledger node absent from the roadmap
|
|
2486
|
+
if nid not in node_order:
|
|
2487
|
+
node_order.append(nid)
|
|
2488
|
+
|
|
2489
|
+
rows = []
|
|
2490
|
+
for nid in node_order:
|
|
2491
|
+
rec = state.get(nid)
|
|
2492
|
+
rows.append(
|
|
2493
|
+
{
|
|
2494
|
+
"node_id": nid,
|
|
2495
|
+
"status": rec.status if rec else "pending",
|
|
2496
|
+
"branch": rec.branch if rec else None,
|
|
2497
|
+
"pr_url": rec.pr_url if rec else None,
|
|
2498
|
+
"merge_order": rec.merge_order if rec else None,
|
|
2499
|
+
"merged_sha": rec.upstream_merge_sha if rec else None,
|
|
2500
|
+
}
|
|
2501
|
+
)
|
|
2502
|
+
|
|
2503
|
+
if bool(getattr(args, "json", False)):
|
|
2504
|
+
print(_json.dumps({"ledger_path": str(ledger_path), "nodes": rows}, indent=2))
|
|
2505
|
+
return 0
|
|
2506
|
+
|
|
2507
|
+
print(f"train-status: {ledger_path}")
|
|
2508
|
+
if not rows:
|
|
2509
|
+
print(" (no ledger records yet — run-train has not run, or the ledger path differs)")
|
|
2510
|
+
return 0
|
|
2511
|
+
for row in rows:
|
|
2512
|
+
line = f" [{row['status']}] {row['node_id']}"
|
|
2513
|
+
if row["merge_order"] is not None:
|
|
2514
|
+
line += f" (order {row['merge_order']})"
|
|
2515
|
+
print(line)
|
|
2516
|
+
if row["pr_url"]:
|
|
2517
|
+
print(f" PR: {row['pr_url']} branch={row['branch']}")
|
|
2518
|
+
if row["merged_sha"]:
|
|
2519
|
+
print(f" merged: {row['merged_sha']}")
|
|
2520
|
+
return 0
|
|
2521
|
+
|
|
2522
|
+
|
|
2523
|
+
def render_handoff_json(path: Path, repo: Path) -> str:
|
|
2524
|
+
import json
|
|
2525
|
+
|
|
2526
|
+
return json.dumps(handoff_metadata(repo, path), indent=2, sort_keys=True)
|
|
2527
|
+
|
|
2528
|
+
|
|
2529
|
+
def monitor_loop(
|
|
2530
|
+
repo: Path,
|
|
2531
|
+
roadmap: Path,
|
|
2532
|
+
*,
|
|
2533
|
+
poll_seconds: int,
|
|
2534
|
+
timeout_seconds: int | None,
|
|
2535
|
+
notify_command: str | None,
|
|
2536
|
+
notify_on: tuple[str, ...],
|
|
2537
|
+
once: bool,
|
|
2538
|
+
as_json: bool,
|
|
2539
|
+
) -> dict[str, object]:
|
|
2540
|
+
started = time.monotonic()
|
|
2541
|
+
last_event_kind: str | None = None
|
|
2542
|
+
default_notify = ("blocked", "stale", "complete", "awaiting_phase_closeout", "operator_halt")
|
|
2543
|
+
watched = set(notify_on or default_notify)
|
|
2544
|
+
notifications: list[dict[str, object]] = []
|
|
2545
|
+
while True:
|
|
2546
|
+
summary = inspect_state(repo, roadmap)
|
|
2547
|
+
monitor_status = summary.get("monitor_status") if isinstance(summary.get("monitor_status"), dict) else {}
|
|
2548
|
+
event_kind = str(monitor_status.get("event_kind") or "heartbeat")
|
|
2549
|
+
if notify_command and event_kind in watched and event_kind != last_event_kind:
|
|
2550
|
+
payload = build_notification_payload(repo=repo, roadmap=roadmap, monitor_status=monitor_status, state_summary=summary)
|
|
2551
|
+
result = run_notification_command(notify_command, payload)
|
|
2552
|
+
if result:
|
|
2553
|
+
notifications.append(result)
|
|
2554
|
+
last_event_kind = event_kind
|
|
2555
|
+
terminal = event_kind in {"blocked", "stale", "complete", "operator_halt", "awaiting_phase_closeout"}
|
|
2556
|
+
timed_out = timeout_seconds is not None and time.monotonic() - started >= timeout_seconds
|
|
2557
|
+
if once or terminal or timed_out:
|
|
2558
|
+
if timed_out and event_kind not in {"blocked", "stale", "complete", "operator_halt", "awaiting_phase_closeout"}:
|
|
2559
|
+
monitor_status = dict(monitor_status)
|
|
2560
|
+
monitor_status["event_kind"] = "timeout"
|
|
2561
|
+
monitor_status["recommended_action"] = "Inspect state, heartbeat, and latest run log before deciding whether to resume."
|
|
2562
|
+
payload = {
|
|
2563
|
+
"repo": str(repo),
|
|
2564
|
+
"roadmap": str(roadmap),
|
|
2565
|
+
"monitor_status": monitor_status,
|
|
2566
|
+
"notifications": notifications,
|
|
2567
|
+
"state": summary,
|
|
2568
|
+
}
|
|
2569
|
+
rendered = json.dumps(payload, indent=2, sort_keys=True) if as_json else _render_monitor_payload(payload)
|
|
2570
|
+
return {"returncode": _monitor_returncode(str(monitor_status.get("event_kind")), bool(summary.get("human_required"))), "rendered": rendered}
|
|
2571
|
+
time.sleep(max(1, poll_seconds))
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
def _render_monitor_payload(payload: dict[str, object]) -> str:
|
|
2575
|
+
monitor_status = payload.get("monitor_status") if isinstance(payload.get("monitor_status"), dict) else {}
|
|
2576
|
+
lines = [
|
|
2577
|
+
f"Monitor event: {monitor_status.get('event_kind', 'unknown')}",
|
|
2578
|
+
f"Current phase: {monitor_status.get('current_phase') or 'none'}",
|
|
2579
|
+
f"Current status: {monitor_status.get('current_status') or 'unknown'}",
|
|
2580
|
+
]
|
|
2581
|
+
if monitor_status.get("blocker_class"):
|
|
2582
|
+
lines.append(f"Blocker: {monitor_status['blocker_class']}")
|
|
2583
|
+
if monitor_status.get("recommended_action"):
|
|
2584
|
+
lines.append(f"Recommended action: {monitor_status['recommended_action']}")
|
|
2585
|
+
if payload.get("notifications"):
|
|
2586
|
+
lines.append(f"Notifications: {len(payload['notifications'])}")
|
|
2587
|
+
return "\n".join(lines)
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
def _monitor_returncode(event_kind: str, human_required: bool) -> int:
|
|
2591
|
+
if event_kind == "complete":
|
|
2592
|
+
return 0
|
|
2593
|
+
if event_kind in {"blocked", "stale", "operator_halt", "timeout"} or human_required:
|
|
2594
|
+
return 1
|
|
2595
|
+
return 0
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
if __name__ == "__main__":
|
|
2599
|
+
raise SystemExit(main(sys.argv[1:]))
|