@pilotspace/add 1.12.0 → 1.14.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
@@ -228,17 +228,130 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
228
228
  new = text
229
229
  for pat, repl in rules:
230
230
  new = re.sub(pat, repl, new, count=1)
231
- # component-aware-add (per-component-verify): record WHICH green-bar a bound task gated
232
- # against, right after the Outcome line. Unbound / no green_bar -> no line (byte-identical).
231
+ # component-aware-add (per-component-verify) + component-registry-fill: record WHICH green-bar
232
+ # the bound task gated against AND the component's verify COMMAND, right after the Outcome line.
233
+ # Unbound / neither declared -> no line (byte-identical). green-bar-only output is unchanged.
233
234
  _bar = _task_green_bar(root, slug)
234
- if _bar:
235
- _line = f"component: {_task_component(root, slug)} · expected green-bar: {_bar}"
235
+ _vfy = _task_verify(root, slug)
236
+ if _bar or _vfy:
237
+ _parts = [f"component: {_task_component(root, slug)}"]
238
+ if _bar:
239
+ _parts.append(f"expected green-bar: {_bar}")
240
+ if _vfy:
241
+ _parts.append(f"verify: {_vfy}")
242
+ _line = " · ".join(_parts) # deterministic per task -> idempotent on re-stamp
236
243
  if _line not in new:
237
244
  new = re.sub(r"(?m)^(Outcome:.*$)", lambda m: m.group(1) + "\n" + _line, new, count=1)
238
245
  if new != text: # no-op = no write (mtime stable)
239
246
  _atomic_write(f, new)
240
247
 
241
248
 
249
+ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
250
+ """Write-back (adr-at-observe): HARVEST a §7 `### Decisions (ADR)` block from the actor-stamps
251
+ ALREADY in the task — §1 framing (AI) · §3 freeze (human) · §5 strategy-actually-used (AI) · §6
252
+ gate (human|AI by autonomy). HARVEST-not-author: every rendered line is sourced from an existing
253
+ stamp; the engine invents no decision content (NO-EXEC). GRANDFATHER like _stamp_gate_record:
254
+ fills ONLY while the block still holds its `<harvested…>` placeholder line; a resolved/absent
255
+ block or an unreadable file -> a byte-identical no-op (legacy + fast tasks untouched). NEVER
256
+ raises: any per-source parse fault renders "<unrecorded>". Called from cmd_gate AFTER
257
+ _stamp_gate_record (so §6 is already mirrored) and AFTER save_state (state is the source of
258
+ truth; the file only mirrors it, so a write fault never loses the verdict).
259
+
260
+ §7-OBSERVE-scoped (INV-7): the placeholder is matched ONLY inside the "## 7 · OBSERVE" section,
261
+ so a "<harvested at done…>" line elsewhere — e.g. a §3 contract that ILLUSTRATES this very
262
+ feature — is never touched (a file-wide first-match would corrupt the frozen contract; caught
263
+ by dogfooding adr-harvest on itself)."""
264
+ f = root / "tasks" / slug / "TASK.md"
265
+ try:
266
+ text = f.read_text(encoding="utf-8")
267
+ except OSError:
268
+ return # unreadable -> no-op
269
+ sec7 = re.search(r"(?ms)^## 7 · OBSERVE\b.*?(?=\n## \d+ ·|\Z)", text)
270
+ if not sec7:
271
+ return # no §7 OBSERVE (fast / legacy) -> no-op
272
+ m = re.search(r"(?m)^<harvested at done[^\n]*>$", sec7.group(0))
273
+ if not m:
274
+ return # resolved (hand-edited) or absent -> grandfather no-op
275
+ ph_start, ph_end = sec7.start() + m.start(), sec7.start() + m.end()
276
+ UN = "<unrecorded>"
277
+ bodies = _raw_phase_bodies(root, slug)
278
+
279
+ def _framing(): # §1 -> [AI]: chosen + rejected
280
+ try:
281
+ m = re.search(r"(?m)^Framings weighed:[ \t]*(.+)$", bodies.get(1, ""))
282
+ if not m:
283
+ return UN, ""
284
+ chosen, rejected = UN, []
285
+ for p in (s.strip() for s in m.group(1).split("·") if s.strip()):
286
+ cm = re.match(r"(.*?)\s*\(chosen\b.*\)\s*$", p) # "(chosen)" OR "(chosen — rationale)"
287
+ if cm:
288
+ chosen = cm.group(1).strip() or UN
289
+ else:
290
+ rejected.append(p)
291
+ # an UNFILLED §1 is a "<chosen>" placeholder token — degrade to <unrecorded>, but a
292
+ # real framing that merely CONTAINS a "<" (e.g. quoting a type) is kept (faithful capture)
293
+ if chosen is UN or chosen.startswith("<"):
294
+ return UN, ""
295
+ return chosen, " · ".join(rejected)
296
+ except Exception:
297
+ return UN, ""
298
+
299
+ def _freeze(): # §3 -> [human]: "FROZEN @ vN — approved by NAME"
300
+ try:
301
+ m = re.search(r"(?m)^.*FROZEN @ (v\d+).*?approved by ([^\n<]+?)\s*$", bodies.get(3, ""))
302
+ if m:
303
+ return m.group(1), m.group(2).strip()
304
+ except Exception:
305
+ pass
306
+ t = ((state.get("tasks") or {}).get(slug) or {})
307
+ fr = t.get("freeze") or {}
308
+ ver = fr.get("version") or t.get("contract_version")
309
+ return (f"v{ver}" if ver else UN), (fr.get("by") or fr.get("actor") or UN)
310
+
311
+ def _strategy(): # §5 -> [AI]: the value, default "as planned"
312
+ try:
313
+ m = re.search(r"(?m)^Strategy actually used:[ \t]*(.+)$", bodies.get(5, ""))
314
+ if m:
315
+ # UNFILLED is the "<fill at …>" template token; a real value may legitimately
316
+ # contain "<" (quoting `<tag>`, "x < y") and must NOT degrade to the default
317
+ val = m.group(1).strip()
318
+ if val and not val.startswith("<fill"):
319
+ return val
320
+ except Exception:
321
+ pass
322
+ return "as planned"
323
+
324
+ def _gate(): # §6 -> [human|AI]: outcome + reviewer
325
+ try:
326
+ mo = re.search(r"(?m)^Outcome:[ \t]*(\S+)", bodies.get(6, ""))
327
+ outcome = mo.group(1) if mo else (((state.get("tasks") or {}).get(slug) or {}).get("gate") or UN)
328
+ mr = re.search(r"(?m)^Reviewed by:[ \t]*([^·\n<]+)", bodies.get(6, ""))
329
+ rev = mr.group(1).strip() if mr else UN
330
+ except Exception:
331
+ outcome, rev = UN, UN
332
+ am = re.search(r"(?m)^autonomy:[ \t]*(\w+)", text)
333
+ actor = "AI" if (am and am.group(1) == "auto") else "human"
334
+ return outcome, rev, actor
335
+
336
+ try:
337
+ chosen, rejected = _framing()
338
+ fver, fby = _freeze()
339
+ strat = _strategy()
340
+ outcome, rev, gate_actor = _gate()
341
+ except Exception:
342
+ return # never block the gate
343
+ rej = f"; rejected {rejected}" if rejected else ""
344
+ lines = [
345
+ f"- [AI] specify — chose {chosen}{rej}",
346
+ f"- [human] freeze — froze §3 @ {fver} (approved by {fby})",
347
+ f"- [AI] build — strategy used: {strat}",
348
+ f"- [{gate_actor}] verify — gate {outcome} (reviewed by {rev})",
349
+ ]
350
+ new = text[:ph_start] + "\n".join(lines) + text[ph_end:]
351
+ if new != text:
352
+ _atomic_write(f, new)
353
+
354
+
242
355
  # --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
