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…

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 ] │
╰──────────────────────────────────────────────╯
| Hook | What it does |
|---|---|
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. |
tool.call on Bash | Records the command and whether it failed. |
turn.complete | Runs git diff --shortstat so the ledger has line counts. |
ui.render on Spinner | Adds · 2 files · 3 cmds after the spinner's word while Claude works. |
/receipts | Prints 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.
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.
hooks/register.tsx 194 lines1// 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}
194tests/register.test.ts 79 lines1import { 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})
79types/index.d.ts 30 lines1export 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}
30README.md 60 lines1# 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