@pennixrv/trellis 0.7.1 → 0.7.2-beta.2

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 (321) hide show
  1. package/README.md +10 -7
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +6 -4
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/ablate.d.ts.map +1 -1
  6. package/dist/commands/ablate.js +2 -0
  7. package/dist/commands/ablate.js.map +1 -1
  8. package/dist/commands/backup.d.ts +4 -0
  9. package/dist/commands/backup.d.ts.map +1 -0
  10. package/dist/commands/backup.js +75 -0
  11. package/dist/commands/backup.js.map +1 -0
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +705 -681
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/mem.d.ts.map +1 -1
  16. package/dist/commands/mem.js +49 -7
  17. package/dist/commands/mem.js.map +1 -1
  18. package/dist/commands/task-progress.d.ts +1 -0
  19. package/dist/commands/task-progress.d.ts.map +1 -1
  20. package/dist/commands/task-progress.js +67 -25
  21. package/dist/commands/task-progress.js.map +1 -1
  22. package/dist/commands/teamwork/adapters/acp.d.ts +20 -0
  23. package/dist/commands/teamwork/adapters/acp.d.ts.map +1 -0
  24. package/dist/commands/teamwork/adapters/acp.js +268 -0
  25. package/dist/commands/teamwork/adapters/acp.js.map +1 -0
  26. package/dist/commands/teamwork/adapters/claude.d.ts +1 -0
  27. package/dist/commands/teamwork/adapters/claude.d.ts.map +1 -1
  28. package/dist/commands/teamwork/adapters/claude.js +5 -0
  29. package/dist/commands/teamwork/adapters/claude.js.map +1 -1
  30. package/dist/commands/teamwork/adapters/codex.d.ts +3 -2
  31. package/dist/commands/teamwork/adapters/codex.d.ts.map +1 -1
  32. package/dist/commands/teamwork/adapters/codex.js +14 -15
  33. package/dist/commands/teamwork/adapters/codex.js.map +1 -1
  34. package/dist/commands/teamwork/adapters/index.d.ts +23 -1
  35. package/dist/commands/teamwork/adapters/index.d.ts.map +1 -1
  36. package/dist/commands/teamwork/adapters/index.js +163 -24
  37. package/dist/commands/teamwork/adapters/index.js.map +1 -1
  38. package/dist/commands/teamwork/agent-loader.d.ts +2 -1
  39. package/dist/commands/teamwork/agent-loader.d.ts.map +1 -1
  40. package/dist/commands/teamwork/agent-loader.js +2 -1
  41. package/dist/commands/teamwork/agent-loader.js.map +1 -1
  42. package/dist/commands/teamwork/artifact.d.ts +42 -14
  43. package/dist/commands/teamwork/artifact.d.ts.map +1 -1
  44. package/dist/commands/teamwork/artifact.js +188 -85
  45. package/dist/commands/teamwork/artifact.js.map +1 -1
  46. package/dist/commands/teamwork/batch-reservation.d.ts +16 -0
  47. package/dist/commands/teamwork/batch-reservation.d.ts.map +1 -0
  48. package/dist/commands/teamwork/batch-reservation.js +43 -0
  49. package/dist/commands/teamwork/batch-reservation.js.map +1 -0
  50. package/dist/commands/teamwork/batch.d.ts +23 -0
  51. package/dist/commands/teamwork/batch.d.ts.map +1 -0
  52. package/dist/commands/teamwork/batch.js +415 -0
  53. package/dist/commands/teamwork/batch.js.map +1 -0
  54. package/dist/commands/teamwork/context-loader.d.ts +1 -1
  55. package/dist/commands/teamwork/context-loader.d.ts.map +1 -1
  56. package/dist/commands/teamwork/context-loader.js +19 -6
  57. package/dist/commands/teamwork/context-loader.js.map +1 -1
  58. package/dist/commands/teamwork/coordination.d.ts +88 -0
  59. package/dist/commands/teamwork/coordination.d.ts.map +1 -0
  60. package/dist/commands/teamwork/coordination.js +153 -0
  61. package/dist/commands/teamwork/coordination.js.map +1 -0
  62. package/dist/commands/teamwork/coordinator.d.ts +33 -6
  63. package/dist/commands/teamwork/coordinator.d.ts.map +1 -1
  64. package/dist/commands/teamwork/coordinator.js +948 -342
  65. package/dist/commands/teamwork/coordinator.js.map +1 -1
  66. package/dist/commands/teamwork/create.d.ts +1 -0
  67. package/dist/commands/teamwork/create.d.ts.map +1 -1
  68. package/dist/commands/teamwork/create.js +2 -1
  69. package/dist/commands/teamwork/create.js.map +1 -1
  70. package/dist/commands/teamwork/dev-parse-trace.d.ts +2 -1
  71. package/dist/commands/teamwork/dev-parse-trace.d.ts.map +1 -1
  72. package/dist/commands/teamwork/dev-parse-trace.js +2 -0
  73. package/dist/commands/teamwork/dev-parse-trace.js.map +1 -1
  74. package/dist/commands/teamwork/foreground-wait.d.ts +6 -0
  75. package/dist/commands/teamwork/foreground-wait.d.ts.map +1 -0
  76. package/dist/commands/teamwork/foreground-wait.js +65 -0
  77. package/dist/commands/teamwork/foreground-wait.js.map +1 -0
  78. package/dist/commands/teamwork/guard.d.ts +2 -2
  79. package/dist/commands/teamwork/guard.d.ts.map +1 -1
  80. package/dist/commands/teamwork/guard.js +25 -7
  81. package/dist/commands/teamwork/guard.js.map +1 -1
  82. package/dist/commands/teamwork/index.d.ts.map +1 -1
  83. package/dist/commands/teamwork/index.js +163 -10
  84. package/dist/commands/teamwork/index.js.map +1 -1
  85. package/dist/commands/teamwork/kill.d.ts +2 -0
  86. package/dist/commands/teamwork/kill.d.ts.map +1 -1
  87. package/dist/commands/teamwork/kill.js +79 -38
  88. package/dist/commands/teamwork/kill.js.map +1 -1
  89. package/dist/commands/teamwork/messages.d.ts.map +1 -1
  90. package/dist/commands/teamwork/messages.js +3 -0
  91. package/dist/commands/teamwork/messages.js.map +1 -1
  92. package/dist/commands/teamwork/plan.d.ts +22 -0
  93. package/dist/commands/teamwork/plan.d.ts.map +1 -0
  94. package/dist/commands/teamwork/plan.js +288 -0
  95. package/dist/commands/teamwork/plan.js.map +1 -0
  96. package/dist/commands/teamwork/process-identity.d.ts +3 -0
  97. package/dist/commands/teamwork/process-identity.d.ts.map +1 -0
  98. package/dist/commands/teamwork/process-identity.js +24 -0
  99. package/dist/commands/teamwork/process-identity.js.map +1 -0
  100. package/dist/commands/teamwork/profiles.d.ts +43 -6
  101. package/dist/commands/teamwork/profiles.d.ts.map +1 -1
  102. package/dist/commands/teamwork/profiles.js +138 -110
  103. package/dist/commands/teamwork/profiles.js.map +1 -1
  104. package/dist/commands/teamwork/prompt.d.ts +4 -0
  105. package/dist/commands/teamwork/prompt.d.ts.map +1 -0
  106. package/dist/commands/teamwork/prompt.js +33 -0
  107. package/dist/commands/teamwork/prompt.js.map +1 -0
  108. package/dist/commands/teamwork/result.d.ts +55 -0
  109. package/dist/commands/teamwork/result.d.ts.map +1 -0
  110. package/dist/commands/teamwork/result.js +94 -0
  111. package/dist/commands/teamwork/result.js.map +1 -0
  112. package/dist/commands/teamwork/rm.d.ts.map +1 -1
  113. package/dist/commands/teamwork/rm.js +64 -64
  114. package/dist/commands/teamwork/rm.js.map +1 -1
  115. package/dist/commands/teamwork/run.d.ts +5 -0
  116. package/dist/commands/teamwork/run.d.ts.map +1 -1
  117. package/dist/commands/teamwork/run.js +120 -24
  118. package/dist/commands/teamwork/run.js.map +1 -1
  119. package/dist/commands/teamwork/runtime.d.ts +9 -0
  120. package/dist/commands/teamwork/runtime.d.ts.map +1 -0
  121. package/dist/commands/teamwork/runtime.js +401 -0
  122. package/dist/commands/teamwork/runtime.js.map +1 -0
  123. package/dist/commands/teamwork/send.d.ts +1 -0
  124. package/dist/commands/teamwork/send.d.ts.map +1 -1
  125. package/dist/commands/teamwork/send.js +2 -1
  126. package/dist/commands/teamwork/send.js.map +1 -1
  127. package/dist/commands/teamwork/spawn.d.ts +10 -3
  128. package/dist/commands/teamwork/spawn.d.ts.map +1 -1
  129. package/dist/commands/teamwork/spawn.js +42 -27
  130. package/dist/commands/teamwork/spawn.js.map +1 -1
  131. package/dist/commands/teamwork/store/lock.js +28 -4
  132. package/dist/commands/teamwork/store/lock.js.map +1 -1
  133. package/dist/commands/teamwork/supervisor/inbox.d.ts +1 -0
  134. package/dist/commands/teamwork/supervisor/inbox.d.ts.map +1 -1
  135. package/dist/commands/teamwork/supervisor/inbox.js +28 -9
  136. package/dist/commands/teamwork/supervisor/inbox.js.map +1 -1
  137. package/dist/commands/teamwork/supervisor/stdout.d.ts.map +1 -1
  138. package/dist/commands/teamwork/supervisor/stdout.js +2 -3
  139. package/dist/commands/teamwork/supervisor/stdout.js.map +1 -1
  140. package/dist/commands/teamwork/supervisor/warning.d.ts.map +1 -1
  141. package/dist/commands/teamwork/supervisor/warning.js +2 -3
  142. package/dist/commands/teamwork/supervisor/warning.js.map +1 -1
  143. package/dist/commands/teamwork/supervisor.d.ts +5 -0
  144. package/dist/commands/teamwork/supervisor.d.ts.map +1 -1
  145. package/dist/commands/teamwork/supervisor.js +21 -12
  146. package/dist/commands/teamwork/supervisor.js.map +1 -1
  147. package/dist/commands/teamwork/teammate-adapters.d.ts +0 -5
  148. package/dist/commands/teamwork/teammate-adapters.d.ts.map +1 -1
  149. package/dist/commands/teamwork/teammate-adapters.js +9 -56
  150. package/dist/commands/teamwork/teammate-adapters.js.map +1 -1
  151. package/dist/commands/teamwork/teammate-config.d.ts +5 -1
  152. package/dist/commands/teamwork/teammate-config.d.ts.map +1 -1
  153. package/dist/commands/teamwork/teammate-config.js +62 -19
  154. package/dist/commands/teamwork/teammate-config.js.map +1 -1
  155. package/dist/commands/teamwork/teammate-launch.d.ts +15 -6
  156. package/dist/commands/teamwork/teammate-launch.d.ts.map +1 -1
  157. package/dist/commands/teamwork/teammate-launch.js +129 -22
  158. package/dist/commands/teamwork/teammate-launch.js.map +1 -1
  159. package/dist/commands/teamwork/wait.d.ts +2 -0
  160. package/dist/commands/teamwork/wait.d.ts.map +1 -1
  161. package/dist/commands/teamwork/wait.js +44 -3
  162. package/dist/commands/teamwork/wait.js.map +1 -1
  163. package/dist/commands/uninstall.d.ts.map +1 -1
  164. package/dist/commands/uninstall.js +92 -84
  165. package/dist/commands/uninstall.js.map +1 -1
  166. package/dist/commands/update.d.ts.map +1 -1
  167. package/dist/commands/update.js +635 -592
  168. package/dist/commands/update.js.map +1 -1
  169. package/dist/commands/workflow.d.ts.map +1 -1
  170. package/dist/commands/workflow.js +94 -89
  171. package/dist/commands/workflow.js.map +1 -1
  172. package/dist/configurators/shared.js +2 -2
  173. package/dist/configurators/shared.js.map +1 -1
  174. package/dist/configurators/workflow.d.ts.map +1 -1
  175. package/dist/configurators/workflow.js +14 -32
  176. package/dist/configurators/workflow.js.map +1 -1
  177. package/dist/constants/paths.d.ts +3 -10
  178. package/dist/constants/paths.d.ts.map +1 -1
  179. package/dist/constants/paths.js +4 -13
  180. package/dist/constants/paths.js.map +1 -1
  181. package/dist/migrations/manifests/0.7.1-beta.0.json +9 -0
  182. package/dist/migrations/manifests/0.7.1-beta.1.json +186 -0
  183. package/dist/migrations/manifests/0.7.1-beta.10.json +9 -0
  184. package/dist/migrations/manifests/0.7.1-beta.11.json +9 -0
  185. package/dist/migrations/manifests/0.7.1-beta.12.json +9 -0
  186. package/dist/migrations/manifests/0.7.1-beta.13.json +9 -0
  187. package/dist/migrations/manifests/0.7.1-beta.14.json +11 -0
  188. package/dist/migrations/manifests/0.7.1-beta.15.json +9 -0
  189. package/dist/migrations/manifests/0.7.1-beta.16.json +9 -0
  190. package/dist/migrations/manifests/0.7.1-beta.17.json +9 -0
  191. package/dist/migrations/manifests/0.7.1-beta.18.json +9 -0
  192. package/dist/migrations/manifests/0.7.1-beta.19.json +9 -0
  193. package/dist/migrations/manifests/0.7.1-beta.2.json +522 -0
  194. package/dist/migrations/manifests/0.7.1-beta.3.json +9 -0
  195. package/dist/migrations/manifests/0.7.1-beta.4.json +9 -0
  196. package/dist/migrations/manifests/0.7.1-beta.5.json +9 -0
  197. package/dist/migrations/manifests/0.7.1-beta.6.json +10 -0
  198. package/dist/migrations/manifests/0.7.1-beta.7.json +8 -0
  199. package/dist/migrations/manifests/0.7.1-beta.8.json +481 -0
  200. package/dist/migrations/manifests/0.7.1-beta.9.json +191 -0
  201. package/dist/migrations/manifests/0.7.2-beta.0.json +11 -0
  202. package/dist/migrations/manifests/0.7.2-beta.1.json +11 -0
  203. package/dist/migrations/manifests/0.7.2-beta.2.json +11 -0
  204. package/dist/templates/claude/hooks/statusline.py +9 -4
  205. package/dist/templates/codex/hooks/session-start.py +14 -11
  206. package/dist/templates/codex/hooks.json +10 -0
  207. package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/workflow.md +2 -0
  208. package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/workflow.md +2 -0
  209. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +1 -1
  210. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +1 -1
  211. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +5 -5
  212. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +3 -2
  213. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
  214. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +1 -1
  215. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-teamwork.md +14 -2
  216. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +8 -2
  217. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
  218. package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +2 -2
  219. package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/workflow.md +8 -0
  220. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/mcp-setup.md +4 -2
  221. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-task-planning.md +2 -2
  222. package/dist/templates/common/bundled-skills/trellis-task-implementation/workflow.md +6 -0
  223. package/dist/templates/common/bundled-skills/trellis-teamwork/SKILL.md +18 -3
  224. package/dist/templates/common/bundled-skills/trellis-teamwork/references/coordination-modes.md +34 -3
  225. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-configuration-matrix.md +37 -26
  226. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +128 -14
  227. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +152 -20
  228. package/dist/templates/common/commands/start.md +2 -2
  229. package/dist/templates/common/skills/before-dev.md +1 -4
  230. package/dist/templates/common/skills/break-loop.md +7 -18
  231. package/dist/templates/common/skills/check.md +7 -11
  232. package/dist/templates/common/skills/{brainstorm.md → request-intake.md} +5 -3
  233. package/dist/templates/common/skills/update-spec.md +5 -7
  234. package/dist/templates/copilot/hooks/session-start.py +14 -11
  235. package/dist/templates/copilot/index.d.ts +1 -6
  236. package/dist/templates/copilot/index.d.ts.map +1 -1
  237. package/dist/templates/copilot/index.js +1 -14
  238. package/dist/templates/copilot/index.js.map +1 -1
  239. package/dist/templates/extract.js +4 -0
  240. package/dist/templates/extract.js.map +1 -1
  241. package/dist/templates/markdown/spec/backend/index.md.txt +4 -35
  242. package/dist/templates/markdown/spec/frontend/index.md.txt +4 -36
  243. package/dist/templates/omp/extensions/trellis/index.ts.txt +2 -2
  244. package/dist/templates/opencode/lib/session-utils.js +301 -209
  245. package/dist/templates/pi/extensions/trellis/index.ts.txt +17 -1
  246. package/dist/templates/shared-hooks/guard-teamwork-wait.py +58 -0
  247. package/dist/templates/shared-hooks/index.d.ts +1 -1
  248. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  249. package/dist/templates/shared-hooks/index.js +1 -0
  250. package/dist/templates/shared-hooks/index.js.map +1 -1
  251. package/dist/templates/shared-hooks/inject-workflow-state.py +8 -3
  252. package/dist/templates/shared-hooks/session-start.py +16 -12
  253. package/dist/templates/snow/SNOW.md +1 -1
  254. package/dist/templates/trellis/agents/delegated-worktree-primary-agent.md +9 -0
  255. package/dist/templates/trellis/agents/profiles/architecture.md +9 -0
  256. package/dist/templates/trellis/agents/profiles/diagnosis.md +9 -0
  257. package/dist/templates/trellis/agents/profiles/exploration.md +9 -0
  258. package/dist/templates/trellis/agents/profiles/research.md +9 -0
  259. package/dist/templates/trellis/agents/profiles/review.md +9 -0
  260. package/dist/templates/trellis/agents/profiles/verification.md +9 -0
  261. package/dist/templates/trellis/agents/teammate-profiles.json +56 -4
  262. package/dist/templates/trellis/agents/teammate.md +4 -52
  263. package/dist/templates/trellis/config.yaml +2 -5
  264. package/dist/templates/trellis/index.d.ts +4 -0
  265. package/dist/templates/trellis/index.d.ts.map +1 -1
  266. package/dist/templates/trellis/index.js +22 -4
  267. package/dist/templates/trellis/index.js.map +1 -1
  268. package/dist/templates/trellis/scripts/add_session.py +1 -1
  269. package/dist/templates/trellis/scripts/common/active_task.py +3 -47
  270. package/dist/templates/trellis/scripts/common/config.py +5 -0
  271. package/dist/templates/trellis/scripts/common/paths.py +9 -25
  272. package/dist/templates/trellis/scripts/common/safe_commit.py +8 -8
  273. package/dist/templates/trellis/scripts/common/task_context.py +11 -51
  274. package/dist/templates/trellis/scripts/common/task_continuity.py +191 -60
  275. package/dist/templates/trellis/scripts/common/task_layout.py +80 -0
  276. package/dist/templates/trellis/scripts/common/task_move.py +345 -0
  277. package/dist/templates/trellis/scripts/common/task_store.py +51 -57
  278. package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
  279. package/dist/templates/trellis/scripts/common/task_workflow.py +395 -49
  280. package/dist/templates/trellis/scripts/common/tasks.py +6 -5
  281. package/dist/templates/trellis/scripts/hooks/linear_sync.py +13 -3
  282. package/dist/templates/trellis/scripts/task.py +76 -24
  283. package/dist/templates/trellis/teamwork.json +10 -0
  284. package/dist/templates/trellis/workflow.md +11 -2
  285. package/dist/utils/ablation-store.d.ts +1 -1
  286. package/dist/utils/ablation-store.d.ts.map +1 -1
  287. package/dist/utils/ablation-store.js +58 -24
  288. package/dist/utils/ablation-store.js.map +1 -1
  289. package/dist/utils/asset-backup.d.ts +138 -0
  290. package/dist/utils/asset-backup.d.ts.map +1 -0
  291. package/dist/utils/asset-backup.js +525 -0
  292. package/dist/utils/asset-backup.js.map +1 -0
  293. package/dist/utils/atomic-write.d.ts +1 -1
  294. package/dist/utils/atomic-write.d.ts.map +1 -1
  295. package/dist/utils/atomic-write.js +6 -3
  296. package/dist/utils/atomic-write.js.map +1 -1
  297. package/dist/utils/manifest-prune.d.ts +4 -1
  298. package/dist/utils/manifest-prune.d.ts.map +1 -1
  299. package/dist/utils/manifest-prune.js +17 -6
  300. package/dist/utils/manifest-prune.js.map +1 -1
  301. package/dist/utils/template-fetcher.d.ts +4 -19
  302. package/dist/utils/template-fetcher.d.ts.map +1 -1
  303. package/dist/utils/template-fetcher.js +9 -45
  304. package/dist/utils/template-fetcher.js.map +1 -1
  305. package/dist/utils/template-hash.d.ts +5 -4
  306. package/dist/utils/template-hash.d.ts.map +1 -1
  307. package/dist/utils/template-hash.js +24 -17
  308. package/dist/utils/template-hash.js.map +1 -1
  309. package/package.json +3 -2
  310. package/dist/templates/copilot/prompts/before-dev.prompt.md +0 -39
  311. package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -13
  312. package/dist/templates/copilot/prompts/break-loop.prompt.md +0 -129
  313. package/dist/templates/copilot/prompts/check-cross-layer.prompt.md +0 -170
  314. package/dist/templates/copilot/prompts/check.prompt.md +0 -110
  315. package/dist/templates/copilot/prompts/create-command.prompt.md +0 -116
  316. package/dist/templates/copilot/prompts/finish-work.prompt.md +0 -22
  317. package/dist/templates/copilot/prompts/integrate-skill.prompt.md +0 -223
  318. package/dist/templates/copilot/prompts/onboard.prompt.md +0 -362
  319. package/dist/templates/copilot/prompts/record-session.prompt.md +0 -58
  320. package/dist/templates/copilot/prompts/start.prompt.md +0 -65
  321. package/dist/templates/copilot/prompts/update-spec.prompt.md +0 -358
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import contextlib
7
+ import copy
7
8
  from datetime import datetime, timezone
