Second brain in Claude Code: vault status band, /vault pane with click-to-triage, /vault-capture for selected text, vault write guard, dashboard auto-refresh.

A Claude Code mod (a plugin of function hooks) for the second-brain vault. It reuses the helpers in ~/.claude/skills/second-brain/helpers, so it needs that skill installed, and it finds the vault the way the skill does.
Hide hides it; /vault brings it back./vault pane: inbox, hub sessions, drafts and hook health, with buttons.Triage inbox runs triage_inbox.py (dry run), shows -> hub (score) under each capture, then File N applies it.triage on a row starts an inbox-triage subagent for that capture; its verdict shows inline with a File under <hub> button.open opens a note in Obsidian; Open dashboard rebuilds and opens the HTML one.archive (orphaned: its finalize already ran) moves it to .drafts/.superseded/; save as summary (crashed or stale) has a subagent write a summary from the draft alone, link it from a hub, validate it, and only then move the draft aside. It never invents a hub or overwrites a note./vault-capture: saves the text you selected (fullscreen mode) to Inbox/.Write into Inbox/, Claude Archive/Sessions/, Notes/ or Daily/ that has no date: frontmatter. Fails open, and does not see Edit or writes through the remote MCP./plugin install second-brain-mod --marketplace Dressi123/second-brain-skill
or for development: claude --plugin-dir /path/to/second-brain-mod.
claude plugin validate .
claude plugin test .
tsc -p . # after the engine has loaded the mod oncehooks/register.tsx 458 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TriageResult, TriageState, VaultSnapshot } from '../types'
5import { SNAPSHOT_PY } from './snapshot'
6import { buildChoices, buildCapture, checkVaultWrite, picked, question } from './vault'
7
8const PANE = 'vault'
9const snap = atom({ plugin: 'second-brain-mod', key: 'snap' } as const, null)
10const isHidden = atom({ plugin: 'second-brain-mod', key: 'isHidden' } as const, false)
11const IDLE: TriageState = { phase: 'idle', proposed: [], review: [], filed: [], note: '', asks: {} }
12const triage = atom({ plugin: 'second-brain-mod', key: 'triage' } as const, IDLE)
13
14// Subagents this mod started, by agent id: their answer arrives as their own turn.complete.
15const waiting: Record<string, { file: string; kind: 'ask' | 'apply' | 'save' }> = {}
16
17const isStuck = (state: string) => state !== 'active' && state !== 'finalizing'
18// One committed palette: warm ink, a slate for chrome, amber for "look at this",
19// coral for broken, mint for healthy. Fixed hex, so it assumes a dark pane.
20const INK = '#E8E2D0'
21const SLATE = '#6B7785'
22const AMBER = '#F0B454'
23const CORAL = '#F2777A'
24const MINT = '#7FD6AE'
25
26const stateColor = (state: string) =>
27 state === 'crashed' || state === 'orphaned' ? CORAL : state === 'stale' ? AMBER : MINT
28
29async function refresh($: EngineInterface): Promise<void> {
30 try {
31 const { exitCode, stdout } = await $.process.run(['python3', '-c', SNAPSHOT_PY, 'snap'], { timeoutMs: 20000 })
32 if (exitCode === 0) {
33 await update($, snap, () => JSON.parse(stdout) as VaultSnapshot)
34 }
35 } catch {
36 // keep the last snapshot; the band simply stops updating
37 }
38}
39
40// Button presses. Each takes `$` as a top-level function, as the validator requires.
41async function openTarget($: EngineInterface, target: string): Promise<void> {
42 await $.process.run(['open', target], { timeoutMs: 10000 })
43}
44
45async function setAsk(
46 $: EngineInterface,
47 file: string,
48 ask: { kind: 'ask' | 'apply' | 'save'; status: 'running' | 'done' | 'failed'; text: string; hub: string | null },
49): Promise<void> {
50 await update($, triage, t => ({ ...t, asks: { ...t.asks, [file]: ask } }))
51}
52
53async function runScript($: EngineInterface, mode: 'triage-dry' | 'triage-apply'): Promise<void> {
54 const applying = mode === 'triage-apply'
55 await update($, triage, t => ({ ...t, phase: (applying ? 'filing' : 'running') as TriageState['phase'], note: '' }))
56 try {
57 const { stdout } = await $.process.run(['python3', '-c', SNAPSHOT_PY, mode], { timeoutMs: 120000 })
58 const r = JSON.parse(stdout) as TriageResult
59 await update($, triage, t => ({
60 ...t,
61 phase: (!r.ok ? 'error' : applying ? 'done' : 'ready') as TriageState['phase'],
62 proposed: r.proposed,
63 review: r.review,
64 filed: applying ? r.filed : t.filed,
65 note: !r.ok ? `Triage failed: ${r.err || 'no output'}` : r.noKey ? 'No TypeSafe key, so every capture needs review.' : '',
66 }))
67 } catch (err) {
68 await update($, triage, t => ({ ...t, phase: 'error' as const, note: `Triage failed: ${String(err).slice(0, 120)}` }))
69 }
70 await refresh($)
71}
72
73async function askClaude($: EngineInterface, file: string): Promise<void> {
74 await setAsk($, file, { kind: 'ask', status: 'running', text: '', hub: null })
75 const started = await $.agent.spawn({
76 subagentType: 'inbox-triage',
77 description: 'Triage one capture',
78 prompt:
79 `Phase 1 only (read-only). Triage exactly one vault inbox capture: Inbox/${file}. ` +
80 `Follow the second-brain skill's "triage inbox" operation: check the real taxonomy (list_taxonomy.py), read the capture, ` +
81 `and decide which hub it belongs under, or none. Do not move or edit anything. ` +
82 `End your reply with exactly one line: "HUB: <hub-id> | <one-line reason>" or "HUB: none | <reason>". Keep the whole reply under 120 words.`,
83 })
84 if (started.deny !== undefined || started.agentId === undefined) {
85 await setAsk($, file, { kind: 'ask', status: 'failed', text: started.deny ?? 'The subagent did not start.', hub: null })
86 return
87 }
88 waiting[started.agentId] = { file, kind: 'ask' }
89}
90
91async function fileUnder($: EngineInterface, file: string, hub: string): Promise<void> {
92 await setAsk($, file, { kind: 'apply', status: 'running', text: '', hub })
93 const started = await $.agent.spawn({
94 subagentType: 'inbox-triage',
95 description: 'File one capture',
96 prompt:
97 `The user approved. File the capture Inbox/${file} under hub ${hub} using the second-brain skill's "triage inbox" steps for a single capture: ` +
98 `set exactly one of project:/topic: in its frontmatter, add a link under that hub's ## Captures, move it to Notes/, then run validate.py on it. ` +
99 `Touch nothing else. End with exactly one line: "FILED: ok" or "FILED: failed | <reason>". Under 60 words.`,
100 })
101 if (started.deny !== undefined || started.agentId === undefined) {
102 await setAsk($, file, { kind: 'apply', status: 'failed', text: started.deny ?? 'The subagent did not start.', hub })
103 return
104 }
105 waiting[started.agentId] = { file, kind: 'apply' }
106}
107
108async function finishAgent(
109 $: EngineInterface,
110 file: string,
111 kind: 'ask' | 'apply' | 'save',
112 answer: string,
113): Promise<void> {
114 if (kind === 'save') {
115 const saved = /^SAVED:\s*(.+)$/im.exec(answer)?.[1]?.trim()
116 const skipped = /^SKIPPED:\s*(.+)$/im.exec(answer)?.[1]?.trim()
117 await setAsk($, file, {
118 kind,
119 status: saved !== undefined ? 'done' : 'failed',
120 text: saved !== undefined ? `saved as ${saved.split('/').pop()}` : `not saved: ${skipped ?? 'no clear answer'}`.slice(0, 140),
121 hub: null,
122 })
123 await refresh($)
124 return
125 }
126 if (kind === 'apply') {
127 const ok = /FILED:\s*ok/i.test(answer)
128 const why = /FILED:\s*failed\s*\|\s*(.*)$/im.exec(answer)?.[1] ?? ''
129 await setAsk($, file, { kind, status: ok ? 'done' : 'failed', text: ok ? 'filed' : why || 'filing failed', hub: null })
130 await refresh($)
131 return
132 }
133 const m = /^HUB:\s*([a-z0-9-]+)\s*(?:\|\s*(.*))?$/im.exec(answer)
134 const hub = m !== null && m[1].toLowerCase() !== 'none' ? m[1] : null
135 const reason = (m?.[2] ?? '').trim() || (m === null ? 'no clear answer' : '')
136 await setAsk($, file, { kind, status: 'done', text: reason.slice(0, 140), hub })
137}
138
139// An orphaned draft's finalize already ran, so its work is saved (or was judged not worth saving):
140// move it aside rather than delete it, as the finalize hook does for skipped drafts.
141async function archiveDraft($: EngineInterface, path: string): Promise<void> {
142 const dir = path.slice(0, path.lastIndexOf('/')) + '/.superseded'
143 await $.process.run(['mkdir', '-p', dir], { timeoutMs: 10000 })
144 await $.process.run(['mv', path, dir + '/'], { timeoutMs: 10000 })
145 await refresh($)
146}
147
148async function saveDraft($: EngineInterface, sid: string, path: string): Promise<void> {
149 const key = `draft-${sid}`
150 await setAsk($, key, { kind: 'save', status: 'running', text: '', hub: null })
151 const started = await $.agent.spawn({
152 subagentType: 'general-purpose',
153 description: 'Save a draft as a summary',
154 prompt:
155 `The user clicked "save as summary" on a leftover session draft that was never finalized: ${path}. ` +
156 `Read it. Following the second-brain skill's "save session summary" operation (~/.claude/skills/second-brain/SKILL.md): ` +
157 `the transcript is not available, so write a short summary built ONLY from what the draft says, inventing nothing, ` +
158 `and end its Reference section with a line saying it was reconstructed from a running draft. ` +
159 `Use the draft file's modification date as the note's date. Pick one real hub from list_taxonomy.py, never an invented one; ` +
160 `if none fits, stop. Check Claude Archive/Sessions/ for an existing summary that already covers the same work, and if one does, write nothing. ` +
161 `Never overwrite an existing file. Write the note to Claude Archive/Sessions/<date>-<slug>.md, link it from the hub with add_session_to_hub.py, ` +
162 `then run validate.py and fix any errors. Only after validation passes, move the draft into a .superseded folder next to it (mkdir -p first). ` +
163 `End with exactly one line: "SAVED: <path>" or "SKIPPED: <one-line reason>". Keep the reply under 80 words.`,
164 })
165 if (started.deny !== undefined || started.agentId === undefined) {
166 await setAsk($, key, { kind: 'save', status: 'failed', text: started.deny ?? 'The subagent did not start.', hub: null })
167 return
168 }
169 waiting[started.agentId] = { file: key, kind: 'save' }
170}
171
172async function rebuildDashboard($: EngineInterface, helpers: string): Promise<void> {
173 await $.process.run(['python3', '-c', SNAPSHOT_PY, 'regen'], { timeoutMs: 120000 })
174 await refresh($)
175 await openTarget($, `${helpers}/brain-status.html`)
176}
177
178export const register: Register = on => {
179 let didRegen = false
180
181 on('session.start', async ($, e, next) => {
182 await $.command.register({ name: 'vault', description: 'Open the second-brain vault pane' })
183 await $.command.register({
184 name: 'vault-capture',
185 description: 'Save the text you selected (fullscreen) to the vault Inbox',
186 })
187 await refresh($)
188 const current = await read($, snap)
189 if (current?.dashboardStale && !didRegen) {
190 didRegen = true
191 await $.process.run(['python3', '-c', SNAPSHOT_PY, 'regen'], { timeoutMs: 120000 })
192 await refresh($)
193 }
194 $.clock.every(60000, () => refresh($))
195 const result = await next(e)
196
197 // Offer the choices in a dialog before the first prompt, instead of the model asking in its first reply.
198 const status = await read($, snap)
199 const choices = e.isInteractive && status !== null ? buildChoices({ inbox: status.inbox.length, hub: status.hub }) : []
200 if (choices.length > 0) {
201 try {
202 const { question: q, options } = question(choices)
203 const answer = await $.ui.ask(q, { options, header: 'Second brain', multiSelect: true })
204 const prompts = picked(answer, choices).map(c => c.prompt)
205 if (prompts.length > 0) void $.prompt.submit({ text: prompts.join(' Then: ') })
206 } catch {
207 // dismissed dialog: the session just opens as usual
208 }
209 }
210
211 return result
212 })
213
214 on('turn.complete', async ($, e, next) => {
215 const w = e.agentId !== undefined ? waiting[e.agentId] : undefined
216 if (w !== undefined && e.agentId !== undefined) {
217 delete waiting[e.agentId]
218 await finishAgent($, w.file, w.kind, e.answer ?? '')
219 }
220 await refresh($)
221
222 return next(e)
223 })
224
225 on('command.run', { command: 'vault' }, async $ => {
226 await update($, isHidden, () => false)
227 await refresh($)
228 await $.ui.open({ id: PANE, title: 'Second brain' })
229
230 return { text: 'Vault pane opened, status band shown.' }
231 })
232
233 on('command.run', { command: 'vault-capture' }, async $ => {
234 const selected = await $.ui.selection()
235 if (selected === undefined || selected.text.trim() === '') {
236 return { text: 'Nothing selected. Select text with the mouse first (fullscreen mode only).' }
237 }
238 let current = await read($, snap)
239 if (current === null) {
240 await refresh($)
241 current = await read($, snap)
242 }
243 if (current === null) return { text: 'Could not find the vault.' }
244 const { filename, text } = buildCapture(selected.text, new Date(await $.clock.now()))
245 await $.fs.write(`${current.vault}/Inbox/${filename}`, text)
246 await refresh($)
247
248 return { text: `Saved to Inbox/${filename}` }
249 })
250
251 // Deliberately no .catch: if this hook itself errors, the write goes through.
252 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
253 const current = await read($, snap)
254 if (current === null) return next(e)
255 let path = e.file_path
256 try {
257 path = (await $.fs.stat(e.file_path, { resolve: true })).realPath ?? e.file_path
258 } catch {
259 // a file that does not exist yet has no realPath; use the path as given
260 }
261 const problem = checkVaultWrite(current.vault, path, e.content)
262 if (problem !== undefined) return { deny: problem }
263
264 return next(e)
265 })
266
267 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
268 const s = await read($, snap)
269 if (e.props.hasSurvey || s === null || (await read($, isHidden))) return next(e)
270 const stuck = s.drafts.filter(d => isStuck(d.state)).length
271 const parts = [
272 s.inbox.length > 0 ? `${s.inbox.length} in inbox` : 'inbox empty',
273 s.dashboardStale ? 'dashboard stale' : 'dashboard current',
274 s.hub !== null ? `hub: ${s.hub}` : 'no hub here',
275 ]
276 if (stuck > 0) parts.push(`${stuck} stuck draft${stuck > 1 ? 's' : ''}`)
277 const { Box, Button, Text } = $.ui.resolve(e)
278
279 return (
280 <Box>
281 <Text dimColor>Vault: {parts.join(' · ')} </Text>
282 <Button key="open" label="Open" onPress={() => $.ui.open({ id: PANE, title: 'Second brain' })} />
283 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
284 </Box>
285 )
286 })
287
288 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
289 const s = await read($, snap)
290 const tr = await read($, triage)
291 const { Box, Button, Text } = $.ui.resolve(e)
292 if (s === null) return <Text color={SLATE}>Loading vault status...</Text>
293 const clearAsk = (file: string) =>
294 update($, triage, t => ({ ...t, asks: Object.fromEntries(Object.entries(t.asks).filter(([f]) => f !== file)) }))
295 const stuck = s.drafts.filter(d => isStuck(d.state)).length
296 const todo = s.inbox.length
297 const calm = stuck === 0 && todo === 0 && !s.dashboardStale
298 const verdict = [
299 todo > 0 ? `${todo} to triage` : null,
300 stuck > 0 ? `${stuck} draft${stuck > 1 ? 's' : ''} stuck` : null,
301 s.dashboardStale ? 'dashboard stale' : null,
302 ].filter(Boolean)
303
304 return (
305 <Box flexDirection="column" gap={1} paddingX={2} paddingY={1}>
306 <Box flexDirection="column">
307 <Text bold color={INK}>S E C O N D B R A I N</Text>
308 <Text color={calm ? MINT : AMBER}>{calm ? '● all clear' : `● ${verdict.join(' · ')}`}</Text>
309 </Box>
310
311 <Box flexDirection="column" borderStyle="round" borderColor={todo > 0 ? AMBER : SLATE} paddingX={1}>
312 <Box justifyContent="space-between">
313 <Text><Text bold color={INK}>INBOX </Text><Text bold color={todo > 0 ? AMBER : MINT}>{todo}</Text></Text>
314 {tr.phase === 'running' || tr.phase === 'filing' ? (
315 <Text color={AMBER}>{tr.phase === 'running' ? 'triaging…' : 'filing…'}</Text>
316 ) : tr.phase === 'ready' && tr.proposed.length > 0 ? (
317 <Box gap={2}>
318 <Button key="triage-apply" label={`File ${tr.proposed.length}`} variant="primary" onPress={() => runScript($, 'triage-apply')} />
319 <Button key="triage-dismiss" dimColor label="dismiss" onPress={() => update($, triage, () => IDLE)} />
320 </Box>
321 ) : todo > 0 ? (
322 <Button key="triage-all" label="Triage inbox" variant="primary" hotkey="t" onPress={() => runScript($, 'triage-dry')} />
323 ) : null}
324 </Box>
325 {tr.note !== '' && <Text wrap="truncate-end" color={CORAL}>{tr.note}</Text>}
326 {tr.phase === 'done' && tr.filed.length > 0 && (
327 <Text color={MINT}>● filed {tr.filed.length}</Text>
328 )}
329 {todo === 0 && <Text color={SLATE}>Nothing to triage.</Text>}
330 {s.inbox.map(n => {
331 const ask = tr.asks[n.file]
332 const proposal = tr.proposed.find(i => i.file === n.file)
333 const review = tr.review.find(i => i.file === n.file)
334 const canAsk = ask === undefined || ask.status === 'failed'
335
336 return (
337 <Box key={n.file} flexDirection="column">
338 <Box gap={2}>
339 <Box flexGrow={1} flexShrink={1} minWidth={0}>
340 <Text wrap="truncate-end" color={INK}>› {n.title}</Text>
341 </Box>
342 <Box flexShrink={0} gap={2}>
343 {canAsk && <Button key={`triage-${n.file}`} label="triage" onPress={() => askClaude($, n.file)} />}
344 <Button key={`open-${n.file}`} dimColor label="open" onPress={() => openTarget($, n.uri)} />
345 </Box>
346 </Box>
347 {ask === undefined && proposal !== undefined && (
348 <Text wrap="truncate-end" color={SLATE}> <Text color={MINT}>→ </Text>{proposal.why}</Text>
349 )}
350 {ask === undefined && review !== undefined && (
351 <Text wrap="truncate-end" color={SLATE}> <Text color={AMBER}>? </Text>{review.why}</Text>
352 )}
353 {ask !== undefined && ask.status === 'running' && (
354 <Text color={AMBER}> {ask.kind === 'apply' ? 'filing…' : 'Claude is looking…'}</Text>
355 )}
356 {ask !== undefined && ask.status === 'failed' && <Text color={CORAL}> {ask.text}</Text>}
357 {ask !== undefined && ask.status === 'done' && ask.kind === 'apply' && <Text color={MINT}> ✓ {ask.text}</Text>}
358 {ask !== undefined && ask.status === 'done' && ask.kind === 'ask' && (
359 <Box flexDirection="column">
360 {ask.hub !== null ? (
361 <Text bold color={MINT}> → {ask.hub}</Text>
362 ) : (
363 <Text bold color={AMBER}> ? no clear hub</Text>
364 )}
365 {ask.text !== '' && <Text color={INK}> {ask.text}</Text>}
366 </Box>
367 )}
368 {ask !== undefined && ask.status !== 'running' && (
369 <Box gap={2} paddingLeft={2}>
370 {ask.kind === 'ask' && ask.status === 'done' && ask.hub !== null && (
371 <Button key={`file-${n.file}`} label={`File under ${ask.hub}`} variant="primary" onPress={() => fileUnder($, n.file, ask.hub ?? '')} />
372 )}
373 <Button key={`clear-${n.file}`} dimColor label="dismiss" onPress={() => clearAsk(n.file)} />
374 </Box>
375 )}
376 </Box>
377 )
378 })}
379 </Box>
380
381 <Box flexDirection="column" borderStyle="round" borderColor={SLATE} paddingX={1}>
382 <Text><Text bold color={INK}>HUB </Text><Text color={s.hub !== null ? MINT : SLATE}>{s.hub ?? 'none for this directory'}</Text></Text>
383 {s.hubSessions.map(sess => (
384 <Box key={sess.uri} gap={2}>
385 <Box flexGrow={1} flexShrink={1} minWidth={0}>
386 <Text wrap="truncate-end" color={INK}><Text color={SLATE}>{sess.date} </Text>{sess.title}</Text>
387 </Box>
388 <Box flexShrink={0}>
389 <Button key={`sess-${sess.uri}`} dimColor label="open" onPress={() => openTarget($, sess.uri)} />
390 </Box>
391 </Box>
392 ))}
393 </Box>
394
395 <Box flexDirection="column" borderStyle="round" borderColor={stuck > 0 ? CORAL : SLATE} paddingX={1}>
396 <Text><Text bold color={INK}>DRAFTS </Text><Text bold color={stuck > 0 ? CORAL : MINT}>{stuck > 0 ? `${stuck} stuck` : 'clean'}</Text></Text>
397 {s.drafts.map(d => {
398 const ask = tr.asks[`draft-${d.sid}`]
399 const stuckHere = isStuck(d.state)
400
401 return (
402 <Box key={d.sid} flexDirection="column">
403 <Box gap={2}>
404 <Box flexGrow={1} flexShrink={1} minWidth={0}>
405 <Text wrap="truncate-end">
406 <Text color={stateColor(d.state)}>● {d.state.padEnd(10)}</Text>
407 <Text color={SLATE}> {d.sid}</Text>
408 </Text>
409 </Box>
410 {stuckHere && (
411 <Box flexShrink={0} gap={2}>
412 {d.state === 'orphaned' ? (
413 <Button key={`archive-${d.sid}`} label="archive" onPress={() => archiveDraft($, d.path)} />
414 ) : (
415 ask?.status !== 'running' && (
416 <Button key={`save-${d.sid}`} label="save as summary" onPress={() => saveDraft($, d.sid, d.path)} />
417 )
418 )}
419 <Button key={`draft-${d.sid}`} dimColor label="open" onPress={() => openTarget($, d.path)} />
420 </Box>
421 )}
422 </Box>
423 {stuckHere && <Text wrap="truncate-end" color={SLATE}> {d.note}</Text>}
424 {ask !== undefined && ask.status === 'running' && <Text color={AMBER}> saving…</Text>}
425 {ask !== undefined && ask.status === 'done' && <Text color={MINT}> ✓ {ask.text}</Text>}
426 {ask !== undefined && ask.status === 'failed' && (
427 <Box gap={2} paddingLeft={2}>
428 <Text color={CORAL} wrap="truncate-end">{ask.text}</Text>
429 <Button key={`clear-draft-${d.sid}`} dimColor label="dismiss" onPress={() => clearAsk(`draft-${d.sid}`)} />
430 </Box>
431 )}
432 </Box>
433 )
434 })}
435 </Box>
436
437 <Box flexDirection="column" borderStyle="round" borderColor={SLATE} paddingX={1}>
438 <Text bold color={INK}>HEALTH</Text>
439 {s.hooks.map(hook => (
440 <Text wrap="truncate-end" color={INK}>
441 <Text color={hook.last === null ? AMBER : MINT}>● </Text>
442 {hook.name} <Text color={SLATE}>{hook.last === null ? 'never observed' : hook.last.slice(5, 16)}</Text>
443 </Text>
444 ))}
445 <Text color={INK}>
446 <Text color={s.dashboardStale ? AMBER : MINT}>● </Text>
447 Dashboard <Text color={SLATE}>{s.dashboardStale ? 'stale' : 'current'}</Text>
448 </Text>
449 <Box gap={2}>
450 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($)} />
451 <Button key="dashboard" label="Open dashboard" hotkey="d" onPress={() => rebuildDashboard($, s.helpers)} />
452 </Box>
453 </Box>
454 </Box>
455 )
456 })
457}
458hooks/snapshot.ts 112 lines1// Inline Python run by the mod. It reuses the second-brain helpers so no vault
2// logic is duplicated here. Argument 1: "snap" (print JSON) or "regen" (rebuild
3// the HTML dashboard without opening it).
4export const SNAPSHOT_PY = String.raw`
5import json, os, re, subprocess, sys
6from datetime import datetime
7from pathlib import Path
8
9H = Path.home() / ".claude" / "skills" / "second-brain" / "helpers"
10sys.path.insert(0, str(H))
11
12if sys.argv[1] == "regen":
13 r = subprocess.run([sys.executable, str(H / "brain_status.py"), "--no-open"], capture_output=True, text=True)
14 print(json.dumps({"ok": r.returncode == 0, "err": r.stderr[-300:]}))
15 sys.exit(0)
16
17if sys.argv[1] in ("triage-dry", "triage-apply"):
18 import shutil
19 uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
20 cmd = [uv, "run", "--script", str(H / "triage_inbox.py")] + (["--apply"] if sys.argv[1] == "triage-apply" else [])
21 res = {"ok": False, "err": "", "noKey": False, "proposed": [], "review": [], "filed": []}
22 try:
23 r = subprocess.run(cmd, capture_output=True, text=True, timeout=110)
24 res["ok"] = r.returncode == 0
25 res["err"] = r.stderr[-300:]
26 res["noKey"] = "no TypeSafe key" in r.stdout
27 cur = None
28 item = None
29 for line in r.stdout.splitlines():
30 m = re.match(r"^(PROPOSED|REVIEW|FILED) \(\d+\)$", line)
31 if m:
32 cur = m.group(1).lower()
33 item = None
34 continue
35 if line.startswith("{"):
36 cur = None
37 continue
38 if cur and line.startswith(" - "):
39 item = {"file": line[4:].strip(), "why": ""}
40 res[cur].append(item)
41 elif cur and item is not None and line.startswith(" "):
42 item["why"] = line.strip()
43 except Exception as ex:
44 res["err"] = str(ex)[:200]
45 print(json.dumps(res))
46 sys.exit(0)
47
48import brain_status as b
49from vault_paths import VAULT
50
51def norm(s):
52 return "".join(c for c in s.lower() if c.isalnum())
53
54def front(p):
55 try:
56 t = p.read_text(encoding="utf-8", errors="replace")[:2000]
57 except OSError:
58 return ""
59 m = re.match(r"---\n(.*?)\n---", t, re.S)
60 return m.group(1) if m else ""
61
62def find_hub(want):
63 files = list((VAULT / "Projects").glob("*.md")) + list((VAULT / "Notes" / "Topics").glob("*.md"))
64 for f in files:
65 m = re.search(r"^id:\s*(\S+)", front(f), re.M)
66 if m and norm(m.group(1)) == want:
67 return f, m.group(1)
68 for f in files:
69 fm = front(f)
70 m = re.search(r"^id:\s*(\S+)", fm, re.M)
71 inline = re.search(r"^aliases:[ \t]*\[(.*?)\]", fm, re.M)
72 block = re.search(r"^aliases:[ \t]*\n((?:[ \t]*-.*(?:\n|$))+)", fm, re.M)
73 vals = inline.group(1).split(",") if inline else ([l.strip()[1:] for l in block.group(1).splitlines()] if block else [])
74 if m and any(norm(v.strip().strip("'\"")) == want for v in vals):
75 return f, m.group(1)
76 return None, None
77
78hub_file, hub_id = find_hub(norm(os.path.basename(os.getcwd())))
79hub_sessions = []
80if hub_id:
81 for p in sorted((VAULT / "Claude Archive" / "Sessions").glob("*.md"), reverse=True):
82 fm = front(p)
83 if re.search(r"^(project|topic):\s*" + re.escape(hub_id) + r"\s*$", fm, re.M):
84 hub_sessions.append({"date": p.name[:10], "title": b.h1_title(p), "uri": b.obsidian_uri(p)})
85 if len(hub_sessions) >= 6:
86 break
87
88stale = False
89if b.LOG.exists():
90 if not b.STATUS_MARKER.exists():
91 stale = True
92 else:
93 mk = b.STATUS_MARKER.stat().st_mtime
94 stale = (datetime.now().timestamp() - mk) > 86400 and b.LOG.stat().st_mtime > mk
95
96hooks = b.parse_hook_log()
97print(json.dumps({
98 "vault": str(VAULT),
99 "helpers": str(H),
100 "inbox": [{"title": t, "file": q.name, "uri": b.obsidian_uri(q)} for t, q in b.read_inbox()],
101 "hub": hub_file.stem if hub_file else None,
102 "hubSessions": hub_sessions,
103 "drafts": [{"sid": d["sid"][:8], "state": d["state"], "note": d["note"], "path": str(d["path"])} for d in b.read_drafts()],
104 "hooks": [
105 {"name": "Stop (draft save)", "last": hooks["stop"]["last"]},
106 {"name": "SessionEnd (finalize)", "last": hooks["session_end"]["last"]},
107 {"name": "SessionStart (inbox check)", "last": hooks["session_start"]["last"]},
108 ],
109 "dashboardStale": stale,
110}))
111`
112hooks/vault.ts 82 lines1// Pure helpers, kept apart from register.tsx so they can be tested directly.
2
3// Folders where a note without `date:` frontmatter is a real mistake. Hubs
4// (Projects/, Notes/Topics/), Templates/ and dot-folders are exempt: checked
5// against the live vault, this rule flags none of the 140 notes in these folders.
6const GUARDED = ['Inbox/', 'Claude Archive/Sessions/', 'Notes/', 'Daily/']
7const EXEMPT = ['Notes/Topics/']
8
9export function checkVaultWrite(vault: string, path: string, content: string): string | undefined {
10 const root = vault.endsWith('/') ? vault : vault + '/'
11 if (!path.startsWith(root) || !path.endsWith('.md')) return undefined
12 const rel = path.slice(root.length)
13 if (rel.split('/').some(part => part.startsWith('.'))) return undefined
14 if (!GUARDED.some(p => rel.startsWith(p)) || EXEMPT.some(p => rel.startsWith(p))) return undefined
15 const fm = /^---\n([\s\S]*?)\n---/.exec(content)
16 if (fm && /^date:\s*\d{4}-\d{2}-\d{2}/m.test(fm[1])) return undefined
17 return `Vault note ${rel} needs YAML frontmatter with "date: YYYY-MM-DD" (second-brain guard). Add it and write again.`
18}
19
20export function slugify(text: string): string {
21 return (
22 text
23 .toLowerCase()
24 .replace(/[^a-z0-9]+/g, '-')
25 .replace(/^-+|-+$/g, '')
26 .slice(0, 60)
27 .replace(/-+$/, '') || 'capture'
28 )
29}
30
31const two = (n: number) => String(n).padStart(2, '0')
32
33export function buildCapture(selected: string, when: Date): { filename: string; text: string } {
34 const body = selected.trim()
35 const first = body.split('\n').find(l => l.trim() !== '') ?? 'Capture'
36 const title = first.replace(/^#+\s*/, '').trim().slice(0, 80)
37 const date = `${when.getFullYear()}-${two(when.getMonth() + 1)}-${two(when.getDate())}`
38 const filename = `${date}-${two(when.getHours())}${two(when.getMinutes())}-${slugify(title)}.md`
39 const text =
40 `---\ndate: ${date}\ntags: [claude-code-capture]\nsource: claude-code\n---\n\n` +
41 `# ${title}\n\n${body}\n`
42 return { filename, text }
43}
44
45export type Choice = { label: string; prompt: string }
46
47export const NOT_NOW = 'Not now'
48
49// What is worth offering as the session opens. The dashboard needs no choice:
50// the mod regenerates a stale one itself at session start.
51export function buildChoices(s: { inbox: number; hub: string | null }): Choice[] {
52 const out: Choice[] = []
53 if (s.hub !== null) {
54 out.push({
55 label: `Load ${s.hub} context`,
56 prompt: `Run the second-brain skill's "load context" operation for the [[${s.hub}]] hub.`,
57 })
58 }
59 if (s.inbox > 0) {
60 out.push({
61 label: `Triage inbox (${s.inbox})`,
62 prompt: `Delegate the ${s.inbox} Desktop/iOS captures in the vault Inbox to the inbox-triage agent (Sonnet), per the second-brain skill's "triage inbox" operation.`,
63 })
64 }
65
66 return out
67}
68
69export function question(choices: Choice[]): { question: string; options: string[] } {
70 return {
71 question: 'Second brain: what should I do before we start?',
72 options: [...choices.slice(0, 3).map(c => c.label), NOT_NOW],
73 }
74}
75
76// ask() comma-joins a multi-select answer, and "Other" is free text: match labels exactly.
77export function picked(answer: string, choices: Choice[]): Choice[] {
78 const parts = answer.split(',').map(p => p.trim())
79
80 return choices.filter(c => parts.includes(c.label))
81}
82types/index.d.ts 45 lines1export type VaultNote = { title: string; file: string; uri: string }
2export type VaultSession = { date: string; title: string; uri: string }
3export type VaultDraft = { sid: string; state: string; note: string; path: string }
4export type VaultHook = { name: string; last: string | null }
5export type VaultSnapshot = {
6 vault: string
7 helpers: string
8 inbox: VaultNote[]
9 hub: string | null
10 hubSessions: VaultSession[]
11 drafts: VaultDraft[]
12 hooks: VaultHook[]
13 dashboardStale: boolean
14}
15
16export type TriageItem = { file: string; why: string }
17export type TriageResult = {
18 ok: boolean
19 err: string
20 noKey: boolean
21 proposed: TriageItem[]
22 review: TriageItem[]
23 filed: TriageItem[]
24}
25export type TriageAsk = {
26 kind: 'ask' | 'apply' | 'save'
27 status: 'running' | 'done' | 'failed'
28 text: string
29 hub: string | null
30}
31export type TriageState = {
32 phase: 'idle' | 'running' | 'ready' | 'filing' | 'done' | 'error'
33 proposed: TriageItem[]
34 review: TriageItem[]
35 filed: TriageItem[]
36 note: string
37 asks: Record<string, TriageAsk>
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'second-brain-mod': { snap: VaultSnapshot | null; isHidden: boolean; triage: TriageState }
43 }
44}
45