SLOPSHOPPER

second-brain-mod

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.

newpanebandguardcommandprocess
★ 2v0.1.0MITupdated 2026-10-07Dressi123/second-brain-skill/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · second-brain-mod
│ ┃ Second brain ✕ › fix the failing auth test and add an audit log call │ ┃ Loading vault status... │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /vault │ ⎿ second-brain-mod: Vault pane opened, status band shown. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Second brain
Loading vault status...
README

second-brain-mod

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.

What it does

  • Status band above the prompt: inbox count, dashboard freshness, hub for the current directory, stuck drafts. 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.
  • On a stuck draft, 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 guard: denies a 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.
  • Dashboard auto-refresh: rebuilds the HTML dashboard once per session when stale.

Install

/plugin install second-brain-mod --marketplace Dressi123/second-brain-skill

or for development: claude --plugin-dir /path/to/second-brain-mod.

Develop

claude plugin validate .
claude plugin test .
tsc -p .          # after the engine has loaded the mod once
Source 4 files
hooks/register.tsx 458 lines
1import { 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}
458
hooks/snapshot.ts 112 lines
1// 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`
112
hooks/vault.ts 82 lines
1// 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}
82
types/index.d.ts 45 lines
1export 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