243
356
  from add_engine.guidelines import (
244
357
  _guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
@@ -465,6 +578,83 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
465
578
  print(_next_footer(root, state))
466
579
 
467
580
 
581
+ def _open_spec_delta_indices(text: str) -> list[int]:
582
+ """Every splitlines(keepends=True) index of an `[SPEC · open]` line (carry-delta --all). PURE.
583
+ A SPEC flip preserves line count + position, so these indices stay valid across sequential
584
+ flips."""
585
+ return [i for i, ln in enumerate(text.splitlines(keepends=True))
586
+ if (m := _SPEC_DELTA_RE.match(ln.rstrip("\n"))) and m.group(2) == "open"]
587
+
588
+
589
+ def cmd_carry_delta(args: argparse.Namespace) -> None:
590
+ """DEFER a task's open SPEC delta(s) non-lossily — `[SPEC · open]` -> `[SPEC · carried]`
591
+ + a ` [carried: <reason>]` stamp (delta-drain). A carried delta clears the release floor and
592
+ the `status` staleness count but SURVIVES on disk: retrievable via `add.py deltas --carried`
593
+ and re-activatable via `reopen-delta`. `--reason` is REQUIRED (no silent carry); `--all` carries
594
+ every open delta in the task; `--match` targets the unique one. Validate-then-write."""
595
+ root = _require_root()
596
+ state = load_state(root)
597
+ slug = _resolve_task(state, args.slug) # unknown task -> _die
598
+ reason = (getattr(args, "reason", None) or "").strip()
599
+ if not reason:
600
+ _die("carry_reason_required: carry-delta needs a --reason — a deferral must say why "
601
+ "(it is the breadcrumb a future loop reads)")
602
+ task_md = root / "tasks" / slug / "TASK.md"
603
+ text = task_md.read_text(encoding="utf-8")
604
+ stamp = f"[carried: {reason}]"
605
+ if getattr(args, "all", False):
606
+ idxs = _open_spec_delta_indices(text)
607
+ if not idxs:
608
+ _die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to carry")
609
+ for idx in idxs: # indices stay valid (flip is in-place)
610
+ text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
611
+ _atomic_write(task_md, text)
612
+ print(f"carried {len(idxs)} open SPEC delta(s) in '{slug}' -> [SPEC · carried] ({reason})")
613
+ print(_next_footer(root, state))
614
+ return
615
+ match = getattr(args, "match", None)
616
+ status, idx, _disp = _select_spec_delta(text, match)
617
+ if status == "no_open":
618
+ _die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to carry")
619
+ if status == "no_match": # a --match miss is DISTINCT from no-open
620
+ _die(f"no_matching_spec_delta: no open SPEC delta in '{slug}' matches --match '{match}'")
621
+ if status == "ambiguous":
622
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
623
+ f"'{slug}' — narrow it, or use --all")
624
+ new_text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
625
+ _atomic_write(task_md, new_text)
626
+ print(f"carried the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> "
627
+ f"[SPEC · carried] ({reason})")
628
+ print(_next_footer(root, state))
629
+
630
+
631
+ def cmd_reopen_delta(args: argparse.Namespace) -> None:
632
+ """RE-ACTIVATE a carried SPEC delta — `[SPEC · carried]` -> `[SPEC · open]` (delta-drain).
633
+ The inverse of carry: a deferred delta re-enters the open count + release floor + staleness.
634
+ The ` [carried: …]` breadcrumb is stripped so a re-activated delta reads clean. `--match`
635
+ targets the unique carried delta. Validate-then-write; refuse `no_carried_spec_delta`."""
636
+ root = _require_root()
637
+ state = load_state(root)
638
+ slug = _resolve_task(state, args.slug)
639
+ task_md = root / "tasks" / slug / "TASK.md"
640
+ text = task_md.read_text(encoding="utf-8")
641
+ match = getattr(args, "match", None)
642
+ status, idx, _disp = _select_spec_delta(text, match, status="carried")
643
+ if status in ("no_open", "no_match"):
644
+ _die(f"no_carried_spec_delta: task '{slug}' has no carried SPEC delta to reopen"
645
+ + (f" matching --match '{match}'" if status == "no_match" else ""))
646
+ if status == "ambiguous":
647
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple carried SPEC deltas in "
648
+ f"'{slug}' — narrow it")
649
+ new_text = _resolve_spec_delta(text, "open", line_index=idx, from_status="carried")
650
+ lines = new_text.splitlines(keepends=True) # drop the carried breadcrumb (no accretion)
651
+ eol = lines[idx][len(lines[idx].rstrip("\n")):]
652
+ lines[idx] = re.sub(r"\s*\[carried:[^\]]*\]\s*$", "", lines[idx].rstrip("\n")) + eol
653
+ _atomic_write(task_md, "".join(lines))
654
+ print(f"reopened the {'matched' if match else 'first'} carried SPEC delta in '{slug}' -> [SPEC · open]")
655
+ print(_next_footer(root, state))
656
+
657
+
468
658
  # a §3 still carrying this template placeholder is NOT a drafted contract yet
469
659
  _CONTRACT_TEMPLATE_RE = re.compile(r"<METHOD>")
470
660
 
@@ -562,7 +752,7 @@ def _resolve_task(state: dict, slug: str | None) -> str:
562
752
  return slug
563
753
 
564
754
 
565
- def _build_entry(root: Path, state: dict, slug: str) -> None:
755
+ def _build_entry(root: Path, state: dict, slug: str, skip_freeze: bool = False) -> None:
566
756
  """The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
567
757
 
568
758
  Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
@@ -572,22 +762,28 @@ def _build_entry(root: Path, state: dict, slug: str) -> None:
572
762
  so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
573
763
  and never routes here, so it stays exempt.
574
764
  """
575
- # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
576
- # milestone opted into --await-confirm is held to the trust floor at tests->build. A
577
- # task under a plain / no milestone is never gated (every existing advance-to-build flow
578
- # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
579
- # snapshots below, writing nothing; the task stays at `tests`.
765
+ # the crossing guards. validate-then-write every refusal runs BEFORE the tripwire/scope
766
+ # snapshots below, writing nothing; the task stays at `tests` (the lone exception is the
767
+ # recorded freeze_skipped marker on the explicit --skip-freeze path).
580
768
  _ms = state["tasks"][slug].get("milestone")
581
769
  _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
582
770
  raw3 = _raw_phase_bodies(root, slug).get(3, "")
583
- # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
584
- # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
585
- # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
586
- # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
587
- _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
588
- if _freeze_gated and not _contract_frozen(raw3):
589
- _die("contract_not_frozen: freeze §3 before crossing into build — approve "
590
- f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
771
+ # freeze gate — UNIVERSAL (freeze-gate-universal, flow-honesty): closes audit finding H1.
772
+ # The gate used to be opt-in (`_optin or fast`), so a plain-milestone task could cross
773
+ # tests->build on a DRAFT §3 the method's decision point was engine-enforced for only a
774
+ # subset. It now fires for EVERY task. The ONLY bypass is the RECORDED `--skip-freeze` escape:
775
+ # it stamps an auditable `freeze_skipped` marker (never silent) and never auto-freezes §3
776
+ # (Status stays DRAFT). Still PRECEDES build-expectations: freeze §3 before pre-declaring §6.
777
+ if not _contract_frozen(raw3):
778
+ if not skip_freeze:
779
+ _die("contract_not_frozen: freeze §3 before crossing into build — approve "
780
+ f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN), or pass "
781
+ "--skip-freeze to cross with a recorded skip")
782
+ state["tasks"][slug]["freeze_skipped"] = {
783
+ "by": identity._actor_stamp(state)["name"],
784
+ "at": _now(),
785
+ "from_phase": state["tasks"][slug].get("phase", "tests"),
786
+ }
591
787
  # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
592
788
  # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
593
789
  # not just green. Same opt-in switch as the contract-fill gate, one level out.
@@ -645,7 +841,11 @@ def cmd_phase(args: argparse.Namespace) -> None:
645
841
  # validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
646
842
  # loop sets phase=build directly (never via cmd_phase) and so stays exempt.
647
843
  if args.phase == "build":
648
- _build_entry(root, state, slug)
844
+ _build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
845
+ # cross-component-recency: the `phase contract` override runs the SAME consumer HOLD `advance`
846
+ # runs (existence + recency), so it is not a backdoor around producer_contract_unfrozen/_stale.
847
+ if args.phase == "contract":
848
+ _consumer_contract_hold(root, state, slug)
649
849
  state["tasks"][slug]["phase"] = args.phase
650
850
  state["tasks"][slug]["updated"] = _now()
651
851
  save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
