Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<session>.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.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 97 additions & 17 deletions hooks/kb-gate.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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__";
Expand All @@ -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<string, "turn" | "session"> = { "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"];
Expand Down Expand Up @@ -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)) : "";
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;

Expand All @@ -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 {
Expand Down Expand Up @@ -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));
Expand All @@ -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" });
Expand Down Expand Up @@ -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(`(?<![\\w./-])(?:\\./)?${escapeRegExp(path)}(?![\\w/-]|\\.\\w)`);
const byName = new RegExp(`(?<![\\w-])${escapeRegExp(name)}(?![\\w-])`);
const matches = readdirSync(memories)
.filter(file => 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 });
}
Expand Down Expand Up @@ -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({
Expand Down
16 changes: 13 additions & 3 deletions techpack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion tests/contracts.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 });
Expand All @@ -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);
Expand Down
Loading
Loading