@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
@@ -201,54 +201,10 @@ def normalize_task_ref(task_ref: str) -> str:
201
201
 
202
202
 
203
203
  def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
204
- """Resolve a task ref to an absolute task directory inside the repo.
204
+ """Use the canonical status-layout resolver shared by all task readers."""
205
+ from .paths import resolve_task_ref as resolve
205
206
 
206
- Mirrors `paths.resolve_task_ref` (same containment check). Duplicated
207
- rather than imported because this module is loaded standalone — hooks add
208
- it to `sys.path` directly — so it stays zero-relative-import on purpose.
209
- """
210
- normalized = normalize_task_ref(task_ref)
211
- if not normalized:
212
- return None
213
-
214
- try:
215
- root = repo_root.resolve()
216
- except OSError:
217
- return None
218
-
219
- path_obj = Path(normalized)
220
- if path_obj.is_absolute():
221
- candidate = path_obj
222
- elif normalized.startswith(f"{DIR_WORKFLOW}/"):
223
- candidate = root / path_obj
224
- else:
225
- candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
226
-
227
- # Both sides are resolved because repo_root itself may sit behind a
228
- # symlink (/tmp on macOS does), and resolve() is what collapses `..`
229
- # instead of leaving it for a lexical relative_to() to wave through.
230
- try:
231
- resolved = candidate.resolve()
232
- workflow_real = (root / DIR_WORKFLOW).resolve()
233
- except OSError:
234
- return None
235
-
236
- try:
237
- resolved.relative_to(root)
238
- return resolved
239
- except ValueError:
240
- pass
241
-
242
- # `.trellis` may itself be a symlink into a store outside the repo (#567).
243
- # The workflow dir's own real location is then a second legitimate
244
- # containment base; a ref that escapes BOTH bases is still refused. Map
245
- # back to the in-repo (lexical) form so callers store a repo-relative ref.
246
- try:
247
- rel = resolved.relative_to(workflow_real)
248
- except ValueError:
249
- return None
250
-
251
- return root / DIR_WORKFLOW / rel
207
+ return resolve(task_ref, repo_root)
252
208
 
253
209
 
254
210
  def _runtime_sessions_dir(repo_root: Path) -> Path:
@@ -301,6 +301,11 @@ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
301
301
  file=sys.stderr,
302
302
  )
303
303
  return []
304
+ if event == "after_complete" and "after_archive" in hooks:
305
+ print(
306
+ "[WARN] ignoring legacy hook `after_archive`; rename it to `after_complete` in config.yaml",
307
+ file=sys.stderr,
308
+ )
304
309
  commands = hooks.get(event)
305
310
  if commands is None:
306
311
  return []
@@ -344,35 +344,19 @@ def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | Non
344
344
  else:
345
345
  candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
346
346
 
347
- # resolve() collapses `..` and follows symlinks, so a task directory that
348
- # links outside the repo is refused too. Both sides are resolved because
349
- # repo_root itself may sit behind a symlink (/tmp on macOS does).
350
- try:
351
- resolved = candidate.resolve()
352
- workflow_real = (root / DIR_WORKFLOW).resolve()
353
- except OSError:
354
- return None
347
+ from .task_layout import task_status_dir
348
+ from .task_move import require_settled_layout
355
349
 
356
350
  try:
357
- resolved.relative_to(root)
358
- return resolved
359
- except ValueError:
360
- pass
361
-
362
- # `.trellis` may itself be a symlink into a store outside the repo (#567).
363
- # The workflow dir's own real location is then a second legitimate
364
- # containment base: a ref through that link never left the workflow tree.
365
- # A ref that escapes BOTH bases (traversal, absolute path elsewhere, a
366
- # task dir symlinked out of the tree) is still refused.
367
- try:
368
- rel = resolved.relative_to(workflow_real)
369
- except ValueError:
351
+ require_settled_layout(root)
352
+ tasks = root / DIR_WORKFLOW / DIR_TASKS
353
+ if task_status_dir(candidate, tasks) is None:
354
+ return None
355
+ relative = candidate.resolve().relative_to(tasks.resolve())
356
+ return tasks / relative
357
+ except (ValueError, OSError, RuntimeError):
370
358
  return None
