@pilotspace/add 1.1.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 +40 -0
  2. package/GETTING-STARTED.md +165 -139
  3. package/README.md +13 -7
  4. package/bin/cli.js +13 -4
  5. package/docs/01-principles.md +3 -3
  6. package/docs/02-the-flow.md +15 -11
  7. package/docs/03-step-1-specify.md +13 -13
  8. package/docs/04-step-2-scenarios.md +2 -2
  9. package/docs/05-step-3-contract.md +3 -3
  10. package/docs/06-step-4-tests.md +2 -2
  11. package/docs/07-step-5-build.md +1 -1
  12. package/docs/08-step-6-verify.md +14 -5
  13. package/docs/09-the-loop.md +12 -6
  14. package/docs/10-setup-and-stages.md +27 -13
  15. package/docs/11-governance.md +2 -2
  16. package/docs/12-roles.md +3 -3
  17. package/docs/13-adoption.md +1 -1
  18. package/docs/14-foundation.md +15 -15
  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 +8 -8
  27. package/docs/appendix-g-references.md +106 -0
  28. package/package.json +1 -1
  29. package/skill/add/SKILL.md +39 -37
  30. package/skill/add/adopt.md +13 -11
  31. package/skill/add/deltas.md +8 -6
  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 +29 -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 +24 -11
  40. package/skill/add/phases/4-tests.md +15 -5
  41. package/skill/add/phases/5-build.md +11 -4
  42. package/skill/add/phases/6-verify.md +24 -2
  43. package/skill/add/phases/7-observe.md +13 -5
  44. package/skill/add/report-template.md +65 -7
  45. package/skill/add/run.md +45 -34
  46. package/skill/add/scope.md +10 -6
  47. package/skill/add/setup-review.md +13 -10
  48. package/skill/add/streams.md +69 -19
  49. package/tooling/add.py +476 -34
  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 +33 -12
package/tooling/add.py CHANGED
@@ -26,7 +26,14 @@ from pathlib import Path
26
26
  ROOT_DIRNAME = ".add"
27
27
  STATE_FILE = "state.json"
28
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)"
29
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"
30
37
  PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
31
38
  GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
32
39
 
@@ -38,7 +45,7 @@ def _phase_index(name: str) -> int:
38
45
  # `add.py guide` copy: per-phase (concrete next action, book chapter to read).
39
46
  # Keep the action wording aligned with each phase's EXIT line in the TASK template.
40
47
  PHASE_GUIDE = {
41
- "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",
42
49
  "03-step-1-specify.md"),
43
50
  "scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
44
51
  "04-step-2-scenarios.md"),
@@ -48,7 +55,7 @@ PHASE_GUIDE = {
48
55
  "06-step-4-tests.md"),
49
56
  "build": ("write the minimum code to pass the tests; change no test and no contract",
50
57
  "07-step-5-build.md"),
