diff --git a/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md b/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md index 66a91534f..b04d6c7f3 100644 --- a/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md +++ b/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md @@ -22,9 +22,26 @@ Actions (`.gitea/workflows/docker-build.yml`), Husky hooks. - Work in the worktree `feat/520-521-decision-lifecycle` off `origin/main`. Never commit in `/Users/timothy/ersatztv`. -- This arc rewrites `docs/decisions.md` wholesale; land it as ONE PR, rebase-then- - `merge_when_checks_succeed`. On rebase conflicts in generated artifacts (`docs/decisions/README.md`), - REGENERATE — never hand-merge. +- **Two PRs, one arc** (Fable review #6; matches #521's "bounded topic batches, avoid a single + conflict-heavy rewrite"): + - **PR1 — decision-lifecycle machinery** (Tasks 1, 2, 3, 5, 7): parser, validator, catalog + generator, exemplar reconciliations (incl. dogfooding the append-only→lifecycle supersession), + guard swap, `decisions.md` header rewrite. CI wires the **decisions** validator + catalog `--check` + only. Small, low-conflict diff. The validator tolerates `legacy-unmigrated`, so concurrent sessions + appending old-style entries stay green. Branch `feat/521-decision-lifecycle-machinery` off + `origin/main`. + - **PR2 — migration + startup** (Tasks 4, 6, 8, 9): kickoff regression guard + its CI wiring, full + corpus migration, #520 kickoff rewrite, retrieval-eval. **The kickoff guard lives here, not in PR1** + — it fails on the pre-rewrite docs (they still say "read #237"), so it can only be wired into CI in + the same PR that cleans the language (Task 8). Branched off PR1's merged head. Migration conflicts + are cheap (a concurrently appended unmigrated entry just gets a metadata block on rebase). + - Each PR: rebase-then-`merge_when_checks_succeed`. On rebase conflicts in generated artifacts + (`docs/decisions/README.md`), REGENERATE — never hand-merge. +- **`[decisions-edit]` is kept narrow, not retired** (Fable review #3/#4): lifecycle *metadata* writes + (add record, set `superseded-by`, move to archive, regen catalog) are token-free — the validator + proves them well-formed. A change to a record's *rationale prose* still requires `[decisions-edit]` + in the commit range, enforced by the validator's body-diff (Task 2). This closes the silent-rewrite + hole while removing friction from routine lifecycle writes. - Metadata block format (verbatim; the parser depends on it), placed immediately under each `##` heading as the first non-blank lines: ``` @@ -239,7 +256,11 @@ def parse_text(text: str, source: Path) -> list[Record]: rec.since = meta.get("since") or None rec.supersedes = meta.get("supersedes") or None rec.superseded_by = meta.get("superseded-by") or None + # The metadata block is contiguous: scan only until the first blank line, so a + # bolded **Rule:** appearing later inside rationale prose can't overwrite the real one. for bl2 in body_lines[k + 1 :]: + if not bl2.strip(): + break if bl2.startswith("**Rule:**"): rec.rule = bl2[len("**Rule:**") :].strip() elif bl2.startswith("**Signals:**"): @@ -292,11 +313,18 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): decision-record parser (de - Test: `scripts/tests/test_decisions_validate.py` (+ good/bad fixtures) **Interfaces:** -- Consumes: `decisions_lib.all_active_records`, `Record`, `KEY_RE`, `STATUSES`, `ARCHIVE_DIR`. -- Produces: `validate(records, archive_records, catalog_ok, budget, removed_active_headings) -> - list[str]` (returns list of error strings; empty = pass); CLI `main()` with - `--base REF --head REF` (no-vanish diff), `--budget N`, exit 1 on any error. +- Consumes: `decisions_lib` (`all_active_records`, `parse_text`, `KEY_RE`, `STATUSES`, `ARCHIVE_DIR`, + `TOPIC_DIR`, `DECISIONS_MD`). +- Produces: `validate(records, *, archive_keys, catalog_ok, budget_ok, removed, rewritten) -> + list[str]` (returns error strings; empty = pass); CLI `main()` with `--base REF --head REF` (enables + the merge-base no-vanish + body-diff checks), `--budget N`, exit 1 on any error. - Non-decision headings skipped: `{"Index", "Active catalog"}` (configurable `SKIP_HEADINGS`). +- Diff semantics (only when `--base/--head` given): compute `mb = git merge-base BASE HEAD` and diff + `mb → HEAD` (never BASE-tip, which false-flags on every main advance). `removed` = active headings + at `mb` gone at `HEAD` and not present in `archive/` at `HEAD` (across `decisions.md` AND all topic + files). `rewritten` = headings whose *rationale body* (record body minus the metadata/Rule/Signals/ + Mechanics lines) changed `mb → HEAD` (for surviving records) OR whose archived copy body ≠ its `mb` + active body — UNLESS `[decisions-edit]` appears in `git log --format=%B mb..HEAD`. - [ ] **Step 1: Write failing tests** `scripts/tests/test_decisions_validate.py` @@ -327,34 +355,43 @@ def _rec(**kw): return dl.Record(**base) +def _v(recs, **kw): + args = dict(archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[], rewritten=[]) + args.update(kw) + return dv.validate(recs, **args) + + def test_two_active_same_key_fails(): - recs = [_rec(key="a.b"), _rec(key="a.b")] - errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[]) - assert any("more than one active" in e for e in errs) + assert any("more than one active" in e for e in _v([_rec(key="a.b"), _rec(key="a.b")])) def test_bad_key_format_fails(): - recs = [_rec(key="BadKey")] - errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[]) - assert any("key format" in e for e in errs) + assert any("key format" in e for e in _v([_rec(key="BadKey")])) def test_dangling_superseded_by_fails(): recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-07-01")] - errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[]) - assert any("superseded-by" in e and "a.c" in e for e in errs) + assert any("superseded-by" in e and "a.c" in e for e in _v(recs)) + + +def test_superseded_by_resolves_to_archive_key_passes(): + # successor lives in archive → known via archive_keys, no dangling error + recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-07-01")] + assert not any("superseded-by" in e for e in _v(recs, archive_keys={"a.c"})) def test_removed_active_not_in_archive_fails(): - errs = dv.validate([], archive_keys=set(), catalog_ok=True, budget_ok=True, - removed=["2026-01-01 — Gone (#9)"]) - assert any("removed from the active set" in e for e in errs) + assert any("removed from the active set" in e + for e in _v([], removed=["2026-01-01 — Gone (#9)"])) + + +def test_rewritten_rationale_without_token_fails(): + assert any("rationale" in e and "decisions-edit" in e + for e in _v([_rec(key="a.b")], rewritten=["2026-01-01 — Reworded (#9)"])) def test_clean_corpus_passes(): - recs = [_rec(key="a.b"), _rec(key="c.d")] - errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[]) - assert errs == [] + assert _v([_rec(key="a.b"), _rec(key="c.d")]) == [] ``` - [ ] **Step 2: Run to verify fail** @@ -370,7 +407,9 @@ Expected: FAIL (`ModuleNotFoundError: scripts.decisions_validate`) Replaces the line-level append-only guard (ersatztv#303 H9) with lifecycle checks that preserve its spirit — rationale is never silently rewritten or deleted; every history touch is deliberate and -reviewable. Fail-open on tooling trouble (missing refs, parse issues), matching the old guard. +reviewable. `[decisions-edit]` is kept ONLY for rationale-prose edits (see body-diff below); routine +lifecycle metadata writes are token-free. Fail-open on tooling trouble (missing refs, git errors, +parse issues), matching the old guard. """ from __future__ import annotations @@ -383,6 +422,7 @@ import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as m SKIP_HEADINGS = {"Index", "Active catalog"} REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by") +EDIT_TOKEN = "[decisions-edit]" def _key_of(ref: str | None) -> str | None: @@ -391,16 +431,17 @@ def _key_of(ref: str | None) -> str | None: return ref.split("@", 1)[0].strip() -def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[str]: +def validate(records, *, archive_keys, catalog_ok, budget_ok, removed, rewritten) -> list[str]: errs: list[str] = [] - decision_recs = [r for r in records if r.heading not in SKIP_HEADINGS] + decision_recs = [ + r for r in records + if r.heading not in SKIP_HEADINGS and r.status != "legacy-unmigrated" + ] active_by_key: dict[str, int] = {} known_keys = set(archive_keys) for r in decision_recs: - if r.status == "legacy-unmigrated": - continue - if r.status not in dl.STATUSES or r.status == "legacy-unmigrated": + if r.status not in dl.STATUSES: errs.append(f"{r.heading}: invalid status {r.status!r}") if not r.key or not dl.KEY_RE.match(r.key): errs.append(f"{r.heading}: bad key format {r.key!r}") @@ -416,7 +457,7 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s if n > 1: errs.append(f"key {key!r}: more than one active record ({n})") - # reciprocal supersession + # reciprocal supersession (successor may live in active OR archive) for r in decision_recs: sk = _key_of(r.superseded_by) if sk and sk not in known_keys: @@ -426,9 +467,9 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s errs.append(f"{r.heading}: supersedes points to unknown key {pk!r}") for h in removed: - errs.append( - f"record removed from the active set without an archive copy: {h!r}" - ) + errs.append(f"record removed from the active set without an archive copy: {h!r}") + for h in rewritten: + errs.append(f"rationale prose of {h!r} changed without the {EDIT_TOKEN} token") if not catalog_ok: errs.append("docs/decisions/README.md active catalog is stale — run build_decisions_catalog.py") @@ -437,6 +478,106 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s return errs +# ---- git helpers (all fail-open: return neutral values on any error) ---- + +def _run(args: list[str]) -> str | None: + r = subprocess.run(args, capture_output=True, text=True) + return r.stdout if r.returncode == 0 else None + + +def _merge_base(base: str, head: str) -> str | None: + out = _run(["git", "merge-base", base, head]) + return out.strip() if out else None + + +def _active_paths_at(ref: str) -> list[str]: + """Active decision files at a ref: decisions.md + docs/decisions/*.md minus README/archive.""" + out = _run(["git", "ls-tree", "-r", "--name-only", ref, "docs/decisions.md", "docs/decisions/"]) + if out is None: + return [] + paths = [] + for p in out.splitlines(): + if not p.endswith(".md"): + continue + if p.startswith("docs/decisions/archive/") or p == "docs/decisions/README.md": + continue + paths.append(p) + return paths + + +def _records_at(ref: str, paths: list[str]): + """{heading: Record} across the given paths at a ref.""" + by_heading = {} + for p in paths: + blob = _run(["git", "show", f"{ref}:{p}"]) + if blob is None: + continue + for rec in dl.parse_text(blob, Path(p)): + if rec.heading not in SKIP_HEADINGS: + by_heading[rec.heading] = rec + return by_heading + + +def _archive_records_at(ref: str): + out = _run(["git", "ls-tree", "-r", "--name-only", ref, "docs/decisions/archive/"]) + paths = [p for p in (out or "").splitlines() if p.endswith(".md")] + by_heading = {} + for p in paths: + blob = _run(["git", "show", f"{ref}:{p}"]) + if blob is None: + continue + for rec in dl.parse_text(blob, Path(p)): + by_heading[rec.heading] = rec + return by_heading + + +def _rationale(rec) -> str: + """Record body with the metadata block stripped, whitespace-normalized — the prose we protect.""" + keep = [] + seen_meta = False + for line in rec.body.splitlines(): + s = line.strip() + if not seen_meta and (s.startswith("`key:") or s.startswith("**Rule:**") + or s.startswith("**Signals:**") or s.startswith("**Mechanics:**")): + seen_meta = True + continue + if seen_meta and (s.startswith("**Rule:**") or s.startswith("**Signals:**") + or s.startswith("**Mechanics:**") or s.startswith("`key:")): + continue + keep.append(s) + return "\n".join(x for x in keep if x) + + +def _diff_findings(base: str, head: str) -> tuple[list[str], list[str]]: + """(removed, rewritten) between merge-base(base,head) and head. Fail-open → ([], []).""" + mb = _merge_base(base, head) + if not mb: + return [], [] + if EDIT_TOKEN.lower() in (_run(["git", "log", "--format=%B", f"{mb}..{head}"]) or "").lower(): + token = True + else: + token = False + + base_active = _records_at(mb, _active_paths_at(mb)) + head_active = _records_at(head, _active_paths_at(head)) + head_archive = _archive_records_at(head) + + gone = set(base_active) - set(head_active) + removed = sorted(h for h in gone if h not in head_archive) + + rewritten: list[str] = [] + if not token: + # surviving records whose rationale prose changed + for h in set(base_active) & set(head_active): + if _rationale(base_active[h]) != _rationale(head_active[h]): + rewritten.append(h) + # archived records must body-match their base active copy (no laundering rewrites via archive) + for h in gone & set(head_archive): + if _rationale(base_active[h]) != _rationale(head_archive[h]): + rewritten.append(h) + return removed, sorted(rewritten) + + def _archive_keys() -> set[str]: keys: set[str] = set() if dl.ARCHIVE_DIR.exists(): @@ -447,42 +588,7 @@ def _archive_keys() -> set[str]: return keys -def _removed_active(base: str, head: str) -> list[str]: - """Headings present in active files at base but gone at head, not landing in archive.""" - try: - out = subprocess.run( - ["git", "diff", "--numstat", f"{base}...{head}", "--", "docs/decisions.md", - "docs/decisions/"], - capture_output=True, text=True, check=True, - ).stdout - except Exception: - return [] # fail-open - # Compare parsed headings in active files between the two trees. - def headings_at(ref: str) -> set[str]: - res: set[str] = set() - for path in ("docs/decisions.md",): - blob = subprocess.run(["git", "show", f"{ref}:{path}"], - capture_output=True, text=True) - if blob.returncode == 0: - res |= {r.heading for r in dl.parse_text(blob.stdout, Path(path))} - return res - def archive_headings_at(ref: str) -> set[str]: - res: set[str] = set() - names = subprocess.run(["git", "ls-tree", "-r", "--name-only", ref, - "docs/decisions/archive/"], capture_output=True, text=True).stdout - for path in names.splitlines(): - if path.endswith(".md"): - blob = subprocess.run(["git", "show", f"{ref}:{path}"], - capture_output=True, text=True) - if blob.returncode == 0: - res |= {r.heading for r in dl.parse_text(blob.stdout, Path(path))} - return res - gone = headings_at(base) - headings_at(head) - {"Index", "Active catalog"} - landed = archive_headings_at(head) - return sorted(gone - landed) - - -def _budget_ok(records, limit: int) -> bool: +def _budget_ok(limit: int) -> bool: total = 0 for f in dl.active_files(): if f.exists(): @@ -497,7 +603,7 @@ def _catalog_ok() -> bool: try: import scripts.build_decisions_catalog as bc except Exception: - return True # fail-open; generator task lands with this validator + return True # fail-open; the CI job also runs an independent --check step want = bc.render_catalog(dl.all_active_records()) cat = dl.TOPIC_DIR / "README.md" have = cat.read_text(encoding="utf-8") if cat.exists() else "" @@ -512,13 +618,14 @@ def main(argv=None) -> int: args = ap.parse_args(argv) records = dl.all_active_records() - removed = _removed_active(args.base, args.head) if args.base and args.head else [] + removed, rewritten = _diff_findings(args.base, args.head) if args.base and args.head else ([], []) errs = validate( records, archive_keys=_archive_keys(), catalog_ok=_catalog_ok(), - budget_ok=_budget_ok(records, args.budget), + budget_ok=_budget_ok(args.budget), removed=removed, + rewritten=rewritten, ) unmigrated = [r for r in records if r.status == "legacy-unmigrated" @@ -622,9 +729,13 @@ def _rel(rec) -> str: def _anchor(heading: str) -> str: + # Gitea/GitHub slugger: lowercase, keep alphanumerics + underscore, spaces/hyphens → '-', + # drop other punctuation. Keeping '_' matters — headings like "…(`iptv.base_url`)…" anchor to + # …iptvbase_url…. VERIFY against Gitea's actual output on 2-3 real headings before trusting links + # (the catalog is the startup surface; a dead link there is expensive). out = [] for ch in heading.lower(): - if ch.isalnum(): + if ch.isalnum() or ch == "_": out.append(ch) elif ch in " -": out.append("-") @@ -696,23 +807,28 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): active-catalog generator" **Interfaces:** - Produces: exit 0 if no active file contains #237-as-live-state language; exit 1 + offending - file:line otherwise. Scans `docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`, - `CLAUDE.md`. Allow-lists lines tagged `` or within an "Archived / historical" - section. + file:line otherwise. Scans `docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`, `CLAUDE.md`, + `scripts/select-queue.sh`. A hit is exempt if the line contains `archiv` (case-insensitive) OR the + scanner is currently inside an archival section — a heading matching `^#{1,6}.*archiv` (case- + insensitive) opens the section; the next same-or-higher-level heading closes it. The `archiv` + substring is a deliberate anti-footgun escape hatch, not an anti-adversary control. - [ ] **Step 1: Write `scripts/check-kickoff-guard.sh`** ```bash #!/usr/bin/env bash # ersatztv#520 — block re-treating the CLOSED arc tracker #237 as live queue state / source of truth. -# Scans active startup docs for forbidden phrasings. An intentional archival mention must carry the -# marker `` on the same line (or be inside a line already containing "archiv"). +# Scans active startup docs for forbidden phrasings. A hit is exempt when the line itself contains +# "archiv" OR the line is inside an archival section (a heading whose text contains "archiv", up to +# the next heading of the same or higher level). The "archiv" escape is an anti-footgun convenience, +# not an anti-adversary control. set -euo pipefail FILES=( "docs/handoffs/chicorytv-issue-queue.md" "docs/README.md" "CLAUDE.md" + "scripts/select-queue.sh" ) # Case-insensitive patterns that assert #237 is live/authoritative. PATTERNS='read #237|#237.?s (body|arc|comments)|tracker #237|queue state lives in|read that, not this file' @@ -720,12 +836,27 @@ PATTERNS='read #237|#237.?s (body|arc|comments)|tracker #237|queue state lives i rc=0 for f in "${FILES[@]}"; do [ -f "$f" ] || continue - while IFS=: read -r lineno content; do - [ -n "$lineno" ] || continue - printf '%s' "$content" | grep -qiE 'archiv|archival:237' && continue - echo "kickoff-guard: $f:$lineno reintroduces #237-as-live-state: ${content}" >&2 - rc=1 - done < <(grep -niE "$PATTERNS" "$f" || true) + archive_level=0 # 0 = not in an archival section; else the heading level that opened it + lineno=0 + while IFS= read -r line; do + lineno=$((lineno + 1)) + # Track archival-section state via Markdown ATX headings. + if [[ "$line" =~ ^(#{1,6})[[:space:]] ]]; then + level=${#BASH_REMATCH[1]} + if printf '%s' "$line" | grep -qi 'archiv'; then + archive_level=$level + elif [ "$archive_level" -ne 0 ] && [ "$level" -le "$archive_level" ]; then + archive_level=0 # a same-or-higher heading closes the archival section + fi + continue + fi + [ "$archive_level" -ne 0 ] && continue # inside archival section → exempt + printf '%s' "$line" | grep -qi 'archiv' && continue # per-line escape + if printf '%s' "$line" | grep -qiE "$PATTERNS"; then + echo "kickoff-guard: $f:$lineno reintroduces #237-as-live-state: ${line}" >&2 + rc=1 + fi + done < "$f" done [ "$rc" -eq 0 ] && echo "kickoff-guard: OK" exit "$rc" @@ -767,6 +898,15 @@ git -c core.hooksPath=/dev/null commit -m "feat(520): kickoff regression guard ( `**Signals:**`, `**Mechanics:**`. - [ ] **Step 3** Do the same reconciliation for the #411 obsolescence (superseded measurement): status `retired` or `superseded` per whether the governed surface still exists; archive if superseded. +- [ ] **Step 3b (dogfood supersession — Fable #5)** This PR *reverses a recorded decision*: the #303 + H9 "docs/decisions.md is append-only" decision (in `docs/decisions/release-ci-governance.md`) is + replaced by the lifecycle validator. The very first PR under the new regime must model the same-PR + supersession it mandates. Add a NEW active record for the lifecycle decision (key + `docs.decision-lifecycle`, status `active`, `supersedes: docs.append-only-guard@2026-06`); give the + existing H9 record the key `docs.append-only-guard`, set `status: superseded`, `superseded-by: + docs.decision-lifecycle@2026-07-21`, and move it to `docs/decisions/archive/release-ci-governance.md` + with a back-link. (Note: the `## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237)` + record is reversed by #520, not this PR — its supersession lands in PR2/Task 8.) - [ ] **Step 4** Start `docs/decisions/migration-map.md`: ```markdown @@ -811,22 +951,34 @@ C = `release-ci-governance.md`; D = `spa-modularization.md`; E–H = `decisions. slices). Dispatch one subagent per batch (balanced/cheap tier; each with its OWN worktree/branch off the feature branch — never two committing agents on one worktree). Each subagent's brief: -- Read the schema format + one exemplar (paste both into the brief). +- Read the schema format + one exemplar (paste both into the brief) + **your batch's pre-assigned + heading→key table** (the orchestrator assigns keys; you do NOT invent them — see Step 0). - For each `##` entry in your assigned file/range: insert the metadata block as the first non-blank - lines under the heading. Derive `key` from the subject (stable, dotted; REUSE an existing key only - for a genuine supersessor). `status: active` unless the prose says the decision was reversed/retired - (then flag it for the orchestrator — do NOT self-archive). `since` = the entry date. `**Rule:**` = - the entry's one-line current rule (from its own text). `**Signals:**` = concepts + `paths:` + - `issues:` mined from the entry. `**Mechanics:**` = any doc/source anchors the entry already cites. -- Do NOT alter rationale prose. Do NOT delete anything. -- Add a migration-map row per entry. -- Return the list of entries you flagged as reversed/retired for orchestrator reconciliation. + lines under the heading, using the key from the supplied table. `status: active` unless the prose + says the decision was reversed/retired (then flag it — do NOT self-archive, do NOT set a supersede + link). `since` = the entry date. `**Rule:**` = the entry's one-line current rule (from its own + text). `**Signals:**` = concepts + `paths:` + `issues:` mined from the entry. `**Mechanics:**` = any + doc/source anchors the entry already cites. +- Do NOT alter rationale prose. Do NOT delete anything. (The validator's body-diff will red-CI a + rationale change without `[decisions-edit]`; the migration commit carries the token, but keeping + prose byte-stable avoids masking a real edit.) +- **RETURN** (do not write) one migration-map row per entry + the list of entries you flagged as + reversed/retired. The orchestrator writes `migration-map.md` once, to avoid inter-batch conflicts. -- [ ] **Step 1** Dispatch batch subagents (2–3 concurrent; gate on free RAM). Collect flagged - reversals. -- [ ] **Step 2** Orchestrator reconciles every flagged reversal/retirement (assign supersede links, - move to `docs/decisions/archive/`, update predecessor). This is judgment work — not delegated. -- [ ] **Step 3** Merge batch branches back into the feature branch. +- [ ] **Step 0 (orchestrator, judgment — before fan-out)** Build the full heading→key table for the + whole corpus (this IS the migration-map skeleton). Assign stable dotted keys by *subject*, so related + decisions across batches share a family prefix and no two batches coin divergent keys for one + subject (the ≤1-active-per-key check catches collisions, NOT divergent keys). Reuse an existing key + only for a genuine supersessor. +- [ ] **Step 1** Dispatch batch subagents (2–3 concurrent; gate on free RAM), each with its key-table + slice. Collect returned rows + flagged reversals. +- [ ] **Step 1b (reversal backstop — Fable #7)** Cheap agents WILL miss prose reversals. Orchestrator + greps the whole corpus for `supersed|reversed|obsolete|no longer|retired|deprecat` and reconciles + every hit against the flag list — a hit not already flagged is a missed reversal to resolve. +- [ ] **Step 2** Orchestrator reconciles every flagged/found reversal/retirement (assign supersede + links, move to `docs/decisions/archive/`, update predecessor). Judgment — not delegated. +- [ ] **Step 3** Merge batch branches back into the feature branch; orchestrator writes + `docs/decisions/migration-map.md` once from the returned rows. - [ ] **Step 4** Regenerate catalog: `PYTHONPATH=. python scripts/build_decisions_catalog.py`. - [ ] **Step 5** Run validator: `PYTHONPATH=. python scripts/decisions_validate.py` — expect OK and **0** legacy-unmigrated (or an explicitly-listed, tracked remainder if the safety valve is invoked). @@ -840,30 +992,39 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): migrate decision corpus to --- -## Task 7: Retire the append-only line guard + `[decisions-edit]`; wire the validator +## Task 7: Rework the guard — retire the line-level mechanic, keep `[decisions-edit]` narrow; wire the validator **Files:** -- Modify: `.claude/hooks/decisions-guard.sh` (replace body with a validator shim, or delete + rewire). -- Modify: `.husky/commit-msg`, `.husky/pre-commit` (call validator instead of append-only guard). +- Modify: `.claude/hooks/decisions-guard.sh` (replace body with a validator shim). +- Modify: `.husky/commit-msg`, `.husky/pre-commit` (call validator instead of append-only line guard). - Modify: `.gitea/workflows/docker-build.yml` (`decisions-guard` job). - Modify: `docs/decisions.md` header (append-only framing → lifecycle framing). -- Modify: `docs/ci-cd.md` (release ritual; drop `[decisions-edit]`, aggregate budget). +- Modify: `docs/ci-cd.md` (release ritual; re-scope `[decisions-edit]`, aggregate budget). -- [ ] **Step 1** Replace `.claude/hooks/decisions-guard.sh` with a thin wrapper: +- [ ] **Step 1** Replace `.claude/hooks/decisions-guard.sh` with a thin, **fail-open** wrapper (Fable + #12ii — python missing must NOT wedge a commit): ```bash #!/usr/bin/env bash -# ersatztv#521 — the append-only line guard is retired. Decision integrity is now enforced by the -# lifecycle validator. This shim keeps the old hook path working: it runs the validator over the -# working tree. Fail-open on tooling trouble. -set -euo pipefail -cd "$(git rev-parse --show-toplevel)" -PYTHONPATH=. python3 scripts/decisions_validate.py || exit 1 +# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by +# the lifecycle validator. `[decisions-edit]` survives ONLY for rationale-prose edits (validator +# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no- +# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 # no python -> fail-open +PYTHONPATH=. python3 scripts/decisions_validate.py +rc=$? +[ "$rc" -eq 1 ] && exit 1 # only a real validation failure blocks +exit 0 # crashes/other codes -> fail-open ``` -- [ ] **Step 2** Update `.husky/commit-msg`: remove the `[decisions-edit]`/append-only block; the - Co-Authored-By check stays. Add the validator to `.husky/pre-commit` (or keep it in commit-msg - via the shim). Verify `grep -rn 'decisions-edit' .husky/` returns nothing. +- [ ] **Step 2** Update `.husky/commit-msg`: remove the OLD append-only `deleted>0 → require token` + block (`decisions-guard.sh staged`); keep the Co-Authored-By check. Call the shim from + `.husky/pre-commit` (structural check on the working tree). The token's remaining job — gating + rationale-body edits — is enforced by the CI body-diff (Step 3), which is the only place a + base/head exists. Verify `grep -rn 'decisions-edit' .husky/` returns nothing (the token is no longer + a hook concept; it lives in commit messages + the CI validator). - [ ] **Step 3** Update the CI `decisions-guard` job in `.gitea/workflows/docker-build.yml`: replace the `decisions-guard.sh range` call with: @@ -874,24 +1035,29 @@ PYTHONPATH=. python3 scripts/decisions_validate.py || exit 1 PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD - name: Active catalog in sync run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check - - name: Kickoff guard - run: bash scripts/check-kickoff-guard.sh ``` - Remove the 1800-line-floor block (now the validator's aggregate budget). Rename the job label to - `decisions lifecycle`. + (The **kickoff guard** CI step is NOT added here — it lands in PR2/Task 8, together with the doc + rewrite that removes the "read #237" language it flags; wiring it in PR1 would red-CI on the + still-uncleaned docs.) Remove the 1800-line-floor block (now the validator's aggregate budget). + Rename the job label to `decisions lifecycle`. **Verify `python3` is present on the runner lane this + job uses** (Fable #12i — the bare `small` lane may lack it): if absent, add `container:` or an + install step, or keep the job on a python-bearing lane. - [ ] **Step 4** Rewrite the `docs/decisions.md` header: append-only → lifecycle; document the - metadata schema, the four statuses, the catalog + archive, and that supersession is same-PR. Remove - `[decisions-edit]` instructions. + metadata schema, the statuses, the catalog + archive, that supersession is same-PR, and that + `[decisions-edit]` is now required ONLY for a rationale-prose edit / factual correction (routine + lifecycle metadata writes are token-free). Do not remove the token — re-scope it. - [ ] **Step 5** Update `docs/ci-cd.md` release ritual (validate lifecycle + reciprocal links; archive already-classified history; refresh catalog; enforce aggregate budget; report unresolved legacy). + Re-scope, don't delete, the `[decisions-edit]` note. - [ ] **Step 6** Run: `bash .claude/hooks/decisions-guard.sh` (shim) → validator OK. - `grep -rn 'decisions-edit' docs/ .husky/ .gitea/ .claude/` → only historical/archival mentions. + `grep -rn 'decisions-edit' .husky/` → nothing (not a hook concept). `grep -rn 'decisions-edit' + docs/decisions.md docs/ci-cd.md` → the re-scoped narrow-usage note (expected). - [ ] **Step 7: Commit** ```bash git add .claude/hooks/decisions-guard.sh .husky/ .gitea/workflows/docker-build.yml docs/decisions.md docs/ci-cd.md -git -c core.hooksPath=/dev/null commit -m "feat(521): retire append-only line guard + [decisions-edit]; wire lifecycle validator into CI" +git -c core.hooksPath=/dev/null commit -m "feat(521): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header rewrite" ``` --- @@ -915,16 +1081,34 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): retire append-only line gu record shape. - [ ] **Step 3** `CLAUDE.md`: docs-first guidance → "read the `docs/README.md` map + the sections it points to, not the whole corpus." Add the `## Closing record` template to the Task Completion - Protocol. Remove `[decisions-edit]` mentions; point decision lookups at the catalog. + Protocol. Point decision lookups at the catalog (`docs/decisions/README.md`). Update `[decisions-edit]` + mentions to the re-scoped narrow meaning (rationale-prose edits only) — do not delete. +- [ ] **Step 3b (MemPalace retrieval rules — #520 done-when, Fable #9)** Add a short "Knowledge + retrieval" block to the kickoff (and/or `docs/README.md`): **MemPalace = candidate discovery only**, + constrained to the ErsatzTV/project wing, retrieved at a bounded `k`, and every passage **verified + against its cited Markdown/Gitea source** before use; if MemPalace is unavailable or stale, fall back + to the `docs/README.md` task map + bounded exact search; **never** derive live queue state from + MemPalace, #237, or historical comments. Without this the #520 "MemPalace candidate discovery + + canonical verification + exact-search fallback documented" box is unmet. +- [ ] **Step 3c (dogfood supersession — Fable #5, PR2 half)** #520 reverses the recorded decision + `## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237)`. Give it key + `queue.state-source`, `status: superseded`, `superseded-by: startup.parallel-orientation@2026-07-21`, + and archive it; add the NEW active record `startup.parallel-orientation` (the two-track startup + + #237-retirement decision) with `supersedes: queue.state-source@2026-07-11`. Commit range carries + `[decisions-edit]` (archiving + the superseded record's body is preserved in archive). - [ ] **Step 4** `scripts/select-queue.sh`: update header comment + any echoed guidance so tiers lead with open milestones/review/priority and no line says "read #237." -- [ ] **Step 5** Run `bash scripts/check-kickoff-guard.sh` → **exit 0** now (the language is gone). - This flips the Task 4 non-vacuity failure to green — the regression box is satisfied. +- [ ] **Step 5** Add the kickoff-guard CI step to the `decisions lifecycle` job (deferred from + PR1/Task 7): `- name: Kickoff guard` / `run: bash scripts/check-kickoff-guard.sh`. Run + `bash scripts/check-kickoff-guard.sh` locally → **exit 0** now (the language is gone). This wires + + satisfies the #520 "cannot regress" box in the same PR that cleans the docs. - [ ] **Step 6: Commit** ```bash -git add docs/handoffs/chicorytv-issue-queue.md docs/README.md CLAUDE.md scripts/select-queue.sh -git -c core.hooksPath=/dev/null commit -m "feat(520): parallel orientation+selection startup; retire #237 as live state" +git add docs/handoffs/chicorytv-issue-queue.md docs/README.md CLAUDE.md scripts/select-queue.sh \ + scripts/check-kickoff-guard.sh scripts/tests/test_kickoff_guard.sh \ + .gitea/workflows/docker-build.yml docs/decisions.md docs/decisions/ +git -c core.hooksPath=/dev/null commit -m "feat(520): parallel orientation+selection startup; retire #237 as live state [decisions-edit]" ``` --- @@ -966,11 +1150,12 @@ git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question ba `PYTHONPATH=. python scripts/build_decisions_catalog.py --check`; `bash scripts/check-kickoff-guard.sh`. - [ ] **Step 2 (startup cold-agent sim, #520 done-when)** Dispatch a fresh general-purpose subagent - given ONLY the new `docs/handoffs/chicorytv-issue-queue.md` + `docs/README.md` (no #237 access) and - the prompt "you are a fresh orchestrator, no issue named — describe your exact startup steps." PASS - iff: runs orientation ‖ `select-queue.sh 5`, resolves only CLAIM?/UMBRELLA? flags + winner, rechecks - live state, claims, focused retrieval — and never reads/consults #237. Record the transcript verdict - in the PR. + given the new `docs/handoffs/chicorytv-issue-queue.md` + `docs/README.md` AND **normal Gitea read + access** (Fable #14 — withholding #237 makes "never reads #237" unfalsifiable; give it the ability + and observe whether it *chooses* to). Prompt: "you are a fresh orchestrator, no issue named — + describe and take your exact startup steps." PASS iff: runs orientation ‖ `select-queue.sh 5`, + resolves only CLAIM?/UMBRELLA? flags + winner, rechecks live state, claims, focused retrieval — and + **never fetches/reads #237** as a queue source. Record the transcript verdict in the PR. - [ ] **Step 3 (retrieval cold-agent sim, #521)** Dispatch a fresh subagent given only `docs/decisions/README.md` + the corpus; feed 5 of the `retrieval-eval.md` questions; PASS iff it cites the expected active keys and does NOT pick a superseded/archived record as current. @@ -991,6 +1176,20 @@ git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question ba `docs/handoffs/chicorytv-issue-queue.md` (kickoff). MEMORY.md updated separately (operator memory, not in-repo): retire `[decisions-edit]` note, add lifecycle-validator + catalog pointers. +## Fable review fold (2026-07-21) + +Verdict SOUND-WITH-FIXES; all findings folded: no-vanish now enumerates all active files at +`merge-base` (BLOCKERS #1/#2); body-diff added so silent rationale rewrite is blocked (BLOCKER #3); +`[decisions-edit]` kept narrow for rationale edits (SHOULD #4, user-confirmed); dogfood supersession +of the append-only decision (PR1/Task 5 Step 3b) and the #237 queue-state decision (PR2/Task 8 Step +3c) (BLOCKER #5); two-PR split (SHOULD #6, user-confirmed); orchestrator assigns keys + reversal-grep +backstop + batches return map rows (SHOULD #7); section-aware kickoff guard + `select-queue.sh` in +scope (SHOULD #8); MemPalace retrieval-rules step (SHOULD #9, Task 8 Step 3b); `_anchor` keeps `_` +(SHOULD #10); parser stops at blank line (#11); fail-open shim + python3-on-lane check (#12); +signature/dead-code tidy (#13); cold-agent sim gets Gitea access (#14). +**Open owner:** the aggregate `--budget` starts at 4200 and must be *ratcheted down* as archive grows +— that ownership lives in the `docs/ci-cd.md` release ritual (Task 7 Step 5). + ## Self-review notes (spec coverage) - #521 done-when: schema (Task 1/5/6), same-PR supersession (Task 5/6 + validator Task 2), active diff --git a/docs/superpowers/specs/2026-07-21-decision-lifecycle-and-kickoff-design.md b/docs/superpowers/specs/2026-07-21-decision-lifecycle-and-kickoff-design.md index b360ffe79..a86665ee0 100644 --- a/docs/superpowers/specs/2026-07-21-decision-lifecycle-and-kickoff-design.md +++ b/docs/superpowers/specs/2026-07-21-decision-lifecycle-and-kickoff-design.md @@ -46,13 +46,25 @@ line, and retiring *moves* a record to archive. **Decision:** replace the line-level mechanic with a **lifecycle validator** that enforces the same *spirit* — rationale is never silently rewritten or deleted; every history touch is deliberate and -reviewable. The `[decisions-edit]` token is **retired** (removed from the Husky hook, the CI job, -and docs/memory references); routine lifecycle writes are expected and are proven well-formed by the -validator instead of gated by a token. +reviewable. The `[decisions-edit]` token is **kept narrow, not retired** (revised after Fable review): +routine lifecycle *metadata* writes (add record, set `superseded-by`, relocate to archive, regen +catalog) are token-free and proven well-formed by the validator; a change to a record's *rationale +prose* still requires `[decisions-edit]`, enforced by the validator's **body-diff**. Retiring the +token entirely would let an agent silently rewrite rationale (or archive a falsified body) and pass — +the exact case the old guard existed for. Spirit preserved by these validator invariants (see Validator below): ≤1 active record per key; no record block may vanish (if it leaves the active set it must reappear under `docs/decisions/archive/`); -supersession links are reciprocal and resolvable; status only moves active → superseded/retired. +**a surviving record's rationale prose can't change, and an archived copy must body-match its pre-move +active version, without `[decisions-edit]` in the commit range**; supersession links are reciprocal +and resolvable; status only moves active → superseded/retired. The no-vanish/body-diff checks run in +CI over `merge-base(base,head)…head` across `decisions.md` **and** every topic file. + +**Delivery: two PRs, one arc** (revised after Fable review). PR1 = machinery (parser, validator, +catalog generator, guard swap, header rewrite, exemplar + append-only→lifecycle supersession); PR2 = +full corpus migration + #520 startup rewrite + kickoff guard + retrieval-eval. Matches #521's +"bounded topic batches, avoid a single conflict-heavy rewrite" and keeps each diff's conflict surface +small on the treadmill-prone `decisions.md`. ## Information model (#521)