@opengsd/gsd-path 1.5.0 → 1.6.1

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 (363) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +169 -0
  3. package/DOCS.md +4 -3
  4. package/FULL.md +68 -7
  5. package/HOOKS.md +81 -4
  6. package/MIGRATE.md +12 -0
  7. package/QUICK.md +4 -2
  8. package/README.md +69 -16
  9. package/RUNTIME.md +23 -5
  10. package/UPDATE.md +35 -0
  11. package/WORKFLOW.md +66 -7
  12. package/package.json +1 -1
  13. package/scripts/_common.py +150 -1
  14. package/scripts/build_recovery.py +62 -30
  15. package/scripts/build_state.py +195 -5
  16. package/scripts/check_handoffs.py +92 -30
  17. package/scripts/check_task_briefs.py +27 -19
  18. package/scripts/detect_project.py +17 -2
  19. package/scripts/discussion_records.py +9 -1
  20. package/scripts/discussion_validate.py +7 -6
  21. package/scripts/dispatch_driver.py +174 -36
  22. package/scripts/git_guard.py +42 -7
  23. package/scripts/guard_hook.py +1216 -47
  24. package/scripts/install.mjs +76 -3
  25. package/scripts/install.py +182 -37
  26. package/scripts/integration.py +115 -68
  27. package/scripts/isolation.py +192 -23
  28. package/scripts/members.py +68 -25
  29. package/scripts/migrate_core.py +2 -0
  30. package/scripts/model_policy.py +13 -2
  31. package/scripts/pipeline_diagnose.py +17 -2
  32. package/scripts/pipeline_git.py +87 -35
  33. package/scripts/pipeline_state.py +31 -7
  34. package/scripts/pipeline_undo.py +17 -8
  35. package/scripts/promote_lookahead.py +3 -3
  36. package/scripts/review_findings.py +180 -44
  37. package/scripts/roadmap.py +9 -1
  38. package/scripts/runtime_store.py +1 -0
  39. package/scripts/state_checkpoint.py +30 -9
  40. package/scripts/state_promote.py +92 -5
  41. package/scripts/task_context.py +2 -0
  42. package/scripts/workflow_run.py +9 -2
  43. package/skills/gsd-path/DEFINE.md +6 -2
  44. package/skills/gsd-path/PLAN.md +6 -2
  45. package/skills/gsd-path/ROADMAP.md +16 -6
  46. package/skills/gsd-path/SHIP.md +38 -11
  47. package/skills/gsd-path/SKILL.md +52 -24
  48. package/skills/gsd-path/references/build-native.md +96 -9
  49. package/skills/gsd-path/references/build-recovery.md +13 -0
  50. package/skills/gsd-path/references/coder.md +7 -3
  51. package/skills/gsd-path/references/model-policy.md +3 -1
  52. package/skills/gsd-path/references/operating-rules.md +14 -5
  53. package/skills/gsd-path/references/planner.md +13 -1
  54. package/skills/gsd-path/references/reviewer-final.md +5 -5
  55. package/skills/gsd-path/references/reviewer-wave.md +46 -13
  56. package/skills/gsd-path/references/runtime-selection.md +10 -0
  57. package/skills/gsd-path/scripts/_common.py +150 -1
  58. package/skills/gsd-path/scripts/build_recovery.py +62 -30
  59. package/skills/gsd-path/scripts/build_state.py +195 -5
  60. package/skills/gsd-path/scripts/check_handoffs.py +92 -30
  61. package/skills/gsd-path/scripts/check_task_briefs.py +27 -19
  62. package/skills/gsd-path/scripts/detect_project.py +17 -2
  63. package/skills/gsd-path/scripts/discussion_records.py +9 -1
  64. package/skills/gsd-path/scripts/discussion_validate.py +7 -6
  65. package/skills/gsd-path/scripts/dispatch_driver.py +174 -36
  66. package/skills/gsd-path/scripts/git_guard.py +42 -7
  67. package/skills/gsd-path/scripts/integration.py +115 -68
  68. package/skills/gsd-path/scripts/isolation.py +192 -23
  69. package/skills/gsd-path/scripts/members.py +68 -25
  70. package/skills/gsd-path/scripts/model_policy.py +13 -2
  71. package/skills/gsd-path/scripts/pipeline_git.py +87 -35
  72. package/skills/gsd-path/scripts/pipeline_state.py +31 -7
  73. package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
  74. package/skills/gsd-path/scripts/review_findings.py +180 -44
  75. package/skills/gsd-path/scripts/roadmap.py +9 -1
  76. package/skills/gsd-path/scripts/state_checkpoint.py +30 -9
  77. package/skills/gsd-path/scripts/state_promote.py +92 -5
  78. package/skills/gsd-path/scripts/task_context.py +2 -0
  79. package/skills/gsd-path/scripts/workflow_run.py +9 -2
  80. package/skills/gsd-path/templates/final-review.md +5 -5
  81. package/skills/gsd-path/templates/state.md +1 -0
  82. package/skills/gsd-path/templates/task.md +1 -0
  83. package/skills/gsd-path/templates/wave-review.md +18 -9
  84. package/skills/gsd-path-build/references/build-native.md +96 -9
  85. package/skills/gsd-path-build/references/build-recovery.md +13 -0
  86. package/skills/gsd-path-build/references/coder.md +7 -3
  87. package/skills/gsd-path-build/references/model-policy.md +3 -1
  88. package/skills/gsd-path-build/references/operating-rules.md +14 -5
  89. package/skills/gsd-path-build/references/reviewer-final.md +5 -5
  90. package/skills/gsd-path-build/references/reviewer-wave.md +46 -13
  91. package/skills/gsd-path-build/references/runtime-selection.md +10 -0
  92. package/skills/gsd-path-build/scripts/_common.py +150 -1
  93. package/skills/gsd-path-build/scripts/build_recovery.py +62 -30
  94. package/skills/gsd-path-build/scripts/build_state.py +195 -5
  95. package/skills/gsd-path-build/scripts/check_handoffs.py +92 -30
  96. package/skills/gsd-path-build/scripts/check_task_briefs.py +27 -19
  97. package/skills/gsd-path-build/scripts/detect_project.py +17 -2
  98. package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
  99. package/skills/gsd-path-build/scripts/discussion_validate.py +7 -6
  100. package/skills/gsd-path-build/scripts/dispatch_driver.py +174 -36
  101. package/skills/gsd-path-build/scripts/integration.py +115 -68
  102. package/skills/gsd-path-build/scripts/isolation.py +192 -23
  103. package/skills/gsd-path-build/scripts/members.py +68 -25
  104. package/skills/gsd-path-build/scripts/model_policy.py +13 -2
  105. package/skills/gsd-path-build/scripts/pipeline_git.py +87 -35
  106. package/skills/gsd-path-build/scripts/pipeline_state.py +31 -7
  107. package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
  108. package/skills/gsd-path-build/scripts/review_findings.py +180 -44
  109. package/skills/gsd-path-build/scripts/roadmap.py +9 -1
  110. package/skills/gsd-path-build/scripts/state_checkpoint.py +30 -9
  111. package/skills/gsd-path-build/scripts/state_promote.py +92 -5
  112. package/skills/gsd-path-build/scripts/task_context.py +2 -0
  113. package/skills/gsd-path-build/scripts/workflow_run.py +9 -2
  114. package/skills/gsd-path-build/templates/task.md +1 -0
  115. package/skills/gsd-path-build/templates/wave-review.md +18 -9
  116. package/skills/gsd-path-decide/references/model-policy.md +3 -1
  117. package/skills/gsd-path-decide/references/operating-rules.md +14 -5
  118. package/skills/gsd-path-decide/references/runtime-selection.md +10 -0
  119. package/skills/gsd-path-decide/scripts/_common.py +150 -1
  120. package/skills/gsd-path-decide/scripts/build_recovery.py +62 -30
  121. package/skills/gsd-path-decide/scripts/check_handoffs.py +92 -30
  122. package/skills/gsd-path-decide/scripts/check_task_briefs.py +27 -19
  123. package/skills/gsd-path-decide/scripts/detect_project.py +17 -2
  124. package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
  125. package/skills/gsd-path-decide/scripts/discussion_validate.py +7 -6
  126. package/skills/gsd-path-decide/scripts/integration.py +115 -68
  127. package/skills/gsd-path-decide/scripts/isolation.py +192 -23
  128. package/skills/gsd-path-decide/scripts/members.py +68 -25
  129. package/skills/gsd-path-decide/scripts/model_policy.py +13 -2
  130. package/skills/gsd-path-decide/scripts/pipeline_git.py +87 -35
  131. package/skills/gsd-path-decide/scripts/pipeline_state.py +31 -7
  132. package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
  133. package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
  134. package/skills/gsd-path-decide/scripts/state_checkpoint.py +30 -9
  135. package/skills/gsd-path-decide/scripts/state_promote.py +92 -5
  136. package/skills/gsd-path-define/SKILL.md +6 -2
  137. package/skills/gsd-path-define/references/build-recovery.md +13 -0
  138. package/skills/gsd-path-define/references/operating-rules.md +14 -5
  139. package/skills/gsd-path-define/references/runtime-selection.md +10 -0
  140. package/skills/gsd-path-define/scripts/_common.py +150 -1
  141. package/skills/gsd-path-define/scripts/build_recovery.py +62 -30
  142. package/skills/gsd-path-define/scripts/check_handoffs.py +92 -30
  143. package/skills/gsd-path-define/scripts/check_task_briefs.py +27 -19
  144. package/skills/gsd-path-define/scripts/detect_project.py +17 -2
  145. package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
  146. package/skills/gsd-path-define/scripts/discussion_validate.py +7 -6
  147. package/skills/gsd-path-define/scripts/integration.py +115 -68
  148. package/skills/gsd-path-define/scripts/isolation.py +192 -23
  149. package/skills/gsd-path-define/scripts/members.py +68 -25
  150. package/skills/gsd-path-define/scripts/model_policy.py +13 -2
  151. package/skills/gsd-path-define/scripts/pipeline_git.py +87 -35
  152. package/skills/gsd-path-define/scripts/pipeline_state.py +31 -7
  153. package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
  154. package/skills/gsd-path-define/scripts/roadmap.py +9 -1
  155. package/skills/gsd-path-define/scripts/state_checkpoint.py +30 -9
  156. package/skills/gsd-path-define/scripts/state_promote.py +92 -5
  157. package/skills/gsd-path-define/templates/state.md +1 -0
  158. package/skills/gsd-path-discuss/references/operating-rules.md +14 -5
  159. package/skills/gsd-path-discuss/references/runtime-selection.md +10 -0
  160. package/skills/gsd-path-discuss/scripts/_common.py +150 -1
  161. package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
  162. package/skills/gsd-path-discuss/scripts/model_policy.py +13 -2
  163. package/skills/gsd-path-docs-audit/references/model-policy.md +3 -1
  164. package/skills/gsd-path-docs-audit/references/operating-rules.md +14 -5
  165. package/skills/gsd-path-docs-audit/references/runtime-selection.md +10 -0
  166. package/skills/gsd-path-docs-audit/scripts/_common.py +150 -1
  167. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +62 -30
  168. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +92 -30
  169. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +27 -19
  170. package/skills/gsd-path-docs-audit/scripts/detect_project.py +17 -2
  171. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
  172. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +7 -6
  173. package/skills/gsd-path-docs-audit/scripts/integration.py +115 -68
  174. package/skills/gsd-path-docs-audit/scripts/isolation.py +192 -23
  175. package/skills/gsd-path-docs-audit/scripts/members.py +68 -25
  176. package/skills/gsd-path-docs-audit/scripts/model_policy.py +13 -2
  177. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +87 -35
  178. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +31 -7
  179. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
  180. package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
  181. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +30 -9
  182. package/skills/gsd-path-docs-audit/scripts/state_promote.py +92 -5
  183. package/skills/gsd-path-forensics/references/operating-rules.md +14 -5
  184. package/skills/gsd-path-forensics/references/runtime-selection.md +10 -0
  185. package/skills/gsd-path-forensics/scripts/_common.py +150 -1
  186. package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
  187. package/skills/gsd-path-forensics/scripts/model_policy.py +13 -2
  188. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
  189. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +17 -8
  190. package/skills/gsd-path-inspect/references/model-policy.md +3 -1
  191. package/skills/gsd-path-inspect/references/operating-rules.md +14 -5
  192. package/skills/gsd-path-inspect/references/runtime-selection.md +10 -0
  193. package/skills/gsd-path-inspect/scripts/_common.py +150 -1
  194. package/skills/gsd-path-inspect/scripts/build_recovery.py +62 -30
  195. package/skills/gsd-path-inspect/scripts/check_handoffs.py +92 -30
  196. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +27 -19
  197. package/skills/gsd-path-inspect/scripts/detect_project.py +17 -2
  198. package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
  199. package/skills/gsd-path-inspect/scripts/discussion_validate.py +7 -6
  200. package/skills/gsd-path-inspect/scripts/integration.py +115 -68
  201. package/skills/gsd-path-inspect/scripts/isolation.py +192 -23
  202. package/skills/gsd-path-inspect/scripts/members.py +68 -25
  203. package/skills/gsd-path-inspect/scripts/model_policy.py +13 -2
  204. package/skills/gsd-path-inspect/scripts/pipeline_git.py +87 -35
  205. package/skills/gsd-path-inspect/scripts/pipeline_state.py +31 -7
  206. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
  207. package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
  208. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +30 -9
  209. package/skills/gsd-path-inspect/scripts/state_promote.py +92 -5
  210. package/skills/gsd-path-inspect/scripts/workflow_run.py +9 -2
  211. package/skills/gsd-path-inspect/templates/state.md +1 -0
  212. package/skills/gsd-path-loop/references/operating-rules.md +14 -5
  213. package/skills/gsd-path-loop/references/runtime-selection.md +10 -0
  214. package/skills/gsd-path-loop/scripts/_common.py +150 -1
  215. package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
  216. package/skills/gsd-path-loop/scripts/model_policy.py +13 -2
  217. package/skills/gsd-path-migrate/SKILL.md +8 -0
  218. package/skills/gsd-path-migrate/references/operating-rules.md +14 -5
  219. package/skills/gsd-path-migrate/references/runtime-selection.md +10 -0
  220. package/skills/gsd-path-migrate/scripts/_common.py +150 -1
  221. package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
  222. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
  223. package/skills/gsd-path-migrate/scripts/model_policy.py +13 -2
  224. package/skills/gsd-path-plan/SKILL.md +6 -2
  225. package/skills/gsd-path-plan/references/build-recovery.md +13 -0
  226. package/skills/gsd-path-plan/references/model-policy.md +3 -1
  227. package/skills/gsd-path-plan/references/operating-rules.md +14 -5
  228. package/skills/gsd-path-plan/references/planner.md +13 -1
  229. package/skills/gsd-path-plan/references/runtime-selection.md +10 -0
  230. package/skills/gsd-path-plan/scripts/_common.py +150 -1
  231. package/skills/gsd-path-plan/scripts/build_recovery.py +62 -30
  232. package/skills/gsd-path-plan/scripts/check_handoffs.py +92 -30
  233. package/skills/gsd-path-plan/scripts/check_task_briefs.py +27 -19
  234. package/skills/gsd-path-plan/scripts/detect_project.py +17 -2
  235. package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
  236. package/skills/gsd-path-plan/scripts/discussion_validate.py +7 -6
  237. package/skills/gsd-path-plan/scripts/integration.py +115 -68
  238. package/skills/gsd-path-plan/scripts/isolation.py +192 -23
  239. package/skills/gsd-path-plan/scripts/members.py +68 -25
  240. package/skills/gsd-path-plan/scripts/model_policy.py +13 -2
  241. package/skills/gsd-path-plan/scripts/pipeline_git.py +87 -35
  242. package/skills/gsd-path-plan/scripts/pipeline_state.py +31 -7
  243. package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
  244. package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
  245. package/skills/gsd-path-plan/scripts/state_checkpoint.py +30 -9
  246. package/skills/gsd-path-plan/scripts/state_promote.py +92 -5
  247. package/skills/gsd-path-plan/scripts/workflow_run.py +9 -2
  248. package/skills/gsd-path-plan/templates/task.md +1 -0
  249. package/skills/gsd-path-research/references/model-policy.md +3 -1
  250. package/skills/gsd-path-research/references/operating-rules.md +14 -5
  251. package/skills/gsd-path-research/references/runtime-selection.md +10 -0
  252. package/skills/gsd-path-research/scripts/_common.py +150 -1
  253. package/skills/gsd-path-research/scripts/build_recovery.py +62 -30
  254. package/skills/gsd-path-research/scripts/check_handoffs.py +92 -30
  255. package/skills/gsd-path-research/scripts/check_task_briefs.py +27 -19
  256. package/skills/gsd-path-research/scripts/detect_project.py +17 -2
  257. package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
  258. package/skills/gsd-path-research/scripts/discussion_validate.py +7 -6
  259. package/skills/gsd-path-research/scripts/integration.py +115 -68
  260. package/skills/gsd-path-research/scripts/isolation.py +192 -23
  261. package/skills/gsd-path-research/scripts/members.py +68 -25
  262. package/skills/gsd-path-research/scripts/model_policy.py +13 -2
  263. package/skills/gsd-path-research/scripts/pipeline_git.py +87 -35
  264. package/skills/gsd-path-research/scripts/pipeline_state.py +31 -7
  265. package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
  266. package/skills/gsd-path-research/scripts/roadmap.py +9 -1
  267. package/skills/gsd-path-research/scripts/state_checkpoint.py +30 -9
  268. package/skills/gsd-path-research/scripts/state_promote.py +92 -5
  269. package/skills/gsd-path-roadmap/SKILL.md +16 -6
  270. package/skills/gsd-path-roadmap/references/model-policy.md +3 -1
  271. package/skills/gsd-path-roadmap/references/operating-rules.md +14 -5
  272. package/skills/gsd-path-roadmap/references/runtime-selection.md +10 -0
  273. package/skills/gsd-path-roadmap/scripts/_common.py +150 -1
  274. package/skills/gsd-path-roadmap/scripts/build_recovery.py +62 -30
  275. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +92 -30
  276. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +27 -19
  277. package/skills/gsd-path-roadmap/scripts/detect_project.py +17 -2
  278. package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
  279. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +7 -6
  280. package/skills/gsd-path-roadmap/scripts/integration.py +115 -68
  281. package/skills/gsd-path-roadmap/scripts/isolation.py +192 -23
  282. package/skills/gsd-path-roadmap/scripts/members.py +68 -25
  283. package/skills/gsd-path-roadmap/scripts/model_policy.py +13 -2
  284. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +87 -35
  285. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +31 -7
  286. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
  287. package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
  288. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +30 -9
  289. package/skills/gsd-path-roadmap/scripts/state_promote.py +92 -5
  290. package/skills/gsd-path-ship/SKILL.md +38 -11
  291. package/skills/gsd-path-ship/references/model-policy.md +3 -1
  292. package/skills/gsd-path-ship/references/operating-rules.md +14 -5
  293. package/skills/gsd-path-ship/references/reviewer-final.md +5 -5
  294. package/skills/gsd-path-ship/references/reviewer-wave.md +46 -13
  295. package/skills/gsd-path-ship/references/runtime-selection.md +10 -0
  296. package/skills/gsd-path-ship/scripts/_common.py +150 -1
  297. package/skills/gsd-path-ship/scripts/build_recovery.py +62 -30
  298. package/skills/gsd-path-ship/scripts/build_state.py +195 -5
  299. package/skills/gsd-path-ship/scripts/check_handoffs.py +92 -30
  300. package/skills/gsd-path-ship/scripts/check_task_briefs.py +27 -19
  301. package/skills/gsd-path-ship/scripts/detect_project.py +17 -2
  302. package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
  303. package/skills/gsd-path-ship/scripts/discussion_validate.py +7 -6
  304. package/skills/gsd-path-ship/scripts/integration.py +115 -68
  305. package/skills/gsd-path-ship/scripts/isolation.py +192 -23
  306. package/skills/gsd-path-ship/scripts/members.py +68 -25
  307. package/skills/gsd-path-ship/scripts/model_policy.py +13 -2
  308. package/skills/gsd-path-ship/scripts/pipeline_git.py +87 -35
  309. package/skills/gsd-path-ship/scripts/pipeline_state.py +31 -7
  310. package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
  311. package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
  312. package/skills/gsd-path-ship/scripts/state_checkpoint.py +30 -9
  313. package/skills/gsd-path-ship/scripts/state_promote.py +92 -5
  314. package/skills/gsd-path-ship/scripts/workflow_run.py +9 -2
  315. package/skills/gsd-path-ship/templates/final-review.md +5 -5
  316. package/skills/gsd-path-ship/templates/wave-review.md +18 -9
  317. package/skills/gsd-path-undo/references/operating-rules.md +14 -5
  318. package/skills/gsd-path-undo/references/runtime-selection.md +10 -0
  319. package/skills/gsd-path-undo/scripts/_common.py +150 -1
  320. package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
  321. package/skills/gsd-path-undo/scripts/model_policy.py +13 -2
  322. package/skills/gsd-path-undo/scripts/pipeline_undo.py +17 -8
  323. package/skills/path/DEFINE.md +6 -2
  324. package/skills/path/PLAN.md +6 -2
  325. package/skills/path/ROADMAP.md +16 -6
  326. package/skills/path/SHIP.md +38 -11
  327. package/skills/path/SKILL.md +52 -24
  328. package/skills/path/references/build-native.md +96 -9
  329. package/skills/path/references/build-recovery.md +13 -0
  330. package/skills/path/references/coder.md +7 -3
  331. package/skills/path/references/model-policy.md +3 -1
  332. package/skills/path/references/operating-rules.md +14 -5
  333. package/skills/path/references/planner.md +13 -1
  334. package/skills/path/references/reviewer-final.md +5 -5
  335. package/skills/path/references/reviewer-wave.md +46 -13
  336. package/skills/path/references/runtime-selection.md +10 -0
  337. package/skills/path/scripts/_common.py +150 -1
  338. package/skills/path/scripts/build_recovery.py +62 -30
  339. package/skills/path/scripts/build_state.py +195 -5
  340. package/skills/path/scripts/check_handoffs.py +92 -30
  341. package/skills/path/scripts/check_task_briefs.py +27 -19
  342. package/skills/path/scripts/detect_project.py +17 -2
  343. package/skills/path/scripts/discussion_records.py +9 -1
  344. package/skills/path/scripts/discussion_validate.py +7 -6
  345. package/skills/path/scripts/dispatch_driver.py +174 -36
  346. package/skills/path/scripts/git_guard.py +42 -7
  347. package/skills/path/scripts/integration.py +115 -68
  348. package/skills/path/scripts/isolation.py +192 -23
  349. package/skills/path/scripts/members.py +68 -25
  350. package/skills/path/scripts/model_policy.py +13 -2
  351. package/skills/path/scripts/pipeline_git.py +87 -35
  352. package/skills/path/scripts/pipeline_state.py +31 -7
  353. package/skills/path/scripts/promote_lookahead.py +3 -3
  354. package/skills/path/scripts/review_findings.py +180 -44
  355. package/skills/path/scripts/roadmap.py +9 -1
  356. package/skills/path/scripts/state_checkpoint.py +30 -9
  357. package/skills/path/scripts/state_promote.py +92 -5
  358. package/skills/path/scripts/task_context.py +2 -0
  359. package/skills/path/scripts/workflow_run.py +9 -2
  360. package/skills/path/templates/final-review.md +5 -5
  361. package/skills/path/templates/state.md +1 -0
  362. package/skills/path/templates/task.md +1 -0
  363. package/skills/path/templates/wave-review.md +18 -9
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import argparse
13
13
  import contextlib
