@pilotspace/add 1.0.0
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/GETTING-STARTED.md +238 -0
- package/LICENSE +20 -0
- package/README.md +106 -0
- package/bin/cli.js +131 -0
- package/docs/00-introduction.md +46 -0
- package/docs/01-principles.md +71 -0
- package/docs/02-the-flow.md +93 -0
- package/docs/03-step-1-specify.md +117 -0
- package/docs/04-step-2-scenarios.md +78 -0
- package/docs/05-step-3-contract.md +78 -0
- package/docs/06-step-4-tests.md +71 -0
- package/docs/07-step-5-build.md +80 -0
- package/docs/08-step-6-verify.md +63 -0
- package/docs/09-the-loop.md +43 -0
- package/docs/10-setup-and-stages.md +75 -0
- package/docs/11-governance.md +87 -0
- package/docs/12-roles.md +99 -0
- package/docs/13-adoption.md +67 -0
- package/docs/14-foundation.md +121 -0
- package/docs/README.md +70 -0
- package/docs/add-competencies.png +0 -0
- package/docs/add-flow.png +0 -0
- package/docs/add-foundation.png +0 -0
- package/docs/add-hierarchy.png +0 -0
- package/docs/appendix-a-templates.md +88 -0
- package/docs/appendix-b-prompts.md +119 -0
- package/docs/appendix-c-glossary.md +85 -0
- package/docs/appendix-d-worked-example.md +152 -0
- package/docs/appendix-e-checklists.md +80 -0
- package/docs/appendix-f-requirements-matrix.md +170 -0
- package/package.json +47 -0
- package/skill/add/SKILL.md +118 -0
- package/skill/add/deltas.md +69 -0
- package/skill/add/fold.md +66 -0
- package/skill/add/intake.md +49 -0
- package/skill/add/phases/0-setup.md +35 -0
- package/skill/add/phases/1-specify.md +55 -0
- package/skill/add/phases/2-scenarios.md +36 -0
- package/skill/add/phases/3-contract.md +41 -0
- package/skill/add/phases/4-tests.md +37 -0
- package/skill/add/phases/5-build.md +38 -0
- package/skill/add/phases/6-verify.md +39 -0
- package/skill/add/phases/7-observe.md +32 -0
- package/skill/add/run.md +152 -0
- package/skill/add/scope.md +58 -0
- package/tooling/add.py +1573 -0
- package/tooling/templates/CONVENTIONS.md.tmpl +8 -0
- package/tooling/templates/GLOSSARY.md.tmpl +3 -0
- package/tooling/templates/MILESTONE.md.tmpl +25 -0
- package/tooling/templates/MODEL_REGISTRY.md.tmpl +6 -0
- package/tooling/templates/PROJECT.md.tmpl +42 -0
- package/tooling/templates/TASK.md.tmpl +111 -0
- package/tooling/templates/dependencies.allowlist.tmpl +2 -0
package/tooling/add.py
ADDED
|
@@ -0,0 +1,1573 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ADD — minimal scaffolder + state tracker for AI-Driven Development.
|
|
3
|
+
|
|
4
|
+
One file = one task. This tool generates the per-task TASK.md (which Claude fills
|
|
5
|
+
in step by step) and maintains .add/state.json so any fresh session can resume
|
|
6
|
+
with `add.py status` instead of re-reading the whole repo. That is the anti-
|
|
7
|
+
context-rot core of the ADD method.
|
|
8
|
+
|
|
9
|
+
Stdlib only. Writes are atomic (temp + os.replace) and refuse to clobber
|
|
10
|
+
existing artifacts unless --force is given.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from datetime import date, datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# --- constants ---------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
ROOT_DIRNAME = ".add"
|
|
26
|
+
STATE_FILE = "state.json"
|
|
27
|
+
MILESTONE_FILE = "MILESTONE.md"
|
|
28
|
+
STAGES = ("prototype", "poc", "mvp", "production")
|
|
29
|
+
PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
30
|
+
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _phase_index(name: str) -> int:
|
|
34
|
+
"""Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
|
|
35
|
+
return PHASES.index(name)
|
|
36
|
+
|
|
37
|
+
# `add.py guide` copy: per-phase (concrete next action, book chapter to read).
|
|
38
|
+
# Keep the action wording aligned with each phase's EXIT line in the TASK template.
|
|
39
|
+
PHASE_GUIDE = {
|
|
40
|
+
"specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions least-sure first and flag the biggest risk",
|
|
41
|
+
"03-step-1-specify.md"),
|
|
42
|
+
"scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
|
|
43
|
+
"04-step-2-scenarios.md"),
|
|
44
|
+
"contract": ("freeze the shape — signature, fields, error codes; names match the glossary",
|
|
45
|
+
"05-step-3-contract.md"),
|
|
46
|
+
"tests": ("write one failing test per scenario; run them RED for the right reason",
|
|
47
|
+
"06-step-4-tests.md"),
|
|
48
|
+
"build": ("write the minimum code to pass the tests; change no test and no contract",
|
|
49
|
+
"07-step-5-build.md"),
|
|
50
|
+
"verify": ("run the suite + blind-spot checks, then record the gate",
|
|
51
|
+
"08-step-6-verify.md"),
|
|
52
|
+
"observe": ("note what to watch + the spec delta for the next loop",
|
|
53
|
+
"09-the-loop.md"),
|
|
54
|
+
"done": ("this task is done — pick the next feature",
|
|
55
|
+
"02-the-flow.md"),
|
|
56
|
+
}
|
|
57
|
+
# Phase -> who owns it, for the `--json` autonomy signal. An autonomous harness may run a
|
|
58
|
+
# phase only when owner=="ai" (stop is false); every other phase is a checkpoint. The map
|
|
59
|
+
# follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
|
|
60
|
+
# are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
|
|
61
|
+
PHASE_OWNER = {
|
|
62
|
+
"specify": "human", "scenarios": "human", "contract": "seam",
|
|
63
|
+
"tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
|
|
64
|
+
}
|
|
65
|
+
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist")
|
|
66
|
+
|
|
67
|
+
# Guideline-injection targets + version-stable markers. NEVER change these marker
|
|
68
|
+
# strings: a re-run finds the old block by exact match, so changing them would
|
|
69
|
+
# orphan every block written by a prior version (see TASK guideline-inject).
|
|
70
|
+
GUIDELINE_FILES = ("AGENTS.md", "CLAUDE.md")
|
|
71
|
+
_GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
|
|
72
|
+
_GUIDE_END = "<!-- ADD:END -->"
|
|
73
|
+
|
|
74
|
+
# Minimal embedded fallback so the tool still works if templates/ is missing
|
|
75
|
+
# (circuit breaker: never hard-fail just because a template file was deleted).
|
|
76
|
+
_FALLBACK_TASK = """# TASK: {title}
|
|
77
|
+
|
|
78
|
+
slug: {slug} · created: {date} · stage: {stage}
|
|
79
|
+
phase: specify
|
|
80
|
+
|
|
81
|
+
## 1 · SPECIFY
|
|
82
|
+
Feature:
|
|
83
|
+
Framings weighed:
|
|
84
|
+
Must:
|
|
85
|
+
Reject:
|
|
86
|
+
After:
|
|
87
|
+
Assumptions — least-sure first:
|
|
88
|
+
⚠ <most likely wrong> — least sure because <why>; if wrong: <cost>
|
|
89
|
+
|
|
90
|
+
## 2 · SCENARIOS
|
|
91
|
+
## 3 · CONTRACT
|
|
92
|
+
Status: DRAFT
|
|
93
|
+
## 4 · TESTS
|
|
94
|
+
## 5 · BUILD
|
|
95
|
+
## 6 · VERIFY
|
|
96
|
+
### GATE RECORD
|
|
97
|
+
Outcome:
|
|
98
|
+
## 7 · OBSERVE
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- low-level IO (designed for failure: atomic, no silent clobber) ----------
|
|
103
|
+
|
|
104
|
+
def _now() -> str:
|
|
105
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
109
|
+
"""Write via a temp file in the same dir, then atomically replace.
|
|
110
|
+
|
|
111
|
+
Avoids a half-written file if the process dies mid-write.
|
|
112
|
+
"""
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
115
|
+
try:
|
|
116
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
117
|
+
fh.write(text)
|
|
118
|
+
os.replace(tmp, path)
|
|
119
|
+
finally:
|
|
120
|
+
if os.path.exists(tmp):
|
|
121
|
+
os.unlink(tmp)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _templates_dir() -> Path:
|
|
125
|
+
return Path(__file__).resolve().parent / "templates"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_template(name: str, **subs: str) -> str:
|
|
129
|
+
"""Load templates/<name>.tmpl and substitute {{key}} tokens.
|
|
130
|
+
|
|
131
|
+
Falls back to a built-in minimal template only for TASK.md.
|
|
132
|
+
"""
|
|
133
|
+
tmpl = _templates_dir() / f"{name}.tmpl"
|
|
134
|
+
if tmpl.exists():
|
|
135
|
+
text = tmpl.read_text(encoding="utf-8")
|
|
136
|
+
elif name == "TASK.md":
|
|
137
|
+
text = _FALLBACK_TASK.replace("{title}", "{{title}}").replace(
|
|
138
|
+
"{slug}", "{{slug}}").replace("{date}", "{{date}}").replace("{stage}", "{{stage}}")
|
|
139
|
+
else:
|
|
140
|
+
text = ""
|
|
141
|
+
for key, val in subs.items():
|
|
142
|
+
text = text.replace("{{" + key + "}}", val)
|
|
143
|
+
return text
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- state -------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def find_root(start: Path | None = None) -> Path | None:
|
|
149
|
+
"""Walk up from cwd to find a .add/ project root."""
|
|
150
|
+
cur = (start or Path.cwd()).resolve()
|
|
151
|
+
for d in (cur, *cur.parents):
|
|
152
|
+
if (d / ROOT_DIRNAME / STATE_FILE).exists():
|
|
153
|
+
return d / ROOT_DIRNAME
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _require_root() -> Path:
|
|
158
|
+
root = find_root()
|
|
159
|
+
if root is None:
|
|
160
|
+
_die("no .add/ project found. Run `add.py init` first.")
|
|
161
|
+
return root
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_state(root: Path) -> dict:
|
|
165
|
+
return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_state_for_json() -> tuple[Path, dict]:
|
|
169
|
+
"""Fail-closed state load for `--json` paths: a missing project or unparseable
|
|
170
|
+
state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
|
|
171
|
+
JSON object a harness might parse). Built from State only — reads no docs/ chapter."""
|
|
172
|
+
root = find_root()
|
|
173
|
+
if root is None:
|
|
174
|
+
_die("no_state")
|
|
175
|
+
try:
|
|
176
|
+
return root, json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
|
|
177
|
+
except (json.JSONDecodeError, OSError):
|
|
178
|
+
_die("no_state")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _phase_owner(phase: str) -> str:
|
|
182
|
+
"""Map a phase to its owner (human|seam|ai); `unmapped_phase` if absent (fail closed)."""
|
|
183
|
+
owner = PHASE_OWNER.get(phase)
|
|
184
|
+
if owner is None:
|
|
185
|
+
_die("unmapped_phase")
|
|
186
|
+
return owner
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def save_state(root: Path, state: dict) -> None:
|
|
190
|
+
state["updated"] = _now()
|
|
191
|
+
_atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _die(msg: str, code: int = 1) -> None:
|
|
195
|
+
print(f"add: error: {msg}", file=sys.stderr)
|
|
196
|
+
raise SystemExit(code)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --- guideline injection (dynamic-by-reference; designed for failure) --------
|
|
200
|
+
#
|
|
201
|
+
# Inject one stable, marker-delimited ADD block into the project root's AGENTS.md
|
|
202
|
+
# and CLAUDE.md. The block is DYNAMIC-BY-REFERENCE: it tells the agent to run
|
|
203
|
+
# `add.py status` and read PROJECT.md — it never embeds live state (slug, phase,
|
|
204
|
+
# gate). Auto-updated context files measurably hurt (ETH-Zurich: ~3% lower success,
|
|
205
|
+
# 20%+ more cost), so the stable pointer is the whole point.
|
|
206
|
+
|
|
207
|
+
def _guideline_block() -> str:
|
|
208
|
+
"""The canonical ADD block (markers + body, no trailing newline)."""
|
|
209
|
+
return (
|
|
210
|
+
f"{_GUIDE_BEGIN}\n"
|
|
211
|
+
"## ADD — how to work in this repo\n"
|
|
212
|
+
"\n"
|
|
213
|
+
"This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
|
|
214
|
+
"the human owns direction and verification. Before you change code:\n"
|
|
215
|
+
"\n"
|
|
216
|
+
"1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
|
|
217
|
+
" next (the resume point; read it first every session).\n"
|
|
218
|
+
"2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
|
|
219
|
+
" builds on.\n"
|
|
220
|
+
"3. Let the **`add` skill drive the flow**: INTAKE sizes the request into a\n"
|
|
221
|
+
" milestone, then each task runs the **one-approval front** — you draft Spec +\n"
|
|
222
|
+
" Scenarios + Contract + Tests as one bundle, the human gives ONE approval at the\n"
|
|
223
|
+
" frozen contract — followed by a self-driving build→verify run. `add.py` is your\n"
|
|
224
|
+
" hands (scaffold + track state); the human talks to you, not the CLI.\n"
|
|
225
|
+
"\n"
|
|
226
|
+
"The full method (the book) is in `.add/docs/`; the `add` skill loads the right\n"
|
|
227
|
+
"phase guide on demand. This block is generated by `add.py sync-guidelines`; edit\n"
|
|
228
|
+
"outside the markers, not inside.\n"
|
|
229
|
+
f"{_GUIDE_END}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _inject_block(path: Path) -> str:
|
|
234
|
+
"""Write the ADD block into `path`. Returns created|updated|unchanged.
|
|
235
|
+
|
|
236
|
+
- unchanged: on-disk block already matches -> no write, no .bak (idempotent).
|
|
237
|
+
- updated: existing content changes -> back up the original to <path>.bak first.
|
|
238
|
+
- created: file did not exist -> write the block, no .bak.
|
|
239
|
+
User content outside the markers is always preserved.
|
|
240
|
+
"""
|
|
241
|
+
block = _guideline_block()
|
|
242
|
+
if path.exists():
|
|
243
|
+
current = path.read_text(encoding="utf-8")
|
|
244
|
+
begin = current.find(_GUIDE_BEGIN)
|
|
245
|
+
if begin != -1:
|
|
246
|
+
end = current.find(_GUIDE_END, begin)
|
|
247
|
+
if end != -1: # replace only the marked region
|
|
248
|
+
end += len(_GUIDE_END)
|
|
249
|
+
new = current[:begin] + block + current[end:]
|
|
250
|
+
else: # begin without end: corrupt — append fresh
|
|
251
|
+
print(f"add: warning: {path.name}: found an ADD:BEGIN with no ADD:END "
|
|
252
|
+
"— appending a fresh block; review the result", file=sys.stderr)
|
|
253
|
+
new = current.rstrip("\n") + "\n\n" + block + "\n"
|
|
254
|
+
else: # no block yet — append, keep user content
|
|
255
|
+
new = current.rstrip("\n") + "\n\n" + block + "\n"
|
|
256
|
+
if new == current:
|
|
257
|
+
return "unchanged"
|
|
258
|
+
_atomic_write(Path(str(path) + ".bak"), current) # rollback path before mutate
|
|
259
|
+
_atomic_write(path, new)
|
|
260
|
+
return "updated"
|
|
261
|
+
_atomic_write(path, block + "\n")
|
|
262
|
+
return "created"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
|
|
266
|
+
"""Inject the block into each guideline file under `project_root`.
|
|
267
|
+
|
|
268
|
+
Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
|
|
269
|
+
written once, against the REAL file (never replacing the symlink with a
|
|
270
|
+
regular file). Per-target OSError is isolated (warn+skip) so one unwritable
|
|
271
|
+
file never aborts the run or `init`.
|
|
272
|
+
"""
|
|
273
|
+
results: list[tuple[str, str]] = []
|
|
274
|
+
seen: set[str] = set()
|
|
275
|
+
for name in GUIDELINE_FILES:
|
|
276
|
+
target = project_root / name
|
|
277
|
+
real = os.path.realpath(target)
|
|
278
|
+
if real in seen:
|
|
279
|
+
continue
|
|
280
|
+
seen.add(real)
|
|
281
|
+
write_target = Path(real) if target.is_symlink() else target
|
|
282
|
+
try:
|
|
283
|
+
action = _inject_block(write_target)
|
|
284
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
285
|
+
# design for failure: an unwritable target OR a non-UTF-8 existing file
|
|
286
|
+
# (e.g. a UTF-16 CLAUDE.md from a Windows editor) must not crash init or
|
|
287
|
+
# abort the other target — warn and skip this one.
|
|
288
|
+
print(f"add: warning: could not sync {name} — {exc}; skipped",
|
|
289
|
+
file=sys.stderr)
|
|
290
|
+
action = "skipped"
|
|
291
|
+
results.append((name, action))
|
|
292
|
+
return results
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --- commands ----------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
298
|
+
base = Path(args.dir).resolve()
|
|
299
|
+
root = base / ROOT_DIRNAME
|
|
300
|
+
state_path = root / STATE_FILE
|
|
301
|
+
if state_path.exists() and not args.force:
|
|
302
|
+
_die(f"already initialised at {root} (use --force to reset state)")
|
|
303
|
+
|
|
304
|
+
(root / "tasks").mkdir(parents=True, exist_ok=True)
|
|
305
|
+
today = date.today().isoformat()
|
|
306
|
+
proj_name = args.name or base.name
|
|
307
|
+
|
|
308
|
+
# survivor-layer files — never clobber an existing one, never write a blank one
|
|
309
|
+
for fname in SETUP_FILES:
|
|
310
|
+
dest = root / fname
|
|
311
|
+
if dest.exists():
|
|
312
|
+
continue
|
|
313
|
+
rendered = _render_template(fname, date=today, project=proj_name, stage=args.stage)
|
|
314
|
+
if not rendered.strip():
|
|
315
|
+
# A missing/stale template rendered to nothing. Skip rather than create
|
|
316
|
+
# a 0-content survivor file (design-for-failure; circuit breaker so an
|
|
317
|
+
# upgrade with a stale templates/ dir can't silently produce empty docs).
|
|
318
|
+
print(f"add: warning: template for {fname} is missing/blank — skipped",
|
|
319
|
+
file=sys.stderr)
|
|
320
|
+
continue
|
|
321
|
+
_atomic_write(dest, rendered)
|
|
322
|
+
|
|
323
|
+
state = {
|
|
324
|
+
"project": proj_name,
|
|
325
|
+
"stage": args.stage,
|
|
326
|
+
"active_task": None,
|
|
327
|
+
"active_milestone": None,
|
|
328
|
+
"tasks": {},
|
|
329
|
+
"milestones": {},
|
|
330
|
+
"created": _now(),
|
|
331
|
+
"updated": _now(),
|
|
332
|
+
}
|
|
333
|
+
save_state(root, state)
|
|
334
|
+
# zero-config: give any agent a stable pointer into the ADD runtime.
|
|
335
|
+
for name, action in _inject_guidelines(base):
|
|
336
|
+
if action != "unchanged":
|
|
337
|
+
print(f"{action:>9} {name}")
|
|
338
|
+
print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
|
|
339
|
+
print("next: open Claude Code, run `/add`, and say what you want to build —")
|
|
340
|
+
print(" the `add` skill sizes it into a milestone and drives the build with you.")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
344
|
+
project_root = _require_root().parent
|
|
345
|
+
for name, action in _inject_guidelines(project_root):
|
|
346
|
+
print(f"{action:>9} {name}")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def cmd_new_task(args: argparse.Namespace) -> None:
|
|
350
|
+
root = _require_root()
|
|
351
|
+
state = load_state(root)
|
|
352
|
+
slug = args.slug
|
|
353
|
+
if not slug.replace("-", "").replace("_", "").isalnum():
|
|
354
|
+
_die("slug must be alphanumeric with - or _ only")
|
|
355
|
+
tdir = root / "tasks" / slug
|
|
356
|
+
task_md = tdir / "TASK.md"
|
|
357
|
+
if task_md.exists() and not args.force:
|
|
358
|
+
_die(f"task '{slug}' already exists (use --force to overwrite TASK.md)")
|
|
359
|
+
|
|
360
|
+
# link to a milestone (explicit, or the active one) — validate before any write
|
|
361
|
+
milestone = getattr(args, "milestone", None) or state.get("active_milestone")
|
|
362
|
+
if milestone and milestone not in state.get("milestones", {}):
|
|
363
|
+
_die("unknown_milestone")
|
|
364
|
+
depends_on = _parse_deps(getattr(args, "depends_on", None))
|
|
365
|
+
|
|
366
|
+
(tdir / "tests").mkdir(parents=True, exist_ok=True)
|
|
367
|
+
(tdir / "src").mkdir(parents=True, exist_ok=True)
|
|
368
|
+
title = args.title or slug.replace("-", " ").replace("_", " ").title()
|
|
369
|
+
_atomic_write(task_md, _render_template(
|
|
370
|
+
"TASK.md", title=title, slug=slug, date=date.today().isoformat(), stage=state["stage"]))
|
|
371
|
+
|
|
372
|
+
state["tasks"][slug] = {
|
|
373
|
+
"title": title,
|
|
374
|
+
"phase": "specify",
|
|
375
|
+
"gate": "none",
|
|
376
|
+
"milestone": milestone,
|
|
377
|
+
"depends_on": depends_on,
|
|
378
|
+
"created": _now(),
|
|
379
|
+
"updated": _now(),
|
|
380
|
+
}
|
|
381
|
+
state["active_task"] = slug
|
|
382
|
+
save_state(root, state)
|
|
383
|
+
print(f"created task '{slug}' -> {task_md}")
|
|
384
|
+
if milestone:
|
|
385
|
+
print(f"linked to milestone '{milestone}'" +
|
|
386
|
+
(f", depends-on {depends_on}" if depends_on else ""))
|
|
387
|
+
else:
|
|
388
|
+
# warn-never-block: the task is created (escape hatch), but nudge back toward the
|
|
389
|
+
# intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
|
|
390
|
+
print(f"note: '{slug}' is not attached to a milestone — size it via /add (intake), "
|
|
391
|
+
"or pass --milestone <id>")
|
|
392
|
+
print("active task set. phase: specify. Fill section 1 (SPECIFY), then: add.py advance")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _parse_deps(raw: str | None) -> list[str]:
|
|
396
|
+
if not raw:
|
|
397
|
+
return []
|
|
398
|
+
return [d.strip() for d in raw.split(",") if d.strip()]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _task_done(t: dict) -> bool:
|
|
402
|
+
# Matrix 3: a task is done when Verify reads PASS *or a signed RISK-ACCEPTED*.
|
|
403
|
+
# Both completing gates advance phase to "done" (cmd_gate), and a waiver is
|
|
404
|
+
# signed at gate time — so a verdict gate is enough here; we need not re-read
|
|
405
|
+
# the waiver. HARD-STOP never reaches "done". A bare `phase done` (escape
|
|
406
|
+
# hatch, gate still "none") deliberately does NOT count: completion needs a
|
|
407
|
+
# recorded verdict, not just a phase marker.
|
|
408
|
+
return t.get("phase") == "done" and t.get("gate") in ("PASS", "RISK-ACCEPTED")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _archived_task_slugs(state: dict) -> set[str]:
|
|
412
|
+
"""Slugs of tasks that left active state via archive — all were PASS-done at
|
|
413
|
+
archive time, so a dep on one of them counts as satisfied (not dangling).
|
|
414
|
+
|
|
415
|
+
INVARIANT: this is sound only because cmd_archive_milestone REFUSES to archive a
|
|
416
|
+
milestone with an incomplete member. Any NEW task-removal path (un-archive/restore,
|
|
417
|
+
heavy archive) MUST preserve "archived ⇒ was PASS-done" or `ready` will green-light
|
|
418
|
+
a task whose dependency never completed."""
|
|
419
|
+
out: set[str] = set()
|
|
420
|
+
for rec in state.get("archived", []):
|
|
421
|
+
out.update(rec.get("task_slugs", [])) # .get: pre-v2 records have none
|
|
422
|
+
return out
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _resolve_task(state: dict, slug: str | None) -> str:
|
|
426
|
+
slug = slug or state.get("active_task")
|
|
427
|
+
if not slug:
|
|
428
|
+
_die("no task specified and no active task set")
|
|
429
|
+
if slug not in state["tasks"]:
|
|
430
|
+
_die(f"unknown task '{slug}'")
|
|
431
|
+
return slug
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cmd_phase(args: argparse.Namespace) -> None:
|
|
435
|
+
root = _require_root()
|
|
436
|
+
state = load_state(root)
|
|
437
|
+
slug = _resolve_task(state, args.slug)
|
|
438
|
+
if args.phase not in PHASES:
|
|
439
|
+
_die(f"phase must be one of: {', '.join(PHASES)}")
|
|
440
|
+
state["tasks"][slug]["phase"] = args.phase
|
|
441
|
+
state["tasks"][slug]["updated"] = _now()
|
|
442
|
+
_sync_task_marker(root, slug, args.phase)
|
|
443
|
+
save_state(root, state)
|
|
444
|
+
print(f"task '{slug}' phase -> {args.phase}")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def cmd_advance(args: argparse.Namespace) -> None:
|
|
448
|
+
root = _require_root()
|
|
449
|
+
state = load_state(root)
|
|
450
|
+
slug = _resolve_task(state, args.slug)
|
|
451
|
+
cur = state["tasks"][slug]["phase"]
|
|
452
|
+
idx = PHASES.index(cur)
|
|
453
|
+
if idx >= len(PHASES) - 1:
|
|
454
|
+
_die(f"task '{slug}' already at final phase ({cur})")
|
|
455
|
+
nxt = PHASES[idx + 1]
|
|
456
|
+
state["tasks"][slug]["phase"] = nxt
|
|
457
|
+
state["tasks"][slug]["updated"] = _now()
|
|
458
|
+
_sync_task_marker(root, slug, nxt)
|
|
459
|
+
save_state(root, state)
|
|
460
|
+
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def cmd_gate(args: argparse.Namespace) -> None:
|
|
464
|
+
root = _require_root()
|
|
465
|
+
state = load_state(root)
|
|
466
|
+
slug = _resolve_task(state, args.slug)
|
|
467
|
+
if args.outcome not in GATES:
|
|
468
|
+
_die(f"outcome must be one of: {', '.join(GATES)}")
|
|
469
|
+
# Completing outcomes (PASS, RISK-ACCEPTED) are the VERIFY step's verdict, so they
|
|
470
|
+
# share the verify-phase guard — no silent skips (principle 7). HARD-STOP stays
|
|
471
|
+
# recordable from any phase (a security finding is always HARD-STOP). The
|
|
472
|
+
# deliberate, logged override is `add.py phase verify <slug>`.
|
|
473
|
+
completing = args.outcome in ("PASS", "RISK-ACCEPTED")
|
|
474
|
+
if completing:
|
|
475
|
+
current = state["tasks"][slug]["phase"]
|
|
476
|
+
if _phase_index(current) < _phase_index("verify"):
|
|
477
|
+
code = ("gate_pass_before_verify" if args.outcome == "PASS"
|
|
478
|
+
else "gate_risk_accepted_before_verify")
|
|
479
|
+
_die(f"{code}: task '{slug}' is at '{current}'; reach the verify phase "
|
|
480
|
+
f"first (or `add.py phase verify {slug}` to override)")
|
|
481
|
+
if args.outcome == "RISK-ACCEPTED":
|
|
482
|
+
# A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
|
|
483
|
+
# so a later `check` can read/expire it. Refuse a partial waiver outright.
|
|
484
|
+
missing = [f for f in ("owner", "ticket", "expires") if not getattr(args, f)]
|
|
485
|
+
if missing:
|
|
486
|
+
_die("waiver_incomplete: RISK-ACCEPTED is a signed waiver; supply "
|
|
487
|
+
+ ", ".join("--" + m for m in missing))
|
|
488
|
+
state["tasks"][slug]["waiver"] = {
|
|
489
|
+
"owner": args.owner, "ticket": args.ticket, "expires": args.expires,
|
|
490
|
+
}
|
|
491
|
+
if completing:
|
|
492
|
+
state["tasks"][slug]["phase"] = "done"
|
|
493
|
+
_sync_task_marker(root, slug, "done")
|
|
494
|
+
state["tasks"][slug]["gate"] = args.outcome
|
|
495
|
+
state["tasks"][slug]["updated"] = _now()
|
|
496
|
+
save_state(root, state)
|
|
497
|
+
print(f"task '{slug}' gate -> {args.outcome}")
|
|
498
|
+
if args.outcome == "HARD-STOP":
|
|
499
|
+
print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_stage(args: argparse.Namespace) -> None:
|
|
503
|
+
root = _require_root()
|
|
504
|
+
state = load_state(root)
|
|
505
|
+
if args.stage not in STAGES:
|
|
506
|
+
_die(f"stage must be one of: {', '.join(STAGES)}")
|
|
507
|
+
state["stage"] = args.stage
|
|
508
|
+
save_state(root, state)
|
|
509
|
+
print(f"project stage -> {args.stage}")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
513
|
+
if getattr(args, "json", False):
|
|
514
|
+
_, state = _load_state_for_json()
|
|
515
|
+
tasks = state.get("tasks") or {}
|
|
516
|
+
milestones = state.get("milestones") or {}
|
|
517
|
+
ms_list = []
|
|
518
|
+
for mslug, m in milestones.items():
|
|
519
|
+
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
520
|
+
ms_list.append({"slug": mslug, "status": m.get("status", "active"),
|
|
521
|
+
"done": sum(1 for t in members if _task_done(t)),
|
|
522
|
+
"total": len(members)})
|
|
523
|
+
print(json.dumps({
|
|
524
|
+
"project": state.get("project"), "stage": state.get("stage"),
|
|
525
|
+
"active_task": state.get("active_task"),
|
|
526
|
+
"milestones": ms_list,
|
|
527
|
+
"tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
528
|
+
"milestone": t.get("milestone")} for s, t in tasks.items()]}))
|
|
529
|
+
return
|
|
530
|
+
root = _require_root()
|
|
531
|
+
state = load_state(root)
|
|
532
|
+
active = state.get("active_task")
|
|
533
|
+
tasks = state.get("tasks", {})
|
|
534
|
+
print(f"project : {state['project']}")
|
|
535
|
+
print(f"stage : {state['stage']}")
|
|
536
|
+
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
537
|
+
if (root / "PROJECT.md").exists():
|
|
538
|
+
print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
|
|
539
|
+
|
|
540
|
+
# milestone rollup (only when milestones are in use)
|
|
541
|
+
milestones = state.get("milestones") or {}
|
|
542
|
+
active_ms = state.get("active_milestone")
|
|
543
|
+
if milestones:
|
|
544
|
+
print("milestones:")
|
|
545
|
+
for mslug, m in milestones.items():
|
|
546
|
+
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
547
|
+
done = sum(1 for t in members if _task_done(t))
|
|
548
|
+
mark = "*" if mslug == active_ms else " "
|
|
549
|
+
print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
|
|
550
|
+
f" status={m.get('status', 'active')}")
|
|
551
|
+
|
|
552
|
+
# archived rollup — one line keeps state visible without re-bloating status
|
|
553
|
+
archived = state.get("archived") or []
|
|
554
|
+
if archived:
|
|
555
|
+
n = len(archived)
|
|
556
|
+
m_tasks = sum(rec.get("tasks", 0) for rec in archived)
|
|
557
|
+
print(f"archived: {n} milestone{'s' if n != 1 else ''} "
|
|
558
|
+
f"({m_tasks} task{'s' if m_tasks != 1 else ''})")
|
|
559
|
+
|
|
560
|
+
print(f"active : {active or '(none)'}")
|
|
561
|
+
if not tasks:
|
|
562
|
+
# First-run panel: a brand-new project's status is the moment a user is most
|
|
563
|
+
# lost. Lead with the AI-first move (/add), keep the CLI as the escape hatch —
|
|
564
|
+
# mirrors `init`'s next-hint so the entry point is actionable, not a bare line.
|
|
565
|
+
print("tasks : (none yet)")
|
|
566
|
+
print()
|
|
567
|
+
print("next : you're set up. In Claude Code, run /add and say what you want to")
|
|
568
|
+
print(" build — the `add` skill sizes it into a milestone and drives the")
|
|
569
|
+
print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
|
|
570
|
+
return
|
|
571
|
+
print("tasks :")
|
|
572
|
+
for slug, t in tasks.items():
|
|
573
|
+
mark = "*" if slug == active else " "
|
|
574
|
+
deps = t.get("depends_on") or []
|
|
575
|
+
dep_s = f" deps={','.join(deps)}" if deps else ""
|
|
576
|
+
ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
|
|
577
|
+
print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}")
|
|
578
|
+
if active:
|
|
579
|
+
ph = tasks[active]["phase"]
|
|
580
|
+
if ph == "done":
|
|
581
|
+
print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
|
|
582
|
+
print(" start the next feature: add.py new-task <slug>")
|
|
583
|
+
else:
|
|
584
|
+
print(f"\nresume : task '{active}' is at phase '{ph}'.")
|
|
585
|
+
print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cmd_guide(args: argparse.Namespace) -> None:
|
|
589
|
+
"""Answer "what do I do next?" for the active (or named) task.
|
|
590
|
+
|
|
591
|
+
Strictly read-only: load_state only — never save_state, never writes a TASK.md.
|
|
592
|
+
"""
|
|
593
|
+
if getattr(args, "json", False):
|
|
594
|
+
_, state = _load_state_for_json()
|
|
595
|
+
slug = args.slug or state.get("active_task")
|
|
596
|
+
if not slug:
|
|
597
|
+
print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
|
|
598
|
+
"next_step": "start your first feature -> add.py new-task <slug>",
|
|
599
|
+
"chapter": ".add/docs/02-the-flow.md", "gate": None}))
|
|
600
|
+
return
|
|
601
|
+
t = (state.get("tasks") or {}).get(slug)
|
|
602
|
+
if t is None:
|
|
603
|
+
_die(f"unknown task '{slug}'")
|
|
604
|
+
phase = t.get("phase")
|
|
605
|
+
owner = _phase_owner(phase) # _die unmapped_phase before any stdout
|
|
606
|
+
action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
|
|
607
|
+
print(json.dumps({"task": slug, "phase": phase, "owner": owner,
|
|
608
|
+
"stop": owner != "ai", "next_step": action,
|
|
609
|
+
"chapter": f".add/docs/{chapter}", "gate": t.get("gate")}))
|
|
610
|
+
return
|
|
611
|
+
root = _require_root()
|
|
612
|
+
state = load_state(root)
|
|
613
|
+
slug = args.slug or state.get("active_task")
|
|
614
|
+
if not slug:
|
|
615
|
+
print("active : (none)")
|
|
616
|
+
print('next : start your first feature -> add.py new-task <slug> --title "..."')
|
|
617
|
+
print("read : .add/docs/02-the-flow.md")
|
|
618
|
+
return
|
|
619
|
+
if slug not in state.get("tasks", {}):
|
|
620
|
+
_die(f"unknown task '{slug}'")
|
|
621
|
+
phase = state["tasks"][slug]["phase"]
|
|
622
|
+
entry = PHASE_GUIDE.get(phase)
|
|
623
|
+
if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError
|
|
624
|
+
_die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
|
|
625
|
+
action, chapter = entry
|
|
626
|
+
print(f"active : {slug} (phase: {phase})")
|
|
627
|
+
print(f"next : {action}")
|
|
628
|
+
print(f"read : .add/docs/{chapter}")
|
|
629
|
+
if phase == "verify":
|
|
630
|
+
print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
|
|
631
|
+
elif phase == "done":
|
|
632
|
+
print("then : start the next feature -> add.py new-task <slug>")
|
|
633
|
+
else:
|
|
634
|
+
print("then : add.py advance")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _read_task_phase(root: Path, slug: str) -> str | None:
|
|
638
|
+
"""Read the `phase:` marker from a task's TASK.md, or None if absent."""
|
|
639
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
640
|
+
if not task_md.exists():
|
|
641
|
+
return None
|
|
642
|
+
for line in task_md.read_text(encoding="utf-8").splitlines():
|
|
643
|
+
if line.startswith("phase:"):
|
|
644
|
+
rest = line[len("phase:"):].strip()
|
|
645
|
+
return rest.split()[0] if rest else None
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def cmd_check(args: argparse.Namespace) -> None:
|
|
650
|
+
"""Read-only integrity check of the .add project. Exit 1 if anything fails."""
|
|
651
|
+
as_json = getattr(args, "json", False)
|
|
652
|
+
if as_json:
|
|
653
|
+
root, state = _load_state_for_json() # fail closed -> no_state + empty stdout
|
|
654
|
+
else:
|
|
655
|
+
root = find_root()
|
|
656
|
+
if root is None:
|
|
657
|
+
_die("no_project")
|
|
658
|
+
try:
|
|
659
|
+
state = json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
|
|
660
|
+
except (json.JSONDecodeError, OSError):
|
|
661
|
+
_die("state_invalid")
|
|
662
|
+
|
|
663
|
+
checks: list[tuple[bool, str, str]] = [] # (ok, description, reason-if-failed)
|
|
664
|
+
for key in ("project", "stage", "active_task", "tasks"):
|
|
665
|
+
checks.append((key in state, f"state has key '{key}'", "missing"))
|
|
666
|
+
|
|
667
|
+
tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
|
|
668
|
+
milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
|
|
669
|
+
archived_slugs = _archived_task_slugs(state) # archived deps still resolve
|
|
670
|
+
warnings: list[tuple[str, str]] = [] # (name, reason) — nudges that NEVER feed `failed`
|
|
671
|
+
for slug, t in tasks.items():
|
|
672
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
673
|
+
checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing"))
|
|
674
|
+
marker, want = _read_task_phase(root, slug), t.get("phase")
|
|
675
|
+
checks.append((marker == want, f"task '{slug}' marker matches state",
|
|
676
|
+
f"marker={marker!r} state={want!r}"))
|
|
677
|
+
# drift: milestone + dependency references must resolve
|
|
678
|
+
ms = t.get("milestone")
|
|
679
|
+
if ms is not None:
|
|
680
|
+
checks.append((ms in milestones, f"task '{slug}' milestone resolves",
|
|
681
|
+
f"unknown milestone {ms!r}"))
|
|
682
|
+
else:
|
|
683
|
+
# warn-never-block: a task outside a milestone is a structural nudge back toward
|
|
684
|
+
# the intake flow — NOT a failure. Names structure, never the act of intake.
|
|
685
|
+
warnings.append((f"task '{slug}'", "is outside a milestone — size it via the /add "
|
|
686
|
+
"intake flow (or attach with --milestone)"))
|
|
687
|
+
for dep in t.get("depends_on") or []:
|
|
688
|
+
checks.append((dep in tasks or dep in archived_slugs,
|
|
689
|
+
f"task '{slug}' dep '{dep}' resolves", "unknown task"))
|
|
690
|
+
# waiver expiry (Matrix 4): a RISK-ACCEPTED waiver whose `expires` has passed is
|
|
691
|
+
# stale — the gate stored it; `check` is the standing monitor that catches the lapse.
|
|
692
|
+
# Fail-closed: a missing/unparseable expires is a FAIL, never a silent pass.
|
|
693
|
+
if t.get("gate") == "RISK-ACCEPTED":
|
|
694
|
+
exp = (t.get("waiver") or {}).get("expires")
|
|
695
|
+
try:
|
|
696
|
+
ok = exp is not None and date.fromisoformat(exp) >= date.today()
|
|
697
|
+
reason = f"waiver_expired (expires={exp})"
|
|
698
|
+
except (ValueError, TypeError):
|
|
699
|
+
ok, reason = False, f"waiver_expired (unparseable expires={exp!r})"
|
|
700
|
+
checks.append((ok, f"task '{slug}' waiver not expired", reason))
|
|
701
|
+
|
|
702
|
+
# drift: a done milestone must have no unfinished tasks
|
|
703
|
+
for mslug, m in milestones.items():
|
|
704
|
+
if m.get("status") == "done":
|
|
705
|
+
unfinished = [s for s, t in tasks.items()
|
|
706
|
+
if t.get("milestone") == mslug and not _task_done(t)]
|
|
707
|
+
checks.append((not unfinished, f"done milestone '{mslug}' fully complete",
|
|
708
|
+
f"unfinished: {unfinished}"))
|
|
709
|
+
|
|
710
|
+
# dependency graph must be acyclic
|
|
711
|
+
cycle = _find_cycle(tasks)
|
|
712
|
+
checks.append((cycle is None, "task dependencies are acyclic",
|
|
713
|
+
f"cycle: {' -> '.join(cycle)}" if cycle else ""))
|
|
714
|
+
|
|
715
|
+
passed = sum(1 for ok, _, _ in checks if ok)
|
|
716
|
+
failed = len(checks) - passed
|
|
717
|
+
if as_json:
|
|
718
|
+
print(json.dumps({"passed": passed, "failed": failed,
|
|
719
|
+
"warned": len(warnings),
|
|
720
|
+
"warnings": [{"name": name, "reason": reason}
|
|
721
|
+
for name, reason in warnings],
|
|
722
|
+
"checks": [{"ok": ok, "name": desc,
|
|
723
|
+
"reason": reason if not ok else ""}
|
|
724
|
+
for ok, desc, reason in checks]}))
|
|
725
|
+
else:
|
|
726
|
+
for ok, desc, reason in checks:
|
|
727
|
+
print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}")
|
|
728
|
+
for name, reason in warnings:
|
|
729
|
+
print(f"WARN {name} {reason}")
|
|
730
|
+
summary = f"check: {passed} passed, {failed} failed"
|
|
731
|
+
if warnings:
|
|
732
|
+
summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)"
|
|
733
|
+
print(summary)
|
|
734
|
+
if failed:
|
|
735
|
+
raise SystemExit(1)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
739
|
+
root = _require_root()
|
|
740
|
+
state = load_state(root)
|
|
741
|
+
slug = args.slug
|
|
742
|
+
if not slug.replace("-", "").replace("_", "").isalnum():
|
|
743
|
+
_die("bad_slug")
|
|
744
|
+
state.setdefault("milestones", {})
|
|
745
|
+
mdir = root / "milestones" / slug
|
|
746
|
+
mfile = mdir / MILESTONE_FILE
|
|
747
|
+
if mfile.exists() and not args.force:
|
|
748
|
+
_die("milestone_exists")
|
|
749
|
+
mdir.mkdir(parents=True, exist_ok=True)
|
|
750
|
+
title = args.title or slug.replace("-", " ").replace("_", " ").title()
|
|
751
|
+
_atomic_write(mfile, _render_template(
|
|
752
|
+
"MILESTONE.md", title=title, goal=args.goal or "<goal>",
|
|
753
|
+
stage=args.stage, date=date.today().isoformat()))
|
|
754
|
+
state["milestones"][slug] = {
|
|
755
|
+
"title": title, "goal": args.goal or "", "stage": args.stage,
|
|
756
|
+
"status": "active", "created": _now(), "updated": _now(),
|
|
757
|
+
}
|
|
758
|
+
state["active_milestone"] = slug
|
|
759
|
+
save_state(root, state)
|
|
760
|
+
print(f"created milestone '{slug}' -> {mfile}")
|
|
761
|
+
print(f"active milestone set. Decompose it into tasks: add.py new-task <slug> --depends-on ...")
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def cmd_ready(args: argparse.Namespace) -> None:
|
|
765
|
+
if getattr(args, "json", False):
|
|
766
|
+
_, state = _load_state_for_json()
|
|
767
|
+
tasks = state.get("tasks") or {}
|
|
768
|
+
archived = _archived_task_slugs(state)
|
|
769
|
+
|
|
770
|
+
def _ok(d: str) -> bool:
|
|
771
|
+
return d in archived or (d in tasks and _task_done(tasks[d]))
|
|
772
|
+
|
|
773
|
+
ready, blocked = [], []
|
|
774
|
+
for slug, t in tasks.items():
|
|
775
|
+
if _task_done(t):
|
|
776
|
+
continue
|
|
777
|
+
unmet = [d for d in (t.get("depends_on") or []) if not _ok(d)]
|
|
778
|
+
(blocked.append({"slug": slug, "waiting_on": unmet})
|
|
779
|
+
if unmet else ready.append(slug))
|
|
780
|
+
print(json.dumps({"ready": ready, "blocked": blocked}))
|
|
781
|
+
return
|
|
782
|
+
root = _require_root()
|
|
783
|
+
state = load_state(root)
|
|
784
|
+
tasks = state.get("tasks", {})
|
|
785
|
+
archived_slugs = _archived_task_slugs(state) # an archived dep was PASS-done
|
|
786
|
+
|
|
787
|
+
def _dep_satisfied(d: str) -> bool:
|
|
788
|
+
if d in archived_slugs:
|
|
789
|
+
return True # archived ⇒ complete when archived
|
|
790
|
+
return d in tasks and _task_done(tasks[d]) # in-state dep must be done; else blocked
|
|
791
|
+
|
|
792
|
+
ready = []
|
|
793
|
+
for slug, t in tasks.items():
|
|
794
|
+
if _task_done(t):
|
|
795
|
+
continue
|
|
796
|
+
deps = t.get("depends_on") or []
|
|
797
|
+
if all(_dep_satisfied(d) for d in deps):
|
|
798
|
+
ready.append(slug)
|
|
799
|
+
if not ready:
|
|
800
|
+
print("ready: (none — all tasks are done or blocked)")
|
|
801
|
+
return
|
|
802
|
+
print("ready to start (deps satisfied):")
|
|
803
|
+
for slug in ready:
|
|
804
|
+
deps = tasks[slug].get("depends_on") or []
|
|
805
|
+
suffix = f" (after {', '.join(deps)})" if deps else ""
|
|
806
|
+
print(f" {slug}{suffix}")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
810
|
+
root = _require_root()
|
|
811
|
+
state = load_state(root)
|
|
812
|
+
slug = args.slug
|
|
813
|
+
if slug not in state.get("milestones", {}):
|
|
814
|
+
_die("unknown_milestone")
|
|
815
|
+
members = {s: t for s, t in state.get("tasks", {}).items() if t.get("milestone") == slug}
|
|
816
|
+
blockers = [s for s, t in members.items() if not _task_done(t)]
|
|
817
|
+
if not members:
|
|
818
|
+
_die("milestone_incomplete") # nothing attached -> nothing proven
|
|
819
|
+
if blockers:
|
|
820
|
+
print(f"milestone '{slug}' has unfinished tasks:", file=sys.stderr)
|
|
821
|
+
for s in blockers:
|
|
822
|
+
t = members[s]
|
|
823
|
+
print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
|
|
824
|
+
_die("milestone_incomplete")
|
|
825
|
+
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
826
|
+
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
827
|
+
# no done-without-retro state). The retro step is read-only on state.json.
|
|
828
|
+
try:
|
|
829
|
+
retro_path = _write_retro(root, state, slug)
|
|
830
|
+
except OSError:
|
|
831
|
+
_die("retro_write_failed")
|
|
832
|
+
state["milestones"][slug]["status"] = "done"
|
|
833
|
+
state["milestones"][slug]["updated"] = _now()
|
|
834
|
+
save_state(root, state)
|
|
835
|
+
waived = [s for s, t in members.items() if t.get("gate") == "RISK-ACCEPTED"]
|
|
836
|
+
tail = f" ({len(waived)} via a signed RISK-ACCEPTED waiver)" if waived else ""
|
|
837
|
+
print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
|
|
838
|
+
print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
|
|
839
|
+
print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.")
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
843
|
+
"""Light archive: collapse a DONE milestone out of active state (files stay)."""
|
|
844
|
+
root = _require_root()
|
|
845
|
+
state = load_state(root)
|
|
846
|
+
slug = args.slug
|
|
847
|
+
# validate before any mutation — a reject must leave state.json byte-for-byte unchanged
|
|
848
|
+
if slug not in state.get("milestones", {}):
|
|
849
|
+
_die("unknown_milestone")
|
|
850
|
+
ms = state["milestones"][slug]
|
|
851
|
+
if ms.get("status") != "done":
|
|
852
|
+
_die("milestone_not_done") # run `add.py milestone-done` first; never lose live work
|
|
853
|
+
tasks = state.get("tasks", {})
|
|
854
|
+
members = [s for s, t in tasks.items() if t.get("milestone") == slug]
|
|
855
|
+
# the status flag can go stale (a task attached AFTER milestone-done is still
|
|
856
|
+
# live); re-check now so archive can never silently delete unfinished work.
|
|
857
|
+
incomplete = [s for s in members if not _task_done(tasks[s])]
|
|
858
|
+
if incomplete:
|
|
859
|
+
print(f"milestone '{slug}' has live unfinished tasks:", file=sys.stderr)
|
|
860
|
+
for s in incomplete:
|
|
861
|
+
t = tasks[s]
|
|
862
|
+
print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
|
|
863
|
+
_die("milestone_has_incomplete_tasks")
|
|
864
|
+
# a slug-list summary (never task bodies) so the active state can't regrow,
|
|
865
|
+
# yet cross-milestone deps on these tasks still resolve (see _archived_task_slugs)
|
|
866
|
+
state.setdefault("archived", []).append({
|
|
867
|
+
"slug": slug,
|
|
868
|
+
"title": ms.get("title", slug),
|
|
869
|
+
"tasks": len(members),
|
|
870
|
+
"task_slugs": members,
|
|
871
|
+
"archived": date.today().isoformat(),
|
|
872
|
+
})
|
|
873
|
+
del state["milestones"][slug]
|
|
874
|
+
for s in members:
|
|
875
|
+
del tasks[s]
|
|
876
|
+
if state.get("active_milestone") == slug:
|
|
877
|
+
state["active_milestone"] = None
|
|
878
|
+
if state.get("active_task") in members:
|
|
879
|
+
state["active_task"] = None
|
|
880
|
+
save_state(root, state)
|
|
881
|
+
print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.")
|
|
882
|
+
print("files on disk are untouched; see `add.py status` for the archived rollup.")
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
886
|
+
root = _require_root()
|
|
887
|
+
state = load_state(root)
|
|
888
|
+
task = args.task
|
|
889
|
+
if task not in state.get("tasks", {}):
|
|
890
|
+
_die("unknown_task")
|
|
891
|
+
if args.milestone == "none":
|
|
892
|
+
new = None
|
|
893
|
+
elif args.milestone in state.get("milestones", {}):
|
|
894
|
+
new = args.milestone
|
|
895
|
+
else:
|
|
896
|
+
_die("unknown_milestone")
|
|
897
|
+
state["tasks"][task]["milestone"] = new
|
|
898
|
+
state["tasks"][task]["updated"] = _now()
|
|
899
|
+
save_state(root, state)
|
|
900
|
+
print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _find_cycle(tasks: dict) -> list[str] | None:
|
|
904
|
+
"""Return a cycle path in the depends_on graph, or None. Ignores unknown deps."""
|
|
905
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
906
|
+
color = {s: WHITE for s in tasks}
|
|
907
|
+
stack: list[str] = []
|
|
908
|
+
|
|
909
|
+
def visit(node: str) -> list[str] | None:
|
|
910
|
+
color[node] = GRAY
|
|
911
|
+
stack.append(node)
|
|
912
|
+
for dep in tasks[node].get("depends_on") or []:
|
|
913
|
+
if dep not in tasks:
|
|
914
|
+
continue
|
|
915
|
+
if color[dep] == GRAY:
|
|
916
|
+
return stack[stack.index(dep):] + [dep]
|
|
917
|
+
if color[dep] == WHITE:
|
|
918
|
+
found = visit(dep)
|
|
919
|
+
if found:
|
|
920
|
+
return found
|
|
921
|
+
color[node] = BLACK
|
|
922
|
+
stack.pop()
|
|
923
|
+
return None
|
|
924
|
+
|
|
925
|
+
for s in tasks:
|
|
926
|
+
if color[s] == WHITE:
|
|
927
|
+
found = visit(s)
|
|
928
|
+
if found:
|
|
929
|
+
return found
|
|
930
|
+
return None
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _sync_task_marker(root: Path, slug: str, phase: str) -> None:
|
|
934
|
+
"""Keep the `phase:` line inside TASK.md in sync with state.json."""
|
|
935
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
936
|
+
if not task_md.exists():
|
|
937
|
+
return
|
|
938
|
+
lines = task_md.read_text(encoding="utf-8").splitlines()
|
|
939
|
+
changed = False
|
|
940
|
+
for i, line in enumerate(lines):
|
|
941
|
+
if line.startswith("phase:"):
|
|
942
|
+
comment = ""
|
|
943
|
+
if "<!--" in line:
|
|
944
|
+
comment = " " + line[line.index("<!--"):]
|
|
945
|
+
lines[i] = f"phase: {phase}{comment}"
|
|
946
|
+
changed = True
|
|
947
|
+
break
|
|
948
|
+
if changed:
|
|
949
|
+
_atomic_write(task_md, "\n".join(lines) + "\n")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
# --- arg parsing -------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
# --- report: the read-only "what happened" dashboard (v9) --------------------
|
|
955
|
+
#
|
|
956
|
+
# A milestone digest a human can scan: banner header · per-task PHASE TRACK ·
|
|
957
|
+
# rollup footer (exit-criteria · waivers · carried deltas). render_report() is
|
|
958
|
+
# PURE — it performs NO writes — so v9's retro-artifact can persist the SAME
|
|
959
|
+
# string to RETRO.md. Structured fields (phase/gate/waiver/status) come from
|
|
960
|
+
# state.json; prose (observe delta, deltas) is parsed from each TASK.md and
|
|
961
|
+
# fails CLOSED to `(unknown)` rather than omitting silently.
|
|
962
|
+
|
|
963
|
+
_DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
|
|
964
|
+
# Two glyph tiers. Alignment is correct only with ASCII in column-positioned
|
|
965
|
+
# cells (every ASCII char is 1 display cell); Unicode glyphs sit at line-END
|
|
966
|
+
# (the PROGRESS track) or in non-aligned rows, where width can't break columns.
|
|
967
|
+
_UNICODE = {"reached": "●", "current": "◉", "pending": "○", "h": "═", "rule": "─", "bullet": "•"}
|
|
968
|
+
_ASCII = {"reached": "#", "current": ">", "pending": ".", "h": "=", "rule": "-", "bullet": "*"}
|
|
969
|
+
_GATE_SHORT = {"PASS": "PASS", "RISK-ACCEPTED": "RISK", "HARD-STOP": "STOP", "none": "—"}
|
|
970
|
+
_ANSI = {"green": "\x1b[32m", "yellow": "\x1b[33m", "red": "\x1b[31m",
|
|
971
|
+
"dim": "\x1b[2m", "reset": "\x1b[0m"}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _bar(num: int, den: int, cells: int, g: dict) -> str:
|
|
975
|
+
"""A progress bar; 0/0 -> all-empty (no divide-by-zero)."""
|
|
976
|
+
filled = 0 if den <= 0 else round(num / den * cells)
|
|
977
|
+
filled = max(0, min(cells, filled))
|
|
978
|
+
return g["reached"] * filled + g["pending"] * (cells - filled)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _phase_track(phase: str, g: dict) -> str:
|
|
982
|
+
"""Compact 8-cell pipeline (no labels — a single legend explains it):
|
|
983
|
+
reached · current · pending. A done task -> all reached."""
|
|
984
|
+
try:
|
|
985
|
+
ci = PHASES.index(phase)
|
|
986
|
+
except ValueError:
|
|
987
|
+
ci = 0
|
|
988
|
+
cells = []
|
|
989
|
+
for i in range(len(PHASES)):
|
|
990
|
+
if phase == "done" or i < ci:
|
|
991
|
+
cells.append(g["reached"])
|
|
992
|
+
elif i == ci:
|
|
993
|
+
cells.append(g["current"])
|
|
994
|
+
else:
|
|
995
|
+
cells.append(g["pending"])
|
|
996
|
+
return "".join(cells)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _use_ascii() -> bool:
|
|
1000
|
+
"""ASCII tier when the terminal can't render Unicode (non-UTF-8 / dumb)."""
|
|
1001
|
+
enc = (getattr(sys.stdout, "encoding", "") or "").lower()
|
|
1002
|
+
return ("utf" not in enc) or (os.environ.get("TERM") == "dumb")
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _color_enabled() -> bool:
|
|
1006
|
+
"""Color only on an interactive tty, honoring NO_COLOR and TERM."""
|
|
1007
|
+
return (sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
1008
|
+
and os.environ.get("TERM", "") not in ("dumb", ""))
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _term_width() -> int:
|
|
1012
|
+
try:
|
|
1013
|
+
import shutil
|
|
1014
|
+
return min(max(shutil.get_terminal_size().columns, 64), 100)
|
|
1015
|
+
except Exception:
|
|
1016
|
+
return _DEFAULT_WIDTH
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _colorize(s: str) -> str:
|
|
1020
|
+
"""Apply ANSI to status tokens — redundant to the text, never the sole signal.
|
|
1021
|
+
Applied ONLY to tty stdout; the persisted RETRO.md string stays plain."""
|
|
1022
|
+
c = _ANSI
|
|
1023
|
+
s = re.sub(r"\bDONE\b", c["green"] + "DONE" + c["reset"], s)
|
|
1024
|
+
s = re.sub(r"\bBLOCKED\b", c["red"] + "BLOCKED" + c["reset"], s)
|
|
1025
|
+
s = re.sub(r"\bPASS\b", c["green"] + "PASS" + c["reset"], s)
|
|
1026
|
+
s = re.sub(r"\bRISK\b", c["yellow"] + "RISK" + c["reset"], s)
|
|
1027
|
+
s = re.sub(r"\bSTOP\b", c["red"] + "STOP" + c["reset"], s)
|
|
1028
|
+
return s
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
|
|
1032
|
+
"""(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
|
|
1033
|
+
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
1034
|
+
if not f.exists():
|
|
1035
|
+
return "(unknown)", "(unknown)"
|
|
1036
|
+
title, goal = "(unknown)", "(unknown)"
|
|
1037
|
+
for line in f.read_text(encoding="utf-8").splitlines():
|
|
1038
|
+
if line.startswith("# MILESTONE:"):
|
|
1039
|
+
title = line.split(":", 1)[1].strip() or "(unknown)"
|
|
1040
|
+
elif line.startswith("goal:"):
|
|
1041
|
+
goal = line.split(":", 1)[1].strip() or "(unknown)"
|
|
1042
|
+
break
|
|
1043
|
+
return title, goal
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
|
|
1047
|
+
"""(met, total) checkbox tally inside MILESTONE.md's 'Exit criteria' section."""
|
|
1048
|
+
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
1049
|
+
if not f.exists():
|
|
1050
|
+
return 0, 0
|
|
1051
|
+
m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
|
|
1052
|
+
if not m:
|
|
1053
|
+
return 0, 0
|
|
1054
|
+
sec = m.group(0)
|
|
1055
|
+
met = len(re.findall(r"- \[x\]", sec))
|
|
1056
|
+
total = met + len(re.findall(r"- \[ \]", sec))
|
|
1057
|
+
return met, total
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _tests_count(root: Path, slug: str) -> int:
|
|
1061
|
+
d = root / "tasks" / slug / "tests"
|
|
1062
|
+
if not d.is_dir():
|
|
1063
|
+
return 0
|
|
1064
|
+
return sum(len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
|
|
1065
|
+
for f in d.glob("*.py"))
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
1069
|
+
"""(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
|
|
1070
|
+
fidelity: both fields wrap across physical lines in real files, so continuation
|
|
1071
|
+
lines are JOINED. Scoped to the OBSERVE section so we read the FIELD, not §1 prose
|
|
1072
|
+
that names it. Fail-closed to '(unknown)' on a missing file / `<...>` placeholder."""
|
|
1073
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
1074
|
+
if not f.exists():
|
|
1075
|
+
return "(unknown)", []
|
|
1076
|
+
text = f.read_text(encoding="utf-8")
|
|
1077
|
+
m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
|
|
1078
|
+
lines = (m7.group(0) if m7 else text).splitlines()
|
|
1079
|
+
_delta_start = re.compile(r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$")
|
|
1080
|
+
|
|
1081
|
+
# observe: the field value + continuation lines until a blank line / heading / list
|
|
1082
|
+
observe = "(unknown)"
|
|
1083
|
+
for i, ln in enumerate(lines):
|
|
1084
|
+
m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
|
|
1085
|
+
if not m:
|
|
1086
|
+
continue
|
|
1087
|
+
parts = [m.group(1).strip()]
|
|
1088
|
+
for nxt in lines[i + 1:]:
|
|
1089
|
+
t = nxt.strip()
|
|
1090
|
+
if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
|
|
1091
|
+
break
|
|
1092
|
+
parts.append(t)
|
|
1093
|
+
joined = " ".join(p for p in parts if p).strip()
|
|
1094
|
+
if joined and not joined.startswith("<"):
|
|
1095
|
+
observe = joined
|
|
1096
|
+
break
|
|
1097
|
+
|
|
1098
|
+
# deltas: each "- [COMP · status] ..." plus its indented continuation lines
|
|
1099
|
+
deltas, i = [], 0
|
|
1100
|
+
while i < len(lines):
|
|
1101
|
+
m = _delta_start.match(lines[i])
|
|
1102
|
+
if not m:
|
|
1103
|
+
i += 1
|
|
1104
|
+
continue
|
|
1105
|
+
parts, j = [m.group(3).strip()], i + 1
|
|
1106
|
+
while j < len(lines):
|
|
1107
|
+
t = lines[j].strip()
|
|
1108
|
+
if not t or t.startswith("#") or _delta_start.match(lines[j]):
|
|
1109
|
+
break
|
|
1110
|
+
parts.append(t)
|
|
1111
|
+
j += 1
|
|
1112
|
+
deltas.append(f"{m.group(1)} · {m.group(2)} · {' '.join(parts).strip()}")
|
|
1113
|
+
i = j
|
|
1114
|
+
return observe, deltas
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _clip(s: str, maxlen: int) -> str:
|
|
1118
|
+
"""Trim a string to fit a fixed-width frame, ellipsizing if it overruns."""
|
|
1119
|
+
return s if len(s) <= maxlen else s[:maxlen - 1].rstrip() + "…"
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _wrap(text: str, width: int, label: str) -> list[str]:
|
|
1123
|
+
"""Wrap `text` to `width`; the first line carries `label`, continuations are
|
|
1124
|
+
blank-indented to the same width (so a multi-line goal shows 'goal' once)."""
|
|
1125
|
+
cont = " " * len(label)
|
|
1126
|
+
lines, cur = [], ""
|
|
1127
|
+
for w in text.split():
|
|
1128
|
+
if cur and len(cur) + 1 + len(w) > width:
|
|
1129
|
+
lines.append(cur)
|
|
1130
|
+
cur = w
|
|
1131
|
+
else:
|
|
1132
|
+
cur = f"{cur} {w}".strip()
|
|
1133
|
+
if cur:
|
|
1134
|
+
lines.append(cur)
|
|
1135
|
+
lines = lines or ["(unknown)"]
|
|
1136
|
+
return [(label if i == 0 else cont) + ln for i, ln in enumerate(lines)]
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
1140
|
+
"""The single source of FACTS for a milestone report — pure, NO writes.
|
|
1141
|
+
Both the text dashboard (render_report) and `report --json` render from this,
|
|
1142
|
+
so the human view and the raw data can never disagree. This is the 'raw data
|
|
1143
|
+
capture' the agent formats into a templated report."""
|
|
1144
|
+
ms = (state.get("milestones") or {}).get(mslug, {})
|
|
1145
|
+
title, goal = _milestone_doc(root, mslug)
|
|
1146
|
+
tasks = state.get("tasks") or {}
|
|
1147
|
+
members = [(s, t) for s, t in tasks.items() if t.get("milestone") == mslug]
|
|
1148
|
+
met, total_ec = _exit_criteria(root, mslug)
|
|
1149
|
+
|
|
1150
|
+
task_rows, waivers, all_deltas = [], [], []
|
|
1151
|
+
for slug, t in members:
|
|
1152
|
+
observe, deltas = _task_prose(root, slug)
|
|
1153
|
+
phase = t.get("phase", "specify")
|
|
1154
|
+
gate = t.get("gate", "none")
|
|
1155
|
+
row = {
|
|
1156
|
+
"slug": slug,
|
|
1157
|
+
"title": t.get("title", slug),
|
|
1158
|
+
"phase": phase,
|
|
1159
|
+
"phase_index": PHASES.index(phase) if phase in PHASES else 0,
|
|
1160
|
+
"done": _task_done(t),
|
|
1161
|
+
"gate": gate,
|
|
1162
|
+
"tests": _tests_count(root, slug),
|
|
1163
|
+
"observe": observe,
|
|
1164
|
+
"deltas": deltas,
|
|
1165
|
+
"waiver": t.get("waiver"),
|
|
1166
|
+
}
|
|
1167
|
+
task_rows.append(row)
|
|
1168
|
+
if t.get("waiver"):
|
|
1169
|
+
w = t["waiver"]
|
|
1170
|
+
waivers.append({"slug": slug, "owner": w.get("owner", "?"),
|
|
1171
|
+
"ticket": w.get("ticket", "?"), "expires": w.get("expires", "?")})
|
|
1172
|
+
all_deltas.extend(deltas)
|
|
1173
|
+
|
|
1174
|
+
return {
|
|
1175
|
+
"milestone": {"slug": mslug, "title": title, "goal": goal,
|
|
1176
|
+
"status": ms.get("status", "active")},
|
|
1177
|
+
"summary": {
|
|
1178
|
+
"tasks_done": sum(1 for r in task_rows if r["done"]),
|
|
1179
|
+
"tasks_total": len(task_rows),
|
|
1180
|
+
"gates": {"PASS": sum(1 for r in task_rows if r["gate"] == "PASS"),
|
|
1181
|
+
"RISK-ACCEPTED": sum(1 for r in task_rows if r["gate"] == "RISK-ACCEPTED"),
|
|
1182
|
+
"HARD-STOP": sum(1 for r in task_rows if r["gate"] == "HARD-STOP")},
|
|
1183
|
+
"exit_criteria": {"met": met, "total": total_ec},
|
|
1184
|
+
},
|
|
1185
|
+
"tasks": task_rows,
|
|
1186
|
+
"waivers": waivers,
|
|
1187
|
+
"deltas": all_deltas,
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _clean_phase_body(body: str) -> str:
|
|
1192
|
+
"""Strip HTML comments (which include the `EXIT:` markers) and surrounding blank
|
|
1193
|
+
lines from a §N body. A body that is empty or ONLY `<...>` angle-placeholders after
|
|
1194
|
+
cleaning -> "(empty)" (fail-closed; never a silent gap). Otherwise the cleaned text
|
|
1195
|
+
is returned with its internal line structure intact (scenarios/code stay readable)."""
|
|
1196
|
+
body = re.sub(r"<!--.*?-->", "", body, flags=re.S)
|
|
1197
|
+
lines = [ln.rstrip() for ln in body.split("\n")]
|
|
1198
|
+
while lines and not lines[0].strip():
|
|
1199
|
+
lines.pop(0)
|
|
1200
|
+
while lines and not lines[-1].strip():
|
|
1201
|
+
lines.pop()
|
|
1202
|
+
meaningful = [ln for ln in lines
|
|
1203
|
+
if ln.strip() and not re.fullmatch(r"\s*<.*>\s*", ln)]
|
|
1204
|
+
return "\n".join(lines) if meaningful else "(empty)"
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def task_phases(root: Path, slug: str) -> list[dict]:
|
|
1208
|
+
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §1–§7 into seven
|
|
1209
|
+
blocks specify→observe. PURE — NO writes. Each entry is
|
|
1210
|
+
{ "phase": <name>, "n": <1..7>, "body": <cleaned text | "(empty)"> }.
|
|
1211
|
+
|
|
1212
|
+
Sections are matched on the NUMBER (`^##\\s*<n>\\s*·`, case/locale-proof, the phase
|
|
1213
|
+
word maps n->PHASES[n-1]); a body runs from its heading to the next `## `/`---`/EOF.
|
|
1214
|
+
Missing file / missing section / placeholder-only body -> "(empty)" (fail-closed).
|
|
1215
|
+
KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
|
|
1216
|
+
today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
|
|
1217
|
+
names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
|
|
1218
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
1219
|
+
try:
|
|
1220
|
+
text = f.read_text(encoding="utf-8")
|
|
1221
|
+
except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
|
|
1222
|
+
return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)]
|
|
1223
|
+
lines = text.splitlines()
|
|
1224
|
+
head = re.compile(r"^##\s*(\d+)\s*·")
|
|
1225
|
+
starts: dict[int, int] = {}
|
|
1226
|
+
for idx, ln in enumerate(lines):
|
|
1227
|
+
m = head.match(ln)
|
|
1228
|
+
if m:
|
|
1229
|
+
n = int(m.group(1))
|
|
1230
|
+
if 1 <= n <= 7 and n not in starts:
|
|
1231
|
+
starts[n] = idx
|
|
1232
|
+
out = []
|
|
1233
|
+
for n in range(1, 8):
|
|
1234
|
+
if n not in starts:
|
|
1235
|
+
out.append({"phase": names[n - 1], "n": n, "body": "(empty)"})
|
|
1236
|
+
continue
|
|
1237
|
+
body_lines = []
|
|
1238
|
+
for ln in lines[starts[n] + 1:]:
|
|
1239
|
+
if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
|
|
1240
|
+
break
|
|
1241
|
+
body_lines.append(ln)
|
|
1242
|
+
out.append({"phase": names[n - 1], "n": n,
|
|
1243
|
+
"body": _clean_phase_body("\n".join(body_lines))})
|
|
1244
|
+
return out
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def _task_title(root: Path, slug: str) -> str:
|
|
1248
|
+
"""The task's display title from TASK.md line 1 `# TASK: <title>` (fail-soft: the
|
|
1249
|
+
slug if the file or the header line is missing)."""
|
|
1250
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
1251
|
+
try:
|
|
1252
|
+
text = f.read_text(encoding="utf-8")
|
|
1253
|
+
except OSError: # missing OR unreadable -> fail-soft to the slug
|
|
1254
|
+
return slug
|
|
1255
|
+
for ln in text.splitlines():
|
|
1256
|
+
m = re.match(r"^#\s*TASK:\s*(.+)", ln)
|
|
1257
|
+
if m:
|
|
1258
|
+
return m.group(1).strip()
|
|
1259
|
+
return slug
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _detail_body(body: str, width: int) -> list[str]:
|
|
1263
|
+
"""Indent a phase body under its block, soft-wrapping over-long physical lines on
|
|
1264
|
+
spaces while preserving blank lines + each line's leading indent (so scenarios and
|
|
1265
|
+
contract code keep their shape). Drill-down = reading is the point, never clipped."""
|
|
1266
|
+
indent = " "
|
|
1267
|
+
out: list[str] = []
|
|
1268
|
+
for raw in body.split("\n"):
|
|
1269
|
+
if not raw.strip():
|
|
1270
|
+
out.append("")
|
|
1271
|
+
continue
|
|
1272
|
+
if len(indent) + len(raw) <= width:
|
|
1273
|
+
out.append(indent + raw)
|
|
1274
|
+
continue
|
|
1275
|
+
lead = raw[: len(raw) - len(raw.lstrip())]
|
|
1276
|
+
prefix = indent + lead
|
|
1277
|
+
cur = ""
|
|
1278
|
+
for w in raw.split():
|
|
1279
|
+
cand = f"{cur} {w}".strip()
|
|
1280
|
+
if cur and len(prefix) + len(cand) > width:
|
|
1281
|
+
out.append(prefix + cur)
|
|
1282
|
+
cur = w
|
|
1283
|
+
else:
|
|
1284
|
+
cur = cand
|
|
1285
|
+
if cur:
|
|
1286
|
+
out.append(prefix + cur)
|
|
1287
|
+
return out
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *,
|
|
1291
|
+
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
1292
|
+
"""Format ONE task's seven phase blocks (specify→observe) as the read-only PHASE
|
|
1293
|
+
DETAIL: each block shows its number+name, a reached/current/pending marker (from the
|
|
1294
|
+
task's state phase), and its captured §N body (fail-closed to "(empty)"). The verify
|
|
1295
|
+
block additionally prints the recorded GATE from state.json — authoritative, NEVER
|
|
1296
|
+
parsed from prose. Returns PLAIN text (no ANSI); color is a tty-only skin in
|
|
1297
|
+
cmd_report. PURE — NO writes (the v9 read-only discipline, carried)."""
|
|
1298
|
+
g = _ASCII if ascii else _UNICODE
|
|
1299
|
+
W = width
|
|
1300
|
+
banner, rule = g["h"] * W, " " + g["rule"] * (W - 1)
|
|
1301
|
+
t = (state.get("tasks") or {}).get(slug, {})
|
|
1302
|
+
phase = t.get("phase", "specify")
|
|
1303
|
+
gate = t.get("gate", "none")
|
|
1304
|
+
ci = PHASES.index(phase) if phase in PHASES else 0
|
|
1305
|
+
|
|
1306
|
+
L = [banner, f" {mslug} · {slug} · {_task_title(root, slug)}", banner]
|
|
1307
|
+
L.append(f" PHASE {phase} GATE {gate}")
|
|
1308
|
+
L.append(banner)
|
|
1309
|
+
for p in task_phases(root, slug):
|
|
1310
|
+
i = p["n"] - 1
|
|
1311
|
+
mk = (g["reached"] if (phase == "done" or i < ci)
|
|
1312
|
+
else g["current"] if i == ci else g["pending"])
|
|
1313
|
+
L.append("")
|
|
1314
|
+
L.append(f" {mk} {p['n']} {p['phase'].upper()}")
|
|
1315
|
+
L.append(rule)
|
|
1316
|
+
if p["n"] == 6: # verify: the recorded gate, sourced from state (not prose)
|
|
1317
|
+
L.append(f" GATE {gate}")
|
|
1318
|
+
if p["body"] == "(empty)":
|
|
1319
|
+
L.append(" (empty)")
|
|
1320
|
+
else:
|
|
1321
|
+
L.extend(_detail_body(p["body"], W))
|
|
1322
|
+
L.append(banner)
|
|
1323
|
+
return "\n".join(L)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def render_report(root: Path, state: dict, mslug: str, *,
|
|
1327
|
+
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
1328
|
+
"""Format the FACTS (report_data) as the text DASHBOARD — verdict-first header,
|
|
1329
|
+
left-aligned ASCII columns (alignment-safe on any locale), Unicode/ASCII glyph
|
|
1330
|
+
tier, one legend. Returns PLAIN text (no ANSI); color is a tty-only layer in
|
|
1331
|
+
cmd_report so the persisted RETRO.md string stays plain. NO writes."""
|
|
1332
|
+
d = report_data(root, state, mslug)
|
|
1333
|
+
g = _ASCII if ascii else _UNICODE
|
|
1334
|
+
W = width
|
|
1335
|
+
banner, rule = g["h"] * W, g["rule"] * W
|
|
1336
|
+
m, s = d["milestone"], d["summary"]
|
|
1337
|
+
done, total = s["tasks_done"], s["tasks_total"]
|
|
1338
|
+
gates, ec = s["gates"], s["exit_criteria"]
|
|
1339
|
+
|
|
1340
|
+
verdict = ("BLOCKED" if gates["HARD-STOP"]
|
|
1341
|
+
else "DONE" if total and done == total else "ACTIVE")
|
|
1342
|
+
gbits = []
|
|
1343
|
+
if gates["PASS"]:
|
|
1344
|
+
gbits.append(f"{gates['PASS']} PASS")
|
|
1345
|
+
if gates["RISK-ACCEPTED"]:
|
|
1346
|
+
gbits.append(f"{gates['RISK-ACCEPTED']} RISK")
|
|
1347
|
+
if gates["HARD-STOP"]:
|
|
1348
|
+
gbits.append(f"{gates['HARD-STOP']} STOP")
|
|
1349
|
+
gate_txt = " ".join(gbits) if gbits else "none"
|
|
1350
|
+
waiver_txt = f"{len(d['waivers'])}" if d["waivers"] else "none"
|
|
1351
|
+
|
|
1352
|
+
# Header: title in the banner, then a 2-col aligned label grid (ASCII-safe cells,
|
|
1353
|
+
# so no width breakage) — VERDICT leads on its own line for emphasis.
|
|
1354
|
+
L = [banner, f" {m['slug']} · {m['title']}", banner]
|
|
1355
|
+
L.append(f" {'VERDICT':<9} {verdict}")
|
|
1356
|
+
L.append(f" {'TASKS':<9} {f'{done}/{total} done':<18} {'CRITERIA':<9} {ec['met']}/{ec['total']} met")
|
|
1357
|
+
L.append(f" {'GATES':<9} {gate_txt:<18} {'WAIVERS':<9} {waiver_txt}")
|
|
1358
|
+
L.append("")
|
|
1359
|
+
L.extend(_wrap(m["goal"], W - 7, " goal "))
|
|
1360
|
+
L.append("")
|
|
1361
|
+
if d["tasks"]:
|
|
1362
|
+
L.append(f" {'TASK':<27} {'PHASE':<9} {'GATE':<4} {'TESTS':<5} PROGRESS")
|
|
1363
|
+
L.append(" " + g["rule"] * (W - 1))
|
|
1364
|
+
for r in d["tasks"]:
|
|
1365
|
+
slug = _clip(r["slug"], 27)
|
|
1366
|
+
gate = _GATE_SHORT.get(r["gate"], r["gate"])
|
|
1367
|
+
L.append(f" {slug:<27} {r['phase']:<9} {gate:<4} "
|
|
1368
|
+
f"{str(r['tests']):<5} {_phase_track(r['phase'], g)}")
|
|
1369
|
+
L.append(f" legend {g['reached']} reached {g['current']} current "
|
|
1370
|
+
f"{g['pending']} pending spec→…→done")
|
|
1371
|
+
else:
|
|
1372
|
+
L.append(" (no tasks yet)")
|
|
1373
|
+
L.append("")
|
|
1374
|
+
L.append(f" EXIT CRITERIA {_bar(ec['met'], ec['total'], 10, g)} {ec['met']}/{ec['total']} met")
|
|
1375
|
+
if d["waivers"]: # header grid carries the count; show DETAILS here only when present
|
|
1376
|
+
L.append("")
|
|
1377
|
+
L.append(f" WAIVERS ({len(d['waivers'])})")
|
|
1378
|
+
for w in d["waivers"]:
|
|
1379
|
+
L.extend(_wrap(f"{w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}",
|
|
1380
|
+
W - 5, f" {g['bullet']} "))
|
|
1381
|
+
L.append("")
|
|
1382
|
+
if d["deltas"]: # the retro's payload — word-wrapped to FULL readable text, never clipped
|
|
1383
|
+
L.append(f" LEARNINGS ({len(d['deltas'])} carried)")
|
|
1384
|
+
for x in d["deltas"]:
|
|
1385
|
+
L.extend(_wrap(x, W - 5, f" {g['bullet']} "))
|
|
1386
|
+
else:
|
|
1387
|
+
L.append(" LEARNINGS none")
|
|
1388
|
+
L.append(banner)
|
|
1389
|
+
return "\n".join(L)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _write_retro(root: Path, state: dict, mslug: str) -> Path:
|
|
1393
|
+
"""Persist the milestone's CANONICAL render to .add/milestones/<mslug>/RETRO.md
|
|
1394
|
+
(the spec'd 'Milestone exit report', appendix-f). Reuses the ONE frozen renderer
|
|
1395
|
+
at its canonical args (width 72, ascii=False) so the doc is byte-identical to a
|
|
1396
|
+
piped `report <mslug>`. PURE on state: reads via render_report, writes exactly
|
|
1397
|
+
this one file with explicit utf-8 (the canonical carries Unicode glyphs — never
|
|
1398
|
+
trust the locale default), never mutates state.json."""
|
|
1399
|
+
content = render_report(root, state, mslug, width=_DEFAULT_WIDTH, ascii=False)
|
|
1400
|
+
path = root / "milestones" / mslug / "RETRO.md"
|
|
1401
|
+
path.write_text(content, encoding="utf-8")
|
|
1402
|
+
return path
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def cmd_report(args: argparse.Namespace) -> None:
|
|
1406
|
+
"""Read-only: capture a milestone's raw data (--json) or render the text
|
|
1407
|
+
dashboard (color on a tty, ASCII when the terminal can't do Unicode, --plain
|
|
1408
|
+
forces the pipe/screen-reader-safe tier). Writes nothing, never mutates state."""
|
|
1409
|
+
root = _require_root()
|
|
1410
|
+
state = load_state(root)
|
|
1411
|
+
milestones = state.get("milestones") or {}
|
|
1412
|
+
tasks = state.get("tasks") or {}
|
|
1413
|
+
name = args.milestone # 1st positional (SMART: milestone-first, else task)
|
|
1414
|
+
task = getattr(args, "task", None)
|
|
1415
|
+
|
|
1416
|
+
# Resolve to a ROLLUP (mslug) or a DRILL (mslug + drill_task). Drill path is purely
|
|
1417
|
+
# additive; the rollup branches are byte-for-byte the v9 behavior.
|
|
1418
|
+
drill_task = None
|
|
1419
|
+
if task is not None: # explicit `report <m> <task>`
|
|
1420
|
+
mslug = name
|
|
1421
|
+
if mslug not in milestones:
|
|
1422
|
+
_die(f"unknown_milestone: '{mslug}' is not a milestone")
|
|
1423
|
+
if tasks.get(task, {}).get("milestone") != mslug:
|
|
1424
|
+
_die(f"unknown_task: '{task}' is not a task of milestone '{mslug}'")
|
|
1425
|
+
drill_task = task
|
|
1426
|
+
elif name is not None: # smart single positional
|
|
1427
|
+
if name in milestones:
|
|
1428
|
+
mslug = name # -> rollup (unchanged)
|
|
1429
|
+
elif name in tasks: # -> drill by task name
|
|
1430
|
+
drill_task = name
|
|
1431
|
+
mslug = tasks[name].get("milestone")
|
|
1432
|
+
if not mslug:
|
|
1433
|
+
_die(f"unknown_milestone: task '{name}' is not attached to a milestone")
|
|
1434
|
+
else:
|
|
1435
|
+
_die(f"unknown_milestone: '{name}' is not a milestone")
|
|
1436
|
+
else: # no positional -> active milestone
|
|
1437
|
+
mslug = state.get("active_milestone")
|
|
1438
|
+
if not mslug:
|
|
1439
|
+
_die("no_active_milestone: no milestone given and none is active; "
|
|
1440
|
+
"try `add.py report <milestone>`")
|
|
1441
|
+
if mslug not in milestones:
|
|
1442
|
+
_die(f"unknown_milestone: '{mslug}' is not a milestone")
|
|
1443
|
+
|
|
1444
|
+
if getattr(args, "json", False):
|
|
1445
|
+
# POLYMORPHIC by path: drill -> task_phases list; rollup -> report_data dict.
|
|
1446
|
+
payload = task_phases(root, drill_task) if drill_task \
|
|
1447
|
+
else report_data(root, state, mslug)
|
|
1448
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1449
|
+
return
|
|
1450
|
+
plain = getattr(args, "plain", False)
|
|
1451
|
+
interactive = sys.stdout.isatty() and not plain
|
|
1452
|
+
width = _term_width() if interactive else _DEFAULT_WIDTH
|
|
1453
|
+
use_ascii = plain or _use_ascii()
|
|
1454
|
+
out = (render_task_detail(root, state, mslug, drill_task, width=width, ascii=use_ascii)
|
|
1455
|
+
if drill_task else
|
|
1456
|
+
render_report(root, state, mslug, width=width, ascii=use_ascii))
|
|
1457
|
+
if not plain and _color_enabled():
|
|
1458
|
+
out = _colorize(out)
|
|
1459
|
+
print(out)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1463
|
+
p = argparse.ArgumentParser(prog="add.py", description="ADD scaffolder + state tracker")
|
|
1464
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
1465
|
+
|
|
1466
|
+
pi = sub.add_parser("init", help="create a .add/ project here")
|
|
1467
|
+
pi.add_argument("--dir", default=".", help="target directory (default: cwd)")
|
|
1468
|
+
pi.add_argument("--name", default=None, help="project name (default: dir name)")
|
|
1469
|
+
pi.add_argument("--stage", default="prototype", choices=STAGES)
|
|
1470
|
+
pi.add_argument("--force", action="store_true", help="reset state.json if present")
|
|
1471
|
+
pi.set_defaults(func=cmd_init)
|
|
1472
|
+
|
|
1473
|
+
pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
|
|
1474
|
+
pn.add_argument("slug")
|
|
1475
|
+
pn.add_argument("--title", default=None)
|
|
1476
|
+
pn.add_argument("--milestone", default=None, help="attach to a milestone (default: active)")
|
|
1477
|
+
pn.add_argument("--depends-on", dest="depends_on", default=None,
|
|
1478
|
+
help="comma-separated task slugs this task depends on")
|
|
1479
|
+
pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
|
|
1480
|
+
pn.set_defaults(func=cmd_new_task)
|
|
1481
|
+
|
|
1482
|
+
pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
|
|
1483
|
+
pm.add_argument("slug")
|
|
1484
|
+
pm.add_argument("--title", default=None)
|
|
1485
|
+
pm.add_argument("--goal", default=None, help="one-sentence outcome")
|
|
1486
|
+
pm.add_argument("--stage", default="mvp", choices=STAGES)
|
|
1487
|
+
pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
|
|
1488
|
+
pm.set_defaults(func=cmd_new_milestone)
|
|
1489
|
+
|
|
1490
|
+
pr = sub.add_parser("ready", help="list tasks whose dependencies are satisfied")
|
|
1491
|
+
pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
1492
|
+
pr.set_defaults(func=cmd_ready)
|
|
1493
|
+
|
|
1494
|
+
pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
|
|
1495
|
+
pmd.add_argument("slug")
|
|
1496
|
+
pmd.set_defaults(func=cmd_milestone_done)
|
|
1497
|
+
|
|
1498
|
+
psm = sub.add_parser("set-milestone", help="attach/move/detach an existing task")
|
|
1499
|
+
psm.add_argument("task")
|
|
1500
|
+
psm.add_argument("milestone", help="milestone slug, or 'none' to detach")
|
|
1501
|
+
psm.set_defaults(func=cmd_set_milestone)
|
|
1502
|
+
|
|
1503
|
+
pam = sub.add_parser("archive-milestone",
|
|
1504
|
+
help="collapse a done milestone out of active state (files stay on disk)")
|
|
1505
|
+
pam.add_argument("slug")
|
|
1506
|
+
pam.set_defaults(func=cmd_archive_milestone)
|
|
1507
|
+
|
|
1508
|
+
pp = sub.add_parser("phase", help="set a task's phase explicitly")
|
|
1509
|
+
pp.add_argument("phase", choices=PHASES)
|
|
1510
|
+
pp.add_argument("slug", nargs="?", default=None)
|
|
1511
|
+
pp.set_defaults(func=cmd_phase)
|
|
1512
|
+
|
|
1513
|
+
pa = sub.add_parser("advance", help="move a task to the next phase")
|
|
1514
|
+
pa.add_argument("slug", nargs="?", default=None)
|
|
1515
|
+
pa.set_defaults(func=cmd_advance)
|
|
1516
|
+
|
|
1517
|
+
pg = sub.add_parser("gate", help="record a verify gate outcome")
|
|
1518
|
+
pg.add_argument("outcome", choices=GATES)
|
|
1519
|
+
pg.add_argument("slug", nargs="?", default=None)
|
|
1520
|
+
pg.add_argument("--owner", help="RISK-ACCEPTED waiver: accountable owner")
|
|
1521
|
+
pg.add_argument("--ticket", help="RISK-ACCEPTED waiver: tracking ticket/link")
|
|
1522
|
+
pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
|
|
1523
|
+
pg.set_defaults(func=cmd_gate)
|
|
1524
|
+
|
|
1525
|
+
ps = sub.add_parser("stage", help="set the project stage")
|
|
1526
|
+
ps.add_argument("stage", choices=STAGES)
|
|
1527
|
+
ps.set_defaults(func=cmd_stage)
|
|
1528
|
+
|
|
1529
|
+
pst = sub.add_parser("status", help="print where the project is (resume point)")
|
|
1530
|
+
pst.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
1531
|
+
pst.set_defaults(func=cmd_status)
|
|
1532
|
+
|
|
1533
|
+
pck = sub.add_parser("check", help="read-only integrity check of the .add project")
|
|
1534
|
+
pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
1535
|
+
pck.set_defaults(func=cmd_check)
|
|
1536
|
+
|
|
1537
|
+
psg = sub.add_parser("sync-guidelines",
|
|
1538
|
+
help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
|
|
1539
|
+
psg.set_defaults(func=cmd_sync_guidelines)
|
|
1540
|
+
|
|
1541
|
+
pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")
|
|
1542
|
+
pgd.add_argument("slug", nargs="?", default=None, help="task slug (default: active task)")
|
|
1543
|
+
pgd.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
1544
|
+
pgd.set_defaults(func=cmd_guide)
|
|
1545
|
+
|
|
1546
|
+
prp = sub.add_parser("report",
|
|
1547
|
+
help="capture/render a milestone's what-happened report (read-only)")
|
|
1548
|
+
prp.add_argument("milestone", nargs="?", default=None,
|
|
1549
|
+
help="milestone slug for the rollup, OR a task slug to drill into "
|
|
1550
|
+
"(smart: tried as a milestone first, then as a task); "
|
|
1551
|
+
"default: active milestone")
|
|
1552
|
+
prp.add_argument("task", nargs="?", default=None,
|
|
1553
|
+
help="explicit `report <milestone> <task>`: render that task's "
|
|
1554
|
+
"per-phase detail instead of the milestone rollup")
|
|
1555
|
+
prp.add_argument("--json", action="store_true",
|
|
1556
|
+
help="emit raw structured data (rollup -> report_data dict; "
|
|
1557
|
+
"drill -> task_phases list of 7 phase dicts)")
|
|
1558
|
+
prp.add_argument("--plain", action="store_true",
|
|
1559
|
+
help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)")
|
|
1560
|
+
prp.set_defaults(func=cmd_report)
|
|
1561
|
+
|
|
1562
|
+
return p
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1566
|
+
parser = build_parser()
|
|
1567
|
+
args = parser.parse_args(argv)
|
|
1568
|
+
args.func(args)
|
|
1569
|
+
return 0
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
if __name__ == "__main__":
|
|
1573
|
+
raise SystemExit(main())
|