8
9
  import hashlib
9
10
  import json
@@ -61,11 +62,14 @@ def _task_dir(value: str) -> Path:
61
62
  return task_dir
62
63
 
63
64
 
64
- def _load(task_dir: Path) -> tuple[dict, dict]:
65
+ def _load(task_dir: Path, *, initialize_meta: bool = False) -> tuple[dict, dict]:
65
66
  task_file = task_dir / FILE_TASK_JSON
66
67
  data, reason = read_json_checked(task_file)
67
68
  if data is None:
68
69
  raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"cannot read {task_file}: {reason}")
70
+ # Explicit initialization may adopt legacy records; malformed metadata still fails.
71
+ if initialize_meta and "meta" not in data:
72
+ data["meta"] = {}
69
73
  meta = data.get("meta")
70
74
  if not isinstance(meta, dict):
71
75
  raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task.meta must be an object")
@@ -106,6 +110,28 @@ def _state(meta: dict) -> dict:
106
110
  raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "route dependency admission has an invalid shape")
107
111
  if state.get("current_phase_id") is not None and state["current_phase_id"] not in state["phase_ids"]:
108
112
  raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "current phase is not in the task route")
113
+ skip_records = state.get("skip_records")
114
+ if not isinstance(skip_records, dict):
115
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task phase skip records have an invalid shape")
116
+ for phase_id, skip in skip_records.items():
117
+ if (
118
+ phase_id not in PHASE_ID_SET
119
+ or not isinstance(skip, dict)
120
+ or not isinstance(skip.get("reason"), str)
121
+ or not skip["reason"].strip()
122
+ or not isinstance(skip.get("evidence_refs"), list)
123
+ or not skip["evidence_refs"]
124
+ or any(not isinstance(value, str) or not value.strip() for value in skip["evidence_refs"])
125
+ or skip.get("task_route_revision") != state["task_route_revision"]
126
+ or not ROUTE_REVISION.fullmatch(str(skip.get("input_revision", "")))
127
+ or not isinstance(skip.get("artifact_revision"), str)
128
+ or not re.fullmatch(r"[a-f0-9]{64}", skip["artifact_revision"])
129
+ or (
130
+ phase_id in state["phases"]
131
+ and skip["artifact_revision"] != state["phases"][phase_id].get("artifact_revision")
132
+ )
133
+ ):
134
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"task phase skip record is invalid: {phase_id}")
109
135
  for phase_id in state["phase_ids"]:
110
136
  record = state["phases"].get(phase_id)
111
137
  if not isinstance(record, dict) or record.get("status") not in {"not_started", "active", "waiting_user", "blocked", "accepted", "skipped"}:
@@ -130,6 +156,10 @@ def implementation_start_ready(data: dict, task_dir: Path) -> bool:
130
156
  phase_ids = state.get("phase_ids")
131
157
  if not isinstance(phase_ids, list) or PHASE_IDS[3] not in phase_ids:
132
158
  return False
159
+ phases = state.get("phases")
160
+ implementation = phases.get(PHASE_IDS[3]) if isinstance(phases, dict) else None
161
+ if not isinstance(implementation, dict) or implementation.get("status") != "active":
162
+ return False
133
163
  if state.get("task_kind") not in {"implementation", "combined"}:
134
164
  return False
135
165
  authorization = state.get("implementation_authorization")
@@ -145,6 +175,7 @@ def implementation_start_ready(data: dict, task_dir: Path) -> bool:
145
175
  ):
146
176
  return False
147
177
  _require_current_requirements_confirmation(task_dir, state)
178
+ _require_current_accepted_artifacts(task_dir, state)
148
179
  if PHASE_IDS[2] not in phase_ids:
149
180
  skip_records = state.get("skip_records")
150
181
  skipped_design = skip_records.get(PHASE_IDS[2]) if isinstance(skip_records, dict) else None
