@pilotspace/add 1.0.0 → 1.2.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 (54) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/GETTING-STARTED.md +172 -84
  3. package/README.md +14 -8
  4. package/bin/cli.js +39 -38
  5. package/docs/01-principles.md +3 -3
  6. package/docs/02-the-flow.md +20 -13
  7. package/docs/03-step-1-specify.md +13 -13
  8. package/docs/04-step-2-scenarios.md +3 -1
  9. package/docs/05-step-3-contract.md +4 -2
  10. package/docs/06-step-4-tests.md +3 -1
  11. package/docs/07-step-5-build.md +1 -1
  12. package/docs/08-step-6-verify.md +22 -4
  13. package/docs/09-the-loop.md +25 -1
  14. package/docs/10-setup-and-stages.md +52 -9
  15. package/docs/11-governance.md +2 -2
  16. package/docs/12-roles.md +3 -3
  17. package/docs/13-adoption.md +3 -3
  18. package/docs/14-foundation.md +19 -11
  19. package/docs/15-foundations-and-lineage.md +106 -0
  20. package/docs/README.md +4 -0
  21. package/docs/appendix-a-templates.md +3 -3
  22. package/docs/appendix-b-prompts.md +40 -5
  23. package/docs/appendix-c-glossary.md +42 -12
  24. package/docs/appendix-d-worked-example.md +2 -2
  25. package/docs/appendix-e-checklists.md +2 -2
  26. package/docs/appendix-f-requirements-matrix.md +12 -11
  27. package/docs/appendix-g-references.md +106 -0
  28. package/package.json +5 -3
  29. package/skill/add/SKILL.md +50 -21
  30. package/skill/add/adopt.md +67 -0
  31. package/skill/add/deltas.md +20 -8
  32. package/skill/add/fold.md +19 -17
  33. package/skill/add/graduate.md +74 -0
  34. package/skill/add/intake.md +22 -7
  35. package/skill/add/loop.md +59 -0
  36. package/skill/add/phases/0-setup.md +92 -24
  37. package/skill/add/phases/1-specify.md +23 -13
  38. package/skill/add/phases/2-scenarios.md +14 -4
  39. package/skill/add/phases/3-contract.md +38 -9
  40. package/skill/add/phases/4-tests.md +29 -5
  41. package/skill/add/phases/5-build.md +14 -4
  42. package/skill/add/phases/6-verify.md +38 -4
  43. package/skill/add/phases/7-observe.md +13 -5
  44. package/skill/add/report-template.md +106 -0
  45. package/skill/add/run.md +53 -34
  46. package/skill/add/scope.md +24 -2
  47. package/skill/add/setup-review.md +65 -0
  48. package/skill/add/streams.md +256 -0
  49. package/tooling/add.py +1388 -62
  50. package/tooling/templates/CONVENTIONS.md.tmpl +1 -1
  51. package/tooling/templates/GLOSSARY.md.tmpl +23 -0
  52. package/tooling/templates/MILESTONE.md.tmpl +1 -0
  53. package/tooling/templates/PROJECT.md.tmpl +4 -3
  54. package/tooling/templates/TASK.md.tmpl +39 -11
package/tooling/add.py CHANGED
@@ -12,6 +12,7 @@ existing artifacts unless --force is given.
12
12
  from __future__ import annotations
13
13
 
14
14
  import argparse
15
+ import getpass
15
16
  import json
16
17
  import os
17
18
  import re
@@ -25,7 +26,14 @@ from pathlib import Path
25
26
  ROOT_DIRNAME = ".add"
26
27
  STATE_FILE = "state.json"
27
28
  MILESTONE_FILE = "MILESTONE.md"
29
+ # The project GOAL (v20) is read live from PROJECT.md — never copied into state.json
30
+ # (single-source; the foundation is the truth). A missing/blank source degrades to
31
+ # this sentinel so the read-only orientation surfaces never blank or crash.
32
+ GOAL_UNSET = "(unset — add a 'goal:' line to PROJECT.md)"
28
33
  STAGES = ("prototype", "poc", "mvp", "production")
34
+ # v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
35
+ # Worded as the ACTION (never a file) so it stands before graduate.md exists.
36
+ GRADUATION_CUE = "MVP covered → propose graduation"
29
37
  PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
30
38
  GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
31
39
 
@@ -37,7 +45,7 @@ def _phase_index(name: str) -> int:
37
45
  # `add.py guide` copy: per-phase (concrete next action, book chapter to read).
38
46
  # Keep the action wording aligned with each phase's EXIT line in the TASK template.
39
47
  PHASE_GUIDE = {
40
- "specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions least-sure first and flag the biggest risk",
48
+ "specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
41
49
  "03-step-1-specify.md"),
42
50
  "scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
43
51
  "04-step-2-scenarios.md"),
@@ -47,7 +55,7 @@ PHASE_GUIDE = {
47
55
  "06-step-4-tests.md"),
48
56
  "build": ("write the minimum code to pass the tests; change no test and no contract",
49
57
  "07-step-5-build.md"),
50
- "verify": ("run the suite + blind-spot checks, then record the gate",
58
+ "verify": ("run the suite + non-functional checks, then record the gate",
51
59
  "08-step-6-verify.md"),
52
60
  "observe": ("note what to watch + the spec delta for the next loop",
53
61
  "09-the-loop.md"),
@@ -84,8 +92,8 @@ Framings weighed:
84
92
  Must:
85
93
  Reject:
86
94
  After:
87
- Assumptions — least-sure first:
88
- ⚠ <most likely wrong> — least sure because <why>; if wrong: <cost>
95
+ Assumptions — lowest-confidence first:
96
+ ⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
89
97
 
90
98
  ## 2 · SCENARIOS
91
99
  ## 3 · CONTRACT
@@ -162,7 +170,14 @@ def _require_root() -> Path:
162
170
 
163
171
 
164
172
  def load_state(root: Path) -> dict:
165
- return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
173
+ """Load + parse state.json, failing CLOSED. A corrupt or unreadable state file
174
+ dies with a clean 'state_invalid' message (never a raw traceback), so every
175
+ command that loads state degrades gracefully (design-for-failure)."""
176
+ try:
177
+ return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
178
+ except (json.JSONDecodeError, OSError) as e:
179
+ _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
180
+ f"({e.__class__.__name__}) — restore it from git or a backup")
166
181
 
167
182
 
168
183
  def _load_state_for_json() -> tuple[Path, dict]:
@@ -191,6 +206,15 @@ def save_state(root: Path, state: dict) -> None:
191
206
  _atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
192
207
 
193
208
 
209
+ def _setup_locked(state: dict) -> bool:
210
+ """True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
211
+
212
+ A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
213
+ project are never gated (the lock is opt-in via `init --await-lock`). The gate is
214
+ therefore active in exactly one case: "setup" present AND locked is False."""
215
+ return ("setup" not in state) or (state["setup"].get("locked") is True)
216
+
217
+
194
218
  def _die(msg: str, code: int = 1) -> None:
195
219
  print(f"add: error: {msg}", file=sys.stderr)
196
220
  raise SystemExit(code)
@@ -205,27 +229,37 @@ def _die(msg: str, code: int = 1) -> None:
205
229
  # 20%+ more cost), so the stable pointer is the whole point.
206
230
 
207
231
  def _guideline_block() -> str:
208
- """The canonical ADD block (markers + body, no trailing newline)."""
232
+ """The canonical ADD block (markers + body, no trailing newline).
233
+
234
+ Agent-agnostic by design (v14 agent-portability): the routing steps depend
235
+ only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
236
+ Codex — can follow them. Claude additionally gets the `add` skill."""
209
237
  return (
210
238
  f"{_GUIDE_BEGIN}\n"
211
239
  "## ADD — how to work in this repo\n"
212
240
  "\n"
213
241
  "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"
242
+ "the human owns direction and verification. The loop below works for any agent —\n"
243
+ "Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
215
244
  "\n"
216
245
  "1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
217
246
  " next (the resume point; read it first every session).\n"
218
247
  "2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
219
248
  " 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"
249
+ "3. Run `python3 .add/tooling/add.py guide` it names the phase and the exact\n"
250
+ " phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
251
+ " guide ends with its exit gate and the command to move on.\n"
225
252
  "\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"
253
+ "The flow: INTAKE sizes a request into a milestone; each task runs the\n"
254
+ "**specification bundle** Spec+Scenarios+Contract+Tests as one bundle,\n"
255
+ "ONE human approval at the frozen contract — then a self-driving build→verify\n"
256
+ "run. Non-negotiable for every agent:\n"
257
+ "Never weaken a test or edit a frozen contract to make a build pass; a security\n"
258
+ "finding is always HARD-STOP — never auto-passed.\n"
259
+ "\n"
260
+ "On Claude Code the `add` skill drives this loop automatically; other agents\n"
261
+ "follow the three steps. The book is in `.add/docs/`. This block is generated\n"
262
+ "by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
229
263
  f"{_GUIDE_END}"
230
264
  )
231
265
 
@@ -294,6 +328,24 @@ def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
294
328
 
295
329
  # --- commands ----------------------------------------------------------------
296
330
 
