# -*- coding: utf-8 -*- """Instruction-file consistency gate — keeps CLAUDE.md files from silently regrowing. Usage: python3 scripts/instructions_gate.py [ ...] python3 scripts/instructions_gate.py --fast (identical: no network, no container runtime) THIS IS A CONSISTENCY GATE, NOT A BUDGET GATE. Measured 2026-08-06 on a 1M-token window: the instruction files occupied 15k tokens against 869k free. Space is NOT the constraint here and no failure message may claim it is. The line ceiling exists because longer instruction files reduce ADHERENCE — Anthropic's guidance is to keep a CLAUDE.md under 200 lines — and because a file nobody can hold in their head is where contradictions hide. Four were found in this project on 2026-08-06, two of which decided where a destructive drill runs. WHAT IS COUNTED. Every check runs against EFFECTIVE text: block-level HTML comments are stripped first, because they are stripped before injection and never reach the model. Verified empirically on Claude Code 2.1.222 with a control (two plain markers -> both seen) and a treatment (one marker inside , twice -> not seen). That is the whole point of the comment convention: earned rationale stays in the repo for human readers at zero cost to the instructions. CHECKS (each names the file and the reason; a missing input is a FAILURE, never a skip): 1. /CLAUDE.md is at most MAX_LINES effective lines. 2. Every /.claude/rules/*.md declares `paths:` in frontmatter, or `unconditional: true`. NOTE: `paths:` is a Claude Code feature — a rule carrying it loads only when a file matching one of its globs is read. `unconditional: true` is NOT a product feature; it is OUR marker, asserting that always-loading was deliberate. The product loads a rule with no `paths:` key unconditionally either way, so this check catches the ACCIDENT, not the product behaviour. 3. No component version literal in effective text. Versions change several times a day and the fleet is not uniform, so a version in an instruction file is stale within a day (ask the hub's /hosts + /configs, or the box). Historical citations belong in an HTML comment beside the rule they justify, where they inform a human and cannot go stale in the model's view. IPv4 addresses are NOT versions — a bare \\d+\\.\\d+\\.\\d+ matches the first three octets of every one of them, which is how this check would otherwise fail on its own repo. 4. No TEMPORARY block carrying a date already past. The block this gate was written for said "TEMPORARY - until ~2026-08-02 ... Delete this block on return" and was still being read as current fact on 2026-08-06, while a sibling repo's CLAUDE.md asserted the opposite. 5. The workspace-root CLAUDE.md and its versioned copy at felhom.eu/documentation/runbooks/workspace-CLAUDE.md are byte-identical. The live file sits in a directory that is not a git repo, so the copy is the only version-controlled record of it; nothing but this check enforces that they agree. 6. The auto-memory index (/.claude-memory/MEMORY.md) is within its line and byte limits, and every top-level topic file is referenced by it. WHY 6 EXISTS, AND WHY ITS THREE OUTCOMES DIFFER (2026-08-06). MEMORY.md is the LARGER half of what loads before a word is typed: measured at the workspace root, the hand-written root CLAUDE.md was 6.6k tokens and MEMORY.md 8.4k. It is also the one instruction file nobody hand-edits — Claude writes it — so nothing was watching it. - OVER THE LIMIT IS A FAILURE. Content past the auto-memory limit is dropped with NO error. A silent truncation of the index is the failure mode with no observable at all, which is exactly the class this project keeps getting bitten by. - AN ORPHAN IS A WARNING, NOT A FAILURE. The store changes between sessions and lives outside git; a hard fail would block pushes for something no commit can fix. On 2026-08-06 the index referenced 113 files while 157 existed — 44 held knowledge nothing would ever read. - AN ABSENT STORE PASSES, AND SAYS SO OUT LOUD. This is a DELIBERATE exception to check 1's "a missing input is a FAILURE, never a skip": the store is machine-local by design and a clone on any other host legitimately has none. The reason is printed in the tally so an absent store can never be mistaken for a silent skip — which is the only thing that made the exception safe to grant. THE POSITIVE OBSERVABLE. Every root prints a per-check tally with the measured numbers, not just a verdict. "0 failures" alone cannot tell a working gate from a blind one — if the effective line count suddenly reads 3, the stripper broke, and the count is where you see it. The kill condition is pinned by scripts/test_instructions_gate.py: an over-length CLAUDE.md still FAILS, and a rule file with neither marker still FAILS. """ import os import re import sys MAX_LINES = 200 # The auto-memory index. Limits are the store's own, not this project's taste: content past them is # dropped with no error. MEMORY_DIRNAME = ".claude-memory" MEMORY_INDEX = "MEMORY.md" MEMORY_ARCHIVE = "archive" MAX_MEMORY_LINES = 200 MAX_MEMORY_BYTES = 25 * 1024 # Block-level HTML comments: stripped before injection, so they cost nothing and are not counted. COMMENT_RE = re.compile(r"", re.S) # A semver-ish literal that is NOT part of a dotted quad (IPv4) and not part of a longer run. VERSION_RE = re.compile(r"(? MAX_LINES: failures.append( "%s: %d effective lines, ceiling %d. This is an ADHERENCE limit, not a space " "limit — long instruction files get followed less reliably and hide " "contradictions. Move path-bound guidance into .claude/rules/*.md with a `paths:` " "list, procedures into the skill that already covers them, and earned rationale " "into an HTML comment (free: stripped before injection)." % (path, n, MAX_LINES) ) def check_rules(root, failures, tally): rules_dir = os.path.join(root, ".claude", "rules") if not os.path.isdir(rules_dir): tally.append(" rule files : none (no .claude/rules/)") return names = sorted(n for n in os.listdir(rules_dir) if n.endswith(".md")) scoped = 0 for name in names: path = os.path.join(rules_dir, name) with io_open(path) as fh: head = fh.read(2048) has_paths = re.search(r"^paths:", head, re.M) is not None has_uncond = re.search(r"^unconditional:\s*true\s*$", head, re.M) is not None if has_paths: scoped += 1 elif not has_uncond: failures.append( "%s: rule file has neither a `paths:` frontmatter list nor an explicit " "`unconditional: true`. Without `paths:` Claude Code loads it in EVERY session, " "which is rarely what a rule file is for — add the globs it applies to, or " "declare `unconditional: true` to say the always-loading is deliberate." % path ) tally.append( " rule files : %d (%d path-scoped)" % (len(names), scoped) ) def check_versions(path, failures, tally): with io_open(path) as fh: eff = effective(fh.read()) hits = [] for i, line in enumerate(eff.split("\n"), 1): for m in VERSION_RE.finditer(line): hits.append((i, m.group(), line.strip()[:90])) tally.append(" version literals : %d" % len(hits)) for lineno, ver, ctx in hits: failures.append( "%s:%d: component version literal %r — versions change several times a day and the " "fleet is not uniform, so this is stale within a day. Ask the hub (/hosts, /configs) " "or the box. A historical citation belongs in an HTML comment beside the rule it " "justifies.\n %s" % (path, lineno, ver, ctx) ) def check_temporary(path, failures, tally): with io_open(path) as fh: eff = effective(fh.read()) lines = eff.split("\n") today = today_tuple() found = 0 for i, line in enumerate(lines, 1): if not TEMPORARY_RE.search(line): continue found += 1 window = "\n".join(lines[i - 1 : i + 6]) for m in ISO_DATE_RE.finditer(window): when = tuple(int(g) for g in m.groups()) if when < today: failures.append( "%s:%d: TEMPORARY block carrying the past date %s. A temporary block that " "outlives its own deadline is read as current fact — this gate exists " "because one did, for four days, while a sibling repo asserted the " "opposite. Delete it; the audit trail is the record." % (path, i, m.group()) ) break tally.append(" TEMPORARY blocks : %d" % found) def check_workspace_copy(workspace_root, failures, tally): live = os.path.join(workspace_root, "CLAUDE.md") copy = os.path.join(workspace_root, WORKSPACE_COPY) if not os.path.exists(live) or not os.path.exists(copy): tally.append(" workspace copy : n/a (not this workspace)") return with io_open(live) as a, io_open(copy) as b: same = a.read() == b.read() tally.append(" workspace copy identical : %s" % ("yes" if same else "NO")) if not same: failures.append( "%s and %s have diverged. The live workspace file sits in a directory that is not a " "git repo, so the copy is its only version-controlled record — nothing but this " "check enforces that they agree. Copy the live file over the versioned one." % (live, copy) ) def memory_referenced(index_text): """Basenames the index points at, via [](x.md) links and [[wikilink]]s alike. Both forms count as a reference. A file reachable only by a wikilink is indexed — it just is not a listed entry — and calling it an orphan would push someone to add a duplicate row for it. """ md = re.findall(r"\]\(([^)]+\.md)\)", index_text) wiki = re.findall(r"\[\[([^\]]+)\]\]", index_text) names = set(os.path.basename(x) for x in md) for w in wiki: names.add(os.path.basename(w if w.endswith(".md") else w + ".md")) return names def check_memory(workspace_root, failures, warnings, tally): store = os.path.join(workspace_root, MEMORY_DIRNAME) if not os.path.isdir(store): # DELIBERATE exception to "a missing input is a FAILURE" — see the module docstring. The # reason is printed so this can never read as a silent skip. tally.append( " memory index : absent (%s/ not on this host — the auto-memory store is " "machine-local by design, so a clone elsewhere legitimately has none; PASS with reason)" % MEMORY_DIRNAME ) return index = os.path.join(store, MEMORY_INDEX) if not os.path.exists(index): failures.append( "%s: the memory store exists but has no %s. An un-indexed store is unreachable " "knowledge — every topic file is read on demand, and the index is the only thing that " "names them." % (store, MEMORY_INDEX) ) tally.append(" memory index : MISSING") return with io_open(index) as fh: text = fh.read() nlines = text.count("\n") nbytes = len(text.encode("utf-8")) tally.append( " memory index : %d lines (ceiling %d), %d bytes (ceiling %d)" % (nlines, MAX_MEMORY_LINES, nbytes, MAX_MEMORY_BYTES) ) if nlines > MAX_MEMORY_LINES: failures.append( "%s: %d lines, ceiling %d. Content past the auto-memory limit is DROPPED WITH NO " "ERROR — a truncated index is a silent failure with no observable. Move detail out of " "the index into the topic files it points at; the index is what loads, the topic files " "are read on demand." % (index, nlines, MAX_MEMORY_LINES) ) if nbytes > MAX_MEMORY_BYTES: failures.append( "%s: %d bytes, ceiling %d. Same reason as the line ceiling — silent truncation. Move " "detail into topic files rather than dropping entries." % (index, nbytes, MAX_MEMORY_BYTES) ) referenced = memory_referenced(text) on_disk = set( n for n in os.listdir(store) if n.endswith(".md") and n != MEMORY_INDEX and os.path.isfile(os.path.join(store, n)) ) orphans = sorted(on_disk - referenced) archived = 0 arc = os.path.join(store, MEMORY_ARCHIVE) if os.path.isdir(arc): archived = len([n for n in os.listdir(arc) if n.endswith(".md")]) tally.append( " memory topic files : %d indexed, %d orphaned, %d archived" % (len(on_disk) - len(orphans), len(orphans), archived) ) if orphans: # WARN, never FAIL: the store is outside git and changes between sessions, so a failure # here would block pushes for something no commit can fix. warnings.append( "%s: %d top-level topic file(s) are not referenced by %s, so nothing will ever read " "them: %s%s" % ( store, len(orphans), MEMORY_INDEX, ", ".join(orphans[:8]), (" (+%d more)" % (len(orphans) - 8)) if len(orphans) > 8 else "", ) ) def io_open(path): return open(path, "r", encoding="utf-8") def main(argv): roots = [a for a in argv[1:] if a != "--fast"] if not roots: sys.stderr.write("usage: instructions_gate.py [--fast] ...\n") return 2 failures = [] warnings = [] for root in roots: root = os.path.abspath(root) print("instructions_gate: %s" % root) tally = [] claude_md = os.path.join(root, "CLAUDE.md") if not os.path.exists(claude_md): failures.append( "%s: no CLAUDE.md. A missing input is a FAILURE, never a skip — a gate that " "silently passes on an absent file is how the check stops running." % root ) else: check_length(claude_md, failures, tally) check_versions(claude_md, failures, tally) check_temporary(claude_md, failures, tally) check_rules(root, failures, tally) check_workspace_copy(os.path.dirname(root), failures, tally) check_memory(os.path.dirname(root), failures, warnings, tally) for line in tally: print(line) if warnings: print("") for w in warnings: print("WARNING: %s" % w) if failures: print("") print("instructions_gate: %d FAILURE(S)" % len(failures)) for f in failures: print(" - %s" % f) return 1 print("") print("instructions_gate: OK%s" % (" (%d warning(s))" % len(warnings) if warnings else "")) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))