@pennixrv/trellis 0.7.0-beta.52 → 0.7.0-beta.53

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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/init.js +2 -2
  3. package/dist/commands/update.d.ts.map +1 -1
  4. package/dist/commands/update.js +1 -1
  5. package/dist/commands/update.js.map +1 -1
  6. package/dist/configurators/shared.js +1 -1
  7. package/dist/configurators/shared.js.map +1 -1
  8. package/dist/migrations/manifests/0.7.0-beta.53.json +27 -0
  9. package/dist/templates/codex/hooks/session-start.py +9 -18
  10. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md +1 -1
  11. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +1 -1
  12. package/dist/templates/common/commands/continue.md +1 -1
  13. package/dist/templates/common/commands/start.md +2 -3
  14. package/dist/templates/common/skills/idea-to-closed-plan.md +105 -0
  15. package/dist/templates/copilot/hooks/session-start.py +9 -18
  16. package/dist/templates/copilot/prompts/start.prompt.md +2 -3
  17. package/dist/templates/opencode/lib/session-utils.js +1 -1
  18. package/dist/templates/shared-hooks/inject-workflow-state.py +1 -1
  19. package/dist/templates/shared-hooks/session-start.py +10 -24
  20. package/dist/templates/trellis/index.d.ts +1 -0
  21. package/dist/templates/trellis/index.d.ts.map +1 -1
  22. package/dist/templates/trellis/index.js +2 -0
  23. package/dist/templates/trellis/index.js.map +1 -1
  24. package/dist/templates/trellis/scripts/common/task_migration.py +231 -0
  25. package/dist/templates/trellis/scripts/common/task_planning.py +252 -10
  26. package/dist/templates/trellis/scripts/common/task_store.py +11 -2
  27. package/dist/templates/trellis/scripts/task.py +86 -38
  28. package/dist/templates/trellis/workflow.md +23 -20
  29. package/dist/utils/template-fetcher.js +1 -1
  30. package/dist/utils/template-fetcher.js.map +1 -1
  31. package/dist/utils/workflow-resolver.d.ts +2 -2
  32. package/dist/utils/workflow-resolver.d.ts.map +1 -1
  33. package/dist/utils/workflow-resolver.js +9 -1
  34. package/dist/utils/workflow-resolver.js.map +1 -1
  35. package/package.json +2 -2
  36. package/dist/templates/common/skills/brainstorm.md +0 -236
  37. package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -162
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ import re
5
7
  from datetime import datetime, timezone
6
8
  from hashlib import sha256
7
9
  from pathlib import Path
@@ -12,6 +14,23 @@ class PlanningError(ValueError):
12
14
  """A planning contract needs correction before a task can start."""
13
15
 
14
16
 
17
+ STAGES = {
18
+ "intake",
19
+ "evidence",
20
+ "decision_convergence",
21
+ "solution_formation",
22
+ "solution_review",
23
+ "user_review",
24
+ "plan_closed",
25
+ "direct_ready",
26
+ }
27
+ REPLAN_STAGES = STAGES - {"plan_closed", "direct_ready"}
28
+ DECISIONS_MARKER = "<!-- trellis-decisions:v1 -->"
29
+ IMPLEMENTATION_PLAN_MARKER = "<!-- trellis-implementation-plan:v1 -->"
30
+ _DECISION_FIELDS = ("id", "status", "question", "evidence", "answer_or_reason", "impact", "reopen_when")
31
+ _STEP_FIELDS = ("id", "target", "depends_on", "action", "deliverables", "verification", "risks", "rollback")
32
+
33
+
15
34
  def validate_meta_value(key: str, value: str) -> None:
16
35
  if key == "planning" or key.startswith("planning."):
17
36
  raise PlanningError("meta.planning is reserved; use task.py plan seal/approve")
@@ -44,12 +63,186 @@ def planning_record(data: dict[str, Any]) -> dict[str, Any]:
44
63
  revision = record.get("revision", 1)
45
64
  if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
46
65
  raise PlanningError("planning revision must be a positive integer")
47
- return {**record, "revision": revision}
66
+ stage = record.get("stage", "intake" if data.get("status") == "planning" else None)
67
+ if stage is not None and (not isinstance(stage, str) or stage not in STAGES):
68
+ raise PlanningError(f"planning stage must be one of: {', '.join(sorted(STAGES))}")
69
+ return {**record, "revision": revision, **({"stage": stage} if stage else {})}
48
70
 
49
71
 
50
- def invalidate_plan(data: dict[str, Any]) -> None:
72
+ def invalidate_plan(data: dict[str, Any], stage: str | None = None) -> None:
51
73
  record = planning_record(data)
