@pilotspace/add 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tooling/add.py CHANGED
@@ -36,6 +36,14 @@ STAGES = ("prototype", "poc", "mvp", "production")
36
36
  # v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
37
37
  # Worded as the ACTION (never a file) so it stands before graduate.md exists.
38
38
  GRADUATION_CUE = "MVP covered → propose graduation"
39
+ # release-altitude: the read-only cue `status` shows when ≥1 closed milestone is
40
+ # unreleased. The 5th scope level (release.md). `{n}` is filled at print time; the
41
+ # wording matches SKILL.md's "Beyond the bundle" cross-ref byte-for-byte.
42
+ RELEASABLE_CUE = "releasable: {n} milestone(s) closed since last release"
43
+ # the append-only release ledger lives at the PROJECT ROOT (the dir containing .add/),
44
+ # a sibling of CHANGELOG.md — NOT inside .add/. The ledger IS the attribution source:
45
+ # a milestone is "released" iff its slug appears on a `milestones:` row.
46
+ RELEASES_FILE = "RELEASES.md"
39
47
  PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
40
48
  GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
41
49
  # heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
@@ -137,6 +145,8 @@ Status: DRAFT
137
145
  ### GATE RECORD
138
146
  Outcome:
139
147
  ## 7 · OBSERVE
148
+ ### Spec delta
149
+ ### Competency deltas
140
150
  """
141
151
 
142
152
 
@@ -162,6 +172,32 @@ def _atomic_write(path: Path, text: str) -> None:
162
172
  os.unlink(tmp)
163
173
 
164
174
 
175
+ def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
176
+ """Two-phase commit across several files — design-for-failure for a multi-file write.
177
+
178
+ Phase 1 STAGES every (path, text) to a sibling temp file; the realistic IO failures
179
+ (disk full, permission denied) surface HERE, before any visible file changes — and on any
180
+ failure every staged temp is removed, so NOTHING is committed. Phase 2 then `os.replace`s
181
+ each staged temp into place (same-dir renames are atomic and effectively never fail once the
182
+ temp is written). This narrows the partial-write window of N independent `_atomic_write`s to
183
+ the rename loop, honouring a caller's "any failure -> write nothing" across the whole set.
184
+ """
185
+ staged: list[tuple[str, Path]] = []
186
+ try:
187
+ for path, text in writes:
188
+ path.parent.mkdir(parents=True, exist_ok=True)
189
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
190
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
191
+ fh.write(text)
192
+ staged.append((tmp, path))
193
+ for tmp, path in staged: # phase 2: commit via atomic renames
194
+ os.replace(tmp, path)
195
+ finally:
196
+ for tmp, _ in staged: # leftover temps (a failed/aborted stage) never persist
197
+ if os.path.exists(tmp):
198
+ os.unlink(tmp)
199
+
200
+
165
201
  def _templates_dir() -> Path:
166
202
  return Path(__file__).resolve().parent / "templates"
167
203
 
@@ -468,15 +504,37 @@ def cmd_new_task(args: argparse.Namespace) -> None:
468
504
  _die("unknown_milestone")
469
505
  depends_on = _parse_deps(getattr(args, "depends_on", None))
470
506
 
507
+ # SEED (--from-delta): resolve a prior task's FIRST open SPEC delta into THIS task.
508
+ # validate-ALL-then-write — resolve the prior, read its open delta, and compute the
509
+ # seeded flip NOW (before any write); the slug-free check above has already passed, so
510
+ # the only writes below are the new TASK.md, then the prior flip, then state.
511
+ from_delta = getattr(args, "from_delta", None)
512
+ feature_override = prior_md = flipped_prior = None
513
+ if from_delta:
514
+ prior = _resolve_task(state, from_delta) # unknown prior -> _die
515
+ prior_md = root / "tasks" / prior / "TASK.md"
516
+ prior_text = prior_md.read_text(encoding="utf-8")
517
+ delta_text = _first_open_spec_text(prior_text)
518
+ if delta_text is None:
519
+ _die(f"no_open_spec_delta: task '{prior}' has no open SPEC delta to seed")
520
+ feature_override = f"{delta_text} (from {prior} spec-delta)"
521
+ flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug)
522
+
471
523
  (tdir / "tests").mkdir(parents=True, exist_ok=True)
472
524
  (tdir / "src").mkdir(parents=True, exist_ok=True)
473
525
  title = args.title or slug.replace("-", " ").replace("_", " ").title()
474
526
  # inherit the project's DECLARED autonomy default (task init-auto-default) — fail-SAFE:
475
527
  # absent -> auto, garbled -> conservative; the posture is project-scoped, not hardcoded.
476
528
  autonomy = _project_autonomy(root)
477
- _atomic_write(task_md, _render_template(
529
+ rendered = _render_template(
478
530
  "TASK.md", title=title, slug=slug, date=date.today().isoformat(),
479
- stage=state["stage"], autonomy=autonomy))
531
+ stage=state["stage"], autonomy=autonomy)
532
+ if feature_override: # pre-fill §1 from the seeded delta
533
+ rendered = re.sub(r"(?m)^Feature:.*$",
534
+ lambda _m: f"Feature: {feature_override}", rendered, count=1)
535
+ _atomic_write(task_md, rendered)
536
+ if flipped_prior is not None: # consume the source delta -> seeded
537
+ _atomic_write(prior_md, flipped_prior)
480
538
  if _project_autonomy_token(root) == "?":
481
539
  print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
482
540
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
@@ -491,6 +549,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
491
549
  "created": _now(),
492
550
  "updated": _now(),
493
551
  }
552
+ if from_delta:
553
+ state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
494
554
  state["active_task"] = slug
495
555
  save_state(root, state)
496
556
  print(f"created task '{slug}' -> {task_md}")
@@ -502,10 +562,31 @@ def cmd_new_task(args: argparse.Namespace) -> None:
502
562
  # intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
503
563
  print(f"note: '{slug}' is not attached to a milestone — size it via /add (intake), "
504
564
  "or pass --milestone <id>")
565
+ if from_delta:
566
+ print(f"seeded from '{from_delta}' — its open SPEC delta is now "
567
+ f"[SPEC · seeded] … [→ {slug}]; §1 Feature pre-filled.")
505
568
  print("active task set. phase: ground. Gather the real codebase (section 0 GROUND).")
506
569
  print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
507
570
 
508
571
 
572
+ def cmd_drop_delta(args: argparse.Namespace) -> None:
573
+ """DISMISS a task's first open SPEC delta — `[SPEC · open]` -> `[SPEC · dropped]`.
574
+
575
+ The dismiss half of the SPEC-delta resolution pair (seed lives on `new-task
576
+ --from-delta`). Validate-then-write: refuse `no_open_spec_delta` before any write;
577
+ text + `(evidence: …)` are byte-preserved by the pure `_resolve_spec_delta`."""
578
+ root = _require_root()
579
+ state = load_state(root)
580
+ slug = _resolve_task(state, args.slug) # unknown task -> _die
581
+ task_md = root / "tasks" / slug / "TASK.md"
582
+ new_text = _resolve_spec_delta(task_md.read_text(encoding="utf-8"), "dropped")
583
+ if new_text is None:
584
+ _die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to drop")
585
+ _atomic_write(task_md, new_text)
586
+ print(f"dropped the first open SPEC delta in '{slug}' -> [SPEC · dropped]")
587
+ print(_next_footer(root, state))
588
+
589
+
509
590
  def _parse_deps(raw: str | None) -> list[str]:
510
591
  if not raw:
511
592
  return []
@@ -1055,6 +1136,14 @@ def cmd_status(args: argparse.Namespace) -> None:
1055
1136
  print(f"archived: {n} milestone{'s' if n != 1 else ''} "
1056
1137
  f"({m_tasks} task{'s' if m_tasks != 1 else ''})")
1057
1138
 
1139
+ # release cue (release-altitude): project-global + read-only. Fires when ≥1 CLOSED
1140
+ # milestone (live-done OR archived) is not yet attributed to a RELEASES.md row — so it
1141
+ # stands even with no live milestones. Additive: a line solely when releasable; the
1142
+ # ledger read is fail-open (a vanished ledger never silences the cue). See release.md.
1143
+ _rel = _releasable(root, state)
1144
+ if _rel:
1145
+ print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
1146
+
1058
1147
  print(f"active : {active or '(none)'}")
1059
1148
  # surface the active task's autonomy level (task explicit-autonomy-dial) so the human
1060
1149
  # reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
@@ -1095,6 +1184,12 @@ def cmd_status(args: argparse.Namespace) -> None:
1095
1184
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1096
1185
  if open_deltas:
1097
1186
  print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)")
1187
+ # SPEC-delta nudge (project-wide): surface unresolved forward hand-offs so a seed/drop
1188
+ # can't be silently skipped (read-only; silent when none). Sibling of the fold nudge.
1189
+ open_spec = len(_collect_open_spec_deltas(root))
1190
+ if open_spec:
1191
+ noun = "delta" if open_spec == 1 else "deltas"
1192
+ print(f"spec : {open_spec} open SPEC {noun} — resolve: new-task --from-delta / drop-delta")
1098
1193
  # When the setup is unlocked, the only terminal guidance that matters is
1099
1194
  # review+lock; suppress the generic resume block so it does not compete.
1100
1195
  if unlocked:
@@ -2221,6 +2316,12 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
2221
2316
  noun = "delta" if open_deltas == 1 else "deltas"
2222
2317
  print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
2223
2318
  f"— review with: add.py deltas")
2319
+ # SPEC-delta nudge (project-wide): the close is also a natural prompt to RESOLVE the
2320
+ # forward hand-offs (seed/drop) so none is orphaned at the eventual compaction.
2321
+ open_spec = len(_collect_open_spec_deltas(root))
2322
+ if open_spec:
2323
+ noun = "delta" if open_spec == 1 else "deltas"
2324
+ print(f"note: {open_spec} open SPEC {noun} to resolve (seed/drop) — review: add.py deltas")
2224
2325
  # the engine-sourced next step (converges the old "Confirm … archive/start the next" hint)
2225
2326
  print(_next_footer(root, state))
2226
2327
 
@@ -2317,6 +2418,15 @@ def cmd_compact(args: argparse.Namespace) -> None:
2317
2418
  if offenders:
2318
2419
  _die("open_deltas_unfolded: consolidate the open lessons first (`add.py deltas`) — "
2319
2420
  "open in: " + " · ".join(offenders))
2421
+ # SPEC-delta guard (PROJECT-WIDE, by the §3 freeze decision): a SPEC delta is a forward
2422
+ # hand-off that resolves into a task, not a foundation lesson — an open one ANYWHERE would
2423
+ # be orphaned at the next compaction. Deliberately broader than the member-scoped competency
2424
+ # guard above. Still validate-before-move: refuses BEFORE the first rename.
2425
+ spec_offenders = sorted({d["task"] for d in _collect_open_spec_deltas(root)})
2426
+ if spec_offenders:
2427
+ _die("open_spec_deltas_unresolved: resolve every open SPEC delta first "
2428
+ "(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta`) — "
2429
+ "open in: " + " · ".join(spec_offenders))
2320
2430
  # every precondition passed — move (same-filesystem renames, never a delete)
2321
2431
  def _files(d: Path) -> int:
2322
2432
  return sum(1 for f in d.rglob("*") if f.is_file())
@@ -2786,9 +2896,17 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
2786
2896
  # ── §5 scope gate (build-scope-lock): touched ⊆ declared, from bytes alone ──────────
2787
2897
  # The walk's NAMED exclusion set — ONE constant; widening it is an additive
2788
2898
  # change-request, never silent. `.add` is engine domain (tripwire + audit guard it);
2789
- # the rest is VCS/bytecode/OS junk with no build signal.
2790
- _SCOPE_EXCLUDE_DIRS = (".git", ".add", "__pycache__", "node_modules")
2791
- _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc by suffix
2899
+ # the rest is VCS/bytecode/OS junk + code-intelligence tool caches + gitignored BUILD
2900
+ # ARTIFACTS, none with build signal. `.serena` holds a symbol index that re-writes itself
2901
+ # whenever a source file changes (md5 churn from a build edit must never read as an
2902
+ # out-of-scope touch — the dogfooding lesson that added it). A regenerated artifact is
2903
+ # likewise NOT a source touch — counting one produced repeated false `scope_violation`s in
2904
+ # consuming projects (`.next/`, `coverage/`, `tsconfig.tsbuildinfo`, whose `incremental`
2905
+ # rewrite even races a clean re-snapshot), so they are pruned here too.
2906
+ _SCOPE_EXCLUDE_DIRS = (".git", ".add", "__pycache__", "node_modules", ".serena",
2907
+ ".next", "coverage", "test-results")
2908
+ _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildinfo by suffix
2909
+ _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
2792
2910
 
2793
2911
 
2794
2912
  def _declared_scope(root: Path, slug: str) -> list[str] | None:
@@ -2847,14 +2965,15 @@ def _in_scope(rel: str, declared: list[str]) -> bool:
2847
2965
 
2848
2966
  def _scope_walk(rootp: Path) -> dict[str, str]:
2849
2967
  """{project-root-relative path: md5} over the project tree, pruning
