gate: instructions_gate check 6 — the auto-memory index (R-229)
gates / gates (push) Successful in 15s

MEMORY.md is the larger half of what loads before a word is typed (8.4k tokens vs the root
CLAUDE.md's 6.6k) and is the one instruction file nobody hand-edits, so nothing was watching it.

Three deliberately different outcomes, each pinned by a test: over-ceiling FAILS (auto-memory
drops content past the limit with no error), an orphan WARNS (the store is outside git), and an
absent store PASSES while PRINTING its reason -- asserted on the reason text, because a pass with
no reason is indistinguishable from a gate that stopped running.

39 assertions (was 20). Red-proof run against the real store, not a fixture.
This commit is contained in:
2026-08-06 10:48:59 +02:00
parent 3a9dd81e18
commit f27aed87cd
3 changed files with 265 additions and 1 deletions
+28
View File
@@ -1,3 +1,31 @@
## instructions_gate.py — check 6: the auto-memory index (2026-08-06, R-229)
**The bigger half of what loads was watched by nothing.** Measured at the workspace root, the
hand-written root `CLAUDE.md` is 6.6k tokens and `MEMORY.md` is 8.4k — and `MEMORY.md` is the one
instruction file nobody hand-edits, because Claude writes it.
Check 6 asserts the index is within its line and byte ceilings and that every top-level topic file is
referenced. **Its three outcomes are deliberately different**, and each has a test proving the
difference is the intended one:
- **Over the ceiling FAILS.** Content past the auto-memory limit is dropped with **no error** — a
silent truncation with no observable at all.
- **An orphan WARNS.** The store lives outside git and changes between sessions; a hard failure
would block pushes for something no commit can fix.
- **An absent store PASSES *and prints why*.** A deliberate exception to check 1's "a missing input
is a FAILURE, never a skip" — the store is machine-local by design, so a clone on any other host
legitimately has none. The reason is printed in the tally, and a test asserts on that text rather
than on `rc == 0`, because **a pass with no reason is indistinguishable from a gate that stopped
running.** That printed reason is the only thing that made the exception safe to grant.
Two load-bearing negatives: a file reachable only via `[[wikilink]]` is **indexed, not orphaned**
(counting it would push someone to add a duplicate row), and `archive/` contents are set aside on
purpose and are never orphans.
Suite: **39 assertions, 0 failures** (was 20). Companion red-proof against the **real** store, not a
fixture — ceiling lowered 200 → 100, the gate went red naming the real file and count
(`.claude-memory/MEMORY.md: 150 lines, ceiling 100`), ceiling restored, gate and suite green again.
## repo_gates.py — `instructions` registered, and the repo that owns the gate now runs it (2026-08-06, R-229)
`instructions_gate.py` **lives in this repo's `scripts/`** and was registered in `controller_gates.py`
+126 -1
View File
@@ -39,6 +39,26 @@ CHECKS (each names the file and the reason; a missing input is a FAILURE, never
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 (<workspace>/.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
@@ -53,6 +73,14 @@ 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)
@@ -189,6 +217,96 @@ def check_workspace_copy(workspace_root, failures, tally):
)
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")
@@ -200,6 +318,7 @@ def main(argv):
return 2
failures = []
warnings = []
for root in roots:
root = os.path.abspath(root)
print("instructions_gate: %s" % root)
@@ -218,10 +337,16 @@ def main(argv):
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))
@@ -230,7 +355,7 @@ def main(argv):
return 1
print("")
print("instructions_gate: OK")
print("instructions_gate: OK%s" % (" (%d warning(s))" % len(warnings) if warnings else ""))
return 0
+111
View File
@@ -184,6 +184,117 @@ def test_diverged_workspace_copy_fails():
check("message names both files", "workspace-CLAUDE.md" in out)
# --- memory-index checks (gate check 6) ---------------------------------------------------------
#
# The three outcomes are deliberately different and each has a test proving it is the one intended:
# over-limit FAILS (silent truncation), an orphan WARNS (the store is outside git), and an absent
# store PASSES *while printing its reason* (machine-local by design). The last one is the dangerous
# grant — an exception to "a missing input is a FAILURE" — so it is asserted on the reason text, not
# just on rc == 0. A pass with no reason is indistinguishable from a gate that stopped running.
def make_memory(tmp, index, topics=None, archived=None):
"""Create <tmp>/.claude-memory. The gate resolves the store from the WORKSPACE root, which for
make_repo(tmp, ...) is tmp itself."""
store = os.path.join(tmp, ".claude-memory")
os.makedirs(store, exist_ok=True)
if index is not None:
with open(os.path.join(store, "MEMORY.md"), "w", encoding="utf-8") as fh:
fh.write(index)
for name, body in (topics or {}).items():
with open(os.path.join(store, name), "w", encoding="utf-8") as fh:
fh.write(body)
if archived:
arc = os.path.join(store, "archive")
os.makedirs(arc, exist_ok=True)
for name, body in archived.items():
with open(os.path.join(arc, name), "w", encoding="utf-8") as fh:
fh.write(body)
return store
def test_memory_absent_passes_and_prints_the_reason():
with tempfile.TemporaryDirectory() as tmp:
root = make_repo(tmp, "# Repo\n") # no .claude-memory at all
rc, out = run_gate(root)
check("absent memory store exits 0", rc == 0, "rc=%d" % rc)
check("absent memory store PRINTS its reason", "machine-local by design" in out)
check("absent memory store is not reported as a skip", "PASS with reason" in out)
def test_memory_within_limits_passes():
with tempfile.TemporaryDirectory() as tmp:
make_memory(tmp, "# Index\n- [a](a.md)\n", topics={"a.md": "# A\n"})
root = make_repo(tmp, "# Repo\n")
rc, out = run_gate(root)
check("memory index within limits exits 0", rc == 0, "rc=%d" % rc)
check("tally reports indexed/orphaned/archived", "1 indexed, 0 orphaned" in out)
def test_memory_over_line_limit_fails_and_names_file_and_count():
with tempfile.TemporaryDirectory() as tmp:
make_memory(tmp, "# Index\n" + "- entry\n" * 250)
root = make_repo(tmp, "# Repo\n")
rc, out = run_gate(root)
check("over-length memory index exits non-zero", rc != 0, "rc=%d" % rc)
check("message names MEMORY.md", "MEMORY.md" in out)
check("message names the line count", "251 lines" in out)
check("message says silent truncation, not space", "DROPPED WITH NO" in out)
def test_memory_over_byte_limit_fails():
"""Few lines, many bytes — the byte ceiling must bite independently of the line ceiling."""
with tempfile.TemporaryDirectory() as tmp:
make_memory(tmp, "# Index\n" + ("x" * 30000) + "\n")
root = make_repo(tmp, "# Repo\n")
rc, out = run_gate(root)
check("over-byte memory index exits non-zero", rc != 0, "rc=%d" % rc)
check("byte failure names the byte ceiling", "25600" in out)
def test_memory_orphan_warns_but_does_not_fail():
with tempfile.TemporaryDirectory() as tmp:
make_memory(tmp, "# Index\n- [a](a.md)\n",
topics={"a.md": "# A\n", "lonely.md": "# Lonely\n"})
root = make_repo(tmp, "# Repo\n")
rc, out = run_gate(root)
check("an orphan does NOT fail the gate", rc == 0, "rc=%d" % rc)
check("an orphan produces a WARNING", "WARNING" in out)
check("the warning names the orphan", "lonely.md" in out)
def test_memory_wikilink_counts_as_a_reference():
"""Load-bearing negative: a file reachable only via [[wikilink]] is indexed, not orphaned.
Calling it an orphan would push someone to add a duplicate row for it."""
with tempfile.TemporaryDirectory() as tmp:
make_memory(tmp, "# Index\n- [a](a.md) — see [[deep]]\n",
topics={"a.md": "# A\n", "deep.md": "# Deep\n"})
root = make_repo(tmp, "# Repo\n")
rc, out = run_gate(root)
check("wikilink-only file is not an orphan", rc == 0 and "WARNING" not in out, "rc=%d" % rc)
def test_memory_archived_file_is_not_an_orphan():
"""archive/ is where deliberately set-aside records live; they are not top-level and must not
be counted as unreferenced."""
with tempfile.TemporaryDirectory() as tmp:
make_memory(tmp, "# Index\n- [a](a.md)\n", topics={"a.md": "# A\n"},
archived={"old-campaign.md": "# Old\n"})
root = make_repo(tmp, "# Repo\n")
rc, out = run_gate(root)
check("archived file is not an orphan", rc == 0 and "WARNING" not in out, "rc=%d" % rc)
check("tally counts the archived file", "1 archived" in out)
def test_memory_store_without_index_fails():
with tempfile.TemporaryDirectory() as tmp:
make_memory(tmp, None, topics={"a.md": "# A\n"}) # store exists, no MEMORY.md
root = make_repo(tmp, "# Repo\n")
rc, out = run_gate(root)
check("store with no index exits non-zero", rc != 0, "rc=%d" % rc)
check("message says unreachable knowledge", "unreachable knowledge" in out)
def main():
print("test_instructions_gate")
for fn in sorted(