SLOPSHOPPER

assumptions

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · assumptions
│ ┃ assumptions ✕ › fix the failing auth test and add an audit log call │ ┃ No assumptions logged yet. │ ⏺ 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 │ │ › /assumptions │ ⎿ assumptions: No assumptions logged yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · assumptions
No assumptions logged yet.
README

assumptions

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.

How Claude logs

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.

What it costs

  • The rule and the tool's schema: a few hundred tokens of system prompt, cached with the rest of it.
  • Each logged guess: one small tool call, about 60 to 100 output tokens.
  • Marking right: nothing. Marking wrong: one turn, the fix you would have asked for anyway.

Commands

CommandDoes
/assumptionsOpen the full list as a pane
/assumptions-right 1 3Mark by number, or all
/assumptions-wrong 2Mark wrong by number; Claude gets a fix prompt for each
/assumptions-clearRemove the checked ones
/assumptions-collapseFold 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.

Install

/plugin install assumptions@arhun-plugins
Source 4 files
hooks/register.tsx 226 lines
1// 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}
226
hooks/model.ts 60 lines
1// 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}
60
hooks/view.tsx 146 lines
1// 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}
146
types/index.d.ts 30 lines
1// 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