@pennixrv/trellis 0.7.1 → 0.7.2-beta.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 (318) 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 +33 -10
  43. package/dist/commands/teamwork/artifact.d.ts.map +1 -1
  44. package/dist/commands/teamwork/artifact.js +182 -50
  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 +22 -0
  51. package/dist/commands/teamwork/batch.d.ts.map +1 -0
  52. package/dist/commands/teamwork/batch.js +394 -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 +17 -3
  63. package/dist/commands/teamwork/coordinator.d.ts.map +1 -1
  64. package/dist/commands/teamwork/coordinator.js +772 -289
  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 +23 -5
  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 +139 -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 +54 -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 +372 -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/templates/claude/hooks/statusline.py +9 -4
  203. package/dist/templates/codex/hooks/session-start.py +14 -11
  204. package/dist/templates/codex/hooks.json +10 -0
  205. package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/workflow.md +2 -0
  206. package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/workflow.md +2 -0
  207. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +1 -1
  208. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +1 -1
  209. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +5 -5
  210. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +3 -2
  211. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
  212. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +1 -1
  213. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-teamwork.md +14 -2
  214. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +8 -2
  215. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
  216. package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +2 -2
  217. package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/workflow.md +8 -0
  218. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/mcp-setup.md +4 -2
  219. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-task-planning.md +2 -2
  220. package/dist/templates/common/bundled-skills/trellis-task-implementation/workflow.md +6 -0
  221. package/dist/templates/common/bundled-skills/trellis-teamwork/SKILL.md +18 -3
  222. package/dist/templates/common/bundled-skills/trellis-teamwork/references/coordination-modes.md +34 -3
  223. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-configuration-matrix.md +37 -26
  224. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +128 -14
  225. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +152 -20
  226. package/dist/templates/common/commands/start.md +2 -2
  227. package/dist/templates/common/skills/before-dev.md +1 -4
  228. package/dist/templates/common/skills/break-loop.md +7 -18
  229. package/dist/templates/common/skills/check.md +7 -11
  230. package/dist/templates/common/skills/{brainstorm.md → request-intake.md} +5 -3
  231. package/dist/templates/common/skills/update-spec.md +5 -7
  232. package/dist/templates/copilot/hooks/session-start.py +14 -11
  233. package/dist/templates/copilot/index.d.ts +1 -6
  234. package/dist/templates/copilot/index.d.ts.map +1 -1
  235. package/dist/templates/copilot/index.js +1 -14
  236. package/dist/templates/copilot/index.js.map +1 -1
  237. package/dist/templates/extract.js +4 -0
  238. package/dist/templates/extract.js.map +1 -1
  239. package/dist/templates/markdown/spec/backend/index.md.txt +4 -35
  240. package/dist/templates/markdown/spec/frontend/index.md.txt +4 -36
  241. package/dist/templates/omp/extensions/trellis/index.ts.txt +2 -2
  242. package/dist/templates/opencode/lib/session-utils.js +301 -209
  243. package/dist/templates/pi/extensions/trellis/index.ts.txt +17 -1
  244. package/dist/templates/shared-hooks/guard-teamwork-wait.py +53 -0
  245. package/dist/templates/shared-hooks/index.d.ts +1 -1
  246. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  247. package/dist/templates/shared-hooks/index.js +1 -0
  248. package/dist/templates/shared-hooks/index.js.map +1 -1
  249. package/dist/templates/shared-hooks/inject-workflow-state.py +8 -3
  250. package/dist/templates/shared-hooks/session-start.py +16 -12
  251. package/dist/templates/snow/SNOW.md +1 -1
  252. package/dist/templates/trellis/agents/delegated-worktree-primary-agent.md +9 -0
  253. package/dist/templates/trellis/agents/profiles/architecture.md +9 -0
  254. package/dist/templates/trellis/agents/profiles/diagnosis.md +9 -0
  255. package/dist/templates/trellis/agents/profiles/exploration.md +9 -0
  256. package/dist/templates/trellis/agents/profiles/research.md +9 -0
  257. package/dist/templates/trellis/agents/profiles/review.md +9 -0
  258. package/dist/templates/trellis/agents/profiles/verification.md +9 -0
  259. package/dist/templates/trellis/agents/teammate-profiles.json +56 -4
  260. package/dist/templates/trellis/agents/teammate.md +4 -52
  261. package/dist/templates/trellis/config.yaml +2 -5
  262. package/dist/templates/trellis/index.d.ts +4 -0
  263. package/dist/templates/trellis/index.d.ts.map +1 -1
  264. package/dist/templates/trellis/index.js +22 -4
  265. package/dist/templates/trellis/index.js.map +1 -1
  266. package/dist/templates/trellis/scripts/add_session.py +1 -1
  267. package/dist/templates/trellis/scripts/common/active_task.py +3 -47
  268. package/dist/templates/trellis/scripts/common/paths.py +9 -25
  269. package/dist/templates/trellis/scripts/common/safe_commit.py +8 -8
  270. package/dist/templates/trellis/scripts/common/task_context.py +11 -51
  271. package/dist/templates/trellis/scripts/common/task_continuity.py +129 -60
  272. package/dist/templates/trellis/scripts/common/task_layout.py +80 -0
  273. package/dist/templates/trellis/scripts/common/task_move.py +345 -0
  274. package/dist/templates/trellis/scripts/common/task_store.py +35 -57
  275. package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
  276. package/dist/templates/trellis/scripts/common/task_workflow.py +339 -46
  277. package/dist/templates/trellis/scripts/common/tasks.py +6 -5
  278. package/dist/templates/trellis/scripts/hooks/linear_sync.py +13 -3
  279. package/dist/templates/trellis/scripts/task.py +71 -24
  280. package/dist/templates/trellis/teamwork.json +10 -0
  281. package/dist/templates/trellis/workflow.md +11 -2
  282. package/dist/utils/ablation-store.d.ts +1 -1
  283. package/dist/utils/ablation-store.d.ts.map +1 -1
  284. package/dist/utils/ablation-store.js +58 -24
  285. package/dist/utils/ablation-store.js.map +1 -1
  286. package/dist/utils/asset-backup.d.ts +138 -0
  287. package/dist/utils/asset-backup.d.ts.map +1 -0
  288. package/dist/utils/asset-backup.js +513 -0
  289. package/dist/utils/asset-backup.js.map +1 -0
  290. package/dist/utils/atomic-write.d.ts +1 -1
  291. package/dist/utils/atomic-write.d.ts.map +1 -1
  292. package/dist/utils/atomic-write.js +6 -3
  293. package/dist/utils/atomic-write.js.map +1 -1
  294. package/dist/utils/manifest-prune.d.ts +4 -1
  295. package/dist/utils/manifest-prune.d.ts.map +1 -1
  296. package/dist/utils/manifest-prune.js +17 -6
  297. package/dist/utils/manifest-prune.js.map +1 -1
  298. package/dist/utils/template-fetcher.d.ts +4 -19
  299. package/dist/utils/template-fetcher.d.ts.map +1 -1
  300. package/dist/utils/template-fetcher.js +9 -45
  301. package/dist/utils/template-fetcher.js.map +1 -1
  302. package/dist/utils/template-hash.d.ts +5 -4
  303. package/dist/utils/template-hash.d.ts.map +1 -1
  304. package/dist/utils/template-hash.js +24 -17
  305. package/dist/utils/template-hash.js.map +1 -1
  306. package/package.json +3 -2
  307. package/dist/templates/copilot/prompts/before-dev.prompt.md +0 -39
  308. package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -13
  309. package/dist/templates/copilot/prompts/break-loop.prompt.md +0 -129
  310. package/dist/templates/copilot/prompts/check-cross-layer.prompt.md +0 -170
  311. package/dist/templates/copilot/prompts/check.prompt.md +0 -110
  312. package/dist/templates/copilot/prompts/create-command.prompt.md +0 -116
  313. package/dist/templates/copilot/prompts/finish-work.prompt.md +0 -22
  314. package/dist/templates/copilot/prompts/integrate-skill.prompt.md +0 -223
  315. package/dist/templates/copilot/prompts/onboard.prompt.md +0 -362
  316. package/dist/templates/copilot/prompts/record-session.prompt.md +0 -58
  317. package/dist/templates/copilot/prompts/start.prompt.md +0 -65
  318. 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")
