@opengsd/gsd-path 1.3.1 → 1.5.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 (481) hide show
  1. package/AGENTS.md +2 -207
  2. package/CHANGELOG.md +331 -69
  3. package/DOCS.md +25 -9
  4. package/FULL.md +12 -5
  5. package/GUIDE.md +1 -1
  6. package/HOOKS.md +104 -14
  7. package/QUICK.md +5 -2
  8. package/README.md +131 -111
  9. package/RUNTIME.md +2 -2
  10. package/UPDATE.md +61 -11
  11. package/WORKFLOW.md +44 -7
  12. package/package.json +13 -9
  13. package/platforms/shared-agents/dispatch.md +13 -0
  14. package/scripts/_common.py +619 -18
  15. package/scripts/archive_milestone.py +160 -86
  16. package/scripts/bootstrap_repository.py +8 -5
  17. package/scripts/build_recovery.py +100 -32
  18. package/scripts/build_state.py +143 -24
  19. package/scripts/check_docs_audit.py +91 -7
  20. package/scripts/check_handoffs.py +662 -50
  21. package/scripts/check_task_briefs.py +338 -30
  22. package/scripts/check_update.py +2 -2
  23. package/scripts/core_hook_gate.py +10 -1
  24. package/scripts/core_hook_settings.py +11 -4
  25. package/scripts/detect_project.py +587 -32
  26. package/scripts/discussion_records.py +19 -13
  27. package/scripts/discussion_validate.py +152 -50
  28. package/scripts/dispatch_driver.py +501 -46
  29. package/scripts/git_guard.py +118 -31
  30. package/scripts/guard_hook.py +1567 -194
  31. package/scripts/install.mjs +77 -46
  32. package/scripts/install.py +611 -50
  33. package/scripts/integration.py +580 -25
  34. package/scripts/isolation.py +1438 -57
  35. package/scripts/lean_verification.py +99 -19
  36. package/scripts/loop_run.py +31 -29
  37. package/scripts/members.py +706 -0
  38. package/scripts/migrate_core.py +2 -2
  39. package/scripts/path_config.py +5 -5
  40. package/scripts/pipeline_diagnose.py +82 -31
  41. package/scripts/pipeline_git.py +124 -34
  42. package/scripts/pipeline_state.py +287 -87
  43. package/scripts/pipeline_undo.py +218 -30
  44. package/scripts/promote_lookahead.py +1 -1
  45. package/scripts/review_panel.py +1 -1
  46. package/scripts/runtime_store.py +55 -11
  47. package/scripts/skill-resources.json +37 -5
  48. package/scripts/state_checkpoint.py +135 -44
  49. package/scripts/state_promote.py +2 -0
  50. package/scripts/status_runtime.py +58 -10
  51. package/scripts/sync_skill_resources.py +8 -7
  52. package/scripts/task_context.py +12 -3
  53. package/scripts/token_budget.py +12 -8
  54. package/scripts/wizard.mjs +1 -0
  55. package/scripts/workflow_run.py +107 -30
  56. package/scripts/worktree_paths.py +72 -10
  57. package/skills/gsd-path/BUILD.md +7 -1
  58. package/skills/gsd-path/DECIDE.md +11 -4
  59. package/skills/gsd-path/DEFINE.md +36 -4
  60. package/skills/gsd-path/DOCS-AUDIT.md +6 -4
  61. package/skills/gsd-path/INSPECT.md +5 -4
  62. package/skills/gsd-path/PLAN.md +18 -4
  63. package/skills/gsd-path/RESEARCH.md +13 -1
  64. package/skills/gsd-path/ROADMAP.md +2 -1
  65. package/skills/gsd-path/SHIP.md +33 -11
  66. package/skills/gsd-path/SKILL.md +45 -1
  67. package/skills/gsd-path/references/build-native.md +63 -15
  68. package/skills/gsd-path/references/codebase-mapper.md +2 -1
  69. package/skills/gsd-path/references/dispatch.md +13 -0
  70. package/skills/gsd-path/references/docs-auditor.md +7 -1
  71. package/skills/gsd-path/references/operating-rules.md +214 -0
  72. package/skills/gsd-path/references/planner.md +22 -4
  73. package/skills/gsd-path/references/reviewer-final.md +20 -2
  74. package/skills/gsd-path/references/reviewer-wave.md +18 -5
  75. package/skills/gsd-path/references/reviewer.md +3 -1
  76. package/skills/gsd-path/references/spec-probes.md +150 -0
  77. package/skills/gsd-path/scripts/_common.py +619 -18
  78. package/skills/gsd-path/scripts/archive_milestone.py +160 -86
  79. package/skills/gsd-path/scripts/bootstrap_repository.py +8 -5
  80. package/skills/gsd-path/scripts/build_recovery.py +100 -32
  81. package/skills/gsd-path/scripts/build_state.py +143 -24
  82. package/skills/gsd-path/scripts/check_docs_audit.py +91 -7
  83. package/skills/gsd-path/scripts/check_handoffs.py +662 -50
  84. package/skills/gsd-path/scripts/check_task_briefs.py +338 -30
  85. package/skills/gsd-path/scripts/check_update.py +2 -2
  86. package/skills/gsd-path/scripts/detect_project.py +587 -32
  87. package/skills/gsd-path/scripts/discussion_records.py +19 -13
  88. package/skills/gsd-path/scripts/discussion_validate.py +152 -50
  89. package/skills/gsd-path/scripts/dispatch_driver.py +501 -46
  90. package/skills/gsd-path/scripts/git_guard.py +118 -31
  91. package/skills/gsd-path/scripts/integration.py +580 -25
  92. package/skills/gsd-path/scripts/isolation.py +1438 -57
  93. package/skills/gsd-path/scripts/lean_verification.py +99 -19
  94. package/skills/gsd-path/scripts/loop_run.py +31 -29
  95. package/skills/gsd-path/scripts/members.py +706 -0
  96. package/skills/gsd-path/scripts/path_config.py +5 -5
  97. package/skills/gsd-path/scripts/pipeline_git.py +124 -34
  98. package/skills/gsd-path/scripts/pipeline_state.py +287 -87
  99. package/skills/gsd-path/scripts/promote_lookahead.py +1 -1
  100. package/skills/gsd-path/scripts/review_panel.py +1 -1
  101. package/skills/gsd-path/scripts/state_checkpoint.py +135 -44
  102. package/skills/gsd-path/scripts/state_promote.py +2 -0
  103. package/skills/gsd-path/scripts/status_runtime.py +58 -10
  104. package/skills/gsd-path/scripts/task_context.py +12 -3
  105. package/skills/gsd-path/scripts/token_budget.py +12 -8
  106. package/skills/gsd-path/scripts/workflow_run.py +107 -30
  107. package/skills/gsd-path/scripts/worktree_paths.py +72 -10
  108. package/skills/gsd-path/templates/codebase.md +1 -1
  109. package/skills/gsd-path/templates/docs-audit.md +6 -2
  110. package/skills/gsd-path/templates/final-review.md +16 -4
  111. package/skills/gsd-path/templates/gap-review.md +2 -1
  112. package/skills/gsd-path/templates/intent.md +30 -0
  113. package/skills/gsd-path/templates/patch-findings.md +3 -3
  114. package/skills/gsd-path/templates/plan.md +17 -2
  115. package/skills/gsd-path/templates/research-handoff.md +14 -3
  116. package/skills/gsd-path/templates/state.md +1 -1
  117. package/skills/gsd-path/templates/task.md +1 -0
  118. package/skills/gsd-path/templates/wave-review.md +16 -4
  119. package/skills/gsd-path-build/SKILL.md +7 -1
  120. package/skills/gsd-path-build/references/build-native.md +63 -15
  121. package/skills/gsd-path-build/references/dispatch.md +13 -0
  122. package/skills/gsd-path-build/references/operating-rules.md +214 -0
  123. package/skills/gsd-path-build/references/reviewer-final.md +20 -2
  124. package/skills/gsd-path-build/references/reviewer-wave.md +18 -5
  125. package/skills/gsd-path-build/references/reviewer.md +3 -1
  126. package/skills/gsd-path-build/references/spec-probes.md +150 -0
  127. package/skills/gsd-path-build/scripts/_common.py +619 -18
  128. package/skills/gsd-path-build/scripts/archive_milestone.py +160 -86
  129. package/skills/gsd-path-build/scripts/build_recovery.py +100 -32
  130. package/skills/gsd-path-build/scripts/build_state.py +143 -24
  131. package/skills/gsd-path-build/scripts/check_handoffs.py +662 -50
  132. package/skills/gsd-path-build/scripts/check_task_briefs.py +338 -30
  133. package/skills/gsd-path-build/scripts/detect_project.py +587 -32
  134. package/skills/gsd-path-build/scripts/discussion_records.py +19 -13
  135. package/skills/gsd-path-build/scripts/discussion_validate.py +152 -50
  136. package/skills/gsd-path-build/scripts/dispatch_driver.py +501 -46
  137. package/skills/gsd-path-build/scripts/integration.py +580 -25
  138. package/skills/gsd-path-build/scripts/isolation.py +1438 -57
  139. package/skills/gsd-path-build/scripts/loop_run.py +31 -29
  140. package/skills/gsd-path-build/scripts/members.py +706 -0
  141. package/skills/gsd-path-build/scripts/path_config.py +5 -5
  142. package/skills/gsd-path-build/scripts/pipeline_git.py +124 -34
  143. package/skills/gsd-path-build/scripts/pipeline_state.py +287 -87
  144. package/skills/gsd-path-build/scripts/promote_lookahead.py +1 -1
  145. package/skills/gsd-path-build/scripts/review_panel.py +1 -1
  146. package/skills/gsd-path-build/scripts/state_checkpoint.py +135 -44
  147. package/skills/gsd-path-build/scripts/state_promote.py +2 -0
  148. package/skills/gsd-path-build/scripts/task_context.py +12 -3
  149. package/skills/gsd-path-build/scripts/token_budget.py +12 -8
  150. package/skills/gsd-path-build/scripts/workflow_run.py +107 -30
  151. package/skills/gsd-path-build/scripts/worktree_paths.py +72 -10
  152. package/skills/gsd-path-build/templates/task.md +1 -0
  153. package/skills/gsd-path-build/templates/wave-review.md +16 -4
  154. package/skills/gsd-path-decide/SKILL.md +11 -4
  155. package/skills/gsd-path-decide/references/dispatch.md +13 -0
  156. package/skills/gsd-path-decide/references/operating-rules.md +214 -0
  157. package/skills/gsd-path-decide/scripts/_common.py +619 -18
  158. package/skills/gsd-path-decide/scripts/archive_milestone.py +160 -86
  159. package/skills/gsd-path-decide/scripts/build_recovery.py +100 -32
  160. package/skills/gsd-path-decide/scripts/check_handoffs.py +662 -50
  161. package/skills/gsd-path-decide/scripts/check_task_briefs.py +338 -30
  162. package/skills/gsd-path-decide/scripts/detect_project.py +587 -32
  163. package/skills/gsd-path-decide/scripts/discussion_records.py +19 -13
  164. package/skills/gsd-path-decide/scripts/discussion_validate.py +152 -50
  165. package/skills/gsd-path-decide/scripts/integration.py +580 -25
  166. package/skills/gsd-path-decide/scripts/isolation.py +1438 -57
  167. package/skills/gsd-path-decide/scripts/loop_run.py +31 -29
  168. package/skills/gsd-path-decide/scripts/members.py +706 -0
  169. package/skills/gsd-path-decide/scripts/path_config.py +5 -5
  170. package/skills/gsd-path-decide/scripts/pipeline_git.py +124 -34
  171. package/skills/gsd-path-decide/scripts/pipeline_state.py +287 -87
  172. package/skills/gsd-path-decide/scripts/promote_lookahead.py +1 -1
  173. package/skills/gsd-path-decide/scripts/review_panel.py +1 -1
  174. package/skills/gsd-path-decide/scripts/state_checkpoint.py +135 -44
  175. package/skills/gsd-path-decide/scripts/state_promote.py +2 -0
  176. package/skills/gsd-path-decide/scripts/worktree_paths.py +72 -10
  177. package/skills/gsd-path-define/SKILL.md +36 -4
  178. package/skills/gsd-path-define/references/operating-rules.md +214 -0
  179. package/skills/gsd-path-define/references/spec-probes.md +150 -0
  180. package/skills/gsd-path-define/scripts/_common.py +619 -18
  181. package/skills/gsd-path-define/scripts/archive_milestone.py +160 -86
  182. package/skills/gsd-path-define/scripts/build_recovery.py +100 -32
  183. package/skills/gsd-path-define/scripts/check_handoffs.py +662 -50
  184. package/skills/gsd-path-define/scripts/check_task_briefs.py +338 -30
  185. package/skills/gsd-path-define/scripts/detect_project.py +587 -32
  186. package/skills/gsd-path-define/scripts/discussion_records.py +19 -13
  187. package/skills/gsd-path-define/scripts/discussion_validate.py +152 -50
  188. package/skills/gsd-path-define/scripts/integration.py +580 -25
  189. package/skills/gsd-path-define/scripts/isolation.py +1438 -57
  190. package/skills/gsd-path-define/scripts/loop_run.py +31 -29
  191. package/skills/gsd-path-define/scripts/members.py +706 -0
  192. package/skills/gsd-path-define/scripts/path_config.py +5 -5
  193. package/skills/gsd-path-define/scripts/pipeline_git.py +124 -34
  194. package/skills/gsd-path-define/scripts/pipeline_state.py +287 -87
  195. package/skills/gsd-path-define/scripts/promote_lookahead.py +1 -1
  196. package/skills/gsd-path-define/scripts/review_panel.py +1 -1
  197. package/skills/gsd-path-define/scripts/state_checkpoint.py +135 -44
  198. package/skills/gsd-path-define/scripts/state_promote.py +2 -0
  199. package/skills/gsd-path-define/scripts/worktree_paths.py +72 -10
  200. package/skills/gsd-path-define/templates/intent.md +30 -0
  201. package/skills/gsd-path-define/templates/state.md +1 -1
  202. package/skills/gsd-path-discuss/SKILL.md +2 -1
  203. package/skills/gsd-path-discuss/references/operating-rules.md +214 -0
  204. package/skills/gsd-path-discuss/scripts/_common.py +619 -18
  205. package/skills/gsd-path-discuss/scripts/discussion_records.py +19 -13
  206. package/skills/gsd-path-discuss/scripts/loop_run.py +31 -29
  207. package/skills/gsd-path-discuss/scripts/path_config.py +5 -5
  208. package/skills/gsd-path-discuss/scripts/review_panel.py +1 -1
  209. package/skills/gsd-path-discuss/scripts/worktree_paths.py +72 -10
  210. package/skills/gsd-path-docs-audit/SKILL.md +6 -4
  211. package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
  212. package/skills/gsd-path-docs-audit/references/docs-auditor.md +7 -1
  213. package/skills/gsd-path-docs-audit/references/operating-rules.md +214 -0
  214. package/skills/gsd-path-docs-audit/scripts/_common.py +619 -18
  215. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +160 -86
  216. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +100 -32
  217. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +91 -7
  218. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +662 -50
  219. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +338 -30
  220. package/skills/gsd-path-docs-audit/scripts/detect_project.py +587 -32
  221. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +19 -13
  222. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +152 -50
  223. package/skills/gsd-path-docs-audit/scripts/integration.py +580 -25
  224. package/skills/gsd-path-docs-audit/scripts/isolation.py +1438 -57
  225. package/skills/gsd-path-docs-audit/scripts/loop_run.py +31 -29
  226. package/skills/gsd-path-docs-audit/scripts/members.py +706 -0
  227. package/skills/gsd-path-docs-audit/scripts/path_config.py +5 -5
  228. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +124 -34
  229. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +287 -87
  230. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +1 -1
  231. package/skills/gsd-path-docs-audit/scripts/review_panel.py +1 -1
  232. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +135 -44
  233. package/skills/gsd-path-docs-audit/scripts/state_promote.py +2 -0
  234. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +72 -10
  235. package/skills/gsd-path-docs-audit/templates/docs-audit.md +6 -2
  236. package/skills/gsd-path-forensics/SKILL.md +2 -1
  237. package/skills/gsd-path-forensics/references/operating-rules.md +214 -0
  238. package/skills/gsd-path-forensics/scripts/_common.py +619 -18
  239. package/skills/gsd-path-forensics/scripts/discussion_records.py +19 -13
  240. package/skills/gsd-path-forensics/scripts/loop_run.py +31 -29
  241. package/skills/gsd-path-forensics/scripts/path_config.py +5 -5
  242. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +82 -31
  243. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +218 -30
  244. package/skills/gsd-path-forensics/scripts/review_panel.py +1 -1
  245. package/skills/gsd-path-forensics/scripts/worktree_paths.py +72 -10
  246. package/skills/gsd-path-inspect/SKILL.md +5 -4
  247. package/skills/gsd-path-inspect/references/codebase-mapper.md +2 -1
  248. package/skills/gsd-path-inspect/references/dispatch.md +13 -0
  249. package/skills/gsd-path-inspect/references/docs-auditor.md +7 -1
  250. package/skills/gsd-path-inspect/references/operating-rules.md +214 -0
  251. package/skills/gsd-path-inspect/scripts/_common.py +619 -18
  252. package/skills/gsd-path-inspect/scripts/archive_milestone.py +160 -86
  253. package/skills/gsd-path-inspect/scripts/build_recovery.py +100 -32
  254. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +91 -7
  255. package/skills/gsd-path-inspect/scripts/check_handoffs.py +662 -50
  256. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +338 -30
  257. package/skills/gsd-path-inspect/scripts/detect_project.py +587 -32
  258. package/skills/gsd-path-inspect/scripts/discussion_records.py +19 -13
  259. package/skills/gsd-path-inspect/scripts/discussion_validate.py +152 -50
  260. package/skills/gsd-path-inspect/scripts/integration.py +580 -25
  261. package/skills/gsd-path-inspect/scripts/isolation.py +1438 -57
  262. package/skills/gsd-path-inspect/scripts/loop_run.py +31 -29
  263. package/skills/gsd-path-inspect/scripts/members.py +706 -0
  264. package/skills/gsd-path-inspect/scripts/path_config.py +5 -5
  265. package/skills/gsd-path-inspect/scripts/pipeline_git.py +124 -34
  266. package/skills/gsd-path-inspect/scripts/pipeline_state.py +287 -87
  267. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +1 -1
  268. package/skills/gsd-path-inspect/scripts/review_panel.py +1 -1
  269. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +135 -44
  270. package/skills/gsd-path-inspect/scripts/state_promote.py +2 -0
  271. package/skills/gsd-path-inspect/scripts/workflow_run.py +107 -30
  272. package/skills/gsd-path-inspect/scripts/worktree_paths.py +72 -10
  273. package/skills/gsd-path-inspect/templates/codebase.md +1 -1
  274. package/skills/gsd-path-inspect/templates/docs-audit.md +6 -2
  275. package/skills/gsd-path-inspect/templates/state.md +1 -1
  276. package/skills/gsd-path-loop/SKILL.md +2 -1
  277. package/skills/gsd-path-loop/references/operating-rules.md +214 -0
  278. package/skills/gsd-path-loop/scripts/_common.py +619 -18
  279. package/skills/gsd-path-loop/scripts/discussion_records.py +19 -13
  280. package/skills/gsd-path-loop/scripts/loop_run.py +31 -29
  281. package/skills/gsd-path-loop/scripts/path_config.py +5 -5
  282. package/skills/gsd-path-loop/scripts/review_panel.py +1 -1
  283. package/skills/gsd-path-loop/scripts/worktree_paths.py +72 -10
  284. package/skills/gsd-path-migrate/SKILL.md +2 -1
  285. package/skills/gsd-path-migrate/references/operating-rules.md +214 -0
  286. package/skills/gsd-path-migrate/scripts/_common.py +619 -18
  287. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +10 -1
  288. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +11 -4
  289. package/skills/gsd-path-migrate/scripts/discussion_records.py +19 -13
  290. package/skills/gsd-path-migrate/scripts/loop_run.py +31 -29
  291. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -2
  292. package/skills/gsd-path-migrate/scripts/path_config.py +5 -5
  293. package/skills/gsd-path-migrate/scripts/review_panel.py +1 -1
  294. package/skills/gsd-path-plan/SKILL.md +18 -4
  295. package/skills/gsd-path-plan/references/dispatch.md +13 -0
  296. package/skills/gsd-path-plan/references/operating-rules.md +214 -0
  297. package/skills/gsd-path-plan/references/planner.md +22 -4
  298. package/skills/gsd-path-plan/references/spec-probes.md +150 -0
  299. package/skills/gsd-path-plan/scripts/_common.py +619 -18
  300. package/skills/gsd-path-plan/scripts/archive_milestone.py +160 -86
  301. package/skills/gsd-path-plan/scripts/build_recovery.py +100 -32
  302. package/skills/gsd-path-plan/scripts/check_handoffs.py +662 -50
  303. package/skills/gsd-path-plan/scripts/check_task_briefs.py +338 -30
  304. package/skills/gsd-path-plan/scripts/detect_project.py +587 -32
  305. package/skills/gsd-path-plan/scripts/discussion_records.py +19 -13
  306. package/skills/gsd-path-plan/scripts/discussion_validate.py +152 -50
  307. package/skills/gsd-path-plan/scripts/integration.py +580 -25
  308. package/skills/gsd-path-plan/scripts/isolation.py +1438 -57
  309. package/skills/gsd-path-plan/scripts/loop_run.py +31 -29
  310. package/skills/gsd-path-plan/scripts/members.py +706 -0
  311. package/skills/gsd-path-plan/scripts/path_config.py +5 -5
  312. package/skills/gsd-path-plan/scripts/pipeline_git.py +124 -34
  313. package/skills/gsd-path-plan/scripts/pipeline_state.py +287 -87
  314. package/skills/gsd-path-plan/scripts/promote_lookahead.py +1 -1
  315. package/skills/gsd-path-plan/scripts/review_panel.py +1 -1
  316. package/skills/gsd-path-plan/scripts/state_checkpoint.py +135 -44
  317. package/skills/gsd-path-plan/scripts/state_promote.py +2 -0
  318. package/skills/gsd-path-plan/scripts/workflow_run.py +107 -30
  319. package/skills/gsd-path-plan/scripts/worktree_paths.py +72 -10
  320. package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
  321. package/skills/gsd-path-plan/templates/plan.md +17 -2
  322. package/skills/gsd-path-plan/templates/task.md +1 -0
  323. package/skills/gsd-path-research/SKILL.md +13 -1
  324. package/skills/gsd-path-research/references/dispatch.md +13 -0
  325. package/skills/gsd-path-research/references/operating-rules.md +214 -0
  326. package/skills/gsd-path-research/scripts/_common.py +619 -18
  327. package/skills/gsd-path-research/scripts/archive_milestone.py +160 -86
  328. package/skills/gsd-path-research/scripts/build_recovery.py +100 -32
  329. package/skills/gsd-path-research/scripts/check_handoffs.py +662 -50
  330. package/skills/gsd-path-research/scripts/check_task_briefs.py +338 -30
  331. package/skills/gsd-path-research/scripts/detect_project.py +587 -32
  332. package/skills/gsd-path-research/scripts/discussion_records.py +19 -13
  333. package/skills/gsd-path-research/scripts/discussion_validate.py +152 -50
  334. package/skills/gsd-path-research/scripts/integration.py +580 -25
  335. package/skills/gsd-path-research/scripts/isolation.py +1438 -57
  336. package/skills/gsd-path-research/scripts/loop_run.py +31 -29
  337. package/skills/gsd-path-research/scripts/members.py +706 -0
  338. package/skills/gsd-path-research/scripts/path_config.py +5 -5
  339. package/skills/gsd-path-research/scripts/pipeline_git.py +124 -34
  340. package/skills/gsd-path-research/scripts/pipeline_state.py +287 -87
  341. package/skills/gsd-path-research/scripts/promote_lookahead.py +1 -1
  342. package/skills/gsd-path-research/scripts/review_panel.py +1 -1
  343. package/skills/gsd-path-research/scripts/state_checkpoint.py +135 -44
  344. package/skills/gsd-path-research/scripts/state_promote.py +2 -0
  345. package/skills/gsd-path-research/scripts/worktree_paths.py +72 -10
  346. package/skills/gsd-path-research/templates/research-handoff.md +14 -3
  347. package/skills/gsd-path-roadmap/SKILL.md +2 -1
  348. package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
  349. package/skills/gsd-path-roadmap/references/operating-rules.md +214 -0
  350. package/skills/gsd-path-roadmap/scripts/_common.py +619 -18
  351. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +160 -86
  352. package/skills/gsd-path-roadmap/scripts/build_recovery.py +100 -32
  353. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +662 -50
  354. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +338 -30
  355. package/skills/gsd-path-roadmap/scripts/detect_project.py +587 -32
  356. package/skills/gsd-path-roadmap/scripts/discussion_records.py +19 -13
  357. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +152 -50
  358. package/skills/gsd-path-roadmap/scripts/integration.py +580 -25
  359. package/skills/gsd-path-roadmap/scripts/isolation.py +1438 -57
  360. package/skills/gsd-path-roadmap/scripts/loop_run.py +31 -29
  361. package/skills/gsd-path-roadmap/scripts/members.py +706 -0
  362. package/skills/gsd-path-roadmap/scripts/path_config.py +5 -5
  363. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +124 -34
  364. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +287 -87
  365. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +1 -1
  366. package/skills/gsd-path-roadmap/scripts/review_panel.py +1 -1
  367. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +135 -44
  368. package/skills/gsd-path-roadmap/scripts/state_promote.py +2 -0
  369. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +72 -10
  370. package/skills/gsd-path-ship/SKILL.md +33 -11
  371. package/skills/gsd-path-ship/references/dispatch.md +13 -0
  372. package/skills/gsd-path-ship/references/operating-rules.md +214 -0
  373. package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
  374. package/skills/gsd-path-ship/references/reviewer-wave.md +18 -5
  375. package/skills/gsd-path-ship/references/reviewer.md +3 -1
  376. package/skills/gsd-path-ship/references/spec-probes.md +150 -0
  377. package/skills/gsd-path-ship/scripts/_common.py +619 -18
  378. package/skills/gsd-path-ship/scripts/archive_milestone.py +160 -86
  379. package/skills/gsd-path-ship/scripts/build_recovery.py +100 -32
  380. package/skills/gsd-path-ship/scripts/build_state.py +143 -24
  381. package/skills/gsd-path-ship/scripts/check_handoffs.py +662 -50
  382. package/skills/gsd-path-ship/scripts/check_task_briefs.py +338 -30
  383. package/skills/gsd-path-ship/scripts/detect_project.py +587 -32
  384. package/skills/gsd-path-ship/scripts/discussion_records.py +19 -13
  385. package/skills/gsd-path-ship/scripts/discussion_validate.py +152 -50
  386. package/skills/gsd-path-ship/scripts/integration.py +580 -25
  387. package/skills/gsd-path-ship/scripts/isolation.py +1438 -57
  388. package/skills/gsd-path-ship/scripts/lean_verification.py +99 -19
  389. package/skills/gsd-path-ship/scripts/loop_run.py +31 -29
  390. package/skills/gsd-path-ship/scripts/members.py +706 -0
  391. package/skills/gsd-path-ship/scripts/path_config.py +5 -5
  392. package/skills/gsd-path-ship/scripts/pipeline_git.py +124 -34
  393. package/skills/gsd-path-ship/scripts/pipeline_state.py +287 -87
  394. package/skills/gsd-path-ship/scripts/promote_lookahead.py +1 -1
  395. package/skills/gsd-path-ship/scripts/review_panel.py +1 -1
  396. package/skills/gsd-path-ship/scripts/state_checkpoint.py +135 -44
  397. package/skills/gsd-path-ship/scripts/state_promote.py +2 -0
  398. package/skills/gsd-path-ship/scripts/workflow_run.py +107 -30
  399. package/skills/gsd-path-ship/scripts/worktree_paths.py +72 -10
  400. package/skills/gsd-path-ship/templates/final-review.md +16 -4
  401. package/skills/gsd-path-ship/templates/gap-review.md +2 -1
  402. package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
  403. package/skills/gsd-path-ship/templates/wave-review.md +16 -4
  404. package/skills/gsd-path-undo/SKILL.md +2 -1
  405. package/skills/gsd-path-undo/references/operating-rules.md +214 -0
  406. package/skills/gsd-path-undo/scripts/_common.py +619 -18
  407. package/skills/gsd-path-undo/scripts/discussion_records.py +19 -13
  408. package/skills/gsd-path-undo/scripts/loop_run.py +31 -29
  409. package/skills/gsd-path-undo/scripts/path_config.py +5 -5
  410. package/skills/gsd-path-undo/scripts/pipeline_undo.py +218 -30
  411. package/skills/gsd-path-undo/scripts/review_panel.py +1 -1
  412. package/skills/gsd-path-undo/scripts/worktree_paths.py +72 -10
  413. package/skills/path/BUILD.md +7 -1
  414. package/skills/path/DECIDE.md +11 -4
  415. package/skills/path/DEFINE.md +36 -4
  416. package/skills/path/DOCS-AUDIT.md +6 -4
  417. package/skills/path/INSPECT.md +5 -4
  418. package/skills/path/PLAN.md +18 -4
  419. package/skills/path/RESEARCH.md +13 -1
  420. package/skills/path/ROADMAP.md +2 -1
  421. package/skills/path/SHIP.md +33 -11
  422. package/skills/path/SKILL.md +45 -1
  423. package/skills/path/references/build-native.md +63 -15
  424. package/skills/path/references/codebase-mapper.md +2 -1
  425. package/skills/path/references/dispatch.md +13 -0
  426. package/skills/path/references/docs-auditor.md +7 -1
  427. package/skills/path/references/operating-rules.md +214 -0
  428. package/skills/path/references/planner.md +22 -4
  429. package/skills/path/references/reviewer-final.md +20 -2
  430. package/skills/path/references/reviewer-wave.md +18 -5
  431. package/skills/path/references/reviewer.md +3 -1
  432. package/skills/path/references/spec-probes.md +150 -0
  433. package/skills/path/scripts/_common.py +619 -18
  434. package/skills/path/scripts/archive_milestone.py +160 -86
  435. package/skills/path/scripts/bootstrap_repository.py +8 -5
  436. package/skills/path/scripts/build_recovery.py +100 -32
  437. package/skills/path/scripts/build_state.py +143 -24
  438. package/skills/path/scripts/check_docs_audit.py +91 -7
  439. package/skills/path/scripts/check_handoffs.py +662 -50
  440. package/skills/path/scripts/check_task_briefs.py +338 -30
  441. package/skills/path/scripts/check_update.py +2 -2
  442. package/skills/path/scripts/detect_project.py +587 -32
  443. package/skills/path/scripts/discussion_records.py +19 -13
  444. package/skills/path/scripts/discussion_validate.py +152 -50
  445. package/skills/path/scripts/dispatch_driver.py +501 -46
  446. package/skills/path/scripts/git_guard.py +118 -31
  447. package/skills/path/scripts/integration.py +580 -25
  448. package/skills/path/scripts/isolation.py +1438 -57
  449. package/skills/path/scripts/lean_verification.py +99 -19
  450. package/skills/path/scripts/loop_run.py +31 -29
  451. package/skills/path/scripts/members.py +706 -0
  452. package/skills/path/scripts/path_config.py +5 -5
  453. package/skills/path/scripts/pipeline_git.py +124 -34
  454. package/skills/path/scripts/pipeline_state.py +287 -87
  455. package/skills/path/scripts/promote_lookahead.py +1 -1
  456. package/skills/path/scripts/review_panel.py +1 -1
  457. package/skills/path/scripts/state_checkpoint.py +135 -44
  458. package/skills/path/scripts/state_promote.py +2 -0
  459. package/skills/path/scripts/status_runtime.py +58 -10
  460. package/skills/path/scripts/task_context.py +12 -3
  461. package/skills/path/scripts/token_budget.py +12 -8
  462. package/skills/path/scripts/workflow_run.py +107 -30
  463. package/skills/path/scripts/worktree_paths.py +72 -10
  464. package/skills/path/templates/codebase.md +1 -1
  465. package/skills/path/templates/docs-audit.md +6 -2
  466. package/skills/path/templates/final-review.md +16 -4
  467. package/skills/path/templates/gap-review.md +2 -1
  468. package/skills/path/templates/intent.md +30 -0
  469. package/skills/path/templates/patch-findings.md +3 -3
  470. package/skills/path/templates/plan.md +17 -2
  471. package/skills/path/templates/research-handoff.md +14 -3
  472. package/skills/path/templates/state.md +1 -1
  473. package/skills/path/templates/task.md +1 -0
  474. package/skills/path/templates/wave-review.md +16 -4
  475. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  476. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  477. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  478. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  479. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  480. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  481. package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
