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

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 (38) 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 +3 -2
  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/migrations/manifests/0.7.0-beta.54.json +8 -0
  10. package/dist/templates/codex/hooks/session-start.py +9 -18
  11. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md +1 -1
  12. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +1 -1
  13. package/dist/templates/common/commands/continue.md +1 -1
  14. package/dist/templates/common/commands/start.md +2 -3
  15. package/dist/templates/common/skills/idea-to-closed-plan.md +105 -0
  16. package/dist/templates/copilot/hooks/session-start.py +9 -18
  17. package/dist/templates/copilot/prompts/start.prompt.md +2 -3
  18. package/dist/templates/opencode/lib/session-utils.js +1 -1
  19. package/dist/templates/shared-hooks/inject-workflow-state.py +1 -1
  20. package/dist/templates/shared-hooks/session-start.py +10 -24
  21. package/dist/templates/trellis/index.d.ts +1 -0
  22. package/dist/templates/trellis/index.d.ts.map +1 -1
  23. package/dist/templates/trellis/index.js +2 -0
  24. package/dist/templates/trellis/index.js.map +1 -1
  25. package/dist/templates/trellis/scripts/common/task_migration.py +231 -0
  26. package/dist/templates/trellis/scripts/common/task_planning.py +252 -10
  27. package/dist/templates/trellis/scripts/common/task_store.py +11 -2
  28. package/dist/templates/trellis/scripts/task.py +86 -38
  29. package/dist/templates/trellis/workflow.md +23 -20
  30. package/dist/utils/template-fetcher.js +1 -1
  31. package/dist/utils/template-fetcher.js.map +1 -1
  32. package/dist/utils/workflow-resolver.d.ts +2 -2
  33. package/dist/utils/workflow-resolver.d.ts.map +1 -1
  34. package/dist/utils/workflow-resolver.js +9 -1
  35. package/dist/utils/workflow-resolver.js.map +1 -1
  36. package/package.json +2 -2
  37. package/dist/templates/common/skills/brainstorm.md +0 -236
  38. package/dist/templates/copilot/prompts/brainstorm.prompt.md +0 -162
