@opengsd/gsd-path 1.3.1 → 1.4.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 (434) hide show
  1. package/AGENTS.md +2 -207
  2. package/CHANGELOG.md +281 -0
  3. package/DOCS.md +13 -7
  4. package/FULL.md +9 -3
  5. package/GUIDE.md +1 -1
  6. package/HOOKS.md +9 -6
  7. package/QUICK.md +4 -1
  8. package/README.md +63 -11
  9. package/RUNTIME.md +1 -1
  10. package/UPDATE.md +57 -8
  11. package/WORKFLOW.md +22 -6
  12. package/package.json +9 -8
  13. package/scripts/_common.py +472 -19
  14. package/scripts/archive_milestone.py +146 -73
  15. package/scripts/bootstrap_repository.py +8 -5
  16. package/scripts/build_recovery.py +4 -1
  17. package/scripts/build_state.py +66 -23
  18. package/scripts/check_docs_audit.py +14 -3
  19. package/scripts/check_handoffs.py +116 -20
  20. package/scripts/check_task_briefs.py +60 -8
  21. package/scripts/check_update.py +2 -2
  22. package/scripts/core_hook_gate.py +10 -1
  23. package/scripts/core_hook_settings.py +11 -4
  24. package/scripts/detect_project.py +345 -15
  25. package/scripts/discussion_records.py +19 -13
  26. package/scripts/discussion_validate.py +66 -21
  27. package/scripts/dispatch_driver.py +111 -26
  28. package/scripts/git_guard.py +116 -29
  29. package/scripts/guard_hook.py +151 -29
  30. package/scripts/install.mjs +54 -38
  31. package/scripts/install.py +562 -41
  32. package/scripts/integration.py +434 -24
  33. package/scripts/isolation.py +1077 -28
  34. package/scripts/lean_verification.py +99 -19
  35. package/scripts/loop_run.py +20 -30
  36. package/scripts/members.py +704 -0
  37. package/scripts/migrate_core.py +2 -2
  38. package/scripts/path_config.py +2 -2
  39. package/scripts/pipeline_diagnose.py +82 -31
  40. package/scripts/pipeline_git.py +61 -22
  41. package/scripts/pipeline_state.py +176 -54
  42. package/scripts/pipeline_undo.py +117 -22
  43. package/scripts/promote_lookahead.py +1 -1
  44. package/scripts/review_panel.py +1 -1
  45. package/scripts/runtime_store.py +35 -9
  46. package/scripts/skill-resources.json +28 -3
  47. package/scripts/state_checkpoint.py +140 -31
  48. package/scripts/state_promote.py +2 -0
  49. package/scripts/status_runtime.py +36 -6
  50. package/scripts/sync_skill_resources.py +8 -7
  51. package/scripts/token_budget.py +12 -8
  52. package/scripts/workflow_run.py +103 -29
  53. package/scripts/worktree_paths.py +18 -8
  54. package/skills/gsd-path/BUILD.md +7 -1
  55. package/skills/gsd-path/DECIDE.md +11 -4
  56. package/skills/gsd-path/DEFINE.md +8 -1
  57. package/skills/gsd-path/DOCS-AUDIT.md +6 -4
  58. package/skills/gsd-path/INSPECT.md +5 -4
  59. package/skills/gsd-path/PLAN.md +7 -1
  60. package/skills/gsd-path/RESEARCH.md +2 -1
  61. package/skills/gsd-path/ROADMAP.md +2 -1
  62. package/skills/gsd-path/SHIP.md +27 -10
  63. package/skills/gsd-path/SKILL.md +45 -1
  64. package/skills/gsd-path/references/build-native.md +47 -14
  65. package/skills/gsd-path/references/codebase-mapper.md +2 -1
  66. package/skills/gsd-path/references/docs-auditor.md +2 -1
  67. package/skills/gsd-path/references/operating-rules.md +214 -0
  68. package/skills/gsd-path/references/planner.md +8 -0
  69. package/skills/gsd-path/references/reviewer-wave.md +3 -1
  70. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  71. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  72. package/skills/gsd-path/scripts/_common.py +472 -19
  73. package/skills/gsd-path/scripts/archive_milestone.py +146 -73
  74. package/skills/gsd-path/scripts/bootstrap_repository.py +8 -5
  75. package/skills/gsd-path/scripts/build_recovery.py +4 -1
  76. package/skills/gsd-path/scripts/build_state.py +66 -23
  77. package/skills/gsd-path/scripts/check_docs_audit.py +14 -3
  78. package/skills/gsd-path/scripts/check_handoffs.py +116 -20
  79. package/skills/gsd-path/scripts/check_task_briefs.py +60 -8
  80. package/skills/gsd-path/scripts/check_update.py +2 -2
  81. package/skills/gsd-path/scripts/detect_project.py +345 -15
  82. package/skills/gsd-path/scripts/discussion_records.py +19 -13
  83. package/skills/gsd-path/scripts/discussion_validate.py +66 -21
  84. package/skills/gsd-path/scripts/dispatch_driver.py +111 -26
  85. package/skills/gsd-path/scripts/git_guard.py +116 -29
  86. package/skills/gsd-path/scripts/integration.py +434 -24
  87. package/skills/gsd-path/scripts/isolation.py +1077 -28
  88. package/skills/gsd-path/scripts/lean_verification.py +99 -19
  89. package/skills/gsd-path/scripts/loop_run.py +20 -30
  90. package/skills/gsd-path/scripts/members.py +704 -0
  91. package/skills/gsd-path/scripts/path_config.py +2 -2
  92. package/skills/gsd-path/scripts/pipeline_git.py +61 -22
  93. package/skills/gsd-path/scripts/pipeline_state.py +176 -54
  94. package/skills/gsd-path/scripts/promote_lookahead.py +1 -1
  95. package/skills/gsd-path/scripts/review_panel.py +1 -1
  96. package/skills/gsd-path/scripts/state_checkpoint.py +140 -31
  97. package/skills/gsd-path/scripts/state_promote.py +2 -0
  98. package/skills/gsd-path/scripts/status_runtime.py +36 -6
  99. package/skills/gsd-path/scripts/token_budget.py +12 -8
  100. package/skills/gsd-path/scripts/workflow_run.py +103 -29
  101. package/skills/gsd-path/scripts/worktree_paths.py +18 -8
  102. package/skills/gsd-path/templates/codebase.md +1 -1
  103. package/skills/gsd-path/templates/docs-audit.md +1 -1
  104. package/skills/gsd-path/templates/final-review.md +5 -2
  105. package/skills/gsd-path/templates/gap-review.md +2 -1
  106. package/skills/gsd-path/templates/task.md +1 -0
  107. package/skills/gsd-path/templates/wave-review.md +3 -1
  108. package/skills/gsd-path-build/SKILL.md +7 -1
  109. package/skills/gsd-path-build/references/build-native.md +47 -14
  110. package/skills/gsd-path-build/references/operating-rules.md +214 -0
  111. package/skills/gsd-path-build/references/reviewer-wave.md +3 -1
  112. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  113. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  114. package/skills/gsd-path-build/scripts/_common.py +472 -19
  115. package/skills/gsd-path-build/scripts/archive_milestone.py +146 -73
  116. package/skills/gsd-path-build/scripts/build_recovery.py +4 -1
  117. package/skills/gsd-path-build/scripts/build_state.py +66 -23
  118. package/skills/gsd-path-build/scripts/check_handoffs.py +116 -20
  119. package/skills/gsd-path-build/scripts/check_task_briefs.py +60 -8
  120. package/skills/gsd-path-build/scripts/detect_project.py +345 -15
  121. package/skills/gsd-path-build/scripts/discussion_records.py +19 -13
  122. package/skills/gsd-path-build/scripts/discussion_validate.py +66 -21
  123. package/skills/gsd-path-build/scripts/dispatch_driver.py +111 -26
  124. package/skills/gsd-path-build/scripts/integration.py +434 -24
  125. package/skills/gsd-path-build/scripts/isolation.py +1077 -28
  126. package/skills/gsd-path-build/scripts/loop_run.py +20 -30
  127. package/skills/gsd-path-build/scripts/members.py +704 -0
  128. package/skills/gsd-path-build/scripts/path_config.py +2 -2
  129. package/skills/gsd-path-build/scripts/pipeline_git.py +61 -22
  130. package/skills/gsd-path-build/scripts/pipeline_state.py +176 -54
  131. package/skills/gsd-path-build/scripts/promote_lookahead.py +1 -1
  132. package/skills/gsd-path-build/scripts/review_panel.py +1 -1
  133. package/skills/gsd-path-build/scripts/state_checkpoint.py +140 -31
  134. package/skills/gsd-path-build/scripts/state_promote.py +2 -0
  135. package/skills/gsd-path-build/scripts/token_budget.py +12 -8
  136. package/skills/gsd-path-build/scripts/workflow_run.py +103 -29
  137. package/skills/gsd-path-build/scripts/worktree_paths.py +18 -8
  138. package/skills/gsd-path-build/templates/task.md +1 -0
  139. package/skills/gsd-path-build/templates/wave-review.md +3 -1
  140. package/skills/gsd-path-decide/SKILL.md +11 -4
  141. package/skills/gsd-path-decide/references/operating-rules.md +214 -0
  142. package/skills/gsd-path-decide/scripts/_common.py +472 -19
  143. package/skills/gsd-path-decide/scripts/archive_milestone.py +146 -73
  144. package/skills/gsd-path-decide/scripts/build_recovery.py +4 -1
  145. package/skills/gsd-path-decide/scripts/check_handoffs.py +116 -20
  146. package/skills/gsd-path-decide/scripts/check_task_briefs.py +60 -8
  147. package/skills/gsd-path-decide/scripts/detect_project.py +345 -15
  148. package/skills/gsd-path-decide/scripts/discussion_records.py +19 -13
  149. package/skills/gsd-path-decide/scripts/discussion_validate.py +66 -21
  150. package/skills/gsd-path-decide/scripts/integration.py +434 -24
  151. package/skills/gsd-path-decide/scripts/isolation.py +1077 -28
  152. package/skills/gsd-path-decide/scripts/loop_run.py +20 -30
  153. package/skills/gsd-path-decide/scripts/members.py +704 -0
  154. package/skills/gsd-path-decide/scripts/path_config.py +2 -2
  155. package/skills/gsd-path-decide/scripts/pipeline_git.py +61 -22
  156. package/skills/gsd-path-decide/scripts/pipeline_state.py +176 -54
  157. package/skills/gsd-path-decide/scripts/promote_lookahead.py +1 -1
  158. package/skills/gsd-path-decide/scripts/review_panel.py +1 -1
  159. package/skills/gsd-path-decide/scripts/state_checkpoint.py +140 -31
  160. package/skills/gsd-path-decide/scripts/state_promote.py +2 -0
  161. package/skills/gsd-path-decide/scripts/worktree_paths.py +18 -8
  162. package/skills/gsd-path-define/SKILL.md +8 -1
  163. package/skills/gsd-path-define/references/operating-rules.md +214 -0
  164. package/skills/gsd-path-define/scripts/_common.py +472 -19
  165. package/skills/gsd-path-define/scripts/archive_milestone.py +146 -73
  166. package/skills/gsd-path-define/scripts/build_recovery.py +4 -1
  167. package/skills/gsd-path-define/scripts/check_handoffs.py +116 -20
  168. package/skills/gsd-path-define/scripts/check_task_briefs.py +60 -8
  169. package/skills/gsd-path-define/scripts/detect_project.py +345 -15
  170. package/skills/gsd-path-define/scripts/discussion_records.py +19 -13
  171. package/skills/gsd-path-define/scripts/discussion_validate.py +66 -21
  172. package/skills/gsd-path-define/scripts/integration.py +434 -24
  173. package/skills/gsd-path-define/scripts/isolation.py +1077 -28
  174. package/skills/gsd-path-define/scripts/loop_run.py +20 -30
  175. package/skills/gsd-path-define/scripts/members.py +704 -0
  176. package/skills/gsd-path-define/scripts/path_config.py +2 -2
  177. package/skills/gsd-path-define/scripts/pipeline_git.py +61 -22
  178. package/skills/gsd-path-define/scripts/pipeline_state.py +176 -54
  179. package/skills/gsd-path-define/scripts/promote_lookahead.py +1 -1
  180. package/skills/gsd-path-define/scripts/review_panel.py +1 -1
  181. package/skills/gsd-path-define/scripts/state_checkpoint.py +140 -31
  182. package/skills/gsd-path-define/scripts/state_promote.py +2 -0
  183. package/skills/gsd-path-define/scripts/worktree_paths.py +18 -8
  184. package/skills/gsd-path-discuss/SKILL.md +2 -1
  185. package/skills/gsd-path-discuss/references/operating-rules.md +214 -0
  186. package/skills/gsd-path-discuss/scripts/_common.py +472 -19
  187. package/skills/gsd-path-discuss/scripts/discussion_records.py +19 -13
  188. package/skills/gsd-path-discuss/scripts/loop_run.py +20 -30
  189. package/skills/gsd-path-discuss/scripts/path_config.py +2 -2
  190. package/skills/gsd-path-discuss/scripts/review_panel.py +1 -1
  191. package/skills/gsd-path-discuss/scripts/worktree_paths.py +18 -8
  192. package/skills/gsd-path-docs-audit/SKILL.md +6 -4
  193. package/skills/gsd-path-docs-audit/references/docs-auditor.md +2 -1
  194. package/skills/gsd-path-docs-audit/references/operating-rules.md +214 -0
  195. package/skills/gsd-path-docs-audit/scripts/_common.py +472 -19
  196. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +146 -73
  197. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +4 -1
  198. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +14 -3
  199. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +116 -20
  200. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +60 -8
  201. package/skills/gsd-path-docs-audit/scripts/detect_project.py +345 -15
  202. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +19 -13
  203. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +66 -21
  204. package/skills/gsd-path-docs-audit/scripts/integration.py +434 -24
  205. package/skills/gsd-path-docs-audit/scripts/isolation.py +1077 -28
  206. package/skills/gsd-path-docs-audit/scripts/loop_run.py +20 -30
  207. package/skills/gsd-path-docs-audit/scripts/members.py +704 -0
  208. package/skills/gsd-path-docs-audit/scripts/path_config.py +2 -2
  209. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +61 -22
  210. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +176 -54
  211. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +1 -1
  212. package/skills/gsd-path-docs-audit/scripts/review_panel.py +1 -1
  213. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +140 -31
  214. package/skills/gsd-path-docs-audit/scripts/state_promote.py +2 -0
  215. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +18 -8
  216. package/skills/gsd-path-docs-audit/templates/docs-audit.md +1 -1
  217. package/skills/gsd-path-forensics/SKILL.md +2 -1
  218. package/skills/gsd-path-forensics/references/operating-rules.md +214 -0
  219. package/skills/gsd-path-forensics/scripts/_common.py +472 -19
  220. package/skills/gsd-path-forensics/scripts/discussion_records.py +19 -13
  221. package/skills/gsd-path-forensics/scripts/loop_run.py +20 -30
  222. package/skills/gsd-path-forensics/scripts/path_config.py +2 -2
  223. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +82 -31
  224. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +117 -22
  225. package/skills/gsd-path-forensics/scripts/review_panel.py +1 -1
  226. package/skills/gsd-path-forensics/scripts/worktree_paths.py +18 -8
  227. package/skills/gsd-path-inspect/SKILL.md +5 -4
  228. package/skills/gsd-path-inspect/references/codebase-mapper.md +2 -1
  229. package/skills/gsd-path-inspect/references/docs-auditor.md +2 -1
  230. package/skills/gsd-path-inspect/references/operating-rules.md +214 -0
  231. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  232. package/skills/gsd-path-inspect/scripts/_common.py +472 -19
  233. package/skills/gsd-path-inspect/scripts/archive_milestone.py +146 -73
  234. package/skills/gsd-path-inspect/scripts/build_recovery.py +4 -1
  235. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +14 -3
  236. package/skills/gsd-path-inspect/scripts/check_handoffs.py +116 -20
  237. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +60 -8
  238. package/skills/gsd-path-inspect/scripts/detect_project.py +345 -15
  239. package/skills/gsd-path-inspect/scripts/discussion_records.py +19 -13
  240. package/skills/gsd-path-inspect/scripts/discussion_validate.py +66 -21
  241. package/skills/gsd-path-inspect/scripts/integration.py +434 -24
  242. package/skills/gsd-path-inspect/scripts/isolation.py +1077 -28
  243. package/skills/gsd-path-inspect/scripts/loop_run.py +20 -30
  244. package/skills/gsd-path-inspect/scripts/members.py +704 -0
  245. package/skills/gsd-path-inspect/scripts/path_config.py +2 -2
  246. package/skills/gsd-path-inspect/scripts/pipeline_git.py +61 -22
  247. package/skills/gsd-path-inspect/scripts/pipeline_state.py +176 -54
  248. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +1 -1
  249. package/skills/gsd-path-inspect/scripts/review_panel.py +1 -1
  250. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +140 -31
  251. package/skills/gsd-path-inspect/scripts/state_promote.py +2 -0
  252. package/skills/gsd-path-inspect/scripts/workflow_run.py +103 -29
  253. package/skills/gsd-path-inspect/scripts/worktree_paths.py +18 -8
  254. package/skills/gsd-path-inspect/templates/codebase.md +1 -1
  255. package/skills/gsd-path-inspect/templates/docs-audit.md +1 -1
  256. package/skills/gsd-path-loop/SKILL.md +2 -1
  257. package/skills/gsd-path-loop/references/operating-rules.md +214 -0
  258. package/skills/gsd-path-loop/scripts/_common.py +472 -19
  259. package/skills/gsd-path-loop/scripts/discussion_records.py +19 -13
  260. package/skills/gsd-path-loop/scripts/loop_run.py +20 -30
  261. package/skills/gsd-path-loop/scripts/path_config.py +2 -2
  262. package/skills/gsd-path-loop/scripts/review_panel.py +1 -1
  263. package/skills/gsd-path-loop/scripts/worktree_paths.py +18 -8
  264. package/skills/gsd-path-migrate/SKILL.md +2 -1
  265. package/skills/gsd-path-migrate/references/operating-rules.md +214 -0
  266. package/skills/gsd-path-migrate/scripts/_common.py +472 -19
  267. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +10 -1
  268. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +11 -4
  269. package/skills/gsd-path-migrate/scripts/discussion_records.py +19 -13
  270. package/skills/gsd-path-migrate/scripts/loop_run.py +20 -30
  271. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -2
  272. package/skills/gsd-path-migrate/scripts/path_config.py +2 -2
  273. package/skills/gsd-path-migrate/scripts/review_panel.py +1 -1
  274. package/skills/gsd-path-plan/SKILL.md +7 -1
  275. package/skills/gsd-path-plan/references/operating-rules.md +214 -0
  276. package/skills/gsd-path-plan/references/planner.md +8 -0
  277. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  278. package/skills/gsd-path-plan/scripts/_common.py +472 -19
  279. package/skills/gsd-path-plan/scripts/archive_milestone.py +146 -73
  280. package/skills/gsd-path-plan/scripts/build_recovery.py +4 -1
  281. package/skills/gsd-path-plan/scripts/check_handoffs.py +116 -20
  282. package/skills/gsd-path-plan/scripts/check_task_briefs.py +60 -8
  283. package/skills/gsd-path-plan/scripts/detect_project.py +345 -15
  284. package/skills/gsd-path-plan/scripts/discussion_records.py +19 -13
  285. package/skills/gsd-path-plan/scripts/discussion_validate.py +66 -21
  286. package/skills/gsd-path-plan/scripts/integration.py +434 -24
  287. package/skills/gsd-path-plan/scripts/isolation.py +1077 -28
  288. package/skills/gsd-path-plan/scripts/loop_run.py +20 -30
  289. package/skills/gsd-path-plan/scripts/members.py +704 -0
  290. package/skills/gsd-path-plan/scripts/path_config.py +2 -2
  291. package/skills/gsd-path-plan/scripts/pipeline_git.py +61 -22
  292. package/skills/gsd-path-plan/scripts/pipeline_state.py +176 -54
  293. package/skills/gsd-path-plan/scripts/promote_lookahead.py +1 -1
  294. package/skills/gsd-path-plan/scripts/review_panel.py +1 -1
  295. package/skills/gsd-path-plan/scripts/state_checkpoint.py +140 -31
  296. package/skills/gsd-path-plan/scripts/state_promote.py +2 -0
  297. package/skills/gsd-path-plan/scripts/workflow_run.py +103 -29
  298. package/skills/gsd-path-plan/scripts/worktree_paths.py +18 -8
  299. package/skills/gsd-path-plan/templates/task.md +1 -0
  300. package/skills/gsd-path-research/SKILL.md +2 -1
  301. package/skills/gsd-path-research/references/operating-rules.md +214 -0
  302. package/skills/gsd-path-research/scripts/_common.py +472 -19
  303. package/skills/gsd-path-research/scripts/archive_milestone.py +146 -73
  304. package/skills/gsd-path-research/scripts/build_recovery.py +4 -1
  305. package/skills/gsd-path-research/scripts/check_handoffs.py +116 -20
  306. package/skills/gsd-path-research/scripts/check_task_briefs.py +60 -8
  307. package/skills/gsd-path-research/scripts/detect_project.py +345 -15
  308. package/skills/gsd-path-research/scripts/discussion_records.py +19 -13
  309. package/skills/gsd-path-research/scripts/discussion_validate.py +66 -21
  310. package/skills/gsd-path-research/scripts/integration.py +434 -24
  311. package/skills/gsd-path-research/scripts/isolation.py +1077 -28
  312. package/skills/gsd-path-research/scripts/loop_run.py +20 -30
  313. package/skills/gsd-path-research/scripts/members.py +704 -0
  314. package/skills/gsd-path-research/scripts/path_config.py +2 -2
  315. package/skills/gsd-path-research/scripts/pipeline_git.py +61 -22
  316. package/skills/gsd-path-research/scripts/pipeline_state.py +176 -54
  317. package/skills/gsd-path-research/scripts/promote_lookahead.py +1 -1
  318. package/skills/gsd-path-research/scripts/review_panel.py +1 -1
  319. package/skills/gsd-path-research/scripts/state_checkpoint.py +140 -31
  320. package/skills/gsd-path-research/scripts/state_promote.py +2 -0
  321. package/skills/gsd-path-research/scripts/worktree_paths.py +18 -8
  322. package/skills/gsd-path-roadmap/SKILL.md +2 -1
  323. package/skills/gsd-path-roadmap/references/operating-rules.md +214 -0
  324. package/skills/gsd-path-roadmap/scripts/_common.py +472 -19
  325. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +146 -73
  326. package/skills/gsd-path-roadmap/scripts/build_recovery.py +4 -1
  327. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +116 -20
  328. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +60 -8
  329. package/skills/gsd-path-roadmap/scripts/detect_project.py +345 -15
  330. package/skills/gsd-path-roadmap/scripts/discussion_records.py +19 -13
  331. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +66 -21
  332. package/skills/gsd-path-roadmap/scripts/integration.py +434 -24
  333. package/skills/gsd-path-roadmap/scripts/isolation.py +1077 -28
  334. package/skills/gsd-path-roadmap/scripts/loop_run.py +20 -30
  335. package/skills/gsd-path-roadmap/scripts/members.py +704 -0
  336. package/skills/gsd-path-roadmap/scripts/path_config.py +2 -2
  337. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +61 -22
  338. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +176 -54
  339. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +1 -1
  340. package/skills/gsd-path-roadmap/scripts/review_panel.py +1 -1
  341. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +140 -31
  342. package/skills/gsd-path-roadmap/scripts/state_promote.py +2 -0
  343. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +18 -8
  344. package/skills/gsd-path-ship/SKILL.md +27 -10
  345. package/skills/gsd-path-ship/references/operating-rules.md +214 -0
  346. package/skills/gsd-path-ship/references/reviewer-wave.md +3 -1
  347. package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  348. package/skills/gsd-path-ship/scripts/_common.py +472 -19
  349. package/skills/gsd-path-ship/scripts/archive_milestone.py +146 -73
  350. package/skills/gsd-path-ship/scripts/build_recovery.py +4 -1
  351. package/skills/gsd-path-ship/scripts/build_state.py +66 -23
  352. package/skills/gsd-path-ship/scripts/check_handoffs.py +116 -20
  353. package/skills/gsd-path-ship/scripts/check_task_briefs.py +60 -8
  354. package/skills/gsd-path-ship/scripts/detect_project.py +345 -15
  355. package/skills/gsd-path-ship/scripts/discussion_records.py +19 -13
  356. package/skills/gsd-path-ship/scripts/discussion_validate.py +66 -21
  357. package/skills/gsd-path-ship/scripts/integration.py +434 -24
  358. package/skills/gsd-path-ship/scripts/isolation.py +1077 -28
  359. package/skills/gsd-path-ship/scripts/lean_verification.py +99 -19
  360. package/skills/gsd-path-ship/scripts/loop_run.py +20 -30
  361. package/skills/gsd-path-ship/scripts/members.py +704 -0
  362. package/skills/gsd-path-ship/scripts/path_config.py +2 -2
  363. package/skills/gsd-path-ship/scripts/pipeline_git.py +61 -22
  364. package/skills/gsd-path-ship/scripts/pipeline_state.py +176 -54
  365. package/skills/gsd-path-ship/scripts/promote_lookahead.py +1 -1
  366. package/skills/gsd-path-ship/scripts/review_panel.py +1 -1
  367. package/skills/gsd-path-ship/scripts/state_checkpoint.py +140 -31
  368. package/skills/gsd-path-ship/scripts/state_promote.py +2 -0
  369. package/skills/gsd-path-ship/scripts/workflow_run.py +103 -29
  370. package/skills/gsd-path-ship/scripts/worktree_paths.py +18 -8
  371. package/skills/gsd-path-ship/templates/final-review.md +5 -2
  372. package/skills/gsd-path-ship/templates/gap-review.md +2 -1
  373. package/skills/gsd-path-ship/templates/wave-review.md +3 -1
  374. package/skills/gsd-path-undo/SKILL.md +2 -1
  375. package/skills/gsd-path-undo/references/operating-rules.md +214 -0
  376. package/skills/gsd-path-undo/scripts/_common.py +472 -19
  377. package/skills/gsd-path-undo/scripts/discussion_records.py +19 -13
  378. package/skills/gsd-path-undo/scripts/loop_run.py +20 -30
  379. package/skills/gsd-path-undo/scripts/path_config.py +2 -2
  380. package/skills/gsd-path-undo/scripts/pipeline_undo.py +117 -22
  381. package/skills/gsd-path-undo/scripts/review_panel.py +1 -1
  382. package/skills/gsd-path-undo/scripts/worktree_paths.py +18 -8
  383. package/skills/path/BUILD.md +7 -1
  384. package/skills/path/DECIDE.md +11 -4
  385. package/skills/path/DEFINE.md +8 -1
  386. package/skills/path/DOCS-AUDIT.md +6 -4
  387. package/skills/path/INSPECT.md +5 -4
  388. package/skills/path/PLAN.md +7 -1
  389. package/skills/path/RESEARCH.md +2 -1
  390. package/skills/path/ROADMAP.md +2 -1
  391. package/skills/path/SHIP.md +27 -10
  392. package/skills/path/SKILL.md +45 -1
  393. package/skills/path/references/build-native.md +47 -14
  394. package/skills/path/references/codebase-mapper.md +2 -1
  395. package/skills/path/references/docs-auditor.md +2 -1
  396. package/skills/path/references/operating-rules.md +214 -0
  397. package/skills/path/references/planner.md +8 -0
  398. package/skills/path/references/reviewer-wave.md +3 -1
  399. package/skills/path/scripts/_common.py +472 -19
  400. package/skills/path/scripts/archive_milestone.py +146 -73
  401. package/skills/path/scripts/bootstrap_repository.py +8 -5
  402. package/skills/path/scripts/build_recovery.py +4 -1
  403. package/skills/path/scripts/build_state.py +66 -23
  404. package/skills/path/scripts/check_docs_audit.py +14 -3
  405. package/skills/path/scripts/check_handoffs.py +116 -20
  406. package/skills/path/scripts/check_task_briefs.py +60 -8
  407. package/skills/path/scripts/check_update.py +2 -2
  408. package/skills/path/scripts/detect_project.py +345 -15
  409. package/skills/path/scripts/discussion_records.py +19 -13
  410. package/skills/path/scripts/discussion_validate.py +66 -21
  411. package/skills/path/scripts/dispatch_driver.py +111 -26
  412. package/skills/path/scripts/git_guard.py +116 -29
  413. package/skills/path/scripts/integration.py +434 -24
  414. package/skills/path/scripts/isolation.py +1077 -28
  415. package/skills/path/scripts/lean_verification.py +99 -19
  416. package/skills/path/scripts/loop_run.py +20 -30
  417. package/skills/path/scripts/members.py +704 -0
  418. package/skills/path/scripts/path_config.py +2 -2
  419. package/skills/path/scripts/pipeline_git.py +61 -22
  420. package/skills/path/scripts/pipeline_state.py +176 -54
  421. package/skills/path/scripts/promote_lookahead.py +1 -1
  422. package/skills/path/scripts/review_panel.py +1 -1
  423. package/skills/path/scripts/state_checkpoint.py +140 -31
  424. package/skills/path/scripts/state_promote.py +2 -0
  425. package/skills/path/scripts/status_runtime.py +36 -6
  426. package/skills/path/scripts/token_budget.py +12 -8
  427. package/skills/path/scripts/workflow_run.py +103 -29
  428. package/skills/path/scripts/worktree_paths.py +18 -8
  429. package/skills/path/templates/codebase.md +1 -1
  430. package/skills/path/templates/docs-audit.md +1 -1
  431. package/skills/path/templates/final-review.md +5 -2
  432. package/skills/path/templates/gap-review.md +2 -1
  433. package/skills/path/templates/task.md +1 -0
  434. package/skills/path/templates/wave-review.md +3 -1