2850
- _SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk. A file
2968
+ _SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk +
2969
+ gitignored build artifacts (_SCOPE_EXCLUDE_FILES/_SCOPE_EXCLUDE_SUFFIXES). A file
2851
2970
  unreadable at SNAPSHOT time is skipped; at the GATE the resulting absence
2852
2971
  reads as a touch (fail-closed at the biting end). Bytes only — no git."""
2853
2972
  files: dict[str, str] = {}
2854
2973
  for dirpath, dirnames, filenames in os.walk(rootp):
2855
2974
  dirnames[:] = [d for d in dirnames if d not in _SCOPE_EXCLUDE_DIRS]
2856
2975
  for name in filenames:
2857
- if name in _SCOPE_EXCLUDE_FILES or name.endswith(".pyc"):
2976
+ if name in _SCOPE_EXCLUDE_FILES or name.endswith(_SCOPE_EXCLUDE_SUFFIXES):
2858
2977
  continue
2859
2978
  p = Path(dirpath) / name
2860
2979
  h = _md5_file(p)
@@ -3010,23 +3129,37 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
3010
3129
  return "(unknown)", []
3011
3130
  text = f.read_text(encoding="utf-8")
3012
3131
  m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
3013
- lines = (m7.group(0) if m7 else text).splitlines()
3014
- # observe: the field value + continuation lines until a blank line / heading / list
3132
+ section = m7.group(0) if m7 else text
3133
+ lines = section.splitlines()
3134
+ # observe: prefer the first OPEN SPEC delta from the "### Spec delta" block; fall
3135
+ # back to the legacy "Spec delta for the next loop:" free-text field (archived
3136
+ # tasks predate the block); else "(unknown)".
3015
3137
  observe = "(unknown)"
3016
- for i, ln in enumerate(lines):
3017
- m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
3018
- if not m:
3138
+ for unit in _spec_delta_entries(section):
3139
+ m = _SPEC_DELTA_RE.match(unit[0])
3140
+ if m.group(2) != "open":
3019
3141
  continue
3020
- parts = [m.group(1).strip()]
3021
- for nxt in lines[i + 1:]:
3022
- t = nxt.strip()
3023
- if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
3024
- break
3025
- parts.append(t)
3026
- joined = " ".join(p for p in parts if p).strip()
3027
- if joined and not joined.startswith("<"):
3028
- observe = joined
3029
- break
3142
+ tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
3143
+ em = _EVIDENCE_RE.match(tail)
3144
+ first = (em.group(1).strip() if em else tail)
3145
+ if first and not first.startswith("<"):
3146
+ observe = first
3147
+ break
3148
+ if observe == "(unknown)":
3149
+ for i, ln in enumerate(lines):
3150
+ m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
3151
+ if not m:
3152
+ continue
3153
+ parts = [m.group(1).strip()]
3154
+ for nxt in lines[i + 1:]:
3155
+ t = nxt.strip()
3156
+ if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
3157
+ break
3158
+ parts.append(t)
3159
+ joined = " ".join(p for p in parts if p).strip()
3160
+ if joined and not joined.startswith("<"):
3161
+ observe = joined
3162
+ break
3030
3163
 
3031
3164
  # deltas: each "- [COMP · status] ..." plus its indented continuation lines
3032
3165
  deltas, i = [], 0
@@ -3116,6 +3249,8 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
3116
3249
  "RISK-ACCEPTED": sum(1 for r in task_rows if r["gate"] == "RISK-ACCEPTED"),
3117
3250
  "HARD-STOP": sum(1 for r in task_rows if r["gate"] == "HARD-STOP")},
3118
3251
  "exit_criteria": {"met": met, "total": total_ec},
3252
+ # project-wide open SPEC-delta count (uniform with status/milestone-done/compact)
3253
+ "open_spec": len(_collect_open_spec_deltas(root)),
3119
3254
  },
3120
3255
  "tasks": task_rows,
3121
3256
  "waivers": waivers,
@@ -3353,6 +3488,11 @@ def render_report(root: Path, state: dict, mslug: str, *,
3353
3488
  L.extend(_wrap(x, W - 5, f" {g['bullet']} "))
3354
3489
  else:
3355
3490
  L.append(" LEARNINGS none")
3491
+ if d.get("summary", {}).get("open_spec"): # project-wide open SPEC-delta nudge (read-only)
3492
+ n = d["summary"]["open_spec"]
3493
+ noun = "delta" if n == 1 else "deltas"
3494
+ L.append("")
3495
+ L.append(f" SPEC DELTAS {n} open {noun} — resolve: new-task --from-delta / drop-delta")
3356
3496
  L.append("") # DECIDE NEXT footer (v13): always present, APPEND-ONLY
3357
3497
  L.extend(_wrap(_decide_next_base(state, d), W - 15, " DECIDE NEXT "))
3358
3498
  if _planned_hint(d): # own segment so the phrase never splits mid-token
@@ -3725,6 +3865,18 @@ _DELTA_RE = re.compile(
3725
3865
  )
3726
3866
  _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
3727
3867
 
3868
+ # SPEC-delta track — a SEPARATE resolution lifecycle from the competency deltas
3869
+ # above. SPEC shares the "- [TAG · status]" LINE shape but its statuses are
3870
+ # DISJOINT (open|seeded|dropped) and it resolves into a TASK (seeded) or is
3871
+ # dismissed (dropped) — never consolidated into the foundation. _STATUS_SETS keys each
3872
+ # tag to its legal status set so the ONE lint can reject a cross-set pairing
3873
+ # ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
3874
+ _SPEC_STATUSES = ("open", "seeded", "dropped")
3875
+ _SPEC_DELTA_RE = re.compile(
3876
+ r"\s*-\s*\[\s*(SPEC)\s*·\s*(open|seeded|dropped)\s*\]\s*(.+)$"
3877
+ )
3878
+ _STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
3879
+
3728
3880
  # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
3729
3881
  # A line with a `· ` bracket separator is a delta-attempt. Does NOT enumerate
3730
3882
  # competencies or statuses — a different abstraction from _DELTA_RE (no DRY violation).
@@ -3732,21 +3884,25 @@ _TAG_BROAD_RE = re.compile(r"^\s*-\s*\[\s*([^\]·]+?)\s*·\s*([^\]·]+?)\s*\]\s*
3732
3884
 
3733
3885
 
3734
3886
  def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
3735
- """Lint all open delta entries in a task's '### Competency deltas' block.
3887
+ """Lint all open delta entries in a task's '### Competency deltas' AND '### Spec delta' blocks.
3736
3888
 
3737
3889
  Returns:
3738
3890
  None — no delta-attempts found; no check emitted.
3739
3891
  (True, "") — all open entries pass.
3740
3892
  (False, "<code> -> <tag line>") — first failing entry with its failure code.
3741
3893
 
3742
- Contract rules (frozen §3, v1):
3894
+ Contract rules (frozen §3, spec-delta-grammar v1):
3743
3895
  - SKIP HTML-comment lines and blank lines (they are never tag lines).
3744
- - Group lines into ENTRIES: a broad tag line starts an entry; following lines
3745
- until next tag / blank / end-of-block are its continuation.
3896
+ - Group lines into ENTRIES across both blocks: a broad tag line starts an entry;
3897
+ following lines until next tag / blank / block boundary are its continuation.
3746
3898
  - A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag.
3747
- - For each entry, skip folded/rejected (open-only history not retrofitted).
3748
- - Validate the remaining (open) entries: COMP in _COMPETENCY_ORDER,
3749
- status in _DELTA_STATUSES, and '(evidence:' present SOMEWHERE in the unit.
3899
+ - Validation is TAG-SCOPED via _STATUS_SETS: each tag carries its own legal
3900
+ status set (the competency statuses for DDD…ADD, the SPEC statuses for SPEC).
3901
+ A status drawn from the wrong set (e.g. a competency-only status on SPEC, or
3902
+ `seeded` on a competency tag) is unknown_status.
3903
+ - Skip an entry whose status is RESOLVED for its tag (open-only — history not
3904
+ retrofitted). Validate the rest: tag known, status legal, non-empty text, and
3905
+ '(evidence:' present — evidence is required on an OPEN entry of ANY tag.
3750
3906
  - Fail-closed: an unparseable attempt FAILS (never silently passes).
3751
3907
  """
