@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.
- package/README.md +10 -7
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/ablate.d.ts.map +1 -1
- package/dist/commands/ablate.js +2 -0
- package/dist/commands/ablate.js.map +1 -1
- package/dist/commands/backup.d.ts +4 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +75 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +705 -681
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mem.d.ts.map +1 -1
- package/dist/commands/mem.js +49 -7
- package/dist/commands/mem.js.map +1 -1
- package/dist/commands/task-progress.d.ts +1 -0
- package/dist/commands/task-progress.d.ts.map +1 -1
- package/dist/commands/task-progress.js +67 -25
- package/dist/commands/task-progress.js.map +1 -1
- package/dist/commands/teamwork/adapters/acp.d.ts +20 -0
- package/dist/commands/teamwork/adapters/acp.d.ts.map +1 -0
- package/dist/commands/teamwork/adapters/acp.js +268 -0
- package/dist/commands/teamwork/adapters/acp.js.map +1 -0
- package/dist/commands/teamwork/adapters/claude.d.ts +1 -0
- package/dist/commands/teamwork/adapters/claude.d.ts.map +1 -1
- package/dist/commands/teamwork/adapters/claude.js +5 -0
- package/dist/commands/teamwork/adapters/claude.js.map +1 -1
- package/dist/commands/teamwork/adapters/codex.d.ts +3 -2
- package/dist/commands/teamwork/adapters/codex.d.ts.map +1 -1
- package/dist/commands/teamwork/adapters/codex.js +14 -15
- package/dist/commands/teamwork/adapters/codex.js.map +1 -1
- package/dist/commands/teamwork/adapters/index.d.ts +23 -1
- package/dist/commands/teamwork/adapters/index.d.ts.map +1 -1
- package/dist/commands/teamwork/adapters/index.js +163 -24
- package/dist/commands/teamwork/adapters/index.js.map +1 -1
- package/dist/commands/teamwork/agent-loader.d.ts +2 -1
- package/dist/commands/teamwork/agent-loader.d.ts.map +1 -1
- package/dist/commands/teamwork/agent-loader.js +2 -1
- package/dist/commands/teamwork/agent-loader.js.map +1 -1
- package/dist/commands/teamwork/artifact.d.ts +33 -10
- package/dist/commands/teamwork/artifact.d.ts.map +1 -1
- package/dist/commands/teamwork/artifact.js +182 -50
- package/dist/commands/teamwork/artifact.js.map +1 -1
- package/dist/commands/teamwork/batch-reservation.d.ts +16 -0
- package/dist/commands/teamwork/batch-reservation.d.ts.map +1 -0
- package/dist/commands/teamwork/batch-reservation.js +43 -0
- package/dist/commands/teamwork/batch-reservation.js.map +1 -0
- package/dist/commands/teamwork/batch.d.ts +22 -0
- package/dist/commands/teamwork/batch.d.ts.map +1 -0
- package/dist/commands/teamwork/batch.js +394 -0
- package/dist/commands/teamwork/batch.js.map +1 -0
- package/dist/commands/teamwork/context-loader.d.ts +1 -1
- package/dist/commands/teamwork/context-loader.d.ts.map +1 -1
- package/dist/commands/teamwork/context-loader.js +19 -6
- package/dist/commands/teamwork/context-loader.js.map +1 -1
- package/dist/commands/teamwork/coordination.d.ts +88 -0
- package/dist/commands/teamwork/coordination.d.ts.map +1 -0
- package/dist/commands/teamwork/coordination.js +153 -0
- package/dist/commands/teamwork/coordination.js.map +1 -0
- package/dist/commands/teamwork/coordinator.d.ts +17 -3
- package/dist/commands/teamwork/coordinator.d.ts.map +1 -1
- package/dist/commands/teamwork/coordinator.js +772 -289
- package/dist/commands/teamwork/coordinator.js.map +1 -1
- package/dist/commands/teamwork/create.d.ts +1 -0
- package/dist/commands/teamwork/create.d.ts.map +1 -1
- package/dist/commands/teamwork/create.js +2 -1
- package/dist/commands/teamwork/create.js.map +1 -1
- package/dist/commands/teamwork/dev-parse-trace.d.ts +2 -1
- package/dist/commands/teamwork/dev-parse-trace.d.ts.map +1 -1
- package/dist/commands/teamwork/dev-parse-trace.js +2 -0
- package/dist/commands/teamwork/dev-parse-trace.js.map +1 -1
- package/dist/commands/teamwork/foreground-wait.d.ts +6 -0
- package/dist/commands/teamwork/foreground-wait.d.ts.map +1 -0
- package/dist/commands/teamwork/foreground-wait.js +65 -0
- package/dist/commands/teamwork/foreground-wait.js.map +1 -0
- package/dist/commands/teamwork/guard.d.ts +2 -2
- package/dist/commands/teamwork/guard.d.ts.map +1 -1
- package/dist/commands/teamwork/guard.js +23 -5
- package/dist/commands/teamwork/guard.js.map +1 -1
- package/dist/commands/teamwork/index.d.ts.map +1 -1
- package/dist/commands/teamwork/index.js +139 -10
- package/dist/commands/teamwork/index.js.map +1 -1
- package/dist/commands/teamwork/kill.d.ts +2 -0
- package/dist/commands/teamwork/kill.d.ts.map +1 -1
- package/dist/commands/teamwork/kill.js +79 -38
- package/dist/commands/teamwork/kill.js.map +1 -1
- package/dist/commands/teamwork/messages.d.ts.map +1 -1
- package/dist/commands/teamwork/messages.js +3 -0
- package/dist/commands/teamwork/messages.js.map +1 -1
- package/dist/commands/teamwork/plan.d.ts +22 -0
- package/dist/commands/teamwork/plan.d.ts.map +1 -0
- package/dist/commands/teamwork/plan.js +288 -0
- package/dist/commands/teamwork/plan.js.map +1 -0
- package/dist/commands/teamwork/process-identity.d.ts +3 -0
- package/dist/commands/teamwork/process-identity.d.ts.map +1 -0
- package/dist/commands/teamwork/process-identity.js +24 -0
- package/dist/commands/teamwork/process-identity.js.map +1 -0
- package/dist/commands/teamwork/profiles.d.ts +43 -6
- package/dist/commands/teamwork/profiles.d.ts.map +1 -1
- package/dist/commands/teamwork/profiles.js +138 -110
- package/dist/commands/teamwork/profiles.js.map +1 -1
- package/dist/commands/teamwork/prompt.d.ts +4 -0
- package/dist/commands/teamwork/prompt.d.ts.map +1 -0
- package/dist/commands/teamwork/prompt.js +33 -0
- package/dist/commands/teamwork/prompt.js.map +1 -0
- package/dist/commands/teamwork/result.d.ts +54 -0
- package/dist/commands/teamwork/result.d.ts.map +1 -0
- package/dist/commands/teamwork/result.js +94 -0
- package/dist/commands/teamwork/result.js.map +1 -0
- package/dist/commands/teamwork/rm.d.ts.map +1 -1
- package/dist/commands/teamwork/rm.js +64 -64
- package/dist/commands/teamwork/rm.js.map +1 -1
- package/dist/commands/teamwork/run.d.ts +5 -0
- package/dist/commands/teamwork/run.d.ts.map +1 -1
- package/dist/commands/teamwork/run.js +120 -24
- package/dist/commands/teamwork/run.js.map +1 -1
- package/dist/commands/teamwork/runtime.d.ts +9 -0
- package/dist/commands/teamwork/runtime.d.ts.map +1 -0
- package/dist/commands/teamwork/runtime.js +372 -0
- package/dist/commands/teamwork/runtime.js.map +1 -0
- package/dist/commands/teamwork/send.d.ts +1 -0
- package/dist/commands/teamwork/send.d.ts.map +1 -1
- package/dist/commands/teamwork/send.js +2 -1
- package/dist/commands/teamwork/send.js.map +1 -1
- package/dist/commands/teamwork/spawn.d.ts +10 -3
- package/dist/commands/teamwork/spawn.d.ts.map +1 -1
- package/dist/commands/teamwork/spawn.js +42 -27
- package/dist/commands/teamwork/spawn.js.map +1 -1
- package/dist/commands/teamwork/store/lock.js +28 -4
- package/dist/commands/teamwork/store/lock.js.map +1 -1
- package/dist/commands/teamwork/supervisor/inbox.d.ts +1 -0
- package/dist/commands/teamwork/supervisor/inbox.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor/inbox.js +28 -9
- package/dist/commands/teamwork/supervisor/inbox.js.map +1 -1
- package/dist/commands/teamwork/supervisor/stdout.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor/stdout.js +2 -3
- package/dist/commands/teamwork/supervisor/stdout.js.map +1 -1
- package/dist/commands/teamwork/supervisor/warning.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor/warning.js +2 -3
- package/dist/commands/teamwork/supervisor/warning.js.map +1 -1
- package/dist/commands/teamwork/supervisor.d.ts +5 -0
- package/dist/commands/teamwork/supervisor.d.ts.map +1 -1
- package/dist/commands/teamwork/supervisor.js +21 -12
- package/dist/commands/teamwork/supervisor.js.map +1 -1
- package/dist/commands/teamwork/teammate-adapters.d.ts +0 -5
- package/dist/commands/teamwork/teammate-adapters.d.ts.map +1 -1
- package/dist/commands/teamwork/teammate-adapters.js +9 -56
- package/dist/commands/teamwork/teammate-adapters.js.map +1 -1
- package/dist/commands/teamwork/teammate-config.d.ts +5 -1
- package/dist/commands/teamwork/teammate-config.d.ts.map +1 -1
- package/dist/commands/teamwork/teammate-config.js +62 -19
- package/dist/commands/teamwork/teammate-config.js.map +1 -1
- package/dist/commands/teamwork/teammate-launch.d.ts +15 -6
- package/dist/commands/teamwork/teammate-launch.d.ts.map +1 -1
- package/dist/commands/teamwork/teammate-launch.js +129 -22
- package/dist/commands/teamwork/teammate-launch.js.map +1 -1
- package/dist/commands/teamwork/wait.d.ts +2 -0
- package/dist/commands/teamwork/wait.d.ts.map +1 -1
- package/dist/commands/teamwork/wait.js +44 -3
- package/dist/commands/teamwork/wait.js.map +1 -1
- package/dist/commands/uninstall.d.ts.map +1 -1
- package/dist/commands/uninstall.js +92 -84
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +635 -592
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +94 -89
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/shared.js +2 -2
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +14 -32
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/constants/paths.d.ts +3 -10
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +4 -13
- package/dist/constants/paths.js.map +1 -1
- package/dist/migrations/manifests/0.7.1-beta.0.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.1.json +186 -0
- package/dist/migrations/manifests/0.7.1-beta.10.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.11.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.12.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.13.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.14.json +11 -0
- package/dist/migrations/manifests/0.7.1-beta.15.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.16.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.17.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.18.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.19.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.2.json +522 -0
- package/dist/migrations/manifests/0.7.1-beta.3.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.4.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.5.json +9 -0
- package/dist/migrations/manifests/0.7.1-beta.6.json +10 -0
- package/dist/migrations/manifests/0.7.1-beta.7.json +8 -0
- package/dist/migrations/manifests/0.7.1-beta.8.json +481 -0
- package/dist/migrations/manifests/0.7.1-beta.9.json +191 -0
- package/dist/migrations/manifests/0.7.2-beta.0.json +11 -0
- package/dist/templates/claude/hooks/statusline.py +9 -4
- package/dist/templates/codex/hooks/session-start.py +14 -11
- package/dist/templates/codex/hooks.json +10 -0
- package/dist/templates/common/bundled-skills/trellis-decision-space-construction-and-merging/workflow.md +2 -0
- package/dist/templates/common/bundled-skills/trellis-goal-exploration-and-requirements/workflow.md +2 -0
- package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +5 -5
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +3 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-teamwork.md +14 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +8 -2
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-solution-design-and-convergence/workflow.md +8 -0
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/mcp-setup.md +4 -2
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-task-planning.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-task-implementation/workflow.md +6 -0
- package/dist/templates/common/bundled-skills/trellis-teamwork/SKILL.md +18 -3
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/coordination-modes.md +34 -3
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-configuration-matrix.md +37 -26
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +128 -14
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +152 -20
- package/dist/templates/common/commands/start.md +2 -2
- package/dist/templates/common/skills/before-dev.md +1 -4
- package/dist/templates/common/skills/break-loop.md +7 -18
- package/dist/templates/common/skills/check.md +7 -11
- package/dist/templates/common/skills/{brainstorm.md → request-intake.md} +5 -3
- package/dist/templates/common/skills/update-spec.md +5 -7
- package/dist/templates/copilot/hooks/session-start.py +14 -11
- package/dist/templates/copilot/index.d.ts +1 -6
- package/dist/templates/copilot/index.d.ts.map +1 -1
- package/dist/templates/copilot/index.js +1 -14
- package/dist/templates/copilot/index.js.map +1 -1
- package/dist/templates/extract.js +4 -0
- package/dist/templates/extract.js.map +1 -1
- package/dist/templates/markdown/spec/backend/index.md.txt +4 -35
- package/dist/templates/markdown/spec/frontend/index.md.txt +4 -36
- package/dist/templates/omp/extensions/trellis/index.ts.txt +2 -2
- package/dist/templates/opencode/lib/session-utils.js +301 -209
- package/dist/templates/pi/extensions/trellis/index.ts.txt +17 -1
- package/dist/templates/shared-hooks/guard-teamwork-wait.py +53 -0
- package/dist/templates/shared-hooks/index.d.ts +1 -1
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +1 -0
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-workflow-state.py +8 -3
- package/dist/templates/shared-hooks/session-start.py +16 -12
- package/dist/templates/snow/SNOW.md +1 -1
- package/dist/templates/trellis/agents/delegated-worktree-primary-agent.md +9 -0
- package/dist/templates/trellis/agents/profiles/architecture.md +9 -0
- package/dist/templates/trellis/agents/profiles/diagnosis.md +9 -0
- package/dist/templates/trellis/agents/profiles/exploration.md +9 -0
- package/dist/templates/trellis/agents/profiles/research.md +9 -0
- package/dist/templates/trellis/agents/profiles/review.md +9 -0
- package/dist/templates/trellis/agents/profiles/verification.md +9 -0
- package/dist/templates/trellis/agents/teammate-profiles.json +56 -4
- package/dist/templates/trellis/agents/teammate.md +4 -52
- package/dist/templates/trellis/config.yaml +2 -5
- package/dist/templates/trellis/index.d.ts +4 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +22 -4
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +1 -1
- package/dist/templates/trellis/scripts/common/active_task.py +3 -47
- package/dist/templates/trellis/scripts/common/paths.py +9 -25
- package/dist/templates/trellis/scripts/common/safe_commit.py +8 -8
- package/dist/templates/trellis/scripts/common/task_context.py +11 -51
- package/dist/templates/trellis/scripts/common/task_continuity.py +129 -60
- package/dist/templates/trellis/scripts/common/task_layout.py +80 -0
- package/dist/templates/trellis/scripts/common/task_move.py +345 -0
- package/dist/templates/trellis/scripts/common/task_store.py +35 -57
- package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
- package/dist/templates/trellis/scripts/common/task_workflow.py +339 -46
- package/dist/templates/trellis/scripts/common/tasks.py +6 -5
- package/dist/templates/trellis/scripts/hooks/linear_sync.py +13 -3
- package/dist/templates/trellis/scripts/task.py +71 -24
- package/dist/templates/trellis/teamwork.json +10 -0
- package/dist/templates/trellis/workflow.md +11 -2
- package/dist/utils/ablation-store.d.ts +1 -1
- package/dist/utils/ablation-store.d.ts.map +1 -1
- package/dist/utils/ablation-store.js +58 -24
- package/dist/utils/ablation-store.js.map +1 -1
- package/dist/utils/asset-backup.d.ts +138 -0
- package/dist/utils/asset-backup.d.ts.map +1 -0
- package/dist/utils/asset-backup.js +513 -0
- package/dist/utils/asset-backup.js.map +1 -0
- package/dist/utils/atomic-write.d.ts +1 -1
- package/dist/utils/atomic-write.d.ts.map +1 -1
- package/dist/utils/atomic-write.js +6 -3
- package/dist/utils/atomic-write.js.map +1 -1
- package/dist/utils/manifest-prune.d.ts +4 -1
- package/dist/utils/manifest-prune.d.ts.map +1 -1
- package/dist/utils/manifest-prune.js +17 -6
- package/dist/utils/manifest-prune.js.map +1 -1
- package/dist/utils/template-fetcher.d.ts +4 -19
- package/dist/utils/template-fetcher.d.ts.map +1 -1
- package/dist/utils/template-fetcher.js +9 -45
- package/dist/utils/template-fetcher.js.map +1 -1
- package/dist/utils/template-hash.d.ts +5 -4
- package/dist/utils/template-hash.d.ts.map +1 -1
- package/dist/utils/template-hash.js +24 -17
- package/dist/utils/template-hash.js.map +1 -1
- package/package.json +3 -2
- package/dist/templates/copilot/prompts/before-dev.prompt.md +0 -39
- package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -13
- package/dist/templates/copilot/prompts/break-loop.prompt.md +0 -129
- package/dist/templates/copilot/prompts/check-cross-layer.prompt.md +0 -170
- package/dist/templates/copilot/prompts/check.prompt.md +0 -110
- package/dist/templates/copilot/prompts/create-command.prompt.md +0 -116
- package/dist/templates/copilot/prompts/finish-work.prompt.md +0 -22
- package/dist/templates/copilot/prompts/integrate-skill.prompt.md +0 -223
- package/dist/templates/copilot/prompts/onboard.prompt.md +0 -362
- package/dist/templates/copilot/prompts/record-session.prompt.md +0 -58
- package/dist/templates/copilot/prompts/start.prompt.md +0 -65
- 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
|
-
"""
|
|
204
|
+
"""Use the canonical status-layout resolver shared by all task readers."""
|
|
205
|
+
from .paths import resolve_task_ref as resolve
|
|
205
206
|
|
|
206
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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/<
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
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)}")
|
|
@@ -298,8 +298,10 @@ def _load_owner(path: Path) -> dict | None:
|
|
|
298
298
|
if record is None:
|
|
299
299
|
return None
|
|
300
300
|
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)
|
|
301
|
+
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
302
|
raise ContinuityError("TASK_TRANSFER_WITHHELD", "ownership record has an invalid shape")
|
|
303
|
+
if "target_continuity_digest" in record and (not isinstance(record["target_continuity_digest"], str) or not PACKAGE_DIGEST_RE.fullmatch(record["target_continuity_digest"])):
|
|
304
|
+
raise ContinuityError("TASK_TRANSFER_WITHHELD", "target continuity digest is invalid")
|
|
303
305
|
return record
|
|
304
306
|
|
|
305
307
|
|
|
@@ -311,6 +313,10 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
|
|
|
311
313
|
root = get_repo_root()
|
|
312
314
|
operation = args.transfer_action
|
|
313
315
|
try:
|
|
316
|
+
package_bound = args.package_id is not None or args.package_digest is not None
|
|
317
|
+
if operation != "status" or package_bound:
|
|
318
|
+
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):
|
|
319
|
+
raise ContinuityError("TASK_TRANSFER_INVALID_INPUT", "session package identity and SHA-256 digest are required")
|
|
314
320
|
task_dir = resolve_task_dir(args.task_path, root)
|
|
315
321
|
if task_dir is None or not is_within_tasks_dir(task_dir, root):
|
|
316
322
|
raise ContinuityError("TASK_TRANSFER_WITHHELD", "task path is unavailable or unsafe")
|
|
@@ -326,10 +332,15 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
|
|
|
326
332
|
path = _ownership_path(root, task_path)
|
|
327
333
|
with _record_lock(path):
|
|
328
334
|
record = _load_owner(path)
|
|
335
|
+
if record and (record["task_id"] != task_id or record["task_path"] != task_path):
|
|
336
|
+
raise ContinuityError("TASK_TRANSFER_WITHHELD", "ownership record does not match the requested task")
|
|
329
337
|
if operation == "status":
|
|
330
|
-
if record and (record["package_id"] != args.package_id or record["package_digest"] != args.package_digest):
|
|
338
|
+
if record and package_bound and (record["package_id"] != args.package_id or record["package_digest"] != args.package_digest):
|
|
331
339
|
raise ContinuityError("TASK_TRANSFER_STALE_PACKAGE", "session package identity or digest changed")
|
|
332
340
|
result = record or {"status": "absent", "generation": 0, "task_id": task_id}
|
|
341
|
+
if args.include_current:
|
|
342
|
+
current = _projection(root, task_dir, data, task_id)
|
|
343
|
+
result = {**result, "current_continuity_digest": _digest(current), "current_projection": current}
|
|
333
344
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
334
345
|
return 0
|
|
335
346
|
if not args.explicit_user_request:
|
|
@@ -337,8 +348,6 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
|
|
|
337
348
|
if args.expected_generation is None or args.expected_generation < 0:
|
|
338
349
|
raise ContinuityError("TASK_TRANSFER_INVALID_INPUT", "a non-negative expected generation is required")
|
|
339
350
|
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
351
|
context_key, current_path = _direct_pointer(root, task_path)
|
|
343
352
|
if current_path is None:
|
|
344
353
|
raise ContinuityError("TASK_TRANSFER_WITHHELD", "source session has no current task")
|
|
@@ -355,13 +364,18 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
|
|
|
355
364
|
result = record
|
|
356
365
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
357
366
|
return 0
|
|
358
|
-
|
|
359
|
-
|
|
367
|
+
if record["status"] != "consumed" or record["generation"] != args.expected_generation:
|
|
368
|
+
raise ContinuityError("TASK_TRANSFER_STALE_GENERATION", "task already has an ownership record at a different state or generation")
|
|
369
|
+
if record["target_session_id"] != context_key:
|
|
370
|
+
raise ContinuityError("TASK_TRANSFER_WITHHELD", "only the consumed target can start another handoff")
|
|
371
|
+
if record["package_id"] == args.package_id:
|
|
372
|
+
raise ContinuityError("TASK_TRANSFER_STALE_PACKAGE", "another handoff requires a new session package")
|
|
373
|
+
elif args.expected_generation != 0:
|
|
360
374
|
raise ContinuityError("TASK_TRANSFER_STALE_GENERATION", "initial ownership generation must be 0")
|
|
361
375
|
record = {
|
|
362
376
|
"schema_version": 1, "task_id": task_id, "task_path": task_path,
|
|
363
377
|
"package_id": args.package_id, "package_digest": args.package_digest,
|
|
364
|
-
"generation": 1, "status": "quiescing", "source_session_id": context_key,
|
|
378
|
+
"generation": args.expected_generation + 1, "status": "quiescing", "source_session_id": context_key,
|
|
365
379
|
"target_session_id": None, "continuity_digest": continuity_digest,
|
|
366
380
|
"route": _route_snapshot(data),
|
|
367
381
|
}
|
|
@@ -372,11 +386,50 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
|
|
|
372
386
|
raise ContinuityError("TASK_TRANSFER_STALE_PACKAGE", "session package identity or digest changed")
|
|
373
387
|
if args.expected_generation != record["generation"]:
|
|
374
388
|
raise ContinuityError("TASK_TRANSFER_STALE_GENERATION", f"expected generation {args.expected_generation}, current generation is {record['generation']}")
|
|
375
|
-
|
|
389
|
+
current_digest = (
|
|
390
|
+
_current_projection_digest(root, task_dir, data, task_id)
|
|
391
|
+
if operation != "release" else record["continuity_digest"]
|
|
392
|
+
)
|
|
393
|
+
if args.expected_continuity_digest is not None:
|
|
394
|
+
if not PACKAGE_DIGEST_RE.fullmatch(args.expected_continuity_digest):
|
|
395
|
+
raise ContinuityError("TASK_TRANSFER_INVALID_INPUT", "expected continuity digest must be a SHA-256 digest")
|
|
396
|
+
if args.expected_continuity_digest != current_digest:
|
|
397
|
+
raise ContinuityError("TASK_TRANSFER_STALE_CONTINUITY", "current task projection differs from the explicitly checked snapshot")
|
|
398
|
+
# Recovery acknowledges a live target snapshot without rebasing the source package.
|
|
399
|
+
recovered_claim = (
|
|
400
|
+
operation == "claim" and record["status"] == "sealed"
|
|
401
|
+
and args.expected_continuity_digest == current_digest
|
|
402
|
+
)
|
|
403
|
+
if operation not in {"release", "recover"} and current_digest != record.get("target_continuity_digest", record["continuity_digest"]) and not recovered_claim:
|
|
376
404
|
raise ContinuityError("TASK_TRANSFER_STALE_CONTINUITY", "task continuity changed during ownership transfer")
|
|
377
405
|
if operation != "release" and _route_snapshot(data) != record["route"]:
|
|
378
406
|
raise ContinuityError("TASK_TRANSFER_STALE_ROUTE", "task phase, route, input, or implementation authorization changed")
|
|
379
407
|
context_key, current_path = _direct_pointer(root)
|
|
408
|
+
if operation == "recover":
|
|
409
|
+
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:
|
|
410
|
+
raise ContinuityError("TASK_TRANSFER_INVALID_TRANSITION", "explicit recovery requires a pending target, a reason, and the current projection digest")
|
|
411
|
+
if current_path not in {None, record["task_path"]}:
|
|
412
|
+
raise ContinuityError("TASK_TRANSFER_WITHHELD", "recovery session already has another active task")
|
|
413
|
+
from .task_workflow import _assert_delegated_resources_closed
|
|
414
|
+
try:
|
|
415
|
+
_assert_delegated_resources_closed(task_dir, data)
|
|
416
|
+
except TaskWorkflowError as error:
|
|
417
|
+
raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", str(error)) from error
|
|
418
|
+
teammates_dir = task_dir / "teammates"
|
|
419
|
+
if any(teammates_dir.rglob("queue.json")) or any(teammates_dir.rglob("claim.json")):
|
|
420
|
+
raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "managed teamwork runtime artifacts remain; reconcile the original batch first")
|
|
421
|
+
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})
|
|
422
|
+
record.update({"status": "recovery_required", "generation": record["generation"] + 1, "target_continuity_digest": current_digest})
|
|
423
|
+
if not write_json(path, record):
|
|
424
|
+
raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not fence the previous generation before recovery")
|
|
425
|
+
for session in {record["source_session_id"], record["target_session_id"]} - {None}:
|
|
426
|
+
if not clear_active_task_if_matches(root, session, record["task_path"]):
|
|
427
|
+
raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "a prior session pointer changed; preserve the current generation")
|
|
428
|
+
record.update({"status": "ready", "target_session_id": None})
|
|
429
|
+
if not write_json(path, record):
|
|
430
|
+
raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not persist explicit recovery")
|
|
431
|
+
print(json.dumps(record, ensure_ascii=False, sort_keys=True))
|
|
432
|
+
return 0
|
|
380
433
|
if operation in {"seal", "retire"}:
|
|
381
434
|
if context_key != record["source_session_id"] or current_path not in {record["task_path"], None}:
|
|
382
435
|
raise ContinuityError("TASK_TRANSFER_WITHHELD", "source session no longer owns the current task")
|
|
@@ -412,13 +465,15 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
|
|
|
412
465
|
write_json(path, record)
|
|
413
466
|
raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "source pointer remains after retirement")
|
|
414
467
|
record["status"] = "ready"
|
|
415
|
-
elif operation == "claim" and record["status"] in {"ready", "claiming", "recovery_required", "claimed"}:
|
|
416
|
-
if record["status"]
|
|
468
|
+
elif operation == "claim" and (record["status"] in {"ready", "claiming", "recovery_required", "claimed"} or recovered_claim):
|
|
469
|
+
if record["status"] in {"claiming", "recovery_required"} and record["target_session_id"] != context_key:
|
|
417
470
|
raise ContinuityError("TASK_TRANSFER_WITHHELD", "only the recorded target session can recover a claim")
|
|
418
471
|
if current_path not in {None, record["task_path"]}:
|
|
419
472
|
raise ContinuityError("TASK_TRANSFER_WITHHELD", "target session already has a current task")
|
|
420
|
-
if record["status"]
|
|
473
|
+
if record["status"] in {"ready", "sealed"}:
|
|
421
474
|
record.update({"status": "claiming", "generation": record["generation"] + 1, "target_session_id": context_key})
|
|
475
|
+
if recovered_claim:
|
|
476
|
+
record["target_continuity_digest"] = current_digest
|
|
422
477
|
if not write_json(path, record):
|
|
423
478
|
raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not record target claim")
|
|
424
479
|
elif record["status"] == "claimed":
|
|
@@ -430,6 +485,10 @@ def transfer_task_ownership(args: argparse.Namespace) -> int:
|
|
|
430
485
|
record.update({"status": "claiming", "generation": record["generation"] + 1, "target_session_id": context_key})
|
|
431
486
|
if not write_json(path, record):
|
|
432
487
|
raise ContinuityError("TASK_TRANSFER_WRITE_FAILED", "could not record target takeover")
|
|
488
|
+
if not clear_active_task_if_matches(root, record["source_session_id"], record["task_path"]):
|
|
489
|
+
record["status"] = "recovery_required"
|
|
490
|
+
write_json(path, record)
|
|
491
|
+
raise ContinuityError("TASK_TRANSFER_RECOVERY_REQUIRED", "source pointer could not be retired during claim")
|
|
433
492
|
if current_path is None and not compare_and_set_active_task(root, context_key, None, record["task_path"]):
|
|
434
493
|
record["status"] = "recovery_required"
|
|
435
494
|
write_json(path, record)
|
|
@@ -463,13 +522,15 @@ def add_continuity_commands(subparsers) -> None:
|
|
|
463
522
|
capture.add_argument("--expected-source-digest", required=True, help="Current captured digest, or absent")
|
|
464
523
|
|
|
465
524
|
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"))
|
|
525
|
+
transfer.add_argument("transfer_action", choices=("status", "quiesce", "seal", "retire", "claim", "consume", "release", "recover"))
|
|
526
|
+
transfer.add_argument("--reason", help="Explicit recovery reason")
|
|
467
527
|
transfer.add_argument("--task-id", required=True)
|
|
468
528
|
transfer.add_argument("--task-path", required=True)
|
|
469
|
-
transfer.add_argument("--package-id",
|
|
470
|
-
transfer.add_argument("--package-digest",
|
|
471
|
-
transfer.add_argument("--expected-continuity-digest")
|
|
529
|
+
transfer.add_argument("--package-id", help="Required with --package-digest for mutations or package-bound status")
|
|
530
|
+
transfer.add_argument("--package-digest", help="Required with --package-id for mutations or package-bound status")
|
|
531
|
+
transfer.add_argument("--expected-continuity-digest", help="Expected live projection for sealed-source claim recovery")
|
|
472
532
|
transfer.add_argument("--expected-generation", type=int)
|
|
533
|
+
transfer.add_argument("--include-current", action="store_true", help="Include the live task projection and its digest in transfer status")
|
|
473
534
|
transfer.add_argument("--explicit-user-request", action="store_true")
|
|
474
535
|
|
|
475
536
|
|
|
@@ -489,35 +550,39 @@ def clear_task_runtime_records(repo_root: Path, task_path: str) -> bool:
|
|
|
489
550
|
@contextlib.contextmanager
|
|
490
551
|
def active_task_change_guard(repo_root: Path, context_key: str, task_path: str, *, clearing: bool):
|
|
491
552
|
"""Serialize pointer writes with ownership changes for the same task."""
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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:
|
|
553
|
+
from .task_move import layout_lock, require_settled_layout
|
|
554
|
+
|
|
555
|
+
with layout_lock(repo_root):
|
|
556
|
+
require_settled_layout(repo_root)
|
|
557
|
+
directory = resolve_task_dir(task_path, repo_root)
|
|
558
|
+
if directory is None:
|
|
510
559
|
yield False
|
|
511
560
|
return
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
561
|
+
task_path = directory.relative_to(repo_root).as_posix()
|
|
562
|
+
path = _ownership_path(repo_root, task_path)
|
|
563
|
+
with _record_lock(path):
|
|
564
|
+
try:
|
|
565
|
+
record = _load_owner(path)
|
|
566
|
+
except ContinuityError:
|
|
567
|
+
yield False
|
|
568
|
+
return
|
|
569
|
+
if record is None:
|
|
570
|
+
yield True
|
|
571
|
+
return
|
|
572
|
+
data, _ = read_json_checked(directory / FILE_TASK_JSON)
|
|
573
|
+
task_id = (data.get("id") or data.get("name")) if isinstance(data, dict) else None
|
|
574
|
+
if task_id != record["task_id"] or record["task_path"] != task_path:
|
|
575
|
+
yield False
|
|
576
|
+
return
|
|
577
|
+
if clearing:
|
|
578
|
+
allowed = record["status"] == "consumed" and record["target_session_id"] == context_key
|
|
579
|
+
elif record["status"] == "ready":
|
|
580
|
+
allowed = context_key != record["source_session_id"] and task_path == record["task_path"]
|
|
581
|
+
elif record["status"] == "consumed":
|
|
582
|
+
allowed = context_key == record["target_session_id"] and task_path == record["task_path"]
|
|
583
|
+
else:
|
|
584
|
+
allowed = record["status"] in {"claiming", "claimed"} and context_key == record["target_session_id"] and task_path == record["task_path"]
|
|
585
|
+
yield allowed
|
|
521
586
|
|
|
522
587
|
|
|
523
588
|
def assert_task_mutation_allowed(repo_root: Path, task_dir: Path, data: dict) -> None:
|
|
@@ -543,21 +608,25 @@ def assert_task_identity_change_allowed(repo_root: Path, task_dir: Path, data: d
|
|
|
543
608
|
@contextlib.contextmanager
|
|
544
609
|
def task_mutation_guard(repo_root: Path, task_dir: Path, data: dict):
|
|
545
610
|
"""Hold the ownership lock across a single task mutation."""
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
with
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
611
|
+
from .task_move import layout_lock, require_settled_layout
|
|
612
|
+
|
|
613
|
+
with layout_lock(repo_root):
|
|
614
|
+
require_settled_layout(repo_root)
|
|
615
|
+
task_path = task_dir.relative_to(repo_root).as_posix()
|
|
616
|
+
path = _ownership_path(repo_root, task_path)
|
|
617
|
+
with _record_lock(path):
|
|
618
|
+
try:
|
|
619
|
+
record = _load_owner(path)
|
|
620
|
+
except ContinuityError:
|
|
621
|
+
yield False
|
|
622
|
+
return
|
|
623
|
+
if record is None:
|
|
624
|
+
yield True
|
|
625
|
+
return
|
|
626
|
+
task_id = (data.get("id") or data.get("name")) if isinstance(data, dict) else None
|
|
627
|
+
if task_id != record["task_id"] or record["task_path"] != task_path:
|
|
628
|
+
yield False
|
|
629
|
+
return
|
|
630
|
+
active = resolve_active_task(repo_root)
|
|
631
|
+
allowed = record["status"] == "consumed" and active.context_key == record["target_session_id"] and active.task_path == task_path
|
|
632
|
+
yield allowed
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Task status directories and bounded discovery (never recurse into task content)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
STATUSES = ("planning", "in_progress", "completed", "cancelled")
|
|
11
|
+
MONTH_RE = re.compile(r"^\d{4}-(?:0[1-9]|1[0-2])$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def task_location(tasks_dir: Path, name: str, status: str, month: str | None = None) -> Path:
|
|
15
|
+
if not name or name in {".", "..", "archive", *STATUSES} or "/" in name or "\\" in name:
|
|
16
|
+
raise ValueError(f"Invalid task directory name: {name}")
|
|
17
|
+
if status not in STATUSES:
|
|
18
|
+
raise ValueError(f"Invalid task status: {status}")
|
|
19
|
+
if status == "completed":
|
|
20
|
+
month = month or date.today().strftime("%Y-%m")
|
|
21
|
+
if not MONTH_RE.fullmatch(month):
|
|
22
|
+
raise ValueError(f"Invalid completion month: {month}")
|
|
23
|
+
return tasks_dir / status / month / name
|
|
24
|
+
return tasks_dir / status / name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def task_status_dir(task_dir: Path, tasks_dir: Path) -> str | None:
|
|
28
|
+
"""Validate exact depth, containment and nested symlinks; allow a shared tasks root."""
|
|
29
|
+
try:
|
|
30
|
+
relative = task_dir.absolute().relative_to(tasks_dir.absolute())
|
|
31
|
+
parts = relative.parts
|
|
32
|
+
valid = (len(parts) == 2 and parts[0] in {"planning", "in_progress", "cancelled"}) or (
|
|
33
|
+
len(parts) == 3 and parts[0] == "completed" and MONTH_RE.fullmatch(parts[1])
|
|
34
|
+
)
|
|
35
|
+
if not valid:
|
|
36
|
+
return None
|
|
37
|
+
current = tasks_dir
|
|
38
|
+
for part in parts:
|
|
39
|
+
current /= part
|
|
40
|
+
if current.is_symlink():
|
|
41
|
+
return None
|
|
42
|
+
task_dir.resolve().relative_to(tasks_dir.resolve())
|
|
43
|
+
return parts[0]
|
|
44
|
+
except (ValueError, OSError, RuntimeError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def iter_task_dirs(tasks_dir: Path, *, legacy: bool = False) -> Iterator[Path]:
|
|
49
|
+
"""Enumerate only the prescribed levels; old layout is migration input only."""
|
|
50
|
+
if not tasks_dir.is_dir():
|
|
51
|
+
return
|
|
52
|
+
candidates: list[Path] = []
|
|
53
|
+
for entry in sorted(tasks_dir.iterdir()):
|
|
54
|
+
if entry.is_symlink():
|
|
55
|
+
raise ValueError(f"Symlink in task layout: {entry}")
|
|
56
|
+
if not entry.is_dir():
|
|
57
|
+
continue
|
|
58
|
+
if entry.name in STATUSES:
|
|
59
|
+
if entry.name == "completed":
|
|
60
|
+
for month in sorted(entry.iterdir()):
|
|
61
|
+
if month.is_symlink() or not month.is_dir() or not MONTH_RE.fullmatch(month.name):
|
|
62
|
+
raise ValueError(f"Invalid completion month directory: {month}")
|
|
63
|
+
candidates.extend(month.iterdir())
|
|
64
|
+
else:
|
|
65
|
+
candidates.extend(entry.iterdir())
|
|
66
|
+
elif legacy and entry.name == "archive":
|
|
67
|
+
for month in sorted(entry.iterdir()):
|
|
68
|
+
if month.is_symlink() or not month.is_dir() or not MONTH_RE.fullmatch(month.name):
|
|
69
|
+
raise ValueError(f"Invalid archive month directory: {month}")
|
|
70
|
+
candidates.extend(month.iterdir())
|
|
71
|
+
elif legacy:
|
|
72
|
+
candidates.append(entry)
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(f"Old task layout at {entry}; run task.py migrate-layout")
|
|
75
|
+
for candidate in sorted(candidates, key=lambda p: (p.name, str(p))):
|
|
76
|
+
if candidate.is_symlink():
|
|
77
|
+
raise ValueError(f"Symlink in task layout: {candidate}")
|
|
78
|
+
if not candidate.is_dir():
|
|
79
|
+
raise ValueError(f"Invalid task directory: {candidate}")
|
|
80
|
+
yield candidate
|