Gives Claude a register_assumption tool and shows the turn's assumptions, decisions and considered-but-not-done items above the prompt

Claude gets a tool, register_assumption. While it works it uses the tool to write down three kinds of thing:
When the turn ends, the list shows above the prompt, grouped by kind:
Ledger for this turn [ Dismiss ]
Considered, not done (1)
· Add retry on 429 (out of scope) [ Do it ]
Assumed (1)
· Node 20 is the runtime
Do it sends Claude a follow-up asking it to do that item, or to say in one line why it should stay undone. Dismiss clears the band. A new turn clears it too.
| Command | What it does |
|---|---|
/assumptions | Prints every entry from this session, grouped by kind, with the turn each came from. |
/assumptions write [path] | Appends the session's entries to DECISIONS-log.md (or path) in the working directory. Nothing is written to disk unless you run this. |
/assumptions off | Stops recording and moves the tool behind ToolSearch, so the model stops seeing it. Remembered across sessions. |
/assumptions on | Turns it back on. |
The mod makes no model calls of its own. Its cost is what the model spends using the tool:
| When | Tokens | Notes |
|---|---|---|
| Every request | about 200 input tokens | The tool's name, description and schema in the tool list. The description never changes, so after the first request this is a prompt-cache read (about a tenth of the price). |
| Each entry Claude records | about 40 to 80 output tokens, plus a 2-token result | One tool call. Claude often batches it with other tool calls in the same step; when it doesn't, the step after it re-reads the context from cache. |
| A turn where nothing was worth noting | 0 extra | The tool's description tells the model to skip the obvious. |
/assumptions off and on | one cache rebuild each | Changing whether the tool is listed changes the tool list, which is part of the cached prefix. |
| Do it | one new turn | You are asking Claude to do more work, so that turn costs what the work costs. |
The band and the session log cost nothing in tokens. /assumptions prints its list as a command-output row in the transcript; whether the model reads that row on the next turn, as it does other command output, was not checked.
{await next(e)}), and it steps aside while a survey holds the band or a turn is running.$.state.get({ plugin: 'assumption-ledger', key: 'pending' }), which holds the current turn's entries until the next turn starts; shown is only filled once this mod's own turn.complete hook has run, so another mod's turn.complete may see it empty) and put the considered-not-done items in its question. That is cheaper and more honest than asking the fork to guess what was skipped./assumptions off through $.command.run: that stops recording, moves the tool behind ToolSearch and remembers the choice. Writing { plugin: 'assumption-ledger', key: 'isOff' } through state.set alone only stops recording: the tool stays in the list (its tool.describe answer is cached until invalidated) and the choice is not remembered./assumptions write gives a file another Claude or a dashboard can read.claude --plugin-dir /path/to/assumption-ledger
Or copy the folder into your mods folder. Check it before loading:
claude plugin validate /path/to/assumption-ledger
claude plugin test /path/to/assumption-ledger
Built and checked against Claude Code 2.1.286.
hooks/register.tsx 312 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LedgerEntry, LedgerKind } from '../types'
5
6const TOOL = 'mcp__assumption-ledger__register_assumption'
7const COMMAND = 'assumptions'
8const LOG_FILE = 'DECISIONS-log.md'
9const KEEP_SESSIONS = 20
10const MAX_TEXT = 400
11const PER_GROUP = 3
12
13// Byte-identical on every turn: the tool list is part of the cached prefix,
14// so any change here costs a cache rebuild for everyone using the mod.
15const DESCRIPTION =
16 'Add one line to the ledger the user reads when your turn ends. Call it as it happens, one item per call, when you: ' +
17 "assume something you did not check (kind 'assumption'); choose between real alternatives (kind 'decision'); " +
18 "or consider a better or more complete fix and decide not to do it now (kind 'considered-not-done'), the one that matters most. " +
19 'Skip the obvious. Keep text to one sentence.'
20
21const SCHEMA = {
22 type: 'object',
23 properties: {
24 kind: { type: 'string', enum: ['assumption', 'decision', 'considered-not-done'] },
25 text: { type: 'string', description: 'One sentence.' },
26 why: { type: 'string', description: 'Optional reason, one clause.' },
27 },
28 required: ['kind', 'text'],
29 additionalProperties: false,
30}
31
32const KINDS: { kind: LedgerKind; title: string; color: string }[] = [
33 { kind: 'considered-not-done', title: 'Considered, not done', color: 'yellow' },
34 { kind: 'assumption', title: 'Assumed', color: 'cyan' },
35 { kind: 'decision', title: 'Decided', color: 'green' },
36]
37
38const pending = atom({ plugin: 'assumption-ledger', key: 'pending' } as const, [] as LedgerEntry[])
39const shown = atom({ plugin: 'assumption-ledger', key: 'shown' } as const, [] as LedgerEntry[])
40const turn = atom({ plugin: 'assumption-ledger', key: 'turn' } as const, 0)
41const isOff = atom({ plugin: 'assumption-ledger', key: 'isOff' } as const, false)
42
43const isKind = (value: unknown): value is LedgerKind =>
44 value === 'assumption' || value === 'decision' || value === 'considered-not-done'
45
46const clip = (text: string) => (text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1)}…` : text)
47
48const sameEntry = (a: LedgerEntry, b: LedgerEntry) =>
49 a.kind === b.kind && a.text === b.text && a.turn === b.turn
50
51const quiet = <T, F = undefined>($: EngineInterface, what: string, p: Promise<T>, fallback?: F): Promise<T | F> =>
52 p.catch((err: unknown) => {
53 $.ui.log(`assumption-ledger: ${what} failed: ${err instanceof Error ? err.message : String(err)}`)
54 return fallback as F
55 })
56
57// Store writes are read-modify-write; parallel tool calls would otherwise race.
58let writes: Promise<unknown> = Promise.resolve()
59const serial = <T,>(work: () => Promise<T>) => {
60 const run = writes.then(work, work)
61 writes = run.catch(() => undefined)
62 return run
63}
64
65const logKey = (sessionId: string) => `log:${sessionId}`
66
67const readLog = async ($: EngineInterface) => {
68 const id = await quiet($, 'session id', $.session.id(), '')
69 if (id === '') return []
70 const stored = await quiet($, 'store read', $.store.get(logKey(id)))
71 return Array.isArray(stored) ? (stored as LedgerEntry[]) : []
72}
73
74const appendLog = ($: EngineInterface, entry: LedgerEntry) =>
75 serial(async () => {
76 const id = await $.session.id()
77 const log = await readLog($)
78 await $.store.set(logKey(id), [...log, entry])
79
80 // Keep the store well under its 4 MiB cap: only the last few sessions' logs.
81 const known = await $.store.get('sessions')
82 const sessions = (Array.isArray(known) ? (known as string[]) : []).filter(s => s !== id)
83 const kept = [...sessions, id].slice(-KEEP_SESSIONS)
84 for (const old of sessions.filter(s => !kept.includes(s))) {
85 await $.store.delete(logKey(old))
86 }
87 await $.store.set('sessions', kept)
88 }).catch((err: unknown) => $.ui.log(`assumption-ledger: log write failed: ${String(err)}`))
89
90const followUp = (entry: LedgerEntry) =>
91 `Earlier you considered this and decided not to do it: ${entry.text.replace(/[.\s]+$/, '')}` +
92 (entry.why ? ` (your reason: ${entry.why})` : '') +
93 '. Do it now, or tell me in one line why it should stay undone.'
94
95// Submit the follow-up as a turn of its own; fall back to the prompt box,
96// then the clipboard, so a press never does nothing.
97const doIt = async ($: EngineInterface, entry: LedgerEntry, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) => {
98 try {
99 await update($, shown, list => list.filter(e => !sameEntry(e, entry)))
100 const text = followUp(entry)
101
102 const submitted = await $.prompt.submit({ text, asUser: true }).then(() => true, () => false)
103 if (submitted) return
104
105 const filled = await quiet($, 'prompt fill', $.prompt.fill({ text }), { isFilled: false })
106 if (filled.isFilled) return
107
108 const copied = await quiet($, 'copy', $.ui.copy({ text, surface }), { isCopied: false as const, reason: 'no-surface' as const })
109 $.ui.toast(copied.isCopied ? 'Follow-up copied: paste it into the prompt' : 'Could not send the follow-up: see /assumptions')
110 } catch (err) {
111 $.ui.log(`assumption-ledger: follow-up failed: ${String(err)}`)
112 }
113}
114
115const asMarkdown = (entries: LedgerEntry[]) =>
116 KINDS.map(({ kind, title }) => {
117 const items = entries.filter(e => e.kind === kind)
118 if (items.length === 0) return ''
119 const lines = items.map(e => `- (turn ${e.turn}) ${e.text}${e.why ? ` _because ${e.why}_` : ''}`)
120 return `### ${title}\n${lines.join('\n')}`
121 })
122 .filter(Boolean)
123 .join('\n\n')
124
125const writeLogFile = async ($: EngineInterface, path: string, entries: LedgerEntry[]) => {
126 const id = await $.session.id()
127 const when = new Date(await $.clock.now()).toISOString()
128 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : '# Decisions log\n'
129 await $.fs.write(path, `${before.trimEnd()}\n\n## Session ${id.slice(0, 8)}, ${when}\n\n${asMarkdown(entries)}\n`)
130}
131
132const setOff = async ($: EngineInterface, value: boolean) => {
133 await quiet($, 'state write', update($, isOff, () => value))
134 await quiet($, 'store write', $.store.set('isOff', value))
135 // Moves the tool behind ToolSearch (off) or back into the list (on): one cache rebuild.
136 $.ui.invalidate('tool.describe')
137}
138
139const runCommand = async ($: EngineInterface, args: string) => {
140 const [verb = '', ...rest] = args.trim().split(/\s+/)
141
142 if (verb === 'off' || verb === 'on') {
143 await setOff($, verb === 'off')
144 return { text: verb === 'off' ? 'Assumption ledger off: the tool is moved behind ToolSearch.' : 'Assumption ledger on.' }
145 }
146
147 const entries = await readLog($)
148 if (entries.length === 0) {
149 return { text: 'No assumptions, decisions or skipped fixes recorded this session.' }
150 }
151
152 if (verb === 'write') {
153 const path = rest.join(' ') || LOG_FILE
154 try {
155 await writeLogFile($, path, entries)
156 return { text: `Wrote ${entries.length} entries to ${path}.` }
157 } catch (err) {
158 return { text: `Could not write ${path}: ${err instanceof Error ? err.message : String(err)}` }
159 }
160 }
161
162 return { text: `## Assumption ledger (${entries.length})\n\n${asMarkdown(entries)}` }
163}
164
165export const register: Register = on => {
166 on('session.start', async ($, e, next) => {
167 const stored = await quiet($, 'store read', $.store.get('isOff'))
168 await quiet($, 'state write', update($, isOff, () => stored === true))
169
170 await quiet($, 'tool register', $.tool.register({ name: 'register_assumption', description: DESCRIPTION, inputSchema: SCHEMA }))
171 await quiet(
172 $,
173 'command register',
174 $.command.register({
175 name: COMMAND,
176 description: "This session's assumptions, decisions and skipped fixes",
177 argumentHint: '[write [path] | off | on]',
178 }),
179 )
180
181 return next(e)
182 })
183
184 on('session.end', async ($, e, next) => {
185 if (e.reason === 'clear') {
186 await quiet($, 'reset', Promise.all([update($, pending, () => []), update($, shown, () => []), update($, turn, () => 0)]))
187 }
188
189 return next(e)
190 })
191
192 on('tool.describe', { tool: TOOL }, async ($, e, next) => {
193 const base = await next(e)
194 const off = await read($, isOff)
195
196 return { ...base, description: DESCRIPTION, isDeferred: off }
197 })
198
199 on('tool.call', { tool: TOOL }, async ($, e) => {
200 if (await read($, isOff)) {
201 return { result: 'The ledger is off for this session. Carry on.' }
202 }
203
204 const { kind, text, why } = e as { kind?: unknown; text?: unknown; why?: unknown }
205 if (!isKind(kind) || typeof text !== 'string' || text.trim() === '') {
206 return { deny: "register_assumption needs kind ('assumption' | 'decision' | 'considered-not-done') and a non-empty text." }
207 }
208
209 const entry: LedgerEntry = {
210 kind,
211 text: clip(text.trim()),
212 ...(typeof why === 'string' && why.trim() !== '' ? { why: clip(why.trim()) } : {}),
213 turn: await read($, turn),
214 }
215
216 await quiet($, 'state write', update($, pending, list => [...list, entry]))
217 void appendLog($, entry)
218
219 return { result: 'Noted.' }
220 })
221
222 on('turn.start', async ($, e, next) => {
223 await quiet(
224 $,
225 'state write',
226 Promise.all([update($, turn, n => n + 1), update($, pending, () => []), update($, shown, () => [])]),
227 )
228
229 return next(e)
230 })
231
232 on('turn.complete', async ($, e, next) => {
233 const result = await next(e)
234
235 if (e.agentId === undefined) {
236 const entries = await read($, pending)
237 if (entries.length > 0) {
238 await quiet($, 'state write', update($, shown, () => entries))
239 }
240 }
241
242 return result
243 })
244
245 on('command.run', { command: COMMAND }, async ($, e) => {
246 try {
247 return await runCommand($, e.args)
248 } catch (err) {
249 return { text: `assumption-ledger: ${err instanceof Error ? err.message : String(err)}` }
250 }
251 })
252
253 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
254 const entries = await read($, shown)
255
256 if (e.props.hasSurvey || e.props.isWorking || entries.length === 0) {
257 return next(e)
258 }
259
260 const { Box, Button, Text } = $.ui.resolve(e)
261 const below = await next(e)
262
263 const groups = KINDS.map(({ kind, title, color }) => {
264 const items = entries.filter(x => x.kind === kind)
265 if (items.length === 0) return null
266 const more = items.length - PER_GROUP
267
268 return (
269 <Box key={kind} flexDirection="column">
270 <Text color={color} bold>
271 {title} ({items.length})
272 </Text>
273 {items.slice(0, PER_GROUP).map((item, i) => (
274 <Box key={`${kind}-${i}`}>
275 <Text wrap="truncate-end">
276 <Text> · {item.text}</Text>
277 {item.why ? <Text dimColor> ({item.why})</Text> : null}
278 </Text>
279 {kind === 'considered-not-done' ? (
280 // No digit hotkey: a bare digit typed into an empty prompt presses
281 // band Buttons, and this one starts a paid turn.
282 <Button
283 key={`do-${i}`}
284 label="Do it"
285 onPress={press => void doIt($, item, press.surface)}
286 />
287 ) : null}
288 </Box>
289 ))}
290 {more > 0 ? <Text dimColor> +{more} more: /assumptions</Text> : null}
291 </Box>
292 )
293 })
294
295 return (
296 <Box flexDirection="column">
297 <Box>
298 <Text dimColor>Ledger for this turn </Text>
299 <Button
300 key="dismiss"
301 label="Dismiss"
302 role="dismiss"
303 onPress={() => void quiet($, 'dismiss', update($, shown, () => []))}
304 />
305 </Box>
306 {groups}
307 {below}
308 </Box>
309 )
310 })
311}
312types/index.d.ts 20 lines1export type LedgerKind = 'assumption' | 'decision' | 'considered-not-done'
2
3export type LedgerEntry = {
4 kind: LedgerKind
5 text: string
6 why?: string
7 turn: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'assumption-ledger': {
13 pending: LedgerEntry[]
14 shown: LedgerEntry[]
15 turn: number
16 isOff: boolean
17 }
18 }
19}
20