@opengsd/gsd-path 1.0.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 (398) hide show
  1. package/AGENTS.md +397 -0
  2. package/DOCS.md +341 -0
  3. package/FULL.md +475 -0
  4. package/GUIDE.md +23 -0
  5. package/HOOKS.md +273 -0
  6. package/LICENSE +21 -0
  7. package/MIGRATE.md +100 -0
  8. package/QUICK.md +146 -0
  9. package/README.md +280 -0
  10. package/RUNTIME.md +229 -0
  11. package/UPDATE.md +196 -0
  12. package/WORKFLOW.md +683 -0
  13. package/package.json +80 -0
  14. package/platforms/claude/dispatch.md +63 -0
  15. package/platforms/copilot/dispatch.md +61 -0
  16. package/platforms/cursor/agent.md +17 -0
  17. package/platforms/cursor/dispatch.md +63 -0
  18. package/platforms/grok/dispatch.md +61 -0
  19. package/platforms/kimi/dispatch.md +64 -0
  20. package/platforms/kiro/dispatch.md +58 -0
  21. package/platforms/opencode/dispatch.md +62 -0
  22. package/platforms/qwen/dispatch.md +60 -0
  23. package/platforms/shared-agents/dispatch.md +131 -0
  24. package/plugin.json +14 -0
  25. package/scripts/_common.py +180 -0
  26. package/scripts/archive_milestone.py +2265 -0
  27. package/scripts/bootstrap_repository.py +710 -0
  28. package/scripts/build_state.py +854 -0
  29. package/scripts/check_docs_audit.py +463 -0
  30. package/scripts/check_handoffs.py +1855 -0
  31. package/scripts/check_task_briefs.py +376 -0
  32. package/scripts/check_update.py +93 -0
  33. package/scripts/core_hook_gate.py +34 -0
  34. package/scripts/core_hook_settings.py +119 -0
  35. package/scripts/detect_project.py +1517 -0
  36. package/scripts/discussion_records.py +619 -0
  37. package/scripts/discussion_validate.py +1321 -0
  38. package/scripts/dispatch_driver.py +1812 -0
  39. package/scripts/git_guard.py +759 -0
  40. package/scripts/guard_hook.py +2124 -0
  41. package/scripts/install.mjs +3239 -0
  42. package/scripts/install.py +3008 -0
  43. package/scripts/integration.py +1433 -0
  44. package/scripts/isolation.py +3538 -0
  45. package/scripts/lean_verification.py +256 -0
  46. package/scripts/loop_run.py +837 -0
  47. package/scripts/migrate_core.py +169 -0
  48. package/scripts/pipeline_diagnose.py +715 -0
  49. package/scripts/pipeline_git.py +940 -0
  50. package/scripts/pipeline_state.py +2291 -0
  51. package/scripts/pipeline_undo.py +1039 -0
  52. package/scripts/promote_lookahead.py +415 -0
  53. package/scripts/review_findings.py +714 -0
  54. package/scripts/review_panel.py +553 -0
  55. package/scripts/skill-resources.json +340 -0
  56. package/scripts/state_checkpoint.py +1039 -0
  57. package/scripts/state_promote.py +743 -0
  58. package/scripts/status_runtime.py +122 -0
  59. package/scripts/sync_skill_resources.py +259 -0
  60. package/scripts/token_budget.py +146 -0
  61. package/scripts/wizard.mjs +179 -0
  62. package/scripts/workflow_run.py +117 -0
  63. package/skills/gsd-path/BUILD.md +700 -0
  64. package/skills/gsd-path/DECIDE.md +115 -0
  65. package/skills/gsd-path/DEFINE.md +297 -0
  66. package/skills/gsd-path/DOCS-AUDIT.md +190 -0
  67. package/skills/gsd-path/INSPECT.md +167 -0
  68. package/skills/gsd-path/PLAN.md +411 -0
  69. package/skills/gsd-path/RESEARCH.md +159 -0
  70. package/skills/gsd-path/ROADMAP.md +215 -0
  71. package/skills/gsd-path/SHIP.md +412 -0
  72. package/skills/gsd-path/SKILL.md +471 -0
  73. package/skills/gsd-path/agents/openai.yaml +6 -0
  74. package/skills/gsd-path/references/codebase-mapper.md +52 -0
  75. package/skills/gsd-path/references/coder.md +90 -0
  76. package/skills/gsd-path/references/decider.md +36 -0
  77. package/skills/gsd-path/references/dispatch.md +131 -0
  78. package/skills/gsd-path/references/docs-auditor.md +60 -0
  79. package/skills/gsd-path/references/planner.md +104 -0
  80. package/skills/gsd-path/references/researcher.md +42 -0
  81. package/skills/gsd-path/references/reviewer.md +188 -0
  82. package/skills/gsd-path/references/roadmapper.md +48 -0
  83. package/skills/gsd-path/scripts/_common.py +180 -0
  84. package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
  85. package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
  86. package/skills/gsd-path/scripts/build_state.py +854 -0
  87. package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
  88. package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
  89. package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
  90. package/skills/gsd-path/scripts/check_update.py +93 -0
  91. package/skills/gsd-path/scripts/detect_project.py +1517 -0
  92. package/skills/gsd-path/scripts/discussion_records.py +619 -0
  93. package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
  94. package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
  95. package/skills/gsd-path/scripts/integration.py +1433 -0
  96. package/skills/gsd-path/scripts/isolation.py +3538 -0
  97. package/skills/gsd-path/scripts/lean_verification.py +256 -0
  98. package/skills/gsd-path/scripts/loop_run.py +837 -0
  99. package/skills/gsd-path/scripts/pipeline_git.py +940 -0
  100. package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
  101. package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
  102. package/skills/gsd-path/scripts/review_findings.py +714 -0
  103. package/skills/gsd-path/scripts/review_panel.py +553 -0
  104. package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
  105. package/skills/gsd-path/scripts/state_promote.py +743 -0
  106. package/skills/gsd-path/scripts/token_budget.py +146 -0
  107. package/skills/gsd-path/scripts/workflow_run.py +117 -0
  108. package/skills/gsd-path/templates/answers.md +29 -0
  109. package/skills/gsd-path/templates/archive-manifest.md +37 -0
  110. package/skills/gsd-path/templates/charter.md +50 -0
  111. package/skills/gsd-path/templates/codebase.md +46 -0
  112. package/skills/gsd-path/templates/dialogue.md +21 -0
  113. package/skills/gsd-path/templates/docs-audit.md +69 -0
  114. package/skills/gsd-path/templates/evidence.md +24 -0
  115. package/skills/gsd-path/templates/final-review.md +27 -0
  116. package/skills/gsd-path/templates/gap-review.md +20 -0
  117. package/skills/gsd-path/templates/intent.md +98 -0
  118. package/skills/gsd-path/templates/loop.md +59 -0
  119. package/skills/gsd-path/templates/patch-findings.md +16 -0
  120. package/skills/gsd-path/templates/plan-panel.md +25 -0
  121. package/skills/gsd-path/templates/plan.md +89 -0
  122. package/skills/gsd-path/templates/repository.md +16 -0
  123. package/skills/gsd-path/templates/research-handoff.md +25 -0
  124. package/skills/gsd-path/templates/roadmap.md +38 -0
  125. package/skills/gsd-path/templates/skeptic.md +36 -0
  126. package/skills/gsd-path/templates/state.md +27 -0
  127. package/skills/gsd-path/templates/synthesis.md +36 -0
  128. package/skills/gsd-path/templates/task.md +74 -0
  129. package/skills/gsd-path/templates/wave-panel.md +25 -0
  130. package/skills/gsd-path/templates/wave-review.md +64 -0
  131. package/skills/gsd-path-build/SKILL.md +700 -0
  132. package/skills/gsd-path-build/agents/openai.yaml +6 -0
  133. package/skills/gsd-path-build/references/coder.md +90 -0
  134. package/skills/gsd-path-build/references/dispatch.md +131 -0
  135. package/skills/gsd-path-build/references/reviewer.md +188 -0
  136. package/skills/gsd-path-build/scripts/_common.py +180 -0
  137. package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
  138. package/skills/gsd-path-build/scripts/build_state.py +854 -0
  139. package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
  140. package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
  141. package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
  142. package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
  143. package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
  144. package/skills/gsd-path-build/scripts/integration.py +1433 -0
  145. package/skills/gsd-path-build/scripts/isolation.py +3538 -0
  146. package/skills/gsd-path-build/scripts/loop_run.py +837 -0
  147. package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
  148. package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
  149. package/skills/gsd-path-build/scripts/review_findings.py +714 -0
  150. package/skills/gsd-path-build/scripts/review_panel.py +553 -0
  151. package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
  152. package/skills/gsd-path-build/scripts/state_promote.py +743 -0
  153. package/skills/gsd-path-build/scripts/token_budget.py +146 -0
  154. package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
  155. package/skills/gsd-path-build/templates/skeptic.md +36 -0
  156. package/skills/gsd-path-build/templates/task.md +74 -0
  157. package/skills/gsd-path-build/templates/wave-panel.md +25 -0
  158. package/skills/gsd-path-build/templates/wave-review.md +64 -0
  159. package/skills/gsd-path-decide/SKILL.md +115 -0
  160. package/skills/gsd-path-decide/agents/openai.yaml +6 -0
  161. package/skills/gsd-path-decide/references/decider.md +36 -0
  162. package/skills/gsd-path-decide/references/dispatch.md +131 -0
  163. package/skills/gsd-path-decide/scripts/_common.py +180 -0
  164. package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
  165. package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
  166. package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
  167. package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
  168. package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
  169. package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
  170. package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
  171. package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
  172. package/skills/gsd-path-decide/templates/synthesis.md +36 -0
  173. package/skills/gsd-path-define/SKILL.md +297 -0
  174. package/skills/gsd-path-define/agents/openai.yaml +6 -0
  175. package/skills/gsd-path-define/scripts/_common.py +180 -0
  176. package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
  177. package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
  178. package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
  179. package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
  180. package/skills/gsd-path-define/scripts/isolation.py +3538 -0
  181. package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
  182. package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
  183. package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
  184. package/skills/gsd-path-define/scripts/state_promote.py +743 -0
  185. package/skills/gsd-path-define/templates/charter.md +50 -0
  186. package/skills/gsd-path-define/templates/intent.md +98 -0
  187. package/skills/gsd-path-define/templates/state.md +27 -0
  188. package/skills/gsd-path-discuss/SKILL.md +162 -0
  189. package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
  190. package/skills/gsd-path-discuss/scripts/_common.py +180 -0
  191. package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
  192. package/skills/gsd-path-discuss/templates/answers.md +29 -0
  193. package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
  194. package/skills/gsd-path-docs-audit/SKILL.md +190 -0
  195. package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
  196. package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
  197. package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
  198. package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
  199. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
  200. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
  201. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
  202. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
  203. package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
  204. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
  205. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
  206. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
  207. package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
  208. package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
  209. package/skills/gsd-path-forensics/SKILL.md +39 -0
  210. package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
  211. package/skills/gsd-path-forensics/scripts/_common.py +180 -0
  212. package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
  213. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
  214. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
  215. package/skills/gsd-path-inspect/SKILL.md +167 -0
  216. package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
  217. package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
  218. package/skills/gsd-path-inspect/references/dispatch.md +131 -0
  219. package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
  220. package/skills/gsd-path-inspect/scripts/_common.py +180 -0
  221. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
  222. package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
  223. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
  224. package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
  225. package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
  226. package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
  227. package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
  228. package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
  229. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
  230. package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
  231. package/skills/gsd-path-inspect/templates/codebase.md +46 -0
  232. package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
  233. package/skills/gsd-path-inspect/templates/state.md +27 -0
  234. package/skills/gsd-path-loop/SKILL.md +98 -0
  235. package/skills/gsd-path-loop/agents/openai.yaml +6 -0
  236. package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
  237. package/skills/gsd-path-loop/scripts/_common.py +180 -0
  238. package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
  239. package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
  240. package/skills/gsd-path-loop/templates/loop.md +59 -0
  241. package/skills/gsd-path-migrate/SKILL.md +134 -0
  242. package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
  243. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
  244. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
  245. package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
  246. package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
  247. package/skills/gsd-path-plan/SKILL.md +411 -0
  248. package/skills/gsd-path-plan/agents/openai.yaml +6 -0
  249. package/skills/gsd-path-plan/references/dispatch.md +131 -0
  250. package/skills/gsd-path-plan/references/planner.md +104 -0
  251. package/skills/gsd-path-plan/scripts/_common.py +180 -0
  252. package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
  253. package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
  254. package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
  255. package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
  256. package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
  257. package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
  258. package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
  259. package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
  260. package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
  261. package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
  262. package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
  263. package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
  264. package/skills/gsd-path-plan/templates/plan.md +89 -0
  265. package/skills/gsd-path-plan/templates/task.md +74 -0
  266. package/skills/gsd-path-research/SKILL.md +159 -0
  267. package/skills/gsd-path-research/agents/openai.yaml +6 -0
  268. package/skills/gsd-path-research/references/dispatch.md +131 -0
  269. package/skills/gsd-path-research/references/researcher.md +42 -0
  270. package/skills/gsd-path-research/scripts/_common.py +180 -0
  271. package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
  272. package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
  273. package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
  274. package/skills/gsd-path-research/scripts/isolation.py +3538 -0
  275. package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
  276. package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
  277. package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
  278. package/skills/gsd-path-research/scripts/state_promote.py +743 -0
  279. package/skills/gsd-path-research/templates/evidence.md +24 -0
  280. package/skills/gsd-path-research/templates/research-handoff.md +25 -0
  281. package/skills/gsd-path-roadmap/SKILL.md +215 -0
  282. package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
  283. package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
  284. package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
  285. package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
  286. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
  287. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
  288. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
  289. package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
  290. package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
  291. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
  292. package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
  293. package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
  294. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
  295. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
  296. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
  297. package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
  298. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
  299. package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
  300. package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
  301. package/skills/gsd-path-ship/SKILL.md +412 -0
  302. package/skills/gsd-path-ship/agents/openai.yaml +6 -0
  303. package/skills/gsd-path-ship/references/dispatch.md +131 -0
  304. package/skills/gsd-path-ship/references/reviewer.md +188 -0
  305. package/skills/gsd-path-ship/scripts/_common.py +180 -0
  306. package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
  307. package/skills/gsd-path-ship/scripts/build_state.py +854 -0
  308. package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
  309. package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
  310. package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
  311. package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
  312. package/skills/gsd-path-ship/scripts/integration.py +1433 -0
  313. package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
  314. package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
  315. package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
  316. package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
  317. package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
  318. package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
  319. package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
  320. package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
  321. package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
  322. package/skills/gsd-path-ship/templates/final-review.md +27 -0
  323. package/skills/gsd-path-ship/templates/gap-review.md +20 -0
  324. package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
  325. package/skills/gsd-path-ship/templates/wave-review.md +64 -0
  326. package/skills/gsd-path-undo/SKILL.md +45 -0
  327. package/skills/gsd-path-undo/agents/openai.yaml +6 -0
  328. package/skills/gsd-path-undo/scripts/_common.py +180 -0
  329. package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
  330. package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
  331. package/skills/path/BUILD.md +700 -0
  332. package/skills/path/DECIDE.md +115 -0
  333. package/skills/path/DEFINE.md +297 -0
  334. package/skills/path/DOCS-AUDIT.md +190 -0
  335. package/skills/path/INSPECT.md +167 -0
  336. package/skills/path/PLAN.md +411 -0
  337. package/skills/path/RESEARCH.md +159 -0
  338. package/skills/path/ROADMAP.md +215 -0
  339. package/skills/path/SHIP.md +412 -0
  340. package/skills/path/SKILL.md +471 -0
  341. package/skills/path/agents/openai.yaml +6 -0
  342. package/skills/path/references/codebase-mapper.md +52 -0
  343. package/skills/path/references/coder.md +90 -0
  344. package/skills/path/references/decider.md +36 -0
  345. package/skills/path/references/dispatch.md +131 -0
  346. package/skills/path/references/docs-auditor.md +60 -0
  347. package/skills/path/references/planner.md +104 -0
  348. package/skills/path/references/researcher.md +42 -0
  349. package/skills/path/references/reviewer.md +188 -0
  350. package/skills/path/references/roadmapper.md +48 -0
  351. package/skills/path/scripts/_common.py +180 -0
  352. package/skills/path/scripts/archive_milestone.py +2265 -0
  353. package/skills/path/scripts/bootstrap_repository.py +710 -0
  354. package/skills/path/scripts/build_state.py +854 -0
  355. package/skills/path/scripts/check_docs_audit.py +463 -0
  356. package/skills/path/scripts/check_handoffs.py +1855 -0
  357. package/skills/path/scripts/check_task_briefs.py +376 -0
  358. package/skills/path/scripts/check_update.py +93 -0
  359. package/skills/path/scripts/detect_project.py +1517 -0
  360. package/skills/path/scripts/discussion_records.py +619 -0
  361. package/skills/path/scripts/discussion_validate.py +1321 -0
  362. package/skills/path/scripts/dispatch_driver.py +1812 -0
  363. package/skills/path/scripts/integration.py +1433 -0
  364. package/skills/path/scripts/isolation.py +3538 -0
  365. package/skills/path/scripts/lean_verification.py +256 -0
  366. package/skills/path/scripts/loop_run.py +837 -0
  367. package/skills/path/scripts/pipeline_git.py +940 -0
  368. package/skills/path/scripts/pipeline_state.py +2291 -0
  369. package/skills/path/scripts/promote_lookahead.py +415 -0
  370. package/skills/path/scripts/review_findings.py +714 -0
  371. package/skills/path/scripts/review_panel.py +553 -0
  372. package/skills/path/scripts/state_checkpoint.py +1039 -0
  373. package/skills/path/scripts/state_promote.py +743 -0
  374. package/skills/path/scripts/token_budget.py +146 -0
  375. package/skills/path/scripts/workflow_run.py +117 -0
  376. package/skills/path/templates/answers.md +29 -0
  377. package/skills/path/templates/archive-manifest.md +37 -0
  378. package/skills/path/templates/charter.md +50 -0
  379. package/skills/path/templates/codebase.md +46 -0
  380. package/skills/path/templates/dialogue.md +21 -0
  381. package/skills/path/templates/docs-audit.md +69 -0
  382. package/skills/path/templates/evidence.md +24 -0
  383. package/skills/path/templates/final-review.md +27 -0
  384. package/skills/path/templates/gap-review.md +20 -0
  385. package/skills/path/templates/intent.md +98 -0
  386. package/skills/path/templates/loop.md +59 -0
  387. package/skills/path/templates/patch-findings.md +16 -0
  388. package/skills/path/templates/plan-panel.md +25 -0
  389. package/skills/path/templates/plan.md +89 -0
  390. package/skills/path/templates/repository.md +16 -0
  391. package/skills/path/templates/research-handoff.md +25 -0
  392. package/skills/path/templates/roadmap.md +38 -0
  393. package/skills/path/templates/skeptic.md +36 -0
  394. package/skills/path/templates/state.md +27 -0
  395. package/skills/path/templates/synthesis.md +36 -0
  396. package/skills/path/templates/task.md +74 -0
  397. package/skills/path/templates/wave-panel.md +25 -0
  398. package/skills/path/templates/wave-review.md +64 -0
