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,1197 @@
1
+ """Cross-repo release-train coordinator (P3 + P4).
2
+
3
+ P3: Serial draft-PR execution: topo-sort the train, preflight ALL repos, then
4
+ per node (in topo order): inject upstream draft ref via set_upstream_ref →
5
+ invoke the unchanged per-repo run_loop → publish a draft PR → append to ledger.
6
+
7
+ P4 (``_merge_phase_enabled=True``): After all draft PRs are open, hold for a
8
+ train-level governed review (one round, reusing ``run_governed_premerge_loop``).
9
+ On panel approval, merge sequentially in topo order. Before merging each
10
+ downstream node, re-resolve its channel to the upstream MERGED SHA and re-verify
11
+ (the false-green killer — NEVER merge a downstream that was only green against
12
+ the draft ref). Forward-only: a downstream failure does NOT revert merged
13
+ upstream nodes; use expand/contract upstream contracts to keep sequential merges
14
+ safe.
15
+
16
+ Safety invariants (enforced structurally, asserted in tests):
17
+ 1. **Zero-PRs-on-preflight-failure**: preflight runs on ALL repos before the
18
+ per-node loop is entered. If any check fails, ``run_train`` returns
19
+ immediately with ``status="preflight_failed"`` and zero publish calls.
20
+ Train-schema validation (T-A/B/C/D via ``validate_train_loud``) runs as
21
+ part of this gate — a malformed train (e.g. a ``none``-channel dependency
22
+ edge) opens zero PRs.
23
+ 2. **Draft-only** (P3): every ``publish_from_worktree`` call uses
24
+ ``draft=True``. P3 never merges.
25
+ 3. **Train state off .phase-loop/**: ledger_path is caller-supplied and must
26
+ pass ``_assert_not_phase_loop``; the coordinator never touches any repo's
27
+ ``.phase-loop/`` directory.
28
+ 4. **Resumable with upstream-change detection**: a partial run leaves prior
29
+ nodes' draft PRs open and the failed node ``blocked`` in the ledger.
30
+ Re-running re-reads both the ledger and live PR state; confirmed-open
31
+ nodes are skipped unless an upstream changed (rebuilt this run, or its live
32
+ head SHA diverged from the ledger — out-of-band push). When an upstream
33
+ changed and the downstream's PR is already open, the downstream is
34
+ **blocked with a clear reason** (``upstream_changed_downstream_pr_open``)
35
+ so the user can close the stale PR and re-run.
36
+ NOTE: automatic downstream rebuild when an upstream changes requires an
37
+ update-existing-PR primitive and is deferred to a future release.
38
+ 5. **Exception safety**: if inject or run_loop raises, the node is marked
39
+ ``blocked`` in the ledger (never left stuck at ``running``).
40
+ 6. **Autonomy boundary** (P4): cross-repo merges are NEVER auto-merged. In
41
+ autonomous mode with ``_merge_phase_enabled=True`` the coordinator stops at
42
+ ``status="drafts_open"`` so the operator can review and re-run with
43
+ ``--governed``. Only ``run_mode="governed"`` proceeds to review + merge.
44
+ 7. **False-green killer** (P4): before merging each downstream node,
45
+ ``set_upstream_ref`` is called with the upstream MERGED SHA (not the draft
46
+ SHA) and ``_reverify_fn`` re-verifies the downstream. A downstream that
47
+ was green only against the draft ref is blocked (``merge_halted``).
48
+ 8. **Idempotent resume** (P4): ledger ``merged`` records carry
49
+ ``upstream_merge_sha`` (the real merge-commit SHA) plus ``branch``,
50
+ ``pr_url``, and ``head_sha`` so the resumed run can skip already-merged
51
+ nodes without double-merging and can still inject the correct draft SHA
52
+ for any remaining downstream nodes.
53
+
54
+ All git/gh/run_loop/publish/review boundaries are injectable seams so the
55
+ module is fully testable without live network access.
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import subprocess
60
+ from pathlib import Path
61
+ from typing import Callable, Dict, List, Optional, Sequence, Set
62
+
63
+ from .cross_repo_channel import ChannelDescriptor, set_upstream_ref
64
+ from .train_ledger import LedgerRecord, append_record, read_ledger
65
+ from .train_roadmap import TrainEdge, TrainNode, TrainRoadmap
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Types
69
+
70
+ ResolveWorkspace = Callable[[TrainNode], Path]
71
+ ResolveOwnedPaths = Callable[[TrainNode], Sequence[str]]
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Preflight check functions
75
+ # Each is a module-level function so tests can patch it individually.
76
+
77
+
78
+ def _check_gh_auth() -> Optional[str]:
79
+ """Return an error string if gh auth is not valid, else None.
80
+
81
+ Stubbable seam: ``patch("phase_loop_runtime.train_runner._check_gh_auth")``.
82
+ """
83
+ completed = subprocess.run(
84
+ ["gh", "auth", "status"],
85
+ capture_output=True,
86
+ text=True,
87
+ timeout=15,
88
+ )
89
+ if completed.returncode != 0:
90
+ return f"gh auth status failed: {completed.stderr.strip() or 'not authenticated'}"
91
+ return None
92
+
93
+
94
+ def _check_repo_clean(workspace: Path, node_id: str) -> Optional[str]:
95
+ """Return an error string if the workspace has uncommitted changes, else None."""
96
+ completed = subprocess.run(
97
+ ["git", "-C", str(workspace), "status", "--short"],
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=15,
101
+ )
102
+ if completed.returncode != 0:
103
+ return (
104
+ f"[{node_id}] git status failed "
105
+ f"(workspace may not be a git repo): {completed.stderr.strip()}"
106
+ )
107
+ if completed.stdout.strip():
108
+ return f"[{node_id}] workspace '{workspace}' has uncommitted changes — preflight failed"
109
+ return None
110
+
111
+
112
+ def _check_remote_reachable(workspace: Path, node_id: str, remote: str = "origin") -> Optional[str]:
113
+ """Return an error string if the remote is not reachable, else None."""
114
+ completed = subprocess.run(
115
+ ["git", "-C", str(workspace), "ls-remote", "--exit-code", remote, "HEAD"],
116
+ capture_output=True,
117
+ text=True,
118
+ timeout=30,
119
+ )
120
+ if completed.returncode != 0:
121
+ return (
122
+ f"[{node_id}] remote '{remote}' is not reachable: "
123
+ f"{completed.stderr.strip() or 'ls-remote failed'}"
124
+ )
125
+ return None
126
+
127
+
128
+ def _check_base_branch_exists(
129
+ workspace: Path, node_id: str, base: str = "main"
130
+ ) -> Optional[str]:
131
+ """Return an error string if origin/<base> does not exist, else None."""
132
+ completed = subprocess.run(
133
+ ["git", "-C", str(workspace), "rev-parse", "--verify", f"origin/{base}"],
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=10,
137
+ )
138
+ if completed.returncode != 0:
139
+ return f"[{node_id}] base branch 'origin/{base}' does not exist"
140
+ return None
141
+
142
+
143
+ def _default_preflight(
144
+ nodes: List[TrainNode],
145
+ resolve_workspace: ResolveWorkspace,
146
+ ) -> List[str]:
147
+ """Run all preflight checks across all nodes; return list of errors (empty = pass).
148
+
149
+ Checks (in order):
150
+ 1. ``gh auth status`` — once, globally.
151
+ 2. Per-repo: workspace clean (no uncommitted changes).
152
+ 3. Per-repo: remote ``origin`` is reachable.
153
+ 4. Per-repo: base branch ``origin/main`` exists.
154
+
155
+ A non-empty return means the entry gate is closed; zero PRs must be opened.
156
+ """
157
+ errors: List[str] = []
158
+
159
+ # gh auth — once globally, before touching any repo
160
+ auth_err = _check_gh_auth()
161
+ if auth_err:
162
+ errors.append(auth_err)
163
+
164
+ for node in nodes:
165
+ workspace = resolve_workspace(node)
166
+ for check_fn in (
167
+ _check_repo_clean,
168
+ _check_remote_reachable,
169
+ _check_base_branch_exists,
170
+ ):
171
+ err = check_fn(workspace, node.node_id)
172
+ if err:
173
+ errors.append(err)
174
+
175
+ return errors
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Live PR state seams
180
+
181
+
182
+ def _live_pr_is_open(workspace: Path, branch: str) -> bool:
183
+ """Return True if ``branch`` has an open PR on the remote.
184
+
185
+ Stubbable seam for tests. Uses ``_gh_pr_metadata`` from ``git_topology``
186
+ (already reused by the P1 publish primitive for the same reason).
187
+ """
188
+ from .git_topology import _gh_pr_metadata
189
+
190
+ meta = _gh_pr_metadata(workspace, branch)
191
+ return bool(meta.get("pr_url"))
192
+
193
+
194
+ def _live_pr_head_sha(workspace: Path, branch: str) -> Optional[str]:
195
+ """Return the live PR head commit SHA for ``branch``, or None if unavailable.
196
+
197
+ Queries ``gh pr list`` (the same endpoint as ``_gh_pr_metadata``, which uses
198
+ the proven ``--head <branch>`` flag) and extracts ``headRefOid`` — the commit
199
+ SHA at the PR's head, which may differ from the ledger-recorded value if the
200
+ branch was force-pushed since the last run.
201
+
202
+ Uses ``gh pr list --head <branch>`` (not ``gh pr view``, which takes a PR
203
+ number, not a branch ref).
204
+
205
+ Stubbable seam: inject ``_live_pr_head_sha_fn`` into :func:`run_train`.
206
+ """
207
+ try:
208
+ completed = subprocess.run(
209
+ [
210
+ "gh", "pr", "list",
211
+ "--head", branch,
212
+ "--state", "open",
213
+ "--limit", "1",
214
+ "--json", "headRefOid",
215
+ "--jq", ".[0].headRefOid",
216
+ ],
217
+ capture_output=True,
218
+ text=True,
219
+ timeout=15,
220
+ cwd=str(workspace),
221
+ )
222
+ sha = completed.stdout.strip() if completed.returncode == 0 else ""
223
+ return sha or None
224
+ except Exception:
225
+ return None
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # P4 merge seams — stubbed in tests; live implementations below.
230
+
231
+ #: Synthetic ledger node_id used to record the train-level review approval.
232
+ #: Not a real roadmap node — only stored in the coordinator ledger.
233
+ _TRAIN_REVIEW_NODE_ID: str = "_train_review_"
234
+
235
+
236
+ def _live_merge_pr(workspace: Path, branch: str) -> str:
237
+ """Merge the PR for ``branch`` via the GitHub CLI; return the merge commit SHA.
238
+
239
+ Idempotent: checks ``_live_pr_merged_sha`` before issuing the merge command.
240
+ Already-merged PRs return the existing SHA without error.
241
+
242
+ Stubbable seam: inject ``_merge_pr_fn`` into :func:`run_train`.
243
+ """
244
+ # Idempotent guard: if already merged, return the existing SHA.
245
+ existing = _live_pr_merged_sha(workspace, branch)
246
+ if existing:
247
+ return existing
248
+
249
+ subprocess.run(
250
+ ["gh", "pr", "merge", branch, "--merge", "--delete-branch", "--yes"],
251
+ cwd=str(workspace),
252
+ check=True,
253
+ capture_output=True,
254
+ text=True,
255
+ timeout=120,
256
+ )
257
+ result = subprocess.run(
258
+ [
259
+ "gh", "pr", "view", branch,
260
+ "--json", "mergeCommit",
261
+ "--jq", ".mergeCommit.oid",
262
+ ],
263
+ cwd=str(workspace),
264
+ capture_output=True,
265
+ text=True,
266
+ timeout=30,
267
+ )
268
+ sha = result.stdout.strip()
269
+ if not sha or sha == "null":
270
+ raise RuntimeError(
271
+ f"could not determine merge commit SHA for branch '{branch}' in "
272
+ f"'{workspace}'; gh pr view returned no mergeCommit.oid after merge"
273
+ )
274
+ return sha
275
+
276
+
277
+ def _train_reverify_enforcement_mode() -> str:
278
+ import os
279
+
280
+ value = os.environ.get("PHASE_LOOP_VERIFY_ENFORCE", "warn").strip().lower()
281
+ return "hard" if value == "hard" else "warn"
282
+
283
+
284
+ def _live_reverify(workspace: Path, roadmap_path: Path, run_mode: str) -> bool:
285
+ """Re-verify a downstream node against the injected upstream merged pin.
286
+
287
+ Called after ``set_upstream_ref`` writes the merged SHA into the downstream
288
+ workspace. Runs the downstream's verification commands (from its plan file)
289
+ directly against that workspace and returns True only when they all pass.
290
+
291
+ This is the live default for ``_reverify_fn``. Tests stub this seam.
292
+
293
+ NOTE: Does NOT re-publish or open a new PR — only re-verifies. The
294
+ existing draft PR from P3 remains open. The merged-pin file that
295
+ ``set_upstream_ref`` wrote is read by whatever commands the plan declares
296
+ in its ``## Verification`` section.
297
+
298
+ Fail-closed: if the plan cannot be located or verification cannot be run
299
+ (for any reason), returns False — never silently green.
300
+ """
301
+ import os
302
+
303
+ from .discovery import find_plan_artifact, resolve_suite_command_doc, verification_commands_from_plan
304
+ from .reconcile import reconcile
305
+ from .verification_evidence import (
306
+ ARTIFACT_NAME,
307
+ detect_changed_dependency_manifests,
308
+ resolve_install_command,
309
+ run_verification,
310
+ validate_verification_artifact,
311
+ )
312
+
313
+ try:
314
+ # 1. Find the current phase from the workspace state. The node was
315
+ # left at awaiting_phase_closeout by the P3 run_loop call; reconcile
316
+ # reads the persisted state and event log to reconstruct that status.
317
+ snapshot = reconcile(workspace, roadmap_path)
318
+ phase = snapshot.current_phase
319
+ if phase is None:
320
+ # Fallback: scan for any phase still at awaiting_phase_closeout.
321
+ for ph, status in snapshot.phases.items():
322
+ if status == "awaiting_phase_closeout":
323
+ phase = ph
324
+ break
325
+ if phase is None:
326
+ # Cannot determine which phase to verify — fail closed.
327
+ return False
328
+
329
+ # 2. Locate the plan file (same resolver the closeout path uses).
330
+ plan = find_plan_artifact(workspace, phase, roadmap=roadmap_path)
331
+ if plan is None:
332
+ # No plan = cannot verify → fail closed.
333
+ return False
334
+
335
+ # 3. Extract verification commands from the plan.
336
+ commands, operational_exemptions = verification_commands_from_plan(plan)
337
+ suite_command, suite_findings = resolve_suite_command_doc(workspace, roadmap_path, plan)
338
+ if suite_findings:
339
+ # Malformed suite command — fail closed.
340
+ return False
341
+
342
+ # 4. If the plan declares no verification at all:
343
+ # - Under PHASE_LOOP_VERIFY_ENFORCE=hard, the re-verify gate cannot
344
+ # prove the downstream still survives the upstream's MERGED-pin
345
+ # contract, so it must fail-closed (return False -> the train halts
346
+ # at merge_halted). This is a NON-HUMAN terminal -- never adds
347
+ # human_required -- preserving autonomy-first. Mirrors the single-repo
348
+ # execute preflight (runner.py `_verification_enforcement_mode` /
349
+ # `_execute_verification_preflight_blocker`, which blocks on a missing
350
+ # suite command under hard enforce). [#39]
351
+ # - Under warn (the default), treat as a trivial pass (the plan author
352
+ # deliberately chose not to add verification).
353
+ if not commands and suite_command is None:
354
+ return _train_reverify_enforcement_mode() != "hard"
355
+
356
+ # 5. Run verification against the workspace. set_upstream_ref has
357
+ # already written the merged-pin file, so commands that read the
358
+ # pin file will see the merged SHA.
359
+ manifests = detect_changed_dependency_manifests(workspace, "HEAD")
360
+ install_argv = resolve_install_command(workspace, manifests) if manifests else None
361
+ env_refresh = (
362
+ {
363
+ "triggered": True,
364
+ "manifests": manifests,
365
+ "install_argv": install_argv or [],
366
+ "exit_code": 127,
367
+ }
368
+ if manifests and install_argv is None
369
+ else (
370
+ {"triggered": True, "manifests": manifests, "install_argv": install_argv}
371
+ if manifests
372
+ else None
373
+ )
374
+ )
375
+ timeout_s = float(os.environ.get("PHASE_LOOP_VERIFY_TIMEOUT_SECONDS", "1200"))
376
+ # run_verification requires run_dir inside the workspace (the same
377
+ # constraint run_artifacts enforces). Use a timestamped subdirectory
378
+ # under .phase-loop/runs/ so the artifact is discoverable.
379
+ from .models import utc_now
380
+ run_id = f"{utc_now().replace(':', '').replace('-', '').replace('Z', 'Z')}-reverify"
381
+ run_dir = workspace / ".phase-loop" / "runs" / run_id
382
+ run_dir.mkdir(parents=True, exist_ok=True)
383
+ run_verification(
384
+ workspace,
385
+ run_dir,
386
+ commands,
387
+ suite_command,
388
+ env_refresh,
389
+ timeout_s,
390
+ operational_exemptions=operational_exemptions,
391
+ )
392
+ artifact_path = run_dir / ARTIFACT_NAME
393
+ validation = validate_verification_artifact(artifact_path)
394
+ return validation.ok
395
+ except Exception:
396
+ return False
397
+
398
+
399
+ def _live_pr_merged_sha(workspace: Path, branch: str) -> Optional[str]:
400
+ """Return the merge-commit SHA if the PR for ``branch`` is already merged, else None.
401
+
402
+ Used for idempotent resume: the merge loop skips nodes whose PR has already
403
+ landed on main (covers crash-between-merge-and-ledger-write).
404
+
405
+ Stubbable seam: inject ``_pr_merged_sha_fn`` into :func:`run_train`.
406
+ """
407
+ try:
408
+ result = subprocess.run(
409
+ [
410
+ "gh", "pr", "view", branch,
411
+ "--json", "state,mergeCommit",
412
+ "--jq", 'if .state == "MERGED" then .mergeCommit.oid else null end',
413
+ ],
414
+ cwd=str(workspace),
415
+ capture_output=True,
416
+ text=True,
417
+ timeout=15,
418
+ )
419
+ sha = result.stdout.strip()
420
+ return sha if (result.returncode == 0 and sha and sha not in {"null", ""}) else None
421
+ except Exception:
422
+ return None
423
+
424
+
425
+ def _default_train_review(artifact: str, run_mode: str) -> "LoopResult":
426
+ """Train-level governed review: one-round bounded panel review.
427
+
428
+ Returns a :class:`LoopResult` with ``mergeable=True`` on approval or a
429
+ non-human terminal blocker (``human_required=False``) on rejection.
430
+
431
+ In ``autonomous`` mode ``run_governed_premerge_loop`` short-circuits to
432
+ ``mergeable=True`` without spawning a panel — callers should gate on
433
+ ``run_mode == "governed"`` before reaching here (P4 enforces this).
434
+
435
+ Stubbable seam: inject ``_train_review_fn`` into :func:`run_train`.
436
+ """
437
+ from .governed_premerge import LoopResult, run_governed_premerge_loop
438
+
439
+ return run_governed_premerge_loop(
440
+ artifact=artifact,
441
+ author_executor="train-coordinator",
442
+ run_mode=run_mode,
443
+ max_rounds=1,
444
+ apply_fix=None,
445
+ )
446
+
447
+
448
+ def _build_train_review_bundle(
449
+ roadmap: "TrainRoadmap",
450
+ completed_nodes: Dict[str, Dict],
451
+ topo_order: "List[TrainNode]",
452
+ ) -> str:
453
+ """Build the artifact text for the train-level review panel.
454
+
455
+ Summarises all draft PRs in merge order so the panel can review the
456
+ cross-repo change as one logical unit.
457
+ """
458
+ lines: List[str] = [
459
+ "# Train-level bundle review\n\n",
460
+ f"**Train:** `{roadmap.title}`\n\n",
461
+ "## Draft PRs (merge order)\n\n",
462
+ "Review the following PRs as **one logical cross-repo change**.\n",
463
+ "Approve (AGREE) only if the change is correct as a unit.\n\n",
464
+ ]
465
+ for i, node in enumerate(topo_order, 1):
466
+ nid = node.node_id
467
+ info = completed_nodes.get(nid, {})
468
+ pr_url = info.get("pr_url", "(unknown)")
469
+ head_sha = info.get("head_sha") or "?"
470
+ short_sha = head_sha[:8] if len(head_sha) >= 8 else head_sha
471
+ lines.append(f"{i}. **`{nid}`** — [PR]({pr_url}) (draft `{short_sha}`)\n")
472
+ lines.append(
473
+ "\n---\n"
474
+ "Reject (DISAGREE) with specific blocking concerns if not ready.\n"
475
+ )
476
+ return "".join(lines)
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # PR body builder
481
+
482
+
483
+ def _build_pr_body(
484
+ node: TrainNode,
485
+ topo_order: List[TrainNode],
486
+ upstream_results: Dict[str, Dict],
487
+ upstream_edges: List[TrainEdge],
488
+ ) -> str:
489
+ """Build the PR body with cross-repo dependency links and merge order.
490
+
491
+ At creation time, upstream PRs are already open (topo order guarantees
492
+ it). Downstream PRs are not yet open, so only backward-links are included.
493
+ """
494
+ lines: List[str] = [
495
+ f"## Cross-repo release train\n\n",
496
+ f"**Node:** `{node.node_id}`\n\n",
497
+ ]
498
+
499
+ if upstream_edges:
500
+ lines.append("### Upstream dependencies (must merge first)\n\n")
501
+ for edge in upstream_edges:
502
+ result = upstream_results.get(edge.upstream.node_id, {})
503
+ pr_url = result.get("pr_url", "(not yet open)")
504
+ lines.append(f"- [{edge.upstream.node_id}]({pr_url})\n")
505
+ lines.append("\n")
506
+
507
+ lines.append("### Train merge order\n\n")
508
+ for i, n in enumerate(topo_order, 1):
509
+ marker = " **(this PR)**" if n.node_id == node.node_id else ""
510
+ lines.append(f"{i}. `{n.node_id}`{marker}\n")
511
+
512
+ return "".join(lines)
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # Main coordinator
517
+
518
+
519
+ def run_train(
520
+ roadmap: TrainRoadmap,
521
+ ledger_path: Path,
522
+ *,
523
+ run_mode: str = "autonomous",
524
+ resolve_workspace: ResolveWorkspace,
525
+ resolve_owned_paths: Optional[ResolveOwnedPaths] = None,
526
+ # Injectable seams — default to the live implementations; tests override.
527
+ _run_loop: Optional[Callable] = None,
528
+ _publish: Optional[Callable] = None,
529
+ _set_upstream_ref_fn: Optional[Callable] = None,
530
+ _pr_is_open: Optional[Callable] = None,
531
+ _live_pr_head_sha_fn: Optional[Callable] = None,
532
+ _preflight_fn: Optional[Callable] = None,
533
+ # P4 gate: False (default) preserves P3 behavior for all existing callers.
534
+ # The CLI sets this True; run_mode then determines autonomous vs governed.
535
+ _merge_phase_enabled: bool = False,
536
+ # P4 seams — unused when _merge_phase_enabled is False.
537
+ _merge_pr_fn: Optional[Callable] = None, # (workspace, branch) → merged_sha
538
+ _reverify_fn: Optional[Callable] = None, # (workspace, roadmap_path, run_mode) → bool
539
+ _train_review_fn: Optional[Callable] = None, # (artifact, run_mode) → LoopResult
540
+ _pr_merged_sha_fn: Optional[Callable] = None, # (workspace, branch) → Optional[str]
541
+ ) -> Dict:
542
+ """Coordinate a cross-repo release train: preflight, topo-sort, draft-PR open [+ merge].
543
+
544
+ Parameters
545
+ ----------
546
+ roadmap:
547
+ Parsed ``TrainRoadmap`` (P2 schema).
548
+ ledger_path:
549
+ Path to the coordinator-side ledger file. Must not be inside any
550
+ repo's ``.phase-loop/`` (enforced by ``append_record``).
551
+ run_mode:
552
+ ``"autonomous"`` or ``"governed"``. Passed unchanged to each
553
+ per-repo ``run_loop`` call and to the P4 review/merge gate.
554
+ resolve_workspace:
555
+ Maps a ``TrainNode`` to its workspace ``Path`` on disk.
556
+ resolve_owned_paths:
557
+ Maps a ``TrainNode`` to the list of paths the publish primitive
558
+ should stage. When ``None`` (the default for real end-to-end runs),
559
+ the coordinator uses the paths produced by ``run_loop`` itself:
560
+ ``StateSnapshot.phase_owned_dirty_paths`` (or ``dirty_paths`` as
561
+ fallback). Callers may pass an explicit resolver to override this
562
+ (e.g. tests, or callers that know the paths ahead of time).
563
+ _run_loop, _publish, _set_upstream_ref_fn, _pr_is_open,
564
+ _live_pr_head_sha_fn, _preflight_fn:
565
+ P3 injectable seams for testing. Each defaults to the corresponding
566
+ live implementation.
567
+ _merge_phase_enabled:
568
+ When ``False`` (default): P3 behavior only — returns
569
+ ``{"status": "completed"}`` once all draft PRs are open. This
570
+ preserves backward compatibility with all P3 callers and tests.
571
+ When ``True``: P4 merge logic activates. Autonomous mode stops at
572
+ ``{"status": "drafts_open"}``; governed mode proceeds to review+merge.
573
+ _merge_pr_fn, _reverify_fn, _train_review_fn, _pr_merged_sha_fn:
574
+ P4 injectable seams. Each defaults to the corresponding live
575
+ implementation. Unused when ``_merge_phase_enabled=False``.
576
+
577
+ Returns
578
+ -------
579
+ dict
580
+ P3 statuses:
581
+ ``{"status": "completed", "nodes": {…}}`` — all draft PRs open
582
+ (``_merge_phase_enabled=False``);
583
+ ``{"status": "blocked", "node_id": …, "detail": …}`` — P3 node
584
+ failed; prior PRs remain open (resumable);
585
+ ``{"status": "preflight_failed", "errors": […]}`` — zero PRs opened.
586
+ P4 statuses (``_merge_phase_enabled=True``):
587
+ ``{"status": "drafts_open", "nodes": {…}}`` — all draft PRs open;
588
+ autonomous mode terminal (no merge);
589
+ ``{"status": "review_halted", …}`` — panel rejected; ZERO merges
590
+ (``terminal_blocker`` carries ``human_required=False``);
591
+ ``{"status": "merged", "nodes": {nid: {branch, merged_sha}}}`` —
592
+ all nodes merged in topo order;
593
+ ``{"status": "merge_halted", "node_id": …, "reason": …}`` —
594
+ downstream re-verify failed; upstream stays merged (forward-only).
595
+ """
596
+ # Resolve seams
597
+ from .publishing import publish_from_worktree as _default_publish
598
+ from .runner import run_loop as _default_run_loop
599
+
600
+ run_loop_fn = _run_loop if _run_loop is not None else _default_run_loop
601
+ publish_fn = _publish if _publish is not None else _default_publish
602
+ set_upstream_ref_fn = (
603
+ _set_upstream_ref_fn if _set_upstream_ref_fn is not None else set_upstream_ref
604
+ )
605
+ pr_is_open_fn = _pr_is_open if _pr_is_open is not None else _live_pr_is_open
606
+ live_pr_head_sha_fn = (
607
+ _live_pr_head_sha_fn if _live_pr_head_sha_fn is not None else _live_pr_head_sha
608
+ )
609
+ preflight_fn = _preflight_fn if _preflight_fn is not None else _default_preflight
610
+
611
+ # Track whether caller supplied an explicit owned-paths resolver so we know
612
+ # whether to fall back to the run_loop-produced snapshot paths (Finding #1).
613
+ _explicit_owned_paths = resolve_owned_paths is not None
614
+
615
+ # --- Step 0: Train-schema validation (T-A/B/C/D) — BEFORE any PR ------
616
+ # A malformed train (e.g. a none-channel dependency edge) must open ZERO
617
+ # PRs. validate_train_loud raises ValueError on any violation.
618
+ from .train_roadmap import validate_train_loud
619
+
620
+ try:
621
+ validate_train_loud(roadmap)
622
+ except ValueError as exc:
623
+ return {
624
+ "status": "preflight_failed",
625
+ "errors": [f"train validation failed: {exc}"],
626
+ }
627
+
628
+ # --- Step 1: Topo-sort (raises ValueError on cycle) --------------------
629
+ topo_order = roadmap.topo_order()
630
+
631
+ # --- Step 2: Train-level preflight — ALL repos, BEFORE any PR ----------
632
+ # This is the structural guarantee that preflight failure → zero PRs:
633
+ # we return immediately here, before the per-node loop is entered.
634
+ preflight_errors = preflight_fn(topo_order, resolve_workspace)
635
+ if preflight_errors:
636
+ return {
637
+ "status": "preflight_failed",
638
+ "errors": preflight_errors,
639
+ }
640
+
641
+ # --- Step 3: Re-read ledger + live PR state (resume support) -----------
642
+ ledger_state = read_ledger(ledger_path)
643
+ # completed_nodes: node_id → {branch, head_sha, pr_url}
644
+ # These are the upstream refs the coordinator can inject into downstream
645
+ # nodes via set_upstream_ref (IF-0-P2-2).
646
+ completed_nodes: Dict[str, Dict] = {}
647
+ # out_of_band_upstreams: nodes whose live PR head SHA differs from the
648
+ # ledger-recorded head_sha — an out-of-band push since the last run.
649
+ out_of_band_upstreams: Set[str] = set()
650
+
651
+ for node in topo_order:
652
+ nid = node.node_id
653
+ rec = ledger_state.get(nid)
654
+ if rec and rec.status in ("pr_open", "merged") and rec.branch and rec.pr_url:
655
+ workspace = resolve_workspace(node)
656
+ if rec.status == "pr_open":
657
+ if not pr_is_open_fn(workspace, rec.branch):
658
+ # In P4 mode: before dropping, check whether this PR was
659
+ # already merged on GitHub (crash window between the merge
660
+ # call and the ledger write). If merged, recover the SHA
661
+ # and add to completed_nodes so downstream injection works
662
+ # correctly; write a merged record so the P4 merge loop
663
+ # skips it (idempotent, forward-only).
664
+ if _merge_phase_enabled:
665
+ _step3_merged_sha_fn = (
666
+ _pr_merged_sha_fn if _pr_merged_sha_fn is not None
667
+ else _live_pr_merged_sha
668
+ )
669
+ _recovered_sha = _step3_merged_sha_fn(workspace, rec.branch)
670
+ if _recovered_sha:
671
+ _recovered_rec = LedgerRecord(
672
+ node_id=nid,
673
+ status="merged",
674
+ branch=rec.branch,
675
+ pr_url=rec.pr_url,
676
+ head_sha=rec.head_sha,
677
+ upstream_merge_sha=_recovered_sha,
678
+ )
679
+ append_record(ledger_path, _recovered_rec)
680
+ # Update in-memory view so Step 4's merged-node skip
681
+ # (nid_rec.status == "merged" → continue) fires correctly.
682
+ ledger_state[nid] = _recovered_rec
683
+ completed_nodes[nid] = {
684
+ "branch": rec.branch,
685
+ "head_sha": rec.head_sha,
686
+ "pr_url": rec.pr_url,
687
+ }
688
+ continue # not open: recovered-as-merged or dropped
689
+ # Prefer the live PR head SHA (the branch may have been updated
690
+ # since the last run); fall back to the ledger-recorded head_sha.
691
+ live_sha = live_pr_head_sha_fn(workspace, rec.branch)
692
+ head_sha = live_sha or rec.head_sha
693
+ # Detect out-of-band push: live SHA exists and differs from ledger.
694
+ if live_sha and rec.head_sha and live_sha != rec.head_sha:
695
+ out_of_band_upstreams.add(nid)
696
+ else:
697
+ # status == "merged" — P4 resume: PR is already merged, no live check.
698
+ # head_sha carries the draft SHA (written to the merged record by P4
699
+ # so this node can still serve as the injection ref for any P3 nodes
700
+ # that were not yet processed before the crash — see merged-record write).
701
+ head_sha = rec.head_sha
702
+ completed_nodes[nid] = {
703
+ "branch": rec.branch,
704
+ "head_sha": head_sha,
705
+ "pr_url": rec.pr_url,
706
+ }
707
+
708
+ # --- Step 4: Execute in topo order ------------------------------------
709
+ # rebuilt_this_run tracks nodes where run_loop was actually invoked during
710
+ # this execution. Used to detect when a downstream's confirmed-open PR
711
+ # is stale because its upstream was rebuilt (Finding #4).
712
+ rebuilt_this_run: Set[str] = set()
713
+
714
+ for i, node in enumerate(topo_order):
715
+ nid = node.node_id
716
+
717
+ # Resume: skip nodes already confirmed pr_open (live PR check passed) OR
718
+ # already merged (P4 resume — merged nodes cannot be rebuilt or stale).
719
+ # For pr_open nodes: check stale-upstream (an upstream rebuilt this run
720
+ # or received an out-of-band push) and block with a clear reason so the
721
+ # user can close the stale PR and re-run.
722
+ #
723
+ # NOTE: automatic downstream rebuild when an upstream changes requires an
724
+ # update-existing-PR primitive and is deferred to a future release.
725
+ if nid in completed_nodes:
726
+ # Merged nodes: skip unconditionally — no stale-upstream check applies.
727
+ nid_rec = ledger_state.get(nid)
728
+ if nid_rec and nid_rec.status == "merged":
729
+ continue
730
+
731
+ upstream_edges = roadmap.edges_for_downstream(node)
732
+ changed_upstreams = [
733
+ edge for edge in upstream_edges
734
+ # #47: an order-only edge injects nothing — the downstream never
735
+ # consumed the upstream, so an upstream change does NOT make the
736
+ # downstream stale. Exclude order-only edges from stale-detection
737
+ # to avoid a spurious 'upstream_changed_downstream_pr_open' block.
738
+ if edge.channel.kind != "order-only"
739
+ and (
740
+ edge.upstream.node_id in rebuilt_this_run
741
+ or edge.upstream.node_id in out_of_band_upstreams
742
+ )
743
+ ]
744
+ if not changed_upstreams:
745
+ continue
746
+ # An upstream changed and this node's draft PR is still open.
747
+ # Block so the user can close the stale PR and re-run.
748
+ change_reasons: List[str] = []
749
+ for edge in changed_upstreams:
750
+ uid = edge.upstream.node_id
751
+ if uid in rebuilt_this_run:
752
+ change_reasons.append(f"upstream {uid!r} was rebuilt this run")
753
+ else:
754
+ new_sha = completed_nodes.get(uid, {}).get("head_sha", "<unknown>")
755
+ change_reasons.append(
756
+ f"upstream {uid!r} advanced to {new_sha!r} (out-of-band push)"
757
+ )
758
+ detail_msg = (
759
+ "; ".join(change_reasons)
760
+ + f"; close/supersede the stale downstream PR and re-run"
761
+ )
762
+ append_record(
763
+ ledger_path,
764
+ LedgerRecord(
765
+ node_id=nid,
766
+ status="blocked",
767
+ branch=completed_nodes[nid].get("branch"),
768
+ ),
769
+ )
770
+ return {
771
+ "status": "blocked",
772
+ "node_id": nid,
773
+ "detail": {
774
+ "reason": "upstream_changed_downstream_pr_open",
775
+ "message": detail_msg,
776
+ },
777
+ }
778
+
779
+ workspace = resolve_workspace(node)
780
+ upstream_edges = roadmap.edges_for_downstream(node)
781
+
782
+ # Mark as running (durable breadcrumb for diagnostics)
783
+ append_record(ledger_path, LedgerRecord(node_id=nid, status="running"))
784
+
785
+ try:
786
+ # (i) Inject upstream draft refs (IF-0-P2-2) BEFORE run_loop.
787
+ # Collect injected paths to union into owned_paths after run_loop.
788
+ #
789
+ # The guard below is a defensive invariant. It should be unreachable
790
+ # in a well-formed train: validate_train_loud (T-B) ensures every
791
+ # upstream is a declared node, and topo-sort guarantees we processed
792
+ # it before this node. If the upstream failed/was blocked, run_train
793
+ # returns immediately and never reaches this downstream. Kept here to
794
+ # make the "no silent skip" contract explicit and catch future refactors.
795
+ injected_channel_paths: List[str] = []
796
+ for edge in upstream_edges:
797
+ if edge.channel.kind == "order-only":
798
+ # #47: an order-only edge enforces merge ORDER (via the topo
799
+ # sort + sequential per-node execution) but carries no physical
800
+ # channel — the downstream does not consume the upstream, so
801
+ # there is nothing to inject. Skip; do not resolve a ref.
802
+ continue
803
+ upstream_result = completed_nodes.get(edge.upstream.node_id)
804
+ if upstream_result is None:
805
+ # Defensive: topo-order + T-B validation make this
806
+ # unreachable; kept as an explicit fail-loud guard.
807
+ raise RuntimeError(
808
+ f"upstream ref for '{edge.upstream.node_id}' is not resolved "
809
+ f"(not in completed_nodes) — cannot inject into "
810
+ f"'{nid}'; the upstream must be built and published first"
811
+ )
812
+ ref = upstream_result.get("head_sha")
813
+ if not ref:
814
+ # Block: do NOT fall back to injecting a moving branch name.
815
+ # A missing SHA means neither the live query nor the ledger
816
+ # have a pinnable ref — injecting a branch name would build
817
+ # the downstream against a moving target.
818
+ raise RuntimeError(
819
+ f"no resolvable SHA for upstream '{edge.upstream.node_id}' "
820
+ f"(live head SHA query returned None and ledger head_sha is "
821
+ f"None); cannot inject a moving branch name for channel "
822
+ f"{edge.channel.kind!r} — resolve the upstream SHA and re-run"
823
+ )
824
+ injected = set_upstream_ref_fn(workspace, edge.channel, ref)
825
+ if injected:
826
+ injected_channel_paths.extend(injected)
827
+
828
+ # (ii) Invoke the unchanged per-repo run_loop.
829
+ # The real run_loop returns (StateSnapshot, list[LaunchResult]).
830
+ result_tuple = run_loop_fn(
831
+ workspace, workspace / node.roadmap, run_mode=run_mode
832
+ )
833
+
834
+ # SHOULD-FIX 3: Guard against partial multi-phase nodes.
835
+ #
836
+ # run_loop defaults to max_phases=1, so a node with a >1-phase
837
+ # roadmap stops after the first phase. If any phase is still
838
+ # "planned" the node is incomplete — publishing it ships a partial
839
+ # draft PR. Block loudly instead.
840
+ #
841
+ # Use getattr so we're forward-compatible with test fixtures that
842
+ # return lightweight SimpleNamespace objects lacking a phases field.
843
+ _node_snapshot = result_tuple[0] if isinstance(result_tuple, tuple) else None
844
+ _node_phases = getattr(_node_snapshot, "phases", None)
845
+ if _node_phases is not None:
846
+ # A node may publish a draft PR only when EVERY phase reached a
847
+ # clean green terminal — "complete" or "awaiting_phase_closeout"
848
+ # (the post-run_loop success state under manual closeout). ANY
849
+ # other state (planned/blocked/failed_verification/executing/
850
+ # human_required/unknown) means the node is incomplete or broken.
851
+ # Blocking only "planned" (the prior narrow guard) let a
852
+ # *failed*-phase node publish a draft that could later trivial-pass
853
+ # P4 re-verify on a no-verification plan — a combined false-green.
854
+ # Block loudly on any non-green phase instead.
855
+ _GREEN_PHASE_STATES = {"complete", "awaiting_phase_closeout"}
856
+ _not_green = sorted(
857
+ ph for ph, st in _node_phases.items() if st not in _GREEN_PHASE_STATES
858
+ )
859
+ if _not_green:
860
+ raise RuntimeError(
861
+ f"node '{nid}' has phases not in a green state after run_loop "
862
+ f"({', '.join(_not_green)}); refusing to publish a partial or "
863
+ f"failed draft PR — every phase must reach complete/"
864
+ f"awaiting_phase_closeout before publishing"
865
+ )
866
+
867
+ # (iii) Determine owned paths (Finding #1).
868
+ # If the caller supplied an explicit resolver, honour it.
869
+ # Otherwise use the snapshot's produced/owned paths so the
870
+ # published PR contains the actual implementation, not just
871
+ # the roadmap file.
872
+ if _explicit_owned_paths:
873
+ owned_paths = list(resolve_owned_paths(node)) # type: ignore[arg-type]
874
+ else:
875
+ snapshot = result_tuple[0] if isinstance(result_tuple, tuple) else None
876
+ if snapshot is not None:
877
+ produced = (
878
+ getattr(snapshot, "phase_owned_dirty_paths", None)
879
+ or getattr(snapshot, "dirty_paths", None)
880
+ or ()
881
+ )
882
+ owned_paths = list(produced)
883
+ else:
884
+ owned_paths = []
885
+
886
+ # Union the coordinator-injected channel paths into owned_paths so
887
+ # the pin/submodule change always ships in the PR even if run_loop's
888
+ # snapshot doesn't include the injected file (Finding #6 / union fix).
889
+ # de-duplicate while preserving order (snapshot paths first).
890
+ if injected_channel_paths:
891
+ seen = set(owned_paths)
892
+ for p in injected_channel_paths:
893
+ if p not in seen:
894
+ owned_paths.append(p)
895
+ seen.add(p)
896
+
897
+ # (iv) Publish as draft PR via the P1 runtime primitive.
898
+ # draft=True is structural — P3 never merges.
899
+ pr_body = _build_pr_body(node, topo_order, completed_nodes, upstream_edges)
900
+ publish_result = publish_fn(
901
+ workspace,
902
+ owned_paths,
903
+ draft=True, # P3 invariant: draft-only, never merge
904
+ pr_body=pr_body,
905
+ )
906
+
907
+ except Exception as exc:
908
+ # Inject or run_loop or publish raised — mark blocked so the node
909
+ # is never left stuck at "running" (Finding #3 / exception safety).
910
+ append_record(
911
+ ledger_path,
912
+ LedgerRecord(
913
+ node_id=nid,
914
+ status="blocked",
915
+ branch=None,
916
+ ),
917
+ )
918
+ return {
919
+ "status": "blocked",
920
+ "node_id": nid,
921
+ "detail": {"reason": str(exc)},
922
+ }
923
+
924
+ if publish_result.get("status") != "published":
925
+ # Node blocked by the publish primitive (e.g. push rejected, dirty
926
+ # worktree, publication_blocked). Record in ledger and halt.
927
+ # Prior nodes' draft PRs remain open; the train is resumable.
928
+ append_record(
929
+ ledger_path,
930
+ LedgerRecord(
931
+ node_id=nid,
932
+ status="blocked",
933
+ branch=publish_result.get("branch"),
934
+ ),
935
+ )
936
+ return {
937
+ "status": "blocked",
938
+ "node_id": nid,
939
+ "detail": publish_result,
940
+ }
941
+
942
+ # Record success — store draft head in ``head_sha``; leave
943
+ # ``upstream_merge_sha`` for P4's merged-SHA (Finding #5).
944
+ branch = publish_result["branch"]
945
+ head_sha = publish_result["head_sha"]
946
+ pr_url = publish_result["pr_url"]
947
+
948
+ completed_nodes[nid] = {
949
+ "branch": branch,
950
+ "head_sha": head_sha,
951
+ "pr_url": pr_url,
952
+ }
953
+ rebuilt_this_run.add(nid)
954
+
955
+ append_record(
956
+ ledger_path,
957
+ LedgerRecord(
958
+ node_id=nid,
959
+ status="pr_open",
960
+ branch=branch,
961
+ pr_url=pr_url,
962
+ head_sha=head_sha, # draft branch HEAD SHA
963
+ upstream_merge_sha=None, # reserved for P4 (merge SHA only)
964
+ merge_order=i,
965
+ ),
966
+ )
967
+
968
+ # --- P3 complete: all draft PRs open ------------------------------------
969
+
970
+ if not _merge_phase_enabled:
971
+ # P3 behavior (default): done once draft PRs are open. Cross-repo
972
+ # merges are a P4 concern. All existing P3 callers and tests hit this
973
+ # path because they never set _merge_phase_enabled=True.
974
+ return {"status": "completed", "nodes": completed_nodes}
975
+
976
+ # --- P4: train-level governed review + sequential merge with re-verify ---
977
+ #
978
+ # Autonomy boundary: cross-repo merges are NEVER auto-merged. In
979
+ # autonomous mode (the default) the coordinator stops here at a
980
+ # ``drafts_open`` terminal. Only ``run_mode="governed"`` (opt-in via
981
+ # ``--governed`` CLI flag) proceeds to the review + merge loop.
982
+ if run_mode != "governed":
983
+ return {"status": "drafts_open", "nodes": completed_nodes}
984
+
985
+ # Resolve P4 seams (defaults to live; tests inject stubs).
986
+ merge_pr_fn = _merge_pr_fn if _merge_pr_fn is not None else _live_merge_pr
987
+ reverify_fn = _reverify_fn if _reverify_fn is not None else _live_reverify
988
+ train_review_fn = (
989
+ _train_review_fn if _train_review_fn is not None else _default_train_review
990
+ )
991
+ pr_merged_sha_fn = (
992
+ _pr_merged_sha_fn if _pr_merged_sha_fn is not None else _live_pr_merged_sha
993
+ )
994
+
995
+ # Re-read ledger to recover P4 state from a previous partial run
996
+ # (idempotent resume: skip already-merged nodes, recover their SHA).
997
+ p4_ledger_state = read_ledger(ledger_path)
998
+
999
+ # Build merged_shas: node_id → actual merge-commit SHA. The field
1000
+ # ``upstream_merge_sha`` on a ``merged`` record holds the real SHA
1001
+ # (NOT the draft head_sha — ledger schema keeps these distinct).
1002
+ merged_shas: Dict[str, str] = {}
1003
+ for _nid_r, _rec_r in p4_ledger_state.items():
1004
+ if _rec_r.status == "merged" and _rec_r.upstream_merge_sha:
1005
+ merged_shas[_nid_r] = _rec_r.upstream_merge_sha
1006
+
1007
+ # Cross-check with live GitHub state: covers crash-between-merge-and-
1008
+ # ledger-write (the PR landed on main but the ledger write didn't happen).
1009
+ for _node_r in topo_order:
1010
+ _nid_r = _node_r.node_id
1011
+ if _nid_r in merged_shas:
1012
+ continue # already recovered from ledger
1013
+ _pr_branch_r = completed_nodes.get(_nid_r, {}).get("branch")
1014
+ if _pr_branch_r:
1015
+ _ws_r = resolve_workspace(_node_r)
1016
+ _live_sha_r = pr_merged_sha_fn(_ws_r, _pr_branch_r)
1017
+ if _live_sha_r:
1018
+ merged_shas[_nid_r] = _live_sha_r
1019
+
1020
+ # --- Train-level review (one-round bounded panel) ----------------------
1021
+ # Idempotent resume: skip review if already approved in a previous run.
1022
+ train_review_rec = p4_ledger_state.get(_TRAIN_REVIEW_NODE_ID)
1023
+ already_approved = (
1024
+ train_review_rec is not None and train_review_rec.status == "approved"
1025
+ )
1026
+
1027
+ if not already_approved:
1028
+ bundle_text = _build_train_review_bundle(roadmap, completed_nodes, topo_order)
1029
+ review_result = train_review_fn(bundle_text, run_mode)
1030
+
1031
+ if not review_result.mergeable:
1032
+ # Non-approval → NON-HUMAN terminal, ZERO merges.
1033
+ # The partial-merge-disaster guard: no node is merged if the panel
1034
+ # rejects the train. terminal_blocker carries human_required=False.
1035
+ return {
1036
+ "status": "review_halted",
1037
+ "nodes": completed_nodes,
1038
+ "terminal_blocker": review_result.terminal_blocker,
1039
+ "reason": review_result.reason or "train_review_rejected",
1040
+ }
1041
+
1042
+ # Record approval (with synthetic node_id — never a real roadmap node).
1043
+ append_record(
1044
+ ledger_path,
1045
+ LedgerRecord(
1046
+ node_id=_TRAIN_REVIEW_NODE_ID,
1047
+ status="approved",
1048
+ ),
1049
+ )
1050
+
1051
+ # --- Sequential merge in topo order with downstream re-verify -----------
1052
+ #
1053
+ # False-green killer: before merging each downstream node, re-resolve its
1054
+ # channel to the upstream MERGED SHA and re-verify. NEVER merge a
1055
+ # downstream that was only green against the draft (unmerged) upstream ref.
1056
+ #
1057
+ # Forward-only: a downstream re-verify failure does NOT revert merged
1058
+ # upstream nodes. Recommendation: use expand/contract (backward-compatible)
1059
+ # upstream contracts so sequential merges remain safe even when a downstream
1060
+ # fails post-merge of its upstream.
1061
+ for _i_m, _node_m in enumerate(topo_order):
1062
+ _nid_m = _node_m.node_id
1063
+
1064
+ # Idempotent: skip already-merged nodes (never double-merge).
1065
+ if _nid_m in merged_shas:
1066
+ continue
1067
+
1068
+ _ws_m = resolve_workspace(_node_m)
1069
+ _upstream_edges_m = roadmap.edges_for_downstream(_node_m)
1070
+
1071
+ if _upstream_edges_m:
1072
+ # Re-resolve every upstream channel to its MERGED SHA (not the
1073
+ # draft SHA from P3). This is the call the test asserts on:
1074
+ # set_upstream_ref must be called with the merged SHA and must
1075
+ # appear in the call log BEFORE the re-verify call.
1076
+ #
1077
+ # Wrap inject + reverify: either can raise (e.g. fs error from
1078
+ # set_upstream_ref_fn, or unexpected exception from reverify_fn).
1079
+ # An unguarded exception here would escape run_train as a traceback
1080
+ # instead of returning merge_halted, leaving already-merged upstreams
1081
+ # with no record. Mirror the same try/except pattern used for the
1082
+ # merge call below (forward-only: already-merged upstreams stay merged).
1083
+ try:
1084
+ for _edge_m in _upstream_edges_m:
1085
+ if _edge_m.channel.kind == "order-only":
1086
+ # #47: order-only edges carry no channel — the merged
1087
+ # upstream is not injected/re-resolved. Merge order is
1088
+ # already enforced by the sequential topo-order merge loop.
1089
+ continue
1090
+ _upstream_merged_sha = merged_shas.get(_edge_m.upstream.node_id)
1091
+ if _upstream_merged_sha is None:
1092
+ # Defensive: topo-order ensures upstream is processed first.
1093
+ raise RuntimeError(
1094
+ f"upstream '{_edge_m.upstream.node_id}' is not yet merged "
1095
+ f"— cannot re-verify downstream '{_nid_m}'; "
1096
+ f"check topo order and that the upstream merge succeeded"
1097
+ )
1098
+ set_upstream_ref_fn(_ws_m, _edge_m.channel, _upstream_merged_sha)
1099
+
1100
+ # Re-verify the downstream against the merged upstream contracts.
1101
+ # Failure means the downstream was only green against the draft ref.
1102
+ _reverify_ok = reverify_fn(_ws_m, _ws_m / _node_m.roadmap, run_mode)
1103
+ except Exception as _inject_exc_m:
1104
+ # Inject or reverify raised — record blocked + return merge_halted
1105
+ # so the status-dict contract is preserved and no traceback escapes.
1106
+ append_record(
1107
+ ledger_path,
1108
+ LedgerRecord(
1109
+ node_id=_nid_m,
1110
+ status="blocked",
1111
+ branch=completed_nodes.get(_nid_m, {}).get("branch"),
1112
+ ),
1113
+ )
1114
+ return {
1115
+ "status": "merge_halted",
1116
+ "node_id": _nid_m,
1117
+ "reason": "reverify_failed",
1118
+ "detail": str(_inject_exc_m),
1119
+ }
1120
+
1121
+ if not _reverify_ok:
1122
+ append_record(
1123
+ ledger_path,
1124
+ LedgerRecord(
1125
+ node_id=_nid_m,
1126
+ status="blocked",
1127
+ branch=completed_nodes.get(_nid_m, {}).get("branch"),
1128
+ ),
1129
+ )
1130
+ # Forward-only: DO NOT revert the already-merged upstream nodes.
1131
+ # Use expand/contract upstream contracts to prevent this situation.
1132
+ return {
1133
+ "status": "merge_halted",
1134
+ "node_id": _nid_m,
1135
+ "reason": "downstream_reverify_failed",
1136
+ "detail": (
1137
+ f"node '{_nid_m}' failed re-verification against upstream "
1138
+ f"merged SHA(s). Upstream nodes remain merged (forward-only). "
1139
+ f"Recommendation: use expand/contract upstream contracts so "
1140
+ f"sequential merges are safe even when a downstream fails."
1141
+ ),
1142
+ }
1143
+
1144
+ # Merge the PR. Wrap the call: a real gh pr merge failure (branch
1145
+ # protection, conflict, required checks) raises CalledProcessError
1146
+ # from the live default (subprocess check=True). Record blocked +
1147
+ # return merge_halted so no uncaught exception escapes run_train and
1148
+ # already-merged upstream nodes remain recorded (forward-only).
1149
+ _pr_branch_m = completed_nodes[_nid_m]["branch"]
1150
+ try:
1151
+ _merged_sha_m = merge_pr_fn(_ws_m, _pr_branch_m)
1152
+ except Exception as _merge_exc_m:
1153
+ append_record(
1154
+ ledger_path,
1155
+ LedgerRecord(
1156
+ node_id=_nid_m,
1157
+ status="blocked",
1158
+ branch=_pr_branch_m,
1159
+ ),
1160
+ )
1161
+ return {
1162
+ "status": "merge_halted",
1163
+ "node_id": _nid_m,
1164
+ "reason": "merge_failed",
1165
+ "detail": str(_merge_exc_m),
1166
+ }
1167
+ merged_shas[_nid_m] = _merged_sha_m
1168
+
1169
+ # Record the merge. Carry branch/pr_url/head_sha forward from
1170
+ # completed_nodes so the merged record is self-sufficient for P4 resume
1171
+ # (last-wins overwrites the pr_open record; the resumed run reads branch
1172
+ # and head_sha from this merged record to inject downstream nodes).
1173
+ _node_info_m = completed_nodes[_nid_m]
1174
+ append_record(
1175
+ ledger_path,
1176
+ LedgerRecord(
1177
+ node_id=_nid_m,
1178
+ status="merged",
1179
+ branch=_node_info_m.get("branch"),
1180
+ pr_url=_node_info_m.get("pr_url"),
1181
+ head_sha=_node_info_m.get("head_sha"), # draft SHA for downstream injection
1182
+ upstream_merge_sha=_merged_sha_m, # actual merge-commit SHA (P4)
1183
+ merge_order=_i_m,
1184
+ ),
1185
+ )
1186
+
1187
+ return {
1188
+ "status": "merged",
1189
+ "nodes": {
1190
+ _nid_out: {
1191
+ "branch": completed_nodes[_nid_out]["branch"],
1192
+ "merged_sha": _sha_out,
1193
+ }
1194
+ for _nid_out, _sha_out in merged_shas.items()
1195
+ if _nid_out in completed_nodes # exclude _train_review_ synthetic node
1196
+ },
1197
+ }