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.
Files changed (370) hide show
  1. phase_loop_runtime/__init__.py +20 -0
  2. phase_loop_runtime/_contract_docs/phase-loop/extraction-readiness.md +90 -0
  3. phase_loop_runtime/_contract_docs/phase-loop/granular-execution-policy.md +137 -0
  4. phase_loop_runtime/_contract_docs/phase-loop/harness-capability-matrix.md +510 -0
  5. phase_loop_runtime/_contract_docs/phase-loop/harness-skill-matrix.md +125 -0
  6. phase_loop_runtime/_contract_docs/phase-loop/harness-substrate-manifest.md +232 -0
  7. phase_loop_runtime/_contract_docs/phase-loop/pi-loop-control.md +247 -0
  8. phase_loop_runtime/_contract_docs/phase-loop/protocol.md +2759 -0
  9. phase_loop_runtime/_contract_docs/phase-loop/runtime-boundary.md +289 -0
  10. phase_loop_runtime/_contract_docs/phase-loop/spec-discovery-roots.md +47 -0
  11. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-governed-pipeline.md +42 -0
  12. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-portal-projection.md +34 -0
  13. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-regenesis.md +29 -0
  14. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-report.md +32 -0
  15. phase_loop_runtime/_contract_docs/runtime/verification-evidence-contract.md +52 -0
  16. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/commands.txt +2 -0
  17. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/manifest.json +16 -0
  18. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/mixed.json +5 -0
  19. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/manifest.json +16 -0
  20. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/timeout-note.txt +2 -0
  21. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/manifest.json +16 -0
  22. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/redacted-summary.json +6 -0
  23. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/manifest.json +16 -0
  24. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/sample.json +7 -0
  25. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/edge.txt +2 -0
  26. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/manifest.json +16 -0
  27. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/manifest.json +16 -0
  28. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-a.txt +1 -0
  29. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-b.txt +1 -0
  30. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-c.txt +1 -0
  31. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/claimed-results.json +5 -0
  32. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/manifest.json +16 -0
  33. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/manifest.json +16 -0
  34. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/metrics.json +9 -0
  35. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/findings.md +10 -0
  36. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/manifest.json +16 -0
  37. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/manifest.json +16 -0
  38. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/scores.json +4 -0
  39. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/summary.txt +1 -0
  40. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/bundle-check.json +8 -0
  41. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/manifest.json +16 -0
  42. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/operator-note.txt +2 -0
  43. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/events-sample.jsonl +3 -0
  44. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/manifest.json +16 -0
  45. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/receipt.txt +1 -0
  46. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/evidence.json +13 -0
  47. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/manifest.json +16 -0
  48. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/summary.txt +2 -0
  49. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/manifest.json +16 -0
  50. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/matrix.json +9 -0
  51. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/notes.md +4 -0
  52. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/console-summary.txt +2 -0
  53. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/manifest.json +16 -0
  54. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/screenshot-standin.ppm +5 -0
  55. phase_loop_runtime/adoption_bundle.py +273 -0
  56. phase_loop_runtime/advisor_board/CONTRACTS.md +257 -0
  57. phase_loop_runtime/advisor_board/__init__.py +288 -0
  58. phase_loop_runtime/advisor_board/backing.py +137 -0
  59. phase_loop_runtime/advisor_board/backing_omnigent.py +498 -0
  60. phase_loop_runtime/advisor_board/config.py +225 -0
  61. phase_loop_runtime/advisor_board/events.py +115 -0
  62. phase_loop_runtime/advisor_board/fixtures/advisor-boards.example.toml +59 -0
  63. phase_loop_runtime/advisor_board/fixtures.py +91 -0
  64. phase_loop_runtime/advisor_board/harness_mapping.py +125 -0
  65. phase_loop_runtime/advisor_board/matrix.py +146 -0
  66. phase_loop_runtime/advisor_board/observability.py +481 -0
  67. phase_loop_runtime/advisor_board/presets.py +188 -0
  68. phase_loop_runtime/advisor_board/registries.py +309 -0
  69. phase_loop_runtime/advisor_board/resolver.py +253 -0
  70. phase_loop_runtime/advisor_board/schema.py +269 -0
  71. phase_loop_runtime/advisor_board/standin.py +166 -0
  72. phase_loop_runtime/advisor_board/validation.py +159 -0
  73. phase_loop_runtime/agent_runtime_provider.py +676 -0
  74. phase_loop_runtime/baml_modular.py +656 -0
  75. phase_loop_runtime/baml_src/dotfiles_adoption_manifest.baml +37 -0
  76. phase_loop_runtime/baml_src/dotfiles_c4_document.baml +14 -0
  77. phase_loop_runtime/baml_src/dotfiles_plan_manifest.baml +50 -0
  78. phase_loop_runtime/baml_src/dotfiles_runtime_projection.baml +16 -0
  79. phase_loop_runtime/baml_src/dotfiles_task_catalog.baml +20 -0
  80. phase_loop_runtime/baml_src/emit_phase_closeout.baml +86 -0
  81. phase_loop_runtime/baml_src/evaluate_suspected_fake_evidence.baml +36 -0
  82. phase_loop_runtime/baml_src/verification_evidence.baml +40 -0
  83. phase_loop_runtime/broker.py +181 -0
  84. phase_loop_runtime/build_bundle.py +294 -0
  85. phase_loop_runtime/capability_registry.py +835 -0
  86. phase_loop_runtime/classifier.py +87 -0
  87. phase_loop_runtime/claude_agent_view.py +625 -0
  88. phase_loop_runtime/claude_channel_sidecar.py +1086 -0
  89. phase_loop_runtime/cli.py +2599 -0
  90. phase_loop_runtime/closeout.py +865 -0
  91. phase_loop_runtime/closeout_classifier.py +120 -0
  92. phase_loop_runtime/closeout_evidence_audit.py +112 -0
  93. phase_loop_runtime/closeout_validation.py +144 -0
  94. phase_loop_runtime/closeout_validators.py +203 -0
  95. phase_loop_runtime/consiliency_gates.py +374 -0
  96. phase_loop_runtime/consiliency_ingest.py +399 -0
  97. phase_loop_runtime/consiliency_layout.py +176 -0
  98. phase_loop_runtime/consiliency_scaffold.py +305 -0
  99. phase_loop_runtime/cross_repo_channel.py +331 -0
  100. phase_loop_runtime/discovery.py +2368 -0
  101. phase_loop_runtime/dispatch_lock.py +167 -0
  102. phase_loop_runtime/doc_delta_validator.py +98 -0
  103. phase_loop_runtime/docs_audit.py +285 -0
  104. phase_loop_runtime/docs_freshness.py +468 -0
  105. phase_loop_runtime/docs_surfaces.py +150 -0
  106. phase_loop_runtime/dotfiles_profile_plugin.py +96 -0
  107. phase_loop_runtime/events.py +154 -0
  108. phase_loop_runtime/events_migration.py +96 -0
  109. phase_loop_runtime/evidence_audit.py +1082 -0
  110. phase_loop_runtime/evidence_audit_config.py +172 -0
  111. phase_loop_runtime/fleet_map.py +426 -0
  112. phase_loop_runtime/fleet_metrics.py +298 -0
  113. phase_loop_runtime/fleet_metrics_export.py +196 -0
  114. phase_loop_runtime/git_discipline.py +479 -0
  115. phase_loop_runtime/git_ops.py +122 -0
  116. phase_loop_runtime/git_topology.py +252 -0
  117. phase_loop_runtime/governed_bundle.py +137 -0
  118. phase_loop_runtime/governed_premerge.py +219 -0
  119. phase_loop_runtime/governed_review.py +264 -0
  120. phase_loop_runtime/handoff.py +932 -0
  121. phase_loop_runtime/handoff_path.py +14 -0
  122. phase_loop_runtime/injection.py +740 -0
  123. phase_loop_runtime/install_status.py +88 -0
  124. phase_loop_runtime/lane_scheduler.py +317 -0
  125. phase_loop_runtime/launcher.py +2760 -0
  126. phase_loop_runtime/lease_store.py +553 -0
  127. phase_loop_runtime/maintenance.py +401 -0
  128. phase_loop_runtime/migrate_handoffs.py +201 -0
  129. phase_loop_runtime/models.py +2144 -0
  130. phase_loop_runtime/observability.py +1290 -0
  131. phase_loop_runtime/panel_invoker.py +1638 -0
  132. phase_loop_runtime/phase_loop_drift_audit.py +388 -0
  133. phase_loop_runtime/phase_worktree_executor.py +433 -0
  134. phase_loop_runtime/pipeline_adapter/__init__.py +12 -0
  135. phase_loop_runtime/pipeline_adapter/branch_ops.py +274 -0
  136. phase_loop_runtime/pipeline_adapter/flag.py +55 -0
  137. phase_loop_runtime/pipeline_adapter/markers.py +13 -0
  138. phase_loop_runtime/pipeline_adapter/merge_policy.py +72 -0
  139. phase_loop_runtime/pipeline_adapter/ratification.py +100 -0
  140. phase_loop_runtime/pipeline_adapter/sibling_matcher.py +123 -0
  141. phase_loop_runtime/plan_ir.py +523 -0
  142. phase_loop_runtime/plan_manifest.py +415 -0
  143. phase_loop_runtime/planner_validation.py +176 -0
  144. phase_loop_runtime/profiles.py +462 -0
  145. phase_loop_runtime/prompts.py +365 -0
  146. phase_loop_runtime/provenance.py +165 -0
  147. phase_loop_runtime/publishing.py +339 -0
  148. phase_loop_runtime/py.typed +1 -0
  149. phase_loop_runtime/reconcile.py +1550 -0
  150. phase_loop_runtime/redaction.py +184 -0
  151. phase_loop_runtime/reflection_sync.py +293 -0
  152. phase_loop_runtime/release_guard.py +186 -0
  153. phase_loop_runtime/render.py +432 -0
  154. phase_loop_runtime/repo_validation.py +427 -0
  155. phase_loop_runtime/review_summary.py +150 -0
  156. phase_loop_runtime/roadmap_lint.py +425 -0
  157. phase_loop_runtime/route_log.py +59 -0
  158. phase_loop_runtime/route_policy/__init__.py +11 -0
  159. phase_loop_runtime/route_policy/fixtures/route_selection.golden.json +263 -0
  160. phase_loop_runtime/runner.py +8400 -0
  161. phase_loop_runtime/runtime_paths.py +225 -0
  162. phase_loop_runtime/runtime_projection.py +113 -0
  163. phase_loop_runtime/runtime_resources.py +36 -0
  164. phase_loop_runtime/schema_export.py +292 -0
  165. phase_loop_runtime/schemas/phase_loop_closeout.schema.json +366 -0
  166. phase_loop_runtime/skill_install.py +176 -0
  167. phase_loop_runtime/skill_inventory.py +662 -0
  168. phase_loop_runtime/skill_paths.py +42 -0
  169. phase_loop_runtime/skill_sources_plugin.py +37 -0
  170. phase_loop_runtime/skills_bundle/claude-advisor-board/README.md +1 -0
  171. phase_loop_runtime/skills_bundle/claude-advisor-board/SKILL.md +38 -0
  172. phase_loop_runtime/skills_bundle/claude-advisor-panel/README.md +1 -0
  173. phase_loop_runtime/skills_bundle/claude-advisor-panel/SKILL.md +38 -0
  174. phase_loop_runtime/skills_bundle/claude-execute-detailed/README.md +1 -0
  175. phase_loop_runtime/skills_bundle/claude-execute-detailed/SKILL.md +152 -0
  176. phase_loop_runtime/skills_bundle/claude-execute-phase/README.md +1 -0
  177. phase_loop_runtime/skills_bundle/claude-execute-phase/SKILL.md +745 -0
  178. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  179. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  180. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  181. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  182. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  183. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  184. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  185. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/state.py +274 -0
  186. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  187. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/team_teardown.sh +32 -0
  188. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/validate_plan_doc.py +642 -0
  189. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/verify_harness.sh +96 -0
  190. phase_loop_runtime/skills_bundle/claude-phase-loop/README.md +1 -0
  191. phase_loop_runtime/skills_bundle/claude-phase-loop/SKILL.md +115 -0
  192. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/README.md +1 -0
  193. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/SKILL.md +439 -0
  194. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/assets/review_prompt.md +19 -0
  195. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  196. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/roadmap-template.md +193 -0
  197. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  198. phase_loop_runtime/skills_bundle/claude-plan-detailed/README.md +1 -0
  199. phase_loop_runtime/skills_bundle/claude-plan-detailed/SKILL.md +334 -0
  200. phase_loop_runtime/skills_bundle/claude-plan-detailed/assets/review_prompt.md +25 -0
  201. phase_loop_runtime/skills_bundle/claude-plan-phase/README.md +1 -0
  202. phase_loop_runtime/skills_bundle/claude-plan-phase/SKILL.md +776 -0
  203. phase_loop_runtime/skills_bundle/claude-plan-phase/assets/review_prompt.md +17 -0
  204. phase_loop_runtime/skills_bundle/claude-plan-phase/scripts/validate_plan_doc.py +1092 -0
  205. phase_loop_runtime/skills_bundle/claude-run-train/README.md +1 -0
  206. phase_loop_runtime/skills_bundle/claude-run-train/SKILL.md +75 -0
  207. phase_loop_runtime/skills_bundle/claude-skill-editor/README.md +1 -0
  208. phase_loop_runtime/skills_bundle/claude-skill-editor/SKILL.md +243 -0
  209. phase_loop_runtime/skills_bundle/claude-skill-editor/assets/editor_prompt.md +72 -0
  210. phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/README.md +1 -0
  211. phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/SKILL.md +249 -0
  212. phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  213. phase_loop_runtime/skills_bundle/claude-task-contextualizer/README.md +1 -0
  214. phase_loop_runtime/skills_bundle/claude-task-contextualizer/SKILL.md +74 -0
  215. phase_loop_runtime/skills_bundle/claude-task-contextualizer/references/task-templates.md +103 -0
  216. phase_loop_runtime/skills_bundle/codex-advisor-board/README.md +1 -0
  217. phase_loop_runtime/skills_bundle/codex-advisor-board/SKILL.md +38 -0
  218. phase_loop_runtime/skills_bundle/codex-advisor-panel/README.md +1 -0
  219. phase_loop_runtime/skills_bundle/codex-advisor-panel/SKILL.md +38 -0
  220. phase_loop_runtime/skills_bundle/codex-execute-detailed/README.md +1 -0
  221. phase_loop_runtime/skills_bundle/codex-execute-detailed/SKILL.md +154 -0
  222. phase_loop_runtime/skills_bundle/codex-execute-phase/README.md +1 -0
  223. phase_loop_runtime/skills_bundle/codex-execute-phase/SKILL.md +231 -0
  224. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  225. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  226. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  227. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  228. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  229. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  230. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  231. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/state.py +274 -0
  232. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  233. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/team_teardown.sh +32 -0
  234. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/validate_plan_doc.py +642 -0
  235. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/verify_harness.sh +96 -0
  236. phase_loop_runtime/skills_bundle/codex-phase-loop/README.md +1 -0
  237. phase_loop_runtime/skills_bundle/codex-phase-loop/SKILL.md +248 -0
  238. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/README.md +1 -0
  239. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/SKILL.md +150 -0
  240. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/assets/review_prompt.md +19 -0
  241. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  242. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/roadmap-template.md +193 -0
  243. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  244. phase_loop_runtime/skills_bundle/codex-plan-detailed/README.md +1 -0
  245. phase_loop_runtime/skills_bundle/codex-plan-detailed/SKILL.md +119 -0
  246. phase_loop_runtime/skills_bundle/codex-plan-detailed/assets/review_prompt.md +25 -0
  247. phase_loop_runtime/skills_bundle/codex-plan-phase/README.md +1 -0
  248. phase_loop_runtime/skills_bundle/codex-plan-phase/SKILL.md +307 -0
  249. phase_loop_runtime/skills_bundle/codex-plan-phase/assets/review_prompt.md +17 -0
  250. phase_loop_runtime/skills_bundle/codex-plan-phase/scripts/validate_plan_doc.py +1092 -0
  251. phase_loop_runtime/skills_bundle/codex-run-train/README.md +1 -0
  252. phase_loop_runtime/skills_bundle/codex-run-train/SKILL.md +75 -0
  253. phase_loop_runtime/skills_bundle/codex-skill-editor/README.md +1 -0
  254. phase_loop_runtime/skills_bundle/codex-skill-editor/SKILL.md +94 -0
  255. phase_loop_runtime/skills_bundle/codex-skill-editor/assets/editor_prompt.md +72 -0
  256. phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/README.md +1 -0
  257. phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/SKILL.md +114 -0
  258. phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  259. phase_loop_runtime/skills_bundle/codex-task-contextualizer/README.md +1 -0
  260. phase_loop_runtime/skills_bundle/codex-task-contextualizer/SKILL.md +102 -0
  261. phase_loop_runtime/skills_bundle/codex-task-contextualizer/references/task-templates.md +103 -0
  262. phase_loop_runtime/skills_bundle/gemini-advisor-board/README.md +1 -0
  263. phase_loop_runtime/skills_bundle/gemini-advisor-board/SKILL.md +38 -0
  264. phase_loop_runtime/skills_bundle/gemini-advisor-panel/README.md +1 -0
  265. phase_loop_runtime/skills_bundle/gemini-advisor-panel/SKILL.md +38 -0
  266. phase_loop_runtime/skills_bundle/gemini-execute-detailed/README.md +1 -0
  267. phase_loop_runtime/skills_bundle/gemini-execute-detailed/SKILL.md +144 -0
  268. phase_loop_runtime/skills_bundle/gemini-execute-phase/README.md +1 -0
  269. phase_loop_runtime/skills_bundle/gemini-execute-phase/SKILL.md +198 -0
  270. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  271. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  272. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  273. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  274. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  275. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  276. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  277. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/state.py +274 -0
  278. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  279. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/team_teardown.sh +32 -0
  280. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/validate_plan_doc.py +642 -0
  281. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/verify_harness.sh +96 -0
  282. phase_loop_runtime/skills_bundle/gemini-phase-loop/README.md +1 -0
  283. phase_loop_runtime/skills_bundle/gemini-phase-loop/SKILL.md +111 -0
  284. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/README.md +1 -0
  285. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/SKILL.md +147 -0
  286. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/assets/review_prompt.md +19 -0
  287. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  288. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/roadmap-template.md +193 -0
  289. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  290. phase_loop_runtime/skills_bundle/gemini-plan-detailed/README.md +1 -0
  291. phase_loop_runtime/skills_bundle/gemini-plan-detailed/SKILL.md +119 -0
  292. phase_loop_runtime/skills_bundle/gemini-plan-detailed/assets/review_prompt.md +25 -0
  293. phase_loop_runtime/skills_bundle/gemini-plan-phase/README.md +1 -0
  294. phase_loop_runtime/skills_bundle/gemini-plan-phase/SKILL.md +217 -0
  295. phase_loop_runtime/skills_bundle/gemini-plan-phase/assets/review_prompt.md +17 -0
  296. phase_loop_runtime/skills_bundle/gemini-plan-phase/scripts/validate_plan_doc.py +1092 -0
  297. phase_loop_runtime/skills_bundle/gemini-run-train/README.md +1 -0
  298. phase_loop_runtime/skills_bundle/gemini-run-train/SKILL.md +75 -0
  299. phase_loop_runtime/skills_bundle/gemini-skill-editor/README.md +1 -0
  300. phase_loop_runtime/skills_bundle/gemini-skill-editor/SKILL.md +87 -0
  301. phase_loop_runtime/skills_bundle/gemini-skill-editor/assets/editor_prompt.md +72 -0
  302. phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/README.md +1 -0
  303. phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/SKILL.md +109 -0
  304. phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  305. phase_loop_runtime/skills_bundle/gemini-task-contextualizer/README.md +1 -0
  306. phase_loop_runtime/skills_bundle/gemini-task-contextualizer/SKILL.md +98 -0
  307. phase_loop_runtime/skills_bundle/gemini-task-contextualizer/references/task-templates.md +103 -0
  308. phase_loop_runtime/skills_bundle/opencode-advisor-board/README.md +1 -0
  309. phase_loop_runtime/skills_bundle/opencode-advisor-board/SKILL.md +38 -0
  310. phase_loop_runtime/skills_bundle/opencode-advisor-panel/README.md +1 -0
  311. phase_loop_runtime/skills_bundle/opencode-advisor-panel/SKILL.md +38 -0
  312. phase_loop_runtime/skills_bundle/opencode-execute-detailed/README.md +1 -0
  313. phase_loop_runtime/skills_bundle/opencode-execute-detailed/SKILL.md +144 -0
  314. phase_loop_runtime/skills_bundle/opencode-execute-phase/README.md +1 -0
  315. phase_loop_runtime/skills_bundle/opencode-execute-phase/SKILL.md +196 -0
  316. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  317. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  318. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  319. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  320. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  321. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  322. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  323. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/state.py +274 -0
  324. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  325. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/team_teardown.sh +32 -0
  326. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/validate_plan_doc.py +642 -0
  327. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/verify_harness.sh +96 -0
  328. phase_loop_runtime/skills_bundle/opencode-phase-loop/README.md +1 -0
  329. phase_loop_runtime/skills_bundle/opencode-phase-loop/SKILL.md +113 -0
  330. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/README.md +1 -0
  331. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/SKILL.md +147 -0
  332. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/assets/review_prompt.md +19 -0
  333. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  334. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/roadmap-template.md +193 -0
  335. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  336. phase_loop_runtime/skills_bundle/opencode-plan-detailed/README.md +1 -0
  337. phase_loop_runtime/skills_bundle/opencode-plan-detailed/SKILL.md +119 -0
  338. phase_loop_runtime/skills_bundle/opencode-plan-detailed/assets/review_prompt.md +25 -0
  339. phase_loop_runtime/skills_bundle/opencode-plan-phase/README.md +1 -0
  340. phase_loop_runtime/skills_bundle/opencode-plan-phase/SKILL.md +211 -0
  341. phase_loop_runtime/skills_bundle/opencode-plan-phase/assets/review_prompt.md +17 -0
  342. phase_loop_runtime/skills_bundle/opencode-plan-phase/scripts/validate_plan_doc.py +1092 -0
  343. phase_loop_runtime/skills_bundle/opencode-run-train/README.md +1 -0
  344. phase_loop_runtime/skills_bundle/opencode-run-train/SKILL.md +75 -0
  345. phase_loop_runtime/skills_bundle/opencode-skill-editor/README.md +1 -0
  346. phase_loop_runtime/skills_bundle/opencode-skill-editor/SKILL.md +87 -0
  347. phase_loop_runtime/skills_bundle/opencode-skill-editor/assets/editor_prompt.md +72 -0
  348. phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/README.md +1 -0
  349. phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/SKILL.md +109 -0
  350. phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  351. phase_loop_runtime/skills_bundle/opencode-task-contextualizer/README.md +1 -0
  352. phase_loop_runtime/skills_bundle/opencode-task-contextualizer/SKILL.md +98 -0
  353. phase_loop_runtime/skills_bundle/opencode-task-contextualizer/references/task-templates.md +103 -0
  354. phase_loop_runtime/state.py +156 -0
  355. phase_loop_runtime/state_degradation.py +136 -0
  356. phase_loop_runtime/state_ops.py +676 -0
  357. phase_loop_runtime/train_ledger.py +215 -0
  358. phase_loop_runtime/train_roadmap.py +339 -0
  359. phase_loop_runtime/train_runner.py +1197 -0
  360. phase_loop_runtime/verification_evidence.py +650 -0
  361. phase_loop_runtime/verification_evidence_validator.py +75 -0
  362. phase_loop_runtime/visual_evidence_validator.py +36 -0
  363. phase_loop_runtime/worker_pool.py +123 -0
  364. phase_loop_runtime/worktree_index.py +270 -0
  365. phase_loop_runtime-0.2.0.data/data/share/phase-loop-runtime/protocol/protocol.md +2475 -0
  366. phase_loop_runtime-0.2.0.dist-info/METADATA +106 -0
  367. phase_loop_runtime-0.2.0.dist-info/RECORD +370 -0
  368. phase_loop_runtime-0.2.0.dist-info/WHEEL +5 -0
  369. phase_loop_runtime-0.2.0.dist-info/entry_points.txt +9 -0
  370. 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:]))