52
- data.setdefault("meta", {})["planning"] = {"revision": record["revision"] + 1}
74
+ next_record = {"revision": record["revision"] + 1}
75
+ if stage is not None:
76
+ if not isinstance(stage, str) or stage not in REPLAN_STAGES:
77
+ raise PlanningError(f"unknown planning stage: {stage}")
78
+ next_record["stage"] = stage
79
+ data.setdefault("meta", {})["planning"] = next_record
80
+
81
+
82
+ def _marked_json(task_dir: Path, document: str, marker: str) -> dict[str, Any]:
83
+ path = task_dir / document
84
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > 256 * 1024:
85
+ raise PlanningError(f"{document} must be a bounded regular file")
86
+ try:
87
+ lines = path.read_text(encoding="utf-8").splitlines()
88
+ except (OSError, UnicodeDecodeError) as exc:
89
+ raise PlanningError(f"{document} must be valid UTF-8") from exc
90
+ markers = [index for index, line in enumerate(lines) if line.strip() == marker]
91
+ if len(markers) != 1:
92
+ raise PlanningError(f"{document} must contain exactly one {marker} block")
93
+ start = markers[0] + 1
94
+ if start >= len(lines) or lines[start].strip() != "```json":
95
+ raise PlanningError(f"{marker} must be immediately followed by a JSON fence")
96
+ end = next((index for index in range(start + 1, len(lines)) if lines[index].strip() == "```"), None)
97
+ if end is None:
98
+ raise PlanningError(f"{document} has an unterminated {marker} JSON fence")
99
+ try:
100
+ value = json.loads("\n".join(lines[start + 1:end]))
101
+ except json.JSONDecodeError as exc:
102
+ raise PlanningError(f"{document} {marker} block is invalid JSON: {exc.msg}") from exc
103
+ if not isinstance(value, dict) or value.get("schema_version") != 1:
104
+ raise PlanningError(f"{document} {marker} block requires schema_version=1")
105
+ return value
106
+
107
+
108
+ def validate_decisions(task_dir: Path) -> list[dict[str, Any]]:
109
+ value = _marked_json(task_dir, "prd.md", DECISIONS_MARKER)
110
+ decisions = value.get("decisions")
111
+ if not isinstance(decisions, list):
112
+ raise PlanningError("trellis-decisions:v1 requires a decisions array")
113
+ ids: set[str] = set()
114
+ for item in decisions:
115
+ if not isinstance(item, dict) or any(key not in item for key in _DECISION_FIELDS):
116
+ raise PlanningError("each decision requires id, status, question, evidence, answer_or_reason, impact, and reopen_when")
117
+ if any(not isinstance(item[key], str) or not item[key].strip() for key in ("id", "question", "answer_or_reason", "impact", "reopen_when")):
118
+ raise PlanningError("decision text fields must be non-empty strings")
119
+ if item["id"] in ids:
120
+ raise PlanningError(f"duplicate decision id: {item['id']}")
121
+ ids.add(item["id"])
122
+ if not isinstance(item["status"], str) or item["status"] not in {"open", "decided", "deferred", "excluded"}:
123
+ raise PlanningError(f"invalid decision status for {item['id']}")
124
+ evidence = item["evidence"]
125
+ if not isinstance(evidence, list) or not evidence or any(not isinstance(entry, str) or not entry.strip() for entry in evidence):
126
+ raise PlanningError(f"decision evidence must contain non-empty references: {item['id']}")
127
+ return decisions
128
+
129
+
130
+ def validate_implementation_plan(task_dir: Path) -> dict[str, Any]:
131
+ value = _marked_json(task_dir, "implement.md", IMPLEMENTATION_PLAN_MARKER)
132
+ steps = value.get("steps")
133
+ if not isinstance(steps, list) or not steps:
134
+ raise PlanningError("trellis-implementation-plan:v1 requires a non-empty steps array")
135
+ seen: set[str] = set()
136
+ for step in steps:
137
+ if not isinstance(step, dict) or any(key not in step for key in _STEP_FIELDS):
138
+ raise PlanningError("each implementation step requires id, target, depends_on, action, deliverables, verification, risks, and rollback")
139
+ identifier = step["id"]
140
+ if not isinstance(identifier, str) or not identifier.strip() or identifier in seen:
141
+ raise PlanningError(f"implementation step id is empty or duplicated: {identifier!r}")
142
+ target = step["target"]
143
+ if not isinstance(target, dict) or any(not isinstance(target.get(key), str) or not target[key].strip() for key in ("repository", "branch", "component_or_path")):
144
+ raise PlanningError(f"implementation step {identifier} requires repository, branch, and component_or_path")
145
+ for key in ("action", "deliverables", "verification", "risks", "rollback"):
146
+ if not isinstance(step[key], str) or not step[key].strip():
147
+ raise PlanningError(f"implementation step {identifier} requires non-empty {key}")
148
+ dependencies = step["depends_on"]
149
+ if not isinstance(dependencies, list) or any(not isinstance(dep, str) for dep in dependencies):
150
+ raise PlanningError(f"implementation step {identifier} depends_on must be an array of earlier step ids")
151
+ if len(dependencies) != len(set(dependencies)) or any(dep not in seen for dep in dependencies):
152
+ raise PlanningError(f"implementation step {identifier} has duplicate, unknown, or forward dependencies")
153
+ seen.add(identifier)
154
+ stop = value.get("stop_point")
155
+ if not isinstance(stop, dict) or any(not isinstance(stop.get(key), str) or not stop[key].strip() for key in ("condition", "observable_evidence", "remaining_work")):
156
+ raise PlanningError("implementation plan requires one stop_point with condition, observable_evidence, and remaining_work")
157
+ return value
158
+
159
+
160
+ def validate_direct_plan(data: dict[str, Any], task_dir: Path) -> None:
161
+ if not isinstance(data.get("assignee"), str) or not data["assignee"].strip():
162
+ raise PlanningError("direct task requires one named owner in task.json")
163
+ prd = task_dir / "prd.md"
164
+ if prd.is_symlink() or not prd.is_file() or prd.stat().st_size > 256 * 1024:
165
+ raise PlanningError("direct task requires a bounded regular prd.md")
166
+ try:
167
+ content = prd.read_text(encoding="utf-8")
168
+ except (OSError, UnicodeDecodeError) as exc:
169
+ raise PlanningError("direct task prd.md must be readable UTF-8") from exc
170
+ headings = list(re.finditer(r"(?m)^#{1,6}\s+(.+?)\s*#*\s*$", content))
171
+ sections: dict[str, str] = {}
172
+ for index, heading in enumerate(headings):
173
+ title = re.sub(r"[\W_]+", "", heading.group(1).casefold())
174
+ end = headings[index + 1].start() if index + 1 < len(headings) else len(content)
175
+ sections[title] = content[heading.end():end].strip()
176
+ required = {
177
+ "one bounded change": {"scope", "boundedchange", "范围", "变更范围", "本次变更"},
178
+ "observable verification": {"verification", "验收", "验证", "验证方式"},
179
+ "rollback method": {"rollback", "rollbackplan", "回退", "回滚", "回退方案", "回滚方案"},
180
+ }
181
+ for label, alternatives in required.items():
182
+ if not any(sections.get(title) for title in alternatives):
183
+ raise PlanningError(f"direct task must provide a non-empty {label} section in prd.md")
184
+
185
+
186
+ def plan_digest(data: dict[str, Any], task_dir: Path) -> str:
187
+ execution, delivery = classification(data)
188
+ documents = _plan_documents(task_dir, execution, delivery)
189
+ payload = {
190
+ "task_id": data.get("id") or task_dir.name,
191
+ "task_name": task_dir.name,
192
+ "execution_class": execution,
193
+ "delivery_mode": delivery,
194
+ "documents": {name: (task_dir / name).read_text(encoding="utf-8") for name in documents},
195
+ }
196
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
197
+ return "sha256:" + sha256(encoded).hexdigest()
198
+
199
+
200
+ def advance_stage(data: dict[str, Any], task_dir: Path, target: str) -> str:
201
+ if data.get("status") != "planning":
202
+ raise PlanningError("planning stages can only advance while status=planning")
203
+ if not isinstance(target, str) or target not in STAGES or target == "plan_closed":
204
+ raise PlanningError(f"invalid planning stage target: {target}")
205
+ execution, delivery = classification(data)
206
+ record = planning_record(data)
207
+ current = record.get("stage", "intake")
208
+ allowed = {
209
+ "intake": {"evidence"},
210
+ "evidence": {"decision_convergence", "solution_formation", "direct_ready"},
211
+ "decision_convergence": {"evidence", "solution_formation"},
212
+ "solution_formation": {"evidence", "decision_convergence", "solution_review"},
213
+ "solution_review": {"evidence", "decision_convergence", "solution_formation", "user_review"},
214
+ "user_review": {"evidence", "decision_convergence", "solution_formation"},
215
+ "plan_closed": set(),
216
+ "direct_ready": set(),
217
+ }
218
+ if current not in allowed or target not in allowed[current]:
219
+ raise PlanningError(f"invalid planning transition: {current} -> {target}")
220
+ decisions: list[dict[str, Any]] | None = None
221
+ if target in {"decision_convergence", "solution_formation", "solution_review", "user_review"} or current == "decision_convergence":
222
+ decisions = validate_decisions(task_dir)
223
+ open_decisions = [item["id"] for item in decisions or [] if item["status"] == "open"]
224
+ if target == "decision_convergence" and (execution != "planned" or delivery != "change_bearing" or not open_decisions):
225
+ raise PlanningError("decision_convergence requires planned change-bearing work with open decisions")
226
+ if target == "solution_formation" and (execution != "planned" or delivery != "change_bearing" or open_decisions):
227
+ raise PlanningError("solution_formation requires planned change-bearing work with no open decisions")
228
+ if target == "solution_review":
229
+ validate_implementation_plan(task_dir)
230
+ if open_decisions:
231
+ raise PlanningError("open decisions block solution review: " + ", ".join(open_decisions))
232
+ if target == "user_review":
233
+ validate_implementation_plan(task_dir)
234
+ if open_decisions:
235
+ raise PlanningError("open decisions block user review: " + ", ".join(open_decisions))
236
+ if target == "direct_ready":
237
+ if execution != "direct" or delivery != "change_bearing":
238
+ raise PlanningError("direct_ready requires direct change-bearing classification")
239
+ validate_direct_plan(data, task_dir)
240
+ if target in {"evidence", "decision_convergence", "solution_formation"} and current in {"solution_review", "user_review"}:
241
+ invalidate_plan(data, target)
242
+ else:
243
+ record["stage"] = target
244
+ data.setdefault("meta", {})["planning"] = record
245
+ return target
53
246
 
