@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.
Files changed (53) hide show
  1. package/GETTING-STARTED.md +238 -0
  2. package/LICENSE +20 -0
  3. package/README.md +106 -0
  4. package/bin/cli.js +131 -0
  5. package/docs/00-introduction.md +46 -0
  6. package/docs/01-principles.md +71 -0
  7. package/docs/02-the-flow.md +93 -0
  8. package/docs/03-step-1-specify.md +117 -0
  9. package/docs/04-step-2-scenarios.md +78 -0
  10. package/docs/05-step-3-contract.md +78 -0
  11. package/docs/06-step-4-tests.md +71 -0
  12. package/docs/07-step-5-build.md +80 -0
  13. package/docs/08-step-6-verify.md +63 -0
  14. package/docs/09-the-loop.md +43 -0
  15. package/docs/10-setup-and-stages.md +75 -0
  16. package/docs/11-governance.md +87 -0
  17. package/docs/12-roles.md +99 -0
  18. package/docs/13-adoption.md +67 -0
  19. package/docs/14-foundation.md +121 -0
  20. package/docs/README.md +70 -0
  21. package/docs/add-competencies.png +0 -0
  22. package/docs/add-flow.png +0 -0
  23. package/docs/add-foundation.png +0 -0
  24. package/docs/add-hierarchy.png +0 -0
  25. package/docs/appendix-a-templates.md +88 -0
  26. package/docs/appendix-b-prompts.md +119 -0
  27. package/docs/appendix-c-glossary.md +85 -0
  28. package/docs/appendix-d-worked-example.md +152 -0
  29. package/docs/appendix-e-checklists.md +80 -0
  30. package/docs/appendix-f-requirements-matrix.md +170 -0
  31. package/package.json +47 -0
  32. package/skill/add/SKILL.md +118 -0
  33. package/skill/add/deltas.md +69 -0
  34. package/skill/add/fold.md +66 -0
  35. package/skill/add/intake.md +49 -0
  36. package/skill/add/phases/0-setup.md +35 -0
  37. package/skill/add/phases/1-specify.md +55 -0
  38. package/skill/add/phases/2-scenarios.md +36 -0
  39. package/skill/add/phases/3-contract.md +41 -0
  40. package/skill/add/phases/4-tests.md +37 -0
  41. package/skill/add/phases/5-build.md +38 -0
  42. package/skill/add/phases/6-verify.md +39 -0
  43. package/skill/add/phases/7-observe.md +32 -0
  44. package/skill/add/run.md +152 -0
  45. package/skill/add/scope.md +58 -0
  46. package/tooling/add.py +1573 -0
  47. package/tooling/templates/CONVENTIONS.md.tmpl +8 -0
  48. package/tooling/templates/GLOSSARY.md.tmpl +3 -0
  49. package/tooling/templates/MILESTONE.md.tmpl +25 -0
  50. package/tooling/templates/MODEL_REGISTRY.md.tmpl +6 -0
  51. package/tooling/templates/PROJECT.md.tmpl +42 -0
  52. package/tooling/templates/TASK.md.tmpl +111 -0
  53. 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())