3752
3908
  task_md = root / "tasks" / slug / "TASK.md"
@@ -3757,71 +3913,64 @@ def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
3757
3913
  except OSError:
3758
3914
  return None
3759
3915
 
3760
- # Locate the "### Competency deltas" block.
3761
- block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
3762
- if not block_match:
3916
+ # Locate BOTH delta blocks — "### Competency deltas" and the SPEC track
3917
+ # "### Spec delta". Each contributes entries to the same tag-scoped validation.
3918
+ blocks = []
3919
+ for pat in (r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)",
3920
+ r"###\s*Spec delta\s*\n(.*?)(?=\n##|\Z)"):
3921
+ bm = re.search(pat, text, re.S)
3922
+ if bm:
3923
+ blocks.append(bm.group(1))
3924
+ if not blocks:
3763
3925
  return None
3764
3926
 
3765
- block = block_match.group(1)
3766
- raw_lines = block.splitlines()
3767
-
3768
- # First pass: collect entries (tag line + continuations).
3769
- # HTML-comment lines are skipped entirely (invisible to the guard).
3770
- # Blank lines terminate the current entry, but are not tags themselves.
3927
+ # First pass: collect entries (tag line + continuations). HTML-comment and blank
3928
+ # lines never start an entry; a block boundary closes any open entry.
3771
3929
  entries: list[tuple[str, list[str]]] = [] # (tag_line, [tag_line, *continuations])
3772
- current: list[str] | None = None
3773
- for raw_line in raw_lines:
3774
- stripped = raw_line.strip()
3775
- # Skip HTML-comment lines.
3776
- if stripped.startswith("<!--"):
3777
- continue
3778
- # Blank line terminates the current entry.
3779
- if not stripped:
3780
- current = None
3781
- continue
3782
- # Broad tag detection: any "- [tok · tok]" line starts a new entry.
3783
- m = _TAG_BROAD_RE.match(raw_line)
3784
- if m:
3785
- current = [stripped]
3786
- entries.append((stripped, current))
3787
- elif current is not None:
3788
- # Continuation line of the current entry.
3789
- current.append(stripped)
3790
- # else: non-blank, non-comment, non-tag line with no prior entry — ignore.
3930
+ for block in blocks:
3931
+ current: list[str] | None = None
3932
+ for raw_line in block.splitlines():
3933
+ stripped = raw_line.strip()
3934
+ if stripped.startswith("<!--"):
3935
+ continue
3936
+ if not stripped:
3937
+ current = None
3938
+ continue
3939
+ if _TAG_BROAD_RE.match(raw_line):
3940
+ current = [stripped]
3941
+ entries.append((stripped, current))
3942
+ elif current is not None:
3943
+ current.append(stripped)
3791
3944
 
3792
3945
  if not entries:
3793
3946
  return None # no delta-attempts → no check emitted
3794
3947
 
3795
- # Second pass: validate each entry.
3948
+ # Second pass: validate each entry, TAG-SCOPED. The status set is per-tag
3949
+ # (_STATUS_SETS): competency → open|folded|rejected, SPEC → open|seeded|dropped.
3796
3950
  for tag_line, unit_lines in entries:
3797
3951
  m = _TAG_BROAD_RE.match(tag_line)
3798
3952
  if not m:
3799
- # Should not happen, but fail-closed.
3800
- return False, f"malformed_delta -> {tag_line}"
3953
+ return False, f"malformed_delta -> {tag_line}" # fail-closed
3801
3954
  raw_comp = m.group(1).strip()
3802
3955
  raw_status = m.group(2).strip()