@@ -13,12 +13,16 @@ sys.dont_write_bytecode = True
13
13
  from pathlib import Path, PurePosixPath
14
14
  from typing import Dict, List, Optional, Sequence, Set, Tuple
15
15
 
16
- try:
17
- from pipeline_state import PipelineStateError, load_state
18
- import _common
19
- except ImportError: # pragma: no cover - package imports used by tests
20
- from scripts.pipeline_state import PipelineStateError, load_state
21
- from scripts import _common
16
+ if __package__:
17
+ from .pipeline_state import PipelineStateError, load_state
18
+ from . import _common
19
+ else:
20
+ try:
21
+ from pipeline_state import PipelineStateError, load_state
22
+ import _common
23
+ except ImportError: # pragma: no cover - package imports used by tests
24
+ from scripts.pipeline_state import PipelineStateError, load_state
25
+ from scripts import _common
22
26
 
23
27
 
24
28
  PIPELINE = "gsd-path/v2"
@@ -29,7 +33,11 @@ DISPATCH_PATTERN = re.compile(
29
33
  r"^- `(?P<dimension>[^`]+)` — (?P<status>dispatched|skipped) "
30
34
  r"→ (?P<output>[^—]+) — (?P<reason>.+)$"
31
35
  )
32
- QUESTION_PATTERN = re.compile(r"^- `\[RESEARCH\] (?P<question>[^`]+)` → `(?P<dimension>[^`]+)`$")
36
+ QUESTION_PATTERN = re.compile(
37
+ r"^- (?P<fence>`+)(?!`)(?P<body>.+?)(?<!`)(?P=fence)(?!`) → "
38
+ r"(?P<dimensions>`[^`,\s]+`(?:,\s*`[^`,\s]+`)*)$"
39
+ )
40
+ QUESTION_PREFIX = "[RESEARCH] "
33
41
  FINDING_PATTERN = re.compile(r"^### (?P<id>P\d{3}) — (?P<title>.+)$")
