@pilotspace/add 1.12.0 → 1.13.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
@@ -239,6 +239,112 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
239
239
  _atomic_write(f, new)
240
240
 
241
241
 
242
+ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
243
+ """Write-back (adr-at-observe): HARVEST a §7 `### Decisions (ADR)` block from the actor-stamps
244
+ ALREADY in the task — §1 framing (AI) · §3 freeze (human) · §5 strategy-actually-used (AI) · §6
245
+ gate (human|AI by autonomy). HARVEST-not-author: every rendered line is sourced from an existing
246
+ stamp; the engine invents no decision content (NO-EXEC). GRANDFATHER like _stamp_gate_record:
247
+ fills ONLY while the block still holds its `<harvested…>` placeholder line; a resolved/absent
248
+ block or an unreadable file -> a byte-identical no-op (legacy + fast tasks untouched). NEVER
249
+ raises: any per-source parse fault renders "<unrecorded>". Called from cmd_gate AFTER
250
+ _stamp_gate_record (so §6 is already mirrored) and AFTER save_state (state is the source of
251
+ truth; the file only mirrors it, so a write fault never loses the verdict).
252
+
253
+ §7-OBSERVE-scoped (INV-7): the placeholder is matched ONLY inside the "## 7 · OBSERVE" section,
254
+ so a "<harvested at done…>" line elsewhere — e.g. a §3 contract that ILLUSTRATES this very
255
+ feature — is never touched (a file-wide first-match would corrupt the frozen contract; caught
256
+ by dogfooding adr-harvest on itself)."""
257
+ f = root / "tasks" / slug / "TASK.md"
258
+ try:
259
+ text = f.read_text(encoding="utf-8")
260
+ except OSError:
261
+ return # unreadable -> no-op
262
+ sec7 = re.search(r"(?ms)^## 7 · OBSERVE\b.*?(?=\n## \d+ ·|\Z)", text)
263
+ if not sec7:
264
+ return # no §7 OBSERVE (fast / legacy) -> no-op
265
+ m = re.search(r"(?m)^<harvested at done[^\n]*>$", sec7.group(0))
266
+ if not m:
267
+ return # resolved (hand-edited) or absent -> grandfather no-op
268
+ ph_start, ph_end = sec7.start() + m.start(), sec7.start() + m.end()
269
+ UN = "<unrecorded>"
270
+ bodies = _raw_phase_bodies(root, slug)
271
+
272
+ def _framing(): # §1 -> [AI]: chosen + rejected
273
+ try:
274
+ m = re.search(r"(?m)^Framings weighed:[ \t]*(.+)$", bodies.get(1, ""))
275
+ if not m:
276
+ return UN, ""
277
+ chosen, rejected = UN, []
278
+ for p in (s.strip() for s in m.group(1).split("·") if s.strip()):
279
+ cm = re.match(r"(.*?)\s*\(chosen\b.*\)\s*$", p) # "(chosen)" OR "(chosen — rationale)"
280
+ if cm:
281
+ chosen = cm.group(1).strip() or UN
282
+ else:
283
+ rejected.append(p)
284
+ # an UNFILLED §1 is a "<chosen>" placeholder token — degrade to <unrecorded>, but a
285
+ # real framing that merely CONTAINS a "<" (e.g. quoting a type) is kept (faithful capture)
286
+ if chosen is UN or chosen.startswith("<"):
287
+ return UN, ""
288
+ return chosen, " · ".join(rejected)
289
+ except Exception:
290
+ return UN, ""
291
+
292
+ def _freeze(): # §3 -> [human]: "FROZEN @ vN — approved by NAME"
293
+ try:
294
+ m = re.search(r"(?m)^.*FROZEN @ (v\d+).*?approved by ([^\n<]+?)\s*$", bodies.get(3, ""))
295
+ if m:
296
+ return m.group(1), m.group(2).strip()
297
+ except Exception:
298
+ pass
299
+ t = ((state.get("tasks") or {}).get(slug) or {})
300
+ fr = t.get("freeze") or {}
301
+ ver = fr.get("version") or t.get("contract_version")
302
+ return (f"v{ver}" if ver else UN), (fr.get("by") or fr.get("actor") or UN)
303
+
304
+ def _strategy(): # §5 -> [AI]: the value, default "as planned"
305
+ try:
306
+ m = re.search(r"(?m)^Strategy actually used:[ \t]*(.+)$", bodies.get(5, ""))
307
+ if m:
308
+ # UNFILLED is the "<fill at …>" template token; a real value may legitimately
309
+ # contain "<" (quoting `<tag>`, "x < y") and must NOT degrade to the default
310
+ val = m.group(1).strip()
311
+ if val and not val.startswith("<fill"):
312
+ return val
313
+ except Exception:
314
+ pass
315
+ return "as planned"
316
+
317
+ def _gate(): # §6 -> [human|AI]: outcome + reviewer
318
+ try:
319
+ mo = re.search(r"(?m)^Outcome:[ \t]*(\S+)", bodies.get(6, ""))
320
+ outcome = mo.group(1) if mo else (((state.get("tasks") or {}).get(slug) or {}).get("gate") or UN)
321
+ mr = re.search(r"(?m)^Reviewed by:[ \t]*([^·\n<]+)", bodies.get(6, ""))
322
+ rev = mr.group(1).strip() if mr else UN
323
+ except Exception:
324
+ outcome, rev = UN, UN
325
+ am = re.search(r"(?m)^autonomy:[ \t]*(\w+)", text)
326
+ actor = "AI" if (am and am.group(1) == "auto") else "human"
327
+ return outcome, rev, actor
328
+
329
+ try:
330
+ chosen, rejected = _framing()
331
+ fver, fby = _freeze()
332
+ strat = _strategy()
333
+ outcome, rev, gate_actor = _gate()
334
+ except Exception:
335
+ return # never block the gate
336
+ rej = f"; rejected {rejected}" if rejected else ""
337
+ lines = [
338
+ f"- [AI] specify — chose {chosen}{rej}",
339
+ f"- [human] freeze — froze §3 @ {fver} (approved by {fby})",
340
+ f"- [AI] build — strategy used: {strat}",
341
+ f"- [{gate_actor}] verify — gate {outcome} (reviewed by {rev})",
342
+ ]
343
+ new = text[:ph_start] + "\n".join(lines) + text[ph_end:]
344
+ if new != text:
345
+ _atomic_write(f, new)
346
+
347
+
242
348
  # --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
