@pilotspace/add 1.2.0 → 1.4.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 (43) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/GETTING-STARTED.md +26 -0
  3. package/README.md +16 -3
  4. package/bin/cli.js +85 -8
  5. package/docs/02-the-flow.md +4 -1
  6. package/docs/03-step-1-specify.md +2 -0
  7. package/docs/06-step-4-tests.md +8 -0
  8. package/docs/07-step-5-build.md +2 -0
  9. package/docs/08-step-6-verify.md +11 -0
  10. package/docs/09-the-loop.md +3 -1
  11. package/docs/10-setup-and-stages.md +1 -1
  12. package/docs/11-governance.md +4 -0
  13. package/docs/appendix-c-glossary.md +16 -1
  14. package/docs/appendix-e-checklists.md +14 -2
  15. package/package.json +1 -1
  16. package/skill/add/SKILL.md +18 -5
  17. package/skill/add/advisor.md +75 -0
  18. package/skill/add/compact-foundation.md +53 -0
  19. package/skill/add/confidence.md +48 -0
  20. package/skill/add/fold.md +4 -4
  21. package/skill/add/phases/0-ground.md +68 -0
  22. package/skill/add/phases/0-setup.md +52 -3
  23. package/skill/add/phases/1-specify.md +7 -0
  24. package/skill/add/phases/2-scenarios.md +2 -0
  25. package/skill/add/phases/3-contract.md +5 -1
  26. package/skill/add/phases/4-tests.md +2 -0
  27. package/skill/add/phases/5-build.md +24 -0
  28. package/skill/add/phases/6-verify.md +18 -0
  29. package/skill/add/phases/7-observe.md +7 -0
  30. package/skill/add/run.md +48 -5
  31. package/skill/add/soul.md +77 -0
  32. package/skill/add/streams.md +65 -9
  33. package/tooling/add.py +1715 -64
  34. package/tooling/templates/DESIGN.md.tmpl +66 -0
  35. package/tooling/templates/GLOSSARY.md.tmpl +11 -1
  36. package/tooling/templates/PROJECT.md.tmpl +3 -1
  37. package/tooling/templates/SOUL.md.tmpl +40 -0
  38. package/tooling/templates/TASK.md.tmpl +23 -4
  39. package/tooling/templates/catalog.sample.json +38 -0
  40. package/tooling/templates/prototype.sample.json +48 -0
  41. package/tooling/templates/tokens.sample.json +55 -0
  42. package/tooling/templates/udd-catalog.md +122 -0
  43. package/tooling/templates/udd-tokens.md +79 -0
package/tooling/add.py CHANGED
@@ -13,12 +13,14 @@ from __future__ import annotations
13
13
 
14
14
  import argparse
15
15
  import getpass
16
+ import hashlib
16
17
  import json
17
18
  import os
18
19
  import re
19
20
  import sys
20
21
  import tempfile
21
- from datetime import date, datetime, timezone
22
+ import urllib.request
23
+ from datetime import date, datetime, timedelta, timezone
22
24
  from pathlib import Path
23
25
 
24
26
  # --- constants ---------------------------------------------------------------
@@ -34,8 +36,14 @@ STAGES = ("prototype", "poc", "mvp", "production")
34
36
  # v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
35
37
  # Worded as the ACTION (never a file) so it stands before graduate.md exists.
36
38
  GRADUATION_CUE = "MVP covered → propose graduation"
37
- PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
39
+ PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
38
40
  GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
41
+ # heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
42
+ # (mechanical tripwire divergence, or an agent-reported semantic refute-read finding) returns
43
+ # the task to BUILD for an honest redo; after HEAL_CAP such attempts the next confirmed cheat
44
+ # forces a HARD-STOP escalation to the human. MONOTONIC — attempts never auto-resets (a gamed
45
+ # green is never auto-passed; the loop is never unbounded).
46
+ HEAL_CAP = 3
39
47
 
40
48
 
41
49
  def _phase_index(name: str) -> int:
@@ -45,6 +53,8 @@ def _phase_index(name: str) -> int:
45
53
  # `add.py guide` copy: per-phase (concrete next action, book chapter to read).
46
54
  # Keep the action wording aligned with each phase's EXIT line in the TASK template.
47
55
  PHASE_GUIDE = {
56
+ "ground": ("gather the real codebase the task touches — files, symbols, signatures, conventions, and the anchor points the contract will cite; defer to PROJECT.md/CONVENTIONS.md and gather only the task delta",
57
+ "02-the-flow.md"),
48
58
  "specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
49
59
  "03-step-1-specify.md"),
50
60
  "scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
