@pennixrv/trellis 0.7.1-beta.5 → 0.7.1-beta.7
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/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +14 -6
- package/dist/commands/init.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/update.d.ts.map +1 -1
- package/dist/commands/update.js +21 -4
- package/dist/commands/update.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.6.json +10 -0
- package/dist/migrations/manifests/0.7.1-beta.7.json +8 -0
- package/dist/templates/claude/hooks/statusline.py +9 -4
- 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/task-system.md +8 -2
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +1 -1
- package/dist/templates/omp/extensions/trellis/index.ts.txt +1 -1
- package/dist/templates/opencode/lib/session-utils.js +301 -209
- package/dist/templates/snow/SNOW.md +1 -1
- package/dist/templates/trellis/agents/teammate.md +5 -5
- package/dist/templates/trellis/index.d.ts +2 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +4 -0
- 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 +53 -45
- 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 +28 -55
- package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
- package/dist/templates/trellis/scripts/common/task_workflow.py +28 -20
- 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 +41 -23
- package/dist/templates/trellis/workflow.md +4 -2
- package/package.json +2 -2
|
@@ -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
|
|
@@ -443,8 +443,14 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
443
443
|
return 1
|
|
444
444
|
|
|
445
445
|
dir_name = f"{date_prefix}-{slug}"
|
|
446
|
-
|
|
446
|
+
from .task_layout import task_location
|
|
447
|
+
task_dir = task_location(tasks_dir, dir_name, "planning")
|
|
447
448
|
task_json_path = task_dir / FILE_TASK_JSON
|
|
449
|
+
from .task_layout import iter_task_dirs
|
|
450
|
+
conflicts = [directory for directory in iter_task_dirs(tasks_dir) if directory.name == dir_name and directory != task_dir]
|
|
451
|
+
if conflicts:
|
|
452
|
+
print(f"Error: Task already exists outside planning: {dir_name}; use its lifecycle commands", file=sys.stderr)
|
|
453
|
+
return 1
|
|
448
454
|
|
|
449
455
|
# Reusing a slug on the same day is an ordinary accident, and continuing
|
|
450
456
|
# would rewrite the existing task.json below — resetting status, children,
|
|
@@ -691,7 +697,7 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
691
697
|
print("", file=sys.stderr)
|
|
692
698
|
|
|
693
699
|
# Output relative path for script chaining
|
|
694
|
-
print(
|
|
700
|
+
print(task_dir.relative_to(repo_root).as_posix())
|
|
695
701
|
|
|
696
702
|
run_task_hooks("after_create", task_json_path, repo_root)
|
|
697
703
|
return 0
|
|
@@ -841,7 +847,8 @@ def _plan_backrefs(
|
|
|
841
847
|
changes: list[tuple[Path, dict, list[str]]] = []
|
|
842
848
|
unreadable: list[Path] = []
|
|
843
849
|
|
|
844
|
-
|
|
850
|
+
from .task_layout import iter_task_dirs
|
|
851
|
+
for candidate in iter_task_dirs(tasks_dir):
|
|
845
852
|
if not candidate.is_dir():
|
|
846
853
|
continue
|
|
847
854
|
if candidate == task_dir:
|
|
@@ -964,62 +971,27 @@ def _rename_interrupted(plan: _RenamePlan) -> None:
|
|
|
964
971
|
|
|
965
972
|
|
|
966
973
|
def _apply_rename(plan: _RenamePlan, repo_root: Path) -> int:
|
|
967
|
-
"""
|
|
974
|
+
"""Apply rename through the same recoverable transaction as status moves."""
|
|
975
|
+
from .task_move import _plan, apply_plan
|
|
968
976
|
|
|
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
977
|
if not _task_mutation_allowed(repo_root, plan.task_dir, plan.task_data):
|
|
976
978
|
return 1
|
|
977
979
|
if plan.identity and not _task_identity_change_allowed(repo_root, plan.task_dir, plan.task_data):
|
|
978
980
|
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
|
-
|
|
981
|
+
if plan.unreadable:
|
|
982
|
+
print("Error: unreadable task back-references; nothing renamed", file=sys.stderr)
|
|
983
|
+
return 1
|
|
984
|
+
data = dict(plan.task_data)
|
|
985
|
+
for field_name, _old_value, new_value in plan.identity:
|
|
986
|
+
data[field_name] = new_value
|
|
987
|
+
updates = {plan.task_dir: data}
|
|
988
|
+
updates.update({path.parent: value for path, value, _labels in plan.backrefs})
|
|
1005
989
|
try:
|
|
1006
|
-
plan.task_dir
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
file=sys.stderr,
|
|
1011
|
-
)
|
|
1012
|
-
_rename_interrupted(plan)
|
|
990
|
+
transaction = _plan(repo_root, [(plan.task_dir, plan.new_dir)], updates)
|
|
991
|
+
apply_plan(repo_root, transaction)
|
|
992
|
+
except (OSError, ValueError) as error:
|
|
993
|
+
print(f"Error: rename withheld: {error}", file=sys.stderr)
|
|
1013
994
|
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
995
|
return 0
|
|
1024
996
|
|
|
1025
997
|
|
|
@@ -1034,12 +1006,12 @@ def cmd_rename(args: argparse.Namespace) -> int:
|
|
|
1034
1006
|
print(colored(f"Error: Task not found: {args.name}", Colors.RED), file=sys.stderr)
|
|
1035
1007
|
return 1
|
|
1036
1008
|
|
|
1037
|
-
# Restrict renames to
|
|
1009
|
+
# Restrict renames to the prescribed status directory levels.
|
|
1038
1010
|
if not is_within_tasks_dir(task_dir, repo_root):
|
|
1039
1011
|
print(
|
|
1040
1012
|
colored(
|
|
1041
1013
|
f"Error: refusing to rename '{args.name}': "
|
|
1042
|
-
f"{task_dir} is not a task
|
|
1014
|
+
f"{task_dir} is not a task in the status layout under {tasks_dir}",
|
|
1043
1015
|
Colors.RED,
|
|
1044
1016
|
),
|
|
1045
1017
|
file=sys.stderr,
|
|
@@ -1062,7 +1034,8 @@ def cmd_rename(args: argparse.Namespace) -> int:
|
|
|
1062
1034
|
return 1
|
|
1063
1035
|
|
|
1064
1036
|
new_dir = task_dir.parent / new_name
|
|
1065
|
-
|
|
1037
|
+
from .task_layout import iter_task_dirs
|
|
1038
|
+
if new_dir.exists() or any(directory.name == new_name for directory in iter_task_dirs(tasks_dir)):
|
|
1066
1039
|
print(
|
|
1067
1040
|
colored(f"Error: Task already exists: {new_name}", Colors.RED),
|
|
1068
1041
|
file=sys.stderr,
|
|
@@ -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
|
|
@@ -61,11 +61,14 @@ def _task_dir(value: str) -> Path:
|
|
|
61
61
|
return task_dir
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def _load(task_dir: Path) -> tuple[dict, dict]:
|
|
64
|
+
def _load(task_dir: Path, *, initialize_meta: bool = False) -> tuple[dict, dict]:
|
|
65
65
|
task_file = task_dir / FILE_TASK_JSON
|
|
66
66
|
data, reason = read_json_checked(task_file)
|
|
67
67
|
if data is None:
|
|
68
68
|
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", f"cannot read {task_file}: {reason}")
|
|
69
|
+
# Explicit initialization may adopt legacy records; malformed metadata still fails.
|
|
70
|
+
if initialize_meta and "meta" not in data:
|
|
71
|
+
data["meta"] = {}
|
|
69
72
|
meta = data.get("meta")
|
|
70
73
|
if not isinstance(meta, dict):
|
|
71
74
|
raise TaskWorkflowError("TASK_PHASE_TASK_INVALID", "task.meta must be an object")
|
|
@@ -177,7 +180,8 @@ def _save(task_file: Path, data: dict) -> None:
|
|
|
177
180
|
|
|
178
181
|
@contextlib.contextmanager
|
|
179
182
|
def _task_lock(task_dir: Path):
|
|
180
|
-
lock_file =
|
|
183
|
+
lock_file = get_repo_root() / ".trellis/.runtime/task-phase" / f"{task_dir.name}.lock"
|
|
184
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
181
185
|
with lock_file.open("a+b") as handle:
|
|
182
186
|
if os.name == "nt":
|
|
183
187
|
import msvcrt
|
|
@@ -696,7 +700,7 @@ def cmd_phase(args: argparse.Namespace) -> int:
|
|
|
696
700
|
try:
|
|
697
701
|
task_dir = _task_dir(args.task_dir)
|
|
698
702
|
with _task_lock(task_dir):
|
|
699
|
-
data, meta = _load(task_dir)
|
|
703
|
+
data, meta = _load(task_dir, initialize_meta=args.phase_action == "init")
|
|
700
704
|
if args.phase_action == "init":
|
|
701
705
|
return _init(args, task_dir, data, meta)
|
|
702
706
|
return _update(args, task_dir, data, _state(meta))
|
|
@@ -982,7 +986,8 @@ def cmd_cancel_task(args: argparse.Namespace) -> int:
|
|
|
982
986
|
}
|
|
983
987
|
data["status"] = "cancelled"
|
|
984
988
|
data["completedAt"] = None
|
|
985
|
-
|
|
989
|
+
from .task_move import relocate_task
|
|
990
|
+
task_dir = relocate_task(get_repo_root(), task_dir, data)
|
|
986
991
|
from .active_task import clear_task_from_sessions
|
|
987
992
|
from .task_continuity import clear_task_runtime_records
|
|
988
993
|
|
|
@@ -1069,26 +1074,28 @@ def _validate_child_dispositions(
|
|
|
1069
1074
|
|
|
1070
1075
|
|
|
1071
1076
|
def _assert_delegated_resources_closed(task_dir: Path, data: dict) -> None:
|
|
1072
|
-
repo_root = get_repo_root()
|
|
1073
1077
|
children = data.get("children", [])
|
|
1074
1078
|
if not isinstance(children, list):
|
|
1075
1079
|
raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", "linked child task list is malformed")
|
|
1076
1080
|
for task_id in [task_dir.name, *children]:
|
|
1077
1081
|
_, child_meta = _load(_task_dir(task_id))
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1082
|
+
_assert_delegated_record_closed(task_id, child_meta.get("delegated_worktree_execution_record"))
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _assert_delegated_record_closed(task_id: str, record) -> None:
|
|
1086
|
+
if record is None:
|
|
1087
|
+
return
|
|
1088
|
+
agent_id = record.get("agent_id") if isinstance(record, dict) else None
|
|
1089
|
+
if not isinstance(agent_id, str) or not agent_id.strip():
|
|
1090
|
+
raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated execution status is unknown for {task_id}; preserve the current state")
|
|
1091
|
+
agents = _paseo_list(
|
|
1092
|
+
["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={task_id}"],
|
|
1093
|
+
get_repo_root(),
|
|
1094
|
+
"TASK_PHASE_RESOURCE_ACTIVE",
|
|
1095
|
+
)
|
|
1096
|
+
agent = next((item for item in agents if item.get("id") == agent_id), None)
|
|
1097
|
+
if agent is None or agent.get("status") != "closed":
|
|
1098
|
+
raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated Paseo agent for {task_id} is not confirmed closed; preserve the current state")
|
|
1092
1099
|
|
|
1093
1100
|
|
|
1094
1101
|
def cmd_complete_task(args: argparse.Namespace) -> int:
|
|
@@ -1114,7 +1121,8 @@ def cmd_complete_task(args: argparse.Namespace) -> int:
|
|
|
1114
1121
|
raise TaskWorkflowError("TASK_PHASE_IMPLEMENTATION_NOT_STARTED", "implementation tasks must enter in_progress before complete")
|
|
1115
1122
|
data["status"] = "completed"
|
|
1116
1123
|
data["completedAt"] = datetime.now().strftime("%Y-%m-%d")
|
|
1117
|
-
|
|
1124
|
+
from .task_move import relocate_task
|
|
1125
|
+
task_dir = relocate_task(get_repo_root(), task_dir, data)
|
|
1118
1126
|
from .active_task import clear_task_from_sessions
|
|
1119
1127
|
from .task_continuity import clear_task_runtime_records
|
|
1120
1128
|
|