51
- "verify": ("run the suite + blind-spot checks, then record the gate",
58
+ "verify": ("run the suite + non-functional checks, then record the gate",
52
59
  "08-step-6-verify.md"),
53
60
  "observe": ("note what to watch + the spec delta for the next loop",
54
61
  "09-the-loop.md"),
@@ -85,8 +92,8 @@ Framings weighed:
85
92
  Must:
86
93
  Reject:
87
94
  After:
88
- Assumptions — least-sure first:
89
- ⚠ <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>
90
97
 
91
98
  ## 2 · SCENARIOS
92
99
  ## 3 · CONTRACT
@@ -244,7 +251,7 @@ def _guideline_block() -> str:
244
251
  " guide ends with its exit gate and the command to move on.\n"
245
252
  "\n"
246
253
  "The flow: INTAKE sizes a request into a milestone; each task runs the\n"
247
- "**one-approval front** — Spec+Scenarios+Contract+Tests as one bundle,\n"
254
+ "**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
248
255
  "ONE human approval at the frozen contract — then a self-driving build→verify\n"
249
256
  "run. Non-negotiable for every agent:\n"
250
257
  "Never weaken a test or edit a frozen contract to make a build pass; a security\n"
@@ -332,7 +339,7 @@ def _is_brownfield(base: Path) -> bool:
332
339
  """True when `base` already holds project content beyond the tool's own scaffolding.
333
340
 
334
341
  Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
335
- autonomous-onboarding flow knows to map existing code into the survivors. INTERPRETING
342
+ autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
336
343
  that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
337
344
  if not base.is_dir():
338
345
  return False
@@ -515,6 +522,20 @@ def cmd_advance(args: argparse.Namespace) -> None:
515
522
  # into build/verify/observe/done is refused until `add.py lock`.
516
523
  if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
517
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
518
539
  state["tasks"][slug]["phase"] = nxt
519
540
  state["tasks"][slug]["updated"] = _now()
520
541
  _sync_task_marker(root, slug, nxt)
@@ -569,7 +590,7 @@ def cmd_gate(args: argparse.Namespace) -> None:
569
590
  hdr = _task_header(root, slug)
570
591
  if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
571
592
  _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
572
- "without autonomy: conservative — lower the dial in the TASK.md "
593
+ "without autonomy: conservative — lower the autonomy level in the TASK.md "
573
594
  "header; a human must own a high-risk gate (run.md guard)")
574
595
  if args.outcome == "RISK-ACCEPTED":
575
596
  # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
@@ -592,10 +613,49 @@ def cmd_gate(args: argparse.Namespace) -> None:
592
613
  print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
593
614
 
594
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
+
595
655
  def cmd_lock(args: argparse.Namespace) -> None:
596
- """The human lock-down: freeze the autonomously-drafted setup in ONE atomic write.
656
+ """The human baseline approval: freeze the autonomously-drafted setup in ONE atomic write.
597
657
 
598
- Setup-altitude analog of the contract freeze — the only new human action onboarding
658
+ Setup-level analog of the contract freeze — the only new human action onboarding
599
659
  needs. `add.py lock` is judgment-free (it records the signature; it does NOT inspect
600
660
  the artifacts): the human's signature IS the gate."""
601
661
  root = _require_root()
@@ -622,19 +682,42 @@ def cmd_lock(args: argparse.Namespace) -> None:
622
682
  print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
623
683
 
624
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
+
625
694
  def cmd_stage(args: argparse.Namespace) -> None:
626
695
  root = _require_root()
627
696
  state = load_state(root)
628
697
  if args.stage not in STAGES:
629
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
630
711
  state["stage"] = args.stage
631
712
  save_state(root, state)
632
713
  print(f"project stage -> {args.stage}")
714
+ if bypassing:
715
+ print("(--force: bypassed roadmap check — no production milestone drafted)")
633
716
 
634
717
 
635
718
  def cmd_status(args: argparse.Namespace) -> None:
636
719
  if getattr(args, "json", False):
637
- _, state = _load_state_for_json()
720
+ root, state = _load_state_for_json()
638
721
  tasks = state.get("tasks") or {}
639
722
  milestones = state.get("milestones") or {}
640
723
  ms_list = []
@@ -643,12 +726,15 @@ def cmd_status(args: argparse.Namespace) -> None:
643
726
  ms_list.append({"slug": mslug, "status": m.get("status", "active"),
644
727
  "done": sum(1 for t in members if _task_done(t)),
645
728
  "total": len(members)})
729
+ grad_ready, grad_met, grad_total = _graduation_ready(root, state)
646
730
  print(json.dumps({
647
731
  "project": state.get("project"), "stage": state.get("stage"),
648
732
  "active_task": state.get("active_task"),
649
733
  "milestones": ms_list,
650
734
  "tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
651
- "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}}))
652
738
  return
653
739
  root = _require_root()
654
740
  state = load_state(root)
@@ -659,9 +745,24 @@ def cmd_status(args: argparse.Namespace) -> None:
659
745
  unlocked = not _setup_locked(state)
660
746
  print(f"project : {state.get('project', '(unknown)')}")
661
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})")
662
755
  # foundation pointer — read the cross-milestone context first (anti-rot)
663
756
  if (root / "PROJECT.md").exists():
664
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)")
665
766
 
666
767
  # milestone rollup (only when milestones are in use)
667
768
  milestones = state.get("milestones") or {}
@@ -674,6 +775,12 @@ def cmd_status(args: argparse.Namespace) -> None:
674
775
  mark = "*" if mslug == active_ms else " "
675
776
  print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
676
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}")
677
784
 
678
785
  # archived rollup — one line keeps state visible without re-bloating status