@@ -67,10 +77,27 @@ PHASE_GUIDE = {
67
77
  # follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
68
78
  # are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
69
79
  PHASE_OWNER = {
80
+ "ground": "ai",
70
81
  "specify": "human", "scenarios": "human", "contract": "seam",
71
82
  "tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
72
83
  }
73
- SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist")
84
+ SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
85
+
86
+ # Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
87
+ # never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
88
+ # milestones/, archive/). These are working state, not records: scope-snapshot.json
89
+ # is the tests->build touch baseline the verify scope-gate reads from disk (the
90
+ # durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
91
+ # archive-milestone's pre-delete recovery net — needed on disk, never in history;
92
+ # .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
93
+ # disk; git-ignoring them is hygiene, never deletion.
94
+ _GITIGNORE_BODY = """\
95
+ # ADD engine transient artifacts — local working state, never committed.
96
+ # (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
97
+ scope-snapshot.json
98
+ pre-archive-state.bak.json
99
+ .update-cache.json
100
+ """
74
101
 
75
102
  # Guideline-injection targets + version-stable markers. NEVER change these marker
76
103
  # strings: a re-run finds the old block by exact match, so changing them would
@@ -84,7 +111,13 @@ _GUIDE_END = "<!-- ADD:END -->"
84
111
  _FALLBACK_TASK = """# TASK: {title}
85
112
 
86
113
  slug: {slug} · created: {date} · stage: {stage}
87
- phase: specify
114
+ autonomy: auto
115
+ phase: ground
116
+
117
+ ## 0 · GROUND
118
+ Touches (files · symbols · signatures):
119
+ Honors (patterns / conventions):
120
+ Anchors the contract cites:
88
121
 
89
122
  ## 1 · SPECIFY
90
123
  Feature:
@@ -354,6 +387,13 @@ def cmd_init(args: argparse.Namespace) -> None:
354
387
  _die(f"already initialised at {root} (use --force to reset state)")
355
388
 
356
389
  (root / "tasks").mkdir(parents=True, exist_ok=True)
390
+ # Keep the engine's transient local artifacts out of git. Never-clobber: a
391
+ # human may have customised .add/.gitignore, so an existing one is left as-is
392
+ # (mirrors the SETUP_FILES skip-not-clobber idiom). Writes ONLY this file — no
393
+ # scope-snapshot.json or .bak is created, deleted, or modified.
394
+ gitignore = root / ".gitignore"
395
+ if not gitignore.exists():
396
+ _atomic_write(gitignore, _GITIGNORE_BODY)
357
397
  today = date.today().isoformat()
358
398
  proj_name = args.name or base.name
359
399
 
@@ -431,12 +471,20 @@ def cmd_new_task(args: argparse.Namespace) -> None:
431
471
  (tdir / "tests").mkdir(parents=True, exist_ok=True)
432
472
  (tdir / "src").mkdir(parents=True, exist_ok=True)
433
473
  title = args.title or slug.replace("-", " ").replace("_", " ").title()
474
+ # inherit the project's DECLARED autonomy default (task init-auto-default) — fail-SAFE:
475
+ # absent -> auto, garbled -> conservative; the posture is project-scoped, not hardcoded.
476
+ autonomy = _project_autonomy(root)
434
477
  _atomic_write(task_md, _render_template(
435
- "TASK.md", title=title, slug=slug, date=date.today().isoformat(), stage=state["stage"]))
478
+ "TASK.md", title=title, slug=slug, date=date.today().isoformat(),
479
+ stage=state["stage"], autonomy=autonomy))
480
+ if _project_autonomy_token(root) == "?":
481
+ print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
482
+ f"autonomy token; new task seeded fail-safe '{autonomy}' "
483
+ "(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
436
484
 
437
485
  state["tasks"][slug] = {
438
486
  "title": title,
439
- "phase": "specify",
487
+ "phase": "ground",
440
488
  "gate": "none",
441
489
  "milestone": milestone,
442
490
  "depends_on": depends_on,
@@ -454,7 +502,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
454
502
  # intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
455
503
  print(f"note: '{slug}' is not attached to a milestone — size it via /add (intake), "
456
504
  "or pass --milestone <id>")
457
- print("active task set. phase: specify. Fill section 1 (SPECIFY), then: add.py advance")
505
+ print("active task set. phase: ground. Gather the real codebase (section 0 GROUND).")
506
+ print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
458
507
 
459
508
 
460
509
  def _parse_deps(raw: str | None) -> list[str]:
@@ -507,6 +556,7 @@ def cmd_phase(args: argparse.Namespace) -> None:
507
556
  _sync_task_marker(root, slug, args.phase)
508
557
  save_state(root, state)
509
558
  print(f"task '{slug}' phase -> {args.phase}")
559
+ print(_next_footer(root, state))
510
560
 
511
561
 
512
562
  def cmd_advance(args: argparse.Namespace) -> None:
@@ -536,21 +586,80 @@ def cmd_advance(args: argparse.Namespace) -> None:
536
586
  "+ substantive content; bare 'none' only as 'none material — "
537
587
  "biggest risk: X') before crossing into build")
538
588
  state["tasks"][slug]["flag_verified"] = True
589
+ # tamper tripwire (verify-integrity): snapshot the red test files + the frozen
590
+ # §3 md5s so the verify gate can prove the green was EARNED, not edited into
591
+ # place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
592
+ # tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
593
+ state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
594
+ # §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
595
+ # the project tree into a sidecar (payload) + a state.json anchor (md5 of the
596
+ # sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
597
+ # UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
598
+ # — and CLEANS UP a previous declaration's leftovers (v3): a declared->
599
+ # undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
600
+ # "UNDECLARED is never refused" holds on every path.
601
+ declared = _declared_scope(root, slug)
602
+ side = root / "tasks" / slug / "scope-snapshot.json"
603
+ if declared is not None:
604
+ payload = json.dumps({"version": 1,
605
+ "files": _scope_walk(root.parent.resolve())},
606
+ sort_keys=True)
607
+ side.write_text(payload, encoding="utf-8")
608
+ state["tasks"][slug]["scope"] = {"declared": declared,
609
+ "snapshot_md5": _md5_text(payload)}
610
+ else:
611
+ state["tasks"][slug].pop("scope", None)
612
+ try:
613
+ side.unlink()
614
+ except OSError:
615
+ pass
539
616
  state["tasks"][slug]["phase"] = nxt
540
617
  state["tasks"][slug]["updated"] = _now()
541
618
  _sync_task_marker(root, slug, nxt)
542
619
  save_state(root, state)
543
620
  print(f"task '{slug}' phase {cur} -> {nxt}")
621
+ print(_next_footer(root, state))
622
+
623
+
624
+ # The mechanized high-risk guard (run.md, v14; widened by explicit-autonomy-dial):
625
+ # judging WHAT is high-risk stays human — a scope declares `risk: high` in its TASK.md
626
+ # header at the freeze. The engine then enforces the pure token contradiction: risk: high
627
+ # WITHOUT a lowered autonomy rung (manual or conservative) is unguarded, and completion is
628
+ # refused. Tokens are read from the header region (text before the first section heading)
629
+ # with HTML comments stripped — a documentation comment is never a declaration. A token
630
+ # counts ONLY at a DECLARATION position — line-start (optionally indented) or just after the
631
+ # `·` slug-line separator — so a freeform H1 title or quoted prose that happens to contain
632
+ # "risk: high" / "autonomy: <x>" is never mistaken for a declaration (a title substring must
633
+ # not be able to fool the guard either way).
634
+ _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
635
+
636
+ # the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
637
+ # manual < conservative < auto, declared as a per-task `autonomy:` header token.
638
+ _AUTONOMY_LEVELS = ("manual", "conservative", "auto")
639
+ # anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
640
+ # `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
641
+ # value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
642
+ # and reads as UNSET.
643
+ _AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
644
+
645
+
646
+ def _autonomy_level(hdr: str):
647
+ """The declared autonomy rung from a TASK.md header region (HTML comments
648
+ already stripped by _task_header). Returns a member of _AUTONOMY_LEVELS, or
649
+ None when no `autonomy:` line is present (UNSET — an unfilled `<…>` placeholder,
650
+ whose value the regex declines, counts as unset), or "?" when a REAL token outside
651
+ the set was written (unknown). PURE."""
652
+ m = _AUTONOMY_LINE_RE.search(hdr)
653
+ if not m:
654
+ return None
655
+ tok = m.group(1).strip().lower()
656
+ return tok if tok in _AUTONOMY_LEVELS else "?"
544
657
 
545
658
 
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")
659
+ def _autonomy_lowered(hdr: str) -> bool:
660
+ """True iff the declared rung is high-risk-safe (manual or conservative). A
661
+ high-risk scope must be lowered to one of these; `auto` and UNSET are not."""
662
+ return _autonomy_level(hdr) in ("manual", "conservative")
554
663
 
555
664
 
556
665
  def _task_header(root: Path, slug: str) -> str:
@@ -563,6 +672,37 @@ def _task_header(root: Path, slug: str) -> str:
563
672
  return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
564
673
 
565
674
 
675
+ def _effective_autonomy(root: Path, state: dict, slug: str) -> str:
676
+ """The autonomy rung that governs `slug` right now: the task's own declared rung,
677
+ falling back to the project default when the task line is UNSET (None) or an
678
+ unrecognized token ("?") — the same fail-safe chain cmd_new_task seeds from
679
+ (_project_autonomy: absent -> auto, garbled -> conservative). PURE. `state` is unused
680
+ today; it is kept in the signature beside _driver_stop for symmetry."""
681
+ lvl = _autonomy_level(_task_header(root, slug))
682
+ return lvl if lvl in _AUTONOMY_LEVELS else _project_autonomy(root)
683
+
684
+
685
+ def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
686
+ """True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
687
+ SINGLE source the footer marker and the guide TEXT marker both render (task
688
+ gate-owner-marker). Refines _phase_owner with the autonomy level at exactly ONE phase,
689
+ verify:
690
+ verify -> the human gates UNLESS the run may auto-gate (effective autonomy == auto)
691
+ else -> the structural owner stops (owner != "ai"), independent of the level
692
+ The frozen machine-state-json JSON `stop` keeps its own structural value (Option F);
693
+ this resolver feeds ONLY the human-facing footer + guide TEXT. _phase_owner still
694
+ _die("unmapped_phase") on a bad phase — the marker invents no default."""
695
+ if phase == "verify":
696
+ return _effective_autonomy(root, state, slug) != "auto"
697
+ return _phase_owner(phase) != "ai"
698
+
699
+
700
+ def _driver_marker(stop: bool) -> str:
701
+ """Render _driver_stop as the reserved-slot word (one leading space each) — the exact
702
+ strings next-footer-engine reserved: ` [human gate]` (a human owns it) / ` [you drive]`."""
703
+ return " [human gate]" if stop else " [you drive]"
704
+
705
+
566
706
  def cmd_gate(args: argparse.Namespace) -> None:
567
707
  root = _require_root()
568
708
  state = load_state(root)
@@ -588,10 +728,18 @@ def cmd_gate(args: argparse.Namespace) -> None:
588
728
  # COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
589
729
  # owns the gate. HARD-STOP is never blocked — stopping is always allowed.
590
730
  hdr = _task_header(root, slug)
591
- if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
731
+ if _RISK_HIGH_RE.search(hdr) and not _autonomy_lowered(hdr):
592
732
  _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
593
- "without autonomy: conservativelower the autonomy level in the TASK.md "
594
- "header; a human must own a high-risk gate (run.md guard)")
733
+ "without a lowered autonomy levelrun `add.py autonomy set conservative` "
734
+ "(or manual); a human must own a high-risk gate (run.md guard)")
735
+ # tamper tripwire (verify-integrity): the method's first mechanical cheat
736
+ # block. A completing outcome is refused if the red suite or the frozen §3
737
+ # changed since the tests->build snapshot. Placed BEFORE the waiver write so
738
+ # a tamper finding is never launderable through RISK-ACCEPTED.
739
+ _tamper_guard(root, state, slug)
740
+ # §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
741
+ # same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
742
+ _scope_guard(root, state, slug)
595
743
  if args.outcome == "RISK-ACCEPTED":
596
744
  # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
597
745
  # so a later `check` can read/expire it. Refuse a partial waiver outright.
@@ -609,8 +757,83 @@ def cmd_gate(args: argparse.Namespace) -> None:
609
757
  state["tasks"][slug]["updated"] = _now()
610
758
  save_state(root, state)
611
759
  print(f"task '{slug}' gate -> {args.outcome}")
612
- if args.outcome == "HARD-STOP":
613
- print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
760
+ # the engine-sourced next step (next-footer-engine): a completing gate hands off to the
761
+ # state arm; HARD-STOP routes to "resolve HARD-STOP …" converging the old bespoke line.
762
+ print(_next_footer(root, state))
763
+
764
+
765
+ # the autonomy level as a first-class verb (task autonomy-command): autonomy was the ONLY mutable,
766
+ # security-relevant task/project token WITHOUT a CLI verb — so an agent under `auto`, applying the
767
+ # correct "first-class state has a command" model, hallucinated `add.py autonomy` and derailed.
768
+ # `show` reads the resolved level; `set` is the FIRST writer of the header token — idempotent (one
769
+ # declaration line, trailing comment preserved, NEVER appended), with the raise + risk:high guards
770
+ # enforced BEFORE the write. state.json is untouched — autonomy stays a header token.
771
+ _AUTONOMY_ORDER = {lvl: i for i, lvl in enumerate(_AUTONOMY_LEVELS)} # manual(0) < conservative(1) < auto(2)
772
+
773
+
774
+ def _autonomy_decl_line(text: str, level: str) -> str:
775
+ """Rewrite the SINGLE `autonomy:` declaration line to `level`, PRESERVING its trailing comment,
776
+ idempotently (replace in place, count=1 — never a second line). If absent, insert it: after the
777
+ `slug:` line for a task header, else after a leading `#` heading (PROJECT.md), else prepend. PURE
778
+ on the text; the caller does the atomic write."""
779
+ pat = re.compile(r"(?m)^(autonomy:[ \t]*)[^\s<#|]+(.*)$")
780
+ if pat.search(text):
781
+ return pat.sub(lambda m: f"{m.group(1)}{level}{m.group(2)}", text, count=1)
782
+ if re.search(r"(?m)^slug:", text):
783
+ return re.sub(r"(?m)^(slug:.*)$", r"\1\nautonomy: " + level, text, count=1)
784
+ lines = text.splitlines(keepends=True)
785
+ if lines and lines[0].lstrip().startswith("#"):
786
+ return lines[0] + f"autonomy: {level}\n" + "".join(lines[1:])
787
+ return f"autonomy: {level}\n" + text
788
+
789
+
790
+ def _guard_autonomy_raise(current: str, target: str, yes: bool) -> None:
791
+ """RAISING the level toward `auto` is a human-owned trust escalation (run.md: the AI may LOWER
792
+ freely — RECOMMEND-only — but RAISING needs a human). Refuse a raise unless --yes confirms it."""
793
+ if _AUTONOMY_ORDER.get(target, -1) > _AUTONOMY_ORDER.get(current, -1) and not yes:
794
+ _die(f"autonomy_raise_unconfirmed: raising autonomy {current} -> {target} is a human-owned "
795
+ "trust escalation (the AI may LOWER freely; RAISING needs a human) — pass --yes to confirm")
796
+
797
+
798
+ def _print_autonomy(root: Path, state: dict, slug: str) -> None:
799
+ """The read-only level view: declared · effective (fallback-resolved) · project default · the
800
+ verify-gate owner under it (the SAME _driver_stop the footer/guide render). Writes nothing."""
801
+ declared = _autonomy_level(_task_header(root, slug))
802
+ stop = _driver_stop(root, state, slug, "verify")
803
+ print(f"task : {slug}")
804
+ print(f"declared : {declared if declared in _AUTONOMY_LEVELS else 'unset'}")
805
+ print(f"effective : {_effective_autonomy(root, state, slug)}")
806
+ print(f"project : {_project_autonomy(root)}")
807
+ print(f"verify gate : {'human gate' if stop else 'you drive'}")
808
+
809
+
810
+ def cmd_autonomy(args: argparse.Namespace) -> None:
811
+ """show / set the autonomy level — the verify-gate owner (task autonomy-command)."""
812
+ root = _require_root() # reused -> "no .add/ project found …"
813
+ state = load_state(root)
814
+ if (getattr(args, "action", None) or "show") == "show":
815
+ _print_autonomy(root, state, _resolve_task(state, args.a1)) # reused -> "unknown task '<slug>'"
816
+ return
817
+ # action == "set"
818
+ level = args.a1
819
+ if level not in _AUTONOMY_LEVELS:
820
+ _die("autonomy_level_invalid: level must be one of "
821
+ f"{', '.join(_AUTONOMY_LEVELS)} (got {level!r})")
822
+ if getattr(args, "project", False):
823
+ target = root / "PROJECT.md"
824
+ _guard_autonomy_raise(_project_autonomy(root), level, getattr(args, "yes", False))
825
+ _atomic_write(target, _autonomy_decl_line(target.read_text(encoding="utf-8"), level))
826
+ print(f"project autonomy -> {level}")
827
+ return
828
+ slug = _resolve_task(state, args.a2) # reused -> "unknown task '<slug>'"
829
+ task_md = root / "tasks" / slug / "TASK.md"
830
+ if _RISK_HIGH_RE.search(_task_header(root, slug)) and level not in ("manual", "conservative"):
831
+ _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high — autonomy must stay "
832
+ f"lowered (manual|conservative); refusing '{level}' (a human must own a high-risk gate)")
833
+ _guard_autonomy_raise(_effective_autonomy(root, state, slug), level, getattr(args, "yes", False))
834
+ _atomic_write(task_md, _autonomy_decl_line(task_md.read_text(encoding="utf-8"), level))
835
+ print(f"task '{slug}' autonomy -> {level}")
836
+ _print_autonomy(root, state, slug)
614
837
 
615
838
 
616
839
  def cmd_reopen(args: argparse.Namespace) -> None:
@@ -636,8 +859,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
636
859
  if not reason:
637
860
  _die("reopen_reason_required: reopen records WHY — supply a non-empty --reason")
638
861
  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})")
862
+ if target not in PHASES[:-1]: # ground..observe; never "done", never an unknown name
863
+ _die(f"reopen_target_invalid: --to must be one of {', '.join(PHASES[:-1])} (got {target!r})")
641
864
  now = _now()
642
865
  entry = {"from": "done", "to": target, "reason": reason, "at": now,
643
866
  "prior_gate": t.get("gate", "none")}
@@ -650,6 +873,32 @@ def cmd_reopen(args: argparse.Namespace) -> None:
650
873
  _sync_task_marker(root, slug, target)
651
874
  save_state(root, state)
652
875
  print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
876
+ print(_next_footer(root, state))
877
+
878
+
879
+ def cmd_heal(args: argparse.Namespace) -> None:
880
+ """Report a CONFIRMED semantic cheat — an earned-green failure the adversarial refute-read
881
+ found — and enter the bounded self-heal loop (heal-then-escalate). The judgment rubric (the
882
+ specific cheats and how to spot them) lives in 6-verify.md, never the engine.
883
+
884
+ The engine cannot SEE a judgment cheat — this is the agent's honest report (honor-system,
885
+ necessary-not-sufficient; the human verify gate stays the real backstop, and the engine
886
+ never spawns the refute-read). It routes through the SAME _heal_or_escalate as the
887
+ mechanical tripwire: return-to-build for an honest redo (≤HEAL_CAP), then a HARD-STOP
888
+ escalation. The refute-read is a verify-gate activity, so the task must be at verify."""
889
+ root = _require_root()
890
+ state = load_state(root)
891
+ slug = _resolve_task(state, args.slug)
892
+ reason = (args.reason or "").strip()
893
+ if not reason:
894
+ _die("heal_reason_required: heal records the refute-read finding — supply a "
895
+ "non-empty --reason (never a silent loop)")
896
+ phase = state["tasks"][slug].get("phase")
897
+ if phase != "verify":
898
+ _die(f"heal_not_at_verify: task '{slug}' is at '{phase}', not verify — the "
899
+ "adversarial refute-read is a verify-gate activity; build then advance to "
900
+ "verify before reporting a cheat")
901
+ _heal_or_escalate(root, state, slug, reason="refute-read:" + reason, source="refute-read")
653
902
 
654
903
 
655
904
  def cmd_lock(args: argparse.Namespace) -> None:
@@ -680,6 +929,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
680
929
  separators=(",", ":")))
681
930
  else:
682
931
  print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
932
+ print(_next_footer(root, state))
683
933
 
684
934
 
685
935
  def _has_production_roadmap(state: dict) -> bool:
@@ -713,6 +963,7 @@ def cmd_stage(args: argparse.Namespace) -> None:
713
963
  print(f"project stage -> {args.stage}")
714
964
  if bypassing:
715
965
  print("(--force: bypassed roadmap check — no production milestone drafted)")
966
+ print(_next_footer(root, state))
716
967
 
717
968
 
718
969
  def cmd_status(args: argparse.Namespace) -> None:
@@ -744,6 +995,9 @@ def cmd_status(args: argparse.Namespace) -> None:
744
995
  # Reuses the canonical helper — do NOT write a parallel predicate.
745
996
  unlocked = not _setup_locked(state)
746
997
  print(f"project : {state.get('project', '(unknown)')}")
998
+ # project autonomy default (task init-auto-default): the posture new tasks INHERIT,
999
+ # read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
1000
+ print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
747
1001
  print(f"stage : {state.get('stage', '(unknown)')}")
748
1002
  # project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
749
1003
  # LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
@@ -752,9 +1006,20 @@ def cmd_status(args: argparse.Namespace) -> None:
752
1006
  _active_ms = state.get("active_milestone")
753
1007
  if _active_ms:
754
1008
  print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
1009
+ # goal-ready (task goal-auto-ready-gate): is the active milestone's goal AUTO-READY
1010
+ # — every exit criterion citing a verifier `(verify: …)` so the engine can self-verify
1011
+ # the result against it? Read LIVE from MILESTONE.md; surfaced every session so the
1012
+ # human sees the goal-clarity gap. Additive — human-readable only, never the JSON surface.
1013
+ _gr_cited, _gr_total = _exit_criteria_cited(root, _active_ms)
1014
+ _gr_state = "auto-ready ✓" if _goal_auto_ready(root, _active_ms) else "NOT auto-ready"
1015
+ print(f"goal-ready: {_gr_state} ({_gr_cited}/{_gr_total} exit criteria cite a verifier)")
755
1016
  # foundation pointer — read the cross-milestone context first (anti-rot)
756
1017
  if (root / "PROJECT.md").exists():
757
1018
  print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
1019
+ # voice pointer — the AI's SOUL (tone · style · trust); read each session, edit freely.
1020
+ # Existence-only: no open/parse, so the pointer adds no IO failure path (a non-file is no voice).
1021
+ if (root / "SOUL.md").exists():
1022
+ print("voice : .add/SOUL.md (how I sound & what keeps your trust — read each session)")
758
1023
  # wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
759
1024
  # Existence-only: no open/read/parse, so the hint adds no IO failure path; a
760
1025
  # non-file at the path is not a ledger. One line PER live ledger — more than
@@ -791,6 +1056,18 @@ def cmd_status(args: argparse.Namespace) -> None:
791
1056
  f"({m_tasks} task{'s' if m_tasks != 1 else ''})")
792
1057
 
793
1058
  print(f"active : {active or '(none)'}")
1059
+ # surface the active task's autonomy level (task explicit-autonomy-dial) so the human
1060
+ # reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
1061
+ if active and active in tasks:
1062
+ print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
1063
+ # grounded (task ground-bundle-wiring): does the active task's §0 GROUND map cite the
1064
+ # anchors §3 names? measure-not-block, human-readable only (never the JSON surface). A
1065
+ # pre-ground / legacy task (no §0) -> _task_grounded None -> NO line, so the surface is
1066
+ # purely additive: an existing task's status output is byte-unchanged.
1067
+ _g = _task_grounded(root, active)
1068
+ if _g is not None:
1069
+ print("grounded: " + ("grounded ✓ — §0 cites the anchors §3 names" if _g
1070
+ else "not yet — fill the §0 GROUND anchors (add.py guide)"))
794
1071
  if not tasks:
795
1072
  # First-run panel: a brand-new project's status is the moment a user is most
796
1073
  # lost. When the setup is unlocked, the only correct next move is review+lock —
@@ -840,6 +1117,7 @@ def cmd_status(args: argparse.Namespace) -> None:
840
1117
  # routed there through the CLI alone. Never a dead pointer: the path is printed
841
1118
  # only if the file exists; a missing tree gets an install hint instead.
842
1119
  _PHASE_GUIDE_FILES = {
1120
+ "ground": "0-ground.md",
843
1121
  "specify": "1-specify.md", "scenarios": "2-scenarios.md",
844
1122
  "contract": "3-contract.md", "tests": "4-tests.md",
845
1123
  "build": "5-build.md", "verify": "6-verify.md", "observe": "7-observe.md",
@@ -897,9 +1175,13 @@ def cmd_guide(args: argparse.Namespace) -> None:
897
1175
  if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError
898
1176
  _die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
899
1177
  action, chapter = entry
1178
+ # the guide names the driver too (task gate-owner-marker) — the SAME _driver_stop the
1179
+ # footer renders, on the next-step line. Computed AFTER the unknown-phase guard above,
1180
+ # so a bad phase fails clean and never reaches the marker (it invents no default).
1181
+ marker = _driver_marker(_driver_stop(root, state, slug, phase))
900
1182
  print(f"active : {slug} (phase: {phase})")
901
1183
  print(f"goal : {_project_goal(root)}") # v20 — the next-step surface still shows what the work is FOR
902
- print(f"next : {action}")
1184
+ print(f"next : {action}{marker}")
903
1185
  print(f"read : .add/docs/{chapter}")
904
1186
  gp = _phase_guide_path(root.parent, phase)
905
1187
  if gp is not None:
@@ -926,6 +1208,404 @@ def _read_task_phase(root: Path, slug: str) -> str | None:
926
1208
  return None
927
1209
 
928
1210
 
1211
+ # --- UDD token-layer validator (udd-token-schema) -----------------------------
1212
+ # A pure, stdlib checker for the compact-DTCG 3-layer token dialect. Returns a
1213
+ # list of (code, path, detail) violations — [] means valid. NOT wired into
1214
+ # cmd_check here: udd-check-lint surfaces these as named reds + adds the catalog/
1215
+ # tree rules (the Fork-A boundary frozen in udd-token-schema §3). The dialect and
1216
+ # its NAMED divergences from DTCG 2025.10 live in templates/udd-tokens.md.
1217
+ _TOKEN_LAYERS = ("primitive", "semantic", "component")
1218
+ _TOKEN_LAYER_CITES = {"semantic": "primitive", "component": "semantic"}
1219
+ _TOKEN_TYPES = ("color", "dimension", "number", "fontFamily", "fontWeight", "duration")
1220
+ _TOKEN_HEX_RE = re.compile(r"^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
1221
+ _TOKEN_DIM_RE = re.compile(r"^-?\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw)$")
1222
+ _TOKEN_DUR_RE = re.compile(r"^\d+(?:\.\d+)?(?:ms|s)$")
1223
+
1224
+
1225
+ def _token_value_form_ok(ttype: str, value: object) -> bool:
1226
+ """True if a LITERAL value matches the compact form for its $type."""
1227
+ if ttype == "color":
1228
+ return isinstance(value, str) and bool(_TOKEN_HEX_RE.match(value))
1229
+ if ttype == "dimension":
1230
+ return isinstance(value, str) and bool(_TOKEN_DIM_RE.match(value))
1231
+ if ttype == "number":
1232
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
1233
+ if ttype == "fontWeight":
1234
+ return isinstance(value, str) or (
1235
+ isinstance(value, int) and not isinstance(value, bool) and 100 <= value <= 900)
1236
+ if ttype == "duration":
1237
+ return isinstance(value, str) and bool(_TOKEN_DUR_RE.match(value))
1238
+ if ttype == "fontFamily":
1239
+ return isinstance(value, str) or (
1240
+ isinstance(value, list) and bool(value) and all(isinstance(x, str) for x in value))
1241
+ return False
1242
+
1243
+
1244
+ def _token_layer_violations(tokens: dict) -> list[tuple[str, str, str]]:
1245
+ """Validate a compact-DTCG token dict against the 3-layer citation rules.
1246
+
1247
+ Pure (never mutates `tokens`), stdlib-only, deterministic document order.
1248
+ Returns [] when valid, else one (code, path, detail) per violation. The six
1249
+ codes are the token-layer named reds udd-check-lint surfaces. A token's LAYER
1250
+ is its top-level group name; value forms diverge from DTCG 2025.10 to compact
1251
+ scalars (color "#hex", dimension "<n><unit>") — see templates/udd-tokens.md.
1252
+ """
1253
+ if not isinstance(tokens, dict):
1254
+ return [("malformed_value", "", "root is not a JSON object")]
1255
+
1256
+ # index every token (object bearing $value) by dotted path — for alias resolution
1257
+ index: dict[str, dict] = {}
1258
+
1259
+ def _index(node: object, path: list[str]) -> None:
1260
+ if not isinstance(node, dict):
1261
+ return
1262
+ if "$value" in node:
1263
+ index[".".join(path)] = node
1264
+ for key, child in node.items(): # descend even past a token — never skip a subtree
1265
+ if not key.startswith("$"):
1266
+ _index(child, path + [key])
1267
+
1268
+ for top, node in tokens.items():
1269
+ if top in _TOKEN_LAYERS:
1270
+ _index(node, [top])
1271
+
1272
+ out: list[tuple[str, str, str]] = []
1273
+
1274
+ def _walk(node: object, path: list[str], layer: str, inherited: "str | None") -> None:
1275
+ if not isinstance(node, dict):
1276
+ return
1277
+ if "$value" in node: # a token
1278
+ pathstr = ".".join(path)
1279
+ ttype = node.get("$type", inherited)
1280
+ value = node.get("$value")
1281
+ if ttype not in _TOKEN_TYPES:
1282
+ out.append(("unknown_type", pathstr, f"$type {ttype!r} not in {list(_TOKEN_TYPES)}"))
1283
+ elif isinstance(value, str) and value.startswith("{") and value.endswith("}"):
1284
+ target = value[1:-1] # an alias
1285
+ if layer == "primitive":
1286
+ out.append(("primitive_has_alias", pathstr,
1287
+ f"a primitive token must hold a literal, not alias {value}"))
1288
+ elif target not in index:
1289
+ out.append(("unresolved_alias", pathstr, f"{value} resolves to no token"))
1290
+ else:
1291
+ target_layer = target.split(".", 1)[0]
1292
+ if target_layer != _TOKEN_LAYER_CITES[layer]:
1293
+ out.append(("cross_layer_citation", pathstr,
1294
+ f"{layer} may alias only {_TOKEN_LAYER_CITES[layer]}, not {target_layer}"))
1295
+ elif not _token_value_form_ok(ttype, value): # a literal
1296
+ out.append(("malformed_value", pathstr, f"{value!r} is not a valid {ttype}"))
1297
+ # a token should be a leaf; if it carries non-$ children, validate them too rather
1298
+ # than letting them pass silently (fail-closed — never skip a subtree).
1299
+ for key, child in node.items():
1300
+ if not key.startswith("$"):
1301
+ _walk(child, path + [key], layer, ttype)
1302
+ return
1303
+ gtype = node.get("$type", inherited) # a group
1304
+ for key, child in node.items():
1305
+ if not key.startswith("$"):
1306
+ _walk(child, path + [key], layer, gtype)
1307
+
1308
+ for top, node in tokens.items():
1309
+ if top not in _TOKEN_LAYERS:
1310
+ out.append(("unknown_layer", top, f"top-level group {top!r} is not a layer"))
1311
+ continue
1312
+ _walk(node, [top], top, None)
1313
+
1314
+ return out
1315
+
1316
+
1317
+ # ---- udd-catalog-content-schema (task 2/4): component catalog + content-tree validator ----
1318
+ _PROPSPEC_LITERALS = ("string", "number", "boolean")
1319
+
1320
+
1321
+ def _propspec_malformed(spec: object) -> "str | None":
1322
+ """Return a reason if a catalog PropSpec is malformed, else None.
1323
+
1324
+ A PropSpec is exactly one of: {type: string|number|boolean} ·
1325
+ {type: enum, values: [str,…]} · {type: token, token: <$type>} (a task-1 $type).
1326
+ """
1327
+ if not isinstance(spec, dict):
1328
+ return "PropSpec is not an object"
1329
+ ptype = spec.get("type")
1330
+ if ptype in _PROPSPEC_LITERALS:
1331
+ return None
1332
+ if ptype == "enum":
1333
+ values = spec.get("values")
1334
+ if not isinstance(values, list) or not values or not all(isinstance(x, str) for x in values):
1335
+ return "enum PropSpec needs a non-empty list of string values"
1336
+ return None
1337
+ if ptype == "token":
1338
+ ttype = spec.get("token")
1339
+ if ttype not in _TOKEN_TYPES:
1340
+ return f"token PropSpec names unknown $type {ttype!r}"
1341
+ return None
1342
+ return f"unknown PropSpec type {ptype!r}"
1343
+
1344
+
1345
+ def _prop_value_code(spec: dict, value: object) -> "str | None":
1346
+ """Return a violation CODE if a tree prop value mismatches its well-formed PropSpec, else None.
1347
+
1348
+ token props are LAYER-only here (frozen §3 @ v2): the value must be a
1349
+ `{semantic.*}` alias. A non-alias literal → prop_type_mismatch; a wrong-layer
1350
+ alias → non_semantic_prop_token. Target existence + $type-match defer to
1351
+ udd-check-lint (the composer that holds tokens.json).
1352
+ """
1353
+ ptype = spec.get("type")
1354
+ if ptype == "string":
1355
+ return None if isinstance(value, str) else "prop_type_mismatch"
1356
+ if ptype == "number":
1357
+ ok = isinstance(value, (int, float)) and not isinstance(value, bool)
1358
+ return None if ok else "prop_type_mismatch"
1359
+ if ptype == "boolean":
1360
+ return None if isinstance(value, bool) else "prop_type_mismatch"
1361
+ if ptype == "enum":
1362
+ return None if value in spec.get("values", []) else "prop_type_mismatch"
1363
+ if ptype == "token":
1364
+ if not (isinstance(value, str) and value.startswith("{") and value.endswith("}")):
1365
+ return "prop_type_mismatch" # a token prop must be an alias, not a literal
1366
+ if value[1:-1].split(".", 1)[0] != "semantic":
1367
+ return "non_semantic_prop_token" # v2: the alias must target the semantic layer
1368
+ return None
1369
+ return None # unreachable for well-formed specs
1370
+
1371
+
1372
+ def _catalog_tree_violations(catalog: dict, tree: dict) -> list[tuple[str, str, str]]:
1373
+ """Validate a json-render content TREE against OUR component CATALOG.
1374
+
1375
+ Pure (never mutates `catalog`/`tree`), stdlib-only, deterministic order. Returns
1376
+ [] when valid, else one (code, path, detail) per violation. The eight named reds:
1377
+ tree_cites_uncataloged_component · unknown_prop · prop_type_mismatch ·
1378
+ non_semantic_prop_token · dangling_child · children_not_allowed · missing_root ·
1379
+ malformed_catalog. SEPARATE from _token_layer_violations; udd-check-lint composes
1380
+ both. non_semantic_prop_token is LAYER-only (§3 @ v2) — token existence/$type-match
1381
+ are udd-check-lint's job (it holds tokens.json). See templates/udd-catalog.md.
1382
+ """
1383
+ out: list[tuple[str, str, str]] = []
1384
+
1385
+ # 1. catalog PropSpecs (malformed_catalog) — and collect the well-formed specs
1386
+ components = catalog.get("components") if isinstance(catalog, dict) else None
1387
+ if not isinstance(components, dict):
1388
+ out.append(("malformed_catalog", "components", "catalog has no 'components' object"))
1389
+ components = {}
1390
+ specs: dict[str, dict[str, dict]] = {} # component -> {prop: well-formed spec}
1391
+ declared_names: dict[str, set] = {} # component -> all declared prop names
1392
+ for cname, comp in components.items():
1393
+ if not isinstance(comp, dict): # v3: a component entry must be an object
1394
+ out.append(("malformed_catalog", f"components.{cname}", "component entry is not an object"))
1395
+ declared_names[cname] = set()
1396
+ specs[cname] = {}
1397
+ continue
1398
+ cprops = comp.get("props", {})
1399
+ cprops = cprops if isinstance(cprops, dict) else {}
1400
+ declared_names[cname] = set(cprops.keys())
1401
+ ok: dict[str, dict] = {}
1402
+ for pname, spec in cprops.items():
1403
+ reason = _propspec_malformed(spec)
1404
+ if reason is not None:
1405
+ out.append(("malformed_catalog", f"components.{cname}.props.{pname}", reason))
1406
+ else:
1407
+ ok[pname] = spec
1408
+ specs[cname] = ok
1409
+
1410
+ # 2. root (missing_root) — checked before the elements walk
1411
+ elements = tree.get("elements") if isinstance(tree, dict) else None
1412
+ elements = elements if isinstance(elements, dict) else {}
1413
+ root = tree.get("root") if isinstance(tree, dict) else None
1414
+ if not isinstance(root, str) or root not in elements:
1415
+ out.append(("missing_root", "root", f"root {root!r} is absent from elements"))
1416
+
1417
+ # 3. elements (document key order)
1418
+ for eid, el in elements.items():
1419
+ if not isinstance(el, dict): # v3: an element must be an object
1420
+ out.append(("malformed_element", f"elements.{eid}", "element is not an object"))
1421
+ continue
1422
+ etype = el.get("type")
1423
+ cataloged = isinstance(etype, str) and etype in components
1424
+ if not cataloged:
1425
+ out.append(("tree_cites_uncataloged_component", f"elements.{eid}.type",
1426
+ f"type {etype!r} not in catalog"))
1427
+
1428
+ props = el.get("props")
1429
+ if "props" in el and not isinstance(props, dict): # v3: props must be an object
1430
+ out.append(("malformed_element", f"elements.{eid}.props", "props is not an object"))
1431
+ elif cataloged and isinstance(props, dict):
1432
+ for pname, value in props.items():
1433
+ if pname not in declared_names.get(etype, set()):
1434
+ out.append(("unknown_prop", f"elements.{eid}.props.{pname}",
1435
+ f"{pname!r} not declared on {etype}"))
1436
+ elif pname in specs.get(etype, {}): # declared + well-formed spec → value-check
1437
+ code = _prop_value_code(specs[etype][pname], value)
1438
+ if code is not None:
1439
+ out.append((code, f"elements.{eid}.props.{pname}",
1440
+ f"{value!r} does not satisfy {specs[etype][pname]}"))
1441
+ # declared-but-malformed-spec prop: the catalog error is already logged; skip value-check
1442
+
1443
+ children = el.get("children")
1444
+ if "children" in el and not isinstance(children, list): # v3: children must be an array
1445
+ out.append(("malformed_element", f"elements.{eid}.children", "children is not an array"))
1446
+ elif isinstance(children, list) and children: # empty list == absent (no violation)
1447
+ comp_entry = components.get(etype)
1448
+ has_children = (bool(comp_entry.get("hasChildren", False))
1449
+ if cataloged and isinstance(comp_entry, dict) else False)
1450
+ if cataloged and not has_children:
1451
+ out.append(("children_not_allowed", f"elements.{eid}.children",
1452
+ f"{etype} does not declare hasChildren"))
1453
+ else:
1454
+ for cid in children:
1455
+ if cid not in elements:
1456
+ out.append(("dangling_child", f"elements.{eid}.children.{cid}",
1457
+ f"child id {cid!r} absent from elements"))
1458
+
1459
+ return out
1460
+
1461
+
1462
+ # ---- udd-check-lint (task 4/4): the composer + cross-file token resolution ----
1463
+ # The single holder of tokens + catalog + tree. _catalog_tree_violations checks a
1464
+ # token-prop alias LAYER-only (it must target `semantic`); here we close the deferral
1465
+ # task 2 left — resolve that alias against tokens.json for EXISTENCE + $type-match.
1466
+
1467
+ def _semantic_token_index(tokens: dict) -> dict[str, "str | None"]:
1468
+ """Map each semantic token's dotted path -> its effective $type.
1469
+
1470
+ A token is a node bearing $value; its $type is the nearest $type on its path
1471
+ (DTCG group inheritance — $type sits on the GROUP, the leaf carries only $value).
1472
+ Keys carry the layer prefix ("semantic.color.accent"), matching the alias body.
1473
+ """
1474
+ out: dict[str, "str | None"] = {}
1475
+ sem = tokens.get("semantic") if isinstance(tokens, dict) else None
1476
+ if not isinstance(sem, dict):
1477
+ return out
1478
+
1479
+ def _walk(node: object, path: list[str], inherited: "str | None") -> None:
1480
+ if not isinstance(node, dict):
1481
+ return
1482
+ ttype = node.get("$type", inherited)
1483
+ if "$value" in node: # a token (a leaf bearing $value)
1484
+ out[".".join(path)] = ttype
1485
+ for key, child in node.items(): # descend even past a token — never skip a subtree
1486
+ if not key.startswith("$"):
1487
+ _walk(child, path + [key], ttype)
1488
+
1489
+ _walk(sem, ["semantic"], None)
1490
+ return out
1491
+
1492
+
1493
+ def _prop_token_resolution_violations(tokens: dict, catalog: dict, tree: dict) -> list[tuple[str, str, str]]:
1494
+ """Resolve a tree's semantic token-prop aliases against tokens.json.
1495
+
1496
+ Pure + TOTAL (never mutates inputs; stdlib only; never raises on dict inputs).
1497
+ Deterministic document order; [] == every token-prop alias resolves to an
1498
+ existing semantic token of the right $type. Acts ONLY on a prop that is BOTH a
1499
+ catalog PropSpec {type:token, token:<$type>} AND a tree {semantic.*} alias (the
1500
+ props _catalog_tree_violations passed LAYER-only); everything else is task 1/2's.
1501
+ Two codes: unresolved_prop_token · prop_token_type_mismatch.
1502
+ """
1503
+ out: list[tuple[str, str, str]] = []
1504
+ sem_index = _semantic_token_index(tokens)
1505
+ components = catalog.get("components") if isinstance(catalog, dict) else None
1506
+ components = components if isinstance(components, dict) else {}
1507
+ elements = tree.get("elements") if isinstance(tree, dict) else None
1508
+ elements = elements if isinstance(elements, dict) else {}
1509
+
1510
+ for eid, el in elements.items():
1511
+ if not isinstance(el, dict):
1512
+ continue # malformed_element — _catalog_tree_violations' job
1513
+ etype = el.get("type")
1514
+ comp = components.get(etype) if isinstance(etype, str) else None
1515
+ if not isinstance(comp, dict):
1516
+ continue # uncataloged / malformed — already flagged there
1517
+ cprops = comp.get("props")
1518
+ cprops = cprops if isinstance(cprops, dict) else {}
1519
+ props = el.get("props")
1520
+ if not isinstance(props, dict):
1521
+ continue
1522
+ for pname, value in props.items():
1523
+ spec = cprops.get(pname)
1524
+ if not isinstance(spec, dict) or spec.get("type") != "token":
1525
+ continue # only catalog token-props
1526
+ if not (isinstance(value, str) and value.startswith("{") and value.endswith("}")):
1527
+ continue # non-alias literal → task-2's prop_type_mismatch
1528
+ target = value[1:-1]
1529
+ if target.split(".", 1)[0] != "semantic":
1530
+ continue # non-semantic alias → task-2's non_semantic_prop_token
1531
+ want = spec.get("token") # the declared $type
1532
+ if want not in _TOKEN_TYPES:
1533
+ continue # malformed token PropSpec → task-2's malformed_catalog owns it
1534
+ path = f"elements.{eid}.props.{pname}"
1535
+ if target not in sem_index:
1536
+ out.append(("unresolved_prop_token", path, f"{value} resolves to no semantic token"))
1537
+ continue
1538
+ got = sem_index[target] # the resolved token's inherited $type
1539
+ if got not in _TOKEN_TYPES:
1540
+ continue # resolved token's $type malformed → task-1's unknown_type owns it
1541
+ if got != want:
1542
+ out.append(("prop_token_type_mismatch", path,
1543
+ f"{value} is {got!r}, but prop wants {want!r}"))
1544
+ return out
1545
+
1546
+
1547
+ def _udd_named_set_checks(root: Path) -> list[tuple[bool, str, str]]:
1548
+ """Lint a project's UDD named set under `.add/design/` (silent when absent).
1549
+
1550
+ Composes _token_layer_violations + _catalog_tree_violations +
1551
+ _prop_token_resolution_violations into cmd_check's (ok, desc, reason) checks.
1552
+ READ-ONLY; FAIL-CLOSED on malformed JSON (a named code, never a crash). Returns
1553
+ [] when no named set exists — so a clean / non-UI project stays untouched.
1554
+ """
1555
+ design = root / "design"
1556
+ tok_path, cat_path = design / "tokens.json", design / "catalog.json"
1557
+ proto_dir = design / "prototypes"
1558
+ trees = sorted(p for p in proto_dir.glob("*.json") if p.is_file()) if proto_dir.is_dir() else []
1559
+ if not (tok_path.exists() or cat_path.exists() or trees):
1560
+ return [] # silent-when-absent
1561
+
1562
+ def _load(p: Path) -> "tuple[object, str | None]":
1563
+ try:
1564
+ return json.loads(p.read_text(encoding="utf-8")), None
1565
+ except (json.JSONDecodeError, OSError) as e:
1566
+ return None, str(e)
1567
+
1568
+ out: list[tuple[bool, str, str]] = []
1569
+
1570
+ tokens = None
1571
+ if tok_path.exists():
1572
+ tokens, err = _load(tok_path)
1573
+ if err is not None:
1574
+ out.append((False, "tokens.json parses", f"malformed_tokens_json: {err}"))
1575
+ tokens = None
1576
+ else:
1577
+ v = _token_layer_violations(tokens)
1578
+ if not v:
1579
+ out.append((True, "tokens.json layer-valid", ""))
1580
+ else:
1581
+ out += [(False, "tokens.json layer-valid", f"{c}: {p} — {d}") for c, p, d in v]
1582
+
1583
+ catalog = None
1584
+ if cat_path.exists():
1585
+ catalog, err = _load(cat_path)
1586
+ if err is not None:
1587
+ out.append((False, "catalog.json parses", f"malformed_catalog_json: {err}"))
1588
+ catalog = None
1589
+
1590
+ for tp in trees:
1591
+ name = tp.stem
1592
+ tree, err = _load(tp)
1593
+ if err is not None:
1594
+ out.append((False, f"prototype '{name}' parses", f"malformed_prototype_json: {err}"))
1595
+ continue
1596
+ if catalog is None:
1597
+ continue # no catalog to validate a tree against — skip quietly
1598
+ v = list(_catalog_tree_violations(catalog, tree))
1599
+ if tokens is not None:
1600
+ v += _prop_token_resolution_violations(tokens, catalog, tree)
1601
+ if not v:
1602
+ out.append((True, f"prototype '{name}' valid", ""))
1603
+ else:
1604
+ out += [(False, f"prototype '{name}' valid", f"{c}: {p} — {d}") for c, p, d in v]
1605
+
1606
+ return out
1607
+
1608
+
929
1609
  def cmd_check(args: argparse.Namespace) -> None:
930
1610
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
931
1611
  as_json = getattr(args, "json", False)
@@ -964,6 +1644,16 @@ def cmd_check(args: argparse.Namespace) -> None:
964
1644
  # the intake flow — NOT a failure. Names structure, never the act of intake.
965
1645
  warnings.append((f"task '{slug}'", "is outside a milestone — size it via the /add "
966
1646
  "intake flow (or attach with --milestone)"))
1647
+ # autonomy level (task explicit-autonomy-dial): a REAL out-of-set token is a hard
1648
+ # unknown_autonomy_level; a LIVE task (phase before done/observe) with no `autonomy:`
1649
+ # line is implicit_autonomy — a WARN, never red. Done/observe predecessors are SKIPPED
1650
+ # (a fresh live-only predicate, NOT the audit open-front skip) so the board never floods.
1651
+ _alvl = _autonomy_level(_task_header(root, slug))
1652
+ checks.append((_alvl != "?", f"task '{slug}' autonomy level recognized",
1653
+ "unknown_autonomy_level (token outside manual|conservative|auto)"))
1654
+ if _alvl is None and t.get("phase") not in ("done", "observe"):
1655
+ warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
1656
+ "— run `add.py autonomy set <level>` to set it"))
967
1657
  for dep in t.get("depends_on") or []:
968
1658
  checks.append((dep in tasks or dep in archived_slugs,
969
1659
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -985,6 +1675,31 @@ def cmd_check(args: argparse.Namespace) -> None:
985
1675
  if lint_result is not None:
986
1676
  ok, reason = lint_result
987
1677
  checks.append((ok, f"task '{slug}' deltas well-formed", reason))
1678
+ # tamper tripwire standing monitor (verify-integrity): a non-done task whose
1679
+ # snapshot has diverged is surfaced EARLY — WARN, never red (the verify GATE
1680
+ # is where it bites, HARD-STOP). Fail-closed via _tripwire_divergence.
1681
+ if not _task_done(t):
1682
+ _tw = t.get("tripwire")
1683
+ if _tw and _tripwire_divergence(root, slug, _tw):
1684
+ warnings.append((f"task '{slug}'", "tampered since its tests->build "
1685
+ "snapshot (build_tampered) — a tracked test or the "
1686
+ "frozen §3 changed; the verify gate will HARD-STOP it"))
1687
+ # §5 scope standing monitor (build-scope-lock): a pending out-of-scope
1688
+ # touch (or a tampered baseline) surfaces EARLY — WARN, never red; the
1689
+ # verify gate is where it bites.
1690
+ _sc = t.get("scope")
1691
+ if isinstance(_sc, dict):
1692
+ _tamper, _out = _scope_findings(root, slug, _sc)
1693
+ if _tamper:
1694
+ warnings.append((f"task '{slug}'", "scope-snapshot.json is "
1695
+ f"{_tamper} against its anchor "
1696
+ "(scope_snapshot_tampered pending) — the verify "
1697
+ "gate will refuse it"))
1698
+ elif _out:
1699
+ warnings.append((f"task '{slug}'", "touched outside its declared "
1700
+ f"§5 Scope: {' · '.join(_out[:3])} "
1701
+ "(scope_violation pending) — the verify gate "
1702
+ "will refuse it"))
988
1703
 
989
1704
  # drift: a done milestone must have no unfinished tasks
990
1705
  for mslug, m in milestones.items():
@@ -994,11 +1709,69 @@ def cmd_check(args: argparse.Namespace) -> None:
994
1709
  checks.append((not unfinished, f"done milestone '{mslug}' fully complete",
995
1710
  f"unfinished: {unfinished}"))
996
1711
 
1712
+ # goal-auto-ready (task goal-auto-ready-gate): nudge the ACTIVE milestone toward a
1713
+ # machine-checkable goal — every exit criterion citing a verifier `(verify: …)` so the
1714
+ # engine can self-verify the result against it. WARN, NEVER red (measurement, not a gate);
1715
+ # fired IFF the goal HAS criteria but not all cite (total >= 1 AND cited < total) — a
1716
+ # zero-criteria milestone is shaping's nudge, not this one's. LIVE-ONLY: the OPEN active
1717
+ # milestone only — a done-but-not-yet-archived one (still the active pointer until
1718
+ # archive clears it) and closed/archived predecessors are never retro-flagged (Must #4).
1719
+ _active_ms = state.get("active_milestone")
1720
+ if _active_ms in milestones and milestones[_active_ms].get("status") != "done":
1721
+ _cited, _total = _exit_criteria_cited(root, _active_ms)
1722
+ if _total >= 1 and _cited < _total:
1723
+ warnings.append(("goal_not_auto_ready",
1724
+ f"milestone '{_active_ms}' goal not auto-ready "
1725
+ f"({_cited}/{_total} exit criteria cite a verifier) — add "
1726
+ "(verify: <test|command|metric>) to each bare criterion"))
1727
+
1728
+ # grounded (task ground-bundle-wiring): the freeze review checklist asks the human to
1729
+ # confirm the contract is grounded; this is the standing monitor for the gap. WARN, NEVER
1730
+ # red (measure-not-block, mirrors goal_not_auto_ready) — fires IFF the ACTIVE task's §3 is
1731
+ # FROZEN AND its §0 GROUND map is ungrounded (the precise "froze without grounding" gap, so
1732
+ # no nag during pre-freeze drafting). A pre-ground / legacy task (no §0 -> _grounded_state
1733
+ # None) is EXEMPT, never retro-flagged. Rides the existing `warnings` array — no new key.
1734
+ _at = state.get("active_task")
1735
+ if _at in tasks:
1736
+ _raw = _raw_phase_bodies(root, _at)
1737
+ if _contract_frozen(_raw.get(3, "")) and _grounded_state(_raw) is False:
1738
+ warnings.append(("task_not_grounded",
1739
+ f"task '{_at}' froze its contract without grounding — fill the "
1740
+ "§0 GROUND anchors the contract cites (add.py guide)"))
1741
+
1742
+ # wave-ledger fork-base (engine-merge-base-enforcement): the engine EXECUTES the
1743
+ # streams.md rule — every roster echo must match `base:`. A FILLED mismatch is red at
1744
+ # ANY status; a pending row is red at `status: merging` (merge-time strictness) but only
1745
+ # a WARN at `status: live` (measure-not-block: step-0 echoes land mid-wave). An
1746
+ # unparseable ledger is fail-closed (`wave_ledger_malformed`) — never a silent skip.
1747
+ for _wp in _wave_ledgers(root):
1748
+ _wm = _wp.parent.name
1749
+ _w = _parse_wave_ledger(_wp)
1750
+ if _w.get("error"):
1751
+ checks.append((False, f"wave '{_wm}' ledger parses",
1752
+ f"wave_ledger_malformed: {_w['error']}"))
1753
+ continue
1754
+ _bad = [r["task"] for r in _w["rows"] if r["filled"] and not r["matched"]]
1755
+ _pending = [r["task"] for r in _w["rows"] if not r["filled"]]
1756
+ if _w["status"] == "merging":
1757
+ _bad += _pending # merge-time strictness: pending == unverified
1758
+ _pending = []
1759
+ checks.append((not _bad, f"wave '{_wm}' fork-base echoes match base",
1760
+ "unverified_fork_base: " + ", ".join(_bad)))
1761
+ for _t in _pending:
1762
+ warnings.append(("fork_base_pending",
1763
+ f"wave '{_wm}' roster row '{_t}' awaits its step-0 echo"))
1764
+
997
1765
  # dependency graph must be acyclic
998
1766
  cycle = _find_cycle(tasks)
999
1767
  checks.append((cycle is None, "task dependencies are acyclic",
1000
1768
  f"cycle: {' -> '.join(cycle)}" if cycle else ""))
1001
1769
 
1770
+ # UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
1771
+ # composes the token + catalog/tree validators + the cross-file prop-token resolution.
1772
+ # Silent when absent; read-only; fail-closed on malformed JSON.
1773
+ checks.extend(_udd_named_set_checks(root))
1774
+
1002
1775
  passed = sum(1 for ok, _, _ in checks if ok)
1003
1776
  failed = len(checks) - passed
1004
1777
  if as_json:
@@ -1022,6 +1795,144 @@ def cmd_check(args: argparse.Namespace) -> None:
1022
1795
  raise SystemExit(1)
1023
1796
 
1024
1797
 
1798
+ # ---------------------------------------------------------------------------
1799
+ # wave-ledger fork-base enforcement (engine-merge-base-enforcement)
1800
+ #
1801
+ # streams.md states the rule; these helpers EXECUTE it (words-exist != method-works).
1802
+ # The ledger is the hand-written `.add/milestones/<m>/WAVE.md` per the streams.md
1803
+ # template: a `base: <sha>` line, a `status: live|merging` field on the header line,
1804
+ # and a `### Roster` table whose 3rd column holds the PASTED `rev-parse HEAD` echo.
1805
+ # Parsing is FAIL-CLOSED: anything off-grammar names the unparseable piece rather
1806
+ # than silently passing — a silent skip would un-guard the trust layer.
1807
+
1808
+ _WAVE_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b")
1809
+
1810
+
1811
+ def _sha_match(a: str, b: str) -> bool:
1812
+ """Exact or prefix match, both tokens >=7 hex chars (git short-sha tolerant)."""
1813
+ if len(a) < 7 or len(b) < 7:
1814
+ return False
1815
+ return a == b or a.startswith(b) or b.startswith(a)
1816
+
1817
+
1818
+ def _wave_ledgers(root: Path) -> list:
1819
+ """Every live wave ledger, stable order (the same glob as the status hint)."""
1820
+ return sorted(p for p in (root / "milestones").glob("*/WAVE.md") if p.is_file())
1821
+
1822
+
1823
+ def _parse_wave_ledger(path: Path) -> dict:
1824
+ """Parse a WAVE.md against the streams.md template grammar. Fail-closed: a dict
1825
+ with an "error" key names exactly the piece that did not parse."""
1826
+ try:
1827
+ text = path.read_text(encoding="utf-8")
1828
+ except OSError as e:
1829
+ return {"error": f"unreadable ({e.__class__.__name__})"}
1830
+ # status is read ONLY from the FIRST `wave:` line — the header. Body text must
1831
+ # never rescue a malformed/invalid header: not free prose (heal-1 FG-2, an
1832
+ # unanchored search) and not a later wave:-prefixed line either (heal-2 FG-3 —
1833
+ # `(?m)^wave:.*?status:` happily skipped a status-less header to a body line).
1834
+ m_header = re.search(r"(?m)^wave:.*$", text)
1835
+ if not m_header:
1836
+ return {"error": "no 'wave:' header line"}
1837
+ # the status value is the EXACT token after `status:`, terminated only by
1838
+ # whitespace, the `·` separator, or end-of-line (v3): `\b` is not a token
1839
+ # terminator on hand-written input — it fires at `|` and `-`, so the unfilled
1840
+ # template placeholder `live|merging` (and drift like `live-ish`) parsed as
1841
+ # its valid prefix and greened an unfilled ledger (5th refute pass). The
1842
+ # `status:` label must itself START a field — start-of-line, whitespace, or
1843
+ # `·` before it (v4): an embedded `substatus:` is not a status field
1844
+ # (6th refute pass, N12).
1845
+ m_status = re.search(r"(?:^|[\s·])status:[ \t]*([^\s·]*)", m_header.group(0))
1846
+ if not m_status:
1847
+ return {"error": "no 'status: live|merging' on the wave: header line"}
1848
+ if m_status.group(1) not in ("live", "merging"):
1849
+ return {"error": "status token "
1850
+ f"{m_status.group(1)!r} is not exactly live or merging"}
1851
+ # base is read ONLY from the FIRST `base:` line, token on THAT line (heal-3 Pex:
1852
+ # `(?m)^base:\s*(\S+)` let \s cross the newline, so an EMPTY base: line parsed
1853
+ # as filled with whatever token the next line started with).
1854
+ m_base_line = re.search(r"(?m)^base:.*$", text)
1855
+ base = ""
1856
+ if m_base_line:
1857
+ m_tok = re.search(r"base:[ \t]*(\S+)", m_base_line.group(0))
1858
+ base = m_tok.group(1) if m_tok else ""
1859
+ if not re.fullmatch(r"[0-9a-f]{7,40}", base):
1860
+ return {"error": "no parseable 'base:' sha (7-40 hex)"}
1861
+ rows, in_roster, echo_col = [], False, None
1862
+ for line in text.splitlines():
1863
+ if line.startswith("### "):
1864
+ in_roster = line.lower().startswith("### roster")
1865
+ echo_col = None
1866
+ continue
1867
+ if not in_roster or not line.lstrip().startswith("|"):
1868
+ continue
1869
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
1870
+ if echo_col is None:
1871
+ # the column-header row MUST name the fork-base column, and the echo is
1872
+ # read from WHEREVER that label sits (heal-3: a hardcoded cells[2] let an
1873
+ # extra leading column hide the echo, and a headerless roster silently
1874
+ # swallowed its first DATA row as the header — a silent skip, refused).
1875
+ # EXACTLY one label may match (v2 ambiguity refusal): first-wins on a
1876
+ # hand-written artifact is fail-open — a second matching label such as
1877
+ # "fork-base-prev" would steal the echo and green a mismatched roster
1878
+ # (4th refute pass, N1/N10).
1879
+ matches = [i for i, c in enumerate(cells) if "fork-base" in c.lower()]
1880
+ if not matches:
1881
+ return {"error": "roster column-header row names no 'fork-base' column"}
1882
+ if len(matches) > 1:
1883
+ labels = ", ".join(cells[i] for i in matches)
1884
+ return {"error": f"ambiguous fork-base columns: {labels}"}
1885
+ echo_col = matches[0]
1886
+ continue
1887
+ if all(set(c) <= set("-: ") for c in cells):
1888
+ continue # the |---| separator row
1889
+ if len(cells) <= echo_col:
1890
+ return {"error": f"roster row with no fork-base cell: {line.strip()!r}"}
1891
+ shas = _WAVE_SHA_RE.findall(cells[echo_col])
1892
+ # fail-closed cell semantics (heal-1 FG-1): the cell must BE the pasted echo,
1893
+ # so EVERY sha token in it must match base — `any()` would green a drift note
1894
+ # ("<alien-sha> synced-to <base-prefix>") that documents the very mismatch
1895
+ # this gate exists to refuse. One alien token -> the row is NOT verified.
1896
+ rows.append({"task": cells[0], "filled": bool(shas),
1897
+ "matched": bool(shas) and all(_sha_match(s, base) for s in shas)})
1898
+ if not rows:
1899
+ return {"error": "no roster row"}
1900
+ return {"status": m_status.group(1), "base": base, "rows": rows}
1901
+
1902
+
1903
+ def cmd_wave_verify(args: argparse.Namespace) -> None:
1904
+ """The explicit merge-time gate: strict at any status, read-only, judgment-free.
1905
+ Exit 0 only when EVERY roster echo matches `base:` — run before the first
1906
+ merge-back. Never mutates the ledger, its status field, or state.json."""
1907
+ root = _require_root()
1908
+ if args.milestone:
1909
+ target = root / "milestones" / args.milestone / "WAVE.md"
1910
+ if not target.is_file():
1911
+ _die(f"wave_not_found: no WAVE.md for milestone '{args.milestone}'")
1912
+ else:
1913
+ ledgers = _wave_ledgers(root)
1914
+ if not ledgers:
1915
+ _die("wave_not_found: no WAVE.md under .add/milestones/ — nothing to verify")
1916
+ if len(ledgers) > 1:
1917
+ _die("wave_ambiguous: " + ", ".join(p.parent.name for p in ledgers)
1918
+ + " — name one: add.py wave-verify <milestone>")
1919
+ target = ledgers[0]
1920
+ w = _parse_wave_ledger(target)
1921
+ if w.get("error"):
1922
+ _die(f"wave_ledger_malformed: {w['error']} ({target.parent.name}/WAVE.md)")
1923
+ bad = []
1924
+ for r in w["rows"]:
1925
+ verdict = "ok" if r["matched"] else ("MISMATCH" if r["filled"] else "PENDING")
1926
+ print(f" {r['task']}: {verdict}")
1927
+ if not r["matched"]:
1928
+ bad.append(r["task"])
1929
+ if bad:
1930
+ _die("unverified_fork_base: " + ", ".join(bad)
1931
+ + f" — every roster echo must match base {w['base'][:12]} before merge-back")
1932
+ print(f"wave '{target.parent.name}' verified — every fork-base echo matches base "
1933
+ f"{w['base'][:12]}; merge-back may proceed (the ledger is untouched).")
1934
+
1935
+
1025
1936
  def cmd_new_milestone(args: argparse.Namespace) -> None:
1026
1937
  root = _require_root()
1027
1938
  state = load_state(root)
@@ -1045,7 +1956,8 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
1045
1956
  state["active_milestone"] = slug
1046
1957
  save_state(root, state)
1047
1958
  print(f"created milestone '{slug}' -> {mfile}")
1048
- print(f"active milestone set. Decompose it into tasks: add.py new-task <slug> --depends-on ...")
1959
+ print("active milestone set.")
1960
+ print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
1049
1961
 
1050
1962
 
1051
1963
  def cmd_ready(args: argparse.Namespace) -> None:
@@ -1093,6 +2005,144 @@ def cmd_ready(args: argparse.Namespace) -> None:
1093
2005
  print(f" {slug}{suffix}")
1094
2006
 
1095
2007
 
2008
+ def _wave_schedule(state: dict, mslug: str) -> dict:
2009
+ """Pure, total: derive the DAG schedule for milestone `mslug` from state — never
2010
+ mutates, never raises on dict input. Returns one of:
2011
+ {"cycle": [slug, ...]} — unschedulable cycle
2012
+ {"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
2013
+
2014
+ A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
2015
+ predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of this milestone
2016
+ forces a later wave. A not-done dep that is NOT an open member (external/unknown)
2017
+ is UNSATISFIABLE here -> the task is `blocked`, never scheduled. Critical path is the
2018
+ longest chain (most tasks) through the scheduled sub-DAG; ties break by sorted slug.
2019
+ Tier is advisory: `top` on the critical path, `mid` elsewhere (scheduled tasks only)."""
2020
+ tasks = state.get("tasks") or {}
2021
+ archived = _archived_task_slugs(state)
2022
+
2023
+ def _ok(d: str) -> bool: # satisfied externally / already done
2024
+ return d in archived or (d in tasks and _task_done(tasks[d]))
2025
+
2026
+ open_members = {s: t for s, t in tasks.items()
2027
+ if t.get("milestone") == mslug and not _task_done(t)}
2028
+
2029
+ # partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
2030
+ # propagates transitively: a task is blocked if any dep is unsatisfiable here, where
2031
+ # unsatisfiable = not _ok AND not a STILL-schedulable member. A dep on an already-blocked
2032
+ # member is itself unsatisfiable, so the dependent blocks too (it would otherwise be
2033
+ # mis-reported as wave-1-ready while its only dep can never complete).
2034
+ blocked: dict[str, list[str]] = {}
2035
+ changed = True
2036
+ while changed:
2037
+ changed = False
2038
+ for s, t in open_members.items():
2039
+ if s in blocked:
2040
+ continue
2041
+ bad = [d for d in (t.get("depends_on") or [])
2042
+ if not _ok(d) and not (d in open_members and d not in blocked)]
2043
+ if bad:
2044
+ blocked[s] = sorted(set(bad))
2045
+ changed = True
2046
+ schedulable = {s for s in open_members if s not in blocked}
2047
+ blocked_sorted = {k: blocked[k] for k in sorted(blocked)}
2048
+ if not schedulable:
2049
+ # nothing to schedule (all-done, empty, or every open task externally blocked)
2050
+ return {"waves": [], "critical_path": [], "critical_path_len": 0,
2051
+ "tiers": {}, "blocked": blocked_sorted}
2052
+
2053
+ def _member_deps(s: str) -> set[str]: # deps that are open members forcing order
2054
+ return {d for d in (open_members[s].get("depends_on") or []) if d in schedulable}
2055
+
2056
+ # Kahn waves over the schedulable sub-DAG
2057
+ waves: list[list[str]] = []
2058
+ placed: set[str] = set()
2059
+ remaining = set(schedulable)
2060
+ while remaining:
2061
+ wave = sorted(s for s in remaining if _member_deps(s) <= placed)
2062
+ if not wave: # no progress => a cycle among the remaining
2063
+ sub = {s: tasks[s] for s in remaining}
2064
+ cyc = _find_cycle(sub) or sorted(remaining)
2065
+ return {"cycle": cyc}
2066
+ waves.append(wave)
2067
+ placed.update(wave)
2068
+ remaining -= set(wave)
2069
+
2070
+ # critical path = longest chain by memoized depth over member-deps
2071
+ depth: dict[str, int] = {}
2072
+ pick: dict[str, str | None] = {}
2073
+
2074
+ def _depth(s: str) -> int:
2075
+ if s in depth:
2076
+ return depth[s]
2077
+ best_d, best_dep = 0, None
2078
+ for d in sorted(_member_deps(s)):
2079
+ dd = _depth(d)
2080
+ if dd > best_d or (dd == best_d and (best_dep is None or d < best_dep)):
2081
+ best_d, best_dep = dd, d
2082
+ depth[s] = 1 + best_d
2083
+ pick[s] = best_dep
2084
+ return depth[s]
2085
+
2086
+ leaf = min(schedulable, key=lambda s: (-_depth(s), s)) # deepest, tie -> smallest slug
2087
+ chain: list[str] = []
2088
+ cur: str | None = leaf
2089
+ while cur is not None:
2090
+ chain.append(cur)
2091
+ cur = pick.get(cur)
2092
+ critical = list(reversed(chain)) # root -> leaf order
2093
+ crit_set = set(critical)
2094
+ tiers = {s: ("top" if s in crit_set else "mid") for s in sorted(schedulable)}
2095
+ return {"waves": waves, "critical_path": critical, "critical_path_len": len(critical),
2096
+ "tiers": tiers, "blocked": blocked_sorted}
2097
+
2098
+
2099
+ def cmd_waves(args: argparse.Namespace) -> None:
2100
+ """READ-ONLY DAG scheduler: print the active milestone's topological waves, critical
2101
+ path, advisory tier hint, and blocked set. Writes nothing; emits no `next:` footer."""
2102
+ is_json = getattr(args, "json", False)
2103
+ if is_json:
2104
+ _, state = _load_state_for_json()
2105
+ else:
2106
+ state = load_state(_require_root())
2107
+ mslug = getattr(args, "milestone", None) or state.get("active_milestone")
2108
+ if not mslug:
2109
+ _die("no_active_milestone: no active milestone and no --milestone given")
2110
+ if mslug not in (state.get("milestones") or {}):
2111
+ _die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
2112
+ sched = _wave_schedule(state, mslug)
2113
+ if "cycle" in sched:
2114
+ _die(f"dependency_cycle: not-done deps form a cycle "
2115
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2116
+
2117
+ if is_json:
2118
+ print(json.dumps({"milestone": mslug, **sched}))
2119
+ return
2120
+
2121
+ print(f"milestone: {mslug}")
2122
+ if not sched["waves"]:
2123
+ if sched["blocked"]:
2124
+ for s in sched["blocked"]:
2125
+ print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2126
+ else:
2127
+ print("all tasks done — nothing to schedule")
2128
+ return
2129
+ scheduled_set = {x for w in sched["waves"] for x in w}
2130
+ for i, wave in enumerate(sched["waves"], start=1):
2131
+ parts = []
2132
+ for s in wave:
2133
+ md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
2134
+ if d in scheduled_set)
2135
+ parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
2136
+ print(f"wave {i}: {', '.join(parts)}")
2137
+ crit = sched["critical_path"]
2138
+ print(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2139
+ tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
2140
+ mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
2141
+ print(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2142
+ for s in sched["blocked"]:
2143
+ print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2144
+
2145
+
1096
2146
  def cmd_milestone_done(args: argparse.Namespace) -> None:
1097
2147
  root = _require_root()
1098
2148
  state = load_state(root)
@@ -1134,13 +2184,14 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
1134
2184
  tail = f" ({len(waived)} via a signed RISK-ACCEPTED waiver)" if waived else ""
1135
2185
  print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
1136
2186
  print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
1137
- print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.")
1138
2187
  # fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
1139
2188
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1140
2189
  if open_deltas:
1141
2190
  noun = "delta" if open_deltas == 1 else "deltas"
1142
2191
  print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
1143
2192
  f"— review with: add.py deltas")
2193
+ # the engine-sourced next step (converges the old "Confirm … archive/start the next" hint)
2194
+ print(_next_footer(root, state))
1144
2195
 
1145
2196
 
1146
2197
  def cmd_archive_milestone(args: argparse.Namespace) -> None:
@@ -1193,6 +2244,7 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
1193
2244
  save_state(root, state)
1194
2245
  print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.")
1195
2246
  print("files on disk are untouched; see `add.py status` for the archived rollup.")
2247
+ print(_next_footer(root, state))
1196
2248
 
1197
2249
 
1198
2250
  def cmd_compact(args: argparse.Namespace) -> None:
@@ -1257,6 +2309,7 @@ def cmd_compact(args: argparse.Namespace) -> None:
1257
2309
  for path, n in moved:
1258
2310
  print(f" moved {path} ({n} files)")
1259
2311
  print("recovery: reverse the moves (mv the bundle's parts back) — state needs no edit.")
2312
+ print(_next_footer(root, state))
1260
2313
 
1261
2314
 
1262
2315
  def cmd_set_milestone(args: argparse.Namespace) -> None:
@@ -1275,6 +2328,7 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
1275
2328
  state["tasks"][task]["updated"] = _now()
1276
2329
  save_state(root, state)
1277
2330
  print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
2331
+ print(_next_footer(root, state))
1278
2332
 
1279
2333
 
1280
2334
  def cmd_use(args: argparse.Namespace) -> None:
@@ -1289,6 +2343,7 @@ def cmd_use(args: argparse.Namespace) -> None:
1289
2343
  state["active_task"] = slug
1290
2344
  save_state(root, state)
1291
2345
  print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
2346
+ print(_next_footer(root, state))
1292
2347
 
1293
2348
 
1294
2349
  def _find_cycle(tasks: dict) -> list[str] | None:
@@ -1370,7 +2425,7 @@ def _bar(num: int, den: int, cells: int, g: dict) -> str:
1370
2425
 
1371
2426
 
1372
2427
  def _phase_track(phase: str, g: dict) -> str:
1373
- """Compact 8-cell pipeline (no labels — a single legend explains it):
2428
+ """Compact 9-cell pipeline (no labels — a single legend explains it):
1374
2429
  reached · current · pending. A done task -> all reached."""
1375
2430
  try:
1376
2431
  ci = PHASES.index(phase)
@@ -1434,6 +2489,27 @@ def _project_goal(root: Path) -> str:
1434
2489
  return GOAL_UNSET
1435
2490
 
1436
2491
 
2492
+ def _project_autonomy_token(root: Path):
2493
+ """The RAW autonomy declaration in PROJECT.md — a recognized rung, None when no
2494
+ declaration line is present, or "?" for a real-but-unrecognized token. Uses the
2495
+ anchored _autonomy_level (a title/prose substring is never a declaration) with
2496
+ HTML comments stripped. Unreadable foundation -> None. Read-only and PURE."""
2497
+ try:
2498
+ text = (root / "PROJECT.md").read_text(encoding="utf-8")
2499
+ except OSError:
2500
+ return None
2501
+ return _autonomy_level(re.sub(r"<!--.*?-->", "", text, flags=re.S))
2502
+
2503
+
2504
+ def _project_autonomy(root: Path) -> str:
2505
+ """The autonomy rung a new task INHERITS from the project default. Fail-SAFE:
2506
+ no declaration -> "auto" (the method default; v7: absent = auto); an unrecognized
2507
+ token -> "conservative" (NEVER silently "auto"); an unreadable foundation -> "auto".
2508
+ Read-only and PURE — mirrors _project_goal; the seed source for cmd_new_task."""
2509
+ tok = _project_autonomy_token(root)
2510
+ return "auto" if tok is None else ("conservative" if tok == "?" else tok)
2511
+
2512
+
1437
2513
  def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
1438
2514
  """(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
1439
2515
  f = root / "milestones" / mslug / MILESTONE_FILE
@@ -1463,6 +2539,41 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
1463
2539
  return met, total
1464
2540
 
1465
2541
 
2542
+ # A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
2543
+ # char inside, so a bare `(verify:)`/`(verify: )` does NOT count (the mid-text substring trap).
2544
+ _VERIFY_CITE_RE = re.compile(r"\(verify:\s*\S.*?\)", re.I)
2545
+
2546
+
2547
+ def _exit_criteria_cited(root: Path, mslug: str) -> tuple[int, int]:
2548
+ """(cited, total) over MILESTONE.md's 'Exit criteria' section. total = every
2549
+ `- [ ]`/`- [x]` criterion line; cited = those carrying a NON-EMPTY
2550
+ `(verify: <citation>)`. Read-only and PURE; missing file/section -> (0, 0).
2551
+ Mirrors _exit_criteria (the checkbox tally) — an ADDITIVE classification beside
2552
+ it; it never touches `milestone_goal_unmet`."""
2553
+ f = root / "milestones" / mslug / MILESTONE_FILE
2554
+ if not f.exists():
2555
+ return 0, 0
2556
+ m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
2557
+ if not m:
2558
+ return 0, 0
2559
+ cited = total = 0
2560
+ for ln in m.group(0).splitlines():
2561
+ if re.match(r"\s*- \[[ x]\]", ln):
2562
+ total += 1
2563
+ if _VERIFY_CITE_RE.search(ln):
2564
+ cited += 1
2565
+ return cited, total
2566
+
2567
+
2568
+ def _goal_auto_ready(root: Path, mslug: str) -> bool:
2569
+ """True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
2570
+ AND every one cites a verifier (cited == total) — so the engine can self-verify the
2571
+ result against the goal without human judgement. A zero-criteria goal is NOT
2572
+ auto-ready (you cannot self-verify against nothing). PURE."""
2573
+ cited, total = _exit_criteria_cited(root, mslug)
2574
+ return total >= 1 and cited == total
2575
+
2576
+
1466
2577
  def _stage_criteria(root: Path) -> tuple[int, int]:
1467
2578
  """(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
1468
2579
  PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
@@ -1507,11 +2618,17 @@ def _count_test_defs(f: Path) -> int:
1507
2618
  return 0
1508
2619
 
1509
2620
 
1510
- def _tests_count(root: Path, slug: str) -> int:
2621
+ def _primary_test_files(root: Path, slug: str) -> list[Path]:
2622
+ """The PRIMARY test set — *.py directly in the task's tests/ dir (the stable
2623
+ path). A list so the tamper tripwire can hash exactly what the engine counts."""
1511
2624
  d = root / "tasks" / slug / "tests"
1512
2625
  if not d.is_dir():
1513
- return 0
1514
- return sum(_count_test_defs(f) for f in d.glob("*.py"))
2626
+ return []
2627
+ return sorted(d.glob("*.py"))
2628
+
2629
+
2630
+ def _tests_count(root: Path, slug: str) -> int:
2631
+ return sum(_count_test_defs(f) for f in _primary_test_files(root, slug))
1515
2632
 
1516
2633
 
1517
2634
  def _confined(p: Path, rootp: Path) -> bool:
@@ -1523,18 +2640,18 @@ def _confined(p: Path, rootp: Path) -> bool:
1523
2640
  return False
1524
2641
 
1525
2642
 
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.
2643
+ def _declared_test_files(root: Path, slug: str) -> list[Path]:
2644
+ """Resolve the §4 'Tests live in:' declared path(s) to a deduped file list. PURE.
1528
2645
  Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
1529
2646
  Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
1530
2647
  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."""
2648
+ token yields the *.py files directly inside it; resolved files are deduped.
2649
+ v2 confinement: every path must resolve inside the project root — '..' traversal,
2650
+ absolute tokens, and symlink escapes are all dropped, fail-closed."""
1534
2651
  body = _raw_phase_bodies(root, slug).get(4, "")
1535
2652
  m = re.search(r"^\s*Tests live in:.*$", body, re.M)
1536
2653
  if not m:
1537
- return 0
2654
+ return []
1538
2655
  tdir = root / "tasks" / slug
1539
2656
  rootp = root.parent.resolve()
1540
2657
  files: list[Path] = []
@@ -1560,7 +2677,12 @@ def _declared_tests_count(root: Path, slug: str) -> int:
1560
2677
  except OSError:
1561
2678
  continue
1562
2679
  files.extend(f for f in cand if f not in files)
1563
- return sum(_count_test_defs(f) for f in files)
2680
+ return files
2681
+
2682
+
2683
+ def _declared_tests_count(root: Path, slug: str) -> int:
2684
+ """Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0."""
2685
+ return sum(_count_test_defs(f) for f in _declared_test_files(root, slug))
1564
2686
 
1565
2687
 
1566
2688
  def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
@@ -1574,6 +2696,279 @@ def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
1574
2696
  return (declared, True) if declared > 0 else (0, False)
1575
2697
 
1576
2698
 
2699
+ def _resolved_test_files(root: Path, slug: str) -> list[Path]:
2700
+ """The file set the engine treats as this task's tests — the PRIMARY set wins
2701
+ when it yields any test defs, else the §4-declared set (mirrors _tests_info's
2702
+ selection). The tamper tripwire hashes exactly THIS set, never a fresh glob."""
2703
+ primary = _primary_test_files(root, slug)
2704
+ if sum(_count_test_defs(f) for f in primary) > 0:
2705
+ return primary
2706
+ return _declared_test_files(root, slug)
2707
+
2708
+
2709
+ def _md5_text(s: str) -> str:
2710
+ return hashlib.md5(s.encode("utf-8")).hexdigest()
2711
+
2712
+
2713
+ def _md5_file(p: Path) -> str | None:
2714
+ """md5 of a file's bytes; None on ANY read error (fail-closed — a tracked file
2715
+ that cannot be read counts as DIVERGED at the gate, never a crash)."""
2716
+ try:
2717
+ return hashlib.md5(p.read_bytes()).hexdigest()
2718
+ except OSError:
2719
+ return None
2720
+
2721
+
2722
+ def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
2723
+ """Freeze the md5 of the resolved red test files + the frozen §3 contract — the
2724
+ tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
2725
+ across the snapshot->gate window). Tool-agnostic: hashes bytes only, never runs
2726
+ tests or measures coverage."""
2727
+ rootp = root.parent.resolve()
2728
+ tests: dict[str, str] = {}
2729
+ for f in _resolved_test_files(root, slug):
2730
+ h = _md5_file(f)
2731
+ if h is None:
2732
+ continue
2733
+ try:
2734
+ rel = str(f.resolve().relative_to(rootp))
2735
+ except (ValueError, OSError):
2736
+ rel = str(f)
2737
+ tests[rel] = h
2738
+ return {"contract_md5": _md5_text(raw3), "tests": tests}
2739
+
2740
+
2741
+ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
2742
+ """Tamper codes for a PRESENT snapshot; [] means clean. Re-reads each tracked
2743
+ path directly (never re-globs), so a weakened, deleted, or unreadable test file
2744
+ and an edited frozen §3 all surface. Fail-closed: an unreadable file -> diverged."""
2745
+ diffs: list[str] = []
2746
+ if _md5_text(_raw_phase_bodies(root, slug).get(3, "")) != tw.get("contract_md5"):
2747
+ diffs.append("contract_tampered")
2748
+ rootp = root.parent.resolve()
2749
+ for rel, snap in (tw.get("tests") or {}).items():
2750
+ if _md5_file(rootp / rel) != snap:
2751
+ diffs.append(f"build_tampered:{rel}")
2752
+ return diffs
2753
+
2754
+
2755
+ # ── §5 scope gate (build-scope-lock): touched ⊆ declared, from bytes alone ──────────
2756
+ # The walk's NAMED exclusion set — ONE constant; widening it is an additive
2757
+ # change-request, never silent. `.add` is engine domain (tripwire + audit guard it);
2758
+ # the rest is VCS/bytecode/OS junk with no build signal.
2759
+ _SCOPE_EXCLUDE_DIRS = (".git", ".add", "__pycache__", "node_modules")
2760
+ _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc by suffix
2761
+
2762
+
2763
+ def _declared_scope(root: Path, slug: str) -> list[str] | None:
2764
+ """Resolve the §5 'Scope (may touch):' declaration to project-root-relative
2765
+ strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
2766
+ grammar: the §4 token rules — backticked spans on the FIRST declaring line ·
2767
+ './…' -> task dir · contains '/' -> project root · bare -> sibling of the
2768
+ previous token's dir · v2 confinement drops everything outside the project
2769
+ root, fail-closed — with ONE divergence: a directory token covers its WHOLE
2770
+ subtree (containment, judged by _in_scope). None = no Scope line (UNDECLARED,
2771
+ grandfathered — never retro-red); [] = a line whose every token was dropped
2772
+ (a garbage declaration grants NO cover)."""
2773
+ body = _raw_phase_bodies(root, slug).get(5, "")
2774
+ m = re.search(r"^\s*Scope \(may touch\):.*$", body, re.M)
2775
+ if not m:
2776
+ return None
2777
+ tdir = root / "tasks" / slug
2778
+ rootp = root.parent.resolve()
2779
+ out: list[str] = []
2780
+ prev_dir = None
2781
+ for tok in re.findall(r"`([^`]+)`", m.group(0)):
2782
+ tok = tok.strip()
2783
+ if tok.startswith("./"):
2784
+ p = tdir / tok[2:]
2785
+ elif "/" in tok:
2786
+ p = root.parent / tok
2787
+ else:
2788
+ p = (prev_dir or tdir) / tok
2789
+ try:
2790
+ if not _confined(p, rootp):
2791
+ continue
2792
+ rp = p.resolve()
2793
+ rel = str(rp.relative_to(rootp))
2794
+ if tok.endswith("/") or rp.is_dir():
2795
+ prev_dir, rel = p, rel.rstrip("/") + "/"
2796
+ else:
2797
+ prev_dir = p.parent
2798
+ except OSError:
2799
+ continue
2800
+ if rel not in out:
2801
+ out.append(rel)
2802
+ return out
2803
+
2804
+
2805
+ def _in_scope(rel: str, declared: list[str]) -> bool:
2806
+ """True when rel falls under any declared token — exact match for a file
2807
+ token, whole-subtree prefix containment for a directory token ('…/')."""
2808
+ for tok in declared:
2809
+ if tok.endswith("/"):
2810
+ if rel.startswith(tok) or rel == tok.rstrip("/"):
2811
+ return True
2812
+ elif rel == tok:
2813
+ return True
2814
+ return False
2815
+
2816
+
2817
+ def _scope_walk(rootp: Path) -> dict[str, str]:
2818
+ """{project-root-relative path: md5} over the project tree, pruning
2819
+ _SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk. A file
2820
+ unreadable at SNAPSHOT time is skipped; at the GATE the resulting absence
2821
+ reads as a touch (fail-closed at the biting end). Bytes only — no git."""
2822
+ files: dict[str, str] = {}
2823
+ for dirpath, dirnames, filenames in os.walk(rootp):
2824
+ dirnames[:] = [d for d in dirnames if d not in _SCOPE_EXCLUDE_DIRS]
2825
+ for name in filenames:
2826
+ if name in _SCOPE_EXCLUDE_FILES or name.endswith(".pyc"):
2827
+ continue
2828
+ p = Path(dirpath) / name
2829
+ h = _md5_file(p)
2830
+ if h is None:
2831
+ continue
2832
+ try:
2833
+ files[str(p.relative_to(rootp))] = h
2834
+ except ValueError:
2835
+ continue
2836
+ return files
2837
+
2838
+
2839
+ def _scope_findings(root: Path, slug: str, anchor: dict) -> tuple[str | None, list[str]]:
2840
+ """(tamper_reason, out_of_scope_touches) for a scope-anchored task. PURE read.
2841
+ The sidecar is integrity-checked against the state.json anchor BEFORE it is
2842
+ trusted; touched = modified ∪ added ∪ deleted vs the snapshot."""
2843
+ side = root / "tasks" / slug / "scope-snapshot.json"
2844
+ try:
2845
+ raw = side.read_text(encoding="utf-8")
2846
+ except OSError:
2847
+ return "missing", []
2848
+ if _md5_text(raw) != anchor.get("snapshot_md5"):
2849
+ return "diverged", []
2850
+ try:
2851
+ snap = json.loads(raw).get("files", {})
2852
+ except (ValueError, AttributeError):
2853
+ return "unparseable", []
2854
+ if not isinstance(snap, dict):
2855
+ return "unparseable", []
2856
+ now = _scope_walk(root.parent.resolve())
2857
+ touched = sorted({k for k, v in snap.items() if now.get(k) != v}
2858
+ | {k for k in now if k not in snap})
2859
+ declared = anchor.get("declared") or []
2860
+ return None, [p for p in touched if not _in_scope(p, declared)]
2861
+
2862
+
2863
+ def _scope_guard(root: Path, state: dict, slug: str) -> None:
2864
+ """Refuse a COMPLETING gate when the build touched outside its declared §5
2865
+ Scope (build-scope-lock). The anchor (state.json) and the sidecar co-witness
2866
+ each other — born in the same tests->build crossing, so EITHER single-file
2867
+ erase is caught (v2, refute-driven): an anchor-less task whose sidecar still
2868
+ EXISTS is scope_anchor_missing, never a silent skip. Both absent -> UNDECLARED
2869
+ or legacy: silent, the grandfather rule (the simultaneous two-file erase is
2870
+ the explicitly accepted floor — the tripwire shares it). Sits directly after
2871
+ _tamper_guard, BEFORE the waiver write, so a violation is never launderable
2872
+ through RISK-ACCEPTED; HARD-STOP never calls it (stopping is always allowed).
2873
+
2874
+ Routing (scope-violation-heal, build-scope-lock 3/3) — tripwire-parity: the
2875
+ RECOVERABLE findings (an out-of-scope touch, a present-but-wrong sidecar) are
2876
+ fixable from BUILD, so they enter the SAME bounded self-heal loop the tamper
2877
+ tripwire uses (_heal_or_escalate, shared HEAL_CAP) — return to build for an
2878
+ honest redo (exit 3), then HARD-STOP at the cap. The ERASED baselines stay
2879
+ die-in-place (exit 1, no heal): a redo cannot recreate an erased anchor or a
2880
+ deleted sidecar — that is tripwire_missing parity. Every heal reason CARRIES
2881
+ its named code, so the existing refusal-token assertions still match."""
2882
+ anchor = state["tasks"][slug].get("scope")
2883
+ if not isinstance(anchor, dict):
2884
+ if (root / "tasks" / slug / "scope-snapshot.json").exists():
2885
+ _die(f"scope_anchor_missing: task '{slug}' carries a scope-snapshot.json "
2886
+ "but no state.json anchor — the touch baseline was erased from "
2887
+ "state; re-establish it (re-advance through tests->build) before "
2888
+ "completing")
2889
+ return
2890
+ tamper, out = _scope_findings(root, slug, anchor)
2891
+ if tamper == "missing":
2892
+ # erased baseline — a redo cannot recreate the evidence (tripwire_missing parity)
2893
+ _die(f"scope_snapshot_tampered: task '{slug}' — scope-snapshot.json is "
2894
+ "missing against its state.json anchor; the touch baseline is "
2895
+ "evidence and must survive the build untouched")
2896
+ if tamper:
2897
+ # diverged | unparseable — present-but-wrong bytes are revertable from build
2898
+ _heal_or_escalate(root, state, slug, source="scope-tamper",
2899
+ reason=(f"scope_snapshot_tampered: task '{slug}' — "
2900
+ f"scope-snapshot.json is {tamper} against its "
2901
+ "state.json anchor; revert it to the snapshot bytes"))
2902
+ if out:
2903
+ shown = " · ".join(out[:5])
2904
+ _heal_or_escalate(root, state, slug, source="scope",
2905
+ reason=(f"scope_violation: task '{slug}' touched outside its "
2906
+ f"declared §5 Scope — {shown} ({len(out)} total)"))
2907
+
2908
+
2909
+ def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source: str) -> None:
2910
+ """The bounded self-heal router (verify-integrity, heal-then-escalate). Called ONLY when
2911
+ a cheat is CONFIRMED at this point — mechanical (tripwire divergence, source "tamper") or
2912
+ semantic (an agent-reported refute-read finding, source "refute-read").
2913
+
2914
+ attempts < HEAL_CAP -> record the attempt, return the task to BUILD for an honest redo,
2915
+ exit 3 (a redo signal, NOT a completing outcome). The phase is set DIRECTLY (never via
2916
+ advance) so the tripwire baseline is not re-snapshotted mid-loop. The increment is saved
2917
+ BEFORE the exit, so a re-run never grants a free attempt (atomic, fail-closed).
2918
+
2919
+ attempts >= HEAL_CAP -> the next confirmed cheat: record gate = HARD-STOP and escalate to
2920
+ the human (_die). A gamed green is NEVER auto-passed; the loop is never unbounded. The
2921
+ counter is MONOTONIC — it never auto-resets (cmd_phase is unguarded, so a reset would be a
2922
+ zero-human cap bypass)."""
2923
+ t = state["tasks"][slug]
2924
+ heal = t.setdefault("heal", {"attempts": 0, "history": []})
2925
+ entry = {"at": _now(), "reason": reason, "source": source}
2926
+ if heal.get("attempts", 0) >= HEAL_CAP:
2927
+ heal.setdefault("history", []).append(entry)
2928
+ t["gate"] = "HARD-STOP" # never a completing outcome; phase stays put
2929
+ t["updated"] = _now()
2930
+ save_state(root, state) # the escalation verdict is durable
2931
+ _die(f"heal_exhausted: task '{slug}' — a confirmed cheat ({reason}) persisted past "
2932
+ f"{HEAL_CAP} honest re-build attempts. HARD-STOP escalated to the human: fix the "
2933
+ "spec (change-request -> re-freeze) or abandon. A gamed green is never auto-passed.")
2934
+ heal["attempts"] = heal.get("attempts", 0) + 1
2935
+ heal.setdefault("history", []).append(entry)
2936
+ t["phase"] = "build" # DIRECT — never via advance (no re-snapshot)
2937
+ t["updated"] = _now()
2938
+ _sync_task_marker(root, slug, "build")
2939
+ save_state(root, state) # the increment is durable BEFORE the exit
2940
+ print(f"return_to_build: task '{slug}' — cheat detected ({reason}); RETURN TO BUILD for an "
2941
+ f"HONEST redo, attempt {heal['attempts']} of {HEAL_CAP}. Revert the tampered file or "
2942
+ "rebuild src honestly, then advance back to verify.")
2943
+ raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
2944
+
2945
+
2946
+ def _tamper_guard(root: Path, state: dict, slug: str) -> None:
2947
+ """HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
2948
+ first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
2949
+ flag_verified: present+diverged -> stop; absent+flag_verified -> suspicious stop
2950
+ (the snapshot was crossed-then-erased); absent+not-verified -> skip (a legacy task
2951
+ or one that never crossed tests->build). A cheat is HARD-STOP-class — this runs
2952
+ for RISK-ACCEPTED too, BEFORE the waiver is recorded, so it is never launderable."""
2953
+ t = state["tasks"][slug]
2954
+ tw = t.get("tripwire")
2955
+ if tw is None:
2956
+ if t.get("flag_verified"):
2957
+ _die(f"tripwire_missing: task '{slug}' crossed tests->build "
2958
+ "(flag_verified) but carries no tamper snapshot — the evidence "
2959
+ "baseline was erased. Re-establish it (reopen -> re-advance through "
2960
+ "tests->build) before completing; a missing baseline is HARD-STOP.")
2961
+ return # legacy: predates the tripwire, or never crossed tests->build
2962
+ diffs = _tripwire_divergence(root, slug, tw)
2963
+ if diffs:
2964
+ # heal-then-escalate (verify-integrity): a mechanical cheat no longer dies on sight —
2965
+ # it enters the bounded self-heal loop (≤HEAL_CAP honest re-build attempts, then a
2966
+ # HARD-STOP escalation). Still HARD-STOP-class: never auto-passed, never launderable
2967
+ # (this runs BEFORE the waiver write). The router returns to build or escalates.
2968
+ _heal_or_escalate(root, state, slug,
2969
+ reason="tamper_detected:" + ",".join(diffs), source="tamper")
2970
+
2971
+
1577
2972
  def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
1578
2973
  """(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
1579
2974
  fidelity: both fields wrap across physical lines in real files, so continuation
@@ -1730,7 +3125,7 @@ def _phase_spans(text: str) -> dict[int, str]:
1730
3125
  m = head.match(ln)
1731
3126
  if m:
1732
3127
  n = int(m.group(1))
1733
- if 1 <= n <= 7 and n not in starts:
3128
+ if 0 <= n <= 7 and n not in starts:
1734
3129
  starts[n] = idx
1735
3130
  out: dict[int, str] = {}
1736
3131
  for n, idx in starts.items():
@@ -1754,23 +3149,23 @@ def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
1754
3149
 
1755
3150
 
1756
3151
  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)"> }.
