project-brain
Local-first MCP server that gives AI tools semantic memory of your codebase.
project-brain indexes your project files into a local LanceDB vector store using Ollama embeddings, and builds a SQLite structural graph (symbols + call edges) with a WASM tree-sitter parser. Once indexed, AI assistants connected via MCP can search your codebase semantically (by meaning) AND structurally (exact symbols, callers/callees, change blast-radius), track module documentation, and maintain knowledge that persists across sessions.
Quick Start
Three commands, roughly two minutes:
brew install jcsoftdev/tap/project-brain # 1. install (see below for other channels)
project-brain setup # 2. register in your AI tools — once per machine
cd my-project && project-brain init # 3. index this project
That is the whole setup. The rest of this section explains what each step did and how to confirm it worked.
1. Install
Pick one. All three deliver the same self-contained binary — no Node, no Bun, nothing else at runtime:
brew install jcsoftdev/tap/project-brain # Homebrew
curl -fsSL https://raw.githubusercontent.com/jcsoftdev/project-brain/main/scripts/install.sh | sh # no package manager
bun install -g project-brain # or npm / pnpm / yarn
Confirm it landed:
project-brain --version
See Install for platform coverage and how upgrades work per channel.
2. Register in your AI tools
project-brain setup
Detects and registers project-brain with Claude, Codex, Cursor, Gemini, Windsurf, Zed and VS Code, and installs its host skills. Run it once per machine, not per project. Skip the skills with --no-skills.
3. Index a project
cd my-project
project-brain init
Detects the stack, writes a CLAUDE.md with MCP instructions, installs a git commit hook, scaffolds module stubs, and runs the first index.
No Ollama running? Use project-brain init --no-embed. Everything structural still works — see What you get without Ollama.
4. Check it worked
project-brain health
Reports embedding service, index counts and staleness. If chunks are 0 or semantic results come back empty, this is the command that tells you why.
5. Use it
From the terminal:
project-brain map # what matters in this codebase, ranked
project-brain impact parseConfig # what breaks if I change this
project-brain callers runSync # who depends on this
Or just ask your AI assistant in plain language — it picks the tool itself:
"What breaks if I change
parseConfig?" "How does authentication work here?"
See Recipes for the prompts that steer it best.
What runs automatically after init
What you get without Ollama
Ollama powers semantic search only. Everything else works offline with no model at all:
Optional — capture the why
The index knows what the code does; it cannot know why it is that way. A knowledge bundle holds that half:
project-brain okf init
See okf.
Prerequisites
Install
Every channel ships the same artifact: a bun build --compile binary with the
runtime, WASM grammars and templates embedded. Nothing else is needed at runtime.
Intel macOS: no prebuilt binary. Homebrew and curl both refuse rather than hand you an arm64 binary — use a registry install there.
project-brain update reads the binary's own path to work out which channel
installed it and prints the matching command. For a channel it cannot drive it
says so rather than guessing: a guessed npm install -g would leave a second
copy shadowing the first.
curl (no package manager)
curl -fsSL https://raw.githubusercontent.com/jcsoftdev/project-brain/main/scripts/install.sh | sh
The script verifies the downloaded binary actually runs on your machine before moving it onto your PATH, so a wrong-arch or truncated download fails at install time instead of at first use.
Build it yourself
git clone [email protected]:jcsoftdev/project-brain.git # or https://github.com/jcsoftdev/project-brain.git
cd project-brain
bun build ./src/cli.ts ./src/parser/worker.ts --compile --outfile project-brain
./project-brain --version
worker.ts is a required second entrypoint — without it the parser's worker pool cannot resolve its own imports inside the compiled binary and silently extracts zero symbols.
Keeping the host skills current
Skills live in your assistant's global skills directory, not in the package, so upgrading the binary does not move them. project-brain compares the installed skill content against what the running binary carries and refreshes any directory it originally created — whichever way you upgraded. It never creates one (that is setup's job) and never touches a directory it cannot prove it wrote. Opt out with BRAIN_NO_SKILL_REFRESH=1.
Usage modes
project-brain runs in two modes — CLI and inside AI tools. Both call the same underlying commands.
CLI (terminal)
Run commands directly from your terminal in any project directory.
Inside AI tools (slash commands)
Once project-brain setup has registered the MCP server in your AI tool, you can invoke commands as slash commands from within Claude Code, Codex, Cursor, Windsurf, Zed, VS Code, or any MCP-compatible AI:
These five are thin wrappers over the CLI. project-brain does not install them — write them yourself as skills in your assistant's global skills directory if you want the shortcuts, or just run the CLI.
Host skills setup installs
Unlike the /brain-* commands above, these have no CLI equivalent. They are host skills: the logic lives in SKILL.md and runs inside the assistant, which calls project-brain's MCP tools to do the work. That is what lets brain-audit answer "is this export dead?" with find_callers instead of guessing from a grep, and what lets brain-okf verify an anchor with find_symbol before writing it.
brain-style is the one that carries no command. It is a standing rule about written code rather than a task to invoke, and it claims precedence over the comment density an agent would otherwise copy from the files around it — leave it unticked at setup if your codebase wants that density kept.
project-brain setup installs them into each registered tool's global skills directory (~/.claude/skills/, ~/.codex/skills/, or the shared ~/.agents/skills/). Opt out with project-brain setup --no-skills (--no-brain-audit still works as an alias).
They are offered, not scheduled. Nothing invokes them for you and nothing fires on commit — you decide when. brain-audit runs discovery, proposes an audit module set, and waits for you to confirm before loading anything: 51 modules exist, and loading all of them every time is what makes an audit expensive. brain-okf proposes the type, title, and anchor and waits before creating a file, because whether an insight deserves a permanent home is your call, not the assistant's.
Setup never overwrites a skill directory it did not create. Ownership is proven per directory, so your own hand-written brain-okf/ is left untouched — and reported — while brain-audit/ upgrades beside it.
MCP Tools
Once connected over MCP, AI assistants get these tools. The server also injects routing instructions so the assistant picks the right one (semantic vs structural).
Semantic (meaning-based)
Lexical (keyword — no embeddings needed)
Structural (AST graph — exact, no embeddings needed)
Every structural tool is also a native CLI command — reusable without an MCP client, and even more offline than the MCP path (no Ollama probe, reads the local graph.db directly):
The CLI commands exit 0 for any executed query — including legitimate empty results (symbol not found, no callers, no path within depth). They exit 1 only for usage errors or an unindexed/unsynced project (project-brain init/sync first).
Modules & knowledge
Curated knowledge (the why behind the code) lives in an OKF bundle rather than in these tools — see okf.
Routing: exact symbol → find_symbol; who-calls → find_callers; what-it-calls → find_callees; "what breaks if I change X" → impact; "how does A end up calling B" → trace_path; fuzzy/conceptual → search_context then expand_context; exact string/identifier you can type verbatim → search_code. The canonical tool list lives in src/constants.ts (TOOL_CATALOG) and is rendered into the MCP server instructions plus the global rules files for hosts not proven to surface those instructions to the model. Claude Code IS proven to surface them (they land in context on every MCP connection), so its global and per-project CLAUDE.md point at that live copy instead of re-embedding the catalog.
Reranking (optional)
search_context can rerank its retrieved pool with TypeSafe's Jev model before the usual threshold/MMR/budget steps run, scoring every candidate's relevance to the query with a single call. Measured on a bench of 332 mined queries (30 candidates each): recall@1 goes from 66.0% to 84.3%, and MRR from 0.761 to 0.894, at p50 0.60s and $0.15 for the whole bench.
It is opt-in and sends data off-machine: the query text and every candidate's code/doc snippet are sent to api.typesafe.ai. Enable it only if that is acceptable for your codebase.
To enable it:
- Run
project-brain setupand select the reranker unit when prompted (you'll be asked for a token), or - Pass
--reranker-token <token>toproject-brain setupfor a non-interactive install, or - Set the
TYPESAFE_API_KEYenvironment variable (checked before the file either way).
With the reranker unit installed, a Claude Code SessionStart hook (project-brain jev-env) also exports the stored token as TYPESAFE_API_KEY into the session's shell, so the typesafe-ai skill, the TypeSafe SDK and ad-hoc scripts find it too. It appends to CLAUDE_ENV_FILE, leaves an already-set TYPESAFE_API_KEY alone, and never prints the token. Re-run project-brain setup once to add the hook to an existing install; the stored token is reused.
With no token configured, nothing changes — zero network calls, identical result order. Any failure (timeout, non-2xx, a malformed response) falls back silently to the normal, unreranked order rather than surfacing an error. project-brain health reports reranker: configured once a token resolves.
Skill suggestions
Suggestions come from their own global UserPromptSubmit hook, project-brain suggest --stdin, separate from the per-project retrieval hook. setup installs it by default (unit hooks:suggest), and an existing install gets it added once the next time its routing SessionStart hook runs, without rerunning setup. It works in every project, including ones never initialized with project-brain: no index is opened, and the prompt vector comes from the project's indexed embedding model when there is one, otherwise from the default model (keyword matching when Ollama does not answer). Without a token it stays silent. To opt out, deselect "Skill/tool suggestions" in setup (or remove the hook by hand) and it is never re-added, or set BRAIN_SKILL_PICKER=0 / BRAIN_TOOL_PICKER=0.
With a token configured, the hook asks Jev which installed skill best fits your prompt and adds one line to the context: Suggested skill: <name> — <why>. It only appears when Jev is at least 70% confident; otherwise nothing is added. Skills are read from ~/.claude/skills, <project>/.claude/skills, ~/.agents/skills and installed plugins (plugin:skill), cached on disk (rebuilt when any SKILL.md changes, or after ten minutes). The shortlist is chosen by embedding similarity using the project's embedding model, so prompts in any language the model supports match English skill descriptions; skill vectors are cached next to the skill list, and without embeddings it falls back to keyword overlap.
The prompt text and the shortlisted skills' names and descriptions (at most 12) are sent to api.typesafe.ai — the same disclosure as the reranker. Trivial prompts send nothing, and on the keyword fallback so do prompts with no overlap with any skill. The whole hook gives up after 5s (Jev itself after 1s) and fails silently. Opt out with BRAIN_SKILL_PICKER=0.
Tool suggestions. The same request can also carry a second question about your MCP tools, and the hook then adds Suggested tool: mcp__<server>__<tool> — <why> (again only at 70% confidence). Claude Code defers most MCP tools, so the assistant often never reaches for the right one; the hint names it. project-brain keeps its own catalog in ~/.project-brain/: tool-catalog.json for user-scope servers (~/.claude.json top level, ~/.claude/mcp-servers.json) and a per-project file for local-scope servers plus the project's .mcp.json ones, which are used only if you approved them in Claude Code (so a freshly cloned repo can never make project-brain start its servers): each stdio server, or unauthenticated HTTP server, is started briefly and asked for tools/list (10s per server, four at a time; a failure or a 401 skips that server, and project-brain itself is left out). Servers that cannot be listed this way, such as claude.ai connectors, are filled in by name only from the deferred_tools_delta entries in your recent Claude Code transcripts (claude.ai connectors and plugin servers only). The suggest hook never builds the catalog: it only reads it, and when it is missing, older than 24 hours or its servers changed, it spawns project-brain tool-catalog refresh in the background (at most once every ten minutes). Run that command yourself to rebuild on demand; it prints how many tools it found, and it also embeds the tools in the background so the hook only ever reads cached vectors. Both questions share the one Jev request, so tool suggestions add no round trip, and the same disclosure applies to the shortlisted tools' names and descriptions (at most 12). Opt out with BRAIN_TOOL_PICKER=0, independently of BRAIN_SKILL_PICKER.
Task-switch notice. Once the session's context is at least 30% full (read from the latest assistant usage in the transcript, against the auto-compact window or 200K), the same Jev request (only when the skill or tool picker is on; with both opted out nothing is sent at all) also asks whether the prompt starts a task unrelated to the recent work. What leaves the machine for that question, only above 30%: your last four prompts (300 characters each), the last assistant text block cut to 400 characters, and the names (never the inputs or outputs) of the last few tools used. Jev answers new_task, related_task or same_task. A confident new_task (0.8 by default) shows a one-line notice (hook systemMessage) suggesting /clear and resending; a related_task answer of 0.6 or more suggests /compact focus on: <the first ~60 characters of your prompt>; a continuation at 75% or more suggests /compact. /clear is never suggested while work is in flight, meaning a background agent, shell command or workflow that has not reported back, or a tool call without its result; past 75% it becomes /compact, otherwise nothing. The notice appears at most once per session for each of /clear and /compact. The prompt always goes through, and under 30% nothing extra is asked or sent. Opt out with PROJECT_BRAIN_TASK_SWITCH=0; tune with PROJECT_BRAIN_TASK_SWITCH_PCT, PROJECT_BRAIN_COMPACT_PCT, PROJECT_BRAIN_TASK_SWITCH_CONFIDENCE (0.5-1, default 0.8) and PROJECT_BRAIN_CONTEXT_WINDOW. scripts/calibrate-task-switch.ts replays your own transcripts read-only against Jev (at most 30 requests) to measure the /clear precision and recall at 0.7, 0.8 and 0.9.
Recipes — get the most out of it
You talk to your AI assistant in natural language; it picks the right tool. These prompts steer it well:
Tips to maximize value:
- Structural and lexical tools work without Ollama.
find_symbol/find_callers/find_callees/impact/trace_pathquery the local SQLite graph, andsearch_codequeries the local FTS index — all work even with embeddings unavailable.search_contextitself degrades gracefully too: with no Ollama it falls back to a BM25 lexical floor (code-aware query expansion over the local FTS index) and marks the resultdegraded: true, mode: "lexical"instead of failing — conceptual recall is reduced, so start Ollama for full semantic search when you can. - Keep the index fresh automatically. The git hook re-syncs on commit and the file watcher re-indexes on save while
serveruns — no manual step. Runproject-brain syncafter big external changes. - Prefer
expand_contextover re-reading files.search_contextreturns achunk_id; expanding it is cheaper than the assistant reading the whole file. - Lead with the exact name when you have it. "find_symbol X" / "who calls X" is faster and more precise than a semantic search.
- Big repos stay cheap. The walk honors
.gitignore; generated/minified files (>512 KB or pathological lines) are skipped from structural parsing automatically.
Commands
setup
One-time global setup. Detects your environment and registers project-brain with AI tools (Claude, Cursor, Gemini, Codex, Windsurf, Zed, VS Code).
project-brain setup
Context-economy units
Every context-economy feature is its own unit in the setup checklist, ticked by default, and an upgrade that never saw them turns them on without a prompt. Untick one to switch it off; your choice is stored in ~/.project-brain/features.json, which the hooks read at runtime (the existing PROJECT_BRAIN_*=0 variables still override it).
setup asks which parts to install — one checklist per group (Hosts, Guidance,
Skills, Other), asked in order, so each question is about one kind of thing — and
remembers the answer in ~/.project-brain/setup-selection.json. Re-running it
shows what is currently on disk beside what you are selecting, then a diff of
what will change before anything is written. A part you deselect is removed and
stays removed; a part that ships in a later release shows up marked NEW and
unchecked. On a first run nothing is pre-ticked except what is already on disk,
so pressing Enter through the checklist changes nothing — you install what you
tick.
Without a TTY it never prompts — it applies the saved selection, or each part's default if there is none.
project-brain setup --with=skill:brain-audit,hooks:worktree
project-brain setup --without=skill:brain-record
project-brain setup --all
project-brain setup --none
Run project-brain setup --with=nope to print the list of valid ids.
Worktrees start from the main checkout's index
init inside a linked git worktree copies the main checkout's vectors, manifest and graph before indexing, then syncs only what differs. Every path those artefacts store is repository-relative, so one checkout's index describes another equally well, and sync re-hashes each file anyway — a stale or wrong-branch base costs a few extra embeddings, never a wrong index.
On this repository that took a worktree's first index from 2m38s to 2.1s.
It copies nothing, and indexes normally, when the main checkout has no index, when the worktree already has one, or when the two would disagree about the embedding model. Vectors from a different model are not comparable, and that is the one case sync could not correct on its own.
Memory slimming
Everything in ~/.claude/CLAUDE.md loads on every turn of every session. setup already moves project-brain's own long block (the model-routing guidance) into the on-demand brain-model-routing skill, leaving a short pointer, and an existing install migrates the next time setup or update re-applies it. For the rest of the file, the config:context-slim unit (on by default) moves your own sections of 1500 tokens or more into skills each time setup or update runs, and unticking it restores them; managed blocks are never touched, so a file that is all managed blocks changes nothing. The manual command stays available:
project-brain context slim # measure: estimated tokens per section (chars / 4)
project-brain context slim --apply user:my-notes # move your own section into a skill (backup first)
project-brain context slim --revert # restore the latest backup, remove the skills it made
Sections are marked project-brain, other tool (inside another tool's / or / markers) or yours. Only yours can be moved; content a tool regenerates (for example gentle-ai) is refused, and has to be changed through that tool's own override mechanism. Moved sections become skills named memory-<heading> with a description derived from the heading (--description overrides it). Backups live in ~/.claude/context-slim/<timestamp>/; each --revert undoes the newest slim that still has a backup, and keeps the pre-revert CLAUDE.md under ~/.claude/context-slim/safety/.
Unused MCP servers
Every enabled MCP server loads its tool list and instructions into every session of every project, used or not. project-brain mcp prune reads each project's transcripts (subagents included) and finds the servers that project has not called in 30 days:
project-brain mcp prune # this project: what would be disabled, no changes
project-brain mcp prune --all # every project in ~/.claude.json
project-brain mcp prune --apply # add them to that project's disabledMcpServers (backup first)
project-brain mcp prune --revert # re-enable exactly what --apply disabled
It is per project: usage in one project never disables a server in another, and nothing touches the user-wide list. Git worktrees of a repo pool their usage and history, so a fresh worktree inherits the repo's. A project needs 10 sessions, or 14 days of history with at least 3 sessions inside the window, and its directory must still exist; new projects are handled by a later setup or update once they have history. project-brain, servers you list in PROJECT_BRAIN_MCP_PRUNE_KEEP (comma separated) and servers you switched back on after we disabled them are never touched: re-enabling a pruned server through /mcp is respected for good, even if it goes unused afterwards. Only MCP tool calls count as usage, so a server you use through resources or prompts alone belongs on the keep list. Setup (config:mcp-prune, checked by default) and update run --apply for all projects; opt out with PROJECT_BRAIN_MCP_PRUNE=0, change the window with PROJECT_BRAIN_MCP_PRUNE_DAYS. ~/.claude.json is backed up under ~/.project-brain/mcp-prune/, written atomically, and only the disabled lists change. Changes apply from the next session.
Auto-compact window
Setup sets Claude Code's autoCompactWindow to 400K tokens (config:auto-compact, checked by default). Every request re-sends the whole conversation, and those cache reads count against your plan. A 1M-context model left on its default compacts near 967K, so a long session keeps re-reading a far larger context than the work needs. With the cap it compacts near 387K. 200K models aren't affected.
If you already set autoCompactWindow to a value of your own, setup reports it as foreign and leaves it alone. To use the full window for one session, start it with claude --autocompact 1000000. The setting ranks below that flag, which is why setup doesn't write the CLAUDE_CODE_AUTO_COMPACT_WINDOW env var: the env var outranks the flag and would take the option away.
Commit attribution
Claude Code appends Co-Authored-By and Claude-Session: <link> trailers to the commits and PRs it writes. The harness adds them, not the model, so a rule in a skill can't keep them out. config:no-commit-attribution sets "attribution": {"commit": "", "pr": ""} in ~/.claude/settings.json, and that stops them.
It ships unchecked: it changes what every commit and PR says in every repo on the machine, and some teams want the trailer. Attribution text you already wrote is reported as foreign and left alone.
chrome-devtools autoConnect
If a detected host runs the chrome-devtools MCP server without --autoConnect, setup offers to add it (config:chrome-autoconnect). Without the flag those browser tools launch a throwaway, logged-out Chrome profile; with it they drive the Chrome you are already signed into, which is the only way to reach a flow behind a login.
It is the one unit that ships unchecked, on purpose. Attaching to your own browser is full control of it, and Chrome says so on the toggle the flag depends on: it "allows external apps to request full control of this browser. This includes read access to your saved data, cookies and site data, and the ability to navigate to any URL."
The flag is only half of it — Chrome refuses the connection until you open chrome://inspect/#remote-debugging and turn on "Allow remote debugging for this browser instance" yourself. Setup never flips that toggle; it prints the step after writing the flag. Entries already wired another way (--browserUrl, --wsEndpoint) or explicitly opted out (--no-auto-connect) are left untouched.
It also costs something the config file does not show, so setup prints that too. An attached server holds the CDP connection for its whole life and buffers what the browser reports on it across every tab; nothing releases it early, because your own Chrome outlives the session. Measured on one machine: two servers attached for ~2 days reached 1.6 GB and 742 MB of RSS, and kept growing after Chrome had been closed. And a host runs one MCP server per session, so the flag written once buys one attachment per open session — six sessions is six of them. Restarting a session is what frees its attachment.
If that multiplication is the problem rather than the flag itself, the next unit converts it.
chrome-devtools shared server
service:chrome-shared-server replaces one server per session with one server for every session. It takes the command a detected host already runs, puts it behind an HTTP bridge held open by a login service, and rewrites each host entry to point at http://127.0.0.1:39100/mcp. Sessions then cost a client connection each; the machine carries one process and one CDP attachment.
It is unchecked by default, and it is the only unit that installs an OS service (launchd on macOS, a systemd user unit on Linux; elsewhere it does not appear) and a Python tool. Both are answers to give on purpose.
The bridge is mcp-proxy, installed with uv if it is missing. It calls stdio_client once at startup and reuses that session for every client, so all clients genuinely share one child, and its JSON-RPC ids cannot collide because it proxies at the MCP semantic layer rather than relaying bytes. Setup pins its SDK below 2: installed without that constraint, the tool resolves an SDK where request_ctx no longer exists and every invocation dies at import.
The command moves across verbatim, so whatever flags you already answered for — --autoConnect included — carry over, and this unit decides none of them. Removing it restores each entry from a record written at install rather than reconstructing a command.
If a host already points at a bridge you set up yourself, under any service name, setup reads that service's command instead. autoConnect then shows current when the flag is in that command (foreign when it isn't; edit it there), and this unit shows foreign: the service is yours, and setup never installs over it or removes it. A service counts only when some host entry's URL uses its --port.
Two limits worth knowing. Sharing the server shares its state: one page list visible to every session, one profile, and process-level flags (--headless, --viewport, --blockedUrlPattern) that can no longer differ per session; --pageIdRouting, on by default, is what stops sessions from acting on each other's tabs. And collapsing N attachments to one does not bound how large that one grows — a single attached server was the 1.6 GB measurement — so setup prints the restart command for your platform when it finishes.
Model routing for sub-agents
Setup also writes model-routing guidance into each detected host's rules file: which tier — fast, balanced, or deep — a delegated sub-agent should run at for a given kind of task, and how to set that tier on that host.
Tiers rather than model names, because four of the six supported hosts default sub-agents to inheriting the parent's model and set the model in an agent-definition file rather than at the call — so "pass the model param" is true on Claude Code and false almost everywhere else. Each host gets its own text:
It is opt-out: a non-interactive run installs it by default. Leave it out of the checklist, or pass --without=guidance:model-routing (--no-model-routing still works as an alias), to skip it. The section carries a content version, so a later release updates it in place instead of leaving you on the text you first accepted.
Override the table in ~/.project-brain/model-routing.json:
{
"models": { "claude": { "deep": "opus" } },
"rules": [{ "task": "Write a migration", "tier": "deep", "why": "irreversible" }]
}
models remaps tiers per host, one tier at a time. rules adds a task, or retiers a built-in one by matching its task exactly. A malformed file warns and falls back to the defaults — it never fails setup.
Hooks (Claude Code only). Accepting the guidance also installs a SessionStart hook that injects the tier table once per session. Claude Code is the only host verified to fire PreToolUse on a sub-agent spawn and to accept additionalContext on SessionStart; a hook that silently never fires is worse than none.
Add --routing-hook-strict for enforcement: a PreToolUse hook that blocks a spawn naming no model and tells the model to re-issue it with one. Off by default — inheriting the session model is sometimes the right call, and the guard fails open on any payload it cannot parse. --no-routing-hook skips both.
Architecture guard
A PreToolUse hook on Edit, Write and MultiEdit that checks an edit against your architecture boundaries. It is selected by default in setup because it is local and warn-only out of the box: the setup unit installs the hook and seeds a global default at ~/.project-brain/architecture.json (hexagonal/clean preset, "mode": "warn"), so every project is covered with no per-project step.
project-brain setup --with=hooks:arch-guard # hook + global default (already on in a plain `setup`)
project-brain arch init # optional: pin a per-project config that overrides the global default
Which config applies. A project's own .project-brain/architecture.json replaces the global one whole, with no merging (even a broken project file means "no check", never a silent fall-through to the global rules). Without one, the global default applies, and a warning or block names it: (global default: ~/.project-brain/architecture.json). The seed is written only when the hook is first installed and the file is absent, so an edited or deliberately deleted global file is never recreated, and removing the unit leaves it in place.
Opt out. Deselect hooks:arch-guard in setup, or set "mode": "off" in a config (global to silence every project, or per project to silence one; a project file wins either way).
The preset. Only unambiguous folder names: domain = **/domain/**; application = **/application/**, **/use-cases/**, **/usecases/**; infrastructure = **/infrastructure/**, **/infra/**. Names like core, entities and adapters are left out because they mean other things in many trees; add them to the layer globs in your own .project-brain/architecture.json if your project uses them that way. Domain may not depend on application or infrastructure; application may not depend on infrastructure. A path matching several layers (src/infra/domain/x.ts) belongs to the layer whose matched directory is closest to the file (here domain); a tie at the same depth goes to the layer declared first. A project whose paths match no layer is left unchecked.
Layer rules (deterministic, offline). .project-brain/architecture.json names the layers and what may not depend on what:
{
"layers": { "domain": "src/domain/**", "application": "src/application/**", "infrastructure": ["src/infra/**", "pkg:pg"] },
"forbid": [{ "from": "domain", "to": ["application", "infrastructure"] }],
"mode": "block"
}
A layer is a glob or an array of globs; pkg:<name> claims a third-party package, which is otherwise in no layer. Only the imports the edit adds are judged, so a violation already in the file never blocks an unrelated change. TS/JS (import, export … from, require, literal import()), Go (through the go.mod module path) and Python are understood; relative specifiers resolve against the edited file, trying extensions and /index. tsconfig paths aliases are not resolved yet. Import syntax is matched in code only, so an import written inside a string, template or docstring is not a dependency. Only an explicit "mode": "block" blocks; a missing or unrecognised mode, and a file that is not valid JSON, mean "warn"; "off" disables the guard, Jev layer included. A warning is delivered to Claude as additionalContext next to the tool result, and a malformed file means no check, never a crash.
Constraints (fuzzy, needs a TypeSafe token). Any type: Constraint concept in okf/ whose resource: anchor covers the edited file is put to Jev as a yes/no question; the edit is blocked when P(violates) is above 0.8. A directory anchor with a trailing slash (resource: ../src/domain/) covers every file under it. This layer sends the edit and the constraint text to api.typesafe.ai, and only runs when a token resolves (the same one the reranker uses). With no token it is skipped, and a timeout or error lets the edit through. It blocks only when the applying architecture.json (project or global) says "mode": "block" (the file may hold nothing else); with no file it only warns, so a token you keep for reranking never stops an edit. At most 10 constraints are asked per edit, the most specific anchors first.
The hook reads stdin and fails open on anything it cannot parse. A block exits 2 with the reason on stderr, which Claude Code hands to the model as the retry prompt.
Commit check (needs a TypeSafe token)
A PreToolUse hook on Bash that acts only on git commit (including git -C x commit and a && git commit). One Jev call over the staged diff does two jobs:
- Review triage. Yes/no questions: security or permission boundaries, data loss or destructive operations, concurrency or locking, public contract changes. If any has P(yes) above 0.8 (or Jev is down or unsure on a risky-looking diff), Claude is told to run an independent deep review on a stronger model, or ask you, before committing. Jev only escalates: a routine change prints nothing, and silence is never an approval.
- Test warning. For staged source files with no staged test, Jev says whether the change is a business rule, bug fix, security boundary or data contract, which is where tests earn their keep. A match is a warning naming the files; nothing is ever required first.
project-brain setup --with=hooks:commit-check
setup ticks it by default only when a token is already configured, since the diff (first 16,000 characters) is sent to api.typesafe.ai. Without a token, with an empty diff, or on any error or timeout, it does nothing. The default is a warning delivered as additionalContext; to make the review escalation block (exit 2, reason on stderr), write ~/.project-brain/commit-check.json:
{ "mode": "block" }
The test warning never blocks.
init
Initialize a project. Detects the stack, writes a CLAUDE.md with MCP instructions, installs a git hook, scaffolds module stubs in docs/modules/, and indexes the project.
project-brain init [--skip-index] [--no-embed] [--embed-model=<key>]
--skip-index— skip the initial index pass (useful when Ollama is not yet running)--no-embed— index for keyword search only, no embedding model (equivalent toBRAIN_EMBED_MODEL=none)--embed-model=<key>— pin the embedding model non-interactively (registry key or raw Ollama model name)
When run in a terminal (and neither flag nor BRAIN_EMBED_MODEL is set), init interactively asks which embedding model to use — including the option to skip embeddings entirely for a lexical-only project. Non-interactive runs (CI, scripts, non-TTY) skip the prompt and fall back to the registry default.
sync
Incremental sync — re-indexes files that changed since the last sync.
project-brain sync
reindex
Full re-index — drops and rebuilds the entire vector index for the current project.
project-brain reindex [--no-embed] [--embed-model=<key>]
Same --no-embed/--embed-model=<key> flags as init (see above). Since a full rebuild is already a deliberate action, reindex also interactively asks which model to use when run in a terminal without a flag/env override — defaulting to whatever model the project is currently indexed with.
health
Check system health: Ollama availability, LanceDB status, and staleness of the index.
project-brain health
Also reports the outcome of the last background sync (.project-brain/sync-status.json) as a Last sync: line — ok, aborted (the watchdog gave up on a wedged job), failed, or crashed (the process died mid-run without ever writing a terminal outcome). Anything short of a clean ok points at .project-brain/sync.log, where the post-commit hook's sync/conceptualize output actually lands (overwritten on each run, so it always reflects the last one). The check_health MCP tool surfaces the same data as a lastSync field.
bench — measure retrieval quality
Published embedding leaderboards score a different corpus and a different task. A percent-or-two delta on someone else's benchmark says nothing about whether YOUR queries still surface the right file after changing model, dimension or chunking — so bench measures against ground truth from this repository instead.
project-brain bench mine [--out .project-brain/bench/queries.jsonl] [--limit N] # mine ground truth from git history
project-brain bench .project-brain/bench/queries.jsonl [--project <id>] [--pipeline full|hybrid]
bench mine walks non-merge commits and turns each one into a query: the commit subject (plus a short first body paragraph) as the query, the files it changed as the expected source(s). It skips release/version-bump commits, mass edits (more than ~8 files), and commits that touched only non-indexed files (lockfiles, package.json, CHANGELOG.md). Deterministic — the same repo history mines the same file every time.
bench then runs every query and reports recall@1/5/10 and MRR. --pipeline hybrid (default) measures hybridSearch alone — the retrieval signal in isolation. --pipeline full runs the exact path search_context runs (threshold + MMR + token budget), scoring what an agent actually receives, at the cost of conflating retrieval quality with post-processing behavior.
search
Search the indexed context and print compact results. Primarily used internally: init installs a UserPromptSubmit hook (project-brain search --stdin) in .claude/settings.json that auto-injects relevant context on every prompt, so retrieval is deterministic rather than relying on the AI to call a tool.
project-brain search "how does auth work"
echo "how does auth work" | project-brain search --stdin
The hook only injects when it is likely to help. Before any embedding work it skips prompts that carry no code question: very short prompts (under 3 words or 15 characters, unless they name a code identifier), acknowledgements and continuations in English and Spanish ("ok", "sí", "continúa", "commit y push"), slash-command echoes, and pasted harness or agent reports. Hits scoring below 0.5 are dropped and at most 5 are kept; if nothing survives, nothing is printed. The plain CLI search "<q>" is not gated.
Structural & lexical commands (find, callers, callees, impact, trace, map, code)
Native CLI equivalents of the structural/lexical MCP tools — see the Structural and Lexical tables above for the full CLI ↔ MCP tool mapping. All read the local index directly (no Ollama probe), so they work fully offline once project-brain sync has run.
project-brain find GraphStore
project-brain callers runSync
project-brain callees runSync
project-brain impact parseConfig --max-depth 3
project-brain trace handleSearch runSync
project-brain map --budget 2000 --focus createServer,runSync
project-brain code "chargeCard" --limit 5
okf — knowledge bundles (the why, not the what)
The index answers "what does this code do". It cannot answer "why is it like this", because that never existed in the AST. An Open Knowledge Format v0.2 bundle — a committed directory of Markdown files with YAML frontmatter — holds that half, and okf keeps the two halves honest about each other.
project-brain okf init [dir] # scaffold an empty bundle. Never overwrites.
project-brain okf validate [dir] # conformance (SPEC §11). Offline.
project-brain okf sync [dir] # index the concepts so search returns them
project-brain okf audit [dir] # cross-check the bundle against the code graph
project-brain okf audit --symbol <name> # which concepts to re-read after <name> changes
project-brain okf audit --json # one JSON object instead of prose, for CI/tooling
project-brain okf audit --judge # ask Claude whether a stale finding's reasoning still holds
--judge is opt-in and costs money: it sends claude-opus-5-5 the concept's prose plus the actual code diff, for every stale finding where the code moved but the reasoning might not have (never for uncommitted, expired, or a broken anchor). Needs Anthropic credentials (ANTHROPIC_API_KEY or ant auth login) — without them the run aborts judging and falls back to the plain, unjudged audit. A "still holds" verdict never auto-attests; it only moves the finding into a non-failing judged backlog for a human to confirm with a verified entry.
init writes index.md and log.md and stops — no seeded concepts, because a bundle shipped with examples makes the first audit report coverage gaps across the whole repo. Type directories appear when the first concept needs them.
It also re-renders CLAUDE.md. project-brain init runs before any bundle exists, so the knowledge-bundle section is omitted then; okf init adds it once there is something to point at. That section asks the assistant to decide, at the end of a task, whether anything belongs in the bundle — and says plainly that most tasks produce nothing. A checkpoint that feels obliged to produce a file is a concept mill, and noise in a knowledge bundle is worse than gaps. Projects with no bundle never see the instruction.
dir defaults to ./okf. Because the bundle is tracked in git, a plain project-brain sync also keeps it fresh — bundle files are routed through the curated projection instead of being chunked as raw markdown, so search_context returns the reasoning next to the code it explains.
A concept anchors code through resource (and sources[]), resolved from the bundle root:
---
type: Gotcha
title: Language.load ignores Bun's /$bunfs virtual filesystem
resource: ../src/parser/wasm.ts#loadGrammar # or ../src/parser/wasm.ts#L14-L24
generated: { by: "human:you", at: 2026-07-30T00:00:00-05:00 }
---
audit reports four things no single graph can answer:
Broken anchors and stale concepts exit 1, so audit works as a CI gate. Coverage gaps and link suggestions are backlog and never fail the run. Clear a stale finding by re-reading the concept and adding a verified entry — the file changes, so its commit date moves and the finding clears.
update
Update project-brain to the latest published version, using whichever install manager (bun, pnpm, yarn, or npm) it was originally installed with.
project-brain update
Every other command already prints an update available notice (current → latest, with the exact command to run) once a day when a newer version is published — update runs that command for you instead of requiring a copy-paste.
Live updates
After installing, update re-applies your saved setup selection with the new binary — no prompts, only the units you chose (a unit you declined is never added, and one never offered is refreshed only if it is already installed). If brew or npm upgraded the binary for you, run project-brain update --apply-only to do just that step.
What reaches Claude Code sessions that are already open:
- Hooks and skills are live at once: Claude Code reloads
settings.jsonand skill directories, and hooks re-run the binary on every event. - CLAUDE.md is only read at launch, so the first prompt after an update shows a one-time notice (
project-brain updated X → Y) with the current model-routing guidance. - The MCP server is not reloaded by Claude Code. By default the same notice tells you to run
/mcp reconnect all(or/mcp) to load the new tools.
Optional: automatic MCP hot swap
Set BRAIN_MCP_HOT_SWAP=1 in the MCP server's environment and stdio serve runs a small supervisor that relays to a worker process. When the binary on disk is newer, it starts a new worker, waits until no request is in flight, switches over, sends tools/list_changed, then lets the old worker drain and exit. A failure at any point leaves the old worker serving, and the notice then says to run /mcp reconnect all; after two failed attempts at a version the supervisor stops trying for that session. It is off by default because the supervisor is a second process per session, about 76 MB of memory each. Servers started before the first release with this feature have no supervisor, so that first update still needs one /mcp reconnect all.
Limits to know about: a new worker is launched through the same command your tool used (for a brew or npm link, the link, so a retargeted link is picked up), never through the old versioned directory; and when running from source with bun run, only the entry script is watched, so a change that touches package.json alone is not noticed.
serve
Start the MCP server. Default mode uses stdio (for local AI tool connections).
project-brain serve
serve --http
Start the MCP server over Streamable HTTP with bearer-token authentication. Useful for remote access or multi-client setups.
BRAIN_HTTP_TOKEN=your-secret project-brain serve --http [--port 3000]
Environment Variables
Tuning (environment variables)
sync/reindex throughput and resilience are tunable independently of the model:
A partial or total embed failure makes sync/reindex exit non-zero (1) — check the exit code in automation (CI, git hooks), not just stderr text.
Leaving BRAIN_EMBED_BATCH_SIZE/BRAIN_EMBED_CONCURRENCY unset does not mean "always use the defaults above" — each unset knob is auto-detected from machine resources at sync time (available free memory, and whether another model is already loaded in Ollama alongside the embed model, which risks VRAM contention). A log line like [sync] auto-tuned embed config: concurrency=1 batchSize=16 (vram-contention) explains why. Set either var explicitly to pin it — env values always win over auto-detection.
.project-brain/manifest.db (plus its -wal/-shm sidecars) replaces the old hashes.json incremental-sync manifest. It is gitignored already — if you have a stale hashes.json around, it migrates automatically on first sync and is renamed to hashes.json.bak.
Update notifications
The CLI checks (at most once a day, in the background) whether a newer project-brain is published on npm and prints a one-line notice when one is available. It is fail-silent, adds zero latency (the check runs in a detached process; the current command reads only a cached result), and is skipped in CI. Disable it with BRAIN_NO_UPDATE_CHECK=1. Run project-brain update to apply it.
Troubleshooting
Module Documentation Workflow
When you run project-brain init, it detects top-level source directories and creates stub files in docs/modules/<name>.md. These stubs are indexed immediately so semantic search works even before they are filled.
To populate a stub:
- Open a project session with your AI assistant.
- The AI reads
CLAUDE.mdand finds the## Module Documentationsection. - The AI fills each stub (Purpose, Key Files, Dependencies, Data Flow, Gotchas, Last Updated).
- The AI calls
add_knowledgewith the filled content so it is vectorized into project-brain.
Run project-brain sync after filling stubs to ensure they are re-indexed with their full content.