@@ -176,7 +207,8 @@ def _save(task_file: Path, data: dict) -> None:
176
207
 
177
208
  @contextlib.contextmanager
178
209
  def _task_lock(task_dir: Path):
179
- lock_file = task_dir / ".task-phase.lock"
210
+ lock_file = get_repo_root() / ".trellis/.runtime/task-phase" / f"{task_dir.name}.lock"
211
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
180
212
  with lock_file.open("a+b") as handle:
181
213
  if os.name == "nt":
182
214
  import msvcrt
@@ -223,28 +255,39 @@ def _phase(state: dict, phase_id: str | None = None) -> tuple[str, dict]:
223
255
  return current, record
224
256
 
225
257
 
226
- def _require_current_requirements_confirmation(task_dir: Path, state: dict) -> None:
227
- if PHASE_IDS[0] not in state["phase_ids"]:
228
- return
258
+ def _is_withdrawal_route(state: dict) -> bool:
229
259
  requirements = state["phases"].get(PHASE_IDS[0], {})
230
260
  withdrawal = state.get("return_record")
231
261
  skip_records = state.get("skip_records")
232
262
  skipped_requirements = skip_records.get(PHASE_IDS[0]) if isinstance(skip_records, dict) else None
233
- if (
263
+ return (
234
264
  state["phase_ids"] == [PHASE_IDS[0], PHASE_IDS[4]]
235
- and state.get("current_phase_id") == PHASE_IDS[4]
265
+ and state.get("current_phase_id") in {PHASE_IDS[4], None}
236
266
  and isinstance(requirements, dict)
237
- and requirements.get("status") == "skipped"
238
- and isinstance(skipped_requirements, dict)
239
- and skipped_requirements.get("task_route_revision") == state.get("task_route_revision")
267
+ and (
268
+ requirements.get("status") == "accepted"
269
+ or (
270
+ requirements.get("status") == "skipped"
271
+ and isinstance(skipped_requirements, dict)
272
+ and skipped_requirements.get("task_route_revision") == state.get("task_route_revision")
273
+ )
274
+ )
240
275
  and isinstance(withdrawal, dict)
241
276
  and isinstance(withdrawal.get("from"), str)
242
277
  and withdrawal["from"] in PHASE_ID_SET
243
278
  and withdrawal.get("to") == PHASE_IDS[4]
279
+ and withdrawal.get("return_kind") == "withdrawal"
244
280
  and withdrawal.get("task_route_revision") == state.get("task_route_revision")
245
281
  and isinstance(withdrawal.get("evidence_refs"), list)
246
282
  and bool(withdrawal["evidence_refs"])
247
- ):
283
+ )
284
+
285
+
286
+ def _require_current_requirements_confirmation(task_dir: Path, state: dict) -> None:
287
+ if PHASE_IDS[0] not in state["phase_ids"]:
288
+ return
289
+ requirements = state["phases"].get(PHASE_IDS[0], {})
290
+ if requirements.get("status") == "skipped" and _is_withdrawal_route(state):
248
291
  return
249
292
  acceptance = requirements.get("acceptance") if isinstance(requirements, dict) else None
250
293
  if (
@@ -280,6 +323,7 @@ def _invalidate_from(state: dict, first_phase: str, keep_first: bool = False) ->
280
323
  for phase_id in state["phase_ids"]:
281
324
  if PHASE_IDS.index(phase_id) < first_index:
282
325
  continue
326
+ state["skip_records"].pop(phase_id, None)
283
327
  record = state["phases"][phase_id]
284
328
  if keep_first and phase_id == first_phase:
285
329
  record["status"] = "active"
@@ -327,6 +371,24 @@ def _artifact_digest(task_dir: Path, refs: list[str]) -> str:
327
371
  return digest.hexdigest()
328
372
 
329
373
 
374
+ def _require_current_accepted_artifacts(task_dir: Path, state: dict) -> None:
375
+ for phase_id in state["phase_ids"]:
376
+ record = state["phases"][phase_id]
377
+ if record.get("status") != "accepted":
378
+ continue
379
+ refs = record.get("artifact_refs")
380
+ if not isinstance(refs, list) or any(not isinstance(ref, str) for ref in refs):
381
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"accepted artifact references are invalid: {phase_id}")
382
+ if _artifact_digest(task_dir, refs) != record.get("artifact_revision"):
383
+ raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", f"accepted artifacts changed for {phase_id}; return to the affected phase and review the current candidate again")
384
+ authorization = state.get("implementation_authorization")
385
+ if authorization is not None:
386
+ if not isinstance(authorization, dict) or not isinstance(authorization.get("contract_ref"), str):
387
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "implementation authorization has no contract reference")
388
+ if _artifact_digest(task_dir, [authorization["contract_ref"]]) != authorization.get("contract_revision"):
389
+ raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "implementation contract changed after authorization; review and authorize its current content before continuing")
390
+
391
+
330
392
  def _validate_route(value: str | None) -> list[str]:
331
393
  raw = _required_text(value, "--phase-ids")
332
394
  phases = raw.split(",")
@@ -350,6 +412,8 @@ def _validate_classification(task_kind: str | None, complexity: str | None, proj
350
412
  raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "--project-context must be new or existing")
351
413
  if task_kind == "analysis" and PHASE_IDS[3] in phases:
352
414
  raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "analysis tasks cannot include the Task Implementation and Test Verification Phase")
415
+ if task_kind in {"implementation", "combined"} and PHASE_IDS[3] not in phases:
416
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "implementation and combined tasks must include the Task Implementation and Test Verification Phase")
353
417
 
354
418
 
355
419
  def _increment(value: str, allow_zero: bool = False) -> str:
@@ -402,7 +466,13 @@ def _init(args: argparse.Namespace, task_dir: Path, data: dict, meta: dict) -> i
402
466
  "implementation_authorization": None,
403
467
  "closeout_disposition": None,
404
468
  "skip_records": {
405
- phase: {"reason": route_reason, "evidence_refs": route_evidence, "task_route_revision": "1"}
469
+ phase: {
470
+ "reason": route_reason,
471
+ "evidence_refs": route_evidence,
472
+ "task_route_revision": "1",
473
+ "input_revision": input_revision,
474
+ "artifact_revision": _phase_record(input_revision)["artifact_revision"],
475
+ }
406
476
  for phase in omitted
407
477
  },
408
478
  "return_record": None,
@@ -417,6 +487,31 @@ def _show(state: dict) -> int:
417
487
  return 0
418
488
 
419
489
 
490
+ def _require_acceptance_coverage(task_dir: Path, contract_ref: str) -> None:
491
+ _artifact_digest(task_dir, [contract_ref])
492
+ lines = (task_dir / contract_ref).read_text(encoding="utf-8").splitlines()
493
+ heading = next((index for index, line in enumerate(lines) if line.strip() == "## 验收覆盖"), None)
494
+ headers = ["接受条件", "工作包", "Owner", "验证入口", "交付证据", "停止条件"]
495
+ if heading is not None:
496
+ section = []
497
+ for line in lines[heading + 1:]:
498
+ if line.startswith("#"):
499
+ break
500
+ if line.strip():
501
+ section.append(line.strip())
502
+ rows = [line.strip("|").split("|") for line in section if line.startswith("|") and line.endswith("|")]
503
+ rows = [[cell.strip() for cell in row] for row in rows]
504
+ if (
505
+ len(rows) >= 3
506
+ and rows[0] == headers
507
+ and len(rows[1]) == len(headers)
508
+ and all(re.fullmatch(r":?-{3,}:?", cell) for cell in rows[1])
509
+ and all(len(row) == len(headers) and all(row) for row in rows[2:])
510
+ ):
511
+ return
512
+ raise TaskWorkflowError("TASK_PHASE_COVERAGE_REQUIRED", "implementation contract requires ## 验收覆盖 with nonempty rows: " + " | ".join(headers))
513
+
514
+
420
515
  def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -> int:
421
516
  action = args.phase_action
422
517
  child_dispositions: dict[str, str] = {}
@@ -427,14 +522,21 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
427
522
  raise TaskWorkflowError("TASK_PHASE_TASK_TERMINAL", f"task is already {data['status']}")
428
523
 
429
524
  _expected_route(state, args.expected_route_revision)
430
- if action == "route" and state.get("current_phase_id") is None:
431
- raise TaskWorkflowError("TASK_PHASE_INCOMPLETE", "completed phases cannot be rerouted")
432
- current, record = _phase(state)
525
+ reviewed_delivery = (
526
+ state.get("current_phase_id") is None
527
+ and action in {"return", "route"}
528
+ and state["phases"].get(PHASE_IDS[4], {}).get("status") == "accepted"
529
+ )
530
+ if reviewed_delivery:
531
+ current, record = PHASE_IDS[4], state["phases"][PHASE_IDS[4]]
532
+ else:
533
+ current, record = _phase(state)
534
+ accepted_design_return = action == "return" and current == PHASE_IDS[2] and record.get("status") == "accepted"
433
535
  returning_to_requirements = action == "return" and args.target_phase == PHASE_IDS[0]
434
536
  rerouting_to_requirements = action == "route" and args.target_phase == PHASE_IDS[0]
435
537
  if current != PHASE_IDS[0] and not returning_to_requirements and not rerouting_to_requirements:
436
538
  _require_current_requirements_confirmation(task_dir, state)
437
- if action not in {"show", "resume", "route", "authorize", "admission"} and record.get("status") != "active":
539
+ if action not in {"show", "resume", "route", "authorize", "admission"} and record.get("status") != "active" and not reviewed_delivery and not accepted_design_return:
438
540
  raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "only the active phase can be changed")
439
541
 
440
542
  if action == "artifact":
@@ -478,9 +580,16 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
478
580
  if current == PHASE_IDS[0]:
479
581
  raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "The Goal Exploration and Requirements Confirmation Phase must reach minimum alignment and acceptance and cannot be skipped")
480
582
  record.update({"status": "skipped", "reason": reason, "evidence_refs": evidence})
481
- state["skip_records"][current] = {"reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
583
+ state["skip_records"][current] = {
584
+ "reason": reason,
585
+ "evidence_refs": evidence,
586
+ "task_route_revision": state["task_route_revision"],
587
+ "input_revision": state["input_revision"],
588
+ "artifact_revision": record["artifact_revision"],
589
+ }
482
590
  _advance(state, current)
483
591
  elif action == "accept":
592
+ _require_current_accepted_artifacts(task_dir, state)
484
593
  artifact_revision = _required_text(args.artifact_revision, "--artifact-revision")
485
594
  current_revision = _artifact_digest(task_dir, record["artifact_refs"])
486
595
  if artifact_revision != record["artifact_revision"] or current_revision != record["artifact_revision"]:
@@ -495,6 +604,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
495
604
  raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "Solution Design and Convergence Phase must remain active for pending_user_decision or deferred; accept implementation_contract, analysis_conclusion, or no_go")
496
605
  if result_ref not in record["artifact_refs"]:
497
606
  raise TaskWorkflowError("TASK_PHASE_RESULT_NOT_BOUND", "--result-ref must be included in the current phase artifact refs")
607
+ if current == PHASE_IDS[2] and outcome == "implementation_contract":
608
+ _require_acceptance_coverage(task_dir, result_ref)
498
609
  if current == PHASE_IDS[3]:
499
610
  authorization = state.get("implementation_authorization")
500
611
  if not isinstance(authorization, dict) or authorization.get("task_route_revision") != state["task_route_revision"]:
@@ -505,8 +616,7 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
505
616
  if outcome != args.closeout_disposition:
506
617
  raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "Delivery Review and On-Site Closeout Phase outcome must match --closeout-disposition")
507
618
  if args.closeout_disposition == "cancelled":
508
- withdrawal = state.get("return_record")
509
- if not isinstance(withdrawal, dict) or withdrawal.get("to") != PHASE_IDS[4] or not withdrawal.get("evidence_refs"):
619
+ if not _is_withdrawal_route(state):
510
620
  raise TaskWorkflowError("TASK_PHASE_USER_CONFIRMATION_REQUIRED", "cancellation closeout requires a user-confirmed route to Delivery Review and On-Site Closeout Phase")
511
621
  child_dispositions = _validate_child_dispositions(
512
622
  task_dir, data, args.closeout_disposition, args.child_disposition
@@ -532,14 +642,38 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
532
642
  _advance(state, current)
533
643
  elif action == "return":
534
644
  target = _required_text(args.target_phase, "--target-phase")
535
- if target not in state["phase_ids"] or state["phase_ids"].index(target) >= state["phase_ids"].index(current):
645
+ return_kind = args.return_kind
646
+ execution_rework = return_kind == "execution_rework"
647
+ if target not in state["phase_ids"] or (
648
+ state["phase_ids"].index(target) >= state["phase_ids"].index(current)
649
+ and not ((reviewed_delivery or accepted_design_return or execution_rework) and target == current)
650
+ ):
536
651
  raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "return target must be an earlier phase in the current route")
537
652
  reason = _required_text(args.reason, "--reason")
538
653
  evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
654
+ authorization = state.get("implementation_authorization")
655
+ admission = state.get("route_dependency_admission")
656
+ if execution_rework:
657
+ if current not in PHASE_IDS[3:] or target not in PHASE_IDS[3:] or PHASE_IDS[3] not in state["phase_ids"]:
658
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "execution_rework is limited to implementation and delivery phases")
659
+ candidate = copy.deepcopy(data)
660
+ candidate_state = candidate["meta"]["task_workflow"]
661
+ _invalidate_from(candidate_state, target, keep_first=True)
662
+ candidate_state["implementation_authorization"] = authorization
663
+ _require_current_accepted_artifacts(task_dir, candidate_state)
664
+ candidate_state["current_phase_id"] = PHASE_IDS[3]
665
+ candidate_state["phases"][PHASE_IDS[3]]["status"] = "active"
666
+ if not implementation_start_ready(candidate, task_dir):
667
+ raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_AUTHORIZED", "execution_rework requires the unchanged authorized implementation contract")
668
+ if admission is not None and (admission.get("admission_state") != "ready" or admission.get("task_route_revision") != state["task_route_revision"]):
669
+ raise TaskWorkflowError("TASK_PHASE_ADMISSION_REQUIRED", "execution_rework requires current ready dependency admission")
539
670
  state["input_revision"] = _increment(state["input_revision"])
540
671
  _invalidate_from(state, target, keep_first=True)
672
+ if execution_rework:
673
+ state["implementation_authorization"] = authorization
674
+ state["route_dependency_admission"] = admission
541
675
  state["phases"][target]["input_revision"] = state["input_revision"]
542
- state["return_record"] = {"from": current, "to": target, "reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
676
+ state["return_record"] = {"from": current, "to": target, "return_kind": return_kind, "authorization_retained": execution_rework, "reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
543
677
  elif action == "route":
544
678
  phases = _validate_route(args.phase_ids)
545
679
  task_kind = args.task_kind if args.task_kind is not None else state["task_kind"]
@@ -563,8 +697,9 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
563
697
  target == PHASE_IDS[4]
564
698
  and phases == [PHASE_IDS[0], PHASE_IDS[4]]
565
699
  and bool(evidence)
700
+ and args.return_kind == "withdrawal"
566
701
  )
567
- if not changed and not classification_changed and target == current:
702
+ if not changed and not classification_changed and target == current and not reviewed_delivery:
568
703
  raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "the updated route and target are unchanged")
569
704
  earliest_change = min((PHASE_IDS.index(phase) for phase in changed), default=PHASE_IDS.index(target))
570
705
  if target not in PHASE_ID_SET or (PHASE_IDS.index(target) > earliest_change and not withdrawal_route):
@@ -604,9 +739,25 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
604
739
  for phase_id, phase_record in state["phases"].items():
605
740
  if phase_id not in old_phases:
606
741
  phase_record["input_revision"] = state["input_revision"]
607
- state["skip_records"] = {}
742
+ state["skip_records"] = {
743
+ phase_id: {
744
+ **skip,
745
+ "task_route_revision": state["task_route_revision"],
746
+ "input_revision": state["input_revision"],
747
+ "artifact_revision": state["phases"][phase_id]["artifact_revision"],
748
+ }
749
+ for phase_id, skip in state["skip_records"].items()
750
+ if phase_id in phases and state["phases"][phase_id].get("status") == "skipped"
751
+ }
608
752
  for phase_id in omitted:
609
- state["skip_records"][phase_id] = {"reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
753
+ phase_record = state["phases"].get(phase_id, old_phases.get(phase_id, _phase_record(state["input_revision"])))
754
+ state["skip_records"][phase_id] = {
755
+ "reason": reason,
756
+ "evidence_refs": evidence,
757
+ "task_route_revision": state["task_route_revision"],
758
+ "input_revision": state["input_revision"],
759
+ "artifact_revision": phase_record["artifact_revision"],
760
+ }
610
761
  if withdrawal_route and state["phases"][PHASE_IDS[0]].get("status") != "accepted":
611
762
  state["phases"][PHASE_IDS[0]].update({
612
763
  "status": "skipped",
@@ -620,6 +771,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
620
771
  "reason": reason,
621
772
  "evidence_refs": evidence,
622
773
  "task_route_revision": state["task_route_revision"],
774
+ "input_revision": state["input_revision"],
775
+ "artifact_revision": state["phases"][PHASE_IDS[0]]["artifact_revision"],
623
776
  }
624
777
  state["implementation_authorization"] = None
625
778
  state["closeout_disposition"] = None
@@ -627,6 +780,7 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
627
780
  state["return_record"] = {
628
781
  "from": current,
629
782
  "to": target,
783
+ **({"return_kind": "withdrawal"} if withdrawal_route else {}),
630
784
  "reason": reason,
631
785
  "evidence_refs": evidence,
632
786
  "task_route_revision": state["task_route_revision"],
@@ -647,7 +801,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
647
801
  raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "Solution Design and Convergence Phase implementation contract changed after acceptance; refresh and accept the current artifact before authorization")
648
802
  elif contract_ref not in state.get("skip_records", {}).get(PHASE_IDS[2], {}).get("evidence_refs", []):
649
803
  raise TaskWorkflowError("TASK_PHASE_CONTRACT_NOT_ACCEPTED", "when Solution Design and Convergence Phase is omitted, the implementation contract must be among its route evidence")
650
- state["implementation_authorization"] = {"contract_ref": contract_ref, "approval_ref": approval_ref, "task_route_revision": state["task_route_revision"]}
804
+ _require_acceptance_coverage(task_dir, contract_ref)
805
+ state["implementation_authorization"] = {"contract_ref": contract_ref, "contract_revision": _artifact_digest(task_dir, [contract_ref]), "approval_ref": approval_ref, "task_route_revision": state["task_route_revision"]}
651
806
  if current == PHASE_IDS[2]:
652
807
  _advance(state, current)
653
808
  else:
@@ -662,7 +817,7 @@ def cmd_phase(args: argparse.Namespace) -> int:
662
817
  try:
663
818
  task_dir = _task_dir(args.task_dir)
664
819
  with _task_lock(task_dir):
665
- data, meta = _load(task_dir)
820
+ data, meta = _load(task_dir, initialize_meta=args.phase_action == "init")
666
821
  if args.phase_action == "init":
667
822
  return _init(args, task_dir, data, meta)
668
823
  return _update(args, task_dir, data, _state(meta))
@@ -707,6 +862,56 @@ def _paseo_list(args: list[str], cwd: Path, code: str) -> list[dict]:
707
862
  return value
708
863
 
709
864
 
865
+ def _delegation_binding(repo_root: Path, child_dir: Path, child_data: dict, result_path: str) -> dict:
866
+ if not isinstance(result_path, str) or Path(result_path).is_absolute() or ".." in Path(result_path).parts or not result_path.startswith(".trellis/delegated-results/"):
867
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", "result path must stay under .trellis/delegated-results/")
868
+ try:
869
+ contract_path = (child_dir / "prd.md").resolve(strict=True)
870
+ contract_path.relative_to(child_dir.resolve())
871
+ role_path = (repo_root / ".trellis/agents/delegated-worktree-primary-agent.md").resolve(strict=True)
872
+ role_path.relative_to((repo_root / ".trellis/agents").resolve())
873
+ if not contract_path.is_file() or not role_path.is_file() or contract_path.stat().st_size + role_path.stat().st_size > 1048576:
874
+ raise ValueError("delegated contract and role must be regular files within the size budget")
875
+ contract = contract_path.read_bytes()
876
+ role = role_path.read_bytes()
877
+ except (OSError, ValueError) as error:
878
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", str(error)) from error
879
+ if not contract.strip() or not role.strip():
880
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", "child PRD and delegated role must be nonempty")
881
+ return {
882
+ "contract_sha256": hashlib.sha256(contract).hexdigest(),
883
+ "role_sha256": hashlib.sha256(role).hexdigest(),
884
+ "branch": _required_text(child_data.get("branch"), "child branch"),
885
+ "base_commit": _git_output(["rev-parse", "--verify", "--end-of-options", f"{_required_text(child_data.get('base_branch'), 'child base_branch')}^{{commit}}"], repo_root),
886
+ "result_path": result_path,
887
+ }
888
+
889
+
890
+ def cmd_delegate_bind(args: argparse.Namespace) -> int:
891
+ try:
892
+ repo_root = get_repo_root().resolve()
893
+ child_dir = _task_dir(args.child_task)
894
+ child, _ = _load(child_dir)
895
+ if not isinstance(child.get("parent"), str) or not child["parent"].strip():
896
+ raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "child task has no parent link")
897
+ parent_dir = _task_dir(child["parent"])
898
+ parent, meta = _load(parent_dir)
899
+ if child_dir.name not in parent.get("children", []) or not implementation_start_ready(parent, parent_dir):
900
+ raise TaskWorkflowError("TASK_DELEGATE_IMPLEMENTATION_NOT_AUTHORIZED", "bind only a linked child within current parent implementation authorization")
901
+ state = _state(meta)
902
+ authorization = state["implementation_authorization"]
903
+ result_path = args.result_path or f".trellis/delegated-results/{child_dir.name}.md"
904
+ binding = _delegation_binding(repo_root, child_dir, child, result_path)
905
+ binding.update({"authorization_ref": authorization["approval_ref"], "parent_contract_revision": authorization["contract_revision"], "task_route_revision": state["task_route_revision"]})
906
+ meta.setdefault("delegation_authorizations", {})[child_dir.name] = binding
907
+ _save(parent_dir / FILE_TASK_JSON, parent)
908
+ print(json.dumps(binding, ensure_ascii=False))
909
+ return 0
910
+ except TaskWorkflowError as error:
911
+ print(f"{error.code}: {error}", file=sys.stderr)
912
+ return 1
913
+
914
+
710
915
  def cmd_delegate_start(args: argparse.Namespace) -> int:
711
916
  try:
712
917
  repo_root = get_repo_root().resolve()
@@ -722,6 +927,11 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
722
927
  child_data, child_meta = _load(child_dir)
723
928
  if child_data.get("parent") != parent_dir.name or child_dir.name not in parent_data.get("children", []):
724
929
  raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "parent and child task links do not match")
930
+ from .task_continuity import assert_task_mutation_allowed
931
+
932
+ assert_task_mutation_allowed(repo_root, parent_dir, parent_data)
933
+ if child_data.get("status") not in {"planning", "in_progress"}:
934
+ raise TaskWorkflowError("TASK_DELEGATE_CHILD_TERMINAL", "only a planning or in-progress child task can be delegated")
725
935
  if parent_data.get("status") != "in_progress" or not implementation_start_ready(parent_data, parent_dir):
726
936
  raise TaskWorkflowError("TASK_DELEGATE_IMPLEMENTATION_NOT_AUTHORIZED", "parent task is not at an authorized Task Implementation and Test Verification Phase entry")
727
937
 
@@ -775,6 +985,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
775
985
  raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", f"could not run Paseo: {error}") from error
776
986
  if version.returncode != 0:
777
987
  raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", (version.stderr or version.stdout).strip() or "Paseo CLI preflight failed")
988
+ _paseo_list(["agent", "ls", "--all", "--global", "--background", "--json"], repo_root, "TASK_DELEGATE_PASEO_UNAVAILABLE")
778
989
  role_path = repo_root / ".trellis" / "agents" / "delegated-worktree-primary-agent.md"
779
990
  try:
780
991
  role = role_path.read_text(encoding="utf-8")
@@ -785,6 +996,16 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
785
996
  if not isinstance(authorization, dict) or not isinstance(authorization.get("approval_ref"), str):
