SLOPSHOPPER

blind-spots

See what Claude assumed and what you forgot to ask. A side pane that lists Claude's unstated assumptions and the scenarios you might have missed, updated after…

newpanecommandmodeltimer
v0.2.1MITupdated 2026-10-19aadityasp/blind-spots
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blind-spots
│ ┃ Blind spots ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ Scenarios you might have missed ────────── │ ┃ Appears after Claude's next reply. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ◇ Assumptions Claude is making ───────────── ⏺ Update(src/auth.ts) │ ┃ Appears after Claude's next reply. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ r: refresh a: auto on ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /blind-spots │ ⎿ blind-spots: Blind spots pane opened. Refreshing now. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blind spots
✦ Scenarios you might have missed ───────────────────── Appears after Claude's next reply. ◇ Assumptions Claude is making ──────────────────────── Appears after Claude's next reply. r: refresh a: auto on
README

Blind spots

See what Claude assumed and what you forgot to ask, right next to your conversation.

Blind spots running beside a Claude Code session

When you ask Claude Code to build something, it quietly fills in the blanks. It decides how people sign in, which database to use and what happens when something fails. Usually you only find out later.

Blind spots adds a side pane that lists those blanks after every reply.

  • Assumptions Claude is making. Decisions it made without asking you, like "Users sign in with email, not SSO."
  • Scenarios you might have missed. Edge cases and questions worth a look, like "What happens when an upload fails?"

Press any item and it goes to Claude as your next message. Correcting an assumption or digging into a scenario takes one key.

Install

Add the marketplace and install the plugin, then start a new session.

claude plugin marketplace add aadityasp/blind-spots
claude plugin install blind-spots@blind-spots

You can also clone it straight into your skills folder.

git clone https://github.com/aadityasp/blind-spots ~/.claude/skills/blind-spots

The pane opens by itself at the start of every session. To try it for a single session without installing it, point Claude Code at the folder instead.

claude --plugin-dir /path/to/blind-spots

Built and tested on Claude Code 2.1.303. It needs a version that supports mods.

Use

KeyWhat it does
1 to 6Ask Claude about that scenario
Click or Tab and Enter on an assumptionConfirm or correct it
rRefresh now
aTurn auto refresh on or off
/blind-spotsReopen the pane and refresh it

Hotkeys work while the pane has focus. Click it or press ctrl+x then tab to focus it. Items you've already asked about turn dim and get a ✓.

The pane docks beside the conversation in fullscreen mode and sits above the prompt otherwise. Claude Code only opens a pane by itself on a terminal at least 144 columns wide. On a narrower one, run /blind-spots once, and from then on it opens by itself from 110 columns.

How it works

After each of Claude's replies in the main conversation, the mod forks the conversation once in the background and asks for both lists as JSON. Turns run by subagents don't trigger a refresh.

Each request includes the lists currently on screen. That lets assumptions you've confirmed or corrected drop off, while scenarios that still matter stay put. Only the newest refresh can write to the pane, so a slow request never overwrites a newer one.

What it reads and sends

The background fork reads your conversation, the same context Claude already has, and returns only the two lists. Nothing goes anywhere else.

The mod submits a prompt only when you press an item, and it never submits anything on its own. What it submits is the one-sentence message shown for that item, which the fork wrote, such as "Should the pane really refresh after every reply?" It goes into the conversation as your next message, exactly as if you had typed it. The full privacy policy has the details.

Hooks

HookWhat it does
session.startOpens the pane and registers /blind-spots
command.runAnswers /blind-spots by opening the pane and refreshing it. It handles no other command.
turn.completePasses the finished turn through unchanged, then schedules a refresh after main conversation replies
ui.renderDraws the pane

Cost

Every refresh is one extra model call that reads your conversation, so it adds to your token usage. If that matters, press a to turn auto refresh off and press r whenever you want a fresh read.

Development

claude plugin test .
claude plugin validate .

Everything lives in hooks/register.tsx. The pane's state is typed in types/index.d.ts, and the tests are in tests/. Claude Code generates the typings in .claude-plugin/types/ for editor type checking, and git ignores them.

License

MIT

