@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/CHANGELOG.md +90 -0
- package/bin/cli.js +203 -15
- package/docs/03-step-1-specify.md +1 -1
- package/docs/04-step-2-scenarios.md +1 -1
- package/docs/08-step-6-verify.md +4 -2
- package/docs/09-the-loop.md +4 -0
- package/docs/11-governance.md +1 -1
- package/docs/16-releasing.md +1 -1
- package/docs/17-components.md +23 -5
- package/docs/appendix-c-glossary.md +6 -4
- package/docs/appendix-d-worked-example.md +79 -0
- package/package.json +1 -1
- package/skill/add/SKILL.md +5 -1
- package/skill/add/advisor.md +8 -1
- package/skill/add/components.md +21 -23
- package/skill/add/phases/5-build.md +1 -1
- package/skill/add/phases/6-verify.md +13 -14
- package/skill/add/phases/7-observe.md +11 -10
- package/skill/add/release.md +1 -1
- package/skill/add/run.md +20 -20
- package/skill/add/scope.md +1 -1
- package/skill/add/streams.md +7 -0
- package/tooling/add.py +689 -93
- package/tooling/add_engine/constants.py +1 -1
- package/tooling/add_engine/release.py +3 -3
- package/tooling/templates/TASK.fast.md.tmpl +3 -1
- package/tooling/templates/TASK.md.tmpl +15 -1
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
|
|
232
|
-
# against
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
576
|
-
#
|
|
577
|
-
#
|
|
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
|
|
584
|
-
#
|
|
585
|
-
#
|
|
586
|
-
#
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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.
|
|
692
|
-
|
|
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
|
|
1431
|
-
#
|
|
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} —
|
|
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 /
|
|
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
|
|
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
|
|
4513
|
-
"""Scan every .add/tasks/*/TASK.md "### Spec delta" block for
|
|
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
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
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) !=
|
|
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
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
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
|
|
4550
|
-
|
|
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
|
|
4554
|
-
token changes (+ a trailing ` [→ <pointer>]`
|
|
4555
|
-
|
|
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
|
|
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) ==
|
|
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 =
|
|
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
|
|
4577
|
-
|
|
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
|
|
4580
|
-
|
|
4581
|
-
(
|
|
4582
|
-
"no_open" (
|
|
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) !=
|
|
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).
|
|
4879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5389
|
+
print(json.dumps({"checked": checked, "findings": findings, "freeze_skips": skips,
|
|
5390
|
+
"guarantee_lints": glints}, ensure_ascii=False, indent=2))
|
|
4885
5391
|
else:
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
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("
|
|
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
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
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
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
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
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
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,
|