Files
ersatztv/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md
T
2026-07-21 02:58:58 +02:00

42 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.
  • This arc rewrites docs/decisions.md wholesale; land it as ONE PR, rebase-then- merge_when_checks_succeed. On rebase conflicts in generated artifacts (docs/decisions/README.md), REGENERATE — never hand-merge.
  • 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
                for bl2 in body_lines[k + 1 :]:
                    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, Record, KEY_RE, STATUSES, ARCHIVE_DIR.

  • Produces: validate(records, archive_records, catalog_ok, budget, removed_active_headings) -> list[str] (returns list of error strings; empty = pass); CLI main() with --base REF --head REF (no-vanish diff), --budget N, exit 1 on any error.

  • Non-decision headings skipped: {"Index", "Active catalog"} (configurable SKIP_HEADINGS).

  • 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 test_two_active_same_key_fails():
    recs = [_rec(key="a.b"), _rec(key="a.b")]
    errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
    assert any("more than one active" in e for e in errs)


def test_bad_key_format_fails():
    recs = [_rec(key="BadKey")]
    errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
    assert any("key format" in e for e in errs)


def test_dangling_superseded_by_fails():
    recs = [_rec(key="a.b", status="superseded", superseded_by="a.c@2026-07-01")]
    errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
    assert any("superseded-by" in e and "a.c" in e for e in errs)


def test_removed_active_not_in_archive_fails():
    errs = dv.validate([], archive_keys=set(), catalog_ok=True, budget_ok=True,
                       removed=["2026-01-01 — Gone (#9)"])
    assert any("removed from the active set" in e for e in errs)


def test_clean_corpus_passes():
    recs = [_rec(key="a.b"), _rec(key="c.d")]
    errs = dv.validate(recs, archive_keys=set(), catalog_ok=True, budget_ok=True, removed=[])
    assert errs == []
  • 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. Fail-open on tooling trouble (missing refs, 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")


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) -> list[str]:
    errs: list[str] = []
    decision_recs = [r for r in records if r.heading not in SKIP_HEADINGS]

    active_by_key: dict[str, int] = {}
    known_keys = set(archive_keys)
    for r in decision_recs:
        if r.status == "legacy-unmigrated":
            continue
        if r.status not in dl.STATUSES or r.status == "legacy-unmigrated":
            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
    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}"
        )

    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


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 _removed_active(base: str, head: str) -> list[str]:
    """Headings present in active files at base but gone at head, not landing in archive."""
    try:
        out = subprocess.run(
            ["git", "diff", "--numstat", f"{base}...{head}", "--", "docs/decisions.md",
             "docs/decisions/"],
            capture_output=True, text=True, check=True,
        ).stdout
    except Exception:
        return []  # fail-open
    # Compare parsed headings in active files between the two trees.
    def headings_at(ref: str) -> set[str]:
        res: set[str] = set()
        for path in ("docs/decisions.md",):
            blob = subprocess.run(["git", "show", f"{ref}:{path}"],
                                  capture_output=True, text=True)
            if blob.returncode == 0:
                res |= {r.heading for r in dl.parse_text(blob.stdout, Path(path))}
        return res
    def archive_headings_at(ref: str) -> set[str]:
        res: set[str] = set()
        names = subprocess.run(["git", "ls-tree", "-r", "--name-only", ref,
                                "docs/decisions/archive/"], capture_output=True, text=True).stdout
        for path in names.splitlines():
            if path.endswith(".md"):
                blob = subprocess.run(["git", "show", f"{ref}:{path}"],
                                      capture_output=True, text=True)
                if blob.returncode == 0:
                    res |= {r.heading for r in dl.parse_text(blob.stdout, Path(path))}
        return res
    gone = headings_at(base) - headings_at(head) - {"Index", "Active catalog"}
    landed = archive_headings_at(head)
    return sorted(gone - landed)


def _budget_ok(records, limit: int) -> bool:
    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; generator task lands with this validator
    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 = _removed_active(args.base, args.head) if args.base and args.head else []
    errs = validate(
        records,
        archive_keys=_archive_keys(),
        catalog_ok=_catalog_ok(),
        budget_ok=_budget_ok(records, args.budget),
        removed=removed,
    )

    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:
    out = []
    for ch in heading.lower():
        if ch.isalnum():
            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. Allow-lists lines tagged <!-- archival:237 --> or within an "Archived / historical" section.

  • 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. An intentional archival mention must carry the
# marker `<!-- archival:237 -->` on the same line (or be inside a line already containing "archiv").
set -euo pipefail

FILES=(
  "docs/handoffs/chicorytv-issue-queue.md"
  "docs/README.md"
  "CLAUDE.md"
)
# 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
  while IFS=: read -r lineno content; do
    [ -n "$lineno" ] || continue
    printf '%s' "$content" | grep -qiE 'archiv|archival:237' && continue
    echo "kickoff-guard: $f:$lineno reintroduces #237-as-live-state: ${content}" >&2
    rc=1
  done < <(grep -niE "$PATTERNS" "$f" || true)
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 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).

  • For each ## entry in your assigned file/range: insert the metadata block as the first non-blank lines under the heading. Derive key from the subject (stable, dotted; REUSE an existing key only for a genuine supersessor). status: active unless the prose says the decision was reversed/retired (then flag it for the orchestrator — do NOT self-archive). since = the entry date. **Rule:** = the entry's one-line current rule (from its own text). **Signals:** = concepts + paths: + issues: mined from the entry. **Mechanics:** = any doc/source anchors the entry already cites.

  • Do NOT alter rationale prose. Do NOT delete anything.

  • Add a migration-map row per entry.

  • Return the list of entries you flagged as reversed/retired for orchestrator reconciliation.

  • Step 1 Dispatch batch subagents (23 concurrent; gate on free RAM). Collect flagged reversals.

  • Step 2 Orchestrator reconciles every flagged reversal/retirement (assign supersede links, move to docs/decisions/archive/, update predecessor). This is judgment work — not delegated.

  • Step 3 Merge batch branches back into the feature branch.

  • Step 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: Retire the append-only line guard + [decisions-edit]; wire the validator

Files:

  • Modify: .claude/hooks/decisions-guard.sh (replace body with a validator shim, or delete + rewire).

  • Modify: .husky/commit-msg, .husky/pre-commit (call validator instead of append-only guard).

  • Modify: .gitea/workflows/docker-build.yml (decisions-guard job).

  • Modify: docs/decisions.md header (append-only framing → lifecycle framing).

  • Modify: docs/ci-cd.md (release ritual; drop [decisions-edit], aggregate budget).

  • Step 1 Replace .claude/hooks/decisions-guard.sh with a thin wrapper:

#!/usr/bin/env bash
# ersatztv#521 — the append-only line guard is retired. Decision integrity is now enforced by the
# lifecycle validator. This shim keeps the old hook path working: it runs the validator over the
# working tree. Fail-open on tooling trouble.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
PYTHONPATH=. python3 scripts/decisions_validate.py || exit 1
  • Step 2 Update .husky/commit-msg: remove the [decisions-edit]/append-only block; the Co-Authored-By check stays. Add the validator to .husky/pre-commit (or keep it in commit-msg via the shim). Verify grep -rn 'decisions-edit' .husky/ returns nothing.
  • Step 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
      - name: Kickoff guard
        run: bash scripts/check-kickoff-guard.sh

Remove the 1800-line-floor block (now the validator's aggregate budget). Rename the job label to decisions lifecycle.

  • Step 4 Rewrite the docs/decisions.md header: append-only → lifecycle; document the metadata schema, the four statuses, the catalog + archive, and that supersession is same-PR. Remove [decisions-edit] instructions.
  • 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).
  • Step 6 Run: bash .claude/hooks/decisions-guard.sh (shim) → validator OK. grep -rn 'decisions-edit' docs/ .husky/ .gitea/ .claude/ → only historical/archival mentions.
  • 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): retire append-only line guard + [decisions-edit]; wire lifecycle validator into CI"

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. Remove [decisions-edit] mentions; point decision lookups at the catalog.

  • Step 4 scripts/select-queue.sh: update header comment + any echoed guidance so tiers lead with open milestones/review/priority and no line says "read #237."

  • Step 5 Run bash scripts/check-kickoff-guard.shexit 0 now (the language is gone). This flips the Task 4 non-vacuity failure to green — the regression box is satisfied.

  • Step 6: Commit

git add docs/handoffs/chicorytv-issue-queue.md docs/README.md CLAUDE.md scripts/select-queue.sh
git -c core.hooksPath=/dev/null commit -m "feat(520): parallel orientation+selection startup; retire #237 as live state"

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 ONLY the new docs/handoffs/chicorytv-issue-queue.md + docs/README.md (no #237 access) and the prompt "you are a fresh orchestrator, no issue named — describe your exact startup steps." PASS iff: runs orientation ‖ select-queue.sh 5, resolves only CLAIM?/UMBRELLA? flags + winner, rechecks live state, claims, focused retrieval — and never reads/consults #237. Record the transcript verdict in the PR.
  • 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.

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.

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