@@ -674,12 +874,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
674
874
  # the HARD-STOP precedes the phase bump, so the task stays at `scenarios`. Undeclared id / no
675
875
  # `consumes:` header -> no hold (byte-identical; a typo'd id is a cmd_check registry finding).
676
876
  if nxt == "contract":
677
- _cid = _task_consumes(root, slug)
678
- _cmap = _contracts(root)
679
- if _cid and _cid in _cmap and not _contract_snapshot(root, _cid).exists():
680
- _die(f"producer_contract_unfrozen: the producer '{_cmap[_cid].get('producer', '?')}' of "
681
- f"contract '{_cid}' must freeze its contract before you write §3 — wait for "
682
- f".add/contracts/{_cid}.json")
877
+ # existence + RECENCY (cross-component-recency): a missing snapshot HARD-STOPs
878
+ # producer_contract_unfrozen; a present-but-stale leftover (live producer drifted/unfroze)
879
+ # HARD-STOPs producer_contract_stale. Shared with cmd_phase so the override is no backdoor.
880
+ _consumer_contract_hold(root, state, slug)
683
881
  # flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
684
882
  # into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
685
883
  # verified marker so `audit` enforces the flag on THIS record only (open/new
@@ -688,8 +886,9 @@ def cmd_advance(args: argparse.Namespace) -> None:
688
886
  if nxt == "build":
689
887
  # the tests->build entry guards + snapshots now live in the shared _build_entry helper
690
888
  # (task phase-build-guard, F4) so `advance` and the `phase build` admin override run the
691
- # IDENTICAL gate stack. Behavior here is byte-unchanged this is a pure extraction.
692
- _build_entry(root, state, slug)
889
+ # IDENTICAL gate stack. `--skip-freeze` (freeze-gate-universal) threads through to the
890
+ # universal freeze gate — the only recorded bypass of the now-mandatory §3 freeze.
891
+ _build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
693
892
  # cross-component contract artifact (cross-component-contract): the contract->tests crossing
694
893
  # is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
695
894
  # a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
@@ -872,10 +1071,14 @@ def cmd_gate(args: argparse.Namespace) -> None:
872
1071
  if completing:
873
1072
  _sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
874
1073
  _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1074
+ _stamp_adr_record(root, state, slug) # adr-at-observe: harvest §7 Decisions (ADR) — AFTER §6 is mirrored
875
1075
  print(f"task '{slug}' gate -> {args.outcome}")
876
1076
  _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
877
1077
  if _gbar:
878
1078
  print(f"component: {_task_component(root, slug)} · expected green-bar: {_gbar}")
1079
+ _vfy = _task_verify(root, slug) # component-registry-fill: surface the verify command
1080
+ if _vfy:
1081
+ print(f"verify: {_vfy} # run this suite — the engine does not (NO-EXEC)")
879
1082
  # the engine-sourced next step (next-footer-engine): a completing gate hands off to the
880
1083
  # state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
881
1084
  print(_next_footer(root, state))
@@ -1427,12 +1630,16 @@ def cmd_status(args: argparse.Namespace) -> None:
1427
1630
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1428
1631
  if open_deltas:
1429
1632
  print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)")
1430
- # SPEC-delta nudge (project-wide): surface unresolved forward hand-offs so a seed/drop
1431
- # can't be silently skipped (read-only; silent when none). Sibling of the fold nudge.
1633
+ # SPEC-delta staleness nudge (project-wide): surface unresolved forward hand-offs as STALE
1634
+ # backpressure so they drain instead of silently accumulating (delta-drain). Read-only;
1635
+ # PRESENT-ONLY (silent when none → byte-identical). The prefix stays `spec :` (the cue the
1636
+ # spec-delta guards pin); the wording now names the staleness + the drain surface, which now
1637
+ # includes carry-delta (defer non-lossily) beside seed/drop.
1432
1638
  open_spec = len(_collect_open_spec_deltas(root))
1433
1639
  if open_spec:
1434
1640
  noun = "delta" if open_spec == 1 else "deltas"
1435
- print(f"spec : {open_spec} open SPEC {noun} — resolve: new-task --from-delta / drop-delta")
1641
+ print(f"spec : {open_spec} open SPEC {noun} — stale; drain via add.py deltas "
1642
+ "(carry-delta / new-task --from-delta / drop-delta)")
1436
1643
  # When the setup is unlocked, the only terminal guidance that matters is
1437
1644
  # review+lock; suppress the generic resume block so it does not compete.
1438
1645
  if unlocked:
@@ -1984,12 +2191,25 @@ def _missing_captures(root: Path) -> list[str]:
1984
2191
  if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
1985
2192
 
1986
2193
 
2194
+ def _federation_source_confined(root: Path, source: str) -> bool:
2195
+ """True iff a federation manifest `source` resolves INSIDE the sibling-repo allowlist — the
2196
+ workspace dir (root.parent.parent) that holds this project and its sibling checkouts. A legit
2197
+ cross-repo source is one level up + down (`../<repo>/…`); an absolute path or a `../../` escape
2198
+ resolves OUTSIDE and returns False. PURE + TOTAL — never raises (every error, incl. an embedded
2199
+ NUL, → False, fail-closed), so cmd_federate can gate on it BEFORE any read (federation-harden)."""
2200
+ try:
2201
+ allow = root.parent.parent.resolve()
2202
+ return _confined(root.parent / source, allow)
2203
+ except (OSError, ValueError):
2204
+ return False
2205
+
2206
+
1987
2207
  def cmd_federate(args: argparse.Namespace) -> None:
1988
2208
  """Multi-repo federation: pull a producer repo's published, immutable contract snapshot into
1989
2209
  this repo. Mono vs multi-repo differ ONLY in snapshot-transport — this lands the byte-copy at
1990
2210
  the SAME local `.add/contracts/<id>.json` the monorepo path (tasks 3/4) already reads, so a
1991
- `consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / missing /
1992
- invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
2211
+ `consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / escaping /
2212
+ missing / invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
1993
2213
  root = find_root()
1994
2214
  if root is None:
1995
2215
  _die("no_project")
@@ -1998,6 +2218,13 @@ def cmd_federate(args: argparse.Namespace) -> None:
1998
2218
  if fid not in fed:
1999
2219
  _die(f"federation_unknown: no [federation.{fid}] in components.toml — declare the producer "
2000
2220
  f"repo's published snapshot source before pulling")
2221
+ # federation-harden: confine the source to the sibling-repo allowlist BEFORE any read — an
2222
+ # absolute path or a `../../` traversal must HARD-STOP and land nothing (never read a path
2223
+ # that escapes the project's sibling repos). Checked after the id lookup, before read_bytes.
2224
+ if not _federation_source_confined(root, fed[fid]["source"]):
2225
+ _die(f"federation_source_escapes: the source '{fed[fid]['source']}' for '{fid}' resolves "
2226
+ f"outside the sibling-repo allowlist (the workspace dir) — refusing to read a path "
2227
+ f"that escapes the project's sibling repos")
2001
2228
  source = (root.parent / fed[fid]["source"])
2002
2229
  try:
2003
2230
  raw = source.read_bytes() # bytes — the landed snapshot must be a byte-for-byte copy
@@ -2019,6 +2246,47 @@ def cmd_federate(args: argparse.Namespace) -> None:
2019
2246
  print(f"federated '{fid}' {snap.get('version', '?')} {snap['hash']} from {fed[fid]['source']}")
2020
2247
 
2021
2248
 
2249
+ def cmd_components(args: argparse.Namespace) -> None:
2250
+ """Read-only: print + validate the component registry (.add/components.toml).
2251
+
2252
+ Opt-in — with no registry this is a friendly single-component no-op (exit 0). Prints
2253
+ the parsed components/contracts/federation, then the existing RED integrity findings
2254
+ (_component_findings + _contract_findings), then the new schema-lint WARNs
2255
+ (_component_schema_findings); exits 1 IFF a RED finding exists (a typo/WARN never fails).
2256
+ NO-EXEC — `verify` is shown as data, never run. Reads no docs/ chapter."""
2257
+ root = find_root()
2258
+ if root is None:
2259
+ _die("no_project")
2260
+ if not (root / "components.toml").exists():
2261
+ print("single-component project (no components.toml) — nothing to validate")
2262
+ return
2263
+ comps, cons, feds = _components(root), _contracts(root), _federation(root)
2264
+ for name in sorted(comps):
2265
+ c = comps[name]
2266
+ print(f"component {name} root={c['root']} verify={c.get('verify') or '-'} "
2267
+ f"green_bar={c.get('green_bar') or '-'} language={c.get('language') or '-'}")
2268
+ for cid in sorted(cons):
2269
+ c = cons[cid]
2270
+ print(f"contract {cid} producer={c['producer']} consumers={c['consumers']}")
2271
+ for fid in sorted(feds):
2272
+ f = feds[fid]
2273
+ print(f"federation {fid} source={f['source']} pin={f.get('pin') or '-'}")
2274
+ reds = sorted(_component_findings(root) + _contract_findings(root))
2275
+ warns = sorted(_component_schema_findings(root))
2276
+ for code, detail in reds:
2277
+ print(f"ERROR {code}: {detail}")
2278
+ for code, detail in warns:
2279
+ print(f"WARN {code}: {detail}")
2280
+ head = f"components: {len(comps)} · contracts: {len(cons)} · federation: {len(feds)}"
2281
+ if reds or warns:
2282
+ seg = ([f"{len(reds)} error(s)"] if reds else []) + ([f"{len(warns)} warning(s)"] if warns else [])
2283
+ print(f"{head} — {', '.join(seg)}")
2284
+ else:
2285
+ print(f"{head} — valid")
2286
+ if reds:
2287
+ raise SystemExit(1)
2288
+
2289
+
2022
2290
  def cmd_check(args: argparse.Namespace) -> None:
2023
2291
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
2024
2292
  as_json = getattr(args, "json", False)
@@ -2095,6 +2363,28 @@ def cmd_check(args: argparse.Namespace) -> None:
2095
2363
  warnings.append((f"task '{slug}'", f"contract_consumer_stale — pinned contract "
2096
2364
  f"'{_pin.get('id')}' changed shape since pin; re-pin (re-cross contract→tests) "
2097
2365
  "after reviewing the producer's new frozen shape"))
2366
+ # cross-component-recency: a consumer whose landed snapshot's LIVE producer drifted/unfroze
2367
+ # since the snapshot — surfaced EARLY (never red) before the consumer re-enters §3, the
2368
+ # check twin of the producer_contract_stale advance HARD-STOP. Degrade-safe.
2369
+ _ccons = _task_consumes(root, slug)
2370
+ if _ccons and _ccons in _contracts(root):
2371
+ _csnap = _contract_snapshot(root, _ccons)
2372
+ if _csnap.exists():
2373
+ try:
2374
+ _chash = json.loads(_csnap.read_text(encoding="utf-8")).get("hash")
2375
+ except (OSError, ValueError, AttributeError):
2376
+ _chash = None
2377
+ if _chash is None:
2378
+ # cross-component-recency R1: a present-but-hash-less snapshot degrades the
2379
+ # recency check to existence-only (frozen behavior) — SURFACE the blind spot
2380
+ # (never red) so a hand-tampered/hash-stripped snapshot is not silently trusted.
2381
+ warnings.append((f"task '{slug}'", f"contract_snapshot_hashless — the landed "
2382
+ f"snapshot for '{_ccons}' carries no hash; recency cannot be "
2383
+ "verified (re-publish the producer contract / re-cross contract→tests)"))
2384
+ elif _producer_snapshot_stale(root, _ccons, _chash):
2385
+ warnings.append((f"task '{slug}'", f"contract_producer_stale — the live producer "
2386
+ f"of '{_ccons}' changed or re-opened its §3 since the landed "
2387
+ "snapshot; re-cross the producer contract→tests, then re-enter"))
2098
2388
  for dep in t.get("depends_on") or []:
2099
2389
  checks.append((dep in tasks or dep in archived_slugs,
2100
2390
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -2222,11 +2512,23 @@ def cmd_check(args: argparse.Namespace) -> None:
2222
2512
  # is a BROKEN JOIN — surface it EARLY as a WARN (never red alone; `federate pull` is where it
2223
2513
  # HARD-STOPs). Silent when no federation is declared (opt-in / byte-identical).
2224
2514
  for _fid, _fspec in _federation(root).items():
2225
- if not (root.parent / _fspec["source"]).is_file():
2515
+ if not _federation_source_confined(root, _fspec["source"]):
2516
+ # federation-harden: an out-of-allowlist source surfaced EARLY as a never-red WARN
2517
+ # (escape takes precedence over unreadable — a real /etc/passwd passes is_file()).
2518
+ warnings.append((f"federation '{_fid}'",
2519
+ f"federation_source_escapes — '{_fspec['source']}' resolves outside the "
2520
+ f"sibling-repo allowlist; `federate pull {_fid}` will HARD-STOP"))
2521
+ elif not (root.parent / _fspec["source"]).is_file():
2226
2522
  warnings.append((f"federation '{_fid}'",
2227
2523
  f"federation_source_unreadable — the producer snapshot at "
2228
2524
  f"'{_fspec['source']}' is missing/unreadable; `federate pull {_fid}` "
2229
2525
  "will hard-stop until the producer repo publishes it"))
2526
+ # components-validator: the schema-lint (typo'd/unknown key · wrong-type value · unknown
2527
+ # table) is a never-red WARN — measure-not-block, forward-compat-safe. Rides `warnings`,
2528
+ # NEVER `checks`/`failed`. Silent when no components.toml. `add.py components` is the
2529
+ # richer surface; this catches the same typos EARLY in CI without failing the build.
2530
+ for _scode, _sdetail in _component_schema_findings(root):
2531
+ warnings.append((_scode, _sdetail))
2230
2532
 
2231
2533
  # UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
2232
2534
  # composes the token + catalog/tree validators + the cross-file prop-token resolution.
@@ -3377,6 +3679,16 @@ def _task_green_bar(root: Path, slug: str) -> str | None:
3377
3679
  return (_components(root).get(comp) or {}).get("green_bar") or None
3378
3680
 
3379
3681
 
3682
+ def _task_verify(root: Path, slug: str) -> str | None:
3683
+ """The `verify` COMMAND of the task's bound component (component-registry-fill) — the literal
3684
+ suite the operator runs at the gate. Twin of _task_green_bar: unbound / "?" / no verify
3685
+ declared all yield None. PURE. SURFACED as data only — the engine NEVER executes it (NO-EXEC)."""
3686
+ comp = _task_component(root, slug)
3687
+ if not comp or comp == "?":
3688
+ return None
3689
+ return (_components(root).get(comp) or {}).get("verify") or None
3690
+
3691
+
3380
3692
  def _component_findings(root: Path) -> list[tuple[str, str]]:
3381
3693
  """The loud gate surface for the registry — the codes a degrade-safe read passes
3382
3694
  over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
@@ -3429,6 +3741,64 @@ def _task_consumes(root: Path, slug: str) -> str | None:
3429
3741
  return m.group(1).strip() if m else None
3430
3742
 
3431
3743
 