14
+ import functools
14
15
  import hashlib
15
16
  import io
16
17
  import json
@@ -103,6 +104,10 @@ class IsolationError(RuntimeError):
103
104
  """Raised when isolation, landing, or retirement cannot proceed."""
104
105
 
105
106
 
107
+ class RemovalError(IsolationError):
108
+ """Raised when the removal of a sidecar worktree fails."""
109
+
110
+
106
111
  run_git = _common.run_git
107
112
 
108
113
 
@@ -146,7 +151,10 @@ def require_commit(repo: Path, value: str) -> str:
146
151
 
147
152
  def require_full_sha(value: str) -> str:
148
153
  if not re.fullmatch(r"[0-9a-f]{40}", value):
149
- raise IsolationError(f"base must be a full 40-character SHA: {value}")
154
+ raise IsolationError(
155
+ f"base must be a full 40-character SHA: {value}; "
156
+ "resolve a ref or a short SHA with git rev-parse first"
157
+ )
150
158
  return value
151
159
 
152
160
 
@@ -714,6 +722,18 @@ def member_checkout(coordinator: Path, member: str) -> Tuple[Path, str]:
714
722
  return checkout, str(role["project"])
715
723
 
716
724
 
725
+ def member_default_branch(coordinator: Path, member: str) -> str:
726
+ """The default branch that MEMBERS.md records for a member; a row without it means main."""
727
+ try:
728
+ import members
729
+ except ImportError: # pragma: no cover - package import used by tests
730
+ from scripts import members
731
+ try:
732
+ return members.member_default_branch(require_directory(coordinator, "coordinator"), member)
733
+ except members.MembersError as error:
734
+ raise IsolationError(str(error)) from error
735
+
736
+
717
737
  def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
