Skip to content
Closed
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: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +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.
**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 the project has opted out, 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.

**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.
**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. Every hook does nothing when `.claude/.no-memory-loop` exists at that root (`optedOut` in TypeScript, a copy of the ladder in `hooks/memory-loop-activator.sh`), so a new entry point needs the check too.

**Indexing is unconditional and incremental.** qmd handles added, edited, deleted, and symlinked memories. Do not add a separate staleness check. The config stores the literal memories path, repairs drift, and is written even before the first memory exists. The directory lock protects update/embed/status/cleanup, and stale empty locks are reclaimed using the previous `find -mmin +5` age rule.

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ Writes to `.claude/memories/` are gated separately, and in every mode except `of

To change the mode, run `mcs sync` again. The selection is baked into the installed hook rather than read from a runtime setting.

## Opting a project out

To exclude one project from a global install, create `.claude/.no-memory-loop` in its root yourself; the gate stops an agent from creating or removing it. The hooks then do nothing there. The MCP server is still registered, so deny it in that project's settings (`"permissions": { "deny": ["mcp__memory-loop"] }`).

## What's included

| Component | What it does |
Expand Down
21 changes: 19 additions & 2 deletions hooks/kb-gate.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
writeFileSync,
} from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { attempt, isDirectory, projectRoot, read } from "./shared.mts";
import { attempt, isDirectory, OPT_OUT_MARKER, optedOut, projectRoot, read } from "./shared.mts";

const MODE: string = "__KB_GATE_MODE__";
const MAX_DENIALS_PER_STATE = 4;
Expand Down Expand Up @@ -132,13 +132,30 @@ function gate(): void {
const isRetrieval = event === "PostToolUse" && RETRIEVAL_TOOLS.includes(payload.tool_name);
const isMemoryWrite = event === "PreToolUse" && MEMORY_WRITE_TOOLS.includes(payload.tool_name);
const target = payload.tool_input?.file_path || payload.tool_input?.notebook_path || "";
if (target && !mentionsMemories(target)) return;
const command = String(payload.tool_input?.command ?? "");
// The marker also switches off the memory write gate, so an agent must not be able to set or clear it.
if (
event === "PreToolUse" &&
((isMemoryWrite && basename(target) === OPT_OUT_MARKER) || (payload.tool_name === "Bash" && command.includes(OPT_OUT_MARKER)))
) {
process.stdout.write(
`${JSON.stringify({
hookSpecificOutput: {
hookEventName: event,
permissionDecision: "deny",
permissionDecisionReason: `Only the user opts a project in or out of the memory loop. Ask them to create or remove .claude/${OPT_OUT_MARKER} themselves.`,
},
})}\n`,
);
return;
}
if (target && !mentionsMemories(target)) return;
const isMemoryDelete =
event === "PreToolUse" && payload.tool_name === "Bash" && command.includes("memories") && MEMORY_DELETE.test(command);
if (event === "PreToolUse" && payload.tool_name === "Bash" && !isMemoryDelete) return;

const root = projectRoot();
if (optedOut(root)) return;
const memories = `${root}/.claude/memories`;
const usage = `${memories}/.usage.tsv`;
const state = `${root}/.claude/.kb-gate`;
Expand Down
4 changes: 4 additions & 0 deletions hooks/memory-loop-activator.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
#!/bin/bash

r=$(git rev-parse --show-toplevel 2>/dev/null)
[ -n "$r" ] || r="${CLAUDE_PROJECT_DIR:-$PWD}"
[ -e "$r/.claude/.no-memory-loop" ] && exit 0

cat << 'EOF'
Memory: search the KB before a new phase, and on an unexpected error
before diagnosing it from scratch. Before your final reply, run
Expand Down
8 changes: 7 additions & 1 deletion hooks/shared.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { accessSync, constants, readFileSync, statSync } from "node:fs";
import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
import { delimiter, join } from "node:path";

export function attempt<T>(action: () => T): T | undefined {
Expand Down Expand Up @@ -33,3 +33,9 @@ export function projectRoot(): string {
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
return result.stdout?.replace(/\n+$/, "") || process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
}

export const OPT_OUT_MARKER = ".no-memory-loop";

export function optedOut(root: string): boolean {
return existsSync(`${root}/.claude/${OPT_OUT_MARKER}`);
}
3 changes: 2 additions & 1 deletion hooks/sync-memories.mts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import { spawnSync } from "node:child_process";
import { closeSync, mkdirSync, openSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { attempt, commandExists, isDirectory, projectRoot, read } from "./shared.mts";
import { attempt, commandExists, isDirectory, optedOut, projectRoot, read } from "./shared.mts";

const embedModel = "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf";

function syncMemories(): void {
attempt(() => read(0));
const root = projectRoot();
if (optedOut(root)) return;
const memories = `${root}/.claude/memories`;
const directory = `${root}/.claude/.kb-index`;
const config = `${directory}/memory-loop.yml`;
Expand Down
6 changes: 6 additions & 0 deletions tests/contracts.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ test("hook messages match the original shell output byte for byte", t => {
assert.equal(f.run(`${root}/hooks/memory-loop-activator.sh`), messages.activator);
});

test("the activator is silent in an opted-out project", t => {
const f = fixture(t);
f.optOut();
assert.equal(f.run(`${root}/hooks/memory-loop-activator.sh`), "");
});

test("no two hook components share an event and destination", () => {
const registrations = hooks.map(component => `${field(component, "hookEvent")} ${field(component, "destination")}`);
assert.deepEqual(registrations, [...new Set(registrations)], "mcs keeps one registration per event and command");
Expand Down
3 changes: 2 additions & 1 deletion tests/helpers.mts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export function fixture(t: TestContext) {
assert.equal(result.stderr, "");
return result.stdout;
});
return { work, project, env, run, runAsync };
const optOut = () => writeFileSync(`${project}/.claude/.no-memory-loop`, "");
return { work, project, env, run, runAsync, optOut };
}

export function gateFixture(t: TestContext, mode = "enforce") {
Expand Down
25 changes: 25 additions & 0 deletions tests/kb-gate.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ test("discovery agents receive the same briefing and marker as the template", t
assert.equal(f.event("SubagentStart", { agent_type: "Explore" }), "");
});

test("an opted-out project is never gated, briefed or logged", t => {
const f = gateFixture(t);
f.optOut();
f.turn();
assert.equal(f.spawn(), "");
assert.equal(f.event("SubagentStart", { agent_type: "Explore" }), "");
assert.equal(f.write(), "");
f.memories("learning_x.md");
f.retrieve(["learning_x.md"]);
assert.equal(existsSync(f.usagePath), false);
assert.equal(existsSync(f.state), false);
assert.equal(existsSync(f.log), false);
});

test("agents can neither set nor clear the opt-out marker", t => {
const f = gateFixture(t);
const marker = `${f.project}/.claude/.no-memory-loop`;
denied(f.write({ file_path: marker }));
denied(f.bash(`touch ${marker}`));
f.optOut();
denied(f.bash(`rm ${marker}`));
denied(f.write({ file_path: marker, agent_id: "agent" }));
assert.equal(f.bash("ls"), "");
});

test("malformed payloads fail open without output or state", t => {
const f = gateFixture(t);
for (const payload of ["", "{", "null", "42", "[]", { tool_input: { prompt: 4 } }, { hook_event_name: "unknown" }]) {
Expand Down
7 changes: 7 additions & 0 deletions tests/sync-memories.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ for (const linked of [false, true]) {
});
}

test("an opted-out project is neither configured nor indexed", t => {
const f = indexer(t);
f.optOut();
assert.deepEqual(f.run(), []);
assert.equal(existsSync(f.directory), false);
});

test("config is registered before memories exist, repaired on drift, and stable otherwise", t => {
const f = indexer(t);
rmSync(`${f.project}/.claude/memories`, { recursive: true });
Expand Down