Unofficial fan-made mod: before Claude edits a file, a ghost Clawd shows what happened to that file before (your past decisions, git history, your own past…

Unofficial fan-made mods. Not affiliated with or endorsed by Anthropic. Clawd is Anthropic's character.
Before Claude edits a file, a small ghost Clawd floats into the band above the prompt and tells you what happened to that file before: a decision you logged, a commit that swung it, or something you said about it in an earlier session. The point is to stop a settled choice from quietly swinging back.
<!-- TODO screenshot: docs/screenshot-ghost-terminal.png and docs/screenshot-ghost-desktop.png at the repository root, then link them here. -->
Each haunting is three lines and two buttons. For example, on a fictional bakery site:
Boooo. I'm hero 64px. You buried me on 12 May. Your last words: "the hero feels too loud" Bring me back and you undo: a quieter hero: hero 56, titles 44. [1 Let me rest] [2 I'm back on purpose]
It only watches. It never blocks, denies or rewrites an edit.
/ghost lists the ghosts for every file this session has edited. It runs right away, even mid-turn.1 and 2 press the band's buttons when the prompt is empty.Everything stays on your machine. The mod makes no network calls.
design/decisions.md. If that isn't there, the mod tries DECISIONS.md, docs/decisions.md and decisions.md. It reads the file from your remote's default branch (origin/HEAD, then origin/main, then origin/master) when there is one, and from the working copy otherwise. One decision per line: - 2026-05-12 · type · a quieter hero: hero 56, titles 44 · "the hero feels too loud" · replaces 64/52
The fields are date, area, decision, why (put your own words in quotes) and what it replaced. You can separate them with · or |. Only the date, area and decision are required.
git log --follow subjects that read as swings (revert, back to, undo, smaller, bigger, tone down…), and each CSS custom property's value history from git log -L.~/.claude/projects/<project>*/*.jsonl). The mod reads only the messages you typed, with no tool output. It removes the surname from git config user.name and shortens absolute paths to file names.Without a decisions file, the ghost still works from git history and your past prompts alone.
The index is built in the background at session start and at most every 10 minutes after a turn, by scripts/build-index.mjs (Node 22.6 or newer, found as node, /opt/homebrew/bin/node or /usr/local/bin/node). It is written to .data/ inside the plugin's own folder.
| Option | Default | What it does |
|---|---|---|
decisionsFile | design/decisions.md | Your decisions log, relative to the repository root. Change it in /config. |
From claude plugin validate:
hooks: session.start, turn.complete, tool.call, prompt.submit, ui.render{component=AbovePrompt}, command.run{command=ghost}
calls: $.clock.after (via showAfter), $.clock.every (via ensureFloat), $.clock.now, $.command.register, $.fs.read, $.fs.stat (via loadIndex, refreshIfStale), $.process.spawn (via buildIndex), $.prompt.submit (via overrule), $.session.root, $.state.get, $.state.set, $.store.get (via rotationOf), $.store.set (via remember), $.ui.blit (via ensureFloat), $.ui.invalidate (via ensureFloat), $.ui.log, $.ui.resolve
$.process.spawn starts the local index builder, nothing else.$.prompt.submit sends one prompt, and only when you press 2 on a swing while Claude is idle.claude plugin marketplace add ozansozuozgit/clawd-mods
claude plugin install clawd-ghost@clawd-mods
Or, in a session: /plugin install clawd-ghost --marketplace ozansozuozgit/clawd-mods
Run /plugin and disable clawd-ghost, or claude plugin uninstall clawd-ghost@clawd-mods.
claude plugin validate --strict .
claude plugin test .
# what an edit would raise, in a real project:
node --experimental-strip-types scripts/build-index.mjs <project> --lookup app/base.css \
--before '--t-display: clamp(40px, 5vw, 56px);' --after '--t-display: clamp(40px, 5vw, 64px);'
# load it for one session:
claude --plugin-dir .
Files
hooks/register.tsx: the hooks.hooks/ghost.ts: the index shape, ranking, the swing maths and the redactor. The index builder imports it too.hooks/copy.ts: the lines and the card.hooks/sprite.ts: the character (pixels, colours, names).hooks/desktop.ts: the Desktop SVG.scripts/build-index.mjs: the index builder.MIT. See LICENSE.
hooks/register.tsx 409 lines1// clawd-ghost: the ghost of sessions past. Unofficial fan-made; not affiliated with Anthropic.
2//
3// Before Claude edits a file, a ghost Clawd above the prompt says what happened
4// to that file before: the owner's dated words, the decision, what an edit back
5// would undo. It gets more solid (and grumpier) each time the same value swings
6// back. Claude gets the same facts as a note after the edit. Observe only: the
7// edit is never denied or rewritten, and the mod never writes the decisions log.
8//
9// The hook only looks things up. scripts/build-index.mjs builds the index in
10// the background at session start and after turns (at most every 10 minutes),
11// from the project's decisions log (the `decisionsFile` option), git history
12// and the owner's own past prompts, all local.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17import type { GhostCard } from '../types'
18import { compose, OVERRULE, overruleNoteOf, pick, REST } from './copy.ts'
19import type { Picked } from './copy.ts'
20import { dayOf, EDIT_TOOLS, editTextOf, lineOf, noteOf, rank, relativeTo, swingOf, targetOf } from './ghost.ts'
21import type { GhostIndex, Swing } from './ghost.ts'
22import { altOf, COMPACT_COLUMNS, desktopArt } from './desktop.ts'
23import { cellsOf, COLUMNS, DESKTOP_TEXT, ROWS, STYLE } from './sprite.ts'
24
25const current = atom({ plugin: 'clawd-ghost', key: 'current' } as const, null)
26const shown = atom({ plugin: 'clawd-ghost', key: 'shown' } as const, [])
27const touched = atom({ plugin: 'clawd-ghost', key: 'touched' } as const, [])
28const pendingNote = atom({ plugin: 'clawd-ghost', key: 'pendingNote' } as const, null)
29
30const REFRESH_MS = 10 * 60 * 1000
31const FRESH_AT_START_MS = 2 * 60 * 1000
32const AFTER_PRESS_MS = 4000
33const FLOAT_MS = 700
34const NODES = ['node', '/opt/homebrew/bin/node', '/usr/local/bin/node']
35/** The last template each copy pool used, so no pool repeats itself back to back (kept across sessions). */
36const ROTATION_KEY = 'rotation'
37
38type Timer = ReturnType<EngineInterface['clock']['every']>
39type Float = { requestId: string; cardId: string; level: GhostCard['level']; frame: 0 | 1; timer?: Timer; isInvalidating: boolean; quietTicks: number }
40
41// Module variables reset on a hot reload; that only costs a re-read or a frame.
42let cached: { path: string; mtimeMs: number; index: GhostIndex } | undefined
43let isBuilding = false
44let float: Float | undefined
45let isWorking = false
46/** The `decisionsFile` option: the decisions log's path in the project ('' lets the index builder look in the usual spots). */
47let decisionsOption = ''
48
49const slugOf = (root: string): string => root.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
50
51function indexPathOf($: EngineInterface, root: string): string {
52 return `${$.plugin.root}/.data/${slugOf(root)}.json`
53}
54
55async function loadIndex($: EngineInterface, path: string): Promise<GhostIndex | undefined> {
56 let mtimeMs: number
57 try {
58 mtimeMs = (await $.fs.stat(path)).mtimeMs
59 } catch {
60 return undefined
61 }
62 if (cached !== undefined && cached.path === path && cached.mtimeMs === mtimeMs) return cached.index
63 try {
64 const index = JSON.parse(await $.fs.read(path)) as GhostIndex
65 if (index.v !== 1 || typeof index.files !== 'object') return undefined
66 cached = { path, mtimeMs, index }
67 return index
68 } catch {
69 return undefined
70 }
71}
72
73/** Builds the index in the background: the hook that starts it does not wait. */
74function buildIndex($: EngineInterface, root: string, out: string): void {
75 if (isBuilding) return
76 isBuilding = true
77 const script = `${$.plugin.root}/scripts/build-index.mjs`
78 void (async () => {
79 try {
80 for (const node of NODES) {
81 for (const flags of [['--experimental-strip-types', '--no-warnings'], []]) {
82 let stderr = ''
83 try {
84 const extra = decisionsOption === '' ? [] : ['--decisions', decisionsOption]
85 const run = $.process.spawn({ argv: [node, ...flags, script, root, out, ...extra], cwd: root })
86 for await (const chunk of run) if (chunk.stream === 'stderr') stderr += chunk.text
87 const ended = await run.result
88 if (ended.code === 0) {
89 $.ui.log(`clawd-ghost: index built for ${root}`, { to: 'debug' })
90 return
91 }
92 $.ui.log(`clawd-ghost: ${node} ${flags.join(' ')} exited ${String(ended.code)}: ${stderr.slice(0, 300)}`, { to: 'debug' })
93 } catch (err) {
94 $.ui.log(`clawd-ghost: could not start ${node}: ${String(err).slice(0, 200)}`, { to: 'debug' })
95 break
96 }
97 }
98 }
99 } finally {
100 isBuilding = false
101 }
102 })()
103}
104
105async function refreshIfStale($: EngineInterface, staleMs: number): Promise<void> {
106 const root = await $.session.root()
107 const path = indexPathOf($, root)
108 let age = Number.POSITIVE_INFINITY
109 try {
110 age = (await $.clock.now()) - (await $.fs.stat(path)).mtimeMs
111 } catch {}
112 if (age > staleMs) buildIndex($, root, path)
113}
114
115/** Today as the ghost says dates: `17 May`. */
116function todayOf(nowMs: number): string {
117 const d = new Date(nowMs)
118 return dayOf(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
119}
120
121async function rotationOf($: EngineInterface): Promise<Picked> {
122 try {
123 const v = await $.store.get(ROTATION_KEY)
124 return v !== null && typeof v === 'object' ? (v as Picked) : {}
125 } catch {
126 return {}
127 }
128}
129
130async function remember($: EngineInterface, used: Picked): Promise<void> {
131 if (Object.keys(used).length === 0) return
132 try {
133 await $.store.set(ROTATION_KEY, { ...(await rotationOf($)), ...used })
134 } catch {}
135}
136
137/** The line Claude reads about the value swing, plainly. */
138function swingNoteOf(swing: Swing, card: GhostCard): string {
139 const v = card.vars
140 const history = swing.history === undefined ? '' : ` Its value history in git: ${swing.history.slice(0, -1).join(' → ')}; this edit makes it ${swing.history.join(' → ')} (reversal #${swing.n}).`
141 return `This edit brings back ${v.old ?? swing.back} for ${v.thing ?? swing.label}, which the ${swing.decision.date} decision replaced with ${swing.decided}.${history}`
142}
143
144/** Adds what Claude should read after a tool result: the ghost's note and any overrule note waiting. */
145async function finish($: EngineInterface, ran: Awaited<ReturnType<EngineInterface['tool']['call']>>, extra: string[], isMain: boolean) {
146 if (ran.deny !== undefined) return ran
147 const notes = [...extra]
148 if (isMain) {
149 const waiting = await read($, pendingNote)
150 if (waiting !== null) {
151 notes.push(waiting)
152 await update($, pendingNote, () => null)
153 }
154 }
155 return notes.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...notes] }
156}
157
158function stopFloat(): void {
159 float?.timer?.cancel()
160 float = undefined
161}
162
163/** Two frames, up and down, repainted with `$.ui.blit`; a redraw instead when a blit is refused. */
164function ensureFloat($: EngineInterface, requestId: string, card: GhostCard): void {
165 if (float !== undefined && float.requestId === requestId && float.cardId === card.id) {
166 float.quietTicks = 0
167 return
168 }
169 stopFloat()
170 const f: Float = { requestId, cardId: card.id, level: card.level, frame: 0, isInvalidating: false, quietTicks: 0 }
171 float = f
172 f.timer = $.clock.every(FLOAT_MS, () => {
173 void (async () => {
174 if (float !== f) return
175 f.frame = f.frame === 0 ? 1 : 0
176 if (f.isInvalidating) {
177 // No redraw for a while means the band is gone: stop.
178 f.quietTicks += 1
179 if (f.quietTicks > 3) stopFloat()
180 else $.ui.invalidate('ui.render')
181 return
182 }
183 const r = await $.ui.blit({ requestId: f.requestId, key: 'ghost-clawd', cells: cellsOf(f.level, f.frame), columns: COLUMNS, rows: ROWS })
184 if (r.deny !== undefined && float === f) {
185 f.isInvalidating = true
186 $.ui.invalidate('ui.render')
187 }
188 })()
189 })
190}
191
192/** Button 1: back to rest, a parting line for a few seconds. */
193async function rest($: EngineInterface, card: GhostCard): Promise<void> {
194 const rotation = await rotationOf($)
195 const line = pick(REST, card.vars, rotation.rest)
196 await remember($, { rest: line?.id })
197 await showAfter($, card, line?.lines[0])
198}
199
200/** Button 2: on a swing, Claude is asked to log the owner's choice; on history, just noted. */
201async function overrule($: EngineInterface, card: GhostCard): Promise<void> {
202 if (card.kind !== 'swing' || card.overrule === undefined) {
203 await update($, current, c => (c?.id === card.id ? null : c))
204 return
205 }
206 const today = todayOf(await $.clock.now())
207 const index = await loadIndex($, indexPathOf($, await $.session.root()))
208 const note = overruleNoteOf(card, card.buttons[1], today, index?.decisionsFile ?? (decisionsOption === '' ? undefined : decisionsOption))
209 if (isWorking) {
210 await update($, pendingNote, () => note)
211 } else {
212 void $.prompt.submit({ text: note }).catch(() => update($, pendingNote, () => note))
213 }
214 const rotation = await rotationOf($)
215 const line = pick(OVERRULE, { ...card.vars, today }, rotation.overrule)
216 await remember($, { overrule: line?.id })
217 await showAfter($, card, line?.lines[0])
218}
219
220async function showAfter($: EngineInterface, card: GhostCard, line: string | undefined): Promise<void> {
221 if (line === undefined) {
222 await update($, current, c => (c?.id === card.id ? null : c))
223 return
224 }
225 await update($, current, c => (c?.id === card.id ? { ...card, mode: 'after' as const, lines: [line] } : c))
226 $.clock.after(AFTER_PRESS_MS, () => {
227 void update($, current, c => (c?.id === card.id ? null : c))
228 })
229}
230
231export const register: Register = (on, options) => {
232 decisionsOption = typeof options.decisionsFile === 'string' ? options.decisionsFile.trim() : ''
233
234 on('session.start', async ($, e, next) => {
235 const started = await next(e)
236 await refreshIfStale($, FRESH_AT_START_MS)
237 try {
238 await $.command.register({
239 name: 'ghost',
240 description: 'clawd-ghost: list the ghosts (past decisions and your past words) for the files this session touched',
241 immediate: true,
242 })
243 } catch (err) {
244 $.ui.log(`clawd-ghost: /ghost not registered: ${String(err)}`, { to: 'debug' })
245 }
246 return started
247 })
248
249 on('turn.complete', async ($, e, next) => {
250 const done = await next(e)
251 if (e.agentId === undefined) await refreshIfStale($, REFRESH_MS)
252 return done
253 })
254
255 // Observe only: look the file up, show the ghost, let the edit run as asked,
256 // then leave Claude a note. Never `{ deny }`, never a rewritten `e`.
257 on('tool.call', async ($, e, next) => {
258 const tool: string = e.tool
259 const isMain = e.agentId === undefined
260 if (!EDIT_TOOLS.includes(tool)) return finish($, await next(e), [], isMain)
261 const input = e as unknown as Record<string, unknown>
262 const target = targetOf(input)
263 const root = await $.session.root()
264 const index = target === undefined ? undefined : await loadIndex($, indexPathOf($, root))
265 const rel = target === undefined || index === undefined ? undefined : relativeTo(index.root, target)
266 if (rel === undefined) return finish($, await next(e), [], isMain)
267
268 await update($, touched, list => (list.includes(rel) ? list : [...list, rel].slice(-200)))
269 if ((await read($, shown)).includes(rel)) return finish($, await next(e), [], isMain)
270
271 const edit = editTextOf(input)
272 if (tool === 'Write' && target !== undefined) {
273 try {
274 edit.before = (await $.fs.read(target)).slice(0, 200000)
275 } catch {}
276 }
277 const ghosts = index?.files[rel] ?? []
278 const ranked = rank(ghosts, edit)
279 if (ranked.length === 0) return finish($, await next(e), [], isMain)
280
281 const swing = swingOf(ranked, edit, index?.values?.[rel])
282 const nowMs = await $.clock.now()
283 const composed = compose({ id: `${nowMs}:${rel}`, file: rel, ranked, all: ghosts, swing, nowMs, today: todayOf(nowMs), last: await rotationOf($) })
284 if (composed === undefined) return finish($, await next(e), [], isMain)
285 await remember($, composed.used)
286 await update($, shown, list => [...list, rel].slice(-500))
287 await update($, current, () => composed.card)
288
289 const note = noteOf(rel, ranked) + (swing === undefined ? '' : `\n${swingNoteOf(swing, composed.card)}`)
290 return finish($, await next(e), [note], isMain)
291 })
292
293 // A new prompt from the owner clears the last ghost.
294 on('prompt.submit', async ($, e, next) => {
295 if (e.turnId === undefined && (await read($, current)) !== null) await update($, current, () => null)
296 return next(e)
297 })
298
299 // The band above the prompt: a glance, not a panel. It shows at any width
300 // (a Pane opened unasked waits for 144 columns) and goes with one press.
301 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
302 isWorking = e.props.isWorking
303 if (e.props.hasSurvey) return next(e)
304 const card = await read($, current)
305 if (card === null) {
306 stopFloat()
307 return next(e)
308 }
309 const style = STYLE[card.level]
310 const [punch = '', fact, consequence] = card.lines
311 const isHaunting = card.mode === 'haunt'
312 const more = card.more > 0 ? ` · +${card.more} more: /ghost` : ''
313 const press1 = () => rest($, card)
314 const press2 = () => overrule($, card)
315
316 if (e.surface === 'terminal') {
317 const { Box, Button, Raster, Text } = $.ui.resolve(e)
318 ensureFloat($, e.requestId, card)
319 const frame = float?.cardId === card.id ? float.frame : 0
320 const width = Math.max(20, e.props.bodyColumns - 14)
321 return (
322 <Box flexDirection="row" gap={1} key="ghost">
323 <Raster key="ghost-clawd" columns={COLUMNS} rows={ROWS} cells={cellsOf(card.level, frame)} />
324 <Box flexDirection="column" width={width}>
325 <Text color={style.color} bold={card.level === 3}>
326 {punch}
327 </Text>
328 {fact !== undefined && <Text>{fact}</Text>}
329 {consequence !== undefined && <Text dimColor>{consequence}</Text>}
330 {isHaunting && (
331 <Box flexDirection="row" gap={1}>
332 <Button key="rest" label={card.buttons[0]} hotkey="1" onPress={press1} />
333 <Button key="overrule" label={card.buttons[1]} hotkey="2" onPress={press2} />
334 <Text dimColor>
335 {card.file}
336 {more}
337 </Text>
338 </Box>
339 )}
340 </Box>
341 </Box>
342 )
343 }
344 if (e.surface === 'desktop') {
345 // The Desktop app's band is a light card: the ghost large and crisp on the left, the
346 // three lines on the right, the buttons below; compact when the band is narrow.
347 const { Box, Button, Svg, Text } = $.ui.resolve(e)
348 // Desktop measures its band in its own columns; the band spans the prompt, so only a really narrow one is compact.
349 const isCompact = e.props.bodyColumns < COMPACT_COLUMNS
350 const art = desktopArt({ level: card.level, isEntering: isHaunting, isCompact, tomb: card.tomb })
351 return (
352 <Box flexDirection="column" gap={1} key="ghost">
353 <Box flexDirection="row" gap={2} alignItems="center">
354 <Svg source={art.source} alt={altOf(card.level, card.lines, isCompact ? undefined : card.tomb)} width={art.width} height={art.height} isInteractive />
355 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
356 <Text color={DESKTOP_TEXT[card.level]} bold={card.level === 3}>
357 {punch}
358 </Text>
359 {fact !== undefined && <Text>{fact}</Text>}
360 {consequence !== undefined && <Text dimColor>{consequence}</Text>}
361 </Box>
362 </Box>
363 {isHaunting && (
364 <Box flexDirection="row" gap={1} alignItems="center">
365 <Button key="rest" label={card.buttons[0]} hotkey="1" onPress={press1} />
366 <Button key="overrule" label={card.buttons[1]} hotkey="2" variant="primary" onPress={press2} />
367 {!isCompact && (
368 <Text dimColor>
369 {card.file}
370 {more}
371 </Text>
372 )}
373 </Box>
374 )}
375 </Box>
376 )
377 }
378 const { Box, Button, Text } = $.ui.resolve(e)
379 return (
380 <Box flexDirection="column" key="ghost">
381 {card.lines.map(line => (
382 <Text>{line}</Text>
383 ))}
384 {isHaunting && <Button key="rest" label={card.buttons[0]} hotkey="1" onPress={press1} />}
385 {isHaunting && <Button key="overrule" label={card.buttons[1]} hotkey="2" onPress={press2} />}
386 </Box>
387 )
388 })
389
390 on('command.run', { command: 'ghost' }, async $ => {
391 const root = await $.session.root()
392 const index = await loadIndex($, indexPathOf($, root))
393 const files = await read($, touched)
394 if (index === undefined) return { text: 'clawd-ghost: no index yet for this project (it builds in the background at session start).' }
395 if (files.length === 0) return { text: 'clawd-ghost: this session has not edited any file yet.' }
396 const rows: string[] = []
397 for (const file of files) {
398 const ghosts = index.files[file]
399 const ranked = rank(ghosts)
400 if (ranked.length === 0) continue
401 rows.push(`${file}`)
402 for (const { ghost } of ranked.slice(0, 3)) rows.push(` ${lineOf(ghost, ghosts ?? [])}`)
403 }
404 if (rows.length === 0) return { text: `clawd-ghost: no ghosts for the ${files.length} file(s) this session touched.` }
405 return { text: rows.join('\n') }
406 })
407}
408
409hooks/copy.ts 264 lines1// The ghost's voice: the copy pools, filling a template only when every
2// placeholder it needs is known, rotating so no pool says the same line twice
3// in a row, and composing the card the band draws. Pure: no `$`.
4
5import { clip, dayOf, lineOf, sharpOf } from './ghost.ts'
6import type { GhostCard, GhostLevel } from '../types'
7import type { Ghost, Ranked, Swing } from './ghost.ts'
8
9export type VarName =
10 | 'old'
11 | 'new'
12 | 'date'
13 | 'quote'
14 | 'decision'
15 | 'file'
16 | 'born'
17 | 'n'
18 | 'thing'
19 | 'days'
20 | 'history'
21 | 'first'
22 | 'dates'
23 | 'today'
24export type Vars = Partial<Record<VarName, string>>
25
26export type Template = { id: string; lines: readonly string[]; buttons?: readonly [string, string] }
27
28export type Pool = 'swing' | 'history' | 'repeat' | 'repeatButtons' | 'rest' | 'overrule'
29
30/** First haunting, when the edit brings back a value a decision replaced. */
31export const SWING: readonly Template[] = [
32 {
33 id: 's1',
34 lines: ["Boooo. I'm {old}. You buried me on {date}.", 'Your last words: "{quote}"', 'Bring me back and you undo: {decision}.'],
35 buttons: ['Let me rest', "I'm back on purpose"],
36 },
37 {
38 id: 's2',
39 lines: ['Remember me? {old}. Died {date}.', 'Cause of death: "{quote}"', 'This edit digs me up. Your call — I just remember things.'],
40 buttons: ['Leave me buried', 'Dig me up'],
41 },
42 { id: 's3', lines: ['Here lies {old} · {born} – {date}', '"{quote}"', 'Reviving me reverses the {date} decision.'], buttons: ['Rest in peace', 'Resurrect'] },
43 {
44 id: 's4',
45 lines: ['This file is haunted.', 'Last time it looked like this, you said "{quote}"', 'You decided {new} on {date}. This edit says {old}.'],
46 buttons: ['Banish', 'Overrule the ghost'],
47 },
48 {
49 id: 's5',
50 lines: ["Ooooh, {old} again? I've seen how this ends.", '{date}: "{quote}" → {new}.', 'If you mean it this time, say so and Claude will log it.'],
51 buttons: ['Let me rest', 'Mean it this time'],
52 },
53 {
54 id: 's6',
55 lines: ['Unfinished business in {file}.', '{date} you chose {new} ("{quote}").', 'This edit brings back the {old} you retired.'],
56 buttons: ['Leave it settled', 'Reopen it'],
57 },
58]
59
60/** First haunting, history only (no value swing). */
61export const HISTORY: readonly Template[] = [
62 { id: 'h7', lines: ['Boooo. {file} has history.', '{date}: "{quote}"', 'Decided: {decision}.'], buttons: ['Let me rest', 'Noted'] },
63 { id: 'h8', lines: ['I used to live in this file.', '{date} you said "{quote}"', "Check this edit doesn't undo: {decision}."], buttons: ['Let me rest', 'Noted'] },
64]
65
66/** Reversed again: one template per count (n ≥ 5 shares the last). */
67export const REPEAT: readonly Template[] = [
68 { id: 'n2', lines: ['Back again? Reversal #{n} on {thing}.', '{date}: "{quote}" → {new}. You\'re at {old} again.', 'Same fight, new day.'] },
69 { id: 'n3', lines: ["Third time. I've been dead {days} days and you keep digging.", '{thing} has swung {history}.', "Pick one and I'll stay buried."] },
70 { id: 'n3-today', lines: ["I died this morning and you're already digging.", '{thing} has swung {history}.', "Pick one and I'll stay buried."] },
71 { id: 'n4', lines: ["At this point I'm paying rent here.", 'Reversal #{n} on {thing} since {first}.', 'Swings so far: {dates}.'] },
72 { id: 'n5', lines: ["I'm not even mad. Impressed, honestly.", '{n} swings on {thing}. Last word, {date}: "{quote}"', "Decide once and I'll haunt somebody else."] },
73]
74
75export const REPEAT_BUTTONS: readonly Template[] = [
76 { id: 'b1', lines: [], buttons: ['Keep {new}', 'Keep {old} for good'] },
77 { id: 'b2', lines: [], buttons: ['Settle it', 'Swing anyway'] },
78 { id: 'b3', lines: [], buttons: ['Make it final', 'One more swing'] },
79]
80
81/** After button 1: the ghost goes back to rest. */
82export const REST: readonly Template[] = [
83 { id: 'r1', lines: ["Fine. I'll be in the attic."] },
84 { id: 'r2', lines: ['Resting. Wake me if you reverse it again.'] },
85 { id: 'r3', lines: ['Back to the crypt. {thing} stays {new}.'] },
86]
87
88/** After button 2 on a swing: Claude is asked to log it; the mod logs nothing itself. */
89export const OVERRULE: readonly Template[] = [
90 { id: 'o1', lines: ['Overruled. Telling Claude to log it so the next ghost knows.'] },
91 { id: 'o2', lines: ['Resurrected. Claude will note: {today}, you chose {old} on purpose.'] },
92 { id: 'o3', lines: ["Fine, I'm back. Somebody update the tombstone."] },
93]
94
95export const POOLS: Record<Pool, readonly Template[]> = {
96 swing: SWING,
97 history: HISTORY,
98 repeat: REPEAT,
99 repeatButtons: REPEAT_BUTTONS,
100 rest: REST,
101 overrule: OVERRULE,
102}
103
104/** Placeholders that are the ghost's own words (not the owner's), so they take a capital at a sentence's start. */
105const OWN_WORDS: readonly VarName[] = ['thing', 'old']
106
107/** Fills `{name}` placeholders; undefined when one is unknown. Quotes stay exactly as the owner wrote them. */
108export function fill(text: string, vars: Vars): string | undefined {
109 let missing = false
110 const out = text.replace(/\{([a-z]+)\}/g, (_, name: string, at: number) => {
111 const v = vars[name as VarName]
112 if (v === undefined || v === '') {
113 missing = true
114 return ''
115 }
116 const isSentenceStart = at === 0 || /[.!?]\s$/.test(text.slice(0, at))
117 return isSentenceStart && OWN_WORDS.includes(name as VarName) ? v.charAt(0).toUpperCase() + v.slice(1) : v
118 })
119 return missing ? undefined : out
120}
121
122export type Filled = { id: string; lines: string[]; buttons?: [string, string] }
123
124/** A template with every placeholder filled, or undefined. */
125export function fillTemplate(t: Template, vars: Vars): Filled | undefined {
126 const lines = t.lines.map(l => fill(l, vars))
127 if (lines.some(l => l === undefined)) return undefined
128 let buttons: [string, string] | undefined
129 if (t.buttons !== undefined) {
130 const a = fill(t.buttons[0], vars)
131 const b = fill(t.buttons[1], vars)
132 if (a === undefined || b === undefined) return undefined
133 buttons = [a, b]
134 }
135 return { id: t.id, lines: lines as string[], buttons }
136}
137
138/**
139 * The next template of a pool after the last one used, among those whose
140 * placeholders are all known; never the last one again while another fits.
141 */
142export function pick(pool: readonly Template[], vars: Vars, last?: string): Filled | undefined {
143 const fits = pool.map(t => fillTemplate(t, vars)).filter((f): f is Filled => f !== undefined)
144 if (fits.length === 0) return undefined
145 const at = pool.findIndex(t => t.id === last)
146 for (let step = 1; step <= pool.length; step++) {
147 const t = pool[(at + step + pool.length) % pool.length]
148 const f = fits.find(one => one.id === t?.id)
149 if (f !== undefined && (f.id !== last || fits.length === 1)) return f
150 }
151 return fits[0]
152}
153
154/** 1 faint, 2 more solid, 3 solid and grumpy. */
155export type Level = GhostLevel
156
157export const levelOf = (n: number): Level => (n >= 3 ? 3 : n === 2 ? 2 : 1)
158
159/** The card the band draws (the state contract's GhostCard). */
160export type Card = GhostCard
161
162export type Picked = Partial<Record<Pool, string>>
163
164const days = (from: string, nowMs: number): number => Math.max(0, Math.floor((nowMs - Date.parse(`${from}T00:00:00`)) / 86400000))
165
166/** The decision in a few words, cut at a comma so a list never ends mid-item ("hero 56, titles 44, items 22"). */
167export function decisionText(ghost: Ghost): string {
168 const text = ghost.what.replace(/^\([^)]*\)\s*/, '').replace(/…$/, '')
169 const parts = text.split(/,\s+/)
170 let out = parts[0] ?? text
171 for (const part of parts.slice(1)) {
172 if (`${out}, ${part}`.split(/\s+/).length > 11) break
173 out = `${out}, ${part}`
174 }
175 return clip(out, 14).replace(/[.…,;:\s]+$/, '')
176}
177
178/** The placeholders a ranked lookup and its swing make known. */
179export function varsOf(file: string, top: Ghost, swing: Swing | undefined, nowMs: number, today: string): Vars {
180 const d = swing?.decision ?? top
181 const vars: Vars = { file, today, date: dayOf(d.date) }
182 if (d.quote !== undefined) vars.quote = sharpOf(d.quote)
183 if (d.kind === 'decision') vars.decision = decisionText(d)
184 if (swing !== undefined) {
185 const unit = swing.unit
186 vars.old = `${swing.label} ${swing.back}${unit}`
187 vars.new = swing.decided
188 vars.thing = unit === 'px' ? `the ${swing.label} size` : `the ${swing.label} colour`
189 vars.n = String(swing.n)
190 vars.days = String(days(d.date, nowMs))
191 if (swing.born !== undefined) vars.born = dayOf(swing.born)
192 if (swing.history !== undefined) vars.history = swing.history.join(' → ')
193 if (swing.dates !== undefined && swing.dates.length > 0) {
194 vars.dates = swing.dates.map(dayOf).join(', ')
195 vars.first = dayOf(swing.dates[0] ?? '')
196 }
197 }
198 return vars
199}
200
201/** Composes the card for an edit; `last` is the last template each pool used. */
202export function compose(args: {
203 id: string
204 file: string
205 ranked: readonly Ranked[]
206 all: readonly Ghost[]
207 swing?: Swing
208 nowMs: number
209 today: string
210 last: Picked
211}): { card: Card; used: Picked } | undefined {
212 const top = args.ranked[0]?.ghost
213 if (top === undefined) return undefined
214 const vars = varsOf(args.file, top, args.swing, args.nowMs, args.today)
215 const n = args.swing?.n ?? 1
216 const level = levelOf(n)
217 const base = { id: args.id, file: args.file, n, level, mode: 'haunt' as const, more: args.ranked.length - 1, vars: vars as Record<string, string> }
218 const used: Picked = {}
219 if (args.swing !== undefined) {
220 const overrule = { old: vars.old ?? '', new: vars.new ?? '', date: args.swing.decision.date, decision: vars.decision ?? '', thing: vars.thing ?? '' }
221 if (n >= 2) {
222 const want = n >= 5 ? ['n5'] : n === 4 ? ['n4'] : n === 3 ? [vars.days === '0' ? 'n3-today' : 'n3'] : ['n2']
223 const t = REPEAT.find(one => one.id === want[0])
224 const filled = t === undefined ? undefined : fillTemplate(t, vars)
225 const buttons = pick(REPEAT_BUTTONS, vars, args.last.repeatButtons)
226 if (filled !== undefined && buttons?.buttons !== undefined) {
227 used.repeat = filled.id
228 used.repeatButtons = buttons.id
229 return { card: { ...base, kind: 'swing', lines: filled.lines, buttons: buttons.buttons, overrule }, used }
230 }
231 }
232 const first = pick(SWING, vars, args.last.swing)
233 if (first?.buttons !== undefined) {
234 used.swing = first.id
235 // The tombstone line gets a gravestone beside the desktop ghost; it needs {born}, as the line does.
236 const tomb = first.id === 's3' && vars.old !== undefined && vars.born !== undefined && vars.date !== undefined ? { name: vars.old, born: vars.born, died: vars.date } : undefined
237 return { card: { ...base, kind: 'swing', lines: first.lines, buttons: first.buttons, overrule, ...(tomb === undefined ? {} : { tomb }) }, used }
238 }
239 }
240 const history = pick(HISTORY, vars, args.last.history)
241 if (history?.buttons !== undefined) {
242 used.history = history.id
243 return { card: { ...base, kind: 'history', lines: history.lines, buttons: history.buttons }, used }
244 }
245 return { card: { ...base, kind: 'plain', lines: [lineOf(top, args.all)], buttons: ['Let me rest', 'Noted'] }, used }
246}
247
248/**
249 * What Claude is asked after the owner overrules the ghost. The mod itself
250 * writes nothing. `decisionsFile` is the project's decisions log (relative
251 * path), or undefined when the project has none yet.
252 */
253export function overruleNoteOf(card: GhostCard, button: string, today: string, decisionsFile?: string): string {
254 const o = card.overrule
255 if (o === undefined) return ''
256 const where = decisionsFile === undefined || decisionsFile === '' ? "the project's decisions log (create one if it has none)" : decisionsFile
257 return [
258 `clawd-ghost: the owner pressed "${button}" on the ghost for ${card.file}: they want ${o.old} back on purpose, overruling the ${o.date} decision (${o.decision}).`,
259 `Please record this as a new owner decision in ${where} through the project's normal process (newest first; why: the owner chose ${o.old} on purpose on ${today}; replaces: the ${o.date} line on ${o.thing}), so the next ghost knows.`,
260 'clawd-ghost wrote nothing itself.',
261 ].join(' ')
262}
263
264hooks/ghost.ts 388 lines1// The ghost's pure logic: the index's shape, the tokens both the index script
2// and the hook speak, ranking a file's ghosts against an edit, and the words.
3// No `$`, no Node: scripts/build-index.mjs imports this file too (node strips
4// the types), and so do the tests.
5
6export type GhostKind = 'decision' | 'commit' | 'owner'
7
8export type Ghost = {
9 kind: GhostKind
10 /** YYYY-MM-DD, local time. */
11 date: string
12 /** The owner's own words, under 20 words. */
13 quote?: string
14 /** The decision, or the commit subject, short. */
15 what: string
16 /** What the decision replaced: what an edit back would undo. */
17 undo?: string
18 /** `undo` as keys: an edit that writes one of these back swings the decision back. */
19 undoKeys?: string[]
20 /** `file`: the history names this file. `topic`: it names a system (type, colour…) the file defines, so it needs the edit to touch it. */
21 scope: 'file' | 'topic'
22 /** Lower-case tokens (and `topic:*`) an edit is matched against. */
23 keys: string[]
24 weight: number
25 /** Where it came from: `decisions.md (origin/main)`, `git a1b2c3d`, `transcript 1a2b3c4d`. */
26 source: string
27}
28
29/** One value a custom property held, from `git log -L` (oldest first in the index). */
30export type ValuePoint = { date: string; value: string; hash: string }
31
32export type GhostIndex = {
33 v: 1
34 /** The repository root the `files` keys are relative to. */
35 root: string
36 builtAt: number
37 sources: { decisions: string | null; commits: number; transcripts: number; ownerMessages: number }
38 /** The decisions log the index read, relative to `root` (undefined or null: none found). */
39 decisionsFile?: string | null
40 files: Record<string, Ghost[]>
41 /** Per file, per custom property it defines: the values it held over time, oldest first. */
42 values?: Record<string, Record<string, ValuePoint[]>>
43}
44
45export type Ranked = { ghost: Ghost; score: number }
46
47export const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
48
49const STOP = new Set(
50 'that this with from have they them then than what when where which while your yours will would there their about into over under after before just like also only some more most very such each every other these those been being were does done doing make made keep kept same still back need want should could shall here first last next once much many good look looks feel feels thing things page site section sections file files line lines text none true false null return const export import default function props style class value values width height auto none'.split(
51 ' ',
52 ),
53)
54
55/** Tokens of a piece of code or prose: custom properties, hex colours, px values, and plain words of four letters or more. */
56export function tokenize(text: string): string[] {
57 const out = new Set<string>()
58 const lower = text.toLowerCase()
59 for (const m of lower.matchAll(/--[a-z][a-z0-9-]*/g)) out.add(m[0])
60 for (const m of lower.matchAll(/#[0-9a-f]{6}\b|#[0-9a-f]{3}\b/g)) out.add(m[0])
61 for (const m of lower.matchAll(/\b(\d{1,4})px\b/g)) out.add(`${m[1]}px`)
62 for (const m of lower.matchAll(/[a-z][a-z-]{3,}/g)) {
63 const word = m[0].replace(/^-+|-+$/g, '')
64 if (word.length >= 4 && !STOP.has(word)) out.add(word)
65 }
66 for (const topic of topicsOf(lower)) out.add(`topic:${topic}`)
67 return [...out]
68}
69
70/** The systems a design file defines, by the custom-property names and words common to most stylesheets. */
71const TOPICS: Record<string, RegExp> = {
72 type: /--(t|f|ls|fs|font|type)-[a-z]|font-size|font-weight|\bfont\b|\bfonts\b|typograph|\btype (size|system|scale)|\bsizings?\b|\bheadings?\b|\bletter-spacing\b|\bline-height\b/,
73 colour: /--(accent|ink|page|paper|surface|bg|fg|brand|primary|secondary|colou?r)[a-z0-9-]*|\bcolou?rs?\b|\bcontrast\b|\bpalette\b|data-theme|#[0-9a-f]{6}\b/,
74 layout: /--(maxw|max-width|measure|gutter|container)\b|max-width|\blayout\b/,
75}
76
77/** The systems a piece of text speaks about. */
78export function topicsOf(text: string): string[] {
79 const lower = text.toLowerCase()
80 return Object.keys(TOPICS).filter(t => TOPICS[t]?.test(lower))
81}
82
83/** Where a project's decisions log may live, in the order the index builder tries them: the configured path first, then the common spots. */
84export const DECISIONS_CANDIDATES: readonly string[] = ['design/decisions.md', 'DECISIONS.md', 'docs/decisions.md', 'decisions.md']
85
86/** The paths to try for the decisions log: `configured` (when set) first, then the common spots, no repeats. */
87export function decisionsCandidates(configured?: string): string[] {
88 const own = (configured ?? '').trim().replace(/^\.\//, '')
89 return [...new Set([...(own === '' ? [] : [own]), ...DECISIONS_CANDIDATES])]
90}
91
92/** What an edit removes and what it writes. */
93export type EditText = { before: string; after: string }
94
95/** The text an edit tool call carries: what it removes (`before`) and what it writes (`after`). */
96export function editTextOf(e: Record<string, unknown>): EditText {
97 const before: string[] = []
98 const after: string[] = []
99 if (typeof e.old_string === 'string') before.push(e.old_string)
100 for (const key of ['new_string', 'content', 'new_source']) {
101 const v = e[key]
102 if (typeof v === 'string') after.push(v)
103 }
104 const edits = e.edits
105 if (Array.isArray(edits)) {
106 for (const one of edits) {
107 if (one !== null && typeof one === 'object') {
108 const o = one as Record<string, unknown>
109 if (typeof o.old_string === 'string') before.push(o.old_string)
110 if (typeof o.new_string === 'string') after.push(o.new_string)
111 }
112 }
113 }
114 return { before: before.join('\n').slice(0, 200000), after: after.join('\n').slice(0, 200000) }
115}
116
117/** Keys that name a system or a value, not a plain word: what a topic ghost needs an edit to touch. */
118export const isStrongKey = (k: string): boolean => k.startsWith('topic:') || k.startsWith('--') || k.startsWith('#') || /^\d+px$/.test(k)
119
120/** The values a decision replaced, as keys: bare sizes of 40 and up read as px (`64/52` → `64px`, `52px`). */
121export function undoKeysOf(replaced: string): string[] {
122 const out = new Set(tokenize(replaced).filter(isStrongKey).filter(k => !k.startsWith('topic:')))
123 for (const m of replaced.matchAll(/(?<![#\w])(\d{1,2},?\d{3}|\d{2,3})(?![\w%])/g)) {
124 const n = Number((m[1] ?? '').replace(',', ''))
125 if (n >= 40 && n <= 4000) out.add(`${n}px`)
126 }
127 return [...out]
128}
129
130/** The path an edit tool call targets. */
131export function targetOf(e: Record<string, unknown>): string | undefined {
132 const p = e.file_path ?? e.notebook_path
133 return typeof p === 'string' && p.length > 0 ? p : undefined
134}
135
136/** A path relative to `root`, or undefined when it lies outside. */
137export function relativeTo(root: string, path: string): string | undefined {
138 const base = root.endsWith('/') ? root : `${root}/`
139 if (path.startsWith(base)) return path.slice(base.length)
140 if (!path.startsWith('/')) return path.replace(/^\.\//, '')
141 return undefined
142}
143
144/**
145 * A file's ghosts that matter for this edit, best first. With `edit`
146 * undefined (listing, not editing) every ghost counts as it is.
147 *
148 * Topic ghosts (a decision about the type system, against the file that
149 * defines it) need the edit to touch that system or one of its values, so a
150 * colour edit in the global stylesheet does not raise the type-size ghost.
151 * An edit that writes back a value a decision replaced ranks that decision
152 * first: that is the swing the ghost is for.
153 */
154export function rank(ghosts: readonly Ghost[] | undefined, edit?: EditText): Ranked[] {
155 if (ghosts === undefined || ghosts.length === 0) return []
156 const before = edit === undefined ? undefined : new Set(tokenize(edit.before))
157 const after = edit === undefined ? undefined : new Set(tokenize(edit.after))
158 const touched = edit === undefined ? undefined : new Set([...(before ?? []), ...(after ?? [])])
159 const added = edit === undefined ? undefined : new Set([...(after ?? [])].filter(k => !(before ?? new Set()).has(k)))
160 const out: Ranked[] = []
161 for (const ghost of ghosts) {
162 let strong = 0
163 let words = 0
164 let undoes = 0
165 if (touched !== undefined) {
166 for (const k of ghost.keys) {
167 if (!touched.has(k)) continue
168 if (isStrongKey(k)) strong += 1
169 else words += 1
170 }
171 for (const k of ghost.undoKeys ?? []) if (added?.has(k)) undoes += 1
172 }
173 if (ghost.scope === 'topic' && touched !== undefined && strong === 0 && undoes === 0) continue
174 const recency = Number(ghost.date.replaceAll('-', '')) / 1e9
175 const score = ghost.weight + 2 * Math.min(strong, 3) + 0.5 * Math.min(words, 4) + (undoes > 0 ? 8 : 0) + recency
176 out.push({ ghost, score })
177 }
178 return out.sort((a, b) => b.score - a.score)
179}
180
181const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
182
183/** `2026-05-12` → `12 May`. */
184export function dayOf(date: string): string {
185 const [, m, d] = date.split('-')
186 return `${Number(d)} ${MONTHS[Number(m) - 1] ?? ''}`.trim()
187}
188
189const bare = (text: string): string => text.replace(/[.…,;:\s]+$/, '')
190
191const STRONG = /\b(loud|louder|shouting|way|hate|horrible|hurts?|weird|bland|empty|busy|cluttered|ugly|cheap|boring|plain|overwhelming|big|bigger|huge|small|smaller|tiny|tall|love|don'?t|dont)\b/gi
192
193/** The sharpest sentence of a quote, for the band: "the hero feels too loud". */
194export function sharpOf(quote: string): string {
195 const sentences = quote.split(/(?<=[.!?])\s+/).filter(Boolean)
196 if (sentences.length < 2) return bare(quote)
197 let best = sentences[0] ?? quote
198 let most = -1
199 for (const one of sentences) {
200 const n = (one.match(STRONG) ?? []).length
201 if (n > most) {
202 most = n
203 best = one
204 }
205 }
206 return bare(best)
207}
208
209const daysBetween = (a: string, b: string): number => (Date.parse(`${b}T12:00:00`) - Date.parse(`${a}T12:00:00`)) / 86400000
210
211/**
212 * A commit swing on the same file and topic landing up to a day before a
213 * decision: "the day after 'Bigger and bolder…' landed here".
214 */
215function companionOf(ghost: Ghost, all: readonly Ghost[]): Ghost | undefined {
216 if (ghost.kind === 'commit') return undefined
217 const topics = new Set(ghost.keys.filter(k => k.startsWith('topic:')))
218 return all.find(other => {
219 if (other.kind !== 'commit') return false
220 const gap = daysBetween(other.date, ghost.date)
221 return gap >= 0 && gap <= 1 && other.keys.some(k => topics.has(k))
222 })
223}
224
225/** The ghost's one line, as the band shows it: the dated words, the decision, what an edit back undoes. */
226export function lineOf(ghost: Ghost, all: readonly Ghost[] = []): string {
227 const day = dayOf(ghost.date)
228 const with_ = companionOf(ghost, all)
229 const when =
230 with_ === undefined
231 ? ''
232 : ` ${daysBetween(with_.date, ghost.date) === 0 ? 'the same day' : 'the day after'} "${bare(clip(with_.what, 4))}" landed here,`
233 if (ghost.kind === 'decision') {
234 const said = ghost.quote === undefined ? '' : ` you said "${sharpOf(ghost.quote)}"${when} and`
235 const undo = ghost.undo === undefined ? '' : ` An edit back undoes it (it replaced ${bare(clip(ghost.undo, 8))}).`
236 return `Ghost of ${day}:${said} decided ${bare(clip(ghost.what, 12))}.${undo}`
237 }
238 if (ghost.kind === 'commit') {
239 return `Ghost of ${day}: this file swung before: "${bare(clip(ghost.what, 10))}" (${ghost.source.replace(/^git /, '')}).`
240 }
241 return `Ghost of ${day}: you said "${sharpOf(ghost.quote ?? ghost.what)}"${when === '' ? ' about this' : when.replace(/,$/, '')}.`
242}
243
244/** The note Claude reads after the edit's result: the same facts, plainly. */
245export function noteOf(file: string, ranked: readonly Ranked[]): string {
246 const rows = ranked.slice(0, 3).map(({ ghost }) => {
247 const said = ghost.quote === undefined || ghost.kind === 'owner' ? '' : ` Owner's words: "${bare(ghost.quote)}".`
248 const undo = ghost.undo === undefined ? '' : ` It replaced: ${bare(ghost.undo)}.`
249 const what = ghost.kind === 'owner' ? `the owner said "${bare(ghost.what)}"` : bare(ghost.what)
250 return `- ${ghost.date}, ${ghost.kind} (${ghost.source}): ${what}.${said}${undo}`
251 })
252 return [
253 `clawd-ghost (observe only): ${file} has history the owner settled before. Check this edit does not swing it back; if it does, tell the owner which decision it reverses rather than quietly undoing it.`,
254 ...rows,
255 ].join('\n')
256}
257
258/** Cuts text to fewer than `max` words, marking the cut. */
259export function clip(text: string, max = 19): string {
260 const words = text.trim().split(/\s+/)
261 return words.length <= max ? words.join(' ') : `${words.slice(0, max).join(' ')}…`
262}
263
264// ---- value swings: what an edit brings back, and how often it has swung ----
265
266/** A custom property's value as a comparable token: its largest px size (`64`), or its hex colour. */
267export function measureOf(value: string): { token: string; unit: 'px' | '' } | undefined {
268 const px = [...value.matchAll(/(\d+(?:\.\d+)?)px\b/g)].map(m => Number(m[1]))
269 if (px.length > 0) return { token: String(Math.max(...px)), unit: 'px' }
270 const hex = value.match(/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/)
271 return hex === null ? undefined : { token: hex[0].toLowerCase(), unit: '' }
272}
273
274/** Custom property definitions in a piece of CSS: `--t-display` → `clamp(40px, 5vw, 56px)`. */
275export function propsOf(text: string): Map<string, string> {
276 const out = new Map<string, string>()
277 for (const m of text.matchAll(/(--[a-zA-Z][\w-]*)\s*:\s*([^;}\n]+)/g)) out.set((m[1] ?? '').toLowerCase(), (m[2] ?? '').trim())
278 return out
279}
280
281export type Swing = {
282 /** The decision this edit would undo. */
283 decision: Ghost
284 /** The custom property, when the edit names one. */
285 prop?: string
286 /** What the value is (`hero`), from the decision's words or the property's name. */
287 label: string
288 /** The value the edit brings back (`64`, `#fdf6ec`) and the decided one it replaces (`56`). */
289 back: string
290 decided: string
291 unit: 'px' | ''
292 /** The values over time, the edit's last: `60 → 64 → 56 → 64`. */
293 history?: string[]
294 /** When each past value landed (YYYY-MM-DD). */
295 dates?: string[]
296 /** When the value the edit brings back first landed. */
297 born?: string
298 /** Which reversal this is on this system: 1 for the first time. */
299 n: number
300}
301
302const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
303
304/** How many times a run of values changed direction (sizes) or went back to an earlier value (colours). */
305export function reversalsOf(seq: readonly string[], unit: 'px' | ''): number {
306 const run = seq.filter((v, i) => i === 0 || v !== seq[i - 1])
307 let n = 0
308 if (unit === 'px') {
309 let last = 0
310 for (let i = 1; i < run.length; i++) {
311 const dir = Math.sign(Number(run[i]) - Number(run[i - 1]))
312 if (dir !== 0 && last !== 0 && dir !== last) n += 1
313 if (dir !== 0) last = dir
314 }
315 return n
316 }
317 for (let i = 2; i < run.length; i++) if (run.slice(0, i - 1).includes(run[i] ?? '')) n += 1
318 return n
319}
320
321/**
322 * The value swing an edit makes, when it brings back a value a decision
323 * replaced: which decision, which value, its history and the reversal count.
324 * Undefined when the edit is not a swing back (or nothing says what it undoes).
325 */
326export function swingOf(ranked: readonly Ranked[], edit: EditText, values?: Record<string, ValuePoint[]>): Swing | undefined {
327 const decisions = ranked.map(r => r.ghost).filter(g => g.kind === 'decision')
328 if (decisions.length === 0) return undefined
329 const before = propsOf(edit.before)
330 const after = propsOf(edit.after)
331 const changes: { prop?: string; from: string; to: string; unit: 'px' | '' }[] = []
332 for (const [prop, value] of after) {
333 const was = before.get(prop)
334 const to = measureOf(value)
335 const from = was === undefined ? undefined : measureOf(was)
336 if (to !== undefined && from !== undefined && to.token !== from.token && to.unit === from.unit) changes.push({ prop, from: from.token, to: to.token, unit: to.unit })
337 }
338 if (changes.length === 0) {
339 // A fragment without the property's name: the sizes it removes and adds.
340 const removed = tokenize(edit.before).filter(k => /^\d+px$/.test(k))
341 const added = tokenize(edit.after).filter(k => /^\d+px$/.test(k) && !removed.includes(k))
342 for (const to of added) for (const from of removed.filter(k => !tokenize(edit.after).includes(k))) changes.push({ from: from.replace('px', ''), to: to.replace('px', ''), unit: 'px' })
343 }
344 for (const change of changes) {
345 const backKey = change.unit === 'px' ? `${change.to}px` : change.to
346 const decidedRe = new RegExp(`(?<![\\w#.])${escape(change.from)}(?:px)?(?![\\w])`, 'i')
347 const earlier = change.prop === undefined ? undefined : values?.[change.prop]
348 const wasBefore = earlier?.some(p => measureOf(p.value)?.token === change.to) === true
349 // A swing back: the value a decision replaced, or one the property held before.
350 const decision =
351 decisions.find(d => (d.undoKeys ?? []).includes(backKey) && decidedRe.test(d.what)) ??
352 decisions.find(d => (d.undoKeys ?? []).includes(backKey)) ??
353 (wasBefore ? decisions.find(d => decidedRe.test(d.what) && d.keys.some(k => k.startsWith('topic:'))) : undefined)
354 if (decision === undefined) continue
355 const labelRe = new RegExp(`([A-Za-z][A-Za-z-]+)\\s+(?:stops at\\s+|is\\s+|at\\s+)?${escape(change.from)}(?:px)?(?![\\w])`, 'i')
356 const word = decision.what.match(labelRe)?.[1]?.toLowerCase()
357 const fromProp = change.prop?.replace(/^--(t|f|ls|c)-/, '').replace(/^--/, '')
358 const label = word !== undefined && !['to', 'and', 'from', 'of', 'the', 'is', 'at'].includes(word) ? word : (fromProp ?? 'value')
359 const points = change.prop === undefined ? undefined : values?.[change.prop]
360 const tokens = points?.map(p => measureOf(p.value)?.token).filter((t): t is string => t !== undefined)
361 let history: string[] | undefined
362 let dates: string[] | undefined
363 let born: string | undefined
364 let n = 1
365 if (points !== undefined && tokens !== undefined && tokens.length >= 2 && tokens[tokens.length - 1] === change.from) {
366 history = [...tokens.filter((t, i) => i === 0 || t !== tokens[i - 1]), change.to]
367 dates = [...new Set(points.map(p => p.date))]
368 born = points.find(p => measureOf(p.value)?.token === change.to)?.date
369 n = Math.max(1, reversalsOf(history, change.unit))
370 }
371 return { decision, prop: change.prop, label, back: change.to, decided: change.from, unit: change.unit, history, dates, born, n }
372 }
373 return undefined
374}
375
376// ---- privacy ----
377
378/** Drops the owner's surname everywhere (inside paths and user names too) and shortens absolute paths to their file name. */
379export function redactorOf(surnames: readonly string[]): (text: string) => string {
380 const names = surnames.filter(t => t.length >= 3)
381 const re = names.length === 0 ? undefined : new RegExp(`(${names.map(escape).join('|')})`, 'gi')
382 return text => {
383 let out = text.replace(/@?"?(\/(?:Users|home|private|tmp|var)\/[^\s"'`)]+)"?/g, (_, path: string) => path.split('/').filter(Boolean).pop() ?? '')
384 if (re !== undefined) out = out.replace(re, '')
385 return out.replace(/\s{2,}/g, ' ').trim()
386 }
387}
388hooks/desktop.ts 166 lines1// The desktop ghost: one inline SVG, pixel-crisp, sized for the Desktop app's
2// light band card. One SVG unit is one sprite pixel; the scale sets the size
3// (11 px a pixel: the ghost is 132 px wide; 6 when the band is really narrow).
4//
5// Animation is SMIL only (the Svg is drawn with isInteractive): a "boo"
6// entrance rising from below the band's edge while the ghost haunts, a slow
7// float, a wavy hem, and on level 3 one shake and a rattling chain.
8
9// The character (pixels, palette, names) comes from sprite.ts; this file only
10// lays it out as SVG, with the ghost's props (halo, chain, gravestone).
11import { bodyOf, FACE_INK, HEMS, LOOK, NAMES, STYLE } from './sprite.ts'
12import type { Level } from './sprite.ts'
13
14/** A gravestone beside the ghost, for the tombstone line. */
15export type Tomb = { name: string; born: string; died: string }
16
17export type DesktopArt = { source: string; width: number; height: number; scale: number }
18
19/**
20 * Below this many columns the band is narrow and the art draws compact. The Desktop band
21 * spans the prompt's width (900 px and more), so only a really narrow band goes compact.
22 */
23export const COMPACT_COLUMNS = 50
24/** 11 px a sprite pixel: the ghost is 132 px wide. */
25export const SCALE = 11
26export const COMPACT_SCALE = 6
27/** The Svg element's own limit on `source`. */
28export const SVG_LIMIT = 131072
29
30const GHOST_W = 17
31const GHOST_H = 14
32const TOMB_W = 12
33/** Where the sprite's top-left pixel sits in the canvas. */
34const AT_X = 2
35const AT_Y = 2
36
37/** The face rows (eyes, brows, mouth) of each look, without the legs. */
38const HEAD = (level: Level): readonly string[] => bodyOf(level).slice(0, 5)
39
40const isOn = (rows: readonly string[], x: number, y: number): boolean => rows[y]?.[x] === 'X'
41
42/** Body pixels, the face (holes inside a row's span), and the outline (empty pixels touching the body). */
43function layers(rows: readonly string[]): { body: [number, number][]; face: [number, number][]; outline: [number, number][] } {
44 const body: [number, number][] = []
45 const face: [number, number][] = []
46 const outline: [number, number][] = []
47 for (let y = -1; y <= rows.length; y++) {
48 const row = rows[y] ?? ''
49 const first = row.indexOf('X')
50 const last = row.lastIndexOf('X')
51 for (let x = -1; x <= 12; x++) {
52 if (isOn(rows, x, y)) body.push([x, y])
53 else if (y >= 0 && y < 5 && first >= 0 && x > first && x < last) face.push([x, y])
54 else if (isOn(rows, x - 1, y) || isOn(rows, x + 1, y) || isOn(rows, x, y - 1) || isOn(rows, x, y + 1)) outline.push([x, y])
55 }
56 }
57 return { body, face, outline }
58}
59
60/** Pixels as rects, one per horizontal run, so no seams show between neighbours. */
61function rects(pixels: readonly [number, number][]): string {
62 const rows = new Map<number, number[]>()
63 for (const [x, y] of pixels) rows.set(y, [...(rows.get(y) ?? []), x])
64 let out = ''
65 for (const [y, xs] of [...rows.entries()].sort((a, b) => a[0] - b[0])) {
66 const sorted = [...new Set(xs)].sort((a, b) => a - b)
67 let start = sorted[0] ?? 0
68 let prev = start
69 for (const x of [...sorted.slice(1), Number.NaN]) {
70 if (x === prev + 1) {
71 prev = x
72 continue
73 }
74 out += `<rect x="${start}" y="${y}" width="${prev - start + 1}" height="1"/>`
75 start = x
76 prev = x
77 }
78 }
79 return out
80}
81
82/** The rattling chain on level 3: links hanging from the right arm, swinging fast. */
83const CHAIN =
84 '<g fill="#6E6A86" shape-rendering="crispEdges"><rect x="12" y="3.5" width="0.9" height="0.9"/><rect x="12.2" y="4.5" width="0.9" height="0.9" fill-opacity="0.6"/><rect x="12.4" y="5.5" width="0.9" height="0.9"/><rect x="12.6" y="6.5" width="0.9" height="0.9" fill-opacity="0.6"/><rect x="12.6" y="7.4" width="1.3" height="1.3"/>' +
85 '<animateTransform attributeName="transform" type="rotate" values="-14 12 3.5;12 12 3.5;-8 12 3.5;10 12 3.5;-14 12 3.5" dur="0.5s" repeatCount="indefinite"/></g>'
86
87function tombOf(tomb: Tomb, x0: number): string {
88 const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
89 // 1.5 units at 11 px a unit is 16.5 px; at the compact 6 px it is 9 px, so the compact card drops the stone.
90 const text = (y: number, s: string, size = 1.5) =>
91 `<text x="${x0 + TOMB_W / 2}" y="${y}" font-size="${size}" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, sans-serif" fill="#4A4658">${esc(s)}</text>`
92 return (
93 `<g><path d="M${x0 + 0.5} ${GHOST_H} V5 Q${x0 + 0.5} 1.5 ${x0 + TOMB_W / 2} 1.5 Q${x0 + TOMB_W - 0.5} 1.5 ${x0 + TOMB_W - 0.5} 5 V${GHOST_H} Z" fill="#E4E2EA" stroke="#9C98AC" stroke-width="0.25"/>` +
94 `<rect x="${x0 - 0.5}" y="${GHOST_H - 0.75}" width="${TOMB_W + 1}" height="0.75" fill="#B9C9A4"/>` +
95 text(4.6, 'R.I.P.', 1.6) +
96 text(7.2, tomb.name) +
97 text(9.5, tomb.born) +
98 text(11.6, `– ${tomb.died}`) +
99 '</g>'
100 )
101}
102
103/**
104 * The desktop ghost for a level. `isEntering` adds the one-time "boo"
105 * entrance (the haunting card has it; the after-press card does not).
106 */
107export function desktopArt(args: { level: Level; isEntering: boolean; isCompact: boolean; tomb?: Tomb }): DesktopArt {
108 const { level, isEntering, isCompact } = args
109 const tomb = isCompact ? undefined : args.tomb
110 const look = LOOK[level]
111 const color = STYLE[level].color
112 const scale = isCompact ? COMPACT_SCALE : SCALE
113 const units = GHOST_W + (tomb === undefined ? 0 : TOMB_W + 1)
114 const head = HEAD(level)
115 const frames = HEMS.map(hem => layers([...head, ...hem]))
116 const still = layers([...head, ...(HEMS[0] ?? [])])
117 const swap = (i: number) =>
118 `<animate attributeName="opacity" values="${i === 0 ? '1;0;1' : '0;1;0'}" keyTimes="0;0.5;1" dur="1.2s" calcMode="discrete" repeatCount="indefinite"/>`
119 // The head is the same in both frames; only the hem rows (5 and down) swap.
120 const hemOnly = (px: readonly [number, number][]) => px.filter(([, y]) => y >= 5)
121 const headOnly = (px: readonly [number, number][]) => px.filter(([, y]) => y < 5)
122 const sprite =
123 `<g shape-rendering="crispEdges">` +
124 `<g fill="${look.outlineColor}" fill-opacity="${look.outline}">${rects(headOnly(still.outline))}</g>` +
125 `<g fill="${color}" fill-opacity="${look.body}">${rects(headOnly(still.body))}</g>` +
126 `<g fill="${FACE_INK}" fill-opacity="${look.face}">${rects(still.face)}</g>` +
127 frames
128 .map(
129 (f, i) =>
130 `<g opacity="${i === 0 ? 1 : 0}">${swap(i)}<g fill="${look.outlineColor}" fill-opacity="${look.outline}">${rects(hemOnly(f.outline))}</g><g fill="${color}" fill-opacity="${look.body}">${rects(hemOnly(f.body))}</g></g>`,
131 )
132 .join('') +
133 (level === 3 ? CHAIN : '') +
134 `</g>`
135 const settle = isEntering ? '0.6s' : '0s'
136 const bob = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.75;0 0" dur="2.4s" begin="${settle}" repeatCount="indefinite"/>`
137 const shake =
138 level === 3
139 ? `<animateTransform attributeName="transform" type="translate" values="0 0;-0.5 0;0.5 0;-0.35 0;0.35 0;0 0" dur="0.45s" begin="${settle}" fill="freeze"/>`
140 : ''
141 const entrance = isEntering
142 ? `<animateTransform attributeName="transform" type="translate" values="0 ${GHOST_H};0 0" keySplines="0.2 0.8 0.3 1" calcMode="spline" dur="0.6s" fill="freeze"/><animate attributeName="opacity" values="0;1" dur="0.6s" fill="freeze"/>`
143 : ''
144 const halo =
145 `<defs><radialGradient id="halo"><stop offset="0" stop-color="${look.haloColor}" stop-opacity="${look.halo}"/><stop offset="0.62" stop-color="${look.haloColor}" stop-opacity="${look.halo}"/><stop offset="1" stop-color="${look.haloColor}" stop-opacity="0"/></radialGradient></defs>` +
146 `<ellipse cx="${AT_X + 6}" cy="${AT_Y + 4.5}" rx="${GHOST_W / 2}" ry="${GHOST_H / 2}" fill="url(#halo)"/>`
147 const ghost = `<g>${entrance}<g>${shake}<g>${bob}<g transform="translate(${AT_X} ${AT_Y})">${sprite}</g></g></g></g>`
148 const width = units * scale
149 const height = GHOST_H * scale
150 const source =
151 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${units} ${GHOST_H}" width="${width}" height="${height}">` +
152 halo +
153 ghost +
154 (tomb === undefined ? '' : tombOf(tomb, GHOST_W + 0.5)) +
155 `</svg>`
156 return { source, width, height, scale }
157}
158
159/** What the art says to a reader who cannot see it: the punchline and the fact, and the stone's words. */
160export function altOf(level: Level, lines: readonly string[], tomb?: Tomb): string {
161 const who = NAMES[level]
162 const said = lines.slice(0, 2).filter(Boolean).join(' ')
163 const stone = tomb === undefined ? '' : ` Beside it a gravestone: R.I.P. ${tomb.name}, ${tomb.born} – ${tomb.died}.`
164 return `${who}: ${said}${stone}`
165}
166hooks/sprite.ts 118 lines1// The character, all in one place: the ghost Clawd's pixels, its colours per
2// level, the Desktop palette, its sheet hem, and the names the alt text uses.
3// A character swap edits this file (and, if the new sprite is not 12 pixels
4// wide, the canvas numbers at the top of desktop.ts). Nothing else draws it.
5//
6// Terminal: 12×8 pixels drawn as 12×4 half-block cells, two float frames (the
7// body one pixel up or down). Level 3 is grumpy: slanted brows, a flat mouth.
8
9import type { GhostLevel } from '../types'
10
11/** 1 faint, 2 more solid, 3 solid and grumpy. */
12export type Level = GhostLevel
13
14export const COLUMNS = 12
15export const ROWS = 4
16
17/** Who the alt text says is there, per level. */
18export const NAMES: Record<Level, string> = { 1: 'Faint ghost Clawd', 2: 'Ghost Clawd', 3: 'Grumpy ghost Clawd' }
19
20/** Clawd, as the buddy draws him. */
21export const CALM: readonly string[] = [
22 '.XXXXXXXXXX.',
23 '.XX.XXXX.XX.',
24 'XXXXXXXXXXXX',
25 '.XXXXXXXXXX.',
26 '.XXXXXXXXXX.',
27 '..X.X..X.X..',
28 '..X.X..X.X..',
29]
30
31/** Grumpy: the eyes run into brows slanting down to the middle, and the mouth is a flat line. */
32export const GRUMPY: readonly string[] = [
33 '.XXXXXXXXXX.',
34 '.X.XXXXXX.X.',
35 '.XX.XXXX.XX.',
36 'XXXXXXXXXXXX',
37 '.XXX....XXX.',
38 '..X.X..X.X..',
39 '..X.X..X.X..',
40]
41
42/** The terminal look per level: the ghost's colour (also the punchline's), and whether it is grumpy. */
43export const STYLE: Record<Level, { color: string; rgb: number; opacity: number; isGrumpy: boolean }> = {
44 1: { color: '#D3CFF5', rgb: 0xd3cff5, opacity: 0.45, isGrumpy: false },
45 2: { color: '#AFA9EC', rgb: 0xafa9ec, opacity: 0.75, isGrumpy: false },
46 3: { color: '#7F77DD', rgb: 0x7f77dd, opacity: 1, isGrumpy: true },
47}
48
49// ---- the Desktop palette (desktop.ts draws with these) ----
50
51/** The sheet's hem, two frames that swap so it waves (Desktop). */
52export const HEMS: readonly (readonly string[])[] = [
53 ['.XXXXXXXXXX.', '.X.XX.XX.XX.', '....X....X..'],
54 ['.XXXXXXXXXX.', '.XX.XX.XX.X.', '..X....X....'],
55]
56
57/** The face's ink on Desktop (eyes, brows, mouth). */
58export const FACE_INK = '#3C3489'
59
60/** The punchline's colour on the Desktop card: the ghost's hue, deep enough to read on the light card (5.3, 6.8 and 8.8 to 1 on #EFEFEF). */
61export const DESKTOP_TEXT: Record<Level, string> = { 1: '#5B52C4', 2: '#4A41B0', 3: '#3B3392' }
62
63/** Desktop opacities and outline/halo colours per level. */
64export const LOOK: Record<Level, { body: number; outline: number; outlineColor: string; face: number; halo: number; haloColor: string }> = {
65 1: { body: 0.7, outline: 0.5, outlineColor: '#7F77DD', face: 0.6, halo: 0.55, haloColor: '#DCD7FB' },
66 2: { body: 0.85, outline: 0.6, outlineColor: '#7F77DD', face: 0.75, halo: 0.7, haloColor: '#CFC8FA' },
67 3: { body: 1, outline: 0.95, outlineColor: '#4B4398', face: 1, halo: 0.85, haloColor: '#B7AFF2' },
68}
69
70/** The body rows for a level (7 rows, legs included). */
71export const bodyOf = (level: Level): readonly string[] => (STYLE[level].isGrumpy ? GRUMPY : CALM)
72
73// ---- terminal cells ----
74
75const DEFAULT = 0x01000000
76const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
77
78/** Standard padded base64 (the es2023 lib has no Uint8Array#toBase64). */
79export function base64(bytes: Uint8Array): string {
80 let out = ''
81 for (let i = 0; i < bytes.length; i += 3) {
82 const a = bytes[i] ?? 0
83 const b = bytes[i + 1]
84 const c = bytes[i + 2]
85 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
86 out += (B64[(n >> 18) & 63] ?? '') + (B64[(n >> 12) & 63] ?? '')
87 out += b === undefined ? '=' : (B64[(n >> 6) & 63] ?? '')
88 out += c === undefined ? '=' : (B64[n & 63] ?? '')
89 }
90 return out
91}
92
93/** The sprite's pixels for a level and a float frame (0: up, 1: down), 8 rows. */
94export function pixelsOf(level: Level, frame: 0 | 1): string[] {
95 const body = bodyOf(level)
96 const blank = '.'.repeat(COLUMNS)
97 const rows = frame === 0 ? [...body, blank] : [blank, ...body]
98 return rows.slice(0, ROWS * 2)
99}
100
101/** Half-block cells, `COLUMNS * ROWS` u32 triplets, base64 (RasterProps). */
102export function cellsOf(level: Level, frame: 0 | 1): string {
103 const px = pixelsOf(level, frame)
104 const color = STYLE[level].rgb
105 const words: number[] = []
106 for (let r = 0; r < px.length; r += 2) {
107 const top = px[r] ?? ''
108 const bottom = px[r + 1] ?? ''
109 for (let c = 0; c < COLUMNS; c++) {
110 const isTop = top[c] === 'X'
111 const isBottom = bottom[c] === 'X'
112 const glyph = isTop && isBottom ? 0x2588 : isTop ? 0x2580 : isBottom ? 0x2584 : 0x20
113 words.push(glyph, isTop || isBottom ? color : DEFAULT, DEFAULT)
114 }
115 }
116 return base64(new Uint8Array(Uint32Array.from(words).buffer))
117}
118types/index.d.ts 43 lines1/** How solid the ghost is: 1 faint, 2 more solid, 3 solid and grumpy. */
2export type GhostLevel = 1 | 2 | 3
3
4/** The ghost the band shows: three lines and two buttons, or one short line after a press. */
5export type GhostCard = {
6 /** Unique per haunting; a timer that hides an after-press line checks it. */
7 id: string
8 /** The file, relative to the project root. */
9 file: string
10 /** `swing`: the edit brings back a value a decision replaced. `history`: the file has history. `plain`: one fallback line. */
11 kind: 'swing' | 'history' | 'plain'
12 level: GhostLevel
13 /** Which reversal this is on the same system. */
14 n: number
15 /** Punchline, fact, consequence (or the after-press line alone). */
16 lines: string[]
17 buttons: [string, string]
18 mode: 'haunt' | 'after'
19 /** How many more ghosts the file has (`/ghost` lists them). */
20 more: number
21 /** The facts button 2 hands to Claude on a swing. */
22 overrule?: { old: string; new: string; date: string; decision: string; thing: string }
23 /** The tombstone line's gravestone (desktop): the value, when it first landed, when it was decided away. */
24 tomb?: { name: string; born: string; died: string }
25 /** The placeholders known for this haunting, for the after-press lines. */
26 vars: Record<string, string>
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'clawd-ghost': {
32 /** The ghost on screen; null when dismissed or none. */
33 current: GhostCard | null
34 /** Files whose ghost was already shown this session (relative paths): no repeats. */
35 shown: string[]
36 /** Files this session edited (relative paths), for `/ghost`. */
37 touched: string[]
38 /** A note for Claude after the owner overruled a ghost; rides the next tool result. */
39 pendingNote: string | null
40 }
41 }
42}
43