SLOPSHOPPER

quiz-after

After a turn that finished a task, a forked check writes 2-3 questions about what was built and draws them above the prompt

newbandguardcommandtoastprompt
★ 536v0.1.0MITupdated 2026-10-03DannyMac180/skills/modsmith/templates/quiz-after
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quiz-after
› 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 › /quiz ⎿ quiz-after: quiz-after is off: no forks until /quiz on. /quiz now still works. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

quiz-after

When Claude finishes a task that changed something, a small quiz appears above the prompt: two or three questions about what was just built, each with a button that reveals the answer and why. It checks that you understand the change, not that you remember file names. Press Save to deck and the questions become spaced-repetition cards in your explain-this deck.

Quiz · Retry queue with backoff · fork: 48.2k cached, 350 new, 520 out
1. Why does the queue back off instead of retrying at a fixed rate?
   [ Reveal answer 1 ]
2. What happens to a job that fails after the last retry?
   a) It is dropped
   b) It goes to the dead-letter list
   c) It retries forever
   → It goes to the dead-letter list The worker never drops work silently.
[ Save to deck ]  [ Dismiss ]

How it decides

  1. While a turn runs, it counts the main conversation's tool calls that were not read-only (an edit, a write, a Bash command that changes things, a subagent launch). Reads, searches, plain chat and bookkeeping tools (TodoWrite, the task tools, plan mode, AskUserQuestion, ToolSearch) count for nothing.
  2. When the main turn ends normally and that count is above zero, it makes one $.model.fork call. The fork sees the conversation as the main thread last sent it and first answers: is the task actually finished?
  3. Not finished: nothing is drawn. Finished: it returns JSON with the questions, which are cleaned up (bad fields dropped, at most three, multiple choice only when the answer is one of the choices) and drawn.

The fork runs after the turn has ended, on a timer, so it never holds up the turn. If you send a new prompt before it answers, that quiz is thrown away.

What it costs

WhenModel callsTokens
Chat-only or read-only turn, aborted turn, subagent turnnone0
/quiz offnone0
A turn that changed something, quiz on, task finished1 forkthe whole transcript as input, served mostly from the prompt cache, plus about 350 new input tokens for the question and roughly 200 to 700 output tokens
The same, but the fork judges the task unfinished1 forkthe same cached transcript and ~350 new input tokens, about 10 output tokens; nothing drawn
/quiz now1 forkas a finished task
Reveal, Dismiss, Save to decknone0

A fork reuses the main thread's prompt cache, so the transcript is billed at the cache-read rate rather than full input price, as long as the cache entry is still warm (it is: the turn just ended). The quiz header shows each fork's actual split, cached / new / out, so you can see what you paid. A cached number near zero means the cache had lapsed or the model changed, and that fork paid full price for the transcript.

The token figures above are estimates from the prompt's length, not measurements; the header's numbers are the real ones. The cached read is the big term: on a 100k-token session, every qualifying turn reads 100k cached tokens, finished or not.

Commands and keys

  • /quiz toggles it on or off. The setting is kept in the plugin's $.store, so it survives restarts. /quiz on and /quiz off set it explicitly.
  • /quiz now asks for a quiz about the work so far, skipping the "is it finished?" check. It works even while the mod is off.
  • With the band focused (ctrl+x tab, or a click): 1, 2, 3 reveal answers, s saves to the deck, x dismisses. The declarations also say a bare digit typed into an empty prompt presses a band Button, so while a quiz is up, 1 in an empty prompt reveals answer 1 instead of typing (not tried live).

Save to deck (explain-this)

The button appends one card per question to $EXPLAIN_THIS_HOME/memory/cards.jsonl (default ~/.explain-this/memory/), in the shape explain-this/scripts/sm2.ts add writes: a fresh SM-2 state (interval_days 0, ease 2.5, due today), type transfer for transfer questions and recall otherwise, and the question's tag. The answer and its reason are stored together because explain-this review grades free-text answers against them. Questions already in the deck are skipped.

Nothing is written unless you press the button. If the memory folder does not exist, the mod creates nothing and says so in a toast. Run explain-this once to set the deck up.