Source 2 files
hooks/register.tsx 311 lines
1// Blind spots: a side pane that, after each of Claude's replies, lists the
2// scenarios you might have missed and the assumptions Claude is making.
3// One background fork of the conversation fills both lists. Pressing an item
4// sends it as your next message.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { Assumption, Spot, SpotKind, Status } from '../types'
10
11const PANE = 'blind-spots'
12const TITLE = 'Blind spots'
13const MISSED_HEADING = 'Scenarios you might have missed'
14const ASSUMED_HEADING = 'Assumptions Claude is making'
15const MAX_SPOTS = 6
16const MAX_ASSUMPTIONS = 12
17
18const spots = atom({ plugin: 'blind-spots', key: 'spots' } as const, [])
19const assumptions = atom({ plugin: 'blind-spots', key: 'assumptions' } as const, [])
20const status = atom({ plugin: 'blind-spots', key: 'status' } as const, 'idle')
21const isAuto = atom({ plugin: 'blind-spots', key: 'isAuto' } as const, true)
22const picked = atom({ plugin: 'blind-spots', key: 'picked' } as const, [])
23
24const KINDS: readonly SpotKind[] = ['question', 'feature', 'topic']
25const TAG: Record<SpotKind, string> = { question: 'Q', feature: 'F', topic: 'T' }
26const KIND_COLOR: Record<SpotKind, string> = { question: 'suggestion', feature: 'success', topic: 'merged' }
27
28function promptFor(prior: readonly Spot[], priorAssumed: readonly Assumption[]): string {
29  const shown =
30    prior.length === 0
31      ? ''
32      : `\nAlready shown as missed (keep one only if it is still unaddressed and still among the most important): ${prior
33          .map(spot => spot.title)
34          .join('; ')}\n`
35  const assumedBefore =
36    priorAssumed.length === 0
37      ? ''
38      : `\nAlready listed as assumptions (keep each one still in force, drop any the user has since confirmed or corrected): ${priorAssumed
39          .map(one => one.text)
40          .join('; ')}\n`
41
42  return `Step outside the conversation for a moment and answer two things.
43
441. "missed": what important things has the user probably NOT considered yet? Think of questions worth asking, features or options worth adding, and topics worth looking into. Prefer high-leverage, non-obvious items specific to this conversation over generic advice. Skip anything already discussed.
45${shown}
462. "assumptions": every assumption the assistant has made so far while doing this work that the user never stated. Include inferred requirements, defaults it picked, guesses about the environment, tools, data or audience, and scope it decided to include or leave out. Write each as a plain statement of what was assumed, specific enough that the user can say yes or no to it. List all that are still in force, most consequential first, up to ${MAX_ASSUMPTIONS}. Use an empty list if there are none.
47${assumedBefore}
48Reply with ONLY one JSON object, nothing else:
49{"missed":[{"kind":"question"|"feature"|"topic","title":"at most 6 words","ask":"one sentence the user could send to start discussing it"}],"assumptions":[{"text":"at most 14 words","ask":"one sentence the user could send to confirm or correct it"}]}
50Give 3 to ${MAX_SPOTS} missed items, most important first. No em dashes.`
51}
52
53type Found = { spots: Spot[]; assumptions: Assumption[] }
54
55function toSpots(raw: unknown): Spot[] {
56  if (!Array.isArray(raw)) return []
57
58  return raw
59    .flatMap((item: unknown): Spot[] => {
60      if (typeof item !== 'object' || item === null) return []
61      const { kind, title, ask } = item as Record<string, unknown>
62      if (typeof title !== 'string' || title.trim() === '') return []
63      const spotKind = KINDS.find(one => one === kind) ?? 'topic'
64      const spotAsk = typeof ask === 'string' && ask.trim() !== '' ? ask.trim() : `Let's discuss: ${title.trim()}`
65
66      return [{ kind: spotKind, title: title.trim(), ask: spotAsk }]
67    })
68    .slice(0, MAX_SPOTS)
69}
70
71function toAssumptions(raw: unknown): Assumption[] {
72  if (!Array.isArray(raw)) return []
73
74  return raw
75    .flatMap((item: unknown): Assumption[] => {
76      const { text, ask } =
77        typeof item === 'string' ? { text: item, ask: undefined } : ((item ?? {}) as Record<string, unknown>)
78      if (typeof text !== 'string' || text.trim() === '') return []
79      const oneAsk =
80        typeof ask === 'string' && ask.trim() !== ''
81          ? ask.trim()
82          : `You assumed this, so let's check it. ${text.trim()}`
83
84      return [{ text: text.trim(), ask: oneAsk }]
85    })
86    .slice(0, MAX_ASSUMPTIONS)
87}
88
89// The reply is one object with both lists. A bare array is the older shape
90// and still reads as missed items only.
91function parse(text: string): Found | undefined {
92  const brace = text.indexOf('{')
93  const bracket = text.indexOf('[')
94  const isArray = bracket >= 0 && (brace < 0 || bracket < brace)
95  const start = isArray ? bracket : brace
96  const end = text.lastIndexOf(isArray ? ']' : '}')
97  if (start < 0 || end <= start) return undefined
98
99  let raw: unknown
100  try {
101    raw = JSON.parse(text.slice(start, end + 1))
102  } catch {
103    return undefined
104  }
105
106  const { missed, assumptions: assumed } = isArray ? { missed: raw, assumptions: [] } : ((raw ?? {}) as Record<string, unknown>)
107  const found: Found = { spots: toSpots(missed), assumptions: toAssumptions(assumed) }
108
109  return found.spots.length === 0 && found.assumptions.length === 0 ? undefined : found
110}
111
112// Only the newest refresh may write its answer.
113let generation = 0
114
115async function refresh($: EngineInterface): Promise<void> {
116  const mine = ++generation
117  await update($, status, (): Status => 'thinking')
118
119  try {
120    const prior = await read($, spots)
121    const priorAssumed = await read($, assumptions)
122    const reply = await $.model.fork({ prompt: promptFor(prior, priorAssumed) })
123    if (mine !== generation) return
124
125    if (!reply.isAnswered) {
126      await update($, status, (): Status => (reply.reason === 'nothing-to-fork' ? 'idle' : 'failed'))
127      return
128    }
129    const found = parse(reply.text)
130    if (found === undefined) {
131      await update($, status, (): Status => 'failed')
132      return
133    }
134    const shownNow = [...found.spots.map(spot => spot.title), ...found.assumptions.map(one => one.text)]
135    await update($, spots, () => found.spots)
136    await update($, assumptions, () => found.assumptions)
137    await update($, picked, list => list.filter(label => shownNow.includes(label)))
138    await update($, status, (): Status => 'idle')
139  } catch {
140    if (mine === generation) await update($, status, (): Status => 'failed')
141  }
142}
143
144// What pressing an item does: send its text as the user's next message and
145// remember its `label` so the pane can dim it. Takes `$` alone, the shape the
146// plugin directory's checks can follow.
147function asker($: EngineInterface) {
148  return async (label: string, text: string): Promise<void> => {
149    await update($, picked, list => (list.includes(label) ? list : [...list, label]))
150    await $.prompt.submit({ text, asUser: true })
151  }
152}
153
154export const register: Register = on => {
155  on('session.start', async ($, e, next) => {
156    await update($, status, (): Status => 'idle')
157    await $.command.register({
158      name: 'blind-spots',
159      description: 'Show scenarios you might have missed and the assumptions Claude is making',
160    })
161    void $.ui.open({ id: PANE, title: TITLE })
162
163    return next(e)
164  })
165
166  on('command.run', { command: 'blind-spots' }, async $ => {
167    await $.ui.open({ id: PANE, title: TITLE })
168    $.clock.after(1, () => void refresh($))
169
170    return { text: 'Blind spots pane opened. Refreshing now.' }
171  })
172
173  on('turn.complete', async ($, e, next) => {
174    const done = await next(e)
175    const isMainAnswer = e.agentId === undefined && e.reason === 'answer'
176
177    if (isMainAnswer && (await read($, isAuto))) {
178      $.clock.after(1, () => void refresh($))
179    }
180
181    return done
182  })
183
184  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185    const { Box, Button, Text } = $.ui.resolve(e)
186    const ask = asker($)
187    const list = await read($, spots)
188    const assumed = await read($, assumptions)
189    const now = await read($, status)
190    const auto = await read($, isAuto)
191    const done = await read($, picked)
192
193    const isTerminal = e.surface === 'terminal'
194    const isEmpty = list.length === 0 && assumed.length === 0
195
196    // A heading, then on the terminal a rule out to the pane's edge with the
197    // item count at its end. A desktop draws a larger heading instead.
198    const heading = (glyph: string, color: string, label: string, count: number) => {
199      const tail = count > 0 ? ` ${count}` : ''
200      const rule = '─'.repeat(Math.max(2, e.props.bodyColumns - label.length - tail.length - 4))
201
202      return (
203        <Box>
204          <Text size="heading" weight="bold">
205            <Text color={color}>{glyph}</Text> {label}
206          </Text>
207          {isTerminal && (
208            <Text dimColor wrap="truncate-end">
209              {' '}
210              {rule}
211              {tail}
212            </Text>
213          )}
214        </Box>
215      )
216    }
217
218    const hint = (text: string) => (
219      <Text dimColor italic>
220        {text}
221      </Text>
222    )
223
224    const asked = (label: string) => done.includes(label) && <Text color="success"> ✓</Text>
225
226    const state =
227      now === 'thinking' ? (
228        <Text color="claude">● thinking</Text>
229      ) : now === 'failed' ? (
230        <Text color="error">✕ refresh failed</Text>
231      ) : (
232        <Text> </Text>
233      )
234
235    return (
236      <Box flexDirection="column">
237        {heading('✦', 'claude', MISSED_HEADING, list.length)}
238        <Box flexDirection="column" paddingLeft={2}>
239          {isEmpty ? (
240            hint("Appears after Claude's next reply.")
241          ) : (
242            <Text dimColor>
243              <Text color={KIND_COLOR.question}>Q</Text> question · <Text color={KIND_COLOR.feature}>F</Text>{' '}
244              feature · <Text color={KIND_COLOR.topic}>T</Text> topic
245            </Text>
246          )}
247          {list.map((spot, index) => (
248            <Button
249              key={`spot:${index}`}
250              plain
251              hotkey={String(index + 1)}
252              dimColor={done.includes(spot.title)}
253              onPress={() => ask(spot.title, spot.ask)}
254            >
255              <Text color={KIND_COLOR[spot.kind]} weight="bold">
256                {TAG[spot.kind]}
257              </Text>
258              {'  '}
259              {spot.title}
260              {asked(spot.title)}
261            </Button>
262          ))}
263        </Box>
264
265        <Box marginTop={1} flexDirection="column">
266          {heading('◇', 'warning', ASSUMED_HEADING, assumed.length)}
267          <Box flexDirection="column" paddingLeft={2}>
268            {isEmpty
269              ? hint("Appears after Claude's next reply.")
270              : assumed.length === 0
271                ? hint('None so far.')
272                : hint('Decided without asking. Press to check.')}
273            {/* The bullet sits outside the Button so a long line wraps under its text. */}
274            {assumed.map((one, index) => (
275              <Box key={`row:${index}`}>
276                {/* Kept from shrinking, or a long line squeezes the bullet out. */}
277                <Box flexShrink={0}>
278                  <Text color="warning">{'› '}</Text>
279                </Box>
280                <Box flexShrink={1}>
281                  <Button
282                    key={`assumed:${index}`}
283                    plain
284                    dimColor={done.includes(one.text)}
285                    onPress={() => ask(one.text, one.ask)}
286                  >
287                    {one.text}
288                    {asked(one.text)}
289                  </Button>
290                </Box>
291              </Box>
292            ))}
293          </Box>
294        </Box>
295
296        <Box marginTop={1} justifyContent="space-between">
297          <Box columnGap={2}>
298            <Button key="refresh" plain hotkey="r" dimColor onPress={() => refresh($)}>
299              refresh
300            </Button>
301            <Button key="auto" plain hotkey="a" dimColor onPress={() => update($, isAuto, value => !value)}>
302              {auto ? 'auto on' : 'auto off'}
303            </Button>
304          </Box>
305          {state}
306        </Box>
307      </Box>
308    )
309  })
310}
311
types/index.d.ts 17 lines
1export type SpotKind = 'question' | 'feature' | 'topic'
2export type Spot = { kind: SpotKind; title: string; ask: string }
3export type Assumption = { text: string; ask: string }
4export type Status = 'idle' | 'thinking' | 'failed'
5
6declare module 'claude-code' {
7  interface PluginState {
8    'blind-spots': {
9      spots: Spot[]
10      assumptions: Assumption[]
11      status: Status
12      isAuto: boolean
13      picked: string[]
14    }
15  }
16}
17