@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Task status directories and bounded discovery (never recurse into task content)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
STATUSES = ("planning", "in_progress", "completed", "cancelled")
|
|
11
|
+
MONTH_RE = re.compile(r"^\d{4}-(?:0[1-9]|1[0-2])$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def task_location(tasks_dir: Path, name: str, status: str, month: str | None = None) -> Path:
|
|
15
|
+
if not name or name in {".", "..", "archive", *STATUSES} or "/" in name or "\\" in name:
|
|
16
|
+
raise ValueError(f"Invalid task directory name: {name}")
|
|
17
|
+
if status not in STATUSES:
|
|
18
|
+
raise ValueError(f"Invalid task status: {status}")
|
|
19
|
+
if status == "completed":
|
|
20
|
+
month = month or date.today().strftime("%Y-%m")
|
|
21
|
+
if not MONTH_RE.fullmatch(month):
|
|
22
|
+
raise ValueError(f"Invalid completion month: {month}")
|
|
23
|
+
return tasks_dir / status / month / name
|
|
24
|
+
return tasks_dir / status / name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def task_status_dir(task_dir: Path, tasks_dir: Path) -> str | None:
|
|
28
|
+
"""Validate exact depth, containment and nested symlinks; allow a shared tasks root."""
|
|
29
|
+
try:
|
|
30
|
+
relative = task_dir.absolute().relative_to(tasks_dir.absolute())
|
|
31
|
+
parts = relative.parts
|
|
32
|
+
valid = (len(parts) == 2 and parts[0] in {"planning", "in_progress", "cancelled"}) or (
|
|
33
|
+
len(parts) == 3 and parts[0] == "completed" and MONTH_RE.fullmatch(parts[1])
|
|
34
|
+
)
|
|
35
|
+
if not valid:
|
|
36
|
+
return None
|
|
37
|
+
current = tasks_dir
|
|
38
|
+
for part in parts:
|
|
39
|
+
current /= part
|
|
40
|
+
if current.is_symlink():
|
|
41
|
+
return None
|
|
42
|
+
task_dir.resolve().relative_to(tasks_dir.resolve())
|
|
43
|
+
return parts[0]
|
|
44
|
+
except (ValueError, OSError, RuntimeError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def iter_task_dirs(tasks_dir: Path, *, legacy: bool = False) -> Iterator[Path]:
|
|
49
|
+
"""Enumerate only the prescribed levels; old layout is migration input only."""
|
|
50
|
+
if not tasks_dir.is_dir():
|
|
51
|
+
return
|
|
52
|
+
candidates: list[Path] = []
|
|
53
|
+
for entry in sorted(tasks_dir.iterdir()):
|
|
54
|
+
if entry.is_symlink():
|
|
55
|
+
raise ValueError(f"Symlink in task layout: {entry}")
|
|
56
|
+
if not entry.is_dir():
|
|
57
|
+
continue
|
|
58
|
+
if entry.name in STATUSES:
|
|
59
|
+
if entry.name == "completed":
|
|
60
|
+
for month in sorted(entry.iterdir()):
|
|
61
|
+
if month.is_symlink() or not month.is_dir() or not MONTH_RE.fullmatch(month.name):
|
|
62
|
+
raise ValueError(f"Invalid completion month directory: {month}")
|
|
63
|
+
candidates.extend(month.iterdir())
|
|
64
|
+
else:
|
|
65
|
+
candidates.extend(entry.iterdir())
|
|
66
|
+
elif legacy and entry.name == "archive":
|
|
67
|
+
for month in sorted(entry.iterdir()):
|
|
68
|
+
if month.is_symlink() or not month.is_dir() or not MONTH_RE.fullmatch(month.name):
|
|
69
|
+
raise ValueError(f"Invalid archive month directory: {month}")
|
|
70
|
+
candidates.extend(month.iterdir())
|
|
71
|
+
elif legacy:
|
|
72
|
+
candidates.append(entry)
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(f"Old task layout at {entry}; run task.py migrate-layout")
|
|
75
|
+
for candidate in sorted(candidates, key=lambda p: (p.name, str(p))):
|
|
76
|
+
if candidate.is_symlink():
|
|
77
|
+
raise ValueError(f"Symlink in task layout: {candidate}")
|
|
78
|
+
if not candidate.is_dir():
|
|
79
|
+
raise ValueError(f"Invalid task directory: {candidate}")
|
|
80
|
+
yield candidate
|
|
@@ -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
|
|
@@ -54,6 +55,15 @@ from .task_utils import (
|
|
|
54
55
|
)
|
|
55
56
|
from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
|
|
56
57
|
|
|
58
|
+
RESERVED_META_KEYS = {
|
|
59
|
+
"task_workflow",
|
|
60
|
+
"delegation_authorizations",
|
|
61
|
+
"delegated_worktree_execution_history",
|
|
62
|
+
"delegated_worktree_execution_record",
|
|
63
|
+
"task_cancellation",
|
|
64
|
+
"task_lifecycle_recoveries",
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
|
|
58
68
|
# =============================================================================
|
|
59
69
|
# Helper Functions
|
|
@@ -247,6 +257,9 @@ def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None:
|
|
|
247
257
|
file=sys.stderr,
|
|
248
258
|
)
|
|
249
259
|
return None
|
|
260
|
+
if key in RESERVED_META_KEYS:
|
|
261
|
+
print(colored(f"Error: metadata key is managed by Trellis: {key}", Colors.RED), file=sys.stderr)
|
|
262
|
+
return None
|
|
250
263
|
meta[key] = value
|
|
251
264
|
return meta
|
|
252
265
|
|
|
@@ -443,8 +456,14 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
443
456
|
return 1
|
|
444
457
|
|
|
445
458
|
dir_name = f"{date_prefix}-{slug}"
|
|
446
|
-
|
|
459
|
+
from .task_layout import task_location
|
|
460
|
+
task_dir = task_location(tasks_dir, dir_name, "planning")
|
|
447
461
|
task_json_path = task_dir / FILE_TASK_JSON
|
|
462
|
+
from .task_layout import iter_task_dirs
|
|
463
|
+
conflicts = [directory for directory in iter_task_dirs(tasks_dir) if directory.name == dir_name and directory != task_dir]
|
|
464
|
+
if conflicts:
|
|
465
|
+
print(f"Error: Task already exists outside planning: {dir_name}; use its lifecycle commands", file=sys.stderr)
|
|
466
|
+
return 1
|
|
448
467
|
|
|
449
468
|
# Reusing a slug on the same day is an ordinary accident, and continuing
|
|
450
469
|
# would rewrite the existing task.json below — resetting status, children,
|
|
@@ -691,7 +710,7 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
691
710
|
print("", file=sys.stderr)
|
|
692
711
|
|
|
693
712
|
# Output relative path for script chaining
|
|
694
|
-
print(
|
|
713
|
+
print(task_dir.relative_to(repo_root).as_posix())
|
|
695
714
|
|
|
696
715
|
run_task_hooks("after_create", task_json_path, repo_root)
|
|
697
716
|
return 0
|
|
@@ -841,7 +860,8 @@ def _plan_backrefs(
|
|
|
841
860
|
changes: list[tuple[Path, dict, list[str]]] = []
|
|
842
861
|
unreadable: list[Path] = []
|
|
843
862
|
|
|
844
|
-
|
|
863
|
+
from .task_layout import iter_task_dirs
|
|
864
|
+
for candidate in iter_task_dirs(tasks_dir):
|
|
845
865
|
if not candidate.is_dir():
|
|
846
866
|
continue
|
|
847
867
|
if candidate == task_dir:
|
|
@@ -964,62 +984,27 @@ def _rename_interrupted(plan: _RenamePlan) -> None:
|
|
|
964
984
|
|
|
965
985
|
|
|
966
986
|
def _apply_rename(plan: _RenamePlan, repo_root: Path) -> int:
|
|
967
|
-
"""
|
|
987
|
+
"""Apply rename through the same recoverable transaction as status moves."""
|
|
988
|
+
from .task_move import _plan, apply_plan
|
|
968
989
|
|
|
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
990
|
if not _task_mutation_allowed(repo_root, plan.task_dir, plan.task_data):
|
|
976
991
|
return 1
|
|
977
992
|
if plan.identity and not _task_identity_change_allowed(repo_root, plan.task_dir, plan.task_data):
|
|
978
993
|
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
|
-
|
|
994
|
+
if plan.unreadable:
|
|
995
|
+
print("Error: unreadable task back-references; nothing renamed", file=sys.stderr)
|
|
996
|
+
return 1
|
|
997
|
+
data = dict(plan.task_data)
|
|
998
|
+
for field_name, _old_value, new_value in plan.identity:
|
|
999
|
+
data[field_name] = new_value
|
|
1000
|
+
updates = {plan.task_dir: data}
|
|
1001
|
+
updates.update({path.parent: value for path, value, _labels in plan.backrefs})
|
|
1005
1002
|
try:
|
|
1006
|
-
plan.task_dir
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
file=sys.stderr,
|
|
1011
|
-
)
|
|
1012
|
-
_rename_interrupted(plan)
|
|
1003
|
+
transaction = _plan(repo_root, [(plan.task_dir, plan.new_dir)], updates)
|
|
1004
|
+
apply_plan(repo_root, transaction)
|
|
1005
|
+
except (OSError, ValueError) as error:
|
|
1006
|
+
print(f"Error: rename withheld: {error}", file=sys.stderr)
|
|
1013
1007
|
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
1008
|
return 0
|
|
1024
1009
|
|
|
1025
1010
|
|
|
@@ -1034,12 +1019,12 @@ def cmd_rename(args: argparse.Namespace) -> int:
|
|
|
1034
1019
|
print(colored(f"Error: Task not found: {args.name}", Colors.RED), file=sys.stderr)
|
|
1035
1020
|
return 1
|
|
1036
1021
|
|
|
1037
|
-
# Restrict renames to
|
|
1022
|
+
# Restrict renames to the prescribed status directory levels.
|
|
1038
1023
|
if not is_within_tasks_dir(task_dir, repo_root):
|
|
1039
1024
|
print(
|
|
1040
1025
|
colored(
|
|
1041
1026
|
f"Error: refusing to rename '{args.name}': "
|
|
1042
|
-
f"{task_dir} is not a task
|
|
1027
|
+
f"{task_dir} is not a task in the status layout under {tasks_dir}",
|
|
1043
1028
|
Colors.RED,
|
|
1044
1029
|
),
|
|
1045
1030
|
file=sys.stderr,
|
|
@@ -1062,7 +1047,8 @@ def cmd_rename(args: argparse.Namespace) -> int:
|
|
|
1062
1047
|
return 1
|
|
1063
1048
|
|
|
1064
1049
|
new_dir = task_dir.parent / new_name
|
|
1065
|
-
|
|
1050
|
+
from .task_layout import iter_task_dirs
|
|
1051
|
+
if new_dir.exists() or any(directory.name == new_name for directory in iter_task_dirs(tasks_dir)):
|
|
1066
1052
|
print(
|
|
1067
1053
|
colored(f"Error: Task already exists: {new_name}", Colors.RED),
|
|
1068
1054
|
file=sys.stderr,
|
|
@@ -1271,6 +1257,10 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
|
1271
1257
|
print("Error: A cancelled parent can unlink only a child recorded as independently continuing in its accepted closeout.", file=sys.stderr)
|
|
1272
1258
|
return 1
|
|
1273
1259
|
|
|
1260
|
+
if parent_data.get("status") != "cancelled":
|
|
1261
|
+
from .task_workflow import _assert_delegated_record_closed
|
|
1262
|
+
_assert_delegated_record_closed(child_dir.name, child_data.get("meta", {}).get("delegated_worktree_execution_record"))
|
|
1263
|
+
original_parent = json.loads(json.dumps(parent_data))
|
|
1274
1264
|
# Remove child from parent's children list
|
|
1275
1265
|
parent_children = _ensure_children_list(parent_data)
|
|
1276
1266
|
child_dir_name = child_dir.name
|
|
@@ -1290,8 +1280,8 @@ def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
|
1290
1280
|
if not write_json(child_json_path, child_data):
|
|
1291
1281
|
print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr)
|
|
1292
1282
|
print(
|
|
1293
|
-
|
|
1294
|
-
f"
|
|
1283
|
+
"Parent link restored." if write_json(parent_json_path, original_parent) else
|
|
1284
|
+
f"Recovery required: could not restore the parent link in {parent_json_path}.",
|
|
1295
1285
|
file=sys.stderr,
|
|
1296
1286
|
)
|
|
1297
1287
|
return 1
|
|
@@ -1449,6 +1439,10 @@ def cmd_set_meta(args: argparse.Namespace) -> int:
|
|
|
1449
1439
|
key = args.key
|
|
1450
1440
|
value = args.value
|
|
1451
1441
|
|
|
1442
|
+
if key in RESERVED_META_KEYS:
|
|
1443
|
+
print(colored(f"Error: metadata key is managed by Trellis: {key}", Colors.RED), file=sys.stderr)
|
|
1444
|
+
return 1
|
|
1445
|
+
|
|
1452
1446
|
if not key:
|
|
1453
1447
|
print(colored("Error: Missing arguments", Colors.RED))
|
|
1454
1448
|
print("Usage: python3 task.py set-meta <task-dir> <key> <value>")
|