@pennixrv/trellis 0.7.0-beta.9 → 0.7.1

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 (483) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +14 -26
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/ablate.js +1 -1
  6. package/dist/commands/ablate.js.map +1 -1
  7. package/dist/commands/init.d.ts.map +1 -1
  8. package/dist/commands/init.js +30 -56
  9. package/dist/commands/init.js.map +1 -1
  10. package/dist/commands/task-progress.d.ts +2 -0
  11. package/dist/commands/task-progress.d.ts.map +1 -1
  12. package/dist/commands/task-progress.js +37 -30
  13. package/dist/commands/task-progress.js.map +1 -1
  14. package/dist/commands/{channel → teamwork}/adapters/claude.d.ts +3 -3
  15. package/dist/commands/teamwork/adapters/claude.d.ts.map +1 -0
  16. package/dist/commands/{channel → teamwork}/adapters/claude.js +3 -3
  17. package/dist/commands/teamwork/adapters/claude.js.map +1 -0
  18. package/dist/commands/{channel → teamwork}/adapters/codex.d.ts +1 -15
  19. package/dist/commands/teamwork/adapters/codex.d.ts.map +1 -0
  20. package/dist/commands/{channel → teamwork}/adapters/codex.js +5 -42
  21. package/dist/commands/teamwork/adapters/codex.js.map +1 -0
  22. package/dist/commands/{channel → teamwork}/adapters/index.d.ts +6 -6
  23. package/dist/commands/teamwork/adapters/index.d.ts.map +1 -0
  24. package/dist/commands/{channel → teamwork}/adapters/index.js +16 -52
  25. package/dist/commands/teamwork/adapters/index.js.map +1 -0
  26. package/dist/commands/{channel → teamwork}/adapters/types.d.ts +2 -2
  27. package/dist/commands/teamwork/adapters/types.d.ts.map +1 -0
  28. package/dist/commands/teamwork/adapters/types.js.map +1 -0
  29. package/dist/commands/{channel → teamwork}/agent-loader.d.ts +2 -4
  30. package/dist/commands/teamwork/agent-loader.d.ts.map +1 -0
  31. package/dist/commands/{channel → teamwork}/agent-loader.js +4 -35
  32. package/dist/commands/teamwork/agent-loader.js.map +1 -0
  33. package/dist/commands/teamwork/artifact.d.ts +177 -0
  34. package/dist/commands/teamwork/artifact.d.ts.map +1 -0
  35. package/dist/commands/teamwork/artifact.js +908 -0
  36. package/dist/commands/teamwork/artifact.js.map +1 -0
  37. package/dist/commands/{channel → teamwork}/context-loader.d.ts +1 -1
  38. package/dist/commands/teamwork/context-loader.d.ts.map +1 -0
  39. package/dist/commands/{channel → teamwork}/context-loader.js +12 -12
  40. package/dist/commands/teamwork/context-loader.js.map +1 -0
  41. package/dist/commands/{channel → teamwork}/context-trust.d.ts +6 -6
  42. package/dist/commands/teamwork/context-trust.d.ts.map +1 -0
  43. package/dist/commands/{channel → teamwork}/context-trust.js +16 -16
  44. package/dist/commands/teamwork/context-trust.js.map +1 -0
  45. package/dist/commands/teamwork/context.d.ts +16 -0
  46. package/dist/commands/teamwork/context.d.ts.map +1 -0
  47. package/dist/commands/{channel → teamwork}/context.js +17 -17
  48. package/dist/commands/teamwork/context.js.map +1 -0
  49. package/dist/commands/teamwork/coordinator.d.ts +89 -0
  50. package/dist/commands/teamwork/coordinator.d.ts.map +1 -0
  51. package/dist/commands/teamwork/coordinator.js +1198 -0
  52. package/dist/commands/teamwork/coordinator.js.map +1 -0
  53. package/dist/commands/teamwork/create.d.ts +23 -0
  54. package/dist/commands/teamwork/create.d.ts.map +1 -0
  55. package/dist/commands/teamwork/create.js +39 -0
  56. package/dist/commands/teamwork/create.js.map +1 -0
  57. package/dist/commands/{channel → teamwork}/dev-parse-trace.d.ts +1 -1
  58. package/dist/commands/teamwork/dev-parse-trace.d.ts.map +1 -0
  59. package/dist/commands/{channel → teamwork}/dev-parse-trace.js +1 -1
  60. package/dist/commands/teamwork/dev-parse-trace.js.map +1 -0
  61. package/dist/commands/{channel → teamwork}/guard.d.ts +27 -46
  62. package/dist/commands/teamwork/guard.d.ts.map +1 -0
  63. package/dist/commands/{channel → teamwork}/guard.js +54 -156
  64. package/dist/commands/teamwork/guard.js.map +1 -0
  65. package/dist/commands/teamwork/index.d.ts +3 -0
  66. package/dist/commands/teamwork/index.d.ts.map +1 -0
  67. package/dist/commands/{channel → teamwork}/index.js +135 -165
  68. package/dist/commands/teamwork/index.js.map +1 -0
  69. package/dist/commands/{channel → teamwork}/interrupt.d.ts +1 -1
  70. package/dist/commands/teamwork/interrupt.d.ts.map +1 -0
  71. package/dist/commands/teamwork/interrupt.js +22 -0
  72. package/dist/commands/teamwork/interrupt.js.map +1 -0
  73. package/dist/commands/teamwork/kill.d.ts +7 -0
  74. package/dist/commands/teamwork/kill.d.ts.map +1 -0
  75. package/dist/commands/{channel → teamwork}/kill.js +21 -21
  76. package/dist/commands/teamwork/kill.js.map +1 -0
  77. package/dist/commands/teamwork/list.d.ts +17 -0
  78. package/dist/commands/teamwork/list.d.ts.map +1 -0
  79. package/dist/commands/{channel → teamwork}/list.js +18 -27
  80. package/dist/commands/teamwork/list.js.map +1 -0
  81. package/dist/commands/{channel → teamwork}/messages.d.ts +1 -1
  82. package/dist/commands/teamwork/messages.d.ts.map +1 -0
  83. package/dist/commands/{channel → teamwork}/messages.js +18 -18
  84. package/dist/commands/teamwork/messages.js.map +1 -0
  85. package/dist/commands/teamwork/profiles.d.ts +25 -0
  86. package/dist/commands/teamwork/profiles.d.ts.map +1 -0
  87. package/dist/commands/teamwork/profiles.js +125 -0
  88. package/dist/commands/teamwork/profiles.js.map +1 -0
  89. package/dist/commands/teamwork/rm.d.ts +27 -0
  90. package/dist/commands/teamwork/rm.d.ts.map +1 -0
  91. package/dist/commands/{channel → teamwork}/rm.js +21 -22
  92. package/dist/commands/teamwork/rm.js.map +1 -0
  93. package/dist/commands/teamwork/run.d.ts +29 -0
  94. package/dist/commands/{channel → teamwork}/run.d.ts.map +1 -1
  95. package/dist/commands/teamwork/run.js +129 -0
  96. package/dist/commands/teamwork/run.js.map +1 -0
  97. package/dist/commands/{channel → teamwork}/send.d.ts +1 -1
  98. package/dist/commands/teamwork/send.d.ts.map +1 -0
  99. package/dist/commands/{channel → teamwork}/send.js +7 -7
  100. package/dist/commands/teamwork/send.js.map +1 -0
  101. package/dist/commands/{channel → teamwork}/spawn.d.ts +8 -12
  102. package/dist/commands/teamwork/spawn.d.ts.map +1 -0
  103. package/dist/commands/{channel → teamwork}/spawn.js +51 -112
  104. package/dist/commands/teamwork/spawn.js.map +1 -0
  105. package/dist/commands/teamwork/store/events.d.ts +22 -0
  106. package/dist/commands/teamwork/store/events.d.ts.map +1 -0
  107. package/dist/commands/{channel → teamwork}/store/events.js +8 -8
  108. package/dist/commands/teamwork/store/events.js.map +1 -0
  109. package/dist/commands/teamwork/store/filter.d.ts +3 -0
  110. package/dist/commands/teamwork/store/filter.d.ts.map +1 -0
  111. package/dist/commands/teamwork/store/filter.js +2 -0
  112. package/dist/commands/teamwork/store/filter.js.map +1 -0
  113. package/dist/commands/{channel → teamwork}/store/lock.d.ts +1 -1
  114. package/dist/commands/teamwork/store/lock.d.ts.map +1 -0
  115. package/dist/commands/{channel → teamwork}/store/lock.js +2 -2
  116. package/dist/commands/teamwork/store/lock.js.map +1 -0
  117. package/dist/commands/{channel → teamwork}/store/paths.d.ts +18 -32
  118. package/dist/commands/teamwork/store/paths.d.ts.map +1 -0
  119. package/dist/commands/{channel → teamwork}/store/paths.js +42 -109
  120. package/dist/commands/teamwork/store/paths.js.map +1 -0
  121. package/dist/commands/teamwork/store/schema.d.ts +18 -0
  122. package/dist/commands/teamwork/store/schema.d.ts.map +1 -0
  123. package/dist/commands/teamwork/store/schema.js +23 -0
  124. package/dist/commands/teamwork/store/schema.js.map +1 -0
  125. package/dist/commands/{channel → teamwork}/store/thread-state.d.ts +3 -3
  126. package/dist/commands/teamwork/store/thread-state.d.ts.map +1 -0
  127. package/dist/commands/{channel → teamwork}/store/thread-state.js +1 -1
  128. package/dist/commands/teamwork/store/thread-state.js.map +1 -0
  129. package/dist/commands/teamwork/store/watch.d.ts +19 -0
  130. package/dist/commands/teamwork/store/watch.d.ts.map +1 -0
  131. package/dist/commands/{channel → teamwork}/store/watch.js +8 -8
  132. package/dist/commands/teamwork/store/watch.js.map +1 -0
  133. package/dist/commands/teamwork/supervisor/idle.d.ts.map +1 -0
  134. package/dist/commands/teamwork/supervisor/idle.js.map +1 -0
  135. package/dist/commands/{channel → teamwork}/supervisor/inbox.d.ts +2 -2
  136. package/dist/commands/teamwork/supervisor/inbox.d.ts.map +1 -0
  137. package/dist/commands/{channel → teamwork}/supervisor/inbox.js +14 -14
  138. package/dist/commands/teamwork/supervisor/inbox.js.map +1 -0
  139. package/dist/commands/{channel → teamwork}/supervisor/shutdown.d.ts +3 -6
  140. package/dist/commands/teamwork/supervisor/shutdown.d.ts.map +1 -0
  141. package/dist/commands/{channel → teamwork}/supervisor/shutdown.js +7 -17
  142. package/dist/commands/teamwork/supervisor/shutdown.js.map +1 -0
  143. package/dist/commands/{channel → teamwork}/supervisor/stdout.d.ts +3 -5
  144. package/dist/commands/teamwork/supervisor/stdout.d.ts.map +1 -0
  145. package/dist/commands/{channel → teamwork}/supervisor/stdout.js +12 -23
  146. package/dist/commands/teamwork/supervisor/stdout.js.map +1 -0
  147. package/dist/commands/teamwork/supervisor/turns.d.ts.map +1 -0
  148. package/dist/commands/teamwork/supervisor/turns.js.map +1 -0
  149. package/dist/commands/{channel → teamwork}/supervisor/warning.d.ts +5 -5
  150. package/dist/commands/teamwork/supervisor/warning.d.ts.map +1 -0
  151. package/dist/commands/{channel → teamwork}/supervisor/warning.js +6 -6
  152. package/dist/commands/teamwork/supervisor/warning.js.map +1 -0
  153. package/dist/commands/{channel → teamwork}/supervisor.d.ts +21 -21
  154. package/dist/commands/teamwork/supervisor.d.ts.map +1 -0
  155. package/dist/commands/{channel → teamwork}/supervisor.js +53 -63
  156. package/dist/commands/teamwork/supervisor.js.map +1 -0
  157. package/dist/commands/teamwork/teammate-adapters.d.ts +31 -0
  158. package/dist/commands/teamwork/teammate-adapters.d.ts.map +1 -0
  159. package/dist/commands/teamwork/teammate-adapters.js +96 -0
  160. package/dist/commands/teamwork/teammate-adapters.js.map +1 -0
  161. package/dist/commands/teamwork/teammate-config.d.ts +19 -0
  162. package/dist/commands/teamwork/teammate-config.d.ts.map +1 -0
  163. package/dist/commands/teamwork/teammate-config.js +97 -0
  164. package/dist/commands/teamwork/teammate-config.js.map +1 -0
  165. package/dist/commands/teamwork/teammate-launch.d.ts +58 -0
  166. package/dist/commands/teamwork/teammate-launch.d.ts.map +1 -0
  167. package/dist/commands/teamwork/teammate-launch.js +125 -0
  168. package/dist/commands/teamwork/teammate-launch.js.map +1 -0
  169. package/dist/commands/teamwork/text-body.d.ts +13 -0
  170. package/dist/commands/teamwork/text-body.d.ts.map +1 -0
  171. package/dist/commands/{channel → teamwork}/text-body.js +3 -3
  172. package/dist/commands/teamwork/text-body.js.map +1 -0
  173. package/dist/commands/teamwork/threads.d.ts +35 -0
  174. package/dist/commands/teamwork/threads.d.ts.map +1 -0
  175. package/dist/commands/{channel → teamwork}/threads.js +19 -19
  176. package/dist/commands/teamwork/threads.js.map +1 -0
  177. package/dist/commands/teamwork/title.d.ts +12 -0
  178. package/dist/commands/teamwork/title.d.ts.map +1 -0
  179. package/dist/commands/teamwork/title.js +24 -0
  180. package/dist/commands/teamwork/title.js.map +1 -0
  181. package/dist/commands/{channel → teamwork}/wait.d.ts +1 -5
  182. package/dist/commands/teamwork/wait.d.ts.map +1 -0
  183. package/dist/commands/{channel → teamwork}/wait.js +8 -21
  184. package/dist/commands/teamwork/wait.js.map +1 -0
  185. package/dist/commands/update.d.ts.map +1 -1
  186. package/dist/commands/update.js +48 -89
  187. package/dist/commands/update.js.map +1 -1
  188. package/dist/commands/workflow.d.ts +1 -1
  189. package/dist/commands/workflow.d.ts.map +1 -1
  190. package/dist/commands/workflow.js +9 -10
  191. package/dist/commands/workflow.js.map +1 -1
  192. package/dist/configurators/claude.d.ts +0 -17
  193. package/dist/configurators/claude.d.ts.map +1 -1
  194. package/dist/configurators/claude.js +2 -61
  195. package/dist/configurators/claude.js.map +1 -1
  196. package/dist/configurators/shared.d.ts.map +1 -1
  197. package/dist/configurators/shared.js +2 -2
  198. package/dist/configurators/shared.js.map +1 -1
  199. package/dist/configurators/snow.js +1 -1
  200. package/dist/configurators/snow.js.map +1 -1
  201. package/dist/configurators/workflow.d.ts +1 -1
  202. package/dist/configurators/workflow.js +5 -5
  203. package/dist/configurators/workflow.js.map +1 -1
  204. package/dist/constants/paths.d.ts +1 -1
  205. package/dist/constants/paths.d.ts.map +1 -1
  206. package/dist/constants/paths.js +1 -1
  207. package/dist/constants/paths.js.map +1 -1
  208. package/dist/migrations/manifests/0.7.0-beta.10.json +9 -0
  209. package/dist/migrations/manifests/0.7.0-beta.11.json +9 -0
  210. package/dist/migrations/manifests/0.7.0-beta.12.json +9 -0
  211. package/dist/migrations/manifests/0.7.0-beta.13.json +9 -0
  212. package/dist/migrations/manifests/0.7.0-beta.14.json +9 -0
  213. package/dist/migrations/manifests/0.7.0-beta.15.json +9 -0
  214. package/dist/migrations/manifests/0.7.0-beta.16.json +9 -0
  215. package/dist/migrations/manifests/0.7.0-beta.17.json +9 -0
  216. package/dist/migrations/manifests/0.7.0-beta.18.json +9 -0
  217. package/dist/migrations/manifests/0.7.0-beta.19.json +9 -0
  218. package/dist/migrations/manifests/0.7.0-beta.20.json +9 -0
  219. package/dist/migrations/manifests/0.7.0-beta.21.json +17 -0
  220. package/dist/migrations/manifests/0.7.0-beta.22.json +9 -0
  221. package/dist/migrations/manifests/0.7.0-beta.23.json +9 -0
  222. package/dist/migrations/manifests/0.7.0-beta.24.json +9 -0
  223. package/dist/migrations/manifests/0.7.0-beta.25.json +9 -0
  224. package/dist/migrations/manifests/0.7.0-beta.26.json +9 -0
  225. package/dist/migrations/manifests/0.7.0-beta.27.json +9 -0
  226. package/dist/migrations/manifests/0.7.0-beta.28.json +9 -0
  227. package/dist/migrations/manifests/0.7.0-beta.29.json +9 -0
  228. package/dist/migrations/manifests/0.7.0-beta.30.json +9 -0
  229. package/dist/migrations/manifests/0.7.0-beta.31.json +9 -0
  230. package/dist/migrations/manifests/0.7.0-beta.32.json +9 -0
  231. package/dist/migrations/manifests/0.7.0-beta.33.json +9 -0
  232. package/dist/migrations/manifests/0.7.0-beta.34.json +9 -0
  233. package/dist/migrations/manifests/0.7.0-beta.35.json +9 -0
  234. package/dist/migrations/manifests/0.7.0-beta.36.json +9 -0
  235. package/dist/migrations/manifests/0.7.0-beta.37.json +9 -0
  236. package/dist/migrations/manifests/0.7.0-beta.38.json +9 -0
  237. package/dist/migrations/manifests/0.7.0-beta.39.json +9 -0
  238. package/dist/migrations/manifests/0.7.0-beta.40.json +9 -0
  239. package/dist/migrations/manifests/0.7.0-beta.41.json +9 -0
  240. package/dist/migrations/manifests/0.7.0-beta.42.json +9 -0
  241. package/dist/migrations/manifests/0.7.0-beta.43.json +9 -0
  242. package/dist/migrations/manifests/0.7.0-beta.44.json +9 -0
  243. package/dist/migrations/manifests/0.7.0-beta.45.json +9 -0
  244. package/dist/migrations/manifests/0.7.0-beta.46.json +9 -0
  245. package/dist/migrations/manifests/0.7.0-beta.47.json +9 -0
  246. package/dist/migrations/manifests/0.7.0-beta.48.json +9 -0
  247. package/dist/migrations/manifests/0.7.0-beta.49.json +13 -0
  248. package/dist/migrations/manifests/0.7.0-beta.50.json +12 -0
  249. package/dist/migrations/manifests/0.7.0-beta.51.json +9 -0
  250. package/dist/migrations/manifests/0.7.0-beta.52.json +9 -0
  251. package/dist/migrations/manifests/0.7.0-beta.53.json +27 -0
  252. package/dist/migrations/manifests/0.7.0-beta.54.json +8 -0
  253. package/dist/migrations/manifests/0.7.1.json +9 -0
  254. package/dist/templates/claude/hooks/statusline.py +0 -7
  255. package/dist/templates/codex/hooks/session-start.py +31 -38
  256. package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/SKILL.md +10 -0
  257. package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/workflow.md +40 -0
  258. package/dist/templates/common/bundled-skills/trellis-delivery-review-and-closeout/SKILL.md +10 -0
  259. package/dist/templates/common/bundled-skills/trellis-delivery-review-and-closeout/workflow.md +55 -0
  260. package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/SKILL.md +10 -0
  261. package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/workflow.md +33 -0
  262. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +25 -25
  263. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-agents.md +1 -1
  264. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +13 -19
  265. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-spec-structure.md +1 -1
  266. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +27 -75
  267. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md +17 -56
  268. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
  269. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/bundled-skills.md +14 -11
  270. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/generated-files.md +0 -33
  271. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-teamwork.md +32 -0
  272. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/spec-system.md +1 -1
  273. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +25 -111
  274. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +19 -60
  275. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +1 -1
  276. package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +1 -1
  277. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +1 -1
  278. package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/SKILL.md +10 -0
  279. package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/workflow.md +36 -0
  280. package/dist/templates/common/bundled-skills/trellis-task-implementation/SKILL.md +10 -0
  281. package/dist/templates/common/bundled-skills/trellis-task-implementation/workflow.md +29 -0
  282. package/dist/templates/common/bundled-skills/trellis-teamwork/SKILL.md +73 -0
  283. package/dist/templates/common/bundled-skills/trellis-teamwork/references/coordination-modes.md +29 -0
  284. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-configuration-matrix.md +31 -0
  285. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +37 -0
  286. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +78 -0
  287. package/dist/templates/common/commands/continue.md +9 -49
  288. package/dist/templates/common/commands/finish-work.md +10 -58
  289. package/dist/templates/common/commands/start.md +19 -49
  290. package/dist/templates/common/skills/brainstorm.md +11 -199
  291. package/dist/templates/copilot/hooks/session-start.py +30 -26
  292. package/dist/templates/copilot/prompts/brainstorm.prompt.md +7 -160
  293. package/dist/templates/copilot/prompts/finish-work.prompt.md +11 -88
  294. package/dist/templates/copilot/prompts/record-session.prompt.md +4 -12
  295. package/dist/templates/copilot/prompts/start.prompt.md +12 -10
  296. package/dist/templates/dsh/DSH.md +2 -2
  297. package/dist/templates/kiro/agents/trellis.json +2 -2
  298. package/dist/templates/markdown/agents.md +14 -3
  299. package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +9 -8
  300. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +1 -1
  301. package/dist/templates/markdown/workspace-index.md +0 -1
  302. package/dist/templates/omp/extensions/trellis/index.ts.txt +28 -17
  303. package/dist/templates/opencode/lib/session-utils.js +20 -25
  304. package/dist/templates/opencode/lib/trellis-context.js +4 -76
  305. package/dist/templates/opencode/plugins/inject-subagent-context.js +5 -4
  306. package/dist/templates/opencode/plugins/inject-workflow-state.js +14 -17
  307. package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -3
  308. package/dist/templates/shared-hooks/inject-subagent-context.py +445 -67
  309. package/dist/templates/shared-hooks/inject-workflow-state.py +44 -36
  310. package/dist/templates/shared-hooks/session-start.py +21 -33
  311. package/dist/templates/trellis/agents/delegated-worktree-primary-agent.md +18 -0
  312. package/dist/templates/trellis/agents/teammate-profiles.json +8 -0
  313. package/dist/templates/trellis/agents/teammate.md +63 -0
  314. package/dist/templates/trellis/config.yaml +29 -38
  315. package/dist/templates/trellis/gitignore.txt +1 -0
  316. package/dist/templates/trellis/index.d.ts +8 -12
  317. package/dist/templates/trellis/index.d.ts.map +1 -1
  318. package/dist/templates/trellis/index.js +14 -25
  319. package/dist/templates/trellis/index.js.map +1 -1
  320. package/dist/templates/trellis/scripts/add_session.py +1 -1
  321. package/dist/templates/trellis/scripts/common/__init__.py +0 -1
  322. package/dist/templates/trellis/scripts/common/active_task.py +136 -100
  323. package/dist/templates/trellis/scripts/common/config.py +7 -9
  324. package/dist/templates/trellis/scripts/common/git_context.py +1 -1
  325. package/dist/templates/trellis/scripts/common/io.py +19 -0
  326. package/dist/templates/trellis/scripts/common/paths.py +0 -1
  327. package/dist/templates/trellis/scripts/common/safe_commit.py +11 -90
  328. package/dist/templates/trellis/scripts/common/session_context.py +1 -8
  329. package/dist/templates/trellis/scripts/common/spec_match.py +1 -1
  330. package/dist/templates/trellis/scripts/common/task_context.py +152 -59
  331. package/dist/templates/trellis/scripts/common/task_continuity.py +563 -0
  332. package/dist/templates/trellis/scripts/common/task_store.py +70 -548
  333. package/dist/templates/trellis/scripts/common/task_utils.py +4 -98
  334. package/dist/templates/trellis/scripts/common/task_workflow.py +1096 -0
  335. package/dist/templates/trellis/scripts/common/tasks.py +5 -8
  336. package/dist/templates/trellis/scripts/common/workflow_phase.py +8 -7
  337. package/dist/templates/trellis/scripts/hooks/linear_sync.py +3 -3
  338. package/dist/templates/trellis/scripts/task.py +114 -377
  339. package/dist/templates/trellis/workflow.md +111 -706
  340. package/dist/utils/agent-refs.d.ts +5 -5
  341. package/dist/utils/agent-refs.js +6 -6
  342. package/dist/utils/file-writer.d.ts +3 -2
  343. package/dist/utils/file-writer.d.ts.map +1 -1
  344. package/dist/utils/file-writer.js +3 -2
  345. package/dist/utils/file-writer.js.map +1 -1
  346. package/dist/utils/managed-paths.d.ts +0 -7
  347. package/dist/utils/managed-paths.d.ts.map +1 -1
  348. package/dist/utils/managed-paths.js +0 -40
  349. package/dist/utils/managed-paths.js.map +1 -1
  350. package/dist/utils/manifest-prune.d.ts.map +1 -1
  351. package/dist/utils/manifest-prune.js +2 -11
  352. package/dist/utils/manifest-prune.js.map +1 -1
  353. package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
  354. package/dist/utils/uninstall-scrubbers.js +3 -3
  355. package/dist/utils/uninstall-scrubbers.js.map +1 -1
  356. package/dist/utils/workflow-resolver.d.ts +10 -16
  357. package/dist/utils/workflow-resolver.d.ts.map +1 -1
  358. package/dist/utils/workflow-resolver.js +25 -36
  359. package/dist/utils/workflow-resolver.js.map +1 -1
  360. package/package.json +2 -2
  361. package/dist/commands/channel/adapters/claude.d.ts.map +0 -1
  362. package/dist/commands/channel/adapters/claude.js.map +0 -1
  363. package/dist/commands/channel/adapters/codex.d.ts.map +0 -1
  364. package/dist/commands/channel/adapters/codex.js.map +0 -1
  365. package/dist/commands/channel/adapters/index.d.ts.map +0 -1
  366. package/dist/commands/channel/adapters/index.js.map +0 -1
  367. package/dist/commands/channel/adapters/types.d.ts.map +0 -1
  368. package/dist/commands/channel/adapters/types.js.map +0 -1
  369. package/dist/commands/channel/agent-loader.d.ts.map +0 -1
  370. package/dist/commands/channel/agent-loader.js.map +0 -1
  371. package/dist/commands/channel/context-loader.d.ts.map +0 -1
  372. package/dist/commands/channel/context-loader.js.map +0 -1
  373. package/dist/commands/channel/context-trust.d.ts.map +0 -1
  374. package/dist/commands/channel/context-trust.js.map +0 -1
  375. package/dist/commands/channel/context.d.ts +0 -16
  376. package/dist/commands/channel/context.d.ts.map +0 -1
  377. package/dist/commands/channel/context.js.map +0 -1
  378. package/dist/commands/channel/create.d.ts +0 -29
  379. package/dist/commands/channel/create.d.ts.map +0 -1
  380. package/dist/commands/channel/create.js +0 -47
  381. package/dist/commands/channel/create.js.map +0 -1
  382. package/dist/commands/channel/dev-parse-trace.d.ts.map +0 -1
  383. package/dist/commands/channel/dev-parse-trace.js.map +0 -1
  384. package/dist/commands/channel/guard.d.ts.map +0 -1
  385. package/dist/commands/channel/guard.js.map +0 -1
  386. package/dist/commands/channel/index.d.ts +0 -3
  387. package/dist/commands/channel/index.d.ts.map +0 -1
  388. package/dist/commands/channel/index.js.map +0 -1
  389. package/dist/commands/channel/interrupt.d.ts.map +0 -1
  390. package/dist/commands/channel/interrupt.js +0 -22
  391. package/dist/commands/channel/interrupt.js.map +0 -1
  392. package/dist/commands/channel/kill.d.ts +0 -7
  393. package/dist/commands/channel/kill.d.ts.map +0 -1
  394. package/dist/commands/channel/kill.js.map +0 -1
  395. package/dist/commands/channel/list.d.ts +0 -18
  396. package/dist/commands/channel/list.d.ts.map +0 -1
  397. package/dist/commands/channel/list.js.map +0 -1
  398. package/dist/commands/channel/messages.d.ts.map +0 -1
  399. package/dist/commands/channel/messages.js.map +0 -1
  400. package/dist/commands/channel/rm.d.ts +0 -27
  401. package/dist/commands/channel/rm.d.ts.map +0 -1
  402. package/dist/commands/channel/rm.js.map +0 -1
  403. package/dist/commands/channel/run.d.ts +0 -32
  404. package/dist/commands/channel/run.js +0 -141
  405. package/dist/commands/channel/run.js.map +0 -1
  406. package/dist/commands/channel/send.d.ts.map +0 -1
  407. package/dist/commands/channel/send.js.map +0 -1
  408. package/dist/commands/channel/spawn.d.ts.map +0 -1
  409. package/dist/commands/channel/spawn.js.map +0 -1
  410. package/dist/commands/channel/store/events.d.ts +0 -22
  411. package/dist/commands/channel/store/events.d.ts.map +0 -1
  412. package/dist/commands/channel/store/events.js.map +0 -1
  413. package/dist/commands/channel/store/filter.d.ts +0 -3
  414. package/dist/commands/channel/store/filter.d.ts.map +0 -1
  415. package/dist/commands/channel/store/filter.js +0 -2
  416. package/dist/commands/channel/store/filter.js.map +0 -1
  417. package/dist/commands/channel/store/lock.d.ts.map +0 -1
  418. package/dist/commands/channel/store/lock.js.map +0 -1
  419. package/dist/commands/channel/store/paths.d.ts.map +0 -1
  420. package/dist/commands/channel/store/paths.js.map +0 -1
  421. package/dist/commands/channel/store/schema.d.ts +0 -27
  422. package/dist/commands/channel/store/schema.d.ts.map +0 -1
  423. package/dist/commands/channel/store/schema.js +0 -34
  424. package/dist/commands/channel/store/schema.js.map +0 -1
  425. package/dist/commands/channel/store/thread-state.d.ts.map +0 -1
  426. package/dist/commands/channel/store/thread-state.js.map +0 -1
  427. package/dist/commands/channel/store/watch.d.ts +0 -19
  428. package/dist/commands/channel/store/watch.d.ts.map +0 -1
  429. package/dist/commands/channel/store/watch.js.map +0 -1
  430. package/dist/commands/channel/supervisor/idle.d.ts.map +0 -1
  431. package/dist/commands/channel/supervisor/idle.js.map +0 -1
  432. package/dist/commands/channel/supervisor/inbox.d.ts.map +0 -1
  433. package/dist/commands/channel/supervisor/inbox.js.map +0 -1
  434. package/dist/commands/channel/supervisor/shutdown.d.ts.map +0 -1
  435. package/dist/commands/channel/supervisor/shutdown.js.map +0 -1
  436. package/dist/commands/channel/supervisor/stdout.d.ts.map +0 -1
  437. package/dist/commands/channel/supervisor/stdout.js.map +0 -1
  438. package/dist/commands/channel/supervisor/turns.d.ts.map +0 -1
  439. package/dist/commands/channel/supervisor/turns.js.map +0 -1
  440. package/dist/commands/channel/supervisor/warning.d.ts.map +0 -1
  441. package/dist/commands/channel/supervisor/warning.js.map +0 -1
  442. package/dist/commands/channel/supervisor.d.ts.map +0 -1
  443. package/dist/commands/channel/supervisor.js.map +0 -1
  444. package/dist/commands/channel/text-body.d.ts +0 -13
  445. package/dist/commands/channel/text-body.d.ts.map +0 -1
  446. package/dist/commands/channel/text-body.js.map +0 -1
  447. package/dist/commands/channel/threads.d.ts +0 -39
  448. package/dist/commands/channel/threads.d.ts.map +0 -1
  449. package/dist/commands/channel/threads.js.map +0 -1
  450. package/dist/commands/channel/title.d.ts +0 -12
  451. package/dist/commands/channel/title.d.ts.map +0 -1
  452. package/dist/commands/channel/title.js +0 -24
  453. package/dist/commands/channel/title.js.map +0 -1
  454. package/dist/commands/channel/wait.d.ts.map +0 -1
  455. package/dist/commands/channel/wait.js.map +0 -1
  456. package/dist/commands/channel/workers.d.ts +0 -9
  457. package/dist/commands/channel/workers.d.ts.map +0 -1
  458. package/dist/commands/channel/workers.js +0 -31
  459. package/dist/commands/channel/workers.js.map +0 -1
  460. package/dist/templates/common/bundled-skills/trellis-channel/SKILL.md +0 -69
  461. package/dist/templates/common/bundled-skills/trellis-channel/references/command-reference.md +0 -549
  462. package/dist/templates/common/bundled-skills/trellis-channel/references/forum.md +0 -233
  463. package/dist/templates/common/bundled-skills/trellis-channel/references/progress-debugging.md +0 -237
  464. package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +0 -222
  465. package/dist/templates/common/bundled-skills/trellis-channel/references/workers.md +0 -295
  466. package/dist/templates/common/bundled-skills/trellis-channel/references/workflows.md +0 -131
  467. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-channel.md +0 -70
  468. package/dist/templates/common/bundled-skills/trellis-research-record/SKILL.md +0 -14
  469. package/dist/templates/copilot/prompts/parallel.prompt.md +0 -204
  470. package/dist/templates/trellis/agents/check.md +0 -70
  471. package/dist/templates/trellis/agents/implement.md +0 -71
  472. package/dist/templates/trellis/agents/subnode.env +0 -4
  473. package/dist/templates/trellis/agents/subnode.md +0 -76
  474. package/dist/templates/trellis/scripts/common/context_projection.py +0 -508
  475. package/dist/templates/trellis/scripts/common/continuation_record.py +0 -450
  476. package/dist/templates/trellis/scripts/common/ownership_record.py +0 -439
  477. package/dist/templates/trellis/scripts/subnode_artifact.py +0 -735
  478. package/dist/templates/trellis/scripts/workspace_note.py +0 -126
  479. /package/dist/commands/{channel → teamwork}/adapters/types.js +0 -0
  480. /package/dist/commands/{channel → teamwork}/supervisor/idle.d.ts +0 -0
  481. /package/dist/commands/{channel → teamwork}/supervisor/idle.js +0 -0
  482. /package/dist/commands/{channel → teamwork}/supervisor/turns.d.ts +0 -0
  483. /package/dist/commands/{channel → teamwork}/supervisor/turns.js +0 -0