3152
+ """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
3153
+ blocks ground→observe. PURE — NO writes. Each entry is
3154
+ { "phase": <name>, "n": <0..7>, "body": <cleaned text | "(empty)"> }.
1760
3155
 
1761
3156
  The heading scan lives in _phase_spans (shared with the decide digest); this view
1762
3157
  CLEANS each body. Missing file / missing section / placeholder-only body ->
1763
3158
  "(empty)" (fail-closed)."""
1764
- names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
3159
+ names = PHASES[:-1] # ground..observe; "done" is a terminal STATE, not a section
1765
3160
  f = root / "tasks" / slug / "TASK.md"
1766
3161
  try:
1767
3162
  text = f.read_text(encoding="utf-8")
1768
3163
  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)]
3164
+ return [{"phase": names[n], "n": n, "body": "(empty)"} for n in range(0, 8)]
1770
3165
  spans = _phase_spans(text)
1771
- return [{"phase": names[n - 1], "n": n,
3166
+ return [{"phase": names[n], "n": n,
1772
3167
  "body": _clean_phase_body(spans[n]) if n in spans else "(empty)"}
1773
- for n in range(1, 8)]
3168
+ for n in range(0, 8)]
1774
3169
 
1775
3170
 
1776
3171
  def _task_title(root: Path, slug: str) -> str:
@@ -1846,7 +3241,7 @@ def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *,
1846
3241
  L.append(f" PHASE {phase} GATE {gate}")
1847
3242
  L.append(banner)
1848
3243
  for p in task_phases(root, slug):
1849
- i = p["n"] - 1
3244
+ i = p["n"] # n IS the PHASES index now (ground=0 .. observe=7)
1850
3245
  mk = (g["reached"] if (phase == "done" or i < ci)
1851
3246
  else g["current"] if i == ci else g["pending"])
1852
3247
  L.append("")
@@ -1981,6 +3376,36 @@ def _contract_frozen(raw3: str) -> bool:
1981
3376
  return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
1982
3377
 
1983
3378
 
3379
+ def _section0_anchors(raw0: str) -> str | None:
3380
+ """The value of the §0 GROUND "Anchors the contract cites:" line, stripped.
3381
+ None when the §0 body carries no such line (no §0, or a malformed map). PURE."""
3382
+ for ln in raw0.splitlines():
3383
+ m = re.match(r"\s*Anchors the contract cites:\s*(.*)$", ln)
3384
+ if m:
3385
+ return m.group(1).strip()
3386
+ return None
3387
+
3388
+
3389
+ def _grounded_state(raw: dict[int, str]) -> bool | None:
3390
+ """Tri-state grounding measure over a task's RAW §bodies (measure-not-block):
3391
+ True — the §0 "Anchors the contract cites:" line is filled (real content)
3392
+ False — the §0 section exists but its Anchors line is the "<…>" placeholder / empty
3393
+ None — no §0 section (a pre-ground / legacy task), OR a §0 with no Anchors line
3394
+ PURE; fail-open (an unparseable §0 -> None, never a false False). The freeze review
3395
+ checklist asks the human to confirm True; status/check surface it, never block on it."""
3396
+ if 0 not in raw:
3397
+ return None
3398
+ anchors = _section0_anchors(raw[0])
3399
+ if anchors is None:
3400
+ return None
3401
+ return bool(anchors) and not anchors.startswith("<")
3402
+
3403
+
3404
+ def _task_grounded(root: Path, slug: str) -> bool | None:
3405
+ """`_grounded_state` for one task by slug (reads its RAW §bodies). Read-only."""
3406
+ return _grounded_state(_raw_phase_bodies(root, slug))
3407
+
3408
+
1984
3409
  _FLAG_LABEL_RE = re.compile(r"Least-sure flag surfaced at freeze\s*:", re.I)
1985
3410
  _FLAG_PART_RE = re.compile(
1986
3411
  r"\[(?:spec|scenario|contract|test)(?:/(?:spec|scenario|contract|test))*\]")
@@ -2022,6 +3447,8 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
2022
3447
  gate = t.get("gate", "none")
2023
3448
  if gate != "none" or phase in ("observe", "done"):
2024
3449
  seam = "recorded"
3450
+ elif phase == "ground":
3451
+ seam = "ground"
2025
3452
  elif phase in _FRONT_PHASES:
2026
3453
  seam = "front"
2027
3454
  else:
@@ -2032,6 +3459,8 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
2032
3459
  judgment = _decision_markers(raw.get(6, ""), 6) + _decision_markers(raw.get(1, ""), 1)
2033
3460
  elif seam == "front" and not frozen:
2034
3461
  judgment = _decision_markers(raw.get(1, ""), 1) + _decision_markers(raw.get(3, ""), 3)
3462
+ elif seam == "ground":
3463
+ judgment = _decision_markers(raw.get(0, ""), 0)
2035
3464
  else:
2036
3465
  judgment = []
2037
3466
 
@@ -2051,6 +3480,9 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
2051
3480
  elif seam == "front":
2052
3481
  unlocks = "none"
2053
3482
  decide = "no decision pending — frozen; the run owns it. next decision point: verify gate"
3483
+ elif seam == "ground":
3484
+ unlocks = "gather the codebase -> advance to specify"
3485
+ decide = "gather the real codebase (the section 0 GROUND map), then: add.py advance"
2054
3486
  else:
2055
3487
  unlocks = "none"
2056
3488
  decide = f"no decision pending — recorded gate: {gate}"
@@ -2069,7 +3501,7 @@ def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
2069
3501
  g = _ASCII if ascii else _UNICODE
2070
3502
  banner = g["h"] * width
2071
3503
  seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
2072
- "recorded": "RECORDED"}[d["seam"]]
3504
+ "recorded": "RECORDED", "ground": "GROUND"}[d["seam"]]
2073
3505
  L = [banner, f" DECIDE · {mslug or '—'} · {slug} · decision point: {seam_label}", banner]
