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

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 ]
$.model.fork call. The fork sees the conversation as the main thread last sent it and first answers: is the task actually finished?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.
| When | Model calls | Tokens |
|---|---|---|
| Chat-only or read-only turn, aborted turn, subagent turn | none | 0 |
/quiz off | none | 0 |
| A turn that changed something, quiz on, task finished | 1 fork | the 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 unfinished | 1 fork | the same cached transcript and ~350 new input tokens, about 10 output tokens; nothing drawn |
/quiz now | 1 fork | as a finished task |
| Reveal, Dismiss, Save to deck | none | 0 |
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.
/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.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).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.
await next(e)) beneath it, so it never hides another mod. It steps aside while a survey holds the band.explain-this review brings the questions back on the SM-2 schedule.cached column before adding a third./quiz off is the switch a "learning mode" would flip.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.
hooks/register.tsx 245 lines1import { 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}
245hooks/quiz.ts 143 lines1import 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}
143types/index.d.ts 28 lines1export 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