679
786
  archived = state.get("archived") or []
@@ -691,7 +798,7 @@ def cmd_status(args: argparse.Namespace) -> None:
691
798
  print("tasks : (none yet)")
692
799
  print()
693
800
  if unlocked:
694
- print("setup : UNLOCKED — review .add/SETUP-REVIEW.md (least-sure first),"
801
+ print("setup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first),"
695
802
  " then sign: add.py lock")
696
803
  print(" (the build-boundary gate is closed until the foundation is locked)")
697
804
  else:
@@ -710,11 +817,11 @@ def cmd_status(args: argparse.Namespace) -> None:
710
817
  # silently outrun the human fold (read-only; v11). Silent when none are open.
711
818
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
712
819
  if open_deltas:
713
- print(f"deltas : {open_deltas} open — fold at milestone close (add.py deltas)")
820
+ print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)")
714
821
  # When the setup is unlocked, the only terminal guidance that matters is
715
822
  # review+lock; suppress the generic resume block so it does not compete.
716
823
  if unlocked:
717
- print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (least-sure first),"
824
+ print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first),"
718
825
  " then sign: add.py lock")
719
826
  print(" (the build-boundary gate is closed until the foundation is locked)")
720
827
  elif active and active in tasks:
@@ -791,6 +898,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
791
898
  _die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
792
899
  action, chapter = entry
793
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
794
902
  print(f"next : {action}")
795
903
  print(f"read : .add/docs/{chapter}")
796
904
  gp = _phase_guide_path(root.parent, phase)
@@ -1001,6 +1109,17 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
1001
1109
  t = members[s]
1002
1110
  print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
1003
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)")
1004
1123
  # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
1005
1124
  # status flip, so a write failure rolls back naturally (status never commits ->
1006
1125
  # no done-without-retro state). The retro step is read-only on state.json.
@@ -1020,7 +1139,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
1020
1139
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1021
1140
  if open_deltas:
1022
1141
  noun = "delta" if open_deltas == 1 else "deltas"
1023
- print(f"note: {open_deltas} open competency {noun} to fold into the foundation "
1142
+ print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
1024
1143
  f"— review with: add.py deltas")
1025
1144
 
1026
1145
 
@@ -1076,6 +1195,70 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
1076
1195
  print("files on disk are untouched; see `add.py status` for the archived rollup.")
1077
1196
 
1078
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
+
1079
1262
  def cmd_set_milestone(args: argparse.Namespace) -> None:
1080
1263
  root = _require_root()
1081
1264
  state = load_state(root)
@@ -1236,6 +1419,21 @@ def _colorize(s: str) -> str:
1236
1419
  return s
1237
1420
 
1238
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
+
1239
1437
  def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
1240
1438
  """(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
1241
1439
  f = root / "milestones" / mslug / MILESTONE_FILE
@@ -1265,6 +1463,41 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
1265
1463
  return met, total
1266
1464
 
1267
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
+
1268
1501
  def _count_test_defs(f: Path) -> int:
1269
1502
  """`def test_` occurrences in one file — the ONE counting regex (primary and
1270
1503
  §4-declared fallback share it by construction). OSError -> 0, fail-closed."""
@@ -1748,8 +1981,39 @@ def _contract_frozen(raw3: str) -> bool:
1748
1981
  return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
1749
1982
 
1750
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
+
1751
2015
  def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
1752
- """FACTS for the task-level decision-seam digest (frozen shape). The seam comes
2016
+ """FACTS for the task-level decision-point digest (frozen shape). The decision comes
1753
2017
  from STATE ONLY: recorded (gate set / observe / done) · front (specify→tests) ·
1754
2018
  gate (build/verify). judgment = extracted markers, byte-verbatim. PURE."""
1755
2019
  tasks = state.get("tasks") or {}
@@ -1786,7 +2050,7 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
1786
2050
  decide = "approve -> freeze §3 (Status: FROZEN @ v1) -> auto run"
1787
2051
  elif seam == "front":
1788
2052
  unlocks = "none"
1789
- decide = "no decision pending — frozen; the run owns it. next seam: verify gate"
2053
+ decide = "no decision pending — frozen; the run owns it. next decision point: verify gate"
1790
2054
  else:
1791
2055
  unlocks = "none"