2074
3506
  if d["decide"].startswith("no decision pending"):
2075
3507
  L.append(f" {d['decide']}")
@@ -2134,14 +3566,22 @@ def _planned_hint(d: dict) -> str:
2134
3566
  return f" — {len(planned)} planned not yet scaffolded: " + " · ".join(planned)
2135
3567
 
2136
3568
 
2137
- def _decide_next_base(state: dict, d: dict) -> str:
3569
+ def _decide_next_pair(state: dict, d: dict) -> tuple[str, bool]:
3570
+ """(next-step text, human_stop) over the active-milestone rollup. `human_stop` is the
3571
+ driver behind the step (task gate-owner-marker): True for every DECISION point a human
3572
+ owns — decompose · resolve HARD-STOP · goal-not-met · consolidate/archive · approve
3573
+ contract · gate — and False ONLY for the run-in-progress fallthrough, the one branch
3574
+ where the AI just continues an in-flight run. Derived from the rollup `d`, never from
3575
+ the rendered prose (the §5 safety rule). The bare string is `_decide_next_base` below."""
2138
3576
  ms = d["milestone"]["slug"]
2139
3577
  rows = d["tasks"]
2140
3578
  if not rows:
2141
- return "none no tasks yet"
3579
+ # command-first (next-footer-engine): an empty milestone's next step is to
3580
+ # decompose it — name the command, not the dead-end "none — no tasks yet".
3581
+ return f"decompose into tasks — add.py new-task {ms}", True
2142
3582
  stopped = [r for r in rows if r["gate"] == "HARD-STOP"]
