@pilotspace/add 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tooling/add.py CHANGED
@@ -100,6 +100,8 @@ from add_engine.io_state import ( # re-exported as module globals: callers use
100
100
  _CONFLICT_MARKER_RE, # conflict-marker re
101
101
  _load_state_for_json, # --json state loader
102
102
  _md5_text, _md5_file, # md5 hashing helpers
103
+ _personas_unseeded, # persona-seed-nudge predicate
104
+ _real_persona_slugs, # persona-fit-nudge slug listing
103
105
  )
104
106
 
105
107
 
@@ -279,10 +281,14 @@ _BLANK_RUN_RE = re.compile(r"\n{3,}")
279
281
  def _strip_live_scaffold(text: str) -> str:
280
282
  """Remove `<!-- … -->` instruction comments from a TASK.md — fences untouched, idempotent.
281
283
 
282
- Splits on fenced code blocks so a comment inside a ``` fence (e.g. the frozen §3) is never
283
- touched; in the non-fence segments it drops comment spans, trims the trailing whitespace a
284
- removal leaves on a line, and collapses 3+ consecutive newlines to one blank line."""
285
- segs = re.split(r"(```.*?```)", text, flags=re.DOTALL)
284
+ Splits on fenced code blocks AND an inline single-backtick span that IS itself a whole
285
+ `` `<!--...-->` `` (literal comment syntax quoted as an example in prose) so neither is
286
+ touched; a live comment that merely CONTAINS unrelated backtick-quoted code (e.g. this very
287
+ template's own `` `add.py autonomy set` ``-style asides) is untouched by this exception and
288
+ still stripped whole, exactly as before. In the remaining segments it drops comment spans,
289
+ trims the trailing whitespace a removal leaves on a line, and collapses 3+ consecutive
290
+ newlines to one blank line."""
291
+ segs = re.split(r"(```.*?```|`<!--.*?-->`)", text, flags=re.DOTALL)
286
292
  for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
287
293
  s = _HTML_COMMENT_RE.sub("", segs[i])
288
294
  s = _TRAILING_WS_RE.sub("", s)
@@ -394,6 +400,28 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
394
400
  _atomic_write(f, new)
395
401
 
396
402
 
403
+ def _capture_wrapped(label: str, body: str):
404
+ """Capture a `<label>: value` field that may WRAP onto continuation lines (a human writing
405
+ prose in a TASK.md field routinely wraps past one line). Matches the label's first line, then
406
+ consumes subsequent physical lines while each is non-blank AND does not itself start a new
407
+ field label — `Word Word:` or `Word Word (parenthetical):` (the real template places labels
408
+ like `Safety rule (feature-specific):`/`Persona (optional):` immediately after a wrapped field
409
+ with no blank line; a parenthetical-blind boundary would silently swallow them) — so a wrapped
410
+ value is captured in full without ever bleeding into the next field or past a blank-line
411
+ paragraph break. Returns None if the label is absent, matching the single-line behavior it
412
+ replaces."""
413
+ m = re.search(rf"(?m)^{re.escape(label)}:[ \t]*(.+)$", body)
414
+ if not m:
415
+ return None
416
+ lines = [m.group(1).strip()]
417
+ rest = body[m.end():].split("\n")[1:]
418
+ for line in rest:
419
+ if not line.strip() or re.match(r"^[A-Z][A-Za-z ]*(\([^)]*\))?[ \t]*:", line):
420
+ break
421
+ lines.append(line.strip())
422
+ return " ".join(lines)
423
+
424
+
397
425
  def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
398
426
  """Write-back (adr-at-observe): HARVEST a §7 `### Decisions (ADR)` block from the actor-stamps
399
427
  ALREADY in the task — §1 framing (AI) · §3 freeze (human) · §5 strategy-actually-used (AI) · §6
@@ -426,11 +454,11 @@ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
426
454
 
427
455
  def _framing(): # §1 -> [AI]: chosen + rejected
428
456
  try:
429
- m = re.search(r"(?m)^Framings weighed:[ \t]*(.+)$", bodies.get(1, ""))
430
- if not m:
457
+ val = _capture_wrapped("Framings weighed", bodies.get(1, ""))
458
+ if val is None:
431
459
  return UN, ""
432
460
  chosen, rejected = UN, []
433
- for p in (s.strip() for s in m.group(1).split("·") if s.strip()):
461
+ for p in (s.strip() for s in val.split("·") if s.strip()):
434
462
  cm = re.match(r"(.*?)\s*\(chosen\b.*\)\s*$", p) # "(chosen)" OR "(chosen — rationale)"
435
463
  if cm:
436
464
  chosen = cm.group(1).strip() or UN
@@ -458,12 +486,11 @@ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
458
486
 
459
487
  def _strategy(): # §5 -> [AI]: the value, default "as planned"
460
488
  try:
461
- m = re.search(r"(?m)^Strategy actually used:[ \t]*(.+)$", bodies.get(5, ""))
462
- if m:
489
+ val = _capture_wrapped("Strategy actually used", bodies.get(5, ""))
490
+ if val:
463
491
  # UNFILLED is the "<fill at …>" template token; a real value may legitimately
464
492
  # contain "<" (quoting `<tag>`, "x < y") and must NOT degrade to the default
465
- val = m.group(1).strip()
466
- if val and not val.startswith("<fill"):
493
+ if not val.startswith("<fill"):
467
494
  return val
468
495
  except Exception:
469
496
  pass
@@ -593,6 +620,26 @@ def cmd_sync_guidelines(args: argparse.Namespace) -> None:
593
620
  print(f"{action:>9} {name}")
594
621
 
595
622
 
623
+ # fastlane-intake-nudge: a frozen, blunt lexical heuristic — substring match only, no semantic
624
+ # read. Advisory-only (see _fastlane_nudge); never blocks, never selects the lane itself.
625
+ RISK_KEYWORDS = frozenset({
626
+ "milestone", "release", "security", "auth", "architecture", "migration",
627
+ "schema", "protocol", "engine", "breaking", "concurrency", "compliance", "payment",
628
+ })
629
+
630
+
631
+ def _fastlane_nudge(title: str, slug: str) -> str | None:
632
+ """PURE. None if any RISK_KEYWORDS token appears in title.lower() or slug.lower();
633
+ else the one-line advisory recommending the fast lane or a direct edit."""
634
+ haystack = f"{title} {slug}".lower()
635
+ if any(word in haystack for word in RISK_KEYWORDS):
636
+ return None
637
+ return ("heuristic: this looks like a fast-lane or direct-edit candidate (no --fast, "
638
+ "no risk keyword in title/slug) — consider `add.py new-task <slug> --fast`, or "
639
+ "just edit directly for a single-file change. Recommendation only — the lane "
640
+ "is yours to pick.")
641
+
642
+
596
643
  def cmd_new_task(args: argparse.Namespace) -> None:
597
644
  root = _require_root()
598
645
  state = load_state(root)
@@ -717,10 +764,31 @@ def cmd_new_task(args: argparse.Namespace) -> None:
717
764
  if from_delta:
718
765
  print(f"seeded from '{from_delta}' — its open SPEC delta is now "
719
766
  f"[SPEC · seeded] … [→ {slug}]; §1 Feature pre-filled.")
767
+ if not fast:
768
+ note = _fastlane_nudge(title, slug)
769
+ if note:
770
+ print(note)
720
771
  print("active task set. phase: ground. Gather the real codebase (section 0 GROUND).")
721
772
  print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
722
773
 
723
774
 
775
+ def _delta_task_md(root: Path, state: dict, raw_slug: str | None) -> tuple[str, Path, bool]:
776
+ """Resolve a delta verb's target to its on-disk TASK.md — ACTIVE (state-tracked) or
777
+ light-ARCHIVED (state entry dropped at archive-milestone, file kept). The SPEC-delta
778
+ lifecycle lives in the FILE and `deltas` already lists archived ones, so the write verbs
779
+ reach them too (delta-drain reach-back) — previously that needed a hand edit. An archived
780
+ target must be named EXPLICITLY: the no-slug active-task fallback never resolves to one.
781
+ A slug neither in state nor on disk still dies `unknown task` (a compacted bundle under
782
+ .add/archive/ stays out of reach — recover it first). Returns (slug, task_md, archived)."""
783
+ if raw_slug and raw_slug not in state.get("tasks", {}):
784
+ task_md = root / "tasks" / raw_slug / "TASK.md"
785
+ if task_md.exists():
786
+ return raw_slug, task_md, True
787
+ _die(f"unknown task '{raw_slug}'")
788
+ slug = _resolve_task(state, raw_slug) # active path, unchanged semantics
789
+ return slug, root / "tasks" / slug / "TASK.md", False
790
+
791
+
724
792
  def cmd_drop_delta(args: argparse.Namespace) -> None:
725
793
  """DISMISS a task's first open SPEC delta — `[SPEC · open]` -> `[SPEC · dropped]`.
726
794
 
@@ -729,8 +797,7 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
729
797
  text + `(evidence: …)` are byte-preserved by the pure `_resolve_spec_delta`."""
730
798
  root = _require_root()
731
799
  state = load_state(root)
732
- slug = _resolve_task(state, args.slug) # unknown task -> _die
733
- task_md = root / "tasks" / slug / "TASK.md"
800
+ slug, task_md, _arch = _delta_task_md(root, state, args.slug)
734
801
  text = task_md.read_text(encoding="utf-8")
735
802
  match = getattr(args, "match", None)
736
803
  status, idx, _disp = _select_spec_delta(text, match)
@@ -743,7 +810,8 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
743
810
  f"'{slug}' — narrow it")
