@opengsd/gsd-path 1.5.0 → 1.6.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 (352) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DOCS.md +4 -3
  3. package/FULL.md +64 -4
  4. package/HOOKS.md +63 -2
  5. package/MIGRATE.md +12 -0
  6. package/QUICK.md +4 -2
  7. package/README.md +67 -15
  8. package/RUNTIME.md +23 -5
  9. package/UPDATE.md +35 -0
  10. package/WORKFLOW.md +58 -3
  11. package/package.json +1 -1
  12. package/scripts/_common.py +107 -1
  13. package/scripts/build_recovery.py +62 -30
  14. package/scripts/build_state.py +195 -5
  15. package/scripts/check_handoffs.py +92 -30
  16. package/scripts/check_task_briefs.py +19 -14
  17. package/scripts/detect_project.py +17 -2
  18. package/scripts/discussion_records.py +9 -1
  19. package/scripts/discussion_validate.py +7 -6
  20. package/scripts/dispatch_driver.py +157 -31
  21. package/scripts/git_guard.py +24 -5
  22. package/scripts/guard_hook.py +1216 -47
  23. package/scripts/install.mjs +76 -3
  24. package/scripts/install.py +182 -37
  25. package/scripts/integration.py +98 -64
  26. package/scripts/isolation.py +148 -11
  27. package/scripts/members.py +68 -25
  28. package/scripts/migrate_core.py +2 -0
  29. package/scripts/model_policy.py +5 -1
  30. package/scripts/pipeline_diagnose.py +17 -2
  31. package/scripts/pipeline_git.py +68 -30
  32. package/scripts/pipeline_state.py +28 -6
  33. package/scripts/pipeline_undo.py +17 -8
  34. package/scripts/promote_lookahead.py +3 -3
  35. package/scripts/review_findings.py +180 -44
  36. package/scripts/roadmap.py +9 -1
  37. package/scripts/runtime_store.py +1 -0
  38. package/scripts/state_checkpoint.py +30 -9
  39. package/scripts/state_promote.py +15 -4
  40. package/scripts/task_context.py +2 -0
  41. package/scripts/workflow_run.py +9 -2
  42. package/skills/gsd-path/DEFINE.md +6 -2
  43. package/skills/gsd-path/PLAN.md +3 -0
  44. package/skills/gsd-path/ROADMAP.md +16 -6
  45. package/skills/gsd-path/SHIP.md +26 -4
  46. package/skills/gsd-path/SKILL.md +52 -24
  47. package/skills/gsd-path/references/build-native.md +96 -9
  48. package/skills/gsd-path/references/build-recovery.md +13 -0
  49. package/skills/gsd-path/references/coder.md +7 -3
  50. package/skills/gsd-path/references/operating-rules.md +11 -3
  51. package/skills/gsd-path/references/planner.md +13 -1
  52. package/skills/gsd-path/references/reviewer-final.md +5 -5
  53. package/skills/gsd-path/references/reviewer-wave.md +46 -13
  54. package/skills/gsd-path/references/runtime-selection.md +9 -0
  55. package/skills/gsd-path/scripts/_common.py +107 -1
  56. package/skills/gsd-path/scripts/build_recovery.py +62 -30
  57. package/skills/gsd-path/scripts/build_state.py +195 -5
  58. package/skills/gsd-path/scripts/check_handoffs.py +92 -30
  59. package/skills/gsd-path/scripts/check_task_briefs.py +19 -14
  60. package/skills/gsd-path/scripts/detect_project.py +17 -2
  61. package/skills/gsd-path/scripts/discussion_records.py +9 -1
  62. package/skills/gsd-path/scripts/discussion_validate.py +7 -6
  63. package/skills/gsd-path/scripts/dispatch_driver.py +157 -31
  64. package/skills/gsd-path/scripts/git_guard.py +24 -5
  65. package/skills/gsd-path/scripts/integration.py +98 -64
  66. package/skills/gsd-path/scripts/isolation.py +148 -11
  67. package/skills/gsd-path/scripts/members.py +68 -25
  68. package/skills/gsd-path/scripts/model_policy.py +5 -1
  69. package/skills/gsd-path/scripts/pipeline_git.py +68 -30
  70. package/skills/gsd-path/scripts/pipeline_state.py +28 -6
  71. package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
  72. package/skills/gsd-path/scripts/review_findings.py +180 -44
  73. package/skills/gsd-path/scripts/roadmap.py +9 -1
  74. package/skills/gsd-path/scripts/state_checkpoint.py +30 -9
  75. package/skills/gsd-path/scripts/state_promote.py +15 -4
  76. package/skills/gsd-path/scripts/task_context.py +2 -0
  77. package/skills/gsd-path/scripts/workflow_run.py +9 -2
  78. package/skills/gsd-path/templates/final-review.md +5 -5
  79. package/skills/gsd-path/templates/state.md +1 -0
  80. package/skills/gsd-path/templates/task.md +1 -0
  81. package/skills/gsd-path/templates/wave-review.md +18 -9
  82. package/skills/gsd-path-build/references/build-native.md +96 -9
  83. package/skills/gsd-path-build/references/build-recovery.md +13 -0
  84. package/skills/gsd-path-build/references/coder.md +7 -3
  85. package/skills/gsd-path-build/references/operating-rules.md +11 -3
  86. package/skills/gsd-path-build/references/reviewer-final.md +5 -5
  87. package/skills/gsd-path-build/references/reviewer-wave.md +46 -13
  88. package/skills/gsd-path-build/references/runtime-selection.md +9 -0
  89. package/skills/gsd-path-build/scripts/_common.py +107 -1
  90. package/skills/gsd-path-build/scripts/build_recovery.py +62 -30
  91. package/skills/gsd-path-build/scripts/build_state.py +195 -5
  92. package/skills/gsd-path-build/scripts/check_handoffs.py +92 -30
  93. package/skills/gsd-path-build/scripts/check_task_briefs.py +19 -14
  94. package/skills/gsd-path-build/scripts/detect_project.py +17 -2
  95. package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
  96. package/skills/gsd-path-build/scripts/discussion_validate.py +7 -6
  97. package/skills/gsd-path-build/scripts/dispatch_driver.py +157 -31
  98. package/skills/gsd-path-build/scripts/integration.py +98 -64
  99. package/skills/gsd-path-build/scripts/isolation.py +148 -11
  100. package/skills/gsd-path-build/scripts/members.py +68 -25
  101. package/skills/gsd-path-build/scripts/model_policy.py +5 -1
  102. package/skills/gsd-path-build/scripts/pipeline_git.py +68 -30
  103. package/skills/gsd-path-build/scripts/pipeline_state.py +28 -6
  104. package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
  105. package/skills/gsd-path-build/scripts/review_findings.py +180 -44
  106. package/skills/gsd-path-build/scripts/roadmap.py +9 -1
  107. package/skills/gsd-path-build/scripts/state_checkpoint.py +30 -9
  108. package/skills/gsd-path-build/scripts/state_promote.py +15 -4
  109. package/skills/gsd-path-build/scripts/task_context.py +2 -0
  110. package/skills/gsd-path-build/scripts/workflow_run.py +9 -2
  111. package/skills/gsd-path-build/templates/task.md +1 -0
  112. package/skills/gsd-path-build/templates/wave-review.md +18 -9
  113. package/skills/gsd-path-decide/references/operating-rules.md +11 -3
  114. package/skills/gsd-path-decide/references/runtime-selection.md +9 -0
  115. package/skills/gsd-path-decide/scripts/_common.py +107 -1
  116. package/skills/gsd-path-decide/scripts/build_recovery.py +62 -30
  117. package/skills/gsd-path-decide/scripts/check_handoffs.py +92 -30
  118. package/skills/gsd-path-decide/scripts/check_task_briefs.py +19 -14
  119. package/skills/gsd-path-decide/scripts/detect_project.py +17 -2
  120. package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
  121. package/skills/gsd-path-decide/scripts/discussion_validate.py +7 -6
  122. package/skills/gsd-path-decide/scripts/integration.py +98 -64
  123. package/skills/gsd-path-decide/scripts/isolation.py +148 -11
  124. package/skills/gsd-path-decide/scripts/members.py +68 -25
  125. package/skills/gsd-path-decide/scripts/model_policy.py +5 -1
  126. package/skills/gsd-path-decide/scripts/pipeline_git.py +68 -30
  127. package/skills/gsd-path-decide/scripts/pipeline_state.py +28 -6
  128. package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
  129. package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
  130. package/skills/gsd-path-decide/scripts/state_checkpoint.py +30 -9
  131. package/skills/gsd-path-decide/scripts/state_promote.py +15 -4
  132. package/skills/gsd-path-define/SKILL.md +6 -2
  133. package/skills/gsd-path-define/references/build-recovery.md +13 -0
  134. package/skills/gsd-path-define/references/operating-rules.md +11 -3
  135. package/skills/gsd-path-define/references/runtime-selection.md +9 -0
  136. package/skills/gsd-path-define/scripts/_common.py +107 -1
  137. package/skills/gsd-path-define/scripts/build_recovery.py +62 -30
  138. package/skills/gsd-path-define/scripts/check_handoffs.py +92 -30
  139. package/skills/gsd-path-define/scripts/check_task_briefs.py +19 -14
  140. package/skills/gsd-path-define/scripts/detect_project.py +17 -2
  141. package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
  142. package/skills/gsd-path-define/scripts/discussion_validate.py +7 -6
  143. package/skills/gsd-path-define/scripts/integration.py +98 -64
  144. package/skills/gsd-path-define/scripts/isolation.py +148 -11
  145. package/skills/gsd-path-define/scripts/members.py +68 -25
  146. package/skills/gsd-path-define/scripts/model_policy.py +5 -1
  147. package/skills/gsd-path-define/scripts/pipeline_git.py +68 -30
  148. package/skills/gsd-path-define/scripts/pipeline_state.py +28 -6
  149. package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
  150. package/skills/gsd-path-define/scripts/roadmap.py +9 -1
  151. package/skills/gsd-path-define/scripts/state_checkpoint.py +30 -9
  152. package/skills/gsd-path-define/scripts/state_promote.py +15 -4
  153. package/skills/gsd-path-define/templates/state.md +1 -0
  154. package/skills/gsd-path-discuss/references/operating-rules.md +11 -3
  155. package/skills/gsd-path-discuss/references/runtime-selection.md +9 -0
  156. package/skills/gsd-path-discuss/scripts/_common.py +107 -1
  157. package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
  158. package/skills/gsd-path-discuss/scripts/model_policy.py +5 -1
  159. package/skills/gsd-path-docs-audit/references/operating-rules.md +11 -3
  160. package/skills/gsd-path-docs-audit/references/runtime-selection.md +9 -0
  161. package/skills/gsd-path-docs-audit/scripts/_common.py +107 -1
  162. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +62 -30
  163. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +92 -30
  164. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +19 -14
  165. package/skills/gsd-path-docs-audit/scripts/detect_project.py +17 -2
  166. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
  167. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +7 -6
  168. package/skills/gsd-path-docs-audit/scripts/integration.py +98 -64
  169. package/skills/gsd-path-docs-audit/scripts/isolation.py +148 -11
  170. package/skills/gsd-path-docs-audit/scripts/members.py +68 -25
  171. package/skills/gsd-path-docs-audit/scripts/model_policy.py +5 -1
  172. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +68 -30
  173. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +28 -6
  174. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
  175. package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
  176. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +30 -9
  177. package/skills/gsd-path-docs-audit/scripts/state_promote.py +15 -4
  178. package/skills/gsd-path-forensics/references/operating-rules.md +11 -3
  179. package/skills/gsd-path-forensics/references/runtime-selection.md +9 -0
  180. package/skills/gsd-path-forensics/scripts/_common.py +107 -1
  181. package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
  182. package/skills/gsd-path-forensics/scripts/model_policy.py +5 -1
  183. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
  184. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +17 -8
  185. package/skills/gsd-path-inspect/references/operating-rules.md +11 -3
  186. package/skills/gsd-path-inspect/references/runtime-selection.md +9 -0
  187. package/skills/gsd-path-inspect/scripts/_common.py +107 -1
  188. package/skills/gsd-path-inspect/scripts/build_recovery.py +62 -30
  189. package/skills/gsd-path-inspect/scripts/check_handoffs.py +92 -30
  190. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +19 -14
  191. package/skills/gsd-path-inspect/scripts/detect_project.py +17 -2
  192. package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
  193. package/skills/gsd-path-inspect/scripts/discussion_validate.py +7 -6
  194. package/skills/gsd-path-inspect/scripts/integration.py +98 -64
  195. package/skills/gsd-path-inspect/scripts/isolation.py +148 -11
  196. package/skills/gsd-path-inspect/scripts/members.py +68 -25
  197. package/skills/gsd-path-inspect/scripts/model_policy.py +5 -1
  198. package/skills/gsd-path-inspect/scripts/pipeline_git.py +68 -30
  199. package/skills/gsd-path-inspect/scripts/pipeline_state.py +28 -6
  200. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
  201. package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
  202. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +30 -9
  203. package/skills/gsd-path-inspect/scripts/state_promote.py +15 -4
  204. package/skills/gsd-path-inspect/scripts/workflow_run.py +9 -2
  205. package/skills/gsd-path-inspect/templates/state.md +1 -0
  206. package/skills/gsd-path-loop/references/operating-rules.md +11 -3
  207. package/skills/gsd-path-loop/references/runtime-selection.md +9 -0
  208. package/skills/gsd-path-loop/scripts/_common.py +107 -1
  209. package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
  210. package/skills/gsd-path-loop/scripts/model_policy.py +5 -1
  211. package/skills/gsd-path-migrate/SKILL.md +8 -0
  212. package/skills/gsd-path-migrate/references/operating-rules.md +11 -3
  213. package/skills/gsd-path-migrate/references/runtime-selection.md +9 -0
  214. package/skills/gsd-path-migrate/scripts/_common.py +107 -1
  215. package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
  216. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
  217. package/skills/gsd-path-migrate/scripts/model_policy.py +5 -1
  218. package/skills/gsd-path-plan/SKILL.md +3 -0
  219. package/skills/gsd-path-plan/references/build-recovery.md +13 -0
  220. package/skills/gsd-path-plan/references/operating-rules.md +11 -3
  221. package/skills/gsd-path-plan/references/planner.md +13 -1
  222. package/skills/gsd-path-plan/references/runtime-selection.md +9 -0
  223. package/skills/gsd-path-plan/scripts/_common.py +107 -1
  224. package/skills/gsd-path-plan/scripts/build_recovery.py +62 -30
  225. package/skills/gsd-path-plan/scripts/check_handoffs.py +92 -30
  226. package/skills/gsd-path-plan/scripts/check_task_briefs.py +19 -14
  227. package/skills/gsd-path-plan/scripts/detect_project.py +17 -2
  228. package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
  229. package/skills/gsd-path-plan/scripts/discussion_validate.py +7 -6
  230. package/skills/gsd-path-plan/scripts/integration.py +98 -64
  231. package/skills/gsd-path-plan/scripts/isolation.py +148 -11
  232. package/skills/gsd-path-plan/scripts/members.py +68 -25
  233. package/skills/gsd-path-plan/scripts/model_policy.py +5 -1
  234. package/skills/gsd-path-plan/scripts/pipeline_git.py +68 -30
  235. package/skills/gsd-path-plan/scripts/pipeline_state.py +28 -6
  236. package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
  237. package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
  238. package/skills/gsd-path-plan/scripts/state_checkpoint.py +30 -9
  239. package/skills/gsd-path-plan/scripts/state_promote.py +15 -4
  240. package/skills/gsd-path-plan/scripts/workflow_run.py +9 -2
  241. package/skills/gsd-path-plan/templates/task.md +1 -0
  242. package/skills/gsd-path-research/references/operating-rules.md +11 -3
  243. package/skills/gsd-path-research/references/runtime-selection.md +9 -0
  244. package/skills/gsd-path-research/scripts/_common.py +107 -1
  245. package/skills/gsd-path-research/scripts/build_recovery.py +62 -30
  246. package/skills/gsd-path-research/scripts/check_handoffs.py +92 -30
  247. package/skills/gsd-path-research/scripts/check_task_briefs.py +19 -14
  248. package/skills/gsd-path-research/scripts/detect_project.py +17 -2
  249. package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
  250. package/skills/gsd-path-research/scripts/discussion_validate.py +7 -6
  251. package/skills/gsd-path-research/scripts/integration.py +98 -64
  252. package/skills/gsd-path-research/scripts/isolation.py +148 -11
  253. package/skills/gsd-path-research/scripts/members.py +68 -25
  254. package/skills/gsd-path-research/scripts/model_policy.py +5 -1
  255. package/skills/gsd-path-research/scripts/pipeline_git.py +68 -30
  256. package/skills/gsd-path-research/scripts/pipeline_state.py +28 -6
  257. package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
  258. package/skills/gsd-path-research/scripts/roadmap.py +9 -1
  259. package/skills/gsd-path-research/scripts/state_checkpoint.py +30 -9
  260. package/skills/gsd-path-research/scripts/state_promote.py +15 -4
  261. package/skills/gsd-path-roadmap/SKILL.md +16 -6
  262. package/skills/gsd-path-roadmap/references/operating-rules.md +11 -3
  263. package/skills/gsd-path-roadmap/references/runtime-selection.md +9 -0
  264. package/skills/gsd-path-roadmap/scripts/_common.py +107 -1
  265. package/skills/gsd-path-roadmap/scripts/build_recovery.py +62 -30
  266. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +92 -30
  267. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +19 -14
  268. package/skills/gsd-path-roadmap/scripts/detect_project.py +17 -2
  269. package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
  270. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +7 -6
  271. package/skills/gsd-path-roadmap/scripts/integration.py +98 -64
  272. package/skills/gsd-path-roadmap/scripts/isolation.py +148 -11
  273. package/skills/gsd-path-roadmap/scripts/members.py +68 -25
  274. package/skills/gsd-path-roadmap/scripts/model_policy.py +5 -1
  275. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +68 -30
  276. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +28 -6
  277. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
  278. package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
  279. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +30 -9
  280. package/skills/gsd-path-roadmap/scripts/state_promote.py +15 -4
  281. package/skills/gsd-path-ship/SKILL.md +26 -4
  282. package/skills/gsd-path-ship/references/operating-rules.md +11 -3
  283. package/skills/gsd-path-ship/references/reviewer-final.md +5 -5
  284. package/skills/gsd-path-ship/references/reviewer-wave.md +46 -13
  285. package/skills/gsd-path-ship/references/runtime-selection.md +9 -0
  286. package/skills/gsd-path-ship/scripts/_common.py +107 -1
  287. package/skills/gsd-path-ship/scripts/build_recovery.py +62 -30
  288. package/skills/gsd-path-ship/scripts/build_state.py +195 -5
  289. package/skills/gsd-path-ship/scripts/check_handoffs.py +92 -30
  290. package/skills/gsd-path-ship/scripts/check_task_briefs.py +19 -14
  291. package/skills/gsd-path-ship/scripts/detect_project.py +17 -2
  292. package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
  293. package/skills/gsd-path-ship/scripts/discussion_validate.py +7 -6
  294. package/skills/gsd-path-ship/scripts/integration.py +98 -64
  295. package/skills/gsd-path-ship/scripts/isolation.py +148 -11
  296. package/skills/gsd-path-ship/scripts/members.py +68 -25
  297. package/skills/gsd-path-ship/scripts/model_policy.py +5 -1
  298. package/skills/gsd-path-ship/scripts/pipeline_git.py +68 -30
  299. package/skills/gsd-path-ship/scripts/pipeline_state.py +28 -6
  300. package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
  301. package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
  302. package/skills/gsd-path-ship/scripts/state_checkpoint.py +30 -9
  303. package/skills/gsd-path-ship/scripts/state_promote.py +15 -4
  304. package/skills/gsd-path-ship/scripts/workflow_run.py +9 -2
  305. package/skills/gsd-path-ship/templates/final-review.md +5 -5
  306. package/skills/gsd-path-ship/templates/wave-review.md +18 -9
  307. package/skills/gsd-path-undo/references/operating-rules.md +11 -3
  308. package/skills/gsd-path-undo/references/runtime-selection.md +9 -0
  309. package/skills/gsd-path-undo/scripts/_common.py +107 -1
  310. package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
  311. package/skills/gsd-path-undo/scripts/model_policy.py +5 -1
  312. package/skills/gsd-path-undo/scripts/pipeline_undo.py +17 -8
  313. package/skills/path/DEFINE.md +6 -2
  314. package/skills/path/PLAN.md +3 -0
  315. package/skills/path/ROADMAP.md +16 -6
  316. package/skills/path/SHIP.md +26 -4
  317. package/skills/path/SKILL.md +52 -24
  318. package/skills/path/references/build-native.md +96 -9
  319. package/skills/path/references/build-recovery.md +13 -0
  320. package/skills/path/references/coder.md +7 -3
  321. package/skills/path/references/operating-rules.md +11 -3
  322. package/skills/path/references/planner.md +13 -1
  323. package/skills/path/references/reviewer-final.md +5 -5
  324. package/skills/path/references/reviewer-wave.md +46 -13
  325. package/skills/path/references/runtime-selection.md +9 -0
  326. package/skills/path/scripts/_common.py +107 -1
  327. package/skills/path/scripts/build_recovery.py +62 -30
  328. package/skills/path/scripts/build_state.py +195 -5
  329. package/skills/path/scripts/check_handoffs.py +92 -30
  330. package/skills/path/scripts/check_task_briefs.py +19 -14
  331. package/skills/path/scripts/detect_project.py +17 -2
  332. package/skills/path/scripts/discussion_records.py +9 -1
  333. package/skills/path/scripts/discussion_validate.py +7 -6
  334. package/skills/path/scripts/dispatch_driver.py +157 -31
  335. package/skills/path/scripts/git_guard.py +24 -5
  336. package/skills/path/scripts/integration.py +98 -64
  337. package/skills/path/scripts/isolation.py +148 -11
  338. package/skills/path/scripts/members.py +68 -25
  339. package/skills/path/scripts/model_policy.py +5 -1
  340. package/skills/path/scripts/pipeline_git.py +68 -30
  341. package/skills/path/scripts/pipeline_state.py +28 -6
  342. package/skills/path/scripts/promote_lookahead.py +3 -3
  343. package/skills/path/scripts/review_findings.py +180 -44
  344. package/skills/path/scripts/roadmap.py +9 -1
  345. package/skills/path/scripts/state_checkpoint.py +30 -9
  346. package/skills/path/scripts/state_promote.py +15 -4
  347. package/skills/path/scripts/task_context.py +2 -0
  348. package/skills/path/scripts/workflow_run.py +9 -2
  349. package/skills/path/templates/final-review.md +5 -5
  350. package/skills/path/templates/state.md +1 -0
  351. package/skills/path/templates/task.md +1 -0
  352. package/skills/path/templates/wave-review.md +18 -9