243
349
  from add_engine.guidelines import (
244
350
  _guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
@@ -465,6 +571,83 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
465
571
  print(_next_footer(root, state))
466
572
 
467
573
 
574
+ def _open_spec_delta_indices(text: str) -> list[int]:
575
+ """Every splitlines(keepends=True) index of an `[SPEC · open]` line (carry-delta --all). PURE.
576
+ A SPEC flip preserves line count + position, so these indices stay valid across sequential
577
+ flips."""
578
+ return [i for i, ln in enumerate(text.splitlines(keepends=True))
579
+ if (m := _SPEC_DELTA_RE.match(ln.rstrip("\n"))) and m.group(2) == "open"]
580
+
581
+
582
+ def cmd_carry_delta(args: argparse.Namespace) -> None:
583
+ """DEFER a task's open SPEC delta(s) non-lossily — `[SPEC · open]` -> `[SPEC · carried]`
584
+ + a ` [carried: <reason>]` stamp (delta-drain). A carried delta clears the release floor and
585
+ the `status` staleness count but SURVIVES on disk: retrievable via `add.py deltas --carried`
586
+ and re-activatable via `reopen-delta`. `--reason` is REQUIRED (no silent carry); `--all` carries
587
+ every open delta in the task; `--match` targets the unique one. Validate-then-write."""
588
+ root = _require_root()
589
+ state = load_state(root)
590
+ slug = _resolve_task(state, args.slug) # unknown task -> _die
591
+ reason = (getattr(args, "reason", None) or "").strip()
592
+ if not reason:
593
+ _die("carry_reason_required: carry-delta needs a --reason — a deferral must say why "
594
+ "(it is the breadcrumb a future loop reads)")
595
+ task_md = root / "tasks" / slug / "TASK.md"
596
+ text = task_md.read_text(encoding="utf-8")
597
+ stamp = f"[carried: {reason}]"
598
+ if getattr(args, "all", False):
599
+ idxs = _open_spec_delta_indices(text)
600
+ if not idxs:
601
+ _die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to carry")
602
+ for idx in idxs: # indices stay valid (flip is in-place)
603
+ text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
604
+ _atomic_write(task_md, text)
605
+ print(f"carried {len(idxs)} open SPEC delta(s) in '{slug}' -> [SPEC · carried] ({reason})")
606
+ print(_next_footer(root, state))
607
+ return
608
+ match = getattr(args, "match", None)
609
+ status, idx, _disp = _select_spec_delta(text, match)
610
+ if status == "no_open":
611
+ _die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to carry")
612
+ if status == "no_match": # a --match miss is DISTINCT from no-open
613
+ _die(f"no_matching_spec_delta: no open SPEC delta in '{slug}' matches --match '{match}'")
614
+ if status == "ambiguous":
615
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
616
+ f"'{slug}' — narrow it, or use --all")
617
+ new_text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
618
+ _atomic_write(task_md, new_text)
619
+ print(f"carried the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> "
620
+ f"[SPEC · carried] ({reason})")
621
+ print(_next_footer(root, state))
622
+
623
+
624
+ def cmd_reopen_delta(args: argparse.Namespace) -> None:
625
+ """RE-ACTIVATE a carried SPEC delta — `[SPEC · carried]` -> `[SPEC · open]` (delta-drain).
626
+ The inverse of carry: a deferred delta re-enters the open count + release floor + staleness.
627
+ The ` [carried: …]` breadcrumb is stripped so a re-activated delta reads clean. `--match`
628
+ targets the unique carried delta. Validate-then-write; refuse `no_carried_spec_delta`."""
629
+ root = _require_root()
630
+ state = load_state(root)
631
+ slug = _resolve_task(state, args.slug)
632
+ task_md = root / "tasks" / slug / "TASK.md"
633
+ text = task_md.read_text(encoding="utf-8")
634
+ match = getattr(args, "match", None)
635
+ status, idx, _disp = _select_spec_delta(text, match, status="carried")
636
+ if status in ("no_open", "no_match"):
637
+ _die(f"no_carried_spec_delta: task '{slug}' has no carried SPEC delta to reopen"
638
+ + (f" matching --match '{match}'" if status == "no_match" else ""))
639
+ if status == "ambiguous":
640
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple carried SPEC deltas in "
641
+ f"'{slug}' — narrow it")
642
+ new_text = _resolve_spec_delta(text, "open", line_index=idx, from_status="carried")
643
+ lines = new_text.splitlines(keepends=True) # drop the carried breadcrumb (no accretion)
644
+ eol = lines[idx][len(lines[idx].rstrip("\n")):]
645
+ lines[idx] = re.sub(r"\s*\[carried:[^\]]*\]\s*$", "", lines[idx].rstrip("\n")) + eol
646
+ _atomic_write(task_md, "".join(lines))
647
+ print(f"reopened the {'matched' if match else 'first'} carried SPEC delta in '{slug}' -> [SPEC · open]")
648
+ print(_next_footer(root, state))
649
+
650
+
468
651
  # a §3 still carrying this template placeholder is NOT a drafted contract yet