331
+ _INIT_EXCLUDE = {
332
+ ".add", "AGENTS.md", "CLAUDE.md", ".git",
333
+ ".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
334
+ "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
335
+ } # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
336
+
337
+
338
+ def _is_brownfield(base: Path) -> bool:
339
+ """True when `base` already holds project content beyond the tool's own scaffolding.
340
+
341
+ Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
342
+ autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
343
+ that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
344
+ if not base.is_dir():
345
+ return False
346
+ return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
347
+
348
+
297
349
  def cmd_init(args: argparse.Namespace) -> None:
298
350
  base = Path(args.dir).resolve()
299
351
  root = base / ROOT_DIRNAME
@@ -330,14 +382,24 @@ def cmd_init(args: argparse.Namespace) -> None:
330
382
  "created": _now(),
331
383
  "updated": _now(),
332
384
  }
385
+ if getattr(args, "await_lock", False):
386
+ # opt-in: seed an UNLOCKED setup so the build-boundary gate is active until
387
+ # `add.py lock`. Plain init omits this key entirely (grandfathered-locked).
388
+ state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
333
389
  save_state(root, state)
334
390
  # zero-config: give any agent a stable pointer into the ADD runtime.
335
391
  for name, action in _inject_guidelines(base):
336
392
  if action != "unchanged":
337
393
  print(f"{action:>9} {name}")
338
394
  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.")
395
+ if _is_brownfield(base):
396
+ # Existing code present the AI maps it SILENTLY into the survivors (skill/add/adopt.md),
397
+ # then the human locks it down. The engine only flags it; it never reads or fills the code.
398
+ print("brownfield: existing code detected — the `add` skill maps it into your")
399
+ print(" foundation (silent), then you lock it down: add.py lock")
400
+ else:
401
+ print("next: open Claude Code, run `/add`, and say what you want to build —")
402
+ print(" the `add` skill sizes it into a milestone and drives the build with you.")
341
403
 
342
404
 
343
405
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
@@ -349,6 +411,9 @@ def cmd_sync_guidelines(args: argparse.Namespace) -> None:
349
411
  def cmd_new_task(args: argparse.Namespace) -> None:
350
412
  root = _require_root()
351
413
  state = load_state(root)
414
+ # build-boundary gate: pre-lock, EXACTLY one first task may be drafted; refuse a 2nd.
415
+ if not _setup_locked(state) and state.get("tasks"):
416
+ _die("setup_unlocked: lock the foundation first — add.py lock")
352
417
  slug = args.slug
353
418
  if not slug.replace("-", "").replace("_", "").isalnum():
354
419
  _die("slug must be alphanumeric with - or _ only")
@@ -453,6 +518,24 @@ def cmd_advance(args: argparse.Namespace) -> None:
453
518
  if idx >= len(PHASES) - 1:
454
519
  _die(f"task '{slug}' already at final phase ({cur})")
455
520
  nxt = PHASES[idx + 1]
521
+ # build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing
522
+ # into build/verify/observe/done is refused until `add.py lock`.
523
+ if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
524
+ _die("setup_unlocked: lock the foundation first — add.py lock")
525
+ # flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
526
+ # into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
527
+ # verified marker so `audit` enforces the flag on THIS record only (open/new
528
+ # freezes — the unmarked predecessors are never retro-redded). REFUSE writes
529
+ # nothing (fail-closed); below the build boundary the flag is never checked.
530
+ if nxt == "build":
531
+ raw3 = _raw_phase_bodies(root, slug).get(3, "")
532
+ if _contract_frozen(raw3):
533
+ if not _flag_well_formed(raw3):
534
+ _die("unflagged_freeze: a frozen §3 must surface a well-formed "
535
+ "'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
536
+ "+ substantive content; bare 'none' only as 'none material — "
537
+ "biggest risk: X') before crossing into build")
538
+ state["tasks"][slug]["flag_verified"] = True
456
539
  state["tasks"][slug]["phase"] = nxt
457
540
  state["tasks"][slug]["updated"] = _now()
458
541
  _sync_task_marker(root, slug, nxt)
@@ -460,10 +543,33 @@ def cmd_advance(args: argparse.Namespace) -> None:
460
543
  print(f"task '{slug}' phase {cur} -> {nxt}")
461
544
 
462
545
 
546
+ # The mechanized high-risk guard (run.md, v14): judging WHAT is high-risk stays
547
+ # human — a scope declares `risk: high` in its TASK.md header at the freeze. The
548
+ # engine then enforces the pure token contradiction: risk: high WITHOUT
549
+ # autonomy: conservative is unguarded, and completion is refused. Tokens are
550
+ # read from the header region (text before the first section heading) with HTML
551
+ # comments stripped — a documentation comment is never a declaration.
552
+ _RISK_HIGH_RE = re.compile(r"\brisk:\s*high\b")
553
+ _AUTONOMY_CONSERVATIVE_RE = re.compile(r"\bautonomy:\s*conservative\b")
554
+
555
+
556
+ def _task_header(root: Path, slug: str) -> str:
557
+ """The TASK.md header region — where declared tokens (risk · autonomy)
558
+ live — with HTML comments stripped. Missing file -> '' (no tokens)."""
559
+ try:
560
+ text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
561
+ except OSError:
562
+ return ""
563
+ return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
564
+
565
+
463
566
  def cmd_gate(args: argparse.Namespace) -> None:
464
567
  root = _require_root()
465
568
  state = load_state(root)
466
569
  slug = _resolve_task(state, args.slug)
570
+ # build-boundary gate: no verdict may be recorded before the setup is locked.
571
+ if not _setup_locked(state):
572
+ _die("setup_unlocked: lock the foundation first — add.py lock")
467
573
  if args.outcome not in GATES:
468
574
  _die(f"outcome must be one of: {', '.join(GATES)}")
469
575
  # Completing outcomes (PASS, RISK-ACCEPTED) are the VERIFY step's verdict, so they
@@ -478,6 +584,14 @@ def cmd_gate(args: argparse.Namespace) -> None:
478
584
  else "gate_risk_accepted_before_verify")
479
585
  _die(f"{code}: task '{slug}' is at '{current}'; reach the verify phase "
480
586
  f"first (or `add.py phase verify {slug}` to override)")
587
+ # the mechanized high-risk guard: an unguarded high-risk header refuses
588
+ # COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
589
+ # owns the gate. HARD-STOP is never blocked — stopping is always allowed.
590
+ hdr = _task_header(root, slug)
591
+ if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
592
+ _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
593
+ "without autonomy: conservative — lower the autonomy level in the TASK.md "
594
+ "header; a human must own a high-risk gate (run.md guard)")
481
595
  if args.outcome == "RISK-ACCEPTED":
482
596
  # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
483
597
  # so a later `check` can read/expire it. Refuse a partial waiver outright.
@@ -499,19 +613,111 @@ def cmd_gate(args: argparse.Namespace) -> None:
499
613
  print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
500
614
 
501
615
 
616
+ def cmd_reopen(args: argparse.Namespace) -> None:
617
+ """Return an already-`done` task to an earlier phase with a never-silent record.
618
+
619
+ The flow already permits backward correction (book ch02: "any phase may return
620
+ to an earlier one"); `done` is terminal EXCEPT via this recorded action. reopen
621
+ sets the phase back, resets the gate to "none" (the task must re-earn its
622
+ verdict), and appends an append-only `reopens` entry recording WHY. A done task
623
+ done via RISK-ACCEPTED carries a live `waiver`; reopen records it inside the entry
624
+ (prior_gate / prior_waiver) and drops the live key, so no signed waiver lingers
625
+ without a verdict. Judgement of WHEN to reopen stays the resolver's; the engine
626
+ only enforces the recorded, coherent transition.
627
+ """
628
+ root = _require_root()
629
+ state = load_state(root)
630
+ slug = _resolve_task(state, args.slug)
631
+ t = state["tasks"][slug]
632
+ if t.get("phase") != "done":
633
+ _die(f"reopen_not_done: task '{slug}' is at '{t.get('phase')}', not done — "
634
+ "backward correction inside a live run is `add.py phase` / HARD-STOP, not reopen")
635
+ reason = (args.reason or "").strip()
636
+ if not reason:
637
+ _die("reopen_reason_required: reopen records WHY — supply a non-empty --reason")
638
+ target = args.to
639
+ if target not in PHASES[:7]: # specify..observe; never "done", never an unknown name
640
+ _die(f"reopen_target_invalid: --to must be one of {', '.join(PHASES[:7])} (got {target!r})")
641
+ now = _now()
642
+ entry = {"from": "done", "to": target, "reason": reason, "at": now,
643
+ "prior_gate": t.get("gate", "none")}
644
+ if t.get("waiver"): # void verdict's waiver -> history, drop the live key
645
+ entry["prior_waiver"] = t.pop("waiver")
646
+ t.setdefault("reopens", []).append(entry)
647
+ t["phase"] = target
648
+ t["gate"] = "none"
649
+ t["updated"] = now
650
+ _sync_task_marker(root, slug, target)
651
+ save_state(root, state)
652
+ print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
653
+
654
+
655
+ def cmd_lock(args: argparse.Namespace) -> None:
656
+ """The human baseline approval: freeze the autonomously-drafted setup in ONE atomic write.
657
+
658
+ Setup-level analog of the contract freeze — the only new human action onboarding
659
+ needs. `add.py lock` is judgment-free (it records the signature; it does NOT inspect
660
+ the artifacts): the human's signature IS the gate."""
661
+ root = _require_root()
662
+ state = load_state(root)
663
+ # idempotent-guarded: the predicate also treats a grandfathered (no "setup" key)
664
+ # project as already locked, so a bare re-lock there refuses too.
665
+ if _setup_locked(state) and not args.force:
666
+ _die("already_locked: setup is already locked (use --force to re-lock)")
667
+ # parse layers BEFORE any write so an invalid request never half-locks (design-for-failure).
668
+ raw = args.layers if args.layers is not None else "foundation,scope,contract"
669
+ layers = [s.strip() for s in raw.split(",") if s.strip()]
670
+ if not layers:
671
+ _die("layers_invalid: --layers must name at least one lock layer")
672
+ who = args.by or getpass.getuser()
673
+ when = _now()
674
+ # ONE atomic write — no partial lock state.
675
+ state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers}
676
+ save_state(root, state)
677
+ if getattr(args, "json", False):
678
+ print(json.dumps(
679
+ {"locked": True, "locked_at": when, "locked_by": who, "layers": layers},
680
+ separators=(",", ":")))
681
+ else:
682
+ print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
683
+
684
+
685
+ def _has_production_roadmap(state: dict) -> bool:
686
+ """True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
687
+ The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
688
+ that a production-roadmap RECORD exists — it never judges whether those milestones are
689
+ done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force."""
690
+ return any(m.get("stage") == "production"
691
+ for m in state.get("milestones", {}).values())
692
+
693
+
502
694
  def cmd_stage(args: argparse.Namespace) -> None:
503
695
  root = _require_root()
504
696
  state = load_state(root)
505
697
  if args.stage not in STAGES:
506
698
  _die(f"stage must be one of: {', '.join(STAGES)}")
699
+ # v22 stage-graduation guard: the →production TRANSITION refuses without a roadmap — a tally
700
+ # check (≥1 production milestone exists), never a readiness judgment. Scoped to production
701
+ # ONLY; every other flip is the existing bare flip, byte-unchanged. --force overrides
702
+ # (precedent: lock --force). The flip is graduate.md's FINAL, confirmed-roadmap step.
703
+ forced = getattr(args, "force", False)
704
+ bypassing = False
705
+ if args.stage == "production":
706
+ roadmap = _has_production_roadmap(state)
707
+ if not roadmap and not forced:
708
+ _die("stage_no_roadmap: no production milestone drafted. Draft ≥1 via "
709
+ "graduate.md (new-milestone --stage production), or use --force to override.")
710
+ bypassing = forced and not roadmap
507
711
  state["stage"] = args.stage
508
712
  save_state(root, state)
509
713
  print(f"project stage -> {args.stage}")
714
+ if bypassing:
715
+ print("(--force: bypassed roadmap check — no production milestone drafted)")
510
716
 
511
717
 
512
718
  def cmd_status(args: argparse.Namespace) -> None:
513
719
  if getattr(args, "json", False):
514
- _, state = _load_state_for_json()
720
+ root, state = _load_state_for_json()
515
721
  tasks = state.get("tasks") or {}
516
722
  milestones = state.get("milestones") or {}
517
723
  ms_list = []
@@ -520,22 +726,43 @@ def cmd_status(args: argparse.Namespace) -> None:
520
726
  ms_list.append({"slug": mslug, "status": m.get("status", "active"),
521
727
  "done": sum(1 for t in members if _task_done(t)),
522
728
  "total": len(members)})
729
+ grad_ready, grad_met, grad_total = _graduation_ready(root, state)
523
730
  print(json.dumps({
524
731
  "project": state.get("project"), "stage": state.get("stage"),
525
732
  "active_task": state.get("active_task"),
526
733
  "milestones": ms_list,
527
734
  "tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
528
- "milestone": t.get("milestone")} for s, t in tasks.items()]}))
735
+ "milestone": t.get("milestone")} for s, t in tasks.items()],
736
+ "graduation_ready": grad_ready,
737
+ "stage_criteria": {"met": grad_met, "total": grad_total}}))
529
738
  return
530
739
  root = _require_root()
531
740
  state = load_state(root)
532
741
  active = state.get("active_task")
533
742
  tasks = state.get("tasks", {})
534
- print(f"project : {state['project']}")
535
- print(f"stage : {state['stage']}")
743
+ # Compute once: True when setup is present AND locked is False (the lock-gate window).
744
+ # Reuses the canonical helper — do NOT write a parallel predicate.
745
+ unlocked = not _setup_locked(state)
746
+ print(f"project : {state.get('project', '(unknown)')}")
747
+ print(f"stage : {state.get('stage', '(unknown)')}")
748
+ # project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
749
+ # LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
750
+ # line stays put. A missing source degrades to a sentinel — one never blanks the other.
751
+ print(f"goal : {_project_goal(root)}")
752
+ _active_ms = state.get("active_milestone")
753
+ if _active_ms:
754
+ print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
536
755
  # foundation pointer — read the cross-milestone context first (anti-rot)
537
756
  if (root / "PROJECT.md").exists():
538
757
  print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
758
+ # wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
759
+ # Existence-only: no open/read/parse, so the hint adds no IO failure path; a
760
+ # non-file at the path is not a ledger. One line PER live ledger — more than
761
+ # one live wave is an anomaly the orchestrator must see, never a line we hide.
762
+ for _wave in sorted((root / "milestones").glob("*/WAVE.md")):
763
+ if _wave.is_file():
764
+ print(f"wave : LIVE — .add/milestones/{_wave.parent.name}/WAVE.md"
765
+ " (wave resume point — re-orient from the ledger first)")
539
766
 
540
767
  # milestone rollup (only when milestones are in use)
541
768
  milestones = state.get("milestones") or {}
@@ -548,6 +775,12 @@ def cmd_status(args: argparse.Namespace) -> None:
548
775
  mark = "*" if mslug == active_ms else " "
549
776
  print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
550
777
  f" status={m.get('status', 'active')}")
778
+ # graduation cue (v22): project-global + read-only. Fires only when every milestone
779
+ # is done AND the human's PROJECT.md stage-goal-criteria are all checked — additive
780
+ # (a new line solely when ready; the non-ready output is byte-identical to before).
781
+ grad_ready, _gm, _gt = _graduation_ready(root, state)
782
+ if grad_ready:
783
+ print(f" → {GRADUATION_CUE}")
551
784
 
552
785
  # archived rollup — one line keeps state visible without re-bloating status
553
786
  archived = state.get("archived") or []
@@ -560,13 +793,18 @@ def cmd_status(args: argparse.Namespace) -> None:
560
793
  print(f"active : {active or '(none)'}")
561
794
  if not tasks:
562
795
  # 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.
