SLOPSHOPPER

blind-spots

After a long turn, a reviewer pass flags above the prompt the one decision, risk, gap or concept you are likely to have missed, and learns from how you react.

newbandcommandtoastpromptmodel
★ 2v0.2.0Apache-2.0updated 2026-10-06forketyfork/agentic-skills/skills/blind-spots
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blind-spots
› fix the failing auth test and add an audit log call ⏺ 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 › /blind-spots ⎿ blind-spots: Reviews a turn once it ends with at least 8 tool calls (base 8, back-off level 0 in this project); 0 topic(s) ⎿ blind-spots: No reactions recorded yet. ⎿ blind-spots: No review has run in this session yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

blind-spots

A Claude Code plugin that adds a reviewer pass after long turns. When the main agent finishes a turn that took many tool calls, the plugin asks the model to step back and look for the one thing you are likely to have missed. If it finds something, it shows a banner above the prompt.

The reviewer looks for four kinds of finding:

  • decision: the agent picked an approach, default or trade-off on its own, and you never weighed in on it.
  • risk: something in the result may be wrong, fragile or unsafe.
  • gap: something you probably think is done but isn't: a skipped test, a TODO, an unverified claim.
  • concept: a system, mechanism or design that shapes the work and that you haven't shown you understand, where misunderstanding it would likely lead you astray later. The explanation starts from scratch, and the next step says where it will matter for you.

The banner is meant as your takeaway from a long turn: the one point you should not miss, even when the agent already stated it somewhere in a long reply, because long replies get skimmed. What counts as known is only what you took up in your own messages. When nothing has a real consequence, nothing is shown. A concept is raised only when there is no more urgent decision, risk or gap.

Using it

The banner shows the kind of finding and a one-line headline. Its buttons (focus the banner with ctrl+x tab, then use the hotkeys):

KeyButtonWhat it does
dDetails / Hide detailsShows or hides the explanation and a suggested next step
aAsk ClaudePuts a question about the finding into an empty prompt box, so you can discuss it in the main session
mMute topic (I know this, for a concept)Hides the finding and tells future reviews never to raise it again
xCloseHides the finding

A finding you leave unopened expires after you send three prompts.

The /blind-spots command:

  • /blind-spots or /blind-spots status shows the current review threshold, your recent reactions per kind, and how the last review went, including the HTTP status when the model request failed. It also shows the reviewer's raw reply, whose reason names the strongest candidate it considered and why it did or did not raise it, so a "nothing to raise" can be checked.
  • /blind-spots review runs a review right away, whatever the threshold.
  • /blind-spots unmute forgets the muted and recently raised topics.
  • /blind-spots reset forgets everything learned: muted topics, reactions, and the back-off of every project.

How it adapts to you

Each finding's fate is recorded:

  • engaged: you opened its details or asked Claude about it.
  • rejected: you muted it without opening it.
  • ignored: you closed it unopened, it expired, or a new finding replaced it while still unopened.

Two things adapt, each scoped to what it measures:

  • How often reviews run, per project. Each ignored or rejected finding raises the project's back-off level by one, and each engaged finding lowers it by one. The threshold is minToolCalls × 2^level, so 8, 16, 32 or at most 64 tool calls. The level drops by one for each day without a reaction, so a quiet week does not silence the plugin for good.
  • What the reviewer raises, for you everywhere. A per-kind summary of your last 30 reactions goes into the review prompt (for example "concept: 1 engaged, 4 ignored"). The reviewer raises a kind you mostly ignore or mute only when it is exceptionally important.

Muted topics are global too: a topic you know does not stop being known in another repository.

Configuration

OptionDefaultMeaning
minToolCalls8The base threshold: a finished turn is reviewed only if the main agent made at least this many tool calls in it. The back-off multiplies it.

Set it in the /config menu, or in settings under pluginConfigs.blind-spots.