3956
+ tail = m.group(3).strip()
3803
3957
 
3804
- # Step 1: skip historical entries (folded/rejected) open-only enforcement.
3805
- # MUST happen before competency/status validation per §3: "history not retrofitted".
3806
- if raw_status in ("folded", "rejected"):
3958
+ # Skip RESOLVED (non-open) entries — history is not retrofitted. Resolved is
3959
+ # tag-scoped (folded|rejected · seeded|dropped); an unknown tag defaults to the
3960
+ # competency set so a legacy folded/rejected line still skips cleanly.
3961
+ resolved = set(_STATUS_SETS.get(raw_comp, _DELTA_STATUSES)) - {"open"}
3962
+ if raw_status in resolved:
3807
3963
  continue
3808
3964
 
3809
- # Step 2: use _DELTA_RE (the canonical grammar, single source of truth) to test
3810
- # whether the tag line is a fully-valid delta shape. If it matches, check evidence
3811
- # only. If it fails, classify the failure via the raw tokens (never a parallel grammar).
3812
- unit_text = " ".join(unit_lines)
3813
- if _DELTA_RE.match(tag_line):
3814
- # Valid comp + status + non-empty tail — check evidence in the joined unit.
3815
- if "(evidence:" not in unit_text:
3816
- return False, f"no_evidence -> {tag_line}"
3817
- else:
3818
- # Classify why _DELTA_RE rejected it (open entries only — folded/rejected skipped).
3819
- if raw_comp not in _COMPETENCY_ORDER:
3820
- return False, f"unknown_competency -> {tag_line}"
3821
- if raw_status not in _DELTA_STATUSES:
3822
- return False, f"unknown_status -> {tag_line}"
3823
- # Comp and status are valid but the line still failed _DELTA_RE (e.g. empty tail).
3965
+ legal = _STATUS_SETS.get(raw_comp)
3966
+ if legal is None:
3967
+ return False, f"unknown_competency -> {tag_line}"
3968
+ if raw_status not in legal:
3969
+ return False, f"unknown_status -> {tag_line}"
3970
+ if not tail:
3824
3971
  return False, f"malformed_delta -> {tag_line}"
3972
+ if "(evidence:" not in " ".join(unit_lines): # required on open of ANY tag
3973
+ return False, f"no_evidence -> {tag_line}"
3825
3974
 
3826
3975
  return True, ""
3827
3976
 
@@ -3881,6 +4030,295 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
3881
4030
  return by_comp
3882
4031
 
3883
4032
 
4033
+ def _spec_delta_entries(text: str) -> list[list[str]]:
4034
+ """Group a "### Spec delta" block into entries (tag line + continuation lines).
4035
+
4036
+ Same grouping discipline as _collect_open_deltas' competency pass, keyed on
4037
+ _SPEC_DELTA_RE: a tag line starts an entry; a non-"- " line continues it; a
4038
+ blank/comment or a new "- " item ends it. Returns [] when the block is absent."""
4039
+ bm = re.search(r"###\s*Spec delta\s*\n(.*?)(?=\n##|\Z)", text, re.S)
4040
+ if not bm:
4041
+ return []
4042
+ entries: list[list[str]] = []
4043
+ current: list[str] | None = None
4044
+ for line in bm.group(1).splitlines():
4045
+ stripped = line.strip()
4046
+ if not stripped or stripped.startswith("<!--"):
4047
+ current = None
4048
+ continue
4049
+ if _SPEC_DELTA_RE.match(stripped):
4050
+ current = [stripped]
4051
+ entries.append(current)
4052
+ elif current is not None and not stripped.startswith("-"):
4053
+ current.append(stripped) # genuine wrap of the current entry
4054
+ else:
4055
+ current = None # a new / malformed list item ends the run
4056
+ return entries
4057
+
4058
+
4059
+ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4060
+ """Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
4061
+
4062
+ Returns a FLAT list of {task, text, evidence} dicts (SPEC is one tag, never
4063
+ bucketed by competency). A SPEC delta is a forward hand-off that resolves into
4064
+ a TASK — never consolidated into the foundation — so it is collected SEPARATELY from
4065
+ _collect_open_deltas. READ-ONLY; never mutates any file."""
4066
+ out: list[dict] = []
4067
+ tasks_dir = root / "tasks"
4068
+ if not tasks_dir.is_dir():
4069
+ return out
4070
+ for task_md in sorted(tasks_dir.glob("*/TASK.md")):
4071
+ slug = task_md.parent.name
4072
+ try:
4073
+ text = task_md.read_text(encoding="utf-8")
4074
+ except OSError:
4075
+ continue
4076
+ for unit in _spec_delta_entries(text):
4077
+ m = _SPEC_DELTA_RE.match(unit[0])
4078
+ if m.group(2) != "open": # seeded / dropped are resolved — excluded
4079
+ continue
4080
+ tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
4081
+ em = _EVIDENCE_RE.match(tail)
4082
+ if em:
4083
+ delta_text, evidence = em.group(1).strip(), em.group(2).strip()
4084
+ else:
4085
+ delta_text, evidence = tail, ""
4086
+ out.append({"task": slug, "text": delta_text, "evidence": evidence})
4087
+ return out
4088
+
4089
+
4090
+ # The FIRST writer of the seeded/dropped statuses (task 1 only TOLERATED them on read).
4091
+ # seed-and-drop's resolution verbs both route through here.
4092
+ _SPEC_OPEN_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)open(\s*\])")
4093
+
4094
+
4095
+ def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None) -> str | None:
4096
+ """Flip the FIRST `[SPEC · open]` line in `text` to `new_status`; return the new text.
4097
+
4098
+ PURE — no IO. Only the status token changes (+ a trailing ` [→ <pointer>]` provenance
4099
+ stamp when seeding); the entry's text and `(evidence: …)` are byte-preserved. Returns
4100
+ None when there is NO open SPEC delta — the caller then refuses and writes nothing
4101
+ (validate-all-then-write). Mirrors the `_autonomy_decl_line` pure-transform pattern."""
4102
+ lines = text.splitlines(keepends=True)
4103
+ for i, ln in enumerate(lines):
4104
+ m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4105
+ if not m or m.group(2) != "open":
4106
+ continue
4107
+ eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
4108
+ body = _SPEC_OPEN_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
4109
+ if pointer:
4110
+ body = f"{body} [→ {pointer}]"
4111
+ lines[i] = body + eol
4112
+ return "".join(lines)
4113
+ return None
4114
+
4115
+
4116
+ def _first_open_spec_text(text: str) -> str | None:
4117
+ """The first OPEN SPEC delta's text (evidence stripped) in `text`, or None.
4118
+
4119
+ Used to pre-fill a seeded task's §1 Feature line from the SAME in-memory text the
4120
+ flip operates on (one read, consistent selection)."""
4121
+ for unit in _spec_delta_entries(text):
4122
+ m = _SPEC_DELTA_RE.match(unit[0])
4123
+ if m.group(2) != "open":
4124
+ continue
4125
+ tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
4126
+ em = _EVIDENCE_RE.match(tail)
4127
+ return em.group(1).strip() if em else tail
4128
+ return None
4129
+
4130
+
4131
+ # ── add.py fold — mechanized competency-lesson consolidation ────────────────────────────────
4132
+ # The HUMAN-AUTHORIZED reversal of the prior "the engine stays judgment-free; there is no
4133
+ # add.py fold" principle (foundation-update-loop re-frozen @ v3). The engine now mechanizes ONE
4134
+ # consolidation session — flip + stamp + route + version-bump — but only ever TRANSCRIBES a
4135
+ # lesson's own captured text into its routed home; it NEVER composes or merges prose (that
4136
+ # editorial judgment stays the human's, via the compaction door). `fold`/`folded` are Group C
4137
+ # machine tokens here (the subcommand name + the status value), referenced by NAME inside output
4138
+ # strings so the ubiquitous-language prose lint sees no slang — only the two defs below carry the
4139
+ # literal, both exempt via MACHINE_CONSTANTS.
4140
+ _FOLD_VERB = "fold" # the subcommand / decision-record verb
4141
+ _FOLDED = "folded" # the resolved status value
4142
+ _COMP_OPEN_TOKEN_RE = re.compile(r"(\[\s*(?:DDD|SDD|UDD|TDD|ADD)\s*·\s*)open(\s*\])")
4143
+
4144
+ # competency -> (foundation file, section-heading PREFIX) — fold.md's routing table. DDD/SDD/UDD
4145
+ # land in PROJECT.md sections; TDD/ADD in CONVENTIONS.md (they ARE the engine). Total over the five.
4146
+ _FOLD_ROUTES = {
4147
+ "DDD": ("PROJECT.md", "## Domain"),
4148
+ "SDD": ("PROJECT.md", "## Spec"),
4149
+ "UDD": ("PROJECT.md", "## Users"),
4150
+ "TDD": ("CONVENTIONS.md", "## Method learnings"),
4151
+ "ADD": ("CONVENTIONS.md", "## Method learnings"),
4152
+ }
4153
+ _KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail section (every session adds one row)
4154
+ _TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
4155
+
4156
+
4157
+ def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
4158
+ """Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
4159
+
4160
+ PURE — no IO. Mirrors `_resolve_spec_delta`: only the status token changes plus the trailing
4161
+ stamp; the line's text + `(evidence: …)` are byte-preserved. `comps` (a set of competency tags)
4162
+ narrows which to flip; None = all five. Returns the new text, or None when NOTHING was open to
4163
+ flip (the caller then refuses / skips — validate-all-then-write)."""
4164
+ lines = text.splitlines(keepends=True)
4165
+ flipped = False
4166
+ for i, ln in enumerate(lines):
4167
+ m = _DELTA_RE.match(ln.rstrip("\n"))
4168
+ if not m or m.group(2) != "open":
4169
+ continue
4170
+ if comps is not None and m.group(1) not in comps:
4171
+ continue
4172
+ eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
4173
+ body = _COMP_OPEN_TOKEN_RE.sub(rf"\g<1>{_FOLDED}\g<2>", ln.rstrip("\n"), count=1)
4174
+ body = f"{body} [{_FOLDED} foundation-version {version}]"
4175
+ lines[i] = body + eol
4176
+ flipped = True
4177
+ return "".join(lines) if flipped else None
4178
+
4179
+
4180
+ def _section_present(text: str, heading_prefix: str) -> bool:
4181
+ return any(ln.startswith(heading_prefix) for ln in text.splitlines())
4182
+
4183
+
4184
+ def _prepend_to_section(text: str, heading_prefix: str, bullet: str) -> str:
4185
+ """Insert `bullet` immediately after the first line starting with `heading_prefix`
4186
+ (newest-first, at the TOP of the section). Caller guarantees the heading exists."""
4187
+ lines = text.splitlines(keepends=True)
4188
+ for i, ln in enumerate(lines):
4189
+ if ln.startswith(heading_prefix):
4190
+ lines.insert(i + 1, bullet if bullet.endswith("\n") else bullet + "\n")
4191
+ return "".join(lines)
4192
+ return text
4193
+
4194
+
4195
+ def _prepend_key_decision_row(text: str, row: str) -> str:
4196
+ """Insert `row` just below the §Key Decisions table separator (newest-first); if the table
4197
+ separator is absent, fall back to right after the heading. Caller guarantees the heading."""
4198
+ lines = text.splitlines(keepends=True)
4199
+ head = next((i for i, ln in enumerate(lines)
4200
+ if ln.startswith(_KEY_DECISIONS_HEADING)), None)
4201
+ if head is None:
4202
+ return text
4203
+ at = head + 1
4204
+ for j in range(head + 1, min(head + 6, len(lines))):
4205
+ if _TABLE_SEP_RE.match(lines[j].rstrip("\n")):
4206
+ at = j + 1
4207
+ break
4208
+ lines.insert(at, row if row.endswith("\n") else row + "\n")
4209
+ return "".join(lines)
4210
+
4211
+
4212
+ def cmd_fold(args: argparse.Namespace) -> None:
4213
+ """Mechanize ONE competency-lesson consolidation session — flip + stamp + route + bump, atomic.
4214
+
4215
+ Collect every OPEN competency lesson (optionally narrowed by --task/--comp), flip each to the
4216
+ resolved status + ` [<resolved> foundation-version N]`, transcribe it VERBATIM into its routed
4217
+ foundation section, prepend one §Key Decisions row, and bump `foundation-version` ONCE.
4218
+ Validate-ALL-then-write: every precondition is checked and every new body built in memory BEFORE
4219
+ any write, so a reject leaves the whole tree byte-unchanged. The engine transcribes — it never
4220
+ composes/merges prose (the human's consolidation, via the compaction door). Running the command
4221
+ IS the human's confirmation; it never self-approves WHICH lessons to keep."""
4222
+ root = _require_root()
4223
+ state = load_state(root)
4224
+
4225
+ by_comp = _collect_open_deltas(root)
4226
+ want_task = getattr(args, "task", None)
4227
+ want_comp = getattr(args, "comp", None)
4228
+ selected = []
4229
+ for comp in _COMPETENCY_ORDER:
4230
+ if want_comp and comp != want_comp:
4231
+ continue
4232
+ for it in by_comp.get(comp, []):
4233
+ if want_task and it["task"] != want_task:
4234
+ continue
4235
+ selected.append({**it, "comp": comp})
4236
+ if not selected:
4237
+ scope = (f"task '{want_task}'" if want_task else "the project") + \
4238
+ (f", competency {want_comp}" if want_comp else "")
4239
+ _die(f"no_open_deltas: no open lesson to consolidate in {scope} (see `add.py deltas`)")
4240
+
4241
+ # version — one bump for the whole session; every stamp carries the SAME N.
4242
+ project_md = root / "PROJECT.md"
4243
+ project_text = project_md.read_text(encoding="utf-8")
4244
+ vm = re.search(r"foundation-version:\s*(\d+)", project_text)
4245
+ if not vm:
4246
+ _die("no_foundation_version: PROJECT.md has no parseable 'foundation-version:' header to bump")
4247
+ prev_v = int(vm.group(1))
4248
+ new_v = prev_v + 1
4249
+
4250
+ # routing — every selected lesson's destination section (and the audit-trail section) must exist.
4251
+ conventions_md = root / "CONVENTIONS.md"
4252
+ conventions_text = conventions_md.read_text(encoding="utf-8") if conventions_md.exists() else ""
4253
+ file_text = {"PROJECT.md": project_text, "CONVENTIONS.md": conventions_text}
4254
+ for it in selected:
4255
+ fname, heading = _FOLD_ROUTES[it["comp"]]
4256
+ if not _section_present(file_text[fname], heading):
4257
+ _die(f"missing_route_section: {fname} has no '{heading}' section for a "
4258
+ f"{it['comp']} lesson — add the section header and re-run")
4259
+ if not _section_present(project_text, _KEY_DECISIONS_HEADING):
4260
+ _die(f"missing_route_section: PROJECT.md has no '{_KEY_DECISIONS_HEADING}' "
4261
+ "section for the audit-trail row — add the section header and re-run")
4262
+
4263
+ # ── build EVERY edit in memory before writing anything ──────────────────────────────────────
4264
+ comps_filter = {want_comp} if want_comp else None
4265
+ task_new: dict[str, str] = {}
4266
+ for slug in dict.fromkeys(it["task"] for it in selected):
4267
+ tmd = root / "tasks" / slug / "TASK.md"
4268
+ flipped = _fold_competency_delta(tmd.read_text(encoding="utf-8"), new_v, comps_filter)
4269
+ if flipped is None: # defensive: selected ⇒ ≥1 open here
4270
+ _die(f"no_open_deltas: task '{slug}' lost its open lesson mid-session")
4271
+ task_new[slug] = flipped
4272
+
4273
+ def _bullet(it):
4274
+ ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
4275
+ return (f"- ({it['comp']}) {it['text']}{ev} "
4276
+ f"[{_FOLDED} foundation-version {new_v} · from {it['task']}]")
4277
+
4278
+ # transcribe verbatim (reverse so canonical-order first lands on top, newest-first).
4279
+ proj_text, conv_text = project_text, conventions_text
4280
+ for it in reversed(selected):
4281
+ fname, heading = _FOLD_ROUTES[it["comp"]]
4282
+ if fname == "PROJECT.md":
4283
+ proj_text = _prepend_to_section(proj_text, heading, _bullet(it))
4284
+ else:
4285
+ conv_text = _prepend_to_section(conv_text, heading, _bullet(it))
4286
+
4287
+ counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
4288
+ count_str = " · ".join(f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c])
4289
+ scope = "all" if not (want_task or want_comp) else " ".join(
4290
+ filter(None, [f"--task {want_task}" if want_task else "",
4291
+ f"--comp {want_comp}" if want_comp else ""]))
4292
+ row = (f"| {date.today().isoformat()} | {_FOLD_VERB} {scope} → foundation-version {new_v} "
4293
+ f"({count_str}) | consolidate captured OBSERVE lessons into the versioned foundation "
4294
+ f"| {len(selected)} lessons open→{_FOLDED}; +{len(selected)} routed bullets; {prev_v}→{new_v} |")
4295
+ proj_text = _prepend_key_decision_row(proj_text, row)
4296
+ proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
4297
+
4298
+ # ── all bodies built; commit via a two-phase write (stage every temp, then rename-all). A
4299
+ # phase-1 temp-write failure — the REALISTIC one (disk-full / permission) — leaves NOTHING
4300
+ # written. A phase-2 mid-rename failure (near-impossible on same-dir renames) can leave the
4301
+ # foundation advanced while a TASK.md stays unflipped; files are ordered foundation-FIRST so
4302
+ # the lesson then stays visibly `open` and a re-run re-transcribes (DUPLICATING, never
4303
+ # losing — manual fixup), rather than a silently-flipped-but-untranscribed loss. A true
4304
+ # all-or-nothing N-file commit is the multi-file-commit follow-up task. ────────────────────
4305
+ writes: list[tuple[Path, str]] = [(project_md, proj_text)]
4306
+ touched = ["PROJECT.md"]
4307
+ if conv_text != conventions_text:
4308
+ writes.append((conventions_md, conv_text))
4309
+ touched.append("CONVENTIONS.md")
4310
+ for slug, body in task_new.items():
4311
+ writes.append((root / "tasks" / slug / "TASK.md", body))
4312
+ touched.append(f"{len(task_new)} TASK.md")
4313
+ _atomic_write_many(writes)
4314
+
4315
+ print(f"{_FOLDED} {len(selected)} lessons -> foundation-version {new_v}")
4316
+ print(f" {count_str}")
4317
+ print(f" bumped PROJECT.md {prev_v} -> {new_v}")
4318
+ print(f" files: {', '.join(touched)}")
4319
+ print(_next_footer(root, state))
4320
+
4321
+
3884
4322
  _AUDIT_STAMP_RE = re.compile(r"Status:\s*FROZEN @ v\d+\s*[—-]+\s*approved by\s+\S+")
