SLOPSHOPPER

teach-me

Learn while Claude codes. After a turn that changed code, a quick question about that exact change appears above the prompt; answer with /a 1-4, see your weak…

newbandguardcommandmodel
v0.1.0MITupdated 2026-10-04saksham10arora-dotcom/claude-teach-me
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · teach-me
› 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 › /a ⎿ teach-me: No question waiting. One appears after Claude changes code. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

teach-me

Learn while Claude codes. A Claude Code mod.

teach-me in a real Claude Code session

A real, unedited session: Claude adds a cache, teach-me asks why (in Hinglish here; English is a setting), /a 1 answers it.

After a turn that changed code, a small model (Haiku) writes one multiple-choice question about that exact change: the idea behind it, not trivia. It sits above the prompt until you answer. Scores are kept per concept, so /teach stats tells you what to revise.

Built for the way a lot of us code now: the agent writes it, you still have to understand it.

Commands

  • /a 1-4 answer, /a skip see the answer
  • /teach stats your weak spots, /teach now a question about the last change
  • /teach on, /teach off

Settings

/plugin configure teach-me@nerfsaksham-teach-me: quiz language (Hinglish or English), quiz model (default haiku), minutes between questions (default 4, so it never nags).

What it touches

Its own store, and one Haiku call per question. Nothing else.

Install

Claude Code 2.1.287 or newer runs mods out of the box. On 2.1.273 to 2.1.286, start it with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 first.

claude plugin marketplace add saksham10arora-dotcom/claude-teach-me
claude plugin install teach-me@nerfsaksham-teach-me

Every option has a default, so the installer's "userConfig options not yet set" note is safe to ignore.

Or try it without installing:

git clone https://github.com/saksham10arora-dotcom/claude-teach-me && claude --plugin-dir claude-teach-me

Develop

claude plugin validate .
claude plugin test .

More mods

Part of a set: teach-me · vhs · frugal · lofi. More community mods: awesome-claude-mods.

MIT. Made by Saksham Arora (@nerfsaksham).

