@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.
- 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.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/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 +11 -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
|
@@ -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.
|
|
@@ -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>"
|
|
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,
|
|
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("
|
|
241
|
-
if args.plan_command == "
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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":
|
|
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>"
|
|
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="
|
|
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",
|