718
738
  """Path's own checkout of a locked member's bound branch, outside the team's checkout."""
719
739
  checkout, _, entry = _member_context(coordinator, member)
@@ -833,7 +853,10 @@ def retire_member_task(
833
853
  if destination.resolve() in registered:
834
854
  if registered[destination.resolve()] != ref:
835
855
  raise IsolationError(f"member task sidecar is on another branch: {destination}")
836
- _remove_worktree(checkout, destination, force)
856
+ try:
857
+ _remove_worktree(checkout, destination, force)
858
+ except IsolationError as error:
859
+ raise RemovalError(str(error)) from error
837
860
  authorization = run_git(checkout, "update-ref", "-d", member_task_authorization_ref(project, task_id))
838
861
  if authorization.returncode != 0:
839
862
  raise IsolationError((authorization.stderr or authorization.stdout).strip() or "git update-ref failed")
@@ -1177,7 +1200,7 @@ def _prove_member_commit(
1177
1200
  parent, commit).splitlines())
1178
1201
  if not changed:
1179
1202
  return "commit changes nothing"
1180
- undeclared = sorted(changed - allowed)
1203
+ undeclared = undeclared_paths(repo, parent, changed, allowed)
1181
1204
  if undeclared:
1182
1205
  return "undeclared paths: " + ", ".join(undeclared)
