Generated banner for obsidian-mind
Generated logo for obsidian-mind

obsidian-mind

Vault template

First committed by breferrari

Published in breferrari/obsidian-mind. Publisher page

5,527 GitHub stars

What it is

A self-organizing Obsidian vault that gives AI coding agents persistent memory.
— the description by breferrari

AutoGineer overview, from the brain’s own files

Obsidian Mind transforms an Obsidian vault into a persistent, structured brain for AI coding agents like Claude Code, Codex, and Gemini CLI. It solves the problem of agent amnesia by maintaining a hybrid memory model that combines file-based storage with semantic search capabilities. The system uses a set of lifecycle hooks to manage agent context, including project status, meeting notes, decisions, and performance tracking. By leveraging a structured folder hierarchy and automated indexing, it ensures that agents can recall past decisions, track project progress, and maintain context across sessions. The vault is designed to be self-maintaining, with automated hygiene checks and routing logic that keeps the knowledge base organized as it grows.

How it works

Memory model: Hybrid

AutoGineer overview, from the brain’s own files

The system uses a combination of Obsidian markdown files for storage and procedural hooks for management. Lifecycle hooks handle tasks like session initialization, content classification, and vault hygiene. The vault is organized into specific folders for work, performance, organizational data, and agent-specific knowledge. Semantic search is provided via an optional QMD integration, which uses local models to index and retrieve information. Agents interact with the vault through commands and MCP tools, allowing them to read, write, and update notes while the underlying scripts ensure that the vault remains structured and linked.

Folders
FolderRoleFiles
.claudeagent-config230
.codexagent-config1
.geminiagent-config21
.githubagent-config11
.obsidianobsidian-config1
brainmemory6
templatestemplates5
.claude-pluginfolder2
.scriptsfolder3
.shardmindfolder6
basesfolder9
orgfolder3
perffolder4
referencefolder1
thinkingfolder1
workfolder6

The agent reads

The agent writes

Requirements

Apps: Obsidian

Databases: Redis, SQLite

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)

Set it up

  1. This brain runs code automatically: 14 hooks (claude-code, codex, gemini-cli). Review .claude/settings.json, .codex/hooks.json, .gemini/settings.json before opening the folder in Claude Code. No step below turns a hook on for you.

    • .claude/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+17 chars]
    • .claude/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+20 chars]
    • .claude/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+18 chars]
    • .claude/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+15 chars]
    • .claude/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+18 chars]
    • .codex/hooks.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/session-start.ts"
    • .codex/hooks.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/classify-message.ts"
    • .codex/hooks.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/validate-write.ts"
    • .codex/hooks.json: node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/stop-checklist.ts"
    • .gemini/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/session-start.ts
    • .gemini/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/classify-message.ts
    • .gemini/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/validate-write.ts
    • .gemini/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/pre-compact.ts
    • .gemini/settings.json: node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/stop-checklist.ts
  2. Clone the repository at the commit AutoGineer scanned:

    git clone https://github.com/breferrari/obsidian-mind.git && cd obsidian-mind && git checkout fae7be467fc5e23bf9d51df7207d41f0c07e2754
  3. In Obsidian, choose “Open folder as vault” and pick the folder.

  4. Point Claude Code at .claude-plugin/marketplace.json: it does not read that file on its own.

  5. cd obsidian-mind && claude

  6. AutoGineer scanned these files at commit fae7be4.

  7. Not scanned by AutoGineerThe author’s MCP configuration (README.md) also starts it in a way AutoGineer does not offer. Read that file: AutoGineer did not scan that path; what it runs is listed under “This brain runs”. https://github.com/breferrari/obsidian-mind/blob/fae7be467fc5e23bf9d51df7207d41f0c07e2754/README.md
  8. Not scanned by AutoGineerThe author’s README also describes installing it from a clone of its repository. Read its install section: AutoGineer did not scan that path. https://github.com/breferrari/obsidian-mind/blob/fae7be467fc5e23bf9d51df7207d41f0c07e2754/README.md#-install-via-shardmind-recommended
  9. Not scanned by AutoGineerThe author also offers https://github.com/breferrari/obsidian-mind/tree/fae7be467fc5e23bf9d51df7207d41f0c07e2754. https://github.com/breferrari/obsidian-mind/tree/fae7be467fc5e23bf9d51df7207d41f0c07e2754
  10. First committed by breferrari. Published in breferrari/obsidian-mind. Licence: MIT License.

Usage

AutoGineer overview, from the brain’s own files

  • Start a new session to load project context and North Star goals.
  • Capture meeting takeaways and decisions using automated commands.
  • Query the vault for past decisions or project history using semantic search.
  • Perform end-of-day wrap-ups to index new notes and identify uncaptured wins.
  • Manage incident response and documentation within the vault structure.
  • Maintain performance records and brag documents for review cycles.
“/om-standup”
The agent loads the North Star goals, active projects, and recent tasks to provide a summary of current work status.
“/om-dump Just had a 1:1 with Sarah. She wants error monitoring added before release.”
The agent updates the person note for Sarah, creates a meeting record, and adds an error monitoring task to the active project note.
“What did we decide about caching?”
The agent uses semantic search to retrieve the specific decision record regarding the Redis migration.
“wrap up”
The agent verifies note links, updates indexes, and identifies uncaptured wins for the brag document.

Documentation by breferrari

🌐 English | 日本語 | 中文 | 한국어

<p align="center"> <img src="obsidian-mind-logo.png" alt="Obsidian Mind" width="120"> </p>

<h1 align="center">Obsidian Mind</h1>

Image: Claude Code Image: Codex CLI Image: Gemini CLI Image: Obsidian Image: Obsidian CLI Image: Obsidian Skills Image: QMD Image: Node Image: License

An Obsidian vault that gives AI coding agents persistent memory. Built for Claude Code, with working hooks for Codex CLI and Gemini CLI. Start a session, talk about your day, and the agent handles the rest — notes, links, indexes, performance tracking. Every conversation builds on the last.


🔴 The Problem

AI coding agents are powerful, but they forget. Every session starts from zero — no context on your goals, your team, your patterns, your wins. You re-explain the same things. You lose decisions made three conversations ago. The knowledge never compounds.

🟢 The Solution

Give your agent a brain.

You: "start session"
Agent: *reads North Star, checks active projects, scans recent memories*
Agent: "You're working on Project Alpha, blocked on the BE contract.
        Last session you decided to split the coordinator. Your 1:1
        with your manager is tomorrow — review brief is ready."

Works with Claude Code (full support), Codex CLI, and Gemini CLI — same hooks, same commands, same vault.

Install via shardmind install or git clone — same vault either way.


⚡ See It In Action

<p align="center"> <img src="obsidian-mind-demo.gif" alt="Obsidian Mind demo — standup and dump commands" width="800"> </p>

Morning kickoff:

/om-standup
# → loads North Star, active projects, open tasks, recent git changes
# → "You have 2 active projects. The auth refactor is blocked on API contract.
#    Your 1:1 with Sarah is at 2pm — last time she flagged observability."

Brain dump after a meeting:

/om-dump Just had a 1:1 with Sarah. She's happy with the auth work but wants
us to add error monitoring before release. Also, Tom mentioned the cache
migration is deferred to Q2 — we decided to focus on the API contract first.
Decision: defer Redis migration. Win: Sarah praised the auth architecture.
→ Updated org/people/Sarah Chen.md with meeting context
→ Created work/1-1/Sarah 2026-03-26.md with key takeaways
→ Created Decision Record: "Defer Redis migration to Q2"
→ Added to perf/Brag Doc.md: "Auth architecture praised by manager"
→ Updated work/active/Auth Refactor.md with error monitoring task

Incident response:

/om-incident-capture https://slack.com/archives/C0INCIDENT/p123456
# → slack-archaeologist reads every message, thread, and profile
# → people-profiler creates notes for new people involved
# → Full timeline, root cause analysis, brag doc entry

End of day:

You: "wrap up"
# → verifies all notes have links
# → updates indexes
# → brag-spotter finds uncaptured wins
# → suggests improvements

🚀 Quick Start

npm install -g shardmind
mkdir my-vault && cd my-vault
shardmind install github:breferrari/obsidian-mind

shardmind install writes into the current directory, so create and enter a fresh folder first. The wizard collects your name, organization, vault purpose, agents to include, and whether to enable QMD; ShardMind then initializes git, optionally bootstraps QMD, and personalizes brain/North Star.md with your answers. Then:

  1. Open the installed folder as an Obsidian vault

  2. Enable the Obsidian CLI in Settings → General (requires Obsidian 1.12+)

  3. Run your agent in the vault directory: claude, codex, or gemini. Codex runs the vault's hooks only once the folder is trusted and each hook approved: see Using with Other Agents.

  4. Start talking about work