744
811
  new_text = _resolve_spec_delta(text, "dropped", line_index=idx)
745
812
  _atomic_write(task_md, new_text)
746
- print(f"dropped the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> [SPEC · dropped]")
813
+ print(f"dropped the {'matched' if match else 'first'} open SPEC delta in "
814
+ f"'{slug}'{' (archived — on-disk record)' if _arch else ''} -> [SPEC · dropped]")
747
815
  print(_next_footer(root, state))
748
816
 
749
817
 
@@ -763,12 +831,12 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
763
831
  every open delta in the task; `--match` targets the unique one. Validate-then-write."""
764
832
  root = _require_root()
765
833
  state = load_state(root)
766
- slug = _resolve_task(state, args.slug) # unknown task -> _die
834
+ slug, task_md, _arch = _delta_task_md(root, state, args.slug)
835
+ _arch_note = " (archived — on-disk record)" if _arch else ""
767
836
  reason = (getattr(args, "reason", None) or "").strip()
768
837
  if not reason:
769
838
  _die("carry_reason_required: carry-delta needs a --reason — a deferral must say why "
770
839
  "(it is the breadcrumb a future loop reads)")
771
- task_md = root / "tasks" / slug / "TASK.md"
772
840
  text = task_md.read_text(encoding="utf-8")
773
841
  stamp = f"[carried: {reason}]"
774
842
  if getattr(args, "all", False):
@@ -778,7 +846,7 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
778
846
  for idx in idxs: # indices stay valid (flip is in-place)
779
847
  text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
780
848
  _atomic_write(task_md, text)
781
- print(f"carried {len(idxs)} open SPEC delta(s) in '{slug}' -> [SPEC · carried] ({reason})")
849
+ print(f"carried {len(idxs)} open SPEC delta(s) in '{slug}'{_arch_note} -> [SPEC · carried] ({reason})")
782
850
  print(_next_footer(root, state))
783
851
  return
784
852
  match = getattr(args, "match", None)
@@ -792,7 +860,7 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
792
860
  f"'{slug}' — narrow it, or use --all")
793
861
  new_text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
794
862
  _atomic_write(task_md, new_text)
795
- print(f"carried the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> "
863
+ print(f"carried the {'matched' if match else 'first'} open SPEC delta in '{slug}'{_arch_note} -> "
796
864
  f"[SPEC · carried] ({reason})")
797
865
  print(_next_footer(root, state))
798
866
 
@@ -804,8 +872,7 @@ def cmd_reopen_delta(args: argparse.Namespace) -> None:
804
872
  targets the unique carried delta. Validate-then-write; refuse `no_carried_spec_delta`."""
805
873
  root = _require_root()
806
874
  state = load_state(root)
807
- slug = _resolve_task(state, args.slug)
808
- task_md = root / "tasks" / slug / "TASK.md"
875
+ slug, task_md, _arch = _delta_task_md(root, state, args.slug)
809
876
  text = task_md.read_text(encoding="utf-8")
810
877
  match = getattr(args, "match", None)
811
878
  status, idx, _disp = _select_spec_delta(text, match, status="carried")
@@ -820,7 +887,8 @@ def cmd_reopen_delta(args: argparse.Namespace) -> None:
820
887
  eol = lines[idx][len(lines[idx].rstrip("\n")):]
821
888
  lines[idx] = re.sub(r"\s*\[carried:[^\]]*\]\s*$", "", lines[idx].rstrip("\n")) + eol
822
889
  _atomic_write(task_md, "".join(lines))
823
- print(f"reopened the {'matched' if match else 'first'} carried SPEC delta in '{slug}' -> [SPEC · open]")
890
+ print(f"reopened the {'matched' if match else 'first'} carried SPEC delta in "
891
+ f"'{slug}'{' (archived — on-disk record)' if _arch else ''} -> [SPEC · open]")
824
892
  print(_next_footer(root, state))
825
893
 
826
894
 