2143
3583
  if stopped:
2144
- return f"resolve HARD-STOP on {stopped[0]['slug']}"
3584
+ return f"resolve HARD-STOP on {stopped[0]['slug']}", True
2145
3585
  s = d["summary"]
2146
3586
  if s["tasks_done"] == s["tasks_total"]:
2147
3587
  # tasks complete — but the milestone holds while the goal (exit criteria) is
@@ -2151,8 +3591,8 @@ def _decide_next_base(state: dict, d: dict) -> str:
2151
3591
  met, total = ec.get("met", 0), ec.get("total", 0)
2152
3592
  if total > 0 and met < total:
2153
3593
  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}"
3594
+ f"from open deltas / the unscaffolded plan (add.py deltas)"), True
3595
+ return f"consolidate learnings + archive-milestone {ms}", True
2156
3596
  active = state.get("active_task")
2157
3597
  order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
2158
3598
  for r in order:
@@ -2160,11 +3600,58 @@ def _decide_next_base(state: dict, d: dict) -> str:
2160
3600
  continue
2161
3601
  if r["phase"] in _FRONT_PHASES:
2162
3602
  return (f"approve the contract of {r['slug']} — "
2163
- f"add.py report {ms} {r['slug']} --decide")
3603
+ f"add.py report {ms} {r['slug']} --decide"), True
2164
3604
  if r["phase"] == "verify" and r["gate"] == "none":
