# 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-lifecycle` off `origin/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.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: ``` `key: ` · `status: ` · `since: ` · `supersedes: ` · `superseded-by: ` **Rule:** **Signals:** · paths: · issues: #NNN **Mechanics:** ``` - `key` format: `^[a-z0-9]+(\.[a-z0-9-]+)+$` (dotted, lowercase, hyphens ok), e.g. `ci.runner-placement`. - `status` enum: `active`, `superseded`, `retired` (records; `legacy-unmigrated` is 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`/`pyright` clean. Tests are pytest under `scripts/tests/`, run with `python -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/--head` for no-vanish). - `scripts/build_decisions_catalog.py` — regenerate/`--check` `docs/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-guard` job → 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 record` template + 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: `Record` dataclass (`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 constants `STATUSES`, `KEY_RE`, `DECISIONS_MD`, `TOPIC_DIR`, `ARCHIVE_DIR`. - [ ] **Step 1: Write the fixture** `scripts/tests/fixtures/sample_decisions.md` ```markdown # 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` ```python 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:** ```python 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`** ```python #!/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 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** ```bash 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); CLI `main()` 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): a `superseded`/`retired` record in the ACTIVE files is an error ("relocate to `docs/decisions/archive/`"); an `active` record under `docs/decisions/archive/` is an error. This makes the MemPalace wing boundary (`ErsatzTV-Decisions` active vs `ErsatzTV-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), mirroring `decisions_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"}` (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` ```python 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:** ```python 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`** ```python #!/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** ```bash 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 for `docs/decisions/README.md`); CLI `--check` (exit 1 if on-disk differs) / default write. - [ ] **Step 1: Write failing test** `scripts/tests/test_build_catalog.py` ```python 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`** ```python #!/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 = "" 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** ```bash 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 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. 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** ```bash 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.md` around 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 it `status: superseded`, `superseded-by: ci.runner-placement@2026-07-17`, and move it to `docs/decisions/archive/ci.md` with a back-link; give #406 `supersedes: <#390-key>@`. If #390 only ever lived as prose inside #406, record that in the migration map (no separate record) and #406 carries `supersedes: none` with 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 `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 # 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** ```bash 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/*.md` for any superseded/retired entry. **Interfaces:** - Consumes: the schema format (Global Constraints) + the Task 5 exemplars. - Produces: 0 `legacy-unmigrated` records; 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: 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 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). - [ ] **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) ```bash 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-guard` job). - Modify: `docs/decisions.md` header (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.sh` with a thin, **fail-open** wrapper (Fable #12ii — python missing must NOT wedge a commit): ```bash #!/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-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: ```yaml - 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.md` header: 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.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' .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): 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 keyed `sched.*`"; "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 ``. Add the `## Closing record` template as the session-end 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. 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`** (active `docs/decisions.md` + `docs/decisions/*.md` + catalog). History wings `ErsatzTV-Decisions-Archive` (`docs/decisions/archive/**`) and `Gitea-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.md` catalog first, then ``rg '^`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. - [ ] **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** 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 \ 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: ```markdown ## 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** ```bash 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.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. - [ ] **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 `.cs` touched; 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-validate` CI job green on `main` with 0 legacy-unmigrated** (which also guarantees every record has `key:`/`status:` and all superseded/retired records are under `docs/decisions/archive/` — the wing boundary #642 keys off). PR2 merges atomically, so main never shows a partial state. On merge, post a `#642` comment: "ready to mine — decisions-validate green on \". Do NOT rely on `archive/` merely appearing; the green-validator sha is the go signal. (Design note: supersession EXTRACTS a single record from a multi-record topic file into `archive/`, 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 ✔.