1792
2056
  decide = f"no decision pending — recorded gate: {gate}"
@@ -1797,7 +2061,7 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
1797
2061
 
1798
2062
  def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
1799
2063
  width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
1800
- """Text view of the decision-seam digest — decisive facts FIRST: NEEDS YOUR
2064
+ """Text view of the decision-point digest — decisive facts FIRST: NEEDS YOUR
1801
2065
  JUDGMENT (markers byte-verbatim, section-tagged) -> [front: §3 verbatim] ->
1802
2066
  ENGINE FACTS -> UNLOCKS -> DECIDE. PURE — no writes; plain text (color is a
1803
2067
  tty-only skin in cmd_report, like every report view)."""
@@ -1806,7 +2070,7 @@ def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
1806
2070
  banner = g["h"] * width
1807
2071
  seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
1808
2072
  "recorded": "RECORDED"}[d["seam"]]
1809
- L = [banner, f" DECIDE · {mslug or '—'} · {slug} · seam: {seam_label}", banner]
2073
+ L = [banner, f" DECIDE · {mslug or '—'} · {slug} · decision point: {seam_label}", banner]
1810
2074
  if d["decide"].startswith("no decision pending"):
1811
2075
  L.append(f" {d['decide']}")
1812
2076
  L.append(f" GATE {d['gate']}")
@@ -1853,8 +2117,8 @@ def _planned_unscaffolded(root: Path, mslug: str) -> list[str]:
1853
2117
 
1854
2118
 
1855
2119
  def _decide_next(state: dict, d: dict) -> str:
1856
- """The rollup's DECIDE NEXT line (frozen precedence): HARD-STOP -> fold+archive
1857
- -> first seam-blocked task (ACTIVE task first, then state order) -> run-in-
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-
1858
2122
  progress. v2: when d carries planned_unscaffolded, the line gains a
1859
2123
  plan-vs-state suffix — precedence itself stays state-only."""
1860
2124
  return _decide_next_base(state, d) + _planned_hint(d)
@@ -1880,7 +2144,15 @@ def _decide_next_base(state: dict, d: dict) -> str:
1880
2144
  return f"resolve HARD-STOP on {stopped[0]['slug']}"
1881
2145
  s = d["summary"]
1882
2146
  if s["tasks_done"] == s["tasks_total"]:
1883
- return f"fold learnings + archive-milestone {ms}"
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}"
1884
2156
  active = state.get("active_task")
1885
2157
  order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
1886
2158
  for r in order:
@@ -2037,7 +2309,7 @@ def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
2037
2309
 
2038
2310
 
2039
2311
  def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
2040
- """Scan every .add/tasks/*/TASK.md for open competency deltas.
2312
+ """Scan every .add/tasks/*/TASK.md for open lessons learned.
2041
2313
 
2042
2314
  Returns a dict keyed by competency in canonical order; each value is a list
