@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/CHANGELOG.md +91 -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/16-releasing.md +182 -0
- package/docs/README.md +3 -0
- package/docs/appendix-c-glossary.md +19 -1
- package/package.json +4 -1
- package/skill/add/SKILL.md +33 -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 +115 -0
- package/skill/add/report-template.md +42 -6
- package/skill/add/scope.md +1 -0
- package/tooling/add.py +862 -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
|
@@ -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
|
-
|
|
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
|
|
2790
|
-
|
|
2791
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
3014
|
-
|
|
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
|
|
3017
|
-
m =
|
|
3018
|
-
if
|
|
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
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
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'
|
|
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;
|
|
3745
|
-
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.
|
|
3746
3898
|
- A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag.
|
|
3747
|
-
-
|
|
3748
|
-
|
|
3749
|
-
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.
|
|
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
|
|
3761
|
-
|
|
3762
|
-
|
|
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
|
-
|
|
3766
|
-
|
|
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
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3805
|
-
#
|
|
3806
|
-
|
|
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
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
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
|
|
4845
|
+
"""Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY.
|
|
4118
4846
|
|
|
4119
|
-
Scans every .add/tasks/*/TASK.md '### Competency deltas'
|
|
4120
|
-
|
|
4121
|
-
|
|
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
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
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)")
|