@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,2291 @@
1
+ #!/usr/bin/env python3
2
+ # gsd-path project runtime
3
+ """Validate, route, and change GSD Path state without model-side parsing.
4
+
5
+ The helper owns the fixed-format STATE.md interface. It checkpoints plan and
6
+ roadmap approvals, promotes a lookahead track as a resumable transaction, and
7
+ classifies plan drift before the promoted milestone can enter build.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import contextlib
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ from dataclasses import asdict, dataclass
23
+ from datetime import date
24
+ from pathlib import Path, PurePosixPath
25
+ from typing import Iterator, Mapping, Optional, Sequence
26
+
27
+ try:
28
+ from isolation import (
29
+ IsolationError,
30
+ authorized_task_worktree,
31
+ checkpoint as isolation_checkpoint,
32
+ collect_artifact_recoveries,
33
+ )
34
+ import _common
35
+ except ModuleNotFoundError as error: # pragma: no cover - package imports used by tests
36
+ if error.name not in {"isolation", "_common"}:
37
+ raise
38
+ from scripts.isolation import (
39
+ IsolationError,
40
+ authorized_task_worktree,
41
+ checkpoint as isolation_checkpoint,
42
+ collect_artifact_recoveries,
43
+ )
44
+ from scripts import _common
45
+
46
+ try: # pragma: no cover - exercised only on Windows
47
+ import fcntl
48
+ except ImportError: # pragma: no cover - exercised only on Windows
49
+ fcntl = None # type: ignore[assignment]
50
+
51
+
52
+ PIPELINE_MARKER = _common.PIPELINE_MARKER
53
+ PHASES = (
54
+ "inspect",
55
+ "define",
56
+ "research",
57
+ "decide",
58
+ "roadmap",
59
+ "plan",
60
+ "build",
61
+ "ship",
62
+ "shipped",
63
+ )
64
+ STATUSES = ("active", "done", "blocked")
65
+ STATE_FIELDS = (
66
+ "pipeline",
67
+ "project",
68
+ "milestone",
69
+ "phase",
70
+ "status",
71
+ "branch",
72
+ "archive",
73
+ "integration_default",
74
+ "integration",
75
+ "integration_source",
76
+ )
77
+ LEGACY_STATE_FIELDS = STATE_FIELDS[:7]
78
+ INTEGRATION_MODES = ("direct", "pull-request")
79
+ INTEGRATION_SOURCES = ("default", "milestone")
80
+ NULL = "null"
81
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
82
+ BOUND_BRANCH_RE = _common.BOUND_BRANCH_RE
83
+ ARCHIVE_RE = re.compile(r"^\.project/archive/(\d{3,})-([a-z0-9][a-z0-9-]*)/?$")
84
+ FRONTMATTER_RE = re.compile(r"^([a-z_]+):\s*([^#]*?)(?:\s+#.*)?$")
85
+ ROADMAP_HEADING_RE = re.compile(r"^### (M\d{3,}) — ([a-z0-9][a-z0-9-]*)\s*$")
86
+ TASK_ID_RE = re.compile(r"^T\d{3,}$")
87
+ TASK_FILE_RE = re.compile(r"^(T\d{3,})-[a-z0-9][a-z0-9-]*\.md$")
88
+ PLAN_APPROVAL_SUBJECT = "plan: build plan approved"
89
+ PROMOTION_SCHEMA = "gsd-path/promote-next/v1"
90
+ STATE_SCHEMA = "gsd-path/state/v1"
91
+ ROUTE_SCHEMA = "gsd-path/route/v1"
92
+ STATUS_SCHEMA = "gsd-path/status/v1"
93
+ LEGACY_BIND_NEXT_JOURNAL_SCHEMA = "gsd-path/bind-next-journal/v1"
94
+ BIND_NEXT_JOURNAL_SCHEMA = "gsd-path/bind-next-journal/v2"
95
+ BIND_NEXT_JOURNAL_DIR = "gsd-path-bind-next"
96
+ PROMOTION_TRACKS = ("intent", "research", "plan", "tasks", "review")
97
+ LOOKAHEAD_PHASES = ("inspect", "define", "research", "decide", "plan")
98
+ LOOKAHEAD_FIXED_ARTIFACTS = {
99
+ "STATE.md": "inspect",
100
+ "research/evidence-codebase.md": "inspect",
101
+ "research/DOCS-AUDIT.md": "inspect",
102
+ "intent/INTENT.md": "define",
103
+ "research/RESEARCH.md": "research",
104
+ "research/SYNTHESIS.md": "decide",
105
+ "plan/PLAN.md": "plan",
106
+ "review/PLAN-PANEL.md": "plan",
107
+ "review/PLAN-PANEL.skipped.json": "plan",
108
+ }
109
+ LOOKAHEAD_EVIDENCE_RE = re.compile(r"^research/evidence-[a-z0-9][a-z0-9-]*\.md$")
110
+ PROMOTION_RESIDUAL = ".gsd-path-promote-next-residual"
111
+ CHECKPOINT_SCHEMA = "gsd-path/state-checkpoint/v1"
112
+ CHECKPOINT_JOURNAL_NAME = "gsd-path-state-checkpoint.json"
113
+ CHECKPOINT_KINDS = ("plan", "roadmap")
114
+ SHIPMENT_SCHEMA = "gsd-path/shipment/v1"
115
+ SHIPMENT_JOURNAL_NAME = "gsd-path-shipment.json"
116
+ UNDO_TRANSACTION_SCHEMA = "gsd-path/undo-transaction/v2"
117
+ UNDO_TRANSACTION_NAME = "gsd-path-undo.json"
118
+
119
+ CROSS_PHASE_TRANSITIONS = {
120
+ ("inspect", "done", "define", "active"),
121
+ ("define", "done", "research", "active"),
122
+ ("define", "done", "plan", "active"),
123
+ ("research", "done", "decide", "active"),
124
+ ("decide", "done", "roadmap", "active"),
125
+ ("define", "active", "roadmap", "active"),
126
+ ("decide", "done", "plan", "active"),
127
+ ("roadmap", "done", "define", "active"),
128
+ ("plan", "done", "build", "active"),
129
+ ("build", "active", "ship", "active"),
130
+ ("build", "done", "ship", "active"),
131
+ ("ship", "active", "shipped", "done"),
132
+ ("build", "active", "roadmap", "active"),
133
+ ("build", "blocked", "roadmap", "active"),
134
+ ("ship", "blocked", "plan", "active"),
135
+ ("shipped", "done", "define", "active"),
136
+ ("shipped", "done", "inspect", "active"),
137
+ ("roadmap", "active", "inspect", "active"),
138
+ }
139
+
140
+
141
+ class PipelineStateError(RuntimeError):
142
+ """Raised when pipeline state cannot be proven or changed safely."""
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class PipelineState:
147
+ pipeline: str
148
+ project: str
149
+ milestone: Optional[str]
150
+ phase: str
151
+ status: str
152
+ branch: Optional[str]
153
+ archive: Optional[str]
154
+ integration_default: str
155
+ integration: str
156
+ integration_source: str
157
+
158
+ def json(self) -> dict[str, Optional[str]]:
159
+ return asdict(self)
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class ApprovedTaskContract:
164
+ path: str
165
+ text: str
166
+ files: tuple[str, ...]
167
+
168
+
169
+ # state_checkpoint and state_promote call back into this module through a
170
+ # module reference and import the constants and classes above, so this import
171
+ # must stay below those definitions.
172
+ if __package__: # imported as scripts.pipeline_state
173
+ from .state_checkpoint import (
174
+ _sha256,
175
+ _tree_digest,
176
+ _checkpoint_artifact_digest,
177
+ _roadmap_status,
178
+ _activate_roadmap_milestone,
179
+ _approval_details,
180
+ _checkpoint_request_fields,
181
+ _checkpoint_file,
182
+ _approval_journal,
183
+ _remove_checkpoint_temporary,
184
+ _converge_checkpoint_file,
185
+ _unlink_checkpoint_journal,
186
+ _resume_checkpoint_locked,
187
+ checkpoint_approval,
188
+ _checkpoint_deferral_error,
189
+ defer_approval,
190
+ resume_checkpoint,
191
+ _task_files,
192
+ _approval_checkpoint,
193
+ _validate_approval_track,
194
+ _safe_declared_paths,
195
+ _git_text_at,
196
+ _approved_task_contracts,
197
+ _task_contracts_at,
198
+ _classify_plan_drift,
199
+ )
200
+ from .state_promote import (
201
+ _render_roadmap,
202
+ _promotion_event,
203
+ _promotion_state,
204
+ _head_with_exact_message,
205
+ _tree_entries,
206
+ _promotion_tree_mapping,
207
+ _completed_promotion,
208
+ _prepare_promotion,
209
+ _require_journal_request,
210
+ _resume_track_moves,
211
+ _resume_metadata,
212
+ _resume_next_removal,
213
+ _commit_promotion,
214
+ promote_next,
215
+ )
216
+ else: # standalone script or sibling import
217
+ if __name__ == "__main__":
218
+ sys.modules.setdefault("pipeline_state", sys.modules[__name__])
219
+ from state_checkpoint import (
220
+ _sha256,
221
+ _tree_digest,
222
+ _checkpoint_artifact_digest,
223
+ _roadmap_status,
224
+ _activate_roadmap_milestone,
225
+ _approval_details,
226
+ _checkpoint_request_fields,
227
+ _checkpoint_file,
228
+ _approval_journal,
229
+ _remove_checkpoint_temporary,
230
+ _converge_checkpoint_file,
231
+ _unlink_checkpoint_journal,
232
+ _resume_checkpoint_locked,
233
+ checkpoint_approval,
234
+ _checkpoint_deferral_error,
235
+ defer_approval,
236
+ resume_checkpoint,
237
+ _task_files,
238
+ _approval_checkpoint,
239
+ _validate_approval_track,
240
+ _safe_declared_paths,
241
+ _git_text_at,
242
+ _approved_task_contracts,
243
+ _task_contracts_at,
244
+ _classify_plan_drift,
245
+ )
246
+ from state_promote import (
247
+ _render_roadmap,
248
+ _promotion_event,
249
+ _promotion_state,
250
+ _head_with_exact_message,
251
+ _tree_entries,
252
+ _promotion_tree_mapping,
253
+ _completed_promotion,
254
+ _prepare_promotion,
255
+ _require_journal_request,
256
+ _resume_track_moves,
257
+ _resume_metadata,
258
+ _resume_next_removal,
259
+ _commit_promotion,
260
+ promote_next,
261
+ )
262
+
263
+ def _run_git(
264
+ repo: Path,
265
+ *arguments: str,
266
+ check: bool = True,
267
+ ) -> subprocess.CompletedProcess[str]:
268
+ result = subprocess.run(
269
+ ["git", "-C", str(repo), *arguments],
270
+ capture_output=True,
271
+ text=True,
272
+ )
273
+ if check and result.returncode != 0:
274
+ detail = result.stderr.strip() or result.stdout.strip()
275
+ raise PipelineStateError(f"git {' '.join(arguments)} failed: {detail}")
276
+ return result
277
+
278
+
279
+ def _repo_root(repo: Path) -> Path:
280
+ if repo.is_symlink() or not repo.is_dir():
281
+ raise PipelineStateError(f"repo must be a real directory: {repo}")
282
+ resolved = repo.resolve()
283
+ top = _run_git(resolved, "rev-parse", "--show-toplevel", check=False)
284
+ if top.returncode == 0 and Path(top.stdout.strip()).resolve() != resolved:
285
+ raise PipelineStateError(f"repo must be the worktree root: {resolved}")
286
+ return resolved
287
+
288
+
289
+ def _track_root(repo: Path, project_dir: str) -> Path:
290
+ repo = repo.resolve()
291
+ relative = PurePosixPath(project_dir)
292
+ if relative.is_absolute() or ".." in relative.parts or not relative.parts:
293
+ raise PipelineStateError(f"project-dir must stay inside repo: {project_dir}")
294
+ root = repo.joinpath(*relative.parts)
295
+ if root.is_symlink() or not root.is_dir():
296
+ raise PipelineStateError(f"project directory must be real: {root}")
297
+ if repo not in root.resolve().parents:
298
+ raise PipelineStateError(f"project directory escapes repo: {root}")
299
+ return root
300
+
301
+
302
+ def _read_real_file(path: Path, label: str) -> str:
303
+ if path.is_symlink() or not path.is_file():
304
+ raise PipelineStateError(f"{label} must be a real file: {path}")
305
+ return path.read_text(encoding="utf-8")
306
+
307
+
308
+ def _optional_real_file(path: Path, label: str) -> Optional[str]:
309
+ if not path.exists() and not path.is_symlink():
310
+ return None
311
+ return _read_real_file(path, label)
312
+
313
+
314
+ def _parse_frontmatter(text: str, label: str) -> dict[str, str]:
315
+ lines = text.splitlines()
316
+ if not lines or lines[0] != "---":
317
+ raise PipelineStateError(f"{label} is missing YAML frontmatter")
318
+ values: dict[str, str] = {}
319
+ for line in lines[1:]:
320
+ if line == "---":
321
+ return values
322
+ stripped = line.strip()
323
+ if not stripped or stripped.startswith("#"):
324
+ continue
325
+ match = FRONTMATTER_RE.fullmatch(line)
326
+ if match is None:
327
+ raise PipelineStateError(f"{label} has unsupported frontmatter: {line}")
328
+ key, raw_value = match.groups()
329
+ if key in values:
330
+ raise PipelineStateError(f"{label} repeats frontmatter field: {key}")
331
+ values[key] = raw_value.strip().strip("\"'")
332
+ raise PipelineStateError(f"{label} frontmatter is not closed")
333
+
334
+
335
+ def _nullable(value: str) -> Optional[str]:
336
+ return None if value == NULL else value
337
+
338
+
339
+ def _bound_branch_number(value: str) -> Optional[int]:
340
+ match = BOUND_BRANCH_RE.fullmatch(value)
341
+ if match is None:
342
+ return None
343
+ number = int(match.group(1))
344
+ return number if number >= 1 else None
345
+
346
+
347
+ def _state_from_text(text: str, label: str = "STATE.md") -> PipelineState:
348
+ values = _parse_frontmatter(text, label)
349
+ missing = [field for field in LEGACY_STATE_FIELDS if field not in values]
350
+ extra = sorted(set(values) - set(STATE_FIELDS))
351
+ if missing:
352
+ raise PipelineStateError(f"{label} is missing fields: {', '.join(missing)}")
353
+ if extra:
354
+ raise PipelineStateError(f"{label} has unknown fields: {', '.join(extra)}")
355
+ integration_pair = {"integration_default", "integration"}
356
+ integration_fields = integration_pair | {"integration_source"}
357
+ present_integration_fields = integration_fields & set(values)
358
+ if present_integration_fields not in (set(), integration_pair, integration_fields):
359
+ raise PipelineStateError(
360
+ f"{label} integration_default and integration must appear together"
361
+ )
362
+
363
+ integration_default = values.get("integration_default", "direct")
364
+ integration = values.get("integration", "direct")
365
+ integration_source = values.get("integration_source")
366
+ if integration_source is None:
367
+ integration_source = (
368
+ "default" if integration == integration_default else "milestone"
369
+ )
370
+
371
+ state = PipelineState(
372
+ pipeline=values["pipeline"],
373
+ project=values["project"],
374
+ milestone=_nullable(values["milestone"]),
375
+ phase=values["phase"],
376
+ status=values["status"],
377
+ branch=_nullable(values["branch"]),
378
+ archive=_nullable(values["archive"]),
379
+ integration_default=integration_default,
380
+ integration=integration,
381
+ integration_source=integration_source,
382
+ )
383
+ _validate_state_values(state, label)
384
+ return state
385
+
386
+
387
+ def _validate_state_values(state: PipelineState, label: str) -> None:
388
+ if state.pipeline != PIPELINE_MARKER:
389
+ raise PipelineStateError(f"{label} pipeline must be {PIPELINE_MARKER}")
390
+ if state.project == NULL or not SLUG_RE.fullmatch(state.project):
391
+ raise PipelineStateError(f"{label} project must be a slug")
392
+ if state.milestone is not None and not SLUG_RE.fullmatch(state.milestone):
393
+ raise PipelineStateError(f"{label} milestone must be null or a slug")
394
+ if state.phase not in PHASES:
395
+ raise PipelineStateError(f"{label} has invalid phase: {state.phase}")
396
+ if state.status not in STATUSES:
397
+ raise PipelineStateError(f"{label} has invalid status: {state.status}")
398
+ if state.integration_default not in INTEGRATION_MODES:
399
+ raise PipelineStateError(
400
+ f"{label} has invalid integration_default: {state.integration_default}"
401
+ )
402
+ if state.integration not in INTEGRATION_MODES:
403
+ raise PipelineStateError(f"{label} has invalid integration: {state.integration}")
404
+ if state.integration_source not in INTEGRATION_SOURCES:
405
+ raise PipelineStateError(
406
+ f"{label} has invalid integration_source: {state.integration_source}"
407
+ )
408
+ if (
409
+ state.integration_source == "default"
410
+ and state.integration != state.integration_default
411
+ ):
412
+ raise PipelineStateError(
413
+ f"{label} default-sourced integration must match integration_default"
414
+ )
415
+ if state.branch is not None and _bound_branch_number(state.branch) is None:
416
+ raise PipelineStateError(f"{label} has invalid branch: {state.branch}")
417
+ archive_match = ARCHIVE_RE.fullmatch(state.archive or "")
418
+ if state.archive is not None and (
419
+ archive_match is None or int(archive_match.group(1)) < 1
420
+ ):
421
+ raise PipelineStateError(f"{label} has invalid archive: {state.archive}")
422
+ if state.phase == "shipped" and state.status != "done":
423
+ raise PipelineStateError(f"{label} shipped phase requires status done")
424
+ if state.phase == "shipped" and state.archive is None:
425
+ raise PipelineStateError(f"{label} shipped phase requires an archive")
426
+ if state.archive is not None and state.phase not in {"build", "ship", "shipped"}:
427
+ raise PipelineStateError(
428
+ f"{label} archive is only legal during build, ship, or shipped"
429
+ )
430
+ if state.phase in {"ship", "shipped"} and state.branch is None:
431
+ raise PipelineStateError(f"{label} {state.phase} phase requires a branch")
432
+ if archive_match is not None:
433
+ if state.milestone != archive_match.group(2):
434
+ raise PipelineStateError(f"{label} archive does not match milestone")
435
+ branch_number = _bound_branch_number(state.branch or "")
436
+ if branch_number is None or branch_number != int(archive_match.group(1)):
437
+ raise PipelineStateError(f"{label} archive does not match branch")
438
+
439
+
440
+ def _is_lookahead(project_dir: str) -> bool:
441
+ return PurePosixPath(project_dir) == PurePosixPath(".project/next")
442
+
443
+
444
+ def _validate_state_context(
445
+ state: PipelineState,
446
+ project_dir: str,
447
+ label: str,
448
+ ) -> None:
449
+ if not _is_lookahead(project_dir):
450
+ return
451
+ if state.branch is not None or state.archive is not None:
452
+ raise PipelineStateError(f"{label} lookahead branch and archive must be null")
453
+ if state.phase not in LOOKAHEAD_PHASES:
454
+ raise PipelineStateError(f"{label} lookahead cannot enter {state.phase}")
455
+
456
+
457
+ def load_state(repo: Path, project_dir: str = ".project") -> tuple[PipelineState, str, Path]:
458
+ root = _track_root(repo, project_dir)
459
+ path = root / "STATE.md"
460
+ text = _read_real_file(path, "STATE.md")
461
+ state = _state_from_text(text)
462
+ _validate_state_context(state, project_dir, "STATE.md")
463
+ if _is_lookahead(project_dir):
464
+ _validate_next_layout(root, state)
465
+ return state, text, path
466
+
467
+
468
+ def validate_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
469
+ resolved = _repo_root(repo)
470
+ state, _, path = load_state(resolved, project_dir)
471
+ return {
472
+ "schema": STATE_SCHEMA,
473
+ "status": "valid",
474
+ "path": str(path),
475
+ "state": state.json(),
476
+ }
477
+
478
+
479
+ def undo_transaction(repo: Path) -> Optional[dict[str, object]]:
480
+ resolved = _repo_root(repo)
481
+ path = _git_path(resolved, UNDO_TRANSACTION_NAME)
482
+ if not path.exists() and not path.is_symlink():
483
+ return None
484
+ value = _read_json(path)
485
+ expected = {
486
+ "schema",
487
+ "repo",
488
+ "kind",
489
+ "expected_head",
490
+ "parent",
491
+ "branch",
492
+ "worktree_fingerprint",
493
+ "archive",
494
+ "archive_fingerprint",
495
+ "discussion",
496
+ }
497
+ if set(value) != expected or value.get("schema") != UNDO_TRANSACTION_SCHEMA:
498
+ raise PipelineStateError("undo transaction has invalid fields")
499
+ if value.get("repo") != str(resolved):
500
+ raise PipelineStateError("undo transaction belongs to another worktree")
501
+ kind = value.get("kind")
502
+ if kind not in {"checkpoint", "uncommitted-archive"}:
503
+ raise PipelineStateError("undo transaction has invalid kind")
504
+ for field in ("expected_head", "parent"):
505
+ field_value = value.get(field)
506
+ if not isinstance(field_value, str) or not re.fullmatch(
507
+ r"[0-9a-f]{40,64}", field_value
508
+ ):
509
+ raise PipelineStateError(f"undo transaction has invalid {field}")
510
+ branch = value.get("branch")
511
+ if not isinstance(branch, str) or BOUND_BRANCH_RE.fullmatch(branch) is None:
512
+ raise PipelineStateError("undo transaction has invalid branch")
513
+ fingerprint = value.get("worktree_fingerprint")
514
+ if not isinstance(fingerprint, str) or not re.fullmatch(
515
+ r"[0-9a-f]{64}", fingerprint
516
+ ):
517
+ raise PipelineStateError("undo transaction has invalid worktree fingerprint")
518
+ archive = value.get("archive")
519
+ archive_fingerprint = value.get("archive_fingerprint")
520
+ if kind == "checkpoint" and (
521
+ archive is not None or archive_fingerprint is not None
522
+ ):
523
+ raise PipelineStateError("checkpoint undo transaction cannot name an archive")
524
+ if kind == "uncommitted-archive" and (
525
+ not isinstance(archive, str) or ARCHIVE_RE.fullmatch(archive) is None
526
+ ):
527
+ raise PipelineStateError("archive undo transaction has invalid archive")
528
+ if kind == "uncommitted-archive" and (
529
+ not isinstance(archive_fingerprint, str)
530
+ or not re.fullmatch(r"[0-9a-f]{64}", archive_fingerprint)
531
+ ):
532
+ raise PipelineStateError(
533
+ "archive undo transaction has invalid archive fingerprint"
534
+ )
535
+ discussion = value.get("discussion")
536
+ if discussion is not None and (
537
+ not isinstance(discussion, dict)
538
+ or set(discussion) != {"DIALOGUE.md", "ANSWERS.md"}
539
+ or any(not isinstance(content, str) for content in discussion.values())
540
+ ):
541
+ raise PipelineStateError("undo transaction has invalid discussion records")
542
+ return value
543
+
544
+
545
+ def transaction_journals(repo: Path) -> dict[str, Optional[str]]:
546
+ """Return paths of any in-progress helper journals. Read-only."""
547
+ resolved = _repo_root(repo)
548
+ found: dict[str, Optional[str]] = {}
549
+ named = {
550
+ "checkpoint": CHECKPOINT_JOURNAL_NAME,
551
+ "shipment": SHIPMENT_JOURNAL_NAME,
552
+ "promotion": "gsd-path-promote-next.json",
553
+ "abandon": "gsd-path-abandon.json",
554
+ }
555
+ for key, name in named.items():
556
+ path = _git_path(resolved, name)
557
+ found[key] = str(path) if path.exists() or path.is_symlink() else None
558
+ state, _, _ = load_state(resolved)
559
+ bind_recovery = _bind_next_recovery(resolved, state)
560
+ found["bind_next"] = (
561
+ str(bind_recovery["route"]["journal"])
562
+ if bind_recovery is not None
563
+ else None
564
+ )
565
+ transaction = undo_transaction(resolved)
566
+ found["undo"] = (
567
+ str(_git_path(resolved, UNDO_TRANSACTION_NAME))
568
+ if transaction is not None
569
+ else None
570
+ )
571
+ try:
572
+ collect = collect_artifact_recoveries(resolved)
573
+ except IsolationError as error:
574
+ raise PipelineStateError(str(error)) from error
575
+ found["collect_artifact"] = str(collect[0]["journal"]) if collect else None
576
+ return found
577
+
578
+
579
+ def _optional_rev(repo: Path, spec: str) -> Optional[str]:
580
+ result = _run_git(repo, "rev-parse", "--verify", "--quiet", spec, check=False)
581
+ sha = result.stdout.strip()
582
+ return sha if result.returncode == 0 and sha else None
583
+
584
+
585
+ def _is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
586
+ result = _run_git(
587
+ repo,
588
+ "merge-base",
589
+ "--is-ancestor",
590
+ ancestor,
591
+ descendant,
592
+ check=False,
593
+ )
594
+ if result.returncode == 0:
595
+ return True
596
+ if result.returncode == 1:
597
+ return False
598
+ detail = result.stderr.strip() or result.stdout.strip() or "git ancestry probe failed"
599
+ raise PipelineStateError(detail)
600
+
601
+
602
+ def _live_annotated_tag(repo: Path, tag_ref: str) -> tuple[str, str]:
603
+ result = _run_git(
604
+ repo,
605
+ "ls-remote",
606
+ "--exit-code",
607
+ "origin",
608
+ tag_ref,
609
+ f"{tag_ref}^{{}}",
610
+ check=False,
611
+ )
612
+ if result.returncode == 2:
613
+ raise PipelineStateError("published milestone tag is missing on origin")
614
+ if result.returncode != 0:
615
+ detail = result.stderr.strip() or result.stdout.strip()
616
+ raise PipelineStateError(
617
+ f"could not inspect published milestone tag on origin: {detail}"
618
+ )
619
+ refs: dict[str, str] = {}
620
+ for line in result.stdout.splitlines():
621
+ fields = line.split("\t", 1)
622
+ if (
623
+ len(fields) != 2
624
+ or fields[1] in refs
625
+ or re.fullmatch(r"[0-9a-f]{40}", fields[0]) is None
626
+ ):
627
+ raise PipelineStateError("origin returned invalid milestone tag data")
628
+ refs[fields[1]] = fields[0]
629
+ peeled_ref = f"{tag_ref}^{{}}"
630
+ if set(refs) != {tag_ref, peeled_ref}:
631
+ raise PipelineStateError("origin milestone tag is missing or not annotated")
632
+ return refs[tag_ref], refs[peeled_ref]
633
+
634
+
635
+ def _commit_subject_body(repo: Path, revision: str = "HEAD") -> tuple[str, str]:
636
+ raw = _run_git(repo, "cat-file", "commit", revision).stdout
637
+ try:
638
+ message = raw.split("\n\n", 1)[1]
639
+ except IndexError as error:
640
+ raise PipelineStateError(f"commit {revision} has no message body") from error
641
+ subject, _, rest = message.partition("\n")
642
+ return subject.strip(), rest.lstrip("\n")
643
+
644
+
645
+ def _pending_answers(repo: Path) -> tuple[list[dict[str, str]], Optional[str]]:
646
+ answers = repo / ".project" / "discuss" / "ANSWERS.md"
647
+ if not answers.exists() and not answers.is_symlink():
648
+ return [], None
649
+ try:
650
+ try:
651
+ from discussion_records import DiscussionError, pending_records
652
+ except ModuleNotFoundError as error: # pragma: no cover - package imports used by tests
653
+ if error.name != "discussion_records":
654
+ raise
655
+ from scripts.discussion_records import DiscussionError, pending_records
656
+ except ModuleNotFoundError as error:
657
+ if error.name not in {"scripts", "scripts.discussion_records"}:
658
+ raise
659
+ return [], "discussion_records helper is unavailable"
660
+ try:
661
+ records = pending_records(answers.parent)
662
+ except (DiscussionError, OSError, UnicodeDecodeError, ValueError) as error:
663
+ return [], str(error)
664
+ pending: list[dict[str, str]] = []
665
+ for record in records:
666
+ pending.append(
667
+ {
668
+ "answer": str(record.get("answer") or ""),
669
+ "owner": str(record.get("owner") or ""),
670
+ "target": str(record.get("target") or ""),
671
+ "status": str(record.get("status") or ""),
672
+ }
673
+ )
674
+ return pending, None
675
+
676
+
677
+ def _next_skill(route: Mapping[str, object]) -> Optional[str]:
678
+ action = route.get("action")
679
+ phase = route.get("phase")
680
+ if action == "run-phase" and isinstance(phase, str):
681
+ return f"gsd-path-{phase}"
682
+ if action in {
683
+ "bind-initial",
684
+ "resume-checkpoint",
685
+ "resume-shipment",
686
+ "resume-promotion",
687
+ "resume-next-handoff",
688
+ "resume-undo",
689
+ "validate-integrated",
690
+ "block",
691
+ }:
692
+ return "gsd-path-undo" if action == "resume-undo" else "gsd-path"
693
+ return None
694
+
695
+
696
+ def status_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
697
+ """Report owned state, route, and git facts without advancing a phase."""
698
+ resolved = _repo_root(repo)
699
+ routed = route_state(resolved, project_dir)
700
+ state = routed["state"]
701
+ route = routed["route"]
702
+ has_git = (
703
+ _run_git(resolved, "rev-parse", "--git-dir", check=False).returncode == 0
704
+ )
705
+ head = _optional_rev(resolved, "HEAD") if has_git else None
706
+ branch = _current_branch(resolved) if has_git else None
707
+ origin_branch = (
708
+ _optional_rev(resolved, f"refs/remotes/origin/{branch}")
709
+ if branch
710
+ else None
711
+ )
712
+ origin_main = (
713
+ _optional_rev(resolved, "refs/remotes/origin/main") if has_git else None
714
+ )
715
+ pending, pending_error = _pending_answers(resolved)
716
+ lookahead = resolved / ".project" / "next"
717
+ subject = ""
718
+ if head is not None:
719
+ subject, _ = _commit_subject_body(resolved, head)
720
+ journals = transaction_journals(resolved) if has_git else {
721
+ "checkpoint": None,
722
+ "shipment": None,
723
+ "promotion": None,
724
+ "abandon": None,
725
+ "bind_next": None,
726
+ "undo": None,
727
+ "collect_artifact": None,
728
+ }
729
+ return {
730
+ "schema": STATUS_SCHEMA,
731
+ "advance": False,
732
+ "state": state,
733
+ "route": route,
734
+ "path": str(_track_root(resolved, project_dir) / "STATE.md"),
735
+ "git": {
736
+ "branch": branch,
737
+ "head": head,
738
+ "subject": subject,
739
+ "dirty": _worktree_changes(resolved) if has_git else [],
740
+ "origin_branch": origin_branch,
741
+ "origin_main": origin_main,
742
+ "published": bool(
743
+ head
744
+ and origin_branch
745
+ and _is_ancestor(resolved, head, origin_branch)
746
+ ),
747
+ "ancestor_of_origin_main": bool(
748
+ head and origin_main and _is_ancestor(resolved, head, origin_main)
749
+ ),
750
+ },
751
+ "pending_answers": pending,
752
+ "pending_error": pending_error,
753
+ "lookahead": lookahead.exists() or lookahead.is_symlink(),
754
+ "journals": journals,
755
+ "next_skill": _next_skill(route if isinstance(route, dict) else {}),
756
+ }
757
+
758
+
759
+ def _current_branch(repo: Path) -> Optional[str]:
760
+ result = _run_git(
761
+ repo,
762
+ "symbolic-ref",
763
+ "--quiet",
764
+ "--short",
765
+ "HEAD",
766
+ check=False,
767
+ )
768
+ if result.returncode == 1:
769
+ return None
770
+ if result.returncode != 0:
771
+ detail = result.stderr.strip() or result.stdout.strip()
772
+ raise PipelineStateError(f"could not inspect current branch: {detail}")
773
+ return result.stdout.strip()
774
+
775
+
776
+ def _intent_lane(track: Path) -> Optional[str]:
777
+ text = _optional_real_file(track / "intent" / "INTENT.md", "INTENT.md")
778
+ if text is None:
779
+ return None
780
+ match = re.search(r"(?m)^Lane:\s*(standard|quick|milestone)\b", text)
781
+ if match is None:
782
+ return "standard"
783
+ return match.group(1)
784
+
785
+
786
+ def _active_roadmap_has_questions(project: Path, milestone: Optional[str] = None) -> bool:
787
+ """Report open questions for the active entry, or for ``milestone`` when given."""
788
+ text = _read_real_file(project / "ROADMAP.md", "ROADMAP.md")
789
+ lines = text.splitlines()
790
+ active_section: Optional[list[str]] = None
791
+ for index, line in enumerate(lines):
792
+ heading = ROADMAP_HEADING_RE.fullmatch(line)
793
+ if heading is None:
794
+ continue
795
+ if int(heading.group(1).removeprefix("M")) < 1:
796
+ raise PipelineStateError(
797
+ f"ROADMAP.md has invalid milestone id: {heading.group(1)}"
798
+ )
799
+ end = next(
800
+ (
801
+ cursor
802
+ for cursor in range(index + 1, len(lines))
803
+ if ROADMAP_HEADING_RE.fullmatch(lines[cursor]) is not None
804
+ ),
805
+ len(lines),
806
+ )
807
+ section = lines[index:end]
808
+ if milestone is not None:
809
+ if heading.group(2) == milestone:
810
+ active_section = section
811
+ continue
812
+ if any(re.fullmatch(r"Status:\s*active(?:\s+#.*)?", item) for item in section):
813
+ if active_section is not None:
814
+ raise PipelineStateError("ROADMAP.md has more than one active milestone")
815
+ active_section = section
816
+ if active_section is None:
817
+ if milestone is not None:
818
+ raise PipelineStateError(f"ROADMAP.md is missing lookahead milestone: {milestone}")
819
+ raise PipelineStateError("ROADMAP.md has no active milestone")
820
+ try:
821
+ start = active_section.index("Open questions") + 1
822
+ except ValueError as error:
823
+ raise PipelineStateError("active roadmap milestone has no Open questions") from error
824
+ questions = [
825
+ line[2:].strip()
826
+ for line in active_section[start:]
827
+ if line.startswith("- ")
828
+ ]
829
+ return any(question.lower() != "none" for question in questions)
830
+
831
+
832
+ def _valid_patch_findings(repo: Path) -> bool:
833
+ validator = Path(__file__).resolve().with_name("check_handoffs.py")
834
+ if validator.is_symlink() or not validator.is_file():
835
+ raise PipelineStateError(f"patch validator must be a real file: {validator}")
836
+ result = subprocess.run(
837
+ [
838
+ sys.executable,
839
+ "-B",
840
+ str(validator),
841
+ "patch",
842
+ "--repo",
843
+ str(repo),
844
+ ],
845
+ capture_output=True,
846
+ text=True,
847
+ )
848
+ return result.returncode == 0
849
+
850
+
851
+ def _selected_initial_branch(project: Path, state: PipelineState) -> str:
852
+ roadmap = project / "ROADMAP.md"
853
+ if roadmap.exists() or roadmap.is_symlink():
854
+ text = _read_real_file(roadmap, "ROADMAP.md")
855
+ lines = text.splitlines(keepends=True)
856
+ active: list[tuple[str, str]] = []
857
+ for slug, (milestone_id, start, end) in _roadmap_sections(text).items():
858
+ statuses = [
859
+ match.group(1)
860
+ for line in lines[start:end]
861
+ if (match := re.match(r"^Status:\s*(\S+)", line)) is not None
862
+ ]
863
+ if statuses == ["active"]:
864
+ active.append((milestone_id, slug))
865
+ if len(active) != 1:
866
+ raise PipelineStateError("ROADMAP.md must have exactly one active milestone")
867
+ milestone_id, slug = active[0]
868
+ if state.milestone is not None and state.milestone != slug:
869
+ raise PipelineStateError("STATE.milestone does not match active roadmap entry")
870
+ return f"gsd-path/{milestone_id}"
871
+
872
+ archive = project / "archive"
873
+ numbers: list[int] = []
874
+ if archive.exists() or archive.is_symlink():
875
+ if archive.is_symlink() or not archive.is_dir():
876
+ raise PipelineStateError(f"archive root must be a real directory: {archive}")
877
+ for child in archive.iterdir():
878
+ match = re.fullmatch(r"(\d{3,})-[a-z0-9][a-z0-9-]*", child.name)
879
+ if (
880
+ child.is_symlink()
881
+ or not child.is_dir()
882
+ or match is None
883
+ or int(match.group(1)) < 1
884
+ ):
885
+ raise PipelineStateError(f"archive root has invalid entry: {child}")
886
+ numbers.append(int(match.group(1)))
887
+ number = max(numbers) + 1 if numbers else 1
888
+ return f"gsd-path/M{number:03d}"
889
+
890
+
891
+ def _route_result(
892
+ state: PipelineState,
893
+ action: str,
894
+ *,
895
+ phase: Optional[str] = None,
896
+ mode: Optional[str] = None,
897
+ reason: str,
898
+ ) -> dict[str, object]:
899
+ route = {"action": action, "reason": reason}
900
+ if phase is not None:
901
+ route["phase"] = phase
902
+ if mode is not None:
903
+ route["mode"] = mode
904
+ return {"schema": ROUTE_SCHEMA, "state": state.json(), "route": route}
905
+
906
+
907
+ def _legacy_bind_next_landing(repo: Path, state: PipelineState) -> str:
908
+ if ARCHIVE_RE.fullmatch(state.archive or "") is None:
909
+ raise PipelineStateError("legacy bind-next journal requires a shipped archive")
910
+ archive_name = PurePosixPath(state.archive or "").name
911
+ tag_ref = f"refs/tags/milestone/{archive_name}"
912
+ published_ref = f"refs/remotes/origin/tags/milestone/{archive_name}"
913
+ for label, ref in (("local", tag_ref), ("published", published_ref)):
914
+ tag_type = _run_git(repo, "cat-file", "-t", ref, check=False)
915
+ if tag_type.returncode != 0 or tag_type.stdout.strip() != "tag":
916
+ raise PipelineStateError(
917
+ f"legacy bind-next journal {label} milestone tag is missing or not annotated"
918
+ )
919
+ local_object = _run_git(repo, "rev-parse", "--verify", tag_ref).stdout.strip()
920
+ published_object = _run_git(
921
+ repo,
922
+ "rev-parse",
923
+ "--verify",
924
+ published_ref,
925
+ ).stdout.strip()
926
+ if local_object != published_object:
927
+ raise PipelineStateError(
928
+ "legacy bind-next journal milestone tags do not match"
929
+ )
930
+ landing = _run_git(
931
+ repo,
932
+ "rev-parse",
933
+ "--verify",
934
+ f"{tag_ref}^{{commit}}",
935
+ ).stdout.strip()
936
+ published_landing = _run_git(
937
+ repo,
938
+ "rev-parse",
939
+ "--verify",
940
+ f"{published_ref}^{{commit}}",
941
+ ).stdout.strip()
942
+ if landing != published_landing:
943
+ raise PipelineStateError(
944
+ "legacy bind-next journal milestone tag landings do not match"
945
+ )
946
+ return landing
947
+
948
+
949
+ def _bind_next_recovery(
950
+ repo: Path,
951
+ state: PipelineState,
952
+ ) -> Optional[dict[str, object]]:
953
+ journal_root = _git_path(repo, BIND_NEXT_JOURNAL_DIR)
954
+ if not journal_root.exists() and not journal_root.is_symlink():
955
+ return None
956
+ if journal_root.is_symlink() or not journal_root.is_dir():
957
+ raise PipelineStateError(f"bind-next journal root must be a real directory: {journal_root}")
958
+
959
+ matches: list[tuple[Path, dict[str, object]]] = []
960
+ for path in sorted(journal_root.glob("*.json")):
961
+ transaction = _read_json(path)
962
+ if transaction.get("schema") not in {
963
+ LEGACY_BIND_NEXT_JOURNAL_SCHEMA,
964
+ BIND_NEXT_JOURNAL_SCHEMA,
965
+ }:
966
+ raise PipelineStateError(f"bind-next journal has invalid schema: {path}")
967
+ if transaction.get("repo") != str(repo):
968
+ raise PipelineStateError(f"bind-next journal belongs to another worktree: {path}")
969
+ if transaction.get("previous_branch") == state.branch:
970
+ matches.append((path, transaction))
971
+ if not matches:
972
+ return None
973
+ if len(matches) != 1:
974
+ raise PipelineStateError("multiple bind-next journals claim the current STATE branch")
975
+ if state.phase != "shipped" or state.status != "done":
976
+ raise PipelineStateError("bind-next journal requires STATE shipped/done")
977
+
978
+ path, transaction = matches[0]
979
+ required = (
980
+ "branch",
981
+ "previous_branch",
982
+ "ship",
983
+ "remote_default",
984
+ "base",
985
+ "stage",
986
+ )
987
+ expected_fields = {"schema", "repo", *required}
988
+ optional_fields = {"allow_remote_absent"}
989
+ if transaction["schema"] == BIND_NEXT_JOURNAL_SCHEMA:
990
+ expected_fields.add("landing")
991
+ else:
992
+ optional_fields.add("landing")
993
+ if (
994
+ not expected_fields <= set(transaction)
995
+ or set(transaction) - expected_fields - optional_fields
996
+ ):
997
+ raise PipelineStateError("bind-next journal has unsupported fields")
998
+ if any(not isinstance(transaction.get(key), str) for key in required):
999
+ raise PipelineStateError("bind-next journal is missing request fields")
1000
+ landing_value = transaction.get("landing")
1001
+ if transaction["schema"] == BIND_NEXT_JOURNAL_SCHEMA and not isinstance(
1002
+ landing_value, str
1003
+ ):
1004
+ raise PipelineStateError("bind-next journal is missing request fields")
1005
+ if landing_value is not None and not isinstance(landing_value, str):
1006
+ raise PipelineStateError("bind-next journal has invalid landing")
1007
+ allow_remote_absent = transaction.get("allow_remote_absent", False)
1008
+ if not isinstance(allow_remote_absent, bool):
1009
+ raise PipelineStateError("bind-next journal has invalid allow_remote_absent")
1010
+ branch = str(transaction["branch"])
1011
+ previous = str(transaction["previous_branch"])
1012
+ ship = str(transaction["ship"])
1013
+ base = str(transaction["base"])
1014
+ stage = str(transaction["stage"])
1015
+ branch_number = _bound_branch_number(branch)
1016
+ previous_number = _bound_branch_number(previous)
1017
+ if branch_number is None or previous_number is None:
1018
+ raise PipelineStateError("bind-next journal has invalid branch fields")
1019
+ if branch_number <= previous_number:
1020
+ raise PipelineStateError("bind-next journal target does not follow previous branch")
1021
+ if path.name != f"M{branch_number:03d}.json":
1022
+ raise PipelineStateError("bind-next journal path does not match target branch")
1023
+ if any(not re.fullmatch(r"[0-9a-f]{40}", value) for value in (ship, base)):
1024
+ raise PipelineStateError("bind-next journal has invalid ship or base SHA")
1025
+ for label, value in (("ship", ship), ("base", base)):
1026
+ resolved = _run_git(
1027
+ repo,
1028
+ "rev-parse",
1029
+ "--verify",
1030
+ f"{value}^{{commit}}",
1031
+ check=False,
1032
+ )
1033
+ if resolved.returncode != 0 or resolved.stdout.strip() != value:
1034
+ raise PipelineStateError(f"bind-next journal {label} is not an existing full SHA")
1035
+ landing = (
1036
+ landing_value
1037
+ if isinstance(landing_value, str)
1038
+ else _legacy_bind_next_landing(repo, state)
1039
+ )
1040
+ if re.fullmatch(r"[0-9a-f]{40}", landing) is None:
1041
+ raise PipelineStateError("bind-next journal has invalid landing SHA")
1042
+ resolved_landing = _run_git(
1043
+ repo,
1044
+ "rev-parse",
1045
+ "--verify",
1046
+ f"{landing}^{{commit}}",
1047
+ check=False,
1048
+ )
1049
+ if resolved_landing.returncode != 0 or resolved_landing.stdout.strip() != landing:
1050
+ raise PipelineStateError("bind-next journal landing is not an existing full SHA")
1051
+ if not _is_ancestor(repo, ship, landing):
1052
+ raise PipelineStateError("bind-next journal landing does not contain ship")
1053
+ if not _is_ancestor(repo, landing, base):
1054
+ raise PipelineStateError("bind-next journal landing is not an ancestor of base")
1055
+ if transaction["remote_default"] != "origin/main":
1056
+ raise PipelineStateError("bind-next journal has invalid remote default")
1057
+ if stage not in {"prepared", "switched", "retired"}:
1058
+ raise PipelineStateError("bind-next journal has invalid stage")
1059
+
1060
+ current = _current_branch(repo)
1061
+ if current not in {previous, branch}:
1062
+ raise PipelineStateError("bind-next journal does not own the current branch")
1063
+ if current == previous and stage != "prepared":
1064
+ raise PipelineStateError("bind-next journal stage conflicts with previous branch")
1065
+ head = _run_git(repo, "rev-parse", "HEAD").stdout.strip()
1066
+ if current == previous:
1067
+ if head != ship:
1068
+ raise PipelineStateError("bind-next previous branch is not at journal ship")
1069
+ if _run_git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
1070
+ raise PipelineStateError("bind-next prepared journal collides with target branch")
1071
+ else:
1072
+ if head != base:
1073
+ raise PipelineStateError("bind-next target branch is not at journal base")
1074
+ result = _route_result(
1075
+ state,
1076
+ "resume-next-handoff",
1077
+ reason="a matching bind-next ownership journal is incomplete",
1078
+ )
1079
+ result["route"].update(
1080
+ {
1081
+ "previous_branch": previous,
1082
+ "branch": branch,
1083
+ "ship": ship,
1084
+ "remote_default": transaction["remote_default"],
1085
+ "base": base,
1086
+ "landing": landing,
1087
+ "allow_remote_absent": allow_remote_absent,
1088
+ "journal": str(path),
1089
+ "dirty": _worktree_changes(repo),
1090
+ }
1091
+ )
1092
+ return result
1093
+
1094
+
1095
+ def _pending_discussion_block(repo: Path, phase: Optional[str]) -> Optional[str]:
1096
+ pending, error = _pending_answers(repo)
1097
+ if error:
1098
+ return f"pending discussion cannot be validated: {error}"
1099
+ if not pending or (phase and all(record["owner"] == f"gsd-path-{phase}" for record in pending)):
1100
+ return None
1101
+ owners = ", ".join(f"{record['answer']} ({record['owner']})" for record in pending)
1102
+ return f"pending discussion requires its owner disposition: {owners}; see {repo / '.project/discuss/ANSWERS.md'}"
1103
+
1104
+
1105
+ def route_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
1106
+ """Route ordinary phase work only when its pending discussion owner can enter."""
1107
+ result = _route_state(repo, project_dir)
1108
+ route = result["route"]
1109
+ if route["action"] == "run-phase" and result["state"]["archive"] is None:
1110
+ reason = _pending_discussion_block(_repo_root(repo), route.get("phase"))
1111
+ if reason:
1112
+ result["route"] = {"action": "block", "reason": reason}
1113
+ return result
1114
+
1115
+
1116
+ def _route_state(repo: Path, project_dir: str = ".project") -> dict[str, object]:
1117
+ """Return the next deterministic pipeline action for one state track."""
1118
+ resolved = _repo_root(repo)
1119
+ state, _, _ = load_state(resolved, project_dir)
1120
+ track = _track_root(resolved, project_dir)
1121
+ project = _track_root(resolved, ".project")
1122
+ lookahead = PurePosixPath(project_dir) == PurePosixPath(".project/next")
1123
+
1124
+ if not lookahead:
1125
+ git_dir = _run_git(resolved, "rev-parse", "--git-dir", check=False)
1126
+ if git_dir.returncode == 0:
1127
+ undo = undo_transaction(resolved)
1128
+ if undo is not None:
1129
+ result = _route_result(
1130
+ state,
1131
+ "resume-undo",
1132
+ reason="a journaled undo transaction is incomplete",
1133
+ )
1134
+ result["route"].update(
1135
+ {
1136
+ "kind": undo["kind"],
1137
+ "expected_head": undo["expected_head"],
1138
+ "journal": str(_git_path(resolved, UNDO_TRANSACTION_NAME)),
1139
+ }
1140
+ )
1141
+ return result
1142
+ shipment_journal = _git_path(resolved, SHIPMENT_JOURNAL_NAME)
1143
+ if shipment_journal.exists() or shipment_journal.is_symlink():
1144
+ transaction = _read_json(shipment_journal)
1145
+ expected_keys = {
1146
+ "schema", "repo", "archive", "event", "roadmap_before",
1147
+ "roadmap_after", "state_before", "state_after",
1148
+ }
1149
+ if set(transaction) != expected_keys or transaction.get("schema") != SHIPMENT_SCHEMA or transaction.get("repo") != str(resolved):
1150
+ raise PipelineStateError("shipment journal is invalid")
1151
+ result = _route_result(
1152
+ state,
1153
+ "resume-shipment",
1154
+ reason="a journaled shipment metadata transaction is incomplete",
1155
+ )
1156
+ result["route"].update(
1157
+ {"archive": transaction["archive"], "event": transaction["event"]}
1158
+ )
1159
+ return result
1160
+ checkpoint_journal = _git_path(resolved, CHECKPOINT_JOURNAL_NAME)
1161
+ if checkpoint_journal.exists() or checkpoint_journal.is_symlink():
1162
+ transaction = _approval_journal(
1163
+ resolved,
1164
+ _read_json(checkpoint_journal),
1165
+ )
1166
+ result = _route_result(
1167
+ state,
1168
+ "resume-checkpoint",
1169
+ reason="a journaled plan or roadmap approval is incomplete",
1170
+ )
1171
+ result["route"].update(
1172
+ {
1173
+ "kind": transaction["kind"],
1174
+ "project_dir": transaction["project_dir"],
1175
+ "journal": str(checkpoint_journal),
1176
+ }
1177
+ )
1178
+ return result
1179
+ promotion_journal = _git_path(
1180
+ resolved,
1181
+ "gsd-path-promote-next.json",
1182
+ )
1183
+ if promotion_journal.exists() or promotion_journal.is_symlink():
1184
+ transaction = _read_json(promotion_journal)
1185
+ required = {
1186
+ "schema": PROMOTION_SCHEMA,
1187
+ "repo": str(resolved),
1188
+ }
1189
+ if any(transaction.get(key) != value for key, value in required.items()):
1190
+ raise PipelineStateError(
1191
+ "promotion journal does not belong to this pipeline worktree"
1192
+ )
1193
+ fields = {
1194
+ key: transaction.get(key)
1195
+ for key in ("milestone", "branch", "integrate")
1196
+ }
1197
+ if not all(isinstance(value, str) for value in fields.values()):
1198
+ raise PipelineStateError("promotion journal is missing request fields")
1199
+ landing = transaction.get("landing", fields["integrate"])
1200
+ base = transaction.get("base", fields["integrate"])
1201
+ if not isinstance(landing, str) or not isinstance(base, str):
1202
+ raise PipelineStateError("promotion journal has invalid landing or base")
1203
+ result = _route_result(
1204
+ state,
1205
+ "resume-promotion",
1206
+ reason="a journaled lookahead promotion is incomplete",
1207
+ )
1208
+ fields["landing"] = landing
1209
+ fields["base"] = base
1210
+ result["route"].update(fields)
1211
+ result["route"]["journal"] = str(promotion_journal)
1212
+ return result
1213
+ bind_recovery = _bind_next_recovery(resolved, state)
1214
+ if bind_recovery is not None:
1215
+ return bind_recovery
1216
+
1217
+ if not lookahead and state.branch is None:
1218
+ result = _route_result(
1219
+ state,
1220
+ "bind-initial",
1221
+ reason="active state has no router-owned branch binding",
1222
+ )
1223
+ result["route"]["branch"] = _selected_initial_branch(project, state)
1224
+ return result
1225
+
1226
+ if state.branch is not None:
1227
+ current = _current_branch(resolved)
1228
+ if current != state.branch:
1229
+ isolated_build = (
1230
+ state.phase == "build"
1231
+ and authorized_task_worktree(resolved, state.branch)
1232
+ )
1233
+ if not isolated_build:
1234
+ return _route_result(
1235
+ state,
1236
+ "block",
1237
+ reason=(
1238
+ f"current branch {current or '<detached>'} != "
1239
+ f"STATE.branch {state.branch}"
1240
+ ),
1241
+ )
1242
+
1243
+ if state.archive is not None:
1244
+ if state.phase == "build":
1245
+ return _route_result(
1246
+ state,
1247
+ "run-phase",
1248
+ phase="build",
1249
+ mode="abandon-recovery",
1250
+ reason="STATE.archive records an interrupted abandon transaction",
1251
+ )
1252
+ if state.phase in {"ship", "shipped"}:
1253
+ return _route_result(
1254
+ state,
1255
+ "run-phase",
1256
+ phase="ship",
1257
+ mode="archive-recovery" if state.phase == "ship" else "validate-integrated",
1258
+ reason="STATE.archive records a ship transaction",
1259
+ )
1260
+
1261
+ phase, status = state.phase, state.status
1262
+ if phase == "inspect":
1263
+ return _route_result(
1264
+ state,
1265
+ "run-phase",
1266
+ phase="define" if status == "done" else "inspect",
1267
+ mode="brownfield" if status == "done" else "normal",
1268
+ reason=f"state is {phase}/{status}",
1269
+ )
1270
+ if phase == "define" and status != "done":
1271
+ return _route_result(
1272
+ state,
1273
+ "run-phase",
1274
+ phase="define",
1275
+ reason=f"state is {phase}/{status}",
1276
+ )
1277
+ if phase == "define":
1278
+ lane = _intent_lane(track)
1279
+ if lane == "quick":
1280
+ return _route_result(
1281
+ state,
1282
+ "run-phase",
1283
+ phase="plan",
1284
+ mode="quick",
1285
+ reason="INTENT lane is quick",
1286
+ )
1287
+ if lane == "milestone":
1288
+ questions = _active_roadmap_has_questions(
1289
+ project,
1290
+ state.milestone if lookahead else None,
1291
+ )
1292
+ entry = "lookahead" if lookahead else "active"
1293
+ return _route_result(
1294
+ state,
1295
+ "run-phase",
1296
+ phase="research" if questions else "plan",
1297
+ mode="milestone",
1298
+ reason=(
1299
+ f"{entry} roadmap milestone has open questions"
1300
+ if questions
1301
+ else f"{entry} roadmap milestone has no open questions"
1302
+ ),
1303
+ )
1304
+ if lane is None:
1305
+ charter = _optional_real_file(project / "CHARTER.md", "CHARTER.md")
1306
+ if charter is None:
1307
+ return _route_result(
1308
+ state,
1309
+ "block",
1310
+ reason="define/done has neither INTENT.md nor CHARTER.md",
1311
+ )
1312
+ mode = "program"
1313
+ else:
1314
+ mode = "standard"
1315
+ return _route_result(
1316
+ state,
1317
+ "run-phase",
1318
+ phase="research",
1319
+ mode=mode,
1320
+ reason="define is done",
1321
+ )
1322
+ if phase == "research":
1323
+ return _route_result(
1324
+ state,
1325
+ "run-phase",
1326
+ phase="decide" if status == "done" else "research",
1327
+ reason=f"state is {phase}/{status}",
1328
+ )
1329
+ if phase == "decide" and status != "done":
1330
+ return _route_result(
1331
+ state,
1332
+ "run-phase",
1333
+ phase="decide",
1334
+ reason=f"state is {phase}/{status}",
1335
+ )
1336
+ if phase == "decide":
1337
+ charter = project / "CHARTER.md"
1338
+ roadmap = project / "ROADMAP.md"
1339
+ next_phase = "roadmap" if charter.is_file() and not roadmap.exists() else "plan"
1340
+ return _route_result(state, "run-phase", phase=next_phase, reason="decide is done")
1341
+ if phase == "roadmap":
1342
+ return _route_result(
1343
+ state,
1344
+ "run-phase",
1345
+ phase="define" if status == "done" else "roadmap",
1346
+ mode="milestone" if status == "done" else "normal",
1347
+ reason=f"state is {phase}/{status}",
1348
+ )
1349
+ if phase == "plan" and status != "done":
1350
+ return _route_result(state, "run-phase", phase="plan", reason=f"state is {phase}/{status}")
1351
+ if phase == "plan":
1352
+ if lookahead:
1353
+ return _route_result(
1354
+ state,
1355
+ "wait",
1356
+ mode="lookahead-ready",
1357
+ reason="lookahead plan is approved and waits for milestone promotion",
1358
+ )
1359
+ return _route_result(
1360
+ state,
1361
+ "run-phase",
1362
+ phase="build",
1363
+ reason="approved plan and branch binding are ready",
1364
+ )
1365
+ if phase == "build":
1366
+ return _route_result(
1367
+ state,
1368
+ "run-phase",
1369
+ phase="build",
1370
+ mode="transition-recovery" if status == "done" else "recovery",
1371
+ reason=f"state is {phase}/{status}",
1372
+ )
1373
+ if phase == "ship":
1374
+ if status == "blocked":
1375
+ findings = project / "review" / "PATCH-FINDINGS.md"
1376
+ if (
1377
+ findings.is_file()
1378
+ and not findings.is_symlink()
1379
+ and _valid_patch_findings(resolved)
1380
+ ):
1381
+ return _route_result(
1382
+ state,
1383
+ "run-phase",
1384
+ phase="plan",
1385
+ mode="patch",
1386
+ reason="validated patch findings exist",
1387
+ )
1388
+ return _route_result(
1389
+ state,
1390
+ "block",
1391
+ reason="ship is blocked without valid PATCH-FINDINGS.md",
1392
+ )
1393
+ return _route_result(
1394
+ state,
1395
+ "run-phase",
1396
+ phase="ship",
1397
+ mode="final",
1398
+ reason=f"state is {phase}/{status}",
1399
+ )
1400
+ return _route_result(state, "validate-integrated", reason="state is shipped/done")
1401
+
1402
+
1403
+ def _set_frontmatter(text: str, changes: Mapping[str, str]) -> str:
1404
+ lines = text.splitlines(keepends=True)
1405
+ closing: Optional[int] = None
1406
+ seen: set[str] = set()
1407
+ for index, line in enumerate(lines[1:], start=1):
1408
+ if line.rstrip("\r\n") == "---":
1409
+ closing = index
1410
+ break
1411
+ match = re.match(r"^([a-z_]+):", line)
1412
+ if match and match.group(1) in changes:
1413
+ key = match.group(1)
1414
+ lines[index] = f"{key}: {changes[key]}\n"
1415
+ seen.add(key)
1416
+ if closing is None:
1417
+ raise PipelineStateError("STATE.md frontmatter is not closed")
1418
+ missing = set(changes) - seen
1419
+ integration_fields = (
1420
+ "integration_default",
1421
+ "integration",
1422
+ "integration_source",
1423
+ )
1424
+ unsupported_missing = missing - set(integration_fields)
1425
+ if unsupported_missing:
1426
+ raise PipelineStateError(f"STATE.md is missing fields: {', '.join(sorted(missing))}")
1427
+ for key in integration_fields:
1428
+ if key in missing:
1429
+ lines.insert(closing, f"{key}: {changes[key]}\n")
1430
+ closing += 1
1431
+ return "".join(lines)
1432
+
1433
+
1434
+ def _append_event(
1435
+ text: str,
1436
+ phase: str,
1437
+ event: str,
1438
+ event_date: Optional[str] = None,
1439
+ ) -> str:
1440
+ cleaned = " ".join(event.split())
1441
+ if not cleaned or "\n" in event or "\r" in event:
1442
+ raise PipelineStateError("state event must be one non-empty line")
1443
+ if "## Log" not in text:
1444
+ raise PipelineStateError("STATE.md is missing ## Log")
1445
+ suffix = "" if text.endswith("\n") else "\n"
1446
+ timestamp = event_date or date.today().isoformat()
1447
+ try:
1448
+ date.fromisoformat(timestamp)
1449
+ except ValueError as error:
1450
+ raise PipelineStateError(f"state event date is invalid: {timestamp}") from error
1451
+ return f"{text}{suffix}- {timestamp} — {phase} — {cleaned}\n"
1452
+
1453
+
1454
+ @contextlib.contextmanager
1455
+ def _state_lock(project: Path) -> Iterator[None]:
1456
+ if fcntl is None: # pragma: no cover - Windows CI is not used
1457
+ raise PipelineStateError("state locking is unavailable on this platform")
1458
+ descriptor = os.open(project, os.O_RDONLY)
1459
+ try:
1460
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
1461
+ yield
1462
+ finally:
1463
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
1464
+ os.close(descriptor)
1465
+
1466
+
1467
+ def _atomic_write(path: Path, content: str) -> None:
1468
+ temporary = path.parent / f".{path.name}.gsd-path-tmp"
1469
+ try:
1470
+ path_status = path.lstat()
1471
+ temporary_status = temporary.lstat()
1472
+ except FileNotFoundError:
1473
+ pass
1474
+ else:
1475
+ if (
1476
+ stat.S_ISREG(path_status.st_mode)
1477
+ and stat.S_ISREG(temporary_status.st_mode)
1478
+ and path_status.st_nlink == 2
1479
+ and temporary_status.st_nlink == 2
1480
+ and os.path.samestat(path_status, temporary_status)
1481
+ ):
1482
+ temporary.unlink()
1483
+ directory = os.open(path.parent, os.O_RDONLY)
1484
+ try:
1485
+ os.fsync(directory)
1486
+ finally:
1487
+ os.close(directory)
1488
+ if temporary.exists() or temporary.is_symlink():
1489
+ raise PipelineStateError(f"atomic-write temporary path already exists: {temporary}")
1490
+ descriptor: Optional[int] = None
1491
+ try:
1492
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
1493
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1494
+ descriptor = None
1495
+ handle.write(content)
1496
+ handle.flush()
1497
+ os.fsync(handle.fileno())
1498
+ os.replace(temporary, path)
1499
+ directory = os.open(path.parent, os.O_RDONLY)
1500
+ try:
1501
+ os.fsync(directory)
1502
+ finally:
1503
+ os.close(directory)
1504
+ finally:
1505
+ if descriptor is not None:
1506
+ os.close(descriptor)
1507
+ if temporary.exists() or temporary.is_symlink():
1508
+ temporary.unlink()
1509
+
1510
+
1511
+ def _validate_transition(
1512
+ before: PipelineState,
1513
+ after: PipelineState,
1514
+ event: str,
1515
+ project_dir: str,
1516
+ approval_kind: Optional[str] = None,
1517
+ allow_integration_configuration: bool = False,
1518
+ ) -> None:
1519
+ before_position = (before.phase, before.status)
1520
+ after_position = (after.phase, after.status)
1521
+ approval_edge = (
1522
+ before.phase in CHECKPOINT_KINDS
1523
+ and before_position == (before.phase, "active")
1524
+ and after_position == (before.phase, "done")
1525
+ )
1526
+ if approval_edge and approval_kind != before.phase:
1527
+ raise PipelineStateError(
1528
+ f"{before.phase} approval requires pipeline_state.py approve "
1529
+ f"--kind {before.phase}"
1530
+ )
1531
+ if before.phase == after.phase:
1532
+ same_phase_statuses = {
1533
+ "active": {"active", "blocked", "done"},
1534
+ "blocked": {"blocked", "active"},
1535
+ "done": {"done"},
1536
+ }
1537
+ patch_reopen = (
1538
+ before_position == ("plan", "done")
1539
+ and after_position == ("plan", "active")
1540
+ and event == "patch plan reopened"
1541
+ )
1542
+ if after.status not in same_phase_statuses[before.status] and not patch_reopen:
1543
+ raise PipelineStateError(
1544
+ f"illegal state transition: {before_position} -> {after_position}"
1545
+ )
1546
+ elif (*before_position, *after_position) not in CROSS_PHASE_TRANSITIONS:
1547
+ raise PipelineStateError(
1548
+ f"illegal state transition: {before_position} -> {after_position}"
1549
+ )
1550
+
1551
+ if before.pipeline != after.pipeline or before.project != after.project:
1552
+ raise PipelineStateError("pipeline and project are immutable")
1553
+ if (
1554
+ (*before_position, *after_position)
1555
+ == ("define", "active", "roadmap", "active")
1556
+ and PurePosixPath(project_dir) != PurePosixPath(".project")
1557
+ ):
1558
+ raise PipelineStateError("roadmap re-slice is legal only on the active track")
1559
+
1560
+ branch_changed = before.branch != after.branch
1561
+ initial_binding = before.branch is None and after.branch is not None
1562
+ next_binding = (
1563
+ before.phase == "shipped"
1564
+ and before.status == "done"
1565
+ and after.phase in {"define", "inspect"}
1566
+ and after.status == "active"
1567
+ and before.branch is not None
1568
+ and after.branch is not None
1569
+ )
1570
+ abandon = (
1571
+ before.phase == "build"
1572
+ and after.phase == "roadmap"
1573
+ and after.status == "active"
1574
+ and before.archive is not None
1575
+ and after.archive is None
1576
+ )
1577
+ integration_changed = (
1578
+ before.integration_default != after.integration_default
1579
+ or before.integration != after.integration
1580
+ or before.integration_source != after.integration_source
1581
+ )
1582
+ if integration_changed and not (next_binding or abandon):
1583
+ if before.phase in {"build", "ship", "shipped"}:
1584
+ raise PipelineStateError("integration mode is locked when build starts")
1585
+ if not allow_integration_configuration:
1586
+ raise PipelineStateError(
1587
+ "integration mode changes require configure-integration"
1588
+ )
1589
+ if next_binding:
1590
+ before_number = _bound_branch_number(before.branch or "")
1591
+ after_number = _bound_branch_number(after.branch or "")
1592
+ if (
1593
+ before_number is None
1594
+ or after_number is None
1595
+ or after_number <= before_number
1596
+ ):
1597
+ raise PipelineStateError(
1598
+ "next milestone requires a higher router-bound branch"
1599
+ )
1600
+ if after.integration_default != before.integration_default:
1601
+ raise PipelineStateError("next milestone must preserve integration_default")
1602
+ if after.integration != before.integration_default:
1603
+ raise PipelineStateError("next milestone must reset its integration override")
1604
+ if after.integration_source != "default":
1605
+ raise PipelineStateError("next milestone integration must use the project default")
1606
+ if abandon:
1607
+ if after.integration_default != before.integration_default:
1608
+ raise PipelineStateError("milestone abandon must preserve integration_default")
1609
+ if after.integration != before.integration_default:
1610
+ raise PipelineStateError("milestone abandon must reset its integration override")
1611
+ if after.integration_source != "default":
1612
+ raise PipelineStateError("milestone abandon integration must use the project default")
1613
+ if branch_changed and not (initial_binding or next_binding):
1614
+ raise PipelineStateError("illegal STATE.branch transition")
1615
+ if initial_binding:
1616
+ if before_position != after_position:
1617
+ raise PipelineStateError("initial branch binding cannot change phase or status")
1618
+ if event != "router bound initial milestone":
1619
+ raise PipelineStateError(
1620
+ "initial branch binding requires event: router bound initial milestone"
1621
+ )
1622
+
1623
+ archive_changed = before.archive != after.archive
1624
+ next_milestone = (
1625
+ before.phase == "shipped"
1626
+ and before.status == "done"
1627
+ and after.phase in {"define", "inspect"}
1628
+ and after.status == "active"
1629
+ and before.archive is not None
1630
+ and after.archive is None
1631
+ )
1632
+ if archive_changed and not (abandon or next_milestone):
1633
+ raise PipelineStateError("illegal STATE.archive transition")
1634
+
1635
+ milestone_changed = before.milestone != after.milestone
1636
+ define_approval = (
1637
+ before_position == ("define", "active")
1638
+ and after_position == ("define", "done")
1639
+ and after.milestone is not None
1640
+ and event == "milestone intent approved"
1641
+ )
1642
+ roadmap_selection = (
1643
+ before_position == ("roadmap", "active")
1644
+ and after_position == ("roadmap", "done")
1645
+ and after.milestone is not None
1646
+ and event == "program roadmap approved"
1647
+ )
1648
+ post_abandon_selection = (
1649
+ before_position == ("roadmap", "active")
1650
+ and after_position == ("inspect", "active")
1651
+ and before.milestone is None
1652
+ and after.milestone is not None
1653
+ )
1654
+ if milestone_changed and not (
1655
+ define_approval
1656
+ or roadmap_selection
1657
+ or next_milestone
1658
+ or abandon
1659
+ or post_abandon_selection
1660
+ ):
1661
+ raise PipelineStateError("illegal STATE.milestone transition")
1662
+ if (
1663
+ before_position == ("plan", "blocked")
1664
+ and after_position == ("plan", "active")
1665
+ and event != "planning resumed"
1666
+ ):
1667
+ raise PipelineStateError("plan recovery requires event: planning resumed")
1668
+ if abandon:
1669
+ expected_event = (
1670
+ f"milestone abandoned: {before.milestone}; archive: {before.archive}; "
1671
+ )
1672
+ if (
1673
+ before.milestone is None
1674
+ or after.milestone is not None
1675
+ or not event.startswith(expected_event)
1676
+ or not event.removeprefix(expected_event).startswith("ruling: ")
1677
+ or event == f"{expected_event}ruling:"
1678
+ ):
1679
+ raise PipelineStateError(
1680
+ "milestone abandon event must name its milestone, archive, and ruling"
1681
+ )
1682
+
1683
+ edge_events = {
1684
+ ("plan", "done", "build", "active"): {"build started"},
1685
+ ("build", "active", "ship", "active"): {
1686
+ "build done; final review pending"
1687
+ },
1688
+ ("build", "done", "ship", "active"): {
1689
+ "build done; final review pending"
1690
+ },
1691
+ ("decide", "done", "roadmap", "active"): {"roadmap started"},
1692
+ ("define", "active", "roadmap", "active"): {
1693
+ "roadmap re-slice started"
1694
+ },
1695
+ ("define", "done", "plan", "active"): {"planning started"},
1696
+ ("decide", "done", "plan", "active"): {"planning started"},
1697
+ ("ship", "blocked", "plan", "active"): {"patch plan reopened"},
1698
+ ("ship", "active", "shipped", "done"): {
1699
+ "archive preflight passed; shipment recorded",
1700
+ "archive preflight passed; shipment recorded; program complete",
1701
+ },
1702
+ }.get((*before_position, *after_position))
1703
+ if edge_events is not None and event not in edge_events:
1704
+ expected = " or ".join(sorted(edge_events))
1705
+ raise PipelineStateError(f"state transition requires event: {expected}")
1706
+
1707
+
1708
+ def transition_state(
1709
+ repo: Path,
1710
+ expected: Mapping[str, Optional[str]],
1711
+ changes: Mapping[str, Optional[str]],
1712
+ event: str,
1713
+ project_dir: str = ".project",
1714
+ ) -> dict[str, object]:
1715
+ """Atomically apply a state change only when expected fields still match."""
1716
+ resolved = _repo_root(repo)
1717
+ project = _track_root(resolved, project_dir)
1718
+ unsupported = (set(expected) | set(changes)) - set(STATE_FIELDS)
1719
+ if unsupported:
1720
+ raise PipelineStateError(f"unsupported state fields: {', '.join(sorted(unsupported))}")
1721
+ if not changes:
1722
+ raise PipelineStateError("transition requires at least one changed field")
1723
+ required_expected = {"phase", "status", "branch", "archive"} | set(changes)
1724
+ missing_expected = sorted(required_expected - set(expected))
1725
+ if missing_expected:
1726
+ raise PipelineStateError(
1727
+ "transition is missing expected fields: " + ", ".join(missing_expected)
1728
+ )
1729
+ with _state_lock(project):
1730
+ state, text, path = load_state(resolved, project_dir)
1731
+ before, after, rendered = _render_transition(
1732
+ state,
1733
+ text,
1734
+ expected,
1735
+ changes,
1736
+ event,
1737
+ project_dir,
1738
+ )
1739
+ if after.status != "blocked":
1740
+ reason = _pending_discussion_block(
1741
+ resolved, after.phase if after.status == "active" else None
1742
+ )
1743
+ if reason:
1744
+ raise PipelineStateError(reason)
1745
+ if state.phase in {"research", "decide"} and (
1746
+ after.phase != state.phase or after.status == "done"
1747
+ ):
1748
+ try:
1749
+ import check_handoffs
1750
+ except ImportError: # pragma: no cover - package imports used by tests
1751
+ from scripts import check_handoffs
1752
+ validator = {
1753
+ "research": check_handoffs.validate_research_artifacts,
1754
+ "decide": check_handoffs.validate_decide_artifacts,
1755
+ }[state.phase]
1756
+ try:
1757
+ validator(resolved, project_dir)
1758
+ except check_handoffs.HandoffError as error:
1759
+ raise PipelineStateError(f"{state.phase} handoff failed: {error}") from error
1760
+ _atomic_write(path, rendered)
1761
+ return {
1762
+ "schema": STATE_SCHEMA,
1763
+ "status": "transitioned",
1764
+ "path": str(path),
1765
+ "previous": before,
1766
+ "state": after.json(),
1767
+ }
1768
+
1769
+
1770
+ def configure_integration(
1771
+ repo: Path,
1772
+ scope: str,
1773
+ mode: str,
1774
+ project_dir: str = ".project",
1775
+ ) -> dict[str, object]:
1776
+ """Set the project default or current milestone integration before build."""
1777
+ if scope not in {"default", "milestone"}:
1778
+ raise PipelineStateError(f"invalid integration scope: {scope}")
1779
+ if mode not in INTEGRATION_MODES:
1780
+ raise PipelineStateError(f"invalid integration mode: {mode}")
1781
+ if scope == "default" and _is_lookahead(project_dir):
1782
+ raise PipelineStateError(
1783
+ "project integration default must be configured on the active track"
1784
+ )
1785
+ resolved = _repo_root(repo)
1786
+ project = _track_root(resolved, project_dir)
1787
+ with _state_lock(project):
1788
+ state, text, path = load_state(resolved, project_dir)
1789
+ if state.phase in {"build", "ship", "shipped"}:
1790
+ raise PipelineStateError("integration mode is locked when build starts")
1791
+ if scope == "default":
1792
+ integration = (
1793
+ mode if state.integration_source == "default" else state.integration
1794
+ )
1795
+ changes = {
1796
+ "integration_default": mode,
1797
+ "integration": integration,
1798
+ "integration_source": state.integration_source,
1799
+ }
1800
+ else:
1801
+ changes = {
1802
+ "integration_default": state.integration_default,
1803
+ "integration": mode,
1804
+ "integration_source": "milestone",
1805
+ }
1806
+ rendered = _set_frontmatter(text, changes)
1807
+ rendered = _append_event(
1808
+ rendered,
1809
+ state.phase,
1810
+ f"integration {scope} set to {mode}",
1811
+ )
1812
+ after = _state_from_text(rendered)
1813
+ _validate_state_context(after, project_dir, "STATE.md")
1814
+ _validate_transition(
1815
+ state,
1816
+ after,
1817
+ f"integration {scope} set to {mode}",
1818
+ project_dir,
1819
+ allow_integration_configuration=True,
1820
+ )
1821
+ _atomic_write(path, rendered)
1822
+ return {
1823
+ "schema": STATE_SCHEMA,
1824
+ "status": "configured",
1825
+ "path": str(path),
1826
+ "previous": state.json(),
1827
+ "state": after.json(),
1828
+ }
1829
+
1830
+
1831
+ def _shipment_roadmap(text: str, state: PipelineState, archive: str) -> str:
1832
+ if state.milestone is None or state.branch is None:
1833
+ raise PipelineStateError("shipment state must name milestone and branch")
1834
+ sections = _roadmap_sections(text)
1835
+ if state.milestone not in sections:
1836
+ raise PipelineStateError("ROADMAP.md is missing the shipped milestone")
1837
+ milestone_id, start, end = sections[state.milestone]
1838
+ number = _bound_branch_number(state.branch)
1839
+ match = ARCHIVE_RE.fullmatch(archive)
1840
+ if match is None or number != int(match.group(1)) or milestone_id != f"M{number:03d}":
1841
+ raise PipelineStateError("shipment branch, archive, and roadmap milestone differ")
1842
+ lines = text.splitlines(keepends=True)
1843
+ _replace_roadmap_field(lines, start, end, "Status", {"active", "shipped"}, "shipped")
1844
+ _replace_roadmap_field(lines, start, end, "Archive", {NULL, archive}, archive)
1845
+ return "".join(lines)
1846
+
1847
+
1848
+ def record_shipment(repo: Path, archive: str, event: str) -> dict[str, object]:
1849
+ """Journal and atomically replace ROADMAP and STATE shipment metadata."""
1850
+ resolved = _repo_root(repo)
1851
+ project = _track_root(resolved, ".project")
1852
+ journal_path = _git_path(resolved, SHIPMENT_JOURNAL_NAME)
1853
+ with _state_lock(project):
1854
+ state, state_text, state_path = load_state(resolved)
1855
+ roadmap_path = project / "ROADMAP.md"
1856
+ charter_path = project / "CHARTER.md"
1857
+ if charter_path.is_symlink() or (
1858
+ charter_path.exists() and not charter_path.is_file()
1859
+ ):
1860
+ raise PipelineStateError("CHARTER.md must be a real file")
1861
+ if roadmap_path.is_symlink() or (
1862
+ roadmap_path.exists() and not roadmap_path.is_file()
1863
+ ):
1864
+ raise PipelineStateError("ROADMAP.md must be a real file")
1865
+ program = charter_path.is_file()
1866
+ if program and not roadmap_path.is_file():
1867
+ raise PipelineStateError("program shipment requires ROADMAP.md")
1868
+ if program or roadmap_path.is_file():
1869
+ roadmap_text: Optional[str] = _read_real_file(roadmap_path, "ROADMAP.md")
1870
+ target_roadmap: Optional[str] = _shipment_roadmap(roadmap_text, state, archive)
1871
+ else:
1872
+ roadmap_text = target_roadmap = None
1873
+ journal: Optional[dict[str, object]] = None
1874
+ event_date: Optional[str] = None
1875
+ if journal_path.exists() or journal_path.is_symlink():
1876
+ journal = _read_json(journal_path)
1877
+ # Reuse the journaled event date so a next-day resume still matches.
1878
+ recorded = re.match(
1879
+ r"- (\S+) — ",
1880
+ str(journal.get("state_after", "")).rstrip("\n").rsplit("\n", 1)[-1],
1881
+ )
1882
+ event_date = recorded.group(1) if recorded else None
1883
+ if (state.phase, state.status) == ("ship", "active"):
1884
+ _, after, target_state = _render_transition(
1885
+ state,
1886
+ state_text,
1887
+ {
1888
+ "phase": "ship",
1889
+ "status": "active",
1890
+ "branch": state.branch,
1891
+ "archive": archive,
1892
+ },
1893
+ {"phase": "shipped", "status": "done"},
1894
+ event,
1895
+ ".project",
1896
+ event_date,
1897
+ )
1898
+ elif (state.phase, state.status) == ("shipped", "done"):
1899
+ if " ".join(event.split()) not in {
1900
+ "archive preflight passed; shipment recorded",
1901
+ "archive preflight passed; shipment recorded; program complete",
1902
+ }:
1903
+ raise PipelineStateError("shipment recovery has an invalid event")
1904
+ after, target_state = state, state_text
1905
+ else:
1906
+ raise PipelineStateError("shipment requires ship/active or shipped/done")
1907
+ request = {
1908
+ "schema": SHIPMENT_SCHEMA,
1909
+ "repo": str(resolved),
1910
+ "archive": archive,
1911
+ "event": " ".join(event.split()),
1912
+ }
1913
+ if journal is not None:
1914
+ expected_keys = {
1915
+ *request,
1916
+ "roadmap_before",
1917
+ "roadmap_after",
1918
+ "state_before",
1919
+ "state_after",
1920
+ }
1921
+ if set(journal) != expected_keys or any(
1922
+ journal.get(key) != value for key, value in request.items()
1923
+ ):
1924
+ raise PipelineStateError("shipment journal does not match request")
1925
+ state_after = journal.get("state_after")
1926
+ roadmap_after = journal.get("roadmap_after")
1927
+ if (
1928
+ not isinstance(state_after, str)
1929
+ or state_after != target_state
1930
+ or roadmap_after != target_roadmap
1931
+ ):
1932
+ raise PipelineStateError("shipment journal target does not match derived metadata")
1933
+ else:
1934
+ journal = {
1935
+ **request,
1936
+ "roadmap_before": roadmap_text,
1937
+ "roadmap_after": target_roadmap,
1938
+ "state_before": state_text,
1939
+ "state_after": target_state,
1940
+ }
1941
+ _write_json(journal_path, journal)
1942
+ targets = [(state_path, "state_before", "state_after")]
1943
+ if target_roadmap is not None:
1944
+ targets.insert(0, (roadmap_path, "roadmap_before", "roadmap_after"))
1945
+ for path, before_key, after_key in targets:
1946
+ current = _read_real_file(path, path.name)
1947
+ desired = journal.get(after_key)
1948
+ if not isinstance(desired, str) or current not in {journal.get(before_key), desired}:
1949
+ raise PipelineStateError(f"shipment metadata drifted: {path.name}")
1950
+ if current != desired:
1951
+ _atomic_write(path, desired)
1952
+ journal_path.unlink()
1953
+ return {"schema": SHIPMENT_SCHEMA, "status": "recorded", "archive": archive, "state": after.json()}
1954
+
1955
+
1956
+ def _render_transition(
1957
+ state: PipelineState,
1958
+ text: str,
1959
+ expected: Mapping[str, Optional[str]],
1960
+ changes: Mapping[str, Optional[str]],
1961
+ event: str,
1962
+ project_dir: str,
1963
+ event_date: Optional[str] = None,
1964
+ approval_kind: Optional[str] = None,
1965
+ ) -> tuple[dict[str, Optional[str]], PipelineState, str]:
1966
+ before = state.json()
1967
+ mismatches = {
1968
+ key: {"expected": value, "actual": before[key]}
1969
+ for key, value in expected.items()
1970
+ if before[key] != value
1971
+ }
1972
+ if mismatches:
1973
+ raise PipelineStateError(
1974
+ f"expected state does not match: {json.dumps(mismatches, sort_keys=True)}"
1975
+ )
1976
+ effective_changes = dict(changes)
1977
+ next_binding = (
1978
+ state.phase == "shipped"
1979
+ and state.status == "done"
1980
+ and effective_changes.get("phase", state.phase) in {"define", "inspect"}
1981
+ and effective_changes.get("status", state.status) == "active"
1982
+ and state.branch is not None
1983
+ and effective_changes.get("branch", state.branch) is not None
1984
+ )
1985
+ if next_binding:
1986
+ effective_changes["integration_default"] = state.integration_default
1987
+ effective_changes["integration"] = state.integration_default
1988
+ effective_changes["integration_source"] = "default"
1989
+ abandon = (
1990
+ state.phase == "build"
1991
+ and effective_changes.get("phase", state.phase) == "roadmap"
1992
+ and effective_changes.get("status", state.status) == "active"
1993
+ and state.archive is not None
1994
+ and effective_changes.get("archive", state.archive) is None
1995
+ )
1996
+ if abandon:
1997
+ effective_changes["integration_default"] = state.integration_default
1998
+ effective_changes["integration"] = state.integration_default
1999
+ effective_changes["integration_source"] = "default"
2000
+ rendered = _set_frontmatter(
2001
+ text,
2002
+ {
2003
+ key: NULL if value is None else value
2004
+ for key, value in effective_changes.items()
2005
+ },
2006
+ )
2007
+ next_phase = effective_changes.get("phase", state.phase)
2008
+ if not isinstance(next_phase, str):
2009
+ raise PipelineStateError("phase cannot be null")
2010
+ normalized_event = " ".join(event.split())
2011
+ rendered = _append_event(rendered, next_phase, normalized_event, event_date)
2012
+ after = _state_from_text(rendered)
2013
+ _validate_state_context(after, project_dir, "STATE.md")
2014
+ _validate_transition(
2015
+ state,
2016
+ after,
2017
+ normalized_event,
2018
+ project_dir,
2019
+ approval_kind,
2020
+ )
2021
+ return before, after, rendered
2022
+
2023
+
2024
+ def _roadmap_sections(text: str) -> dict[str, tuple[str, int, int]]:
2025
+ lines = text.splitlines(keepends=True)
2026
+ headings: list[tuple[str, str, int]] = []
2027
+ for index, line in enumerate(lines):
2028
+ match = ROADMAP_HEADING_RE.fullmatch(line.rstrip("\r\n"))
2029
+ if match:
2030
+ if int(match.group(1).removeprefix("M")) < 1:
2031
+ raise PipelineStateError(f"ROADMAP.md has invalid milestone id: {match.group(1)}")
2032
+ headings.append((match.group(1), match.group(2), index))
2033
+ sections: dict[str, tuple[str, int, int]] = {}
2034
+ for position, (milestone_id, slug, start) in enumerate(headings):
2035
+ end = headings[position + 1][2] if position + 1 < len(headings) else len(lines)
2036
+ if slug in sections:
2037
+ raise PipelineStateError(f"ROADMAP.md repeats milestone slug: {slug}")
2038
+ sections[slug] = (milestone_id, start, end)
2039
+ return sections
2040
+
2041
+
2042
+ def _replace_roadmap_field(
2043
+ lines: list[str],
2044
+ start: int,
2045
+ end: int,
2046
+ field: str,
2047
+ expected: set[str],
2048
+ value: str,
2049
+ ) -> None:
2050
+ found: Optional[int] = None
2051
+ for index in range(start, end):
2052
+ raw = lines[index].rstrip("\r\n")
2053
+ ending = lines[index][len(raw) :]
2054
+ match = re.match(rf"^({re.escape(field)}:\s*)(\S+)(.*)$", raw)
2055
+ if match is None:
2056
+ continue
2057
+ if found is not None:
2058
+ raise PipelineStateError(f"ROADMAP.md repeats {field} in one milestone")
2059
+ if match.group(2) not in expected:
2060
+ raise PipelineStateError(
2061
+ f"ROADMAP.md {field} is {match.group(2)}, expected {sorted(expected)}"
2062
+ )
2063
+ lines[index] = f"{match.group(1)}{value}{match.group(3)}{ending}"
2064
+ found = index
2065
+ if found is None:
2066
+ raise PipelineStateError(f"ROADMAP.md milestone is missing {field}")
2067
+
2068
+
2069
+ def _git_path(repo: Path, name: str) -> Path:
2070
+ value = _run_git(repo, "rev-parse", "--git-path", name).stdout.strip()
2071
+ path = Path(value)
2072
+ if not path.is_absolute():
2073
+ path = repo / path
2074
+ return path.resolve()
2075
+
2076
+
2077
+ def _read_json(path: Path) -> dict[str, object]:
2078
+ if path.is_symlink() or not path.is_file():
2079
+ raise PipelineStateError(f"transaction journal must be a real file: {path}")
2080
+ try:
2081
+ value = json.loads(path.read_text(encoding="utf-8"))
2082
+ except (OSError, json.JSONDecodeError) as error:
2083
+ raise PipelineStateError(f"transaction journal is unreadable: {path}: {error}") from error
2084
+ if not isinstance(value, dict):
2085
+ raise PipelineStateError(f"transaction journal must contain an object: {path}")
2086
+ return value
2087
+
2088
+
2089
+ def _write_json(path: Path, value: Mapping[str, object]) -> None:
2090
+ path.parent.mkdir(parents=True, exist_ok=True)
2091
+ _atomic_write(path, json.dumps(value, indent=2, sort_keys=True) + "\n")
2092
+
2093
+
2094
+ def _commit_message(subject: str, body_fields: Mapping[str, str]) -> str:
2095
+ body = "\n".join(f"{key}: {value}" for key, value in body_fields.items())
2096
+ return f"{subject}\n\n{body}\n"
2097
+
2098
+
2099
+ def _validate_next_layout(next_root: Path, state: PipelineState) -> None:
2100
+ phase_index = LOOKAHEAD_PHASES.index(state.phase)
2101
+ for path in next_root.rglob("*"):
2102
+ relative = path.relative_to(next_root)
2103
+ if path.is_symlink():
2104
+ raise PipelineStateError(f"lookahead artifacts must not be symlinks: {path}")
2105
+ if path.is_dir():
2106
+ if (
2107
+ relative.parent != PurePosixPath(".")
2108
+ or path.name not in PROMOTION_TRACKS
2109
+ ):
2110
+ raise PipelineStateError(f"lookahead has unowned directory: {path}")
2111
+ continue
2112
+ if not path.is_file():
2113
+ raise PipelineStateError(f"lookahead has unsafe artifact: {path}")
2114
+ artifact = relative.as_posix()
2115
+ required_phase = LOOKAHEAD_FIXED_ARTIFACTS.get(artifact)
2116
+ if required_phase is None and LOOKAHEAD_EVIDENCE_RE.fullmatch(artifact):
2117
+ required_phase = "research"
2118
+ if (
2119
+ required_phase is None
2120
+ and relative.parent == PurePosixPath("tasks")
2121
+ and TASK_FILE_RE.fullmatch(path.name)
2122
+ ):
2123
+ required_phase = "plan"
2124
+ if (
2125
+ required_phase is None
2126
+ or LOOKAHEAD_PHASES.index(required_phase) > phase_index
2127
+ ):
2128
+ raise PipelineStateError(
2129
+ f"lookahead has artifact not owned by {state.phase}: {path}"
2130
+ )
2131
+
2132
+
2133
+ def _worktree_changes(repo: Path) -> list[str]:
2134
+ commands = (
2135
+ ("diff", "--name-only", "-z"),
2136
+ ("diff", "--cached", "--name-only", "-z"),
2137
+ ("ls-files", "--others", "--exclude-standard", "-z"),
2138
+ )
2139
+ return sorted(
2140
+ {
2141
+ path
2142
+ for arguments in commands
2143
+ for path in _run_git(repo, *arguments).stdout.split("\0")
2144
+ if path
2145
+ }
2146
+ )
2147
+
2148
+
2149
+ def _field_arguments(parser: argparse.ArgumentParser, prefix: str) -> None:
2150
+ for field in STATE_FIELDS:
2151
+ parser.add_argument(f"--{prefix}-{field}", dest=f"{prefix}_{field}")
2152
+
2153
+
2154
+ def _specified_fields(arguments: argparse.Namespace, prefix: str) -> dict[str, Optional[str]]:
2155
+ values: dict[str, Optional[str]] = {}
2156
+ for field in STATE_FIELDS:
2157
+ value = getattr(arguments, f"{prefix}_{field}")
2158
+ if value is not None:
2159
+ values[field] = None if value == NULL else value
2160
+ return values
2161
+
2162
+
2163
+ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
2164
+ parser = argparse.ArgumentParser(description=__doc__)
2165
+ subparsers = parser.add_subparsers(dest="command", required=True)
2166
+ for command in ("validate", "route", "status"):
2167
+ subparser = subparsers.add_parser(command)
2168
+ subparser.add_argument("--repo", required=True, type=Path)
2169
+ subparser.add_argument("--project-dir", default=".project")
2170
+ transition = subparsers.add_parser("transition")
2171
+ transition.add_argument("--repo", required=True, type=Path)
2172
+ transition.add_argument("--project-dir", default=".project")
2173
+ transition.add_argument("--event", required=True)
2174
+ _field_arguments(transition, "expect")
2175
+ _field_arguments(transition, "set")
2176
+ approve = subparsers.add_parser("approve")
2177
+ approve.add_argument("--repo", required=True, type=Path)
2178
+ approve.add_argument("--kind", required=True, choices=CHECKPOINT_KINDS)
2179
+ approve.add_argument("--expected-head")
2180
+ approve.add_argument("--project-dir", default=".project")
2181
+ approve.add_argument("--milestone")
2182
+ approve.add_argument("--defer-checkpoint", action="store_true")
2183
+ approve.add_argument("--patch", action="store_true")
2184
+ resume = subparsers.add_parser("resume-checkpoint")
2185
+ resume.add_argument("--repo", required=True, type=Path)
2186
+ promote = subparsers.add_parser("promote-next")
2187
+ promote.add_argument("--repo", required=True, type=Path)
2188
+ promote.add_argument("--milestone", required=True)
2189
+ promote.add_argument("--branch", required=True)
2190
+ promote.add_argument("--integrate")
2191
+ promote.add_argument("--base")
2192
+ promote.add_argument("--landing")
2193
+ configure = subparsers.add_parser("configure-integration")
2194
+ configure.add_argument("--repo", required=True, type=Path)
2195
+ configure.add_argument("--project-dir", default=".project")
2196
+ configure.add_argument("--scope", required=True, choices=("default", "milestone"))
2197
+ configure.add_argument("--mode", required=True, choices=INTEGRATION_MODES)
2198
+ shipment = subparsers.add_parser("record-shipment")
2199
+ shipment.add_argument("--repo", required=True, type=Path)
2200
+ shipment.add_argument("--archive", required=True)
2201
+ shipment.add_argument("--event", required=True)
2202
+ return parser.parse_args(argv)
2203
+
2204
+
2205
+ def main(argv: Optional[Sequence[str]] = None) -> int:
2206
+ args = parse_args(argv)
2207
+ try:
2208
+ if args.command == "validate":
2209
+ result = validate_state(args.repo, args.project_dir)
2210
+ elif args.command == "route":
2211
+ result = route_state(args.repo, args.project_dir)
2212
+ elif args.command == "status":
2213
+ result = status_state(args.repo, args.project_dir)
2214
+ elif args.command == "transition":
2215
+ result = transition_state(
2216
+ args.repo,
2217
+ _specified_fields(args, "expect"),
2218
+ _specified_fields(args, "set"),
2219
+ args.event,
2220
+ args.project_dir,
2221
+ )
2222
+ elif args.command == "approve" and (args.defer_checkpoint or args.patch):
2223
+ if args.expected_head is not None:
2224
+ raise PipelineStateError(
2225
+ "--expected-head is not accepted with --defer-checkpoint or --patch"
2226
+ )
2227
+ result = defer_approval(
2228
+ args.repo,
2229
+ args.kind,
2230
+ args.project_dir,
2231
+ args.milestone,
2232
+ args.patch,
2233
+ )
2234
+ elif args.command == "approve":
2235
+ if args.expected_head is None:
2236
+ raise PipelineStateError(
2237
+ "approve requires --expected-head unless --defer-checkpoint or --patch"
2238
+ )
2239
+ result = checkpoint_approval(
2240
+ args.repo,
2241
+ args.kind,
2242
+ args.expected_head,
2243
+ args.project_dir,
2244
+ args.milestone,
2245
+ )
2246
+ elif args.command == "resume-checkpoint":
2247
+ result = resume_checkpoint(args.repo)
2248
+ elif args.command == "promote-next":
2249
+ if args.integrate is not None and (
2250
+ args.base is not None or args.landing is not None
2251
+ ):
2252
+ raise PipelineStateError(
2253
+ "promote-next cannot combine --integrate with --base or --landing"
2254
+ )
2255
+ base = args.integrate if args.integrate is not None else args.base
2256
+ landing = (
2257
+ args.integrate
2258
+ if args.integrate is not None
2259
+ else args.landing or base
2260
+ )
2261
+ if base is None:
2262
+ raise PipelineStateError(
2263
+ "promote-next requires --base or legacy --integrate"
2264
+ )
2265
+ result = promote_next(
2266
+ args.repo,
2267
+ args.milestone,
2268
+ args.branch,
2269
+ base,
2270
+ landing,
2271
+ )
2272
+ elif args.command == "record-shipment":
2273
+ result = record_shipment(args.repo, args.archive, args.event)
2274
+ elif args.command == "configure-integration":
2275
+ result = configure_integration(
2276
+ args.repo,
2277
+ args.scope,
2278
+ args.mode,
2279
+ args.project_dir,
2280
+ )
2281
+ else: # pragma: no cover - argparse rejects unknown commands
2282
+ raise PipelineStateError(f"unknown command: {args.command}")
2283
+ except PipelineStateError as error:
2284
+ print(f"error: {error}", file=sys.stderr)
2285
+ return 1
2286
+ print(json.dumps(result, sort_keys=True))
2287
+ return 0
2288
+
2289
+
2290
+ if __name__ == "__main__":
2291
+ raise SystemExit(main())