package/WORKFLOW.md CHANGED
@@ -161,7 +161,8 @@ Owned state routes by STATE.md without rerunning the helper. Reserve `classify`
161
161
  for read-only inspection; do not re-derive a verdict from a directory listing.
162
162
 
163
163
  For a newly initialized existing Git repository, the state router returns
164
- `bind-initial` while `STATE.branch` is null. The router fetches `origin/main`,
164
+ `bind-initial` while `STATE.branch` is null. The router fetches the recorded
165
+ [default branch](#default-branch) (`origin/main` in this document),
165
166
  runs `pipeline_git.py bind-initial` with that exact SHA, and records the
166
167
  returned `gsd-path/M00N` binding through `pipeline_state.py transition` before
167
168
  entering inspect or define. Build consumes this binding and never creates or
@@ -540,6 +541,49 @@ the subject and field body defined by its canonical phase contract; task-land
540
541
  fields live only in the
541
542
  [build contract](skills/gsd-path-build/SKILL.md).
542
543
 
544
+ ### Default branch
545
+
546
+ Path ships onto the default branch of `origin`. The name is not fixed.
547
+ `detect_project.py initialize` reads the local `refs/remotes/origin/HEAD` one
548
+ time and records the name in `STATE.md` as `default_branch`; an unset
549
+ `origin/HEAD` records `main`. Run `git remote set-head origin --auto` before
550
+ the first Path run so that the record is the default branch of the forge. A
551
+ `STATE.md` without the field means `main`, so older projects do not change.
552
+
553
+ This document writes `main` and `origin/main` for the recorded default branch.
554
+ In a project that records `master`, read them as `master` and `origin/master`:
555
+ the integration merge subject ends with `into master`, and the user's merge
556
+ request in `external-landing` mode targets `master`.
557
+
558
+ The record does not change after initialization. Bind, integrate, validate,
559
+ promotion, and the git guard read it. When the forge default no longer agrees
560
+ with the record, integration stops with `remote default must be <recorded>`.
561
+ A lookahead `next/STATE.md` must carry the same `default_branch` as the active
562
+ state, or promotion stops. Names in the reserved `gsd-path/`,
563
+ `gsd-path-integrate/`, and `gsd-path-task/` namespaces, and names with `#` or
564
+ a quote character, cannot be recorded.
565
+
566
+ In a multi-repo project, each repository has its own record. The coordinator
567
+ records its default branch in `STATE.md`, as above. `members.py add` reads the
568
+ local `refs/remotes/origin/HEAD` of the member one time and records the name
569
+ in the member's section of `.project/MEMBERS.md`, as `Default branch: <name>`;
570
+ an unset `origin/HEAD` records `main`. Run `git remote set-head origin --auto`
571
+ in the member before you add it. A section without the field means `main`, so
572
+ an older `MEMBERS.md` does not change. The members of one project can have
573
+ different default branches, and the coordinator default can differ from them.
574
+ The same name rules apply.
575
+
576
+ Build start, the task brief check, undo, the member pre-push guard, and member
577
+ close read the member record. The member bound branch starts at
578
+ `origin/<recorded>`, the member merge subject ends with `into <recorded>`, and
579
+ a member pull request targets `<recorded>`. When the remote default of the
580
+ member no longer agrees with the record, `members.py validate` and member
581
+ close stop with `remote default must be <recorded>`.
582
+
583
+ Not supported: a new repository that Path creates uses `main`. This includes
584
+ `members.py add --create`, which stops when the new repository has another
585
+ default branch.
586
+
543
587
  ### Integration
544
588
 
545
589
  After the postcommit validator passes, ship performs integration — the only
@@ -548,14 +592,15 @@ receives merges or back-merges, and the default checkout is never entered;
548
592
  the local default branch ref may lag origin, which is harmless because
549
593
  binding resolves remote SHAs. `STATE.integration_default` stores the project
550
594
  choice and `STATE.integration` stores the current milestone choice. Both are
551
- `direct` or `pull-request`; they may change only before build, and the current
595
+ `direct`, `pull-request`, or `external-landing`; they may change only before build, and the current
552
596
  milestone resets to the project default at the next handoff. Older v2 state
553
597
  without these fields means `direct`. `STATE.integration_source` is `default` or
554
598
  `milestone` so an explicit override remains distinct when its value happens to
555
599
  match the project default.
556
600
 
557
601
  Ship fetches origin, refreshes `origin/HEAD`, mirrors published milestone tags,
558
- and requires the remote default to be `main`. It then follows the locked mode:
602
+ and requires the remote default to be the recorded
603
+ [default branch](#default-branch). It then follows the locked mode:
559
604
 
560
605
  - `direct` creates a temporary named worktree (`gsd-path-integrate/M00N`) at
561
606
  the fetched remote-default SHA and merges the ship commit with `--no-ff`
@@ -571,6 +616,16 @@ and requires the remote default to be `main`. It then follows the locked mode:
571
616
  never enables auto-merge or merges the PR. After validation, Path creates the
572
617
  annotated milestone tag. The [ship contract](skills/gsd-path-ship/SKILL.md)
573
618
  owns the candidate, provenance, topology, publication, and recovery rules.
619
+ - `external-landing` is for an origin that is not GitHub.com, such as GitLab,
620
+ Gitea, or GitHub Enterprise Server, where a merge request is the only route
621
+ to `main`. It needs no forge CLI. Path publishes the bound branch at the
622
+ exact ship commit, does not push `main`, and returns `awaiting-merge` with
623
+ the required merge subject and body. You open the merge request in your
624
+ forge (for example with `glab`), let your pipeline run, and merge it with a
625
+ merge commit that has exactly that message. Do not squash, rebase, or
626
+ fast-forward. Your forge may delete the source branch after the merge. On
627
+ the next ship invocation, Path validates the two-parent merge on `main` and
628
+ creates the annotated milestone tag.
574
629
 
575
630
  Ship leaves the primary worktree and `STATE.branch` on the shipped local
576
631
  `gsd-path/M00N`; the router owns the later branch handoff. NNN always comes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-path",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Disk-backed AI agent pipeline. Docs: DOCS.md (start here), QUICK.md, FULL.md, UPDATE.md",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,7 +25,8 @@ import signal
25
25
  import subprocess
26
26
  import time
27
27
  from pathlib import Path
28
- from typing import BinaryIO, Iterator, List, Optional, Sequence
28
+ from typing import BinaryIO, Iterable, Iterator, List, Optional, Sequence, Tuple
29
+
29
30
 
30
31
  # Chosen by what imports, not os.name, so tests can emulate Windows on POSIX.
31
32
  try:
@@ -161,6 +162,33 @@ def project_ignore_error(repo: Path) -> Optional[str]:
161
162
  + ". Anchor the product rule (for example `/build/`) in a task that owns it.")
162
163
 
163
164
 
165
+ _DEFAULT_BRANCH_RE = re.compile(
166
+ r"[^\s#\"'\\~^:?*\[\x00-\x1f\x7f-][^\s#\"'\\~^:?*\[\x00-\x1f\x7f]*"
167
+ )
168
+ _RESERVED_BRANCH_PREFIXES = ("gsd-path/", "gsd-path-integrate/", "gsd-path-task/")
169
+
170
+
171
+ def valid_default_branch(name: str) -> bool:
172
+ """A git branch name that STATE frontmatter can hold and Path does not reserve."""
173
+ return (
174
+ bool(_DEFAULT_BRANCH_RE.fullmatch(name))
175
+ and name != "null"
176
+ and not name.startswith(_RESERVED_BRANCH_PREFIXES)
177
+ and not name.endswith(("/", ".", ".lock"))
178
+ and not any(part in name for part in ("..", "//", "/.", "@{"))
179
+ )
180
+
181
+
182
+ def origin_default_branch(repo: Path) -> str:
183
+ """The origin default branch that initialization records; an unset origin/HEAD means main."""
184
+ result = run_git(repo, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
185
+ prefix = "refs/remotes/origin/"
186
+ remote_ref = result.stdout.strip()
187
+ if result.returncode != 0 or not remote_ref.startswith(prefix):
188
+ return "main"
189
+ return remote_ref.removeprefix(prefix)
190
+
191
+
164
192
  def section_body(text: str, heading: str) -> Optional[str]:
165
193
  """The text under `## <heading>` up to the next `## `, or None when absent."""
166
194
  match = re.search(
@@ -169,6 +197,79 @@ def section_body(text: str, heading: str) -> Optional[str]:
169
197
  return match.group("body") if match else None
170
198
 
171
199
 
200
+ def overlapping_paths(left: Sequence[str], right: Sequence[str]) -> List[str]:
201
+ """Return the sorted paths that two task `files` lists both cover.
202
+
203
+ An entry covers itself and the paths below it. For a declared directory
204
+ and a path below it, the result names the lower path. The callers have no
205
+ task base, so this does not ask if an entry is a file: the task-brief
206
+ lint rejects a path below a file at the base.
207
+ """
208
+ return sorted(
209
+ {
210
+ max(one, other, key=len)
211
+ for one in left
212
+ for other in right
213
+ if one == other or one.startswith(other + "/") or other.startswith(one + "/")
214
+ }
215
+ )
216
+
217
+
218
+ # The `- ` of an indented sub-bullet line. A line that would then read as a
219
+ # numbered item (`- 2. text`) keeps its marker, so both readings of a source
220
+ # have the same criteria.
221
+ SUB_BULLET_MARKER = re.compile(r"(?m)^([ \t]+)-[ \t]+(?![ \t]*\d+\.\s)")
222
+
223
+
224
+ def without_sub_bullet_markers(source: str) -> str:
225
+ """`source` with the `- ` marker of each indented sub-bullet line removed."""
226
+ return SUB_BULLET_MARKER.sub(r"\1", source)
227
+
228
+
229
+ class Criterion(str):
230
+ """A criterion as its parser joins it, with the `- ` markers of its sub-bullet lines.
231
+
232
+ `bare` is the same criterion parsed from `without_sub_bullet_markers(source)`.
233
+ The joined text cannot tell a marker from a minus sign; the source lines can.
234
+ """
235
+
236
+ bare: str
237
+
238
+ def __new__(cls, text: str, bare: str) -> "Criterion":
239
+ criterion = super().__new__(cls, text)
240
+ criterion.bare = bare
241
+ return criterion
242
+
243
+
244
+ def quotes_criterion(quote: str, criterion: str) -> bool:
245
+ """Whether a review quote is `criterion`, for every reader.
246
+
247
+ Only whitespace is collapsed. The quote must equal the criterion with all
248
+ the markers of its sub-bullet lines or with none of them; every other
249
+ character, a hyphen inside a line too, is compared.
250
+ """
251
+ forms = (criterion, getattr(criterion, "bare", criterion))
252
+ return " ".join(quote.split()) in {" ".join(form.split()) for form in forms}
253
+
254
+
255
+ def quoted_criterion(item: str, criteria: Sequence[str]) -> Optional[Tuple[int, int]]:
256
+ """Index of the criterion quoted at the start of `item`, and where the quote ends.
257
+
258
+ A criterion can contain ` — ` itself, so each separator and the end of
259
+ `item` is a candidate, the longest quote first. A criterion as written
260
+ wins over any criterion read without its sub-bullet markers.
261
+ """
262
+ ends = [match.start() for match in re.finditer("(?= —(?: |$))|$", item)][::-1]
263
+ for bare in (False, True):
264
+ for end in ends:
265
+ quote = " ".join(item[:end].split())
266
+ for index, criterion in enumerate(criteria):
267
+ form = getattr(criterion, "bare", criterion) if bare else criterion
268
+ if quote == " ".join(form.split()):
269
+ return index, end
270
+ return None
271
+
272
+
172
273
  def task_verify_command(task_text: str) -> str:
173
274
  """The Verify shell text, excluding the closing fence's separator newline."""
174
275
  body = section_body(task_text, "Verify")
@@ -176,6 +277,11 @@ def task_verify_command(task_text: str) -> str:
176
277
  return block.group("block").removesuffix("\n") if block else ""
177
278
 
178
279
 
280
+ def fix_verify(commands: Iterable[str]) -> str:
281
+ """The Verify of a fix task: each distinct source Verify in its own subshell, under `set -e`."""
282
+ return "set -e\n" + "\n".join(f"(\n{command}\n)" for command in dict.fromkeys(commands))
283
+
284
+
179
285
  def latest_verify_entry(entries: list, command: str, commit: str,
180
286
  repo: Optional[str] = None) -> Optional[dict]:
181
287
  """Rows are keyed by (command, repo, commit); a row without `repo` is a coordinator row."""
@@ -22,6 +22,10 @@ MARKER = "build recovery: "
22
22
  RECOVERY_KEYS = frozenset({"base", "branch", "kind", "source"})
23
23
 
24
24
 
25
+ # Only the value of a cap line is exempt; a comment or a line separator after it stays in the compared text.
26
+ CAP_VALUE = re.compile(r"(?m)^(-[ \t]*(?:max_review_cycles|wave_budget):)[ \t\w.,$/:]*")
27
+
28
+
25
29
  def runtime():
26
30
  if __package__:
27
31
  from scripts import pipeline_state
@@ -58,8 +62,17 @@ def _parse_recovery_record(value: object) -> dict:
58
62
  return {**value, "source": source}
59
63
 
60
64
 
61
- def _recovery_base_proves_milestone(before, current, source: str) -> bool:
62
- if before.branch != current.branch or before.milestone != current.milestone:
65
+ def _recovery_base_proves_milestone(before, current, source: str, active: bool = True) -> bool:
66
+ if before.branch != current.branch:
67
+ return False
68
+ if before.milestone != current.milestone and (
69
+ # Issue #374: a legacy base records milestone null when define never
70
+ # named it. The owner names it at ship, after the build resumed, so
71
+ # only a closed build recovery accepts an unset base milestone; the
72
+ # bound branch already encodes M00N. A milestone named by the base
73
+ # must still equal the current one.
74
+ active or source != "build" or before.milestone is not None
75
+ ):
63
76
  return False
64
77
  if source == "plan":
65
78
  return (before.phase, before.status) in {("plan", "active"), ("plan", "blocked")}
@@ -99,7 +112,9 @@ def context(repo: Path, text: str | None = None) -> dict | None:
99
112
  if base_text is None:
100
113
  raise state.PipelineStateError("build recovery base has no STATE.md")
101
114
  before = state._state_from_text(base_text)
102
- if not _recovery_base_proves_milestone(before, current, recovery.get("source", "build")):
115
+ if not _recovery_base_proves_milestone(
116
+ before, current, recovery.get("source", "build"), recovery["active"]
117
+ ):
103
118
  raise state.PipelineStateError("build recovery base does not prove this blocked milestone")
104
119
  return recovery
105
120
 
@@ -289,61 +304,78 @@ def validate_plan(repo: Path) -> None:
289
304
  intent = checkpoint_runtime()._git_text_at(repo, recovery["base"], ".project/intent/INTENT.md")
290
305
  if state._read_real_file(repo / ".project/intent/INTENT.md", "INTENT.md") != intent:
291
306
  raise state.PipelineStateError("plan repair cannot change approved intent")
292
- unchanged = unchanged_waves(repo, recovery)
293
- for path in (repo / ".project/review").iterdir():
294
- match = re.match(r"wave-([1-9]\d*)[.]", path.name)
295
- if match and int(match[1]) not in unchanged:
296
- raise state.PipelineStateError("changed wave must be reviewed after build resumes")
307
+ if stale_review_reasons(repo, recovery, _review_waves(repo / ".project/review")):
308
+ raise state.PipelineStateError("changed wave must be reviewed after build resumes")
297
309
 
298
310
 
299
- def unchanged_waves(repo: Path, recovery: dict) -> set[int]:
300
- """Reuse evidence only when its intent, wave contract, and task bytes match."""
311
+ def _review_waves(directory: Path) -> set[int]:
312
+ return {int(match[1]) for path in directory.iterdir()
313
+ if (match := re.match(r"wave-([1-9]\d*)[.]", path.name))}
314
+
315
+
316
+ def stale_review_reasons(repo: Path, recovery: dict, waves: set[int]) -> dict[int, str]:
317
+ """Say why a wave's preserved review is stale; a wave not returned has matching intent, plan, and task bytes."""
301
318
  state = runtime()
302
319
  project = repo / ".project"
303
320
  old_intent = checkpoint_runtime()._git_text_at(repo, recovery["base"], ".project/intent/INTENT.md")
304
321
  if state._read_real_file(project / "intent/INTENT.md", "INTENT.md") != old_intent:
305
- return set()
322
+ return dict.fromkeys(waves, "INTENT.md changed")
306
323
  old_plan = checkpoint_runtime()._git_text_at(repo, recovery["base"], ".project/plan/PLAN.md") or ""
307
324
  new_plan = state._read_real_file(project / "plan/PLAN.md", "PLAN.md")
325
+ panel_lines = r"(?m)^-[ \t]*review_panel:.*$"
326
+ if re.findall(panel_lines, old_plan) != re.findall(panel_lines, new_plan):
327
+ return dict.fromkeys(waves, "PLAN.md `review_panel` setting changed")
308
328
 
309
329
  def sections(text):
310
330
  return {match[1]: match[2] for match in re.finditer(
311
331
  r"(?ms)^## ([^\n]+)\n(.*?)(?=^## |\Z)", text,
312
332
  )}
313
333
 
334
+ def governing(plan):
335
+ # Non-wave settings govern every wave, including coverage and surfaces. Caps only bound
336
+ # later work and Dependency notes are prose, so neither changes what a passed review judged.
337
+ return {title: CAP_VALUE.sub(r"\1", body) if title == "Config" else body
338
+ for title, body in plan.items()
339
+ if not title.startswith("Wave ") and title != "Dependency notes"}
340
+
314
341
  old_sections, new_sections = sections(old_plan), sections(new_plan)
315
- # Non-wave settings govern every wave, including coverage and surfaces.
316
- if ({k: v for k, v in old_sections.items() if not k.startswith("Wave ")}
317
- != {k: v for k, v in new_sections.items() if not k.startswith("Wave ")}):
318
- return set()
342
+ old_governing, new_governing = governing(old_sections), governing(new_sections)
343
+ for title in sorted(old_governing.keys() | new_governing.keys()):
344
+ if old_governing.get(title) != new_governing.get(title):
345
+ return dict.fromkeys(waves, f"PLAN.md section `{title}` changed")
319
346
  old_tasks = checkpoint_runtime()._task_contracts_at(repo, recovery["base"], ".project/tasks")
320
347
  current = settled_tasks(repo)
321
- unchanged = set()
322
- for title, body in new_sections.items():
323
- match = re.match(r"Wave ([1-9]\d*)\b", title)
324
- if not match or old_sections.get(title) != body:
348
+ reasons = {}
349
+ for wave in waves:
350
+ section = {title: body for title, body in new_sections.items() if re.match(rf"Wave {wave}\b", title)}
351
+ if not section or any(old_sections.get(title) != body for title, body in section.items()):
352
+ reasons[wave] = f"PLAN.md section `Wave {wave}` changed"
325
353
  continue
326
- wave = match[1]
327
354
  before = {Path(path).name: text for path, text in old_tasks.values()
328
- if task_fields(text, path).get("wave") == wave}
329
- after = {name: text for name, (text, fields) in current.items() if fields.get("wave") == wave}
330
- if before and before == after:
331
- unchanged.add(int(wave))
332
- return unchanged
355
+ if task_fields(text, path).get("wave") == str(wave)}
356
+ after = {name: text for name, (text, fields) in current.items() if fields.get("wave") == str(wave)}
357
+ if not before or before != after:
358
+ reasons[wave] = f"wave {wave} task contracts changed"
359
+ return reasons
333
360
 
334
361
 
335
- def restore_unchanged_reviews(repo: Path) -> None:
362
+ def restore_unchanged_reviews(repo: Path) -> dict | None:
363
+ """Restore reusable wave reviews and report every preserved wave, so a dropped review is visible at approval."""
336
364
  recovery = context(repo)
337
365
  if not recovery or not recovery["active"]:
338
- return
339
- unchanged = unchanged_waves(repo, recovery)
340
- for path in review_backup(repo, recovery).iterdir():
366
+ return None
367
+ backup = review_backup(repo, recovery)
368
+ waves = _review_waves(backup)
369
+ reasons = stale_review_reasons(repo, recovery, waves)
370
+ for path in backup.iterdir():
341
371
  match = re.match(r"wave-([1-9]\d*)[.]", path.name)
342
- if match and int(match[1]) in unchanged:
372
+ if match and int(match[1]) not in reasons:
343
373
  destination = repo / ".project/review" / path.name
344
374
  content = path.read_text(encoding="utf-8")
345
375
  if not destination.exists() or runtime()._read_real_file(destination, destination.name) != content:
346
376
  runtime()._atomic_write(destination, content)
377
+ return {"restored": sorted(waves - reasons.keys()),
378
+ "review_again": [{"wave": wave, "reason": reasons[wave]} for wave in sorted(reasons)]}
347
379
 
348
380
 
349
381
  def inventory_checkpoint(repo: Path, head: str) -> bool:
@@ -94,6 +94,8 @@ class Task:
94
94
  verify_heavy: bool
95
95
  repo: str = ""
96
96
  review_findings: bool = False
97
+ verify: str = ""
98
+ supersedes: Tuple[str, ...] = ()
97
99
 
98
100
 
99
101
  @dataclass(frozen=True)
@@ -248,6 +250,8 @@ def _parse_task(path: Path, relative_path: str, task_file: Optional[str] = None)
248
250
  verify_heavy=_verify_heavy(text, label),
249
251
  repo=str(fields.get("repo") or ""),
250
252
  review_findings=REVIEW_FINDINGS_HEADING in _sections(text),
253
+ verify=_common.task_verify_command(text),
254
+ supersedes=_normalize_list(fields.get("supersedes", []), f"{label} supersedes", task_ids=True),
251
255
  )
252
256
 
253
257
 
@@ -420,7 +424,8 @@ def _recovery_reports(project: Project) -> Dict[str, Mapping[str, object]]:
420
424
  return reports
421
425
 
422
426
 
423
- def _validate_ready_metadata(project: Project) -> None:
427
+ def _validate_ready_metadata(project: Project) -> Dict[str, Mapping[str, object]]:
428
+ """Validate every task against its recovery report; return the reports by task id."""
424
429
  by_id = {task.task_id: task for task in project.tasks}
425
430
  recovery = _recovery_reports(project)
426
431
  seen_worktrees: Dict[str, str] = {}
@@ -484,12 +489,155 @@ def _validate_ready_metadata(project: Project) -> None:
484
489
  if report.get("verdict") not in LANDED_VERDICTS:
485
490
  reason = report.get("reason", "landing commit is not proven")
486
491
  _invalid_state(task, str(reason))
492
+ return recovery
487
493
 
488
494
 
489
495
  def _overlap(left: Task, right: Task) -> List[str]:
490
496
  if left.repo != right.repo:
491
497
  return []
492
- return sorted(set(left.files) & set(right.files))
498
+ return _common.overlapping_paths(left.files, right.files)
499
+
500
+
501
+ def _superseded(tasks: Sequence[Task], task: Task) -> Dict[str, str]:
502
+ """Earlier task id -> the task that replaces its behavior: this task, or a `done` task."""
503
+
504
+ return {
505
+ earlier: other.task_id
506
+ for other in tasks
507
+ if other.status == "done" or other.task_id == task.task_id
508
+ for earlier in other.supersedes
509
+ }
510
+
511
+
512
+ def _held(by_id: Mapping[str, Task], task: Task) -> List[Task]:
513
+ """The tasks whose protective role this task inherits: each task it supersedes, through a chain."""
514
+
515
+ held: Dict[str, Task] = {}
516
+ pending = list(task.supersedes)
517
+ while pending:
518
+ other = by_id.get(pending.pop())
519
+ if other is not None and other.task_id not in held: # a cycle ends here
520
+ held[other.task_id] = other
521
+ pending.extend(other.supersedes)
522
+ return [by_id[name] for name in sorted(held)]
523
+
524
+
525
+ def _live_verify(
526
+ by_id: Mapping[str, Task], superseded: Mapping[str, str], source: Task
527
+ ) -> Tuple[str, Optional[str], List[Tuple[str, str]]]:
528
+ """The Verify of a source that still runs, or the task that retired all of it, and the drops.
529
+
530
+ The Verify of a fix task is exactly the composition that fix-tasks generates from the
531
+ Verify of each task in its deps, so it carries a copy of each: only the copies that are
532
+ not retired stay in the command. Any other Verify is one command. Each drop is
533
+ (superseded task, superseding task); a copy whose command a live dependency still runs
534
+ is not a drop.
535
+ """
536
+
537
+ if source.task_id in superseded:
538
+ return "", superseded[source.task_id], [(source.task_id, superseded[source.task_id])]
539
+ deps = [by_id[dependency] for dependency in source.deps if dependency in by_id]
540
+ if not deps or source.verify != _common.fix_verify(dep.verify for dep in deps):
541
+ return source.verify, None, []
542
+ parts = [_live_verify(by_id, superseded, dep) for dep in deps]
543
+ live = [command for command, retired_by, _ in parts if not retired_by]
544
+ dropped = list(dict.fromkeys(drop for _, _, drops in parts for drop in drops
545
+ if by_id[drop[0]].verify not in live))
546
+ if not live:
547
+ return "", parts[0][1], dropped
548
+ return _common.fix_verify(live), None, dropped
549
+
550
+
551
+ def _regression_sources(
552
+ tasks: Sequence[Task], task: Task
553
+ ) -> List[Tuple[Task, List[str], Optional[str], str, List[Tuple[str, str]]]]:
554
+ """Each `done` task whose Verify protects a file of this task:
555
+ (source, replaced, retired_by, command, dropped).
556
+
557
+ A source is a task of an earlier wave that shares a declared file with this task. A task
558
+ that supersedes others inherits their role: it is also a source, in any wave, for a task
559
+ that shares a file with a task it holds, and `replaced` names those tasks.
560
+ `retired_by` names the task that supersedes the source: this task, or a `done` task. A
561
+ retired Verify does not run, also not as a copy in the Verify of a fix task: `command`
562
+ is the Verify that runs and `dropped` names each retired copy (`_live_verify`). The source still counts for the one-at-a-time
563
+ rule, so the superseding task never runs beside a task that shares a file of the
564
+ superseded task.
565
+ """
566
+
567
+ by_id = {other.task_id: other for other in tasks}
568
+ superseded = _superseded(tasks, task)
569
+ sources = []
570
+ for other in tasks:
571
+ if other.status != "done" or other.task_id == task.task_id:
572
+ continue
573
+ replaced = [held.task_id for held in _held(by_id, other) if _overlap(task, held)]
574
+ if replaced or (other.wave < task.wave and _overlap(task, other)):
575
+ command, retired_by, dropped = _live_verify(by_id, superseded, other)
576
+ sources.append((other, replaced, retired_by, command, dropped))
577
+ return sources
578
+
579
+
580
+ def _landing_tasks(repo: Path, task_id: str, project_dir: str) -> Tuple[List[Task], Task]:
581
+ tasks = [
582
+ _parse_task(path, path.relative_to(repo).as_posix())
583
+ for path in sorted((repo / project_dir / "tasks").glob("*.md"))
584
+ ]
585
+ task = next((item for item in tasks if item.task_id == task_id), None)
586
+ if task is None:
587
+ raise BuildStateError("unknown-task", f"task file is missing: {task_id}")
588
+ return tasks, task
589
+
590
+
591
+ def retired_verifies(
592
+ repo: Path, task_id: str, project_dir: str = DEFAULT_PROJECT_DIR
593
+ ) -> List[Dict[str, str]]:
594
+ """Return the earlier-wave tasks whose Verify a landing of this task does not run again.
595
+
596
+ A task's plan-approved `supersedes` names them. The Verify is retired for that task, and
597
+ for every task that lands after that task is `done`: the Verify of the superseding task
598
+ then runs in its place (`regression_verifies`, key `supersedes`). A fix task whose
599
+ generated Verify holds only retired copies is here too. A fix task whose generated Verify
600
+ loses some of its copies has one entry for each, with the superseded task under `part`.
601
+ """
602
+
603
+ tasks, task = _landing_tasks(repo, task_id, project_dir)
604
+ retired: List[Dict[str, str]] = []
605
+ for other, _, retired_by, _, dropped in _regression_sources(tasks, task):
606
+ if retired_by:
607
+ if other.verify:
608
+ retired.append({"task": other.task_id, "superseded_by": retired_by})
609
+ else:
610
+ retired.extend({"task": other.task_id, "part": part, "superseded_by": by}
611
+ for part, by in dropped)
612
+ return retired
613
+
614
+
615
+ def regression_verifies(
616
+ repo: Path, task_id: str, own: str, project_dir: str = DEFAULT_PROJECT_DIR
617
+ ) -> List[Dict[str, object]]:
618
+ """Return the Verify commands of landed tasks that a landing of this task must also pass.
619
+
620
+ One entry per distinct command, with every source task that has it, and under
621
+ `supersedes` the tasks whose retired Verify it replaces for this landing. A command
622
+ that the task's own Verify already runs is left out: an identical command, or
623
+ a dependency's command when the own Verify is exactly the fix-task Verify of
624
+ the task's dependencies. Text that only contains a command proves nothing.
625
+ A retired Verify is not here, also not as a copy in the Verify of a fix task: see
626
+ `retired_verifies`.
627
+ """
628
+
629
+ tasks, task = _landing_tasks(repo, task_id, project_dir)
630
+ by_id = {item.task_id: item for item in tasks}
631
+ composed = [by_id[dependency].verify for dependency in task.deps if dependency in by_id]
632
+ covered = {own, *(composed if own == _common.fix_verify(composed) else ())}
633
+ sources: Dict[str, Dict[str, object]] = {}
634
+ for other, replaced, _, command, _ in _regression_sources(tasks, task):
635
+ if command and command not in covered:
636
+ entry = sources.setdefault(command, {"tasks": [], "command": command})
637
+ entry["tasks"].append(other.task_id)
638
+ if replaced: # this Verify runs in the place of the Verify of each task it replaced
639
+ entry["supersedes"] = sorted({*entry.get("supersedes", ()), *replaced})
640
+ return list(sources.values())
493
641
 
494
642
 
495
643
  def _blocked_wave_reviews(project: Project) -> Dict[int, int]:
@@ -522,12 +670,31 @@ def _blocked_wave_reviews(project: Project) -> Dict[int, int]:
522
670
  }