@@ -130,6 +134,10 @@ def implementation_start_ready(data: dict, task_dir: Path) -> bool:
130
134
  phase_ids = state.get("phase_ids")
131
135
  if not isinstance(phase_ids, list) or PHASE_IDS[3] not in phase_ids:
132
136
  return False
137
+ phases = state.get("phases")
138
+ implementation = phases.get(PHASE_IDS[3]) if isinstance(phases, dict) else None
139
+ if not isinstance(implementation, dict) or implementation.get("status") != "active":
140
+ return False
133
141
  if state.get("task_kind") not in {"implementation", "combined"}:
134
142
  return False
135
143
  authorization = state.get("implementation_authorization")
@@ -145,6 +153,7 @@ def implementation_start_ready(data: dict, task_dir: Path) -> bool:
145
153
  ):
146
154
  return False
147
155
  _require_current_requirements_confirmation(task_dir, state)
156
+ _require_current_accepted_artifacts(task_dir, state)
148
157
  if PHASE_IDS[2] not in phase_ids:
149
158
  skip_records = state.get("skip_records")
150
159
  skipped_design = skip_records.get(PHASE_IDS[2]) if isinstance(skip_records, dict) else None
@@ -176,7 +185,8 @@ def _save(task_file: Path, data: dict) -> None:
176
185
 
177
186
  @contextlib.contextmanager
178
187
  def _task_lock(task_dir: Path):
179
- lock_file = task_dir / ".task-phase.lock"
188
+ lock_file = get_repo_root() / ".trellis/.runtime/task-phase" / f"{task_dir.name}.lock"
189
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
180
190
  with lock_file.open("a+b") as handle:
181
191
  if os.name == "nt":
182
192
  import msvcrt
@@ -223,20 +233,23 @@ def _phase(state: dict, phase_id: str | None = None) -> tuple[str, dict]:
223
233
  return current, record
224
234
 
225
235
 
226
- def _require_current_requirements_confirmation(task_dir: Path, state: dict) -> None:
227
- if PHASE_IDS[0] not in state["phase_ids"]:
228
- return
236
+ def _is_withdrawal_route(state: dict) -> bool:
229
237
  requirements = state["phases"].get(PHASE_IDS[0], {})
230
238
  withdrawal = state.get("return_record")
231
239
  skip_records = state.get("skip_records")
232
240
  skipped_requirements = skip_records.get(PHASE_IDS[0]) if isinstance(skip_records, dict) else None