How it works

  • It is a hooks module: a plugin of TypeScript function hooks (hooks/register.tsx), not a skill.
  • A turn.step hook counts the main agent's tool calls in each turn. Subagent steps are not counted.
  • A turn.complete hook starts the review once the turn has ended normally and reached the current threshold. It does not wait for the review, so the session is free right away.
  • The review is a $.model.fork call: the session's own conversation is sent again with a review prompt after it, tools disabled, on the session's model and connection. That is why it works whichever way Claude Code reaches the model (direct API, a gateway, or a cloud provider). It also means each review costs a full request over the conversation, cheaper when the prompt cache still holds it.
  • A fork replays the main thread's last request, which ends before the turn's final reply. The plugin keeps each main turn's final answer and adds it to the review prompt, so the agent's closing claims, summaries and caveats are reviewed too, and can themselves be what the banner surfaces.
  • The reviewer answers in JSON (hooks/review.ts has the prompt and the parser). Replies it cannot read are reported by /blind-spots status, never shown as findings.
  • hooks/feedback.ts holds the fate, back-off and summary rules as pure functions.
  • The finding being shown lives in session state. Muted and recent topics, reactions and the per-project back-off (keyed by the session's project root) live in the plugin's store, a JSON file under the Claude Code configuration directory, so they carry over to later sessions.

Developing

claude plugin validate skills/blind-spots
claude plugin test skills/blind-spots
claude --plugin-dir skills/blind-spots   # run a session with the working copy

When the engine loads the plugin from a folder, it writes the API typings into .claude-plugin/types/. That folder is gitignored, and tsc -p skills/blind-spots type-checks against it.

Source 4 files
hooks/register.tsx 323 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, Register } from 'claude-code'
3
4import type { Finding, FindingKind, ReviewOutcome, ReviewRecord } from '../types'
5import {
6  PROMPTS_BEFORE_EXPIRY,
7  afterFate,
8  effectiveLevel,
9  fateOf,
10  quietMap,
11  reactionSummary,
12  reactions,
13  reviewThreshold,
14  withQuiet,
15  withReaction,
16} from './feedback'
17import type { FindingEnd } from './feedback'
18import {
19  MAX_MUTED,
20  MAX_RECENT,
21  askDraft,
22  isReviewDue,
23  mentions,
24  readVerdict,
25  reviewPrompt,
26  strings,
27  withTopic,
28} from './review'
29
30const finding = atom({ plugin: 'blind-spots', key: 'finding' } as const, null)
31const lastReview = atom({ plugin: 'blind-spots', key: 'lastReview' } as const, null)
32const lastAnswer = atom({ plugin: 'blind-spots', key: 'lastAnswer' } as const, null)
33
34const DEFAULT_MIN_TOOL_CALLS = 8
35const MAX_SHOWN_REPLY = 2000
36
37const KIND_COLOR: Record<FindingKind, 'yellow' | 'red' | 'magenta' | 'cyan'> = {
38  decision: 'yellow',
39  risk: 'red',
40  gap: 'magenta',
41  concept: 'cyan',
42}
43
44const OUTCOME_TEXT: Record<ReviewOutcome, string> = {
45  flagged: 'raised a blind spot',
46  clean: 'nothing to raise',
47  muted: 'the only finding was a recent or muted topic',
48  malformed: 'the reviewer reply could not be read',
49  'nothing-to-fork': 'nothing to review yet (no model response in this conversation)',
50  'api-error': 'the model request failed',
51  'empty-reply': 'the reviewer replied with no text',
52  aborted: 'the review was cut short',
53  failed: 'the review threw an error',
54}
55
56const USAGE = [
57  'Usage: /blind-spots [status|review|unmute|reset]',
58  '  status  when reviews run, how you reacted so far, and how the last review went (the default)',
59  '  review  review the conversation right now',
60  '  unmute  forget the muted and recently raised topics',
61  '  reset   forget everything learned: muted topics, reactions and the back-off of every project',
62].join('\n')
63
64// Module variables start over on a hot reload: a turn in flight then simply goes unreviewed.
65let isReviewing = false
66const toolCallsByTurn = new Map<string, number>()
67
68async function currentThreshold($: EngineInterface, minToolCalls: number): Promise<{ level: number; threshold: number }> {
69  const quiet = quietMap(await $.store.get('quiet'))
70  const level = effectiveLevel(quiet[await $.session.root()], Date.now())
71
72  return { level, threshold: reviewThreshold(minToolCalls, level) }
73}
74
75/** Records how the user treated a finding: globally per kind, and as this project's back-off. */
76async function recordFate($: EngineInterface, ended: Finding, end: FindingEnd) {
77  const now = Date.now()
78  const fate = fateOf(ended, end)
79  await $.store.set('reactions', withReaction(reactions(await $.store.get('reactions')), { kind: ended.kind, fate, at: now }))
80
81  const root = await $.session.root()
82  const quiet = quietMap(await $.store.get('quiet'))
83  await $.store.set('quiet', withQuiet(quiet, root, afterFate(quiet[root], fate, now)))
84}
85
86/** Ends the finding with `id`; a no-op once it is no longer the one shown. */
87async function endFinding($: EngineInterface, id: string, end: FindingEnd) {
88  let ended: Finding | null = null
89  await update($, finding, current => {
90    ended = current?.id === id ? current : null
91
92    return ended === null ? current : null
93  })
94  if (ended !== null) await recordFate($, ended, end)
95}
96
97async function review($: EngineInterface): Promise<ReviewRecord> {
98  isReviewing = true
99  try {
100    const recent = strings(await $.store.get('recent'))
101    const muted = strings(await $.store.get('muted'))
102    const summary = reactionSummary(reactions(await $.store.get('reactions')))
103    const answer = await read($, lastAnswer)
104    const reply = await $.model.fork({ prompt: reviewPrompt(recent, muted, summary, answer) })
105    const record = await conclude($, reply)
106    await update($, lastReview, () => record)
107
108    return record
109  } catch (error) {
110    const record: ReviewRecord = { at: Date.now(), outcome: 'failed', detail: String(error) }
111    await update($, lastReview, () => record)
112
113    return record
114  } finally {
115    isReviewing = false
116  }
117}
118
119async function conclude($: EngineInterface, reply: ModelForkResult): Promise<ReviewRecord> {
120  if (!reply.isAnswered) {
121    const detail = reply.reason === 'api-error' ? `HTTP ${reply.status ?? 'none'}, ${reply.error}` : undefined
122
123    return { at: Date.now(), outcome: reply.reason, detail }
124  }
125
126  return { ...(await settleVerdict($, reply.text)), reply: reply.text }
127}
128
129async function settleVerdict($: EngineInterface, text: string): Promise<ReviewRecord> {
130  const at = Date.now()
131
132  const verdict = readVerdict(text)
133  if (verdict.kind === 'clean') return { at, outcome: 'clean' }
134  if (verdict.kind === 'malformed') return { at, outcome: 'malformed', detail: verdict.reason }
135
136  // Read again: the user may have muted, unmuted or reset while the model was answering.
137  const recent = strings(await $.store.get('recent'))
138  const muted = strings(await $.store.get('muted'))
139  const { headline } = verdict.finding
140  if (mentions([...recent, ...muted], headline)) return { at, outcome: 'muted', detail: headline }
141
142  const replaced = await read($, finding)
143  if (replaced !== null) await endFinding($, replaced.id, 'replaced')
144  await $.store.set('recent', withTopic(recent, headline, MAX_RECENT))
145  const shown: Finding = { ...verdict.finding, id: crypto.randomUUID(), isOpen: false, wasOpened: false, promptsUnread: 0 }
146  await update($, finding, () => shown)
147
148  return { at, outcome: 'flagged', detail: headline }
149}
150
151async function mute($: EngineInterface, muted: Finding) {
152  await $.store.set('muted', withTopic(strings(await $.store.get('muted')), muted.headline, MAX_MUTED))
153  await endFinding($, muted.id, 'muted')
154}
155
156async function toggle($: EngineInterface, id: string) {
157  await update($, finding, f => (f?.id === id ? { ...f, isOpen: !f.isOpen, wasOpened: true } : f))
158}
159
160async function askClaude($: EngineInterface, shown: Finding) {
161  const box = await $.prompt.read()
162  if (box.text.trim() !== '') {
163    $.ui.toast('blind-spots: the prompt box is not empty. Send or clear it first.')
164
165    return
166  }
167  const filled = await $.prompt.fill({ text: askDraft(shown.headline, shown.nextStep) })
168  if (!filled.isFilled) {
169    $.ui.toast('blind-spots: could not put the question in the prompt box.')
170
171    return
172  }
173  await endFinding($, shown.id, 'asked')
174}
175
176async function countUnreadPrompt($: EngineInterface) {
177  const shown = await read($, finding)
178  if (shown === null || shown.wasOpened) return
179  if (shown.promptsUnread + 1 >= PROMPTS_BEFORE_EXPIRY) {
180    await endFinding($, shown.id, 'expired')
181
182    return
183  }
184  await update($, finding, f => (f?.id === shown.id ? { ...f, promptsUnread: f.promptsUnread + 1 } : f))
185}
186
187async function status($: EngineInterface, minToolCalls: number): Promise<string> {
188  const muted = strings(await $.store.get('muted')).length
189  const { level, threshold } = await currentThreshold($, minToolCalls)
190  const summary = reactionSummary(reactions(await $.store.get('reactions')))
191  const last = await read($, lastReview)
192
193  return [
194    `Reviews a turn once it ends with at least ${threshold} tool calls (base ${minToolCalls}, back-off level ${level} in this project); ${muted} topic(s) muted.`,
195    summary.length === 0 ? 'No reactions recorded yet.' : `Your recent reactions: ${summary.join('; ')}.`,
196    last === null ? 'No review has run in this session yet.' : `Last review: ${describe(last)}`,
197    ...(last?.reply === undefined ? [] : [`Reviewer reply:\n${cut(last.reply, MAX_SHOWN_REPLY)}`]),
198  ].join('\n')
199}
200
201function cut(text: string, limit: number): string {
202  return text.length <= limit ? text : `${text.slice(0, limit)}\n… ${text.length - limit} more characters not shown.`
203}
204
205function describe(record: ReviewRecord): string {
206  const when = new Date(record.at).toLocaleTimeString()
207  const detail = record.detail === undefined ? '' : ` (${record.detail})`
208
209  return `${when}: ${OUTCOME_TEXT[record.outcome]}${detail}`
210}
211
212export const register: Register = (on, options) => {
213  const configured = Number(options.minToolCalls)
214  const minToolCalls = Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MIN_TOOL_CALLS
215
216  on('session.start', async ($, e, next) => {
217    await $.command.register({
218      name: 'blind-spots',
219      description: 'Review long turns for decisions, risks, gaps and concepts you may have missed',
220      argumentHint: '[status|review|unmute|reset]',
221    })
222
223    return next(e)
224  })
225
226  on('turn.step', async function* ($, e, next) {
227    const result = yield* next(e)
228    if (e.agentId === undefined) {
229      toolCallsByTurn.set(e.turnId, (toolCallsByTurn.get(e.turnId) ?? 0) + result.toolUses.length)
230    }
231
232    return result
233  })
234
235  on('turn.complete', async ($, e, next) => {
236    const result = await next(e)
237    if (e.agentId !== undefined) return result
238
239    const toolCalls = toolCallsByTurn.get(e.turnId) ?? 0
240    toolCallsByTurn.delete(e.turnId)
241    if (e.reason !== 'answer') return result
242    await update($, lastAnswer, () => e.answer)
243
244    const { threshold } = await currentThreshold($, minToolCalls)
245    if (isReviewDue(toolCalls, threshold, isReviewing)) {
246      // Not awaited: the session is idle again and the review lands whenever it is ready.
247      void review($)
248    }
249
250    return result
251  })
252
253  on('prompt.submit', async ($, e, next) => {
254    if (e.origin.kind === 'composer') await countUnreadPrompt($)
255
256    return next(e)
257  })
258
259  on('command.run', { command: 'blind-spots' }, async ($, e) => {
260    const arg = e.args.trim().toLowerCase()
261
262    if (arg === 'review') {
263      if (isReviewing) return { text: 'A review is already running.' }
264
265      return { text: describe(await review($)) }
266    }
267
268    if (arg === 'unmute') {
269      await $.store.delete('muted')
270      await $.store.delete('recent')
271
272      return { text: 'Forgot the muted and recently raised topics.' }
273    }
274
275    if (arg === 'reset') {
276      for (const key of ['muted', 'recent', 'reactions', 'quiet']) await $.store.delete(key)
277
278      return { text: 'Forgot the muted topics, your reactions and the back-off of every project.' }
279    }
280
281    if (arg === '' || arg === 'status') return { text: await status($, minToolCalls) }
282
283    return { text: USAGE }
284  })
285
286  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
287    const shown = await read($, finding)
288    if (e.props.hasSurvey || shown === null) return next(e)
289
290    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
291
292    return (
293      <Box flexDirection="column">
294        <Text wrap="wrap">
295          <Text bold color={KIND_COLOR[shown.kind]}>
296            blind spot · {shown.kind}
297          </Text>{' '}
298          {shown.headline}
299        </Text>
300        {shown.isOpen && <Markdown text={`${shown.details}\n\n**Next step:** ${shown.nextStep}`} />}
301        <Box flexDirection="row" gap={2}>
302          <Button
303            key="toggle"
304            plain
305            hotkey="d"
306            label={shown.isOpen ? 'Hide details' : 'Details'}
307            onPress={() => toggle($, shown.id)}
308          />
309          <Button key="ask" plain hotkey="a" label="Ask Claude" onPress={() => askClaude($, shown)} />
310          <Button
311            key="mute"
312            plain
313            hotkey="m"
314            label={shown.kind === 'concept' ? 'I know this' : 'Mute topic'}
315            onPress={() => mute($, shown)}
316          />
317          <Button key="close" plain hotkey="x" role="dismiss" label="Close" onPress={() => endFinding($, shown.id, 'closed')} />
318        </Box>
319      </Box>
320    )
321  })
322}
323
hooks/feedback.ts 82 lines
1import type { Fate, Finding, FindingKind, QuietEntry, Reaction } from '../types'
2
3export const MAX_QUIET_LEVEL = 3
4export const MAX_REACTIONS = 30
5export const MAX_PROJECTS = 50
6/** An unopened finding expires, as ignored, once the user has sent this many prompts past it. */
7export const PROMPTS_BEFORE_EXPIRY = 3
8
9const DAY_MS = 24 * 60 * 60 * 1000
10
11export type FindingEnd = 'closed' | 'muted' | 'asked' | 'expired' | 'replaced'
12
13export function fateOf(finding: Finding, end: FindingEnd): Fate {
14  if (finding.wasOpened || end === 'asked') return 'engaged'
15
16  return end === 'muted' ? 'rejected' : 'ignored'
17}
18
19/** The stored level loses one step per full day since it last changed. */
20export function effectiveLevel(entry: QuietEntry | undefined, now: number): number {
21  if (entry === undefined) return 0
22  const decayed = entry.level - Math.floor(Math.max(0, now - entry.changedAt) / DAY_MS)
23
24  return Math.min(MAX_QUIET_LEVEL, Math.max(0, decayed))
25}
26
27export function afterFate(entry: QuietEntry | undefined, fate: Fate, now: number): QuietEntry {
28  const level = effectiveLevel(entry, now)
29  const next = fate === 'engaged' ? level - 1 : level + 1
30
31  return { level: Math.min(MAX_QUIET_LEVEL, Math.max(0, next)), changedAt: now }
32}
33
34export function reviewThreshold(minToolCalls: number, level: number): number {
35  return minToolCalls * 2 ** level
36}
37
38export function reactions(value: unknown): Reaction[] {
39  return Array.isArray(value)
40    ? value.filter(
41        (one): one is Reaction =>
42          typeof one === 'object' && one !== null && typeof one.kind === 'string' && typeof one.fate === 'string',
43      )
44    : []
45}
46
47export function withReaction(list: readonly Reaction[], reaction: Reaction): Reaction[] {
48  return [...list, reaction].slice(-MAX_REACTIONS)
49}
50
51export type QuietMap = Record<string, QuietEntry>
52
53export function quietMap(value: unknown): QuietMap {
54  return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as QuietMap) : {}
55}
56
57/** Keeps the most recently changed projects so the store does not grow without bound. */
58export function withQuiet(map: QuietMap, root: string, entry: QuietEntry): QuietMap {
59  const entries = Object.entries({ ...map, [root]: entry })
60    .sort(([, a], [, b]) => b.changedAt - a.changedAt)
61    .slice(0, MAX_PROJECTS)
62
63  return Object.fromEntries(entries)
64}
65
66const FATE_WORDS: Record<Fate, string> = { engaged: 'engaged', ignored: 'ignored', rejected: 'muted' }
67const KIND_ORDER: readonly FindingKind[] = ['decision', 'risk', 'gap', 'concept']
68
69/** One line per kind the user has reacted to, e.g. "concept: 1 engaged, 4 ignored". */
70export function reactionSummary(list: readonly Reaction[]): string[] {
71  return KIND_ORDER.flatMap(kind => {
72    const ofKind = list.filter(one => one.kind === kind)
73    if (ofKind.length === 0) return []
74    const counts = (['engaged', 'ignored', 'rejected'] as const)
75      .map(fate => [fate, ofKind.filter(one => one.fate === fate).length] as const)
76      .filter(([, count]) => count > 0)
77      .map(([fate, count]) => `${count} ${FATE_WORDS[fate]}`)
78
79    return [`${kind}: ${counts.join(', ')}`]
80  })
81}
82
hooks/review.ts 136 lines
1import type { FindingKind } from '../types'
2
3export const MAX_MUTED = 100
4export const MAX_RECENT = 10
5
6export const KINDS: readonly FindingKind[] = ['decision', 'risk', 'gap', 'concept']
7
8export type Verdict =
9  | { kind: 'clean' }
10  | { kind: 'malformed'; reason: string }
11  | { kind: 'flagged'; finding: { kind: FindingKind; headline: string; details: string; nextStep: string } }
12
13export function isReviewDue(toolCalls: number, threshold: number, isReviewing: boolean): boolean {
14  return !isReviewing && toolCalls >= threshold
15}
16
17/** Two headlines that differ only in case, punctuation or spacing name one topic. */
18export function topicKey(headline: string): string {
19  return headline
20    .toLowerCase()
21    .replace(/[^\p{L}\p{N}]+/gu, ' ')
22    .trim()
23}
24
25export function mentions(list: readonly string[], headline: string): boolean {
26  const key = topicKey(headline)
27
28  return list.some(one => topicKey(one) === key)
29}
30
31export function withTopic(list: readonly string[], headline: string, limit: number): string[] {
32  return [...list.filter(one => topicKey(one) !== topicKey(headline)), headline].slice(-limit)
33}
34
35export function strings(value: unknown): string[] {
36  return Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : []
37}
38
39function field(record: Record<string, unknown>, name: string): string {
40  const value = record[name]
41
42  return typeof value === 'string' ? value.trim() : ''
43}
44
45/** The reviewer is asked for bare JSON, but a fenced block or a sentence around it is tolerated. */
46export function readVerdict(text: string): Verdict {
47  const start = text.indexOf('{')
48  const end = text.lastIndexOf('}')
49  if (start === -1 || end < start) return { kind: 'malformed', reason: 'no JSON object in the reply' }
50
51  let parsed: unknown
52  try {
53    parsed = JSON.parse(text.slice(start, end + 1))
54  } catch (error) {
55    return { kind: 'malformed', reason: `invalid JSON: ${String(error)}` }
56  }
57  if (typeof parsed !== 'object' || parsed === null) return { kind: 'malformed', reason: 'not an object' }
58
59  const record = parsed as Record<string, unknown>
60  if (record.flag === false) return { kind: 'clean' }
61  if (record.flag !== true) return { kind: 'malformed', reason: '"flag" is neither true nor false' }
62
63  const kind = field(record, 'kind') as FindingKind
64  if (!KINDS.includes(kind)) return { kind: 'malformed', reason: `unknown kind "${kind}"` }
65
66  const headline = field(record, 'headline')
67  const details = field(record, 'details')
68  const nextStep = field(record, 'next_step')
69  if (headline === '' || details === '' || nextStep === '') {
70    return { kind: 'malformed', reason: 'headline, details or next_step is empty' }
71  }
72
73  return { kind: 'flagged', finding: { kind, headline, details, nextStep } }
74}
75
76function listed(title: string, items: readonly string[]): string {
77  return items.length === 0 ? '' : `\n${title}\n${items.map(item => `- ${item}`).join('\n')}\n`
78}
79
80export function reviewPrompt(
81  recent: readonly string[],
82  muted: readonly string[],
83  reactions: readonly string[],
84  lastAnswer: string | null,
85): string {
86  // The forked request ends where the latest turn's last model request did, before its reply.
87  const finalAnswer =
88    lastAnswer === null
89      ? ''
90      : `The conversation above stops just before your final reply of the latest turn. That reply was:\n<final_reply>\n${lastAnswer}\n</final_reply>\nReview it together with the work. People skim long replies: a decision, caveat or risk stated in this reply, even clearly, can still be the one thing to put in front of the user. Do not skip a point just because the reply already mentions it.\n`
91
92  const calibration =
93    reactions.length === 0
94      ? ''
95      : `\nHow the user reacted to recent findings, by kind (engaged: opened or asked about it; ignored: closed or left unread; muted: asked never to see it again):\n${reactions.map(line => `- ${line}`).join('\n')}\nFor a kind the user mostly ignores or mutes, raise one only if it is exceptionally important. Kinds they engage with are welcome.\n`
96
97  return `[blind-spots review: an automated request from a Claude Code plugin, not a message the user typed]
98
99Stop working on the task. For this one reply you are a reviewer, not the assistant above. Tool calls are disabled and the main session will not see your answer; only the user will, in a one-line banner above their prompt.
100
101The user is busy, switches between tasks, and skims. The banner is their takeaway from this work: the single thing they most need to know before they move on, whether it is buried in the tool calls or sits in plain sight in the final reply.
102
103${finalAnswer}
104Look back at the work done in this conversation, especially the latest turn, and pick the one thing the user should not miss. It is one of:
105- decision: you (the assistant) picked an approach, default, scope cut or trade-off on your own, and the user never weighed in on it.
106- risk: something in the result may be wrong, fragile or unsafe: a failing or skipped check, an assumption you could not confirm, a change with side effects outside what was asked.
107- gap: something the user probably believes is done but is not: a step left out, a test not run, a TODO, a disabled feature, an unverified claim.
108- concept: a system, mechanism or design that shapes this work and that the user has not shown they understand: judge from their own messages, not from what you explained. Raise it only if misunderstanding it would plausibly lead them to a wrong decision or wasted effort later; something merely interesting does not qualify.
109
110Raise it only if ALL hold:
1111. A reasonable user would want to know before they move on: ignoring it would plausibly cost real time, money, correctness or trust.
1122. The user has not taken it up themselves: they did not ask about it, answer it, or discuss it in their own messages. What the assistant wrote does not count as the user knowing it, however prominently it was said.
1133. The conversation itself supports it. Do not speculate beyond it.
114
115Pick the single most important one; a concept wins only when there is no decision, risk or gap worth raising. A long turn that changed code or made choices usually has one point worth a line, but do not invent stakes: when nothing meets rule 1, raise nothing.
116${listed('Already raised recently; do not raise these again:', recent)}${listed('The user muted these topics or said they already know them; never raise them:', muted)}${calibration}
117Reply with ONE JSON object and nothing else, no code fence.
118
119Nothing to raise:
120{"flag": false, "reason": "..."}
121
122Something to raise:
123{"flag": true, "kind": ${KINDS.map(kind => `"${kind}"`).join(' | ')}, "headline": "...", "details": "...", "next_step": "...", "reason": "..."}
124
125- reason: one sentence for the plugin's log, not shown in the banner: the strongest candidate you considered and why it did or did not clear the bar.
126- headline: at most 12 words, the takeaway itself, written so that someone who reads nothing else still gets the point. It must make sense without having read the conversation. Name the concrete thing (the file, endpoint, flag, test).
127- details: Markdown, at most 120 words. What happened, why it matters, and how sure you are. Define any term the user has not used themselves. Do not refer to "the second option" or similar; restate what you mean. For a concept, explain it from scratch with a small concrete example from this work.
128- next_step: one short imperative sentence the user can act on (check, decide, ask for). For a concept, say where in their work it will matter.
129
130Do not copy secrets, tokens, credentials or personal data from the conversation into the reply. Write the headline, details and next_step in the language the user writes in.`
131}
132
133export function askDraft(headline: string, nextStep: string): string {
134  return `Your blind-spot reviewer raised: "${headline}". Its suggested next step: ${nextStep} Can you go over this with me?`
135}
136
types/index.d.ts 52 lines
1/**
2 * decision: the agent chose something on its own; risk: something may be wrong;
3 * gap: something was skipped or not verified; concept: something the user should understand.
4 */
5export type FindingKind = 'decision' | 'risk' | 'gap' | 'concept'
6
7export type Finding = {
8  /** Identifies the finding a banner action was drawn for, so a stale press cannot touch a newer one. */
9  id: string
10  kind: FindingKind
11  headline: string
12  details: string
13  nextStep: string
14  isOpen: boolean
15  wasOpened: boolean
16  /** Prompts the user sent while the finding stood unopened. */
17  promptsUnread: number
18}
19
20/** engaged: opened or asked about; rejected: muted unopened; ignored: closed, expired or replaced unopened. */
21export type Fate = 'engaged' | 'ignored' | 'rejected'
22
23export type Reaction = { kind: FindingKind; fate: Fate; at: number }
24
25/** How much the review threshold is raised for one project; it decays with time since `changedAt`. */
26export type QuietEntry = { level: number; changedAt: number }
27
28export type ReviewOutcome =
29  | 'flagged'
30  | 'clean'
31  | 'muted'
32  | 'malformed'
33  | 'nothing-to-fork'
34  | 'api-error'
35  | 'empty-reply'
36  | 'aborted'
37  | 'failed'
38
39export type ReviewRecord = {
40  at: number
41  outcome: ReviewOutcome
42  detail?: string
43  /** The reviewer's raw reply, when the model answered. */
44  reply?: string
45}
46
47declare module 'claude-code' {
48  interface PluginState {
49    'blind-spots': { finding: Finding | null; lastReview: ReviewRecord | null; lastAnswer: string | null }
50  }
51}
52