371
359
 
372
- # Map back to the in-repo (lexical) form so callers store the same
373
- # repo-relative ref as in the non-symlinked layout.
374
- return root / DIR_WORKFLOW / rel
375
-
376
360
 
377
361
  def get_current_task(
378
362
  repo_root: Path | None = None,
@@ -70,7 +70,7 @@ def safe_trellis_paths_to_add(
70
70
  Included:
71
71
  - .trellis/workspace/<developer>/journal-*.md
72
72
  - .trellis/workspace/<developer>/index.md
73
- - .trellis/tasks/<task_name>/ (ONLY the current task dir when
73
+ - .trellis/tasks/<task-dir>/ (ONLY the current task dir when
74
74
  ``task_name`` is passed)
75
75
 
76
76
  Excluded (intentionally — these must not be staged):
@@ -112,16 +112,16 @@ def safe_trellis_paths_to_add(
112
112
  # Narrow scope — ONLY the current task directory.
113
113
  # Never iterdir() all tasks: parallel-window dirty task dirs must not
114
114
  # leak into the session auto-commit.
115
- active_task = tasks_dir / task_name
116
- if active_task.is_dir():
117
- paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}")
115
+ from .task_utils import find_task_by_name
116
+ active_task = find_task_by_name(task_name, tasks_dir)
117
+ if active_task is not None and active_task.is_dir():
118
+ paths.append(active_task.relative_to(repo_root).as_posix())
118
119
  return paths
119
120
 
120
121
  # Wide scope (no task_name): each direct task directory.
121
- for child in sorted(tasks_dir.iterdir()):
122
- if not child.is_dir():
123
- continue
124
- paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
122
+ from .task_layout import iter_task_dirs
123
+ for child in iter_task_dirs(tasks_dir):
124
+ paths.append(child.relative_to(repo_root).as_posix())
125
125
 
126
126
  return paths
127
127
 
@@ -29,7 +29,7 @@ from .config import get_context_injection_limits
29
29
  from .git import branch_exists_locally
30
30
  from .io import read_json
31
31
  from .log import Colors, colored
32
- from .paths import DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
32
+ from .paths import FILE_TASK_JSON, get_repo_root
33
33
  from .task_utils import resolve_task_dir
34
34
 
35
35
  # Extensions that look like code rather than spec/research docs. Entries with
@@ -195,54 +195,6 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
195
195
  return False
196
196
 
197
197
 
198
- def _resolve_context_entry_path(
199
- file_path: str, repo_root: Path, task_dir: Path | None
200
- ) -> Path | None:
201
- """Bind historical self-references to their archive copy without fallback."""
202
- repo_path = repo_root / file_path
203
- if task_dir is None:
204
- return repo_path
205
-
206
- try:
207
- task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
208
- except ValueError:
209
- return repo_path
210
-
211
- archive_prefix = (DIR_WORKFLOW, DIR_TASKS, "archive")
212
- if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
213
- return repo_path
214
-
215
- year_month = task_parts[3]
216
- if (
217
- len(year_month) != 7
218
- or year_month[4] != "-"
219
- or not year_month[:4].isdigit()
220
- or not year_month[5:].isdigit()
221
- ):
222
- return repo_path
223
-
224
- historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
225
- posix_path = file_path.replace("\\", "/")
226
- if posix_path == historical_root:
227
- relative_parts: tuple[str, ...] = ()
228
- elif posix_path.startswith(f"{historical_root}/"):
229
- relative_path = posix_path[len(historical_root) + 1 :]
230
- if relative_path.endswith("/"):
231
- relative_path = relative_path[:-1]
232
- relative_parts = tuple(relative_path.split("/")) if relative_path else ()
233
- if any(part in ("", ".", "..") for part in relative_parts):
234
- return None
235
- else:
236
- return repo_path
237
-
238
- try:
239
- archive_root = task_dir.resolve()
240
- resolved_path = task_dir.joinpath(*relative_parts).resolve()
241
- resolved_path.relative_to(archive_root)
242
- except (OSError, RuntimeError, ValueError):
243
- return None
244
- return resolved_path
245
-
246
198
 
247
199
  def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
248
200
  """Validate a single JSONL file.
@@ -268,7 +220,7 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
268
220
  task_rel = ""
269
221
  if task_dir is not None:
270
222
  try:
271
- task_rel = task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
223
+ task_rel = task_dir.absolute().relative_to(repo_root.absolute()).as_posix()
272
224
  except ValueError:
273
225
  task_rel = ""
274
226
 
@@ -323,7 +275,15 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
323
275
  continue
324
276
 
325
277
  real_entries += 1
326
- full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
278
+ full_path = repo_root / file_path
279
+ normalized = file_path.replace("\\", "/")
280
+ if task_dir is not None and task_rel and (normalized == task_rel or normalized.startswith(task_rel + "/")):
281
+ try:
282
+ if ".." in Path(normalized).parts:
283
+ raise ValueError("Traversal in task reference")
284
+ full_path.resolve().relative_to(task_dir.resolve())
285
+ except (ValueError, OSError, RuntimeError):
286
+ full_path = None
327
287
  if entry_type == "directory":
328
288
  if full_path is None or not full_path.is_dir():
329
289
  print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
@@ -97,6 +97,65 @@ def _task(root: Path, task_path: str | None = None) -> tuple[Path, dict, str, st
97
97
  return directory, data, task_id, active.context_key
98
98
 
99
99
 
100
+ def _submodule_facts(root: Path) -> list[dict]:
101
+ rc, output, _ = run_git(["submodule", "status", "--recursive"], cwd=root, timeout=20)
102
+ if rc:
103
+ raise ContinuityError("TASK_CONTINUITY_WITHHELD", "Git submodule status is unavailable")
104
+ facts = []
105
+ for line in output.splitlines():
106
+ if len(line) < 43:
107
+ raise ContinuityError("TASK_CONTINUITY_WITHHELD", "Git submodule status is malformed")
108
+ state, revision, relative_text = line[0], line[1:41], line[42:]
109
+ if " (" in relative_text:
110
+ relative_text = relative_text.rsplit(" (", 1)[0]
111
+ relative = Path(relative_text)
112
+ candidate = root / relative
113
+ try:
114
+ candidate.resolve(strict=True).relative_to(root.resolve())
115
+ except (OSError, ValueError) as error:
116
+ if state == "-" and not candidate.exists():
117
+ facts.append({"path": relative.as_posix(), "revision": revision, "state": "uninitialized"})
118
+ continue
119
+ raise ContinuityError("TASK_CONTINUITY_WITHHELD", f"submodule path is unavailable or unsafe: {relative}") from error
120
+ if state == "-":
121
+ facts.append({"path": relative.as_posix(), "revision": revision, "state": "uninitialized"})
122
+ continue
123
+ if not candidate.is_dir():
124
+ raise ContinuityError("TASK_CONTINUITY_WITHHELD", f"submodule path is not a directory: {relative}")
125
+ rc, head, _ = run_git(["rev-parse", "HEAD"], cwd=candidate, timeout=5)
126
+ rc_status, status, _ = run_git(["status", "--porcelain=v1", "--untracked-files=all"], cwd=candidate, timeout=10)
127
+ rc_diff, diff, _ = run_git(["diff", "--binary", "HEAD", "--", "."], cwd=candidate, timeout=20)
128
+ rc_untracked, untracked, _ = run_git(["ls-files", "--others", "--exclude-standard", "-z"], cwd=candidate, timeout=10)
129
+ if any((rc, rc_status, rc_diff, rc_untracked)):
130
+ raise ContinuityError("TASK_CONTINUITY_WITHHELD", f"submodule worktree is unavailable: {relative}")
131
+ digest = hashlib.sha256(diff.encode("utf-8", errors="surrogateescape"))
132
+ for raw_path in sorted(os.fsencode(path) for path in untracked.split("\0") if path):
133
+ path = candidate / Path(os.fsdecode(raw_path))
134
+ try:
135
+ path.resolve(strict=True).relative_to(candidate.resolve())
136
+ if path.is_symlink():
137
+ content = os.fsencode(os.readlink(path))
138
+ elif path.is_file():
139
+ content = path.read_bytes()
140
+ else:
141
+ raise OSError("not a regular file or symbolic link")
142
+ except (OSError, ValueError) as error:
143
+ raise ContinuityError("TASK_CONTINUITY_WITHHELD", f"submodule untracked path is unavailable or unsafe: {relative / path.name}") from error
144
+ digest.update(raw_path)
145
+ digest.update(b"\0")
146
+ digest.update(content)
147
+ digest.update(b"\0")
148
+ facts.append({
149
+ "path": relative.as_posix(),
150
+ "revision": revision,
151
+ "head": head.strip(),
152
+ "state": state,
153
+ "status_digest": hashlib.sha256(status.encode("utf-8")).hexdigest(),
154
+ "content_digest": digest.hexdigest(),
155
+ })
156
+ return facts
157
+
158
+
100
159
  def _projection(root: Path, directory: Path, data: dict, task_id: str) -> dict:
101
160
  meta = data.get("meta")
102
161
  if not isinstance(meta, dict):
@@ -178,6 +237,8 @@ def _projection(root: Path, directory: Path, data: dict, task_id: str) -> dict:
178
237
  worktree.update(b"\0")
179
238
  worktree.update(content)
180
239
  worktree.update(b"\0")
240
+ submodules = _submodule_facts(root)
241
+ worktree.update(json.dumps(submodules, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8"))
181
242
  evidence_digest = hashlib.sha256(
182
243
  json.dumps(sorted(evidence), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
183
244
  ).hexdigest()
@@ -191,6 +252,7 @@ def _projection(root: Path, directory: Path, data: dict, task_id: str) -> dict:
191
252
  "clean": not status,
192
253
  "status_digest": hashlib.sha256(status.encode("utf-8")).hexdigest(),
193
254
  "content_digest": worktree.hexdigest(),
255
+ "submodules": submodules,
194
256
  },
195
257
  }
196
258
 
@@ -298,8 +360,10 @@ def _load_owner(path: Path) -> dict | None:
298
360
  if record is None:
299
361
  return None
300
362
  required = {"schema_version", "task_id", "task_path", "package_id", "package_digest", "generation", "status", "source_session_id", "target_session_id", "continuity_digest", "route"}
301
- if set(record) != required or record.get("schema_version") != 1 or not isinstance(record.get("generation"), int):
363
+ if not required.issubset(record) or set(record) - required - {"target_continuity_digest", "recovery_history"} or record.get("schema_version") != 1 or not isinstance(record.get("generation"), int):
302
364
  raise ContinuityError("TASK_TRANSFER_WITHHELD", "ownership record has an invalid shape")
365
+ if "target_continuity_digest" in record and (not isinstance(record["target_continuity_digest"], str) or not PACKAGE_DIGEST_RE.fullmatch(record["target_continuity_digest"])):
366
+ raise ContinuityError("TASK_TRANSFER_WITHHELD", "target continuity digest is invalid")
303
367
  return record
304
368
 
305
369
 
@@ -311,6 +375,10 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
311
375
  root = get_repo_root()
312
376
  operation = args.transfer_action
313
377
  try:
378
+ package_bound = args.package_id is not None or args.package_digest is not None
379
+ if operation != "status" or package_bound:
380
+ if not isinstance(args.package_id, str) or not PACKAGE_ID_RE.fullmatch(args.package_id) or not isinstance(args.package_digest, str) or not PACKAGE_DIGEST_RE.fullmatch(args.package_digest):
381
+ raise ContinuityError("TASK_TRANSFER_INVALID_INPUT", "session package identity and SHA-256 digest are required")
314
382
  task_dir = resolve_task_dir(args.task_path, root)
315
383
  if task_dir is None or not is_within_tasks_dir(task_dir, root):
316
384
  raise ContinuityError("TASK_TRANSFER_WITHHELD", "task path is unavailable or unsafe")
@@ -326,10 +394,15 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
326
394
  path = _ownership_path(root, task_path)
327
395
  with _record_lock(path):
328
396
  record = _load_owner(path)
397
+ if record and (record["task_id"] != task_id or record["task_path"] != task_path):
398
+ raise ContinuityError("TASK_TRANSFER_WITHHELD", "ownership record does not match the requested task")
329
399
  if operation == "status":
330
- if record and (record["package_id"] != args.package_id or record["package_digest"] != args.package_digest):
400
+ if record and package_bound and (record["package_id"] != args.package_id or record["package_digest"] != args.package_digest):
331
401
  raise ContinuityError("TASK_TRANSFER_STALE_PACKAGE", "session package identity or digest changed")
332
402
  result = record or {"status": "absent", "generation": 0, "task_id": task_id}
403
+ if args.include_current:
404
+ current = _projection(root, task_dir, data, task_id)
405
+ result = {**result, "current_continuity_digest": _digest(current), "current_projection": current}
333
406
  print(json.dumps(result, ensure_ascii=False, sort_keys=True))
334
407
  return 0
335
408
  if not args.explicit_user_request:
@@ -337,8 +410,6 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
337
410
  if args.expected_generation is None or args.expected_generation < 0:
338
411
  raise ContinuityError("TASK_TRANSFER_INVALID_INPUT", "a non-negative expected generation is required")
339
412
  if operation == "quiesce":
340
- if not PACKAGE_ID_RE.fullmatch(args.package_id) or not PACKAGE_DIGEST_RE.fullmatch(args.package_digest):
341
- raise ContinuityError("TASK_TRANSFER_INVALID_INPUT", "session package identity or SHA-256 digest is invalid")
342
413
  context_key, current_path = _direct_pointer(root, task_path)
343
414
  if current_path is None:
344
415
  raise ContinuityError("TASK_TRANSFER_WITHHELD", "source session has no current task")
@@ -355,13 +426,18 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
355
426
  result = record
356
427
  print(json.dumps(result, ensure_ascii=False, sort_keys=True))
357
428
  return 0
358
- raise ContinuityError("TASK_TRANSFER_STALE_GENERATION", "task already has an ownership record")
359
- if args.expected_generation != 0:
429
+ if record["status"] != "consumed" or record["generation"] != args.expected_generation:
430
+ raise ContinuityError("TASK_TRANSFER_STALE_GENERATION", "task already has an ownership record at a different state or generation")
431
+ if record["target_session_id"] != context_key:
432
+ raise ContinuityError("TASK_TRANSFER_WITHHELD", "only the consumed target can start another handoff")
433
+ if record["package_id"] == args.package_id:
434
+ raise ContinuityError("TASK_TRANSFER_STALE_PACKAGE", "another handoff requires a new session package")
435
+ elif args.expected_generation != 0:
360
436
  raise ContinuityError("TASK_TRANSFER_STALE_GENERATION", "initial ownership generation must be 0")
361
437
  record = {
362
438
  "schema_version": 1, "task_id": task_id, "task_path": task_path,
363
439
  "package_id": args.package_id, "package_digest": args.package_digest,
364
- "generation": 1, "status": "quiescing", "source_session_id": context_key,
440
+ "generation": args.expected_generation + 1, "status": "quiescing", "source_session_id": context_key,
365
441
  "target_session_id": None, "continuity_digest": continuity_digest,
366
442
  "route": _route_snapshot(data),
367
443
  }
@@ -372,11 +448,50 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
372
448
  raise ContinuityError("TASK_TRANSFER_STALE_PACKAGE", "session package identity or digest changed")
373
449
  if args.expected_generation != record["generation"]:
374
450
  raise ContinuityError("TASK_TRANSFER_STALE_GENERATION", f"expected generation {args.expected_generation}, current generation is {record['generation']}")
375
- if operation != "release" and _current_projection_digest(root, task_dir, data, task_id) != record["continuity_digest"]:
451
+ current_digest = (
452
+ _current_projection_digest(root, task_dir, data, task_id)
453
+ if operation != "release" else record["continuity_digest"]
454
+ )
455
+ if args.expected_continuity_digest is not None:
456
+ if not PACKAGE_DIGEST_RE.fullmatch(args.expected_continuity_digest):
457
+ raise ContinuityError("TASK_TRANSFER_INVALID_INPUT", "expected continuity digest must be a SHA-256 digest")
458
+ if args.expected_continuity_digest != current_digest:
459
+ raise ContinuityError("TASK_TRANSFER_STALE_CONTINUITY", "current task projection differs from the explicitly checked snapshot")
460
+ # Recovery acknowledges a live target snapshot without rebasing the source package.
461
+ recovered_claim = (
462
+ operation == "claim" and record["status"] == "sealed"
463
+ and args.expected_continuity_digest == current_digest
464
+ )
465
+ if operation not in {"release", "recover"} and current_digest != record.get("target_continuity_digest", record["continuity_digest"]) and not recovered_claim:
376
466
  raise ContinuityError("TASK_TRANSFER_STALE_CONTINUITY", "task continuity changed during ownership transfer")
377
467
  if operation != "release" and _route_snapshot(data) != record["route"]:
378
468
  raise ContinuityError("TASK_TRANSFER_STALE_ROUTE", "task phase, route, input, or implementation authorization changed")
379
469
  context_key, current_path = _direct_pointer(root)
470
+ if operation == "recover":
471
+ if record["status"] not in {"claiming", "claimed", "recovery_required"} or not isinstance(args.reason, str) or not args.reason.strip() or args.expected_continuity_digest != current_digest:
472
+ raise ContinuityError("TASK_TRANSFER_INVALID_TRANSITION", "explicit recovery requires a pending target, a reason, and the current projection digest")
473
+ if current_path not in {None, record["task_path"]}:
474
+ raise ContinuityError("TASK_TRANSFER_WITHHELD", "recovery session already has another active task")
475
+ from .task_workflow import _assert_delegated_resources_closed
476
+ try:
477
+ _assert_delegated_resources_closed(task_dir, data)
478
+ except TaskWorkflowError as error:
479
+ raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", str(error)) from error
480
+ teammates_dir = task_dir / "teammates"
481
+ if any(teammates_dir.rglob("queue.json")) or any(teammates_dir.rglob("claim.json")):
482
+ raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "managed teamwork runtime artifacts remain; reconcile the original batch first")
483
+ record.setdefault("recovery_history", []).append({"generation": record["generation"], "status": record["status"], "target_session_id": record["target_session_id"], "requested_by": context_key, "reason": args.reason})
484
+ record.update({"status": "recovery_required", "generation": record["generation"] + 1, "target_continuity_digest": current_digest})
485
+ if not write_json(path, record):
486
+ raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not fence the previous generation before recovery")
487
+ for session in {record["source_session_id"], record["target_session_id"]} - {None}:
488
+ if not clear_active_task_if_matches(root, session, record["task_path"]):
489
+ raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "a prior session pointer changed; preserve the current generation")
490
+ record.update({"status": "ready", "target_session_id": None})
491
+ if not write_json(path, record):
492
+ raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not persist explicit recovery")
493
+ print(json.dumps(record, ensure_ascii=False, sort_keys=True))
494
+ return 0
380
495
  if operation in {"seal", "retire"}:
381
496
  if context_key != record["source_session_id"] or current_path not in {record["task_path"], None}:
382
497
  raise ContinuityError("TASK_TRANSFER_WITHHELD", "source session no longer owns the current task")
@@ -412,13 +527,15 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
412
527
  write_json(path, record)
413
528
  raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "source pointer remains after retirement")
414
529
  record["status"] = "ready"
415
- elif operation == "claim" and record["status"] in {"ready", "claiming", "recovery_required", "claimed"}:
416
- if record["status"] == "recovery_required" and record["target_session_id"] != context_key:
530
+ elif operation == "claim" and (record["status"] in {"ready", "claiming", "recovery_required", "claimed"} or recovered_claim):
531
+ if record["status"] in {"claiming", "recovery_required"} and record["target_session_id"] != context_key:
417
532
  raise ContinuityError("TASK_TRANSFER_WITHHELD", "only the recorded target session can recover a claim")
418
533
  if current_path not in {None, record["task_path"]}:
419
534
  raise ContinuityError("TASK_TRANSFER_WITHHELD", "target session already has a current task")
420
- if record["status"] == "ready":
535
+ if record["status"] in {"ready", "sealed"}:
421
536
  record.update({"status": "claiming", "generation": record["generation"] + 1, "target_session_id": context_key})
537
+ if recovered_claim:
538
+ record["target_continuity_digest"] = current_digest
422
539
  if not write_json(path, record):
423
540
  raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not record target claim")
424
541
  elif record["status"] == "claimed":
@@ -430,6 +547,10 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
430
547
  record.update({"status": "claiming", "generation": record["generation"] + 1, "target_session_id": context_key})
431
548
  if not write_json(path, record):
432
549
  raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not record target takeover")
550
+ if not clear_active_task_if_matches(root, record["source_session_id"], record["task_path"]):
551
+ record["status"] = "recovery_required"
552
+ write_json(path, record)
553
+ raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "source pointer could not be retired during claim")
433
554
  if current_path is None and not compare_and_set_active_task(root, context_key, None, record["task_path"]):
434
555
  record["status"] = "recovery_required"
435
556
  write_json(path, record)
@@ -463,13 +584,15 @@ def add_continuity_commands(subparsers) -> None:
463
584
  capture.add_argument("--expected-source-digest", required=True, help="Current captured digest, or absent")
464
585
 
465
586
  transfer = subparsers.add_parser("transfer", help="Transfer task ownership between direct sessions")
466
- transfer.add_argument("transfer_action", choices=("status", "quiesce", "seal", "retire", "claim", "consume", "release"))
587
+ transfer.add_argument("transfer_action", choices=("status", "quiesce", "seal", "retire", "claim", "consume", "release", "recover"))
588
+ transfer.add_argument("--reason", help="Explicit recovery reason")
467
589
  transfer.add_argument("--task-id", required=True)
468
590
  transfer.add_argument("--task-path", required=True)
469
- transfer.add_argument("--package-id", required=True)
470
- transfer.add_argument("--package-digest", required=True)
471
- transfer.add_argument("--expected-continuity-digest")
591
+ transfer.add_argument("--package-id", help="Required with --package-digest for mutations or package-bound status")
592
+ transfer.add_argument("--package-digest", help="Required with --package-id for mutations or package-bound status")
593
+ transfer.add_argument("--expected-continuity-digest", help="Expected live projection for sealed-source claim recovery")
472
594
  transfer.add_argument("--expected-generation", type=int)
595
+ transfer.add_argument("--include-current", action="store_true", help="Include the live task projection and its digest in transfer status")
473
596
  transfer.add_argument("--explicit-user-request", action="store_true")
474
597
 
475
598
 
@@ -489,35 +612,39 @@ def clear_task_runtime_records(repo_root: Path, task_path: str) -> bool:
489
612
  @contextlib.contextmanager
490
613
  def active_task_change_guard(repo_root: Path, context_key: str, task_path: str, *, clearing: bool):
491
614
  """Serialize pointer writes with ownership changes for the same task."""
492
- directory = resolve_task_dir(task_path, repo_root)
493
- if directory is None:
494
- yield False
495
- return
496
- task_path = directory.relative_to(repo_root).as_posix()
497
- path = _ownership_path(repo_root, task_path)
498
- with _record_lock(path):
499
- try:
500
- record = _load_owner(path)
501
- except ContinuityError:
502
- yield False
503
- return
504
- if record is None:
505
- yield True
506
- return
507
- data, _ = read_json_checked(directory / FILE_TASK_JSON)
508
- task_id = (data.get("id") or data.get("name")) if isinstance(data, dict) else None
509
- if task_id != record["task_id"] or record["task_path"] != task_path:
615
+ from .task_move import layout_lock, require_settled_layout
616
+
617
+ with layout_lock(repo_root):
618
+ require_settled_layout(repo_root)
619
+ directory = resolve_task_dir(task_path, repo_root)
620
+ if directory is None:
510
621
  yield False
511
622
  return
512
- if clearing:
513
- allowed = record["status"] == "consumed" and record["target_session_id"] == context_key
514
- elif record["status"] == "ready":
515
- allowed = context_key != record["source_session_id"] and task_path == record["task_path"]
516
- elif record["status"] == "consumed":
517
- allowed = context_key == record["target_session_id"] and task_path == record["task_path"]
518
- else:
519
- allowed = record["status"] in {"claiming", "claimed"} and context_key == record["target_session_id"] and task_path == record["task_path"]
520
- yield allowed
623
+ task_path = directory.relative_to(repo_root).as_posix()
624
+ path = _ownership_path(repo_root, task_path)
625
+ with _record_lock(path):
626
+ try:
627
+ record = _load_owner(path)
628
+ except ContinuityError:
629
+ yield False
630
+ return
631
+ if record is None:
632
+ yield True
633
+ return
634
+ data, _ = read_json_checked(directory / FILE_TASK_JSON)
635
+ task_id = (data.get("id") or data.get("name")) if isinstance(data, dict) else None
636
+ if task_id != record["task_id"] or record["task_path"] != task_path:
637
+ yield False
638
+ return
639
+ if clearing:
640
+ allowed = record["status"] == "consumed" and record["target_session_id"] == context_key
641
+ elif record["status"] == "ready":
642
+ allowed = context_key != record["source_session_id"] and task_path == record["task_path"]
643
+ elif record["status"] == "consumed":
644
+ allowed = context_key == record["target_session_id"] and task_path == record["task_path"]
645
+ else:
646
+ allowed = record["status"] in {"claiming", "claimed"} and context_key == record["target_session_id"] and task_path == record["task_path"]
647
+ yield allowed
521
648
 
522
649
 
523
650
  def assert_task_mutation_allowed(repo_root: Path, task_dir: Path, data: dict) -> None:
@@ -543,21 +670,25 @@ def assert_task_identity_change_allowed(repo_root: Path, task_dir: Path, data: d
543
670
  @contextlib.contextmanager
544
671
  def task_mutation_guard(repo_root: Path, task_dir: Path, data: dict):
545
672
  """Hold the ownership lock across a single task mutation."""
546
- task_path = task_dir.relative_to(repo_root).as_posix()
547
- path = _ownership_path(repo_root, task_path)
548
- with _record_lock(path):
549
- try:
550
- record = _load_owner(path)
551
- except ContinuityError:
552
- yield False
553
- return
554
- if record is None:
555
- yield True
556
- return
557
- task_id = (data.get("id") or data.get("name")) if isinstance(data, dict) else None
558
- if task_id != record["task_id"] or record["task_path"] != task_path:
559
- yield False
560
- return
561
- active = resolve_active_task(repo_root)
562
- allowed = record["status"] == "consumed" and active.context_key == record["target_session_id"] and active.task_path == task_path
563
- yield allowed
673
+ from .task_move import layout_lock, require_settled_layout
674
+
675
+ with layout_lock(repo_root):
676
+ require_settled_layout(repo_root)
677
+ task_path = task_dir.relative_to(repo_root).as_posix()
678
+ path = _ownership_path(repo_root, task_path)
679
+ with _record_lock(path):
680
+ try:
681
+ record = _load_owner(path)
682
+ except ContinuityError:
683
+ yield False
684
+ return
685
+ if record is None:
686
+ yield True
687
+ return
688
+ task_id = (data.get("id") or data.get("name")) if isinstance(data, dict) else None
689
+ if task_id != record["task_id"] or record["task_path"] != task_path:
690
+ yield False
691
+ return
692
+ active = resolve_active_task(repo_root)
693
+ allowed = record["status"] == "consumed" and active.context_key == record["target_session_id"] and active.task_path == task_path
694
+ yield allowed