@@ -10,9 +10,11 @@ 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
16
18
  import os
17
19
  import re
18
20
  import stat
@@ -23,22 +25,15 @@ import sys
23
25
  sys.dont_write_bytecode = True
24
26
  import tempfile
25
27
  from pathlib import Path, PurePosixPath
26
- from typing import Dict, Optional, Sequence, Set
28
+ from typing import Dict, Iterator, Optional, Sequence, Set, Tuple
27
29
 
28
- try:
29
- from pipeline_git import (
30
- attest_commit_body,
31
- attest_commit_subject,
32
- is_bound_branch,
33
- ship_commit_body,
34
- ship_subject,
35
- task_commit_body,
36
- task_commit_subject,
37
- )
38
- import _common
39
- import worktree_paths
40
- except ImportError: # pragma: no cover - package import used by tests
41
- from scripts.pipeline_git import (
30
+ if sys.platform == "win32":
31
+ import msvcrt
32
+ else:
33
+ import fcntl
34
+
35
+ if __package__:
36
+ from .pipeline_git import (
42
37
  attest_commit_body,
43
38
  attest_commit_subject,
44
39
  is_bound_branch,
@@ -47,8 +42,33 @@ except ImportError: # pragma: no cover - package import used by tests
47
42
  task_commit_body,
48
43
  task_commit_subject,
49
44
  )
50
- from scripts import _common
51
- from scripts import worktree_paths
45
+ from . import _common
46
+ from . import worktree_paths
47
+ else:
48
+ try:
49
+ from pipeline_git import (
50
+ attest_commit_body,
51
+ attest_commit_subject,
52
+ is_bound_branch,
53
+ ship_commit_body,
54
+ ship_subject,
55
+ task_commit_body,
56
+ task_commit_subject,
57
+ )
58
+ import _common
59
+ import worktree_paths
60
+ except ImportError: # pragma: no cover - package import used by tests
61
+ from scripts.pipeline_git import (
62
+ attest_commit_body,
63
+ attest_commit_subject,
64
+ is_bound_branch,
65
+ ship_commit_body,
66
+ ship_subject,
67
+ task_commit_body,
68
+ task_commit_subject,
69
+ )
70
+ from scripts import _common
71
+ from scripts import worktree_paths
52
72
 
53
73
 
54
74
  TASK_BRANCH_PREFIX = "gsd-path-task/"
@@ -247,6 +267,9 @@ def verify_branch_name(name: str) -> str:
247
267
 
248
268
 
249
269
  def relative_posix(path: str) -> str:
270
+ if os.name == "nt":
271
+ # A native Windows relative path; backslash cannot occur in a name there.
272
+ path = path.replace("\\", "/")
250
273
  parsed = PurePosixPath(path)
251
274
  if parsed.is_absolute() or ".." in parsed.parts:
252
275
  raise IsolationError(f"path must be relative without '..': {path}")
@@ -480,8 +503,11 @@ def _cleanup_reserved_worktree(
480
503
  run_git(primary, "update-ref", "-d", ref, base)
481
504
 
482
505
 
483
- def create_named_worktree(primary: Path, branch: str, destination: Path, base: str) -> None:
484
- bound = require_bound(primary)
506
+ def create_named_worktree(
507
+ primary: Path, branch: str, destination: Path, base: str, bound: Optional[str] = None
508
+ ) -> None:
509
+ # A member names its bound branch; the member team's checkout is never on it.
510
+ bound = bound or require_bound(primary)
485
511
  if branch == bound:
486
512
  raise IsolationError(
487
513
  f"refusing to check out the bound branch {bound} in a second worktree"
@@ -576,6 +602,730 @@ def isolate_task(
576
602
  }
577
603
 
578
604
 
605
+ def _member_context(
606
+ coordinator: Path, member: str, lock_at: Optional[str] = None
607
+ ) -> Tuple[Path, str, Dict[str, str]]:
608
+ """The checkout, coordinator project, and lock entry of a locked, verified member."""
609
+ try:
610
+ import members
611
+ except ImportError: # pragma: no cover - package import used by tests
612
+ from scripts import members
613
+ coordinator = require_directory(coordinator, "coordinator")
614
+ if lock_at is None:
615
+ lock = coordinator / members.LOCK_PATH
616
+ entries = json.loads(lock.read_text(encoding="utf-8"))["members"] if lock.is_file() else []
617
+ else:
618
+ shown = run_git(coordinator, "show", f"{lock_at}:{members.LOCK_PATH}")
619
+ if shown.returncode != 0:
620
+ raise IsolationError(f"member lock is missing at {lock_at}: {members.LOCK_PATH}")
621
+ entries = json.loads(shown.stdout)["members"]
622
+ entry = next((item for item in entries if item["name"] == member), None)
623
+ if entry is None:
624
+ raise IsolationError(f"member {member} is not locked for this build because no task named it at build start")
625
+ checkout, project = member_checkout(coordinator, member)
626
+ return checkout, project, entry
627
+
628
+
629
+ def member_checkout(coordinator: Path, member: str) -> Tuple[Path, str]:
630
+ """A MEMBERS.md member's checkout and coordinator project, after checking its marker."""
631
+ try:
632
+ import members
633
+ except ImportError: # pragma: no cover - package import used by tests
634
+ from scripts import members
635
+ coordinator = require_directory(coordinator, "coordinator")
636
+ repair = f"run members.py repair --repo {coordinator}"
637
+ try:
638
+ listed = {item["name"]: item for item in members.read_members(coordinator)}
639
+ if member not in listed:
640
+ raise IsolationError(f"member {member} is not in MEMBERS.md; {repair}")
641
+ checkout = Path(listed[member]["checkout"]).resolve()
642
+ role = members.member_role(checkout)
643
+ except members.MembersError as error:
644
+ raise IsolationError(f"{error}; {repair}") from error
645
+ if role is None or role["coordinator"] != coordinator or role["name"] != member:
646
+ raise IsolationError(f"member marker for {member} is missing or stale; {repair}")
647
+ return checkout, str(role["project"])
648
+
649
+
650
+ def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
651
+ """Path's own checkout of a locked member's bound branch, outside the team's checkout."""
652
+ checkout, _, entry = _member_context(coordinator, member)
653
+ branch = entry["branch"]
654
+ destination = sidecar_root(checkout, "bound", branch.removeprefix("gsd-path/"), pin=True)
655
+ holders = [path for path, ref in _registered_worktrees(checkout).items() if ref == f"refs/heads/{branch}"]
656
+ if holders and holders != [destination.resolve()]:
657
+ raise IsolationError(f"{branch} is checked out outside Path's workspace: {holders[0]}")
658
+ if holders:
659
+ root = run_git(destination, "rev-parse", "--show-toplevel")
660
+ common = run_git(destination, "rev-parse", "--path-format=absolute", "--git-common-dir")
661
+ head = run_git(destination, "symbolic-ref", "-q", "HEAD")
662
+ if (root.returncode != 0 or Path(root.stdout.strip()).resolve() != destination.resolve()
663
+ or common.returncode != 0 or Path(common.stdout.strip()).resolve() != common_git_dir(checkout)
664
+ or head.returncode != 0 or head.stdout.strip() != f"refs/heads/{branch}"):
665
+ raise IsolationError(f"bound checkout is missing or invalid: {destination}")
666
+ if not holders:
667
+ if os.path.lexists(destination):
668
+ raise IsolationError(f"worktree path already exists: {destination}")
669
+ destination.parent.mkdir(parents=True, exist_ok=True)
670
+ added = run_git(checkout, "worktree", "add", str(destination), branch)
671
+ if added.returncode != 0:
672
+ raise IsolationError((added.stderr or added.stdout).strip() or "git worktree add failed")
673
+ return {"branch": branch, "checkout": str(destination.resolve()), "member": member}
674
+
675
+
676
+ def isolate_member_task(coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
677
+ """Member tasks always run in a sidecar at the member bound branch tip."""
678
+ validate_task_id(task_id)
679
+ bound = member_bound_checkout(coordinator, member)
680
+ bound_checkout = Path(bound["checkout"])
681
+ dirty = uncommitted_paths(bound_checkout)
682
+ if dirty:
683
+ raise IsolationError(
684
+ "member task isolation requires a clean member bound checkout: " + ", ".join(sorted(dirty))
685
+ )
686
+ checkout, project, _ = _member_context(coordinator, member)
687
+ member_base = current_sha(bound_checkout)
688
+ name = f"{project}-{task_id}"
689
+ branch = f"{TASK_BRANCH_PREFIX}{name}"
690
+ destination = sidecar_root(checkout, "task", name, pin=True)
691
+ create_named_worktree(checkout, branch, destination, member_base, bound=str(bound["branch"]))
692
+ return {
693
+ "bound_branch": bound["branch"],
694
+ "bound_checkout": bound["checkout"],
695
+ "kind": "task",
696
+ "member": member,
697
+ "member_base": member_base,
698
+ "mode": "member",
699
+ "task_branch": branch,
700
+ "worktree": str(destination),
701
+ }
702
+
703
+
704
+ def _rejected_member_attempt_error(
705
+ coordinator: Path, member: str, task_id: str, task_file: Optional[str], sidecar: Path
706
+ ) -> tuple[Optional[str], Optional[str]]:
707
+ """A forced retire discards a rejected attempt only after the parent kept its evidence.
708
+
709
+ Returns (refusal, copy_rejected); an invalid live copy holds no trusted Log delta,
710
+ so the parent's own rejection record stands in for it.
711
+ """
712
+ if not task_file:
713
+ return "forced member task retirement requires --task-file", None
714
+ task_file = relative_posix(task_file)
715
+ shown = run_git(coordinator, "show", f"HEAD:{task_file}")
716
+ fields, error = task_frontmatter(shown.stdout) if shown.returncode == 0 else (None, "task file is not committed")
717
+ if error or fields is None:
718
+ return error or "unreadable task file", None
719
+ if str(fields.get("id")) != task_id or fields.get("repo") != member:
720
+ return f"{task_file} does not own member task {task_id} in {member}", None
721
+ # A member task never leaves pending in the coordinator until its landing record.
722
+ if fields.get("status") != "pending":
723
+ return "forced member task retirement requires the coordinator task at pending", None
724
+ if os.path.lexists(_member_journal_path(coordinator, task_id)):
725
+ return "member task has a landing journal; recover it instead", None
726
+ if sidecar.is_dir():
727
+ copy = _safe_member_task_copy(sidecar, task_file)
728
+ if copy.is_file():
729
+ try:
730
+ text = copy.read_text(encoding="utf-8")
731
+ base = str((task_frontmatter(text)[0] or {}).get("base", ""))
732
+ contract = git_text(coordinator, "show", f"{require_full_sha(base)}:{task_file}")
733
+ delta = member_log_delta(contract, text).strip()
734
+ except (IsolationError, UnicodeDecodeError) as copy_error:
735
+ return None, str(copy_error)
736
+ if delta and delta not in _normalize_newlines(shown.stdout):
737
+ return ("copy the rejected attempt's Log delta into the coordinator task file "
738
+ "and checkpoint it before forced retirement"), None
739
+ return None, None
740
+
741
+
742
+ def retire_member_task(
743
+ coordinator: Path, member: str, task_id: str, force: bool = False, task_file: Optional[str] = None
744
+ ) -> Optional[str]:
745
+ """Remove an unused member task sidecar; landed work needs the landing proof.
746
+
747
+ `force` discards a rejected (dirty) attempt so a retry starts from a fresh sidecar,
748
+ and returns why its live task copy was rejected, if it was.
749
+ """
750
+ checkout, project, entry = _member_context(coordinator, member)
751
+ name = f"{project}-{validate_task_id(task_id)}"
752
+ ref = f"refs/heads/{TASK_BRANCH_PREFIX}{name}"
753
+ tip = run_git(checkout, "rev-parse", "--verify", "--quiet", ref)
754
+ # Landed commits arrive on the bound branch by cherry-pick, so compare patches.
755
+ cherry = run_git(checkout, "cherry", f"refs/heads/{entry['branch']}", ref) if tip.returncode == 0 else None
756
+ if cherry is not None and (cherry.returncode != 0 or any(
757
+ not line.startswith("- ") for line in cherry.stdout.splitlines())):
758
+ raise IsolationError(f"member task branch {ref} has unlanded commits")
759
+ destination = sidecar_root(checkout, "task", name)
760
+ copy_rejected = None
761
+ if force:
762
+ rejected, copy_rejected = _rejected_member_attempt_error(coordinator, member, task_id, task_file, destination)
763
+ if rejected:
764
+ raise IsolationError(rejected)
765
+ registered = _registered_worktrees(checkout)
766
+ if destination.resolve() in registered:
767
+ if registered[destination.resolve()] != ref:
768
+ raise IsolationError(f"member task sidecar is on another branch: {destination}")
769
+ removed = run_git(checkout, "worktree", "remove", *(("--force",) if force else ()), str(destination))
770
+ if removed.returncode != 0:
771
+ raise IsolationError((removed.stderr or removed.stdout).strip() or "git worktree remove failed")
772
+ authorization = run_git(checkout, "update-ref", "-d", member_task_authorization_ref(project, task_id))
773
+ if authorization.returncode != 0:
774
+ raise IsolationError((authorization.stderr or authorization.stdout).strip() or "git update-ref failed")
775
+ if tip.returncode == 0:
776
+ deleted = run_git(checkout, "update-ref", "-d", ref, tip.stdout.strip())
777
+ if deleted.returncode != 0:
778
+ raise IsolationError((deleted.stderr or deleted.stdout).strip() or "git update-ref failed")
779
+ return copy_rejected
780
+
781
+
782
+ MEMBER_LANDING_DIR = ("gsd-path", "member-landings")
783
+ # A running member task's live task file sits in its member sidecar, untracked
784
+ # and excluded, so the coordinator stays clean while member work runs.
785
+ MEMBER_TASK_COPY_DIR = ".gsd-path-coordinator"
786
+ MEMBER_IMMUTABLE_FIELDS = ("id", "title", "repo", "files", "deps", "wave")
787
+ MEMBER_ACTIVATION_FIELDS = {"status", "agent", "base", "member_base", "worktree", "task_branch"}
788
+
789
+
790
+ def _member_hook_configs(coordinator: Path) -> Dict[str, str]:
791
+ """Host configs for a member sidecar that call the coordinator guard by absolute path.
792
+
793
+ Only hosts whose managed config is installed in the coordinator get one; inside
794
+ a member sidecar the Git top level and project dir name the member, not the
795
+ coordinator, so the installed relative commands would miss the guard.
796
+ """
797
+ guard = coordinator / ".gsd-path" / "guard_hook.py"
798
+ command = f"{shlex.quote(sys.executable)} {shlex.quote(str(guard))}"
799
+ windows_interpreter = sys.executable.replace("'", "''")
800
+ windows_guard = str(guard).replace("'", "''")
801
+ command_windows = ("powershell.exe -NoProfile -NonInteractive -Command "
802
+ f'"& \'{windows_interpreter}\' \'{windows_guard}\'"')
803
+ formats = {
804
+ ".claude/settings.json": {"hooks": {"PreToolUse": [
805
+ {"matcher": ".*", "hooks": [{"type": "command", "command": command}]}]}},
806
+ ".codex/hooks.json": {"hooks": {"PreToolUse": [
807
+ {"matcher": ".*", "hooks": [{"type": "command", "command": command,
808
+ "commandWindows": command_windows}]}]}},
809
+ ".cursor/hooks.json": {"version": 1, "hooks": {"preToolUse": [
810
+ {"command": command, "matcher": ".*", "failClosed": True}]}},
811
+ }
812
+ configs = {}
813
+ for relative, content in formats.items():
814
+ installed = coordinator / relative
815
+ if installed.is_file() and not installed.is_symlink() and "guard_hook.py" in installed.read_text(encoding="utf-8"):
816
+ configs[relative] = json.dumps(content, indent=2) + "\n"
817
+ return configs
818
+
819
+
820
+ def _check_member_hook_collisions(configs: Dict[str, str], sidecar: Path) -> None:
821
+ for relative, content in configs.items():
822
+ # A member's own config (tracked or local) would run instead of the guard; never replace it.
823
+ target = sidecar / relative
824
+ if target.parent.is_symlink() or target.is_symlink() or (
825
+ target.exists() and target.read_bytes() != content.encode("utf-8")):
826
+ raise IsolationError(f"member sidecar already has its own {relative}; its host would run "
827
+ "without the coordinator guard")
828
+
829
+
830
+ def _write_member_hook_configs(coordinator: Path, checkout: Path, sidecar: Path) -> None:
831
+ configs = _member_hook_configs(coordinator)
832
+ _check_member_hook_collisions(configs, sidecar)
833
+ exclude = common_git_dir(checkout) / "info" / "exclude"
834
+ existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else ""
835
+ missing = [f"/{relative}" for relative in configs if f"/{relative}" not in existing.splitlines()]
836
+ if missing:
837
+ exclude.parent.mkdir(parents=True, exist_ok=True)
838
+ prefix = existing + ("" if not existing or existing.endswith("\n") else "\n")
839
+ _common.atomic_write(exclude, prefix + "\n".join(missing) + "\n")
840
+ for relative, content in configs.items():
841
+ target = sidecar / relative
842
+ target.parent.mkdir(parents=True, exist_ok=True)
843
+ _common.atomic_write(target, content)
844
+
845
+
846
+ def ensure_member_hooks(coordinator: Path, member: str, task_id: str) -> None:
847
+ checkout, project, _ = _member_context(coordinator, member)
848
+ sidecar = sidecar_root(checkout, "task", f"{project}-{validate_task_id(task_id)}")
849
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
850
+ if require_attached(sidecar) != branch:
851
+ raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
852
+ _write_member_hook_configs(coordinator, checkout, sidecar)
853
+
854
+
855
+ def member_task_copy(sidecar: Path, task_file: str) -> Path:
856
+ return sidecar / MEMBER_TASK_COPY_DIR / relative_posix(task_file)
857
+
858
+
859
+ def _safe_member_task_copy(sidecar: Path, task_file: str) -> Path:
860
+ copy = member_task_copy(sidecar, task_file)
861
+ if git_output(sidecar, "ls-files", "--", MEMBER_TASK_COPY_DIR):
862
+ raise IsolationError("member task copy directory is tracked")
863
+ parent = sidecar
864
+ for part in (MEMBER_TASK_COPY_DIR, *PurePosixPath(task_file).parts[:-1]):
865
+ parent = parent / part
866
+ if os.path.lexists(parent) and (parent.is_symlink() or not parent.is_dir()):
867
+ raise IsolationError(f"member task copy parent is not a real directory: {parent}")
868
+ try:
869
+ parent.resolve().relative_to(sidecar.resolve())
870
+ except ValueError as error:
871
+ raise IsolationError(f"member task copy parent escapes the sidecar: {parent}") from error
872
+ if os.path.lexists(copy) and (copy.is_symlink() or not copy.is_file()):
873
+ raise IsolationError(f"member task copy is not a regular file: {copy}")
874
+ return copy
875
+
876
+
877
+ def member_task_authorization_ref(project: str, task_id: str) -> str:
878
+ return f"{TASK_AUTHORIZATION_PREFIX}{project}-{validate_task_id(task_id)}"
879
+
880
+
881
+ def _activated_member_text(
882
+ contract: str, task_id: str, agent: str, base: str, sidecar: Path, branch: str, member_base: str
883
+ ) -> str:
884
+ activated = _activated_task_text(contract, task_id, agent, base, sidecar, branch)
885
+ head, body = split_frontmatter(activated)
886
+ head = [line for line in head if not line.startswith("member_base:")]
887
+ head.insert(next(i for i, line in enumerate(head) if line.startswith("base:")) + 1,
888
+ f"member_base: {member_base}")
889
+ return "---\n" + "\n".join(head) + "\n---\n" + body
890
+
891
+
892
+ def activate_member_task(
893
+ coordinator: Path, member: str, task_id: str, agent: str, task_file: str, base: str
894
+ ) -> Dict[str, object]:
895
+ """Write the live task copy into the member sidecar and authorize its member base."""
896
+ coordinator = require_directory(coordinator, "coordinator")
897
+ if not agent or agent == "null" or "\n" in agent:
898
+ raise IsolationError("task activation requires an assigned agent")
899
+ task_file = relative_posix(task_file)
900
+ if not task_file.startswith(".project/tasks/"):
901
+ raise IsolationError("task activation file must stay under .project/tasks/")
902
+ checkout, project, entry = _member_context(coordinator, member)
903
+ sidecar = sidecar_root(checkout, "task", f"{project}-{validate_task_id(task_id)}")
904
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
905
+ if require_attached(sidecar) != branch:
906
+ raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
907
+ member_base = current_sha(sidecar)
908
+ if run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").stdout.strip() != member_base \
909
+ or run_git(checkout, "merge-base", "--is-ancestor", member_base,
910
+ f"refs/heads/{entry['branch']}").returncode != 0:
911
+ raise IsolationError("member task activation requires the sidecar at its member base")
912
+ _check_member_hook_collisions(_member_hook_configs(coordinator), sidecar)
913
+ if uncommitted_paths(sidecar):
914
+ raise IsolationError("member task activation requires a clean member sidecar")
915
+ resolved_base = require_commit(coordinator, require_full_sha(base))
916
+ _member_contract(coordinator, resolved_base, task_file, member)
917
+ contract = git_text(coordinator, "show", f"{resolved_base}:{task_file}")
918
+ activated = _activated_member_text(contract, task_id, agent, resolved_base, sidecar, branch, member_base)
919
+ copy = _safe_member_task_copy(sidecar, task_file)
920
+ _write_member_hook_configs(coordinator, checkout, sidecar)
921
+ exclude = common_git_dir(checkout) / "info" / "exclude"
922
+ rule = f"/{MEMBER_TASK_COPY_DIR}/"
923
+ existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else ""
924
+ if rule not in existing.splitlines():
925
+ exclude.parent.mkdir(parents=True, exist_ok=True)
926
+ _common.atomic_write(exclude, existing + ("" if not existing or existing.endswith("\n") else "\n") + rule + "\n")
927
+ if copy.is_file() and copy.read_text(encoding="utf-8") != activated:
928
+ current, _ = task_frontmatter(copy.read_text(encoding="utf-8"))
929
+ if (not current or current.get("agent") != agent or current.get("base") != resolved_base
930
+ or current.get("member_base") != member_base):
931
+ raise IsolationError(f"member task is already active with another agent or base: {copy}")
932
+ elif not copy.is_file():
933
+ copy.parent.mkdir(parents=True, exist_ok=True)
934
+ _common.atomic_write(copy, activated)
935
+ ref = member_task_authorization_ref(project, task_id)
936
+ created = run_git(checkout, "update-ref", ref, member_base, "0" * 40)
937
+ if created.returncode != 0 and run_git(checkout, "rev-parse", "--verify", "--quiet", ref).stdout.strip() != member_base:
938
+ raise IsolationError("could not record member task authorization")
939
+ return {"agent": agent, "base": resolved_base, "copy": str(copy), "member": member,
940
+ "member_base": member_base, "status": "in-progress", "task_branch": branch,
941
+ "task_file": task_file, "worktree": str(sidecar)}
942
+
943
+
944
+ def retained_member_task(
945
+ coordinator: Path, member: str, task_id: str, task_file: str, base: str
946
+ ) -> Optional[Dict[str, object]]:
947
+ """Inspect an interrupted member isolate before another dispatch uses its name."""
948
+ checkout, project, _ = _member_context(coordinator, member)
949
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{validate_task_id(task_id)}"
950
+ sidecar = sidecar_root(checkout, "task", f"{project}-{task_id}")
951
+ tip = run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").stdout.strip()
952
+ authorization = run_git(checkout, "rev-parse", "--verify", "--quiet",
953
+ member_task_authorization_ref(project, task_id)).stdout.strip()
954
+ if not tip:
955
+ if os.path.lexists(sidecar) or authorization:
956
+ raise IsolationError(f"member task {task_id} has a sidecar or authorization without its branch")
957
+ return None
958
+ if _registered_worktrees(checkout).get(sidecar.resolve()) != f"refs/heads/{branch}":
959
+ raise IsolationError(f"member task branch has no matching sidecar: {branch}")
960
+ if require_attached(sidecar) != branch or current_sha(sidecar) != tip:
961
+ raise IsolationError(f"member task sidecar does not match its branch: {branch}")
962
+ bound = member_bound_checkout(coordinator, member)
963
+ bound_tip = current_sha(Path(str(bound["checkout"])))
964
+ if (run_git(checkout, "merge-base", "--is-ancestor", tip, bound_tip).returncode != 0
965
+ or uncommitted_paths(sidecar)):
966
+ raise IsolationError(f"member task branch is used or its member base moved: {branch}")
967
+ copy = _safe_member_task_copy(sidecar, task_file)
968
+ isolate = {"task_branch": branch, "member_base": tip, "worktree": str(sidecar)}
969
+ if not authorization:
970
+ if copy.is_file():
971
+ text = copy.read_text(encoding="utf-8")
972
+ fields, error = task_frontmatter(text)
973
+ if error or fields is None:
974
+ raise IsolationError(error or f"member task {task_id} has an unreadable activation")
975
+ copy_base = require_commit(coordinator, require_full_sha(str(fields.get("base", ""))))
976
+ _member_contract(coordinator, copy_base, task_file, member)
977
+ contract = git_text(coordinator, "show", f"{copy_base}:{task_file}")
978
+ expected = _activated_member_text(contract, task_id, f"build_{task_id.lower()}",
979
+ copy_base, sidecar, branch, tip)
980
+ if text != expected:
981
+ raise IsolationError(f"member task {task_id} has a changed activation")
982
+ return {"state": "unused", "isolate": isolate}
983
+ if not copy.is_file() or authorization != tip:
984
+ raise IsolationError(f"member task {task_id} has an incomplete activation")
985
+ if tip != bound_tip:
986
+ raise IsolationError(f"member task {task_id} has a used activation at an older member base")
987
+ journal = {"member": member, "task_id": task_id, "task_file": task_file,
988
+ "base": base, "member_base": tip, "copy": str(copy)}
989
+ text = _member_copy_text(journal, coordinator)
990
+ contract = git_text(coordinator, "show", f"{base}:{task_file}")
991
+ fields, _ = task_frontmatter(text)
992
+ if fields is None or fields.get("agent") != f"build_{task_id.lower()}" or member_log_delta(contract, text):
993
+ raise IsolationError(f"member task {task_id} has a used or changed activation")
994
+ return {"state": "active", "isolate": isolate,
995
+ "active": {"copy": str(copy), "worktree": str(sidecar)}}
996
+
997
+
998
+ def _member_copy_text(journal: Dict[str, object], coordinator: Path) -> str:
999
+ """The live copy, checked against the contract: same contract fields, Log only grows."""
1000
+ checkout, project, _ = _member_context(coordinator, str(journal["member"]))
1001
+ sidecar = sidecar_root(checkout, "task", f"{project}-{journal['task_id']}")
1002
+ copy = _safe_member_task_copy(sidecar, str(journal["task_file"]))
1003
+ if copy != Path(str(journal["copy"])) or not copy.is_file():
1004
+ raise IsolationError(f"member task is not activated: {copy}")
1005
+ text = copy.read_text(encoding="utf-8")
1006
+ contract = git_text(coordinator, "show", f"{journal['base']}:{journal['task_file']}")
1007
+ fields, error = task_frontmatter(text)
1008
+ expected, _ = task_frontmatter(contract)
1009
+ if error or fields is None or expected is None:
1010
+ raise IsolationError(error or "unreadable member task copy")
1011
+ member_log_delta(contract, text)
1012
+ if not isinstance(fields.get("agent"), str) or fields["agent"] in {"", "null"}:
1013
+ raise IsolationError("member task copy has no assigned agent")
1014
+ if (fields.get("status"), fields.get("base"), fields.get("member_base")) != (
1015
+ "in-progress", journal["base"], journal["member_base"]):
1016
+ raise IsolationError("member task copy is not the activated task for this landing")
1017
+ if (fields.get("worktree"), fields.get("task_branch")) != (
1018
+ str(sidecar), f"{TASK_BRANCH_PREFIX}{project}-{journal['task_id']}"):
1019
+ raise IsolationError("member task copy is not the activated task for this landing")
1020
+ return text
1021
+
1022
+
1023
+ def _coordinator_clean_for_record(coordinator: Path, recovery: Optional[Dict[str, object]] = None) -> None:
1024
+ staged = set(git_output(coordinator, "diff", "--cached", "--name-only").splitlines())
1025
+ dirty = (uncommitted_paths(coordinator) - BOOKKEEPING_PATHS) | staged
1026
+ if recovery is not None:
1027
+ task_file = str(recovery["task_file"])
1028
+ if task_file in dirty:
1029
+ stamped = _landed_task_text(_member_copy_text(recovery, coordinator), str(recovery["base"]))
1030
+ current, _ = _read_task_text(coordinator / task_file)
1031
+ if (current == stamped and (task_file not in staged
1032
+ or git_text(coordinator, "show", f":{task_file}") == stamped)):
1033
+ dirty.remove(task_file)
1034
+ dirty = sorted(dirty)
1035
+ if dirty:
1036
+ raise IsolationError("coordinator worktree is dirty: " + ", ".join(dirty))
1037
+
1038
+
1039
+ def member_commit_body(task_file: str, paths: Sequence[str], member_base: str, contract: str) -> str:
1040
+ """The member landing body: coordinator task, member base, contract revision, files."""
1041
+ lines = [f"Task: {task_file}", f"Base: {member_base}", f"Contract: {contract}", "Files:"]
1042
+ lines.extend(f"- {path}" for path in sorted(paths))
1043
+ return "\n".join(lines) + "\n"
1044
+
1045
+
1046
+ def _member_journal_path(coordinator: Path, task_id: str) -> Path:
1047
+ return common_git_dir(coordinator).joinpath(*MEMBER_LANDING_DIR, f"{validate_task_id(task_id)}.json")
1048
+
1049
+
1050
+ @contextlib.contextmanager
1051
+ def _member_landing_lock(coordinator: Path) -> Iterator[None]:
1052
+ path = common_git_dir(coordinator).joinpath(*MEMBER_LANDING_DIR, ".lock")
1053
+ path.parent.mkdir(parents=True, exist_ok=True)
1054
+ with path.open("a+b") as handle:
1055
+ if sys.platform == "win32":
1056
+ handle.seek(0, os.SEEK_END)
1057
+ if handle.tell() == 0:
1058
+ handle.write(b"\0")
1059
+ handle.flush()
1060
+ handle.seek(0)
1061
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
1062
+ else:
1063
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
1064
+ try:
1065
+ yield
1066
+ finally:
1067
+ if sys.platform == "win32":
1068
+ handle.seek(0)
1069
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
1070
+ else:
1071
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
1072
+
1073
+
1074
+ def _require_member_coordinator_branch(coordinator: Path) -> None:
1075
+ branch = require_bound(coordinator)
1076
+ if __package__:
1077
+ from .pipeline_state import load_state
1078
+ else:
1079
+ try:
1080
+ from pipeline_state import load_state
1081
+ except ImportError:
1082
+ from scripts.pipeline_state import load_state
1083
+ state, _, _ = load_state(coordinator)
1084
+ if branch != state.branch:
1085
+ raise IsolationError(f"coordinator branch {branch} differs from STATE bound branch {state.branch}")
1086
+
1087
+
1088
+ def _record_member_journal(path: Path, journal: Dict[str, object]) -> None:
1089
+ path.parent.mkdir(parents=True, exist_ok=True)
1090
+ _common.atomic_write(path, json.dumps(journal, indent=2, sort_keys=True) + "\n")
1091
+
1092
+
1093
+ def _member_contract(
1094
+ coordinator: Path, base: str, task_file: str, member: str,
1095
+ current_text: Optional[str] = None,
1096
+ ) -> tuple[str, Set[str]]:
1097
+ shown = run_git(coordinator, "show", f"{base}:{task_file}")
1098
+ if shown.returncode != 0:
1099
+ raise IsolationError(f"task contract is missing at {base}: {task_file}")
1100
+ fields, error = task_frontmatter(shown.stdout)
1101
+ if error or fields is None:
1102
+ raise IsolationError(error or f"unreadable task contract: {task_file}")
1103
+ current = current_text
1104
+ if current is None:
1105
+ current, _ = _read_task_text(coordinator / task_file)
1106
+ current_fields, current_error = task_frontmatter(current)
1107
+ if current_error or current_fields is None:
1108
+ raise IsolationError(current_error or f"unreadable current task: {task_file}")
1109
+ if any(current_fields.get(key) != fields.get(key) for key in MEMBER_IMMUTABLE_FIELDS):
1110
+ raise IsolationError(f"current task contract differs from {base}: {task_file}")
1111
+ if fields.get("repo") != member:
1112
+ raise IsolationError(f"{task_file} is not a task for member {member}")
1113
+ files = fields.get("files")
1114
+ if not isinstance(files, list) or not files:
1115
+ raise IsolationError(f"{task_file} declares no files")
1116
+ return str(fields.get("title", "")), {relative_posix(str(path)) for path in files}
1117
+
1118
+
1119
+ def _prove_member_commit(
1120
+ repo: Path, commit: str, parent: str, subject: str, body: str, allowed: Set[str]
1121
+ ) -> Optional[str]:
1122
+ found_parent, error = _single_parent(repo, commit)
1123
+ if error or found_parent != parent:
1124
+ return error or f"commit parent is not {parent}"
1125
+ changed = set(git_output(repo, "diff-tree", "--no-commit-id", "--name-only", "-r", "--no-renames",
1126
+ parent, commit).splitlines())
1127
+ if not changed:
1128
+ return "commit changes nothing"
1129
+ undeclared = sorted(changed - allowed)
1130
+ if undeclared:
1131
+ return "undeclared paths: " + ", ".join(undeclared)
1132
+ if git_output(repo, "log", "-1", "--format=%s", commit) != subject:
1133
+ return f"subject is not {subject!r}"
1134
+ if git_output(repo, "log", "-1", "--format=%b", commit) != body.strip():
1135
+ return "body is not the member landing block"
1136
+ return None
1137
+
1138
+
1139
+ def _commit_member_source(sidecar: Path, journal: Dict[str, object], allowed: Set[str]) -> str:
1140
+ member_base = str(journal["member_base"])
1141
+ pending = uncommitted_paths(sidecar)
1142
+ if pending:
1143
+ if current_sha(sidecar) != member_base:
1144
+ raise IsolationError("dirty member source HEAD must equal the member base")
1145
+ undeclared = sorted(pending - allowed)
1146
+ if undeclared:
1147
+ raise IsolationError("undeclared paths: " + ", ".join(undeclared))
1148
+ git_output(sidecar, "add", "-A", "--", *sorted(pending))
1149
+ body = member_commit_body(str(journal["task_file"]), sorted(pending), member_base, str(journal["base"]))
1150
+ git_output(sidecar, "commit", "-q", "-m", str(journal["subject"]), "-m", body.strip())
1151
+ source = current_sha(sidecar)
1152
+ if source == member_base:
1153
+ raise IsolationError("no changes to land")
1154
+ paths = git_output(sidecar, "diff-tree", "--no-commit-id", "--name-only", "-r", "--no-renames",
1155
+ member_base, source).splitlines()
1156
+ body = member_commit_body(str(journal["task_file"]), paths, member_base, str(journal["base"]))
1157
+ error = _prove_member_commit(sidecar, source, member_base, str(journal["subject"]), body, allowed)
1158
+ if error:
1159
+ raise IsolationError(f"member source commit proof failed: {error}")
1160
+ return source
1161
+
1162
+
1163
+ def _pick_member_landing(bound: Path, journal: Dict[str, object]) -> str:
1164
+ if current_sha(bound) != journal["expected_parent"] or uncommitted_paths(bound):
1165
+ raise IsolationError("member bound checkout moved or is dirty before landing")
1166
+ picked = run_git(bound, "cherry-pick", str(journal["source"]))
1167
+ if picked.returncode != 0:
1168
+ run_git(bound, "cherry-pick", "--abort")
1169
+ detail = (picked.stderr or picked.stdout).strip() or "cherry-pick conflict"
1170
+ raise IsolationError(f"conflict: {detail}")
1171
+ return current_sha(bound)
1172
+
1173
+
1174
+ def _prove_member_landing(bound: Path, journal: Dict[str, object], landing: str, allowed: Set[str]) -> Optional[str]:
1175
+ source = str(journal["source"])
1176
+ body = git_output(bound, "log", "-1", "--format=%b", source) + "\n"
1177
+ error = _prove_member_commit(bound, landing, str(journal["expected_parent"]),
1178
+ str(journal["subject"]), body, allowed)
1179
+ if error:
1180
+ return error
1181
+ if _tree_delta(bound, str(journal["expected_parent"]), landing) != _tree_delta(
1182
+ bound, str(journal["member_base"]), source):
1183
+ return "landing changes differ from the source commit"
1184
+ return None
1185
+
1186
+
1187
+ def _write_member_record(coordinator: Path, journal: Dict[str, object]) -> str:
1188
+ task_file = str(journal["task_file"])
1189
+ task_path = coordinator / task_file
1190
+ _, mode = _read_task_text(task_path)
1191
+ stamped = _landed_task_text(_member_copy_text(journal, coordinator), str(journal["base"]))
1192
+ _replace_regular_file(task_path, stamped.encode("utf-8"), mode)
1193
+ record = (f"Task: {task_file}\nBase: {journal['base']}\n"
1194
+ f"Member: {journal['member']} {journal['landing']} {journal['member_base']}")
1195
+ git_output(coordinator, "add", "--", task_file)
1196
+ git_output(coordinator, "commit", "-q", "-m", str(journal["subject"]), "-m", record)
1197
+ return current_sha(coordinator)
1198
+
1199
+
1200
+ def _existing_member_record(coordinator: Path, journal: Dict[str, object]) -> Optional[str]:
1201
+ task_file = str(journal["task_file"])
1202
+ body = (f"Task: {task_file}\nBase: {journal['base']}\n"
1203
+ f"Member: {journal['member']} {journal['landing']} {journal['member_base']}")
1204
+ for sha, subject, found_body in _first_parent_records(coordinator, "HEAD"):
1205
+ if subject != journal["subject"] or found_body != body:
1206
+ continue
1207
+ parent, error = _single_parent(coordinator, sha)
1208
+ if error or parent is None:
1209
+ continue
1210
+ changed = set(git_output(coordinator, "diff-tree", "--no-commit-id", "--name-only", "-r",
1211
+ "--no-renames", parent, sha).splitlines())
1212
+ if changed == {task_file}:
1213
+ return sha
1214
+ return None
1215
+
1216
+
1217
+ def _finish_member_landing(
1218
+ coordinator: Path, path: Path, journal: Dict[str, object], *, recovery: bool = False
1219
+ ) -> Dict[str, object]:
1220
+ _coordinator_clean_for_record(coordinator, journal if recovery else None)
1221
+ record = _write_member_record(coordinator, journal)
1222
+ path.unlink()
1223
+ return {"commit": record, "landing": journal["landing"], "member": journal["member"],
1224
+ "mode": "member", "source_commit": journal["source"], "state": "landed"}
1225
+
1226
+
1227
+ def land_member(
1228
+ coordinator: Path, member: str, task_id: str, title: str, task_file: str, base: str, member_base: str
1229
+ ) -> Dict[str, object]:
1230
+ """Land a member task: member product commit first, then the coordinator record.
1231
+
1232
+ A journal in the coordinator Git directory is written before the member
1233
+ branch moves, so recover_member_landing never lands the same work twice.
1234
+ A conflict keeps the source commit on its task sidecar for retry; the bound
1235
+ member branch and coordinator stay unchanged.
1236
+ """
1237
+ coordinator = require_directory(coordinator, "coordinator")
1238
+ _require_member_coordinator_branch(coordinator)
1239
+ task_file = relative_posix(task_file)
1240
+ with _member_landing_lock(coordinator):
1241
+ path = _member_journal_path(coordinator, task_id)
1242
+ if os.path.lexists(path):
1243
+ raise IsolationError(f"a member landing is pending for {task_id}; run recover_member_landing first")
1244
+ for pending in sorted(path.parent.glob("*.json")):
1245
+ if json.loads(pending.read_text(encoding="utf-8"))["member"] == member:
1246
+ raise IsolationError(
1247
+ f"member {member} has pending landing {pending.stem}; run recover_member_landing first"
1248
+ )
1249
+ checkout, project, _ = _member_context(coordinator, member)
1250
+ bound = Path(member_bound_checkout(coordinator, member)["checkout"])
1251
+ sidecar = sidecar_root(checkout, "task", f"{project}-{task_id}")
1252
+ if require_attached(sidecar) != f"{TASK_BRANCH_PREFIX}{project}-{task_id}":
1253
+ raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
1254
+ resolved_base = require_commit(coordinator, require_full_sha(base))
1255
+ contract_title, allowed = _member_contract(coordinator, resolved_base, task_file, member)
1256
+ if title != contract_title:
1257
+ raise IsolationError(f"title does not match the task contract: {contract_title!r}")
1258
+ _coordinator_clean_for_record(coordinator)
1259
+ member_base = require_full_sha(member_base)
1260
+ authorization = run_git(checkout, "rev-parse", "--verify", "--quiet",
1261
+ member_task_authorization_ref(project, task_id)).stdout.strip()
1262
+ if authorization != member_base:
1263
+ raise IsolationError(f"member task {task_id} is not authorized at its member base")
1264
+ journal: Dict[str, object] = {
1265
+ "base": resolved_base, "copy": str(member_task_copy(sidecar, task_file)), "member": member,
1266
+ "member_base": member_base, "subject": task_commit_subject(task_id, title),
1267
+ "task_file": task_file, "task_id": task_id,
1268
+ }
1269
+ _member_copy_text(journal, coordinator)
1270
+ journal["source"] = _commit_member_source(sidecar, journal, allowed)
1271
+ journal["expected_parent"] = current_sha(bound)
1272
+ journal["landing"] = None
1273
+ _record_member_journal(path, journal)
1274
+ try:
1275
+ landing = _pick_member_landing(bound, journal)
1276
+ except IsolationError:
1277
+ path.unlink()
1278
+ raise
1279
+ error = _prove_member_landing(bound, journal, landing, allowed)
1280
+ if error:
1281
+ raise IsolationError(f"member landing proof failed: {error}; the journal keeps it for recovery")
1282
+ journal["landing"] = landing
1283
+ _record_member_journal(path, journal)
1284
+ return _finish_member_landing(coordinator, path, journal)
1285
+
1286
+
1287
+ def recover_member_landing(coordinator: Path, task_id: str) -> Dict[str, object]:
1288
+ """Finish an interrupted member landing from its journal, never picking twice."""
1289
+ coordinator = require_directory(coordinator, "coordinator")
1290
+ path = _member_journal_path(coordinator, task_id)
1291
+ if not os.path.lexists(path):
1292
+ return {"state": "none"}
1293
+ _require_member_coordinator_branch(coordinator)
1294
+ with _member_landing_lock(coordinator):
1295
+ if not os.path.lexists(path):
1296
+ return {"state": "none"}
1297
+ journal = json.loads(path.read_text(encoding="utf-8"))
1298
+ member = str(journal["member"])
1299
+ bound = Path(member_bound_checkout(coordinator, member)["checkout"])
1300
+ _, allowed = _member_contract(coordinator, str(journal["base"]), str(journal["task_file"]), member)
1301
+ if journal.get("landing"):
1302
+ record = _existing_member_record(coordinator, journal)
1303
+ if record:
1304
+ path.unlink()
1305
+ return {"commit": record, "landing": journal["landing"], "member": member,
1306
+ "mode": "member", "source_commit": journal["source"], "state": "landed"}
1307
+ if current_sha(bound) != journal["landing"]:
1308
+ raise IsolationError("the member bound branch moved after the landing journal was written")
1309
+ _require_first_parent_commit(bound, current_sha(bound), str(journal["landing"]))
1310
+ error = _prove_member_landing(bound, journal, str(journal["landing"]), allowed)
1311
+ if error:
1312
+ raise IsolationError(f"member landing proof failed: {error}")
1313
+ if not journal.get("landing"):
1314
+ tip = current_sha(bound)
1315
+ if tip == journal["expected_parent"]:
1316
+ landing = _pick_member_landing(bound, journal)
1317
+ elif _prove_member_landing(bound, journal, tip, allowed) is None:
1318
+ landing = tip
1319
+ else:
1320
+ raise IsolationError("the member bound branch moved after the landing journal was written")
1321
+ error = _prove_member_landing(bound, journal, landing, allowed)
1322
+ if error:
1323
+ raise IsolationError(f"member landing proof failed: {error}")
1324
+ journal["landing"] = landing
1325
+ _record_member_journal(path, journal)
1326
+ return _finish_member_landing(coordinator, path, journal, recovery=True)
1327
+
1328
+
579
1329
  def isolate_verify(primary: Path, base: str, name: str, historical_task: Optional[str] = None) -> Dict[str, object]:
580
1330
  primary = require_directory(primary, "primary worktree")
581
1331
  if worktree_root(primary) != primary:
@@ -612,6 +1362,68 @@ def isolate_verify(primary: Path, base: str, name: str, historical_task: Optiona
612
1362
  }
613
1363
 
614
1364
 
1365
+ def check_member_verify_collision(entries: Sequence[Dict[str, str]], sidecar_name: str) -> None:
1366
+ if any(item["name"] == sidecar_name for item in entries):
1367
+ raise IsolationError(
1368
+ f"locked member {sidecar_name} collides with coordinator Verify sidecar folder {sidecar_name}"
1369
+ )
1370
+
1371
+
1372
+ def isolate_member_verify(coordinator: Path, sidecar: Path) -> Dict[str, Dict[str, str]]:
1373
+ """Check out each locked member's bound tip beside the coordinator verify sidecar."""
1374
+ try:
1375
+ import members
1376
+ except ImportError: # pragma: no cover - package import used by tests
1377
+ from scripts import members
1378
+ lock = coordinator / members.LOCK_PATH
1379
+ entries = json.loads(lock.read_text(encoding="utf-8"))["members"] if lock.is_file() else []
1380
+ check_member_verify_collision(entries, sidecar.name)
1381
+ created: Dict[str, Dict[str, str]] = {}
1382
+ try:
1383
+ for item in entries:
1384
+ checkout, project, entry = _member_context(coordinator, item["name"])
1385
+ tip = git_output(checkout, "rev-parse", "--verify", f"refs/heads/{entry['branch']}^{{commit}}")
1386
+ branch = f"{VERIFY_BRANCH_PREFIX}{project}-{entry['name']}"
1387
+ destination = sidecar.parent / entry["name"]
1388
+ create_named_worktree(checkout, branch, destination, tip, bound=entry["branch"])
1389
+ created[entry["name"]] = {"checkout": str(checkout), "worktree": str(destination),
1390
+ "branch": branch, "tip": tip}
1391
+ except BaseException:
1392
+ retire_member_verify(created)
1393
+ raise
1394
+ return created
1395
+
1396
+
1397
+ def _member_verify_sidecar(record: Dict[str, str]) -> Tuple[Path, Path, str]:
1398
+ checkout = Path(record["checkout"])
1399
+ destination = Path(record["worktree"])
1400
+ branch = record["branch"]
1401
+ registered = _registered_worktrees(checkout)
1402
+ if (destination.is_symlink() or not destination.is_dir()
1403
+ or destination.resolve() not in registered
1404
+ or worktree_root(destination) != destination.resolve()
1405
+ or common_git_dir(destination) != common_git_dir(checkout)):
1406
+ raise IsolationError(f"member Verify sidecar ownership changed: {destination}")
1407
+ return checkout, destination, branch
1408
+
1409
+
1410
+ def retire_member_verify(created: Dict[str, Dict[str, str]]) -> None:
1411
+ """Remove the member siblings of a coordinator verify sidecar; they hold no output."""
1412
+ for record in reversed(list(created.values())):
1413
+ checkout, destination, branch = _member_verify_sidecar(record)
1414
+ git_output(checkout, "worktree", "remove", "--force", str(destination))
1415
+ git_output(checkout, "branch", "-D", branch)
1416
+
1417
+
1418
+ def check_member_verify(created: Dict[str, Dict[str, str]]) -> None:
1419
+ for record in created.values():
1420
+ _, destination, _ = _member_verify_sidecar(record)
1421
+ if require_attached(destination) != record["branch"]:
1422
+ raise IsolationError(f"member Verify sidecar branch changed: {destination}")
1423
+ if current_sha(destination) != record["tip"]:
1424
+ raise IsolationError(f"member Verify sidecar HEAD changed: {destination}")
1425
+
1426
+
615
1427
  def clean_verify(primary: Path, worktree: Path, base: str, branch: str) -> Dict[str, object]:
616
1428
  """Discard command-created files only in the named verification sidecar."""
617
1429
  primary = require_directory(primary, "primary worktree")
@@ -636,13 +1448,48 @@ def _split_paths(block: str) -> Set[str]:
636
1448
  return {line.strip() for line in block.splitlines() if line.strip()}
637
1449
 
638
1450
 
639
- # Top-level .project entries the pipeline owns; ship verification rejects anything else.
1451
+ # Top-level .project entries the pipeline owns; ship checks filter other entries by Git visibility.
640
1452
  PROJECT_ENTRIES = frozenset({
641
- "STATE.md", "LESSONS.md", "REPOSITORY.md", "CHARTER.md", "ROADMAP.md", "SYNTHESIS.md",
1453
+ "STATE.md", "LESSONS.md", "REPOSITORY.md", "MEMBERS.md", "CHARTER.md", "ROADMAP.md",
1454
+ "SYNTHESIS.md",
642
1455
  "intent", "research", "plan", "tasks", "review", "build", "discuss", "archive", "next",
643
1456
  })
644
1457
 
645
1458
 
1459
+ def prune_host_scratch(project: Path, archive: Optional[Path] = None) -> list:
1460
+ """Remove file-free `.claude` dirs a host left under .project.
1461
+
1462
+ Claude Code's Bash sandbox creates `<cwd>/.claude/.cc-writes` as an atomic-write staging dir each
1463
+ time it initializes, and adds `**/.claude/.cc-writes/` to the global gitignore. Git cannot ship
1464
+ empty dirs, so removal loses nothing. A `.claude` that holds any file or symlink is kept, and the
1465
+ caller checks retained entries against Git visibility at the .project top level. Archives stay
1466
+ untouched except `archive`, the current uncommitted one.
1467
+ """
1468
+ roots = [path for path in project.iterdir() if path.name != "archive"]
1469
+ if archive is not None:
1470
+ roots.append(archive)
1471
+ hosts = []
1472
+ for root in roots:
1473
+ if root.is_symlink() or not root.is_dir():
1474
+ continue
1475
+ if root.name == ".claude":
1476
+ hosts.append(root)
1477
+ for directory, names, _ in os.walk(root):
1478
+ hosts.extend(Path(directory, name) for name in names if name == ".claude")
1479
+ removed = []
1480
+ for host in hosts:
1481
+ if host.is_symlink() or not host.is_dir():
1482
+ continue # a symlink, or already removed with an outer `.claude`
1483
+ tree = list(os.walk(host, topdown=False))
1484
+ if any(files or any(Path(directory, name).is_symlink() for name in names)
1485
+ for directory, names, files in tree):
1486
+ continue
1487
+ for directory, _, _ in tree:
1488
+ os.rmdir(directory)
1489
+ removed.append(host.relative_to(project).as_posix())
1490
+ return removed
1491
+
1492
+
646
1493
  def uncommitted_paths(repo: Path) -> Set[str]:
647
1494
  tracked = git_output(
648
1495
  repo, "diff", "--no-renames", "--name-only", "--relative", "HEAD"
@@ -664,12 +1511,30 @@ def committed_paths_since(repo: Path, base: str) -> Set[str]:
664
1511
  )
665
1512
 
666
1513
 
667
- def _commit_pending(repo: Path, pending: Set[str], subject: str, body: str) -> str:
1514
+ def ignored_legacy_ledger(repo: Path) -> bool:
1515
+ """True when a verify ledger absent from HEAD is hidden by an ignore rule.
1516
+
1517
+ Only milestones started before verify-record refused ignored ledgers reach
1518
+ this state. A build checkpoint commits the file so a task that anchors the
1519
+ rule does not expose it as a task change.
1520
+ """
1521
+ path = repo.joinpath(*PurePosixPath(VERIFY_LEDGER_PATH).parts)
1522
+ if path.is_symlink() or not path.is_file():
1523
+ return False
1524
+ if run_git(repo, "cat-file", "-e", f"HEAD:{VERIFY_LEDGER_PATH}").returncode == 0:
1525
+ return False
1526
+ return run_git(repo, "check-ignore", "-q", "--no-index", "--", VERIFY_LEDGER_PATH).returncode == 0
1527
+
1528
+
1529
+ def _commit_pending(
1530
+ repo: Path, pending: Set[str], subject: str, body: str, forced: Set[str] = frozenset()
1531
+ ) -> str:
668
1532
  reset = run_git(repo, "reset", "-q", "HEAD")
669
1533
  if reset.returncode != 0:
670
1534
  raise IsolationError("could not clear the index before commit")
671
1535
  for path in sorted(pending):
672
- added = run_git(repo, "add", "-A", "--", path)
1536
+ mode = "-f" if path in forced else "-A"
1537
+ added = run_git(repo, "add", mode, "--", path)
673
1538
  if added.returncode != 0:
674
1539
  raise IsolationError(f"could not stage {path}")
675
1540
  staged = _split_paths(
@@ -1147,6 +2012,10 @@ def land(
1147
2012
  raise IsolationError(f"source is not its Git root: {source}")
1148
2013
  if common_git_dir(primary) != common_git_dir(source):
1149
2014
  raise IsolationError("source worktree belongs to another repository")
2015
+ # The source holds the task's own .gitignore edits, so a task that anchors a rule can land.
2016
+ ignore_error = _common.project_ignore_error(source)
2017
+ if ignore_error:
2018
+ raise IsolationError(ignore_error)
1150
2019
  bound = require_bound(primary)
1151
2020
  source_branch = require_attached(source)
1152
2021
  resolved_base = require_commit(primary, require_full_sha(base))
@@ -1687,6 +2556,111 @@ def _resume_task_error(
1687
2556
  return _retained_task_contract_error(current_text, isolate_text)
1688
2557
 
1689
2558
 
2559
+ MEMBER_RECORD_RE = re.compile(
2560
+ r"Task: (?P<task>\S+)\nBase: (?P<base>[0-9a-f]{40})\n"
2561
+ r"Member: (?P<member>\S+) (?P<landing>[0-9a-f]{40}) (?P<member_base>[0-9a-f]{40})"
2562
+ )
2563
+
2564
+
2565
+ def _recover_unfinished_member_task(coordinator: Path, task_id: str, member: str) -> Dict[str, object]:
2566
+ """Unfinished member task: a pending journal retries landing; a retained sidecar resumes."""
2567
+ if os.path.lexists(_member_journal_path(coordinator, task_id)):
2568
+ return {"verdict": "resume", "landing_retry": True, "member": member, "worktree": None}
2569
+ try:
2570
+ checkout, project, _ = _member_context(coordinator, member)
2571
+ except IsolationError as error:
2572
+ return {"verdict": "block", "reason": str(error)}
2573
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
2574
+ retained = run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").returncode == 0
2575
+ if retained:
2576
+ return {"verdict": "resume", "member": member, "task_branch": None, "worktree": None,
2577
+ "member_task_branch": branch}
2578
+ return {"verdict": "none", "reason": "pending member task has no retained isolate"}
2579
+
2580
+
2581
+ def deactivate_member_task(coordinator: Path, member: str, task_id: str) -> None:
2582
+ """Clear a member task's authorization so a failed attempt cannot land."""
2583
+ checkout, project, _ = _member_context(coordinator, member)
2584
+ deleted = run_git(checkout, "update-ref", "-d", member_task_authorization_ref(project, task_id))
2585
+ if deleted.returncode != 0:
2586
+ raise IsolationError((deleted.stderr or deleted.stdout).strip() or "could not clear member task authorization")
2587
+
2588
+
2589
+ def member_log_delta(contract: str, copy: str) -> str:
2590
+ """The Log a coder appended to a member task's live copy since its contract."""
2591
+ expected, expected_error = task_frontmatter(contract)
2592
+ fields, error = task_frontmatter(copy)
2593
+ if expected_error or error or expected is None or fields is None:
2594
+ raise IsolationError(error or expected_error or "unreadable member task copy")
2595
+ if {key: value for key, value in fields.items() if key not in MEMBER_ACTIVATION_FIELDS} != {
2596
+ key: value for key, value in expected.items() if key not in MEMBER_ACTIVATION_FIELDS}:
2597
+ raise IsolationError("member task copy changes contract fields")
2598
+ base_body = _normalize_newlines(split_frontmatter(contract)[1])
2599
+ body = _normalize_newlines(split_frontmatter(copy)[1])
2600
+ if not body.startswith(base_body):
2601
+ raise IsolationError("member task copy Log is not append-only")
2602
+ return body[len(base_body):]
2603
+
2604
+
2605
+ def _prove_member_task(
2606
+ coordinator: Path, task_file: str, fields: Dict[str, object], subject: str,
2607
+ history: Dict[str, list[tuple[str, str]]],
2608
+ ) -> Dict[str, object]:
2609
+ """A done member task: one coordinator record naming a proven member landing."""
2610
+ if fields.get("status") != "done":
2611
+ return {"verdict": "block", "reason": "member task must have status: done"}
2612
+ member = str(fields.get("repo"))
2613
+ records = []
2614
+ for sha, body in history.get(subject, []):
2615
+ match = MEMBER_RECORD_RE.fullmatch(body.strip())
2616
+ if match and match["task"] == task_file and match["member"] == member:
2617
+ records.append((sha, match))
2618
+ if len(records) != 1:
2619
+ return {"verdict": "block", "reason": f"expected one coordinator record for {task_file}, found {len(records)}"}
2620
+ record, match = records[0]
2621
+ if git_output(coordinator, "diff-tree", "--no-commit-id", "--name-only", "-r", record).splitlines() != [task_file]:
2622
+ return {"verdict": "block", "reason": "the coordinator record touches more than the task file"}
2623
+ for field in ("base", "member_base"):
2624
+ if fields.get(field) != match[field]:
2625
+ return {"verdict": "block", "reason": f"task {field} does not match the coordinator record"}
2626
+ try:
2627
+ recorded = run_git(coordinator, "show", f"{record}:{task_file}")
2628
+ if recorded.returncode != 0:
2629
+ raise IsolationError(f"task is missing from coordinator record: {task_file}")
2630
+ recorded_fields, error = task_frontmatter(recorded.stdout)
2631
+ if error or recorded_fields is None:
2632
+ raise IsolationError(error or f"unreadable recorded task: {task_file}")
2633
+ if recorded_fields.get("status") != "done":
2634
+ raise IsolationError("recorded task status is not done")
2635
+ for field in ("base", "member_base"):
2636
+ if recorded_fields.get(field) != match[field]:
2637
+ raise IsolationError(f"recorded task {field} does not match the coordinator record")
2638
+ checkout, _, entry = _member_context(coordinator, member, lock_at=record)
2639
+ _, allowed = _member_contract(
2640
+ coordinator, match["base"], task_file, member, current_text=recorded.stdout
2641
+ )
2642
+ if any(fields.get(key) != recorded_fields.get(key) for key in MEMBER_IMMUTABLE_FIELDS):
2643
+ raise IsolationError(f"task artifact contract differs from {match['base']}: {task_file}")
2644
+ except IsolationError as error:
2645
+ return {"verdict": "block", "reason": str(error)}
2646
+ landing = match["landing"]
2647
+ history_on_bound = git_output(checkout, "rev-list", "--first-parent", f"refs/heads/{entry['branch']}").split()
2648
+ if landing not in history_on_bound:
2649
+ return {"verdict": "block", "reason": f"landing {landing[:12]} is not on the member bound branch"}
2650
+ parent, error = _single_parent(checkout, landing)
2651
+ if error or parent is None or run_git(
2652
+ checkout, "merge-base", "--is-ancestor", match["member_base"], parent).returncode != 0:
2653
+ return {"verdict": "block", "reason": error or "member landing does not descend from member_base"}
2654
+ paths = git_output(checkout, "diff-tree", "--no-commit-id", "--name-only", "-r", "--no-renames",
2655
+ parent, landing).splitlines()
2656
+ body = member_commit_body(task_file, paths, match["member_base"], match["base"])
2657
+ error = _prove_member_commit(checkout, landing, parent, subject, body, allowed)
2658
+ if error:
2659
+ return {"verdict": "block", "reason": f"member landing proof failed: {error}"}
2660
+ return {"verdict": "recovered", "commit": record, "base": match["base"],
2661
+ "landing": landing, "member": member}
2662
+
2663
+
1690
2664
  def _recover_task(
1691
2665
  primary: Path,
1692
2666
  path: Path,
@@ -1718,6 +2692,14 @@ def _recover_task(
1718
2692
  return result("block", reason=str(error))
1719
2693
  except IsolationError as exc:
1720
2694
  return result("block", reason=str(exc))
2695
+ if fields.get("repo"):
2696
+ # Only the landing record proves member task completion.
2697
+ if status in ("pending", "in-progress"):
2698
+ if any(MEMBER_RECORD_RE.fullmatch(body.strip()) and f"Task: {task_file}\n" in body
2699
+ for _, body in history.get(subject, [])):
2700
+ return result("block", reason="member task has a landing record but is not done")
2701
+ return result(**_recover_unfinished_member_task(primary, task_id, str(fields["repo"])))
2702
+ return result(**_prove_member_task(primary, task_file, fields, subject, history))
1721
2703
  task_branch = task_branch_name(task_id)
1722
2704
  branch_exists = task_branch in branches
1723
2705
  worktree, worktree_error = _worktree_report(
@@ -2907,10 +3889,13 @@ def publication_base(repo: Path, branch: str, ship_commit: str) -> Optional[str]
2907
3889
  Returns None when the milestone is not an adopted one; raises when the
2908
3890
  archived receipt, ship commit, or archived tasks fail proof.
2909
3891
  """
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
3892
+ if __package__:
3893
+ from .pipeline_state import load_state
3894
+ else:
3895
+ try:
3896
+ from pipeline_state import load_state
3897
+ except ImportError: # pragma: no cover - package import used by tests
3898
+ from scripts.pipeline_state import load_state
2914
3899
  repo = require_directory(repo, "repository")
2915
3900
  state, state_text, _ = load_state(repo)
2916
3901
  if (state.phase, state.status, state.integration, state.branch) != (
@@ -3123,6 +4108,22 @@ def checkpoint(
3123
4108
  }
3124
4109
  raise IsolationError("primary HEAD differs from --expected-head")
3125
4110
  pending = uncommitted_paths(repo)
4111
+ forced: Set[str] = set()
4112
+ # Build only: an approval undo resets to a parent without the ledger and would drop it.
4113
+ if (
4114
+ subject.strip().startswith("build:")
4115
+ and any(
4116
+ VERIFY_LEDGER_PATH == path or VERIFY_LEDGER_PATH.startswith(path + "/")
4117
+ for path in allowed
4118
+ )
4119
+ and ignored_legacy_ledger(repo)
4120
+ ):
4121
+ try:
4122
+ _common.verify_ledger_entries(repo / VERIFY_LEDGER_PATH)
4123
+ except ValueError as error:
4124
+ raise IsolationError(str(error)) from error
4125
+ forced.add(VERIFY_LEDGER_PATH)
4126
+ pending.add(VERIFY_LEDGER_PATH)
3126
4127
  if not pending:
3127
4128
  raise IsolationError("no changes to checkpoint")
3128
4129
  unexpected = sorted(
@@ -3176,7 +4177,7 @@ def checkpoint(
3176
4177
  + ", ".join(removed)
3177
4178
  )
3178
4179
  return {
3179
- "commit": _commit_pending(repo, pending, normalized_subject, body),
4180
+ "commit": _commit_pending(repo, pending, normalized_subject, body, forced),
3180
4181
  "paths": sorted(pending),
3181
4182
  "status": "committed",
3182
4183
  "subject": normalized_subject,
@@ -3303,6 +4304,16 @@ def retire(
3303
4304
  )
3304
4305
  if proof_error:
3305
4306
  raise IsolationError(proof_error)
4307
+ # A half-retire can leave the missing checkout registered, which blocks -D.
4308
+ if _registered_worktrees(primary).get(resolved_worktree) == f"refs/heads/{branch}":
4309
+ cleared = run_git(
4310
+ primary, "worktree", "remove", str(resolved_worktree)
4311
+ )
4312
+ if cleared.returncode != 0:
4313
+ raise IsolationError(
4314
+ (cleared.stderr or cleared.stdout).strip()
4315
+ or "could not clear stale worktree registration"
4316
+ )
3306
4317
  deleted = run_git(primary, "branch", "-D", branch)
3307
4318
  if deleted.returncode != 0:
3308
4319
  raise IsolationError(
@@ -3390,6 +4401,33 @@ def parser() -> argparse.ArgumentParser:
3390
4401
  activate_task_parser.add_argument("--task-file", required=True)
3391
4402
  activate_task_parser.add_argument("--task-branch")
3392
4403
 
4404
+ isolate_member_parser = subparsers.add_parser(
4405
+ "isolate-member-task", help="create a member task sidecar at the member bound tip"
4406
+ )
4407
+ isolate_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
4408
+ isolate_member_parser.add_argument("--member", required=True)
4409
+ isolate_member_parser.add_argument("--task-id", required=True)
4410
+
4411
+ activate_member_parser = subparsers.add_parser(
4412
+ "activate-member-task", help="record one member task dispatch in its sidecar"
4413
+ )
4414
+ activate_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
4415
+ activate_member_parser.add_argument("--member", required=True)
4416
+ activate_member_parser.add_argument("--task-id", required=True)
4417
+ activate_member_parser.add_argument("--agent", required=True)
4418
+ activate_member_parser.add_argument("--task-file", required=True)
4419
+ activate_member_parser.add_argument("--base", required=True)
4420
+
4421
+ retire_member_parser = subparsers.add_parser(
4422
+ "retire-member-task", help="remove a member task sidecar and branch before a retry"
4423
+ )
4424
+ retire_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
4425
+ retire_member_parser.add_argument("--member", required=True)
4426
+ retire_member_parser.add_argument("--task-id", required=True)
4427
+ retire_member_parser.add_argument("--force", action="store_true",
4428
+ help="discard a rejected attempt whose Log the coordinator task holds")
4429
+ retire_member_parser.add_argument("--task-file")
4430
+
3393
4431
  deactivate_task_parser = subparsers.add_parser(
3394
4432
  "deactivate-task", help="revoke one helper-owned task dispatch"
3395
4433
  )
@@ -3490,6 +4528,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
3490
4528
  arguments.task_file,
3491
4529
  arguments.task_branch,
3492
4530
  )
4531
+ elif arguments.command == "isolate-member-task":
4532
+ result = isolate_member_task(arguments.repo, arguments.member, arguments.task_id)
4533
+ elif arguments.command == "activate-member-task":
4534
+ result = activate_member_task(arguments.repo, arguments.member, arguments.task_id,
4535
+ arguments.agent, arguments.task_file, arguments.base)
4536
+ elif arguments.command == "retire-member-task":
4537
+ copy_rejected = retire_member_task(arguments.repo, arguments.member, arguments.task_id,
4538
+ arguments.force, arguments.task_file)
4539
+ result = {"member": arguments.member, "retired": True, "task_id": arguments.task_id}
4540
+ if copy_rejected:
4541
+ result["copy_rejected"] = copy_rejected
3493
4542
  elif arguments.command == "deactivate-task":
3494
4543
  result = deactivate_task(
3495
4544
  arguments.repo,