2165
- return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide"
3605
+ return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide", True
2166
3606
  r = next(x for x in order if not x["done"])
2167
- return f"none — run in progress ({r['slug']} at {r['phase']})"
3607
+ return f"none — run in progress ({r['slug']} at {r['phase']})", False
3608
+
3609
+
3610
+ def _decide_next_base(state: dict, d: dict) -> str:
3611
+ """The next-step TEXT only — the thin str wrapper the report rollup/digest callers use.
3612
+ The driver behind it (human_stop) is in _decide_next_pair, read by the footer Arm B."""
3613
+ return _decide_next_pair(state, d)[0]
3614
+
3615
+
3616
+ def _next_footer(root: Path, state: dict) -> str:
3617
+ """The single engine-sourced `next:` line a COMPLETING (exit-0) mutating verb prints
3618
+ as its last stdout (task next-footer-engine). ONE resolver, two arms — reusing the
3619
+ guide path, never a parallel next-step source:
3620
+
3621
+ Arm A — an active IN-FLIGHT task (gate == "none" AND phase != "done"): the phase's
3622
+ own command (advance, or the gate verbs at verify) + its PHASE_GUIDE why.
3623
+ The gate=="none" guard is precise — a HARD-STOPped task keeps gate=="HARD-STOP"
3624
+ (never done) so it falls to Arm B and is never told to re-gate itself.
3625
+ Arm B — otherwise: `_decide_next_base` over the active milestone's rollup — the SAME
3626
+ precedence the report dashboard renders (HARD-STOP -> "resolve HARD-STOP …",
3627
+ empty milestone -> "decompose … add.py new-task <ms>").
3628
+
3629
+ Fail-soft (design-for-failure): the footer is computed AFTER save_state, so a
3630
+ resolution error — no active milestone, an unreadable doc, a corrupt rollup — must
3631
+ NEVER turn a saved mutation into a crash; it degrades to one generic re-orient line.
3632
+ Pure render: it writes nothing. The trailing MARKER slot (task gate-owner-marker) names
3633
+ the driver — ` [you drive]` (the AI proceeds) / ` [human gate]` (a human owns it) — from
3634
+ `_driver_stop`: Arm A by phase×autonomy, Arm B by the rollup's own decision (human_stop).
3635
+ The fail-soft line carries NO marker — never assert a driver that could not be computed.
3636
+ """
3637
+ try:
3638
+ slug = state.get("active_task")
3639
+ t = (state.get("tasks") or {}).get(slug) if slug else None
3640
+ if t and t.get("gate", "none") == "none" and t.get("phase") != "done":
3641
+ phase = t.get("phase")
3642
+ why = PHASE_GUIDE[phase][0].split(" — ")[0].strip() # the short phase clause
3643
+ command = ("add.py gate PASS | RISK-ACCEPTED | HARD-STOP"
3644
+ if phase == "verify" else "add.py advance")
3645
+ marker = _driver_marker(_driver_stop(root, state, slug, phase))
3646
+ return f"next: {command} — {why}{marker}"
3647
+ mslug = state.get("active_milestone")
3648
+ if mslug:
3649
+ d = report_data(root, state, mslug)
3650
+ text, human_stop = _decide_next_pair(state, d)
3651
+ return "next: " + text + _driver_marker(human_stop)
3652
+ except Exception:
3653
+ pass # a footer never aborts the verb that already saved its state
3654
+ return "next: add.py status — re-orient"
2168
3655
 