1183
1206
  if git_output(repo, "log", "-1", "--format=%s", commit) != subject:
@@ -1193,12 +1216,20 @@ def _commit_member_source(sidecar: Path, journal: Dict[str, object], allowed: Se
1193
1216
  if pending:
1194
1217
  if current_sha(sidecar) != member_base:
1195
1218
  raise IsolationError("dirty member source HEAD must equal the member base")
1196
- undeclared = sorted(pending - allowed)
1219
+ undeclared = undeclared_paths(sidecar, member_base, pending, allowed)
1197
1220
  if undeclared:
1198
1221
  raise IsolationError("undeclared paths: " + ", ".join(undeclared))
1199
- git_output(sidecar, "add", "-A", "--", *sorted(pending))
1200
1222
  body = member_commit_body(str(journal["task_file"]), sorted(pending), member_base, str(journal["base"]))
1201
- git_output(sidecar, "commit", "-q", "-m", str(journal["subject"]), "-m", body.strip())
1223
+ # The paths and the body grow with the change set, so both go through
1224
+ # stdin: a Windows command line holds about 32 KiB.
1225
+ for arguments, text in (
1226
+ (("add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"),
1227
+ "".join(f"{path}\0" for path in sorted(pending))),
1228
+ (("commit", "-q", "-F", "-"), _commit_message(str(journal["subject"]), body)),
1229
+ ):
1230
+ result = run_git(sidecar, *arguments, input=text)
1231
+ if result.returncode != 0:
1232
+ raise IsolationError((result.stderr or result.stdout).strip() or "git command failed")
1202
1233
  source = current_sha(sidecar)
1203
1234
  if source == member_base:
1204
1235
  raise IsolationError("no changes to land")
@@ -1646,6 +1677,25 @@ def committed_paths_since(repo: Path, base: str) -> Set[str]:
1646
1677
  )
1647
1678
 
1648
1679
 
