SLOPSHOPPER

receipts

A ledger of what Claude touched this session: files created and edited, commands run and whether they failed, the git diffstat after each turn. /receipts…

neworiginalpanespinnerguardcommand
v0.1.0MITupdated 2026-10-06ryx2/slopshopper/mods/receipts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · receipts
│ ┃ Receipts ✕ › fix the failing auth test and add an audit log call │ ┃ ▤ receipts 3 files · 4 commands · 1 failed │ ┃ 2 files changed, 14 insertions(+), 3 ⏺ Read(src/auth.ts) │ ┃ deletions(-) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Files ⎿ Added 2 lines, removed 1 line │ ┃ mod /work/app/src/auth.ts ⏺ Bash(bun test) │ ┃ new /work/app/src/audit.ts ⎿ 3 pass, 1 fail │ ┃ new /work/app/src/cache.ts │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Commands │ ┃ ✗ bun test ✻ Worked for 42s · done 4:20 PM │ ┃ ✓ git status --porcelain │ ┃ ✓ rm -rf build && git push --force origin ma › /receipts │ ┃ ✓ cat .env ⎿ receipts: ## Session receipts │ ┃ ⎿ receipts: │ ┃ [ Copy as Markdown ] [ Clear ] [ Close ] ⎿ receipts: 1 turn · 3 files · 4 commands · 2 files changed, 14 in │ ⎿ receipts: │ ⎿ receipts: ### Files │ ⎿ receipts: - modified `/work/app/src/auth.ts` │ │ ✻ Thinking · 3 files · 4 cmds… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Receipts
▤ receipts 3 files · 4 commands · 1 failed 2 files changed, 14 insertions(+), 3 deletions(-) Files mod /work/app/src/auth.ts new /work/app/src/audit.ts new /work/app/src/cache.ts Commands ✗ bun test ✓ git status --porcelain ✓ rm -rf build && git push --force origin main ✓ cat .env [ Copy as Markdown ] [ Clear ] [ Close ]
README

receipts

A ledger of what Claude touched this session.

 ✻ Thinking · 2 files · 3 cmds…
╭──────────────────────────────────────────────╮
│ ▤ receipts  2 files · 3 commands · 1 failed  │
│  2 files changed, 14 insertions(+), 3 del…   │
│                                              │
│ Files                                        │
│ new src/audit.ts                             │
│ mod src/auth.ts ×2                           │
│                                              │
│ Commands                                     │
│ ✗ bun test                                   │
│ ✓ git status --porcelain                     │
│                                              │
│ [ Copy as Markdown ]  [ Clear ]  [ Close ]   │
╰──────────────────────────────────────────────╯
HookWhat it does
tool.call on Write and EditRecords the file, counting whole writes and in-place edits separately. A refused or failed call is not recorded.
tool.call on BashRecords the command and whether it failed.
turn.completeRuns git diff --shortstat so the ledger has line counts.
ui.render on SpinnerAdds · 2 files · 3 cmds after the spinner's word while Claude works.
/receiptsPrints the ledger as Markdown and opens the pane. /receipts copy puts it on the clipboard instead.

The Markdown is written for a pull request description or a standup note:

## Session receipts

3 turns · 2 files · 5 commands · 2 files changed, 14 insertions(+), 3 deletions(-)

### Files
- new `src/audit.ts`
- modified `src/auth.ts` (2 edits)

### Commands
- ✗ `bun test`
- ✓ `git status --porcelain`

The ledger lives in $.state, so it survives a hot reload of the mod and resets with /clear.

Install

claude plugin marketplace add ryx2/slopshopper
claude plugin install receipts@slopshopper

Tested with Claude Code 2.1.289. claude plugin validate and claude plugin test pass. The only process it starts is git diff --shortstat.

