Asks what you expect while a long task runs, then shows how your predictions hold up

A Claude Code mod that keeps you doing your own thinking about your business and its users. It does two things:
PLAN.md explains the reasoning and the review that shaped this version.
My take: on a new line. A notice says why.Details:
CLASSIFY in hooks/register.tsx. Code, tooling, chores, and short replies go straight through. Edit the definition to match what you mean by a thinking question.My take: line to any prompt without being asked. Claude gets the same instruction, and it counts as a take.expect: to any prompt: `` Find out why cancellations rose in September expect: the price change in August drove it ``d to discuss, which puts your prediction and the verdict in the prompt box as a question for Claudex to dismissA prediction that names something checkable teaches more than "it will work", which gets rated Too vague.
Covers the last 6 weeks, across every project:
Each take and prediction is one JSON file in ~/.claude/think-first/records/.
claude --plugin-dir ~/Code/claude-code-setup/mods/think-firstCLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. That change is yours to make.expect: line in one reaches Claude.claude plugin validate mods/think-first
claude plugin test mods/think-firsthooks/register.tsx 389 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4import type { Entry, Gate, Open, Prediction, Rating, Take } from '../types'
5
6const PANE = 'think-first'
7const MARKER = 'My take:'
8const EXPECT_LINE = /^[ \t]*expect:[ \t]*(.*)$/gim
9/** Shorter prompts are replies in a conversation, not questions worth a take. */
10const MIN_QUESTION_CHARS = 20
11const QUIET_AFTER_SKIP_MS = 30 * 60_000
12const HISTORY_WEEKS = 6
13const OFF_LIST = 10
14const DAY_MS = 86_400_000
15const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
16const RATING_LABELS: Record<Rating, string> = { hit: 'Hit', partly: 'Partly right', miss: 'Miss', vague: 'Too vague to check' }
17
18const CLASSIFY = `You sort a message a person sent to their coding agent. The message is inside <message> tags. Do not answer or act on it. Reply with one word, THINK or BUILD.
19
20THINK: the message asks for judgment about the person's business or its users: what users need, do, or feel and why; how the business works, makes money, or grows; strategy; priorities; product direction; or whether a product decision is right.
21
22BUILD: anything else, including writing, fixing, reviewing, explaining, or running code; configuration and tooling; chores; and short replies within a conversation.`
23
24const CHECK = `You check a person's prediction against what actually happened. The person wrote the prediction before an AI agent did the work. Reply in exactly two lines:
25
26RATING: hit, partly, miss, or vague
27GAP: one sentence
28
29hit: the result matches the prediction's specific claim.
30partly: some of its claims matched and some did not. A prediction with one right claim and one wrong claim is partly.
31miss: the result contradicts all of it.
32vague: it is too general to check against the result, such as "it will work".
33
34GAP, for partly or miss: what the person expected versus what turned out to be true, concretely. For hit: what made the prediction right. For vague: what a checkable prediction would have named. Address the person as "you".`
35
36const TAKE_NOTE =
37 'think-first: the user wrote their own take after "My take:" before seeing yours. Before giving your answer, respond to their take: say what holds up, what is wrong or missing, and what evidence would settle it. Do not simply agree with it.'
38
39const NOTICE = `Your take first: write it after "${MARKER}" and press Enter. To skip, press Enter with it empty.`
40
41const GATE = atom({ plugin: 'think-first', key: 'gate' } as const, null as Gate | null)
42const OPEN = atom({ plugin: 'think-first', key: 'open' } as const, null as Open | null)
43const HISTORY = atom({ plugin: 'think-first', key: 'history' } as const, [] as Entry[])
44
45let isOn = false
46let dir = ''
47let sessionId = ''
48let folder = ''
49let quietUntil = 0
50// The full prompt behind the open prediction, for the check. A reload loses it,
51// and the check then reads the prediction's shortened copy.
52let predictedPrompt: string | null = null
53
54function isPersonsWords(origin: PromptOrigin): boolean {
55 return origin.kind === 'composer' || origin.kind === 'bridge' || (origin.kind === 'plugin' && origin.asUser === true)
56}
57
58/** Takes the `expect:` lines out of a prompt. */
59function splitExpect(text: string): { text: string; expect: string | null } {
60 const found = [...text.matchAll(EXPECT_LINE)].map(m => (m[1] ?? '').trim()).filter(Boolean)
61 if (found.length === 0) return { text, expect: null }
62
63 return { text: text.replace(EXPECT_LINE, '').replace(/\n{3,}/g, '\n\n').trim(), expect: found.join(' ') }
64}
65
66/** Splits a prompt at its last `My take:` line, or returns null when it has none. */
67function splitTake(text: string): { question: string; take: string } | null {
68 const at = text.lastIndexOf(MARKER)
69 if (at === -1 || (at > 0 && text[at - 1] !== '\n')) return null
70
71 return { question: text.slice(0, at).trim(), take: text.slice(at + MARKER.length).trim() }
72}
73
74// One prompt can make a take and a prediction in the same millisecond, so the kind is part of the id.
75async function newId($: EngineInterface, kind: Entry['kind']): Promise<{ id: string; at: number }> {
76 const at = await $.clock.now()
77
78 return { id: `${at}-${sessionId.slice(0, 8)}-${kind}`, at }
79}
80
81// Each record is its own file, written only by the session that made it, so
82// sessions running at once never overwrite each other's records.
83async function save($: EngineInterface, entry: Entry) {
84 await $.fs.write(`${dir}/${entry.id}.json`, JSON.stringify(entry))
85 await update($, HISTORY, h => [entry, ...h.filter(one => one.id !== entry.id)])
86}
87
88async function saveTake($: EngineInterface, question: string, take: string | null) {
89 const { id, at } = await newId($, 'take')
90 const entry: Take = { kind: 'take', id, at, folder, question: question.slice(0, 300), take }
91 await save($, entry)
92}
93
94async function isThinkingQuestion($: EngineInterface, text: string): Promise<boolean> {
95 $.ui.status('Checking whether this asks for your take…')
96 const r = await $.model.complete({ model: 'haiku', system: CLASSIFY, prompt: `<message>\n${text.slice(0, 4000)}\n</message>`, maxTokens: 5, effort: 'low', timeoutMs: 3000 })
97 $.ui.status(undefined)
98
99 return r.isAnswered && /THINK/i.test(r.text)
100}
101
102/** Fills the prompt box once the engine has cleared it of the dropped prompt. */
103function refill($: EngineInterface, text: string) {
104 $.clock.after(50, () => {
105 void $.prompt.fill({ text: `${text.trimEnd()}\n\n${MARKER} `, mode: 'replace' })
106 })
107}
108
109function parseCheck(text: string): { rating: Rating; gap: string } | null {
110 const rating = /RATING:\s*(hit|partly|miss|vague)/i.exec(text)?.[1]?.toLowerCase() as Rating | undefined
111 const gap = /GAP:\s*(.+)/i.exec(text)?.[1]?.trim()
112
113 return rating && gap ? { rating, gap } : null
114}
115
116async function check($: EngineInterface, p: Prediction, prompt: string, answer: string) {
117 const r = await $.model.complete({
118 model: 'sonnet',
119 system: CHECK,
120 prompt: `<request>\n${prompt.slice(0, 6000)}\n</request>\n<prediction>\n${p.expect}\n</prediction>\n<result>\n${answer.slice(0, 12_000)}\n</result>`,
121 maxTokens: 300,
122 effort: 'low',
123 timeoutMs: 60_000,
124 })
125 const parsed = r.isAnswered ? parseCheck(r.text) : null
126 const checked: Prediction = { ...p, rating: parsed?.rating ?? null, gap: parsed?.gap ?? null }
127 await save($, checked)
128 await update($, OPEN, o => (o?.prediction.id === p.id ? { prediction: checked, phase: 'checked' as const } : o))
129}
130
131async function discuss($: EngineInterface) {
132 const o = await read($, OPEN)
133 if (!o) return
134 await update($, OPEN, () => null)
135 const p = o.prediction
136 const verdict = p.rating ? ` A check rated it "${RATING_LABELS[p.rating]}": ${p.gap}` : ''
137 await $.prompt.fill({ text: `Before that turn I predicted: "${p.expect}".${verdict} Where was my thinking off, and what should I have looked at?`, mode: 'replace' })
138}
139
140function parseEntry(text: string): Entry | null {
141 try {
142 const e = JSON.parse(text) as Entry
143 if (typeof e.id !== 'string' || typeof e.at !== 'number') return null
144
145 return e.kind === 'take' || e.kind === 'prediction' ? e : null
146 } catch {
147 return null
148 }
149}
150
151async function loadHistory($: EngineInterface) {
152 const since = (await $.clock.now()) - HISTORY_WEEKS * 7 * DAY_MS
153 const files = await $.fs.list(dir).catch(() => [])
154 const names = files.map(f => f.name).filter(name => name.endsWith('.json') && Number(name.split('-')[0]) >= since)
155 const texts = await Promise.all(names.map(name => $.fs.read(`${dir}/${name}`).catch(() => '')))
156 const list = texts
157 .map(t => parseEntry(String(t)))
158 .filter((e): e is Entry => e !== null)
159 .sort((a, b) => b.at - a.at)
160 await update($, HISTORY, () => list)
161}
162
163function day(at: number): string {
164 const d = new Date(at)
165
166 return `${MONTHS[d.getMonth()]} ${d.getDate()}`
167}
168
169/** Midnight of the Monday that starts the week holding `at`, local time. */
170function weekStart(at: number): number {
171 const d = new Date(at)
172 d.setHours(0, 0, 0, 0)
173 d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
174
175 return d.getTime()
176}
177
178function counts(list: Entry[]) {
179 const takes = list.filter((e): e is Take => e.kind === 'take')
180 const predictions = list.filter((e): e is Prediction => e.kind === 'prediction')
181 const rated = (rating: Rating) => predictions.filter(p => p.rating === rating).length
182
183 return {
184 asked: takes.length,
185 given: takes.filter(t => t.take !== null).length,
186 predictions: predictions.length,
187 hit: rated('hit'),
188 partly: rated('partly'),
189 miss: rated('miss'),
190 vague: rated('vague'),
191 }
192}
193
194function lastSegment(path: string): string {
195 return path.split('/').filter(Boolean).at(-1) ?? path
196}
197
198export const register: Register = on => {
199 on('session.start', async ($, e, next) => {
200 const r = await next(e)
201 isOn = e.isInteractive
202 if (!isOn) return r
203 sessionId = await $.session.id()
204 folder = await $.session.root()
205 dir = `${(await $.env.get('HOME')) ?? ''}/.claude/think-first/records`
206 await $.command.register({ name: 'think', description: 'Show your takes and where your predictions were off' })
207
208 return r
209 })
210
211 on('session.end', async ($, e, next) => {
212 if (isOn && e.reason === 'clear') {
213 await update($, GATE, () => null)
214 await update($, OPEN, () => null)
215 predictedPrompt = null
216 }
217
218 return next(e)
219 })
220
221 on('prompt.submit', async ($, e, next) => {
222 // A prompt sent while a turn runs reaches that turn directly; it is left alone.
223 if (!isOn || e.turnId || !isPersonsWords(e.origin)) return next(e)
224 const { text, expect } = splitExpect(e.text)
225 const take = splitTake(text)
226 let context = e.context
227 let sent = text
228
229 if (await read($, GATE)) {
230 await update($, GATE, () => null)
231 if (take?.take) {
232 await saveTake($, take.question, take.take)
233 context = [...(context ?? []), TAKE_NOTE]
234 } else {
235 // An empty take, or a prompt rewritten without one: a skip.
236 await saveTake($, take?.question ?? text, null)
237 quietUntil = (await $.clock.now()) + QUIET_AFTER_SKIP_MS
238 if (take) sent = take.question
239 }
240 } else if (take?.take) {
241 // A take the person wrote without being asked.
242 await saveTake($, take.question, take.take)
243 context = [...(context ?? []), TAKE_NOTE]
244 } else if (!take && text.length >= MIN_QUESTION_CHARS && (await $.clock.now()) >= quietUntil && (await isThinkingQuestion($, text))) {
245 const at = await $.clock.now()
246 await update($, GATE, () => ({ question: text, at }))
247 refill($, e.text)
248
249 return { drop: NOTICE }
250 }
251
252 if (expect && sent) {
253 const { id, at } = await newId($, 'prediction')
254 const prediction: Prediction = { kind: 'prediction', id, at, folder, prompt: sent.slice(0, 300), expect, rating: null, gap: null }
255 predictedPrompt = sent
256 await update($, OPEN, () => ({ prediction, phase: 'running' as const }))
257 }
258
259 return next({ ...e, text: sent || e.text, context })
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 const r = await next(e)
264 if (!isOn || e.agentId) return r
265 const o = await read($, OPEN)
266 if (o?.phase !== 'running') return r
267 const p = o.prediction
268 const prompt = predictedPrompt ?? p.prompt
269 predictedPrompt = null
270 if (e.reason === 'answer' && !e.isAborted && e.answer.trim()) {
271 await update($, OPEN, () => ({ prediction: p, phase: 'checking' as const }))
272 void check($, p, prompt, e.answer)
273 } else {
274 // An interrupted turn has no result to check the prediction against.
275 await update($, OPEN, () => null)
276 await save($, p)
277 }
278
279 return r
280 })
281
282 on('command.run', { command: 'think' }, async $ => {
283 await loadHistory($)
284 await $.ui.open({ id: PANE, title: 'Your thinking', focus: true, closeOnEscape: true })
285
286 return { text: 'Opened your thinking record.' }
287 })
288
289 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
290 if (!isOn || e.props.hasSurvey) return next(e)
291 const o = await read($, OPEN)
292 if (!o) return next(e)
293 const { Box, Button, Text } = $.ui.resolve(e)
294 const p = o.prediction
295
296 if (o.phase !== 'checked') {
297 return (
298 <Text dimColor wrap="truncate-end">
299 You expect: {p.expect}
300 {o.phase === 'checking' ? ' · checking it against the result…' : ''}
301 </Text>
302 )
303 }
304
305 return (
306 <Box flexDirection="column">
307 <Text wrap="truncate-end">
308 <Text bold>You expected:</Text> {p.expect}
309 </Text>
310 {p.rating ? (
311 <Text wrap="wrap">
312 <Text bold>{RATING_LABELS[p.rating]}.</Text> {p.gap}
313 </Text>
314 ) : (
315 <Text dimColor>The check didn't answer.</Text>
316 )}
317 <Box flexDirection="row" gap={2}>
318 <Button key="discuss" label="Discuss" hotkey="d" plain onPress={() => discuss($)} />
319 <Button key="dismiss" label="Dismiss" hotkey="x" plain onPress={() => update($, OPEN, () => null)} />
320 </Box>
321 </Box>
322 )
323 })
324
325 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
326 const { Box, Text } = $.ui.resolve(e)
327 const list = await read($, HISTORY)
328
329 if (list.length === 0) {
330 return (
331 <Box flexDirection="column">
332 <Text>Nothing recorded in the last {HISTORY_WEEKS} weeks.</Text>
333 <Text dimColor>A question about your business or users asks for your take first.</Text>
334 <Text dimColor>To make a prediction, add a line starting "expect:" to a prompt.</Text>
335 </Box>
336 )
337 }
338
339 const all = counts(list)
340 const weeks = new Map<number, Entry[]>()
341 for (const entry of list) {
342 const w = weekStart(entry.at)
343 weeks.set(w, [...(weeks.get(w) ?? []), entry])
344 }
345 const off = list
346 .filter((p): p is Prediction => p.kind === 'prediction' && (p.rating === 'miss' || p.rating === 'partly' || p.rating === 'vague'))
347 .slice(0, OFF_LIST)
348
349 return (
350 <Box flexDirection="column">
351 <Text dimColor>Last {HISTORY_WEEKS} weeks, all projects</Text>
352 <Text>
353 Your take first on <Text bold>{all.given}</Text> of {all.asked} thinking questions
354 </Text>
355 <Text>
356 {all.predictions} predictions: {all.hit} hit · {all.partly} partly · {all.miss} miss · {all.vague} too vague
357 </Text>
358 <Text> </Text>
359 <Text bold>{'Week of'.padEnd(10)}{'Takes'.padEnd(10)}Predictions</Text>
360 {[...weeks.entries()].map(([w, entries]) => {
361 const c = counts(entries)
362
363 return (
364 <Text>
365 {day(w).padEnd(10)}
366 {`${c.given} of ${c.asked}`.padEnd(10)}
367 {c.predictions}
368 </Text>
369 )
370 })}
371 <Text> </Text>
372 <Text bold>Where your thinking was off</Text>
373 {off.length === 0 && <Text dimColor>No missed, partial, or vague predictions yet.</Text>}
374 {off.map(p => (
375 <Box flexDirection="column" marginBottom={1}>
376 <Text wrap="wrap">
377 <Text bold>{p.rating ? RATING_LABELS[p.rating] : ''}:</Text> you expected "{p.expect}"
378 </Text>
379 <Text wrap="wrap">{p.gap}</Text>
380 <Text dimColor wrap="truncate-end">
381 {day(p.at)} · {lastSegment(p.folder)} · {p.prompt.replace(/\s+/g, ' ')}
382 </Text>
383 </Box>
384 ))}
385 </Box>
386 )
387 })
388}
389types/index.d.ts 44 lines1export type Rating = 'hit' | 'partly' | 'miss' | 'vague'
2
3/** A thinking question the mod held back for the person's own take. `take` is null when they skipped. */
4export type Take = {
5 kind: 'take'
6 id: string
7 at: number
8 /** The session's project folder. */
9 folder: string
10 question: string
11 take: string | null
12}
13
14/** A prediction from an `expect:` line. `rating` and `gap` stay null until the check answers. */
15export type Prediction = {
16 kind: 'prediction'
17 id: string
18 at: number
19 folder: string
20 /** The start of the prompt, without its `expect:` line. */
21 prompt: string
22 expect: string
23 rating: Rating | null
24 /** One sentence from the check: where the person's thinking was off, or what made it right. */
25 gap: string | null
26}
27
28export type Entry = Take | Prediction
29
30/** A thinking question waiting in the prompt box for the person's take. */
31export type Gate = { question: string; at: number }
32
33/**
34 * The prediction the band shows: `running` while its turn runs, `checking`
35 * while the check reads the result, `checked` once it answered or failed.
36 */
37export type Open = { prediction: Prediction; phase: 'running' | 'checking' | 'checked' }
38
39declare module 'claude-code' {
40 interface PluginState {
41 'think-first': { gate: Gate | null; open: Open | null; history: Entry[] }
42 }
43}
44