@@ -0,0 +1,3538 @@
1
+ #!/usr/bin/env python3
2
+ # gsd-path project runtime
3
+ """Named checkouts for task isolation, verify sidecars, and task landing.
4
+
5
+ The orchestrator calls this helper instead of inventing git worktree, commit,
6
+ or cherry-pick commands. Every checkout this module creates is a named branch;
7
+ it never detaches HEAD.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import hashlib
14
+ import io
15
+ import json
16
+ import os
17
+ import re
18
+ import stat
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ from pathlib import Path, PurePosixPath
23
+ from typing import Dict, Optional, Sequence, Set
24
+
25
+ try:
26
+ from pipeline_git import (
27
+ attest_commit_body,
28
+ attest_commit_subject,
29
+ is_bound_branch,
30
+ ship_commit_body,
31
+ ship_subject,
32
+ task_commit_body,
33
+ task_commit_subject,
34
+ )
35
+ import _common
36
+ except ImportError: # pragma: no cover - package import used by tests
37
+ from scripts.pipeline_git import (
38
+ attest_commit_body,
39
+ attest_commit_subject,
40
+ is_bound_branch,
41
+ ship_commit_body,
42
+ ship_subject,
43
+ task_commit_body,
44
+ task_commit_subject,
45
+ )
46
+ from scripts import _common
47
+
48
+
49
+ TASK_BRANCH_PREFIX = "gsd-path-task/"
50
+ TASK_AUTHORIZATION_PREFIX = "refs/gsd-path/task-authorizations/"
51
+ VERIFY_BRANCH_PREFIX = "gsd-path-verify/"
52
+ DISCUSSION_PATHS = frozenset(
53
+ {".project/discuss/DIALOGUE.md", ".project/discuss/ANSWERS.md"}
54
+ )
55
+ VERIFY_LEDGER_PATH = _common.VERIFY_LEDGER_PATH
56
+ # recover verdicts that count as a landed task
57
+ LANDED_VERDICTS = frozenset({"recovered", "attested"})
58
+ # Orchestrator bookkeeping that may stay uncommitted in the primary while a
59
+ # parallel task lands; the next `.project` checkpoint commits it.
60
+ BOOKKEEPING_PATHS = DISCUSSION_PATHS | {VERIFY_LEDGER_PATH}
61
+ # Bookkeeping path prefixes: pipeline records plus the guard's own install
62
+ # artifacts. git_guard and the post-adoption proof share this one definition.
63
+ BOOKKEEPING_PREFIXES = (
64
+ ".project/",
65
+ ".gsd-path/",
66
+ ".claude/settings.json",
67
+ ".codex/hooks.json",
68
+ ".cursor/hooks.json",
69
+ )
70
+ TASK_ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
71
+ NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
72
+ FIELD_PATTERN = _common.FIELD_PATTERN
73
+ INLINE_LIST_PATTERN = _common.INLINE_LIST_PATTERN
74
+ LIST_ITEM_PATTERN = _common.LIST_ITEM_PATTERN
75
+ COLLECT_JOURNAL_SCHEMA = "gsd-path/collect-artifact/v1"
76
+ NULL_SHA = "0" * 40
77
+
78
+
79
+ class IsolationError(RuntimeError):
80
+ """Raised when isolation, landing, or retirement cannot proceed."""
81
+
82
+
83
+ run_git = _common.run_git
84
+
85
+
86
+ def git_output(repo: Path, *arguments: str) -> str:
87
+ result = run_git(repo, *arguments)
88
+ if result.returncode != 0:
89
+ detail = (result.stderr or result.stdout).strip() or "git command failed"
90
+ raise IsolationError(detail)
91
+ return result.stdout.strip()
92
+
93
+
94
+ def require_directory(path: Path, label: str) -> Path:
95
+ resolved = path.resolve()
96
+ if not resolved.is_dir():
97
+ raise IsolationError(f"{label} is not a directory: {resolved}")
98
+ return resolved
99
+
100
+
101
+ def require_attached(repo: Path) -> str:
102
+ branch = git_output(repo, "branch", "--show-current")
103
+ if not branch:
104
+ raise IsolationError(f"HEAD is detached in {repo}; expected a named branch")
105
+ return branch
106
+
107
+
108
+ def require_bound(repo: Path) -> str:
109
+ branch = require_attached(repo)
110
+ if not is_bound_branch(branch):
111
+ raise IsolationError(
112
+ f"primary branch must be canonical gsd-path/M00N, found {branch}"
113
+ )
114
+ return branch
115
+
116
+
117
+ def require_commit(repo: Path, value: str) -> str:
118
+ result = run_git(repo, "rev-parse", "--verify", "--quiet", f"{value}^{{commit}}")
119
+ if result.returncode != 0:
120
+ raise IsolationError(f"does not resolve to a commit: {value}")
121
+ return result.stdout.strip()
122
+
123
+
124
+ def require_full_sha(value: str) -> str:
125
+ if not re.fullmatch(r"[0-9a-f]{40}", value):
126
+ raise IsolationError(f"base must be a full 40-character SHA: {value}")
127
+ return value
128
+
129
+
130
+ def current_sha(repo: Path) -> str:
131
+ return git_output(repo, "rev-parse", "HEAD")
132
+
133
+
134
+ def worktree_root(repo: Path) -> Path:
135
+ return Path(git_output(repo, "rev-parse", "--show-toplevel")).resolve()
136
+
137
+
138
+ def common_git_dir(repo: Path) -> Path:
139
+ raw = Path(git_output(repo, "rev-parse", "--git-common-dir"))
140
+ if raw.is_absolute():
141
+ return raw.resolve()
142
+ return (repo / raw).resolve()
143
+
144
+
145
+ def sidecar_root(primary: Path, kind: str, name: str) -> Path:
146
+ return primary.parent / f"{primary.name}.gsd-path" / kind / name
147
+
148
+
149
+ def validate_task_id(task_id: str) -> str:
150
+ if not TASK_ID_RE.fullmatch(task_id):
151
+ raise IsolationError(f"invalid task id: {task_id}")
152
+ return task_id
153
+
154
+
155
+ def validate_name(name: str, label: str) -> str:
156
+ if not NAME_RE.fullmatch(name):
157
+ raise IsolationError(f"invalid {label}: {name}")
158
+ return name
159
+
160
+
161
+ def task_branch_name(task_id: str) -> str:
162
+ return f"{TASK_BRANCH_PREFIX}{validate_task_id(task_id)}"
163
+
164
+
165
+ def task_authorization_ref(task_id: str) -> str:
166
+ return f"{TASK_AUTHORIZATION_PREFIX}{validate_task_id(task_id)}"
167
+
168
+
169
+ def _delete_task_authorization(primary: Path, branch: str) -> None:
170
+ if branch.startswith(TASK_BRANCH_PREFIX):
171
+ task_id = validate_task_id(branch.removeprefix(TASK_BRANCH_PREFIX))
172
+ deleted = run_git(
173
+ primary, "update-ref", "-d", task_authorization_ref(task_id)
174
+ )
175
+ if deleted.returncode != 0:
176
+ detail = deleted.stderr.strip() or deleted.stdout.strip()
177
+ raise IsolationError(
178
+ detail or f"could not clear task authorization for {task_id}"
179
+ )
180
+
181
+
182
+ def authorized_task_worktree(worktree: Path, bound_branch: str) -> bool:
183
+ try:
184
+ worktree = require_directory(worktree, "task worktree")
185
+ branch = require_attached(worktree)
186
+ if not branch.startswith(TASK_BRANCH_PREFIX):
187
+ return False
188
+ task_id = validate_task_id(branch.removeprefix(TASK_BRANCH_PREFIX))
189
+ records = _registered_worktrees(worktree)
190
+ primary_matches = [
191
+ path
192
+ for path, record_branch in records.items()
193
+ if record_branch == f"refs/heads/{bound_branch}"
194
+ ]
195
+ if len(primary_matches) != 1:
196
+ return False
197
+ primary = primary_matches[0]
198
+ if (
199
+ worktree != sidecar_root(primary, "task", task_id).resolve()
200
+ or common_git_dir(worktree) != common_git_dir(primary)
201
+ ):
202
+ return False
203
+ head = current_sha(worktree)
204
+ authorization = run_git(
205
+ worktree, "rev-parse", "--verify", task_authorization_ref(task_id)
206
+ )
207
+ if authorization.returncode != 0 or authorization.stdout.strip() != head:
208
+ return False
209
+ matches = []
210
+ tasks_dir = worktree / ".project" / "tasks"
211
+ if tasks_dir.is_symlink() or tasks_dir.resolve() != tasks_dir:
212
+ return False
213
+ for task_path in tasks_dir.glob("*.md") if tasks_dir.is_dir() else ():
214
+ if task_path.is_symlink() or not task_path.is_file():
215
+ return False
216
+ fields, error = task_frontmatter(task_path.read_text(encoding="utf-8"))
217
+ if error is None and fields is not None and fields.get("id") == task_id:
218
+ matches.append(fields)
219
+ if len(matches) != 1:
220
+ return False
221
+ fields = matches[0]
222
+ agent = fields.get("agent")
223
+ return (
224
+ fields.get("status") == "in-progress"
225
+ and fields.get("base") == head
226
+ and fields.get("task_branch") == branch
227
+ and Path(str(fields.get("worktree", ""))).resolve() == worktree
228
+ and isinstance(agent, str)
229
+ and agent not in {"", "null"}
230
+ )
231
+ except (IsolationError, OSError, UnicodeError):
232
+ return False
233
+
234
+
235
+ def verify_branch_name(name: str) -> str:
236
+ return f"{VERIFY_BRANCH_PREFIX}{validate_name(name, 'verify name')}"
237
+
238
+
239
+ def relative_posix(path: str) -> str:
240
+ parsed = PurePosixPath(path)
241
+ if parsed.is_absolute() or ".." in parsed.parts:
242
+ raise IsolationError(f"path must be relative without '..': {path}")
243
+ return parsed.as_posix()
244
+
245
+
246
+ def _real_file(root: Path, relative: str, label: str) -> Path:
247
+ normalized = relative_posix(relative)
248
+ if normalized in {"", "."}:
249
+ raise IsolationError(f"{label} must name a file")
250
+ path = root / normalized
251
+ if not path.is_file() or path.is_symlink():
252
+ raise IsolationError(f"{label} is not a real file: {normalized}")
253
+ try:
254
+ path.resolve().relative_to(root)
255
+ except ValueError as error:
256
+ raise IsolationError(f"{label} escapes its worktree: {normalized}") from error
257
+ return path
258
+
259
+
260
+ def _safe_destination(root: Path, relative: str) -> Path:
261
+ normalized = relative_posix(relative)
262
+ if normalized in {"", "."} or not normalized.startswith(".project/"):
263
+ raise IsolationError("artifact destination must be a file under .project/")
264
+ current = root
265
+ parts = PurePosixPath(normalized).parts
266
+ for part in parts[:-1]:
267
+ current = current / part
268
+ if current.is_symlink():
269
+ raise IsolationError(f"artifact destination has a symlink parent: {normalized}")
270
+ destination = root / normalized
271
+ if destination.is_symlink() or (destination.exists() and not destination.is_file()):
272
+ raise IsolationError(f"artifact destination is not a real file: {normalized}")
273
+ return destination
274
+
275
+
276
+ def _sha256(data: bytes) -> str:
277
+ return hashlib.sha256(data).hexdigest()
278
+
279
+
280
+ def _collect_journal_path(primary: Path, request: Dict[str, object]) -> Path:
281
+ key = hashlib.sha256(
282
+ json.dumps(request, sort_keys=True, separators=(",", ":")).encode()
283
+ ).hexdigest()
284
+ return common_git_dir(primary) / "gsd-path" / "collect-artifact" / f"{key}.json"
285
+
286
+
287
+ def _write_collect_journal(path: Path, payload: Dict[str, object]) -> None:
288
+ path.parent.mkdir(parents=True, exist_ok=True)
289
+ temporary_name: Optional[str] = None
290
+ try:
291
+ with tempfile.NamedTemporaryFile(
292
+ mode="w",
293
+ encoding="utf-8",
294
+ dir=path.parent,
295
+ prefix=".collect-artifact-",
296
+ delete=False,
297
+ ) as handle:
298
+ json.dump(payload, handle, sort_keys=True)
299
+ handle.write("\n")
300
+ handle.flush()
301
+ os.fsync(handle.fileno())
302
+ temporary_name = handle.name
303
+ os.replace(temporary_name, path)
304
+ temporary_name = None
305
+ finally:
306
+ if temporary_name:
307
+ Path(temporary_name).unlink(missing_ok=True)
308
+
309
+
310
+ def _read_collect_journal(path: Path) -> Dict[str, object]:
311
+ if path.is_symlink() or not path.is_file():
312
+ raise IsolationError(f"artifact collection journal is not a real file: {path}")
313
+ try:
314
+ payload = json.loads(path.read_text(encoding="utf-8"))
315
+ except (OSError, json.JSONDecodeError) as error:
316
+ raise IsolationError(f"artifact collection journal is unreadable: {path}") from error
317
+ if not isinstance(payload, dict):
318
+ raise IsolationError("artifact collection journal must contain an object")
319
+ return payload
320
+
321
+
322
+ def _validated_collect_journal(
323
+ primary: Path,
324
+ path: Path,
325
+ *,
326
+ allow_complete_from_other_worktree: bool = False,
327
+ ) -> Dict[str, object]:
328
+ journal = _read_collect_journal(path)
329
+ request_fields = {
330
+ "schema",
331
+ "primary_worktree",
332
+ "source_worktree",
333
+ "base",
334
+ "branch",
335
+ "source",
336
+ "destination",
337
+ "expected_destination",
338
+ }
339
+ expected_fields = request_fields | {
340
+ "bytes", "previous_sha256", "replaced", "sha256", "stage",
341
+ }
342
+ if set(journal) != expected_fields:
343
+ raise IsolationError("artifact collection journal has invalid fields")
344
+ if journal.get("schema") != COLLECT_JOURNAL_SCHEMA:
345
+ raise IsolationError("artifact collection journal has invalid schema")
346
+ string_fields = request_fields - {"schema", "expected_destination"}
347
+ if any(not isinstance(journal.get(field), str) for field in string_fields):
348
+ raise IsolationError("artifact collection journal has invalid request fields")
349
+ try:
350
+ require_full_sha(str(journal["base"]))
351
+ relative_posix(str(journal["source"]))
352
+ relative_posix(str(journal["destination"]))
353
+ except IsolationError as error:
354
+ raise IsolationError(
355
+ f"artifact collection journal has an invalid request: {error}"
356
+ ) from error
357
+ if not Path(str(journal["primary_worktree"])).is_absolute():
358
+ raise IsolationError("artifact collection primary worktree must be absolute")
359
+ source_worktree = Path(str(journal["source_worktree"]))
360
+ if not source_worktree.is_absolute():
361
+ raise IsolationError("artifact collection source worktree must be absolute")
362
+ if not str(journal["branch"]).startswith(VERIFY_BRANCH_PREFIX):
363
+ raise IsolationError("artifact collection journal has an invalid verify branch")
364
+ if journal.get("stage") not in {"prepared", "collected", "complete"}:
365
+ raise IsolationError("artifact collection journal has an invalid stage")
366
+ count = journal.get("bytes")
367
+ if not isinstance(count, int) or isinstance(count, bool):
368
+ raise IsolationError("artifact collection journal has invalid byte count")
369
+ if not isinstance(journal.get("replaced"), bool):
370
+ raise IsolationError("artifact collection journal has invalid replacement state")
371
+ if not re.fullmatch(r"[0-9a-f]{64}", str(journal.get("sha256", ""))):
372
+ raise IsolationError("artifact collection journal has invalid content hash")
373
+ previous = journal.get("previous_sha256")
374
+ if previous is not None and not re.fullmatch(r"[0-9a-f]{64}", str(previous)):
375
+ raise IsolationError("artifact collection journal has invalid previous hash")
376
+ expected = journal.get("expected_destination")
377
+ if expected is not None and expected != "base" and not re.fullmatch(
378
+ r"[0-9a-f]{64}", str(expected)
379
+ ):
380
+ raise IsolationError("artifact collection journal has invalid expected destination")
381
+ if journal.get("primary_worktree") != str(primary) and not (
382
+ allow_complete_from_other_worktree and journal["stage"] == "complete"
383
+ ):
384
+ raise IsolationError("artifact collection journal belongs to another worktree")
385
+ return journal
386
+
387
+
388
+ def collect_artifact_recoveries(primary: Path) -> list[Dict[str, object]]:
389
+ """Return validated incomplete artifact collection transactions."""
390
+ primary = require_directory(primary, "primary worktree")
391
+ root = common_git_dir(primary) / "gsd-path" / "collect-artifact"
392
+ if not root.exists() and not root.is_symlink():
393
+ return []
394
+ if root.is_symlink() or not root.is_dir():
395
+ raise IsolationError(f"artifact collection journal root is unsafe: {root}")
396
+ recoveries = []
397
+ for path in sorted(root.iterdir()):
398
+ if path.suffix != ".json" or path.is_symlink() or not path.is_file():
399
+ raise IsolationError(f"artifact collection journal entry is unsafe: {path}")
400
+ journal = _validated_collect_journal(
401
+ primary, path, allow_complete_from_other_worktree=True
402
+ )
403
+ if journal["stage"] != "complete":
404
+ recoveries.append({**journal, "journal": str(path)})
405
+ return recoveries
406
+
407
+
408
+ def _git_file(repo: Path, revision: str, path: str) -> bytes:
409
+ result = subprocess.run(
410
+ ("git", "-C", str(repo), "show", f"{revision}:{path}"),
411
+ capture_output=True,
412
+ check=False,
413
+ )
414
+ if result.returncode != 0:
415
+ raise IsolationError(f"file is missing at {revision}: {path}")
416
+ return result.stdout
417
+
418
+
419
+ def _registered_worktrees(primary: Path) -> Dict[Path, Optional[str]]:
420
+ result = run_git(primary, "worktree", "list", "--porcelain")
421
+ if result.returncode != 0:
422
+ raise IsolationError(
423
+ (result.stderr or result.stdout).strip() or "could not list worktrees"
424
+ )
425
+ records: Dict[Path, Optional[str]] = {}
426
+ for block in result.stdout.strip().split("\n\n"):
427
+ fields = block.splitlines()
428
+ if not fields or not fields[0].startswith("worktree "):
429
+ continue
430
+ path = Path(fields[0].removeprefix("worktree ")).resolve()
431
+ branch = next(
432
+ (
433
+ line.removeprefix("branch ")
434
+ for line in fields[1:]
435
+ if line.startswith("branch ")
436
+ ),
437
+ None,
438
+ )
439
+ records[path] = branch
440
+ return records
441
+
442
+
443
+ def _same_directory(path: Path, expected: os.stat_result) -> bool:
444
+ try:
445
+ current = path.lstat()
446
+ except OSError:
447
+ return False
448
+ return stat.S_ISDIR(current.st_mode) and os.path.samestat(current, expected)
449
+
450
+
451
+ def _cleanup_reserved_worktree(
452
+ primary: Path,
453
+ branch: str,
454
+ destination: Path,
455
+ base: str,
456
+ reserved_directory: os.stat_result,
457
+ ) -> None:
458
+ ref = f"refs/heads/{branch}"
459
+ records = _registered_worktrees(primary)
460
+ registered = records.get(destination.resolve())
461
+ if registered == ref and _same_directory(destination, reserved_directory):
462
+ run_git(primary, "worktree", "remove", "--force", str(destination))
463
+ records = _registered_worktrees(primary)
464
+ elif registered is None and _same_directory(destination, reserved_directory):
465
+ try:
466
+ destination.rmdir()
467
+ except OSError:
468
+ pass
469
+ if ref not in records.values():
470
+ run_git(primary, "update-ref", "-d", ref, base)
471
+
472
+
473
+ def create_named_worktree(primary: Path, branch: str, destination: Path, base: str) -> None:
474
+ bound = require_bound(primary)
475
+ if branch == bound:
476
+ raise IsolationError(
477
+ f"refusing to check out the bound branch {bound} in a second worktree"
478
+ )
479
+ if os.path.lexists(destination):
480
+ raise IsolationError(f"worktree path already exists: {destination}")
481
+ destination.parent.mkdir(parents=True, exist_ok=True)
482
+ ref = f"refs/heads/{branch}"
483
+ reserved = run_git(primary, "update-ref", ref, base, NULL_SHA)
484
+ if reserved.returncode != 0:
485
+ raise IsolationError(f"branch already exists: {branch}")
486
+ try:
487
+ destination.mkdir()
488
+ except FileExistsError as error:
489
+ run_git(primary, "update-ref", "-d", ref, base)
490
+ raise IsolationError(f"worktree path already exists: {destination}") from error
491
+ except OSError as error:
492
+ run_git(primary, "update-ref", "-d", ref, base)
493
+ raise IsolationError(f"cannot reserve worktree path {destination}: {error}") from error
494
+ reserved_directory = destination.lstat()
495
+ result = run_git(
496
+ primary,
497
+ "worktree",
498
+ "add",
499
+ str(destination),
500
+ branch,
501
+ )
502
+ if result.returncode != 0:
503
+ _cleanup_reserved_worktree(
504
+ primary, branch, destination, base, reserved_directory
505
+ )
506
+ detail = (result.stderr or result.stdout).strip() or "git worktree add failed"
507
+ raise IsolationError(detail)
508
+ current = git_output(destination, "branch", "--show-current")
509
+ if current != branch:
510
+ _cleanup_reserved_worktree(
511
+ primary, branch, destination, base, reserved_directory
512
+ )
513
+ raise IsolationError(
514
+ f"worktree HEAD is not {branch} (got {current or 'detached'})"
515
+ )
516
+ if git_output(destination, "rev-parse", "HEAD") != base:
517
+ _cleanup_reserved_worktree(
518
+ primary, branch, destination, base, reserved_directory
519
+ )
520
+ raise IsolationError("new worktree is not at the recorded base")
521
+
522
+
523
+ def isolate_task(
524
+ primary: Path,
525
+ base: str,
526
+ task_id: str,
527
+ round_size: int,
528
+ ) -> Dict[str, object]:
529
+ primary = require_directory(primary, "primary worktree")
530
+ if worktree_root(primary) != primary:
531
+ raise IsolationError(f"primary is not its Git root: {primary}")
532
+ bound = require_bound(primary)
533
+ resolved_base = require_commit(primary, require_full_sha(base))
534
+ validate_task_id(task_id)
535
+ if round_size < 1:
536
+ raise IsolationError("round-size must be >= 1")
537
+ if current_sha(primary) != resolved_base:
538
+ raise IsolationError("task isolation requires primary HEAD to equal the recorded base")
539
+ dirty = uncommitted_paths(primary)
540
+ if dirty:
541
+ raise IsolationError(
542
+ "task isolation requires a clean primary worktree: "
543
+ + ", ".join(sorted(dirty))
544
+ )
545
+ if round_size == 1:
546
+ return {
547
+ "base": resolved_base,
548
+ "bound_branch": bound,
549
+ "detached": False,
550
+ "kind": "task",
551
+ "mode": "serial",
552
+ "task_branch": None,
553
+ "worktree": str(primary),
554
+ }
555
+ branch = task_branch_name(task_id)
556
+ destination = sidecar_root(primary, "task", task_id)
557
+ create_named_worktree(primary, branch, destination, resolved_base)
558
+ return {
559
+ "base": resolved_base,
560
+ "bound_branch": bound,
561
+ "detached": False,
562
+ "kind": "task",
563
+ "mode": "parallel",
564
+ "task_branch": branch,
565
+ "worktree": str(destination),
566
+ }
567
+
568
+
569
+ def isolate_verify(primary: Path, base: str, name: str, historical_task: Optional[str] = None) -> Dict[str, object]:
570
+ primary = require_directory(primary, "primary worktree")
571
+ if worktree_root(primary) != primary:
572
+ raise IsolationError(f"primary is not its Git root: {primary}")
573
+ bound = require_bound(primary)
574
+ resolved_base = require_commit(primary, require_full_sha(base))
575
+ if historical_task is not None:
576
+ reports = recover(primary, Path(".project/tasks"))["tasks"]
577
+ matches = [report for report in reports if report.get("task_id") == historical_task]
578
+ if (len(matches) != 1 or matches[0].get("verdict") != "recovered"
579
+ or matches[0].get("commit") != resolved_base):
580
+ raise IsolationError("historical verification requires this task's recovery-proven landing")
581
+ elif current_sha(primary) != resolved_base:
582
+ raise IsolationError("verify isolation requires primary HEAD to equal the recorded base")
583
+ product_dirt = sorted(
584
+ path for path in uncommitted_paths(primary) if not path.startswith(".project/")
585
+ )
586
+ if product_dirt:
587
+ raise IsolationError(
588
+ "verify isolation refuses non-.project primary changes: "
589
+ + ", ".join(product_dirt)
590
+ )
591
+ branch = verify_branch_name(name)
592
+ destination = sidecar_root(primary, "verify", name)
593
+ create_named_worktree(primary, branch, destination, resolved_base)
594
+ return {
595
+ "base": resolved_base,
596
+ "bound_branch": bound,
597
+ "branch": branch,
598
+ "detached": False,
599
+ "kind": "verify",
600
+ "mode": "sidecar",
601
+ "worktree": str(destination),
602
+ }
603
+
604
+
605
+ def clean_verify(primary: Path, worktree: Path, base: str, branch: str) -> Dict[str, object]:
606
+ """Discard command-created files only in the named verification sidecar."""
607
+ primary = require_directory(primary, "primary worktree")
608
+ worktree = require_directory(worktree, "verification worktree")
609
+ if worktree == primary or not branch.startswith(VERIFY_BRANCH_PREFIX):
610
+ raise IsolationError("verification cleanup requires a sidecar branch")
611
+ if worktree != _sidecar_path_for_branch(primary, branch):
612
+ raise IsolationError("verification cleanup path differs from its branch")
613
+ if worktree_root(worktree) != worktree or common_git_dir(worktree) != common_git_dir(primary):
614
+ raise IsolationError("verification cleanup requires the primary repository's sidecar")
615
+ if require_attached(worktree) != branch:
616
+ raise IsolationError("verification cleanup branch changed")
617
+ expected = require_commit(primary, require_full_sha(base))
618
+ if current_sha(primary) != expected or current_sha(worktree) != expected:
619
+ raise IsolationError("verification cleanup HEAD changed")
620
+ git_output(worktree, "restore", "--source=HEAD", "--staged", "--worktree", "--", ".")
621
+ removed = git_output(worktree, "clean", "-fdx", "--", ".")
622
+ return {"worktree": str(worktree), "branch": branch, "base": expected, "removed": removed}
623
+
624
+
625
+ def _split_paths(block: str) -> Set[str]:
626
+ return {line.strip() for line in block.splitlines() if line.strip()}
627
+
628
+
629
+ # Top-level .project entries the pipeline owns; ship verification rejects anything else.
630
+ PROJECT_ENTRIES = frozenset({
631
+ "STATE.md", "LESSONS.md", "REPOSITORY.md", "CHARTER.md", "ROADMAP.md", "SYNTHESIS.md",
632
+ "intent", "research", "plan", "tasks", "review", "build", "discuss", "archive", "next",
633
+ })
634
+
635
+
636
+ def uncommitted_paths(repo: Path) -> Set[str]:
637
+ tracked = git_output(
638
+ repo, "diff", "--no-renames", "--name-only", "--relative", "HEAD"
639
+ )
640
+ cached = git_output(
641
+ repo, "diff", "--no-renames", "--name-only", "--cached", "--relative"
642
+ )
643
+ untracked = git_output(repo, "ls-files", "--others", "--exclude-standard")
644
+ return _split_paths(tracked) | _split_paths(cached) | _split_paths(untracked)
645
+
646
+
647
+ def committed_paths_since(repo: Path, base: str) -> Set[str]:
648
+ if current_sha(repo) == base:
649
+ return set()
650
+ return _split_paths(
651
+ git_output(
652
+ repo, "diff", "--no-renames", "--name-only", "--relative", base, "HEAD"
653
+ )
654
+ )
655
+
656
+
657
+ def _commit_pending(repo: Path, pending: Set[str], subject: str, body: str) -> str:
658
+ reset = run_git(repo, "reset", "-q", "HEAD")
659
+ if reset.returncode != 0:
660
+ raise IsolationError("could not clear the index before commit")
661
+ for path in sorted(pending):
662
+ added = run_git(repo, "add", "-A", "--", path)
663
+ if added.returncode != 0:
664
+ raise IsolationError(f"could not stage {path}")
665
+ staged = _split_paths(
666
+ git_output(
667
+ repo,
668
+ "diff",
669
+ "--no-renames",
670
+ "--cached",
671
+ "--name-only",
672
+ "--relative",
673
+ )
674
+ )
675
+ if staged != pending:
676
+ raise IsolationError("staged paths do not match the uncommitted change set")
677
+ committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
678
+ if committed.returncode != 0:
679
+ raise IsolationError((committed.stderr or committed.stdout).strip() or "commit failed")
680
+ return current_sha(repo)
681
+
682
+
683
+ def validate_allowed_changes(
684
+ repo: Path, base: str, pending: Set[str], allowed: Set[str]
685
+ ) -> None:
686
+ if not pending:
687
+ raise IsolationError("no changes to land")
688
+ since_base = committed_paths_since(repo, base) | pending
689
+ unexpected = sorted(path for path in since_base if path not in allowed)
690
+ if unexpected:
691
+ raise IsolationError("unexpected paths: " + ", ".join(unexpected))
692
+ def _clean_blob_oid(repo: Path, path: str, text: str, *, write: bool) -> str:
693
+ arguments = ["hash-object"]
694
+ if write:
695
+ arguments.append("-w")
696
+ arguments.extend((f"--path={path}", "--stdin"))
697
+ result = run_git(repo, *arguments, input=text)
698
+ oid = result.stdout.strip()
699
+ if result.returncode != 0 or not re.fullmatch(r"[0-9a-f]{40,64}", oid):
700
+ detail = (result.stderr or result.stdout).strip() or "clean filter failed"
701
+ raise IsolationError(detail)
702
+ return oid
703
+
704
+
705
+ def commit_allowed_changes(
706
+ repo: Path,
707
+ base: str,
708
+ subject: str,
709
+ allowed: Set[str],
710
+ body: str,
711
+ ) -> tuple[str, str, Dict[str, Optional[tuple[str, str]]]]:
712
+ require_attached(repo)
713
+ pending = uncommitted_paths(repo)
714
+ validate_allowed_changes(repo, base, pending, allowed)
715
+ reset = run_git(repo, "reset", "-q", "HEAD")
716
+ if reset.returncode != 0:
717
+ raise IsolationError("could not clear the index before landing")
718
+ for path in sorted(pending):
719
+ added = run_git(repo, "add", "-A", "--", path)
720
+ if added.returncode != 0:
721
+ raise IsolationError(f"could not stage {path}")
722
+ staged = _split_paths(
723
+ git_output(repo, "diff", "--cached", "--name-only", "--relative")
724
+ )
725
+ if staged != pending:
726
+ raise IsolationError("staged paths do not match the uncommitted change set")
727
+ verified_tree = git_output(repo, "write-tree")
728
+ verified_delta = _tree_delta(repo, base, verified_tree)
729
+ committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
730
+ if committed.returncode != 0:
731
+ raise IsolationError(
732
+ (committed.stderr or committed.stdout).strip() or "commit failed"
733
+ )
734
+ return current_sha(repo), verified_tree, verified_delta
735
+
736
+
737
+ def split_frontmatter(text: str) -> tuple[list[str], str]:
738
+ """Return (frontmatter lines without the --- fences, body text)."""
739
+ lines = text.splitlines(keepends=True)
740
+ if not lines or lines[0].rstrip("\r\n") != "---":
741
+ raise IsolationError("task file has no frontmatter")
742
+ for index in range(1, len(lines)):
743
+ if lines[index].rstrip("\r\n") == "---":
744
+ return [line.rstrip("\r\n") for line in lines[1:index]], "".join(lines[index + 1 :])
745
+ raise IsolationError("task file frontmatter is not closed")
746
+
747
+
748
+ _strip_yaml_comment = _common.strip_yaml_comment
749
+ _unquote = _common.unquote
750
+
751
+
752
+ def task_frontmatter(text: str) -> tuple[Optional[Dict[str, object]], Optional[str]]:
753
+ try:
754
+ lines, _ = split_frontmatter(text)
755
+ except IsolationError as error:
756
+ return None, str(error)
757
+ fields: Dict[str, object] = {}
758
+ index = 0
759
+ while index < len(lines):
760
+ match = FIELD_PATTERN.match(lines[index])
761
+ if match is None:
762
+ index += 1
763
+ continue
764
+ key = match.group("key")
765
+ value = _strip_yaml_comment(match.group("value"))
766
+ inline = INLINE_LIST_PATTERN.fullmatch(value)
767
+ if inline is not None:
768
+ fields[key] = [
769
+ _unquote(item)
770
+ for item in inline.group("body").split(",")
771
+ if item.strip()
772
+ ]
773
+ index += 1
774
+ continue
775
+ if value:
776
+ fields[key] = _unquote(value)
777
+ index += 1
778
+ continue
779
+ items = []
780
+ cursor = index + 1
781
+ while cursor < len(lines):
782
+ item = LIST_ITEM_PATTERN.match(lines[cursor])
783
+ if item is None:
784
+ break
785
+ items.append(_unquote(item.group("value")))
786
+ cursor += 1
787
+ fields[key] = items
788
+ index = cursor
789
+ return fields, None
790
+
791
+
792
+ LANDED_FIELDS = {"status": "done", "worktree": "null", "task_branch": "null"}
793
+ LANDING_MUTABLE_FIELDS = ("status", "agent", "base", "worktree", "task_branch")
794
+
795
+
796
+ def _activated_task_text(
797
+ text: str,
798
+ task_id: str,
799
+ agent: str,
800
+ base: str,
801
+ worktree: Path,
802
+ task_branch: Optional[str],
803
+ ) -> str:
804
+ head, body = split_frontmatter(text)
805
+ fields, error = task_frontmatter(text)
806
+ if error is not None or fields is None:
807
+ raise IsolationError(error or "task frontmatter is unreadable")
808
+ pending = {
809
+ "id": task_id,
810
+ "status": "pending",
811
+ "agent": "null",
812
+ "base": "null",
813
+ "worktree": "null",
814
+ "task_branch": "null",
815
+ }
816
+ values = {
817
+ "status": "in-progress",
818
+ "agent": agent,
819
+ "base": base,
820
+ "worktree": str(worktree),
821
+ "task_branch": task_branch or "null",
822
+ }
823
+ active = {"id": task_id, **values}
824
+ if all(fields.get(key) == value for key, value in active.items()):
825
+ return text
826
+ for key, value in pending.items():
827
+ if fields.get(key) != value:
828
+ raise IsolationError(f"task activation requires {key}: {value}")
829
+ found = set()
830
+ lines = []
831
+ for line in head:
832
+ key = _frontmatter_key(line)
833
+ if key in values:
834
+ if key in found:
835
+ raise IsolationError(f"task frontmatter repeats {key}")
836
+ found.add(key)
837
+ lines.append(f"{key}: {values[key]}")
838
+ else:
839
+ lines.append(line)
840
+ missing = sorted(values.keys() - found)
841
+ if missing:
842
+ raise IsolationError(
843
+ "task frontmatter missing activation fields: " + ", ".join(missing)
844
+ )
845
+ return "---\n" + "\n".join(lines) + "\n---\n" + body
846
+
847
+
848
+ def activate_task(
849
+ worktree: Path,
850
+ base: str,
851
+ task_id: str,
852
+ agent: str,
853
+ task_file: str,
854
+ task_branch: Optional[str],
855
+ ) -> Dict[str, object]:
856
+ worktree = require_directory(worktree, "task worktree")
857
+ if worktree_root(worktree) != worktree:
858
+ raise IsolationError(f"task worktree is not its Git root: {worktree}")
859
+ resolved_base = require_commit(worktree, require_full_sha(base))
860
+ if current_sha(worktree) != resolved_base:
861
+ raise IsolationError(
862
+ "task activation requires worktree HEAD at the recorded base"
863
+ )
864
+ task_id = validate_task_id(task_id)
865
+ if not agent or agent == "null" or "\n" in agent:
866
+ raise IsolationError("task activation requires an assigned agent")
867
+ branch = require_attached(worktree)
868
+ if task_branch is None:
869
+ if branch.startswith(TASK_BRANCH_PREFIX):
870
+ raise IsolationError("parallel task activation requires --task-branch")
871
+ elif task_branch != task_branch_name(task_id) or branch != task_branch:
872
+ raise IsolationError("task activation branch does not match the task worktree")
873
+ normalized_task = relative_posix(task_file)
874
+ if not normalized_task.startswith(".project/tasks/"):
875
+ raise IsolationError("task activation file must stay under .project/tasks/")
876
+ task_path = _real_file(worktree, normalized_task, "task activation file")
877
+ text, mode = _read_task_text(task_path)
878
+ activated = _activated_task_text(
879
+ text, task_id, agent, resolved_base, worktree, task_branch
880
+ )
881
+ _replace_regular_file(task_path, activated.encode("utf-8"), mode)
882
+ if task_branch is not None:
883
+ authorization_ref = task_authorization_ref(task_id)
884
+ authorized = run_git(
885
+ worktree,
886
+ "update-ref",
887
+ authorization_ref,
888
+ resolved_base,
889
+ "0" * len(resolved_base),
890
+ )
891
+ if authorized.returncode != 0:
892
+ existing = run_git(
893
+ worktree,
894
+ "rev-parse",
895
+ "--verify",
896
+ authorization_ref,
897
+ )
898
+ if existing.returncode != 0 or existing.stdout.strip() != resolved_base:
899
+ _replace_regular_file(task_path, text.encode("utf-8"), mode)
900
+ raise IsolationError("could not record task worktree authorization")
901
+ return {
902
+ "agent": agent,
903
+ "base": resolved_base,
904
+ "status": "in-progress",
905
+ "task_branch": task_branch,
906
+ "task_file": normalized_task,
907
+ "worktree": str(worktree),
908
+ }
909
+
910
+
911
+ def deactivate_task(
912
+ worktree: Path, task_id: str, task_branch: str
913
+ ) -> Dict[str, object]:
914
+ worktree = require_directory(worktree, "task worktree")
915
+ if worktree_root(worktree) != worktree:
916
+ raise IsolationError(f"task worktree is not its Git root: {worktree}")
917
+ task_id = validate_task_id(task_id)
918
+ expected_branch = task_branch_name(task_id)
919
+ if (
920
+ task_branch != expected_branch
921
+ or require_attached(worktree) != expected_branch
922
+ ):
923
+ raise IsolationError("task deactivation branch does not match the task worktree")
924
+ _delete_task_authorization(worktree, task_branch)
925
+ return {
926
+ "status": "deactivated",
927
+ "task_branch": task_branch,
928
+ "task_id": task_id,
929
+ "worktree": str(worktree),
930
+ }
931
+
932
+
933
+ def _landed_task_text(text: str, base: str) -> str:
934
+ head, body = split_frontmatter(text)
935
+ values = {**LANDED_FIELDS, "base": base}
936
+ found = set()
937
+ lines = []
938
+ for line in head:
939
+ key = line.split(":", 1)[0]
940
+ if key in values:
941
+ if key in found:
942
+ raise IsolationError(f"task frontmatter repeats {key}")
943
+ if key == "base":
944
+ recorded_base = _unquote(line.split(":", 1)[1])
945
+ if recorded_base not in {"null", base}:
946
+ raise IsolationError(
947
+ "task frontmatter base must be null or match landing base"
948
+ )
949
+ found.add(key)
950
+ lines.append(f"{key}: {values[key]}")
951
+ else:
952
+ lines.append(line)
953
+ missing = sorted(values.keys() - found)
954
+ if missing:
955
+ raise IsolationError("task frontmatter missing landing fields: " + ", ".join(missing))
956
+ return "---\n" + "\n".join(lines) + "\n---\n" + body
957
+
958
+
959
+ def _read_regular_file(path: Path) -> tuple[bytes, int]:
960
+ descriptor = -1
961
+ try:
962
+ initial = path.lstat()
963
+ if not stat.S_ISREG(initial.st_mode):
964
+ raise IsolationError(f"task path is not a regular file: {path}")
965
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
966
+ metadata = os.fstat(descriptor)
967
+ current = path.lstat()
968
+ initial_identity = (initial.st_dev, initial.st_ino)
969
+ opened_identity = (metadata.st_dev, metadata.st_ino)
970
+ current_identity = (current.st_dev, current.st_ino)
971
+ if (
972
+ not stat.S_ISREG(metadata.st_mode)
973
+ or not stat.S_ISREG(current.st_mode)
974
+ or initial_identity != opened_identity
975
+ or current_identity != opened_identity
976
+ ):
977
+ raise IsolationError(f"task path is not a regular file: {path}")
978
+ with os.fdopen(descriptor, "rb") as stream:
979
+ descriptor = -1
980
+ return stream.read(), stat.S_IMODE(metadata.st_mode)
981
+ except OSError as error:
982
+ raise IsolationError(f"task path is not a regular file: {path}") from error
983
+ finally:
984
+ if descriptor >= 0:
985
+ os.close(descriptor)
986
+
987
+
988
+ def _read_task_text(path: Path) -> tuple[str, int]:
989
+ data, mode = _read_regular_file(path)
990
+ try:
991
+ return data.decode("utf-8"), mode
992
+ except UnicodeDecodeError as error:
993
+ raise IsolationError(f"task file is not UTF-8: {path}") from error
994
+
995
+
996
+ def _replace_regular_file(path: Path, data: bytes, mode: int) -> None:
997
+ descriptor = -1
998
+ temporary_path: Optional[Path] = None
999
+ try:
1000
+ descriptor, temporary_name = tempfile.mkstemp(
1001
+ dir=path.parent, prefix=f".{path.name}."
1002
+ )
1003
+ temporary_path = Path(temporary_name)
1004
+ os.chmod(temporary_path, mode)
1005
+ with os.fdopen(descriptor, "wb") as stream:
1006
+ descriptor = -1
1007
+ stream.write(data)
1008
+ os.replace(temporary_path, path)
1009
+ temporary_path = None
1010
+ except OSError as error:
1011
+ raise IsolationError(f"could not replace task file {path}: {error}") from error
1012
+ finally:
1013
+ if descriptor >= 0:
1014
+ os.close(descriptor)
1015
+ if temporary_path is not None:
1016
+ temporary_path.unlink(missing_ok=True)
1017
+
1018
+
1019
+ def stamp_task_landed(task_path: Path, base: str) -> None:
1020
+ """Write the orchestrator-owned landing state into the task frontmatter."""
1021
+ text, mode = _read_task_text(task_path)
1022
+ _replace_regular_file(
1023
+ task_path, _landed_task_text(text, base).encode("utf-8"), mode
1024
+ )
1025
+
1026
+
1027
+ def _restore_landing_state(
1028
+ worktree: Path, task_path: Path, task_bytes: bytes, task_mode: int, index_tree: str
1029
+ ) -> None:
1030
+ errors = []
1031
+ try:
1032
+ _replace_regular_file(task_path, task_bytes, task_mode)
1033
+ except IsolationError as error:
1034
+ errors.append(str(error))
1035
+ restored = run_git(worktree, "read-tree", index_tree)
1036
+ if restored.returncode != 0:
1037
+ errors.append((restored.stderr or restored.stdout).strip() or "index restore failed")
1038
+ if errors:
1039
+ raise IsolationError("could not restore landing state: " + "; ".join(errors))
1040
+
1041
+
1042
+ def _restore_rejected_commit(
1043
+ repo: Path, commit: str, parent: str, worktree_tree: str
1044
+ ) -> None:
1045
+ if current_sha(repo) != commit:
1046
+ raise IsolationError("HEAD moved after the rejected landing commit")
1047
+ moved = run_git(repo, "update-ref", "HEAD", parent, commit)
1048
+ if moved.returncode != 0:
1049
+ detail = (moved.stderr or moved.stdout).strip() or "could not restore HEAD"
1050
+ raise IsolationError(detail)
1051
+ restored = run_git(repo, "read-tree", "--reset", "-u", worktree_tree)
1052
+ if restored.returncode != 0:
1053
+ detail = (
1054
+ (restored.stderr or restored.stdout).strip()
1055
+ or "could not restore the verified tree"
1056
+ )
1057
+ raise IsolationError(detail)
1058
+
1059
+
1060
+ def _stamp_and_commit(
1061
+ worktree: Path, base: str, subject: str, task_file: str, allowed: Set[str]
1062
+ ) -> tuple[str, Dict[str, Optional[tuple[str, str]]]]:
1063
+ task_path = worktree / task_file
1064
+ task_text, task_mode = _read_task_text(task_path)
1065
+ task_bytes = task_text.encode("utf-8")
1066
+ stamped_text = _landed_task_text(task_text, base)
1067
+ stamped = stamped_text.encode("utf-8")
1068
+ _, landing_error = _landing_state(
1069
+ worktree,
1070
+ base,
1071
+ task_file,
1072
+ stamped_text,
1073
+ subject,
1074
+ allowed,
1075
+ clean_after=True,
1076
+ )
1077
+ if landing_error:
1078
+ raise IsolationError(landing_error)
1079
+ pending = uncommitted_paths(worktree)
1080
+ if stamped != task_bytes:
1081
+ pending.add(task_file)
1082
+ validate_allowed_changes(worktree, base, pending, allowed)
1083
+ original_head = current_sha(worktree)
1084
+ index_tree = git_output(worktree, "write-tree")
1085
+ try:
1086
+ _replace_regular_file(task_path, stamped, task_mode)
1087
+ body = task_commit_body(task_file, uncommitted_paths(worktree), base)
1088
+ commit, verified_tree, verified_delta = commit_allowed_changes(
1089
+ worktree, base, subject, allowed, body
1090
+ )
1091
+ proof_error = _landing_commit_proof_error(
1092
+ worktree,
1093
+ commit,
1094
+ original_head,
1095
+ task_file,
1096
+ base,
1097
+ allowed,
1098
+ verified_delta,
1099
+ )
1100
+ if proof_error:
1101
+ try:
1102
+ _restore_rejected_commit(
1103
+ worktree, commit, original_head, verified_tree
1104
+ )
1105
+ except IsolationError as restore_error:
1106
+ raise IsolationError(
1107
+ f"landing commit proof failed: {proof_error}; {restore_error}"
1108
+ ) from restore_error
1109
+ raise IsolationError(f"landing commit proof failed: {proof_error}")
1110
+ return commit, verified_delta
1111
+ except BaseException as error:
1112
+ if current_sha(worktree) != original_head:
1113
+ raise IsolationError(
1114
+ f"landing failed after HEAD changed; refusing rollback: {error}"
1115
+ ) from error
1116
+ try:
1117
+ _restore_landing_state(
1118
+ worktree, task_path, task_bytes, task_mode, index_tree
1119
+ )
1120
+ except IsolationError as restore_error:
1121
+ raise IsolationError(f"{error}; {restore_error}") from error
1122
+ raise
1123
+ def land(
1124
+ primary: Path,
1125
+ source: Path,
1126
+ base: str,
1127
+ task_id: str,
1128
+ title: str,
1129
+ task_file: str,
1130
+ allow_paths: Sequence[str],
1131
+ ) -> Dict[str, object]:
1132
+ primary = require_directory(primary, "primary worktree")
1133
+ source = require_directory(source, "source worktree")
1134
+ if worktree_root(primary) != primary:
1135
+ raise IsolationError(f"primary is not its Git root: {primary}")
1136
+ if worktree_root(source) != source:
1137
+ raise IsolationError(f"source is not its Git root: {source}")
1138
+ if common_git_dir(primary) != common_git_dir(source):
1139
+ raise IsolationError("source worktree belongs to another repository")
1140
+ bound = require_bound(primary)
1141
+ source_branch = require_attached(source)
1142
+ resolved_base = require_commit(primary, require_full_sha(base))
1143
+ validate_task_id(task_id)
1144
+ try:
1145
+ subject = task_commit_subject(task_id, title)
1146
+ except ValueError as error:
1147
+ raise IsolationError(str(error)) from error
1148
+ allowed = {relative_posix(task_file)}
1149
+ for path in allow_paths:
1150
+ allowed.add(relative_posix(path))
1151
+ serial = source == primary
1152
+ if serial:
1153
+ if source_branch != bound:
1154
+ raise IsolationError("serial landing requires the bound branch")
1155
+ if current_sha(primary) != resolved_base:
1156
+ raise IsolationError("serial landing requires HEAD to equal the recorded base")
1157
+ commit, _ = _stamp_and_commit(
1158
+ primary,
1159
+ resolved_base,
1160
+ subject,
1161
+ relative_posix(task_file),
1162
+ allowed,
1163
+ )
1164
+ return {
1165
+ "bound_branch": bound,
1166
+ "commit": commit,
1167
+ "mode": "serial",
1168
+ "source_commit": commit,
1169
+ "subject": subject,
1170
+ }
1171
+ expected_branch = task_branch_name(task_id)
1172
+ if source_branch != expected_branch:
1173
+ raise IsolationError(
1174
+ f"source branch is {source_branch}, expected {expected_branch}"
1175
+ )
1176
+ if require_attached(primary) != bound:
1177
+ raise IsolationError("primary left the bound branch")
1178
+ dirty = sorted(uncommitted_paths(primary) - BOOKKEEPING_PATHS)
1179
+ if dirty:
1180
+ raise IsolationError(
1181
+ "primary worktree is dirty; refusing to cherry-pick: "
1182
+ + ", ".join(dirty)
1183
+ + "; commit or remove those paths in the primary worktree first"
1184
+ )
1185
+ source_dirty = bool(uncommitted_paths(source))
1186
+ if source_dirty:
1187
+ if current_sha(source) != resolved_base:
1188
+ raise IsolationError("dirty parallel source HEAD must equal the recorded base")
1189
+ source_commit, source_delta = _stamp_and_commit(
1190
+ source, resolved_base, subject, relative_posix(task_file), allowed
1191
+ )
1192
+ else:
1193
+ source_commit = current_sha(source)
1194
+ if source_commit == resolved_base:
1195
+ raise IsolationError("no changes to land")
1196
+ source_delta = _tree_delta(source, resolved_base, source_commit)
1197
+ source_parent, parent_error = _single_parent(source, source_commit)
1198
+ if parent_error or source_parent != resolved_base:
1199
+ raise IsolationError(
1200
+ parent_error or "clean parallel source commit parent must equal the recorded base"
1201
+ )
1202
+ if uncommitted_paths(source):
1203
+ raise IsolationError("source worktree is dirty after creating the task commit")
1204
+ source_body = git_output(source, "log", "-1", "--format=%b", source_commit)
1205
+ _, proof_error = _prove_task_commit(
1206
+ source,
1207
+ source_commit,
1208
+ source_body,
1209
+ relative_posix(task_file),
1210
+ resolved_base,
1211
+ None,
1212
+ allowed,
1213
+ )
1214
+ if proof_error:
1215
+ raise IsolationError(f"source commit body/task proof failed: {proof_error}")
1216
+ landing_parent = current_sha(primary)
1217
+ picked = run_git(primary, "cherry-pick", source_commit)
1218
+ if picked.returncode != 0:
1219
+ run_git(primary, "cherry-pick", "--abort")
1220
+ if git_output(primary, "status", "--porcelain"):
1221
+ raise IsolationError("cherry-pick failed and primary is not clean after abort")
1222
+ if require_attached(primary) != bound:
1223
+ raise IsolationError("cherry-pick failed and primary is no longer on the bound branch")
1224
+ detail = (picked.stderr or picked.stdout).strip() or "cherry-pick conflict"
1225
+ raise IsolationError(f"conflict: {detail}")
1226
+ commit = current_sha(primary)
1227
+ proof_error = _landing_commit_proof_error(
1228
+ primary,
1229
+ commit,
1230
+ landing_parent,
1231
+ relative_posix(task_file),
1232
+ resolved_base,
1233
+ allowed,
1234
+ source_delta,
1235
+ )
1236
+ if proof_error:
1237
+ try:
1238
+ _restore_rejected_commit(
1239
+ primary, commit, landing_parent, landing_parent
1240
+ )
1241
+ except IsolationError as restore_error:
1242
+ raise IsolationError(
1243
+ f"landing commit proof failed: {proof_error}; {restore_error}"
1244
+ ) from restore_error
1245
+ raise IsolationError(f"landing commit proof failed: {proof_error}")
1246
+ return {
1247
+ "bound_branch": bound,
1248
+ "commit": commit,
1249
+ "mode": "parallel",
1250
+ "source_commit": source_commit,
1251
+ "subject": subject,
1252
+ }
1253
+
1254
+
1255
+ def git_text(repo: Path, *arguments: str) -> str:
1256
+ result = run_git(repo, *arguments)
1257
+ if result.returncode != 0:
1258
+ detail = (result.stderr or result.stdout).strip() or "git command failed"
1259
+ raise IsolationError(detail)
1260
+ return result.stdout
1261
+
1262
+
1263
+ def _tree_delta(
1264
+ repo: Path, before: str, after: str
1265
+ ) -> Dict[str, Optional[tuple[str, str]]]:
1266
+ raw = git_text(
1267
+ repo,
1268
+ "diff",
1269
+ "--raw",
1270
+ "--no-abbrev",
1271
+ "--no-renames",
1272
+ "-z",
1273
+ before,
1274
+ after,
1275
+ "--",
1276
+ )
1277
+ records = raw.split("\0")
1278
+ if records and records[-1] == "":
1279
+ records.pop()
1280
+ if len(records) % 2:
1281
+ raise IsolationError("could not parse tree delta")
1282
+ delta: Dict[str, Optional[tuple[str, str]]] = {}
1283
+ for metadata, path in zip(records[0::2], records[1::2]):
1284
+ fields = metadata.split()
1285
+ if (
1286
+ len(fields) != 5
1287
+ or not fields[0].startswith(":")
1288
+ or not path
1289
+ or path in delta
1290
+ ):
1291
+ raise IsolationError("could not parse tree delta")
1292
+ mode = fields[1]
1293
+ oid = fields[3]
1294
+ delta[path] = None if mode == "000000" else (mode, oid)
1295
+ return delta
1296
+
1297
+
1298
+ def _frontmatter_key(line: str) -> Optional[str]:
1299
+ match = FIELD_PATTERN.match(line)
1300
+ return match.group("key") if match is not None else None
1301
+
1302
+
1303
+ def _single_parent(repo: Path, sha: str) -> tuple[Optional[str], Optional[str]]:
1304
+ parents = git_output(repo, "show", "-s", "--format=%P", sha).split()
1305
+ if len(parents) != 1:
1306
+ return None, "task commit must have exactly one parent"
1307
+ return parents[0], None
1308
+
1309
+
1310
+ def _immutable_frontmatter(lines: Sequence[str]) -> list[str]:
1311
+ return [line for line in lines if _frontmatter_key(line) not in LANDING_MUTABLE_FIELDS]
1312
+
1313
+
1314
+ def _landing_transition_error(
1315
+ before_head: Sequence[str],
1316
+ after_head: Sequence[str],
1317
+ before_fields: Dict[str, object],
1318
+ after_fields: Dict[str, object],
1319
+ base: str,
1320
+ ) -> Optional[str]:
1321
+ for key in LANDING_MUTABLE_FIELDS:
1322
+ if sum(_frontmatter_key(line) == key for line in before_head) != 1:
1323
+ return f"base task frontmatter must contain one {key} field"
1324
+ if sum(_frontmatter_key(line) == key for line in after_head) != 1:
1325
+ return f"landed task frontmatter must contain one {key} field"
1326
+ if _immutable_frontmatter(after_head) != _immutable_frontmatter(before_head):
1327
+ return "task frontmatter changes fields outside landing metadata"
1328
+ if before_fields.get("status") == "done":
1329
+ return "base task is already done"
1330
+ expected = {**LANDED_FIELDS, "base": base}
1331
+ for key, value in expected.items():
1332
+ if after_fields.get(key) != value:
1333
+ return f"landed task frontmatter has invalid {key}"
1334
+ agent = after_fields.get("agent")
1335
+ if not isinstance(agent, str) or agent in {"", "null"}:
1336
+ return "landed task frontmatter has no assigned agent"
1337
+ return None
1338
+
1339
+
1340
+ def _declared_task_paths(
1341
+ fields: Dict[str, object],
1342
+ ) -> tuple[Optional[Set[str]], Optional[str]]:
1343
+ raw_files = fields.get("files")
1344
+ if not isinstance(raw_files, list) or not all(
1345
+ isinstance(item, str) for item in raw_files
1346
+ ):
1347
+ return None, "base task frontmatter has invalid files"
1348
+ try:
1349
+ return {relative_posix(item) for item in raw_files}, None
1350
+ except IsolationError as error:
1351
+ return None, str(error)
1352
+
1353
+
1354
+ def _regular_blob_oid(repo: Path, revision: str, path: str) -> str:
1355
+ entries = git_output(repo, "ls-tree", revision, "--", path).splitlines()
1356
+ if len(entries) != 1:
1357
+ raise IsolationError(f"task path is not a regular file in {revision}: {path}")
1358
+ parts = entries[0].split(maxsplit=3)
1359
+ if len(parts) != 4 or parts[0] not in {"100644", "100755"} or parts[1] != "blob":
1360
+ raise IsolationError(f"task path is not a regular file in {revision}: {path}")
1361
+ return parts[2]
1362
+
1363
+
1364
+ def _base_task_contract(
1365
+ repo: Path, base: str, task_file: str
1366
+ ) -> tuple[list[str], str, Dict[str, object], Set[str]]:
1367
+ _regular_blob_oid(repo, base, task_file)
1368
+ text = git_text(repo, "show", f"{base}:{task_file}")
1369
+ head, body = split_frontmatter(text)
1370
+ fields, error = task_frontmatter(text)
1371
+ if error or fields is None:
1372
+ raise IsolationError(error or "base task frontmatter is unreadable")
1373
+ files, files_error = _declared_task_paths(fields)
1374
+ if files_error or files is None:
1375
+ raise IsolationError(files_error or "base task files are unreadable")
1376
+ return head, body, fields, {task_file, *files}
1377
+
1378
+
1379
+ def _clean_task_text(repo: Path, path: str, text: str) -> str:
1380
+ oid = _clean_blob_oid(repo, path, text, write=True)
1381
+ return git_text(repo, "cat-file", "blob", oid)
1382
+
1383
+
1384
+ def _normalize_newlines(text: str) -> str:
1385
+ return text.replace("\r\n", "\n").replace("\r", "\n")
1386
+
1387
+
1388
+ def _landing_state(
1389
+ repo: Path,
1390
+ base: str,
1391
+ task_file: str,
1392
+ after_text: str,
1393
+ subject: str,
1394
+ allowed: Optional[Set[str]],
1395
+ *,
1396
+ clean_after: bool = False,
1397
+ ) -> tuple[Optional[Set[str]], Optional[str]]:
1398
+ try:
1399
+ before_head, before_body, before_fields, contract_paths = _base_task_contract(
1400
+ repo, base, task_file
1401
+ )
1402
+ if clean_after:
1403
+ after_text = _clean_task_text(repo, task_file, after_text)
1404
+ after_head, after_body = split_frontmatter(after_text)
1405
+ except IsolationError as error:
1406
+ return None, str(error)
1407
+ after_fields, after_error = task_frontmatter(after_text)
1408
+ if after_error or after_fields is None:
1409
+ return contract_paths, after_error or "landed task frontmatter is unreadable"
1410
+ try:
1411
+ expected_subject = task_commit_subject(
1412
+ str(before_fields.get("id", "")), str(before_fields.get("title", ""))
1413
+ )
1414
+ except ValueError as error:
1415
+ return contract_paths, str(error)
1416
+ if subject != expected_subject:
1417
+ return (
1418
+ contract_paths,
1419
+ f"source commit subject is {subject!r}, expected {expected_subject!r}",
1420
+ )
1421
+ if allowed is not None and allowed != contract_paths:
1422
+ missing = sorted(contract_paths - allowed)
1423
+ extra = sorted(allowed - contract_paths)
1424
+ details = []
1425
+ if missing:
1426
+ details.append("missing " + ", ".join(missing))
1427
+ if extra:
1428
+ details.append("extra " + ", ".join(extra))
1429
+ return (
1430
+ contract_paths,
1431
+ "landing allow-list differs from the base task: " + "; ".join(details),
1432
+ )
1433
+ transition_error = _landing_transition_error(
1434
+ before_head, after_head, before_fields, after_fields, base
1435
+ )
1436
+ if transition_error:
1437
+ return contract_paths, transition_error
1438
+ if not _normalize_newlines(after_body).startswith(
1439
+ _normalize_newlines(before_body)
1440
+ ):
1441
+ return contract_paths, "task file body is not append-only"
1442
+ return contract_paths, None
1443
+
1444
+
1445
+ def _landing_retry_error(
1446
+ repo: Path, base: str, task_file: str, task_text: str, subject: str
1447
+ ) -> Optional[str]:
1448
+ contract_paths, error = _landing_state(
1449
+ repo, base, task_file, task_text, subject, None
1450
+ )
1451
+ if error or contract_paths is None:
1452
+ return error or "base task contract is unreadable"
1453
+ pending = uncommitted_paths(repo)
1454
+ if task_file not in pending:
1455
+ return "interrupted landing does not retain the task change"
1456
+ try:
1457
+ validate_allowed_changes(repo, base, pending, contract_paths)
1458
+ except IsolationError as validation_error:
1459
+ return str(validation_error)
1460
+ return None
1461
+
1462
+
1463
+ def _prove_task_commit(
1464
+ repo: Path,
1465
+ sha: str,
1466
+ body: str,
1467
+ task_file: str,
1468
+ expected_base: str,
1469
+ current_task_text: Optional[str],
1470
+ allowed: Optional[Set[str]] = None,
1471
+ compare_head: bool = True,
1472
+ ) -> tuple[Optional[str], Optional[str]]:
1473
+ """Return (base, None) when `sha` is this task's landing commit, else (None, reason)."""
1474
+ base = next((line[6:].strip() for line in body.splitlines() if line.startswith("Base: ")), None)
1475
+ if not base:
1476
+ return None, "body has no Base: field"
1477
+ if base != expected_base:
1478
+ return None, "body Base: differs from the task's recorded base"
1479
+ parent, parent_error = _single_parent(repo, sha)
1480
+ if parent_error or parent is None:
1481
+ return None, parent_error or "task commit parent is unreadable"
1482
+ if run_git(repo, "merge-base", "--is-ancestor", base, parent).returncode != 0:
1483
+ return None, "commit does not descend from its Base:"
1484
+ try:
1485
+ after_oid = _regular_blob_oid(repo, sha, task_file)
1486
+ after_text = git_text(repo, "show", f"{sha}:{task_file}")
1487
+ except IsolationError as error:
1488
+ return None, str(error)
1489
+ subject = git_output(repo, "log", "-1", "--format=%s", sha)
1490
+ contract_paths, landing_error = _landing_state(
1491
+ repo, base, task_file, after_text, subject, allowed
1492
+ )
1493
+ if contract_paths is None:
1494
+ return None, landing_error or "base task contract is unreadable"
1495
+ changed = set(
1496
+ git_output(
1497
+ repo, "diff-tree", "--no-commit-id", "--name-only", "-r", sha
1498
+ ).splitlines()
1499
+ )
1500
+ if task_file not in changed:
1501
+ return None, "commit does not touch the task file"
1502
+ stray = sorted(changed - contract_paths)
1503
+ if stray:
1504
+ return None, f"commit touches undeclared paths: {', '.join(stray)}"
1505
+ if landing_error:
1506
+ return None, landing_error
1507
+ if body != task_commit_body(task_file, changed, base).strip():
1508
+ return None, "body Task:/Files: fields do not match the changed paths"
1509
+ if current_task_text is not None:
1510
+ try:
1511
+ current_oid = _clean_blob_oid(
1512
+ repo, task_file, current_task_text, write=False
1513
+ )
1514
+ current_head_oid = (
1515
+ _regular_blob_oid(repo, "HEAD", task_file)
1516
+ if compare_head
1517
+ else after_oid
1518
+ )
1519
+ except IsolationError as error:
1520
+ return None, str(error)
1521
+ if after_oid != current_head_oid or after_oid != current_oid:
1522
+ return None, "current done task differs from its landing commit"
1523
+ return base, None
1524
+
1525
+
1526
+ def _landing_commit_proof_error(
1527
+ repo: Path,
1528
+ commit: str,
1529
+ expected_parent: str,
1530
+ task_file: str,
1531
+ expected_base: str,
1532
+ allowed: Set[str],
1533
+ expected_delta: Dict[str, Optional[tuple[str, str]]],
1534
+ ) -> Optional[str]:
1535
+ parent, parent_error = _single_parent(repo, commit)
1536
+ if parent_error or parent != expected_parent:
1537
+ return parent_error or "landing commit parent differs from the pre-landing HEAD"
1538
+ try:
1539
+ current_task_text, _ = _read_task_text(repo / task_file)
1540
+ body = git_output(repo, "log", "-1", "--format=%b", commit)
1541
+ except IsolationError as error:
1542
+ return str(error)
1543
+ _, proof_error = _prove_task_commit(
1544
+ repo,
1545
+ commit,
1546
+ body,
1547
+ task_file,
1548
+ expected_base,
1549
+ current_task_text,
1550
+ allowed,
1551
+ )
1552
+ if proof_error:
1553
+ return proof_error
1554
+ try:
1555
+ actual_delta = _tree_delta(repo, expected_parent, commit)
1556
+ except IsolationError as error:
1557
+ return str(error)
1558
+ if actual_delta != expected_delta:
1559
+ return "landing commit tree differs from the verified changes"
1560
+ pending = uncommitted_paths(repo) - BOOKKEEPING_PATHS
1561
+ if pending:
1562
+ return "landing left uncommitted paths: " + ", ".join(sorted(pending))
1563
+ return None
1564
+
1565
+
1566
+ def _task_branch_proof_error(
1567
+ repo: Path, branch: str, base: str, landed_commit: str
1568
+ ) -> Optional[str]:
1569
+ branch_commit = require_commit(repo, branch)
1570
+ branch_parent, branch_parent_error = _single_parent(repo, branch_commit)
1571
+ if branch_parent_error or branch_parent != base:
1572
+ return branch_parent_error or "retained task branch parent differs from Base:"
1573
+ landed_parent, landed_parent_error = _single_parent(repo, landed_commit)
1574
+ if landed_parent_error or landed_parent is None:
1575
+ return landed_parent_error or "landing commit parent is unreadable"
1576
+ if run_git(repo, "merge-base", "--is-ancestor", base, landed_parent).returncode != 0:
1577
+ return "landing commit does not descend from Base:"
1578
+ branch_subject = git_output(repo, "log", "-1", "--format=%s", branch_commit)
1579
+ landed_subject = git_output(repo, "log", "-1", "--format=%s", landed_commit)
1580
+ branch_body = git_output(repo, "log", "-1", "--format=%b", branch_commit)
1581
+ landed_body = git_output(repo, "log", "-1", "--format=%b", landed_commit)
1582
+ if branch_subject != landed_subject or branch_body != landed_body:
1583
+ return "retained task branch message differs from the landing commit"
1584
+ try:
1585
+ branch_delta = _tree_delta(repo, base, branch_commit)
1586
+ landed_delta = _tree_delta(repo, landed_parent, landed_commit)
1587
+ except IsolationError as error:
1588
+ return str(error)
1589
+ if branch_delta != landed_delta:
1590
+ return "retained task branch differs from the landing commit"
1591
+ return None
1592
+
1593
+
1594
+ def _worktree_report(
1595
+ primary: Path, path: Path, expected_branch: str
1596
+ ) -> tuple[Optional[Dict[str, object]], Optional[str]]:
1597
+ if not os.path.lexists(path):
1598
+ return None, None
1599
+ if path.is_symlink() or not path.is_dir() or path.resolve() != path:
1600
+ return None, f"sidecar path is not an owned worktree: {path}"
1601
+ try:
1602
+ if worktree_root(path) != path:
1603
+ return None, f"sidecar path is not its Git root: {path}"
1604
+ if common_git_dir(path) != common_git_dir(primary):
1605
+ return None, f"sidecar belongs to another repository: {path}"
1606
+ branch = require_attached(path)
1607
+ if branch != expected_branch:
1608
+ return None, f"sidecar is on {branch}, expected {expected_branch}"
1609
+ return {
1610
+ "path": str(path),
1611
+ "branch": branch,
1612
+ "clean": not git_output(
1613
+ path, "status", "--porcelain", "--untracked-files=all"
1614
+ ),
1615
+ }, None
1616
+ except IsolationError as error:
1617
+ return None, f"invalid sidecar worktree {path}: {error}"
1618
+
1619
+
1620
+ def _retained_task_contract_error(
1621
+ current_text: str, isolate_text: str
1622
+ ) -> Optional[str]:
1623
+ try:
1624
+ current_head, current_body = split_frontmatter(current_text)
1625
+ isolate_head, isolate_body = split_frontmatter(isolate_text)
1626
+ except IsolationError as error:
1627
+ return str(error)
1628
+ if _immutable_frontmatter(isolate_head) != _immutable_frontmatter(current_head):
1629
+ return "retained task contract differs from the current task"
1630
+ if not _normalize_newlines(isolate_body).startswith(
1631
+ _normalize_newlines(current_body)
1632
+ ):
1633
+ return "retained task body does not extend the current task"
1634
+ return None
1635
+
1636
+
1637
+ def _resume_task_error(
1638
+ current_text: str,
1639
+ isolate_text: str,
1640
+ task_id: str,
1641
+ title: str,
1642
+ base: str,
1643
+ worktree: str,
1644
+ task_branch: str,
1645
+ ) -> Optional[str]:
1646
+ isolate_head, _ = split_frontmatter(isolate_text)
1647
+ isolate_fields, fields_error = task_frontmatter(isolate_text)
1648
+ if fields_error or isolate_fields is None:
1649
+ return fields_error or "retained task frontmatter is unreadable"
1650
+ expected = {
1651
+ "id": task_id,
1652
+ "title": title,
1653
+ "status": "in-progress",
1654
+ "base": base,
1655
+ "task_branch": task_branch,
1656
+ }
1657
+ for key, value in expected.items():
1658
+ if (
1659
+ sum(_frontmatter_key(line) == key for line in isolate_head) != 1
1660
+ or isolate_fields.get(key) != value
1661
+ ):
1662
+ return f"retained task has invalid {key}"
1663
+ recorded_worktree = isolate_fields.get("worktree")
1664
+ if (
1665
+ sum(_frontmatter_key(line) == "worktree" for line in isolate_head) != 1
1666
+ or not isinstance(recorded_worktree, str)
1667
+ or Path(recorded_worktree).resolve() != Path(worktree).resolve()
1668
+ ):
1669
+ return "retained task has invalid worktree"
1670
+ agent = isolate_fields.get("agent")
1671
+ if (
1672
+ sum(_frontmatter_key(line) == "agent" for line in isolate_head) != 1
1673
+ or not isinstance(agent, str)
1674
+ or agent in {"", "null"}
1675
+ ):
1676
+ return "retained task has no assigned agent"
1677
+ return _retained_task_contract_error(current_text, isolate_text)
1678
+
1679
+
1680
+ def _recover_task(
1681
+ primary: Path,
1682
+ path: Path,
1683
+ history: Dict[str, list[tuple[str, str]]],
1684
+ branches: Set[str],
1685
+ head: str,
1686
+ bound: str,
1687
+ canonical_task_file: Optional[str] = None,
1688
+ ) -> Dict[str, object]:
1689
+ task_file = canonical_task_file or relative_posix(str(path.relative_to(primary)))
1690
+ report: Dict[str, object] = {"task": task_file}
1691
+
1692
+ def result(verdict: str, **extra: object) -> Dict[str, object]:
1693
+ return {**report, "verdict": verdict, **extra}
1694
+
1695
+ try:
1696
+ task_text, _ = _read_task_text(path)
1697
+ except IsolationError as error:
1698
+ return result("block", reason=str(error))
1699
+ fields, error = task_frontmatter(task_text)
1700
+ if error or fields is None:
1701
+ return result("block", reason=error or "unreadable frontmatter")
1702
+ task_id, status, title = (str(fields.get(key, "")) for key in ("id", "status", "title"))
1703
+ report.update({"task_id": task_id, "status": status})
1704
+ try:
1705
+ validate_task_id(task_id)
1706
+ subject = task_commit_subject(task_id, title)
1707
+ except ValueError as error:
1708
+ return result("block", reason=str(error))
1709
+ except IsolationError as exc:
1710
+ return result("block", reason=str(exc))
1711
+ task_branch = task_branch_name(task_id)
1712
+ branch_exists = task_branch in branches
1713
+ worktree, worktree_error = _worktree_report(
1714
+ primary, sidecar_root(primary, "task", task_id), task_branch
1715
+ )
1716
+ report["worktree"] = worktree
1717
+ report["task_branch"] = task_branch if branch_exists else None
1718
+ report["rejected"] = []
1719
+
1720
+ if worktree_error:
1721
+ return result("block", reason=worktree_error)
1722
+
1723
+ valid_statuses = {"pending", "in-progress", "done", "failed", "blocked"}
1724
+ if status not in valid_statuses:
1725
+ return result("block", reason=f"unknown task status: {status!r}")
1726
+
1727
+ retained = branch_exists or report["worktree"] is not None
1728
+
1729
+ if status in {"pending", "in-progress"}:
1730
+ if status == "pending" and not retained:
1731
+ return result("none", reason="pending task has no retained isolate")
1732
+ if retained:
1733
+ if not branch_exists:
1734
+ return result("block", reason="sidecar worktree has no retained branch")
1735
+ try:
1736
+ base = git_output(primary, "merge-base", head, task_branch)
1737
+ branch_head = require_commit(primary, task_branch)
1738
+ except IsolationError as error:
1739
+ return result("block", reason=f"{status} task has invalid base: {error}")
1740
+ if worktree is None:
1741
+ if branch_head != base:
1742
+ return result("block", reason="retained task branch advanced past its base")
1743
+ return result("resume", base=base)
1744
+ sidecar = Path(str(worktree["path"]))
1745
+ isolate_path = sidecar / task_file
1746
+ try:
1747
+ isolate_text, _ = _read_task_text(isolate_path)
1748
+ except IsolationError as error:
1749
+ return result("block", reason=str(error))
1750
+ isolate_fields, isolate_error = task_frontmatter(isolate_text)
1751
+ if isolate_error or isolate_fields is None:
1752
+ return result(
1753
+ "block",
1754
+ reason=isolate_error or "retained task frontmatter is unreadable",
1755
+ )
1756
+ if status == "pending" and branch_head == base and bool(worktree["clean"]):
1757
+ try:
1758
+ base_oid = _regular_blob_oid(primary, base, task_file)
1759
+ current_oid = _clean_blob_oid(
1760
+ primary, task_file, task_text, write=False
1761
+ )
1762
+ isolate_oid = _clean_blob_oid(
1763
+ sidecar, task_file, isolate_text, write=False
1764
+ )
1765
+ except IsolationError as error:
1766
+ return result("block", reason=str(error))
1767
+ if current_oid == base_oid and isolate_oid == base_oid:
1768
+ return result(
1769
+ "resume",
1770
+ reason="task isolation was created; retry dispatch",
1771
+ dispatch_retry=True,
1772
+ base=base,
1773
+ )
1774
+ if isolate_fields.get("status") == "done":
1775
+ contract_error = _retained_task_contract_error(
1776
+ task_text, isolate_text
1777
+ )
1778
+ if contract_error:
1779
+ return result("block", reason=contract_error)
1780
+ if branch_head == base:
1781
+ retry_error = _landing_retry_error(
1782
+ sidecar,
1783
+ base,
1784
+ task_file,
1785
+ isolate_text,
1786
+ subject,
1787
+ )
1788
+ source_commit = None
1789
+ else:
1790
+ source_commit = branch_head
1791
+ try:
1792
+ parent, parent_error = _single_parent(primary, branch_head)
1793
+ if parent_error or parent != base:
1794
+ retry_error = parent_error or (
1795
+ "retained task commit parent differs from its base"
1796
+ )
1797
+ elif not bool(worktree["clean"]):
1798
+ retry_error = (
1799
+ "retained task commit has uncommitted changes"
1800
+ )
1801
+ else:
1802
+ body = git_output(
1803
+ primary, "log", "-1", "--format=%b", branch_head
1804
+ )
1805
+ _, retry_error = _prove_task_commit(
1806
+ primary,
1807
+ branch_head,
1808
+ body,
1809
+ task_file,
1810
+ base,
1811
+ None,
1812
+ )
1813
+ if retry_error is None:
1814
+ committed_oid = _regular_blob_oid(
1815
+ primary, branch_head, task_file
1816
+ )
1817
+ checkout_oid = _clean_blob_oid(
1818
+ sidecar,
1819
+ task_file,
1820
+ isolate_text,
1821
+ write=False,
1822
+ )
1823
+ if committed_oid != checkout_oid:
1824
+ retry_error = (
1825
+ "retained task differs from its commit"
1826
+ )
1827
+ except IsolationError as error:
1828
+ retry_error = str(error)
1829
+ if retry_error:
1830
+ return result("block", reason=retry_error)
1831
+ return result(
1832
+ "resume",
1833
+ reason="landing was interrupted; retry land without redispatch",
1834
+ landing_retry=True,
1835
+ source_commit=source_commit,
1836
+ base=base,
1837
+ )
1838
+ if branch_head != base:
1839
+ return result("block", reason="retained task branch advanced past its base")
1840
+ resume_error = _resume_task_error(
1841
+ task_text,
1842
+ isolate_text,
1843
+ task_id,
1844
+ title,
1845
+ base,
1846
+ str(sidecar),
1847
+ task_branch,
1848
+ )
1849
+ else:
1850
+ try:
1851
+ base = require_commit(
1852
+ primary, require_full_sha(str(fields.get("base", "")))
1853
+ )
1854
+ except IsolationError as error:
1855
+ return result("block", reason=f"{status} task has invalid base: {error}")
1856
+ if head != base:
1857
+ return result("block", reason=f"{status} serial task advanced past its base")
1858
+ primary_worktree, primary_error = _worktree_report(primary, primary, bound)
1859
+ if primary_error:
1860
+ return result("block", reason=primary_error)
1861
+ report["worktree"] = primary_worktree
1862
+ resume_error = _resume_task_error(
1863
+ task_text,
1864
+ task_text,
1865
+ task_id,
1866
+ title,
1867
+ base,
1868
+ str(primary),
1869
+ "null",
1870
+ )
1871
+ if resume_error:
1872
+ return result("block", reason=resume_error)
1873
+ return result("resume", base=base)
1874
+
1875
+ if status in {"failed", "blocked"}:
1876
+ recorded_worktree = str(fields.get("worktree", ""))
1877
+ serial_retained = not retained and recorded_worktree == str(primary)
1878
+ if not retained and not serial_retained:
1879
+ return result("none", reason=f"{status} task has no retained isolate")
1880
+ try:
1881
+ base = require_commit(
1882
+ primary, require_full_sha(str(fields.get("base", "")))
1883
+ )
1884
+ except IsolationError as error:
1885
+ return result("block", reason=f"{status} task has invalid base: {error}")
1886
+ if retained:
1887
+ expected_worktree = str(sidecar_root(primary, "task", task_id))
1888
+ if str(fields.get("worktree", "")) != expected_worktree:
1889
+ return result("block", reason=f"{status} task records another worktree")
1890
+ if str(fields.get("task_branch", "")) != task_branch:
1891
+ return result("block", reason=f"{status} task records another branch")
1892
+ if branch_exists and git_output(primary, "merge-base", head, task_branch) != base:
1893
+ return result(
1894
+ "block", reason=f"{status} task branch differs from its recorded base"
1895
+ )
1896
+ if serial_retained:
1897
+ if str(fields.get("task_branch", "")) != "null":
1898
+ return result("block", reason=f"{status} serial task records a task branch")
1899
+ if run_git(primary, "merge-base", "--is-ancestor", base, head).returncode != 0:
1900
+ return result("block", reason=f"{status} serial task base is not an ancestor")
1901
+ primary_worktree, primary_error = _worktree_report(primary, primary, bound)
1902
+ if primary_error:
1903
+ return result("block", reason=primary_error)
1904
+ report["worktree"] = primary_worktree
1905
+ return result("reconcile", base=base)
1906
+
1907
+ recorded_base = str(fields.get("base", ""))
1908
+ try:
1909
+ recorded_base = require_commit(primary, require_full_sha(recorded_base))
1910
+ except IsolationError as error:
1911
+ return result("block", reason=f"done task has invalid base: {error}")
1912
+
1913
+ proven, rejected = [], []
1914
+ candidates = history.get(subject, [])
1915
+ for sha, body in candidates:
1916
+ base, why = _prove_task_commit(
1917
+ primary,
1918
+ sha,
1919
+ body,
1920
+ task_file,
1921
+ recorded_base,
1922
+ task_text,
1923
+ compare_head=canonical_task_file is None,
1924
+ )
1925
+ if why is None:
1926
+ proven.append((sha, base))
1927
+ else:
1928
+ rejected.append({"commit": sha, "reason": why})
1929
+ report["rejected"] = rejected
1930
+ if len(proven) > 1:
1931
+ return result("block", reason="multiple proven landing commits: " + ", ".join(sha for sha, _ in proven))
1932
+ attested = []
1933
+ for sha, body in history.get(attest_commit_subject(task_id, title), []):
1934
+ base, why = _prove_attest_commit(
1935
+ primary, sha, body, task_file, task_text, compare_head=canonical_task_file is None
1936
+ )
1937
+ if why is None:
1938
+ attested.append((sha, base))
1939
+ else:
1940
+ rejected.append({"commit": sha, "reason": why})
1941
+ if proven and attested:
1942
+ return result("block", reason="task has both a landing commit and an attestation")
1943
+ if len(attested) > 1:
1944
+ return result("block", reason="multiple attestations: " + ", ".join(sha for sha, _ in attested))
1945
+ if attested:
1946
+ commit, base = attested[0]
1947
+ if worktree is not None and not bool(worktree["clean"]):
1948
+ return result("block", reason="attested task retains a dirty worktree", commit=commit, base=base)
1949
+ return result("attested", commit=commit, base=base)
1950
+ if proven:
1951
+ commit, base = proven[0]
1952
+ if branch_exists:
1953
+ branch_error = _task_branch_proof_error(primary, task_branch, base, commit)
1954
+ if branch_error:
1955
+ return result("block", reason=branch_error)
1956
+ if worktree is not None and not bool(worktree["clean"]):
1957
+ return result(
1958
+ "block",
1959
+ reason="proven task retains a dirty worktree",
1960
+ commit=commit,
1961
+ base=base,
1962
+ )
1963
+ return result("recovered", commit=commit, base=base)
1964
+ if not retained and len(candidates) == 1:
1965
+ landing = candidates[0][0]
1966
+ project_root = path.parent.parent # .project or .project/archive/<n>
1967
+ try:
1968
+ rebased_base = _prove_rebase_adoption(
1969
+ primary, project_root, task_file, head, landing, task_text
1970
+ )
1971
+ except IsolationError as error:
1972
+ return result("block", reason=f"rebase adoption receipt is invalid: {error}")
1973
+ if rebased_base is not None:
1974
+ return result(
1975
+ "attested",
1976
+ commit=landing,
1977
+ base=rebased_base,
1978
+ provenance="owner-authorized-rebase",
1979
+ )
1980
+ if not retained and head == recorded_base:
1981
+ retry_error = _landing_retry_error(
1982
+ primary, recorded_base, task_file, task_text, subject
1983
+ )
1984
+ if retry_error is None:
1985
+ primary_worktree, primary_error = _worktree_report(
1986
+ primary, primary, bound
1987
+ )
1988
+ if primary_error:
1989
+ return result("block", reason=primary_error)
1990
+ report["worktree"] = primary_worktree
1991
+ return result(
1992
+ "resume",
1993
+ reason="landing was interrupted; retry land without redispatch",
1994
+ landing_retry=True,
1995
+ source_commit=None,
1996
+ base=recorded_base,
1997
+ )
1998
+ return result("block", reason="done but no landing commit proves it")
1999
+
2000
+
2001
+ def _recovery_task_inventory(
2002
+ primary: Path, tasks_dir: Path, head: str
2003
+ ) -> tuple[list[Path], Optional[str]]:
2004
+ requested = tasks_dir if tasks_dir.is_absolute() else primary / tasks_dir
2005
+ if os.path.lexists(requested) and not requested.is_dir():
2006
+ raise IsolationError(f"tasks directory is not a directory: {requested}")
2007
+ resolved = requested.resolve()
2008
+ if resolved != requested.absolute():
2009
+ raise IsolationError(f"tasks directory must not use symlinks: {requested}")
2010
+ try:
2011
+ relative_dir = resolved.relative_to(primary)
2012
+ except ValueError as error:
2013
+ raise IsolationError(
2014
+ f"tasks directory is outside the primary worktree: {resolved}"
2015
+ ) from error
2016
+ relative_posix_dir = PurePosixPath(relative_dir.as_posix())
2017
+ revisions = [head]
2018
+ parents = git_output(primary, "show", "-s", "--format=%P", head).split()
2019
+ if parents:
2020
+ revisions.append(parents[0])
2021
+ tracked = set()
2022
+ for revision in revisions:
2023
+ tracked.update(
2024
+ path
2025
+ for path in git_text(
2026
+ primary,
2027
+ "ls-tree",
2028
+ "-r",
2029
+ "--name-only",
2030
+ "-z",
2031
+ revision,
2032
+ "--",
2033
+ relative_posix_dir.as_posix(),
2034
+ ).split("\0")
2035
+ if path
2036
+ and PurePosixPath(path).parent == relative_posix_dir
2037
+ and PurePosixPath(path).suffix == ".md"
2038
+ )
2039
+ task_paths = sorted(resolved.glob("*.md")) if resolved.is_dir() else []
2040
+ live = {path.relative_to(primary).as_posix() for path in task_paths}
2041
+ details = []
2042
+ missing = sorted(tracked - live)
2043
+ unexpected = sorted(live - tracked)
2044
+ if missing:
2045
+ details.append("missing " + ", ".join(missing))
2046
+ if unexpected:
2047
+ details.append("unexpected " + ", ".join(unexpected))
2048
+ return task_paths, "; ".join(details) or None
2049
+
2050
+
2051
+ def _first_parent_records(repo: Path, revision: str) -> list[tuple[str, str, str]]:
2052
+ """First-parent commits of `revision` as (sha, subject, stripped body) in log order."""
2053
+ log = git_output(repo, "log", "--first-parent", "--format=%x1e%H%x00%s%x00%b", revision)
2054
+ records = []
2055
+ for record in filter(None, log.split("\x1e")):
2056
+ sha, subject, body = record.split("\x00", 2)
2057
+ records.append((sha, subject, body.strip()))
2058
+ return records
2059
+
2060
+
2061
+ def _first_parent_history(repo: Path, revision: str) -> Dict[str, list[tuple[str, str]]]:
2062
+ """Map each first-parent commit subject to its [(sha, body)] in log order."""
2063
+ history: Dict[str, list[tuple[str, str]]] = {}
2064
+ for sha, subject, body in _first_parent_records(repo, revision):
2065
+ history.setdefault(subject, []).append((sha, body))
2066
+ return history
2067
+
2068
+
2069
+ def recover(primary: Path, tasks_dir: Path) -> Dict[str, object]:
2070
+ """Prove each task's landing state from git. Read-only.
2071
+
2072
+ Verdicts: `recovered` (exactly one proven landing commit; `commit` and
2073
+ `base` returned), `resume` (pending, in progress, or an exact interrupted
2074
+ landing), `reconcile` (a failed or blocked task retains isolation), `block`
2075
+ (missing or conflicting proof), and `none` (nothing to recover). Retained
2076
+ `task_branch` and worktree state are returned. Verify is not rerun: a proven
2077
+ landing commit is its own evidence.
2078
+ """
2079
+ primary = require_directory(primary, "primary worktree")
2080
+ if worktree_root(primary) != primary:
2081
+ raise IsolationError(f"primary is not its Git root: {primary}")
2082
+ bound = require_attached(primary)
2083
+ head = current_sha(primary)
2084
+ task_paths, inventory_error = _recovery_task_inventory(primary, tasks_dir, head)
2085
+ if inventory_error:
2086
+ return {
2087
+ "bound_branch": bound,
2088
+ "tasks": [],
2089
+ "verdict": "block",
2090
+ "reason": "task inventory differs from trusted Git state: "
2091
+ + inventory_error,
2092
+ }
2093
+ history = _first_parent_history(primary, bound)
2094
+ branches = set(
2095
+ git_output(primary, "for-each-ref", "--format=%(refname:short)", f"refs/heads/{TASK_BRANCH_PREFIX}").splitlines()
2096
+ )
2097
+ tasks = [
2098
+ _recover_task(primary, path, history, branches, head, bound)
2099
+ for path in task_paths
2100
+ ]
2101
+ return {
2102
+ "bound_branch": bound,
2103
+ "tasks": tasks,
2104
+ "verdict": "block" if any(t["verdict"] == "block" for t in tasks) else "ok",
2105
+ }
2106
+
2107
+
2108
+ def verify_landed_task_files(
2109
+ primary: Path,
2110
+ task_paths: Sequence[Path],
2111
+ canonical_tasks_dir: str,
2112
+ head: str,
2113
+ ) -> Dict[str, object]:
2114
+ """Prove archived task files against their canonical landing commits."""
2115
+
2116
+ primary = require_directory(primary, "primary worktree")
2117
+ if worktree_root(primary) != primary:
2118
+ raise IsolationError(f"primary is not its Git root: {primary}")
2119
+ bound = require_attached(primary)
2120
+ resolved_head = require_commit(primary, require_full_sha(head))
2121
+ canonical_dir = relative_posix(canonical_tasks_dir)
2122
+ history = _first_parent_history(primary, resolved_head)
2123
+ branches = set(
2124
+ git_output(
2125
+ primary,
2126
+ "for-each-ref",
2127
+ "--format=%(refname:short)",
2128
+ f"refs/heads/{TASK_BRANCH_PREFIX}",
2129
+ ).splitlines()
2130
+ )
2131
+ tasks = []
2132
+ for path in task_paths:
2133
+ resolved = path.resolve()
2134
+ if path.is_symlink() or not path.is_file():
2135
+ raise IsolationError(f"task artifact is missing or unsafe: {path}")
2136
+ try:
2137
+ resolved.relative_to(primary)
2138
+ except ValueError as error:
2139
+ raise IsolationError(f"task artifact is outside the primary: {path}") from error
2140
+ canonical = relative_posix(f"{canonical_dir}/{path.name}")
2141
+ task_text, _ = _read_task_text(resolved)
2142
+ fields, error = task_frontmatter(task_text)
2143
+ if error or fields is None:
2144
+ raise IsolationError(error or f"unreadable task frontmatter: {path.name}")
2145
+ if fields.get("status") != "done":
2146
+ raise IsolationError(
2147
+ f"{fields.get('id', path.name)}: task must have status: done"
2148
+ )
2149
+ tasks.append(
2150
+ _recover_task(
2151
+ primary,
2152
+ resolved,
2153
+ history,
2154
+ branches,
2155
+ resolved_head,
2156
+ bound,
2157
+ canonical,
2158
+ )
2159
+ )
2160
+ blocked = [task for task in tasks if task["verdict"] not in LANDED_VERDICTS]
2161
+ if blocked:
2162
+ def failure_reason(task: Dict[str, object]) -> str:
2163
+ rejected = task.get("rejected")
2164
+ if isinstance(rejected, list) and rejected:
2165
+ details = ", ".join(
2166
+ str(item.get("reason", "rejected"))
2167
+ for item in rejected
2168
+ if isinstance(item, dict)
2169
+ )
2170
+ if details:
2171
+ return details
2172
+ return str(task.get("reason", task["verdict"]))
2173
+
2174
+ reasons = "; ".join(
2175
+ f"{task.get('task_id', task['task'])}: {failure_reason(task)}"
2176
+ for task in blocked
2177
+ )
2178
+ raise IsolationError(reasons)
2179
+ return {"bound_branch": bound, "head": resolved_head, "tasks": tasks}
2180
+
2181
+
2182
+ def _sidecar_path_for_branch(primary: Path, branch: str) -> Path:
2183
+ if branch.startswith(TASK_BRANCH_PREFIX):
2184
+ task_id = branch.removeprefix(TASK_BRANCH_PREFIX)
2185
+ validate_task_id(task_id)
2186
+ return sidecar_root(primary, "task", task_id)
2187
+ if branch.startswith(VERIFY_BRANCH_PREFIX):
2188
+ name = branch.removeprefix(VERIFY_BRANCH_PREFIX)
2189
+ validate_name(name, "verify name")
2190
+ return sidecar_root(primary, "verify", name)
2191
+ raise IsolationError(f"refusing to retire unrecognized branch {branch}")
2192
+
2193
+
2194
+ def _landing_base(repo: Path, commit: str) -> str:
2195
+ body = git_output(repo, "log", "-1", "--format=%b", commit)
2196
+ values = [
2197
+ line.removeprefix("Base: ").strip()
2198
+ for line in body.splitlines()
2199
+ if line.startswith("Base: ")
2200
+ ]
2201
+ if len(values) != 1:
2202
+ raise IsolationError("landing commit body must contain one Base: field")
2203
+ return require_commit(repo, require_full_sha(values[0]))
2204
+
2205
+
2206
+ def _require_first_parent_commit(repo: Path, bound: str, commit: str) -> None:
2207
+ history = git_output(repo, "rev-list", "--first-parent", bound).splitlines()
2208
+ if commit not in history:
2209
+ raise IsolationError("landing commit is not on the bound first-parent history")
2210
+
2211
+
2212
+ def _failed_task_branch_proof_error(
2213
+ repo: Path, branch: str, task_file: str
2214
+ ) -> Optional[str]:
2215
+ task_file = relative_posix(task_file)
2216
+ task_path = repo / task_file
2217
+ try:
2218
+ task_text, _ = _read_task_text(task_path)
2219
+ except IsolationError as error:
2220
+ return str(error)
2221
+ fields, fields_error = task_frontmatter(task_text)
2222
+ if fields_error or fields is None:
2223
+ return fields_error or "failed task frontmatter is unreadable"
2224
+ task_id = str(fields.get("id", ""))
2225
+ try:
2226
+ if task_branch_name(task_id) != branch:
2227
+ return "failed task id does not own the retained branch"
2228
+ except IsolationError as error:
2229
+ return str(error)
2230
+ if fields.get("status") not in {"failed", "blocked"}:
2231
+ return "forced rejected-branch retirement requires failed or blocked task state"
2232
+ if fields.get("task_branch") != branch:
2233
+ return "failed task does not record the retained branch"
2234
+ if fields.get("worktree") != str(sidecar_root(repo, "task", task_id)):
2235
+ return "failed task does not record the retained worktree"
2236
+ try:
2237
+ base = require_commit(repo, require_full_sha(str(fields.get("base", ""))))
2238
+ branch_commit = require_commit(repo, branch)
2239
+ except IsolationError as error:
2240
+ return str(error)
2241
+ parent, parent_error = _single_parent(repo, branch_commit)
2242
+ if parent_error or parent != base:
2243
+ return parent_error or "retained rejected branch parent differs from its recorded base"
2244
+ body = git_output(repo, "log", "-1", "--format=%b", branch_commit)
2245
+ _, proof_error = _prove_task_commit(
2246
+ repo, branch_commit, body, task_file, base, None
2247
+ )
2248
+ return proof_error
2249
+ def collect_artifact(
2250
+ primary: Path,
2251
+ source: Path,
2252
+ base: str,
2253
+ branch: str,
2254
+ source_path: str,
2255
+ destination_path: str,
2256
+ expected_destination: Optional[str] = None,
2257
+ ) -> Dict[str, object]:
2258
+ """Atomically collect one artifact and retain an idempotent receipt."""
2259
+
2260
+ primary = require_directory(primary, "primary worktree")
2261
+ source = require_directory(source, "source worktree")
2262
+ if worktree_root(primary) != primary or worktree_root(source) != source:
2263
+ raise IsolationError("primary and source must each be their Git root")
2264
+ if common_git_dir(primary) != common_git_dir(source):
2265
+ raise IsolationError("source worktree belongs to another repository")
2266
+ resolved_base = require_commit(primary, require_full_sha(base))
2267
+ if current_sha(primary) != resolved_base:
2268
+ raise IsolationError("primary worktree HEAD differs from the recorded base")
2269
+ if current_sha(source) != resolved_base:
2270
+ raise IsolationError("source worktree HEAD differs from the recorded base")
2271
+ if not branch.startswith(VERIFY_BRANCH_PREFIX) or require_attached(source) != branch:
2272
+ raise IsolationError("source worktree is not on the expected verify branch")
2273
+ normalized_source = relative_posix(source_path)
2274
+ normalized_destination = relative_posix(destination_path)
2275
+ destination = _safe_destination(primary, normalized_destination)
2276
+ request: Dict[str, object] = {
2277
+ "schema": COLLECT_JOURNAL_SCHEMA,
2278
+ "primary_worktree": str(primary),
2279
+ "source_worktree": str(source),
2280
+ "base": resolved_base,
2281
+ "branch": branch,
2282
+ "source": normalized_source,
2283
+ "destination": normalized_destination,
2284
+ "expected_destination": expected_destination,
2285
+ }
2286
+ journal_path = _collect_journal_path(primary, request)
2287
+ changed = uncommitted_paths(source)
2288
+ journal: Optional[Dict[str, object]] = None
2289
+ if journal_path.exists() or journal_path.is_symlink():
2290
+ journal = _validated_collect_journal(primary, journal_path)
2291
+ if any(
2292
+ journal.get(key) != value for key, value in request.items()
2293
+ ):
2294
+ raise IsolationError("artifact collection journal does not match request")
2295
+ if journal["stage"] == "complete" and changed == {normalized_source}:
2296
+ journal = None # a later sidecar run reuses the same branch and paths
2297
+
2298
+ if journal is None:
2299
+ if changed != {normalized_source}:
2300
+ unexpected = sorted(changed - {normalized_source})
2301
+ detail = ", ".join(unexpected) if unexpected else "expected artifact is unchanged"
2302
+ raise IsolationError(f"unexpected sidecar paths: {detail}")
2303
+ source_file = _real_file(source, normalized_source, "artifact source")
2304
+ data = source_file.read_bytes()
2305
+ previous_data = destination.read_bytes() if destination.exists() else None
2306
+ if previous_data is not None and previous_data != data:
2307
+ actual_hash = _sha256(previous_data)
2308
+ if expected_destination == "base":
2309
+ expected_hash = _sha256(
2310
+ _git_file(primary, resolved_base, normalized_destination)
2311
+ )
2312
+ elif expected_destination and re.fullmatch(
2313
+ r"[0-9a-f]{64}", expected_destination
2314
+ ):
2315
+ expected_hash = expected_destination
2316
+ else:
2317
+ raise IsolationError(
2318
+ "artifact destination has different content without a proven expected version"
2319
+ )
2320
+ if actual_hash != expected_hash:
2321
+ raise IsolationError(
2322
+ "artifact destination changed after its expected version was recorded"
2323
+ )
2324
+ journal = {
2325
+ **request,
2326
+ "bytes": len(data),
2327
+ "previous_sha256": _sha256(previous_data) if previous_data is not None else None,
2328
+ "replaced": previous_data is not None and previous_data != data,
2329
+ "sha256": _sha256(data),
2330
+ "stage": "prepared",
2331
+ }
2332
+ _write_collect_journal(journal_path, journal)
2333
+ else:
2334
+ current_hash = _sha256(destination.read_bytes()) if destination.exists() else None
2335
+ if not changed:
2336
+ if current_hash != journal["sha256"]:
2337
+ raise IsolationError(
2338
+ "artifact receipt exists but the collected destination changed"
2339
+ )
2340
+ journal["stage"] = "complete"
2341
+ _write_collect_journal(journal_path, journal)
2342
+ return {
2343
+ key: journal[key]
2344
+ for key in (
2345
+ "base",
2346
+ "branch",
2347
+ "bytes",
2348
+ "destination",
2349
+ "previous_sha256",
2350
+ "replaced",
2351
+ "sha256",
2352
+ "source",
2353
+ )
2354
+ }
2355
+ if changed != {normalized_source}:
2356
+ unexpected = sorted(changed - {normalized_source})
2357
+ raise IsolationError(
2358
+ "unexpected sidecar paths: " + ", ".join(unexpected)
2359
+ )
2360
+ source_file = _real_file(source, normalized_source, "artifact source")
2361
+ data = source_file.read_bytes()
2362
+ if _sha256(data) != journal["sha256"]:
2363
+ raise IsolationError("artifact source changed after collection was prepared")
2364
+ if current_hash not in {journal["previous_sha256"], journal["sha256"]}:
2365
+ raise IsolationError(
2366
+ "artifact destination changed after collection was prepared"
2367
+ )
2368
+
2369
+ destination.parent.mkdir(parents=True, exist_ok=True)
2370
+ if not destination.exists() or _sha256(destination.read_bytes()) != journal["sha256"]:
2371
+ temporary_name: Optional[str] = None
2372
+ try:
2373
+ with tempfile.NamedTemporaryFile(
2374
+ dir=destination.parent,
2375
+ prefix=".gsd-path-artifact-",
2376
+ delete=False,
2377
+ ) as handle:
2378
+ handle.write(data)
2379
+ handle.flush()
2380
+ os.fsync(handle.fileno())
2381
+ temporary_name = handle.name
2382
+ os.replace(temporary_name, destination)
2383
+ temporary_name = None
2384
+ finally:
2385
+ if temporary_name:
2386
+ Path(temporary_name).unlink(missing_ok=True)
2387
+ if _sha256(destination.read_bytes()) != journal["sha256"]:
2388
+ raise IsolationError("artifact destination does not match the prepared receipt")
2389
+ journal["stage"] = "collected"
2390
+ _write_collect_journal(journal_path, journal)
2391
+
2392
+ reset = run_git(source, "reset", "-q", "HEAD", "--", normalized_source)
2393
+ if reset.returncode != 0:
2394
+ raise IsolationError("artifact was collected but its staged state could not be cleared")
2395
+ tracked = run_git(source, "ls-files", "--error-unmatch", "--", normalized_source)
2396
+ if tracked.returncode == 0:
2397
+ restored = run_git(source, "restore", "--worktree", "--", normalized_source)
2398
+ if restored.returncode != 0:
2399
+ raise IsolationError("artifact was collected but the source could not be restored")
2400
+ else:
2401
+ source_file.unlink()
2402
+ if uncommitted_paths(source):
2403
+ raise IsolationError("artifact was collected but the source worktree is still dirty")
2404
+ journal["stage"] = "complete"
2405
+ _write_collect_journal(journal_path, journal)
2406
+ return {
2407
+ key: journal[key]
2408
+ for key in (
2409
+ "base",
2410
+ "branch",
2411
+ "bytes",
2412
+ "destination",
2413
+ "previous_sha256",
2414
+ "replaced",
2415
+ "sha256",
2416
+ "source",
2417
+ )
2418
+ }
2419
+
2420
+
2421
+ def _validate_checkpoint_message(subject: str, body: str) -> None:
2422
+ lines = [line.strip() for line in body.splitlines() if line.strip()]
2423
+ if subject == "roadmap: program roadmap approved":
2424
+ if lines != ["Why: approved roadmap checkpoint"]:
2425
+ raise IsolationError("roadmap checkpoint body is not canonical")
2426
+ return
2427
+ if subject == "plan: build plan approved":
2428
+ if (
2429
+ len(lines) != 2
2430
+ or lines[0] != "Why: approved plan checkpoint"
2431
+ or not re.fullmatch(r"Milestone: \S+", lines[1])
2432
+ ):
2433
+ raise IsolationError("plan checkpoint body is not canonical")
2434
+ return
2435
+ if re.fullmatch(r"build: abandon milestone [a-z0-9][a-z0-9-]*", subject):
2436
+ if len(lines) != 1 or not lines[0].startswith("Why: "):
2437
+ raise IsolationError("abandon checkpoint body must contain one Why line")
2438
+ reason = lines[0][len("Why: ") :]
2439
+ if not reason or reason != " ".join(reason.split()):
2440
+ raise IsolationError("abandon checkpoint reason must be normalized")
2441
+ return
2442
+ if re.fullmatch(r"build: \S.*", subject):
2443
+ if not lines or not re.fullmatch(r"Why: \S.*", lines[0]):
2444
+ raise IsolationError("build checkpoint body must start with Why")
2445
+ seen = set()
2446
+ for line in lines[1:]:
2447
+ match = re.fullmatch(r"(Wave|Tasks|Base): \S.*", line)
2448
+ if not match or match.group(1) in seen:
2449
+ raise IsolationError("build checkpoint body has an invalid field")
2450
+ seen.add(match.group(1))
2451
+ return
2452
+ raise IsolationError("unsupported pipeline checkpoint subject")
2453
+
2454
+
2455
+ task_verify_command = _common.task_verify_command
2456
+
2457
+
2458
+ def _ledger_pass(entries: Sequence[Dict[str, object]], command: str, commit: str) -> bool:
2459
+ entry = _common.latest_verify_entry(entries, command, commit)
2460
+ return entry is not None and entry["result"] == "pass"
2461
+
2462
+
2463
+ def _verify_ledger_pass(repo: Path, command: str, commit: str) -> bool:
2464
+ """True when the worktree verify ledger's latest run of command at commit passed."""
2465
+ path = repo.joinpath(*PurePosixPath(VERIFY_LEDGER_PATH).parts)
2466
+ if path.is_symlink():
2467
+ raise IsolationError(f"{VERIFY_LEDGER_PATH} must be a regular file")
2468
+ try:
2469
+ return _ledger_pass(_common.verify_ledger_entries(path), command, commit)
2470
+ except ValueError as error:
2471
+ raise IsolationError(str(error)) from error
2472
+
2473
+
2474
+ def _task_contract_error(base_text: str, text: str, label: str) -> Optional[str]:
2475
+ """Only landing metadata and an appended log may differ from the base task."""
2476
+ try:
2477
+ base_head, base_body = split_frontmatter(base_text)
2478
+ head, body = split_frontmatter(text)
2479
+ except IsolationError as error:
2480
+ return str(error)
2481
+ base_fields, _ = task_frontmatter(base_text)
2482
+ if base_fields is not None and base_fields.get("status") == "done":
2483
+ return "base task is already done"
2484
+ if _immutable_frontmatter(base_head) != _immutable_frontmatter(head) or not (
2485
+ _normalize_newlines(body).startswith(_normalize_newlines(base_body))
2486
+ ):
2487
+ return f"{label} changes the task contract recorded at its base"
2488
+ return None
2489
+
2490
+
2491
+ def task_log_delta(base_text: str, text: str) -> str:
2492
+ """The task body appended since its base; raises unless the task is an append-only extension."""
2493
+ error = _task_contract_error(base_text, text, "task")
2494
+ if error:
2495
+ raise IsolationError(error)
2496
+ base_body = _normalize_newlines(split_frontmatter(base_text)[1])
2497
+ return _normalize_newlines(split_frontmatter(text)[1])[len(base_body):]
2498
+
2499
+
2500
+ def _attest_body_fields(body: str) -> tuple[Dict[str, str], list[str]]:
2501
+ fields: Dict[str, str] = {}
2502
+ files: list[str] = []
2503
+ for line in body.splitlines():
2504
+ if line.startswith("- "):
2505
+ files.append(line[2:].strip())
2506
+ else:
2507
+ key, separator, value = line.partition(":")
2508
+ if separator and key in {"Task", "Base", "Head", "Verify", "Ruling", "Attestation", "Verify-JSON"}:
2509
+ fields[key] = value.strip()
2510
+ return fields, files
2511
+
2512
+
2513
+ def _attested_changes(repo: Path, base: str, head: str, declared: Set[str]) -> list[str]:
2514
+ """Declared paths that changed between base and head, matched literally like land."""
2515
+ changed = set(git_output(repo, "diff", "--name-only", base, head).splitlines())
2516
+ return sorted(changed & declared)
2517
+
2518
+
2519
+ def _prove_attest_commit(
2520
+ repo: Path,
2521
+ sha: str,
2522
+ body: str,
2523
+ task_file: str,
2524
+ task_text: str,
2525
+ compare_head: bool = True,
2526
+ ) -> tuple[Optional[str], Optional[str]]:
2527
+ """Return (base, None) when `sha` attests this task, else (None, reason)."""
2528
+ fields, files = _attest_body_fields(body)
2529
+ base, head = fields.get("Base", ""), fields.get("Head", "")
2530
+ if fields.get("Task") != task_file:
2531
+ return None, "body Task: differs from the task file"
2532
+ if not fields.get("Ruling"):
2533
+ return None, "attestation has no Ruling:"
2534
+ try:
2535
+ base = require_commit(repo, require_full_sha(base))
2536
+ head = require_commit(repo, require_full_sha(head))
2537
+ except IsolationError as error:
2538
+ return None, f"attestation Base:/Head: are invalid: {error}"
2539
+ parent, parent_error = _single_parent(repo, sha)
2540
+ if parent_error or parent != head:
2541
+ return None, parent_error or "attestation parent differs from its Head:"
2542
+ if run_git(repo, "merge-base", "--is-ancestor", base, head).returncode != 0:
2543
+ return None, "attestation Head: does not descend from its Base:"
2544
+ changed = set(git_output(repo, "diff-tree", "--no-commit-id", "--name-only", "-r", sha).splitlines())
2545
+ if task_file not in changed or not changed <= {task_file, VERIFY_LEDGER_PATH}:
2546
+ return None, "attestation commit must change only the task file and the verify ledger"
2547
+ try:
2548
+ before_text = git_text(repo, "show", f"{head}:{task_file}")
2549
+ after_text = git_text(repo, "show", f"{sha}:{task_file}")
2550
+ before_head, before_body = split_frontmatter(before_text)
2551
+ after_head, after_body = split_frontmatter(after_text)
2552
+ except IsolationError as error:
2553
+ return None, str(error)
2554
+ after_fields, after_error = task_frontmatter(after_text)
2555
+ if after_error or after_fields is None:
2556
+ return None, after_error or "attested task frontmatter is unreadable"
2557
+ if _immutable_frontmatter(before_head) != _immutable_frontmatter(after_head) or not after_body.startswith(before_body):
2558
+ return None, "attestation changes the task outside landing metadata and an appended log"
2559
+ for key, value in {**LANDED_FIELDS, "base": base}.items():
2560
+ if after_fields.get(key) != value:
2561
+ return None, f"attested task frontmatter has invalid {key}"
2562
+ agent = after_fields.get("agent")
2563
+ if not isinstance(agent, str) or agent in {"", "null"}:
2564
+ return None, "attested task frontmatter has no assigned agent"
2565
+ try:
2566
+ contract_error = _task_contract_error(
2567
+ git_text(repo, "show", f"{base}:{task_file}"), after_text, "attestation"
2568
+ )
2569
+ except IsolationError as error:
2570
+ return None, str(error)
2571
+ if contract_error:
2572
+ return None, contract_error
2573
+ verify = task_verify_command(after_text)
2574
+ legacy = "Attestation" not in fields
2575
+ if legacy:
2576
+ verify = " ".join(verify.split())
2577
+ recorded_verify = fields.get("Verify")
2578
+ else:
2579
+ if fields["Attestation"] != "gsd-path/attestation/v2":
2580
+ return None, "unknown attestation version"
2581
+ try:
2582
+ recorded_verify = json.loads(fields.get("Verify-JSON", ""))
2583
+ except json.JSONDecodeError:
2584
+ return None, "attestation Verify-JSON is invalid"
2585
+ if not verify.strip() or recorded_verify != verify:
2586
+ return None, "body Verify: differs from the task's ## Verify command"
2587
+ ledger = run_git(repo, "show", f"{sha}:{VERIFY_LEDGER_PATH}")
2588
+ if ledger.returncode != 0:
2589
+ return None, "attestation commit carries no verify ledger"
2590
+ try:
2591
+ entries = _common.parse_verify_ledger(ledger.stdout)
2592
+ if legacy:
2593
+ matches = [e for e in entries if e.get("schema") is None and e["command"] == verify and e["commit"] == head]
2594
+ passed = bool(matches) and matches[-1]["result"] == "pass"
2595
+ else:
2596
+ passed = _ledger_pass(entries, verify, head)
2597
+ if not passed:
2598
+ return None, "verify ledger in the attestation has no passing run at Head:"
2599
+ except ValueError as error:
2600
+ return None, str(error)
2601
+ declared, declared_error = _declared_task_paths(after_fields)
2602
+ if declared_error or declared is None:
2603
+ return None, declared_error or "attested task files are unreadable"
2604
+ expected_files = _attested_changes(repo, base, head, declared)
2605
+ if not expected_files:
2606
+ return None, "no declared file changed between Base: and Head:"
2607
+ if files != expected_files:
2608
+ return None, "body Files: does not match the declared changes between Base: and Head:"
2609
+ expected_body = attest_commit_body(
2610
+ task_file, base, head, expected_files, verify, fields["Ruling"], legacy=legacy
2611
+ )
2612
+ if body.strip() != expected_body.strip():
2613
+ return None, "attestation body is not canonical"
2614
+ try:
2615
+ current_oid = _clean_blob_oid(repo, task_file, task_text, write=False)
2616
+ after_oid = _regular_blob_oid(repo, sha, task_file)
2617
+ head_oid = _regular_blob_oid(repo, "HEAD", task_file) if compare_head else after_oid
2618
+ except IsolationError as error:
2619
+ return None, str(error)
2620
+ if current_oid != after_oid or head_oid != after_oid:
2621
+ return None, "current done task differs from its attestation"
2622
+ return base, None
2623
+
2624
+
2625
+ REBASE_ADOPTION_SCHEMA = "gsd-path/rebase-adoption/v1"
2626
+ REBASE_ADOPTION_RECEIPT = "build/rebase-adoption.json"
2627
+ REBASE_ADOPTION_PATH = ".project/" + REBASE_ADOPTION_RECEIPT
2628
+
2629
+
2630
+ def _one_landing(history: Dict[str, list[tuple[str, str]]], subject: str) -> tuple[str, str]:
2631
+ """The single (sha, body) landing for `subject`; adoption needs exactly one."""
2632
+ candidates = history.get(subject, [])
2633
+ if len(candidates) != 1:
2634
+ raise IsolationError("rebase adoption requires one landing: " + subject)
2635
+ return candidates[0]
2636
+
2637
+
2638
+ def _committed_task_paths(repo: Path, revision: str) -> list[str]:
2639
+ return git_text(repo, "ls-tree", "-r", "--name-only", revision, "--", ".project/tasks").splitlines()
2640
+
2641
+
2642
+ def _ordered_edits(repo: Path, sha: str) -> list[bytes]:
2643
+ """Every changed byte, path, mode and binary payload of `sha`; hunk
2644
+ coordinates and content-addressed index headers excluded."""
2645
+ result = subprocess.run(
2646
+ ("git", "-C", str(repo), "diff", "--binary", "--full-index", "--no-renames",
2647
+ "--unified=0", sha + "^", sha),
2648
+ capture_output=True,
2649
+ check=False,
2650
+ )
2651
+ if result.returncode != 0:
2652
+ detail = (result.stderr or result.stdout).decode("utf-8", errors="replace").strip()
2653
+ raise IsolationError(detail or "git command failed")
2654
+ return [line for line in io.BytesIO(result.stdout)
2655
+ if not line.startswith((b"index ", b"@@ "))]
2656
+
2657
+
2658
+ def _task_contract_section(text: str) -> str:
2659
+ """Everything before the task's `## Log`: the part adoption keeps immutable."""
2660
+ before, separator, _ = text.partition("\n## Log\n")
2661
+ if not separator:
2662
+ raise IsolationError("task is missing its Log boundary")
2663
+ return before
2664
+
2665
+
2666
+ def _task_proof(
2667
+ repo: Path,
2668
+ path: str,
2669
+ original: tuple[str, str],
2670
+ rebased: tuple[str, str],
2671
+ adopted: str,
2672
+ current_text: str,
2673
+ ) -> Dict[str, object]:
2674
+ """One receipt row: `rebased` replays the strictly proven `original` landing."""
2675
+ (original_sha, original_body), (rebased_sha, rebased_body) = original, rebased
2676
+ original_text = git_text(repo, "show", f"{original_sha}:{path}")
2677
+ fields, error = task_frontmatter(original_text)
2678
+ if error or fields is None:
2679
+ raise IsolationError(error or "invalid original task")
2680
+ base = str(fields.get("base", ""))
2681
+ _, error = _prove_task_commit(repo, original_sha, original_body, path, base, None)
2682
+ if error:
2683
+ raise IsolationError("original landing is invalid: " + error)
2684
+ parent, error = _single_parent(repo, rebased_sha)
2685
+ if error or parent is None:
2686
+ raise IsolationError(error or "invalid rebased parent")
2687
+ # Subjects already match: both landings were looked up under the same subject.
2688
+ if original_body != rebased_body:
2689
+ raise IsolationError("rebased landing message differs")
2690
+ if _ordered_edits(repo, original_sha) != _ordered_edits(repo, rebased_sha):
2691
+ raise IsolationError("rebased landing changes different bytes, paths or modes")
2692
+ if _regular_blob_oid(repo, original_sha, path) != _regular_blob_oid(repo, rebased_sha, path):
2693
+ raise IsolationError("rebased task differs from original landing")
2694
+ _regular_blob_oid(repo, adopted, path) # rejects a symlinked task at the adopted head
2695
+ if _task_contract_section(current_text) != _task_contract_section(original_text):
2696
+ raise IsolationError("adopted task contract differs from original landing")
2697
+ return {"path": path, "original_landing": original_sha, "rebased_landing": rebased_sha,
2698
+ "original_base": base, "original_task": original_text,
2699
+ "adopted_task": current_text,
2700
+ "context_changed": git_text(repo, "diff", "--binary", original_sha + "^", original_sha)
2701
+ != git_text(repo, "diff", "--binary", rebased_sha + "^", rebased_sha)}
2702
+
2703
+
2704
+ def adopt_rebase(primary: Path, original_head: str, head: str, ruling: str) -> Dict[str, object]:
2705
+ """Compute the owner-ruled receipt adopting every done task's rebased landing.
2706
+
2707
+ Adoption attests a pinned rebased revision, not behavioral equivalence or
2708
+ final acceptance: each done task needs its original canonical landing on
2709
+ `original_head`, one landing with identical message and ordered edits on
2710
+ `head`, and an unchanged task contract. Pending tasks are skipped; tasks in
2711
+ flight cannot be adopted.
2712
+ """
2713
+ if not ruling.strip():
2714
+ raise IsolationError("an explicit owner ruling is required")
2715
+ for sha in (original_head, head):
2716
+ require_commit(primary, require_full_sha(sha))
2717
+ if original_head == head:
2718
+ raise IsolationError("original and adopted heads must differ")
2719
+ old, new = _first_parent_history(primary, original_head), _first_parent_history(primary, head)
2720
+ paths = _committed_task_paths(primary, head)
2721
+ if not paths:
2722
+ raise IsolationError("no committed task inventory")
2723
+ tasks = []
2724
+ for path in paths:
2725
+ text = git_text(primary, "show", f"{head}:{path}")
2726
+ fields, error = task_frontmatter(text)
2727
+ if error or fields is None:
2728
+ raise IsolationError(f"{path}: {error or 'unreadable task frontmatter'}")
2729
+ status = str(fields.get("status", ""))
2730
+ if status == "pending":
2731
+ continue
2732
+ if status != "done":
2733
+ raise IsolationError(f"rebasing a {status} task is unsupported: {path}")
2734
+ subject = task_commit_subject(str(fields.get("id", "")), str(fields.get("title", "")))
2735
+ tasks.append(_task_proof(primary, path, _one_landing(old, subject), _one_landing(new, subject), head, text))
2736
+ return {"schema": REBASE_ADOPTION_SCHEMA, "original_head": original_head, "head": head,
2737
+ "ruling": ruling.strip(), "tasks": tasks}
2738
+
2739
+
2740
+ # Proof outcomes per process, including failures: git objects are immutable, so
2741
+ # a receipt or range that failed once fails for every task that asks again.
2742
+ _ADOPTION_MEMO: Dict[tuple, tuple[bool, object]] = {}
2743
+
2744
+
2745
+ def _memoized(key: tuple, compute):
2746
+ if key not in _ADOPTION_MEMO:
2747
+ try:
2748
+ _ADOPTION_MEMO[key] = (True, compute())
2749
+ except IsolationError as error:
2750
+ _ADOPTION_MEMO[key] = (False, error)
2751
+ ok, value = _ADOPTION_MEMO[key]
2752
+ if not ok:
2753
+ raise value
2754
+ return value
2755
+
2756
+
2757
+ def _require_landings_after(repo: Path, adopted: str, head: str) -> None:
2758
+ """After adoption every product change must be a strictly proven landing."""
2759
+ _memoized(("landings", str(repo), adopted, head), lambda: _check_landings_after(repo, adopted, head))
2760
+
2761
+
2762
+ def _check_landings_after(repo: Path, adopted: str, head: str) -> None:
2763
+ if run_git(repo, "merge-base", "--is-ancestor", adopted, head).returncode:
2764
+ raise IsolationError("adopted revision is not an ancestor")
2765
+ inventory = set(_committed_task_paths(repo, head))
2766
+ archived_paths = git_text(
2767
+ repo, "ls-tree", "-r", "--name-only", head, "--", ".project/archive"
2768
+ ).splitlines()
2769
+ for path in archived_paths:
2770
+ match = re.fullmatch(r"\.project/archive/[^/]+/tasks/([^/]+\.md)", path)
2771
+ if match:
2772
+ inventory.add(".project/tasks/" + match.group(1))
2773
+ for sha, _, body in _first_parent_records(repo, f"{adopted}..{head}"):
2774
+ changed = git_output(repo, "diff", "--name-only", sha + "^", sha).splitlines()
2775
+ # Bookkeeping, including real attestations, cannot change product paths.
2776
+ if all(path.startswith(BOOKKEEPING_PREFIXES) for path in changed):
2777
+ continue
2778
+ fields, _ = _attest_body_fields(body)
2779
+ task_file, base = fields.get("Task", ""), fields.get("Base", "")
2780
+ if task_file in inventory and re.fullmatch(r"[0-9a-f]{40}", base):
2781
+ _, error = _prove_task_commit(repo, sha, body, task_file, base, None)
2782
+ if error is None:
2783
+ continue
2784
+ raise IsolationError("product changed outside a proven landing after the adopted revision")
2785
+
2786
+
2787
+ def _receipt_path(project_root: Path) -> Path:
2788
+ """The adoption receipt beside a live (.project/) or archived project root."""
2789
+ path = project_root / REBASE_ADOPTION_RECEIPT
2790
+ try:
2791
+ resolved = path.resolve()
2792
+ except (RuntimeError, OSError) as error:
2793
+ raise IsolationError(f"unsafe rebase adoption receipt path: {error}") from error
2794
+ if resolved != path.absolute():
2795
+ raise IsolationError("unsafe rebase adoption receipt path")
2796
+ return path
2797
+
2798
+
2799
+ def _receipt_bytes(project_root: Path) -> Optional[bytes]:
2800
+ """The receipt's bytes, or None when the project root has no receipt."""
2801
+ path = _receipt_path(project_root)
2802
+ if not os.path.lexists(path):
2803
+ return None
2804
+ if not path.is_file():
2805
+ raise IsolationError("rebase adoption receipt is not a regular file")
2806
+ try:
2807
+ return path.read_bytes()
2808
+ except OSError as error:
2809
+ raise IsolationError(str(error)) from error
2810
+
2811
+
2812
+ def _validated_receipt(repo: Path, raw: bytes) -> Dict[str, object]:
2813
+ """Parse and recompute a receipt once per process; git objects are immutable."""
2814
+ key = ("receipt", str(repo), hashlib.sha256(raw).hexdigest())
2815
+ return _memoized(key, lambda: _validate_receipt(repo, raw))
2816
+
2817
+
2818
+ def _validate_receipt(repo: Path, raw: bytes) -> Dict[str, object]:
2819
+ try:
2820
+ receipt = json.loads(raw)
2821
+ if not isinstance(receipt, dict) or receipt.get("schema") != REBASE_ADOPTION_SCHEMA:
2822
+ raise IsolationError("unknown schema")
2823
+ for field in ("original_head", "head"):
2824
+ if not isinstance(receipt.get(field), str):
2825
+ raise IsolationError(f"{field} must be a full lowercase SHA")
2826
+ require_full_sha(receipt[field])
2827
+ if not isinstance(receipt.get("ruling"), str) or not receipt["ruling"].strip():
2828
+ raise IsolationError("ruling must be a non-empty string")
2829
+ if not isinstance(receipt.get("tasks"), list) or any(
2830
+ not isinstance(row, dict) for row in receipt["tasks"]
2831
+ ):
2832
+ raise IsolationError("tasks must be a list of objects")
2833
+ expected = adopt_rebase(repo, receipt["original_head"], receipt["head"], receipt["ruling"])
2834
+ except (KeyError, TypeError, ValueError) as error:
2835
+ raise IsolationError(str(error)) from error
2836
+ if receipt != expected:
2837
+ raise IsolationError("receipt does not match recomputed Git evidence")
2838
+ return receipt
2839
+
2840
+
2841
+ def _prove_rebase_adoption(
2842
+ repo: Path, project_root: Path, task_file: str, head: str, landing: str, current_text: str
2843
+ ) -> Optional[str]:
2844
+ """Return the original base the receipt binds to `landing`; None without a receipt.
2845
+
2846
+ `project_root` is the task artifact's own root: `.project` for live tasks,
2847
+ `.project/archive/<n>` for archived ones.
2848
+ """
2849
+ raw = _receipt_bytes(project_root)
2850
+ if raw is None:
2851
+ return None
2852
+ receipt = _validated_receipt(repo, raw)
2853
+ if receipt["head"] != head:
2854
+ _require_landings_after(repo, receipt["head"], head)
2855
+ rows = [row for row in receipt["tasks"] if row["path"] == task_file]
2856
+ if len(rows) != 1 or rows[0]["rebased_landing"] != landing:
2857
+ raise IsolationError("receipt does not bind this landing")
2858
+ if rows[0]["adopted_task"] != current_text:
2859
+ raise IsolationError("current task differs from adopted task")
2860
+ return str(rows[0]["original_base"])
2861
+
2862
+
2863
+ def record_rebase_adoption(
2864
+ primary: Path, original_head: str, head: str, ruling: str
2865
+ ) -> Dict[str, object]:
2866
+ """Write the adoption receipt for the bound milestone worktree at `head`."""
2867
+ primary = require_directory(primary, "primary worktree")
2868
+ if worktree_root(primary) != primary:
2869
+ raise IsolationError(f"primary is not its Git root: {primary}")
2870
+ bound = require_bound(primary)
2871
+ if current_sha(primary) != head:
2872
+ raise IsolationError("HEAD differs from approved adopted revision")
2873
+ receipt = adopt_rebase(primary, original_head, head, ruling)
2874
+ existing = _receipt_bytes(primary / ".project")
2875
+ try:
2876
+ if existing is not None and json.loads(existing) != receipt:
2877
+ raise IsolationError("another adoption receipt already exists")
2878
+ except ValueError as error:
2879
+ raise IsolationError(f"unreadable existing receipt: {error}") from error
2880
+ target = _receipt_path(primary / ".project")
2881
+ _common.atomic_write(target, json.dumps(receipt, indent=2) + "\n")
2882
+ return {"bound_branch": bound, "head": head, "path": str(target),
2883
+ "tasks": len(receipt["tasks"]), "verdict": "adopted"}
2884
+
2885
+
2886
+ def publication_base(repo: Path, branch: str, ship_commit: str) -> Optional[str]:
2887
+ """The adopted head a shipped, owner-adopted milestone may advance from.
2888
+
2889
+ Returns None when the milestone is not an adopted one; raises when the
2890
+ archived receipt, ship commit, or archived tasks fail proof.
2891
+ """
2892
+ try:
2893
+ from pipeline_state import load_state
2894
+ except ImportError: # pragma: no cover - package import used by tests
2895
+ from scripts.pipeline_state import load_state
2896
+ repo = require_directory(repo, "repository")
2897
+ state, state_text, _ = load_state(repo)
2898
+ if (state.phase, state.status, state.integration, state.branch) != (
2899
+ "shipped", "done", "direct", branch
2900
+ ) or state.archive is None:
2901
+ return None
2902
+ raw = _receipt_bytes(repo / state.archive)
2903
+ if raw is None:
2904
+ return None
2905
+ if current_sha(repo) != ship_commit or uncommitted_paths(repo):
2906
+ raise IsolationError("publication requires the clean ship commit")
2907
+ if git_text(repo, "show", f"{ship_commit}:.project/STATE.md") != state_text:
2908
+ raise IsolationError("ship state differs from committed state")
2909
+ receipt = _validated_receipt(repo, raw)
2910
+ parent, error = _single_parent(repo, ship_commit)
2911
+ if error or parent != receipt["head"]:
2912
+ raise IsolationError("ship parent is not the adopted revision")
2913
+ changed = git_output(repo, "diff", "--name-only", parent, ship_commit).splitlines()
2914
+ if any(not name.startswith(".project/") for name in changed):
2915
+ raise IsolationError("ship commit changes product files")
2916
+ expected = (ship_subject(Path(state.archive).name) + "\n\n"
2917
+ + ship_commit_body(state.archive, parent)).strip()
2918
+ if git_text(repo, "show", "-s", "--format=%B", ship_commit).strip() != expected:
2919
+ raise IsolationError("ship commit message is not canonical")
2920
+ for row in receipt["tasks"]:
2921
+ name = Path(str(row["path"])).name
2922
+ task = _real_file(repo, f"{state.archive}/tasks/{name}", "archived task")
2923
+ if _read_task_text(task)[0] != row["adopted_task"]:
2924
+ raise IsolationError("archived task differs from adopted record")
2925
+ return parent
2926
+
2927
+
2928
+ def attest(
2929
+ primary: Path, task_file: str, ruling: str, base: Optional[str] = None
2930
+ ) -> Dict[str, object]:
2931
+ """Record an owner ruling that a done task's work is already on the branch.
2932
+
2933
+ The attestation is one .project/-only commit that stamps the task's landed
2934
+ state (and carries an uncommitted verify-ledger line) and names the base,
2935
+ the attested HEAD, the declared files that changed between them, the task
2936
+ Verify recorded as passing at that HEAD, and the ruling verbatim. `recover`
2937
+ reports it as `attested`, never `recovered`.
2938
+ """
2939
+ primary = require_directory(primary, "primary worktree")
2940
+ if worktree_root(primary) != primary:
2941
+ raise IsolationError(f"primary is not its Git root: {primary}")
2942
+ bound = require_bound(primary)
2943
+ task_file = relative_posix(task_file)
2944
+ if not task_file.startswith(".project/tasks/"):
2945
+ raise IsolationError("task file must live under .project/tasks/")
2946
+ ruling_text = " ".join(ruling.split())
2947
+ if not ruling_text:
2948
+ raise IsolationError("--ruling must not be empty")
2949
+ pending = uncommitted_paths(primary)
2950
+ dirty = sorted(pending - BOOKKEEPING_PATHS)
2951
+ if dirty:
2952
+ raise IsolationError("primary worktree is dirty: " + ", ".join(dirty))
2953
+ head = current_sha(primary)
2954
+ task_path = primary / task_file
2955
+ task_text, task_mode = _read_task_text(task_path)
2956
+ fields, error = task_frontmatter(task_text)
2957
+ if error or fields is None:
2958
+ raise IsolationError(error or "unreadable task frontmatter")
2959
+ task_id, title = str(fields.get("id", "")), str(fields.get("title", ""))
2960
+ validate_task_id(task_id)
2961
+ if fields.get("status") != "done":
2962
+ raise IsolationError("only a done task can be attested")
2963
+ try:
2964
+ subject = attest_commit_subject(task_id, title)
2965
+ except ValueError as exc:
2966
+ raise IsolationError(str(exc)) from exc
2967
+ report = recover(primary, Path(task_file).parent)
2968
+ mine = next((task for task in report["tasks"] if task.get("task_id") == task_id), None)
2969
+ if mine is None:
2970
+ raise IsolationError(f"task recovery did not report {task_id}: {report.get('reason', '')}")
2971
+ if mine["verdict"] in LANDED_VERDICTS:
2972
+ raise IsolationError(f"task is already {mine['verdict']} at {mine['commit']}")
2973
+ reason = str(mine.get("reason", ""))
2974
+ if mine["verdict"] != "block" or not reason.startswith(
2975
+ ("done but no landing commit proves it", "done task has invalid base")
2976
+ ):
2977
+ raise IsolationError(f"task recovery is not a missing landing: {reason or mine['verdict']}")
2978
+ if mine.get("rejected"):
2979
+ raise IsolationError(
2980
+ "task has rejected landing evidence on the branch; resolve it before attesting: "
2981
+ + "; ".join(str(item.get("reason", "")) for item in mine["rejected"])
2982
+ )
2983
+ if mine.get("worktree") is not None or mine.get("task_branch") is not None:
2984
+ raise IsolationError("task retains isolation; retire it before attesting")
2985
+ recorded = str(fields.get("base", ""))
2986
+ resolved_base = require_commit(primary, base or recorded)
2987
+ if base and recorded not in {"", "null"} and not resolved_base.startswith(recorded):
2988
+ raise IsolationError("--base may only repair the recorded base, not replace it")
2989
+ if run_git(primary, "merge-base", "--is-ancestor", resolved_base, head).returncode != 0:
2990
+ raise IsolationError("base is not an ancestor of HEAD")
2991
+ _regular_blob_oid(primary, resolved_base, task_file)
2992
+ contract_error = _task_contract_error(
2993
+ git_text(primary, "show", f"{resolved_base}:{task_file}"), task_text, "task"
2994
+ )
2995
+ if contract_error:
2996
+ raise IsolationError(contract_error + "; only landing metadata and an appended log may change")
2997
+ declared, declared_error = _declared_task_paths(fields)
2998
+ if declared_error or declared is None:
2999
+ raise IsolationError(declared_error or "task files are unreadable")
3000
+ changed = _attested_changes(primary, resolved_base, head, declared)
3001
+ if not changed:
3002
+ raise IsolationError("no declared file changed between the base and HEAD; nothing to attest")
3003
+ verify = task_verify_command(task_text)
3004
+ if not verify:
3005
+ raise IsolationError("task has no ## Verify bash command")
3006
+ if not _verify_ledger_pass(primary, verify, head):
3007
+ raise IsolationError(
3008
+ f"verify ledger has no passing run of the task Verify at {head}; "
3009
+ "run it and record it with build_state.py verify-record first"
3010
+ )
3011
+ frontmatter, body = split_frontmatter(task_text)
3012
+ frontmatter = [
3013
+ f"base: {resolved_base}" if _frontmatter_key(line) == "base" else line
3014
+ for line in frontmatter
3015
+ ]
3016
+ normalized = "---\n" + "\n".join(frontmatter) + "\n---\n" + body
3017
+ stamped = _landed_task_text(normalized, resolved_base)
3018
+ if not stamped.endswith("\n"):
3019
+ stamped += "\n"
3020
+ stamped += f"- attested: {ruling_text}\n"
3021
+ task_bytes = task_text.encode("utf-8")
3022
+ index_tree = git_output(primary, "write-tree")
3023
+ commit_body = attest_commit_body(task_file, resolved_base, head, changed, verify, ruling_text)
3024
+ try:
3025
+ _replace_regular_file(task_path, stamped.encode("utf-8"), task_mode)
3026
+ # The ledger line proving Verify at HEAD travels inside the attestation.
3027
+ commit = _commit_pending(
3028
+ primary, {task_file, *(pending & {VERIFY_LEDGER_PATH})}, subject, commit_body
3029
+ )
3030
+ except BaseException as error:
3031
+ try:
3032
+ _restore_landing_state(primary, task_path, task_bytes, task_mode, index_tree)
3033
+ except IsolationError as restore_error:
3034
+ raise IsolationError(f"{error}; {restore_error}") from error
3035
+ raise
3036
+ _, proof_error = _prove_attest_commit(primary, commit, commit_body.strip(), task_file, stamped)
3037
+ if proof_error:
3038
+ # Move HEAD back and restore the index and task file; leave every other
3039
+ # worktree path (ledger, discussion records) exactly as it was.
3040
+ if current_sha(primary) != commit:
3041
+ raise IsolationError("HEAD moved after the rejected attestation commit")
3042
+ moved = run_git(primary, "update-ref", "HEAD", head, commit)
3043
+ if moved.returncode != 0:
3044
+ raise IsolationError((moved.stderr or moved.stdout).strip() or "could not restore HEAD")
3045
+ _restore_landing_state(primary, task_path, task_bytes, task_mode, index_tree)
3046
+ raise IsolationError(f"attestation proof failed: {proof_error}")
3047
+ return {
3048
+ "bound_branch": bound,
3049
+ "commit": commit,
3050
+ "base": resolved_base,
3051
+ "head": head,
3052
+ "files": changed,
3053
+ "subject": subject,
3054
+ "verdict": "attested",
3055
+ }
3056
+
3057
+
3058
+ def checkpoint(
3059
+ repo: Path,
3060
+ expected_head: str,
3061
+ subject: str,
3062
+ body: str,
3063
+ allow_paths: Sequence[str],
3064
+ ) -> Dict[str, object]:
3065
+ """Create one allowlisted pipeline checkpoint with a canonical message."""
3066
+
3067
+ repo = require_directory(repo, "primary worktree")
3068
+ if worktree_root(repo) != repo:
3069
+ raise IsolationError(f"primary is not its Git root: {repo}")
3070
+ require_bound(repo)
3071
+ expected = require_commit(repo, require_full_sha(expected_head))
3072
+ _validate_checkpoint_message(subject.strip(), body)
3073
+ allowed = [relative_posix(path).rstrip("/") for path in allow_paths]
3074
+ if not allowed or any(
3075
+ path in {"", "."}
3076
+ or not (path == ".project" or path.startswith(".project/"))
3077
+ for path in allowed
3078
+ ):
3079
+ raise IsolationError("checkpoint allow paths must stay under .project/")
3080
+ current = current_sha(repo)
3081
+ if current != expected:
3082
+ parents = git_output(repo, "show", "-s", "--format=%P", current).split()
3083
+ actual_subject = git_output(repo, "show", "-s", "--format=%s", current)
3084
+ actual_body = git_output(repo, "show", "-s", "--format=%b", current)
3085
+ paths = committed_paths_since(repo, expected) if parents == [expected] else set()
3086
+ paths_allowed = paths and all(
3087
+ any(
3088
+ path == allowed_path or path.startswith(allowed_path + "/")
3089
+ for allowed_path in allowed
3090
+ )
3091
+ for path in paths
3092
+ )
3093
+ if (
3094
+ parents == [expected]
3095
+ and actual_subject == subject.strip()
3096
+ and actual_body == body.strip()
3097
+ and paths_allowed
3098
+ and not uncommitted_paths(repo)
3099
+ ):
3100
+ return {
3101
+ "commit": current,
3102
+ "paths": sorted(paths),
3103
+ "status": "already-complete",
3104
+ "subject": subject.strip(),
3105
+ }
3106
+ raise IsolationError("primary HEAD differs from --expected-head")
3107
+ pending = uncommitted_paths(repo)
3108
+ if not pending:
3109
+ raise IsolationError("no changes to checkpoint")
3110
+ unexpected = sorted(
3111
+ path
3112
+ for path in pending
3113
+ if not any(
3114
+ path == allowed_path or path.startswith(allowed_path + "/")
3115
+ for allowed_path in allowed
3116
+ )
3117
+ )
3118
+ if unexpected:
3119
+ raise IsolationError("unexpected paths: " + ", ".join(unexpected))
3120
+ unsupported = sorted({
3121
+ path.split("/")[1]
3122
+ for path in pending
3123
+ if path.startswith(".project/") and path.split("/")[1] not in PROJECT_ENTRIES
3124
+ })
3125
+ if unsupported:
3126
+ raise IsolationError(
3127
+ "unsupported .project artifacts: " + ", ".join(unsupported)
3128
+ + "; remove them before the checkpoint"
3129
+ )
3130
+ normalized_subject = subject.strip()
3131
+ if normalized_subject.startswith("build:"):
3132
+ # Build owns fix-task additions and brief repairs, never the task
3133
+ # inventory: a deleted or renamed brief is a replan. Abandon moves
3134
+ # briefs into .project/archive/ inside the same checkpoint.
3135
+ abandon = re.fullmatch(
3136
+ r"build: abandon milestone [a-z0-9][a-z0-9-]*", normalized_subject
3137
+ )
3138
+ archived = set()
3139
+ if abandon:
3140
+ for path in pending:
3141
+ match = re.fullmatch(
3142
+ r"\.project/archive/[^/]+/tasks/([^/]+\.md)", path
3143
+ )
3144
+ if match and (repo / path).is_file():
3145
+ archived.add(match.group(1))
3146
+ removed = sorted(
3147
+ path
3148
+ for path in pending
3149
+ if path.startswith(".project/tasks/")
3150
+ and path.endswith(".md")
3151
+ and "/" not in path[len(".project/tasks/") :]
3152
+ and not (repo / path).is_file()
3153
+ and path.rsplit("/", 1)[1] not in archived
3154
+ )
3155
+ if removed:
3156
+ raise IsolationError(
3157
+ "build checkpoints never delete or rename task briefs: "
3158
+ + ", ".join(removed)
3159
+ )
3160
+ return {
3161
+ "commit": _commit_pending(repo, pending, normalized_subject, body),
3162
+ "paths": sorted(pending),
3163
+ "status": "committed",
3164
+ "subject": normalized_subject,
3165
+ }
3166
+
3167
+
3168
+ def _unlanded_task_commits(repo: Path, bound: str, branch: str) -> list[str]:
3169
+ """Return branch commits whose patch is not on the bound branch."""
3170
+ return [
3171
+ line[2:]
3172
+ for line in git_output(repo, "cherry", bound, branch).splitlines()
3173
+ if line.startswith("+ ")
3174
+ ]
3175
+
3176
+
3177
+ def _require_landed_task_branch(
3178
+ primary: Path,
3179
+ bound: str,
3180
+ branch: str,
3181
+ landed_commit: Optional[str],
3182
+ task_file: Optional[str],
3183
+ ) -> None:
3184
+ """Refuse to delete a task branch while it still holds unlanded commits."""
3185
+ unlanded = _unlanded_task_commits(primary, bound, branch)
3186
+ if not unlanded:
3187
+ return
3188
+ if landed_commit:
3189
+ landed = require_commit(primary, require_full_sha(landed_commit))
3190
+ _require_first_parent_commit(primary, bound, landed)
3191
+ base = _landing_base(primary, landed)
3192
+ proof_error = _task_branch_proof_error(primary, branch, base, landed)
3193
+ elif task_file:
3194
+ proof_error = _failed_task_branch_proof_error(primary, branch, task_file)
3195
+ else:
3196
+ proof_error = (
3197
+ "pass --landed-commit <landing commit> or --task-file <failed task file>"
3198
+ )
3199
+ if proof_error:
3200
+ raise IsolationError(
3201
+ f"refusing to retire {branch}: {len(unlanded)} commit(s) are not landed "
3202
+ f"on {bound}: {', '.join(unlanded)}; {proof_error}; run: "
3203
+ f"python3 {Path(__file__).resolve()} recover --repo {primary}"
3204
+ )
3205
+
3206
+
3207
+ def retire(
3208
+ primary: Path,
3209
+ worktree: Optional[Path],
3210
+ branch: Optional[str],
3211
+ force: bool,
3212
+ landed_commit: Optional[str] = None,
3213
+ task_file: Optional[str] = None,
3214
+ ) -> Dict[str, object]:
3215
+ primary = require_directory(primary, "primary worktree")
3216
+ bound = require_attached(primary)
3217
+ if branch == bound:
3218
+ raise IsolationError("refusing to retire the bound branch")
3219
+ expected_worktree = (
3220
+ _sidecar_path_for_branch(primary, branch) if branch else None
3221
+ )
3222
+ if worktree is None:
3223
+ if expected_worktree is None:
3224
+ raise IsolationError("retire requires --worktree or --branch")
3225
+ resolved_worktree = expected_worktree
3226
+ else:
3227
+ resolved_worktree = worktree.resolve()
3228
+ if (
3229
+ branch
3230
+ and not resolved_worktree.exists()
3231
+ and expected_worktree is not None
3232
+ and resolved_worktree != expected_worktree
3233
+ ):
3234
+ raise IsolationError("absent worktree path does not match the sidecar branch")
3235
+ if resolved_worktree == primary:
3236
+ if branch:
3237
+ raise IsolationError("refusing to retire a sidecar branch through the primary")
3238
+ return {
3239
+ "bound_branch": bound,
3240
+ "branch": None,
3241
+ "retired": False,
3242
+ "reason": "serial",
3243
+ "worktree": str(primary),
3244
+ }
3245
+ if not resolved_worktree.exists():
3246
+ branch_retired = False
3247
+ if branch:
3248
+ if branch == bound:
3249
+ raise IsolationError("refusing to delete the bound branch")
3250
+ if not (
3251
+ branch.startswith(TASK_BRANCH_PREFIX)
3252
+ or branch.startswith(VERIFY_BRANCH_PREFIX)
3253
+ ):
3254
+ raise IsolationError(f"refusing to retire unrecognized branch {branch}")
3255
+ _delete_task_authorization(primary, branch)
3256
+ existing = run_git(
3257
+ primary, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"
3258
+ )
3259
+ if existing.returncode == 0:
3260
+ deleted = run_git(primary, "branch", "-d", branch)
3261
+ if deleted.returncode != 0 and not force:
3262
+ raise IsolationError(
3263
+ (deleted.stderr or deleted.stdout).strip()
3264
+ or f"could not delete {branch}"
3265
+ )
3266
+ if deleted.returncode != 0:
3267
+ if not branch.startswith(TASK_BRANCH_PREFIX):
3268
+ raise IsolationError(
3269
+ "forced branch-only retirement requires a task branch"
3270
+ )
3271
+ if landed_commit:
3272
+ landed = require_commit(primary, require_full_sha(landed_commit))
3273
+ _require_first_parent_commit(primary, bound, landed)
3274
+ base = _landing_base(primary, landed)
3275
+ proof_error = _task_branch_proof_error(
3276
+ primary, branch, base, landed
3277
+ )
3278
+ elif task_file:
3279
+ proof_error = _failed_task_branch_proof_error(
3280
+ primary, branch, task_file
3281
+ )
3282
+ else:
3283
+ raise IsolationError(
3284
+ "forced branch-only task retirement requires --landed-commit or --task-file"
3285
+ )
3286
+ if proof_error:
3287
+ raise IsolationError(proof_error)
3288
+ deleted = run_git(primary, "branch", "-D", branch)
3289
+ if deleted.returncode != 0:
3290
+ raise IsolationError(
3291
+ (deleted.stderr or deleted.stdout).strip()
3292
+ or f"could not delete {branch}"
3293
+ )
3294
+ branch_retired = True
3295
+ return {
3296
+ "bound_branch": bound,
3297
+ "branch": branch,
3298
+ "retired": True,
3299
+ "reason": "branch-only" if branch_retired else "already-absent",
3300
+ "worktree": str(resolved_worktree),
3301
+ }
3302
+ if worktree_root(resolved_worktree) != resolved_worktree:
3303
+ raise IsolationError(f"worktree is not its Git root: {resolved_worktree}")
3304
+ if common_git_dir(primary) != common_git_dir(resolved_worktree):
3305
+ raise IsolationError("worktree belongs to another repository")
3306
+ current = require_attached(resolved_worktree)
3307
+ if current == bound:
3308
+ raise IsolationError("refusing to retire a worktree on the bound branch")
3309
+ if branch and current != branch:
3310
+ raise IsolationError(f"worktree is on {current}, expected {branch}")
3311
+ if current.startswith(TASK_BRANCH_PREFIX) or current.startswith(VERIFY_BRANCH_PREFIX):
3312
+ retire_branch = current
3313
+ else:
3314
+ raise IsolationError(f"refusing to retire unrecognized branch {current}")
3315
+ if retire_branch.startswith(TASK_BRANCH_PREFIX):
3316
+ _require_landed_task_branch(
3317
+ primary, bound, retire_branch, landed_commit, task_file
3318
+ )
3319
+ _delete_task_authorization(primary, retire_branch)
3320
+ if not force:
3321
+ dirty = git_output(
3322
+ resolved_worktree, "status", "--porcelain", "--untracked-files=all"
3323
+ )
3324
+ if dirty:
3325
+ raise IsolationError(
3326
+ "worktree is dirty; pass --force only from the retry-retirement path"
3327
+ )
3328
+ remove = run_git(
3329
+ primary,
3330
+ "worktree",
3331
+ "remove",
3332
+ *(("--force",) if force else ()),
3333
+ str(resolved_worktree),
3334
+ )
3335
+ if remove.returncode != 0:
3336
+ raise IsolationError(
3337
+ (remove.stderr or remove.stdout).strip() or "git worktree remove failed"
3338
+ )
3339
+ deleted = run_git(primary, "branch", "-D", retire_branch)
3340
+ if deleted.returncode != 0:
3341
+ raise IsolationError(
3342
+ (deleted.stderr or deleted.stdout).strip() or f"could not delete {retire_branch}"
3343
+ )
3344
+ return {
3345
+ "bound_branch": bound,
3346
+ "branch": retire_branch,
3347
+ "retired": True,
3348
+ "reason": "removed",
3349
+ "worktree": str(resolved_worktree),
3350
+ }
3351
+
3352
+
3353
+ def parser() -> argparse.ArgumentParser:
3354
+ argument_parser = argparse.ArgumentParser(description=__doc__)
3355
+ subparsers = argument_parser.add_subparsers(dest="command", required=True)
3356
+
3357
+ isolate_task_parser = subparsers.add_parser(
3358
+ "isolate-task", help="create or reuse a named task checkout"
3359
+ )
3360
+ isolate_task_parser.add_argument("--repo", type=Path, required=True)
3361
+ isolate_task_parser.add_argument("--base", required=True)
3362
+ isolate_task_parser.add_argument("--task-id", required=True)
3363
+ isolate_task_parser.add_argument("--round-size", type=int, required=True)
3364
+
3365
+ activate_task_parser = subparsers.add_parser(
3366
+ "activate-task", help="record one helper-owned task dispatch"
3367
+ )
3368
+ activate_task_parser.add_argument("--repo", type=Path, required=True)
3369
+ activate_task_parser.add_argument("--base", required=True)
3370
+ activate_task_parser.add_argument("--task-id", required=True)
3371
+ activate_task_parser.add_argument("--agent", required=True)
3372
+ activate_task_parser.add_argument("--task-file", required=True)
3373
+ activate_task_parser.add_argument("--task-branch")
3374
+
3375
+ deactivate_task_parser = subparsers.add_parser(
3376
+ "deactivate-task", help="revoke one helper-owned task dispatch"
3377
+ )
3378
+ deactivate_task_parser.add_argument("--repo", type=Path, required=True)
3379
+ deactivate_task_parser.add_argument("--task-id", required=True)
3380
+ deactivate_task_parser.add_argument("--task-branch", required=True)
3381
+
3382
+ isolate_verify_parser = subparsers.add_parser(
3383
+ "isolate-verify", help="create a named verify sidecar"
3384
+ )
3385
+ isolate_verify_parser.add_argument("--repo", type=Path, required=True)
3386
+ isolate_verify_parser.add_argument("--base", required=True)
3387
+ isolate_verify_parser.add_argument("--name", required=True)
3388
+ isolate_verify_parser.add_argument("--historical-task")
3389
+
3390
+ land_parser = subparsers.add_parser("land", help="commit a task onto the bound branch")
3391
+ land_parser.add_argument("--repo", type=Path, required=True)
3392
+ land_parser.add_argument("--source", type=Path, required=True)
3393
+ land_parser.add_argument("--base", required=True)
3394
+ land_parser.add_argument("--task-id", required=True)
3395
+ land_parser.add_argument("--title", required=True)
3396
+ land_parser.add_argument("--task-file", required=True)
3397
+ land_parser.add_argument("--allow-path", action="append", default=[], dest="allow_paths")
3398
+
3399
+ attest_parser = subparsers.add_parser(
3400
+ "attest", help="record an owner ruling that a done task's work is already on the branch"
3401
+ )
3402
+ attest_parser.add_argument("--repo", type=Path, required=True)
3403
+ attest_parser.add_argument("--task-file", required=True)
3404
+ attest_parser.add_argument("--ruling", required=True)
3405
+ attest_parser.add_argument("--base", help="full or abbreviated base SHA; defaults to the task's recorded base")
3406
+
3407
+ adopt_parser = subparsers.add_parser(
3408
+ "adopt-rebase", help="record an owner ruling adopting every done task's rebased landing"
3409
+ )
3410
+ adopt_parser.add_argument("--repo", type=Path, required=True)
3411
+ adopt_parser.add_argument("--original-head", required=True, help="preserved original tip, full SHA")
3412
+ adopt_parser.add_argument("--head", required=True, help="rebased HEAD being adopted, full SHA")
3413
+ adopt_parser.add_argument("--ruling", required=True)
3414
+
3415
+ recover_parser = subparsers.add_parser(
3416
+ "recover", help="prove landed task commits from git; read-only"
3417
+ )
3418
+ recover_parser.add_argument("--repo", type=Path, required=True)
3419
+ recover_parser.add_argument("--tasks-dir", type=Path, default=Path(".project/tasks"))
3420
+
3421
+ collect_parser = subparsers.add_parser(
3422
+ "collect-artifact", help="atomically collect one verified sidecar artifact"
3423
+ )
3424
+ collect_parser.add_argument("--repo", type=Path, required=True)
3425
+ collect_parser.add_argument("--source", type=Path, required=True)
3426
+ collect_parser.add_argument("--base", required=True)
3427
+ collect_parser.add_argument("--branch", required=True)
3428
+ collect_parser.add_argument("--source-path", required=True)
3429
+ collect_parser.add_argument("--destination-path", required=True)
3430
+ collect_parser.add_argument(
3431
+ "--expected-destination",
3432
+ help="base or the recorded SHA-256 of an existing destination",
3433
+ )
3434
+
3435
+ checkpoint_parser = subparsers.add_parser(
3436
+ "checkpoint", help="commit allowlisted pipeline bookkeeping"
3437
+ )
3438
+ checkpoint_parser.add_argument("--repo", type=Path, required=True)
3439
+ checkpoint_parser.add_argument("--expected-head", required=True)
3440
+ checkpoint_parser.add_argument("--subject", required=True)
3441
+ checkpoint_parser.add_argument("--body", required=True)
3442
+ checkpoint_parser.add_argument(
3443
+ "--allow-path", action="append", required=True, dest="allow_paths"
3444
+ )
3445
+
3446
+ retire_parser = subparsers.add_parser("retire", help="remove a named sidecar checkout")
3447
+ retire_parser.add_argument("--repo", type=Path, required=True)
3448
+ retire_parser.add_argument("--worktree", type=Path)
3449
+ retire_parser.add_argument("--branch")
3450
+ retire_parser.add_argument("--force", action="store_true")
3451
+ retire_parser.add_argument("--landed-commit")
3452
+ retire_parser.add_argument("--task-file")
3453
+ return argument_parser
3454
+
3455
+
3456
+ def main(argv: Optional[Sequence[str]] = None) -> int:
3457
+ arguments = parser().parse_args(argv)
3458
+ try:
3459
+ if arguments.command == "isolate-task":
3460
+ result = isolate_task(
3461
+ arguments.repo,
3462
+ arguments.base,
3463
+ arguments.task_id,
3464
+ arguments.round_size,
3465
+ )
3466
+ elif arguments.command == "activate-task":
3467
+ result = activate_task(
3468
+ arguments.repo,
3469
+ arguments.base,
3470
+ arguments.task_id,
3471
+ arguments.agent,
3472
+ arguments.task_file,
3473
+ arguments.task_branch,
3474
+ )
3475
+ elif arguments.command == "deactivate-task":
3476
+ result = deactivate_task(
3477
+ arguments.repo,
3478
+ arguments.task_id,
3479
+ arguments.task_branch,
3480
+ )
3481
+ elif arguments.command == "isolate-verify":
3482
+ result = isolate_verify(arguments.repo, arguments.base, arguments.name, arguments.historical_task)
3483
+ elif arguments.command == "land":
3484
+ result = land(
3485
+ arguments.repo,
3486
+ arguments.source,
3487
+ arguments.base,
3488
+ arguments.task_id,
3489
+ arguments.title,
3490
+ arguments.task_file,
3491
+ arguments.allow_paths,
3492
+ )
3493
+ elif arguments.command == "attest":
3494
+ result = attest(
3495
+ arguments.repo, arguments.task_file, arguments.ruling, arguments.base
3496
+ )
3497
+ elif arguments.command == "adopt-rebase":
3498
+ result = record_rebase_adoption(
3499
+ arguments.repo, arguments.original_head, arguments.head, arguments.ruling
3500
+ )
3501
+ elif arguments.command == "recover":
3502
+ result = recover(arguments.repo, arguments.tasks_dir)
3503
+ elif arguments.command == "collect-artifact":
3504
+ result = collect_artifact(
3505
+ arguments.repo,
3506
+ arguments.source,
3507
+ arguments.base,
3508
+ arguments.branch,
3509
+ arguments.source_path,
3510
+ arguments.destination_path,
3511
+ arguments.expected_destination,
3512
+ )
3513
+ elif arguments.command == "checkpoint":
3514
+ result = checkpoint(
3515
+ arguments.repo,
3516
+ arguments.expected_head,
3517
+ arguments.subject,
3518
+ arguments.body,
3519
+ arguments.allow_paths,
3520
+ )
3521
+ else:
3522
+ result = retire(
3523
+ arguments.repo,
3524
+ arguments.worktree,
3525
+ arguments.branch,
3526
+ arguments.force,
3527
+ arguments.landed_commit,
3528
+ arguments.task_file,
3529
+ )
3530
+ except IsolationError as error:
3531
+ print(str(error), file=sys.stderr)
3532
+ return 1
3533
+ print(json.dumps(result, sort_keys=True))
3534
+ return 0
3535
+
3536
+
3537
+ if __name__ == "__main__":
3538
+ raise SystemExit(main())