@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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Recoverable task relocation shared by lifecycle commands and explicit migration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .io import read_json_checked, write_json, write_text_atomic
|
|
13
|
+
from .paths import get_repo_root, get_tasks_dir
|
|
14
|
+
from .task_layout import MONTH_RE, iter_task_dirs, task_location, task_status_dir
|
|
15
|
+
|
|
16
|
+
_LOCK_DEPTH = 0
|
|
17
|
+
_APPLYING = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def journal_path(root: Path) -> Path:
|
|
21
|
+
return root / ".trellis/.runtime/task-layout-transaction.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_settled_layout(root: Path) -> None:
|
|
25
|
+
if not _APPLYING and journal_path(root).exists():
|
|
26
|
+
raise ValueError("Task relocation requires recovery: run task.py migrate-layout --recover")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextlib.contextmanager
|
|
30
|
+
def layout_lock(root: Path):
|
|
31
|
+
# Reentrant within one process; all task/pointer writers take this before record locks.
|
|
32
|
+
global _LOCK_DEPTH
|
|
33
|
+
from .task_continuity import _record_lock
|
|
34
|
+
|
|
35
|
+
if _LOCK_DEPTH:
|
|
36
|
+
_LOCK_DEPTH += 1
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
finally:
|
|
40
|
+
_LOCK_DEPTH -= 1
|
|
41
|
+
return
|
|
42
|
+
with _record_lock(root / ".trellis/.runtime/task-layout.json"):
|
|
43
|
+
_LOCK_DEPTH = 1
|
|
44
|
+
try:
|
|
45
|
+
yield
|
|
46
|
+
finally:
|
|
47
|
+
_LOCK_DEPTH = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read(path: Path) -> dict:
|
|
51
|
+
data, reason = read_json_checked(path)
|
|
52
|
+
if data is None:
|
|
53
|
+
raise ValueError(f"Cannot read {path}: {reason}")
|
|
54
|
+
return data
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _json(data: object) -> str:
|
|
58
|
+
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _rewrite(value, mapping: dict[str, str]):
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
for old, new in mapping.items():
|
|
64
|
+
if value == old or value.startswith(old + "/"):
|
|
65
|
+
return new + value[len(old):]
|
|
66
|
+
return value
|
|
67
|
+
if isinstance(value, list):
|
|
68
|
+
return [_rewrite(item, mapping) for item in value]
|
|
69
|
+
if isinstance(value, dict):
|
|
70
|
+
return {key: _rewrite(item, mapping) for key, item in value.items()}
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_path(root: Path, relative: str) -> Path:
|
|
75
|
+
path = root / relative
|
|
76
|
+
if not relative or Path(relative).is_absolute() or ".." in Path(relative).parts:
|
|
77
|
+
raise ValueError(f"Unsafe relocation path: {relative}")
|
|
78
|
+
# The tasks root may itself be shared; descendants must never be symlinks.
|
|
79
|
+
allowed = get_tasks_dir(root) if Path(relative).parts[:2] == (".trellis", "tasks") else root / ".trellis"
|
|
80
|
+
path.resolve().relative_to(allowed.resolve())
|
|
81
|
+
current = root / ".trellis"
|
|
82
|
+
for part in Path(relative).parts[1:]:
|
|
83
|
+
current /= part
|
|
84
|
+
if current != get_tasks_dir(root) and current.is_symlink():
|
|
85
|
+
raise ValueError(f"Symlink in relocation input: {current}")
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _plan(root: Path, moves: list[tuple[Path, Path]], updates: dict[Path, dict]) -> dict:
|
|
90
|
+
from .active_task import resolve_active_task
|
|
91
|
+
from .task_continuity import _load_owner, _ownership_path
|
|
92
|
+
|
|
93
|
+
mapping = {old.relative_to(root).as_posix(): new.relative_to(root).as_posix() for old, new in moves if old != new}
|
|
94
|
+
rewrites = {**mapping, **{str(root / old): str(root / new) for old, new in mapping.items()}}
|
|
95
|
+
destinations = list(mapping.values())
|
|
96
|
+
if len(set(destinations)) != len(destinations):
|
|
97
|
+
raise ValueError("Two tasks have the same destination; nothing moved")
|
|
98
|
+
for old, new in mapping.items():
|
|
99
|
+
source, target = _safe_path(root, old), _safe_path(root, new)
|
|
100
|
+
if not source.is_dir() or target.exists():
|
|
101
|
+
raise ValueError(f"Missing source or destination conflict: {old} -> {new}")
|
|
102
|
+
for path in source.rglob("*"):
|
|
103
|
+
if path.is_symlink():
|
|
104
|
+
raise ValueError(f"Symlink in task content: {path}")
|
|
105
|
+
if path.is_file():
|
|
106
|
+
path.read_bytes()
|
|
107
|
+
elif not path.is_dir():
|
|
108
|
+
raise ValueError(f"Unsupported task content: {path}")
|
|
109
|
+
data = _read(source / "task.json")
|
|
110
|
+
meta = data.get("meta")
|
|
111
|
+
delegated = meta.get("delegated_worktree_execution_record") if isinstance(meta, dict) else None
|
|
112
|
+
if delegated is not None:
|
|
113
|
+
from .task_workflow import _assert_delegated_record_closed
|
|
114
|
+
_assert_delegated_record_closed(source.name, delegated)
|
|
115
|
+
owner = _load_owner(_ownership_path(root, old))
|
|
116
|
+
if owner:
|
|
117
|
+
active = resolve_active_task(root)
|
|
118
|
+
if owner["task_id"] != (data.get("id") or data.get("name")) or owner["task_path"] != old or owner["status"] != "consumed" or active.context_key != owner["target_session_id"] or active.task_path != old:
|
|
119
|
+
raise ValueError(f"Task ownership prevents relocation: {old}")
|
|
120
|
+
|
|
121
|
+
before: dict[str, str | None] = {}
|
|
122
|
+
after: dict[str, str | None] = {}
|
|
123
|
+
|
|
124
|
+
def change(path: Path, text: str | None):
|
|
125
|
+
relative = path.relative_to(root).as_posix()
|
|
126
|
+
_safe_path(root, relative)
|
|
127
|
+
original = path.read_text(encoding="utf-8") if path.exists() else None
|
|
128
|
+
if original != text:
|
|
129
|
+
before[relative], after[relative] = original, text
|
|
130
|
+
|
|
131
|
+
directories = iter_task_dirs(get_tasks_dir(root), legacy=True) if mapping else iter(updates)
|
|
132
|
+
for directory in directories:
|
|
133
|
+
data = updates.get(directory, _read(directory / "task.json"))
|
|
134
|
+
replacement = _rewrite(data, rewrites)
|
|
135
|
+
if directory in updates or replacement != data:
|
|
136
|
+
change(directory / "task.json", _json(replacement))
|
|
137
|
+
for path in sorted(directory.glob("*.jsonl")):
|
|
138
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
139
|
+
changed = False
|
|
140
|
+
for index, line in enumerate(lines):
|
|
141
|
+
if not line.strip():
|
|
142
|
+
continue
|
|
143
|
+
record = json.loads(line)
|
|
144
|
+
updated = _rewrite(record, rewrites)
|
|
145
|
+
if updated != record:
|
|
146
|
+
lines[index] = json.dumps(updated, ensure_ascii=False) + "\n"
|
|
147
|
+
changed = True
|
|
148
|
+
if changed:
|
|
149
|
+
change(path, "".join(lines))
|
|
150
|
+
|
|
151
|
+
runtime = root / ".trellis/.runtime"
|
|
152
|
+
terminal = {directory.relative_to(root).as_posix() for directory, data in updates.items()
|
|
153
|
+
if data.get("status") in {"completed", "cancelled"}}
|
|
154
|
+
terminal_hashes = {hashlib.sha256(ref.encode()).hexdigest() for ref in terminal}
|
|
155
|
+
terminal_refs = terminal | {str(root / ref) for ref in terminal}
|
|
156
|
+
for folder in ("sessions", "continuity", "task-ownership"):
|
|
157
|
+
for path in sorted((runtime / folder).glob("*.json")):
|
|
158
|
+
record = _read(path)
|
|
159
|
+
if (folder == "sessions" and record.get("current_task") in terminal_refs) or (
|
|
160
|
+
folder != "sessions" and path.stem in terminal_hashes
|
|
161
|
+
):
|
|
162
|
+
change(path, None)
|
|
163
|
+
continue
|
|
164
|
+
updated = _rewrite(record, rewrites)
|
|
165
|
+
target = path
|
|
166
|
+
if folder != "sessions":
|
|
167
|
+
for old, new in mapping.items():
|
|
168
|
+
if path.stem == hashlib.sha256(old.encode()).hexdigest():
|
|
169
|
+
target = path.with_name(hashlib.sha256(new.encode()).hexdigest() + ".json")
|
|
170
|
+
if target.exists():
|
|
171
|
+
raise ValueError(f"Runtime destination conflict: {target}")
|
|
172
|
+
break
|
|
173
|
+
if target != path:
|
|
174
|
+
change(path, None)
|
|
175
|
+
change(target, _json(updated))
|
|
176
|
+
elif updated != record:
|
|
177
|
+
change(path, _json(updated))
|
|
178
|
+
return {"schema_version": 1, "state": "pending", "moves": mapping, "before": before, "after": after}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _write(path: Path, text: str | None) -> None:
|
|
182
|
+
if text is None:
|
|
183
|
+
path.unlink(missing_ok=True)
|
|
184
|
+
else:
|
|
185
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
if not write_text_atomic(path, text):
|
|
187
|
+
raise OSError(f"Cannot write {path}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def recover_layout(root: Path) -> None:
|
|
191
|
+
path = _safe_path(root, journal_path(root).relative_to(root).as_posix())
|
|
192
|
+
if not path.exists():
|
|
193
|
+
return
|
|
194
|
+
journal = _read(path)
|
|
195
|
+
if journal.get("schema_version") != 1 or journal.get("state") not in {"pending", "committed"}:
|
|
196
|
+
raise ValueError("Invalid task relocation recovery record")
|
|
197
|
+
if not all(isinstance(journal.get(key), dict) for key in ("moves", "before", "after")) or set(journal["before"]) != set(journal["after"]):
|
|
198
|
+
raise ValueError("Invalid task relocation recovery inputs")
|
|
199
|
+
def task_path(relative):
|
|
200
|
+
if not isinstance(relative, str):
|
|
201
|
+
raise ValueError("Invalid recovery task path")
|
|
202
|
+
candidate = _safe_path(root, relative)
|
|
203
|
+
parts = candidate.relative_to(get_tasks_dir(root)).parts
|
|
204
|
+
if not (len(parts) == 1 or task_status_dir(candidate, get_tasks_dir(root)) or (
|
|
205
|
+
len(parts) == 3 and parts[0] == "archive" and MONTH_RE.fullmatch(parts[1])
|
|
206
|
+
)):
|
|
207
|
+
raise ValueError(f"Invalid recovery task directory: {relative}")
|
|
208
|
+
task_location(get_tasks_dir(root), candidate.name, "planning")
|
|
209
|
+
return candidate
|
|
210
|
+
|
|
211
|
+
if not all(isinstance(old, str) and isinstance(new, str) for old, new in journal["moves"].items()):
|
|
212
|
+
raise ValueError("Invalid recovery moves")
|
|
213
|
+
sources, targets = set(journal["moves"]), set(journal["moves"].values())
|
|
214
|
+
if len(targets) != len(journal["moves"]) or sources & targets:
|
|
215
|
+
raise ValueError("Conflicting recovery moves")
|
|
216
|
+
for old, new in journal["moves"].items():
|
|
217
|
+
task_path(old)
|
|
218
|
+
if not task_status_dir(task_path(new), get_tasks_dir(root)):
|
|
219
|
+
raise ValueError(f"Invalid recovery destination: {new}")
|
|
220
|
+
for relative, original in journal["before"].items():
|
|
221
|
+
if not isinstance(relative, str) or any(value is not None and not isinstance(value, str)
|
|
222
|
+
for value in (original, journal["after"][relative])):
|
|
223
|
+
raise ValueError("Invalid recovery file input")
|
|
224
|
+
candidate = _safe_path(root, relative)
|
|
225
|
+
parts = Path(relative).parts
|
|
226
|
+
if parts[:2] == (".trellis", "tasks"):
|
|
227
|
+
task_path(candidate.parent.relative_to(root).as_posix())
|
|
228
|
+
if candidate.name != "task.json" and candidate.suffix != ".jsonl":
|
|
229
|
+
raise ValueError(f"Invalid recovery task file: {relative}")
|
|
230
|
+
elif not (len(parts) == 4 and parts[:2] == (".trellis", ".runtime")
|
|
231
|
+
and parts[2] in {"sessions", "continuity", "task-ownership"} and candidate.suffix == ".json"):
|
|
232
|
+
raise ValueError(f"Recovery file is outside the task transaction: {relative}")
|
|
233
|
+
if journal["state"] == "pending":
|
|
234
|
+
moved = {}
|
|
235
|
+
for old, new in journal["moves"].items():
|
|
236
|
+
source, target = root / old, root / new
|
|
237
|
+
if source.is_dir() == target.is_dir():
|
|
238
|
+
raise ValueError(f"Recovery conflict: {old} / {new}; preserve both for inspection")
|
|
239
|
+
if target.exists():
|
|
240
|
+
moved[old] = new
|
|
241
|
+
for relative, original in journal["before"].items():
|
|
242
|
+
current_relative = _rewrite(relative, moved)
|
|
243
|
+
if not isinstance(current_relative, str):
|
|
244
|
+
raise ValueError("Invalid recovery file path")
|
|
245
|
+
current_path = root / current_relative
|
|
246
|
+
current = current_path.read_text(encoding="utf-8") if current_path.exists() else None
|
|
247
|
+
if current not in (original, journal["after"][relative]):
|
|
248
|
+
raise ValueError(f"Recovery input changed: {relative}; no overwrite")
|
|
249
|
+
for old, new in reversed(list(moved.items())):
|
|
250
|
+
source = root / old
|
|
251
|
+
source.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
(root / new).rename(source)
|
|
253
|
+
for relative, original in journal["before"].items():
|
|
254
|
+
_write(root / relative, original)
|
|
255
|
+
path.unlink()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def apply_plan(root: Path, plan: dict) -> None:
|
|
259
|
+
global _APPLYING
|
|
260
|
+
require_settled_layout(root)
|
|
261
|
+
if not plan["moves"] and not plan["before"]:
|
|
262
|
+
return
|
|
263
|
+
transaction = _safe_path(root, journal_path(root).relative_to(root).as_posix())
|
|
264
|
+
transaction.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
if not write_json(transaction, plan):
|
|
266
|
+
raise OSError("Cannot save task relocation recovery record")
|
|
267
|
+
_APPLYING = True
|
|
268
|
+
try:
|
|
269
|
+
for relative, text in plan["after"].items():
|
|
270
|
+
_write(root / relative, text)
|
|
271
|
+
for old, new in plan["moves"].items():
|
|
272
|
+
target = root / new
|
|
273
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
(root / old).rename(target)
|
|
275
|
+
plan["state"] = "committed"
|
|
276
|
+
if not write_json(journal_path(root), plan):
|
|
277
|
+
raise OSError("Cannot commit task relocation")
|
|
278
|
+
journal_path(root).unlink()
|
|
279
|
+
except BaseException:
|
|
280
|
+
recover_layout(root)
|
|
281
|
+
raise
|
|
282
|
+
finally:
|
|
283
|
+
_APPLYING = False
|
|
284
|
+
archive = get_tasks_dir(root) / "archive"
|
|
285
|
+
if archive.is_dir():
|
|
286
|
+
for month in sorted(archive.iterdir()):
|
|
287
|
+
if month.is_dir() and not any(month.iterdir()):
|
|
288
|
+
month.rmdir()
|
|
289
|
+
if not any(archive.iterdir()):
|
|
290
|
+
archive.rmdir()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def relocate_task(root: Path, directory: Path, data: dict) -> Path:
|
|
294
|
+
"""Change lifecycle status and location together; do not grant workflow authorization."""
|
|
295
|
+
destination = task_location(get_tasks_dir(root), directory.name, data["status"])
|
|
296
|
+
if data["status"] == "completed" and task_status_dir(directory, get_tasks_dir(root)) == "completed":
|
|
297
|
+
destination = directory
|
|
298
|
+
with layout_lock(root):
|
|
299
|
+
require_settled_layout(root)
|
|
300
|
+
plan = _plan(root, [(directory, destination)], {directory: data})
|
|
301
|
+
apply_plan(root, plan)
|
|
302
|
+
return destination
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cmd_migrate_layout(args: argparse.Namespace) -> int:
|
|
306
|
+
root = get_repo_root()
|
|
307
|
+
try:
|
|
308
|
+
with layout_lock(root):
|
|
309
|
+
if args.recover:
|
|
310
|
+
recover_layout(root)
|
|
311
|
+
print(json.dumps({"status": "recovered"}))
|
|
312
|
+
return 0
|
|
313
|
+
require_settled_layout(root)
|
|
314
|
+
tasks = get_tasks_dir(root)
|
|
315
|
+
moves: list[tuple[Path, Path]] = []
|
|
316
|
+
names: set[str] = set()
|
|
317
|
+
for directory in iter_task_dirs(tasks, legacy=True):
|
|
318
|
+
data = _read(directory / "task.json")
|
|
319
|
+
if directory.name in names:
|
|
320
|
+
raise ValueError(f"Duplicate task directory name: {directory.name}")
|
|
321
|
+
names.add(directory.name)
|
|
322
|
+
status = data.get("status")
|
|
323
|
+
if not isinstance(status, str):
|
|
324
|
+
raise ValueError(f"Task status is missing: {directory}")
|
|
325
|
+
month = None
|
|
326
|
+
if status == "completed":
|
|
327
|
+
relative = directory.relative_to(tasks).parts
|
|
328
|
+
if len(relative) == 3 and relative[0] in {"archive", "completed"} and MONTH_RE.fullmatch(relative[1]):
|
|
329
|
+
month = relative[1]
|
|
330
|
+
elif isinstance(data.get("completedAt"), str) and MONTH_RE.fullmatch(data["completedAt"][:7]):
|
|
331
|
+
month = data["completedAt"][:7]
|
|
332
|
+
else:
|
|
333
|
+
raise ValueError(f"Completion month is missing: {directory}")
|
|
334
|
+
target = task_location(tasks, directory.name, status, month)
|
|
335
|
+
if directory != target:
|
|
336
|
+
moves.append((directory, target))
|
|
337
|
+
plan = _plan(root, moves, {})
|
|
338
|
+
result = {"status": "applied" if args.apply else "preview", "moves": plan["moves"], "changed_files": sorted(plan["before"])}
|
|
339
|
+
if args.apply:
|
|
340
|
+
apply_plan(root, plan)
|
|
341
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
342
|
+
return 0
|
|
343
|
+
except (ValueError, OSError, RuntimeError) as error:
|
|
344
|
+
print(f"TASK_LAYOUT_WITHHELD: {error}", file=sys.stderr)
|
|
345
|
+
return 1
|
|
@@ -17,6 +17,7 @@ Provides:
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import argparse
|
|
20
|
+
import json
|
|
20
21
|
import re
|
|
21
22
|
import sys
|
|
22
23
|
from dataclasses import dataclass, field
|
|
@@ -443,8 +444,14 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
443
444
|
return 1
|
|
444
445
|
|
|
445
446
|
dir_name = f"{date_prefix}-{slug}"
|
|
446
|
-
|
|
447
|
+
from .task_layout import task_location
|
|
448
|
+
task_dir = task_location(tasks_dir, dir_name, "planning")
|
|
447
449
|
task_json_path = task_dir / FILE_TASK_JSON
|
|
450
|
+
from .task_layout import iter_task_dirs
|
|
451
|
+
conflicts = [directory for directory in iter_task_dirs(tasks_dir) if directory.name == dir_name and directory != task_dir]
|
|
452
|
+
if conflicts:
|
|
453
|
+
print(f"Error: Task already exists outside planning: {dir_name}; use its lifecycle commands", file=sys.stderr)
|
|
454
|
+
return 1
|
|
448
455
|
|
|
449
456
|
# Reusing a slug on the same day is an ordinary accident, and continuing
|
|
450
457
|
# would rewrite the existing task.json below — resetting status, children,
|
|
@@ -691,7 +698,7 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
691
698
|
print("", file=sys.stderr)
|
|
692
699
|
|
|
693
700
|
# Output relative path for script chaining
|
|
694
|
-
print(
|
|
701
|
+
print(task_dir.relative_to(repo_root).as_posix())
|
|
695
702
|
|
|
696
703
|
run_task_hooks("after_create", task_json_path, repo_root)
|
|
697
704
|
return 0
|
|
@@ -841,7 +848,8 @@ def _plan_backrefs(
|
|
|
841
848
|
changes: list[tuple[Path, dict, list[str]]] = []
|
|
842
849
|
unreadable: list[Path] = []
|
|
843
850
|
|
|
844
|
-
|
|
851
|
+
from .task_layout import iter_task_dirs
|
|
852
|
+
for candidate in iter_task_dirs(tasks_dir):
|
|
845
853
|
if not candidate.is_dir():
|
|
846
854
|
continue
|
|
847
855
|
if candidate == task_dir:
|
|
@@ -964,62 +972,27 @@ def _rename_interrupted(plan: _RenamePlan) -> None:
|
|
|
964
972
|
|
|
965
973
|
|
|
966
974
|
def _apply_rename(plan: _RenamePlan, repo_root: Path) -> int:
|
|
967
|
-
"""
|
|
975
|
+
"""Apply rename through the same recoverable transaction as status moves."""
|
|
976
|
+
from .task_move import _plan, apply_plan
|
|
968
977
|
|
|
969
|
-
The directory move goes last on purpose. Everything before it is an
|
|
970
|
-
in-place edit that re-running the identical command recomputes as
|
|
971
|
-
already-done, so an interruption leaves a tree that the same command
|
|
972
|
-
finishes; a move-first ordering would instead leave the old name
|
|
973
|
-
unresolvable and the remaining edits to be made by hand.
|
|
974
|
-
"""
|
|
975
978
|
if not _task_mutation_allowed(repo_root, plan.task_dir, plan.task_data):
|
|
976
979
|
return 1
|
|
977
980
|
if plan.identity and not _task_identity_change_allowed(repo_root, plan.task_dir, plan.task_data):
|
|
978
981
|
return 1
|
|
979
|
-
if plan.
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
for jsonl_path, _linenos, text in plan.jsonl:
|
|
989
|
-
try:
|
|
990
|
-
jsonl_path.write_text(text, encoding="utf-8")
|
|
991
|
-
except OSError as exc:
|
|
992
|
-
print(
|
|
993
|
-
colored(f"Error: Failed to write {jsonl_path}: {exc}", Colors.RED),
|
|
994
|
-
file=sys.stderr,
|
|
995
|
-
)
|
|
996
|
-
_rename_interrupted(plan)
|
|
997
|
-
return 1
|
|
998
|
-
|
|
999
|
-
for json_path, data, _labels in plan.backrefs:
|
|
1000
|
-
if not write_json(json_path, data):
|
|
1001
|
-
_report_write_failure(json_path)
|
|
1002
|
-
_rename_interrupted(plan)
|
|
1003
|
-
return 1
|
|
1004
|
-
|
|
982
|
+
if plan.unreadable:
|
|
983
|
+
print("Error: unreadable task back-references; nothing renamed", file=sys.stderr)
|
|
984
|
+
return 1
|
|
985
|
+
data = dict(plan.task_data)
|
|
986
|
+
for field_name, _old_value, new_value in plan.identity:
|
|
987
|
+
data[field_name] = new_value
|
|
988
|
+
updates = {plan.task_dir: data}
|
|
989
|
+
updates.update({path.parent: value for path, value, _labels in plan.backrefs})
|
|
1005
990
|
try:
|
|
1006
|
-
plan.task_dir
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
file=sys.stderr,
|
|
1011
|
-
)
|
|
1012
|
-
_rename_interrupted(plan)
|
|
991
|
+
transaction = _plan(repo_root, [(plan.task_dir, plan.new_dir)], updates)
|
|
992
|
+
apply_plan(repo_root, transaction)
|
|
993
|
+
except (OSError, ValueError) as error:
|
|
994
|
+
print(f"Error: rename withheld: {error}", file=sys.stderr)
|
|
1013
995
|
return 1
|
|
1014
|
-
|
|
1015
|
-
# Last, and after the move: a session pointing at the old path would now
|
|
1016
|
-
# resolve to a missing directory, so `current` would report the task stale
|
|
1017
|
-
# and the context hook would inject nothing until the user ran `start`
|
|
1018
|
-
# again. Rename moves the pointers because the task is still being worked on.
|
|
1019
|
-
from .active_task import repoint_task_in_sessions
|
|
1020
|
-
|
|
1021
|
-
repoint_task_in_sessions(str(plan.task_dir), str(plan.new_dir), repo_root)
|
|
1022
|
-
|
|
1023
996
|
return 0
|
|
1024
997
|
|
|
1025
998
|
|
|
@@ -1034,12 +1007,12 @@ def cmd_rename(args: argparse.Namespace) -> int:
|
|
|
1034
1007
|
print(colored(f"Error: Task not found: {args.name}", Colors.RED), file=sys.stderr)
|
|
1035
1008
|
return 1
|
|
1036
1009
|
|
|
1037
|
-
# Restrict renames to
|
|
1010
|
+
# Restrict renames to the prescribed status directory levels.
|
|
1038
1011
|
if not is_within_tasks_dir(task_dir, repo_root):
|
|
1039
1012
|
print(
|
|
1040
1013
|
colored(
|
|
1041
1014
|
f"Error: refusing to rename '{args.name}': "
|
|
1042
|
-
f"{task_dir} is not a task
|
|
1015
|
+
f"{task_dir} is not a task in the status layout under {tasks_dir}",
|
|
1043
1016
|
Colors.RED,
|
|
1044
1017
|
),
|
|
1045
1018
|
file=sys.stderr,
|
|
@@ -1062,7 +1035,8 @@ def cmd_rename(args: argparse.Namespace) -> int:
|
|
|
1062
1035
|
return 1
|
|
1063
1036
|
|
|
1064
1037
|
new_dir = task_dir.parent / new_name
|
|
1065
|
-
|
|
1038
|
+
from .task_layout import iter_task_dirs
|
|
1039
|
+
if new_dir.exists() or any(directory.name == new_name for directory in iter_task_dirs(tasks_dir)):
|
|
1066
1040
|
print(
|
|
1067
1041
|
colored(f"Error: Task already exists: {new_name}", Colors.RED),
|
|
1068
1042
|
file=sys.stderr,
|
|
@@ -1271,6 +1245,10 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
|
1271
1245
|
print("Error: A cancelled parent can unlink only a child recorded as independently continuing in its accepted closeout.", file=sys.stderr)
|
|
1272
1246
|
return 1
|
|
1273
1247
|
|
|
1248
|
+
if parent_data.get("status") != "cancelled":
|
|
1249
|
+
from .task_workflow import _assert_delegated_record_closed
|
|
1250
|
+
_assert_delegated_record_closed(child_dir.name, child_data.get("meta", {}).get("delegated_worktree_execution_record"))
|
|
1251
|
+
original_parent = json.loads(json.dumps(parent_data))
|
|
1274
1252
|
# Remove child from parent's children list
|
|
1275
1253
|
parent_children = _ensure_children_list(parent_data)
|
|
1276
1254
|
child_dir_name = child_dir.name
|
|
@@ -1290,8 +1268,8 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
|
1290
1268
|
if not write_json(child_json_path, child_data):
|
|
1291
1269
|
print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr)
|
|
1292
1270
|
print(
|
|
1293
|
-
|
|
1294
|
-
f"
|
|
1271
|
+
"Parent link restored." if write_json(parent_json_path, original_parent) else
|
|
1272
|
+
f"Recovery required: could not restore the parent link in {parent_json_path}.",
|
|
1295
1273
|
file=sys.stderr,
|
|
1296
1274
|
)
|
|
1297
1275
|
return 1
|
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from typing import TYPE_CHECKING
|
|
17
17
|
|
|
18
18
|
from .paths import get_repo_root, get_tasks_dir
|
|
19
|
+
from .task_layout import iter_task_dirs, task_status_dir
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
22
|
import subprocess
|
|
@@ -28,20 +29,14 @@ if TYPE_CHECKING:
|
|
|
28
29
|
def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool:
|
|
29
30
|
"""Check that a resolved task directory really is a task under the tasks dir.
|
|
30
31
|
|
|
31
|
-
A real task
|
|
32
|
-
only when ``task_dir_abs`` is an immediate child of the tasks directory.
|
|
33
|
-
|
|
34
|
-
The task must be a direct child of the tasks directory, not the directory
|
|
35
|
-
itself or a nested path.
|
|
32
|
+
A real task occupies one prescribed status directory, never nested content.
|
|
36
33
|
"""
|
|
37
34
|
if repo_root is None:
|
|
38
35
|
repo_root = get_repo_root()
|
|
39
36
|
try:
|
|
40
|
-
|
|
41
|
-
tasks_resolved = get_tasks_dir(repo_root).resolve()
|
|
37
|
+
return task_status_dir(task_dir_abs, get_tasks_dir(repo_root)) is not None
|
|
42
38
|
except (OSError, RuntimeError):
|
|
43
39
|
return False
|
|
44
|
-
return resolved.parent == tasks_resolved and resolved.name != "archive"
|
|
45
40
|
|
|
46
41
|
|
|
47
42
|
# =============================================================================
|
|
@@ -74,22 +69,19 @@ def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
|
|
74
69
|
print(f"Error: invalid task name: {task_name}", file=sys.stderr)
|
|
75
70
|
return None
|
|
76
71
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
matches =
|
|
84
|
-
d for d in tasks_dir.iterdir()
|
|
85
|
-
if d.is_dir() and d.name.endswith(f"-{task_name}")
|
|
86
|
-
)
|
|
72
|
+
try:
|
|
73
|
+
candidates = list(iter_task_dirs(tasks_dir))
|
|
74
|
+
except (ValueError, OSError) as error:
|
|
75
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
76
|
+
return None
|
|
77
|
+
exact = [d for d in candidates if d.name == task_name]
|
|
78
|
+
matches = exact or [d for d in candidates if d.name.endswith(f"-{task_name}")]
|
|
87
79
|
if len(matches) == 1:
|
|
88
80
|
return matches[0]
|
|
89
81
|
if matches:
|
|
90
82
|
print(f"Error: ambiguous task name '{task_name}' matches:", file=sys.stderr)
|
|
91
83
|
for match in matches:
|
|
92
|
-
print(f" - {match.
|
|
84
|
+
print(f" - {match.relative_to(tasks_dir)}", file=sys.stderr)
|
|
93
85
|
print("Pass the full task directory name.", file=sys.stderr)
|
|
94
86
|
|
|
95
87
|
return None
|
|
@@ -128,6 +120,14 @@ def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
|
|
|
128
120
|
print("Error: task directory is required", file=sys.stderr)
|
|
129
121
|
return None
|
|
130
122
|
|
|
123
|
+
from .task_move import require_settled_layout
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
require_settled_layout(repo_root)
|
|
127
|
+
except ValueError as error:
|
|
128
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
131
|
normalized = target_dir.replace("\\", "/")
|
|
132
132
|
while normalized.startswith("./"):
|
|
133
133
|
normalized = normalized[2:]
|
|
@@ -175,6 +175,10 @@ def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
|
|
|
175
175
|
)
|
|
176
176
|
return None
|
|
177
177
|
|
|
178
|
+
if not is_within_tasks_dir(candidate, repo_root):
|
|
179
|
+
print(f"Error: invalid task layout: {target_dir}; run task.py migrate-layout for old tasks", file=sys.stderr)
|
|
180
|
+
return None
|
|
181
|
+
|
|
178
182
|
# Hand back the path spelled through the repo's own tasks dir rather than
|
|
179
183
|
# `resolved`: `.trellis` may be a symlink into a store outside the repo
|
|
180
184
|
# (#567), in which case `resolved` sits outside the repo even though the
|