54
247
 
55
248
  def _plan_documents(task_dir: Path, execution: str, delivery: str) -> list[str]:
@@ -67,15 +260,24 @@ def _plan_documents(task_dir: Path, execution: str, delivery: str) -> list[str]:
67
260
  return names
68
261
 
69
262
 
70
- def seal_plan(data: dict[str, Any], task_dir: Path) -> int:
263
+ def seal_plan(data: dict[str, Any], task_dir: Path, expected_digest: str) -> int:
264
+ record = planning_record(data)
265
+ if record.get("stage") != "user_review":
266
+ raise PlanningError("plan seal requires stage=user_review")
71
267
  execution, delivery = classification(data)
72
268
  documents = _plan_documents(task_dir, execution, delivery)
73
- record = planning_record(data)
74
- # Resealing an already sealed plan declares a material new version. A replan
75
- # already advanced the revision; its first seal keeps that revision.
269
+ decisions = validate_decisions(task_dir)
270
+ if any(item["status"] == "open" for item in decisions):
271
+ raise PlanningError("open decisions prevent plan sealing")
272
+ validate_implementation_plan(task_dir)
273
+ actual_digest = plan_digest(data, task_dir)
274
+ if expected_digest != actual_digest:
275
+ raise PlanningError("displayed plan digest is stale; review the current complete plan again")
76
276
  revision = record["revision"] + (1 if record.get("seal") else 0)
