docs(520,521): fold Fable review — body-diff guard, two-PR split, key/backstop fixes
Refs #520 #521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,9 +22,26 @@ Actions (`.gitea/workflows/docker-build.yml`), Husky hooks.
|
||||
|
||||
- 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.
|
||||
- **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:
|
||||
```
|
||||
@@ -239,7 +256,11 @@ def parse_text(text: str, source: Path) -> list[Record]:
|
||||
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:**"):
|
||||
@@ -292,11 +313,18 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): decision-record parser (de
|
||||
- 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.
|
||||
- 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) ->
|
||||
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.
|
||||
- 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`
|
||||
|
||||
@@ -327,34 +355,43 @@ def _rec(**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():
|
||||
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)
|
||||
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():
|
||||
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)
|
||||
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")]
|
||||
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)
|
||||
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():
|
||||
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)
|
||||
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():
|
||||
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 == []
|
||||
assert _v([_rec(key="a.b"), _rec(key="c.d")]) == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify fail**
|
||||
@@ -370,7 +407,9 @@ Expected: FAIL (`ModuleNotFoundError: scripts.decisions_validate`)
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -383,6 +422,7 @@ import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as m
|
||||
|
||||
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:
|
||||
@@ -391,16 +431,17 @@ def _key_of(ref: str | None) -> str | None:
|
||||
return ref.split("@", 1)[0].strip()
|
||||
|
||||
|
||||
def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[str]:
|
||||
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]
|
||||
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 == "legacy-unmigrated":
|
||||
continue
|
||||
if r.status not in dl.STATUSES or r.status == "legacy-unmigrated":
|
||||
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}")
|
||||
@@ -416,7 +457,7 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s
|
||||
if n > 1:
|
||||
errs.append(f"key {key!r}: more than one active record ({n})")
|
||||
|
||||
# reciprocal supersession
|
||||
# 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:
|
||||
@@ -426,9 +467,9 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s
|
||||
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}"
|
||||
)
|
||||
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")
|
||||
@@ -437,6 +478,106 @@ def validate(records, *, archive_keys, catalog_ok, budget_ok, removed) -> list[s
|
||||
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():
|
||||
@@ -447,42 +588,7 @@ def _archive_keys() -> set[str]:
|
||||
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:
|
||||
def _budget_ok(limit: int) -> bool:
|
||||
total = 0
|
||||
for f in dl.active_files():
|
||||
if f.exists():
|
||||
@@ -497,7 +603,7 @@ def _catalog_ok() -> bool:
|
||||
try:
|
||||
import scripts.build_decisions_catalog as bc
|
||||
except Exception:
|
||||
return True # fail-open; generator task lands with this validator
|
||||
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 ""
|
||||
@@ -512,13 +618,14 @@ def main(argv=None) -> int:
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
records = dl.all_active_records()
|
||||
removed = _removed_active(args.base, args.head) if args.base and args.head else []
|
||||
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(records, args.budget),
|
||||
budget_ok=_budget_ok(args.budget),
|
||||
removed=removed,
|
||||
rewritten=rewritten,
|
||||
)
|
||||
|
||||
unmigrated = [r for r in records if r.status == "legacy-unmigrated"
|
||||
@@ -622,9 +729,13 @@ def _rel(rec) -> str:
|
||||
|
||||
|
||||
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():
|
||||
if ch.isalnum() or ch == "_":
|
||||
out.append(ch)
|
||||
elif ch in " -":
|
||||
out.append("-")
|
||||
@@ -696,23 +807,28 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): active-catalog generator"
|
||||
|
||||
**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.
|
||||
file:line otherwise. Scans `docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`, `CLAUDE.md`,
|
||||
`scripts/select-queue.sh`. A hit is exempt if the line contains `archiv` (case-insensitive) OR the
|
||||
scanner is currently inside an archival section — a heading matching `^#{1,6}.*archiv` (case-
|
||||
insensitive) opens the section; the next same-or-higher-level heading closes it. The `archiv`
|
||||
substring is a deliberate anti-footgun escape hatch, not an anti-adversary control.
|
||||
|
||||
- [ ] **Step 1: Write `scripts/check-kickoff-guard.sh`**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# ersatztv#520 — block re-treating the CLOSED arc tracker #237 as live queue state / source of truth.
|
||||
# Scans active startup docs for forbidden phrasings. An intentional archival mention must carry the
|
||||
# marker `<!-- archival:237 -->` on the same line (or be inside a line already containing "archiv").
|
||||
# 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'
|
||||
@@ -720,12 +836,27 @@ PATTERNS='read #237|#237.?s (body|arc|comments)|tracker #237|queue state lives i
|
||||
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)
|
||||
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"
|
||||
@@ -767,6 +898,15 @@ git -c core.hooksPath=/dev/null commit -m "feat(520): kickoff regression guard (
|
||||
`**Signals:**`, `**Mechanics:**`.
|
||||
- [ ] **Step 3** Do the same reconciliation for the #411 obsolescence (superseded measurement):
|
||||
status `retired` or `superseded` per whether the governed surface still exists; archive if superseded.
|
||||
- [ ] **Step 3b (dogfood supersession — Fable #5)** This PR *reverses a recorded decision*: the #303
|
||||
H9 "docs/decisions.md is append-only" decision (in `docs/decisions/release-ci-governance.md`) is
|
||||
replaced by the lifecycle validator. The very first PR under the new regime must model the same-PR
|
||||
supersession it mandates. Add a NEW active record for the lifecycle decision (key
|
||||
`docs.decision-lifecycle`, status `active`, `supersedes: docs.append-only-guard@2026-06`); give the
|
||||
existing H9 record the key `docs.append-only-guard`, set `status: superseded`, `superseded-by:
|
||||
docs.decision-lifecycle@2026-07-21`, and move it to `docs/decisions/archive/release-ci-governance.md`
|
||||
with a back-link. (Note: the `## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237)`
|
||||
record is reversed by #520, not this PR — its supersession lands in PR2/Task 8.)
|
||||
- [ ] **Step 4** Start `docs/decisions/migration-map.md`:
|
||||
|
||||
```markdown
|
||||
@@ -811,22 +951,34 @@ C = `release-ci-governance.md`; D = `spa-modularization.md`; E–H = `decisions.
|
||||
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).
|
||||
- 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. 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.
|
||||
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 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 0 (orchestrator, judgment — before fan-out)** Build the full heading→key table for the
|
||||
whole corpus (this IS the migration-map skeleton). Assign stable dotted keys by *subject*, so related
|
||||
decisions across batches share a family prefix and no two batches coin divergent keys for one
|
||||
subject (the ≤1-active-per-key check catches collisions, NOT divergent keys). Reuse an existing key
|
||||
only for a genuine supersessor.
|
||||
- [ ] **Step 1** Dispatch batch subagents (2–3 concurrent; gate on free RAM), each with its key-table
|
||||
slice. Collect returned rows + flagged reversals.
|
||||
- [ ] **Step 1b (reversal backstop — Fable #7)** Cheap agents WILL miss prose reversals. Orchestrator
|
||||
greps the whole corpus for `supersed|reversed|obsolete|no longer|retired|deprecat` and reconciles
|
||||
every hit against the flag list — a hit not already flagged is a missed reversal to resolve.
|
||||
- [ ] **Step 2** Orchestrator reconciles every flagged/found reversal/retirement (assign supersede
|
||||
links, move to `docs/decisions/archive/`, update predecessor). Judgment — not delegated.
|
||||
- [ ] **Step 3** Merge batch branches back into the feature branch; orchestrator writes
|
||||
`docs/decisions/migration-map.md` once from the returned rows.
|
||||
- [ ] **Step 4** Regenerate catalog: `PYTHONPATH=. python scripts/build_decisions_catalog.py`.
|
||||
- [ ] **Step 5** Run validator: `PYTHONPATH=. python scripts/decisions_validate.py` — expect OK and
|
||||
**0** legacy-unmigrated (or an explicitly-listed, tracked remainder if the safety valve is invoked).
|
||||
@@ -840,30 +992,39 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): migrate decision corpus to
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Retire the append-only line guard + `[decisions-edit]`; wire the validator
|
||||
## 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, or delete + rewire).
|
||||
- Modify: `.husky/commit-msg`, `.husky/pre-commit` (call validator instead of append-only guard).
|
||||
- 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; drop `[decisions-edit]`, aggregate budget).
|
||||
- Modify: `docs/ci-cd.md` (release ritual; re-scope `[decisions-edit]`, aggregate budget).
|
||||
|
||||
- [ ] **Step 1** Replace `.claude/hooks/decisions-guard.sh` with a thin wrapper:
|
||||
- [ ] **Step 1** Replace `.claude/hooks/decisions-guard.sh` with a thin, **fail-open** wrapper (Fable
|
||||
#12ii — python missing must NOT wedge a commit):
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# ersatztv#521 — the 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
|
||||
# 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 `[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 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:
|
||||
|
||||
@@ -874,24 +1035,29 @@ PYTHONPATH=. python3 scripts/decisions_validate.py || exit 1
|
||||
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`.
|
||||
(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 four statuses, the catalog + archive, and that supersession is same-PR. Remove
|
||||
`[decisions-edit]` instructions.
|
||||
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' docs/ .husky/ .gitea/ .claude/` → only historical/archival mentions.
|
||||
`grep -rn 'decisions-edit' .husky/` → nothing (not a hook concept). `grep -rn 'decisions-edit'
|
||||
docs/decisions.md docs/ci-cd.md` → the re-scoped narrow-usage note (expected).
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add .claude/hooks/decisions-guard.sh .husky/ .gitea/workflows/docker-build.yml docs/decisions.md docs/ci-cd.md
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(521): retire append-only line guard + [decisions-edit]; wire lifecycle validator into CI"
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(521): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header rewrite"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -915,16 +1081,34 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): retire append-only line gu
|
||||
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.
|
||||
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`): **MemPalace = candidate discovery only**,
|
||||
constrained to the ErsatzTV/project wing, retrieved at a bounded `k`, and every passage **verified
|
||||
against its cited Markdown/Gitea source** before use; if MemPalace is unavailable or stale, fall back
|
||||
to the `docs/README.md` task map + bounded 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** 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 5** Add the kickoff-guard CI step to the `decisions lifecycle` job (deferred from
|
||||
PR1/Task 7): `- name: Kickoff guard` / `run: bash scripts/check-kickoff-guard.sh`. Run
|
||||
`bash scripts/check-kickoff-guard.sh` locally → **exit 0** now (the language is gone). This wires +
|
||||
satisfies the #520 "cannot regress" box in the same PR that cleans the docs.
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/handoffs/chicorytv-issue-queue.md docs/README.md CLAUDE.md scripts/select-queue.sh
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(520): parallel orientation+selection startup; retire #237 as live state"
|
||||
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]"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -966,11 +1150,12 @@ git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question ba
|
||||
`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.
|
||||
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.
|
||||
@@ -991,6 +1176,20 @@ git -c core.hooksPath=/dev/null commit -m "docs(521): retrieval-eval question ba
|
||||
`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
|
||||
|
||||
Reference in New Issue
Block a user