@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,2265 @@
1
+ #!/usr/bin/env python3
2
+ # gsd-path project runtime
3
+ """Prepare, render, validate, integrate, and abandon GSD Path milestone archives.
4
+
5
+ Also validates milestone integration: the read-only validate-integrated
6
+ command checks the shipped transaction, the selected mode's two-parent merge
7
+ on the remote default branch, and the milestone tag. Pull-request validation
8
+ requires network access to verify live origin publication.
9
+ """
10
+
11
+ import argparse
12
+ import filecmp
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from contextlib import contextmanager
20
+ from datetime import date
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Iterator, NamedTuple, Optional, Sequence
23
+
24
+ try:
25
+ from isolation import (
26
+ IsolationError,
27
+ checkpoint as isolation_checkpoint,
28
+ verify_landed_task_files,
29
+ )
30
+ from pipeline_git import (
31
+ bound_branch_name,
32
+ default_branch_name,
33
+ integrate_commit_body,
34
+ integrate_subject,
35
+ is_bound_branch,
36
+ is_integrate_subject,
37
+ is_ship_subject,
38
+ milestone_id,
39
+ milestone_number,
40
+ ship_commit_body,
41
+ ship_subject,
42
+ )
43
+ from review_panel import KNOWN_FAMILIES, ReviewPanelError, parse_sources
44
+ from pipeline_state import (
45
+ PipelineState,
46
+ PipelineStateError,
47
+ load_state,
48
+ transition_state,
49
+ )
50
+ import _common
51
+ except ImportError: # pragma: no cover - package import used by tests
52
+ from scripts.isolation import (
53
+ IsolationError,
54
+ checkpoint as isolation_checkpoint,
55
+ verify_landed_task_files,
56
+ )
57
+ from scripts.pipeline_git import (
58
+ bound_branch_name,
59
+ default_branch_name,
60
+ integrate_commit_body,
61
+ integrate_subject,
62
+ is_bound_branch,
63
+ is_integrate_subject,
64
+ is_ship_subject,
65
+ milestone_id,
66
+ milestone_number,
67
+ ship_commit_body,
68
+ ship_subject,
69
+ )
70
+ from scripts.review_panel import KNOWN_FAMILIES, ReviewPanelError, parse_sources
71
+ from scripts.pipeline_state import (
72
+ PipelineState,
73
+ PipelineStateError,
74
+ load_state,
75
+ transition_state,
76
+ )
77
+ from scripts import _common
78
+
79
+ if sys.platform == "win32":
80
+ import msvcrt
81
+ else:
82
+ import fcntl
83
+
84
+
85
+ ARCHIVE_PATTERN = re.compile(r"^(\d{3,})-([a-z0-9][a-z0-9-]*)$")
86
+ TASK_FILE_PATTERN = re.compile(r"^T\d{3}-[a-z0-9][a-z0-9-]*\.md$")
87
+ WAVE_FILE_PATTERN = re.compile(
88
+ r"^wave-([1-9]\d*)\.cycle([1-9]\d*)(\.(?:contract|adversarial|panel))?\.md$"
89
+ )
90
+ WAVE_PANEL_SKIP_PATTERN = re.compile(
91
+ r"^wave-([1-9]\d*)\.cycle([1-9]\d*)\.panel\.skipped\.json$"
92
+ )
93
+ WAVE_SKEPTIC_PATTERN = re.compile(
94
+ r"^wave-([1-9]\d*)\.cycle([1-9]\d*)\.skeptic-"
95
+ r"([a-z0-9]+(?:_[a-z0-9]+)*)\.md$"
96
+ )
97
+ WAVE_REPAIR_PATTERN = re.compile(
98
+ r"^wave-([1-9]\d*)\.cycle([1-9]\d*)\.repair-(T\d{3})\.json$"
99
+ )
100
+ # A review file claims to be wave-cycle evidence when its name starts with a
101
+ # wave number followed by "cycle" (a missing dot is a typo, not a note). Other
102
+ # wave-* names are free-form review notes like any non-canonical review file.
103
+ WAVE_CYCLE_CLAIM_PATTERN = re.compile(r"^wave-\d+\.?cycle")
104
+ FINAL_CRITERION_PATTERN = re.compile(r"^### SC([1-9]\d*) — (.+)$")
105
+ INTENT_CRITERION_PATTERN = re.compile(r"^([1-9]\d*)\.\s+(\S.*)$")
106
+ HTML_COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
107
+ FINAL_GAP_FILE_PATTERN = re.compile(r"^final-gap-([1-9]\d*)\.md$")
108
+ FINAL_GAP_HEADING_PATTERN = re.compile(r"^# Gap Review — ([1-9]\d*): (.+)$")
109
+ WAVE_TASK_HEADING_PATTERN = re.compile(r"^## (T\d{3}) — (.+): (pass|fail)$")
110
+ WAVE_SC_HEADING_PATTERN = re.compile(
111
+ r"^### (SC[1-9]\d*) — (.+): (pass|fail)$"
112
+ )
113
+ PLAN_COVERAGE_ROW_PATTERN = re.compile(
114
+ r"^\|\s*(SC[1-9]\d*)\s*\|\s*(T\d{3})\s*\|\s*([^|]+?)\s*\|$"
115
+ )
116
+ DIALOGUE_HEADING_PATTERN = re.compile(
117
+ r"^### D(\d{3}) — (\d{4}-\d{2}-\d{2}) — "
118
+ r"([a-z-]+)/(active|blocked|done) — (.+)$"
119
+ )
120
+ ANSWER_HEADING_PATTERN = re.compile(
121
+ r"^## Answer A(\d{3}) — (\d{4}-\d{2}-\d{2}) — .+$"
122
+ )
123
+ DISPOSITION_HEADING_PATTERN = re.compile(
124
+ r"^## Disposition X(\d{3}) — (\d{4}-\d{2}-\d{2})$"
125
+ )
126
+ DISCUSSION_PHASES = {
127
+ "inspect",
128
+ "define",
129
+ "research",
130
+ "decide",
131
+ "roadmap",
132
+ "plan",
133
+ "build",
134
+ "ship",
135
+ }
136
+ DISCUSSION_FILES = ("DIALOGUE.md", "ANSWERS.md")
137
+ EMPTY_DISCUSSION_FILES = {
138
+ "DIALOGUE.md": """# GSD Path Discussion — Dialogue
139
+
140
+ <!-- Written by $gsd-path-discuss. Append turns; never rewrite prior turns. -->
141
+
142
+ ## Turns
143
+ """,
144
+ "ANSWERS.md": """# GSD Path Discussion — Answers
145
+
146
+ <!-- Written by $gsd-path-discuss. Append one record per turn; never rewrite prior answers. -->
147
+ """,
148
+ }
149
+ DISCUSSION_TRANSACTION_NAME = ".discussion-archive-transaction.json"
150
+ DIRECTORIES_TO_ARCHIVE = ("intent", "research", "plan", "tasks", "review")
151
+ # build/ holds the verify ledger; it archives with the milestone when present.
152
+ OPTIONAL_DIRECTORIES_TO_ARCHIVE = ("discuss", "build")
153
+ TRANSACTION_DIRECTORIES = (*DIRECTORIES_TO_ARCHIVE, *OPTIONAL_DIRECTORIES_TO_ARCHIVE)
154
+ MANIFEST_FIELDS = (
155
+ "Milestone:",
156
+ "Shipped:",
157
+ "Final verdict:",
158
+ "Waves:",
159
+ "Carried forward:",
160
+ )
161
+ MANIFEST_HEADINGS = ("## Success criteria at ship", "## Contents", "## Notes")
162
+ CARRY_TEMP_NAME = ".DOCS-AUDIT.md.gsd-path-tmp"
163
+ STATE_TEMP_NAME = ".STATE.md.gsd-path-tmp"
164
+ MANIFEST_TEMP_NAME = ".MANIFEST.md.gsd-path-tmp"
165
+ ABANDON_JOURNAL_SCHEMA = "gsd-path/abandon/v1"
166
+ ABANDON_JOURNAL_NAME = "gsd-path-abandon.json"
167
+ ROADMAP_HEADING_PATTERN = re.compile(
168
+ r"^### (M\d{3,}) — ([a-z0-9][a-z0-9-]*)\s*$"
169
+ )
170
+ # Evidence files are per-dispatched-dimension: the research phase gate owns
171
+ # their existence, so the archive requires only the always-produced core.
172
+ REQUIRED_ARCHIVE_FILES = (
173
+ "intent/INTENT.md",
174
+ "research/SYNTHESIS.md",
175
+ "plan/PLAN.md",
176
+ "review/FINAL.md",
177
+ )
178
+ # Abandon runs from build: review/ may hold only wave files, but an approved
179
+ # plan implies these artifacts existed.
180
+ ABANDON_REQUIRED_ARCHIVE_FILES = (
181
+ "intent/INTENT.md",
182
+ "research/SYNTHESIS.md",
183
+ "plan/PLAN.md",
184
+ )
185
+ ABANDON_REQUIRED_DIRECTORIES = ("intent", "research", "plan", "tasks")
186
+
187
+ # Template placeholders look like <name> or <one line>. Comparison text such
188
+ # as "120ms < 200ms" or "a -> b" is legitimate evidence, not a placeholder.
189
+ PLACEHOLDER_PATTERN = re.compile(r"(?<!\w)<[a-zA-Z][^<>\n]*>")
190
+ CODE_SPAN_PATTERN = re.compile(r"(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)")
191
+
192
+
193
+ def contains_placeholder(value: str) -> bool:
194
+ # A wholly unfilled value is a placeholder even when quoted.
195
+ cleaned = value.strip().strip("`")
196
+ if PLACEHOLDER_PATTERN.fullmatch(cleaned):
197
+ return True
198
+ # Scan with code spans blanked out: quoted code such as a TypeScript
199
+ # generic is not an unfilled placeholder.
200
+ return PLACEHOLDER_PATTERN.search(CODE_SPAN_PATTERN.sub(" ", value)) is not None
201
+
202
+
203
+ def plan_review_panel_config(plan_text: str) -> dict:
204
+ """Parse PLAN review-panel policy with the canonical fail-closed parser."""
205
+ try:
206
+ return parse_sources(plan_text, None)
207
+ except ReviewPanelError as error:
208
+ raise ArchiveError(f"PLAN.md review_panel config is invalid: {error}") from error
209
+
210
+
211
+ def split_manifest_row(line: str) -> Sequence[str]:
212
+ """Split a manifest table row, honoring \\| as an escaped literal pipe."""
213
+ text = line.strip()
214
+ if text.startswith("|"):
215
+ text = text[1:]
216
+ if text.endswith("|") and not text.endswith("\\|"):
217
+ text = text[:-1]
218
+ cells = []
219
+ current = []
220
+ index = 0
221
+ while index < len(text):
222
+ char = text[index]
223
+ if char == "\\" and index + 1 < len(text) and text[index + 1] == "|":
224
+ current.append("|")
225
+ index += 2
226
+ continue
227
+ if char == "|":
228
+ cells.append("".join(current).strip())
229
+ current = []
230
+ else:
231
+ current.append(char)
232
+ index += 1
233
+ cells.append("".join(current).strip())
234
+ return cells
235
+
236
+
237
+ class ArchiveError(RuntimeError):
238
+ pass
239
+
240
+
241
+ PIPELINE_MARKER = _common.PIPELINE_MARKER
242
+ PR_CREDIT_LINE = (
243
+ "PR prepared with [GSD Path](https://github.com/open-gsd/gsd-path)."
244
+ )
245
+ GITHUB_HOST = "github.com"
246
+
247
+
248
+ # discussion_validate and integration call back into this module through a
249
+ # module reference and import the constants and ArchiveError above, so this
250
+ # import must stay below those definitions.
251
+ if __package__: # imported as scripts.archive_milestone
252
+ from .discussion_validate import (
253
+ record_sections,
254
+ required_dialogue_block,
255
+ require_contiguous_ids,
256
+ require_iso_date,
257
+ require_bullet_fields,
258
+ validate_discussion_directory,
259
+ require_append_only_discussion,
260
+ reconcile_append_only_discussion,
261
+ require_safe_discussion_destination,
262
+ finish_discussion_reconciliation,
263
+ remove_interrupted_discussion_copies,
264
+ remove_interrupted_carry_copy,
265
+ require_transaction_context,
266
+ completed_field,
267
+ completed_bullet_field,
268
+ section_lines,
269
+ archived_intent_criteria,
270
+ parse_final_review,
271
+ validate_gap_reviews,
272
+ meaningful_review_evidence,
273
+ archived_wave_tasks,
274
+ plan_wave_criteria,
275
+ validate_wave_review,
276
+ plan_wave_depths,
277
+ review_cycle_counts,
278
+ manifest_cell,
279
+ archive_file_inventory,
280
+ render_manifest,
281
+ validate_manifest,
282
+ )
283
+ from .integration import (
284
+ resolve_remote_default,
285
+ live_remote_ref,
286
+ publish_bound_branch,
287
+ refresh_origin,
288
+ optional_ref,
289
+ integration_names,
290
+ registered_worktree,
291
+ remove_registered_worktree,
292
+ delete_integration_branch,
293
+ require_generated_integration_commit,
294
+ discard_unpublished_stale_integration,
295
+ create_integration_merge,
296
+ require_annotated_tag,
297
+ ensure_integration_tag,
298
+ github_repository,
299
+ require_github_authentication,
300
+ github_api_json,
301
+ require_pull_request_shape,
302
+ require_pull_request_pages,
303
+ pull_request_head_matches_repository,
304
+ require_pull_request_identity,
305
+ find_pull_request,
306
+ pull_request_body,
307
+ update_pull_request_body,
308
+ create_pull_request,
309
+ pull_request_tag_message,
310
+ pull_request_tag_metadata,
311
+ require_pull_request_merge,
312
+ require_pull_request_merge_provenance,
313
+ integrate_pull_request,
314
+ integrate,
315
+ find_integrate_commit,
316
+ validate_integrated,
317
+ require_published_integration,
318
+ )
319
+ else: # standalone script or sibling import
320
+ if __name__ == "__main__":
321
+ sys.modules.setdefault("archive_milestone", sys.modules[__name__])
322
+ try:
323
+ from discussion_validate import (
324
+ record_sections,
325
+ required_dialogue_block,
326
+ require_contiguous_ids,
327
+ require_iso_date,
328
+ require_bullet_fields,
329
+ validate_discussion_directory,
330
+ require_append_only_discussion,
331
+ reconcile_append_only_discussion,
332
+ require_safe_discussion_destination,
333
+ finish_discussion_reconciliation,
334
+ remove_interrupted_discussion_copies,
335
+ remove_interrupted_carry_copy,
336
+ require_transaction_context,
337
+ completed_field,
338
+ completed_bullet_field,
339
+ section_lines,
340
+ archived_intent_criteria,
341
+ parse_final_review,
342
+ validate_gap_reviews,
343
+ meaningful_review_evidence,
344
+ archived_wave_tasks,
345
+ plan_wave_criteria,
346
+ validate_wave_review,
347
+ plan_wave_depths,
348
+ review_cycle_counts,
349
+ manifest_cell,
350
+ archive_file_inventory,
351
+ render_manifest,
352
+ validate_manifest,
353
+ )
354
+ from integration import (
355
+ resolve_remote_default,
356
+ live_remote_ref,
357
+ publish_bound_branch,
358
+ refresh_origin,
359
+ optional_ref,
360
+ integration_names,
361
+ registered_worktree,
362
+ remove_registered_worktree,
363
+ delete_integration_branch,
364
+ require_generated_integration_commit,
365
+ discard_unpublished_stale_integration,
366
+ create_integration_merge,
367
+ require_annotated_tag,
368
+ ensure_integration_tag,
369
+ github_repository,
370
+ require_github_authentication,
371
+ github_api_json,
372
+ require_pull_request_shape,
373
+ require_pull_request_pages,
374
+ pull_request_head_matches_repository,
375
+ require_pull_request_identity,
376
+ find_pull_request,
377
+ pull_request_body,
378
+ update_pull_request_body,
379
+ create_pull_request,
380
+ pull_request_tag_message,
381
+ pull_request_tag_metadata,
382
+ require_pull_request_merge,
383
+ require_pull_request_merge_provenance,
384
+ integrate_pull_request,
385
+ integrate,
386
+ find_integrate_commit,
387
+ validate_integrated,
388
+ require_published_integration,
389
+ )
390
+ except ImportError: # pragma: no cover - package import used by tests
391
+ from scripts.discussion_validate import (
392
+ record_sections,
393
+ required_dialogue_block,
394
+ require_contiguous_ids,
395
+ require_iso_date,
396
+ require_bullet_fields,
397
+ validate_discussion_directory,
398
+ require_append_only_discussion,
399
+ reconcile_append_only_discussion,
400
+ require_safe_discussion_destination,
401
+ finish_discussion_reconciliation,
402
+ remove_interrupted_discussion_copies,
403
+ remove_interrupted_carry_copy,
404
+ require_transaction_context,
405
+ completed_field,
406
+ completed_bullet_field,
407
+ section_lines,
408
+ archived_intent_criteria,
409
+ parse_final_review,
410
+ validate_gap_reviews,
411
+ meaningful_review_evidence,
412
+ archived_wave_tasks,
413
+ plan_wave_criteria,
414
+ validate_wave_review,
415
+ plan_wave_depths,
416
+ review_cycle_counts,
417
+ manifest_cell,
418
+ archive_file_inventory,
419
+ render_manifest,
420
+ validate_manifest,
421
+ )
422
+ from scripts.integration import (
423
+ resolve_remote_default,
424
+ live_remote_ref,
425
+ publish_bound_branch,
426
+ refresh_origin,
427
+ optional_ref,
428
+ integration_names,
429
+ registered_worktree,
430
+ remove_registered_worktree,
431
+ delete_integration_branch,
432
+ require_generated_integration_commit,
433
+ discard_unpublished_stale_integration,
434
+ create_integration_merge,
435
+ require_annotated_tag,
436
+ ensure_integration_tag,
437
+ github_repository,
438
+ require_github_authentication,
439
+ github_api_json,
440
+ require_pull_request_shape,
441
+ require_pull_request_pages,
442
+ pull_request_head_matches_repository,
443
+ require_pull_request_identity,
444
+ find_pull_request,
445
+ pull_request_body,
446
+ update_pull_request_body,
447
+ create_pull_request,
448
+ pull_request_tag_message,
449
+ pull_request_tag_metadata,
450
+ require_pull_request_merge,
451
+ require_pull_request_merge_provenance,
452
+ integrate_pull_request,
453
+ integrate,
454
+ find_integrate_commit,
455
+ validate_integrated,
456
+ require_published_integration,
457
+ )
458
+
459
+
460
+ @contextmanager
461
+ def discussion_lock(active_root: Path) -> Iterator[None]:
462
+ if sys.platform == "win32":
463
+ lock_value = require_git_success(
464
+ run_git(
465
+ active_root.parent,
466
+ "rev-parse",
467
+ "--git-path",
468
+ "gsd-path-discussion.lock",
469
+ ),
470
+ "resolve discussion lock",
471
+ )
472
+ lock_path = Path(lock_value)
473
+ if not lock_path.is_absolute():
474
+ lock_path = active_root.parent / lock_path
475
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
476
+ with lock_path.open("a+b") as handle:
477
+ handle.seek(0, os.SEEK_END)
478
+ if handle.tell() == 0:
479
+ handle.write(b"\0")
480
+ handle.flush()
481
+ handle.seek(0)
482
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
483
+ try:
484
+ yield
485
+ finally:
486
+ handle.seek(0)
487
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
488
+ return
489
+
490
+ descriptor = os.open(active_root, os.O_RDONLY)
491
+ try:
492
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
493
+ yield
494
+ finally:
495
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
496
+ os.close(descriptor)
497
+
498
+
499
+ atomic_replace = _common.atomic_replace
500
+
501
+
502
+ def atomic_write(path: Path, content: str) -> None:
503
+ atomic_replace(path, path.parent / STATE_TEMP_NAME, content)
504
+
505
+
506
+ def frontmatter_value(content: str, key: str) -> Optional[str]:
507
+ lines = content.splitlines()
508
+ if not lines or lines[0] != "---":
509
+ raise ArchiveError("STATE.md is missing YAML frontmatter")
510
+ for line in lines[1:]:
511
+ if line == "---":
512
+ break
513
+ match = re.match(rf"^{re.escape(key)}:\s*([^#]*?)\s*(?:#.*)?$", line)
514
+ if match:
515
+ value = match.group(1).strip().strip('"').strip("'")
516
+ return value
517
+ return None
518
+
519
+
520
+ def set_frontmatter_value(content: str, key: str, value: str) -> str:
521
+ lines = content.splitlines(keepends=True)
522
+ if not lines or lines[0].rstrip("\r\n") != "---":
523
+ raise ArchiveError("STATE.md is missing YAML frontmatter")
524
+
525
+ closing_index: Optional[int] = None
526
+ key_index: Optional[int] = None
527
+ for index, line in enumerate(lines[1:], start=1):
528
+ stripped = line.rstrip("\r\n")
529
+ if stripped == "---":
530
+ closing_index = index
531
+ break
532
+ if re.match(rf"^{re.escape(key)}:", stripped):
533
+ key_index = index
534
+
535
+ if closing_index is None:
536
+ raise ArchiveError("STATE.md frontmatter is not closed")
537
+
538
+ replacement = f"{key}: {value}\n"
539
+ if key_index is None:
540
+ lines.insert(closing_index, replacement)
541
+ else:
542
+ lines[key_index] = replacement
543
+ return "".join(lines)
544
+
545
+
546
+ def normalized_slug(value: str) -> str:
547
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
548
+ return slug or "project"
549
+
550
+
551
+ def is_unset(value: Optional[str]) -> bool:
552
+ return not value or value.lower() in {"null", "none"} or value.startswith("<")
553
+
554
+
555
+ def milestone_slug(state: PipelineState) -> str:
556
+ milestone = state.milestone
557
+ if is_unset(milestone):
558
+ raise ArchiveError("STATE.md does not name a milestone")
559
+ return normalized_slug(milestone or "")
560
+
561
+
562
+ def require_archive_milestone(archive_name: str, state: PipelineState) -> None:
563
+ match = ARCHIVE_PATTERN.fullmatch(archive_name)
564
+ if (
565
+ not match
566
+ or int(match.group(1)) < 1
567
+ or match.group(2) != milestone_slug(state)
568
+ ):
569
+ raise ArchiveError("persisted archive slug does not match normalized STATE.milestone")
570
+ branch = state.branch
571
+ if (
572
+ branch is None
573
+ or not is_bound_branch(branch)
574
+ or int(match.group(1)) != milestone_number(branch)
575
+ ):
576
+ raise ArchiveError("persisted archive sequence does not match STATE.branch")
577
+
578
+
579
+ def require_project_layout(project: Path) -> Path:
580
+ active_root = project / ".project"
581
+ if active_root.is_symlink() or not active_root.is_dir():
582
+ raise ArchiveError(f".project must be a real directory inside the repository: {active_root}")
583
+ if active_root.resolve().parent != project:
584
+ raise ArchiveError(f".project resolves outside the repository: {active_root}")
585
+ state_path = active_root / "STATE.md"
586
+ if state_path.is_symlink() or not state_path.is_file():
587
+ raise ArchiveError(f"missing real state file: {state_path}")
588
+ return active_root
589
+
590
+
591
+ def safe_archive_root(project: Path) -> Path:
592
+ active_root = require_project_layout(project)
593
+ archive_root = active_root / "archive"
594
+ if archive_root.is_symlink() or not archive_root.is_dir():
595
+ raise ArchiveError(
596
+ f"archive root must be a real, non-symlink directory: {archive_root}"
597
+ )
598
+ if archive_root.resolve().parent != active_root.resolve():
599
+ raise ArchiveError(f"archive root resolves outside .project: {archive_root}")
600
+ for candidate in archive_root.iterdir():
601
+ if candidate.is_symlink():
602
+ raise ArchiveError(f"archive entries must not be symlinks: {candidate}")
603
+ return archive_root
604
+
605
+
606
+ def create_archive_root(project: Path) -> Path:
607
+ active_root = require_project_layout(project)
608
+ archive_root = active_root / "archive"
609
+ if archive_root.is_symlink():
610
+ raise ArchiveError(f"archive root must not be a symlink: {archive_root}")
611
+ archive_root.mkdir(parents=True, exist_ok=True)
612
+ return safe_archive_root(project)
613
+
614
+
615
+ def archive_relative_from_state(configured: str) -> PurePosixPath:
616
+ relative = PurePosixPath(configured)
617
+ if (
618
+ relative.parent != PurePosixPath(".project/archive")
619
+ or not ARCHIVE_PATTERN.fullmatch(relative.name)
620
+ ):
621
+ raise ArchiveError(f"STATE.md has an invalid archive path: {configured}")
622
+ return relative
623
+
624
+
625
+ def archive_from_state(project: Path, configured: str) -> Path:
626
+ relative = archive_relative_from_state(configured)
627
+ archive_root = safe_archive_root(project)
628
+ archive = project.joinpath(*relative.parts)
629
+ if archive.is_symlink() or archive.resolve(strict=False).parent != archive_root.resolve():
630
+ raise ArchiveError(f"archive target must not escape through a symlink: {archive}")
631
+ return archive
632
+
633
+
634
+ def strict_state(project: Path) -> tuple[PipelineState, str, Path]:
635
+ try:
636
+ return load_state(project, ".project")
637
+ except PipelineStateError as error:
638
+ raise ArchiveError(f"STATE.md is invalid: {error}") from error
639
+
640
+
641
+ def archive_sequence_entries(project: Path) -> dict[int, Path]:
642
+ archive_root = project / ".project" / "archive"
643
+ if not archive_root.exists() and not archive_root.is_symlink():
644
+ return {}
645
+ archive_root = safe_archive_root(project)
646
+ entries = {}
647
+ for candidate in archive_root.iterdir():
648
+ match = ARCHIVE_PATTERN.fullmatch(candidate.name)
649
+ if match is None or not candidate.is_dir():
650
+ raise ArchiveError(f"archive root has a noncanonical entry: {candidate.name}")
651
+ number = int(match.group(1))
652
+ if number == 0:
653
+ raise ArchiveError("archive milestone number must be >= 1")
654
+ if number in entries:
655
+ raise ArchiveError(
656
+ f"archive sequence {number:03d} has multiple milestone directories"
657
+ )
658
+ entries[number] = candidate
659
+ return entries
660
+
661
+
662
+ def resolved_archive_target(
663
+ project: Path, slug: str, parsed_state: PipelineState
664
+ ) -> Path:
665
+ branch = parsed_state.branch
666
+ if not isinstance(branch, str) or not is_bound_branch(branch):
667
+ raise ArchiveError("STATE.branch must be a canonical gsd-path/M00N branch")
668
+ expected_number = milestone_number(branch)
669
+ configured = parsed_state.archive
670
+ entries = archive_sequence_entries(project)
671
+ missing_prior = [
672
+ number for number in range(1, expected_number) if number not in entries
673
+ ]
674
+ later = sorted(number for number in entries if number > expected_number)
675
+ if missing_prior or later:
676
+ raise ArchiveError(
677
+ f"archive sequence does not match bound branch {branch}: "
678
+ f"missing={missing_prior}, later={later}"
679
+ )
680
+
681
+ if configured is not None:
682
+ archive = archive_from_state(project, configured)
683
+ if milestone_number(archive.name) != expected_number:
684
+ raise ArchiveError("persisted archive sequence does not match STATE.branch")
685
+ collision = entries.get(expected_number)
686
+ if collision is not None and collision.resolve() != archive.resolve():
687
+ raise ArchiveError(
688
+ f"archive sequence {expected_number:03d} collides with {collision.name}"
689
+ )
690
+ return archive
691
+
692
+ if expected_number in entries:
693
+ raise ArchiveError(
694
+ f"archive sequence {expected_number:03d} already exists before persistence"
695
+ )
696
+ archive_root = project / ".project" / "archive"
697
+ return archive_root / f"{expected_number:03d}-{normalized_slug(slug)}"
698
+
699
+
700
+ def persisted_archive(
701
+ project: Path, state_path: Path, slug: str, parsed_state: PipelineState
702
+ ) -> Path:
703
+ archive = resolved_archive_target(project, slug, parsed_state)
704
+ if parsed_state.archive is not None:
705
+ return archive
706
+ create_archive_root(project)
707
+ relative_archive = archive.relative_to(project).as_posix()
708
+ state_text = state_path.read_text(encoding="utf-8")
709
+ atomic_write(state_path, set_frontmatter_value(state_text, "archive", relative_archive))
710
+ return archive
711
+
712
+
713
+ def pending_ruling_count(audit: Path) -> int:
714
+ if not audit.is_file():
715
+ return 0
716
+ in_rulings = False
717
+ count = 0
718
+ for line in audit.read_text(encoding="utf-8").splitlines():
719
+ if line == "## User rulings":
720
+ in_rulings = True
721
+ continue
722
+ if in_rulings and line.startswith("## "):
723
+ break
724
+ if not in_rulings or not line.lstrip().startswith("|"):
725
+ continue
726
+ fields = [field.strip() for field in line.strip().strip("|").split("|")]
727
+ if fields and fields[-1] == "no":
728
+ count += 1
729
+ return count
730
+
731
+
732
+ def is_existing_carry_forward(source: Path, archived: Path) -> bool:
733
+ entries = list(source.iterdir()) if source.is_dir() else []
734
+ archived_audit = archived / "DOCS-AUDIT.md"
735
+ if not archived_audit.is_file() or pending_ruling_count(archived_audit) == 0:
736
+ return False
737
+ if not entries:
738
+ return True
739
+ return (
740
+ len(entries) == 1
741
+ and entries[0].name == "DOCS-AUDIT.md"
742
+ and filecmp.cmp(entries[0], archived_audit, shallow=False)
743
+ )
744
+
745
+
746
+ def move_directory(source: Path, destination: Path) -> None:
747
+ if source.exists() and destination.exists():
748
+ if source.name == "research" and is_existing_carry_forward(source, destination):
749
+ return
750
+ raise ArchiveError(f"archive collision: both {source} and {destination} exist")
751
+ if source.exists():
752
+ destination.parent.mkdir(parents=True, exist_ok=True)
753
+ shutil.move(str(source), str(destination))
754
+
755
+
756
+ def require_complete_transaction_inputs(active_root: Path, archive: Path) -> None:
757
+ for name in DIRECTORIES_TO_ARCHIVE:
758
+ source = active_root / name
759
+ destination = archive / name
760
+ if source.exists() and destination.exists():
761
+ if name == "research" and is_existing_carry_forward(source, destination):
762
+ continue
763
+ raise ArchiveError(f"archive collision: both {source} and {destination} exist")
764
+ if not source.exists() and not destination.exists():
765
+ raise ArchiveError(f"missing both active and archived {name}")
766
+
767
+ for name in OPTIONAL_DIRECTORIES_TO_ARCHIVE:
768
+ source = active_root / name
769
+ destination = archive / name
770
+ if source.exists() and destination.exists():
771
+ if name == "discuss":
772
+ require_append_only_discussion(source, destination)
773
+ continue
774
+ raise ArchiveError(f"archive collision: both {source} and {destination} exist")
775
+
776
+
777
+
778
+ def selected_transaction_path(active_root: Path, archive: Path, relative: str) -> Path:
779
+ path = PurePosixPath(relative)
780
+ active_top = active_root / path.parts[0]
781
+ archived_top = archive / path.parts[0]
782
+ if active_top.exists() and not archived_top.exists():
783
+ return active_root.joinpath(*path.parts)
784
+ return archive.joinpath(*path.parts)
785
+
786
+
787
+ def is_real_file(path: Path) -> bool:
788
+ return not path.is_symlink() and path.is_file()
789
+
790
+
791
+ def canonical_task_files(tasks: Path) -> Sequence[Path]:
792
+ if tasks.is_symlink() or not tasks.is_dir():
793
+ return ()
794
+ candidates = sorted(tasks.iterdir())
795
+ if any(
796
+ not TASK_FILE_PATTERN.fullmatch(path.name) or not is_real_file(path)
797
+ for path in candidates
798
+ ):
799
+ raise ArchiveError("canonical task artifacts must be real T###-slug.md files")
800
+ return candidates
801
+
802
+
803
+ def completed_task_files(
804
+ project: Path, tasks: Path, reviewed_head: str
805
+ ) -> Sequence[Path]:
806
+ """Return task artifacts only when each records landed completion evidence."""
807
+ return landed_task_evidence(project, tasks, reviewed_head)[0]
808
+
809
+
810
+ def landed_task_evidence(
811
+ project: Path, tasks: Path, reviewed_head: str
812
+ ) -> tuple[Sequence[Path], int]:
813
+ """(task artifacts, attested count) once every task proves landed or attested."""
814
+ candidates = canonical_task_files(tasks)
815
+ try:
816
+ proven = verify_landed_task_files(
817
+ project,
818
+ candidates,
819
+ ".project/tasks",
820
+ reviewed_head,
821
+ )
822
+ except (IsolationError, ValueError) as error:
823
+ raise ArchiveError(f"task landing proof failed: {error}") from error
824
+ attested = sum(1 for task in proven["tasks"] if task.get("verdict") == "attested")
825
+ return candidates, attested
826
+
827
+
828
+ def validate_panel_skip_receipt(path: Path, expected_mode: str) -> None:
829
+ if not is_real_file(path):
830
+ raise ArchiveError(f"panel skip receipt must be a real file: {path.name}")
831
+ try:
832
+ payload = json.loads(path.read_text(encoding="utf-8"))
833
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
834
+ raise ArchiveError(f"{path.name} is not valid review_panel JSON") from error
835
+ expected_keys = {
836
+ "missing",
837
+ "mode",
838
+ "parent_family",
839
+ "reason",
840
+ "selected",
841
+ "skipped",
842
+ "status",
843
+ }
844
+ if not isinstance(payload, dict) or set(payload) != expected_keys:
845
+ raise ArchiveError(f"{path.name} is not a canonical skipped-panel receipt")
846
+ if (
847
+ expected_mode != "detected"
848
+ or payload["mode"] != expected_mode
849
+ or payload["status"] != "skipped"
850
+ or payload["reason"] != "no advertised cross-model families"
851
+ or payload["selected"] != []
852
+ ):
853
+ raise ArchiveError(f"{path.name} does not record the expected skipped resolution")
854
+ parent_family = payload["parent_family"]
855
+ if parent_family is not None and not isinstance(parent_family, str):
856
+ raise ArchiveError(f"{path.name} has an invalid parent_family")
857
+ missing = payload["missing"]
858
+ if (
859
+ not isinstance(missing, list)
860
+ or any(item not in KNOWN_FAMILIES for item in missing)
861
+ or len(missing) != len(set(missing))
862
+ ):
863
+ raise ArchiveError(f"{path.name} has invalid missing families")
864
+ skipped = payload["skipped"]
865
+ if not isinstance(skipped, list) or any(
866
+ not isinstance(item, dict)
867
+ or set(item) != {"family", "reason"}
868
+ or item["family"] not in KNOWN_FAMILIES
869
+ or item["reason"] not in {"parent family", "not advertised", "cap"}
870
+ for item in skipped
871
+ ):
872
+ raise ArchiveError(f"{path.name} has invalid skipped families")
873
+
874
+
875
+ def require_plan_panel_evidence(
876
+ plan_text: str, panel: Path, skipped_receipt: Path
877
+ ) -> dict:
878
+ config = plan_review_panel_config(plan_text)
879
+ panel_exists = is_real_file(panel)
880
+ receipt_exists = is_real_file(skipped_receipt)
881
+ if config["mode"] == "off":
882
+ if receipt_exists:
883
+ raise ArchiveError(
884
+ f"{skipped_receipt.name} conflicts with review_panel mode off"
885
+ )
886
+ return config
887
+ if panel_exists == receipt_exists:
888
+ raise ArchiveError(
889
+ "configured review_panel requires exactly one of review/PLAN-PANEL.md "
890
+ "or review/PLAN-PANEL.skipped.json"
891
+ )
892
+ if receipt_exists:
893
+ validate_panel_skip_receipt(skipped_receipt, str(config["mode"]))
894
+ return config
895
+
896
+
897
+ class WaveArtifact(NamedTuple):
898
+ path: Path
899
+ wave: int
900
+ cycle: int
901
+ lens: Optional[str]
902
+
903
+
904
+ class SkepticArtifact(NamedTuple):
905
+ path: Path
906
+ wave: int
907
+ cycle: int
908
+ locator: str
909
+
910
+
911
+ class RepairArtifact(NamedTuple):
912
+ path: Path
913
+ wave: int
914
+ cycle: int
915
+ task: str
916
+
917
+
918
+ def canonical_wave_files(reviews: Path) -> Sequence[WaveArtifact]:
919
+ if reviews.is_symlink() or not reviews.is_dir():
920
+ return ()
921
+ candidates = sorted(
922
+ path for path in reviews.iterdir() if WAVE_CYCLE_CLAIM_PATTERN.match(path.name)
923
+ )
924
+ matches = [(path, WAVE_FILE_PATTERN.fullmatch(path.name)) for path in candidates]
925
+ if any(
926
+ (
927
+ match is None
928
+ and WAVE_PANEL_SKIP_PATTERN.fullmatch(path.name) is None
929
+ and WAVE_SKEPTIC_PATTERN.fullmatch(path.name) is None
930
+ and WAVE_REPAIR_PATTERN.fullmatch(path.name) is None
931
+ )
932
+ or not is_real_file(path)
933
+ for path, match in matches
934
+ ):
935
+ raise ArchiveError(
936
+ "canonical wave artifacts must be real wave-N.cycleC.md files "
937
+ "(a .contract, .adversarial, or .panel lens suffix is allowed), "
938
+ "canonical .panel.skipped.json receipts, or auxiliary "
939
+ ".skeptic-<locator>.md and .repair-T###.json evidence"
940
+ )
941
+ return [
942
+ WaveArtifact(
943
+ path,
944
+ int(match.group(1)),
945
+ int(match.group(2)),
946
+ match.group(3).removeprefix(".") if match.group(3) else None,
947
+ )
948
+ for path, match in matches
949
+ if match is not None
950
+ ]
951
+
952
+
953
+ def canonical_wave_skeptic_files(reviews: Path) -> Sequence[SkepticArtifact]:
954
+ if reviews.is_symlink() or not reviews.is_dir():
955
+ return ()
956
+ artifacts = []
957
+ for path in sorted(reviews.iterdir()):
958
+ match = WAVE_SKEPTIC_PATTERN.fullmatch(path.name)
959
+ if match is None:
960
+ continue
961
+ if not is_real_file(path):
962
+ raise ArchiveError(f"skeptic evidence must be a real file: {path.name}")
963
+ artifacts.append(
964
+ SkepticArtifact(
965
+ path,
966
+ int(match.group(1)),
967
+ int(match.group(2)),
968
+ match.group(3),
969
+ )
970
+ )
971
+ return artifacts
972
+
973
+
974
+ def validate_wave_skeptic_files(
975
+ reviews: Path,
976
+ wave_depths: dict[int, str],
977
+ wave_artifacts: Sequence[WaveArtifact],
978
+ ) -> None:
979
+ deep_cycles = {}
980
+ for artifact in wave_artifacts:
981
+ if artifact.lens in {"contract", "adversarial"}:
982
+ deep_cycles.setdefault((artifact.wave, artifact.cycle), set()).add(
983
+ artifact.lens
984
+ )
985
+ required_lenses = {"contract", "adversarial"}
986
+ for artifact in canonical_wave_skeptic_files(reviews):
987
+ key = (artifact.wave, artifact.cycle)
988
+ if (
989
+ wave_depths.get(artifact.wave) != "deep"
990
+ or deep_cycles.get(key) != required_lenses
991
+ ):
992
+ raise ArchiveError(
993
+ f"{artifact.path.name} does not match a canonical deep review cycle"
994
+ )
995
+ lines = artifact.path.read_text(encoding="utf-8").splitlines()
996
+ heading = f"# Skeptic — wave {artifact.wave}, cycle {artifact.cycle}"
997
+ if lines.count(heading) != 1:
998
+ raise ArchiveError(
999
+ f"{artifact.path.name} heading does not match its filename"
1000
+ )
1001
+ completed_field(lines, "- Criterion:", artifact.path.name)
1002
+ locator = completed_field(lines, "- Criterion locator:", artifact.path.name)
1003
+ if locator != artifact.locator:
1004
+ raise ArchiveError(
1005
+ f"{artifact.path.name} Criterion locator does not match its filename"
1006
+ )
1007
+
1008
+
1009
+ def canonical_wave_panel_skip_files(reviews: Path) -> dict[tuple[int, int], Path]:
1010
+ if reviews.is_symlink() or not reviews.is_dir():
1011
+ return {}
1012
+ receipts = {}
1013
+ for path in sorted(reviews.iterdir()):
1014
+ match = WAVE_PANEL_SKIP_PATTERN.fullmatch(path.name)
1015
+ if match is None:
1016
+ continue
1017
+ if not is_real_file(path):
1018
+ raise ArchiveError(f"panel skip receipt must be a real file: {path.name}")
1019
+ receipts[(int(match.group(1)), int(match.group(2)))] = path
1020
+ return receipts
1021
+
1022
+
1023
+ def canonical_wave_repair_files(reviews: Path) -> Sequence[RepairArtifact]:
1024
+ if reviews.is_symlink() or not reviews.is_dir():
1025
+ return ()
1026
+ artifacts = []
1027
+ for path in sorted(reviews.iterdir()):
1028
+ match = WAVE_REPAIR_PATTERN.fullmatch(path.name)
1029
+ if match is None:
1030
+ continue
1031
+ if not is_real_file(path):
1032
+ raise ArchiveError(f"repair receipt must be a real file: {path.name}")
1033
+ try:
1034
+ payload = json.loads(path.read_text(encoding="utf-8"))
1035
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
1036
+ raise ArchiveError(f"{path.name} is not valid repair-evidence JSON") from error
1037
+ wave = int(match.group(1))
1038
+ cycle = int(match.group(2))
1039
+ task = match.group(3)
1040
+ if (
1041
+ not isinstance(payload, dict)
1042
+ or payload.get("command") != "repair-evidence"
1043
+ or payload.get("status") != "ok"
1044
+ or payload.get("source") != {"wave": wave, "cycle": cycle}
1045
+ or not isinstance(payload.get("repair"), dict)
1046
+ or payload["repair"].get("task") != task
1047
+ ):
1048
+ raise ArchiveError(
1049
+ f"{path.name} identity does not match its repair-evidence payload"
1050
+ )
1051
+ artifacts.append(RepairArtifact(path, wave, cycle, task))
1052
+ return artifacts
1053
+
1054
+
1055
+ def require_canonical_transaction_inputs(active_root: Path, archive: Path) -> None:
1056
+ missing = [
1057
+ relative
1058
+ for relative in REQUIRED_ARCHIVE_FILES
1059
+ if not is_real_file(selected_transaction_path(active_root, archive, relative))
1060
+ ]
1061
+ tasks = selected_transaction_path(active_root, archive, "tasks")
1062
+ reviews = selected_transaction_path(active_root, archive, "review")
1063
+ if not canonical_task_files(tasks):
1064
+ missing.append("tasks/T###-slug.md")
1065
+ wave_artifacts = canonical_wave_files(reviews)
1066
+ if not wave_artifacts:
1067
+ missing.append("review/wave-N.cycleC.md")
1068
+ plan = selected_transaction_path(active_root, archive, "plan/PLAN.md")
1069
+ if missing:
1070
+ raise ArchiveError(f"canonical milestone artifacts are missing: {', '.join(missing)}")
1071
+ plan_text = plan.read_text(encoding="utf-8")
1072
+ _, wave_depths = plan_wave_depths(plan_text)
1073
+ validate_wave_skeptic_files(reviews, wave_depths, wave_artifacts)
1074
+ canonical_wave_repair_files(reviews)
1075
+ require_plan_panel_evidence(
1076
+ plan_text,
1077
+ selected_transaction_path(active_root, archive, "review/PLAN-PANEL.md"),
1078
+ selected_transaction_path(
1079
+ active_root, archive, "review/PLAN-PANEL.skipped.json"
1080
+ ),
1081
+ )
1082
+ discussion = selected_transaction_path(active_root, archive, "discuss")
1083
+ if discussion.exists():
1084
+ validate_discussion_directory(discussion)
1085
+
1086
+
1087
+ def require_safe_move_inputs(active_root: Path, archive: Path) -> None:
1088
+ archive_device = archive.stat().st_dev
1089
+ for name in TRANSACTION_DIRECTORIES:
1090
+ source = active_root / name
1091
+ destination = archive / name
1092
+ if source.is_symlink() or destination.is_symlink():
1093
+ raise ArchiveError(f"archive transaction paths must not be symlinks: {name}")
1094
+ if source.exists() and source.stat().st_dev != archive_device:
1095
+ raise ArchiveError(f"archive move crosses filesystems: {source}")
1096
+
1097
+ def atomic_copy(source: Path, destination: Path, temporary_name: str = CARRY_TEMP_NAME) -> None:
1098
+ destination.parent.mkdir(parents=True, exist_ok=True)
1099
+ temporary_path = destination.parent / temporary_name
1100
+ try:
1101
+ if temporary_path.exists() or temporary_path.is_symlink():
1102
+ temporary_path.unlink()
1103
+ shutil.copy2(source, temporary_path)
1104
+ os.replace(temporary_path, destination)
1105
+ finally:
1106
+ if temporary_path.exists() or temporary_path.is_symlink():
1107
+ temporary_path.unlink()
1108
+
1109
+
1110
+ def prepare(repo: Path, slug: str) -> dict:
1111
+ project = repo.resolve()
1112
+ active_root = require_project_layout(project)
1113
+ with discussion_lock(active_root):
1114
+ return prepare_locked(project, active_root, slug)
1115
+
1116
+
1117
+ def prepare_locked(project: Path, active_root: Path, slug: str) -> dict:
1118
+ state_path = active_root / "STATE.md"
1119
+ state_temporary = active_root / STATE_TEMP_NAME
1120
+
1121
+ parsed_state, _, loaded_state_path = strict_state(project)
1122
+ if loaded_state_path.resolve() != state_path.resolve():
1123
+ raise ArchiveError("strict STATE.md loader returned an unexpected path")
1124
+ require_transaction_context(project, parsed_state, ("ship", "shipped"))
1125
+ phase = parsed_state.phase
1126
+ status = parsed_state.status
1127
+ if (phase, status) not in {("ship", "active"), ("shipped", "done")}:
1128
+ raise ArchiveError(f"archive transaction is not valid in state: {phase}/{status}")
1129
+ expected_slug = milestone_slug(parsed_state)
1130
+ if normalized_slug(slug) != expected_slug:
1131
+ raise ArchiveError(
1132
+ f"--slug {slug!r} does not match normalized STATE.milestone {expected_slug!r}"
1133
+ )
1134
+ configured = parsed_state.archive
1135
+ if phase == "shipped" and configured is None:
1136
+ raise ArchiveError("shipped state has no persisted archive transaction")
1137
+ if configured is not None:
1138
+ relative_archive = archive_relative_from_state(configured)
1139
+ require_archive_milestone(relative_archive.name, parsed_state)
1140
+ require_uncommitted_archive(project, configured)
1141
+ if state_temporary.exists() or state_temporary.is_symlink():
1142
+ state_temporary.unlink()
1143
+ if configured is None:
1144
+ # Prove the inputs before STATE.archive is persisted or any directory exists.
1145
+ # Archiving a stale final review cannot be undone, so check it here, not only in preflight.
1146
+ final = active_root / "review" / "FINAL.md"
1147
+ reviewed = re.search(r"(?m)^Reviewed HEAD:\s*(\S+)", final.read_text(encoding="utf-8")) if final.is_file() else None
1148
+ head = require_git_success(run_git(project, "rev-parse", "HEAD"), "resolve HEAD").strip()
1149
+ if reviewed and reviewed.group(1) != head:
1150
+ raise ArchiveError(
1151
+ f"FINAL.md Reviewed HEAD {reviewed.group(1)} does not match current HEAD {head}; "
1152
+ "re-run the final review on the current commit before archiving"
1153
+ )
1154
+ target = resolved_archive_target(project, slug, parsed_state)
1155
+ require_archive_milestone(target.name, parsed_state)
1156
+ require_complete_transaction_inputs(active_root, target)
1157
+ require_canonical_transaction_inputs(active_root, target)
1158
+
1159
+ archive = persisted_archive(project, state_path, slug, parsed_state)
1160
+ require_archive_milestone(archive.name, parsed_state)
1161
+ archive.mkdir(parents=True, exist_ok=True)
1162
+ manifest_temporary = archive / MANIFEST_TEMP_NAME
1163
+ if manifest_temporary.exists() or manifest_temporary.is_symlink():
1164
+ manifest_temporary.unlink()
1165
+
1166
+ remove_interrupted_carry_copy(active_root, archive)
1167
+ remove_interrupted_discussion_copies(active_root, archive)
1168
+ finish_discussion_reconciliation(active_root, archive)
1169
+ require_complete_transaction_inputs(active_root, archive)
1170
+ require_canonical_transaction_inputs(active_root, archive)
1171
+ require_safe_move_inputs(active_root, archive)
1172
+ reconcile_append_only_discussion(active_root, archive)
1173
+ for name in TRANSACTION_DIRECTORIES:
1174
+ move_directory(active_root / name, archive / name)
1175
+
1176
+ archived_audit = archive / "research" / "DOCS-AUDIT.md"
1177
+ carried_forward = pending_ruling_count(archived_audit)
1178
+ if carried_forward:
1179
+ active_audit = active_root / "research" / "DOCS-AUDIT.md"
1180
+ active_audit.parent.mkdir(parents=True, exist_ok=True)
1181
+ if active_audit.exists() and not filecmp.cmp(active_audit, archived_audit, shallow=False):
1182
+ raise ArchiveError(f"carry-forward collision: {active_audit} differs from archived audit")
1183
+ if not active_audit.exists():
1184
+ atomic_copy(archived_audit, active_audit)
1185
+
1186
+ return {
1187
+ "archive": archive.relative_to(project).as_posix(),
1188
+ "carried_forward": carried_forward,
1189
+ }
1190
+
1191
+
1192
+ def require_complete_abandon_inputs(active_root: Path, archive: Path) -> None:
1193
+ for name in ABANDON_REQUIRED_DIRECTORIES:
1194
+ source = active_root / name
1195
+ destination = archive / name
1196
+ if source.exists() and destination.exists():
1197
+ if name == "research" and is_existing_carry_forward(source, destination):
1198
+ continue
1199
+ raise ArchiveError(f"archive collision: both {source} and {destination} exist")
1200
+ if not source.exists() and not destination.exists():
1201
+ raise ArchiveError(f"missing both active and archived {name}")
1202
+
1203
+ review_source = active_root / "review"
1204
+ review_destination = archive / "review"
1205
+ if review_source.exists() and review_destination.exists():
1206
+ raise ArchiveError(
1207
+ f"archive collision: both {review_source} and {review_destination} exist"
1208
+ )
1209
+
1210
+ for name in OPTIONAL_DIRECTORIES_TO_ARCHIVE:
1211
+ source = active_root / name
1212
+ destination = archive / name
1213
+ if source.exists() and destination.exists():
1214
+ if name == "discuss":
1215
+ require_append_only_discussion(source, destination)
1216
+ continue
1217
+ raise ArchiveError(f"archive collision: both {source} and {destination} exist")
1218
+
1219
+
1220
+ def require_canonical_abandon_inputs(active_root: Path, archive: Path) -> None:
1221
+ missing = [
1222
+ relative
1223
+ for relative in ABANDON_REQUIRED_ARCHIVE_FILES
1224
+ if not is_real_file(selected_transaction_path(active_root, archive, relative))
1225
+ ]
1226
+ tasks = selected_transaction_path(active_root, archive, "tasks")
1227
+ if tasks.is_symlink() or not tasks.is_dir():
1228
+ missing.append("tasks/")
1229
+ else:
1230
+ canonical_task_files(tasks)
1231
+ reviews = selected_transaction_path(active_root, archive, "review")
1232
+ if reviews.exists():
1233
+ canonical_wave_files(reviews)
1234
+ if missing:
1235
+ raise ArchiveError(f"canonical milestone artifacts are missing: {', '.join(missing)}")
1236
+ discussion = selected_transaction_path(active_root, archive, "discuss")
1237
+ if discussion.exists():
1238
+ validate_discussion_directory(discussion)
1239
+
1240
+
1241
+ def write_abandon_manifest(
1242
+ archive: Path, slug: str, ruling: str, abandoned_on: str
1243
+ ) -> None:
1244
+ contents = []
1245
+ for path in archive.rglob("*"):
1246
+ if path.is_symlink():
1247
+ raise ArchiveError(f"archive contents must not be symlinks: {path}")
1248
+ if path.is_file() and path.name != "MANIFEST.md":
1249
+ contents.append(path.relative_to(archive).as_posix())
1250
+ contents.sort()
1251
+ listed_contents = "\n".join(f"- {path}" for path in contents)
1252
+ atomic_replace(
1253
+ archive / "MANIFEST.md",
1254
+ archive / MANIFEST_TEMP_NAME,
1255
+ f"""# Archive — {archive.name}
1256
+
1257
+ Milestone: {slug}
1258
+ Abandoned: {abandoned_on}
1259
+ Reason: {ruling}
1260
+
1261
+ ## Contents
1262
+
1263
+ {listed_contents}
1264
+
1265
+ ## Notes
1266
+
1267
+ - none
1268
+ """,
1269
+ )
1270
+
1271
+
1272
+ def validate_abandon_manifest(
1273
+ archive: Path, slug: str, ruling: str, abandoned_on: str
1274
+ ) -> None:
1275
+ manifest = archive / "MANIFEST.md"
1276
+ if manifest.is_symlink() or not manifest.is_file():
1277
+ raise ArchiveError("abandoned archive MANIFEST.md must be a real file")
1278
+ lines = manifest.read_text(encoding="utf-8").splitlines()
1279
+ if lines.count(f"# Archive — {archive.name}") != 1:
1280
+ raise ArchiveError("abandon manifest header does not match its archive")
1281
+ expected_fields = {
1282
+ "Milestone:": slug,
1283
+ "Abandoned:": abandoned_on,
1284
+ "Reason:": ruling,
1285
+ }
1286
+ for field, expected in expected_fields.items():
1287
+ if completed_field(lines, field, "abandon manifest") != expected:
1288
+ raise ArchiveError(f"abandon manifest {field} does not match its transaction")
1289
+ try:
1290
+ date.fromisoformat(abandoned_on)
1291
+ except ValueError as error:
1292
+ raise ArchiveError("abandon manifest date is invalid") from error
1293
+ if lines.count("## Contents") != 1 or lines.count("## Notes") != 1:
1294
+ raise ArchiveError("abandon manifest requires Contents and Notes sections")
1295
+ contents_start = lines.index("## Contents") + 1
1296
+ listed = []
1297
+ for line in lines[contents_start:]:
1298
+ if line.startswith("## "):
1299
+ break
1300
+ if line.startswith("- "):
1301
+ listed.append(line[2:].strip().strip("`"))
1302
+ if len(listed) != len(set(listed)) or sorted(listed) != archive_file_inventory(archive):
1303
+ raise ArchiveError("abandon manifest contents do not match the archive")
1304
+
1305
+
1306
+ def abandon_journal_path(project: Path) -> Path:
1307
+ common = require_git_success(
1308
+ run_git(project, "rev-parse", "--path-format=absolute", "--git-common-dir"),
1309
+ "resolve common Git directory",
1310
+ )
1311
+ path = Path(common)
1312
+ if not path.is_absolute():
1313
+ path = project / path
1314
+ return path.resolve() / ABANDON_JOURNAL_NAME
1315
+
1316
+
1317
+ def read_abandon_journal(path: Path) -> dict:
1318
+ try:
1319
+ payload = json.loads(path.read_text(encoding="utf-8"))
1320
+ except (OSError, json.JSONDecodeError) as error:
1321
+ raise ArchiveError(f"abandon journal is unreadable: {path}") from error
1322
+ fields = {
1323
+ "schema",
1324
+ "repo",
1325
+ "slug",
1326
+ "ruling",
1327
+ "expected_head",
1328
+ "archive",
1329
+ "branch",
1330
+ "build_status",
1331
+ "abandoned_on",
1332
+ }
1333
+ if not isinstance(payload, dict) or set(payload) != fields:
1334
+ raise ArchiveError("abandon journal has invalid fields")
1335
+ if payload["schema"] != ABANDON_JOURNAL_SCHEMA or any(
1336
+ not isinstance(payload[field], str) for field in fields - {"schema"}
1337
+ ):
1338
+ raise ArchiveError("abandon journal has invalid values")
1339
+ if (
1340
+ not re.fullmatch(r"[0-9a-f]{40,64}", payload["expected_head"])
1341
+ or payload["build_status"] not in {"active", "blocked"}
1342
+ or not is_bound_branch(payload["branch"])
1343
+ ):
1344
+ raise ArchiveError("abandon journal has invalid transaction metadata")
1345
+ try:
1346
+ date.fromisoformat(payload["abandoned_on"])
1347
+ except ValueError as error:
1348
+ raise ArchiveError("abandon journal has invalid transaction date") from error
1349
+ return payload
1350
+
1351
+
1352
+ def write_abandon_journal(path: Path, payload: dict) -> None:
1353
+ temporary = path.with_name(f".{path.name}.tmp")
1354
+ if temporary.exists() or temporary.is_symlink():
1355
+ temporary.unlink()
1356
+ atomic_replace(path, temporary, json.dumps(payload, sort_keys=True) + "\n")
1357
+
1358
+
1359
+ def require_abandon_request(
1360
+ project: Path, journal: dict, slug: str, ruling: str
1361
+ ) -> None:
1362
+ expected = {
1363
+ "repo": str(project),
1364
+ "slug": slug,
1365
+ "ruling": ruling,
1366
+ }
1367
+ mismatches = [
1368
+ key
1369
+ for key, value in expected.items()
1370
+ if journal[key] != value
1371
+ ]
1372
+ if mismatches:
1373
+ raise ArchiveError(
1374
+ "abandon request differs from journal fields: "
1375
+ + ", ".join(sorted(mismatches))
1376
+ )
1377
+ resolved = run_git(
1378
+ project,
1379
+ "rev-parse",
1380
+ "--verify",
1381
+ "--quiet",
1382
+ f"{journal['expected_head']}^{{commit}}",
1383
+ )
1384
+ if resolved.returncode != 0 or resolved.stdout.strip() != journal["expected_head"]:
1385
+ raise ArchiveError("abandon journal expected HEAD no longer resolves")
1386
+
1387
+
1388
+ def update_abandoned_roadmap(
1389
+ path: Path,
1390
+ archive_name: str,
1391
+ slug: str,
1392
+ configured: str,
1393
+ *,
1394
+ write: bool = True,
1395
+ ) -> None:
1396
+ if path.is_symlink() or not path.is_file():
1397
+ raise ArchiveError(f"milestone abandon requires a real ROADMAP.md: {path}")
1398
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
1399
+ headings = []
1400
+ for index, line in enumerate(lines):
1401
+ match = ROADMAP_HEADING_PATTERN.fullmatch(line.rstrip("\r\n"))
1402
+ if match is None:
1403
+ continue
1404
+ if int(match.group(1).removeprefix("M")) < 1:
1405
+ raise ArchiveError("ROADMAP milestone number must be >= 1")
1406
+ headings.append((index, match.group(1), match.group(2)))
1407
+ identifier = milestone_id(milestone_number(archive_name))
1408
+ matches = [item for item in headings if item[1:] == (identifier, slug)]
1409
+ if len(matches) != 1:
1410
+ raise ArchiveError(
1411
+ f"ROADMAP.md requires one exact {identifier} — {slug} milestone entry"
1412
+ )
1413
+ start = matches[0][0]
1414
+ end = next((index for index, _, _ in headings if index > start), len(lines))
1415
+ status_indexes = [
1416
+ index for index in range(start + 1, end) if lines[index].startswith("Status:")
1417
+ ]
1418
+ archive_indexes = [
1419
+ index for index in range(start + 1, end) if lines[index].startswith("Archive:")
1420
+ ]
1421
+ if len(status_indexes) != 1 or len(archive_indexes) > 1:
1422
+ raise ArchiveError("ROADMAP milestone has ambiguous Status or Archive fields")
1423
+ status_index = status_indexes[0]
1424
+ status = lines[status_index].rstrip("\r\n")
1425
+ if status not in {"Status: active", "Status: abandoned"}:
1426
+ raise ArchiveError(f"ROADMAP milestone cannot be abandoned from {status!r}")
1427
+ lines[status_index] = "Status: abandoned\n"
1428
+ if archive_indexes:
1429
+ archive_index = archive_indexes[0]
1430
+ current = lines[archive_index].rstrip("\r\n")
1431
+ if current not in {"Archive: null", f"Archive: {configured}"}:
1432
+ raise ArchiveError("ROADMAP milestone Archive field conflicts with transaction")
1433
+ lines[archive_index] = f"Archive: {configured}\n"
1434
+ else:
1435
+ lines.insert(status_index + 1, f"Archive: {configured}\n")
1436
+ if not write:
1437
+ return
1438
+ temporary = path.with_name(f".{path.name}.gsd-path-tmp")
1439
+ if temporary.exists() or temporary.is_symlink():
1440
+ temporary.unlink()
1441
+ atomic_replace(path, temporary, "".join(lines))
1442
+
1443
+
1444
+ def append_abandon_lesson(
1445
+ path: Path, archive_name: str, ruling: str, *, write: bool = True
1446
+ ) -> None:
1447
+ line = f"- {archive_name} — abandoned: {ruling}"
1448
+ if path.exists() or path.is_symlink():
1449
+ if path.is_symlink() or not path.is_file():
1450
+ raise ArchiveError(f"LESSONS.md must be a real file: {path}")
1451
+ text = path.read_text(encoding="utf-8")
1452
+ else:
1453
+ text = "# Lessons\n"
1454
+ matches = [
1455
+ value
1456
+ for value in text.splitlines()
1457
+ if value.startswith(f"- {archive_name} — abandoned:")
1458
+ ]
1459
+ if matches and matches != [line]:
1460
+ raise ArchiveError("LESSONS.md has a conflicting abandon ruling")
1461
+ if matches == [line]:
1462
+ return
1463
+ if not write:
1464
+ raise ArchiveError("LESSONS.md is missing the canonical abandon ruling")
1465
+ rendered = text.rstrip() + f"\n\n{line}\n"
1466
+ temporary = path.with_name(f".{path.name}.gsd-path-tmp")
1467
+ if temporary.exists() or temporary.is_symlink():
1468
+ temporary.unlink()
1469
+ atomic_replace(path, temporary, rendered)
1470
+
1471
+
1472
+ def completed_abandon(
1473
+ project: Path, active_root: Path, slug: str, ruling: str, state: PipelineState
1474
+ ) -> dict:
1475
+ if not (
1476
+ state.phase == "roadmap"
1477
+ and state.status == "active"
1478
+ and state.milestone is None
1479
+ and state.archive is None
1480
+ and state.branch is not None
1481
+ ):
1482
+ raise ArchiveError(
1483
+ f"milestone abandon is not valid in state: {state.phase}/{state.status}"
1484
+ )
1485
+ require_transaction_context(project, state, ("roadmap",))
1486
+ number = milestone_number(state.branch)
1487
+ archive_name = f"{number:03d}-{slug}"
1488
+ configured = f".project/archive/{archive_name}"
1489
+ archive = archive_from_state(project, configured)
1490
+ if archive.is_symlink() or not archive.is_dir():
1491
+ raise ArchiveError("completed abandoned archive is missing")
1492
+ manifest = archive / "MANIFEST.md"
1493
+ if manifest.is_symlink() or not manifest.is_file():
1494
+ raise ArchiveError("abandoned archive MANIFEST.md must be a real file")
1495
+ manifest_lines = manifest.read_text(encoding="utf-8").splitlines()
1496
+ abandoned_on = completed_field(manifest_lines, "Abandoned:", "abandon manifest")
1497
+ validate_abandon_manifest(archive, slug, ruling, abandoned_on)
1498
+ update_abandoned_roadmap(
1499
+ active_root / "ROADMAP.md",
1500
+ archive_name,
1501
+ slug,
1502
+ configured,
1503
+ write=False,
1504
+ )
1505
+ append_abandon_lesson(
1506
+ active_root / "LESSONS.md", archive_name, ruling, write=False
1507
+ )
1508
+ head = require_git_success(run_git(project, "rev-parse", "HEAD"), "resolve HEAD")
1509
+ parent = require_git_success(
1510
+ run_git(project, "rev-parse", f"{head}^"), "resolve abandon checkpoint parent"
1511
+ )
1512
+ try:
1513
+ checkpoint = isolation_checkpoint(
1514
+ project,
1515
+ parent,
1516
+ f"build: abandon milestone {slug}",
1517
+ f"Why: {ruling}",
1518
+ [".project"],
1519
+ )
1520
+ except IsolationError as error:
1521
+ raise ArchiveError(f"completed abandon checkpoint is invalid: {error}") from error
1522
+ return {
1523
+ "archive": configured,
1524
+ "carried_forward": pending_ruling_count(
1525
+ archive / "research" / "DOCS-AUDIT.md"
1526
+ ),
1527
+ "commit": checkpoint["commit"],
1528
+ "status": checkpoint["status"],
1529
+ }
1530
+
1531
+
1532
+ def abandon(repo: Path, slug: str, reason: str) -> dict:
1533
+ project = repo.resolve()
1534
+ active_root = require_project_layout(project)
1535
+ requested_slug = normalized_slug(slug)
1536
+ ruling = " ".join(reason.split())
1537
+ if not ruling:
1538
+ raise ArchiveError("milestone abandon requires a non-empty --reason ruling")
1539
+ journal_path = abandon_journal_path(project)
1540
+ if journal_path.is_symlink():
1541
+ raise ArchiveError(f"abandon journal must not be a symlink: {journal_path}")
1542
+ journal = read_abandon_journal(journal_path) if journal_path.is_file() else None
1543
+ if journal_path.exists() and journal is None:
1544
+ raise ArchiveError(f"abandon journal must be a real file: {journal_path}")
1545
+ if journal is not None:
1546
+ require_abandon_request(project, journal, requested_slug, ruling)
1547
+ else:
1548
+ state, _, _ = strict_state(project)
1549
+ if state.phase == "roadmap":
1550
+ with discussion_lock(active_root):
1551
+ return completed_abandon(
1552
+ project, active_root, requested_slug, ruling, state
1553
+ )
1554
+ with discussion_lock(active_root):
1555
+ journal, carried_forward = abandon_locked(
1556
+ project,
1557
+ active_root,
1558
+ requested_slug,
1559
+ ruling,
1560
+ journal_path,
1561
+ journal,
1562
+ )
1563
+
1564
+ try:
1565
+ state, _, _ = strict_state(project)
1566
+ if state.phase == "build":
1567
+ transition_state(
1568
+ project,
1569
+ expected={
1570
+ "phase": "build",
1571
+ "status": journal["build_status"],
1572
+ "milestone": journal["slug"],
1573
+ "branch": journal["branch"],
1574
+ "archive": journal["archive"],
1575
+ },
1576
+ changes={
1577
+ "phase": "roadmap",
1578
+ "status": "active",
1579
+ "milestone": None,
1580
+ "archive": None,
1581
+ },
1582
+ event=(
1583
+ f"milestone abandoned: {journal['slug']}; "
1584
+ f"archive: {journal['archive']}; ruling: {journal['ruling']}"
1585
+ ),
1586
+ )
1587
+ elif not (
1588
+ state.phase == "roadmap"
1589
+ and state.status == "active"
1590
+ and state.milestone is None
1591
+ and state.branch == journal["branch"]
1592
+ and state.archive is None
1593
+ ):
1594
+ raise ArchiveError("abandon transaction has an invalid finalized STATE.md")
1595
+
1596
+ finalized_state, _, _ = strict_state(project)
1597
+ require_transaction_context(project, finalized_state, ("roadmap",))
1598
+ checkpoint = isolation_checkpoint(
1599
+ project,
1600
+ journal["expected_head"],
1601
+ f"build: abandon milestone {journal['slug']}",
1602
+ f"Why: {journal['ruling']}",
1603
+ [".project"],
1604
+ )
1605
+ except (PipelineStateError, IsolationError) as error:
1606
+ raise ArchiveError(f"abandon finalization failed: {error}") from error
1607
+
1608
+ journal_path.unlink()
1609
+ return {
1610
+ "archive": journal["archive"],
1611
+ "carried_forward": carried_forward,
1612
+ "commit": checkpoint["commit"],
1613
+ "status": checkpoint["status"],
1614
+ }
1615
+
1616
+
1617
+ def abandon_locked(
1618
+ project: Path,
1619
+ active_root: Path,
1620
+ slug: str,
1621
+ ruling: str,
1622
+ journal_path: Path,
1623
+ journal: Optional[dict],
1624
+ ) -> tuple[dict, int]:
1625
+ state_path = active_root / "STATE.md"
1626
+ state_temporary = active_root / STATE_TEMP_NAME
1627
+
1628
+ parsed_state, _, loaded_state_path = strict_state(project)
1629
+ if loaded_state_path.resolve() != state_path.resolve():
1630
+ raise ArchiveError("strict STATE.md loader returned an unexpected path")
1631
+ if not is_real_file(active_root / "ROADMAP.md"):
1632
+ raise ArchiveError(
1633
+ "milestone abandon requires program flow: missing .project/ROADMAP.md"
1634
+ )
1635
+ require_transaction_context(project, parsed_state, ("build", "roadmap"))
1636
+ if journal is None:
1637
+ if parsed_state.phase != "build" or parsed_state.status not in {"active", "blocked"}:
1638
+ raise ArchiveError(
1639
+ f"milestone abandon is not valid in state: "
1640
+ f"{parsed_state.phase}/{parsed_state.status}"
1641
+ )
1642
+ expected_slug = milestone_slug(parsed_state)
1643
+ if slug != expected_slug:
1644
+ raise ArchiveError(
1645
+ f"--slug {slug!r} does not match normalized "
1646
+ f"STATE.milestone {expected_slug!r}"
1647
+ )
1648
+ archive = resolved_archive_target(project, slug, parsed_state)
1649
+ configured = archive.relative_to(project).as_posix()
1650
+ expected_head = require_git_success(
1651
+ run_git(project, "rev-parse", "HEAD"), "resolve abandon base"
1652
+ )
1653
+ journal = {
1654
+ "schema": ABANDON_JOURNAL_SCHEMA,
1655
+ "repo": str(project),
1656
+ "slug": slug,
1657
+ "ruling": ruling,
1658
+ "expected_head": expected_head,
1659
+ "archive": configured,
1660
+ "branch": parsed_state.branch,
1661
+ "build_status": parsed_state.status,
1662
+ "abandoned_on": date.today().isoformat(),
1663
+ }
1664
+ write_abandon_journal(journal_path, journal)
1665
+ else:
1666
+ require_abandon_request(project, journal, slug, ruling)
1667
+ if parsed_state.branch != journal["branch"]:
1668
+ raise ArchiveError("STATE.branch differs from the abandon journal")
1669
+
1670
+ configured = journal["archive"]
1671
+ if parsed_state.phase == "roadmap":
1672
+ archive = archive_from_state(project, configured)
1673
+ if not (
1674
+ parsed_state.status == "active"
1675
+ and parsed_state.milestone is None
1676
+ and parsed_state.archive is None
1677
+ ):
1678
+ raise ArchiveError("abandon transaction has an invalid finalized STATE.md")
1679
+ if archive.is_symlink() or not archive.is_dir():
1680
+ raise ArchiveError("abandoned archive is missing during checkpoint recovery")
1681
+ validate_abandon_manifest(
1682
+ archive,
1683
+ journal["slug"],
1684
+ journal["ruling"],
1685
+ journal["abandoned_on"],
1686
+ )
1687
+ update_abandoned_roadmap(
1688
+ active_root / "ROADMAP.md", archive.name, journal["slug"], configured
1689
+ )
1690
+ append_abandon_lesson(
1691
+ active_root / "LESSONS.md", archive.name, journal["ruling"]
1692
+ )
1693
+ require_clean_active_root(active_root, archive)
1694
+ return journal, pending_ruling_count(archive / "research" / "DOCS-AUDIT.md")
1695
+
1696
+ if (
1697
+ parsed_state.status != journal["build_status"]
1698
+ or parsed_state.milestone != journal["slug"]
1699
+ or parsed_state.branch != journal["branch"]
1700
+ ):
1701
+ raise ArchiveError("build STATE.md differs from the abandon journal")
1702
+ update_abandoned_roadmap(
1703
+ active_root / "ROADMAP.md",
1704
+ PurePosixPath(configured).name,
1705
+ journal["slug"],
1706
+ configured,
1707
+ write=False,
1708
+ )
1709
+ if parsed_state.archive is not None:
1710
+ relative_archive = archive_relative_from_state(parsed_state.archive)
1711
+ require_archive_milestone(relative_archive.name, parsed_state)
1712
+ if parsed_state.archive != configured:
1713
+ raise ArchiveError("STATE.archive differs from the abandon journal")
1714
+ require_uncommitted_archive(project, configured)
1715
+ if state_temporary.exists() or state_temporary.is_symlink():
1716
+ state_temporary.unlink()
1717
+
1718
+ archive = persisted_archive(project, state_path, slug, parsed_state)
1719
+ if archive.relative_to(project).as_posix() != configured:
1720
+ raise ArchiveError("persisted archive differs from the abandon journal")
1721
+ require_archive_milestone(archive.name, parsed_state)
1722
+ archive.mkdir(parents=True, exist_ok=True)
1723
+ manifest_temporary = archive / MANIFEST_TEMP_NAME
1724
+ if manifest_temporary.exists() or manifest_temporary.is_symlink():
1725
+ manifest_temporary.unlink()
1726
+
1727
+ remove_interrupted_carry_copy(active_root, archive)
1728
+ remove_interrupted_discussion_copies(active_root, archive)
1729
+ finish_discussion_reconciliation(active_root, archive)
1730
+ require_complete_abandon_inputs(active_root, archive)
1731
+ require_canonical_abandon_inputs(active_root, archive)
1732
+ require_safe_move_inputs(active_root, archive)
1733
+ reconcile_append_only_discussion(active_root, archive)
1734
+ for name in TRANSACTION_DIRECTORIES:
1735
+ move_directory(active_root / name, archive / name)
1736
+
1737
+ archived_audit = archive / "research" / "DOCS-AUDIT.md"
1738
+ carried_forward = pending_ruling_count(archived_audit)
1739
+ if carried_forward:
1740
+ active_audit = active_root / "research" / "DOCS-AUDIT.md"
1741
+ active_audit.parent.mkdir(parents=True, exist_ok=True)
1742
+ if active_audit.exists() and not filecmp.cmp(active_audit, archived_audit, shallow=False):
1743
+ raise ArchiveError(f"carry-forward collision: {active_audit} differs from archived audit")
1744
+ if not active_audit.exists():
1745
+ atomic_copy(archived_audit, active_audit)
1746
+
1747
+ write_abandon_manifest(
1748
+ archive,
1749
+ journal["slug"],
1750
+ journal["ruling"],
1751
+ journal["abandoned_on"],
1752
+ )
1753
+ validate_abandon_manifest(
1754
+ archive,
1755
+ journal["slug"],
1756
+ journal["ruling"],
1757
+ journal["abandoned_on"],
1758
+ )
1759
+ update_abandoned_roadmap(
1760
+ active_root / "ROADMAP.md", archive.name, journal["slug"], configured
1761
+ )
1762
+ append_abandon_lesson(
1763
+ active_root / "LESSONS.md", archive.name, journal["ruling"]
1764
+ )
1765
+ require_clean_active_root(active_root, archive)
1766
+ return journal, carried_forward
1767
+
1768
+
1769
+ run_git = _common.run_git
1770
+ run_command = _common.run_command
1771
+
1772
+
1773
+ def require_git_success(result: subprocess.CompletedProcess, action: str) -> str:
1774
+ if result.returncode != 0:
1775
+ detail = result.stderr.strip() or result.stdout.strip()
1776
+ raise ArchiveError(f"{action} failed: {detail}")
1777
+ return result.stdout.strip()
1778
+
1779
+
1780
+ def archive_is_committed(project: Path, configured: str) -> bool:
1781
+ return run_git(project, "cat-file", "-e", f"HEAD:{configured}").returncode == 0
1782
+
1783
+
1784
+ def require_uncommitted_archive(project: Path, configured: str) -> None:
1785
+ if archive_is_committed(project, configured):
1786
+ raise ArchiveError("current archive is already committed; run validate instead")
1787
+
1788
+
1789
+ def require_clean_older_archives(project: Path, configured: str) -> None:
1790
+ result = run_git(
1791
+ project,
1792
+ "status",
1793
+ "--porcelain=v1",
1794
+ "-z",
1795
+ "--untracked-files=all",
1796
+ "--ignored=matching",
1797
+ "--",
1798
+ ".project/archive",
1799
+ )
1800
+ if result.returncode != 0:
1801
+ require_git_success(result, "inspect archive worktree status")
1802
+ status = result.stdout
1803
+ current_prefix = f"{configured}/"
1804
+ dirty = set()
1805
+ ignored_current = set()
1806
+ for record in status.split("\0"):
1807
+ has_status = len(record) > 3 and record[2] == " "
1808
+ code = record[:2] if has_status else ""
1809
+ path = record[3:] if has_status else record
1810
+ if code == "!!" and (
1811
+ path == ".project/archive/"
1812
+ or path == configured
1813
+ or path.startswith(current_prefix)
1814
+ ):
1815
+ ignored_current.add(path)
1816
+ if (
1817
+ path.startswith(".project/archive/")
1818
+ and path != configured
1819
+ and not path.startswith(current_prefix)
1820
+ ):
1821
+ dirty.add(path)
1822
+ if ignored_current:
1823
+ raise ArchiveError(
1824
+ f"ignored current archive paths would be omitted from shipment: "
1825
+ f"{', '.join(sorted(ignored_current))}"
1826
+ )
1827
+ if dirty:
1828
+ raise ArchiveError(f"dirty older archive paths block shipment: {', '.join(sorted(dirty))}")
1829
+
1830
+
1831
+ def require_canonical_archive(archive: Path) -> None:
1832
+ missing = [relative for relative in REQUIRED_ARCHIVE_FILES if not is_real_file(archive / relative)]
1833
+ if not canonical_task_files(archive / "tasks"):
1834
+ missing.append("tasks/T###-slug.md")
1835
+ if not canonical_wave_files(archive / "review"):
1836
+ missing.append("review/wave-N.cycleC.md")
1837
+ plan = archive / "plan" / "PLAN.md"
1838
+ for directory in DIRECTORIES_TO_ARCHIVE:
1839
+ path = archive / directory
1840
+ if path.is_symlink() or not path.is_dir():
1841
+ missing.append(f"{directory}/")
1842
+ if missing:
1843
+ raise ArchiveError(f"canonical archive artifacts are missing: {', '.join(sorted(set(missing)))}")
1844
+ require_plan_panel_evidence(
1845
+ plan.read_text(encoding="utf-8"),
1846
+ archive / "review" / "PLAN-PANEL.md",
1847
+ archive / "review" / "PLAN-PANEL.skipped.json",
1848
+ )
1849
+ discussion = archive / "discuss"
1850
+ if discussion.exists() or discussion.is_symlink():
1851
+ validate_discussion_directory(discussion)
1852
+
1853
+
1854
+ def require_clean_active_root(active_root: Path, archive: Path) -> None:
1855
+ archived_audit = archive / "research" / "DOCS-AUDIT.md"
1856
+ carried_forward = pending_ruling_count(archived_audit)
1857
+ # Persistent project metadata ships but never archives with a milestone.
1858
+ allowed = {"STATE.md", "REPOSITORY.md", "archive", "LESSONS.md", "next"}
1859
+ if (active_root / "CHARTER.md").is_file():
1860
+ # Program flow: these persist at the .project/ top level.
1861
+ allowed |= {"CHARTER.md", "ROADMAP.md", "SYNTHESIS.md"}
1862
+ active_research = active_root / "research"
1863
+
1864
+ if carried_forward:
1865
+ allowed.add("research")
1866
+ active_audit = active_research / "DOCS-AUDIT.md"
1867
+ entries = sorted(path.name for path in active_research.iterdir()) if active_research.is_dir() else []
1868
+ if (
1869
+ active_research.is_symlink()
1870
+ or entries != ["DOCS-AUDIT.md"]
1871
+ or active_audit.is_symlink()
1872
+ or not active_audit.is_file()
1873
+ or not filecmp.cmp(active_audit, archived_audit, shallow=False)
1874
+ ):
1875
+ raise ArchiveError("pending DOCS-AUDIT rulings lack an exact active carry-forward copy")
1876
+ elif active_research.exists() or active_research.is_symlink():
1877
+ raise ArchiveError("active research exists without a pending carry-forward queue")
1878
+
1879
+ unexpected = sorted(path.name for path in active_root.iterdir() if path.name not in allowed)
1880
+ if unexpected:
1881
+ raise ArchiveError(f"unexpected active .project paths remain: {', '.join(unexpected)}")
1882
+
1883
+
1884
+ def require_stageable_carry_forward(project: Path, archive: Path) -> None:
1885
+ if pending_ruling_count(archive / "research" / "DOCS-AUDIT.md") == 0:
1886
+ return
1887
+ carry_path = ".project/research/DOCS-AUDIT.md"
1888
+ tracked = run_git(project, "ls-files", "--error-unmatch", "--", carry_path)
1889
+ if tracked.returncode == 0:
1890
+ return
1891
+ if tracked.returncode != 1:
1892
+ require_git_success(tracked, "inspect active carry-forward tracking status")
1893
+ ignored = run_git(project, "check-ignore", "-q", "--no-index", "--", carry_path)
1894
+ if ignored.returncode == 0:
1895
+ raise ArchiveError("ignored active carry-forward is not tracked and would be omitted")
1896
+ if ignored.returncode != 1:
1897
+ require_git_success(ignored, "inspect active carry-forward ignore status")
1898
+
1899
+
1900
+ def require_committed_carry_forward(project: Path, configured: str, archive: Path, ref: str) -> None:
1901
+ if pending_ruling_count(archive / "research" / "DOCS-AUDIT.md") == 0:
1902
+ return
1903
+ active_blob = require_git_success(
1904
+ run_git(project, "rev-parse", "--verify", f"{ref}:.project/research/DOCS-AUDIT.md"),
1905
+ "verify committed carry-forward",
1906
+ )
1907
+ archived_blob = require_git_success(
1908
+ run_git(
1909
+ project,
1910
+ "rev-parse",
1911
+ "--verify",
1912
+ f"{ref}:{configured}/research/DOCS-AUDIT.md",
1913
+ ),
1914
+ "verify archived carry-forward",
1915
+ )
1916
+ if active_blob != archived_blob:
1917
+ raise ArchiveError("committed carry-forward differs from the archived DOCS-AUDIT.md")
1918
+
1919
+
1920
+ def prepared_transaction(
1921
+ repo: Path,
1922
+ phases: Sequence[str],
1923
+ must_be_uncommitted: bool = False,
1924
+ ) -> tuple:
1925
+ project = repo.resolve()
1926
+ active_root = require_project_layout(project)
1927
+ state_path = active_root / "STATE.md"
1928
+
1929
+ state, _, loaded_state_path = strict_state(project)
1930
+ if loaded_state_path.resolve() != state_path.resolve():
1931
+ raise ArchiveError("strict STATE.md loader returned an unexpected path")
1932
+ require_transaction_context(project, state, phases)
1933
+
1934
+ configured = state.archive
1935
+ if configured is None:
1936
+ raise ArchiveError("STATE.md does not name the archive transaction")
1937
+ relative_archive = archive_relative_from_state(configured)
1938
+ require_archive_milestone(relative_archive.name, state)
1939
+ if must_be_uncommitted:
1940
+ require_uncommitted_archive(project, configured)
1941
+ archive = archive_from_state(project, configured)
1942
+ if archive.is_symlink() or not archive.is_dir() or not (archive / "MANIFEST.md").is_file():
1943
+ raise ArchiveError("archive or MANIFEST.md is missing")
1944
+ if (archive / MANIFEST_TEMP_NAME).exists() or (archive / MANIFEST_TEMP_NAME).is_symlink():
1945
+ raise ArchiveError("interrupted manifest temporary file remains; rerun prepare")
1946
+
1947
+ require_canonical_archive(archive)
1948
+ require_clean_active_root(active_root, archive)
1949
+ archived_files, reviewed_head = validate_manifest(project, archive, state)
1950
+ return project, active_root, state, configured, archive, archived_files, reviewed_head
1951
+
1952
+
1953
+ def preflight(repo: Path) -> dict:
1954
+ project, _, state, configured, archive, _, reviewed_head = prepared_transaction(
1955
+ repo,
1956
+ ("ship", "shipped"),
1957
+ must_be_uncommitted=True,
1958
+ )
1959
+ phase = state.phase
1960
+ status = state.status
1961
+ if (phase, status) not in {("ship", "active"), ("shipped", "done")}:
1962
+ raise ArchiveError("archive preflight requires ship/active or uncommitted shipped/done")
1963
+ require_stageable_carry_forward(project, archive)
1964
+ require_clean_older_archives(project, configured)
1965
+ head = require_git_success(run_git(project, "rev-parse", "HEAD"), "resolve preflight HEAD")
1966
+ if reviewed_head != head:
1967
+ raise ArchiveError(
1968
+ f"FINAL.md Reviewed HEAD {reviewed_head} does not match current HEAD {head}"
1969
+ )
1970
+ return {
1971
+ "archive": configured,
1972
+ "carried_forward": pending_ruling_count(archive / "research" / "DOCS-AUDIT.md"),
1973
+ }
1974
+
1975
+
1976
+ def require_canonical_commit_body(
1977
+ project: Path,
1978
+ commit: str,
1979
+ canonical_subject: str,
1980
+ expected_body: str,
1981
+ label: str,
1982
+ ) -> None:
1983
+ message = require_git_success(
1984
+ run_git(project, "show", "-s", "--format=%s%x00%b", commit),
1985
+ f"inspect {label} commit message",
1986
+ )
1987
+ subject, separator, body = message.partition("\x00")
1988
+ if not separator:
1989
+ raise ArchiveError(f"{label} commit message is malformed")
1990
+ if subject != canonical_subject:
1991
+ raise ArchiveError(f"{label} commit subject must be {canonical_subject!r}")
1992
+ if body.strip() != expected_body.strip():
1993
+ raise ArchiveError(f"{label} commit body does not match required fields")
1994
+
1995
+
1996
+ def find_ship_commit(project: Path, archive_name: str) -> str:
1997
+ expected_subject = ship_subject(archive_name)
1998
+ log = require_git_success(
1999
+ run_git(project, "log", "--first-parent", "--format=%H%x00%s", "HEAD"),
2000
+ "inspect HEAD history for the ship commit",
2001
+ )
2002
+ matches = []
2003
+ for record in log.splitlines():
2004
+ if "\x00" not in record:
2005
+ continue
2006
+ commit, subject = record.split("\x00", 1)
2007
+ if subject == expected_subject:
2008
+ matches.append(commit)
2009
+ if not matches:
2010
+ raise ArchiveError(f"no commit with exact subject {expected_subject!r} in HEAD history")
2011
+ if len(matches) != 1:
2012
+ raise ArchiveError(
2013
+ f"multiple commits with exact subject {expected_subject!r} in HEAD history"
2014
+ )
2015
+ return matches[0]
2016
+
2017
+
2018
+ def validate_shipped_roadmap(text: str, state: PipelineState, archive: str) -> None:
2019
+ if state.milestone is None or state.branch is None:
2020
+ raise ArchiveError("shipped program state must name milestone and branch")
2021
+ lines = text.splitlines()
2022
+ headings = [
2023
+ (index, match)
2024
+ for index, line in enumerate(lines)
2025
+ if (match := ROADMAP_HEADING_PATTERN.fullmatch(line)) is not None
2026
+ ]
2027
+ matches = [item for item in headings if item[1].group(2) == state.milestone]
2028
+ if len(matches) != 1:
2029
+ raise ArchiveError("ROADMAP.md must contain one shipped milestone entry")
2030
+ start, heading = matches[0]
2031
+ end = next((index for index, _ in headings if index > start), len(lines))
2032
+ expected_id = milestone_id(milestone_number(state.branch))
2033
+ if heading.group(1) != expected_id:
2034
+ raise ArchiveError("ROADMAP.md shipped milestone id differs from STATE.branch")
2035
+ section = lines[start:end]
2036
+ statuses = [line.split(":", 1)[1].strip() for line in section if line.startswith("Status:")]
2037
+ archives = [line.split(":", 1)[1].strip() for line in section if line.startswith("Archive:")]
2038
+ if statuses != ["shipped"] or archives != [archive]:
2039
+ raise ArchiveError("ROADMAP.md must record Status: shipped and the exact Archive")
2040
+
2041
+
2042
+ def validate(repo: Path) -> dict:
2043
+ project, _, state, configured, archive, archived_files, reviewed_head = prepared_transaction(
2044
+ repo,
2045
+ ("shipped",),
2046
+ )
2047
+ if state.status != "done":
2048
+ raise ArchiveError("STATE.md is not shipped/done")
2049
+
2050
+ project_status = require_git_success(run_git(project, "status", "--porcelain"), "inspect worktree status")
2051
+ if project_status:
2052
+ raise ArchiveError("ship transaction worktree is not clean")
2053
+
2054
+ ship_commit = find_ship_commit(project, archive.name)
2055
+
2056
+ parents = require_git_success(
2057
+ run_git(project, "rev-list", "--parents", "-n", "1", ship_commit),
2058
+ "inspect ship commit parents",
2059
+ ).split()
2060
+ if len(parents) != 2:
2061
+ raise ArchiveError("ship commit must have exactly one parent")
2062
+
2063
+ changed_paths = require_git_success(
2064
+ run_git(
2065
+ project,
2066
+ "diff-tree",
2067
+ "--root",
2068
+ "--no-commit-id",
2069
+ "--name-only",
2070
+ "--no-renames",
2071
+ "-r",
2072
+ ship_commit,
2073
+ ),
2074
+ "inspect ship commit paths",
2075
+ ).splitlines()
2076
+ outside_project = [path for path in changed_paths if not path.startswith(".project/")]
2077
+ if outside_project:
2078
+ raise ArchiveError(f"ship commit changes paths outside .project: {', '.join(outside_project)}")
2079
+
2080
+ archive_prefix = f"{configured}/"
2081
+ older_archive_changes = [
2082
+ path
2083
+ for path in changed_paths
2084
+ if path.startswith(".project/archive/") and not path.startswith(archive_prefix)
2085
+ ]
2086
+ if older_archive_changes:
2087
+ raise ArchiveError(f"ship commit mutates an older archive: {', '.join(older_archive_changes)}")
2088
+
2089
+ allowed_active_prefixes = tuple(f".project/{name}/" for name in TRANSACTION_DIRECTORIES)
2090
+ persistent_program_files = set()
2091
+ charter_path = project / ".project" / "CHARTER.md"
2092
+ roadmap_path = project / ".project" / "ROADMAP.md"
2093
+ if charter_path.is_symlink() or (charter_path.exists() and not charter_path.is_file()):
2094
+ raise ArchiveError("CHARTER.md must be a real file")
2095
+ if roadmap_path.is_symlink() or (roadmap_path.exists() and not roadmap_path.is_file()):
2096
+ raise ArchiveError("ROADMAP.md must be a real file")
2097
+ program_ship = is_real_file(charter_path)
2098
+ if program_ship:
2099
+ persistent_program_files = {
2100
+ ".project/CHARTER.md",
2101
+ ".project/ROADMAP.md",
2102
+ ".project/SYNTHESIS.md",
2103
+ }
2104
+ unexpected_project_paths = [
2105
+ path
2106
+ for path in changed_paths
2107
+ if path != ".project/STATE.md"
2108
+ and path != ".project/LESSONS.md"
2109
+ and path not in persistent_program_files
2110
+ and not path.startswith(archive_prefix)
2111
+ and not path.startswith(allowed_active_prefixes)
2112
+ ]
2113
+ if unexpected_project_paths:
2114
+ raise ArchiveError(
2115
+ f"ship commit changes unexpected .project paths: {', '.join(unexpected_project_paths)}"
2116
+ )
2117
+
2118
+ required_paths = {
2119
+ ".project/STATE.md",
2120
+ f"{configured}/MANIFEST.md",
2121
+ *(f"{configured}/{path}" for path in archived_files),
2122
+ }
2123
+ if program_ship:
2124
+ required_paths.add(".project/ROADMAP.md")
2125
+ missing_from_commit = sorted(required_paths - set(changed_paths))
2126
+ if missing_from_commit:
2127
+ raise ArchiveError(
2128
+ f"ship commit does not record the complete transaction: {', '.join(missing_from_commit)}"
2129
+ )
2130
+ require_committed_carry_forward(project, configured, archive, ship_commit)
2131
+
2132
+ ship_parent = require_git_success(run_git(project, "rev-parse", f"{ship_commit}^"), "resolve ship parent")
2133
+ if reviewed_head != ship_parent:
2134
+ raise ArchiveError(
2135
+ f"FINAL.md Reviewed HEAD {reviewed_head} does not match ship parent {ship_parent}"
2136
+ )
2137
+ require_canonical_commit_body(
2138
+ project,
2139
+ ship_commit,
2140
+ ship_subject(archive.name),
2141
+ ship_commit_body(configured, reviewed_head),
2142
+ "ship",
2143
+ )
2144
+
2145
+ archive_in_parent = run_git(project, "cat-file", "-e", f"{ship_commit}^:{configured}")
2146
+ if archive_in_parent.returncode == 0:
2147
+ raise ArchiveError("current archive was already committed before the ship commit")
2148
+
2149
+ committed_state = require_git_success(
2150
+ run_git(project, "show", f"{ship_commit}:.project/STATE.md"),
2151
+ "read committed STATE.md",
2152
+ )
2153
+ current_state = (project / ".project" / "STATE.md").read_text(
2154
+ encoding="utf-8"
2155
+ ).strip()
2156
+ if committed_state != current_state:
2157
+ raise ArchiveError("ship commit does not contain the shipped state transaction")
2158
+ if program_ship:
2159
+ committed_roadmap = require_git_success(
2160
+ run_git(project, "show", f"{ship_commit}:.project/ROADMAP.md"),
2161
+ "read committed ROADMAP.md",
2162
+ )
2163
+ validate_shipped_roadmap(committed_roadmap, state, configured)
2164
+
2165
+ manifest_path = f"{ship_commit}:{configured}/MANIFEST.md"
2166
+ require_git_success(run_git(project, "cat-file", "-e", manifest_path), "verify committed manifest")
2167
+
2168
+ project_drift = require_git_success(
2169
+ run_git(project, "diff", "--name-only", ship_commit, "HEAD", "--", ".project"),
2170
+ "inspect .project history after the ship commit",
2171
+ )
2172
+ if project_drift:
2173
+ raise ArchiveError(
2174
+ ".project changed in history after the ship commit: "
2175
+ + ", ".join(project_drift.splitlines())
2176
+ + f"; run: git -C {project} revert <commits after {ship_commit[:12]} that touch .project>"
2177
+ )
2178
+
2179
+ return {"archive": configured, "commit": ship_commit}
2180
+
2181
+
2182
+ def parser() -> argparse.ArgumentParser:
2183
+ argument_parser = argparse.ArgumentParser(description=__doc__)
2184
+ subparsers = argument_parser.add_subparsers(dest="command", required=True)
2185
+
2186
+ prepare_parser = subparsers.add_parser("prepare", help="prepare or resume an archive transaction")
2187
+ prepare_parser.add_argument("--repo", required=True, type=Path)
2188
+ prepare_parser.add_argument("--slug", required=True)
2189
+
2190
+ preflight_parser = subparsers.add_parser(
2191
+ "preflight",
2192
+ help="validate the prepared transaction before the ship commit",
2193
+ )
2194
+ preflight_parser.add_argument("--repo", required=True, type=Path)
2195
+
2196
+ manifest_parser = subparsers.add_parser(
2197
+ "render-manifest",
2198
+ help="render the current uncommitted archive manifest from validated evidence",
2199
+ )
2200
+ manifest_parser.add_argument("--repo", required=True, type=Path)
2201
+
2202
+ validate_parser = subparsers.add_parser("validate", help="validate the committed ship transaction")
2203
+ validate_parser.add_argument("--repo", required=True, type=Path)
2204
+
2205
+ validate_integrated_parser = subparsers.add_parser(
2206
+ "validate-integrated",
2207
+ help=(
2208
+ "validate the committed ship transaction and its default-branch integration; "
2209
+ "pull-request mode requires origin network access"
2210
+ ),
2211
+ )
2212
+ validate_integrated_parser.add_argument("--repo", required=True, type=Path)
2213
+ validate_integrated_parser.add_argument("--slug", required=True)
2214
+
2215
+ refresh_parser = subparsers.add_parser(
2216
+ "refresh-origin",
2217
+ help="fetch origin, refresh origin/HEAD, and mirror published milestone tags",
2218
+ )
2219
+ refresh_parser.add_argument("--repo", required=True, type=Path)
2220
+
2221
+ integrate_parser = subparsers.add_parser(
2222
+ "integrate",
2223
+ help="resume and publish the shipped milestone integration",
2224
+ )
2225
+ integrate_parser.add_argument("--repo", required=True, type=Path)
2226
+ integrate_parser.add_argument("--slug", required=True)
2227
+
2228
+ abandon_parser = subparsers.add_parser(
2229
+ "abandon",
2230
+ help="abandon the active build milestone and archive its partial artifacts",
2231
+ )
2232
+ abandon_parser.add_argument("--repo", required=True, type=Path)
2233
+ abandon_parser.add_argument("--slug", required=True)
2234
+ abandon_parser.add_argument("--reason", required=True)
2235
+ return argument_parser
2236
+
2237
+
2238
+ def main(argv: Optional[Sequence[str]] = None) -> int:
2239
+ arguments = parser().parse_args(argv)
2240
+ try:
2241
+ if arguments.command == "prepare":
2242
+ result = prepare(arguments.repo, arguments.slug)
2243
+ elif arguments.command == "render-manifest":
2244
+ result = render_manifest(arguments.repo)
2245
+ elif arguments.command == "preflight":
2246
+ result = preflight(arguments.repo)
2247
+ elif arguments.command == "abandon":
2248
+ result = abandon(arguments.repo, arguments.slug, arguments.reason)
2249
+ elif arguments.command == "validate-integrated":
2250
+ result = validate_integrated(arguments.repo, arguments.slug)
2251
+ elif arguments.command == "refresh-origin":
2252
+ result = refresh_origin(arguments.repo)
2253
+ elif arguments.command == "integrate":
2254
+ result = integrate(arguments.repo, arguments.slug)
2255
+ else:
2256
+ result = validate(arguments.repo)
2257
+ except (ArchiveError, OSError) as error:
2258
+ print(str(error), file=sys.stderr)
2259
+ return 1
2260
+ print(json.dumps(result, sort_keys=True))
2261
+ return 0
2262
+
2263
+
2264
+ if __name__ == "__main__":
2265
+ raise SystemExit(main())