34
42
  CRITERION_LOCATOR_PATTERN = re.compile(r"^SC[1-9]\d*$")
35
43
  COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
@@ -52,6 +60,21 @@ WAVE_REVIEW_NAME = re.compile(
52
60
  r"wave-(?P<wave>[1-9]\d*)\.cycle(?P<cycle>[1-9]\d*)"
53
61
  r"(?:\.(?P<lens>contract|adversarial))?\.md$"
54
62
  )
63
+ VALID_REVIEW_DEPTHS = frozenset({"full", "deep", "verify-only"})
64
+
65
+
66
+ def _max_wave_cycle(root: Path, project_dir: str, wave: int) -> int:
67
+ review_dir = root / PurePosixPath(project_dir) / "review"
68
+ if not review_dir.is_dir():
69
+ return 0
70
+ maximum = 0
71
+ for path in review_dir.iterdir():
72
+ named = WAVE_REVIEW_NAME.fullmatch(path.name)
73
+ if named and int(named.group("wave")) == wave:
74
+ maximum = max(maximum, int(named.group("cycle")))
75
+ return maximum
76
+
77
+
55
78
  WAVE_SC_HEADING = re.compile(r"^### (SC[1-9]\d*) — (.+)$")
56
79
  WAVE_TASK_HEADING = re.compile(
57
80
  r"^## (?P<task>T\d{3}) — (?P<title>.+): (?P<verdict>pass|fail)$"
@@ -69,6 +92,23 @@ SURFACE_HEADING = re.compile(
69
92
  r"(?m)^### (?P<surface>\S.*?) — (?P<task>T\d{3})\s*$"
70
93
  )
71
94
  SURFACE_CONTRACT_HEADING = re.compile(r"(?m)^## Surface contract\s*$")
95
+ # Spec-reach probes (see references/spec-probes.md). The closed edge taxonomy
96
+ # names the classic black-box categories; `none` records a criterion with no
97
+ # data-shape edge and must be dismissed with a reason.
98
+ EDGE_CATEGORIES = (
99
+ "boundary",
100
+ "adjacency",
101
+ "empty",
102
+ "encoding",
103
+ "ordering",
104
+ "precision",
105
+ "idempotency",
106
+ "concurrency",
107
+ )
108
+ EDGE_ID_PATTERN = re.compile(r"^E[1-9]\d*$")
109
+ PROHIBITION_ID_PATTERN = re.compile(r"^N[1-9]\d*$")
110
+ CRITERION_DISPOSITION_PATTERN = re.compile(r"^criterion\s+(SC[1-9]\d*)$")
111
+ TABLE_SEPARATOR_PATTERN = re.compile(r"^:?-+:?$")
72
112
  GAP_NAME_PATTERN = re.compile(r"^final-gap-(?P<number>[1-9]\d*)\.md$")
73
113
  GAP_HEADING_PATTERN = re.compile(
74
114
  r"^# Gap Review — (?P<number>[1-9]\d*): (?P<risk>\S.*)$"
@@ -177,7 +217,7 @@ def _read(root: Path, relative: str) -> str:
177
217
  path = root / relative
178
218
  if not path.is_file() or path.is_symlink():
179
219
  raise HandoffError(f"missing real hand-off file: {relative}")
180
- return path.read_text(encoding="utf-8")
220
+ return _common.read_user_text(path)
181
221
 
182
222
 
183
223
  def _require_pipeline(root: Path, project_dir: str) -> Dict[str, str]:
@@ -357,7 +397,7 @@ def _research_questions(intent: str) -> List[str]:
357
397
  ]
358
398
 
359
399
 
360
- def _research_assignments(section: str) -> List[Tuple[str, str]]:
400
+ def _research_assignments(section: str) -> List[Tuple[str, List[str]]]:
361
401
  assignments = []
362
402
  lines = [line.strip() for line in section.splitlines() if line.strip()]
363
403
  if lines == ["- none"]:
@@ -368,7 +408,25 @@ def _research_assignments(section: str) -> List[Tuple[str, str]]:
368
408
  match = QUESTION_PATTERN.fullmatch(line)
369
409
  if match is None:
370
410
  raise HandoffError("RESEARCH.md has a malformed question assignment")
371
- assignments.append((match.group("question").strip(), match.group("dimension")))
411
+ body = match.group("body")
412
+ fence_length = len(match.group("fence"))
413
+ if any(len(run) == fence_length for run in re.findall(r"`+", body)):
414
+ raise HandoffError("RESEARCH.md has a malformed question assignment")
415
+ if (
416
+ body.startswith(" ")
417
+ and body.endswith(" ")
418
+ and body.strip(" ")
419
+ ):
420
+ body = body[1:-1]
421
+ if not body.startswith(QUESTION_PREFIX):
422
+ raise HandoffError("RESEARCH.md has a malformed question assignment")
423
+ question = body[len(QUESTION_PREFIX) :]
424
+ if not question:
425
+ raise HandoffError("RESEARCH.md has a malformed question assignment")
426
+ dimensions = re.findall(r"`([^`]+)`", match.group("dimensions"))
427
+ if len(dimensions) != len(set(dimensions)):
428
+ raise HandoffError("RESEARCH.md repeats a target in a question assignment")
429
+ assignments.append((question, dimensions))
372
430
  return assignments
373
431
 
374
432
 
@@ -377,7 +435,10 @@ def validate_research(
377
435
  ) -> Dict[str, object]:
378
436
  """Validate the research-to-synthesis hand-off and return its summary."""
379
437
 
380
- _require_state(root, "research", "active", project_dir)
438
+ # Research runs this before closing; decide runs it after its entry transition.
439
+ _require_state_one_of(
440
+ root, {("research", "active"), ("decide", "active")}, project_dir
441
+ )
381
442
  return validate_research_artifacts(root, project_dir)
382
443
 
383
444
 
@@ -439,19 +500,23 @@ def validate_research_artifacts(
439
500
 
440
501
  assignments = _research_assignments(_section(handoff, "Question assignments"))
441
502
  questions = _research_questions(intent)
442
- assigned_questions = [question for question, _dimension in assignments]
443
- if sorted(assigned_questions) != sorted(questions):
444
- raise HandoffError("RESEARCH.md question assignments do not match the intent source")
503
+ assigned_questions = [question for question, _dimensions in assignments]
445
504
  if len(assigned_questions) != len(set(assigned_questions)):
446
505
  raise HandoffError("RESEARCH.md repeats a question assignment")
506
+ if sorted(assigned_questions) != sorted(questions):
507
+ raise HandoffError("RESEARCH.md question assignments do not match the intent source")
447
508
  dispatched_set = set(dispatched)
448
- if any(dimension not in dispatched_set for _question, dimension in assignments):
509
+ if any(
510
+ dimension not in dispatched_set
511
+ for _question, assigned_dimensions in assignments
512
+ for dimension in assigned_dimensions
513
+ ):
449
514
  raise HandoffError("every assigned question must target a dispatched dimension")
450
515
  for dimension in dispatched:
451
516
  dimension_questions = [
452
517
  question
453
- for question, assigned_dimension in assignments
454
- if assigned_dimension == dimension
518
+ for question, assigned_dimensions in assignments
519
+ if dimension in assigned_dimensions
455
520
  ]
456
521
  if not dimension_questions:
457
522
  raise HandoffError(f"dispatched {dimension} has no assigned research question")
@@ -489,7 +554,7 @@ def _field(block: str, field: str) -> str:
489
554
 
490
555
 
491
556
  def _raw_source_field(block: str, field: str, source: str) -> str:
492
- matches = re.findall(rf"(?m)^- \*\*{re.escape(field)}\*\*:\s*(.*)$", block)
557
+ matches = _common.find_review_bullet_field_values(block.splitlines(), field)
493
558
  if not matches:
494
559
  raise HandoffError(f"{source} is missing {field}")
495
560
  if len(matches) != 1:
@@ -514,10 +579,16 @@ def _heading_block(text: str, heading: re.Match[str]) -> str:
514
579
  def _source_repair_fields(source: str, text: str, locator: str) -> Tuple[str, str]:
515
580
  if source.endswith("FINAL.md"):
516
581
  if not CRITERION_LOCATOR_PATTERN.fullmatch(locator):
517
- raise HandoffError(f"{source} locator must name an SC criterion")
582
+ raise HandoffError(
583
+ f"{source} locator must be an SC<n> id; copy the id from a "
584
+ "criterion heading"
585
+ )
518
586
  heading = re.search(rf"(?m)^### {re.escape(locator)} — .+$", text)
519
587
  if not heading:
520
- raise HandoffError(f"{source} does not contain locator {locator}")
588
+ raise HandoffError(
589
+ f"{source} has no criterion heading for locator {locator}; "
590
+ "copy an SC<n> id from a heading"
591
+ )
521
592
  block = _heading_block(text, heading)
522
593
  verdict = _source_field(block, "Verdict", source)
523
594
  if verdict not in {"not-met", "unverifiable"}:
@@ -525,8 +596,13 @@ def _source_repair_fields(source: str, text: str, locator: str) -> Tuple[str, st
525
596
  evidence = _source_field(block, "Finding", source)
526
597
  fix_direction = _source_field(block, "Fix direction", source)
527
598
  else:
528
- if not locator.startswith("Risk: ") or locator not in text.splitlines():
529
- raise HandoffError(f"{source} does not contain locator {locator}")
599
+ risk_lines = [line for line in text.splitlines() if line.startswith("Risk: ")]
600
+ if not locator.startswith("Risk: ") or locator not in risk_lines:
601
+ candidates = ", ".join(repr(line) for line in risk_lines) or "none found"
602
+ raise HandoffError(
603
+ f"{source} locator must equal a full 'Risk: ...' line from the "
604
+ f"gap review; copy one of {candidates}"
605
+ )
530
606
  block = _section(text, "Finding")
531
607
  evidence = _source_field(block, "Found", source)
532
608
  fix_direction = _source_field(block, "Fix direction", source)
@@ -548,6 +624,45 @@ def _reviewed_head(text: str, source: str) -> str:
548
624
  return value
549
625
 
550
626
 
627
+ MEMBER_HEAD_PATTERN = re.compile(r"(?m)^Member reviewed HEAD:(.*)$")
628
+
629
+
630
+ def member_reviewed_heads(text: str, source: str) -> Dict[str, str]:
631
+ """`Member reviewed HEAD: <member> <sha>` lines, one per member of a multi-repo milestone."""
632
+ heads: Dict[str, str] = {}
633
+ # Only the header binds heads; command output quoted in later sections cannot add one.
634
+ for value in MEMBER_HEAD_PATTERN.findall(_strip_comments(text).split("\n## ", 1)[0]):
635
+ name, _, sha = value.strip().partition(" ")
636
+ if not name or not SHA_PATTERN.fullmatch(sha.strip()):
637
+ raise HandoffError(
638
+ f"{source} Member reviewed HEAD must be '<member> <full commit SHA>', not {value.strip() or 'empty'}"
639
+ )
640
+ if name in heads:
641
+ raise HandoffError(f"{source} repeats Member reviewed HEAD for {name}")
642
+ heads[name] = sha.strip()
643
+ return heads
644
+
645
+
646
+ def _member_tips(root: Path) -> Dict[str, str]:
647
+ """Each locked member's bound branch tip; empty when the milestone locks no member."""
648
+ try:
649
+ import isolation
650
+ except ImportError: # pragma: no cover - package import used by tests
651
+ from scripts import isolation
652
+ lock = root / ".project/build/members.json"
653
+ if not lock.is_file():
654
+ return {}
655
+ tips: Dict[str, str] = {}
656
+ try:
657
+ for item in json.loads(lock.read_text(encoding="utf-8"))["members"]:
658
+ checkout, _, entry = isolation._member_context(root, item["name"])
659
+ tips[entry["name"]] = isolation.git_output(
660
+ checkout, "rev-parse", "--verify", f"refs/heads/{entry['branch']}^{{commit}}")
661
+ except isolation.IsolationError as error:
662
+ raise HandoffError(str(error)) from error
663
+ return tips
664
+
665
+
551
666
  def _strip_comments(text: str) -> str:
552
667
  return COMMENT_PATTERN.sub("", text)
553
668
 
@@ -581,6 +696,42 @@ def _success_criteria(intent: str) -> Dict[str, str]:
581
696
  return {f"SC{number}": text for number, text in items.items()}
582
697
 
583
698
 
699
+ def _split_top_level_commas(value: str) -> List[str]:
700
+ """Split on commas outside parentheses, brackets, and simple quoted strings."""
701
+
702
+ parts: List[str] = []
703
+ start = 0
704
+ depth_paren = 0
705
+ depth_bracket = 0
706
+ quote: Optional[str] = None
707
+ index = 0
708
+ while index < len(value):
709
+ character = value[index]
710
+ if quote is not None:
711
+ if character == quote:
712
+ quote = None
713
+ index += 1
714
+ continue
715
+ if character in {"'", '"'}:
716
+ quote = character
717
+ index += 1
718
+ continue
719
+ if character == "(":
720
+ depth_paren += 1
721
+ elif character == ")":
722
+ depth_paren = max(0, depth_paren - 1)
723
+ elif character == "[":
724
+ depth_bracket += 1
725
+ elif character == "]":
726
+ depth_bracket = max(0, depth_bracket - 1)
727
+ elif character == "," and depth_paren == 0 and depth_bracket == 0:
728
+ parts.append(value[start:index])
729
+ start = index + 1
730
+ index += 1
731
+ parts.append(value[start:])
732
+ return parts
733
+
734
+
584
735
  def _surfaces(text: str, label: str) -> List[str]:
585
736
  """Human-facing surfaces one milestone delivers; empty when `none`."""
586
737
 
@@ -597,7 +748,7 @@ def _surfaces(text: str, label: str) -> List[str]:
597
748
  _non_placeholder(value, f"{label} Surfaces")
598
749
  named: List[str] = []
599
750
  seen = set()
600
- for item in value.split(","):
751
+ for item in _split_top_level_commas(value):
601
752
  if not item.strip():
602
753
  continue
603
754
  surface = _unquoted(item)
@@ -744,12 +895,346 @@ def _coverage_rows(plan: str) -> List[Tuple[str, str, str]]:
744
895
  return rows
745
896
 
746
897
 
898
+ def _probe_table(
899
+ body: str, label: str, header: str, id_pattern: "re.Pattern[str]", width: int
900
+ ) -> List[List[str]]:
901
+ """Rows of one fixed-width probe table; the last cell keeps any `|` it holds."""
902
+
903
+ rows: List[List[str]] = []
904
+ for line in _strip_comments(body).splitlines():
905
+ stripped = line.strip()
906
+ if not stripped.startswith("|"):
907
+ # A row that lost its leading pipe would otherwise vanish silently.
908
+ if "|" in stripped and id_pattern.fullmatch(_unquoted(stripped.split("|", 1)[0])):
909
+ raise HandoffError(f"{label} row must start with |: {stripped}")
910
+ continue
911
+ cells = [_unquoted(cell) for cell in stripped.strip("|").split("|")]
912
+ first = cells[0] if cells else ""
913
+ if first.casefold() == header.casefold() or all(
914
+ TABLE_SEPARATOR_PATTERN.fullmatch(cell) for cell in cells if cell
915
+ ):
916
+ continue
917
+ if not id_pattern.fullmatch(first):
918
+ raise HandoffError(f"{label} has an invalid row: {stripped}")
919
+ if len(cells) < width:
920
+ raise HandoffError(f"{label} {first} needs {width} cells")
921
+ rows.append(cells[: width - 1] + ["|".join(cells[width - 1 :]).strip()])
922
+ ids = [row[0] for row in rows]
923
+ repeated = sorted({value for value in ids if ids.count(value) > 1})
924
+ if repeated:
925
+ raise HandoffError(f"{label} repeats " + ", ".join(repeated))
926
+ # id_pattern fixes a one-letter prefix, so the ids sort by their number.
927
+ expected = [f"{ids[0][0]}{number}" for number in range(1, len(ids) + 1)] if ids else []
928
+ if sorted(ids, key=lambda value: int(value[1:])) != expected:
929
+ raise HandoffError(f"{label} ids must be contiguous from 1")
930
+ return rows
931
+
932
+
933
+ def _probe_section(text: str, heading: str, label: str) -> Optional[str]:
934
+ """Body of one exact `## <heading>` section; a near-miss heading is an error.
935
+
936
+ The probe gates key on this heading, so a misspelled or re-levelled one
937
+ must fail loudly instead of reading as an intent written before the probe.
938
+ """
939
+
940
+ live = _strip_comments(text)
941
+ exact = re.findall(rf"(?m)^## {re.escape(heading)}[ \t]*$", live)
942
+ near = [
943
+ line.strip()
944
+ for line in re.findall(rf"(?im)^[ \t]*#{{1,6}}[ \t]*{re.escape(heading)}\b.*$", live)
945
+ if not re.fullmatch(rf"## {re.escape(heading)}", line.strip())
946
+ ]
947
+ if near:
948
+ raise HandoffError(f"{label} heading {near[0]!r} must be exactly '## {heading}'")
949
+ if len(exact) > 1:
950
+ raise HandoffError(f"{label} repeats ## {heading}")
951
+ if not exact:
952
+ return None
953
+ return _common.section_body(live, heading)
954
+
955
+
956
+ def _criterion_disposition(
957
+ value: str, criteria: Dict[str, str], label: str
958
+ ) -> Optional[str]:
959
+ match = CRITERION_DISPOSITION_PATTERN.fullmatch(" ".join(value.split()))
960
+ if match is None:
961
+ return None
962
+ target = match.group(1)
963
+ if target not in criteria:
964
+ raise HandoffError(f"{label} names unknown {target}")
965
+ return target
966
+
967
+
968
+ def edge_coverage(
969
+ intent: str, criteria: Dict[str, str]
970
+ ) -> Optional[Dict[str, Dict[str, str]]]:
971
+ """INTENT.md `## Edge coverage`, or None for an intent written before the probe.
972
+
973
+ Each row rules on one applicable edge of one success criterion:
974
+ `criterion SCn` (the edge is stated in a success criterion), `held-out`
975
+ (a named test must pin the ruling in Detail), or `dismissed` (Detail says
976
+ why it does not apply). Every criterion appears at least once, so a
977
+ skipped walk is visible rather than silent.
978
+ """
979
+
980
+ label = "INTENT.md Edge coverage"
981
+ body = _probe_section(intent, "Edge coverage", label)
982
+ if body is None:
983
+ return None
984
+ edges: Dict[str, Dict[str, str]] = {}
985
+ seen: Set[Tuple[str, str, str, str]] = set()
986
+ categories: Dict[str, Set[str]] = {}
987
+ for edge_id, criterion, category, disposition, detail in _probe_table(
988
+ body, label, "Edge", EDGE_ID_PATTERN, 5
989
+ ):
990
+ row_label = f"{label} {edge_id}"
991
+ if criterion not in criteria:
992
+ raise HandoffError(f"{row_label} names unknown criterion {criterion or 'none'}")
993
+ category = category.casefold()
994
+ if category not in EDGE_CATEGORIES + ("none",):
995
+ raise HandoffError(f"{row_label} has unknown category {category or 'none'}")
996
+ disposition = " ".join(disposition.split())
997
+ key = (criterion, category, disposition, _normalize_ws(detail))
998
+ if key in seen:
999
+ raise HandoffError(f"{row_label} repeats an earlier row")
1000
+ seen.add(key)
1001
+ categories.setdefault(criterion, set()).add(category)
1002
+ target = _criterion_disposition(disposition, criteria, row_label)
1003
+ if target is None and disposition not in {"held-out", "dismissed"}:
1004
+ raise HandoffError(
1005
+ f"{row_label} disposition must be criterion SCn, held-out, or dismissed"
1006
+ )
1007
+ if category == "none" and disposition != "dismissed":
1008
+ raise HandoffError(f"{row_label} category none must be dismissed")
1009
+ if target is None:
1010
+ detail = _non_placeholder(detail, f"{row_label} Detail")
1011
+ elif detail:
1012
+ _reject_placeholder(detail, f"{row_label} Detail")
1013
+ edges[edge_id] = {
1014
+ "criterion": criterion,
1015
+ "category": category,
1016
+ "disposition": "criterion" if target else disposition,
1017
+ "target": target or "",
1018
+ "detail": detail,
1019
+ }
1020
+ if not edges:
1021
+ raise HandoffError(f"{label} has no rows")
1022
+ for criterion, raised in categories.items():
1023
+ if "none" in raised and len(raised) > 1:
1024
+ raise HandoffError(f"{label} {criterion} mixes category none with edges")
1025
+ walked = {edge["criterion"] for edge in edges.values()}
1026
+ unwalked = [criterion for criterion in criteria if criterion not in walked]
1027
+ if unwalked:
1028
+ raise HandoffError(f"{label} does not walk " + ", ".join(unwalked))
1029
+ return edges
1030
+
1031
+
1032
+ def prohibitions(
1033
+ intent: str, criteria: Dict[str, str]
1034
+ ) -> Optional[Dict[str, Dict[str, str]]]:
1035
+ """INTENT.md `## Prohibitions`, or None for an intent written before the probe.
1036
+
1037
+ Each row names what one criterion must never silently become:
1038
+ `criterion SCn` (stated as a checkable success criterion), `judgment`
1039
+ (the final reviewer judges it; Detail says what to look for), or
1040
+ `dismissed`. Criterion `all` is allowed only for a dismissed row, so work
1041
+ no person touches needs one row instead of one per criterion.
1042
+ """
1043
+
1044
+ label = "INTENT.md Prohibitions"
1045
+ body = _probe_section(intent, "Prohibitions", label)
1046
+ if body is None:
1047
+ return None
1048
+ rows: Dict[str, Dict[str, str]] = {}
1049
+ for prohibition_id, criterion, must_not, disposition, detail in _probe_table(
1050
+ body, label, "Prohibition", PROHIBITION_ID_PATTERN, 5
1051
+ ):
1052
+ row_label = f"{label} {prohibition_id}"
1053
+ disposition = " ".join(disposition.split())
1054
+ if criterion.casefold() == "all":
1055
+ if disposition != "dismissed":
1056
+ raise HandoffError(f"{row_label} criterion all must be dismissed")
1057
+ criterion = "all"
1058
+ elif criterion not in criteria:
1059
+ raise HandoffError(f"{row_label} names unknown criterion {criterion or 'none'}")
1060
+ target = _criterion_disposition(disposition, criteria, row_label)
1061
+ if target is None and disposition not in {"judgment", "dismissed"}:
1062
+ raise HandoffError(
1063
+ f"{row_label} disposition must be criterion SCn, judgment, or dismissed"
1064
+ )
1065
+ if disposition != "dismissed":
1066
+ must_not = _non_placeholder(must_not, f"{row_label} Must not")
1067
+ elif must_not:
1068
+ _reject_placeholder(must_not, f"{row_label} Must not")
1069
+ if target is None:
1070
+ detail = _non_placeholder(detail, f"{row_label} Detail")
1071
+ elif detail:
1072
+ _reject_placeholder(detail, f"{row_label} Detail")
1073
+ rows[prohibition_id] = {
1074
+ "criterion": criterion,
1075
+ "must_not": must_not,
1076
+ "disposition": "criterion" if target else disposition,
1077
+ "target": target or "",
1078
+ "detail": detail,
1079
+ }
1080
+ if not rows:
1081
+ raise HandoffError(f"{label} has no rows")
1082
+ walked = {row["criterion"] for row in rows.values()}
1083
+ if "all" in walked:
1084
+ # `all` records that no person is affected at all, so it stands alone.
1085
+ if len(rows) != 1 or next(iter(rows.values()))["must_not"].casefold() != "none":
1086
+ raise HandoffError(f"{label} criterion all must be the only row, with Must not none")
1087
+ return rows
1088
+ unwalked = [criterion for criterion in criteria if criterion not in walked]
1089
+ if unwalked:
1090
+ raise HandoffError(f"{label} does not walk " + ", ".join(unwalked))
1091
+ return rows
1092
+
1093
+
1094
+ def evidence_tags(intent: str, criteria: Dict[str, str]) -> Dict[str, List[str]]:
1095
+ """Per criterion, the held-out edges and judgment prohibitions a `met` verdict must cite."""
1096
+
1097
+ tags: Dict[str, List[str]] = {}
1098
+ for edge_id, edge in (edge_coverage(intent, criteria) or {}).items():
1099
+ if edge["disposition"] == "held-out":
1100
+ tags.setdefault(edge["criterion"], []).append(edge_id)
1101
+ for prohibition_id, row in (prohibitions(intent, criteria) or {}).items():
1102
+ if row["disposition"] == "judgment":
1103
+ tags.setdefault(row["criterion"], []).append(prohibition_id)
1104
+ return tags
1105
+
1106
+
1107
+ def _cites(text: str, tag: str) -> bool:
1108
+ """A citation of an E#/N# id as a word, not inside a path, name, or longer id."""
1109
+
1110
+ return re.search(rf"(?<![A-Za-z0-9_./\\-]){re.escape(tag)}(?![A-Za-z0-9_])", text) is not None
1111
+
1112
+
1113
+ def _held_out_checks(plan: str) -> Dict[str, Tuple[str, str]]:
1114
+ body = _common.section_body(plan, "Held-out checks")
1115
+ if body is None:
1116
+ return {}
1117
+ label = "PLAN.md Held-out checks"
1118
+ checks: Dict[str, Tuple[str, str]] = {}
1119
+ for line in _strip_comments(body).splitlines():
1120
+ stripped = line.strip()
1121
+ if not stripped.startswith("|"):
1122
+ continue
1123
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
1124
+ if cells[0].casefold() == "edge" or all(
1125
+ TABLE_SEPARATOR_PATTERN.fullmatch(cell) for cell in cells if cell
1126
+ ):
1127
+ continue
1128
+ if len(cells) != 3 or not EDGE_ID_PATTERN.fullmatch(cells[0]):
1129
+ raise HandoffError(f"{label} has an invalid row: {stripped}")
1130
+ edge_id, task_id, test = (_unquoted(cell) for cell in cells)
1131
+ if edge_id in checks:
1132
+ raise HandoffError(f"{label} repeats {edge_id}")
1133
+ # A pytest-style node id (`tests/x.py::test_y`) names its file before `::`.
1134
+ test = _canonical_repo_path(test.split("::", 1)[0], f"{label} {edge_id} Test")
1135
+ checks[edge_id] = (task_id, test)
1136
+ return checks
1137
+
1138
+
1139
+ def _verify_without_comments(task_text: str) -> str:
1140
+ body = _section(task_text, "Verify")
1141
+ block = VERIFY_BLOCK_PATTERN.search(body)
1142
+ if block is None:
1143
+ return ""
1144
+ return _normalize_ws(re.sub(r"(?m)(?:^|\s)#.*$", "", block.group("block")))
1145
+
1146
+
1147
+ def _validate_held_out_checks(
1148
+ plan: str,
1149
+ edges: Optional[Dict[str, Dict[str, str]]],
1150
+ tasks: Dict[str, str],
1151
+ ) -> Dict[str, str]:
1152
+ """Each held-out edge maps to one task whose Verify runs the named test."""
1153
+
1154
+ held_out = sorted(
1155
+ (edge_id for edge_id, edge in (edges or {}).items() if edge["disposition"] == "held-out"),
1156
+ key=lambda value: int(value[1:]),
1157
+ )
1158
+ checks = _held_out_checks(plan)
1159
+ label = "PLAN.md Held-out checks"
1160
+ missing = [edge_id for edge_id in held_out if edge_id not in checks]
1161
+ if missing:
1162
+ raise HandoffError(f"{label} omits held-out " + ", ".join(missing))
1163
+ extra = sorted(set(checks) - set(held_out), key=lambda value: int(value[1:]))
1164
+ if extra:
1165
+ raise HandoffError(f"{label} names edges that are not held-out: " + ", ".join(extra))
1166
+ for edge_id in held_out:
1167
+ task_id, test = checks[edge_id]
1168
+ if task_id not in tasks:
1169
+ raise HandoffError(f"{label} {edge_id} names unknown {task_id}")
1170
+ text = tasks[task_id]
1171
+ criterion = (edges or {})[edge_id]["criterion"]
1172
+ if criterion not in _owned_criteria(text, task_id):
1173
+ raise HandoffError(
1174
+ f"{label} {edge_id} belongs to {criterion}, which {task_id} does not own"
1175
+ )
1176
+ files = [_posix_path(path) for path in _frontmatter_files(text)]
1177
+ if test not in files:
1178
+ raise HandoffError(f"{label} {edge_id} test {test} is not listed in {task_id} files")
1179
+ if any(other != test and other.startswith(test + "/") for other in files):
1180
+ raise HandoffError(f"{label} {edge_id} test {test} names a directory, not a test file")
1181
+ if not _verify_mentions_a_file(_verify_without_comments(text), [test]):
1182
+ raise HandoffError(f"{task_id} Verify must run held-out test {test} for {edge_id}")
1183
+ acceptance = " ".join(_acceptance_items(text, task_id).values())
1184
+ if not _cites(acceptance, edge_id):
1185
+ raise HandoffError(f"{task_id} Acceptance criteria must state held-out {edge_id}")
1186
+ return {edge_id: checks[edge_id][0] for edge_id in held_out}
1187
+
1188
+
1189
+ def validate_intent_probes(root: Path, project_dir: str = DEFAULT_PROJECT_DIR) -> None:
1190
+ """Probe-table check for leaving define; a no-op without INTENT.md or its tables."""
1191
+
1192
+ path = root / _intent_path(project_dir)
1193
+ if not path.is_file():
1194
+ return
1195
+ intent = _read(root, _intent_path(project_dir))
1196
+ live = _strip_comments(intent)
1197
+ if not re.search(r"(?im)^[ \t]*#{1,6}[ \t]*(edge coverage|prohibitions)\b", live):
1198
+ return
1199
+ criteria = _success_criteria(intent)
1200
+ edge_coverage(intent, criteria)
1201
+ prohibitions(intent, criteria)
1202
+
1203
+
1204
+ def validate_intent(
1205
+ root: Path, project_dir: str = DEFAULT_PROJECT_DIR
1206
+ ) -> Dict[str, object]:
1207
+ """Check INTENT.md success criteria and its spec-reach probe tables before approval."""
1208
+
1209
+ intent = _read(root, _intent_path(project_dir))
1210
+ criteria = _success_criteria(intent)
1211
+ _surfaces(intent, "INTENT.md")
1212
+ edges = edge_coverage(intent, criteria)
1213
+ rows = prohibitions(intent, criteria)
1214
+ return {
1215
+ "phase": "define",
1216
+ "criteria": sorted(criteria, key=lambda value: int(value[2:])),
1217
+ "edge_coverage": "absent" if edges is None else len(edges),
1218
+ "held_out": sorted(
1219
+ (edge_id for edge_id, edge in (edges or {}).items() if edge["disposition"] == "held-out"),
1220
+ key=lambda value: int(value[1:]),
1221
+ ),
1222
+ "prohibitions": "absent" if rows is None else len(rows),
1223
+ "judgment": sorted(
1224
+ (row_id for row_id, row in (rows or {}).items() if row["disposition"] == "judgment"),
1225
+ key=lambda value: int(value[1:]),
1226
+ ),
1227
+ }
1228
+
1229
+
747
1230
  def _task_texts(root: Path, project_dir: str) -> Dict[str, str]:
748
1231
  tasks_dir = root / project_dir / "tasks"
749
1232
  if not tasks_dir.is_dir():
750
1233
  raise HandoffError(f"missing {project_dir}/tasks")
751
1234
  tasks: Dict[str, str] = {}
752
1235
  for path in sorted(tasks_dir.iterdir()):
1236
+ if _common.is_ignored_junk(path):
1237
+ continue
753
1238
  named = TASK_FILE_NAME.fullmatch(path.name)
754
1239
  if named is None or not path.is_file() or path.is_symlink():
755
1240
  raise HandoffError(f"non-canonical task artifact: {path.name}")
@@ -917,6 +1402,20 @@ def _task_deps(task_text: str, task_id: str) -> List[str]:
917
1402
  return _inline_ids(rendered, "T", f"{task_id} deps")
918
1403
 
919
1404
 
1405
+ def _dependency_reachable(graph: Dict[str, List[str]], start: str, target: str) -> bool:
1406
+ seen: Set[str] = set()
1407
+ pending = list(graph[start])
1408
+ while pending:
1409
+ task_id = pending.pop()
1410
+ if task_id in seen:
1411
+ continue
1412
+ if task_id == target:
1413
+ return True
1414
+ seen.add(task_id)
1415
+ pending.extend(graph[task_id])
1416
+ return False
1417
+
1418
+
920
1419
  def _canonical_repo_path(value: str, label: str) -> str:
921
1420
  cleaned = value.strip().strip("`\"'")
922
1421
  path = PurePosixPath(cleaned)
@@ -1008,7 +1507,10 @@ def _validate_task_graph(
1008
1507
  graph: Dict[str, List[str]] = {}
1009
1508
  task_waves: Dict[str, int] = {}
1010
1509
  task_files: Dict[str, List[str]] = {}
1510
+ # `repo:` names the member a task changes; absent means the coordinator.
1511
+ task_repos: Dict[str, str] = {}
1011
1512
  for task_id, text in tasks.items():
1513
+ task_repos[task_id] = str(_strict_frontmatter(text, task_id).get("repo") or "")
1012
1514
  # Patch mode reopens planning beside landed tasks; only new tasks start clean.
1013
1515
  landed = _task_scalar(text, task_id, "status") == "done"
1014
1516
  _require_task_structure(task_id, text, initial=initial and not landed)
@@ -1028,10 +1530,21 @@ def _validate_task_graph(
1028
1530
 
1029
1531
  for index, left in enumerate(task_ids):
1030
1532
  for right in task_ids[index + 1 :]:
1031
- if task_waves[left] != task_waves[right]:
1533
+ if task_waves[left] != task_waves[right] or task_repos[left] != task_repos[right]:
1032
1534
  continue
1033
1535
  overlap = sorted(set(task_files[left]) & set(task_files[right]))
1034
1536
  if overlap:
1537
+ if (
1538
+ _task_scalar(tasks[left], left, "status") == "done"
1539
+ and _task_scalar(tasks[right], right, "status") == "done"
1540
+ ):
1541
+ continue
1542
+ # Dependency-ordered tasks serialize safely; only unordered
1543
+ # same-wave overlap races on a shared file.
1544
+ if _dependency_reachable(graph, left, right) or _dependency_reachable(
1545
+ graph, right, left
1546
+ ):
1547
+ continue
1035
1548
  raise HandoffError(
1036
1549
  f"same-wave file overlap between {left} and {right}: {', '.join(overlap)}"
1037
1550
  )
@@ -1039,6 +1552,7 @@ def _validate_task_graph(
1039
1552
  visiting: Set[str] = set()
1040
1553
  visited: Set[str] = set()
1041
1554
  dependency_files: Dict[str, Set[str]] = {}
1555
+ dependency_pairs: Dict[str, Set[Tuple[str, str]]] = {}
1042
1556
 
1043
1557
  def visit(task_id: str) -> None:
1044
1558
  if task_id in visiting:
@@ -1046,11 +1560,15 @@ def _validate_task_graph(
1046
1560
  if task_id in visited:
1047
1561
  return
1048
1562
  visiting.add(task_id)
1049
- dependency_files[task_id] = set()
1563
+ dependency_pairs[task_id] = set()
1050
1564
  for dependency in graph[task_id]:
1051
1565
  visit(dependency)
1052
- dependency_files[task_id].update(task_files[dependency])
1053
- dependency_files[task_id].update(dependency_files[dependency])
1566
+ dependency_pairs[task_id].update((task_repos[dependency], name) for name in task_files[dependency])
1567
+ dependency_pairs[task_id].update(dependency_pairs[dependency])
1568
+ # A dependency's files are supplied only within the same repo.
1569
+ dependency_files[task_id] = {
1570
+ name for repo, name in dependency_pairs[task_id] if repo == task_repos[task_id]
1571
+ }
1054
1572
  visiting.remove(task_id)
1055
1573
  visited.add(task_id)
1056
1574
 
@@ -1060,11 +1578,12 @@ def _validate_task_graph(
1060
1578
 
1061
1579
 
1062
1580
  def plan_brief_inputs(
1063
- root: Path, project_dir: str = DEFAULT_PROJECT_DIR
1581
+ root: Path, project_dir: str = DEFAULT_PROJECT_DIR, *, initial: bool = True
1064
1582
  ) -> Tuple[Dict[str, str], Dict[str, Set[str]]]:
1583
+ """Validate the plan graph, requiring clean unlanded tasks only at approval."""
1065
1584
  tasks = _task_texts(root, project_dir)
1066
1585
  wave_depths, _ = _plan_waves(_read(root, f"{project_dir}/plan/PLAN.md"))
1067
- return tasks, _validate_task_graph(wave_depths, tasks, initial=True)
1586
+ return tasks, _validate_task_graph(wave_depths, tasks, initial=initial)
1068
1587
 
1069
1588
 
1070
1589
  def _intent_path(project_dir: str) -> str:
@@ -1212,7 +1731,7 @@ def _head_file(root: Path, relative: str) -> Optional[str]:
1212
1731
  ["git", "show", f"HEAD:{relative}"],
1213
1732
  cwd=root,
1214
1733
  capture_output=True,
1215
- text=True,
1734
+ encoding="utf-8", errors="replace",
1216
1735
  check=False,
1217
1736
  )
1218
1737
  return result.stdout if result.returncode == 0 else None
@@ -1313,6 +1832,21 @@ def validate_roadmap(
1313
1832
  return {"phase": "roadmap", "milestones": ids, "dependencies": dependencies}
1314
1833
 
1315
1834
 
1835
+ def _integrated_members(root: Path, state: Dict[str, str]) -> Set[str]:
1836
+ """Members this milestone already closed; a new task for one would need a second merge."""
1837
+ match = re.fullmatch(r"gsd-path/M(\d{3,})", state.get("branch") or "")
1838
+ common = subprocess.run(["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-common-dir"],
1839
+ capture_output=True, text=True, check=False)
1840
+ if match is None or common.returncode != 0:
1841
+ return set()
1842
+ if __package__:
1843
+ from . import members
1844
+ else:
1845
+ import members
1846
+ rows = members.milestone_member_close(Path(common.stdout.strip()), int(match.group(1)))
1847
+ return {row["name"] for row in rows if row.get("status") == "integrated"}
1848
+
1849
+
1316
1850
  def validate_plan(
1317
1851
  root: Path, project_dir: str = DEFAULT_PROJECT_DIR
1318
1852
  ) -> Dict[str, object]:
@@ -1332,6 +1866,14 @@ def validate_plan(
1332
1866
  # Build readiness owns task lifecycle validation; coverage must survive landings.
1333
1867
  state = _require_pipeline(root, project_dir)
1334
1868
  _validate_task_graph(wave_depths, tasks, initial=state["phase"] == "plan")
1869
+ integrated = _integrated_members(root, state)
1870
+ for task_id, text in tasks.items():
1871
+ repo = str(_strict_frontmatter(text, task_id).get("repo") or "")
1872
+ if repo in integrated and _task_scalar(text, task_id, "status") != "done":
1873
+ raise HandoffError(
1874
+ f"{task_id} changes member {repo}, which is already integrated in this milestone; "
1875
+ "plan that change in a new milestone"
1876
+ )
1335
1877
  assigned: Dict[str, Set[str]] = {task_id: set() for task_id in tasks}
1336
1878
  covered = set()
1337
1879
  for criterion, task_id, acceptance in rows:
@@ -1396,6 +1938,9 @@ def validate_plan(
1396
1938
  continue
1397
1939
  if not _verify_mentions_a_file(command, _frontmatter_files(text)):
1398
1940
  raise HandoffError(f"{task_id} Verify must name a path from files")
1941
+ edges = edge_coverage(intent, criteria)
1942
+ prohibitions(intent, criteria)
1943
+ held_out = _validate_held_out_checks(plan, edges, tasks)
1399
1944
  return {
1400
1945
  "phase": "plan",
1401
1946
  "criteria": sorted(criteria),
@@ -1403,6 +1948,7 @@ def validate_plan(
1403
1948
  "tasks": len(tasks),
1404
1949
  "waves": waves,
1405
1950
  "surfaces": {surface: task for surface, (task, _) in owners.items()},
1951
+ "held_out": held_out,
1406
1952
  }
1407
1953
 
1408
1954
 
@@ -1464,17 +2010,27 @@ def validate_wave_evidence(
1464
2010
  assigned[task_id].add(criterion)
1465
2011
  owned = _owned_by_wave(tasks, assigned, wave)
1466
2012
  text = _read(root, review_path.as_posix())
2013
+ is_last_cycle = cycle >= _max_wave_cycle(root, project_dir, wave)
1467
2014
  if _line_value(text, "Cycle:") != str(cycle):
1468
2015
  raise HandoffError(f"{name} Cycle field does not match its filename")
1469
- if _line_value(text, "Depth:") != expected_depth:
2016
+ recorded_depth = _line_value(text, "Depth:")
2017
+ if recorded_depth not in VALID_REVIEW_DEPTHS:
2018
+ raise HandoffError(f"{name} Depth does not match PLAN.md")
2019
+ if is_last_cycle and recorded_depth != expected_depth:
1470
2020
  raise HandoffError(f"{name} Depth does not match PLAN.md")
1471
2021
  lens_fields = re.findall(r"(?m)^Lens:[ \t]*(\S.*?)[ \t]*$", text)
1472
2022
  if lens is None and lens_fields:
1473
2023
  raise HandoffError(f"{name} must not declare a review lens")
1474
2024
  if lens is not None and lens_fields != [lens]:
1475
2025
  raise HandoffError(f"{name} Lens field does not match its filename")
2026
+ overall = _line_value(text, "Wave verdict:")
2027
+ if overall not in {"pass", "blocked"}:
2028
+ raise HandoffError(f"{name} Wave verdict is invalid")
1476
2029
  reviewed = _line_value(text, "Tasks reviewed:")
1477
- if not reviewed.isdigit() or int(reviewed) != len(expected_tasks):
2030
+ enforce_plan_tasks = is_last_cycle and overall != "blocked"
2031
+ if not reviewed.isdigit() or int(reviewed) < 1:
2032
+ raise HandoffError(f"{name} Tasks reviewed does not match Wave {wave}")
2033
+ if enforce_plan_tasks and int(reviewed) != len(expected_tasks):
1478
2034
  raise HandoffError(f"{name} Tasks reviewed does not match Wave {wave}")
1479
2035
 
1480
2036
  lines = text.splitlines()
@@ -1483,11 +2039,25 @@ def validate_wave_evidence(
1483
2039
  match = WAVE_TASK_HEADING.fullmatch(line)
1484
2040
  if match:
1485
2041
  task_headings.append((index, match))
2042
+ if len(task_headings) != int(reviewed):
2043
+ raise HandoffError(f"{name} Tasks reviewed does not match Wave {wave}")
1486
2044
  reviewed_tasks = [match.group("task") for _, match in task_headings]
1487
- if reviewed_tasks != expected_tasks:
1488
- raise HandoffError(
1489
- f"{name} must review exactly {', '.join(expected_tasks)} in plan order"
1490
- )
2045
+ if enforce_plan_tasks:
2046
+ if reviewed_tasks != expected_tasks:
2047
+ raise HandoffError(
2048
+ f"{name} must review exactly {', '.join(expected_tasks)} in plan order"
2049
+ )
2050
+ else:
2051
+ unknown = [task_id for task_id in reviewed_tasks if task_id not in expected_tasks]
2052
+ if unknown:
2053
+ raise HandoffError(
2054
+ f"{name} must review exactly {', '.join(expected_tasks)} in plan order"
2055
+ )
2056
+ plan_order = [task_id for task_id in expected_tasks if task_id in reviewed_tasks]
2057
+ if reviewed_tasks != plan_order:
2058
+ raise HandoffError(
2059
+ f"{name} must review exactly {', '.join(expected_tasks)} in plan order"
2060
+ )
1491
2061
  task_verdicts = []
1492
2062
  for heading_index, heading in task_headings:
1493
2063
  task_id = heading.group("task")
@@ -1516,9 +2086,16 @@ def validate_wave_evidence(
1516
2086
  _non_placeholder(item, f"{name} task {task_id} {verdict} evidence")
1517
2087
  task_verdicts.append(verdict)
1518
2088
 
1519
- overall = _line_value(text, "Wave verdict:")
1520
- if overall not in {"pass", "blocked"}:
1521
- raise HandoffError(f"{name} Wave verdict is invalid")
2089
+ if not is_last_cycle:
2090
+ return {
2091
+ "phase": "wave",
2092
+ "wave": wave,
2093
+ "cycle": cycle,
2094
+ "owned": owned if owned else [],
2095
+ "review": review,
2096
+ "verdict": overall,
2097
+ }
2098
+
1522
2099
  if overall == "pass" and "fail" in task_verdicts:
1523
2100
  raise HandoffError(f"{name} Wave verdict is pass while a task failed")
1524
2101
  try:
@@ -1591,6 +2168,19 @@ def validate_wave_evidence(
1591
2168
  }
1592
2169
 
1593
2170
 
2171
+ def criterion_surfaces(root: Path, project_dir: str = DEFAULT_PROJECT_DIR) -> Dict[str, str]:
2172
+ """Map each surface-owned INTENT SC to the surface PLAN.md's contract lists it under."""
2173
+
2174
+ surface_of: Dict[str, str] = {}
2175
+ surfaces = _surfaces(_read(root, _intent_path(project_dir)), "INTENT.md")
2176
+ if surfaces:
2177
+ plan = _read(root, f"{project_dir}/plan/PLAN.md")
2178
+ for surface, (_task, owned) in _surface_contract(plan, surfaces).items():
2179
+ for criterion in owned:
2180
+ surface_of[criterion] = surface
2181
+ return surface_of
2182
+
2183
+
1594
2184
  def validate_final(
1595
2185
  root: Path, project_dir: str = DEFAULT_PROJECT_DIR, *, final_text: Optional[str] = None
1596
2186
  ) -> Dict[str, object]:
@@ -1599,20 +2189,15 @@ def validate_final(
1599
2189
  _require_state(root, "ship", "active", project_dir)
1600
2190
  intent = _read(root, _intent_path(project_dir))
1601
2191
  criteria = _success_criteria(intent)
1602
- surface_of: Dict[str, str] = {}
1603
- surfaces = _surfaces(intent, "INTENT.md")
1604
- if surfaces:
1605
- plan = _read(root, f"{project_dir}/plan/PLAN.md")
1606
- for surface, (_task, owned) in _surface_contract(plan, surfaces).items():
1607
- for criterion in owned:
1608
- surface_of[criterion] = surface
2192
+ surface_of = criterion_surfaces(root, project_dir)
2193
+ tags = evidence_tags(intent, criteria)
1609
2194
  relative = f"{project_dir}/review/FINAL.md"
1610
2195
  text = _read(root, relative) if final_text is None else final_text
1611
2196
  reviewed_head = _reviewed_head(text, relative)
1612
2197
  head_result = subprocess.run(
1613
2198
  ("git", "-C", str(root), "rev-parse", "--verify", "HEAD"),
1614
2199
  capture_output=True,
1615
- text=True,
2200
+ encoding="utf-8", errors="replace",
1616
2201
  check=False,
1617
2202
  )
1618
2203
  if head_result.returncode != 0:
@@ -1623,6 +2208,14 @@ def validate_final(
1623
2208
  f"FINAL.md Reviewed HEAD {reviewed_head} is not the current HEAD "
1624
2209
  f"{current_head}; re-run the final review on the current commit"
1625
2210
  )
2211
+ member_heads = member_reviewed_heads(text, relative)
2212
+ tips = _member_tips(root)
2213
+ if member_heads != tips:
2214
+ expected = ", ".join(f"{name} {tip}" for name, tip in tips.items()) or "none"
2215
+ raise HandoffError(
2216
+ f"FINAL.md Member reviewed HEAD lines must name each locked member head ({expected}); "
2217
+ "re-run the final review on the current member heads"
2218
+ )
1626
2219
  overall = _line_value(text, "Overall verdict:")
1627
2220
  if overall not in {"pass", "blocked"}:
1628
2221
  raise HandoffError("FINAL.md Overall verdict is invalid")
@@ -1675,6 +2268,16 @@ def validate_final(
1675
2268
  raise HandoffError(
1676
2269
  f"FINAL.md {sc_id} met verdict must have no Finding or Fix direction"
1677
2270
  )
2271
+ # Honest verifier: a spec-tagged criterion is met only on cited
2272
+ # evidence for each tag; otherwise the reviewer records unverifiable.
2273
+ cited = " ".join((check, observed, reference))
2274
+ uncited = [tag for tag in tags.get(sc_id, []) if not _cites(cited, tag)]
2275
+ if uncited:
2276
+ raise HandoffError(
2277
+ f"FINAL.md {sc_id} is met without cited evidence for "
2278
+ + ", ".join(uncited)
2279
+ + "; cite each held-out check or judgment, or record unverifiable"
2280
+ )
1678
2281
  elif finding.casefold() == "none" or fix_direction.casefold() == "none":
1679
2282
  raise HandoffError(
1680
2283
  f"FINAL.md {sc_id} {verdict} verdict requires a Finding and Fix direction"
@@ -1697,6 +2300,8 @@ def validate_final(
1697
2300
  raise HandoffError(f"{gap_relative} heading does not match its file number")
1698
2301
  if _reviewed_head(gap, gap_relative) != reviewed_head:
1699
2302
  raise HandoffError(f"{gap_relative} Reviewed HEAD differs from FINAL.md")
2303
+ if member_reviewed_heads(gap, gap_relative) != member_heads:
2304
+ raise HandoffError(f"{gap_relative} Member reviewed HEAD lines differ from FINAL.md")
1700
2305
  verdict = _line_value(gap, "Gap verdict:")
1701
2306
  if verdict not in {"pass", "blocked"}:
1702
2307
  raise HandoffError(f"{gap_relative} Gap verdict is invalid")
@@ -1793,10 +2398,15 @@ def validate_patch_findings(
1793
2398
  source, source_text, locator
1794
2399
  )
1795
2400
  if evidence != source_evidence:
1796
- raise HandoffError(f"{source} locator {locator} evidence does not match its source")
2401
+ evidence_field = "Finding" if source.endswith("FINAL.md") else "Found"
2402
+ raise HandoffError(
2403
+ f"{source} locator {locator} Evidence must equal the source's "
2404
+ f"{evidence_field} field; copy it verbatim: {source_evidence!r}"
2405
+ )
1797
2406
  if fix_direction != source_fix_direction:
1798
2407
  raise HandoffError(
1799
- f"{source} locator {locator} fix direction does not match its source"
2408
+ f"{source} locator {locator} Fix direction must equal the source's "
2409
+ f"Fix direction field; copy it verbatim: {source_fix_direction!r}"
1800
2410
  )
1801
2411
  sources.append(source)
1802
2412
 
@@ -1814,7 +2424,8 @@ def validate_patch_findings(
1814
2424
  def parser() -> argparse.ArgumentParser:
1815
2425
  argument_parser = argparse.ArgumentParser(description=__doc__)
1816
2426
  argument_parser.add_argument(
1817
- "phase", choices=("research", "decide", "roadmap", "patch", "plan", "wave", "final")
2427
+ "phase",
2428
+ choices=("intent", "research", "decide", "roadmap", "patch", "plan", "wave", "final"),
1818
2429
  )
1819
2430
  argument_parser.add_argument("--repo", type=Path, required=True)
1820
2431
  argument_parser.add_argument(
@@ -1842,6 +2453,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
1842
2453
  result = validate_wave(repo, arguments.project_dir, arguments.review)
1843
2454
  else:
1844
2455
  validators = {
2456
+ "intent": validate_intent,
1845
2457
  "research": validate_research,
1846
2458
  "decide": validate_decide,
1847
2459
  "roadmap": validate_roadmap,