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,1550 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from .classifier import classify_all
7
+ from .closeout_classifier import classify_unowned_path
8
+ from .discovery import PLAN_RE, find_plan_artifact, manifest_plan_artifact, parse_automation_status, plan_matches_roadmap
9
+ from .events import read_events
10
+ from .models import BLOCKER_CLASSES, StateSnapshot, utc_now
11
+ from .pipeline_adapter.branch_ops import REFUSE_DEFAULT_BRANCH_COMMIT_PREFIX
12
+ from .provenance import (
13
+ phase_provenance_map,
14
+ phase_sha256,
15
+ provenance_mismatch_reason,
16
+ roadmap_sha256,
17
+ snapshot_provenance,
18
+ status_provenance_matches,
19
+ )
20
+ from .state import load_state
21
+
22
+
23
+ RECONCILE_EVENT_STATUSES = {
24
+ "complete",
25
+ "blocked",
26
+ "unknown",
27
+ "executed",
28
+ "awaiting_phase_closeout",
29
+ "executing",
30
+ "planned",
31
+ "unplanned",
32
+ }
33
+
34
+
35
+ def reconcile(repo: Path, roadmap: Path) -> StateSnapshot:
36
+ phases = classify_all(repo, roadmap)
37
+ current_roadmap_sha = roadmap_sha256(roadmap)
38
+ current_phase_sha = phase_provenance_map(roadmap)
39
+ snapshot = load_state(repo)
40
+ human_required = False
41
+ blocker_class = None
42
+ blocker_summary = None
43
+ required_human_inputs: tuple[str, ...] = ()
44
+ access_attempts: tuple[dict, ...] = ()
45
+ dirty_paths: tuple[str, ...] = ()
46
+ phase_owned_dirty_paths: tuple[str, ...] = ()
47
+ previous_phase_owned_paths: tuple[str, ...] = ()
48
+ unowned_dirty_paths: tuple[str, ...] = ()
49
+ pre_existing_dirty_paths: tuple[str, ...] = ()
50
+ phase_owned_dirty = False
51
+ terminal_summary = None
52
+ closeout_terminal_status = None
53
+ closeout_summary = None
54
+ latest_closeout_summary = None
55
+ latest_terminal_summary = None
56
+ ledger_warnings: list[dict] = []
57
+ ledger_duplicates_skipped: list[dict] = []
58
+ seen_event_keys: set[tuple[object, ...]] = set()
59
+ blocker_phase: str | None = None
60
+ dirty_summary_by_phase: dict[str, dict[str, object]] = {}
61
+ closeout_summary_by_phase: dict[str, dict[str, object]] = {}
62
+ terminal_summary_by_phase: dict[str, dict[str, object]] = {}
63
+ ledger_warnings.extend(_reconcile_plan_manifest(repo, roadmap, phases))
64
+ if snapshot:
65
+ same_roadmap = Path(snapshot.roadmap).expanduser().resolve() == roadmap.resolve()
66
+ if same_roadmap:
67
+ for phase, status in snapshot.phases.items():
68
+ if phase not in phases:
69
+ continue
70
+ if status == "unplanned":
71
+ continue
72
+ if not status_provenance_matches(status, snapshot.roadmap_sha256, snapshot.phase_sha256.get(phase), current_roadmap_sha, current_phase_sha.get(phase)):
73
+ ledger_warnings.append(_ledger_warning("state", phase, status, provenance_mismatch_reason(status, snapshot.roadmap_sha256, snapshot.phase_sha256.get(phase), current_roadmap_sha, current_phase_sha.get(phase))))
74
+ continue
75
+ if status == "planned" and find_plan_artifact(repo, phase, roadmap=roadmap) is None:
76
+ continue
77
+ if status == "executing":
78
+ phases[phase] = "unknown" if _dirty(repo) else "executing"
79
+ elif status in {"planned", "complete", "blocked", "unknown", "executed", "awaiting_phase_closeout"}:
80
+ phases[phase] = status
81
+ if (
82
+ snapshot.current_phase
83
+ and snapshot.phases.get(snapshot.current_phase) in {"blocked", "awaiting_phase_closeout"}
84
+ and status_provenance_matches(
85
+ snapshot.phases.get(snapshot.current_phase, "unknown"),
86
+ snapshot.roadmap_sha256,
87
+ snapshot.phase_sha256.get(snapshot.current_phase),
88
+ current_roadmap_sha,
89
+ current_phase_sha.get(snapshot.current_phase),
90
+ )
91
+ ):
92
+ dirty_summary_by_phase[snapshot.current_phase] = {
93
+ "dirty_paths": list(snapshot.dirty_paths),
94
+ "phase_owned_dirty_paths": list(snapshot.phase_owned_dirty_paths),
95
+ "previous_phase_owned_paths": list(snapshot.previous_phase_owned_paths),
96
+ "unowned_dirty_paths": list(snapshot.unowned_dirty_paths),
97
+ "pre_existing_dirty_paths": list(snapshot.pre_existing_dirty_paths),
98
+ "phase_owned_dirty": snapshot.phase_owned_dirty,
99
+ "terminal_status": snapshot.closeout_terminal_status,
100
+ }
101
+ if snapshot.closeout_summary:
102
+ closeout_summary_by_phase[snapshot.current_phase] = dict(snapshot.closeout_summary)
103
+ if snapshot.terminal_summary:
104
+ terminal_summary_by_phase[snapshot.current_phase] = dict(snapshot.terminal_summary)
105
+ if same_roadmap and snapshot.human_required and _snapshot_blocker_trusted(snapshot, current_roadmap_sha, current_phase_sha):
106
+ human_required = snapshot.human_required
107
+ blocker_class = snapshot.blocker_class
108
+ blocker_summary = snapshot.blocker_summary
109
+ required_human_inputs = snapshot.required_human_inputs
110
+ access_attempts = snapshot.access_attempts
111
+ pending_event_warnings: dict[str, list[dict]] = {}
112
+ latest_untrusted_terminal_event: dict[str, dict] = {}
113
+ legacy_executor_closeout_action_warned = False
114
+ for raw_event in read_events(repo):
115
+ event = _normalize_automation_event(repo, roadmap, raw_event, current_roadmap_sha, current_phase_sha)
116
+ event, normalized_legacy_executor_closeout = _normalize_legacy_executor_closeout_action(event)
117
+ if Path(str(event.get("roadmap", ""))).expanduser().resolve() != roadmap.resolve():
118
+ continue
119
+ if normalized_legacy_executor_closeout and not legacy_executor_closeout_action_warned:
120
+ ledger_warnings.append(
121
+ _ledger_warning(
122
+ "event",
123
+ str(event.get("phase", "")).upper(),
124
+ str(event.get("status") or ""),
125
+ "legacy_executor_closeout_action_normalized",
126
+ raw_event=raw_event,
127
+ )
128
+ )
129
+ legacy_executor_closeout_action_warned = True
130
+ dedup_key = _event_dedup_key(event)
131
+ if event.get("action") == "phase_reopen":
132
+ seen_event_keys = {key for key in seen_event_keys if key[1] != dedup_key[1]}
133
+ dedup_identity = _event_dedup_identity(event, dedup_key)
134
+ if dedup_identity in seen_event_keys:
135
+ ledger_duplicates_skipped.append(_ledger_duplicate_record(event, dedup_key))
136
+ continue
137
+ seen_event_keys.add(dedup_identity)
138
+ phase = str(event.get("phase", "")).upper()
139
+ status = event.get("status")
140
+ if phase not in phases:
141
+ ledger_warnings.append(_ledger_warning("event", phase, str(status or ""), "phase_missing", raw_event=event))
142
+ continue
143
+ if status not in RECONCILE_EVENT_STATUSES:
144
+ ledger_warnings.append(_ledger_warning("event", phase, str(status or ""), "not_in_allowed_status_set", raw_event=event))
145
+ continue
146
+ if int(event.get("schema_version") or 1) < 2:
147
+ ledger_warnings.append(_ledger_warning("event", phase, str(status), "legacy_pre_schema_v2", raw_event=event))
148
+ continue
149
+ if event.get("action") == "state_transition" and _state_transition_metadata(event) is None:
150
+ ledger_warnings.append(_ledger_warning("event", phase, str(status), "malformed_state_transition", raw_event=event))
151
+ continue
152
+ if event.get("action") == "manual_recovery" and _manual_recovery_metadata(event) is None:
153
+ ledger_warnings.append(_ledger_warning("event", phase, str(status), "malformed_manual_recovery", raw_event=event))
154
+ continue
155
+ if phase in phases and status in RECONCILE_EVENT_STATUSES:
156
+ if not _event_status_provenance_matches(event, str(status), current_roadmap_sha, current_phase_sha.get(phase)):
157
+ pending_event_warnings.setdefault(phase, []).append(
158
+ _ledger_warning(
159
+ "event",
160
+ phase,
161
+ str(status),
162
+ provenance_mismatch_reason(
163
+ str(status),
164
+ event.get("roadmap_sha256"),
165
+ event.get("phase_sha256"),
166
+ current_roadmap_sha,
167
+ current_phase_sha.get(phase),
168
+ ),
169
+ raw_event=event,
170
+ )
171
+ )
172
+ if status in {"blocked", "unknown", "executed", "awaiting_phase_closeout"}:
173
+ latest_untrusted_terminal_event[phase] = event
174
+ continue
175
+ if status == "planned" and event.get("action") != "phase_reopen" and find_plan_artifact(repo, phase, roadmap=roadmap) is None:
176
+ ledger_warnings.append(_ledger_warning("event", phase, str(status), "planned_without_plan_artifact", raw_event=event))
177
+ continue
178
+ latest_untrusted_terminal_event.pop(phase, None)
179
+ pending_event_warnings.pop(phase, None)
180
+ if phases.get(phase) == "blocked" and status in {"planned", "unplanned"} and not _planned_event_clears_blocker(event):
181
+ ledger_warnings.append(_ledger_warning("event", phase, str(status), "blocker_supersession", raw_event=event))
182
+ continue
183
+ closeout_summary = _event_closeout_summary(event)
184
+ if _closeout_completed(status, closeout_summary):
185
+ status = "complete"
186
+ phases[phase] = status
187
+ dirty_summary = _event_dirty_summary(event)
188
+ if dirty_summary:
189
+ dirty_summary_by_phase[phase] = dirty_summary
190
+ elif status in {"planned", "unplanned", "complete", "executed", "unknown"}:
191
+ dirty_summary_by_phase.pop(phase, None)
192
+ if closeout_summary:
193
+ closeout_summary_by_phase[phase] = closeout_summary
194
+ latest_closeout_summary = {"phase": phase, **closeout_summary}
195
+ elif status in {"planned", "unplanned", "unknown"}:
196
+ closeout_summary_by_phase.pop(phase, None)
197
+ terminal = _event_terminal_summary(event)
198
+ if terminal and _event_terminal_summary_is_event_only(event, terminal):
199
+ ledger_warnings.append(
200
+ _ledger_warning(
201
+ "event",
202
+ phase,
203
+ "dry_run",
204
+ "event_only_status",
205
+ raw_event=event,
206
+ value="dry_run",
207
+ )
208
+ )
209
+ elif terminal:
210
+ terminal_summary_by_phase[phase] = terminal
211
+ latest_terminal_summary = {"phase": phase, **terminal}
212
+ elif _event_clears_terminal_summary(event, status):
213
+ terminal_summary_by_phase.pop(phase, None)
214
+ if latest_terminal_summary and latest_terminal_summary.get("phase") == phase:
215
+ latest_terminal_summary = None
216
+ elif status in {"planned", "unplanned", "unknown"}:
217
+ terminal_summary_by_phase.pop(phase, None)
218
+ blocker = _event_blocker(event)
219
+ preserve_human_blocker = (
220
+ human_required
221
+ and blocker_phase == phase
222
+ and (
223
+ status == "executed"
224
+ or (status == "blocked" and _event_has_verified_dirty_closeout_recovery(event))
225
+ )
226
+ )
227
+ if not preserve_human_blocker and not (status == "blocked" and blocker):
228
+ human_required = False
229
+ blocker_class = None
230
+ blocker_summary = None
231
+ required_human_inputs = ()
232
+ access_attempts = ()
233
+ blocker_phase = None
234
+ blocker = _event_blocker(event)
235
+ if (
236
+ blocker
237
+ and phase in phases
238
+ and (phases.get(phase) == "blocked" or (status == "executed" and _truthy(blocker.get("human_required"))))
239
+ and _event_status_provenance_matches(event, str(status), current_roadmap_sha, current_phase_sha.get(phase))
240
+ ):
241
+ human_required = _truthy(blocker.get("human_required"))
242
+ blocker_class = blocker.get("blocker_class")
243
+ blocker_summary = blocker.get("blocker_summary")
244
+ required_human_inputs = tuple(blocker.get("required_human_inputs", ()))
245
+ access_attempts = tuple(blocker.get("access_attempts", ()))
246
+ blocker_phase = phase
247
+ for warnings in pending_event_warnings.values():
248
+ ledger_warnings.extend(warnings)
249
+ for phase, event in latest_untrusted_terminal_event.items():
250
+ if phases.get(phase) == "complete":
251
+ phases[phase] = "unknown"
252
+ ledger_warnings.append(
253
+ _ledger_warning(
254
+ "event",
255
+ phase,
256
+ str(event.get("status")),
257
+ "newer_untrusted_terminal_event",
258
+ )
259
+ )
260
+ current = _current_phase(phases)
261
+ if current:
262
+ latest_dirty = dirty_summary_by_phase.get(current, {})
263
+ dirty_paths = tuple(latest_dirty.get("dirty_paths", ()))
264
+ phase_owned_dirty_paths = tuple(latest_dirty.get("phase_owned_dirty_paths", ()))
265
+ previous_phase_owned_paths = tuple(latest_dirty.get("previous_phase_owned_paths", ()))
266
+ unowned_dirty_paths = tuple(latest_dirty.get("unowned_dirty_paths", ()))
267
+ pre_existing_dirty_paths = tuple(latest_dirty.get("pre_existing_dirty_paths", ()))
268
+ phase_owned_dirty = bool(latest_dirty.get("phase_owned_dirty", False))
269
+ phase_terminal_summary = terminal_summary_by_phase.get(current)
270
+ terminal_summary = {"phase": current, **phase_terminal_summary} if phase_terminal_summary else None
271
+ closeout_terminal_status = _optional_text(latest_dirty.get("terminal_status"))
272
+ if closeout_terminal_status is None and terminal_summary:
273
+ closeout_terminal_status = _optional_text(terminal_summary.get("terminal_status"))
274
+ closeout_summary = closeout_summary_by_phase.get(current) or latest_closeout_summary
275
+ if not closeout_summary and phases.get(current) == "awaiting_phase_closeout":
276
+ closeout_summary = _default_closeout_summary(closeout_terminal_status)
277
+ else:
278
+ terminal_summary = None if phases and all(status == "complete" for status in phases.values()) else latest_terminal_summary
279
+ closeout_summary = latest_closeout_summary
280
+ if current and phases.get(current) == "blocked" and not human_required:
281
+ plan_blocker = _plan_blocker(repo, roadmap, current)
282
+ if plan_blocker:
283
+ human_required = _truthy(plan_blocker.get("human_required"))
284
+ blocker_class = plan_blocker.get("blocker_class")
285
+ blocker_summary = plan_blocker.get("blocker_summary")
286
+ required_human_inputs = tuple(plan_blocker.get("required_human_inputs", ()))
287
+ if (
288
+ current
289
+ and phases.get(current) == "blocked"
290
+ and not human_required
291
+ and _clean_planned_artifact_supersedes_blocker(repo, roadmap, current, pending_event_warnings.get(current, ()))
292
+ ):
293
+ phases[current] = "planned"
294
+ blocker_class = None
295
+ blocker_summary = None
296
+ required_human_inputs = ()
297
+ access_attempts = ()
298
+ dirty_paths = ()
299
+ phase_owned_dirty_paths = ()
300
+ previous_phase_owned_paths = ()
301
+ unowned_dirty_paths = ()
302
+ pre_existing_dirty_paths = ()
303
+ phase_owned_dirty = False
304
+ terminal_summary = None
305
+ closeout_terminal_status = None
306
+ ledger_warnings.append(_ledger_warning("plan", current, "planned", "clean_plan_superseded_nonhuman_blocker"))
307
+ if current and phases.get(current) == "blocked" and not human_required:
308
+ recovery_closeout = _clean_verified_dirty_closeout_recovery_supersedes_blocker(
309
+ repo,
310
+ roadmap,
311
+ current,
312
+ current_roadmap_sha,
313
+ current_phase_sha,
314
+ )
315
+ if recovery_closeout is not None:
316
+ repaired_phase = current
317
+ phases[repaired_phase] = "complete"
318
+ current = _current_phase(phases)
319
+ blocker_class = None
320
+ blocker_summary = None
321
+ required_human_inputs = ()
322
+ access_attempts = ()
323
+ dirty_paths = ()
324
+ phase_owned_dirty_paths = ()
325
+ previous_phase_owned_paths = ()
326
+ unowned_dirty_paths = ()
327
+ pre_existing_dirty_paths = ()
328
+ phase_owned_dirty = False
329
+ terminal_summary = None
330
+ closeout_terminal_status = None
331
+ closeout = recovery_closeout.get("closeout_summary")
332
+ if isinstance(closeout, dict) and closeout:
333
+ closeout_summary = {"phase": repaired_phase, **closeout}
334
+ ledger_warnings.append(
335
+ _ledger_warning("event", repaired_phase, "complete", "clean_verified_dirty_closeout_recovery_superseded_nonhuman_blocker")
336
+ )
337
+ return StateSnapshot(
338
+ timestamp=utc_now(),
339
+ repo=str(repo),
340
+ roadmap=str(roadmap),
341
+ phases=phases,
342
+ current_phase=current,
343
+ last_action="reconcile",
344
+ human_required=human_required,
345
+ blocker_class=blocker_class,
346
+ blocker_summary=blocker_summary,
347
+ required_human_inputs=required_human_inputs,
348
+ access_attempts=access_attempts,
349
+ dirty_paths=dirty_paths,
350
+ phase_owned_dirty_paths=phase_owned_dirty_paths,
351
+ previous_phase_owned_paths=previous_phase_owned_paths,
352
+ unowned_dirty_paths=unowned_dirty_paths,
353
+ pre_existing_dirty_paths=pre_existing_dirty_paths,
354
+ phase_owned_dirty=phase_owned_dirty,
355
+ terminal_summary=terminal_summary,
356
+ closeout_terminal_status=closeout_terminal_status,
357
+ closeout_summary=closeout_summary,
358
+ ledger_warnings=tuple(ledger_warnings),
359
+ ledger_duplicates_skipped=tuple(ledger_duplicates_skipped),
360
+ **snapshot_provenance(roadmap),
361
+ )
362
+ repair_closeout = _clean_manual_repair_complete_supersedes_blocker(
363
+ repo,
364
+ roadmap,
365
+ current,
366
+ current_roadmap_sha,
367
+ current_phase_sha,
368
+ )
369
+ if repair_closeout is not None:
370
+ repaired_phase = current
371
+ phases[repaired_phase] = "complete"
372
+ current = _current_phase(phases)
373
+ blocker_class = None
374
+ blocker_summary = None
375
+ required_human_inputs = ()
376
+ access_attempts = ()
377
+ dirty_paths = ()
378
+ phase_owned_dirty_paths = ()
379
+ previous_phase_owned_paths = ()
380
+ unowned_dirty_paths = ()
381
+ pre_existing_dirty_paths = ()
382
+ phase_owned_dirty = False
383
+ terminal_summary = None
384
+ closeout_terminal_status = None
385
+ closeout = repair_closeout.get("closeout_summary")
386
+ if isinstance(closeout, dict) and closeout:
387
+ closeout_summary = {"phase": repaired_phase, **closeout}
388
+ ledger_warnings.append(
389
+ _ledger_warning("event", repaired_phase, "complete", "clean_manual_repair_superseded_nonhuman_blocker")
390
+ )
391
+ # Self-clearing stale blocker pass (regenesis v37 fix).
392
+ #
393
+ # state.json (or the latest event) can hold a cached blocker whose
394
+ # precondition was already resolved out-of-band (operator manually
395
+ # created the pipeline branch, resolved the merge, etc.). Without
396
+ # this pass reconcile would echo the stale blocker forever and the
397
+ # runner would refuse to advance.
398
+ #
399
+ # Narrow scope, intentionally:
400
+ # - human_required=True blockers NEVER self-clear -- the operator
401
+ # tagged it and the runner does not second-guess that.
402
+ # - dirty_worktree_conflict is intentionally NOT covered here. The
403
+ # runner already emits "repair_precondition_cleared" state
404
+ # transitions for that class via a richer event-level mechanism
405
+ # (see test_phase_loop_repair_skipped_when_blocker_cleared);
406
+ # clearing it in reconcile would preempt that flow and break the
407
+ # audit trail.
408
+ # - Human-action classes (admin_approval, missing_secret,
409
+ # product_decision_missing, ...) default-deny -- they cannot be
410
+ # detected from repo state.
411
+ if (
412
+ current
413
+ and phases.get(current) == "blocked"
414
+ and blocker_class
415
+ and not human_required
416
+ and _blocker_precondition_cleared(
417
+ repo, str(blocker_class), blocker_summary=str(blocker_summary or "")
418
+ )
419
+ ):
420
+ cleared_class = str(blocker_class)
421
+ phases[current] = "planned" if find_plan_artifact(repo, current, roadmap=roadmap) else "unplanned"
422
+ human_required = False
423
+ blocker_class = None
424
+ blocker_summary = None
425
+ required_human_inputs = ()
426
+ access_attempts = ()
427
+ dirty_paths = ()
428
+ phase_owned_dirty_paths = ()
429
+ previous_phase_owned_paths = ()
430
+ unowned_dirty_paths = ()
431
+ pre_existing_dirty_paths = ()
432
+ phase_owned_dirty = False
433
+ terminal_summary = None
434
+ closeout_terminal_status = None
435
+ ledger_warnings.append(
436
+ _ledger_warning(
437
+ "blocker",
438
+ current,
439
+ phases[current],
440
+ "blocker_precondition_self_cleared",
441
+ value=cleared_class,
442
+ )
443
+ )
444
+ current = _current_phase(phases)
445
+ return StateSnapshot(
446
+ timestamp=utc_now(),
447
+ repo=str(repo),
448
+ roadmap=str(roadmap),
449
+ phases=phases,
450
+ current_phase=current,
451
+ last_action="reconcile",
452
+ human_required=human_required,
453
+ blocker_class=blocker_class,
454
+ blocker_summary=blocker_summary,
455
+ required_human_inputs=required_human_inputs,
456
+ access_attempts=access_attempts,
457
+ dirty_paths=dirty_paths,
458
+ phase_owned_dirty_paths=phase_owned_dirty_paths,
459
+ previous_phase_owned_paths=previous_phase_owned_paths,
460
+ unowned_dirty_paths=unowned_dirty_paths,
461
+ pre_existing_dirty_paths=pre_existing_dirty_paths,
462
+ phase_owned_dirty=phase_owned_dirty,
463
+ terminal_summary=terminal_summary,
464
+ closeout_terminal_status=closeout_terminal_status,
465
+ closeout_summary=closeout_summary,
466
+ ledger_warnings=tuple(ledger_warnings),
467
+ ledger_duplicates_skipped=tuple(ledger_duplicates_skipped),
468
+ **snapshot_provenance(roadmap),
469
+ )
470
+
471
+
472
+ def _manifest_file_phase_key(entry) -> tuple[str, str]:
473
+ """#46: dedup identity for a manifest plan entry — normalized file path +
474
+ upper-cased phase alias. Matches entries pointing at the same phase plan
475
+ file/phase regardless of their (planner vs synthetic-import) slug, so
476
+ reconcile does not re-add a duplicate imported row."""
477
+ file_norm = str(getattr(entry, "file", "") or "").replace("\\", "/").strip().lower()
478
+ phase_norm = str(getattr(entry, "phase_alias", "") or "").strip().upper()
479
+ return (file_norm, phase_norm)
480
+
481
+
482
+ def _reconcile_plan_manifest(repo: Path, roadmap: Path, phases: dict[str, str]) -> list[dict]:
483
+ from .discovery import _phase_manifest_disabled
484
+
485
+ if _phase_manifest_disabled():
486
+ return []
487
+ if not (repo / "plans" / "manifest.json").exists():
488
+ return []
489
+ warnings: list[dict] = []
490
+ try:
491
+ from .plan_manifest import append_entry, import_existing_phase_plans, read_manifest, update_lifecycle
492
+ except Exception as exc:
493
+ return [_ledger_warning("manifest", "", "unknown", "manifest_import_unavailable", value=str(exc))]
494
+ try:
495
+ manifest = read_manifest(repo)
496
+ except Exception as exc:
497
+ return [_ledger_warning("manifest", "", "unknown", "manifest_read_failed", value=str(exc))]
498
+ known_slugs = {entry.slug for entry in manifest.plans}
499
+ # #46: also key by (normalized file, phase_alias) so an auto-import whose
500
+ # SYNTHETIC slug differs from a committed planner entry (e.g. import slug
501
+ # "v1-CORE" vs planner slug "phase-plan-v1-CORE") is not appended as a
502
+ # duplicate when the same phase-plan file + phase alias is already present.
503
+ # Dedup-by-slug-alone re-added a duplicate `imported` row (and, with it, a
504
+ # punctuation-variant IF gate that the planner entry never contained).
505
+ known_file_phase = {_manifest_file_phase_key(entry) for entry in manifest.plans}
506
+ for entry in manifest.plans:
507
+ if entry.type != "phase" or entry.status in {"orphaned", "completed", "failed"}:
508
+ continue
509
+ if not (repo / entry.file).exists():
510
+ try:
511
+ update_lifecycle(
512
+ repo,
513
+ entry.slug,
514
+ "orphaned",
515
+ "phase-loop-reconcile",
516
+ {"reason": "manifest_plan_file_missing", "file": entry.file},
517
+ )
518
+ except Exception as exc:
519
+ warnings.append(_ledger_warning("manifest", str(entry.phase_alias or ""), entry.status, "manifest_orphan_update_failed", value=str(exc)))
520
+ warnings.append(_ledger_warning("manifest", str(entry.phase_alias or ""), "orphaned", "manifest_plan_file_missing", value=entry.file))
521
+ try:
522
+ imported = import_existing_phase_plans(repo)
523
+ except Exception as exc:
524
+ warnings.append(_ledger_warning("manifest", "", "unknown", "manifest_auto_import_scan_failed", value=str(exc)))
525
+ return warnings
526
+ for entry in imported.plans:
527
+ if entry.slug in known_slugs:
528
+ continue
529
+ if _manifest_file_phase_key(entry) in known_file_phase:
530
+ # #46: same phase-plan file + phase alias already represented by a
531
+ # committed entry — do not append a second `imported` row.
532
+ continue
533
+ if entry.phase_alias and entry.phase_alias.upper() not in phases:
534
+ continue
535
+ try:
536
+ append_entry(repo, entry)
537
+ known_slugs.add(entry.slug)
538
+ known_file_phase.add(_manifest_file_phase_key(entry))
539
+ except Exception as exc:
540
+ warnings.append(_ledger_warning("manifest", str(entry.phase_alias or ""), "imported", "manifest_auto_import_failed", value=str(exc)))
541
+ else:
542
+ pass
543
+ for phase in phases:
544
+ _manifest_plan, conflict = manifest_plan_artifact(repo, phase, roadmap=roadmap)
545
+ if conflict:
546
+ warnings.append(
547
+ _ledger_warning(
548
+ "manifest",
549
+ str(conflict.get("phase") or phase),
550
+ str(conflict.get("status") or "unknown"),
551
+ str(conflict.get("reason") or "manifest_warning"),
552
+ value=conflict,
553
+ )
554
+ )
555
+ return warnings
556
+
557
+
558
+ def _blocker_precondition_cleared(repo: Path, blocker_class: str, *, blocker_summary: str = "") -> bool:
559
+ """True when the current repo/git state no longer satisfies the cached
560
+ blocker's precondition. The runner should drop the cached blocker in
561
+ that case.
562
+
563
+ NOTE: ``dirty_worktree_conflict`` is intentionally NOT covered here.
564
+ The runner emits a richer ``repair_precondition_cleared`` state
565
+ transition for that class on its own (see
566
+ test_phase_loop_repair_skipped_when_blocker_cleared). Clearing it in
567
+ reconcile would preempt that flow and drop the audit trail.
568
+
569
+ Default-deny: human-action classes (admin_approval, missing_secret,
570
+ product_decision_missing, account_or_billing_setup, ...) and
571
+ branch_sync_conflict variants we cannot reliably detect (e.g.
572
+ ``base_ref_unavailable`` from release_guard) stay cached.
573
+ """
574
+ if blocker_class == "branch_sync_conflict":
575
+ # Narrow to the BranchGov default-branch refusal variant -- the
576
+ # v37 case. Identified by the blocker_summary text emitted in
577
+ # pipeline_adapter.branch_ops.refuse_default_branch_commit.
578
+ # release_guard's "base_ref_unavailable" variant and other
579
+ # branch_sync_conflict producers stay cached.
580
+ summary = blocker_summary or ""
581
+ if REFUSE_DEFAULT_BRANCH_COMMIT_PREFIX not in summary:
582
+ return False
583
+ try:
584
+ current_branch = subprocess.check_output(
585
+ ["git", "-C", str(repo), "branch", "--show-current"],
586
+ text=True,
587
+ stderr=subprocess.DEVNULL,
588
+ ).strip()
589
+ except Exception:
590
+ return False
591
+ if not current_branch:
592
+ return False
593
+ remote_head = ""
594
+ try:
595
+ remote_head = subprocess.check_output(
596
+ ["git", "-C", str(repo), "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
597
+ text=True,
598
+ stderr=subprocess.DEVNULL,
599
+ ).strip()
600
+ except Exception:
601
+ remote_head = ""
602
+ default_branch = remote_head.removeprefix("origin/") if remote_head.startswith("origin/") else ""
603
+ if not default_branch:
604
+ try:
605
+ ls_remote = subprocess.check_output(
606
+ ["git", "-C", str(repo), "ls-remote", "--symref", "origin", "HEAD"],
607
+ text=True,
608
+ stderr=subprocess.DEVNULL,
609
+ )
610
+ except Exception:
611
+ ls_remote = ""
612
+ for line in ls_remote.splitlines():
613
+ if line.startswith("ref: refs/heads/"):
614
+ ref = line.split("\t", 1)[0].removeprefix("ref: refs/heads/").strip()
615
+ if ref:
616
+ default_branch = ref
617
+ break
618
+ if not default_branch:
619
+ # When the default branch cannot be discovered, default-deny
620
+ # rather than risk clearing a stale blocker that may still
621
+ # apply.
622
+ return False
623
+ return current_branch != default_branch
624
+ if blocker_class == "merge_conflict":
625
+ # Cached blocker was "merge in progress with conflicts". If
626
+ # `git status --porcelain` no longer reports unmerged paths
627
+ # (UU/AA/DD/UD/DU/AU/UA), the precondition is cleared.
628
+ try:
629
+ out = subprocess.check_output(
630
+ ["git", "-C", str(repo), "status", "--porcelain"],
631
+ text=True,
632
+ stderr=subprocess.DEVNULL,
633
+ )
634
+ except Exception:
635
+ return False
636
+ unmerged_prefixes = ("UU", "AA", "DD", "UD", "DU", "AU", "UA")
637
+ return not any(line.startswith(unmerged_prefixes) for line in out.splitlines())
638
+ # Default-deny: dirty_worktree_conflict (handled by runner mechanism),
639
+ # human-action blocker classes (admin_approval, missing_secret,
640
+ # product_decision_missing, account_or_billing_setup,
641
+ # operator_override_missing_reason, etc.) and any unknown class do
642
+ # NOT self-clear from repo state alone.
643
+ return False
644
+
645
+
646
+ def _event_blocker(event: dict) -> dict:
647
+ blocker = event.get("blocker")
648
+ if isinstance(blocker, dict):
649
+ return _normalize_blocker(blocker)
650
+ if event.get("human_required") is not None or event.get("blocker_class") or event.get("blocker_summary"):
651
+ return _normalize_blocker({
652
+ "human_required": _truthy(event.get("human_required")),
653
+ "blocker_class": event.get("blocker_class"),
654
+ "blocker_summary": event.get("blocker_summary"),
655
+ "required_human_inputs": event.get("required_human_inputs", ()),
656
+ "access_attempts": event.get("access_attempts", ()),
657
+ })
658
+ return {}
659
+
660
+
661
+ def _plan_blocker(repo: Path, roadmap: Path, phase: str) -> dict:
662
+ plan = find_plan_artifact(repo, phase, roadmap=roadmap)
663
+ if not plan:
664
+ return {}
665
+ from .plan_ir import parse_phase_plan_ir
666
+
667
+ lane_ir = parse_phase_plan_ir(plan)
668
+ if lane_ir.lanes and lane_ir.diagnostics:
669
+ override = _lane_ir_override(repo, roadmap, phase, plan)
670
+ remaining = tuple(diagnostic for diagnostic in lane_ir.diagnostics if diagnostic.kind not in override)
671
+ if not remaining:
672
+ return {}
673
+ # #52: name the concrete failing diagnostic(s) and the plan file so the
674
+ # repo-local repair can fix the plan instead of guessing. Previously the
675
+ # summary was a generic "failed closed" string with no lane/kind/location,
676
+ # forcing the operator to guess which lane tripped which contract rule.
677
+ try:
678
+ plan_rel: object = plan.relative_to(repo)
679
+ except ValueError:
680
+ plan_rel = plan
681
+ detail = "; ".join(
682
+ f"{d.kind}@{d.lane_id or 'plan'}"
683
+ + (f" ({d.message})" if getattr(d, "message", None) else "")
684
+ for d in remaining
685
+ )
686
+ return {
687
+ "human_required": False,
688
+ "blocker_class": "contract_bug",
689
+ "blocker_summary": (
690
+ f"Lane IR diagnostics failed closed for phase '{phase}' ({plan_rel}): "
691
+ f"{detail}. Fix the named lane(s) in the phase plan, then re-run."
692
+ ),
693
+ "required_human_inputs": (),
694
+ "lane_ir_diagnostics": tuple(diagnostic.to_json() for diagnostic in remaining),
695
+ }
696
+ automation = parse_automation_status(plan.read_text(encoding="utf-8"))
697
+ if automation.get("automation_status") != "blocked":
698
+ return {}
699
+ human_required = automation.get("automation_human_required")
700
+ blocker_class = automation.get("automation_blocker_class")
701
+ if blocker_class in {None, "", "none"}:
702
+ blocker_class = None
703
+ return {
704
+ "human_required": _truthy(human_required),
705
+ "blocker_class": blocker_class,
706
+ "blocker_summary": automation.get("automation_blocker_summary"),
707
+ "required_human_inputs": automation.get("automation_required_human_inputs", ()),
708
+ }
709
+
710
+
711
+ def _lane_ir_override(repo: Path, roadmap: Path, phase: str, plan: Path) -> tuple[str, ...]:
712
+ roadmap_path = roadmap.resolve()
713
+ plan_path = plan.resolve()
714
+ current_roadmap_sha = roadmap_sha256(roadmap)
715
+ current_phase_sha = phase_sha256(roadmap, phase)
716
+ override_kinds: tuple[str, ...] = ()
717
+ for event in read_events(repo):
718
+ if event.get("action") != "lane_ir_override":
719
+ continue
720
+ if event.get("roadmap_sha256") != current_roadmap_sha or event.get("phase_sha256") != current_phase_sha:
721
+ continue
722
+ if str(event.get("phase", "")).upper() != phase.upper():
723
+ continue
724
+ try:
725
+ event_roadmap = Path(str(event.get("roadmap", ""))).expanduser().resolve()
726
+ except OSError:
727
+ continue
728
+ if event_roadmap != roadmap_path:
729
+ continue
730
+ metadata = event.get("metadata")
731
+ payload = metadata.get("runner.lane_ir_override_invoked") if isinstance(metadata, dict) else None
732
+ if not isinstance(payload, dict):
733
+ continue
734
+ if not str(payload.get("operator_reason") or "").strip():
735
+ continue
736
+ event_plan = payload.get("plan_path")
737
+ if event_plan:
738
+ try:
739
+ if Path(str(event_plan)).expanduser().resolve() != plan_path:
740
+ continue
741
+ except OSError:
742
+ continue
743
+ kinds = payload.get("diagnostic_kinds_overridden")
744
+ if not isinstance(kinds, list):
745
+ continue
746
+ override_kinds = tuple(str(kind) for kind in kinds if kind)
747
+ return override_kinds
748
+
749
+
750
+ def _closeout_allow_unowned_attested(repo: Path, roadmap: Path, phase: str) -> bool:
751
+ """BREAKGLASS SL-2: True when a non-stale ``closeout_allow_unowned`` operator
752
+ attestation is recorded for this exact phase content. Scoping mirrors
753
+ ``_lane_ir_override``: roadmap_sha256 + phase_sha256 (content-bound freshness) +
754
+ phase + roadmap path + non-empty operator_reason. A stale attestation (content
755
+ drifted since it was written) no longer matches and does NOT authorize a later
756
+ closeout.
757
+ """
758
+ roadmap_path = roadmap.resolve()
759
+ current_roadmap_sha = roadmap_sha256(roadmap)
760
+ current_phase_sha = phase_sha256(roadmap, phase)
761
+ plan = find_plan_artifact(repo, phase, roadmap=roadmap)
762
+ plan_path = plan.resolve() if plan else None
763
+ for event in read_events(repo):
764
+ if event.get("action") != "closeout_allow_unowned":
765
+ continue
766
+ if event.get("roadmap_sha256") != current_roadmap_sha or event.get("phase_sha256") != current_phase_sha:
767
+ continue
768
+ if str(event.get("phase", "")).upper() != phase.upper():
769
+ continue
770
+ try:
771
+ event_roadmap = Path(str(event.get("roadmap", ""))).expanduser().resolve()
772
+ except OSError:
773
+ continue
774
+ if event_roadmap != roadmap_path:
775
+ continue
776
+ metadata = event.get("metadata")
777
+ payload = metadata.get("runner.closeout_allow_unowned_invoked") if isinstance(metadata, dict) else None
778
+ if not isinstance(payload, dict):
779
+ continue
780
+ if not str(payload.get("operator_reason") or "").strip():
781
+ continue
782
+ event_plan = payload.get("plan_path")
783
+ if event_plan and plan_path is not None:
784
+ try:
785
+ if Path(str(event_plan)).expanduser().resolve() != plan_path:
786
+ continue
787
+ except OSError:
788
+ continue
789
+ return True
790
+ return False
791
+
792
+
793
+ def _clean_planned_artifact_supersedes_blocker(repo: Path, roadmap: Path, phase: str, warnings: list[dict] | tuple[dict, ...]) -> bool:
794
+ if not any(warning.get("source") == "event" and warning.get("status") == "planned" for warning in warnings):
795
+ return False
796
+ if _dirty_except_plan_manifest(repo):
797
+ return False
798
+ plan = find_plan_artifact(repo, phase, roadmap=roadmap)
799
+ if not plan:
800
+ return False
801
+ try:
802
+ automation = parse_automation_status(plan.read_text(encoding="utf-8"))
803
+ except OSError:
804
+ return False
805
+ return automation.get("automation_status") == "planned"
806
+
807
+
808
+ def _dirty_except_plan_manifest(repo: Path) -> bool:
809
+ try:
810
+ lines = subprocess.check_output(["git", "-C", str(repo), "status", "--short"], text=True).splitlines()
811
+ except Exception:
812
+ return False
813
+ return any(line[3:] != "plans/manifest.json" for line in lines if len(line) > 3)
814
+
815
+
816
+ def _clean_manual_repair_complete_supersedes_blocker(
817
+ repo: Path,
818
+ roadmap: Path,
819
+ phase: str,
820
+ current_roadmap_sha: str,
821
+ current_phase_sha: dict[str, str],
822
+ ) -> dict[str, object] | None:
823
+ if _dirty(repo):
824
+ return None
825
+ complete_event: dict | None = None
826
+ stale_blocked_after_complete = False
827
+ for raw_event in read_events(repo):
828
+ event = _normalize_automation_event(repo, roadmap, raw_event, current_roadmap_sha, current_phase_sha)
829
+ if Path(str(event.get("roadmap", ""))).expanduser().resolve() != roadmap.resolve():
830
+ continue
831
+ event_phase = str(event.get("phase", "")).upper()
832
+ status = event.get("status")
833
+ if event_phase != phase:
834
+ continue
835
+ if not status_provenance_matches(
836
+ str(status),
837
+ event.get("roadmap_sha256"),
838
+ event.get("phase_sha256"),
839
+ current_roadmap_sha,
840
+ current_phase_sha.get(phase),
841
+ ):
842
+ continue
843
+ if status == "complete" and event.get("action") == "manual_repair" and _planned_event_clears_blocker(event):
844
+ complete_event = event
845
+ stale_blocked_after_complete = False
846
+ continue
847
+ if complete_event and status == "blocked" and _supersedable_nonhuman_repair_blocker(_event_blocker(event)):
848
+ stale_blocked_after_complete = True
849
+ if not complete_event or not stale_blocked_after_complete:
850
+ return None
851
+ return {
852
+ "terminal_summary": _event_terminal_summary(complete_event),
853
+ "closeout_summary": _event_closeout_summary(complete_event),
854
+ }
855
+
856
+
857
+ def _clean_verified_dirty_closeout_recovery_supersedes_blocker(
858
+ repo: Path,
859
+ roadmap: Path,
860
+ phase: str,
861
+ current_roadmap_sha: str,
862
+ current_phase_sha: dict[str, str],
863
+ ) -> dict[str, object] | None:
864
+ if _dirty(repo):
865
+ return None
866
+ latest_event: dict | None = None
867
+ for raw_event in read_events(repo):
868
+ event = _normalize_automation_event(repo, roadmap, raw_event, current_roadmap_sha, current_phase_sha)
869
+ if Path(str(event.get("roadmap", ""))).expanduser().resolve() != roadmap.resolve():
870
+ continue
871
+ # BREAKGLASS SL-2: operator attestation events are not closeout/terminal events;
872
+ # they must not shadow the verified-dirty-closeout recovery event as latest_event.
873
+ if event.get("action") == "closeout_allow_unowned":
874
+ continue
875
+ event_phase = str(event.get("phase", "")).upper()
876
+ status = event.get("status")
877
+ if event_phase != phase:
878
+ continue
879
+ if not status_provenance_matches(
880
+ str(status),
881
+ event.get("roadmap_sha256"),
882
+ event.get("phase_sha256"),
883
+ current_roadmap_sha,
884
+ current_phase_sha.get(phase),
885
+ ):
886
+ continue
887
+ latest_event = event
888
+ if latest_event is None:
889
+ return None
890
+ metadata = latest_event.get("metadata")
891
+ if not isinstance(metadata, dict):
892
+ return None
893
+ dirty = metadata.get("completion_dirty_worktree")
894
+ if not isinstance(dirty, dict):
895
+ return None
896
+ if dirty.get("reason") != "verified_dirty_closeout_recovery":
897
+ return None
898
+ unowned = tuple(dirty.get("unowned_dirty_paths") or ())
899
+ if unowned:
900
+ # BREAKGLASS SL-2 (reconcile parity): a recorded operator attestation lifts the
901
+ # unowned-remainder bail — EXCEPT secrets, which are never break-glassable. The
902
+ # repo is already clean here (the closeout committed; the top-of-function _dirty
903
+ # guard is the real secrets backstop since SL-1 never commits a secret, leaving
904
+ # the worktree dirty); the explicit secrets check is defense-in-depth.
905
+ if not _closeout_allow_unowned_attested(repo, roadmap, phase):
906
+ return None
907
+ if any(classify_unowned_path(p).sensitivity_class == "secrets" for p in unowned):
908
+ return None
909
+ closeout_summary = _event_closeout_summary(latest_event)
910
+ if not closeout_summary.get("closeout_commit"):
911
+ return None
912
+ terminal_summary = _event_terminal_summary(latest_event)
913
+ if terminal_summary.get("verification_status") != "passed":
914
+ return None
915
+ return {
916
+ "terminal_summary": terminal_summary,
917
+ "closeout_summary": closeout_summary,
918
+ "auto_recovery": True,
919
+ }
920
+
921
+
922
+ def _event_has_verified_dirty_closeout_recovery(event: dict) -> bool:
923
+ metadata = event.get("metadata")
924
+ if not isinstance(metadata, dict):
925
+ return False
926
+ dirty = metadata.get("completion_dirty_worktree")
927
+ return isinstance(dirty, dict) and dirty.get("reason") == "verified_dirty_closeout_recovery"
928
+
929
+
930
+ def _supersedable_nonhuman_repair_blocker(blocker: dict) -> bool:
931
+ if not blocker or _truthy(blocker.get("human_required")):
932
+ return False
933
+ summary = str(blocker.get("blocker_summary") or "")
934
+ blocker_class = blocker.get("blocker_class")
935
+ if blocker_class == "repeated_verification_failure":
936
+ return (
937
+ "did not emit a valid shared automation closeout" in summary
938
+ or "blocked outcome without the shared blocker metadata" in summary
939
+ )
940
+ if blocker_class == "dirty_worktree_conflict":
941
+ return "left dirty paths that are not closeout-safe" in summary
942
+ return False
943
+
944
+
945
+ def _dirty(repo: Path) -> bool:
946
+ try:
947
+ return bool(subprocess.check_output(["git", "-C", str(repo), "status", "--short"], text=True).strip())
948
+ except Exception:
949
+ return False
950
+
951
+
952
+ def detect_downstream_plan_staleness(repo: Path, roadmap: Path, completed_phase: str) -> tuple[dict[str, object], ...]:
953
+ from .discovery import parse_frontmatter, parse_roadmap_phases
954
+
955
+ aliases = [str(alias).upper() for alias in parse_roadmap_phases(roadmap)]
956
+ current_roadmap_sha = roadmap_sha256(roadmap)
957
+ current_phase_sha = phase_provenance_map(roadmap)
958
+ try:
959
+ start = aliases.index(completed_phase.upper()) + 1
960
+ except ValueError:
961
+ start = 0
962
+ stale: list[dict[str, object]] = []
963
+ for alias in aliases[start:]:
964
+ for plan in sorted((repo / "plans").glob("phase-plan-v*-*.md")):
965
+ match = PLAN_RE.search(plan.name)
966
+ if not match or match.group(2).upper() != alias:
967
+ continue
968
+ metadata = parse_frontmatter(plan.read_text(encoding="utf-8"))
969
+ reasons: list[str] = []
970
+ if metadata.get("roadmap_sha256") != current_roadmap_sha:
971
+ reasons.append("roadmap_sha256")
972
+ plan_phase_sha = metadata.get("phase_sha256")
973
+ if plan_phase_sha and plan_phase_sha != current_phase_sha.get(alias):
974
+ reasons.append("phase_sha256")
975
+ if reasons:
976
+ stale.append({"phase": alias, "plan": str(plan), "reasons": tuple(reasons)})
977
+ return tuple(stale)
978
+
979
+
980
+ def invalidate_stale_downstream_plans(repo: Path, roadmap: Path, completed_phase: str) -> dict[str, object]:
981
+ stale = detect_downstream_plan_staleness(repo, roadmap, completed_phase)
982
+ if not stale:
983
+ return {"status": "unchanged", "stale_plans": ()}
984
+ next_phase = str(stale[0]["phase"])
985
+ return {
986
+ "status": "planning_required",
987
+ "blocker_class": "gold_record_amendment",
988
+ "next_skill": "codex-plan-phase",
989
+ "next_command": f"codex-plan-phase {roadmap} {next_phase}",
990
+ "stale_plans": stale,
991
+ }
992
+
993
+
994
+ def _ledger_warning(
995
+ source: str,
996
+ phase: str,
997
+ status: str,
998
+ reason: str,
999
+ *,
1000
+ raw_event: dict | None = None,
1001
+ value: object | None = None,
1002
+ ) -> dict:
1003
+ return _ledger_warning_record(source, phase, status, reason, raw_event=raw_event, value=value)
1004
+
1005
+
1006
+ def _ledger_warning_record(
1007
+ source: str,
1008
+ phase: str,
1009
+ status: str,
1010
+ reason: str,
1011
+ *,
1012
+ raw_event: dict | None = None,
1013
+ value: object | None = None,
1014
+ ) -> dict:
1015
+ warning = {
1016
+ "source": source,
1017
+ "phase": phase,
1018
+ "status": status,
1019
+ "reason": reason,
1020
+ "canonical_reason": _canonical_ledger_reason(reason),
1021
+ "value": value,
1022
+ }
1023
+ if raw_event is not None:
1024
+ warning["timestamp"] = _optional_text(raw_event.get("timestamp"))
1025
+ warning["action"] = _optional_text(raw_event.get("action"))
1026
+ warning["raw_event_summary"] = _raw_event_summary(raw_event)
1027
+ return {key: value for key, value in warning.items() if value is not None}
1028
+
1029
+
1030
+ def _event_dedup_key(event: dict) -> tuple[object, ...]:
1031
+ phase = str(event.get("phase", "")).upper()
1032
+ automation_status = _event_automation_status(event)
1033
+ blocker_class = _event_blocker_class(event)
1034
+ return (
1035
+ _optional_text(event.get("timestamp")),
1036
+ phase,
1037
+ _optional_text(event.get("action")),
1038
+ _optional_text(event.get("status")),
1039
+ automation_status,
1040
+ blocker_class,
1041
+ )
1042
+
1043
+
1044
+ def _event_content_signature(event: dict) -> tuple[object, ...]:
1045
+ """Stable content signature drawn from the executor's terminal_summary.
1046
+
1047
+ DEF-4: the runner emits two events with identical (timestamp, phase,
1048
+ action, status, automation_status, blocker_class) keys during a single
1049
+ blocked-execute call — one carrying an empty terminal_summary, the other
1050
+ carrying the executor's dirty_paths / phase_owned_dirty_paths /
1051
+ produced_if_gates. Without a content signature they share a dedup
1052
+ identity; the first event wins and the rich one is silently dropped,
1053
+ leaving the snapshot's dirty_paths empty and the closeout auto-classifier
1054
+ unable to fire.
1055
+
1056
+ Including the content signature in the dedup identity lets both events
1057
+ survive reconcile. The reducer's existing overwrite-on-non-empty +
1058
+ no-pop-on-blocked semantics then naturally surface the rich event's data.
1059
+
1060
+ True byte-identical duplicates still collapse: their content signatures
1061
+ match exactly. The regression guard for that is intentional — replayed
1062
+ ledgers (e.g. after restoring `events.jsonl.bak-*`) must not double-count.
1063
+ """
1064
+ metadata = event.get("metadata")
1065
+ if not isinstance(metadata, dict):
1066
+ return ()
1067
+ summary = metadata.get("terminal_summary")
1068
+ if not isinstance(summary, dict):
1069
+ return ()
1070
+ return (
1071
+ tuple(sorted(str(p) for p in (summary.get("dirty_paths") or ()))),
1072
+ tuple(sorted(str(p) for p in (summary.get("phase_owned_dirty_paths") or ()))),
1073
+ tuple(sorted(str(g) for g in (summary.get("produced_if_gates") or ()))),
1074
+ _optional_text(summary.get("terminal_status")),
1075
+ _optional_text(summary.get("verification_status")),
1076
+ )
1077
+
1078
+
1079
+ def _event_dedup_identity(event: dict, dedup_key: tuple[object, ...]) -> tuple[object, ...]:
1080
+ return (
1081
+ *dedup_key,
1082
+ _optional_text(event.get("roadmap_sha256")),
1083
+ _optional_text(event.get("phase_sha256")),
1084
+ event.get("schema_version"),
1085
+ _event_content_signature(event),
1086
+ )
1087
+
1088
+
1089
+ def _ledger_duplicate_record(event: dict, dedup_key: tuple[object, ...]) -> dict:
1090
+ timestamp, phase, action, status, automation_status, blocker_class = dedup_key
1091
+ duplicate_key = {
1092
+ "timestamp": timestamp,
1093
+ "phase": phase,
1094
+ "action": action,
1095
+ "status": status,
1096
+ "automation_status": automation_status,
1097
+ "blocker_class": blocker_class,
1098
+ }
1099
+ return {
1100
+ "phase": phase,
1101
+ "timestamp": timestamp,
1102
+ "action": action,
1103
+ "status": status,
1104
+ "automation_status": automation_status,
1105
+ "blocker_class": blocker_class,
1106
+ "duplicate_key": {key: value for key, value in duplicate_key.items() if value is not None},
1107
+ "raw_event_summary": _raw_event_summary(event),
1108
+ }
1109
+
1110
+
1111
+ def _event_status_provenance_matches(
1112
+ event: dict,
1113
+ status: str,
1114
+ current_roadmap_sha: str,
1115
+ current_phase_sha: str | None,
1116
+ ) -> bool:
1117
+ if event.get("action") == "manual_recovery" and status == "unplanned":
1118
+ status = "planned"
1119
+ return status_provenance_matches(
1120
+ status,
1121
+ event.get("roadmap_sha256"),
1122
+ event.get("phase_sha256"),
1123
+ current_roadmap_sha,
1124
+ current_phase_sha,
1125
+ )
1126
+
1127
+
1128
+ def _event_automation_status(event: dict) -> str | None:
1129
+ value = _optional_text(event.get("automation_status"))
1130
+ if value:
1131
+ return value
1132
+ automation = event.get("automation")
1133
+ if isinstance(automation, dict):
1134
+ value = _optional_text(automation.get("status") or automation.get("automation_status"))
1135
+ if value:
1136
+ return value
1137
+ metadata = event.get("metadata")
1138
+ if isinstance(metadata, dict):
1139
+ child_automation = metadata.get("child_automation")
1140
+ if isinstance(child_automation, dict):
1141
+ value = _optional_text(child_automation.get("automation_status") or child_automation.get("status"))
1142
+ if value:
1143
+ return value
1144
+ closeout = metadata.get("closeout")
1145
+ if isinstance(closeout, dict):
1146
+ value = _optional_text(closeout.get("status") or closeout.get("terminal_status"))
1147
+ if value:
1148
+ return value
1149
+ terminal = metadata.get("terminal_summary")
1150
+ if isinstance(terminal, dict):
1151
+ value = _optional_text(terminal.get("terminal_status"))
1152
+ if value:
1153
+ return value
1154
+ return None
1155
+
1156
+
1157
+ def _event_blocker_class(event: dict) -> str | None:
1158
+ blocker = _event_blocker(event)
1159
+ value = _optional_text(blocker.get("blocker_class")) if blocker else None
1160
+ if value:
1161
+ return value
1162
+ value = _optional_text(event.get("blocker_class"))
1163
+ if value:
1164
+ return _normalize_blocker_class(value)
1165
+ automation = event.get("automation")
1166
+ if isinstance(automation, dict):
1167
+ value = _optional_text(automation.get("blocker_class") or automation.get("automation_blocker_class"))
1168
+ if value:
1169
+ return _normalize_blocker_class(value)
1170
+ metadata = event.get("metadata")
1171
+ if isinstance(metadata, dict):
1172
+ child_automation = metadata.get("child_automation")
1173
+ if isinstance(child_automation, dict):
1174
+ value = _optional_text(
1175
+ child_automation.get("automation_blocker_class") or child_automation.get("blocker_class")
1176
+ )
1177
+ if value:
1178
+ return _normalize_blocker_class(value)
1179
+ terminal = metadata.get("terminal_summary")
1180
+ if isinstance(terminal, dict):
1181
+ terminal_blocker = terminal.get("terminal_blocker")
1182
+ if isinstance(terminal_blocker, dict):
1183
+ value = _optional_text(terminal_blocker.get("blocker_class"))
1184
+ if value:
1185
+ return _normalize_blocker_class(value)
1186
+ return None
1187
+
1188
+
1189
+ def _canonical_ledger_reason(reason: str) -> str:
1190
+ if reason in {
1191
+ "phase_missing",
1192
+ "not_in_allowed_status_set",
1193
+ "legacy_pre_schema_v2",
1194
+ "planned_without_plan_artifact",
1195
+ "blocker_supersession",
1196
+ "event_only_status",
1197
+ "malformed_state_transition",
1198
+ "malformed_manual_recovery",
1199
+ "legacy_executor_closeout_action_normalized",
1200
+ }:
1201
+ return reason
1202
+ return "provenance_mismatch"
1203
+
1204
+
1205
+ def _raw_event_summary(event: dict) -> dict[str, object]:
1206
+ summary = {
1207
+ "schema_version": event.get("schema_version"),
1208
+ "source": event.get("source"),
1209
+ "phase": event.get("phase"),
1210
+ "action": event.get("action"),
1211
+ "status": event.get("status"),
1212
+ "timestamp": event.get("timestamp"),
1213
+ "roadmap_sha256_present": bool(event.get("roadmap_sha256")),
1214
+ "phase_sha256_present": bool(event.get("phase_sha256")),
1215
+ }
1216
+ transition = _state_transition_metadata(event)
1217
+ if transition is not None:
1218
+ summary["state_transition"] = transition
1219
+ return {key: value for key, value in summary.items() if value not in (None, "")}
1220
+
1221
+
1222
+ def _normalize_automation_event(repo: Path, roadmap: Path, event: dict, current_roadmap_sha: str, current_phase_sha: dict[str, str]) -> dict:
1223
+ if event.get("phase") or not isinstance(event.get("automation"), dict):
1224
+ return event
1225
+
1226
+ automation = event["automation"]
1227
+ status = automation.get("status")
1228
+ artifact = automation.get("artifact")
1229
+ if status not in RECONCILE_EVENT_STATUSES or not artifact:
1230
+ return event
1231
+
1232
+ artifact_path = Path(str(artifact)).expanduser().resolve()
1233
+ try:
1234
+ artifact_path.relative_to(repo.resolve())
1235
+ except ValueError:
1236
+ return event
1237
+ match = PLAN_RE.search(artifact_path.name)
1238
+ if not match or not artifact_path.exists():
1239
+ return event
1240
+ phase = match.group(2).upper()
1241
+ if not plan_matches_roadmap(repo, artifact_path, roadmap, phase):
1242
+ return event
1243
+
1244
+ normalized = dict(event)
1245
+ normalized.update(
1246
+ {
1247
+ "repo": str(repo),
1248
+ "roadmap": str(roadmap),
1249
+ "phase": phase,
1250
+ "action": event.get("action", "automation"),
1251
+ "status": status,
1252
+ "source": event.get("source", "manual"),
1253
+ "schema_version": 2,
1254
+ "roadmap_sha256": current_roadmap_sha,
1255
+ "phase_sha256": current_phase_sha.get(phase),
1256
+ }
1257
+ )
1258
+ blocker = _normalize_blocker(
1259
+ {
1260
+ "human_required": _truthy(automation.get("human_required")),
1261
+ "blocker_class": automation.get("blocker_class"),
1262
+ "blocker_summary": automation.get("blocker_summary"),
1263
+ "required_human_inputs": automation.get("required_human_inputs", ()),
1264
+ }
1265
+ )
1266
+ if blocker:
1267
+ normalized["blocker"] = blocker
1268
+ return normalized
1269
+
1270
+
1271
+ def _normalize_legacy_executor_closeout_action(event: dict) -> tuple[dict, bool]:
1272
+ metadata = event.get("metadata")
1273
+ if event.get("action") != "run" or not isinstance(metadata, dict):
1274
+ return event, False
1275
+ if not isinstance(metadata.get("executor_closeout_event"), dict):
1276
+ return event, False
1277
+ normalized = dict(event)
1278
+ normalized["action"] = "executor.closeout"
1279
+ return normalized, True
1280
+
1281
+
1282
+ def _normalize_blocker(raw: dict) -> dict:
1283
+ human_required = _truthy(raw.get("human_required"))
1284
+ blocker_class = _normalize_blocker_class(_optional_text(raw.get("blocker_class")))
1285
+ blocker_summary = _optional_text(raw.get("blocker_summary"))
1286
+ required_human_inputs = tuple(raw.get("required_human_inputs") or ())
1287
+ access_attempts = tuple(raw.get("access_attempts") or ())
1288
+ if not human_required and not blocker_class and not blocker_summary and not required_human_inputs and not access_attempts:
1289
+ return {}
1290
+ return {
1291
+ "human_required": human_required,
1292
+ "blocker_class": blocker_class,
1293
+ "blocker_summary": blocker_summary,
1294
+ "required_human_inputs": required_human_inputs,
1295
+ "access_attempts": access_attempts,
1296
+ }
1297
+
1298
+
1299
+ def _normalize_blocker_class(value: object | None) -> str | None:
1300
+ if value is None:
1301
+ return None
1302
+ text = str(value).strip()
1303
+ if text in BLOCKER_CLASSES:
1304
+ return text
1305
+ aliases = {
1306
+ "operator_auth_required": "account_or_billing_setup",
1307
+ "blocked_operator_unavailable": "account_or_billing_setup",
1308
+ "interactive_auth_required": "account_or_billing_setup",
1309
+ "blocked_by_external_setup": "admin_approval",
1310
+ "blocked_by_implementation": "repeated_verification_failure",
1311
+ }
1312
+ return aliases.get(text, "repeated_verification_failure")
1313
+
1314
+
1315
+ def _event_dirty_summary(event: dict) -> dict[str, object]:
1316
+ metadata = event.get("metadata")
1317
+ if not isinstance(metadata, dict):
1318
+ return {}
1319
+ for key in ("completion_dirty_worktree", "plan_dirty_worktree", "incomplete_execute_dirty_worktree"):
1320
+ value = metadata.get(key)
1321
+ if isinstance(value, dict) and "dirty_paths" in value:
1322
+ return {
1323
+ "dirty_paths": list(value.get("dirty_paths", ())),
1324
+ "phase_owned_dirty_paths": list(value.get("phase_owned_dirty_paths", ())),
1325
+ "previous_phase_owned_paths": list(value.get("previous_phase_owned_paths", ())),
1326
+ "unowned_dirty_paths": list(value.get("unowned_dirty_paths", ())),
1327
+ "pre_existing_dirty_paths": list(value.get("pre_existing_dirty_paths", ())),
1328
+ "phase_owned_dirty": bool(value.get("phase_owned_dirty", False)),
1329
+ "terminal_status": value.get("terminal_status"),
1330
+ }
1331
+ # Fallback: executor's terminal_summary carries dirty_paths directly when
1332
+ # the runner hasn't (yet) wrapped them into the completion_dirty_worktree /
1333
+ # plan_dirty_worktree / incomplete_execute_dirty_worktree shapes. Without
1334
+ # this read, the closeout fallback at runner.py:6207 sees empty
1335
+ # snapshot.dirty_paths and refuses, even though the executor accurately
1336
+ # reported its phase-owned output. Skip dry-run summaries so the
1337
+ # event_only_status warning path stays intact.
1338
+ terminal_summary = metadata.get("terminal_summary")
1339
+ # Only fall back when the executor actually reported dirty work AND it's
1340
+ # not a dry-run. Empty-dirty terminal_summaries from closeout-refused
1341
+ # follow-up events would otherwise overwrite the prior executor report.
1342
+ if (
1343
+ isinstance(terminal_summary, dict)
1344
+ and terminal_summary.get("dirty_paths")
1345
+ and terminal_summary.get("terminal_status") != "dry_run"
1346
+ and not metadata.get("dry_run_only")
1347
+ ):
1348
+ return {
1349
+ "dirty_paths": list(terminal_summary.get("dirty_paths", ())),
1350
+ "phase_owned_dirty_paths": list(terminal_summary.get("phase_owned_dirty_paths", ())),
1351
+ "previous_phase_owned_paths": list(terminal_summary.get("previous_phase_owned_paths", ())),
1352
+ "unowned_dirty_paths": list(terminal_summary.get("unowned_dirty_paths", ())),
1353
+ "pre_existing_dirty_paths": list(terminal_summary.get("pre_existing_dirty_paths", ())),
1354
+ "phase_owned_dirty": bool(terminal_summary.get("phase_owned_dirty", False)),
1355
+ "terminal_status": terminal_summary.get("terminal_status"),
1356
+ }
1357
+ return {}
1358
+
1359
+
1360
+ def _event_closeout_summary(event: dict) -> dict[str, object]:
1361
+ metadata = event.get("metadata")
1362
+ if not isinstance(metadata, dict):
1363
+ return {}
1364
+ closeout = metadata.get("closeout")
1365
+ if not isinstance(closeout, dict):
1366
+ manual_repair = metadata.get("manual_repair")
1367
+ closeout = manual_repair if isinstance(manual_repair, dict) and manual_repair.get("closeout_commit") else None
1368
+ if not isinstance(closeout, dict):
1369
+ return {}
1370
+ summary = {
1371
+ "closeout_mode": _optional_text(closeout.get("closeout_mode")) or ("commit" if closeout.get("closeout_commit") else None),
1372
+ "closeout_action": _optional_text(closeout.get("closeout_action")) or ("commit" if closeout.get("closeout_commit") else None),
1373
+ "closeout_commit": _optional_text(closeout.get("closeout_commit")),
1374
+ "closeout_push_ref": _optional_text(closeout.get("closeout_push_ref")),
1375
+ "closeout_refusal_reason": _optional_text(closeout.get("closeout_refusal_reason")),
1376
+ "verification_status": _optional_text(closeout.get("verification_status")) or ("passed" if closeout.get("closeout_commit") else None),
1377
+ }
1378
+ return {key: value for key, value in summary.items() if value is not None}
1379
+
1380
+
1381
+ def _closeout_completed(status: object, closeout: dict[str, object]) -> bool:
1382
+ if status == "complete":
1383
+ return True
1384
+ if status == "blocked":
1385
+ return False
1386
+ if status == "planned":
1387
+ return False
1388
+ if closeout.get("verification_status") in {"blocked", "failed"}:
1389
+ return False
1390
+ action = closeout.get("closeout_action")
1391
+ commit = closeout.get("closeout_commit")
1392
+ return bool(commit and action in {"commit", "push", "push_refused"})
1393
+
1394
+
1395
+ def _event_terminal_summary(event: dict) -> dict[str, object]:
1396
+ metadata = event.get("metadata")
1397
+ if not isinstance(metadata, dict):
1398
+ return {}
1399
+ terminal = metadata.get("terminal_summary")
1400
+ if not isinstance(terminal, dict):
1401
+ return {}
1402
+ summary = {
1403
+ "terminal_status": _optional_text(terminal.get("terminal_status")),
1404
+ "terminal_blocker": terminal.get("terminal_blocker"),
1405
+ "verification_status": _optional_text(terminal.get("verification_status")),
1406
+ "next_action": _optional_text(terminal.get("next_action")),
1407
+ "dirty_paths": list(terminal.get("dirty_paths", ())),
1408
+ "phase_owned_dirty": bool(terminal.get("phase_owned_dirty", False)),
1409
+ "phase_owned_dirty_paths": list(terminal.get("phase_owned_dirty_paths", ())),
1410
+ "previous_phase_owned_paths": list(terminal.get("previous_phase_owned_paths", ())),
1411
+ "unowned_dirty_paths": list(terminal.get("unowned_dirty_paths", ())),
1412
+ "pre_existing_dirty_paths": list(terminal.get("pre_existing_dirty_paths", ())),
1413
+ "artifact_paths": dict(terminal.get("artifact_paths", {})) if isinstance(terminal.get("artifact_paths"), dict) else {},
1414
+ }
1415
+ return {key: value for key, value in summary.items() if value is not None}
1416
+
1417
+
1418
+ def _event_terminal_summary_is_event_only(event: dict, terminal: dict[str, object]) -> bool:
1419
+ if terminal.get("terminal_status") != "dry_run":
1420
+ return False
1421
+ metadata = event.get("metadata")
1422
+ if not isinstance(metadata, dict):
1423
+ return False
1424
+ if metadata.get("dry_run_only") is True:
1425
+ return True
1426
+ launch = metadata.get("launch")
1427
+ return isinstance(launch, dict) and (launch.get("dry_run") is True or launch.get("dry_run_only") is True)
1428
+
1429
+
1430
+ def _default_closeout_summary(terminal_status: object) -> dict[str, object]:
1431
+ verification_status = "passed" if terminal_status == "complete" else "not_run"
1432
+ return {
1433
+ "closeout_mode": "manual",
1434
+ "closeout_action": "awaiting_phase_closeout",
1435
+ "verification_status": verification_status,
1436
+ }
1437
+
1438
+
1439
+ def _planned_event_clears_blocker(event: dict) -> bool:
1440
+ transition = _state_transition_metadata(event)
1441
+ if transition is not None and event.get("status") in {"planned", "executing"}:
1442
+ return transition.get("reason") == "repair_precondition_cleared"
1443
+ recovery = _manual_recovery_metadata(event)
1444
+ if recovery is not None and event.get("status") in {"planned", "unplanned"}:
1445
+ return bool(recovery.get("clears_blocker"))
1446
+ # phase_reopen is explicit operator intent to re-dispatch from a stuck
1447
+ # status (typed event with --reason); always clears any prior blocker.
1448
+ if event.get("action") == "phase_reopen" and event.get("status") == "planned":
1449
+ return True
1450
+ if event.get("action") != "manual_repair":
1451
+ return False
1452
+ metadata = event.get("metadata")
1453
+ if not isinstance(metadata, dict):
1454
+ return False
1455
+ manual_repair = metadata.get("manual_repair")
1456
+ if isinstance(manual_repair, dict):
1457
+ return bool(manual_repair.get("clears_blocker"))
1458
+ return bool(metadata.get("clears_blocker"))
1459
+
1460
+
1461
+ def _manual_recovery_metadata(event: dict) -> dict[str, object] | None:
1462
+ if event.get("action") != "manual_recovery":
1463
+ return None
1464
+ metadata = event.get("metadata")
1465
+ if not isinstance(metadata, dict):
1466
+ return None
1467
+ recovery = metadata.get("manual_recovery")
1468
+ if not isinstance(recovery, dict):
1469
+ return None
1470
+ required = ("from", "to", "reason", "trigger")
1471
+ if any(not _optional_text(recovery.get(key)) for key in required):
1472
+ return None
1473
+ if recovery.get("from") != "blocked":
1474
+ return None
1475
+ if recovery.get("to") not in {"planned", "unplanned"}:
1476
+ return None
1477
+ if recovery.get("to") != event.get("status"):
1478
+ return None
1479
+ if recovery.get("trigger") != "cli":
1480
+ return None
1481
+ if recovery.get("verification_status") != "not_run":
1482
+ return None
1483
+ if not recovery.get("clears_blocker"):
1484
+ return None
1485
+ return recovery
1486
+
1487
+
1488
+ def _state_transition_metadata(event: dict) -> dict[str, object] | None:
1489
+ if event.get("action") != "state_transition":
1490
+ return None
1491
+ metadata = event.get("metadata")
1492
+ if not isinstance(metadata, dict):
1493
+ return None
1494
+ transition = metadata.get("state_transition")
1495
+ if not isinstance(transition, dict):
1496
+ return None
1497
+ required = ("from", "to", "reason", "trigger")
1498
+ if any(not _optional_text(transition.get(key)) for key in required):
1499
+ return None
1500
+ return transition
1501
+
1502
+
1503
+ def _event_clears_terminal_summary(event: dict, status: object) -> bool:
1504
+ if _manual_recovery_metadata(event) is not None and status in {"planned", "unplanned"}:
1505
+ return True
1506
+ if event.get("action") == "phase_reopen" and status == "planned":
1507
+ return True
1508
+ if event.get("action") == "manual_repair":
1509
+ metadata = event.get("metadata")
1510
+ if isinstance(metadata, dict):
1511
+ manual_repair = metadata.get("manual_repair")
1512
+ if isinstance(manual_repair, dict) and manual_repair.get("clears_blocker") and status in {"complete", "planned"}:
1513
+ return True
1514
+ if metadata.get("clears_blocker") and status in {"complete", "planned"}:
1515
+ return True
1516
+ if status != "complete":
1517
+ return False
1518
+ return event.get("action") in {"execute", "run", "manual"}
1519
+
1520
+
1521
+ def _optional_text(value: object) -> object | None:
1522
+ if value is None:
1523
+ return None
1524
+ if isinstance(value, str):
1525
+ stripped = value.strip()
1526
+ if stripped.lower() in {"", "none", "null"}:
1527
+ return None
1528
+ if stripped.startswith("<") and stripped.endswith(">") and "none" in stripped.lower():
1529
+ return None
1530
+ return stripped
1531
+ return value
1532
+
1533
+
1534
+ def _snapshot_blocker_trusted(snapshot: StateSnapshot, current_roadmap_sha: str, current_phase_sha: dict[str, str]) -> bool:
1535
+ for phase, status in snapshot.phases.items():
1536
+ if status == "blocked" and status_provenance_matches(status, snapshot.roadmap_sha256, snapshot.phase_sha256.get(phase), current_roadmap_sha, current_phase_sha.get(phase)):
1537
+ return True
1538
+ return False
1539
+
1540
+
1541
+ def _current_phase(phases: dict[str, str]) -> str | None:
1542
+ for preferred_status in ("blocked", "awaiting_phase_closeout", "unknown", "executing", "executed", "planned", "unplanned"):
1543
+ for phase, status in phases.items():
1544
+ if status == preferred_status:
1545
+ return phase
1546
+ return None
1547
+
1548
+
1549
+ def _truthy(value: object) -> bool:
1550
+ return value is True or str(value).lower() == "true"