@opengsd/gsd-path 1.0.0

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 (398) hide show
  1. package/AGENTS.md +397 -0
  2. package/DOCS.md +341 -0
  3. package/FULL.md +475 -0
  4. package/GUIDE.md +23 -0
  5. package/HOOKS.md +273 -0
  6. package/LICENSE +21 -0
  7. package/MIGRATE.md +100 -0
  8. package/QUICK.md +146 -0
  9. package/README.md +280 -0
  10. package/RUNTIME.md +229 -0
  11. package/UPDATE.md +196 -0
  12. package/WORKFLOW.md +683 -0
  13. package/package.json +80 -0
  14. package/platforms/claude/dispatch.md +63 -0
  15. package/platforms/copilot/dispatch.md +61 -0
  16. package/platforms/cursor/agent.md +17 -0
  17. package/platforms/cursor/dispatch.md +63 -0
  18. package/platforms/grok/dispatch.md +61 -0
  19. package/platforms/kimi/dispatch.md +64 -0
  20. package/platforms/kiro/dispatch.md +58 -0
  21. package/platforms/opencode/dispatch.md +62 -0
  22. package/platforms/qwen/dispatch.md +60 -0
  23. package/platforms/shared-agents/dispatch.md +131 -0
  24. package/plugin.json +14 -0
  25. package/scripts/_common.py +180 -0
  26. package/scripts/archive_milestone.py +2265 -0
  27. package/scripts/bootstrap_repository.py +710 -0
  28. package/scripts/build_state.py +854 -0
  29. package/scripts/check_docs_audit.py +463 -0
  30. package/scripts/check_handoffs.py +1855 -0
  31. package/scripts/check_task_briefs.py +376 -0
  32. package/scripts/check_update.py +93 -0
  33. package/scripts/core_hook_gate.py +34 -0
  34. package/scripts/core_hook_settings.py +119 -0
  35. package/scripts/detect_project.py +1517 -0
  36. package/scripts/discussion_records.py +619 -0
  37. package/scripts/discussion_validate.py +1321 -0
  38. package/scripts/dispatch_driver.py +1812 -0
  39. package/scripts/git_guard.py +759 -0
  40. package/scripts/guard_hook.py +2124 -0
  41. package/scripts/install.mjs +3239 -0
  42. package/scripts/install.py +3008 -0
  43. package/scripts/integration.py +1433 -0
  44. package/scripts/isolation.py +3538 -0
  45. package/scripts/lean_verification.py +256 -0
  46. package/scripts/loop_run.py +837 -0
  47. package/scripts/migrate_core.py +169 -0
  48. package/scripts/pipeline_diagnose.py +715 -0
  49. package/scripts/pipeline_git.py +940 -0
  50. package/scripts/pipeline_state.py +2291 -0
  51. package/scripts/pipeline_undo.py +1039 -0
  52. package/scripts/promote_lookahead.py +415 -0
  53. package/scripts/review_findings.py +714 -0
  54. package/scripts/review_panel.py +553 -0
  55. package/scripts/skill-resources.json +340 -0
  56. package/scripts/state_checkpoint.py +1039 -0
  57. package/scripts/state_promote.py +743 -0
  58. package/scripts/status_runtime.py +122 -0
  59. package/scripts/sync_skill_resources.py +259 -0
  60. package/scripts/token_budget.py +146 -0
  61. package/scripts/wizard.mjs +179 -0
  62. package/scripts/workflow_run.py +117 -0
  63. package/skills/gsd-path/BUILD.md +700 -0
  64. package/skills/gsd-path/DECIDE.md +115 -0
  65. package/skills/gsd-path/DEFINE.md +297 -0
  66. package/skills/gsd-path/DOCS-AUDIT.md +190 -0
  67. package/skills/gsd-path/INSPECT.md +167 -0
  68. package/skills/gsd-path/PLAN.md +411 -0
  69. package/skills/gsd-path/RESEARCH.md +159 -0
  70. package/skills/gsd-path/ROADMAP.md +215 -0
  71. package/skills/gsd-path/SHIP.md +412 -0
  72. package/skills/gsd-path/SKILL.md +471 -0
  73. package/skills/gsd-path/agents/openai.yaml +6 -0
  74. package/skills/gsd-path/references/codebase-mapper.md +52 -0
  75. package/skills/gsd-path/references/coder.md +90 -0
  76. package/skills/gsd-path/references/decider.md +36 -0
  77. package/skills/gsd-path/references/dispatch.md +131 -0
  78. package/skills/gsd-path/references/docs-auditor.md +60 -0
  79. package/skills/gsd-path/references/planner.md +104 -0
  80. package/skills/gsd-path/references/researcher.md +42 -0
  81. package/skills/gsd-path/references/reviewer.md +188 -0
  82. package/skills/gsd-path/references/roadmapper.md +48 -0
  83. package/skills/gsd-path/scripts/_common.py +180 -0
  84. package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
  85. package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
  86. package/skills/gsd-path/scripts/build_state.py +854 -0
  87. package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
  88. package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
  89. package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
  90. package/skills/gsd-path/scripts/check_update.py +93 -0
  91. package/skills/gsd-path/scripts/detect_project.py +1517 -0
  92. package/skills/gsd-path/scripts/discussion_records.py +619 -0
  93. package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
  94. package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
  95. package/skills/gsd-path/scripts/integration.py +1433 -0
  96. package/skills/gsd-path/scripts/isolation.py +3538 -0
  97. package/skills/gsd-path/scripts/lean_verification.py +256 -0
  98. package/skills/gsd-path/scripts/loop_run.py +837 -0
  99. package/skills/gsd-path/scripts/pipeline_git.py +940 -0
  100. package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
  101. package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
  102. package/skills/gsd-path/scripts/review_findings.py +714 -0
  103. package/skills/gsd-path/scripts/review_panel.py +553 -0
  104. package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
  105. package/skills/gsd-path/scripts/state_promote.py +743 -0
  106. package/skills/gsd-path/scripts/token_budget.py +146 -0
  107. package/skills/gsd-path/scripts/workflow_run.py +117 -0
  108. package/skills/gsd-path/templates/answers.md +29 -0
  109. package/skills/gsd-path/templates/archive-manifest.md +37 -0
  110. package/skills/gsd-path/templates/charter.md +50 -0
  111. package/skills/gsd-path/templates/codebase.md +46 -0
  112. package/skills/gsd-path/templates/dialogue.md +21 -0
  113. package/skills/gsd-path/templates/docs-audit.md +69 -0
  114. package/skills/gsd-path/templates/evidence.md +24 -0
  115. package/skills/gsd-path/templates/final-review.md +27 -0
  116. package/skills/gsd-path/templates/gap-review.md +20 -0
  117. package/skills/gsd-path/templates/intent.md +98 -0
  118. package/skills/gsd-path/templates/loop.md +59 -0
  119. package/skills/gsd-path/templates/patch-findings.md +16 -0
  120. package/skills/gsd-path/templates/plan-panel.md +25 -0
  121. package/skills/gsd-path/templates/plan.md +89 -0
  122. package/skills/gsd-path/templates/repository.md +16 -0
  123. package/skills/gsd-path/templates/research-handoff.md +25 -0
  124. package/skills/gsd-path/templates/roadmap.md +38 -0
  125. package/skills/gsd-path/templates/skeptic.md +36 -0
  126. package/skills/gsd-path/templates/state.md +27 -0
  127. package/skills/gsd-path/templates/synthesis.md +36 -0
  128. package/skills/gsd-path/templates/task.md +74 -0
  129. package/skills/gsd-path/templates/wave-panel.md +25 -0
  130. package/skills/gsd-path/templates/wave-review.md +64 -0
  131. package/skills/gsd-path-build/SKILL.md +700 -0
  132. package/skills/gsd-path-build/agents/openai.yaml +6 -0
  133. package/skills/gsd-path-build/references/coder.md +90 -0
  134. package/skills/gsd-path-build/references/dispatch.md +131 -0
  135. package/skills/gsd-path-build/references/reviewer.md +188 -0
  136. package/skills/gsd-path-build/scripts/_common.py +180 -0
  137. package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
  138. package/skills/gsd-path-build/scripts/build_state.py +854 -0
  139. package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
  140. package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
  141. package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
  142. package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
  143. package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
  144. package/skills/gsd-path-build/scripts/integration.py +1433 -0
  145. package/skills/gsd-path-build/scripts/isolation.py +3538 -0
  146. package/skills/gsd-path-build/scripts/loop_run.py +837 -0
  147. package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
  148. package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
  149. package/skills/gsd-path-build/scripts/review_findings.py +714 -0
  150. package/skills/gsd-path-build/scripts/review_panel.py +553 -0
  151. package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
  152. package/skills/gsd-path-build/scripts/state_promote.py +743 -0
  153. package/skills/gsd-path-build/scripts/token_budget.py +146 -0
  154. package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
  155. package/skills/gsd-path-build/templates/skeptic.md +36 -0
  156. package/skills/gsd-path-build/templates/task.md +74 -0
  157. package/skills/gsd-path-build/templates/wave-panel.md +25 -0
  158. package/skills/gsd-path-build/templates/wave-review.md +64 -0
  159. package/skills/gsd-path-decide/SKILL.md +115 -0
  160. package/skills/gsd-path-decide/agents/openai.yaml +6 -0
  161. package/skills/gsd-path-decide/references/decider.md +36 -0
  162. package/skills/gsd-path-decide/references/dispatch.md +131 -0
  163. package/skills/gsd-path-decide/scripts/_common.py +180 -0
  164. package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
  165. package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
  166. package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
  167. package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
  168. package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
  169. package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
  170. package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
  171. package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
  172. package/skills/gsd-path-decide/templates/synthesis.md +36 -0
  173. package/skills/gsd-path-define/SKILL.md +297 -0
  174. package/skills/gsd-path-define/agents/openai.yaml +6 -0
  175. package/skills/gsd-path-define/scripts/_common.py +180 -0
  176. package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
  177. package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
  178. package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
  179. package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
  180. package/skills/gsd-path-define/scripts/isolation.py +3538 -0
  181. package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
  182. package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
  183. package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
  184. package/skills/gsd-path-define/scripts/state_promote.py +743 -0
  185. package/skills/gsd-path-define/templates/charter.md +50 -0
  186. package/skills/gsd-path-define/templates/intent.md +98 -0
  187. package/skills/gsd-path-define/templates/state.md +27 -0
  188. package/skills/gsd-path-discuss/SKILL.md +162 -0
  189. package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
  190. package/skills/gsd-path-discuss/scripts/_common.py +180 -0
  191. package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
  192. package/skills/gsd-path-discuss/templates/answers.md +29 -0
  193. package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
  194. package/skills/gsd-path-docs-audit/SKILL.md +190 -0
  195. package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
  196. package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
  197. package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
  198. package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
  199. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
  200. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
  201. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
  202. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
  203. package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
  204. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
  205. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
  206. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
  207. package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
  208. package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
  209. package/skills/gsd-path-forensics/SKILL.md +39 -0
  210. package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
  211. package/skills/gsd-path-forensics/scripts/_common.py +180 -0
  212. package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
  213. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
  214. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
  215. package/skills/gsd-path-inspect/SKILL.md +167 -0
  216. package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
  217. package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
  218. package/skills/gsd-path-inspect/references/dispatch.md +131 -0
  219. package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
  220. package/skills/gsd-path-inspect/scripts/_common.py +180 -0
  221. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
  222. package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
  223. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
  224. package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
  225. package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
  226. package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
  227. package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
  228. package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
  229. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
  230. package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
  231. package/skills/gsd-path-inspect/templates/codebase.md +46 -0
  232. package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
  233. package/skills/gsd-path-inspect/templates/state.md +27 -0
  234. package/skills/gsd-path-loop/SKILL.md +98 -0
  235. package/skills/gsd-path-loop/agents/openai.yaml +6 -0
  236. package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
  237. package/skills/gsd-path-loop/scripts/_common.py +180 -0
  238. package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
  239. package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
  240. package/skills/gsd-path-loop/templates/loop.md +59 -0
  241. package/skills/gsd-path-migrate/SKILL.md +134 -0
  242. package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
  243. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
  244. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
  245. package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
  246. package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
  247. package/skills/gsd-path-plan/SKILL.md +411 -0
  248. package/skills/gsd-path-plan/agents/openai.yaml +6 -0
  249. package/skills/gsd-path-plan/references/dispatch.md +131 -0
  250. package/skills/gsd-path-plan/references/planner.md +104 -0
  251. package/skills/gsd-path-plan/scripts/_common.py +180 -0
  252. package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
  253. package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
  254. package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
  255. package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
  256. package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
  257. package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
  258. package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
  259. package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
  260. package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
  261. package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
  262. package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
  263. package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
  264. package/skills/gsd-path-plan/templates/plan.md +89 -0
  265. package/skills/gsd-path-plan/templates/task.md +74 -0
  266. package/skills/gsd-path-research/SKILL.md +159 -0
  267. package/skills/gsd-path-research/agents/openai.yaml +6 -0
  268. package/skills/gsd-path-research/references/dispatch.md +131 -0
  269. package/skills/gsd-path-research/references/researcher.md +42 -0
  270. package/skills/gsd-path-research/scripts/_common.py +180 -0
  271. package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
  272. package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
  273. package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
  274. package/skills/gsd-path-research/scripts/isolation.py +3538 -0
  275. package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
  276. package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
  277. package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
  278. package/skills/gsd-path-research/scripts/state_promote.py +743 -0
  279. package/skills/gsd-path-research/templates/evidence.md +24 -0
  280. package/skills/gsd-path-research/templates/research-handoff.md +25 -0
  281. package/skills/gsd-path-roadmap/SKILL.md +215 -0
  282. package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
  283. package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
  284. package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
  285. package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
  286. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
  287. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
  288. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
  289. package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
  290. package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
  291. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
  292. package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
  293. package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
  294. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
  295. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
  296. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
  297. package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
  298. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
  299. package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
  300. package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
  301. package/skills/gsd-path-ship/SKILL.md +412 -0
  302. package/skills/gsd-path-ship/agents/openai.yaml +6 -0
  303. package/skills/gsd-path-ship/references/dispatch.md +131 -0
  304. package/skills/gsd-path-ship/references/reviewer.md +188 -0
  305. package/skills/gsd-path-ship/scripts/_common.py +180 -0
  306. package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
  307. package/skills/gsd-path-ship/scripts/build_state.py +854 -0
  308. package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
  309. package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
  310. package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
  311. package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
  312. package/skills/gsd-path-ship/scripts/integration.py +1433 -0
  313. package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
  314. package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
  315. package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
  316. package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
  317. package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
  318. package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
  319. package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
  320. package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
  321. package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
  322. package/skills/gsd-path-ship/templates/final-review.md +27 -0
  323. package/skills/gsd-path-ship/templates/gap-review.md +20 -0
  324. package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
  325. package/skills/gsd-path-ship/templates/wave-review.md +64 -0
  326. package/skills/gsd-path-undo/SKILL.md +45 -0
  327. package/skills/gsd-path-undo/agents/openai.yaml +6 -0
  328. package/skills/gsd-path-undo/scripts/_common.py +180 -0
  329. package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
  330. package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
  331. package/skills/path/BUILD.md +700 -0
  332. package/skills/path/DECIDE.md +115 -0
  333. package/skills/path/DEFINE.md +297 -0
  334. package/skills/path/DOCS-AUDIT.md +190 -0
  335. package/skills/path/INSPECT.md +167 -0
  336. package/skills/path/PLAN.md +411 -0
  337. package/skills/path/RESEARCH.md +159 -0
  338. package/skills/path/ROADMAP.md +215 -0
  339. package/skills/path/SHIP.md +412 -0
  340. package/skills/path/SKILL.md +471 -0
  341. package/skills/path/agents/openai.yaml +6 -0
  342. package/skills/path/references/codebase-mapper.md +52 -0
  343. package/skills/path/references/coder.md +90 -0
  344. package/skills/path/references/decider.md +36 -0
  345. package/skills/path/references/dispatch.md +131 -0
  346. package/skills/path/references/docs-auditor.md +60 -0
  347. package/skills/path/references/planner.md +104 -0
  348. package/skills/path/references/researcher.md +42 -0
  349. package/skills/path/references/reviewer.md +188 -0
  350. package/skills/path/references/roadmapper.md +48 -0
  351. package/skills/path/scripts/_common.py +180 -0
  352. package/skills/path/scripts/archive_milestone.py +2265 -0
  353. package/skills/path/scripts/bootstrap_repository.py +710 -0
  354. package/skills/path/scripts/build_state.py +854 -0
  355. package/skills/path/scripts/check_docs_audit.py +463 -0
  356. package/skills/path/scripts/check_handoffs.py +1855 -0
  357. package/skills/path/scripts/check_task_briefs.py +376 -0
  358. package/skills/path/scripts/check_update.py +93 -0
  359. package/skills/path/scripts/detect_project.py +1517 -0
  360. package/skills/path/scripts/discussion_records.py +619 -0
  361. package/skills/path/scripts/discussion_validate.py +1321 -0
  362. package/skills/path/scripts/dispatch_driver.py +1812 -0
  363. package/skills/path/scripts/integration.py +1433 -0
  364. package/skills/path/scripts/isolation.py +3538 -0
  365. package/skills/path/scripts/lean_verification.py +256 -0
  366. package/skills/path/scripts/loop_run.py +837 -0
  367. package/skills/path/scripts/pipeline_git.py +940 -0
  368. package/skills/path/scripts/pipeline_state.py +2291 -0
  369. package/skills/path/scripts/promote_lookahead.py +415 -0
  370. package/skills/path/scripts/review_findings.py +714 -0
  371. package/skills/path/scripts/review_panel.py +553 -0
  372. package/skills/path/scripts/state_checkpoint.py +1039 -0
  373. package/skills/path/scripts/state_promote.py +743 -0
  374. package/skills/path/scripts/token_budget.py +146 -0
  375. package/skills/path/scripts/workflow_run.py +117 -0
  376. package/skills/path/templates/answers.md +29 -0
  377. package/skills/path/templates/archive-manifest.md +37 -0
  378. package/skills/path/templates/charter.md +50 -0
  379. package/skills/path/templates/codebase.md +46 -0
  380. package/skills/path/templates/dialogue.md +21 -0
  381. package/skills/path/templates/docs-audit.md +69 -0
  382. package/skills/path/templates/evidence.md +24 -0
  383. package/skills/path/templates/final-review.md +27 -0
  384. package/skills/path/templates/gap-review.md +20 -0
  385. package/skills/path/templates/intent.md +98 -0
  386. package/skills/path/templates/loop.md +59 -0
  387. package/skills/path/templates/patch-findings.md +16 -0
  388. package/skills/path/templates/plan-panel.md +25 -0
  389. package/skills/path/templates/plan.md +89 -0
  390. package/skills/path/templates/repository.md +16 -0
  391. package/skills/path/templates/research-handoff.md +25 -0
  392. package/skills/path/templates/roadmap.md +38 -0
  393. package/skills/path/templates/skeptic.md +36 -0
  394. package/skills/path/templates/state.md +27 -0
  395. package/skills/path/templates/synthesis.md +36 -0
  396. package/skills/path/templates/task.md +74 -0
  397. package/skills/path/templates/wave-panel.md +25 -0
  398. package/skills/path/templates/wave-review.md +64 -0