Composes with

  • Other bands above the prompt (for example token-weather): quiz-after draws its quiz and then whatever the rest of the chain draws (await next(e)) beneath it, so it never hides another mod. It steps aside while a survey holds the band.
  • explain-this: shares its card deck, so explain-this review brings the questions back on the SM-2 schedule.
  • Other fork-based mods (a next-steps supervisor, an assumptions check): each one is its own fork and pays its own cache read. If you run several, check the cached column before adding a third.
  • A mode selector: quiz-after has no mode of its own yet. /quiz off is the switch a "learning mode" would flip.

Install

claude plugin validate templates/quiz-after
claude --plugin-dir templates/quiz-after
claude plugin test templates/quiz-after   # parsing, card shape, gating and the band on terminal and desktop

Copy the folder and rename it before changing it. Built against Claude Code 2.1.286's declarations.

Source 3 files
hooks/register.tsx 245 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Quiz, QuizPhase } from '../types'
5import { appendCards, forkPrompt, parseReply, toCards } from './quiz'
6
7const quizAtom = atom({ plugin: 'quiz-after', key: 'quiz' } as const, null as Quiz | null)
8const phaseAtom = atom({ plugin: 'quiz-after', key: 'phase' } as const, 'idle' as QuizPhase)
9
10const BOOKKEEPING = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskStop', 'AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode', 'ToolSearch'])
11
12const short = (n: number) => (n >= 1000 ? `${+(n / 1000).toFixed(1)}k` : `${n}`)
13
14const isOn = async ($: EngineInterface) => (await $.store.get('isOn').catch(() => true)) !== false
15
16const say = ($: EngineInterface, line: string) => $.ui.log(`quiz-after: ${line}`, { to: 'debug' })
17
18// Per-turn bookkeeping. Module variables are fine here: a reload mid-turn
19// only costs one skipped quiz, and nothing draws from them.
20let turnSeq = 0
21let changes = 0
22let isAsking = false
23
24// One fork per call. Resolves a line for /quiz now; never rejects.
25const ask = async ($: EngineInterface, isForced: boolean): Promise<string> => {
26  if (isAsking) return 'A quiz check is already running.'
27  isAsking = true
28  const seq = turnSeq
29
30  try {
31    await update($, phaseAtom, () => 'checking')
32    const reply = await $.model.fork({ prompt: forkPrompt(isForced) })
33
34    if (!reply.isAnswered) {
35      say($, `no quiz (${reply.reason})`)
36      return `No quiz: the fork did not answer (${reply.reason}).`
37    }
38
39    // A new prompt went in while the fork ran: that quiz is about stale work.
40    if (seq !== turnSeq && !isForced) return 'Skipped: a new turn started.'
41
42    const parsed = parseReply(reply.text)
43    const { usage } = reply
44    const cost = {
45      cached: usage.cache_read_input_tokens ?? 0,
46      input: usage.input_tokens + (usage.cache_creation_input_tokens ?? 0),
47      output: usage.output_tokens,
48    }
49
50    if (parsed.kind === 'not-done') return 'The fork judged the task not finished yet, so no quiz.'
51    if (parsed.kind === 'unreadable') {
52      say($, `unreadable reply: ${reply.text.slice(0, 120)}`)
53      return 'The fork answered, but not with a quiz this mod could read.'
54    }
55
56    const quiz: Quiz = { ...parsed, revealed: [], isSaved: false, cost }
57    await update($, quizAtom, () => quiz)
58    return `Quiz ready above the prompt: ${quiz.questions.length} questions.`
59  } catch (err) {
60    say($, `ask failed: ${err}`)
61    return 'No quiz: the check failed (see the debug log).'
62  } finally {
63    isAsking = false
64    await update($, phaseAtom, () => 'idle').catch(() => undefined)
65  }
66}
67
68const saveToDeck = async ($: EngineInterface) => {
69  try {
70    const quiz = await read($, quizAtom)
71    if (quiz === null || quiz.isSaved) return
72
73    const home = (await $.env.get('EXPLAIN_THIS_HOME')) || `${(await $.env.get('HOME')) ?? '~'}/.explain-this`
74    const memory = `${home}/memory`
75
76    // explain-this owns that folder; a quiz never creates it.
77    if (!(await $.fs.exists(memory))) {
78      $.ui.toast(`No explain-this deck at ${memory}, so nothing was saved`, { timeoutMs: 6000 })
79      return
80    }
81
82    const path = `${memory}/cards.jsonl`
83    const existing = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
84    const cards = toCards(quiz, await $.session.cwd(), await $.clock.now())
85    const { text, added } = appendCards(existing, cards)
86
87    if (added > 0) await $.fs.write(path, text)
88    await update($, quizAtom, q => (q === null ? q : { ...q, isSaved: true }))
89    $.ui.toast(added > 0 ? `Saved ${added} cards to your explain-this deck` : 'Those cards are already in your deck')
90  } catch (err) {
91    say($, `save failed: ${err}`)
92    $.ui.toast('Could not save to the explain-this deck (see the debug log)')
93  }
94}
95
96export const register: Register = on => {
97  on('session.start', async ($, e, next) => {
98    const result = await next(e)
99
100    await $.command
101      .register({ name: 'quiz', description: 'Quiz after finished tasks: on, off, or now (quiz-after)', argumentHint: '[on|off|now]' })
102      .catch(err => say($, `/quiz not registered: ${err}`))
103
104    return result
105  })
106
107  on('session.end', async ($, e, next) => {
108    if (e.reason === 'clear') {
109      await update($, quizAtom, () => null).catch(() => undefined)
110    }
111
112    return next(e)
113  })
114
115  on('prompt.submit', ($, e, next) => {
116    turnSeq += 1
117    changes = 0
118
119    return next(e)
120  })
121
122  // A turn counts as having done something once a main-loop tool ran that was
123  // not read-only. Subagents' own calls are skipped: delegating already shows
124  // as the main loop's Agent call, and another mod's background agent is not
125  // this task's work. Bookkeeping tools change no code, so they never pay a fork.
126  on('tool.call', async ($, e, next) => {
127    const result = await next(e)
128    const isChange = result.deny === undefined && result.isReadOnly !== true && !BOOKKEEPING.has(e.tool)
129    if (e.agentId === undefined && isChange) changes += 1
130
131    return result
132  })
133
134  on('turn.complete', async ($, e, next) => {
135    const result = await next(e)
136    const isWorthAsking = e.agentId === undefined && e.reason === 'answer' && changes > 0 && !isAsking
137
138    // The fork runs on a timer so the turn ends at once instead of waiting on it.
139    if (isWorthAsking && (await isOn($))) {
140      $.clock.after(0, () => {
141        void ask($, false)
142      })
143    }
144
145    return result
146  })
147
148  on('command.run', { command: 'quiz' }, async ($, e) => {
149    const arg = e.args.trim().toLowerCase()
150    if (arg === 'now') return { text: await ask($, true) }
151
152    const turnOn = arg === 'on' ? true : arg === 'off' ? false : arg === '' ? !(await isOn($)) : undefined
153    if (turnOn === undefined) return { text: 'Usage: /quiz [on|off|now]' }
154
155    try {
156      await $.store.set('isOn', turnOn)
157      if (!turnOn) await update($, quizAtom, () => null)
158    } catch (err) {
159      return { text: `quiz-after: could not save the setting (${err})` }
160    }
161
162    return {
163      text: turnOn
164        ? 'quiz-after is on: after a turn that changed something, one forked check may draw a quiz.'
165        : 'quiz-after is off: no forks until /quiz on. /quiz now still works.',
166    }
167  })
168
169  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170    const quiz = await read($, quizAtom)
171    const phase = await read($, phaseAtom)
172    const below = await next(e)
173
174    if (e.props.hasSurvey || (quiz === null && phase === 'idle')) return below
175
176    const { Box, Button, Text } = $.ui.resolve(e)
177
178    if (quiz === null) {
179      return (
180        <Box flexDirection="column">
181          <Text dimColor>Quiz: checking whether that task is finished…</Text>
182          {below}
183        </Box>
184      )
185    }
186
187    const reveal = (i: number) => () => {
188      void update($, quizAtom, q => (q === null ? q : { ...q, revealed: [...q.revealed, i] })).catch(() => undefined)
189    }
190    const dismiss = () => {
191      void update($, quizAtom, () => null).catch(() => undefined)
192    }
193    const { cached, input, output } = quiz.cost
194
195    return (
196      <Box flexDirection="column">
197        <Text wrap="truncate-end">
198          <Text bold color="cyan">
199            Quiz
200          </Text>
201          <Text dimColor>
202            {' '}
203            · {quiz.title} · fork: {short(cached)} cached, {short(input)} new, {short(output)} out
204          </Text>
205        </Text>
206        <Box>
207          {quiz.isSaved ? (
208            <Text dimColor>Saved to deck </Text>
209          ) : (
210            <Button key="save" hotkey="s" label="Save to deck" onPress={() => void saveToDeck($)} />
211          )}
212          <Text> </Text>
213          <Button key="dismiss" hotkey="x" role="dismiss" label="Dismiss" onPress={dismiss} />
214        </Box>
215        <Text dimColor wrap="truncate-end">
216          Click the band or press ctrl+x tab, then 1-3 to reveal, s to save, x to dismiss
217        </Text>
218        {quiz.questions.map((q, i) => (
219          <Box flexDirection="column">
220            <Text wrap="wrap">
221              <Text bold>{i + 1}. </Text>
222              {q.q}
223            </Text>
224            {(q.choices ?? []).map((c, j) => (
225              <Text wrap="wrap" dimColor>
226                {'   '}
227                {String.fromCharCode(97 + j)}) {c}
228              </Text>
229            ))}
230            {quiz.revealed.includes(i) ? (
231              <Text wrap="wrap">
232                <Text color="green">{'   '}→ {q.answer}</Text>
233                {q.why === '' ? '' : <Text dimColor> {q.why}</Text>}
234              </Text>
235            ) : (
236              <Button key={`reveal-${i}`} hotkey={String(i + 1)} label={`Reveal answer ${i + 1}`} dimColor onPress={reveal(i)} />
237            )}
238          </Box>
239        ))}
240        {below}
241      </Box>
242    )
243  })
244}
245
hooks/quiz.ts 143 lines
1import type { Quiz, QuizQuestion, QuizTag } from '../types'
2
3// Pure helpers: no $, so the parsing and the card shape can be tested anywhere.
4
5const TAGS: QuizTag[] = ['terminology', 'mechanism', 'derivation', 'transfer', 'big-picture']
6const MAX_QUESTIONS = 3
7const MAX_FIELD = 400
8
9export const forkPrompt = (isForced: boolean) =>
10  [
11    '[quiz-after] Step outside the task for this one reply. Do not call any tools.',
12    isForced
13      ? 'Treat the work done so far in this conversation as finished, and answer with "done": true.'
14      : 'First decide: is the task the user most recently asked for now complete, actually implemented rather than planned, half-done or waiting on the user? If it is not, reply with exactly {"done": false} and nothing else.',
15    'If it is complete, reply with one JSON object and nothing else, no prose and no code fence:',
16    '{"done": true, "title": "<4 to 8 words naming what was built>", "questions": [{"q": "...", "choices": ["...", "..."], "answer": "...", "why": "...", "tag": "mechanism"}]}',
17    'Write 2 or 3 questions about what was actually implemented in this conversation: why it was built this way, how the pieces connect, what would happen if something changed.',
18    'Test understanding, not trivia: no file names, line numbers, flag spellings or identifiers to recall.',
19    '"choices" is optional: 3 or 4 options, and "answer" must then be one of them word for word.',
20    '"why" is one sentence. "tag" is one of terminology, mechanism, derivation, transfer, big-picture.',
21    'Keep every string under 300 characters.',
22  ].join('\n')
23
24const text = (value: unknown) =>
25  typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, MAX_FIELD) : undefined
26
27const toQuestion = (raw: unknown): QuizQuestion | undefined => {
28  if (typeof raw !== 'object' || raw === null) return undefined
29  const fields = raw as Record<string, unknown>
30  const q = text(fields.q)
31  const answer = text(fields.answer)
32  if (q === undefined || answer === undefined) return undefined
33
34  const tag = TAGS.find(t => t === fields.tag) ?? 'mechanism'
35  const why = text(fields.why) ?? ''
36  const choices = Array.isArray(fields.choices)
37    ? fields.choices.map(text).filter((c): c is string => c !== undefined).slice(0, 4)
38    : []
39
40  // Choices that do not contain the answer would mark every option wrong; drop them.
41  return choices.length >= 2 && choices.includes(answer)
42    ? { q, choices, answer, why, tag }
43    : { q, answer, why, tag }
44}
45
46export type Parsed =
47  | { kind: 'not-done' }
48  | { kind: 'quiz'; title: string; questions: QuizQuestion[] }
49  | { kind: 'unreadable' }
50
51// The fork answers in prose often enough that the object is cut out of whatever came back.
52export const parseReply = (reply: string): Parsed => {
53  const start = reply.indexOf('{')
54  const end = reply.lastIndexOf('}')
55  if (start < 0 || end <= start) return { kind: 'unreadable' }
56
57  let raw: unknown
58  try {
59    raw = JSON.parse(reply.slice(start, end + 1))
60  } catch {
61    return { kind: 'unreadable' }
62  }
63  if (typeof raw !== 'object' || raw === null) return { kind: 'unreadable' }
64
65  const fields = raw as Record<string, unknown>
66  if (fields.done !== true) return { kind: 'not-done' }
67
68  const questions = Array.isArray(fields.questions)
69    ? fields.questions
70        .map(toQuestion)
71        .filter((q): q is QuizQuestion => q !== undefined)
72        .slice(0, MAX_QUESTIONS)
73    : []
74  if (questions.length === 0) return { kind: 'unreadable' }
75
76  return { kind: 'quiz', title: text(fields.title) ?? 'This session', questions }
77}
78
79// explain-this card schema, as scripts/sm2.ts `add` stores it.
80export type Card = {
81  id: string
82  artifact: { title: string; source: string; explained: string }
83  question: string
84  answer: string
85  type: 'recall' | 'transfer' | 'explain-back'
86  tags: string[]
87  state: { interval_days: number; ease: number; due: string; reps: number; lapses: number }
88  history: { ts: string; result: 'hit' | 'partial' | 'miss'; mode: 'inline' | 'review' }[]
89  status: 'active' | 'retired' | 'suspended'
90}
91
92// sm2.ts compares due dates as local YYYY-MM-DD strings.
93export const localDate = (ms: number) => {
94  const d = new Date(ms)
95  const pad = (n: number) => String(n).padStart(2, '0')
96  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
97}
98
99const slug = (s: string) =>
100  s
101    .toLowerCase()
102    .replace(/[^a-z0-9]+/g, '-')
103    .replace(/^-+|-+$/g, '')
104    .slice(0, 32)
105
106export const toCards = (quiz: Quiz, source: string, nowMs: number): Card[] => {
107  const today = localDate(nowMs)
108  const stamp = nowMs.toString(36)
109
110  // Free-text review grades against the answer, so the reason travels with it.
111  return quiz.questions.map((q, i) => ({
112    id: `card_quiz_${slug(quiz.title) || 'session'}_${stamp}_${i + 1}`,
113    artifact: { title: quiz.title, source, explained: today },
114    question: q.q,
115    answer: q.why === '' ? q.answer : `${q.answer} (${q.why})`,
116    type: q.tag === 'transfer' ? 'transfer' : 'recall',
117    tags: [q.tag],
118    state: { interval_days: 0, ease: 2.5, due: today, reps: 0, lapses: 0 },
119    history: [],
120    status: 'active',
121  }))
122}
123
124// Appends to an existing JSONL body, skipping ids or questions already in the deck.
125export const appendCards = (existing: string, cards: Card[]) => {
126  const seen = new Set<string>()
127  for (const line of existing.split('\n')) {
128    try {
129      const card = JSON.parse(line) as Partial<Card>
130      if (typeof card.id === 'string') seen.add(card.id)
131      if (typeof card.question === 'string') seen.add(card.question)
132    } catch {
133      // A line sm2.ts would reject is left exactly as it is.
134    }
135  }
136
137  const fresh = cards.filter(c => !seen.has(c.id) && !seen.has(c.question))
138  const head = existing === '' || existing.endsWith('\n') ? existing : `${existing}\n`
139  const body = fresh.map(c => JSON.stringify(c)).join('\n')
140
141  return { text: fresh.length === 0 ? existing : `${head}${body}\n`, added: fresh.length }
142}
143
types/index.d.ts 28 lines
1export type QuizTag = 'terminology' | 'mechanism' | 'derivation' | 'transfer' | 'big-picture'
2
3export type QuizQuestion = {
4  q: string
5  choices?: string[]
6  answer: string
7  why: string
8  tag: QuizTag
9}
10
11export type QuizCost = { cached: number; input: number; output: number }
12
13export type Quiz = {
14  title: string
15  questions: QuizQuestion[]
16  revealed: number[]
17  isSaved: boolean
18  cost: QuizCost
19}
20
21export type QuizPhase = 'idle' | 'checking'
22
23declare module 'claude-code' {
24  interface PluginState {
25    'quiz-after': { quiz: Quiz | null; phase: QuizPhase }
26  }
27}
28