786
997
  raise TaskWorkflowError("TASK_DELEGATE_AUTHORIZATION_INVALID", "current implementation authorization is invalid")
787
998
  contract_digest = hashlib.sha256(contract_bytes).hexdigest()
999
+ bindings = parent_meta.get("delegation_authorizations", {})
1000
+ binding = bindings.get(child_dir.name) if isinstance(bindings, dict) else None
1001
+ if not isinstance(binding, dict):
1002
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_NOT_BOUND", "freeze this child through delegate-bind before dispatch")
1003
+ actual = _delegation_binding(repo_root, child_dir, child_data, binding.get("result_path", ""))
1004
+ if any(binding.get(key) != value for key, value in actual.items()) or actual["contract_sha256"] != contract_digest or actual["role_sha256"] != hashlib.sha256(role.encode("utf-8")).hexdigest() or binding.get("authorization_ref") != authorization["approval_ref"] or binding.get("parent_contract_revision") != authorization["contract_revision"] or binding.get("task_route_revision") != state["task_route_revision"]:
1005
+ raise TaskWorkflowError("TASK_DELEGATE_STALE_CONTRACT", "child PRD, role, Git base, output path, or parent authorization changed; review and bind the child again")
1006
+ prompt_bytes = len((role + contract).encode("utf-8"))
1007
+ if prompt_bytes > (24000 if os.name == "nt" else 96000):
1008
+ raise TaskWorkflowError("TASK_DELEGATE_PROMPT_TOO_LARGE", "delegated prompt exceeds the native Paseo argv budget; narrow the contract before creating resources")
788
1009
  previous = child_meta.get("delegated_worktree_execution_record")
789
1010
  reuse_workspace = False
790
1011
  workspace_id = None
@@ -808,7 +1029,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
808
1029
  previous_worktree = Path(previous_worktree_path).expanduser().resolve()
809
1030
  agents = _paseo_list([
810
1031
  "agent", "ls", "--all", "--global", "--background", "--json",
811
- "--label", f"trellis.task_id={child_dir.name}",
1032
+ "--label", f"trellis.task_id={previous.get('label_task_id', child_dir.name)}",
812
1033
  ], repo_root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
813
1034
  for agent in agents:
814
1035
  agent_id = agent.get("id")
@@ -859,9 +1080,14 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
859
1080
  "workspace_id": workspace_id,
860
1081
  "worktree_path": str(worktree) if worktree else None,
861
1082
  "agent_id": None,
1083
+ "label_task_id": child_dir.name,
1084
+ "role_sha256": actual["role_sha256"],
1085
+ "result_path": actual["result_path"],
862
1086
  "status": "starting",
863
1087
  "started_at": datetime.now(timezone.utc).isoformat(),
864
1088
  }
1089
+ if previous is not None:
1090
+ child_meta.setdefault("delegated_worktree_execution_history", []).append(previous)
865
1091
  child_meta["delegated_worktree_execution_record"] = record
866
1092
  _save(child_dir / FILE_TASK_JSON, child_data)
867
1093
 
@@ -902,6 +1128,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
902
1128
  "the parent authorization and Git baseline are execution metadata. Do not accept task phases or change task lifecycle state.\n\n"
903
1129
  f"Parent task: {parent_dir.name}\nParent route revision: {state['task_route_revision']}\n"
904
1130
  f"Authorized contract: prd.md\nBranch: {branch}\nBase commit: {base_commit}\n\n"
1131
+ f"Assigned result artifact: {actual['result_path']}\n"
905
1132
  f"{role}\n\n## Child task prd.md\n{contract}"
906
1133
  )
907
1134
  agent = _paseo_json([
@@ -939,6 +1166,7 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
939
1166
  readiness_error = task_closeout_readiness(data, "cancelled")
940
1167
  if readiness_error:
941
1168
  raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
1169
+ _require_current_accepted_artifacts(task_dir, _state(meta))
942
1170
  _validate_child_dispositions(task_dir, data, "cancelled", None, from_acceptance=True)
943
1171
  _assert_delegated_resources_closed(task_dir, data)
944
1172
  meta["task_cancellation"] = {
@@ -947,7 +1175,8 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
947
1175
  }
948
1176
  data["status"] = "cancelled"
949
1177
  data["completedAt"] = None
950
- _save(task_dir / FILE_TASK_JSON, data)
1178
+ from .task_move import relocate_task
1179
+ task_dir = relocate_task(get_repo_root(), task_dir, data)
951
1180
  from .active_task import clear_task_from_sessions
952
1181
  from .task_continuity import clear_task_runtime_records
953
1182
 
@@ -961,6 +1190,56 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
961
1190
  return 1
962
1191
 
963
1192
 
1193
+ def cmd_reopen_task(args: argparse.Namespace) -> int:
1194
+ try:
1195
+ task_dir = _task_dir(args.task_dir)
1196
+ with _task_lock(task_dir):
1197
+ data, meta = _load(task_dir)
1198
+ previous_status = data.get("status")
1199
+ if previous_status not in {"completed", "cancelled"}:
1200
+ raise TaskWorkflowError("TASK_PHASE_REOPEN_INVALID_STATUS", "only completed or cancelled tasks can be reopened")
1201
+ reason = _required_text(args.reason, "--reason")
1202
+ evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
1203
+ _assert_delegated_resources_closed(task_dir, data)
1204
+ recoveries = meta.setdefault("task_lifecycle_recoveries", [])
1205
+ if not isinstance(recoveries, list) or any(not isinstance(record, dict) for record in recoveries):
1206
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task lifecycle recovery history is malformed")
1207
+ recovery = {
1208
+ "from_status": previous_status,
1209
+ "to_status": "planning",
1210
+ "reason": reason,
1211
+ "evidence_refs": evidence,
1212
+ "reopened_at": datetime.now(timezone.utc).isoformat(),
1213
+ "terminal_completed_at": data.get("completedAt"),
1214
+ "terminal_cancellation": copy.deepcopy(meta.get("task_cancellation")),
1215
+ }
1216
+ state = meta.get("task_workflow")
1217
+ if state is not None:
1218
+ state = _state(meta)
1219
+ state["implementation_authorization"] = None
1220
+ state["route_dependency_admission"] = None
1221
+ state["closeout_disposition"] = None
1222
+ recoveries.append(recovery)
1223
+ data["status"] = "planning"
1224
+ data["completedAt"] = None
1225
+ from .task_move import relocate_task
1226
+ from .active_task import clear_task_from_sessions
1227
+ from .task_continuity import clear_task_runtime_records
1228
+
1229
+ repo_root = get_repo_root()
1230
+ # Relocation rewrites runtime paths; terminal runtime must be discarded first.
1231
+ for runtime_dir in (task_dir, repo_root / ".trellis/tasks/planning" / task_dir.name):
1232
+ clear_task_from_sessions(str(runtime_dir), repo_root)
1233
+ if not clear_task_runtime_records(repo_root, runtime_dir.relative_to(repo_root).as_posix()):
1234
+ raise TaskWorkflowError("TASK_PHASE_RUNTIME_CLEANUP_FAILED", "could not discard terminal task runtime; task remains terminal")
1235
+ task_dir = relocate_task(repo_root, task_dir, data)
1236
+ print(json.dumps({"status": "planning", "task_lifecycle_recovery": recovery, "runtime_records_cleared": True}, ensure_ascii=False))
1237
+ return 0
1238
+ except TaskWorkflowError as error:
1239
+ print(f"{error.code}: {error}", file=sys.stderr)
1240
+ return 1
1241
+
1242
+
964
1243
  def task_closeout_readiness(data: dict, disposition: str) -> str | None:
965
1244
  if "meta" in data and not isinstance(data["meta"], dict):
966
1245
  return "task metadata is malformed"
@@ -982,8 +1261,7 @@ def task_closeout_readiness(data: dict, disposition: str) -> str | None:
982
1261
  if state.get("closeout_disposition") != disposition:
983
1262
  return f"Delivery Review and On-Site Closeout Phase disposition is {state.get('closeout_disposition')}; expected {disposition}"
984
1263
  if disposition == "cancelled":
985
- withdrawal = state.get("return_record")
986
- if not isinstance(withdrawal, dict) or withdrawal.get("to") != PHASE_IDS[4] or not withdrawal.get("evidence_refs"):
1264
+ if not _is_withdrawal_route(state):
987
1265
  return "cancellation requires a user-confirmed route to Delivery Review and On-Site Closeout Phase"
988
1266
  return None
989
1267
 
@@ -1033,27 +1311,96 @@ def _validate_child_dispositions(
1033
1311
  return choices
1034
1312
 
1035
1313
 
1314
+ def cmd_delegate_reconcile(args: argparse.Namespace) -> int:
1315
+ try:
1316
+ root = get_repo_root().resolve()
1317
+ task_dir = _task_dir(args.child_task)
1318
+ data, meta = _load(task_dir)
1319
+ record = meta.get("delegated_worktree_execution_record")
1320
+ if not isinstance(record, dict):
1321
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "no valid delegated execution mapping")
1322
+ if record.get("status") == "retired" and isinstance(record.get("closure_evidence"), dict):
1323
+ print(json.dumps(record, ensure_ascii=False))
1324
+ return 0
1325
+ label = record.get("label_task_id", task_dir.name)
1326
+ agents = _paseo_list(["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={label}"], root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
1327
+ if any(agent.get("status") != "closed" for agent in agents):
1328
+ raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "a delegated Paseo agent is still active or unknown")
1329
+ agent_id = record.get("agent_id")
1330
+ if agent_id and not any(agent.get("id") == agent_id for agent in agents):
1331
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "recorded agent is not available; preserve its mapping until native closure evidence is available")
1332
+ worktree_path = record.get("worktree_path")
1333
+ workspace_id = record.get("workspace_id")
1334
+ workspaces = _paseo_list(["workspace", "ls", "--background", "--json"], root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
1335
+ workspace = next((row for row in workspaces if row.get("workspaceId") == workspace_id), None) if workspace_id else None
1336
+ if workspace and (not isinstance(worktree_path, str) or Path(workspace.get("cwd", "")).resolve() != Path(worktree_path).resolve()):
1337
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "workspace mapping does not match")
1338
+ head = None
1339
+ if isinstance(worktree_path, str) and Path(worktree_path).is_dir():
1340
+ worktree = Path(worktree_path).resolve()
1341
+ common = Path(_git_output(["rev-parse", "--git-common-dir"], worktree))
1342
+ common = (common if common.is_absolute() else worktree / common).resolve()
1343
+ expected_common = Path(_git_output(["rev-parse", "--git-common-dir"], root))
1344
+ expected_common = (expected_common if expected_common.is_absolute() else root / expected_common).resolve()
1345
+ if common != expected_common or _git_output(["branch", "--show-current"], worktree) != record.get("branch"):
1346
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree does not belong to the recorded repository and branch")
1347
+ dirty = _git_output(["status", "--porcelain", "--untracked-files=all"], worktree)
1348
+ allowed = "?? " + record.get("result_path", "")
1349
+ if any(line != allowed for line in dirty.splitlines()):
1350
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree contains unintegrated changes; preserve and integrate them before retiring")
1351
+ head = _git_output(["rev-parse", "HEAD"], worktree)
1352
+ if run_git(["merge-base", "--is-ancestor", head, "HEAD"], cwd=root)[0] != 0:
1353
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree commits are not integrated into the coordinator checkout")
1354
+ elif workspace:
1355
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "workspace still exists but its worktree cannot be inspected")
1356
+ elif not agent_id:
1357
+ branch = record.get("branch")
1358
+ if not isinstance(branch, str) or run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=root)[0] != 1:
1359
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "incomplete mapping still has an unresolved branch")
1360
+ evidence = {"agent_ids": [agent.get("id") for agent in agents], "workspace_id": workspace_id, "head": head, "checked_at": datetime.now(timezone.utc).isoformat()}
1361
+ if not args.execute:
1362
+ print(json.dumps({"status": "ready", "evidence": evidence}, ensure_ascii=False))
1363
+ return 0
1364
+ reason = _required_text(args.reason, "--reason")
1365
+ approval_ref = _required_text(args.approval_ref, "--approval-ref")
1366
+ record.update({"status": "retired", "closure_evidence": {**evidence, "reason": reason, "approval_ref": approval_ref}})
1367
+ _save(task_dir / FILE_TASK_JSON, data)
1368
+ print(json.dumps(record, ensure_ascii=False))
1369
+ return 0
1370
+ except TaskWorkflowError as error:
1371
+ print(f"{error.code}: {error}", file=sys.stderr)
1372
+ return 1
1373
+
1374
+
1036
1375
  def _assert_delegated_resources_closed(task_dir: Path, data: dict) -> None:
1037
- repo_root = get_repo_root()
1038
1376
  children = data.get("children", [])
1039
1377
  if not isinstance(children, list):
1040
1378
  raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "linked child task list is malformed")
1041
1379
  for task_id in [task_dir.name, *children]:
1042
1380
  _, child_meta = _load(_task_dir(task_id))
1043
- record = child_meta.get("delegated_worktree_execution_record")
1044
- if record is None:
1045
- continue
1046
- agent_id = record.get("agent_id") if isinstance(record, dict) else None
1047
- if not isinstance(agent_id, str) or not agent_id.strip():
1048
- raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated execution status is unknown for {task_id}; preserve the current state")
1049
- agents = _paseo_list(
1050
- ["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={task_id}"],
1051
- repo_root,
1052
- "TASK_PHASE_RESOURCE_ACTIVE",
1053
- )
1054
- agent = next((item for item in agents if item.get("id") == agent_id), None)
1055
- if agent is None or agent.get("status") != "closed":
1056
- raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated Paseo agent for {task_id} is not confirmed closed; preserve the current state")
1381
+ _assert_delegated_record_closed(task_id, child_meta.get("delegated_worktree_execution_record"))
1382
+
1383
+
1384
+ def _assert_delegated_record_closed(task_id: str, record) -> None:
1385
+ if record is None:
1386
+ return
1387
+ if isinstance(record, dict) and record.get("status") == "retired" and isinstance(record.get("closure_evidence"), dict):
1388
+ return
1389
+ agent_id = record.get("agent_id") if isinstance(record, dict) else None
1390
+ if not isinstance(agent_id, str) or not agent_id.strip():
1391
+ raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated execution status is unknown for {task_id}; preserve the current state")
1392
+ agents = _paseo_list(
1393
+ ["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={record.get('label_task_id', task_id)}"],
1394
+ get_repo_root(),
1395
+ "TASK_PHASE_RESOURCE_ACTIVE",
1396
+ )
1397
+ agent = next((item for item in agents if item.get("id") == agent_id), None)
1398
+ if agent is None or agent.get("status") != "closed":
1399
+ raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated Paseo agent for {task_id} is not confirmed closed; preserve the current state")
1400
+ worktree_path = record.get("worktree_path")
1401
+ if isinstance(worktree_path, str) and Path(worktree_path).is_dir():
1402
+ if _git_output(["status", "--porcelain", "--untracked-files=all", "--", ".trellis/tasks", ".trellis/.runtime"], Path(worktree_path)):
1403
+ raise TaskWorkflowError("TASK_DELEGATE_TASK_FACTS_CHANGED", "delegated worktree changed protected task facts; preserve evidence and exclude those changes before integration")
1057
1404
 
1058
1405
 
1059
1406
  def cmd_complete_task(args: argparse.Namespace) -> int:
@@ -1068,9 +1415,7 @@ def cmd_complete_task(args: argparse.Namespace) -> int:
1068
1415
  disposition = state.get("closeout_disposition")
1069
1416
  if disposition not in {"delivery_ready", "no_go"}:
1070
1417
  raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", "Delivery Review and On-Site Closeout Phase must accept delivery_ready or no_go before complete")
1071
- closeout = state["phases"][PHASE_IDS[4]]
1072
- if _artifact_digest(task_dir, closeout.get("artifact_refs", [])) != closeout.get("artifact_revision"):
1073
- raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "Delivery Review and On-Site Closeout Phase artifacts changed after acceptance; review the current delivery candidate again")
1418
+ _require_current_accepted_artifacts(task_dir, state)
1074
1419
  readiness_error = task_closeout_readiness(data, disposition)
1075
1420
  if readiness_error:
1076
1421
  raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
@@ -1081,7 +1426,8 @@ def cmd_complete_task(args: argparse.Namespace) -> int:
1081
1426
  raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_STARTED", "implementation tasks must enter in_progress before complete")
1082
1427
  data["status"] = "completed"
1083
1428
  data["completedAt"] = datetime.now().strftime("%Y-%m-%d")
1084
- _save(task_dir / FILE_TASK_JSON, data)
1429
+ from .task_move import relocate_task
1430
+ task_dir = relocate_task(get_repo_root(), task_dir, data)
1085
1431
  from .active_task import clear_task_from_sessions
1086
1432
  from .task_continuity import clear_task_runtime_records
1087
1433