3744
+ def _producer_snapshot_stale(root: Path, cid: str, snap_hash: str | None) -> bool:
3745
+ """True IFF a LIVE producer task backs `cid` (some task under root/tasks/ whose header carries
3746
+ `produces: cid`) AND that producer's current §3 is NOT frozen, OR its frozen body-hash differs
3747
+ from `snap_hash` (the landed snapshot's hash) — i.e. the producer re-opened/changed its contract
3748
+ since the snapshot was written (cross-component-recency). No live producer task (archived in a
3749
+ prior milestone, or a federation/external snapshot) -> False = existence-only, recency is the
3750
+ producer repo's job via the federation version pin. snap_hash None / unreadable §3 -> False (not
3751
+ confirmable here). PURE + TOTAL — never raises (a read error is a non-confirmation, not a block)."""
3752
+ if snap_hash is None:
3753
+ return False
3754
+ tasks_dir = root / "tasks"
3755
+ if not tasks_dir.is_dir():
3756
+ return False
3757
+ try:
3758
+ producers = sorted(td.name for td in tasks_dir.iterdir() if td.is_dir())
3759
+ except OSError:
3760
+ return False
3761
+ for pslug in producers:
3762
+ if _task_produces(root, pslug) != cid:
3763
+ continue
3764
+ raw3 = _raw_phase_bodies(root, pslug).get(3, "")
3765
+ if "FROZEN @" not in raw3:
3766
+ return True # live producer exists but its §3 is not frozen
3767
+ return _contract_body_hash(raw3) != snap_hash # frozen but drifted from the landed snapshot
3768
+ return False # no live producer backs cid -> existence-only
3769
+
3770
+
3771
+ def _consumer_contract_hold(root: Path, state: dict, slug: str) -> None:
3772
+ """The cross-component consumer HOLD at the §3 boundary, shared by cmd_advance (nxt=="contract")
3773
+ and cmd_phase (phase=="contract") so the admin override is not a backdoor (mirrors the
3774
+ phase build -> _build_entry precedent). A `consumes: <cid>` task targeting a DECLARED contract:
3775
+ a MISSING snapshot HARD-STOPs `producer_contract_unfrozen` (producer hasn't frozen); a PRESENT
3776
+ snapshot whose live producer has drifted/unfrozen HARD-STOPs `producer_contract_stale`
3777
+ (cross-component-recency — a stale leftover must not admit a consumer). No consumes / undeclared
3778
+ id -> return (byte-identical; a typo'd id is a cmd_check finding). `state` is accepted for call-site
3779
+ symmetry with _build_entry (unused here — the hold reads the task + contract files). Validate-
3780
+ then-write: every _die precedes the caller's phase bump, so a refused task does not move."""
3781
+ cid = _task_consumes(root, slug)
3782
+ if not cid:
3783
+ return
3784
+ cmap = _contracts(root)
3785
+ if cid not in cmap:
3786
+ return
3787
+ snap = _contract_snapshot(root, cid)
3788
+ if not snap.exists():
3789
+ _die(f"producer_contract_unfrozen: the producer '{cmap[cid].get('producer', '?')}' of "
3790
+ f"contract '{cid}' must freeze its contract before you write §3 — wait for "
3791
+ f".add/contracts/{cid}.json")
3792
+ try:
3793
+ snap_hash = json.loads(snap.read_text(encoding="utf-8")).get("hash")
3794
+ except (OSError, ValueError, AttributeError):
3795
+ snap_hash = None
3796
+ if _producer_snapshot_stale(root, cid, snap_hash):
3797
+ _die(f"producer_contract_stale: the live producer of contract '{cid}' changed or re-opened "
3798
+ f"its §3 since the landed .add/contracts/{cid}.json — re-cross the producer "
3799
+ "contract->tests to refresh the snapshot, then re-enter (never build against a stale shape)")
3800
+
3801
+
3432
3802
  def _contract_body_hash(raw3: str) -> str:
3433
3803
  """md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
3434
3804
  version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
@@ -3449,6 +3819,67 @@ def _contract_findings(root: Path) -> list[tuple[str, str]]:
3449
3819
  return findings
3450
3820
 
3451
3821
 
3822
+ # ── components.toml schema-lint (components-validator) ─────────────────────────────────
3823
+ # The SOFT typo surface: the keys/tables a DEGRADE-SAFE read silently drops. All three
3824
+ # codes are WARN-severity (measure-not-block) — forward-compat-safe (an older engine
3825
+ # reading a newer file flags, never fails). Surfaced at `check` (as warnings) AND by
3826
+ # `add.py components`. PURE · DEGRADE-SAFE · NO-EXEC: reads only .add/components.toml,
3827
+ # never raises, never executes `verify`. A parse break is _component_findings' RED job.
3828
+ _SCHEMA_KNOWN_KEYS: dict[str, tuple[str, ...]] = {
3829
+ "component": ("root", "verify", "green_bar", "language"),
3830
+ "contract": ("producer", "consumers"),
3831
+ "federation": ("source", "pin"),
3832
+ }
3833
+ _SCHEMA_KEY_TYPES: dict[str, dict[str, type]] = {
3834
+ # `root` is intentionally absent — a missing/non-str root is already RED `components_malformed`
3835
+ "component": {"verify": str, "green_bar": str, "language": str},
3836
+ "contract": {"producer": str, "consumers": list},
3837
+ "federation": {"source": str, "pin": str},
3838
+ }
3839
+ _SCHEMA_TYPENAME = {str: "a string", list: "a list"}
3840
+
3841
+
3842
+ def _component_schema_findings(root: Path) -> list[tuple[str, str]]:
3843
+ """Schema-lint the components.toml registry for typos a degrade-safe read drops:
3844
+ an unknown/misspelled key, a wrong-type value on a known key, or an unrecognized
3845
+ top-level table. Returns [(code, detail)] (deterministic file order); [] when the
3846
+ file is absent / opted-out / unparseable (no double-report with the RED surface).
3847
+ All codes WARN: component_unknown_key · component_type_mismatch · component_unknown_table."""
3848
+ if tomllib is None:
3849
+ return []
3850
+ try:
3851
+ data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
3852
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
3853
+ return [] # a parse break is _component_findings' RED job
3854
+ if not isinstance(data, dict):
3855
+ return []
3856
+ out: list[tuple[str, str]] = []
3857
+ for top in data: # 1. unrecognized top-level tables/keys
3858
+ if top not in _SCHEMA_KNOWN_KEYS:
3859
+ out.append(("component_unknown_table",
3860
+ f"top-level [{top}] is not a known table "
3861
+ f"(expected one of: {', '.join(_SCHEMA_KNOWN_KEYS)})"))
3862
+ for table, known in _SCHEMA_KNOWN_KEYS.items(): # 2/3. per-entry keys + value types
3863
+ entries = data.get(table)
3864
+ if not isinstance(entries, dict):
3865
+ continue
3866
+ types = _SCHEMA_KEY_TYPES[table]
3867
+ for name, spec in entries.items():
3868
+ if not isinstance(spec, dict):
3869
+ continue # a non-table entry is the RED surface's job
3870
+ for key, val in spec.items():
3871
+ if key not in known:
3872
+ out.append(("component_unknown_key",
3873
+ f"[{table}.{name}] has unknown key {key!r} "
3874
+ f"(known: {', '.join(known)})"))
3875
+ elif key in types and not isinstance(val, types[key]):
3876
+ out.append(("component_type_mismatch",
3877
+ f"[{table}.{name}].{key} should be "
3878
+ f"{_SCHEMA_TYPENAME.get(types[key], types[key].__name__)}, "
3879
+ f"got {type(val).__name__}"))
3880
+ return out
3881
+
3882
+
3452
3883
  def _declared_scope(root: Path, slug: str) -> list[str] | None:
3453
3884
  """Resolve the §5 'Scope (may touch):' declaration to project-root-relative
3454
3885
  strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
@@ -4353,7 +4784,7 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
4353
4784
  # dismissed (dropped) — never consolidated into the foundation. _STATUS_SETS keys each
4354
4785
  # tag to its legal status set so the ONE lint can reject a cross-set pairing
4355
4786
  # ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
4356
- _SPEC_STATUSES = ("open", "seeded", "dropped")
4787
+ _SPEC_STATUSES = ("open", "seeded", "dropped", "carried")
4357
4788
  _STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
4358
4789
 
4359
4790
  # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
@@ -4509,13 +4940,13 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
4509
4940
  return by_comp
4510
4941
 
4511
4942
 