469
652
  _CONTRACT_TEMPLATE_RE = re.compile(r"<METHOD>")
470
653
 
@@ -562,7 +745,7 @@ def _resolve_task(state: dict, slug: str | None) -> str:
562
745
  return slug
563
746
 
564
747
 
565
- def _build_entry(root: Path, state: dict, slug: str) -> None:
748
+ def _build_entry(root: Path, state: dict, slug: str, skip_freeze: bool = False) -> None:
566
749
  """The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
567
750
 
568
751
  Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
@@ -572,22 +755,28 @@ def _build_entry(root: Path, state: dict, slug: str) -> None:
572
755
  so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
573
756
  and never routes here, so it stays exempt.
574
757
  """
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`.
758
+ # the crossing guards. validate-then-write every refusal runs BEFORE the tripwire/scope
759
+ # snapshots below, writing nothing; the task stays at `tests` (the lone exception is the
760
+ # recorded freeze_skipped marker on the explicit --skip-freeze path).
580
761
  _ms = state["tasks"][slug].get("milestone")
581
762
  _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
582
763
  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)")
764
+ # freeze gate — UNIVERSAL (freeze-gate-universal, flow-honesty): closes audit finding H1.
765
+ # The gate used to be opt-in (`_optin or fast`), so a plain-milestone task could cross
766
+ # tests->build on a DRAFT §3 the method's decision point was engine-enforced for only a
767
+ # subset. It now fires for EVERY task. The ONLY bypass is the RECORDED `--skip-freeze` escape:
768
+ # it stamps an auditable `freeze_skipped` marker (never silent) and never auto-freezes §3
769
+ # (Status stays DRAFT). Still PRECEDES build-expectations: freeze §3 before pre-declaring §6.
770
+ if not _contract_frozen(raw3):
771
+ if not skip_freeze:
772
+ _die("contract_not_frozen: freeze §3 before crossing into build — approve "
773
+ f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN), or pass "
774
+ "--skip-freeze to cross with a recorded skip")
775
+ state["tasks"][slug]["freeze_skipped"] = {
776
+ "by": identity._actor_stamp(state)["name"],
777
+ "at": _now(),
778
+ "from_phase": state["tasks"][slug].get("phase", "tests"),
779
+ }
591
780
  # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
592
781
  # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
593
782
  # not just green. Same opt-in switch as the contract-fill gate, one level out.
@@ -645,7 +834,7 @@ def cmd_phase(args: argparse.Namespace) -> None:
645
834
  # validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
646
835
  # loop sets phase=build directly (never via cmd_phase) and so stays exempt.
647
836
  if args.phase == "build":
648
- _build_entry(root, state, slug)
837
+ _build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
649
838
  state["tasks"][slug]["phase"] = args.phase
650
839
  state["tasks"][slug]["updated"] = _now()
651
840
  save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
@@ -688,8 +877,9 @@ def cmd_advance(args: argparse.Namespace) -> None:
688
877
  if nxt == "build":
689
878
  # the tests->build entry guards + snapshots now live in the shared _build_entry helper
690
879
  # (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)
880
+ # IDENTICAL gate stack. `--skip-freeze` (freeze-gate-universal) threads through to the
881
+ # universal freeze gate — the only recorded bypass of the now-mandatory §3 freeze.
882
+ _build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
693
883
  # cross-component contract artifact (cross-component-contract): the contract->tests crossing
694
884
  # is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
695
885
  # a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
@@ -872,6 +1062,7 @@ def cmd_gate(args: argparse.Namespace) -> None:
872
1062
  if completing:
873
1063
  _sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
874
1064
  _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1065
+ _stamp_adr_record(root, state, slug) # adr-at-observe: harvest §7 Decisions (ADR) — AFTER §6 is mirrored
875
1066
  print(f"task '{slug}' gate -> {args.outcome}")
876
1067
  _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
877
1068
  if _gbar:
@@ -1427,12 +1618,16 @@ def cmd_status(args: argparse.Namespace) -> None:
1427
1618
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1428
1619
  if open_deltas:
1429
1620
  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.
1621
+ # SPEC-delta staleness nudge (project-wide): surface unresolved forward hand-offs as STALE
1622
+ # backpressure so they drain instead of silently accumulating (delta-drain). Read-only;
1623
+ # PRESENT-ONLY (silent when none → byte-identical). The prefix stays `spec :` (the cue the
1624
+ # spec-delta guards pin); the wording now names the staleness + the drain surface, which now
1625
+ # includes carry-delta (defer non-lossily) beside seed/drop.
1432
1626
  open_spec = len(_collect_open_spec_deltas(root))
1433
1627
  if open_spec:
1434
1628
  noun = "delta" if open_spec == 1 else "deltas"
1435
- print(f"spec : {open_spec} open SPEC {noun} — resolve: new-task --from-delta / drop-delta")
1629
+ print(f"spec : {open_spec} open SPEC {noun} — stale; drain via add.py deltas "
1630
+ "(carry-delta / new-task --from-delta / drop-delta)")
1436
1631
  # When the setup is unlocked, the only terminal guidance that matters is
1437
1632
  # review+lock; suppress the generic resume block so it does not compete.
1438
1633
  if unlocked:
@@ -4353,7 +4548,7 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
4353
4548
  # dismissed (dropped) — never consolidated into the foundation. _STATUS_SETS keys each
4354
4549
  # tag to its legal status set so the ONE lint can reject a cross-set pairing
4355
4550
  # ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
4356
- _SPEC_STATUSES = ("open", "seeded", "dropped")
4551
+ _SPEC_STATUSES = ("open", "seeded", "dropped", "carried")
4357
4552
  _STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
4358
4553
 
4359
4554
  # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
@@ -4509,13 +4704,13 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
4509
4704
  return by_comp
4510
4705
 
4511
4706
 
4512
- def _collect_open_spec_deltas(root: Path) -> list[dict]:
4513
- """Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
4707
+ def _collect_spec_deltas(root: Path, status: str = "open") -> list[dict]:
4708
+ """Scan every .add/tasks/*/TASK.md "### Spec delta" block for SPEC deltas of `status`.
4514
4709
 
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."""
4710
+ Returns a FLAT list of {task, text, evidence} dicts (SPEC is one tag, never bucketed by
4711
+ competency). A SPEC delta is a forward hand-off that resolves into a TASK (seeded), is
4712
+ dismissed (dropped), or is DEFERRED non-lossily (carried)the open/carried VIEWS are this
4713
+ one scan keyed on `status`. READ-ONLY; never mutates any file."""
4519
4714
  out: list[dict] = []
4520
4715
  tasks_dir = root / "tasks"
4521
4716
  if not tasks_dir.is_dir():
@@ -4528,7 +4723,7 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4528
4723
  continue
4529
4724
  for unit in _spec_delta_entries(text):
4530
4725
  m = _SPEC_DELTA_RE.match(unit[0])
4531
- if m.group(2) != "open": # seeded / dropped are resolved excluded
4726
+ if m.group(2) != status: # other statuses are excluded from this view
4532
4727
  continue
4533
4728
  tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
4534
4729
  em = _EVIDENCE_RE.match(tail)
@@ -4540,51 +4735,69 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4540
4735
  return out
4541
4736
 
4542
4737
 
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*\])")
4738
+ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4739
+ """Open SPEC deltas — the release-floor + `deltas` + `status` count source (a thin view)."""
4740
+ return _collect_spec_deltas(root, "open")
4741
+
4742
+
4743
+ def _collect_carried_spec_deltas(root: Path) -> list[dict]:
4744
+ """Carried (deferred, non-lossy) SPEC deltas — the `deltas --carried` retrieval surface."""
4745
+ return _collect_spec_deltas(root, "carried")
4746
+
4747
+
4748
+ # The FIRST writer of the seeded/dropped/carried statuses (task 1 only TOLERATED them on read).
4749
+ # seed-and-drop's resolution verbs AND delta-drain's carry/reopen all route through here. The token
4750
+ # regex matches ANY current SPEC status, so the flip works in either direction (open->carried,
4751
+ # carried->open) — not only away from open.
4752
+ _SPEC_STATUS_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)(?:open|seeded|dropped|carried)(\s*\])")
4546
4753
 
4547
4754
 
4548
4755
  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.
4756
+ line_index: int | None = None, *, from_status: str = "open",
4757
+ stamp: str | None = None) -> str | None:
4758
+ """Flip ONE `[SPEC · <from_status>]` line in `text` to `new_status`; return the new text.
4551
4759
 
4552
4760
  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 —
4761
+ returns) flip THAT line; without it, flip the FIRST `from_status` delta (back-compat;
4762
+ `from_status` defaults to "open"). Only the status token changes (+ a trailing ` [→ <pointer>]`
4763
+ seed stamp, or a free-form ` <stamp>` e.g. `[carried: <reason>]`); the entry's text and
4764
+ `(evidence: …)` are byte-preserved. Returns None when there is NO matching delta to flip —
4556
4765
  the caller then refuses and writes nothing. Mirrors the `_autonomy_decl_line` pure-transform."""
4557
4766
  lines = text.splitlines(keepends=True)
4558
4767
  target = line_index
4559
- if target is None: # back-compat: the FIRST open delta
4768
+ if target is None: # back-compat: the FIRST from_status delta
4560
4769
  for i, ln in enumerate(lines):
4561
4770
  m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4562
- if m and m.group(2) == "open":
4771
+ if m and m.group(2) == from_status:
4563
4772
  target = i
4564
4773
  break
4565
4774
  if target is None:
4566
4775
  return None
4567
4776
  ln = lines[target]
4568
4777
  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)
4778
+ body = _SPEC_STATUS_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
4570
4779
  if pointer:
4571
4780
  body = f"{body} [→ {pointer}]"
4781
+ if stamp:
4782
+ body = f"{body} {stamp}"
4572
4783
  lines[target] = body + eol
4573
4784
  return "".join(lines)
4574
4785
 
4575
4786
 
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.
4787
+ def _select_spec_delta(text: str, match: str | None = None,
4788
+ status: str = "open") -> tuple[str, int | None, str | None]:
4789
+ """Pick the SPEC delta (of `status`, default "open") to resolve (delta-match-selector). PURE.
4578
4790
 
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."""
4791
+ `match=None` -> the FIRST such delta. `match=<substr>` -> the UNIQUE one whose display text
4792
+ (status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
4793
+ (result, line_index, display_text) where result is one of: "ok" (line_index/display set),
4794
+ "no_open" (none of `status` at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
4795
+ line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips. (The
4796
+ "no_open" token is status-agnostic; the carried-track caller maps it to `no_carried_spec_delta`.)"""
4584
4797
  opens: list[tuple[int, str]] = []
4585
4798
  for i, ln in enumerate(text.splitlines(keepends=True)):
4586
4799
  m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4587
- if not m or m.group(2) != "open":
4800
+ if not m or m.group(2) != status:
4588
4801
  continue
4589
4802
  tail = m.group(3).strip()
4590
4803
  cut = tail.find("(evidence:") # exclude the evidence tail even if its paren is unclosed
@@ -4870,24 +5083,96 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
4870
5083
  for k in ("owner", "ticket", "expires")):
4871
5084
  f(slug, "waiver_incomplete",
4872
5085
  "RISK-ACCEPTED needs owner · ticket · expires")
5086
+ # adr-at-observe: a gated/done task whose §7 Decisions (ADR) block STILL holds its bare
5087
+ # <harvested…> placeholder never harvested its decision record. GRANDFATHER (INV-1): a §7
5088
+ # with NO block is legacy → skipped. BARE-LINE probe (INV-2, the same regex _stamp_adr_record
5089
+ # writes through) → harvested prose that merely quotes "<harvested at done" is not a false hit.
5090
+ s7 = raw.get(7, "")
5091
+ if "### Decisions (ADR)" in s7 and re.search(r"(?m)^<harvested at done[^\n]*>$", s7):
5092
+ f(slug, "adr_record_missing",
5093
+ "§7 Decisions (ADR) block still holds its <harvested…> placeholder (never harvested at gate)")
4873
5094
  return checked, findings
4874
5095
 
4875
5096
 
5097
+ def _freeze_skip_notices(state: dict) -> list[dict]:
5098
+ """The recorded `--skip-freeze` crossings (freeze-gate-universal): tasks that crossed
5099
+ tests->build on a DRAFT §3 via the explicit escape. SURFACED by `add.py audit` so no skip is
5100
+ silent — but NOT a finding: a recorded, sanctioned bypass never fails CI; the human judges it.
5101
+ PURE — reads only."""
5102
+ out = []
5103
+ for slug in sorted(state.get("tasks") or {}):
5104
+ mark = (state["tasks"][slug] or {}).get("freeze_skipped")
5105
+ if isinstance(mark, dict):
5106
+ out.append({"task": slug, "by": mark.get("by", "?"),
5107
+ "at": mark.get("at", "?"), "from_phase": mark.get("from_phase", "?")})
5108
+ return out
5109
+
5110
+
5111
+ # Any `risk:` declaration in the header (high|normal|low|…) — read from the `·`-delimited header
5112
+ # region only (mirrors _RISK_HIGH_RE's anchoring so a title substring can never look like one).
5113
+ _RISK_ANY_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*\S", re.MULTILINE)
5114
+
5115
+
5116
+ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
5117
+ """PRESENCE-ONLY, MEASURE-NOT-BLOCK lints SURFACED (never failed-on) by `add.py audit`
5118
+ (guarantee-audit-lints). For tasks that reached verify (phase ∈ {verify, observe, done}):
5119
+ shallow[] = §6 '### Deep checks' block present-but-unfilled (_section_unfilled; an
5120
+ ABSENT block grandfathers a legacy task — never retro-flagged);
5121
+ risk_unset[] = the header carries NO `risk:` token (an undeclared risk level at verify);
5122
+ refute_unrecorded[]= §6 '### Refute-read verdict' block present-but-unfilled (self-grading-
5123
+ refute-record, M4) — the earned-green verdict the AI must record under
5124
+ `auto`; ABSENT block grandfathers exactly like shallow. MEASURE-NOT-BLOCK:
5125
+ never auto-blocks a gate, only surfaced here for review + a human spot-audit.
5126
+ Honest visibility for three verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
5127
+ TASK.md + state only, writes nothing."""
5128
+ shallow, risk_unset, refute_unrecorded = [], [], []
5129
+ for slug in sorted(state.get("tasks") or {}):
5130
+ if (state["tasks"][slug] or {}).get("phase") not in ("verify", "observe", "done"):
5131
+ continue
5132
+ body6 = _raw_phase_bodies(root, slug).get(6, "")
5133
+ if _section_unfilled(body6, "### Deep checks"):
5134
+ shallow.append(slug)
5135
+ if _section_unfilled(body6, "### Refute-read verdict"):
5136
+ refute_unrecorded.append(slug)
5137
+ if not _RISK_ANY_RE.search(_task_header(root, slug)):
5138
+ risk_unset.append(slug)
5139
+ return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded}
5140
+
5141
+
4876
5142
  def cmd_audit(args: argparse.Namespace) -> None:
4877
5143
  """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."""
5144
+ exit 1 with findings — the enforcement gate CI consumes (audit-ci). Also SURFACES (never
5145
+ fails on) recorded `--skip-freeze` crossings, so a skipped freeze is visible in review.
5146
+ Writes NOTHING; every other command is byte-identical."""
4880
5147
  root = _require_root()
4881
- checked, findings = _audit_findings(root, load_state(root))
5148
+ state = load_state(root)
5149
+ checked, findings = _audit_findings(root, state)
5150
+ skips = _freeze_skip_notices(state)
5151
+ glints = _guarantee_lint_notices(root, state)
4882
5152
  if getattr(args, "json", False):
4883
- print(json.dumps({"checked": checked, "findings": findings},
4884
- ensure_ascii=False, indent=2))
5153
+ print(json.dumps({"checked": checked, "findings": findings, "freeze_skips": skips,
5154
+ "guarantee_lints": glints}, ensure_ascii=False, indent=2))
4885
5155
  else:
4886
- if findings:
4887
- for x in findings:
4888
- print(f"audit: {x['code']} {x['task']} — {x['detail']}")
4889
- else:
5156
+ for x in findings:
5157
+ print(f"audit: {x['code']} {x['task']} — {x['detail']}")
5158
+ for s in skips:
5159
+ print(f"audit: freeze_skipped {s['task']} — crossed tests->build with a DRAFT §3 "
5160
+ f"(by {s['by']} at {s['at']})")
5161
+ for slug in glints["shallow"]:
5162
+ print(f"audit: shallow_deep_check {slug} — §6 Deep-checks block unfilled "
5163
+ f"(a shallow verify, not a pass)")
5164
+ if glints["risk_unset"]:
5165
+ rs = glints["risk_unset"]
5166
+ print(f"audit: risk_unset — {len(rs)} task(s) reached verify with no risk: "
5167
+ f"declaration: {', '.join(rs)}")
5168
+ if glints["refute_unrecorded"]:
5169
+ ru = glints["refute_unrecorded"]
5170
+ print(f"audit: refute_unrecorded — {len(ru)} task(s): {', '.join(ru)} "
5171
+ f"— record the earned-green refute verdict (§6); a spot-audit is the backstop")
5172
+ if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
5173
+ and not glints["refute_unrecorded"]:
4890
5174
  print(f"audit: clean ({checked} tasks checked)")
5175
+ # MEASURE-NOT-BLOCK: only real findings raise the exit code; notices never do.
4891
5176
  if findings:
4892
5177
  sys.exit(1)
4893
5178
 
@@ -5156,6 +5441,15 @@ def release_data(root: Path, state: dict) -> dict:
5156
5441
  # loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
5157
5442
  loose = _releasable_loose_tasks(root, state)
5158
5443
 
5444
+ # open_spec_deltas — unresolved SPEC deltas riding into the cut (the forceable floor's count
5445
+ # source; one record-set so the floor + release-report can never disagree). GATHER, never judge.
5446
+ # LIVE-only: a SPEC delta blocks the cut only while its task is in active state. Archived tasks
5447
+ # are PASS-done history (their deltas stay preserved + visible PROJECT-WIDE in `add.py deltas` /
5448
+ # the `status` cue, but never block a fresh release — they cannot be cleared by the live-scoped
5449
+ # carry/drop verbs). This makes the floor count == the set the CLI can actually drain.
5450
+ live_tasks = state.get("tasks") or {}
5451
+ open_deltas = [d for d in _collect_open_spec_deltas(root) if d["task"] in live_tasks]
5452
+
5159
5453
  return {
5160
5454
  "releasable": releasable,
5161
5455
  "changed": changed,
@@ -5163,9 +5457,12 @@ def release_data(root: Path, state: dict) -> dict:
5163
5457
  "blockers": blockers,
5164
5458
  "monitors": monitors,
5165
5459
  "loose": loose,
5460
+ "open_spec_deltas": {"count": len(open_deltas),
5461
+ "items": [{"task": d["task"], "text": d["text"]} for d in open_deltas]},
5166
5462
  "summary": {
5167
5463
  "releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
5168
5464
  "blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
5465
+ "open_spec_deltas": len(open_deltas),
5169
5466
  },
5170
5467
  }
5171
5468
 
@@ -5241,8 +5538,8 @@ def cmd_release(args: argparse.Namespace) -> None:
5241
5538
  "never shipped. Resolve it (a change request back to Specify) before releasing. "
5242
5539
  "--force does NOT override this.")
5243
5540
  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.")
5541
+ _die("release_build_in_flight: a build is in flight without a recorded green gate — finish "
5542
+ "and gate it first, or pass --force to override.")
5246
5543
  bundle = _releasable(root, state)
5247
5544
  loose_bundle = _releasable_loose_tasks(root, state)
5248
5545
  if not forced and not bundle and not loose_bundle:
@@ -5252,6 +5549,12 @@ def cmd_release(args: argparse.Namespace) -> None:
5252
5549
  if not forced and d["waivers"] and not disclosed:
5253
5550
  _die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
5254
5551
  "--with-waivers to disclose it in the notes, or --force to override.")
5552
+ open_spec = d["open_spec_deltas"]["count"]
5553
+ if not forced and open_spec > 0:
5554
+ _die(f"release_open_spec_deltas: {open_spec} open SPEC delta(s) unresolved — drain them "
5555
+ "first (carry-delta / new-task --from-delta / drop-delta; see `add.py deltas`), or "
5556
+ "pass --force to cut anyway (they ride into the release unresolved). Unlike "
5557
+ "release_security_open, this floor IS forceable.")
5255
5558
 
5256
5559
  # ── RECORD — build both contents in memory, then write CHANGELOG, then RELEASES (commit) ──
5257
5560
  day = date.today().isoformat()
@@ -5286,16 +5589,22 @@ def cmd_release(args: argparse.Namespace) -> None:
5286
5589
 
5287
5590
 
5288
5591
  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."""
5592
+ """Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY — plus, with
5593
+ `--carried`/`--all`, the carried (deferred, non-lossy) SPEC deltas as a RETRIEVAL surface.
5594
+
5595
+ Scans every .add/tasks/*/TASK.md: '### Competency deltas' → open lessons grouped by competency
5596
+ (DDD·SDD·UDD·TDD·ADD), and '### Spec delta' open forward hand-offs in their own section (a SPEC
5597
+ delta resolves into a task, never consolidates). `--carried` lists the carried deltas (re-activate
5598
+ via `reopen-delta`); `--all` shows open + carried. Bare output is BYTE-IDENTICAL to before.
5599
+ --json emits one object (carried keys only when requested). Exit 0 ALWAYS. Writes NOTHING."""
5295
5600
  root = _require_root()
5296
5601
  by_comp = _collect_open_deltas(root)
5297
5602
  total = sum(len(v) for v in by_comp.values())
5298
5603
  spec = _collect_open_spec_deltas(root)
5604
+ only_carried = getattr(args, "carried", False)
5605
+ want_carried = only_carried or getattr(args, "all", False)
5606
+ want_open = not only_carried # --carried hides open; --all keeps it
5607
+ carried = _collect_carried_spec_deltas(root) if want_carried else []
5299
5608
 
5300
5609
  if getattr(args, "json", False):
5301
5610
  payload: dict = {
@@ -5304,26 +5613,40 @@ def cmd_deltas(args: argparse.Namespace) -> None:
5304
5613
  "spec": spec,
5305
5614
  "spec_total": len(spec),
5306
5615
  }
5616
+ if want_carried:
5617
+ payload["carried"] = carried
5618
+ payload["carried_total"] = len(carried)
5307
5619
  print(json.dumps(payload, ensure_ascii=False))
5308
5620
  return
5309
5621
 
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:
5622
+ printed = False
5623
+ if want_open:
5624
+ if total:
5625
+ print(f"open lessons learned ({total} total):")
5626
+ for comp in _COMPETENCY_ORDER:
5627
+ entries = by_comp[comp]
5628
+ if not entries:
5629
+ continue
5630
+ print(f" {comp} ({len(entries)}):")
5631
+ for e in entries:
5632
+ print(f" - {e['text']} [{e['task']}]")
5633
+ printed = True
5634
+ if spec:
5635
+ print(f"open spec deltas ({len(spec)} total):")
5636
+ for e in spec:
5637
+ print(f" - {e['text']} [{e['task']}]")
5638
+ printed = True
5639
+ if want_carried:
5640
+ if carried:
5641
+ print(f"carried spec deltas ({len(carried)} total — reopen via add.py reopen-delta):")
5642
+ for e in carried:
5322
5643
  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']}]")
5644
+ printed = True
5645
+ elif only_carried: # --carried alone, nothing carried
5646
+ print("no carried spec deltas.")
5647
+ printed = True
5648
+ if not printed:
5649
+ print("no open deltas.")
5327
5650
 
5328
5651
 
5329
5652
  def cmd_project(args: argparse.Namespace) -> None:
@@ -5513,6 +5836,27 @@ def build_parser() -> argparse.ArgumentParser:
5513
5836
  "(case-insensitive) instead of the first")
5514
5837
  pdd.set_defaults(func=cmd_drop_delta)
5515
5838
 
5839
+ pcd = sub.add_parser("carry-delta",
5840
+ help="defer a task's open SPEC delta non-lossily -> [SPEC · carried]")
5841
+ pcd.add_argument("slug", help="task whose open SPEC delta to carry (defer)")
5842
+ pcd.add_argument("--reason", default=None, metavar="TEXT",
5843
+ help="REQUIRED — why it is deferred (the breadcrumb a future loop reads)")
5844
+ grp = pcd.add_mutually_exclusive_group()
5845
+ grp.add_argument("--match", default=None, metavar="SUBSTR",
5846
+ help="target the UNIQUE open SPEC delta whose text contains SUBSTR "
5847
+ "(case-insensitive) instead of the first")
5848
+ grp.add_argument("--all", action="store_true",
5849
+ help="carry EVERY open SPEC delta in the task")
5850
+ pcd.set_defaults(func=cmd_carry_delta)
5851
+
5852
+ prd = sub.add_parser("reopen-delta",
5853
+ help="re-activate a carried SPEC delta -> [SPEC · open]")
5854
+ prd.add_argument("slug", help="task whose carried SPEC delta to reopen")
5855
+ prd.add_argument("--match", default=None, metavar="SUBSTR",
5856
+ help="target the UNIQUE carried SPEC delta whose text contains SUBSTR "
5857
+ "(case-insensitive) instead of the first")
5858
+ prd.set_defaults(func=cmd_reopen_delta)
5859
+
5516
5860
  pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
5517
5861
  pm.add_argument("slug")
5518
5862
  pm.add_argument("--title", default=None)
@@ -5588,10 +5932,16 @@ def build_parser() -> argparse.ArgumentParser:
5588
5932
  pp = sub.add_parser("phase", help="set a task's phase explicitly")
5589
5933
  pp.add_argument("phase", choices=PHASES)
5590
5934
  pp.add_argument("slug", nargs="?", default=None)
5935
+ pp.add_argument("--skip-freeze", action="store_true",
5936
+ help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
5937
+ "marker (the universal freeze gate's only bypass; never auto-freezes §3)")
5591
5938
  pp.set_defaults(func=cmd_phase, _opt_positionals=("slug",))
5592
5939
 
5593
5940
  pa = sub.add_parser("advance", help="move a task to the next phase")
5594
5941
  pa.add_argument("slug", nargs="?", default=None)
5942
+ pa.add_argument("--skip-freeze", action="store_true",
5943
+ help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
5944
+ "marker (the universal freeze gate's only bypass; never auto-freezes §3)")
5595
5945
  pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
5596
5946
 
5597
5947
  pg = sub.add_parser("gate", help="record a verify gate outcome")
@@ -5711,6 +6061,11 @@ def build_parser() -> argparse.ArgumentParser:
5711
6061
  pdt = sub.add_parser("deltas",
5712
6062
  help="read-only report: open lessons learned grouped by competency")
5713
6063
  pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
6064
+ pdt_g = pdt.add_mutually_exclusive_group()
6065
+ pdt_g.add_argument("--carried", action="store_true",
6066
+ help="list the carried (deferred) SPEC deltas instead of the open ones")
6067
+ pdt_g.add_argument("--all", action="store_true",
6068
+ help="list open AND carried SPEC deltas")
5714
6069
  pdt.set_defaults(func=cmd_deltas)
5715
6070
 
5716
6071
  pfo = sub.add_parser(_FOLD_VERB,