@pennixrv/trellis 0.7.1 → 0.7.2-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/README.md +10 -7
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +6 -4
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/ablate.d.ts.map +1 -1
  6. package/dist/commands/ablate.js +2 -0
  7. package/dist/commands/ablate.js.map +1 -1
  8. package/dist/commands/backup.d.ts +4 -0
  9. package/dist/commands/backup.d.ts.map +1 -0
  10. package/dist/commands/backup.js +75 -0
  11. package/dist/commands/backup.js.map +1 -0
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +705 -681
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/mem.d.ts.map +1 -1
  16. package/dist/commands/mem.js +49 -7
  17. package/dist/commands/mem.js.map +1 -1
  18. package/dist/commands/task-progress.d.ts +1 -0
  19. package/dist/commands/task-progress.d.ts.map +1 -1
  20. package/dist/commands/task-progress.js +67 -25
  21. package/dist/commands/task-progress.js.map +1 -1
  22. package/dist/commands/teamwork/adapters/acp.d.ts +20 -0
  23. package/dist/commands/teamwork/adapters/acp.d.ts.map +1 -0
  24. package/dist/commands/teamwork/adapters/acp.js +268 -0
  25. package/dist/commands/teamwork/adapters/acp.js.map +1 -0
  26. package/dist/commands/teamwork/adapters/claude.d.ts +1 -0
  27. package/dist/commands/teamwork/adapters/claude.d.ts.map +1 -1
  28. package/dist/commands/teamwork/adapters/claude.js +5 -0
  29. package/dist/commands/teamwork/adapters/claude.js.map +1 -1
  30. package/dist/commands/teamwork/adapters/codex.d.ts +3 -2
  31. package/dist/commands/teamwork/adapters/codex.d.ts.map +1 -1
  32. package/dist/commands/teamwork/adapters/codex.js +14 -15
  33. package/dist/commands/teamwork/adapters/codex.js.map +1 -1
  34. package/dist/commands/teamwork/adapters/index.d.ts +23 -1
  35. package/dist/commands/teamwork/adapters/index.d.ts.map +1 -1
  36. package/dist/commands/teamwork/adapters/index.js +163 -24
  37. package/dist/commands/teamwork/adapters/index.js.map +1 -1
  38. package/dist/commands/teamwork/agent-loader.d.ts +2 -1
  39. package/dist/commands/teamwork/agent-loader.d.ts.map +1 -1
  40. package/dist/commands/teamwork/agent-loader.js +2 -1
  41. package/dist/commands/teamwork/agent-loader.js.map +1 -1
  42. package/dist/commands/teamwork/artifact.d.ts +33 -10
  43. package/dist/commands/teamwork/artifact.d.ts.map +1 -1
  44. package/dist/commands/teamwork/artifact.js +182 -50
  45. package/dist/commands/teamwork/artifact.js.map +1 -1
  46. package/dist/commands/teamwork/batch-reservation.d.ts +16 -0
  47. package/dist/commands/teamwork/batch-reservation.d.ts.map +1 -0
  48. package/dist/commands/teamwork/batch-reservation.js +43 -0
  49. package/dist/commands/teamwork/batch-reservation.js.map +1 -0
  50. package/dist/commands/teamwork/batch.d.ts +22 -0
  51. package/dist/commands/teamwork/batch.d.ts.map +1 -0
  52. package/dist/commands/teamwork/batch.js +394 -0
  53. package/dist/commands/teamwork/batch.js.map +1 -0
  54. package/dist/commands/teamwork/context-loader.d.ts +1 -1
  55. package/dist/commands/teamwork/context-loader.d.ts.map +1 -1
  56. package/dist/commands/teamwork/context-loader.js +19 -6
  57. package/dist/commands/teamwork/context-loader.js.map +1 -1
  58. package/dist/commands/teamwork/coordination.d.ts +88 -0
  59. package/dist/commands/teamwork/coordination.d.ts.map +1 -0
  60. package/dist/commands/teamwork/coordination.js +153 -0
  61. package/dist/commands/teamwork/coordination.js.map +1 -0
  62. package/dist/commands/teamwork/coordinator.d.ts +17 -3
  63. package/dist/commands/teamwork/coordinator.d.ts.map +1 -1
  64. package/dist/commands/teamwork/coordinator.js +772 -289
  65. package/dist/commands/teamwork/coordinator.js.map +1 -1
  66. package/dist/commands/teamwork/create.d.ts +1 -0
  67. package/dist/commands/teamwork/create.d.ts.map +1 -1
  68. package/dist/commands/teamwork/create.js +2 -1
  69. package/dist/commands/teamwork/create.js.map +1 -1
  70. package/dist/commands/teamwork/dev-parse-trace.d.ts +2 -1
  71. package/dist/commands/teamwork/dev-parse-trace.d.ts.map +1 -1
  72. package/dist/commands/teamwork/dev-parse-trace.js +2 -0
  73. package/dist/commands/teamwork/dev-parse-trace.js.map +1 -1
  74. package/dist/commands/teamwork/foreground-wait.d.ts +6 -0
  75. package/dist/commands/teamwork/foreground-wait.d.ts.map +1 -0
  76. package/dist/commands/teamwork/foreground-wait.js +65 -0
  77. package/dist/commands/teamwork/foreground-wait.js.map +1 -0
  78. package/dist/commands/teamwork/guard.d.ts +2 -2
  79. package/dist/commands/teamwork/guard.d.ts.map +1 -1
  80. package/dist/commands/teamwork/guard.js +23 -5
  81. package/dist/commands/teamwork/guard.js.map +1 -1
  82. package/dist/commands/teamwork/index.d.ts.map +1 -1
  83. package/dist/commands/teamwork/index.js +139 -10
  84. package/dist/commands/teamwork/index.js.map +1 -1
  85. package/dist/commands/teamwork/kill.d.ts +2 -0
  86. package/dist/commands/teamwork/kill.d.ts.map +1 -1
  87. package/dist/commands/teamwork/kill.js +79 -38
  88. package/dist/commands/teamwork/kill.js.map +1 -1
  89. package/dist/commands/teamwork/messages.d.ts.map +1 -1
  90. package/dist/commands/teamwork/messages.js +3 -0
  91. package/dist/commands/teamwork/messages.js.map +1 -1
  92. package/dist/commands/teamwork/plan.d.ts +22 -0
  93. package/dist/commands/teamwork/plan.d.ts.map +1 -0
  94. package/dist/commands/teamwork/plan.js +288 -0
  95. package/dist/commands/teamwork/plan.js.map +1 -0
  96. package/dist/commands/teamwork/process-identity.d.ts +3 -0
  97. package/dist/commands/teamwork/process-identity.d.ts.map +1 -0
  98. package/dist/commands/teamwork/process-identity.js +24 -0
  99. package/dist/commands/teamwork/process-identity.js.map +1 -0
  100. package/dist/commands/teamwork/profiles.d.ts +43 -6
  101. package/dist/commands/teamwork/profiles.d.ts.map +1 -1
  102. package/dist/commands/teamwork/profiles.js +138 -110
  103. package/dist/commands/teamwork/profiles.js.map +1 -1
  104. package/dist/commands/teamwork/prompt.d.ts +4 -0
  105. package/dist/commands/teamwork/prompt.d.ts.map +1 -0
  106. package/dist/commands/teamwork/prompt.js +33 -0
  107. package/dist/commands/teamwork/prompt.js.map +1 -0
  108. package/dist/commands/teamwork/result.d.ts +54 -0
  109. package/dist/commands/teamwork/result.d.ts.map +1 -0
  110. package/dist/commands/teamwork/result.js +94 -0
  111. package/dist/commands/teamwork/result.js.map +1 -0
  112. package/dist/commands/teamwork/rm.d.ts.map +1 -1
  113. package/dist/commands/teamwork/rm.js +64 -64
  114. package/dist/commands/teamwork/rm.js.map +1 -1
  115. package/dist/commands/teamwork/run.d.ts +5 -0
  116. package/dist/commands/teamwork/run.d.ts.map +1 -1
  117. package/dist/commands/teamwork/run.js +120 -24
  118. package/dist/commands/teamwork/run.js.map +1 -1
  119. package/dist/commands/teamwork/runtime.d.ts +9 -0
  120. package/dist/commands/teamwork/runtime.d.ts.map +1 -0
  121. package/dist/commands/teamwork/runtime.js +372 -0
  122. package/dist/commands/teamwork/runtime.js.map +1 -0
  123. package/dist/commands/teamwork/send.d.ts +1 -0
  124. package/dist/commands/teamwork/send.d.ts.map +1 -1
  125. package/dist/commands/teamwork/send.js +2 -1
  126. package/dist/commands/teamwork/send.js.map +1 -1
  127. package/dist/commands/teamwork/spawn.d.ts +10 -3
  128. package/dist/commands/teamwork/spawn.d.ts.map +1 -1
  129. package/dist/commands/teamwork/spawn.js +42 -27
  130. package/dist/commands/teamwork/spawn.js.map +1 -1
  131. package/dist/commands/teamwork/store/lock.js +28 -4
  132. package/dist/commands/teamwork/store/lock.js.map +1 -1
  133. package/dist/commands/teamwork/supervisor/inbox.d.ts +1 -0
  134. package/dist/commands/teamwork/supervisor/inbox.d.ts.map +1 -1
  135. package/dist/commands/teamwork/supervisor/inbox.js +28 -9
  136. package/dist/commands/teamwork/supervisor/inbox.js.map +1 -1
  137. package/dist/commands/teamwork/supervisor/stdout.d.ts.map +1 -1
  138. package/dist/commands/teamwork/supervisor/stdout.js +2 -3
  139. package/dist/commands/teamwork/supervisor/stdout.js.map +1 -1
  140. package/dist/commands/teamwork/supervisor/warning.d.ts.map +1 -1
  141. package/dist/commands/teamwork/supervisor/warning.js +2 -3
  142. package/dist/commands/teamwork/supervisor/warning.js.map +1 -1
  143. package/dist/commands/teamwork/supervisor.d.ts +5 -0
  144. package/dist/commands/teamwork/supervisor.d.ts.map +1 -1
  145. package/dist/commands/teamwork/supervisor.js +21 -12
  146. package/dist/commands/teamwork/supervisor.js.map +1 -1
  147. package/dist/commands/teamwork/teammate-adapters.d.ts +0 -5
  148. package/dist/commands/teamwork/teammate-adapters.d.ts.map +1 -1
  149. package/dist/commands/teamwork/teammate-adapters.js +9 -56
  150. package/dist/commands/teamwork/teammate-adapters.js.map +1 -1
  151. package/dist/commands/teamwork/teammate-config.d.ts +5 -1
  152. package/dist/commands/teamwork/teammate-config.d.ts.map +1 -1
  153. package/dist/commands/teamwork/teammate-config.js +62 -19
  154. package/dist/commands/teamwork/teammate-config.js.map +1 -1
  155. package/dist/commands/teamwork/teammate-launch.d.ts +15 -6
  156. package/dist/commands/teamwork/teammate-launch.d.ts.map +1 -1
  157. package/dist/commands/teamwork/teammate-launch.js +129 -22
  158. package/dist/commands/teamwork/teammate-launch.js.map +1 -1
  159. package/dist/commands/teamwork/wait.d.ts +2 -0
  160. package/dist/commands/teamwork/wait.d.ts.map +1 -1
  161. package/dist/commands/teamwork/wait.js +44 -3
  162. package/dist/commands/teamwork/wait.js.map +1 -1
  163. package/dist/commands/uninstall.d.ts.map +1 -1
  164. package/dist/commands/uninstall.js +92 -84
  165. package/dist/commands/uninstall.js.map +1 -1
  166. package/dist/commands/update.d.ts.map +1 -1
  167. package/dist/commands/update.js +635 -592
  168. package/dist/commands/update.js.map +1 -1
  169. package/dist/commands/workflow.d.ts.map +1 -1
  170. package/dist/commands/workflow.js +94 -89
  171. package/dist/commands/workflow.js.map +1 -1
  172. package/dist/configurators/shared.js +2 -2
  173. package/dist/configurators/shared.js.map +1 -1
  174. package/dist/configurators/workflow.d.ts.map +1 -1
  175. package/dist/configurators/workflow.js +14 -32
  176. package/dist/configurators/workflow.js.map +1 -1
  177. package/dist/constants/paths.d.ts +3 -10
  178. package/dist/constants/paths.d.ts.map +1 -1
  179. package/dist/constants/paths.js +4 -13
  180. package/dist/constants/paths.js.map +1 -1
  181. package/dist/migrations/manifests/0.7.1-beta.0.json +9 -0
  182. package/dist/migrations/manifests/0.7.1-beta.1.json +186 -0
  183. package/dist/migrations/manifests/0.7.1-beta.10.json +9 -0
  184. package/dist/migrations/manifests/0.7.1-beta.11.json +9 -0
  185. package/dist/migrations/manifests/0.7.1-beta.12.json +9 -0
  186. package/dist/migrations/manifests/0.7.1-beta.13.json +9 -0
  187. package/dist/migrations/manifests/0.7.1-beta.14.json +11 -0
  188. package/dist/migrations/manifests/0.7.1-beta.15.json +9 -0
  189. package/dist/migrations/manifests/0.7.1-beta.16.json +9 -0
  190. package/dist/migrations/manifests/0.7.1-beta.17.json +9 -0
  191. package/dist/migrations/manifests/0.7.1-beta.18.json +9 -0
  192. package/dist/migrations/manifests/0.7.1-beta.19.json +9 -0
  193. package/dist/migrations/manifests/0.7.1-beta.2.json +522 -0
  194. package/dist/migrations/manifests/0.7.1-beta.3.json +9 -0
  195. package/dist/migrations/manifests/0.7.1-beta.4.json +9 -0
  196. package/dist/migrations/manifests/0.7.1-beta.5.json +9 -0
  197. package/dist/migrations/manifests/0.7.1-beta.6.json +10 -0
  198. package/dist/migrations/manifests/0.7.1-beta.7.json +8 -0
  199. package/dist/migrations/manifests/0.7.1-beta.8.json +481 -0
  200. package/dist/migrations/manifests/0.7.1-beta.9.json +191 -0
  201. package/dist/migrations/manifests/0.7.2-beta.0.json +11 -0
  202. package/dist/templates/claude/hooks/statusline.py +9 -4
  203. package/dist/templates/codex/hooks/session-start.py +14 -11
  204. package/dist/templates/codex/hooks.json +10 -0
  205. package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/workflow.md +2 -0
  206. package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/workflow.md +2 -0
  207. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +1 -1
  208. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +1 -1
  209. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +5 -5
  210. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +3 -2
  211. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
  212. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +1 -1
  213. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-teamwork.md +14 -2
  214. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +8 -2
  215. package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
  216. package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +2 -2
  217. package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/workflow.md +8 -0
  218. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/mcp-setup.md +4 -2
  219. package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-task-planning.md +2 -2
  220. package/dist/templates/common/bundled-skills/trellis-task-implementation/workflow.md +6 -0
  221. package/dist/templates/common/bundled-skills/trellis-teamwork/SKILL.md +18 -3
  222. package/dist/templates/common/bundled-skills/trellis-teamwork/references/coordination-modes.md +34 -3
  223. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-configuration-matrix.md +37 -26
  224. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +128 -14
  225. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +152 -20
  226. package/dist/templates/common/commands/start.md +2 -2
  227. package/dist/templates/common/skills/before-dev.md +1 -4
  228. package/dist/templates/common/skills/break-loop.md +7 -18
  229. package/dist/templates/common/skills/check.md +7 -11
  230. package/dist/templates/common/skills/{brainstorm.md → request-intake.md} +5 -3
  231. package/dist/templates/common/skills/update-spec.md +5 -7
  232. package/dist/templates/copilot/hooks/session-start.py +14 -11
  233. package/dist/templates/copilot/index.d.ts +1 -6
  234. package/dist/templates/copilot/index.d.ts.map +1 -1
  235. package/dist/templates/copilot/index.js +1 -14
  236. package/dist/templates/copilot/index.js.map +1 -1
  237. package/dist/templates/extract.js +4 -0
  238. package/dist/templates/extract.js.map +1 -1
  239. package/dist/templates/markdown/spec/backend/index.md.txt +4 -35
  240. package/dist/templates/markdown/spec/frontend/index.md.txt +4 -36
  241. package/dist/templates/omp/extensions/trellis/index.ts.txt +2 -2
  242. package/dist/templates/opencode/lib/session-utils.js +301 -209
  243. package/dist/templates/pi/extensions/trellis/index.ts.txt +17 -1
  244. package/dist/templates/shared-hooks/guard-teamwork-wait.py +53 -0
  245. package/dist/templates/shared-hooks/index.d.ts +1 -1
  246. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  247. package/dist/templates/shared-hooks/index.js +1 -0
  248. package/dist/templates/shared-hooks/index.js.map +1 -1
  249. package/dist/templates/shared-hooks/inject-workflow-state.py +8 -3
  250. package/dist/templates/shared-hooks/session-start.py +16 -12
  251. package/dist/templates/snow/SNOW.md +1 -1
  252. package/dist/templates/trellis/agents/delegated-worktree-primary-agent.md +9 -0
  253. package/dist/templates/trellis/agents/profiles/architecture.md +9 -0
  254. package/dist/templates/trellis/agents/profiles/diagnosis.md +9 -0
  255. package/dist/templates/trellis/agents/profiles/exploration.md +9 -0
  256. package/dist/templates/trellis/agents/profiles/research.md +9 -0
  257. package/dist/templates/trellis/agents/profiles/review.md +9 -0
  258. package/dist/templates/trellis/agents/profiles/verification.md +9 -0
  259. package/dist/templates/trellis/agents/teammate-profiles.json +56 -4
  260. package/dist/templates/trellis/agents/teammate.md +4 -52
  261. package/dist/templates/trellis/config.yaml +2 -5
  262. package/dist/templates/trellis/index.d.ts +4 -0
  263. package/dist/templates/trellis/index.d.ts.map +1 -1
  264. package/dist/templates/trellis/index.js +22 -4
  265. package/dist/templates/trellis/index.js.map +1 -1
  266. package/dist/templates/trellis/scripts/add_session.py +1 -1
  267. package/dist/templates/trellis/scripts/common/active_task.py +3 -47
  268. package/dist/templates/trellis/scripts/common/paths.py +9 -25
  269. package/dist/templates/trellis/scripts/common/safe_commit.py +8 -8
  270. package/dist/templates/trellis/scripts/common/task_context.py +11 -51
  271. package/dist/templates/trellis/scripts/common/task_continuity.py +129 -60
  272. package/dist/templates/trellis/scripts/common/task_layout.py +80 -0
  273. package/dist/templates/trellis/scripts/common/task_move.py +345 -0
  274. package/dist/templates/trellis/scripts/common/task_store.py +35 -57
  275. package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
  276. package/dist/templates/trellis/scripts/common/task_workflow.py +339 -46
  277. package/dist/templates/trellis/scripts/common/tasks.py +6 -5
  278. package/dist/templates/trellis/scripts/hooks/linear_sync.py +13 -3
  279. package/dist/templates/trellis/scripts/task.py +71 -24
  280. package/dist/templates/trellis/teamwork.json +10 -0
  281. package/dist/templates/trellis/workflow.md +11 -2
  282. package/dist/utils/ablation-store.d.ts +1 -1
  283. package/dist/utils/ablation-store.d.ts.map +1 -1
  284. package/dist/utils/ablation-store.js +58 -24
  285. package/dist/utils/ablation-store.js.map +1 -1
  286. package/dist/utils/asset-backup.d.ts +138 -0
  287. package/dist/utils/asset-backup.d.ts.map +1 -0
  288. package/dist/utils/asset-backup.js +513 -0
  289. package/dist/utils/asset-backup.js.map +1 -0
  290. package/dist/utils/atomic-write.d.ts +1 -1
  291. package/dist/utils/atomic-write.d.ts.map +1 -1
  292. package/dist/utils/atomic-write.js +6 -3
  293. package/dist/utils/atomic-write.js.map +1 -1
  294. package/dist/utils/manifest-prune.d.ts +4 -1
  295. package/dist/utils/manifest-prune.d.ts.map +1 -1
  296. package/dist/utils/manifest-prune.js +17 -6
  297. package/dist/utils/manifest-prune.js.map +1 -1
  298. package/dist/utils/template-fetcher.d.ts +4 -19
  299. package/dist/utils/template-fetcher.d.ts.map +1 -1
  300. package/dist/utils/template-fetcher.js +9 -45
  301. package/dist/utils/template-fetcher.js.map +1 -1
  302. package/dist/utils/template-hash.d.ts +5 -4
  303. package/dist/utils/template-hash.d.ts.map +1 -1
  304. package/dist/utils/template-hash.js +24 -17
  305. package/dist/utils/template-hash.js.map +1 -1
  306. package/package.json +3 -2
  307. package/dist/templates/copilot/prompts/before-dev.prompt.md +0 -39
  308. package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -13
  309. package/dist/templates/copilot/prompts/break-loop.prompt.md +0 -129
  310. package/dist/templates/copilot/prompts/check-cross-layer.prompt.md +0 -170
  311. package/dist/templates/copilot/prompts/check.prompt.md +0 -110
  312. package/dist/templates/copilot/prompts/create-command.prompt.md +0 -116
  313. package/dist/templates/copilot/prompts/finish-work.prompt.md +0 -22
  314. package/dist/templates/copilot/prompts/integrate-skill.prompt.md +0 -223
  315. package/dist/templates/copilot/prompts/onboard.prompt.md +0 -362
  316. package/dist/templates/copilot/prompts/record-session.prompt.md +0 -58
  317. package/dist/templates/copilot/prompts/start.prompt.md +0 -65
  318. package/dist/templates/copilot/prompts/update-spec.prompt.md +0 -358
