@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/CHANGELOG.md +50 -0
- 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/appendix-c-glossary.md +3 -1
- package/package.json +1 -1
- package/skill/add/SKILL.md +5 -1
- package/skill/add/advisor.md +8 -1
- 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 +435 -80
- package/tooling/add_engine/constants.py +1 -1
- package/tooling/add_engine/release.py +3 -3
- package/tooling/templates/TASK.fast.md.tmpl +2 -0
- package/tooling/templates/TASK.md.tmpl +15 -1
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
|
|
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`.
|
|
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
|
|
584
|
-
#
|
|
585
|
-
#
|
|
586
|
-
#
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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.
|
|
692
|
-
|
|
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
|
|
1431
|
-
#
|
|
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} —
|
|
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
|
|
4513
|
-
"""Scan every .add/tasks/*/TASK.md "### Spec delta" block for
|
|
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
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
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) !=
|
|
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
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
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
|
|
4550
|
-
|
|
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
|
|
4554
|
-
token changes (+ a trailing ` [→ <pointer>]`
|
|
4555
|
-
|
|
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
|
|
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) ==
|
|
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 =
|
|
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
|
|
4577
|
-
|
|
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
|
|
4580
|
-
|
|
4581
|
-
(
|
|
4582
|
-
"no_open" (
|
|
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) !=
|
|
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).
|
|
4879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5153
|
+
print(json.dumps({"checked": checked, "findings": findings, "freeze_skips": skips,
|
|
5154
|
+
"guarantee_lints": glints}, ensure_ascii=False, indent=2))
|
|
4885
5155
|
else:
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
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("
|
|
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
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
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
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
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
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
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,
|