@opengsd/gsd-path 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (481) hide show
  1. package/AGENTS.md +2 -207
  2. package/CHANGELOG.md +331 -69
  3. package/DOCS.md +25 -9
  4. package/FULL.md +12 -5
  5. package/GUIDE.md +1 -1
  6. package/HOOKS.md +104 -14
  7. package/QUICK.md +5 -2
  8. package/README.md +131 -111
  9. package/RUNTIME.md +2 -2
  10. package/UPDATE.md +61 -11
  11. package/WORKFLOW.md +44 -7
  12. package/package.json +13 -9
  13. package/platforms/shared-agents/dispatch.md +13 -0
  14. package/scripts/_common.py +619 -18
  15. package/scripts/archive_milestone.py +160 -86
  16. package/scripts/bootstrap_repository.py +8 -5
  17. package/scripts/build_recovery.py +100 -32
  18. package/scripts/build_state.py +143 -24
  19. package/scripts/check_docs_audit.py +91 -7
  20. package/scripts/check_handoffs.py +662 -50
  21. package/scripts/check_task_briefs.py +338 -30
  22. package/scripts/check_update.py +2 -2
  23. package/scripts/core_hook_gate.py +10 -1
  24. package/scripts/core_hook_settings.py +11 -4
  25. package/scripts/detect_project.py +587 -32
  26. package/scripts/discussion_records.py +19 -13
  27. package/scripts/discussion_validate.py +152 -50
  28. package/scripts/dispatch_driver.py +501 -46
  29. package/scripts/git_guard.py +118 -31
  30. package/scripts/guard_hook.py +1567 -194
  31. package/scripts/install.mjs +77 -46
  32. package/scripts/install.py +611 -50
  33. package/scripts/integration.py +580 -25
  34. package/scripts/isolation.py +1438 -57
  35. package/scripts/lean_verification.py +99 -19
  36. package/scripts/loop_run.py +31 -29
  37. package/scripts/members.py +706 -0
  38. package/scripts/migrate_core.py +2 -2
  39. package/scripts/path_config.py +5 -5
  40. package/scripts/pipeline_diagnose.py +82 -31
  41. package/scripts/pipeline_git.py +124 -34
  42. package/scripts/pipeline_state.py +287 -87
  43. package/scripts/pipeline_undo.py +218 -30
  44. package/scripts/promote_lookahead.py +1 -1
  45. package/scripts/review_panel.py +1 -1
  46. package/scripts/runtime_store.py +55 -11
  47. package/scripts/skill-resources.json +37 -5
  48. package/scripts/state_checkpoint.py +135 -44
  49. package/scripts/state_promote.py +2 -0
  50. package/scripts/status_runtime.py +58 -10
  51. package/scripts/sync_skill_resources.py +8 -7
  52. package/scripts/task_context.py +12 -3
  53. package/scripts/token_budget.py +12 -8
  54. package/scripts/wizard.mjs +1 -0
  55. package/scripts/workflow_run.py +107 -30
  56. package/scripts/worktree_paths.py +72 -10
  57. package/skills/gsd-path/BUILD.md +7 -1
  58. package/skills/gsd-path/DECIDE.md +11 -4
  59. package/skills/gsd-path/DEFINE.md +36 -4
  60. package/skills/gsd-path/DOCS-AUDIT.md +6 -4
  61. package/skills/gsd-path/INSPECT.md +5 -4
  62. package/skills/gsd-path/PLAN.md +18 -4
  63. package/skills/gsd-path/RESEARCH.md +13 -1
  64. package/skills/gsd-path/ROADMAP.md +2 -1
  65. package/skills/gsd-path/SHIP.md +33 -11
  66. package/skills/gsd-path/SKILL.md +45 -1
  67. package/skills/gsd-path/references/build-native.md +63 -15
  68. package/skills/gsd-path/references/codebase-mapper.md +2 -1
  69. package/skills/gsd-path/references/dispatch.md +13 -0
  70. package/skills/gsd-path/references/docs-auditor.md +7 -1
  71. package/skills/gsd-path/references/operating-rules.md +214 -0
  72. package/skills/gsd-path/references/planner.md +22 -4
  73. package/skills/gsd-path/references/reviewer-final.md +20 -2
  74. package/skills/gsd-path/references/reviewer-wave.md +18 -5
  75. package/skills/gsd-path/references/reviewer.md +3 -1
  76. package/skills/gsd-path/references/spec-probes.md +150 -0
  77. package/skills/gsd-path/scripts/_common.py +619 -18
  78. package/skills/gsd-path/scripts/archive_milestone.py +160 -86
  79. package/skills/gsd-path/scripts/bootstrap_repository.py +8 -5
  80. package/skills/gsd-path/scripts/build_recovery.py +100 -32
  81. package/skills/gsd-path/scripts/build_state.py +143 -24
  82. package/skills/gsd-path/scripts/check_docs_audit.py +91 -7
  83. package/skills/gsd-path/scripts/check_handoffs.py +662 -50
  84. package/skills/gsd-path/scripts/check_task_briefs.py +338 -30
  85. package/skills/gsd-path/scripts/check_update.py +2 -2
  86. package/skills/gsd-path/scripts/detect_project.py +587 -32
  87. package/skills/gsd-path/scripts/discussion_records.py +19 -13
  88. package/skills/gsd-path/scripts/discussion_validate.py +152 -50
  89. package/skills/gsd-path/scripts/dispatch_driver.py +501 -46
  90. package/skills/gsd-path/scripts/git_guard.py +118 -31
  91. package/skills/gsd-path/scripts/integration.py +580 -25
  92. package/skills/gsd-path/scripts/isolation.py +1438 -57
  93. package/skills/gsd-path/scripts/lean_verification.py +99 -19
  94. package/skills/gsd-path/scripts/loop_run.py +31 -29
  95. package/skills/gsd-path/scripts/members.py +706 -0
  96. package/skills/gsd-path/scripts/path_config.py +5 -5
  97. package/skills/gsd-path/scripts/pipeline_git.py +124 -34
  98. package/skills/gsd-path/scripts/pipeline_state.py +287 -87
  99. package/skills/gsd-path/scripts/promote_lookahead.py +1 -1
  100. package/skills/gsd-path/scripts/review_panel.py +1 -1
  101. package/skills/gsd-path/scripts/state_checkpoint.py +135 -44
  102. package/skills/gsd-path/scripts/state_promote.py +2 -0
  103. package/skills/gsd-path/scripts/status_runtime.py +58 -10
  104. package/skills/gsd-path/scripts/task_context.py +12 -3
  105. package/skills/gsd-path/scripts/token_budget.py +12 -8
  106. package/skills/gsd-path/scripts/workflow_run.py +107 -30
  107. package/skills/gsd-path/scripts/worktree_paths.py +72 -10
  108. package/skills/gsd-path/templates/codebase.md +1 -1
  109. package/skills/gsd-path/templates/docs-audit.md +6 -2
  110. package/skills/gsd-path/templates/final-review.md +16 -4
  111. package/skills/gsd-path/templates/gap-review.md +2 -1
  112. package/skills/gsd-path/templates/intent.md +30 -0
  113. package/skills/gsd-path/templates/patch-findings.md +3 -3
  114. package/skills/gsd-path/templates/plan.md +17 -2
  115. package/skills/gsd-path/templates/research-handoff.md +14 -3
  116. package/skills/gsd-path/templates/state.md +1 -1
  117. package/skills/gsd-path/templates/task.md +1 -0
  118. package/skills/gsd-path/templates/wave-review.md +16 -4
  119. package/skills/gsd-path-build/SKILL.md +7 -1
  120. package/skills/gsd-path-build/references/build-native.md +63 -15
  121. package/skills/gsd-path-build/references/dispatch.md +13 -0
  122. package/skills/gsd-path-build/references/operating-rules.md +214 -0
  123. package/skills/gsd-path-build/references/reviewer-final.md +20 -2
  124. package/skills/gsd-path-build/references/reviewer-wave.md +18 -5
  125. package/skills/gsd-path-build/references/reviewer.md +3 -1
  126. package/skills/gsd-path-build/references/spec-probes.md +150 -0
  127. package/skills/gsd-path-build/scripts/_common.py +619 -18
  128. package/skills/gsd-path-build/scripts/archive_milestone.py +160 -86
  129. package/skills/gsd-path-build/scripts/build_recovery.py +100 -32
  130. package/skills/gsd-path-build/scripts/build_state.py +143 -24
  131. package/skills/gsd-path-build/scripts/check_handoffs.py +662 -50
  132. package/skills/gsd-path-build/scripts/check_task_briefs.py +338 -30
  133. package/skills/gsd-path-build/scripts/detect_project.py +587 -32
  134. package/skills/gsd-path-build/scripts/discussion_records.py +19 -13
  135. package/skills/gsd-path-build/scripts/discussion_validate.py +152 -50
  136. package/skills/gsd-path-build/scripts/dispatch_driver.py +501 -46
  137. package/skills/gsd-path-build/scripts/integration.py +580 -25
  138. package/skills/gsd-path-build/scripts/isolation.py +1438 -57
  139. package/skills/gsd-path-build/scripts/loop_run.py +31 -29
  140. package/skills/gsd-path-build/scripts/members.py +706 -0
  141. package/skills/gsd-path-build/scripts/path_config.py +5 -5
  142. package/skills/gsd-path-build/scripts/pipeline_git.py +124 -34
  143. package/skills/gsd-path-build/scripts/pipeline_state.py +287 -87
  144. package/skills/gsd-path-build/scripts/promote_lookahead.py +1 -1
  145. package/skills/gsd-path-build/scripts/review_panel.py +1 -1
  146. package/skills/gsd-path-build/scripts/state_checkpoint.py +135 -44
  147. package/skills/gsd-path-build/scripts/state_promote.py +2 -0
  148. package/skills/gsd-path-build/scripts/task_context.py +12 -3
  149. package/skills/gsd-path-build/scripts/token_budget.py +12 -8
  150. package/skills/gsd-path-build/scripts/workflow_run.py +107 -30
  151. package/skills/gsd-path-build/scripts/worktree_paths.py +72 -10
  152. package/skills/gsd-path-build/templates/task.md +1 -0
  153. package/skills/gsd-path-build/templates/wave-review.md +16 -4
  154. package/skills/gsd-path-decide/SKILL.md +11 -4
  155. package/skills/gsd-path-decide/references/dispatch.md +13 -0
  156. package/skills/gsd-path-decide/references/operating-rules.md +214 -0
  157. package/skills/gsd-path-decide/scripts/_common.py +619 -18
  158. package/skills/gsd-path-decide/scripts/archive_milestone.py +160 -86
  159. package/skills/gsd-path-decide/scripts/build_recovery.py +100 -32
  160. package/skills/gsd-path-decide/scripts/check_handoffs.py +662 -50
  161. package/skills/gsd-path-decide/scripts/check_task_briefs.py +338 -30
  162. package/skills/gsd-path-decide/scripts/detect_project.py +587 -32
  163. package/skills/gsd-path-decide/scripts/discussion_records.py +19 -13
  164. package/skills/gsd-path-decide/scripts/discussion_validate.py +152 -50
  165. package/skills/gsd-path-decide/scripts/integration.py +580 -25
  166. package/skills/gsd-path-decide/scripts/isolation.py +1438 -57
  167. package/skills/gsd-path-decide/scripts/loop_run.py +31 -29
  168. package/skills/gsd-path-decide/scripts/members.py +706 -0
  169. package/skills/gsd-path-decide/scripts/path_config.py +5 -5
  170. package/skills/gsd-path-decide/scripts/pipeline_git.py +124 -34
  171. package/skills/gsd-path-decide/scripts/pipeline_state.py +287 -87
  172. package/skills/gsd-path-decide/scripts/promote_lookahead.py +1 -1
  173. package/skills/gsd-path-decide/scripts/review_panel.py +1 -1
  174. package/skills/gsd-path-decide/scripts/state_checkpoint.py +135 -44
  175. package/skills/gsd-path-decide/scripts/state_promote.py +2 -0
  176. package/skills/gsd-path-decide/scripts/worktree_paths.py +72 -10
  177. package/skills/gsd-path-define/SKILL.md +36 -4
  178. package/skills/gsd-path-define/references/operating-rules.md +214 -0
  179. package/skills/gsd-path-define/references/spec-probes.md +150 -0
  180. package/skills/gsd-path-define/scripts/_common.py +619 -18
  181. package/skills/gsd-path-define/scripts/archive_milestone.py +160 -86
  182. package/skills/gsd-path-define/scripts/build_recovery.py +100 -32
  183. package/skills/gsd-path-define/scripts/check_handoffs.py +662 -50
  184. package/skills/gsd-path-define/scripts/check_task_briefs.py +338 -30
  185. package/skills/gsd-path-define/scripts/detect_project.py +587 -32
  186. package/skills/gsd-path-define/scripts/discussion_records.py +19 -13
  187. package/skills/gsd-path-define/scripts/discussion_validate.py +152 -50
  188. package/skills/gsd-path-define/scripts/integration.py +580 -25
  189. package/skills/gsd-path-define/scripts/isolation.py +1438 -57
  190. package/skills/gsd-path-define/scripts/loop_run.py +31 -29
  191. package/skills/gsd-path-define/scripts/members.py +706 -0
  192. package/skills/gsd-path-define/scripts/path_config.py +5 -5
  193. package/skills/gsd-path-define/scripts/pipeline_git.py +124 -34
  194. package/skills/gsd-path-define/scripts/pipeline_state.py +287 -87
  195. package/skills/gsd-path-define/scripts/promote_lookahead.py +1 -1
  196. package/skills/gsd-path-define/scripts/review_panel.py +1 -1
  197. package/skills/gsd-path-define/scripts/state_checkpoint.py +135 -44
  198. package/skills/gsd-path-define/scripts/state_promote.py +2 -0
  199. package/skills/gsd-path-define/scripts/worktree_paths.py +72 -10
  200. package/skills/gsd-path-define/templates/intent.md +30 -0
  201. package/skills/gsd-path-define/templates/state.md +1 -1
  202. package/skills/gsd-path-discuss/SKILL.md +2 -1
  203. package/skills/gsd-path-discuss/references/operating-rules.md +214 -0
  204. package/skills/gsd-path-discuss/scripts/_common.py +619 -18
  205. package/skills/gsd-path-discuss/scripts/discussion_records.py +19 -13
  206. package/skills/gsd-path-discuss/scripts/loop_run.py +31 -29
  207. package/skills/gsd-path-discuss/scripts/path_config.py +5 -5
  208. package/skills/gsd-path-discuss/scripts/review_panel.py +1 -1
  209. package/skills/gsd-path-discuss/scripts/worktree_paths.py +72 -10
  210. package/skills/gsd-path-docs-audit/SKILL.md +6 -4
  211. package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
  212. package/skills/gsd-path-docs-audit/references/docs-auditor.md +7 -1
  213. package/skills/gsd-path-docs-audit/references/operating-rules.md +214 -0
  214. package/skills/gsd-path-docs-audit/scripts/_common.py +619 -18
  215. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +160 -86
  216. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +100 -32
  217. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +91 -7
  218. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +662 -50
  219. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +338 -30
  220. package/skills/gsd-path-docs-audit/scripts/detect_project.py +587 -32
  221. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +19 -13
  222. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +152 -50
  223. package/skills/gsd-path-docs-audit/scripts/integration.py +580 -25
  224. package/skills/gsd-path-docs-audit/scripts/isolation.py +1438 -57
  225. package/skills/gsd-path-docs-audit/scripts/loop_run.py +31 -29
  226. package/skills/gsd-path-docs-audit/scripts/members.py +706 -0
  227. package/skills/gsd-path-docs-audit/scripts/path_config.py +5 -5
  228. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +124 -34
  229. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +287 -87
  230. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +1 -1
  231. package/skills/gsd-path-docs-audit/scripts/review_panel.py +1 -1
  232. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +135 -44
  233. package/skills/gsd-path-docs-audit/scripts/state_promote.py +2 -0
  234. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +72 -10
  235. package/skills/gsd-path-docs-audit/templates/docs-audit.md +6 -2
  236. package/skills/gsd-path-forensics/SKILL.md +2 -1
  237. package/skills/gsd-path-forensics/references/operating-rules.md +214 -0
  238. package/skills/gsd-path-forensics/scripts/_common.py +619 -18
  239. package/skills/gsd-path-forensics/scripts/discussion_records.py +19 -13
  240. package/skills/gsd-path-forensics/scripts/loop_run.py +31 -29
  241. package/skills/gsd-path-forensics/scripts/path_config.py +5 -5
  242. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +82 -31
  243. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +218 -30
  244. package/skills/gsd-path-forensics/scripts/review_panel.py +1 -1
  245. package/skills/gsd-path-forensics/scripts/worktree_paths.py +72 -10
  246. package/skills/gsd-path-inspect/SKILL.md +5 -4
  247. package/skills/gsd-path-inspect/references/codebase-mapper.md +2 -1
  248. package/skills/gsd-path-inspect/references/dispatch.md +13 -0
  249. package/skills/gsd-path-inspect/references/docs-auditor.md +7 -1
  250. package/skills/gsd-path-inspect/references/operating-rules.md +214 -0
  251. package/skills/gsd-path-inspect/scripts/_common.py +619 -18
  252. package/skills/gsd-path-inspect/scripts/archive_milestone.py +160 -86
  253. package/skills/gsd-path-inspect/scripts/build_recovery.py +100 -32
  254. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +91 -7
  255. package/skills/gsd-path-inspect/scripts/check_handoffs.py +662 -50
  256. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +338 -30
  257. package/skills/gsd-path-inspect/scripts/detect_project.py +587 -32
  258. package/skills/gsd-path-inspect/scripts/discussion_records.py +19 -13
  259. package/skills/gsd-path-inspect/scripts/discussion_validate.py +152 -50
  260. package/skills/gsd-path-inspect/scripts/integration.py +580 -25
  261. package/skills/gsd-path-inspect/scripts/isolation.py +1438 -57
  262. package/skills/gsd-path-inspect/scripts/loop_run.py +31 -29
  263. package/skills/gsd-path-inspect/scripts/members.py +706 -0
  264. package/skills/gsd-path-inspect/scripts/path_config.py +5 -5
  265. package/skills/gsd-path-inspect/scripts/pipeline_git.py +124 -34
  266. package/skills/gsd-path-inspect/scripts/pipeline_state.py +287 -87
  267. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +1 -1
  268. package/skills/gsd-path-inspect/scripts/review_panel.py +1 -1
  269. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +135 -44
  270. package/skills/gsd-path-inspect/scripts/state_promote.py +2 -0
  271. package/skills/gsd-path-inspect/scripts/workflow_run.py +107 -30
  272. package/skills/gsd-path-inspect/scripts/worktree_paths.py +72 -10
  273. package/skills/gsd-path-inspect/templates/codebase.md +1 -1
  274. package/skills/gsd-path-inspect/templates/docs-audit.md +6 -2
  275. package/skills/gsd-path-inspect/templates/state.md +1 -1
  276. package/skills/gsd-path-loop/SKILL.md +2 -1
  277. package/skills/gsd-path-loop/references/operating-rules.md +214 -0
  278. package/skills/gsd-path-loop/scripts/_common.py +619 -18
  279. package/skills/gsd-path-loop/scripts/discussion_records.py +19 -13
  280. package/skills/gsd-path-loop/scripts/loop_run.py +31 -29
  281. package/skills/gsd-path-loop/scripts/path_config.py +5 -5
  282. package/skills/gsd-path-loop/scripts/review_panel.py +1 -1
  283. package/skills/gsd-path-loop/scripts/worktree_paths.py +72 -10
  284. package/skills/gsd-path-migrate/SKILL.md +2 -1
  285. package/skills/gsd-path-migrate/references/operating-rules.md +214 -0
  286. package/skills/gsd-path-migrate/scripts/_common.py +619 -18
  287. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +10 -1
  288. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +11 -4
  289. package/skills/gsd-path-migrate/scripts/discussion_records.py +19 -13
  290. package/skills/gsd-path-migrate/scripts/loop_run.py +31 -29
  291. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -2
  292. package/skills/gsd-path-migrate/scripts/path_config.py +5 -5
  293. package/skills/gsd-path-migrate/scripts/review_panel.py +1 -1
  294. package/skills/gsd-path-plan/SKILL.md +18 -4
  295. package/skills/gsd-path-plan/references/dispatch.md +13 -0
  296. package/skills/gsd-path-plan/references/operating-rules.md +214 -0
  297. package/skills/gsd-path-plan/references/planner.md +22 -4
  298. package/skills/gsd-path-plan/references/spec-probes.md +150 -0
  299. package/skills/gsd-path-plan/scripts/_common.py +619 -18
  300. package/skills/gsd-path-plan/scripts/archive_milestone.py +160 -86
  301. package/skills/gsd-path-plan/scripts/build_recovery.py +100 -32
  302. package/skills/gsd-path-plan/scripts/check_handoffs.py +662 -50
  303. package/skills/gsd-path-plan/scripts/check_task_briefs.py +338 -30
  304. package/skills/gsd-path-plan/scripts/detect_project.py +587 -32
  305. package/skills/gsd-path-plan/scripts/discussion_records.py +19 -13
  306. package/skills/gsd-path-plan/scripts/discussion_validate.py +152 -50
  307. package/skills/gsd-path-plan/scripts/integration.py +580 -25
  308. package/skills/gsd-path-plan/scripts/isolation.py +1438 -57
  309. package/skills/gsd-path-plan/scripts/loop_run.py +31 -29
  310. package/skills/gsd-path-plan/scripts/members.py +706 -0
  311. package/skills/gsd-path-plan/scripts/path_config.py +5 -5
  312. package/skills/gsd-path-plan/scripts/pipeline_git.py +124 -34
  313. package/skills/gsd-path-plan/scripts/pipeline_state.py +287 -87
  314. package/skills/gsd-path-plan/scripts/promote_lookahead.py +1 -1
  315. package/skills/gsd-path-plan/scripts/review_panel.py +1 -1
  316. package/skills/gsd-path-plan/scripts/state_checkpoint.py +135 -44
  317. package/skills/gsd-path-plan/scripts/state_promote.py +2 -0
  318. package/skills/gsd-path-plan/scripts/workflow_run.py +107 -30
  319. package/skills/gsd-path-plan/scripts/worktree_paths.py +72 -10
  320. package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
  321. package/skills/gsd-path-plan/templates/plan.md +17 -2
  322. package/skills/gsd-path-plan/templates/task.md +1 -0
  323. package/skills/gsd-path-research/SKILL.md +13 -1
  324. package/skills/gsd-path-research/references/dispatch.md +13 -0
  325. package/skills/gsd-path-research/references/operating-rules.md +214 -0
  326. package/skills/gsd-path-research/scripts/_common.py +619 -18
  327. package/skills/gsd-path-research/scripts/archive_milestone.py +160 -86
  328. package/skills/gsd-path-research/scripts/build_recovery.py +100 -32
  329. package/skills/gsd-path-research/scripts/check_handoffs.py +662 -50
  330. package/skills/gsd-path-research/scripts/check_task_briefs.py +338 -30
  331. package/skills/gsd-path-research/scripts/detect_project.py +587 -32
  332. package/skills/gsd-path-research/scripts/discussion_records.py +19 -13
  333. package/skills/gsd-path-research/scripts/discussion_validate.py +152 -50
  334. package/skills/gsd-path-research/scripts/integration.py +580 -25
  335. package/skills/gsd-path-research/scripts/isolation.py +1438 -57
  336. package/skills/gsd-path-research/scripts/loop_run.py +31 -29
  337. package/skills/gsd-path-research/scripts/members.py +706 -0
  338. package/skills/gsd-path-research/scripts/path_config.py +5 -5
  339. package/skills/gsd-path-research/scripts/pipeline_git.py +124 -34
  340. package/skills/gsd-path-research/scripts/pipeline_state.py +287 -87
  341. package/skills/gsd-path-research/scripts/promote_lookahead.py +1 -1
  342. package/skills/gsd-path-research/scripts/review_panel.py +1 -1
  343. package/skills/gsd-path-research/scripts/state_checkpoint.py +135 -44
  344. package/skills/gsd-path-research/scripts/state_promote.py +2 -0
  345. package/skills/gsd-path-research/scripts/worktree_paths.py +72 -10
  346. package/skills/gsd-path-research/templates/research-handoff.md +14 -3
  347. package/skills/gsd-path-roadmap/SKILL.md +2 -1
  348. package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
  349. package/skills/gsd-path-roadmap/references/operating-rules.md +214 -0
  350. package/skills/gsd-path-roadmap/scripts/_common.py +619 -18
  351. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +160 -86
  352. package/skills/gsd-path-roadmap/scripts/build_recovery.py +100 -32
  353. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +662 -50
  354. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +338 -30
  355. package/skills/gsd-path-roadmap/scripts/detect_project.py +587 -32
  356. package/skills/gsd-path-roadmap/scripts/discussion_records.py +19 -13
  357. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +152 -50
  358. package/skills/gsd-path-roadmap/scripts/integration.py +580 -25
  359. package/skills/gsd-path-roadmap/scripts/isolation.py +1438 -57
  360. package/skills/gsd-path-roadmap/scripts/loop_run.py +31 -29
  361. package/skills/gsd-path-roadmap/scripts/members.py +706 -0
  362. package/skills/gsd-path-roadmap/scripts/path_config.py +5 -5
  363. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +124 -34
  364. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +287 -87
  365. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +1 -1
  366. package/skills/gsd-path-roadmap/scripts/review_panel.py +1 -1
  367. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +135 -44
  368. package/skills/gsd-path-roadmap/scripts/state_promote.py +2 -0
  369. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +72 -10
  370. package/skills/gsd-path-ship/SKILL.md +33 -11
  371. package/skills/gsd-path-ship/references/dispatch.md +13 -0
  372. package/skills/gsd-path-ship/references/operating-rules.md +214 -0
  373. package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
  374. package/skills/gsd-path-ship/references/reviewer-wave.md +18 -5
  375. package/skills/gsd-path-ship/references/reviewer.md +3 -1
  376. package/skills/gsd-path-ship/references/spec-probes.md +150 -0
  377. package/skills/gsd-path-ship/scripts/_common.py +619 -18
  378. package/skills/gsd-path-ship/scripts/archive_milestone.py +160 -86
  379. package/skills/gsd-path-ship/scripts/build_recovery.py +100 -32
  380. package/skills/gsd-path-ship/scripts/build_state.py +143 -24
  381. package/skills/gsd-path-ship/scripts/check_handoffs.py +662 -50
  382. package/skills/gsd-path-ship/scripts/check_task_briefs.py +338 -30
  383. package/skills/gsd-path-ship/scripts/detect_project.py +587 -32
  384. package/skills/gsd-path-ship/scripts/discussion_records.py +19 -13
  385. package/skills/gsd-path-ship/scripts/discussion_validate.py +152 -50
  386. package/skills/gsd-path-ship/scripts/integration.py +580 -25
  387. package/skills/gsd-path-ship/scripts/isolation.py +1438 -57
  388. package/skills/gsd-path-ship/scripts/lean_verification.py +99 -19
  389. package/skills/gsd-path-ship/scripts/loop_run.py +31 -29
  390. package/skills/gsd-path-ship/scripts/members.py +706 -0
  391. package/skills/gsd-path-ship/scripts/path_config.py +5 -5
  392. package/skills/gsd-path-ship/scripts/pipeline_git.py +124 -34
  393. package/skills/gsd-path-ship/scripts/pipeline_state.py +287 -87
  394. package/skills/gsd-path-ship/scripts/promote_lookahead.py +1 -1
  395. package/skills/gsd-path-ship/scripts/review_panel.py +1 -1
  396. package/skills/gsd-path-ship/scripts/state_checkpoint.py +135 -44
  397. package/skills/gsd-path-ship/scripts/state_promote.py +2 -0
  398. package/skills/gsd-path-ship/scripts/workflow_run.py +107 -30
  399. package/skills/gsd-path-ship/scripts/worktree_paths.py +72 -10
  400. package/skills/gsd-path-ship/templates/final-review.md +16 -4
  401. package/skills/gsd-path-ship/templates/gap-review.md +2 -1
  402. package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
  403. package/skills/gsd-path-ship/templates/wave-review.md +16 -4
  404. package/skills/gsd-path-undo/SKILL.md +2 -1
  405. package/skills/gsd-path-undo/references/operating-rules.md +214 -0
  406. package/skills/gsd-path-undo/scripts/_common.py +619 -18
  407. package/skills/gsd-path-undo/scripts/discussion_records.py +19 -13
  408. package/skills/gsd-path-undo/scripts/loop_run.py +31 -29
  409. package/skills/gsd-path-undo/scripts/path_config.py +5 -5
  410. package/skills/gsd-path-undo/scripts/pipeline_undo.py +218 -30
  411. package/skills/gsd-path-undo/scripts/review_panel.py +1 -1
  412. package/skills/gsd-path-undo/scripts/worktree_paths.py +72 -10
  413. package/skills/path/BUILD.md +7 -1
  414. package/skills/path/DECIDE.md +11 -4
  415. package/skills/path/DEFINE.md +36 -4
  416. package/skills/path/DOCS-AUDIT.md +6 -4
  417. package/skills/path/INSPECT.md +5 -4
  418. package/skills/path/PLAN.md +18 -4
  419. package/skills/path/RESEARCH.md +13 -1
  420. package/skills/path/ROADMAP.md +2 -1
  421. package/skills/path/SHIP.md +33 -11
  422. package/skills/path/SKILL.md +45 -1
  423. package/skills/path/references/build-native.md +63 -15
  424. package/skills/path/references/codebase-mapper.md +2 -1
  425. package/skills/path/references/dispatch.md +13 -0
  426. package/skills/path/references/docs-auditor.md +7 -1
  427. package/skills/path/references/operating-rules.md +214 -0
  428. package/skills/path/references/planner.md +22 -4
  429. package/skills/path/references/reviewer-final.md +20 -2
  430. package/skills/path/references/reviewer-wave.md +18 -5
  431. package/skills/path/references/reviewer.md +3 -1
  432. package/skills/path/references/spec-probes.md +150 -0
  433. package/skills/path/scripts/_common.py +619 -18
  434. package/skills/path/scripts/archive_milestone.py +160 -86
  435. package/skills/path/scripts/bootstrap_repository.py +8 -5
  436. package/skills/path/scripts/build_recovery.py +100 -32
  437. package/skills/path/scripts/build_state.py +143 -24
  438. package/skills/path/scripts/check_docs_audit.py +91 -7
  439. package/skills/path/scripts/check_handoffs.py +662 -50
  440. package/skills/path/scripts/check_task_briefs.py +338 -30
  441. package/skills/path/scripts/check_update.py +2 -2
  442. package/skills/path/scripts/detect_project.py +587 -32
  443. package/skills/path/scripts/discussion_records.py +19 -13
  444. package/skills/path/scripts/discussion_validate.py +152 -50
  445. package/skills/path/scripts/dispatch_driver.py +501 -46
  446. package/skills/path/scripts/git_guard.py +118 -31
  447. package/skills/path/scripts/integration.py +580 -25
  448. package/skills/path/scripts/isolation.py +1438 -57
  449. package/skills/path/scripts/lean_verification.py +99 -19
  450. package/skills/path/scripts/loop_run.py +31 -29
  451. package/skills/path/scripts/members.py +706 -0
  452. package/skills/path/scripts/path_config.py +5 -5
  453. package/skills/path/scripts/pipeline_git.py +124 -34
  454. package/skills/path/scripts/pipeline_state.py +287 -87
  455. package/skills/path/scripts/promote_lookahead.py +1 -1
  456. package/skills/path/scripts/review_panel.py +1 -1
  457. package/skills/path/scripts/state_checkpoint.py +135 -44
  458. package/skills/path/scripts/state_promote.py +2 -0
  459. package/skills/path/scripts/status_runtime.py +58 -10
  460. package/skills/path/scripts/task_context.py +12 -3
  461. package/skills/path/scripts/token_budget.py +12 -8
  462. package/skills/path/scripts/workflow_run.py +107 -30
  463. package/skills/path/scripts/worktree_paths.py +72 -10
  464. package/skills/path/templates/codebase.md +1 -1
  465. package/skills/path/templates/docs-audit.md +6 -2
  466. package/skills/path/templates/final-review.md +16 -4
  467. package/skills/path/templates/gap-review.md +2 -1
  468. package/skills/path/templates/intent.md +30 -0
  469. package/skills/path/templates/patch-findings.md +3 -3
  470. package/skills/path/templates/plan.md +17 -2
  471. package/skills/path/templates/research-handoff.md +14 -3
  472. package/skills/path/templates/state.md +1 -1
  473. package/skills/path/templates/task.md +1 -0
  474. package/skills/path/templates/wave-review.md +16 -4
  475. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  476. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  477. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  478. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  479. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  480. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  481. package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
