Claude logs the small guesses it makes without asking; once the turn ends a strip lists them, you mark each right or wrong, and a wrong one sends Claude a…

When something is unclear, Claude asks about the big decisions. For the small ones it quietly picks something and keeps going: a default timeout, a file name, what happens on an empty list. Most of those guesses are right; the wrong ones surface later, when something breaks.
This mod makes them visible. Claude logs each small guess as it makes it, and when the turn ends a strip appears above the prompt:
◆ Claude made 6 assumptions · 6 to check ▸
Open it (the chevron, or /assumptions-collapse) and mark each one:
◆ Claude made 6 assumptions · 3 to check ▾
● #1 Timeout is 30 s · api/client.ts right undo
● #2 Retries twice on 5xx · api/client.ts fix sent
● #3 Dates shown in UTC right wrong
● #4 Empty list shows "No results" · ui/List.tsx right wrong
…
[ All right ] [ Clear checked ]
Marking one wrong sends Claude a prompt on its own: what the guess was and where, to fix only what depends on it, and the guesses you marked right to keep as they are. You do not have to explain the problem; if the right choice is not clear, Claude asks.
The mod registers a tool, assumption_log, and adds a short rule to the system prompt: when Claude decides something the user left open instead of asking, it logs one line (what it assumed, and the file or area). The tool sits in the prompt's tool list, not behind ToolSearch, so a guess is logged in passing.
| Command | Does |
|---|---|
/assumptions | Open the full list as a pane |
/assumptions-right 1 3 | Mark by number, or all |
/assumptions-wrong 2 | Mark wrong by number; Claude gets a fix prompt for each |
/assumptions-clear | Remove the checked ones |
/assumptions-collapse | Fold the strip to one line, or open it (it starts folded) |
Nothing is written to the repository: the list lives in the session and goes with /clear. The fold is remembered across sessions.
/plugin install assumptions@arhun-pluginshooks/register.tsx 226 lines1// assumptions: the small guesses Claude makes without asking, listed above the prompt for the person to check.
2//
3// Claude logs each guess through a tool of the mod's own (assumption_log, named in a short system-prompt rule) as it
4// makes it. When the turn ends, a strip says how many it made; the list marks each right or wrong, and a wrong one
5// sends Claude a prompt to redo only that part, naming the ones confirmed right so they stay. Nothing is written to
6// the repository: the list lives in the session's state and goes with /clear.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Assumption, AssumptionVerdict } from '../types'
12import { entriesOf, fixPrompt, idsOf, withLogged } from './model'
13import { renderList, type Actions } from './view'
14
15const TOOL = 'mcp__assumptions__assumption_log'
16const PANE = 'assumptions'
17const itemsAtom = atom({ plugin: 'assumptions', key: 'items' } as const, [])
18const collapsedAtom = atom({ plugin: 'assumptions', key: 'isCollapsed' } as const, true)
19
20const RULES = `# Assumptions
21When you fill a gap the user left open and decide it yourself instead of asking (a default value, a name, a file location, an edge case, a library, a format, what happens on error), log it with ${TOOL} as you make it: {text: what you assumed, in a few words, in the user's language; where: the file or area}; several at once as {items: [...]}. Log real guesses that shape what you build, not facts you checked or what the user said. Keep working after logging, don't ask about them, and don't mention the log to the user: they review the list themselves.`
22
23const COMMANDS = [
24 { name: 'assumptions', description: "Open the full list of Claude's assumptions" },
25 { name: 'assumptions-right', description: 'Mark assumptions right: their numbers, or "all"' },
26 { name: 'assumptions-wrong', description: 'Mark assumptions wrong by number; Claude gets a prompt to fix each' },
27 { name: 'assumptions-clear', description: 'Remove the checked assumptions from the list' },
28 { name: 'assumptions-collapse', description: 'Fold the assumptions strip to one line, or open it again' },
29] as const
30
31async function mark($: EngineInterface, id: number, verdict: AssumptionVerdict): Promise<string> {
32 const now = await $.clock.now()
33 let wrong: Assumption | null = null
34 let found = false
35 let wasWrong = false
36 const items = await update($, itemsAtom, list =>
37 list.map(a => {
38 if (a.id !== id) return a
39 found = true
40 // a wrong one is final: its fix prompt has gone
41 if (a.verdict === 'wrong') {
42 wasWrong = true
43 return a
44 }
45 if (verdict === 'wrong') {
46 wrong = { ...a, verdict, fixSentAt: now }
47 return wrong
48 }
49 return { ...a, verdict }
50 }),
51 )
52 if (!found) return `No assumption #${id}.`
53 if (wasWrong) return `#${id} is already marked wrong; its fix was sent.`
54 const sent = wrong as Assumption | null
55 if (!sent) return verdict === 'right' ? `#${id} marked right.` : `#${id} back to unchecked.`
56 // The prompt runs once the session is idle; a turn under way finishes first.
57 void $.prompt.submit({ text: fixPrompt(sent, items) }).catch(() => $.ui.toast(`Could not send the fix for #${id}.`))
58 return `#${id} marked wrong: Claude got a prompt to fix it.`
59}
60
61async function allRight($: EngineInterface): Promise<number> {
62 let n = 0
63 await update($, itemsAtom, list =>
64 list.map(a => {
65 if (a.verdict !== 'unchecked') return a
66 n += 1
67 return { ...a, verdict: 'right' as const }
68 }),
69 )
70 return n
71}
72
73async function clearChecked($: EngineInterface): Promise<number> {
74 let n = 0
75 await update($, itemsAtom, list => {
76 const kept = list.filter(a => a.verdict === 'unchecked')
77 n = list.length - kept.length
78 return kept
79 })
80 return n
81}
82
83async function toggleCollapsed($: EngineInterface): Promise<boolean> {
84 const isCollapsed = !(await read($, collapsedAtom))
85 await update($, collapsedAtom, () => isCollapsed)
86 await $.store.set('isCollapsed', isCollapsed)
87 return isCollapsed
88}
89
90function actionsFor($: EngineInterface, canFold: boolean): Actions {
91 return {
92 mark: (id, verdict) => void mark($, id, verdict).then(text => verdict === 'wrong' && $.ui.toast(text)),
93 allRight: () => void allRight($),
94 clearChecked: () => void clearChecked($),
95 toggle: canFold ? () => void toggleCollapsed($) : undefined,
96 }
97}
98
99export const register: Register = on => {
100 on('session.start', async ($, e, next) => {
101 await $.tool.register({
102 name: 'assumption_log',
103 description:
104 'Log a small assumption you made without asking (a default, a name, a location, an edge case) so the user can confirm or reject it later. {text, where?} or {items: [{text, where?}]}.',
105 inputSchema: {
106 type: 'object',
107 properties: {
108 text: { type: 'string', description: 'What you assumed, in a few words' },
109 where: { type: 'string', description: 'The file or area it applies to' },
110 items: {
111 type: 'array',
112 description: 'Several assumptions at once',
113 items: { type: 'object', required: ['text'], properties: { text: { type: 'string' }, where: { type: 'string' } } },
114 },
115 },
116 },
117 // in the prompt's tool list, not behind ToolSearch: a guess is logged in passing, so the tool must be at hand
118 isDeferred: false,
119 })
120 // One command each: the desktop composer drops anything typed after a slash command's name.
121 for (const c of COMMANDS) await $.command.register(c)
122 if ((await $.store.get('isCollapsed')) === false) await update($, collapsedAtom, () => false)
123 return next(e)
124 })
125
126 on('prompt.compose', async ($, e, next) => {
127 const result = await next(e)
128 return { sections: [...result.sections, { id: 'assumptions:rules', text: RULES, scope: 'session' as const }] }
129 })
130
131 on('tool.call', { tool: TOOL }, async ($, e) => {
132 const entries = entriesOf(e as unknown as Record<string, unknown>)
133 if (entries.length === 0) return { deny: 'assumption_log: give {text, where?} or {items: [{text, where?}]}.' }
134 const now = await $.clock.now()
135 let added: Assumption[] = []
136 await update($, itemsAtom, list => {
137 const r = withLogged(list, entries, now)
138 added = r.added
139 return r.items
140 })
141 return { result: added.length ? `logged ${added.map(a => `#${a.id}`).join(', ')}` : 'already logged' }
142 })
143
144 // /clear starts a new conversation: its guesses start over.
145 on('session.end', async ($, e, next) => {
146 const result = await next(e)
147 if (e.reason === 'clear') await update($, itemsAtom, () => [])
148 return result
149 })
150
151 // ── Commands ───────────────────────────────────────────────────────────
152
153 on('command.run', { command: 'assumptions' }, async $ => {
154 if ((await read($, itemsAtom)).length === 0) return { text: 'No assumptions logged yet.' }
155 await $.ui.open({ id: PANE, title: 'Assumptions' })
156 return { text: 'Assumptions opened.' }
157 })
158
159 on('command.run', { command: 'assumptions-right' }, async ($, e) => {
160 if (/\ball\b/i.test(e.args)) return { text: `${await allRight($)} marked right.` }
161 const ids = idsOf(e.args)
162 if (ids.length === 0) return { text: 'Give the numbers to mark right, or "all".' }
163 const lines: string[] = []
164 for (const id of ids) lines.push(await mark($, id, 'right'))
165 return { text: lines.join('\n') }
166 })
167
168 on('command.run', { command: 'assumptions-wrong' }, async ($, e) => {
169 const ids = idsOf(e.args)
170 if (ids.length === 0) return { text: 'Give the numbers to mark wrong.' }
171 const lines: string[] = []
172 for (const id of ids) lines.push(await mark($, id, 'wrong'))
173 return { text: lines.join('\n') }
174 })
175
176 on('command.run', { command: 'assumptions-clear' }, async $ => ({ text: `${await clearChecked($)} checked assumptions removed.` }))
177
178 on('command.run', { command: 'assumptions-collapse' }, async $ => {
179 const isCollapsed = await toggleCollapsed($)
180 return { text: isCollapsed ? 'Assumptions folded to one line.' : 'Assumptions expanded.' }
181 })
182
183 // ── The strip above the prompt, once the turn is over ──────────────────
184
185 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186 if (e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) return next(e)
187 const [items, isCollapsed] = await Promise.all([read($, itemsAtom), read($, collapsedAtom)])
188 if (items.length === 0) return next(e)
189 const E = $.ui.resolve(e)
190 const mine = renderList(E as Parameters<typeof renderList>[0], items, actionsFor($, true), {
191 isDesktop: e.surface !== 'terminal',
192 isCollapsed,
193 // the header and the buttons row with the gap between them take three of the band's rows
194 rows: Math.max(3, e.props.maxRows - 3),
195 isPane: false,
196 })
197 // What the host and the plugins after this one draw here stays, under the strip.
198 const below = await next(e)
199 if (!below) return mine
200 const { Box } = E
201 return (
202 <Box flexDirection="column" rowGap={1}>
203 {mine}
204 {below}
205 </Box>
206 )
207 })
208
209 // ── The full list as a pane (/assumptions) ─────────────────────────────
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const items = await read($, itemsAtom)
213 const E = $.ui.resolve(e)
214 if (items.length === 0) {
215 const { Text } = E
216 return <Text dimColor>No assumptions logged yet.</Text>
217 }
218 return renderList(E as Parameters<typeof renderList>[0], items, actionsFor($, false), {
219 isDesktop: e.surface !== 'terminal',
220 isCollapsed: false,
221 rows: Math.max(items.length, e.props.scroll.bodyRows - 3),
222 isPane: true,
223 })
224 })
225}
226hooks/model.ts 60 lines1// The list's rules, without engine calls, so the tests can hold them: what a
2// log call adds, what a verdict changes, and the prompt a wrong guess sends.
3
4import type { Assumption } from '../types'
5
6export const MAX_TEXT = 160
7export const MAX_WHERE = 80
8export const MAX_ITEMS = 200
9
10type Raw = Record<string, unknown>
11
12// the cut drops half a character left at the edge (an emoji is two UTF-16 units)
13const str = (v: unknown, max: number) =>
14 typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max).replace(/[\uD800-\uDBFF]$/, '').trimEnd() : ''
15
16/** The guesses one log call carries: `{text, where}` alone or `{items: [...]}`. */
17export function entriesOf(input: Raw): { text: string; where: string | null }[] {
18 const raw = Array.isArray(input['items']) ? (input['items'] as unknown[]) : [input]
19 return raw
20 .filter((x): x is Raw => Boolean(x) && typeof x === 'object')
21 .map(x => ({ text: str(x['text'], MAX_TEXT), where: str(x['where'], MAX_WHERE) || null }))
22 .filter(x => x.text !== '')
23}
24
25const same = (a: Assumption, text: string, where: string | null) =>
26 a.text.toLowerCase() === text.toLowerCase() && (a.where ?? '') === (where ?? '')
27
28/** Adds what is new; a guess already on the list (same text, same place) is not logged twice. */
29export function withLogged(items: readonly Assumption[], entries: { text: string; where: string | null }[], now: number): { items: Assumption[]; added: Assumption[] } {
30 let next = items.reduce((n, a) => Math.max(n, a.id), 0) + 1
31 const added: Assumption[] = []
32 for (const e of entries) {
33 if ([...items, ...added].some(a => same(a, e.text, e.where))) continue
34 added.push({ id: next++, text: e.text, where: e.where, verdict: 'unchecked', at: now, fixSentAt: null })
35 }
36 return { items: [...items, ...added].slice(-MAX_ITEMS), added }
37}
38
39export const toCheck = (items: readonly Assumption[]) => items.filter(a => a.verdict === 'unchecked').length
40
41/** The prompt sent for a wrong guess: only that part is redone, the confirmed ones stay. */
42export function fixPrompt(wrong: Assumption, items: readonly Assumption[]): string {
43 const at = wrong.where ? ` (${wrong.where})` : ''
44 const kept = items.filter(a => a.verdict === 'right')
45 const lines = [
46 `The user marked assumption #${wrong.id} as wrong${at}: "${wrong.text}".`,
47 'Fix only what depends on it. If the right choice is not clear from the conversation or the code, ask the user before changing anything.',
48 ]
49 if (kept.length) {
50 lines.push(`Keep these, the user confirmed them: ${kept.map(a => `#${a.id} "${a.text}"`).join('; ')}.`)
51 }
52 lines.push('Log any new assumption the fix makes, as usual.')
53 return lines.join('\n')
54}
55
56/** `3`, `3 5`, `#3,#5`: the numbers a command names. */
57export function idsOf(args: string): number[] {
58 return [...args.matchAll(/\d+/g)].map(m => Number(m[0])).filter(n => n > 0)
59}
60hooks/view.tsx 146 lines1// The strip and the list, laid out as the other mods in this marketplace: host elements and theme colours, a
2// coloured glyph with a bold title and dim detail after ` · `, the fold chevron at the end of that row, status dots
3// before default text, the buttons on their own row under the list. No card or background.
4
5import type { Elements, RenderElement } from 'claude-code'
6
7import type { Assumption, AssumptionVerdict } from '../types'
8import { toCheck } from './model'
9
10type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
11type DeskSvg = Elements['desktop']['Svg']
12
13export type Actions = {
14 mark: (id: number, verdict: AssumptionVerdict) => void
15 allRight: () => void
16 clearChecked: () => void
17 /** Folds the band to its strip or opens it; absent in the pane. */
18 toggle?: () => void
19}
20
21const DOT: Record<AssumptionVerdict, 'inactive' | 'success' | 'error'> = { unchecked: 'inactive', right: 'success', wrong: 'error' }
22
23// Lucide chevron-up (fold) / chevron-down (open), drawn as the other mods draw theirs on the desktop: the picture
24// centred in its cell and a blank Button over it, so the host draws its own hover and focus ring and takes the press.
25const CHIP = 16
26const chevronSvg = (isCollapsed: boolean) =>
27 `<svg xmlns="http://www.w3.org/2000/svg" width="${CHIP}" height="${CHIP}" viewBox="0 0 24 24"><style>.c{stroke:#C2C0B6}` +
28 `@media (prefers-color-scheme:light){.c{stroke:#3D3D3A}}</style>` +
29 `<path class="c" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="${isCollapsed ? 'm6 9 6 6 6-6' : 'm18 15-6-6-6 6'}"/></svg>`
30
31function chevronFor(E: Els, isDesktop: boolean, isCollapsed: boolean, toggle: () => void): RenderElement {
32 const { Box, Button } = E
33 if (!isDesktop) return <Button key="collapse" plain dimColor label={isCollapsed ? '▸' : '▾'} onPress={toggle} />
34 const Svg = (E as unknown as { Svg: DeskSvg }).Svg
35 return (
36 <Box key="collapse-cell" width={3} height={1} flexShrink={0} justifyContent="center" alignItems="center">
37 <Svg source={chevronSvg(isCollapsed)} alt={isCollapsed ? 'expand the assumptions' : 'collapse the assumptions'} width={CHIP} height={CHIP} />
38 <Box position="absolute" top={0} left={0} right={0} bottom={0} justifyContent="center" alignItems="center">
39 <Button key="collapse" plain label={' '} onPress={toggle} />
40 </Box>
41 </Box>
42 )
43}
44
45function header(E: Els, items: readonly Assumption[], chevron: RenderElement | null) {
46 const { Box, Text } = E
47 const left = toCheck(items)
48 const n = items.length
49 return (
50 <Box key="header" flexDirection="row" columnGap={1}>
51 <Text color={left > 0 ? 'warning' : 'success'}>◆</Text>
52 <Box flexShrink={1} minWidth={0}>
53 <Text wrap="truncate-end">
54 <Text bold>{`Claude made ${n} assumption${n === 1 ? '' : 's'}`}</Text>
55 <Text dimColor>{left > 0 ? ` · ${left} to check` : ' · all checked'}</Text>
56 </Text>
57 </Box>
58 <Box flexGrow={1} />
59 {chevron}
60 </Box>
61 )
62}
63
64function row(E: Els, a: Assumption, idW: number, actions: Actions) {
65 const { Box, Text, Button } = E
66 const controls =
67 a.verdict === 'unchecked' ? (
68 <Box flexDirection="row" columnGap={1} flexShrink={0}>
69 <Button key={`right-${a.id}`} plain dimColor label="right" onPress={() => actions.mark(a.id, 'right')} />
70 <Button key={`wrong-${a.id}`} plain dimColor label="wrong" onPress={() => actions.mark(a.id, 'wrong')} />
71 </Box>
72 ) : a.verdict === 'right' ? (
73 <Box flexDirection="row" columnGap={1} flexShrink={0}>
74 <Text dimColor>right</Text>
75 <Button key={`undo-${a.id}`} plain dimColor label="undo" onPress={() => actions.mark(a.id, 'unchecked')} />
76 </Box>
77 ) : (
78 <Box flexShrink={0}>
79 <Text dimColor>{a.fixSentAt === null ? 'wrong' : 'fix sent'}</Text>
80 </Box>
81 )
82 return (
83 <Box key={`a-${a.id}`} flexDirection="row" columnGap={1}>
84 <Text color={DOT[a.verdict]}>●</Text>
85 <Box width={idW} flexShrink={0}>
86 <Text dimColor>{`#${a.id}`}</Text>
87 </Box>
88 <Box flexGrow={1} flexShrink={1} minWidth={0}>
89 <Text wrap="truncate-end">
90 <Text>{a.text}</Text>
91 {a.where ? <Text dimColor>{` · ${a.where}`}</Text> : null}
92 </Text>
93 </Box>
94 {controls}
95 </Box>
96 )
97}
98
99/** The guesses to show in `rows` lines: the unchecked first, then the latest checked ones. */
100export function visible(items: readonly Assumption[], rows: number): { shown: Assumption[]; more: number } {
101 const unchecked = items.filter(a => a.verdict === 'unchecked')
102 const checked = items.filter(a => a.verdict !== 'unchecked').reverse()
103 const ordered = [...unchecked, ...checked]
104 if (ordered.length <= rows) return { shown: ordered.sort((a, b) => a.id - b.id), more: 0 }
105 const shown = ordered.slice(0, Math.max(1, rows - 1)).sort((a, b) => a.id - b.id)
106 return { shown, more: ordered.length - shown.length }
107}
108
109export type ListOpts = {
110 isDesktop: boolean
111 isCollapsed: boolean
112 /** Lines the list may take; the pane passes enough for all. */
113 rows: number
114 isPane: boolean
115}
116
117export function renderList(E: Els, items: readonly Assumption[], actions: Actions, o: ListOpts): RenderElement {
118 const { Box, Text, Button } = E
119 const chevron = actions.toggle ? chevronFor(E, o.isDesktop, o.isCollapsed, actions.toggle) : null
120 if (o.isCollapsed && !o.isPane) {
121 return (
122 <Box key="assumptions" flexDirection="column">
123 {header(E, items, chevron)}
124 </Box>
125 )
126 }
127 const { shown, more } = visible(items, o.rows)
128 const idW = String(items.reduce((n, a) => Math.max(n, a.id), 0)).length + 1
129 const hasChecked = items.some(a => a.verdict !== 'unchecked')
130 return (
131 <Box key="assumptions" flexDirection="column" rowGap={1}>
132 <Box flexDirection="column">
133 {header(E, items, chevron)}
134 {shown.map(a => row(E, a, idW, actions))}
135 {more > 0 ? <Text dimColor>{` +${more} more · /assumptions opens the full list`}</Text> : null}
136 </Box>
137 {toCheck(items) > 0 || hasChecked ? (
138 <Box flexDirection="row" columnGap={1}>
139 {toCheck(items) > 0 ? <Button key="all-right" label="All right" variant="primary" hotkey="a" onPress={actions.allRight} /> : null}
140 {hasChecked ? <Button key="clear-checked" label="Clear checked" hotkey="c" onPress={actions.clearChecked} /> : null}
141 </Box>
142 ) : null}
143 </Box>
144 )
145}
146types/index.d.ts 30 lines1// Contract for assumptions: the values it keeps in $.state.
2
3/** What the person said about a guess: not looked at yet, confirmed, or wrong. */
4export type AssumptionVerdict = 'unchecked' | 'right' | 'wrong'
5
6/** One small guess Claude made without asking. */
7export type Assumption = {
8 /** 1-based, in the order logged this session; the number the list and the fix prompt use. */
9 id: number
10 /** What Claude assumed, one line. */
11 text: string
12 /** The file or area it applies to; null when Claude did not say. */
13 where: string | null
14 verdict: AssumptionVerdict
15 /** When it was logged, epoch milliseconds. */
16 at: number
17 /** When the fix prompt for a wrong one was sent; null before. */
18 fixSentAt: number | null
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 assumptions: {
24 items: Assumption[]
25 /** Folded to the one-line strip, the default (the chevron, /assumptions-collapse); mirrored to $.store. */
26 isCollapsed: boolean
27 }
28 }
29}
30