diff --git a/CLAUDE.md b/CLAUDE.md index 34911b6..85a12ca 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, 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. **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..adb6395 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, 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. ## What's included diff --git a/hooks/kb-gate.mts b/hooks/kb-gate.mts index 8c45a1d..1bd6d3f 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_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,14 +130,15 @@ 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; 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 +153,8 @@ function gate(): void { const queriesFile = `${state}/${session}.queries`; const denialsFile = `${state}/${session}.denials`; const skillsFile = `${state}/${session}.skills`; + 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; @@ -163,8 +171,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 +204,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)); @@ -222,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" }); @@ -282,13 +309,66 @@ 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 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 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, + 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 (flagged.length >= MAX_LINKED_NOTES_PER_TURN) { + logLinked("allow", { skip_reason: "linked_cap" }); + return; + } + append(flaggedFile, [turn, path]); + logLinked(MODE === "observe" ? "observe" : "warn"); + if (MODE === "observe") return; + output({ + 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; + } + 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 }); } @@ -338,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 8bdd812..43335e6 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -281,7 +281,7 @@ components: - 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 + 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 hookMatcher: "mcp__memory-loop__query|Skill" @@ -297,8 +297,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 @@ -313,6 +313,16 @@ 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 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 diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index bf92038..d40c09f 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); @@ -34,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 }); @@ -60,6 +62,7 @@ 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, /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); diff --git a/tests/fixtures/hook-messages.json b/tests/fixtures/hook-messages.json index 2b57c5a..4dcc73a 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": "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 6c3e8eb..dd19654 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,12 +101,17 @@ export function gateFixture(t: TestContext, mode = "enforce") { retrieveConcurrently, readMemory, memories, + memory, usagePath, usage, lastLog, }; } +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 76d0ea4..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."; @@ -392,3 +392,107 @@ 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"; + +for (const mode of ["warn", "enforce"]) { + 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()), /^A memory mentions src\/app\.ts,/); + assert.deepEqual(f.lastLog().files, ["learning_app.md"]); + assert.equal(edit(), ""); + f.turn(); + assert.match(linked(edit()), /^A memory mentions/); + }); +} + +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.retrieve(["learning_app.md"]); + f.readMemory(`${f.project}/.claude/memories/decision_app.md`); + 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(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" })), /^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 => { + 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("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` })), /^A memory mentions Package\.swift,/); + assert.deepEqual(f.lastLog().files, ["learning_root.md"]); +});