@pilotspace/add 1.6.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/CHANGELOG.md +55 -0
- package/README.md +34 -1
- package/bin/cli.js +449 -58
- package/docs/02-the-flow.md +1 -1
- package/docs/04-step-2-scenarios.md +6 -0
- package/docs/09-the-loop.md +2 -0
- package/docs/appendix-c-glossary.md +8 -0
- package/package.json +4 -1
- package/skill/add/SKILL.md +26 -0
- package/skill/add/fold.md +53 -35
- package/skill/add/graduate.md +2 -1
- package/skill/add/intake.md +1 -0
- package/skill/add/loop.md +18 -4
- package/skill/add/phases/0-setup.md +1 -1
- package/skill/add/phases/2-scenarios.md +2 -0
- package/skill/add/phases/3-contract.md +1 -1
- package/skill/add/phases/6-verify.md +1 -1
- package/skill/add/release.md +2 -1
- package/skill/add/report-template.md +42 -6
- package/skill/add/scope.md +1 -0
- package/tooling/add.py +536 -91
- package/tooling/templates/GLOSSARY.md.tmpl +2 -0
- package/tooling/templates/MILESTONE.md.tmpl +23 -0
- package/tooling/templates/TASK.md.tmpl +5 -1
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
|
-
|
|
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
|
|
2806
|
-
|
|
2807
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
3030
|
-
|
|
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
|
|
3033
|
-
m =
|
|
3034
|
-
if
|
|
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
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
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'
|
|
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;
|
|
3761
|
-
until next tag / blank /
|
|
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
|
-
-
|
|
3764
|
-
|
|
3765
|
-
status
|
|
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
|
|
3777
|
-
|
|
3778
|
-
|
|
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
|
-
|
|
3782
|
-
|
|
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
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3821
|
-
#
|
|
3822
|
-
|
|
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
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
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
|
|
4845
|
+
"""Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY.
|
|
4424
4846
|
|
|
4425
|
-
Scans every .add/tasks/*/TASK.md '### Competency deltas'
|
|
4426
|
-
|
|
4427
|
-
|
|
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
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
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")
|