SLOPSHOPPER

obsidian-mind

Delivers obsidian-mind's session context as an instruction file, so it survives compaction whole and reaches subagents, and shows the vault's Stop report as…

newpromptprocess
★ 4,912v9.1.0MITupdated 2026-10-06breferrari/obsidian-mind/.claude/skills/obsidian-mind
A shopper browsing a rack in a slop shop
README

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

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

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

Claude Code Codex CLI Gemini CLI Obsidian Obsidian CLI Obsidian Skills QMD Node 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

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

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

📦 Install via ShardMind (recommended)

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
  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).

🔍 Recommended: QMD Semantic Search

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. Claude is instructed (via CLAUDE.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.

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 CLAUDE.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

Five 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 CLAUDE.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 obsidian-mind: vault check: … beneath Claude's reply, and 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
AlwaysCLAUDE.md + 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, and Gemini CLI. The vault conventions in CLAUDE.md, the hook scripts in .claude/scripts/, and the commands in .claude/commands/ are all agent-agnostic — pure Markdown, TypeScript, and shell with no SDK dependencies.

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

Codex CLI — reads AGENTS.md natively. 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).

Gemini CLI — reads GEMINI.md natively. Hook config at .gemini/settings.json maps Gemini's event names to the shared hook scripts.

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 is Claude Code-specific. See AGENTS.md for the full portability guide.


🧠 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.

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.

Step 2 — point the consuming project at the vault

Add this to that project's own CLAUDE.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