@@ -0,0 +1,1096 @@
1
+ """Trellis task phase state, stored in task.json.meta.task_workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ from datetime import datetime, timezone
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .git import run_git
18
+ from .io import read_json_checked, write_json
19
+ from .paths import FILE_TASK_JSON, get_repo_root
20
+ from .task_utils import is_within_tasks_dir, resolve_task_dir, run_task_hooks
21
+
22
+
23
+ PHASE_IDS = (
24
+ "goal_exploration_and_requirements",
25
+ "decision_space_construction_and_merging",
26
+ "solution_design_and_convergence",
27
+ "task_implementation",
28
+ "delivery_review_and_closeout",
29
+ )
30
+ PHASE_ID_SET = set(PHASE_IDS)
31
+ ROUTE_REVISION = re.compile(r"^[1-9][0-9]*$")
32
+ EMPTY_ARTIFACT_REVISION = hashlib.sha256(b"[]").hexdigest()
33
+
34
+
35
+ class TaskWorkflowError(Exception):
36
+ def __init__(self, code: str, message: str):
37
+ super().__init__(message)
38
+ self.code = code
39
+
40
+
41
+ def _required_text(value: str | None, field: str) -> str:
42
+ if not value or not value.strip() or "\0" in value:
43
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", f"{field} is required")
44
+ return value.strip()
45
+
46
+
47
+ def _refs(values: list[str] | None, field: str, required: bool = False) -> list[str]:
48
+ result = [_required_text(value, field) for value in values or []]
49
+ if required and not result:
50
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", f"at least one {field} is required")
51
+ if len(set(result)) != len(result):
52
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", f"{field} contains duplicates")
53
+ return result
54
+
55
+
56
+ def _task_dir(value: str) -> Path:
57
+ repo_root = get_repo_root()
58
+ task_dir = resolve_task_dir(value, repo_root)
59
+ if task_dir is None or not is_within_tasks_dir(task_dir, repo_root):
60
+ raise TaskWorkflowError("TASK_PHASE_TASK_NOT_FOUND", f"active task directory not found: {value}")
61
+ return task_dir
62
+
63
+
64
+ def _load(task_dir: Path) -> tuple[dict, dict]:
65
+ task_file = task_dir / FILE_TASK_JSON
66
+ data, reason = read_json_checked(task_file)
67
+ if data is None:
68
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"cannot read {task_file}: {reason}")
69
+ meta = data.get("meta")
70
+ if not isinstance(meta, dict):
71
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task.meta must be an object")
72
+ return data, meta
73
+
74
+
75
+ def _state(meta: dict) -> dict:
76
+ state = meta.get("task_workflow")
77
+ if not isinstance(state, dict) or state.get("schema_version") != 1:
78
+ raise TaskWorkflowError("TASK_PHASE_NOT_INITIALIZED", "task phase workflow is not initialized")
79
+ if not isinstance(state.get("phases"), dict) or not isinstance(state.get("phase_ids"), list):
80
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task phase workflow has an invalid shape")
81
+ try:
82
+ _validate_route(",".join(state["phase_ids"]))
83
+ _validate_classification(
84
+ state.get("task_kind"),
85
+ state.get("complexity"),
86
+ state.get("project_context"),
87
+ state["phase_ids"],
88
+ )
89
+ except (TypeError, TaskWorkflowError) as error:
90
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task phase classification or route is invalid") from error
91
+ if not ROUTE_REVISION.fullmatch(str(state.get("task_route_revision", ""))) or not ROUTE_REVISION.fullmatch(str(state.get("input_revision", ""))):
92
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task phase revision is invalid")
93
+ admission = state.get("route_dependency_admission")
94
+ if admission is not None and (
95
+ not isinstance(admission, dict)
96
+ or not ROUTE_REVISION.fullmatch(str(admission.get("task_route_revision", "")))
97
+ or admission.get("admission_state") not in {"ready", "missing", "stale", "incompatible", "blocked"}
98
+ or not isinstance(admission.get("dependencies"), list)
99
+ or not admission["dependencies"]
100
+ or any(not isinstance(value, str) or not value.strip() for value in admission["dependencies"])
101
+ or len(set(admission["dependencies"])) != len(admission["dependencies"])
102
+ or not isinstance(admission.get("evidence_refs"), list)
103
+ or any(not isinstance(value, str) or not value.strip() for value in admission["evidence_refs"])
104
+ or len(set(admission["evidence_refs"])) != len(admission["evidence_refs"])
105
+ ):
106
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "route dependency admission has an invalid shape")
107
+ if state.get("current_phase_id") is not None and state["current_phase_id"] not in state["phase_ids"]:
108
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "current phase is not in the task route")
109
+ for phase_id in state["phase_ids"]:
110
+ record = state["phases"].get(phase_id)
111
+ if not isinstance(record, dict) or record.get("status") not in {"not_started", "active", "waiting_user", "blocked", "accepted", "skipped"}:
112
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"task phase record is invalid: {phase_id}")
113
+ if phase_id == PHASE_IDS[0] and record.get("status") == "accepted":
114
+ acceptance = record.get("acceptance")
115
+ if (
116
+ not isinstance(acceptance, dict)
117
+ or acceptance.get("artifact_revision") != record.get("artifact_revision")
118
+ or not isinstance(acceptance.get("evidence_refs"), list)
119
+ or not acceptance["evidence_refs"]
120
+ ):
121
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "accepted Goal Exploration and Requirements Confirmation Phase must retain confirmation for its current artifact revision")
122
+ return state
123
+
124
+
125
+ def implementation_start_ready(data: dict, task_dir: Path) -> bool:
126
+ meta = data.get("meta")
127
+ state = meta.get("task_workflow") if isinstance(meta, dict) else None
128
+ if not isinstance(state, dict) or state.get("current_phase_id") != PHASE_IDS[3]:
129
+ return False
130
+ phase_ids = state.get("phase_ids")
131
+ if not isinstance(phase_ids, list) or PHASE_IDS[3] not in phase_ids:
132
+ return False
133
+ if state.get("task_kind") not in {"implementation", "combined"}:
134
+ return False
135
+ authorization = state.get("implementation_authorization")
136
+ contract_ref = authorization.get("contract_ref") if isinstance(authorization, dict) else None
137
+ approval_ref = authorization.get("approval_ref") if isinstance(authorization, dict) else None
138
+ if (
139
+ not isinstance(authorization, dict)
140
+ or authorization.get("task_route_revision") != state.get("task_route_revision")
141
+ or not isinstance(contract_ref, str)
142
+ or not contract_ref.strip()
143
+ or not isinstance(approval_ref, str)
144
+ or not approval_ref.strip()
145
+ ):
146
+ return False
147
+ _require_current_requirements_confirmation(task_dir, state)
148
+ if PHASE_IDS[2] not in phase_ids:
149
+ skip_records = state.get("skip_records")
150
+ skipped_design = skip_records.get(PHASE_IDS[2]) if isinstance(skip_records, dict) else None
151
+ evidence = skipped_design.get("evidence_refs") if isinstance(skipped_design, dict) else None
152
+ return isinstance(evidence, list) and contract_ref in evidence
153
+ design = state.get("phases", {}).get(PHASE_IDS[2])
154
+ if not (
155
+ isinstance(design, dict)
156
+ and design.get("status") == "accepted"
157
+ and design.get("outcome_kind") == "implementation_contract"
158
+ and design.get("result_ref") == contract_ref
159
+ ):
160
+ return False
161
+ artifact_refs = design.get("artifact_refs")
162
+ if not isinstance(artifact_refs, list) or any(not isinstance(ref, str) for ref in artifact_refs):
163
+ return False
164
+ if _artifact_digest(task_dir, artifact_refs) != design.get("artifact_revision"):
165
+ raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "Solution Design and Convergence Phase implementation contract changed after acceptance; refresh and accept the current artifact before implementation")
166
+ return True
167
+
168
+
169
+ def _save(task_file: Path, data: dict) -> None:
170
+ from .task_continuity import assert_task_mutation_allowed
171
+
172
+ assert_task_mutation_allowed(get_repo_root(), task_file.parent, data)
173
+ if not write_json(task_file, data):
174
+ raise TaskWorkflowError("TASK_PHASE_WRITE_FAILED", f"could not update {task_file}")
175
+
176
+
177
+ @contextlib.contextmanager
178
+ def _task_lock(task_dir: Path):
179
+ lock_file = task_dir / ".task-phase.lock"
180
+ with lock_file.open("a+b") as handle:
181
+ if os.name == "nt":
182
+ import msvcrt
183
+
184
+ handle.seek(0)
185
+ if handle.read(1) == b"":
186
+ handle.write(b"\0")
187
+ handle.flush()
188
+ handle.seek(0)
189
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
190
+ try:
191
+ yield
192
+ finally:
193
+ handle.seek(0)
194
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
195
+ else:
196
+ import fcntl
197
+
198
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
199
+ try:
200
+ yield
201
+ finally:
202
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
203
+
204
+
205
+ def _expected_route(state: dict, value: str | None) -> None:
206
+ expected = _required_text(value, "--expected-route-revision")
207
+ if expected != state.get("task_route_revision"):
208
+ raise TaskWorkflowError(
209
+ "TASK_PHASE_STALE_ROUTE",
210
+ f"expected route revision {expected}, current revision is {state.get('task_route_revision')}",
211
+ )
212
+
213
+
214
+ def _phase(state: dict, phase_id: str | None = None) -> tuple[str, dict]:
215
+ current = phase_id or state.get("current_phase_id")
216
+ if current not in PHASE_ID_SET or current not in state["phase_ids"]:
217
+ raise TaskWorkflowError("TASK_PHASE_INVALID_PHASE", f"phase is not in the current route: {current}")
218
+ record = state["phases"].get(current)
219
+ if not isinstance(record, dict):
220
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"missing state for phase {current}")
221
+ if phase_id is not None and current != state.get("current_phase_id"):
222
+ raise TaskWorkflowError("TASK_PHASE_NOT_CURRENT", f"current phase is {state.get('current_phase_id')}")
223
+ return current, record
224
+
225
+
226
+ def _require_current_requirements_confirmation(task_dir: Path, state: dict) -> None:
227
+ if PHASE_IDS[0] not in state["phase_ids"]:
228
+ return
229
+ requirements = state["phases"].get(PHASE_IDS[0], {})
230
+ withdrawal = state.get("return_record")
231
+ skip_records = state.get("skip_records")
232
+ skipped_requirements = skip_records.get(PHASE_IDS[0]) if isinstance(skip_records, dict) else None
233
+ if (
234
+ state["phase_ids"] == [PHASE_IDS[0], PHASE_IDS[4]]
235
+ and state.get("current_phase_id") == PHASE_IDS[4]
236
+ 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")
240
+ and isinstance(withdrawal, dict)
241
+ and isinstance(withdrawal.get("from"), str)
242
+ and withdrawal["from"] in PHASE_ID_SET
243
+ and withdrawal.get("to") == PHASE_IDS[4]
244
+ and withdrawal.get("task_route_revision") == state.get("task_route_revision")
245
+ and isinstance(withdrawal.get("evidence_refs"), list)
246
+ and bool(withdrawal["evidence_refs"])
247
+ ):
248
+ return
249
+ acceptance = requirements.get("acceptance") if isinstance(requirements, dict) else None
250
+ if (
251
+ not isinstance(acceptance, dict)
252
+ or requirements.get("status") != "accepted"
253
+ or acceptance.get("artifact_revision") != requirements.get("artifact_revision")
254
+ or not isinstance(acceptance.get("evidence_refs"), list)
255
+ or not acceptance["evidence_refs"]
256
+ ):
257
+ raise TaskWorkflowError("TASK_PHASE_USER_CONFIRMATION_REQUIRED", "Goal Exploration and Requirements Confirmation Phase must be accepted with explicit user confirmation before continuing")
258
+ current_revision = _artifact_digest(task_dir, requirements.get("artifact_refs", []))
259
+ if current_revision != requirements["artifact_revision"]:
260
+ raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "Goal Exploration and Requirements Confirmation Phase artifacts changed after user confirmation; return to Goal Exploration and Requirements Confirmation Phase and obtain confirmation for the current revision")
261
+
262
+
263
+ def _next_phase(state: dict, phase_id: str) -> str | None:
264
+ index = state["phase_ids"].index(phase_id)
265
+ return state["phase_ids"][index + 1] if index + 1 < len(state["phase_ids"]) else None
266
+
267
+
268
+ def _advance(state: dict, phase_id: str) -> None:
269
+ state["route_dependency_admission"] = None
270
+ following = _next_phase(state, phase_id)
271
+ state["current_phase_id"] = following
272
+ if following is not None:
273
+ state["phases"][following]["status"] = "active"
274
+ state["phases"][following]["input_revision"] = state["input_revision"]
275
+
276
+
277
+ def _invalidate_from(state: dict, first_phase: str, keep_first: bool = False) -> None:
278
+ state["route_dependency_admission"] = None
279
+ first_index = PHASE_IDS.index(first_phase)
280
+ for phase_id in state["phase_ids"]:
281
+ if PHASE_IDS.index(phase_id) < first_index:
282
+ continue
283
+ record = state["phases"][phase_id]
284
+ if keep_first and phase_id == first_phase:
285
+ record["status"] = "active"
286
+ record.update({
287
+ "result_ref": None,
288
+ "outcome_kind": None,
289
+ "evidence_refs": [],
290
+ "acceptance": None,
291
+ })
292
+ else:
293
+ record.update({
294
+ "status": "not_started",
295
+ "result_ref": None,
296
+ "outcome_kind": None,
297
+ "reason": None,
298
+ "evidence_refs": [],
299
+ "acceptance": None,
300
+ })
301
+ state["current_phase_id"] = first_phase
302
+ state["implementation_authorization"] = None
303
+ state["closeout_disposition"] = None
304
+
305
+
306
+ def _artifact_digest(task_dir: Path, refs: list[str]) -> str:
307
+ if not refs:
308
+ return EMPTY_ARTIFACT_REVISION
309
+ digest = hashlib.sha256()
310
+ for value in sorted(refs):
311
+ candidate = Path(value)
312
+ if candidate.is_absolute() or ".." in candidate.parts:
313
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", f"artifact path must stay inside the task: {value}")
314
+ full_path = task_dir / candidate
315
+ try:
316
+ resolved = full_path.resolve(strict=True)
317
+ resolved.relative_to(task_dir.resolve())
318
+ if full_path.is_symlink() or not resolved.is_file():
319
+ raise OSError("not a regular file")
320
+ content = resolved.read_bytes()
321
+ except (OSError, ValueError) as error:
322
+ raise TaskWorkflowError("TASK_PHASE_ARTIFACT_INVALID", f"cannot read task artifact {value}: {error}") from error
323
+ digest.update(value.encode("utf-8"))
324
+ digest.update(b"\0")
325
+ digest.update(content)
326
+ digest.update(b"\0")
327
+ return digest.hexdigest()
328
+
329
+
330
+ def _validate_route(value: str | None) -> list[str]:
331
+ raw = _required_text(value, "--phase-ids")
332
+ phases = raw.split(",")
333
+ if not phases or any(phase not in PHASE_ID_SET for phase in phases):
334
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "--phase-ids must contain declared phase identifiers")
335
+ if len(set(phases)) != len(phases) or phases != [phase for phase in PHASE_IDS if phase in phases]:
336
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "--phase-ids must be unique and follow the defined phase order")
337
+ if phases[0] != PHASE_IDS[0]:
338
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "Goal Exploration and Requirements Confirmation Phase must remain first")
339
+ if phases[-1] != PHASE_IDS[-1]:
340
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "--phase-ids must include Delivery Review and On-Site Closeout Phase")
341
+ return phases
342
+
343
+
344
+ def _validate_classification(task_kind: str | None, complexity: str | None, project_context: str | None, phases: list[str]) -> None:
345
+ if task_kind not in {"analysis", "implementation", "combined"}:
346
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "--task-kind must be analysis, implementation, or combined")
347
+ if complexity not in {"simple", "standard", "complex"}:
348
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "--complexity must be simple, standard, or complex")
349
+ if project_context not in {"new", "existing"}:
350
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "--project-context must be new or existing")
351
+ if task_kind == "analysis" and PHASE_IDS[3] in phases:
352
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "analysis tasks cannot include the Task Implementation and Test Verification Phase")
353
+
354
+
355
+ def _increment(value: str, allow_zero: bool = False) -> str:
356
+ if not re.fullmatch(r"(?:0|[1-9][0-9]*)" if allow_zero else ROUTE_REVISION.pattern, value):
357
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"invalid revision value: {value}")
358
+ return str(int(value) + 1)
359
+
360
+
361
+ def _phase_record(input_revision: str, status: str = "not_started") -> dict:
362
+ return {
363
+ "status": status,
364
+ "input_revision": input_revision,
365
+ "artifact_revision": EMPTY_ARTIFACT_REVISION,
366
+ "artifact_refs": [],
367
+ "result_revision": "0",
368
+ "result_ref": None,
369
+ "outcome_kind": None,
370
+ "reason": None,
371
+ "evidence_refs": [],
372
+ "acceptance": None,
373
+ }
374
+
375
+
376
+ def _init(args: argparse.Namespace, task_dir: Path, data: dict, meta: dict) -> int:
377
+ if isinstance(meta.get("task_workflow"), dict):
378
+ raise TaskWorkflowError("TASK_PHASE_ALREADY_INITIALIZED", "task phase workflow already exists")
379
+ phases = _validate_route(args.phase_ids)
380
+ omitted = [phase for phase in PHASE_IDS if phase not in phases]
381
+ route_reason = args.route_reason
382
+ route_evidence = _refs(args.route_evidence_ref, "--route-evidence-ref")
383
+ if omitted:
384
+ route_reason = _required_text(route_reason, "--route-reason")
385
+ if not route_evidence:
386
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "an abbreviated route needs at least one --route-evidence-ref")
387
+ elif route_reason or route_evidence:
388
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "route rationale is only used when a phase is omitted")
389
+ _validate_classification(args.task_kind, args.complexity, args.project_context, phases)
390
+ input_revision = "1"
391
+ meta["task_workflow"] = {
392
+ "schema_version": 1,
393
+ "task_route_revision": "1",
394
+ "task_kind": args.task_kind,
395
+ "complexity": args.complexity,
396
+ "project_context": args.project_context,
397
+ "phase_ids": phases,
398
+ "current_phase_id": phases[0],
399
+ "input_revision": input_revision,
400
+ "phases": {phase: _phase_record(input_revision, "active" if phase == phases[0] else "not_started") for phase in phases},
401
+ "route_dependency_admission": None,
402
+ "implementation_authorization": None,
403
+ "closeout_disposition": None,
404
+ "skip_records": {
405
+ phase: {"reason": route_reason, "evidence_refs": route_evidence, "task_route_revision": "1"}
406
+ for phase in omitted
407
+ },
408
+ "return_record": None,
409
+ }
410
+ _save(task_dir / FILE_TASK_JSON, data)
411
+ print(json.dumps(meta["task_workflow"], ensure_ascii=False))
412
+ return 0
413
+
414
+
415
+ def _show(state: dict) -> int:
416
+ print(json.dumps(state, ensure_ascii=False))
417
+ return 0
418
+
419
+
420
+ def _update(args: argparse.Namespace, task_dir: Path, data: dict, state: dict) -> int:
421
+ action = args.phase_action
422
+ child_dispositions: dict[str, str] = {}
423
+ if action == "show":
424
+ return _show(state)
425
+
426
+ if data.get("status") in {"completed", "cancelled"}:
427
+ raise TaskWorkflowError("TASK_PHASE_TASK_TERMINAL", f"task is already {data['status']}")
428
+
429
+ _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)
433
+ returning_to_requirements = action == "return" and args.target_phase == PHASE_IDS[0]
434
+ rerouting_to_requirements = action == "route" and args.target_phase == PHASE_IDS[0]
435
+ if current != PHASE_IDS[0] and not returning_to_requirements and not rerouting_to_requirements:
436
+ _require_current_requirements_confirmation(task_dir, state)
437
+ if action not in {"show", "resume", "route", "authorize", "admission"} and record.get("status") != "active":
438
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "only the active phase can be changed")
439
+
440
+ if action == "artifact":
441
+ refs = _refs(args.artifact_ref, "--artifact-ref")
442
+ revision = _artifact_digest(task_dir, refs)
443
+ if revision != record["artifact_revision"] or refs != record["artifact_refs"]:
444
+ state["route_dependency_admission"] = None
445
+ record["artifact_revision"] = revision
446
+ record["artifact_refs"] = refs
447
+ state["input_revision"] = _increment(state["input_revision"])
448
+ record["input_revision"] = state["input_revision"]
449
+ record.update({"acceptance": None, "result_ref": None, "outcome_kind": None})
450
+ _save(task_dir / FILE_TASK_JSON, data)
451
+ print(json.dumps({"phase_id": current, "artifact_revision": revision, "input_revision": state["input_revision"]}))
452
+ return 0
453
+
454
+ if action in {"wait", "block"}:
455
+ record["status"] = "waiting_user" if action == "wait" else "blocked"
456
+ record["reason"] = _required_text(args.reason, "--reason")
457
+ record["evidence_refs"] = _refs(args.evidence_ref, "--evidence-ref")
458
+ record["outcome_kind"] = args.outcome_kind
459
+ record["acceptance"] = None
460
+ elif action == "admission":
461
+ if current != PHASE_IDS[3]:
462
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "route dependency admission is recorded at Task Implementation and Test Verification Phase")
463
+ if args.admission_state is None:
464
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "--admission-state is required for dependency admission")
465
+ state["route_dependency_admission"] = {
466
+ "task_route_revision": state["task_route_revision"],
467
+ "dependencies": _refs(args.dependency, "--dependency", required=True),
468
+ "admission_state": args.admission_state,
469
+ "evidence_refs": _refs(args.evidence_ref, "--evidence-ref", required=True),
470
+ }
471
+ elif action == "resume":
472
+ if record["status"] not in {"waiting_user", "blocked"}:
473
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "only a waiting or blocked phase can resume")
474
+ record.update({"status": "active", "reason": None, "outcome_kind": None, "evidence_refs": [], "acceptance": None})
475
+ elif action == "skip":
476
+ reason = _required_text(args.reason, "--reason")
477
+ evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
478
+ if current == PHASE_IDS[0]:
479
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "The Goal Exploration and Requirements Confirmation Phase must reach minimum alignment and acceptance and cannot be skipped")
480
+ 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"]}
482
+ _advance(state, current)
483
+ elif action == "accept":
484
+ artifact_revision = _required_text(args.artifact_revision, "--artifact-revision")
485
+ current_revision = _artifact_digest(task_dir, record["artifact_refs"])
486
+ if artifact_revision != record["artifact_revision"] or current_revision != record["artifact_revision"]:
487
+ raise TaskWorkflowError("TASK_PHASE_STALE_ARTIFACT", "artifact revision changed; refresh the phase artifact before acceptance")
488
+ evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
489
+ result_ref = _required_text(args.result_ref, "--result-ref")
490
+ outcome = _required_text(args.outcome_kind, "--outcome-kind")
491
+ if current == PHASE_IDS[0]:
492
+ if outcome != "accepted":
493
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "Goal Exploration and Requirements Confirmation Phase can advance only with phase_result=accepted")
494
+ if current == PHASE_IDS[2] and outcome not in {"implementation_contract", "analysis_conclusion", "no_go"}:
495
+ 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
+ if result_ref not in record["artifact_refs"]:
497
+ raise TaskWorkflowError("TASK_PHASE_RESULT_NOT_BOUND", "--result-ref must be included in the current phase artifact refs")
498
+ if current == PHASE_IDS[3]:
499
+ authorization = state.get("implementation_authorization")
500
+ if not isinstance(authorization, dict) or authorization.get("task_route_revision") != state["task_route_revision"]:
501
+ raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_AUTHORIZED", "Task Implementation and Test Verification Phase requires a current implementation authorization")
502
+ if current == PHASE_IDS[4]:
503
+ if args.closeout_disposition not in {"delivery_ready", "no_go", "cancelled"}:
504
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "Delivery Review and On-Site Closeout Phase requires --closeout-disposition delivery_ready, no_go, or cancelled")
505
+ if outcome != args.closeout_disposition:
506
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "Delivery Review and On-Site Closeout Phase outcome must match --closeout-disposition")
507
+ 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"):
510
+ raise TaskWorkflowError("TASK_PHASE_USER_CONFIRMATION_REQUIRED", "cancellation closeout requires a user-confirmed route to Delivery Review and On-Site Closeout Phase")
511
+ child_dispositions = _validate_child_dispositions(
512
+ task_dir, data, args.closeout_disposition, args.child_disposition
513
+ )
514
+ state["closeout_disposition"] = args.closeout_disposition
515
+ record["result_revision"] = _increment(record["result_revision"], allow_zero=True)
516
+ record.update({"status": "accepted", "result_ref": result_ref, "outcome_kind": outcome, "reason": None, "evidence_refs": evidence})
517
+ record["acceptance"] = {
518
+ "artifact_revision": artifact_revision,
519
+ "result_revision": record["result_revision"],
520
+ "input_revision": record["input_revision"],
521
+ "task_route_revision": state["task_route_revision"],
522
+ "evidence_refs": evidence,
523
+ **({"child_dispositions": child_dispositions} if current == PHASE_IDS[4] and child_dispositions else {}),
524
+ }
525
+ state["input_revision"] = _increment(state["input_revision"])
526
+ # The authorization is the explicit boundary into implementation.
527
+ if not (
528
+ current == PHASE_IDS[2]
529
+ and outcome == "implementation_contract"
530
+ and PHASE_IDS[3] in state["phase_ids"]
531
+ ):
532
+ _advance(state, current)
533
+ elif action == "return":
534
+ 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):
536
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "return target must be an earlier phase in the current route")
537
+ reason = _required_text(args.reason, "--reason")
538
+ evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
539
+ state["input_revision"] = _increment(state["input_revision"])
540
+ _invalidate_from(state, target, keep_first=True)
541
+ 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"]}
543
+ elif action == "route":
544
+ phases = _validate_route(args.phase_ids)
545
+ task_kind = args.task_kind if args.task_kind is not None else state["task_kind"]
546
+ complexity = args.complexity if args.complexity is not None else state["complexity"]
547
+ project_context = args.project_context if args.project_context is not None else state["project_context"]
548
+ _validate_classification(task_kind, complexity, project_context, phases)
549
+ reason = _required_text(args.reason, "--reason")
550
+ target = _required_text(args.target_phase, "--target-phase")
551
+ evidence = _refs(args.evidence_ref, "--evidence-ref", required=True)
552
+ old_ids = state["phase_ids"]
553
+ changed = [phase for phase in PHASE_IDS if (phase in old_ids) != (phase in phases)]
554
+ classification_changed = any(
555
+ new != state[field]
556
+ for field, new in (
557
+ ("task_kind", task_kind),
558
+ ("complexity", complexity),
559
+ ("project_context", project_context),
560
+ )
561
+ )
562
+ withdrawal_route = (
563
+ target == PHASE_IDS[4]
564
+ and phases == [PHASE_IDS[0], PHASE_IDS[4]]
565
+ and bool(evidence)
566
+ )
567
+ if not changed and not classification_changed and target == current:
568
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "the updated route and target are unchanged")
569
+ earliest_change = min((PHASE_IDS.index(phase) for phase in changed), default=PHASE_IDS.index(target))
570
+ if target not in PHASE_ID_SET or (PHASE_IDS.index(target) > earliest_change and not withdrawal_route):
571
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ROUTE", "--target-phase must cover the earliest affected route change")
572
+ omitted = [phase for phase in PHASE_IDS if phase not in phases]
573
+ old_phases = state["phases"]
574
+ previous_current = state.get("current_phase_id")
575
+ state["task_route_revision"] = _increment(state["task_route_revision"])
576
+ state["input_revision"] = _increment(state["input_revision"])
577
+ state["task_kind"] = task_kind
578
+ state["complexity"] = complexity
579
+ state["project_context"] = project_context
580
+ state["phase_ids"] = phases
581
+ state["phases"] = {phase: old_phases.get(phase, _phase_record(state["input_revision"])) for phase in phases}
582
+ affected = [phase for phase in phases if PHASE_IDS.index(phase) >= PHASE_IDS.index(target)]
583
+ for phase_id in affected:
584
+ state["phases"][phase_id].update({
585
+ "status": "not_started",
586
+ "result_ref": None,
587
+ "outcome_kind": None,
588
+ "evidence_refs": [],
589
+ "acceptance": None,
590
+ })
591
+ if (
592
+ not withdrawal_route
593
+ and previous_current in phases
594
+ and PHASE_IDS.index(previous_current) < PHASE_IDS.index(target)
595
+ ):
596
+ state["current_phase_id"] = previous_current
597
+ state["phases"][previous_current]["status"] = "active"
598
+ else:
599
+ next_current = next((phase for phase in phases if PHASE_IDS.index(phase) >= PHASE_IDS.index(target)), None)
600
+ state["current_phase_id"] = next_current
601
+ if next_current is not None:
602
+ state["phases"][next_current]["status"] = "active"
603
+ state["phases"][next_current]["input_revision"] = state["input_revision"]
604
+ for phase_id, phase_record in state["phases"].items():
605
+ if phase_id not in old_phases:
606
+ phase_record["input_revision"] = state["input_revision"]
607
+ state["skip_records"] = {}
608
+ for phase_id in omitted:
609
+ state["skip_records"][phase_id] = {"reason": reason, "evidence_refs": evidence, "task_route_revision": state["task_route_revision"]}
610
+ if withdrawal_route and state["phases"][PHASE_IDS[0]].get("status") != "accepted":
611
+ state["phases"][PHASE_IDS[0]].update({
612
+ "status": "skipped",
613
+ "result_ref": None,
614
+ "outcome_kind": None,
615
+ "reason": reason,
616
+ "evidence_refs": evidence,
617
+ "acceptance": None,
618
+ })
619
+ state["skip_records"][PHASE_IDS[0]] = {
620
+ "reason": reason,
621
+ "evidence_refs": evidence,
622
+ "task_route_revision": state["task_route_revision"],
623
+ }
624
+ state["implementation_authorization"] = None
625
+ state["closeout_disposition"] = None
626
+ state["route_dependency_admission"] = None
627
+ state["return_record"] = {
628
+ "from": current,
629
+ "to": target,
630
+ "reason": reason,
631
+ "evidence_refs": evidence,
632
+ "task_route_revision": state["task_route_revision"],
633
+ }
634
+ elif action == "authorize":
635
+ if current not in {PHASE_IDS[2], PHASE_IDS[3]}:
636
+ raise TaskWorkflowError("TASK_PHASE_INVALID_TRANSITION", "implementation authorization is recorded at the Task Implementation and Test Verification Phase entry")
637
+ contract_ref = _required_text(args.contract_ref, "--contract-ref")
638
+ approval_ref = _required_text(args.approval_ref, "--approval-ref")
639
+ design = state["phases"].get(PHASE_IDS[2])
640
+ if PHASE_IDS[2] in state["phase_ids"]:
641
+ if not isinstance(design, dict) or design.get("status") != "accepted" or design.get("outcome_kind") != "implementation_contract" or design.get("result_ref") != contract_ref:
642
+ raise TaskWorkflowError("TASK_PHASE_CONTRACT_NOT_ACCEPTED", "authorize the accepted implementation contract from Solution Design and Convergence Phase")
643
+ artifact_refs = design.get("artifact_refs")
644
+ if not isinstance(artifact_refs, list) or any(not isinstance(ref, str) for ref in artifact_refs):
645
+ raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "accepted Solution Design and Convergence Phase artifact references are invalid")
646
+ if _artifact_digest(task_dir, artifact_refs) != design.get("artifact_revision"):
647
+ 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
+ elif contract_ref not in state.get("skip_records", {}).get(PHASE_IDS[2], {}).get("evidence_refs", []):
649
+ 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"]}
651
+ if current == PHASE_IDS[2]:
652
+ _advance(state, current)
653
+ else:
654
+ raise TaskWorkflowError("TASK_PHASE_INVALID_ACTION", f"unknown phase action: {action}")
655
+
656
+ _save(task_dir / FILE_TASK_JSON, data)
657
+ print(json.dumps(state, ensure_ascii=False))
658
+ return 0
659
+
660
+
661
+ def cmd_phase(args: argparse.Namespace) -> int:
662
+ try:
663
+ task_dir = _task_dir(args.task_dir)
664
+ with _task_lock(task_dir):
665
+ data, meta = _load(task_dir)
666
+ if args.phase_action == "init":
667
+ return _init(args, task_dir, data, meta)
668
+ return _update(args, task_dir, data, _state(meta))
669
+ except TaskWorkflowError as error:
670
+ print(f"{error.code}: {error}", file=sys.stderr)
671
+ return 1
672
+
673
+
674
+ DELEGATION_DEPENDENCIES = {"pennix-lifecycle", "paseo", "git"}
675
+
676
+
677
+ def _git_output(args: list[str], cwd: Path) -> str:
678
+ code, output, error = run_git(args, cwd=cwd)
679
+ if code != 0:
680
+ raise TaskWorkflowError("TASK_DELEGATE_GIT_INVALID", error.strip() or "Git validation failed")
681
+ return output.strip()
682
+
683
+
684
+ def _paseo_json(args: list[str], cwd: Path, code: str) -> dict | list:
685
+ try:
686
+ result = subprocess.run(
687
+ ["paseo", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8"
688
+ )
689
+ except OSError as error:
690
+ raise TaskWorkflowError(code, f"could not run Paseo: {error}") from error
691
+ if result.returncode != 0:
692
+ detail = (result.stderr or result.stdout).strip()
693
+ raise TaskWorkflowError(code, detail or f"Paseo exited with status {result.returncode}")
694
+ try:
695
+ value = json.loads(result.stdout)
696
+ except json.JSONDecodeError as error:
697
+ raise TaskWorkflowError(code, "Paseo did not return valid JSON") from error
698
+ if not isinstance(value, (dict, list)):
699
+ raise TaskWorkflowError(code, "Paseo returned an invalid response")
700
+ return value
701
+
702
+
703
+ def _paseo_list(args: list[str], cwd: Path, code: str) -> list[dict]:
704
+ value = _paseo_json(args, cwd, code)
705
+ if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
706
+ raise TaskWorkflowError(code, "Paseo returned an invalid list response")
707
+ return value
708
+
709
+
710
+ def cmd_delegate_start(args: argparse.Namespace) -> int:
711
+ try:
712
+ repo_root = get_repo_root().resolve()
713
+ child_dir = _task_dir(args.child_task)
714
+ child_data, _ = _load(child_dir)
715
+ parent_ref = child_data.get("parent")
716
+ if not isinstance(parent_ref, str) or not parent_ref.strip():
717
+ raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "child task has no parent link")
718
+ parent_dir = _task_dir(parent_ref)
719
+ lock_dirs = sorted((parent_dir, child_dir), key=str)
720
+ with _task_lock(lock_dirs[0]), _task_lock(lock_dirs[1]):
721
+ parent_data, parent_meta = _load(parent_dir)
722
+ child_data, child_meta = _load(child_dir)
723
+ if child_data.get("parent") != parent_dir.name or child_dir.name not in parent_data.get("children", []):
724
+ raise TaskWorkflowError("TASK_DELEGATE_PARENT_LINK_INVALID", "parent and child task links do not match")
725
+ if parent_data.get("status") != "in_progress" or not implementation_start_ready(parent_data, parent_dir):
726
+ raise TaskWorkflowError("TASK_DELEGATE_IMPLEMENTATION_NOT_AUTHORIZED", "parent task is not at an authorized Task Implementation and Test Verification Phase entry")
727
+
728
+ state = _state(parent_meta)
729
+ admission = state.get("route_dependency_admission")
730
+ if not isinstance(admission, dict):
731
+ raise TaskWorkflowError("TASK_DELEGATE_ADMISSION_REQUIRED", "parent task has no current route dependency admission")
732
+ dependencies = admission.get("dependencies", [])
733
+ if (
734
+ admission.get("admission_state") != "ready"
735
+ or admission.get("task_route_revision") != state.get("task_route_revision")
736
+ or not DELEGATION_DEPENDENCIES.issubset(set(dependencies))
737
+ or not admission.get("evidence_refs")
738
+ ):
739
+ raise TaskWorkflowError("TASK_DELEGATE_ADMISSION_REQUIRED", "route dependency admission must be current, ready, evidenced, and include Pennix Lifecycle, Paseo, and Git")
740
+
741
+ contract_path = child_dir / "prd.md"
742
+ try:
743
+ resolved_contract = contract_path.resolve(strict=True)
744
+ resolved_contract.relative_to(child_dir.resolve())
745
+ contract_bytes = resolved_contract.read_bytes()
746
+ contract = contract_bytes.decode("utf-8")
747
+ except (OSError, UnicodeError, ValueError) as error:
748
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", f"child task prd.md is unavailable: {error}") from error
749
+ if not contract.strip():
750
+ raise TaskWorkflowError("TASK_DELEGATE_CONTRACT_INVALID", "child task prd.md is empty")
751
+
752
+ branch_value = child_data.get("branch")
753
+ base_value = child_data.get("base_branch")
754
+ if not isinstance(branch_value, str) or not isinstance(base_value, str):
755
+ raise TaskWorkflowError("TASK_DELEGATE_GIT_INVALID", "child task branch and base_branch must be set")
756
+ branch = _required_text(branch_value, "child task branch")
757
+ base_branch = _required_text(base_value, "child task base_branch")
758
+ if branch == base_branch:
759
+ raise TaskWorkflowError("TASK_DELEGATE_GIT_INVALID", "child task branch and base_branch must differ")
760
+ _git_output(["check-ref-format", "--branch", branch], repo_root)
761
+ _git_output(["check-ref-format", "--branch", base_branch], repo_root)
762
+ top_level = Path(_git_output(["rev-parse", "--show-toplevel"], repo_root)).resolve()
763
+ if top_level != repo_root:
764
+ raise TaskWorkflowError("TASK_DELEGATE_GIT_INVALID", "Trellis repository root does not match the Git checkout")
765
+ base_commit = _git_output(["rev-parse", "--verify", "--end-of-options", f"{base_branch}^{{commit}}"], repo_root)
766
+ branch_exists, _, _ = run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=repo_root)
767
+ if branch_exists not in {0, 1}:
768
+ raise TaskWorkflowError("TASK_DELEGATE_GIT_INVALID", "could not check whether the child task branch already exists")
769
+
770
+ if shutil.which("paseo") is None:
771
+ raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", "Paseo CLI is not available on PATH")
772
+ try:
773
+ version = subprocess.run(["paseo", "--version"], cwd=repo_root, capture_output=True, text=True, encoding="utf-8")
774
+ except OSError as error:
775
+ raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", f"could not run Paseo: {error}") from error
776
+ if version.returncode != 0:
777
+ raise TaskWorkflowError("TASK_DELEGATE_PASEO_UNAVAILABLE", (version.stderr or version.stdout).strip() or "Paseo CLI preflight failed")
778
+ role_path = repo_root / ".trellis" / "agents" / "delegated-worktree-primary-agent.md"
779
+ try:
780
+ role = role_path.read_text(encoding="utf-8")
781
+ except (OSError, UnicodeError) as error:
782
+ raise TaskWorkflowError("TASK_DELEGATE_ROLE_MISSING", f"delegated worktree role is unavailable: {error}") from error
783
+
784
+ authorization = state.get("implementation_authorization")
785
+ if not isinstance(authorization, dict) or not isinstance(authorization.get("approval_ref"), str):
786
+ raise TaskWorkflowError("TASK_DELEGATE_AUTHORIZATION_INVALID", "current implementation authorization is invalid")
787
+ contract_digest = hashlib.sha256(contract_bytes).hexdigest()
788
+ previous = child_meta.get("delegated_worktree_execution_record")
789
+ reuse_workspace = False
790
+ workspace_id = None
791
+ worktree = None
792
+ if previous is not None:
793
+ if not isinstance(previous, dict):
794
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "existing execution mapping is invalid; reconcile it before starting another run")
795
+ if previous.get("authorization_ref") == authorization["approval_ref"]:
796
+ raise TaskWorkflowError("TASK_DELEGATE_REAUTHORIZATION_REQUIRED", "reconcile the prior execution and record fresh implementation authorization before retrying")
797
+ if (
798
+ previous.get("parent_task_id") != parent_dir.name
799
+ or previous.get("parent_task_route_revision") != state["task_route_revision"]
800
+ or previous.get("implementation_contract_sha256") != contract_digest
801
+ or previous.get("branch") != branch
802
+ ):
803
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "the prior execution belongs to a different route, contract, or branch; create a newly authorized child task")
804
+ previous_workspace_id = previous.get("workspace_id")
805
+ previous_worktree_path = previous.get("worktree_path")
806
+ if not isinstance(previous_workspace_id, str) or not previous_workspace_id.strip() or not isinstance(previous_worktree_path, str) or not previous_worktree_path.strip():
807
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "the prior execution mapping is incomplete; reconcile the Paseo workspace and worktree before retrying")
808
+ previous_worktree = Path(previous_worktree_path).expanduser().resolve()
809
+ agents = _paseo_list([
810
+ "agent", "ls", "--all", "--global", "--background", "--json",
811
+ "--label", f"trellis.task_id={child_dir.name}",
812
+ ], repo_root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
813
+ for agent in agents:
814
+ agent_id = agent.get("id")
815
+ if agent.get("status") != "closed":
816
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "a Paseo agent for this child task is not confirmed closed")
817
+ if agent_id == previous.get("agent_id"):
818
+ agent_cwd = agent.get("cwd")
819
+ if not isinstance(agent_cwd, str) or Path(agent_cwd).expanduser().resolve() != previous_worktree:
820
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "the recorded Paseo agent does not match the mapped worktree")
821
+ if previous.get("agent_id") and not any(agent.get("id") == previous.get("agent_id") for agent in agents):
822
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "the recorded Paseo agent is not present for reconciliation")
823
+ workspaces = _paseo_list(["workspace", "ls", "--background", "--json"], repo_root, "TASK_DELEGATE_RECONCILIATION_REQUIRED")
824
+ workspace_row = next((item for item in workspaces if item.get("workspaceId") == previous_workspace_id), None)
825
+ if workspace_row is not None:
826
+ workspace_cwd = workspace_row.get("cwd")
827
+ if not isinstance(workspace_cwd, str) or Path(workspace_cwd).expanduser().resolve() != previous_worktree or not previous_worktree.is_dir():
828
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "the recorded Paseo workspace does not match its existing worktree")
829
+ worktree = previous_worktree
830
+ worktree_root = Path(_git_output(["rev-parse", "--show-toplevel"], worktree)).resolve()
831
+ common_dir = Path(_git_output(["rev-parse", "--git-common-dir"], repo_root))
832
+ worktree_common_dir = Path(_git_output(["rev-parse", "--git-common-dir"], worktree))
833
+ common_dir = (common_dir if common_dir.is_absolute() else repo_root / common_dir).resolve()
834
+ worktree_common_dir = (worktree_common_dir if worktree_common_dir.is_absolute() else worktree / worktree_common_dir).resolve()
835
+ if (
836
+ worktree_root != worktree
837
+ or worktree_common_dir != common_dir
838
+ or _git_output(["branch", "--show-current"], worktree) != branch
839
+ or _git_output(["rev-parse", "HEAD"], worktree) != base_commit
840
+ or _git_output(["status", "--porcelain", "--untracked-files=all"], worktree)
841
+ ):
842
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "the prior worktree is not clean at the authorized branch and Git base; preserve and reconcile it before retrying")
843
+ workspace_id = previous_workspace_id
844
+ reuse_workspace = True
845
+ else:
846
+ if previous_worktree.exists() or branch_exists == 0:
847
+ raise TaskWorkflowError("TASK_DELEGATE_RECONCILIATION_REQUIRED", "the prior workspace is unavailable but its worktree or branch remains; reconcile it before rebuilding")
848
+ elif branch_exists == 0:
849
+ raise TaskWorkflowError("TASK_DELEGATE_GIT_INVALID", f"child task branch already exists: {branch}")
850
+
851
+ record = {
852
+ "parent_task_id": parent_dir.name,
853
+ "parent_task_route_revision": state["task_route_revision"],
854
+ "authorization_ref": authorization["approval_ref"],
855
+ "implementation_contract_ref": "prd.md",
856
+ "implementation_contract_sha256": contract_digest,
857
+ "branch": branch,
858
+ "base_commit": base_commit,
859
+ "workspace_id": workspace_id,
860
+ "worktree_path": str(worktree) if worktree else None,
861
+ "agent_id": None,
862
+ "status": "starting",
863
+ "started_at": datetime.now(timezone.utc).isoformat(),
864
+ }
865
+ child_meta["delegated_worktree_execution_record"] = record
866
+ _save(child_dir / FILE_TASK_JSON, child_data)
867
+
868
+ try:
869
+ if not reuse_workspace:
870
+ workspace = _paseo_json([
871
+ "workspace", "create", "--isolation", "worktree", "--path", str(repo_root),
872
+ "--mode", "branch-off", "--worktree-slug", child_dir.name,
873
+ "--new-branch", branch, "--base", base_commit,
874
+ "--title", child_data.get("title") or child_dir.name, "--json",
875
+ ], repo_root, "TASK_DELEGATE_WORKSPACE_FAILED")
876
+ if not isinstance(workspace, dict):
877
+ raise TaskWorkflowError("TASK_DELEGATE_WORKSPACE_FAILED", "Paseo returned an invalid workspace response")
878
+ workspace_id = workspace.get("workspaceId")
879
+ worktree_path = workspace.get("cwd")
880
+ if not isinstance(workspace_id, str) or not workspace_id.strip() or not isinstance(worktree_path, str) or not worktree_path.strip():
881
+ raise TaskWorkflowError("TASK_DELEGATE_WORKSPACE_FAILED", "Paseo workspace response lacks workspaceId or cwd")
882
+ worktree = Path(worktree_path).expanduser()
883
+ if not worktree.is_absolute():
884
+ worktree = repo_root / worktree
885
+ worktree = worktree.resolve()
886
+ record.update({"workspace_id": workspace_id, "worktree_path": str(worktree), "status": "workspace_created"})
887
+ _save(child_dir / FILE_TASK_JSON, child_data)
888
+ if not isinstance(worktree, Path) or not isinstance(workspace_id, str):
889
+ raise TaskWorkflowError("TASK_DELEGATE_WORKSPACE_FAILED", "Paseo workspace mapping is incomplete")
890
+ worktree_root = Path(_git_output(["rev-parse", "--show-toplevel"], worktree)).resolve()
891
+ common_dir = Path(_git_output(["rev-parse", "--git-common-dir"], repo_root))
892
+ worktree_common_dir = Path(_git_output(["rev-parse", "--git-common-dir"], worktree))
893
+ common_dir = (common_dir if common_dir.is_absolute() else repo_root / common_dir).resolve()
894
+ worktree_common_dir = (worktree_common_dir if worktree_common_dir.is_absolute() else worktree / worktree_common_dir).resolve()
895
+ if worktree_root != worktree or worktree_common_dir != common_dir:
896
+ raise TaskWorkflowError("TASK_DELEGATE_WORKSPACE_FAILED", "Paseo workspace is not a worktree of the task repository")
897
+ if _git_output(["branch", "--show-current"], worktree) != branch or _git_output(["rev-parse", "HEAD"], worktree) != base_commit:
898
+ raise TaskWorkflowError("TASK_DELEGATE_WORKSPACE_FAILED", "Paseo worktree branch or base does not match the child task")
899
+
900
+ prompt = (
901
+ "Follow the delegated worktree role below. The child task's prd.md is the sole task-specific implementation contract; "
902
+ "the parent authorization and Git baseline are execution metadata. Do not accept task phases or change task lifecycle state.\n\n"
903
+ f"Parent task: {parent_dir.name}\nParent route revision: {state['task_route_revision']}\n"
904
+ f"Authorized contract: prd.md\nBranch: {branch}\nBase commit: {base_commit}\n\n"
905
+ f"{role}\n\n## Child task prd.md\n{contract}"
906
+ )
907
+ agent = _paseo_json([
908
+ "run", "--workspace", workspace_id, "--no-wait", "--json",
909
+ "--title", child_data.get("title") or child_dir.name,
910
+ "--label", f"trellis.task_id={child_dir.name}",
911
+ "--label", f"trellis.parent_task_id={parent_dir.name}", prompt,
912
+ ], repo_root, "TASK_DELEGATE_AGENT_START_FAILED")
913
+ if not isinstance(agent, dict):
914
+ raise TaskWorkflowError("TASK_DELEGATE_AGENT_START_FAILED", "Paseo returned an invalid agent response")
915
+ agent_id = agent.get("agentId")
916
+ if not isinstance(agent_id, str) or not agent_id.strip():
917
+ raise TaskWorkflowError("TASK_DELEGATE_AGENT_START_FAILED", "Paseo run response lacks agentId")
918
+ record.update({"agent_id": agent_id, "status": "running"})
919
+ _save(child_dir / FILE_TASK_JSON, child_data)
920
+ except TaskWorkflowError as error:
921
+ record["status"] = "reconciliation_required"
922
+ record["last_error"] = {"code": error.code, "reason": str(error)}
923
+ _save(child_dir / FILE_TASK_JSON, child_data)
924
+ raise
925
+
926
+ print(json.dumps(record, ensure_ascii=False))
927
+ return 0
928
+ except TaskWorkflowError as error:
929
+ print(f"{error.code}: {error}", file=sys.stderr)
930
+ return 1
931
+
932
+ def cmd_cancel_task(args: argparse.Namespace) -> int:
933
+ try:
934
+ task_dir = _task_dir(args.task_dir)
935
+ with _task_lock(task_dir):
936
+ data, meta = _load(task_dir)
937
+ if data.get("status") in {"completed", "cancelled"}:
938
+ raise TaskWorkflowError("TASK_PHASE_TASK_TERMINAL", f"task is already {data['status']}")
939
+ readiness_error = task_closeout_readiness(data, "cancelled")
940
+ if readiness_error:
941
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
942
+ _validate_child_dispositions(task_dir, data, "cancelled", None, from_acceptance=True)
943
+ _assert_delegated_resources_closed(task_dir, data)
944
+ meta["task_cancellation"] = {
945
+ "reason": _required_text(args.reason, "--reason"),
946
+ "evidence_refs": _refs(args.evidence_ref, "--evidence-ref", required=True),
947
+ }
948
+ data["status"] = "cancelled"
949
+ data["completedAt"] = None
950
+ _save(task_dir / FILE_TASK_JSON, data)
951
+ from .active_task import clear_task_from_sessions
952
+ from .task_continuity import clear_task_runtime_records
953
+
954
+ repo_root = get_repo_root()
955
+ clear_task_from_sessions(str(task_dir), repo_root)
956
+ records_cleared = clear_task_runtime_records(repo_root, task_dir.relative_to(repo_root).as_posix())
957
+ print(json.dumps({"status": data["status"], "task_cancellation": meta["task_cancellation"], "runtime_records_cleared": records_cleared}, ensure_ascii=False))
958
+ return 0
959
+ except TaskWorkflowError as error:
960
+ print(f"{error.code}: {error}", file=sys.stderr)
961
+ return 1
962
+
963
+
964
+ def task_closeout_readiness(data: dict, disposition: str) -> str | None:
965
+ if "meta" in data and not isinstance(data["meta"], dict):
966
+ return "task metadata is malformed"
967
+ meta = data.get("meta")
968
+ if not isinstance(meta, dict) or "task_workflow" not in meta:
969
+ return "task phase workflow is not initialized"
970
+ state = meta["task_workflow"]
971
+ if not isinstance(state, dict) or not isinstance(state.get("phase_ids"), list) or not isinstance(state.get("phases"), dict):
972
+ return "task phase workflow data is malformed"
973
+ if state.get("current_phase_id") is not None:
974
+ return "the current task phase route is not complete"
975
+ for phase_id in state["phase_ids"]:
976
+ phase = state["phases"].get(phase_id)
977
+ if not isinstance(phase, dict) or phase.get("status") not in {"accepted", "skipped"}:
978
+ return f"task phase {phase_id} is not accepted or skipped"
979
+ closeout = state["phases"].get(PHASE_IDS[4]) if PHASE_IDS[4] in state["phase_ids"] else None
980
+ if not closeout or closeout.get("status") != "accepted":
981
+ return "Delivery Review and On-Site Closeout Phase must be accepted before ending the task"
982
+ if state.get("closeout_disposition") != disposition:
983
+ return f"Delivery Review and On-Site Closeout Phase disposition is {state.get('closeout_disposition')}; expected {disposition}"
984
+ 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"):
987
+ return "cancellation requires a user-confirmed route to Delivery Review and On-Site Closeout Phase"
988
+ return None
989
+
990
+
991
+ def _validate_child_dispositions(
992
+ task_dir: Path,
993
+ data: dict,
994
+ disposition: str,
995
+ requested: list[str] | None,
996
+ *,
997
+ from_acceptance: bool = False,
998
+ ) -> dict[str, str]:
999
+ children = data.get("children", [])
1000
+ if not isinstance(children, list) or any(not isinstance(child, str) or not child.strip() for child in children):
1001
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", "linked child task list is malformed")
1002
+ state = data.get("meta", {}).get("task_workflow", {})
1003
+ closeout = state.get("phases", {}).get(PHASE_IDS[4], {})
1004
+ acceptance = closeout.get("acceptance") if isinstance(closeout, dict) else None
1005
+ if from_acceptance:
1006
+ choices = acceptance.get("child_dispositions") if isinstance(acceptance, dict) else None
1007
+ if children and not isinstance(choices, dict):
1008
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", "closeout must record a disposition for every linked child task")
1009
+ choices = choices or {}
1010
+ else:
1011
+ choices = {}
1012
+ for item in requested or []:
1013
+ child_id, separator, choice = item.partition("=")
1014
+ child_id, choice = child_id.strip(), choice.strip()
1015
+ if not separator or not child_id or not choice or child_id in choices:
1016
+ raise TaskWorkflowError("TASK_PHASE_INVALID_INPUT", "--child-disposition must use unique <task-id>=<choice> values")
1017
+ choices[child_id] = choice
1018
+ if set(choices) != set(children):
1019
+ if children or choices:
1020
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", "record exactly one disposition for every linked child task")
1021
+ allowed = {"integrated"} if disposition in {"delivery_ready", "no_go"} else {"continue", "independent", "cancelled"}
1022
+ if set(choices) != set(children) or any(choice not in allowed for choice in choices.values()):
1023
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", "child task dispositions do not match the closeout outcome")
1024
+ for child_id, choice in choices.items():
1025
+ child_dir = _task_dir(child_id)
1026
+ child_data, _ = _load(child_dir)
1027
+ if child_data.get("parent") != task_dir.name:
1028
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", f"child task link is inconsistent: {child_id}")
1029
+ if choice == "integrated" and child_data.get("status") != "completed":
1030
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", f"child task is not completed and integrated: {child_id}")
1031
+ if choice == "cancelled" and child_data.get("status") != "cancelled":
1032
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", f"child task must be cancelled through its own flow: {child_id}")
1033
+ return choices
1034
+
1035
+
1036
+ def _assert_delegated_resources_closed(task_dir: Path, data: dict) -> None:
1037
+ repo_root = get_repo_root()
1038
+ children = data.get("children", [])
1039
+ if not isinstance(children, list):
1040
+ raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "linked child task list is malformed")
1041
+ for task_id in [task_dir.name, *children]:
1042
+ _, 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")
1057
+
1058
+
1059
+ def cmd_complete_task(args: argparse.Namespace) -> int:
1060
+ try:
1061
+ task_dir = _task_dir(args.task_dir)
1062
+ with _task_lock(task_dir):
1063
+ data, meta = _load(task_dir)
1064
+ if data.get("status") in {"completed", "cancelled"}:
1065
+ raise TaskWorkflowError("TASK_PHASE_TASK_TERMINAL", f"task is already {data['status']}")
1066
+ state = _state(meta)
1067
+ _require_current_requirements_confirmation(task_dir, state)
1068
+ disposition = state.get("closeout_disposition")
1069
+ if disposition not in {"delivery_ready", "no_go"}:
1070
+ 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")
1074
+ readiness_error = task_closeout_readiness(data, disposition)
1075
+ if readiness_error:
1076
+ raise TaskWorkflowError("TASK_PHASE_CLOSEOUT_REQUIRED", readiness_error)
1077
+ _validate_child_dispositions(task_dir, data, disposition, None, from_acceptance=True)
1078
+ _assert_delegated_resources_closed(task_dir, data)
1079
+ implementation = state["phases"].get(PHASE_IDS[3])
1080
+ if isinstance(implementation, dict) and implementation.get("status") == "accepted" and data.get("status") != "in_progress":
1081
+ raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_STARTED", "implementation tasks must enter in_progress before complete")
1082
+ data["status"] = "completed"
1083
+ data["completedAt"] = datetime.now().strftime("%Y-%m-%d")
1084
+ _save(task_dir / FILE_TASK_JSON, data)
1085
+ from .active_task import clear_task_from_sessions
1086
+ from .task_continuity import clear_task_runtime_records
1087
+
1088
+ repo_root = get_repo_root()
1089
+ clear_task_from_sessions(str(task_dir), repo_root)
1090
+ records_cleared = clear_task_runtime_records(repo_root, task_dir.relative_to(repo_root).as_posix())
1091
+ run_task_hooks("after_complete", task_dir / FILE_TASK_JSON, repo_root)
1092
+ print(json.dumps({"status": data["status"], "completedAt": data["completedAt"], "runtime_records_cleared": records_cleared}, ensure_ascii=False))
1093
+ return 0
1094
+ except TaskWorkflowError as error:
1095
+ print(f"{error.code}: {error}", file=sys.stderr)
1096
+ return 1