@pennixrv/trellis 0.7.1 → 0.7.2-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/README.md +10 -7
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +6 -4
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/ablate.d.ts.map +1 -1
  6. package/dist/commands/ablate.js +2 -0
  7. package/dist/commands/ablate.js.map +1 -1
  8. package/dist/commands/backup.d.ts +4 -0
  9. package/dist/commands/backup.d.ts.map +1 -0
  10. package/dist/commands/backup.js +75 -0
  11. package/dist/commands/backup.js.map +1 -0
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +705 -681
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/mem.d.ts.map +1 -1
  16. package/dist/commands/mem.js +49 -7
  17. package/dist/commands/mem.js.map +1 -1
  18. package/dist/commands/task-progress.d.ts +1 -0
  19. package/dist/commands/task-progress.d.ts.map +1 -1
  20. package/dist/commands/task-progress.js +67 -25
  21. package/dist/commands/task-progress.js.map +1 -1
  22. package/dist/commands/teamwork/adapters/acp.d.ts +20 -0
  23. package/dist/commands/teamwork/adapters/acp.d.ts.map +1 -0
  24. package/dist/commands/teamwork/adapters/acp.js +268 -0
  25. package/dist/commands/teamwork/adapters/acp.js.map +1 -0
  26. package/dist/commands/teamwork/adapters/claude.d.ts +1 -0
  27. package/dist/commands/teamwork/adapters/claude.d.ts.map +1 -1
  28. package/dist/commands/teamwork/adapters/claude.js +5 -0
  29. package/dist/commands/teamwork/adapters/claude.js.map +1 -1
  30. package/dist/commands/teamwork/adapters/codex.d.ts +3 -2
  31. package/dist/commands/teamwork/adapters/codex.d.ts.map +1 -1
  32. package/dist/commands/teamwork/adapters/codex.js +14 -15
  33. package/dist/commands/teamwork/adapters/codex.js.map +1 -1
  34. package/dist/commands/teamwork/adapters/index.d.ts +23 -1
  35. package/dist/commands/teamwork/adapters/index.d.ts.map +1 -1
  36. package/dist/commands/teamwork/adapters/index.js +163 -24
  37. package/dist/commands/teamwork/adapters/index.js.map +1 -1
  38. package/dist/commands/teamwork/agent-loader.d.ts +2 -1
  39. package/dist/commands/teamwork/agent-loader.d.ts.map +1 -1
  40. package/dist/commands/teamwork/agent-loader.js +2 -1
  41. package/dist/commands/teamwork/agent-loader.js.map +1 -1
  42. package/dist/commands/teamwork/artifact.d.ts +42 -14
  43. package/dist/commands/teamwork/artifact.d.ts.map +1 -1
  44. package/dist/commands/teamwork/artifact.js +188 -85
  45. package/dist/commands/teamwork/artifact.js.map +1 -1
  46. package/dist/commands/teamwork/batch-reservation.d.ts +16 -0
  47. package/dist/commands/teamwork/batch-reservation.d.ts.map +1 -0
  48. package/dist/commands/teamwork/batch-reservation.js +43 -0
  49. package/dist/commands/teamwork/batch-reservation.js.map +1 -0
  50. package/dist/commands/teamwork/batch.d.ts +23 -0
  51. package/dist/commands/teamwork/batch.d.ts.map +1 -0
  52. package/dist/commands/teamwork/batch.js +415 -0
  53. package/dist/commands/teamwork/batch.js.map +1 -0
  54. package/dist/commands/teamwork/context-loader.d.ts +1 -1
  55. package/dist/commands/teamwork/context-loader.d.ts.map +1 -1
  56. package/dist/commands/teamwork/context-loader.js +19 -6
  57. package/dist/commands/teamwork/context-loader.js.map +1 -1
  58. package/dist/commands/teamwork/coordination.d.ts +88 -0
  59. package/dist/commands/teamwork/coordination.d.ts.map +1 -0
  60. package/dist/commands/teamwork/coordination.js +153 -0
  61. package/dist/commands/teamwork/coordination.js.map +1 -0
  62. package/dist/commands/teamwork/coordinator.d.ts +33 -6
  63. package/dist/commands/teamwork/coordinator.d.ts.map +1 -1
  64. package/dist/commands/teamwork/coordinator.js +948 -342
  65. package/dist/commands/teamwork/coordinator.js.map +1 -1
  66. package/dist/commands/teamwork/create.d.ts +1 -0
  67. package/dist/commands/teamwork/create.d.ts.map +1 -1
  68. package/dist/commands/teamwork/create.js +2 -1
  69. package/dist/commands/teamwork/create.js.map +1 -1
  70. package/dist/commands/teamwork/dev-parse-trace.d.ts +2 -1
  71. package/dist/commands/teamwork/dev-parse-trace.d.ts.map +1 -1
  72. package/dist/commands/teamwork/dev-parse-trace.js +2 -0
  73. package/dist/commands/teamwork/dev-parse-trace.js.map +1 -1
  74. package/dist/commands/teamwork/foreground-wait.d.ts +6 -0
  75. package/dist/commands/teamwork/foreground-wait.d.ts.map +1 -0
  76. package/dist/commands/teamwork/foreground-wait.js +65 -0
  77. package/dist/commands/teamwork/foreground-wait.js.map +1 -0
  78. package/dist/commands/teamwork/guard.d.ts +2 -2
  79. package/dist/commands/teamwork/guard.d.ts.map +1 -1
  80. package/dist/commands/teamwork/guard.js +25 -7
  81. package/dist/commands/teamwork/guard.js.map +1 -1
  82. package/dist/commands/teamwork/index.d.ts.map +1 -1
  83. package/dist/commands/teamwork/index.js +163 -10
  84. package/dist/commands/teamwork/index.js.map +1 -1
  85. package/dist/commands/teamwork/kill.d.ts +2 -0
  86. package/dist/commands/teamwork/kill.d.ts.map +1 -1
  87. package/dist/commands/teamwork/kill.js +79 -38
  88. package/dist/commands/teamwork/kill.js.map +1 -1
  89. package/dist/commands/teamwork/messages.d.ts.map +1 -1
  90. package/dist/commands/teamwork/messages.js +3 -0
  91. package/dist/commands/teamwork/messages.js.map +1 -1
  92. package/dist/commands/teamwork/plan.d.ts +22 -0
  93. package/dist/commands/teamwork/plan.d.ts.map +1 -0
  94. package/dist/commands/teamwork/plan.js +288 -0
  95. package/dist/commands/teamwork/plan.js.map +1 -0
  96. package/dist/commands/teamwork/process-identity.d.ts +3 -0
  97. package/dist/commands/teamwork/process-identity.d.ts.map +1 -0
  98. package/dist/commands/teamwork/process-identity.js +24 -0
  99. package/dist/commands/teamwork/process-identity.js.map +1 -0
  100. package/dist/commands/teamwork/profiles.d.ts +43 -6
  101. package/dist/commands/teamwork/profiles.d.ts.map +1 -1
  102. package/dist/commands/teamwork/profiles.js +138 -110
  103. package/dist/commands/teamwork/profiles.js.map +1 -1
  104. package/dist/commands/teamwork/prompt.d.ts +4 -0
  105. package/dist/commands/teamwork/prompt.d.ts.map +1 -0
  106. package/dist/commands/teamwork/prompt.js +33 -0
  107. package/dist/commands/teamwork/prompt.js.map +1 -0
  108. package/dist/commands/teamwork/result.d.ts +55 -0
  109. package/dist/commands/teamwork/result.d.ts.map +1 -0
  110. package/dist/commands/teamwork/result.js +94 -0
  111. package/dist/commands/teamwork/result.js.map +1 -0
  112. package/dist/commands/teamwork/rm.d.ts.map +1 -1
  113. package/dist/commands/teamwork/rm.js +64 -64
  114. package/dist/commands/teamwork/rm.js.map +1 -1
  115. package/dist/commands/teamwork/run.d.ts +5 -0
  116. package/dist/commands/teamwork/run.d.ts.map +1 -1
  117. package/dist/commands/teamwork/run.js +120 -24
  118. package/dist/commands/teamwork/run.js.map +1 -1
  119. package/dist/commands/teamwork/runtime.d.ts +9 -0
  120. package/dist/commands/teamwork/runtime.d.ts.map +1 -0
  121. package/dist/commands/teamwork/runtime.js +401 -0
  122. package/dist/commands/teamwork/runtime.js.map +1 -0
  123. package/dist/commands/teamwork/send.d.ts +1 -0
  124. package/dist/commands/teamwork/send.d.ts.map +1 -1
  125. package/dist/commands/teamwork/send.js +2 -1
  126. package/dist/commands/teamwork/send.js.map +1 -1
  127. package/dist/commands/teamwork/spawn.d.ts +10 -3
  128. package/dist/commands/teamwork/spawn.d.ts.map +1 -1
  129. package/dist/commands/teamwork/spawn.js +42 -27
  130. package/dist/commands/teamwork/spawn.js.map +1 -1
  131. package/dist/commands/teamwork/store/lock.js +28 -4
  132. package/dist/commands/teamwork/store/lock.js.map +1 -1
  133. package/dist/commands/teamwork/supervisor/inbox.d.ts +1 -0
  134. package/dist/commands/teamwork/supervisor/inbox.d.ts.map +1 -1
  135. package/dist/commands/teamwork/supervisor/inbox.js +28 -9
  136. package/dist/commands/teamwork/supervisor/inbox.js.map +1 -1
  137. package/dist/commands/teamwork/supervisor/stdout.d.ts.map +1 -1
  138. package/dist/commands/teamwork/supervisor/stdout.js +2 -3
  139. package/dist/commands/teamwork/supervisor/stdout.js.map +1 -1
  140. package/dist/commands/teamwork/supervisor/warning.d.ts.map +1 -1
  141. package/dist/commands/teamwork/supervisor/warning.js +2 -3
  142. package/dist/commands/teamwork/supervisor/warning.js.map +1 -1
  143. package/dist/commands/teamwork/supervisor.d.ts +5 -0
  144. package/dist/commands/teamwork/supervisor.d.ts.map +1 -1
  145. package/dist/commands/teamwork/supervisor.js +21 -12
  146. package/dist/commands/teamwork/supervisor.js.map +1 -1
  147. package/dist/commands/teamwork/teammate-adapters.d.ts +0 -5
  148. package/dist/commands/teamwork/teammate-adapters.d.ts.map +1 -1
  149. package/dist/commands/teamwork/teammate-adapters.js +9 -56
  150. package/dist/commands/teamwork/teammate-adapters.js.map +1 -1
  151. package/dist/commands/teamwork/teammate-config.d.ts +5 -1
  152. package/dist/commands/teamwork/teammate-config.d.ts.map +1 -1
  153. package/dist/commands/teamwork/teammate-config.js +62 -19
  154. package/dist/commands/teamwork/teammate-config.js.map +1 -1
  155. package/dist/commands/teamwork/teammate-launch.d.ts +15 -6
  156. package/dist/commands/teamwork/teammate-launch.d.ts.map +1 -1
  157. package/dist/commands/teamwork/teammate-launch.js +129 -22
  158. package/dist/commands/teamwork/teammate-launch.js.map +1 -1
  159. package/dist/commands/teamwork/wait.d.ts +2 -0
  160. package/dist/commands/teamwork/wait.d.ts.map +1 -1
  161. package/dist/commands/teamwork/wait.js +44 -3
  162. package/dist/commands/teamwork/wait.js.map +1 -1
  163. package/dist/commands/uninstall.d.ts.map +1 -1
  164. package/dist/commands/uninstall.js +92 -84
  165. package/dist/commands/uninstall.js.map +1 -1
  166. package/dist/commands/update.d.ts.map +1 -1
  167. package/dist/commands/update.js +635 -592
  168. package/dist/commands/update.js.map +1 -1
  169. package/dist/commands/workflow.d.ts.map +1 -1
  170. package/dist/commands/workflow.js +94 -89
  171. package/dist/commands/workflow.js.map +1 -1
  172. package/dist/configurators/shared.js +2 -2
  173. package/dist/configurators/shared.js.map +1 -1
  174. package/dist/configurators/workflow.d.ts.map +1 -1
  175. package/dist/configurators/workflow.js +14 -32
  176. package/dist/configurators/workflow.js.map +1 -1
  177. package/dist/constants/paths.d.ts +3 -10
  178. package/dist/constants/paths.d.ts.map +1 -1
  179. package/dist/constants/paths.js +4 -13
  180. package/dist/constants/paths.js.map +1 -1
  181. package/dist/migrations/manifests/0.7.1-beta.0.json +9 -0
  182. package/dist/migrations/manifests/0.7.1-beta.1.json +186 -0
  183. package/dist/migrations/manifests/0.7.1-beta.10.json +9 -0
  184. package/dist/migrations/manifests/0.7.1-beta.11.json +9 -0
  185. package/dist/migrations/manifests/0.7.1-beta.12.json +9 -0
  186. package/dist/migrations/manifests/0.7.1-beta.13.json +9 -0
  187. package/dist/migrations/manifests/0.7.1-beta.14.json +11 -0
  188. package/dist/migrations/manifests/0.7.1-beta.15.json +9 -0
  189. package/dist/migrations/manifests/0.7.1-beta.16.json +9 -0
  190. package/dist/migrations/manifests/0.7.1-beta.17.json +9 -0
  191. package/dist/migrations/manifests/0.7.1-beta.18.json +9 -0
  192. package/dist/migrations/manifests/0.7.1-beta.19.json +9 -0
  193. package/dist/migrations/manifests/0.7.1-beta.2.json +522 -0
  194. package/dist/migrations/manifests/0.7.1-beta.3.json +9 -0
  195. package/dist/migrations/manifests/0.7.1-beta.4.json +9 -0
  196. package/dist/migrations/manifests/0.7.1-beta.5.json +9 -0
  197. package/dist/migrations/manifests/0.7.1-beta.6.json +10 -0
  198. package/dist/migrations/manifests/0.7.1-beta.7.json +8 -0
  199. package/dist/migrations/manifests/0.7.1-beta.8.json +481 -0
  200. package/dist/migrations/manifests/0.7.1-beta.9.json +191 -0
  201. package/dist/migrations/manifests/0.7.2-beta.0.json +11 -0
  202. package/dist/migrations/manifests/0.7.2-beta.1.json +11 -0
  203. package/dist/migrations/manifests/0.7.2-beta.2.json +11 -0
  204. package/dist/templates/claude/hooks/statusline.py +9 -4
  205. package/dist/templates/codex/hooks/session-start.py +14 -11
  206. package/dist/templates/codex/hooks.json +10 -0
  207. package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/workflow.md +2 -0
  208. package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/workflow.md +2 -0
  209. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +1 -1
  210. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +1 -1
  211. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +5 -5
  212. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +3 -2
  213. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
  214. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +1 -1
  215. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-teamwork.md +14 -2
  216. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +8 -2
  217. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
  218. package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +2 -2
  219. package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/workflow.md +8 -0
  220. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/mcp-setup.md +4 -2
  221. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-task-planning.md +2 -2
  222. package/dist/templates/common/bundled-skills/trellis-task-implementation/workflow.md +6 -0
  223. package/dist/templates/common/bundled-skills/trellis-teamwork/SKILL.md +18 -3
  224. package/dist/templates/common/bundled-skills/trellis-teamwork/references/coordination-modes.md +34 -3
  225. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-configuration-matrix.md +37 -26
  226. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +128 -14
  227. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +152 -20
  228. package/dist/templates/common/commands/start.md +2 -2
  229. package/dist/templates/common/skills/before-dev.md +1 -4
  230. package/dist/templates/common/skills/break-loop.md +7 -18
  231. package/dist/templates/common/skills/check.md +7 -11
  232. package/dist/templates/common/skills/{brainstorm.md → request-intake.md} +5 -3
  233. package/dist/templates/common/skills/update-spec.md +5 -7
  234. package/dist/templates/copilot/hooks/session-start.py +14 -11
  235. package/dist/templates/copilot/index.d.ts +1 -6
  236. package/dist/templates/copilot/index.d.ts.map +1 -1
  237. package/dist/templates/copilot/index.js +1 -14
  238. package/dist/templates/copilot/index.js.map +1 -1
  239. package/dist/templates/extract.js +4 -0
  240. package/dist/templates/extract.js.map +1 -1
  241. package/dist/templates/markdown/spec/backend/index.md.txt +4 -35
  242. package/dist/templates/markdown/spec/frontend/index.md.txt +4 -36
  243. package/dist/templates/omp/extensions/trellis/index.ts.txt +2 -2
  244. package/dist/templates/opencode/lib/session-utils.js +301 -209
  245. package/dist/templates/pi/extensions/trellis/index.ts.txt +17 -1
  246. package/dist/templates/shared-hooks/guard-teamwork-wait.py +58 -0
  247. package/dist/templates/shared-hooks/index.d.ts +1 -1
  248. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  249. package/dist/templates/shared-hooks/index.js +1 -0
  250. package/dist/templates/shared-hooks/index.js.map +1 -1
  251. package/dist/templates/shared-hooks/inject-workflow-state.py +8 -3
  252. package/dist/templates/shared-hooks/session-start.py +16 -12
  253. package/dist/templates/snow/SNOW.md +1 -1
  254. package/dist/templates/trellis/agents/delegated-worktree-primary-agent.md +9 -0
  255. package/dist/templates/trellis/agents/profiles/architecture.md +9 -0
  256. package/dist/templates/trellis/agents/profiles/diagnosis.md +9 -0
  257. package/dist/templates/trellis/agents/profiles/exploration.md +9 -0
  258. package/dist/templates/trellis/agents/profiles/research.md +9 -0
  259. package/dist/templates/trellis/agents/profiles/review.md +9 -0
  260. package/dist/templates/trellis/agents/profiles/verification.md +9 -0
  261. package/dist/templates/trellis/agents/teammate-profiles.json +56 -4
  262. package/dist/templates/trellis/agents/teammate.md +4 -52
  263. package/dist/templates/trellis/config.yaml +2 -5
  264. package/dist/templates/trellis/index.d.ts +4 -0
  265. package/dist/templates/trellis/index.d.ts.map +1 -1
  266. package/dist/templates/trellis/index.js +22 -4
  267. package/dist/templates/trellis/index.js.map +1 -1
  268. package/dist/templates/trellis/scripts/add_session.py +1 -1
  269. package/dist/templates/trellis/scripts/common/active_task.py +3 -47
  270. package/dist/templates/trellis/scripts/common/config.py +5 -0
  271. package/dist/templates/trellis/scripts/common/paths.py +9 -25
  272. package/dist/templates/trellis/scripts/common/safe_commit.py +8 -8
  273. package/dist/templates/trellis/scripts/common/task_context.py +11 -51
  274. package/dist/templates/trellis/scripts/common/task_continuity.py +191 -60
  275. package/dist/templates/trellis/scripts/common/task_layout.py +80 -0
  276. package/dist/templates/trellis/scripts/common/task_move.py +345 -0
  277. package/dist/templates/trellis/scripts/common/task_store.py +51 -57
  278. package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
  279. package/dist/templates/trellis/scripts/common/task_workflow.py +395 -49
  280. package/dist/templates/trellis/scripts/common/tasks.py +6 -5
  281. package/dist/templates/trellis/scripts/hooks/linear_sync.py +13 -3
  282. package/dist/templates/trellis/scripts/task.py +76 -24
  283. package/dist/templates/trellis/teamwork.json +10 -0
  284. package/dist/templates/trellis/workflow.md +11 -2
  285. package/dist/utils/ablation-store.d.ts +1 -1
  286. package/dist/utils/ablation-store.d.ts.map +1 -1
  287. package/dist/utils/ablation-store.js +58 -24
  288. package/dist/utils/ablation-store.js.map +1 -1
  289. package/dist/utils/asset-backup.d.ts +138 -0
  290. package/dist/utils/asset-backup.d.ts.map +1 -0
  291. package/dist/utils/asset-backup.js +525 -0
  292. package/dist/utils/asset-backup.js.map +1 -0
  293. package/dist/utils/atomic-write.d.ts +1 -1
  294. package/dist/utils/atomic-write.d.ts.map +1 -1
  295. package/dist/utils/atomic-write.js +6 -3
  296. package/dist/utils/atomic-write.js.map +1 -1
  297. package/dist/utils/manifest-prune.d.ts +4 -1
  298. package/dist/utils/manifest-prune.d.ts.map +1 -1
  299. package/dist/utils/manifest-prune.js +17 -6
  300. package/dist/utils/manifest-prune.js.map +1 -1
  301. package/dist/utils/template-fetcher.d.ts +4 -19
  302. package/dist/utils/template-fetcher.d.ts.map +1 -1
  303. package/dist/utils/template-fetcher.js +9 -45
  304. package/dist/utils/template-fetcher.js.map +1 -1
  305. package/dist/utils/template-hash.d.ts +5 -4
  306. package/dist/utils/template-hash.d.ts.map +1 -1
  307. package/dist/utils/template-hash.js +24 -17
  308. package/dist/utils/template-hash.js.map +1 -1
  309. package/package.json +3 -2
  310. package/dist/templates/copilot/prompts/before-dev.prompt.md +0 -39
  311. package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -13
  312. package/dist/templates/copilot/prompts/break-loop.prompt.md +0 -129
  313. package/dist/templates/copilot/prompts/check-cross-layer.prompt.md +0 -170
  314. package/dist/templates/copilot/prompts/check.prompt.md +0 -110
  315. package/dist/templates/copilot/prompts/create-command.prompt.md +0 -116
  316. package/dist/templates/copilot/prompts/finish-work.prompt.md +0 -22
  317. package/dist/templates/copilot/prompts/integrate-skill.prompt.md +0 -223
  318. package/dist/templates/copilot/prompts/onboard.prompt.md +0 -362
  319. package/dist/templates/copilot/prompts/record-session.prompt.md +0 -58
  320. package/dist/templates/copilot/prompts/start.prompt.md +0 -65
  321. package/dist/templates/copilot/prompts/update-spec.prompt.md +0 -358