@@ -0,0 +1,1812 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic dispatch driver for one build wave.
3
+
4
+ Composes the canonical helpers in the build contract's order: recover, ready,
5
+ brief lint, task isolation, child dispatch, Verify, landing, verify ledger,
6
+ retire. Every action re-derives its state from Git, the task files, and the
7
+ dispatch records under the Git common directory, so a call may be repeated
8
+ after a crash or a tool timeout. The driver never edits task frontmatter,
9
+ commits, or creates a checkout itself: isolation.py and build_state.py own
10
+ those. Children are spawned through an owner-supplied command that reads the
11
+ brief on stdin; the owner therefore owns the child's permission posture.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import errno
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import shlex
23
+ import signal
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import BinaryIO, Callable, Dict, List, Optional
31
+
32
+ try:
33
+ from scripts import (_common, archive_milestone, build_state, discussion_validate, isolation,
34
+ pipeline_state, review_findings, review_panel, workflow_run)
35
+ from scripts import check_handoffs as contracts
36
+ except ImportError: # bundled copy inside a skill's scripts directory
37
+ import _common
38
+ import archive_milestone
39
+ import build_state
40
+ import check_handoffs as contracts
41
+ import discussion_validate
42
+ import isolation
43
+ import pipeline_state
44
+ import review_findings
45
+ import review_panel
46
+ import workflow_run
47
+
48
+ if os.name == "nt":
49
+ import msvcrt
50
+ else:
51
+ import fcntl
52
+
53
+ RESULT_LINE = re.compile(r"(?im)^RESULT:\s*(?P<task>\S+)\s+(?P<verdict>ready|blocked)\s*$")
54
+ QUESTION_MARK = "NEEDS-ORCHESTRATOR:"
55
+ ANSWER_MARK = "Orchestrator answer:"
56
+ OUTPUT_TAIL_LINES = 20
57
+ POLL_SECONDS = 1 # ponytail: fixed poll cadence; make it an owner value if it ever matters
58
+
59
+
60
+ ORPHANED = "child wrapper exited without recording a result"
61
+
62
+
63
+ class DriverStop(RuntimeError):
64
+ """A typed stop: the receipt carries the reason; the parent decides."""
65
+
66
+ def __init__(self, reason: str, **details: object) -> None:
67
+ super().__init__(reason)
68
+ self.details = details
69
+
70
+
71
+ STOP_ERRORS = (DriverStop, isolation.IsolationError, build_state.BuildStateError,
72
+ contracts.HandoffError, review_findings.ReviewFindingsError,
73
+ pipeline_state.PipelineStateError, archive_milestone.ArchiveError)
74
+ RECOVERY_BLOCKED = "recovery blocked"
75
+ # The stops the build contract answers with build/blocked when the round raises them (steps 1 and 2);
76
+ # the same stops from review, panel, or skeptics stay with the parent.
77
+ BLOCKING_STOPS = {"dependency-deadlock": "dependency deadlock", RECOVERY_BLOCKED: "blocked recovery"}
78
+
79
+
80
+ def now() -> str:
81
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
82
+
83
+
84
+ def tail(text: str) -> str:
85
+ return "\n".join(text.splitlines()[-OUTPUT_TAIL_LINES:])
86
+
87
+
88
+ # --- dispatch records ------------------------------------------------------
89
+
90
+
91
+ def milestone_slug(primary: Path) -> str:
92
+ """The bound branch as a path segment; every record and ledger is scoped to one milestone."""
93
+ return isolation.require_bound(primary).replace("/", "-")
94
+
95
+
96
+ def records_root(primary: Path) -> Path:
97
+ return isolation.common_git_dir(primary) / "gsd-path" / "dispatch" / milestone_slug(primary)
98
+
99
+
100
+ def budget_ledger(primary: Path) -> Path:
101
+ return isolation.common_git_dir(primary) / "gsd-path" / "budget" / f"{milestone_slug(primary)}.json"
102
+
103
+
104
+ def attempts_used(root: Path, task_id: str) -> int:
105
+ """Dispatches of this task in this milestone, excluding question redispatches."""
106
+ return sum(1 for path in (root / task_id).glob("attempt-*/state.json")
107
+ if load_state(path).get("origin") == "dispatch")
108
+
109
+
110
+ def acquire_lock(primary: Path) -> BinaryIO:
111
+ root = records_root(primary)
112
+ root.mkdir(parents=True, exist_ok=True)
113
+ handle = open(root / "driver.lock", "a+b")
114
+ try:
115
+ if os.name == "nt":
116
+ handle.seek(0)
117
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
118
+ else:
119
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
120
+ except OSError as error:
121
+ handle.close()
122
+ if error.errno in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
123
+ raise DriverStop("another dispatch_driver invocation holds the lock") from error
124
+ raise
125
+ return handle
126
+
127
+
128
+ def attempt_number(path: Path) -> int:
129
+ return int(path.name.split("-")[1])
130
+
131
+
132
+ def load_state(path: Path) -> Dict[str, object]:
133
+ return json.loads(path.read_text(encoding="utf-8"))
134
+
135
+
136
+ def save_state(path: Path, state: Dict[str, object]) -> None:
137
+ _common.atomic_write(path, json.dumps(state, indent=2, sort_keys=True) + "\n")
138
+
139
+
140
+ def update_state(state: Dict[str, object], **changes: object) -> None:
141
+ path = Path(str(state["_path"]))
142
+ save_state(path, dict(load_state(path), **changes))
143
+ state.update(changes)
144
+
145
+
146
+ def latest_states(root: Path) -> List[Dict[str, object]]:
147
+ """The newest attempt record of every task, in task-id order."""
148
+ states = []
149
+ if root.is_dir():
150
+ for task_dir in sorted(root.iterdir()):
151
+ attempts = sorted(task_dir.glob("attempt-*/state.json"),
152
+ key=lambda path: attempt_number(path.parent))
153
+ if attempts:
154
+ states.append(read_attempt(attempts[-1]))
155
+ return states
156
+
157
+
158
+ def read_attempt(path: Path) -> Dict[str, object]:
159
+ """One attempt's state.json merged with the exit receipt its child wrapper writes."""
160
+ state = dict(load_state(path), _path=str(path))
161
+ exit_path = path.with_name("exit.json")
162
+ if exit_path.is_file():
163
+ state.update(load_state(exit_path))
164
+ return state
165
+
166
+
167
+ def child_running(state: Dict[str, object]) -> bool:
168
+ return state.get("finished_at") is None and bool(state.get("pid")) and process_alive(int(state["pid"]))
169
+
170
+
171
+ def process_alive(pid: int) -> bool:
172
+ try:
173
+ os.kill(pid, 0)
174
+ except ProcessLookupError:
175
+ return False
176
+ return True
177
+
178
+
179
+ # --- child process ---------------------------------------------------------
180
+
181
+
182
+ def final_message(stdout: str) -> str:
183
+ """The child's final text from Claude JSON, Codex JSONL, or plain text."""
184
+ try:
185
+ document = json.loads(stdout)
186
+ if isinstance(document, dict):
187
+ return str(document.get("result", ""))
188
+ except json.JSONDecodeError:
189
+ pass
190
+ text = None
191
+ for line in stdout.splitlines():
192
+ try:
193
+ event = json.loads(line)
194
+ except json.JSONDecodeError:
195
+ continue
196
+ item = event.get("item") if isinstance(event, dict) else None
197
+ if isinstance(item, dict) and event.get("type") == "item.completed" \
198
+ and item.get("type") == "agent_message":
199
+ text = str(item.get("text", ""))
200
+ return stdout if text is None else text
201
+
202
+
203
+ def child_main(state_path: Path) -> int:
204
+ """Run the owner's child command under the isolate; record its exit in a separate receipt."""
205
+ state = load_state(state_path)
206
+ attempt_dir = state_path.parent
207
+ with open(attempt_dir / "brief.md", "rb") as brief, \
208
+ open(attempt_dir / "stdout", "wb") as out, open(attempt_dir / "stderr", "wb") as err:
209
+ process = subprocess.Popen(state["command"], cwd=state["worktree"], stdin=brief,
210
+ stdout=out, stderr=err, start_new_session=True)
211
+ try:
212
+ exit_code: Optional[int] = process.wait(timeout=state.get("child_timeout"))
213
+ timed_out = False
214
+ except subprocess.TimeoutExpired:
215
+ os.killpg(process.pid, signal.SIGKILL)
216
+ process.wait()
217
+ exit_code, timed_out = None, True
218
+ save_state(attempt_dir / "exit.json",
219
+ dict(finished_at=now(), exit_code=exit_code, timed_out=timed_out))
220
+ return 0
221
+
222
+
223
+ def brief_text(state: Dict[str, object], role_brief: Path, task_template: Path) -> str:
224
+ worktree = Path(str(state["worktree"]))
225
+ task_id = state["task_id"]
226
+ agents = worktree / "AGENTS.md"
227
+ workflow = worktree / "WORKFLOW.md"
228
+ lines = [
229
+ f"You are the coder for GSD Path task {task_id}.",
230
+ f"Read the coder role brief first and follow it exactly, including its terminal "
231
+ f"`RESULT: {task_id} ready|blocked` line: {role_brief}",
232
+ f"Work only in this worktree root: {worktree}",
233
+ f"Task file: {worktree / str(state['task_file'])}",
234
+ f"Task template: {task_template}",
235
+ f"INTENT.md: {worktree / '.project/intent/INTENT.md'}",
236
+ f"AGENTS.md: {agents if agents.exists() else 'absent'}",
237
+ f"WORKFLOW.md: {workflow if workflow.exists() else 'absent'}",
238
+ f"Recorded base: {state['base']}",
239
+ f"Dispatch mode: {state['mode']} ("
240
+ + ("the primary worktree on the bound branch" if state["mode"] == "serial"
241
+ else "an isolated task worktree; never touch the primary") + ").",
242
+ "Next consumer: the build orchestrator reruns Verify in an isolated checkout and lands "
243
+ "the task. Gate: Verify passes and only the task's declared files plus its Log changed.",
244
+ "Never delegate to another agent. Never stage, commit, or otherwise mutate Git.",
245
+ ]
246
+ if state.get("answered"):
247
+ lines.append(f"The task Log now records an `{ANSWER_MARK}` to your earlier question; "
248
+ "continue from it.")
249
+ return "\n".join(lines) + "\n"
250
+
251
+
252
+ def spawn(root: Path, state: Dict[str, object], options: argparse.Namespace,
253
+ brief: Callable[[Dict[str, object]], str], command: Optional[str] = None) -> Dict[str, object]:
254
+ """Create the next attempt record, write the brief the callback builds, and start the wrapper."""
255
+ task_dir = root / str(state["task_id"])
256
+ attempt = max((attempt_number(path) for path in task_dir.glob("attempt-*")), default=0) + 1
257
+ attempt_dir = task_dir / f"attempt-{attempt}"
258
+ attempt_dir.mkdir(parents=True)
259
+ stale = ("pid", "finished_at", "exit_code", "timed_out", "reason", "question", "commit", "answer",
260
+ "usage_recorded")
261
+ state = {key: value for key, value in state.items()
262
+ if not key.startswith("_") and key not in stale}
263
+ state.update({"attempt": attempt, "command": shlex.split(command or options.child_command),
264
+ "child_timeout": options.child_timeout, "outcome": None,
265
+ "dispatched_at": now(), "origin": "question" if state.get("answered") else "dispatch"})
266
+ state_path = attempt_dir / "state.json"
267
+ (attempt_dir / "brief.md").write_text(brief(state), encoding="utf-8")
268
+ save_state(state_path, state)
269
+ wrapper = subprocess.Popen(
270
+ [sys.executable, "-B", str(Path(__file__).resolve()), "_child", "--state", str(state_path)],
271
+ cwd=state["worktree"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
272
+ stderr=subprocess.DEVNULL, start_new_session=True,
273
+ )
274
+ state["_path"] = str(state_path)
275
+ update_state(state, pid=wrapper.pid)
276
+ return state
277
+
278
+
279
+ # --- finishing a task ------------------------------------------------------
280
+
281
+
282
+ def log_delta(primary: Path, state: Dict[str, object]) -> str:
283
+ base_text = isolation.git_text(primary, "show", f"{state['base']}:{state['task_file']}")
284
+ text = (Path(str(state["worktree"])) / str(state["task_file"])).read_text(encoding="utf-8")
285
+ return isolation.task_log_delta(base_text, text)
286
+
287
+
288
+ def append_log(task_path: Path, line: str) -> None:
289
+ text = task_path.read_text(encoding="utf-8")
290
+ if not text.endswith("\n"):
291
+ text += "\n"
292
+ _common.atomic_write(task_path, text + line + "\n")
293
+
294
+
295
+ def snapshot_tree(primary: Path) -> str:
296
+ """Tree of the primary's complete working tree via a temporary index; the real index is untouched."""
297
+ with tempfile.TemporaryDirectory() as temporary:
298
+ env = dict(os.environ, GIT_INDEX_FILE=str(Path(temporary) / "index"))
299
+ for arguments in (("read-tree", "HEAD"), ("add", "-A"), ("write-tree",)):
300
+ completed = subprocess.run(("git", "-C", str(primary), *arguments), env=env,
301
+ text=True, capture_output=True)
302
+ if completed.returncode:
303
+ raise DriverStop(f"git {' '.join(arguments)} failed: {completed.stderr.strip()}")
304
+ return completed.stdout.strip()
305
+
306
+
307
+ def run_verify(command: str, cwd: Path) -> Dict[str, object]:
308
+ completed = _common.run_command("bash", "-c", command, cwd=cwd)
309
+ return {"exit_code": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
310
+
311
+
312
+ def state_from_task(primary: Path, task_id: str) -> Dict[str, object]:
313
+ """A finish-able state derived from the task frontmatter alone, for coders dispatched by hand."""
314
+ tasks_dir = primary / ".project/tasks"
315
+ for task_path in sorted(tasks_dir.glob("*.md")):
316
+ fields, _ = isolation.task_frontmatter(task_path.read_text(encoding="utf-8"))
317
+ if fields and str(fields.get("id")) == task_id:
318
+ break
319
+ else:
320
+ raise DriverStop(f"task {task_id} has no task file")
321
+ if fields.get("status") != "in-progress" or not fields.get("base"):
322
+ raise DriverStop(f"task {task_id} is not an in-progress task with a recorded base")
323
+ def value(key: str) -> Optional[str]:
324
+ raw = fields.get(key)
325
+ return None if raw in (None, "", "null") else str(raw)
326
+
327
+ task_branch = value("task_branch")
328
+ name = f"task-{task_id.lower()}-verify"
329
+ sidecar = None if task_branch else {
330
+ "worktree": str(isolation.sidecar_root(primary, "verify", name)),
331
+ "branch": isolation.verify_branch_name(name)}
332
+ return {"task_id": task_id, "title": str(fields.get("title")),
333
+ "task_file": task_path.relative_to(primary).as_posix(), "files": list(fields.get("files") or []),
334
+ "base": str(fields.get("base")), "worktree": value("worktree") or str(primary),
335
+ "task_branch": task_branch, "mode": "parallel" if task_branch else "serial",
336
+ "sidecar": sidecar}
337
+
338
+
339
+ def finish_task(primary: Path, state: Dict[str, object]) -> Dict[str, object]:
340
+ """Verify in the isolate, land, record the ledger, retire. Returns the landing receipt."""
341
+ worktree = Path(str(state["worktree"]))
342
+ task_id = str(state["task_id"])
343
+ task_file = str(state["task_file"])
344
+ base = str(state["base"])
345
+ task_path = worktree / task_file
346
+ command = _common.task_verify_command(task_path.read_text(encoding="utf-8"))
347
+ if not command:
348
+ raise DriverStop("task has no Verify command", task=task_id)
349
+ if state["mode"] == "serial":
350
+ # ponytail: sidecar reproduction is git plumbing that belongs in isolation.py as the
351
+ # inverse of clean_verify; lift it there when a second caller appears.
352
+ sidecar = state["sidecar"]
353
+ sidecar_path = Path(str(sidecar["worktree"]))
354
+ tree = snapshot_tree(primary)
355
+ isolation.git_output(sidecar_path, "read-tree", "-u", "--reset", tree)
356
+ if isolation.git_output(sidecar_path, "write-tree") != tree:
357
+ raise DriverStop("sidecar reproduction differs from the primary task diff", task=task_id)
358
+ execution = run_verify(command, sidecar_path)
359
+ location = f"sidecar {sidecar['branch']}"
360
+ isolation.clean_verify(primary, sidecar_path, base, str(sidecar["branch"]))
361
+ isolation.retire(primary, sidecar_path, str(sidecar["branch"]), False)
362
+ else:
363
+ execution = run_verify(command, worktree)
364
+ location = f"isolate {state['task_branch']}"
365
+ passed = execution["exit_code"] == 0
366
+ execution.update({"worktree": str(worktree), "location": location})
367
+ evidence_dir = (Path(str(state["_path"])).parent if state.get("_path")
368
+ else records_root(primary) / task_id / f"manual-{now().replace(':', '')}")
369
+ evidence_dir.mkdir(parents=True, exist_ok=True)
370
+ _common.atomic_write(evidence_dir / "verify.json",
371
+ json.dumps(execution, indent=2, sort_keys=True) + "\n")
372
+ execution["evidence"] = str(evidence_dir / "verify.json")
373
+ output = "\n".join(f" {line}" for line in tail(execution["stdout"] + execution["stderr"]).splitlines())
374
+ append_log(task_path, f"- {now()[:10]} — orchestrator Verify ({location}): "
375
+ f"{'pass' if passed else 'fail'}, exit {execution['exit_code']}"
376
+ + (f"; output tail:\n ```\n{output}\n ```" if output else ""))
377
+ if not passed:
378
+ if state["mode"] == "parallel":
379
+ isolation.deactivate_task(worktree, task_id, str(state["task_branch"]))
380
+ raise DriverStop("Verify failed in the isolate", task=task_id, execution=execution)
381
+ landed = isolation.land(primary, worktree, base, task_id, str(state["title"]), task_file,
382
+ list(state["files"]))
383
+ commit = str(landed["commit"])
384
+ # Only a landing whose parent is the recorded base proves the verified tree.
385
+ ledger = isolation.git_output(primary, "rev-parse", f"{commit}^") == base
386
+ if ledger:
387
+ build_state.verify_record(str(primary), command, commit, "pass", execution=execution)
388
+ isolation.retire(primary, worktree, state.get("task_branch") or None, False)
389
+ return {"task": task_id, "commit": commit, "mode": landed["mode"], "verify": execution,
390
+ "ledger": ledger}
391
+
392
+
393
+ # --- shared by round and review --------------------------------------------
394
+
395
+
396
+ def summary(state: Dict[str, object]) -> Dict[str, object]:
397
+ return {key: state.get(key) for key in
398
+ ("task_id", "attempt", "mode", "worktree", "wave", "cycle", "verify_heavy", "dispatched_at")}
399
+
400
+
401
+ def recover_report(primary: Path, project_dir: str, receipt: Dict[str, object]) -> Dict[str, object]:
402
+ report = isolation.recover(primary, Path(project_dir) / "tasks")
403
+ receipt["steps"].append({"script": "isolation.py recover", "result": report})
404
+ if report["verdict"] == "block":
405
+ raise DriverStop(RECOVERY_BLOCKED, recover=report)
406
+ return report
407
+
408
+
409
+ def checkpoint_project(primary: Path, receipt: Dict[str, object], subject: str, body: str) -> Optional[str]:
410
+ """Commit .project bookkeeping through the canonical checkpoint; None when nothing is dirty."""
411
+ if not any(path.startswith(".project/") for path in isolation.uncommitted_paths(primary)):
412
+ return None
413
+ result = isolation.checkpoint(primary, isolation.current_sha(primary), subject, body, [".project"])
414
+ receipt["steps"].append({"script": "isolation.py checkpoint", "result": result})
415
+ return str(result.get("commit"))
416
+
417
+
418
+ def transition(primary: Path, project_dir: str, receipt: Dict[str, object], state: pipeline_state.PipelineState,
419
+ changes: Dict[str, str], event: str) -> None:
420
+ """Move STATE.md through pipeline_state, expecting exactly the state the caller inspected."""
421
+ result = pipeline_state.transition_state(primary, state.json(), changes, event, project_dir)
422
+ receipt["steps"].append({"script": "pipeline_state.py transition", "result": result})
423
+
424
+
425
+ def run_step(primary: Path, project_dir: str, receipt: Dict[str, object], action: str, head: str,
426
+ task_id: Optional[str] = None, round_size: Optional[int] = None) -> List[Dict[str, object]]:
427
+ """One workflow_run action; its steps join the receipt and a blocked result is a stop."""
428
+ result = workflow_run.run_workflow(primary, action, project_dir, head, task_id, round_size)
429
+ receipt["steps"].extend(result["steps"])
430
+ if result["status"] != "complete":
431
+ raise DriverStop(f"{action} {task_id or ''}: {result['reason']}".rstrip())
432
+ return result["steps"]
433
+
434
+
435
+ def wave_tasks(primary: Path, project_dir: str, wave: int, receipt: Dict[str, object]) -> List[Dict[str, object]]:
436
+ """The wave's tasks with proven landings; an unproven task is a stop."""
437
+ tasks = []
438
+ for task in recover_report(primary, project_dir, receipt)["tasks"]:
439
+ text = (primary / str(task["task"])).read_text(encoding="utf-8")
440
+ if contracts._task_wave(text, str(task.get("task_id"))) != wave:
441
+ continue
442
+ if task["verdict"] not in ("recovered", "attested"):
443
+ raise DriverStop(f"task {task.get('task_id')} has no proven landing: {task['verdict']}", recover=task)
444
+ tasks.append(task)
445
+ if not tasks:
446
+ raise DriverStop(f"wave {wave} has no tasks")
447
+ return tasks
448
+
449
+
450
+ def stop(receipt: Dict[str, object], error: BaseException) -> Dict[str, object]:
451
+ receipt["status"] = "blocked"
452
+ receipt["blocked"].append({"reason": str(error), "code": getattr(error, "code", None),
453
+ **getattr(error, "details", {})})
454
+ receipt["next"] = "$gsd-path-forensics"
455
+ return receipt
456
+
457
+
458
+ # --- the round -------------------------------------------------------------
459
+
460
+
461
+ class Round:
462
+ def __init__(self, primary: Path, options: argparse.Namespace) -> None:
463
+ self.primary = primary
464
+ self.options = options
465
+ self.project_dir = options.project_dir
466
+ self.root = records_root(primary)
467
+ self.proven: Dict[str, str] = {} # task id -> landing commit proven by recover
468
+ self.budgeted = (getattr(options, "task_limit", None) is not None
469
+ or budget_ledger(primary).is_file())
470
+ self.receipt: Dict[str, object] = {
471
+ "status": None, "wave": getattr(options, "wave", None), "landed": [],
472
+ "dispatched": [], "in_flight": [], "questions": [], "blocked": [], "steps": [],
473
+ }
474
+
475
+ # state transitions ----------------------------------------------------
476
+
477
+ def enter(self) -> None:
478
+ """The contract's checkpointed entry from plan/done, or recovery from build/blocked."""
479
+ state, _, _ = pipeline_state.load_state(self.primary, self.project_dir)
480
+ if (state.phase, state.status) not in (("plan", "done"), ("build", "blocked")):
481
+ return
482
+ transition(self.primary, self.project_dir, self.receipt, state, {"phase": "build", "status": "active"},
483
+ "build started")
484
+ checkpoint_project(self.primary, self.receipt, "build: start milestone",
485
+ f"Why: enter build from {state.phase}/{state.status} with the initial .project artifacts")
486
+
487
+ def block(self) -> None:
488
+ """Set build/blocked when the stop just recorded is one the contract names, and checkpoint it."""
489
+ last = self.receipt["blocked"][-1]
490
+ cause = BLOCKING_STOPS.get(last.get("code")) or BLOCKING_STOPS.get(last["reason"])
491
+ if cause is None:
492
+ return
493
+ try:
494
+ state, _, _ = pipeline_state.load_state(self.primary, self.project_dir)
495
+ transition(self.primary, self.project_dir, self.receipt, state, {"status": "blocked"},
496
+ f"build blocked: {cause}")
497
+ self.receipt["checkpoint"] = checkpoint_project(
498
+ self.primary, self.receipt, f"build: record {cause}", f"Why: {last['reason']}")
499
+ except STOP_ERRORS as inner:
500
+ self.receipt["blocked"].append({"reason": str(inner), "helper": "pipeline_state.py transition"})
501
+
502
+ # token budget ---------------------------------------------------------
503
+
504
+ def budget(self, action: str, *arguments: str) -> Dict[str, object]:
505
+ """Run the bundled token_budget.py against this milestone's ledger; blocked is a stop."""
506
+ command = [sys.executable, "-B", str(Path(__file__).resolve().parent / "token_budget.py"),
507
+ action, "--ledger", str(budget_ledger(self.primary)), *arguments]
508
+ completed = subprocess.run(command, cwd=self.primary, capture_output=True, text=True)
509
+ result = json.loads(completed.stdout) if completed.stdout.strip() else {}
510
+ self.receipt["steps"].append({"script": f"token_budget.py {action}", "command": command,
511
+ "exit_code": completed.returncode, "result": result,
512
+ "stderr": completed.stderr})
513
+ if completed.returncode:
514
+ raise DriverStop(f"token budget {action}: {result.get('reason', completed.stderr.strip())}",
515
+ budget=result)
516
+ return result
517
+
518
+ def admit(self, task_id: str) -> None:
519
+ if self.budgeted:
520
+ self.budget("admit", "--task", task_id)
521
+
522
+ def configure_budget(self) -> None:
523
+ if getattr(self.options, "task_limit", None) is not None:
524
+ self.budget("configure", "--task-limit", str(getattr(self.options, "task_limit", None)),
525
+ "--session-limit", str(getattr(self.options, "session_limit", None)),
526
+ "--authority", getattr(self.options, "budget_authority", None))
527
+
528
+ # recovery -------------------------------------------------------------
529
+
530
+ def recover(self) -> None:
531
+ report = recover_report(self.primary, self.project_dir, self.receipt)
532
+ for task in report["tasks"]:
533
+ worktree = task.get("worktree")
534
+ verdict = task["verdict"]
535
+ if verdict in ("recovered", "attested"):
536
+ self.proven[str(task.get("task_id"))] = str(task.get("commit"))
537
+ if verdict == "recovered" and worktree and worktree["clean"] and task.get("task_branch"):
538
+ isolation.retire(self.primary, Path(str(worktree["path"])),
539
+ str(task["task_branch"]), False)
540
+ elif (verdict == "recovered" and worktree is None and task.get("task_branch")) or (
541
+ verdict == "resume" and (task.get("landing_retry") or task.get("dispatch_retry"))):
542
+ # ponytail: interrupted retirements and retries stay with the parent's documented
543
+ # procedure; failed/blocked reconciliation surfaces through ready's typed error.
544
+ raise DriverStop(f"task {task.get('task_id')} needs recovery: {verdict}", recover=task)
545
+
546
+ # settle exited children ---------------------------------------------
547
+
548
+ def isolate_live(self, state: Dict[str, object]) -> bool:
549
+ """True while the isolate still holds the task as in-progress; the parent's recovery clears it."""
550
+ task_path = Path(str(state["worktree"])) / str(state["task_file"])
551
+ if not task_path.is_file():
552
+ return False
553
+ fields, _ = isolation.task_frontmatter(task_path.read_text(encoding="utf-8"))
554
+ return fields is not None and fields.get("status") == "in-progress"
555
+
556
+ def settle(self) -> bool:
557
+ """Classify every exited child. Returns True when a landing or redispatch changed the round."""
558
+ changed = False
559
+ for state in latest_states(self.root):
560
+ if state.get("wave") != self.receipt["wave"]:
561
+ continue
562
+ outcome = state.get("outcome")
563
+ if outcome == "question":
564
+ if state.get("answered"):
565
+ self.admit(str(state["task_id"]))
566
+ self.launch(dict(state, answered=True))
567
+ update_state(state, outcome="redispatched")
568
+ changed = True
569
+ else:
570
+ self.receipt["questions"].append({**self.summary(state),
571
+ "question": state.get("question")})
572
+ elif outcome == "blocked":
573
+ if self.isolate_live(state):
574
+ self.receipt["blocked"].append({**self.summary(state),
575
+ "reason": state.get("reason"), "reported": True})
576
+ else:
577
+ update_state(state, outcome="resolved")
578
+ elif outcome is None:
579
+ if str(state["task_id"]) in self.proven: # landed, then interrupted before recording
580
+ update_state(state, outcome="landed", commit=self.proven[str(state["task_id"])])
581
+ elif state.get("finished_at") is None:
582
+ if child_running(state):
583
+ self.receipt["in_flight"].append(self.summary(state))
584
+ else:
585
+ self.fail(state, ORPHANED)
586
+ else:
587
+ changed = self.classify(state) or changed
588
+ return changed
589
+
590
+ summary = staticmethod(summary)
591
+
592
+ def fail(self, state: Dict[str, object], reason: str, **details: object) -> None:
593
+ update_state(state, outcome="blocked", reason=reason)
594
+ self.receipt["blocked"].append({**self.summary(state), "reason": reason, **details})
595
+
596
+ def classify(self, state: Dict[str, object]) -> bool:
597
+ attempt_dir = Path(str(state["_path"])).parent
598
+ if self.budgeted and not state.get("usage_recorded"):
599
+ self.budget("record", "--task", str(state["task_id"]), "--events", str(attempt_dir / "stdout"))
600
+ update_state(state, usage_recorded=True)
601
+ message = final_message((attempt_dir / "stdout").read_text(encoding="utf-8", errors="replace"))
602
+ stderr = (attempt_dir / "stderr").read_text(encoding="utf-8", errors="replace")
603
+ try:
604
+ delta = log_delta(self.primary, state)
605
+ except (isolation.IsolationError, FileNotFoundError) as error:
606
+ self.fail(state, str(error), stdout_tail=tail(message), stderr_tail=tail(stderr))
607
+ return False
608
+ entries = [line for line in delta.splitlines() if line.strip()]
609
+ answered_at = max((index for index, line in enumerate(entries) if ANSWER_MARK in line), default=-1)
610
+ unanswered = entries[answered_at + 1:]
611
+ matches = list(RESULT_LINE.finditer(message))
612
+ verdict = matches[-1].group("verdict").lower() if matches else None
613
+ named = matches[-1].group("task").lower() if matches else None
614
+ if state.get("timed_out"): # a failed child is a failure even when it wrote a question
615
+ self.fail(state, "child exceeded the configured timeout", log_delta=delta.strip())
616
+ elif state.get("exit_code") != 0:
617
+ self.fail(state, f"child exited {state.get('exit_code')}", stderr_tail=tail(stderr),
618
+ stdout_tail=tail(message), log_delta=delta.strip())
619
+ elif unanswered and QUESTION_MARK in unanswered[0]:
620
+ update_state(state, outcome="question", question=unanswered[0], answered=False)
621
+ self.receipt["questions"].append({**self.summary(state), "question": unanswered[0],
622
+ "log_delta": delta.strip()})
623
+ elif verdict != "ready" or named != str(state["task_id"]).lower():
624
+ self.fail(state, "child returned blocked" if verdict == "blocked"
625
+ else "child returned no RESULT line", stdout_tail=tail(message),
626
+ log_delta=delta.strip())
627
+ else:
628
+ return self.finish(state)
629
+ return False
630
+
631
+ def finish(self, state: Dict[str, object]) -> bool:
632
+ try:
633
+ landing = finish_task(self.primary, state)
634
+ except STOP_ERRORS as error:
635
+ details = getattr(error, "details", {})
636
+ self.fail(state, str(error), **{key: value for key, value in details.items()
637
+ if key != "task"})
638
+ return False
639
+ update_state(state, outcome="landed", commit=landing["commit"])
640
+ self.receipt["landed"].append(landing)
641
+ return True
642
+
643
+ def launch(self, state: Dict[str, object]) -> None:
644
+ if state.get("wave") != self.receipt["wave"]:
645
+ raise DriverStop("task is outside the requested wave", task=state["task_id"],
646
+ wave=state.get("wave"), requested_wave=self.receipt["wave"])
647
+ fresh = spawn(self.root, state, self.options,
648
+ lambda fresh: brief_text(fresh, self.options.role_brief, self.options.task_template))
649
+ self.receipt["dispatched"].append(self.summary(fresh))
650
+ self.receipt["in_flight"].append(self.summary(fresh))
651
+
652
+ # dispatch -------------------------------------------------------------
653
+
654
+ def in_flight_states(self) -> List[Dict[str, object]]:
655
+ return [state for state in latest_states(self.root)
656
+ if state.get("outcome") is None and state.get("wave") == self.receipt["wave"]]
657
+
658
+ def checkpoint_bookkeeping(self) -> None:
659
+ open_ids = {state["task_id"] for state in latest_states(self.root)
660
+ if state.get("outcome") in (None, "question", "blocked")}
661
+ orphaned = []
662
+ for task_path in sorted((self.primary / ".project/tasks").glob("*.md")):
663
+ fields, _ = isolation.task_frontmatter(task_path.read_text(encoding="utf-8"))
664
+ if fields and fields.get("status") == "in-progress" and fields.get("id") not in open_ids:
665
+ orphaned.append(str(fields.get("id")))
666
+ if orphaned:
667
+ raise DriverStop("in-progress tasks have no open dispatch record", tasks=orphaned)
668
+ checkpoint_project(self.primary, self.receipt, "build: record dispatch bookkeeping",
669
+ "Why: commit the verify ledger and task state before the next dispatch round\n"
670
+ f"Base: {isolation.current_sha(self.primary)}")
671
+
672
+ def dispatch_ready(self) -> bool:
673
+ """Dispatch every selectable task. Returns True when the wave is complete."""
674
+ in_flight = self.in_flight_states()
675
+ if any(state["mode"] == "serial" for state in in_flight):
676
+ return False
677
+ ready = build_state.ready(str(self.primary), self.project_dir)
678
+ self.receipt["steps"].append({"script": "build_state.py ready", "result": ready})
679
+ wave = ready["current_wave"]
680
+ if wave is not None and wave < self.receipt["wave"]:
681
+ raise DriverStop(f"wave {wave} is still unfinished; run round --wave {wave} first")
682
+ self.checkpoint_bookkeeping()
683
+ if wave is None or wave > self.receipt["wave"]:
684
+ return not in_flight
685
+ active_ids = {state["task_id"] for state in in_flight}
686
+ # ponytail: the one-heavy-Verify-at-a-time rule lives here; move it into
687
+ # build_state.ready if the hand-dispatch path ever needs it too.
688
+ heavy_busy = any(state.get("verify_heavy") for state in in_flight)
689
+ capacity = self.options.capacity
690
+ selected = []
691
+ for task in ready["ready"]:
692
+ if capacity is not None and len(in_flight) + len(selected) >= capacity:
693
+ break
694
+ if task["wave"] != self.receipt["wave"]:
695
+ continue
696
+ if task["id"] in active_ids or (task["verify_heavy"] and heavy_busy):
697
+ continue
698
+ heavy_busy = heavy_busy or task["verify_heavy"]
699
+ selected.append(task)
700
+ if not selected:
701
+ return False
702
+ for task in selected:
703
+ used = attempts_used(self.root, task["id"])
704
+ if used >= self.options.max_attempts:
705
+ raise DriverStop(f"task {task['id']} reached the attempt limit ({self.options.max_attempts}) "
706
+ "for this milestone; a person must rule", task=task["id"], attempts=used)
707
+ self.admit(task["id"])
708
+ head = isolation.current_sha(self.primary)
709
+ self.runner("lint-round", head)
710
+ round_size = len(selected) + len(in_flight)
711
+ for task in selected:
712
+ prepared = self.runner("prepare-task", head, task["id"], round_size)
713
+ isolate = prepared[0]["result"]
714
+ sidecar = prepared[1]["result"] if len(prepared) > 1 else None
715
+ worktree = Path(str(isolate["worktree"]))
716
+ isolation.activate_task(worktree, head, task["id"], f"build_{task['id'].lower()}",
717
+ task["task_file"], isolate["task_branch"])
718
+ self.launch({"task_id": task["id"], "title": task["title"], "task_file": task["task_file"],
719
+ "files": task["files"], "wave": task["wave"],
720
+ "verify_heavy": task["verify_heavy"], "base": head, "worktree": str(worktree),
721
+ "task_branch": isolate["task_branch"], "mode": isolate["mode"],
722
+ "sidecar": sidecar, "answered": False})
723
+ return False
724
+
725
+ def runner(self, action: str, head: str, task_id: Optional[str] = None,
726
+ round_size: Optional[int] = None) -> List[Dict[str, object]]:
727
+ return run_step(self.primary, self.project_dir, self.receipt, action, head, task_id, round_size)
728
+
729
+ def run(self) -> Dict[str, object]:
730
+ deadline = time.monotonic() + self.options.wait if self.options.wait else None
731
+ try:
732
+ self.enter()
733
+ self.configure_budget()
734
+ self.recover()
735
+ changed = True
736
+ while True:
737
+ self.receipt["in_flight"] = []
738
+ changed = self.settle() or changed
739
+ if self.receipt["questions"] or self.receipt["blocked"]:
740
+ self.receipt["status"] = "question" if self.receipt["questions"] else "blocked"
741
+ return self.receipt
742
+ # ready is expensive and its inputs only move when a landing or redispatch happened
743
+ if changed and self.dispatch_ready():
744
+ self.receipt["status"] = "done"
745
+ return self.receipt
746
+ changed = False
747
+ live = self.in_flight_states()
748
+ if deadline is None or time.monotonic() >= deadline or not live:
749
+ self.receipt["status"] = "in-flight" if live else "done"
750
+ return self.receipt
751
+ time.sleep(POLL_SECONDS)
752
+ except STOP_ERRORS as error:
753
+ stop(self.receipt, error)
754
+ self.block()
755
+ return self.receipt
756
+
757
+
758
+ # --- the wave review -------------------------------------------------------
759
+
760
+
761
+ def advance(state: Dict[str, object], collect: Callable[[Dict[str, object]], None]) -> bool:
762
+ """Move one sidecar child toward a terminal outcome, in place. False while it still runs."""
763
+ if state.get("outcome") == "collected" and (
764
+ state.get("cleanup_pending") or not state.get("cleanup_complete")):
765
+ collect(state)
766
+ if state.get("outcome") is None:
767
+ if state.get("finished_at") is None:
768
+ if child_running(state):
769
+ return False
770
+ state.update(read_attempt(Path(str(state["_path"])))) # the wrapper may have just recorded its result
771
+ if state.get("finished_at") is not None:
772
+ collect(state)
773
+ else:
774
+ update_state(state, outcome="blocked", reason=ORPHANED)
775
+ return True
776
+
777
+
778
+ def retire_sidecar(primary: Path, state: Dict[str, object]) -> None:
779
+ """Clean and retire a collected child's sidecar; a failure stays pending for the next run."""
780
+ sidecar = Path(str(state["worktree"]))
781
+ try:
782
+ if sidecar.exists():
783
+ isolation.clean_verify(primary, sidecar, str(state["base"]), str(state["branch"]))
784
+ isolation.retire(primary, sidecar, str(state["branch"]), False)
785
+ except isolation.IsolationError as error:
786
+ update_state(state, cleanup_pending=True, cleanup_error=str(error))
787
+ return
788
+ update_state(state, cleanup_complete=True, cleanup_pending=False, cleanup_error=None)
789
+
790
+
791
+ def review_base(records: Path, wave: int, cycle: int) -> str:
792
+ """The clean base the cycle's review recorded; a stop when no review ran."""
793
+ base = next((state["base"] for state in latest_states(records / "reviews")
794
+ if state.get("wave") == wave and state.get("cycle") == cycle), None)
795
+ if base is None:
796
+ raise DriverStop("review base is not recorded; run review first")
797
+ return str(base)
798
+
799
+
800
+ def spawn_in_sidecar(root: Path, primary: Path, receipt: Dict[str, object], options: argparse.Namespace,
801
+ name: str, state: Dict[str, object], brief: Callable[[Dict[str, object]], str],
802
+ command: Optional[str] = None) -> None:
803
+ """Cut a verify sidecar at the state's base and start one child in it."""
804
+ sidecar = isolation.isolate_verify(primary, str(state["base"]), name)
805
+ receipt["steps"].append({"script": "isolation.py isolate-verify", "result": sidecar})
806
+ spawn(root, dict(state, worktree=str(sidecar["worktree"]), branch=str(sidecar["branch"])), options, brief,
807
+ command=command)
808
+
809
+
810
+ def review_brief(state: Dict[str, object], options: argparse.Namespace, primary: Path,
811
+ tasks: List[Dict[str, object]], final_scope: bool, previous: Optional[Path]) -> str:
812
+ wave, cycle, lens = state["wave"], state["cycle"], state.get("lens")
813
+ agents = primary / "AGENTS.md"
814
+ lines = [
815
+ f"You are the reviewer for GSD Path wave {wave}, cycle {cycle}; logical task name "
816
+ f"{state['task_id']}. Mode: wave" + (f"; lens: {lens}." if lens else "."),
817
+ f"Read the reviewer role brief first and follow it exactly: {options.role_brief}",
818
+ f"Template (use only this): {options.template}",
819
+ f"AGENTS.md: {agents if agents.exists() else 'absent'}",
820
+ f"Repository root, read-only: {primary}",
821
+ f"Your verify sidecar root, the only place you may write or apply patches: {state['worktree']} "
822
+ f"on branch {state['branch']} at the recorded review base {state['base']}.",
823
+ f"Write exactly this output file: {Path(str(state['worktree'])) / str(state['relative'])}",
824
+ f"Canonical path after the orchestrator collects it: {state['relative']}",
825
+ f"INTENT.md: {primary / '.project/intent/INTENT.md'}",
826
+ f"PLAN.md: {primary / '.project/plan/PLAN.md'}",
827
+ "Tasks in this wave, each with its recorded base and proven landing commit; the "
828
+ "orchestrator's recorded isolated Verify result is the `orchestrator Verify` entry in each Log:",
829
+ ]
830
+ for task in tasks:
831
+ lines.append(f"- {task['task_id']}: {primary / str(task['task'])} — base {task['base']}, "
832
+ f"landing commit {task['commit']}")
833
+ if previous is not None:
834
+ lines.append(f"Previous cycle review to re-check: {previous}")
835
+ if options.repair_evidence:
836
+ lines.append(f"Repair evidence receipt (re-review after a proven repair): {options.repair_evidence}")
837
+ if final_scope:
838
+ lines.append(f"Final scope applies: record `Review scope: final` and `Reviewed HEAD: {state['base']}`, "
839
+ "check every INTENT success criterion and PLAN.md's Surface contract, and add "
840
+ "Surface, Check, and Observed fields to each surface criterion block.")
841
+ lines += [
842
+ "Do not re-run task Verify or PLAN.md's project Verify. Never edit the primary worktree. "
843
+ "Never delegate to another agent. Never stage or commit.",
844
+ "Terminal result: the `Wave verdict:` line of the file you wrote is your result; the "
845
+ "orchestrator validates the file and reads it from there. Name that verdict in your final message.",
846
+ ]
847
+ return "\n".join(lines) + "\n"
848
+
849
+
850
+ class Review:
851
+ """Step 6 of the build contract at `full` or `deep` depth: one reviewer per lens in its own
852
+ verify sidecar, validated, collected, retired; findings grouped on blocked."""
853
+
854
+ def __init__(self, primary: Path, options: argparse.Namespace) -> None:
855
+ self.primary = primary
856
+ self.options = options
857
+ self.project_dir = options.project_dir
858
+ self.wave, self.cycle = options.wave, options.cycle
859
+ self.root = records_root(primary) / "reviews"
860
+ self.receipt: Dict[str, object] = {"status": None, "wave": self.wave, "cycle": self.cycle,
861
+ "lenses": {}, "in_flight": [], "blocked": [], "steps": []}
862
+
863
+ def lens_name(self, key: str) -> str:
864
+ return f"review_wave_{self.wave}_cycle_{self.cycle}" + ("" if key == "canonical" else f"_{key}")
865
+
866
+ def current_states(self, keys: List[str]) -> Dict[str, Dict[str, object]]:
867
+ names = {self.lens_name(key): key for key in keys}
868
+ return {names[str(state["task_id"])]: state for state in latest_states(self.root)
869
+ if str(state["task_id"]) in names}
870
+
871
+ def wave_tasks(self) -> List[Dict[str, object]]:
872
+ return wave_tasks(self.primary, self.project_dir, self.wave, self.receipt)
873
+
874
+ def dispatch(self, lenses: Dict[str, str], depth: str, base: Optional[str] = None) -> None:
875
+ dirty = sorted(isolation.uncommitted_paths(self.primary))
876
+ if dirty: # the review base must include every bookkeeping change
877
+ raise DriverStop("review collection base requires a clean primary", paths=dirty)
878
+ tasks = self.wave_tasks()
879
+ plan_text = (self.primary / self.project_dir / "plan/PLAN.md").read_text(encoding="utf-8")
880
+ intent = (self.primary / self.project_dir / "intent/INTENT.md").read_text(encoding="utf-8")
881
+ try:
882
+ lane = contracts._line_value(intent, "Lane:")
883
+ except contracts.HandoffError:
884
+ lane = None # an INTENT without a lane line is never quick
885
+ final_scope = depth == "full" and lane == "quick" and contracts._plan_waves(plan_text)[1] == [1]
886
+ earlier = (review_findings.review_paths(self.primary / self.project_dir, self.wave, self.cycle - 1, depth)
887
+ if self.cycle > 1 else {})
888
+ for previous in earlier.values():
889
+ if not previous.is_file():
890
+ raise DriverStop(f"previous cycle review is missing: {previous}")
891
+ base = base or isolation.current_sha(self.primary)
892
+ self.receipt["base"] = base
893
+ for key, relative in lenses.items():
894
+ name = f"wave-{self.wave}-cycle-{self.cycle}" + ("" if key == "canonical" else f"-{key}")
895
+ previous = earlier.get(key)
896
+ state = {"task_id": self.lens_name(key), "lens": None if key == "canonical" else key,
897
+ "wave": self.wave, "cycle": self.cycle, "base": base, "relative": relative}
898
+ spawn_in_sidecar(self.root, self.primary, self.receipt, self.options, name, state,
899
+ lambda fresh, previous=previous: review_brief(fresh, self.options, self.primary, tasks,
900
+ final_scope, previous))
901
+
902
+ def collect(self, state: Dict[str, object]) -> None:
903
+ """Validate in the sidecar, then copy to the canonical path, then retire: the contract's order."""
904
+ sidecar = Path(str(state["worktree"]))
905
+ relative = str(state["relative"])
906
+ stderr = (Path(str(state["_path"])).parent / "stderr").read_text(encoding="utf-8", errors="replace")
907
+ if state.get("timed_out") or state.get("exit_code") != 0:
908
+ update_state(state, outcome="blocked", reason=f"reviewer child exited {state.get('exit_code')}",
909
+ stderr_tail=tail(stderr))
910
+ return
911
+ staged = sidecar / relative
912
+ canonical = self.primary / relative
913
+ if state.get("outcome") != "collected":
914
+ if not staged.is_file():
915
+ if (state.get("validated") and canonical.is_file()
916
+ and hashlib.sha256(canonical.read_bytes()).hexdigest() == state.get("validated_sha256")):
917
+ update_state(state, outcome="collected")
918
+ else:
919
+ update_state(state, outcome="blocked", reason="reviewer wrote no review file", stderr_tail=tail(stderr))
920
+ return
921
+ else:
922
+ try:
923
+ validated = contracts.validate_wave_evidence(sidecar, self.project_dir, relative)
924
+ except contracts.HandoffError as error:
925
+ update_state(state, outcome="blocked", reason=str(error), helper="check_handoffs.py wave")
926
+ return
927
+ update_state(state, verdict=validated["verdict"], owned=validated.get("owned"), validated=True,
928
+ validated_sha256=hashlib.sha256(staged.read_bytes()).hexdigest())
929
+ try:
930
+ collected = isolation.collect_artifact(self.primary, sidecar, str(state["base"]),
931
+ str(state["branch"]), relative, relative, None)
932
+ except isolation.IsolationError as error:
933
+ update_state(state, outcome="blocked", reason=str(error), helper="isolation.py")
934
+ return
935
+ update_state(state, outcome="collected")
936
+ self.receipt["steps"].append({"script": "isolation.py collect-artifact", "result": collected})
937
+ retire_sidecar(self.primary, state)
938
+
939
+ def input_failure(self, base: str) -> Optional[Dict[str, str]]:
940
+ paths = review_findings.review_paths(self.primary / self.project_dir, self.wave,
941
+ self.cycle, self.receipt["depth"])
942
+ allowed = {path.relative_to(self.primary).as_posix() for path in paths.values()}
943
+ head = isolation.current_sha(self.primary)
944
+ committed = set(filter(None, isolation.git_text(
945
+ self.primary, "diff", "--no-renames", "--name-only", "-z", base, head, "--").split("\0")))
946
+ changed = committed | isolation.uncommitted_paths(self.primary)
947
+ checkpoint = f"build: record wave {self.wave} cycle {self.cycle} review"
948
+ history_matches = head == base or (
949
+ isolation.git_output(self.primary, "show", "-s", "--format=%P", head) == base
950
+ and isolation.git_output(self.primary, "show", "-s", "--format=%s", head) == checkpoint)
951
+ if changed - allowed or not history_matches:
952
+ return {"reason": "inputs changed after the review base; run a new cycle",
953
+ "helper": "dispatch_driver.py review"}
954
+ return None
955
+
956
+ def settle(self, states: Dict[str, Dict[str, object]]) -> None:
957
+ """Advance every lens and render the receipt from the records; settle is their only writer."""
958
+ self.receipt["in_flight"], self.receipt["blocked"], self.receipt["lenses"] = [], [], {}
959
+ for key, state in states.items():
960
+ if not advance(state, self.collect):
961
+ self.receipt["in_flight"].append(summary(state))
962
+ continue
963
+ self.receipt["base"] = state.get("base")
964
+ if state.get("outcome") == "collected":
965
+ failure = self.input_failure(str(state["base"]))
966
+ if failure:
967
+ self.receipt["blocked"].append({**summary(state), **failure})
968
+ continue
969
+ canonical = self.primary / str(state["relative"])
970
+ if (not canonical.is_file()
971
+ or hashlib.sha256(canonical.read_bytes()).hexdigest() != state.get("validated_sha256")):
972
+ self.receipt["blocked"].append({**summary(state),
973
+ "reason": "canonical review artifact changed after collection",
974
+ "helper": "isolation.py collect-artifact"})
975
+ continue
976
+ if state.get("cleanup_pending"):
977
+ self.receipt["blocked"].append({**summary(state), "reason": state.get("cleanup_error"),
978
+ "helper": "isolation.py retire"})
979
+ self.receipt["lenses"][key] = {"verdict": state.get("verdict"), "owned": state.get("owned"),
980
+ "path": state.get("relative")}
981
+ elif state.get("outcome") == "blocked":
982
+ self.receipt["blocked"].append({**summary(state), "reason": state.get("reason"),
983
+ "helper": state.get("helper"), "stderr_tail": state.get("stderr_tail")})
984
+
985
+ def conclude(self) -> None:
986
+ lenses: Dict[str, Dict[str, object]] = self.receipt["lenses"]
987
+ passing = not self.receipt["blocked"] and all(entry.get("verdict") == "pass" for entry in lenses.values())
988
+ failure = self.input_failure(str(self.receipt["base"])) if passing else None
989
+ if passing and failure is None:
990
+ if not self.receipt["panel_required"]: # with a panel, the parent's single checkpoint follows it
991
+ self.receipt["checkpoint"] = checkpoint_project(
992
+ self.primary, self.receipt, f"build: record wave {self.wave} cycle {self.cycle} review",
993
+ f"Why: persist the passing wave review verdict\nWave: {self.wave}")
994
+ self.receipt["status"] = "pass"
995
+ return
996
+ helper_failures = [f"{item['helper']}: {item['reason']}" for item in self.receipt["blocked"]
997
+ if item.get("helper")]
998
+ if failure:
999
+ self.receipt.update(failure)
1000
+ helper_failures.append(f"{failure['helper']}: {failure['reason']}")
1001
+ try:
1002
+ self.receipt["findings"] = review_findings.compute(self.primary, self.project_dir, self.wave,
1003
+ self.cycle, helper_failures)
1004
+ except review_findings.ReviewFindingsError as error:
1005
+ self.receipt["findings"] = {"status": "error", "error": str(error)}
1006
+ self.receipt["status"] = "blocked"
1007
+
1008
+ def run(self) -> Dict[str, object]:
1009
+ deadline = time.monotonic() + self.options.wait if self.options.wait else None
1010
+ try:
1011
+ project = self.primary / self.project_dir
1012
+ plan_text = (project / "plan/PLAN.md").read_text(encoding="utf-8")
1013
+ depth = review_findings.wave_depth(plan_text, self.wave)
1014
+ self.receipt["depth"] = depth
1015
+ if depth == "verify-only":
1016
+ # ponytail: verify-only files are orchestrator-written judgment; the parent owns them.
1017
+ self.receipt.update(status="not-applicable",
1018
+ reason="verify-only reviews are orchestrator-written; the driver does not run them")
1019
+ return self.receipt
1020
+ self.receipt["panel_required"] = review_panel.plan_review_panel_enabled(plan_text)
1021
+ lenses = {key: path.relative_to(self.primary).as_posix() for key, path in
1022
+ review_findings.review_paths(project, self.wave, self.cycle, depth).items()}
1023
+ states = self.current_states(list(lenses))
1024
+ missing = {key: relative for key, relative in lenses.items() if key not in states}
1025
+ if missing:
1026
+ base = None
1027
+ if states:
1028
+ bases = {state["base"] for state in states.values()}
1029
+ head = isolation.current_sha(self.primary)
1030
+ if bases != {head}:
1031
+ raise DriverStop(f"missing review lenses {', '.join(missing)}: HEAD moved to {head}; "
1032
+ f"recorded bases: {', '.join(sorted(bases))}")
1033
+ base = head
1034
+ self.dispatch(missing, depth, base)
1035
+ states = self.current_states(list(lenses))
1036
+ while True:
1037
+ self.settle(states)
1038
+ if set(states) == set(lenses) and all(
1039
+ state.get("outcome") in ("collected", "blocked") for state in states.values()):
1040
+ self.conclude()
1041
+ return self.receipt
1042
+ if deadline is None or time.monotonic() >= deadline:
1043
+ self.receipt["status"] = "in-flight"
1044
+ return self.receipt
1045
+ time.sleep(POLL_SECONDS)
1046
+ states = self.current_states(list(lenses))
1047
+ except STOP_ERRORS as error:
1048
+ return stop(self.receipt, error)
1049
+
1050
+
1051
+ # --- the review panel and fix tasks ----------------------------------------
1052
+
1053
+
1054
+ def helper_json(receipt: Dict[str, object], script: str, *arguments: str, cwd: Path) -> Dict[str, object]:
1055
+ """Run a bundled helper and return its JSON stdout; a non-zero exit is a typed stop."""
1056
+ command = [sys.executable, "-B", str(Path(__file__).resolve().parent / script), *arguments]
1057
+ completed = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
1058
+ step = {"script": script, "command": command, "exit_code": completed.returncode,
1059
+ "stdout": completed.stdout, "stderr": completed.stderr}
1060
+ receipt["steps"].append(step)
1061
+ try:
1062
+ result = json.loads(completed.stdout) if completed.stdout.strip() else {}
1063
+ except json.JSONDecodeError:
1064
+ result = {}
1065
+ if completed.returncode:
1066
+ raise DriverStop(f"{script} failed: {result.get('error') or completed.stderr.strip()}", result=result)
1067
+ result["_stdout"] = completed.stdout
1068
+ return result
1069
+
1070
+
1071
+ class Children:
1072
+ """One sidecar child per key of a wave cycle, each polled to a terminal outcome and its file
1073
+ collected; Panel and Skeptics differ only in what they dispatch and record."""
1074
+
1075
+ records = "" # dispatch records subdirectory
1076
+ field = "" # the state key naming a child
1077
+ results = "" # the receipt key holding collected entries
1078
+
1079
+ def __init__(self, primary: Path, options: argparse.Namespace) -> None:
1080
+ self.primary, self.options = primary, options
1081
+ self.project_dir, self.wave, self.cycle = options.project_dir, options.wave, options.cycle
1082
+ self.root = records_root(primary) / self.records
1083
+ self.receipt: Dict[str, object] = {"status": None, "wave": self.wave, "cycle": self.cycle,
1084
+ self.results: {}, "in_flight": [], "blocked": [], "steps": []}
1085
+
1086
+ def states(self) -> Dict[str, Dict[str, object]]:
1087
+ return {str(state[self.field]): state for state in latest_states(self.root)
1088
+ if state.get("wave") == self.wave and state.get("cycle") == self.cycle}
1089
+
1090
+ def validate(self, staged: Path) -> Dict[str, object]:
1091
+ """State fields read from the staged file; raises review_findings.ReviewFindingsError."""
1092
+ return {}
1093
+
1094
+ def entry(self, state: Dict[str, object]) -> Dict[str, object]:
1095
+ raise NotImplementedError
1096
+
1097
+ def collect(self, state: Dict[str, object]) -> None:
1098
+ collect_file(self.primary, self.receipt, state, self.validate)
1099
+
1100
+ def poll(self, states: Dict[str, Dict[str, object]], deadline: Optional[float]) -> bool:
1101
+ """Advance every child; True once all are terminal, False when the deadline passes first."""
1102
+ while True:
1103
+ self.receipt["in_flight"], self.receipt["blocked"], self.receipt[self.results] = [], [], {}
1104
+ for key, state in states.items():
1105
+ if not advance(state, self.collect):
1106
+ self.receipt["in_flight"].append(summary(state))
1107
+ continue
1108
+ if state.get("cleanup_pending"):
1109
+ self.receipt["blocked"].append({**summary(state), "reason": state.get("cleanup_error"),
1110
+ "helper": "isolation.py retire"})
1111
+ if state.get("outcome") == "collected":
1112
+ self.receipt[self.results][key] = self.entry(state)
1113
+ elif state.get("outcome") == "blocked":
1114
+ self.receipt["blocked"].append({**summary(state), "reason": state.get("reason"),
1115
+ "helper": state.get("helper")})
1116
+ if all(state.get("outcome") in ("collected", "blocked") for state in states.values()):
1117
+ return True
1118
+ if deadline is None or time.monotonic() >= deadline:
1119
+ self.receipt["status"] = "in-flight"
1120
+ return False
1121
+ time.sleep(POLL_SECONDS)
1122
+ states.update(self.states()) # an unreaped wrapper still looks alive; its exit receipt does not
1123
+
1124
+
1125
+ def collect_file(primary: Path, receipt: Dict[str, object], state: Dict[str, object],
1126
+ validate: Callable[[Path], Dict[str, object]]) -> None:
1127
+ """Validate a child's staged file in its sidecar, copy it to the canonical path, then retire."""
1128
+ sidecar, relative = Path(str(state["worktree"])), str(state["relative"])
1129
+ if state.get("outcome") != "collected":
1130
+ if state.get("timed_out") or state.get("exit_code") != 0:
1131
+ update_state(state, outcome="blocked", reason=f"child exited {state.get('exit_code')}")
1132
+ return
1133
+ staged = sidecar / relative
1134
+ if staged.is_file():
1135
+ try:
1136
+ fields = validate(staged)
1137
+ except review_findings.ReviewFindingsError as error:
1138
+ update_state(state, outcome="blocked", reason=str(error), helper="review_findings.py")
1139
+ return
1140
+ update_state(state, validated=True,
1141
+ validated_sha256=hashlib.sha256(staged.read_bytes()).hexdigest(), **fields)
1142
+ else:
1143
+ canonical = primary / relative
1144
+ if (not state.get("validated") or not canonical.is_file()
1145
+ or hashlib.sha256(canonical.read_bytes()).hexdigest() != state.get("validated_sha256")):
1146
+ update_state(state, outcome="blocked", reason="child wrote no file or collected file changed")
1147
+ return
1148
+ try:
1149
+ collected = isolation.collect_artifact(primary, sidecar, str(state["base"]), str(state["branch"]),
1150
+ relative, relative, None)
1151
+ except isolation.IsolationError as error:
1152
+ update_state(state, outcome="blocked", reason=str(error), helper="isolation.py")
1153
+ return
1154
+ receipt["steps"].append({"script": "isolation.py collect-artifact", "result": collected})
1155
+ update_state(state, outcome="collected")
1156
+ retire_sidecar(primary, state)
1157
+
1158
+
1159
+ class Panel(Children):
1160
+ """Step 6's optional review panel: resolve, one panelist per family in its own sidecar,
1161
+ collect, merge, then the single on-pass checkpoint with the canonical review."""
1162
+
1163
+ records, field, results = "panels", "family", "families"
1164
+
1165
+ def family_name(self, family: str) -> str:
1166
+ return f"review_wave_{self.wave}_cycle_{self.cycle}_panel_{family}"
1167
+
1168
+ def entry(self, state: Dict[str, object]) -> Dict[str, object]:
1169
+ return {"slug": state.get("slug"), "path": state.get("relative")}
1170
+
1171
+ def relative(self, family: str) -> str:
1172
+ return f"{self.project_dir}/review/wave-{self.wave}.cycle{self.cycle}.panel.{family}.md"
1173
+
1174
+ def brief(self, state: Dict[str, object], tasks: List[Dict[str, object]], depth: str, review: str) -> str:
1175
+ agents = self.primary / "AGENTS.md"
1176
+ lines = [
1177
+ f"You are one review-panel child for GSD Path wave {self.wave}, cycle {self.cycle}; logical task "
1178
+ f"name {state['task_id']}. Mode: wave panel. Family: {state['family']}. Model: {state['slug']}.",
1179
+ f"Read the reviewer role brief first and follow its Wave panel mode exactly: {self.options.role_brief}",
1180
+ f"Template (use only this): {self.options.template}",
1181
+ f"AGENTS.md: {agents if agents.exists() else 'absent'}",
1182
+ f"Depth for this brief: {'adversarial' if depth == 'deep' else 'full'}.",
1183
+ f"Repository root, read-only: {self.primary}",
1184
+ f"Canonical wave review, read-only: {self.primary / review}",
1185
+ f"Your verify sidecar root, the only place you may write or apply patches: {state['worktree']} "
1186
+ f"on branch {state['branch']} at the recorded review base {state['base']}.",
1187
+ f"Write exactly this output file: {Path(str(state['worktree'])) / str(state['relative'])}",
1188
+ f"INTENT.md: {self.primary / '.project/intent/INTENT.md'}",
1189
+ "Tasks in this wave, each with its recorded base and proven landing commit:",
1190
+ ]
1191
+ for task in tasks:
1192
+ lines.append(f"- {task['task_id']}: {self.primary / str(task['task'])} — base {task['base']}, "
1193
+ f"landing commit {task['commit']}")
1194
+ lines += ["Do not write a Wave verdict or edit the canonical review. Never edit the primary. "
1195
+ "Never delegate. Never stage or commit.",
1196
+ "Terminal result: the file you wrote is your result; name its finding count in your final message."]
1197
+ return "\n".join(lines) + "\n"
1198
+
1199
+ def run(self) -> Dict[str, object]:
1200
+ deadline = time.monotonic() + self.options.wait if self.options.wait else None
1201
+ try:
1202
+ project = self.primary / self.project_dir
1203
+ plan_text = (project / "plan/PLAN.md").read_text(encoding="utf-8")
1204
+ depth = review_findings.wave_depth(plan_text, self.wave)
1205
+ self.receipt["depth"] = depth
1206
+ if depth == "verify-only":
1207
+ raise DriverStop("never spawn a review panel at verify-only")
1208
+ paths = review_findings.review_paths(project, self.wave, self.cycle, depth)
1209
+ review = next(iter(paths.values())).relative_to(self.primary).as_posix()
1210
+ if not all(path.is_file() for path in paths.values()):
1211
+ raise DriverStop("the canonical wave review must be collected before the panel")
1212
+ output = project / "review" / f"wave-{self.wave}.cycle{self.cycle}.panel.md"
1213
+ skipped = project / "review" / f"wave-{self.wave}.cycle{self.cycle}.panel.skipped.json"
1214
+ states = self.states()
1215
+ roster_path = self.root / f"wave-{self.wave}-cycle-{self.cycle}" / "roster.json"
1216
+ if roster_path.is_file():
1217
+ roster = load_state(roster_path)
1218
+ else:
1219
+ if states:
1220
+ raise DriverStop("panel records exist without a persisted roster")
1221
+ arguments = ["resolve", "--plan", str(project / "plan/PLAN.md"),
1222
+ "--intent", str(project / "intent/INTENT.md"),
1223
+ "--advertised", self.options.advertised]
1224
+ if self.options.parent_slug:
1225
+ arguments += ["--parent-slug", self.options.parent_slug]
1226
+ if (project / "CHARTER.md").exists():
1227
+ arguments += ["--charter", str(project / "CHARTER.md")]
1228
+ resolved = helper_json(self.receipt, "review_panel.py", *arguments, cwd=self.primary)
1229
+ self.receipt["resolve"] = resolved
1230
+ if resolved["status"] == "off":
1231
+ self.receipt["status"] = "off"
1232
+ return self.receipt
1233
+ if resolved["status"] == "skipped":
1234
+ base = review_base(self.root.parent, self.wave, self.cycle)
1235
+ failure = self.input_failure(base, paths)
1236
+ if failure:
1237
+ raise DriverStop(failure)
1238
+ if skipped.exists(): # an interrupted checkpoint left a valid receipt: reuse it
1239
+ try:
1240
+ saved = json.loads(skipped.read_text(encoding="utf-8"))
1241
+ except (OSError, json.JSONDecodeError):
1242
+ saved = {}
1243
+ if not isinstance(saved, dict) or saved.get("status") != "skipped":
1244
+ raise DriverStop("panel skipped receipt exists but is not a skipped receipt",
1245
+ path=str(skipped))
1246
+ else:
1247
+ _common.atomic_write(skipped, str(resolved.pop("_stdout")))
1248
+ self.receipt["skipped_receipt"] = str(skipped)
1249
+ failure = self.input_failure(base, paths)
1250
+ if failure:
1251
+ raise DriverStop(failure)
1252
+ self.checkpoint(paths, "persist the wave review and the skipped panel receipt")
1253
+ self.receipt["status"] = "skipped"
1254
+ return self.receipt
1255
+ if skipped.exists():
1256
+ raise DriverStop("a skipped-panel receipt exists for this cycle", path=str(skipped))
1257
+ roster = {"families": [item["family"] for item in resolved["selected"]],
1258
+ "slugs": {item["family"]: item["slug"] for item in resolved["selected"]},
1259
+ "mode": resolved["mode"], "base": isolation.current_sha(self.primary)}
1260
+ save_state(roster_path, roster)
1261
+ self.receipt["mode"] = roster["mode"]
1262
+ if set(states) - set(roster["families"]) or any(
1263
+ state["base"] != roster["base"] for state in states.values()):
1264
+ raise DriverStop("panel records disagree with the persisted roster")
1265
+ missing = [family for family in roster["families"] if family not in states]
1266
+ if missing:
1267
+ base = roster["base"]
1268
+ if isolation.current_sha(self.primary) != base:
1269
+ raise DriverStop("cannot resume missing panel families: HEAD moved from the recorded base")
1270
+ tasks = wave_tasks(self.primary, self.project_dir, self.wave, self.receipt)
1271
+ for family in missing:
1272
+ slug = roster["slugs"][family]
1273
+ state = {"task_id": self.family_name(family), "family": family, "slug": slug,
1274
+ "wave": self.wave, "cycle": self.cycle, "base": base, "mode": roster["mode"],
1275
+ "relative": self.relative(family)}
1276
+ spawn_in_sidecar(self.root, self.primary, self.receipt, self.options,
1277
+ f"wave-{self.wave}-cycle-{self.cycle}-panel-{family}", state,
1278
+ lambda fresh: self.brief(fresh, tasks, depth, review),
1279
+ command=self.options.child_command.replace("{model}", slug))
1280
+ states = self.states()
1281
+ if not self.poll(states, deadline):
1282
+ return self.receipt
1283
+ if self.receipt["blocked"]:
1284
+ self.receipt["status"] = "blocked"
1285
+ return self.receipt
1286
+ if set(states) != set(roster["families"]) or not all(
1287
+ state.get("outcome") == "collected" for state in states.values()):
1288
+ raise DriverStop("the complete panel roster must be collected before merge")
1289
+ mode = roster["mode"]
1290
+ inputs = ",".join(str(self.primary / entry["path"]) for entry in self.receipt["families"].values())
1291
+ failure = self.input_failure(str(roster["base"]), paths)
1292
+ if failure:
1293
+ raise DriverStop(failure)
1294
+ merged = helper_json(self.receipt, "review_panel.py", "merge", "--kind", "wave", "--wave", str(self.wave),
1295
+ "--cycle", str(self.cycle), "--inputs", inputs, "--output", str(output),
1296
+ "--mode", str(mode), cwd=self.primary)
1297
+ self.receipt["merge"] = merged
1298
+ self.receipt["panel"] = output.relative_to(self.primary).as_posix()
1299
+ failure = self.input_failure(str(roster["base"]), paths)
1300
+ if failure:
1301
+ raise DriverStop(failure)
1302
+ self.checkpoint(paths, "persist the wave review and its review panel")
1303
+ self.receipt["status"] = "pass"
1304
+ return self.receipt
1305
+ except STOP_ERRORS as error:
1306
+ return stop(self.receipt, error)
1307
+
1308
+ def input_failure(self, base: str, paths: Dict[str, Path]) -> Optional[str]:
1309
+ """Only this cycle's review and panel artifacts may differ from the recorded base."""
1310
+ review_dir = self.primary / self.project_dir / "review"
1311
+ allowed = {path.relative_to(self.primary).as_posix() for path in paths.values()}
1312
+ allowed |= {(review_dir / name).relative_to(self.primary).as_posix() for name in (
1313
+ f"wave-{self.wave}.cycle{self.cycle}.panel.md", f"wave-{self.wave}.cycle{self.cycle}.panel.skipped.json")}
1314
+ allowed |= {relative for relative in (state.get("relative") for state in self.states().values()) if relative}
1315
+ head = isolation.current_sha(self.primary)
1316
+ committed = set(filter(None, isolation.git_text(
1317
+ self.primary, "diff", "--no-renames", "--name-only", "-z", base, head, "--").split("\0")))
1318
+ if head != base or (committed | isolation.uncommitted_paths(self.primary)) - allowed:
1319
+ return "inputs changed after the review base; run a new cycle"
1320
+ return None
1321
+
1322
+ def checkpoint(self, paths: Dict[str, Path], reason: str) -> None:
1323
+ verdicts = [contracts.validate_wave_evidence(
1324
+ self.primary, self.project_dir, path.relative_to(self.primary).as_posix())["verdict"]
1325
+ for path in paths.values()]
1326
+ self.receipt["review_verdict"] = "pass" if all(verdict == "pass" for verdict in verdicts) else "blocked"
1327
+ self.receipt["checkpoint"] = None
1328
+ if self.receipt["review_verdict"] == "pass":
1329
+ self.receipt["checkpoint"] = checkpoint_project(
1330
+ self.primary, self.receipt, f"build: record wave {self.wave} cycle {self.cycle} review",
1331
+ f"Why: {reason}\nWave: {self.wave}")
1332
+
1333
+
1334
+ class Skeptics(Children):
1335
+ """Step 7's skeptic branch: one read-only skeptic per `needs-skeptic` locator in its own sidecar at
1336
+ the review base, validated, collected, retired, then the findings helper rerun. The skeptic files
1337
+ stay uncommitted for the fix-task or ruling checkpoint."""
1338
+
1339
+ records, field, results = "skeptics", "locator", "skeptics"
1340
+
1341
+ def compute(self) -> Dict[str, object]:
1342
+ try:
1343
+ return review_findings.compute(self.primary, self.project_dir, self.wave, self.cycle)
1344
+ except review_findings.ReviewFindingsError as error:
1345
+ raise DriverStop(str(error), helper="review_findings.py collect")
1346
+
1347
+ def validate(self, staged: Path) -> Dict[str, object]:
1348
+ group = next(group for group in self.compute()["groups"]
1349
+ if staged.name == f"wave-{self.wave}.cycle{self.cycle}.skeptic-{group['locator']}.md")
1350
+ return {"verdict": review_findings.parse_skeptic(
1351
+ staged, self.wave, [item["text"] for item in group["observations"]])["verdict"]}
1352
+
1353
+ def entry(self, state: Dict[str, object]) -> Dict[str, object]:
1354
+ return {"verdict": state.get("verdict"), "path": state.get("relative")}
1355
+
1356
+ def brief(self, state: Dict[str, object], group: Dict[str, object], tasks: List[Dict[str, object]]) -> str:
1357
+ worktree = Path(str(state["worktree"]))
1358
+ cited = [task for task in tasks if task["task_id"] in group["tasks"]]
1359
+ lines = [
1360
+ f"You are one finding-skeptic child for GSD Path wave {self.wave}, cycle {self.cycle}; logical task "
1361
+ f"name {state['task_id']}. Mode: skeptic.",
1362
+ f"Read the reviewer role brief first and follow its Skeptic mode exactly: {self.options.role_brief}",
1363
+ f"Template (use only this): {self.options.template}",
1364
+ f"Criterion locator: {group['locator']}",
1365
+ f"Criterion: {group['criterion']}",
1366
+ f"Lenses: {', '.join(group['lenses'])}",
1367
+ "Observations to refute, verbatim, one per line:",
1368
+ *[f"- [{item['lens']}] {item['text']}" for item in group["observations"]],
1369
+ f"Repository root, read-only: {self.primary}",
1370
+ f"Your verify sidecar root, the only place you may write or apply patches: {worktree} "
1371
+ f"on branch {state['branch']} at the recorded review base {state['base']}.",
1372
+ f"Write exactly this output file: {worktree / str(state['relative'])}",
1373
+ f"INTENT.md: {self.primary / self.project_dir / 'intent/INTENT.md'}",
1374
+ "Tasks the observations cite, each with its recorded base and proven landing commit:",
1375
+ *[f"- {task['task_id']}: {self.primary / str(task['task'])} — base {task['base']}, "
1376
+ f"landing commit {task['commit']}" for task in cited],
1377
+ "Do not write a Wave verdict. Never edit the primary. Never delegate. Never stage or commit.",
1378
+ "Terminal result: the `## Verdict` of the file you wrote is your result; name it in your final message.",
1379
+ ]
1380
+ return "\n".join(lines) + "\n"
1381
+
1382
+ def run(self) -> Dict[str, object]:
1383
+ deadline = time.monotonic() + self.options.wait if self.options.wait else None
1384
+ try:
1385
+ findings = self.compute()
1386
+ self.receipt["findings"] = findings
1387
+ escalation = [key for key in ("structural_blockers", "cap_reached") if findings.get(key)]
1388
+ if escalation:
1389
+ self.receipt.update(status="escalate", escalation=escalation)
1390
+ return self.receipt
1391
+ states = self.states()
1392
+ missing = [locator for locator in findings["skeptic_groups"] if locator not in states]
1393
+ if not states and not missing:
1394
+ self.receipt.update(status="none", reason="no skeptic groups")
1395
+ return self.receipt
1396
+ if missing:
1397
+ base = review_base(self.root.parent, self.wave, self.cycle)
1398
+ if isolation.current_sha(self.primary) != base:
1399
+ raise DriverStop("cannot dispatch skeptics: HEAD moved from the recorded review base")
1400
+ groups = {group["locator"]: group for group in findings["groups"]}
1401
+ tasks = wave_tasks(self.primary, self.project_dir, self.wave, self.receipt)
1402
+ for locator in missing:
1403
+ state = {"task_id": f"review_wave_{self.wave}_cycle_{self.cycle}_skeptic_{locator}",
1404
+ "locator": locator, "wave": self.wave, "cycle": self.cycle, "base": base,
1405
+ "relative": f"{self.project_dir}/review/wave-{self.wave}.cycle{self.cycle}.skeptic-{locator}.md"}
1406
+ spawn_in_sidecar(self.root, self.primary, self.receipt, self.options,
1407
+ f"wave-{self.wave}-cycle-{self.cycle}-skeptic-{locator}", state,
1408
+ lambda fresh, group=groups[locator]: self.brief(fresh, group, tasks))
1409
+ states = self.states()
1410
+ if not self.poll(states, deadline):
1411
+ return self.receipt
1412
+ if self.receipt["blocked"]:
1413
+ self.receipt["status"] = "blocked"
1414
+ return self.receipt
1415
+ self.receipt["findings"] = self.compute() # the helper routes refutations and fix groups
1416
+ self.receipt["status"] = "done"
1417
+ return self.receipt
1418
+ except STOP_ERRORS as error:
1419
+ return stop(self.receipt, error)
1420
+
1421
+
1422
+ def next_task_id(tasks_dir: Path) -> str:
1423
+ numbers = []
1424
+ for path in tasks_dir.glob("*.md"):
1425
+ fields, _ = isolation.task_frontmatter(path.read_text(encoding="utf-8"))
1426
+ match = re.fullmatch(r"T(\d+)", str((fields or {}).get("id", "")))
1427
+ if match:
1428
+ numbers.append(int(match.group(1)))
1429
+ return f"T{max(numbers, default=0) + 1:03d}"
1430
+
1431
+
1432
+ def fix_tasks(primary: Path, options: argparse.Namespace) -> Dict[str, object]:
1433
+ """Step 7: one fix task per findings batch, carrying failed criteria and observations verbatim."""
1434
+ receipt: Dict[str, object] = {"status": None, "wave": options.wave, "cycle": options.cycle,
1435
+ "created": [], "existing": [], "carried": [], "blocked": [], "steps": []}
1436
+ try:
1437
+ project = primary / options.project_dir
1438
+ tasks_dir = project / "tasks"
1439
+ repairs = []
1440
+ for path in sorted(tasks_dir.glob("*-fix-wave-*-cycle-*.md")):
1441
+ text = path.read_text(encoding="utf-8")
1442
+ fields, _ = isolation.task_frontmatter(text)
1443
+ repairs.append({"task": fields["id"], "path": path.relative_to(primary).as_posix(),
1444
+ "wave": int(fields["wave"]), "locators": re.findall(r"(?m)^### (.+)$", _common.section_body(text, "Review findings") or "")})
1445
+ suffix = f"-fix-wave-{options.wave}-cycle-{options.cycle}.md"
1446
+ existing = [item for item in repairs if item["path"].endswith(suffix)]
1447
+ carried = {locator for item in repairs if item not in existing for locator in item["locators"]}
1448
+ findings = review_findings.compute(primary, options.project_dir, options.wave, options.cycle)
1449
+ receipt["findings"] = findings
1450
+ escalation = [key for key in ("structural_blockers", "skeptic_groups", "cap_reached", "all_refuted")
1451
+ if findings.get(key)]
1452
+ if escalation: # step 7's branch table for these stays with the parent and the user
1453
+ receipt.update(status="escalate", escalation=escalation)
1454
+ return receipt
1455
+ if not findings.get("fix_batches"):
1456
+ receipt.update(status="none", reason="no fix batches")
1457
+ return receipt
1458
+ texts = contracts._task_texts(primary, options.project_dir)
1459
+ groups = {group["locator"]: group for group in findings["groups"]}
1460
+ today = now()[:10]
1461
+ # Same-wave file overlap is forbidden, so repairs land in a newly appended wave.
1462
+ plan_path = project / "plan/PLAN.md"
1463
+ plan_text = plan_path.read_text(encoding="utf-8")
1464
+ depths, waves = contracts._plan_waves(plan_text)
1465
+ repair_waves = {item["wave"] for item in existing}
1466
+ if len(repair_waves) > 1:
1467
+ raise DriverStop("repair tasks for this cycle disagree on their wave")
1468
+ repair_heading = re.search(
1469
+ rf"(?m)^## Wave (\d+) — fix wave {options.wave} cycle {options.cycle} review findings$", plan_text)
1470
+ if repair_waves:
1471
+ fix_wave = next(iter(repair_waves))
1472
+ elif repair_heading:
1473
+ fix_wave = int(repair_heading.group(1))
1474
+ else:
1475
+ fix_wave = max(waves) + 1
1476
+ if repair_heading and int(repair_heading.group(1)) != fix_wave:
1477
+ raise DriverStop("repair tasks disagree with the PLAN repair wave")
1478
+ receipt["existing"] = existing
1479
+ for batch in findings["fix_batches"]:
1480
+ if any(set(batch["locators"]) <= set(item["locators"]) for item in existing):
1481
+ continue
1482
+ if all(locator in carried for locator in batch["locators"]):
1483
+ receipt["carried"].append(batch)
1484
+ continue
1485
+ batch_groups = [groups[locator] for locator in batch["locators"]]
1486
+ sources = sorted({task for group in batch_groups for task in group["tasks"]})
1487
+ for source in sources:
1488
+ if source not in texts:
1489
+ raise DriverStop(f"fix batch names unknown task {source}")
1490
+ task_id = next_task_id(tasks_dir)
1491
+ verify = "set -e\n" + "\n".join( # each source Verify in its own subshell
1492
+ f"(\n{command}\n)" for command in dict.fromkeys(_common.task_verify_command(texts[source]) for source in sources))
1493
+ heavy = any(re.search(r"(?m)^Heavy:\s*yes", texts[source]) for source in sources)
1494
+ criteria = "\n".join(f"{index}. {criterion}" for index, criterion in
1495
+ enumerate(dict.fromkeys(group["criterion"] for group in batch_groups), 1))
1496
+ blocks = "\n\n".join(
1497
+ f"### {group['locator']}\nCriterion: {group['criterion']}\n"
1498
+ + "\n".join(f"- {item['text']}" for item in group["observations"]) for group in batch_groups)
1499
+ files = "\n".join(f" - {path}" for path in batch["files"])
1500
+ title = f"Fix wave {options.wave} cycle {options.cycle} review findings in {', '.join(sources)}"
1501
+ text = f"""---
1502
+ id: {task_id}
1503
+ title: {title}
1504
+ wave: {fix_wave}
1505
+ deps: [{', '.join(sources)}]
1506
+ status: pending
1507
+ agent: null
1508
+ base: null
1509
+ worktree: null
1510
+ task_branch: null
1511
+ files:
1512
+ {files}
1513
+ ---
1514
+
1515
+ # {task_id} — {title}
1516
+
1517
+ ## Context
1518
+
1519
+ Repair task created from the wave {options.wave} cycle {options.cycle} review of {', '.join(sources)}.
1520
+ The failed criteria and every reviewer observation are recorded verbatim under Review findings.
1521
+ The source task contracts are unchanged; read them and .project/intent/INTENT.md before editing.
1522
+
1523
+ ## Approach
1524
+
1525
+ - Fix only what the Review findings name, inside the listed files; do not change the source
1526
+ tasks' acceptance criteria or interface contracts.
1527
+ - Re-run the source tasks' Verify commands after the fix and record the result in the Log.
1528
+
1529
+ ## Interface contract
1530
+
1531
+ - None
1532
+
1533
+ ## Intent coverage
1534
+
1535
+ - None
1536
+
1537
+ ## Acceptance criteria
1538
+
1539
+ {criteria}
1540
+
1541
+ ## Verify
1542
+
1543
+ ```bash
1544
+ {verify}
1545
+ ```
1546
+
1547
+ Heavy: {'yes' if heavy else 'no'}
1548
+
1549
+ ## Review findings
1550
+
1551
+ {blocks}
1552
+
1553
+ ## Log
1554
+
1555
+ - {today} — created by dispatch_driver.py fix-tasks from wave {options.wave} cycle {options.cycle}
1556
+ """
1557
+ path = tasks_dir / f"{task_id}-fix-wave-{options.wave}-cycle-{options.cycle}.md"
1558
+ _common.atomic_write(path, text)
1559
+ receipt["created"].append({"task": task_id, "path": path.relative_to(primary).as_posix(),
1560
+ "wave": fix_wave, "deps": sources, "files": batch["files"],
1561
+ "locators": batch["locators"]})
1562
+ rows = []
1563
+ for item in [*existing, *receipt["created"]]:
1564
+ fields, _ = isolation.task_frontmatter((primary / item["path"]).read_text(encoding="utf-8"))
1565
+ rows.append(f"| {item['task']} | {fields['title']} | {', '.join(fields['deps'])} | "
1566
+ f"{', '.join(fields['files'])} |")
1567
+ if not rows:
1568
+ receipt.update(status="none", reason="all fix batches already carried")
1569
+ return receipt
1570
+ block = (f"## Wave {fix_wave} — fix wave {options.wave} cycle {options.cycle} review findings\n\n"
1571
+ f"Goal: Repair the blocking findings of the wave {options.wave} cycle {options.cycle} review "
1572
+ "without changing source task contracts.\n"
1573
+ f"Review depth: {depths[options.wave]}\n\n| Task | Title | Deps | Files |\n|------|-------|------|-------|\n"
1574
+ + "\n".join(rows) + "\n\n")
1575
+ if repair_heading:
1576
+ start = repair_heading.start()
1577
+ following = re.search(r"(?m)^## ", plan_text[repair_heading.end():])
1578
+ end = repair_heading.end() + following.start() if following else len(plan_text)
1579
+ current = plan_text[start:end]
1580
+ present = set(re.findall(r"(?m)^\|\s*(T\d+)\s*\|", current))
1581
+ missing_rows = [row for row in rows if row.split("|")[1].strip() not in present]
1582
+ updated = plan_text
1583
+ if missing_rows:
1584
+ updated = plan_text[:end].rstrip() + "\n" + "\n".join(missing_rows) + "\n\n" + plan_text[end:]
1585
+ else:
1586
+ marker = "## Intent coverage"
1587
+ index = plan_text.index(marker) if marker in plan_text else len(plan_text)
1588
+ updated = plan_text[:index] + block + plan_text[index:]
1589
+ plan_changed = updated != plan_text
1590
+ if plan_changed:
1591
+ _common.atomic_write(plan_path, updated)
1592
+ receipt["plan_wave"] = fix_wave
1593
+ lint = subprocess.run([sys.executable, "-B", str(Path(__file__).resolve().parent / "check_task_briefs.py"),
1594
+ "--repo", str(primary), "--base", isolation.current_sha(primary),
1595
+ "--tasks-dir", f"{options.project_dir}/tasks"], capture_output=True, text=True)
1596
+ receipt["steps"].append({"script": "check_task_briefs.py", "exit_code": lint.returncode,
1597
+ "stdout": lint.stdout, "stderr": lint.stderr})
1598
+ if lint.returncode:
1599
+ raise DriverStop("fix task briefs failed lint", stderr=lint.stderr.strip())
1600
+ # The blocked review and its fix tasks land in one bookkeeping checkpoint, as step 7 requires.
1601
+ dirty = any(path.startswith(".project/") for path in isolation.uncommitted_paths(primary))
1602
+ receipt["checkpoint"] = checkpoint_project(
1603
+ primary, receipt, f"build: record wave {options.wave} cycle {options.cycle} review and fix tasks",
1604
+ f"Why: persist the blocked review verdict with the fix tasks it batched\nWave: {options.wave}\n"
1605
+ f"Tasks: {', '.join(item['task'] for item in [*existing, *receipt['created']])}")
1606
+ receipt["status"] = "created" if receipt["created"] or plan_changed or dirty else "exists"
1607
+ return receipt
1608
+ except STOP_ERRORS as error:
1609
+ return stop(receipt, error)
1610
+
1611
+
1612
+ def complete(primary: Path, options: argparse.Namespace) -> Dict[str, object]:
1613
+ """Completion: every wave landed and its last review cycle passed; landing proof; ship/active; checkpoint."""
1614
+ receipt: Dict[str, object] = {"status": None, "blocked": [], "steps": []}
1615
+ try:
1616
+ dirty = sorted(isolation.uncommitted_paths(primary))
1617
+ if dirty:
1618
+ raise DriverStop("completion requires a clean primary", paths=dirty)
1619
+ state, _, _ = pipeline_state.load_state(primary, options.project_dir)
1620
+ if (state.phase, state.status) not in (("build", "active"), ("build", "done")):
1621
+ raise DriverStop(f"completion requires build/active, not {state.phase}/{state.status}")
1622
+ ready = build_state.ready(str(primary), options.project_dir, allow_done=True)
1623
+ receipt["steps"].append({"script": "build_state.py ready", "result": ready})
1624
+ if ready["current_wave"] is not None:
1625
+ raise DriverStop(f"wave {ready['current_wave']} is still unfinished")
1626
+ # The archive-time check is the one definition of "every wave passed": contiguous cycles,
1627
+ # passing last-cycle wave and task verdicts, and the panel and skeptic artifacts each cycle owes.
1628
+ receipt["review_cycles"] = list(discussion_validate.review_cycle_counts(primary / options.project_dir))
1629
+ head = isolation.current_sha(primary)
1630
+ run_step(primary, options.project_dir, receipt, "build-evidence", head)
1631
+ transition(primary, options.project_dir, receipt, state, {"phase": "ship", "status": "active"},
1632
+ "build done; final review pending")
1633
+ receipt["checkpoint"] = checkpoint_project(
1634
+ primary, receipt, "build: complete milestone",
1635
+ f"Why: every wave landed and its latest review passed; landing proof recorded\nBase: {head}")
1636
+ receipt["status"] = "done"
1637
+ return receipt
1638
+ except STOP_ERRORS as error:
1639
+ return stop(receipt, error)
1640
+
1641
+
1642
+ # --- other actions ---------------------------------------------------------
1643
+
1644
+
1645
+ def answer(primary: Path, task_id: str, text: str) -> Dict[str, object]:
1646
+ states = [state for state in latest_states(records_root(primary)) if state["task_id"] == task_id]
1647
+ if not states or states[0].get("outcome") != "question":
1648
+ raise DriverStop(f"task {task_id} has no open question")
1649
+ state = states[0]
1650
+ task_path = Path(str(state["worktree"])) / str(state["task_file"])
1651
+ append_log(task_path, f"- {now()[:10]} — {ANSWER_MARK} {text}")
1652
+ update_state(state, answered=True, answer=text)
1653
+ return {"status": "answered", "task": task_id, "task_file": str(task_path),
1654
+ "next": "run `round` to redispatch the retained isolate"}
1655
+
1656
+
1657
+ def require_files(parser: argparse.ArgumentParser, arguments: argparse.Namespace, *names: str) -> None:
1658
+ for name in names:
1659
+ value = getattr(arguments, name)
1660
+ if value is None or not Path(value).is_file():
1661
+ parser.error(f"--{name.replace('_', '-')} must name an existing file")
1662
+ setattr(arguments, name, Path(value).resolve())
1663
+
1664
+
1665
+ def positive_int(value: str) -> int:
1666
+ number = int(value)
1667
+ if number < 1:
1668
+ raise argparse.ArgumentTypeError("value must be at least 1")
1669
+ return number
1670
+
1671
+
1672
+ def default_resource(name: str) -> Optional[Path]:
1673
+ candidate = Path(__file__).resolve().parent.parent / name
1674
+ return candidate if candidate.is_file() else None
1675
+
1676
+
1677
+ def main(argv=None) -> int:
1678
+ parser = argparse.ArgumentParser(description=__doc__)
1679
+ commands = parser.add_subparsers(dest="action", required=True)
1680
+ round_parser = commands.add_parser("round", help="settle, land, and dispatch the current wave")
1681
+ round_parser.add_argument("--repo", type=Path, required=True)
1682
+ round_parser.add_argument("--project-dir", default=".project")
1683
+ round_parser.add_argument("--child-command", required=True,
1684
+ help="owner-supplied command that reads the brief on stdin")
1685
+ round_parser.add_argument("--wait", type=float, help="seconds to keep polling before returning")
1686
+ round_parser.add_argument("--wave", type=int, required=True, help="wave selected by the parent")
1687
+ round_parser.add_argument("--child-timeout", type=float, help="per-child wall clock in seconds")
1688
+ round_parser.add_argument("--capacity", type=positive_int, help="owner's concurrent-child limit")
1689
+ round_parser.add_argument("--max-attempts", type=positive_int, default=2,
1690
+ help="dispatches per task per milestone before a person must rule; "
1691
+ "default 2 is the build contract's one logged redispatch")
1692
+ round_parser.add_argument("--task-limit", type=positive_int, help="owner output-token limit per task")
1693
+ round_parser.add_argument("--session-limit", type=positive_int,
1694
+ help="owner output-token limit for the milestone")
1695
+ round_parser.add_argument("--budget-authority", help="quoted owner policy for the limits")
1696
+ round_parser.add_argument("--role-brief", type=Path, default=default_resource("references/coder.md"))
1697
+ round_parser.add_argument("--task-template", type=Path, default=default_resource("templates/task.md"))
1698
+ cycle_child = argparse.ArgumentParser(add_help=False) # shared by the review-cycle child runners
1699
+ cycle_child.add_argument("--repo", type=Path, required=True)
1700
+ cycle_child.add_argument("--project-dir", default=".project")
1701
+ cycle_child.add_argument("--wave", type=int, required=True)
1702
+ cycle_child.add_argument("--cycle", type=positive_int, required=True)
1703
+ cycle_child.add_argument("--child-command", required=True,
1704
+ help="owner-supplied command that reads the brief on stdin; "
1705
+ "for panel, a literal {model} is replaced by the family slug")
1706
+ cycle_child.add_argument("--wait", type=float, help="seconds to keep polling before returning")
1707
+ cycle_child.add_argument("--child-timeout", type=float, help="per-child wall clock in seconds")
1708
+ cycle_child.add_argument("--role-brief", type=Path, default=default_resource("references/reviewer.md"))
1709
+ review_parser = commands.add_parser("review", parents=[cycle_child],
1710
+ help="run one wave review cycle at full or deep depth")
1711
+ review_parser.add_argument("--repair-evidence", type=Path,
1712
+ help="review_findings.py repair-evidence receipt for a re-review")
1713
+ review_parser.add_argument("--template", type=Path, default=default_resource("templates/wave-review.md"))
1714
+ panel_parser = commands.add_parser("panel", parents=[cycle_child],
1715
+ help="run the optional review panel for a collected wave review")
1716
+ panel_parser.add_argument("--advertised", required=True, help="comma-separated model slugs the host advertises")
1717
+ panel_parser.add_argument("--parent-slug", help="the parent's model slug when known")
1718
+ panel_parser.add_argument("--template", type=Path, default=default_resource("templates/wave-panel.md"))
1719
+ skeptic_parser = commands.add_parser("skeptics", parents=[cycle_child],
1720
+ help="run one finding skeptic per needs-skeptic locator")
1721
+ skeptic_parser.add_argument("--template", type=Path, default=default_resource("templates/skeptic.md"))
1722
+ fix_parser = commands.add_parser("fix-tasks", help="write one fix task per findings batch of a blocked cycle")
1723
+ fix_parser.add_argument("--repo", type=Path, required=True)
1724
+ fix_parser.add_argument("--project-dir", default=".project")
1725
+ fix_parser.add_argument("--wave", type=int, required=True)
1726
+ fix_parser.add_argument("--cycle", type=positive_int, required=True)
1727
+ complete_parser = commands.add_parser(
1728
+ "complete", help="prove every wave landed and passed review, record landing evidence, enter ship")
1729
+ complete_parser.add_argument("--repo", type=Path, required=True)
1730
+ complete_parser.add_argument("--project-dir", default=".project")
1731
+ finish_parser = commands.add_parser(
1732
+ "finish", help="verify, land, record, and retire one in-progress task whose coder has returned")
1733
+ finish_parser.add_argument("--repo", type=Path, required=True)
1734
+ finish_parser.add_argument("--task-id", required=True)
1735
+ finish_parser.add_argument("--project-dir", default=".project")
1736
+ answer_parser = commands.add_parser("answer", help="record an orchestrator answer for a question")
1737
+ answer_parser.add_argument("--repo", type=Path, required=True)
1738
+ answer_parser.add_argument("--task-id", required=True)
1739
+ answer_parser.add_argument("--answer", required=True)
1740
+ status_parser = commands.add_parser("status", help="list dispatch records")
1741
+ status_parser.add_argument("--repo", type=Path, required=True)
1742
+ child_parser = commands.add_parser("_child", help=argparse.SUPPRESS)
1743
+ child_parser.add_argument("--state", type=Path, required=True)
1744
+ arguments = parser.parse_args(argv)
1745
+
1746
+ if arguments.action == "_child":
1747
+ return child_main(arguments.state)
1748
+ primary = arguments.repo.resolve()
1749
+ lock = None
1750
+ try:
1751
+ if arguments.action != "status":
1752
+ lock = acquire_lock(primary)
1753
+ if arguments.action == "panel":
1754
+ require_files(parser, arguments, "role_brief", "template")
1755
+ result = Panel(primary, arguments).run()
1756
+ elif arguments.action == "skeptics":
1757
+ require_files(parser, arguments, "role_brief", "template")
1758
+ result = Skeptics(primary, arguments).run()
1759
+ elif arguments.action == "fix-tasks":
1760
+ result = fix_tasks(primary, arguments)
1761
+ elif arguments.action == "complete":
1762
+ result = complete(primary, arguments)
1763
+ elif arguments.action == "review":
1764
+ require_files(parser, arguments, "role_brief", "template",
1765
+ *(["repair_evidence"] if arguments.repair_evidence is not None else []))
1766
+ result = Review(primary, arguments).run()
1767
+ elif arguments.action == "round":
1768
+ budget_values = (arguments.task_limit, arguments.session_limit, arguments.budget_authority)
1769
+ if any(value is not None for value in budget_values) and None in budget_values:
1770
+ parser.error("--task-limit, --session-limit, and --budget-authority go together")
1771
+ require_files(parser, arguments, "role_brief", "task_template")
1772
+ result = Round(primary, arguments).run()
1773
+ elif arguments.action == "finish":
1774
+ current = Round(primary, arguments)
1775
+ current.recover()
1776
+ records = [state for state in latest_states(current.root)
1777
+ if state["task_id"] == arguments.task_id]
1778
+ if arguments.task_id in current.proven:
1779
+ commit = current.proven[arguments.task_id]
1780
+ if records and records[0].get("outcome") is None:
1781
+ update_state(records[0], outcome="landed", commit=commit)
1782
+ current.receipt["landed"].append(
1783
+ {"task": arguments.task_id, "commit": commit, "recovered": True})
1784
+ elif records:
1785
+ record = records[0]
1786
+ if record.get("outcome") is not None:
1787
+ raise DriverStop(f"task {arguments.task_id} dispatch record is {record['outcome']}; "
1788
+ "use round")
1789
+ if record.get("finished_at") is None:
1790
+ raise DriverStop(f"task {arguments.task_id} still has a running child")
1791
+ current.classify(record)
1792
+ else: # a coder dispatched by hand: derive the isolate from the task frontmatter
1793
+ current.receipt["landed"].append(
1794
+ finish_task(primary, state_from_task(primary, arguments.task_id)))
1795
+ result = {**current.receipt, "status": "landed" if current.receipt["landed"] else "blocked"}
1796
+ elif arguments.action == "answer":
1797
+ result = answer(primary, arguments.task_id, arguments.answer)
1798
+ else:
1799
+ result = {"status": "ok", "dispatches": [
1800
+ {key: value for key, value in state.items() if key not in ("_path", "command")}
1801
+ for state in latest_states(records_root(primary))]}
1802
+ except STOP_ERRORS as error:
1803
+ result = {"status": "blocked", "reason": str(error), **getattr(error, "details", {})}
1804
+ finally:
1805
+ if lock is not None:
1806
+ lock.close()
1807
+ print(json.dumps(result, sort_keys=True, default=str))
1808
+ return 0 if result["status"] != "blocked" else 1
1809
+
1810
+
1811
+ if __name__ == "__main__":
1812
+ raise SystemExit(main())