CLAUDE.md · git:20261017.ceb83ea · 2026-10-17 · sha256 929b930c60638229
CLAUDE.md git:20261017.ceb83eaA
Immutable. This exact content never changes and is served at /api/v1/blob/929b930c60638229.
# CLAUDE.md
Lightweight persistent memory for Claude Code. MCP server + hooks plugin.
- **Version**: 6.29.1 — **this exact string is a release guard.**
`tests/install-e2e.test.mjs` asserts CLAUDE.md contains `**Version**: <v>` matching
`package.json`, `plugin.json` and `marketplace.json`. Do not reformat this line.
- **Runtime**: Node >=22 (20 dropped in v4.0.0), ESM · npm · better-sqlite3 + FTS5
**Capped at 20 KB: RULES ONLY.** Each rule was expensive to establish and several shipped as
a WRONG draft first, so **these one-liners are TRIGGERS, not the argument.** The evidence is
**verbatim** in the tracked appendix a fresh clone gets — `docs/measurement/findings.md`
(invariants, doctrine, calibers), `baselines.md`, `rulers.md`, `docs/audit*/`. **Read it
before retrieval, measurement, release, migration or schema work.**
## Commands
| Task | Command |
|------|---------|
| Setup · tests | `npm install` · `npx vitest run` · one file `npx vitest run tests/foo.test.mjs` · one case `-t 'case name'` · before tagging `npm run test:ci-env` (CI env) |
| Coverage | `npm run test:coverage` (gate: statements 81 / branches 75 / functions 87 / lines 83) |
| Lint · shell | `npx eslint .` · `shellcheck scripts/*.sh` |
| Format | `npm run format` — **run it twice**, `tests/hook-update.test.mjs` needs a second pass to reach a fixed point. `format:check` is gated in CI and pre-commit |
| Dead code | `npm run dead-code` (knip — measure from the **primary working tree**; the NAME SET is the evidence, not the count) |
| **Plugin manifests** | `npm run validate:manifests` — **`claude plugin validate . --strict` is NOT equivalent**: it never checks `.claude-plugin/plugin.json` |
| Bench · multipliers | `npm run benchmark:gate` · `benchmark:multipliers:gate` — **run the second after touching any constant in `scoring-sql.mjs` or `MULT_EXPR`; the first is structurally blind to those** |
| **Recapture the gate baseline** | `node benchmark/benchmark.mjs --production-hybrid > benchmark/baseline.json` — **expires 30 days after its own `timestamp`**, and CI + publish pass `--strict`. Sampled **2026-10-07T16:18:48Z** → red from **2026-11-06 16:18 UTC**. The stamp lives in THREE places (`baseline.json`, `ci.yml`, this row); `tests/baseline-stamp-sync.test.mjs` fails if they disagree. **Recapture BEFORE tagging, in its own commit** — otherwise it goes red after the tag is pushed |
| Audit metrics | `npm run audit:metrics` · `audit:baseline` · `audit:selfcheck` |
Two CLI families, both canonical in `cli.mjs` (`claude-mem-lite help` for flags):
**`CLI_COMMANDS`** = `search recent recall get timeline browse context save update delete
defer compress maintain optimize fts-check restore verify-apply export import-jsonl stats citation-stats
activity memdir-audit adopt unadopt help`; **`INSTALL_COMMANDS`** = `install uninstall status
doctor cleanup cleanup-hooks self-update repair rebuild-binding release`.
**`rebuild-binding` heals a missing native binding; `npm rebuild better-sqlite3` does not** —
it exits 0 printing "rebuilt dependencies successfully" while compiling nothing. **Never hand
a human `NATIVE_BINDING_REBUILD_CMD` alone**: hints go through `nativeBindingRepairHint()`,
which sequences with `&&`, never `||`. `doctor --metrics` is the only reader for the `inject`
series. Sandbox harness `tests/sandbox/` (`SBX_BASE` mandatory) after any dependency major.
→ `findings.md § Commands`.
## Architecture
Seven hook events in `hooks/hooks.json`: `SessionStart`, `PreCompact`, `PreToolUse`,
`PostToolUse`, `PostToolUseFailure`, `Stop`, `UserPromptSubmit`. **`PreToolUse` has THREE
matchers** (Bash via a prefilter).
`code-graph-mcp overview .` maps the tree; the modules whose ROLE the filename does not give
away are: **`tfidf.mjs` — the name is historical**, it is the Porter stemmer alone
since the vector engine was removed; `lib/tool-refusal.mjs` gates PostToolUseFailure,
separating a program failing from the agent's own chain refusing; `hook-optimize.mjs` is
re-enrich + normalize + cluster-merge + smart-compress; `server.mjs` has 19 tool definitions:
9 listed (10 with search telemetry), 9 hidden-but-callable; `schema.mjs` is schema + migrations
(v49 drops the vector tables); `scripts/post-tool-use.sh` is a ~5ms bash pre-filter; and
`hook.mjs` is the single entry for session-start / stop / post-tool-use / failure /
user-prompt.
Retrieval path: `sanitizeFtsQuery` → BM25 → OR fallback → concept co-occurrence. SessionStart
emits `<claude-mem-context>` on stdout fresh from the DB.
**Where new code goes:** `lib/` takes a path two faces SHARE (CLI + MCP, or two hook events
→ `lib/*-core.mjs`), a unit carved out of an entry file so COVERAGE reaches it, or a LEAF
that keeps a load graph out. **Never line count alone. Register every new one in BOTH
`source-files.mjs` and `package.json#files`.**
## Measurement doctrine
Most expensive mistakes here have been *measurement* mistakes; **violating one silently
produces a number that looks measured and is not.** → `findings.md § Measurement`.
1. **Stamp every number** with its date AND tree/corpus; an unstamped figure cannot be superseded.
2. **Never diff two runs taken at different times** — every corpus grows every session, including the one writing the note. Run both arms back-to-back.
3. **State the population.** "Which rows" is a required field, not a caveat.
4. **A count is a smoke alarm; the NAME SET is the evidence.** Never attribute a delta by subtracting two counts.
5. **A ruler must be able to say NO** — mutation-verify. A check nothing can break is not a check.
6. **A ruler must not pollute what it measures** — `{ counterfactual: true }`; a readonly handle shuts only one of two sinks.
7. **Measure the RELEASE tree, and measure it last.**
8. **Absolutes from a recency-weighted selector are snapshots, not properties.**
9. **A NEUTRAL from a structurally blind ruler says nothing, and a reading from the WRONG ruler says less** — two different failures. Check which arms a ruler actually executes before quoting it: `hybrid` names the eight multipliers in one place and FTS+vector in another, and that collision retired a subsystem on a number that never ran it.
10. **Correct the premise before quoting it.**
## Rulers
**Read a ruler's `rulers.md` entry before quoting or re-measuring it.**
`denoise-ab.mjs` any precision/recall lever, BEFORE shipping · `error-recall-live-replay.mjs`
+ `error-recall-suite.mjs` command-vocabulary admits, and the bm25 floor denoise-ab is blind
to · `citation-live-replay.mjs` per-face cite-rate from transcripts (prefer over
`citation-stats`) · `episode-flush-replay.mjs` flush decisions through the shipped batcher ·
`{rerank,keyctx,imperative}-pool-replay.mjs` candidate-pool bounds, Key Context's unit being
a PROJECT · `patha-exclude-report.mjs` D#216, deciding column `refilled` ·
`deep-search-holdout.mjs` deep search's PRECISION arm ·
`compress-veto-rate.mjs` whether D#10's veto FIRES, three arms · `haiku-model-ab.mjs` a haiku-tier
model swap · `multiplier-discrimination.mjs` whether each of the 8 multipliers is WIRED at its magnitude · `longmemeval.mjs` standard
recall, lexical baseline · `injection-budget.mjs` hook bytes per face, frozen corpus.
## Baselines
**Current reading only** — re-measure rather than carry. The test count is partly generated:
`tests/obs-id-caliber-sync.test.mjs` emits one case per `.mjs`/`.js` under `benchmark/`,
`lib/`, `scripts/` and the repo root, so adding a source file — or leaving an untracked
scratch file there — moves the headline.
| Baseline | Value | Tree / date |
|----------|-------|-------------|
| Tests | **428 files / 6628**, 0 skipped (1 skips without git hooks) | `main` @ `c8cfab5`, 2026-09-25, v6.12.1 tree |
| Knip | **32** unused exports, **0** unused files, **3** unlisted binaries | same tree, primary working tree, knip 6.35.1 |
| Coverage | **85.34** stmts · **79.75** branches · **90.72** funcs · **86.58** lines | same tree, vitest 5.0.0 |
Coverage `include` is a **denylist** — staying out costs a named `exclude`. Outside by
design: `install.mjs`, `server.mjs`, `hook.mjs`, `cli.mjs`, `benchmark/**`, `scripts/**`
(subprocess E2E, invisible to v8 coverage of the parent). Quote the v8 **text reporter**,
never `coverage/clover.xml`. **Three caliber breaks — do NOT diff across them**: the
2026-09-05 reformat (`36f8c0f`), vitest 5.0.0 (coverage), and the 2026-09-07 `include`
inversion (83 → 130 files). → `baselines.md`, `findings.md § Baselines`.
## Invariants that bite
**Full text, measured evidence and the wrong drafts: `findings.md § Invariants`.**
### Hooks and session lifecycle
- **`PostToolUse` does NOT fire for a host-flagged failure** — those go to `PostToolUseFailure` (text in `error`, no `tool_response`; `additionalContext` is the channel). **Do not widen `HARD_ERROR_RE`** — every anchor measured zero gain. Gated on `lib/tool-refusal.mjs`: most such failures are the agent's own guardrails refusing.
- **`Stop` fires once per assistant TURN, and `/clear` ROTATES the host session id.** The session file survives Stop, so **its presence no longer tells you why a session started** — ask stdin's `source` (`startup|clear|compact|resume`).
- **Every Stop-side writer needs a per-session idempotency key**, since Stop rescans the whole transcript; `access_count` written before v5.6.0 is an upper bound, not a count.
- **`hooks/hooks.json` and `install.mjs`'s `settings.json` entries are two hook sets and must change together** (`tests/audit-silent-20260814.test.mjs` diffs them) — **but the diff ignores `timeout`**. Adding a field? Ask whether the guard reads it.
- **The SessionStart "### Recent" table is a SELECTION, sorted for DISPLAY only** — both sites copy before sorting, so injected rows and the token budget are untouched.
- **`spawnBackground` children have no stderr** (`stdio: 'ignore'`), so "warn the user" there is dead on arrival — route it to `doctor`. An in-process spy on a logger is not evidence the log is delivered.
### Retrieval and ranking
- **Search's reported `total` is NOT the number of rows you can page to** — `reachable` is `preFinalizeCount`, never a re-derived `max(limit*3,60)`. The disclosure goes **SILENT** under a post-filter rather than guess; do not "improve" it with `total - postFilterDropped`.
- **A SQL `LIMIT` upstream of a JS-side relevance filter is a REACHABILITY bound, not a ranking bound** — an importance demotion becomes an *eviction*. Count such populations with the pool's own `liveObsFilterSql`, not a bare `WHERE importance = 3`.
- **`ORDER BY created_at_epoch DESC` without an id tiebreaker INVERTS on a tie** — `tests/order-by-created-at-guard.test.mjs` fails a new one. Spelling: `importance DESC, created_at_epoch DESC, id DESC`.
- **Deep search floods on questions the corpus cannot answer** — holdout reads **FP@10 = 10.00, 12/12**. The flood is the **AND→OR fallback**, which is also the vocab-mismatch recall win, so three gates were tested against both arms and **rejected**.
- **That same OR fallback DISARMS auto-escalation**, so both deep rulers describe EXPLICIT deep only and an escalation A/B reading Δ=0 is a blind instrument (rule 9).
- **`benchmark:gate` CANNOT say NO about the eight scoring multipliers** — saturated corpus, ablations gated by nothing. Use `multiplier-discrimination.mjs`; all eight are wired at their declared magnitude, but whether they *help a real user* is not answerable on this corpus.
### Writes, liveness and concurrency
- **`COALESCE(compressed_into,0)=0` alone is NOT the liveness predicate** — `liveObsFilterSql` also needs `superseded_at IS NULL`. **Which sites need the full one is settled; do not re-derive it** — the per-site reasons differ and are not interchangeable.
- **A long LLM round-trip needs `liveObsFilterSql` in the UPDATE's WHERE, not just the SELECT** — a concurrent hook can supersede the row mid-call. Treat `changes === 0` as a skip; when one write of a pair is guarded, check the other.
- **Every re-enrich pool's predicate is another pass's OUTPUT column, so filling a column EVICTS the row from whatever pool keyed on its emptiness.** Before adding a writer, ask which pool's WHERE clause that column is. `optimized_at` is the re-enrich pools' flag and nothing else's.
- **A `project` column is not a substitute for a project CHECK on a write** — pass `{ mode: 'write' }`; cross-project ops must compare both rows' projects first.
- **`writeFileSync(path, data, { flag: 'wx' })` is TWO syscalls**, so the file is briefly visible EMPTY — fill a private temp and `linkSync` it into place.
- **The injected-ids marker is a union across TABLES**, so ids need namespacing (`injectedIdKey`): most observation ids are also event ids.
- **`importance` is rewritten by five writers** — do not treat it as stable.
### Unattended LLM paths
- **The daily unattended `normalize` fans out to one scoped pass per project** — a union pass let ONE observation's content rewrite every project. **A corpus-derived whitelist cannot fix that**: if the attacker can write to the corpus they can write to the whitelist. Layer 1 is `isConceptShaped`, a Unicode **property**, never a hand-drawn class; the guard is `lib/memory-input-guard.mjs`, kept separate from `deep-search.mjs`'s `INJECTION_GUARD`. The 8-project cap **ROTATES** via a cursor the escape hatch must not reset.
- **An `if (x)` guard whose else-branch is a LOOSER RULE is a second policy nobody reviewed, and deleting the `if` promotes it**. Smart-compress fails **CLOSED** on a missing `should_compress`; the measured veto argues **against** disabling the branch outright.
- **Repetition must vary something** — `DEFAULT_LLM_TEMPERATURE` is 0, so on the sonnet tier re-asking the same prompt measures nothing (Haiku 5.5 runs unpinned). Rotate member order.
### Install, platform and recovery
- **`package.json`'s `os` is an npm INSTALL gate sitting on every MCP launch after an update** (the plugin cache ships without `node_modules`): on Windows it killed the stdio server. **A gate is not a message.** `doctor` keys on whether **bash runs** and returns **three** outcomes: "I could not look" gets its own warning, because a green "nothing to check" ends the search.
- **A recovery path must not import the thing it recovers** — one import edge, for two path constants, put the signature-verified repair out of reach on the broken install it exists to repair. They live in `lib/data-paths.mjs` (a leaf): importing a constant drags in its module's whole load graph.
- **A prebuilt addon that is PRESENT and will not load cannot be healed by compiling one** — better-sqlite3 picks `prebuilds/` on existence alone. Quarantine the dead prebuild **only inside the source-build branch**, and **never name the addon's path — ask `getPrebuildPath()`**.
- **A DB written by a NEWER claude-mem-lite locks every older code home out, permanently.** `lib/schema-skew.mjs` computes the remedy from the ROOT that is behind, not the machine's global shape — **grep its importers rather than enumerating surfaces here.** The dedup marker must be per PROJECT; nothing called from `openDb`'s catch may throw (`getSessionId()` MINTS and writes).
- **A database file SQLite will not open is that shape with a DESTRUCTIVE remedy.** `SQLITE_CORRUPT_VTAB` (a damaged FTS index) carries the **same message text** as a damaged file — classify on `err.code` via `isFtsCorruptionError`, or you offer to overwrite a database whose rows are intact. The two channels carry **different strings**: the human gets the shell command, the model none. Register new per-project markers in `GC_PROJECT_MARKER_PREFIXES`.
- **`claude mcp remove -s project` edits the repository you are standing in**; install warns instead, on both branches.
### Testing this repo
- **Tests use a `:memory:` DB**; schema changes must sync to test files.
- **A test that reads repo source as TEXT must use `dirname(fileURLToPath(...))` + `join()`, never `new URL('../x.mjs', import.meta.url)`** — guarded by `tests/no-url-module-paths.test.mjs`.
- **`MEM_NO_AUTO_ADOPT=1` is a GLOBAL opt-out every auto-adopt caller must honour** — any test spawning `install` or `repair` must set it, or the suite rewrites this repo's own CLAUDE.md and sidecar.
- **`effectiveQuiet()` drops both Key Context sections under this repo's own cwd** (it is adopted), so a test asserting on them passes vacuously — point `CLAUDE_PROJECT_DIR` at a temp dir, set `MEM_NO_AUTO_ADOPT=1`, assert a premise.
- **An MCP tool's advertised JSON Schema is not its enforced schema, and `.pipe()` is where they part** — zod 4 renders the ZodPipe's INPUT side. Put the constraint INSIDE the `z.preprocess`.
- **Tool name mapping**: Claude Code's Agent tool is `'Agent'`, not `'Task'`; Skill via `event.tool_input?.skill`. Skill commands (`/search`, `/recall`, `/recent`, `/timeline`) use `!` preprocessing for CLI injection.
- **A sweep is only as wide as its population, and `walkShipped` is every shipped `.mjs`/`.js`** — the four shipped bash hooks sit outside every guard built on it. Read a guard's population before its criteria, and fix this class behaviourally: a text scan carries the same blind spot.
<!-- claude-mem-lite:begin v1 -->
## claude-mem-lite — persistent memory
PreToolUse hooks already run `mem_recall` for past lessons before Read/Edit/Write. The calls worth making proactively:
| When | Call |
|------|------|
| Before Edit/Write | hook already recalled; if an injected `#NN` lesson changed what you did, add the bare tag `(#NN)` once at the end of the sentence describing that change (citing = adopting; uncited lessons decay; skip ones that did not apply). No other mention of memory ids, saves or the memory store in replies to the user |
| A recalled memory drives an answer or a design choice | check its claim in the code or `git log` first: `#NN` and `E#NN` rows are notes from past sessions, many written automatically, so they can be wrong, and they describe the code as it was. If the code disagrees, trust the code and replace the note: `mem_save(..., supersedes=[NN])`, or `supersedes=["E#NN"]` for an event |
| After fixing a non-trivial bug | `mem_save(type="bugfix", lesson_learned="<root cause + fix, only what this change's diff shows>", importance=2)` |
| After a non-obvious architecture decision | `mem_save(type="decision", lesson_learned="<constraint + tradeoff>")` |
| Deferring to a future session | `mem_defer({title, priority:1|2|3, detail})`; when fixed, add `closes_deferred=[N]` to `mem_save` |
| Looking up past work / history | `mem_search "keywords"` · `mem_recent` · `mem_timeline` |
Path cost is round-trips, not milliseconds: the PreToolUse hook above already recalls (0 calls) — prefer it. For an explicit query, if these `mem_*` tools are deferred behind ToolSearch this session, the Bash CLI `claude-mem-lite` is one call vs two (ToolSearch + call); the MCP server instructions carry the absolute path to use when it is not on PATH.
Full tool + CLI tables, citation/decay rules, and save discipline → `.claude/plugin_claude_mem_lite.md`
<!-- claude-mem-lite:end -->
<!-- code-graph-mcp:begin v2 -->
## Code Graph (repo-wide AST index)
Parsed index of the whole repo. For structural questions — who calls X, what a
change breaks, every use of a symbol — one call replaces rounds of Grep + Read:
| Intent | Command |
|--------|---------|
| Who calls X / what X calls | `code-graph-mcp callgraph X` |
| Impact before changing a signature | `code-graph-mcp impact X` |
| Tests to re-run after changing files | `code-graph-mcp affected <files>` |
| Rename / remove audit | `code-graph-mcp refs X`, then `grep -w X` |
| Unfamiliar dir / module | `code-graph-mcp overview <dir>` |
| Symbol source / signature | `code-graph-mcp show X` |
| Concept search (no exact name) | `code-graph-mcp search "…"` (vector: MCP `semantic_code_search`) |
| grep + AST context | `code-graph-mcp grep "pat" [paths] [-t lang] [-g glob] [-c]` |
Unresolved calls (dynamic dispatch, reflection, unresolved imports) leave no edge, so
an empty answer is not proof: confirm with grep before deleting or renaming.
Still use Grep for literal strings/regex in non-code files; still Read files you'll edit.
Full command + MCP-tool table: `.claude/plugin_code_graph_mcp.md`
<!-- code-graph-mcp:end -->