2169
3656
 
2170
3657
  def render_decide_next(root: Path, state: dict, mslug: str, *,
@@ -2421,9 +3908,9 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
2421
3908
  # catches post-gate header tampering and auto-resolved high-risk gates.
2422
3909
  hdr = _task_header(root, slug)
2423
3910
  if _RISK_HIGH_RE.search(hdr):
2424
- if not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
3911
+ if not _autonomy_lowered(hdr):
2425
3912
  f(slug, "unguarded_high_risk_auto",
2426
- "risk: high declared but autonomy is not 'conservative'")
3913
+ "risk: high declared but autonomy is not lowered (manual or conservative)")
2427
3914
  elif rev and "auto-gate" in rev.group(1):
2428
3915
  f(slug, "unguarded_high_risk_auto",
2429
3916
  "risk: high task whose GATE RECORD reviewer is the auto-gate")
@@ -2772,6 +4259,13 @@ def build_parser() -> argparse.ArgumentParser:
2772
4259
  pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
2773
4260
  pr.set_defaults(func=cmd_ready)
2774
4261
 
4262
+ pwa = sub.add_parser("waves", help="read-only DAG schedule of a milestone: topological "
4263
+ "waves + critical path + advisory tier hint")
4264
+ pwa.add_argument("--milestone", default=None,
4265
+ help="milestone slug to schedule (default: the active milestone)")
4266
+ pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
4267
+ pwa.set_defaults(func=cmd_waves)
4268
+
2775
4269
  pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