233
- if (
241
+ return (
234
242
  state["phase_ids"] == [PHASE_IDS[0], PHASE_IDS[4]]
235
- and state.get("current_phase_id") == PHASE_IDS[4]
243
+ and state.get("current_phase_id") in {PHASE_IDS[4], None}
236
244
  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")
245
+ and (
246
+ requirements.get("status") == "accepted"
247
+ or (
248
+ requirements.get("status") == "skipped"
249
+ and isinstance(skipped_requirements, dict)
250
+ and skipped_requirements.get("task_route_revision") == state.get("task_route_revision")
251
+ )
252
+ )
240
253
  and isinstance(withdrawal, dict)
241
254
  and isinstance(withdrawal.get("from"), str)
242
255
  and withdrawal["from"] in PHASE_ID_SET
@@ -244,7 +257,14 @@ def _require_current_requirements_confirmation(task_dir: Path, state: dict) -> N
244
257
  and withdrawal.get("task_route_revision") == state.get("task_route_revision")
245
258
  and isinstance(withdrawal.get("evidence_refs"), list)
246
259
  and bool(withdrawal["evidence_refs"])
247
- ):
260
+ )
261
+
262
+
263
+ def _require_current_requirements_confirmation(task_dir: Path, state: dict) -> None:
264
+ if PHASE_IDS[0] not in state["phase_ids"]:
265
+ return
266
+ requirements = state["phases"].get(PHASE_IDS[0], {})
267
+ if requirements.get("status") == "skipped" and _is_withdrawal_route(state):
248
268
  return
249
269
  acceptance = requirements.get("acceptance") if isinstance(requirements, dict) else None
250
270
  if (
@@ -280,6 +300,7 @@ def _invalidate_from(state: dict, first_phase: str, keep_first: bool = False) ->
280
300
  for phase_id in state["phase_ids"]:
281
301
  if PHASE_IDS.index(phase_id) < first_index:
282
302
  continue
303
+ state["skip_records"].pop(phase_id, None)
283
304
  record = state["phases"][phase_id]
284
305
  if keep_first and phase_id == first_phase:
285
306
  record["status"] = "active"
@@ -327,6 +348,24 @@ def _artifact_digest(task_dir: Path, refs: list[str]) -> str:
327
348
  return digest.hexdigest()
328
349
 
329
350
 
351
+ def _require_current_accepted_artifacts(task_dir: Path, state: dict) -> None:
352
+ for phase_id in state["phase_ids"]:
353
+ record = state["phases"][phase_id]
354
+ if record.get("status") != "accepted":
355
+ continue
356
+ refs = record.get("artifact_refs")
357
+ if not isinstance(refs, list) or any(not isinstance(ref, str) for ref in refs):
358
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"accepted artifact references are invalid: {phase_id}")
359
+ if _artifact_digest(task_dir, refs) != record.get("artifact_revision"):
360
+ raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", f"accepted artifacts changed for {phase_id}; return to the affected phase and review the current candidate again")
361
+ authorization = state.get("implementation_authorization")
362
+ if authorization is not None:
363
+ if not isinstance(authorization, dict) or not isinstance(authorization.get("contract_ref"), str):
364
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "implementation authorization has no contract reference")
365
+ if _artifact_digest(task_dir, [authorization["contract_ref"]]) != authorization.get("contract_revision"):
366
+ raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "implementation contract changed after authorization; review and authorize its current content before continuing")
367
+
368
+
330
369
  def _validate_route(value: str | None) -> list[str]:
331
370
  raw = _required_text(value, "--phase-ids")
332
371
  phases = raw.split(",")
@@ -417,6 +456,31 @@ def _show(state: dict) -> int:
417
456
  return 0
418
457
 
419
458
 
459
+ def _require_acceptance_coverage(task_dir: Path, contract_ref: str) -> None:
460
+ _artifact_digest(task_dir, [contract_ref])
461
+ lines = (task_dir / contract_ref).read_text(encoding="utf-8").splitlines()
462
+ heading = next((index for index, line in enumerate(lines) if line.strip() == "## 验收覆盖"), None)
463
+ headers = ["接受条件", "工作包", "Owner", "验证入口", "交付证据", "停止条件"]
464
+ if heading is not None:
465
+ section = []
466
+ for line in lines[heading + 1:]:
467
+ if line.startswith("#"):
468
+ break
469
+ if line.strip():
470
+ section.append(line.strip())
471
+ rows = [line.strip("|").split("|") for line in section if line.startswith("|") and line.endswith("|")]
472
+ rows = [[cell.strip() for cell in row] for row in rows]
473
+ if (
474
+ len(rows) >= 3
475
+ and rows[0] == headers
476
+ and len(rows[1]) == len(headers)
477
+ and all(re.fullmatch(r":?-{3,}:?", cell) for cell in rows[1])
478
+ and all(len(row) == len(headers) and all(row) for row in rows[2:])
479
+ ):
480
+ return
481
+ raise TaskWorkflowError("TASK_PHASE_COVERAGE_REQUIRED", "implementation contract requires ## 验收覆盖 with nonempty rows: " + " | ".join(headers))
482
+
483
+
420
484
  def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -> int:
421
485
  action = args.phase_action
422
486
  child_dispositions: dict[str, str] = {}
@@ -427,14 +491,21 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
427
491
  raise TaskWorkflowError("TASK_PHASE_TASK_TERMINAL", f"task is already {data['status']}")
428
492
 
429
493
  _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)
494
+ reviewed_delivery = (
495
+ state.get("current_phase_id") is None
496
+ and action in {"return", "route"}
497
+ and state["phases"].get(PHASE_IDS[4], {}).get("status") == "accepted"
498
+ )
499
+ if reviewed_delivery:
500
+ current, record = PHASE_IDS[4], state["phases"][PHASE_IDS[4]]
501
+ else:
502
+ current, record = _phase(state)
503
+ accepted_design_return = action == "return" and current == PHASE_IDS[2] and record.get("status") == "accepted"
433
504
  returning_to_requirements = action == "return" and args.target_phase == PHASE_IDS[0]
