Interactive Go tutor: lesson band, test watcher, and an editor split driven from Claude

An interactive Go tutoring workspace for Claude Code. Claude is the tutor; you are the student. Each lesson is a short conversation, a scaffolded exercise with // TODO(you) markers, tests that pin down the behaviour (goroutine leaks and deadlocks included), and a review. Short pure-recall drills warm up each session.
What's in the box:
CLAUDE.md) that tells Claude how to teach: talk first, Socratic hints, never write the student's code, review like a mentor..claude/skills/gotutor/) that adds /learn, /hint, /review, /check, /tutor, a lesson band above the prompt with live test status, a watcher that re-runs go test -race on save, and tools Claude uses to start lessons and drive your editor.lessons/, drills in drills/, and PROGRESS.md, which the tutor keeps current.testing/synctest, sync.WaitGroup.Go, range-over-func)task), tmux (recommended), Neovim 0.11+ or Vimgopls for the editor's LSP (optional but nice)git clone https://github.com/derekmwright/goTutor
cd goTutor
task nvim:install # optional: Neovim to ~/.local, no sudo
task start # Claude Code inside a tmux session named gotutor
In Claude Code, accept the trust prompt for the directory, then:
/learn
Two lessons (channel foundations, pipelines and bounded parallelism) and six drills are already scaffolded, so Claude will suggest starting there. For a new topic, /learn <topic>: Claude talks through what you know, scaffolds lessons/NN-slug/, opens the editor beside you, and runs the baseline tests. Edit the TODO(you) parts, save, and watch the band above the prompt. /hint for the next nudge, /review when you think you're done, /check to run the tests by hand.
Start each session with a warm-up: task drill:list shows what's due, Claude picks one and runs task drill -- <name>; you check with task drill:check -- <name>.
CLAUDE.md the tutor's instructions
Taskfile.yml tasks (task --list)
.claude/skills/gotutor/ the Claude Code mod (hooks/register.tsx)
editor/ tutor-editor.sh launcher/driver, init.lua, vimrc
lessons/NN-slug/ README, code with TODO(you) stubs, tests
drills/ warm-up katas; log.tsv (your times) is git-ignored
PROGRESS.md what you've finished and what to revisit
task mod:check # claude plugin validate + claude plugin test
/reload-plugins # in the session, after editing the mod
For hot reload while developing, run claude --plugin-dir .claude/skills/gotutor from the repo root. If you keep a working copy elsewhere, MOD_SRC=/path/to/copy task mod:sync copies it back into the repo.
Lessons and drills here are a starting point. Fork it, clear PROGRESS.md, and let Claude pick the next topic from the curriculum ideas in CLAUDE.md or follow your own interests.
hooks/register.tsx 344 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Lesson, TestRun } from '../types'
5import { countTodos, parseGoTest, summary, tailOf } from './gotest'
6
7const PANE = 'gotutor'
8const EDITOR = 'editor/tutor-editor.sh'
9const lesson = atom({ plugin: 'gotutor', key: 'lesson' } as const, null)
10const run = atom({ plugin: 'gotutor', key: 'run' } as const, null)
11
12const HINT =
13 'Give me a hint for the current lesson: only the next rung of the hint ladder, no solution code.'
14const REVIEW =
15 'Review my current code for this lesson (read the files fresh and look at the latest test run). ' +
16 'Tell me what is right, what is wrong or racy, and ask me a question instead of fixing it.'
17
18const EDITOR_ACTIONS = ['open', 'goto', 'reload', 'resize', 'zoom', 'focus', 'close', 'status'] as const
19
20// Module state: lost on reload, which only costs one extra test run.
21let isRunning = false
22let lastSignature = ''
23
24async function sourceFiles($: EngineInterface, dir: string) {
25 const entries = await $.fs.list(dir).catch(() => [])
26 return entries.filter(f => f.kind === 'file' && f.name.endsWith('.go'))
27}
28
29async function runTests($: EngineInterface): Promise<{ run: TestRun; output: string } | null> {
30 const current = await read($, lesson)
31 if (!current || isRunning) return null
32 isRunning = true
33 try {
34 const at = await $.clock.now()
35 await update($, run, (prev): TestRun => ({ ...(prev ?? { tests: [], tail: '', todos: 0 }), status: 'running', at }))
36 $.ui.status(`go: ${current.title} · running…`)
37
38 const res = await $.process.run(
39 ['go', 'test', '-race', '-count=1', '-timeout', '20s', '-v', `./${current.dir}/...`],
40 { timeoutMs: 120_000 },
41 )
42 const output = `${res.stdout}${res.stderr}`
43
44 let todos = 0
45 for (const f of await sourceFiles($, current.dir)) {
46 if (f.name.endsWith('_test.go')) continue
47 todos += countTodos(await $.fs.read(`${current.dir}/${f.name}`))
48 }
49
50 const result: TestRun = { ...parseGoTest(res.exitCode, output), todos, at: await $.clock.now() }
51 await update($, run, () => result)
52 $.ui.status(`go: ${current.title} · ${summary(result)}`)
53 if (result.status === 'pass') $.ui.toast(`goTutor: all tests pass for ${current.title}`)
54
55 return { run: result, output }
56 } finally {
57 isRunning = false
58 }
59}
60
61async function editor($: EngineInterface, args: string[]) {
62 const exists = await $.fs.exists(EDITOR)
63 if (!exists) return { ok: false, text: `${EDITOR} not found: start Claude Code from the goTutor repo root.` }
64 const res = await $.process.run([EDITOR, ...args], { timeoutMs: 15_000 })
65 return { ok: res.exitCode === 0, text: `${res.stdout}${res.stderr}`.trim() }
66}
67
68async function openEditor($: EngineInterface) {
69 const current = await read($, lesson)
70 if (!current) return { ok: false, text: 'No lesson is active.' }
71 return editor($, ['open', current.dir, ...current.files])
72}
73
74// Submitting from inside command.run would wait on the turn that hook holds,
75// so the prompt goes out from a timer a moment later.
76function ask($: EngineInterface, text: string) {
77 $.clock.after(1, () => {
78 $.prompt.submit({ text, asUser: true }).catch(err => $.ui.toast(`goTutor: could not send prompt: ${String(err)}`))
79 })
80}
81
82// Re-runs the tests whenever a .go file in the lesson changes on disk.
83async function watch($: EngineInterface) {
84 const current = await read($, lesson)
85 if (!current || isRunning) return
86 const files = await sourceFiles($, current.dir)
87 const signature = files.map(f => `${f.name}:${f.mtimeMs}`).join('|')
88 const isChanged = lastSignature !== '' && signature !== lastSignature
89 lastSignature = signature
90 if (isChanged) await runTests($)
91}
92
93export const register: Register = on => {
94 on('session.start', async ($, e, next) => {
95 const saved = (await $.store.get('lesson')) as Lesson | undefined
96 if (saved) {
97 await update($, lesson, () => saved)
98 $.ui.status(`go: ${saved.title} · ${summary(await read($, run))}`)
99 }
100
101 const commands = [
102 { name: 'learn', description: 'goTutor: start a lesson on a Go topic', argumentHint: '[topic]' },
103 { name: 'hint', description: 'goTutor: next hint for the current lesson' },
104 { name: 'review', description: 'goTutor: have the tutor review your code' },
105 { name: 'check', description: 'goTutor: run the lesson tests now' },
106 { name: 'tutor', description: 'goTutor: show the lesson pane' },
107 { name: 'editor', description: 'goTutor: open the editor on the lesson', argumentHint: '[zoom|focus|close|status]' },
108 ]
109 for (const c of commands) await $.command.register(c).catch(() => undefined)
110
111 await $.tool.register({
112 name: 'start_lesson',
113 description:
114 'Begin a goTutor lesson after scaffolding its folder. Records the lesson, shows it in the ' +
115 "student's lesson band, runs the baseline tests, and opens the student's editor (a tmux or " +
116 'Windows Terminal split beside Claude) on the files they will edit.',
117 inputSchema: {
118 type: 'object',
119 properties: {
120 dir: { type: 'string', description: 'Lesson folder relative to the repo root, e.g. lessons/03-fan-in' },
121 title: { type: 'string', description: 'Short title, e.g. "Fan-out / fan-in"' },
122 objectives: { type: 'array', items: { type: 'string' }, description: '2-5 things the student will be able to do' },
123 files: { type: 'array', items: { type: 'string' }, description: 'Files the student edits, relative to dir' },
124 openEditor: { type: 'boolean', description: 'Open the editor (default true)' },
125 },
126 required: ['dir', 'title', 'objectives', 'files'],
127 },
128 })
129 await $.tool.register({
130 name: 'run_tests',
131 description:
132 "Run the current lesson's tests (go test -race -v, 20s timeout) and return the output. " +
133 "Also refreshes the student's lesson band. Tests re-run by themselves whenever the student saves.",
134 inputSchema: { type: 'object', properties: {} },
135 })
136 await $.tool.register({
137 name: 'editor',
138 description:
139 "Drive the student's editor. open: (re)open on the lesson files. goto: show file at line " +
140 '(use to point at code while explaining). reload: reload buffers after you edit files. ' +
141 'resize: editor width percent. zoom: toggle full-window. focus: give the student the keyboard. ' +
142 'close, status.',
143 inputSchema: {
144 type: 'object',
145 properties: {
146 action: { type: 'string', enum: EDITOR_ACTIONS },
147 file: { type: 'string', description: 'goto: path relative to the repo root' },
148 line: { type: 'number', description: 'goto: line number' },
149 percent: { type: 'number', description: 'resize: width percent' },
150 },
151 required: ['action'],
152 },
153 })
154 await $.tool.register({
155 name: 'finish_lesson',
156 description: 'Close the current lesson once the student has it working and you have debriefed. Clears the band.',
157 inputSchema: {
158 type: 'object',
159 properties: { notes: { type: 'string', description: 'One line: what clicked, what to revisit' } },
160 required: ['notes'],
161 },
162 })
163
164 $.clock.every(1500, () => void watch($))
165
166 return next(e)
167 })
168
169 on('tool.call', { tool: 'mcp__gotutor__start_lesson' }, async ($, e) => {
170 // A registered tool's arguments arrive as fields of e itself (beside tool and
171 // tool_use_id), not under e.input.
172 const input = e as unknown as Partial<Lesson> & { openEditor?: boolean }
173 if (!input.dir || !(await $.fs.exists(input.dir))) {
174 return { result: `Lesson folder ${input.dir} does not exist yet: scaffold it first.` }
175 }
176 const next: Lesson = {
177 dir: input.dir.replace(/^\.\//, '').replace(/\/$/, ''),
178 title: input.title ?? input.dir,
179 objectives: input.objectives ?? [],
180 files: input.files ?? [],
181 startedAt: await $.clock.now(),
182 }
183 await update($, lesson, () => next)
184 await update($, run, () => null)
185 await $.store.set('lesson', next)
186 lastSignature = ''
187
188 const baseline = await runTests($)
189 const opened = input.openEditor === false ? null : await openEditor($)
190
191 return {
192 result: [
193 `Lesson "${next.title}" started in ${next.dir}.`,
194 opened ? `Editor: ${opened.text}` : 'Editor not opened.',
195 `Baseline: ${summary(baseline?.run ?? null)}`,
196 baseline ? tailOf(baseline.output, 30) : '',
197 ].join('\n'),
198 }
199 })
200
201 on('tool.call', { tool: 'mcp__gotutor__run_tests' }, async $ => {
202 const done = await runTests($)
203 if (!done) return { result: (await read($, lesson)) ? 'Tests are already running; try again shortly.' : 'No lesson is active.' }
204 return {
205 result: `${summary(done.run)} · TODO(you) left: ${done.run.todos}\n\n${tailOf(done.output, 150)}`,
206 }
207 })
208
209 on('tool.call', { tool: 'mcp__gotutor__editor' }, async ($, e) => {
210 const input = e as unknown as { action: string; file?: string; line?: number; percent?: number }
211 if (input.action === 'open') return { result: (await openEditor($)).text }
212 const args = [input.action]
213 if (input.action === 'goto') args.push(input.file ?? '', String(input.line ?? 1))
214 if (input.action === 'resize') args.push(String(input.percent ?? 55))
215 return { result: (await editor($, args)).text }
216 })
217
218 on('tool.call', { tool: 'mcp__gotutor__finish_lesson' }, async ($, e) => {
219 const current = await read($, lesson)
220 if (!current) return { result: 'No lesson is active.' }
221 const notes = String((e as unknown as { notes?: string }).notes ?? '')
222 const history = ((await $.store.get('history')) as unknown[] | undefined) ?? []
223 await $.store.set('history', [...history, { ...current, notes, finishedAt: await $.clock.now() }])
224 await $.store.delete('lesson')
225 await update($, lesson, () => null)
226 await update($, run, () => null)
227 $.ui.status(undefined)
228 $.ui.toast(`goTutor: finished ${current.title}`)
229 return { result: `Finished "${current.title}". Remember to update PROGRESS.md.` }
230 })
231
232 on('command.run', { command: 'learn' }, async ($, e) => {
233 const topic = e.args.trim()
234 ask(
235 $,
236 topic
237 ? `Teach me: ${topic}. Start with a short conversation to see what I already know, then scaffold a lesson.`
238 : 'Suggest what I should learn next, based on PROGRESS.md. Offer 3 options.',
239 )
240 return { text: topic ? `Starting a lesson on ${topic}…` : 'Picking a next topic…' }
241 })
242
243 on('command.run', { command: 'hint' }, async $ => {
244 ask($, HINT)
245 return { text: 'Asking for a hint…' }
246 })
247
248 on('command.run', { command: 'review' }, async $ => {
249 ask($, REVIEW)
250 return { text: 'Asking for a review…' }
251 })
252
253 on('command.run', { command: 'check' }, async $ => {
254 const done = await runTests($)
255 if (!done) return { text: 'No lesson is active (or tests are already running).' }
256 return { text: `${summary(done.run)} · TODO(you) left: ${done.run.todos}\n${tailOf(done.output, 25)}` }
257 })
258
259 on('command.run', { command: 'tutor' }, async $ => {
260 await $.ui.open({ id: PANE, title: 'goTutor', closeOnEscape: true })
261 return { text: 'Lesson pane opened.' }
262 })
263
264 on('command.run', { command: 'editor' }, async ($, e) => {
265 const action = e.args.trim()
266 if (action && action !== 'open') return { text: (await editor($, [action])).text }
267 return { text: (await openEditor($)).text }
268 })
269
270 // The student's own prompts carry the lesson and the latest run, so the tutor
271 // never has to ask "did the tests pass?".
272 on('prompt.submit', async ($, e, next) => {
273 const current = await read($, lesson)
274 if (!current || e.origin.kind !== 'composer') return next(e)
275 const last = await read($, run)
276 const note = `goTutor: active lesson "${current.title}" in ${current.dir}; latest test run: ${summary(last)}; TODO(you) left: ${last?.todos ?? '?'}.`
277 return next({ ...e, context: [...(e.context ?? []), note] })
278 })
279
280 // One line above the prompt while a lesson is active.
281 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282 const current = await read($, lesson)
283 if (!current || e.props.hasSurvey) return next(e)
284 const last = await read($, run)
285 const { Box, Button, Text } = $.ui.resolve(e)
286 const color = !last || last.status === 'running' ? 'yellow' : last.status === 'pass' ? 'green' : 'red'
287
288 return (
289 <Box flexDirection="row" gap={1} flexWrap="wrap">
290 <Text bold>goTutor</Text>
291 <Text>{current.title}</Text>
292 <Text color={color}>{summary(last)}</Text>
293 {last && last.todos > 0 && <Text dimColor>TODO×{last.todos}</Text>}
294 <Button key="check" label="test" hotkey="t" plain onPress={() => void runTests($)} />
295 <Button key="hint" label="hint" hotkey="h" plain onPress={() => ask($, HINT)} />
296 <Button key="review" label="review" hotkey="r" plain onPress={() => ask($, REVIEW)} />
297 <Button key="editor" label="editor" hotkey="e" plain onPress={() => void openEditor($)} />
298 <Button key="more" label="more" hotkey="m" plain onPress={() => void $.ui.open({ id: PANE, title: 'goTutor', closeOnEscape: true })} />
299 </Box>
300 )
301 })
302
303 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
304 const { Box, Text, Code } = $.ui.resolve(e)
305 const current = await read($, lesson)
306 if (!current) {
307 return (
308 <Box flexDirection="column">
309 <Text bold>goTutor</Text>
310 <Text dimColor>No lesson yet. Try /learn concurrency fan-out fan-in</Text>
311 </Box>
312 )
313 }
314 const last = await read($, run)
315
316 return (
317 <Box flexDirection="column" gap={1}>
318 <Box flexDirection="column">
319 <Text bold>{current.title}</Text>
320 <Text dimColor>{current.dir}</Text>
321 </Box>
322 <Box flexDirection="column">
323 <Text bold>Objectives</Text>
324 {current.objectives.map(o => (
325 <Text>• {o}</Text>
326 ))}
327 </Box>
328 <Box flexDirection="column">
329 <Text bold>Tests: {summary(last)}</Text>
330 {(last?.tests ?? []).map(t => (
331 <Text color={t.result === 'pass' ? 'green' : t.result === 'fail' ? 'red' : undefined} dimColor={t.result === 'skip'}>
332 {t.result === 'pass' ? '✓' : t.result === 'fail' ? '✗' : '–'} {t.name}
333 </Text>
334 ))}
335 {last && <Text dimColor>TODO(you) markers left: {last.todos}</Text>}
336 </Box>
337 {last && last.status !== 'pass' && last.tail && (
338 <Code source={tailOf(last.tail, Math.max(5, (e.viewport?.rows ?? 30) - 18))} wrap="wrap" />
339 )}
340 </Box>
341 )
342 })
343}
344hooks/gotest.ts 44 lines1import type { TestCase, TestRun } from '../types'
2
3const CASE = /^\s*--- (PASS|FAIL|SKIP): (\S+)/
4
5/** Reads `go test -v` output into a run summary. */
6export function parseGoTest(
7 exitCode: number,
8 output: string,
9): Pick<TestRun, 'status' | 'tests' | 'tail'> {
10 const lines = output.split('\n')
11 const seen = new Map<string, TestCase['result']>()
12 for (const line of lines) {
13 const m = CASE.exec(line)
14 if (m?.[1] && m[2]) seen.set(m[2], m[1].toLowerCase() as TestCase['result'])
15 }
16 const tests = [...seen].map(([name, result]) => ({ name, result }))
17
18 let status: TestRun['status'] = 'fail'
19 if (exitCode === 0) status = 'pass'
20 else if (/panic: test timed out/.test(output)) status = 'timeout'
21 else if (/\[(build|setup) failed\]/.test(output) || (tests.length === 0 && !/^(FAIL|---)/m.test(output))) status = 'build'
22
23 return { status, tests, tail: tailOf(output, 40) }
24}
25
26export function tailOf(text: string, n: number): string {
27 return text.trimEnd().split('\n').slice(-n).join('\n')
28}
29
30export function countTodos(source: string): number {
31 return source.split('TODO(you)').length - 1
32}
33
34/** One line for the status bar and the band. */
35export function summary(run: TestRun | null): string {
36 if (!run) return 'not run yet'
37 if (run.status === 'running') return 'running…'
38 if (run.status === 'build') return 'does not compile'
39 if (run.status === 'timeout') return 'timed out (deadlock?)'
40 const passed = run.tests.filter(t => t.result === 'pass').length
41 const mark = run.status === 'pass' ? '✓' : '✗'
42 return `${mark} ${passed}/${run.tests.length} tests`
43}
44types/index.d.ts 31 lines1export type Lesson = {
2 /** The lesson folder, relative to the project root (`lessons/01-fan-out`). */
3 dir: string
4 title: string
5 objectives: string[]
6 /** Files the student edits, relative to `dir`; opened in the editor. */
7 files: string[]
8 startedAt: number
9}
10
11export type TestCase = { name: string; result: 'pass' | 'fail' | 'skip' }
12
13export type TestRun = {
14 status: 'running' | 'pass' | 'fail' | 'build' | 'timeout'
15 tests: TestCase[]
16 /** The last lines of `go test` output. */
17 tail: string
18 /** `TODO(you)` markers still in the student's files. */
19 todos: number
20 at: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 gotutor: {
26 lesson: Lesson | null
27 run: TestRun | null
28 }
29 }
30}
31