523
671
 
524
672
 
673
+ def _unreviewed_waves(project: Project, waves: Sequence[int]) -> List[int]:
674
+ """Return the given waves that still need a canonical review file of their own.
675
+
676
+ A repair wave is left out: the next review cycle of its source wave judges it.
677
+ """
678
+
679
+ review_dir = project.tasks_dir.parent / "review"
680
+ reviewed = {
681
+ wave for wave in waves
682
+ if all(task.review_findings for task in project.tasks if task.wave == wave)
683
+ }
684
+ if review_dir.is_dir() and not review_dir.is_symlink():
685
+ for path in review_dir.iterdir():
686
+ named = check_handoffs.WAVE_REVIEW_NAME.fullmatch(path.name)
687
+ if named is not None and path.is_file() and not path.is_symlink():
688
+ reviewed.add(int(named.group("wave")))
689
+ return [wave for wave in waves if wave not in reviewed]
690
+
691
+
525
692
  def ready(repo: str, project_dir: str = DEFAULT_PROJECT_DIR, *,
526
693
  allow_done: bool = False) -> Dict[str, object]:
527
694
  """Return task readiness, optionally accepting build/done for completion checks."""
528
695
 
529
696
  project = _load_project(repo, project_dir, ("active", "done") if allow_done else ("active",))
