diff --git a/CLAUDE.md b/CLAUDE.md index 34911b6..5bd0acb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 67ba6a2..f6b24df 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/hooks/kb-gate.mts b/hooks/kb-gate.mts index 8c45a1d..b070b03 100755 --- a/hooks/kb-gate.mts +++ b/hooks/kb-gate.mts @@ -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; @@ -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`; diff --git a/hooks/memory-loop-activator.sh b/hooks/memory-loop-activator.sh index d07545c..a07b291 100644 --- a/hooks/memory-loop-activator.sh +++ b/hooks/memory-loop-activator.sh @@ -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 diff --git a/hooks/shared.mts b/hooks/shared.mts index 5f52800..6e68c4e 100644 --- a/hooks/shared.mts +++ b/hooks/shared.mts @@ -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(action: () => T): T | undefined { @@ -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}`); +} diff --git a/hooks/sync-memories.mts b/hooks/sync-memories.mts index 97ceeb5..0897eb5 100755 --- a/hooks/sync-memories.mts +++ b/hooks/sync-memories.mts @@ -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`; diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index bf92038..754a84b 100644 --- a/tests/contracts.test.mts +++ b/tests/contracts.test.mts @@ -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"); diff --git a/tests/helpers.mts b/tests/helpers.mts index 6c3e8eb..20d9587 100644 --- a/tests/helpers.mts +++ b/tests/helpers.mts @@ -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") { diff --git a/tests/kb-gate.test.mts b/tests/kb-gate.test.mts index 76d0ea4..908da63 100644 --- a/tests/kb-gate.test.mts +++ b/tests/kb-gate.test.mts @@ -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" }]) { diff --git a/tests/sync-memories.test.mts b/tests/sync-memories.test.mts index 1af4171..37543a5 100644 --- a/tests/sync-memories.test.mts +++ b/tests/sync-memories.test.mts @@ -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 });