An all-in-one notes manager for an Obsidian second brain: writes the daily note itself (changes and why, decisions, discussions, research and lookups), queues…

A Claude Code mod that keeps your Obsidian second brain up to date by itself. You work with Claude; it takes the notes.
Every session writes itself into today's daily note: what changed and why, what you decided (in your own words), what was discussed but not settled, and what was researched or looked up, with sources. When work in one project affects another, it leaves a signal so nothing slips between projects. It survives crashes, works from your phone, and never needs you to remember anything.
Why this exists, in one line: a second brain is only as good as what gets written into it, and the moment you most need a note (mid-task, deep in a session) is the moment you are least likely to stop and write one.
A block like this lands in today's daily note, updated after every turn:
## Session 2 (14:05): contact form spam
*auto-written by vault-scribe · session `abcdef12` · own project: Website · cwd `C:/code/website` · updated 14:40*
### What Got Done
- added a honeypot field to the contact form (why: bots were submitting it daily)
- returned 422 for spam submissions (why: the blog embed needs a clear failure)
### Decisions Made
- use a honeypot, not a captcha (Alex: "no captcha, people hate them")
### Discussions & Open Questions
- weighed rate limiting per IP, deferred until spam volume is measured
- proposed, not confirmed: move the form to its own page
### Research & Lookups
- searched honeypot field effectiveness: catches most simple bots (source: https://example.com/honeypot)
- read the blog's embed code: it posts to the same endpoint, so it needed the 422 handling
### What's Still In Progress
- measure spam volume for a week
### Notes Touched
- [[Projects/Website/memory/forms|forms]]
### Signals for Review
- 1 queued for review → [[vault-scribe/signals/2026-10-08|signals 2026-10-08]]: `SIG-2026-10-08-abcdef12-1`
What each section is for:
map.md:128), an edit that shifts lines tells Claude exactly which citations elsewhere now point at the wrong line.Requires Claude Code 2.1.287 or newer (mods), in the terminal or the desktop app's Code tab.
/plugin install vault-scribe --marketplace saladin1980/vault-scribe
Then:
vault-scribe/config.md inside your vault.vault_root option (/config, or pluginConfigs in ~/.claude/settings.json)./scribe to check it found everything.Full walkthrough: docs/setup.md.
/scribe: status (owner, project, what's captured, signals, heads-up)/scribe flush: summarize and write now. Optional: it happens on its own.It reads your vault and the tools Claude runs in your session. Summaries go through your own Claude session's model (the one in scribe_model). Secrets that look like tokens, keys and passwords are redacted before anything is stored, summarized or written. Everything it writes stays in your vault. Details: docs/how-it-works.md.
claude plugin validate .
claude plugin test .
MIT. Built by saladin1980 to run a personal second brain, and shared in case it helps yours.
hooks/register.tsx 551 lines1// vault-scribe: an all-in-one notes manager for an Obsidian "second brain".
2// Writes the daily note itself (changes and why, decisions, discussions, research and lookups), queues
3// cross-project signals for a reviewer, shows a heads-up band, and guards a few dangerous writes.
4// Configured by one note in the vault: see README.md and docs/configuration.md.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { HeadsUp, HeadsUpItem } from '../types'
9import {
10 N8N_WHY, approvesPush, checkQuotes, clip, fwd, commandDenyReason, matchPushRule, parseGitPush, countSessions, dailyNotePath, digestText, extractCitations, firstDiffLine,
11 isN8nDeleteTool, isPending, isTrivial, isUnder, lineCount, mergeSummary, newDailyNote, newSignalsFile, norm,
12 ownProject, parseProjectMap, parseSignals, parseSummary, pathDenyReason, redact, renderBlock, renderSignal,
13 shouldQueue, signalsPath, staleCitations, stampOf, summaryPrompt, tagText, touchName, upsertSession, vaultRel,
14} from './lib'
15import type { Agg, Citation, GuardOptions, ProjectMap, Signal, Summary, TurnRec } from './lib'
16
17type $T = EngineInterface
18
19const headsUp = atom({ plugin: 'vault-scribe', key: 'headsUp' } as const, null)
20const isDismissed = atom({ plugin: 'vault-scribe', key: 'isDismissed' } as const, false)
21
22const HUMAN = new Set(['composer', 'bridge'])
23const IDLE_MS = 8 * 60 * 1000
24const HEADS_UP_EVERY_MS = 10 * 60 * 1000
25const HEADS_UP_DAYS = 14
26const KEEP_SESSIONS_MS = 3 * 24 * 3600 * 1000
27const RECOVER_AFTER_MS = 30 * 60 * 1000
28
29
30// Module state: a hot reload drops it; the session's aggregate lives in $.store too.
31let root = ''
32let configNote = 'vault-scribe/config.md'
33let map: ProjectMap = parseProjectMap('')
34let agg: Agg | null = null
35let current: TurnRec = { actions: [] }
36let citations: Citation[] | null = null
37let headsUpAt = 0
38let idle: { cancel: () => void } | null = null
39let chain: Promise<unknown> = Promise.resolve()
40
41function serial<T>(fn: () => Promise<T>): Promise<T> {
42 const run = chain.then(fn, fn)
43 chain = run.catch(() => undefined)
44 return run
45}
46function log($: $T, text: string) {
47 try { $.ui.log(`vault-scribe: ${text}`) } catch { /* nothing draws */ }
48}
49
50async function loadMap($: $T) {
51 try {
52 map = parseProjectMap(await $.fs.read(`${root}/${configNote}`))
53 } catch {
54 log($, `config note ${configNote} unreadable; using defaults, project tagging is off until it is back`)
55 }
56 return map
57}
58
59async function freshAgg($: $T, id: string, cwd: string): Promise<Agg> {
60 const now = await $.clock.now()
61 const s = stampOf(now, map.timeZone)
62 return {
63 sessionId: id, cwd: fwd(cwd), own: ownProject(cwd, map.projects, root), sessionNo: 0, date: s.date, startedAt: s.time,
64 updatedAtMs: now, ctx: [], isNudged: false, isHeadsUpSent: false, topic: '', indexLine: '', done: [],
65 decisions: [], inProgress: [], discussions: [], research: [], notes: [], signalIds: [], digest: [], humanTurns: 0,
66 isReps: false,
67 }
68}
69
70async function ensureAgg($: $T): Promise<Agg> {
71 const id = await $.session.id()
72 if (agg && agg.sessionId === id) return agg
73 const stored = (await $.store.get(`sess:${id}`)) as Agg | undefined
74 agg = stored && stored.sessionId === id ? stored : await freshAgg($, id, await $.session.root())
75 // Records made by an older build lack the newer fields.
76 agg.discussions ??= []
77 agg.research ??= []
78 return agg
79}
80
81async function saveAgg($: $T, a: Agg) {
82 a.updatedAtMs = await $.clock.now()
83 await $.store.set(`sess:${a.sessionId}`, a)
84}
85
86async function save($: $T) {
87 if (agg) await saveAgg($, agg)
88}
89
90// ---------- heads-up ----------
91
92async function refreshHeadsUp($: $T) {
93 headsUpAt = await $.clock.now()
94 const a = await ensureAgg($)
95 const dir = `${root}/${map.signalsDir}`
96 if (!(await $.fs.exists(dir))) return
97 const cutoff = stampOf(headsUpAt - HEADS_UP_DAYS * 24 * 3600 * 1000, map.timeZone).date
98 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && /^\d{4}-\d{2}-\d{2}\.md$/.test(f.name) && f.name.slice(0, 10) >= cutoff)
99 const id8 = a.sessionId.slice(0, 8)
100 const items: HeadsUpItem[] = []
101 for (const f of files) {
102 const sigs = parseSignals(await $.fs.read(`${dir}/${f.name}`).catch(() => ''))
103 for (const s of sigs) {
104 if (!isPending(s) || s.sessionId === id8) continue
105 const touchesOwn = a.own === 'vault' || s.touches.some(t => touchName(t) === a.own)
106 if (touchesOwn) items.push({ id: s.id, what: s.what, kind: s.kind, from: s.from.replace(/ · cwd.*$/, '') })
107 }
108 }
109 const next: HeadsUp | null = items.length === 0 ? null : { project: a.own, items: items.slice(-5), total: items.length }
110 await update($, headsUp, () => next)
111 $.ui.status(`📓 scribe · ${a.own}${next ? ` · ⚠ ${next.total}` : ''}`)
112}
113
114// ---------- citation index (vault .md files citing file.md:NNN) ----------
115
116async function buildCitations($: $T) {
117 const out: Citation[] = []
118 const walk = async (dir: string, depth: number) => {
119 if (depth > 8) return
120 const entries = await $.fs.list(dir).catch(() => [])
121 for (const en of entries) {
122 if (en.kind === 'dir' && !map.skipDirs.includes(en.name) && !en.name.startsWith('.')) await walk(`${dir}/${en.name}`, depth + 1)
123 else if (en.kind === 'file' && en.name.toLowerCase().endsWith('.md') && en.size < 2_000_000) {
124 const path = `${dir}/${en.name}`
125 const text = await $.fs.read(path).catch(() => '')
126 if (/\.md:\d/.test(text)) out.push(...extractCitations(path, text))
127 }
128 }
129 }
130 await walk(root, 0)
131 citations = out
132}
133
134function reindex(path: string, text: string) {
135 if (!citations) return
136 citations = citations.filter(c => norm(c.file) !== norm(path)).concat(extractCitations(path, text))
137}
138
139// ---------- flush: summary → signals → daily note ----------
140
141/** The live session's flush. `summarize` false = the cheap per-turn write: no model call, digest kept. */
142async function flush($: $T, why: string, summarize = true) {
143 return serial(async () => flushAgg($, await ensureAgg($), why, summarize))
144}
145
146async function flushAgg($: $T, a: Agg, why: string, summarize: boolean) {
147 {
148 if (a.digest.length === 0 && a.notes.length === 0 && a.done.length === 0) return
149 if (summarize && a.digest.length === 0 && a.sessionNo > 0) return
150 await loadMap($)
151 const names = map.projects.map(p => p.name)
152 const now = await $.clock.now()
153 const stamp = stampOf(now, map.timeZone)
154 a.discussions ??= []
155 a.research ??= []
156
157 let summary: Summary | null = null
158 if (summarize && a.digest.length > 0) {
159 const { system, prompt } = summaryPrompt(digestText(a.digest), map.projects, a.own, map.owner)
160 const r = await $.model.complete({ model: map.model, system, prompt, maxTokens: 2500, effort: 'low' }).catch(() => null)
161 if (r && r.isAnswered) summary = parseSummary(r.text, names)
162 // A decision only counts when it quotes words the owner actually typed.
163 const ownerWords = a.digest.map(t => t.prompt ?? '').filter(x => x !== '')
164 if (summary) summary = checkQuotes(summary, ownerWords)
165 if (!summary) log($, `summary unavailable (${r ? (r.isAnswered ? 'unparseable reply' : r.reason) : 'request refused'}); wrote the deterministic part only`)
166 }
167
168 // Deterministic tags from what the session actually touched.
169 const det = new Map<string, string[]>()
170 for (const t of a.digest) for (const act of t.actions) {
171 // Only changes count: edits, writes and non-read-only shell commands. Reads, lookups and reports don't.
172 if (!/^(edit|write) |^(Bash|PowerShell): /.test(act)) continue
173 // An edit is evidence by its path alone, never by the text it changed.
174 const subject = /^(edit|write) /.test(act) ? actionPath(act) : act
175 // Only a real path or host is evidence of touching a project; a keyword inside command text is not
176 // (a heredoc full of project names tagged seven projects on 2026-10-08).
177 for (const [p, ev] of tagText(subject, map.projects)) {
178 const hard = ev.filter(x => !x.startsWith('keyword '))
179 if (hard.length > 0) det.set(p, [...new Set([...(det.get(p) ?? []), ...hard])])
180 }
181 }
182
183 const found: { kind: Signal['kind']; what: string; why: string; touches: Map<string, string[]> }[] = []
184 for (const s of summary?.signals ?? []) {
185 const touches = new Map<string, string[]>()
186 for (const p of s.projects) touches.set(p, ['named by summary'])
187 for (const [p, ev] of tagText(`${s.what} ${s.why}`, map.projects)) touches.set(p, [...(touches.get(p) ?? []), ...ev])
188 found.push({ kind: s.kind, what: s.what, why: s.why, touches })
189 }
190 for (const [p, ev] of det) {
191 if (p === a.own || found.some(f => f.touches.has(p))) continue
192 found.push({
193 kind: 'change',
194 what: `This session touched ${p} while working in ${a.own}: ${ev.slice(0, 3).join(', ')}`,
195 why: 'Detected from the tools the session ran; no summarized signal covered it. Check what changed and whether its notes need it.',
196 touches: new Map([[p, ev]]),
197 })
198 }
199
200 const queued: Signal[] = []
201 const id8 = a.sessionId.slice(0, 8)
202 if (summarize && !a.isReps) {
203 for (const f of found) {
204 const projects = [...f.touches.keys()]
205 if (!shouldQueue(f.kind, projects, a.own)) continue
206 const touches = projects.map(p => `${p}${p === a.own ? ' (own project)' : ''} (${clip(f.touches.get(p)!.join(', '), 160)})`)
207 queued.push({
208 id: `SIG-${stamp.date}-${id8}-${a.signalIds.length + queued.length + 1}`, status: '⏳ pending', kind: f.kind,
209 what: redact(f.what), why: redact(f.why), sessionId: id8,
210 from: `session ${id8} · cwd \`${a.cwd}\` · own project: ${a.own} · ${stamp.date} ${stamp.time}`,
211 touches: touches.length > 0 ? touches : [`${a.own} (own project)`],
212 })
213 }
214 if (queued.length > 0) {
215 const path = signalsPath(root, stamp.date, map.signalsDir)
216 const text = await $.fs.read(path).catch(() => newSignalsFile(stamp.date))
217 await $.fs.write(path, text.replace(/\s*$/, '\n\n') + queued.map(renderSignal).join('\n'))
218 a.signalIds.push(...queued.map(q => q.id))
219 }
220 }
221
222 // Notes for the model, delivered with the owner's next prompt.
223 for (const q of queued) {
224 if (q.kind === 'decision' || q.kind === 'correction') {
225 a.ctx.push(`vault-scribe: captured a ${q.kind}: "${clip(q.what, 200)}" (${q.id}, queued for review). Before calling it applied, search the WHOLE vault for its subject, not just the file at hand: a ruling applied to one file tends to leave contradicting lines elsewhere.`)
226 }
227 }
228 const ownRow = map.projects.find(p => p.name === a.own)
229 const touchedOwnMemory = ownRow ? a.notes.some(n => isUnder(n, `${root}/${ownRow.memory}`)) : true
230 const editedSomething = a.digest.some(t => t.actions.some(x => /^(edit|write) /.test(x)))
231 if (summarize && ownRow && !touchedOwnMemory && !a.isNudged && editedSomething && a.humanTurns >= 3) {
232 a.isNudged = true
233 a.ctx.push(`vault-scribe: this session has been changing ${a.own}, and nothing in its memory folder (${ownRow.memory}) has been updated yet. If a fact a future session needs changed, update that project's memory notes now while the context is here; the daily note is already handled.`)
234 }
235
236 if (summary) Object.assign(a, mergeSummary(a, summary))
237 if (summarize && !summary && a.digest.length > 0) {
238 const acts = a.digest.flatMap(t => t.actions).filter(x => /^(edit|write) /.test(x)).map(actionPath)
239 if (acts.length > 0) a.done.push(`edited ${acts.length} file(s): ${clip([...new Set(acts)].slice(0, 4).join(', '), 220)}`)
240 }
241 if (summarize) a.digest = []
242
243 // Daily note: this session's block + Index bullet only, the file re-read just before the write.
244 const notePath = dailyNotePath(root, stampOfDate(a.date), map.dailyNotePattern)
245 const raw = await $.fs.read(notePath).catch(() => newDailyNote(stampOfDate(a.date), map.dailyFrontmatter))
246 const isCrlf = raw.includes('\r\n')
247 if (a.sessionNo === 0) a.sessionNo = countSessions(raw) + 1
248 const sigLink = a.signalIds.length > 0 ? `[[${map.signalsDir.replace(/^[\\/]+|[\\/]+$/g, '')}/${a.date}|signals ${a.date}]]` : null
249 const indexLine = a.indexLine || `**${a.topic || 'Session'}**: in progress`
250 const out = upsertSession(raw, a.sessionId, renderBlock(a, root, sigLink, stamp.time), indexLine)
251 await $.fs.write(notePath, isCrlf ? out.replace(/\n/g, '\r\n') : out)
252 await saveAgg($, a)
253 if (why === 'command') log($, `daily note updated (${notePath})`)
254 if (why === 'recover') log($, `recovered session ${id8} (it ended without writing up its last turns)`)
255 }
256}
257
258/** Sessions that died (crash, closed window) with turns never written up: write them now. */
259async function recoverOrphans($: $T) {
260 const now = await $.clock.now()
261 const live = agg?.sessionId
262 for (const k of await $.store.keys()) {
263 if (!k.startsWith('sess:') || k === `sess:${live}`) continue
264 const v = (await $.store.get(k)) as Agg | undefined
265 if (!v) continue
266 const idleFor = now - (v.updatedAtMs ?? 0)
267 // A live session writes up its digest within IDLE_MS; one still holding turns well past that is dead.
268 if (v.digest && v.digest.length > 0 && idleFor > RECOVER_AFTER_MS) {
269 await serial(() => flushAgg($, v, 'recover', true)).catch(() => undefined)
270 } else if (idleFor > KEEP_SESSIONS_MS && (!v.digest || v.digest.length === 0)) {
271 await $.store.delete(k)
272 }
273 }
274}
275
276/** `edit D:/x/y.md: "old" -> "new"` / `write D:/x/y.md (12 lines)` → `D:/x/y.md` */
277function actionPath(act: string): string {
278 return act.replace(/^(edit|write) /, '').split(/: "| \(/)[0] ?? ''
279}
280
281const oneLine = (t: string) => t.replace(/\s+/g, ' ').trim()
282
283/** What a tool call contributes to the work log, or null for calls not worth noting. */
284function describeCall(tool: string, args: Record<string, unknown>, isReadOnly: boolean): string | null {
285 const s = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
286 const path = fwd(s('file_path') || s('notebook_path'))
287 if (tool === 'Edit' && path) {
288 return `edit ${path}: "${clip(redact(oneLine(s('old_string'))), 90)}" -> "${clip(redact(oneLine(s('new_string'))), 90)}"`
289 }
290 if (tool === 'MultiEdit' && path) return `edit ${path} (${Array.isArray(args.edits) ? args.edits.length : 1} edits)`
291 if (tool === 'NotebookEdit' && path) return `edit ${path} (notebook cell)`
292 if (tool === 'Write' && path) return `write ${path} (${s('content').split(/\r?\n/).length} lines)`
293 if (tool === 'Bash' || tool === 'PowerShell') {
294 const cmd = s('command')
295 return cmd ? `${isReadOnly ? 'read ' : ''}${tool}: ${clip(redact(oneLine(cmd)), 300)}` : null
296 }
297 if (tool === 'Read' && path) return `read Read: ${path}`
298 if (tool === 'Grep') return `read Grep: "${clip(s('pattern'), 80)}" in ${fwd(s('path')) || '.'}`
299 if (tool === 'Glob') return `read Glob: ${clip(s('pattern'), 80)} in ${fwd(s('path')) || '.'}`
300 if (tool === 'WebSearch' || (/web[_-]?search/i.test(tool) && s('query'))) return `web search: ${clip(s('query'), 200)}`
301 if (tool === 'WebFetch' || (/fetch/i.test(tool) && s('url'))) return `web fetch: ${clip(s('url'), 300)}`
302 if (/navigate$/i.test(tool) && s('url')) return `web open: ${clip(s('url'), 300)}`
303 if (tool === 'Agent' || tool === 'Task') return `research agent: ${clip(oneLine(s('description') || s('prompt')), 160)}`
304 return null
305}
306
307/**
308 * The push guard: what the config note's push policy says about this `git push`, judged before it runs.
309 * A held push goes through only when the owner's own prompt this turn approves it ("ship it", "push it").
310 */
311async function pushDecision($: $T, command: string): Promise<{ deny?: string; note?: string }> {
312 const p = parseGitPush(command)
313 if (!p || (map.pushPolicy.length === 0 && !map.guardForcePush)) return {}
314 const cwd = fwd(await $.session.cwd())
315 const dir = p.dir === null ? cwd : /^[a-z]:|^\//i.test(fwd(p.dir)) ? fwd(p.dir) : `${cwd}/${fwd(p.dir)}`
316 const git = async (...args: string[]) => {
317 const r = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 5000 })
318 return r.exitCode === 0 ? r.stdout.trim() : ''
319 }
320 const url = await git('remote', 'get-url', p.remote)
321 const branch = p.branch ?? (await git('rev-parse', '--abbrev-ref', 'HEAD'))
322 const rule = matchPushRule(map.pushPolicy, url, dir, branch)
323 const said = current.prompt ?? ''
324 const where = `${url || dir} (${branch || 'current branch'})`
325 if (p.isForce && map.guardForcePush && !approvesPush(said, true)) {
326 return { deny: `vault-scribe held this push: it is a FORCE push to ${where}, which can wipe history. Ask ${map.owner} first; it goes through when their own message approves a force push (e.g. "force push it").` }
327 }
328 if (rule?.policy === 'hold' && !approvesPush(said, p.isForce)) {
329 return {
330 deny: [
331 `vault-scribe held this push to ${where}: ${rule.deploysTo || 'the push policy holds it'}.`,
332 rule.test ? `Before asking: ${rule.test}.` : '',
333 `Get ${map.owner}'s explicit sign-off, then push again. It goes through when their own message approves it ("ship it", "push it", "go ahead and push").`,
334 ].filter(x => x !== '').join(' '),
335 }
336 }
337 if (rule?.policy === 'announce') {
338 return { note: `vault-scribe: that push to ${where} deploys: ${rule.deploysTo}. Tell ${map.owner} it is on its way and what to test${rule.test ? ` (${rule.test})` : ''}.` }
339 }
340 return {}
341}
342
343function stampOfDate(date: string) {
344 return stampOf(Date.parse(`${date}T12:00:00Z`))
345}
346
347function armIdle($: $T) {
348 idle?.cancel()
349 idle = $.clock.after(IDLE_MS, () => { void flush($, 'idle').catch(() => undefined) })
350}
351
352export const register: Register = (on, options) => {
353 // ---------- hooks ----------
354
355 on('session.start', async ($, e, next) => {
356 // Vault: the vault_root option, else VAULT_SCRIBE_ROOT, else the folder the session started in.
357 const opt = (k: string) => (typeof options[k] === 'string' ? (options[k] as string).trim() : '')
358 root = (opt('vault_root') || (await $.env.get('VAULT_SCRIBE_ROOT')) || (await $.session.root()))
359 .replace(/\\/g, '/').replace(/\/+$/, '')
360 configNote = (opt('config_note') || (await $.env.get('VAULT_SCRIBE_CONFIG')) || 'vault-scribe/config.md').replace(/\\/g, '/')
361 await loadMap($)
362 agg = null
363 const a = await ensureAgg($)
364 // The map may have changed since this session's record was made (or an older build computed it).
365 const startDir = await $.session.root()
366 a.cwd = fwd(startDir)
367 a.own = ownProject(startDir, map.projects, root)
368 await save($)
369 current = { actions: [] }
370 // A resumed or reloaded session with turns never written up: write them now, summarize in the background.
371 if (a.digest.length > 0) {
372 await flush($, 'resume', false).catch(() => undefined)
373 $.clock.after(1000, () => { void flush($, 'resume', true).catch(() => undefined) })
374 }
375 await $.command.register({ name: 'scribe', description: 'vault-scribe: status, or `/scribe flush` to write the daily note now', argumentHint: '[flush]' })
376 await refreshHeadsUp($).catch(() => undefined)
377 // Background: prune old session records, build the citation index.
378 $.clock.after(1500, () => {
379 void (async () => {
380 await recoverOrphans($)
381 await buildCitations($)
382 })().catch(() => undefined)
383 })
384 return next(e)
385 })
386
387 on('session.end', async ($, e, next) => {
388 await flush($, 'end').catch(() => undefined)
389 return next(e)
390 })
391
392 on('prompt.submit', async ($, e, next) => {
393 const a = await ensureAgg($).catch(() => null)
394 if (!a) return next(e)
395 const text = e.text.trim()
396 const kind = e.origin.kind
397 const rc = map.reviewerCommand
398 if (rc !== '' && (text.startsWith(`/${rc}`) || (kind === 'scheduled-trigger' && text.includes(rc)))) a.isReps = true
399 if (HUMAN.has(kind) && !text.startsWith('/') && !isTrivial(text)) {
400 current.prompt = clip(redact(text), 1500)
401 } else if (HUMAN.has(kind) && isTrivial(text)) {
402 current.prompt = current.prompt ?? text
403 }
404
405 const extra: string[] = []
406 const hu = await read($, headsUp)
407 if (hu && !a.isHeadsUpSent && HUMAN.has(kind) && a.own !== 'vault') {
408 a.isHeadsUpSent = true
409 extra.push([
410 `vault-scribe heads-up: ${hu.total} change(s) from other sessions touch ${hu.project} and have not been reviewed yet. Treat the notes they affect as possibly stale:`,
411 ...hu.items.map(i => `- [${i.kind}] ${i.what} (${i.from}; ${i.id})`),
412 `Mention this to ${map.owner} once if it bears on what they asked. Do not fix other projects' notes from here; the reviewer does that.`,
413 ].join('\n'))
414 }
415 if (a.ctx.length > 0 && HUMAN.has(kind)) extra.push(...a.ctx.splice(0))
416 if ((await $.clock.now()) - headsUpAt > HEADS_UP_EVERY_MS) $.clock.after(50, () => { void refreshHeadsUp($).catch(() => undefined) })
417 await save($)
418 return extra.length > 0 ? next({ ...e, context: [...(e.context ?? []), ...extra] }) : next(e)
419 }).catch(($, e, next) => next(e))
420
421 on('tool.call', async ($, e, next) => {
422 const tool = String(e.tool)
423 const args = e as unknown as Record<string, unknown>
424 const path = typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : null
425 const command = (tool === 'Bash' || tool === 'PowerShell') && typeof args.command === 'string' ? args.command : null
426 const isWrite = tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit'
427
428 // Hard rules: refuse before anything runs.
429 const g: GuardOptions = { vaultRoot: root, autoMemory: map.guardAutoMemory, paths: map.guardPaths, n8n: map.guardN8nDelete }
430 const reason = (isWrite && path ? pathDenyReason(path, g) : null)
431 ?? (command ? commandDenyReason(command, g) : null)
432 ?? (map.guardN8nDelete && isN8nDeleteTool(tool) ? N8N_WHY : null)
433 if (reason) return { deny: `vault-scribe refused this: ${reason}` }
434 const push = command ? await pushDecision($, command).catch(() => ({} as { deny?: string; note?: string })) : {}
435 if (push.deny) return { deny: push.deny }
436
437 // Soft rules: look before, act after.
438 const isVaultMd = isWrite && path !== null && isUnder(path, root) && /\.md$/i.test(path)
439 const before = isVaultMd ? await $.fs.read(path!).catch(() => null) : null
440 const mf = map.memoryFolderName.toLowerCase()
441 const newMemoryDir = isWrite && path !== null && mf !== '' && norm(path).split('/').includes(mf)
442 ? !(await $.fs.exists(path.replace(/[\\/][^\\/]*$/, '')).catch(() => true))
443 : false
444
445 const ran = await next(e)
446 if (ran.deny !== undefined || ran.isError === true) return ran
447
448 // Read-only commands (the engine's own check) are context for the summary, never evidence of a change.
449 const described = describeCall(tool, args, ran.isReadOnly === true)
450 if (described) current.actions.push(described)
451
452 const notes: string[] = []
453 if (push.note) notes.push(push.note)
454 if (agg && isVaultMd && path) {
455 if (!agg.notes.some(n => norm(n) === norm(path))) agg.notes.push(path)
456 if (map.reviewDir !== '' && isUnder(path, `${root}/${map.reviewDir}`)) agg.isReps = true
457 }
458 if (isVaultMd && path) {
459 const after = await $.fs.read(path).catch(() => null)
460 if (after !== null) {
461 if (before !== null && citations) {
462 const delta = lineCount(after) - lineCount(before)
463 const from = firstDiffLine(before, after)
464 const stale = staleCitations(citations, path, from, delta)
465 if (stale.length > 0) {
466 notes.push([
467 `vault-scribe: this edit changed ${vaultRel(path, root)}.md's line count by ${delta > 0 ? '+' : ''}${delta} from line ${from}. These citations into it now point ${Math.abs(delta)} line(s) off. Fix them now (or make the edit line-neutral):`,
468 ...stale.slice(0, 15).map(c => `- ${vaultRel(c.file, root) ?? c.file}.md:${c.line} cites :${c.cited} → now :${c.cited + delta}`),
469 ...(stale.length > 15 ? [`- …and ${stale.length - 15} more`] : []),
470 ].join('\n'))
471 }
472 }
473 reindex(path, after)
474 }
475 }
476 if (newMemoryDir && path) {
477 notes.push([
478 `vault-scribe: this created a new ${map.memoryFolderName}/ folder (${fwd(path).replace(/\/[^/]*$/, '')}), so a new project. Update these together, in this session (the config note's new project checklist):`,
479 ...map.checklist.map(m => `- ${m}`),
480 ].join('\n'))
481 }
482 return notes.length > 0 ? { ...ran, context: [...(ran.context ?? []), ...notes] } : ran
483 }).catch(($, e, next) => next(e))
484
485 on('turn.complete', async ($, e, next) => {
486 // A subagent's final report is research the main session acted on: keep it for the summary.
487 if (e.agentId !== undefined && e.answer) current.actions.push(`research report: ${clip(redact(oneLine(e.answer)), 1200)}`)
488 if (e.agentId === undefined) {
489 const a = await ensureAgg($).catch(() => null)
490 if (a) {
491 if (e.answer) current.answer = clip(redact(e.answer), 1500)
492 if (current.prompt || current.actions.length > 0) {
493 a.digest.push(current)
494 if (current.prompt) a.humanTurns++
495 }
496 current = { actions: [] }
497 await save($)
498 // The note is written here, inside the hook: no timer a reload or crash could drop.
499 await flush($, 'turn', false).catch(() => undefined)
500 const prompts = a.digest.filter(t => t.prompt).length
501 if (prompts >= map.summarizeEvery) $.clock.after(10, () => { void flush($, 'turns', true).catch(() => undefined) })
502 armIdle($)
503 }
504 }
505 return next(e)
506 })
507
508 on('command.run', { command: 'scribe' }, async ($, e) => {
509 const a = await ensureAgg($)
510 if (e.args.trim() === 'flush') {
511 if (current.prompt || current.actions.length > 0) { a.digest.push(current); current = { actions: [] } }
512 await flush($, 'command')
513 return { text: `vault-scribe: daily note written for session ${a.sessionId.slice(0, 8)} (${a.signalIds.length} signal(s) queued so far).` }
514 }
515 await refreshHeadsUp($).catch(() => undefined)
516 const hu = await read($, headsUp)
517 return {
518 text: [
519 `vault-scribe · owner: ${map.owner} · own project: ${a.own} · cwd ${a.cwd} · config: ${configNote}`,
520 `captured: ${a.humanTurns} prompt(s), ${a.digest.length} turn(s) waiting for the next summary, ${a.notes.length} vault note(s) touched, ${(a.discussions ?? []).length} discussion(s), ${(a.research ?? []).length} research item(s)`,
521 `daily note: ${a.sessionNo > 0 ? `Session ${a.sessionNo} in ${a.date}.md` : 'not written yet'} · summary model: ${map.model} every ${map.summarizeEvery} prompt(s)`,
522 `signals queued by this session: ${a.signalIds.length}${a.isReps ? ' (reviewer run: queueing is off)' : ''}`,
523 `heads-up: ${hu ? `${hu.total} pending signal(s) touch ${hu.project}` : 'nothing pending for this project'}`,
524 `citation index: ${citations ? `${citations.length} citations` : 'building'}`,
525 ].join('\n'),
526 }
527 })
528
529 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
530 const hu = await read($, headsUp)
531 if (!hu || e.props.hasSurvey || (await read($, isDismissed))) return next(e)
532 const { Box, Text, Button } = $.ui.resolve(e)
533 const cols = Math.max(30, (e.props.bodyColumns ?? e.viewport?.columns ?? 80) - 4)
534 return (
535 <Box flexDirection="column">
536 <Text color="warning" bold>
537 ⚠ {hu.total} change{hu.total === 1 ? '' : 's'} from other sessions touch {hu.project}, pending review
538 </Text>
539 {hu.items.slice(-3).map(i => (
540 <Text dimColor wrap="truncate-end">
541 {clip(`· ${i.what} (${i.from})`, cols)}
542 </Text>
543 ))}
544 <Box>
545 <Button key="vs-dismiss" label="Dismiss" onPress={() => update($, isDismissed, () => true)} />
546 </Box>
547 </Box>
548 )
549 })
550}
551hooks/lib.ts 727 lines1// vault-scribe: pure logic, no `$`. Everything here is testable on its own.
2// See README.md and docs/ for what each piece is for.
3
4export type Project = { name: string; memory: string; paths: string[]; hosts: string[]; keywords: string[] }
5/** Everything a vault owner can tune, read from the config note's frontmatter at runtime. */
6export type ProjectMap = {
7 projects: Project[]
8 model: string
9 summarizeEvery: number
10 /** Who the sessions belong to, as the summaries should name them. */
11 owner: string
12 timeZone: string
13 /** Vault-relative; tokens {YYYY} {MM} {DD} {Month}. */
14 dailyNotePattern: string
15 signalsDir: string
16 /** Edits under this vault folder mark a session as the reviewer's own run (no signals queued). */
17 reviewDir: string
18 /** A slash command that runs your reviewer (e.g. `review-vault`); its sessions queue no signals. */
19 reviewerCommand: string
20 /** Folder name that marks a project's memory (e.g. `claude-memory`); creating a new one shows the checklist. */
21 memoryFolderName: string
22 /** Folder names the citation index never walks into. */
23 skipDirs: string[]
24 /** Extra frontmatter lines for a daily note the scribe creates. */
25 dailyFrontmatter: string[]
26 /** Refuse writes into Claude Code's per-project auto-memory (~/.claude/projects/<x>/memory), except the vault's own. */
27 guardAutoMemory: boolean
28 /** Refuse writes whose path contains any of these fragments (case-insensitive, forward slashes). */
29 guardPaths: string[]
30 guardN8nDelete: boolean
31 /** What to update when a new project memory folder appears ("## New project checklist" in the config note). */
32 checklist: string[]
33 /** Per repo and branch: what a push does and whether it is held ("## Push policy" in the config note). */
34 pushPolicy: PushRule[]
35 /** Hold every force push, whatever the policy says. */
36 guardForcePush: boolean
37}
38
39export type PushPolicyKind = 'hold' | 'announce' | 'go'
40export type PushRule = { repo: string; branch: string; policy: PushPolicyKind; deploysTo: string; test: string }
41
42export const DEFAULT_DAILY_NOTE = '{YYYY}-{MM}-{DD}.md'
43export const DEFAULT_SIGNALS_DIR = 'vault-scribe/signals'
44export const DEFAULT_CHECKLIST = ['the vault-scribe config note: a project-table row so the new project gets tagged']
45
46export type SignalKind = 'change' | 'decision' | 'correction' | 'fact' | 'discussion'
47export type Signal = {
48 id: string
49 status: string
50 kind: SignalKind
51 what: string
52 why: string
53 from: string
54 touches: string[]
55 sessionId: string
56}
57
58export type TurnRec = { prompt?: string; actions: string[]; answer?: string }
59
60export type Agg = {
61 sessionId: string
62 cwd: string
63 own: string
64 sessionNo: number
65 /** The note's date (the session's start day), and its HH:MM start. */
66 date: string
67 startedAt: string
68 updatedAtMs: number
69 /** Notes for the model, flushed into the next prompt's context. */
70 ctx: string[]
71 isNudged: boolean
72 isHeadsUpSent: boolean
73 topic: string
74 indexLine: string
75 done: string[]
76 decisions: string[]
77 inProgress: string[]
78 /** Options weighed, proposals waiting on the owner, ideas rejected and why. */
79 discussions: string[]
80 /** What was read or looked up, the finding, and what it informed (with sources). */
81 research: string[]
82 notes: string[]
83 signalIds: string[]
84 digest: TurnRec[]
85 humanTurns: number
86 isReps: boolean
87}
88
89export type Summary = {
90 topic: string
91 indexLine: string
92 done: string[]
93 decisions: string[]
94 inProgress: string[]
95 discussions: string[]
96 research: string[]
97 signals: { kind: SignalKind; what: string; why: string; projects: string[]; quote: string }[]
98}
99
100// ---------- paths ----------
101
102/** Display form of a path: forward slashes. */
103export const fwd = (p: string) => p.replace(/\\/g, '/')
104
105export const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
106
107export const isUnder = (path: string, root: string) => {
108 const p = norm(path)
109 const r = norm(root)
110 return p === r || p.startsWith(r + '/')
111}
112
113/** `C:\Vault\Projects\x.md` → `Projects/x` (null outside the vault). */
114export function vaultRel(path: string, vaultRoot: string): string | null {
115 if (!isUnder(path, vaultRoot)) return null
116 const p = path.replace(/\\/g, '/')
117 const rel = p.slice(vaultRoot.replace(/\\/g, '/').replace(/\/+$/, '').length + 1)
118 return rel.replace(/\.md$/i, '')
119}
120
121export function noteLink(path: string, vaultRoot: string): string | null {
122 const rel = vaultRel(path, vaultRoot)
123 if (rel === null || !/\.md$/i.test(path)) return null
124 const base = rel.split('/').pop() ?? rel
125 return `[[${rel}|${base}]]`
126}
127
128// ---------- project map ----------
129
130export function parseFrontmatter(text: string): Record<string, string> {
131 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
132 const out: Record<string, string> = {}
133 if (!m) return out
134 for (const line of (m[1] ?? '').split(/\r?\n/)) {
135 const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line)
136 if (kv && kv[1]) out[kv[1]] = (kv[2] ?? '').trim()
137 }
138 return out
139}
140
141const cells = (s: string) =>
142 s.split(',').map(x => x.trim()).filter(x => x !== '')
143
144export function parseProjectMap(text: string): ProjectMap {
145 const fm = parseFrontmatter(text)
146 const projects: Project[] = []
147 let inTable = false
148 for (const raw of text.split(/\r?\n/)) {
149 const line = raw.trim()
150 if (/^\|\s*Project\s*\|/i.test(line)) { inTable = true; continue }
151 if (!inTable) continue
152 if (!line.startsWith('|')) { if (projects.length > 0) break; continue }
153 if (/^\|[\s|:-]+\|$/.test(line)) continue
154 const c = line.slice(1, line.endsWith('|') ? -1 : undefined).split('|').map(x => x.trim())
155 const [name = '', memory = '', paths = '', hosts = '', keywords = ''] = c
156 if (c.length < 5 || name === '') continue
157 projects.push({ name, memory, paths: cells(paths), hosts: cells(hosts), keywords: cells(keywords) })
158 }
159 const every = Number.parseInt(fm.summarize_every ?? '', 10)
160 const flag = (v: string | undefined, d: boolean) => (v === undefined || v === '' ? d : !/^(false|no|off|0)$/i.test(v))
161 const text_ = (v: string | undefined, d: string) => (v ?? '').trim().replace(/^["']|["']$/g, '') || d
162 const list = (v: string | undefined, d: string[]) => {
163 const l = (v ?? '').split(',').map(x => x.trim().replace(/^["']|["']$/g, '')).filter(x => x !== '')
164 return l.length > 0 ? l : d
165 }
166 const checklist: string[] = []
167 const pushPolicy: PushRule[] = []
168 let inPush = false
169 for (const raw of text.split(/\r?\n/)) {
170 const line = raw.trim()
171 if (/^#{1,6}\s+push policy\s*$/i.test(line)) { inPush = true; continue }
172 if (inPush && /^#{1,6}\s/.test(line)) break
173 if (!inPush || !line.startsWith('|') || /^\|[\s|:-]+\|$/.test(line) || /^\|\s*repo\s*\|/i.test(line)) continue
174 const [repo = '', branch = '', policy = '', deploysTo = '', test = ''] = line.slice(1, line.endsWith('|') ? -1 : undefined).split('|').map(x => x.trim())
175 const kind = policy.toLowerCase()
176 if (repo !== '' && (kind === 'hold' || kind === 'announce' || kind === 'go')) {
177 pushPolicy.push({ repo, branch: branch || '*', policy: kind, deploysTo, test })
178 }
179 }
180 let inChecklist = false
181 for (const raw of text.split(/\r?\n/)) {
182 if (/^#{1,6}\s+new project checklist\s*$/i.test(raw.trim())) { inChecklist = true; continue }
183 if (inChecklist && /^#{1,6}\s/.test(raw.trim())) break
184 const b = inChecklist ? /^\s*[-*]\s+(.+)$/.exec(raw) : null
185 if (b?.[1]) checklist.push(b[1].trim())
186 }
187 return {
188 projects,
189 model: text_(fm.scribe_model, 'haiku'),
190 summarizeEvery: Number.isFinite(every) && every > 0 ? every : 3,
191 owner: text_(fm.owner, 'the user'),
192 timeZone: text_(fm.timezone, 'America/New_York'),
193 dailyNotePattern: text_(fm.daily_note_path, DEFAULT_DAILY_NOTE),
194 signalsDir: text_(fm.signals_dir, DEFAULT_SIGNALS_DIR),
195 reviewDir: text_(fm.review_dir, ''),
196 reviewerCommand: text_(fm.reviewer_command, '').replace(/^\//, ''),
197 memoryFolderName: text_(fm.memory_folder_name, ''),
198 skipDirs: list(fm.skip_dirs, ['.obsidian', '.git', '.trash', 'node_modules']),
199 dailyFrontmatter: list((fm.daily_note_frontmatter ?? '').replace(/;/g, ','), []),
200 // Every guard is off unless the vault owner turns it on: what is off limits is specific to one setup.
201 guardAutoMemory: flag(fm.guard_auto_memory ?? fm.guard_retired_memory, false),
202 guardPaths: list(fm.guard_paths, []),
203 guardN8nDelete: flag(fm.guard_n8n_delete, false),
204 checklist: checklist.length > 0 ? checklist : DEFAULT_CHECKLIST,
205 pushPolicy,
206 guardForcePush: flag(fm.guard_force_push, false),
207 }
208}
209
210/** The project whose paths hold the cwd (longest match). Vault root is `vault`; nothing matched is `unmapped`. */
211export function ownProject(cwd: string, projects: Project[], vaultRoot: string): string {
212 if (norm(cwd) === norm(vaultRoot)) return 'vault'
213 let best = ''
214 let bestLen = -1
215 for (const pr of projects) for (const p of pr.paths) {
216 if (isUnder(cwd, p) && norm(p).length > bestLen) { best = pr.name; bestLen = norm(p).length }
217 }
218 if (best) return best
219 return isUnder(cwd, vaultRoot) ? 'vault' : 'unmapped'
220}
221
222const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
223const wordRe = (w: string) => new RegExp(`(^|[^a-z0-9_.-])${escapeRe(w.toLowerCase())}($|[^a-z0-9_-])`, 'i')
224
225/** Every project a piece of text points at, with the evidence for each tag. */
226export function tagText(text: string, projects: Project[]): Map<string, string[]> {
227 const out = new Map<string, string[]>()
228 const t = text.replace(/\\/g, '/').toLowerCase()
229 const add = (name: string, ev: string) => {
230 const list = out.get(name) ?? []
231 if (!list.includes(ev)) list.push(ev)
232 out.set(name, list)
233 }
234 // Paths: a path that is a prefix of a longer matched path loses (`C:/code/site/blog` beats `C:/code/site`).
235 const hits: { name: string; path: string }[] = []
236 for (const pr of projects) for (const p of pr.paths) {
237 const np = norm(p)
238 if (t.includes(np + '/') || t.endsWith(np) || t.includes(np + ' ') || t.includes(np + '"') || t.includes(np + "'")) {
239 hits.push({ name: pr.name, path: p })
240 }
241 }
242 const occ = (s: string) => t.split(s).length - 1
243 for (const h of hits) {
244 // Shadowed only when every occurrence of the short path is inside a longer, other project's path.
245 const longer = hits.filter(o => o.name !== h.name && norm(o.path).startsWith(norm(h.path) + '/'))
246 const inLonger = longer.reduce((n, o) => n + occ(norm(o.path)), 0)
247 if (longer.length === 0 || occ(norm(h.path)) > inLonger) add(h.name, `path ${h.path}`)
248 }
249 for (const pr of projects) {
250 for (const h of pr.hosts) if (wordRe(h).test(t)) add(pr.name, `host ${h}`)
251 for (const k of pr.keywords) if (wordRe(k).test(t)) add(pr.name, `keyword "${k}"`)
252 }
253 return out
254}
255
256// ---------- guardrails (each one off unless the config note turns it on) ----------
257
258/** How Claude Code names a folder under ~/.claude/projects/: `D:\My Vault` → `d--my-vault`. */
259export const projectDirName = (dir: string) => dir.replace(/[\\/:\s]/g, '-').replace(/-+$/, '').toLowerCase()
260
261export type GuardOptions = { vaultRoot: string; autoMemory: boolean; paths: string[]; n8n: boolean }
262
263/** A write into Claude Code's per-project auto-memory, other than the vault's own folder. */
264export function isAutoMemoryPath(path: string, vaultRoot: string): boolean {
265 const m = /\/\.claude\/projects\/([^/]+)\/memory(\/|$)/.exec(norm(path))
266 return m !== null && m[1] !== projectDirName(vaultRoot)
267}
268
269const guardedFragment = (text: string, paths: string[]) =>
270 paths.find(f => f.trim() !== '' && text.includes(f.replace(/\\/g, '/').toLowerCase()))
271
272const WRITE_VERB =
273 /(\b(set-content|add-content|out-file|new-item|copy-item|move-item|remove-item|rename-item|rm|mv|cp|tee|del|erase|mkdir|touch|rmdir)\b|\bsed\s+-i)/i
274/** Redirect targets (`> file`, `>> "file"`); `2>&1` and fd duplications are not targets. */
275const REDIRECT = /(?:^|[^0-9&>])>{1,2}\s*(?!&)(["']?)([^"'\s|;&]+(?:\s[^"'\s|;&>]+)*?)\1(?=\s|$|[|;&])/g
276
277const AUTO_MEMORY = /\/\.claude\/projects\/([^/\s'"]+)\/memory(\/|\b)/g
278
279const AUTO_MEMORY_WHY = 'that writes into Claude Code\'s per-project auto-memory (~/.claude/projects/<x>/memory/). This vault keeps project memory in the vault itself; write it there.'
280const guardPathWhy = (f: string) => `that writes under "${f}", which this vault's config marks off limits (guard_paths).`
281export const N8N_WHY = 'n8n workflows are archive only here, never deleted. Archive the workflow instead (POST /api/v1/workflows/{id}/archive).'
282
283/** Why a shell command must be refused, or null. */
284export function commandDenyReason(cmd: string, g: GuardOptions): string | null {
285 const c = cmd.replace(/\\/g, '/')
286 if (g.n8n && /\/workflows?\b/i.test(c) &&
287 /(-X\s*['"]?DELETE|--request\s+['"]?DELETE|-Method\s+['"]?Delete|method\s*[:=]\s*['"]DELETE)/i.test(c)) {
288 return N8N_WHY
289 }
290 // A write verb anywhere puts the whole command in scope; a bare redirect only its target.
291 const scope = WRITE_VERB.test(c) ? c.toLowerCase() : [...c.matchAll(REDIRECT)].map(m => m[2] ?? '').join(' ').toLowerCase()
292 if (scope === '') return null
293 if (g.autoMemory) {
294 const own = projectDirName(g.vaultRoot)
295 if ([...scope.matchAll(AUTO_MEMORY)].some(m => m[1] !== own)) return AUTO_MEMORY_WHY
296 }
297 const f = guardedFragment(scope, g.paths)
298 return f ? guardPathWhy(f) : null
299}
300
301/** Why an Edit/Write to this path must be refused, or null. */
302export function pathDenyReason(path: string, g: GuardOptions): string | null {
303 if (g.autoMemory && isAutoMemoryPath(path, g.vaultRoot)) return AUTO_MEMORY_WHY
304 const f = guardedFragment(norm(path), g.paths)
305 return f ? guardPathWhy(f) : null
306}
307
308export const isN8nDeleteTool = (tool: string) => /n8n/i.test(tool) && /delete/i.test(tool)
309
310// ---------- push guard ----------
311
312export type GitPush = { dir: string | null; remote: string; branch: string | null; isForce: boolean }
313
314const unquote = (t: string) => t.replace(/^["']|["']$/g, '')
315const ARG = /"[^"]*"|'[^']*'|[^\s;&|]+/g
316
317/**
318 * The `git push` in a shell command, or null: its folder (`git -C x`, or a `cd x` / `Set-Location x` before it),
319 * remote, branch (from a refspec) and whether it forces.
320 */
321export function parseGitPush(cmd: string): GitPush | null {
322 const c = cmd.replace(/\r?\n/g, ' ')
323 const m = /(?:^|[;&|]\s*|\bthen\s+)\s*git((?:\s+-C\s+(?:"[^"]*"|'[^']*'|\S+)|\s+-c\s+\S+)*)\s+push\b([^;&|]*)/i.exec(c)
324 if (!m) return null
325 const before = c.slice(0, m.index)
326 const cds = [...before.matchAll(/\b(?:cd|set-location|push-location|sl|pushd)\s+(?:-path\s+|-literalpath\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/gi)]
327 const gitC = /-C\s+("[^"]*"|'[^']*'|\S+)/.exec(m[1] ?? '')
328 const dir = gitC?.[1] ? unquote(gitC[1]) : cds.length > 0 ? unquote(cds[cds.length - 1]?.[1] ?? '') : null
329 const args = [...(m[2] ?? '').matchAll(ARG)].map(a => unquote(a[0]))
330 const positional = args.filter(a => !a.startsWith('-'))
331 const isForce = args.some(a => /^(-f|--force|--force-with-lease(=.*)?|--force-if-includes|--mirror|--delete|-d)$/.test(a)) ||
332 positional.slice(1).some(r => r.startsWith('+') || r.startsWith(':'))
333 const ref = positional[1] ? positional[1].replace(/^\+/, '') : null
334 const branch = ref === null ? null : (ref.includes(':') ? ref.split(':').pop() ?? '' : ref).replace(/^refs\/heads\//, '') || null
335 return { dir: dir || null, remote: positional[0] ?? 'origin', branch: branch === 'HEAD' ? null : branch, isForce }
336}
337
338/** `https://github.com/me/site.git` and `git@github.com:me/site.git` both become `github.com/me/site`. */
339export const normRemote = (url: string) =>
340 url.trim().toLowerCase().replace(/^git@([^:]+):/, '$1/').replace(/^[a-z+]+:\/\/([^@/]*@)?/, '').replace(/\.git$/, '').replace(/\/+$/, '')
341
342/** The most specific rule for this repo and branch: an exact branch beats `*`. A repo is a remote suffix or a path prefix. */
343export function matchPushRule(rules: PushRule[], remoteUrl: string, dir: string, branch: string): PushRule | null {
344 const r = normRemote(remoteUrl)
345 const isRepo = (rule: PushRule) => {
346 const want = rule.repo.toLowerCase().replace(/\.git$/, '').replace(/\/+$/, '')
347 if (/^[a-z]:|^\//i.test(rule.repo)) return isUnder(dir, rule.repo)
348 return r === want || r.endsWith('/' + want)
349 }
350 const hits = rules.filter(x => isRepo(x) && (x.branch === '*' || x.branch.toLowerCase() === branch.toLowerCase()))
351 return hits.find(x => x.branch !== '*') ?? hits[0] ?? null
352}
353
354const APPROVE_PUSH =
355 /\b(ship\s+it|push\s+it|push\s+(?:it\s+)?(?:now|away|live)|deploy\s+it|go\s+ahead\s+and\s+(?:push|deploy|ship)|(?:yes|ok|okay|yep)[,!.\s]+(?:push|deploy|ship)|approved?\s+(?:to\s+)?(?:push|deploy)|send\s+it)\b/i
356
357/** Whether the owner's own prompt approves a held push. A force push also needs the word "force". */
358export const approvesPush = (prompt: string, isForce: boolean) =>
359 APPROVE_PUSH.test(prompt) && (!isForce || /\bforce\b/i.test(prompt))
360
361// ---------- prompts ----------
362
363const TRIVIAL =
364 /^(y|yes|yep|yeah|no|nope|ok|okay|k|go|go on|go ahead|do it|continue|proceed|next|done|thanks|thank you|ty|great|nice|cool|lgtm|ship it|\d{1,3}|[a-e])[\s.!)]*$/i
365export const isTrivial = (text: string) => TRIVIAL.test(text.trim())
366
367const SECRET = [
368 /\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs]|glpat)[-_][A-Za-z0-9_-]{8,}/gi,
369 /\bAKIA[0-9A-Z]{16}\b/g,
370 /\bAIza[0-9A-Za-z_-]{20,}/g,
371 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}(\.[A-Za-z0-9_-]+)?/g,
372 /discord(app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/gi,
373 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
374]
375/** `password: hunter2` keeps its label: `password: [redacted]`. */
376const LABELLED = /\b(password|passwd|passphrase|secret|token|api[ _-]?key)(\s*(?:\bis\b|:|=)\s*)\S{4,}/gi
377
378export function redact(text: string): string {
379 let t = text
380 for (const re of SECRET) t = t.replace(re, '[redacted]')
381 return t.replace(LABELLED, '$1$2[redacted]')
382}
383
384export const clip = (s: string, n: number) => (s.length <= n ? s : s.slice(0, n - 1) + '…')
385
386// ---------- time ----------
387
388export type Stamp = { date: string; time: string; monthFolder: string; dayTitle: string }
389
390const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
391const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
392
393/** Local date/time in America/New_York (the vault's rule), whatever zone the host runs in. */
394export function stampOf(epochMs: number, timeZone = 'America/New_York'): Stamp {
395 let y: number, mo: number, d: number, hh: number, mm: number, wd: number
396 try {
397 const parts = new Intl.DateTimeFormat('en-US', {
398 timeZone, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', hourCycle: 'h23', weekday: 'short',
399 }).formatToParts(new Date(epochMs))
400 const get = (t: string) => parts.find(p => p.type === t)?.value ?? ''
401 y = Number(get('year')); mo = Number(get('month')); d = Number(get('day'))
402 hh = Number(get('hour')) % 24; mm = Number(get('minute'))
403 wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(get('weekday'))
404 if (!y || !mo || !d || wd < 0) throw new Error('parts')
405 } catch {
406 const dt = new Date(epochMs)
407 y = dt.getFullYear(); mo = dt.getMonth() + 1; d = dt.getDate(); hh = dt.getHours(); mm = dt.getMinutes(); wd = dt.getDay()
408 }
409 const p2 = (n: number) => String(n).padStart(2, '0')
410 return {
411 date: `${y}-${p2(mo)}-${p2(d)}`,
412 time: `${p2(hh)}:${p2(mm)}`,
413 monthFolder: `${p2(mo)} - ${MONTHS[mo - 1]} ${y}`,
414 dayTitle: `${DAYS[wd]}, ${MONTHS[mo - 1]} ${d}, ${y}`,
415 }
416}
417
418// ---------- daily note ----------
419
420export function dailyNotePath(vaultRoot: string, s: Stamp, pattern = DEFAULT_DAILY_NOTE): string {
421 const [y = '', mo = '', d = ''] = s.date.split('-')
422 const rel = pattern
423 .replace(/\{YYYY\}/g, y).replace(/\{MM\}/g, mo).replace(/\{DD\}/g, d)
424 .replace(/\{Month\}/g, MONTHS[Number(mo) - 1] ?? mo)
425 return `${vaultRoot.replace(/[\\/]+$/, '')}/${rel.replace(/^[\\/]+/, '')}`
426}
427
428export function newDailyNote(s: Stamp, frontmatter: string[] = []): string {
429 return [
430 '---', ...frontmatter, `created: ${s.date}`, '---', '',
431 `# ${s.dayTitle}`, '', '## Index', '', '---', '',
432 ].join('\n')
433}
434
435export const blockStart = (id: string) => `<!-- vault-scribe:session:${id} -->`
436export const blockEnd = (id: string) => `<!-- /vault-scribe:session:${id} -->`
437export const indexTag = (id: string) => `<!-- vs:${id.slice(0, 8)} -->`
438
439export const countSessions = (note: string) => (note.match(/^## Session \d+/gm) ?? []).length
440
441const bullets = (items: string[]) => (items.length === 0 ? ['-'] : items.map(i => `- ${i}`))
442
443export function renderBlock(a: Agg, vaultRoot: string, signalsLink: string | null, nowTime: string): string {
444 const notes = a.notes.map(n => noteLink(n, vaultRoot)).filter((x): x is string => x !== null)
445 const sig = a.signalIds.length === 0
446 ? ['- none queued']
447 : [`- ${a.signalIds.length} queued for review${signalsLink ? ` → ${signalsLink}` : ''}: ${a.signalIds.map(i => `\`${i}\``).join(', ')}`]
448 return [
449 blockStart(a.sessionId),
450 `## Session ${a.sessionNo} (${a.startedAt}): ${a.topic || 'session'}`,
451 `*auto-written by vault-scribe · session \`${a.sessionId.slice(0, 8)}\` · own project: ${a.own} · cwd \`${fwd(a.cwd)}\` · updated ${nowTime}*`,
452 '',
453 '### What Got Done', ...bullets([...a.done, ...(a.digest.length > 0 ? [`_${a.digest.length} recent turn(s) captured, summary on the next pass_`] : [])]), '',
454 '### Decisions Made', ...bullets(a.decisions), '',
455 '### Discussions & Open Questions', ...bullets(a.discussions ?? []), '',
456 '### Research & Lookups', ...bullets(a.research ?? []), '',
457 "### What's Still In Progress", ...bullets(a.inProgress), '',
458 '### Notes Touched', ...bullets(notes), '',
459 '### Signals for Review', ...sig,
460 blockEnd(a.sessionId),
461 ].join('\n')
462}
463
464/** Puts this session's block and Index bullet into the note, leaving every other byte alone. */
465export function upsertSession(note: string, sessionId: string, block: string, indexLine: string): string {
466 let text = note.replace(/\r\n/g, '\n')
467 const tag = indexTag(sessionId)
468 const bullet = `- ${indexLine} ${tag}`
469
470 // Index bullet
471 const lines = text.split('\n')
472 const at = lines.findIndex(l => l.includes(tag))
473 if (at >= 0) {
474 lines[at] = bullet
475 } else {
476 let idx = lines.findIndex(l => /^## Index\s*$/.test(l))
477 if (idx < 0) {
478 const h1 = lines.findIndex(l => /^# /.test(l))
479 lines.splice(h1 + 1, 0, '', '## Index', '')
480 idx = h1 + 2
481 }
482 // Drop the template's placeholder bullet (and the double blank it leaves).
483 const ph = lines.findIndex((l, i) => i > idx && /^- \*\*Session topic\*\* (—|:|-)/.test(l))
484 if (ph >= 0) {
485 lines.splice(ph, 1)
486 if ((lines[ph - 1] ?? '').trim() === '' && (lines[ph] ?? '').trim() === '') lines.splice(ph, 1)
487 }
488 let end = idx + 1
489 while (end < lines.length && !/^#{1,2} /.test(lines[end] ?? '') && (lines[end] ?? '').trim() !== '---') end++
490 let lastBullet = -1
491 for (let i = idx + 1; i < end; i++) if (/^\s*- /.test(lines[i] ?? '')) lastBullet = i
492 if (lastBullet >= 0) {
493 lines.splice(lastBullet + 1, 0, bullet)
494 } else {
495 let ins = end
496 while (ins > idx + 1 && (lines[ins - 1] ?? '').trim() === '') ins--
497 const add = [(lines[ins - 1] ?? '').trim() === '' ? null : '', bullet].filter((x): x is string => x !== null)
498 if ((lines[ins] ?? '').trim() !== '') add.push('')
499 lines.splice(ins, 0, ...add)
500 }
501 }
502 text = lines.join('\n')
503
504 // Session block
505 const s = text.indexOf(blockStart(sessionId))
506 const e = text.indexOf(blockEnd(sessionId))
507 if (s >= 0 && e > s) {
508 text = text.slice(0, s) + block + text.slice(e + blockEnd(sessionId).length)
509 } else {
510 text = text.replace(/\s*$/, '') + '\n\n' + block + '\n\n---\n'
511 }
512 return text
513}
514
515// ---------- signals ----------
516
517export const signalsPath = (vaultRoot: string, date: string, dir = DEFAULT_SIGNALS_DIR) =>
518 `${vaultRoot.replace(/[\\/]+$/, '')}/${dir.replace(/^[\\/]+|[\\/]+$/g, '')}/${date}.md`
519
520export function newSignalsFile(date: string): string {
521 return [
522 '---', 'status: active', 'project: meta', 'type: log', `created: ${date}`, '---', '',
523 `# vault-scribe signals: ${date}`, '',
524 'Cross-project changes, decisions, corrections and discussions captured live by the vault-scribe mod, queued for review.',
525 'The reviewer flips each heading from `⏳ pending` to `✅ processed <date>: ...`, `🔁 routed <date>: ...` or `✖ dropped <date>: ...` on the same line, so the edit is line-neutral.', '',
526 ].join('\n')
527}
528
529export function renderSignal(s: Signal): string {
530 return [
531 `### ${s.id} · ${s.status}`,
532 `- kind: ${s.kind}`,
533 `- what: ${s.what}`,
534 `- why: ${s.why || '-'}`,
535 `- from: ${s.from}`,
536 `- touches: ${s.touches.join('; ')}`,
537 '',
538 ].join('\n')
539}
540
541export function parseSignals(text: string): Signal[] {
542 const out: Signal[] = []
543 const blocks = text.replace(/\r\n/g, '\n').split(/^### /m).slice(1)
544 for (const b of blocks) {
545 const [head = '', ...rest] = b.split('\n')
546 const hm = /^(SIG-\d{4}-\d{2}-\d{2}-([0-9a-z]{8})-\d+)\s*·\s*(.*)$/i.exec(head.trim())
547 if (!hm || !hm[1] || !hm[2]) continue
548 const field = (k: string) => rest.find(l => l.startsWith(`- ${k}:`))?.slice(k.length + 3).trim() ?? ''
549 const touches = field('touches').split(';').map(x => x.trim()).filter(Boolean)
550 out.push({
551 id: hm[1], sessionId: hm[2], status: (hm[3] ?? '').trim(),
552 kind: (field('kind') || 'change') as SignalKind,
553 what: field('what'), why: field('why'), from: field('from'), touches,
554 })
555 }
556 return out
557}
558
559/** The project name of a `touches` entry: `Website (path C:/code/site)` → `Website`. */
560export const touchName = (t: string) => t.replace(/\s*\(.*$/, '').trim()
561
562export const isPending = (s: Signal) => s.status.startsWith('⏳')
563
564// ---------- summary ----------
565
566export function digestText(turns: TurnRec[], maxChars = 14000): string {
567 const parts: string[] = []
568 turns.forEach((t, i) => {
569 parts.push(`## Turn ${i + 1}`)
570 if (t.prompt) parts.push(`USER: ${clip(t.prompt, 1500)}`)
571 for (const a of t.actions.slice(0, 40)) parts.push(`ACTION: ${clip(a, a.startsWith('research report') ? 1200 : 300)}`)
572 if (t.actions.length > 40) parts.push(`ACTION: …and ${t.actions.length - 40} more`)
573 if (t.answer) parts.push(`ASSISTANT: ${clip(t.answer, 1500)}`)
574 })
575 const all = parts.join('\n')
576 return all.length <= maxChars ? all : all.slice(all.length - maxChars)
577}
578
579export function summaryPrompt(digest: string, projects: Project[], own: string, owner = 'the user'): { system: string; prompt: string } {
580 const list = projects.map(p => `- ${p.name}: memory ${p.memory}; paths ${p.paths.join(', ')}; hosts ${p.hosts.join(', ') || 'none'}; keywords ${p.keywords.join(', ')}`).join('\n')
581 const system = [
582 `You keep the work log for ${owner}'s Claude Code sessions, part of their second brain. You read a digest of recent turns and answer with ONE JSON object and nothing else.`,
583 `In the digest, USER lines are ${owner}'s own words. ACTION lines are what the assistant did: edits with before/after, writes, shell commands, reads (lines starting "read"), web searches/fetches/pages opened, and research-agent reports. ASSISTANT lines are its answers.`,
584 'Write past tense, terse, factual. Never invent: only what the digest shows. Never include secrets, passwords or tokens.',
585 'Style: never use em-dashes (use a colon, comma or period instead) and write every file path with forward slashes.',
586 'Fields:',
587 '- topic: 2-6 words naming what this session is about',
588 '- index_line: one line, "**Topic**: what happened/was decided" (past tense, under 160 chars)',
589 '- done: up to 8 bullets of concrete changes completed in THESE turns. Each bullet names WHAT changed (file or system) and WHY, as "what (why: reason)". The why comes from the USER\'s words or the reason the assistant gave.',
590 `- decisions: up to 4 bullets, ONLY decisions or rulings ${owner} made. Each MUST end with the exact words from a USER line that state or accept it, as (${owner}: "exact words"). A plan the assistant proposed that the USER has not clearly accepted is NOT a decision: put it under discussions.`,
591 '- discussions: up to 5 bullets that could affect LATER choices, so they are not lost: options weighed, proposals still waiting on the USER, ideas rejected and why, trade-offs, contradictions found.',
592 '- research: up to 6 bullets for reads and lookups that informed a choice: what was read or looked up (files, a whole project\'s notes, web searches, pages, agent reports), the key finding, and what it informed. Keep URLs as sources, e.g. "searched X: finding (source: https://...)". Skip trivial reads that informed nothing.',
593 '- in_progress: up to 4 bullets still open at the end of these turns',
594 '- signals: things another project\'s notes would need to know. Each {"kind":"change|decision|correction|fact|discussion","what":"...","why":"...","projects":["<exact project names from the list>"],"quote":"<for decision/correction: the exact USER words, else empty>"}. Use change/fact/discussion when it affects a project OTHER than the session\'s own, even indirectly (shared API, host, database, webhook, rule, convention, or research one project did that another project needs). Use decision/correction only with a USER quote. Empty list when nothing qualifies.',
595 `The session's own project is: ${own}.`,
596 'Projects:',
597 list,
598 ].join('\n')
599 const prompt = `<digest>\n${digest}\n</digest>\nAnswer with the JSON object only.`
600 return { system, prompt }
601}
602
603/** House style: no em-dashes, forward-slash paths. */
604const tidy = (s: string) => s.replace(/\s*\u2014\s*/g, ': ').replace(/\\/g, '/')
605const str = (v: unknown, n: number) => (typeof v === 'string' ? clip(tidy(redact(v.trim())), n) : '')
606const strs = (v: unknown, max: number, n: number) =>
607 Array.isArray(v) ? v.map(x => str(x, n)).filter(x => x !== '').slice(0, max) : []
608
609export function parseSummary(text: string, projectNames: string[]): Summary | null {
610 const json = /\{[\s\S]*\}/.exec(text)?.[0]
611 if (!json) return null
612 let o: Record<string, unknown>
613 try { o = JSON.parse(json) as Record<string, unknown> } catch { return null }
614 const names = new Map(projectNames.map(n => [n.toLowerCase(), n]))
615 const kinds: SignalKind[] = ['change', 'decision', 'correction', 'fact', 'discussion']
616 const signals = Array.isArray(o.signals) ? o.signals.flatMap(s => {
617 if (typeof s !== 'object' || s === null) return []
618 const r = s as Record<string, unknown>
619 const kind = kinds.includes(r.kind as SignalKind) ? (r.kind as SignalKind) : 'change'
620 const what = str(r.what, 300)
621 if (what === '') return []
622 const projects = Array.isArray(r.projects)
623 ? [...new Set(r.projects.map(p => names.get(String(p).toLowerCase())).filter((x): x is string => x !== undefined))]
624 : []
625 return [{ kind, what, why: str(r.why, 300), projects, quote: str(r.quote, 300) }]
626 }).slice(0, 8) : []
627 return {
628 topic: str(o.topic, 60),
629 indexLine: str(o.index_line, 200),
630 done: strs(o.done, 8, 300),
631 decisions: strs(o.decisions, 4, 300),
632 inProgress: strs(o.in_progress, 4, 240),
633 discussions: strs(o.discussions, 5, 300),
634 research: strs(o.research, 6, 360),
635 signals,
636 }
637}
638
639const dedupe = (xs: string[], max: number) => {
640 const seen = new Set<string>()
641 const out: string[] = []
642 for (const x of xs) { const k = x.toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(x) } }
643 return out.slice(-max)
644}
645
646const squash = (t: string) => t.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
647
648/** True when one of the quoted strings in `text` really appears in what the owner typed. */
649export function hasVerifiedQuote(text: string, ownerWords: string[]): boolean {
650 const said = squash(ownerWords.join(' \n '))
651 const quotes = [...text.matchAll(/["\u201c]([^"\u201d]{4,})["\u201d]/g)].map(m => squash(m[1] ?? ''))
652 return quotes.some(q => q.length >= 4 && said.includes(q))
653}
654
655/**
656 * A decision only counts when it quotes the owner. Unverifiable decisions become discussions, and an
657 * unverifiable decision/correction signal becomes a discussion signal (2026-10-08: proposals were logged as rulings).
658 */
659export function checkQuotes(s: Summary, ownerWords: string[]): Summary {
660 const kept: string[] = []
661 const moved: string[] = []
662 for (const d of s.decisions) (hasVerifiedQuote(d, ownerWords) ? kept : moved).push(d)
663 return {
664 ...s,
665 decisions: kept,
666 discussions: [...s.discussions, ...moved.map(m => `proposed, not confirmed: ${m}`)],
667 signals: s.signals.map(x =>
668 (x.kind === 'decision' || x.kind === 'correction') && !hasVerifiedQuote(`"${x.quote}"`, ownerWords)
669 ? { ...x, kind: 'discussion' as const }
670 : x),
671 }
672}
673
674export function mergeSummary(a: Agg, s: Summary): Agg {
675 return {
676 ...a,
677 topic: a.topic || s.topic,
678 indexLine: s.indexLine || a.indexLine,
679 done: dedupe([...a.done, ...s.done], 20),
680 decisions: dedupe([...a.decisions, ...s.decisions], 12),
681 discussions: dedupe([...(a.discussions ?? []), ...s.discussions], 15),
682 research: dedupe([...(a.research ?? []), ...s.research], 20),
683 inProgress: s.inProgress.length > 0 ? s.inProgress : a.inProgress,
684 }
685}
686
687/** Signals worth queueing for review: anything touching another project, plus every decision/correction. */
688export function shouldQueue(kind: SignalKind, projects: string[], own: string): boolean {
689 if (kind === 'decision' || kind === 'correction') return true
690 return projects.some(p => p !== own)
691}
692
693// ---------- citations ----------
694
695export type Citation = { file: string; line: number; target: string; cited: number }
696
697const CITE = /([^\s`'"()[\]|<>*]+\.md):(\d+)(?:-(\d+))?/g
698
699/** Every `name.md:NNN` citation in a note, keyed by the cited file's lowercase basename. */
700export function extractCitations(file: string, text: string): Citation[] {
701 const out: Citation[] = []
702 text.split(/\r?\n/).forEach((l, i) => {
703 for (const m of l.matchAll(CITE)) {
704 const name = m[1] ?? ''
705 const base = name.replace(/\\/g, '/').split('/').pop() ?? name
706 out.push({ file, line: i + 1, target: base.toLowerCase(), cited: Number(m[2]) })
707 }
708 })
709 return out
710}
711
712export function firstDiffLine(before: string, after: string): number {
713 const a = before.split(/\r?\n/)
714 const b = after.split(/\r?\n/)
715 const n = Math.min(a.length, b.length)
716 for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i + 1
717 return n + 1
718}
719
720export const lineCount = (t: string) => t.split(/\r?\n/).length
721
722export function staleCitations(index: Citation[], editedFile: string, fromLine: number, delta: number): Citation[] {
723 const base = (editedFile.replace(/\\/g, '/').split('/').pop() ?? '').toLowerCase()
724 if (delta === 0 || base === '') return []
725 return index.filter(c => c.target === base && c.cited >= fromLine && norm(c.file) !== norm(editedFile))
726}
727types/index.d.ts 9 lines1export type HeadsUpItem = { id: string; what: string; from: string; kind: string }
2export type HeadsUp = { project: string; items: HeadsUpItem[]; total: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'vault-scribe': { headsUp: HeadsUp | null; isDismissed: boolean }
7 }
8}
9