ShardMind is the package manager for Obsidian vault templates. The install adds a .shardmind/ sidecar that powers the wizard, optional modules (skip what you don't use), and three-way-merge upgrades. With every value at its default the install is byte-equivalent to git clone — clone-UX is preserved exactly. Delete .shardmind/ and shard-values.yaml from the installed vault and it keeps working: ShardMind is additive, not load-bearing.

Or clone directly
git clone https://github.com/breferrari/obsidian-mind.git

Or use it as a GitHub template. Skip the wizard, get the bare template. Then run through the same 4 steps above, plus fill in brain/North Star.md with your goals (the ShardMind wizard does this for you).

QMD is where most of the agent's retrieval intelligence comes from. Optional in the strict sense — the vault falls back to grep + the Obsidian CLI — but the experience is meaningfully better with it:

  • Semantic recall. Find "what did we decide about caching" even when the note is titled "Redis Migration ADR."

  • Brain topics available on demand. Your agent is instructed (via AGENTS.md, which points to .claude/rules/brain-topics.md) to consult brain/ guidance through QMD when the conversation touches a listed topic.

  • Subagents get sharper context. context-loader, review-prep, brag-spotter, and friends consult QMD first, then fall back to grep.

  • Native agent tools via MCP. Registered as a Model Context Protocol server in .mcp.json — when QMD is installed, mcp__qmd__query, mcp__qmd__get, and mcp__qmd__multi_get appear in the agent's tool menu alongside Read and Edit. Subagents, slash commands, and the main conversation all call the same typed contract. Add another MCP-aware tool later (a database, a ticketing system, a calendar) and it plugs in the same way.

npm install -g @tobilu/qmd
node --experimental-strip-types .scripts/qmd-bootstrap.ts

The bootstrap is idempotent — safe to re-run. It resolves this vault's index name — the qmd_index field from vault-manifest.json when set, otherwise the vault folder name slugified — reads qmd_context, registers the collection, attaches the context, and builds the index + embeddings. The SessionStart hook and .mcp.json wrapper both read the same manifest field, so CLI queries, the MCP server, and the re-index all scope to the same named SQLite store. This isolates the vault from any other QMD-using vault on the same machine.

If you want to use a different index name (for example, one vault per engineer on a shared workstation), edit qmd_index in vault-manifest.json before running the bootstrap. Once the store is populated, always pass --index <name> to the CLI:

qmd --index obsidian-mind query "what did we decide about caching"
qmd --index obsidian-mind update   # after bulk edits
qmd --index obsidian-mind embed    # after many new notes
How it works under the hood

QMD runs three small models locally, so there is no API key to set up, no per-query cost, and it works offline:

modelsizejob
embeddinggemma-300M~328MBturns notes and queries into vectors
qmd-query-expansion-1.7B~1.28GBrewrites your query into better search terms
Qwen3-Reranker-0.6B~640MBreorders the shortlist by actual relevance

They download on first use and are cached. QMD offloads to the GPU when it finds one — CUDA on a discrete card, Metal on Apple Silicon — and falls back to CPU otherwise. Check what yours is doing with qmd doctor.

Use qmd 2.5.1 or later. In 2.1, qmd status probes the GPU, and on a machine without one that can take several seconds (4 to 37 s on CI runners, measured 2026-10-13, #349). SessionStart's self-heal reads qmd status under a 5-second limit, so a status that times out leaves it unable to see a broken install.

The three CLI verbs map onto that stack, cheapest first: qmd search is BM25 keywords with no model at all, qmd vsearch is vector-only, and qmd query is the full hybrid. If you want to avoid the larger downloads, search alone is genuinely useful.

What that means for the MCP server

The om server sends a lexical and a vector sub-query, so retrieval finds the note that answers your question even when it shares no keywords with it. Practical consequences worth knowing:

  • Reads are the expensive side, not writes. A query embeds locally before it can search, so recall with a query takes a couple of seconds while recall without one is near-instant. search over notes is fast — it is the vector step that costs.

  • Writing a memory does not wait for the model. The index update is synchronous, so a new memory is immediately retrievable; generating its vector happens in the background, because that only affects where it ranks, not whether it is found.

  • No index, no problem. Without QMD the server falls back to lexical matching. Ordering gets worse; nothing disappears.

NOTE
If QMD isn't installed, everything still works — the agent falls back to grep and the Obsidian CLI, and the MCP server entry is skipped with a harmless warning.


📋 Requirements

  • Obsidian 1.12+ (for CLI support)

  • An AI coding agent: Claude Code (full support), Codex CLI, or Gemini CLI

  • Node 22+ LTS (for hook scripts — typically already installed alongside Claude Code / Codex / Gemini CLI)

  • Git (for version history)

  • QMD (optional, for semantic search)

Note on the Node flag. Hook scripts execute TypeScript directly via Node's --experimental-strip-types flag, stable in Node 22.6+ (Aug 2024) and the default behaviour in Node 23.6+. The flag is marked experimental but has been unchanged across 22 LTS and 24 LTS; if a future Node release retires or renames it, hook commands in .claude/settings.json, .codex/hooks.json, and .gemini/settings.json need a one-line update.


⚙️ How It Works

Procedural code owns the environment. The agent owns content. The hooks in .claude/scripts/ handle classification, validation, indexing, and lifecycle injection — deterministic, testable, runs the same for every agent. Writing notes, filing them, linking them, drafting briefs — those are judgments, and they stay with the agent. The two halves meet at small handoffs (hooks inject context, agent reads the vault) so neither has to do the other's job.

Folders group by purpose. Links group by meaning. A note lives in one folder (its home) but links to many notes (its context). Your agent maintains this graph — linking work notes to people, decisions, and competencies automatically. When review season arrives, the backlinks on each competency note are already the evidence trail. A note without links is a bug.

Vault-first memory keeps context across sessions and machines. All durable knowledge lives in brain/ topic notes (git-tracked, Obsidian-browsable, linked). Claude Code's MEMORY.md (~/.claude/) is an auto-loaded index that points to vault locations — never the storage itself. This means memories survive machine changes and are part of the graph.

Sessions have a designed lifecycle. The SessionStart hook auto-injects your North Star goals, active projects, recent changes, open tasks, and the full vault file listing — your agent starts every session with context, not a blank slate. At the end, say "wrap up" and the agent runs /om-wrap-up — verifying notes, updating indexes, and spotting uncaptured wins. The AGENTS.md operating manual governs everything in between: where to file things, how to link, when to split a note, what to do with decisions and incidents.

🔗 Hooks

The lifecycle hooks handle routing automatically:

HookWhenWhat
🚀 SessionStartOn startup/resumeQMD re-index + self-heal, inject North Star focus, active work, recent changes, tasks, file listing, vault-hygiene drift flags — held under a byte budget that fits Claude Code's hook output cap, ending with an injection-size meter
💬 UserPromptSubmitEvery messageClassifies content (decision, incident, win, 1:1, architecture, person, project update) and injects routing hints; also hands the agent the Stop report from the previous turn
✍️ PostToolUseAfter writing .mdValidates frontmatter + wikilinks, blocks misplaced memory files, flags oversized notes (split, don't trim) and write-time topic clusters
💾 PreCompactBefore context compactionBacks up session transcript to thinking/session-logs/
🏁 StopAfter every responseChecklist + concrete drift findings (same hygiene scan as SessionStart), shown once per session and again only when they change: you see a short summary, one line per section, and the agent gets the full report with your next message and decides whether to act; hands drift to om-tidy

TIP
You just talk. The hooks handle the routing.

🧩 The Claude Code mod

On Claude Code 2.1.287 or later, the vault also ships a mod: .claude/skills/obsidian-mind/, a plugin whose code runs inside Claude Code. It runs the vault's own hook scripts and changes only how their output reaches the session:

  • Session context arrives as an instruction file, the way AGENTS.md does. It is re-read whole after compaction and /clear (as hook output it shrinks to a pointer), it reaches general-purpose subagents (hook output never does), and it is not cut at Claude Code's 10,000-character hook limit. Its budget is eager_layer_instruction_budget_bytes in vault-manifest.json; sections that never shrink, such as open tasks, can still take it past that. /memory lists it as .claude/session-context.md.

  • The Stop report becomes one line under the answer. When the findings change you see a line beneath Claude's reply, starting with the mod's name — obsidian-mind: vault check: …. Claude Code joins the names of the plugins that answered on Stop with +, so when another plugin also answers, the label reads obsidian-mind+<plugin>: vault check: …. Claude gets the full report with your next message, unseen. A finding marked urgent gets Claude's attention at once instead, once per message you send (a second one waits inside the report); the template's own report has none.

For each event it handles, the mod tells the matching hook to stand down. Wherever the mod does not load, the hooks run exactly as before: Codex and Gemini, older Claude Code, a session started in a vault subfolder (launch from the vault root, or /cd there and /clear), or a folder you have not trusted. It loads only after you accept Claude Code's trust prompt for the vault.

A mod is unsandboxed code that runs with your permissions, so check what it does before trusting the folder: claude plugin validate .claude/skills/obsidian-mind lists every event it hooks and every call it makes (it runs the vault's own scripts, writes the context file, remembers in its own store which report each session was shown, and can submit a prompt for an urgent finding, nothing else). To turn it off, add "enabledPlugins": { "obsidian-mind@skills-dir": false } to .claude/settings.local.json.

<!-- mod-validate:start -->

The two lines that matter in its output, for this version of the mod:

  ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit, turn.start
  ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get (via setShown, shownFor), $.store.set (via setShown), $.ui.invalidate

<!-- mod-validate:end -->

⚡ Token Efficiency

obsidian-mind does not dump your entire vault into context. It uses tiered loading to keep token costs low:

TierWhatWhenCost
AlwaysAGENTS.md (plus .claude/rules/ on Claude Code) + SessionStart context (North Star excerpt, git summary, tasks, vault file listing)Session startcapped by the manifest budget, itself held under Claude Code's 10,000-character hook output cap; the meter reports the real size every session
On-demandQMD semantic search resultsWhen the agent needs specific contextTargeted
TriggeredClassification routing hintsEvery message~100 tokens
TriggeredPostToolUse validationAfter .md writes~200 tokens
RareFull file readsOnly when explicitly neededVariable

SessionStart loads lightweight context — small excerpts from key files, filenames, and git summary — not full note contents. Five mechanisms keep the eager layer honest as the vault grows: source-aware injection (resume/compact re-inject only volatile sections — the static bulk is already in-conversation), an injection-size meter as the last line of every injection (you always see what context costs), an injection budget that enforces what the meter measures (over the ceiling, the cheapest-to-lose sections degrade to pointers — and the meter names every one it dropped, because a silent loss is worse than the bloat), a single hook spawn per write (the QMD refresh rides the validation hook), and listing collapse (any folder past a note-count threshold folds to one count line, so a vault can't outgrow the ceiling through whichever folder nobody thought to configure). Both the budget and the threshold are tunable in vault-manifest.json, and the budget is held under Claude Code's 10,000-character hook output cap whatever it is set to, because past the cap a session receives only a 2,000-character preview. The agent queries by meaning via QMD before reading files, so it pulls only what's relevant. The classification hook is one lightweight Node call per message. The validation hook only fires on markdown writes and skips excluded paths.

🌐 Using with Other Agents

obsidian-mind works with Claude Code, Codex CLI, Gemini CLI, Antigravity and Grok. Every agent reads one instruction file, AGENTS.md. The hook scripts in .claude/scripts/ and the commands in .claude/commands/ are agent-agnostic — pure Markdown, TypeScript, and shell with no SDK dependencies. .claude/rules/ holds guidance only Claude Code can use (its skills, subagents, auto-memory index, hooks and mod), and the sections AGENTS.md moved out to stay under its size cap. Claude Code and Grok load every file there at session start, so Claude keeps the whole manual; the other agents read a moved section when AGENTS.md points them to it.

AgentReads AGENTS.mdFrom a vault subfolderAlso loadsKnown gaps
Claude CodeNatively, when no CLAUDE.md is presentYes, but @ imports are dropped (upstream).claude/rules/*.mdA CLAUDE.md beside or above AGENTS.md is read instead, silently: see Troubleshooting
Codex CLINativelyYes (walks up to the git root)—Stops reading past ~32 KiB, silently
Gemini CLIThrough .gemini/settings.json (context.fileName)No: see Troubleshooting—What a live session does with it is not yet observed (TBC)
AntigravityNativelyYes— (not .claude/rules/, not CLAUDE.md)Stops reading past ~24,000 bytes, silently. Has a workspace-trust dialog; whether it gates AGENTS.md is TBC (a headless -p run loaded it in a never-opened folder). Its .agents/ manifests are not exercised here (TBC)
GrokOnce the folder is trustedYes, once trusted.claude/rules/*.md and CLAUDE.md, plus project .claude/settings.json hooks and user-level ~/.claude skills, permissions and hooksAn untrusted folder gets no project instructions at all: see Troubleshooting

Observed with scratch vaults as of 2026-10-06 (Claude Code 2.1.291, Codex CLI 0.160.0, Gemini CLI 0.37.1 discovery log, Antigravity 1.3.0, Grok 1.0.46); the measurements are in #321. A test keeps AGENTS.md under 22,000 bytes, so no agent cuts it off.

Claude Code — full support. Hooks, commands, subagents, and the memory system all work out of the box.

Codex CLI — hook config at .codex/hooks.json wires the same hook scripts Claude Code uses — session context, message classification, and write validation work automatically. Commands work as regular prompts (e.g. type om-standup without the / prefix).

Codex runs a project's hooks only once two kinds of trust are in place (observed with Codex CLI 0.160.0 as of 2026-10-13, #345):

  • The vault folder is trusted. Codex records this in ~/.codex/config.toml under [projects.'<path>']. On Windows the key is the \\?\-prefixed path, e.g. [projects.'\\?\C:\path\to\vault'].

  • Each hook is approved. Codex stores each approval as a trusted_hash under [hooks.state]. Approving them in an interactive codex session is expected to write these (inferred: the #345 check stood in for them with --dangerously-bypass-hook-trust, a flag for one non-interactive run).

Until both are in place, Codex runs no hook, and the session gets no vault context.

Gemini CLI — hook config at .gemini/settings.json maps Gemini's event names to the shared hook scripts, and its context.fileName setting points Gemini at AGENTS.md. Gemini reads custom commands only as TOML, so .gemini/commands/ holds a generated wrapper per command that injects the command's .md (node --experimental-strip-types .scripts/gemini-commands.ts regenerates them).

Antigravity and Grok — read AGENTS.md for vault conventions. Their hook, skill and command layers are tracked in #322 and #323.

Other agents (Cursor, Windsurf, GitHub Copilot, JetBrains AI) — read AGENTS.md for vault conventions. Hook support varies by agent.

NOTE
Hooks, commands, subagent prompts, and vault memory (brain/) are all agent-agnostic. Only the ~/.claude/ auto-memory loader and the obsidian-mind mod are Claude Code-specific. .claude/rules/hooks-per-agent.md maps each hook to each agent's event.


🧠 Reach Your Vault From Any Repo

Your vault normally only helps while you are sitting in it. The om MCP server changes that: a coding session in any other repository can search your notes, read them, follow the graph, and record what it learned back into the vault.

Step 1 — register the server once, for every repo
claude mcp add --scope user om node "/absolute/path/to/your-vault/.claude/scripts/om-mcp.mjs"

User scope is the right default here: it registers in your own config, so the server is available in every directory on the machine with nothing added to any repository. No env var is needed either, because the launcher resolves the vault from its own location.

<details> <summary>Per-repo alternative, and one trap to avoid</summary>

If you would rather a specific repo carry the wiring (so a teammate gets it on clone), put it in that project's .mcp.json:

{
  "mcpServers": {
    "om": {
      "command": "node",
      "args": ["/absolute/path/to/your-vault/.claude/scripts/om-mcp.mjs"]
    }
  }
}

Note the cost: that path is absolute and machine-specific, so committing it breaks every collaborator and every other machine of yours.

Use an absolute path, and do not copy the relative one. This vault's own .mcp.json registers qmd with a relative path (.claude/scripts/qmd-mcp.mjs). That is correct there, because a relative path resolves against the current working directory and a session in the vault is already in it. Reused for om in a consuming project, the same shape silently resolves against that project instead, and the server never starts.

</details>

Step 2 — point the consuming project at the vault

Add this to that project's own instruction file (CLAUDE.md or AGENTS.md), filling in the triggers:

## Where design decisions live

Design rationale for this project is recorded outside this repo, reachable
through the `om` MCP server.

- **`search`** reaches the written record: why a choice was made, what was
  rejected, what a constraint was set against. **Start here.**
- **`expand`** shows a known note's links and backlinks, which is cheaper than
  searching again for its neighbourhood.
- **`recall`** returns short durable lessons scoped to this project. It is empty
  until sessions put things in it, so early on it returns nothing, and that is
  *not* evidence the record is missing.
- **`health`** when something that should be there cannot be found. Every failure
  in this layer looks identical from outside (no results), and this tells them
  apart.

Consult the record before changing:

- <the storage format or schema>
- <the ID or key semantics>
- <the public surface: CLI flags, API shape, exported names>

If that record and this repo disagree, the record holds the *why*. Reconcile
before changing behaviour.

**Say what came back, in whatever you write before implementing:** a plan, a
design note, an issue comment. Name the recorded decisions the work rests on,
anything you found that argues *against* the approach, and an explicit "nothing
recorded on this" when the record is empty, which is a finding rather than a
blank to skip. Consulting once at the start of a task is consulting at the
moment you know least about what you will need; writing the result down moves it
to the moment you commit, while a contradiction is still free to fix.

## Recording what you learn

Two tools, and picking the wrong one is the common mistake. The test is whether
it would help someone working on a **different** project.

- **`remember`** stores a durable lesson: a constraint you discovered, a gotcha
  that cost time, a rule that generalises. Set `confidence`
  (`verified` / `inferred` / `unverified`) honestly and supply `verification`
  when you claim `verified`. For something specific to this project use
  `scope: "project"` with `projects: ["<this-repo>"]`. Reach for
  `scope: "platform"` before `"general"`: a dependency's quirk or a language's
  rule is platform-level however hard-won, and `general` claims it would help
  someone whose stack shares nothing with yours. When you do claim `general`,
  supply `generality` saying why, the same way `verification` backs `verified`.
- **`record_work`** files what happened *here*: changes, decisions and the
  alternatives rejected, what was learned, what is still open, how it was
  verified.

A dependency limitation that would bite any project is a `remember`. "Landed the
watch engine and here is what it cost" is a `record_work`. Do both when both are
true.

Three to five triggers, and name specific nouns. Consult it before changing the storage format, ID semantics, or the CLI surface works. Check the vault for context is exactly the advisory phrasing that gets skipped, because it gives the model nothing to match against the task in front of it.

Anchor the consultation to an artifact, not to a moment. "Consult before changing X" fires once, at the start of a task, which is when the session knows least about what it will need. Requiring the result to appear in whatever the project writes before implementing turns consultation from something a session intends to do into something an artifact is incomplete without. Measured across a day of real use: captures ran roughly four times heavier than consultations, because remember fires on an event (you just learned something) while consulting fires on intention. Event-triggered behaviour happens; intention-triggered behaviour decays.

TIP
Do not lead with recall. It returns memories, and the store is empty on a project's first session by construction: remember refuses when called from inside the vault, since a memory written there would be scoped to the vault and reach nobody. So memories only ever arrive from outside. A snippet that promises recall will return this project's decisions therefore returns nothing on the first run, and fails silently as "no results" rather than as an error.

IMPORTANT
Both steps are required, and the second is not paperwork. Measured: with the server wired and no repo-side instruction, a session made zero vault calls and implemented a design the vault had recorded as explicitly rejected. With the instruction present, it refused and cited the note.

A prohibition in the MCP instructions field propagates into the calling session reliably. A positive "go consult the vault" is advisory and gets skipped whenever a nearer source exists. The server can stop a session doing something; only the project's own law makes one go looking.

That asymmetry decides which side a rule belongs on, and it matters most for anything you would rather not advertise. Writing "this repo deliberately excludes X" into a public repo announces the withholding and points at exactly what is being withheld. The same rule as a prohibition in instructions reaches every session invisibly, and the repo says nothing at all. Routing belongs repo-side because it has to; constraints belong server-side because they can.

What the session gets: search (semantic + keyword), expand (a note's links and backlinks), recall (durable lessons scoped to that repo), remember (record a lesson), record_work (file what happened), reason (judgement across several notes), and health (is the wiring intact?). Plus your notes as readable resources.

reason is the one that thinks. The others retrieve; this one reads the vault with a second Claude session and answers questions that need judgement across notes — is what I am about to do consistent with what these six notes decided? It seeds itself from search, so you never have to search first. It runs on your own CLI default model, so the vault answers at the level you are already working at, and nothing about it is capped: it is Claude, on your machine, under your auth. Every call is logged with its cost, turns, model and wall time. Its answers are marked confidence: inferred and are never recorded as memories on their own — your session decides whether any of it is worth keeping.

Repos are identified by folder name, which is right until it isn't — two repos both called api share one identity and therefore each other's memories. Drop a .om-project file with a distinct name at the repo root to separate them; health tells you which repo it thinks is calling and where that name came from.

Cross-repo memory with an epistemic contract. A lesson learned in one repo reaches the repos it was scoped to and no others. Reach is declared at write time, never guessed at read time, so a sibling project does not inherit another's constraints. Everything recorded carries confidence: verified | inferred | unverified, dated volatile facts, and provenance derived server-side — a session cannot claim to be a project it is not. Corrections supersede rather than overwrite, so the store gets more trustworthy as it grows instead of accumulating contradictions.

Which notes it serves. Your vault, your notes, your session — the default is simply what your vault already declares as your content (user_content_roots), at the granularity you wrote it (work/active/, not all of work/). Set mcp_exposed_roots in vault-manifest.json only if this vault holds material that is not yours to share — employer-confidential notes, a client's data. A note tagged private is never served, and memories are never served as ordinary notes, since they carry their own scope. One case reads across the two: when a capture's promoted: marker carries an anchor, recall serves that block out of brain/ so a foreign repo gets the corrected version rather than the capture as first written — bounded by this same policy, so a private or withheld note is refused there too.

Every read is logged with the calling repo, so "what did that session actually see" is answerable afterwards.

NOTE
Keeping vault material out of a public PR is the contract's job, not the exposure list's — a session can read your vault directly regardless. The prohibition injected into the calling session is the part that measurably holds.

Want the mechanics? ARCHITECTURE.md → Reaching the Vault From Another Repo walks the whole layer with diagrams: the four MCP surfaces and why the non-tool ones matter, the identity handshake, a search call traced end to end, how each memory's reach is evaluated, the write path, and every failure mode against the signal that reveals it.


📅 Daily Workflow

Morning: Run /om-standup. Your agent loads your North Star, active projects, open tasks, and recent changes. You get a structured summary and suggested priorities.

Throughout the day: Talk naturally. Mention a decision you made, an incident that happened, a 1:1 you just had, a win you want to remember. The classification hook nudges the agent to file each piece correctly. For bigger brain dumps, use /om-dump and narrate everything at once.

End of day: Say "wrap up" and the agent invokes /om-wrap-up — verifies notes, updates indexes, checks links, spots uncaptured wins.

Weekly: Run /om-weekly for cross-session synthesis — North Star alignment, patterns, uncaptured wins, and next-week priorities. Run /om-vault-audit to catch orphan notes, broken links, and stale content.

Review season: Run /om-review-brief manager and get a structured review prep document with all the evidence already linked.


🛠️ Commands

Defined in .claude/commands/. Run them in Claude Code, Codex CLI, or Gemini CLI.

CommandWhat It Does
/om-standupMorning kickoff — loads context, reviews yesterday, surfaces tasks, suggests priorities
/om-dumpFreeform capture — talk naturally about anything, routes it all to the right notes
/om-wrap-upFull session review — verify notes, indexes, links, suggest improvements
/om-humanizeVoice-calibrated editing — makes Claude-drafted text sound like you wrote it
/om-weeklyWeekly synthesis — cross-session patterns, North Star alignment, uncaptured wins
/om-capture-1on1Capture a 1:1 meeting transcript into a structured vault note
/om-incident-captureCapture an incident from Slack/channels into structured notes
/om-slack-scanDeep scan Slack channels/DMs for evidence
/om-peer-scanDeep scan a peer's GitHub PRs for review prep
/om-review-briefGenerate a review brief (manager or peer version)
/om-self-reviewWrite your self-assessment for review season — projects, competencies, principles
/om-review-peerWrite a peer review — projects, principles, performance summary
/om-tidySelf-maintenance — acts on every hygiene flag: archive, group, split. Never deletes, never commits
/om-correctSweep a corrected fact — applies it at the single source, links restatements, preserves dated records
/om-vault-auditAudit indexes, links, orphans, stale context
/om-vault-upgradeUpgrade this vault through ShardMind (keeps your local improvements), or import content from an existing vault — version detection, classification, migration
/om-prep-1on1Prep for an upcoming 1:1 — load person context, open items, suggested agenda
/om-meetingPrep for any meeting by topic — subject-forward briefing with open items and considerations
/om-intakeProcess meeting notes inbox — classify and route to the right vault notes
/om-project-archiveMove a completed project from active/ to archive/, update indexes

🤖 Subagents

Specialized agents that run in isolated context windows. They handle heavy operations without polluting your main conversation.

AgentPurposeInvoked by
brag-spotterFinds uncaptured wins and competency gaps/om-wrap-up, /om-weekly
context-loaderLoads all vault context about a person, project, or conceptDirect
cross-linkerFinds missing wikilinks, orphans, broken backlinks/om-vault-audit
people-profilerBulk creates/updates person notes from Slack profiles/om-incident-capture
review-prepAggregates all performance evidence for a review period/om-review-brief
slack-archaeologistFull Slack reconstruction — every message, thread, profile/om-incident-capture
vault-librarianDeep vault maintenance — orphans, broken links, stale notes/om-vault-audit
review-fact-checkerVerify every claim in a review draft against vault sources/om-self-review, /om-review-peer
vault-migratorClassify, transform, and migrate content from a source vault/om-vault-upgrade

NOTE
Subagents are defined in .claude/agents/. You can add your own for domain-specific workflows.


📊 Performance Graph

The vault doubles as a performance tracking system:

  1. Competency notes in perf/competencies/ define your org's competency framework — one note per competency

  2. Work notes link to competencies in their ## Related section, annotated with what was demonstrated

  3. Backlinks accumulate automatically — review prep becomes reading the backlinks panel on each competency note

  4. Brag Doc aggregates wins per quarter with links to evidence notes

  5. /om-peer-scan deep-scans a colleague's GitHub PRs and writes structured evidence to perf/evidence/

  6. /om-review-brief generates a full review brief by aggregating everything: brag entries, decisions, incidents, competency evidence, and 1:1 feedback

TIP
To get started: create competency notes from the template, then link your work notes to them as you go. The graph does the rest.


📋 Bases

The bases/ folder contains database views that query your notes' frontmatter properties. They update automatically as notes change.

BaseShows
Work DashboardActive projects filtered by quarter, grouped by status — plus a Stale Actives view (active but untouched 14+ days)
Recently TouchedEvery note by real modified time — the correct answer to "what did I work on recently" (filename dates lie for living notes)
IncidentsAll incidents sorted by severity and date
People DirectoryEveryone in org/people/ with role, team
1:1 HistoryAll 1:1 notes sortable by person and date
Review EvidencePR scans and evidence grouped by person and cycle
Competency MapCompetencies with evidence counts from backlinks
TemplatesQuick access to all templates

Home.md embeds these views, making it the vault's dashboard.


📁 Vault Structure

Home.md                 Vault entry point — embedded Base views, quick links
AGENTS.md               Operating manual — read by every agent, every session
vault-manifest.json     Template metadata — version, structure, schemas
.shardmindignore        Files excluded from `shardmind install` (CONTRIBUTING, translations, marketing media)
CHANGELOG.md            Version history
CONTRIBUTING.md         Template development checklist
README.md               Product documentation
LICENSE                 MIT license

bases/                  Dynamic database views (Work Dashboard, Incidents, People, etc.)

work/
  active/               Current projects (1–3 files at a time)
  archive/YYYY/         Completed work, organized by year
  incidents/            Incident docs (main note + RCA + deep dive)
  1-1/                  1:1 meeting notes — named <Person> YYYY-MM-DD.md
  Index.md              Map of Content for all work

org/
  people/               One note per person — role, team, relationship, key moments
  teams/                One note per team — members, scope, interactions
  People & Context.md   MOC for organizational knowledge

perf/
  Brag Doc.md           Running log of wins, linked to evidence
  brag/                 Quarterly brag notes (one per quarter)
  competencies/         One note per competency (link targets)
  evidence/             PR deep scans, data extracts for reviews
  <cycle>/              Review cycle briefs and artifacts

brain/
  North Star.md         Goals and focus areas — read every session
  Memories.md           Index of memory topics
  Key Decisions.md      Significant decisions and their reasoning
  Patterns.md           Recurring patterns observed across work
  Gotchas.md            Things that have gone wrong and why
  Skills.md             Custom workflows and slash commands

reference/              Codebase knowledge, architecture maps, flow docs
thinking/               Scratchpad for drafts — promote findings, then delete
templates/              Obsidian templates with YAML frontmatter

.claude/
  rules/                Claude Code-only guidance, loaded beside AGENTS.md
  commands/             20 slash commands
  agents/               10 subagents
  scripts/              Hook scripts + charcount.ts utility
  skills/               Obsidian + QMD skills, and the obsidian-mind mod
  settings.json         5 hooks configuration

.scripts/                Vault-level tooling — QMD bootstrap (run once on a fresh clone)

.shardmind/             ShardMind sidecar — only used if installed via `shardmind install`
  shard.yaml            Manifest (name, version, modules, hooks)
  shard-schema.yaml     Wizard values + module gating
  hooks/                bootstrap (git init + QMD), personalize (North Star), post-update

NOTE
.shardmind/ is additive, not load-bearing. A clone-and-open vault never reads it; only the shardmind CLI does. Delete it and the vault keeps working. See the v6 layout contract in shardmind/docs/SHARD-LAYOUT.md.


📝 Templates

Templates with YAML frontmatter, each including a description field for progressive disclosure:

  • Work Note — date, description, project, status, quarter, tags

  • Decision Record — date, description, status (proposed/accepted/deprecated), owner, context

  • Thinking Note — date, description, context, tags (scratchpad — delete after promoting)

  • Competency Note — date, description, current-level, target-level, proficiency table

  • 1:1 Note — date, person, key takeaways, action items, quotes

  • Incident Note — date, ticket, severity, role, timeline, root cause, impact


🔧 What's Included

🧩 Obsidian Skills

kepano/obsidian-skills pre-installed in .claude/skills/:

  • obsidian-markdown — Obsidian-flavored markdown (wikilinks, embeds, callouts, properties)

  • obsidian-cli — CLI commands for vault operations

  • obsidian-bases — Database-style .base files

  • json-canvas — Visual .canvas file creation

  • defuddle — Web page to markdown extraction

🔍 QMD Skill

A custom skill in .claude/skills/qmd/ that teaches the agent to use QMD semantic search proactively — before reading files, before creating notes (to check for duplicates), and after creating notes (to find related content that should link to it).


🎨 Customize It

This is a starting point. Adapt it to how you work:

WhatWhere
Your goalsbrain/North Star.md — grounds every session
Your orgorg/ — add your manager, team, key collaborators
Your competenciesperf/competencies/ — match your org's framework
Your tools.claude/commands/ — edit for your GitHub org, Slack workspace
Your conventionsAGENTS.md — the operating manual, evolve it as you go (.claude/rules/ for Claude Code-only additions)
Your domainAdd folders, subagents in .claude/agents/, or classification rules in .claude/scripts/

IMPORTANT
AGENTS.md is the operating manual. When you change conventions, update it — your agent reads it every session.


🔄 Upgrading

Ask your agent

The easiest way — just tell your agent:

Update this vault to the latest obsidian-mind from https://github.com/breferrari/obsidian-mind

The agent will pull the latest changes, resolve conflicts, and update infrastructure files. Works with Claude Code, Codex CLI, or Gemini CLI.

Updating an existing clone

If you cloned the repo directly:

cd your-vault
git pull origin main

New files appear automatically and hook scripts are updated in place. If you edited CLAUDE.md, the pull stops on it, because you changed a file the template deleted: move your additions out as Troubleshooting describes, then delete it.

Updating a fork

If you forked the repo:

git remote add upstream https://github.com/breferrari/obsidian-mind.git
git fetch upstream
git merge upstream/main

Resolve any conflicts in files you customized (typically AGENTS.md, brain/ notes; a CLAUDE.md you edited conflicts with its deletion, see Troubleshooting). Infrastructure files (.claude/scripts/, .codex/, .gemini/) should merge cleanly.

Adopting an existing clone into ShardMind (v5.x → v6)

Already cloned obsidian-mind and want the wizard, optional modules, and three-way-merge upgrades without losing your customizations? shardmind adopt reconciles your existing vault into a managed v6 install — keeping every byte of your edits and only adding the .shardmind/ sidecar + shard-values.yaml:

npm install -g shardmind
shardmind adopt github:breferrari/obsidian-mind

The 2-way diff UI walks you through any local changes, asks per-file what to keep, then writes the engine metadata. Result: a v6-managed vault with your existing content intact, ready for shardmind update from there forward. No re-cloning.

Migrating from an older vault (or any other vault)

Using a pre-v5 obsidian-mind, or migrating from a totally different Obsidian vault? The /om-vault-upgrade command migrates your content into the latest template:

# 1. Clone the latest obsidian-mind
git clone https://github.com/breferrari/obsidian-mind.git ~/new-vault

# 2. Open it in your agent
cd ~/new-vault && claude   # or codex, or gemini

# 3. Run the upgrade pointing to your old vault
/om-vault-upgrade ~/my-old-vault

The agent will:

  1. Detect your vault version (v1–v3.x, or identify it as a non-obsidian-mind vault)

  2. Inventory every file — classify as user content, scaffold, infrastructure, or uncategorized

  3. Present a migration plan — you see exactly what will be copied, transformed, and skipped

  4. Execute after your approval — transforms frontmatter, fixes wikilinks, rebuilds indexes

  5. Validate — checks for orphans, broken links, missing frontmatter

Your old vault is never modified. Use --dry-run to preview the plan without executing.

NOTE
Works with any Obsidian vault, not just obsidian-mind. For non-obsidian-mind vaults, the agent reads each note and classifies it semantically — routing work notes, people, incidents, 1:1s, and decisions to the right folders.


🩺 Troubleshooting

Claude Code ignores the vault's conventions. Claude Code reads a CLAUDE.md in place of AGENTS.md and skips AGENTS.md without saying so. Two ways that happens:

  • A leftover CLAUDE.md in the vault. An update keeps a CLAUDE.md you edited. The session hygiene flags say CLAUDE.md present beside AGENTS.md, and /om-vault-upgrade lists it under Move out. Move what you added to .claude/rules/local.md (or into AGENTS.md if every agent needs it, keeping AGENTS.md under 23 KB, where the hygiene flags start: Antigravity stops reading it at ~24,000 bytes), then delete CLAUDE.md. Nothing is merged automatically.

  • A CLAUDE.md in a folder above the vault. Claude Code reads that one too, and AGENTS.md is skipped. The hygiene flag cannot see it, because it checks the vault root only. Move the vault out from under that folder, or move that file's content somewhere it applies only where you want it.

Gemini CLI loads no instructions when started in a vault subfolder. Gemini's context search walks up the folders, but its settings lookup does not, so a session started in a subfolder never reads the vault's .gemini/settings.json and never learns to look for AGENTS.md. Start Gemini from the vault root.

Gemini ignores what you added to GEMINI.md. Gemini now reads AGENTS.md (through .gemini/settings.json), so a leftover GEMINI.md is never loaded. The hygiene flags say GEMINI.md present beside AGENTS.md, and /om-vault-upgrade lists it under Move out. Move your additions to AGENTS.md, then delete it.

Grok reads nothing from the vault. Grok loads project instructions only from a trusted folder. Trust the vault once: run /hooks-trust in a Grok session, or launch with --trust. One grant covers MCP, LSP, hooks, project instructions and project skills.


🗺️ Roadmap

Issues and bug reports welcome. Code contributions go through assignment: comment on an issue to ask for it, and open a PR once it's assigned to you. A PR for an issue not assigned to you is closed without review. See CONTRIBUTING.md.


🙏 Design Influences


👤 Author

Created by Brenno Ferrari — Senior iOS Engineer in Berlin, building developer tools with Claude Code.


📄 License

MIT

ARCHITECTURE.md

Architecture

This document describes how obsidian-mind fits together — the load-bearing seams, the design choices behind them, and where to extend the system. It is aimed at contributors and anyone forking the template who wants to customize it without breaking the mechanics.

It is not a folder tour. For the day-to-day layout, read AGENTS.md. For the user-facing story, read README.md.


System Overview

obsidian-mind is a plain Obsidian vault with four systems layered on top:

  1. The vault itself — Markdown files, frontmatter, wikilinks. Portable, git-tracked, Obsidian-browsable.

  2. A hook pipeline — small TypeScript scripts invoked by the agent harness at lifecycle events (session start, every message, after writes, before compaction, after each response; Gemini at session end).

  3. A semantic search layer (QMD) — a separate CLI + SQLite index + MCP server, all scoped to a named index read from vault-manifest.json.

  4. The om MCP server — the vault as a service, so a session running in a different repository can search it, read notes, follow the graph, and record back into it.

The layers communicate through one coordination point: vault-manifest.json. It declares the template version, the QMD index name, which folders the server serves, where memories live, and the boundary between infrastructure files (shipped by the template) and user content (created by the human).

The fourth layer is the one that changes the shape of the system. Layers 1–3 assume the agent is sitting in the vault; om removes that assumption, which is why identity — "which repo is asking?" — becomes a first-class concept below.

flowchart TB
    User["Human operator"]
    subgraph Agent["Agent session INSIDE the vault"]
        Harness["Agent harness"]
        Hooks["Hook pipeline<br/>(.claude/scripts/)"]
    end
    subgraph Foreign["Agent session in ANOTHER repo"]
        FSession["Coding session"]
        FConfig[".mcp.json entry<br/>+ that repo's own instruction file"]
    end
    subgraph Vault["Obsidian vault"]
        Manifest["vault-manifest.json<br/>(source of truth)"]
        Notes["Markdown notes<br/>(work/, perf/, brain/, org/)"]
        Memories["memories/<br/>(cross-repo, scope declared per memory)"]
        Config[".claude/ .codex/ .gemini/<br/>(hook configs)"]
    end
    OM["om MCP server<br/>(om-mcp.ts)"]
    QMD["QMD<br/>(named SQLite index + embeddings)"]
    Obsidian["Obsidian app<br/>(graph, Bases, CLI)"]

    User <--> Agent
    User <--> Foreign
    User <--> Obsidian
    Harness --> Hooks
    Harness -->|"mcp__qmd__*"| QMD
    Hooks --> Manifest
    Hooks --> Notes
    Hooks --> QMD
    Config --> Hooks
    FConfig --> FSession
    FSession <-->|"MCP over stdio"| OM
    OM --> Manifest
    OM --> Notes
    OM --> Memories
    OM -->|"MCP client"| QMD
    QMD --> Notes
    QMD --> Memories
    Obsidian --> Notes

The vault is the persistent state. Everything else is machinery around it.


Division of Responsibility

Two actors do the work, and the boundary between them is the most important design choice in the system.

Procedural code owns the environment. Hooks in .claude/scripts/ classify messages, validate writes, maintain the QMD index, inject context at session start, and back up transcripts before compaction. None of this logic is in the agent's head. It runs identically whether the agent is Claude Code, Codex, or Gemini, and it produces deterministic, testable behavior — every contract in .claude/scripts/lib/ is locked by a unit suite, run in CI on every push.

The agent owns content. Writing notes, choosing where to file them, adding wikilinks, updating indexes, promoting thinking drafts, drafting review briefs — these are judgments, not rules, and they live with the agent. AGENTS.md documents the conventions the agent should follow; it does not replace the agent's judgment.

The two halves meet at small, well-defined handoffs: hooks inject context and routing hints through stdout, the agent reads the vault and calls Write or Edit. Neither side reaches across the boundary. This is what keeps the hooks portable (no agent-specific logic) and keeps the agent's tokens pointed at judgment rather than bookkeeping.


Design Principles

Four ideas shape every decision in this template. When a change breaks one of them, it needs a very good reason.

1. Graph-first, not folder-first

Folders group by purpose. Links group by meaning. A note lives in one folder (its home) but links to many notes (its context). Competency notes stay definitional and receive evidence through backlinks — review prep becomes reading the backlinks panel on each competency. This is why every new note must link to at least one existing note, and why the agent is instructed to treat orphan notes as bugs.

2. Vault-first memory

All durable knowledge lives in the vault, inside brain/ topic notes. The agent-specific memory indexes (~/.claude/.../MEMORY.md) are pointers to vault locations, never the storage themselves. This keeps memory git-tracked, machine-portable, and visible in the Obsidian graph.

The om server extends this outward rather than around it. A session in another repo writes into memories/ — still Markdown, still in the graph, still yours to open in Obsidian — instead of into a private store belonging to some memory service. A generic memory server solves the wrong half of the problem: it gives you a second knowledge base that is not your vault.

Because those memories are written from many repos and read by many repos, each one declares its reach when written — which projects and platforms it applies to. A reader never widens what a writer declared. That is a relevance rule, not an access-control rule: the store is the user's, the sessions are the user's, and the question being answered is "which lessons bear on the repo asking?" — not "who is allowed to know this?"

3. Progressive disclosure

SessionStart injects a small block of lightweight context (North Star excerpt, git summary, tasks, file listing). Full note contents are pulled on demand via QMD semantic search. A full file read is a last resort, not a default.

Session cost stays flat regardless of vault size because it is enforced, not merely intended. Two of those inputs grow with the vault — the file listing grows with every note, the North Star excerpt with every status edit — so without a ceiling the eager layer drifts upward a little every day and nobody notices until a session is paying for it. A byte budget holds the total; over it, the cheapest-to-lose sections degrade to pointers, worst-priority first, and the closing size meter names each one it dropped. Line-based caps cannot do this job: shortening entries under a line cap just slides the window deeper and refills it. Budget and listing-collapse threshold are set in vault-manifest.json.

The budget has a hard ceiling it cannot be raised past: Claude Code's hook output cap. Claude Code caps a hook's context at 10,000 characters, with no setting to raise it. Over the cap, the session gets a file path and the first 2,000 characters, and Claude Code does not ask the model to read the rest. A budget above the cap never binds: nothing degrades, the meter on the last line is the first thing lost, and the meter the user reads still looks healthy (#254). So session-start.ts clamps any budget, set or unset, to the cap less the JSON envelope and room for the meter (about 9,000; a section is sized as the larger of its UTF-8 bytes and its JSON-encoded length, so this holds the character cap too). When it clamps a configured value, the meter says so. The sections that never degrade (the date, open tasks, hygiene) could still pass the cap on their own, so the whole output is fitted as a backstop: cut on a line, marked where it was cut, the meter still last and saying it was "truncated to fit the hook output cap".

Within that ceiling, the budget is a runaway guard, not a squeeze. The meter is the detector; the budget is the emergency brake. A ceiling low enough to bite in normal use degrades your context every session instead of catching a problem. If the budget starts firing, look at what grew. It can no longer be raised past the cap, so the fix is to make the section smaller or let it degrade to its pointer. Content a session must always have, in full, belongs in an instruction file (AGENTS.md, plus .claude/rules/ on Claude Code), which this cap does not apply to.

The same asymmetry decides what may degrade. Rank the eager layer by value density, not size: filenames are the cheapest bytes (one Glob rebuilds them), so the listing surrenders first. Anything irreplaceable — identity, personal context, correctness guards — carries no fallback and is never traded for plumbing. Optimizing this layer means removing duplication, not information, which is exactly why re-entry via resume/compact drops the static bulk: it is already in the conversation, so omitting it loses nothing.

4. Agent-agnostic core

The hook scripts, subagent prompts, command definitions, and vault conventions are pure Markdown and TypeScript with no SDK dependencies. Each agent (Claude Code, Codex CLI, Gemini CLI) brings its own config file pointing at the same scripts. Only the ~/.claude/ auto-memory loader is Claude Code-specific.


The Manifest as Source of Truth

vault-manifest.json is the one file that every layer reads. It answers eight questions:

QuestionField
What version of the template is this?version, released, version_fingerprints
What does QMD call its store?qmd_index, qmd_context, qmd_min_version
Which files are template infrastructure?infrastructure[]
Which files are user content?user_content_roots[], scaffold{}
What frontmatter is required for each note type?frontmatter_required{}
Which notes does the om server serve, and where do memories live?mcp_exposed_roots[], mcp_never_expose[], memory_root, mcp_inbox
How much context may the eager layer spend?eager_layer_budget_bytes (as hook output, held under the 10,000-char hook cap), eager_layer_instruction_budget_bytes (when the mod delivers it as an instruction file), listing_collapse_threshold
Which model does a reason spawn run on?reason.model — unset means the user's own CLI default

The qmd_index field is the most load-bearing. Five independent callers read it, and they fail silently when they disagree — one writes to a store another never reads, which surfaces only as "0 documents" or as an empty search:

flowchart LR
    Manifest["vault-manifest.json<br/>qmd_index, or the folder slug"]
    Boot["qmd-bootstrap.ts<br/>(one-time setup)"]
    SessionStart["SessionStart hook<br/>(session-start.ts)"]
    MCP[".mcp.json wrapper<br/>(qmd-mcp.mjs)"]
    Refresh["Mid-session refresh<br/>PostToolUse / Stop / PreCompact<br/>→ qmd-refresh-run.ts worker"]
    OM["om MCP server<br/>(mcp-context.ts)"]
    Store[("QMD SQLite store<br/>named per vault")]

    Manifest --> Boot
    Manifest --> SessionStart
    Manifest --> MCP
    Manifest --> Refresh
    Manifest --> OM
    Boot -->|"create + first index"| Store
    SessionStart -->|"re-index at startup"| Store
    Refresh -->|"debounced refresh"| Store
    MCP -->|"search tools"| Store
    OM -->|"search, and re-index after a write"| Store

All five resolve the name through one function, resolveQmdIndex — the qmd_index field when pinned, otherwise the vault folder name slugified — so the vault coexists with other QMD-indexed projects on the same machine without collision. The template ships the field empty so two installs never share a store by default. Set qmd_index and the next bootstrap creates a fresh, isolated store under that name.

Routing every caller through one resolver is the fix for a real class of failure rather than a tidiness preference: a hardcoded or independently-derived name in any one of the five produces a vault that indexes into one store and searches another, with nothing anywhere reporting an error. health on the om server checks that the index it would query actually belongs to this vault.

The infrastructure[] vs user_content_roots[] split is what makes /om-vault-upgrade work. When importing from an older template, the migrator overwrites infrastructure files wholesale and preserves user content untouched.


Lifecycle Hooks

Five hooks run at different moments in a session. Each is a small Node script invoked via --experimental-strip-types (TypeScript executes directly, no build step).

sequenceDiagram
    participant User
    participant Agent
    participant Hooks
    participant Vault
    participant QMD

    User->>Agent: start session
    Agent->>Hooks: SessionStart
    Hooks->>Vault: read North Star, git log, tasks, file listing
    Hooks->>QMD: re-index (async)
    Hooks-->>Agent: inject a briefing, held under the byte budget

    loop each user message
        User->>Agent: prompt
        Agent->>Hooks: UserPromptSubmit
        Hooks->>Hooks: classify (decision, incident, win, 1:1, ...)
        Hooks-->>Agent: routing hints + any Stop report saved last turn
    end

    loop each Write/Edit to .md
        Agent->>Hooks: PostToolUse
        Hooks->>Hooks: validate frontmatter + wikilinks
        Hooks->>QMD: debounced refresh (detached)
        Hooks-->>Agent: warnings if invalid
    end

    Note over Agent,Hooks: if context fills
    Agent->>Hooks: PreCompact
    Hooks->>Vault: back up transcript to thinking/session-logs/

    loop each completed response
        Agent->>Hooks: Stop
        Hooks->>QMD: debounced refresh (detached)
        Hooks-->>User: short summary, one line per section, first time this session or when changed
        Hooks->>Hooks: save the full report for the next prompt
    end

A few specific design choices are worth calling out:

  • SessionStart injects, it does not load. It builds a briefing (filename listing, North Star excerpt, git summary, open tasks aggregated from work/active/ and the vault root) and hands it to the agent. Full note contents never flow through this hook. Its size is bounded by eager_layer_budget_bytes, held under Claude Code's 10,000-character hook output cap, and reported by the meter on the last line of every injection, so the cost is visible rather than assumed. When the template's Claude Code mod delivers the same briefing as an instruction file instead, the hook stands down and the briefing is bounded by eager_layer_instruction_budget_bytes, since instruction files are not under the hook cap (#264). The same backstop holds there, in bytes: sections that never degrade are cut at the budget plus the meter's room, and the meter says "truncated to fit the instruction budget". On the hook path the briefing goes out as one JSON object, {"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":…}}, for every agent (#340). Gemini parses a hook's stdout as JSON and takes context only from that field (per its hooks reference as of 2026-10-13, not observed), and Claude Code accepts the same shape, capping the additionalContext string rather than the raw stdout. Codex takes context from its additionalContext too (observed with Codex CLI 0.160.0 as of 2026-10-13, #345). Whether Grok does is unverified (TBC). So the budget and the backstop measure the JSON-encoded text (escaping counts), keeping the whole stdout under the cap for an agent whose cap is undocumented. The mod's deliver run stays plain text, since it becomes an instruction file. The open-tasks scan is filesystem-only so the hook never spawns the Obsidian CLI — that subprocess flashes the Electron app on macOS when no instance is running (#83).

  • UserPromptSubmit classifies, it does not route. It tags the prompt with hints like ARCHITECTURE discussion or DECISION; the agent decides where to file. Keeping the hook opinion-free means the routing logic lives in AGENTS.md, which is editable per-user without touching scripts.

  • QMD refresh is shared, debounced, and detached. Three hook entries fire the same refresh helper — PostToolUse (after .md writes), PreCompact (before transcript backup; writes tend to cluster before compaction), and Stop (after a response) — sharing one sentinel file so a burst of events produces at most one worker per debounce window. The actual indexing runs in .claude/scripts/qmd-refresh-run.ts as a detached, stdio-silent worker (qmd update → qmd embed → tail-chase qmd update), so the parent hook returns in milliseconds and nothing flows to the agent's context.

  • VAULT_QMD_REFRESH=off switches the detached refresh off (exactly off, as wiki-mind's copy reads it): no qmd-refresh-run.ts worker, and no detached qmd update or bootstrap from SessionStart. The debounce decision and its sentinel stamps still run. The test suite sets it in every test process and every hook a test runs. The test runner also stops and fails on any surviving worker whose command line names the run's temp folder, which every trailing worker's does; an immediate-path worker carries no path, so for those the switch is the guard (#337).

  • PreCompact also backs up the transcript. In addition to kicking the QMD refresh, it copies the current session transcript out to thinking/session-logs/ so long conversations remain recoverable after compaction.

  • Stop reports once, not every turn. Claude and Codex fire it whenever the agent finishes a response, not when the session ends (#252). It triggers the shared refresh every time, but shows the checklist and hygiene findings only the first time a session sees that exact report, and again when the findings change; otherwise it returns the empty JSON envelope. A warning that repeats unchanged is one users learn to ignore (#155). The dedupe reuses the classifier's per-session state shape (#107): one self-pruning file, failing open to "show" on a missing session_id or unreadable state.

  • A changed Stop report reaches the agent too (#256). A systemMessage is shown to the user and never added to the model's context, so the drift the agent is best placed to fix never reached it. And every Stop output that does reach the model is printed in full for the user: a decision: "block" reason under a "Stop hook error" label (v8.5.0 shipped that), Stop additionalContext under "Stop hook feedback". UserPromptSubmit additionalContext is the one channel that reaches the model and is never shown. So when the findings change, Stop shows the user a one-line-per-section summary and saves the full report for the session (lib/stop-handoff.ts: written atomically, claimed by rename so it is delivered once, the newest report wins, a week-old one is pruned). The next prompt's classify-message takes it and hands it to the agent beside any routing hints, with a preface telling it to deal with the user's message first. If the report cannot be saved, Stop sends it as feedback instead (Claude Code; Codex's handling of Stop feedback is unverified): visible, but never lost. SessionEnd and a Stop without a session_id have no next prompt to ride, so the user gets the full report.

  • Why not SessionEnd. It looks like the natural home for an end-of-session checklist, but Claude Code discards a SessionEnd hook's systemMessage, and Codex documents SessionEnd as advisory and does not surface its systemMessage. Wired there, the checklist would reach nobody on two of the three agents. Gemini does display it during shutdown, so Gemini keeps its SessionEnd wiring. The acting path is /om-wrap-up, which runs the hygiene pass while tools are still live.

The Claude Code mod

Claude Code 2.1.287 added mods: plugins whose code runs inside Claude Code and can wrap the settings-hook events as classic.<Event> (PreToolUse rides tool.call instead). The template ships one, obsidian-mind, at .claude/skills/obsidian-mind/; Claude Code loads it in place once the vault folder is trusted, from the session's working folder only.

  • The hooks stay the engine. Codex and Gemini have no mods, and on Claude Code the mod does not load everywhere (an older CLI, an untrusted folder, a session started in a subfolder, managed-only policy). So the mod adds no behaviour of its own: it runs the same scripts and only changes the channel their output reaches the session through.

  • The switch rides on the event. What the mod passes to next() is what the settings hooks read on stdin (lib/om-mod.ts). For an event it handles, the mod does its own work first and then passes om_mod: "standdown", and the script exits. The flag exists only on events the mod's hook actually ran on: a mod that throws, crashed or never loaded sends the original event, and the hook runs as without it. An environment variable would outlive a dead mod and leave the hooks silent; answering the event without next would silence the user's own hooks too.

  • SessionStart becomes an instruction file. The mod runs session-start.ts with om_mod: "deliver" (the full layer, held to eager_layer_instruction_budget_bytes) and adds the output in prompt.context as a project instruction file. Claude Code re-reads instruction files after compaction and /clear, gives them to general-purpose subagents, and does not cut them at the hook limit. The text is kept in the mod's per-session state ($.state, which survives a hot reload of the module) and cleared (and redrawn) before every run that starts a conversation, so a run that fails there cannot leave an older context riding beside the full layer the settings hook then prints. A compaction keeps it: the hook's fallback there is only a pointer to a static half that, under the mod, was never in the conversation, so the last good context is the better thing to keep. It is also written to .claude/session-context.md (gitignored), which is where /memory points: unawaited, so delivery never waits on the file. The file is only what /memory shows: two runs seconds apart could in principle leave the older text on disk, and that case is accepted rather than guarded, since runs are minutes apart in practice.

  • Stop becomes a line under the answer. The mod runs stop-checklist.ts with om_mod: "report", which returns the report as data and claims no state; the mod keeps which report each session was last given, and whether it reached the agent, by session id, in $.store (a file Claude Code keeps per plugin, capped at the most recent sessions). A claude --resume in a new process then neither repeats a report the agent had nor loses one still waiting, matching the settings hook's file-backed handoff. A report counts as delivered when a turn starts with the prompt that carried it; that turn can start inside the prompt's own prompt.submit (observed on 2.1.288), so the prompt is recorded as carrying it before the hook calls next. What is waiting to be delivered is one record in $.state: the report, its line and its urgent finding, and the session it is for. When the findings change it draws one line under the next main-loop answer that completes (turn.complete; a subagent turn, an interrupt or an error keeps the line pending), after any line a hook below already set, and attaches the full report to the next prompt the person sends (prompt.submit context, never shown). Prompts from peers, notifications, schedules and other plugins do not carry it, and a prompt dropped below keeps it queued. A prompt enters when it is queued, not when its turn starts, and a queued prompt can be pulled back out of the queue unrun; so the report a prompt took is held until a turn starts with that prompt (or a folded turn that contains it), and if another prompt's turn starts first, the queue being first in, first out, it goes back to be delivered with the next prompt. A duplicate delivery is possible there; a lost one is not. A report marked urgent is submitted from turn.complete as a prompt of its own, framed as the plugin's rather than the person's. The finding also rides inside the queued report, so it reaches the agent whether or not its own turn happens, and one such turn runs per prompt the person sends that actually entered: findings that keep changing while the agent fixes them cannot chain turns. Once a prompt enters with the report, a line or urgent finding still waiting is dropped, unless a newer report was queued while it entered. Every session start except a compaction (startup, /clear, an in-process resume or fork) drops anything queued, since $.state is not reset there; a compaction continues the conversation and keeps it. A dropped report never reached the agent, so its session forgets having been shown it and the same findings show again; a report the agent already had stays shown. Each such start also bumps a generation counter, and a prompt that took the report and then failed to enter puts it back only if no such start happened meanwhile, so a report in flight across a /clear cannot land in the new conversation. State that may have changed while a hook awaited next is read through update, never read: every $.state read in one dispatch sees one moment. The report the mod receives carries its own framing (MOD_PREFACE), since the user saw a line rather than a summary and the prompt may be the plugin's. The submit never comes from classic.Stop: Claude Code refuses it there, because it would wait on the turn the hook may be holding. The template's report has no urgent class; the field is there for a vault that adds one.

  • The work happens before next. If the script fails, the mod's hook throws before passing the flag down, Claude Code skips it, and the settings hook delivers as usual. A failure after next resolved would keep the stood-down result, so nothing runs after it.


QMD Integration

QMD provides semantic search. It is the mechanism behind most of the agent's retrieval intelligence. Four entry points all read from the same named index:

CallerEntry pointWhen
Agent tool menu.mcp.json → qmd-mcp.mjsEvery mcp__qmd__* tool call from a session inside the vault
Session startupsession-start.tsRe-index on every new session
Mid-session refreshvalidate-write.ts / stop-checklist.ts / pre-compact.ts → lib/qmd-refresh.ts (shared sentinel + debounce) → detached qmd-refresh-run.tsAfter .md writes, after responses, and before compaction
The om serverlib/mcp-qmd-client.ts (search) and reindexSync (after a write)Every search, every queried recall, every remember / record_work

Every qmd update invocation re-reads the per-index YAML (~/.config/qmd/<index>.yml), so changes to the collection config — including the ignore list synced from .obsidian/app.json — propagate to every surface without a session restart.

What QMD actually runs

Worth understanding, because it explains the cost profile of everything built on top. QMD runs three small models locally. There is no API key, no per-query cost, and it works offline.

modelsizejob
Qwen3-Embedding-0.6B~640 MBturns notes and queries into vectors
qmd-query-expansion-1.7B~1.28 GBrewrites a query into better search terms, and writes hypothetical answers for HyDE — reached by the qmd query CLI, not by the typed sub-queries this template sends (see below)
Qwen3-Reranker-0.6B~640 MBreorders the shortlist by actual relevance

The embedder is chosen, not inherited. qmd defaults to embeddinggemma-300M, and the bootstrap replaces it. Measured on a 684-document vault against 211 human-labelled retrieval pairs, query path otherwise untouched: rank-1 0.299 → 0.469 and found@5 0.521 → 0.758 (McNemar exact p < 0.0001), replicated on a second independently written vault at rank-1 0.650 → 0.950 (p = 0.0003). It costs ~13ms a query and roughly doubles a full index build. Reranking is not a cheaper substitute for it — on the default embedder reranking is a large significant gain, on this one it is not significant on rank-1, because it had been compensating for the embedder; this model with no reranker beats the default with one, at an eighth of the latency.

Set models.embed in the qmd config yourself and the bootstrap leaves it alone — it replaces qmd's default, never a choice. Changing the model invalidates every existing vector, since the two produce different dimensions and qmd refuses to mix them, so the bootstrap forces a full re-embed in the same run.

They download on first use and are cached. QMD offloads to the GPU when it finds one — CUDA on a discrete card, Metal on Apple Silicon — and falls back to CPU otherwise. qmd doctor reports which.

The CLI verbs map onto that stack, cheapest first:

verbwhat runscost
qmd searchBM25 keyword matching, no model at allinstant
qmd vsearchvector search — embeds the query, then comparesone embed
qmd querythe full hybrid: sub-queries, then rerankembed + optional expansion + rerank
qmd updateparse and index note textno model
qmd embedbuild the vectors for new or changed notesthe embedding model, per note

Two consequences shape the design of everything downstream:

  • update and embed answer different questions. update decides whether a note is findable at all; embed decides only where it ranks. They are separated everywhere in this template, and the split is why a write can report success honestly without waiting on a local model run.

  • Reads are the expensive side, not writes. A query has to embed before it can search, so a semantic lookup costs a model pass while a keyword lookup costs none. This is the opposite of the usual database intuition, and it is why the retrieval paths below are careful about when they pay for a vector.

flowchart LR
    subgraph Text["Text path — no model"]
        Update["qmd update"]
        FTS[("BM25 / FTS index")]
        Update --> FTS
    end
    subgraph Vector["Vector path — local models"]
        Embed["qmd embed"]
        Emb["embeddinggemma-300M"]
        Vec[("vector index")]
        Embed --> Emb --> Vec
    end
    subgraph Read["Query time"]
        Q["qmd query"]
        Expand["qmd-query-expansion-1.7B<br/>expansion + HyDE"]
        Rank["Qwen3-Reranker-0.6B"]
        Q --> Expand
        Q --> Rank
    end
    FTS --> Q
    Vec --> Q
    Notes["Markdown notes"] --> Update
    Notes --> Embed
flowchart TB
    subgraph Reader["Agent tools"]
        MCP_get["mcp__qmd__get"]
        MCP_query["mcp__qmd__query"]
        MCP_multi["mcp__qmd__multi_get"]
        MCP_status["mcp__qmd__status"]
    end
    subgraph Writer["Index maintenance"]
        Boot["qmd-bootstrap.ts<br/>(one-time)"]
        Session["session-start.ts<br/>(per session)"]
        Refresh["qmd-refresh-run.ts<br/>(per write/stop/pre-compact,<br/>debounced, detached)"]
    end
    Wrapper["qmd-mcp.mjs<br/>(reads qmd_index from manifest)"]
    CLI["qmd CLI<br/>(--index &lt;name&gt; from manifest)"]
    Store[("Named SQLite store<br/>+ embeddings")]

    MCP_get --> Wrapper
    MCP_query --> Wrapper
    MCP_multi --> Wrapper
    MCP_status --> Wrapper
    Wrapper --> Store
    Boot --> CLI
    Session --> CLI
    Refresh --> CLI
    CLI --> Store

QMD is technically optional. When it isn't installed at all, the agent falls back through a preference order defined in AGENTS.md and the qmd skill: MCP tools first when registered, then the qmd CLI, then Grep/Glob/Read as a last resort. Every fallback step is non-fatal: .mcp.json entries that fail to launch are skipped with a harmless warning, the hook scripts detect a missing qmd binary and no-op, and the operating manual tells the agent what to reach for next.

QMD over MCP — the in-vault retrieval path

This section is about the qmd MCP server, which a session inside the vault uses. The om server, which a session in another repo uses, is a different thing built on top of it — see Reaching the Vault From Another Repo. The one rule connecting them: a consuming repo registers om, never raw qmd.

When the MCP server is registered, the agent's normal path to QMD is through typed tools — mcp__qmd__query, mcp__qmd__get, mcp__qmd__multi_get, and mcp__qmd__status — that appear in its tool menu alongside Read and Edit. These come from the Model Context Protocol server declared in .mcp.json, launched by a thin wrapper (.claude/scripts/qmd-mcp.mjs) that reads qmd_index from the manifest and invokes qmd mcp underneath. The CLI remains available — and is documented as the fallback — but during a session with MCP live, the agent goes through typed tools, not shell.

The wrapper exists so that each vault can run its own isolated index without users hand-editing .mcp.json. The index name flows from the manifest into the wrapper, the wrapper into QMD, QMD into its per-vault SQLite store.

The contract matters because the alternative — teaching every subagent and slash command to shell out to qmd search on every call — would couple each prompt to QMD's CLI surface, duplicate parse/retry logic across files, and force every prompt to re-explain the tool. MCP collapses all of that into one typed interface for the in-session path. When QMD changes its CLI, the wrapper adapts; the rest of the template is insulated. When another MCP-aware service needs to join the vault (a bug tracker, a docs search, a calendar), it registers in .mcp.json and gains the same privileged position.

One ignore list, two engines

Obsidian and QMD both need to know which files to hide from search. Rather than maintain two lists that can drift, the template treats .obsidian/app.json → userIgnoreFilters as the single source of truth. qmd-bootstrap.ts reads that array and writes it into the QMD per-index YAML (~/.config/qmd/<index>.yml) as the collection's ignore field. Every subsequent qmd update (initial index, mid-session refresh, session-start reindex) honors the list.

flowchart LR
    App[".obsidian/app.json<br/>userIgnoreFilters"]
    Boot["qmd-bootstrap.ts"]
    YAML["~/.config/qmd/&lt;index&gt;.yml<br/>collections.&lt;name&gt;.ignore"]
    Obs["Obsidian<br/>(search, graph, switcher)"]
    QmdUpdate["qmd update<br/>(every invocation)"]

    App --> Obs
    App --> Boot
    Boot --> YAML
    YAML --> QmdUpdate

This is why the list lives in Obsidian's config and not vault-manifest.json: users who adjust what's hidden in Obsidian's UI get the same change propagated to QMD on the next bootstrap. Files that are infrastructure (template dev docs like this one, CHANGELOG.md, CONTRIBUTING.md) are good candidates; user-authored content should not be here.


Reaching the Vault From Another Repo

Everything above assumes the agent is running inside the vault. The om MCP server removes that assumption.

The problem, precisely

A session working in your app's repo has no durable memory of why anything was decided. It re-derives context every time, asks you, or guesses. The knowledge exists — it is in your vault — but the session is in a different directory with no way to reach it.

Bolting a generic memory server onto that session solves the wrong half. Those write to their own store, in their own shape, and you end up with a second knowledge base that is not your vault, not in your graph, and not yours to browse. om exposes the vault itself.

An MCP server is four surfaces, not one

The non-tool surfaces turned out to matter more than expected, so it is worth naming all four and which direction each runs.

surfacedirectionwhat it iswho triggers it
instructionsvault → sessionthe vault's rules, injected into the calling session's system prompt at connectautomatic, once per connection
toolssession → vaultsearch, expand, recall, remember, record_work, reason, healththe model decides
resourcessession → vaultnotes listable and readable by vault://note/<path> URIthe model or the client
promptsyou → vaultrecall_topic, prior_art — slash commands in the calling sessionthe human decides

The split between rows 2 and 4 is load-bearing, because of an asymmetry measured while building this:

Prohibitions propagate. Routing instructions do not.

A rule in instructions — "never put a session URL in a commit" — held under direct pressure and under an authority override. A positive instruction — "consult the vault before answering questions about past decisions" — is advisory and gets skipped whenever a nearer source exists. A server can stop a session doing something; it cannot make one go looking.

That is why prompts exist (the human invokes them, so no model decision is involved) and why the install requires a pointer in the consuming repo's own instruction file (CLAUDE.md or AGENTS.md) — the nearest source wins, so the nearest source has to be the thing that says "go look."

The tier ladder
tierwhatwhat it costs you at call time
0 — presencethe contract reaches the calling sessionone-off, no extra session spawned
1 — retrievalsearch, expand, resourcesseconds, no model call beyond the local embedding
2 — capturerecall, remember, record_workno model call
3 — reasoningreason — a spawned session reads the vault and judgesseconds to minutes of waiting

Tier 0 is the counterintuitive one. The original design assumed the vault would have to think for you: spawn its own session, hand back an answer. That works and it is expensive. Tier 0 instead hands the calling session the vault's contract and lets it think with the model you are already using.

The ladder is about latency, not money. Tier 3 runs your CLI, on your machine, under your auth — the same thing you get typing claude. Prefer search when search would do because it answers in two seconds, not because a second session is a hazard.

Module map

The entry script is deliberately thin. It owns only what a test cannot usefully drive — resolving the vault, opening stdio, holding the lazy qmd child. Every decision worth arguing about lives in a library module that can be driven in-process.

flowchart TB
    Entry["om-mcp.ts<br/>vault resolution · stdio · qmd child lifecycle"]
    Proto["mcp-protocol.ts<br/>JSON-RPC framing · identity handshake"]
    Ctx["mcp-context.ts<br/>vault root · manifest · index name<br/>INSTRUCTIONS · PROMPTS"]
    Server["mcp-server.ts<br/>method → behaviour wiring"]
    Tools["mcp-tools.ts<br/>tool declarations + annotations"]
    Exp["mcp-exposure.ts<br/>which notes are served"]
    Qmd["mcp-qmd-client.ts<br/>qmd MCP client + result filter"]
    Graph["mcp-graph.ts<br/>expand: links out, links back"]
    Caller["mcp-caller.ts<br/>identity · sanitise · audit log"]
    Bridge["mcp-memory-bridge.ts<br/>memory ↔ vault seam"]
    Capture["mcp-capture.ts<br/>record_work filing"]
    Reason["mcp-reason.ts<br/>tier 3: argv · prompt · spawn · record"]
    Promo["memory-promoted.ts<br/>promoted-block resolution"]
    RH["read-head.ts<br/>bounded frontmatter prefix"]
    subgraph Core["Memory core — no MCP knowledge at all"]
        MW["memory-write.ts"]
        MR["memory-recall.ts"]
        MS["memory-similarity.ts"]
        MSup["memory-supersede.ts"]
        MD["memory-discover.ts"]
        Idx["memory-index.ts<br/>parse cache over the store"]
    end

    Entry --> Proto
    Entry --> Ctx
    Entry --> Server
    Entry --> Qmd
    Entry --> Caller
    Server --> Tools
    Server --> Exp
    Server --> Graph
    Server --> Bridge
    Server --> Capture
    Server --> Reason
    Server --> Promo
    Promo --> Exp
    Server --> Idx
    Server --> Core
    Server --> Qmd
    Idx --> MR
    Graph --> Exp
    Qmd --> Exp
    Bridge --> Core
    Bridge --> Qmd
    Exp --> RH
    Bridge --> RH
    MD --> RH

The memory core knows nothing about MCP. That is what lets the epistemic contract be hammered by tests without an MCP client, a vault on disk, or a search index.

memory-promoted.ts carries the memory- prefix and sits deliberately outside that subgraph: resolving a promoted block means asking the exposure policy, so it imports mcp-exposure.ts and would break the invariant the box states. The prefix names the domain it serves, not the layer it lives in.

Connection: the identity handshake

Almost everything this server does depends on who is asking. MCP provides that: the server can ask the client which directories the calling session has open (roots/list). It is derived from the client, never declared by the caller, so there is no argument through which a session could claim to be a project it is not.

The complication is that the handshake is asynchronous, and the client's first resources/list arrives before it resolves.

sequenceDiagram
    participant Client as Calling session
    participant Server as om server
    participant Repo as The repo on disk

    Client->>Server: initialize
    Server-->>Client: capabilities + INSTRUCTIONS
    Note over Client: instructions land in the system prompt
    Client->>Server: notifications/initialized
    Server->>Client: roots/list (server-initiated)

    par the client asks early
        Client->>Server: resources/list
        Note over Server: WAITS on identityReady, capped at 2s
    and identity is still in flight
        Client-->>Server: roots result
        Server->>Repo: read .om-project, else use folder name
        Note over Server: identity resolved, gate opens
    end

    Server-->>Client: resources scoped to this caller

Announcing notifications/resources/list_changed after the fact was tried first, and the client did not re-fetch — the listing simply stayed unscoped. The deterministic fix is the wait: something that scopes by caller must not answer before it knows the caller. The cap matters too — a client that never answers gets an anonymous, general-only view rather than a hang.

The same gate reopens on notifications/roots/list_changed. Without that, a tool call arriving between the notification and the reply was served under the stale identity: the same race, one step later in the lifecycle.

Identity resolution:

flowchart TB
    R["the first root URI<br/>from the handshake"] --> P["rootToPath<br/>file:// → plain path"]
    P --> M{".om-project<br/>at the root?"}
    M -->|"yes, and it is a valid name"| D["declared identity"]
    M -->|no| F["folder name, lowercased"]
    D --> Out["caller project"]
    F --> Out
    Out --> Plat["platforms: read from<br/>the project's own vault note"]

The folder name is right until it isn't: two repos both called api share one identity and therefore each other's memories. .om-project resolves that, and health reports which source was used so the collision is discoverable rather than mysterious.

Which notes the server serves
flowchart TB
    Start["resolveExposure"] --> A{"mcp_exposed_roots<br/>declared?"}
    A -->|yes| Man["source: manifest"]
    A -->|no| B{"user_content_roots<br/>present on disk?"}
    B -->|yes| Der["source: derived"]
    B -->|no| Fall["source: fallback<br/>brain, reference"]
    Man --> Strip["strip the memory root — always"]
    Der --> Strip
    Fall --> Strip
    Strip --> Walk["walk each root, max depth 4"]
    Walk --> S{"entry is a symlink?"}
    S -->|no| C{"filename in<br/>mcp_never_expose?"}
    S -->|yes| Cont{"target resolves<br/>inside the root?"}
    Cont -->|"no, or broken"| Drop["not served"]
    Cont -->|yes| C
    C -->|yes| Drop
    C -->|no| E{"tagged private<br/>in frontmatter?"}
    E -->|"yes, or unreadable"| Drop
    E -->|no| Serve["served"]

The default is the vault's own user_content_roots, at the granularity the manifest declares them — work/active/, not all of work/. Roots are path prefixes, matched on whole segments, so work/active does not admit work/active-secrets.

mcp_exposed_roots narrows that, and exists for the unusual vault holding material that is not the user's to share — employer-confidential notes, a client's data. Both exposure keys ship empty: the template must not impose one vault's sensitivities on every install.

What this list is, and is not. It decides which notes the server serves. It is not an egress control: a session started with --add-dir reads the whole vault regardless, so narrowing the read surface prevents nothing on its own. Keeping vault material out of a public PR is the job of the prohibition in instructions — the form measured to hold — plus the audit log. Reading this list as a security boundary leads to a narrow default, and a narrow default fences off the user's own project notes, which are the single most useful thing a coding session could read.

Five things hold regardless of configuration:

  • memories/ is never served as an ordinary note. Memories carry their own declared reach, evaluated per caller; the note surface would bypass it. It is stripped from the root list unconditionally, and again during the walk.

  • A symlink is contained before it is followed. The walk lstats each entry — which describes the entry rather than its target — and resolves any link against the root's realpath. Enumerating with stat instead means a .md link inside an exposed root pulls in a file from anywhere on disk.

  • mcp_never_expose withholds every case spelling, not the one that was declared. The admit side has always compared case-insensitively because the filesystem does; the withhold side was exact-case, and on Windows and macOS that asymmetry served the file — ["SOUL.md"] refused brain/SOUL.md and returned brain/soul.md, so the listing withheld a note the reader handed over. realpathSync on Windows does not canonicalise case, so the post-realpath re-check saw the caller's spelling too. The comparison owns the rule rather than the constructor, because a policy assembled any other way would otherwise fail open — and every test assembles one by hand.

  • Frontmatter that does not close inside the head window is private. readHead stops at 1200 characters, so private: true sitting past that read as public on every surface. A file whose frontmatter cannot be fully inspected is withheld, which is the rule already applied to a file that cannot be read at all.

  • Every read is logged to .claude/om-mcp-audit.jsonl (gitignored, rotated at 5 MB, one generation kept) with the calling repo — so "what did that session actually see" is answerable afterwards. Frontmatter-derived fields in that line are length-bounded and the promoted list is capped: the log rotates by size, so an unbounded field lets a hostile note erase the very history this design leans on.

Two questions, one answer each — and they are different questions. Which notes exist resolves through visibleFiles: search filters its hits against it, expand computes backlinks only over it, and the resource enumerators build from it. May this one path be read resolves through resolveExposedNote: the resource resolver calls it, and so does the promoted-block path below. Each is one implementation rather than several, because the recurring defect in this layer is a second read path that reaches notes by its own route and applies a different rule.

The symlink case is that defect in its most recent form, and worth keeping as the worked example: resolveResourceUri did realpath containment from the start, while the enumerator followed links silently. So the resource listing published an out-of-vault file's description and expand returned its body, while reading the very same URI was refused. Both ends now contain against the matched declared root — not the first path segment, since roots are prefixes and work/active/ and work/1-1/ share one.

That defect recurred once more, which is why the second predicate is now named rather than left implicit inside the URI resolver. recall gained the ability to serve a promoted block out of brain/ (below), and shipped with its own root check: it dropped neverExpose, dropped isPrivate, and compared the first path segment against roots that are prefixes. So it served two classes of note every other surface withholds, while refusing most of the vault's own declared roots. Every test passed — the fixture policy was ["brain", "projects"], a shape no real policy has. resolveExposedNote was extracted out of resolveExactResourceUri so both callers ask it rather than re-deriving it. A new surface calls one of the two predicates above; it does not grow a third.

Serving a promoted block through recall

recall read the memory root and search/expand read everything but, so the two were disjoint. A lesson promoted from a capture into a brain/ topic note therefore exists twice, and a foreign repo can only reach the capture — the version as first written, which may predate a correction swept through the promoted one.

Since promotion is additive (the capture stays), the capture is still the reach record and is already correct. So visibility is computed exactly as before, from the capture's scope/projects/platforms, and only the content changes: when the capture's promoted: marker carries an anchor, recall serves the promoted text instead of the capture body.

conditionbehaviour
promoted: brain/Note (no anchor)named, never served — the pre-existing behaviour
promoted: brain/Note#^om-idthe block is served, and the facet line says so
promoted: brain/Note#Headingthe section is served, up to the next heading of the same or higher level
promoted: brain/Note#TheH1refused — a level-1 heading is the note's own title, so it addresses the whole note
anchor no longer resolvescapture body, marked STALE — never the whole note
the policy withholds the notecapture body, marked withheld

Four things bound it. Serving is opt-in: only an anchored marker serves, and anchors are written at promotion time, so pointing at a block is a deliberate act rather than an automatic consequence of the marker existing. The policy still decides, via resolveExposedNote — a private-tagged or never-exposed note is refused here exactly as everywhere else. It degrades rather than widens: a stale anchor returns the reason, because serving a whole Gotchas note because one bullet in it was promoted is worse than serving nothing. And what it serves is capped at 40 lines, with the caller told when the cap bit — a level-1 heading is refused outright, since in this vault's shape the H1 is the note's own title and #Some Note would address the entire note through an anchor that looks specific.

Two things that look like content are not: a block id inside a fenced code block is skipped, because /om-tidy teaches the id syntax inside fences and a brain/ note about this feature therefore carries decoys; and a marker containing a newline is refused, because the facet line is built from frontmatter and is not heading-demoted the way a memory body is, so \n\n## FORGED ENTRY added an entry to the response that a reading agent could not tell from a real one.

The audit line carries the promoted notes and their statuses. This is the only surface that reads outside its own root, and the audit log is the stated mitigation for the exposure list not being a security boundary — a recall that served a brain/ block while logging only a count would leave "what did that session actually see" unanswerable.

The residual risk, recorded rather than solved: a promoted block can say more than the capture it came from. The capture's scope was declared for its own text, and once merged into a topic note the block sits beside other material. Opt-in-by-anchor is the mitigation, not a proof.

The incentive gradient, and who hears about decay

Opt-in has a cost that took a real vault to notice: the cheapest way to clear the memory-inbox flag is the form that serves nothing. Any non-empty promoted: drains the count, so a bare marker buys the same hygiene outcome as an anchored one for strictly less work. In a real store, 35 of 37 captures carried a marker and none carried an anchor — the serving path above was dead in production while every flag read as clean.

The fix is not to require an anchor. A bare marker is a legitimate promotion — it records that the lesson was copied — and forcing an anchor to clear hygiene would push anchors onto captures whose promoted block is not fit to leave the vault, which is the exact disclosure risk the paragraph above is careful about. So the gradient is made visible rather than mandatory:

  • health reports Promotions: N servable, M named only, unconditionally. Without it a store where nothing is servable is indistinguishable from one where everything is, because the flag falls either way.

  • health warns on genuinely broken promotions — a stale anchor, a withheld or missing target — and names the capture to edit rather than the brain/ note, because the capture is the file that carries the marker.

  • The hygiene line repeats the split only when the inbox flag is already firing. It never raises a flag of its own: namedOnly cannot revive the count, or the inbox stops being able to reach zero — the permanently-unclearable failure that additive promotion was designed around.

The diagnostic itself already existed; only recall consumed it, which put it in front of a foreign repo — the one party that cannot see brain/ and therefore cannot fix anything. health is the surface a vault session reads, so it is where the report belongs.

A search call, end to end
sequenceDiagram
    participant Session as Calling session
    participant OM as om server
    participant Exp as mcp-exposure
    participant QC as qmd client
    participant QMD as qmd MCP server

    Session->>OM: tools/call search with query and limit
    OM->>OM: await identityReady()
    OM->>Exp: allowedSearchPaths(vault, policy)
    Exp-->>OM: set of vault-relative keys
    OM->>QC: qmdSearch(allowed, query, limit)
    QC->>QC: subQueries → lex + vec
    QC->>QMD: tools/call query, limit = max(limit*4, 20)
    QMD-->>QC: structuredContent.results — the WHOLE vault
    QC->>QC: filter each hit against allowed
    QC-->>OM: text + withheld + total
    OM->>OM: audit the call — query, withheld, total
    OM-->>Session: ranked passages with note paths

Four details in that flow are decisions rather than mechanics:

The server is an MCP client of the vault's own qmd server. Reusing the existing launcher inherits two fixes for free — the Windows .cmd shim workaround and the named-index pin — and the launcher is located rather than hardcoded, because a stale path here kills search silently.

Filter the result, never the query. The index covers the whole vault, including memories and any folder outside the served roots. Filtering the query would require every caller to construct a scoped query correctly; filtering the result means no query a caller can write returns more than the policy serves. Refusing to answer when structuredContent is absent belongs to the same rule — qmd's human-readable summary carries note paths too, so falling back to it would return results nothing ever checked.

Over-fetch, then trim. Because the filter runs on the result, asking qmd for exactly limit means a vault with much unserved content returns far fewer than requested with no sign that more existed. The client asks for max(limit * 4, 20).

lex and vec, and deliberately nothing else. Keywords find the exact term, vectors find the note that answers the question without using the word. There is no hyde sub-query, because qmd treats hyde as a label on a vector search rather than as a mode — as of 2.8.3 every site in its store reads type === 'vec' || type === 'hyde', and generation happens only inside expandQuery(), which the plain-text query parameter reaches and a searches array bypasses. Passing the raw question in a hyde slot therefore repeats the vec search already going out, and pays for both: measured over 211 human-labelled pairs, sending it never, conditionally or always gave identical rank-1 and found@5 to four decimal places for +35-40ms a query. An authored hyde — a caller-written passage describing what the answer would look like — is a real technique and the SubQuery type still allows it; handing it the user's question is not that.

Degradation is explicit. qmd is optional in this template. A failed call degrades that one search and says so; it must never present as "the vault is empty". The client tracks liveness, so one qmd crash does not disable search for the life of the server, and the entry script replaces a dead child behind a 5-second cooldown so a permanently-broken qmd cannot fork a process per call.

Which memories reach which repo

This is the part the layer exists for. Every memory declares its reach when written, and a reader never widens what a writer declared.

scope: project              # general | platform | project
projects: [atlas, atlas-api]  # a LIST — the multi-valued axis
platforms: [ios]
confidence: verified
origin: atlas               # derived from the roots handshake, not caller-asserted
session: 2026-07-26T14:02:11Z

The list is the load-bearing choice. A memory is multi-valued; a folder is not. A lesson touching two projects and a cross-cutting theme has no correct folder — you would pick one and lose the rest, or duplicate and have two sources of truth. A memories/<project>/ taxonomy was designed and rejected for exactly this. Time is the only thing in the path, because it is the only single-valued fact about a memory:

memories/2026/07/2026-07-26 <title>.md

Visibility is evaluated in order, and the order is the design:

flowchart TB
    M["a memory"] --> S1{"scope: general?"}
    S1 -->|yes| V["visible"]
    S1 -->|no| S2{"the projects list names<br/>the calling repo?"}
    S2 -->|yes| V
    S2 -->|no| S3{"scope: platform<br/>AND platforms overlap?"}
    S3 -->|yes| V
    S3 -->|no| H["not visible — default deny"]
  • general reaches everyone. The only scope that does, which is why the write path polices it hardest.

  • An explicit project listing always wins, whatever the declared scope. This is what makes the multi-project case work: projects: [a, b] reaches both, and neither has to know the other exists.

  • platform reaches any caller sharing a platform — an iOS lesson reaches the next iOS app and must not reach the web one.

  • Otherwise, not visible. A near-miss does not surface.

A caller with no roots sees general only: the safest reading of "I don't know who you are", and it degrades to useless rather than to wide-open.

Ranking, once visibility has decided the set:

flowchart LR
    A["superseded sinks<br/>below live"] --> B["specificity<br/>project &gt; platform &gt; general"]
    B --> C["date, newest first"]
    C --> D["session timestamp<br/>breaks same-day ties"]

A memory naming only your project outranks one naming five. date is day-granular, so without the session tiebreak a just-written memory could sort arbitrarily and fall outside the caller's limit.

Adding a query on top:

sequenceDiagram
    participant S as Session
    participant OM as om server
    participant MR as memory-recall
    participant QC as qmd

    S->>OM: recall {query, limit, explain}
    OM->>MR: recall(vault, caller)
    MR-->>OM: visible set, ranked by declaration
    alt query given and more than one result
        OM->>QC: semanticMemoryOrder(query, visible)
        alt index answered
            QC-->>OM: reordered
        else index down or no structured results
            QC-->>OM: null
            OM->>OM: lexical fallback — per-token, title AND body
        end
        OM->>OM: regroup — live first, superseded after
    end
    OM-->>S: entries with facets, reasons, and withheld count

Three properties in that path each closed a real defect:

  • Retrieve semantically, then filter by visibility — never the reverse. The index sees every memory including other projects'. Applying the scope rule to the result keeps one implementation of it, so semantic recall returns exactly the set plain recall would.

  • Semantic ordering REORDERS; it does not filter. An earlier version returned only the memories the index matched, so a just-written memory — no embedding yet — vanished from recall at every limit. Anything the index did not place is appended in declared order.

  • Relevance orders within groups, never across the supersession boundary. Reordering by relevance alone puts a corrected-away fact above the correction that replaced it, which is the one thing supersession exists to prevent.

A missing index degrades ordering, never availability. "The vault knows nothing" is not an acceptable answer to a wiring problem.

Reading the store without re-reading it

recall and the duplicate scan read the same files and parse the same frontmatter, once per call each. That is linear in the store, and it runs on every recall and every write. memory-index.ts caches the parse — and only the parse.

flowchart TB
    Call["recall, or the duplicate scan"] --> List["list the store<br/>every call"]
    List --> Stat["stat each file<br/>every call"]
    Stat --> Q{"size AND mtime<br/>unchanged?"}
    Q -->|yes| Hit["reuse the parsed entry"]
    Q -->|no| Read["read + parse, then cache it"]
    Hit --> Out["entries"]
    Read --> Out
    Gone["a file that vanished"] --> Drop["dropped from the cache,<br/>so it tracks the store"]

The listing and the stat happen on every call. Only re-reading and re-parsing an unchanged file is skipped, which is what makes this safe on the duplicate path: a memory written a second ago is always seen, and a stale view there would admit a duplicate permanently, because nothing downstream re-checks.

Size and mtime, because either alone is weak — some filesystems keep mtime to a whole second, and supersession rewrites frontmatter in place, which can preserve length. Writers also invalidate explicitly, so correctness never rests on timestamp resolution.

It is deliberately in-process, not on disk. Measured, these walks are a minority of a queried recall — the local query embedding dominates — so persisting the index buys a fraction of one operation while adding a file that can rot, disagree with the store, or need migrating in every vault that ever installed the template. The server is long-lived per repo, so every call after the first is warm and a cold start costs what it always did.

The same reasoning trimmed a second cost. Several call sites inspected only a note's frontmatter — is it private, what is its description, what are its aliases, was it agent-written, which platforms does the calling repo declare — and each read the whole file to look at its first kilobyte. visibleFiles does that for every note it enumerates, on every search, expand and write, so the cost scaled with the vault's total bytes: one long reference note made every unrelated call slower. read-head.ts reads a bounded prefix instead, preserving the exact string the old expression produced.

The write path
flowchart TB
    Call["remember — title, body,<br/>confidence, scope, projects"] --> Vault{"is the caller<br/>the vault itself?"}
    Vault -->|yes| Refuse["refused — a memory written here<br/>reaches only sessions that<br/>already read every note"]
    Vault -->|no| Neut["neutralise dangling wikilinks<br/>in title AND body"]
    Neut --> Val["validateMemory<br/>epistemic contract"]
    Val -->|fails| Report["refused, with reasons"]
    Val --> Sup0["resolve supersedes, in the caller's reach —<br/>the named targets drop out of the comparison"]
    Sup0 --> Dup{"near-duplicate against the rest?<br/>facet-gated, wherever the new<br/>memory would be read"}
    Dup -->|"yes, and force is not set"| Collide["reports what it collided with, if the caller<br/>can see it — suggests supersedes, or names the<br/>supersedes titles that matched nothing;<br/>out of reach, refuses without naming it"]
    Dup -->|no| Links["resolve links — emitted only if the target exists"]
    Links --> Write["claimFile: atomic exclusive create"]
    Write --> Sup["mark superseded memories,<br/>kept and back-linked"]
    Sup --> Idx["reindexSync"]
    Idx --> Done["Recorded: path, scope, warnings"]

The vault does not write to its own memory layer. A session inside the vault already reads every note directly, and a memory written there would be scoped to the vault-as-a-project — reaching only sessions that by definition did not need it. Write-only by construction, so it is refused rather than allowed to accumulate.

The claim is atomic. writeMemory originally used check-then-write. Six processes reporting success produced four files — silent loss, reproducible only across real processes, and one-server-per-repo is the actual deployment shape. It is now an exclusive create (COPYFILE_EXCL), guarded by a test that spawns real processes, since a single-threaded test serialises the calls and always passes.

Re-indexing splits by what each step guarantees:

flowchart LR
    W["a memory is written"] --> U["qmd update<br/>SYNCHRONOUS, bounded at 20s"]
    U -->|"failed"| Warn["the response says so —<br/>reporting success would be<br/>a lie the caller cannot detect"]
    U -->|"ok"| E["qmd embed<br/>DETACHED, unawaited"]
    E --> R["ranking improves<br/>moments later"]

update decides whether the note is retrievable at all, so it is synchronous. embed only decides where it ranks, and recall appends everything the index did not match in declared order — so waiting on a local model run for ordering that corrects itself moments later buys nothing. Measured, it was most of the write.

The vault normally re-indexes from a PostToolUse hook, but an MCP write is not a Claude Code tool call, so no hook fires. Without this step a note sits on disk and cannot be found, which is worse than no note because it looks like the system worked.

record_work is the sibling tool, and the two are constantly confused. The distinction is single-valued vs multi-valued: a work record is about one project at one moment, so it has a correct folder and gets filed into it. Routing is delegated to the calling session — which already carries the vault's conventions and can search to see where similar notes live — and the server's job is to validate, never to guess. A caller-supplied folder must resolve inside a declared root, with containment checked against that root rather than merely the vault: brain/../work passes a first-segment check and still resolves inside the vault, which is how a capture once landed in a folder nobody named.

Tier 3: reasoning, and why it has to spawn

Everything above answers without inference. search returns passages, expand returns neighbours, recall returns memories in a defensible order. None of them can answer "is what I am about to do consistent with what these six notes decided, and what do I do about the two that disagree?" — retrieval hands you the notes. Something has to read them.

The server cannot borrow the calling session's inference to do that. MCP has a name for it — sampling, where a server asks its client to run a completion — and Claude Code does not implement it (anthropics/claude-code#1785). So reason starts a session through the user's own CLI.

flowchart TB
    Q["reason(question)"] --> Seed["tier-1 search, 6 hits<br/>inside the exposure policy"]
    Seed --> HasEv{"any hits the caller<br/>may actually see?"}
    HasEv -->|yes| P1["prompt: start from these passages,<br/>read further when they are thin"]
    HasEv -->|no| P2["prompt: the INDEX was silent,<br/>the vault may not be —<br/>go read it yourself"]
    P1 --> Spawn
    P2 --> Spawn
    Spawn["spawn claude, async, cwd = vault root"] --> Flags["--strict-mcp-config + empty server map<br/>--tools Read,Grep,Glob<br/>--output-format json<br/>stdin closed"]
    Flags --> Log["audit — EVERY run, before any refusal:<br/>question, cost, turns, terminal,<br/>model asked, model used, wall ms, roots"]
    Log --> Run{"answer came back?"}
    Run -->|no| Ref["refuse by name — timeout, early end,<br/>or could-not-start — with the evidence"]
    Run -->|yes| Rec["write the record<br/>.claude/om-reasoning/, gitignored"]

It runs on the caller's own model, by running on none of its own. MCP gives a server no way to see which model the calling session is using, so there is nothing to mirror. Passing no --model at all makes the spawn take the user's CLI default — whatever they get typing claude — which is the closest reachable thing to the level they are already working at. A vault that wants something else sets reason.model. Bare aliases — haiku, sonnet, opus — are dropped in favour of inheriting, because --model haiku is not honoured and does not error: it silently runs claude-sonnet-5, and a pin that quietly means a different model is worse than no pin. Anything else is passed through as written rather than matched against a shape, since an id-shaped allow-list drops real ids it did not anticipate. Every answer names the model that actually ran, and says whether that was the pinned one.

Usage is answered by the record, not by a limit. Every invocation is appended to the audit log — before any refusal, so a run that produced no answer is still recorded — with the question, cost, turns, terminal reason, model asked for, model that ran, wall time, and the roots the spawn was given. health reports the day's figure from the tail of that log, and says at least when the log was too large to read whole: a number standing in for a cap has to admit being a floor rather than quietly under-report on the busiest day. The spawn is Claude, on the user's machine, under the user's auth, on the account the calling session already runs on; a server-side bound there would be this layer rationing the user's own resource back to them. The one bound present is a 300-second timeout, which kills a hung child — a failure, not a preference.

Isolation is what stops it recursing. --strict-mcp-config pointed at a config declaring no servers leaves the spawn with no MCP at all, so it cannot call back into om and start a tree that multiplies at every level. Verified on the wire: a spawn under those flags reports no MCP servers available.

Read-only comes from restricting the toolset, not from a permission mode. --tools Read,Grep,Glob removes the writing tools outright, so an attempted write returns No such tool available: Write rather than waiting on a prompt nothing is there to answer. --permission-mode plan was the first attempt and is wrong twice over: it writes the plan under ~/.claude/plans/, outside the vault, and it changes what the spawn believes it is producing — in a measured run the model composed its analysis as a plan, could not hand it back without ExitPlanMode headlessly, wrote the content to a file and returned a one-line note about having done so. The whole answer was lost, and the failure is non-deterministic, so it survived the first round of testing.

A related trap, since it is how that one nearly got mis-diagnosed: a spawn's account of its own tools is unreliable. Asked to list them under this allowlist, it returns the full default set including Write. Asked to actually write a file, it cannot. It reports its prior, not its runtime — so capability is checked by trying, never by asking.

The spawn is told the same boundary its seed was filtered through. Read,Grep,Glob with cwd at the vault root reaches the whole tree, while the seed came through allowedSearchPaths — so without an explicit boundary the one tool that reads most would be the only one ignoring mcp_exposed_roots, and could quote memories/ scoped to other callers into its answer. The prompt therefore names the exposed roots and prohibits everything else, in the prohibition form measured to propagate. It is a boundary the spawn observes, not one the filesystem enforces, which is exactly why the audit line records the roots it was given: what it was allowed to read stays answerable afterwards.

It runs asynchronously, and that is load-bearing. A synchronous spawn holds the whole event loop for the life of the child — measured at 73–80s, capped at 300s — during which the server parses no stdin at all: no other tool call, no ping, no roots/list reply, and no timers, so even the identity gate's own 2s cap could not fire. The entry script buys concurrency deliberately ("serialising them here would make one slow search block every other call"), and a synchronous spawn took that back harder than serialising would have. Verified on the wire: with reason running for 80.3s, a search and a health issued two seconds in came back at 2.9s and 2.8s.

Two things about the spawn are non-obvious and were found the hard way. stdin must be closed, or the CLI waits on it and the call hangs to the timeout instead of answering. And the binary is spawned directly with argv, never through a shell — on Windows cmd.exe strips the quotes out of an inline JSON argument, and the CLI then tries to open the mangled string as a file path.

The seed prompt has two branches, and the second one exists because of a measured failure.

flowchart LR
    E["empty seed"] --> R1["read as: the vault<br/>has nothing on this"]
    R1 --> W["reports 'no prior decisions recorded'<br/>about a question answered<br/>one directory away"]
    E --> R2["read as: the INDEX<br/>had nothing"]
    R2 --> G["go read the vault directly,<br/>and say how you looked"]

    style W fill:#5f1e1e,color:#fff
    style G fill:#1e4620,color:#fff

A vault whose index is missing or stale returns exactly what a vault with nothing to say returns. Under the normal wording the spawn treated the first as the second and reported silence — the same absence-read-as-answer failure this layer already had in retrieval, reproduced one tier up. The empty case therefore gets its own instructions rather than a blank evidence block.

Which branch fires is decided by the permitted hit count, not by qmd's total: total counts what the index returned before the exposure policy filtered it, so a search whose every hit was withheld has total > 0 and nothing the spawn can see. Reading the total alone would hand it an empty evidence block — precisely the shape that produced the false-absence answer. The prose sentinels ((no results), search failed: …) remain as a fallback for callers holding only the text, matched as prefixes so a passage that merely quotes one of those phrases still counts as evidence.

Against a vault whose index is working, seeding is worth about one turn — less than it looks like it should be, because a spawn with cwd set to the vault already inherits the vault's contract and can search for itself. Its value there is mostly that it starts in the right place, and it carries a bias: in a measured run the seeded answer leaned on the passages and repeated a framing the vault had since corrected. Hence the explicit licence in the prompt to read past the evidence, and the instruction to say when it is thin.

Losing the index entirely costs less than it sounds like it should. Same vault, same question, same build, run solo and sequentially so nothing competes for the machine:

conditionturnswallreported cost
index working, seeded2372.8s$0.37
no index at all, 1,267 notes2676.2s$0.44

Three turns and four seconds. The fallback branch does exactly what it is told — enumerates the note tree, greps it, reads every distinct note, correctly identifies the 1,200 synthetic filler notes as filler, reports how it looked, and only then says nothing is recorded — and a spawn sitting in the vault turns out to be good at that. The index makes tier 3 cheaper, not viable. The thing that would make it useless is the failure above: concluding silence without looking.

Both figures are from the shipped flags. An earlier measurement here showed 143s for the unindexed case and was wrong about the cause — it was taken under --permission-mode plan, where the turns went into composing a plan rather than into reading the vault.

An early end returns no answer, not a short one. If the spawn errors, times out, or stops before producing a result, the tool refuses and hands back the evidence the search already found. A truncated synthesis presented as a complete one is the single outcome worse than admitting there isn't one.

Answers are not memories. The record lands in .claude/om-reasoning/ — gitignored, outside the note tree, skipped by every read surface because it is a dot-directory — carrying confidence: inferred. Auto-recording it would fill the store with machine conclusions nobody asked for and nobody verified, and the epistemic contract exists precisely to stop that. The calling session decides whether any of it earns a remember.

Failure modes, and how each is made visible

Every failure in this layer presents identically as "no results". That is what health exists for.

failurehow it presentswhat surfaces it
memory root renamed in Obsidianrecall returns nothinghealth reports the discovered root and the drift
captures split across two rootsrecall sees only onehealth warns, naming both
qmd index belongs to another vaultsearch returns nothinghealth checks index ownership
qmd not installedsearch degrades to lexicalhealth reports the launcher missing
OM_VAULT_PATH points elsewhereeverything describes the wrong vaulthealth warns when it disagrees with the launcher location
caller unidentifiedonly general memories visiblerecall says so in prose; health says ANONYMOUS
memory scoped awayabsent from recallrecall with explain: true gives counts and reasons
two repos sharing a folder nameeach sees the other's memorieshealth reports the identity source and suggests .om-project
the CLI is not on the server's PATHreason cannot start at allthe refusal names the spawn error; OM_CLAUDE_BIN points at the binary
a pinned reason.model is ignoredanswers come from another model, silentlythe answer names the model that actually ran, and flags the mismatch
a reasoning spawn ends earlywould otherwise read as a complete answerrefused by name, with the turn count, the terminal reason and the evidence
a reasoning spawn hits the 300s timeoutlooks identical to a spawn that never startedthe kill signal is read, so the terminal reason is timeout, in the message and the log
a promoted anchor goes stalerecall quietly serves the raw capture instead of the corrected texthealth names the CAPTURE to edit, and why
every promotion is decorativethe inbox flag clears and nothing is servablehealth reports servable vs named-only; hygiene repeats it once the flag is already firing
the server exits mid-spawna session runs on with no bound and no audit lineshutdown kills every in-flight spawn
a promoted anchor no longer resolvesthe capture body is served, silently older than the vault's own textthe facet line marks the anchor STALE; the audit line records stale-anchor
a promoted note is outside the exposed roots, private, or withheld by namethe capture body is served rather than the correctionthe facet line says it is withheld; the audit line records not-exposed
a promoted note is missing or unreadablethe capture body is servedthe facet line says unreadable; the audit line records unreadable

A stray VAULT_PATH was honoured at one point, and that name is too generic to claim — it is set for unrelated reasons on real machines, and the result was a server serving a different vault while reporting that vault's config as if correct. Only OM_VAULT_PATH is read now.

The install is two steps, and both are required
{
  "mcpServers": {
    "om": {
      "command": "node",
      "args": ["<absolute path to your vault>/.claude/scripts/om-mcp.mjs"]
    }
  }
}

That goes in the consuming project's .mcp.json. Then add a short section to that project's own instruction file (CLAUDE.md or AGENTS.md) telling it the vault exists and to consult it.

Step 2 is not documentation garnish. Measured: with the server wired and no repo-side instruction, a session made zero vault calls and implemented a design the vault had recorded as explicitly rejected. With the instruction present, it refused and cited the note. This follows directly from the prohibition/routing asymmetry above — and it makes the repo-side snippet a shipped deliverable, since a server installed without it is a server that gets ignored.

Do not register the raw qmd server in a consuming repo. It searches every note directly, with no notion of which memories were written for which project, so the repo matches against lessons meant for unrelated ones. Applying declared scope on top of the index is exactly what om adds, and going around it returns the wrong things, not merely more of them.


Multi-Agent Portability

The same scripts serve three agents. Each agent has its own config file mapping equivalent lifecycle events to the shared scripts. The checklist runs on Stop for Claude Code and Codex (per response, deduped) and on SessionEnd for Gemini (at shutdown). The script branches on the documented event name and on what a payload field means, never on which agent sent it. Where agents spell one field differently, every spelling is read, so the field can never tell the agents apart: Grok's Stop sends the re-entry flag as stopHookActive, and the checklist reads it beside stop_hook_active (#323). Matchers differ the same way. Claude Code and Codex read a SessionStart matcher as a regex over the source (startup|resume|clear|compact). Gemini compares a lifecycle matcher to the source as an exact string, with "" or "*" matching all (gemini-cli hookPlanner.ts, as of 2026-10-13). A startup|resume matcher once matched nothing there, so session-start never ran under Gemini (#352). tests/hook-config.test.ts checks every agent's SessionStart matcher under that agent's own rule.

Session-boundary output is JSON-or-nothing on all three agents. On the hook path session-start.ts prints one hookSpecificOutput envelope too, for every agent (#340). stop-checklist.ts emits {} when a Stop has nothing new to say. What the user sees is systemMessage, the one user-facing field all three agents share: on a Stop whose findings changed, a one-line-per-section summary, with the full report saved for the next prompt's UserPromptSubmit to hand to the agent unseen. Only when the report cannot be saved does it go out as Stop feedback, which Claude Code prints in full; whether Codex honours Stop feedback is unverified. SessionEnd (Gemini) has no next prompt, so there the full report is systemMessage only. om-wrap-up still owns the deliberate acting pass at the end of a session.

flowchart TB
    subgraph Configs["Per-agent config (event name mapping)"]
        Claude[".claude/settings.json<br/>Claude Code"]
        Codex[".codex/hooks.json<br/>Codex CLI"]
        Gemini[".gemini/settings.json<br/>Gemini CLI"]
    end
    subgraph Shared[".claude/scripts/ (shared core)"]
        S1["session-start.ts"]
        S2["classify-message.ts"]
        S3["validate-write.ts"]
        S5["pre-compact.ts"]
        S6["stop-checklist.ts"]
        S7["qmd-refresh-run.ts<br/>(detached worker, not a hook)"]
    end
    subgraph Manuals["Instruction files"]
        AM["AGENTS.md<br/>(every agent)"]
        RM[".claude/rules/*.md<br/>(Claude-only files, and shared<br/>sections AGENTS.md points to)"]
    end

    Claude --> Shared
    Codex --> Shared
    Gemini --> Shared
    Claude -.reads.-> AM
    Claude -.reads.-> RM
    AM -.points to.-> RM
    Codex -.reads.-> AM
    Gemini -.reads.-> AM

Commands in .claude/commands/ are plain Markdown prompts. Claude Code invokes them as slash commands. Codex and Gemini treat them as regular prompts (users type om-standup without the leading slash). No SDK binding is required.

Adding another agent means writing one more config file, and making sure it reads AGENTS.md: natively, or through a setting the way Gemini's context.fileName does. There is one instruction file, so there is no second manual to keep in sync. .claude/rules/ holds the Claude-only files and the shared sections AGENTS.md points to: Claude Code and Grok load every file there at session start, and other agents read a shared one when a pointer sends them.

Subagents on other agents

The subagents in .claude/agents/ handle isolated tasks (brag spotting, vault auditing, cross-linking, etc.). The prompt content is agent-agnostic markdown.

  • Codex CLI: as of the Skills launch (Dec 2025), Codex discovers skills at .agents/skills/<name>/SKILL.md (directory-per-skill; frontmatter requires name and description). Mirror each .claude/agents/*.md into a SKILL.md, keeping the prompt body intact. See the Codex Skills docs for the full schema.

  • Gemini CLI: agents live in .gemini/agents/. Copy the files and adapt the YAML frontmatter fields to Gemini's schema.

The om server sits outside this table on purpose. It speaks MCP over stdio and knows nothing about which harness is on the other end, so it needs no per-agent config at all — any MCP-capable client registers it the same way.


Vault-First Memory

There are two memory systems, and the distinction is load-bearing:

flowchart LR
    SessionStart["SessionStart hook<br/>(.claude/scripts/session-start.ts)"]
    subgraph Ephemeral["~/.claude/ (not git-tracked)"]
        MemIndex["MEMORY.md<br/>(auto-loaded index)"]
    end
    subgraph Durable["Vault (git-tracked)"]
        NorthStar["brain/North Star.md"]
        BrainIdx["brain/Memories.md<br/>(topic index)"]
        Gotchas["brain/Gotchas.md"]
        Patterns["brain/Patterns.md"]
        Decisions["brain/Key Decisions.md"]
    end

    SessionStart ==>|reads every session| NorthStar
    MemIndex -->|points at| BrainIdx
    MemIndex -->|points at| Gotchas
    MemIndex -->|points at| Patterns
    MemIndex -->|points at| Decisions
    BrainIdx --> Gotchas
    BrainIdx --> Patterns
    BrainIdx --> Decisions

Two load paths into a session, both landing in the vault:

  • Pointer indirection — ~/.claude/.../MEMORY.md is Claude Code's private auto-memory directory. The template uses it only to hold a thin index that points at vault locations. Topics fire on demand when the conversation touches them.

  • Direct injection — brain/North Star.md is loaded by the SessionStart hook on every session as its own context block. It's the goals document; it needs to be present every time, not only when triggered.

All actual memory content lives in brain/ as real Obsidian notes — queryable by QMD, visible in the graph, and shared across every agent (Claude Code, Codex, Gemini) because they all read the same vault.

The rule that enforces this: "when asked to remember, write to the relevant brain/ topic note, not to ~/.claude/." It is restated in AGENTS.md because it is the easiest rule to break and the hardest to detect breaking.

Two stores, one vault

brain/ and memories/ look similar and answer different questions. Keeping them apart is deliberate.

brain/memories/
written bya session inside the vault, or youa session in another repo, through om
shapecurated topic notes, edited over timeappend-only atomic entries under YYYY/MM/
reachthe whole vault, alwaysdeclared per entry — scope, projects[], platforms[]
correctionsedit the notea new entry that supersedes the old, which is kept and back-linked
browsingthe note itself, and the graphbases/Memories.base — Recent, By project, Needs review, Superseded, General reach
served as an MCP resourceyes, if inside an exposed rootnever — reach is per caller, so the note surface would bypass it

Both are Markdown in the vault, both are in the graph, both are git-tracked. The difference is that a brain/ note is a document you maintain, while a memory is an immutable claim with a declared audience and a confidence level.

The third store is ~/.claude/.../MEMORY.md, which holds no content at all — only pointers. The template creates nothing else there, and validate-write.ts blocks attempts to.


Skills and Commands

The template ships two categories of skills:

  • Obsidian-native skills (kepano/obsidian-skills) in .claude/skills/ — teach the agent Obsidian-flavored Markdown, the Obsidian CLI, Bases, and JSON Canvas. Loaded automatically when relevant.

  • A custom QMD skill in .claude/skills/qmd/ — teaches the agent the preference order for vault retrieval (MCP tools when registered → qmd CLI as fallback → Grep/Glob as last resort) and the signals that should trigger a proactive search (past decisions, incidents, people, architecture, duplicates before creating a note).

Slash commands in .claude/commands/ are operational workflows (e.g. /om-standup, /om-wrap-up, /om-review-brief). Each is a Markdown file with prompt instructions. Subagents in .claude/agents/ are invoked by those commands to keep heavy operations (Slack archaeology, PR deep scans, vault migration) out of the main context window.

The rule for adding a new command: if it produces durable knowledge, it should write to the vault. If it would only make sense within one session, it is probably better as a prompt pattern than a command.


Extension Seams

The design makes these changes easy:

ChangeTouch this
Add a new note typevault-manifest.json → frontmatter_required, and a template in templates/
Isolate this vault from others on the same machineautomatic (folder-derived); override with vault-manifest.json → qmd_index, then re-bootstrap
Add a new classification category.claude/scripts/classify-message.ts + AGENTS.md routing rules
Add a new lifecycle behaviorA new script in .claude/scripts/ wired into all three agent configs
Add a new agent (Cursor, Windsurf, …)New config file mapping events to existing scripts; the agent reads AGENTS.md
Add a new subagentA new Markdown file in .claude/agents/, referenced from the command that invokes it
Add a new Base viewA new .base file in bases/, embedded from Home.md if it should surface
Change which notes om servesvault-manifest.json → mcp_exposed_roots / mcp_never_expose, or tag a note private
Move the memory storeRename the folder in Obsidian — discovery finds it and health reports the drift; pin it with memory_root to be explicit
Add a new om toolA declaration in .claude/scripts/lib/mcp-tools.ts + a case in mcp-server.ts — the description is what the model reads when deciding to call it
Change what the eager layer may spendvault-manifest.json → eager_layer_budget_bytes (hook path), eager_layer_instruction_budget_bytes (mod path), listing_collapse_threshold

The design is hostile to these changes (on purpose):

  • Storing memories outside the vault. The whole point of brain/ and memories/ is portability and graph visibility.

  • Bypassing the manifest. If a new component needs to know the index name, the memory root, or the infrastructure boundary, it should read the manifest rather than hardcode.

  • Hardcoding agent event names inside scripts. Event name translation is a config-layer concern.

  • Scoping vault notes per calling repo. An earlier revision did this — a repo saw only its own notes — and it killed the most valuable measured capability, because the answers worth having come from connecting projects. Memories declare their reach; notes do not.

  • Adding a second read path. Any new surface that answers "which notes exist" must resolve through visibleFiles rather than walking the vault itself — and any surface that answers "may this one path be read" must call resolveExposedNote rather than re-checking roots. Both have already been re-derived once each, and both re-derivations shipped serving notes the policy withholds.


Upgrade Path

Template versions are tracked in vault-manifest.json with fingerprints that let /om-vault-upgrade detect an older vault's version by presence or absence of specific files. The migrator uses the infrastructure/user-content split to decide what to overwrite versus preserve. CHANGELOG.md documents what changed in each version.

The long-term stability guarantee is narrow: the manifest keys (qmd_index, infrastructure, user_content_roots, frontmatter_required, memory_root), the hook script names under .claude/scripts/, the om-mcp.mjs launcher path that consuming repos put in their .mcp.json, and the folder layout for user content. Everything else — including command names, subagent internals, and classification logic — is allowed to evolve between versions.

memories/ is listed in user_content_roots, so an upgrade preserves it rather than treating it as template infrastructure. The launcher path is on this list because it is the one string that lives outside the vault, written into other repositories' config — moving it silently breaks every consumer.

Install Paths

Two ways to bring obsidian-mind into a new directory:

git clone — the original. Clone the repo, open the folder in Obsidian, talk to the agent. Zero machinery beyond git. Every file ships verbatim; no install-time substitution. The hook scripts under .claude/scripts/ only run when the agent triggers them. This path is the long-standing default and the one new contributors use to read the codebase.

shardmind install — v6. ShardMind is a package manager for Obsidian vault templates that produces the same vault as git clone plus a .shardmind/ sidecar. A wizard collects four values (user_name, org_name, vault_purpose, qmd_enabled), gates eleven modules (4 always-on content, 4 removable content, 3 agent — claude / codex / gemini), and runs the lifecycle hooks: bootstrap (.shardmind/hooks/bootstrap.ts) initializes git and optionally bootstraps QMD when enabled, and personalize (.shardmind/hooks/personalize.ts) writes the user's name into brain/North Star.md. The shard contract is locked by three invariants:

  1. Invariant 1 — clone-equivalence under defaults. shardmind install --defaults produces a vault byte-equivalent to git clone modulo Tier 1 exclusions (.git, .github, ephemeral .obsidian/workspace*.json), the engine metadata under .shardmind/, and a vault-root shard-values.yaml.

  2. Invariant 2 — hooks no-op on defaults. Managed-file edits (e.g., the North Star personalization) live in the personalize hook, which the engine refuses to call when every value is at its default — so the invariant is engine-enforced, not hook-checked. With every value at its default, the install remains byte-equivalent to clone.

  3. Invariant 3 — post-update is additive-only. The post-update hook restricts managed-file writes to ctx.newFiles (paths added by the new version), preventing clobbers of the merge engine's three-way resolution of user edits.

The contract surface lives at .shardmind/shard.yaml (manifest), .shardmind/shard-schema.yaml (values + module declarations), .shardmind/hooks/{bootstrap,personalize,post-update}.ts (lifecycle), and .shardmindignore at repo root (excludes CONTRIBUTING.md, README translations, marketing media from the install). Spec: ShardMind docs/SHARD-LAYOUT.md.

Additive principle. shardmind install produces a strictly larger vault than git clone — never smaller, never different on shared paths under defaults. Deleting .shardmind/ and shard-values.yaml from an installed vault leaves a working clone-equivalent vault. The same is true on the source side: deleting .shardmind/ from this repo would produce a v5.1-shape working vault. ShardMind extends the clone experience; it doesn't replace it.

shardmind update (v6+) three-way-merges your edits with upstream changes — the moat that git pull doesn't provide for templates with installed-time personalization. /om-vault-upgrade remains the path for migrating a v5.x clone or arbitrary vault into v6 in place; once installed, shardmind update (or shardmind adopt for retroactive adoption) takes over.

329 files · 3 not served here

Advantages

  • Provides persistent memory across sessions, preventing agents from starting from zero. evidence: README.md
  • Uses local semantic search for efficient, offline retrieval of information. evidence: README.md
  • Automates vault hygiene and organization through lifecycle hooks. evidence: README.md

Limitations

  • Requires manual setup of hooks and optional dependencies like QMD for full functionality. evidence: README.md
  • Semantic search indexing can be resource-intensive on machines without a dedicated GPU. evidence: README.md
  • Hook scripts rely on experimental Node.js flags that may change in future versions. evidence: README.md

Security

Risk level: High risk

This brain runs

Agent instructions that run or fetch something (82)

  • .claude/agents/brag-spotter.md: `git log --since="<quarter start>" --oneline` on the vault itself
  • .claude/agents/correction-sweep.md: Run both.
  • .claude/agents/review-prep.md: **Git History** (optional): `git log --since="<start>" --until="<end>" --oneline` for volume of vault activity during the period.
  • .claude/agents/vault-librarian.md: Run a full health check and produce a maintenance report.
  • .claude/agents/vault-librarian.md: **Orphan Detection**: Run `obsidian orphans` (or `grep -rL '\[\[' work/ org/ perf/ brain/ reference/` as fallback).
  • .claude/agents/vault-librarian.md: **Broken Wikilinks**: Run `obsidian unresolved` (or grep for `\[\[.*\]\]` patterns and check if targets exist).
  • .claude/agents/vault-migrator.md: **Task**: Execute the migration plan — read from source, transform, write to target.
  • .claude/commands/om-correct.md: Run it **at the moment of correction**, not on a schedule.
  • .claude/commands/om-humanize.md: The user can run `git diff` for the full picture.
  • .claude/commands/om-humanize.md: If charcount matters (review content in `perf/`), verify limits after editing with `node --experimental-strip-types .claude/scripts/charcount.ts`.
  • .claude/commands/om-incident-capture.md: "Should I run `/om-vault-audit` to verify everything links properly?"
  • .claude/commands/om-intake.md: No arguments needed — just drop files and run.
  • .claude/commands/om-intake.md: Run this command after.
  • .claude/commands/om-intake.md: Before routing, run `qmd query "<meeting topic or person name>"` to find existing notes the content should attach to.
  • .claude/commands/om-meeting.md: **Search the vault** — run `qmd query "$ARGUMENTS"` and `qmd vsearch "$ARGUMENTS"` to find everything related to the topic, including semantically related content
  • .claude/commands/om-prep-1on1.md: **Check open tasks** — run `obsidian tasks daily todo`;
  • .claude/commands/om-prep-1on1.md: Run `/om-capture-1on1 $ARGUMENTS` to file the notes into `work/1-1/` and update the person note.
  • .claude/commands/om-project-archive.md: git mv "work/active/<Note>.md" "work/archive/YYYY/"
  • .claude/commands/om-project-archive.md: git mv "work/active/<Topic>" "work/archive/YYYY/<Topic>"
  • .claude/commands/om-project-archive.md: Run a quick check that no wikilinks are broken (Obsidian resolves by name, so moves shouldn't break links)
  • .claude/commands/om-review-peer.md: PR evidence from `perf/evidence/<Name> PRs - <cycle>.md` (run `/om-peer-scan` if missing)
  • .claude/commands/om-review-peer.md: Use `node --experimental-strip-types .claude/scripts/charcount.ts <file> "<section>" "" <limit>` to verify.
  • .claude/commands/om-review-peer.md: Run these before presenting the draft:
  • .claude/commands/om-review-peer.md: [ ] All sections within your tool's character limit (use `node --experimental-strip-types .claude/scripts/charcount.ts <file> "<section>" "" <limit>`)
  • .claude/commands/om-self-review.md: [ ] All sections within your tool's character limit (use `node --experimental-strip-types .claude/scripts/charcount.ts <file> "<section>" "" <limit>`)
  • .claude/commands/om-standup.md: Run the morning standup.
  • .claude/commands/om-standup.md: Re-reading `Home.md`, `brain/North Star.md`, or re-running `git log` doubles the token cost for zero new information.
  • .claude/commands/om-tidy.md: Run on demand, during wrap-up when flags are present, or when the flag list has visibly piled up.
  • .claude/commands/om-tidy.md: **Completed-not-archived** — `git mv` from `work/active/` to `work/archive/YYYY/`;
  • .claude/commands/om-tidy.md: `node --experimental-strip-types .claude/scripts/tidy-fix.ts [--apply]` runs the DETERMINISTIC subset from cron with no agent: archives completed work, migrates misplaced memory files (copy → regenerate index → verify → remove), and refuses every judgment class with a pointer back here.
  • .claude/commands/om-tidy.md: Dry-run by default.
  • .claude/commands/om-vault-audit.md: If a note is in the wrong folder, move it with `git mv`
  • .claude/commands/om-vault-upgrade.md: /om-vault-upgrade --dry-run # show the upgrade plan only
  • .claude/commands/om-vault-upgrade.md: /om-vault-upgrade <path-to-source-vault> --dry-run
  • .claude/commands/om-vault-upgrade.md: /om-vault-upgrade --source <path-to-source-vault> [--dry-run]
  • .claude/commands/om-vault-upgrade.md: `--dry-run` — produce the plan without changing anything
  • .claude/commands/om-vault-upgrade.md: upgrade with `npm i -g shardmind@latest`).
  • .claude/commands/om-vault-upgrade.md: node --experimental-strip-types .claude/scripts/upgrade-plan.ts
  • .claude/commands/om-vault-upgrade.md: **Managed** (`.shardmind/state.json` exists): it reads `shardmind --json` and `shardmind update --dry-run --json`.
  • .claude/commands/om-vault-upgrade.md: It reads `shardmind adopt … --from-version <v> --dry-run --yes --json` and hashes each differing file against that release.
  • .claude/commands/om-vault-upgrade.md: With `--dry-run`, stop here.
  • .claude/commands/om-vault-upgrade.md: ShardMind executes only through its terminal UI (`--json` works with `--dry-run` only), so run it non-interactively: `CI=true … --yes < /dev/null`.
  • .claude/commands/om-vault-upgrade.md: **Conflicts:** ask the user to run `!
  • .claude/commands/om-vault-upgrade.md: A managed update deletes an untouched `CLAUDE.md` or `GEMINI.md` in that same run;
  • .claude/commands/om-vault-upgrade.md: node --experimental-strip-types .claude/scripts/upgrade-plan.ts --apply-behind
  • .claude/commands/om-vault-upgrade.md: **Conflicts:** have the user run `!
  • .claude/commands/om-vault-upgrade.md: The hooks run: `echo '{}' | node --experimental-strip-types .claude/scripts/session-start.ts` exits 0 and prints the session context, as one JSON object whose `hookSpecificOutput.additionalContext` carries it.
  • .claude/commands/om-vault-upgrade.md: **Not found**: run version fingerprint detection from the target's `vault-manifest.json`:
  • .claude/commands/om-vault-upgrade.md: Run Tier 0 (vault shape detection) to understand the organizational pattern
  • .claude/commands/om-vault-upgrade.md: On re-run, compute the **source hash** for each source file and compare against the logged source hash:
  • .claude/commands/om-vault-upgrade.md: Say 'go' to execute."
  • .claude/commands/om-vault-upgrade.md: **For `--dry-run`**: write the plan to `thinking/vault-upgrade-plan-YYYY-MM-DD.md` and stop.
  • .claude/commands/om-vault-upgrade.md: ⚠ 3 orphan notes (no inbound links) — run /om-vault-audit to fix
  • .claude/commands/om-vault-upgrade.md: "Run `/om-vault-audit` for a full structural check"
  • .claude/commands/om-weekly.md: **`brag-spotter`** — run with weekly scope to find uncaptured wins and competency gaps
  • .claude/commands/om-weekly.md: `git log --since="7 days ago" --oneline --no-merges` — all vault changes
  • .claude/commands/om-weekly.md: Run the `brag-spotter` subagent with its standard (quarterly) scope, then filter its findings down to wins that clearly occurred in the past 7 days.
  • .claude/commands/om-wrap-up.md: **`brag-spotter`** — run at the end to find uncaptured wins and competency gaps from the session
  • .claude/commands/om-wrap-up.md: Auto-memory index — if brain/ notes were added or their descriptions changed, REGENERATE the index instead of hand-editing it: `node --experimental-strip-types .claude/scripts/generate-memory-index.ts > <your ~/.claude/projects/<slug>/memory/MEMORY.md path>`.
  • .claude/skills/defuddle/SKILL.md: If not installed: `npm install -g defuddle`
  • .claude/skills/excalidraw-diagram/SKILL.md: When generating an Excalidraw diagram, **automatically execute these steps:**
  • .claude/skills/excalidraw-diagram/SKILL.md: 1. Open https://excalidraw.com · uses the network
  • .claude/skills/excalidraw-diagram/SKILL.md: 1. Open https://dai-shi.github.io/excalidraw-animate/ · uses the network
  • .claude/skills/obsidian-bases/SKILL.md: | Function | Signature | Description | |----------|-----------|-------------| | `date()` | `date(string): date` | Parse string to date (`YYYY-MM-DD HH:mm:ss`) | | `now()` | `now(): date` | Current date and time | | `today()` | `today(): date` | Current date (time = 00:00:00) | | `if()` …[+244 chars] · uses the network
  • .claude/skills/obsidian-cli/SKILL.md: Run `obsidian help` to see all available commands.
  • .claude/skills/obsidian-cli/SKILL.md: Run JavaScript in the app context:
  • .claude/skills/obsidian-cli/SKILL.md: Run `obsidian help` to see additional developer commands including CDP and debugger controls.
  • .claude/skills/qmd/SKILL.md: Use when the MCP server isn't registered (older vault clones, Windows install drift, disabled in `.mcp.json`), or for one-off shell checks outside a session.
  • .claude/skills/qmd/SKILL.md: The template ships `qmd_index` empty so every install gets its own store without being asked a question.
  • .claude/skills/qmd/SKILL.md: The SessionStart hook and the PostToolUse debounced refresh both run this automatically;
  • .claude/skills/qmd/SKILL.md: `qmd --index <name> embed` — Regenerate vector embeddings (slower, run after bulk changes).
  • .claude/skills/qmd/SKILL.md: Run the bootstrap once:
  • .claude/skills/qmd/SKILL.md: node --experimental-strip-types .scripts/qmd-bootstrap.ts
  • .claude/skills/qmd/SKILL.md: Idempotent — safe to re-run.
  • .claude/skills/qmd/SKILL.md: Run `qmd --index <name> update && qmd --index <name> embed` in the shell.
  • .claude/skills/qmd/SKILL.md: The SessionStart hook and the PostToolUse debounced refresh run `update` automatically, but `embed` should be run explicitly after sessions that create many notes.
  • AGENTS.md: Stale Actives), `Recently Touched` (recency by real mtime — the answer to "what's most recent", not filename dates), `Incidents`, `People Directory`, `1-1 History`, `Review Evidence`, `Competency Map`, `Templates` | | `work/` | Work notes index | `Index.md` (detailed MOC) | | `work/ac …[+1219 chars]
  • AGENTS.md: See `.claude/rules/obsidian-cli.md`, or run `obsidian help`.
  • AGENTS.md: Codex will find and execute the command file.
  • AGENTS.md: On a fresh clone, run `node --experimental-strip-types .scripts/qmd-bootstrap.ts` once to build the index.
  • AGENTS.md: Run `/om-standup`.
  • AGENTS.md: **Zero data loss**: when reorganizing, always use `git mv`.

Hooks (14)

  • .claude/settings.json (SessionStart): node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+17 chars]
  • .claude/settings.json (UserPromptSubmit): node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+20 chars]
  • .claude/settings.json (PostToolUse): node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+18 chars]
  • .claude/settings.json (PreCompact): node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+15 chars]
  • .claude/settings.json (Stop): node --disable-warning=ExperimentalWarning --experimental-strip-types "$(r="${CLAUDE_PROJECT_DIR:-.}"; d="$r"; while [ ! -f "$d/vault-manifest.json" ] && p="$(dirname "$d")" && [ "$d" != "$p" ]; do d="$p"; done; [ -f "$d/vault-manifest.json" ] || d="$r"; printf %s "$d")/.claude/scripts/ …[+18 chars]
  • .codex/hooks.json (SessionStart): node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/session-start.ts"
  • .codex/hooks.json (UserPromptSubmit): node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/classify-message.ts"
  • .codex/hooks.json (PostToolUse): node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/validate-write.ts"
  • .codex/hooks.json (Stop): node --disable-warning=ExperimentalWarning --experimental-strip-types "${CODEX_PROJECT_DIR:-.}/.claude/scripts/stop-checklist.ts"
  • .gemini/settings.json (SessionStart): node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/session-start.ts
  • .gemini/settings.json (BeforeAgent): node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/classify-message.ts
  • .gemini/settings.json (AfterTool): node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/validate-write.ts
  • .gemini/settings.json (PreCompress): node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/pre-compact.ts
  • .gemini/settings.json (SessionEnd): node --disable-warning=ExperimentalWarning --experimental-strip-types .claude/scripts/stop-checklist.ts

MCP servers (2)

  • .mcp.json (qmd): node .claude/scripts/qmd-mcp.mjs
  • README.md (om): node /absolute/path/to/your-vault/.claude/scripts/om-mcp.mjs
  • Medium risk: runs-instruction-instruction-network (.claude/skills/excalidraw-diagram/SKILL.md:456)
  • Medium risk: runs-instruction-instruction-network (.claude/skills/excalidraw-diagram/SKILL.md:470)
  • Medium risk: runs-instruction-instruction-network (.claude/skills/obsidian-bases/SKILL.md:181)
  • Low risk: network-instructions (.claude/commands/om-vault-upgrade.md:30)
  • Low risk: scripts-present (.claude/scripts/classify-message.ts)
  • Low risk: scripts-present (.claude/scripts/lib/active-hygiene.ts)
  • Low risk: scripts-present (.claude/scripts/lib/atomic-write.ts)
  • Low risk: scripts-present (.claude/scripts/lib/correction-sweep.ts)
  • Low risk: scripts-present (.claude/scripts/lib/env-path.ts)
  • Low risk: scripts-present (.claude/scripts/lib/frontmatter.ts)
  • Low risk: scripts-present (.claude/scripts/lib/hint-state.ts)
  • Low risk: scripts-present (.claude/scripts/lib/hook-io.ts)
  • Low risk: scripts-present (.claude/scripts/lib/instruction-files.ts)
  • Low risk: scripts-present (.claude/scripts/lib/main-guard.ts)
  • Low risk: scripts-present (.claude/scripts/lib/matcher.ts)
  • Low risk: scripts-present (.claude/scripts/lib/mcp-exposure.ts)
  • Low risk: scripts-present (.claude/scripts/lib/mcp-qmd-client.ts)
  • Low risk: scripts-present (.claude/scripts/lib/memory-promoted.ts)
  • Low risk: scripts-present (.claude/scripts/lib/memory-recall.ts)
  • Low risk: scripts-present (.claude/scripts/lib/memory-supersede.ts)
  • Low risk: scripts-present (.claude/scripts/lib/memory-write.ts)
  • Low risk: scripts-present (.claude/scripts/lib/om-mod.ts)
  • Low risk: scripts-present (.claude/scripts/lib/project-dir.ts)
  • Low risk: scripts-present (.claude/scripts/lib/prose-width.ts)
  • Low risk: scripts-present (.claude/scripts/lib/qmd-cwd.ts)
  • Low risk: scripts-present (.claude/scripts/lib/qmd-refresh.ts)
  • Low risk: scripts-present (.claude/scripts/lib/qmd.ts)
  • Low risk: scripts-present (.claude/scripts/lib/read-head.ts)
  • Low risk: scripts-present (.claude/scripts/lib/regex.ts)
  • Low risk: scripts-present (.claude/scripts/lib/reinject.ts)
  • Low risk: scripts-present (.claude/scripts/lib/report-key.ts)
  • Low risk: scripts-present (.claude/scripts/lib/session-start-run.ts)
  • Low risk: scripts-present (.claude/scripts/lib/session-start.ts)
  • Low risk: scripts-present (.claude/scripts/lib/signals.ts)
  • Low risk: scripts-present (.claude/scripts/lib/stop-handoff.ts)
  • Low risk: scripts-present (.claude/scripts/lib/stop-report.ts)
  • Low risk: scripts-present (.claude/scripts/lib/write-targets.ts)
  • Low risk: scripts-present (.claude/scripts/pre-compact.ts)
  • Low risk: scripts-present (.claude/scripts/qmd-mcp.mjs)
  • Low risk: scripts-present (.claude/scripts/session-start.ts)
  • Low risk: scripts-present (.claude/scripts/stop-checklist.ts)
  • Low risk: scripts-present (.claude/scripts/validate-write.ts)
  • Low risk: network-instructions (.claude/skills/defuddle/SKILL.md:10)

AutoGineer scanned breferrari/obsidian-mind@fae7be4 (fae7be4).

Attribution and licence

First committed by
GitHub avatar of breferraribreferrari
Declared authors
  • Steph Ango (declared in .claude-plugin/plugin.json)
Publisher
Brenno Ferrari (Community)
Source
breferrari/obsidian-mind · commit fae7be4
Licence
MIT License (files served here)
  • ATTRIBUTION.md (written by AutoGineer)
Synced
Contributors
breferrari, Yi-111-a, sedetweiler, A220man, HoangThai18

AutoGineer is not affiliated with or endorsed by breferrari.

Install from source

3 of its files are not served here, so there is no ZIP. breferrari/obsidian-mind at fae7be4