@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,1326 @@
1
+ #!/usr/bin/env python3
2
+ # gsd-path project runtime
3
+ """Validate discussion records, reviews and the manifest for archive_milestone."""
4
+
5
+ import sys
6
+
7
+ # Runtime helpers must not modify their immutable installation.
8
+ sys.dont_write_bytecode = True
9
+
10
+ import json
11
+ import re
12
+ import shutil
13
+ from datetime import date
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Optional, Sequence
16
+
17
+ try:
18
+ from pipeline_state import PipelineState
19
+ import _common
20
+ except ImportError: # pragma: no cover - package import used by tests
21
+ from scripts.pipeline_state import PipelineState
22
+ from scripts import _common
23
+
24
+
25
+ atomic_replace = _common.atomic_replace
26
+ run_git = _common.run_git
27
+
28
+
29
+ def record_sections(
30
+ lines: Sequence[str], pattern: re.Pattern, boundaries: Sequence[re.Pattern]
31
+ ) -> Sequence[tuple]:
32
+ headings = []
33
+ for index, line in enumerate(lines):
34
+ match = pattern.fullmatch(line)
35
+ if match:
36
+ headings.append((index, match))
37
+ sections = []
38
+ for start, match in headings:
39
+ end = next(
40
+ (
41
+ index
42
+ for index in range(start + 1, len(lines))
43
+ if any(boundary.fullmatch(lines[index]) for boundary in boundaries)
44
+ ),
45
+ len(lines),
46
+ )
47
+ sections.append((match, lines[start + 1 : end]))
48
+ return sections
49
+
50
+
51
+ def required_dialogue_block(
52
+ lines: Sequence[str],
53
+ marker: str,
54
+ end_marker: str,
55
+ artifact: str,
56
+ quote: bool = False,
57
+ end_from_last: bool = False,
58
+ ) -> None:
59
+ if lines.count(marker) != 1:
60
+ raise ArchiveError(f"{artifact} requires one {marker.removeprefix('- **').removesuffix('**:')} block")
61
+ start = lines.index(marker) + 1
62
+ boundaries = [
63
+ index for index in range(start, len(lines)) if lines[index] == end_marker
64
+ ]
65
+ if not boundaries:
66
+ end = len(lines)
67
+ elif end_from_last:
68
+ end = boundaries[-1]
69
+ else:
70
+ end = boundaries[0]
71
+ content = "\n".join(lines[start:end]).strip()
72
+ if quote:
73
+ content = "\n".join(
74
+ line.lstrip().removeprefix(">").strip() for line in content.splitlines()
75
+ ).strip()
76
+ if not content:
77
+ label = "verbatim user" if quote else "assistant"
78
+ raise ArchiveError(f"{artifact} requires a non-empty {label} block")
79
+
80
+
81
+ def require_contiguous_ids(values: Sequence[int], artifact: str) -> None:
82
+ if not values or list(values) != list(range(1, len(values) + 1)):
83
+ raise ArchiveError(f"{artifact} record ids must be contiguous from 001")
84
+
85
+
86
+ def require_iso_date(value: str, artifact: str) -> None:
87
+ try:
88
+ parsed = date.fromisoformat(value)
89
+ except ValueError as error:
90
+ raise ArchiveError(f"{artifact} date must be YYYY-MM-DD") from error
91
+ if parsed.isoformat() != value:
92
+ raise ArchiveError(f"{artifact} date must be YYYY-MM-DD")
93
+
94
+
95
+ def require_bullet_fields(
96
+ lines: Sequence[str], artifact: str, fields: Sequence[str]
97
+ ) -> dict:
98
+ return {
99
+ field: completed_bullet_field(lines, field, artifact) for field in fields
100
+ }
101
+
102
+
103
+ def validate_discussion_directory(
104
+ directory: Path, require_dispositions: bool = True
105
+ ) -> None:
106
+ if directory.is_symlink() or not directory.is_dir():
107
+ raise ArchiveError("discussion archive must be a real directory")
108
+ entries = sorted(path.name for path in directory.iterdir())
109
+ if entries != sorted(DISCUSSION_FILES):
110
+ raise ArchiveError("discussion archive must contain exactly DIALOGUE.md and ANSWERS.md")
111
+ dialogue, answers = (directory / name for name in DISCUSSION_FILES)
112
+ if not archive_milestone.is_real_file(dialogue) or not archive_milestone.is_real_file(answers):
113
+ raise ArchiveError("discussion archive records must be real files")
114
+
115
+ dialogue_content = dialogue.read_text(encoding="utf-8")
116
+ answer_content = answers.read_text(encoding="utf-8")
117
+ dialogue_lines = dialogue_content.splitlines()
118
+ answer_lines = answer_content.splitlines()
119
+ if archive_milestone.contains_placeholder("\n".join((*dialogue_lines, *answer_lines))):
120
+ raise ArchiveError("discussion archive records contain placeholders")
121
+
122
+ dialogue_sections = record_sections(
123
+ dialogue_lines, DIALOGUE_HEADING_PATTERN, (DIALOGUE_HEADING_PATTERN,)
124
+ )
125
+ answer_boundaries = (ANSWER_HEADING_PATTERN, DISPOSITION_HEADING_PATTERN)
126
+ answer_sections = record_sections(answer_lines, ANSWER_HEADING_PATTERN, answer_boundaries)
127
+ dialogue_ids = [int(match.group(1)) for match, _ in dialogue_sections]
128
+ answer_ids = [int(match.group(1)) for match, _ in answer_sections]
129
+ if not dialogue_ids and not answer_ids:
130
+ actual = {
131
+ "DIALOGUE.md": dialogue_content,
132
+ "ANSWERS.md": answer_content,
133
+ }
134
+ if actual != EMPTY_DISCUSSION_FILES:
135
+ raise ArchiveError("empty discussion records must use canonical headers")
136
+ return
137
+ require_contiguous_ids(dialogue_ids, "DIALOGUE.md")
138
+ require_contiguous_ids(answer_ids, "ANSWERS.md")
139
+ if dialogue_ids != answer_ids:
140
+ raise ArchiveError("discussion dialogue and answer ids do not correspond")
141
+
142
+ dialogue_threads = {}
143
+ dialogue_phases = {}
144
+ dialogue_dates = {}
145
+ for match, section in dialogue_sections:
146
+ identifier = int(match.group(1))
147
+ require_iso_date(match.group(2), "DIALOGUE.md")
148
+ if match.group(3) not in DISCUSSION_PHASES:
149
+ raise ArchiveError("DIALOGUE.md has an invalid phase")
150
+ thread = completed_bullet_field(section, "Thread", "DIALOGUE.md")
151
+ if not re.fullmatch(r"T\d{3}", thread):
152
+ raise ArchiveError("DIALOGUE.md Thread must be T###")
153
+ reply_to = completed_bullet_field(section, "Reply to", "DIALOGUE.md")
154
+ if reply_to != "none" and not re.fullmatch(r"D\d{3}", reply_to):
155
+ raise ArchiveError("DIALOGUE.md Reply to must be none or D###")
156
+ if reply_to != "none" and int(reply_to[1:]) >= identifier:
157
+ raise ArchiveError("DIALOGUE.md Reply to must reference an earlier turn")
158
+ if reply_to != "none" and dialogue_threads[int(reply_to[1:])] != thread:
159
+ raise ArchiveError("DIALOGUE.md Reply to must stay in the same thread")
160
+ values = require_bullet_fields(
161
+ section,
162
+ "DIALOGUE.md",
163
+ ("Evidence checked", "Research", "Thread status"),
164
+ )
165
+ thread_status = values["Thread status"]
166
+ if thread_status not in {"working", "NEEDS-USER", "final"}:
167
+ raise ArchiveError("DIALOGUE.md has an invalid Thread status")
168
+ required_dialogue_block(
169
+ section,
170
+ "- **User (verbatim)**:",
171
+ "- **Assistant**:",
172
+ "DIALOGUE.md",
173
+ quote=True,
174
+ )
175
+ required_dialogue_block(
176
+ section,
177
+ "- **Assistant**:",
178
+ "- **Evidence checked**:",
179
+ "DIALOGUE.md",
180
+ end_from_last=True,
181
+ )
182
+ dialogue_threads[identifier] = thread
183
+ dialogue_phases[identifier] = f"{match.group(3)}/{match.group(4)}"
184
+ dialogue_dates[identifier] = match.group(2)
185
+
186
+ answer_threads = {}
187
+ pending_answers = set()
188
+ answer_receipts = {}
189
+ for match, section in answer_sections:
190
+ identifier = int(match.group(1))
191
+ require_iso_date(match.group(2), "ANSWERS.md")
192
+ if match.group(2) != dialogue_dates[identifier]:
193
+ raise ArchiveError("discussion answer date does not match its dialogue turn")
194
+ thread = completed_bullet_field(section, "Thread", "ANSWERS.md")
195
+ if thread != dialogue_threads[identifier]:
196
+ raise ArchiveError("discussion answer Thread does not match its dialogue turn")
197
+ if completed_bullet_field(section, "Turn", "ANSWERS.md") != f"D{identifier:03d}":
198
+ raise ArchiveError("discussion answer Turn does not match its id")
199
+ supersedes = completed_bullet_field(section, "Supersedes", "ANSWERS.md")
200
+ if supersedes != "none" and not re.fullmatch(r"A\d{3}", supersedes):
201
+ raise ArchiveError("ANSWERS.md Supersedes must be none or A###")
202
+ if supersedes != "none" and int(supersedes[1:]) >= identifier:
203
+ raise ArchiveError("ANSWERS.md Supersedes must reference an earlier answer")
204
+ if supersedes != "none" and answer_threads[int(supersedes[1:])] != thread:
205
+ raise ArchiveError("ANSWERS.md Supersedes must stay in the same thread")
206
+ values = require_bullet_fields(
207
+ section,
208
+ "ANSWERS.md",
209
+ (
210
+ "Question",
211
+ "Status",
212
+ "Phase/status",
213
+ "Conclusion",
214
+ "Reasoning / pushback",
215
+ "Evidence",
216
+ "Research",
217
+ "Confidence",
218
+ "Unresolved",
219
+ "Next owner",
220
+ "Target artifact",
221
+ "Follow-up",
222
+ ),
223
+ )
224
+ status = values["Status"]
225
+ if status not in {"working", "NEEDS-USER", "final"}:
226
+ raise ArchiveError("ANSWERS.md has an invalid Status")
227
+ phase_status = values["Phase/status"]
228
+ if not re.fullmatch(r"[a-z-]+/(active|blocked|done)", phase_status):
229
+ raise ArchiveError("ANSWERS.md has an invalid Phase/status")
230
+ if phase_status != dialogue_phases[identifier]:
231
+ raise ArchiveError("discussion answer Phase/status does not match its dialogue turn")
232
+ confidence = values["Confidence"]
233
+ if confidence not in {"high", "medium", "low", "unverifiable"}:
234
+ raise ArchiveError("ANSWERS.md has an invalid Confidence")
235
+ follow_up = values["Follow-up"]
236
+ if follow_up not in {"none", "required"}:
237
+ raise ArchiveError("ANSWERS.md has an invalid Follow-up")
238
+ next_owner = values["Next owner"]
239
+ target = values["Target artifact"]
240
+ answer_key = f"A{identifier:03d}"
241
+ answer_receipts[answer_key] = (next_owner, target)
242
+ if follow_up == "required":
243
+ if status not in {"final", "NEEDS-USER"}:
244
+ raise ArchiveError("required discussion follow-up must be final or NEEDS-USER")
245
+ if next_owner in {"none", "user"}:
246
+ raise ArchiveError("required discussion follow-up needs a phase owner")
247
+ if target == "none":
248
+ raise ArchiveError("required discussion follow-up needs a target artifact")
249
+ pending_answers.add(answer_key)
250
+ answer_threads[identifier] = thread
251
+
252
+ disposition_sections = record_sections(
253
+ answer_lines, DISPOSITION_HEADING_PATTERN, answer_boundaries
254
+ )
255
+ disposition_ids = [int(match.group(1)) for match, _ in disposition_sections]
256
+ if disposition_ids:
257
+ require_contiguous_ids(disposition_ids, "ANSWERS.md dispositions")
258
+ seen_answers = set()
259
+ for match, section in disposition_sections:
260
+ require_iso_date(match.group(2), "ANSWERS.md disposition")
261
+ answer = completed_bullet_field(section, "Answer", "ANSWERS.md")
262
+ if not re.fullmatch(r"A\d{3}", answer) or int(answer[1:]) not in answer_ids:
263
+ raise ArchiveError("discussion disposition references an unknown answer")
264
+ if answer in seen_answers:
265
+ raise ArchiveError("discussion answer has multiple dispositions")
266
+ seen_answers.add(answer)
267
+ disposition = completed_bullet_field(section, "Status", "ANSWERS.md")
268
+ if disposition not in {
269
+ "applied",
270
+ "acknowledged-no-change",
271
+ "superseded",
272
+ "rejected-by-user",
273
+ }:
274
+ raise ArchiveError("discussion disposition has an invalid Status")
275
+ receipt = require_bullet_fields(
276
+ section, "ANSWERS.md", ("Owner", "Artifact", "Evidence")
277
+ )
278
+ expected_owner, expected_artifact = answer_receipts[answer]
279
+ expected_owner = (
280
+ "user" if disposition == "rejected-by-user" else expected_owner
281
+ )
282
+ if receipt["Owner"] != expected_owner:
283
+ raise ArchiveError(
284
+ f"discussion disposition owner must be {expected_owner} for {answer}"
285
+ )
286
+ if receipt["Artifact"] != expected_artifact:
287
+ raise ArchiveError(
288
+ "discussion disposition artifact must match the answer target "
289
+ f"for {answer}"
290
+ )
291
+ unresolved = sorted(pending_answers - seen_answers)
292
+ if require_dispositions and unresolved:
293
+ raise ArchiveError(
294
+ f"discussion follow-up lacks a disposition receipt: {', '.join(unresolved)}"
295
+ )
296
+
297
+
298
+ def require_append_only_discussion(
299
+ source: Path,
300
+ destination: Path,
301
+ *,
302
+ require_dispositions: bool = True,
303
+ ) -> None:
304
+ validate_discussion_directory(source, require_dispositions=require_dispositions)
305
+ validate_discussion_directory(destination, require_dispositions=require_dispositions)
306
+ for name in DISCUSSION_FILES:
307
+ active = (source / name).read_bytes()
308
+ archived = (destination / name).read_bytes()
309
+ if not active.startswith(archived):
310
+ raise ArchiveError(f"active discussion is not an append-only extension of archived {name}")
311
+
312
+
313
+ def reconcile_append_only_discussion(active_root: Path, archive: Path) -> None:
314
+ source = active_root / "discuss"
315
+ destination = archive / "discuss"
316
+ if not source.exists() or not destination.exists():
317
+ return
318
+ require_append_only_discussion(source, destination)
319
+ transaction = active_root / DISCUSSION_TRANSACTION_NAME
320
+ payload = {
321
+ "schema": "gsd-path/discussion-archive/v1",
322
+ "archive": str(archive.resolve()),
323
+ "files": {
324
+ name: (source / name).read_text(encoding="utf-8")
325
+ for name in DISCUSSION_FILES
326
+ },
327
+ }
328
+ archive_milestone.atomic_write(transaction, json.dumps(payload, sort_keys=True) + "\n")
329
+ finish_discussion_reconciliation(active_root, archive)
330
+
331
+
332
+ def require_safe_discussion_destination(
333
+ active_root: Path, archive: Path, destination: Path
334
+ ) -> None:
335
+ archive_root = active_root / "archive"
336
+ if active_root.is_symlink() or not active_root.is_dir():
337
+ raise ArchiveError("discussion reconciliation requires a real .project directory")
338
+ if archive_root.is_symlink() or not archive_root.is_dir():
339
+ raise ArchiveError("discussion reconciliation requires a real archive root")
340
+ if archive.is_symlink() or not archive.is_dir():
341
+ raise ArchiveError("discussion reconciliation requires a real archive directory")
342
+ if archive.resolve().parent != archive_root.resolve():
343
+ raise ArchiveError("discussion reconciliation archive escapes the archive root")
344
+ if destination.is_symlink() or not destination.is_dir():
345
+ raise ArchiveError("discussion reconciliation destination must be a real directory")
346
+ if destination.resolve().parent != archive.resolve():
347
+ raise ArchiveError("discussion reconciliation destination escapes the archive")
348
+
349
+
350
+ def finish_discussion_reconciliation(active_root: Path, archive: Path) -> None:
351
+ transaction = active_root / DISCUSSION_TRANSACTION_NAME
352
+ if not transaction.exists():
353
+ return
354
+ if transaction.is_symlink() or not transaction.is_file():
355
+ raise ArchiveError("discussion reconciliation journal must be a real file")
356
+ try:
357
+ payload = json.loads(transaction.read_text(encoding="utf-8"))
358
+ except (OSError, json.JSONDecodeError) as error:
359
+ raise ArchiveError(f"discussion reconciliation journal is unreadable: {error}") from error
360
+ if payload.get("schema") != "gsd-path/discussion-archive/v1":
361
+ raise ArchiveError("discussion reconciliation journal has the wrong schema")
362
+ if payload.get("archive") != str(archive.resolve()):
363
+ raise ArchiveError("discussion reconciliation journal targets another archive")
364
+ files = payload.get("files")
365
+ if (
366
+ not isinstance(files, dict)
367
+ or set(files) != set(DISCUSSION_FILES)
368
+ or any(not isinstance(files[name], str) for name in DISCUSSION_FILES)
369
+ ):
370
+ raise ArchiveError("discussion reconciliation journal has invalid files")
371
+ source = active_root / "discuss"
372
+ if source.exists():
373
+ validate_discussion_directory(source)
374
+ active_files = {
375
+ name: (source / name).read_text(encoding="utf-8")
376
+ for name in DISCUSSION_FILES
377
+ }
378
+ if any(not active_files[name].startswith(files[name]) for name in DISCUSSION_FILES):
379
+ raise ArchiveError("active discussion diverged during archive reconciliation")
380
+ if active_files != files:
381
+ files = active_files
382
+ payload["files"] = files
383
+ archive_milestone.atomic_write(transaction, json.dumps(payload, sort_keys=True) + "\n")
384
+ destination = archive / "discuss"
385
+ require_safe_discussion_destination(active_root, archive, destination)
386
+ for name in DISCUSSION_FILES:
387
+ temporary = destination / f".{name}.gsd-path-tmp"
388
+ atomic_replace(destination / name, temporary, files[name])
389
+ validate_discussion_directory(destination)
390
+ if source.exists():
391
+ shutil.rmtree(source)
392
+ transaction.unlink()
393
+
394
+
395
+ def remove_interrupted_discussion_copies(active_root: Path, archive: Path) -> None:
396
+ if not (active_root / "discuss").exists():
397
+ return
398
+ directory = archive / "discuss"
399
+ if directory.exists() or directory.is_symlink():
400
+ require_safe_discussion_destination(active_root, archive, directory)
401
+ for name in DISCUSSION_FILES:
402
+ temporary = directory / f".{name}.gsd-path-tmp"
403
+ if temporary.exists() or temporary.is_symlink():
404
+ temporary.unlink()
405
+
406
+
407
+ def remove_interrupted_carry_copy(active_root: Path, archive: Path) -> None:
408
+ archived_audit = archive / "research" / "DOCS-AUDIT.md"
409
+ if archive_milestone.pending_ruling_count(archived_audit) == 0:
410
+ return
411
+ temporary_path = active_root / "research" / CARRY_TEMP_NAME
412
+ if temporary_path.exists() or temporary_path.is_symlink():
413
+ temporary_path.unlink()
414
+
415
+
416
+ def require_transaction_context(
417
+ project: Path, state: PipelineState, phases: Sequence[str], *, historical: bool = False
418
+ ) -> None:
419
+ phase = state.phase
420
+ if phase not in phases:
421
+ raise ArchiveError(f"archive transaction is not valid in phase: {phase}")
422
+
423
+ repository_root = archive_milestone.require_git_success(
424
+ run_git(project, "rev-parse", "--show-toplevel"),
425
+ "resolve Git root",
426
+ )
427
+ if Path(repository_root).resolve() != project:
428
+ raise ArchiveError(f"--repo must be the Git worktree root: {repository_root}")
429
+
430
+ expected_branch = state.branch
431
+ current_branch = archive_milestone.require_git_success(
432
+ run_git(project, "branch", "--show-current"),
433
+ "resolve current branch",
434
+ )
435
+ if archive_milestone.is_unset(expected_branch):
436
+ raise ArchiveError("STATE.md does not name a bound build branch")
437
+ if current_branch != expected_branch and not historical:
438
+ raise ArchiveError(
439
+ f"current branch {current_branch!r} does not match STATE.branch {expected_branch!r}"
440
+ )
441
+
442
+ non_project_status = archive_milestone.require_git_success(
443
+ run_git(
444
+ project,
445
+ "status",
446
+ "--porcelain",
447
+ "--untracked-files=all",
448
+ "--",
449
+ ".",
450
+ ":(exclude).project",
451
+ ),
452
+ "inspect non-project worktree status",
453
+ )
454
+ if non_project_status and not historical:
455
+ raise ArchiveError("non-.project worktree changes block the archive transaction")
456
+
457
+
458
+ def completed_field(lines: Sequence[str], field: str, artifact: str) -> str:
459
+ values = [line.removeprefix(field).strip() for line in lines if line.startswith(field)]
460
+ if len(values) != 1 or not values[0] or archive_milestone.contains_placeholder(values[0]):
461
+ raise ArchiveError(f"{artifact} requires one completed {field} field")
462
+ return values[0]
463
+
464
+
465
+ def completed_bullet_field(lines: Sequence[str], field: str, artifact: str) -> str:
466
+ pattern = re.compile(rf"^- \*\*{re.escape(field)}\*\*:\s*(.*)$")
467
+ values = []
468
+ for line in lines:
469
+ match = pattern.fullmatch(line)
470
+ if match:
471
+ values.append(match.group(1).strip().strip("`"))
472
+ if len(values) != 1 or not values[0] or archive_milestone.contains_placeholder(values[0]):
473
+ raise ArchiveError(f"{artifact} requires one completed {field} field")
474
+ return values[0]
475
+
476
+
477
+ def section_lines(lines: Sequence[str], heading: str, artifact: str) -> Sequence[str]:
478
+ if lines.count(heading) != 1:
479
+ raise ArchiveError(f"{artifact} requires one {heading} section")
480
+ start = lines.index(heading) + 1
481
+ end = next(
482
+ (index for index in range(start, len(lines)) if lines[index].startswith("## ")),
483
+ len(lines),
484
+ )
485
+ return lines[start:end]
486
+
487
+
488
+ def archived_intent_criteria(archive: Path) -> dict[int, str]:
489
+ intent = archive / "intent" / "INTENT.md"
490
+ if not archive_milestone.is_real_file(intent):
491
+ raise ArchiveError("archived intent must be a real INTENT.md file")
492
+ text = HTML_COMMENT_PATTERN.sub("", intent.read_text(encoding="utf-8"))
493
+ try:
494
+ import check_handoffs
495
+ except ImportError:
496
+ from scripts import check_handoffs
497
+ try:
498
+ criteria = check_handoffs._success_criteria(text)
499
+ except check_handoffs.HandoffError as error:
500
+ raise ArchiveError(str(error)) from error
501
+ return {int(key[2:]): " ".join(value.split()) for key, value in criteria.items()}
502
+
503
+
504
+ def parse_final_review(archive: Path) -> tuple:
505
+ try:
506
+ import check_handoffs
507
+ except ImportError: # pragma: no cover - package import used by tests
508
+ from scripts import check_handoffs
509
+
510
+ final = archive / "review" / "FINAL.md"
511
+ if not archive_milestone.is_real_file(final):
512
+ raise ArchiveError("final review must be a real FINAL.md file")
513
+ lines = final.read_text(encoding="utf-8").splitlines()
514
+ reviewed_head = completed_field(lines, "Reviewed HEAD:", "FINAL.md")
515
+ if not re.fullmatch(r"[0-9a-fA-F]{40}", reviewed_head):
516
+ raise ArchiveError("FINAL.md Reviewed HEAD must be a full commit SHA")
517
+ if completed_field(lines, "Overall verdict:", "FINAL.md") != "pass":
518
+ raise ArchiveError("FINAL.md final review did not pass")
519
+ if lines.count("## Success criteria") != 1:
520
+ raise ArchiveError("FINAL.md requires one Success criteria section")
521
+
522
+ start = lines.index("## Success criteria") + 1
523
+ headings = []
524
+ criteria_end = len(lines)
525
+ for index, line in enumerate(lines[start:], start=start):
526
+ if line.startswith("## "):
527
+ criteria_end = index
528
+ break
529
+ if line.startswith("### "):
530
+ match = FINAL_CRITERION_PATTERN.fullmatch(line)
531
+ if not match:
532
+ raise ArchiveError("FINAL.md has an invalid success-criterion heading")
533
+ headings.append((index, int(match.group(1)), match.group(2).strip()))
534
+ if not headings or [number for _, number, _ in headings] != list(range(1, len(headings) + 1)):
535
+ raise ArchiveError("FINAL.md success criteria must be ordered and contiguous")
536
+ intent_criteria = archived_intent_criteria(archive)
537
+ intent_text = (archive / "intent" / "INTENT.md").read_text(encoding="utf-8")
538
+ try:
539
+ surfaces = (
540
+ check_handoffs._surfaces(intent_text, "INTENT.md")
541
+ if re.search(r"(?m)^Surfaces:", intent_text) else []
542
+ )
543
+ except check_handoffs.HandoffError as error:
544
+ raise ArchiveError(str(error)) from error
545
+ if len(headings) != len(intent_criteria):
546
+ raise ArchiveError("FINAL.md success criteria do not exactly cover INTENT.md")
547
+ for _, number, criterion in headings:
548
+ if " ".join(criterion.split()) != intent_criteria[number]:
549
+ raise ArchiveError(
550
+ f"FINAL.md SC{number} heading text differs from archived INTENT.md"
551
+ )
552
+
553
+ expected_fields = ("Verdict", "Check", "Observed", "Reference", "Finding", "Fix direction")
554
+ criteria = []
555
+ for position, (heading_index, _, criterion) in enumerate(headings):
556
+ section_start = heading_index + 1
557
+ section_end = headings[position + 1][0] if position + 1 < len(headings) else criteria_end
558
+ values = {}
559
+ for line in lines[section_start:section_end]:
560
+ match = re.fullmatch(r"- \*\*(Verdict|Check|Observed|Reference|Finding|Fix direction)\*\*:\s*(.*)", line)
561
+ if not match:
562
+ continue
563
+ key, value = match.groups()
564
+ if key in values:
565
+ raise ArchiveError(f"FINAL.md repeats {key} for {criterion}")
566
+ values[key] = value.strip().strip("`")
567
+ surface_values = [
568
+ line.removeprefix("- **Surface**:").strip().strip("`")
569
+ for line in lines[section_start:section_end]
570
+ if line.startswith("- **Surface**:")
571
+ ]
572
+ surface_evidence = len(surface_values) == 1 and any(
573
+ # Older final gates stripped terminal Markdown code delimiters.
574
+ " ".join(surface_values[0].split()).casefold()
575
+ == " ".join(surface.split()).strip("`").casefold()
576
+ for surface in surfaces
577
+ )
578
+ if tuple(values) != expected_fields:
579
+ raise ArchiveError(f"FINAL.md criterion is incomplete: {criterion}")
580
+ for field, value in values.items():
581
+ if surface_evidence and field in {"Check", "Observed"}:
582
+ try:
583
+ check_handoffs._surface_value(value, f"FINAL.md surface {field}")
584
+ except check_handoffs.HandoffError as error:
585
+ raise ArchiveError(str(error)) from error
586
+ elif not value or archive_milestone.contains_placeholder(value):
587
+ raise ArchiveError(f"FINAL.md criterion is incomplete: {criterion}")
588
+ if values["Verdict"] != "met":
589
+ raise ArchiveError(f"FINAL.md criterion lacks passing evidence: {criterion}")
590
+ evidence = next(
591
+ (values[field] for field in ("Reference", "Check", "Observed") if values[field] != "none"),
592
+ None,
593
+ )
594
+ if evidence is None:
595
+ raise ArchiveError(f"FINAL.md criterion lacks passing evidence: {criterion}")
596
+ criteria.append((criterion, "met", evidence))
597
+ return reviewed_head.lower(), criteria
598
+
599
+
600
+ def validate_gap_reviews(archive: Path, reviewed_head: str) -> None:
601
+ review = archive / "review"
602
+ candidates = sorted(path for path in review.iterdir() if path.name.startswith("final-gap-"))
603
+ if not candidates:
604
+ raise ArchiveError("final review requires at least one final-gap-N.md artifact")
605
+
606
+ numbered = []
607
+ for path in candidates:
608
+ match = FINAL_GAP_FILE_PATTERN.fullmatch(path.name)
609
+ if not match or not archive_milestone.is_real_file(path):
610
+ raise ArchiveError("final gap artifacts must be real final-gap-N.md files")
611
+ numbered.append((int(match.group(1)), path))
612
+ numbered.sort(key=lambda item: item[0])
613
+ if [number for number, _ in numbered] != list(range(1, len(numbered) + 1)):
614
+ raise ArchiveError("final gap artifacts must be numbered contiguously")
615
+
616
+ for number, path in numbered:
617
+ lines = path.read_text(encoding="utf-8").splitlines()
618
+ headings = [FINAL_GAP_HEADING_PATTERN.fullmatch(line) for line in lines]
619
+ headings = [match for match in headings if match]
620
+ if (
621
+ len(headings) != 1
622
+ or int(headings[0].group(1)) != number
623
+ or archive_milestone.contains_placeholder(headings[0].group(2))
624
+ ):
625
+ raise ArchiveError(f"{path.name} heading does not match its number")
626
+ gap_head = completed_field(lines, "Reviewed HEAD:", path.name).lower()
627
+ if gap_head != reviewed_head:
628
+ raise ArchiveError(f"{path.name} Reviewed HEAD does not match FINAL.md")
629
+ if completed_field(lines, "Gap verdict:", path.name) != "pass":
630
+ raise ArchiveError(f"{path.name} gap review did not pass")
631
+ risk = completed_field(lines, "Risk:", path.name)
632
+ if risk.casefold() == "none":
633
+ raise ArchiveError(f"{path.name} does not name a completed risk")
634
+ if (
635
+ " ".join(headings[0].group(2).split()).casefold()
636
+ != " ".join(risk.split()).casefold()
637
+ ):
638
+ raise ArchiveError(f"{path.name} heading risk does not match Risk field")
639
+ completed_field(lines, "Waves checked:", path.name)
640
+ evidence = section_lines(lines, "## Checked evidence", path.name)
641
+ check = completed_bullet_field(evidence, "Check", path.name)
642
+ observed = completed_bullet_field(evidence, "Observed", path.name)
643
+ reference = completed_bullet_field(evidence, "Reference", path.name)
644
+ if observed.casefold() == "none":
645
+ raise ArchiveError(f"{path.name} checked evidence has no observed result")
646
+ if check.casefold() == "none" and reference.casefold() == "none":
647
+ raise ArchiveError(f"{path.name} checked evidence has no command or reference")
648
+ finding = section_lines(lines, "## Finding", path.name)
649
+ found = completed_bullet_field(finding, "Found", path.name)
650
+ fix_direction = completed_bullet_field(finding, "Fix direction", path.name)
651
+ if found.casefold() == "none":
652
+ raise ArchiveError(f"{path.name} finding has no observed result")
653
+ if fix_direction.casefold() != "none":
654
+ raise ArchiveError(f"{path.name} pass must use Fix direction: none")
655
+
656
+
657
+ def meaningful_review_evidence(lines: Sequence[str], marker: str, task_text: str = "") -> bool:
658
+ try:
659
+ from check_handoffs import task_review_observation
660
+ except ImportError: # pragma: no cover - package import used by tests
661
+ from scripts.check_handoffs import task_review_observation
662
+
663
+ prefix = f"- {marker} "
664
+ evidence = []
665
+ for line in lines:
666
+ stripped = line.strip()
667
+ if not stripped.startswith(prefix):
668
+ continue
669
+ item = task_review_observation(stripped.removeprefix(prefix), task_text)
670
+ # Keep the code spans intact: unquoting first would drop a leading or
671
+ # trailing backtick and unbalance every span after it, so quoted code
672
+ # would read as an unfilled placeholder.
673
+ evidence.append(item.strip())
674
+ return bool(evidence) and all(
675
+ item.strip("`")
676
+ and item.strip("`").casefold() not in {"none", "n/a", "null"}
677
+ and archive_milestone.PLACEHOLDER_PATTERN.fullmatch(item.strip("`")) is None
678
+ # Blank the code spans before the scan, so a placeholder-shaped token
679
+ # that is quoted evidence is not read as an unfilled placeholder.
680
+ and re.search(
681
+ r"(?<!\w)<[a-zA-Z][^<>\n]*>",
682
+ archive_milestone.CODE_SPAN_PATTERN.sub(" ", item),
683
+ ) is None
684
+ for item in evidence
685
+ )
686
+
687
+
688
+ def archived_wave_tasks(
689
+ archive: Path, wave_numbers: Sequence[int]
690
+ ) -> dict[int, Sequence[tuple[str, str]]]:
691
+ tasks: dict[int, list[tuple[str, str]]] = {
692
+ wave: [] for wave in wave_numbers
693
+ }
694
+ seen = set()
695
+ for path in archive_milestone.canonical_task_files(archive / "tasks"):
696
+ content = path.read_text(encoding="utf-8")
697
+ task_id = archive_milestone.frontmatter_value(content, "id")
698
+ title = archive_milestone.frontmatter_value(content, "title")
699
+ wave_text = archive_milestone.frontmatter_value(content, "wave")
700
+ if task_id is None or title is None or wave_text is None:
701
+ raise ArchiveError(f"{path.name} is missing id, title, or wave")
702
+ if task_id in seen or not path.name.startswith(f"{task_id}-"):
703
+ raise ArchiveError(f"{path.name} has an invalid or repeated task id")
704
+ seen.add(task_id)
705
+ try:
706
+ wave = int(wave_text)
707
+ except ValueError as error:
708
+ raise ArchiveError(f"{path.name} has an invalid wave") from error
709
+ if wave not in tasks:
710
+ raise ArchiveError(f"{path.name} names unknown Wave {wave}")
711
+ if archive_milestone.contains_placeholder(title):
712
+ raise ArchiveError(f"{path.name} has an incomplete title")
713
+ tasks[wave].append((task_id, title))
714
+ for wave, members in tasks.items():
715
+ if not members:
716
+ raise ArchiveError(f"plan Wave {wave} has no task files")
717
+ return tasks
718
+
719
+
720
+ def plan_wave_criteria(
721
+ archive: Path,
722
+ plan_text: str,
723
+ wave_tasks: dict[int, Sequence[tuple[str, str]]],
724
+ ) -> dict[int, Sequence[tuple[str, str]]]:
725
+ intent_path = archive / "intent" / "INTENT.md"
726
+ if not intent_path.exists() and not intent_path.is_symlink():
727
+ return {wave: () for wave in wave_tasks}
728
+ criteria = archived_intent_criteria(archive)
729
+ lines = plan_text.splitlines()
730
+ if lines.count("## Intent coverage") != 1:
731
+ raise ArchiveError("PLAN.md requires one Intent coverage section")
732
+ coverage = section_lines(lines, "## Intent coverage", "PLAN.md")
733
+ rows = []
734
+ for line in coverage:
735
+ if not re.match(r"^\|\s*SC\d", line.strip()):
736
+ continue
737
+ match = PLAN_COVERAGE_ROW_PATTERN.fullmatch(line.strip())
738
+ if match is None:
739
+ raise ArchiveError("PLAN.md has a malformed Intent coverage row")
740
+ rows.append((match.group(1), match.group(2), match.group(3).strip()))
741
+ if not rows:
742
+ raise ArchiveError("PLAN.md Intent coverage has no rows")
743
+ known_tasks = {
744
+ task_id for tasks in wave_tasks.values() for task_id, _title in tasks
745
+ }
746
+ seen = set()
747
+ for sc_id, task_id, acceptance in rows:
748
+ number = int(sc_id.removeprefix("SC"))
749
+ if number not in criteria:
750
+ raise ArchiveError(f"PLAN.md Intent coverage names unknown {sc_id}")
751
+ if task_id not in known_tasks:
752
+ raise ArchiveError(f"PLAN.md Intent coverage names unknown {task_id}")
753
+ if not acceptance or archive_milestone.contains_placeholder(acceptance):
754
+ raise ArchiveError("PLAN.md Intent coverage has incomplete acceptance evidence")
755
+ if (sc_id, task_id, acceptance) in seen:
756
+ raise ArchiveError("PLAN.md Intent coverage repeats a row")
757
+ seen.add((sc_id, task_id, acceptance))
758
+ result = {}
759
+ for wave, tasks in wave_tasks.items():
760
+ task_ids = {task_id for task_id, _title in tasks}
761
+ owned_numbers = sorted(
762
+ {
763
+ int(sc_id.removeprefix("SC"))
764
+ for sc_id, task_id, _acceptance in rows
765
+ if task_id in task_ids
766
+ }
767
+ )
768
+ result[wave] = tuple(
769
+ (f"SC{number}", criteria[number]) for number in owned_numbers
770
+ )
771
+ return result
772
+
773
+
774
+ def validate_wave_review(
775
+ path: Path,
776
+ expected_tasks: Sequence[tuple[str, str]],
777
+ expected_criteria: Sequence[tuple[str, str]],
778
+ expected_depth: str,
779
+ expected_lens: Optional[str] = None,
780
+ task_texts: Optional[dict[str, str]] = None,
781
+ ) -> Sequence[str]:
782
+ lines = path.read_text(encoding="utf-8").splitlines()
783
+ depth = completed_field(lines, "Depth:", path.name)
784
+ if depth != expected_depth:
785
+ raise ArchiveError(f"{path.name} has an invalid review depth")
786
+ if expected_lens is not None:
787
+ lens = completed_field(lines, "Lens:", path.name)
788
+ if lens != expected_lens:
789
+ raise ArchiveError(f"{path.name} Lens field does not match its filename")
790
+ wave_verdict = completed_field(lines, "Wave verdict:", path.name)
791
+ reviewed = completed_field(lines, "Tasks reviewed:", path.name)
792
+ if not reviewed.isdigit() or int(reviewed) < 1:
793
+ raise ArchiveError(f"{path.name} requires a positive task count")
794
+
795
+ headings = []
796
+ for index, line in enumerate(lines):
797
+ match = WAVE_TASK_HEADING_PATTERN.fullmatch(line)
798
+ if match:
799
+ headings.append((index, match.group(1), match.group(2), match.group(3)))
800
+ if len(headings) != int(reviewed):
801
+ raise ArchiveError(f"{path.name} task count does not match Tasks reviewed")
802
+ task_ids = [task_id for _, task_id, _, _ in headings]
803
+ if len(task_ids) != len(set(task_ids)):
804
+ raise ArchiveError(f"{path.name} repeats a reviewed task")
805
+ actual_tasks = [(task_id, " ".join(title.split())) for _, task_id, title, _ in headings]
806
+ normalized_expected = [
807
+ (task_id, " ".join(title.split())) for task_id, title in expected_tasks
808
+ ]
809
+ if actual_tasks != normalized_expected:
810
+ raise ArchiveError(
811
+ f"{path.name} tasks and titles do not match its wave task files in order"
812
+ )
813
+
814
+ verdicts = []
815
+ for position, (heading_index, _task_id, _title, verdict) in enumerate(headings):
816
+ next_task = (
817
+ headings[position + 1][0]
818
+ if position + 1 < len(headings)
819
+ else len(lines)
820
+ )
821
+ next_section = next(
822
+ (
823
+ index
824
+ for index in range(heading_index + 1, next_task)
825
+ if lines[index].startswith("## ")
826
+ ),
827
+ next_task,
828
+ )
829
+ task_lines = lines[heading_index + 1 : next_section]
830
+ marker = "✅" if verdict == "pass" else "❌"
831
+ if not meaningful_review_evidence(task_lines, marker, (task_texts or {}).get(_task_id, "")):
832
+ raise ArchiveError(
833
+ f"{path.name} task {task_ids[position]} lacks non-placeholder evidence"
834
+ )
835
+ verdicts.append(verdict)
836
+
837
+ coverage_indexes = [
838
+ index for index, line in enumerate(lines) if line == "## Intent coverage"
839
+ ]
840
+ if not expected_criteria:
841
+ if coverage_indexes:
842
+ start = coverage_indexes[0] + 1
843
+ end = next(
844
+ (
845
+ index
846
+ for index in range(start, len(lines))
847
+ if lines[index].startswith("## ")
848
+ ),
849
+ len(lines),
850
+ )
851
+ if any(WAVE_SC_HEADING_PATTERN.fullmatch(line) for line in lines[start:end]):
852
+ raise ArchiveError(f"{path.name} names unowned success criteria")
853
+ return verdicts
854
+ if len(coverage_indexes) != 1:
855
+ raise ArchiveError(f"{path.name} requires one Intent coverage section")
856
+ coverage_start = coverage_indexes[0] + 1
857
+ coverage_end = next(
858
+ (
859
+ index
860
+ for index in range(coverage_start, len(lines))
861
+ if lines[index].startswith("## ")
862
+ ),
863
+ len(lines),
864
+ )
865
+ criterion_headings = []
866
+ for index in range(coverage_start, coverage_end):
867
+ line = lines[index]
868
+ if not line.startswith("### "):
869
+ continue
870
+ match = WAVE_SC_HEADING_PATTERN.fullmatch(line)
871
+ if match is None:
872
+ raise ArchiveError(f"{path.name} has an invalid Intent coverage heading")
873
+ criterion_headings.append(
874
+ (index, match.group(1), " ".join(match.group(2).split()), match.group(3))
875
+ )
876
+ actual = [(sc_id, text) for _, sc_id, text, _ in criterion_headings]
877
+ normalized_expected_criteria = [
878
+ (sc_id, " ".join(text.split())) for sc_id, text in expected_criteria
879
+ ]
880
+ if actual != normalized_expected_criteria:
881
+ raise ArchiveError(
882
+ f"{path.name} Intent coverage does not match its owned success criteria"
883
+ )
884
+ for position, (heading_index, sc_id, _text, verdict) in enumerate(
885
+ criterion_headings
886
+ ):
887
+ section_end = (
888
+ criterion_headings[position + 1][0]
889
+ if position + 1 < len(criterion_headings)
890
+ else coverage_end
891
+ )
892
+ marker = "✅" if verdict == "pass" else "❌"
893
+ if not meaningful_review_evidence(
894
+ lines[heading_index + 1 : section_end], marker
895
+ ):
896
+ raise ArchiveError(f"{path.name} {sc_id} lacks non-placeholder evidence")
897
+ if verdict == "fail" and wave_verdict == "pass":
898
+ raise ArchiveError(
899
+ f"{path.name} passes while owned success criterion {sc_id} fails"
900
+ )
901
+ return verdicts
902
+
903
+
904
+ def plan_wave_depths(plan_text: str) -> tuple[Sequence[int], dict[int, str]]:
905
+ wave_matches = list(re.finditer(r"^## Wave (\d+)\b", plan_text, re.MULTILINE))
906
+ wave_numbers = [int(match.group(1)) for match in wave_matches]
907
+ if not wave_numbers or wave_numbers != list(range(1, len(wave_numbers) + 1)):
908
+ raise ArchiveError("plan wave numbers must be ordered and contiguous")
909
+ wave_depths = {}
910
+ for index, match in enumerate(wave_matches):
911
+ section_end = (
912
+ wave_matches[index + 1].start()
913
+ if index + 1 < len(wave_matches)
914
+ else len(plan_text)
915
+ )
916
+ section = plan_text[match.end() : section_end]
917
+ depths = re.findall(
918
+ r"^Review depth:\s*(\S+)",
919
+ section,
920
+ re.MULTILINE,
921
+ )
922
+ wave = int(match.group(1))
923
+ if len(depths) > 1:
924
+ raise ArchiveError(f"plan wave {wave} has multiple review depths")
925
+ depth = depths[0] if depths else "full"
926
+ if depth not in {"full", "deep", "verify-only"}:
927
+ raise ArchiveError(f"plan wave {wave} has an invalid review depth")
928
+ wave_depths[wave] = depth
929
+ return wave_numbers, wave_depths
930
+
931
+
932
+ def review_cycle_counts(archive: Path) -> Sequence[int]:
933
+ plan = archive / "plan" / "PLAN.md"
934
+ plan_text = plan.read_text(encoding="utf-8")
935
+ wave_numbers, wave_depths = plan_wave_depths(plan_text)
936
+ wave_tasks = archived_wave_tasks(archive, wave_numbers)
937
+ task_texts = {
938
+ path.name.split("-", 1)[0]: path.read_text(encoding="utf-8")
939
+ for path in archive_milestone.canonical_task_files(archive / "tasks")
940
+ }
941
+ wave_criteria = plan_wave_criteria(archive, plan_text, wave_tasks)
942
+
943
+ artifacts = {}
944
+ panel_config = archive_milestone.plan_review_panel_config(plan_text)
945
+ panel_skip_receipts = archive_milestone.canonical_wave_panel_skip_files(archive / "review")
946
+ repair_receipts = archive_milestone.canonical_wave_repair_files(archive / "review")
947
+ repair_introductions = {}
948
+ for receipt in repair_receipts:
949
+ task_text = task_texts.get(receipt.task)
950
+ if task_text is None or "## Review findings" not in task_text:
951
+ raise ArchiveError(
952
+ f"{receipt.path.name} does not name an archived repair task"
953
+ )
954
+ if receipt.task in repair_introductions:
955
+ raise ArchiveError(f"multiple repair receipts name {receipt.task}")
956
+ repair_introductions[receipt.task] = (receipt.wave, receipt.cycle)
957
+ wave_artifacts = archive_milestone.canonical_wave_files(archive / "review")
958
+ archive_milestone.validate_wave_skeptic_files(archive / "review", wave_depths, wave_artifacts)
959
+ for path, wave, cycle, lens in wave_artifacts:
960
+ if lens == "panel":
961
+ artifacts.setdefault(wave, {}).setdefault(cycle, {})[lens] = path
962
+ continue
963
+ lines = path.read_text(encoding="utf-8").splitlines()
964
+ expected_heading = f"# Review — wave {wave}, cycle {cycle}"
965
+ if lines.count(expected_heading) != 1:
966
+ raise ArchiveError(f"{path.name} heading does not match its filename")
967
+ recorded_cycle = completed_field(lines, "Cycle:", path.name)
968
+ if recorded_cycle != str(cycle):
969
+ raise ArchiveError(f"{path.name} Cycle field does not match its filename")
970
+ verdict = completed_field(lines, "Wave verdict:", path.name)
971
+ if verdict not in {"pass", "blocked"}:
972
+ raise ArchiveError(f"{path.name} has an invalid wave verdict")
973
+ artifacts.setdefault(wave, {}).setdefault(cycle, {})[lens] = path
974
+ if sorted(artifacts) != wave_numbers:
975
+ raise ArchiveError("review cycle waves do not match PLAN.md")
976
+ for receipt in repair_receipts:
977
+ if receipt.wave not in artifacts or receipt.cycle not in artifacts[receipt.wave]:
978
+ raise ArchiveError(
979
+ f"{receipt.path.name} does not match an archived review cycle"
980
+ )
981
+ if (
982
+ any(task_id == receipt.task for task_id, _ in wave_tasks[receipt.wave])
983
+ and max(artifacts[receipt.wave]) <= receipt.cycle
984
+ ):
985
+ raise ArchiveError(
986
+ f"{receipt.path.name} requires a later review cycle for its repair task"
987
+ )
988
+
989
+ counts = []
990
+ for wave in wave_numbers:
991
+ cycles = artifacts[wave]
992
+ if sorted(cycles) != list(range(1, max(cycles) + 1)):
993
+ raise ArchiveError(f"review cycles for wave {wave} are not contiguous")
994
+ wave_verdicts = {}
995
+ task_verdicts = {}
996
+ for cycle, reviews in cycles.items():
997
+ depth = wave_depths[wave]
998
+ gate = {lens: path for lens, path in reviews.items() if lens != "panel"}
999
+ if depth == "deep":
1000
+ if set(gate) != {"contract", "adversarial"}:
1001
+ raise ArchiveError(
1002
+ f"wave {wave} cycle {cycle} requires contract and adversarial "
1003
+ "reviews for PLAN depth deep"
1004
+ )
1005
+ expected = tuple(
1006
+ (gate[lens], depth, lens) for lens in ("contract", "adversarial")
1007
+ )
1008
+ else:
1009
+ if set(gate) != {None}:
1010
+ raise ArchiveError(
1011
+ f"wave {wave} cycle {cycle} requires one base review "
1012
+ f"for PLAN depth {depth}"
1013
+ )
1014
+ expected = ((gate[None], depth, None),)
1015
+ receipt = panel_skip_receipts.pop((wave, cycle), None)
1016
+ if panel_config["mode"] != "off" and depth in {"full", "deep"}:
1017
+ has_panel = "panel" in reviews
1018
+ if has_panel == (receipt is not None):
1019
+ raise ArchiveError(
1020
+ f"wave {wave} cycle {cycle} requires exactly one panel artifact "
1021
+ "or canonical skipped-panel receipt"
1022
+ )
1023
+ if receipt is not None:
1024
+ archive_milestone.validate_panel_skip_receipt(
1025
+ receipt, str(panel_config["mode"])
1026
+ )
1027
+ elif receipt is not None:
1028
+ raise ArchiveError(f"unexpected skipped-panel receipt: {receipt.name}")
1029
+
1030
+ wave_verdicts[cycle] = []
1031
+ task_verdicts[cycle] = []
1032
+ expected_tasks = [
1033
+ task
1034
+ for task in wave_tasks[wave]
1035
+ if task[0] not in repair_introductions
1036
+ or repair_introductions[task[0]][0] != wave
1037
+ or cycle > repair_introductions[task[0]][1]
1038
+ ]
1039
+ for path, depth, lens in expected:
1040
+ lines = path.read_text(encoding="utf-8").splitlines()
1041
+ wave_verdicts[cycle].append(completed_field(lines, "Wave verdict:", path.name))
1042
+ task_verdicts[cycle].extend(
1043
+ validate_wave_review(
1044
+ path,
1045
+ expected_tasks,
1046
+ wave_criteria[wave],
1047
+ depth,
1048
+ lens,
1049
+ task_texts,
1050
+ )
1051
+ )
1052
+
1053
+ last_cycle = max(cycles)
1054
+ if any(verdict != "pass" for verdict in wave_verdicts[last_cycle]):
1055
+ raise ArchiveError(f"last review cycle for wave {wave} did not pass")
1056
+ if any(task_verdict != "pass" for task_verdict in task_verdicts[last_cycle]):
1057
+ raise ArchiveError(f"last review cycle for wave {wave} has a failed task")
1058
+ counts.append(last_cycle)
1059
+ if panel_skip_receipts:
1060
+ names = ", ".join(path.name for path in panel_skip_receipts.values())
1061
+ raise ArchiveError(f"skipped-panel receipts do not match review cycles: {names}")
1062
+ return counts
1063
+
1064
+
1065
+ def manifest_cell(value: str) -> str:
1066
+ return value.replace("\\|", "|").replace("|", "\\|")
1067
+
1068
+
1069
+ def archive_file_inventory(archive: Path) -> Sequence[str]:
1070
+ contents = []
1071
+ for path in archive.rglob("*"):
1072
+ if path.is_symlink():
1073
+ raise ArchiveError(f"archive contents must not be symlinks: {path}")
1074
+ if path.is_file() and path.name != "MANIFEST.md":
1075
+ contents.append(path.relative_to(archive).as_posix())
1076
+ return sorted(contents)
1077
+
1078
+
1079
+ def render_manifest(repo: Path) -> dict:
1080
+ project = repo.resolve()
1081
+ active_root = archive_milestone.require_project_layout(project)
1082
+ state, _, loaded_state_path = archive_milestone.strict_state(project)
1083
+ if loaded_state_path.resolve() != (active_root / "STATE.md").resolve():
1084
+ raise ArchiveError("strict STATE.md loader returned an unexpected path")
1085
+ require_transaction_context(project, state, ("ship", "shipped"))
1086
+ phase = state.phase
1087
+ status = state.status
1088
+ if (phase, status) not in {("ship", "active"), ("shipped", "done")}:
1089
+ raise ArchiveError(
1090
+ "manifest rendering requires ship/active or uncommitted shipped/done"
1091
+ )
1092
+
1093
+ configured = state.archive
1094
+ if configured is None:
1095
+ raise ArchiveError("STATE.md does not name the archive transaction")
1096
+ archive = archive_milestone.archive_from_state(project, configured)
1097
+ archive_milestone.require_archive_milestone(archive.name, state)
1098
+ archive_milestone.require_uncommitted_archive(project, configured)
1099
+ if archive.is_symlink() or not archive.is_dir():
1100
+ raise ArchiveError("archive is missing")
1101
+ archive_milestone.require_canonical_archive(archive)
1102
+ archive_milestone.require_clean_active_root(active_root, archive)
1103
+
1104
+ reviewed_head, criteria = parse_final_review(archive)
1105
+ validate_gap_reviews(archive, reviewed_head)
1106
+ tasks, attested = archive_milestone.landed_task_evidence(project, archive / "tasks", reviewed_head)
1107
+ cycles = review_cycle_counts(archive)
1108
+ carried_forward = archive_milestone.pending_ruling_count(archive / "research" / "DOCS-AUDIT.md")
1109
+ carry_text = (
1110
+ "none"
1111
+ if carried_forward == 0
1112
+ else f"{carried_forward} DOCS-AUDIT ruling(s)"
1113
+ )
1114
+ criteria_rows = "\n".join(
1115
+ f"| {manifest_cell(criterion)} | {verdict} | {manifest_cell(evidence)} |"
1116
+ for criterion, verdict, evidence in criteria
1117
+ )
1118
+ listed_contents = "\n".join(f"- {path}" for path in archive_file_inventory(archive))
1119
+ cycle_text = "/".join(str(cycle) for cycle in cycles)
1120
+ count_text = (
1121
+ f"{len(cycles)} Tasks: {len(tasks)} done / {len(tasks)} total "
1122
+ f"Review cycles used: {cycle_text}"
1123
+ + (f" Attested: {attested}" if attested else "")
1124
+ )
1125
+ content = f"""# Archive — {archive.name}
1126
+
1127
+ Milestone: {state.milestone}
1128
+ Shipped: {date.today().isoformat()}
1129
+ Final verdict: all criteria met; project verify passed
1130
+ Waves: {count_text}
1131
+ Carried forward: {carry_text}
1132
+
1133
+ ## Success criteria at ship
1134
+
1135
+ | Criterion | Verdict | Evidence |
1136
+ |-----------|---------|----------|
1137
+ {criteria_rows}
1138
+
1139
+ ## Contents
1140
+
1141
+ {listed_contents}
1142
+
1143
+ ## Notes
1144
+
1145
+ - none
1146
+ """
1147
+ atomic_replace(archive / "MANIFEST.md", archive / MANIFEST_TEMP_NAME, content)
1148
+ validate_manifest(project, archive, state)
1149
+ return {"archive": configured, "reviewed_head": reviewed_head}
1150
+
1151
+
1152
+ def validate_manifest(project: Path, archive: Path, state: PipelineState) -> tuple:
1153
+ manifest = archive / "MANIFEST.md"
1154
+ if manifest.is_symlink() or not manifest.is_file():
1155
+ raise ArchiveError("archive MANIFEST.md must be a real file")
1156
+ content = manifest.read_text(encoding="utf-8")
1157
+ lines = content.splitlines()
1158
+
1159
+ if lines.count(f"# Archive — {archive.name}") != 1:
1160
+ raise ArchiveError("manifest header does not match the archive name")
1161
+ fields = {field: completed_field(lines, field, "manifest") for field in MANIFEST_FIELDS}
1162
+ for heading in MANIFEST_HEADINGS:
1163
+ if lines.count(heading) != 1:
1164
+ raise ArchiveError(f"manifest requires one {heading} section")
1165
+
1166
+ milestone = state.milestone
1167
+ if fields["Milestone:"] != milestone:
1168
+ raise ArchiveError(
1169
+ f"manifest milestone {fields['Milestone:']!r} does not match STATE.milestone {milestone!r}"
1170
+ )
1171
+ try:
1172
+ date.fromisoformat(fields["Shipped:"])
1173
+ except ValueError:
1174
+ raise ArchiveError("manifest Shipped field must be an ISO date")
1175
+ if fields["Final verdict:"] != "all criteria met; project verify passed":
1176
+ raise ArchiveError("manifest final verdict does not record the passed final gate")
1177
+
1178
+ reviewed_head, final_criteria = parse_final_review(archive)
1179
+ validate_gap_reviews(archive, reviewed_head)
1180
+ task_files, attested = archive_milestone.landed_task_evidence(project, archive / "tasks", reviewed_head)
1181
+ cycle_counts = review_cycle_counts(archive)
1182
+ wave_count = len(cycle_counts)
1183
+ counts = re.fullmatch(
1184
+ r"(\d+)\s+Tasks:\s+(\d+) done / (\d+) total\s+Review cycles used:\s+([1-9]\d*(?:/[1-9]\d*)*)"
1185
+ r"(?:\s+Attested:\s+([1-9]\d*))?",
1186
+ fields["Waves:"],
1187
+ )
1188
+ if not counts:
1189
+ raise ArchiveError("manifest Waves field has an invalid format")
1190
+ recorded_waves, done_tasks, total_tasks = (int(counts.group(index)) for index in (1, 2, 3))
1191
+ cycles = [int(value) for value in counts.group(4).split("/")]
1192
+ recorded_attested = int(counts.group(5) or 0)
1193
+ if (
1194
+ recorded_waves != wave_count
1195
+ or done_tasks != len(task_files)
1196
+ or total_tasks != len(task_files)
1197
+ or cycles != list(cycle_counts)
1198
+ or recorded_attested != attested
1199
+ ):
1200
+ raise ArchiveError("manifest wave, task, or review-cycle counts do not match the archive")
1201
+
1202
+ carried_forward = archive_milestone.pending_ruling_count(archive / "research" / "DOCS-AUDIT.md")
1203
+ expected_carry = (
1204
+ "none" if carried_forward == 0 else f"{carried_forward} DOCS-AUDIT ruling(s)"
1205
+ )
1206
+ if fields["Carried forward:"] != expected_carry:
1207
+ raise ArchiveError("manifest carried-forward count does not match DOCS-AUDIT.md")
1208
+
1209
+ criteria_start = lines.index("## Success criteria at ship") + 1
1210
+ criteria_rows = []
1211
+ criteria_line_indexes = set()
1212
+ for index, line in enumerate(lines[criteria_start:], criteria_start):
1213
+ if line.startswith("## "):
1214
+ break
1215
+ if not line.startswith("|"):
1216
+ continue
1217
+ cells = archive_milestone.split_manifest_row(line)
1218
+ if cells and (cells[0] == "Criterion" or set(cells[0]) <= {"-", ":"}):
1219
+ continue
1220
+ criteria_rows.append(cells)
1221
+ criteria_line_indexes.add(index)
1222
+ if not criteria_rows or any(
1223
+ len(row) != 3
1224
+ or not row[0]
1225
+ or row[1] != "met"
1226
+ or not row[2]
1227
+ or row[2] == "none"
1228
+ or any(archive_milestone.contains_placeholder(cell) for cell in row[1:])
1229
+ for row in criteria_rows
1230
+ ):
1231
+ raise ArchiveError("manifest success-criteria rows are incomplete")
1232
+ if [tuple(row) for row in criteria_rows] != list(final_criteria):
1233
+ raise ArchiveError("manifest success criteria do not match FINAL.md evidence in order")
1234
+
1235
+ # Exact validated criterion rows may quote CLI notation such as <integer>.
1236
+ # Their other cells were checked above; every remaining line stays guarded.
1237
+ placeholder_content = "\n".join(
1238
+ line for index, line in enumerate(lines) if index not in criteria_line_indexes
1239
+ ).replace("<!--", "").replace("-->", "")
1240
+ if archive_milestone.contains_placeholder(placeholder_content):
1241
+ raise ArchiveError("manifest contains an unfinished template placeholder")
1242
+
1243
+ contents_start = lines.index("## Contents") + 1
1244
+ listed = []
1245
+ for line in lines[contents_start:]:
1246
+ if line.startswith("## "):
1247
+ break
1248
+ if line.startswith("- "):
1249
+ listed.append(line[2:].strip().strip("`"))
1250
+
1251
+ if len(listed) != len(set(listed)):
1252
+ raise ArchiveError("manifest contents contain duplicate paths")
1253
+ for value in listed:
1254
+ path = PurePosixPath(value)
1255
+ if not value or path.is_absolute() or ".." in path.parts or value == "MANIFEST.md":
1256
+ raise ArchiveError(f"manifest contains an invalid path: {value}")
1257
+
1258
+ actual = archive_file_inventory(archive)
1259
+ if sorted(listed) != actual:
1260
+ missing = sorted(set(actual) - set(listed))
1261
+ extra = sorted(set(listed) - set(actual))
1262
+ raise ArchiveError(f"manifest contents mismatch; missing={missing}, extra={extra}")
1263
+
1264
+ notes_start = lines.index("## Notes") + 1
1265
+ notes = []
1266
+ for line in lines[notes_start:]:
1267
+ if line.startswith("## "):
1268
+ break
1269
+ if line.startswith("- "):
1270
+ notes.append(line[2:].strip())
1271
+ if not notes or any(not note or archive_milestone.contains_placeholder(note) for note in notes):
1272
+ raise ArchiveError("manifest Notes section is incomplete")
1273
+ return actual, reviewed_head
1274
+
1275
+
1276
+ # archive_milestone imports this module, so the parent is resolved after the
1277
+ # definitions above. Parent functions are looked up on the module at call time
1278
+ # so patches applied to archive_milestone stay visible here.
1279
+ if __package__: # imported as scripts.discussion_validate
1280
+ from . import archive_milestone
1281
+ from .archive_milestone import (
1282
+ ANSWER_HEADING_PATTERN,
1283
+ CARRY_TEMP_NAME,
1284
+ DIALOGUE_HEADING_PATTERN,
1285
+ DISCUSSION_FILES,
1286
+ DISCUSSION_PHASES,
1287
+ DISCUSSION_TRANSACTION_NAME,
1288
+ DISPOSITION_HEADING_PATTERN,
1289
+ EMPTY_DISCUSSION_FILES,
1290
+ FINAL_CRITERION_PATTERN,
1291
+ FINAL_GAP_FILE_PATTERN,
1292
+ FINAL_GAP_HEADING_PATTERN,
1293
+ HTML_COMMENT_PATTERN,
1294
+ INTENT_CRITERION_PATTERN,
1295
+ MANIFEST_FIELDS,
1296
+ MANIFEST_HEADINGS,
1297
+ MANIFEST_TEMP_NAME,
1298
+ PLAN_COVERAGE_ROW_PATTERN,
1299
+ WAVE_SC_HEADING_PATTERN,
1300
+ WAVE_TASK_HEADING_PATTERN,
1301
+ ArchiveError,
1302
+ )
1303
+ else: # standalone script or sibling import
1304
+ import archive_milestone
1305
+ from archive_milestone import (
1306
+ ANSWER_HEADING_PATTERN,
1307
+ CARRY_TEMP_NAME,
1308
+ DIALOGUE_HEADING_PATTERN,
1309
+ DISCUSSION_FILES,
1310
+ DISCUSSION_PHASES,
1311
+ DISCUSSION_TRANSACTION_NAME,
1312
+ DISPOSITION_HEADING_PATTERN,
1313
+ EMPTY_DISCUSSION_FILES,
1314
+ FINAL_CRITERION_PATTERN,
1315
+ FINAL_GAP_FILE_PATTERN,
1316
+ FINAL_GAP_HEADING_PATTERN,
1317
+ HTML_COMMENT_PATTERN,
1318
+ INTENT_CRITERION_PATTERN,
1319
+ MANIFEST_FIELDS,
1320
+ MANIFEST_HEADINGS,
1321
+ MANIFEST_TEMP_NAME,
1322
+ PLAN_COVERAGE_ROW_PATTERN,
1323
+ WAVE_SC_HEADING_PATTERN,
1324
+ WAVE_TASK_HEADING_PATTERN,
1325
+ ArchiveError,
1326
+ )