@@ -0,0 +1,80 @@
1
+ """Task status directories and bounded discovery (never recurse into task content)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterator
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ STATUSES = ("planning", "in_progress", "completed", "cancelled")
11
+ MONTH_RE = re.compile(r"^\d{4}-(?:0[1-9]|1[0-2])$")
12
+
13
+
14
+ def task_location(tasks_dir: Path, name: str, status: str, month: str | None = None) -> Path:
15
+ if not name or name in {".", "..", "archive", *STATUSES} or "/" in name or "\\" in name:
16
+ raise ValueError(f"Invalid task directory name: {name}")
17
+ if status not in STATUSES:
18
+ raise ValueError(f"Invalid task status: {status}")
19
+ if status == "completed":
20
+ month = month or date.today().strftime("%Y-%m")
21
+ if not MONTH_RE.fullmatch(month):
22
+ raise ValueError(f"Invalid completion month: {month}")
23
+ return tasks_dir / status / month / name
24
+ return tasks_dir / status / name
25
+
26
+
27
+ def task_status_dir(task_dir: Path, tasks_dir: Path) -> str | None:
28
+ """Validate exact depth, containment and nested symlinks; allow a shared tasks root."""
29
+ try:
30
+ relative = task_dir.absolute().relative_to(tasks_dir.absolute())
31
+ parts = relative.parts
32
+ valid = (len(parts) == 2 and parts[0] in {"planning", "in_progress", "cancelled"}) or (
33
+ len(parts) == 3 and parts[0] == "completed" and MONTH_RE.fullmatch(parts[1])
34
+ )
35
+ if not valid:
36
+ return None
37
+ current = tasks_dir
38
+ for part in parts:
39
+ current /= part
40
+ if current.is_symlink():
41
+ return None
42
+ task_dir.resolve().relative_to(tasks_dir.resolve())
43
+ return parts[0]
44
+ except (ValueError, OSError, RuntimeError):
45
+ return None
46
+
47
+
48
+ def iter_task_dirs(tasks_dir: Path, *, legacy: bool = False) -> Iterator[Path]:
49
+ """Enumerate only the prescribed levels; old layout is migration input only."""
50
+ if not tasks_dir.is_dir():
51
+ return
52
+ candidates: list[Path] = []
53
+ for entry in sorted(tasks_dir.iterdir()):
54
+ if entry.is_symlink():
55
+ raise ValueError(f"Symlink in task layout: {entry}")
56
+ if not entry.is_dir():
57
+ continue
58
+ if entry.name in STATUSES:
59
+ if entry.name == "completed":
60
+ for month in sorted(entry.iterdir()):
61
+ if month.is_symlink() or not month.is_dir() or not MONTH_RE.fullmatch(month.name):
62
+ raise ValueError(f"Invalid completion month directory: {month}")
63
+ candidates.extend(month.iterdir())
64
+ else:
65
+ candidates.extend(entry.iterdir())
66
+ elif legacy and entry.name == "archive":
67
+ for month in sorted(entry.iterdir()):
68
+ if month.is_symlink() or not month.is_dir() or not MONTH_RE.fullmatch(month.name):
69
+ raise ValueError(f"Invalid archive month directory: {month}")
70
+ candidates.extend(month.iterdir())
71
+ elif legacy:
72
+ candidates.append(entry)
73
+ else:
74
+ raise ValueError(f"Old task layout at {entry}; run task.py migrate-layout")
75
+ for candidate in sorted(candidates, key=lambda p: (p.name, str(p))):
76
+ if candidate.is_symlink():
77
+ raise ValueError(f"Symlink in task layout: {candidate}")
78
+ if not candidate.is_dir():
79
+ raise ValueError(f"Invalid task directory: {candidate}")
80
+ yield candidate
@@ -0,0 +1,345 @@
1
+ """Recoverable task relocation shared by lifecycle commands and explicit migration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import hashlib
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from .io import read_json_checked, write_json, write_text_atomic
13
+ from .paths import get_repo_root, get_tasks_dir
14
+ from .task_layout import MONTH_RE, iter_task_dirs, task_location, task_status_dir
15
+
16
+ _LOCK_DEPTH = 0
17
+ _APPLYING = False
18
+
19
+
20
+ def journal_path(root: Path) -> Path:
21
+ return root / ".trellis/.runtime/task-layout-transaction.json"
22
+
23
+
24
+ def require_settled_layout(root: Path) -> None:
25
+ if not _APPLYING and journal_path(root).exists():
26
+ raise ValueError("Task relocation requires recovery: run task.py migrate-layout --recover")
27
+
28
+
29
+ @contextlib.contextmanager
30
+ def layout_lock(root: Path):
31
+ # Reentrant within one process; all task/pointer writers take this before record locks.
32
+ global _LOCK_DEPTH
33
+ from .task_continuity import _record_lock
34
+
35
+ if _LOCK_DEPTH:
36
+ _LOCK_DEPTH += 1
37
+ try:
38
+ yield
39
+ finally:
40
+ _LOCK_DEPTH -= 1
41
+ return
42
+ with _record_lock(root / ".trellis/.runtime/task-layout.json"):
43
+ _LOCK_DEPTH = 1
44
+ try:
45
+ yield
46
+ finally:
47
+ _LOCK_DEPTH = 0
48
+
49
+
50
+ def _read(path: Path) -> dict:
51
+ data, reason = read_json_checked(path)
52
+ if data is None:
53
+ raise ValueError(f"Cannot read {path}: {reason}")
54
+ return data
55
+
56
+
57
+ def _json(data: object) -> str:
58
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
59
+
60
+
61
+ def _rewrite(value, mapping: dict[str, str]):
62
+ if isinstance(value, str):
63
+ for old, new in mapping.items():
64
+ if value == old or value.startswith(old + "/"):
65
+ return new + value[len(old):]
66
+ return value
67
+ if isinstance(value, list):
68
+ return [_rewrite(item, mapping) for item in value]
69
+ if isinstance(value, dict):
70
+ return {key: _rewrite(item, mapping) for key, item in value.items()}
71
+ return value
72
+
73
+
74
+ def _safe_path(root: Path, relative: str) -> Path:
75
+ path = root / relative
76
+ if not relative or Path(relative).is_absolute() or ".." in Path(relative).parts:
77
+ raise ValueError(f"Unsafe relocation path: {relative}")
78
+ # The tasks root may itself be shared; descendants must never be symlinks.
79
+ allowed = get_tasks_dir(root) if Path(relative).parts[:2] == (".trellis", "tasks") else root / ".trellis"
80
+ path.resolve().relative_to(allowed.resolve())
81
+ current = root / ".trellis"
82
+ for part in Path(relative).parts[1:]:
83
+ current /= part
84
+ if current != get_tasks_dir(root) and current.is_symlink():
85
+ raise ValueError(f"Symlink in relocation input: {current}")
86
+ return path
87
+
88
+
89
+ def _plan(root: Path, moves: list[tuple[Path, Path]], updates: dict[Path, dict]) -> dict:
90
+ from .active_task import resolve_active_task
91
+ from .task_continuity import _load_owner, _ownership_path
92
+
93
+ mapping = {old.relative_to(root).as_posix(): new.relative_to(root).as_posix() for old, new in moves if old != new}
94
+ rewrites = {**mapping, **{str(root / old): str(root / new) for old, new in mapping.items()}}
95
+ destinations = list(mapping.values())
96
+ if len(set(destinations)) != len(destinations):
97
+ raise ValueError("Two tasks have the same destination; nothing moved")
98
+ for old, new in mapping.items():
99
+ source, target = _safe_path(root, old), _safe_path(root, new)
100
+ if not source.is_dir() or target.exists():
101
+ raise ValueError(f"Missing source or destination conflict: {old} -> {new}")
102
+ for path in source.rglob("*"):
103
+ if path.is_symlink():
104
+ raise ValueError(f"Symlink in task content: {path}")
105
+ if path.is_file():
106
+ path.read_bytes()
107
+ elif not path.is_dir():
108
+ raise ValueError(f"Unsupported task content: {path}")
109
+ data = _read(source / "task.json")
110
+ meta = data.get("meta")
111
+ delegated = meta.get("delegated_worktree_execution_record") if isinstance(meta, dict) else None
112
+ if delegated is not None:
113
+ from .task_workflow import _assert_delegated_record_closed
114
+ _assert_delegated_record_closed(source.name, delegated)
115
+ owner = _load_owner(_ownership_path(root, old))
116
+ if owner:
117
+ active = resolve_active_task(root)
118
+ if owner["task_id"] != (data.get("id") or data.get("name")) or owner["task_path"] != old or owner["status"] != "consumed" or active.context_key != owner["target_session_id"] or active.task_path != old:
119
+ raise ValueError(f"Task ownership prevents relocation: {old}")
120
+
121
+ before: dict[str, str | None] = {}
122
+ after: dict[str, str | None] = {}
123
+
124
+ def change(path: Path, text: str | None):
125
+ relative = path.relative_to(root).as_posix()
126
+ _safe_path(root, relative)
127
+ original = path.read_text(encoding="utf-8") if path.exists() else None
128
+ if original != text:
129
+ before[relative], after[relative] = original, text
130
+
131
+ directories = iter_task_dirs(get_tasks_dir(root), legacy=True) if mapping else iter(updates)
132
+ for directory in directories:
133
+ data = updates.get(directory, _read(directory / "task.json"))
134
+ replacement = _rewrite(data, rewrites)
135
+ if directory in updates or replacement != data:
136
+ change(directory / "task.json", _json(replacement))
137
+ for path in sorted(directory.glob("*.jsonl")):
138
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
139
+ changed = False
140
+ for index, line in enumerate(lines):
141
+ if not line.strip():
142
+ continue
143
+ record = json.loads(line)
144
+ updated = _rewrite(record, rewrites)
145
+ if updated != record:
146
+ lines[index] = json.dumps(updated, ensure_ascii=False) + "\n"
147
+ changed = True
148
+ if changed:
149
+ change(path, "".join(lines))
150
+
151
+ runtime = root / ".trellis/.runtime"
152
+ terminal = {directory.relative_to(root).as_posix() for directory, data in updates.items()
153
+ if data.get("status") in {"completed", "cancelled"}}
154
+ terminal_hashes = {hashlib.sha256(ref.encode()).hexdigest() for ref in terminal}
155
+ terminal_refs = terminal | {str(root / ref) for ref in terminal}
156
+ for folder in ("sessions", "continuity", "task-ownership"):
157
+ for path in sorted((runtime / folder).glob("*.json")):
158
+ record = _read(path)
159
+ if (folder == "sessions" and record.get("current_task") in terminal_refs) or (
160
+ folder != "sessions" and path.stem in terminal_hashes
161
+ ):
162
+ change(path, None)
163
+ continue
164
+ updated = _rewrite(record, rewrites)
165
+ target = path
166
+ if folder != "sessions":
167
+ for old, new in mapping.items():
168
+ if path.stem == hashlib.sha256(old.encode()).hexdigest():
169
+ target = path.with_name(hashlib.sha256(new.encode()).hexdigest() + ".json")
170
+ if target.exists():
171
+ raise ValueError(f"Runtime destination conflict: {target}")
172
+ break
173
+ if target != path:
174
+ change(path, None)
175
+ change(target, _json(updated))
176
+ elif updated != record:
177
+ change(path, _json(updated))
178
+ return {"schema_version": 1, "state": "pending", "moves": mapping, "before": before, "after": after}
179
+
180
+
181
+ def _write(path: Path, text: str | None) -> None:
182
+ if text is None:
183
+ path.unlink(missing_ok=True)
184
+ else:
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+ if not write_text_atomic(path, text):
187
+ raise OSError(f"Cannot write {path}")
188
+
189
+
190
+ def recover_layout(root: Path) -> None:
191
+ path = _safe_path(root, journal_path(root).relative_to(root).as_posix())
192
+ if not path.exists():
193
+ return
194
+ journal = _read(path)
195
+ if journal.get("schema_version") != 1 or journal.get("state") not in {"pending", "committed"}:
196
+ raise ValueError("Invalid task relocation recovery record")
197
+ if not all(isinstance(journal.get(key), dict) for key in ("moves", "before", "after")) or set(journal["before"]) != set(journal["after"]):
198
+ raise ValueError("Invalid task relocation recovery inputs")
199
+ def task_path(relative):
200
+ if not isinstance(relative, str):
201
+ raise ValueError("Invalid recovery task path")
202
+ candidate = _safe_path(root, relative)
203
+ parts = candidate.relative_to(get_tasks_dir(root)).parts
204
+ if not (len(parts) == 1 or task_status_dir(candidate, get_tasks_dir(root)) or (
205
+ len(parts) == 3 and parts[0] == "archive" and MONTH_RE.fullmatch(parts[1])
206
+ )):
207
+ raise ValueError(f"Invalid recovery task directory: {relative}")
208
+ task_location(get_tasks_dir(root), candidate.name, "planning")
209
+ return candidate
210
+
211
+ if not all(isinstance(old, str) and isinstance(new, str) for old, new in journal["moves"].items()):
212
+ raise ValueError("Invalid recovery moves")
213
+ sources, targets = set(journal["moves"]), set(journal["moves"].values())
214
+ if len(targets) != len(journal["moves"]) or sources & targets:
215
+ raise ValueError("Conflicting recovery moves")
216
+ for old, new in journal["moves"].items():
217
+ task_path(old)
218
+ if not task_status_dir(task_path(new), get_tasks_dir(root)):
219
+ raise ValueError(f"Invalid recovery destination: {new}")
220
+ for relative, original in journal["before"].items():
221
+ if not isinstance(relative, str) or any(value is not None and not isinstance(value, str)
222
+ for value in (original, journal["after"][relative])):
223
+ raise ValueError("Invalid recovery file input")
224
+ candidate = _safe_path(root, relative)
225
+ parts = Path(relative).parts
226
+ if parts[:2] == (".trellis", "tasks"):
227
+ task_path(candidate.parent.relative_to(root).as_posix())
228
+ if candidate.name != "task.json" and candidate.suffix != ".jsonl":
229
+ raise ValueError(f"Invalid recovery task file: {relative}")
230
+ elif not (len(parts) == 4 and parts[:2] == (".trellis", ".runtime")
231
+ and parts[2] in {"sessions", "continuity", "task-ownership"} and candidate.suffix == ".json"):
232
+ raise ValueError(f"Recovery file is outside the task transaction: {relative}")
233
+ if journal["state"] == "pending":
234
+ moved = {}
235
+ for old, new in journal["moves"].items():
236
+ source, target = root / old, root / new
237
+ if source.is_dir() == target.is_dir():
238
+ raise ValueError(f"Recovery conflict: {old} / {new}; preserve both for inspection")
239
+ if target.exists():
240
+ moved[old] = new
241
+ for relative, original in journal["before"].items():
242
+ current_relative = _rewrite(relative, moved)
243
+ if not isinstance(current_relative, str):
244
+ raise ValueError("Invalid recovery file path")
245
+ current_path = root / current_relative
246
+ current = current_path.read_text(encoding="utf-8") if current_path.exists() else None
247
+ if current not in (original, journal["after"][relative]):
248
+ raise ValueError(f"Recovery input changed: {relative}; no overwrite")
249
+ for old, new in reversed(list(moved.items())):
250
+ source = root / old
251
+ source.parent.mkdir(parents=True, exist_ok=True)
252
+ (root / new).rename(source)
253
+ for relative, original in journal["before"].items():
254
+ _write(root / relative, original)
255
+ path.unlink()
256
+
257
+
258
+ def apply_plan(root: Path, plan: dict) -> None:
259
+ global _APPLYING
260
+ require_settled_layout(root)
261
+ if not plan["moves"] and not plan["before"]:
262
+ return
263
+ transaction = _safe_path(root, journal_path(root).relative_to(root).as_posix())
264
+ transaction.parent.mkdir(parents=True, exist_ok=True)
265
+ if not write_json(transaction, plan):
266
+ raise OSError("Cannot save task relocation recovery record")
267
+ _APPLYING = True
268
+ try:
269
+ for relative, text in plan["after"].items():
270
+ _write(root / relative, text)
271
+ for old, new in plan["moves"].items():
272
+ target = root / new
273
+ target.parent.mkdir(parents=True, exist_ok=True)
274
+ (root / old).rename(target)
275
+ plan["state"] = "committed"
276
+ if not write_json(journal_path(root), plan):
277
+ raise OSError("Cannot commit task relocation")
278
+ journal_path(root).unlink()
279
+ except BaseException:
280
+ recover_layout(root)
281
+ raise
282
+ finally:
283
+ _APPLYING = False
284
+ archive = get_tasks_dir(root) / "archive"
285
+ if archive.is_dir():
286
+ for month in sorted(archive.iterdir()):
287
+ if month.is_dir() and not any(month.iterdir()):
288
+ month.rmdir()
289
+ if not any(archive.iterdir()):
290
+ archive.rmdir()
291
+
292
+
293
+ def relocate_task(root: Path, directory: Path, data: dict) -> Path:
294
+ """Change lifecycle status and location together; do not grant workflow authorization."""
295
+ destination = task_location(get_tasks_dir(root), directory.name, data["status"])
296
+ if data["status"] == "completed" and task_status_dir(directory, get_tasks_dir(root)) == "completed":
297
+ destination = directory
298
+ with layout_lock(root):
299
+ require_settled_layout(root)
300
+ plan = _plan(root, [(directory, destination)], {directory: data})
301
+ apply_plan(root, plan)
302
+ return destination
303
+
304
+
305
+ def cmd_migrate_layout(args: argparse.Namespace) -> int:
306
+ root = get_repo_root()
307
+ try:
308
+ with layout_lock(root):
309
+ if args.recover:
310
+ recover_layout(root)
311
+ print(json.dumps({"status": "recovered"}))
312
+ return 0
313
+ require_settled_layout(root)
314
+ tasks = get_tasks_dir(root)
315
+ moves: list[tuple[Path, Path]] = []
316
+ names: set[str] = set()
317
+ for directory in iter_task_dirs(tasks, legacy=True):
318
+ data = _read(directory / "task.json")
319
+ if directory.name in names:
320
+ raise ValueError(f"Duplicate task directory name: {directory.name}")
321
+ names.add(directory.name)
322
+ status = data.get("status")
323
+ if not isinstance(status, str):
324
+ raise ValueError(f"Task status is missing: {directory}")
325
+ month = None
326
+ if status == "completed":
327
+ relative = directory.relative_to(tasks).parts
328
+ if len(relative) == 3 and relative[0] in {"archive", "completed"} and MONTH_RE.fullmatch(relative[1]):
329
+ month = relative[1]
330
+ elif isinstance(data.get("completedAt"), str) and MONTH_RE.fullmatch(data["completedAt"][:7]):
331
+ month = data["completedAt"][:7]
332
+ else:
333
+ raise ValueError(f"Completion month is missing: {directory}")
334
+ target = task_location(tasks, directory.name, status, month)
335
+ if directory != target:
336
+ moves.append((directory, target))
337
+ plan = _plan(root, moves, {})
338
+ result = {"status": "applied" if args.apply else "preview", "moves": plan["moves"], "changed_files": sorted(plan["before"])}
339
+ if args.apply:
340
+ apply_plan(root, plan)
341
+ print(json.dumps(result, ensure_ascii=False))
342
+ return 0
343
+ except (ValueError, OSError, RuntimeError) as error:
344
+ print(f"TASK_LAYOUT_WITHHELD: {error}", file=sys.stderr)
345
+ return 1
@@ -17,6 +17,7 @@ Provides:
17
17
  from __future__ import annotations