530
- _validate_ready_metadata(project)
697
+ reports = _validate_ready_metadata(project)
531
698
  by_id = {task.task_id: task for task in project.tasks}
532
699
  unfinished_waves = sorted({task.wave for task in project.tasks if task.status != "done"})
533
700
  current_wave = unfinished_waves[0] if unfinished_waves else None
@@ -626,11 +793,31 @@ def ready(repo: str, project_dir: str = DEFAULT_PROJECT_DIR, *,
626
793
  {"wave": current_wave, "tasks": stalled},
627
794
  )
628
795
 
796
+ # Tasks that share a regression source land one at a time, each from the landing of the one
797
+ # before, so the source's Verify runs on their combined edits. A parallel round leaves the
798
+ # primary task file pending: a retained isolate shows that the task is already dispatched.
799
+ # A retired source still counts: its superseding task holds its role.
800
+ sources = {task.task_id: {other.task_id for other, _, _, _, _ in _regression_sources(project.tasks, task)}
801
+ for task in concurrent}
802
+ started = [task for task in selectable if reports[task.task_id].get("verdict") != "none"]
803
+ claimed = {source for task in active + started for source in sources[task.task_id]}
804
+ dispatchable = []
805
+ for task in selectable:
806
+ if task in started or not sources[task.task_id] & claimed:
807
+ dispatchable.append(task)
808
+ claimed |= sources[task.task_id]
809
+
629
810
  typed: Dict[str, object] = {}
