SLOPSHOPPER

vault-jot

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

newpanebandspinnercommandtoast
v0.2.0no licenseupdated 2026-10-06Hsiang-LinC/vault-jot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vault-jot
│ ┃ incubate ✕ › fix the failing auth test and add an audit log call │ ┃ vault-jot: vaultPath is not set; set it in │ ┃ /config under vault-jot ⏺ 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 │ │ › /jot │ ⎿ vault-jot: draft is in your prompt. Edit it and press Enter to s │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ vault-jot: vault-jot: vaultPath is not set; set it in /config under vault-jot

Draws

Pane · incubate
vault-jot: vaultPath is not set; set it in /config under vault-jot
README

vault-jot

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.

Use

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.

Draft from the conversation

/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

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

Configure

In /config, under vault-jot:

FieldDefaultMeaning
vaultPath(unset)Vault root, absolute or ~/.... Required; /jot refuses and the status line says so until it is set.
backlogCount10Band threshold by count.
backlogDays7Band 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.

Install

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.

Develop

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.

Source 5 files
hooks/register.tsx 516 lines
1import { 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}
516
hooks/core.ts 147 lines
1// 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}
147
hooks/handoff.ts 96 lines
1// 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}
96
hooks/notes.ts 160 lines
1// 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}
160
types/index.d.ts 24 lines
1// 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