18
18
 
19
19
  import argparse
20
+ import json
20
21
  import re
21
22
  import sys
22
23
  from dataclasses import dataclass, field
@@ -54,6 +55,15 @@ from .task_utils import (
54
55
  )
55
56
  from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
56
57
 
58
+ RESERVED_META_KEYS = {
59
+ "task_workflow",
60
+ "delegation_authorizations",
61
+ "delegated_worktree_execution_history",
62
+ "delegated_worktree_execution_record",
63
+ "task_cancellation",
64
+ "task_lifecycle_recoveries",
65
+ }
66
+
57
67
 
58
68
  # =============================================================================
59
69
  # Helper Functions
@@ -247,6 +257,9 @@ def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None:
247
257
  file=sys.stderr,
248
258
  )
249
259
  return None
260
+ if key in RESERVED_META_KEYS:
261
+ print(colored(f"Error: metadata key is managed by Trellis: {key}", Colors.RED), file=sys.stderr)
262
+ return None
250
263
  meta[key] = value
251
264
  return meta
252
265
 
@@ -443,8 +456,14 @@ def cmd_create(args: argparse.Namespace) -> int:
443
456
  return 1
444
457
 
445
458
  dir_name = f"{date_prefix}-{slug}"
446
- task_dir = tasks_dir / dir_name
459
+ from .task_layout import task_location
460
+ task_dir = task_location(tasks_dir, dir_name, "planning")
447
461
  task_json_path = task_dir / FILE_TASK_JSON