630
811
  if repair_for_wave is not None:
631
812
  typed["repair_for_wave"] = repair_for_wave
632
813
  if blocked_review is not None:
633
814
  typed["blocked_review"] = blocked_review
815
+ # Informational: every wave below the lowest unfinished wave is all done.
816
+ unreviewed = _unreviewed_waves(project, sorted(
817
+ {task.wave for task in project.tasks if unfinished_waves and task.wave < unfinished_waves[0]}
818
+ ))
819
+ if unreviewed:
820
+ typed["unreviewed_waves"] = unreviewed
634
821
  return {
635
822
  "command": "ready",
636
823
  "branch": project.branch,
@@ -646,10 +833,13 @@ def ready(repo: str, project_dir: str = DEFAULT_PROJECT_DIR, *,
646
833
  "deps": list(task.deps),
647
834
  "files": list(task.files),
648
835
  "task_file": task.task_file,
649
- "verify_heavy": task.verify_heavy,
836
+ # The landing also runs each regression Verify, so a heavy one makes the task heavy.
837
+ "verify_heavy": task.verify_heavy
838
+ or any(other.verify_heavy and not retired_by
839
+ for other, _, retired_by, _, _ in _regression_sources(project.tasks, task)),
650
840
  **({"repo": task.repo} if task.repo else {}),
651
841
  }
652
- for task in selectable
842
+ for task in dispatchable
653
843
  ],
654
844
  }
655
845