From cce9b6f0c2a46ecde288ceb9e0253c7b132eda31 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Thu, 29 Oct 2026 01:17:38 +0100 Subject: [PATCH 1/3] Warn when an edited file is named in memories not read this turn - kb-gate scans memories for the edited file's project-relative path and names unread ones as context; it never blocks the edit - get/multi_get are recorded synchronously so a memory read just before an edit counts - Tests, contract fixture and docs cover the new warning --- CLAUDE.md | 4 +- README.md | 2 + hooks/kb-gate.mts | 81 +++++++++++++++++++++++++------ techpack.yaml | 19 ++++---- tests/contracts.test.mts | 6 ++- tests/fixtures/hook-messages.json | 6 +++ tests/helpers.mts | 4 +- tests/kb-gate.test.mts | 70 ++++++++++++++++++++++++++ 8 files changed, 164 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 34911b6..08bebd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,9 @@ Two harness details are load-bearing rather than incidental: **Memory writes are gated by skill, per turn.** A file-tool write into `.claude/memories/` is denied unless a memory skill authorizes it. A Bash `rm`, `unlink`, `mv` or `git rm` whose command mentions `memories` is denied unless memory-audit ran, because only the audit deletes, and only after per-batch approval. That check exists for auto mode, where no permission prompt would stop the command. It matches the whole command rather than parsing arguments, since variables, globs and `cd` make parsing easy to evade. The scope is the turn for capture because a session that loaded the skill turns earlier edits from a stale copy and skips its checks. A typed skill command produces no `Skill` tool call, only prompt text, which is why the `UserPromptSubmit` branch scans the prompt as well. This gate ignores `KB_GATE_MODE` except `off`, so its own per-turn denial budget is what keeps a broken `Skill` record from blocking writes forever. -**Both hooks fail open.** Unexpected filesystem or payload errors exit 0 without output. The gate never exits 2, never calls qmd, and logs every evaluation except when disabled or when an uninteresting sub-agent starts. Preserve the append-only query and denial files, monotonic turn counter, and separate per-state/per-turn denial budgets. Gate state is compatible with previous shell installs. +**Edits are checked against the memories that name the file.** A Write, Edit or NotebookEdit outside `memories/` scans every memory for the file's project-relative path as a plain substring, with no qmd call, since that would put a model on every edit. Hits not retrieved this turn produce a warning, never a denial, in both `warn` and `enforce`; `observe` only logs. A path is flagged once per turn, and edits outside the project root are skipped. Retrievals are recorded per turn in `.kb-gate/.retrieved`, which is why `get` and `multi_get` sit on the synchronous PostToolUse registration: an edit issued right after one must already see it as read. `Read` stays async, so a memory opened that way can still be warned about once. + +**Both hooks fail open.** Unexpected filesystem or payload errors exit 0 without output. The gate never exits 2, never calls qmd, and logs every evaluation except when disabled, when an uninteresting sub-agent starts, or when an edited file is named in no memory. Preserve the append-only query and denial files, monotonic turn counter, and separate per-state/per-turn denial budgets. Gate state is compatible with previous shell installs. **Project-root derivation must agree everywhere.** `hooks/shared.mts` resolves git toplevel → `CLAUDE_PROJECT_DIR` → `$PWD` for the hooks. The small shell launchers and doctor checks in `techpack.yaml` retain the same ladder. Keep those shell copies identical to one another and behaviorally equivalent to the TypeScript helper. All readers and writers use `.claude/.kb-index/`. Tests exercise git subdirectories, non-git fallbacks, and paths needing YAML escaping. diff --git a/README.md b/README.md index 67ba6a2..acc5d6e 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ During installation, the pack asks how strictly it should enforce searching the Writes to `.claude/memories/` are gated separately, and in every mode except `off` they are denied unless a memory skill is active. That means `continuous-learning` in the same turn, or `memory-audit` earlier in the session. Deleting or moving a memory from the shell is allowed only under `memory-audit`. This keeps every save behind the skill's search and pre-write checks. +Edits to other files are checked against the memories that mention them. When a memory names the edited file's project-relative path and hasn't been read this turn, Claude is told which memories to read before relying on the edit. This happens in `warn` and `enforce` alike, and never blocks the edit. `observe` only logs it. + To change the mode, run `mcs sync` again. The selection is baked into the installed hook rather than read from a runtime setting. ## What's included diff --git a/hooks/kb-gate.mts b/hooks/kb-gate.mts index 8c45a1d..9f04b31 100755 --- a/hooks/kb-gate.mts +++ b/hooks/kb-gate.mts @@ -11,7 +11,7 @@ import { statSync, writeFileSync, } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { attempt, isDirectory, projectRoot, read } from "./shared.mts"; const MODE: string = "__KB_GATE_MODE__"; @@ -23,8 +23,9 @@ const STATE_MAX_AGE_DAYS = 7; const KB_MARKER = "KB context:"; const GATED_AGENTS = ["Explore", "general-purpose", "Plan"]; const MEMORY_SKILL_SCOPES: Record = { "continuous-learning": "turn", "memory-audit": "session" }; -const MEMORY_WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"]; +const FILE_WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"]; const MAX_MEMORY_WRITE_DENIALS_PER_TURN = 3; +const MAX_LINKED_TITLES = 3; const MEMORY_DELETE = /(?:^|[^\w-])(?:rm|unlink|mv)(?=\s)/; const SLASH_SKILL = new RegExp(`(?:^|[\\s>])/(?:[\\w-]+:)?(${Object.keys(MEMORY_SKILL_SCOPES).join("|")})\\b`, "g"); const RETRIEVAL_TOOLS = ["mcp__memory-loop__get", "mcp__memory-loop__multi_get", "Read"]; @@ -130,9 +131,9 @@ function gate(): void { const toolSkill = isSkillCall ? memorySkill(payload.tool_input?.skill) : undefined; if (isSkillCall && !toolSkill) return; const isRetrieval = event === "PostToolUse" && RETRIEVAL_TOOLS.includes(payload.tool_name); - const isMemoryWrite = event === "PreToolUse" && MEMORY_WRITE_TOOLS.includes(payload.tool_name); + const isFileWrite = event === "PreToolUse" && FILE_WRITE_TOOLS.includes(payload.tool_name); const target = payload.tool_input?.file_path || payload.tool_input?.notebook_path || ""; - if (target && !mentionsMemories(target)) return; + if (target && !isFileWrite && !mentionsMemories(target)) return; const command = String(payload.tool_input?.command ?? ""); const isMemoryDelete = event === "PreToolUse" && payload.tool_name === "Bash" && command.includes("memories") && MEMORY_DELETE.test(command); @@ -147,6 +148,8 @@ function gate(): void { const queriesFile = `${state}/${session}.queries`; const denialsFile = `${state}/${session}.denials`; const skillsFile = `${state}/${session}.skills`; + const retrievedFile = `${state}/${session}.retrieved`; + const flaggedFile = `${state}/${session}.flagged`; const turnText = (attempt(() => read(turnFile)) ?? "").split("\n")[0]?.trim() ?? ""; const turn = /^\d+$/.test(turnText) ? Number(turnText) : 0; @@ -163,8 +166,12 @@ function gate(): void { process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: event, ...fields } })}\n`); } + function thisTurn(file: string): string[][] | undefined { + return rows(file)?.filter(([at]) => Number(at) === turn); + } + function denials(phase: string): string[][] | undefined { - return rows(denialsFile)?.filter(fields => Number(fields[0]) === turn && fields[1] === phase); + return thisTurn(denialsFile)?.filter(fields => fields[1] === phase); } function append(file: string, fields: (string | number)[]): void { @@ -192,14 +199,21 @@ function gate(): void { )?.[1]; } - function memoryName(path: string): string | undefined { + function under(base: string, path: string): string | undefined { const lexical = resolve(root, path); - if (lexical.startsWith(`${memories}/`)) return lexical.slice(memories.length + 1); - const real = realPath(lexical); - const memoriesReal = attempt(() => realpathSync(memories)) ?? memories; - return real.startsWith(`${memoriesReal}/`) ? real.slice(memoriesReal.length + 1) : undefined; + const baseReal = attempt(() => realpathSync(base)) ?? base; + // Either spelling may be the one under the base: a new file has no real path yet, a symlinked base no lexical one. + return [relative(base, lexical), relative(baseReal, realPath(lexical))].find( + candidate => candidate && !/^\.\.(?:\/|$)/.test(candidate) && !isAbsolute(candidate), + ); } + function memoryName(path: string): string | undefined { + return under(memories, path); + } + + const isMemoryWrite = isFileWrite && memoryName(target) !== undefined; + if (event === "UserPromptSubmit") { mkdirSync(state, { recursive: true }); if (turn === 0) attempt(() => sweep(state)); @@ -233,6 +247,8 @@ function gate(): void { const names = target ? [memoryName(target)] : [...stdin.matchAll(RETRIEVED_RESOURCE)].map(match => match[1]); const retrieved = [...new Set(names)].filter((name): name is string => !!name && isMemory(memories, name)); if (retrieved.length === 0) return; + // Recorded before the skill check: a memory read while capturing still counts as read before an edit. + for (const name of retrieved) append(retrievedFile, [turn, name]); const skill = activeMemorySkill(true); if (skill) { logEvent({ tool: payload.tool_name, decision: "skip", skip_reason: "memory_skill", skill }); @@ -282,13 +298,48 @@ mcp__memory-loop__query. return; } + if (isFileWrite && !isMemoryWrite) { + if (!isDirectory(memories)) return; + const path = under(root, target); + if (!path) return; + const flagged = thisTurn(flaggedFile); + if (!flagged || flagged.some(([, seen]) => seen === path)) return; + const hits = readdirSync(memories) + .filter(name => isMemory(memories, name)) + .map(name => ({ name, text: attempt(() => read(`${memories}/${name}`)) ?? "" })) + .filter(({ text }) => text.includes(path)); + if (hits.length === 0) return; + const readThisTurn = new Set(thisTurn(retrievedFile)?.map(([, name]) => name)); + const unread = hits.filter(({ name }) => !readThisTurn.has(name)); + function logLinked(decision: string, extra = {}): void { + logEvent({ phase: "linked_edit", tool: payload.tool_name, target: path, agent_type: agentType, decision, ...extra }); + } + if (unread.length === 0) { + logLinked("allow", { skip_reason: "linked_read", files: hits.map(({ name }) => name) }); + return; + } + append(flaggedFile, [turn, path]); + logLinked(MODE === "observe" ? "observe" : "warn", { files: unread.map(({ name }) => name) }); + if (MODE === "observe") return; + const ranked = unread + .map(({ name, text }) => { + const title = /^# (.+)$/m.exec(text)?.[1]?.trim() || name; + const appliesTo = /^\*\*Applies to:\*\*.*$/m.exec(text)?.[0] ?? ""; + return { name, title, named: title.includes(path) || appliesTo.includes(path) }; + }) + // A memory naming the file in its title or Applies to: is most likely about that file. + .sort((a, b) => Number(b.named) - Number(a.named) || a.name.localeCompare(b.name)); + const listed = ranked.slice(0, MAX_LINKED_TITLES).map(({ name, title }) => `"${title}" (${name})`); + const more = ranked.length - listed.length; + output({ + additionalContext: `Memories not read this turn mention ${path}: ${listed.join(", ")}${more ? `, and ${more} more` : ""}. This edit was applied without them; retrieve them with mcp__memory-loop__get and check the edit against them${more ? `; search the KB for "${path}" to see the rest` : ""}.`, + }); + return; + } + if (isMemoryWrite || isMemoryDelete) { const phase = isMemoryWrite ? "memory_write" : "memory_delete"; - let subject = command.slice(0, 200); - if (isMemoryWrite) { - subject = realPath(resolve(root, target)); - if (memoryName(target) === undefined) return; - } + const subject = isMemoryWrite ? realPath(resolve(root, target)) : command.slice(0, 200); function logMemory(decision: string, extra = {}): void { logEvent({ phase, tool: payload.tool_name, target: subject, agent_type: agentType, decision, ...extra }); } diff --git a/techpack.yaml b/techpack.yaml index 8bdd812..da3d697 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -280,11 +280,12 @@ components: event: UserPromptSubmit - id: hook-kb-gate-record - displayName: KB gate — record searches and memory skills - description: Records each KB search and memory-skill invocation so delegated discovery and memory writes can be checked against them + displayName: KB gate — record searches, memory skills and retrievals + description: Records each KB search, memory-skill invocation and memory retrieval so delegated discovery, memory writes and file edits can be checked against them hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: PostToolUse - hookMatcher: "mcp__memory-loop__query|Skill" + # Retrievals are synchronous here so an edit made right after one already sees it as read. + hookMatcher: "mcp__memory-loop__query|mcp__memory-loop__get|mcp__memory-loop__multi_get|Skill" hook: source: hooks/kb-gate.mts destination: kb-gate.mts @@ -297,8 +298,8 @@ components: event: PostToolUse - id: hook-kb-gate-check - displayName: KB gate — check sub-agent spawns and memory writes - description: Checks sub-agent spawns for KB findings and memory changes for an active memory skill + displayName: KB gate — check sub-agent spawns, memory writes and file edits + description: Checks sub-agent spawns for KB findings, memory changes for an active memory skill, and file edits for memories that mention the file hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: PreToolUse # The spawn tool is `Agent` in current Claude Code and `Task` in older @@ -314,12 +315,12 @@ components: event: PreToolUse - id: hook-kb-gate-retrieval - displayName: KB gate — record memory retrievals - description: Records the day each memory was last retrieved in .claude/memories/.usage.tsv, so a team can see which memories are still read + displayName: KB gate — record memory reads + description: Records memories opened with Read, for .claude/memories/.usage.tsv and for the file-edit check hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: PostToolUse - hookMatcher: "mcp__memory-loop__get|mcp__memory-loop__multi_get|Read" - # Every Read starts this hook, and it never needs to block, so it stays off the turn's critical path. + hookMatcher: "Read" + # Every Read starts this hook, so it stays off the turn's critical path. hookAsync: true hook: source: hooks/kb-gate.mts diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index bf92038..ca32757 100644 --- a/tests/contracts.test.mts +++ b/tests/contracts.test.mts @@ -20,6 +20,8 @@ test("hook messages match the original shell output byte for byte", t => { f.turn(); assert.equal(f.spawn(), `${JSON.stringify(messages[mode])}\n`); assert.equal(f.event("SubagentStart", { agent_type: "Explore" }), `${JSON.stringify(messages.brief)}\n`); + f.memory("learning_app.md", "# Learning: app\n\nNever reorder `src/app.ts`.\n"); + assert.equal(f.write({ file_path: `${f.project}/src/app.ts`, tool_name: "Edit" }), `${JSON.stringify(messages.linked)}\n`); } const f = fixture(t); assert.equal(f.run(`${root}/hooks/memory-loop-activator.sh`), messages.activator); @@ -60,8 +62,8 @@ for (const type of ["commonjs", "module"]) { if (event === "SubagentStart") assert.equal(JSON.parse(output).hookSpecificOutput.hookEventName, event); } assert.match(manifest, /hookMatcher: "Agent\|Task\|Write\|Edit\|NotebookEdit\|Bash"/); - assert.match(manifest, /hookMatcher: "mcp__memory-loop__query\|Skill"/); - assert.match(manifest, /hookMatcher: "mcp__memory-loop__get\|mcp__memory-loop__multi_get\|Read"/); + assert.match(manifest, /hookMatcher: "mcp__memory-loop__query\|mcp__memory-loop__get\|mcp__memory-loop__multi_get\|Skill"/); + assert.match(manifest, /hookMatcher: "Read"/); assert.equal((manifest.match(/hookAsync: true/g) ?? []).length, 3); assert.equal((manifest.match(/hookTimeout: 120/g) ?? []).length, 2); }); diff --git a/tests/fixtures/hook-messages.json b/tests/fixtures/hook-messages.json index 2b57c5a..435661f 100644 --- a/tests/fixtures/hook-messages.json +++ b/tests/fixtures/hook-messages.json @@ -18,5 +18,11 @@ "additionalContext": "This project keeps a knowledge base of past learnings, decisions, and\ndebugging discoveries in .claude/memories/, searchable with\nmcp__memory-loop__query.\n\n- If your prompt contains a \"KB context:\" block, treat it as established\n ground truth. Verify it against the code, but do NOT search the KB and\n do NOT re-derive it.\n- Otherwise, if you are about to read or grep more than a couple of files,\n issue ONE mcp__memory-loop__query for the topic of your task first — a\n lex line of terms you expect verbatim plus a vec line phrased as a\n question, and an intent saying what you want and what to avoid, with\n rerank:false and limit:6. One search is far cheaper than a blind\n file sweep. Unlike the main thread, do not try keyword variations: if\n nothing relevant comes back, move on to the code.\n- Snippets are leads, not evidence — they are capped at 300 characters and\n often show only a title. mcp__memory-loop__get a document before relying\n on what it says.\n- Report back anything the KB got wrong or left out." } }, + "linked": { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": "Memories not read this turn mention src/app.ts: \"Learning: app\" (learning_app.md). This edit was applied without them; retrieve them with mcp__memory-loop__get and check the edit against them." + } + }, "activator": "Memory: search the KB before a new phase, and on an unexpected error\nbefore diagnosing it from scratch. Before your final reply, run\nSkill(continuous-learning) once if this turn found a non-obvious cause,\nsettled a project decision the user made or agreed to, or showed a\nmemory is wrong or outdated. Never write .claude/memories/ directly.\n" } diff --git a/tests/helpers.mts b/tests/helpers.mts index 6c3e8eb..86e4ebb 100644 --- a/tests/helpers.mts +++ b/tests/helpers.mts @@ -74,8 +74,9 @@ export function gateFixture(t: TestContext, mode = "enforce") { const retrieve = (names: string[], tool_name?: string) => event("PostToolUse", retrieval(names, tool_name)); const retrieveConcurrently = (names: string[]) => f.runAsync(script, payload("PostToolUse", retrieval(names))); const readMemory = (file_path: string) => event("PostToolUse", { tool_name: "Read", tool_input: { file_path }, tool_response: {} }); + const memory = (name: string, text: string) => writeFileSync(`${f.project}/.claude/memories/${name}`, text); const memories = (...names: string[]) => { - for (const name of names) writeFileSync(`${f.project}/.claude/memories/${name}`, "# x\n"); + for (const name of names) memory(name, "# x\n"); }; const usagePath = `${f.project}/.claude/memories/.usage.tsv`; const usage = () => readFileSync(usagePath, "utf8"); @@ -100,6 +101,7 @@ export function gateFixture(t: TestContext, mode = "enforce") { retrieveConcurrently, readMemory, memories, + memory, usagePath, usage, lastLog, diff --git a/tests/kb-gate.test.mts b/tests/kb-gate.test.mts index 76d0ea4..62d7388 100644 --- a/tests/kb-gate.test.mts +++ b/tests/kb-gate.test.mts @@ -392,3 +392,73 @@ test("concurrent retrievals record every memory", async t => { names.map(name => `${name}\t${today()}`), ); }); + +const appRule = "# Learning: app\n\nNever reorder `src/app.ts`.\n"; +const linked = (output: string): string => JSON.parse(output).hookSpecificOutput.additionalContext; + +for (const mode of ["warn", "enforce"]) { + test(`${mode} warns once per path per turn about unread memories that mention an edited file`, t => { + const f = gateFixture(t, mode); + f.memory("learning_app.md", appRule); + f.turn(); + const edit = () => f.write({ file_path: `${f.project}/src/app.ts`, tool_name: "Edit" }); + assert.match(linked(edit()), /mention src\/app\.ts: "Learning: app" \(learning_app\.md\)\./); + assert.deepEqual(f.lastLog().files, ["learning_app.md"]); + assert.equal(edit(), ""); + f.turn(); + assert.match(linked(edit()), /learning_app\.md/); + }); +} + +test("a memory retrieved this turn by get or Read no longer warns, even during capture", t => { + const f = gateFixture(t); + f.memory("learning_app.md", appRule); + f.memory("decision_app.md", "# Decision: app\n\nSee src/app.ts.\n"); + f.turn(); + f.skill(); + f.retrieve(["learning_app.md"]); + f.readMemory(`${f.project}/.claude/memories/decision_app.md`); + assert.equal(f.write({ file_path: `${f.project}/src/app.ts` }), ""); + assert.equal(f.lastLog().skip_reason, "linked_read"); + f.turn(); + assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts` })), /decision_app\.md.*learning_app\.md/); +}); + +test("a warning lists three memories, those naming the file in their title or Applies to: first", t => { + const f = gateFixture(t); + for (const name of ["a", "b", "c"]) f.memory(`learning_${name}.md`, "# Learning\n\nTouches `src/app.ts`.\n"); + f.memory("learning_z.md", "# Learning: z\n\n**Applies to:** src/app.ts\n"); + f.memory("learning_y.md", "# Learning: src/app.ts order\n"); + f.turn(); + assert.equal( + linked(f.write({ file_path: `${f.project}/src/app.ts` })), + 'Memories not read this turn mention src/app.ts: "Learning: src/app.ts order" (learning_y.md), "Learning: z" (learning_z.md), "Learning" (learning_a.md), and 2 more. This edit was applied without them; retrieve them with mcp__memory-loop__get and check the edit against them; search the KB for "src/app.ts" to see the rest.', + ); +}); + +test("sub-agent edits are warned too", t => { + const f = gateFixture(t); + f.memory("learning_app.md", appRule); + f.turn(); + assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts`, agent_id: "agent-1" })), /learning_app\.md/); +}); + +test("observe only logs linked edits; unmatched and outside-root edits leave no trace", t => { + const f = gateFixture(t, "observe"); + f.memory("learning_app.md", appRule); + f.turn(); + assert.equal(f.write({ file_path: `${f.project}/src/app.ts` }), ""); + assert.equal(f.lastLog().decision, "observe"); + const before = readFileSync(f.log, "utf8"); + assert.equal(f.write({ file_path: `${f.project}/src/other.ts` }), ""); + assert.equal(f.write({ file_path: `${f.work}/src/app.ts` }), ""); + assert.equal(readFileSync(f.log, "utf8"), before); +}); + +test("off checks no edits", t => { + const f = gateFixture(t, "off"); + f.memory("learning_app.md", appRule); + f.turn(); + assert.equal(f.write({ file_path: `${f.project}/src/app.ts` }), ""); + assert.equal(existsSync(f.log), false); +}); From 4d59ab4454a83aab1c170701d21541c11fb6fe47 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Thu, 29 Oct 2026 01:51:32 +0100 Subject: [PATCH 2/3] Match edited paths as whole paths and point the warning at a KB search - A memory counts only when it names the full path, so a root file no longer matches nested paths ending in its name - The note gives the count and asks for a KB search on the path instead of listing titles --- README.md | 2 +- hooks/kb-gate.mts | 27 ++++++++++---------------- tests/fixtures/hook-messages.json | 2 +- tests/kb-gate.test.mts | 32 +++++++++++++++---------------- 4 files changed, 28 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index acc5d6e..6662d76 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ During installation, the pack asks how strictly it should enforce searching the Writes to `.claude/memories/` are gated separately, and in every mode except `off` they are denied unless a memory skill is active. That means `continuous-learning` in the same turn, or `memory-audit` earlier in the session. Deleting or moving a memory from the shell is allowed only under `memory-audit`. This keeps every save behind the skill's search and pre-write checks. -Edits to other files are checked against the memories that mention them. When a memory names the edited file's project-relative path and hasn't been read this turn, Claude is told which memories to read before relying on the edit. This happens in `warn` and `enforce` alike, and never blocks the edit. `observe` only logs it. +Edits to other files are checked against the memories that mention them. When a memory names the edited file's project-relative path and hasn't been read this turn, Claude is told how many memories mention the file and to search the knowledge base for its path, then check the edit against what it finds. This happens in `warn` and `enforce` alike, and never blocks the edit. `observe` only logs it. To change the mode, run `mcs sync` again. The selection is baked into the installed hook rather than read from a runtime setting. diff --git a/hooks/kb-gate.mts b/hooks/kb-gate.mts index 9f04b31..a1d9101 100755 --- a/hooks/kb-gate.mts +++ b/hooks/kb-gate.mts @@ -25,7 +25,6 @@ const GATED_AGENTS = ["Explore", "general-purpose", "Plan"]; const MEMORY_SKILL_SCOPES: Record = { "continuous-learning": "turn", "memory-audit": "session" }; const FILE_WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"]; const MAX_MEMORY_WRITE_DENIALS_PER_TURN = 3; -const MAX_LINKED_TITLES = 3; const MEMORY_DELETE = /(?:^|[^\w-])(?:rm|unlink|mv)(?=\s)/; const SLASH_SKILL = new RegExp(`(?:^|[\\s>])/(?:[\\w-]+:)?(${Object.keys(MEMORY_SKILL_SCOPES).join("|")})\\b`, "g"); const RETRIEVAL_TOOLS = ["mcp__memory-loop__get", "mcp__memory-loop__multi_get", "Read"]; @@ -304,35 +303,29 @@ mcp__memory-loop__query. if (!path) return; const flagged = thisTurn(flaggedFile); if (!flagged || flagged.some(([, seen]) => seen === path)) return; + // Whole paths only: a longer path ending in this one names a different file. + const mention = new RegExp(`(? isMemory(memories, name)) - .map(name => ({ name, text: attempt(() => read(`${memories}/${name}`)) ?? "" })) - .filter(({ text }) => text.includes(path)); + .filter(name => { + const text = attempt(() => read(`${memories}/${name}`)) ?? ""; + return text.includes(path) && mention.test(text); + }); if (hits.length === 0) return; const readThisTurn = new Set(thisTurn(retrievedFile)?.map(([, name]) => name)); - const unread = hits.filter(({ name }) => !readThisTurn.has(name)); + const unread = hits.filter(name => !readThisTurn.has(name)); function logLinked(decision: string, extra = {}): void { logEvent({ phase: "linked_edit", tool: payload.tool_name, target: path, agent_type: agentType, decision, ...extra }); } if (unread.length === 0) { - logLinked("allow", { skip_reason: "linked_read", files: hits.map(({ name }) => name) }); + logLinked("allow", { skip_reason: "linked_read", files: hits }); return; } append(flaggedFile, [turn, path]); - logLinked(MODE === "observe" ? "observe" : "warn", { files: unread.map(({ name }) => name) }); + logLinked(MODE === "observe" ? "observe" : "warn", { files: unread }); if (MODE === "observe") return; - const ranked = unread - .map(({ name, text }) => { - const title = /^# (.+)$/m.exec(text)?.[1]?.trim() || name; - const appliesTo = /^\*\*Applies to:\*\*.*$/m.exec(text)?.[0] ?? ""; - return { name, title, named: title.includes(path) || appliesTo.includes(path) }; - }) - // A memory naming the file in its title or Applies to: is most likely about that file. - .sort((a, b) => Number(b.named) - Number(a.named) || a.name.localeCompare(b.name)); - const listed = ranked.slice(0, MAX_LINKED_TITLES).map(({ name, title }) => `"${title}" (${name})`); - const more = ranked.length - listed.length; output({ - additionalContext: `Memories not read this turn mention ${path}: ${listed.join(", ")}${more ? `, and ${more} more` : ""}. This edit was applied without them; retrieve them with mcp__memory-loop__get and check the edit against them${more ? `; search the KB for "${path}" to see the rest` : ""}.`, + additionalContext: `Memories not read this turn mention ${path} (${unread.length}). This edit was applied without them; search the KB for "${path}" with mcp__memory-loop__query, retrieve the relevant results with mcp__memory-loop__get, and check the edit against them.`, }); return; } diff --git a/tests/fixtures/hook-messages.json b/tests/fixtures/hook-messages.json index 435661f..f507a8a 100644 --- a/tests/fixtures/hook-messages.json +++ b/tests/fixtures/hook-messages.json @@ -21,7 +21,7 @@ "linked": { "hookSpecificOutput": { "hookEventName": "PreToolUse", - "additionalContext": "Memories not read this turn mention src/app.ts: \"Learning: app\" (learning_app.md). This edit was applied without them; retrieve them with mcp__memory-loop__get and check the edit against them." + "additionalContext": "Memories not read this turn mention src/app.ts (1). This edit was applied without them; search the KB for \"src/app.ts\" with mcp__memory-loop__query, retrieve the relevant results with mcp__memory-loop__get, and check the edit against them." } }, "activator": "Memory: search the KB before a new phase, and on an unexpected error\nbefore diagnosing it from scratch. Before your final reply, run\nSkill(continuous-learning) once if this turn found a non-obvious cause,\nsettled a project decision the user made or agreed to, or showed a\nmemory is wrong or outdated. Never write .claude/memories/ directly.\n" diff --git a/tests/kb-gate.test.mts b/tests/kb-gate.test.mts index 62d7388..fcb1c1c 100644 --- a/tests/kb-gate.test.mts +++ b/tests/kb-gate.test.mts @@ -402,11 +402,11 @@ for (const mode of ["warn", "enforce"]) { f.memory("learning_app.md", appRule); f.turn(); const edit = () => f.write({ file_path: `${f.project}/src/app.ts`, tool_name: "Edit" }); - assert.match(linked(edit()), /mention src\/app\.ts: "Learning: app" \(learning_app\.md\)\./); + assert.match(linked(edit()), /^Memories not read this turn mention src\/app\.ts \(1\)\./); assert.deepEqual(f.lastLog().files, ["learning_app.md"]); assert.equal(edit(), ""); f.turn(); - assert.match(linked(edit()), /learning_app\.md/); + assert.match(linked(edit()), /\(1\)/); }); } @@ -421,26 +421,15 @@ test("a memory retrieved this turn by get or Read no longer warns, even during c assert.equal(f.write({ file_path: `${f.project}/src/app.ts` }), ""); assert.equal(f.lastLog().skip_reason, "linked_read"); f.turn(); - assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts` })), /decision_app\.md.*learning_app\.md/); -}); - -test("a warning lists three memories, those naming the file in their title or Applies to: first", t => { - const f = gateFixture(t); - for (const name of ["a", "b", "c"]) f.memory(`learning_${name}.md`, "# Learning\n\nTouches `src/app.ts`.\n"); - f.memory("learning_z.md", "# Learning: z\n\n**Applies to:** src/app.ts\n"); - f.memory("learning_y.md", "# Learning: src/app.ts order\n"); - f.turn(); - assert.equal( - linked(f.write({ file_path: `${f.project}/src/app.ts` })), - 'Memories not read this turn mention src/app.ts: "Learning: src/app.ts order" (learning_y.md), "Learning: z" (learning_z.md), "Learning" (learning_a.md), and 2 more. This edit was applied without them; retrieve them with mcp__memory-loop__get and check the edit against them; search the KB for "src/app.ts" to see the rest.', - ); + assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts` })), /\(2\)/); + assert.deepEqual(f.lastLog().files, ["decision_app.md", "learning_app.md"]); }); test("sub-agent edits are warned too", t => { const f = gateFixture(t); f.memory("learning_app.md", appRule); f.turn(); - assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts`, agent_id: "agent-1" })), /learning_app\.md/); + assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts`, agent_id: "agent-1" })), /\(1\)/); }); test("observe only logs linked edits; unmatched and outside-root edits leave no trace", t => { @@ -462,3 +451,14 @@ test("off checks no edits", t => { assert.equal(f.write({ file_path: `${f.project}/src/app.ts` }), ""); assert.equal(existsSync(f.log), false); }); + +test("a path matches only as a whole path", t => { + const f = gateFixture(t); + f.memory("learning_nested.md", "# Learning: nested\n\nNever reorder `Tuist/Package.swift`.\n"); + f.memory("learning_longer.md", "# Learning: longer\n\nSee `Package.swift.bak`.\n"); + f.turn(); + assert.equal(f.write({ file_path: `${f.project}/Package.swift` }), ""); + f.memory("learning_root.md", "# Learning: root\n\nSee ./Package.swift.\n"); + assert.match(linked(f.write({ file_path: `${f.project}/Package.swift` })), /^Memories not read this turn mention Package\.swift \(1\)\./); + assert.deepEqual(f.lastLog().files, ["learning_root.md"]); +}); From 501b38bf813c6d32df6fc845e8223bb3bf7c402a Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Thu, 29 Oct 2026 10:37:43 +0100 Subject: [PATCH 3/3] Trigger edit notes on a file's path or multi-word name, silenced by a KB search - A memory naming the file's path, or its name when that has two or more words, triggers a note asking for a lex+vec KB search - Only a search for the path or name since session start or the last compaction silences it; reading a memory does not - At most three notes per turn; a SessionStart "compact" registration marks where earlier searches stop counting --- CLAUDE.md | 2 +- README.md | 2 +- hooks/kb-gate.mts | 76 +++++++++++++++++++++++-------- techpack.yaml | 25 ++++++---- tests/contracts.test.mts | 7 +-- tests/fixtures/hook-messages.json | 2 +- tests/helpers.mts | 4 ++ tests/kb-gate.test.mts | 76 ++++++++++++++++++++++--------- 8 files changed, 139 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08bebd2..85a12ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Two harness details are load-bearing rather than incidental: **Memory writes are gated by skill, per turn.** A file-tool write into `.claude/memories/` is denied unless a memory skill authorizes it. A Bash `rm`, `unlink`, `mv` or `git rm` whose command mentions `memories` is denied unless memory-audit ran, because only the audit deletes, and only after per-batch approval. That check exists for auto mode, where no permission prompt would stop the command. It matches the whole command rather than parsing arguments, since variables, globs and `cd` make parsing easy to evade. The scope is the turn for capture because a session that loaded the skill turns earlier edits from a stale copy and skips its checks. A typed skill command produces no `Skill` tool call, only prompt text, which is why the `UserPromptSubmit` branch scans the prompt as well. This gate ignores `KB_GATE_MODE` except `off`, so its own per-turn denial budget is what keeps a broken `Skill` record from blocking writes forever. -**Edits are checked against the memories that name the file.** A Write, Edit or NotebookEdit outside `memories/` scans every memory for the file's project-relative path as a plain substring, with no qmd call, since that would put a model on every edit. Hits not retrieved this turn produce a warning, never a denial, in both `warn` and `enforce`; `observe` only logs. A path is flagged once per turn, and edits outside the project root are skipped. Retrievals are recorded per turn in `.kb-gate/.retrieved`, which is why `get` and `multi_get` sit on the synchronous PostToolUse registration: an edit issued right after one must already see it as read. `Read` stays async, so a memory opened that way can still be warned about once. +**Edits are checked against the memories that name the file.** A Write, Edit or NotebookEdit outside `memories/` scans every memory for the file's project-relative path, matched as a whole path, or for its name without the extension, matched as a whole word when the name has two or more words (`HermesManager`, `user_service`, `kb-gate`). Memories name types more often than paths, and one-word names like `index` occur in prose everywhere. There is no qmd call, since that would put a model on every edit. A hit produces a note asking for a KB search on that path or name, with a `vec` line giving the edit's reason, because the search ranks relevance better than any text match. Only a KB search whose text contains the path or the name silences it, for the rest of the session: its results stay in context across turns. A compaction summarizes them away, so a `SessionStart` registration with matcher `compact` stores the `.queries` file's row count in `.kb-gate/.compacted`, and only searches after that row count. Reading one of the memories does not silence it, because a file can be named in many memories and one broad read would hide the rest. The note never denies, in `warn` or `enforce`; `observe` only logs. A path is noted once per turn, at most three notes per turn, and edits outside the project root are skipped. **Both hooks fail open.** Unexpected filesystem or payload errors exit 0 without output. The gate never exits 2, never calls qmd, and logs every evaluation except when disabled, when an uninteresting sub-agent starts, or when an edited file is named in no memory. Preserve the append-only query and denial files, monotonic turn counter, and separate per-state/per-turn denial budgets. Gate state is compatible with previous shell installs. diff --git a/README.md b/README.md index 6662d76..adb6395 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ During installation, the pack asks how strictly it should enforce searching the Writes to `.claude/memories/` are gated separately, and in every mode except `off` they are denied unless a memory skill is active. That means `continuous-learning` in the same turn, or `memory-audit` earlier in the session. Deleting or moving a memory from the shell is allowed only under `memory-audit`. This keeps every save behind the skill's search and pre-write checks. -Edits to other files are checked against the memories that mention them. When a memory names the edited file's project-relative path and hasn't been read this turn, Claude is told how many memories mention the file and to search the knowledge base for its path, then check the edit against what it finds. This happens in `warn` and `enforce` alike, and never blocks the edit. `observe` only logs it. +Edits to other files are checked against the memories that mention them, by the file's path or by a file name of two or more words such as `HermesManager`. When a match exists and the knowledge base hasn't been searched for that file in the session (since the conversation was last compacted), Claude is asked to search for it, together with what the edit changes and why, and to check the edit against what it finds. This happens in `warn` and `enforce` alike, at most three times per turn, and never blocks the edit. `observe` only logs it. To change the mode, run `mcs sync` again. The selection is baked into the installed hook rather than read from a runtime setting. diff --git a/hooks/kb-gate.mts b/hooks/kb-gate.mts index a1d9101..1bd6d3f 100755 --- a/hooks/kb-gate.mts +++ b/hooks/kb-gate.mts @@ -25,6 +25,7 @@ const GATED_AGENTS = ["Explore", "general-purpose", "Plan"]; const MEMORY_SKILL_SCOPES: Record = { "continuous-learning": "turn", "memory-audit": "session" }; const FILE_WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"]; const MAX_MEMORY_WRITE_DENIALS_PER_TURN = 3; +const MAX_LINKED_NOTES_PER_TURN = 3; const MEMORY_DELETE = /(?:^|[^\w-])(?:rm|unlink|mv)(?=\s)/; const SLASH_SKILL = new RegExp(`(?:^|[\\s>])/(?:[\\w-]+:)?(${Object.keys(MEMORY_SKILL_SCOPES).join("|")})\\b`, "g"); const RETRIEVAL_TOOLS = ["mcp__memory-loop__get", "mcp__memory-loop__multi_get", "Read"]; @@ -52,6 +53,10 @@ function mentionsMemories(path: string): boolean { return lexical.includes("memories") || realPath(lexical).includes("memories"); } +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function readIfExists(file: string): string | undefined { return existsSync(file) ? attempt(() => read(file)) : ""; } @@ -125,7 +130,8 @@ function gate(): void { const agentType = payload.tool_input?.subagent_type || payload.agent_type || ""; const hadKBBlock = (payload.tool_input?.prompt || "").includes(KB_MARKER); if (event === "SubagentStart" && !GATED_AGENTS.includes(agentType)) return; - if (!["UserPromptSubmit", "PostToolUse", "PreToolUse", "SubagentStart"].includes(event)) return; + if (!["UserPromptSubmit", "PostToolUse", "PreToolUse", "SubagentStart", "SessionStart"].includes(event)) return; + if (event === "SessionStart" && payload.source !== "compact") return; const isSkillCall = event === "PostToolUse" && payload.tool_name === "Skill"; const toolSkill = isSkillCall ? memorySkill(payload.tool_input?.skill) : undefined; if (isSkillCall && !toolSkill) return; @@ -147,8 +153,8 @@ function gate(): void { const queriesFile = `${state}/${session}.queries`; const denialsFile = `${state}/${session}.denials`; const skillsFile = `${state}/${session}.skills`; - const retrievedFile = `${state}/${session}.retrieved`; const flaggedFile = `${state}/${session}.flagged`; + const compactedFile = `${state}/${session}.compacted`; const turnText = (attempt(() => read(turnFile)) ?? "").split("\n")[0]?.trim() ?? ""; const turn = /^\d+$/.test(turnText) ? Number(turnText) : 0; @@ -235,6 +241,14 @@ function gate(): void { return; } + if (event === "SessionStart") { + // A row count into the append-only queries file: searches before it were summarized away by the compaction. + mkdirSync(state, { recursive: true }); + attempt(() => writeFileSync(compactedFile, `${rows(queriesFile)?.length ?? 0}\n`)); + logEvent({ decision: "compacted" }); + return; + } + if (toolSkill) { append(skillsFile, [turn, toolSkill]); logEvent({ decision: "recorded_skill", skill: toolSkill, source: "tool" }); @@ -246,8 +260,6 @@ function gate(): void { const names = target ? [memoryName(target)] : [...stdin.matchAll(RETRIEVED_RESOURCE)].map(match => match[1]); const retrieved = [...new Set(names)].filter((name): name is string => !!name && isMemory(memories, name)); if (retrieved.length === 0) return; - // Recorded before the skill check: a memory read while capturing still counts as read before an edit. - for (const name of retrieved) append(retrievedFile, [turn, name]); const skill = activeMemorySkill(true); if (skill) { logEvent({ tool: payload.tool_name, decision: "skip", skip_reason: "memory_skill", skill }); @@ -303,29 +315,53 @@ mcp__memory-loop__query. if (!path) return; const flagged = thisTurn(flaggedFile); if (!flagged || flagged.some(([, seen]) => seen === path)) return; + const name = basename(path).replace(/\.[^.]+$/, ""); + // Memories name types and modules more often than paths. A one-word name like `index` or `utils` + // turns up in prose everywhere, so only a name of two or more words is distinctive enough to match. + const distinctive = name.split(/[_.-]+|(?<=[a-z0-9])(?=[A-Z])/).filter(Boolean).length >= 2; // Whole paths only: a longer path ending in this one names a different file. - const mention = new RegExp(`(? isMemory(memories, name)) - .filter(name => { - const text = attempt(() => read(`${memories}/${name}`)) ?? ""; - return text.includes(path) && mention.test(text); - }); - if (hits.length === 0) return; - const readThisTurn = new Set(thisTurn(retrievedFile)?.map(([, name]) => name)); - const unread = hits.filter(name => !readThisTurn.has(name)); + const byPath = new RegExp(`(? isMemory(memories, file)) + .map(file => { + const text = attempt(() => read(`${memories}/${file}`)) ?? ""; + const viaPath = text.includes(path) && byPath.test(text); + return { file, viaPath, hit: viaPath || (distinctive && text.includes(name) && byName.test(text)) }; + }) + .filter(({ hit }) => hit); + if (matches.length === 0) return; + const hits = matches.map(({ file }) => file); + const term = matches.some(({ viaPath }) => viaPath) ? path : name; function logLinked(decision: string, extra = {}): void { - logEvent({ phase: "linked_edit", tool: payload.tool_name, target: path, agent_type: agentType, decision, ...extra }); + logEvent({ + phase: "linked_edit", + tool: payload.tool_name, + target: path, + term, + agent_type: agentType, + decision, + files: hits, + ...extra, + }); + } + // Any search since the last compaction counts: its results are still in context, whichever turn ran it. + const compactedAt = Number(attempt(() => read(compactedFile).trim())) || 0; + const searches = (rows(queriesFile) ?? []).slice(compactedAt).map(([, ...query]) => query.join("\t").toLowerCase()); + const searched = [...new Set([term, ...(distinctive ? [name] : [])])].map(text => text.toLowerCase()); + if (searches.some(query => searched.some(text => query.includes(text)))) { + logLinked("allow", { skip_reason: "linked_searched" }); + return; } - if (unread.length === 0) { - logLinked("allow", { skip_reason: "linked_read", files: hits }); + if (flagged.length >= MAX_LINKED_NOTES_PER_TURN) { + logLinked("allow", { skip_reason: "linked_cap" }); return; } append(flaggedFile, [turn, path]); - logLinked(MODE === "observe" ? "observe" : "warn", { files: unread }); + logLinked(MODE === "observe" ? "observe" : "warn"); if (MODE === "observe") return; output({ - additionalContext: `Memories not read this turn mention ${path} (${unread.length}). This edit was applied without them; search the KB for "${path}" with mcp__memory-loop__query, retrieve the relevant results with mcp__memory-loop__get, and check the edit against them.`, + additionalContext: `${hits.length === 1 ? "A memory mentions" : `${hits.length} memories mention`} ${term}, and the KB has not been searched for it since the session began or was last compacted. This edit was applied without them: search the KB with a lex line for "${term}" and a vec line saying what this edit changes and why, retrieve the relevant results with mcp__memory-loop__get, and check the edit against them.`, }); return; } @@ -382,7 +418,7 @@ mcp__memory-loop__query. logEvent({ decision: "skip", skip_reason: "no_memories_dir" }); return; } - const queries = (attempt(() => read(queriesFile)) ?? "").split("\n").filter(line => line && Number(line.split("\t")[0]) === turn).length; + const queries = thisTurn(queriesFile)?.length ?? 0; const freshQuery = queries > 0; function logDecision(satisfied: boolean, decision: string, extra = {}): void { logEvent({ diff --git a/techpack.yaml b/techpack.yaml index da3d697..43335e6 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -280,12 +280,11 @@ components: event: UserPromptSubmit - id: hook-kb-gate-record - displayName: KB gate — record searches, memory skills and retrievals - description: Records each KB search, memory-skill invocation and memory retrieval so delegated discovery, memory writes and file edits can be checked against them + displayName: KB gate — record searches and memory skills + description: Records each KB search and memory-skill invocation so delegated discovery, memory writes and file edits can be checked against them hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: PostToolUse - # Retrievals are synchronous here so an edit made right after one already sees it as read. - hookMatcher: "mcp__memory-loop__query|mcp__memory-loop__get|mcp__memory-loop__multi_get|Skill" + hookMatcher: "mcp__memory-loop__query|Skill" hook: source: hooks/kb-gate.mts destination: kb-gate.mts @@ -314,13 +313,23 @@ components: section: Hooks event: PreToolUse + - id: hook-kb-gate-compact + displayName: KB gate — mark compactions + description: Marks where the conversation was compacted, so file-edit notes only count KB searches still in context + hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning + hookEvent: SessionStart + hookMatcher: "compact" + hook: + source: hooks/kb-gate.mts + destination: kb-gate.mts + - id: hook-kb-gate-retrieval - displayName: KB gate — record memory reads - description: Records memories opened with Read, for .claude/memories/.usage.tsv and for the file-edit check + displayName: KB gate — record memory retrievals + description: Records the day each memory was last retrieved in .claude/memories/.usage.tsv, so a team can see which memories are still read hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: PostToolUse - hookMatcher: "Read" - # Every Read starts this hook, so it stays off the turn's critical path. + hookMatcher: "mcp__memory-loop__get|mcp__memory-loop__multi_get|Read" + # Every Read starts this hook, and it never needs to block, so it stays off the turn's critical path. hookAsync: true hook: source: hooks/kb-gate.mts diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index ca32757..d40c09f 100644 --- a/tests/contracts.test.mts +++ b/tests/contracts.test.mts @@ -36,7 +36,7 @@ for (const type of ["commonjs", "module"]) { test(`manifest hook layout works in a ${type} consumer`, t => { const f = fixture(t); writeFileSync(`${f.project}/package.json`, JSON.stringify({ type })); - assert.equal(hooks.length, 8); + assert.equal(hooks.length, 9); assert.match(manifest, /hook:\n {6}source: hooks\/shared.mts\n {6}destination: shared.mts/); const installed = `${f.project}/.claude/hooks/memory`; mkdirSync(installed, { recursive: true }); @@ -62,8 +62,9 @@ for (const type of ["commonjs", "module"]) { if (event === "SubagentStart") assert.equal(JSON.parse(output).hookSpecificOutput.hookEventName, event); } assert.match(manifest, /hookMatcher: "Agent\|Task\|Write\|Edit\|NotebookEdit\|Bash"/); - assert.match(manifest, /hookMatcher: "mcp__memory-loop__query\|mcp__memory-loop__get\|mcp__memory-loop__multi_get\|Skill"/); - assert.match(manifest, /hookMatcher: "Read"/); + assert.match(manifest, /hookEvent: SessionStart\n {4}hookMatcher: "compact"\n {4}hook:\n {6}source: hooks\/kb-gate.mts/); + assert.match(manifest, /hookMatcher: "mcp__memory-loop__query\|Skill"/); + assert.match(manifest, /hookMatcher: "mcp__memory-loop__get\|mcp__memory-loop__multi_get\|Read"/); assert.equal((manifest.match(/hookAsync: true/g) ?? []).length, 3); assert.equal((manifest.match(/hookTimeout: 120/g) ?? []).length, 2); }); diff --git a/tests/fixtures/hook-messages.json b/tests/fixtures/hook-messages.json index f507a8a..4dcc73a 100644 --- a/tests/fixtures/hook-messages.json +++ b/tests/fixtures/hook-messages.json @@ -21,7 +21,7 @@ "linked": { "hookSpecificOutput": { "hookEventName": "PreToolUse", - "additionalContext": "Memories not read this turn mention src/app.ts (1). This edit was applied without them; search the KB for \"src/app.ts\" with mcp__memory-loop__query, retrieve the relevant results with mcp__memory-loop__get, and check the edit against them." + "additionalContext": "A memory mentions src/app.ts, and the KB has not been searched for it since the session began or was last compacted. This edit was applied without them: search the KB with a lex line for \"src/app.ts\" and a vec line saying what this edit changes and why, retrieve the relevant results with mcp__memory-loop__get, and check the edit against them." } }, "activator": "Memory: search the KB before a new phase, and on an unexpected error\nbefore diagnosing it from scratch. Before your final reply, run\nSkill(continuous-learning) once if this turn found a non-obvious cause,\nsettled a project decision the user made or agreed to, or showed a\nmemory is wrong or outdated. Never write .claude/memories/ directly.\n" diff --git a/tests/helpers.mts b/tests/helpers.mts index 86e4ebb..dd19654 100644 --- a/tests/helpers.mts +++ b/tests/helpers.mts @@ -108,6 +108,10 @@ export function gateFixture(t: TestContext, mode = "enforce") { }; } +export function linked(output: string): string { + return JSON.parse(output).hookSpecificOutput.additionalContext; +} + export function denied(output: string): { permissionDecisionReason: string } { const decision = JSON.parse(output).hookSpecificOutput; assert.equal(decision.permissionDecision, "deny"); diff --git a/tests/kb-gate.test.mts b/tests/kb-gate.test.mts index fcb1c1c..865e148 100644 --- a/tests/kb-gate.test.mts +++ b/tests/kb-gate.test.mts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import test from "node:test"; -import { denied, gateFixture, root } from "./helpers.mts"; +import { denied, gateFixture, linked, root } from "./helpers.mts"; const kb = "KB context: none relevant."; @@ -394,42 +394,84 @@ test("concurrent retrievals record every memory", async t => { }); const appRule = "# Learning: app\n\nNever reorder `src/app.ts`.\n"; -const linked = (output: string): string => JSON.parse(output).hookSpecificOutput.additionalContext; for (const mode of ["warn", "enforce"]) { - test(`${mode} warns once per path per turn about unread memories that mention an edited file`, t => { + test(`${mode} warns once per path per turn about memories that mention an edited file`, t => { const f = gateFixture(t, mode); f.memory("learning_app.md", appRule); f.turn(); const edit = () => f.write({ file_path: `${f.project}/src/app.ts`, tool_name: "Edit" }); - assert.match(linked(edit()), /^Memories not read this turn mention src\/app\.ts \(1\)\./); + assert.match(linked(edit()), /^A memory mentions src\/app\.ts,/); assert.deepEqual(f.lastLog().files, ["learning_app.md"]); assert.equal(edit(), ""); f.turn(); - assert.match(linked(edit()), /\(1\)/); + assert.match(linked(edit()), /^A memory mentions/); }); } -test("a memory retrieved this turn by get or Read no longer warns, even during capture", t => { +test("a KB search for the file silences the note until the next compaction; reading a memory does not", t => { const f = gateFixture(t); f.memory("learning_app.md", appRule); f.memory("decision_app.md", "# Decision: app\n\nSee src/app.ts.\n"); + const edit = () => f.write({ file_path: `${f.project}/src/app.ts` }); f.turn(); - f.skill(); f.retrieve(["learning_app.md"]); f.readMemory(`${f.project}/.claude/memories/decision_app.md`); - assert.equal(f.write({ file_path: `${f.project}/src/app.ts` }), ""); - assert.equal(f.lastLog().skip_reason, "linked_read"); + assert.match(linked(edit()), /^2 memories mention src\/app\.ts/); + f.turn(); + f.search({ query: "app" }); + assert.match(linked(edit()), /^2 memories/); + f.turn(); + f.search({ searches: [{ type: "lex", query: "SRC/APP.TS" }] }); + assert.equal(edit(), ""); + assert.equal(f.lastLog().skip_reason, "linked_searched"); + f.turn(); + assert.equal(edit(), ""); + f.event("SessionStart", { source: "startup" }); + f.turn(); + assert.equal(edit(), ""); + f.event("SessionStart", { source: "compact" }); + assert.equal(f.lastLog().decision, "compacted"); f.turn(); - assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts` })), /\(2\)/); - assert.deepEqual(f.lastLog().files, ["decision_app.md", "learning_app.md"]); + assert.match(linked(edit()), /^2 memories/); + f.search({ query: "what src/app.ts orders" }); + f.turn(); + assert.equal(edit(), ""); +}); + +test("a multi-word file name matches memories that name it; a one-word name does not", t => { + const f = gateFixture(t); + f.memory("learning_hermes.md", "# Learning: hermes\n\n`HermesManager.init` must run on main.\n"); + f.memory("learning_tests.md", "# Learning: tests\n\nSee `HermesManagerTests`.\n"); + f.memory("learning_index.md", "# Learning: index\n\nThe search index is rebuilt nightly.\n"); + f.turn(); + assert.match( + linked(f.write({ file_path: `${f.project}/Sources/Support/HermesManager.swift` })), + /^A memory mentions HermesManager, and .* a lex line for "HermesManager" and a vec line/, + ); + assert.deepEqual(f.lastLog().files, ["learning_hermes.md"]); + assert.equal(f.write({ file_path: `${f.project}/src/index.ts` }), ""); + f.search({ query: "how does hermesmanager start" }); + assert.equal(f.write({ file_path: `${f.project}/Sources/Other/user_service.py` }), ""); }); test("sub-agent edits are warned too", t => { const f = gateFixture(t); f.memory("learning_app.md", appRule); f.turn(); - assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts`, agent_id: "agent-1" })), /\(1\)/); + assert.match(linked(f.write({ file_path: `${f.project}/src/app.ts`, agent_id: "agent-1" })), /^A memory mentions/); +}); + +test("at most three notes per turn; later matches are only logged", t => { + const f = gateFixture(t); + for (const name of ["a", "b", "c", "d"]) f.memory(`learning_${name}.md`, `# Learning: ${name}\n\nSee src/${name}.ts.\n`); + f.turn(); + for (const name of ["a", "b", "c"]) + assert.match(linked(f.write({ file_path: `${f.project}/src/${name}.ts` })), new RegExp(`src/${name}\\.ts`)); + assert.equal(f.write({ file_path: `${f.project}/src/d.ts` }), ""); + assert.equal(f.lastLog().skip_reason, "linked_cap"); + f.turn(); + assert.match(linked(f.write({ file_path: `${f.project}/src/d.ts` })), /src\/d\.ts/); }); test("observe only logs linked edits; unmatched and outside-root edits leave no trace", t => { @@ -444,14 +486,6 @@ test("observe only logs linked edits; unmatched and outside-root edits leave no assert.equal(readFileSync(f.log, "utf8"), before); }); -test("off checks no edits", t => { - const f = gateFixture(t, "off"); - f.memory("learning_app.md", appRule); - f.turn(); - assert.equal(f.write({ file_path: `${f.project}/src/app.ts` }), ""); - assert.equal(existsSync(f.log), false); -}); - test("a path matches only as a whole path", t => { const f = gateFixture(t); f.memory("learning_nested.md", "# Learning: nested\n\nNever reorder `Tuist/Package.swift`.\n"); @@ -459,6 +493,6 @@ test("a path matches only as a whole path", t => { f.turn(); assert.equal(f.write({ file_path: `${f.project}/Package.swift` }), ""); f.memory("learning_root.md", "# Learning: root\n\nSee ./Package.swift.\n"); - assert.match(linked(f.write({ file_path: `${f.project}/Package.swift` })), /^Memories not read this turn mention Package\.swift \(1\)\./); + assert.match(linked(f.write({ file_path: `${f.project}/Package.swift` })), /^A memory mentions Package\.swift,/); assert.deepEqual(f.lastLog().files, ["learning_root.md"]); });