SLOPSHOPPER

think-first

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

newpanebandcommandstatusprompt
★ 47v0.1.0MITupdated 2026-10-06petekp/claude-code-setup/mods/think-first
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · think-first
│ ┃ Your thinking ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing recorded in the last 6 weeks. │ ┃ A question about your business or users asks ⏺ Read(src/auth.ts) │ ┃ for your take first. ⎿ Read 6 lines │ ┃ To make a prediction, add a line starting ⏺ Update(src/auth.ts) │ ┃ "expect:" to a prompt. ⎿ 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 │ │ › /think │ ⎿ think-first: Opened your thinking record. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Your thinking
Nothing recorded in the last 6 weeks. A question about your business or users asks for your take first. To make a prediction, add a line starting "expect:" to a prompt.
README

think-first

A Claude Code mod that keeps you doing your own thinking about your business and its users. It does two things:

  • Before Claude answers a question about the business, it asks for your take.
  • On any prompt, you can predict the result. After the turn, a check tells you where your thinking was off.

PLAN.md explains the reasoning and the review that shaped this version.

Your take first

  1. You ask a question about your business or its users, such as "Why do small teams cancel in their second month?".
  2. The mod holds the prompt back and puts it in the prompt box again, with My take: on a new line. A notice says why.
  3. Write your take and press Enter. Claude is told to say what holds up, what's wrong or missing, and what evidence would settle it, before giving its own answer.
  4. To skip, press Enter with the line empty. The question goes as it was, and the session doesn't ask again for 30 minutes.

Details:

  • What gets caught. A Haiku call decides which prompts count, using the definition in 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.
  • Writing your own take. You can add a My take: line to any prompt without being asked. Claude gets the same instruction, and it counts as a take.

Predictions

  1. Add a line starting expect: to any prompt: `` Find out why cancellations rose in September expect: the price change in August drove it ``
  2. The mod removes the line before Claude sees the prompt. While the turn runs, the band above the prompt shows your prediction.
  3. When the turn ends, a Sonnet call compares your prediction with Claude's reply. It rates the prediction Hit, Partly right, Miss, or Too vague to check, and says in one sentence where your thinking was off.
  4. Press ctrl+x tab, then:
  5. d to discuss, which puts your prediction and the verdict in the prompt box as a question for Claude
  6. x to dismiss

A prediction that names something checkable teaches more than "it will work", which gets rated Too vague.

/think

Covers the last 6 weeks, across every project:

  • how many thinking questions got your take first
  • your prediction ratings
  • one row per week
  • Where your thinking was off: your latest missed, partly right, and vague predictions, each with the check's sentence. This list is the one worth rereading.

Where the data lives

Each take and prediction is one JSON file in ~/.claude/think-first/records/.

Loading it

  • This session: hot reload loads it from the session's mods folder.
  • Another session: claude --plugin-dir ~/Code/claude-code-setup/mods/think-first
  • Every session: add the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. That change is yours to make.

Limits

  • Delay. Each prompt of 20 or more characters waits for the Haiku call, about a second.
  • Sorting mistakes. The sorting will sometimes be wrong. A missed thinking question just goes through. A wrong catch costs one Enter.
  • Prompts typed during a turn. These reach the running turn directly, and the mod leaves them alone. An expect: line in one reaches Claude.
  • Shared band. The band above the prompt shows one plugin's row at a time. In a live test, this mod's prediction row showed in place of the inbox mod's.
  • Claude Code only. Codex sessions don't run mods.

Checks

claude plugin validate mods/think-first
claude plugin test mods/think-first
Source 2 files
hooks/register.tsx 389 lines
1import { 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}
389
types/index.d.ts 44 lines
1export 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