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,1092 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ validate_plan_doc.py — mechanical lint for phase-plan docs.
4
+
5
+ Usage:
6
+ validate_plan_doc.py <plan-path>
7
+
8
+ Checks the plan doc produced by /claude-plan-phase before it's handed off to
9
+ /claude-execute-phase. Exit 0 on clean pass; non-zero with a human-readable
10
+ failure list on stderr.
11
+
12
+ Checks (in order; all run even if earlier ones fail so the author sees
13
+ every issue at once):
14
+
15
+ (A) Required top-level headings present:
16
+ # <anything>
17
+ ## Context
18
+ ## Interface Freeze Gates
19
+ ## Spec Closeout Plan
20
+ ## Lane Index & Dependencies
21
+ ## Lanes
22
+ ## Execution Notes
23
+ ## Acceptance Criteria
24
+ ## Verification
25
+ Optional: ## Cross-Repo Gates
26
+
27
+ (B) Lane Index stanzas parse cleanly: each SL-N has
28
+ Depends on: comma-separated SL-IDs or (none)
29
+ Blocks: comma-separated SL-IDs or (none)
30
+ Parallel-safe: yes | no | mixed
31
+
32
+ (C) DAG has no cycles (topological sort succeeds).
33
+
34
+ (D) `Owned files` globs across ### SL-N sections are disjoint.
35
+ If run inside a git repo, expand each glob via `git ls-files`
36
+ and check for overlap. Otherwise fall back to textual prefix
37
+ comparison — flag any exact glob duplication and warn about
38
+ unresolvable cases.
39
+
40
+ (E) Every `impl` task row in a lane's task table has a preceding
41
+ `test` task in the same lane.
42
+
43
+ (F) Every `Interfaces consumed` entry either appears in some upstream
44
+ lane's `Interfaces provided` or is tagged "(pre-existing)".
45
+
46
+ (I) `Spec Closeout Plan` declares `spec_delta_closeout.v1`, a valid decision,
47
+ target surfaces, evidence paths, and `metadata_only` redaction.
48
+
49
+ Design: zero external deps (stdlib only). Parses markdown by regex on
50
+ stable headings produced by the claude-plan-phase template — not a full
51
+ Markdown parser, which would be overkill.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import hashlib
57
+ import re
58
+ import subprocess
59
+ import sys
60
+ from dataclasses import dataclass, field
61
+ from pathlib import Path
62
+ from typing import Dict, List, Optional, Set, Tuple
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Data model
67
+
68
+ @dataclass
69
+ class Lane:
70
+ sl_id: str # e.g. "SL-1"
71
+ name: str # free-text after —
72
+ depends_on: List[str] = field(default_factory=list)
73
+ blocks: List[str] = field(default_factory=list)
74
+ parallel_safe: str = "" # yes | no | mixed
75
+ owned_globs: List[str] = field(default_factory=list)
76
+ interfaces_provided: List[str] = field(default_factory=list)
77
+ interfaces_consumed: List[str] = field(default_factory=list)
78
+ task_rows: List[dict] = field(default_factory=list) # parsed from the markdown table
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Helpers
83
+
84
+ def _read(path: Path) -> str:
85
+ try:
86
+ return path.read_text(encoding="utf-8")
87
+ except Exception as exc:
88
+ _fail(f"cannot read {path}: {exc}")
89
+ sys.exit(2)
90
+
91
+
92
+ def _top_headings(src: str) -> Set[str]:
93
+ return {
94
+ line[3:].strip()
95
+ for line in src.splitlines()
96
+ if line.startswith("## ")
97
+ }
98
+
99
+
100
+ def _has_h1(src: str) -> bool:
101
+ for line in src.splitlines():
102
+ if line.startswith("# ") and not line.startswith("## "):
103
+ return True
104
+ return False
105
+
106
+
107
+ def _parse_frontmatter(src: str) -> dict:
108
+ lines = src.splitlines()
109
+ if not lines or lines[0].strip() != "---":
110
+ return {}
111
+ data: dict[str, str] = {}
112
+ for line in lines[1:]:
113
+ if line.strip() == "---":
114
+ return data
115
+ if ":" not in line:
116
+ continue
117
+ key, value = line.split(":", 1)
118
+ data[key.strip()] = value.strip().strip('"').strip("'")
119
+ return {}
120
+
121
+
122
+ def _sha256_file(path: Path) -> str:
123
+ digest = hashlib.sha256()
124
+ with path.open("rb") as fh:
125
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
126
+ digest.update(chunk)
127
+ return digest.hexdigest()
128
+
129
+
130
+ def _extract_section(src: str, heading: str) -> str:
131
+ """Return the body of a ## heading up to the next ## heading or EOF."""
132
+ lines = src.splitlines()
133
+ want = f"## {heading}"
134
+ in_section = False
135
+ out: List[str] = []
136
+ for line in lines:
137
+ if line.startswith("## ") and line.strip() == want:
138
+ in_section = True
139
+ continue
140
+ if in_section and line.startswith("## "):
141
+ break
142
+ if in_section:
143
+ out.append(line)
144
+ return "\n".join(out)
145
+
146
+
147
+ _SL_HEADER_RE = re.compile(r"^(SL-\d+)\s*—\s*(.+?)\s*$")
148
+ _KEY_VALUE_RE = re.compile(r"^\s*(Depends on|Blocks|Parallel-safe)\s*:\s*(.*?)\s*$")
149
+ _COMMA_SPLIT_RE = re.compile(r"[,\s]+")
150
+
151
+
152
+ def _parse_lane_index(body: str) -> List[Lane]:
153
+ """Parse stanzas of the form:
154
+ SL-1 — name
155
+ Depends on: (none)
156
+ Blocks: SL-3, SL-4
157
+ Parallel-safe: yes
158
+ """
159
+ lanes: List[Lane] = []
160
+ current: Optional[Lane] = None
161
+ for line in body.splitlines():
162
+ if not line.strip():
163
+ continue
164
+ m_header = _SL_HEADER_RE.match(line)
165
+ if m_header:
166
+ if current is not None:
167
+ lanes.append(current)
168
+ current = Lane(sl_id=m_header.group(1), name=m_header.group(2))
169
+ continue
170
+ if current is None:
171
+ continue
172
+ m_kv = _KEY_VALUE_RE.match(line)
173
+ if not m_kv:
174
+ continue
175
+ key, value = m_kv.group(1), m_kv.group(2)
176
+ if key == "Depends on":
177
+ current.depends_on = _parse_sl_list(value)
178
+ elif key == "Blocks":
179
+ current.blocks = _parse_sl_list(value)
180
+ elif key == "Parallel-safe":
181
+ current.parallel_safe = value.split()[0] if value.strip() else ""
182
+ if current is not None:
183
+ lanes.append(current)
184
+ return lanes
185
+
186
+
187
+ def _parse_sl_list(value: str) -> List[str]:
188
+ value = value.strip()
189
+ if not value or value.lower() == "(none)":
190
+ return []
191
+ return [tok.strip() for tok in re.split(r",\s*", value) if tok.strip()]
192
+
193
+
194
+ # Regex for per-lane ### SL-N sections inside ## Lanes
195
+ _LANE_SECTION_RE = re.compile(r"^###\s+(SL-\d+)\b.*$", re.MULTILINE)
196
+
197
+
198
+ def _split_lane_sections(lanes_body: str) -> Dict[str, str]:
199
+ """Map SL-N -> section body (text between this ### heading and the next)."""
200
+ matches = list(_LANE_SECTION_RE.finditer(lanes_body))
201
+ out: Dict[str, str] = {}
202
+ for idx, m in enumerate(matches):
203
+ sl_id = m.group(1)
204
+ start = m.end()
205
+ end = matches[idx + 1].start() if idx + 1 < len(matches) else len(lanes_body)
206
+ out[sl_id] = lanes_body[start:end]
207
+ return out
208
+
209
+
210
+ _BULLET_VALUE_RE = re.compile(
211
+ r"^\s*-\s*\*\*(Scope|Owned files|Interfaces provided|Interfaces consumed)\*\*\s*:\s*(.*?)\s*$",
212
+ re.IGNORECASE,
213
+ )
214
+
215
+
216
+ def _parse_lane_section(body: str) -> dict:
217
+ """Extract Scope, Owned files, Interfaces provided, Interfaces consumed,
218
+ and task rows from a single ### SL-N section body."""
219
+ out = {
220
+ "owned_globs": [],
221
+ "interfaces_provided": [],
222
+ "interfaces_consumed": [],
223
+ "task_rows": [],
224
+ }
225
+ # Collect bullet values (may span multiple lines if formatted oddly;
226
+ # stick to single-line bullets per the template).
227
+ for line in body.splitlines():
228
+ m = _BULLET_VALUE_RE.match(line)
229
+ if not m:
230
+ continue
231
+ key = m.group(1).lower()
232
+ val = m.group(2)
233
+ items = _split_inline_items(val)
234
+ if key == "owned files":
235
+ out["owned_globs"] = items
236
+ elif key == "interfaces provided":
237
+ out["interfaces_provided"] = items
238
+ elif key == "interfaces consumed":
239
+ out["interfaces_consumed"] = items
240
+ # Parse task table rows. The plan template uses:
241
+ # | Task ID | Type | Depends on | Files in scope | Tests owned | Test command |
242
+ out["task_rows"] = _parse_task_table(body)
243
+ return out
244
+
245
+
246
+ _CODE_TOKEN_RE = re.compile(r"`([^`]+)`")
247
+
248
+
249
+ def _split_inline_items(raw: str) -> List[str]:
250
+ """Extract items from an inline list. Prefer backtick-quoted tokens; else
251
+ fall back to comma-splitting. Normalizes '(none)' to [].
252
+
253
+ Propagates an inline '(pre-existing)' annotation onto EVERY extracted
254
+ token so downstream checks (e.g., _check_f_interfaces_trace) can see
255
+ the annotation even when the user wrote it outside the backticks,
256
+ e.g. ``- **Interfaces consumed**: `IPlugin` (pre-existing)``.
257
+ """
258
+ s = raw.strip()
259
+ if not s or s.lower() in {"(none)", "none", "—"}:
260
+ return []
261
+ has_pre_existing = "(pre-existing)" in s.lower()
262
+ tokens = _CODE_TOKEN_RE.findall(s)
263
+ if tokens:
264
+ items = [t.strip() for t in tokens if t.strip()]
265
+ else:
266
+ items = [tok.strip().strip("`") for tok in re.split(r",\s*", s) if tok.strip()]
267
+ if has_pre_existing:
268
+ items = [f"{t} (pre-existing)" for t in items]
269
+ return items
270
+
271
+
272
+ def _parse_task_table(body: str) -> List[dict]:
273
+ """Parse the markdown task table out of a lane body.
274
+
275
+ Expected header (case-insensitive):
276
+ | Task ID | Type | Depends on | Files in scope | Tests owned | Test command |
277
+ """
278
+ rows: List[dict] = []
279
+ lines = body.splitlines()
280
+ header_idx: Optional[int] = None
281
+ for idx, line in enumerate(lines):
282
+ if (
283
+ line.startswith("|")
284
+ and "Task ID" in line
285
+ and "Type" in line
286
+ and "Depends" in line
287
+ ):
288
+ header_idx = idx
289
+ break
290
+ if header_idx is None:
291
+ return rows
292
+ # Skip the separator row (---|---|).
293
+ for line in lines[header_idx + 2:]:
294
+ if not line.startswith("|"):
295
+ break
296
+ cells = [c.strip() for c in line.split("|")[1:-1]]
297
+ if len(cells) < 2:
298
+ continue
299
+ task_id = cells[0].strip("`")
300
+ task_type = cells[1].strip("`").lower()
301
+ rows.append({"task_id": task_id, "type": task_type})
302
+ return rows
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Checks
307
+
308
+ Findings = List[str] # human-readable error strings
309
+
310
+
311
+ def _check_a_required_headings(src: str) -> Findings:
312
+ out: Findings = []
313
+ if not _has_h1(src):
314
+ out.append("(A) missing top-level `# <PHASE_ID>:` heading")
315
+ required = [
316
+ "Context",
317
+ "Interface Freeze Gates",
318
+ "Lane Index & Dependencies",
319
+ "Lanes",
320
+ "Execution Notes",
321
+ "Acceptance Criteria",
322
+ "Verification",
323
+ ]
324
+ found = _top_headings(src)
325
+ for heading in required:
326
+ if heading not in found:
327
+ out.append(f"(A) missing required heading: `## {heading}`")
328
+ return out
329
+
330
+
331
+ def _check_frontmatter(src: str, path: Path, repo_root: Optional[Path]) -> Findings:
332
+ out: Findings = []
333
+ metadata = _parse_frontmatter(src)
334
+ required = ("phase_loop_plan_version", "phase", "roadmap", "roadmap_sha256")
335
+ for key in required:
336
+ if not metadata.get(key):
337
+ out.append(f"(FM) missing required frontmatter field: `{key}`")
338
+ if out:
339
+ return out
340
+ if metadata.get("phase_loop_plan_version") != "1":
341
+ out.append("(FM) `phase_loop_plan_version` must be `1`")
342
+ roadmap_value = metadata.get("roadmap", "")
343
+ roadmap_path = Path(roadmap_value)
344
+ if repo_root is not None:
345
+ resolved_roadmap = roadmap_path if roadmap_path.is_absolute() else repo_root / roadmap_path
346
+ if not resolved_roadmap.exists():
347
+ out.append(f"(FM) `roadmap` does not resolve from repo root: `{roadmap_value}`")
348
+ else:
349
+ expected_sha = _sha256_file(resolved_roadmap)
350
+ if metadata.get("roadmap_sha256") != expected_sha:
351
+ out.append("(FM) `roadmap_sha256` does not match the referenced roadmap")
352
+ try:
353
+ expected_rel = str(resolved_roadmap.resolve().relative_to(repo_root.resolve()))
354
+ except ValueError:
355
+ expected_rel = str(resolved_roadmap.resolve())
356
+ if roadmap_value != expected_rel:
357
+ out.append(f"(FM) `roadmap` must be repo-relative canonical path `{expected_rel}`")
358
+ elif not roadmap_value:
359
+ out.append("(FM) `roadmap` must name the roadmap path")
360
+ if path.name:
361
+ phase = metadata.get("phase", "").upper()
362
+ if phase and not path.name.upper().endswith(f"-{phase}.MD"):
363
+ out.append(f"(FM) `phase` `{phase}` does not match plan filename `{path.name}`")
364
+ return out
365
+
366
+
367
+ def _check_b_lane_index_parses(lanes: List[Lane]) -> Findings:
368
+ out: Findings = []
369
+ if not lanes:
370
+ out.append("(B) `## Lane Index & Dependencies` has no parseable `SL-N — name` stanzas")
371
+ return out
372
+ for lane in lanes:
373
+ if not lane.parallel_safe:
374
+ out.append(f"(B) {lane.sl_id}: missing `Parallel-safe:` line")
375
+ elif lane.parallel_safe not in {"yes", "no", "mixed"}:
376
+ out.append(
377
+ f"(B) {lane.sl_id}: `Parallel-safe: {lane.parallel_safe}` not in {{yes, no, mixed}}"
378
+ )
379
+ return out
380
+
381
+
382
+ def _check_c_dag_acyclic(lanes: List[Lane]) -> Findings:
383
+ """Kahn's algorithm. Nodes with no incoming edges go first; remove them
384
+ and their outgoing edges; repeat. If we can't drain the graph, there's
385
+ a cycle."""
386
+ out: Findings = []
387
+ if not lanes:
388
+ return out
389
+ # Validate referenced IDs exist.
390
+ known_ids = {lane.sl_id for lane in lanes}
391
+ for lane in lanes:
392
+ for dep in lane.depends_on:
393
+ if dep not in known_ids:
394
+ out.append(f"(C) {lane.sl_id}: depends on unknown lane `{dep}`")
395
+ if out:
396
+ return out
397
+ in_deg: Dict[str, int] = {lane.sl_id: len(lane.depends_on) for lane in lanes}
398
+ reverse_edges: Dict[str, List[str]] = {lane.sl_id: [] for lane in lanes}
399
+ for lane in lanes:
400
+ for dep in lane.depends_on:
401
+ reverse_edges[dep].append(lane.sl_id)
402
+ queue = [sl for sl, d in in_deg.items() if d == 0]
403
+ visited = 0
404
+ while queue:
405
+ node = queue.pop(0)
406
+ visited += 1
407
+ for dependent in reverse_edges[node]:
408
+ in_deg[dependent] -= 1
409
+ if in_deg[dependent] == 0:
410
+ queue.append(dependent)
411
+ if visited != len(lanes):
412
+ unresolved = [sl for sl, d in in_deg.items() if d > 0]
413
+ out.append(f"(C) lane DAG has a cycle; unresolved after topological sort: {unresolved}")
414
+ return out
415
+
416
+
417
+ def _git_ls_files(repo_root: Path) -> Optional[List[str]]:
418
+ try:
419
+ r = subprocess.run(
420
+ ["git", "ls-files"],
421
+ cwd=repo_root,
422
+ capture_output=True,
423
+ text=True,
424
+ check=True,
425
+ )
426
+ except Exception:
427
+ return None
428
+ return [line for line in r.stdout.splitlines() if line]
429
+
430
+
431
+ def _glob_to_regex(pattern: str) -> re.Pattern:
432
+ """Minimal glob to regex: ** → any depth; * → any chars not /; everything else literal."""
433
+ s = pattern.strip("`").strip()
434
+ # Escape regex specials, then un-escape our own glob metacharacters.
435
+ esc = re.escape(s)
436
+ esc = esc.replace(r"\*\*", ".*").replace(r"\*", "[^/]*").replace(r"\?", ".")
437
+ return re.compile("^" + esc + "$")
438
+
439
+
440
+ def _check_d_owned_files_disjoint(
441
+ lane_sections: Dict[str, dict],
442
+ repo_root: Optional[Path],
443
+ ) -> Findings:
444
+ out: Findings = []
445
+ tracked: Optional[List[str]] = None
446
+ if repo_root is not None:
447
+ tracked = _git_ls_files(repo_root)
448
+
449
+ # First: exact glob duplication check.
450
+ glob_origin: Dict[str, str] = {}
451
+ for sl_id, parsed in lane_sections.items():
452
+ for glob in parsed["owned_globs"]:
453
+ norm = glob.strip().strip("`")
454
+ if not norm:
455
+ continue
456
+ if norm in glob_origin and glob_origin[norm] != sl_id:
457
+ out.append(
458
+ f"(D) duplicate owned glob `{norm}` in {glob_origin[norm]} and {sl_id}"
459
+ )
460
+ else:
461
+ glob_origin[norm] = sl_id
462
+
463
+ if tracked is None:
464
+ # No repo context — skip expansion, only the exact-duplicate check ran.
465
+ out.append(
466
+ "(D) WARN: not running inside a git repo; owned-file disjointness only checked for exact duplicates"
467
+ )
468
+ return out
469
+
470
+ # Expand each lane's globs to a concrete file set, then intersect.
471
+ lane_file_sets: Dict[str, Set[str]] = {}
472
+ for sl_id, parsed in lane_sections.items():
473
+ matched: Set[str] = set()
474
+ for glob in parsed["owned_globs"]:
475
+ norm = glob.strip().strip("`")
476
+ if not norm:
477
+ continue
478
+ rex = _glob_to_regex(norm)
479
+ matched |= {p for p in tracked if rex.match(p)}
480
+ lane_file_sets[sl_id] = matched
481
+
482
+ sl_ids = sorted(lane_file_sets.keys())
483
+ for i in range(len(sl_ids)):
484
+ for j in range(i + 1, len(sl_ids)):
485
+ a, b = sl_ids[i], sl_ids[j]
486
+ inter = lane_file_sets[a] & lane_file_sets[b]
487
+ if inter:
488
+ sample = sorted(inter)[:5]
489
+ out.append(
490
+ f"(D) file-ownership overlap between {a} and {b}: {len(inter)} files "
491
+ f"(first few: {sample})"
492
+ )
493
+ return out
494
+
495
+
496
+ def _check_owned_files_are_concrete(lane_sections_raw: Dict[str, str]) -> Findings:
497
+ out: Findings = []
498
+ for sl_id, body in lane_sections_raw.items():
499
+ for line in body.splitlines():
500
+ m = _BULLET_VALUE_RE.match(line)
501
+ if not m or m.group(1).lower() != "owned files":
502
+ continue
503
+ raw = m.group(2).strip()
504
+ if raw.lower() in {"(none)", "none", "—"}:
505
+ continue
506
+ for item in [part.strip() for part in raw.split(",") if part.strip()]:
507
+ if not re.fullmatch(r"`[^`]+`", item):
508
+ out.append(
509
+ f"(D) {sl_id}: `Owned files` must be comma-separated backticked concrete paths/globs; "
510
+ f"found prose or unquoted entry `{item}`"
511
+ )
512
+ return out
513
+
514
+
515
+ def _check_e_test_before_impl(lane_sections: Dict[str, dict]) -> Findings:
516
+ out: Findings = []
517
+ for sl_id, parsed in lane_sections.items():
518
+ seen_test = False
519
+ for row in parsed["task_rows"]:
520
+ tt = row["type"]
521
+ if tt == "test":
522
+ seen_test = True
523
+ elif tt == "impl" and not seen_test:
524
+ out.append(
525
+ f"(E) {sl_id} task {row['task_id']}: `impl` appears before any `test` in the same lane"
526
+ )
527
+ return out
528
+
529
+
530
+ def _check_f_interfaces_trace(
531
+ lane_sections: Dict[str, dict],
532
+ lane_sections_raw: Optional[Dict[str, str]] = None,
533
+ ) -> Findings:
534
+ out: Findings = []
535
+ provided: Set[str] = set()
536
+ for parsed in lane_sections.values():
537
+ for sym in parsed["interfaces_provided"]:
538
+ provided.add(_normalize_interface(sym))
539
+ for sl_id, parsed in lane_sections.items():
540
+ # Fall back to raw body scan when the token's annotation was
541
+ # separated from it during parsing (e.g. ``backtick`` (pre-existing)).
542
+ raw_body = (lane_sections_raw or {}).get(sl_id, "")
543
+ for sym in parsed["interfaces_consumed"]:
544
+ norm = _normalize_interface(sym)
545
+ if not norm:
546
+ continue
547
+ if "pre-existing" in sym.lower():
548
+ continue
549
+ # Raw-body fallback: the token itself appears with a (pre-existing)
550
+ # annotation somewhere in the lane body (maybe on a different line).
551
+ if raw_body:
552
+ pattern = rf"`?{re.escape(norm)}`?\s*\(pre-existing\)"
553
+ if re.search(pattern, raw_body, re.IGNORECASE):
554
+ continue
555
+ if norm not in provided:
556
+ out.append(
557
+ f"(F) WARN: {sl_id} consumes `{sym}` but no upstream lane provides it "
558
+ f"(mark '(pre-existing)' if it exists outside P-this-phase)"
559
+ )
560
+ return out
561
+
562
+
563
+ def _check_g_grep_paired_with_tests(src: str) -> Findings:
564
+ """Every acceptance criterion that uses `rg`/`grep` as its sole assertion
565
+ must cite a test file in the same bullet. A bare grep is defeatable by
566
+ renaming a symbol to pass the regex — pair it with a behavioral test."""
567
+ out: Findings = []
568
+ body = _extract_section(src, "Acceptance Criteria")
569
+ if not body:
570
+ return out
571
+ # Iterate top-level bullets. A bullet is a line starting with "- [ ]" or "- [x]".
572
+ # Multi-line bullets continue until the next top-level bullet or blank line.
573
+ lines = body.splitlines()
574
+ current: List[str] = []
575
+ bullets: List[str] = []
576
+ for line in lines:
577
+ if line.lstrip().startswith("- ["):
578
+ if current:
579
+ bullets.append("\n".join(current))
580
+ current = [line]
581
+ elif current:
582
+ if not line.strip():
583
+ bullets.append("\n".join(current))
584
+ current = []
585
+ else:
586
+ current.append(line)
587
+ if current:
588
+ bullets.append("\n".join(current))
589
+ for bullet in bullets:
590
+ low = bullet.lower()
591
+ has_grep = (
592
+ re.search(r"\brg\s+-?\w*\b", bullet) is not None
593
+ or re.search(r"\bgrep\s+-?\w*\b", bullet) is not None
594
+ )
595
+ if not has_grep:
596
+ continue
597
+ # Cite a test file if anything in the bullet looks like a test path:
598
+ # `tests/` or `test_` or a `.py` with a test-ish stem.
599
+ cites_test = (
600
+ "tests/" in low
601
+ or re.search(r"\btest_\w+\.py\b", low) is not None
602
+ or re.search(r"\.py\b", low) is not None
603
+ and ("test" in low or "pytest" in low)
604
+ )
605
+ if not cites_test:
606
+ first_line = bullet.strip().splitlines()[0][:90]
607
+ out.append(
608
+ f"(G) WARN: acceptance criterion uses grep/rg without citing a paired test "
609
+ f"file — rename-defeat risk: {first_line!r}"
610
+ )
611
+ return out
612
+
613
+
614
+ def _check_h_eager_reexport(src: str) -> Findings:
615
+ """If Execution Notes says SL-0 adds re-exports to a __init__.py, require
616
+ the doc to also specify the __getattr__ lazy pattern. Eager top-level
617
+ re-exports break package load when a later lane drops or renames the
618
+ exported symbol."""
619
+ out: Findings = []
620
+ body = _extract_section(src, "Execution Notes")
621
+ if not body:
622
+ return out
623
+ low = body.lower()
624
+ mentions_reexport = ("re-export" in low) or ("reexport" in low)
625
+ mentions_init_py = "__init__.py" in low
626
+ if mentions_reexport and mentions_init_py and "__getattr__" not in low:
627
+ out.append(
628
+ "(H) WARN: Execution Notes mentions re-exports in __init__.py but does not "
629
+ "specify the `__getattr__` lazy pattern — eager top-level imports break "
630
+ "package load when a later lane drops or renames the exported symbol"
631
+ )
632
+ return out
633
+
634
+
635
+ _SPEC_DELTA_DECISIONS = {
636
+ "no_spec_delta",
637
+ "roadmap_amendment",
638
+ "canonical_spec_update",
639
+ "governed_pipeline_refresh",
640
+ "mirror_cutover_required",
641
+ "dotfiles_skill_source_update",
642
+ "human_source_judgment_required",
643
+ }
644
+
645
+
646
+ def _check_i_spec_closeout_plan(src: str) -> Findings:
647
+ out: Findings = []
648
+ body = _extract_section(src, "Spec Closeout Plan")
649
+ if not body.strip():
650
+ return ["(I) missing required `## Spec Closeout Plan` section"]
651
+ for token in ("spec_delta_closeout.v1", "target surfaces", "evidence paths", "metadata_only"):
652
+ if token not in body:
653
+ out.append(f"(I) Spec Closeout Plan missing `{token}`")
654
+ decision_match = re.search(r"(?im)^\s*-\s*decision\s*:\s*`?([a-z_]+)`?\s*$", body)
655
+ if not decision_match:
656
+ out.append("(I) Spec Closeout Plan missing `decision: <literal>`")
657
+ elif decision_match.group(1) not in _SPEC_DELTA_DECISIONS:
658
+ allowed = ", ".join(sorted(_SPEC_DELTA_DECISIONS))
659
+ out.append(
660
+ f"(I) invalid Spec Closeout Plan decision `{decision_match.group(1)}`; allowed: {allowed}"
661
+ )
662
+ return out
663
+
664
+
665
+ def _normalize_interface(sym: str) -> str:
666
+ """Strip backticks, parens, and leading/trailing whitespace. Drop annotations like '(pre-existing)'."""
667
+ s = sym.strip().strip("`").strip()
668
+ # Drop any trailing parenthesized annotation.
669
+ s = re.sub(r"\s*\([^)]*\)\s*$", "", s)
670
+ return s.strip()
671
+
672
+
673
+ # ---------------------------------------------------------------------------
674
+ # Main
675
+
676
+ def _fail(msg: str) -> None:
677
+ print(f"validate_plan_doc: {msg}", file=sys.stderr)
678
+
679
+
680
+ def _check_j_docs_lane(src: str) -> Findings:
681
+ """rigor-v1 P2: warn when no terminal docs-sweep lane is present.
682
+
683
+ plan-phase requires a no-opt-out docs lane so a public-surface change cannot
684
+ silently skip its doc footprint. Autonomy-first: this is a WARN (recorded,
685
+ non-blocking); promote to an error once adopted across the fleet.
686
+ """
687
+ for m in re.finditer(r"^###\s+SL-\d+\s*[—-]\s*(.+?)\s*$", src, re.MULTILINE):
688
+ # Word-bounded so "Docker"/"Docusaurus" don't masquerade as a docs lane.
689
+ if re.search(r"\bdocs?\b|\bdocumentation\b", m.group(1), re.IGNORECASE):
690
+ return []
691
+ if re.search(r"SL-docs|docs[-\s]sweep", src, re.IGNORECASE):
692
+ return []
693
+ return [
694
+ "(J) WARN: no terminal docs-sweep lane found (a `### SL-N — …docs…` lane). "
695
+ "plan-phase requires a no-opt-out docs lane; add a terminal "
696
+ "`SL-N — Documentation sweep` lane or promote this check to an error once adopted."
697
+ ]
698
+
699
+
700
+ def _acceptance_bullets(src: str) -> List[str]:
701
+ body = _extract_section(src, "Acceptance Criteria")
702
+ if not body:
703
+ return []
704
+ bullets: List[str] = []
705
+ current: List[str] = []
706
+ for line in body.splitlines():
707
+ if line.lstrip().startswith("- ["):
708
+ if current:
709
+ bullets.append("\n".join(current))
710
+ current = [line]
711
+ elif current:
712
+ if not line.strip():
713
+ bullets.append("\n".join(current))
714
+ current = []
715
+ else:
716
+ current.append(line)
717
+ if current:
718
+ bullets.append("\n".join(current))
719
+ return bullets
720
+
721
+
722
+ def _check_k_acceptance_testable(src: str) -> Findings:
723
+ """rigor-v1 P5: warn on acceptance criteria that name no proving command.
724
+
725
+ A testable bullet cites something checkable — a command/path/symbol in
726
+ backticks, a path, an HTTP verb+status, or a comparison/return assertion.
727
+ A prose bullet ("users can log in") is not mechanically checkable.
728
+ Autonomy-first WARN; promotion to error is opt-in.
729
+ """
730
+ out: Findings = []
731
+ # Case-sensitive on purpose: uppercase HTTP verbs are real assertions, but
732
+ # lowercase "get"/"post"/"put" are ordinary English. No bare "/\w" (it matched
733
+ # "and/or", "TCP/IP") and no bare "returns" (matched "user returns home").
734
+ testable = re.compile(
735
+ r"`[^`]+`" # backticked command/path/symbol
736
+ r"|\b(GET|POST|PUT|PATCH|DELETE)\b.*?\b\d{3}\b" # HTTP verb (uppercase) + status code
737
+ r"|==|!=|>=|<=|->" # comparison / return arrow
738
+ r"|\bexit code\b|\bstatus code\b"
739
+ r"|\btests?/|\btest_\w|\bpytest\b" # cites a test
740
+ )
741
+ for bullet in _acceptance_bullets(src):
742
+ if not testable.search(bullet):
743
+ first = bullet.strip().splitlines()[0][:90]
744
+ out.append(
745
+ f"(K) WARN: acceptance criterion is not mechanically testable — "
746
+ f"name the proving command/path/assertion: {first!r}"
747
+ )
748
+ return out
749
+
750
+
751
+ def _check_l_ui_visual_verification(src: str) -> Findings:
752
+ """rigor-v1 P6: warn when a plan touches UI/visual files but the Verification
753
+ section names no browser/screenshot step. Autonomy-first WARN."""
754
+ if not re.search(r"\.(tsx|jsx|vue|svelte|css|scss)\b|/components/", src, re.IGNORECASE):
755
+ return []
756
+ verif = _extract_section(src, "Verification") or ""
757
+ if re.search(r"playwright|screenshot|browser|in-chrome|visual", verif, re.IGNORECASE):
758
+ return []
759
+ return [
760
+ "(L) WARN: plan touches UI/visual surfaces but `## Verification` names no "
761
+ "browser/screenshot/Playwright step — add a visual check (and a visually "
762
+ "observable acceptance criterion) for UI changes."
763
+ ]
764
+
765
+
766
+ # Release/package signal (issue #18). Either the explicit release-dispatch
767
+ # mutation in frontmatter, OR a lane owning a release artifact (manifest /
768
+ # version file / changelog / release workflow).
769
+ _RELEASE_ARTIFACT_RE = re.compile(
770
+ r"package\.json|Cargo\.toml|pyproject\.toml|setup\.cfg|"
771
+ r"(?:^|/)VERSION$|version\.txt|__version__\.py|CHANGELOG|"
772
+ r"\.github/workflows/(?:release|publish)",
773
+ re.IGNORECASE,
774
+ )
775
+ # Public-doc surfaces a release/package phase's docs lane must own (or record a
776
+ # no-doc-change decision for).
777
+ _RELEASE_DOC_SURFACE_RE = re.compile(r"README\.md|CHANGELOG|RELEASE", re.IGNORECASE)
778
+
779
+
780
+ def _is_explicit_release_plan(src: str) -> bool:
781
+ """True only when the plan frontmatter *explicitly* declares a release.
782
+
783
+ This is the authoritative marker and the ONLY signal that escalates the
784
+ docs-coverage gap to an ERROR. The artifact-glob heuristic deliberately does
785
+ not satisfy it (an ordinary changelog/dep bump must not become an ERROR).
786
+ """
787
+ fm = _parse_frontmatter_block(src)
788
+ if str(fm.get("phase_loop_mutation", "")).strip().lower() == "release_dispatch":
789
+ return True
790
+ return str(fm.get("phase_type", "")).strip().lower() in (
791
+ "release",
792
+ "package",
793
+ "roadmap_completion",
794
+ "roadmap-completion",
795
+ )
796
+
797
+
798
+ def _is_release_plan(src: str, lane_sections_parsed: Dict[str, dict]) -> bool:
799
+ """Release/package *shape* — explicit frontmatter OR a lane owning a release
800
+ artifact. Governs whether the docs-coverage check fires at all; the
801
+ explicit-vs-heuristic distinction (ERROR vs WARN) is made by callers via
802
+ :func:`_is_explicit_release_plan`."""
803
+ if _is_explicit_release_plan(src):
804
+ return True
805
+ for parsed in lane_sections_parsed.values():
806
+ for glob in parsed.get("owned_globs", []):
807
+ if _RELEASE_ARTIFACT_RE.search(glob):
808
+ return True
809
+ return False
810
+
811
+
812
+ def _parse_frontmatter_block(src: str) -> Dict[str, str]:
813
+ if not src.startswith("---\n"):
814
+ return {}
815
+ end = src.find("\n---", 4)
816
+ if end == -1:
817
+ return {}
818
+ data: Dict[str, str] = {}
819
+ for line in src[4:end].splitlines():
820
+ if ":" not in line:
821
+ continue
822
+ key, value = line.split(":", 1)
823
+ data[key.strip()] = value.strip().strip("'\"")
824
+ return data
825
+
826
+
827
+ def _docs_lane_ids(lanes: List[Lane], lane_sections_parsed: Dict[str, dict]) -> List[str]:
828
+ ids: List[str] = []
829
+ for lane in lanes:
830
+ if re.search(r"\bdocs?\b|\bdocumentation\b", lane.name, re.IGNORECASE):
831
+ ids.append(lane.sl_id)
832
+ return ids
833
+
834
+
835
+ def _check_m_release_docs_coverage(
836
+ src: str,
837
+ lanes: List[Lane],
838
+ lane_sections_parsed: Dict[str, dict],
839
+ ) -> Findings:
840
+ """issue #18: release/package phases must own public-doc surfaces and the
841
+ docs reducer must depend on every producer lane.
842
+
843
+ For phases that **explicitly** declare a release (frontmatter
844
+ ``phase_loop_mutation: release_dispatch`` / a release ``phase_type``) this is
845
+ an ERROR (the issue asks for a blocker). For a heuristic-only release shape
846
+ (a lane owns a release artifact but the plan carries no explicit release
847
+ frontmatter) — and for ordinary phases — the same coverage gap is recorded
848
+ as a WARN to preserve autonomy-first: an ordinary changelog/dep bump must
849
+ not be escalated to a blocking ERROR. Skips entirely when the phase is
850
+ neither (no docs lane is a separate concern handled by check (J)).
851
+ """
852
+ release = _is_release_plan(src, lane_sections_parsed)
853
+ explicit = _is_explicit_release_plan(src)
854
+ docs_ids = _docs_lane_ids(lanes, lane_sections_parsed)
855
+ if not docs_ids:
856
+ # check (J) already covers a wholly-missing docs lane; for explicit
857
+ # release phases restate it as an error here. A heuristic-only release
858
+ # shape stays inert here (its gap is WARN-tier, handled below only when a
859
+ # docs lane exists).
860
+ if explicit:
861
+ return [
862
+ "(M) release/package phase has no docs lane that owns README/"
863
+ "CHANGELOG/release-notes — add a terminal docs lane owning the "
864
+ "public-doc surfaces or record a per-surface no-doc-change decision."
865
+ ]
866
+ return []
867
+
868
+ tag = "" if explicit else "WARN: "
869
+ out: Findings = []
870
+
871
+ # 1) The docs lane(s) must own at least one public-doc surface, OR the plan
872
+ # records an explicit no-doc-change decision (`no_doc_delta` /
873
+ # `freshness-ok` / "no doc change").
874
+ owns_doc_surface = False
875
+ for sl_id in docs_ids:
876
+ for glob in lane_sections_parsed.get(sl_id, {}).get("owned_globs", []):
877
+ if _RELEASE_DOC_SURFACE_RE.search(glob):
878
+ owns_doc_surface = True
879
+ break
880
+ records_no_doc_decision = bool(
881
+ re.search(r"no_doc_delta|no doc change|no-doc-change|freshness-ok", src, re.IGNORECASE)
882
+ )
883
+ if not owns_doc_surface and not records_no_doc_decision:
884
+ out.append(
885
+ f"(M) {tag}docs lane(s) {', '.join(docs_ids)} do not own README/"
886
+ "CHANGELOG/release-notes and the plan records no explicit "
887
+ "no-doc-change decision; a release/package phase must own its public-"
888
+ "doc surfaces or justify why each is already current."
889
+ )
890
+
891
+ # 2) The docs reducer must depend on every producer lane (the SKILL.md:523
892
+ # rule). Producers = every non-docs lane.
893
+ producers = {lane.sl_id for lane in lanes if lane.sl_id not in docs_ids}
894
+ by_id = {lane.sl_id: lane for lane in lanes}
895
+ for sl_id in docs_ids:
896
+ lane = by_id.get(sl_id)
897
+ if lane is None:
898
+ continue
899
+ missing = sorted(producers - set(lane.depends_on), key=_sl_sort_key)
900
+ if missing:
901
+ out.append(
902
+ f"(M) {tag}docs reducer {sl_id} does not depend on every producer "
903
+ f"lane; missing `Depends on`: {', '.join(missing)} — a synthesized "
904
+ "docs/release summary must list every producer lane it summarizes."
905
+ )
906
+ return out
907
+
908
+
909
+ def _sl_sort_key(sl_id: str) -> tuple:
910
+ m = re.match(r"SL-(\d+)", sl_id)
911
+ return (int(m.group(1)),) if m else (10**9, sl_id)
912
+
913
+
914
+ # issue #18 F4 — post-dispatch evidence-reducer lane.
915
+ #
916
+ # A release-DISPATCH phase cuts a tag / runs an external release workflow whose
917
+ # commit SHA and workflow result are NOT KNOWABLE until *after* dispatch. Any
918
+ # evidence doc a pre-dispatch reducer writes therefore carries a placeholder
919
+ # (e.g. `recovery commit pending`). F1's docs-freshness scan BLOCKS a release
920
+ # closeout if those placeholders survive — that is the backstop. F4 makes the
921
+ # back-fill an explicit, *required* planning step: a release-dispatch plan must
922
+ # include a post-dispatch lane that re-opens the pre-dispatch evidence docs and
923
+ # back-fills the now-known SHA / workflow result, so the placeholder is resolved
924
+ # by design rather than tripping the closeout gate.
925
+ #
926
+ # Tokens that mark a lane as the post-dispatch reducer. Word/phrase signals the
927
+ # planner is told to emit (see plan-phase SKILL.md). Matched against the lane
928
+ # NAME and its section body.
929
+ _POST_DISPATCH_LANE_RE = re.compile(
930
+ r"post[-\s]?dispatch|back[-\s]?fill|backfill|"
931
+ r"reconcile.*(?:sha|commit|workflow|release)|"
932
+ r"(?:sha|commit|workflow|tag).*back[-\s]?fill",
933
+ re.IGNORECASE,
934
+ )
935
+
936
+
937
+ def _is_release_dispatch_plan(src: str) -> bool:
938
+ """True only for an explicit ``phase_loop_mutation: release_dispatch`` plan.
939
+
940
+ F4 is scoped to the *dispatch* case specifically (not every explicit
941
+ release ``phase_type``): only a dispatch cuts a tag / runs a workflow whose
942
+ SHA + result are unknowable pre-dispatch, which is what creates the
943
+ placeholder a post-dispatch reducer must back-fill.
944
+ """
945
+ fm = _parse_frontmatter_block(src)
946
+ return str(fm.get("phase_loop_mutation", "")).strip().lower() == "release_dispatch"
947
+
948
+
949
+ def _has_post_dispatch_reducer_lane(
950
+ lanes: List[Lane],
951
+ lane_sections_raw: Dict[str, str],
952
+ ) -> bool:
953
+ """True if some lane is shaped as a post-dispatch evidence-reducer lane.
954
+
955
+ Detection is signal-based (mirrors how check (J)/(M) detect a docs lane):
956
+ a lane whose name or body names the post-dispatch back-fill of the
957
+ now-known SHA / workflow result.
958
+ """
959
+ for lane in lanes:
960
+ if _POST_DISPATCH_LANE_RE.search(lane.name):
961
+ return True
962
+ for body in lane_sections_raw.values():
963
+ if _POST_DISPATCH_LANE_RE.search(body):
964
+ return True
965
+ return False
966
+
967
+
968
+ def _check_n_post_dispatch_reducer(
969
+ src: str,
970
+ lanes: List[Lane],
971
+ lane_sections_raw: Dict[str, str],
972
+ lane_sections_parsed: Dict[str, dict],
973
+ ) -> Findings:
974
+ """issue #18 F4: a release-dispatch phase must include a post-dispatch
975
+ evidence-reducer lane.
976
+
977
+ A release-dispatch phase writes evidence docs that reference a commit SHA /
978
+ workflow result unknowable before the tag is cut; a pre-dispatch reducer
979
+ necessarily leaves a placeholder. The plan must therefore carry a
980
+ post-dispatch lane that re-opens those evidence docs and back-fills the
981
+ now-known values (otherwise F1's placeholder scan blocks the closeout).
982
+
983
+ ERROR for explicit ``release_dispatch`` phases missing the lane (mirrors the
984
+ F2 explicit-release ERROR posture). WARN otherwise — for a non-dispatch
985
+ release shape we record the advisory but never escalate, preserving
986
+ autonomy-first. A non-release plan is inert (no finding)."""
987
+ if _has_post_dispatch_reducer_lane(lanes, lane_sections_raw):
988
+ return []
989
+ if _is_release_dispatch_plan(src):
990
+ return [
991
+ "(N) release-dispatch phase has no post-dispatch evidence-reducer "
992
+ "lane; a dispatch references a commit SHA / workflow result that is "
993
+ "unknowable before the tag is cut, so the plan must include a "
994
+ "terminal lane that re-opens the pre-dispatch evidence docs and "
995
+ "back-fills the now-known SHA / workflow result (otherwise the "
996
+ "docs-freshness placeholder scan blocks the closeout)."
997
+ ]
998
+ # A non-dispatch release *shape* gets a WARN; ordinary phases stay inert.
999
+ if _is_release_plan(src, lane_sections_parsed):
1000
+ return [
1001
+ "(N) WARN: release-shaped plan has no post-dispatch evidence-reducer "
1002
+ "lane; if this phase dispatches an external release (tag/workflow), "
1003
+ "add a lane that back-fills the now-known SHA / workflow result into "
1004
+ "the pre-dispatch evidence docs."
1005
+ ]
1006
+ return []
1007
+
1008
+
1009
+ def main(argv: List[str]) -> int:
1010
+ if len(argv) != 2:
1011
+ _fail("usage: validate_plan_doc.py <plan-path>")
1012
+ return 2
1013
+ path = Path(argv[1])
1014
+ if not path.exists():
1015
+ _fail(f"plan doc not found: {path}")
1016
+ return 2
1017
+
1018
+ src = _read(path)
1019
+
1020
+ # Work out if we're inside a git repo for the disjointness expansion.
1021
+ repo_root: Optional[Path] = None
1022
+ try:
1023
+ r = subprocess.run(
1024
+ ["git", "rev-parse", "--show-toplevel"],
1025
+ cwd=path.parent,
1026
+ capture_output=True,
1027
+ text=True,
1028
+ check=True,
1029
+ )
1030
+ repo_root = Path(r.stdout.strip())
1031
+ except Exception:
1032
+ repo_root = None
1033
+
1034
+ findings: Findings = []
1035
+ findings.extend(_check_frontmatter(src, path, repo_root))
1036
+ findings.extend(_check_a_required_headings(src))
1037
+
1038
+ lane_index_body = _extract_section(src, "Lane Index & Dependencies")
1039
+ lanes = _parse_lane_index(lane_index_body)
1040
+ findings.extend(_check_b_lane_index_parses(lanes))
1041
+ findings.extend(_check_c_dag_acyclic(lanes))
1042
+
1043
+ lanes_body = _extract_section(src, "Lanes")
1044
+ lane_sections_raw = _split_lane_sections(lanes_body)
1045
+ lane_sections_parsed = {
1046
+ sl_id: _parse_lane_section(body) for sl_id, body in lane_sections_raw.items()
1047
+ }
1048
+
1049
+ # Every lane listed in the Lane Index should also have a ### section.
1050
+ missing_sections = [
1051
+ lane.sl_id for lane in lanes if lane.sl_id not in lane_sections_parsed
1052
+ ]
1053
+ for sl_id in missing_sections:
1054
+ findings.append(f"(B) {sl_id}: no matching `### {sl_id}` section under `## Lanes`")
1055
+
1056
+ findings.extend(_check_owned_files_are_concrete(lane_sections_raw))
1057
+ findings.extend(_check_d_owned_files_disjoint(lane_sections_parsed, repo_root))
1058
+ findings.extend(_check_e_test_before_impl(lane_sections_parsed))
1059
+ findings.extend(_check_f_interfaces_trace(lane_sections_parsed, lane_sections_raw))
1060
+ findings.extend(_check_g_grep_paired_with_tests(src))
1061
+ findings.extend(_check_h_eager_reexport(src))
1062
+ findings.extend(_check_i_spec_closeout_plan(src))
1063
+ findings.extend(_check_j_docs_lane(src))
1064
+ findings.extend(_check_k_acceptance_testable(src))
1065
+ findings.extend(_check_l_ui_visual_verification(src))
1066
+ findings.extend(_check_m_release_docs_coverage(src, lanes, lane_sections_parsed))
1067
+ findings.extend(_check_n_post_dispatch_reducer(src, lanes, lane_sections_raw, lane_sections_parsed))
1068
+
1069
+ # Partition findings into errors vs warnings.
1070
+ errors = [f for f in findings if "WARN" not in f]
1071
+ warnings = [f for f in findings if "WARN" in f]
1072
+
1073
+ if warnings:
1074
+ for w in warnings:
1075
+ print(w, file=sys.stderr)
1076
+ if errors:
1077
+ for e in errors:
1078
+ print(e, file=sys.stderr)
1079
+ print(
1080
+ f"validate_plan_doc: {len(errors)} error(s), {len(warnings)} warning(s)",
1081
+ file=sys.stderr,
1082
+ )
1083
+ return 1
1084
+
1085
+ print(
1086
+ f"validate_plan_doc: OK — {len(lanes)} lanes, {len(warnings)} warning(s)"
1087
+ )
1088
+ return 0
1089
+
1090
+
1091
+ if __name__ == "__main__":
1092
+ sys.exit(main(sys.argv))