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