77
277
  data.setdefault("meta", {})["planning"] = {
78
278
  "revision": revision,
279
+ "stage": "user_review",
280
+ "review_digest": actual_digest,
79
281
  "seal": {
80
282
  "revision": revision,
81
283
  "task_id": data.get("id") or task_dir.name,
@@ -84,13 +286,14 @@ def seal_plan(data: dict[str, Any], task_dir: Path) -> int:
84
286
  "document_digests": {name: sha256((task_dir / name).read_bytes()).hexdigest() for name in documents},
85
287
  "execution_class": execution,
86
288
  "delivery_mode": delivery,
289
+ "plan_digest": actual_digest,
87
290
  "sealed_at": datetime.now(timezone.utc).isoformat(),
88
291
  },
89
292
  }
90
293
  return revision
91
294
 
92
295
 
93
- def _require_seal(data: dict[str, Any], task_dir: Path) -> dict[str, Any]:
296
+ def _require_seal(data: dict[str, Any], task_dir: Path, required_stage: str = "user_review") -> dict[str, Any]:
94
297
  execution, delivery = classification(data)
95
298
  record = planning_record(data)
96
299
  seal = record.get("seal")
@@ -104,6 +307,13 @@ def _require_seal(data: dict[str, Any], task_dir: Path) -> dict[str, Any]:
104
307
  raise PlanningError("sealed plan documents do not match this task")
105
308
  if seal.get("document_digests") != {name: sha256((task_dir / name).read_bytes()).hexdigest() for name in seal["documents"]}:
106
309
  raise PlanningError("sealed plan content changed or lacks content digests; reseal the material plan")
310
+ if seal.get("plan_digest") != plan_digest(data, task_dir) or record.get("review_digest") != seal.get("plan_digest"):
311
+ raise PlanningError("sealed plan digest is stale; review the current complete plan again")
312
+ if record.get("stage") != required_stage:
313
+ raise PlanningError(f"plan receipt requires stage={required_stage}")
314
+ if any(item["status"] == "open" for item in validate_decisions(task_dir)):
315
+ raise PlanningError("open decisions prevent plan approval")
316
+ validate_implementation_plan(task_dir)
107
317
  return record
108
318
 
109
319
 
