Capture ideas, takes, and reading items into an Obsidian vault inbox from any Claude Code session

A Claude Code plugin for capturing thoughts into an Obsidian vault without leaving the session you are working in, and for keeping the vault's inbox from going stale.
Design and roadmap: docs/design.md.
Most of the time, just write the thought:
/jot Obsidian Bases can group by any property
/jot https://example.com/long-post — recommended in the hooks thread
With no kind it is saved as note, and ingest decides what it becomes (idea, concept, pitfall, reading, ...) from the text and its origin. Prefix a kind when you already know, or when it needs a target:
/jot idea: a mod that turns jots into design docs
/jot improve @cx: make the panel smaller and navigable by layer
/jot read: https://example.com/long-post — recommended in the hooks thread
/jot til: Obsidian Bases can group by any property
/jot pitfall: fs.write creates missing parent directories
/jot plugin: claude-obsidian — lint is fast, the router ignores custom types
Each capture becomes inbox/jot-<YYYYMMDD-HHMMSS>-<kind>-<slug>.md in the vault, with flat frontmatter: title, kind, captured, and the origin (origin_cwd, origin_repo, origin_branch, origin_session). /jot runs immediately, even mid-turn. Kinds and how ingest files them are defined in the vault's Vault Guide ("Capture Kinds").
improve @<target>: is a change to make to an app. <target> is the repo's folder name, so a session in that repo can find it; leave it out when the jot is not about one app. Ingest files it as an idea with a target.
The prompt footer shows the backlog (📥 inbox 4 · 9d), and 🛠 cx 3 when a session runs in a repo that has open ideas targeting it; problems such as an unset or unreadable vaultPath go to the status line instead. When the inbox reaches backlogCount captures or its oldest is backlogDays old, a band above the prompt offers Ingest (fills the prompt with an ingest request to review and send) and Hide (for this session).
/jot ingest does the same on demand, at any backlog size: it fills the prompt with the ingest request for you to review and send. An empty inbox says so and fills nothing. Text that merely starts with the word (/jot ingest is slow) is still captured.
/jot with no text asks a fork of the conversation for the one thing worth keeping, and puts it in your prompt as /jot <kind>: <draft>. Edit it and press Enter to save, or clear it. Nothing is saved until you do.
/incubate opens a small pane you move through layer by layer:
Shelves 🌱 Ideas 2 📖 Reading 1 Review
└ Ideas ‹ Shelves · 🌱 Replay Mode · developing · 🌱 Smaller Panel @cx · seed
└ Replay Mode open questions, options [Choose], a Decision box, [Expand], [Hand off to <repo>]
└ Reading
└ Hooks Post url, [Reading] [Done] [Drop]
└ Review open ideas per app, seeds waiting 14+ days
/incubate <title> jumps straight to an idea. Every change is handed to Claude as a prompt (recorded under "## Decisions", status moves, idea handed off to the repo), so claude-obsidian stays the only writer under wiki/. Within a state, ideas group by target.
Hand off appears when the session is in a repo other than the vault. If the repo has a harness (docs/harness/index.md), Claude uses that repo's to-issues (or to-prd) skill; otherwise it writes docs/design/<title>.md. Either way the idea gets handoff: (what was created), project:, and status: archived. Hotkeys: b back, i/r/v shelves and review, 1–9 choose an option, e expand, x hand off or drop.
In /config, under vault-jot:
| Field | Default | Meaning |
|---|---|---|
vaultPath | (unset) | Vault root, absolute or ~/.... Required; /jot refuses and the status line says so until it is set. |
backlogCount | 10 | Band threshold by count. |
backlogDays | 7 | Band threshold by age of the oldest capture. |
The mod never creates inbox/: a missing one means a wrong vaultPath, and the capture is refused rather than written elsewhere.
The repo is its own plugin marketplace (.claude-plugin/marketplace.json).
From GitHub (the repo is private, so git must be able to clone it, e.g. after gh auth setup-git):
claude plugin marketplace add Hsiang-LinC/vault-jot
claude plugin install vault-jot@vault-jot
From a local clone, read live from the folder (after an edit, run /reload-plugins):
claude plugin marketplace add ~/Developer/personal/vault-jot
claude plugin install vault-jot@vault-jot
Then set the vault path, in Claude Code with /plugin configure vault-jot@vault-jot, or:
echo '{"vaultPath":"~/Developer/personal/notes"}' | claude plugin configure vault-jot@vault-jot --values-stdin
Update a GitHub install with claude plugin marketplace update vault-jot then claude plugin update vault-jot@vault-jot. Install it one way only: the same plugin also loaded through --plugin-dir or a mods folder runs twice.
For a one-off session without installing: claude --plugin-dir ~/Developer/personal/vault-jot.
claude plugin validate .
claude plugin test .
tsc -p . # once the engine has loaded the mod and written .claude-plugin/types/
hooks/core.ts (captures, backlog), hooks/notes.ts (reading notes) and hooks/handoff.ts (prompts for Claude) are pure; hooks/register.tsx is the only module that talks to Claude Code, because the engine follows $ only into functions declared in the hooks module itself.
hooks/register.tsx 516 lines1import { atom, read, update } from 'claude-code'
2import type { CommandRunResult, CommandSpec, EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { OpenForRepo, Shelf, View } from '../types'
5
6import {
7 KINDS,
8 backlogLabel,
9 expandHome,
10 fileName,
11 isOverdue,
12 localStamp,
13 parseJot,
14 renderNote,
15 summarizeInbox,
16} from './core'
17import type { Jot, Thresholds } from './core'
18import { DRAFT_PROMPT, HARNESS_INDEX, decisionPrompt, draftCommand, expandPrompt, handoffPrompt, ingestPrompt, parseDraft, readingPrompt } from './handoff'
19import type { ReadingState } from './handoff'
20import { SHELVES, STALE_SEED_DAYS, findNote, openForTarget, parseNote, reviewIdeas, sortNotes, stateOf, targetOf, titleOf } from './notes'
21import type { Note } from './notes'
22
23const backlog = atom({ plugin: 'vault-jot', key: 'backlog' } as const, null)
24const isHidden = atom({ plugin: 'vault-jot', key: 'isHidden' } as const, false)
25const openForRepo = atom({ plugin: 'vault-jot', key: 'openForRepo' } as const, null as OpenForRepo | null)
26
27// Command output carries no "vault-jot:" prefix: Claude Code already labels
28// a plugin command's output with the plugin's name.
29const USAGE = `Usage: /jot [${KINDS.join('|')}:] <text>, or /jot ingest`
30const INGEST_WORD = 'ingest'
31const MAX_NAME_ATTEMPTS = 5
32const GIT_TIMEOUT_MS = 3000
33const STATUS_MAX = 80
34
35type Config = { vaultPath: string; thresholds: Thresholds }
36
37function readConfig(options: PluginOptions): Config {
38 return {
39 vaultPath: String(options.vaultPath ?? '').trim(),
40 thresholds: { count: Number(options.backlogCount ?? 10), days: Number(options.backlogDays ?? 7) },
41 }
42}
43
44const describe = (error: unknown) => (error instanceof Error ? error.message : String(error))
45
46// Resolves the vault root, failing loudly when it is unset or does not look
47// like a vault. `$.fs.write` creates missing directories, so writing without
48// this check would silently start a new "vault" at a mistyped path.
49async function resolveVault($: EngineInterface, config: Config) {
50 if (config.vaultPath === '') {
51 throw new Error('vaultPath is not set; set it in /config under vault-jot')
52 }
53 const vault = expandHome(config.vaultPath, await $.env.get('HOME')).replace(/\/+$/, '')
54 const inbox = `${vault}/inbox`
55 const stat = await $.fs.stat(inbox).catch((error: unknown) => {
56 throw new Error(`cannot read ${inbox}: ${describe(error)}`)
57 })
58 if (stat.kind !== 'dir') {
59 throw new Error(`${inbox} is not a directory`)
60 }
61
62 return { vault, inbox }
63}
64
65// Provenance is best effort: outside a repo, or where processes cannot run
66// (a desktop host), the capture is still saved without a branch.
67async function gitBranch($: EngineInterface, cwd: string): Promise<string | null> {
68 try {
69 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
70 cwd,
71 timeoutMs: GIT_TIMEOUT_MS,
72 })
73
74 return exitCode === 0 && stdout.trim() !== '' ? stdout.trim() : null
75 } catch (error) {
76 $.ui.log(`vault-jot: no branch recorded: ${describe(error)}`, { to: 'debug' })
77
78 return null
79 }
80}
81
82async function freePath($: EngineInterface, inbox: string, jot: Jot, compactStamp: string) {
83 for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt += 1) {
84 const path = `${inbox}/${fileName(jot, compactStamp, attempt)}`
85 if (!(await $.fs.exists(path))) {
86 return path
87 }
88 }
89 throw new Error(`${MAX_NAME_ATTEMPTS} captures with the same name this second; try again`)
90}
91
92async function capture($: EngineInterface, config: Config, jot: Jot): Promise<string> {
93 const { inbox } = await resolveVault($, config)
94 const now = await $.clock.now()
95 const stamp = localStamp(now, -new Date(now).getTimezoneOffset())
96 const cwd = await $.session.cwd()
97 const [repo, branch, session] = await Promise.all([$.session.repo(), gitBranch($, cwd), $.session.id()])
98 const note = renderNote(jot, stamp.iso, {
99 cwd,
100 repo: repo === null ? null : (repo.remote ?? repo.root),
101 branch,
102 session,
103 })
104 const path = await freePath($, inbox, jot, stamp.compact)
105 await $.fs.write(path, note)
106
107 return path.slice(inbox.length + 1)
108}
109
110// The backlog shows as a prompt-footer label (see the SessionMode hook); the
111// status line is kept for problems, which the engine marks as notices.
112// `/jot` with no text: a fork of the conversation drafts one capture, and the
113// draft goes into the prompt box as a `/jot` command, so nothing is saved
114// until the person edits or accepts it with Enter.
115async function draft($: EngineInterface): Promise<CommandRunResult> {
116 const reply = await $.model.fork({ prompt: DRAFT_PROMPT })
117 if (!reply.isAnswered) {
118 return {
119 text:
120 reply.reason === 'nothing-to-fork'
121 ? `nothing to draft from yet. ${USAGE}`
122 : `could not draft a capture (${reply.reason}). ${USAGE}`,
123 }
124 }
125 const jot = parseDraft(reply.text)
126 if (jot === null) {
127 return { text: `the draft came back empty. ${USAGE}` }
128 }
129 const command = draftCommand(jot)
130 const filled = await $.prompt.fill({ text: command })
131 if (!filled.isFilled) {
132 return { text: `could not fill the prompt (${filled.refusal ?? 'refused'}). Draft: ${command}` }
133 }
134
135 return { text: 'draft is in your prompt. Edit it and press Enter to save, or clear it.' }
136}
137
138// `/jot ingest`: the band's Ingest button on demand, whatever the thresholds.
139// The request goes into the prompt box for review, never straight to Claude.
140async function ingest($: EngineInterface, config: Config): Promise<CommandRunResult> {
141 try {
142 const { inbox } = await resolveVault($, config)
143 const files = (await $.fs.list(inbox)).filter(entry => entry.kind === 'file')
144 if (summarizeInbox(files, await $.clock.now()).count === 0) {
145 return { text: 'inbox is empty; nothing to ingest.' }
146 }
147 const text = ingestPrompt(inbox)
148 const filled = await $.prompt.fill({ text })
149
150 return {
151 text: filled.isFilled
152 ? 'ingest request is in your prompt. Press Enter to run it, or clear it.'
153 : `could not fill the prompt (${filled.refusal ?? 'refused'}). Request: ${text}`,
154 }
155 } catch (error) {
156 return { text: `not started: ${describe(error)}` }
157 }
158}
159
160// The last path segment of the session's repo, which is what an idea's
161// `target` is matched against; null outside a repo and inside the vault.
162async function repoName($: EngineInterface, vault: string): Promise<string | null> {
163 const repo = await $.session.repo()
164
165 return repo === null || repo.root === vault ? null : (repo.root.replace(/\/+$/, '').split('/').at(-1) ?? null)
166}
167
168async function refresh($: EngineInterface, config: Config) {
169 try {
170 const { vault, inbox } = await resolveVault($, config)
171 const files = (await $.fs.list(inbox)).filter(entry => entry.kind === 'file')
172 const next = summarizeInbox(files, await $.clock.now())
173 const name = await repoName($, vault)
174 const ideas = name === null ? [] : (await loadShelf($, vault, 'ideas')).notes
175 $.ui.status(undefined)
176 await update($, backlog, () => next)
177 await update($, openForRepo, () => (name === null ? null : { target: name, count: openForTarget(ideas, name).length }))
178 } catch (error) {
179 $.ui.status(`vault-jot: ${describe(error)}`.slice(0, STATUS_MAX))
180 await update($, backlog, () => null)
181 await update($, openForRepo, () => null)
182 }
183}
184
185// The incubate pane browses wiki/ideas and wiki/reading; it only reads notes
186// and hands every change to Claude.
187const PANE = 'incubate'
188const HOME: View = { layer: 'home' }
189const view = atom({ plugin: 'vault-jot', key: 'view' } as const, HOME)
190
191// Each draw reads a shelf's notes from disk, so the pane always shows what
192// Claude last wrote. Bounded: past this many notes a shelf says it is cut.
193const SHELF_MAX = 200
194
195type Loaded = { notes: Note[]; isTruncated: boolean }
196
197async function loadShelf($: EngineInterface, vault: string, shelf: Shelf): Promise<Loaded> {
198 const dir = `${vault}/${SHELVES[shelf].folder}`
199 // No folder yet means nothing has been ingested into this shelf.
200 if (!(await $.fs.exists(dir))) {
201 return { notes: [], isTruncated: false }
202 }
203 const files = (await $.fs.list(dir))
204 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
205 .sort((a, b) => a.name.localeCompare(b.name))
206 const kept = files.slice(0, SHELF_MAX)
207 const notes = await Promise.all(kept.map(async entry => parseNote(entry.name, await $.fs.read(`${dir}/${entry.name}`))))
208
209 return { notes: sortNotes(shelf, notes), isTruncated: files.length > kept.length }
210}
211
212const notePath = (vault: string, shelf: Shelf, file: string) => `${vault}/${SHELVES[shelf].folder}/${file}`
213
214const INCUBATE_COMMAND: CommandSpec = {
215 name: 'incubate',
216 description: 'Browse vault ideas and reading, and decide what to do with them',
217 argumentHint: '[idea title]',
218}
219
220export const register: Register = (on, options) => {
221 const config = readConfig(options)
222
223 on('session.start', async ($, e, next) => {
224 await $.command.register({
225 name: 'jot',
226 description: 'Capture a thought into the vault inbox',
227 argumentHint: `[${KINDS.join('|')}:] <text>, nothing to draft one, or ingest`,
228 immediate: true,
229 })
230 await $.command.register(INCUBATE_COMMAND)
231 await refresh($, config)
232
233 return next(e)
234 })
235
236 on('command.run', { command: 'jot' }, async ($, e) => {
237 if (e.args.trim() === '') {
238 return draft($)
239 }
240 if (e.args.trim().toLowerCase() === INGEST_WORD) {
241 return ingest($, config)
242 }
243 const jot = parseJot(e.args)
244 if (jot === null) {
245 return { text: USAGE }
246 }
247 try {
248 const name = await capture($, config, jot)
249 await refresh($, config)
250
251 return { text: `Jotted ${jot.kind} → inbox/${name}` }
252 } catch (error) {
253 return { text: `not saved: ${describe(error)}` }
254 }
255 })
256
257 // Ingest may run in any session, so recount after each main-agent turn;
258 // Claude may also have edited notes the incubate pane shows.
259 on('turn.complete', async ($, e, next) => {
260 if (e.agentId === undefined) {
261 await refresh($, config)
262 $.ui.invalidate('ui.render')
263 }
264
265 return next(e)
266 })
267
268 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
269 const current = await read($, backlog)
270 const forRepo = await read($, openForRepo)
271 const labels = [
272 current === null ? undefined : backlogLabel(current),
273 forRepo === null || forRepo.count === 0 ? undefined : `🛠 ${forRepo.target} ${forRepo.count}`,
274 ].filter(label => label !== undefined)
275
276 return labels.length === 0 ? next(e) : next({ ...e, props: { ...e.props, modes: [...e.props.modes, ...labels] } })
277 })
278
279 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
280 const current = await read($, backlog)
281 const isQuiet =
282 e.props.hasSurvey || current === null || !isOverdue(current, config.thresholds) || (await read($, isHidden))
283 if (isQuiet) {
284 return next(e)
285 }
286 const { Box, Button, Text } = $.ui.resolve(e)
287 const fillIngest = async () => {
288 const { inbox } = await resolveVault($, config)
289 await $.prompt.fill({ text: ingestPrompt(inbox) })
290 }
291
292 return (
293 <Box>
294 <Text dimColor>
295 Vault inbox: {current.count} captures, oldest {current.oldestDays}d{' '}
296 </Text>
297 <Button key="ingest" label="Ingest" variant="primary" onPress={fillIngest} />
298 <Text> </Text>
299 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
300 </Box>
301 )
302 })
303
304 // The incubate pane: shelves → notes → one note, with decisions handed to Claude.
305 on('command.run', { command: 'incubate' }, async ($, e) => {
306 try {
307 const { vault } = await resolveVault($, config)
308 const query = e.args.trim()
309 let next: View = HOME
310 let text = 'incubate pane opened.'
311 if (query !== '') {
312 const found = findNote((await loadShelf($, vault, 'ideas')).notes, query)
313 if (found === undefined || found === 'ambiguous') {
314 next = { layer: 'list', shelf: 'ideas' }
315 text =
316 found === undefined
317 ? `no idea matches "${query}"; showing all ideas.`
318 : `several ideas match "${query}"; pick one.`
319 } else {
320 next = { layer: 'detail', shelf: 'ideas', file: found.file }
321 }
322 }
323 await update($, view, () => next)
324 const opened = await $.ui.open({ id: PANE, title: 'Incubate', focus: true })
325
326 return { text: opened.isPlaced ? text : `${text} The pane is waiting: ${opened.reason}` }
327 } catch (error) {
328 return { text: `cannot open incubate: ${describe(error)}` }
329 }
330 })
331
332 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
333 const { Box, Button, Text } = $.ui.resolve(e)
334 let vault: string
335 try {
336 vault = (await resolveVault($, config)).vault
337 } catch (error) {
338 return <Text color="red">vault-jot: {describe(error)}</Text>
339 }
340 const current = await read($, view)
341 const go = (to: View) => () => update($, view, () => to)
342 const send = (text: string, what: string) => async () => {
343 await $.prompt.submit({ text })
344 $.ui.toast(`vault-jot: asked Claude to ${what}`)
345 }
346
347 if (current.layer === 'home') {
348 const [ideas, reading] = await Promise.all([loadShelf($, vault, 'ideas'), loadShelf($, vault, 'reading')])
349
350 return (
351 <Box flexDirection="column">
352 <Text dimColor>Pick a shelf.</Text>
353 <Box>
354 <Button key="ideas" hotkey="i" label={`${SHELVES.ideas.icon} Ideas ${ideas.notes.length}`} onPress={go({ layer: 'list', shelf: 'ideas' })} />
355 <Text> </Text>
356 <Button key="reading" hotkey="r" label={`${SHELVES.reading.icon} Reading ${reading.notes.length}`} onPress={go({ layer: 'list', shelf: 'reading' })} />
357 <Text> </Text>
358 <Button key="review" hotkey="v" label="Review" onPress={go({ layer: 'review' })} />
359 </Box>
360 </Box>
361 )
362 }
363
364 if (current.layer === 'review') {
365 const { notes: ideas } = await loadShelf($, vault, 'ideas')
366 const review = reviewIdeas(ideas, await $.clock.now())
367
368 return (
369 <Box flexDirection="column">
370 <Box>
371 <Button key="back" hotkey="b" plain label="‹ Shelves" onPress={go(HOME)} />
372 <Text bold> Review</Text>
373 </Box>
374 <Text bold>Open ideas by app</Text>
375 {review.openByTarget.length === 0 && review.openUntargeted === 0 && <Text dimColor> none</Text>}
376 {review.openByTarget.map(([target, count]) => (
377 <Text key={`app:${target}`}> • {target}: {count}</Text>
378 ))}
379 {review.openUntargeted > 0 && <Text> • no target: {review.openUntargeted}</Text>}
380 <Text bold>Seeds waiting {STALE_SEED_DAYS}+ days</Text>
381 {review.staleSeeds.length === 0 && <Text dimColor> none</Text>}
382 {review.staleSeeds.map(note => (
383 <Button
384 key={`stale:${note.file}`}
385 plain
386 label={`🌱 ${titleOf(note)}`}
387 onPress={go({ layer: 'detail', shelf: 'ideas', file: note.file })}
388 />
389 ))}
390 </Box>
391 )
392 }
393
394 const shelf = SHELVES[current.shelf]
395 const { notes, isTruncated } = await loadShelf($, vault, current.shelf)
396
397 if (current.layer === 'list') {
398 return (
399 <Box flexDirection="column">
400 <Box>
401 <Button key="back" hotkey="b" plain label="‹ Shelves" onPress={go(HOME)} />
402 <Text bold> {shelf.label}</Text>
403 </Box>
404 {notes.length === 0 && (
405 <Text dimColor>
406 Nothing here yet. Captures of kind {current.shelf === 'ideas' ? 'idea' : 'read'} land here after ingest.
407 </Text>
408 )}
409 {notes.map(note => (
410 <Button
411 key={`note:${note.file}`}
412 plain
413 label={`${shelf.icon} ${titleOf(note)}${targetOf(note) === undefined || current.shelf !== 'ideas' ? '' : ` @${targetOf(note)}`} · ${stateOf(current.shelf, note)}`}
414 onPress={go({ layer: 'detail', shelf: current.shelf, file: note.file })}
415 />
416 ))}
417 {isTruncated && <Text dimColor>Showing the first {SHELF_MAX} notes.</Text>}
418 </Box>
419 )
420 }
421
422 const note = notes.find(candidate => candidate.file === current.file)
423 const back = <Button key="back" hotkey="b" plain label={`‹ ${shelf.label}`} onPress={go({ layer: 'list', shelf: current.shelf })} />
424 if (note === undefined) {
425 return (
426 <Box flexDirection="column">
427 {back}
428 <Text dimColor>{current.file} is no longer in {shelf.folder}.</Text>
429 </Box>
430 )
431 }
432 const path = notePath(vault, current.shelf, note.file)
433 const header = (
434 <Box>
435 {back}
436 <Text bold> {titleOf(note)}</Text>
437 <Text dimColor> · {stateOf(current.shelf, note)}</Text>
438 {current.shelf === 'ideas' && targetOf(note) !== undefined && <Text dimColor> · @{targetOf(note)}</Text>}
439 </Box>
440 )
441
442 if (current.shelf === 'reading') {
443 const mark = (state: ReadingState, label: string, hotkey: string) => (
444 <Button key={`mark:${state}`} hotkey={hotkey} label={label} onPress={send(readingPrompt(vault, path, state), `mark it ${state}`)} />
445 )
446
447 return (
448 <Box flexDirection="column">
449 {header}
450 {note.props.url && <Text dimColor>{note.props.url}</Text>}
451 <Box>
452 {mark('reading', 'Reading', 'r')}
453 <Text> </Text>
454 {mark('done', 'Done', 'd')}
455 <Text> </Text>
456 {mark('dropped', 'Drop', 'x')}
457 </Box>
458 </Box>
459 )
460 }
461
462 const questions = note.sections['Open Questions'] ?? []
463 const options = note.sections['Options'] ?? []
464 const repo = await $.session.repo()
465 const handoffRoot = repo !== null && repo.root !== vault ? repo.root : undefined
466 const hasHarness = handoffRoot !== undefined && (await $.fs.exists(`${handoffRoot}/${HARNESS_INDEX}`))
467 const decide = (decision: string) => send(decisionPrompt(vault, path, decision), 'record the decision')()
468 // Mobile has no Input; there the options' Choose buttons still decide.
469 let decisionInput = null
470 if (e.surface !== 'mobile') {
471 const { Input } = $.ui.resolve(e)
472 decisionInput = (
473 <Input
474 key="decision"
475 label="Decision"
476 placeholder="type a decision, Enter sends it to Claude"
477 onSubmit={value => {
478 if (value.trim() !== '') {
479 void decide(value.trim())
480 }
481 }}
482 />
483 )
484 }
485
486 return (
487 <Box flexDirection="column">
488 {header}
489 <Text bold>Open questions</Text>
490 {questions.length === 0 ? <Text dimColor> none yet; Expand drafts some</Text> : questions.map(question => <Text> • {question}</Text>)}
491 <Text bold>Options</Text>
492 {options.length === 0 && <Text dimColor> none yet; Expand drafts some</Text>}
493 {options.map((option, index) => (
494 <Box>
495 <Button key={`choose:${index}`} hotkey={index < 9 ? String(index + 1) : undefined} plain label="Choose" onPress={() => decide(`Chose: ${option}`)} />
496 <Text> {option}</Text>
497 </Box>
498 ))}
499 {decisionInput}
500 <Box>
501 <Button key="expand" hotkey="e" label="Expand" onPress={send(expandPrompt(vault, path), 'expand the idea')} />
502 {handoffRoot !== undefined && <Text> </Text>}
503 {handoffRoot !== undefined && (
504 <Button
505 key="handoff"
506 hotkey="x"
507 label={`Hand off to ${handoffRoot.split('/').at(-1)}`}
508 onPress={send(handoffPrompt(vault, path, handoffRoot, hasHarness), 'hand it off')}
509 />
510 )}
511 </Box>
512 </Box>
513 )
514 })
515}
516hooks/core.ts 147 lines1// Pure logic for vault-jot: parsing captures, naming and rendering inbox
2// files, and summarizing the inbox backlog. No `$` calls here.
3
4import type { Backlog } from '../types'
5
6// Must match the "Capture Kinds" table in the vault's Vault Guide, which
7// says what ingest turns each kind into.
8export const KINDS = ['idea', 'improve', 'read', 'til', 'pitfall', 'plugin', 'note'] as const
9export type Kind = (typeof KINDS)[number]
10
11// `target` names the app or repo an `improve` capture is about; ingest files
12// it as the idea's `target`, and the incubate pane matches it to the repo
13// folder name.
14export type Jot = { kind: Kind; text: string; target?: string }
15
16export type Origin = {
17 cwd: string
18 repo: string | null
19 branch: string | null
20 session: string
21}
22
23export type InboxEntry = { name: string; mtimeMs: number }
24
25export type Thresholds = { count: number; days: number }
26
27const KIND_PREFIX = new RegExp(`^(${KINDS.join('|')})(?:\\s+@([\\p{L}\\p{N}_.-]+))?:\\s*`, 'iu')
28const DAY_MS = 24 * 60 * 60 * 1000
29const SLUG_MAX = 40
30const TITLE_MAX = 80
31
32// `/jot idea: text` → { kind: 'idea', text }; `/jot improve @cx: text` also
33// carries a target. No known prefix → kind `note`, so text like
34// "https://..." is never mistaken for a kind. Null when empty.
35export function parseJot(args: string): Jot | null {
36 const trimmed = args.trim()
37 const match = KIND_PREFIX.exec(trimmed)
38 const kind = (match?.[1]?.toLowerCase() ?? 'note') as Kind
39 const text = (match ? trimmed.slice(match[0].length) : trimmed).trim()
40
41 if (text === '') {
42 return null
43 }
44 const target = match?.[2]
45
46 return target === undefined ? { kind, text } : { kind, text, target }
47}
48
49// Letters and digits in any script survive, so a non-English capture still
50// gets a readable name.
51export function slugify(text: string): string {
52 const slug = text
53 .toLowerCase()
54 .replace(/[^\p{L}\p{N}]+/gu, '-')
55 .replace(/^-+|-+$/g, '')
56
57 return [...slug].slice(0, SLUG_MAX).join('').replace(/-+$/, '')
58}
59
60export function titleOf(text: string): string {
61 const firstLine = (text.split('\n', 1)[0] ?? '').trim()
62 const chars = [...firstLine]
63
64 return chars.length > TITLE_MAX ? `${chars.slice(0, TITLE_MAX - 1).join('')}…` : firstLine
65}
66
67// Local wall-clock parts for `ms`, given the zone offset in minutes east of
68// UTC (the negation of Date#getTimezoneOffset).
69export function localStamp(ms: number, offsetMinutes: number) {
70 const shifted = new Date(ms + offsetMinutes * 60_000)
71 const pad = (n: number) => String(n).padStart(2, '0')
72 const date = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`
73 const time = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`
74 const sign = offsetMinutes < 0 ? '-' : '+'
75 const abs = Math.abs(offsetMinutes)
76 const zone = `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
77
78 return { iso: `${date}T${time}${zone}`, compact: `${date.replaceAll('-', '')}-${time.replaceAll(':', '')}` }
79}
80
81// `attempt` 0 is the plain name; later attempts add a suffix for the rare
82// capture landing in the same second with the same slug.
83export function fileName(jot: Jot, compactStamp: string, attempt = 0): string {
84 const slug = slugify(jot.text)
85 const base = ['jot', compactStamp, jot.kind, slug].filter(Boolean).join('-')
86
87 return attempt === 0 ? `${base}.md` : `${base}-${attempt + 1}.md`
88}
89
90// JSON strings are valid YAML double-quoted scalars.
91const yamlString = (value: string) => JSON.stringify(value)
92
93export function renderNote(jot: Jot, isoStamp: string, origin: Origin): string {
94 const lines = [
95 '---',
96 `title: ${yamlString(titleOf(jot.text))}`,
97 `kind: ${jot.kind}`,
98 jot.target === undefined ? null : `target: ${yamlString(jot.target)}`,
99 `captured: ${isoStamp}`,
100 `origin_cwd: ${yamlString(origin.cwd)}`,
101 origin.repo === null ? null : `origin_repo: ${yamlString(origin.repo)}`,
102 origin.branch === null ? null : `origin_branch: ${yamlString(origin.branch)}`,
103 `origin_session: ${yamlString(origin.session)}`,
104 '---',
105 '',
106 jot.text,
107 '',
108 ]
109
110 return lines.filter(line => line !== null).join('\n')
111}
112
113// Dotfiles (`.gitkeep`) are not captures.
114export function summarizeInbox(entries: readonly InboxEntry[], nowMs: number): Backlog {
115 const captures = entries.filter(entry => !entry.name.startsWith('.'))
116 if (captures.length === 0) {
117 return { count: 0, oldestDays: 0 }
118 }
119 const oldest = captures.reduce((min, entry) => Math.min(min, entry.mtimeMs), Infinity)
120
121 return { count: captures.length, oldestDays: Math.max(0, Math.floor((nowMs - oldest) / DAY_MS)) }
122}
123
124// The prompt-footer label; undefined for an empty inbox so nothing shows.
125export function backlogLabel(backlog: Backlog): string | undefined {
126 if (backlog.count === 0) {
127 return undefined
128 }
129
130 return `📥 inbox ${backlog.count} · ${backlog.oldestDays}d`
131}
132
133export function isOverdue(backlog: Backlog, thresholds: Thresholds): boolean {
134 return backlog.count > 0 && (backlog.count >= thresholds.count || backlog.oldestDays >= thresholds.days)
135}
136
137export function expandHome(path: string, home: string | undefined): string {
138 if (path !== '~' && !path.startsWith('~/')) {
139 return path
140 }
141 if (home === undefined || home === '') {
142 throw new Error(`cannot expand "${path}": HOME is not set`)
143 }
144
145 return home + path.slice(1)
146}
147hooks/handoff.ts 96 lines1// Pure builders for what vault-jot asks Claude to do. The mod never writes
2// under wiki/: claude-obsidian is the single writer there, so changes to
3// maintained notes are handed to Claude as prompts built here.
4
5import { KINDS, parseJot } from './core'
6import type { Jot } from './core'
7
8const DRAFT_MAX = 500
9
10const rules = (vault: string) =>
11 `Work in the vault at ${vault}: follow its CLAUDE.md and wiki/meta/Vault Guide.md, set \`updated\` to today, and record the change in wiki/log.md.`
12
13// What the inbox band's Ingest button and `/jot ingest` both fill into the prompt.
14export const ingestPrompt = (inbox: string) =>
15 `Ingest the captures in ${inbox} with claude-obsidian wiki-ingest (batch).`
16
17export function expandPrompt(vault: string, notePath: string): string {
18 return [
19 `Incubate the idea note ${notePath}.`,
20 `Fill in "Why It's Interesting", "Open Questions" (concrete and answerable), "Options" (2-3 list items, one line each with its tradeoff), and "Next Step", from the note and anything related in the vault.`,
21 `If its status is seed, set it to developing.`,
22 rules(vault),
23 ].join(' ')
24}
25
26export function decisionPrompt(vault: string, notePath: string, decision: string): string {
27 return [
28 `Record a decision on the idea note ${notePath}: ${JSON.stringify(decision)}.`,
29 `Add it as a dated list item under a "## Decisions" section, remove open questions it answers, and update "Next Step".`,
30 `If the decision drops the idea, set status: archived and keep the reason; if it makes the idea ready to start, set status: mature.`,
31 rules(vault),
32 ].join(' ')
33}
34
35// Where a handed-off idea lands depends on the target repo: with a harness
36// (`docs/harness/index.md`) its own tracker skills decide the artifact; without
37// one, a design doc. Either way the vault side is fixed: `handoff:` points at
38// what was created and the idea is closed out.
39export const HARNESS_INDEX = 'docs/harness/index.md'
40
41export function handoffPrompt(vault: string, notePath: string, repoRoot: string, hasHarness: boolean): string {
42 const root = repoRoot.replace(/\/+$/, '')
43 const project = root.split('/').at(-1) ?? root
44 const repoStep = hasHarness
45 ? `Hand off the idea note ${notePath} to the repo at ${root}, which has a harness: read ${root}/${HARNESS_INDEX}, then use the to-issues skill there (to-prd if the idea is a whole feature), taking the note's Spark, Options, Decisions and Next Step as the plan, and publish through the harness tracker.`
46 : `Turn the idea note ${notePath} into a design doc at ${root}/docs/design/<kebab-case title>.md: problem, goals, non-goals, options with tradeoffs, decisions, open questions, next steps. Follow that repo's conventions.`
47
48 return [
49 repoStep,
50 `Then, in the vault, set the idea's \`project:\` to ${project}, \`handoff:\` to the path or URL of what you created, status: archived, and add it under "Related".`,
51 rules(vault),
52 ].join(' ')
53}
54
55export type ReadingState = 'reading' | 'done' | 'dropped'
56
57export function readingPrompt(vault: string, notePath: string, state: ReadingState): string {
58 const followUp = {
59 reading: '',
60 done: 'Then ask me for my takeaways, add them under "## Takeaways", and ask whether to ingest the item as a source.',
61 dropped: 'Ask me for a one-line reason and record it under "## Notes".',
62 }[state]
63
64 return [`Set reading_state: ${state} on the reading note ${notePath}.`, followUp, rules(vault)]
65 .filter(Boolean)
66 .join(' ')
67}
68
69// What `/jot` with no text asks a fork of the conversation.
70export const DRAFT_PROMPT = [
71 `Draft one capture for my notes vault: the single most useful thing in this conversation worth keeping.`,
72 `Reply with exactly one line, \`<kind>: <text>\`, where kind is one of ${KINDS.join(', ')}`,
73 `(idea: something to explore or build; improve: a change to make to an app, as "improve @<repo folder name>: <text>" when it is about a specific app; read: something to read, with its URL; til: something learned; pitfall: a failure mode and how to prevent it; plugin: a take on a tool; note: anything else).`,
74 `Keep the text under 200 characters, in the language of the conversation. No other words.`,
75].join(' ')
76
77// The model's first non-empty line, unwrapped from quotes or backticks.
78export function parseDraft(reply: string): Jot | null {
79 const line = reply
80 .split('\n')
81 .map(part => part.trim())
82 .find(part => part !== '')
83 if (line === undefined) {
84 return null
85 }
86
87 return parseJot(line.replace(/^[`"']+|[`"']+$/g, ''))
88}
89
90export function draftCommand(jot: Jot): string {
91 const chars = [...jot.text]
92 const text = chars.length > DRAFT_MAX ? `${chars.slice(0, DRAFT_MAX - 1).join('')}…` : jot.text
93
94 return `/jot ${jot.kind}: ${text}`
95}
96hooks/notes.ts 160 lines1// Pure reading of vault notes for the incubate pane: flat frontmatter, list
2// items per `##` section, and lookup by title. The pane only reads notes;
3// every write is handed to Claude (see handoff.ts).
4
5import type { Shelf } from '../types'
6
7export type Note = {
8 file: string
9 props: Readonly<Record<string, string>>
10 sections: Readonly<Record<string, readonly string[]>>
11}
12
13export const SHELVES: Readonly<Record<Shelf, { folder: string; label: string; icon: string }>> = {
14 ideas: { folder: 'wiki/ideas', label: 'Ideas', icon: '🌱' },
15 reading: { folder: 'wiki/reading', label: 'Reading', icon: '📖' },
16}
17
18// Lifecycles from the vault's Vault Guide, in the order the pane lists them:
19// what needs attention first.
20const STATE_ORDER: Readonly<Record<Shelf, readonly string[]>> = {
21 ideas: ['developing', 'seed', 'mature', 'archived'],
22 reading: ['reading', 'queued', 'done', 'dropped'],
23}
24
25const unquote = (raw: string): string => {
26 const value = raw.trim()
27 if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
28 try {
29 return String(JSON.parse(value))
30 } catch {
31 return value.slice(1, -1)
32 }
33 }
34 if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
35 return value.slice(1, -1).replaceAll("''", "'")
36 }
37
38 return value
39}
40
41// Scalar `key: value` lines only; block lists (tags, sources) are skipped,
42// since the pane needs none of them.
43export function parseNote(file: string, text: string): Note {
44 const lines = text.split(/\r?\n/)
45 const props: Record<string, string> = {}
46 let bodyStart = 0
47 if (lines[0]?.trim() === '---') {
48 const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---')
49 if (end > 0) {
50 for (const line of lines.slice(1, end)) {
51 const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
52 const value = match ? unquote(match[2] ?? '') : ''
53 if (match?.[1] && value !== '') {
54 props[match[1]] = value
55 }
56 }
57 bodyStart = end + 1
58 }
59 }
60
61 const sections: Record<string, string[]> = {}
62 let current: string[] | undefined
63 for (const line of lines.slice(bodyStart)) {
64 const heading = /^##\s+(.+?)\s*$/.exec(line)
65 if (heading?.[1]) {
66 current = sections[heading[1]] = []
67 continue
68 }
69 const item = /^\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?(.+?)\s*$/.exec(line)
70 if (current && item?.[1]) {
71 current.push(item[1])
72 }
73 }
74
75 return { file, props, sections }
76}
77
78export const titleOf = (note: Note): string => note.props.title ?? note.file.replace(/\.md$/, '')
79
80export function stateOf(shelf: Shelf, note: Note): string {
81 return shelf === 'ideas' ? (note.props.status ?? 'seed') : (note.props.reading_state ?? 'queued')
82}
83
84// An idea's `target` names the app it would change (an `improve` capture);
85// the pane matches it to the repo folder name.
86export const targetOf = (note: Note): string | undefined => note.props.target
87
88// Ideas within a state group by target, then title; untargeted ideas last.
89export function sortNotes(shelf: Shelf, notes: readonly Note[]): Note[] {
90 const order = STATE_ORDER[shelf]
91 const rank = (note: Note) => {
92 const index = order.indexOf(stateOf(shelf, note))
93
94 return index === -1 ? order.length : index
95 }
96 const target = (note: Note) => (shelf === 'ideas' ? (targetOf(note)?.toLowerCase() ?? '\uffff') : '')
97
98 return [...notes].sort(
99 (a, b) => rank(a) - rank(b) || target(a).localeCompare(target(b)) || titleOf(a).localeCompare(titleOf(b)),
100 )
101}
102
103const isClosed = (note: Note) => stateOf('ideas', note) === 'archived'
104
105// Open (not archived) ideas aimed at `target`, compared case-insensitively.
106export function openForTarget(ideas: readonly Note[], target: string): Note[] {
107 const wanted = target.toLowerCase()
108
109 return ideas.filter(note => targetOf(note)?.toLowerCase() === wanted && !isClosed(note))
110}
111
112const DAY_MS = 24 * 60 * 60 * 1000
113export const STALE_SEED_DAYS = 14
114
115export type Review = { staleSeeds: Note[]; openByTarget: [string, number][]; openUntargeted: number }
116
117// What the review layer shows: seeds nobody has picked up, and how much open
118// feedback each app has. `created` is a YYYY-MM-DD date; seeds without a
119// readable one are not counted stale.
120export function reviewIdeas(ideas: readonly Note[], nowMs: number): Review {
121 const open = ideas.filter(note => !isClosed(note))
122 const age = (note: Note) => {
123 const created = Date.parse(note.props.created ?? '')
124
125 return Number.isNaN(created) ? 0 : Math.floor((nowMs - created) / DAY_MS)
126 }
127 const counts = new Map<string, number>()
128 let openUntargeted = 0
129 for (const note of open) {
130 const target = targetOf(note)?.toLowerCase()
131 if (target === undefined) {
132 openUntargeted += 1
133 } else {
134 counts.set(target, (counts.get(target) ?? 0) + 1)
135 }
136 }
137
138 return {
139 staleSeeds: open.filter(note => stateOf('ideas', note) === 'seed' && age(note) >= STALE_SEED_DAYS),
140 openByTarget: [...counts].sort(([a], [b]) => a.localeCompare(b)),
141 openUntargeted,
142 }
143}
144
145// Exact title or file name first (case-insensitive), then a unique substring.
146export function findNote(notes: readonly Note[], query: string): Note | 'ambiguous' | undefined {
147 const wanted = query.trim().toLowerCase()
148 const names = (note: Note) => [titleOf(note).toLowerCase(), note.file.replace(/\.md$/, '').toLowerCase()]
149 const exact = notes.find(note => names(note).includes(wanted))
150 if (exact) {
151 return exact
152 }
153 const partial = notes.filter(note => names(note).some(name => name.includes(wanted)))
154 if (partial.length > 1) {
155 return 'ambiguous'
156 }
157
158 return partial[0]
159}
160types/index.d.ts 24 lines1// The inbox backlog the footer label and band show; null until first read or
2// when the inbox cannot be read.
3export type Backlog = { count: number; oldestDays: number }
4
5// Open ideas aimed at the session's repo, for the footer label; null when the
6// session is not in a repo other than the vault.
7export type OpenForRepo = { target: string; count: number }
8
9// The incubate pane's shelves, one per vault folder it browses.
10export type Shelf = 'ideas' | 'reading'
11
12// Where the incubate pane is: shelves, one shelf's notes, or one note.
13export type View =
14 | { layer: 'home' }
15 | { layer: 'review' }
16 | { layer: 'list'; shelf: Shelf }
17 | { layer: 'detail'; shelf: Shelf; file: string }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'vault-jot': { backlog: Backlog | null; isHidden: boolean; view: View; openForRepo: OpenForRepo | null }
22 }
23}
24