@pennixrv/trellis 0.7.1-beta.4 → 0.7.1-beta.6

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.
Files changed (49) hide show
  1. package/dist/commands/init.d.ts.map +1 -1
  2. package/dist/commands/init.js +14 -6
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/commands/task-progress.d.ts +1 -0
  5. package/dist/commands/task-progress.d.ts.map +1 -1
  6. package/dist/commands/task-progress.js +67 -25
  7. package/dist/commands/task-progress.js.map +1 -1
  8. package/dist/commands/update.js +2 -2
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/constants/paths.d.ts +3 -10
  11. package/dist/constants/paths.d.ts.map +1 -1
  12. package/dist/constants/paths.js +4 -13
  13. package/dist/constants/paths.js.map +1 -1
  14. package/dist/migrations/manifests/0.7.1-beta.5.json +9 -0
  15. package/dist/migrations/manifests/0.7.1-beta.6.json +10 -0
  16. package/dist/templates/claude/hooks/statusline.py +9 -4
  17. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +1 -1
  18. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +1 -1
  19. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +5 -5
  20. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +3 -2
  21. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +1 -1
  22. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +1 -1
  23. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +8 -2
  24. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teammate-processes.md +2 -2
  25. package/dist/templates/common/bundled-skills/trellis-teamwork/references/teamwork-plan-construction.md +1 -1
  26. package/dist/templates/omp/extensions/trellis/index.ts.txt +1 -1
  27. package/dist/templates/opencode/lib/session-utils.js +301 -209
  28. package/dist/templates/snow/SNOW.md +1 -1
  29. package/dist/templates/trellis/agents/teammate.md +5 -5
  30. package/dist/templates/trellis/index.d.ts +3 -0
  31. package/dist/templates/trellis/index.d.ts.map +1 -1
  32. package/dist/templates/trellis/index.js +6 -0
  33. package/dist/templates/trellis/index.js.map +1 -1
  34. package/dist/templates/trellis/scripts/add_session.py +1 -1
  35. package/dist/templates/trellis/scripts/common/active_task.py +3 -47
  36. package/dist/templates/trellis/scripts/common/paths.py +9 -25
  37. package/dist/templates/trellis/scripts/common/safe_commit.py +8 -8
  38. package/dist/templates/trellis/scripts/common/task_context.py +11 -51
  39. package/dist/templates/trellis/scripts/common/task_continuity.py +53 -45
  40. package/dist/templates/trellis/scripts/common/task_layout.py +80 -0
  41. package/dist/templates/trellis/scripts/common/task_move.py +345 -0
  42. package/dist/templates/trellis/scripts/common/task_store.py +28 -55
  43. package/dist/templates/trellis/scripts/common/task_utils.py +23 -19
  44. package/dist/templates/trellis/scripts/common/task_workflow.py +28 -20
  45. package/dist/templates/trellis/scripts/common/tasks.py +6 -5
  46. package/dist/templates/trellis/scripts/hooks/linear_sync.py +13 -3
  47. package/dist/templates/trellis/scripts/task.py +41 -23
  48. package/dist/templates/trellis/workflow.md +4 -2
  49. 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
- task_dir = tasks_dir / dir_name
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(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
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
- for candidate in sorted(tasks_dir.iterdir()):
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
- """Write the planned change set.
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.identity:
980
- data = dict(plan.task_data)
981
- for field_name, _old_value, new_value in plan.identity:
982
- data[field_name] = new_value
983
- if not write_json(plan.task_json_path, data):
984
- _report_write_failure(plan.task_json_path)
985
- print("Nothing was renamed.", file=sys.stderr)
986
- return 1
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.rename(plan.new_dir)
1007
- except OSError as exc:
1008
- print(
1009
- colored(f"Error: Failed to move {plan.old_rel} -> {plan.new_rel}: {exc}", Colors.RED),
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 task records directly under the tasks directory.
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 directly under {tasks_dir}",
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
- if new_dir.exists():
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 lives directly at ``.trellis/tasks/<name>``. This returns True
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
- resolved = task_dir_abs.resolve()
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
- # Try exact match first
78
- exact_match = tasks_dir / task_name
79
- if exact_match.is_dir():
80
- return exact_match
81
-
82
- # Try suffix match (e.g., "my-task" matches "01-21-my-task")
83
- matches = sorted(
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.name}", file=sys.stderr)
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 = task_dir / ".task-phase.lock"
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
- _save(task_dir / FILE_TASK_JSON, data)
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
- record = child_meta.get("delegated_worktree_execution_record")
1079
- if record is None:
1080
- continue
1081
- agent_id = record.get("agent_id") if isinstance(record, dict) else None
1082
- if not isinstance(agent_id, str) or not agent_id.strip():
1083
- raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated execution status is unknown for {task_id}; preserve the current state")
1084
- agents = _paseo_list(
1085
- ["agent", "ls", "--all", "--global", "--background", "--json", "--label", f"trellis.task_id={task_id}"],
1086
- repo_root,
1087
- "TASK_PHASE_RESOURCE_ACTIVE",
1088
- )
1089
- agent = next((item for item in agents if item.get("id") == agent_id), None)
1090
- if agent is None or agent.get("status") != "closed":
1091
- raise TaskWorkflowError("TASK_PHASE_RESOURCE_ACTIVE", f"delegated Paseo agent for {task_id} is not confirmed closed; preserve the current state")
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
- _save(task_dir / FILE_TASK_JSON, data)
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