@@ -0,0 +1,231 @@
1
+ """Digest-guarded, reversible migration of legacy task planning metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .io import write_text_atomic
12
+ from .ownership_record import assert_task_mutation_allowed
13
+ from .task_planning import REPLAN_STAGES
14
+
15
+
16
+ class MigrationError(ValueError):
17
+ """A migration manifest, precondition, or receipt is unsafe or stale."""
18
+
19
+
20
+ def _digest(content: bytes) -> str:
21
+ return hashlib.sha256(content).hexdigest()
22
+
23
+
24
+ def _read_object(path: Path, label: str) -> tuple[dict[str, Any], bytes]:
25
+ try:
26
+ raw = path.read_bytes()
27
+ data = json.loads(raw)
28
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
29
+ raise MigrationError(f"{label} is unreadable or invalid JSON: {path}") from exc
30
+ if not isinstance(data, dict):
31
+ raise MigrationError(f"{label} must contain a JSON object: {path}")
32
+ return data, raw
33
+
34
+
35
+ def _task_path(repo_root: Path, value: Any) -> Path:
36
+ if not isinstance(value, str) or not value or "\\" in value:
37
+ raise MigrationError("manifest task path must be a project-relative POSIX path")
38
+ relative = Path(value)
39
+ if relative.is_absolute() or relative.parts[:2] != (".trellis", "tasks") or len(relative.parts) != 3:
40
+ raise MigrationError(f"migration only accepts direct active tasks: {value!r}")
41
+ tasks_dir = repo_root / ".trellis" / "tasks"
42
+ if (repo_root / ".trellis").is_symlink() or tasks_dir.is_symlink() or relative.parts[2] in {"", ".", "..", "archive"}:
43
+ raise MigrationError(f"unsafe task path: {value!r}")
44
+ target = repo_root / relative
45
+ if target.is_symlink() or not target.is_dir() or target.resolve().parent != tasks_dir.resolve():
46
+ raise MigrationError(f"task path is missing, linked, or outside active tasks: {value!r}")
47
+ return target
48
+
49
+
50
+ def _load_manifest(repo_root: Path, manifest_path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
51
+ manifest, raw = _read_object(manifest_path, "migration manifest")
52
+ if manifest.get("schema_version") != 1 or not isinstance(manifest.get("tasks"), list) or not manifest["tasks"]:
53
+ raise MigrationError("migration manifest requires schema_version=1 and a non-empty tasks array")
54
+ rows: list[dict[str, Any]] = []
55
+ seen: set[str] = set()
56
+ for entry in manifest["tasks"]:
57
+ if not isinstance(entry, dict):
58
+ raise MigrationError("each migration task entry must be an object")
59
+ task_value = entry.get("path")
60
+ if not isinstance(task_value, str):
61
+ raise MigrationError("manifest task path must be a string")
62
+ task_dir = _task_path(repo_root, task_value)
63
+ if task_value in seen:
64
+ raise MigrationError(f"duplicate migration task path: {task_value}")
65
+ seen.add(task_value)
66
+ digests = entry.get("source_digests")
67
+ target = entry.get("target")
68
+ if not isinstance(digests, dict) or "task.json" not in digests or not isinstance(target, dict):
69
+ raise MigrationError(f"{task_value}: source_digests.task.json and target are required")
70
+ if any(not isinstance(name, str) or Path(name).name != name or not isinstance(value, str) or len(value) != 64 for name, value in digests.items()):
71
+ raise MigrationError(f"{task_value}: source digests must map filenames to SHA-256 hex digests")
72
+ if any(any(c not in "0123456789abcdef" for c in value) for value in digests.values()):
73
+ raise MigrationError(f"{task_value}: source digests must be lowercase SHA-256 hex")
74
+ execution = target.get("execution_class")
75
+ delivery = target.get("delivery_mode")
76
+ stage = target.get("stage")
77
+ if "stage" not in target:
78
+ raise MigrationError(f"{task_value}: target must explicitly set stage to a stage or null")
79
+ if execution not in ("planned", "direct") or delivery not in ("change_bearing", "analysis_only"):
80
+ raise MigrationError(f"{task_value}: target requires explicit execution_class and delivery_mode")
81
+ if stage is not None and (not isinstance(stage, str) or stage not in REPLAN_STAGES):
82
+ raise MigrationError(f"{task_value}: unsupported target stage: {stage!r}")
83
+ task_data, task_bytes = _read_object(task_dir / "task.json", f"{task_value}/task.json")
84
+ if task_data.get("status") == "planning" and stage != "intake":
85
+ raise MigrationError(f"{task_value}: existing planning task must migrate to intake")
86
+ if task_data.get("status") == "in_progress" and stage is not None:
87
+ raise MigrationError(f"{task_value}: migration must not infer a stage for an in_progress task")
88
+ if task_data.get("status") not in {"planning", "in_progress"}:
89
+ raise MigrationError(f"{task_value}: only planning or in_progress tasks may migrate")
90
+ for name, expected in digests.items():
91
+ path = task_dir / name
92
+ if path.is_symlink() or not path.is_file():
93
+ raise MigrationError(f"{task_value}/{name}: source file is missing or linked")
94
+ if _digest(path.read_bytes()) != expected:
95
+ raise MigrationError(f"{task_value}/{name}: source digest changed")
96
+ meta = task_data.get("meta", {})
97
+ if not isinstance(meta, dict):
98
+ raise MigrationError(f"{task_value}: task meta must be an object")
99
+ if "planning" in meta:
100
+ raise MigrationError(f"{task_value}: planning metadata already exists; refusing to overwrite it")
101
+ before_meta = {key: meta.get(key) for key in ("execution_class", "delivery_mode", "planning") if key in meta}
102
+ after_meta = {
103
+ **meta,
104
+ "execution_class": execution,
105
+ "delivery_mode": delivery,
106
+ "planning": {"revision": 1, **({"stage": stage} if stage else {})},
107
+ }
108
+ updated = {**task_data, "meta": after_meta}
109
+ rows.append({
110
+ "path": task_value,
111
+ "task_dir": task_dir,
112
+ "source_digests": digests,
113
+ "before_bytes": task_bytes,
114
+ "before_meta": before_meta,
115
+ "after_meta": {key: after_meta[key] for key in ("execution_class", "delivery_mode", "planning")},
116
+ "after_bytes": (json.dumps(updated, indent=2, ensure_ascii=False) + "\n").encode("utf-8"),
117
+ })
118
+ return {"manifest_digest": _digest(raw)}, rows
119
+
120
+
121
+ def _check_sources(rows: list[dict[str, Any]]) -> None:
122
+ for row in rows:
123
+ for name, expected in row["source_digests"].items():
124
+ path = row["task_dir"] / name
125
+ if path.is_symlink() or not path.is_file() or _digest(path.read_bytes()) != expected:
126
+ raise MigrationError(f"{row['path']}/{name}: source digest changed")
127
+
128
+
129
+ def _write(path: Path, raw: bytes) -> None:
130
+ if not write_text_atomic(path, raw.decode("utf-8")):
131
+ raise MigrationError(f"atomic write failed: {path}")
132
+
133
+
134
+ def _receipt_path(repo_root: Path, value: str) -> Path:
135
+ path = Path(value)
136
+ if not path.is_absolute():
137
+ path = repo_root / path
138
+ trellis_dir = (repo_root / ".trellis").resolve()
139
+ if path.is_symlink() or not path.parent.exists() or not path.parent.resolve().is_relative_to(trellis_dir):
140
+ raise MigrationError("receipt path must be a new regular file under .trellis/")
141
+ if path.exists():
142
+ raise MigrationError(f"refusing to overwrite migration receipt: {path}")
143
+ return path
144
+
145
+
146
+ def migrate(repo_root: Path, manifest_path: Path, *, apply: bool, receipt_value: str | None = None) -> dict[str, Any]:
147
+ if manifest_path.is_symlink() or not manifest_path.is_file():
148
+ raise MigrationError("manifest must be a regular file")
149
+ manifest, rows = _load_manifest(repo_root, manifest_path)
150
+ _check_sources(rows)
151
+ preview = [{"path": row["path"], "before": row["before_meta"], "after": row["after_meta"]} for row in rows]
152
+ if not apply:
153
+ return {"mode": "dry_run", "manifest_digest": manifest["manifest_digest"], "tasks": preview}
154
+ if not receipt_value:
155
+ raise MigrationError("--apply requires --receipt under .trellis/")
156
+ receipt_path = _receipt_path(repo_root, receipt_value)
157
+ receipt = {
158
+ "schema_version": 1,
159
+ "manifest_digest": manifest["manifest_digest"],
160
+ "created_at": datetime.now(timezone.utc).isoformat(),
161
+ "tasks": [],
162
+ }
163
+ for row in rows:
164
+ assert_task_mutation_allowed(repo_root, row["task_dir"])
165
+ written: list[dict[str, Any]] = []
166
+ try:
167
+ # Recheck every source before the first write to avoid partial migration on stale input.
168
+ _check_sources(rows)
169
+ for row in rows:
170
+ target = row["task_dir"] / "task.json"
171
+ _write(target, row["after_bytes"])
172
+ entry = {
173
+ "path": row["path"],
174
+ "before_task_json": row["before_bytes"].decode("utf-8"),
175
+ "after_digest": _digest(row["after_bytes"]),
176
+ }
177
+ receipt["tasks"].append(entry)
178
+ written.append(row)
179
+ receipt_raw = (json.dumps(receipt, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
180
+ _write(receipt_path, receipt_raw)
181
+ except Exception:
182
+ restore_errors: list[str] = []
183
+ for row in reversed(written):
184
+ try:
185
+ _write(row["task_dir"] / "task.json", row["before_bytes"])
186
+ except Exception as exc: # report if a disk failure also blocks recovery
187
+ restore_errors.append(f"{row['path']}: {exc}")
188
+ if restore_errors:
189
+ raise MigrationError("migration failed and rollback was incomplete: " + "; ".join(restore_errors))
190
+ raise
191
+ return {"mode": "applied", "receipt": receipt_path.relative_to(repo_root).as_posix(), "tasks": preview}
192
+
193
+
194
+ def rollback(repo_root: Path, receipt_path: Path) -> dict[str, Any]:
195
+ trellis_dir = (repo_root / ".trellis").resolve()
196
+ if (repo_root / ".trellis").is_symlink() or receipt_path.is_symlink() or not receipt_path.is_file() or not receipt_path.resolve().is_relative_to(trellis_dir):
197
+ raise MigrationError("receipt must be a regular file")
198
+ receipt, _raw = _read_object(receipt_path, "migration receipt")
199
+ rows = receipt.get("tasks")
200
+ if receipt.get("schema_version") != 1 or not isinstance(rows, list) or not rows:
201
+ raise MigrationError("migration receipt has an unsupported format")
202
+ prepared: list[tuple[Path, bytes, bytes]] = []
203
+ seen: set[str] = set()
204
+ for row in rows:
205
+ if not isinstance(row, dict) or not isinstance(row.get("path"), str) or row["path"] in seen:
206
+ raise MigrationError("migration receipt contains an invalid or duplicate task")
207
+ seen.add(row["path"])
208
+ task_dir = _task_path(repo_root, row["path"])
209
+ before = row.get("before_task_json")
210
+ after_digest = row.get("after_digest")
211
+ if not isinstance(before, str) or not isinstance(after_digest, str):
212
+ raise MigrationError(f"{row['path']}: receipt is missing rollback data")
213
+ current_path = task_dir / "task.json"
214
+ if current_path.is_symlink():
215
+ raise MigrationError(f"{row['path']}/task.json: symlinked task.json refused")
216
+ current = current_path.read_bytes()
217
+ if _digest(current) != after_digest:
218
+ raise MigrationError(f"{row['path']}/task.json: changed since migration; refusing rollback")
219
+ prepared.append((current_path, current, before.encode("utf-8")))
220
+ for path, _current, _before in prepared:
221
+ assert_task_mutation_allowed(repo_root, path.parent)
222
+ restored: list[tuple[Path, bytes]] = []
223
+ try:
224
+ for path, current, before in prepared:
225
+ _write(path, before)
226
+ restored.append((path, current))
227
+ except Exception:
228
+ for path, current in reversed(restored):
229
+ _write(path, current)
230
+ raise
231
+ return {"mode": "rolled_back", "tasks": [row["path"] for row in rows]}
@@ -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