SLOPSHOPPER

assumptions

Claude logs each assumption it makes while working; review them in a side panel, mark them right or wrong, and send corrections mid-task.

newpanebandguardcommandtoast
v0.4.0MITupdated 2026-10-14richardmuucyi/rmahwj-mods/assumptions
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. │ ┃ Claude adds one here each time it assumes ⏺ Read(src/auth.ts) │ ┃ something while working. ⎿ 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 in this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Assumptions
No assumptions logged yet. Claude adds one here each time it assumes something while working.
README

assumptions

Claude logs each assumption it makes while it works. You review them, mark them right or wrong, and send a correction in the middle of a task.

How it works

  • The mod gives Claude one tool, log_assumption. Claude calls it when it makes a non-trivial assumption you have not confirmed. It can mark an assumption as high impact, and it carries on without waiting.
  • /assumptions shows what Claude assumed in this session. A side panel shows the same list.
  • Mark an assumption right, or mark it wrong and type the fix. If Claude is mid-turn, it reads the correction at its next step. If not, the correction goes in as your next prompt. It reads Correction to assumption A<n> ("…"): <your fix> and asks Claude to adjust the work that depended on it.
  • If you also install session-board, its recap shows how many assumptions are still open. This mod writes the count to a small file in ~/.claude/session-board/. Nothing reaches the model.

Install

/plugin marketplace add richardmuucyi/rmahwj-mods
/plugin install assumptions@rmahwj-mods
/reload-plugins

Tests