434
505
  rerouting_to_requirements = action == "route" and args.target_phase == PHASE_IDS[0]
435
506
  if current != PHASE_IDS[0] and not returning_to_requirements and not rerouting_to_requirements:
436
507
  _require_current_requirements_confirmation(task_dir, state)
437
- if action not in {"show", "resume", "route", "authorize", "admission"} and record.get("status") != "active":
508
+ if action not in {"show", "resume", "route", "authorize", "admission"} and record.get("status") != "active" and not reviewed_delivery and not accepted_design_return:
438
509
  raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "only the active phase can be changed")
439
510
 
440
511
  if action == "artifact":
@@ -481,6 +552,7 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
481
552
  state["skip_records"][current] = {"reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
482
553
  _advance(state, current)
483
554
  elif action == "accept":
555
+ _require_current_accepted_artifacts(task_dir, state)
484
556
  artifact_revision = _required_text(args.artifact_revision, "--artifact-revision")
485
557
  current_revision = _artifact_digest(task_dir, record["artifact_refs"])
486
558
  if artifact_revision != record["artifact_revision"] or current_revision != record["artifact_revision"]:
@@ -495,6 +567,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
495
567
  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
568
  if result_ref not in record["artifact_refs"]:
497
569
  raise TaskWorkflowError("TASK_PHASE_RESULT_NOT_BOUND", "--result-ref must be included in the current phase artifact refs")
570
+ if current == PHASE_IDS[2] and outcome == "implementation_contract":
571
+ _require_acceptance_coverage(task_dir, result_ref)
498
572
  if current == PHASE_IDS[3]:
499
573
  authorization = state.get("implementation_authorization")
500
574
  if not isinstance(authorization, dict) or authorization.get("task_route_revision") != state["task_route_revision"]:
@@ -505,8 +579,7 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
505
579
  if outcome != args.closeout_disposition:
506
580
  raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "Delivery Review and On-Site Closeout Phase outcome must match --closeout-disposition")
507
581
  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"):
582
+ if not _is_withdrawal_route(state):
510
583
  raise TaskWorkflowError("TASK_PHASE_USER_CONFIRMATION_REQUIRED", "cancellation closeout requires a user-confirmed route to Delivery Review and On-Site Closeout Phase")
511
584
  child_dispositions = _validate_child_dispositions(
512
585
  task_dir, data, args.closeout_disposition, args.child_disposition
@@ -532,14 +605,38 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
532
605
  _advance(state, current)
533
606
  elif action == "return":
534
607
  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):
608
+ return_kind = args.return_kind
609
+ execution_rework = return_kind == "execution_rework"
610
+ if target not in state["phase_ids"] or (
611
+ state["phase_ids"].index(target) >= state["phase_ids"].index(current)
612
+ and not ((reviewed_delivery or accepted_design_return or execution_rework) and target == current)
613
+ ):
536
614
  raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "return target must be an earlier phase in the current route")
537
615
  reason = _required_text(args.reason, "--reason")
538
616
  evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
617
+ authorization = state.get("implementation_authorization")
618
+ admission = state.get("route_dependency_admission")
619
+ if execution_rework:
620
+ if current not in PHASE_IDS[3:] or target not in PHASE_IDS[3:] or PHASE_IDS[3] not in state["phase_ids"]:
621
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "execution_rework is limited to implementation and delivery phases")
622
+ candidate = copy.deepcopy(data)
623
+ candidate_state = candidate["meta"]["task_workflow"]
624
+ _invalidate_from(candidate_state, target, keep_first=True)
625
+ candidate_state["implementation_authorization"] = authorization
626
+ _require_current_accepted_artifacts(task_dir, candidate_state)
627
+ candidate_state["current_phase_id"] = PHASE_IDS[3]
628
+ candidate_state["phases"][PHASE_IDS[3]]["status"] = "active"
629
+ if not implementation_start_ready(candidate, task_dir):
630
+ raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_AUTHORIZED", "execution_rework requires the unchanged authorized implementation contract")
631
+ if admission is not None and (admission.get("admission_state") != "ready" or admission.get("task_route_revision") != state["task_route_revision"]):
632
+ raise TaskWorkflowError("TASK_PHASE_ADMISSION_REQUIRED", "execution_rework requires current ready dependency admission")
539
633
  state["input_revision"] = _increment(state["input_revision"])
540
634
  _invalidate_from(state, target, keep_first=True)
635
+ if execution_rework:
636
+ state["implementation_authorization"] = authorization
637
+ state["route_dependency_admission"] = admission
541
638
  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"]}
639
+ 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
640
  elif action == "route":
544
641
  phases = _validate_route(args.phase_ids)
545
642
  task_kind = args.task_kind if args.task_kind is not None else state["task_kind"]
@@ -564,7 +661,7 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
564
661
  and phases == [PHASE_IDS[0], PHASE_IDS[4]]
565
662
  and bool(evidence)
566
663
  )
567
- if not changed and not classification_changed and target == current:
664
+ if not changed and not classification_changed and target == current and not reviewed_delivery:
568
665
  raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "the updated route and target are unchanged")
569
666
  earliest_change = min((PHASE_IDS.index(phase) for phase in changed), default=PHASE_IDS.index(target))
570
667
  if target not in PHASE_ID_SET or (PHASE_IDS.index(target) > earliest_change and not withdrawal_route):
@@ -604,7 +701,11 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
604
701
  for phase_id, phase_record in state["phases"].items():