@@ -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
@@ -443,8 +444,14 @@ def cmd_create(args: argparse.Namespace) -> int:
443
444
  return 1
444
445
 
445
446
  dir_name = f"{date_prefix}-{slug}"
446
- task_dir = tasks_dir / dir_name
447
+ from .task_layout import task_location
448
+ task_dir = task_location(tasks_dir, dir_name, "planning")
447
449
  task_json_path = task_dir / FILE_TASK_JSON
450
+ from .task_layout import iter_task_dirs
451
+ conflicts = [directory for directory in iter_task_dirs(tasks_dir) if directory.name == dir_name and directory != task_dir]
452
+ if conflicts:
453
+ print(f"Error: Task already exists outside planning: {dir_name}; use its lifecycle commands", file=sys.stderr)
454
+ return 1
448
455
 
449
456
  # Reusing a slug on the same day is an ordinary accident, and continuing
450
457
  # would rewrite the existing task.json below — resetting status, children,
@@ -691,7 +698,7 @@ def cmd_create(args: argparse.Namespace) -> int:
691
698
  print("", file=sys.stderr)
692
699
 
693
700
  # Output relative path for script chaining
694
- print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
701
+ print(task_dir.relative_to(repo_root).as_posix())
695
702
 
696
703
  run_task_hooks("after_create", task_json_path, repo_root)