2043
2315
  of {task, text, evidence} dicts. READ-ONLY — never mutates any file."""
@@ -2099,7 +2371,7 @@ _AUDIT_REVIEWED_RE = re.compile(r"^Reviewed by:(.*)$", re.M)
2099
2371
 
2100
2372
 
2101
2373
  def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
2102
- """The gate-audit core: verify that human seams left WELL-FORMED records.
2374
+ """The gate-audit core: verify that human decision points left WELL-FORMED records.
2103
2375
  Judgment-free — checks record SHAPE (a named human at the freeze, exactly one
2104
2376
  gate outcome, prose ≡ state, a marked security note never auto-reviewed),
2105
2377
  never re-decides an outcome. Scope: active tasks done/observe or gated; open
@@ -2122,6 +2394,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
2122
2394
  if not _AUDIT_STAMP_RE.search(s3):
2123
2395
  f(slug, "unstamped_freeze",
2124
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")
2125
2406
  outcomes = _AUDIT_OUTCOME_RE.findall(s6)
2126
2407
  if len(outcomes) != 1:
2127
2408
  f(slug, "malformed_gate_record",
@@ -2158,8 +2439,8 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
2158
2439
 
2159
2440
 
2160
2441
  def cmd_audit(args: argparse.Namespace) -> None:
2161
- """Read-only: audit recorded human seams for well-formedness. Exit 0 clean,
2162
- exit 1 with findings — the enforcement seam CI consumes (audit-ci). Writes
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
2163
2444
  NOTHING; every other command is byte-identical."""
2164
2445
  root = _require_root()
2165
2446
  checked, findings = _audit_findings(root, load_state(root))
@@ -2176,8 +2457,146 @@ def cmd_audit(args: argparse.Namespace) -> None:
2176
2457
  sys.exit(1)
2177
2458
 
2178
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
+
2179
2598
  def cmd_deltas(args: argparse.Namespace) -> None:
2180
- """Read-only: report all open competency deltas grouped by competency.
2599
+ """Read-only: report all open lessons learned grouped by competency.
2181
2600
 
2182
2601
  Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
2183
2602
  matching the delta grammar; shows only `open` entries in canonical competency
@@ -2199,7 +2618,7 @@ def cmd_deltas(args: argparse.Namespace) -> None:
2199
2618
  print("no open deltas.")
2200
2619
  return
2201
2620
 
2202
- print(f"open competency deltas ({total} total):")
2621
+ print(f"open lessons learned ({total} total):")
2203
2622
  for comp in _COMPETENCY_ORDER:
2204
2623
  entries = by_comp[comp]
2205
2624
  if not entries:
@@ -2324,7 +2743,7 @@ def build_parser() -> argparse.ArgumentParser:
2324
2743
  pi.set_defaults(func=cmd_init)
2325
2744
 
2326
2745
  pl = sub.add_parser("lock",
2327
- help="freeze the autonomous setup (the human lock-down) and open the build")
2746
+ help="freeze the autonomous setup (the human baseline approval) and open the build")
2328
2747
  pl.add_argument("--by", default=None, help="who is locking (default: current OS user)")
2329
2748
  pl.add_argument("--layers", default=None,
2330
2749
  help="comma-separated lock layers (default: foundation,scope,contract)")
@@ -2371,6 +2790,12 @@ def build_parser() -> argparse.ArgumentParser:
2371
2790
  pam.add_argument("slug")
2372
2791
  pam.set_defaults(func=cmd_archive_milestone)
2373
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
+
2374
2799
  pp = sub.add_parser("phase", help="set a task's phase explicitly")
2375
2800
  pp.add_argument("phase", choices=PHASES)
2376
2801
  pp.add_argument("slug", nargs="?", default=None)
@@ -2388,8 +2813,18 @@ def build_parser() -> argparse.ArgumentParser:
2388
2813
  pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
2389
2814
  pg.set_defaults(func=cmd_gate)
2390
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
+
2391
2824
  ps = sub.add_parser("stage", help="set the project stage")
2392
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)")
2393
2828
  ps.set_defaults(func=cmd_stage)
2394
2829
 
2395
2830
  pst = sub.add_parser("status", help="print where the project is (resume point)")
@@ -2424,19 +2859,26 @@ def build_parser() -> argparse.ArgumentParser:
2424
2859
  prp.add_argument("--plain", action="store_true",
2425
2860
  help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)")
2426
2861
  prp.add_argument("--decide", action="store_true",
2427
- help="decision-seam digest: what needs the human's judgment NOW "
2428
- "(task -> seam digest; milestone -> DECIDE NEXT only; "
2862
+ help="decision-point digest: what needs the human's judgment NOW "
2863
+ "(task -> decision digest; milestone -> DECIDE NEXT only; "
2429
2864
  "bare -> the active task)")
2430
2865
  prp.set_defaults(func=cmd_report)
2431
2866
 
2432
2867
  pdt = sub.add_parser("deltas",
2433
- help="read-only report: open competency deltas grouped by competency")
2868
+ help="read-only report: open lessons learned grouped by competency")
2434
2869
  pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
2435
2870
  pdt.set_defaults(func=cmd_deltas)
2436
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
+
2437
2879
  pau = sub.add_parser("audit",
2438
- help="read-only: verify human seams left well-formed records "
2439
- "(exit 1 on findings — the CI enforcement seam)")
2880
+ help="read-only: verify recorded human decision points left well-formed records "
2881
+ "(exit 1 on findings — the CI enforcement gate)")
2440
2882
  pau.add_argument("--json", action="store_true", help="machine-readable JSON output")
2441
2883
  pau.set_defaults(func=cmd_audit)
2442
2884