@@ -1032,6 +1100,35 @@ def cmd_phase(args: argparse.Namespace) -> None:
1032
1100
  print(_next_footer(root, state))
1033
1101
 
1034
1102
 
1103
+ def cmd_recross(args: argparse.Namespace) -> None:
1104
+ """The RECORDED post-freeze re-cross (bundle-advance): a HUMAN-APPROVED test change after
1105
+ the tests->build crossing (e.g. a test added at review) re-arms the tamper tripwire + §5
1106
+ scope snapshot by re-running the IDENTICAL _build_entry gate stack — never a freeze bypass
1107
+ (a DRAFT §3 still refuses contract_not_frozen). The approver is recorded in state
1108
+ (tasks[slug]["recross"] = {by, at, from_phase}) so an audit can tell a signed re-cross
1109
+ from silent tampering. Replaces the undocumented `phase tests` + `advance` dance."""
1110
+ root = _require_root()
1111
+ state = load_state(root)
1112
+ slug = _resolve_task(state, args.slug)
1113
+ cur = state["tasks"][slug]["phase"]
1114
+ if cur not in ("build", "verify"):
1115
+ _die(f"recross_wrong_phase: re-cross re-arms the tests->build snapshots — only a "
1116
+ f"task at build or verify can re-cross (task '{slug}' is at {cur})")
1117
+ if not (getattr(args, "by", "") or "").strip():
1118
+ _die("recross_unsigned: a post-freeze test change is human-approved — record the "
1119
+ "approver with --by <name>")
1120
+ _build_entry(root, state, slug) # full gate stack; validate-then-write
1121
+ state["tasks"][slug]["recross"] = {"by": args.by.strip(), "at": _now(),
1122
+ "from_phase": cur}
1123
+ state["tasks"][slug]["phase"] = "build"
1124
+ state["tasks"][slug]["updated"] = _now()
1125
+ save_state(root, state) # durable state FIRST (source of truth)
1126
+ _sync_task_marker(root, slug, "build") # then mirror into TASK.md — no split-brain
1127
+ print(f"task '{slug}' re-crossed tests->build — tripwire + scope re-snapshotted "
1128
+ f"(approved by {args.by.strip()})")
1129
+ print(_next_footer(root, state))
1130
+
1131
+
1035
1132
  def cmd_advance(args: argparse.Namespace) -> None:
1036
1133
  root = _require_root()
1037
1134
  state = load_state(root)
@@ -1040,6 +1137,18 @@ def cmd_advance(args: argparse.Namespace) -> None:
1040
1137
  idx = PHASES.index(cur)
1041
1138
  if idx >= len(PHASES) - 1:
1042
1139
  _die(f"task '{slug}' already at final phase ({cur})")
1140
+ # bundle fast-forward (bundle-advance): --to repeats the SINGLE-STEP advance — every
1141
+ # crossing guard below runs per step — and stops hard at `tests`: the tests->build
1142
+ # crossing carries the gate stack (_build_entry) and is never fast-forwarded.
1143
+ _to = getattr(args, "to", None)
1144
+ if _to is not None:
1145
+ if _to not in PHASES:
1146
+ _die(f"advance_to_invalid: --to must be one of: {', '.join(PHASES)}")
1147
+ if PHASES.index(_to) > PHASES.index("tests"):
1148
+ _die("advance_to_stops_at_tests: --to fast-forwards the bundle bookkeeping only — "
1149
+ "the tests->build crossing carries the gate stack; cross it with a plain advance")
1150
+ if PHASES.index(_to) <= idx:
1151
+ _die(f"advance_to_not_forward: task '{slug}' is already at {cur}")
1043
1152
  nxt = PHASES[idx + 1]
1044
1153
  # build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing
1045
1154
  # into build/verify/observe/done is refused until `add.py lock`.
@@ -1119,6 +1228,11 @@ def cmd_advance(args: argparse.Namespace) -> None:
1119
1228
  print(" note: record the lessons this loop taught the foundation in §7 "
1120
1229
  "OBSERVE, then update PROJECT.md when ready:")
1121
1230
  print(f" add.py {_FOLD_VERB} --task {slug} (review first: add.py deltas)")
1231
+ # bundle fast-forward: keep stepping (each pass re-loads state and re-runs every
1232
+ # crossing guard) until the validated --to target is reached.
1233
+ if _to is not None and PHASES.index(nxt) < PHASES.index(_to):
1234
+ cmd_advance(args)
1235
+ return
1122
1236
  print(_next_footer(root, state))
1123
1237
 
1124
1238
 
@@ -1302,9 +1416,47 @@ def _driver_marker(stop: bool) -> str:
1302
1416
  return " [human gate]" if stop else " [you drive]"
1303
1417
 
1304
1418
 
1419
+ def _gate_explain(root: Path, state: dict, slug: str) -> None:
1420
+ """gate-explain (method-ergonomics): compose the gate decision from the SAME predicates
1421
+ cmd_gate enforces, as a READ-ONLY answer — the agent asks instead of recalling run.md.
1422
+ PURE: prints, writes nothing."""
1423
+ hdr = _task_header(root, slug)
1424
+ body6 = _raw_phase_bodies(root, slug).get(6, "")
1425
+ from add_engine.autonomy import _autonomy_level
1426
+ level = _autonomy_level(hdr) or "auto"
1427
+ high = bool(_RISK_HIGH_RE.search(hdr))
1428
+ sens = _task_sensitivity(hdr, valid=_project_sensitivity_values(root)) or "unset"
1429
+ adv_pass = _advisor_verdict_is_pass(body6)
1430
+ adv_clean = adv_pass and _advisor_no_residue(body6)
1431
+ relaxed = sens == "mechanical" and adv_clean
1432
+ print(f"gate-explain {slug}")
1433
+ print(f" phase: {state['tasks'][slug].get('phase', '?')}")
1434
+ print(f" autonomy: {level} · risk: {'high' if high else 'unset/low'} · sensitivity: {sens}")
1435
+ print(f" advisor 3-lens: {'PASS, residue none' if adv_clean else ('PASS with residue' if adv_pass else 'unrecorded or non-PASS')}")
1436
+ print(f" advisor-gate-relax: {'applies (mechanical + clean advisor verdict)' if relaxed else 'not applicable'}")
1437
+ if _autonomy_lowered(hdr):
1438
+ print(" path: HUMAN — the lowered autonomy level puts a person at this verify gate")
1439
+ elif high and not relaxed:
1440
+ print(" path: REFUSED at completion (unguarded_high_risk_auto) — lower the autonomy "
1441
+ "level (`add.py autonomy set conservative`), or for a mechanical task record a "
1442
+ "clean Advisor 3-lens verdict")
1443
+ elif relaxed:
1444
+ print(" path: RELAX — mechanical sensitivity + advisor PASS/none may complete "
1445
+ "without a lowered level (advisor-gate-relax)")
1446
+ else:
1447
+ print(" path: AUTO — may auto-PASS on complete evidence (tests green · no tamper · "
1448
+ "loops dry · deep check + refute-read + 3-lens recorded) with no residue")
1449
+ print(" floor: a security finding is always HARD-STOP — never auto-passed, on every path")
1450
+
1451
+
1305
1452
  def cmd_gate(args: argparse.Namespace) -> None:
1306
1453
  root = _require_root()
1307
1454
  state = load_state(root)
1455
+ if getattr(args, "explain", False):
1456
+ # slug may have landed in the outcome slot (`gate --explain <slug>`)
1457
+ cand = args.slug or (args.outcome if args.outcome not in GATES else None)
1458
+ _gate_explain(root, state, _resolve_task(state, cand))
1459
+ return
1308
1460
  slug = _resolve_task(state, args.slug)
1309
1461
  # build-boundary gate: no verdict may be recorded before the setup is locked.
1310
1462
  if not _setup_locked(state):
@@ -1492,6 +1644,55 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
1492
1644
  _print_autonomy(root, state, slug)
1493
1645
 
1494
1646
 
1647
+ def cmd_worktree_prep(args: argparse.Namespace) -> None:
1648
+ """Mechanize streams.md's manual worktree recipe (worktree-prep): cut an isolated git
1649
+ worktree at HEAD for a spawned worker, materialize the gitignored engine content a
1650
+ tracked-only checkout lacks (.add/tooling · .add/docs — confirmed absent 3-for-3 when
1651
+ done by hand), and echo the fork base for the WAVE.md ledger. Workspace-only: never
1652
+ writes state.json (prep is not a state transition). validate-then-act — every refusal
1653
+ precedes the first filesystem write; a dirty tree WARNS (streams.md: cut AFTER the
1654
+ bundle commit) but proceeds. Runs git only (identity.py precedent) — the NO-EXEC floor
1655
+ (never run a verify suite) is untouched."""
1656
+ root = _require_root()
1657
+ state = load_state(root)
1658
+ slug = _resolve_task(state, args.slug) # unknown task -> _die
1659
+ project = root.parent
1660
+ try:
1661
+ r = subprocess.run(["git", "-C", str(project), "rev-parse", "--short", "HEAD"],
1662
+ capture_output=True, text=True, timeout=30)
1663
+ except (OSError, subprocess.SubprocessError):
1664
+ r = None
1665
+ if r is None or r.returncode != 0:
1666
+ _die("worktree_prep_no_git: the project root is not a git repository with a commit — "
1667
+ "worktree isolation needs git (commit the frozen bundle first)")
1668
+ base = r.stdout.strip()
1669
+ dest = (Path(args.dir).expanduser().resolve() if getattr(args, "dir", None)
1670
+ else project.parent / f"{project.name}-wt-{slug}")
1671
+ if dest.exists():
1672
+ _die(f"worktree_prep_exists: {dest} already exists — remove it or pass --dir <path>")
1673
+ dirty = subprocess.run(["git", "-C", str(project), "status", "--porcelain"],
1674
+ capture_output=True, text=True, timeout=30).stdout.strip()
1675
+ if dirty:
1676
+ print("warning: worktree_prep_dirty_tree — uncommitted changes will NOT ride into the "
1677
+ "worktree; streams.md cuts AFTER committing the frozen bundle", file=sys.stderr)
1678
+ r = subprocess.run(["git", "-C", str(project), "worktree", "add", str(dest), "HEAD"],
1679
+ capture_output=True, text=True, timeout=120)
1680
+ if r.returncode != 0:
1681
+ _die(f"worktree_prep_git_failed: git worktree add refused — {r.stderr.strip()[:300]}")
1682
+ copied = [] # tracked-only checkout lacks these
1683
+ for name in ("tooling", "docs"):
1684
+ src = root / name
1685
+ if src.is_dir():
1686
+ shutil.copytree(src, dest / ".add" / name, dirs_exist_ok=True)
1687
+ copied.append(f".add/{name}")
1688
+ print(f"worktree ready: {dest}")
1689
+ print(f"fork base: {base} (record it in the WAVE.md ledger; the worker's step-0 "
1690
+ "re-echoes `git rev-parse HEAD` and wave-verify checks the match)")
1691
+ print(f"materialized (gitignored, absent from a bare checkout): "
1692
+ f"{', '.join(copied) if copied else 'none found'}")
1693
+ print(f"cleanup when merged: git -C {project} worktree remove {dest}")
1694
+
1695
+
1495
1696
  def cmd_streams(args: argparse.Namespace) -> None:
