@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.
- 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 +42 -14
- package/dist/commands/teamwork/artifact.d.ts.map +1 -1
- package/dist/commands/teamwork/artifact.js +188 -85
- 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 +23 -0
- package/dist/commands/teamwork/batch.d.ts.map +1 -0
- package/dist/commands/teamwork/batch.js +415 -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 +33 -6
- package/dist/commands/teamwork/coordinator.d.ts.map +1 -1
- package/dist/commands/teamwork/coordinator.js +948 -342
- 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 +25 -7
- 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 +163 -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 +55 -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 +401 -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/migrations/manifests/0.7.2-beta.1.json +11 -0
- package/dist/migrations/manifests/0.7.2-beta.2.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 +58 -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/config.py +5 -0
- 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 +191 -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 +51 -57
- package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
- package/dist/templates/trellis/scripts/common/task_workflow.py +395 -49
- 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 +76 -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 +525 -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:
|
|
@@ -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
|
-
|
|
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)}")
|
|
@@ -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)
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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"]
|
|
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"]
|
|
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",
|
|
470
|
-
transfer.add_argument("--package-digest",
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
with
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|