@@ -111,12 +321,13 @@ def approve_plan(data: dict[str, Any], task_dir: Path, revision: int, basis: str
111
321
  record = _require_seal(data, task_dir)
112
322
  if revision != record["revision"]:
113
323
  raise PlanningError(f"approval revision must match current revision {record['revision']}")
114
- if not basis.strip() or len(basis) > 1024 or "\x00" in basis:
324
+ if not isinstance(basis, str) or not basis.strip() or len(basis) > 1024 or "\x00" in basis:
115
325
  raise PlanningError("approval basis must be non-empty bounded text without credentials")
116
326
  record.update({
117
327
  "approved_revision": revision,
118
328
  "approval_basis": basis.strip(),
119
329
  "approved_at": datetime.now(timezone.utc).isoformat(),
330
+ "stage": "plan_closed",
120
331
  })
121
332
  data["meta"]["planning"] = record
122
333
 
@@ -132,6 +343,37 @@ def require_start_approval(data: dict[str, Any], task_dir: Path) -> None:
132
343
  if delivery == "analysis_only":
133
344
  raise PlanningError("analysis_only tasks remain in planning and cannot be started")
134
345
  if execution == "planned" and delivery == "change_bearing":
135
- record = _require_seal(data, task_dir)
346
+ record = _require_seal(data, task_dir, "plan_closed")
136
347
  if type(record.get("approved_revision")) is not int or record.get("approved_revision") != record["revision"] or not record.get("approval_basis"):
137
348
  raise PlanningError("current sealed plan has no matching implementation approval; use task.py plan approve only after the user explicitly approves the displayed plan")
349
+ elif execution == "direct" and delivery == "change_bearing":
350
+ if planning_record(data).get("stage") != "direct_ready":
351
+ raise PlanningError("direct change-bearing tasks require stage=direct_ready before start")
352
+ validate_direct_plan(data, task_dir)
353
+
354
+
355
+ def prepare_archive(data: dict[str, Any], task_dir: Path, reason: str | None) -> None:
356
+ if data.get("status") != "planning":
357
+ return
358
+ if not isinstance(reason, str) or not reason.strip() or len(reason) > 1024:
359
+ raise PlanningError("archiving a planning task requires --reason with the analysis result or abandonment reason")
360
+ record = planning_record(data)
361
+ meta = data.get("meta", {})
362
+ if not isinstance(meta, dict):
363
+ raise PlanningError("task meta must be an object")
364
+ if "execution_class" in meta or "delivery_mode" in meta:
365
+ _, delivery = classification(data)
366
+ else:
367
+ delivery = None
368
+ if delivery == "analysis_only":
369
+ if record.get("stage") != "evidence":
370
+ raise PlanningError("analysis-only archive requires stage=evidence and a completed evidence review")
371
+ research = task_dir / "research"
372
+ evidence_files = [path for path in research.glob("*.md") if path.is_file() and not path.is_symlink()] if research.is_dir() and not research.is_symlink() else []
373
+ if not evidence_files:
374
+ raise PlanningError("analysis-only archive requires at least one regular research/*.md evidence file")
375
+ kind = "analysis_complete"
376
+ else:
377
+ kind = "administrative_abandonment"
378
+ record["archive"] = {"kind": kind, "reason": reason.strip(), "recorded_at": datetime.now(timezone.utc).isoformat()}
379
+ data.setdefault("meta", {})["planning"] = record
@@ -68,6 +68,7 @@ from .task_utils import (
68
68
  resolve_task_dir,
69
69
  run_task_hooks,
70
70
  )
71
+ from .task_planning import PlanningError, prepare_archive
71
72
  from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
72
73
 
73
74
 
@@ -582,7 +583,7 @@ def cmd_create(args: argparse.Namespace) -> int:
582
583
  "parent": None,
583
584
  "relatedFiles": [],
584
585
  "notes": "",
585
- "meta": meta,
586
+ "meta": {**meta, "planning": {"revision": 1, "stage": "intake"}},
586
587
  }
587
588
  # Optional per-task workflow selection: key present only when opted in,
588
589
  # so tasks without a selection keep today's task.json shape byte-for-byte.
@@ -1385,6 +1386,11 @@ def cmd_archive(args: argparse.Namespace) -> int:
1385
1386
  file=sys.stderr,
1386
1387
  )
1387
1388
  else:
1389
+ try:
1390
+ prepare_archive(data, task_dir, getattr(args, "reason", None))
1391
+ except PlanningError as exc:
1392
+ print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
1393
+ return 1
1388
1394
  # Before any mutation: branch metadata is unrecoverable once the
1389
1395
  # task leaves the active tree. Stale branches only warn.