3885
4323
  _AUDIT_OUTCOME_RE = re.compile(r"^Outcome:\s*(PASS|RISK-ACCEPTED|HARD-STOP)\b", re.M)
3886
4324
  _AUDIT_SECURITY_RE = re.compile(
@@ -4113,36 +4551,334 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
4113
4551
  print("\n".join(L))
4114
4552
 
4115
4553
 
4554
+ def _releases_path(root: Path) -> Path:
4555
+ """The append-only release ledger — at the PROJECT ROOT (root IS the .add dir, so its
4556
+ parent), a sibling of CHANGELOG.md. NOT inside .add/."""
4557
+ return root.parent / RELEASES_FILE
4558
+
4559
+
4560
+ def _released_milestones(root: Path) -> set[str]:
4561
+ """Slugs already attributed to a release — the union of every `milestones:` row in
4562
+ RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
4563
+ every closed milestone reads as still-releasable (a vanished ledger never hides work).
4564
+ READ-ONLY."""
4565
+ try:
4566
+ text = _releases_path(root).read_text(encoding="utf-8")
4567
+ except OSError:
4568
+ return set() # no ledger (or a dir at the path) -> nothing released yet
4569
+ out: set[str] = set()
4570
+ for line in text.splitlines():
4571
+ st = line.strip()
4572
+ if st.lower().startswith("milestones:"):
4573
+ for tok in re.split(r"[,\s]+", st.split(":", 1)[1]):
4574
+ tok = tok.strip()
4575
+ if tok and tok.lower() != "none":
4576
+ out.add(tok)
4577
+ return out
4578
+
4579
+
4580
+ def _closed_milestones(state: dict) -> list[dict]:
4581
+ """Every CLOSED milestone (its milestone-done gate passed): LIVE done milestones
4582
+ (status == 'done', still in state) + ARCHIVED milestones (all were PASS-done before
4583
+ archive — see _archived_task_slugs). Each: {slug, title, tier}."""
4584
+ out: list[dict] = []
4585
+ for slug, m in (state.get("milestones") or {}).items():
4586
+ if m.get("status") == "done":
4587
+ out.append({"slug": slug, "title": m.get("title", slug), "tier": "live"})
4588
+ for rec in state.get("archived") or []:
4589
+ if rec.get("slug"):
4590
+ out.append({"slug": rec["slug"], "title": rec.get("title", rec["slug"]),
4591
+ "tier": "archived"})
4592
+ return out
4593
+
4594
+
4595
+ def _releasable(root: Path, state: dict) -> list[dict]:
4596
+ """Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
4597
+ bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
4598
+ released = _released_milestones(root)
4599
+ return [m for m in _closed_milestones(state) if m["slug"] not in released]
4600
+
4601
+
4602
+ def _key_decisions_for(root: Path, slug: str) -> list[str]:
4603
+ """Best-effort §Key-Decisions rows from PROJECT.md that NAME this milestone slug — the
4604
+ consolidated decisions the changelog can cite. Fail-open: a missing section / unreadable
4605
+ foundation / no slug match -> [] (a gather never raises). READ-ONLY."""
4606
+ try:
4607
+ text = (root / "PROJECT.md").read_text(encoding="utf-8")
4608
+ except OSError:
4609
+ return []
4610
+ m = re.search(r"^#{1,6}[^\n]*key decision[^\n]*$(.*?)(?=^#{1,6}\s|\Z)", text, re.S | re.M | re.I)
4611
+ if not m:
4612
+ return []
4613
+ return [st.lstrip("-* ").strip() for st in (ln.strip() for ln in m.group(1).splitlines())
4614
+ if st.startswith(("-", "*")) and slug in st]
4615
+
4616
+
4617
+ def release_data(root: Path, state: dict) -> dict:
4618
+ """The single source of FACTS for a release cut — PURE, NO writes (mirrors graduation_data).
4619
+ Both the `release-report` text dashboard and `--json` render from this one dict, so the human
4620
+ view and the machine view can never disagree.
4621
+
4622
+ GATHER, never JUDGE: every value is a RECORD the human verifies by looking; there is no
4623
+ readiness/score/ranking field by construction. Five record-sets feed the release.md flow:
4624
+ releasable — closed-but-unreleased milestones (the bundle candidate; the cue's count)
4625
+ changed — per releasable milestone: RETRO path + carried-delta count + §Key-Decisions rows
4626
+ waivers — open RISK-ACCEPTED riding into the cut (soonest expiry first)
4627
+ blockers — open HARD-STOP gate records (the security stop the floor will refuse on)
4628
+ monitors — declared §7 Watch lines to carry into the post-cut watch step
4629
+ A source is read fail-closed (skip on error); the ledger is read fail-OPEN (see _releasable)."""
4630
+ tasks = state.get("tasks") or {}
4631
+ releasable = _releasable(root, state)
4632
+
4633
+ # changed — the consolidated learning trail per releasable milestone (the changelog source)
4634
+ changed = []
4635
+ for m in releasable:
4636
+ slug = m["slug"]
4637
+ retro = None
4638
+ for sub in ("milestones", "archive"):
4639
+ cand = root / sub / slug / "RETRO.md"
4640
+ if cand.is_file(): # a directory at the path is not a ledger (fail-closed)
4641
+ retro = str(cand.relative_to(root))
4642
+ break
4643
+ changed.append({"milestone": slug, "key_decisions": _key_decisions_for(root, slug),
4644
+ "retro": retro,
4645
+ "carried_deltas": _retro_carried(root / retro) if retro else 0})
4646
+
4647
+ # waivers — open RISK-ACCEPTED riding into the cut, soonest expiry first (mirrors graduation_data)
4648
+ waivers = []
4649
+ for slug, t in tasks.items():
4650
+ if t.get("gate") == "RISK-ACCEPTED" and t.get("waiver"):
4651
+ w = t["waiver"]
4652
+ waivers.append({"slug": slug, "owner": w.get("owner", "?"),
4653
+ "ticket": w.get("ticket", "?"), "expires": w.get("expires", "?")})
4654
+
4655
+ def _exp_key(wv):
4656
+ try:
4657
+ return (0, date.fromisoformat(wv["expires"]).isoformat())
4658
+ except (ValueError, TypeError):
4659
+ return (1, "") # unparseable/missing -> after every real date
4660
+ waivers.sort(key=_exp_key)
4661
+
4662
+ # blockers — open HARD-STOP gate records (the un-forceable security stop the floor enforces)
4663
+ blockers = [{"slug": s, "gate": t.get("gate")} for s, t in tasks.items()
4664
+ if t.get("gate") == "HARD-STOP"]
4665
+
4666
+ # monitors — declared §7 Watch lines (filled, not the `<…>` template) for the watch step
4667
+ monitors = []
4668
+ for slug in tasks:
4669
+ try:
4670
+ text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
4671
+ except OSError:
4672
+ continue # unreadable TASK.md -> skip this task's monitor record
4673
+ for line in text.splitlines():
4674
+ st = line.strip()
4675
+ if st.startswith("Watch") and "<" not in st and st != "Watch":
4676
+ monitors.append({"slug": slug, "watch": st})
4677
+ break
4678
+
4679
+ return {
4680
+ "releasable": releasable,
4681
+ "changed": changed,
4682
+ "waivers": waivers,
4683
+ "blockers": blockers,
4684
+ "monitors": monitors,
4685
+ "summary": {
4686
+ "releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
4687
+ "blockers": len(blockers), "monitors": len(monitors),
4688
+ },
4689
+ }
4690
+
4691
+
4692
+ def cmd_release_report(args: argparse.Namespace) -> None:
4693
+ """Read-only: GATHER the release inventory into five labeled record-sets for the release.md
4694
+ flow. text (default) or --json (the frozen JSON facts interface). Exit 0 ALWAYS — a gather,
4695
+ not a gate; the ONLY non-zero exit is no_project. Judges nothing. NO writes."""
4696
+ root = find_root()
4697
+ if root is None: # frozen contract: fail-closed with a no_project signal
4698
+ _die("no_project: no .add/ project found. Run `add.py init` first.")
4699
+ state = load_state(root)
4700
+ d = release_data(root, state)
4701
+
4702
+ if getattr(args, "json", False):
4703
+ print(json.dumps(d, ensure_ascii=False, indent=2))
4704
+ return
4705
+
4706
+ s = d["summary"]
4707
+ L = ["RELEASE REPORT — release inventory (gather, not judge)", ""]
4708
+ L.append(f"Releasable ({s['releasable']}) — closed milestones not yet in {RELEASES_FILE}:")
4709
+ for m in d["releasable"]:
4710
+ L.append(f" - {m['slug']} [{m['tier']}]: {m['title']}")
4711
+ L.append("")
4712
+ L.append(f"Changed ({s['changed']}) — the consolidated learning trail per milestone:")
4713
+ for c in d["changed"]:
4714
+ L.append(f" - {c['milestone']}: {c['retro'] or '(no RETRO record)'} "
4715
+ f"({c['carried_deltas']} carried · {len(c['key_decisions'])} key decision(s))")
4716
+ L.append("")
4717
+ L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED riding into the cut, soonest expiry first:")
4718
+ for w in d["waivers"]:
4719
+ L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}")
4720
+ L.append("")
4721
+ L.append(f"Blockers ({s['blockers']}) — open HARD-STOP (the un-forceable security stop):")
4722
+ for b in d["blockers"]:
4723
+ L.append(f" - {b['slug']}: {b['gate']}")
4724
+ L.append("")
4725
+ L.append(f"Monitors ({s['monitors']}) — declared §7 Watch lines to carry into the watch step:")
4726
+ for mo in d["monitors"]:
4727
+ L.append(f" - {mo['slug']}: {mo['watch']}")
4728
+ print("\n".join(L))
4729
+
4730
+
4731
+ def _build_in_flight(state: dict) -> bool:
4732
+ """release_tests_red proxy (PURE): is any ACTIVE task mid-build without a recorded green gate
4733
+ — phase ∈ {build, verify} AND gate == 'none'? The tool-agnostic engine never runs the suite,
4734
+ so an entered-but-ungated build is the recorded-evidence stand-in for 'the suite is red'."""
4735
+ return any(t.get("phase") in ("build", "verify") and t.get("gate") == "none"
4736
+ for t in (state.get("tasks") or {}).values())
4737
+
4738
+
4739
+ def _prepend_block(existing: str, header: str, block: str) -> str:
4740
+ """Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
4741
+ header when `existing` is empty / headerless. Existing content is preserved VERBATIM
4742
+ (append-only). `block` is expected to end in a blank-line separator."""
4743
+ if not existing.strip():
4744
+ return f"{header}\n\n{block}"
4745
+ if existing.lstrip().startswith(header):
4746
+ after = existing.split(header, 1)[1].lstrip("\n")
4747
+ return f"{header}\n\n{block}{after}"
4748
+ return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
4749
+
4750
+
4751
+ def _render_changelog_block(version: str, day: str, bundle: list[dict],
4752
+ changed_by_slug: dict) -> str:
4753
+ """A CHANGELOG block: `## <version> — <date>` + one bullet per bundled milestone (title +
4754
+ carried-delta / key-decision counts from release_data['changed'])."""
4755
+ lines = [f"## {version} — {day}", ""]
4756
+ if bundle:
4757
+ for m in bundle:
4758
+ c = changed_by_slug.get(m["slug"], {})
4759
+ lines.append(f"- {m['title']} — {c.get('carried_deltas', 0)} carried · "
4760
+ f"{len(c.get('key_decisions', []))} key decision(s)")
4761
+ else:
4762
+ lines.append("- (no milestone bundled)")
4763
+ return "\n".join(lines) + "\n\n"
4764
+
4765
+
4766
+ def _render_releases_row(version: str, day: str, bundle: list[dict],
4767
+ waiver_slugs: list[str], evidence: str | None) -> str:
4768
+ """One append-only RELEASES.md row — the attribution source (`milestones:` membership)."""
4769
+ ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
4770
+ wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
4771
+ return (f"## {version} — {day}\n"
4772
+ f"milestones: {ms}\n"
4773
+ f"waivers: {wv}\n"
4774
+ f"evidence: {evidence or 'recorded by add.py release'}\n\n")
4775
+
4776
+
4777
+ def cmd_release(args: argparse.Namespace) -> None:
4778
+ """GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
4779
+ prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
4780
+ the bundle). The engine RECORDS; it NEVER tags / publishes / deploys / bumps a version source /
4781
+ writes state.json. Validate-before-write: a reject leaves both files + state.json byte-unchanged.
4782
+ A failed second write rolls back the first (release_write_failed)."""
4783
+ root = find_root()
4784
+ if root is None: # frozen contract: fail-closed with a no_project signal
4785
+ _die("no_project: no .add/ project found. Run `add.py init` first.")
4786
+ state = load_state(root)
4787
+ d = release_data(root, state)
4788
+ forced = getattr(args, "force", False)
4789
+ disclosed = getattr(args, "with_waivers", False)
4790
+
4791
+ # ── FLOOR — all checks BEFORE any write (validate-before-write) ──────────────────────────
4792
+ if d["blockers"]: # the UN-FORCEABLE reject — security is never shipped
4793
+ _die("release_security_open: an open HARD-STOP blocks the cut — a security finding is "
4794
+ "never shipped. Resolve it (a change request back to Specify) before releasing. "
4795
+ "--force does NOT override this.")
4796
+ if not forced and _build_in_flight(state):
4797
+ _die("release_tests_red: a build is in flight without a recorded green gate — finish and "
4798
+ "gate it first, or pass --force to override.")
4799
+ bundle = _releasable(root, state)
4800
+ if not forced and not bundle:
4801
+ _die("release_no_closed_milestone: nothing closed-and-unreleased to bundle — the cut "
4802
+ "would be a no-op. Close a milestone first, or pass --force to override.")
4803
+ if not forced and d["waivers"] and not disclosed:
4804
+ _die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
4805
+ "--with-waivers to disclose it in the notes, or --force to override.")
4806
+
4807
+ # ── RECORD — build both contents in memory, then write CHANGELOG, then RELEASES (commit) ──
4808
+ day = date.today().isoformat()
4809
+ changed_by_slug = {c["milestone"]: c for c in d["changed"]}
4810
+ waiver_slugs = [w["slug"] for w in d["waivers"]] if disclosed else []
4811
+ changelog_path = root.parent / "CHANGELOG.md"
4812
+ releases_path = _releases_path(root)
4813
+ cl_before = changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
4814
+ rel_before = releases_path.read_text(encoding="utf-8") if releases_path.exists() else ""
4815
+ new_cl = _prepend_block(cl_before or "", "# Changelog",
4816
+ _render_changelog_block(args.version, day, bundle, changed_by_slug))
4817
+ new_rel = _prepend_block(rel_before, "# Releases",
4818
+ _render_releases_row(args.version, day, bundle, waiver_slugs,
4819
+ getattr(args, "evidence", None)))
4820
+ _atomic_write(changelog_path, new_cl)
4821
+ try:
4822
+ _atomic_write(releases_path, new_rel) # the attribution commit point
4823
+ except OSError as e:
4824
+ if cl_before is not None: # ROLLBACK (design-for-failure)
4825
+ _atomic_write(changelog_path, cl_before)
4826
+ else:
4827
+ try:
4828
+ changelog_path.unlink()
4829
+ except OSError:
4830
+ pass
4831
+ _die(f"release_write_failed: the ledger write failed ({e}); CHANGELOG was rolled back — "
4832
+ "nothing was recorded. Retry the release.")
4833
+
4834
+ # NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
4835
+ ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
4836
+ print(f"released {args.version} — recorded {len(bundle)} milestone(s): {ms}")
4837
+ print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
4838
+ "you run the tag / publish / deploy.")
4839
+ if forced:
4840
+ print(" (--force: forceable floor rejects were bypassed — release_security_open is never bypassable)")
4841
+ print(_next_footer(root, state))
4842
+
4843
+
4116
4844
  def cmd_deltas(args: argparse.Namespace) -> None:
4117
- """Read-only: report all open lessons learned grouped by competency.
4845
+ """Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY.
4118
4846
 
4119
- Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
4120
- matching the delta grammar; shows only `open` entries in canonical competency
4121
- order (DDD·SDD·UDD·TDD·ADD). --json emits one JSON object. Exit 0 ALWAYS.
4122
- Writes NOTHING."""
4847
+ Scans every .add/tasks/*/TASK.md: '### Competency deltas' open lessons grouped
4848
+ by competency (DDD·SDD·UDD·TDD·ADD), and '### Spec delta' open forward hand-offs
4849
+ in their own section (a SPEC delta resolves into a task, never consolidates). --json emits
4850
+ one JSON object with both under separate keys. Exit 0 ALWAYS. Writes NOTHING."""
4123
4851
  root = _require_root()
4124
4852
  by_comp = _collect_open_deltas(root)
4125
4853
  total = sum(len(v) for v in by_comp.values())
4854
+ spec = _collect_open_spec_deltas(root)
4126
4855
 
4127
4856
  if getattr(args, "json", False):
4128
4857
  payload: dict = {
4129
4858
  "total": total,
4130
4859
  "by_competency": {c: v for c, v in by_comp.items() if v},
4860
+ "spec": spec,
4861
+ "spec_total": len(spec),
4131
4862
  }
4132
4863
  print(json.dumps(payload, ensure_ascii=False))
4133
4864
  return
4134
4865
 
4135
- if total == 0:
4866
+ if total == 0 and not spec:
4136
4867
  print("no open deltas.")
4137
4868
  return
4138
4869
 
4139
- print(f"open lessons learned ({total} total):")
4140
- for comp in _COMPETENCY_ORDER:
4141
- entries = by_comp[comp]
4142
- if not entries:
4143
- continue
4144
- print(f" {comp} ({len(entries)}):")
4145
- for e in entries:
4870
+ if total:
4871
+ print(f"open lessons learned ({total} total):")
4872
+ for comp in _COMPETENCY_ORDER:
4873
+ entries = by_comp[comp]
4874
+ if not entries:
4875
+ continue
4876
+ print(f" {comp} ({len(entries)}):")
4877
+ for e in entries:
4878
+ print(f" - {e['text']} [{e['task']}]")
4879
+ if spec:
4880
+ print(f"open spec deltas ({len(spec)} total):")
4881
+ for e in spec:
4146
4882
  print(f" - {e['text']} [{e['task']}]")
4147
4883
 
4148
4884
 
@@ -4275,9 +5011,17 @@ def build_parser() -> argparse.ArgumentParser:
4275
5011
  pn.add_argument("--milestone", default=None, help="attach to a milestone (default: active)")
4276
5012
  pn.add_argument("--depends-on", dest="depends_on", default=None,
4277
5013
  help="comma-separated task slugs this task depends on")
5014
+ pn.add_argument("--from-delta", dest="from_delta", default=None, metavar="PRIOR",
5015
+ help="SEED PRIOR's first open SPEC delta into this task (pre-fills §1 "
5016
+ "Feature, flips the source -> [SPEC · seeded] [→ this])")
4278
5017
  pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
4279
5018
  pn.set_defaults(func=cmd_new_task)
4280
5019
 
5020
+ pdd = sub.add_parser("drop-delta",
5021
+ help="dismiss a task's first open SPEC delta -> [SPEC · dropped]")
5022
+ pdd.add_argument("slug", help="task whose first open SPEC delta to drop")
5023
+ pdd.set_defaults(func=cmd_drop_delta)
5024
+
4281
5025
  pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
4282
5026
  pm.add_argument("slug")
4283
5027
  pm.add_argument("--title", default=None)
@@ -4418,6 +5162,13 @@ def build_parser() -> argparse.ArgumentParser:
4418
5162
  pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
4419
5163
  pdt.set_defaults(func=cmd_deltas)
4420
5164
 
5165
+ pfo = sub.add_parser(_FOLD_VERB,
5166
+ help="record one retrospective consolidation of open lessons into the "
5167
+ "versioned foundation (stamp + route + version-bump, atomic)")
5168
+ pfo.add_argument("--task", help="narrow to one task's open lessons")
5169
+ pfo.add_argument("--comp", choices=_COMPETENCY_ORDER, help="narrow to one competency's open lessons")
5170
+ pfo.set_defaults(func=cmd_fold)
5171
+
4421
5172
  pgr = sub.add_parser("graduation-report",
4422
5173
  help="read-only: gather the MVP loop's evidence (deltas · waivers · RETROs · "
4423
5174
  "residue · coverage gaps) for a graduation interview — gathers, never judges")
@@ -4425,6 +5176,26 @@ def build_parser() -> argparse.ArgumentParser:
4425
5176
  pgr.add_argument("--plain", action="store_true", help="ASCII/pipe-safe text (output is plain by default)")
4426
5177
  pgr.set_defaults(func=cmd_graduation_report)
4427
5178
 
5179
+ prr = sub.add_parser("release-report",
5180
+ help="read-only: gather the release inventory (releasable milestones · "
5181
+ "changed/RETROs · waivers · HARD-STOP blockers · monitors) for a "
5182
+ "release cut — gathers, never judges")
5183
+ prr.add_argument("--json", action="store_true", help="emit the frozen JSON facts interface")
5184
+ prr.add_argument("--plain", action="store_true", help="ASCII/pipe-safe text (output is plain by default)")
5185
+ prr.set_defaults(func=cmd_release_report)
5186
+
5187
+ prl = sub.add_parser("release",
5188
+ help="guarded, record-only: cut a version — enforce the readiness floor, "
5189
+ "then prepend CHANGELOG.md + an append-only RELEASES.md row (the "
5190
+ "engine records; you tag/publish). Security HARD-STOP is un-forceable")
5191
+ prl.add_argument("version", help="the version string to cut (free-form: semver / calver / any)")
5192
+ prl.add_argument("--force", action="store_true",
5193
+ help="override the forceable floor rejects (NEVER release_security_open)")
5194
+ prl.add_argument("--with-waivers", action="store_true", dest="with_waivers",
5195
+ help="disclose riding RISK-ACCEPTED waivers (records them on the ledger row)")
5196
+ prl.add_argument("--evidence", default=None, help="the RELEASES.md row's evidence line")
5197
+ prl.set_defaults(func=cmd_release)
5198
+
4428
5199
  pau = sub.add_parser("audit",
4429
5200
  help="read-only: verify recorded human decision points left well-formed records "
4430
5201
  "(exit 1 on findings — the CI enforcement gate)")