claude plugin test assumptions
Source 2 files
hooks/register.tsx 428 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Assumption } from '../types'
5
6const PANE = 'assumptions'
7const TOOL = 'mcp__assumptions__log_assumption'
8
9const list = atom({ plugin: 'assumptions', key: 'list' } as const, [])
10const editing = atom({ plugin: 'assumptions', key: 'editing' } as const, null)
11const isWorking = atom({ plugin: 'assumptions', key: 'isWorking' } as const, false)
12const nextId = atom({ plugin: 'assumptions', key: 'nextId' } as const, 1)
13const showReviewed = atom({ plugin: 'assumptions', key: 'showReviewed' } as const, false)
14
15// After the last open assumption is reviewed, the panel closes after this pause.
16const CLOSE_DELAY_MS = 2_000
17
18const GUIDANCE = `## Logging assumptions
19While you work, whenever you make a non-trivial assumption the user has not confirmed (about requirements, scope, file or folder locations, formats, naming, defaults, data, or which option they want), call the tool \`${TOOL}\` at once with that assumption in one short sentence, and a few words on why. Then keep working; do not stop to ask. The user reviews the assumptions as they come.
20- Set impact to "high" when a wrong guess would be costly to undo (deleting or overwriting data, architecture, money, anything sent to other people); otherwise leave it out.
21- Log one assumption per call, when you make it, not in a batch at the end.
22- Skip trivial or obvious choices, and anything the user already stated.
23- If a message arrives that says "Correction to assumption A<n>", follow it: revise the work that depended on that assumption, and say briefly what you changed.`
24
25function short(text: string, n = 60): string {
26  const one = text.replace(/\s+/g, ' ').trim()
27  return one.length > n ? `${one.slice(0, n - 1)}…` : one
28}
29
30function ago(ms: number): string {
31  const s = Math.max(0, Math.round(ms / 1000))
32  if (s < 60) return `${s}s ago`
33  const m = Math.round(s / 60)
34  return m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`
35}
36
37/** True when a drawing shows nothing (an empty box or blank text): such a tree only adds a blank row. */
38function isBlank(n: unknown): boolean {
39  if (n === null || n === undefined || n === false || n === true) return true
40  if (typeof n === 'string') return n.trim() === ''
41  if (typeof n === 'number') return false
42  if (Array.isArray(n)) return n.every(isBlank)
43  const el = n as { type?: unknown; children?: unknown[] }
44  if (el.type === 'Box' || el.type === 'Text') return (el.children ?? []).every(isBlank)
45  return false
46}
47
48/** Review order: high impact first, skipped ones last, then oldest first. */
49function queue(items: Assumption[]): Assumption[] {
50  return items
51    .filter(a => a.status === 'open')
52    .sort(
53      (x, y) =>
54        Number(y.impact === 'high') - Number(x.impact === 'high') ||
55        (x.skippedAt ?? 0) - (y.skippedAt ?? 0) ||
56        x.id - y.id,
57    )
58}
59
60/** Mark one assumption reviewed; close the panel shortly after the last one. */
61async function mark($: EngineInterface, id: number, status: Assumption['status'], correction?: string): Promise<void> {
62  await update($, list, all => all.map(a => (a.id === id ? { ...a, status, correction } : a)))
63  await closeIfDone($)
64}
65
66/** Close the panel once nothing is left open: a short pause first so the last ✓ or ✗ is seen. */
67async function closeIfDone($: EngineInterface): Promise<void> {
68  if (queue(await read($, list)).length > 0) return
69  $.clock.after(CLOSE_DELAY_MS, () => {
70    void (async () => {
71      const still = queue(await read($, list)).length
72      if (still === 0 && (await read($, editing)) === null) await $.ui.close({ id: PANE })
73    })()
74  })
75}
76
77/** Send a correction to Claude: into the running turn if one runs, else as a new prompt. */
78async function sendCorrection($: EngineInterface, a: Assumption, value: string): Promise<void> {
79  const fix = value.trim()
80  if (fix === '') return
81  await update($, editing, () => null)
82  await mark($, a.id, 'wrong', fix)
83  const text = `Correction to assumption A${a.id} ("${a.text}"): ${fix}\nPlease adjust the work that depended on it.`
84  if (await read($, isWorking)) {
85    // Mid-turn: Claude reads it at its next step.
86    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
87    $.ui.toast(`Correction to A${a.id} sent to Claude`)
88  } else {
89    await $.prompt.submit({ text, asUser: true })
90  }
91}
92
93/** Start a correction: a field where the surface has one, else the prompt box pre-filled. */
94async function startFix($: EngineInterface, a: Assumption, hasInput: boolean): Promise<void> {
95  if (hasInput) {
96    await update($, editing, () => a.id)
97    return
98  }
99  await mark($, a.id, 'wrong')
100  await $.prompt.fill({ text: `Correction to assumption A${a.id} ("${a.text}"): `, mode: 'replace' })
101}
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    await $.tool.register({
106      name: 'log_assumption',
107      description:
108        'Record one assumption you are making while working, so the user can confirm or correct it. Call it the moment you make a non-trivial assumption the user has not confirmed, then carry on.',
109      inputSchema: {
110        type: 'object',
111        properties: {
112          assumption: { type: 'string', description: 'The assumption, in one short sentence.' },
113          why: { type: 'string', description: 'Optional: a few words on why you assumed it.' },
114          impact: {
115            type: 'string',
116            enum: ['high', 'low'],
117            description: '"high" when a wrong guess would be costly to undo; omit otherwise.',
118          },
119        },
120        required: ['assumption'],
121      },
122      isDeferred: false,
123    })
124    await $.command.register({
125      name: 'assumptions',
126      description: 'Show the assumptions Claude made in this session',
127    })
128    // How many are still open, for session-board's recap chip: a mod cannot
129    // read another mod's state, so it goes in a small file beside the board's
130    // cards. Written only when the count changes; nothing reaches the model.
131    const home = ((await $.env.get('USERPROFILE').catch(() => undefined)) || (await $.env.get('HOME').catch(() => undefined)) || '').replace(/[\\/]$/, '')
132    if (home !== '') {
133      let last = ''
134      $.clock.every(2_000, () => {
135        void (async () => {
136          const all = await read($, list)
137          const open = all.filter(a => a.status === 'open')
138          const text = JSON.stringify({ open: open.length, high: open.filter(a => a.impact === 'high').length })
139          // The id changes on /clear: the count follows the session it belongs to.
140          const path = `${home}/.claude/session-board/${await $.session.id()}.assume`
141          if (`${path}|${text}` === last) return
142          last = `${path}|${text}`
143          await $.fs.write(path, text)
144        })().catch(() => undefined)
145      })
146    }
147    return next(e)
148  })
149
150  // Tell the model to log its assumptions.
151  on('prompt.compose', async ($, e, next) => {
152    const composed = await next(e)
153    return {
154      sections: [...composed.sections, { id: 'assumptions:guidance', text: GUIDANCE, scope: 'session' as const }],
155    }
156  })
157
158  // The tool itself: store the assumption and answer at once.
159  on('tool.call', { tool: TOOL }, async ($, e) => {
160    // Arguments sit beside `tool` on the event (older callers nest them under `input`).
161    const raw = e as unknown as Record<string, unknown>
162    const input = (
163      typeof raw.assumption === 'string' ? raw : ((raw.input as Record<string, unknown> | undefined) ?? {})
164    ) as { assumption?: unknown; why?: unknown; impact?: unknown }
165    const text = typeof input.assumption === 'string' ? input.assumption.trim() : ''
166    if (text === '') return { deny: 'assumption is required' }
167    const why = typeof input.why === 'string' && input.why.trim() !== '' ? input.why.trim() : undefined
168    const impact = input.impact === 'high' ? ('high' as const) : ('low' as const)
169
170    let id = 0
171    await update($, nextId, n => {
172      id = n
173      return n + 1
174    })
175    const one: Assumption = { id, text, why, impact, status: 'open', at: await $.clock.now() }
176    await update($, list, items => [...items, one])
177    return { result: `Logged as A${id}. Carry on; the user will send a correction if it is wrong.` }
178  })
179
180  on('turn.start', async ($, e, next) => {
181    await update($, isWorking, () => true)
182    return next(e)
183  })
184
185  on('turn.complete', async ($, e, next) => {
186    await update($, isWorking, () => false)
187    return next(e)
188  })
189
190  on('command.run', { command: 'assumptions' }, async $ => {
191    const opened = await $.ui.open({ id: PANE, title: 'Assumptions', closeOnEscape: true })
192    const items = await read($, list)
193    if (items.length === 0) return { text: 'No assumptions logged yet in this session.' }
194    return {
195      text: opened.isPlaced ? `Assumptions panel opened (${items.length} logged).` : 'Widen the window to see the assumptions panel.',
196    }
197  })
198
199  // The card above the prompt: one assumption at a time, answered in place.
200  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
201    const items = await read($, list)
202    const open = queue(items)
203    if (open.length === 0 || e.props.hasSurvey) return next(e)
204    const els = $.ui.resolve(e)
205    const { Box, Button, Text } = els
206    const Input = 'Input' in els ? els.Input : undefined
207    const cur = open[0]!
208    const hot = cur.impact === 'high'
209    const edit = await read($, editing)
210    const t = await $.clock.now()
211    const isFixing = edit === cur.id && Input !== undefined
212    const accent = hot ? 'yellow' : 'gray'
213
214    const header = (
215      <Box key="as-card-head">
216        <Text bold color="black" backgroundColor={accent}>
217          {` ${hot ? '⚠ ' : ''}${isFixing ? 'FIXING' : 'ASSUMPTION'} 1/${open.length} `}
218        </Text>
219        <Text dimColor>
220          {'  '}
221          {hot ? 'high impact · ' : ''}
222          {cur.at !== undefined ? ago(t - cur.at) : `A${cur.id}`}
223        </Text>
224      </Box>
225    )
226
227    const body = isFixing ? (
228      <Box key="as-card-fixing" flexDirection="column">
229        <Text dimColor>{cur.text}</Text>
230        <Input
231          key="as-card-fix-input"
232          label="It should be: "
233          placeholder="tell Claude what's right"
234          submitLabel="send"
235          autoFocus
236          onSubmit={(value: string) => sendCorrection($, cur, value)}
237        />
238        <Box>
239          <Button key="as-card-cancel" label="Cancel" onPress={() => update($, editing, () => null)} />
240        </Box>
241      </Box>
242    ) : (
243      <Box key="as-card-body" flexDirection="column">
244        <Text bold>{cur.text}</Text>
245        {cur.why !== undefined && (
246          <Text dimColor italic>
247            why: {cur.why}
248          </Text>
249        )}
250        <Box marginTop={1}>
251          <Button key="as-card-right" label="✓ Right" hotkey="y" variant="primary" onPress={() => mark($, cur.id, 'right')} />
252          <Text>  </Text>
253          <Button key="as-card-fix" label="✗ Fix" hotkey="n" onPress={() => startFix($, cur, Input !== undefined)} />
254          <Text>  </Text>
255          <Button
256            key="as-card-skip"
257            label="Skip"
258            hotkey="s"
259            onPress={async () => {
260              const now = await $.clock.now()
261              await update($, list, all => all.map(a => (a.id === cur.id ? { ...a, skippedAt: now } : a)))
262            }}
263          />
264          <Text>    </Text>
265          <Button
266            key="as-open"
267            plain
268            onPress={() => $.ui.open({ id: PANE, title: 'Assumptions', closeOnEscape: true })}
269          >
270            <Text color="blue">all {open.length} ›</Text>
271          </Button>
272        </Box>
273      </Box>
274    )
275
276    const mine = (
277      <Box key="as-card" flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
278        {header}
279        {body}
280      </Box>
281    )
282    // Keep whatever else is drawn above the prompt (other mods' strips) beneath this one.
283    const beneath = await next(e)
284    return (
285      <Box flexDirection="column">
286        {mine}
287        {!isBlank(beneath) && beneath}
288      </Box>
289    )
290  })
291
292  // The panel: a timeline of every assumption, to review in bulk or look back.
293  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
294    const els = $.ui.resolve(e)
295    const { Box, Button, Text } = els
296    // Mobile draws no text field: there, Fix pre-fills the prompt instead.
297    const Input = 'Input' in els ? els.Input : undefined
298    const items = await read($, list)
299    const edit = await read($, editing)
300
301    if (items.length === 0) {
302      return (
303        <Box flexDirection="column">
304          <Text dimColor>No assumptions logged yet.</Text>
305          <Text dimColor>Claude adds one here each time it assumes something while working.</Text>
306        </Box>
307      )
308    }
309
310    const open = queue(items)
311    const reviewed = items.filter(a => a.status !== 'open').reverse()
312    const showAll = await read($, showReviewed)
313
314    const openRow = (a: Assumption) => {
315      const hot = a.impact === 'high'
316      return (
317        <Box key={`as-row-${a.id}`} flexDirection="column" marginTop={1}>
318          <Text>
319            <Text color={hot ? 'yellow' : undefined}>● </Text>
320            <Text bold>
321              A{a.id}  {a.text}
322            </Text>
323            {hot && <Text color="yellow">   ⚠ high</Text>}
324          </Text>
325          {a.why !== undefined && (
326            <Text>
327              <Text dimColor>│   </Text>
328              <Text dimColor italic>
329                why: {a.why}
330              </Text>
331            </Text>
332          )}
333          {edit === a.id && Input !== undefined ? (
334            <Box key={`as-fixing-${a.id}`} flexDirection="column">
335              <Input
336                key={`as-fix-${a.id}`}
337                label="│   It should be: "
338                placeholder="tell Claude what's right, then Enter"
339                submitLabel="send"
340                autoFocus
341                onSubmit={(value: string) => sendCorrection($, a, value)}
342              />
343              <Box>
344                <Text dimColor>│   </Text>
345                <Button key={`as-cancel-${a.id}`} label="Cancel" onPress={() => update($, editing, () => null)} />
346              </Box>
347            </Box>
348          ) : (
349            <Box key={`as-acts-${a.id}`}>
350              <Text dimColor>│   </Text>
351              <Button key={`as-right-${a.id}`} label="✓ Right" variant="primary" onPress={() => mark($, a.id, 'right')} />
352              <Text>  </Text>
353              <Button key={`as-wrong-${a.id}`} label="✗ Fix…" onPress={() => startFix($, a, Input !== undefined)} />
354            </Box>
355          )}
356        </Box>
357      )
358    }
359
360    const reviewedRow = (a: Assumption) => (
361      <Box key={`as-row-${a.id}`} flexDirection="column">
362        <Text>
363          <Text color={a.status === 'right' ? 'green' : 'red'}>● </Text>
364          <Text dimColor>
365            A{a.id}  {a.text}
366          </Text>
367          <Text color={a.status === 'right' ? 'green' : 'red'}>{a.status === 'right' ? '   ✓ right' : '   ✗ fixed'}</Text>
368        </Text>
369        {a.correction !== undefined && (
370          <Text>
371            <Text dimColor>│   </Text>
372            <Text color="green">→ {a.correction}</Text>
373          </Text>
374        )}
375      </Box>
376    )
377
378    return (
379      <Box flexDirection="column">
380        <Text>
381          <Text bold color="black" backgroundColor={open.length > 0 ? 'yellow' : 'gray'}>
382            {` TO REVIEW ${open.length} `}
383          </Text>
384        </Text>
385        {open.length === 0 && <Text dimColor>All reviewed.</Text>}
386        {open.map(openRow)}
387        {reviewed.length > 0 && (
388          <Box marginTop={1}>
389            <Button key="as-toggle-reviewed" plain onPress={() => update($, showReviewed, v => !v)}>
390              <Text bold color="white" backgroundColor="gray">
391                {` REVIEWED ${reviewed.length} `}
392              </Text>
393              <Text dimColor> {showAll ? '▾ hide' : '▸ show'}</Text>
394            </Button>
395          </Box>
396        )}
397        {showAll && reviewed.map(reviewedRow)}
398        <Box marginTop={1}>
399          <Text dimColor>⋯  </Text>
400          {open.length > 0 && (
401            <Button
402              key="as-all-right"
403              plain
404              onPress={async () => {
405                await update($, list, all => all.map(a => (a.status === 'open' ? { ...a, status: 'right' as const } : a)))
406                await closeIfDone($)
407              }}
408            >
409              <Text dimColor>Mark all right</Text>
410            </Button>
411          )}
412          {open.length > 0 && <Text dimColor>   ·   </Text>}
413          <Button
414            key="as-clear"
415            plain
416            onPress={async () => {
417              await update($, list, () => [])
418              await update($, editing, () => null)
419            }}
420          >
421            <Text dimColor>Clear history</Text>
422          </Button>
423        </Box>
424      </Box>
425    )
426  })
427}
428
types/index.d.ts 32 lines
1export type Assumption = {
2  id: number
3  /** The assumption, one sentence, as Claude logged it. */
4  text: string
5  /** Why Claude assumed it, if given. */
6  why?: string
7  /** High: a mistake here would be costly to undo. Reviewed first, drawn in yellow. */
8  impact?: 'high' | 'low'
9  status: 'open' | 'right' | 'wrong'
10  /** What the person said it should be, once marked wrong and sent. */
11  correction?: string
12  /** Epoch ms when Claude logged it. */
13  at?: number
14  /** Epoch ms of the last Skip: skipped ones go to the back of the queue. */
15  skippedAt?: number
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    assumptions: {
21      list: Assumption[]
22      /** Id of the assumption whose correction field is open, or null. */
23      editing: number | null
24      isWorking: boolean
25      /** The next assumption's number; never reset, so numbers never repeat. */
26      nextId: number
27      /** Whether the panel lists the reviewed assumptions too. */
28      showReviewed: boolean
29    }
30  }
31}
32