605
702
  if phase_id not in old_phases:
606
703
  phase_record["input_revision"] = state["input_revision"]
607
- state["skip_records"] = {}
704
+ state["skip_records"] = {
705
+ phase_id: {**skip, "task_route_revision": state["task_route_revision"]}
706
+ for phase_id, skip in state["skip_records"].items()
707
+ if phase_id in phases and state["phases"][phase_id].get("status") == "skipped"
708
+ }
608
709
  for phase_id in omitted:
609
710
  state["skip_records"][phase_id] = {"reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
610
711
  if withdrawal_route and state["phases"][PHASE_IDS[0]].get("status") != "accepted":
@@ -647,7 +748,8 @@ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -
647
748
  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
749
  elif contract_ref not in state.get("skip_records", {}).get(PHASE_IDS[2], {}).get("evidence_refs", []):
649
750
  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"]}
751
+ _require_acceptance_coverage(task_dir, contract_ref)
752
+ 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
753
  if current == PHASE_IDS[2]:
652
754
  _advance(state, current)
653
755
  else:
@@ -662,7 +764,7 @@ def cmd_phase(args: argparse.Namespace) -> int:
662
764
  try:
663
765
  task_dir = _task_dir(args.task_dir)
664
766
  with _task_lock(task_dir):
665
- data, meta = _load(task_dir)
767
+ data, meta = _load(task_dir, initialize_meta=args.phase_action == "init")
666
768
  if args.phase_action == "init":
667
769
  return _init(args, task_dir, data, meta)
668
770
  return _update(args, task_dir, data, _state(meta))
@@ -707,6 +809,56 @@ def _paseo_list(args: list[str], cwd: Path, code: str) -> list[dict]:
707
809
  return value
708
810
 
709
811
 
812
+ def _delegation_binding(repo_root: Path, child_dir: Path, child_data: dict, result_path: str) -> dict:
813
+ 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/"):
814
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", "result path must stay under .trellis/delegated-results/")
815
+ try:
816
+ contract_path = (child_dir / "prd.md").resolve(strict=True)
817
+ contract_path.relative_to(child_dir.resolve())
818
+ role_path = (repo_root / ".trellis/agents/delegated-worktree-primary-agent.md").resolve(strict=True)
819
+ role_path.relative_to((repo_root / ".trellis/agents").resolve())
820
+ if not contract_path.is_file() or not role_path.is_file() or contract_path.stat().st_size + role_path.stat().st_size > 1048576:
821
+ raise ValueError("delegated contract and role must be regular files within the size budget")
822
+ contract = contract_path.read_bytes()
823
+ role = role_path.read_bytes()
824
+ except (OSError, ValueError) as error:
825
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", str(error)) from error
826
+ if not contract.strip() or not role.strip():
827
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", "child PRD and delegated role must be nonempty")
828
+ return {
829
+ "contract_sha256": hashlib.sha256(contract).hexdigest(),
830
+ "role_sha256": hashlib.sha256(role).hexdigest(),
831
+ "branch": _required_text(child_data.get("branch"), "child branch"),
832
+ "base_commit": _git_output(["rev-parse", "--verify", "--end-of-options", f"{_required_text(child_data.get('base_branch'), 'child base_branch')}^{{commit}}"], repo_root),
833
+ "result_path": result_path,
834
+ }
835
+
836
+
837
+ def cmd_delegate_bind(args: argparse.Namespace) -> int:
838
+ try:
839
+ repo_root = get_repo_root().resolve()
840
+ child_dir = _task_dir(args.child_task)
841
+ child, _ = _load(child_dir)
842
+ if not isinstance(child.get("parent"), str) or not child["parent"].strip():
843
+ raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "child task has no parent link")
844
+ parent_dir = _task_dir(child["parent"])
845
+ parent, meta = _load(parent_dir)
846
+ if child_dir.name not in parent.get("children", []) or not implementation_start_ready(parent, parent_dir):
847
+ raise TaskWorkflowError("TASK_DELEGATE_IMPLEMENTATION_NOT_AUTHORIZED", "bind only a linked child within current parent implementation authorization")
848
+ state = _state(meta)
849
+ authorization = state["implementation_authorization"]
850
+ result_path = args.result_path or f".trellis/delegated-results/{child_dir.name}.md"
851
+ binding = _delegation_binding(repo_root, child_dir, child, result_path)
852
+ binding.update({"authorization_ref": authorization["approval_ref"], "parent_contract_revision": authorization["contract_revision"], "task_route_revision": state["task_route_revision"]})
853
+ meta.setdefault("delegation_authorizations", {})[child_dir.name] = binding
854
+ _save(parent_dir / FILE_TASK_JSON, parent)
855
+ print(json.dumps(binding, ensure_ascii=False))
856
+ return 0
857
+ except TaskWorkflowError as error:
858
+ print(f"{error.code}: {error}", file=sys.stderr)
859
+ return 1
860
+
861
+
710
862
  def cmd_delegate_start(args: argparse.Namespace) -> int:
711
863
  try:
712
864
  repo_root = get_repo_root().resolve()
@@ -722,6 +874,11 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
722
874
  child_data, child_meta = _load(child_dir)
723
875
  if child_data.get("parent") != parent_dir.name or child_dir.name not in parent_data.get("children", []):
724
876
  raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "parent and child task links do not match")
