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