1680
+ def _stage_paths(repo: Path, mode: str, paths: Set[str]) -> None:
1681
+ """Stage `paths` with one `git add`.
1682
+
1683
+ One call for each path fails when a file replaces a tracked directory: the
1684
+ first call drops the files below the directory from the index, and then
1685
+ their pathspecs match no file. The `./` prefix stops pathspec magic, so a
1686
+ name such as `:!b` cannot exclude another path of the set. Git names the
1687
+ path in its error text.
1688
+ """
1689
+ if not paths:
1690
+ return
1691
+ added = run_git(
1692
+ repo, "add", mode, "--pathspec-from-file=-", "--pathspec-file-nul",
1693
+ input="".join(f"./{path}\0" for path in sorted(paths)),
1694
+ )
1695
+ if added.returncode != 0:
1696
+ raise IsolationError(f"could not stage the change set: {added.stderr.strip()}")
1697
+
1698
+
1649
1699
  def ignored_legacy_ledger(repo: Path) -> bool:
1650
1700
  """True when a verify ledger absent from HEAD is hidden by an ignore rule.
1651
1701
 
@@ -1661,17 +1711,25 @@ def ignored_legacy_ledger(repo: Path) -> bool:
1661
1711
  return run_git(repo, "check-ignore", "-q", "--no-index", "--", VERIFY_LEDGER_PATH).returncode == 0
1662
1712
 
1663
1713
 
1714
+ def _commit_message(subject: str, body: str) -> str:
1715
+ """The message for `git commit -F -`.
1716
+
1717
+ A landing body lists every changed path. As a `-m` argument it can exceed
1718
+ the Windows command-line limit, so the message goes through stdin. Like
1719
+ `-m`, the message ends its last line.
1720
+ """
1721
+ message = f"{subject}\n\n{body}"
1722
+ return message if message.endswith("\n") else message + "\n"
1723
+
1724
+
1664
1725
  def _commit_pending(
1665
1726
  repo: Path, pending: Set[str], subject: str, body: str, forced: Set[str] = frozenset()
1666
1727
  ) -> str:
1667
1728
  reset = run_git(repo, "reset", "-q", "HEAD")
1668
1729
  if reset.returncode != 0:
1669
1730
  raise IsolationError("could not clear the index before commit")
1670
- for path in sorted(pending):
1671
- mode = "-f" if path in forced else "-A"
1672
- added = run_git(repo, "add", mode, "--", path)
1673
- if added.returncode != 0:
1674
- raise IsolationError(f"could not stage {path}")
1731
+ _stage_paths(repo, "-A", pending - forced)
1732
+ _stage_paths(repo, "-f", pending & forced)
1675
1733
  staged = _split_paths(
1676
1734
  git_output(
1677
1735
  repo,
@@ -1684,12 +1742,38 @@ def _commit_pending(
1684
1742
  )
1685
1743
  if staged != pending:
1686
1744
  raise IsolationError("staged paths do not match the uncommitted change set")
1687
- committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
1745
+ committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
1688
1746
  if committed.returncode != 0:
1689
1747
  raise IsolationError((committed.stderr or committed.stdout).strip() or "commit failed")
1690
1748
  return current_sha(repo)
1691
1749
 
1692
1750
 
1751
+ def undeclared_paths(
1752
+ repo: Path, base: str, paths: Set[str], declared: Set[str]
1753
+ ) -> list[str]:
1754
+ """Return the sorted paths that the declared entries do not cover.
1755
+
1756
+ An entry covers itself and, as a declared directory, the files below it.
1757
+ An entry that is a file at `base` covers only itself: the task-brief lint
1758
+ rejects a path below a base file.
1759
+ """
1760
+
1761
+ @functools.lru_cache(maxsize=None)
1762
+ def directory(entry: str) -> bool:
1763
+ kind = run_git(repo, "cat-file", "-t", f"{base}:{entry}")
1764
+ return kind.returncode != 0 or kind.stdout.strip() == "tree"
1765
+
1766
+ return sorted(
1767
+ path
1768
+ for path in paths
1769
+ if path not in declared
1770
+ and not any(
1771
+ str(parent) in declared and directory(str(parent))
1772
+ for parent in list(PurePosixPath(path).parents)[:-1]
1773
+ )
1774
+ )
1775
+
1776
+
1693
1777
  def validate_allowed_changes(
1694
1778
  repo: Path,
1695
1779
  base: str,
@@ -1703,7 +1787,7 @@ def validate_allowed_changes(
1703
1787
  if not landing:
1704
1788
  raise IsolationError("no changes to land")
1705
1789
  since_base = committed_paths_since(repo, base) | landing
1706
- unexpected = sorted(path for path in since_base if path not in allowed)
1790
+ unexpected = undeclared_paths(repo, base, since_base, allowed)
1707
1791
  if unexpected:
1708
1792
  raise IsolationError(
1709
1793
  "unexpected paths: " + ", ".join(unexpected)
@@ -1737,18 +1821,15 @@ def commit_allowed_changes(
1737
1821
  reset = run_git(repo, "reset", "-q", "HEAD")
1738
1822
  if reset.returncode != 0:
1739
1823
  raise IsolationError("could not clear the index before landing")
1740
- for path in sorted(staging):
1741
- added = run_git(repo, "add", "-A", "--", path)
1742
- if added.returncode != 0:
1743
- raise IsolationError(f"could not stage {path}")
1824
+ _stage_paths(repo, "-A", staging)
1744
1825
  staged = _split_paths(
1745
- git_output(repo, "diff", "--cached", "--name-only", "--relative")
1826
+ git_output(repo, "diff", "--no-renames", "--cached", "--name-only", "--relative")
1746
1827
  )
1747
1828
  if staged != staging:
1748
1829
  raise IsolationError("staged paths do not match the uncommitted change set")
1749
1830
  verified_tree = git_output(repo, "write-tree")
1750
1831
  verified_delta = _tree_delta(repo, base, verified_tree)
1751
- committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
1832
+ committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
1752
1833
  if committed.returncode != 0:
1753
1834
  raise IsolationError(
1754
1835
  (committed.stderr or committed.stdout).strip() or "commit failed"
@@ -2619,7 +2700,7 @@ def _prove_task_commit(
2619
2700
  )
2620
2701
  if task_file not in changed:
2621
2702
  return None, "commit does not touch the task file"
2622
- stray = sorted(changed - contract_paths)
2703
+ stray = undeclared_paths(repo, base, changed, contract_paths)
2623
2704
  if stray:
2624
2705
  return None, f"commit touches undeclared paths: {', '.join(stray)}"
2625
2706
  if landing_error:
@@ -2711,6 +2792,56 @@ def _task_branch_proof_error(
2711
2792
  return None
2712
2793
 
2713
2794
 
2795
+ def only_missing_files(worktree: Path) -> bool:
2796
+ """True when the only difference of a worktree from its HEAD is tracked files that are missing.
2797
+
2798
+ An untracked or a gitignored file is content that Git does not have, so it makes this False.
2799
+ """
2800
+ status = run_git(worktree, "status", "--porcelain", "--untracked-files=all", "--ignored=matching")
2801
+ lines = status.stdout.splitlines()
2802
+ return status.returncode == 0 and bool(lines) and all(line.startswith(" D") for line in lines)
2803
+
2804
+
2805
+ def holds_only_landed_files(repo: Path, path: Path, commits: Sequence[str]) -> bool:
2806
+ """True when `path` is a directory, not a usable worktree, that holds only files of `commits`.
2807
+
2808
+ Each file must be the same blob at the same path in one of the commits, so the removal of
2809
+ the directory loses nothing that Git does not have. A check that cannot be done is False.
2810
+ """
2811
+
2812
+ def unreadable(error: OSError) -> None:
2813
+ raise error
2814
+
2815
+ try:
2816
+ if path.is_symlink() or not path.is_dir() or path.resolve() != path:
2817
+ return False
2818
+ root = run_git(path, "rev-parse", "--show-toplevel")
2819
+ if root.returncode == 0 and Path(root.stdout.strip()).resolve() == path:
2820
+ return False
2821
+ files = []
2822
+ for folder, folders, names in os.walk(path, onerror=unreadable):
2823
+ if any(os.path.islink(os.path.join(folder, name)) for name in folders):
2824
+ return False
2825
+ files += [os.path.join(folder, name) for name in names]
2826
+ # What a removal left of the worktree link is not content.
2827
+ files = [name for name in files if name != str(path / ".git")]
2828
+ if not all(stat.S_ISREG(os.lstat(name).st_mode) for name in files):
2829
+ return False
2830
+ known = {
2831
+ entry.split(" ", 2)[2]
2832
+ for commit in commits
2833
+ for entry in git_text(repo, "ls-tree", "-r", "-z", commit).split("\0")
2834
+ if entry
2835
+ }
2836
+ hashed = run_git(repo, "hash-object", "--stdin-paths", input="".join(f"{name}\n" for name in files))
2837
+ except (OSError, IsolationError):
2838
+ return False
2839
+ hashes = hashed.stdout.split()
2840
+ return hashed.returncode == 0 and len(hashes) == len(files) and all(
2841
+ f"{sha}\t{Path(name).relative_to(path).as_posix()}" in known for sha, name in zip(hashes, files)
2842
+ )
2843
+
2844
+
2714
2845
  def _worktree_report(
2715
2846
  primary: Path, path: Path, expected_branch: str
2716
2847
  ) -> tuple[Optional[Dict[str, object]], Optional[str]]:
@@ -2944,6 +3075,7 @@ def _recover_task(
2944
3075
  head: str,
2945
3076
  bound: str,
2946
3077
  canonical_task_file: Optional[str] = None,
3078
+ inspect_isolate: bool = True,
2947
3079
  ) -> Dict[str, object]:
2948
3080
  task_file = canonical_task_file or relative_posix(str(path.relative_to(primary)))
2949
3081
  report: Dict[str, object] = {"task": task_file}
@@ -2951,6 +3083,11 @@ def _recover_task(
2951
3083
  def result(verdict: str, **extra: object) -> Dict[str, object]:
2952
3084
  return {**report, "verdict": verdict, **extra}
2953
3085
 
3086
+ def leftover(commit: object, base: object) -> Dict[str, object]:
3087
+ # The landing is proven, so this block names only what is left to remove.
3088
+ return result("block", reason=f"task landed at {commit}; remove its leftover isolate: {sidecar}",
3089
+ commit=commit, base=base, leftover=str(sidecar))
3090
+
2954
3091
  try:
2955
3092
  task_text, _ = _read_task_text(path)
2956
3093
  except IsolationError as error:
@@ -2977,14 +3114,22 @@ def _recover_task(
2977
3114
  return result(**_prove_member_task(primary, task_file, fields, subject, history))
2978
3115
  task_branch = task_branch_name(task_id)
2979
3116
  branch_exists = task_branch in branches
2980
- worktree, worktree_error = _worktree_report(
2981
- primary, sidecar_root(primary, "task", task_id), task_branch
3117
+ sidecar = sidecar_root(primary, "task", task_id)
3118
+ worktree, worktree_error = (
3119
+ _worktree_report(primary, sidecar, task_branch) if inspect_isolate else (None, None)
2982
3120
  )
2983
3121
  report["worktree"] = worktree
2984
3122
  report["task_branch"] = task_branch if branch_exists else None
2985
3123
  report["rejected"] = []
2986
3124
 
2987
3125
  if worktree_error:
3126
+ landed = _recover_task(
3127
+ primary, path, history, branches, head, bound, canonical_task_file, False
3128
+ ) if status == "done" else {}
3129
+ if landed.get("verdict") == "recovered" and holds_only_landed_files(
3130
+ primary, sidecar, [str(landed["commit"]), *([task_branch] if branch_exists else [])]
3131
+ ):
3132
+ return leftover(landed["commit"], landed["base"])
2988
3133
  return result("block", reason=worktree_error)
2989
3134
 
2990
3135
  valid_statuses = {"pending", "in-progress", "done", "failed", "blocked"}
@@ -3238,6 +3383,9 @@ def _recover_task(
3238
3383
  if branch_error:
3239
3384
  return result("block", reason=branch_error)
3240
3385
  if worktree is not None and not bool(worktree["clean"]):
3386
+ if only_missing_files(sidecar):
3387
+ # Only landed files are missing: a removal stopped part way, and nothing unknown is there.
3388
+ return leftover(commit, base)
3241
3389
  return result(
3242
3390
  "block",
3243
3391
  reason="proven task retains a dirty worktree",
@@ -3803,7 +3951,11 @@ def _attest_body_fields(body: str) -> tuple[Dict[str, str], list[str]]:
3803
3951
 
3804
3952
 
3805
3953
  def _attested_changes(repo: Path, base: str, head: str, declared: Set[str]) -> list[str]:
3806
- """Declared paths that changed between base and head, matched literally like land."""
3954
+ """Declared paths that changed between base and head, matched literally.
3955
+
3956
+ Land also accepts files below a declared directory. Attestation does not:
3957
+ a recorded attestation body must stay provable.
3958
+ """
3807
3959
  changed = set(git_output(repo, "diff", "--name-only", base, head).splitlines())
3808
3960
  return sorted(changed & declared)
3809
3961
 
@@ -4711,6 +4863,23 @@ def retire_unused_verify(
4711
4863
  return retire(primary, expected, branch, False)
4712
4864
 
4713
4865
 
4866
+ def retire_stale_verify(primary: Path, worktree: Path, branch: str) -> Dict[str, object]:
4867
+ """Retire the Verify sidecar of an earlier prepare-task only when it is proven untouched.
4868
+
4869
+ The reflog of its branch is the record of its creation base. It must hold only the
4870
+ creation, at a commit of the bound branch; `retire_unused_verify` then proves the checkout.
4871
+ """
4872
+ try:
4873
+ created = git_output(primary, "reflog", "show", "--format=%H", f"refs/heads/{branch}").split()
4874
+ if len(created) != 1:
4875
+ raise IsolationError("its branch moved after its creation, or has no record of its creation")
4876
+ if run_git(primary, "merge-base", "--is-ancestor", created[0], "HEAD").returncode != 0:
4877
+ raise IsolationError("its branch is not at a commit of the bound branch")
4878
+ return retire_unused_verify(primary, worktree, created[0], branch)
4879
+ except IsolationError as error:
4880
+ raise IsolationError(f"Verify sidecar {worktree} is kept: {error}") from error
4881
+
4882
+
4714
4883
  def parser() -> argparse.ArgumentParser:
4715
4884
  argument_parser = argparse.ArgumentParser(description=__doc__)
4716
4885
  subparsers = argument_parser.add_subparsers(dest="command", required=True)
@@ -35,6 +35,8 @@ MARKER_SCHEMA = "gsd-path/member/v1"
35
35
  MARKER_KEYS = {"schema", "coordinator", "project", "name"}
36
36
  NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
37
37
  FIELDS = ("Checkout", "Remote", "Integration")
38
+ # Optional: `add` records it one time; a row without it means main.
39
+ DEFAULT_BRANCH_FIELD = "Default branch"
38
40
  INTEGRATIONS = ("default", "direct", "pull-request")
39
41
  GITHUB_REMOTE_RE = re.compile(
40
42
  r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([^/\s]+)/([^/\s]+)"
@@ -221,9 +223,10 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
221
223
  if not line.strip():
222
224
  continue
223
225
  key, separator, value = line.partition(":")
224
- if not separator or key not in FIELDS or key.lower() in current:
226
+ field = key.lower().replace(" ", "_")
227
+ if not separator or key not in (*FIELDS, DEFAULT_BRANCH_FIELD) or field in current:
225
228
  raise MembersError(f"MEMBERS.md line {number}: unexpected line {line!r}")
226
- current[key.lower()] = value.strip()
229
+ current[field] = value.strip()
227
230
  if not preamble or preamble[0] != "# Members":
228
231
  raise MembersError("MEMBERS.md format error: expected # Members")
229
232
  rest = preamble[1:]
@@ -247,17 +250,37 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
247
250
  )
248
251
  if not Path(member["checkout"]).is_absolute():
249
252
  raise MembersError(f"MEMBERS.md member {member['name']} Checkout must be absolute")
253
+ if not _common.valid_default_branch(default_branch(member)):
254
+ raise MembersError(
255
+ f"MEMBERS.md member {member['name']} has invalid Default branch: {member['default_branch']}"
256
+ )
250
257
  return members
251
258
 
252
259
 
260
+ def default_branch(member: dict[str, str]) -> str:
261
+ """The default branch a MEMBERS.md row records; a row without the field means main."""
262
+ return member.get("default_branch", "main")
263
+
264
+
265
+ def member_default_branch(coordinator: Path, name: str) -> str:
266
+ """The default branch MEMBERS.md records for the member `name`."""
267
+ for member in read_members(coordinator):
268
+ if member["name"] == name:
269
+ return default_branch(member)
270
+ raise MembersError(f"member {name} is not in MEMBERS.md")
271
+
272
+
253
273
  def check_member(
254
274
  coordinator: Path,
255
275
  coordinator_name: str,
256
276
  checkout: Path,
257
- recorded_remote: Optional[str] = None,
277
+ recorded: Optional[dict[str, str]] = None,
258
278
  refuse_reserved_refs: bool = True,
259
- ) -> str:
260
- """Refuse a checkout that cannot join; return its origin URL."""
279
+ ) -> tuple[str, str]:
280
+ """Refuse a checkout that cannot join; return its origin URL and default branch.
281
+
282
+ `recorded` is the member's MEMBERS.md row. Without it (`add`), the default
283
+ branch is the local origin/HEAD, read this one time; an unset ref means main."""
261
284
  if checkout.is_symlink() or not checkout.is_dir():
262
285
  raise MembersError(f"member checkout is not a real directory: {checkout}")
263
286
  checkout = checkout.resolve()
@@ -279,12 +302,21 @@ def check_member(
279
302
  remote = _git(checkout, "config", "--get", "remote.origin.url")
280
303
  if not GITHUB_REMOTE_RE.fullmatch(remote):
281
304
  raise MembersError(f"member requires a GitHub.com origin: {remote}")
282
- if recorded_remote is not None and remote != recorded_remote:
283
- raise MembersError(f"member origin changed: {recorded_remote} -> {remote}")
284
- default = _common.run_git(checkout, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
285
- if default.returncode != 0 or default.stdout.strip() != "origin/main":
286
- raise MembersError(f"member remote default must be main: {checkout}")
287
- require_origin_main(checkout)
305
+ if recorded is not None and remote != recorded["remote"]:
306
+ raise MembersError(f"member origin changed: {recorded['remote']} -> {remote}")
307
+ live = _common.origin_default_branch(checkout)
308
+ default = live if recorded is None else default_branch(recorded)
309
+ if live != default:
310
+ raise MembersError(
311
+ f"member remote default must be {default}, got {live}: {checkout}; "
312
+ "run `git remote set-head origin --auto` when the local origin/HEAD is stale"
313
+ )
314
+ if not _common.valid_default_branch(default):
315
+ raise MembersError(
316
+ f"member default branch {default!r} cannot be recorded in MEMBERS.md: it is a "
317
+ "GSD Path reserved name or it has a character that the file cannot hold"
318
+ )
319
+ require_origin_branch(checkout, default)
288
320
  member_state = checkout / ".project" / "STATE.md"
289
321
  if member_state.exists() or member_state.is_symlink():
290
322
  if member_state.is_symlink() or not member_state.is_file():
@@ -303,7 +335,7 @@ def check_member(
303
335
  colliding = [ref for ref in refs if ref.startswith(prefixes)]
304
336
  if colliding:
305
337
  raise MembersError("member refs collide with coordinator names: " + ", ".join(colliding))
306
- return remote
338
+ return remote, default
307
339
 
308
340
 
309
341
  MEMBER_CLOSE_SCHEMA = "gsd-path/member-close/v1"
@@ -338,12 +370,14 @@ def write_member_close(common: Path, archive_name: str, rows: list[dict]) -> Non
338
370
  _common.atomic_write(path, json.dumps({"schema": MEMBER_CLOSE_SCHEMA, "members": rows}, indent=2) + "\n")
339
371
 
340
372
 
341
- def require_origin_main(checkout: Path) -> None:
373
+ def require_origin_branch(checkout: Path, branch: str) -> str:
374
+ """The commit of the member's fetched default branch `branch`."""
342
375
  baseline = _common.run_git(
343
- checkout, "rev-parse", "--verify", "--quiet", "refs/remotes/origin/main^{commit}"
376
+ checkout, "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}^{{commit}}"
344
377
  )