Source 1 files
hooks/register.ts 229 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// teach-me: Claude writes the code, you learn what it did.
4// Edits made during a turn are collected; when the turn ends (and the cooldown
5// allows) a small model writes one multiple-choice question about that exact
6// change. It sits above the prompt until you answer with /a 1-4 or /a skip.
7// Scores are kept per concept, so /teach stats shows what to revise.
8
9const CORAL = '#e8603c'
10const CREAM = '#f3eee6'
11const MUTED = '#8a8f98'
12const GREEN = '#3ecf8e'
13const RED = '#ef5a5a'
14
15type Change = { path: string; before: string; after: string }
16type Quiz = { concept: string; question: string; choices: string[]; answer: number; why: string; path: string }
17type Score = { right: number; wrong: number }
18type Verdict = { isCorrect: boolean; text: string } | null
19
20const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
21const CLIP = 1600 // characters of each side of a change sent to the model
22
23// Module state for this session (a hot reload starts it over).
24let changes: Change[] = []
25let pending: Quiz | null = null
26let lastVerdict: Verdict = null
27let lastQuizAt = 0
28let isMaking = false
29
30const clip = (s: string) => (s.length > CLIP ? `${s.slice(0, CLIP)}\n...(${s.length - CLIP} more characters)` : s)
31const str = (v: unknown) => (typeof v === 'string' ? v : '')
32
33function changeFrom(e: Record<string, unknown>): Change | null {
34  const path = str(e.file_path) || str(e.notebook_path)
35  if (!path) return null
36  if (Array.isArray(e.edits)) {
37    const edits = e.edits as { old_string?: unknown; new_string?: unknown }[]
38    return { path, before: clip(edits.map(x => str(x.old_string)).join('\n...\n')), after: clip(edits.map(x => str(x.new_string)).join('\n...\n')) }
39  }
40  if (typeof e.content === 'string') return { path, before: '', after: clip(e.content) }
41  return { path, before: clip(str(e.old_string)), after: clip(str(e.new_string) || str(e.new_source)) }
42}
43
44function parseQuiz(text: string, path: string): Quiz | null {
45  const m = text.match(/\{[\s\S]*\}/)
46  if (!m) return null
47  try {
48    const j = JSON.parse(m[0]) as Partial<Quiz>
49    const choices = Array.isArray(j.choices) ? j.choices.map(c => String(c)).slice(0, 4) : []
50    const answer = Number(j.answer)
51    if (!j.question || choices.length !== 4 || !(answer >= 1 && answer <= 4)) return null
52    return { concept: String(j.concept || 'this change').slice(0, 40), question: String(j.question), choices, answer, why: String(j.why || ''), path }
53  } catch {
54    return null
55  }
56}
57
58async function makeQuiz($: EngineInterface, batch: Change[], model: string, language: string) {
59  isMaking = true
60  try {
61    const shown = batch.slice(-3)
62    const diff = shown
63      .map(c => `FILE ${c.path}\n${c.before ? `--- before\n${c.before}\n` : ''}+++ after\n${c.after}`)
64      .join('\n\n')
65    const r = await $.model.complete({
66      model,
67      effort: 'low',
68      maxTokens: 700,
69      timeoutMs: 25000,
70      system:
71        'You write one quiz question that teaches a student developer what an AI coding agent just changed. ' +
72        'The student did not write this code and is learning to understand and judge it.',
73      prompt:
74        'Here is the change the agent just made:\n\n' +
75        diff +
76        '\n\nWrite ONE multiple-choice question about WHY this change was made or what it does: the idea behind it ' +
77        '(a design choice, a concept, a bug it fixes, a trade-off), not trivia like variable names or line numbers. ' +
78        'Exactly four choices, one clearly right, three plausible but wrong. Keep the question under 25 words and each choice under 14 words. ' +
79        `Write the question, the choices and "why" in ${language === 'Hinglish' ? 'casual Hinglish (Hindi in Latin script mixed with English tech terms)' : 'plain English'}; "why" is 2 short sentences. ` +
80        'Reply with JSON only: {"concept": "2-4 words, e.g. caching, race condition", "question": "...", "choices": ["...","...","...","..."], "answer": 1-4, "why": "..."}',
81    })
82    if (!r.isAnswered) return
83    const quiz = parseQuiz(r.text ?? '', shown[shown.length - 1].path)
84    if (!quiz) return
85    pending = quiz
86    lastVerdict = null
87    lastQuizAt = await $.clock.now()
88    $.ui.invalidate('ui.render')
89  } finally {
90    isMaking = false
91  }
92}
93
94async function record($: EngineInterface, concept: string, isCorrect: boolean) {
95  const raw = (await $.store.get('scores')) as Record<string, Score> | undefined
96  const scores: Record<string, Score> = raw && typeof raw === 'object' ? raw : {}
97  const s = scores[concept] ?? { right: 0, wrong: 0 }
98  scores[concept] = isCorrect ? { ...s, right: s.right + 1 } : { ...s, wrong: s.wrong + 1 }
99  await $.store.set('scores', scores)
100  const streak = Number(await $.store.get('streak')) || 0
101  await $.store.set('streak', isCorrect ? streak + 1 : 0)
102}
103
104async function stats($: EngineInterface) {
105  const raw = (await $.store.get('scores')) as Record<string, Score> | undefined
106  const rows = Object.entries(raw && typeof raw === 'object' ? raw : {})
107  if (rows.length === 0) return 'No answers yet. Questions appear after Claude changes code.'
108  const right = rows.reduce((n, [, s]) => n + s.right, 0)
109  const total = rows.reduce((n, [, s]) => n + s.right + s.wrong, 0)
110  const weak = rows
111    .filter(([, s]) => s.wrong > 0)
112    .sort((a, b) => b[1].wrong / (b[1].right + b[1].wrong) - a[1].wrong / (a[1].right + a[1].wrong) || b[1].wrong - a[1].wrong)
113    .slice(0, 5)
114    .map(([c, s]) => `  ${c}: ${s.right}/${s.right + s.wrong}`)
115  const strong = rows
116    .filter(([, s]) => s.wrong === 0 && s.right >= 2)
117    .map(([c]) => c)
118    .slice(0, 6)
119  const streak = Number(await $.store.get('streak')) || 0
120  return (
121    `${right}/${total} right (${Math.round((right / total) * 100)}%), streak ${streak}.\n` +
122    (weak.length ? `Revise these:\n${weak.join('\n')}\n` : 'Nothing weak yet.\n') +
123    (strong.length ? `Solid: ${strong.join(', ')}` : '')
124  )
125}
126
127export const register: Register = (on, options) => {
128  const language = String(options.language || 'Hinglish')
129  const model = String(options.quizModel || 'haiku')
130  const cooldownMs = Math.max(0, Number(options.cooldownMinutes ?? 4)) * 60_000
131  let enabled = true
132
133  on('session.start', async ($, e, next) => {
134    const r = await next(e)
135    enabled = (await $.store.get('enabled')) !== false
136    await $.command.register({ name: 'a', description: 'teach-me: answer the question above the prompt (1-4, or skip)', argumentHint: '<1-4 | skip>', immediate: true })
137    await $.command.register({ name: 'teach', description: 'teach-me: on, off, stats, or now (a question about the last change)', argumentHint: '[on | off | stats | now]', immediate: true })
138    return r
139  })
140
141  on('turn.start', async ($, e, next) => {
142    if (!e.agentId) {
143      changes = []
144      if (lastVerdict) {
145        lastVerdict = null // the result line stays until the next turn starts
146        $.ui.invalidate('ui.render')
147      }
148    }
149    return next(e)
150  })
151
152  on('tool.call', async ($, e, next) => {
153    const ran = await next(e)
154    if (!EDIT_TOOLS.has(e.tool) || ran.deny !== undefined || ran.isError === true) return ran
155    const c = changeFrom(e as unknown as Record<string, unknown>)
156    if (c) changes.push(c)
157    return ran
158  })
159
160  on('turn.complete', async ($, e, next) => {
161    const r = await next(e)
162    if (e.agentId || e.isAborted || !enabled || pending || isMaking || changes.length === 0) return r
163    if ((await $.clock.now()) - lastQuizAt < cooldownMs) return r
164    void makeQuiz($, changes.slice(), model, language)
165    return r
166  })
167
168  on('command.run', { command: 'a' }, async ($, e) => {
169    const arg = String(e.args ?? '').trim().toLowerCase()
170    if (!pending) return { text: 'No question waiting. One appears after Claude changes code.' }
171    const quiz = pending
172    if (arg === 'skip') {
173      pending = null
174      $.ui.invalidate('ui.render')
175      return { text: `Skipped. The answer was ${quiz.answer}) ${quiz.choices[quiz.answer - 1]}. ${quiz.why}` }
176    }
177    const n = Number(arg)
178    if (!(n >= 1 && n <= 4)) return { text: 'Answer with /a 1, /a 2, /a 3 or /a 4 (or /a skip).' }
179    const isCorrect = n === quiz.answer
180    await record($, quiz.concept, isCorrect)
181    pending = null
182    const text = isCorrect
183      ? `Sahi! ${quiz.why}`
184      : `Not quite: it was ${quiz.answer}) ${quiz.choices[quiz.answer - 1]}. ${quiz.why}`
185    lastVerdict = { isCorrect, text }
186    $.ui.invalidate('ui.render')
187    return { text: `${isCorrect ? '✓' : '✗'} ${text}` }
188  })
189
190  on('command.run', { command: 'teach' }, async ($, e) => {
191    const arg = String(e.args ?? '').trim().toLowerCase()
192    if (arg === 'on' || arg === 'off') {
193      enabled = arg === 'on'
194      await $.store.set('enabled', enabled)
195      if (!enabled) pending = null
196      $.ui.invalidate('ui.render')
197      return { text: enabled ? 'teach-me is on: a question appears after turns that change code.' : 'teach-me is off.' }
198    }
199    if (arg === 'stats') return { text: await stats($) }
200    if (arg === 'now') {
201      if (changes.length === 0) return { text: 'No changes in the last turn to ask about.' }
202      if (isMaking) return { text: 'Already writing one.' }
203      void makeQuiz($, changes.slice(), model, language)
204      return { text: 'Writing a question about the last change...' }
205    }
206    return { text: `teach-me is ${enabled ? 'on' : 'off'}. ${await stats($)}\nTry /teach on, off, stats or now.` }
207  })
208
209  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
210    const original = await next(e)
211    if (!enabled || (!pending && !lastVerdict)) return original
212    const { Box, Text } = $.ui.resolve(e)
213    const rows: ReturnType<typeof Text>[] = []
214    if (pending) {
215      const q = pending
216      rows.push(
217        Text({ wrap: 'truncate', children: [Text({ color: CORAL, bold: true, children: '◆ teach-me ' }), Text({ color: MUTED, children: `${q.concept} · ${q.path.split('/').pop()}` })] }),
218        Text({ color: CREAM, bold: true, children: q.question }),
219        ...q.choices.map((c, i) => Text({ color: CREAM, wrap: 'truncate', children: `  ${i + 1}) ${c}` })),
220        Text({ color: MUTED, children: '  answer with /a 1-4, or /a skip' }),
221      )
222    } else if (lastVerdict) {
223      rows.push(Text({ color: lastVerdict.isCorrect ? GREEN : RED, children: `◆ teach-me ${lastVerdict.isCorrect ? '✓' : '✗'} ${lastVerdict.text}` }))
224    }
225    const band = Box({ flexDirection: 'column', marginTop: 1, children: rows })
226    return original ? Box({ flexDirection: 'column', children: [original, band] }) : band
227  })
228}
229