2776
4270
  pmd.add_argument("slug")
2777
4271
  pmd.set_defaults(func=cmd_milestone_done)
@@ -2799,11 +4293,11 @@ def build_parser() -> argparse.ArgumentParser:
2799
4293
  pp = sub.add_parser("phase", help="set a task's phase explicitly")
2800
4294
  pp.add_argument("phase", choices=PHASES)
2801
4295
  pp.add_argument("slug", nargs="?", default=None)
2802
- pp.set_defaults(func=cmd_phase)
4296
+ pp.set_defaults(func=cmd_phase, _opt_positionals=("slug",))
2803
4297
 
2804
4298
  pa = sub.add_parser("advance", help="move a task to the next phase")
2805
4299
  pa.add_argument("slug", nargs="?", default=None)
2806
- pa.set_defaults(func=cmd_advance)
4300
+ pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
2807
4301
 
2808
4302
  pg = sub.add_parser("gate", help="record a verify gate outcome")
2809
4303
  pg.add_argument("outcome", choices=GATES)
@@ -2811,15 +4305,32 @@ def build_parser() -> argparse.ArgumentParser:
2811
4305
  pg.add_argument("--owner", help="RISK-ACCEPTED waiver: accountable owner")
2812
4306
  pg.add_argument("--ticket", help="RISK-ACCEPTED waiver: tracking ticket/link")
2813
4307
  pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
2814
- pg.set_defaults(func=cmd_gate)
4308
+ pg.set_defaults(func=cmd_gate, _opt_positionals=("slug",))
4309
+
4310
+ pan = sub.add_parser("autonomy", help="show or set the autonomy level (the verify-gate owner)")
4311
+ pan.add_argument("action", nargs="?", choices=("show", "set"), default="show")
4312
+ pan.add_argument("a1", nargs="?", default=None, help="set: <level>; show: [slug]")
4313
+ pan.add_argument("a2", nargs="?", default=None, help="set: [slug]")
4314
+ pan.add_argument("--project", action="store_true",
4315
+ help="set the PROJECT.md default instead of a task header")
4316
+ pan.add_argument("--yes", action="store_true",
4317
+ help="confirm a RAISE toward auto (a human-owned trust escalation)")
4318
+ pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
2815
4319
 
2816
4320
  pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
2817
4321
  pr.add_argument("slug", nargs="?", default=None)
2818
4322
  # --to / --reason are validated in-body (not argparse choices) so the named reject
2819
4323
  # 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)")
4324
+ pr.add_argument("--to", default=None, help="target phase (ground..observe)")
2821
4325
  pr.add_argument("--reason", default="", help="why the task is reopened (required, non-empty)")
2822
- pr.set_defaults(func=cmd_reopen)
4326
+ pr.set_defaults(func=cmd_reopen, _opt_positionals=("slug",))
4327
+
4328
+ ph = sub.add_parser("heal", help="report a confirmed cheat: bounded return-to-build, then escalate")
4329
+ ph.add_argument("slug", nargs="?", default=None)
4330
+ # --reason validated in-body so the named rejects fire (heal_reason_required /
4331
+ # heal_not_at_verify), not a bare argparse usage-2.
4332
+ ph.add_argument("--reason", default="", help="the refute-read finding (required, non-empty)")
4333
+ ph.set_defaults(func=cmd_heal, _opt_positionals=("slug",))
2823
4334
 
2824
4335
  ps = sub.add_parser("stage", help="set the project stage")
2825
4336
  ps.add_argument("stage", choices=STAGES)
@@ -2835,6 +4346,13 @@ def build_parser() -> argparse.ArgumentParser:
2835
4346
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
2836
4347
  pck.set_defaults(func=cmd_check)
2837
4348
 
4349
+ pwv = sub.add_parser("wave-verify",
4350
+ help="read-only merge-time gate: every WAVE.md roster echo must match "
4351
+ "base (refuses unverified_fork_base) — run before the first merge-back")
4352
+ pwv.add_argument("milestone", nargs="?", default=None,
4353
+ help="milestone whose WAVE.md to verify (default: the single live ledger)")
4354
+ pwv.set_defaults(func=cmd_wave_verify, _opt_positionals=("milestone",))
4355
+
2838
4356
  psg = sub.add_parser("sync-guidelines",
2839
4357
  help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
2840
4358
  psg.set_defaults(func=cmd_sync_guidelines)
@@ -2842,7 +4360,7 @@ def build_parser() -> argparse.ArgumentParser:
2842
4360
  pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")
2843
4361
  pgd.add_argument("slug", nargs="?", default=None, help="task slug (default: active task)")
2844
4362
  pgd.add_argument("--json", action="store_true", help="machine-readable JSON output")
2845
- pgd.set_defaults(func=cmd_guide)
4363
+ pgd.set_defaults(func=cmd_guide, _opt_positionals=("slug",))
2846
4364
 
2847
4365
  prp = sub.add_parser("report",
2848
4366
  help="capture/render a milestone's what-happened report (read-only)")
@@ -2862,7 +4380,7 @@ def build_parser() -> argparse.ArgumentParser:
2862
4380
  help="decision-point digest: what needs the human's judgment NOW "
2863
4381
  "(task -> decision digest; milestone -> DECIDE NEXT only; "
2864
4382
  "bare -> the active task)")
2865
- prp.set_defaults(func=cmd_report)
4383
+ prp.set_defaults(func=cmd_report, _opt_positionals=("milestone", "task"))
2866
4384
 
2867
4385
  pdt = sub.add_parser("deltas",
2868
4386
  help="read-only report: open lessons learned grouped by competency")
@@ -2888,9 +4406,142 @@ def build_parser() -> argparse.ArgumentParser:
2888
4406
  return p
2889
4407
 
2890
4408
 
4409
+ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
4410
+ args: argparse.Namespace,
4411
+ extras: list[str]) -> argparse.Namespace:
4412
+ """argv portability (py<=3.12): argparse cannot bind an optional positional that
4413
+ trails value-taking flags once a REQUIRED positional was consumed in an earlier
4414
+ block — `gate RISK-ACCEPTED --owner X --ticket Y --expires Z <slug>` dies
4415
+ `unrecognized arguments: <slug>` on 3.10/3.11/3.12 (3.13+ parses it natively).
4416
+ Fix at main(): parse_known_args leaves the stranded slug in `extras`; re-bind
4417
+ non-flag extras into UNFILLED (still-default-None) optional positionals, in the
4418
+ order each subparser declared via set_defaults(_opt_positionals=...).
4419
+ Safety rule (frozen §3, engine-argv-portability): ANY flag-like extra refuses the
4420
+ WHOLE re-bind, and leftover extras re-raise the stock exit-2 error — a typo'd
4421
+ flag's value must never be mis-bound as a slug (that would gate the WRONG task)."""
4422
+ slots = [name for name in getattr(args, "_opt_positionals", ())
4423
+ if getattr(args, name, None) is None]
4424
+ if any(tok.startswith("-") for tok in extras) or len(extras) > len(slots):
4425
+ parser.error("unrecognized arguments: " + " ".join(extras))
4426
+ for name, value in zip(slots, extras):
4427
+ setattr(args, name, value)
4428
+ return args
4429
+
4430
+
4431
+ # --- agent-agnostic update nudge --------------------------------------------
4432
+ # ADD is agent-agnostic: ANY agent (Claude Code · Gemini CLI · Codex) is told by the
4433
+ # guideline block to run `add.py status`/`guide` FIRST, every session. That is the one
4434
+ # universal chokepoint to tell a stale install to refresh — a plain line on STDERR the
4435
+ # agent reads and acts on. Bounded + fail-open by design (see _maybe_nudge_update).
4436
+ #
4437
+ # This is the engine's ONE deliberate, isolated network touch. It is justified narrowly:
4438
+ # an agent that is offline cannot run at all, so when the network is unreachable this
4439
+ # silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
4440
+ _UPDATE_CACHE = ".update-cache.json"
4441
+ _UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
4442
+ _REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
4443
+
4444
+
4445
+ def _read_json_safe(path: Path):
4446
+ try:
4447
+ return json.loads(path.read_text(encoding="utf-8"))
4448
+ except (OSError, json.JSONDecodeError):
4449
+ return None
4450
+
4451
+
4452
+ def _write_json_safe(path: Path, obj) -> None:
4453
+ try:
4454
+ path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
4455
+ except OSError:
4456
+ pass
4457
+
4458
+
4459
+ def _version_gt(a: str, b: str) -> bool:
4460
+ """True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
4461
+ def key(v: str):
4462
+ out = []
4463
+ for part in str(v).split("."):
4464
+ part = part.split("-", 1)[0]
4465
+ out.append((0, int(part)) if part.isdigit() else (1, part))
4466
+ return out
4467
+ try:
4468
+ return key(a) > key(b)
4469
+ except Exception:
4470
+ return False
4471
+
4472
+
4473
+ def _fetch_latest_version(timeout: float = 1.5):
4474
+ """GET the registry's latest version. Returns a string, or None on ANY failure
4475
+ (offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
4476
+ try:
4477
+ req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
4478
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
4479
+ data = json.loads(resp.read().decode("utf-8"))
4480
+ v = data.get("version")
4481
+ return v if isinstance(v, str) and v else None
4482
+ except Exception:
4483
+ return None
4484
+
4485
+
4486
+ def _cached_latest(add_dir: Path):
4487
+ """The registry's latest version, throttled: served from .update-cache.json within
4488
+ the TTL, else refreshed over the network (fail-open). None when unknown."""
4489
+ cache = _read_json_safe(add_dir / _UPDATE_CACHE)
4490
+ if cache and cache.get("latest") and cache.get("checked_at"):
4491
+ try:
4492
+ ts = datetime.fromisoformat(cache["checked_at"])
4493
+ if datetime.now(timezone.utc) - ts < _UPDATE_TTL:
4494
+ return cache["latest"] # fresh -> no network
4495
+ except ValueError:
4496
+ pass
4497
+ latest = _fetch_latest_version()
4498
+ if latest:
4499
+ _write_json_safe(add_dir / _UPDATE_CACHE, {
4500
+ "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
4501
+ "latest": latest,
4502
+ })
4503
+ return latest
4504
+ return cache.get("latest") if cache else None # stale beats nothing
4505
+
4506
+
4507
+ def _maybe_nudge_update(args: argparse.Namespace) -> None:
4508
+ """If the tooling is stale, print an ACTION-REQUIRED line to STDERR so the agent
4509
+ updates before the user's task. Fully bounded + fail-open — never raises, never
4510
+ touches stdout or the exit code."""
4511
+ try:
4512
+ if os.environ.get("ADD_NO_UPDATE_CHECK"):
4513
+ return
4514
+ fn = getattr(args, "func", None)
4515
+ # orientation reads only — never a mid-run mutating verb (gate/advance/...)
4516
+ if getattr(fn, "__name__", "") not in ("cmd_status", "cmd_guide"):
4517
+ return
4518
+ add_dir = find_root()
4519
+ if add_dir is None:
4520
+ return
4521
+ stamp = _read_json_safe(add_dir / ".add-version")
4522
+ current = (stamp or {}).get("version")
4523
+ if not current:
4524
+ return # no launcher stamp -> inert (engine-only/test projects)
4525
+ latest = _cached_latest(add_dir)
4526
+ if not latest or not _version_gt(latest, current):
4527
+ return
4528
+ cmd = ("pipx run pilotspace-add update"
4529
+ if (stamp or {}).get("channel") == "pip"
4530
+ else "npx @pilotspace/add@latest update")
4531
+ sys.stderr.write(
4532
+ f"\n⚠ ADD is out of date: this project is on {current}, latest is {latest}.\n"
4533
+ f" ACTION REQUIRED before the user's task: run `{cmd}`, then re-read the "
4534
+ f"guide from disk, then continue.\n\n")
4535
+ except Exception:
4536
+ return # a nudge must NEVER break a command (design-for-failure)
4537
+
4538
+
2891
4539
  def main(argv: list[str] | None = None) -> int:
2892
4540
  parser = build_parser()
2893
- args = parser.parse_args(argv)
4541
+ args, extras = parser.parse_known_args(argv)
4542
+ if extras:
4543
+ args = _rebind_optional_positionals(parser, args, extras)
4544
+ _maybe_nudge_update(args) # advisory preamble; stderr-only, fail-open
2894
4545
  args.func(args)
2895
4546
  return 0
2896
4547