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.

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:
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.
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):
| Key | Button | What it does |
|---|---|---|
d | Details / Hide details | Shows or hides the explanation and a suggested next step |
a | Ask Claude | Puts a question about the finding into an empty prompt box, so you can discuss it in the main session |
m | Mute topic (I know this, for a concept) | Hides the finding and tells future reviews never to raise it again |
x | Close | Hides 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.Each finding's fate is recorded:
Two things adapt, each scoped to what it measures:
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.Muted topics are global too: a topic you know does not stop being known in another repository.
| Option | Default | Meaning |
|---|---|---|
minToolCalls | 8 | The 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.
hooks/register.tsx), not a skill.turn.step hook counts the main agent's tool calls in each turn. Subagent steps are not counted.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.$.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.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.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.
hooks/register.tsx 323 lines1import { 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}
323hooks/feedback.ts 82 lines1import 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}
82hooks/review.ts 136 lines1import 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}
136types/index.d.ts 52 lines1/**
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