796
+ # lost. When the setup is unlocked, the only correct next move is review+lock
797
+ # suppress the generic /add hint and name the two steps that matter.
565
798
  print("tasks : (none yet)")
566
799
  print()
567
- print("next : you're set up. In Claude Code, run /add and say what you want to")
568
- print(" buildthe `add` skill sizes it into a milestone and drives the")
569
- print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
800
+ if unlocked:
801
+ print("setup : UNLOCKED review .add/SETUP-REVIEW.md (lowest-confidence first),"
802
+ " then sign: add.py lock")
803
+ print(" (the build-boundary gate is closed until the foundation is locked)")
804
+ else:
805
+ print("next : you're set up. In Claude Code, run /add and say what you want to")
806
+ print(" build — the `add` skill sizes it into a milestone and drives the")
807
+ print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
570
808
  return
571
809
  print("tasks :")
572
810
  for slug, t in tasks.items():
@@ -575,7 +813,18 @@ def cmd_status(args: argparse.Namespace) -> None:
575
813
  dep_s = f" deps={','.join(deps)}" if deps else ""
576
814
  ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
577
815
  print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}")
578
- if active:
816
+ # fold-pressure nudge: surface unfolded competency deltas so emission can't
817
+ # silently outrun the human fold (read-only; v11). Silent when none are open.
818
+ open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
819
+ if open_deltas:
820
+ print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)")
821
+ # When the setup is unlocked, the only terminal guidance that matters is
822
+ # review+lock; suppress the generic resume block so it does not compete.
823
+ if unlocked:
824
+ print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first),"
825
+ " then sign: add.py lock")
826
+ print(" (the build-boundary gate is closed until the foundation is locked)")
827
+ elif active and active in tasks:
579
828
  ph = tasks[active]["phase"]
580
829
  if ph == "done":
581
830
  print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
@@ -585,18 +834,42 @@ def cmd_status(args: argparse.Namespace) -> None:
585
834
  print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
586
835
 
587
836
 
837
+ # Agent-portability (v14): `guide` names the PHASE PLAYBOOK file — the same
838
+ # guides the Claude skill loads, installed as plain markdown by every channel
839
+ # at .claude/skills/add/phases/ — so ANY agent (Cursor, Copilot, Codex) can be
840
+ # routed there through the CLI alone. Never a dead pointer: the path is printed
841
+ # only if the file exists; a missing tree gets an install hint instead.
842
+ _PHASE_GUIDE_FILES = {
843
+ "specify": "1-specify.md", "scenarios": "2-scenarios.md",
844
+ "contract": "3-contract.md", "tests": "4-tests.md",
845
+ "build": "5-build.md", "verify": "6-verify.md", "observe": "7-observe.md",
846
+ }
847
+ _SKILL_PHASES_DIR = Path(".claude") / "skills" / "add" / "phases"
848
+
849
+
850
+ def _phase_guide_path(project_root: Path, phase: str) -> str | None:
851
+ """Relative path to the phase playbook if it exists, else None.
852
+ done/unknown phases have no playbook (the `then:` line routes onward)."""
853
+ fname = _PHASE_GUIDE_FILES.get(phase)
854
+ if fname is None:
855
+ return None
856
+ rel = _SKILL_PHASES_DIR / fname
857
+ return str(rel) if (project_root / rel).is_file() else None
858
+
859
+
588
860
  def cmd_guide(args: argparse.Namespace) -> None:
589
861
  """Answer "what do I do next?" for the active (or named) task.
590
862
 
591
863
  Strictly read-only: load_state only — never save_state, never writes a TASK.md.
592
864
  """
593
865
  if getattr(args, "json", False):
594
- _, state = _load_state_for_json()
866
+ json_root, state = _load_state_for_json()
595
867
  slug = args.slug or state.get("active_task")
596
868
  if not slug:
597
869
  print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
598
870
  "next_step": "start your first feature -> add.py new-task <slug>",
599
- "chapter": ".add/docs/02-the-flow.md", "gate": None}))
871
+ "chapter": ".add/docs/02-the-flow.md", "gate": None,
872
+ "guide": None}))
600
873
  return
601
874
  t = (state.get("tasks") or {}).get(slug)
602
875
  if t is None:
@@ -606,7 +879,8 @@ def cmd_guide(args: argparse.Namespace) -> None:
606
879
  action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
607
880
  print(json.dumps({"task": slug, "phase": phase, "owner": owner,
608
881
  "stop": owner != "ai", "next_step": action,
609
- "chapter": f".add/docs/{chapter}", "gate": t.get("gate")}))
882
+ "chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
883
+ "guide": _phase_guide_path(json_root.parent, phase)}))
610
884
  return
611
885
  root = _require_root()
612
886
  state = load_state(root)
@@ -624,8 +898,14 @@ def cmd_guide(args: argparse.Namespace) -> None:
624
898
  _die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
625
899
  action, chapter = entry
626
900
  print(f"active : {slug} (phase: {phase})")
901
+ print(f"goal : {_project_goal(root)}") # v20 — the next-step surface still shows what the work is FOR
627
902
  print(f"next : {action}")
628
903
  print(f"read : .add/docs/{chapter}")
904
+ gp = _phase_guide_path(root.parent, phase)
905
+ if gp is not None:
906
+ print(f"guide : {gp}")
907
+ elif phase in _PHASE_GUIDE_FILES:
908
+ print("guide : (phase guides not installed — npx @pilotspace/add init)")
629
909
  if phase == "verify":
630
910
  print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
631
911
  elif phase == "done":
@@ -698,6 +978,13 @@ def cmd_check(args: argparse.Namespace) -> None:
698
978
  except (ValueError, TypeError):
699
979
  ok, reason = False, f"waiver_expired (unparseable expires={exp!r})"
700
980
  checks.append((ok, f"task '{slug}' waiver not expired", reason))
981
+ # delta-lint: validate all OPEN entries in the "### Competency deltas" block.
982
+ # Fail-closed; folded/rejected entries are skipped (open-only). Only emits a
983
+ # check when at least one delta-attempt is present in the block.
984
+ lint_result = _lint_task_deltas(root, slug)
985
+ if lint_result is not None:
986
+ ok, reason = lint_result
987
+ checks.append((ok, f"task '{slug}' deltas well-formed", reason))
701
988
 
702
989
  # drift: a done milestone must have no unfinished tasks
703
990
  for mslug, m in milestones.items():
@@ -822,6 +1109,17 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
822
1109
  t = members[s]
823
1110
  print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
824
1111
  _die("milestone_incomplete")
1112
+ # Goal-gate (v20 dynamic-task-loop): a milestone holds until its exit criteria are
1113
+ # met. The engine READS the checkbox tally (the human's goal-met affirmation, like a
1114
+ # gate=PASS) — it never judges the goal. Fires ONLY when criteria exist, so a
1115
+ # criteria-less milestone and every pre-v20 close path stay valid. milestone-done is
1116
+ # the SOLE status->done transition; archive-milestone/compact already refuse a
1117
+ # non-done milestone, so this single gate has no back door. Refuse BEFORE any write.
1118
+ met, total = _exit_criteria(root, slug)
1119
+ if total > 0 and met < total:
1120
+ _die(f"milestone_goal_unmet: milestone '{slug}' has {met}/{total} exit criteria met "
1121
+ f"— check the remaining boxes in MILESTONE.md (the goal-gate holds the loop "
1122
+ f"open) or propose the next tasks (add.py deltas)")
825
1123
  # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
826
1124
  # status flip, so a write failure rolls back naturally (status never commits ->
827
1125
  # no done-without-retro state). The retro step is read-only on state.json.
@@ -837,6 +1135,12 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
837
1135
  print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
838
1136
  print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
839
1137
  print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.")
1138
+ # fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
1139
+ open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1140
+ if open_deltas:
1141
+ noun = "delta" if open_deltas == 1 else "deltas"
1142
+ print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
1143
+ f"— review with: add.py deltas")
840
1144
 
841
1145
 
842
1146
  def cmd_archive_milestone(args: argparse.Namespace) -> None:
@@ -861,6 +1165,15 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
861
1165
  t = tasks[s]
862
1166
  print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
863
1167
  _die("milestone_has_incomplete_tasks")
1168
+ # pre-archive snapshot (design-for-failure): the archived record below keeps only a
1169
+ # slug-list, so capture the full milestone + member task records to a .bak BEFORE the
1170
+ # destructive deletes — an accidental archive stays recoverable (phase/gate/waiver/deps
1171
+ # the record drops). Mirrors the .bak the guideline injector writes before mutating.
1172
+ _atomic_write(
1173
+ root / "milestones" / slug / "pre-archive-state.bak.json",
1174
+ json.dumps({"milestone": ms, "tasks": {s: tasks[s] for s in members},
1175
+ "archived_at": _now()}, indent=2) + "\n",
1176
+ )
864
1177
  # a slug-list summary (never task bodies) so the active state can't regrow,
865
1178
  # yet cross-milestone deps on these tasks still resolve (see _archived_task_slugs)
