Files
ersatztv/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md
T

58 KiB
Raw Blame History

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: <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>
    
  • 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.ymldecisions-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

# 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); 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

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 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

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 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

#!/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: 23 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>@<date>. 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:

# 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/*.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; EH = 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 (23 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)

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):

#!/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:
      - 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
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 110 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 <!-- archival:237 -->. 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: <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.
  • 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

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.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 <main-sha>". 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 ✔.