SLOPSHOPPER

clawd-ghost

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…

newbandguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-ghost
› fix the failing auth test and add an audit log call ● clawd-ghost: clawd-ghost: could not start node: TypeError: undefined is not an object (evaluating 'ended.code') ● clawd-ghost: clawd-ghost: could not start /opt/homebrew/bin/node: TypeError: undefined is not an object (evaluating 'ended.code') ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ghost ⎿ clawd-ghost: clawd-ghost: no index yet for this project (it builds in the background at session start). ● clawd-ghost: clawd-ghost: could not start /usr/local/bin/node: TypeError: undefined is not an object (evaluating 'ended.code') ● clawd-ghost: clawd-ghost: could not start node: TypeError: undefined is not an object (evaluating 'ended.code') ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clawd-ghost: the ghost of sessions 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. -->

What you see

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]

  • The ghost gets more solid, and on the third reversal of the same value grumpy, each time a value swings back. The count comes from that value's history in git.
  • 1 dismisses it with a parting line. Claude is told nothing.
  • 2 on a swing asks Claude to record your choice in your decisions file, through your project's normal process. The mod itself never writes a file. On a history-only ghost, 2 just says "Noted".
  • After the edit runs, Claude gets the same facts as a short note, so it can tell you which decision an edit reverses.
  • It stays quiet when the file has no history, when the history is about something the edit doesn't touch (for example, a colour decision during a font-size edit), and for files it has already haunted this session.

It only watches. It never blocks, denies or rewrites an edit.

Commands and keys

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

What it reads

Everything stays on your machine. The mod makes no network calls.

  1. Your decisions file, if you keep one. By default it's 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.

  1. Git history: 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.
  2. Your own past prompts in this project's Claude Code transcripts (~/.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.

Settings

OptionDefaultWhat it does
decisionsFiledesign/decisions.mdYour decisions log, relative to the repository root. Change it in /config.

What the mod reaches

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.

Requirements

  • Claude Code 2.1.287 or newer (CLI). The Desktop app 2.1.286 has been seen working.
  • The ghost is drawn in the terminal and in the Desktop app's Code tab. Elsewhere the hooks still run, but nothing is drawn.
  • Node 22.6 or newer for the index builder.

Install

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

Turn it off

Run /plugin and disable clawd-ghost, or claude plugin uninstall clawd-ghost@clawd-mods.

Develop

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.

Licence

MIT. See LICENSE.

Source 6 files
hooks/register.tsx 409 lines
1// 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
409
hooks/copy.ts 264 lines
1// 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
264
hooks/ghost.ts 388 lines
1// 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}
388
hooks/desktop.ts 166 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
166
hooks/sprite.ts 118 lines
1// 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}
118
types/index.d.ts 43 lines
1/** 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