462
+ from .task_layout import iter_task_dirs
463
+ conflicts = [directory for directory in iter_task_dirs(tasks_dir) if directory.name == dir_name and directory != task_dir]
464
+ if conflicts:
465
+ print(f"Error: Task already exists outside planning: {dir_name}; use its lifecycle commands", file=sys.stderr)
466
+ return 1
448
467
 
449
468
  # Reusing a slug on the same day is an ordinary accident, and continuing
450
469
  # would rewrite the existing task.json below — resetting status, children,
@@ -691,7 +710,7 @@ def cmd_create(args: argparse.Namespace) -> int:
691
710
  print("", file=sys.stderr)
692
711
 
693
712
  # Output relative path for script chaining
694
- print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
713
+ print(task_dir.relative_to(repo_root).as_posix())
695
714
 
696
715
  run_task_hooks("after_create", task_json_path, repo_root)
697
716
  return 0
@@ -841,7 +860,8 @@ def _plan_backrefs(
841
860
  changes: list[tuple[Path, dict, list[str]]] = []
842
861
  unreadable: list[Path] = []
843
862
 
844
- for candidate in sorted(tasks_dir.iterdir()):
863
+ from .task_layout import iter_task_dirs
864
+ for candidate in iter_task_dirs(tasks_dir):
845
865
  if not candidate.is_dir():
846
866
  continue
847
867
  if candidate == task_dir:
@@ -964,62 +984,27 @@ def _rename_interrupted(plan: _RenamePlan) -> None:
964
984
 
965
985
 
966
986
  def _apply_rename(plan: _RenamePlan, repo_root: Path) -> int:
967
- """Write the planned change set.
987
+ """Apply rename through the same recoverable transaction as status moves."""
988
+ from .task_move import _plan, apply_plan
968
989
 
969
- The directory move goes last on purpose. Everything before it is an
970
- in-place edit that re-running the identical command recomputes as
971
- already-done, so an interruption leaves a tree that the same command
972
- finishes; a move-first ordering would instead leave the old name
973
- unresolvable and the remaining edits to be made by hand.
974
- """
975
990
  if not _task_mutation_allowed(repo_root, plan.task_dir, plan.task_data):
976
991
  return 1
977
992
  if plan.identity and not _task_identity_change_allowed(repo_root, plan.task_dir, plan.task_data):
978
993
  return 1
979
- if plan.identity:
980
- data = dict(plan.task_data)
981
- for field_name, _old_value, new_value in plan.identity:
982
- data[field_name] = new_value
983
- if not write_json(plan.task_json_path, data):
984
- _report_write_failure(plan.task_json_path)
985
- print("Nothing was renamed.", file=sys.stderr)
986
- return 1
987
-
988
- for jsonl_path, _linenos, text in plan.jsonl:
989
- try:
990
- jsonl_path.write_text(text, encoding="utf-8")
991
- except OSError as exc:
992
- print(
993
- colored(f"Error: Failed to write {jsonl_path}: {exc}", Colors.RED),
994
- file=sys.stderr,
995
- )
996
- _rename_interrupted(plan)
997
- return 1
998
-
999
- for json_path, data, _labels in plan.backrefs:
1000
- if not write_json(json_path, data):
1001
- _report_write_failure(json_path)
1002
- _rename_interrupted(plan)
1003
- return 1
1004
-
994
+ if plan.unreadable:
995
+ print("Error: unreadable task back-references; nothing renamed", file=sys.stderr)
996
+ return 1
997
+ data = dict(plan.task_data)
998
+ for field_name, _old_value, new_value in plan.identity:
999
+ data[field_name] = new_value
1000
+ updates = {plan.task_dir: data}
1001
+ updates.update({path.parent: value for path, value, _labels in plan.backrefs})
1005
1002
  try:
1006
- plan.task_dir.rename(plan.new_dir)
1007
- except OSError as exc:
1008
- print(
1009
- colored(f"Error: Failed to move {plan.old_rel} -> {plan.new_rel}: {exc}", Colors.RED),
1010
- file=sys.stderr,
1011
- )
1012
- _rename_interrupted(plan)
1003
+ transaction = _plan(repo_root, [(plan.task_dir, plan.new_dir)], updates)
1004
+ apply_plan(repo_root, transaction)
1005
+ except (OSError, ValueError) as error:
1006
+ print(f"Error: rename withheld: {error}", file=sys.stderr)
1013
1007
  return 1
1014
-
1015
- # Last, and after the move: a session pointing at the old path would now
1016
- # resolve to a missing directory, so `current` would report the task stale
1017
- # and the context hook would inject nothing until the user ran `start`
1018
- # again. Rename moves the pointers because the task is still being worked on.
1019
- from .active_task import repoint_task_in_sessions
1020
-
1021
- repoint_task_in_sessions(str(plan.task_dir), str(plan.new_dir), repo_root)
1022
-
1023
1008
  return 0
1024
1009
 
1025
1010
 
@@ -1034,12 +1019,12 @@ def cmd_rename(args: argparse.Namespace) -> int:
1034
1019
  print(colored(f"Error: Task not found: {args.name}", Colors.RED), file=sys.stderr)
1035
1020
  return 1
1036
1021
 
1037
- # Restrict renames to task records directly under the tasks directory.
1022
+ # Restrict renames to the prescribed status directory levels.
1038
1023
  if not is_within_tasks_dir(task_dir, repo_root):
1039
1024
  print(
1040
1025
  colored(
1041
1026
  f"Error: refusing to rename '{args.name}': "
1042
- f"{task_dir} is not a task directly under {tasks_dir}",
1027
+ f"{task_dir} is not a task in the status layout under {tasks_dir}",
1043
1028
  Colors.RED,
1044
1029
  ),
1045
1030
  file=sys.stderr,
@@ -1062,7 +1047,8 @@ def cmd_rename(args: argparse.Namespace) -> int:
1062
1047
  return 1
1063
1048
 
1064
1049
  new_dir = task_dir.parent / new_name
1065
- if new_dir.exists():
1050
+ from .task_layout import iter_task_dirs
1051
+ if new_dir.exists() or any(directory.name == new_name for directory in iter_task_dirs(tasks_dir)):
1066
1052
  print(
1067
1053
  colored(f"Error: Task already exists: {new_name}", Colors.RED),
1068
1054
  file=sys.stderr,
@@ -1271,6 +1257,10 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
1271
1257
  print("Error: A cancelled parent can unlink only a child recorded as independently continuing in its accepted closeout.", file=sys.stderr)
1272
1258
  return 1
1273
1259
 
1260
+ if parent_data.get("status") != "cancelled":
1261
+ from .task_workflow import _assert_delegated_record_closed
1262
+ _assert_delegated_record_closed(child_dir.name, child_data.get("meta", {}).get("delegated_worktree_execution_record"))
1263
+ original_parent = json.loads(json.dumps(parent_data))
1274
1264
  # Remove child from parent's children list
1275
1265
  parent_children = _ensure_children_list(parent_data)
1276
1266
  child_dir_name = child_dir.name
@@ -1290,8 +1280,8 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
1290
1280
  if not write_json(child_json_path, child_data):
1291
1281
  print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr)
1292
1282
  print(
1293
- f"Unlink is half-written: {parent_json_path} no longer lists "
1294
- f"'{child_dir.name}', but the child still records the parent.",
1283
+ "Parent link restored." if write_json(parent_json_path, original_parent) else
1284
+ f"Recovery required: could not restore the parent link in {parent_json_path}.",
1295
1285
  file=sys.stderr,
1296
1286
  )
1297
1287
  return 1
@@ -1449,6 +1439,10 @@ def cmd_set_meta(args: argparse.Namespace) -> int:
1449
1439
  key = args.key
1450
1440
  value = args.value
1451
1441
 
1442
+ if key in RESERVED_META_KEYS:
1443
+ print(colored(f"Error: metadata key is managed by Trellis: {key}", Colors.RED), file=sys.stderr)
1444
+ return 1
1445
+
1452
1446
  if not key:
1453
1447
  print(colored("Error: Missing arguments", Colors.RED))
1454
1448
  print("Usage: python3 task.py set-meta <task-dir> <key> <value>")