Source 4 files
hooks/register.ts 284 lines
1import { atom, read, update, type EngineInterface, type PluginState, type Register } from "claude-code";
2import { withSessionContext } from "./context.ts";
3import { carriesReport, fromPerson, parseStopReport, ranIn, summaryLine, withLine } from "./stop.ts";
4
5/**
6 * obsidian-mind's Claude Code mod (#262).
7 *
8 * The vault's settings hooks stay the engine: Codex and Gemini run them, and
9 * so does Claude Code wherever this mod does not load (an older CLI, an
10 * untrusted folder, a session launched in a vault subfolder, a policy that
11 * allows only managed mods). This mod changes how their output reaches the
12 * session, never what it says: it runs the vault's own scripts and delivers
13 * the result through a better channel.
14 *
15 * Session context (#265): `session-start.ts` runs here with
16 * `om_mod: "deliver"`, and its output becomes an instruction file instead of
17 * hook output. Unlike hook output it is not cut at 10,000 characters, it is
18 * re-read whole after compaction and `/clear` instead of shrinking to a
19 * pointer, and general-purpose subagents receive it.
20 *
21 * Stop report (#266): `stop-checklist.ts` runs here with `om_mod: "report"`.
22 * When the findings changed, the user sees one line under the answer and the
23 * agent gets the full report with the next prompt, unseen. A finding marked
24 * urgent gets a turn of its own at once.
25 *
26 * The switch is the event itself: the settings hook is passed
27 * `om_mod: "standdown"` and exits, but only on an event this hook actually
28 * handled. The work is done before `next`, so if it fails the hook throws,
29 * Claude Code skips it, and the settings hook gets the original event and
30 * runs as it would without the mod.
31 *
32 * What the hooks hand each other lives in `$.state`, not in module
33 * variables: the host keeps it for the session, across a hot reload.
34 */
35
36/** Where the delivered context is also written, so /memory opens what the model received. Gitignored. */
37const CONTEXT_FILE = ".claude/session-context.md";
38
39/** The session context this session's instruction file carries; what prompt.context hands the model. */
40const sessionContext = atom({ plugin: "obsidian-mind", key: "context" } as const, null);
41/** The report waiting to be delivered: its text, and its line and urgent finding until they are used. */
42const queued = atom({ plugin: "obsidian-mind", key: "queued" } as const, null);
43/** Whether an urgent finding has had its turn since the person last spoke. */
44const urgentSpent = atom({ plugin: "obsidian-mind", key: "urgentSpent" } as const, false);
45/** Bumped by every start that begins another conversation, so a report in flight across one is not put back. */
46const generation = atom({ plugin: "obsidian-mind", key: "generation" } as const, 0);
47/**
48 * The report a prompt took, until a turn starts with that prompt. A prompt
49 * enters when it is queued, not when its turn starts, and a queued prompt can
50 * be pulled back out of the queue; if a turn starts with another prompt
51 * first, this one never ran, and the report goes back in the queue.
52 */
53const inFlight = atom({ plugin: "obsidian-mind", key: "inFlight" } as const, null);
54
55type Queued = NonNullable<PluginState["obsidian-mind"]["queued"]>;
56
57/**
58 * Which report each session was last given, by session id, and whether it
59 * reached the agent: in `$.store`, not `$.state`, because the store outlives
60 * the process. A `claude --resume` in a new process then neither repeats a
61 * report the agent had nor loses one that was still waiting (the settings
62 * hook's dedupe is file-backed for the same reason). Only the most recent
63 * sessions are kept.
64 */
65const SHOWN = "shown";
66const SHOWN_KEEP = 20;
67type Shown = { readonly key: string; readonly delivered: boolean };
68
69async function shownFor($: EngineInterface, sessionId: string): Promise<Shown | undefined> {
70	const shown = ((await $.store.get(SHOWN)) ?? {}) as Record<string, Shown>;
71	return shown[sessionId];
72}
73
74async function setShown($: EngineInterface, sessionId: string, entry: Shown | null): Promise<void> {
75	const shown = { ...(((await $.store.get(SHOWN)) ?? {}) as Record<string, Shown>) };
76	delete shown[sessionId];
77	if (entry !== null) shown[sessionId] = entry;
78	// Insertion order is recency: drop the oldest sessions past the cap.
79	const ids = Object.keys(shown);
80	for (const id of ids.slice(0, Math.max(0, ids.length - SHOWN_KEEP))) delete shown[id];
81	await $.store.set(SHOWN, shown);
82}
83
84/**
85 * Run one of the vault's hook scripts with `input` on stdin; its stdout, or a
86 * throw. `timeoutMs` matches the script's own timeout in settings.json, so the
87 * mod never waits longer than the hook it replaces would have.
88 */
89async function runScript($: EngineInterface, root: string, script: string, input: object, timeoutMs: number): Promise<string> {
90	const run = await $.process.run(["node", "--disable-warning=ExperimentalWarning", "--experimental-strip-types", `${root}/.claude/scripts/${script}`], {
91		cwd: root,
92		env: { CLAUDE_PROJECT_DIR: root },
93		stdin: JSON.stringify(input),
94		timeoutMs,
95	});
96	if (run.exitCode !== 0 || run.stdout.trim() === "") {
97		throw new Error(`${script} exited ${run.exitCode}: ${run.stderr.slice(0, 300)}`);
98	}
99	// A cut output would stand the hook down for part of what it delivers.
100	if (run.isStdoutTruncated) throw new Error(`${script} printed more than process.run keeps`);
101	return run.stdout;
102}
103
104export const register: Register = (on) => {
105	on("classic.SessionStart", async ($, e, next) => {
106		// Only a compaction continues the same conversation. Every other start
107		// (`/clear`, an in-process `/resume` or fork) may keep this process and
108		// its `$.state`, and a report about another conversation must not ride
109		// the first prompt of this one, so what was queued is dropped.
110		if (e.source !== "compact") {
111			// A report dropped here was never marked delivered, so the session it
112			// was for gets it again at its next Stop; one it already had stays given.
113			await update($, queued, () => null);
114			await update($, urgentSpent, () => false);
115			await update($, generation, (now) => now + 1);
116			await update($, inFlight, () => null);
117			// If this run fails, the settings hook runs instead and prints the full
118			// layer, so the old context is cleared first (and the render redrawn)
119			// or it would ride beside the fresh one. At a compaction the hook
120			// prints only a pointer, trusting the static half to be in the
121			// conversation already; under the mod it never was, so there the last
122			// good context is kept rather than lost.
123			await update($, sessionContext, () => null);
124			$.ui.invalidate("prompt.context");
125		}
126		const root = await $.session.root();
127		const text = await runScript($, root, "session-start.ts", { ...e, om_mod: "deliver" }, 30_000);
128		await update($, sessionContext, () => text);
129		// Not awaited: delivery does not depend on the file, so a slow, hung or
130		// failed write never holds up the session. The file only backs what
131		// /memory shows. Runs are minutes apart (startup, then a compaction), so
132		// two writes landing out of order is not a case worth machinery: a
133		// deadline would need a timer that outlives this hook, and a chain
134		// without one would let a hung write stall every later write.
135		$.fs.write(`${root}/${CONTEXT_FILE}`, text).catch(() => {});
136		$.ui.invalidate("prompt.context");
137		return next({ ...e, om_mod: "standdown" } as typeof e);
138	});
139
140	on("prompt.context", async ($, e, next) => {
141		const below = await next(e);
142		const text = await read($, sessionContext);
143		if (text === null) return below;
144		return withSessionContext(below, `${await $.session.root()}/${CONTEXT_FILE}`, text);
145	});
146
147	on("classic.Stop", async ($, e, next) => {
148		// A turn some Stop hook forced: the settings hook exits on its own.
149		if (e.stop_hook_active) return next(e);
150		const root = await $.session.root();
151		let report: ReturnType<typeof parseStopReport>;
152		try {
153			report = parseStopReport(await runScript($, root, "stop-checklist.ts", { ...e, om_mod: "report" }, 5_000));
154		} catch (error) {
155			// The settings hook runs in this hook's place and hands over its own
156			// report; one still queued here would ride the same prompt beside it.
157			await update($, queued, () => null);
158			throw error;
159		}
160		// Per session, so a new session shows its first report even with the
161		// same findings, as the settings hook's dedupe does.
162		const sessionId = String(e.session_id);
163		const given = await shownFor($, sessionId);
164		const waiting = (record: { readonly sessionId: string; readonly key: string } | null | undefined) =>
165			record?.sessionId === sessionId && record.key === report.key;
166		// The same findings again: skip them if the agent had them, or if they are
167		// still on their way in this process. Not delivered and not on their way
168		// (a resume in a new process, a dropped queue) means queue them again.
169		const already = given?.key === report.key && (given.delivered || waiting(await read($, queued)) || waiting((await read($, inFlight))?.record));
170		if (!already) {
171			await setShown($, sessionId, { key: report.key, delivered: false });
172			// The urgent finding rides inside the report too, so whatever happens
173			// to its own turn, the agent gets it with the report.
174			const text = report.urgent === undefined ? report.agentText : `${report.agentText}\n\nUrgent: ${report.urgent}`;
175			await update($, queued, (): Queued => ({ sessionId, key: report.key, report: text, line: summaryLine(report), urgent: report.urgent ?? null }));
176		}
177		return next({ ...e, om_mod: "standdown" } as typeof e);
178	});
179
180	on("turn.complete", async ($, e, next) => {
181		const done = await next(e);
182		// Only under a main-loop answer that completed: a subagent's turn, an
183		// interrupted one or one an error ended keeps the line for the next.
184		if (e.agentId !== undefined || e.reason !== "answer") return done;
185		// The line and the urgent finding are used once; the report stays queued for the next prompt.
186		let line: string | null = null;
187		let urgent: string | null = null;
188		await update($, queued, (now) => {
189			line = now?.line ?? null;
190			urgent = now?.urgent ?? null;
191			return now === null || now.line === null ? now : { ...now, line: null, urgent: null };
192		});
193		if (line === null) return done;
194		// One urgent turn per prompt the person sends: findings that keep
195		// changing while the agent fixes them must not chain turns. A finding
196		// that gets no turn still reaches the agent inside the queued report.
197		if (urgent !== null && !(await read($, urgentSpent))) {
198			await update($, urgentSpent, () => true);
199			// Never from classic.Stop: the engine refuses a submit that would wait
200			// on the turn the hook may be holding, and names turn.complete instead.
201			// Framed as this plugin's message, so the model knows it is not the
202			// person speaking. The report rides it (prompt.submit below); if the
203			// prompt never enters, the report stays queued for the next one.
204			$.prompt.submit({ text: urgent }).catch(() => {});
205		}
206		return { ...done, text: withLine(done.text, e.answer, line) };
207	});
208
209	on("prompt.submit", async ($, e, next) => {
210		// The person speaking renews the urgent allowance, once their prompt has entered.
211		const renew = async (entered: Awaited<ReturnType<typeof next>>) => {
212			if (entered.drop === undefined && fromPerson(e.origin)) await update($, urgentSpent, () => false);
213			return entered;
214		};
215		if (!carriesReport(e.origin)) return renew(await next(e));
216		// The whole record is taken before `next`, so two prompts entering at
217		// once cannot both carry it, and a report queued while this one enters
218		// is a different record that nothing here touches. Put back if this
219		// prompt never enters (dropped or blocked below, or a throw), unless a
220		// newer one was queued meanwhile.
221		let taken: Queued | null = null;
222		await update($, queued, (now) => {
223			taken = now;
224			return null;
225		});
226		if (taken === null) return renew(await next(e));
227		const record: Queued = taken;
228		const startedIn = await read($, generation);
229		// Put back only into the conversation it was taken from: a `/clear` or
230		// resume while this prompt was entering has dropped the queue on purpose.
231		// Read through `update`, whose function sees writes made during `next`.
232		const putBack = async () => {
233			let now = startedIn;
234			await update($, generation, (g) => {
235				now = g;
236				return g;
237			});
238			if (now === startedIn) await update($, queued, (current) => current ?? record);
239		};
240		// Held before `next`: the prompt's own turn can start inside `next`
241		// (observed on 2.1.288), and turn.start must find it there to count it run.
242		await update($, inFlight, () => ({ text: e.text, record }));
243		let entered: Awaited<ReturnType<typeof next>>;
244		try {
245			entered = await next({ ...e, context: [...(e.context ?? []), record.report] });
246		} catch (error) {
247			await update($, inFlight, () => null);
248			await putBack();
249			throw error;
250		}
251		if (entered.drop !== undefined) {
252			await update($, inFlight, () => null);
253			await putBack();
254			return entered;
255		}
256		return renew(entered);
257	});
258
259	on("turn.start", async ($, e, next) => {
260		// Only the main loop's prompts carry the report; a turn begun without a
261		// prompt (a continuation, text "") says nothing about the queue.
262		let held: PluginState["obsidian-mind"]["inFlight"] = null;
263		await update($, inFlight, (now) => {
264			held = now;
265			return now === null || e.text === "" ? now : null;
266		});
267		const waiting: PluginState["obsidian-mind"]["inFlight"] = held;
268		if (waiting === null || e.text === "") return next(e);
269		// Queued prompts run in order and may be folded into one turn, so a turn
270		// whose text holds the prompt's ran it: delivered, and remembered as
271		// delivered so no later start repeats it. Any other prompt's turn starting
272		// first means the one holding the report left the queue unrun.
273		if (ranIn(e.text, waiting.text)) {
274			const given = await shownFor($, waiting.record.sessionId);
275			if (given?.key === waiting.record.key) await setShown($, waiting.record.sessionId, { key: waiting.record.key, delivered: true });
276		} else {
277			// A start that begins another conversation clears what is in flight,
278			// so a prompt still held here is from this one.
279			await update($, queued, (current) => current ?? waiting.record);
280		}
281		return next(e);
282	});
283};
284
hooks/context.ts 37 lines
1import type { PromptContextResult } from "claude-code";
2
3/** The name the context renders under when no instruction file can carry it. */
4export const CONTEXT_BLOCK = "obsidian-mind";
5
6/**
7 * The first message's context with the session context added: as a project
8 * instruction file, which Claude Code frames like CLAUDE.md, re-reads after
9 * compaction and `/clear`, and gives general-purpose subagents.
10 *
11 * When a hook above rewrote the `claudeMd` text, the files behind it are
12 * unknown and no file can be added (`instructionFiles` is undefined). The
13 * context then rides as a block of its own, so the session still gets it:
14 * the settings hook has already stood down for this event.
15 *
16 * Either way an earlier copy is replaced, never duplicated.
17 */
18export function withSessionContext(below: PromptContextResult, path: string, text: string): PromptContextResult {
19	if (below.instructionFiles) {
20		const others = below.instructionFiles.filter((file) => !samePath(file.path, path));
21		return { ...below, instructionFiles: [...others, { path, kind: "project", content: text }] };
22	}
23	const others = below.blocks.filter((block) => block.name !== CONTEXT_BLOCK);
24	return { ...below, blocks: [...others, { name: CONTEXT_BLOCK, text }] };
25}
26
27/**
28 * One file, however it is spelled: the root comes back from Claude Code in
29 * the OS's form (`C:\vault` on Windows) while the mod appends `/…`, and the
30 * engine may hand a path back normalised: separators and the drive letter's
31 * case are not part of a file's identity.
32 */
33export function samePath(a: string, b: string): boolean {
34	const norm = (p: string) => p.replaceAll("\\", "/").replace(/^([a-z]):/i, (d) => d.toLowerCase());
35	return norm(a) === norm(b);
36}
37
hooks/stop.ts 83 lines
1import type { PromptOrigin } from "claude-code";
2
3/**
4 * The Stop report as the mod receives it from `stop-checklist.ts` run with
5 * `om_mod: "report"` (#264), and how it is shown (#266).
6 */
7export type StopReport = {
8	/** The report's identity: the same findings give the same key. */
9	readonly key: string;
10	/** One short claim per finding, e.g. "1 note(s) marked done but still in active/". */
11	readonly claims: readonly string[];
12	/** The full report, prefaced for the agent. */
13	readonly agentText: string;
14	/**
15	 * Set only for a finding that should not wait for the person's next
16	 * message. The template's report has no such class today; a vault that adds
17	 * one (say, an agent artifact in an unpushed commit) gets an immediate turn.
18	 */
19	readonly urgent?: string;
20};
21
22/** The report in `stop-checklist.ts`'s `report` output, or an error naming what was wrong. */
23export function parseStopReport(stdout: string): StopReport {
24	const report = (JSON.parse(stdout) as { report?: Partial<StopReport> }).report;
25	if (
26		!report ||
27		typeof report.key !== "string" ||
28		!Array.isArray(report.claims) ||
29		!report.claims.every((claim) => typeof claim === "string") ||
30		typeof report.agentText !== "string" ||
31		(report.urgent !== undefined && typeof report.urgent !== "string")
32	) {
33		throw new Error(`stop-checklist.ts returned no usable report: ${stdout.slice(0, 200)}`);
34	}
35	return { key: report.key, claims: report.claims, agentText: report.agentText, ...(report.urgent !== undefined ? { urgent: report.urgent } : {}) };
36}
37
38/**
39 * The line drawn under the answer when the report changed: what drifted, in
40 * the report's own words, and where the rest went. Claude Code shows it after
41 * the mod's name (observed on 2.1.288: `obsidian-mind: …`).
42 */
43export function summaryLine(report: StopReport): string {
44	const what = report.claims.length > 0 ? report.claims.join(" · ") : "wrap-up checklist";
45	// The settings hook's wording (lib/stop-report.ts SUMMARY_TRAILER), except that an
46	// urgent finding sends the report at once, in a turn of its own.
47	const where = report.urgent === undefined ? "the full report reaches the agent with your next message" : "the full report goes to the agent now";
48	return `vault check: ${what} · ${where}`;
49}
50
51/**
52 * The text to return from `turn.complete`. A hook below that already set a
53 * line of its own (its text differs from the answer) keeps it; ours goes
54 * after it rather than replacing it.
55 */
56export function withLine(textBelow: string, answer: string, line: string): string {
57	return textBelow !== answer && textBelow.trim() !== "" ? `${textBelow}\n${line}` : line;
58}
59
60/**
61 * Whether a prompt from this origin should carry the queued report: the
62 * person's own prompts (typed, over Remote Control, or through the SDK) and
63 * this mod's urgent prompt. A peer's message, a notification or a schedule
64 * is not the person writing, and must not consume the report.
65 */
66/**
67 * Whether a turn that started with `turnText` ran the prompt `promptText`:
68 * the same text, or queued prompts folded into one turn with it among them as
69 * whole lines. Never a substring: a held "ok" is not run by "looks ok now".
70 */
71export function ranIn(turnText: string, promptText: string): boolean {
72	return `\n${turnText}\n`.includes(`\n${promptText}\n`);
73}
74
75export function carriesReport(origin: PromptOrigin | undefined): boolean {
76	return fromPerson(origin) || (origin?.kind === "plugin" && origin.name === "obsidian-mind");
77}
78
79/** Whether the person sent this prompt: typed, over Remote Control, through the SDK, or as the session's owner pinging it from Slack. */
80export function fromPerson(origin: PromptOrigin | undefined): boolean {
81	return origin === undefined || origin.kind === "composer" || origin.kind === "bridge" || origin.kind === "sdk" || origin.kind === "slack-ping";
82}
83
types/index.d.ts 31 lines
1// The mod's per-session state: what the host keeps for it for the session,
2// across a hot reload of the module. What must outlive the process (which
3// report each session was shown) is in `$.store` instead; see register.ts.
4
5declare module "claude-code" {
6	interface PluginState {
7		"obsidian-mind": {
8			/** The session context this session's instruction file carries; null when none was delivered. */
9			context: string | null;
10			/**
11			 * The Stop report waiting to be delivered: the session it is for, its full text for the next
12			 * prompt, and the line and urgent finding until the next completed
13			 * answer uses them. Null when nothing is waiting.
14			 */
15			queued: { readonly sessionId: string; readonly key: string; readonly report: string; readonly line: string | null; readonly urgent: string | null } | null;
16			/** Whether an urgent finding has had its turn since the person last spoke. */
17			urgentSpent: boolean;
18			/** Bumped by every start that begins another conversation (startup, /clear, resume, fork). */
19			generation: number;
20			/**
21			 * The report a prompt took, and the prompt's text, until a turn starts
22			 * with that prompt. Null when none is.
23			 */
24			inFlight: {
25				readonly text: string;
26				readonly record: { readonly sessionId: string; readonly key: string; readonly report: string; readonly line: string | null; readonly urgent: string | null };
27			} | null;
28		};
29	}
30}
31