@@ -10,9 +10,12 @@ it never detaches HEAD.
10
10
  from __future__ import annotations
11
11
 
12
12
  import argparse
13
+ import contextlib
13
14
  import hashlib
14
15
  import io
15
16
  import json
17
+ import shlex
18
+ import shutil
16
19
  import os
17
20
  import re
18
21
  import stat
@@ -23,10 +26,10 @@ import sys
23
26
  sys.dont_write_bytecode = True
24
27
  import tempfile
25
28
  from pathlib import Path, PurePosixPath
26
- from typing import Dict, Optional, Sequence, Set
29
+ from typing import Dict, Iterator, Optional, Sequence, Set, Tuple
27
30
 
28
- try:
29
- from pipeline_git import (
31
+ if __package__:
32
+ from .pipeline_git import (
30
33
  attest_commit_body,
31
34
  attest_commit_subject,
32
35
  is_bound_branch,
@@ -35,20 +38,33 @@ try:
35
38
  task_commit_body,
36
39
  task_commit_subject,
37
40
  )
38
- import _common
39
- import worktree_paths
40
- except ImportError: # pragma: no cover - package import used by tests
41
- from scripts.pipeline_git import (
42
- attest_commit_body,
43
- attest_commit_subject,
44
- is_bound_branch,
45
- ship_commit_body,
46
- ship_subject,
47
- task_commit_body,
48
- task_commit_subject,
49
- )
50
- from scripts import _common
51
- from scripts import worktree_paths
41
+ from . import _common
42
+ from . import worktree_paths
43
+ else:
44
+ try:
45
+ from pipeline_git import (
46
+ attest_commit_body,
47
+ attest_commit_subject,
48
+ is_bound_branch,
49
+ ship_commit_body,
50
+ ship_subject,
51
+ task_commit_body,
52
+ task_commit_subject,
53
+ )
54
+ import _common
55
+ import worktree_paths
56
+ except ImportError: # pragma: no cover - package import used by tests
57
+ from scripts.pipeline_git import (
58
+ attest_commit_body,
59
+ attest_commit_subject,
60
+ is_bound_branch,
61
+ ship_commit_body,
62
+ ship_subject,
63
+ task_commit_body,
64
+ task_commit_subject,
65
+ )
66
+ from scripts import _common
67
+ from scripts import worktree_paths
52
68
 
53
69
 
54
70
  TASK_BRANCH_PREFIX = "gsd-path-task/"
@@ -156,6 +172,12 @@ def sidecar_root(primary: Path, kind: str, name: str, *, pin: bool = False) -> P
156
172
  raise IsolationError(str(error)) from error
157
173
 
158
174
 
175
+ def release_workspace_quietly(primary: Path) -> None:
176
+ """Best-effort placement hygiene after a sidecar retires; never blocks landing."""
177
+ with contextlib.suppress(ValueError, OSError):
178
+ worktree_paths.release_workspace(primary)
179
+
180
+
159
181
  def validate_task_id(task_id: str) -> str:
160
182
  if not TASK_ID_RE.fullmatch(task_id):
161
183
  raise IsolationError(f"invalid task id: {task_id}")
@@ -247,6 +269,9 @@ def verify_branch_name(name: str) -> str:
247
269
 
248
270
 
249
271
  def relative_posix(path: str) -> str:
272
+ if os.name == "nt":
273
+ # A native Windows relative path; backslash cannot occur in a name there.
274
+ path = path.replace("\\", "/")
250
275
  parsed = PurePosixPath(path)
251
276
  if parsed.is_absolute() or ".." in parsed.parts:
252
277
  raise IsolationError(f"path must be relative without '..': {path}")
@@ -416,8 +441,9 @@ def collect_artifact_recoveries(primary: Path) -> list[Dict[str, object]]:
416
441
 
417
442
 
418
443
  def _git_file(repo: Path, revision: str, path: str) -> bytes:
444
+ git_path = PurePosixPath(path.replace("\\", "/")).as_posix()
419
445
  result = subprocess.run(
420
- ("git", "-C", str(repo), "show", f"{revision}:{path}"),
446
+ ("git", "-C", str(repo), "show", f"{revision}:{git_path}"),
421
447
  capture_output=True,
422
448
  check=False,
423
449
  )
@@ -458,6 +484,70 @@ def _same_directory(path: Path, expected: os.stat_result) -> bool:
458
484
  return stat.S_ISDIR(current.st_mode) and os.path.samestat(current, expected)
459
485
 
460
486
 
487
+ def _long_path(absolute: str) -> str:
488
+ if absolute.startswith("\\\\?\\"):
489
+ return absolute
490
+ if absolute.startswith("\\\\"):
491
+ return "\\\\?\\UNC\\" + absolute[2:]
492
+ return "\\\\?\\" + absolute
493
+
494
+
495
+ def _force_rmtree(path: Path) -> None:
496
+ """Delete a sidecar tree directly, tolerating read-only files and long paths."""
497
+ def _writable(function, target, _error):
498
+ os.chmod(target, stat.S_IWRITE)
499
+ function(target)
500
+
501
+ # The \\?\ prefix sidesteps Windows MAX_PATH on deep sidecar trees.
502
+ target = Path(_long_path(os.path.abspath(path))) if os.name == "nt" else path
503
+ try:
504
+ shutil.rmtree(target, onerror=_writable)
505
+ except OSError as error:
506
+ raise IsolationError(f"could not delete {path}: {error}") from error
507
+
508
+
509
+ def _remove_worktree(primary: Path, destination: Path, force: bool) -> None:
510
+ """Remove a sidecar worktree, tolerating populated submodules and locked trees.
511
+
512
+ git refuses to remove worktrees whose submodules were ever initialized, and
513
+ forced removal can still fail deleting deep trees (Windows "Directory not
514
+ empty"). A dirty sidecar keeps git's refusal unless `force`, so forced
515
+ retries only run for a provably clean tree. The direct-delete fallback runs
516
+ only for git's tree-deletion failures, so any other git error leaves the
517
+ sidecar intact for diagnosis.
518
+ """
519
+ tree_delete_failure = ("failed to delete", "directory not empty", "permission denied")
520
+
521
+ def failure(*flags: str) -> str:
522
+ result = run_git(primary, "worktree", "remove", *flags, str(destination))
523
+ return (result.stderr or result.stdout).strip() if result.returncode else ""
524
+
525
+ detail = failure("--force") if force else failure()
526
+ if not detail:
527
+ return
528
+ if not force and destination.is_dir():
529
+ status = run_git(
530
+ destination, "status", "--porcelain", "--untracked-files=all"
531
+ )
532
+ if status.returncode != 0 or status.stdout.strip():
533
+ raise IsolationError(detail or "git worktree remove failed")
534
+ run_git(destination, "submodule", "deinit", "--all", "--force")
535
+ detail = failure("--force")
536
+ if not detail:
537
+ return
538
+ if destination.is_dir() and any(
539
+ marker in detail.lower() for marker in tree_delete_failure
540
+ ):
541
+ _force_rmtree(destination)
542
+ detail = failure("--force")
543
+ if detail and not destination.is_dir():
544
+ pruned = run_git(primary, "worktree", "prune")
545
+ if pruned.returncode == 0:
546
+ detail = ""
547
+ if detail or destination.is_dir() or destination.resolve() in _registered_worktrees(primary):
548
+ raise IsolationError(detail or "git worktree remove failed")
549
+
550
+
461
551
  def _cleanup_reserved_worktree(
462
552
  primary: Path,
463
553
  branch: str,
@@ -480,8 +570,11 @@ def _cleanup_reserved_worktree(
480
570
  run_git(primary, "update-ref", "-d", ref, base)
481
571
 
482
572
 
483
- def create_named_worktree(primary: Path, branch: str, destination: Path, base: str) -> None:
484
- bound = require_bound(primary)
573
+ def create_named_worktree(
574
+ primary: Path, branch: str, destination: Path, base: str, bound: Optional[str] = None
575
+ ) -> None:
576
+ # A member names its bound branch; the member team's checkout is never on it.
577
+ bound = bound or require_bound(primary)
485
578
  if branch == bound:
486
579
  raise IsolationError(
487
580
  f"refusing to check out the bound branch {bound} in a second worktree"
@@ -576,6 +669,714 @@ def isolate_task(
576
669
  }
577
670
 
578
671
 
672
+ def _member_context(
673
+ coordinator: Path, member: str, lock_at: Optional[str] = None
674
+ ) -> Tuple[Path, str, Dict[str, str]]:
675
+ """The checkout, coordinator project, and lock entry of a locked, verified member."""
676
+ try:
677
+ import members
678
+ except ImportError: # pragma: no cover - package import used by tests
679
+ from scripts import members
680
+ coordinator = require_directory(coordinator, "coordinator")
681
+ if lock_at is None:
682
+ lock = coordinator / members.LOCK_PATH
683
+ entries = json.loads(lock.read_text(encoding="utf-8"))["members"] if lock.is_file() else []
684
+ else:
685
+ shown = run_git(coordinator, "show", f"{lock_at}:{members.LOCK_PATH}")
686
+ if shown.returncode != 0:
687
+ raise IsolationError(f"member lock is missing at {lock_at}: {members.LOCK_PATH}")
688
+ entries = json.loads(shown.stdout)["members"]
689
+ entry = next((item for item in entries if item["name"] == member), None)
690
+ if entry is None:
691
+ raise IsolationError(f"member {member} is not locked for this build because no task named it at build start")
692
+ checkout, project = member_checkout(coordinator, member)
693
+ return checkout, project, entry
694
+
695
+
696
+ def member_checkout(coordinator: Path, member: str) -> Tuple[Path, str]:
697
+ """A MEMBERS.md member's checkout and coordinator project, after checking its marker."""
698
+ try:
699
+ import members
700
+ except ImportError: # pragma: no cover - package import used by tests
701
+ from scripts import members
702
+ coordinator = require_directory(coordinator, "coordinator")
703
+ repair = f"run members.py repair --repo {coordinator}"
704
+ try:
705
+ listed = {item["name"]: item for item in members.read_members(coordinator)}
706
+ if member not in listed:
707
+ raise IsolationError(f"member {member} is not in MEMBERS.md; {repair}")
708
+ checkout = Path(listed[member]["checkout"]).resolve()
709
+ role = members.member_role(checkout)
710
+ except members.MembersError as error:
711
+ raise IsolationError(f"{error}; {repair}") from error
712
+ if role is None or role["coordinator"] != coordinator or role["name"] != member:
713
+ raise IsolationError(f"member marker for {member} is missing or stale; {repair}")
714
+ return checkout, str(role["project"])
715
+
716
+
717
+ def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
718
+ """Path's own checkout of a locked member's bound branch, outside the team's checkout."""
719
+ checkout, _, entry = _member_context(coordinator, member)
720
+ branch = entry["branch"]
721
+ destination = sidecar_root(checkout, "bound", branch.removeprefix("gsd-path/"), pin=True)
722
+ holders = [path for path, ref in _registered_worktrees(checkout).items() if ref == f"refs/heads/{branch}"]
723
+ if holders and holders != [destination.resolve()]:
724
+ raise IsolationError(f"{branch} is checked out outside Path's workspace: {holders[0]}")
725
+ if holders:
726
+ root = run_git(destination, "rev-parse", "--show-toplevel")
727
+ common = run_git(destination, "rev-parse", "--path-format=absolute", "--git-common-dir")
728
+ head = run_git(destination, "symbolic-ref", "-q", "HEAD")
729
+ if (root.returncode != 0 or Path(root.stdout.strip()).resolve() != destination.resolve()
730
+ or common.returncode != 0 or Path(common.stdout.strip()).resolve() != common_git_dir(checkout)
731
+ or head.returncode != 0 or head.stdout.strip() != f"refs/heads/{branch}"):
732
+ raise IsolationError(f"bound checkout is missing or invalid: {destination}")
733
+ if not holders:
734
+ if os.path.lexists(destination):
735
+ raise IsolationError(f"worktree path already exists: {destination}")
736
+ destination.parent.mkdir(parents=True, exist_ok=True)
737
+ added = run_git(checkout, "worktree", "add", str(destination), branch)
738
+ if added.returncode != 0:
739
+ raise IsolationError((added.stderr or added.stdout).strip() or "git worktree add failed")
740
+ return {"branch": branch, "checkout": str(destination.resolve()), "member": member}
741
+
742
+
743
+ def isolate_member_task(coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
744
+ """Member tasks always run in a sidecar at the member bound branch tip."""
745
+ validate_task_id(task_id)
746
+ bound = member_bound_checkout(coordinator, member)
747
+ bound_checkout = Path(bound["checkout"])
748
+ dirty = uncommitted_paths(bound_checkout)
749
+ if dirty:
750
+ raise IsolationError(
751
+ "member task isolation requires a clean member bound checkout: " + ", ".join(sorted(dirty))
752
+ )
753
+ checkout, project, _ = _member_context(coordinator, member)
754
+ member_base = current_sha(bound_checkout)
755
+ name = f"{project}-{task_id}"
756
+ branch = f"{TASK_BRANCH_PREFIX}{name}"
757
+ destination = sidecar_root(checkout, "task", name, pin=True)
758
+ create_named_worktree(checkout, branch, destination, member_base, bound=str(bound["branch"]))
759
+ return {
760
+ "bound_branch": bound["branch"],
761
+ "bound_checkout": bound["checkout"],
762
+ "kind": "task",
763
+ "member": member,
764
+ "member_base": member_base,
765
+ "mode": "member",
766
+ "task_branch": branch,
767
+ "worktree": str(destination),
768
+ }
769
+
770
+
771
+ def _rejected_member_attempt_error(
772
+ coordinator: Path, member: str, task_id: str, task_file: Optional[str], sidecar: Path
773
+ ) -> tuple[Optional[str], Optional[str]]:
774
+ """A forced retire discards a rejected attempt only after the parent kept its evidence.
775
+
776
+ Returns (refusal, copy_rejected); an invalid live copy holds no trusted Log delta,
777
+ so the parent's own rejection record stands in for it.
778
+ """
779
+ if not task_file:
780
+ return "forced member task retirement requires --task-file", None
781
+ task_file = relative_posix(task_file)
782
+ shown = run_git(coordinator, "show", f"HEAD:{task_file}")
783
+ fields, error = task_frontmatter(shown.stdout) if shown.returncode == 0 else (None, "task file is not committed")
784
+ if error or fields is None:
785
+ return error or "unreadable task file", None
786
+ if str(fields.get("id")) != task_id or fields.get("repo") != member:
787
+ return f"{task_file} does not own member task {task_id} in {member}", None
788
+ # A member task never leaves pending in the coordinator until its landing record.
789
+ if fields.get("status") != "pending":
790
+ return "forced member task retirement requires the coordinator task at pending", None
791
+ if os.path.lexists(_member_journal_path(coordinator, task_id)):
792
+ return "member task has a landing journal; recover it instead", None
793
+ if sidecar.is_dir():
794
+ copy = _safe_member_task_copy(sidecar, task_file)
795
+ if copy.is_file():
796
+ try:
797
+ text = copy.read_text(encoding="utf-8")
798
+ base = str((task_frontmatter(text)[0] or {}).get("base", ""))
799
+ contract = git_text(coordinator, "show", f"{require_full_sha(base)}:{task_file}")
800
+ delta = member_log_delta(contract, text).strip()
801
+ except (IsolationError, UnicodeDecodeError) as copy_error:
802
+ return None, str(copy_error)
803
+ if delta and delta not in _normalize_newlines(shown.stdout):
804
+ return ("copy the rejected attempt's Log delta into the coordinator task file "
805
+ "and checkpoint it before forced retirement"), None
806
+ return None, None
807
+
808
+
809
+ def retire_member_task(
810
+ coordinator: Path, member: str, task_id: str, force: bool = False, task_file: Optional[str] = None
811
+ ) -> Optional[str]:
812
+ """Remove an unused member task sidecar; landed work needs the landing proof.
813
+
814
+ `force` discards a rejected (dirty) attempt so a retry starts from a fresh sidecar,
815
+ and returns why its live task copy was rejected, if it was.
816
+ """
817
+ checkout, project, entry = _member_context(coordinator, member)
818
+ name = f"{project}-{validate_task_id(task_id)}"
819
+ ref = f"refs/heads/{TASK_BRANCH_PREFIX}{name}"
820
+ tip = run_git(checkout, "rev-parse", "--verify", "--quiet", ref)
821
+ # Landed commits arrive on the bound branch by cherry-pick, so compare patches.
822
+ cherry = run_git(checkout, "cherry", f"refs/heads/{entry['branch']}", ref) if tip.returncode == 0 else None
823
+ if cherry is not None and (cherry.returncode != 0 or any(
824
+ not line.startswith("- ") for line in cherry.stdout.splitlines())):
825
+ raise IsolationError(f"member task branch {ref} has unlanded commits")
826
+ destination = sidecar_root(checkout, "task", name)
827
+ copy_rejected = None
828
+ if force and task_file:
829
+ rejected, copy_rejected = _rejected_member_attempt_error(coordinator, member, task_id, task_file, destination)
830
+ if rejected:
831
+ raise IsolationError(rejected)
832
+ registered = _registered_worktrees(checkout)
833
+ if destination.resolve() in registered:
834
+ if registered[destination.resolve()] != ref:
835
+ raise IsolationError(f"member task sidecar is on another branch: {destination}")
836
+ _remove_worktree(checkout, destination, force)
837
+ authorization = run_git(checkout, "update-ref", "-d", member_task_authorization_ref(project, task_id))
838
+ if authorization.returncode != 0:
839
+ raise IsolationError((authorization.stderr or authorization.stdout).strip() or "git update-ref failed")
840
+ if tip.returncode == 0:
841
+ deleted = run_git(checkout, "update-ref", "-d", ref, tip.stdout.strip())
842
+ if deleted.returncode != 0:
843
+ raise IsolationError((deleted.stderr or deleted.stdout).strip() or "git update-ref failed")
844
+ release_workspace_quietly(checkout)
845
+ return copy_rejected
846
+
847
+
848
+ MEMBER_LANDING_DIR = ("gsd-path", "member-landings")
849
+ # A running member task's live task file sits in its member sidecar, untracked
850
+ # and excluded, so the coordinator stays clean while member work runs.
851
+ MEMBER_TASK_COPY_DIR = ".gsd-path-coordinator"
852
+ MEMBER_IMMUTABLE_FIELDS = ("id", "title", "repo", "files", "deps", "wave")
853
+ MEMBER_ACTIVATION_FIELDS = {"status", "agent", "base", "member_base", "worktree", "task_branch"}
854
+
855
+
856
+ def _member_hook_configs(coordinator: Path) -> Dict[str, str]:
857
+ """Host configs for a member sidecar that call the coordinator guard by absolute path.
858
+
859
+ Only hosts whose managed config is installed in the coordinator get one; inside
860
+ a member sidecar the Git top level and project dir name the member, not the
861
+ coordinator, so the installed relative commands would miss the guard.
862
+ """
863
+ guard = coordinator / ".gsd-path" / "guard_hook.py"
864
+ command = f"{shlex.quote(sys.executable)} {shlex.quote(str(guard))}"
865
+ windows_interpreter = sys.executable.replace("'", "''")
866
+ windows_guard = str(guard).replace("'", "''")
867
+ command_windows = ("powershell.exe -NoProfile -NonInteractive -Command "
868
+ f'"& \'{windows_interpreter}\' \'{windows_guard}\'"')
869
+ formats = {
870
+ ".claude/settings.json": {"hooks": {"PreToolUse": [
871
+ {"matcher": ".*", "hooks": [{"type": "command", "command": command}]}]}},
872
+ ".codex/hooks.json": {"hooks": {"PreToolUse": [
873
+ {"matcher": ".*", "hooks": [{"type": "command", "command": command,
874
+ "commandWindows": command_windows}]}]}},
875
+ ".cursor/hooks.json": {"version": 1, "hooks": {"preToolUse": [
876
+ {"command": command, "matcher": ".*", "failClosed": True}]}},
877
+ }
878
+ configs = {}
879
+ for relative, content in formats.items():
880
+ installed = coordinator / relative
881
+ if installed.is_file() and not installed.is_symlink() and "guard_hook.py" in installed.read_text(encoding="utf-8"):
882
+ configs[relative] = json.dumps(content, indent=2) + "\n"
883
+ return configs
884
+
885
+
886
+ def _check_member_hook_collisions(configs: Dict[str, str], sidecar: Path) -> None:
887
+ for relative, content in configs.items():
888
+ # A member's own config (tracked or local) would run instead of the guard; never replace it.
889
+ target = sidecar / relative
890
+ if target.parent.is_symlink() or target.is_symlink() or (
891
+ target.exists() and target.read_bytes() != content.encode("utf-8")):
892
+ raise IsolationError(f"member sidecar already has its own {relative}; its host would run "
893
+ "without the coordinator guard")
894
+
895
+
896
+ def _write_member_hook_configs(coordinator: Path, checkout: Path, sidecar: Path) -> None:
897
+ configs = _member_hook_configs(coordinator)
898
+ _check_member_hook_collisions(configs, sidecar)
899
+ exclude = common_git_dir(checkout) / "info" / "exclude"
900
+ existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else ""
901
+ missing = [f"/{relative}" for relative in configs if f"/{relative}" not in existing.splitlines()]
902
+ if missing:
903
+ exclude.parent.mkdir(parents=True, exist_ok=True)
904
+ prefix = existing + ("" if not existing or existing.endswith("\n") else "\n")
905
+ _common.atomic_write(exclude, prefix + "\n".join(missing) + "\n")
906
+ for relative, content in configs.items():
907
+ target = sidecar / relative
908
+ target.parent.mkdir(parents=True, exist_ok=True)
909
+ _common.atomic_write(target, content)
910
+
911
+
912
+ def ensure_member_hooks(coordinator: Path, member: str, task_id: str) -> None:
913
+ checkout, project, _ = _member_context(coordinator, member)
914
+ sidecar = sidecar_root(checkout, "task", f"{project}-{validate_task_id(task_id)}")
915
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
916
+ if require_attached(sidecar) != branch:
917
+ raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
918
+ _write_member_hook_configs(coordinator, checkout, sidecar)
919
+
920
+
921
+ def member_task_copy(sidecar: Path, task_file: str) -> Path:
922
+ return sidecar / MEMBER_TASK_COPY_DIR / relative_posix(task_file)
923
+
924
+
925
+ def _safe_member_task_copy(sidecar: Path, task_file: str) -> Path:
926
+ copy = member_task_copy(sidecar, task_file)
927
+ if git_output(sidecar, "ls-files", "--", MEMBER_TASK_COPY_DIR):
928
+ raise IsolationError("member task copy directory is tracked")
929
+ parent = sidecar
930
+ for part in (MEMBER_TASK_COPY_DIR, *PurePosixPath(task_file).parts[:-1]):
931
+ parent = parent / part
932
+ if os.path.lexists(parent) and (parent.is_symlink() or not parent.is_dir()):
933
+ raise IsolationError(f"member task copy parent is not a real directory: {parent}")
934
+ try:
935
+ parent.resolve().relative_to(sidecar.resolve())
936
+ except ValueError as error:
937
+ raise IsolationError(f"member task copy parent escapes the sidecar: {parent}") from error
938
+ if os.path.lexists(copy) and (copy.is_symlink() or not copy.is_file()):
939
+ raise IsolationError(f"member task copy is not a regular file: {copy}")
940
+ return copy
941
+
942
+
943
+ def member_task_authorization_ref(project: str, task_id: str) -> str:
944
+ return f"{TASK_AUTHORIZATION_PREFIX}{project}-{validate_task_id(task_id)}"
945
+
946
+
947
+ def _activated_member_text(
948
+ contract: str, task_id: str, agent: str, base: str, sidecar: Path, branch: str, member_base: str
949
+ ) -> str:
950
+ activated = _activated_task_text(contract, task_id, agent, base, sidecar, branch)
951
+ head, body = split_frontmatter(activated)
952
+ head = [line for line in head if not line.startswith("member_base:")]
953
+ head.insert(next(i for i, line in enumerate(head) if line.startswith("base:")) + 1,
954
+ f"member_base: {member_base}")
955
+ return "---\n" + "\n".join(head) + "\n---\n" + body
956
+
957
+
958
+ def activate_member_task(
959
+ coordinator: Path, member: str, task_id: str, agent: str, task_file: str, base: str
960
+ ) -> Dict[str, object]:
961
+ """Write the live task copy into the member sidecar and authorize its member base."""
962
+ coordinator = require_directory(coordinator, "coordinator")
963
+ if not agent or agent == "null" or "\n" in agent:
964
+ raise IsolationError("task activation requires an assigned agent")
965
+ task_file = relative_posix(task_file)
966
+ if not task_file.startswith(".project/tasks/"):
967
+ raise IsolationError("task activation file must stay under .project/tasks/")
968
+ checkout, project, entry = _member_context(coordinator, member)
969
+ sidecar = sidecar_root(checkout, "task", f"{project}-{validate_task_id(task_id)}")
970
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
971
+ if require_attached(sidecar) != branch:
972
+ raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
973
+ member_base = current_sha(sidecar)
974
+ if run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").stdout.strip() != member_base \
975
+ or run_git(checkout, "merge-base", "--is-ancestor", member_base,
976
+ f"refs/heads/{entry['branch']}").returncode != 0:
977
+ raise IsolationError("member task activation requires the sidecar at its member base")
978
+ _check_member_hook_collisions(_member_hook_configs(coordinator), sidecar)
979
+ if uncommitted_paths(sidecar):
980
+ raise IsolationError("member task activation requires a clean member sidecar")
981
+ resolved_base = require_commit(coordinator, require_full_sha(base))
982
+ _member_contract(coordinator, resolved_base, task_file, member)
983
+ contract = git_text(coordinator, "show", f"{resolved_base}:{task_file}")
984
+ activated = _activated_member_text(contract, task_id, agent, resolved_base, sidecar, branch, member_base)
985
+ copy = _safe_member_task_copy(sidecar, task_file)
986
+ _write_member_hook_configs(coordinator, checkout, sidecar)
987
+ exclude = common_git_dir(checkout) / "info" / "exclude"
988
+ rule = f"/{MEMBER_TASK_COPY_DIR}/"
989
+ existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else ""
990
+ if rule not in existing.splitlines():
991
+ exclude.parent.mkdir(parents=True, exist_ok=True)
992
+ _common.atomic_write(exclude, existing + ("" if not existing or existing.endswith("\n") else "\n") + rule + "\n")
993
+ if copy.is_file() and copy.read_text(encoding="utf-8") != activated:
994
+ current, _ = task_frontmatter(copy.read_text(encoding="utf-8"))
995
+ if (not current or current.get("agent") != agent or current.get("base") != resolved_base
996
+ or current.get("member_base") != member_base):
997
+ raise IsolationError(f"member task is already active with another agent or base: {copy}")
998
+ elif not copy.is_file():
999
+ copy.parent.mkdir(parents=True, exist_ok=True)
1000
+ _common.atomic_write(copy, activated)
1001
+ ref = member_task_authorization_ref(project, task_id)
1002
+ created = run_git(checkout, "update-ref", ref, member_base, "0" * 40)
1003
+ if created.returncode != 0 and run_git(checkout, "rev-parse", "--verify", "--quiet", ref).stdout.strip() != member_base:
1004
+ raise IsolationError("could not record member task authorization")
1005
+ result = {"agent": agent, "base": resolved_base, "copy": str(copy), "member": member,
1006
+ "member_base": member_base, "status": "in-progress", "task_branch": branch,
1007
+ "task_file": task_file, "worktree": str(sidecar)}
1008
+ _write_native_member_dispatch_record(coordinator, result)
1009
+ return result
1010
+
1011
+
1012
+ def retained_member_task(
1013
+ coordinator: Path, member: str, task_id: str, task_file: str, base: str
1014
+ ) -> Optional[Dict[str, object]]:
1015
+ """Inspect an interrupted member isolate before another dispatch uses its name."""
1016
+ checkout, project, _ = _member_context(coordinator, member)
1017
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{validate_task_id(task_id)}"
1018
+ sidecar = sidecar_root(checkout, "task", f"{project}-{task_id}")
1019
+ tip = run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").stdout.strip()
1020
+ authorization = run_git(checkout, "rev-parse", "--verify", "--quiet",
1021
+ member_task_authorization_ref(project, task_id)).stdout.strip()
1022
+ if not tip:
1023
+ if os.path.lexists(sidecar) or authorization:
1024
+ raise IsolationError(f"member task {task_id} has a sidecar or authorization without its branch")
1025
+ return None
1026
+ if _registered_worktrees(checkout).get(sidecar.resolve()) != f"refs/heads/{branch}":
1027
+ raise IsolationError(f"member task branch has no matching sidecar: {branch}")
1028
+ if require_attached(sidecar) != branch or current_sha(sidecar) != tip:
1029
+ raise IsolationError(f"member task sidecar does not match its branch: {branch}")
1030
+ bound = member_bound_checkout(coordinator, member)
1031
+ bound_tip = current_sha(Path(str(bound["checkout"])))
1032
+ if (run_git(checkout, "merge-base", "--is-ancestor", tip, bound_tip).returncode != 0
1033
+ or uncommitted_paths(sidecar)):
1034
+ raise IsolationError(f"member task branch is used or its member base moved: {branch}")
1035
+ copy = _safe_member_task_copy(sidecar, task_file)
1036
+ isolate = {"task_branch": branch, "member_base": tip, "worktree": str(sidecar)}
1037
+ if not authorization:
1038
+ if copy.is_file():
1039
+ text = copy.read_text(encoding="utf-8")
1040
+ fields, error = task_frontmatter(text)
1041
+ if error or fields is None:
1042
+ raise IsolationError(error or f"member task {task_id} has an unreadable activation")
1043
+ copy_base = require_commit(coordinator, require_full_sha(str(fields.get("base", ""))))
1044
+ _member_contract(coordinator, copy_base, task_file, member)
1045
+ contract = git_text(coordinator, "show", f"{copy_base}:{task_file}")
1046
+ expected = _activated_member_text(contract, task_id, f"build_{task_id.lower()}",
1047
+ copy_base, sidecar, branch, tip)
1048
+ if text != expected:
1049
+ raise IsolationError(f"member task {task_id} has a changed activation")
1050
+ return {"state": "unused", "isolate": isolate}
1051
+ if not copy.is_file() or authorization != tip:
1052
+ raise IsolationError(f"member task {task_id} has an incomplete activation")
1053
+ if tip != bound_tip:
1054
+ raise IsolationError(f"member task {task_id} has a used activation at an older member base")
1055
+ journal = {"member": member, "task_id": task_id, "task_file": task_file,
1056
+ "base": base, "member_base": tip, "copy": str(copy)}
1057
+ text = _member_copy_text(journal, coordinator)
1058
+ contract = git_text(coordinator, "show", f"{base}:{task_file}")
1059
+ fields, _ = task_frontmatter(text)
1060
+ if fields is None or fields.get("agent") != f"build_{task_id.lower()}" or member_log_delta(contract, text):
1061
+ raise IsolationError(f"member task {task_id} has a used or changed activation")
1062
+ return {"state": "active", "isolate": isolate,
1063
+ "active": {"copy": str(copy), "worktree": str(sidecar)}}
1064
+
1065
+
1066
+ def _member_copy_text(journal: Dict[str, object], coordinator: Path) -> str:
1067
+ """The live copy, checked against the contract: same contract fields, Log only grows."""
1068
+ checkout, project, _ = _member_context(coordinator, str(journal["member"]))
1069
+ sidecar = sidecar_root(checkout, "task", f"{project}-{journal['task_id']}")
1070
+ copy = _safe_member_task_copy(sidecar, str(journal["task_file"]))
1071
+ if copy != Path(str(journal["copy"])) or not copy.is_file():
1072
+ raise IsolationError(f"member task is not activated: {copy}")
1073
+ text = copy.read_text(encoding="utf-8")
1074
+ contract = git_text(coordinator, "show", f"{journal['base']}:{journal['task_file']}")
1075
+ fields, error = task_frontmatter(text)
1076
+ expected, _ = task_frontmatter(contract)
1077
+ if error or fields is None or expected is None:
1078
+ raise IsolationError(error or "unreadable member task copy")
1079
+ member_log_delta(contract, text)
1080
+ if not isinstance(fields.get("agent"), str) or fields["agent"] in {"", "null"}:
1081
+ raise IsolationError("member task copy has no assigned agent")
1082
+ if (fields.get("status"), fields.get("base"), fields.get("member_base")) != (
1083
+ "in-progress", journal["base"], journal["member_base"]):
1084
+ raise IsolationError("member task copy is not the activated task for this landing")
1085
+ if (fields.get("worktree"), fields.get("task_branch")) != (
1086
+ str(sidecar), f"{TASK_BRANCH_PREFIX}{project}-{journal['task_id']}"):
1087
+ raise IsolationError("member task copy is not the activated task for this landing")
1088
+ return text
1089
+
1090
+
1091
+ def _coordinator_clean_for_record(coordinator: Path, recovery: Optional[Dict[str, object]] = None) -> None:
1092
+ staged = set(git_output(coordinator, "diff", "--cached", "--name-only").splitlines())
1093
+ dirty = (uncommitted_paths(coordinator) - BOOKKEEPING_PATHS) | staged
1094
+ if recovery is not None:
1095
+ task_file = str(recovery["task_file"])
1096
+ if task_file in dirty:
1097
+ stamped = _landed_task_text(_member_copy_text(recovery, coordinator), str(recovery["base"]))
1098
+ current, _ = _read_task_text(coordinator / task_file)
1099
+ if (current == stamped and (task_file not in staged
1100
+ or git_text(coordinator, "show", f":{task_file}") == stamped)):
1101
+ dirty.remove(task_file)
1102
+ dirty = sorted(dirty)
1103
+ if dirty:
1104
+ raise IsolationError("coordinator worktree is dirty: " + ", ".join(dirty))
1105
+
1106
+
1107
+ def member_commit_body(task_file: str, paths: Sequence[str], member_base: str, contract: str) -> str:
1108
+ """The member landing body: coordinator task, member base, contract revision, files."""
1109
+ lines = [f"Task: {task_file}", f"Base: {member_base}", f"Contract: {contract}", "Files:"]
1110
+ lines.extend(f"- {path}" for path in sorted(paths))
1111
+ return "\n".join(lines) + "\n"
1112
+
1113
+
1114
+ def _member_journal_path(coordinator: Path, task_id: str) -> Path:
1115
+ return common_git_dir(coordinator).joinpath(*MEMBER_LANDING_DIR, f"{validate_task_id(task_id)}.json")
1116
+
1117
+
1118
+ @contextlib.contextmanager
1119
+ def _member_landing_lock(coordinator: Path) -> Iterator[None]:
1120
+ path = common_git_dir(coordinator).joinpath(*MEMBER_LANDING_DIR, ".lock")
1121
+ with _common.exclusive_lock(path):
1122
+ yield
1123
+
1124
+
1125
+ def _require_member_coordinator_branch(coordinator: Path) -> None:
1126
+ branch = require_bound(coordinator)
1127
+ if __package__:
1128
+ from .pipeline_state import load_state
1129
+ else:
1130
+ try:
1131
+ from pipeline_state import load_state
1132
+ except ImportError:
1133
+ from scripts.pipeline_state import load_state
1134
+ state, _, _ = load_state(coordinator)
1135
+ if branch != state.branch:
1136
+ raise IsolationError(f"coordinator branch {branch} differs from STATE bound branch {state.branch}")
1137
+
1138
+
1139
+ def _record_member_journal(path: Path, journal: Dict[str, object]) -> None:
1140
+ path.parent.mkdir(parents=True, exist_ok=True)
1141
+ _common.atomic_write(path, json.dumps(journal, indent=2, sort_keys=True) + "\n")
1142
+
1143
+
1144
+ def _member_contract(
1145
+ coordinator: Path, base: str, task_file: str, member: str,
1146
+ current_text: Optional[str] = None,
1147
+ ) -> tuple[str, Set[str]]:
1148
+ shown = run_git(coordinator, "show", f"{base}:{task_file}")
1149
+ if shown.returncode != 0:
1150
+ raise IsolationError(f"task contract is missing at {base}: {task_file}")
1151
+ fields, error = task_frontmatter(shown.stdout)
1152
+ if error or fields is None:
1153
+ raise IsolationError(error or f"unreadable task contract: {task_file}")
1154
+ current = current_text
1155
+ if current is None:
1156
+ current, _ = _read_task_text(coordinator / task_file)
1157
+ current_fields, current_error = task_frontmatter(current)
1158
+ if current_error or current_fields is None:
1159
+ raise IsolationError(current_error or f"unreadable current task: {task_file}")
1160
+ if any(current_fields.get(key) != fields.get(key) for key in MEMBER_IMMUTABLE_FIELDS):
1161
+ raise IsolationError(f"current task contract differs from {base}: {task_file}")
1162
+ if fields.get("repo") != member:
1163
+ raise IsolationError(f"{task_file} is not a task for member {member}")
1164
+ files = fields.get("files")
1165
+ if not isinstance(files, list) or not files:
1166
+ raise IsolationError(f"{task_file} declares no files")
1167
+ return str(fields.get("title", "")), {relative_posix(str(path)) for path in files}
1168
+
1169
+
1170
+ def _prove_member_commit(
1171
+ repo: Path, commit: str, parent: str, subject: str, body: str, allowed: Set[str]
1172
+ ) -> Optional[str]:
1173
+ found_parent, error = _single_parent(repo, commit)
1174
+ if error or found_parent != parent:
1175
+ return error or f"commit parent is not {parent}"
1176
+ changed = set(git_output(repo, "diff-tree", "--no-commit-id", "--name-only", "-r", "--no-renames",
1177
+ parent, commit).splitlines())
1178
+ if not changed:
1179
+ return "commit changes nothing"
1180
+ undeclared = sorted(changed - allowed)
1181
+ if undeclared:
1182
+ return "undeclared paths: " + ", ".join(undeclared)
1183
+ if git_output(repo, "log", "-1", "--format=%s", commit) != subject:
1184
+ return f"subject is not {subject!r}"
1185
+ if git_output(repo, "log", "-1", "--format=%b", commit) != body.strip():
1186
+ return "body is not the member landing block"
1187
+ return None
1188
+
1189
+
1190
+ def _commit_member_source(sidecar: Path, journal: Dict[str, object], allowed: Set[str]) -> str:
1191
+ member_base = str(journal["member_base"])
1192
+ pending = uncommitted_paths(sidecar)
1193
+ if pending:
1194
+ if current_sha(sidecar) != member_base:
1195
+ raise IsolationError("dirty member source HEAD must equal the member base")
1196
+ undeclared = sorted(pending - allowed)
1197
+ if undeclared:
1198
+ raise IsolationError("undeclared paths: " + ", ".join(undeclared))
1199
+ git_output(sidecar, "add", "-A", "--", *sorted(pending))
1200
+ 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())
1202
+ source = current_sha(sidecar)
1203
+ if source == member_base:
1204
+ raise IsolationError("no changes to land")
1205
+ paths = git_output(sidecar, "diff-tree", "--no-commit-id", "--name-only", "-r", "--no-renames",
1206
+ member_base, source).splitlines()
1207
+ body = member_commit_body(str(journal["task_file"]), paths, member_base, str(journal["base"]))
1208
+ error = _prove_member_commit(sidecar, source, member_base, str(journal["subject"]), body, allowed)
1209
+ if error:
1210
+ raise IsolationError(f"member source commit proof failed: {error}")
1211
+ return source
1212
+
1213
+
1214
+ def _pick_member_landing(bound: Path, journal: Dict[str, object]) -> str:
1215
+ if current_sha(bound) != journal["expected_parent"] or uncommitted_paths(bound):
1216
+ raise IsolationError("member bound checkout moved or is dirty before landing")
1217
+ picked = run_git(bound, "cherry-pick", str(journal["source"]))
1218
+ if picked.returncode != 0:
1219
+ run_git(bound, "cherry-pick", "--abort")
1220
+ detail = (picked.stderr or picked.stdout).strip() or "cherry-pick conflict"
1221
+ raise IsolationError(f"conflict: {detail}")
1222
+ return current_sha(bound)
1223
+
1224
+
1225
+ def _prove_member_landing(bound: Path, journal: Dict[str, object], landing: str, allowed: Set[str]) -> Optional[str]:
1226
+ source = str(journal["source"])
1227
+ body = git_output(bound, "log", "-1", "--format=%b", source) + "\n"
1228
+ error = _prove_member_commit(bound, landing, str(journal["expected_parent"]),
1229
+ str(journal["subject"]), body, allowed)
1230
+ if error:
1231
+ return error
1232
+ if _tree_delta(bound, str(journal["expected_parent"]), landing) != _tree_delta(
1233
+ bound, str(journal["member_base"]), source):
1234
+ return "landing changes differ from the source commit"
1235
+ return None
1236
+
1237
+
1238
+ def _write_member_record(coordinator: Path, journal: Dict[str, object]) -> str:
1239
+ task_file = str(journal["task_file"])
1240
+ task_path = coordinator / task_file
1241
+ _, mode = _read_task_text(task_path)
1242
+ stamped = _landed_task_text(_member_copy_text(journal, coordinator), str(journal["base"]))
1243
+ _replace_regular_file(task_path, stamped.encode("utf-8"), mode)
1244
+ record = (f"Task: {task_file}\nBase: {journal['base']}\n"
1245
+ f"Member: {journal['member']} {journal['landing']} {journal['member_base']}")
1246
+ git_output(coordinator, "add", "--", task_file)
1247
+ git_output(coordinator, "commit", "-q", "-m", str(journal["subject"]), "-m", record)
1248
+ return current_sha(coordinator)
1249
+
1250
+
1251
+ def _existing_member_record(coordinator: Path, journal: Dict[str, object]) -> Optional[str]:
1252
+ task_file = str(journal["task_file"])
1253
+ body = (f"Task: {task_file}\nBase: {journal['base']}\n"
1254
+ f"Member: {journal['member']} {journal['landing']} {journal['member_base']}")
1255
+ for sha, subject, found_body in _first_parent_records(coordinator, "HEAD"):
1256
+ if subject != journal["subject"] or found_body != body:
1257
+ continue
1258
+ parent, error = _single_parent(coordinator, sha)
1259
+ if error or parent is None:
1260
+ continue
1261
+ changed = set(git_output(coordinator, "diff-tree", "--no-commit-id", "--name-only", "-r",
1262
+ "--no-renames", parent, sha).splitlines())
1263
+ if changed == {task_file}:
1264
+ return sha
1265
+ return None
1266
+
1267
+
1268
+ def _finish_member_landing(
1269
+ coordinator: Path, path: Path, journal: Dict[str, object], *, recovery: bool = False
1270
+ ) -> Dict[str, object]:
1271
+ _coordinator_clean_for_record(coordinator, journal if recovery else None)
1272
+ record = _write_member_record(coordinator, journal)
1273
+ path.unlink()
1274
+ return {"commit": record, "landing": journal["landing"], "member": journal["member"],
1275
+ "mode": "member", "source_commit": journal["source"], "state": "landed"}
1276
+
1277
+
1278
+ def land_member(
1279
+ coordinator: Path, member: str, task_id: str, title: str, task_file: str, base: str, member_base: str
1280
+ ) -> Dict[str, object]:
1281
+ """Land a member task: member product commit first, then the coordinator record.
1282
+
1283
+ A journal in the coordinator Git directory is written before the member
1284
+ branch moves, so recover_member_landing never lands the same work twice.
1285
+ A conflict keeps the source commit on its task sidecar for retry; the bound
1286
+ member branch and coordinator stay unchanged.
1287
+ """
1288
+ coordinator = require_directory(coordinator, "coordinator")
1289
+ _require_member_coordinator_branch(coordinator)
1290
+ task_file = relative_posix(task_file)
1291
+ with _member_landing_lock(coordinator):
1292
+ path = _member_journal_path(coordinator, task_id)
1293
+ if os.path.lexists(path):
1294
+ raise IsolationError(f"a member landing is pending for {task_id}; run recover_member_landing first")
1295
+ for pending in sorted(path.parent.glob("*.json")):
1296
+ if json.loads(pending.read_text(encoding="utf-8"))["member"] == member:
1297
+ raise IsolationError(
1298
+ f"member {member} has pending landing {pending.stem}; run recover_member_landing first"
1299
+ )
1300
+ checkout, project, _ = _member_context(coordinator, member)
1301
+ bound = Path(member_bound_checkout(coordinator, member)["checkout"])
1302
+ sidecar = sidecar_root(checkout, "task", f"{project}-{task_id}")
1303
+ if require_attached(sidecar) != f"{TASK_BRANCH_PREFIX}{project}-{task_id}":
1304
+ raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
1305
+ resolved_base = require_commit(coordinator, require_full_sha(base))
1306
+ contract_title, allowed = _member_contract(coordinator, resolved_base, task_file, member)
1307
+ if title != contract_title:
1308
+ raise IsolationError(f"title does not match the task contract: {contract_title!r}")
1309
+ _coordinator_clean_for_record(coordinator)
1310
+ member_base = require_full_sha(member_base)
1311
+ authorization = run_git(checkout, "rev-parse", "--verify", "--quiet",
1312
+ member_task_authorization_ref(project, task_id)).stdout.strip()
1313
+ if authorization != member_base:
1314
+ raise IsolationError(f"member task {task_id} is not authorized at its member base")
1315
+ journal: Dict[str, object] = {
1316
+ "base": resolved_base, "copy": str(member_task_copy(sidecar, task_file)), "member": member,
1317
+ "member_base": member_base, "subject": task_commit_subject(task_id, title),
1318
+ "task_file": task_file, "task_id": task_id,
1319
+ }
1320
+ _member_copy_text(journal, coordinator)
1321
+ journal["source"] = _commit_member_source(sidecar, journal, allowed)
1322
+ journal["expected_parent"] = current_sha(bound)
1323
+ journal["landing"] = None
1324
+ _record_member_journal(path, journal)
1325
+ try:
1326
+ landing = _pick_member_landing(bound, journal)
1327
+ except IsolationError:
1328
+ path.unlink()
1329
+ raise
1330
+ error = _prove_member_landing(bound, journal, landing, allowed)
1331
+ if error:
1332
+ raise IsolationError(f"member landing proof failed: {error}; the journal keeps it for recovery")
1333
+ journal["landing"] = landing
1334
+ _record_member_journal(path, journal)
1335
+ return _finish_member_landing(coordinator, path, journal)
1336
+
1337
+
1338
+ def recover_member_landing(coordinator: Path, task_id: str) -> Dict[str, object]:
1339
+ """Finish an interrupted member landing from its journal, never picking twice."""
1340
+ coordinator = require_directory(coordinator, "coordinator")
1341
+ path = _member_journal_path(coordinator, task_id)
1342
+ if not os.path.lexists(path):
1343
+ return {"state": "none"}
1344
+ _require_member_coordinator_branch(coordinator)
1345
+ with _member_landing_lock(coordinator):
1346
+ if not os.path.lexists(path):
1347
+ return {"state": "none"}
1348
+ journal = json.loads(path.read_text(encoding="utf-8"))
1349
+ member = str(journal["member"])
1350
+ bound = Path(member_bound_checkout(coordinator, member)["checkout"])
1351
+ _, allowed = _member_contract(coordinator, str(journal["base"]), str(journal["task_file"]), member)
1352
+ if journal.get("landing"):
1353
+ record = _existing_member_record(coordinator, journal)
1354
+ if record:
1355
+ path.unlink()
1356
+ return {"commit": record, "landing": journal["landing"], "member": member,
1357
+ "mode": "member", "source_commit": journal["source"], "state": "landed"}
1358
+ if current_sha(bound) != journal["landing"]:
1359
+ raise IsolationError("the member bound branch moved after the landing journal was written")
1360
+ _require_first_parent_commit(bound, current_sha(bound), str(journal["landing"]))
1361
+ error = _prove_member_landing(bound, journal, str(journal["landing"]), allowed)
1362
+ if error:
1363
+ raise IsolationError(f"member landing proof failed: {error}")
1364
+ if not journal.get("landing"):
1365
+ tip = current_sha(bound)
1366
+ if tip == journal["expected_parent"]:
1367
+ landing = _pick_member_landing(bound, journal)
1368
+ elif _prove_member_landing(bound, journal, tip, allowed) is None:
1369
+ landing = tip
1370
+ else:
1371
+ raise IsolationError("the member bound branch moved after the landing journal was written")
1372
+ error = _prove_member_landing(bound, journal, landing, allowed)
1373
+ if error:
1374
+ raise IsolationError(f"member landing proof failed: {error}")
1375
+ journal["landing"] = landing
1376
+ _record_member_journal(path, journal)
1377
+ return _finish_member_landing(coordinator, path, journal, recovery=True)
1378
+
1379
+
579
1380
  def isolate_verify(primary: Path, base: str, name: str, historical_task: Optional[str] = None) -> Dict[str, object]:
580
1381
  primary = require_directory(primary, "primary worktree")
581
1382
  if worktree_root(primary) != primary:
@@ -612,6 +1413,69 @@ def isolate_verify(primary: Path, base: str, name: str, historical_task: Optiona
612
1413
  }
613
1414
 
614
1415
 
1416
+ def check_member_verify_collision(entries: Sequence[Dict[str, str]], sidecar_name: str) -> None:
1417
+ if any(item["name"] == sidecar_name for item in entries):
1418
+ raise IsolationError(
1419
+ f"locked member {sidecar_name} collides with coordinator Verify sidecar folder {sidecar_name}"
1420
+ )
1421
+
1422
+
1423
+ def isolate_member_verify(coordinator: Path, sidecar: Path) -> Dict[str, Dict[str, str]]:
1424
+ """Check out each locked member's bound tip beside the coordinator verify sidecar."""
1425
+ try:
1426
+ import members
1427
+ except ImportError: # pragma: no cover - package import used by tests
1428
+ from scripts import members
1429
+ lock = coordinator / members.LOCK_PATH
1430
+ entries = json.loads(lock.read_text(encoding="utf-8"))["members"] if lock.is_file() else []
1431
+ check_member_verify_collision(entries, sidecar.name)
1432
+ created: Dict[str, Dict[str, str]] = {}
1433
+ try:
1434
+ for item in entries:
1435
+ checkout, project, entry = _member_context(coordinator, item["name"])
1436
+ tip = git_output(checkout, "rev-parse", "--verify", f"refs/heads/{entry['branch']}^{{commit}}")
1437
+ branch = f"{VERIFY_BRANCH_PREFIX}{project}-{entry['name']}"
1438
+ destination = sidecar.parent / entry["name"]
1439
+ create_named_worktree(checkout, branch, destination, tip, bound=entry["branch"])
1440
+ created[entry["name"]] = {"checkout": str(checkout), "worktree": str(destination),
1441
+ "branch": branch, "tip": tip}
1442
+ except BaseException:
1443
+ retire_member_verify(created)
1444
+ raise
1445
+ return created
1446
+
1447
+
1448
+ def _member_verify_sidecar(record: Dict[str, str]) -> Tuple[Path, Path, str]:
1449
+ checkout = Path(record["checkout"])
1450
+ destination = Path(record["worktree"])
1451
+ branch = record["branch"]
1452
+ registered = _registered_worktrees(checkout)
1453
+ if (destination.is_symlink() or not destination.is_dir()
1454
+ or destination.resolve() not in registered
1455
+ or worktree_root(destination) != destination.resolve()
1456
+ or common_git_dir(destination) != common_git_dir(checkout)):
1457
+ raise IsolationError(f"member Verify sidecar ownership changed: {destination}")
1458
+ return checkout, destination, branch
1459
+
1460
+
1461
+ def retire_member_verify(created: Dict[str, Dict[str, str]]) -> None:
1462
+ """Remove the member siblings of a coordinator verify sidecar; they hold no output."""
1463
+ for record in reversed(list(created.values())):
1464
+ checkout, destination, branch = _member_verify_sidecar(record)
1465
+ _remove_worktree(checkout, destination, True)
1466
+ git_output(checkout, "branch", "-D", branch)
1467
+ release_workspace_quietly(checkout)
1468
+
1469
+
1470
+ def check_member_verify(created: Dict[str, Dict[str, str]]) -> None:
1471
+ for record in created.values():
1472
+ _, destination, _ = _member_verify_sidecar(record)
1473
+ if require_attached(destination) != record["branch"]:
1474
+ raise IsolationError(f"member Verify sidecar branch changed: {destination}")
1475
+ if current_sha(destination) != record["tip"]:
1476
+ raise IsolationError(f"member Verify sidecar HEAD changed: {destination}")
1477
+
1478
+
615
1479
  def clean_verify(primary: Path, worktree: Path, base: str, branch: str) -> Dict[str, object]:
616
1480
  """Discard command-created files only in the named verification sidecar."""
617
1481
  primary = require_directory(primary, "primary worktree")
@@ -632,17 +1496,135 @@ def clean_verify(primary: Path, worktree: Path, base: str, branch: str) -> Dict[
632
1496
  return {"worktree": str(worktree), "branch": branch, "base": expected, "removed": removed}
633
1497
 
634
1498
 
1499
+ def ensure_verify_sidecar(primary: Path, base: str, branch: str) -> Dict[str, object]:
1500
+ """Return the named verify sidecar, recreating it when it is missing.
1501
+
1502
+ Finish needs the sidecar prepare-task created. A deleted sidecar must be
1503
+ rebuildable from the recorded base without a clean primary: the serial
1504
+ task's own product changes are exactly the tree finish reproduces next,
1505
+ so this must not reuse isolate_verify's clean-tree gate.
1506
+ """
1507
+ primary = require_directory(primary, "primary worktree")
1508
+ if worktree_root(primary) != primary:
1509
+ raise IsolationError(f"primary is not its Git root: {primary}")
1510
+ resolved_base = require_commit(primary, require_full_sha(base))
1511
+ if not branch.startswith(VERIFY_BRANCH_PREFIX):
1512
+ raise IsolationError("verify sidecar reproduction requires a verify branch")
1513
+ destination = _sidecar_path_for_branch(primary, branch)
1514
+ ref = f"refs/heads/{branch}"
1515
+ registered = _registered_worktrees(primary)
1516
+ if destination.is_symlink() or (destination.exists() and not destination.is_dir()):
1517
+ raise IsolationError(f"worktree path already exists: {destination}")
1518
+ if destination.is_dir():
1519
+ try:
1520
+ # A leftover empty directory (a half-deleted sidecar) clears;
1521
+ # anything non-empty must be the sidecar itself.
1522
+ destination.rmdir()
1523
+ except OSError:
1524
+ try:
1525
+ same = (
1526
+ worktree_root(destination) == destination
1527
+ and destination.resolve() in registered
1528
+ and registered[destination.resolve()] == ref
1529
+ and common_git_dir(destination) == common_git_dir(primary)
1530
+ )
1531
+ except IsolationError:
1532
+ same = False
1533
+ if not same:
1534
+ raise IsolationError(
1535
+ f"verify sidecar ownership changed: {destination}"
1536
+ ) from None
1537
+ if (
1538
+ require_attached(destination) != branch
1539
+ or current_sha(destination) != resolved_base
1540
+ ):
1541
+ raise IsolationError(f"verify sidecar HEAD changed: {destination}")
1542
+ return {
1543
+ "base": resolved_base,
1544
+ "branch": branch,
1545
+ "kind": "verify",
1546
+ "mode": "sidecar",
1547
+ "worktree": str(destination),
1548
+ }
1549
+ if destination.resolve() in registered:
1550
+ cleared = run_git(primary, "worktree", "remove", str(destination))
1551
+ if cleared.returncode != 0:
1552
+ run_git(primary, "worktree", "prune")
1553
+ if destination.resolve() in _registered_worktrees(primary):
1554
+ raise IsolationError(
1555
+ (cleared.stderr or cleared.stdout).strip()
1556
+ or "could not clear stale worktree registration"
1557
+ )
1558
+ existing = run_git(primary, "show-ref", "--verify", "--quiet", ref)
1559
+ if existing.returncode == 0:
1560
+ deleted = run_git(primary, "update-ref", "-d", ref)
1561
+ if deleted.returncode != 0:
1562
+ raise IsolationError(
1563
+ (deleted.stderr or deleted.stdout).strip() or "git update-ref failed"
1564
+ )
1565
+ create_named_worktree(primary, branch, destination, resolved_base)
1566
+ return {
1567
+ "base": resolved_base,
1568
+ "branch": branch,
1569
+ "kind": "verify",
1570
+ "mode": "sidecar",
1571
+ "worktree": str(destination),
1572
+ }
1573
+
1574
+
1575
+ def reproduce_verify_sidecar(sidecar_path: Path, tree: str) -> None:
1576
+ """Copy the primary's current task tree into the verify sidecar (clean_verify's inverse)."""
1577
+ git_output(sidecar_path, "read-tree", "-u", "--reset", tree)
1578
+ if git_output(sidecar_path, "write-tree") != tree:
1579
+ raise IsolationError("sidecar reproduction differs from the primary task diff")
1580
+
1581
+
635
1582
  def _split_paths(block: str) -> Set[str]:
636
1583
  return {line.strip() for line in block.splitlines() if line.strip()}
637
1584
 
638
1585
 
639
- # Top-level .project entries the pipeline owns; ship verification rejects anything else.
1586
+ # Top-level .project entries the pipeline owns; ship checks filter other entries by Git visibility.
640
1587
  PROJECT_ENTRIES = frozenset({
641
- "STATE.md", "LESSONS.md", "REPOSITORY.md", "CHARTER.md", "ROADMAP.md", "SYNTHESIS.md",
1588
+ "STATE.md", "LESSONS.md", "REPOSITORY.md", "MEMBERS.md", "CHARTER.md", "ROADMAP.md",
1589
+ "SYNTHESIS.md", "config.json", "model-policy.json",
642
1590
  "intent", "research", "plan", "tasks", "review", "build", "discuss", "archive", "next",
643
1591
  })
644
1592
 
645
1593
 
1594
+ def prune_host_scratch(project: Path, archive: Optional[Path] = None) -> list:
1595
+ """Remove file-free `.claude` dirs a host left under .project.
1596
+
1597
+ Claude Code's Bash sandbox creates `<cwd>/.claude/.cc-writes` as an atomic-write staging dir each
1598
+ time it initializes, and adds `**/.claude/.cc-writes/` to the global gitignore. Git cannot ship
1599
+ empty dirs, so removal loses nothing. A `.claude` that holds any file or symlink is kept, and the
1600
+ caller checks retained entries against Git visibility at the .project top level. Archives stay
1601
+ untouched except `archive`, the current uncommitted one.
1602
+ """
1603
+ roots = [path for path in project.iterdir() if path.name != "archive"]
1604
+ if archive is not None:
1605
+ roots.append(archive)
1606
+ hosts = []
1607
+ for root in roots:
1608
+ if root.is_symlink() or not root.is_dir():
1609
+ continue
1610
+ if root.name == ".claude":
1611
+ hosts.append(root)
1612
+ for directory, names, _ in os.walk(root):
1613
+ hosts.extend(Path(directory, name) for name in names if name == ".claude")
1614
+ removed = []
1615
+ for host in hosts:
1616
+ if host.is_symlink() or not host.is_dir():
1617
+ continue # a symlink, or already removed with an outer `.claude`
1618
+ tree = list(os.walk(host, topdown=False))
1619
+ if any(files or any(Path(directory, name).is_symlink() for name in names)
1620
+ for directory, names, files in tree):
1621
+ continue
1622
+ for directory, _, _ in tree:
1623
+ os.rmdir(directory)
1624
+ removed.append(host.relative_to(project).as_posix())
1625
+ return removed
1626
+
1627
+
646
1628
  def uncommitted_paths(repo: Path) -> Set[str]:
647
1629
  tracked = git_output(
648
1630
  repo, "diff", "--no-renames", "--name-only", "--relative", "HEAD"
@@ -664,12 +1646,30 @@ def committed_paths_since(repo: Path, base: str) -> Set[str]:
664
1646
  )
665
1647
 
666
1648
 
667
- def _commit_pending(repo: Path, pending: Set[str], subject: str, body: str) -> str:
1649
+ def ignored_legacy_ledger(repo: Path) -> bool:
1650
+ """True when a verify ledger absent from HEAD is hidden by an ignore rule.
1651
+
1652
+ Only milestones started before verify-record refused ignored ledgers reach
1653
+ this state. A build checkpoint commits the file so a task that anchors the
1654
+ rule does not expose it as a task change.
1655
+ """
1656
+ path = repo.joinpath(*PurePosixPath(VERIFY_LEDGER_PATH).parts)
1657
+ if path.is_symlink() or not path.is_file():
1658
+ return False
1659
+ if run_git(repo, "cat-file", "-e", f"HEAD:{VERIFY_LEDGER_PATH}").returncode == 0:
1660
+ return False
1661
+ return run_git(repo, "check-ignore", "-q", "--no-index", "--", VERIFY_LEDGER_PATH).returncode == 0
1662
+
1663
+
1664
+ def _commit_pending(
1665
+ repo: Path, pending: Set[str], subject: str, body: str, forced: Set[str] = frozenset()
1666
+ ) -> str:
668
1667
  reset = run_git(repo, "reset", "-q", "HEAD")
669
1668
  if reset.returncode != 0:
670
1669
  raise IsolationError("could not clear the index before commit")
671
1670
  for path in sorted(pending):
672
- added = run_git(repo, "add", "-A", "--", path)
1671
+ mode = "-f" if path in forced else "-A"
1672
+ added = run_git(repo, "add", mode, "--", path)
673
1673
  if added.returncode != 0:
674
1674
  raise IsolationError(f"could not stage {path}")
675
1675
  staged = _split_paths(
@@ -691,14 +1691,24 @@ def _commit_pending(repo: Path, pending: Set[str], subject: str, body: str) -> s
691
1691
 
692
1692
 
693
1693
  def validate_allowed_changes(
694
- repo: Path, base: str, pending: Set[str], allowed: Set[str]
1694
+ repo: Path,
1695
+ base: str,
1696
+ pending: Set[str],
1697
+ allowed: Set[str],
1698
+ tolerated: Set[str] = frozenset(),
695
1699
  ) -> None:
696
- if not pending:
1700
+ # Tolerated paths (primary bookkeeping) may stay uncommitted; the next
1701
+ # `.project` checkpoint commits them.
1702
+ landing = pending - tolerated
1703
+ if not landing:
697
1704
  raise IsolationError("no changes to land")
698
- since_base = committed_paths_since(repo, base) | pending
1705
+ since_base = committed_paths_since(repo, base) | landing
699
1706
  unexpected = sorted(path for path in since_base if path not in allowed)
700
1707
  if unexpected:
701
- raise IsolationError("unexpected paths: " + ", ".join(unexpected))
1708
+ raise IsolationError(
1709
+ "unexpected paths: " + ", ".join(unexpected)
1710
+ + "; landing allows only the task file and its declared files"
1711
+ )
702
1712
  def _clean_blob_oid(repo: Path, path: str, text: str, *, write: bool) -> str:
703
1713
  arguments = ["hash-object"]
704
1714
  if write:
@@ -718,21 +1728,23 @@ def commit_allowed_changes(
718
1728
  subject: str,
719
1729
  allowed: Set[str],
720
1730
  body: str,
1731
+ tolerated: Set[str] = frozenset(),
721
1732
  ) -> tuple[str, str, Dict[str, Optional[tuple[str, str]]]]:
722
1733
  require_attached(repo)
723
1734
  pending = uncommitted_paths(repo)
724
- validate_allowed_changes(repo, base, pending, allowed)
1735
+ validate_allowed_changes(repo, base, pending, allowed, tolerated)
1736
+ staging = pending - tolerated
725
1737
  reset = run_git(repo, "reset", "-q", "HEAD")
726
1738
  if reset.returncode != 0:
727
1739
  raise IsolationError("could not clear the index before landing")
728
- for path in sorted(pending):
1740
+ for path in sorted(staging):
729
1741
  added = run_git(repo, "add", "-A", "--", path)
730
1742
  if added.returncode != 0:
731
1743
  raise IsolationError(f"could not stage {path}")
732
1744
  staged = _split_paths(
733
1745
  git_output(repo, "diff", "--cached", "--name-only", "--relative")
734
1746
  )
735
- if staged != pending:
1747
+ if staged != staging:
736
1748
  raise IsolationError("staged paths do not match the uncommitted change set")
737
1749
  verified_tree = git_output(repo, "write-tree")
738
1750
  verified_delta = _tree_delta(repo, base, verified_tree)
@@ -878,6 +1890,17 @@ def activate_task(
878
1890
  if task_branch is None:
879
1891
  if branch.startswith(TASK_BRANCH_PREFIX):
880
1892
  raise IsolationError("parallel task activation requires --task-branch")
1893
+ live = [
1894
+ path
1895
+ for path, ref in _registered_worktrees(worktree).items()
1896
+ if ref == f"refs/heads/{task_branch_name(task_id)}"
1897
+ and path != worktree
1898
+ ]
1899
+ if live:
1900
+ raise IsolationError(
1901
+ "task has an active parallel worktree; serial activation in "
1902
+ f"another worktree is refused (activate {live[0]} with --task-branch)"
1903
+ )
881
1904
  elif task_branch != task_branch_name(task_id) or branch != task_branch:
882
1905
  raise IsolationError("task activation branch does not match the task worktree")
883
1906
  normalized_task = relative_posix(task_file)
@@ -908,7 +1931,7 @@ def activate_task(
908
1931
  if existing.returncode != 0 or existing.stdout.strip() != resolved_base:
909
1932
  _replace_regular_file(task_path, text.encode("utf-8"), mode)
910
1933
  raise IsolationError("could not record task worktree authorization")
911
- return {
1934
+ result = {
912
1935
  "agent": agent,
913
1936
  "base": resolved_base,
914
1937
  "status": "in-progress",
@@ -916,6 +1939,30 @@ def activate_task(
916
1939
  "task_file": normalized_task,
917
1940
  "worktree": str(worktree),
918
1941
  }
1942
+ _write_native_dispatch_record(worktree, result)
1943
+ return result
1944
+
1945
+
1946
+ def _write_native_dispatch_record(worktree: Path, activation: Dict[str, object]) -> None:
1947
+ try:
1948
+ if __package__:
1949
+ from . import dispatch_driver
1950
+ else:
1951
+ import dispatch_driver
1952
+ except ImportError:
1953
+ return
1954
+ dispatch_driver.record_native_activation(worktree, activation)
1955
+
1956
+
1957
+ def _write_native_member_dispatch_record(coordinator: Path, activation: Dict[str, object]) -> None:
1958
+ try:
1959
+ if __package__:
1960
+ from . import dispatch_driver
1961
+ else:
1962
+ import dispatch_driver
1963
+ except ImportError:
1964
+ return
1965
+ dispatch_driver.record_native_member_activation(coordinator, activation)
919
1966
 
920
1967
 
921
1968
  def deactivate_task(
@@ -1034,6 +2081,59 @@ def stamp_task_landed(task_path: Path, base: str) -> None:
1034
2081
  )
1035
2082
 
1036
2083
 
2084
+ def block_serial_task(
2085
+ primary: Path, task_file: str, task_id: str, base: str
2086
+ ) -> Dict[str, str]:
2087
+ """Record an explicit blocked result on its still-active serial task."""
2088
+ primary = require_directory(primary, "primary worktree")
2089
+ if worktree_root(primary) != primary:
2090
+ raise IsolationError(f"primary is not its Git root: {primary}")
2091
+ normalized_task = relative_posix(task_file)
2092
+ if not normalized_task.startswith(".project/tasks/"):
2093
+ raise IsolationError("serial task file must stay under .project/tasks/")
2094
+ task_id = validate_task_id(task_id)
2095
+ expected_base = require_commit(primary, require_full_sha(base))
2096
+ task_path = _real_file(primary, normalized_task, "serial task file")
2097
+ text, mode = _read_task_text(task_path)
2098
+ fields, error = task_frontmatter(text)
2099
+ if error or fields is None:
2100
+ raise IsolationError(error or "serial task frontmatter is unreadable")
2101
+ if fields.get("id") != task_id:
2102
+ raise IsolationError("serial task id changed before blocked result")
2103
+ if fields.get("status") not in {"in-progress", "blocked"}:
2104
+ raise IsolationError("serial task is no longer active")
2105
+ if fields.get("base") != expected_base:
2106
+ raise IsolationError("serial task base changed before blocked result")
2107
+ if fields.get("task_branch") != "null":
2108
+ raise IsolationError("serial task records a task branch")
2109
+ recorded_worktree = fields.get("worktree")
2110
+ if not isinstance(recorded_worktree, str) or Path(recorded_worktree).resolve() != primary:
2111
+ raise IsolationError("serial task worktree changed before blocked result")
2112
+
2113
+ if fields.get("status") == "blocked":
2114
+ return {"task_id": task_id, "task_file": normalized_task,
2115
+ "base": expected_base, "status": "blocked"}
2116
+
2117
+ head, body = split_frontmatter(text)
2118
+ replaced = False
2119
+ lines = []
2120
+ for line in head:
2121
+ if _frontmatter_key(line) == "status":
2122
+ if replaced:
2123
+ raise IsolationError("serial task frontmatter repeats status")
2124
+ replaced = True
2125
+ lines.append("status: blocked")
2126
+ else:
2127
+ lines.append(line)
2128
+ if not replaced:
2129
+ raise IsolationError("serial task frontmatter is missing status")
2130
+ _replace_regular_file(
2131
+ task_path, ("---\n" + "\n".join(lines) + "\n---\n" + body).encode("utf-8"), mode
2132
+ )
2133
+ return {"task_id": task_id, "task_file": normalized_task,
2134
+ "base": expected_base, "status": "blocked"}
2135
+
2136
+
1037
2137
  def _restore_landing_state(
1038
2138
  worktree: Path, task_path: Path, task_bytes: bytes, task_mode: int, index_tree: str
1039
2139
  ) -> None:
@@ -1068,7 +2168,12 @@ def _restore_rejected_commit(
1068
2168
 
1069
2169
 
1070
2170
  def _stamp_and_commit(
1071
- worktree: Path, base: str, subject: str, task_file: str, allowed: Set[str]
2171
+ worktree: Path,
2172
+ base: str,
2173
+ subject: str,
2174
+ task_file: str,
2175
+ allowed: Set[str],
2176
+ tolerated: Set[str] = frozenset(),
1072
2177
  ) -> tuple[str, Dict[str, Optional[tuple[str, str]]]]:
1073
2178
  task_path = worktree / task_file
1074
2179
  task_text, task_mode = _read_task_text(task_path)
@@ -1089,14 +2194,14 @@ def _stamp_and_commit(
1089
2194
  pending = uncommitted_paths(worktree)
1090
2195
  if stamped != task_bytes:
1091
2196
  pending.add(task_file)
1092
- validate_allowed_changes(worktree, base, pending, allowed)
2197
+ validate_allowed_changes(worktree, base, pending, allowed, tolerated)
1093
2198
  original_head = current_sha(worktree)
1094
2199
  index_tree = git_output(worktree, "write-tree")
1095
2200
  try:
1096
2201
  _replace_regular_file(task_path, stamped, task_mode)
1097
- body = task_commit_body(task_file, uncommitted_paths(worktree), base)
2202
+ body = task_commit_body(task_file, uncommitted_paths(worktree) - tolerated, base)
1098
2203
  commit, verified_tree, verified_delta = commit_allowed_changes(
1099
- worktree, base, subject, allowed, body
2204
+ worktree, base, subject, allowed, body, tolerated
1100
2205
  )
1101
2206
  proof_error = _landing_commit_proof_error(
1102
2207
  worktree,
@@ -1147,6 +2252,10 @@ def land(
1147
2252
  raise IsolationError(f"source is not its Git root: {source}")
1148
2253
  if common_git_dir(primary) != common_git_dir(source):
1149
2254
  raise IsolationError("source worktree belongs to another repository")
2255
+ # The source holds the task's own .gitignore edits, so a task that anchors a rule can land.
2256
+ ignore_error = _common.project_ignore_error(source)
2257
+ if ignore_error:
2258
+ raise IsolationError(ignore_error)
1150
2259
  bound = require_bound(primary)
1151
2260
  source_branch = require_attached(source)
1152
2261
  resolved_base = require_commit(primary, require_full_sha(base))
@@ -1170,6 +2279,7 @@ def land(
1170
2279
  subject,
1171
2280
  relative_posix(task_file),
1172
2281
  allowed,
2282
+ BOOKKEEPING_PATHS,
1173
2283
  )
1174
2284
  return {
1175
2285
  "bound_branch": bound,
@@ -1464,7 +2574,7 @@ def _landing_retry_error(
1464
2574
  if task_file not in pending:
1465
2575
  return "interrupted landing does not retain the task change"
1466
2576
  try:
1467
- validate_allowed_changes(repo, base, pending, contract_paths)
2577
+ validate_allowed_changes(repo, base, pending, contract_paths, BOOKKEEPING_PATHS)
1468
2578
  except IsolationError as validation_error:
1469
2579
  return str(validation_error)
1470
2580
  return None
@@ -1687,6 +2797,145 @@ def _resume_task_error(
1687
2797
  return _retained_task_contract_error(current_text, isolate_text)
1688
2798
 
1689
2799
 
2800
+ def _serial_bookkeeping_error(primary: Path, base: str, head: str) -> Optional[str]:
2801
+ """Prove that serial task history advanced only through linear .project commits."""
2802
+ if base == head:
2803
+ return None
2804
+ if run_git(primary, "merge-base", "--is-ancestor", base, head).returncode != 0:
2805
+ return "recorded base is not an ancestor of the current branch"
2806
+ commits = git_output(
2807
+ primary, "rev-list", "--first-parent", "--reverse", f"{base}..{head}"
2808
+ ).splitlines()
2809
+ previous = base
2810
+ for commit in commits:
2811
+ parents = git_output(primary, "rev-list", "--parents", "-n", "1", commit).split()
2812
+ if len(parents) != 2 or parents[1] != previous:
2813
+ return "history contains an unproven merge or commit sequence"
2814
+ paths = git_output(
2815
+ primary,
2816
+ "diff-tree",
2817
+ "--no-commit-id",
2818
+ "--name-only",
2819
+ "-r",
2820
+ "--no-renames",
2821
+ previous,
2822
+ commit,
2823
+ ).splitlines()
2824
+ if not paths:
2825
+ return "history contains an empty commit"
2826
+ if any(path != ".project" and not path.startswith(".project/") for path in paths):
2827
+ return "history contains changes outside .project"
2828
+ previous = commit
2829
+ if previous != head:
2830
+ return "history does not prove a first-parent path from the recorded base"
2831
+ return None
2832
+
2833
+
2834
+ MEMBER_RECORD_RE = re.compile(
2835
+ r"Task: (?P<task>\S+)\nBase: (?P<base>[0-9a-f]{40})\n"
2836
+ r"Member: (?P<member>\S+) (?P<landing>[0-9a-f]{40}) (?P<member_base>[0-9a-f]{40})"
2837
+ )
2838
+
2839
+
2840
+ def _recover_unfinished_member_task(coordinator: Path, task_id: str, member: str) -> Dict[str, object]:
2841
+ """Unfinished member task: a pending journal retries landing; a retained sidecar resumes."""
2842
+ if os.path.lexists(_member_journal_path(coordinator, task_id)):
2843
+ return {"verdict": "resume", "landing_retry": True, "member": member, "worktree": None}
2844
+ try:
2845
+ checkout, project, _ = _member_context(coordinator, member)
2846
+ except IsolationError as error:
2847
+ return {"verdict": "block", "reason": str(error)}
2848
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
2849
+ retained = run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").returncode == 0
2850
+ if retained:
2851
+ return {"verdict": "resume", "member": member, "task_branch": None, "worktree": None,
2852
+ "member_task_branch": branch}
2853
+ return {"verdict": "none", "reason": "pending member task has no retained isolate"}
2854
+
2855
+
2856
+ def deactivate_member_task(coordinator: Path, member: str, task_id: str) -> None:
2857
+ """Clear a member task's authorization so a failed attempt cannot land."""
2858
+ checkout, project, _ = _member_context(coordinator, member)
2859
+ deleted = run_git(checkout, "update-ref", "-d", member_task_authorization_ref(project, task_id))
2860
+ if deleted.returncode != 0:
2861
+ raise IsolationError((deleted.stderr or deleted.stdout).strip() or "could not clear member task authorization")
2862
+
2863
+
2864
+ def member_log_delta(contract: str, copy: str) -> str:
2865
+ """The Log a coder appended to a member task's live copy since its contract."""
2866
+ expected, expected_error = task_frontmatter(contract)
2867
+ fields, error = task_frontmatter(copy)
2868
+ if expected_error or error or expected is None or fields is None:
2869
+ raise IsolationError(error or expected_error or "unreadable member task copy")
2870
+ if {key: value for key, value in fields.items() if key not in MEMBER_ACTIVATION_FIELDS} != {
2871
+ key: value for key, value in expected.items() if key not in MEMBER_ACTIVATION_FIELDS}:
2872
+ raise IsolationError("member task copy changes contract fields")
2873
+ base_body = _normalize_newlines(split_frontmatter(contract)[1])
2874
+ body = _normalize_newlines(split_frontmatter(copy)[1])
2875
+ if not body.startswith(base_body):
2876
+ raise IsolationError("member task copy Log is not append-only")
2877
+ return body[len(base_body):]
2878
+
2879
+
2880
+ def _prove_member_task(
2881
+ coordinator: Path, task_file: str, fields: Dict[str, object], subject: str,
2882
+ history: Dict[str, list[tuple[str, str]]],
2883
+ ) -> Dict[str, object]:
2884
+ """A done member task: one coordinator record naming a proven member landing."""
2885
+ if fields.get("status") != "done":
2886
+ return {"verdict": "block", "reason": "member task must have status: done"}
2887
+ member = str(fields.get("repo"))
2888
+ records = []
2889
+ for sha, body in history.get(subject, []):
2890
+ match = MEMBER_RECORD_RE.fullmatch(body.strip())
2891
+ if match and match["task"] == task_file and match["member"] == member:
2892
+ records.append((sha, match))
2893
+ if len(records) != 1:
2894
+ return {"verdict": "block", "reason": f"expected one coordinator record for {task_file}, found {len(records)}"}
2895
+ record, match = records[0]
2896
+ if git_output(coordinator, "diff-tree", "--no-commit-id", "--name-only", "-r", record).splitlines() != [task_file]:
2897
+ return {"verdict": "block", "reason": "the coordinator record touches more than the task file"}
2898
+ for field in ("base", "member_base"):
2899
+ if fields.get(field) != match[field]:
2900
+ return {"verdict": "block", "reason": f"task {field} does not match the coordinator record"}
2901
+ try:
2902
+ recorded = run_git(coordinator, "show", f"{record}:{task_file}")
2903
+ if recorded.returncode != 0:
2904
+ raise IsolationError(f"task is missing from coordinator record: {task_file}")
2905
+ recorded_fields, error = task_frontmatter(recorded.stdout)
2906
+ if error or recorded_fields is None:
2907
+ raise IsolationError(error or f"unreadable recorded task: {task_file}")
2908
+ if recorded_fields.get("status") != "done":
2909
+ raise IsolationError("recorded task status is not done")
2910
+ for field in ("base", "member_base"):
2911
+ if recorded_fields.get(field) != match[field]:
2912
+ raise IsolationError(f"recorded task {field} does not match the coordinator record")
2913
+ checkout, _, entry = _member_context(coordinator, member, lock_at=record)
2914
+ _, allowed = _member_contract(
2915
+ coordinator, match["base"], task_file, member, current_text=recorded.stdout
2916
+ )
2917
+ if any(fields.get(key) != recorded_fields.get(key) for key in MEMBER_IMMUTABLE_FIELDS):
2918
+ raise IsolationError(f"task artifact contract differs from {match['base']}: {task_file}")
2919
+ except IsolationError as error:
2920
+ return {"verdict": "block", "reason": str(error)}
2921
+ landing = match["landing"]
2922
+ history_on_bound = git_output(checkout, "rev-list", "--first-parent", f"refs/heads/{entry['branch']}").split()
2923
+ if landing not in history_on_bound:
2924
+ return {"verdict": "block", "reason": f"landing {landing[:12]} is not on the member bound branch"}
2925
+ parent, error = _single_parent(checkout, landing)
2926
+ if error or parent is None or run_git(
2927
+ checkout, "merge-base", "--is-ancestor", match["member_base"], parent).returncode != 0:
2928
+ return {"verdict": "block", "reason": error or "member landing does not descend from member_base"}
2929
+ paths = git_output(checkout, "diff-tree", "--no-commit-id", "--name-only", "-r", "--no-renames",
2930
+ parent, landing).splitlines()
2931
+ body = member_commit_body(task_file, paths, match["member_base"], match["base"])
2932
+ error = _prove_member_commit(checkout, landing, parent, subject, body, allowed)
2933
+ if error:
2934
+ return {"verdict": "block", "reason": f"member landing proof failed: {error}"}
2935
+ return {"verdict": "recovered", "commit": record, "base": match["base"],
2936
+ "landing": landing, "member": member}
2937
+
2938
+
1690
2939
  def _recover_task(
1691
2940
  primary: Path,
1692
2941
  path: Path,
@@ -1718,6 +2967,14 @@ def _recover_task(
1718
2967
  return result("block", reason=str(error))
1719
2968
  except IsolationError as exc:
1720
2969
  return result("block", reason=str(exc))
2970
+ if fields.get("repo"):
2971
+ # Only the landing record proves member task completion.
2972
+ if status in ("pending", "in-progress"):
2973
+ if any(MEMBER_RECORD_RE.fullmatch(body.strip()) and f"Task: {task_file}\n" in body
2974
+ for _, body in history.get(subject, [])):
2975
+ return result("block", reason="member task has a landing record but is not done")
2976
+ return result(**_recover_unfinished_member_task(primary, task_id, str(fields["repo"])))
2977
+ return result(**_prove_member_task(primary, task_file, fields, subject, history))
1721
2978
  task_branch = task_branch_name(task_id)
1722
2979
  branch_exists = task_branch in branches
1723
2980
  worktree, worktree_error = _worktree_report(
@@ -1863,8 +3120,12 @@ def _recover_task(
1863
3120
  )
1864
3121
  except IsolationError as error:
1865
3122
  return result("block", reason=f"{status} task has invalid base: {error}")
1866
- if head != base:
1867
- return result("block", reason=f"{status} serial task advanced past its base")
3123
+ advance_error = _serial_bookkeeping_error(primary, base, head)
3124
+ if advance_error:
3125
+ return result(
3126
+ "block",
3127
+ reason=f"{status} serial task advanced past its base: {advance_error}",
3128
+ )
1868
3129
  primary_worktree, primary_error = _worktree_report(primary, primary, bound)
1869
3130
  if primary_error:
1870
3131
  return result("block", reason=primary_error)
@@ -1878,6 +3139,15 @@ def _recover_task(
1878
3139
  str(primary),
1879
3140
  "null",
1880
3141
  )
3142
+ if head != base and resume_error is None:
3143
+ return result(
3144
+ "reconcile",
3145
+ base=base,
3146
+ reason=(
3147
+ "serial task advanced through .project bookkeeping; reconcile the "
3148
+ "attempt and any retained Verify sidecar before resetting it to pending"
3149
+ ),
3150
+ )
1881
3151
  if resume_error:
1882
3152
  return result("block", reason=resume_error)
1883
3153
  return result("resume", base=base)
@@ -1906,8 +3176,12 @@ def _recover_task(
1906
3176
  if serial_retained:
1907
3177
  if str(fields.get("task_branch", "")) != "null":
1908
3178
  return result("block", reason=f"{status} serial task records a task branch")
1909
- if run_git(primary, "merge-base", "--is-ancestor", base, head).returncode != 0:
1910
- return result("block", reason=f"{status} serial task base is not an ancestor")
3179
+ advance_error = _serial_bookkeeping_error(primary, base, head)
3180
+ if advance_error:
3181
+ return result(
3182
+ "block",
3183
+ reason=f"{status} serial task advanced past its base: {advance_error}",
3184
+ )
1911
3185
  primary_worktree, primary_error = _worktree_report(primary, primary, bound)
1912
3186
  if primary_error:
1913
3187
  return result("block", reason=primary_error)
@@ -2907,10 +4181,13 @@ def publication_base(repo: Path, branch: str, ship_commit: str) -> Optional[str]
2907
4181
  Returns None when the milestone is not an adopted one; raises when the
2908
4182
  archived receipt, ship commit, or archived tasks fail proof.
2909
4183
  """
2910
- try:
2911
- from pipeline_state import load_state
2912
- except ImportError: # pragma: no cover - package import used by tests
2913
- from scripts.pipeline_state import load_state
4184
+ if __package__:
4185
+ from .pipeline_state import load_state
4186
+ else:
4187
+ try:
4188
+ from pipeline_state import load_state
4189
+ except ImportError: # pragma: no cover - package import used by tests
4190
+ from scripts.pipeline_state import load_state
2914
4191
  repo = require_directory(repo, "repository")
2915
4192
  state, state_text, _ = load_state(repo)
2916
4193
  if (state.phase, state.status, state.integration, state.branch) != (
@@ -3123,6 +4400,22 @@ def checkpoint(
3123
4400
  }
3124
4401
  raise IsolationError("primary HEAD differs from --expected-head")
3125
4402
  pending = uncommitted_paths(repo)
4403
+ forced: Set[str] = set()
4404
+ # Build only: an approval undo resets to a parent without the ledger and would drop it.
4405
+ if (
4406
+ subject.strip().startswith("build:")
4407
+ and any(
4408
+ VERIFY_LEDGER_PATH == path or VERIFY_LEDGER_PATH.startswith(path + "/")
4409
+ for path in allowed
4410
+ )
4411
+ and ignored_legacy_ledger(repo)
4412
+ ):
4413
+ try:
4414
+ _common.verify_ledger_entries(repo / VERIFY_LEDGER_PATH)
4415
+ except ValueError as error:
4416
+ raise IsolationError(str(error)) from error
4417
+ forced.add(VERIFY_LEDGER_PATH)
4418
+ pending.add(VERIFY_LEDGER_PATH)
3126
4419
  if not pending:
3127
4420
  raise IsolationError("no changes to checkpoint")
3128
4421
  unexpected = sorted(
@@ -3176,7 +4469,7 @@ def checkpoint(
3176
4469
  + ", ".join(removed)
3177
4470
  )
3178
4471
  return {
3179
- "commit": _commit_pending(repo, pending, normalized_subject, body),
4472
+ "commit": _commit_pending(repo, pending, normalized_subject, body, forced),
3180
4473
  "paths": sorted(pending),
3181
4474
  "status": "committed",
3182
4475
  "subject": normalized_subject,
@@ -3303,6 +4596,16 @@ def retire(
3303
4596
  )
3304
4597
  if proof_error:
3305
4598
  raise IsolationError(proof_error)
4599
+ # A half-retire can leave the missing checkout registered, which blocks -D.
4600
+ if _registered_worktrees(primary).get(resolved_worktree) == f"refs/heads/{branch}":
4601
+ cleared = run_git(
4602
+ primary, "worktree", "remove", str(resolved_worktree)
4603
+ )
4604
+ if cleared.returncode != 0:
4605
+ raise IsolationError(
4606
+ (cleared.stderr or cleared.stdout).strip()
4607
+ or "could not clear stale worktree registration"
4608
+ )
3306
4609
  deleted = run_git(primary, "branch", "-D", branch)
3307
4610
  if deleted.returncode != 0:
3308
4611
  raise IsolationError(
@@ -3310,6 +4613,7 @@ def retire(
3310
4613
  or f"could not delete {branch}"
3311
4614
  )
3312
4615
  branch_retired = True
4616
+ release_workspace_quietly(primary)
3313
4617
  return {
3314
4618
  "bound_branch": bound,
3315
4619
  "branch": branch,
@@ -3343,22 +4647,13 @@ def retire(
3343
4647
  raise IsolationError(
3344
4648
  "worktree is dirty; pass --force only from the retry-retirement path"
3345
4649
  )
3346
- remove = run_git(
3347
- primary,
3348
- "worktree",
3349
- "remove",
3350
- *(("--force",) if force else ()),
3351
- str(resolved_worktree),
3352
- )
3353
- if remove.returncode != 0:
3354
- raise IsolationError(
3355
- (remove.stderr or remove.stdout).strip() or "git worktree remove failed"
3356
- )
4650
+ _remove_worktree(primary, resolved_worktree, force)
3357
4651
  deleted = run_git(primary, "branch", "-D", retire_branch)
3358
4652
  if deleted.returncode != 0:
3359
4653
  raise IsolationError(
3360
4654
  (deleted.stderr or deleted.stdout).strip() or f"could not delete {retire_branch}"
3361
4655
  )
4656
+ release_workspace_quietly(primary)
3362
4657
  return {
3363
4658
  "bound_branch": bound,
3364
4659
  "branch": retire_branch,
@@ -3368,6 +4663,54 @@ def retire(
3368
4663
  }
3369
4664
 
3370
4665
 
4666
+ def retire_unused_verify(
4667
+ primary: Path, worktree: Path, base: str, branch: str
4668
+ ) -> Dict[str, object]:
4669
+ """Retire a Verify sidecar only when it is provably untouched since creation."""
4670
+ primary = require_directory(primary, "primary worktree")
4671
+ if worktree_root(primary) != primary:
4672
+ raise IsolationError(f"primary is not its Git root: {primary}")
4673
+ if not branch.startswith(VERIFY_BRANCH_PREFIX):
4674
+ raise IsolationError("unused Verify retirement requires a verify branch")
4675
+ expected = _sidecar_path_for_branch(primary, branch)
4676
+ resolved_worktree = worktree.resolve()
4677
+ if resolved_worktree != expected:
4678
+ raise IsolationError("unused Verify path differs from its branch")
4679
+ expected_base = require_commit(primary, require_full_sha(base))
4680
+
4681
+ if os.path.lexists(worktree):
4682
+ if worktree.is_symlink() or not worktree.is_dir() or worktree.resolve() != worktree:
4683
+ raise IsolationError(f"unused Verify path is not an owned worktree: {worktree}")
4684
+ if worktree_root(worktree) != worktree:
4685
+ raise IsolationError(f"unused Verify path is not its Git root: {worktree}")
4686
+ if common_git_dir(primary) != common_git_dir(worktree):
4687
+ raise IsolationError("unused Verify sidecar belongs to another repository")
4688
+ if require_attached(worktree) != branch:
4689
+ raise IsolationError("unused Verify sidecar branch changed")
4690
+ if current_sha(worktree) != expected_base:
4691
+ raise IsolationError("unused Verify sidecar HEAD changed; preserving it")
4692
+ dirty = git_output(
4693
+ worktree,
4694
+ "status",
4695
+ "--porcelain",
4696
+ "--untracked-files=all",
4697
+ "--ignored=matching",
4698
+ )
4699
+ if dirty:
4700
+ raise IsolationError("unused Verify sidecar has uncommitted files; preserving it")
4701
+ else:
4702
+ branch_head = run_git(
4703
+ primary, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"
4704
+ )
4705
+ if branch_head.returncode == 0 and branch_head.stdout.strip() != expected_base:
4706
+ raise IsolationError("unused Verify branch HEAD changed; preserving it")
4707
+ for registered_path, registered_branch in _registered_worktrees(primary).items():
4708
+ if registered_branch == f"refs/heads/{branch}" and registered_path != expected:
4709
+ raise IsolationError("unused Verify branch is checked out at another path")
4710
+
4711
+ return retire(primary, expected, branch, False)
4712
+
4713
+
3371
4714
  def parser() -> argparse.ArgumentParser:
3372
4715
  argument_parser = argparse.ArgumentParser(description=__doc__)
3373
4716
  subparsers = argument_parser.add_subparsers(dest="command", required=True)
@@ -3390,6 +4733,33 @@ def parser() -> argparse.ArgumentParser:
3390
4733
  activate_task_parser.add_argument("--task-file", required=True)
3391
4734
  activate_task_parser.add_argument("--task-branch")
3392
4735
 
4736
+ isolate_member_parser = subparsers.add_parser(
4737
+ "isolate-member-task", help="create a member task sidecar at the member bound tip"
4738
+ )
4739
+ isolate_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
4740
+ isolate_member_parser.add_argument("--member", required=True)
4741
+ isolate_member_parser.add_argument("--task-id", required=True)
4742
+
4743
+ activate_member_parser = subparsers.add_parser(
4744
+ "activate-member-task", help="record one member task dispatch in its sidecar"
4745
+ )
4746
+ activate_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
4747
+ activate_member_parser.add_argument("--member", required=True)
4748
+ activate_member_parser.add_argument("--task-id", required=True)
4749
+ activate_member_parser.add_argument("--agent", required=True)
4750
+ activate_member_parser.add_argument("--task-file", required=True)
4751
+ activate_member_parser.add_argument("--base", required=True)
4752
+
4753
+ retire_member_parser = subparsers.add_parser(
4754
+ "retire-member-task", help="remove a member task sidecar and branch before a retry"
4755
+ )
4756
+ retire_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
4757
+ retire_member_parser.add_argument("--member", required=True)
4758
+ retire_member_parser.add_argument("--task-id", required=True)
4759
+ retire_member_parser.add_argument("--force", action="store_true",
4760
+ help="discard a rejected attempt whose Log the coordinator task holds")
4761
+ retire_member_parser.add_argument("--task-file")
4762
+
3393
4763
  deactivate_task_parser = subparsers.add_parser(
3394
4764
  "deactivate-task", help="revoke one helper-owned task dispatch"
3395
4765
  )
@@ -3490,6 +4860,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
3490
4860
  arguments.task_file,
3491
4861
  arguments.task_branch,
3492
4862
  )
4863
+ elif arguments.command == "isolate-member-task":
4864
+ result = isolate_member_task(arguments.repo, arguments.member, arguments.task_id)
4865
+ elif arguments.command == "activate-member-task":
4866
+ result = activate_member_task(arguments.repo, arguments.member, arguments.task_id,
4867
+ arguments.agent, arguments.task_file, arguments.base)
4868
+ elif arguments.command == "retire-member-task":
4869
+ copy_rejected = retire_member_task(arguments.repo, arguments.member, arguments.task_id,
4870
+ arguments.force, arguments.task_file)
4871
+ result = {"member": arguments.member, "retired": True, "task_id": arguments.task_id}
4872
+ if copy_rejected:
4873
+ result["copy_rejected"] = copy_rejected
3493
4874
  elif arguments.command == "deactivate-task":
3494
4875
  result = deactivate_task(
3495
4876
  arguments.repo,