@pilotspace/add 1.6.0 → 1.7.1

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
@@ -145,6 +145,8 @@ Status: DRAFT
145
145
  ### GATE RECORD
146
146
  Outcome:
147
147
  ## 7 · OBSERVE
148
+ ### Spec delta
149
+ ### Competency deltas
148
150
  """
149
151
 
150
152
 
@@ -170,6 +172,32 @@ def _atomic_write(path: Path, text: str) -> None:
170
172
  os.unlink(tmp)
171
173
 
172
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
+
173
201
  def _templates_dir() -> Path:
174
202
  return Path(__file__).resolve().parent / "templates"
175
203
 
@@ -476,15 +504,37 @@ def cmd_new_task(args: argparse.Namespace) -> None:
476
504
  _die("unknown_milestone")
477
505
  depends_on = _parse_deps(getattr(args, "depends_on", None))
478
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
+
479
523
  (tdir / "tests").mkdir(parents=True, exist_ok=True)
480
524
  (tdir / "src").mkdir(parents=True, exist_ok=True)
481
525
  title = args.title or slug.replace("-", " ").replace("_", " ").title()
482
526
  # inherit the project's DECLARED autonomy default (task init-auto-default) — fail-SAFE:
483
527
  # absent -> auto, garbled -> conservative; the posture is project-scoped, not hardcoded.
484
528
  autonomy = _project_autonomy(root)
485
- _atomic_write(task_md, _render_template(
529
+ rendered = _render_template(
486
530
  "TASK.md", title=title, slug=slug, date=date.today().isoformat(),
487
- 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)
488
538
  if _project_autonomy_token(root) == "?":
489
539
  print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
490
540
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
@@ -499,6 +549,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
499
549
  "created": _now(),
500
550
  "updated": _now(),
501
551
  }
552
+ if from_delta:
553
+ state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
502
554
  state["active_task"] = slug
503
555
  save_state(root, state)
504
556
  print(f"created task '{slug}' -> {task_md}")
@@ -510,10 +562,31 @@ def cmd_new_task(args: argparse.Namespace) -> None:
510
562
  # intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
511
563
  print(f"note: '{slug}' is not attached to a milestone — size it via /add (intake), "
512
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.")
513
568
  print("active task set. phase: ground. Gather the real codebase (section 0 GROUND).")
514
569
  print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
515
570
 
516
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
+
517
590
  def _parse_deps(raw: str | None) -> list[str]:
518
591
  if not raw:
519
592
  return []
@@ -1111,6 +1184,12 @@ def cmd_status(args: argparse.Namespace) -> None:
1111
1184
  open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1112
1185
  if open_deltas:
1113
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")
1114
1193
  # When the setup is unlocked, the only terminal guidance that matters is
1115
1194
  # review+lock; suppress the generic resume block so it does not compete.
1116
1195
  if unlocked:
@@ -2237,6 +2316,12 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
2237
2316
  noun = "delta" if open_deltas == 1 else "deltas"
2238
2317
  print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
2239
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")
2240
2325
  # the engine-sourced next step (converges the old "Confirm … archive/start the next" hint)
2241
2326
  print(_next_footer(root, state))
2242
2327
 
@@ -2333,6 +2418,15 @@ def cmd_compact(args: argparse.Namespace) -> None:
2333
2418
  if offenders:
2334
2419
  _die("open_deltas_unfolded: consolidate the open lessons first (`add.py deltas`) — "
2335
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))
2336
2430
  # every precondition passed — move (same-filesystem renames, never a delete)
2337
2431
  def _files(d: Path) -> int:
2338
2432
  return sum(1 for f in d.rglob("*") if f.is_file())
@@ -2802,9 +2896,17 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
2802
2896
  # ── §5 scope gate (build-scope-lock): touched ⊆ declared, from bytes alone ──────────
2803
2897
  # The walk's NAMED exclusion set — ONE constant; widening it is an additive
2804
2898
  # change-request, never silent. `.add` is engine domain (tripwire + audit guard it);
2805
- # the rest is VCS/bytecode/OS junk with no build signal.
2806
- _SCOPE_EXCLUDE_DIRS = (".git", ".add", "__pycache__", "node_modules")
2807
- _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")
2808
2910
 
2809
2911
 
2810
2912
  def _declared_scope(root: Path, slug: str) -> list[str] | None:
@@ -2863,14 +2965,15 @@ def _in_scope(rel: str, declared: list[str]) -> bool:
2863
2965
 
2864
2966
  def _scope_walk(rootp: Path) -> dict[str, str]:
2865
2967
  """{project-root-relative path: md5} over the project tree, pruning
2866
- _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
2867
2970
  unreadable at SNAPSHOT time is skipped; at the GATE the resulting absence
2868
2971
  reads as a touch (fail-closed at the biting end). Bytes only — no git."""
2869
2972
  files: dict[str, str] = {}
2870
2973
  for dirpath, dirnames, filenames in os.walk(rootp):
2871
2974
  dirnames[:] = [d for d in dirnames if d not in _SCOPE_EXCLUDE_DIRS]
2872
2975
  for name in filenames:
2873
- if name in _SCOPE_EXCLUDE_FILES or name.endswith(".pyc"):
2976
+ if name in _SCOPE_EXCLUDE_FILES or name.endswith(_SCOPE_EXCLUDE_SUFFIXES):
2874
2977
  continue
2875
2978
  p = Path(dirpath) / name
2876
2979
  h = _md5_file(p)
@@ -3026,23 +3129,37 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
3026
3129
  return "(unknown)", []
3027
3130
  text = f.read_text(encoding="utf-8")
3028
3131
  m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
3029
- lines = (m7.group(0) if m7 else text).splitlines()
3030
- # 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)".
3031
3137
  observe = "(unknown)"
3032
- for i, ln in enumerate(lines):
3033
- m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
3034
- 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":
3035
3141
  continue
3036
- parts = [m.group(1).strip()]
3037
- for nxt in lines[i + 1:]:
3038
- t = nxt.strip()
3039
- if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
3040
- break
3041
- parts.append(t)
3042
- joined = " ".join(p for p in parts if p).strip()
3043
- if joined and not joined.startswith("<"):
3044
- observe = joined
3045
- 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
3046
3163
 
3047
3164
  # deltas: each "- [COMP · status] ..." plus its indented continuation lines
3048
3165
  deltas, i = [], 0
@@ -3132,6 +3249,8 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
3132
3249
  "RISK-ACCEPTED": sum(1 for r in task_rows if r["gate"] == "RISK-ACCEPTED"),
3133
3250
  "HARD-STOP": sum(1 for r in task_rows if r["gate"] == "HARD-STOP")},
3134
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)),
3135
3254
  },
3136
3255
  "tasks": task_rows,
3137
3256
  "waivers": waivers,
@@ -3369,6 +3488,11 @@ def render_report(root: Path, state: dict, mslug: str, *,
3369
3488
  L.extend(_wrap(x, W - 5, f" {g['bullet']} "))
3370
3489
  else:
3371
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")
3372
3496
  L.append("") # DECIDE NEXT footer (v13): always present, APPEND-ONLY
3373
3497
  L.extend(_wrap(_decide_next_base(state, d), W - 15, " DECIDE NEXT "))
3374
3498
  if _planned_hint(d): # own segment so the phrase never splits mid-token
@@ -3741,6 +3865,18 @@ _DELTA_RE = re.compile(
3741
3865
  )
3742
3866
  _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
3743
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
+
3744
3880
  # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
3745
3881
  # A line with a `· ` bracket separator is a delta-attempt. Does NOT enumerate
3746
3882
  # competencies or statuses — a different abstraction from _DELTA_RE (no DRY violation).
@@ -3748,21 +3884,25 @@ _TAG_BROAD_RE = re.compile(r"^\s*-\s*\[\s*([^\]·]+?)\s*·\s*([^\]·]+?)\s*\]\s*
3748
3884
 
3749
3885
 
3750
3886
  def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
3751
- """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.
3752
3888
 
3753
3889
  Returns:
3754
3890
  None — no delta-attempts found; no check emitted.
3755
3891
  (True, "") — all open entries pass.
3756
3892
  (False, "<code> -> <tag line>") — first failing entry with its failure code.
3757
3893
 
3758
- Contract rules (frozen §3, v1):
3894
+ Contract rules (frozen §3, spec-delta-grammar v1):
3759
3895
  - SKIP HTML-comment lines and blank lines (they are never tag lines).
3760
- - Group lines into ENTRIES: a broad tag line starts an entry; following lines
3761
- 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.
3762
3898
  - A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag.
3763
- - For each entry, skip folded/rejected (open-only history not retrofitted).
3764
- - Validate the remaining (open) entries: COMP in _COMPETENCY_ORDER,
3765
- 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.
3766
3906
  - Fail-closed: an unparseable attempt FAILS (never silently passes).
3767
3907
  """
3768
3908
  task_md = root / "tasks" / slug / "TASK.md"
@@ -3773,71 +3913,64 @@ def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
3773
3913
  except OSError:
3774
3914
  return None
3775
3915
 
3776
- # Locate the "### Competency deltas" block.
3777
- block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
3778
- 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:
3779
3925
  return None
3780
3926
 
3781
- block = block_match.group(1)
3782
- raw_lines = block.splitlines()
3783
-
3784
- # First pass: collect entries (tag line + continuations).
3785
- # HTML-comment lines are skipped entirely (invisible to the guard).
3786
- # 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.
3787
3929
  entries: list[tuple[str, list[str]]] = [] # (tag_line, [tag_line, *continuations])
3788
- current: list[str] | None = None
3789
- for raw_line in raw_lines:
3790
- stripped = raw_line.strip()
3791
- # Skip HTML-comment lines.
3792
- if stripped.startswith("<!--"):
3793
- continue
3794
- # Blank line terminates the current entry.
3795
- if not stripped:
3796
- current = None
3797
- continue
3798
- # Broad tag detection: any "- [tok · tok]" line starts a new entry.
3799
- m = _TAG_BROAD_RE.match(raw_line)
3800
- if m:
3801
- current = [stripped]
3802
- entries.append((stripped, current))
3803
- elif current is not None:
3804
- # Continuation line of the current entry.
3805
- current.append(stripped)
3806
- # 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)
3807
3944
 
3808
3945
  if not entries:
3809
3946
  return None # no delta-attempts → no check emitted
3810
3947
 
3811
- # 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.
3812
3950
  for tag_line, unit_lines in entries:
3813
3951
  m = _TAG_BROAD_RE.match(tag_line)
3814
3952
  if not m:
3815
- # Should not happen, but fail-closed.
3816
- return False, f"malformed_delta -> {tag_line}"
3953
+ return False, f"malformed_delta -> {tag_line}" # fail-closed
3817
3954
  raw_comp = m.group(1).strip()
3818
3955
  raw_status = m.group(2).strip()
3956
+ tail = m.group(3).strip()
3819
3957
 
3820
- # Step 1: skip historical entries (folded/rejected) open-only enforcement.
3821
- # MUST happen before competency/status validation per §3: "history not retrofitted".
3822
- 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:
3823
3963
  continue
3824
3964
 
3825
- # Step 2: use _DELTA_RE (the canonical grammar, single source of truth) to test
3826
- # whether the tag line is a fully-valid delta shape. If it matches, check evidence
3827
- # only. If it fails, classify the failure via the raw tokens (never a parallel grammar).
3828
- unit_text = " ".join(unit_lines)
3829
- if _DELTA_RE.match(tag_line):
3830
- # Valid comp + status + non-empty tail — check evidence in the joined unit.
3831
- if "(evidence:" not in unit_text:
3832
- return False, f"no_evidence -> {tag_line}"
3833
- else:
3834
- # Classify why _DELTA_RE rejected it (open entries only — folded/rejected skipped).
3835
- if raw_comp not in _COMPETENCY_ORDER:
3836
- return False, f"unknown_competency -> {tag_line}"
3837
- if raw_status not in _DELTA_STATUSES:
3838
- return False, f"unknown_status -> {tag_line}"
3839
- # 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:
3840
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}"
3841
3974
 
3842
3975
  return True, ""
3843
3976
 
@@ -3897,6 +4030,295 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
3897
4030
  return by_comp
3898
4031
 
3899
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
+
3900
4322
  _AUDIT_STAMP_RE = re.compile(r"Status:\s*FROZEN @ v\d+\s*[—-]+\s*approved by\s+\S+")
3901
4323
  _AUDIT_OUTCOME_RE = re.compile(r"^Outcome:\s*(PASS|RISK-ACCEPTED|HARD-STOP)\b", re.M)
3902
4324
  _AUDIT_SECURITY_RE = re.compile(
@@ -4420,35 +4842,43 @@ def cmd_release(args: argparse.Namespace) -> None:
4420
4842
 
4421
4843
 
4422
4844
  def cmd_deltas(args: argparse.Namespace) -> None:
4423
- """Read-only: report all open lessons learned grouped by competency.
4845
+ """Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY.
4424
4846
 
4425
- Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
4426
- matching the delta grammar; shows only `open` entries in canonical competency
4427
- order (DDD·SDD·UDD·TDD·ADD). --json emits one JSON object. Exit 0 ALWAYS.
4428
- 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."""
4429
4851
  root = _require_root()
4430
4852
  by_comp = _collect_open_deltas(root)
4431
4853
  total = sum(len(v) for v in by_comp.values())
4854
+ spec = _collect_open_spec_deltas(root)
4432
4855
 
4433
4856
  if getattr(args, "json", False):
4434
4857
  payload: dict = {
4435
4858
  "total": total,
4436
4859
  "by_competency": {c: v for c, v in by_comp.items() if v},
4860
+ "spec": spec,
4861
+ "spec_total": len(spec),
4437
4862
  }
4438
4863
  print(json.dumps(payload, ensure_ascii=False))
4439
4864
  return
4440
4865
 
4441
- if total == 0:
4866
+ if total == 0 and not spec:
4442
4867
  print("no open deltas.")
4443
4868
  return
4444
4869
 
4445
- print(f"open lessons learned ({total} total):")
4446
- for comp in _COMPETENCY_ORDER:
4447
- entries = by_comp[comp]
4448
- if not entries:
4449
- continue
4450
- print(f" {comp} ({len(entries)}):")
4451
- 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:
4452
4882
  print(f" - {e['text']} [{e['task']}]")
4453
4883
 
4454
4884
 
@@ -4581,9 +5011,17 @@ def build_parser() -> argparse.ArgumentParser:
4581
5011
  pn.add_argument("--milestone", default=None, help="attach to a milestone (default: active)")
4582
5012
  pn.add_argument("--depends-on", dest="depends_on", default=None,
4583
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])")
4584
5017
  pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
4585
5018
  pn.set_defaults(func=cmd_new_task)
4586
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
+
4587
5025
  pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
4588
5026
  pm.add_argument("slug")
4589
5027
  pm.add_argument("--title", default=None)
@@ -4724,6 +5162,13 @@ def build_parser() -> argparse.ArgumentParser:
4724
5162
  pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
4725
5163
  pdt.set_defaults(func=cmd_deltas)
4726
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
+
4727
5172
  pgr = sub.add_parser("graduation-report",
4728
5173
  help="read-only: gather the MVP loop's evidence (deltas · waivers · RETROs · "
4729
5174
  "residue · coverage gaps) for a graduation interview — gathers, never judges")