1390
1396
  if not _validate_branch_metadata(
@@ -1985,9 +1991,12 @@ def cmd_set_meta(args: argparse.Namespace) -> int:
1985
1991
  meta = data.get("meta")
1986
1992
  if not isinstance(meta, dict):
1987
1993
  meta = {}
1994
+ if key in {"execution_class", "delivery_mode"} and meta.get(key) != value and data.get("status") == "in_progress":
1995
+ print(colored("Error: replan the in-progress task before changing its classification", Colors.RED), file=sys.stderr)
1996
+ return 1
1988
1997
  if key in {"execution_class", "delivery_mode"} and meta.get(key) != value and "planning" in meta:
1989
1998
  try:
1990
- invalidate_plan(data)
1999
+ invalidate_plan(data, "intake")
1991
2000
  except ValueError as exc:
1992
2001
  print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
1993
2002
  return 1
@@ -12,7 +12,7 @@ Usage:
12
12
  python3 task.py plan seal <dir> # Seal the current material plan
13
13
  python3 task.py plan approve <dir> --revision N --basis "approval"
14
14
  python3 task.py start <dir> # Start an approved task, record current branch
15
- python3 task.py replan <dir> "<reason>" # Return an in-progress task to planning
15
+ python3 task.py replan <dir> --to <stage> "<reason>" # Reopen the earliest affected planning stage
16
16
  python3 task.py current [--source] [--json] # Show active task
17
17
  python3 task.py ownership <operation> ... # Manage formal handoff task ownership
18
18
  python3 task.py finish # Clear active task
@@ -37,6 +37,7 @@ import os
37
37
  import sys
38
38
  from datetime import datetime, timezone
39
39
  from pathlib import Path
40
+ from typing import Any
40
41
 
41
42
  from common.log import Colors, colored
42
43
  from common.paths import (
@@ -64,8 +65,10 @@ from common.io import (
64
65
  )
65
66
  from common.task_utils import is_within_tasks_dir, resolve_task_dir, run_task_hooks
66
67
  from common.task_planning import (
67
- PlanningError, approve_plan, invalidate_plan, require_start_approval, seal_plan,
68
+ PlanningError, REPLAN_STAGES, STAGES, advance_stage, approve_plan, invalidate_plan, plan_digest,
69
+ planning_record, require_start_approval, seal_plan,
68
70
  )
71
+ from common.task_migration import MigrationError, migrate as migrate_tasks, rollback as rollback_task_migration
69
72
  from common.tasks import iter_active_tasks, children_progress
70
73
  from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task
71
74
 
@@ -234,12 +237,38 @@ def cmd_select(args: argparse.Namespace) -> int:
234
237
 
235
238
  def cmd_plan(args: argparse.Namespace) -> int:
236
239
  repo_root = get_repo_root()
240
+ if args.plan_command == "migrate":
241
+ try:
242
+ if args.rollback:
243
+ receipt_path = args.rollback if args.rollback.is_absolute() else repo_root / args.rollback
244
+ result = rollback_task_migration(repo_root, receipt_path)
245
+ else:
246
+ if not args.manifest:
247
+ raise MigrationError("plan migrate requires --manifest unless --rollback is used")
248
+ manifest_path = args.manifest if args.manifest.is_absolute() else repo_root / args.manifest
249
+ result = migrate_tasks(repo_root, manifest_path, apply=args.apply, receipt_value=args.receipt)
250
+ except (MigrationError, OwnershipError, OSError, ValueError) as exc:
251
+ print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
252
+ return 1
253
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
254
+ return 0
255
+ stage: str | None = None
237
256
  try:
238
257
  target, data = _active_task_target(args.dir, repo_root)
258
+ if args.plan_command == "digest":
259
+ print(json.dumps({
260
+ "task_id": data.get("id") or target.name,
261
+ "revision": planning_record(data)["revision"],
262
+ "digest": plan_digest(data, target),
263
+ }, ensure_ascii=False, sort_keys=True))
264
+ return 0
239
265
  if data.get("status") != "planning":
240
- raise PlanningError("plan seal/approve requires planning; use replan for a material change")
241
- if args.plan_command == "seal":
242
- revision = seal_plan(data, target)
266
+ raise PlanningError("planning transitions require status=planning; use replan for an in-progress task")
267
+ if args.plan_command == "advance":
268
+ stage = advance_stage(data, target, args.to)
269
+ revision = planning_record(data)["revision"]
270
+ elif args.plan_command == "seal":
271
+ revision = seal_plan(data, target, args.expected_digest)
243
272
  else:
244
273
  approve_plan(data, target, args.revision, args.basis)
245
274
  revision = args.revision
@@ -248,7 +277,10 @@ def cmd_plan(args: argparse.Namespace) -> int:
248
277
  except (PlanningError, OwnershipError, OSError, ValueError) as exc:
249
278
  print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
250
279
  return 1
251
- print(colored(f"✓ Plan {args.plan_command}: revision {revision}", Colors.GREEN))
280
+ if args.plan_command == "advance":
281
+ print(colored(f"✓ Planning stage: {stage} (revision {revision})", Colors.GREEN))
282
+ else:
283
+ print(colored(f"✓ Plan {args.plan_command}: revision {revision}", Colors.GREEN))
252
284
  return 0
253
285
 
254
286
 
@@ -376,51 +408,37 @@ def cmd_start(args: argparse.Namespace) -> int:
376
408
 
377
409
 
378
410
  def cmd_replan(args: argparse.Namespace) -> int:
379
- """Return an in-progress task to planning without losing its binding."""
411
+ """Return a task to its earliest affected planning stage."""
380
412
  repo_root = get_repo_root()
381
- full_path = resolve_task_dir(args.dir, repo_root)
382
- if full_path is None or not full_path.is_dir():
383
- print(colored(f"Error: Task not found: {args.dir}", Colors.RED), file=sys.stderr)
384
- return 1
385
-
386
413
  try:
387
- assert_task_mutation_allowed(repo_root, full_path)
388
- except (OwnershipError, OSError) as exc:
414
+ full_path, data = _active_task_target(args.dir, repo_root)
415
+ except (PlanningError, OwnershipError, OSError, ValueError) as exc:
389
416
  print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
390
- return 2
417
+ return 1
391
418
 
392
419
  reason = " ".join(args.reason).strip()
393
420
  if not reason:
394
421
  print(colored("Error: replan reason must not be empty", Colors.RED), file=sys.stderr)
395
422
  return 1
396
423
 
397
- task_json_path = full_path / FILE_TASK_JSON
398
- data, read_reason = read_json_checked(task_json_path)
399
- if data is None:
400
- problem, hint = describe_json_read_failure(task_json_path, read_reason)
401
- print(colored(f"Error: {problem}", Colors.RED), file=sys.stderr)
402
- print(hint, file=sys.stderr)
403
- return 1
404
- if data.get("status") != "in_progress":
405
- print(
406
- colored(
407
- f"Error: replan requires status=in_progress (found {data.get('status')!r})",
408
- Colors.RED,
409
- ),
410
- file=sys.stderr,
411
- )
412
- return 1
413
-
414
424
  try:
415
- invalidate_plan(data)
425
+ from_status = data["status"]
426
+ from_stage = planning_record(data).get("stage")
427
+ if from_status == "planning" and from_stage != "plan_closed":
428
+ raise PlanningError("replan requires planning/plan_closed or any in_progress task")
429
+ invalidate_plan(data, args.to)
416
430
  except PlanningError as exc:
417
431
  print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
418
432
  return 1
419
433
 
434
+ task_json_path = full_path / FILE_TASK_JSON
420
435
  event = {
421
436
  "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
422
437
  "task": full_path.relative_to(repo_root).as_posix(),
423
- "from_status": "in_progress",
438
+ "from_status": from_status,
439
+ "from_stage": from_stage,
440
+ "to_stage": args.to,
441
+ "revision": planning_record(data)["revision"],
424
442
  "reason": reason,
425
443
  "branch": data.get("branch"),
426
444
  }
@@ -443,7 +461,7 @@ def cmd_replan(args: argparse.Namespace) -> int:
443
461
  print(colored("Error: replan event recorded but task status was not changed", Colors.RED), file=sys.stderr)
444
462
  return 1
445
463
 
446
- print(colored(f"✓ Task returned to planning: {full_path.relative_to(repo_root)}", Colors.GREEN))
464
+ print(colored(f"✓ Task returned to planning/{args.to}: {full_path.relative_to(repo_root)}", Colors.GREEN))
447
465
  print("Reason:", reason)
448
466
  run_task_hooks("after_replan", task_json_path, repo_root)
449
467
  return 0
@@ -487,6 +505,15 @@ def cmd_finish(args: argparse.Namespace) -> int:
487
505
  return 0
488
506
 
489
507
 
508
+ def _planning_stage(data: dict[str, Any]) -> str | None:
509
+ meta = data.get("meta")
510
+ planning = meta.get("planning") if isinstance(meta, dict) else None
511
+ stage = planning.get("stage") if isinstance(planning, dict) else None
512
+ if isinstance(stage, str) and stage:
513
+ return stage
514
+ return "intake" if data.get("status") == "planning" else None
515
+
516
+
490
517
  def cmd_current(args: argparse.Namespace) -> int:
491
518
  """Show active task."""
492
519
  repo_root = get_repo_root()
@@ -513,6 +540,7 @@ def cmd_current(args: argparse.Namespace) -> int:
513
540
  "id": data.get("id") or data.get("name"),
514
541
  "title": data.get("title"),
515
542
  "status": data.get("status"),
543
+ "planning_stage": _planning_stage(data),
516
544
  "parent": data.get("parent"),
517
545
  "children": data.get("children", []),
518
546
  "branch": data.get("branch"),
@@ -757,6 +785,7 @@ def cmd_list(args: argparse.Namespace) -> int:
757
785
  "id": t.raw.get("id") or dir_name,
758
786
  "title": t.title,
759
787
  "status": t.status,
788
+ "planning_stage": _planning_stage(t.raw),
760
789
  "display_status": _display_status(t, all_statuses),
761
790
  "priority": t.priority,
762
791
  "assignee": t.assignee or None,
@@ -800,6 +829,9 @@ def cmd_list(args: argparse.Namespace) -> int:
800
829
  # Children progress
801
830
  progress = children_progress(t.children, all_statuses)
802
831
  status_label = _display_status(t, all_statuses)
832
+ stage = _planning_stage(t.raw)
833
+ if stage:
834
+ status_label += f" · {stage}"
803
835
 
804
836
  # Package tag
805
837
  pkg_tag = f" @{t.package}" if t.package else ""
@@ -883,6 +915,8 @@ Usage:
883
915
  python3 task.py select <dir> Select context without starting
884
916
  python3 task.py plan seal <dir> Seal the current material plan
885
917
  python3 task.py plan approve <dir> --revision N --basis "approval"
918
+ python3 task.py plan migrate --manifest <file> [--apply --receipt <.trellis/file>]
919
+ python3 task.py plan migrate --rollback <.trellis/receipt.json>
886
920
  python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package
887
921
  python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent
888
922
  python3 task.py create <title> --description <desc> --no-start Create without making it active in this session
@@ -891,7 +925,7 @@ Usage:
891
925
  python3 task.py validate <dir> Validate jsonl files
892
926
  python3 task.py list-context <dir> List jsonl entries
893
927
  python3 task.py start <dir> Set active task; records the checked-out branch when unset
894
- python3 task.py replan <dir> "<reason>" Return an in-progress task to planning
928
+ python3 task.py replan <dir> --to <stage> "<reason>" Return task to its earliest affected planning stage
895
929
  python3 task.py current [--source] Show active task
896
930
  python3 task.py finish Clear active task
897
931
  python3 task.py workflow <id> Select workflow variant for active task
@@ -1047,14 +1081,26 @@ def main() -> int:
1047
1081
  p_select = subparsers.add_parser("select", help="Select a task without changing its phase")
1048
1082
  p_select.add_argument("dir", help="Existing active task directory")
1049
1083
 
1050
- p_plan = subparsers.add_parser("plan", help="Seal a reviewed material plan or record its explicit approval")
1084
+ p_plan = subparsers.add_parser("plan", help="Advance planning, inspect a plan digest, seal, migrate, or record approval")
1051
1085
  plan_sub = p_plan.add_subparsers(dest="plan_command", required=True)
1086
+ p_advance = plan_sub.add_parser("advance", help="Advance to an allowed planning stage")
1087
+ p_advance.add_argument("dir", help="Planning task directory")
1088
+ p_advance.add_argument("--to", choices=sorted(STAGES - {"plan_closed"}), required=True, help="Target planning stage")
1089
+ p_digest = plan_sub.add_parser("digest", help="Show the digest of the complete current plan")
1090
+ p_digest.add_argument("dir", help="Planning task directory")
1052
1091
  p_seal = plan_sub.add_parser("seal", help="Seal the current material plan revision")
1053
1092
  p_seal.add_argument("dir", help="Planning task directory")
1093
+ p_seal.add_argument("--expected-digest", required=True, help="Digest shown with the complete plan to the user")
1054
1094
  p_approve = plan_sub.add_parser("approve", help="Record actual user approval of the displayed plan")
1055
1095
  p_approve.add_argument("dir", help="Planning task directory")
1056
1096
  p_approve.add_argument("--revision", type=int, required=True)
1057
1097
  p_approve.add_argument("--basis", required=True, help="Short non-sensitive basis for real approval")
1098
+ p_migrate = plan_sub.add_parser("migrate", help="Preview or apply a digest-guarded legacy task migration")
1099
+ migration_mode = p_migrate.add_mutually_exclusive_group()
1100
+ migration_mode.add_argument("--apply", action="store_true", help="Apply after validating every frozen source digest")
1101
+ migration_mode.add_argument("--rollback", type=Path, help="Conditionally restore a prior migration receipt")
1102
+ p_migrate.add_argument("--manifest", type=Path, help="Frozen JSON migration manifest (required except for rollback)")
1103
+ p_migrate.add_argument("--receipt", help="New rollback receipt path under .trellis/; required with --apply")
1058
1104
 
1059
1105
  # start
1060
1106
  p_start = subparsers.add_parser("start", help="Set active task")
@@ -1066,9 +1112,10 @@ def main() -> int:
1066
1112
  )
1067
1113
 
1068
1114
  # replan
1069
- p_replan = subparsers.add_parser("replan", help="Return an in-progress task to planning")
1115
+ p_replan = subparsers.add_parser("replan", help="Return an in-progress task to a selected planning stage")
1070
1116
  p_replan.add_argument("dir", help="Task directory")
1071
1117
  p_replan.add_argument("reason", nargs="+", help="Material reason for returning to planning")
1118
+ p_replan.add_argument("--to", choices=sorted(REPLAN_STAGES), required=True, help="Earliest affected planning stage")
1072
1119
 
1073
1120
  # current
1074
1121
  p_current = subparsers.add_parser("current", help="Show active task")
@@ -1176,6 +1223,7 @@ def main() -> int:
1176
1223
  p_archive = subparsers.add_parser("archive", help="Archive task")
1177
1224
  p_archive.add_argument("name", help="Task directory or name")
1178
1225
  p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
1226
+ p_archive.add_argument("--reason", help="Required when archiving a planning task")
1179
1227
  p_archive.add_argument(
1180
1228
  "--skip-branch-validation",
1181
1229
  action="store_true",