1496
1697
  """show / set the project STREAMS posture — the parallel-vs-sequential half of the run mode
1497
1698
  (persist-run-mode). Project-scoped (parallelism is ACROSS tasks, so there is no per-task posture)
@@ -1886,6 +2087,11 @@ def cmd_status(args: argparse.Namespace) -> None:
1886
2087
  # Existence-only: no open/parse, so the pointer adds no IO failure path (a non-file is no voice).
1887
2088
  if (root / "SOUL.md").exists():
1888
2089
  print("voice : .add/SOUL.md (how I sound & what keeps your trust — read each session)")
2090
+ # persona pointer (persona-seed-nudge v2): project-wide, read every session like context/voice
2091
+ # above — fires until >=1 REAL persona is seeded, self-clears once one lands. Advisory only;
2092
+ # never gates, never touches the --json branch (human-readable orientation surface only).
2093
+ if _personas_unseeded(root):
2094
+ print(f"persona : {PERSONA_HINT}")
1889
2095
  # wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
1890
2096
  # Existence-only: no open/read/parse, so the hint adds no IO failure path; a
1891
2097
  # non-file at the path is not a ledger. One line PER live ledger — more than
@@ -1939,6 +2145,20 @@ def cmd_status(args: argparse.Namespace) -> None:
1939
2145
  if _loose:
1940
2146
  print(f" → releasable: {len(_loose)} loose task(s) since last release")
1941
2147
 
2148
+ # loop-surfacing-nudges: two ADDITIVE cues so the observe loop surfaces its own accumulation
2149
+ # (cues COUNT, never judge; a clean project's output is byte-identical). carried = the deferred
2150
+ # spec-delta backlog (write-only memory otherwise); compaction = the folded tail the
2151
+ # compact-foundation.md ritual exists to roll (threshold 25 keeps young projects quiet).
2152
+ _carried_n = len(_collect_carried_spec_deltas(root))
2153
+ if _carried_n:
2154
+ print(f" → carried: {_carried_n} deferred spec delta(s) — add.py deltas --carried")
2155
+ _tail = _foundation_tail(root)
2156
+ if _tail["bullets"] >= 25:
2157
+ _rolled = f"fv{_tail['last_settled_fv']}" if _tail["last_settled_fv"] else "never"
2158
+ _now = f"fv{_tail['fv']}" if _tail["fv"] else "fv?"
2159
+ print(f" → compaction: {_tail['bullets']} consolidated lesson(s) above the settled line "
2160
+ f"(last rolled {_rolled}, now {_now}) — compact-foundation.md")
2161
+
1942
2162
  # fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
1943
2163
  # glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
1944
2164
  _fast_mark = " · fast" if active and tasks.get(active, {}).get("fast") is True else ""
@@ -2930,6 +3150,11 @@ def cmd_check(args: argparse.Namespace) -> None:
2930
3150
  "persona_schema_incomplete: missing " + ", ".join(missing)))
2931
3151
  else:
2932
3152
  infos.append((f"persona '{slug}'", "schema-conformant"))
3153
+ # persona-seed-nudge: surface the SAME "no real persona" gap `new-milestone` nudges on, so
3154
+ # it is also visible on a plain `check`/`status` sweep — an INFO affirmation-of-absence,
3155
+ # never a WARN (measure-not-block; a project with no personas behaves exactly as before).
3156
+ if _personas_unseeded(root):
3157
+ infos.append(("personas", f"unseeded — {PERSONA_HINT}"))
2933
3158
 
2934
3159
  # drift: a done milestone must have no unfinished tasks
2935
3160
  for mslug, m in milestones.items():
@@ -3431,6 +3656,19 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
3431
3656
  else:
3432
3657
  print("active milestone set." + ("" if not await_confirm else
3433
3658
  " (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
3659
+ # persona-seed-nudge: a non-blocking hint (never a gate) when this project has no
3660
+ # REAL project-fit persona yet — points at the cross-cutting selection/drafting
3661
+ # service rather than inventing a second mechanism. Fires only on the ACTIVE arm
3662
+ # (a queued milestone isn't yet in flight, so the nudge would be premature there).
3663
+ if _personas_unseeded(root):
3664
+ print(f"note: {PERSONA_HINT}")
3665
+ else:
3666
+ # persona-fit-nudge: the opposite branch — ≥1 real persona already exists, so nudge
3667
+ # the AI to confirm domain fit (or draft a new one) rather than silently assuming an
3668
+ # existing persona covers this brand-new milestone. Existence-only, mutually
3669
+ # exclusive with the note above (same predicate, opposite branch — never both).
3670
+ slugs = ", ".join(_real_persona_slugs(root))
3671
+ print(f"persona-fit: {PERSONA_FIT_HINT_TEMPLATE.format(slugs=slugs)}")
3434
3672
  print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
3435
3673
 
3436
3674
 
@@ -5623,6 +5861,32 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
5623
5861
  return _collect_spec_deltas(root, "open")
5624
5862
 
5625
5863
 
5864
+ def _foundation_tail(root: Path) -> dict:
5865
+ """Count the un-compacted foundation tail — READ-ONLY facts for the status compaction cue.
5866
+
5867
+ Returns {bullets, last_settled_fv, fv}: live `[folded foundation-version N]` stamps across
5868
+ PROJECT.md + CONVENTIONS.md, the highest fv a `settled …fvK–fvM` rolled line reaches (None
5869
+ when never rolled), and the current header fv (None when unparseable). Judges nothing."""
5870
+ bullets, settled, fv = 0, None, None
5871
+ for name in ("PROJECT.md", "CONVENTIONS.md"):
5872
+ f = root / name
5873
+ if not f.is_file():
5874
+ continue
5875
+ try:
5876
+ s = f.read_text(encoding="utf-8")
5877
+ except OSError:
5878
+ continue
5879
+ bullets += s.count("[folded foundation-version")
5880
+ for m in re.finditer(r"settled[^\n]*?fv\d+[–-]fv(\d+)", s):
5881
+ n = int(m.group(1))
5882
+ settled = n if settled is None else max(settled, n)
5883
+ if fv is None:
5884
+ hm = re.search(r"foundation-version:\s*(\d+)", s)
5885
+ if hm:
5886
+ fv = int(hm.group(1))
5887
+ return {"bullets": bullets, "last_settled_fv": settled, "fv": fv}
5888
+
5889
+
5626
5890
  def _collect_carried_spec_deltas(root: Path) -> list[dict]:
5627
5891
  """Carried (deferred, non-lossy) SPEC deltas — the `deltas --carried` retrieval surface."""
5628
5892
  return _collect_spec_deltas(root, "carried")
@@ -5728,12 +5992,136 @@ _KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail sectio
5728
5992
  _TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
5729
5993
 
5730
5994
  # persona-self-improve: a `persona:<slug>` lesson routes into `.add/personas/<slug>.md` instead of a
5731
- # foundation file. The section HINT picks the growable section; only these two are routable.
5995
+ # foundation file. The section HINT picks the growable section; only these four are routable
5996
+ # (fold-persona-sections widened the pair to the 1.16.1 schema's behavioral sections).
5732
5997
  _PERSONA_FOLD_SECTIONS = {
5733
5998
  "critical-rule": "## Critical Rules",
5734
5999
  "success-metric": "## Success Metrics",
6000
+ "anti-pattern": "## Anti-patterns",
6001
+ "ability": "## Abilities",
5735
6002
  }
5736
6003
 
6004
+ # fold-glossary-deltas: a 6th pseudo-competency, `GLOSSARY`, folding a DONE task's own §3
6005
+ # `Glossary deltas: <term>: <definition>` line into `.add/GLOSSARY.md` — unstamped, matching
6006
+ # that file's own existing convention (provenance lives in the task's OWN stamped line instead).
6007
+ _GLOSSARY_LINE_RE = re.compile(r"^Glossary deltas:\s*(.*)$")
6008
+ _GLOSSARY_STAMP_RE = re.compile(r"\[" + re.escape(_FOLDED) + r" foundation-version \d+\]\s*$")
6009
+ _GLOSSARY_EMBEDDED_TERM_RE = re.compile(r"`[^`\n]{1,80}`\s*:") # a 2nd `term`: span -> multi-term, reject
6010
+ _TASK_ATTR_LINE_RE = re.compile(r"^[A-Z][A-Za-z0-9 /()'-]*:\s") # a new top-level "Key: value" line ends a wrap
6011
+ # (real corpus has hyphenated/apostrophe'd labels too, e.g. "Least-sure flag surfaced at freeze:",
6012
+ # "BIND-DON'T-BREAK:" — a narrower class silently over-joins the NEXT field into a Glossary delta)
6013
+
6014
+
6015
+ def _parse_glossary_delta(full_text: str) -> tuple[str, str] | None:
6016
+ """Parse a joined Glossary-deltas value into (term, definition), PURE, or None when it does
6017
+ NOT cleanly parse as a single `<term>: <definition>` pair — "none", already resolved (the
6018
+ `[<resolved> foundation-version N]` stamp), no top-level colon, an unmatched backtick before
6019
+ the split (the `roster-portable-shape`-shaped hazard), or a second embedded `` `term`: ``
6020
+ span further in the definition (the `search-index`-shaped two-terms-in-one-line hazard) all
6021
+ reject."""
6022
+ text = full_text.strip()
6023
+ if not text or text.lower().startswith("none"):
6024
+ return None
6025
+ if _GLOSSARY_STAMP_RE.search(text):
6026
+ return None
6027
+ colon = text.find(":")
6028
+ if colon == -1:
6029
+ return None
6030
+ term_candidate, definition = text[:colon], text[colon + 1:].strip()
6031
+ if not definition or term_candidate.count("`") % 2 != 0:
6032
+ return None
6033
+ term = term_candidate.strip()
6034
+ if term.startswith("`") and term.endswith("`") and len(term) >= 2:
6035
+ term = term[1:-1].strip()
6036
+ if not term or _GLOSSARY_EMBEDDED_TERM_RE.search(definition):
6037
+ return None
6038
+ return term, definition
6039
+
6040
+
6041
+ def _fold_glossary_delta(text: str, version: int) -> str | None:
6042
+ """Append the resolved-stamp (` [<resolved> foundation-version N]`) to the LAST physical line
6043
+ of the FIRST clean, not-yet-resolved `Glossary deltas:` entry in `text` (joining any wrapped
6044
+ continuation lines the same way `_collect_open_deltas` groups a multi-line delta). PURE — no
6045
+ IO. Returns None when there is no clean, not-yet-resolved candidate (the caller then refuses
6046
+ — validate-all-then-write)."""
6047
+ lines = text.splitlines(keepends=True)
6048
+ start = next((i for i, ln in enumerate(lines) if _GLOSSARY_LINE_RE.match(ln.rstrip("\n"))), None)
6049
+ if start is None:
6050
+ return None
6051
+ end = start
6052
+ unit = [_GLOSSARY_LINE_RE.match(lines[start].rstrip("\n")).group(1).strip()]
6053
+ for j in range(start + 1, len(lines)):
6054
+ stripped = lines[j].strip()
6055
+ if not stripped or _TASK_ATTR_LINE_RE.match(stripped) or stripped.startswith("<!--"):
6056
+ break
6057
+ unit.append(stripped)
6058
+ end = j
6059
+ if _parse_glossary_delta(" ".join(unit)) is None:
6060
+ return None
6061
+ last = lines[end]
6062
+ eol = last[len(last.rstrip("\n")):]
6063
+ lines[end] = last.rstrip("\n") + f" [{_FOLDED} foundation-version {version}]" + eol
6064
+ return "".join(lines)
6065
+
6066
+
6067
+ def _collect_glossary_deltas(root: Path) -> tuple[list[dict], int]:
6068
+ """Scan every DONE task's own `Glossary deltas:` line for a clean, not-yet-resolved
6069
+ consolidation candidate.
6070
+
6071
+ Returns (candidates, skipped_count): candidates are {task, term, definition} dicts, READ-ONLY.
6072
+ skipped_count tallies DONE tasks whose line is non-"none", not already resolved-stamped, but
6073
+ does NOT cleanly parse — measured and reported (never silently invisible), per this
6074
+ mechanism's own freeze decision: "count and report skipped/unparseable lines"."""
6075
+ candidates: list[dict] = []
6076
+ skipped = 0
6077
+ tasks_dir = root / "tasks"
6078
+ if not tasks_dir.is_dir():
6079
+ return candidates, skipped
6080
+ for task_md in sorted(tasks_dir.glob("*/TASK.md")):
6081
+ slug = task_md.parent.name
6082
+ if _read_task_phase(root, slug) != "done":
6083
+ continue
6084
+ try:
6085
+ text = task_md.read_text(encoding="utf-8")
6086
+ except OSError:
6087
+ continue
6088
+ lines = text.splitlines()
6089
+ collected = None
6090
+ for i, line in enumerate(lines):
6091
+ m = _GLOSSARY_LINE_RE.match(line)
6092
+ if not m:
6093
+ continue
6094
+ unit = [m.group(1).strip()]
6095
+ for cont in lines[i + 1:]:
6096
+ stripped = cont.strip()
6097
+ if not stripped or _TASK_ATTR_LINE_RE.match(stripped) or stripped.startswith("<!--"):
6098
+ break
6099
+ unit.append(stripped)
6100
+ collected = " ".join(unit).strip()
6101
+ break
6102
+ if collected is None:
6103
+ continue
6104
+ if not collected or collected.lower().startswith("none") or _GLOSSARY_STAMP_RE.search(collected):
6105
+ continue # not a candidate — already handled or none
6106
+ parsed = _parse_glossary_delta(collected)
6107
+ if parsed is None:
6108
+ skipped += 1
6109
+ continue
6110
+ term, definition = parsed
6111
+ candidates.append({"task": slug, "term": term, "definition": definition})
6112
+ return candidates, skipped
6113
+
6114
+
6115
+ def _glossary_has_term(glossary_text: str, term: str) -> bool:
6116
+ """Case-insensitive match of `term` against the text before GLOSSARY.md's own first ': '
6117
+ on each line — the existing-term dup check (`_FOLD_ROUTES`-style, GLOSSARY.md-specific)."""
6118
+ needle = term.strip().lower()
6119
+ for line in glossary_text.splitlines():
6120
+ idx = line.find(": ")
6121
+ if idx != -1 and line[:idx].strip().lower() == needle:
6122
+ return True
6123
+ return False
6124
+
5737
6125
 
