gate: instructions_gate check 6 — the auto-memory index (R-229)
gates / gates (push) Successful in 15s
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:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user