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

An Obsidian research vault on the LLM-wiki pattern: you bring sources, and an agent reads them and keeps a linked wiki of concepts, entities and comparisons up to date. You steer, read and correct. Every note is plain Markdown that Obsidian opens without the agent.
wiki-mind is a ShardMind shard, built for Claude Code.
sources/ | One note per paper or article: what it says, its key claims, each linked to what it informs. |
concepts/ | Atomic ideas, each citing its sources. |
entities/ | Named systems, tools and people, each citing its sources. |
syntheses/ | Comparisons: "X vs Y" notes over two or more concepts or entities. |
questions/ | The intake queue: questions and leads, each open, answered or dropped. |
inbox/ | Raw material before it is read: PDFs, saved pages, notes. |
Index.md | The entry point: the agent's one-line annotations above a Bases view of each note type. |
templates/, bases/ | A template and a Bases view per note type. |
CLAUDE.md | The agent's manual for the vault. |
Claude Code hooks keep the wiki honest as you work. The session starts with the wiki's state and open questions. Each write is checked against the note type's rules. Each answer ends with a short drift report: notes that cite no source, one-sided comparisons, orphans, notes Index.md doesn't annotate, stale questions.
| Command | Does |
|---|---|
/wiki-ingest <url or inbox path> | Reads a source in full, writes its note, and updates the concepts and entities it informs, with every claim linked back to it. |
/wiki-synthesize <X> vs <Y> | Compares notes the wiki already has, citing their sources. Refuses when a side has no note yet. |
/wiki-question <text> | Files a question in the intake queue. |
/wiki-lint | Checks the whole wiki for drift and fixes what it can. |
You need Obsidian 1.12 or later, Node.js 22.6 or later, ShardMind 0.2.1 or later (npx fetches it), and Claude Code.
mkdir my-wiki && cd my-wiki
npx shardmind install breferrari/wiki-mind
If your ShardMind can't find it by name, use the full GitHub reference: npx shardmind install github:breferrari/wiki-mind.
The installer asks three questions: your name, what the wiki is about, and whether to use QMD search. Then open the folder as a vault in Obsidian, and start Claude Code in it.
Or clone it. git clone https://github.com/breferrari/wiki-mind my-wiki gives the same vault with the defaults. To receive updates, run npx shardmind adopt breferrari/wiki-mind in it.
Updates. In a vault installed with ShardMind, npx shardmind update brings in a new release and merges it with your edits. A file you changed is never overwritten without asking.
With QMD installed (npm install -g @tobilu/qmd), the agent searches the wiki semantically, and the hooks keep the index fresh as notes change. Each vault gets its own index. Without QMD, everything still works, and the agent searches with grep.
Windows, macOS and Linux. CI installs the vault and runs every hook on all three.
hooks/register.ts 284 lines1import { 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 * wiki-mind's Claude Code mod (obsidian-mind#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 (obsidian-mind#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 (obsidian-mind#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: "wiki-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: "wiki-mind", key: "queued" } as const, null);
43/** Whether an urgent finding has had its turn since the person last spoke. */
44const urgentSpent = atom({ plugin: "wiki-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: "wiki-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: "wiki-mind", key: "inFlight" } as const, null);
54
55type Queued = NonNullable<PluginState["wiki-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["wiki-mind"]["inFlight"] = null;
263 await update($, inFlight, (now) => {
264 held = now;
265 return now === null || e.text === "" ? now : null;
266 });
267 const waiting: PluginState["wiki-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};
284hooks/context.ts 37 lines1import type { PromptContextResult } from "claude-code";
2
3/** The name the context renders under when no instruction file can carry it. */
4export const CONTEXT_BLOCK = "wiki-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}
37hooks/stop.ts 84 lines1import 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"` (obsidian-mind#264), and how it is shown (obsidian-mind#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 in obsidian-mind on 2.1.288 as `obsidian-mind: …`,
42 * and `wiki-mind: …` here on 2.1.289 (the real-session test bed asserts it).
43 */
44export function summaryLine(report: StopReport): string {
45 const what = report.claims.length > 0 ? report.claims.join(" · ") : "wrap-up checklist";
46 // The settings hook's wording (lib/stop-report.ts SUMMARY_TRAILER), except that an
47 // urgent finding sends the report at once, in a turn of its own.
48 const where = report.urgent === undefined ? "the full report reaches the agent with your next message" : "the full report goes to the agent now";
49 return `vault check: ${what} · ${where}`;
50}
51
52/**
53 * The text to return from `turn.complete`. A hook below that already set a
54 * line of its own (its text differs from the answer) keeps it; ours goes
55 * after it rather than replacing it.
56 */
57export function withLine(textBelow: string, answer: string, line: string): string {
58 return textBelow !== answer && textBelow.trim() !== "" ? `${textBelow}\n${line}` : line;
59}
60
61/**
62 * Whether a prompt from this origin should carry the queued report: the
63 * person's own prompts (typed, over Remote Control, or through the SDK) and
64 * this mod's urgent prompt. A peer's message, a notification or a schedule
65 * is not the person writing, and must not consume the report.
66 */
67/**
68 * Whether a turn that started with `turnText` ran the prompt `promptText`:
69 * the same text, or queued prompts folded into one turn with it among them as
70 * whole lines. Never a substring: a held "ok" is not run by "looks ok now".
71 */
72export function ranIn(turnText: string, promptText: string): boolean {
73 return `\n${turnText}\n`.includes(`\n${promptText}\n`);
74}
75
76export function carriesReport(origin: PromptOrigin | undefined): boolean {
77 return fromPerson(origin) || (origin?.kind === "plugin" && origin.name === "wiki-mind");
78}
79
80/** Whether the person sent this prompt: typed, over Remote Control, through the SDK, or as the session's owner pinging it from Slack. */
81export function fromPerson(origin: PromptOrigin | undefined): boolean {
82 return origin === undefined || origin.kind === "composer" || origin.kind === "bridge" || origin.kind === "sdk" || origin.kind === "slack-ping";
83}
84types/index.d.ts 31 lines1// 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 "wiki-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