From f27aed87cd3a295ecdb83429c563dd7bc67c9378 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 6 Aug 2026 10:48:59 +0200 Subject: [PATCH] =?UTF-8?q?gate:=20instructions=5Fgate=20check=206=20?= =?UTF-8?q?=E2=80=94=20the=20auto-memory=20index=20(R-229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/CHANGELOG.md | 28 +++++++ scripts/instructions_gate.py | 127 +++++++++++++++++++++++++++++- scripts/test_instructions_gate.py | 111 ++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index a937643..e75c267 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -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` diff --git a/scripts/instructions_gate.py b/scripts/instructions_gate.py index f0f70a6..02cd747 100644 --- a/scripts/instructions_gate.py +++ b/scripts/instructions_gate.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 (/.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 diff --git a/scripts/test_instructions_gate.py b/scripts/test_instructions_gate.py index e888649..f4bbc2c 100644 --- a/scripts/test_instructions_gate.py +++ b/scripts/test_instructions_gate.py @@ -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 /.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(