866
1179
  state.setdefault("archived", []).append({
@@ -882,6 +1195,70 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
882
1195
  print("files on disk are untouched; see `add.py status` for the archived rollup.")
883
1196
 
884
1197
 
1198
+ def cmd_compact(args: argparse.Namespace) -> None:
1199
+ """Heavy archive (step two, after `archive-milestone`): move a light-archived
1200
+ milestone's files — MILESTONE.md + siblings + every rollup-member task dir — into
1201
+ one recovery bundle `.add/archive/<slug>/`. Validate-all-then-move: any reject
1202
+ leaves the tree AND state.json byte-for-byte unchanged. Compact never deletes,
1203
+ only renames; recovery = reverse move, no state edit (state already dropped these
1204
+ at light archive). Preserves the _archived_task_slugs invariant: `task_slugs` is
1205
+ never touched — archived ⇒ was PASS-done keeps resolving cross-milestone deps."""
1206
+ root = _require_root()
1207
+ state = load_state(root)
1208
+ slug = args.slug
1209
+ # validate before any mutation — a reject must leave tree + state byte-for-byte unchanged
1210
+ if slug in state.get("milestones", {}):
1211
+ _die(f"milestone_not_archived: '{slug}' is still active — "
1212
+ f"run `add.py archive-milestone {slug}` first (light archive is step one)")
1213
+ entry = next((e for e in state.get("archived", []) if e.get("slug") == slug), None)
1214
+ if entry is None:
1215
+ _die("unknown_milestone")
1216
+ if entry.get("compacted"):
1217
+ _die(f"already_compacted: '{slug}' was compacted {entry['compacted']} — "
1218
+ f"see .add/archive/{slug}/")
1219
+ dest = root / "archive" / slug
1220
+ if dest.exists():
1221
+ _die(f"archive_destination_exists: .add/archive/{slug}/ exists without a "
1222
+ "compacted stamp — resolve the collision by hand before compacting")
1223
+ ms_dir = root / "milestones" / slug
1224
+ members = list(entry.get("task_slugs") or [])
1225
+ missing = [str(p.relative_to(root)) for p in
1226
+ [ms_dir, *(root / "tasks" / t for t in members)] if not p.is_dir()]
1227
+ if missing:
1228
+ _die("source_files_missing: " + " · ".join(missing))
1229
+ # deltas folded first: an `open` lesson inside the bundle would silently vanish
1230
+ # from `add.py deltas` (_collect_open_deltas globs tasks/*/TASK.md) once moved.
1231
+ member_set = set(members)
1232
+ offenders = sorted({e["task"] for v in _collect_open_deltas(root).values()
1233
+ for e in v if e["task"] in member_set})
1234
+ if offenders:
1235
+ _die("open_deltas_unfolded: consolidate the open lessons first (`add.py deltas`) — "
1236
+ "open in: " + " · ".join(offenders))
1237
+ # every precondition passed — move (same-filesystem renames, never a delete)
1238
+ def _files(d: Path) -> int:
1239
+ return sum(1 for f in d.rglob("*") if f.is_file())
1240
+ moved: list[tuple[str, int]] = []
1241
+ (root / "archive").mkdir(exist_ok=True)
1242
+ n = _files(ms_dir)
1243
+ ms_dir.rename(dest) # the milestone dir becomes the bundle root
1244
+ moved.append((f"milestones/{slug}/", n))
1245
+ (dest / "tasks").mkdir(exist_ok=True)
1246
+ for t in members:
1247
+ src = root / "tasks" / t
1248
+ n = _files(src)
1249
+ src.rename(dest / "tasks" / t)
1250
+ moved.append((f"tasks/{t}/", n))
1251
+ # state write is the LAST step: additive stamp only — task_slugs untouched
1252
+ entry["compacted"] = date.today().isoformat()
1253
+ save_state(root, state)
1254
+ total = sum(n for _, n in moved)
1255
+ print(f"compacted milestone '{slug}' -> .add/archive/{slug}/ "
1256
+ f"({len(members)} task dirs, {total} files moved)")
1257
+ for path, n in moved:
1258
+ print(f" moved {path} ({n} files)")
1259
+ print("recovery: reverse the moves (mv the bundle's parts back) — state needs no edit.")
1260
+
1261
+
885
1262
  def cmd_set_milestone(args: argparse.Namespace) -> None:
886
1263
  root = _require_root()
887
1264
  state = load_state(root)
@@ -900,6 +1277,20 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
900
1277
  print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
901
1278
 
902
1279
 
1280
+ def cmd_use(args: argparse.Namespace) -> None:
1281
+ """Set the active task to an EXISTING task (switch focus) without scaffolding a new
1282
+ one or hand-editing state.json. advance/gate/phase still take an explicit slug; `use`
1283
+ just moves the default focus, closing the only gap that forced manual state edits."""
1284
+ root = _require_root()
1285
+ state = load_state(root)
1286
+ slug = args.slug
1287
+ if slug not in state.get("tasks", {}):
1288
+ _die("unknown_task")
1289
+ state["active_task"] = slug
1290
+ save_state(root, state)
1291
+ print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
1292
+
1293
+
903
1294
  def _find_cycle(tasks: dict) -> list[str] | None:
904
1295
  """Return a cycle path in the depends_on graph, or None. Ignores unknown deps."""
905
1296
  WHITE, GRAY, BLACK = 0, 1, 2
@@ -1028,6 +1419,21 @@ def _colorize(s: str) -> str:
1028
1419
  return s
1029
1420
 
1030
1421
 
1422
+ def _project_goal(root: Path) -> str:
1423
+ """The project GOAL — the value of the first `goal:` line in PROJECT.md, else
1424
+ GOAL_UNSET. Read-only and fail-closed: a missing/unreadable foundation or a
1425
+ blank value degrades to the sentinel (orientation never raises). Mirrors how
1426
+ _milestone_doc reads the milestone goal — the foundation is the single source."""
1427
+ f = root / "PROJECT.md"
1428
+ try:
1429
+ for line in f.read_text(encoding="utf-8").splitlines():
1430
+ if line.startswith("goal:"):
1431
+ return line.split(":", 1)[1].strip() or GOAL_UNSET
1432
+ except OSError:
1433
+ pass
1434
+ return GOAL_UNSET
1435
+
1436
+
1031
1437
  def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
1032
1438
  """(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
1033
1439
  f = root / "milestones" / mslug / MILESTONE_FILE
@@ -1057,12 +1463,115 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
1057
1463
  return met, total
1058
1464
 
1059
1465
 
1466
+ def _stage_criteria(root: Path) -> tuple[int, int]:
1467
+ """(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
1468
+ PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
1469
+ and fail-closed to (0, 0): a missing file, a missing section, or any read error never raises
1470
+ and never fabricates a cue (so an unreadable foundation withholds graduation, design-for-failure)."""
1471
+ try:
1472
+ text = (root / "PROJECT.md").read_text(encoding="utf-8")
1473
+ except OSError:
1474
+ return 0, 0
1475
+ m = re.search(r"## Stage goal criteria.*?(?=\n## |\Z)", text, re.S)
1476
+ if not m:
1477
+ return 0, 0
1478
+ sec = m.group(0)
1479
+ met = len(re.findall(r"- \[x\]", sec))
1480
+ total = met + len(re.findall(r"- \[ \]", sec))
1481
+ return met, total
1482
+
1483
+
1484
+ def _all_milestones_done(state: dict) -> bool:
1485
+ """True when the project HAS milestones and EVERY one is status=done (v22). Archived
1486
+ milestones are absent from state['milestones'] (removed by the archive lifecycle), so they
1487
+ do not count; a project with zero milestones is not 'covered' and returns False."""
1488
+ ms = state.get("milestones") or {}
1489
+ return bool(ms) and all(m.get("status") == "done" for m in ms.values())
1490
+
1491
+
1492
+ def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
1493
+ """(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
1494
+ human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
1495
+ text and --json status branches share, so the cue and the json signal can never disagree."""
1496
+ met, total = _stage_criteria(root)
1497
+ ready = _all_milestones_done(state) and total > 0 and met == total
1498
+ return ready, met, total
1499
+
1500
+
1501
+ def _count_test_defs(f: Path) -> int:
1502
+ """`def test_` occurrences in one file — the ONE counting regex (primary and
1503
+ §4-declared fallback share it by construction). OSError -> 0, fail-closed."""
1504
+ try:
1505
+ return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
1506
+ except OSError:
1507
+ return 0
1508
+
1509
+
1060
1510
  def _tests_count(root: Path, slug: str) -> int:
1061
1511
  d = root / "tasks" / slug / "tests"
1062
1512
  if not d.is_dir():
1063
1513
  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"))
1514
+ return sum(_count_test_defs(f) for f in d.glob("*.py"))
1515
+
1516
+
1517
+ def _confined(p: Path, rootp: Path) -> bool:
1518
+ """True only if p resolves (symlinks followed) inside rootp; errors -> False.
1519
+ The v2 confinement check — no read is attempted on a path that fails it."""
1520
+ try:
1521
+ return p.resolve().is_relative_to(rootp)
1522
+ except OSError:
1523
+ return False
1524
+
1525
+
1526
+ def _declared_tests_count(root: Path, slug: str) -> int:
1527
+ """Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0.
1528
+ Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
1529
+ Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
1530
+ bare name -> sibling of the previous resolved token (else task dir). A directory
1531
+ token counts the *.py files directly inside it; resolved files are deduped.
1532
+ v2 confinement: every file read must resolve inside the project root — '..'
1533
+ traversal, absolute tokens, and symlink escapes all contribute 0, fail-closed."""
1534
+ body = _raw_phase_bodies(root, slug).get(4, "")
1535
+ m = re.search(r"^\s*Tests live in:.*$", body, re.M)
1536
+ if not m:
1537
+ return 0
1538
+ tdir = root / "tasks" / slug
1539
+ rootp = root.parent.resolve()
1540
+ files: list[Path] = []
1541
+ prev_dir = None
1542
+ for tok in re.findall(r"`([^`]+)`", m.group(0)):
1543
+ tok = tok.strip()
1544
+ if tok.startswith("./"):
1545
+ p = tdir / tok[2:]
1546
+ elif "/" in tok:
1547
+ p = root.parent / tok
1548
+ else:
1549
+ p = (prev_dir or tdir) / tok
1550
+ try:
1551
+ if not _confined(p, rootp):
1552
+ continue
1553
+ if p.is_dir():
1554
+ cand, prev_dir = sorted(f for f in p.glob("*.py")
1555
+ if _confined(f, rootp)), p
1556
+ elif p.is_file() and p.suffix == ".py":
1557
+ cand, prev_dir = [p], p.parent
1558
+ else:
1559
+ continue
1560
+ except OSError:
1561
+ continue
1562
+ files.extend(f for f in cand if f not in files)
1563
+ return sum(_count_test_defs(f) for f in files)
1564
+
1565
+
1566
+ def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
1567
+ """(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
1568
+ §4-declared fallback — flagged True only when it supplied a non-zero count, so
1569
+ a true zero stays a bare, honest 0."""
1570
+ primary = _tests_count(root, slug)
1571
+ if primary > 0:
1572
+ return primary, False
1573
+ declared = _declared_tests_count(root, slug)
1574
+ return (declared, True) if declared > 0 else (0, False)
1066
1575
 
1067
1576
 
1068
1577
  def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
@@ -1076,8 +1585,6 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
1076
1585
  text = f.read_text(encoding="utf-8")
1077
1586
  m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
1078
1587
  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
1588
  # observe: the field value + continuation lines until a blank line / heading / list
1082
1589
  observe = "(unknown)"
1083
1590
  for i, ln in enumerate(lines):
@@ -1098,14 +1605,14 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
1098
1605
  # deltas: each "- [COMP · status] ..." plus its indented continuation lines
1099
1606
  deltas, i = [], 0
1100
1607
  while i < len(lines):
1101
- m = _delta_start.match(lines[i])
1608
+ m = _DELTA_RE.match(lines[i])
1102
1609
  if not m:
1103
1610
  i += 1
1104
1611
  continue
1105
1612
  parts, j = [m.group(3).strip()], i + 1
1106
1613
  while j < len(lines):
1107
1614
  t = lines[j].strip()
1108
- if not t or t.startswith("#") or _delta_start.match(lines[j]):
1615
+ if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
1109
1616
  break
1110
1617
  parts.append(t)
1111
1618
  j += 1
@@ -1152,6 +1659,7 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
1152
1659
  observe, deltas = _task_prose(root, slug)
1153
1660
  phase = t.get("phase", "specify")
1154
1661
  gate = t.get("gate", "none")
1662
+ n_tests, t_declared = _tests_info(root, slug)
1155
1663
  row = {
1156
1664
  "slug": slug,
1157
1665
  "title": t.get("title", slug),
@@ -1159,7 +1667,8 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
1159
1667
  "phase_index": PHASES.index(phase) if phase in PHASES else 0,
1160
1668
  "done": _task_done(t),
1161
1669
  "gate": gate,
1162
- "tests": _tests_count(root, slug),
1670
+ "tests": n_tests,
1671
+ "tests_declared": t_declared,
1163
1672
  "observe": observe,
1164
1673
  "deltas": deltas,
1165
1674
  "waiver": t.get("waiver"),
@@ -1185,6 +1694,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
1185
1694
  "tasks": task_rows,
1186
1695
  "waivers": waivers,
1187
1696
  "deltas": all_deltas,
1697
+ # additive (v13-1): MILESTONE.md-planned slugs with no TASK.md yet —
1698
+ # the plan-vs-state diff DECIDE NEXT was blind to; [] when none
1699
+ "planned_unscaffolded": _planned_unscaffolded(root, mslug),
1188
1700
  }
1189
1701
 
1190
1702
 
@@ -1204,22 +1716,13 @@ def _clean_phase_body(body: str) -> str:
1204
1716
  return "\n".join(lines) if meaningful else "(empty)"
1205
1717
 
1206
1718
 
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).
1719
+ def _phase_spans(text: str) -> dict[int, str]:
1720
+ """Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
1721
+ canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
1722
+ its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
1723
+ the decision-marker extractor (decide-digest) depends on byte-verbatim text.
1215
1724
  KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
1216
1725
  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
1726
  lines = text.splitlines()
1224
1727
  head = re.compile(r"^##\s*(\d+)\s*·")
1225
1728
  starts: dict[int, int] = {}
@@ -1229,21 +1732,47 @@ def task_phases(root: Path, slug: str) -> list[dict]:
1229
1732
  n = int(m.group(1))
1230
1733
  if 1 <= n <= 7 and n not in starts:
1231
1734
  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
1735
+ out: dict[int, str] = {}
1736
+ for n, idx in starts.items():
1237
1737
  body_lines = []
1238
- for ln in lines[starts[n] + 1:]:
1738
+ for ln in lines[idx + 1:]:
1239
1739
  if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
1240
1740
  break
1241
1741
  body_lines.append(ln)
1242
- out.append({"phase": names[n - 1], "n": n,
1243
- "body": _clean_phase_body("\n".join(body_lines))})
1742
+ out[n] = "\n".join(body_lines)
1244
1743
  return out
1245
1744
 
1246
1745
 
1746
+ def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
1747
+ """RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
1748
+ Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
1749
+ f = root / "tasks" / slug / "TASK.md"
1750
+ try:
1751
+ return _phase_spans(f.read_text(encoding="utf-8"))
1752
+ except OSError:
1753
+ return {}
1754
+
1755
+
1756
+ def task_phases(root: Path, slug: str) -> list[dict]:
1757
+ """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §1–§7 into seven
1758
+ blocks specify→observe. PURE — NO writes. Each entry is
1759
+ { "phase": <name>, "n": <1..7>, "body": <cleaned text | "(empty)"> }.
1760
+
1761
+ The heading scan lives in _phase_spans (shared with the decide digest); this view
1762
+ CLEANS each body. Missing file / missing section / placeholder-only body ->
1763
+ "(empty)" (fail-closed)."""
1764
+ names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
1765
+ f = root / "tasks" / slug / "TASK.md"
1766
+ try:
1767
+ text = f.read_text(encoding="utf-8")
1768
+ except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
1769
+ return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)]
1770
+ spans = _phase_spans(text)
1771
+ return [{"phase": names[n - 1], "n": n,
1772
+ "body": _clean_phase_body(spans[n]) if n in spans else "(empty)"}
1773
+ for n in range(1, 8)]
1774
+
1775
+
1247
1776
  def _task_title(root: Path, slug: str) -> str:
1248
1777
  """The task's display title from TASK.md line 1 `# TASK: <title>` (fail-soft: the
1249
1778
  slug if the file or the header line is missing)."""
@@ -1262,10 +1791,20 @@ def _task_title(root: Path, slug: str) -> str:
1262
1791
  def _detail_body(body: str, width: int) -> list[str]:
1263
1792
  """Indent a phase body under its block, soft-wrapping over-long physical lines on
1264
1793
  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."""
1794
+ contract code keep their shape). Fenced ``` blocks are exempt: delimiter lines and
1795
+ everything inside an open fence emit BYTE-VERBATIM (indent + raw — no wrap, no
1796
+ whitespace collapse, even past width) so a copied contract round-trips after
1797
+ stripping the uniform indent; an unclosed fence runs verbatim to the §body end
1798
+ (fail-open). Drill-down = reading is the point, never clipped."""
1266
1799
  indent = " "
1267
1800
  out: list[str] = []
1801
+ fenced = False
1268
1802
  for raw in body.split("\n"):
1803
+ is_delim = raw.lstrip().startswith("```")
1804
+ if fenced or is_delim:
1805
+ fenced = fenced != is_delim # delimiter toggles; content keeps state
1806
+ out.append(indent + raw if raw.strip() else "")
1807
+ continue
1269
1808
  if not raw.strip():
1270
1809
  out.append("")
1271
1810
  continue
@@ -1364,10 +1903,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
1364
1903
  for r in d["tasks"]:
1365
1904
  slug = _clip(r["slug"], 27)
1366
1905
  gate = _GATE_SHORT.get(r["gate"], r["gate"])
1906
+ tests = f"{r['tests']}†" if r.get("tests_declared") else str(r["tests"])
1367
1907
  L.append(f" {slug:<27} {r['phase']:<9} {gate:<4} "
1368
- f"{str(r['tests']):<5} {_phase_track(r['phase'], g)}")
1908
+ f"{tests:<5} {_phase_track(r['phase'], g)}")
1369
1909
  L.append(f" legend {g['reached']} reached {g['current']} current "
1370
1910
  f"{g['pending']} pending spec→…→done")
1911
+ if any(r.get("tests_declared") for r in d["tasks"]):
1912
+ L.append(" † counted at the §4-declared path")
1371
1913
  else:
1372
1914
  L.append(" (no tasks yet)")
1373
1915
  L.append("")
@@ -1385,6 +1927,256 @@ def render_report(root: Path, state: dict, mslug: str, *,
1385
1927
  L.extend(_wrap(x, W - 5, f" {g['bullet']} "))
1386
1928
  else:
1387
1929
  L.append(" LEARNINGS none")
1930
+ L.append("") # DECIDE NEXT footer (v13): always present, APPEND-ONLY
1931
+ L.extend(_wrap(_decide_next_base(state, d), W - 15, " DECIDE NEXT "))
1932
+ if _planned_hint(d): # own segment so the phrase never splits mid-token
1933
+ L.extend(_wrap(_planned_hint(d).removeprefix(" — "), W - 15, " " * 14))
1934
+ L.append(banner)
1935
+ return "\n".join(L)
1936
+
1937
+
1938
+ # ---- decide digest (v13 decide-digest, frozen §3) ---------------------------
1939
+ # Decision markers: prose conventions surfaced VERBATIM. The engine EXTRACTS; it
1940
+ # never interprets, scores, or filters — add.py stays judgment-free, the human
1941
+ # signature is the gate.
1942
+ _MARKER_PREFIXES = (("⚠", "⚠"), ("- [~]", "[~]"), ("- [ ]", "[ ]"))
1943
+ _FRONT_PHASES = ("specify", "scenarios", "contract", "tests")
1944
+
1945
+
1946
+ def _decision_markers(body: str, section: int) -> list[dict]:
1947
+ """Extract decision markers from a RAW §body: a line whose first non-space chars
1948
+ are `⚠` / `- [~]` / `- [ ]`, PLUS its continuation lines (immediately following
1949
+ non-blank lines indented deeper than the marker). text is BYTE-VERBATIM — never
1950
+ re-wrapped, never clipped. Fail-open by design (a differently-worded item is
1951
+ missed); the always-printed count keeps that visible."""
1952
+ items: list[dict] = []
1953
+ lines = body.split("\n")
1954
+ i = 0
1955
+ while i < len(lines):
1956
+ ln = lines[i]
1957
+ stripped = ln.lstrip()
1958
+ tag = next((t for p, t in _MARKER_PREFIXES if stripped.startswith(p)), None)
1959
+ if tag is None:
1960
+ i += 1
1961
+ continue
1962
+ indent = len(ln) - len(stripped)
1963
+ block = [ln]
1964
+ j = i + 1
1965
+ while j < len(lines):
1966
+ nxt = lines[j]
1967
+ ns = nxt.lstrip()
1968
+ if ns and (len(nxt) - len(ns)) > indent:
1969
+ block.append(nxt)
1970
+ j += 1
1971
+ else:
1972
+ break
1973
+ items.append({"marker": tag, "section": section, "text": "\n".join(block)})
1974
+ i = j
1975
+ return items
1976
+
1977
+
1978
+ def _contract_frozen(raw3: str) -> bool:
1979
+ """§3's `Status:` line is the freeze signal (v12 precedent: the freeze is
1980
+ artifact-observable; no engine flag). Missing Status -> DRAFT (fail-closed)."""
1981
+ return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
1982
+
1983
+
1984
+ _FLAG_LABEL_RE = re.compile(r"Least-sure flag surfaced at freeze\s*:", re.I)
1985
+ _FLAG_PART_RE = re.compile(
1986
+ r"\[(?:spec|scenario|contract|test)(?:/(?:spec|scenario|contract|test))*\]")
1987
+ _FLAG_NONE_ESCAPE_RE = re.compile(
1988
+ r"none material\s*[—-]+\s*biggest risk\s*:\s*\S", re.I)
1989
+
1990
+
1991
+ def _flag_well_formed(raw3: str) -> bool:
1992
+ """A FROZEN §3 must surface a WELL-FORMED lowest-confidence flag — the unit
1993
+ that NAMES which part of the bundle is least certain. Well-formed := the label
1994
+ phrase + a unit carrying >=1 [part] tag (part in spec/scenario/contract/test,
1995
+ slash-joinable like [spec/contract]) + substantive content. A bare 'none' is
1996
+ refused unless it takes the honest escape 'none material — biggest risk: X'.
1997
+ why/cost stay a human-read convention, never machine keywords (evidence: the
1998
+ lived flags use em-dash/prose, never literal because/if-wrong). HTML comments
1999
+ (template hints) never count. PURE — fail-closed on a missing label."""
2000
+ body = re.sub(r"<!--.*?-->", "", raw3, flags=re.S)
2001
+ m = _FLAG_LABEL_RE.search(body)
2002
+ if not m:
2003
+ return False
2004
+ unit = body[m.end():].strip()
2005
+ if not unit:
2006
+ return False
2007
+ if _FLAG_NONE_ESCAPE_RE.search(unit): # the honest-none escape — no tag needed
2008
+ return True
2009
+ if not _FLAG_PART_RE.search(unit): # must name WHICH part is uncertain
2010
+ return False
2011
+ residue = _FLAG_PART_RE.sub("", unit).replace("⚠", "").strip(" -—·\n\t")
2012
+ return len(residue) >= 3 # substantive content beyond the tag(s)
2013
+
2014
+
2015
+ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
2016
+ """FACTS for the task-level decision-point digest (frozen shape). The decision comes
2017
+ from STATE ONLY: recorded (gate set / observe / done) · front (specify→tests) ·
2018
+ gate (build/verify). judgment = extracted markers, byte-verbatim. PURE."""
2019
+ tasks = state.get("tasks") or {}
2020
+ t = tasks.get(slug, {})
2021
+ phase = t.get("phase", "specify")
2022
+ gate = t.get("gate", "none")
2023
+ if gate != "none" or phase in ("observe", "done"):
2024
+ seam = "recorded"
2025
+ elif phase in _FRONT_PHASES:
2026
+ seam = "front"
2027
+ else:
2028
+ seam = "gate"
2029
+ raw = _raw_phase_bodies(root, slug)
2030
+ frozen = _contract_frozen(raw.get(3, ""))
2031
+ if seam == "gate": # the items closest to the gate lead: §6 first, then §1
2032
+ judgment = _decision_markers(raw.get(6, ""), 6) + _decision_markers(raw.get(1, ""), 1)
2033
+ elif seam == "front" and not frozen:
2034
+ judgment = _decision_markers(raw.get(1, ""), 1) + _decision_markers(raw.get(3, ""), 3)
2035
+ else:
2036
+ judgment = []
2037
+
2038
+ members = [x for x in tasks.values() if x.get("milestone") == mslug]
2039
+ done, total = sum(1 for x in members if _task_done(x)), len(members)
2040
+ facts = {"phase": phase, "gate": gate,
2041
+ "deps": [{"slug": d, "gate": tasks.get(d, {}).get("gate", "none")}
2042
+ for d in t.get("depends_on", [])],
2043
+ "tests": _tests_info(root, slug)[0]}
2044
+
2045
+ if seam == "gate":
2046
+ unlocks = f"gate PASS -> task done -> milestone {min(done + 1, total)}/{total}"
2047
+ decide = "add.py gate PASS | RISK-ACCEPTED | HARD-STOP"
2048
+ elif seam == "front" and not frozen:
2049
+ unlocks = "freeze §3 -> the auto run takes build -> verify (autonomy: auto by default)"
2050
+ decide = "approve -> freeze §3 (Status: FROZEN @ v1) -> auto run"
2051
+ elif seam == "front":
2052
+ unlocks = "none"
2053
+ decide = "no decision pending — frozen; the run owns it. next decision point: verify gate"
2054
+ else:
2055
+ unlocks = "none"
2056
+ decide = f"no decision pending — recorded gate: {gate}"
2057
+ return {"seam": seam, "milestone": mslug, "task": slug, "phase": phase,
2058
+ "gate": gate, "judgment": judgment, "facts": facts,
2059
+ "unlocks": unlocks, "decide": decide}
2060
+
2061
+
2062
+ def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
2063
+ width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
2064
+ """Text view of the decision-point digest — decisive facts FIRST: NEEDS YOUR
2065
+ JUDGMENT (markers byte-verbatim, section-tagged) -> [front: §3 verbatim] ->
2066
+ ENGINE FACTS -> UNLOCKS -> DECIDE. PURE — no writes; plain text (color is a
2067
+ tty-only skin in cmd_report, like every report view)."""
2068
+ d = decide_data(root, state, mslug, slug)
2069
+ g = _ASCII if ascii else _UNICODE
2070
+ banner = g["h"] * width
2071
+ seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
2072
+ "recorded": "RECORDED"}[d["seam"]]
2073
+ L = [banner, f" DECIDE · {mslug or '—'} · {slug} · decision point: {seam_label}", banner]
2074
+ if d["decide"].startswith("no decision pending"):
2075
+ L.append(f" {d['decide']}")
2076
+ L.append(f" GATE {d['gate']}")
2077
+ L.append(banner)
2078
+ return "\n".join(L)
2079
+ L.append(f" NEEDS YOUR JUDGMENT ({len(d['judgment'])})")
2080
+ for item in d["judgment"]:
2081
+ L.append(f" [§{item['section']}]")
2082
+ L.extend(item["text"].split("\n")) # byte-verbatim — never wrapped/clipped
2083
+ if d["seam"] == "front":
2084
+ L.append("")
2085
+ L.append(" CONTRACT (§3 verbatim)")
2086
+ L.extend(_raw_phase_bodies(root, slug).get(3, "").split("\n"))
2087
+ L.append(" STATUS DRAFT")
2088
+ f = d["facts"]
2089
+ deps_txt = " ".join(f"{x['slug']}:{x['gate']}" for x in f["deps"]) or "none"
2090
+ L.append("")
2091
+ L.append(f" ENGINE FACTS phase {f['phase']} · gate {f['gate']} · "
2092
+ f"deps {deps_txt} · tests {f['tests']}")
2093
+ L.append(f" UNLOCKS {d['unlocks']}")
2094
+ L.append(f" DECIDE {d['decide']}")
2095
+ L.append(banner)
2096
+ return "\n".join(L)
2097
+
2098
+
2099
+ def _planned_unscaffolded(root: Path, mslug: str) -> list[str]:
2100
+ """Slugs MILESTONE.md plans (rows `- [ ] <slug> …`) that have no TASK.md yet —
2101
+ the plan-vs-state diff. Only valid-slug first-tokens match (a template
2102
+ placeholder like <slug> never does); file order, deduped; fail-closed []."""
2103
+ md = root / "milestones" / mslug / "MILESTONE.md"
2104
+ try:
2105
+ text = md.read_text(encoding="utf-8")
2106
+ except OSError:
2107
+ return []
2108
+ out: list[str] = []
2109
+ for sec in re.split(r"^## ", text, flags=re.M)[1:]:
2110
+ if not sec.startswith("Tasks"): # only the Tasks list — never exit criteria
2111
+ continue
2112
+ for m in re.finditer(r"^- \[[ x~]\] ([A-Za-z0-9_-]+)\b", sec, re.M):
2113
+ slug = m.group(1)
2114
+ if slug not in out and not (root / "tasks" / slug / "TASK.md").is_file():
2115
+ out.append(slug)
2116
+ return out
2117
+
2118
+
2119
+ def _decide_next(state: dict, d: dict) -> str:
2120
+ """The rollup's DECIDE NEXT line (frozen precedence): HARD-STOP -> consolidate+archive
2121
+ -> first decision-blocked task (ACTIVE task first, then state order) -> run-in-
2122
+ progress. v2: when d carries planned_unscaffolded, the line gains a
2123
+ plan-vs-state suffix — precedence itself stays state-only."""
2124
+ return _decide_next_base(state, d) + _planned_hint(d)
2125
+
2126
+
2127
+ def _planned_hint(d: dict) -> str:
2128
+ """The plan-vs-state suffix ('' when nothing is missing). Text renders emit it
2129
+ as its OWN wrapped segment so the phrase never splits mid-token; the JSON
2130
+ 'decide' string carries it inline via _decide_next."""
2131
+ planned = d.get("planned_unscaffolded") or []
2132
+ if not planned:
2133
+ return ""
2134
+ return f" — {len(planned)} planned not yet scaffolded: " + " · ".join(planned)
2135
+
2136
+
2137
+ def _decide_next_base(state: dict, d: dict) -> str:
2138
+ ms = d["milestone"]["slug"]
2139
+ rows = d["tasks"]
2140
+ if not rows:
2141
+ return "none — no tasks yet"
2142
+ stopped = [r for r in rows if r["gate"] == "HARD-STOP"]
2143
+ if stopped:
2144
+ return f"resolve HARD-STOP on {stopped[0]['slug']}"
2145
+ s = d["summary"]
2146
+ if s["tasks_done"] == s["tasks_total"]:
2147
+ # tasks complete — but the milestone holds while the goal (exit criteria) is
2148
+ # unmet (v20). Point at the feed-forward inventory the loop draws from, instead
2149
+ # of "archive". Fires only when criteria exist; else the prompt is unchanged.
2150
+ ec = s.get("exit_criteria") or {}
2151
+ met, total = ec.get("met", 0), ec.get("total", 0)
2152
+ if total > 0 and met < total:
2153
+ return (f"goal not met ({met}/{total} exit criteria) — propose next tasks "
2154
+ f"from open deltas / the unscaffolded plan (add.py deltas)")
2155
+ return f"consolidate learnings + archive-milestone {ms}"
2156
+ active = state.get("active_task")
2157
+ order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
2158
+ for r in order:
2159
+ if r["done"]:
2160
+ continue
2161
+ if r["phase"] in _FRONT_PHASES:
2162
+ return (f"approve the contract of {r['slug']} — "
2163
+ f"add.py report {ms} {r['slug']} --decide")
2164
+ if r["phase"] == "verify" and r["gate"] == "none":
2165
+ return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide"
2166
+ r = next(x for x in order if not x["done"])
2167
+ return f"none — run in progress ({r['slug']} at {r['phase']})"
2168
+
2169
+
2170
+ def render_decide_next(root: Path, state: dict, mslug: str, *,
2171
+ width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
2172
+ """`report <ms> --decide`: ONLY the DECIDE NEXT block (no rollup table). PURE."""
2173
+ g = _ASCII if ascii else _UNICODE
2174
+ banner = g["h"] * width
2175
+ d = report_data(root, state, mslug)
2176
+ L = [banner, f" {mslug} · DECIDE NEXT", banner]
2177
+ L.extend(_wrap(_decide_next_base(state, d), width - 4, " "))
2178
+ if _planned_hint(d): # own segment so the phrase never splits mid-token
2179
+ L.extend(_wrap(_planned_hint(d).removeprefix(" — "), width - 4, " "))
1388
2180
  L.append(banner)
1389
2181
  return "\n".join(L)
1390
2182
 
@@ -1398,10 +2190,456 @@ def _write_retro(root: Path, state: dict, mslug: str) -> Path:
1398
2190
  trust the locale default), never mutates state.json."""
1399
2191
  content = render_report(root, state, mslug, width=_DEFAULT_WIDTH, ascii=False)
1400
2192
  path = root / "milestones" / mslug / "RETRO.md"
1401
- path.write_text(content, encoding="utf-8")
2193
+ _atomic_write(path, content) # honor the module's atomic-write contract (no half-write)
1402
2194
  return path
1403
2195
 
1404
2196
 
2197
+ _COMPETENCY_ORDER = ("DDD", "SDD", "UDD", "TDD", "ADD")
2198
+ _DELTA_STATUSES = ("open", "folded", "rejected")
2199
+
2200
+ # Canonical delta grammar — the single compiled source for the enumerated
2201
+ # competency · status shape. Leading \s* is PERMISSIVE so _task_prose can feed
2202
+ # un-stripped lines directly; callers that pre-strip their input
2203
+ # (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
2204
+ # matches zero). Anchored at line-start via re.match.
2205
+ _DELTA_RE = re.compile(
2206
+ r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
2207
+ )
2208
+ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
2209
+
2210
+ # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
2211
+ # A line with a `· ` bracket separator is a delta-attempt. Does NOT enumerate
2212
+ # competencies or statuses — a different abstraction from _DELTA_RE (no DRY violation).
2213
+ _TAG_BROAD_RE = re.compile(r"^\s*-\s*\[\s*([^\]·]+?)\s*·\s*([^\]·]+?)\s*\]\s*(.*)$")
2214
+
2215
+
2216
+ def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
2217
+ """Lint all open delta entries in a task's '### Competency deltas' block.
2218
+
2219
+ Returns:
2220
+ None — no delta-attempts found; no check emitted.
2221
+ (True, "") — all open entries pass.
2222
+ (False, "<code> -> <tag line>") — first failing entry with its failure code.
2223
+
2224
+ Contract rules (frozen §3, v1):
2225
+ - SKIP HTML-comment lines and blank lines (they are never tag lines).
2226
+ - Group lines into ENTRIES: a broad tag line starts an entry; following lines
2227
+ until next tag / blank / end-of-block are its continuation.
2228
+ - A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag.
2229
+ - For each entry, skip folded/rejected (open-only — history not retrofitted).
2230
+ - Validate the remaining (open) entries: COMP in _COMPETENCY_ORDER,
2231
+ status in _DELTA_STATUSES, and '(evidence:' present SOMEWHERE in the unit.
2232
+ - Fail-closed: an unparseable attempt FAILS (never silently passes).
2233
+ """
2234
+ task_md = root / "tasks" / slug / "TASK.md"
2235
+ if not task_md.exists():
2236
+ return None
2237
+ try:
2238
+ text = task_md.read_text(encoding="utf-8")
2239
+ except OSError:
2240
+ return None
2241
+
2242
+ # Locate the "### Competency deltas" block.
2243
+ block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
2244
+ if not block_match:
2245
+ return None
2246
+
2247
+ block = block_match.group(1)
2248
+ raw_lines = block.splitlines()
2249
+
2250
+ # First pass: collect entries (tag line + continuations).
2251
+ # HTML-comment lines are skipped entirely (invisible to the guard).
2252
+ # Blank lines terminate the current entry, but are not tags themselves.
2253
+ entries: list[tuple[str, list[str]]] = [] # (tag_line, [tag_line, *continuations])
2254
+ current: list[str] | None = None
2255
+ for raw_line in raw_lines:
2256
+ stripped = raw_line.strip()
2257
+ # Skip HTML-comment lines.
2258
+ if stripped.startswith("<!--"):
2259
+ continue
2260
+ # Blank line terminates the current entry.
2261
+ if not stripped:
2262
+ current = None
2263
+ continue
2264
+ # Broad tag detection: any "- [tok · tok]" line starts a new entry.
2265
+ m = _TAG_BROAD_RE.match(raw_line)
2266
+ if m:
2267
+ current = [stripped]
2268
+ entries.append((stripped, current))
2269
+ elif current is not None:
2270
+ # Continuation line of the current entry.
2271
+ current.append(stripped)
2272
+ # else: non-blank, non-comment, non-tag line with no prior entry — ignore.
2273
+
2274
+ if not entries:
2275
+ return None # no delta-attempts → no check emitted
2276
+
2277
+ # Second pass: validate each entry.
2278
+ for tag_line, unit_lines in entries:
2279
+ m = _TAG_BROAD_RE.match(tag_line)
2280
+ if not m:
2281
+ # Should not happen, but fail-closed.
2282
+ return False, f"malformed_delta -> {tag_line}"
2283
+ raw_comp = m.group(1).strip()
2284
+ raw_status = m.group(2).strip()
2285
+
2286
+ # Step 1: skip historical entries (folded/rejected) — open-only enforcement.
2287
+ # MUST happen before competency/status validation per §3: "history not retrofitted".
2288
+ if raw_status in ("folded", "rejected"):
2289
+ continue
2290
+
2291
+ # Step 2: use _DELTA_RE (the canonical grammar, single source of truth) to test
2292
+ # whether the tag line is a fully-valid delta shape. If it matches, check evidence
2293
+ # only. If it fails, classify the failure via the raw tokens (never a parallel grammar).
2294
+ unit_text = " ".join(unit_lines)
2295
+ if _DELTA_RE.match(tag_line):
2296
+ # Valid comp + status + non-empty tail — check evidence in the joined unit.
2297
+ if "(evidence:" not in unit_text:
2298
+ return False, f"no_evidence -> {tag_line}"
2299
+ else:
2300
+ # Classify why _DELTA_RE rejected it (open entries only — folded/rejected skipped).
2301
+ if raw_comp not in _COMPETENCY_ORDER:
2302
+ return False, f"unknown_competency -> {tag_line}"
2303
+ if raw_status not in _DELTA_STATUSES:
2304
+ return False, f"unknown_status -> {tag_line}"
2305
+ # Comp and status are valid but the line still failed _DELTA_RE (e.g. empty tail).
2306
+ return False, f"malformed_delta -> {tag_line}"
2307
+
2308
+ return True, ""
2309
+
2310
+
2311
+ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
2312
+ """Scan every .add/tasks/*/TASK.md for open lessons learned.
2313
+
2314
+ Returns a dict keyed by competency in canonical order; each value is a list
2315
+ of {task, text, evidence} dicts. READ-ONLY — never mutates any file."""
2316
+ by_comp: dict[str, list[dict]] = {c: [] for c in _COMPETENCY_ORDER}
2317
+ tasks_dir = root / "tasks"
2318
+ if not tasks_dir.is_dir():
2319
+ return by_comp
2320
+ for task_md in sorted(tasks_dir.glob("*/TASK.md")):
2321
+ slug = task_md.parent.name
2322
+ try:
2323
+ text = task_md.read_text(encoding="utf-8")
2324
+ except OSError:
2325
+ continue
2326
+ # Locate the "### Competency deltas" block (may appear anywhere in the file).
2327
+ block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
2328
+ if not block_match:
2329
+ continue
2330
+ block = block_match.group(1)
2331
+ # Group lines into entries (tag line + continuations) so a multi-line delta —
2332
+ # whose learning wraps and whose (evidence: …) may land on a later line — is read
2333
+ # in FULL, not truncated to its first line. A tag line starts an entry; a line
2334
+ # that does not begin a new "- " list item continues it; a blank/comment or a
2335
+ # new "- " item ends it (a trailing malformed item can't pollute a delta's text).
2336
+ entries: list[list[str]] = []
2337
+ current: list[str] | None = None
2338
+ for line in block.splitlines():
2339
+ stripped = line.strip()
2340
+ if not stripped or stripped.startswith("<!--"):
2341
+ current = None
2342
+ continue
2343
+ if _DELTA_RE.match(stripped):
2344
+ current = [stripped]
2345
+ entries.append(current)
2346
+ elif current is not None and not stripped.startswith("-"):
2347
+ current.append(stripped) # genuine wrap of the current learning
2348
+ else:
2349
+ current = None # a new / malformed list item ends the run
2350
+ for unit in entries:
2351
+ m = _DELTA_RE.match(unit[0])
2352
+ comp, status = m.group(1), m.group(2)
2353
+ if status != "open":
2354
+ continue
2355
+ # Join the tag line's tail with any continuation lines, then split evidence.
2356
+ tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
2357
+ em = _EVIDENCE_RE.match(tail)
2358
+ if em:
2359
+ delta_text, evidence = em.group(1).strip(), em.group(2).strip()
2360
+ else:
2361
+ delta_text, evidence = tail, ""
2362
+ by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence})
2363
+ return by_comp
2364
+
2365
+
2366
+ _AUDIT_STAMP_RE = re.compile(r"Status:\s*FROZEN @ v\d+\s*[—-]+\s*approved by\s+\S+")
2367
+ _AUDIT_OUTCOME_RE = re.compile(r"^Outcome:\s*(PASS|RISK-ACCEPTED|HARD-STOP)\b", re.M)
2368
+ _AUDIT_SECURITY_RE = re.compile(
2369
+ r"^\s*- \[[ x~]\] no exposed secrets.*(?:\n(?!\s*- \[|#).*)*", re.M)
2370
+ _AUDIT_REVIEWED_RE = re.compile(r"^Reviewed by:(.*)$", re.M)
2371
+
2372
+
2373
+ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
2374
+ """The gate-audit core: verify that human decision points left WELL-FORMED records.
2375
+ Judgment-free — checks record SHAPE (a named human at the freeze, exactly one
2376
+ gate outcome, prose ≡ state, a marked security note never auto-reviewed),
2377
+ never re-decides an outcome. Scope: active tasks done/observe or gated; open
2378
+ fronts skipped. PURE — reads only. Honest limit: shape, not engagement — a
2379
+ forged name passes; CI wiring makes forgery explicit and attributable."""
2380
+ tasks = state.get("tasks") or {}
2381
+ checked, findings = 0, []
2382
+
2383
+ def f(slug: str, code: str, detail: str) -> None:
2384
+ findings.append({"task": slug, "code": code, "detail": detail})
2385
+
2386
+ for slug in sorted(tasks):
2387
+ t = tasks[slug]
2388
+ phase, gate = t.get("phase", "specify"), t.get("gate", "none")
2389
+ if phase not in ("done", "observe") and gate == "none":
2390
+ continue # the front is still open — nothing recorded to audit
2391
+ checked += 1
2392
+ raw = _raw_phase_bodies(root, slug)
2393
+ s3, s6 = raw.get(3, ""), raw.get(6, "")
2394
+ if not _AUDIT_STAMP_RE.search(s3):
2395
+ f(slug, "unstamped_freeze",
2396
+ "§3 lacks 'Status: FROZEN @ vN — approved by <name>'")
2397
+ # verified-marker discriminator (task unflagged-freeze): enforce the
2398
+ # lowest-confidence flag ONLY on records that crossed the guard (flag_verified).
2399
+ # A marked record whose flag was deleted/corrupted post-freeze is
2400
+ # tampering; unmarked predecessors are skipped — the board is never
2401
+ # retro-redded.
2402
+ if t.get("flag_verified") and not _flag_well_formed(s3):
2403
+ f(slug, "unflagged_freeze",
2404
+ "flag_verified record lost its well-formed "
2405
+ "'Least-sure flag surfaced at freeze:' unit")
2406
+ outcomes = _AUDIT_OUTCOME_RE.findall(s6)
2407
+ if len(outcomes) != 1:
2408
+ f(slug, "malformed_gate_record",
2409
+ f"{len(outcomes)} Outcome lines in §6 (need exactly 1)")
2410
+ elif gate != "none" and outcomes[0] != gate:
2411
+ f(slug, "gate_record_mismatch",
2412
+ f"§6 records {outcomes[0]} but state.json records {gate}")
2413
+ sec = _AUDIT_SECURITY_RE.search(s6)
2414
+ marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
2415
+ rev = _AUDIT_REVIEWED_RE.search(s6)
2416
+ if marked and rev and "auto-gate" in rev.group(1):
2417
+ f(slug, "unescalated_security_note",
2418
+ "security-line note (NOTE/⚠) with an auto-gate reviewer")
2419
+ # F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
2420
+ # high-risk record must show a guarded dial AND a human at the gate —
2421
+ # catches post-gate header tampering and auto-resolved high-risk gates.
2422
+ hdr = _task_header(root, slug)
2423
+ if _RISK_HIGH_RE.search(hdr):
2424
+ if not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
2425
+ f(slug, "unguarded_high_risk_auto",
2426
+ "risk: high declared but autonomy is not 'conservative'")
2427
+ elif rev and "auto-gate" in rev.group(1):
2428
+ f(slug, "unguarded_high_risk_auto",
2429
+ "risk: high task whose GATE RECORD reviewer is the auto-gate")
2430
+ if outcomes == ["RISK-ACCEPTED"]:
2431
+ if marked:
2432
+ f(slug, "risk_accepted_security",
2433
+ "a waiver on a marked security item is never allowed")
2434
+ if not all(re.search(rf"{k}:\s*(?!<)\S", s6)
2435
+ for k in ("owner", "ticket", "expires")):
2436
+ f(slug, "waiver_incomplete",
2437
+ "RISK-ACCEPTED needs owner · ticket · expires")
2438
+ return checked, findings
2439
+
2440
+
2441
+ def cmd_audit(args: argparse.Namespace) -> None:
2442
+ """Read-only: audit recorded human decision points for well-formedness. Exit 0 clean,
2443
+ exit 1 with findings — the enforcement gate CI consumes (audit-ci). Writes
2444
+ NOTHING; every other command is byte-identical."""
2445
+ root = _require_root()
2446
+ checked, findings = _audit_findings(root, load_state(root))
2447
+ if getattr(args, "json", False):
2448
+ print(json.dumps({"checked": checked, "findings": findings},
2449
+ ensure_ascii=False, indent=2))
2450
+ else:
2451
+ if findings:
2452
+ for x in findings:
2453
+ print(f"audit: {x['code']} {x['task']} — {x['detail']}")
2454
+ else:
2455
+ print(f"audit: clean ({checked} tasks checked)")
2456
+ if findings:
2457
+ sys.exit(1)
2458
+
2459
+
2460
+ def _retro_carried(path: Path) -> int:
2461
+ """Parse the 'LEARNINGS (N carried)' count from a RETRO.md; absent/unreadable -> 0.
2462
+ READ-ONLY (the graduation harvest's carried-delta facet for the consolidated tier)."""
2463
+ try:
2464
+ text = path.read_text(encoding="utf-8")
2465
+ except OSError:
2466
+ return 0
2467
+ m = re.search(r"LEARNINGS \((\d+) carried\)", text)
2468
+ return int(m.group(1)) if m else 0
2469
+
2470
+
2471
+ def graduation_data(root: Path, state: dict) -> dict:
2472
+ """The single source of FACTS for the graduation harvest — PURE, NO writes (mirrors
2473
+ report_data). Both the `graduation-report` text dashboard and `--json` render from this
2474
+ one dict, so the human view and the machine view can never disagree.
2475
+
2476
+ GATHER, never JUDGE: every value is a RECORD the human verifies by looking; there is no
2477
+ readiness/score/ranking field by construction (would_be_judging is structurally impossible).
2478
+ Two tiers: LIVE = in-state (state + on-disk TASK.md); CONSOLIDATED = compacted milestones,
2479
+ a RETRO record only. A missing/unreadable source is SKIPPED, never a crash (fail-closed)."""
2480
+ tasks = state.get("tasks") or {}
2481
+ milestones = state.get("milestones") or {}
2482
+ archived = state.get("archived") or []
2483
+
2484
+ # a — open deltas by competency (reuse the project-wide harvester; compacted folded out)
2485
+ by_comp = _collect_open_deltas(root)
2486
+ open_deltas = {"total": sum(len(v) for v in by_comp.values()),
2487
+ "by_competency": {c: v for c, v in by_comp.items() if v}}
2488
+
2489
+ # b — open RISK-ACCEPTED waivers, soonest expiry first (missing/unparseable expiry sorts LAST)
2490
+ waivers = []
2491
+ for slug, t in tasks.items():
2492
+ if t.get("gate") == "RISK-ACCEPTED" and t.get("waiver"):
2493
+ w = t["waiver"]
2494
+ waivers.append({"slug": slug, "owner": w.get("owner", "?"),
2495
+ "ticket": w.get("ticket", "?"), "expires": w.get("expires", "?")})
2496
+
2497
+ def _exp_key(wv):
2498
+ try:
2499
+ return (0, date.fromisoformat(wv["expires"]).isoformat())
2500
+ except (ValueError, TypeError):
2501
+ return (1, "") # unparseable/missing -> after every real date
2502
+ waivers.sort(key=_exp_key)
2503
+
2504
+ # c — RETRO records: LIVE under milestones/, CONSOLIDATED under archive/ (the compacted backbone)
2505
+ retros = []
2506
+ for sub_dir, tier in ((root / "milestones", "live"), (root / "archive", "consolidated")):
2507
+ if sub_dir.is_dir():
2508
+ for retro in sorted(sub_dir.glob("*/RETRO.md")):
2509
+ if retro.is_file(): # a directory at the path is not a ledger (fail-closed)
2510
+ retros.append({"milestone": retro.parent.name,
2511
+ "path": str(retro.relative_to(root)),
2512
+ "carried_deltas": _retro_carried(retro), "tier": tier})
2513
+
2514
+ # d-i — residue gate records: the residue-class facet (RISK-ACCEPTED shares the waivers[] record)
2515
+ residue_gates = [{"slug": s, "gate": t.get("gate")} for s, t in tasks.items()
2516
+ if t.get("gate") in ("RISK-ACCEPTED", "HARD-STOP")]
2517
+
2518
+ # d-ii — §6 disclosed residue: in-state tasks' '- [⚠]' VERIFY list items (the pinned rule)
2519
+ # e — coverage-gaps proxy: in-state §7 Watch still the '<error rate' placeholder head
2520
+ residue_disclosed, coverage_gaps = [], []
2521
+ for slug in tasks:
2522
+ try:
2523
+ text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
2524
+ except OSError:
2525
+ continue # unreadable TASK.md -> skip this task's prose records
2526
+ m = re.search(r"##\s*6\b.*?(?=\n##\s*\d|\Z)", text, re.S) # the VERIFY section only
2527
+ for line in (m.group(0) if m else "").splitlines():
2528
+ st = line.strip()
2529
+ if st.startswith("- [⚠]"):
2530
+ residue_disclosed.append({"slug": slug, "line": st[len("- [⚠]"):].strip()})
2531
+ for line in text.splitlines():
2532
+ if line.startswith("Watch") and "<error rate" in line: # unfilled <…> template head
2533
+ coverage_gaps.append({"slug": slug})
2534
+ break
2535
+
2536
+ return {
2537
+ "open_deltas": open_deltas,
2538
+ "waivers": waivers,
2539
+ "retros": retros,
2540
+ "residue_gates": residue_gates,
2541
+ "residue_disclosed": residue_disclosed,
2542
+ "coverage_gaps": coverage_gaps,
2543
+ "summary": {
2544
+ "open_deltas": open_deltas["total"], "waivers": len(waivers), "retros": len(retros),
2545
+ "residue_gates": len(residue_gates), "residue_disclosed": len(residue_disclosed),
2546
+ "coverage_gaps": len(coverage_gaps),
2547
+ "milestones_live": len(milestones), "milestones_consolidated": len(archived),
2548
+ },
2549
+ }
2550
+
2551
+
2552
+ def cmd_graduation_report(args: argparse.Namespace) -> None:
2553
+ """Read-only: GATHER the MVP loop's evidence into five labeled record-sets for the
2554
+ graduate.md interview. text (default) or --json (the frozen JSON facts interface). Exit 0 ALWAYS —
2555
+ a gather, not a gate; the ONLY non-zero exit is no_project. Judges nothing. NO writes."""
2556
+ root = find_root()
2557
+ if root is None: # frozen contract: fail-closed with a no_project signal
2558
+ _die("no_project: no .add/ project found. Run `add.py init` first.")
2559
+ state = load_state(root)
2560
+ d = graduation_data(root, state)
2561
+
2562
+ if getattr(args, "json", False):
2563
+ print(json.dumps(d, ensure_ascii=False, indent=2))
2564
+ return
2565
+
2566
+ s = d["summary"]
2567
+ L = ["GRADUATION REPORT — MVP-loop evidence (gather, not judge)", ""]
2568
+ L.append(f"Open deltas ({s['open_deltas']}) — unfolded lessons by competency:")
2569
+ for comp, entries in d["open_deltas"]["by_competency"].items():
2570
+ for e in entries:
2571
+ L.append(f" - [{comp}] {e['text']} [{e['task']}]")
2572
+ L.append("")
2573
+ L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED, soonest expiry first:")
2574
+ for w in d["waivers"]:
2575
+ L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}")
2576
+ L.append("")
2577
+ _live_retros = sum(1 for r in d["retros"] if r["tier"] == "live")
2578
+ _cons_retros = s["retros"] - _live_retros
2579
+ L.append(f"RETRO records ({s['retros']}: {_live_retros} live · {_cons_retros} consolidated) — "
2580
+ f"milestones: {s['milestones_live']} live · "
2581
+ f"{s['milestones_consolidated']} represented by RETRO record:")
2582
+ for r in d["retros"]:
2583
+ L.append(f" - {r['milestone']} [{r['tier']}]: {r['path']} ({r['carried_deltas']} carried)")
2584
+ L.append("")
2585
+ L.append(f"Verify residue — gate records ({s['residue_gates']}, RISK-ACCEPTED/HARD-STOP):")
2586
+ for g in d["residue_gates"]:
2587
+ L.append(f" - {g['slug']}: {g['gate']}")
2588
+ L.append(f"Verify residue — disclosed §6 lines ({s['residue_disclosed']}):")
2589
+ for r in d["residue_disclosed"]:
2590
+ L.append(f" - {r['slug']}: {r['line']}")
2591
+ L.append("")
2592
+ L.append(f"Coverage gaps ({s['coverage_gaps']}) — PROXY (monitor not declared; §7 Watch unfilled):")
2593
+ for c in d["coverage_gaps"]:
2594
+ L.append(f" - {c['slug']}")
2595
+ print("\n".join(L))
2596
+
2597
+
2598
+ def cmd_deltas(args: argparse.Namespace) -> None:
2599
+ """Read-only: report all open lessons learned grouped by competency.
2600
+
2601
+ Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
2602
+ matching the delta grammar; shows only `open` entries in canonical competency
2603
+ order (DDD·SDD·UDD·TDD·ADD). --json emits one JSON object. Exit 0 ALWAYS.
2604
+ Writes NOTHING."""
2605
+ root = _require_root()
2606
+ by_comp = _collect_open_deltas(root)
2607
+ total = sum(len(v) for v in by_comp.values())
2608
+
2609
+ if getattr(args, "json", False):
2610
+ payload: dict = {
2611
+ "total": total,
2612
+ "by_competency": {c: v for c, v in by_comp.items() if v},
2613
+ }
2614
+ print(json.dumps(payload, ensure_ascii=False))
2615
+ return
2616
+
2617
+ if total == 0:
2618
+ print("no open deltas.")
2619
+ return
2620
+
2621
+ print(f"open lessons learned ({total} total):")
2622
+ for comp in _COMPETENCY_ORDER:
2623
+ entries = by_comp[comp]
2624
+ if not entries:
2625
+ continue
2626
+ print(f" {comp} ({len(entries)}):")
2627
+ for e in entries:
2628
+ print(f" - {e['text']} [{e['task']}]")
2629
+
2630
+
2631
+ def cmd_project(args: argparse.Namespace) -> None:
2632
+ """Read-only: print .add/PROJECT.md (the read-first foundation) in one command.
2633
+
2634
+ Fail-closed: a missing foundation dies with a clear stderr message + a non-zero
2635
+ exit, never a silent empty print. Writes NOTHING."""
2636
+ root = _require_root()
2637
+ foundation = root / "PROJECT.md"
2638
+ if not foundation.exists():
2639
+ _die("missing foundation: .add/PROJECT.md (run `add.py init` to scaffold it)")
2640
+ print(foundation.read_text(encoding="utf-8"), end="")
2641
+
2642
+
1405
2643
  def cmd_report(args: argparse.Namespace) -> None:
1406
2644
  """Read-only: capture a milestone's raw data (--json) or render the text
1407
2645
  dashboard (color on a tty, ASCII when the terminal can't do Unicode, --plain
@@ -1433,6 +2671,12 @@ def cmd_report(args: argparse.Namespace) -> None:
1433
2671
  _die(f"unknown_milestone: task '{name}' is not attached to a milestone")
1434
2672
  else:
1435
2673
  _die(f"unknown_milestone: '{name}' is not a milestone")
2674
+ elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
2675
+ slug = state.get("active_task")
2676
+ if not slug or slug not in tasks:
2677
+ _die("no_active_task — name one: add.py report <milestone> <task> --decide")
2678
+ drill_task = slug
2679
+ mslug = tasks[slug].get("milestone") or ""
1436
2680
  else: # no positional -> active milestone
1437
2681
  mslug = state.get("active_milestone")
1438
2682
  if not mslug:
@@ -1441,6 +2685,32 @@ def cmd_report(args: argparse.Namespace) -> None:
1441
2685
  if mslug not in milestones:
1442
2686
  _die(f"unknown_milestone: '{mslug}' is not a milestone")
1443
2687
 
2688
+ if getattr(args, "decide", False):
2689
+ # Decision-seam digest (v13): task -> seam digest; milestone -> DECIDE NEXT
2690
+ # block only. PURE, like every report path.
2691
+ if getattr(args, "json", False):
2692
+ if drill_task:
2693
+ payload = decide_data(root, state, mslug, drill_task)
2694
+ else: # milestone altitude: same frozen key set, task null
2695
+ d = report_data(root, state, mslug)
2696
+ payload = {"seam": "milestone", "milestone": mslug, "task": None,
2697
+ "phase": "", "gate": "none", "judgment": [],
2698
+ "facts": {"phase": "", "gate": "none", "deps": [], "tests": 0},
2699
+ "unlocks": "", "decide": _decide_next(state, d)}
2700
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
2701
+ return
2702
+ plain = getattr(args, "plain", False)
2703
+ interactive = sys.stdout.isatty() and not plain
2704
+ width = _term_width() if interactive else _DEFAULT_WIDTH
2705
+ use_ascii = plain or _use_ascii()
2706
+ out = (render_decide(root, state, mslug, drill_task, width=width, ascii=use_ascii)
2707
+ if drill_task else
2708
+ render_decide_next(root, state, mslug, width=width, ascii=use_ascii))
2709
+ if not plain and _color_enabled():
2710
+ out = _colorize(out)
2711
+ print(out)
2712
+ return
2713
+
1444
2714
  if getattr(args, "json", False):
1445
2715
  # POLYMORPHIC by path: drill -> task_phases list; rollup -> report_data dict.
1446
2716
  payload = task_phases(root, drill_task) if drill_task \
@@ -1468,8 +2738,19 @@ def build_parser() -> argparse.ArgumentParser:
1468
2738
  pi.add_argument("--name", default=None, help="project name (default: dir name)")
1469
2739
  pi.add_argument("--stage", default="prototype", choices=STAGES)
1470
2740
  pi.add_argument("--force", action="store_true", help="reset state.json if present")
2741
+ pi.add_argument("--await-lock", dest="await_lock", action="store_true",
2742
+ help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
1471
2743
  pi.set_defaults(func=cmd_init)
1472
2744
 
2745
+ pl = sub.add_parser("lock",
2746
+ help="freeze the autonomous setup (the human baseline approval) and open the build")
2747
+ pl.add_argument("--by", default=None, help="who is locking (default: current OS user)")
2748
+ pl.add_argument("--layers", default=None,
2749
+ help="comma-separated lock layers (default: foundation,scope,contract)")
2750
+ pl.add_argument("--force", action="store_true", help="re-lock an already-locked project")
2751
+ pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
2752
+ pl.set_defaults(func=cmd_lock)
2753
+
1473
2754
  pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
1474
2755
  pn.add_argument("slug")
1475
2756
  pn.add_argument("--title", default=None)
@@ -1500,11 +2781,21 @@ def build_parser() -> argparse.ArgumentParser:
1500
2781
  psm.add_argument("milestone", help="milestone slug, or 'none' to detach")
1501
2782
  psm.set_defaults(func=cmd_set_milestone)
1502
2783
 
2784
+ pu = sub.add_parser("use", help="set the active task to an existing one (switch focus)")
2785
+ pu.add_argument("slug")
2786
+ pu.set_defaults(func=cmd_use)
2787
+
1503
2788
  pam = sub.add_parser("archive-milestone",
1504
2789
  help="collapse a done milestone out of active state (files stay on disk)")
1505
2790
  pam.add_argument("slug")
1506
2791
  pam.set_defaults(func=cmd_archive_milestone)
1507
2792
 
2793
+ pco = sub.add_parser("compact",
2794
+ help="heavy archive: move an archived milestone's files into "
2795
+ ".add/archive/<slug>/ (recoverable reverse move)")
2796
+ pco.add_argument("slug")
2797
+ pco.set_defaults(func=cmd_compact)
2798
+
1508
2799
  pp = sub.add_parser("phase", help="set a task's phase explicitly")
1509
2800
  pp.add_argument("phase", choices=PHASES)
1510
2801
  pp.add_argument("slug", nargs="?", default=None)
@@ -1522,8 +2813,18 @@ def build_parser() -> argparse.ArgumentParser:
1522
2813
  pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
1523
2814
  pg.set_defaults(func=cmd_gate)
1524
2815
 
2816
+ pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
2817
+ pr.add_argument("slug", nargs="?", default=None)
2818
+ # --to / --reason are validated in-body (not argparse choices) so the named reject
2819
+ # codes fire (reopen_target_invalid / reopen_reason_required), not a bare exit-2.
2820
+ pr.add_argument("--to", default=None, help="target phase (specify..observe)")
2821
+ pr.add_argument("--reason", default="", help="why the task is reopened (required, non-empty)")
2822
+ pr.set_defaults(func=cmd_reopen)
2823
+
1525
2824
  ps = sub.add_parser("stage", help="set the project stage")
1526
2825
  ps.add_argument("stage", choices=STAGES)
2826
+ ps.add_argument("--force", action="store_true",
2827
+ help="override the →production roadmap guard (stage_no_roadmap)")
1527
2828
  ps.set_defaults(func=cmd_stage)
1528
2829
 
1529
2830
  pst = sub.add_parser("status", help="print where the project is (resume point)")
@@ -1557,8 +2858,33 @@ def build_parser() -> argparse.ArgumentParser:
1557
2858
  "drill -> task_phases list of 7 phase dicts)")
1558
2859
  prp.add_argument("--plain", action="store_true",
1559
2860
  help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)")
2861
+ prp.add_argument("--decide", action="store_true",
2862
+ help="decision-point digest: what needs the human's judgment NOW "
2863
+ "(task -> decision digest; milestone -> DECIDE NEXT only; "
2864
+ "bare -> the active task)")
1560
2865
  prp.set_defaults(func=cmd_report)
1561
2866
 
2867
+ pdt = sub.add_parser("deltas",
2868
+ help="read-only report: open lessons learned grouped by competency")
2869
+ pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
2870
+ pdt.set_defaults(func=cmd_deltas)
2871
+
2872
+ pgr = sub.add_parser("graduation-report",
2873
+ help="read-only: gather the MVP loop's evidence (deltas · waivers · RETROs · "
2874
+ "residue · coverage gaps) for a graduation interview — gathers, never judges")
2875
+ pgr.add_argument("--json", action="store_true", help="emit the frozen JSON facts interface")
2876
+ pgr.add_argument("--plain", action="store_true", help="ASCII/pipe-safe text (output is plain by default)")
2877
+ pgr.set_defaults(func=cmd_graduation_report)
2878
+
2879
+ pau = sub.add_parser("audit",
2880
+ help="read-only: verify recorded human decision points left well-formed records "
2881
+ "(exit 1 on findings — the CI enforcement gate)")
2882
+ pau.add_argument("--json", action="store_true", help="machine-readable JSON output")
2883
+ pau.set_defaults(func=cmd_audit)
2884
+
2885
+ ppj = sub.add_parser("project", help="print .add/PROJECT.md (the read-first foundation)")
2886
+ ppj.set_defaults(func=cmd_project)
2887
+
1562
2888
  return p
1563
2889
 
1564
2890