345
378
  if baseline.returncode != 0:
346
- raise MembersError("member requires refs/remotes/origin/main; run git fetch origin")
379
+ raise MembersError(f"member requires refs/remotes/origin/{branch}; run git fetch origin")
380
+ return baseline.stdout.strip()
347
381
 
348
382
 
349
383
  def member_ref_prefixes(project: str) -> tuple[str, ...]:
@@ -405,7 +439,8 @@ def _task_members(coordinator: Path) -> set[str]:
405
439
 
406
440
  def lock_build_members(coordinator: Path) -> Optional[list[dict[str, str]]]:
407
441
  """At build start, lock the members tasks name (MEMBERS.md order) and create
408
- each member's bound branch at its origin/main. None when no task names a member."""
442
+ each member's bound branch at its recorded default branch on origin. None when
443
+ no task names a member."""
409
444
  root, state = _coordinator(coordinator)
410
445
  named = _task_members(root)
411
446
  lock_path = root / LOCK_PATH
@@ -444,8 +479,8 @@ def lock_build_members(coordinator: Path) -> Optional[list[dict[str, str]]]:
444
479
  remote = _git(checkout, "config", "--get", "remote.origin.url")
445
480
  if remote != member["remote"]:
446
481
  raise MembersError(f"member origin changed: {member['remote']} -> {remote}")
447
- require_origin_main(checkout)
448
- base = _git(checkout, "rev-parse", "refs/remotes/origin/main^{commit}")
482
+ default = default_branch(member)
483
+ base = require_origin_branch(checkout, default)
449
484
  existing = _common.run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
450
485
  if existing.returncode == 0:
451
486
  tip = existing.stdout.strip()
@@ -458,7 +493,7 @@ def lock_build_members(coordinator: Path) -> Optional[list[dict[str, str]]]:
458
493
  elif _common.run_git(checkout, "merge-base", "--is-ancestor", tip, base).returncode == 0:
459
494
  base = tip
460
495
  else:
461
- raise MembersError(f"member {member['name']} branch {branch} has commits not on origin/main")
496
+ raise MembersError(f"member {member['name']} branch {branch} has commits not on origin/{default}")
462
497
  else:
463
498
  missing.append((checkout, base))
464
499
  entries.append({"name": member["name"], "branch": branch, "base": base})
@@ -475,6 +510,8 @@ def render(members: Sequence[dict[str, str]]) -> str:
475
510
  f"Checkout: {member['checkout']}\n"
476
511
  f"Remote: {member['remote']}\n"
477
512
  f"Integration: {member['integration']}\n"
513
+ # A row that never had the field keeps none, so old rows are not rewritten.
514
+ + (f"{DEFAULT_BRANCH_FIELD}: {member['default_branch']}\n" if "default_branch" in member else "")
478
515
  for member in members
479
516
  ]
480
517
  return "\n".join([HEADER, *sections])
@@ -488,7 +525,7 @@ def add_member(repo: Path, name: str, checkout: Path, integration: str) -> list[
488
525
  raise MembersError(f"invalid member name: {name!r}")
489
526
  members = read_members(root)
490
527
  resolved = checkout.resolve()
491
- remote = check_member(root, state.project, checkout)
528
+ remote, default = check_member(root, state.project, checkout)
492
529
  common = Path(_git(resolved, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
493
530
  identity = _remote_identity(remote)
494
531
  for member in members:
@@ -499,7 +536,8 @@ def add_member(repo: Path, name: str, checkout: Path, integration: str) -> list[
499
536
  raise MembersError(f"member already recorded: {member['name']}")
500
537
  _refuse_foreign_marker(resolved, root, state.project, name)
501
538
  members.append(
502
- {"name": name, "checkout": str(resolved), "remote": remote, "integration": integration}
539
+ {"name": name, "checkout": str(resolved), "remote": remote, "integration": integration,
540
+ "default_branch": default}
503
541
  )
504
542
  _write_marker(resolved, root, state.project, name)
505
543
  _common.atomic_write(root / ".project" / MEMBERS_FILE, render(members))
@@ -560,8 +598,9 @@ def create_member(repo: Path, name: str, checkout: Path, integration: str, githu
560
598
  _common.atomic_write(journal, json.dumps(recorded, indent=2, sort_keys=True) + "\n")
561
599
  listed = [member for member in read_members(root) if member["name"] == name]
562
600
  if listed:
563
- if (len(listed) != 1 or listed[0] != {"name": name, "checkout": str(resolved), "remote": url,
564
- "integration": integration}
601
+ if (len(listed) != 1 or {**listed[0], "default_branch": default_branch(listed[0])} != {
602
+ "name": name, "checkout": str(resolved), "remote": url,
603
+ "integration": integration, "default_branch": "main"}
565
604
  or member_role(resolved) != {"coordinator": root, "project": state.project, "name": name}):
566
605
  raise MembersError(f"member already recorded with a different checkout or marker: {name}")
567
606
  journal.unlink()
@@ -610,6 +649,10 @@ def create_member(repo: Path, name: str, checkout: Path, integration: str, githu
610
649
  cloned = _common.run_git(resolved.parent, "clone", "-q", url, str(resolved))
611
650
  if cloned.returncode != 0:
612
651
  raise MembersError(f"could not clone {url}: {(cloned.stderr or cloned.stdout).strip()}")
652
+ # ponytail: --create stays main-only, because a new repository uses main;
653
+ # `add` joins an existing repository with any default branch.
654
+ if _common.origin_default_branch(resolved) != "main":
655
+ raise MembersError(f"member remote default must be main: {resolved}")
613
656
  recorded["step"] = "join"
614
657
  _common.atomic_write(journal, json.dumps(recorded, indent=2, sort_keys=True) + "\n")
615
658
  joined = add_member(root, name, resolved, integration)
@@ -622,7 +665,7 @@ def validate_members(repo: Path) -> list[dict[str, str]]:
622
665
  members = read_members(root)
623
666
  for member in members:
624
667
  checkout = Path(member["checkout"])
625
- check_member(root, state.project, checkout, member["remote"], refuse_reserved_refs=False)
668
+ check_member(root, state.project, checkout, member, refuse_reserved_refs=False)
626
669
  try:
627
670
  role = member_role(checkout)
628
671
  except MembersError as error:
@@ -641,7 +684,7 @@ def repair_members(repo: Path) -> list[dict[str, str]]:
641
684
  members = read_members(root)
642
685
  for member in members:
643
686
  checkout = Path(member["checkout"])
644
- check_member(root, state.project, checkout, member["remote"], refuse_reserved_refs=False)
687
+ check_member(root, state.project, checkout, member, refuse_reserved_refs=False)
645
688
  _refuse_foreign_marker(checkout.resolve(), root, state.project, member["name"])
646
689
  for member in members:
647
690
  checkout = Path(member["checkout"]).resolve()
@@ -10,6 +10,8 @@ import shutil
10
10
  import stat
11
11
  import sys
12
12
 
13
+ import _common
14
+
13
15
 
14
16
  def hook_inventory(settings):
15
17
  if settings.is_symlink() or not settings.is_file():
@@ -196,16 +196,27 @@ def task_settings(path):
196
196
 
197
197
  def native_record(args):
198
198
  """The parent owns lifecycle; release requires its recorded terminal-result receipt."""
199
+ # A relative path would resolve against cwd, which may be the immutable runtime store.
200
+ if not args.record.is_absolute():
201
+ raise PolicyError(f'--record must be an absolute path: {args.record}')
199
202
  record = args.record.resolve()
203
+ missing = f'release requires an existing record and terminal-result evidence: {record}'
204
+ # Check before log_lock creates the record directory and its lock file.
205
+ if args.action == 'release' and not record.exists():
206
+ raise PolicyError(missing)
200
207
  with log_lock({'log': str(record)}):
201
208
  data = read_json(record) if record.exists() else None
202
209
  identity = {'repo': str(args.repo.resolve()), 'project_dir': args.project_dir,
203
210
  'scope': args.scope, 'assignment': args.assignment, 'role': args.role}
204
211
  if data is not None and data.get('identity') != identity:
205
- raise PolicyError('record belongs to another assignment or milestone/track/cycle')
212
+ recorded = data.get('identity')
213
+ recorded = recorded if isinstance(recorded, dict) else {}
214
+ differing = ', '.join(f'{field}: {value}, record has {recorded.get(field)}'
215
+ for field, value in identity.items() if recorded.get(field) != value)
216
+ raise PolicyError(f'record belongs to another assignment or milestone/track/cycle ({differing})')
206
217
  if args.action == 'release':
207
218
  if data is None or not args.terminal_result:
208
- raise PolicyError('release requires an existing record and terminal-result evidence')
219
+ raise PolicyError(missing)
209
220
  data.update(active=False, terminal_result=args.terminal_result)
210
221
  else:
211
222
  if args.capabilities is None: