Refs #520 #521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
42 KiB
Decision-lifecycle + retrieval-efficient startup — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give ErsatzTV decision records a stable key + explicit lifecycle + a generated compact active view (#521), replace the line-level append-only guard with a lifecycle validator, and rewire startup to run orientation ‖ selection and stop treating closed tracker #237 as live state (#520).
Architecture: A shared Python parser (decisions_lib.py) reads H2 decision records + a visible
metadata block from docs/decisions.md and the topic files. Two generators/validators build on it:
build-decisions-catalog.py regenerates docs/decisions/README.md (the active catalog), and
decisions-validate.py enforces lifecycle invariants (≤1 active/key, reciprocal supersession,
no-vanish, catalog freshness, aggregate budget). A bash check-kickoff-guard.sh blocks
#237-as-live-state language. Existing entries are migrated into schema form by fanned-out subagents;
superseded/retired records move to docs/decisions/archive/. CI's decisions-guard job is
extended to run the validators; the [decisions-edit] token and its Husky/CI wiring are retired.
Tech Stack: Python 3 (stdlib only; ruff/pyright/pytest via uv locally), Bash, Markdown, Gitea
Actions (.gitea/workflows/docker-build.yml), Husky hooks.
Global Constraints
- Work in the worktree
feat/520-521-decision-lifecycleofforigin/main. Never commit in/Users/timothy/ersatztv. - This arc rewrites
docs/decisions.mdwholesale; 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> keyformat:^[a-z0-9]+(\.[a-z0-9-]+)+$(dotted, lowercase, hyphens ok), e.g.ci.runner-placement.statusenum:active,superseded,retired(records;legacy-unmigratedis the implicit status of an unmigrated entry — no metadata line — and must trend to 0 by end of migration).- Python scripts: stdlib only,
#!/usr/bin/env python3, module docstring,ruff/pyrightclean. Tests are pytest underscripts/tests/, run withpython -m pytest scripts/tests -q. - Every
.cs? None in this arc. No OpenAPI regen. No DB migration. - Never set
ETV_UPDATE_GOLDENS.
File Structure
Create:
scripts/decisions_lib.py— shared parser (records + metadata) over the active decision files.scripts/decisions_validate.py— lifecycle-invariant validator (CLI,--base/--headfor no-vanish).scripts/build_decisions_catalog.py— regenerate/--checkdocs/decisions/README.md.scripts/check-kickoff-guard.sh— grep guard against #237-as-live-state language.scripts/tests/test_decisions_lib.py,scripts/tests/test_decisions_validate.py,scripts/tests/fixtures/…— pytest suite.docs/decisions/README.md— GENERATED active catalog / task router.docs/decisions/archive/(+docs/decisions/archive/README.md) — superseded/retired records.docs/decisions/migration-map.md— auditable legacy-heading → active-record/archive mapping.docs/decisions/retrieval-eval.md— the retrieval question bank + expected active-record answers.
Modify:
docs/decisions.md— header reframed (append-only → lifecycle); every in-file entry migrated to schema.docs/decisions/{optimistic-concurrency,api-auth-security,release-ci-governance,spa-modularization}.md— entries migrated to schema..claude/hooks/decisions-guard.sh— replaced by a thin shim calling the validator (or removed + hook rewired)..husky/commit-msg/.husky/pre-commit— swap the append-only call for the validator..gitea/workflows/docker-build.yml—decisions-guardjob → runs validator + catalog--check+ kickoff guard; drop[decisions-edit]/size-floor text.docs/handoffs/chicorytv-issue-queue.md— startup rewrite (#520).docs/README.md— reading-order → task-signal map + active-catalog pointer.CLAUDE.md— docs-first guidance +## Closing recordtemplate + drop[decisions-edit]mentions.docs/ci-cd.md— release ritual rewrite; drop[decisions-edit]; aggregate budget.scripts/select-queue.sh— comment/output text (tiers lead with milestones/review/priority; no "read #237").
Task 1: Shared decision-record parser (decisions_lib.py)
Files:
- Create:
scripts/decisions_lib.py - Test:
scripts/tests/test_decisions_lib.py,scripts/tests/fixtures/sample_decisions.md
Interfaces:
-
Produces:
Recorddataclass (heading, source, lineno, key, status, since, supersedes, superseded_by, rule, signals, mechanics, body);parse_file(path) -> list[Record];active_files() -> list[Path];all_active_records() -> list[Record]; module constantsSTATUSES,KEY_RE,DECISIONS_MD,TOPIC_DIR,ARCHIVE_DIR. -
Step 1: Write the fixture
scripts/tests/fixtures/sample_decisions.md
# Decisions
## Index
- [x](#y)
## 2026-07-17 — Migrated example (#406)
`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** Every CI services container gets an explicit cap.
**Signals:** ci, runners · paths: .gitea/workflows/docker-build.yml · issues: #390 #406
**Mechanics:** docs/ci-cd.md → CI lanes
Rationale prose here.
## 2026-07-11 — Legacy unmigrated example (#231)
Some prose with no metadata line at all.
- Step 2: Write the failing test
scripts/tests/test_decisions_lib.py
from pathlib import Path
import scripts.decisions_lib as dl
FIX = Path(__file__).parent / "fixtures" / "sample_decisions.md"
def test_parses_migrated_record():
recs = dl.parse_file(FIX)
migrated = [r for r in recs if r.key == "ci.runner-placement"]
assert len(migrated) == 1
r = migrated[0]
assert r.status == "active"
assert r.since == "2026-07-17"
assert r.supersedes == "none"
assert r.superseded_by == "none"
assert r.rule == "Every CI services container gets an explicit cap."
assert "issues: #390 #406" in r.signals
assert r.mechanics.startswith("docs/ci-cd.md")
def test_legacy_record_is_unmigrated():
recs = dl.parse_file(FIX)
legacy = [r for r in recs if r.heading.endswith("(#231)")]
assert len(legacy) == 1
assert legacy[0].key is None
assert legacy[0].status == "legacy-unmigrated"
def test_index_section_is_not_a_record():
recs = dl.parse_file(FIX)
assert all(r.heading != "Index" for r in recs)
Note: the Index H2 has no metadata → parsed as a legacy record with heading Index; the validator
(Task 2) skips a configured set of non-decision headings. This test asserts we can identify it, so
here it's acceptable that Index parses as a record — adjust assertion to
assert any(r.heading == "Index" for r in recs) and let Task 2 own the skip-list. Use this
corrected assertion:
def test_index_section_parses_as_heading():
recs = dl.parse_file(FIX)
assert any(r.heading == "Index" for r in recs)
- Step 3: Run test to verify it fails
Run: python -m pytest scripts/tests/test_decisions_lib.py -q
Expected: FAIL (ModuleNotFoundError: scripts.decisions_lib)
- Step 4: Implement
scripts/decisions_lib.py
#!/usr/bin/env python3
"""Parse ErsatzTV decision records from docs/decisions.md and docs/decisions/*.md.
A decision record is a Markdown H2 section. A *migrated* record carries a visible metadata
block as its first non-blank content:
## 2026-07-17 — Title … (#406)
`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** one-line current rule.
**Signals:** concept · paths: a/b.yml · issues: #406
**Mechanics:** docs/ci-cd.md → CI lanes
<rationale prose …>
An H2 with no metadata line is treated as status `legacy-unmigrated` (migration target).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DECISIONS_MD = REPO_ROOT / "docs" / "decisions.md"
TOPIC_DIR = REPO_ROOT / "docs" / "decisions"
ARCHIVE_DIR = TOPIC_DIR / "archive"
STATUSES = {"active", "superseded", "retired", "legacy-unmigrated"}
KEY_RE = re.compile(r"^[a-z0-9]+(\.[a-z0-9-]+)+$")
_HEADING_RE = re.compile(r"^##\s+(.*\S)\s*$")
_META_FIELD_RE = re.compile(r"`([a-z-]+):\s*([^`]*)`")
@dataclass
class Record:
heading: str
source: Path
lineno: int
key: str | None = None
status: str = "legacy-unmigrated"
since: str | None = None
supersedes: str | None = None
superseded_by: str | None = None
rule: str | None = None
signals: str | None = None
mechanics: str | None = None
body: str = ""
def _parse_meta_line(line: str) -> dict[str, str]:
return {m.group(1): m.group(2).strip() for m in _META_FIELD_RE.finditer(line)}
def parse_text(text: str, source: Path) -> list[Record]:
lines = text.splitlines()
records: list[Record] = []
i = 0
while i < len(lines):
m = _HEADING_RE.match(lines[i])
if not m:
i += 1
continue
rec = Record(heading=m.group(1), source=source, lineno=i + 1)
j = i + 1
body_lines: list[str] = []
while j < len(lines) and not _HEADING_RE.match(lines[j]):
body_lines.append(lines[j])
j += 1
for k, bl in enumerate(body_lines):
if not bl.strip():
continue
meta = _parse_meta_line(bl)
if "key" in meta and "status" in meta:
rec.key = meta.get("key") or None
rec.status = meta.get("status") or "legacy-unmigrated"
rec.since = meta.get("since") or None
rec.supersedes = meta.get("supersedes") or None
rec.superseded_by = meta.get("superseded-by") or None
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); CLImain()with--base REF --head REF(no-vanish diff),--budget N, exit 1 on any error. -
Non-decision headings skipped:
{"Index", "Active catalog"}(configurableSKIP_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 fordocs/decisions/README.md); CLI--check(exit 1 if on-disk differs) / default write. -
Step 1: Write failing test
scripts/tests/test_build_catalog.py
from pathlib import Path
import scripts.decisions_lib as dl
import scripts.build_decisions_catalog as bc
def test_catalog_lists_only_active_sorted_by_key():
recs = [
dl.Record(heading="H2", source=Path("x"), lineno=1, key="z.a", status="active",
rule="Zeta rule"),
dl.Record(heading="H1", source=Path("x"), lineno=1, key="a.b", status="active",
rule="Alpha rule"),
dl.Record(heading="Old", source=Path("x"), lineno=1, key="a.b", status="superseded",
rule="old"),
]
out = bc.render_catalog(recs)
assert "a.b" in out and "z.a" in out
assert out.index("a.b") < out.index("z.a") # sorted
assert "Alpha rule" in out and "Zeta rule" in out
assert "old" not in out # superseded excluded
assert "GENERATED" in out # do-not-edit banner
- Step 2: Run to verify fail
Run: PYTHONPATH=. python -m pytest scripts/tests/test_build_catalog.py -q → FAIL (module missing).
- Step 3: Implement
scripts/build_decisions_catalog.py
#!/usr/bin/env python3
"""Generate docs/decisions/README.md — the compact ACTIVE decision catalog / task router.
Do not edit docs/decisions/README.md by hand; regenerate with this script. The decisions-validate
CI job checks it is in sync.
"""
from __future__ import annotations
import sys
from pathlib import Path
import scripts.decisions_lib as dl
OUTPUT = dl.TOPIC_DIR / "README.md"
BANNER = "<!-- GENERATED by scripts/build_decisions_catalog.py — do not edit by hand. -->"
def _rel(rec) -> str:
"""Link from docs/decisions/README.md to the record's heading."""
src = Path(rec.source).name
anchor = _anchor(rec.heading)
if src == "decisions.md":
return f"../decisions.md#{anchor}"
return f"{src}#{anchor}"
def _anchor(heading: str) -> str:
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: 2–3 fully-migrated exemplar records the subagents in Task 6 copy as the pattern.
-
Step 1 Read the #406 entry (
docs/decisions.mdaround the "No persistent compiler servers" heading) and the #411-related text. Identify the reversal pair: #390's small-lane move (predecessor) → #406 (reverses it). Assign keys:ci.runner-placement(the #406 active record). If #390 exists as a distinct record, mark itstatus: superseded,superseded-by: ci.runner-placement@2026-07-17, and move it todocs/decisions/archive/ci.mdwith a back-link; give #406supersedes: <#390-key>@<date>. If #390 only ever lived as prose inside #406, record that in the migration map (no separate record) and #406 carriessupersedes: nonewith a rationale note. -
Step 2 Add the full metadata block to #406 (see Global Constraints format). Add
**Rule:**,**Signals:**,**Mechanics:**. -
Step 3 Do the same reconciliation for the #411 obsolescence (superseded measurement): status
retiredorsupersededper whether the governed surface still exists; archive if superseded. -
Step 4 Start
docs/decisions/migration-map.md:
# Decision migration map (legacy heading → active record / archive)
Auditable mapping produced during the #521 lifecycle migration. Every legacy H2 heading maps to
either an active record (key) or an archive location.
| Legacy heading | Key | Status | Location |
| --- | --- | --- | --- |
| 2026-07-17 — No persistent compiler servers in CI … (#406) | ci.runner-placement | active | docs/decisions.md |
- Step 5 Run
PYTHONPATH=. python scripts/decisions_validate.py— expect OK plus a::notice::legacy-unmigrated count (still high; migration ongoing). Regenerate catalog:PYTHONPATH=. python scripts/build_decisions_catalog.py. - Step 6: Commit
git add docs/decisions.md docs/decisions/
git -c core.hooksPath=/dev/null commit -m "feat(521): reconcile #390/#406 + #411 reversals; seed schema exemplars [decisions-edit]"
(Interim commits may still carry [decisions-edit] while the old hook exists; Task 7 removes it.)
Task 6: Full entry migration (subagent fan-out)
Files:
- Modify: every entry in
docs/decisions.md+ the four topic files. - Modify:
docs/decisions/migration-map.md(one row per entry). - Create: records under
docs/decisions/archive/*.mdfor any superseded/retired entry.
Interfaces:
- Consumes: the schema format (Global Constraints) + the Task 5 exemplars.
- Produces: 0
legacy-unmigratedrecords; validator + catalog green.
Orchestration (not a code step — the executor runs this): Split the corpus into bounded batches
by topic/date range (e.g. batch A = topic file optimistic-concurrency.md; B = api-auth-security.md;
C = release-ci-governance.md; D = spa-modularization.md; E–H = decisions.md in ~10-entry
slices). Dispatch one subagent per batch (balanced/cheap tier; each with its OWN worktree/branch off
the feature branch — never two committing agents on one worktree). Each subagent's brief:
-
Read the schema format + one exemplar (paste both into the brief).
-
For each
##entry in your assigned file/range: insert the metadata block as the first non-blank lines under the heading. Derivekeyfrom the subject (stable, dotted; REUSE an existing key only for a genuine supersessor).status: activeunless 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 (2–3 concurrent; gate on free RAM). Collect flagged reversals.
-
Step 2 Orchestrator reconciles every flagged reversal/retirement (assign supersede links, move to
docs/decisions/archive/, update predecessor). This is judgment work — not delegated. -
Step 3 Merge batch branches back into the feature branch.
-
Step 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-guardjob). -
Modify:
docs/decisions.mdheader (append-only framing → lifecycle framing). -
Modify:
docs/ci-cd.md(release ritual; drop[decisions-edit], aggregate budget). -
Step 1 Replace
.claude/hooks/decisions-guard.shwith 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). Verifygrep -rn 'decisions-edit' .husky/returns nothing. - Step 3 Update the CI
decisions-guardjob in.gitea/workflows/docker-build.yml: replace thedecisions-guard.sh rangecall with:
- name: Validate decision lifecycle
run: |
base_ref="${{ ... existing base ref expr ... }}"
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- 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.mdheader: 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.mdrelease 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 1–10 reading order with a compact task-signal → minimal sections map (e.g. "API change → api-conventions §checklist + endpoint-index"; "scheduling → domain-model + decisions catalog rows keyedsched.*"; "startup/queue → handoff kickoff + select-queue.sh"). Add a pointer: decisions active view =docs/decisions/README.md(catalog), archive =docs/decisions/archive/. -
Step 2
docs/handoffs/chicorytv-issue-queue.md: rewrite the STANDING KICKOFF around the two concurrent tracks (orientation ‖select-queue.sh 5); named-issue path skips selection → focused retrieval; move the pre-script "re-derive the contested tier by hand" selector lore into a clearly labeled "Archived — pre-script selector history (do not follow)" section; reduce #237 to a single archival line marked<!-- archival:237 -->. Add the## Closing recordtemplate as the session-end record shape. -
Step 3
CLAUDE.md: docs-first guidance → "read thedocs/README.mdmap + the sections it points to, not the whole corpus." Add the## Closing recordtemplate to the Task Completion Protocol. 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.sh→ exit 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 theretrieval-eval.mdquestions; PASS iff it cites the expected active keys and does NOT pick a superseded/archived record as current. - Step 4 (adversarial review) Cold-context review-only agent (cross-model/Fable if available) over the whole diff: focus on the guard rework (does the lifecycle validator actually preserve the no-silent-delete spirit? any way to delete rationale and pass?), the no-vanish diff logic (fail-open holes), and #520 doc consistency. Fold fixes.
- Step 5 (BOM/format) No
.cstouched; skip the BOM sweep. Confirm no stray CRLF/BOM in new scripts:for f in scripts/decisions_lib.py scripts/decisions_validate.py scripts/build_decisions_catalog.py; do head -c3 "$f" | xxd -p | grep -q '^efbbbf' && echo "BOM: $f"; done(expect no output). - Step 6 Push once (batched), open PR
fixes #520 #521, arm CI monitor at PR-open.
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 ✔.