@pennixrv/trellis 0.7.0-beta.51 → 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.
- package/README.md +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/shared.js +1 -1
- package/dist/configurators/shared.js.map +1 -1
- package/dist/migrations/manifests/0.7.0-beta.52.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.53.json +27 -0
- package/dist/templates/codex/hooks/session-start.py +9 -18
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +1 -1
- package/dist/templates/common/commands/continue.md +1 -1
- package/dist/templates/common/commands/start.md +2 -3
- package/dist/templates/common/skills/idea-to-closed-plan.md +105 -0
- package/dist/templates/copilot/hooks/session-start.py +9 -18
- package/dist/templates/copilot/prompts/start.prompt.md +2 -3
- package/dist/templates/opencode/lib/session-utils.js +1 -1
- package/dist/templates/shared-hooks/inject-workflow-state.py +1 -1
- package/dist/templates/shared-hooks/session-start.py +10 -24
- package/dist/templates/trellis/index.d.ts +1 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +2 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/ownership_record.py +27 -12
- package/dist/templates/trellis/scripts/common/task_migration.py +231 -0
- package/dist/templates/trellis/scripts/common/task_planning.py +252 -10
- package/dist/templates/trellis/scripts/common/task_store.py +18 -2
- package/dist/templates/trellis/scripts/task.py +86 -38
- package/dist/templates/trellis/workflow.md +23 -20
- package/dist/utils/template-fetcher.js +1 -1
- package/dist/utils/template-fetcher.js.map +1 -1
- package/dist/utils/workflow-resolver.d.ts +2 -2
- package/dist/utils/workflow-resolver.d.ts.map +1 -1
- package/dist/utils/workflow-resolver.js +9 -1
- package/dist/utils/workflow-resolver.js.map +1 -1
- package/package.json +2 -2
- package/dist/templates/common/skills/brainstorm.md +0 -236
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
@@ -1095,6 +1096,13 @@ def cmd_rename(args: argparse.Namespace) -> int:
|
|
|
1095
1096
|
)
|
|
1096
1097
|
return 1
|
|
1097
1098
|
|
|
1099
|
+
from .ownership_record import assert_task_rename_allowed
|
|
1100
|
+
try:
|
|
1101
|
+
assert_task_rename_allowed(repo_root, task_dir)
|
|
1102
|
+
except (OSError, ValueError) as exc:
|
|
1103
|
+
print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
|
|
1104
|
+
return 1
|
|
1105
|
+
|
|
1098
1106
|
old_name = task_dir.name
|
|
1099
1107
|
date_prefix, _old_slug = _split_date_prefix(old_name)
|
|
1100
1108
|
|
|
@@ -1378,6 +1386,11 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
|
|
1378
1386
|
file=sys.stderr,
|
|
1379
1387
|
)
|
|
1380
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
|
|
1381
1394
|
# Before any mutation: branch metadata is unrecoverable once the
|
|
1382
1395
|
# task leaves the active tree. Stale branches only warn.
|
|
1383
1396
|
if not _validate_branch_metadata(
|
|
@@ -1978,9 +1991,12 @@ def cmd_set_meta(args: argparse.Namespace) -> int:
|
|
|
1978
1991
|
meta = data.get("meta")
|
|
1979
1992
|
if not isinstance(meta, dict):
|
|
1980
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
|
|
1981
1997
|
if key in {"execution_class", "delivery_mode"} and meta.get(key) != value and "planning" in meta:
|
|
1982
1998
|
try:
|
|
1983
|
-
invalidate_plan(data)
|
|
1999
|
+
invalidate_plan(data, "intake")
|
|
1984
2000
|
except ValueError as exc:
|
|
1985
2001
|
print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
|
|
1986
2002
|
return 1
|