Source 4 files
hooks/register.tsx 194 lines
1// receipts: a ledger of what Claude touched this session.
2//
3// tool.call (Write, Edit): records the file; (Bash): records the command and
4//   whether it failed.
5// turn.complete: reads `git diff --shortstat` so the ledger has line counts.
6// ui.render (Spinner): adds "· 2 files · 3 cmds" after the spinner's word.
7// /receipts prints the ledger as Markdown and opens a pane with Copy, Clear
8//   and Close.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { CommandReceipt, FileReceipt } from '../types'
14
15const PANE = 'receipts'
16const MAX_COMMANDS = 150
17
18const files = atom({ plugin: 'receipts', key: 'files' } as const, [])
19const commands = atom({ plugin: 'receipts', key: 'commands' } as const, [])
20const turnCount = atom({ plugin: 'receipts', key: 'turnCount' } as const, 0)
21const diffstat = atom({ plugin: 'receipts', key: 'diffstat' } as const, '')
22const turnFiles = atom({ plugin: 'receipts', key: 'turnFiles' } as const, 0)
23const turnCommands = atom({ plugin: 'receipts', key: 'turnCommands' } as const, 0)
24
25export const register: Register = on => {
26  on('session.start', async ($, e, next) => {
27    try {
28      await $.command.register({ name: 'receipts', description: 'Everything Claude touched this session, as Markdown', argumentHint: '[copy]' })
29    } catch {
30      // the name is taken; the pane can still be opened by other means
31    }
32    return next(e)
33  })
34
35  on('turn.start', async ($, e, next) => {
36    await update($, turnCount, n => n + 1)
37    await update($, turnFiles, () => 0)
38    await update($, turnCommands, () => 0)
39    return next(e)
40  })
41
42  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
43    const ran = await next(e)
44    if (ran.deny !== undefined || ran.isError === true) return ran
45    const path = String(e.file_path ?? '')
46    const turn = await read($, turnCount)
47    const isWrite = e.tool === 'Write'
48    await update($, files, list => {
49      const i = list.findIndex(f => f.path === path)
50      if (i < 0) return [...list, { path, writes: isWrite ? 1 : 0, edits: isWrite ? 0 : 1, turn }]
51      const cur = list[i]!
52      const nextEntry: FileReceipt = { ...cur, writes: cur.writes + (isWrite ? 1 : 0), edits: cur.edits + (isWrite ? 0 : 1) }
53      return [...list.slice(0, i), nextEntry, ...list.slice(i + 1)]
54    })
55    await update($, turnFiles, n => n + 1)
56    $.ui.invalidate('ui.render')
57    return ran
58  })
59
60  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
61    const ran = await next(e)
62    if (ran.deny !== undefined) return ran
63    const turn = await read($, turnCount)
64    const receipt: CommandReceipt = { command: String(e.command ?? '').replace(/\s+/g, ' ').trim().slice(0, 160), isError: ran.isError === true, turn }
65    await update($, commands, list => [...list, receipt].slice(-MAX_COMMANDS))
66    await update($, turnCommands, n => n + 1)
67    $.ui.invalidate('ui.render')
68    return ran
69  })
70
71  on('turn.complete', async ($, e, next) => {
72    const result = await next(e)
73    if (e.agentId !== undefined) return result
74    await refreshDiffstat($)
75    return result
76  })
77
78  on('command.run', { command: 'receipts' }, async ($, e) => {
79    const text = await ledger($)
80    if (/\bcopy\b/i.test(e.args)) {
81      const copied = await $.ui.copy({ text })
82      return { text: copied.isCopied ? 'receipts: copied the ledger to the clipboard.' : `receipts: could not copy (${copied.reason}); here it is instead.\n\n${text}` }
83    }
84    await $.ui.open({ id: PANE, title: 'Receipts', focus: true, closeOnEscape: true })
85    return { text }
86  })
87
88  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
89    const f = await read($, turnFiles)
90    const c = await read($, turnCommands)
91    if (f === 0 && c === 0) return next(e)
92    const parts = [f > 0 ? `${f} file${f === 1 ? '' : 's'}` : '', c > 0 ? `${c} cmd${c === 1 ? '' : 's'}` : ''].filter(Boolean)
93    return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix ?? ''} · ${parts.join(' · ')}` } })
94  })
95
96  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
97    const { Box, Text, Button } = $.ui.resolve(e)
98    const fl = await read($, files)
99    const cl = await read($, commands)
100    const stat = await read($, diffstat)
101    const width = e.props.bodyColumns
102    const failed = cl.filter(c => c.isError).length
103    return (
104      <Box flexDirection="column">
105        <Box flexDirection="row" columnGap={2}>
106          <Text bold>▤ receipts</Text>
107          <Text dimColor>
108            {fl.length} file{fl.length === 1 ? '' : 's'} · {cl.length} command{cl.length === 1 ? '' : 's'}
109            {failed > 0 ? ` · ${failed} failed` : ''}
110          </Text>
111        </Box>
112        {stat !== '' && <Text color="cyan">{stat}</Text>}
113        {fl.length === 0 && cl.length === 0 && <Text dimColor>Nothing yet. Files and commands appear as Claude works.</Text>}
114        {fl.length > 0 && (
115          <Box flexDirection="column" marginTop={1}>
116            <Text bold>Files</Text>
117            {fl.slice(-14).map(f => (
118              <Box flexDirection="row" columnGap={1}>
119                <Text color={f.writes > 0 && f.edits === 0 ? 'green' : 'yellow'}>{f.writes > 0 && f.edits === 0 ? 'new' : 'mod'}</Text>
120                <Text wrap="truncate-start">{shortPath(f.path, width - 12)}</Text>
121                <Text dimColor>{f.edits > 1 ? `×${f.edits}` : ''}</Text>
122              </Box>
123            ))}
124          </Box>
125        )}
126        {cl.length > 0 && (
127          <Box flexDirection="column" marginTop={1}>
128            <Text bold>Commands</Text>
129            {cl.slice(-8).map(c => (
130              <Box flexDirection="row" columnGap={1}>
131                <Text color={c.isError ? 'red' : 'green'}>{c.isError ? '✗' : '✓'}</Text>
132                <Text dimColor={c.isError ? undefined : true} wrap="truncate-end">
133                  {c.command.slice(0, Math.max(10, width - 4))}
134                </Text>
135              </Box>
136            ))}
137          </Box>
138        )}
139        <Box flexDirection="row" columnGap={2} marginTop={1}>
140          <Button key="copy" label="Copy as Markdown" hotkey="c" variant="primary" onPress={async () => void (await $.ui.copy({ text: await ledger($) }))} />
141          <Button key="clear" label="Clear" hotkey="x" onPress={async () => clear($)} />
142          <Button key="close" label="Close" hotkey="q" onPress={() => $.ui.close({ id: PANE })} />
143        </Box>
144      </Box>
145    )
146  })
147}
148
149async function refreshDiffstat($: EngineInterface): Promise<void> {
150  try {
151    const out = await $.process.run(['git', 'diff', '--shortstat'], { timeoutMs: 5000 })
152    if (out.exitCode === 0) await update($, diffstat, () => out.stdout.trim())
153  } catch {
154    // not a git repository, or git is slow; the ledger just has no diffstat
155  }
156}
157
158async function clear($: EngineInterface): Promise<void> {
159  await update($, files, () => [])
160  await update($, commands, () => [])
161  await update($, diffstat, () => '')
162}
163
164/** The ledger as Markdown. */
165async function ledger($: EngineInterface): Promise<string> {
166  const fl = await read($, files)
167  const cl = await read($, commands)
168  const stat = await read($, diffstat)
169  const turns = await read($, turnCount)
170  if (fl.length === 0 && cl.length === 0) return 'receipts: nothing touched yet this session.'
171  const lines: string[] = [`## Session receipts`, '', `${turns} turn${turns === 1 ? '' : 's'} · ${fl.length} file${fl.length === 1 ? '' : 's'} · ${cl.length} command${cl.length === 1 ? '' : 's'}${stat ? ` · ${stat}` : ''}`]
172  if (fl.length) {
173    lines.push('', '### Files')
174    for (const f of fl) lines.push(`- ${f.writes > 0 && f.edits === 0 ? 'new' : 'modified'} \`${f.path}\`${f.edits > 1 ? ` (${f.edits} edits)` : ''}`)
175  }
176  if (cl.length) {
177    lines.push('', '### Commands')
178    for (const c of cl.slice(-40)) lines.push(`- ${c.isError ? '✗' : '✓'} \`${c.command}\``)
179  }
180  return lines.join('\n')
181}
182
183export function shortPath(path: string, room: number): string {
184  if (path.length <= room) return path
185  const parts = path.split('/')
186  let out = parts[parts.length - 1] ?? path
187  for (let i = parts.length - 2; i >= 0; i--) {
188    const next = parts[i] + '/' + out
189    if (next.length + 1 > room) return '…/' + out
190    out = next
191  }
192  return out
193}
194
tests/register.test.ts 79 lines
1import { describe, expect, test } from 'claude-code/testing'
2
3const PANE = {
4  plugin: 'receipts',
5  component: 'Pane',
6  requestId: 'receipts',
7  viewport: { columns: 120, rows: 40 },
8  props: { title: 'Receipts', isFocused: true, bodyColumns: 60, placement: 'dock', scroll: { offset: 0, bodyRows: 20 }, view: {} },
9} as const
10
11const SPINNER = {
12  plugin: 'receipts',
13  component: 'Spinner',
14  requestId: 'main',
15  viewport: { columns: 120, rows: 40 },
16  props: { word: 'Thinking', message: null, suffix: '', mode: 'thinking' },
17} as const
18
19describe('register', () => {
20  test('records files and commands and prints them as Markdown', async ($, on) => {
21    on('tool.call', ($, e) => (e.tool === 'Bash' && String(e.command).includes('test') ? { result: 'fail', isError: true } : { result: 'ok' }))
22    on('turn.start', ($, e) => ({ turnId: e.turnId }))
23    on('turn.complete', () => ({ text: '' }))
24    on('process.run', () => ({ value: { exitCode: 0, stdout: ' 2 files changed, 14 insertions(+), 3 deletions(-)\n', stderr: '' } }))
25    on('ui.open', () => ({ value: { isPlaced: true } }))
26
27    await $.turn.start({ turnId: 't1' })
28    await $.tool.call({ tool: 'Write', file_path: '/work/app/src/audit.ts', content: 'export {}' })
29    await $.tool.call({ tool: 'Edit', file_path: '/work/app/src/auth.ts', old_string: 'a', new_string: 'b' })
30    await $.tool.call({ tool: 'Edit', file_path: '/work/app/src/auth.ts', old_string: 'b', new_string: 'c' })
31    await $.tool.call({ tool: 'Bash', command: 'bun test' })
32    await $.tool.call({ tool: 'Bash', command: 'git status' })
33    await $.turn.complete({ turnId: 't1', answer: 'done', durationMs: 1000, isAborted: false, usage: null })
34
35    const out = await $.command.run({ command: 'receipts', args: '' })
36    expect(out.text).toContain('1 turn · 2 files · 2 commands · 2 files changed')
37    expect(out.text).toContain('- new `/work/app/src/audit.ts`')
38    expect(out.text).toContain('- modified `/work/app/src/auth.ts` (2 edits)')
39    expect(out.text).toContain('- ✗ `bun test`')
40    expect(out.text).toContain('- ✓ `git status`')
41  })
42
43  test('the spinner counts the turn, and the pane lists and clears', async ($, on) => {
44    on('tool.call', () => ({ result: 'ok' }))
45    on('turn.start', ($, e) => ({ turnId: e.turnId }))
46    on('ui.render', ($, e) => ({ type: 'Text', props: {}, children: [`${e.props.word}${e.props.suffix}`] }))
47
48    await $.turn.start({ turnId: 't1' })
49    await $.tool.call({ tool: 'Write', file_path: '/work/app/a.ts', content: '' })
50    await $.tool.call({ tool: 'Bash', command: 'ls' })
51    const spinner = await $.ui.mount({ ...SPINNER, surface: 'terminal' })
52    expect(await spinner.find({ type: 'Text', text: /1 file · 1 cmd/ })).toBeDefined()
53    await spinner.unmount()
54
55    for (const surface of ['terminal', 'desktop'] as const) {
56      const ui = await $.ui.mount({ ...PANE, surface })
57      expect(await ui.find({ type: 'Text', text: /1 file · 1 command/ })).toBeDefined()
58      await ui.unmount()
59    }
60    const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
61    await ui.press({ key: 'clear' })
62    expect(await ui.find({ type: 'Text', text: /Nothing yet/ })).toBeDefined()
63    await ui.unmount()
64  })
65
66  test('/receipts copy puts the ledger on the clipboard', async ($, on) => {
67    on('tool.call', () => ({ result: 'ok' }))
68    let copied = ''
69    on('ui.copy', ($, e) => {
70      copied = e.text
71      return { value: { isCopied: true } }
72    })
73    await $.tool.call({ tool: 'Write', file_path: '/work/app/b.ts', content: '' })
74    const out = await $.command.run({ command: 'receipts', args: 'copy' })
75    expect(out.text).toContain('copied')
76    expect(copied).toContain('## Session receipts')
77  })
78})
79
types/index.d.ts 30 lines
1export type FileReceipt = {
2  path: string
3  /** Times the file was written whole. */
4  writes: number
5  /** Times it was edited in place. */
6  edits: number
7  /** The turn it was first touched in. */
8  turn: number
9}
10
11export type CommandReceipt = {
12  command: string
13  isError: boolean
14  turn: number
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    receipts: {
20      files: FileReceipt[]
21      commands: CommandReceipt[]
22      turnCount: number
23      /** `git diff --shortstat` after the last turn, or ''. */
24      diffstat: string
25      turnFiles: number
26      turnCommands: number
27    }
28  }
29}
30
README.md 60 lines
1# receipts
2
3A ledger of what Claude touched this session.
4
5```text
6 ✻ Thinking · 2 files · 3 cmds…
7```
8
9```text
10╭──────────────────────────────────────────────╮
11│ ▤ receipts  2 files · 3 commands · 1 failed  │
12│  2 files changed, 14 insertions(+), 3 del…   │
13│                                              │
14│ Files                                        │
15│ new src/audit.ts                             │
16│ mod src/auth.ts ×2                           │
17│                                              │
18│ Commands                                     │
19│ ✗ bun test                                   │
20│ ✓ git status --porcelain                     │
21│                                              │
22│ [ Copy as Markdown ]  [ Clear ]  [ Close ]   │
23╰──────────────────────────────────────────────╯
24```
25
26| Hook | What it does |
27| --- | --- |
28| `tool.call` on `Write` and `Edit` | Records the file, counting whole writes and in-place edits separately. A refused or failed call is not recorded. |
29| `tool.call` on `Bash` | Records the command and whether it failed. |
30| `turn.complete` | Runs `git diff --shortstat` so the ledger has line counts. |
31| `ui.render` on `Spinner` | Adds `· 2 files · 3 cmds` after the spinner's word while Claude works. |
32| `/receipts` | Prints the ledger as Markdown and opens the pane. `/receipts copy` puts it on the clipboard instead. |
33
34The Markdown is written for a pull request description or a standup note:
35
36```markdown
37## Session receipts
38
393 turns · 2 files · 5 commands · 2 files changed, 14 insertions(+), 3 deletions(-)
40
41### Files
42- new `src/audit.ts`
43- modified `src/auth.ts` (2 edits)
44
45### Commands
46- ✗ `bun test`
47- ✓ `git status --porcelain`
48```
49
50The ledger lives in `$.state`, so it survives a hot reload of the mod and resets with `/clear`.
51
52## Install
53
54```bash
55claude plugin marketplace add ryx2/slopshopper
56claude plugin install receipts@slopshopper
57```
58
59Tested with Claude Code 2.1.289. `claude plugin validate` and `claude plugin test` pass. The only process it starts is `git diff --shortstat`.
60