Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
58 KiB
Decision-lifecycle + retrieval-efficient startup — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give ErsatzTV decision records a stable key + explicit lifecycle + a generated compact active view (#521), replace the line-level append-only guard with a lifecycle validator, and rewire startup to run orientation ‖ selection and stop treating closed tracker #237 as live state (#520).
Architecture: A shared Python parser (decisions_lib.py) reads H2 decision records + a visible
metadata block from docs/decisions.md and the topic files. Two generators/validators build on it:
build-decisions-catalog.py regenerates docs/decisions/README.md (the active catalog), and
decisions-validate.py enforces lifecycle invariants (≤1 active/key, reciprocal supersession,
no-vanish, catalog freshness, aggregate budget). A bash check-kickoff-guard.sh blocks
#237-as-live-state language. Existing entries are migrated into schema form by fanned-out subagents;
superseded/retired records move to docs/decisions/archive/. CI's decisions-guard job is
extended to run the validators; the [decisions-edit] token and its Husky/CI wiring are retired.
Tech Stack: Python 3 (stdlib only; ruff/pyright/pytest via uv locally), Bash, Markdown, Gitea
Actions (.gitea/workflows/docker-build.yml), Husky hooks.
Global Constraints
- Work in the worktree
feat/520-521-decision-lifecycleofforigin/main. Never commit in/Users/timothy/ersatztv. - 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.mdheader rewrite. CI wires the decisions validator + catalog--checkonly. Small, low-conflict diff. The validator tolerateslegacy-unmigrated, so concurrent sessions appending old-style entries stay green. Branchfeat/521-decision-lifecycle-machineryofforigin/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.
- 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-edit]is kept narrow, not retired (Fable review #3/#4): lifecycle metadata writes (add record, setsuperseded-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:`key: <dotted.key>` · `status: <active|superseded|retired>` · `since: <YYYY-MM-DD>` · `supersedes: <key@date|none>` · `superseded-by: <key@date|none>` **Rule:** <one line> **Signals:** <concepts> · paths: <a/b> · issues: #NNN **Mechanics:** <doc/source/test anchors> keyformat:^[a-z0-9]+(\.[a-z0-9-]+)+$(dotted, lowercase, hyphens ok), e.g.ci.runner-placement.statusenum:active,superseded,retired(records;legacy-unmigratedis the implicit status of an unmigrated entry — no metadata line — and must trend to 0 by end of migration).- Python scripts: stdlib only,
#!/usr/bin/env python3, module docstring,ruff/pyrightclean. Tests are pytest underscripts/tests/, run withpython -m pytest scripts/tests -q. - Every
.cs? None in this arc. No OpenAPI regen. No DB migration. - Never set
ETV_UPDATE_GOLDENS.
File Structure
Create:
scripts/decisions_lib.py— shared parser (records + metadata) over the active decision files.scripts/decisions_validate.py— lifecycle-invariant validator (CLI,--base/--headfor no-vanish).scripts/build_decisions_catalog.py— regenerate/--checkdocs/decisions/README.md.scripts/check-kickoff-guard.sh— grep guard against #237-as-live-state language.scripts/tests/test_decisions_lib.py,scripts/tests/test_decisions_validate.py,scripts/tests/fixtures/…— pytest suite.docs/decisions/README.md— GENERATED active catalog / task router.docs/decisions/archive/(+docs/decisions/archive/README.md) — superseded/retired records.docs/decisions/migration-map.md— auditable legacy-heading → active-record/archive mapping.docs/decisions/retrieval-eval.md— the retrieval question bank + expected active-record answers.
Modify:
docs/decisions.md— header reframed (append-only → lifecycle); every in-file entry migrated to schema.docs/decisions/{optimistic-concurrency,api-auth-security,release-ci-governance,spa-modularization}.md— entries migrated to schema..claude/hooks/decisions-guard.sh— replaced by a thin shim calling the validator (or removed + hook rewired)..husky/commit-msg/.husky/pre-commit— swap the append-only call for the validator..gitea/workflows/docker-build.yml—decisions-guardjob → runs validator + catalog--check+ kickoff guard; drop[decisions-edit]/size-floor text.docs/handoffs/chicorytv-issue-queue.md— startup rewrite (#520).docs/README.md— reading-order → task-signal map + active-catalog pointer.CLAUDE.md— docs-first guidance +## Closing recordtemplate + drop[decisions-edit]mentions.docs/ci-cd.md— release ritual rewrite; drop[decisions-edit]; aggregate budget.scripts/select-queue.sh— comment/output text (tiers lead with milestones/review/priority; no "read #237").
Task 1: Shared decision-record parser (decisions_lib.py)
Files:
- Create:
scripts/decisions_lib.py - Test:
scripts/tests/test_decisions_lib.py,scripts/tests/fixtures/sample_decisions.md
Interfaces:
-
Produces:
Recorddataclass (heading, source, lineno, key, status, since, supersedes, superseded_by, rule, signals, mechanics, body);parse_file(path) -> list[Record];active_files() -> list[Path];all_active_records() -> list[Record]; module constantsSTATUSES,KEY_RE,DECISIONS_MD,TOPIC_DIR,ARCHIVE_DIR. -
Step 1: Write the fixture
scripts/tests/fixtures/sample_decisions.md
# Decisions
## Index
- [x](#y)
## 2026-07-17 — Migrated example (#406)
`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** Every CI services container gets an explicit cap.
**Signals:** ci, runners · paths: .gitea/workflows/docker-build.yml · issues: #390 #406
**Mechanics:** docs/ci-cd.md → CI lanes
Rationale prose here.
## 2026-07-11 — Legacy unmigrated example (#231)
Some prose with no metadata line at all.
- Step 2: Write the failing test
scripts/tests/test_decisions_lib.py
from pathlib import Path
import scripts.decisions_lib as dl
FIX = Path(__file__).parent / "fixtures" / "sample_decisions.md"
def test_parses_migrated_record():
recs = dl.parse_file(FIX)
migrated = [r for r in recs if r.key == "ci.runner-placement"]
assert len(migrated) == 1
r = migrated[0]
assert r.status == "active"
assert r.since == "2026-07-17"
assert r.supersedes == "none"
assert r.superseded_by == "none"
assert r.rule == "Every CI services container gets an explicit cap."
assert "issues: #390 #406" in r.signals
assert r.mechanics.startswith("docs/ci-cd.md")
def test_legacy_record_is_unmigrated():
recs = dl.parse_file(FIX)
legacy = [r for r in recs if r.heading.endswith("(#231)")]
assert len(legacy) == 1
assert legacy[0].key is None
assert legacy[0].status == "legacy-unmigrated"
def test_index_section_is_not_a_record():
recs = dl.parse_file(FIX)
assert all(r.heading != "Index" for r in recs)
Note: the Index H2 has no metadata → parsed as a legacy record with heading Index; the validator
(Task 2) skips a configured set of non-decision headings. This test asserts we can identify it, so
here it's acceptable that Index parses as a record — adjust assertion to
assert any(r.heading == "Index" for r in recs) and let Task 2 own the skip-list. Use this
corrected assertion:
def test_index_section_parses_as_heading():
recs = dl.parse_file(FIX)
assert any(r.heading == "Index" for r in recs)
- Step 3: Run test to verify it fails
Run: python -m pytest scripts/tests/test_decisions_lib.py -q
Expected: FAIL (ModuleNotFoundError: scripts.decisions_lib)
- Step 4: Implement
scripts/decisions_lib.py
#!/usr/bin/env python3
"""Parse ErsatzTV decision records from docs/decisions.md and docs/decisions/*.md.
A decision record is a Markdown H2 section. A *migrated* record carries a visible metadata
block as its first non-blank content:
## 2026-07-17 — Title … (#406)
`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** one-line current rule.
**Signals:** concept · paths: a/b.yml · issues: #406
**Mechanics:** docs/ci-cd.md → CI lanes
<rationale prose …>
An H2 with no metadata line is treated as status `legacy-unmigrated` (migration target).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DECISIONS_MD = REPO_ROOT / "docs" / "decisions.md"
TOPIC_DIR = REPO_ROOT / "docs" / "decisions"
ARCHIVE_DIR = TOPIC_DIR / "archive"
STATUSES = {"active", "superseded", "retired", "legacy-unmigrated"}
KEY_RE = re.compile(r"^[a-z0-9]+(\.[a-z0-9-]+)+$")
_HEADING_RE = re.compile(r"^##\s+(.*\S)\s*$")
_META_FIELD_RE = re.compile(r"`([a-z-]+):\s*([^`]*)`")
@dataclass
class Record:
heading: str
source: Path
lineno: int
key: str | None = None
status: str = "legacy-unmigrated"
since: str | None = None
supersedes: str | None = None
superseded_by: str | None = None
rule: str | None = None
signals: str | None = None
mechanics: str | None = None
body: str = ""
def _parse_meta_line(line: str) -> dict[str, str]:
return {m.group(1): m.group(2).strip() for m in _META_FIELD_RE.finditer(line)}
def parse_text(text: str, source: Path) -> list[Record]:
lines = text.splitlines()
records: list[Record] = []
i = 0
while i < len(lines):
m = _HEADING_RE.match(lines[i])
if not m:
i += 1
continue
rec = Record(heading=m.group(1), source=source, lineno=i + 1)
j = i + 1
body_lines: list[str] = []
while j < len(lines) and not _HEADING_RE.match(lines[j]):
body_lines.append(lines[j])
j += 1
for k, bl in enumerate(body_lines):
if not bl.strip():
continue
meta = _parse_meta_line(bl)
if "key" in meta and "status" in meta:
rec.key = meta.get("key") or None
rec.status = meta.get("status") or "legacy-unmigrated"
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:**"):
rec.signals = bl2[len("**Signals:**") :].strip()
elif bl2.startswith("**Mechanics:**"):
rec.mechanics = bl2[len("**Mechanics:**") :].strip()
break
rec.body = "\n".join(body_lines).strip()
records.append(rec)
i = j
return records
def parse_file(path: Path) -> list[Record]:
return parse_text(path.read_text(encoding="utf-8"), path)
def active_files() -> list[Path]:
files = [DECISIONS_MD]
files += sorted(p for p in TOPIC_DIR.glob("*.md") if p.name != "README.md")
return files
def all_active_records() -> list[Record]:
recs: list[Record] = []
for f in active_files():
if f.exists():
recs += parse_file(f)
return recs
- Step 5: Run tests to verify they pass
Run: python -m pytest scripts/tests/test_decisions_lib.py -q (from repo root, so scripts is importable; add scripts/tests/__init__.py and scripts/__init__.py if needed, or run with PYTHONPATH=.)
Expected: PASS (3 tests). Also run ruff check scripts/decisions_lib.py && pyright scripts/decisions_lib.py.
- Step 6: Commit
git add scripts/decisions_lib.py scripts/tests/
git -c core.hooksPath=/dev/null commit -m "feat(521): decision-record parser (decisions_lib)"
Task 2: Lifecycle validator (decisions-validate.py)
Files:
- Create:
scripts/decisions-validate.py - Test:
scripts/tests/test_decisions_validate.py(+ good/bad fixtures)
Interfaces:
-
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, archive_records=None) -> list[str](returns error strings; empty = pass); CLImain()with--base REF --head REF(enables the merge-base no-vanish + body-diff checks),--budget N, exit 1 on any error. -
Archive-placement invariant (folded post-review; coordinated with server-management#642 so supersession = a physical move, not an in-place
status:flip): asuperseded/retiredrecord in the ACTIVE files is an error ("relocate todocs/decisions/archive/"); anactiverecord underdocs/decisions/archive/is an error. This makes the MemPalace wing boundary (ErsatzTV-Decisionsactive vsErsatzTV-Decisions-Archive) hold by construction. -
Bounded body-strip (folded post-review):
_rationale()strips ONLY the contiguous top metadata block (first non-blank`key:line → first following blank line), mirroringdecisions_lib's parser — so a**Rule:**-prefixed line deep in rationale prose is prose, not silently laundered past the body-diff. -
Non-decision headings skipped:
{"Index", "Active catalog"}(configurableSKIP_HEADINGS). -
Diff semantics (only when
--base/--headgiven): computemb = git merge-base BASE HEADand diffmb → HEAD(never BASE-tip, which false-flags on every main advance).removed= active headings atmbgone atHEADand not present inarchive/atHEAD(acrossdecisions.mdAND all topic files).rewritten= headings whose rationale body (record body minus the metadata/Rule/Signals/ Mechanics lines) changedmb → HEAD(for surviving records) OR whose archived copy body ≠ itsmbactive body — UNLESS[decisions-edit]appears ingit log --format=%B mb..HEAD. -
Step 1: Write failing tests
scripts/tests/test_decisions_validate.py
import importlib
from pathlib import Path
import scripts.decisions_lib as dl
dv = importlib.import_module("scripts.decisions-validate".replace("-", "_")) \
if False else __import__("importlib").import_module("scripts.decisions_validate")
Note: a hyphenated filename can't be imported directly. Name the module decisions_validate.py
(underscore) and give the CLI entry via python scripts/decisions_validate.py. Update the File
Structure name accordingly for both validator and generator? No — keep the generator/validator
importable: use underscores in filenames (decisions_validate.py, build_decisions_catalog.py),
matching decisions_lib.py. CLI callers use the underscore names. Corrected test header:
from pathlib import Path
import scripts.decisions_lib as dl
import scripts.decisions_validate as dv
def _rec(**kw):
base = dict(heading="H", source=Path("x"), lineno=1, status="active")
base.update(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():
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():
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")]
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():
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():
assert _v([_rec(key="a.b"), _rec(key="c.d")]) == []
- Step 2: Run to verify fail
Run: PYTHONPATH=. python -m pytest scripts/tests/test_decisions_validate.py -q
Expected: FAIL (ModuleNotFoundError: scripts.decisions_validate)
- Step 3: Implement
scripts/decisions_validate.py
#!/usr/bin/env python3
"""Validate ErsatzTV decision-record lifecycle invariants.
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. `[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
import argparse
import subprocess
import sys
from pathlib import Path
import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as module)
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:
if not ref or ref == "none":
return None
return ref.split("@", 1)[0].strip()
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 and r.status != "legacy-unmigrated"
]
active_by_key: dict[str, int] = {}
known_keys = set(archive_keys)
for r in decision_recs:
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}")
continue
known_keys.add(r.key)
for f in REQUIRED_META:
if getattr(r, f) in (None, ""):
errs.append(f"{r.heading}: missing required metadata {f}")
if r.status == "active":
active_by_key[r.key] = active_by_key.get(r.key, 0) + 1
for key, n in active_by_key.items():
if n > 1:
errs.append(f"key {key!r}: more than one active record ({n})")
# 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:
errs.append(f"{r.heading}: superseded-by points to unknown key {sk!r}")
pk = _key_of(r.supersedes)
if pk and pk not in known_keys:
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}")
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")
if not budget_ok:
errs.append("aggregate active-corpus budget exceeded (see --budget)")
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():
for f in dl.ARCHIVE_DIR.glob("*.md"):
for r in dl.parse_file(f):
if r.key:
keys.add(r.key)
return keys
def _budget_ok(limit: int) -> bool:
total = 0
for f in dl.active_files():
if f.exists():
total += len(f.read_text(encoding="utf-8").splitlines())
cat = dl.TOPIC_DIR / "README.md"
if cat.exists():
total += len(cat.read_text(encoding="utf-8").splitlines())
return total <= limit
def _catalog_ok() -> bool:
try:
import scripts.build_decisions_catalog as bc
except Exception:
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 ""
return want.strip() == have.strip()
def main(argv=None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base")
ap.add_argument("--head")
ap.add_argument("--budget", type=int, default=4200) # aggregate; ratchet down as archive grows
args = ap.parse_args(argv)
records = dl.all_active_records()
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(args.budget),
removed=removed,
rewritten=rewritten,
)
unmigrated = [r for r in records if r.status == "legacy-unmigrated"
and r.heading not in SKIP_HEADINGS]
if unmigrated:
print(f"::notice::{len(unmigrated)} legacy-unmigrated decision record(s) remain "
f"(must trend to 0).")
if errs:
for e in errs:
print(f"decisions-validate: {e}", file=sys.stderr)
return 1
print("decisions-validate: OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
- Step 4: Run tests to verify pass
Run: PYTHONPATH=. python -m pytest scripts/tests/test_decisions_validate.py -q
Expected: PASS (5 tests). Then ruff check + pyright on the file.
- Step 5: Commit
git add scripts/decisions_validate.py scripts/tests/test_decisions_validate.py
git -c core.hooksPath=/dev/null commit -m "feat(521): decision lifecycle validator"
Task 3: Active-catalog generator (build_decisions_catalog.py)
Files:
- Create:
scripts/build_decisions_catalog.py,docs/decisions/README.md(generated output) - Test:
scripts/tests/test_build_catalog.py
Interfaces:
-
Consumes:
decisions_lib.all_active_records,Record. -
Produces:
render_catalog(records) -> str(full Markdown fordocs/decisions/README.md); CLI--check(exit 1 if on-disk differs) / default write. -
Step 1: Write failing test
scripts/tests/test_build_catalog.py
from pathlib import Path
import scripts.decisions_lib as dl
import scripts.build_decisions_catalog as bc
def test_catalog_lists_only_active_sorted_by_key():
recs = [
dl.Record(heading="H2", source=Path("x"), lineno=1, key="z.a", status="active",
rule="Zeta rule"),
dl.Record(heading="H1", source=Path("x"), lineno=1, key="a.b", status="active",
rule="Alpha rule"),
dl.Record(heading="Old", source=Path("x"), lineno=1, key="a.b", status="superseded",
rule="old"),
]
out = bc.render_catalog(recs)
assert "a.b" in out and "z.a" in out
assert out.index("a.b") < out.index("z.a") # sorted
assert "Alpha rule" in out and "Zeta rule" in out
assert "old" not in out # superseded excluded
assert "GENERATED" in out # do-not-edit banner
- Step 2: Run to verify fail
Run: PYTHONPATH=. python -m pytest scripts/tests/test_build_catalog.py -q → FAIL (module missing).
- Step 3: Implement
scripts/build_decisions_catalog.py
#!/usr/bin/env python3
"""Generate docs/decisions/README.md — the compact ACTIVE decision catalog / task router.
Do not edit docs/decisions/README.md by hand; regenerate with this script. The decisions-validate
CI job checks it is in sync.
"""
from __future__ import annotations
import sys
from pathlib import Path
import scripts.decisions_lib as dl
OUTPUT = dl.TOPIC_DIR / "README.md"
BANNER = "<!-- GENERATED by scripts/build_decisions_catalog.py — do not edit by hand. -->"
def _rel(rec) -> str:
"""Link from docs/decisions/README.md to the record's heading."""
src = Path(rec.source).name
anchor = _anchor(rec.heading)
if src == "decisions.md":
return f"../decisions.md#{anchor}"
return f"{src}#{anchor}"
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() or ch == "_":
out.append(ch)
elif ch in " -":
out.append("-")
return "".join(out)
def render_catalog(records) -> str:
active = sorted(
[r for r in records if r.status == "active" and r.key],
key=lambda r: r.key,
)
lines = [
BANNER,
"",
"# Active decisions — catalog / task router",
"",
"The compact current view of settled decisions. Each row is an **active** record; follow",
"the link for rationale. Superseded/retired history lives in `archive/`. Regenerated by",
"`scripts/build_decisions_catalog.py`.",
"",
"| Key | Current rule | Since | Record |",
"| --- | --- | --- | --- |",
]
for r in active:
rule = (r.rule or "").replace("|", "\\|")
lines.append(f"| `{r.key}` | {rule} | {r.since or ''} | [link]({_rel(r)}) |")
lines.append("")
return "\n".join(lines)
def main(argv=None) -> int:
check = "--check" in (argv or sys.argv[1:])
want = render_catalog(dl.all_active_records())
have = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
if check:
if want.strip() != have.strip():
print("build_decisions_catalog: docs/decisions/README.md is stale — regenerate.",
file=sys.stderr)
return 1
print("build_decisions_catalog: catalog up to date")
return 0
OUTPUT.write_text(want + "\n", encoding="utf-8")
print(f"build_decisions_catalog: wrote {OUTPUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
- Step 4: Run test to verify pass
Run: PYTHONPATH=. python -m pytest scripts/tests/test_build_catalog.py -q → PASS. ruff/pyright clean.
- Step 5: Commit
git add scripts/build_decisions_catalog.py scripts/tests/test_build_catalog.py
git -c core.hooksPath=/dev/null commit -m "feat(521): active-catalog generator"
Task 4: Kickoff regression guard (check-kickoff-guard.sh)
Files:
- Create:
scripts/check-kickoff-guard.sh - Test:
scripts/tests/test_kickoff_guard.sh(bash asserts, run in CI/local)
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,scripts/select-queue.sh. A hit is exempt if the line containsarchiv(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. Thearchivsubstring is a deliberate anti-footgun escape hatch, not an anti-adversary control. -
Step 1: Write
scripts/check-kickoff-guard.sh
#!/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. 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'
rc=0
for f in "${FILES[@]}"; do
[ -f "$f" ] || continue
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"
- Step 2: Verify it FAILS on current tree (proves non-vacuity)
Run: bash scripts/check-kickoff-guard.sh against the pre-rewrite files.
Expected: exit 1, flags docs/handoffs/chicorytv-issue-queue.md + docs/README.md (both currently
say "read #237"). This confirms the guard bites before Task 8 cleans the language.
- Step 3: Commit
git add scripts/check-kickoff-guard.sh scripts/tests/test_kickoff_guard.sh
git -c core.hooksPath=/dev/null commit -m "feat(520): kickoff regression guard (blocks #237-as-live-state)"
Task 5: Reconcile prose-only reversals; seed schema exemplars
Files:
- Modify:
docs/decisions.md(the #406 entry — add supersession metadata; the #390 lane entry if present as its own record), the #411 measurement obsolescence. - Create:
docs/decisions/migration-map.md(start the auditable mapping).
Interfaces:
-
Produces: 2–3 fully-migrated exemplar records the subagents in Task 6 copy as the pattern.
-
Step 1 Read the #406 entry (
docs/decisions.mdaround the "No persistent compiler servers" heading) and the #411-related text. Identify the reversal pair: #390's small-lane move (predecessor) → #406 (reverses it). Assign keys:ci.runner-placement(the #406 active record). If #390 exists as a distinct record, mark itstatus: superseded,superseded-by: ci.runner-placement@2026-07-17, and move it todocs/decisions/archive/ci.mdwith a back-link; give #406supersedes: <#390-key>@<date>. If #390 only ever lived as prose inside #406, record that in the migration map (no separate record) and #406 carriessupersedes: nonewith a rationale note. -
Step 2 Add the full metadata block to #406 (see Global Constraints format). Add
**Rule:**,**Signals:**,**Mechanics:**. -
Step 3 Do the same reconciliation for the #411 obsolescence (superseded measurement): status
retiredorsupersededper 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 (keydocs.decision-lifecycle, statusactive,supersedes: docs.append-only-guard@2026-06); give the existing H9 record the keydocs.append-only-guard, setstatus: superseded,superseded-by: docs.decision-lifecycle@2026-07-21, and move it todocs/decisions/archive/release-ci-governance.mdwith 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:
# Decision migration map (legacy heading → active record / archive)
Auditable mapping produced during the #521 lifecycle migration. Every legacy H2 heading maps to
either an active record (key) or an archive location.
| Legacy heading | Key | Status | Location |
| --- | --- | --- | --- |
| 2026-07-17 — No persistent compiler servers in CI … (#406) | ci.runner-placement | active | docs/decisions.md |
- Step 5 Run
PYTHONPATH=. python scripts/decisions_validate.py— expect OK plus a::notice::legacy-unmigrated count (still high; migration ongoing). Regenerate catalog:PYTHONPATH=. python scripts/build_decisions_catalog.py. - Step 6: Commit
git add docs/decisions.md docs/decisions/
git -c core.hooksPath=/dev/null commit -m "feat(521): reconcile #390/#406 + #411 reversals; seed schema exemplars [decisions-edit]"
(Interim commits may still carry [decisions-edit] while the old hook exists; Task 7 removes it.)
Task 6: Full entry migration (subagent fan-out)
Files:
- Modify: every entry in
docs/decisions.md+ the four topic files. - Modify:
docs/decisions/migration-map.md(one row per entry). - Create: records under
docs/decisions/archive/*.mdfor any superseded/retired entry.
Interfaces:
- Consumes: the schema format (Global Constraints) + the Task 5 exemplars.
- Produces: 0
legacy-unmigratedrecords; validator + catalog green.
Orchestration (not a code step — the executor runs this): Split the corpus into bounded batches
by topic/date range (e.g. batch A = topic file optimistic-concurrency.md; B = api-auth-security.md;
C = release-ci-governance.md; D = spa-modularization.md; E–H = decisions.md in ~10-entry
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) + 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, using the key from the supplied table.status: activeunless 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.mdonce, to avoid inter-batch conflicts. -
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|deprecatand 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.mdonce 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). -
Step 6 Run the full pytest suite:
PYTHONPATH=. python -m pytest scripts/tests -q. -
Step 7: Commit (single squashed migration commit per batch is fine)
git add docs/decisions.md docs/decisions/
git -c core.hooksPath=/dev/null commit -m "feat(521): migrate decision corpus to lifecycle schema [decisions-edit]"
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). -
Modify:
.husky/commit-msg,.husky/pre-commit(call validator instead of append-only line guard). -
Modify:
.gitea/workflows/docker-build.yml(decisions-guardjob). -
Modify:
docs/decisions.mdheader (append-only framing → lifecycle framing). -
Modify:
docs/ci-cd.md(release ritual; re-scope[decisions-edit], aggregate budget). -
Step 1 Replace
.claude/hooks/decisions-guard.shwith a thin, fail-open wrapper (Fable #12ii — python missing must NOT wedge a commit):
#!/usr/bin/env bash
# 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 OLD append-onlydeleted>0 → require tokenblock (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. Verifygrep -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-guardjob in.gitea/workflows/docker-build.yml: replace thedecisions-guard.sh rangecall with:
- name: Validate decision lifecycle
run: |
base_ref="${{ ... existing base ref expr ... }}"
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
(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.mdheader: append-only → lifecycle; document the 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.mdrelease 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' .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
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): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header rewrite"
Task 8: Startup / kickoff rewrite (#520)
Files:
-
Modify:
docs/handoffs/chicorytv-issue-queue.md,docs/README.md,CLAUDE.md,scripts/select-queue.sh. -
Step 1
docs/README.md: replace the mandatory 1–10 reading order with a compact task-signal → minimal sections map (e.g. "API change → api-conventions §checklist + endpoint-index"; "scheduling → domain-model + decisions catalog rows keyedsched.*"; "startup/queue → handoff kickoff + select-queue.sh"). Add a pointer: decisions active view =docs/decisions/README.md(catalog), archive =docs/decisions/archive/. -
Step 2
docs/handoffs/chicorytv-issue-queue.md: rewrite the STANDING KICKOFF around the two concurrent tracks (orientation ‖select-queue.sh 5); named-issue path skips selection → focused retrieval; move the pre-script "re-derive the contested tier by hand" selector lore into a clearly labeled "Archived — pre-script selector history (do not follow)" section; reduce #237 to a single archival line marked<!-- archival:237 -->. Add the## Closing recordtemplate as the session-end record shape. -
Step 3
CLAUDE.md: docs-first guidance → "read thedocs/README.mdmap + the sections it points to, not the whole corpus." Add the## Closing recordtemplate to the Task Completion 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), using the concrete conventions agreed with server-management#642 (the sole Gitea→MemPalace exporter):- MemPalace = candidate discovery only, retrieved at a bounded
k; every passage verified against its cited Markdown/Gitea source before use. - Default wing =
ErsatzTV-Decisions(activedocs/decisions.md+docs/decisions/*.md+ catalog). History wingsErsatzTV-Decisions-Archive(docs/decisions/archive/**) andGitea-ErsatzTV(issues/comments incl. #520 closing records) are touched only when the question is explicitly "what did the rule used to be." "What is the current rule for X" never touches the history wings. - Exact-search fallback (MemPalace stale/down):
docs/decisions/README.mdcatalog first, thenrg '^`key: <dotted.key>`' docs/decisions/in the checkout. MemPalace is never the authority or the fallback. - Staleness bounds (so agents know when to distrust a hit): webhook re-mine in seconds; hourly reconcile; weekly full sweep catches file moves/deletes. Worst case before a supersession takes effect in retrieval: ~1h, or ~1 week if the webhook is down and only the move happened. #642 alerts above 5400s. When in doubt, fall back to 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.
- MemPalace = candidate discovery only, retrieved at a bounded
-
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 keyqueue.state-source,status: superseded,superseded-by: startup.parallel-orientation@2026-07-21, and archive it; add the NEW active recordstartup.parallel-orientation(the two-track startup + #237-retirement decision) withsupersedes: 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 Add the kickoff-guard CI step to the
decisions lifecyclejob (deferred from PR1/Task 7):- name: Kickoff guard/run: bash scripts/check-kickoff-guard.sh. Runbash scripts/check-kickoff-guard.shlocally → 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
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]"
Task 9: Retrieval evaluation bank
Files:
-
Create:
docs/decisions/retrieval-eval.md -
Step 1 Write ~10 questions across the required classes (paraphrased task→decision; exact code/path lookup; active-vs-superseded; retired feature; rationale/rejected-alternative; and at least one "convention already implemented — do not reimplement"). Each question lists the expected active record key + citation. Example rows:
## Retrieval eval — active-record selection
1. "Where do REST response DTOs live and why?" → key `api.response-dtos` (docs/decisions.md).
2. "Should I add a service layer over the MediatR handlers?" → key `api.no-service-layer` (already
decided NO — prevents reimplementation).
3. "Is #390's small-lane CI move still in effect?" → NO; superseded by `ci.runner-placement`
(active). Selecting the #390 archive record as current is a FAILURE.
- Step 2: Commit
git add docs/decisions/retrieval-eval.md
git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question bank (active-record selection)"
Task 10: Verification — cold-agent sims + adversarial review
- Step 1 (deterministic) From the worktree root run all gates green:
PYTHONPATH=. python -m pytest scripts/tests -q;PYTHONPATH=. python scripts/decisions_validate.py(0 legacy-unmigrated);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 the new
docs/handoffs/chicorytv-issue-queue.md+docs/README.mdAND 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 theretrieval-eval.mdquestions; PASS iff it cites the expected active keys and does NOT pick a superseded/archived record as current. - Step 4 (adversarial review) Cold-context review-only agent (cross-model/Fable if available) over the whole diff: focus on the guard rework (does the lifecycle validator actually preserve the no-silent-delete spirit? any way to delete rationale and pass?), the no-vanish diff logic (fail-open holes), and #520 doc consistency. Fold fixes.
- Step 5 (BOM/format) No
.cstouched; skip the BOM sweep. Confirm no stray CRLF/BOM in new scripts:for f in scripts/decisions_lib.py scripts/decisions_validate.py scripts/build_decisions_catalog.py; do head -c3 "$f" | xxd -p | grep -q '^efbbbf' && echo "BOM: $f"; done(expect no output). - Step 6 Push once (batched), open PR
fixes #520 #521, arm CI monitor at PR-open. - Step 7 (notify server-management#642 — the MemPalace exporter) #642 is the sole
Gitea→MemPalace ingest and holds (mines nothing) until ErsatzTV signals readiness. The definitive
ready signal is the
decisions-validateCI job green onmainwith 0 legacy-unmigrated (which also guarantees every record haskey:/status:and all superseded/retired records are underdocs/decisions/archive/— the wing boundary #642 keys off). PR2 merges atomically, so main never shows a partial state. On merge, post a#642comment: "ready to mine — decisions-validate green on <main-sha>". Do NOT rely onarchive/merely appearing; the green-validator sha is the go signal. (Design note: supersession EXTRACTS a single record from a multi-record topic file intoarchive/, leaving that file's other active records in place — do not repeat metadata per section; the wing boundary is the guarantee, so chunks 2..n of a record need no inline status.)
Docs updated in this PR (checklist)
docs/README.md(map),docs/decisions.md(header),docs/decisions/README.md(generated catalog),docs/ci-cd.md(release ritual),CLAUDE.md(docs-first + closing record),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
catalog (Task 3), topic/archive responsibilities (Task 6/7), per-record identity (Task 6), prose
reversals reconciled (Task 5), validator (Task 2), aggregate budget (Task 2
_budget_ok), release ritual archives classified history (Task 7), migration complete-or-bounded (Task 6), retrieval benchmark (Task 9 + Task 10 Step 3), startup points at compact view (Task 8), docs same PR ✔. - #520 done-when: start without #237 (Task 8), concurrent orientation+selector (Task 8), named-issue skips selection (Task 8), deterministic top-five (existing select-queue.sh, unchanged), README map (Task 8), MemPalace candidate+verify+exact-search fallback documented (Task 8 kickoff), #237 archival only (Task 8), pre-script lore archived (Task 8), structured closing comment (Task 8), cannot-regress check (Task 4 + Task 10), cold-agent sim (Task 10 Step 2), docs same PR ✔.