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,2368 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from fnmatch import fnmatchcase
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import Any, Iterable
14
+
15
+ try: # Optional in stripped adapter runtimes; normal installs and tests provide it.
16
+ import yaml
17
+ except Exception: # pragma: no cover
18
+ yaml = None
19
+
20
+ from .events import read_events
21
+ from .models import (
22
+ BLOCKER_CLASSES,
23
+ DispatchHints,
24
+ ExecutionPolicyDocument,
25
+ ExecutionPolicyParseError,
26
+ ExecutionPolicyRule,
27
+ LaneIRDiagnostic,
28
+ PHASE_SCHEDULER_MODES,
29
+ PHASE_SOURCE_BUNDLE_SCHEMA,
30
+ PHASE_STATUSES,
31
+ PhaseSourceBundle,
32
+ PhaseTeamEligibility,
33
+ PipelineMetadataDiagnostic,
34
+ PipelinePlanMetadata,
35
+ PipelineProtectedSource,
36
+ PIPELINE_PROTECTED_SOURCE_ROLES,
37
+ PRODUCT_LOOP_ACTIONS,
38
+ require_literal,
39
+ )
40
+ from .pipeline_adapter.flag import reconcile_git_reality_enabled
41
+ from .provenance import phase_sha256, roadmap_sha256
42
+ from .runtime_paths import phase_loop_state_read_file
43
+
44
+
45
+ PHASE_RE = re.compile(
46
+ r"^###\s+Phase\s+\d+(?:\.\d+)?[A-Z]?\s+.*?\(([A-Z][A-Z0-9._-]*)(?:\s*,[^)]*)?\)[ \t]*(?:\S[^\n]*)?$",
47
+ re.MULTILINE,
48
+ )
49
+ PLAN_RE = re.compile(r"phase-plan-(v[\w.-]+?)-([A-Z][A-Za-z0-9._-]*?)\.md$")
50
+ LANE_SECTION_RE = re.compile(
51
+ r"^###\s+(?:SL-\d+[A-Z]?|[A-Z][A-Z0-9]*-\d+[A-Z]?|Lane\s+\d+|Swim\s*lane\s+\d+)\b.*$",
52
+ re.IGNORECASE | re.MULTILINE,
53
+ )
54
+ OWNED_FILES_RE = re.compile(r"^\s*(?:-\s+)?\*\*Owned files\*\*:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE)
55
+ DISPATCH_SECTION_RE = re.compile(
56
+ r"^#{2,3}\s+Dispatch Hints\s*$\n(?P<body>.*?)(?=^#{1,3}\s+\S|\Z)",
57
+ re.IGNORECASE | re.MULTILINE | re.DOTALL,
58
+ )
59
+ DISPATCH_SUBSECTION_RE = re.compile(
60
+ r"^#{3,4}\s+(?P<label>default|roadmap|plan|roadmap default|plan default|"
61
+ r"roadmap|plan|execute|repair|review|maintain-skills)\s*$\n(?P<body>.*?)(?=^#{3,4}\s+\S|\Z)",
62
+ re.IGNORECASE | re.MULTILINE | re.DOTALL,
63
+ )
64
+ DISPATCH_LINE_RE = re.compile(
65
+ r"^\s*-\s*(?:(?P<action>roadmap|plan|execute|repair|review|maintain-skills)\s+)?"
66
+ r"(?P<label>preferred executors|allowed executors|fallback executors|disabled executors|required capabilities)\s*:\s*(?P<value>.+?)\s*$",
67
+ re.IGNORECASE | re.MULTILINE,
68
+ )
69
+ EXECUTION_POLICY_SECTION_RE = re.compile(
70
+ r"^#{2,3}\s+Execution Policy\s*$\n(?P<body>.*?)(?=^#{1,3}\s+\S|\Z)",
71
+ re.IGNORECASE | re.MULTILINE | re.DOTALL,
72
+ )
73
+ EXECUTION_POLICY_LINE_RE = re.compile(r"^\s*-\s*(?P<selector>[^:]+?)\s*:\s*(?P<value>.+?)\s*$", re.MULTILINE)
74
+ EXECUTION_POLICY_ASSIGNMENT_RE = re.compile(
75
+ r"(?P<key>executor|model|effort|work-unit|work_unit|unsupported|fallback|inherit-default|inherit_default|reason)"
76
+ r"\s*=\s*(?P<value>`[^`]+`|[^,;]+)",
77
+ re.IGNORECASE,
78
+ )
79
+
80
+ WORKFLOW_PLAN_SKILLS = (
81
+ "codex-plan-phase",
82
+ "claude-plan-phase",
83
+ "gemini-plan-phase",
84
+ "opencode-plan-phase",
85
+ )
86
+
87
+ WORKFLOW_EXECUTE_SKILLS = (
88
+ "codex-execute-phase",
89
+ "claude-execute-phase",
90
+ "gemini-execute-phase",
91
+ "opencode-execute-phase",
92
+ )
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class RepoIdentity:
97
+ root: Path
98
+ repo_hash: str
99
+ branch: str
100
+ branch_slug: str
101
+ commit: str
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class PlanOwnership:
106
+ owned_patterns: tuple[str, ...]
107
+ control_paths: tuple[str, ...]
108
+ valid: bool
109
+ errors: tuple[str, ...] = ()
110
+
111
+ @property
112
+ def is_control_only(self) -> bool:
113
+ """A legitimate control/backfill phase: a *valid* plan that owns no
114
+ files (all lanes read-only or declared ``(none)``). IF-0-FOUND-2.
115
+
116
+ Distinct from an *invalid* empty-owned plan (one whose lanes carry
117
+ ``missing_owned_files``/``malformed_owned_files`` diagnostics), for which
118
+ ``valid`` is False — those are genuinely misconfigured and must keep
119
+ refusing closeout.
120
+ """
121
+ return self.valid and self.owned_patterns == ()
122
+
123
+ def matches(self, repo_path: str) -> bool:
124
+ return (
125
+ repo_path in self.control_paths
126
+ or repo_path in self.owned_patterns
127
+ or any(_owned_pattern_matches(repo_path, pattern) for pattern in self.owned_patterns)
128
+ )
129
+
130
+ def matches_dirty_output(self, repo_path: str) -> bool:
131
+ return self.matches(repo_path) or expanded_dirty_ownership_matches(self, repo_path)
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class PlanLane:
136
+ heading: str
137
+ owned_patterns: tuple[str, ...]
138
+ read_only: bool
139
+ text: str
140
+
141
+
142
+ DIRTY_WORKTREE_METADATA_KEYS = (
143
+ "completion_dirty_worktree",
144
+ "plan_dirty_worktree",
145
+ "incomplete_execute_dirty_worktree",
146
+ )
147
+
148
+
149
+ def previous_phase_owned_dirty_paths(repo: Path, phase: str | None) -> tuple[str, ...]:
150
+ if not phase:
151
+ return ()
152
+ phase_alias = phase.upper()
153
+ for event in reversed(read_events(repo)):
154
+ if str(event.get("phase", "")).upper() != phase_alias:
155
+ continue
156
+ metadata = event.get("metadata")
157
+ if not isinstance(metadata, dict):
158
+ continue
159
+ for key in DIRTY_WORKTREE_METADATA_KEYS:
160
+ dirty = metadata.get(key)
161
+ if isinstance(dirty, dict) and "dirty_paths" in dirty:
162
+ return _normalized_path_tuple(dirty.get("phase_owned_dirty_paths"))
163
+ terminal = metadata.get("terminal_summary")
164
+ if isinstance(terminal, dict) and "dirty_paths" in terminal:
165
+ return _normalized_path_tuple(terminal.get("phase_owned_dirty_paths"))
166
+ return ()
167
+
168
+
169
+ def _normalized_path_tuple(value: object) -> tuple[str, ...]:
170
+ if not isinstance(value, list):
171
+ return ()
172
+ paths: list[str] = []
173
+ seen: set[str] = set()
174
+ for item in value:
175
+ if not isinstance(item, str):
176
+ continue
177
+ path = item.strip()
178
+ if not path or path in seen:
179
+ continue
180
+ paths.append(path)
181
+ seen.add(path)
182
+ return tuple(paths)
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class DispatchHintsParseError:
187
+ """Surfaced when ``## Dispatch Hints`` emits a literal not in the runner's
188
+ DISPATCH_CAPABILITIES / EXECUTORS allowlist. Mirrors F3's
189
+ ExecutionPolicyParseError pattern: callers convert this to a
190
+ contract_bug blocker instead of crashing on raw ValueError."""
191
+
192
+ path: str
193
+ bucket: str # e.g., "default", "execute", "plan"
194
+ raw_message: str # the original ValueError from require_literal
195
+ invalid_literal: str | None = None # extracted from raw_message when possible
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class CloseoutParseError:
200
+ """Native closeout literal drift surfaced before BAML schema parsing.
201
+
202
+ The runner converts these diagnostics to repairable non-human
203
+ ``contract_bug`` blockers, preserving the original invalid literal.
204
+ """
205
+
206
+ source: str
207
+ field: str
208
+ raw_message: str
209
+ invalid_literal: str | None = None
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class SuiteCommandFinding:
214
+ code: str
215
+ message: str
216
+ source: str
217
+ value: object = None
218
+
219
+
220
+ def _extract_invalid_literal(message: str) -> str | None:
221
+ """Best-effort extraction of the offending literal from a require_literal
222
+ ValueError message. Format is typically: 'invalid {label}: {value}'."""
223
+ if ":" in message:
224
+ candidate = message.rsplit(":", 1)[-1].strip()
225
+ if candidate:
226
+ return candidate
227
+ return None
228
+
229
+
230
+ CLOSEOUT_VERIFICATION_STATUSES = ("not_run", "passed", "failed", "blocked")
231
+
232
+
233
+ def parse_closeout_payload(text: str, *, kind: str = "closeout") -> dict[str, Any] | None:
234
+ """Backward-compatible payload-only closeout parser.
235
+
236
+ Literal drift returns ``None`` so existing callers can keep using a simple
237
+ truthy payload check. Schema/type errors still follow the BAML validation
238
+ path and raise ``BamlValidationError``.
239
+ """
240
+ payload, errors = parse_closeout_payload_doc(text, kind=kind)
241
+ return None if errors else payload
242
+
243
+
244
+ def parse_closeout_payload_doc(
245
+ text: str, *, kind: str
246
+ ) -> tuple[dict[str, Any] | None, tuple[CloseoutParseError, ...]]:
247
+ """Parse native closeout JSON with graceful soft-fail for enum drift.
248
+
249
+ Unknown ``terminal_status``, ``verification_status``, and
250
+ ``blocker_class`` literals become structured diagnostics instead of raw
251
+ ``ValueError`` crashes. Valid payloads still pass through the canonical
252
+ ``EmitPhaseCloseout`` BAML validation path for schema and type checks.
253
+ """
254
+ from .baml_modular import parse_baml_response
255
+
256
+ extracted = _find_closeout_payload_doc(text)
257
+ parse_text = json.dumps(extracted) if extracted is not None else str(text or "")
258
+ literal_source = extracted if extracted is not None else {}
259
+ errors = _closeout_literal_errors(literal_source, kind=kind) if isinstance(literal_source, dict) else []
260
+ if errors:
261
+ return None, tuple(errors)
262
+ return parse_baml_response("EmitPhaseCloseout", parse_text).payload, ()
263
+
264
+
265
+ def _find_closeout_payload_doc(text: str) -> dict[str, Any] | None:
266
+ decoder = json.JSONDecoder()
267
+ raw = str(text or "")
268
+ try:
269
+ data = json.loads(raw)
270
+ except json.JSONDecodeError:
271
+ data = None
272
+ if isinstance(data, str):
273
+ return _find_closeout_payload_doc(data)
274
+ if isinstance(data, dict) and {"terminal_status", "verification_status", "dirty_paths"}.issubset(data):
275
+ return data
276
+ for index, char in enumerate(raw):
277
+ if char != "{":
278
+ continue
279
+ try:
280
+ data, _end = decoder.raw_decode(raw[index:])
281
+ except json.JSONDecodeError:
282
+ continue
283
+ if isinstance(data, dict) and {"terminal_status", "verification_status", "dirty_paths"}.issubset(data):
284
+ return data
285
+ return None
286
+
287
+
288
+ def _closeout_literal_errors(payload: dict[str, Any], *, kind: str) -> list[CloseoutParseError]:
289
+ errors: list[CloseoutParseError] = []
290
+ terminal_status = payload.get("terminal_status")
291
+ verification_status = payload.get("verification_status")
292
+ blocker_class = payload.get("blocker_class")
293
+
294
+ _append_literal_error(errors, kind=kind, field="terminal_status", value=terminal_status, allowed=PHASE_STATUSES)
295
+ _append_literal_error(
296
+ errors,
297
+ kind=kind,
298
+ field="verification_status",
299
+ value=verification_status,
300
+ allowed=CLOSEOUT_VERIFICATION_STATUSES,
301
+ )
302
+ if blocker_class is not None:
303
+ _append_literal_error(
304
+ errors,
305
+ kind=kind,
306
+ field="blocker_class",
307
+ value=blocker_class,
308
+ allowed=(*BLOCKER_CLASSES, "none"),
309
+ )
310
+ if errors:
311
+ return errors
312
+
313
+ terminal_text = str(terminal_status)
314
+ verification_text = str(verification_status)
315
+ if terminal_text == "complete" and verification_text != "passed":
316
+ errors.append(
317
+ CloseoutParseError(
318
+ source=kind,
319
+ field="terminal_status+verification_status",
320
+ raw_message="invalid closeout field-pair: terminal_status complete requires verification_status passed",
321
+ invalid_literal=f"{terminal_text}/{verification_text}",
322
+ )
323
+ )
324
+ elif terminal_text in {"planned", "unplanned"} and verification_text != "not_run":
325
+ errors.append(
326
+ CloseoutParseError(
327
+ source=kind,
328
+ field="terminal_status+verification_status",
329
+ raw_message=f"invalid closeout field-pair: terminal_status {terminal_text} requires verification_status not_run",
330
+ invalid_literal=f"{terminal_text}/{verification_text}",
331
+ )
332
+ )
333
+ elif terminal_text == "blocked" and verification_text not in {"failed", "blocked"}:
334
+ errors.append(
335
+ CloseoutParseError(
336
+ source=kind,
337
+ field="terminal_status+verification_status",
338
+ raw_message="invalid closeout field-pair: terminal_status blocked requires verification_status failed or blocked",
339
+ invalid_literal=f"{terminal_text}/{verification_text}",
340
+ )
341
+ )
342
+ return errors
343
+
344
+
345
+ def _append_literal_error(
346
+ errors: list[CloseoutParseError], *, kind: str, field: str, value: Any, allowed: tuple[str, ...]
347
+ ) -> None:
348
+ try:
349
+ require_literal(str(value), allowed, field)
350
+ except ValueError as exc:
351
+ errors.append(
352
+ CloseoutParseError(
353
+ source=kind,
354
+ field=field,
355
+ raw_message=str(exc),
356
+ invalid_literal=_extract_invalid_literal(str(exc)),
357
+ )
358
+ )
359
+
360
+
361
+ def resolve_repo(repo: str | Path | None = None) -> Path:
362
+ base = Path(repo or os.getcwd()).expanduser().resolve()
363
+ try:
364
+ out = subprocess.check_output(["git", "-C", str(base), "rev-parse", "--show-toplevel"], text=True)
365
+ return Path(out.strip()).resolve()
366
+ except Exception:
367
+ return base
368
+
369
+
370
+ def slug_branch(branch: str) -> str:
371
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-").lower()
372
+ slug = re.sub(r"-+", "-", slug)
373
+ return slug or "unknown"
374
+
375
+
376
+ def repo_identity(repo: str | Path | None = None) -> RepoIdentity:
377
+ root = resolve_repo(repo)
378
+ repo_hash = hashlib.sha256(str(root).encode()).hexdigest()[:8]
379
+ branch = _git(root, "branch", "--show-current") or _git(root, "rev-parse", "--short", "HEAD")
380
+ commit = _git(root, "rev-parse", "--short", "HEAD") or "unknown"
381
+ branch_slug = slug_branch(branch if branch else f"detached-{commit}")
382
+ return RepoIdentity(root=root, repo_hash=repo_hash, branch=branch, branch_slug=branch_slug, commit=commit)
383
+
384
+
385
+ def _git(root: Path, *args: str) -> str:
386
+ try:
387
+ return subprocess.check_output(["git", "-C", str(root), *args], text=True, stderr=subprocess.DEVNULL).strip()
388
+ except Exception:
389
+ return ""
390
+
391
+
392
+ def select_roadmap(repo: Path, explicit: str | Path | None = None) -> Path:
393
+ if explicit:
394
+ path = Path(explicit).expanduser()
395
+ if not path.is_absolute():
396
+ path = repo / path
397
+ if not path.exists():
398
+ raise FileNotFoundError(f"roadmap not found: {path}")
399
+ return path.resolve()
400
+
401
+ state_roadmap = active_state_roadmap(repo)
402
+ if state_roadmap is not None:
403
+ return state_roadmap
404
+
405
+ manifest_roadmap = manifest_backed_roadmap(repo)
406
+ if manifest_roadmap is not None:
407
+ return manifest_roadmap
408
+
409
+ handoff = latest_handoff_roadmap(repo_identity(repo), "codex-phase-roadmap-builder")
410
+ if handoff is not None:
411
+ return handoff
412
+
413
+ candidates = sorted((repo / "specs").glob("phase-plans-v*.md"))
414
+ if not candidates:
415
+ raise FileNotFoundError("no specs/phase-plans-v*.md roadmap found")
416
+ if len(candidates) != 1:
417
+ raise RuntimeError("ambiguous roadmap selection")
418
+ return candidates[0].resolve()
419
+
420
+
421
+ def active_state_roadmap(repo: Path) -> Path | None:
422
+ path = phase_loop_state_read_file(repo)
423
+ if not path.exists():
424
+ return None
425
+ try:
426
+ data = json.loads(path.read_text())
427
+ except (OSError, json.JSONDecodeError):
428
+ return None
429
+ roadmap = data.get("roadmap")
430
+ if not isinstance(roadmap, str) or not roadmap:
431
+ return None
432
+ roadmap_path = Path(roadmap).expanduser()
433
+ if not roadmap_path.is_absolute():
434
+ roadmap_path = repo / roadmap_path
435
+ try:
436
+ resolved = roadmap_path.resolve()
437
+ resolved.relative_to(repo.resolve())
438
+ except (OSError, ValueError):
439
+ return None
440
+ return resolved if resolved.exists() else None
441
+
442
+
443
+ def manifest_backed_roadmap(repo: Path) -> Path | None:
444
+ if _phase_manifest_disabled():
445
+ return None
446
+ candidates: list[Path] = []
447
+ for entry in _phase_manifest_entries(repo):
448
+ if entry.roadmap_ref is None or entry.status == "orphaned":
449
+ continue
450
+ path = repo / entry.roadmap_ref.file
451
+ try:
452
+ resolved = path.resolve()
453
+ resolved.relative_to(repo.resolve())
454
+ except (OSError, ValueError):
455
+ continue
456
+ if resolved.exists() and resolved not in candidates:
457
+ candidates.append(resolved)
458
+ return candidates[0] if len(candidates) == 1 else None
459
+
460
+
461
+ def latest_handoff_roadmap(identity: RepoIdentity, predecessor: str) -> Path | None:
462
+ path = Path.home() / ".codex" / "skills" / predecessor / "handoffs" / identity.repo_hash / identity.branch_slug / "latest.md"
463
+ if not path.exists():
464
+ return None
465
+ frontmatter = parse_frontmatter(path.read_text())
466
+ if frontmatter.get("from") != predecessor:
467
+ return None
468
+ if frontmatter.get("repo") != identity.repo_hash:
469
+ return None
470
+ if Path(frontmatter.get("repo_root", "")).resolve() != identity.root:
471
+ return None
472
+ if frontmatter.get("branch_slug") != identity.branch_slug:
473
+ return None
474
+ artifact = frontmatter.get("artifact")
475
+ if not artifact:
476
+ return None
477
+ artifact_path = Path(artifact).expanduser().resolve()
478
+ try:
479
+ artifact_path.relative_to(identity.root)
480
+ except ValueError:
481
+ return None
482
+ return artifact_path if artifact_path.exists() else None
483
+
484
+
485
+ def parse_frontmatter(text: str) -> dict[str, str]:
486
+ if not text.startswith("---\n"):
487
+ return {}
488
+ end = text.find("\n---", 4)
489
+ if end == -1:
490
+ return {}
491
+ data: dict[str, str] = {}
492
+ for line in text[4:end].splitlines():
493
+ if ":" not in line:
494
+ continue
495
+ key, value = line.split(":", 1)
496
+ data[key.strip()] = value.strip().strip("'\"")
497
+ return data
498
+
499
+
500
+ def parse_frontmatter_document(text: str) -> dict[str, Any]:
501
+ if not text.startswith("---\n"):
502
+ return {}
503
+ end = text.find("\n---", 4)
504
+ if end == -1:
505
+ return {}
506
+ body = text[4:end]
507
+ if yaml is not None:
508
+ try:
509
+ data = yaml.safe_load(body)
510
+ except Exception:
511
+ return {}
512
+ return data if isinstance(data, dict) else {}
513
+ data: dict[str, Any] = {}
514
+ current_mapping: dict[str, Any] | None = None
515
+ for line in body.splitlines():
516
+ if not line.strip():
517
+ continue
518
+ if line.startswith((" ", "\t")) and current_mapping is not None and ":" in line:
519
+ key, value = line.split(":", 1)
520
+ current_mapping[key.strip()] = _plain_frontmatter_scalar(value.strip())
521
+ continue
522
+ current_mapping = None
523
+ if ":" not in line:
524
+ continue
525
+ key, value = line.split(":", 1)
526
+ key = key.strip()
527
+ value = value.strip()
528
+ if value:
529
+ data[key] = _plain_frontmatter_scalar(value)
530
+ else:
531
+ nested: dict[str, Any] = {}
532
+ data[key] = nested
533
+ current_mapping = nested
534
+ return data
535
+
536
+
537
+ def resolve_suite_command(repo: Path, roadmap: Path, plan: Path | None = None) -> list[str] | None:
538
+ command, _findings = resolve_suite_command_doc(repo, roadmap, plan)
539
+ return command
540
+
541
+
542
+ def resolve_suite_command_doc(repo: Path, roadmap: Path, plan: Path | None = None) -> tuple[list[str] | None, tuple[SuiteCommandFinding, ...]]:
543
+ findings: list[SuiteCommandFinding] = []
544
+ roadmap_value = _automation_suite_command(roadmap)
545
+ plan_value = _automation_suite_command(plan) if plan is not None else None
546
+ source = str(plan) if plan_value is not None and plan is not None else str(roadmap)
547
+ raw_value = plan_value if plan_value is not None else roadmap_value
548
+ if raw_value is None:
549
+ return None, ()
550
+ command, finding = _normalize_suite_command(raw_value, source=source)
551
+ if finding is not None:
552
+ findings.append(finding)
553
+ return command, tuple(findings)
554
+
555
+
556
+ def validate_plan_verification_commands_for_intake(repo: Path, plan: Path) -> list[Any]:
557
+ from .verification_evidence import ValidationFinding, validate_verification_commands
558
+
559
+ commands, _operational = verification_commands_from_plan(plan)
560
+ findings = list(validate_verification_commands(repo, commands))
561
+ _suite, suite_findings = resolve_suite_command_doc(repo, _roadmap_from_plan(repo, plan), plan)
562
+ for suite_finding in suite_findings:
563
+ findings.append(
564
+ ValidationFinding(
565
+ code=suite_finding.code,
566
+ message=suite_finding.message,
567
+ command_index=-1,
568
+ value=str(suite_finding.value) if suite_finding.value is not None else None,
569
+ )
570
+ )
571
+ return findings
572
+
573
+
574
+ def verification_commands_from_plan(plan: Path) -> tuple[list[list[str]], list[dict[str, Any]]]:
575
+ try:
576
+ text = plan.read_text(encoding="utf-8")
577
+ except OSError:
578
+ return [], []
579
+ match = re.search(r"^##\s+Verification\s*$\n(?P<body>.*?)(?=^##\s+\S|\Z)", text, re.MULTILINE | re.DOTALL)
580
+ if not match:
581
+ return [], []
582
+ commands: list[list[str]] = []
583
+ operational: list[dict[str, Any]] = []
584
+ for line_number, line in enumerate(match.group("body").splitlines(), start=text[: match.start("body")].count("\n") + 1):
585
+ stripped = line.strip()
586
+ if not stripped.startswith("- "):
587
+ continue
588
+ raw = stripped[2:].strip()
589
+ parsed = _strip_markdown_command(raw)
590
+ if parsed is None:
591
+ continue
592
+ command_text, trailing_text = parsed
593
+ if not command_text:
594
+ continue
595
+ if re.search(r"\bevidence\s*:\s*operational\b", raw, re.IGNORECASE):
596
+ operational.append({"line": line_number, "command": command_text, "reason": "evidence: operational"})
597
+ continue
598
+ if trailing_text.strip():
599
+ continue
600
+ for chunk in _split_shell_and(command_text):
601
+ try:
602
+ argv = shlex.split(chunk)
603
+ except ValueError:
604
+ argv = []
605
+ if argv:
606
+ commands.append(argv)
607
+ return commands, operational
608
+
609
+
610
+ def _automation_suite_command(path: Path | None) -> object:
611
+ if path is None:
612
+ return None
613
+ try:
614
+ data = parse_frontmatter_document(path.read_text(encoding="utf-8"))
615
+ except OSError:
616
+ return None
617
+ automation = data.get("automation")
618
+ if not isinstance(automation, dict):
619
+ return None
620
+ return automation.get("suite_command")
621
+
622
+
623
+ def _normalize_suite_command(value: object, *, source: str) -> tuple[list[str] | None, SuiteCommandFinding | None]:
624
+ if isinstance(value, str):
625
+ try:
626
+ argv = shlex.split(value)
627
+ except ValueError as exc:
628
+ return None, SuiteCommandFinding("malformed_suite_command", str(exc), source, value)
629
+ elif isinstance(value, list) and all(isinstance(item, str) for item in value):
630
+ argv = list(value)
631
+ elif isinstance(value, list):
632
+ return None, SuiteCommandFinding(
633
+ "malformed_suite_command",
634
+ "automation.suite_command list entries must be strings",
635
+ source,
636
+ value,
637
+ )
638
+ else:
639
+ return None, SuiteCommandFinding(
640
+ "malformed_suite_command",
641
+ "automation.suite_command must be a shell string or list of strings",
642
+ source,
643
+ value,
644
+ )
645
+ if not argv or any(not part for part in argv):
646
+ return None, SuiteCommandFinding("empty_suite_command", "automation.suite_command must not be empty", source, value)
647
+ return argv, None
648
+
649
+
650
+ def _strip_markdown_command(value: str) -> tuple[str, str] | None:
651
+ text = value.strip()
652
+ if text.startswith("`") and "`" in text[1:]:
653
+ end = text.find("`", 1)
654
+ return text[1:end].strip(), text[end + 1 :].strip()
655
+ return None
656
+
657
+
658
+ def _split_shell_and(command: str) -> list[str]:
659
+ return [part.strip() for part in re.split(r"\s+&&\s+", command) if part.strip()]
660
+
661
+
662
+ def _plain_frontmatter_scalar(value: str) -> Any:
663
+ value = value.strip().strip("'\"")
664
+ if value.startswith("[") and value.endswith("]"):
665
+ try:
666
+ data = json.loads(value)
667
+ return data
668
+ except json.JSONDecodeError:
669
+ return [item.strip().strip("'\"") for item in value[1:-1].split(",") if item.strip()]
670
+ return value
671
+
672
+
673
+ def _roadmap_from_plan(repo: Path, plan: Path) -> Path:
674
+ metadata = plan_metadata(plan)
675
+ roadmap = metadata.get("roadmap")
676
+ return (repo / roadmap) if roadmap else repo / "specs" / "phase-plans-v1.md"
677
+
678
+
679
+ def parse_roadmap_phases(roadmap: Path) -> list[str]:
680
+ text = roadmap.read_text()
681
+ aliases = [match.group(1).strip() for match in PHASE_RE.finditer(text)]
682
+ return aliases
683
+
684
+
685
+ def roadmap_closeout_evidence_audit_enabled(roadmap: Path) -> bool:
686
+ text = roadmap.read_text(encoding="utf-8")
687
+ frontmatter = parse_frontmatter(text)
688
+ if _truthy_metadata_value(frontmatter.get("closeout_evidence_audit")):
689
+ return True
690
+ body_start = 0
691
+ if text.startswith("---\n"):
692
+ end = text.find("\n---", 4)
693
+ if end != -1:
694
+ body_start = end + len("\n---")
695
+ body = text[body_start:]
696
+ first_h2 = re.search(r"^##\s+.*$", body, re.MULTILINE)
697
+ if first_h2 is None:
698
+ return False
699
+ second_h2 = re.search(r"^##\s+.*$", body[first_h2.end() :], re.MULTILINE)
700
+ first_h2_body = (
701
+ body[first_h2.end() :]
702
+ if second_h2 is None
703
+ else body[first_h2.end() : first_h2.end() + second_h2.start()]
704
+ )
705
+ return bool(
706
+ re.search(
707
+ r"^\s*(?:-\s*)?closeout_evidence_audit\s*:\s*true\s*$",
708
+ first_h2_body,
709
+ re.IGNORECASE | re.MULTILINE,
710
+ )
711
+ )
712
+
713
+
714
+ def _truthy_metadata_value(value: object) -> bool:
715
+ return str(value or "").strip().lower() in {"true", "yes", "1", "on"}
716
+
717
+
718
+ def parse_dispatch_hints(path: Path, *, kind: str) -> dict[str, DispatchHints]:
719
+ """Backward-compat entry: returns just the hints dict, silently dropping
720
+ buckets that contain unknown literals (which used to crash). For the
721
+ diagnostic-aware version that surfaces a parse_error for the runner to
722
+ convert to a contract_bug blocker, use ``parse_dispatch_hints_doc``."""
723
+ hints, _ = parse_dispatch_hints_doc(path, kind=kind)
724
+ return hints
725
+
726
+
727
+ def parse_dispatch_hints_doc(
728
+ path: Path, *, kind: str
729
+ ) -> tuple[dict[str, DispatchHints], tuple[DispatchHintsParseError, ...]]:
730
+ """Parse ## Dispatch Hints with graceful soft-fail.
731
+
732
+ Per-bucket DispatchHints construction wrapped in try/except so a planner
733
+ that invented an unknown literal (e.g. ``browser_automation`` before it
734
+ was allowlisted) surfaces a structured DispatchHintsParseError rather
735
+ than crashing the whole runner. Mirrors F3's parse_execution_policy
736
+ pattern (parse_error field → runner converts to contract_bug blocker).
737
+ """
738
+ try:
739
+ text = path.read_text(encoding="utf-8")
740
+ except OSError:
741
+ return {}, ()
742
+ section_match = DISPATCH_SECTION_RE.search(text)
743
+ if not section_match:
744
+ return {}, ()
745
+ section_body = section_match.group("body")
746
+ buckets: dict[str, dict[str, list[str]]] = {}
747
+ subsection_matches = list(DISPATCH_SUBSECTION_RE.finditer(section_body))
748
+ if subsection_matches:
749
+ for match in subsection_matches:
750
+ label = match.group("label").strip().lower()
751
+ key = "default" if label in {"default", "roadmap", "plan", "roadmap default", "plan default"} else label
752
+ _collect_dispatch_lines(match.group("body"), buckets, default_key=key)
753
+ else:
754
+ _collect_dispatch_lines(section_body, buckets, default_key="default")
755
+ result: dict[str, DispatchHints] = {}
756
+ errors: list[DispatchHintsParseError] = []
757
+ for action_key, values in buckets.items():
758
+ action = None if action_key == "default" else action_key
759
+ try:
760
+ result[action_key] = DispatchHints(
761
+ preferred_executors=tuple(values.get("preferred_executors", ())),
762
+ allowed_executors=tuple(values.get("allowed_executors", ())),
763
+ fallback_executors=tuple(values.get("fallback_executors", ())),
764
+ disabled_executors=tuple(values.get("disabled_executors", ())),
765
+ required_capabilities=tuple(values.get("required_capabilities", ())),
766
+ source=kind if action is None else f"{kind}:{action}",
767
+ action=action,
768
+ )
769
+ except ValueError as exc:
770
+ errors.append(
771
+ DispatchHintsParseError(
772
+ path=str(path),
773
+ bucket=action_key,
774
+ raw_message=str(exc),
775
+ invalid_literal=_extract_invalid_literal(str(exc)),
776
+ )
777
+ )
778
+ # Skip the bucket — downstream callers see no hints for this
779
+ # action_key, which is preferable to crashing the whole loop.
780
+ return result, tuple(errors)
781
+
782
+
783
+ def dispatch_hints_for_action(hints: dict[str, DispatchHints], action: str) -> DispatchHints | None:
784
+ action_key = action.lower()
785
+ specific = hints.get(action_key)
786
+ default = hints.get("default")
787
+ if specific and default:
788
+ return DispatchHints(
789
+ preferred_executors=specific.preferred_executors or default.preferred_executors,
790
+ allowed_executors=specific.allowed_executors or default.allowed_executors,
791
+ fallback_executors=specific.fallback_executors or default.fallback_executors,
792
+ disabled_executors=tuple(dict.fromkeys((*default.disabled_executors, *specific.disabled_executors))),
793
+ required_capabilities=tuple(dict.fromkeys((*default.required_capabilities, *specific.required_capabilities))),
794
+ source=specific.source,
795
+ action=action_key,
796
+ )
797
+ if specific:
798
+ return specific
799
+ if default:
800
+ return DispatchHints(
801
+ preferred_executors=default.preferred_executors,
802
+ allowed_executors=default.allowed_executors,
803
+ fallback_executors=default.fallback_executors,
804
+ disabled_executors=default.disabled_executors,
805
+ required_capabilities=default.required_capabilities,
806
+ source=default.source,
807
+ action=action_key,
808
+ )
809
+ return None
810
+
811
+
812
+ def parse_execution_policy(path: Path, *, kind: str) -> ExecutionPolicyDocument:
813
+ try:
814
+ text = path.read_text(encoding="utf-8")
815
+ except OSError:
816
+ return ExecutionPolicyDocument(source=kind)
817
+ section_match = EXECUTION_POLICY_SECTION_RE.search(text)
818
+ if not section_match:
819
+ return ExecutionPolicyDocument(source=kind)
820
+ body = section_match.group("body")
821
+ body_offset = section_match.start("body")
822
+ rules: list[ExecutionPolicyRule] = []
823
+ for match in EXECUTION_POLICY_LINE_RE.finditer(body):
824
+ selector = match.group("selector").strip()
825
+ try:
826
+ assignments = _execution_policy_assignments(match.group("value"))
827
+ rules.append(_execution_policy_rule(selector, assignments, source=kind))
828
+ except ValueError as exc:
829
+ raw_line = text[match.start() + body_offset : match.end() + body_offset]
830
+ line_number = text.count("\n", 0, match.start() + body_offset) + 1
831
+ return ExecutionPolicyDocument(
832
+ rules=(),
833
+ source=kind,
834
+ parse_error=ExecutionPolicyParseError(
835
+ path=str(path),
836
+ line_number=line_number,
837
+ raw_line=raw_line.strip(),
838
+ detail=str(exc),
839
+ ),
840
+ )
841
+ return ExecutionPolicyDocument(rules=tuple(rules), source=kind)
842
+
843
+
844
+ def execution_policy_for_action(document: ExecutionPolicyDocument, action: str) -> ExecutionPolicyRule | None:
845
+ action_key = action.lower()
846
+ default_rule: ExecutionPolicyRule | None = None
847
+ specific_rule: ExecutionPolicyRule | None = None
848
+ for rule in document.rules:
849
+ if rule.lane is not None:
850
+ continue
851
+ if rule.action == action_key:
852
+ specific_rule = rule
853
+ elif rule.selector == "default":
854
+ default_rule = rule
855
+ if specific_rule and default_rule:
856
+ return _merge_execution_policy_rules(default_rule, specific_rule, selector=action_key, action=action_key)
857
+ if specific_rule:
858
+ return specific_rule
859
+ if default_rule:
860
+ return _merge_execution_policy_rules(default_rule, None, selector=action_key, action=action_key)
861
+ return None
862
+
863
+
864
+ def execution_policy_for_lane(document: ExecutionPolicyDocument, action: str, lane: str) -> ExecutionPolicyRule | None:
865
+ action_rule = execution_policy_for_action(document, action)
866
+ lane_key = lane.strip().upper()
867
+ lane_rule = next((rule for rule in document.rules if rule.lane == lane_key), None)
868
+ if lane_rule and action_rule:
869
+ return _merge_execution_policy_rules(action_rule, lane_rule, selector=lane_key, action=action.lower(), lane=lane_key)
870
+ if lane_rule:
871
+ return _merge_execution_policy_rules(lane_rule, None, selector=lane_key, action=action.lower(), lane=lane_key)
872
+ return action_rule
873
+
874
+
875
+ def execution_policy_dispatch_hints(rule: ExecutionPolicyRule | None) -> DispatchHints | None:
876
+ if rule is None or not rule.executor:
877
+ return None
878
+ fallbacks = (rule.fallback,) if rule.fallback in {"codex", "claude", "gemini", "opencode", "command", "manual"} else ()
879
+ return DispatchHints(
880
+ preferred_executors=(rule.executor,),
881
+ fallback_executors=fallbacks,
882
+ source=f"{rule.source}-policy",
883
+ action=rule.action,
884
+ )
885
+
886
+
887
+ def _execution_policy_assignments(value: str) -> dict[str, str]:
888
+ assignments: dict[str, str] = {}
889
+ for match in EXECUTION_POLICY_ASSIGNMENT_RE.finditer(value):
890
+ key = match.group("key").lower().replace("-", "_")
891
+ raw_value = match.group("value").strip().strip("`").strip()
892
+ assignments[key] = raw_value
893
+ if not assignments:
894
+ raise ValueError(f"malformed execution policy line: {value}")
895
+ return assignments
896
+
897
+
898
+ def _execution_policy_rule(selector: str, assignments: dict[str, str], *, source: str) -> ExecutionPolicyRule:
899
+ selector_key = selector.strip().lower()
900
+ action = selector_key if selector_key in PRODUCT_LOOP_ACTIONS else None
901
+ lane = None
902
+ normalized_selector = selector_key
903
+ if selector_key.startswith("lane "):
904
+ lane = selector_key.split(None, 1)[1].strip().upper()
905
+ normalized_selector = lane
906
+ elif re.match(r"^[a-z]+-\d+[a-z]?$", selector_key, re.IGNORECASE):
907
+ lane = selector_key.upper()
908
+ normalized_selector = lane
909
+ elif selector_key in {"default", "work-unit defaults", "work unit defaults"}:
910
+ normalized_selector = "default"
911
+ elif action is None:
912
+ raise ValueError(f"invalid execution policy selector: {selector}")
913
+ inherit_default = assignments.get("inherit_default", "false").lower() in {"true", "yes", "1"}
914
+ return ExecutionPolicyRule(
915
+ selector=normalized_selector,
916
+ action=action,
917
+ lane=lane,
918
+ executor=assignments.get("executor"),
919
+ model=assignments.get("model"),
920
+ effort=assignments.get("effort"),
921
+ work_unit_kind=assignments.get("work_unit"),
922
+ unsupported_policy_behavior=assignments.get("unsupported", "block"),
923
+ fallback=assignments.get("fallback"),
924
+ inherit_default=inherit_default,
925
+ source=source if action is None and lane is None else f"{source}:{normalized_selector}",
926
+ override_reason=assignments.get("reason"),
927
+ )
928
+
929
+
930
+ def _merge_execution_policy_rules(
931
+ base: ExecutionPolicyRule,
932
+ override: ExecutionPolicyRule | None,
933
+ *,
934
+ selector: str,
935
+ action: str | None,
936
+ lane: str | None = None,
937
+ ) -> ExecutionPolicyRule:
938
+ if override is None:
939
+ return ExecutionPolicyRule(
940
+ selector=selector,
941
+ action=action,
942
+ lane=lane,
943
+ executor=base.executor,
944
+ model=base.model,
945
+ effort=base.effort,
946
+ work_unit_kind=base.work_unit_kind,
947
+ unsupported_policy_behavior=base.unsupported_policy_behavior,
948
+ fallback=base.fallback,
949
+ inherit_default=base.inherit_default,
950
+ source=base.source,
951
+ override_reason=base.override_reason,
952
+ )
953
+ return ExecutionPolicyRule(
954
+ selector=selector,
955
+ action=action,
956
+ lane=lane,
957
+ executor=override.executor or base.executor,
958
+ model=override.model or base.model,
959
+ effort=override.effort or base.effort,
960
+ work_unit_kind=override.work_unit_kind or base.work_unit_kind,
961
+ unsupported_policy_behavior=override.unsupported_policy_behavior
962
+ if override.unsupported_policy_behavior != "block" or override.fallback or override.inherit_default
963
+ else base.unsupported_policy_behavior,
964
+ fallback=override.fallback or base.fallback,
965
+ inherit_default=override.inherit_default or base.inherit_default,
966
+ source=override.source,
967
+ override_reason=override.override_reason or base.override_reason,
968
+ )
969
+
970
+
971
+ def find_plan_artifact(repo: Path, phase: str, roadmap: Path | None = None) -> Path | None:
972
+ manifest_plan, regex_conflict = manifest_plan_artifact(repo, phase, roadmap=roadmap)
973
+ if manifest_plan is not None:
974
+ return manifest_plan
975
+ if regex_conflict is not None:
976
+ return None
977
+
978
+ # Fast path: when we know the roadmap, construct the exact expected
979
+ # plan-doc filename and check it directly. This handles cases the legacy
980
+ # glob+regex iteration can't disambiguate, including hyphenated aliases
981
+ # (e.g. SL-1 in v32) and suffix-bearing spec names (e.g.
982
+ # phase-plans-v32-VISUALPARITY.md → phase-plan-v32-VISUALPARITY-SL-1.md).
983
+ # Surfaced by the regen 2026-05-22/23 v32-VISUALPARITY incident; see
984
+ # plans/detailed-phase-loop-plan-discovery-bugs-20260523-0224.md.
985
+ if roadmap is not None:
986
+ m = re.fullmatch(r"phase-plans-(v[\w.-]+)\.md", roadmap.name)
987
+ if m:
988
+ version = m.group(1)
989
+ expected = repo / "plans" / f"phase-plan-{version}-{phase}.md"
990
+ if expected.is_file():
991
+ if plan_matches_roadmap(repo, expected, roadmap, phase):
992
+ return expected.resolve()
993
+ # Fallback: glob+regex iteration (back-compat for case-folded matching
994
+ # or when roadmap is None). PLAN_RE captures (version, alias);
995
+ # group(2) is the alias.
996
+ phase_lower = phase.lower()
997
+ plans = sorted((repo / "plans").glob("phase-plan-v*-*.md"))
998
+ for plan in plans:
999
+ match = PLAN_RE.search(plan.name)
1000
+ if not match:
1001
+ continue
1002
+ if match.group(2).lower() != phase_lower:
1003
+ continue
1004
+ if roadmap is not None and not plan_matches_roadmap(repo, plan, roadmap, phase):
1005
+ continue
1006
+ return plan.resolve()
1007
+ return None
1008
+
1009
+
1010
+ def manifest_plan_artifact(repo: Path, phase: str, roadmap: Path | None = None) -> tuple[Path | None, dict[str, object] | None]:
1011
+ if _phase_manifest_disabled():
1012
+ return None, None
1013
+ phase_upper = phase.upper()
1014
+ candidates: list[tuple[object, Path]] = []
1015
+ for entry in _phase_manifest_entries(repo):
1016
+ if str(entry.phase_alias or "").upper() != phase_upper:
1017
+ continue
1018
+ if entry.status == "orphaned":
1019
+ continue
1020
+ plan = repo / entry.file
1021
+ if not plan.exists():
1022
+ return None, {
1023
+ "source": "manifest",
1024
+ "phase": phase_upper,
1025
+ "status": entry.status,
1026
+ "reason": "manifest_plan_file_missing",
1027
+ "file": entry.file,
1028
+ "slug": entry.slug,
1029
+ }
1030
+ if roadmap is not None:
1031
+ roadmap_ref = entry.roadmap_ref.file if entry.roadmap_ref else None
1032
+ if roadmap_ref != roadmap_repo_relative_path(repo, roadmap):
1033
+ continue
1034
+ if not plan_matches_roadmap(repo, plan, roadmap, phase):
1035
+ continue
1036
+ candidates.append((entry.updated_at, plan.resolve()))
1037
+ if not candidates:
1038
+ return None, None
1039
+ candidates.sort(key=lambda item: str(item[0]))
1040
+ manifest_plan = candidates[-1][1]
1041
+ regex_plan = _regex_plan_artifact(repo, phase, roadmap=roadmap)
1042
+ if regex_plan is not None and regex_plan.resolve() != manifest_plan.resolve():
1043
+ return manifest_plan, {
1044
+ "source": "manifest",
1045
+ "phase": phase_upper,
1046
+ "status": "conflict",
1047
+ "reason": "manifest_regex_plan_conflict",
1048
+ "manifest_file": str(manifest_plan.relative_to(repo.resolve())),
1049
+ "regex_file": str(regex_plan.relative_to(repo.resolve())),
1050
+ }
1051
+ return manifest_plan, None
1052
+
1053
+
1054
+ def _regex_plan_artifact(repo: Path, phase: str, roadmap: Path | None = None) -> Path | None:
1055
+ if roadmap is not None:
1056
+ m = re.fullmatch(r"phase-plans-(v[\w.-]+)\.md", roadmap.name)
1057
+ if m:
1058
+ version = m.group(1)
1059
+ expected = repo / "plans" / f"phase-plan-{version}-{phase}.md"
1060
+ if expected.is_file() and plan_matches_roadmap(repo, expected, roadmap, phase):
1061
+ return expected.resolve()
1062
+ phase_lower = phase.lower()
1063
+ for plan in sorted((repo / "plans").glob("phase-plan-v*-*.md")):
1064
+ match = PLAN_RE.search(plan.name)
1065
+ if not match or match.group(2).lower() != phase_lower:
1066
+ continue
1067
+ if roadmap is not None and not plan_matches_roadmap(repo, plan, roadmap, phase):
1068
+ continue
1069
+ return plan.resolve()
1070
+ return None
1071
+
1072
+
1073
+ def _phase_manifest_disabled() -> bool:
1074
+ return os.environ.get("PHASE_LOOP_MANIFEST_DISABLED") == "1"
1075
+
1076
+
1077
+ def _phase_manifest_entries(repo: Path) -> tuple[object, ...]:
1078
+ try:
1079
+ from .plan_manifest import read_manifest, validate_manifest
1080
+ except Exception:
1081
+ return ()
1082
+ manifest_path = repo / "plans" / "manifest.json"
1083
+ if manifest_path.exists() and not validate_manifest(manifest_path).valid:
1084
+ return ()
1085
+ try:
1086
+ manifest = read_manifest(repo)
1087
+ except Exception:
1088
+ return ()
1089
+ return tuple(entry for entry in manifest.plans if entry.type == "phase")
1090
+
1091
+
1092
+ def plan_is_stale(plan: Path, roadmap: Path) -> bool:
1093
+ repo = plan.resolve().parents[1] if plan.parent.name == "plans" else plan.parent
1094
+ return not plan_matches_roadmap(repo, plan, roadmap)
1095
+
1096
+
1097
+ def plan_metadata(plan: Path) -> dict[str, str]:
1098
+ try:
1099
+ return parse_frontmatter(plan.read_text())
1100
+ except OSError:
1101
+ return {}
1102
+
1103
+
1104
+ def roadmap_fingerprint(roadmap: Path) -> str:
1105
+ return roadmap_sha256(roadmap)
1106
+
1107
+
1108
+ def roadmap_repo_relative_path(repo: Path, roadmap: Path) -> str:
1109
+ resolved_repo = repo.resolve()
1110
+ resolved_roadmap = roadmap.resolve()
1111
+ try:
1112
+ return str(resolved_roadmap.relative_to(resolved_repo))
1113
+ except ValueError:
1114
+ return str(resolved_roadmap)
1115
+
1116
+
1117
+ def plan_matches_roadmap(repo: Path, plan: Path, roadmap: Path, phase: str | None = None) -> bool:
1118
+ return plan_artifact_diagnostic(repo, plan, roadmap, phase) is None
1119
+
1120
+
1121
+ def plan_artifact_diagnostic(repo: Path, plan: Path, roadmap: Path, phase: str | None = None) -> str | None:
1122
+ metadata = plan_metadata(plan)
1123
+ if metadata.get("phase_loop_plan_version") != "1":
1124
+ return "missing_or_invalid_phase_loop_plan_version"
1125
+ if phase is not None and metadata.get("phase", "").upper() != phase.upper():
1126
+ return "mismatched_phase"
1127
+ if metadata.get("roadmap") != roadmap_repo_relative_path(repo, roadmap):
1128
+ return "mismatched_roadmap_path"
1129
+ try:
1130
+ if metadata.get("roadmap_sha256") != roadmap_fingerprint(roadmap):
1131
+ return "mismatched_roadmap_sha256"
1132
+ except OSError:
1133
+ return "roadmap_unreadable"
1134
+ pipeline_diagnostic = pipeline_plan_metadata_diagnostic(repo, plan)
1135
+ if pipeline_diagnostic is not None:
1136
+ return f"pipeline_metadata:{pipeline_diagnostic.kind}"
1137
+ return None
1138
+
1139
+
1140
+ def parse_pipeline_plan_metadata(plan_or_frontmatter: Path | dict[str, str]) -> PipelinePlanMetadata:
1141
+ metadata = plan_metadata(plan_or_frontmatter) if isinstance(plan_or_frontmatter, Path) else dict(plan_or_frontmatter)
1142
+ return PipelinePlanMetadata(
1143
+ source_bundle=metadata.get("source_bundle") or None,
1144
+ source_bundle_sha256=metadata.get("source_bundle_sha256") or None,
1145
+ pipeline_phase_id=metadata.get("pipeline_phase_id") or None,
1146
+ pipeline_mode=metadata.get("pipeline_mode") or None,
1147
+ )
1148
+
1149
+
1150
+ def pipeline_plan_metadata_diagnostic(
1151
+ repo: Path,
1152
+ plan: Path,
1153
+ *,
1154
+ pipeline_required: bool = False,
1155
+ ) -> PipelineMetadataDiagnostic | None:
1156
+ try:
1157
+ metadata = parse_pipeline_plan_metadata(plan)
1158
+ except ValueError as exc:
1159
+ return PipelineMetadataDiagnostic(kind="invalid_pipeline_mode", message=str(exc))
1160
+
1161
+ if not pipeline_required and not metadata.required:
1162
+ return None
1163
+ if not metadata.source_bundle:
1164
+ return PipelineMetadataDiagnostic(
1165
+ kind="missing_source_bundle",
1166
+ message="pipeline-required plan is missing source_bundle frontmatter",
1167
+ metadata=metadata,
1168
+ )
1169
+ if not metadata.source_bundle_sha256:
1170
+ return PipelineMetadataDiagnostic(
1171
+ kind="missing_source_bundle_sha256",
1172
+ message="pipeline-required plan is missing source_bundle_sha256 frontmatter",
1173
+ metadata=metadata,
1174
+ )
1175
+
1176
+ source_bundle = Path(metadata.source_bundle).expanduser()
1177
+ if not source_bundle.is_absolute():
1178
+ source_bundle = repo / source_bundle
1179
+ if not source_bundle.exists():
1180
+ return PipelineMetadataDiagnostic(
1181
+ kind="missing_source_bundle_file",
1182
+ message="pipeline-required source_bundle does not exist",
1183
+ metadata=metadata,
1184
+ expected_sha256=metadata.source_bundle_sha256,
1185
+ )
1186
+ actual_sha256 = hashlib.sha256(source_bundle.read_bytes()).hexdigest()
1187
+ if actual_sha256 != metadata.source_bundle_sha256:
1188
+ return PipelineMetadataDiagnostic(
1189
+ kind="mismatched_source_bundle_sha256",
1190
+ message="pipeline-required source_bundle_sha256 does not match the source bundle file",
1191
+ metadata=metadata,
1192
+ expected_sha256=metadata.source_bundle_sha256,
1193
+ actual_sha256=actual_sha256,
1194
+ )
1195
+ bundle_diagnostic = phase_source_bundle_diagnostic(
1196
+ repo,
1197
+ source_bundle,
1198
+ pipeline_mode=metadata.pipeline_mode,
1199
+ )
1200
+ if bundle_diagnostic is not None:
1201
+ return bundle_diagnostic
1202
+ return None
1203
+
1204
+
1205
+ def pipeline_execution_plan_diagnostic(
1206
+ repo: Path,
1207
+ plan: Path,
1208
+ *,
1209
+ phase: str | None = None,
1210
+ roadmap: Path | None = None,
1211
+ ) -> PipelineMetadataDiagnostic | None:
1212
+ try:
1213
+ metadata = parse_pipeline_plan_metadata(plan)
1214
+ except ValueError as exc:
1215
+ return PipelineMetadataDiagnostic(kind="invalid_pipeline_mode", message=str(exc))
1216
+
1217
+ if metadata.empty:
1218
+ return None
1219
+ if not metadata.source_bundle:
1220
+ return PipelineMetadataDiagnostic(
1221
+ kind="missing_source_bundle",
1222
+ message="Pipeline-aware execution plan is missing source_bundle frontmatter",
1223
+ metadata=metadata,
1224
+ )
1225
+ if not metadata.source_bundle_sha256:
1226
+ return PipelineMetadataDiagnostic(
1227
+ kind="missing_source_bundle_sha256",
1228
+ message="Pipeline-aware execution plan is missing source_bundle_sha256 frontmatter",
1229
+ metadata=metadata,
1230
+ )
1231
+
1232
+ source_bundle = _resolve_bundle_path(repo, metadata.source_bundle)
1233
+ if not source_bundle.exists():
1234
+ return PipelineMetadataDiagnostic(
1235
+ kind="missing_source_bundle_file",
1236
+ message="Pipeline-aware execution source_bundle does not exist",
1237
+ metadata=metadata,
1238
+ expected_sha256=metadata.source_bundle_sha256,
1239
+ )
1240
+ actual_sha256 = hashlib.sha256(source_bundle.read_bytes()).hexdigest()
1241
+ if actual_sha256 != metadata.source_bundle_sha256:
1242
+ return PipelineMetadataDiagnostic(
1243
+ kind="mismatched_source_bundle_sha256",
1244
+ message="Pipeline-aware execution source_bundle_sha256 does not match the source bundle file",
1245
+ metadata=metadata,
1246
+ expected_sha256=metadata.source_bundle_sha256,
1247
+ actual_sha256=actual_sha256,
1248
+ )
1249
+ return phase_source_bundle_diagnostic(
1250
+ repo,
1251
+ source_bundle,
1252
+ phase=phase,
1253
+ roadmap=roadmap,
1254
+ pipeline_mode=metadata.pipeline_mode,
1255
+ )
1256
+
1257
+
1258
+ def pipeline_execution_blocker(diagnostic: PipelineMetadataDiagnostic) -> dict[str, object]:
1259
+ return {
1260
+ "human_required": diagnostic.human_required,
1261
+ "blocker_class": diagnostic.blocker_class,
1262
+ "blocker_summary": f"Pipeline execution freshness validation failed: {diagnostic.kind}",
1263
+ "required_human_inputs": (),
1264
+ "access_attempts": (),
1265
+ }
1266
+
1267
+
1268
+ def load_execution_phase_source_bundle(
1269
+ repo: Path,
1270
+ plan: Path,
1271
+ *,
1272
+ phase: str | None = None,
1273
+ roadmap: Path | None = None,
1274
+ ) -> PhaseSourceBundle | None:
1275
+ try:
1276
+ metadata = parse_pipeline_plan_metadata(plan)
1277
+ except ValueError:
1278
+ return None
1279
+ if metadata.empty or not metadata.source_bundle:
1280
+ return None
1281
+ return load_phase_source_bundle(
1282
+ repo,
1283
+ metadata.source_bundle,
1284
+ phase=phase,
1285
+ roadmap=roadmap,
1286
+ pipeline_mode=metadata.pipeline_mode,
1287
+ )
1288
+
1289
+
1290
+ def load_phase_source_bundle(
1291
+ repo: Path,
1292
+ bundle_path: str | Path | None,
1293
+ *,
1294
+ phase: str | None = None,
1295
+ roadmap: Path | None = None,
1296
+ pipeline_mode: str | None = None,
1297
+ ) -> PhaseSourceBundle | None:
1298
+ bundle, diagnostic = _read_phase_source_bundle(
1299
+ repo,
1300
+ bundle_path,
1301
+ phase=phase,
1302
+ roadmap=roadmap,
1303
+ pipeline_mode=pipeline_mode,
1304
+ )
1305
+ return None if diagnostic is not None else bundle
1306
+
1307
+
1308
+ def phase_source_bundle_diagnostic(
1309
+ repo: Path,
1310
+ bundle_path: str | Path | None,
1311
+ *,
1312
+ phase: str | None = None,
1313
+ roadmap: Path | None = None,
1314
+ pipeline_mode: str | None = None,
1315
+ ) -> PipelineMetadataDiagnostic | None:
1316
+ _bundle, diagnostic = _read_phase_source_bundle(
1317
+ repo,
1318
+ bundle_path,
1319
+ phase=phase,
1320
+ roadmap=roadmap,
1321
+ pipeline_mode=pipeline_mode,
1322
+ )
1323
+ return diagnostic
1324
+
1325
+
1326
+ def _read_phase_source_bundle(
1327
+ repo: Path,
1328
+ bundle_path: str | Path | None,
1329
+ *,
1330
+ phase: str | None,
1331
+ roadmap: Path | None,
1332
+ pipeline_mode: str | None,
1333
+ ) -> tuple[PhaseSourceBundle | None, PipelineMetadataDiagnostic | None]:
1334
+ try:
1335
+ mode = pipeline_mode or ("pipeline_optional" if bundle_path else "standalone")
1336
+ PipelinePlanMetadata(pipeline_mode=mode)
1337
+ except ValueError as exc:
1338
+ return None, PipelineMetadataDiagnostic(kind="invalid_pipeline_mode", message=str(exc))
1339
+
1340
+ if not bundle_path:
1341
+ if mode == "pipeline_required":
1342
+ return None, PipelineMetadataDiagnostic(
1343
+ kind="missing_source_bundle",
1344
+ message="pipeline-required planning run is missing a source bundle",
1345
+ metadata=PipelinePlanMetadata(pipeline_mode=mode),
1346
+ )
1347
+ return None, None
1348
+
1349
+ source_bundle = _resolve_bundle_path(repo, bundle_path)
1350
+ metadata = PipelinePlanMetadata(source_bundle=_relative_repo_path(repo, source_bundle), pipeline_mode=mode)
1351
+ if not source_bundle.exists():
1352
+ return None, PipelineMetadataDiagnostic(
1353
+ kind="missing_source_bundle_file",
1354
+ message="Pipeline source bundle does not exist",
1355
+ metadata=metadata,
1356
+ )
1357
+ try:
1358
+ raw = source_bundle.read_bytes()
1359
+ data = json.loads(raw.decode("utf-8"))
1360
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
1361
+ return None, PipelineMetadataDiagnostic(
1362
+ kind="malformed_source_bundle",
1363
+ message=f"Pipeline source bundle is not valid JSON: {exc}",
1364
+ metadata=metadata,
1365
+ )
1366
+ actual_sha256 = hashlib.sha256(raw).hexdigest()
1367
+ if not isinstance(data, dict):
1368
+ return None, PipelineMetadataDiagnostic(
1369
+ kind="malformed_source_bundle",
1370
+ message="Pipeline source bundle root must be an object",
1371
+ metadata=metadata,
1372
+ actual_sha256=actual_sha256,
1373
+ )
1374
+ if data.get("schema") != PHASE_SOURCE_BUNDLE_SCHEMA:
1375
+ return None, PipelineMetadataDiagnostic(
1376
+ kind="malformed_source_bundle",
1377
+ message=f"Pipeline source bundle schema must be {PHASE_SOURCE_BUNDLE_SCHEMA}",
1378
+ metadata=metadata,
1379
+ actual_sha256=actual_sha256,
1380
+ )
1381
+
1382
+ required = ("phase", "roadmap", "protected_sources", "delegated_write_policy", "source_files", "artifact_target_root", "freshness")
1383
+ missing = [field for field in required if field not in data]
1384
+ if missing:
1385
+ return None, PipelineMetadataDiagnostic(
1386
+ kind="malformed_source_bundle",
1387
+ message=f"Pipeline source bundle is missing required fields: {', '.join(missing)}",
1388
+ metadata=metadata,
1389
+ actual_sha256=actual_sha256,
1390
+ )
1391
+
1392
+ phase_data = data.get("phase")
1393
+ roadmap_data = data.get("roadmap")
1394
+ protected_data = data.get("protected_sources")
1395
+ if not isinstance(phase_data, dict) or not isinstance(roadmap_data, dict) or not isinstance(protected_data, dict):
1396
+ return None, PipelineMetadataDiagnostic(
1397
+ kind="malformed_source_bundle",
1398
+ message="Pipeline source bundle phase, roadmap, and protected_sources fields must be objects",
1399
+ metadata=metadata,
1400
+ actual_sha256=actual_sha256,
1401
+ )
1402
+ phase_missing = [field for field in ("phase_id", "phase_alias", "phase_plan_path") if not phase_data.get(field)]
1403
+ roadmap_missing = [field for field in ("path", "sha256") if not roadmap_data.get(field)]
1404
+ if phase_missing or roadmap_missing:
1405
+ missing_text = ", ".join([*(f"phase.{field}" for field in phase_missing), *(f"roadmap.{field}" for field in roadmap_missing)])
1406
+ return None, PipelineMetadataDiagnostic(
1407
+ kind="malformed_source_bundle",
1408
+ message=f"Pipeline source bundle is missing required nested fields: {missing_text}",
1409
+ metadata=metadata,
1410
+ actual_sha256=actual_sha256,
1411
+ )
1412
+
1413
+ freshness = data.get("freshness") if isinstance(data.get("freshness"), dict) else {}
1414
+ expected_bundle_hash = freshness.get("source_bundle_hash")
1415
+ if isinstance(expected_bundle_hash, str) and _looks_like_sha256(expected_bundle_hash) and expected_bundle_hash != actual_sha256:
1416
+ return None, PipelineMetadataDiagnostic(
1417
+ kind="mismatched_source_bundle_sha256",
1418
+ message="Pipeline source bundle freshness.source_bundle_hash does not match the source bundle file",
1419
+ metadata=metadata,
1420
+ expected_sha256=expected_bundle_hash,
1421
+ actual_sha256=actual_sha256,
1422
+ )
1423
+
1424
+ phase_id = str(phase_data["phase_id"])
1425
+ phase_alias = str(phase_data["phase_alias"])
1426
+ if _phase_is_unknown(repo, roadmap, roadmap_data, phase, phase_id, phase_alias):
1427
+ return None, PipelineMetadataDiagnostic(
1428
+ kind="unknown_phase_id",
1429
+ message=f"Pipeline source bundle phase does not match the selected roadmap phase: {phase_id}/{phase_alias}",
1430
+ metadata=metadata,
1431
+ actual_sha256=actual_sha256,
1432
+ )
1433
+
1434
+ protected_entries = protected_data.get("entries")
1435
+ if not isinstance(protected_entries, list) or not protected_entries:
1436
+ return None, PipelineMetadataDiagnostic(
1437
+ kind="missing_protected_source_entries",
1438
+ message="Pipeline source bundle protected_sources.entries must contain at least one entry",
1439
+ metadata=metadata,
1440
+ actual_sha256=actual_sha256,
1441
+ )
1442
+ require_roles = bool(protected_data.get("requires_roles") or protected_data.get("role_required"))
1443
+ protected_sources: list[PipelineProtectedSource] = []
1444
+ for entry in protected_entries:
1445
+ if not isinstance(entry, dict):
1446
+ return None, PipelineMetadataDiagnostic(
1447
+ kind="malformed_source_bundle",
1448
+ message="Pipeline source bundle protected source entries must be objects",
1449
+ metadata=metadata,
1450
+ actual_sha256=actual_sha256,
1451
+ )
1452
+ if require_roles and not entry.get("role"):
1453
+ return None, PipelineMetadataDiagnostic(
1454
+ kind="malformed_source_bundle",
1455
+ message="Pipeline source bundle requires adoption-sensitive protected source role metadata",
1456
+ metadata=metadata,
1457
+ actual_sha256=actual_sha256,
1458
+ )
1459
+ try:
1460
+ protected_source = PipelineProtectedSource(
1461
+ path=str(entry.get("path", "")),
1462
+ category=str(entry.get("category", "")),
1463
+ sha256=str(entry["sha256"]) if entry.get("sha256") is not None else None,
1464
+ role=str(entry["role"]) if entry.get("role") is not None else None,
1465
+ )
1466
+ except ValueError as exc:
1467
+ return None, PipelineMetadataDiagnostic(
1468
+ kind="malformed_source_bundle",
1469
+ message=f"Pipeline source bundle has invalid protected source entry: {exc}",
1470
+ metadata=metadata,
1471
+ actual_sha256=actual_sha256,
1472
+ )
1473
+ if require_roles and protected_source.role not in PIPELINE_PROTECTED_SOURCE_ROLES:
1474
+ return None, PipelineMetadataDiagnostic(
1475
+ kind="malformed_source_bundle",
1476
+ message="Pipeline source bundle requires adoption-sensitive protected source role metadata",
1477
+ metadata=metadata,
1478
+ actual_sha256=actual_sha256,
1479
+ )
1480
+ protected_diagnostic = _protected_source_file_diagnostic(repo, protected_source, metadata, actual_sha256)
1481
+ if protected_diagnostic is not None:
1482
+ return None, protected_diagnostic
1483
+ protected_sources.append(protected_source)
1484
+
1485
+ source_files = data.get("source_files")
1486
+ if not isinstance(source_files, list):
1487
+ return None, PipelineMetadataDiagnostic(
1488
+ kind="malformed_source_bundle",
1489
+ message="Pipeline source bundle source_files must be a list",
1490
+ metadata=metadata,
1491
+ actual_sha256=actual_sha256,
1492
+ )
1493
+
1494
+ bundle = PhaseSourceBundle(
1495
+ path=_relative_repo_path(repo, source_bundle),
1496
+ sha256=actual_sha256,
1497
+ phase_id=phase_id,
1498
+ phase_alias=phase_alias,
1499
+ phase_plan_path=str(phase_data["phase_plan_path"]),
1500
+ roadmap_path=str(roadmap_data["path"]),
1501
+ roadmap_sha256=str(roadmap_data["sha256"]),
1502
+ protected_sources=tuple(protected_sources),
1503
+ delegated_write_policy=dict(data.get("delegated_write_policy") if isinstance(data.get("delegated_write_policy"), dict) else {}),
1504
+ source_files=tuple(item for item in source_files if isinstance(item, dict)),
1505
+ artifact_target_root=str(data.get("artifact_target_root") or ""),
1506
+ freshness=dict(freshness),
1507
+ pipeline_mode=mode if mode != "standalone" else "pipeline_optional",
1508
+ )
1509
+ return bundle, None
1510
+
1511
+
1512
+ def _resolve_bundle_path(repo: Path, bundle_path: str | Path) -> Path:
1513
+ path = Path(bundle_path).expanduser()
1514
+ if not path.is_absolute():
1515
+ path = repo / path
1516
+ return path.resolve()
1517
+
1518
+
1519
+ def _looks_like_sha256(value: str) -> bool:
1520
+ return bool(re.fullmatch(r"[0-9a-fA-F]{64}", value.strip()))
1521
+
1522
+
1523
+ def _phase_is_unknown(
1524
+ repo: Path,
1525
+ roadmap: Path | None,
1526
+ roadmap_data: dict[str, Any],
1527
+ selected_phase: str | None,
1528
+ phase_id: str,
1529
+ phase_alias: str,
1530
+ ) -> bool:
1531
+ candidates = {phase_id.upper(), phase_alias.upper()}
1532
+ if selected_phase:
1533
+ return selected_phase.upper() not in candidates
1534
+ roadmap_path = roadmap
1535
+ if roadmap_path is None:
1536
+ raw_roadmap = roadmap_data.get("path")
1537
+ if isinstance(raw_roadmap, str) and raw_roadmap:
1538
+ roadmap_path = Path(raw_roadmap).expanduser()
1539
+ if not roadmap_path.is_absolute():
1540
+ roadmap_path = repo / roadmap_path
1541
+ if roadmap_path is None or not roadmap_path.exists():
1542
+ return False
1543
+ aliases = {alias.upper() for alias in parse_roadmap_phases(roadmap_path)}
1544
+ return not bool(candidates & aliases)
1545
+
1546
+
1547
+ def _protected_source_file_diagnostic(
1548
+ repo: Path,
1549
+ source: PipelineProtectedSource,
1550
+ metadata: PipelinePlanMetadata,
1551
+ bundle_sha256: str,
1552
+ ) -> PipelineMetadataDiagnostic | None:
1553
+ source_path = Path(source.path).expanduser()
1554
+ if not source_path.is_absolute():
1555
+ source_path = repo / source_path
1556
+ if not source_path.exists():
1557
+ return PipelineMetadataDiagnostic(
1558
+ kind="missing_protected_source_file",
1559
+ message=f"Pipeline protected source does not exist: {source.path}",
1560
+ metadata=metadata,
1561
+ actual_sha256=bundle_sha256,
1562
+ )
1563
+ if source.sha256 and _looks_like_sha256(source.sha256):
1564
+ actual = hashlib.sha256(source_path.read_bytes()).hexdigest()
1565
+ if actual != source.sha256:
1566
+ return PipelineMetadataDiagnostic(
1567
+ kind="mismatched_protected_source_sha256",
1568
+ message=f"Pipeline protected source sha256 does not match: {source.path}",
1569
+ metadata=metadata,
1570
+ expected_sha256=source.sha256,
1571
+ actual_sha256=actual,
1572
+ )
1573
+ return None
1574
+
1575
+
1576
+ def parse_plan_ownership(repo: Path, roadmap: Path, plan: Path | None) -> PlanOwnership:
1577
+ # plans/manifest.json is written by the runtime's own skill bundle and read
1578
+ # back for plan discovery in every repo, so the closeout classifier must
1579
+ # treat it as a control path (phase-owned) rather than an unowned dirty file.
1580
+ runtime_control = ("plans/manifest.json",)
1581
+ control_paths = (_relative_repo_path(repo, roadmap), *runtime_control)
1582
+ if plan is None:
1583
+ return PlanOwnership(owned_patterns=(), control_paths=control_paths, valid=False, errors=("missing_plan_artifact",))
1584
+
1585
+ control_paths = (_relative_repo_path(repo, roadmap), _relative_repo_path(repo, plan), *runtime_control)
1586
+ try:
1587
+ text = plan.read_text(encoding="utf-8")
1588
+ except OSError:
1589
+ return PlanOwnership(owned_patterns=(), control_paths=control_paths, valid=False, errors=("plan_unreadable",))
1590
+
1591
+ from .plan_ir import parse_phase_plan_ir
1592
+
1593
+ lane_ir = parse_phase_plan_ir(plan)
1594
+ if lane_ir.lanes:
1595
+ errors = []
1596
+ for diagnostic in lane_ir.diagnostics:
1597
+ if diagnostic.kind in {"missing_owned_files", "malformed_owned_files"}:
1598
+ errors.append(f"{diagnostic.kind}:{diagnostic.details.get('heading', diagnostic.lane_id or 'plan')}")
1599
+ else:
1600
+ errors.append(f"{diagnostic.kind}:{diagnostic.lane_id or 'plan'}")
1601
+ return PlanOwnership(
1602
+ owned_patterns=tuple(
1603
+ dict.fromkeys(
1604
+ pattern
1605
+ for lane in lane_ir.lanes
1606
+ if not lane.read_only
1607
+ for pattern in lane.owned_files
1608
+ )
1609
+ ),
1610
+ control_paths=control_paths,
1611
+ valid=lane_ir.valid,
1612
+ errors=tuple(errors),
1613
+ )
1614
+
1615
+ lane_starts = list(LANE_SECTION_RE.finditer(text))
1616
+ if not lane_starts:
1617
+ return PlanOwnership(owned_patterns=(), control_paths=control_paths, valid=False, errors=("missing_lane_sections",))
1618
+
1619
+ owned_patterns: list[str] = []
1620
+ errors: list[str] = []
1621
+ for index, lane_start in enumerate(lane_starts):
1622
+ end = lane_starts[index + 1].start() if index + 1 < len(lane_starts) else len(text)
1623
+ section = text[lane_start.start() : end]
1624
+ raw = _owned_files_text(section)
1625
+ if raw is None:
1626
+ errors.append(f"missing_owned_files:{lane_start.group(0).strip()}")
1627
+ continue
1628
+ normalized = raw.strip("`").strip().lower()
1629
+ if normalized.startswith("none") or normalized.startswith("(none"):
1630
+ continue
1631
+ entries = [item.strip() for item in re.findall(r"`([^`]+)`", raw)]
1632
+ if not entries:
1633
+ errors.append(f"malformed_owned_files:{lane_start.group(0).strip()}")
1634
+ continue
1635
+ for entry in entries:
1636
+ if entry:
1637
+ owned_patterns.append(entry)
1638
+
1639
+ return PlanOwnership(
1640
+ owned_patterns=tuple(dict.fromkeys(owned_patterns)),
1641
+ control_paths=control_paths,
1642
+ valid=not errors,
1643
+ errors=tuple(errors),
1644
+ )
1645
+
1646
+
1647
+ # --- v45 FOUND: cross-phase scheduling, ownership, and reconciliation ----------
1648
+ # Frozen contracts for runner hardening (IF-0-FOUND-1..4). These are pure
1649
+ # functions plus the no-op reconcile hook; the runner main-loop wiring that
1650
+ # consumes select_ready_phase_wave lands in v45 Phase 3 (SCHED).
1651
+
1652
+
1653
+ def phase_owned_files(repo: Path, roadmap: Path, alias: str) -> tuple[str, ...]:
1654
+ """IF-0-FOUND-2. Owned-file patterns a phase declares across its plan's
1655
+ writer lanes. Returns ``()`` when the phase is unplanned (no plan artifact)
1656
+ or genuinely owns nothing — the same empty-owned condition that makes a
1657
+ verified plan ``is_control_only``. Ownership is sourced from the resolved
1658
+ plan artifact via :func:`parse_plan_ownership`; roadmaps carry no lane
1659
+ ownership, so an unplanned phase is correctly empty-owned here."""
1660
+ plan = find_plan_artifact(repo, alias, roadmap)
1661
+ return parse_plan_ownership(repo, roadmap, plan).owned_patterns
1662
+
1663
+
1664
+ def compute_ready_phases(
1665
+ roadmap: Path,
1666
+ classifications: dict[str, str],
1667
+ completed: set[str],
1668
+ ) -> tuple[str, ...]:
1669
+ """IF-0-FOUND-3. Roadmap phases whose ``**Depends on**`` aliases are all in
1670
+ ``completed`` and whose own status in ``classifications`` is neither
1671
+ ``complete`` nor ``blocked``. Order follows the roadmap's phase order."""
1672
+ from .roadmap_lint import _extract_phases
1673
+
1674
+ try:
1675
+ text = roadmap.read_text(encoding="utf-8")
1676
+ except OSError:
1677
+ return ()
1678
+ ready: list[str] = []
1679
+ for phase in _extract_phases(text):
1680
+ if classifications.get(phase.alias) in {"complete", "blocked"}:
1681
+ continue
1682
+ if all(dependency in completed for dependency in phase.depends_on):
1683
+ ready.append(phase.alias)
1684
+ return tuple(ready)
1685
+
1686
+
1687
+ def select_ready_phase_wave(
1688
+ waves: tuple[tuple[str, ...], ...],
1689
+ classifications: dict[str, str],
1690
+ mode: str,
1691
+ ) -> tuple[str, ...]:
1692
+ """IF-0-FOUND-1. Given precomputed topological ``waves`` of phase aliases,
1693
+ return the aliases to dispatch next, skipping any already ``complete`` or
1694
+ ``blocked``. ``off``/``serialized`` return at most one alias (today's
1695
+ one-phase-at-a-time behavior); ``concurrent`` returns the full ready wave.
1696
+ Selects from the first wave that still has ready phases."""
1697
+ for wave in waves:
1698
+ ready = tuple(
1699
+ alias
1700
+ for alias in wave
1701
+ if classifications.get(alias) not in {"complete", "blocked"}
1702
+ )
1703
+ if not ready:
1704
+ continue
1705
+ return ready if mode == "concurrent" else ready[:1]
1706
+ return ()
1707
+
1708
+
1709
+ def validate_concurrent_phase_ownership(
1710
+ repo: Path,
1711
+ roadmap: Path,
1712
+ aliases: tuple[str, ...],
1713
+ ) -> tuple[LaneIRDiagnostic, ...]:
1714
+ """IF-0-FOUND-2. Phase-level analog of
1715
+ ``lane_scheduler.validate_concurrent_lane_ownership``: emit an
1716
+ ``overlapping_write_ownership`` diagnostic for any pair of phases in
1717
+ ``aliases`` whose declared owned files overlap, which would make them unsafe
1718
+ to execute concurrently."""
1719
+ owned = {alias: phase_owned_files(repo, roadmap, alias) for alias in aliases}
1720
+ diagnostics: list[LaneIRDiagnostic] = []
1721
+ ordered = list(aliases)
1722
+ for index, left in enumerate(ordered):
1723
+ for right in ordered[index + 1 :]:
1724
+ if _lane_patterns_overlap(owned[left], owned[right]):
1725
+ diagnostics.append(
1726
+ LaneIRDiagnostic(
1727
+ kind="overlapping_write_ownership",
1728
+ lane_id=right,
1729
+ message=(
1730
+ f"phases {left} and {right} cannot run concurrently "
1731
+ "with overlapping owned files"
1732
+ ),
1733
+ details={"left": left, "right": right},
1734
+ )
1735
+ )
1736
+ return tuple(diagnostics)
1737
+
1738
+
1739
+ def reconcile_against_git_reality(
1740
+ repo: Path,
1741
+ roadmap: Path,
1742
+ classifications: dict[str, str],
1743
+ ) -> dict[str, str]:
1744
+ """IF-0-RECONCILE-1. Promote a phase to ``complete`` when merged git history
1745
+ proves it was completed against a roadmap section byte-identical to the
1746
+ current one (the roadmap was renamed/advanced, not the phase edited) AND the
1747
+ phase's own work still exists at HEAD.
1748
+
1749
+ Conservative by construction (criterion 4 dominates criterion 3): only
1750
+ ``unplanned`` phases are candidates, the function never demotes, and any
1751
+ uncertainty — no completion-trailer commit, an *edited* section, or owned
1752
+ work that was reverted/modified since closeout — leaves the classification
1753
+ untouched so a phase that really needs to run is never falsely marked done.
1754
+
1755
+ Gated behind ``PHASE_LOOP_RECONCILE_GIT_REALITY`` (default off → identity
1756
+ no-op), the safe cutover point for landing on the live runtime."""
1757
+ if not reconcile_git_reality_enabled():
1758
+ return dict(classifications)
1759
+ result = dict(classifications)
1760
+ for alias, status in classifications.items():
1761
+ if status != "unplanned":
1762
+ continue
1763
+ if _phase_complete_in_git_reality(repo, roadmap, alias):
1764
+ result[alias] = "complete"
1765
+ return result
1766
+
1767
+
1768
+ def _phase_complete_in_git_reality(repo: Path, roadmap: Path, alias: str) -> bool:
1769
+ """Shared predicate for both ``reconcile_against_git_reality`` and
1770
+ ``classify_phase`` (single source of truth — the two entry points cannot
1771
+ drift into different safety postures).
1772
+
1773
+ True when HEAD-reachable history holds a completion commit for ``alias``
1774
+ whose (1) ``Terminal-Status`` parsed trailer is ``complete`` and ``Plan``
1775
+ trailer resolves to ``alias``, (2) roadmap section is byte-identical
1776
+ (``phase_sha256``) to the current roadmap's (rename, not edit), and (3) every
1777
+ owned file from that plan is unchanged between the completion commit and HEAD
1778
+ (the work still exists — a later revert/modify makes the diff non-empty)."""
1779
+ if not reconcile_git_reality_enabled():
1780
+ return False
1781
+ current_sha = phase_sha256(roadmap, alias)
1782
+ if current_sha is None:
1783
+ return False
1784
+ alias_upper = alias.upper()
1785
+ # Extract the Plan / Terminal-Status values with git's OWN trailer parser
1786
+ # (`%(trailers:key=...,valueonly)`), not a body scan — so a commit whose prose
1787
+ # merely mentions "Terminal-Status: complete" cannot forge a completion (B2).
1788
+ # Records: <sha> US <plan-values> US <status-values> RS; multi-valued trailers
1789
+ # are GS-separated (paths/statuses never contain these control bytes).
1790
+ log = _git(
1791
+ repo,
1792
+ "log",
1793
+ "--format=%H%x1f%(trailers:key=Plan,valueonly,separator=%x1d)"
1794
+ "%x1f%(trailers:key=Terminal-Status,valueonly,separator=%x1d)%x1e",
1795
+ )
1796
+ if not log:
1797
+ return False
1798
+ for record in log.split("\x1e"):
1799
+ parts = record.split("\x1f")
1800
+ if len(parts) != 3:
1801
+ continue
1802
+ commit = parts[0].strip()
1803
+ if not commit:
1804
+ continue
1805
+ statuses = {value.strip() for value in parts[2].split("\x1d") if value.strip()}
1806
+ if "complete" not in statuses:
1807
+ continue
1808
+ plan_rel = next((value.strip() for value in parts[1].split("\x1d") if value.strip()), "")
1809
+ if not plan_rel or not _plan_rel_matches_alias(plan_rel, alias_upper):
1810
+ continue
1811
+ historical_sha = _historical_phase_sha256(repo, commit, plan_rel, alias)
1812
+ if historical_sha is None or historical_sha != current_sha:
1813
+ continue
1814
+ # B1: the work must still exist at HEAD, not merely have existed once.
1815
+ if not _owned_work_persists_to_head(repo, commit, plan_rel, roadmap):
1816
+ continue
1817
+ return True
1818
+ return False
1819
+
1820
+
1821
+ def _plan_rel_matches_alias(plan_rel: str, alias_upper: str) -> bool:
1822
+ match = PLAN_RE.search(plan_rel)
1823
+ return bool(match and match.group(2).upper() == alias_upper)
1824
+
1825
+
1826
+ def _historical_phase_sha256(repo: Path, commit: str, plan_rel: str, alias: str) -> str | None:
1827
+ """Recompute the phase-section sha256 from the roadmap as it stood at the
1828
+ completion commit, so a rename (new filename, identical section) matches while
1829
+ an edit (changed section) does not."""
1830
+ plan_text = _git_show_blob(repo, commit, plan_rel)
1831
+ if not plan_text:
1832
+ return None
1833
+ roadmap_rel = parse_frontmatter(plan_text).get("roadmap")
1834
+ if not roadmap_rel:
1835
+ return None
1836
+ roadmap_text = _git_show_blob(repo, commit, roadmap_rel)
1837
+ if not roadmap_text:
1838
+ return None
1839
+ tmp = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
1840
+ try:
1841
+ tmp.write(roadmap_text)
1842
+ tmp.close()
1843
+ return phase_sha256(Path(tmp.name), alias)
1844
+ finally:
1845
+ os.unlink(tmp.name)
1846
+
1847
+
1848
+ def _owned_work_persists_to_head(repo: Path, commit: str, plan_rel: str, roadmap: Path) -> bool:
1849
+ """True when every owned file declared in the plan at ``commit`` is unchanged
1850
+ between ``commit`` and HEAD. A reverted or rewritten owned file yields a
1851
+ non-empty diff → the work no longer matches the closeout → no promotion.
1852
+
1853
+ Scope: the *phase's own* artifacts persisting, not global repo consistency (a
1854
+ reverted dependency owned by another phase is verification's concern). A
1855
+ control-only plan (no owned files) has no artifacts to revert and passes."""
1856
+ plan_text = _git_show_blob(repo, commit, plan_rel)
1857
+ if not plan_text:
1858
+ return False
1859
+ tmp = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
1860
+ try:
1861
+ tmp.write(plan_text)
1862
+ tmp.close()
1863
+ ownership = parse_plan_ownership(repo, roadmap, Path(tmp.name))
1864
+ finally:
1865
+ os.unlink(tmp.name)
1866
+ if not ownership.owned_patterns:
1867
+ # A *valid* control-only plan owns no files → nothing to revert → persists.
1868
+ # An *invalid*/unparseable plan (valid=False, empty owned) is genuinely
1869
+ # misconfigured and must NOT promote — is_control_only is False for it.
1870
+ return ownership.is_control_only
1871
+ # Resolve owned patterns to the EXACT files present in the completion commit's
1872
+ # tree using the codebase's OWN ownership matcher (globstar-aware), then diff
1873
+ # those literal paths. We cannot hand the glob patterns to git pathspec: git's
1874
+ # default pathspec mis-handles `**` (e.g. `dir/**/*.ext` matches neither the
1875
+ # file in `ls-tree` nor a deletion in `diff`), which would silently miss a
1876
+ # reverted file. Literal paths diff reliably — a reverted/deleted/modified
1877
+ # owned file yields rc=1 → no promotion.
1878
+ listed = subprocess.run(
1879
+ ["git", "-C", str(repo), "ls-tree", "-r", "--name-only", commit],
1880
+ capture_output=True,
1881
+ text=True,
1882
+ )
1883
+ if listed.returncode != 0:
1884
+ return False
1885
+ exact = [
1886
+ line.strip()
1887
+ for line in listed.stdout.splitlines()
1888
+ if line.strip() and ownership.matches_dirty_output(line.strip())
1889
+ ]
1890
+ if not exact:
1891
+ # The plan declares owned files but none exist in the completion commit's
1892
+ # tree — the completion did not actually produce them → do not promote.
1893
+ return False
1894
+ proc = subprocess.run(
1895
+ ["git", "-C", str(repo), "diff", "--quiet", commit, "HEAD", "--", *exact],
1896
+ stdout=subprocess.DEVNULL,
1897
+ stderr=subprocess.DEVNULL,
1898
+ )
1899
+ return proc.returncode == 0
1900
+
1901
+
1902
+ def _git_show_blob(repo: Path, ref: str, rel_path: str) -> str | None:
1903
+ """``git show <ref>:<path>`` without ``_git``'s trailing-whitespace strip, so
1904
+ the recomputed ``phase_sha256`` matches the committed bytes exactly."""
1905
+ try:
1906
+ return subprocess.check_output(
1907
+ ["git", "-C", str(repo), "show", f"{ref}:{rel_path}"],
1908
+ text=True,
1909
+ stderr=subprocess.DEVNULL,
1910
+ )
1911
+ except Exception:
1912
+ return None
1913
+
1914
+
1915
+ def classify_phase_team_eligibility(repo: Path, roadmap: Path, plan: Path | None) -> PhaseTeamEligibility:
1916
+ ownership = parse_plan_ownership(repo, roadmap, plan)
1917
+ if plan is None:
1918
+ return PhaseTeamEligibility(
1919
+ allowed_execution_modes=("solo",),
1920
+ default_execution_mode="solo",
1921
+ eligible_for_native_team=False,
1922
+ has_disjoint_write_lanes=False,
1923
+ has_only_read_only_lanes=False,
1924
+ unmanaged_write_risk=True,
1925
+ reason="missing_phase_plan",
1926
+ invalid_reasons=("missing_phase_plan",),
1927
+ )
1928
+ from .plan_ir import parse_phase_plan_ir
1929
+
1930
+ lane_ir = parse_phase_plan_ir(plan) if plan is not None else None
1931
+ if (
1932
+ lane_ir is not None
1933
+ and lane_ir.lanes
1934
+ and lane_ir.diagnostics
1935
+ and all(diagnostic.kind == "overlapping_write_ownership" for diagnostic in lane_ir.diagnostics)
1936
+ ):
1937
+ lanes = tuple(
1938
+ PlanLane(
1939
+ heading=lane.heading,
1940
+ owned_patterns=lane.owned_files,
1941
+ read_only=lane.read_only,
1942
+ text="",
1943
+ )
1944
+ for lane in lane_ir.lanes
1945
+ )
1946
+ write_lanes = [lane for lane in lanes if not lane.read_only]
1947
+ overlaps = _overlapping_lane_pairs(write_lanes)
1948
+ return PhaseTeamEligibility(
1949
+ allowed_execution_modes=("solo",),
1950
+ default_execution_mode="solo",
1951
+ eligible_for_native_team=False,
1952
+ has_disjoint_write_lanes=False,
1953
+ has_only_read_only_lanes=False,
1954
+ unmanaged_write_risk=True,
1955
+ reason="overlapping_owned_files",
1956
+ invalid_reasons=tuple(f"overlap:{left}<->{right}" for left, right in overlaps),
1957
+ lane_summaries=tuple(
1958
+ {
1959
+ "heading": lane.heading,
1960
+ "read_only": lane.read_only,
1961
+ "owned_patterns": list(lane.owned_patterns),
1962
+ }
1963
+ for lane in lanes
1964
+ ),
1965
+ )
1966
+
1967
+ if not ownership.valid:
1968
+ return PhaseTeamEligibility(
1969
+ allowed_execution_modes=("solo",),
1970
+ default_execution_mode="solo",
1971
+ eligible_for_native_team=False,
1972
+ has_disjoint_write_lanes=False,
1973
+ has_only_read_only_lanes=False,
1974
+ unmanaged_write_risk=True,
1975
+ reason="invalid_owned_files_contract",
1976
+ invalid_reasons=ownership.errors,
1977
+ )
1978
+
1979
+ if lane_ir is not None and lane_ir.lanes:
1980
+ lanes = tuple(
1981
+ PlanLane(
1982
+ heading=lane.heading,
1983
+ owned_patterns=lane.owned_files,
1984
+ read_only=lane.read_only,
1985
+ text="",
1986
+ )
1987
+ for lane in lane_ir.lanes
1988
+ )
1989
+ else:
1990
+ lanes = _parse_plan_lanes(plan)
1991
+ if not lanes:
1992
+ return PhaseTeamEligibility(
1993
+ allowed_execution_modes=("solo",),
1994
+ default_execution_mode="solo",
1995
+ eligible_for_native_team=False,
1996
+ has_disjoint_write_lanes=False,
1997
+ has_only_read_only_lanes=False,
1998
+ unmanaged_write_risk=True,
1999
+ reason="missing_lane_sections",
2000
+ invalid_reasons=("missing_lane_sections",),
2001
+ )
2002
+
2003
+ write_lanes = [lane for lane in lanes if not lane.read_only]
2004
+ read_only_lanes = [lane for lane in lanes if lane.read_only]
2005
+ overlaps = _overlapping_lane_pairs(write_lanes)
2006
+ unmanaged_write_risk = bool(overlaps)
2007
+ has_disjoint_write_lanes = bool(write_lanes) and not unmanaged_write_risk
2008
+ has_only_read_only_lanes = bool(lanes) and len(read_only_lanes) == len(lanes)
2009
+ eligible = has_only_read_only_lanes or has_disjoint_write_lanes
2010
+ allowed_modes = ("solo", "subagent", "agent_team") if eligible else ("solo",)
2011
+ reason = (
2012
+ "read_only_lanes_only"
2013
+ if has_only_read_only_lanes
2014
+ else "disjoint_write_lanes"
2015
+ if has_disjoint_write_lanes
2016
+ else "overlapping_owned_files"
2017
+ )
2018
+ invalid_reasons = tuple(f"overlap:{left}<->{right}" for left, right in overlaps)
2019
+ lane_summaries = tuple(
2020
+ {
2021
+ "heading": lane.heading,
2022
+ "read_only": lane.read_only,
2023
+ "owned_patterns": list(lane.owned_patterns),
2024
+ }
2025
+ for lane in lanes
2026
+ )
2027
+ return PhaseTeamEligibility(
2028
+ allowed_execution_modes=allowed_modes,
2029
+ default_execution_mode="solo",
2030
+ eligible_for_native_team=eligible,
2031
+ has_disjoint_write_lanes=has_disjoint_write_lanes,
2032
+ has_only_read_only_lanes=has_only_read_only_lanes,
2033
+ unmanaged_write_risk=unmanaged_write_risk,
2034
+ reason=reason,
2035
+ invalid_reasons=invalid_reasons,
2036
+ lane_summaries=lane_summaries,
2037
+ )
2038
+
2039
+
2040
+ def _owned_files_text(section: str) -> str | None:
2041
+ lines = section.splitlines()
2042
+ for index, line in enumerate(lines):
2043
+ owned_match = re.match(r"^\s*(?:-\s+)?\*\*Owned files\*\*:\s*(.+?)\s*$", line, re.IGNORECASE)
2044
+ if not owned_match:
2045
+ continue
2046
+ parts = [owned_match.group(1).strip()]
2047
+ for continuation in lines[index + 1 :]:
2048
+ stripped = continuation.strip()
2049
+ if not stripped:
2050
+ break
2051
+ if re.match(r"^(?:-\s+)?\*\*[^*]+\*\*:", stripped) or stripped.startswith("#"):
2052
+ break
2053
+ if "`" not in stripped:
2054
+ break
2055
+ parts.append(stripped)
2056
+ return " ".join(parts)
2057
+ return None
2058
+
2059
+
2060
+ def _parse_plan_lanes(plan: Path) -> tuple[PlanLane, ...]:
2061
+ try:
2062
+ text = plan.read_text(encoding="utf-8")
2063
+ except OSError:
2064
+ return ()
2065
+ lane_starts = list(LANE_SECTION_RE.finditer(text))
2066
+ lanes: list[PlanLane] = []
2067
+ for index, lane_start in enumerate(lane_starts):
2068
+ end = lane_starts[index + 1].start() if index + 1 < len(lane_starts) else len(text)
2069
+ section = text[lane_start.start() : end]
2070
+ raw = _owned_files_text(section)
2071
+ if raw is None:
2072
+ continue
2073
+ normalized = raw.strip().lower()
2074
+ if normalized.startswith("none"):
2075
+ lanes.append(
2076
+ PlanLane(
2077
+ heading=lane_start.group(0).strip(),
2078
+ owned_patterns=(),
2079
+ read_only=True,
2080
+ text=section,
2081
+ )
2082
+ )
2083
+ continue
2084
+ entries = tuple(item.strip() for item in re.findall(r"`([^`]+)`", raw) if item.strip())
2085
+ if not entries:
2086
+ continue
2087
+ lanes.append(
2088
+ PlanLane(
2089
+ heading=lane_start.group(0).strip(),
2090
+ owned_patterns=entries,
2091
+ read_only=False,
2092
+ text=section,
2093
+ )
2094
+ )
2095
+ return tuple(lanes)
2096
+
2097
+
2098
+ def _overlapping_lane_pairs(lanes: list[PlanLane]) -> tuple[tuple[str, str], ...]:
2099
+ overlaps: list[tuple[str, str]] = []
2100
+ for index, left in enumerate(lanes):
2101
+ for right in lanes[index + 1 :]:
2102
+ if _lane_patterns_overlap(left.owned_patterns, right.owned_patterns):
2103
+ overlaps.append((left.heading, right.heading))
2104
+ return tuple(overlaps)
2105
+
2106
+
2107
+ def _lane_patterns_overlap(left_patterns: tuple[str, ...], right_patterns: tuple[str, ...]) -> bool:
2108
+ for left in left_patterns:
2109
+ for right in right_patterns:
2110
+ if _patterns_overlap(left, right):
2111
+ return True
2112
+ return False
2113
+
2114
+
2115
+ def _patterns_overlap(left: str, right: str) -> bool:
2116
+ if left == right:
2117
+ return True
2118
+ left_has_glob = _has_glob(left)
2119
+ right_has_glob = _has_glob(right)
2120
+ if not left_has_glob and fnmatchcase(left, right):
2121
+ return True
2122
+ if not right_has_glob and fnmatchcase(right, left):
2123
+ return True
2124
+ if left_has_glob and right_has_glob:
2125
+ left_prefix = _pattern_prefix(left)
2126
+ right_prefix = _pattern_prefix(right)
2127
+ return bool(left_prefix and right_prefix and (left_prefix.startswith(right_prefix) or right_prefix.startswith(left_prefix)))
2128
+ return False
2129
+
2130
+
2131
+ def _owned_pattern_matches(repo_path: str, pattern: str) -> bool:
2132
+ if pattern.endswith("/"):
2133
+ return repo_path.startswith(pattern)
2134
+ if "*" not in pattern and "?" not in pattern:
2135
+ return False
2136
+ return fnmatchcase(repo_path, pattern)
2137
+
2138
+
2139
+ def expanded_dirty_ownership_matches(ownership: PlanOwnership, repo_path: str) -> bool:
2140
+ if not _valid_relative_posix_path(repo_path):
2141
+ return False
2142
+ return any(_dirty_output_matches_owned_pattern(repo_path, pattern) for pattern in ownership.owned_patterns)
2143
+
2144
+
2145
+ def _dirty_output_matches_owned_pattern(repo_path: str, pattern: str) -> bool:
2146
+ if not _valid_relative_posix_path(pattern):
2147
+ return False
2148
+ return _matches_test_or_fixture_sibling(repo_path, pattern) or _matches_vendor_module_test(repo_path, pattern)
2149
+
2150
+
2151
+ def _valid_relative_posix_path(repo_path: str) -> bool:
2152
+ path = PurePosixPath(repo_path)
2153
+ return bool(repo_path) and not path.is_absolute() and ".." not in path.parts
2154
+
2155
+
2156
+ def _matches_test_or_fixture_sibling(repo_path: str, pattern: str) -> bool:
2157
+ if pattern.endswith("/") or _has_glob(pattern):
2158
+ return False
2159
+ owned = PurePosixPath(pattern)
2160
+ target = PurePosixPath(repo_path)
2161
+ if not owned.suffix or target.parent.parent != owned.parent:
2162
+ return False
2163
+ if target.parent.name == "__tests__":
2164
+ return target.name in {f"{owned.stem}.test{owned.suffix}", f"{owned.stem}.spec{owned.suffix}"}
2165
+ if target.parent.name == "__fixtures__":
2166
+ return target.name.startswith(f"{owned.stem}.")
2167
+ return False
2168
+
2169
+
2170
+ def _matches_vendor_module_test(repo_path: str, pattern: str) -> bool:
2171
+ owned = PurePosixPath(pattern)
2172
+ target = PurePosixPath(repo_path)
2173
+ if len(owned.parts) < 3 or owned.parts[0] != "vendor" or owned.parts[2] != "src":
2174
+ return False
2175
+ return (
2176
+ len(target.parts) == 4
2177
+ and target.parts[0] == "vendor"
2178
+ and target.parts[1] == owned.parts[1]
2179
+ and target.parts[2] == "tests"
2180
+ and target.parts[3].startswith("test_")
2181
+ and target.suffix == ".py"
2182
+ )
2183
+
2184
+
2185
+ def _has_glob(pattern: str) -> bool:
2186
+ return any(token in pattern for token in ("*", "?", "["))
2187
+
2188
+
2189
+ def _pattern_prefix(pattern: str) -> str:
2190
+ match = re.match(r"^[^*?\[]+", pattern)
2191
+ return match.group(0).rstrip("/") if match else ""
2192
+
2193
+
2194
+ def handoff_matches_roadmap(repo: Path, phase: str, roadmap: Path, handoff: dict[str, str] | None) -> bool:
2195
+ if not handoff:
2196
+ return False
2197
+ artifact = handoff.get("artifact")
2198
+ if not artifact:
2199
+ return False
2200
+ artifact_path = Path(artifact).expanduser().resolve()
2201
+ try:
2202
+ artifact_path.relative_to(repo.resolve())
2203
+ except ValueError:
2204
+ return False
2205
+ if not artifact_path.exists() or PLAN_RE.search(artifact_path.name) is None:
2206
+ return False
2207
+ return plan_matches_roadmap(repo, artifact_path, roadmap, phase)
2208
+
2209
+
2210
+ def list_plan_artifacts(repo: Path) -> dict[str, Path]:
2211
+ result: dict[str, Path] = {}
2212
+ for plan in sorted((repo / "plans").glob("phase-plan-v*-*.md")):
2213
+ match = PLAN_RE.search(plan.name)
2214
+ if match:
2215
+ result[match.group(2).upper()] = plan.resolve()
2216
+ return result
2217
+
2218
+
2219
+ def latest_skill_handoff(identity: RepoIdentity, skill: str) -> dict[str, str] | None:
2220
+ path = Path.home() / ".codex" / "skills" / skill / "handoffs" / identity.repo_hash / identity.branch_slug / "latest.md"
2221
+ if not path.exists():
2222
+ return None
2223
+ text = path.read_text()
2224
+ frontmatter = parse_frontmatter(text)
2225
+ if frontmatter.get("from") != skill:
2226
+ return None
2227
+ if frontmatter.get("repo") != identity.repo_hash:
2228
+ return None
2229
+ if Path(frontmatter.get("repo_root", "")).expanduser().resolve() != identity.root:
2230
+ return None
2231
+ if frontmatter.get("branch_slug") != identity.branch_slug:
2232
+ return None
2233
+ if frontmatter.get("branch") != identity.branch:
2234
+ return None
2235
+ if not frontmatter.get("commit"):
2236
+ return None
2237
+ artifact = frontmatter.get("artifact")
2238
+ if not artifact:
2239
+ return None
2240
+ artifact_path = Path(artifact).expanduser().resolve()
2241
+ try:
2242
+ artifact_path.relative_to(identity.root)
2243
+ except ValueError:
2244
+ return None
2245
+ if not artifact_path.exists():
2246
+ return None
2247
+ automation = parse_automation_status(text)
2248
+ return {
2249
+ **frontmatter,
2250
+ **automation,
2251
+ "workflow_skill": skill,
2252
+ "originating_harness": skill.split("-", 1)[0],
2253
+ }
2254
+
2255
+
2256
+ def latest_workflow_handoff(
2257
+ identity: RepoIdentity,
2258
+ repo: Path,
2259
+ roadmap: Path,
2260
+ skills: Iterable[str],
2261
+ ) -> dict[str, str] | None:
2262
+ candidates: list[tuple[str, int, dict[str, str]]] = []
2263
+ for index, skill in enumerate(skills):
2264
+ handoff = latest_skill_handoff(identity, skill)
2265
+ if not handoff:
2266
+ continue
2267
+ artifact = handoff.get("artifact")
2268
+ if not artifact:
2269
+ continue
2270
+ artifact_path = Path(artifact).expanduser().resolve()
2271
+ if not plan_matches_roadmap(repo, artifact_path, roadmap):
2272
+ continue
2273
+ candidates.append((handoff.get("timestamp", ""), -index, handoff))
2274
+ if not candidates:
2275
+ return None
2276
+ candidates.sort()
2277
+ return candidates[-1][2]
2278
+
2279
+
2280
+ def parse_automation_status(text: str) -> dict[str, object]:
2281
+ text = _last_automation_block(text)
2282
+ values: dict[str, object] = {}
2283
+ for key in ("status", "next_skill", "next_command", "human_required", "blocker_class", "blocker_summary", "verification_status"):
2284
+ match = re.search(rf"^\s*{re.escape(key)}:\s*(.+?)\s*$", text, re.MULTILINE)
2285
+ if match:
2286
+ values[f"automation_{key}"] = match.group(1).strip().strip("'\"")
2287
+ list_match = re.search(r"^\s*required_human_inputs:\s*(?:\n(?P<items>(?:\s+-\s+.+\n?)+)|(?P<inline>\[\]))", text, re.MULTILINE)
2288
+ if list_match:
2289
+ if list_match.group("inline") == "[]":
2290
+ values["automation_required_human_inputs"] = []
2291
+ else:
2292
+ items = []
2293
+ for line in (list_match.group("items") or "").splitlines():
2294
+ item = re.sub(r"^\s+-\s+", "", line).strip().strip("'\"")
2295
+ if item:
2296
+ items.append(item)
2297
+ values["automation_required_human_inputs"] = items
2298
+ return values
2299
+
2300
+
2301
+ def _last_automation_block(text: str) -> str:
2302
+ normalized_marker = "Normalized shared automation closeout:"
2303
+ normalized_index = text.rfind(normalized_marker)
2304
+ if normalized_index >= 0:
2305
+ original_index = text.find("\n\nOriginal child output:", normalized_index)
2306
+ if original_index >= 0:
2307
+ text = text[normalized_index:original_index]
2308
+ else:
2309
+ text = text[normalized_index:]
2310
+ matches = list(re.finditer(r"(?m)^(?P<indent>[ \t]*)automation:\s*$", text))
2311
+ if not matches:
2312
+ return text
2313
+ match = matches[-1]
2314
+ base_indent = len(match.group("indent"))
2315
+ selected: list[str] = []
2316
+ for index, line in enumerate(text[match.start() :].splitlines()):
2317
+ if index == 0 or not line.strip():
2318
+ selected.append(line)
2319
+ continue
2320
+ indent = len(line) - len(line.lstrip())
2321
+ if indent > base_indent:
2322
+ selected.append(line)
2323
+ continue
2324
+ break
2325
+ return "\n".join(selected)
2326
+
2327
+
2328
+ def phase_after(phases: Iterable[str], current: str) -> str | None:
2329
+ items = list(phases)
2330
+ for index, phase in enumerate(items):
2331
+ if phase.lower() == current.lower() and index + 1 < len(items):
2332
+ return items[index + 1]
2333
+ return None
2334
+
2335
+
2336
+ def _collect_dispatch_lines(section: str, buckets: dict[str, dict[str, list[str]]], *, default_key: str) -> None:
2337
+ for match in DISPATCH_LINE_RE.finditer(section):
2338
+ action = (match.group("action") or default_key).strip().lower()
2339
+ if action != "default":
2340
+ _validate_action(action)
2341
+ label = match.group("label").strip().lower().replace(" ", "_")
2342
+ values = _parse_dispatch_values(match.group("value"))
2343
+ bucket = buckets.setdefault(action, {})
2344
+ current = bucket.setdefault(label, [])
2345
+ for value in values:
2346
+ if value not in current:
2347
+ current.append(value)
2348
+
2349
+
2350
+ def _parse_dispatch_values(raw: str) -> list[str]:
2351
+ quoted = re.findall(r"`([^`]+)`", raw)
2352
+ values = quoted or [part.strip() for part in raw.split(",")]
2353
+ cleaned = [value.strip().lower() for value in values if value.strip()]
2354
+ return cleaned
2355
+
2356
+
2357
+ def _validate_action(action: str) -> None:
2358
+ if action not in PRODUCT_LOOP_ACTIONS:
2359
+ raise ValueError(f"invalid dispatch hint action: {action}")
2360
+
2361
+
2362
+ def _relative_repo_path(repo: Path, path: Path) -> str:
2363
+ resolved_repo = repo.resolve()
2364
+ resolved_path = path.resolve()
2365
+ try:
2366
+ return str(resolved_path.relative_to(resolved_repo))
2367
+ except ValueError:
2368
+ return str(resolved_path)