4512
- def _collect_open_spec_deltas(root: Path) -> list[dict]:
4513
- """Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
4943
+ def _collect_spec_deltas(root: Path, status: str = "open") -> list[dict]:
4944
+ """Scan every .add/tasks/*/TASK.md "### Spec delta" block for SPEC deltas of `status`.
4514
4945
 
4515
- Returns a FLAT list of {task, text, evidence} dicts (SPEC is one tag, never
4516
- bucketed by competency). A SPEC delta is a forward hand-off that resolves into
4517
- a TASK never consolidated into the foundation so it is collected SEPARATELY from
4518
- _collect_open_deltas. READ-ONLY; never mutates any file."""
4946
+ Returns a FLAT list of {task, text, evidence} dicts (SPEC is one tag, never bucketed by
4947
+ competency). A SPEC delta is a forward hand-off that resolves into a TASK (seeded), is
4948
+ dismissed (dropped), or is DEFERRED non-lossily (carried)the open/carried VIEWS are this
4949
+ one scan keyed on `status`. READ-ONLY; never mutates any file."""
4519
4950
  out: list[dict] = []
4520
4951
  tasks_dir = root / "tasks"
4521
4952
  if not tasks_dir.is_dir():
@@ -4528,7 +4959,7 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4528
4959
  continue
4529
4960
  for unit in _spec_delta_entries(text):
4530
4961
  m = _SPEC_DELTA_RE.match(unit[0])
4531
- if m.group(2) != "open": # seeded / dropped are resolved excluded
4962
+ if m.group(2) != status: # other statuses are excluded from this view
4532
4963
  continue
4533
4964
  tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
4534
4965
  em = _EVIDENCE_RE.match(tail)
@@ -4540,51 +4971,69 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4540
4971
  return out
4541
4972
 
4542
4973
 
4543
- # The FIRST writer of the seeded/dropped statuses (task 1 only TOLERATED them on read).
4544
- # seed-and-drop's resolution verbs both route through here.
4545
- _SPEC_OPEN_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)open(\s*\])")
4974
+ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4975
+ """Open SPEC deltas — the release-floor + `deltas` + `status` count source (a thin view)."""
4976
+ return _collect_spec_deltas(root, "open")
4977
+
4978
+
4979
+ def _collect_carried_spec_deltas(root: Path) -> list[dict]:
4980
+ """Carried (deferred, non-lossy) SPEC deltas — the `deltas --carried` retrieval surface."""
4981
+ return _collect_spec_deltas(root, "carried")
4982
+
4983
+
4984
+ # The FIRST writer of the seeded/dropped/carried statuses (task 1 only TOLERATED them on read).
4985
+ # seed-and-drop's resolution verbs AND delta-drain's carry/reopen all route through here. The token
4986
+ # regex matches ANY current SPEC status, so the flip works in either direction (open->carried,
4987
+ # carried->open) — not only away from open.
4988
+ _SPEC_STATUS_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)(?:open|seeded|dropped|carried)(\s*\])")
4546
4989
 
4547
4990
 
4548
4991
  def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None,
4549
- line_index: int | None = None) -> str | None:
4550
- """Flip ONE `[SPEC · open]` line in `text` to `new_status`; return the new text.
4992
+ line_index: int | None = None, *, from_status: str = "open",
4993
+ stamp: str | None = None) -> str | None:
4994
+ """Flip ONE `[SPEC · <from_status>]` line in `text` to `new_status`; return the new text.
4551
4995
 
4552
4996
  PURE — no IO. With `line_index` (a splitlines(keepends=True) index, as `_select_spec_delta`
4553
- returns) flip THAT line; without it, flip the FIRST open delta (back-compat). Only the status
4554
- token changes (+ a trailing ` [→ <pointer>]` provenance stamp when seeding); the entry's text
4555
- and `(evidence: …)` are byte-preserved. Returns None when there is NO open SPEC delta to flip —
4997
+ returns) flip THAT line; without it, flip the FIRST `from_status` delta (back-compat;
4998
+ `from_status` defaults to "open"). Only the status token changes (+ a trailing ` [→ <pointer>]`
4999
+ seed stamp, or a free-form ` <stamp>` e.g. `[carried: <reason>]`); the entry's text and
5000
+ `(evidence: …)` are byte-preserved. Returns None when there is NO matching delta to flip —
4556
5001
  the caller then refuses and writes nothing. Mirrors the `_autonomy_decl_line` pure-transform."""
4557
5002
  lines = text.splitlines(keepends=True)
4558
5003
  target = line_index
4559
- if target is None: # back-compat: the FIRST open delta
5004
+ if target is None: # back-compat: the FIRST from_status delta
4560
5005
  for i, ln in enumerate(lines):
4561
5006
  m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4562
- if m and m.group(2) == "open":
5007
+ if m and m.group(2) == from_status:
4563
5008
  target = i
4564
5009
  break
4565
5010
  if target is None:
4566
5011
  return None
4567
5012
  ln = lines[target]
4568
5013
  eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
4569
- body = _SPEC_OPEN_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
5014
+ body = _SPEC_STATUS_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
4570
5015
  if pointer:
4571
5016
  body = f"{body} [→ {pointer}]"
5017
+ if stamp:
5018
+ body = f"{body} {stamp}"
4572
5019
  lines[target] = body + eol
4573
5020
  return "".join(lines)
4574
5021
 
4575
5022
 
4576
- def _select_spec_delta(text: str, match: str | None = None) -> tuple[str, int | None, str | None]:
4577
- """Pick the open SPEC delta to resolve (delta-match-selector). PURE no IO.
5023
+ def _select_spec_delta(text: str, match: str | None = None,
5024
+ status: str = "open") -> tuple[str, int | None, str | None]:
5025
+ """Pick the SPEC delta (of `status`, default "open") to resolve (delta-match-selector). PURE.
4578
5026
 
4579
- `match=None` -> the FIRST open delta. `match=<substr>` -> the UNIQUE open delta whose display
4580
- text (status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
4581
- (status, line_index, display_text) where status is one of: "ok" (line_index/display set),
4582
- "no_open" (no open delta at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
4583
- line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips."""
5027
+ `match=None` -> the FIRST such delta. `match=<substr>` -> the UNIQUE one whose display text
5028
+ (status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
5029
+ (result, line_index, display_text) where result is one of: "ok" (line_index/display set),
5030
+ "no_open" (none of `status` at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
5031
+ line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips. (The
5032
+ "no_open" token is status-agnostic; the carried-track caller maps it to `no_carried_spec_delta`.)"""
4584
5033
  opens: list[tuple[int, str]] = []
4585
5034
  for i, ln in enumerate(text.splitlines(keepends=True)):
4586
5035
  m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4587
- if not m or m.group(2) != "open":
5036
+ if not m or m.group(2) != status:
4588
5037
  continue
4589
5038
  tail = m.group(3).strip()
4590
5039
  cut = tail.find("(evidence:") # exclude the evidence tail even if its paren is unclosed
@@ -4870,24 +5319,96 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
4870
5319
  for k in ("owner", "ticket", "expires")):
4871
5320
  f(slug, "waiver_incomplete",
4872
5321
  "RISK-ACCEPTED needs owner · ticket · expires")
5322
+ # adr-at-observe: a gated/done task whose §7 Decisions (ADR) block STILL holds its bare
5323
+ # <harvested…> placeholder never harvested its decision record. GRANDFATHER (INV-1): a §7
5324
+ # with NO block is legacy → skipped. BARE-LINE probe (INV-2, the same regex _stamp_adr_record
5325
+ # writes through) → harvested prose that merely quotes "<harvested at done" is not a false hit.
5326
+ s7 = raw.get(7, "")
5327
+ if "### Decisions (ADR)" in s7 and re.search(r"(?m)^<harvested at done[^\n]*>$", s7):
5328
+ f(slug, "adr_record_missing",
5329
+ "§7 Decisions (ADR) block still holds its <harvested…> placeholder (never harvested at gate)")
4873
5330
  return checked, findings
4874
5331
 
4875
5332
 
5333
+ def _freeze_skip_notices(state: dict) -> list[dict]:
5334
+ """The recorded `--skip-freeze` crossings (freeze-gate-universal): tasks that crossed
5335
+ tests->build on a DRAFT §3 via the explicit escape. SURFACED by `add.py audit` so no skip is
5336
+ silent — but NOT a finding: a recorded, sanctioned bypass never fails CI; the human judges it.
5337
+ PURE — reads only."""
5338
+ out = []
5339
+ for slug in sorted(state.get("tasks") or {}):
5340
+ mark = (state["tasks"][slug] or {}).get("freeze_skipped")
5341
+ if isinstance(mark, dict):
5342
+ out.append({"task": slug, "by": mark.get("by", "?"),
5343
+ "at": mark.get("at", "?"), "from_phase": mark.get("from_phase", "?")})
5344
+ return out
5345
+
5346
+
5347
+ # Any `risk:` declaration in the header (high|normal|low|…) — read from the `·`-delimited header
5348
+ # region only (mirrors _RISK_HIGH_RE's anchoring so a title substring can never look like one).
5349
+ _RISK_ANY_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*\S", re.MULTILINE)
5350
+
5351
+
5352
+ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
5353
+ """PRESENCE-ONLY, MEASURE-NOT-BLOCK lints SURFACED (never failed-on) by `add.py audit`
5354
+ (guarantee-audit-lints). For tasks that reached verify (phase ∈ {verify, observe, done}):
5355
+ shallow[] = §6 '### Deep checks' block present-but-unfilled (_section_unfilled; an
5356
+ ABSENT block grandfathers a legacy task — never retro-flagged);
5357
+ risk_unset[] = the header carries NO `risk:` token (an undeclared risk level at verify);
5358
+ refute_unrecorded[]= §6 '### Refute-read verdict' block present-but-unfilled (self-grading-
5359
+ refute-record, M4) — the earned-green verdict the AI must record under
5360
+ `auto`; ABSENT block grandfathers exactly like shallow. MEASURE-NOT-BLOCK:
5361
+ never auto-blocks a gate, only surfaced here for review + a human spot-audit.
5362
+ Honest visibility for three verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
5363
+ TASK.md + state only, writes nothing."""
5364
+ shallow, risk_unset, refute_unrecorded = [], [], []
5365
+ for slug in sorted(state.get("tasks") or {}):
5366
+ if (state["tasks"][slug] or {}).get("phase") not in ("verify", "observe", "done"):
5367
+ continue
5368
+ body6 = _raw_phase_bodies(root, slug).get(6, "")
5369
+ if _section_unfilled(body6, "### Deep checks"):
5370
+ shallow.append(slug)
5371
+ if _section_unfilled(body6, "### Refute-read verdict"):
5372
+ refute_unrecorded.append(slug)
5373
+ if not _RISK_ANY_RE.search(_task_header(root, slug)):
5374
+ risk_unset.append(slug)
5375
+ return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded}
5376
+
5377
+
4876
5378
  def cmd_audit(args: argparse.Namespace) -> None:
4877
5379
  """Read-only: audit recorded human decision points for well-formedness. Exit 0 clean,
4878
- exit 1 with findings — the enforcement gate CI consumes (audit-ci). Writes
4879
- NOTHING; every other command is byte-identical."""
5380
+ exit 1 with findings — the enforcement gate CI consumes (audit-ci). Also SURFACES (never
5381
+ fails on) recorded `--skip-freeze` crossings, so a skipped freeze is visible in review.
5382
+ Writes NOTHING; every other command is byte-identical."""
4880
5383
  root = _require_root()
4881
- checked, findings = _audit_findings(root, load_state(root))
5384
+ state = load_state(root)
5385
+ checked, findings = _audit_findings(root, state)
5386
+ skips = _freeze_skip_notices(state)
5387
+ glints = _guarantee_lint_notices(root, state)
4882
5388
  if getattr(args, "json", False):
4883
- print(json.dumps({"checked": checked, "findings": findings},
4884
- ensure_ascii=False, indent=2))
5389
+ print(json.dumps({"checked": checked, "findings": findings, "freeze_skips": skips,
5390
+ "guarantee_lints": glints}, ensure_ascii=False, indent=2))
4885
5391
  else:
4886
- if findings:
4887
- for x in findings:
4888
- print(f"audit: {x['code']} {x['task']} — {x['detail']}")
4889
- else:
5392
+ for x in findings:
5393
+ print(f"audit: {x['code']} {x['task']} — {x['detail']}")
5394
+ for s in skips:
5395
+ print(f"audit: freeze_skipped {s['task']} — crossed tests->build with a DRAFT §3 "
5396
+ f"(by {s['by']} at {s['at']})")
5397
+ for slug in glints["shallow"]:
5398
+ print(f"audit: shallow_deep_check {slug} — §6 Deep-checks block unfilled "
5399
+ f"(a shallow verify, not a pass)")
5400
+ if glints["risk_unset"]:
5401
+ rs = glints["risk_unset"]
5402
+ print(f"audit: risk_unset — {len(rs)} task(s) reached verify with no risk: "
5403
+ f"declaration: {', '.join(rs)}")
5404
+ if glints["refute_unrecorded"]:
5405
+ ru = glints["refute_unrecorded"]
5406
+ print(f"audit: refute_unrecorded — {len(ru)} task(s): {', '.join(ru)} "
5407
+ f"— record the earned-green refute verdict (§6); a spot-audit is the backstop")
5408
+ if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
5409
+ and not glints["refute_unrecorded"]:
4890
5410
  print(f"audit: clean ({checked} tasks checked)")
5411
+ # MEASURE-NOT-BLOCK: only real findings raise the exit code; notices never do.
4891
5412
  if findings:
4892
5413
  sys.exit(1)
4893
5414
 
@@ -5156,6 +5677,15 @@ def release_data(root: Path, state: dict) -> dict:
5156
5677
  # loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
5157
5678
  loose = _releasable_loose_tasks(root, state)
5158
5679
 
5680
+ # open_spec_deltas — unresolved SPEC deltas riding into the cut (the forceable floor's count
5681
+ # source; one record-set so the floor + release-report can never disagree). GATHER, never judge.
5682
+ # LIVE-only: a SPEC delta blocks the cut only while its task is in active state. Archived tasks
5683
+ # are PASS-done history (their deltas stay preserved + visible PROJECT-WIDE in `add.py deltas` /
5684
+ # the `status` cue, but never block a fresh release — they cannot be cleared by the live-scoped
5685
+ # carry/drop verbs). This makes the floor count == the set the CLI can actually drain.
5686
+ live_tasks = state.get("tasks") or {}
5687
+ open_deltas = [d for d in _collect_open_spec_deltas(root) if d["task"] in live_tasks]
5688
+
5159
5689
  return {
5160
5690
  "releasable": releasable,
5161
5691
  "changed": changed,
@@ -5163,9 +5693,12 @@ def release_data(root: Path, state: dict) -> dict:
5163
5693
  "blockers": blockers,
5164
5694
  "monitors": monitors,
5165
5695
  "loose": loose,
5696
+ "open_spec_deltas": {"count": len(open_deltas),
5697
+ "items": [{"task": d["task"], "text": d["text"]} for d in open_deltas]},
5166
5698
  "summary": {
5167
5699
  "releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
5168
5700
  "blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
5701
+ "open_spec_deltas": len(open_deltas),
5169
5702
  },
5170
5703
  }
5171
5704
 
@@ -5241,8 +5774,8 @@ def cmd_release(args: argparse.Namespace) -> None:
5241
5774
  "never shipped. Resolve it (a change request back to Specify) before releasing. "
5242
5775
  "--force does NOT override this.")
5243
5776
  if not forced and _build_in_flight(state):
5244
- _die("release_tests_red: a build is in flight without a recorded green gate — finish and "
5245
- "gate it first, or pass --force to override.")
5777
+ _die("release_build_in_flight: a build is in flight without a recorded green gate — finish "
5778
+ "and gate it first, or pass --force to override.")
5246
5779
  bundle = _releasable(root, state)
5247
5780
  loose_bundle = _releasable_loose_tasks(root, state)
5248
5781
  if not forced and not bundle and not loose_bundle:
@@ -5252,6 +5785,12 @@ def cmd_release(args: argparse.Namespace) -> None:
5252
5785
  if not forced and d["waivers"] and not disclosed:
5253
5786
  _die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
5254
5787
  "--with-waivers to disclose it in the notes, or --force to override.")
5788
+ open_spec = d["open_spec_deltas"]["count"]
5789
+ if not forced and open_spec > 0:
5790
+ _die(f"release_open_spec_deltas: {open_spec} open SPEC delta(s) unresolved — drain them "
5791
+ "first (carry-delta / new-task --from-delta / drop-delta; see `add.py deltas`), or "
5792
+ "pass --force to cut anyway (they ride into the release unresolved). Unlike "
5793
+ "release_security_open, this floor IS forceable.")
5255
5794
 
5256
5795
  # ── RECORD — build both contents in memory, then write CHANGELOG, then RELEASES (commit) ──
5257
5796
  day = date.today().isoformat()
@@ -5286,16 +5825,22 @@ def cmd_release(args: argparse.Namespace) -> None:
5286
5825
 
5287
5826
 
5288
5827
  def cmd_deltas(args: argparse.Namespace) -> None:
5289
- """Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY.
5290
-
5291
- Scans every .add/tasks/*/TASK.md: '### Competency deltas' → open lessons grouped
5292
- by competency (DDD·SDD·UDD·TDD·ADD), and '### Spec delta' → open forward hand-offs
5293
- in their own section (a SPEC delta resolves into a task, never consolidates). --json emits
5294
- one JSON object with both under separate keys. Exit 0 ALWAYS. Writes NOTHING."""
5828
+ """Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY — plus, with
5829
+ `--carried`/`--all`, the carried (deferred, non-lossy) SPEC deltas as a RETRIEVAL surface.
5830
+
5831
+ Scans every .add/tasks/*/TASK.md: '### Competency deltas' → open lessons grouped by competency
5832
+ (DDD·SDD·UDD·TDD·ADD), and '### Spec delta' open forward hand-offs in their own section (a SPEC
5833
+ delta resolves into a task, never consolidates). `--carried` lists the carried deltas (re-activate
5834
+ via `reopen-delta`); `--all` shows open + carried. Bare output is BYTE-IDENTICAL to before.
5835
+ --json emits one object (carried keys only when requested). Exit 0 ALWAYS. Writes NOTHING."""
5295
5836
  root = _require_root()
5296
5837
  by_comp = _collect_open_deltas(root)
5297
5838
  total = sum(len(v) for v in by_comp.values())
5298
5839
  spec = _collect_open_spec_deltas(root)
5840
+ only_carried = getattr(args, "carried", False)
5841
+ want_carried = only_carried or getattr(args, "all", False)
5842
+ want_open = not only_carried # --carried hides open; --all keeps it
5843
+ carried = _collect_carried_spec_deltas(root) if want_carried else []
5299
5844
 
5300
5845
  if getattr(args, "json", False):
5301
5846
  payload: dict = {
@@ -5304,26 +5849,40 @@ def cmd_deltas(args: argparse.Namespace) -> None:
5304
5849
  "spec": spec,
5305
5850
  "spec_total": len(spec),
5306
5851
  }
5852
+ if want_carried:
5853
+ payload["carried"] = carried
5854
+ payload["carried_total"] = len(carried)
5307
5855
  print(json.dumps(payload, ensure_ascii=False))
5308
5856
  return
5309
5857
 
5310
- if total == 0 and not spec:
5311
- print("no open deltas.")
5312
- return
5313
-
5314
- if total:
5315
- print(f"open lessons learned ({total} total):")
5316
- for comp in _COMPETENCY_ORDER:
5317
- entries = by_comp[comp]
5318
- if not entries:
5319
- continue
5320
- print(f" {comp} ({len(entries)}):")
5321
- for e in entries:
5858
+ printed = False
5859
+ if want_open:
5860
+ if total:
5861
+ print(f"open lessons learned ({total} total):")
5862
+ for comp in _COMPETENCY_ORDER:
5863
+ entries = by_comp[comp]
5864
+ if not entries:
5865
+ continue
5866
+ print(f" {comp} ({len(entries)}):")
5867
+ for e in entries:
5868
+ print(f" - {e['text']} [{e['task']}]")
5869
+ printed = True
5870
+ if spec:
5871
+ print(f"open spec deltas ({len(spec)} total):")
5872
+ for e in spec:
5873
+ print(f" - {e['text']} [{e['task']}]")
5874
+ printed = True
5875
+ if want_carried:
5876
+ if carried:
5877
+ print(f"carried spec deltas ({len(carried)} total — reopen via add.py reopen-delta):")
5878
+ for e in carried:
5322
5879
  print(f" - {e['text']} [{e['task']}]")
5323
- if spec:
5324
- print(f"open spec deltas ({len(spec)} total):")
5325
- for e in spec:
5326
- print(f" - {e['text']} [{e['task']}]")
5880
+ printed = True
5881
+ elif only_carried: # --carried alone, nothing carried
5882
+ print("no carried spec deltas.")
5883
+ printed = True
5884
+ if not printed:
5885
+ print("no open deltas.")
5327
5886
 
5328
5887
 
5329
5888
  def cmd_project(args: argparse.Namespace) -> None:
@@ -5513,6 +6072,27 @@ def build_parser() -> argparse.ArgumentParser:
5513
6072
  "(case-insensitive) instead of the first")
5514
6073
  pdd.set_defaults(func=cmd_drop_delta)
5515
6074
 
6075
+ pcd = sub.add_parser("carry-delta",
6076
+ help="defer a task's open SPEC delta non-lossily -> [SPEC · carried]")
6077
+ pcd.add_argument("slug", help="task whose open SPEC delta to carry (defer)")
6078
+ pcd.add_argument("--reason", default=None, metavar="TEXT",
6079
+ help="REQUIRED — why it is deferred (the breadcrumb a future loop reads)")
6080
+ grp = pcd.add_mutually_exclusive_group()
6081
+ grp.add_argument("--match", default=None, metavar="SUBSTR",
6082
+ help="target the UNIQUE open SPEC delta whose text contains SUBSTR "
6083
+ "(case-insensitive) instead of the first")
6084
+ grp.add_argument("--all", action="store_true",
6085
+ help="carry EVERY open SPEC delta in the task")
6086
+ pcd.set_defaults(func=cmd_carry_delta)
6087
+
6088
+ prd = sub.add_parser("reopen-delta",
6089
+ help="re-activate a carried SPEC delta -> [SPEC · open]")
6090
+ prd.add_argument("slug", help="task whose carried SPEC delta to reopen")
6091
+ prd.add_argument("--match", default=None, metavar="SUBSTR",
6092
+ help="target the UNIQUE carried SPEC delta whose text contains SUBSTR "
6093
+ "(case-insensitive) instead of the first")
6094
+ prd.set_defaults(func=cmd_reopen_delta)
6095
+
5516
6096
  pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
5517
6097
  pm.add_argument("slug")
5518
6098
  pm.add_argument("--title", default=None)
@@ -5588,10 +6168,16 @@ def build_parser() -> argparse.ArgumentParser:
5588
6168
  pp = sub.add_parser("phase", help="set a task's phase explicitly")
5589
6169
  pp.add_argument("phase", choices=PHASES)
5590
6170
  pp.add_argument("slug", nargs="?", default=None)
6171
+ pp.add_argument("--skip-freeze", action="store_true",
6172
+ help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
6173
+ "marker (the universal freeze gate's only bypass; never auto-freezes §3)")
5591
6174
  pp.set_defaults(func=cmd_phase, _opt_positionals=("slug",))
5592
6175
 
5593
6176
  pa = sub.add_parser("advance", help="move a task to the next phase")
5594
6177
  pa.add_argument("slug", nargs="?", default=None)
6178
+ pa.add_argument("--skip-freeze", action="store_true",
6179
+ help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
6180
+ "marker (the universal freeze gate's only bypass; never auto-freezes §3)")
5595
6181
  pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
5596
6182
 
5597
6183
  pg = sub.add_parser("gate", help="record a verify gate outcome")
@@ -5648,6 +6234,11 @@ def build_parser() -> argparse.ArgumentParser:
5648
6234
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
5649
6235
  pck.set_defaults(func=cmd_check)
5650
6236
 
6237
+ pcomp = sub.add_parser("components",
6238
+ help="read-only: print + validate the component registry "
6239
+ "(.add/components.toml) — RED integrity errors + schema-lint typo warnings")
6240
+ pcomp.set_defaults(func=cmd_components)
6241
+
5651
6242
  pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
5652
6243
  "contract snapshot into this repo (fail-loud)")
5653
6244
  pfedsub = pfed.add_subparsers(dest="action", required=True)
@@ -5711,6 +6302,11 @@ def build_parser() -> argparse.ArgumentParser:
5711
6302
  pdt = sub.add_parser("deltas",
5712
6303
  help="read-only report: open lessons learned grouped by competency")
5713
6304
  pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
6305
+ pdt_g = pdt.add_mutually_exclusive_group()
6306
+ pdt_g.add_argument("--carried", action="store_true",
6307
+ help="list the carried (deferred) SPEC deltas instead of the open ones")
6308
+ pdt_g.add_argument("--all", action="store_true",
6309
+ help="list open AND carried SPEC deltas")
5714
6310
  pdt.set_defaults(func=cmd_deltas)
5715
6311
 
5716
6312
  pfo = sub.add_parser(_FOLD_VERB,