877
+ from .task_continuity import assert_task_mutation_allowed
878
+
879
+ assert_task_mutation_allowed(repo_root, parent_dir, parent_data)
880
+ if child_data.get("status") not in {"planning", "in_progress"}:
881
+ raise TaskWorkflowError("TASK_DELEGATE_CHILD_TERMINAL", "only a planning or in-progress child task can be delegated")
725
882
  if parent_data.get("status") != "in_progress" or not implementation_start_ready(parent_data, parent_dir):
726
883
  raise TaskWorkflowError("TASK_DELEGATE_IMPLEMENTATION_NOT_AUTHORIZED", "parent task is not at an authorized Task Implementation and Test Verification Phase entry")
727
884
 
@@ -775,6 +932,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
775
932
  raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", f"could not run Paseo: {error}") from error
776
933
  if version.returncode != 0:
777
934
  raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", (version.stderr or version.stdout).strip() or "Paseo CLI preflight failed")
935
+ _paseo_list(["agent", "ls", "--all", "--global", "--background", "--json"], repo_root, "TASK_DELEGATE_PASEO_UNAVAILABLE")
778
936
  role_path = repo_root / ".trellis" / "agents" / "delegated-worktree-primary-agent.md"
779
937
  try:
780
938
  role = role_path.read_text(encoding="utf-8")
@@ -785,6 +943,16 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
785
943
  if not isinstance(authorization, dict) or not isinstance(authorization.get("approval_ref"), str):
786
944
  raise TaskWorkflowError("TASK_DELEGATE_AUTHORIZATION_INVALID", "current implementation authorization is invalid")
787
945
  contract_digest = hashlib.sha256(contract_bytes).hexdigest()
946
+ bindings = parent_meta.get("delegation_authorizations", {})
947
+ binding = bindings.get(child_dir.name) if isinstance(bindings, dict) else None
948
+ if not isinstance(binding, dict):
949
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_NOT_BOUND", "freeze this child through delegate-bind before dispatch")
950
+ actual = _delegation_binding(repo_root, child_dir, child_data, binding.get("result_path", ""))
951
+ 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"]:
952
+ raise TaskWorkflowError("TASK_DELEGATE_STALE_CONTRACT", "child PRD, role, Git base, output path, or parent authorization changed; review and bind the child again")
953
+ prompt_bytes = len((role + contract).encode("utf-8"))
954
+ if prompt_bytes > (24000 if os.name == "nt" else 96000):
955
+ raise TaskWorkflowError("TASK_DELEGATE_PROMPT_TOO_LARGE", "delegated prompt exceeds the native Paseo argv budget; narrow the contract before creating resources")
788
956
  previous = child_meta.get("delegated_worktree_execution_record")
789
957
  reuse_workspace = False
790
958
  workspace_id = None
@@ -808,7 +976,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
808
976
  previous_worktree = Path(previous_worktree_path).expanduser().resolve()
809
977
  agents = _paseo_list([
810
978
  "agent", "ls", "--all", "--global", "--background", "--json",
811
- "--label", f"trellis.task_id={child_dir.name}",
979
+ "--label", f"trellis.task_id={previous.get('label_task_id', child_dir.name)}",
812
980
  ], repo_root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
813
981
  for agent in agents:
814
982
  agent_id = agent.get("id")
@@ -859,9 +1027,14 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
859
1027
  "workspace_id": workspace_id,
860
1028
  "worktree_path": str(worktree) if worktree else None,
861
1029
  "agent_id": None,
1030
+ "label_task_id": child_dir.name,
1031
+ "role_sha256": actual["role_sha256"],
1032
+ "result_path": actual["result_path"],
862
1033
  "status": "starting",
863
1034
  "started_at": datetime.now(timezone.utc).isoformat(),
864
1035
  }
1036
+ if previous is not None:
1037
+ child_meta.setdefault("delegated_worktree_execution_history", []).append(previous)
865
1038
  child_meta["delegated_worktree_execution_record"] = record
866
1039
  _save(child_dir / FILE_TASK_JSON, child_data)
867
1040
 
@@ -902,6 +1075,7 @@ def cmd_delegate_start(args: argparse.Namespace) -> int:
902
1075
  "the parent authorization and Git baseline are execution metadata. Do not accept task phases or change task lifecycle state.\n\n"
903
1076
  f"Parent task: {parent_dir.name}\nParent route revision: {state['task_route_revision']}\n"
904
1077
  f"Authorized contract: prd.md\nBranch: {branch}\nBase commit: {base_commit}\n\n"
1078
+ f"Assigned result artifact: {actual['result_path']}\n"
905
1079
  f"{role}\n\n## Child task prd.md\n{contract}"
906
1080
  )