5738
6126
  def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
5739
6127
  """Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
@@ -5814,7 +6202,16 @@ def cmd_fold(args: argparse.Namespace) -> None:
5814
6202
  if want_task and it["task"] != want_task:
5815
6203
  continue
5816
6204
  selected.append({**it, "comp": comp})
5817
- if not selected:
6205
+
6206
+ # fold-glossary-deltas: --comp GLOSSARY | (no --comp) ALSO folds glossary terms; a specific
6207
+ # competency (--comp DDD etc.) narrows AWAY from glossary, matching the frozen contract.
6208
+ glossary_selected: list[dict] = []
6209
+ glossary_skipped = 0
6210
+ if want_comp is None or want_comp == "GLOSSARY":
6211
+ glossary_candidates, glossary_skipped = _collect_glossary_deltas(root)
6212
+ glossary_selected = [it for it in glossary_candidates if not want_task or it["task"] == want_task]
6213
+
6214
+ if not selected and not glossary_selected:
5818
6215
  scope = (f"task '{want_task}'" if want_task else "the project") + \
5819
6216
  (f", competency {want_comp}" if want_comp else "")
5820
6217
  _die(f"no_open_deltas: no open lesson to consolidate in {scope} (see `add.py deltas`)")
@@ -5861,15 +6258,45 @@ def cmd_fold(args: argparse.Namespace) -> None:
5861
6258
  "seed the persona first (setup) or fix the slug")
5862
6259
  persona_paths[slug] = ppath
5863
6260
 
6261
+ # glossary routing — GLOSSARY.md must exist to fold into (this mechanism never creates it).
6262
+ glossary_path = root / "GLOSSARY.md"
6263
+ if glossary_selected and not glossary_path.exists():
6264
+ _die("missing_glossary_file: no .add/GLOSSARY.md to consolidate a glossary term into — "
6265
+ "create it first (or narrow away from --comp GLOSSARY) and re-run")
6266
+
5864
6267
  # ── build EVERY edit in memory before writing anything ──────────────────────────────────────
5865
6268
  comps_filter = {want_comp} if want_comp else None
6269
+ comp_task_set = {it["task"] for it in selected}
6270
+ glossary_task_set = {it["task"] for it in glossary_selected}
5866
6271
  task_new: dict[str, str] = {}
5867
- for slug in dict.fromkeys(it["task"] for it in selected):
6272
+ for slug in list(dict.fromkeys(it["task"] for it in selected)) + [
6273
+ s for s in dict.fromkeys(it["task"] for it in glossary_selected) if s not in comp_task_set]:
5868
6274
  tmd = root / "tasks" / slug / "TASK.md"
5869
- flipped = _fold_competency_delta(tmd.read_text(encoding="utf-8"), new_v, comps_filter)
5870
- if flipped is None: # defensive: selected ⇒ ≥1 open here
5871
- _die(f"no_open_deltas: task '{slug}' lost its open lesson mid-session")
5872
- task_new[slug] = flipped
6275
+ body = tmd.read_text(encoding="utf-8")
6276
+ if slug in comp_task_set:
6277
+ flipped = _fold_competency_delta(body, new_v, comps_filter)
6278
+ if flipped is None: # defensive: selected ⇒ ≥1 open here
6279
+ _die(f"no_open_deltas: task '{slug}' lost its open lesson mid-session")
6280
+ body = flipped
6281
+ if slug in glossary_task_set:
6282
+ gflipped = _fold_glossary_delta(body, new_v)
6283
+ if gflipped is None: # defensive: selected ⇒ ≥1 clean candidate here
6284
+ _die(f"no_open_deltas: task '{slug}' lost its glossary delta mid-session")
6285
+ body = gflipped
6286
+ task_new[slug] = body
6287
+
6288
+ # glossary transcription — append clean, UNSTAMPED "Term: definition" lines (GLOSSARY.md's own
6289
+ # existing convention); a case-insensitive-existing term is skipped, never duplicated.
6290
+ glossary_text = glossary_path.read_text(encoding="utf-8") if glossary_selected else ""
6291
+ glossary_new_lines: list[str] = []
6292
+ for it in glossary_selected:
6293
+ if _glossary_has_term(glossary_text, it["term"]):
6294
+ continue
6295
+ line = f"{it['term']}: {it['definition']}"
6296
+ if glossary_text and not glossary_text.endswith("\n"):
6297
+ glossary_text += "\n"
6298
+ glossary_text += line + "\n"
6299
+ glossary_new_lines.append(line)
5873
6300
 
5874
6301
  def _bullet(it):
5875
6302
  ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
@@ -5911,13 +6338,17 @@ def cmd_fold(args: argparse.Namespace) -> None:
5911
6338
  persona_new[slug] = after
5912
6339
 
5913
6340
  counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
5914
- count_str = " · ".join(f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c])
6341
+ count_parts = [f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c]]
6342
+ if glossary_selected:
6343
+ count_parts.append(f"GLOSSARY {len(glossary_selected)}")
6344
+ count_str = " · ".join(count_parts)
5915
6345
  scope = "all" if not (want_task or want_comp) else " ".join(
5916
6346
  filter(None, [f"--task {want_task}" if want_task else "",
5917
6347
  f"--comp {want_comp}" if want_comp else ""]))
6348
+ glossary_row_note = f"; {len(glossary_new_lines)} glossary term(s) added" if glossary_selected else ""
5918
6349
  row = (f"| {date.today().isoformat()} | {_FOLD_VERB} {scope} → foundation-version {new_v} "
5919
6350
  f"({count_str}) | consolidate captured OBSERVE lessons into the versioned foundation "
5920
- f"| {len(selected)} lessons open→{_FOLDED}; +{len(selected)} routed bullets; {prev_v}→{new_v} |")
6351
+ f"| {len(selected)} lessons open→{_FOLDED}; +{len(selected)} routed bullets{glossary_row_note}; {prev_v}→{new_v} |")
5921
6352
  proj_text = _prepend_key_decision_row(proj_text, row)
5922
6353
  proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
5923
6354
 
@@ -5936,11 +6367,22 @@ def cmd_fold(args: argparse.Namespace) -> None:
5936
6367
  writes.append((persona_paths[slug], body))
5937
6368
  if persona_new:
5938
6369
  touched.append(f"{len(persona_new)} persona")
6370
+ if glossary_selected and glossary_new_lines:
6371
+ writes.append((glossary_path, glossary_text))
6372
+ touched.append("GLOSSARY.md")
5939
6373
  _atomic_write_many(writes)
5940
6374
 
5941
- print(f"{_FOLDED} {len(selected)} lessons -> foundation-version {new_v}")
5942
- print(f" {count_str}")
6375
+ print(f"{_FOLDED} {len(selected)} lesson(s) -> foundation-version {new_v}")
6376
+ if count_str:
6377
+ print(f" {count_str}")
5943
6378
  print(f" bumped PROJECT.md {prev_v} -> {new_v}")
6379
+ if glossary_selected:
6380
+ added = len(glossary_new_lines)
6381
+ dup = len(glossary_selected) - added
6382
+ dup_note = f"; {dup} duplicate term(s) skipped" if dup else ""
6383
+ print(f" glossary: {added} term(s) added{dup_note}")
6384
+ if glossary_skipped:
6385
+ print(f" glossary: {glossary_skipped} line(s) unparseable, skipped")
5944
6386
  print(f" files: {', '.join(touched)}")
5945
6387
  print(_next_footer(root, state))
5946
6388
 
@@ -6028,7 +6470,9 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
6028
6470
  if marked:
6029
6471
  f(slug, "risk_accepted_security",
6030
6472
  "a waiver on a marked security item is never allowed")
6031
- if not all(re.search(rf"{k}:\s*(?!<)\S", s6)
6473
+ # case-insensitive: human-signed records write Owner:/Ticket:/Expires: —
6474
+ # the seam is the field EXISTING, casing is presentation (waiver-field-case)
6475
+ if not all(re.search(rf"{k}:\s*(?!<)\S", s6, re.IGNORECASE)
6032
6476
  for k in ("owner", "ticket", "expires")):
6033
6477
  f(slug, "waiver_incomplete",
6034
6478
  "RISK-ACCEPTED needs owner · ticket · expires")
@@ -6171,7 +6615,12 @@ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
6171
6615
  "advisor_residue_on_mechanical_mis_tier": advisor_residue_on_mechanical_mis_tier,
6172
6616
  "rule_coverage_gap": rule_coverage_gap,
6173
6617
  "contract_report_unrecorded": contract_report_unrecorded,
6174
- "verify_report_unrecorded": verify_report_unrecorded}
6618
+ "verify_report_unrecorded": verify_report_unrecorded,
6619
+ # DERIVED rollup (verify-record-rollup): one summary list over the four §6-record
6620
+ # lists — additive; the per-code lists above stay the source of truth.
6621
+ "verify_record_incomplete": sorted(
6622
+ set(shallow) | set(refute_unrecorded)
6623
+ | set(advisor_verdict_unrecorded) | set(verify_report_unrecorded))}
6175
6624
 
6176
6625
 
6177
6626
  def cmd_audit(args: argparse.Namespace) -> None:
@@ -6234,6 +6683,11 @@ def cmd_audit(args: argparse.Namespace) -> None:
6234
6683
  vr = glints["verify_report_unrecorded"]
6235
6684
  print(f"audit: verify_report_unrecorded — {len(vr)} task(s): {', '.join(vr)} "
6236
6685
  f"— record the rendered gate report (§6 `Reported: yes`); a spot-audit is the backstop")
6686
+ if glints["verify_record_incomplete"]:
6687
+ vi = glints["verify_record_incomplete"]
6688
+ print(f"audit: verify_record_incomplete — {len(vi)} task(s): {', '.join(vi)} "
6689
+ f"— fill the §6 verify record (rollup of the four detail lines above: "
6690
+ f"deep-check · refute-read · 3-lens · gate-report)")
6237
6691
  if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
6238
6692
  and not glints["refute_unrecorded"] and not glints["advisor_verdict_unrecorded"] \
6239
6693
  and not glints["sensitivity_unset"] \
@@ -6563,6 +7017,13 @@ def cmd_release_report(args: argparse.Namespace) -> None:
6563
7017
  L.append(f" - {c['milestone']}: {c['retro'] or '(no RETRO record)'} "
6564
7018
  f"({c['carried_deltas']} carried · {len(c['key_decisions'])} key decision(s))")
6565
7019
  L.append("")
7020
+ _carried = _collect_carried_spec_deltas(root)
7021
+ L.append(f"Carried ({len(_carried)}) — deferred spec deltas riding across releases (re-triage each cut):")
7022
+ for cd in _carried[:10]:
7023
+ L.append(f" - [{cd['task']}] {cd['text'][:100]}")
7024
+ if len(_carried) > 10:
7025
+ L.append(f" … and {len(_carried) - 10} more — add.py deltas --carried")
7026
+ L.append("")
6566
7027
  L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED riding into the cut, soonest expiry first:")
6567
7028
  for w in d["waivers"]:
6568
7029
  L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}")
@@ -7039,11 +7500,24 @@ def build_parser() -> argparse.ArgumentParser:
7039
7500
  pa.add_argument("--skip-freeze", action="store_true",
7040
7501
  help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
7041
7502
  "marker (the universal freeze gate's only bypass; never auto-freezes §3)")
7503
+ pa.add_argument("--to", default=None,
7504
+ help="fast-forward the bundle bookkeeping to this phase (at most `tests`); "
7505
+ "every crossing guard still runs per step")
7042
7506
  pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
7043
7507
 
7508
+ prx = sub.add_parser("re-cross", help="re-arm the tests->build snapshots after a "
7509
+ "HUMAN-APPROVED post-freeze test change")
7510
+ prx.add_argument("slug", nargs="?", default=None)
7511
+ prx.add_argument("--by", default="",
7512
+ help="the human approver (required — a post-freeze test change is human-approved)")
7513
+ prx.set_defaults(func=cmd_recross, _opt_positionals=("slug",))
7514
+
7044
7515
  pg = sub.add_parser("gate", help="record a verify gate outcome")
7045
- pg.add_argument("outcome", choices=GATES)
7046
- pg.add_argument("slug", nargs="?", default=None)
7516
+ pg.add_argument("outcome", nargs="?", default=None) # validated in cmd_gate (gate-explain
7517
+ pg.add_argument("slug", nargs="?", default=None) # made it optional under --explain)
7518
+ pg.add_argument("--explain", action="store_true",
7519
+ help="READ-ONLY: print the composed auto-pass/escalation path for the task "
7520
+ "(autonomy · risk · sensitivity · advisor verdict), then exit")
7047
7521
  pg.add_argument("--owner", help="RISK-ACCEPTED waiver: accountable owner")
7048
7522
  pg.add_argument("--ticket", help="RISK-ACCEPTED waiver: tracking ticket/link")
7049
7523
  pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
@@ -7064,6 +7538,13 @@ def build_parser() -> argparse.ArgumentParser:
7064
7538
  pst.add_argument("posture", nargs="?", default=None, help="set: <parallel|sequential>")
7065
7539
  pst.set_defaults(func=cmd_streams, _opt_positionals=("posture",))
7066
7540
 
7541
+ pwt = sub.add_parser("worktree-prep",
7542
+ help="cut a worker worktree at HEAD + materialize gitignored "
7543
+ ".add/tooling + .add/docs + echo the fork base (streams.md recipe)")
7544
+ pwt.add_argument("slug", nargs="?", default=None, help="task the worktree is for (default: active)")
7545
+ pwt.add_argument("--dir", default=None, help="worktree path (default: ../<project>-wt-<slug>)")
7546
+ pwt.set_defaults(func=cmd_worktree_prep)
7547
+
7067
7548
  pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
7068
7549
  pto.add_argument("text", nargs="?", default=None,
7069
7550
  help="todo text to capture; omit to LIST open todos")
@@ -7191,7 +7672,8 @@ def build_parser() -> argparse.ArgumentParser:
7191
7672
  help="record one retrospective consolidation of open lessons into the "
7192
7673
  "versioned foundation (stamp + route + version-bump, atomic)")
7193
7674
  pfo.add_argument("--task", help="narrow to one task's open lessons")
7194
- pfo.add_argument("--comp", choices=_COMPETENCY_ORDER, help="narrow to one competency's open lessons")
7675
+ pfo.add_argument("--comp", choices=[*_COMPETENCY_ORDER, "GLOSSARY"],
7676
+ help="narrow to one competency's open lessons, or GLOSSARY for glossary-only")
7195
7677
  pfo.set_defaults(func=cmd_fold)
7196
7678
 
7197
7679
  pgr = sub.add_parser("graduation-report",