697
704
  return 0
@@ -841,7 +848,8 @@ def _plan_backrefs(
841
848
  changes: list[tuple[Path, dict, list[str]]] = []
842
849
  unreadable: list[Path] = []
843
850
 
844
- for candidate in sorted(tasks_dir.iterdir()):
851
+ from .task_layout import iter_task_dirs
852
+ for candidate in iter_task_dirs(tasks_dir):
845
853
  if not candidate.is_dir():
846
854
  continue
847
855
  if candidate == task_dir:
@@ -964,62 +972,27 @@ def _rename_interrupted(plan: _RenamePlan) -> None:
964
972
 
965
973
 
966
974
  def _apply_rename(plan: _RenamePlan, repo_root: Path) -> int:
967
- """Write the planned change set.
975
+ """Apply rename through the same recoverable transaction as status moves."""
976
+ from .task_move import _plan, apply_plan
968
977
 
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
978
  if not _task_mutation_allowed(repo_root, plan.task_dir, plan.task_data):
976
979
  return 1
977
980
  if plan.identity and not _task_identity_change_allowed(repo_root, plan.task_dir, plan.task_data):
978
981
  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
-
982
+ if plan.unreadable:
983
+ print("Error: unreadable task back-references; nothing renamed", file=sys.stderr)
984
+ return 1
985
+ data = dict(plan.task_data)
986
+ for field_name, _old_value, new_value in plan.identity:
987
+ data[field_name] = new_value
988
+ updates = {plan.task_dir: data}
989
+ updates.update({path.parent: value for path, value, _labels in plan.backrefs})
1005
990
  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)
991
+ transaction = _plan(repo_root, [(plan.task_dir, plan.new_dir)], updates)
992
+ apply_plan(repo_root, transaction)
993
+ except (OSError, ValueError) as error:
994
+ print(f"Error: rename withheld: {error}", file=sys.stderr)
1013
995
  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
996
  return 0
1024
997
 
1025
998
 
@@ -1034,12 +1007,12 @@ def cmd_rename(args: argparse.Namespace) -> int:
1034
1007
  print(colored(f"Error: Task not found: {args.name}", Colors.RED), file=sys.stderr)
1035
1008
  return 1
1036
1009
 
1037
- # Restrict renames to task records directly under the tasks directory.
1010
+ # Restrict renames to the prescribed status directory levels.
1038
1011
  if not is_within_tasks_dir(task_dir, repo_root):
1039
1012
  print(
1040
1013
  colored(
1041
1014
  f"Error: refusing to rename '{args.name}': "
1042
- f"{task_dir} is not a task directly under {tasks_dir}",
1015
+ f"{task_dir} is not a task in the status layout under {tasks_dir}",
1043
1016
  Colors.RED,
1044
1017
  ),
1045
1018
  file=sys.stderr,
@@ -1062,7 +1035,8 @@ def cmd_rename(args: argparse.Namespace) -> int:
1062
1035
  return 1
1063
1036
 
1064
1037
  new_dir = task_dir.parent / new_name
1065
- if new_dir.exists():
1038
+ from .task_layout import iter_task_dirs
1039
+ if new_dir.exists() or any(directory.name == new_name for directory in iter_task_dirs(tasks_dir)):
1066
1040
  print(
1067
1041
  colored(f"Error: Task already exists: {new_name}", Colors.RED),
1068
1042
  file=sys.stderr,
@@ -1271,6 +1245,10 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
1271
1245
  print("Error: A cancelled parent can unlink only a child recorded as independently continuing in its accepted closeout.", file=sys.stderr)
1272
1246
  return 1
1273
1247
 
1248
+ if parent_data.get("status") != "cancelled":
1249
+ from .task_workflow import _assert_delegated_record_closed
1250
+ _assert_delegated_record_closed(child_dir.name, child_data.get("meta", {}).get("delegated_worktree_execution_record"))
1251
+ original_parent = json.loads(json.dumps(parent_data))
1274
1252
  # Remove child from parent's children list
1275
1253
  parent_children = _ensure_children_list(parent_data)
1276
1254
  child_dir_name = child_dir.name
@@ -1290,8 +1268,8 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
1290
1268
  if not write_json(child_json_path, child_data):
1291
1269
  print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr)
1292
1270
  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.",
1271
+ "Parent link restored." if write_json(parent_json_path, original_parent) else
1272
+ f"Recovery required: could not restore the parent link in {parent_json_path}.",
1295
1273
  file=sys.stderr,
1296
1274
  )
1297
1275
  return 1
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
  from typing import TYPE_CHECKING
17
17
 
18
18
  from .paths import get_repo_root, get_tasks_dir
19
+ from .task_layout import iter_task_dirs, task_status_dir
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  import subprocess
@@ -28,20 +29,14 @@ if TYPE_CHECKING:
28
29
  def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool:
29
30
  """Check that a resolved task directory really is a task under the tasks dir.
30
31
 
31
- A real task lives directly at ``.trellis/tasks/<name>``. This returns True
32
- only when ``task_dir_abs`` is an immediate child of the tasks directory.
33
-
34
- The task must be a direct child of the tasks directory, not the directory
35
- itself or a nested path.
32
+ A real task occupies one prescribed status directory, never nested content.
36
33
  """
37
34
  if repo_root is None:
38
35
  repo_root = get_repo_root()
39
36
  try:
40
- resolved = task_dir_abs.resolve()
41
- tasks_resolved = get_tasks_dir(repo_root).resolve()
37
+ return task_status_dir(task_dir_abs, get_tasks_dir(repo_root)) is not None
42
38
  except (OSError, RuntimeError):
43
39
  return False
44
- return resolved.parent == tasks_resolved and resolved.name != "archive"
45
40
 
46
41
 
47
42
  # =============================================================================
@@ -74,22 +69,19 @@ def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
74
69
  print(f"Error: invalid task name: {task_name}", file=sys.stderr)
75
70
  return None
76
71
 
77
- # Try exact match first
78
- exact_match = tasks_dir / task_name
79
- if exact_match.is_dir():
80
- return exact_match
81
-
82
- # Try suffix match (e.g., "my-task" matches "01-21-my-task")
83
- matches = sorted(
84
- d for d in tasks_dir.iterdir()
85
- if d.is_dir() and d.name.endswith(f"-{task_name}")
86
- )
72
+ try:
73
+ candidates = list(iter_task_dirs(tasks_dir))
74
+ except (ValueError, OSError) as error:
75
+ print(f"Error: {error}", file=sys.stderr)
76
+ return None
77
+ exact = [d for d in candidates if d.name == task_name]
78
+ matches = exact or [d for d in candidates if d.name.endswith(f"-{task_name}")]
87
79
  if len(matches) == 1:
88
80
  return matches[0]
89
81
  if matches:
90
82
  print(f"Error: ambiguous task name '{task_name}' matches:", file=sys.stderr)
91
83
  for match in matches:
92
- print(f" - {match.name}", file=sys.stderr)
84
+ print(f" - {match.relative_to(tasks_dir)}", file=sys.stderr)
93
85
  print("Pass the full task directory name.", file=sys.stderr)
94
86
 
95
87
  return None
@@ -128,6 +120,14 @@ def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
128
120
  print("Error: task directory is required", file=sys.stderr)
129
121
  return None
130
122
 
123
+ from .task_move import require_settled_layout
124
+
125
+ try:
126
+ require_settled_layout(repo_root)
127
+ except ValueError as error:
128
+ print(f"Error: {error}", file=sys.stderr)
129
+ return None
130
+
131
131
  normalized = target_dir.replace("\\", "/")
132
132
  while normalized.startswith("./"):
133
133
  normalized = normalized[2:]
@@ -175,6 +175,10 @@ def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
175
175
  )
176
176
  return None
177
177
 
178
+ if not is_within_tasks_dir(candidate, repo_root):
179
+ print(f"Error: invalid task layout: {target_dir}; run task.py migrate-layout for old tasks", file=sys.stderr)
180
+ return None
181
+
178
182
  # Hand back the path spelled through the repo's own tasks dir rather than
179
183
  # `resolved`: `.trellis` may be a symlink into a store outside the repo
180
184
  # (#567), in which case `resolved` sits outside the repo even though the