907
1081
  agent = _paseo_json([
@@ -939,6 +1113,7 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
939
1113
  readiness_error = task_closeout_readiness(data, "cancelled")
940
1114
  if readiness_error:
941
1115
  raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
1116
+ _require_current_accepted_artifacts(task_dir, _state(meta))
942
1117
  _validate_child_dispositions(task_dir, data, "cancelled", None, from_acceptance=True)
943
1118
  _assert_delegated_resources_closed(task_dir, data)
944
1119
  meta["task_cancellation"] = {
@@ -947,7 +1122,8 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
947
1122
  }
948
1123
  data["status"] = "cancelled"
949
1124
  data["completedAt"] = None
950
- _save(task_dir / FILE_TASK_JSON, data)
1125
+ from .task_move import relocate_task
1126
+ task_dir = relocate_task(get_repo_root(), task_dir, data)
951
1127
  from .active_task import clear_task_from_sessions
952
1128
  from .task_continuity import clear_task_runtime_records
953
1129
 
@@ -961,6 +1137,56 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
961
1137
  return 1
962
1138
 
963
1139
 
1140
+ def cmd_reopen_task(args: argparse.Namespace) -> int:
1141
+ try:
1142
+ task_dir = _task_dir(args.task_dir)
1143
+ with _task_lock(task_dir):
1144
+ data, meta = _load(task_dir)
1145
+ previous_status = data.get("status")
1146
+ if previous_status not in {"completed", "cancelled"}:
1147
+ raise TaskWorkflowError("TASK_PHASE_REOPEN_INVALID_STATUS", "only completed or cancelled tasks can be reopened")
1148
+ reason = _required_text(args.reason, "--reason")
1149
+ evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
1150
+ _assert_delegated_resources_closed(task_dir, data)
1151
+ recoveries = meta.setdefault("task_lifecycle_recoveries", [])
1152
+ if not isinstance(recoveries, list) or any(not isinstance(record, dict) for record in recoveries):
1153
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task lifecycle recovery history is malformed")
1154
+ recovery = {
1155
+ "from_status": previous_status,
1156
+ "to_status": "planning",
1157
+ "reason": reason,
1158
+ "evidence_refs": evidence,
1159
+ "reopened_at": datetime.now(timezone.utc).isoformat(),
1160
+ "terminal_completed_at": data.get("completedAt"),
1161
+ "terminal_cancellation": copy.deepcopy(meta.get("task_cancellation")),
1162
+ }
1163
+ state = meta.get("task_workflow")
1164
+ if state is not None:
1165
+ state = _state(meta)
1166
+ state["implementation_authorization"] = None
1167
+ state["route_dependency_admission"] = None
1168
+ state["closeout_disposition"] = None
1169
+ recoveries.append(recovery)
1170
+ data["status"] = "planning"
1171
+ data["completedAt"] = None
1172
+ from .task_move import relocate_task
1173
+ from .active_task import clear_task_from_sessions
1174
+ from .task_continuity import clear_task_runtime_records
1175
+
1176
+ repo_root = get_repo_root()
1177
+ # Relocation rewrites runtime paths; terminal runtime must be discarded first.
1178
+ for runtime_dir in (task_dir, repo_root / ".trellis/tasks/planning" / task_dir.name):
1179
+ clear_task_from_sessions(str(runtime_dir), repo_root)
1180
+ if not clear_task_runtime_records(repo_root, runtime_dir.relative_to(repo_root).as_posix()):
1181
+ raise TaskWorkflowError("TASK_PHASE_RUNTIME_CLEANUP_FAILED", "could not discard terminal task runtime; task remains terminal")
1182
+ task_dir = relocate_task(repo_root, task_dir, data)
1183
+ print(json.dumps({"status": "planning", "task_lifecycle_recovery": recovery, "runtime_records_cleared": True}, ensure_ascii=False))
1184
+ return 0
1185
+ except TaskWorkflowError as error:
1186
+ print(f"{error.code}: {error}", file=sys.stderr)
1187
+ return 1
1188
+
1189
+
964
1190
  def task_closeout_readiness(data: dict, disposition: str) -> str | None:
965
1191
  if "meta" in data and not isinstance(data["meta"], dict):
966
1192
  return "task metadata is malformed"
@@ -982,8 +1208,7 @@ def task_closeout_readiness(data: dict, disposition: str) -> str | None:
982
1208
  if state.get("closeout_disposition") != disposition:
983
1209
  return f"Delivery Review and On-Site Closeout Phase disposition is {state.get('closeout_disposition')}; expected {disposition}"
984
1210
  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"):
1211
+ if not _is_withdrawal_route(state):
987
1212
  return "cancellation requires a user-confirmed route to Delivery Review and On-Site Closeout Phase"
988
1213
  return None
989
1214
 
@@ -1033,27 +1258,96 @@ def _validate_child_dispositions(
1033
1258
  return choices
1034
1259
 
1035
1260
 
1261
+ def cmd_delegate_reconcile(args: argparse.Namespace) -> int:
1262
+ try:
1263
+ root = get_repo_root().resolve()
1264
+ task_dir = _task_dir(args.child_task)
1265
+ data, meta = _load(task_dir)
1266
+ record = meta.get("delegated_worktree_execution_record")
1267
+ if not isinstance(record, dict):
1268
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "no valid delegated execution mapping")
1269
+ if record.get("status") == "retired" and isinstance(record.get("closure_evidence"), dict):
1270
+ print(json.dumps(record, ensure_ascii=False))
1271
+ return 0
1272
+ label = record.get("label_task_id", task_dir.name)
1273
+ agents = _paseo_list(["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={label}"], root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
1274
+ if any(agent.get("status") != "closed" for agent in agents):
1275
+ raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "a delegated Paseo agent is still active or unknown")
1276
+ agent_id = record.get("agent_id")
1277
+ if agent_id and not any(agent.get("id") == agent_id for agent in agents):
1278
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "recorded agent is not available; preserve its mapping until native closure evidence is available")
1279
+ worktree_path = record.get("worktree_path")
1280
+ workspace_id = record.get("workspace_id")
1281
+ workspaces = _paseo_list(["workspace", "ls", "--background", "--json"], root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
1282
+ workspace = next((row for row in workspaces if row.get("workspaceId") == workspace_id), None) if workspace_id else None
1283
+ if workspace and (not isinstance(worktree_path, str) or Path(workspace.get("cwd", "")).resolve() != Path(worktree_path).resolve()):
1284
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "workspace mapping does not match")
1285
+ head = None
1286
+ if isinstance(worktree_path, str) and Path(worktree_path).is_dir():
1287
+ worktree = Path(worktree_path).resolve()
1288
+ common = Path(_git_output(["rev-parse", "--git-common-dir"], worktree))
1289
+ common = (common if common.is_absolute() else worktree / common).resolve()
1290
+ expected_common = Path(_git_output(["rev-parse", "--git-common-dir"], root))
1291
+ expected_common = (expected_common if expected_common.is_absolute() else root / expected_common).resolve()
1292
+ if common != expected_common or _git_output(["branch", "--show-current"], worktree) != record.get("branch"):
1293
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree does not belong to the recorded repository and branch")
1294
+ dirty = _git_output(["status", "--porcelain", "--untracked-files=all"], worktree)
1295
+ allowed = "?? " + record.get("result_path", "")
1296
+ if any(line != allowed for line in dirty.splitlines()):
1297
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree contains unintegrated changes; preserve and integrate them before retiring")
1298
+ head = _git_output(["rev-parse", "HEAD"], worktree)
1299
+ if run_git(["merge-base", "--is-ancestor", head, "HEAD"], cwd=root)[0] != 0:
1300
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "worktree commits are not integrated into the coordinator checkout")
1301
+ elif workspace:
1302
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "workspace still exists but its worktree cannot be inspected")
1303
+ elif not agent_id:
1304
+ branch = record.get("branch")
1305
+ if not isinstance(branch, str) or run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=root)[0] != 1:
1306
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "incomplete mapping still has an unresolved branch")
1307
+ evidence = {"agent_ids": [agent.get("id") for agent in agents], "workspace_id": workspace_id, "head": head, "checked_at": datetime.now(timezone.utc).isoformat()}
1308
+ if not args.execute:
1309
+ print(json.dumps({"status": "ready", "evidence": evidence}, ensure_ascii=False))
1310
+ return 0
1311
+ reason = _required_text(args.reason, "--reason")
1312
+ approval_ref = _required_text(args.approval_ref, "--approval-ref")
1313
+ record.update({"status": "retired", "closure_evidence": {**evidence, "reason": reason, "approval_ref": approval_ref}})
1314
+ _save(task_dir / FILE_TASK_JSON, data)
1315
+ print(json.dumps(record, ensure_ascii=False))
1316
+ return 0
1317
+ except TaskWorkflowError as error:
1318
+ print(f"{error.code}: {error}", file=sys.stderr)
1319
+ return 1
1320
+
1321
+
1036
1322
  def _assert_delegated_resources_closed(task_dir: Path, data: dict) -> None:
1037
- repo_root = get_repo_root()
1038
1323
  children = data.get("children", [])
1039
1324
  if not isinstance(children, list):
1040
1325
  raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "linked child task list is malformed")
1041
1326
  for task_id in [task_dir.name, *children]:
1042
1327
  _, 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")
1328
+ _assert_delegated_record_closed(task_id, child_meta.get("delegated_worktree_execution_record"))
1329
+
1330
+
1331
+ def _assert_delegated_record_closed(task_id: str, record) -> None:
1332
+ if record is None:
1333
+ return
1334
+ if isinstance(record, dict) and record.get("status") == "retired" and isinstance(record.get("closure_evidence"), dict):
1335
+ return
1336
+ agent_id = record.get("agent_id") if isinstance(record, dict) else None
1337
+ if not isinstance(agent_id, str) or not agent_id.strip():
1338
+ raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated execution status is unknown for {task_id}; preserve the current state")
1339
+ agents = _paseo_list(
1340
+ ["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={record.get('label_task_id', task_id)}"],
1341
+ get_repo_root(),
1342
+ "TASK_PHASE_RESOURCE_ACTIVE",
1343
+ )
1344
+ agent = next((item for item in agents if item.get("id") == agent_id), None)
1345
+ if agent is None or agent.get("status") != "closed":
1346
+ raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated Paseo agent for {task_id} is not confirmed closed; preserve the current state")
1347
+ worktree_path = record.get("worktree_path")
1348
+ if isinstance(worktree_path, str) and Path(worktree_path).is_dir():
1349
+ if _git_output(["status", "--porcelain", "--untracked-files=all", "--", ".trellis/tasks", ".trellis/.runtime"], Path(worktree_path)):
1350
+ raise TaskWorkflowError("TASK_DELEGATE_TASK_FACTS_CHANGED", "delegated worktree changed protected task facts; preserve evidence and exclude those changes before integration")
1057
1351
 
1058
1352
 
1059
1353
  def cmd_complete_task(args: argparse.Namespace) -> int:
@@ -1068,9 +1362,7 @@ def cmd_complete_task(args: argparse.Namespace) -> int:
1068
1362
  disposition = state.get("closeout_disposition")
1069
1363
  if disposition not in {"delivery_ready", "no_go"}:
1070
1364
  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")
1365
+ _require_current_accepted_artifacts(task_dir, state)
1074
1366
  readiness_error = task_closeout_readiness(data, disposition)
1075
1367
  if readiness_error:
1076
1368
  raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
@@ -1081,7 +1373,8 @@ def cmd_complete_task(args: argparse.Namespace) -> int:
1081
1373
  raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_STARTED", "implementation tasks must enter in_progress before complete")
1082
1374
  data["status"] = "completed"
1083
1375
  data["completedAt"] = datetime.now().strftime("%Y-%m-%d")
1084
- _save(task_dir / FILE_TASK_JSON, data)
1376
+ from .task_move import relocate_task
1377
+ task_dir = relocate_task(get_repo_root(), task_dir, data)
1085
1378
  from .active_task import clear_task_from_sessions
1086
1379
  from .task_continuity import clear_task_runtime_records
1087
1380