Learn the code Claude is changing in your repo: a knowledge map plus one short card per editing turn

Claude changed billing/ 14 times this week. Do you still understand it?
Why · How it works · Install · What you see · Commands · Privacy · Requirements · FAQ · Roadmap · Docs · Sponsor · About me
Apprentice is a mod for Claude Code. It draws a knowledge map of your repo that is bright where you understand the code and dim where Claude changed code you haven't seen. After each editing turn it asks one short question about the edit, at your level for that area, and a right answer relights the area.
No account, no server. It runs inside Claude Code and uses Haiku, through Claude Code, to write the cards. It never blocks Claude: the card sits above your prompt while Claude keeps working.
Type 1 into the empty prompt and the card shows the answer and a one-line explanation. Get it right and Billing brightens on the map again.
[!NOTE] Status: alpha. Everything in v1 is built and covered by 118 tests, and
claude plugin validatepasses. It has been checked by hand in the Desktop app, the terminal and a linked worktree. Watch or ⭐ the repo to follow along, and open an issue if something looks off.
AI agents now write a large share of the code I ship. Writing it is no longer the slow part. Understanding it is. Code changes faster than its owners can read it, and the gap grows quietly. I call that gap comprehension debt: code that changed since you last understood it.
Juniors feel it as "I ship code I can't explain." Seniors feel it as "Claude changed billing/ 14 times this week and I haven't looked." Nothing in Claude Code measures that gap, or helps pay it down.
| Shows what changed | Shows what you still understand | Teaches you while the agent works | |
|---|---|---|---|
| Reading the diff | ✅ | ❌ | ◐ if you read every one |
| Pull request review | ✅ | ❌ | ❌ after the fact |
git blame | ✅ who wrote it | ◐ only what you wrote | ❌ |
| Apprentice | ✅ per area, on a map | ✅ a score per area | ✅ one card per editing turn |
The map shows how much of your codebase you still understand, and the cards teach you the rest while Claude works, a few seconds at a time.
Claude edits a file ─▶ its area dims ─▶ Haiku writes one card ─▶ you answer ─▶ the area relights
| What it does | Uses Haiku | |
|---|---|---|
| 🗺️ Map | Groups the repo into at most 15 areas and seeds each score from git blame: lines you wrote count as lines you understand, up to 90% | Once per map, to name the areas |
| 🌘 Dim | Every Claude edit (Edit, Write, MultiEdit) lowers its area's score by the share of the area's lines it changed | No |
| 🃏 Card | After the first edit of a turn, one card about that edit. Junior and mid areas get a question with three answers; senior areas get a spot-the-flaw card with the diff | Yes, at most 6 an hour |
| 💡 Relight | A right answer gives back what the edit took, plus 2 points. A wrong one gives back a quarter and shows the explanation. Three right in a row move the area up a level; two wrong move it down | No |
Your level per area starts from your seed score: under 40% is junior, under 75% is mid, above that is senior. A senior who wrote most of an area gets spot-the-flaw cards from the first edit.
In Claude Code:
/plugin marketplace add isAlmogK/apprentice-claude-mod
/plugin install apprentice@apprentice
Then open a git repo and run /apprentice.
From source:
git clone https://github.com/isAlmogK/apprentice-claude-mod.git
claude --plugin-dir apprentice-claude-mod
The images below are drawn from Apprentice's own view code with demo data (node scripts/screenshots.ts), so the text and layout match what the mod draws. Claude Code's own styling differs a little between the Desktop app and the terminal.
The band. After Claude's first edit of a turn, a card appears above your prompt while Claude keeps working. Juniors and mids get a question with three numbered answers. Seniors get a spot-the-flaw card: the diff Claude just wrote, with Looks fine and Flag to Claude. Flag to Claude sends Claude a short note about the concern once it's idle. Answer with a click, or type 1, 2 or 3 into the empty prompt. A wrong answer still shows the explanation. Skip keeps the card in the area's list for later; Quiet 1h pauses cards.
The map. /apprentice groups your repo into at most 15 areas, names them, and seeds each score from git blame. Each area is a star with its name under it, sized by its share of the code and brighter the more you know it. A dashed outline means Claude changed it since you last looked. Under the map, each area is a button for its details: rename it, merge it into another, or bring back a skipped card.
The terminal draws the same map as a character grid:
Codebase Wrapped. Once a week, when you've answered at least 5 cards, a toast says your week is ready. /apprentice wrapped shows areas mastered, flaws you caught, cards answered, dark zones left and your biggest jump. Copy puts a text version on your clipboard, ready to post. It never contains code.
| Command | Does |
|---|---|
/apprentice | Opens the map (maps the repo first if needed) |
/apprentice wrapped | Opens this week's Codebase Wrapped |
/apprentice quiet [minutes] | Pauses cards, 60 minutes by default. Dimming continues |
/apprentice remap | Regroups the areas. Areas that keep their id keep what you learned |
Every worktree of a repo shares one map, so a session in a linked worktree sees and updates the same scores.
Everything stays on your machine except what Apprentice asks Haiku, through Claude Code.
Goes to Haiku:
Never goes to Haiku:
.env*, *.pem, *.key, id_rsa*, id_ed25519*, anything with secret or credential in the path). Their edits still dim the map, but they never get a card.Scores, answers and skipped cards are stored locally in Claude Code's mod store. At most 6 card requests go to Haiku an hour.
Good to know:
/apprentice appears once the session has started, so send a first message if it isn't in the / menu yet. While Claude is working, the Desktop app queues it; Send now runs it straight away.claude plugin disable replay-theater@claude-code-playground-mods to keep cards on screen until you answer them.No. The card is made in the background after Claude's edit and appears above the prompt while Claude keeps working. If anything in Apprentice fails, Claude's edit goes through unchanged and no card appears.
Cards are written by Haiku through Claude Code, so they count toward your Claude Code usage: at most 6 card requests an hour, plus one call each time the repo is mapped. /apprentice quiet stops cards; dimming needs no model.
No. A spot-the-flaw card asks whether you see a problem, and the answer is Haiku's view, not a verdict on the code. For checking the change itself, see trustbut and Holdline.
No. Scores live in Claude Code's local mod store on your machine. Codebase Wrapped is shared only if you copy it and post it yourself.
| Phase | Scope | State |
|---|---|---|
| v1 | Knowledge map (Desktop and terminal), question and spot-the-flaw cards, Flag to Claude, Codebase Wrapped, rename and merge areas | ✅ Built |
| Launch | Hand check in a live session ✅, listing on claude-mods.com, first release | 🚧 In progress |
| Later | Lines between areas that change together, VS Code support, cards for edits made by people, a team view for onboarding | Planned |
Apprentice is built test first, largely by AI agents working from written specs and plans, with every task reviewed before it merges. All logic lives in src/ as plain TypeScript with no mods API, and only hooks/register.ts talks to Claude Code. Read CLAUDE.md before changing it. Issues and ideas are welcome.
claude plugin test # the whole test suite
claude plugin validate . # the manifest and hooks module
Run both before every commit. To try a change, start Claude Code with claude --plugin-dir <path to your clone>. After changing a view, run node scripts/screenshots.ts (Node 22.18 or later and Google Chrome) to redraw the images in docs/images/.
Apprentice is free and open source, and I build it in my own time. If it helps you keep up with the code your agent writes, sponsoring me on GitHub keeps it maintained and moving down the roadmap.
I'm Almog Koren, a software engineer in Tallinn with more than 15 years of shipping production software. I founded and built DogBase, a bootstrapped SaaS for working-dog teams that won the main pitch competition at Latitude59 2026, and before that I was co-founder and CTO of Platfarm, which was acquired. I took both companies through Techstars.
These days I mostly build AI agents and the systems around them. Apprentice is the third of my tools for working with them, and they don't overlap: trustbut proves a change is actually tested, Holdline proves it didn't break a business rule you approved, and Apprentice makes sure you still understand it. It came out of my own work: I wanted to keep understanding the code my agents were changing.
I'm open to work: full-time roles, fractional CTO work and AI agent projects. My CV and contact details are at almog.io.
Built by Almog Koren · almog.io · @isAlmogK. If Apprentice helps you keep up with your agent, a ⭐ helps others find it, and sponsoring helps keep it going.
hooks/register.ts 517 lines1import { areaForPath, carryProgress, draftToArea, groupAreas, mergeAreas, renameArea, type AreaDraft } from '../src/core/areas.ts'
2import { BLAME_SAMPLE, parseBlame, parseLog, parseLsTree, parseWorktreeList, relativeTo, repoIdFrom, repoNameOf, rootFor, sampleFiles } from '../src/core/git.ts'
3import type { Answer, Area, Card } from '../src/core/types.ts'
4import { applyAnswer, applyDim, changedLinesOf, dimmedByFor, initialLevel, isFlawAnswerRight, seedScore } from '../src/core/understanding.ts'
5import { buildWrapped, isoWeek, weekIndex, wrappedText } from '../src/core/wrapped.ts'
6import { isSecretPath } from '../src/cards/exclude.ts'
7import { CARDS_PER_HOUR, HOUR_MS, cardsWithinHour } from '../src/cards/policy.ts'
8import { parseAreaNames, parseCardReply } from '../src/cards/parse.ts'
9import { MAX_HUNK_LINES, buildCardRequest, flagPromptText, buildNamingRequest, diffOf, editTextsOf, type EditText } from '../src/cards/prompt.ts'
10import { renderCard } from '../src/views/card.ts'
11import { renderWrapped } from '../src/views/wrapped.ts'
12import { renderPane, type PaneModel, type PaneStatus } from '../src/views/pane.ts'
13
14export const MAP_PANE = 'apprentice'
15export const WRAPPED_PANE = 'apprentice-wrapped'
16const GIT_TIMEOUT_MS = 600_000
17const USAGE = 'Usage: /apprentice [wrapped | quiet [minutes] | remap]'
18
19// root is the session's working tree (a linked worktree's own root, not the main checkout's): mapping and blame run there.
20type Repo = { id: string; root: string; name: string }
21type Meta = { areaIds: string[]; weekStart?: { week: string; scores: Record<string, number> } }
22
23// --- state ---
24let repo: Repo | null = null
25let status: PaneStatus = 'no-git'
26let progress = ''
27let mapError = ''
28let areas: Area[] = []
29let selectedId: string | null = null
30let pendingCards: Card[] = []
31
32// --- helpers ---
33function areaKey(id: string): string {
34 return `repo:${repo?.id}:area:${id}`
35}
36
37function metaKey(): string {
38 return `repo:${repo?.id}:meta`
39}
40
41async function runGit($: any, root: string, args: string[]): Promise<{ exitCode: number; stdout: string }> {
42 // core.quotepath=false: paths with non-ASCII characters come back as they are, not quoted
43 return $.process.run(['git', '-c', 'core.quotepath=false', ...args], { cwd: root, timeoutMs: GIT_TIMEOUT_MS })
44}
45
46// The working tree an edited file lives in, found among the repo's worktrees (longest root wins).
47// Listed on every edit, so a worktree made during the session is found too.
48async function fileRootOf($: any, r: Repo, path: string): Promise<string> {
49 if (!path.startsWith('/')) return r.root
50 const listed = await runGit($, r.root, ['worktree', 'list', '--porcelain'])
51 const roots = listed.exitCode === 0 ? parseWorktreeList(listed.stdout) : []
52 return rootFor([r.root, ...roots], path) ?? r.root
53}
54
55async function loadAreas($: any, r: Repo): Promise<Area[]> {
56 const meta: Meta | undefined = await $.store.get(`repo:${r.id}:meta`)
57 if (!meta || !Array.isArray(meta.areaIds)) return []
58 const loaded: Area[] = []
59 for (const id of meta.areaIds) {
60 const a = await $.store.get(`repo:${r.id}:area:${id}`)
61 if (a) loaded.push(a)
62 }
63 return loaded
64}
65
66async function saveAllAreas($: any, r: Repo, list: Area[]): Promise<void> {
67 for (const a of list) await $.store.set(`repo:${r.id}:area:${a.id}`, a)
68 const meta: Meta | undefined = await $.store.get(`repo:${r.id}:meta`)
69 await $.store.set(`repo:${r.id}:meta`, { ...(meta ?? {}), areaIds: list.map(a => a.id) })
70}
71
72// Read the stored copy, apply the pure change, write it back, and return what was written: the store
73// is the source of truth, so two sessions in one repo don't overwrite each other.
74async function updateArea($: any, r: Repo, id: string, change: (a: Area) => Area): Promise<Area | undefined> {
75 const key = `repo:${r.id}:area:${id}`
76 const stored: Area | undefined = await $.store.get(key)
77 if (!stored) return undefined
78 const written = change(stored)
79 await $.store.set(key, written)
80 return written
81}
82
83// Put a value read from or written to the store into the in-memory list.
84function putArea(saved: Area | undefined): void {
85 if (saved) areas = areas.map(a => (a.id === saved.id ? saved : a))
86}
87
88async function nameAreas($: any, drafts: AreaDraft[]): Promise<Record<string, string>> {
89 const request = buildNamingRequest(drafts)
90 if (request.ids.length === 0) return {}
91 try {
92 const reply = await $.model.complete({ model: 'haiku', system: request.system, prompt: request.prompt, maxTokens: 600, timeoutMs: 20000 })
93 return reply.isAnswered ? parseAreaNames(reply.text, request.ids) : {}
94 } catch {
95 return {}
96 }
97}
98
99async function mapRepo($: any): Promise<void> {
100 if (!repo) return
101 const r = repo
102 let step = 'listing files'
103 try {
104 const tree = await runGit($, r.root, ['ls-tree', '-r', '-l', 'HEAD'])
105 if (tree.exitCode !== 0) throw new Error(step)
106 const files = parseLsTree(tree.stdout)
107 step = 'reading history'
108 const log = await runGit($, r.root, ['log', '--name-only', '--format=@@commit', '-n', '500'])
109 const commits = log.exitCode === 0 ? parseLog(log.stdout) : []
110 const drafts = groupAreas(files, commits)
111 step = 'naming areas'
112 const names = await nameAreas($, drafts)
113 // Start from the stored areas, which hold what other sessions in this repo learned.
114 const stored = await loadAreas($, r)
115 const previous = stored.length > 0 ? stored : areas
116 const kept = new Map(previous.map(a => [a.id, a.name]))
117 const known = new Set(previous.map(a => a.id))
118 areas = carryProgress(previous, drafts.map(d => draftToArea({ ...d, name: kept.get(d.id) ?? names[d.id] ?? d.name }, 0)))
119 const fresh = drafts.filter(d => !known.has(d.id))
120 $.ui.invalidate('ui.render')
121 step = 'reading your git email'
122 const email = fresh.length > 0 ? (await runGit($, r.root, ['config', 'user.email'])).stdout.trim() : ''
123 step = 'seeding scores'
124 for (let i = 0; i < fresh.length; i++) {
125 const draft = fresh[i]
126 let mine = 0
127 let total = 0
128 if (email !== '') {
129 for (const f of sampleFiles(draft.files, BLAME_SAMPLE)) {
130 const blamed = await runGit($, r.root, ['blame', '--line-porcelain', '--', f.path])
131 if (blamed.exitCode !== 0) continue
132 const counted = parseBlame(blamed.stdout, email)
133 mine += counted.mine
134 total += counted.total
135 }
136 }
137 const score = seedScore(mine, total)
138 areas = areas.map(a => (a.id === draft.id ? { ...a, score, level: initialLevel(score) } : a))
139 progress = `Mapping your repo… ${i + 1} of ${fresh.length} areas seeded`
140 $.ui.invalidate('ui.render')
141 }
142 step = 'saving'
143 // Seeding can take a while: take progress saved meanwhile for the surviving ids before writing.
144 const latest = await loadAreas($, r)
145 if (latest.length > 0) areas = carryProgress(latest, areas)
146 await saveAllAreas($, r, areas)
147 status = 'ready'
148 progress = ''
149 } catch {
150 status = 'failed'
151 mapError = `Mapping stopped while ${step}.`
152 }
153 $.ui.invalidate('ui.render')
154}
155
156function startMapping($: any): void {
157 status = 'mapping'
158 progress = 'Mapping your repo…'
159 selectedId = null
160 $.ui.invalidate('ui.render')
161 $.clock.after(0, () => mapRepo($))
162}
163
164async function selectArea($: any, id: string | null): Promise<void> {
165 selectedId = id
166 pendingCards = id && repo ? ((await $.store.get(`repo:${repo.id}:pending:${id}`)) ?? []) : []
167 $.ui.invalidate('ui.render')
168}
169
170async function renameAndSave($: any, id: string, name: string): Promise<void> {
171 if (!repo || name.trim() === '') return
172 areas = renameArea(areas, id, name)
173 $.ui.invalidate('ui.render')
174 putArea(await updateArea($, repo, id, a => ({ ...a, name: name.trim() })))
175 $.ui.invalidate('ui.render')
176}
177
178async function mergeAndSave($: any, fromId: string, intoId: string): Promise<void> {
179 if (!repo) return
180 // Merge the stored copies, which hold what other sessions learned; memory is the fallback.
181 const from: Area | undefined = (await $.store.get(areaKey(fromId))) ?? areas.find(a => a.id === fromId)
182 const into: Area | undefined = (await $.store.get(areaKey(intoId))) ?? areas.find(a => a.id === intoId)
183 if (!from || !into || fromId === intoId) return
184 const merged = mergeAreas([into, from], fromId, intoId)[0]
185 areas = areas.filter(a => a.id !== fromId).map(a => (a.id === intoId ? merged : a))
186 await $.store.set(areaKey(intoId), merged)
187 await $.store.delete(areaKey(fromId))
188 const meta: Meta | undefined = await $.store.get(metaKey())
189 await $.store.set(metaKey(), { ...(meta ?? {}), areaIds: areas.map(a => a.id) })
190 selectedId = intoId
191 pendingCards = (await $.store.get(`repo:${repo.id}:pending:${intoId}`)) ?? []
192 $.ui.invalidate('ui.render')
193}
194
195function paneModel(e: any): PaneModel {
196 return {
197 surface: e.surface, status, progress, error: mapError, repoName: repo?.name ?? '',
198 areas, selectedId, pending: pendingCards,
199 columns: Math.max(20, Math.min(60, Number(e.props?.bodyColumns ?? 60))),
200 rows: Number(e.props?.scroll?.bodyRows) || undefined,
201 }
202}
203
204// --- cards ---
205const REVEAL_MS = 8000
206const PENDING_MAX = 5
207
208type CardContext = { root: string; areaId: string; areaName: string; level: Area['level']; edit: EditText; dimmedBy: number; editId: string }
209
210let card: Card | null = null
211let revealed: { isRight: boolean } | null = null
212let quietUntil = 0
213let cardTimes: number[] = []
214let firstEditDone = false
215
216async function makeCard($: any, ctx: CardContext): Promise<void> {
217 if (!repo) return
218 // Exit 1 is the only "not ignored": 0 is ignored, and anything else is a git error, so fail closed.
219 const ignored = await runGit($, ctx.root, ['check-ignore', '-q', ctx.edit.file])
220 if (ignored.exitCode !== 1) return
221 const request = buildCardRequest(ctx.areaName, ctx.level, ctx.edit)
222 // Count the request, not the card shown, so replies that never make a card still count against the cap.
223 const asked = await $.clock.now()
224 cardTimes = [...cardsWithinHour(cardTimes, asked), asked]
225 let reply: any
226 try {
227 reply = await $.model.complete({ model: 'haiku', system: request.system, prompt: request.prompt, maxTokens: 600, timeoutMs: 20000 })
228 } catch {
229 return
230 }
231 if (!reply?.isAnswered) return
232 const parsed = parseCardReply(reply.text, {
233 areaId: ctx.areaId, level: ctx.level, file: ctx.edit.file, editId: ctx.editId,
234 dimmedBy: ctx.dimmedBy, hunk: diffOf(ctx.edit, MAX_HUNK_LINES),
235 })
236 if (!parsed) return
237 // Cards may have been paused while Haiku was answering.
238 if ((await $.clock.now()) < quietUntil) return
239 card = parsed
240 revealed = null
241 $.ui.invalidate('ui.render')
242}
243
244async function logAnswer($: any, c: Card, outcome: { skipped: boolean; isRight: boolean; flagged: boolean }): Promise<void> {
245 if (!repo) return
246 const now = await $.clock.now()
247 const key = `repo:${repo.id}:answers:${isoWeek(now)}`
248 const log = (await $.store.get(key)) ?? []
249 const entry = {
250 at: now, areaId: c.areaId, level: c.level, kind: c.kind, ...outcome,
251 haikuSeesFlaw: c.kind === 'flaw' ? c.haikuSeesFlaw : false,
252 }
253 await $.store.set(key, [...log, entry])
254}
255
256async function answerCard($: any, c: Card, isRight: boolean, flagged: boolean): Promise<void> {
257 if (!repo || card !== c || revealed) return
258 revealed = { isRight }
259 $.ui.invalidate('ui.render')
260 // Clear and flag first, so a store failure below never leaves the card stuck or drops the flag.
261 $.clock.after(REVEAL_MS, () => {
262 if (card === c) {
263 card = null
264 revealed = null
265 $.ui.invalidate('ui.render')
266 }
267 })
268 if (flagged && c.kind === 'flaw') submitFlag($, flagPromptText(c))
269 const r = repo
270 try {
271 await ensureWeekStart($, await $.clock.now())
272 putArea(await updateArea($, r, c.areaId, a => applyAnswer(a, c.dimmedBy, isRight)))
273 $.ui.invalidate('ui.render')
274 await logAnswer($, c, { skipped: false, isRight, flagged })
275 } catch {
276 // The answer still shows; only its saving failed.
277 }
278}
279
280async function skipCard($: any, c: Card): Promise<void> {
281 if (!repo || card !== c) return
282 card = null
283 revealed = null
284 $.ui.invalidate('ui.render')
285 await ensureWeekStart($, await $.clock.now())
286 const key = `repo:${repo.id}:pending:${c.areaId}`
287 const pending: Card[] = (await $.store.get(key)) ?? []
288 const updated = [...pending, c].slice(-PENDING_MAX)
289 await $.store.set(key, updated)
290 if (selectedId === c.areaId) pendingCards = updated
291 await logAnswer($, c, { skipped: true, isRight: false, flagged: false })
292}
293
294async function quietCards($: any, minutes: number): Promise<void> {
295 quietUntil = (await $.clock.now()) + minutes * 60_000
296 card = null
297 revealed = null
298 $.ui.invalidate('ui.render')
299}
300
301async function restorePending($: any, index: number): Promise<void> {
302 if (!repo || !selectedId) return
303 const key = `repo:${repo.id}:pending:${selectedId}`
304 const pending: Card[] = (await $.store.get(key)) ?? []
305 const chosen = pending[index]
306 if (!chosen) return
307 const rest = pending.filter((_, i) => i !== index)
308 await $.store.set(key, rest)
309 pendingCards = rest
310 card = chosen
311 revealed = null
312 $.ui.invalidate('ui.render')
313}
314
315const KEEP_WEEKS = 8
316const TOAST_MIN_ANSWERS = 5
317
318let pendingFlag: string | null = null
319
320async function submitFlag($: any, text: string): Promise<void> {
321 let reason = ''
322 try {
323 const result = await $.prompt.submit({ text })
324 reason = result?.drop ?? ''
325 } catch (error: any) {
326 reason = String(error?.message ?? error)
327 }
328 if (reason.includes('the session was stopped and nobody has sent input since') || reason.includes('was removed from the queue before it ran')) {
329 pendingFlag = text
330 }
331}
332
333async function pruneAnswers($: any, id: string, week: string): Promise<void> {
334 const prefix = `repo:${id}:answers:`
335 const keys: string[] = (await $.store.keys()) ?? []
336 for (const key of keys) {
337 if (!key.startsWith(prefix)) continue
338 if (weekIndex(week) - weekIndex(key.slice(prefix.length)) > KEEP_WEEKS) await $.store.delete(key)
339 }
340}
341
342async function ensureWeekStart($: any, now: number): Promise<void> {
343 if (!repo) return
344 const week = isoWeek(now)
345 const meta: Meta = (await $.store.get(metaKey())) ?? { areaIds: areas.map(a => a.id) }
346 if (meta.weekStart?.week === week) return
347 const scores = Object.fromEntries(areas.map(a => [a.id, a.score]))
348 await $.store.set(metaKey(), { ...meta, weekStart: { week, scores } })
349 await pruneAnswers($, repo.id, week)
350}
351
352async function checkWrappedToast($: any): Promise<void> {
353 if (!repo) return
354 const week = isoWeek(await $.clock.now())
355 const log: Answer[] = (await $.store.get(`repo:${repo.id}:answers:${week}`)) ?? []
356 if (log.filter(a => !a.skipped).length < TOAST_MIN_ANSWERS) return
357 const toastedKey = `repo:${repo.id}:toasted`
358 if ((await $.store.get(toastedKey)) === week) return
359 await $.store.set(toastedKey, week)
360 $.ui.toast('Codebase Wrapped is ready: run /apprentice wrapped')
361}
362
363async function copyWrapped($: any, text: string): Promise<void> {
364 await $.ui.copy({ text })
365 $.ui.toast('Copied')
366}
367
368export function register(on: any) {
369 on('session.start', async ($: any, e: any, next: any) => {
370 try {
371 const found = await $.session.repo()
372 if (found) {
373 // repo() names the main checkout for every worktree: it keys progress, so all worktrees share it.
374 // Path work uses the session's own working tree.
375 const cwd = String((await $.session.cwd()) ?? found.root)
376 const top = await runGit($, cwd, ['rev-parse', '--show-toplevel'])
377 const root = top.exitCode === 0 && top.stdout.trim() !== '' ? top.stdout.trim() : found.root
378 const r: Repo = { id: await repoIdFrom(found.remote ?? found.root), root, name: repoNameOf(found.root) }
379 areas = await loadAreas($, r)
380 repo = r
381 status = areas.length > 0 ? 'ready' : 'idle'
382 }
383 } catch {
384 // Without a repo Apprentice stays off, but /apprentice is still registered and says why.
385 repo = null
386 areas = []
387 status = 'no-git'
388 }
389 $.clock.every(HOUR_MS, () => checkWrappedToast($))
390 await $.command.register({
391 name: 'apprentice',
392 description: 'Open your knowledge map of this repo',
393 argumentHint: '[wrapped | quiet [minutes] | remap]',
394 immediate: true,
395 })
396 return next(e)
397 })
398
399 on('command.run', { command: 'apprentice' }, async ($: any, e: any) => {
400 const [sub = '', arg = ''] = String(e.args ?? '').trim().split(/\s+/)
401 if (sub === 'quiet') {
402 const minutes = Number(arg) > 0 ? Math.max(1, Math.round(Number(arg))) : 60
403 await quietCards($, minutes)
404 return { text: `Cards are paused for ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.` }
405 }
406 if (sub === 'wrapped') {
407 if (!repo) return { text: 'Apprentice needs a git repo.' }
408 await $.ui.open({ id: WRAPPED_PANE, title: 'Codebase Wrapped', focus: true, closeOnEscape: true })
409 return {}
410 }
411 if (sub !== '' && sub !== 'remap') return { text: USAGE }
412 // While a mapping runs, /apprentice remap only opens the pane.
413 if (repo && status !== 'mapping' && (sub === 'remap' || status === 'idle' || status === 'failed')) startMapping($)
414 else if (repo && status === 'ready') {
415 // Pick up what other sessions in this repo saved since this one loaded.
416 const loaded = await loadAreas($, repo)
417 if (loaded.length > 0) areas = loaded
418 }
419 await $.ui.open({ id: MAP_PANE, title: 'Apprentice', focus: true, closeOnEscape: true })
420 return {}
421 })
422
423 on('ui.render', { component: 'Pane' }, async ($: any, e: any, next: any) => {
424 if (e.requestId === WRAPPED_PANE && repo) {
425 const E = $.ui.resolve(e)
426 const week = isoWeek(await $.clock.now())
427 const log: Answer[] = (await $.store.get(`repo:${repo.id}:answers:${week}`)) ?? []
428 const meta: Meta | undefined = await $.store.get(metaKey())
429 const start = meta?.weekStart?.week === week ? meta.weekStart.scores : {}
430 const summary = buildWrapped(log, areas, start)
431 const name = repo.name
432 return renderWrapped(E, summary, name, week, { onCopy: () => copyWrapped($, wrappedText(summary, name, week)) })
433 }
434 if (e.requestId !== MAP_PANE) return next(e)
435 const E = $.ui.resolve(e)
436 return renderPane(E, paneModel(e), {
437 onSelect: (id: string | null) => selectArea($, id),
438 onRename: (id: string, name: string) => renameAndSave($, id, name),
439 onMerge: (fromId: string, intoId: string) => mergeAndSave($, fromId, intoId),
440 onRestore: (index: number) => restorePending($, index),
441 })
442 })
443
444 // --- more hooks ---
445
446 on('turn.complete', async ($: any, e: any, next: any) => {
447 if (pendingFlag !== null) {
448 const text = pendingFlag
449 pendingFlag = null
450 // Not awaited: the hook would stay open until the new turn starts
451 submitFlag($, text)
452 }
453 return next(e)
454 })
455
456 on('turn.start', async ($: any, e: any, next: any) => {
457 if (e.agentId === undefined) firstEditDone = false
458 return next(e)
459 })
460
461 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit'] }, async ($: any, e: any, next: any) => {
462 const result = await next(e)
463 try {
464 if (result?.deny || result?.isError || status !== 'ready' || !repo) return result
465 const path = String(e.file_path ?? '')
466 const root = await fileRootOf($, repo, path)
467 const rel = relativeTo(root, path)
468 if (rel === null) return result
469 const area = areaForPath(areas, rel)
470 if (!area) return result
471 const changed = changedLinesOf(e.tool, e)
472 // Dim the stored copy by what its own score allows, and keep that amount for the card.
473 let dimmedBy = 0
474 putArea(await updateArea($, repo, area.id, a => {
475 dimmedBy = dimmedByFor(a, changed)
476 return applyDim(a, dimmedBy)
477 }))
478 $.ui.invalidate('ui.render')
479 const now = await $.clock.now()
480 const wantsCard = !firstEditDone && now >= quietUntil
481 && cardsWithinHour(cardTimes, now).length < CARDS_PER_HOUR && !isSecretPath(rel)
482 firstEditDone = true
483 if (wantsCard) {
484 const ctx: CardContext = {
485 root, areaId: area.id, areaName: area.name, level: area.level,
486 edit: { file: rel, ...editTextsOf(e.tool, e) }, dimmedBy, editId: `${now}`,
487 }
488 $.clock.after(0, () => makeCard($, ctx))
489 }
490 return result
491 } catch {
492 return result
493 }
494 }).catch(async ($: any, e: any, next: any) => next(e))
495
496 on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => {
497 if (!card || e.surface === 'vscode') return next(e)
498 const shown = card
499 const E = $.ui.resolve(e)
500 const areaName = areas.find(a => a.id === shown.areaId)?.name ?? 'Other'
501 const theirs = await next(e)
502 return E.Box({
503 flexDirection: 'column',
504 children: [
505 renderCard(E, { card: shown, areaName, revealed, maxRows: Number(e.props?.maxRows) || undefined, columns: Number(e.props?.bodyColumns) || undefined }, {
506 onAnswer: (index: number) => answerCard($, shown, shown.kind === 'question' && index === shown.answer, false),
507 onFlaw: (pressed: 'flag' | 'looks-fine') =>
508 answerCard($, shown, shown.kind === 'flaw' && isFlawAnswerRight(shown, pressed), pressed === 'flag'),
509 onSkip: () => skipCard($, shown),
510 onQuiet: () => quietCards($, 60),
511 }),
512 theirs,
513 ],
514 })
515 })
516}
517src/core/areas.ts 208 lines1import type { Area } from './types.ts'
2import { initialLevel, round4 } from './understanding.ts'
3
4export type FileInfo = { path: string; lines: number }
5export type AreaDraft = { id: string; name: string; paths: string[]; lines: number; files: FileInfo[] }
6
7export const SPLIT_SHARE = 0.25
8export const SMALL_SHARE = 0.02
9export const COCHANGE_RATIO = 0.6
10export const MIN_COMMITS = 3
11export const MIN_AREAS = 6
12export const MAX_AREAS = 15
13
14// A folder prefix such as 'src/' (everything under it), or its direct files only.
15type Group = { prefix: string; direct: boolean; files: FileInfo[] }
16type Cluster = { id: string; name: string; paths: string[]; files: FileInfo[] }
17
18const sumLines = (files: FileInfo[]) => files.reduce((sum, f) => sum + f.lines, 0)
19
20function idForPrefix(prefix: string): string {
21 if (prefix === '') return 'root'
22 return prefix.replace(/\/$/, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase() || 'root'
23}
24
25function nameForPrefix(prefix: string): string {
26 return prefix === '' ? 'Root files' : prefix.replace(/\/$/, '')
27}
28
29function initialGroups(files: FileInfo[]): Group[] {
30 const groups = new Map<string, Group>()
31 for (const f of files) {
32 const slash = f.path.indexOf('/')
33 const direct = slash === -1
34 const prefix = direct ? '' : f.path.slice(0, slash + 1)
35 const key = (direct ? '=' : '') + prefix
36 if (!groups.has(key)) groups.set(key, { prefix, direct, files: [] })
37 groups.get(key)!.files.push(f)
38 }
39 return [...groups.values()]
40}
41
42function childPrefix(prefix: string, path: string): string | null {
43 const rest = path.slice(prefix.length)
44 const slash = rest.indexOf('/')
45 return slash === -1 ? null : prefix + rest.slice(0, slash + 1)
46}
47
48function canSplit(g: Group): boolean {
49 return !g.direct && g.files.some(f => childPrefix(g.prefix, f.path) !== null)
50}
51
52function split(g: Group): Group[] {
53 const children = new Map<string, Group>()
54 const direct: FileInfo[] = []
55 for (const f of g.files) {
56 const child = childPrefix(g.prefix, f.path)
57 if (child === null) {
58 direct.push(f)
59 continue
60 }
61 if (!children.has(child)) children.set(child, { prefix: child, direct: false, files: [] })
62 children.get(child)!.files.push(f)
63 }
64 const out = [...children.values()]
65 if (direct.length > 0) out.push({ prefix: g.prefix, direct: true, files: direct })
66 return out
67}
68
69function commitSet(cluster: Cluster, commits: string[][]): Set<number> {
70 const mine = new Set(cluster.files.map(f => f.path))
71 const touched = new Set<number>()
72 commits.forEach((commit, i) => {
73 if (commit.some(path => mine.has(path))) touched.add(i)
74 })
75 return touched
76}
77
78// Keeps the larger cluster's id and name; on a tie, the first one's.
79function mergePair(list: Cluster[], i: number, j: number): Cluster[] {
80 const [a, b] = [list[i], list[j]]
81 const keep = sumLines(b.files) > sumLines(a.files) ? b : a
82 const merged: Cluster = { id: keep.id, name: keep.name, paths: [...a.paths, ...b.paths], files: [...a.files, ...b.files] }
83 return list.filter((_, k) => k !== i && k !== j).concat(merged)
84}
85
86function mergeByCoChange(clusters: Cluster[], commits: string[][]): Cluster[] {
87 let list = clusters
88 while (list.length > MIN_AREAS) {
89 const sets = list.map(c => commitSet(c, commits))
90 let best: { i: number; j: number; ratio: number } | null = null
91 for (let i = 0; i < list.length; i++) {
92 for (let j = i + 1; j < list.length; j++) {
93 if (list[i].id === 'other' || list[j].id === 'other') continue
94 if (sets[i].size < MIN_COMMITS || sets[j].size < MIN_COMMITS) continue
95 const both = [...sets[i]].filter(c => sets[j].has(c)).length
96 const either = new Set([...sets[i], ...sets[j]]).size
97 const ratio = both / either
98 if (ratio > COCHANGE_RATIO && (!best || ratio > best.ratio)) best = { i, j, ratio }
99 }
100 }
101 if (!best) break
102 list = mergePair(list, best.i, best.j)
103 }
104 return list
105}
106
107function capCount(clusters: Cluster[]): Cluster[] {
108 let list = clusters
109 while (list.length > MAX_AREAS) {
110 const order = list
111 .map((c, i) => ({ i, lines: sumLines(c.files), id: c.id }))
112 .sort((a, b) => a.lines - b.lines || a.id.localeCompare(b.id))
113 const [first, second] = [order[0].i, order[1].i].sort((a, b) => a - b)
114 list = mergePair(list, first, second)
115 }
116 return list
117}
118
119function uniqueIds(clusters: Cluster[]): Cluster[] {
120 const seen = new Map<string, number>()
121 return clusters.map(c => {
122 const count = (seen.get(c.id) ?? 0) + 1
123 seen.set(c.id, count)
124 return count === 1 ? c : { ...c, id: `${c.id}-${count}` }
125 })
126}
127
128export function groupAreas(files: FileInfo[], commits: string[][]): AreaDraft[] {
129 const total = sumLines(files)
130 if (total === 0) return []
131 let groups = initialGroups(files)
132 for (let guard = 0; guard < 500; guard++) {
133 const big = groups
134 .filter(g => sumLines(g.files) > SPLIT_SHARE * total && canSplit(g))
135 .sort((a, b) => sumLines(b.files) - sumLines(a.files))[0]
136 if (!big) break
137 groups = [...groups.filter(g => g !== big), ...split(big)]
138 }
139 const small = groups.filter(g => sumLines(g.files) < SMALL_SHARE * total)
140 const kept = groups.filter(g => !small.includes(g))
141 let clusters: Cluster[] = kept.map(g => ({
142 id: idForPrefix(g.prefix), name: nameForPrefix(g.prefix), paths: [g.prefix], files: g.files,
143 }))
144 if (small.length > 0) {
145 clusters.push({ id: 'other', name: 'Other', paths: small.map(g => g.prefix), files: small.flatMap(g => g.files) })
146 }
147 clusters = capCount(mergeByCoChange(uniqueIds(clusters), commits))
148 return clusters
149 .map(c => ({ id: c.id, name: c.name, paths: [...c.paths].sort(), lines: sumLines(c.files), files: c.files }))
150 .sort((a, b) => b.lines - a.lines || a.id.localeCompare(b.id))
151}
152
153export function draftToArea(draft: AreaDraft, score: number): Area {
154 return {
155 id: draft.id, name: draft.name, paths: draft.paths, lines: draft.lines,
156 score, editsSinceLooked: 0, level: initialLevel(score), streak: 0,
157 }
158}
159
160// The '' prefix ("Root files") holds only files at the repo root, so a path in a folder
161// no area lists matches nothing and is ignored.
162function covers(prefix: string, path: string): boolean {
163 return prefix === '' ? !path.includes('/') : path.startsWith(prefix)
164}
165
166export function areaForPath<T extends { paths: string[] }>(list: T[], path: string): T | undefined {
167 let best: T | undefined
168 let bestLength = -1
169 for (const item of list) {
170 for (const prefix of item.paths) {
171 if (covers(prefix, path) && prefix.length > bestLength) {
172 best = item
173 bestLength = prefix.length
174 }
175 }
176 }
177 return best
178}
179
180export function renameArea(list: Area[], id: string, name: string): Area[] {
181 const clean = name.trim()
182 if (clean === '') return list
183 return list.map(a => (a.id === id ? { ...a, name: clean } : a))
184}
185
186export function mergeAreas(list: Area[], fromId: string, intoId: string): Area[] {
187 const from = list.find(a => a.id === fromId)
188 const into = list.find(a => a.id === intoId)
189 if (!from || !into || from === into) return list
190 const lines = from.lines + into.lines
191 const score = lines > 0 ? round4((from.score * from.lines + into.score * into.lines) / lines) : 0
192 const merged: Area = {
193 ...into, paths: [...into.paths, ...from.paths], lines, score,
194 editsSinceLooked: from.editsSinceLooked + into.editsSinceLooked, streak: 0,
195 }
196 return list.filter(a => a !== from).map(a => (a === into ? merged : a))
197}
198
199// A remap keeps what was learned: areas whose id survives keep score, level, streak and edits,
200// and take only the new paths and lines. Areas with a new id are returned as they are.
201export function carryProgress(previous: Area[], next: Area[]): Area[] {
202 const old = new Map(previous.map(a => [a.id, a]))
203 return next.map(a => {
204 const p = old.get(a.id)
205 return p ? { ...a, score: p.score, level: p.level, streak: p.streak, editsSinceLooked: p.editsSinceLooked } : a
206 })
207}
208src/core/git.ts 76 lines1import type { FileInfo } from './areas.ts'
2
3export const BYTES_PER_LINE = 40
4export const BLAME_SAMPLE = 20
5const LS_TREE_LINE = /^\d+ blob [0-9a-f]+\s+(\d+)\t(.+)$/
6
7export function parseLsTree(stdout: string): FileInfo[] {
8 const files: FileInfo[] = []
9 for (const line of stdout.split('\n')) {
10 const match = LS_TREE_LINE.exec(line)
11 if (match) files.push({ path: match[2], lines: Math.max(1, Math.round(Number(match[1]) / BYTES_PER_LINE)) })
12 }
13 return files
14}
15
16export function parseLog(stdout: string): string[][] {
17 const commits: string[][] = []
18 let current: string[] | null = null
19 for (const raw of stdout.split('\n')) {
20 const line = raw.trim()
21 if (line === '@@commit') {
22 current = []
23 commits.push(current)
24 } else if (line !== '' && current) {
25 current.push(line)
26 }
27 }
28 return commits.filter(c => c.length > 0)
29}
30
31export function parseBlame(stdout: string, email: string): { mine: number; total: number } {
32 const want = `<${email.trim().toLowerCase()}>`
33 let mine = 0
34 let total = 0
35 for (const line of stdout.split('\n')) {
36 if (!line.startsWith('author-mail ')) continue
37 total += 1
38 if (line.slice('author-mail '.length).trim().toLowerCase() === want) mine += 1
39 }
40 return { mine, total }
41}
42
43export async function repoIdFrom(text: string): Promise<string> {
44 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
45 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16)
46}
47
48export function relativeTo(root: string, path: string): string | null {
49 if (!path.startsWith('/')) return path.replace(/^\.\//, '')
50 const base = root.endsWith('/') ? root : root + '/'
51 return path.startsWith(base) ? path.slice(base.length) : null
52}
53
54export function repoNameOf(root: string): string {
55 return root.split('/').filter(Boolean).at(-1) ?? root
56}
57
58export function sampleFiles(files: FileInfo[], n: number): FileInfo[] {
59 return [...files].sort((a, b) => b.lines - a.lines || a.path.localeCompare(b.path)).slice(0, n)
60}
61
62// The `worktree <path>` lines of `git worktree list --porcelain`: every working tree of the repo.
63export function parseWorktreeList(stdout: string): string[] {
64 return stdout.split('\n').filter(l => l.startsWith('worktree ')).map(l => l.slice('worktree '.length).trim()).filter(Boolean)
65}
66
67// The working tree an absolute path is in: the longest root it sits under, or null.
68export function rootFor(roots: string[], path: string): string | null {
69 if (!path.startsWith('/')) return null
70 let best: string | null = null
71 for (const root of roots) {
72 if (relativeTo(root, path) !== null && (best === null || root.length > best.length)) best = root
73 }
74 return best
75}
76src/core/types.ts 52 lines1export type Level = 'junior' | 'mid' | 'senior'
2
3export type Area = {
4 id: string
5 name: string
6 paths: string[]
7 lines: number
8 score: number
9 editsSinceLooked: number
10 level: Level
11 streak: number
12}
13
14export type QuestionCard = {
15 kind: 'question'
16 areaId: string
17 level: 'junior' | 'mid'
18 file: string
19 question: string
20 options: [string, string, string]
21 answer: 0 | 1 | 2
22 explanation: string
23 editId: string
24 dimmedBy: number
25}
26
27export type FlawCard = {
28 kind: 'flaw'
29 areaId: string
30 level: 'senior'
31 file: string
32 hunk: string
33 haikuSeesFlaw: boolean
34 summary: string
35 explanation: string
36 editId: string
37 dimmedBy: number
38}
39
40export type Card = QuestionCard | FlawCard
41
42export type Answer = {
43 at: number
44 areaId: string
45 level: Level
46 kind: Card['kind']
47 skipped: boolean
48 isRight: boolean
49 flagged: boolean
50 haikuSeesFlaw: boolean
51}
52src/core/understanding.ts 76 lines1import type { Area, FlawCard, Level } from './types.ts'
2
3export const SEED_CAP = 0.9
4export const RIGHT_BONUS = 0.02
5export const WRONG_SHARE = 0.25
6const LEVELS: Level[] = ['junior', 'mid', 'senior']
7
8export function round4(x: number): number {
9 return Math.round(x * 10000) / 10000
10}
11
12const clamp01 = (x: number) => Math.min(1, Math.max(0, x))
13
14export function seedScore(mine: number, sampled: number): number {
15 if (sampled <= 0) return 0
16 return round4(Math.min(SEED_CAP, mine / sampled))
17}
18
19export function initialLevel(score: number): Level {
20 if (score < 0.4) return 'junior'
21 if (score < 0.75) return 'mid'
22 return 'senior'
23}
24
25export function countLines(text: unknown): number {
26 if (typeof text !== 'string' || text === '') return 0
27 return text.split('\n').length
28}
29
30export function changedLinesOf(tool: string, input: Record<string, unknown>): number {
31 if (tool === 'Write') return countLines(input.content)
32 if (tool === 'Edit') return Math.max(countLines(input.old_string), countLines(input.new_string))
33 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
34 return input.edits.reduce(
35 (sum: number, edit: any) => sum + Math.max(countLines(edit?.old_string), countLines(edit?.new_string)),
36 0,
37 )
38 }
39 return 0
40}
41
42export function dimmedByFor(area: Area, changedLines: number): number {
43 const share = area.lines > 0 ? changedLines / area.lines : 1
44 return round4(Math.min(area.score, Math.max(0, share)))
45}
46
47export function applyDim(area: Area, dimmedBy: number): Area {
48 const taken = Math.min(area.score, dimmedBy)
49 return { ...area, score: round4(area.score - taken), editsSinceLooked: area.editsSinceLooked + 1 }
50}
51
52export function applyAnswer(area: Area, dimmedBy: number, isRight: boolean): Area {
53 const raised = isRight ? area.score + dimmedBy + RIGHT_BONUS : area.score + dimmedBy * WRONG_SHARE
54 let streak = isRight ? Math.max(area.streak, 0) + 1 : Math.min(area.streak, 0) - 1
55 let level = area.level
56 const index = LEVELS.indexOf(level)
57 if (streak >= 3) {
58 level = LEVELS[Math.min(index + 1, LEVELS.length - 1)]
59 streak = 0
60 } else if (streak <= -2) {
61 level = LEVELS[Math.max(index - 1, 0)]
62 streak = 0
63 }
64 return { ...area, score: round4(clamp01(raised)), streak, level, editsSinceLooked: 0 }
65}
66
67export function isFlawAnswerRight(card: FlawCard, pressed: 'flag' | 'looks-fine'): boolean {
68 return (pressed === 'flag') === card.haikuSeesFlaw
69}
70
71export function knownPercent(areas: Area[]): number {
72 const total = areas.reduce((sum, a) => sum + a.lines, 0)
73 if (total === 0) return 0
74 return Math.round((areas.reduce((sum, a) => sum + a.score * a.lines, 0) / total) * 100)
75}
76src/core/wrapped.ts 60 lines1import type { Answer, Area } from './types.ts'
2
3export const MASTERED = 0.8
4export const DARK = 0.3
5
6export function isoWeek(ms: number): string {
7 const date = new Date(ms)
8 const dayFromMonday = (date.getUTCDay() + 6) % 7
9 const thursday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() - dayFromMonday + 3))
10 const year = thursday.getUTCFullYear()
11 // January 4th is always in week 1
12 const jan4 = new Date(Date.UTC(year, 0, 4))
13 const jan4FromMonday = (jan4.getUTCDay() + 6) % 7
14 const week = 1 + Math.round(((thursday.getTime() - jan4.getTime()) / 864e5 - 3 + jan4FromMonday) / 7)
15 return `${year}-W${String(week).padStart(2, '0')}`
16}
17
18export function weekIndex(week: string): number {
19 const [year, number] = week.split('-W').map(Number)
20 return year * 53 + number
21}
22
23export type Wrapped = {
24 mastered: number
25 flawsCaught: number
26 answered: number
27 darkZones: number
28 biggestJump: { name: string; from: number; to: number } | null
29}
30
31export function buildWrapped(answers: Answer[], areas: Area[], startScores: Record<string, number>): Wrapped {
32 const startOf = (a: Area) => startScores[a.id] ?? a.score
33 let jump: { name: string; from: number; to: number; gain: number } | null = null
34 for (const a of areas) {
35 const gain = a.score - startOf(a)
36 if (gain > 0 && (!jump || gain > jump.gain)) {
37 jump = { name: a.name, from: Math.round(startOf(a) * 100), to: Math.round(a.score * 100), gain }
38 }
39 }
40 return {
41 mastered: areas.filter(a => startOf(a) < MASTERED && a.score >= MASTERED).length,
42 flawsCaught: answers.filter(a => a.flagged && a.haikuSeesFlaw).length,
43 answered: answers.filter(a => !a.skipped).length,
44 darkZones: areas.filter(a => a.score < DARK).length,
45 biggestJump: jump ? { name: jump.name, from: jump.from, to: jump.to } : null,
46 }
47}
48
49export function wrappedText(w: Wrapped, repoName: string, week: string): string {
50 const lines = [
51 `My week in ${repoName} (${week}), from Apprentice:`,
52 `- Areas mastered: ${w.mastered}`,
53 `- Flaws I caught: ${w.flawsCaught}`,
54 `- Cards answered: ${w.answered}`,
55 `- Dark zones left: ${w.darkZones}`,
56 ]
57 if (w.biggestJump) lines.push(`- Biggest jump: ${w.biggestJump.name}, ${w.biggestJump.from}% to ${w.biggestJump.to}%`)
58 return lines.join('\n')
59}
60src/cards/exclude.ts 14 lines1const SECRET_PATTERNS = [
2 /(^|\/)\.env/i,
3 /\.pem$/i,
4 /\.key$/i,
5 /(^|\/)id_rsa/i,
6 /(^|\/)id_ed25519/i,
7 /secret/i,
8 /credential/i,
9]
10
11export function isSecretPath(path: string): boolean {
12 return SECRET_PATTERNS.some(pattern => pattern.test(path))
13}
14src/cards/policy.ts 7 lines1export const CARDS_PER_HOUR = 6
2export const HOUR_MS = 3_600_000
3
4export function cardsWithinHour(times: number[], now: number): number[] {
5 return times.filter(t => now - t < HOUR_MS)
6}
7src/cards/parse.ts 53 lines1import type { Card, Level } from '../core/types.ts'
2
3export type CardMeta = { areaId: string; level: Level; file: string; editId: string; dimmedBy: number; hunk: string }
4
5export function extractJson(text: string): unknown {
6 const start = text.indexOf('{')
7 const end = text.lastIndexOf('}')
8 if (start === -1 || end <= start) return null
9 try {
10 return JSON.parse(text.slice(start, end + 1))
11 } catch {
12 return null
13 }
14}
15
16const isText = (value: unknown, max: number): value is string =>
17 typeof value === 'string' && value.trim() !== '' && value.length <= max
18
19export function parseCardReply(text: string, meta: CardMeta): Card | null {
20 const data = extractJson(text) as Record<string, unknown> | null
21 if (!data || typeof data !== 'object') return null
22 const base = { areaId: meta.areaId, file: meta.file, editId: meta.editId, dimmedBy: meta.dimmedBy }
23 if (meta.level === 'senior') {
24 if (typeof data.haikuSeesFlaw !== 'boolean') return null
25 if (!isText(data.summary, 200) || !isText(data.explanation, 600)) return null
26 return {
27 kind: 'flaw', ...base, level: 'senior', hunk: meta.hunk,
28 haikuSeesFlaw: data.haikuSeesFlaw, summary: data.summary.trim(), explanation: data.explanation.trim(),
29 }
30 }
31 const options = data.options
32 if (!isText(data.question, 300) || !Array.isArray(options) || options.length !== 3) return null
33 if (!options.every(o => isText(o, 120))) return null
34 if (data.answer !== 0 && data.answer !== 1 && data.answer !== 2) return null
35 if (!isText(data.explanation, 600)) return null
36 return {
37 kind: 'question', ...base, level: meta.level,
38 question: data.question.trim(),
39 options: [options[0].trim(), options[1].trim(), options[2].trim()],
40 answer: data.answer, explanation: data.explanation.trim(),
41 }
42}
43
44export function parseAreaNames(text: string, ids: string[]): Record<string, string> {
45 const data = extractJson(text) as Record<string, unknown> | null
46 const names: Record<string, string> = {}
47 if (!data || typeof data !== 'object') return names
48 for (const id of ids) {
49 if (isText(data[id], 40)) names[id] = (data[id] as string).trim()
50 }
51 return names
52}
53src/cards/prompt.ts 90 lines1import type { AreaDraft } from '../core/areas.ts'
2import { sampleFiles } from '../core/git.ts'
3import { isSecretPath } from './exclude.ts'
4import type { FlawCard, Level } from '../core/types.ts'
5
6export const MAX_DIFF_LINES = 120
7export const MAX_HUNK_LINES = 40
8
9export type EditText = { file: string; oldText: string; newText: string }
10
11const QUESTION_SHAPE =
12 'Reply with only JSON: {"question": string, "options": [string, string, string], "answer": 0 | 1 | 2, "explanation": string}. ' +
13 'Keep the question under 140 characters, each option under 60, and the explanation under 400. Exactly one option is correct, and the others are plausible.'
14
15const SYSTEMS: Record<Level, string> = {
16 junior:
17 'You write one multiple-choice question that helps a developer understand a change Claude just made in their own project. ' +
18 'Ask what the change does or why it was made. ' + QUESTION_SHAPE,
19 mid:
20 'You write one multiple-choice question that helps a developer understand a change Claude just made in their own project. ' +
21 'Ask what would break without it, or what it depends on. ' + QUESTION_SHAPE,
22 senior:
23 'You review one code change for a senior developer. Decide whether it has a real flaw: a bug, a security problem, or a behavior change that looks unintended. ' +
24 'Reply with only JSON: {"haikuSeesFlaw": boolean, "summary": string, "explanation": string}. ' +
25 'summary is one sentence under 200 characters that names the flaw, or says why the change looks fine. explanation is under 400 characters.',
26}
27
28const NAMING_SYSTEM =
29 'You name areas of a codebase. Reply with only JSON that maps each area id to a name of one to three words, such as "Billing" or "Dog transfers". ' +
30 'Use the folder and file names to pick names a developer on this project would use.'
31
32const text = (value: unknown) => (typeof value === 'string' ? value : '')
33
34export function editTextsOf(tool: string, input: Record<string, unknown>): { oldText: string; newText: string } {
35 if (tool === 'Write') return { oldText: '', newText: text(input.content) }
36 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
37 return {
38 oldText: input.edits.map((e: any) => text(e?.old_string)).join('\n'),
39 newText: input.edits.map((e: any) => text(e?.new_string)).join('\n'),
40 }
41 }
42 return { oldText: text(input.old_string), newText: text(input.new_string) }
43}
44
45export const moreLines = (n: number) => `… ${n} more ${n === 1 ? 'line' : 'lines'}`
46export const MORE_LINES = /^… (\d+) more lines?$/
47
48// Claude carries unchanged lines around an edit so old_string is unique. Lines the old and new text share at
49// the start and end are context (' '), and only the lines between them are removed ('-') and added ('+').
50export function diffOf(edit: EditText, maxLines: number): string {
51 const before = edit.oldText ? edit.oldText.split('\n') : []
52 const after = edit.newText ? edit.newText.split('\n') : []
53 let head = 0
54 while (head < before.length && head < after.length && before[head] === after[head]) head++
55 let tail = 0
56 while (tail < before.length - head && tail < after.length - head && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail++
57 const lines = [
58 ...before.slice(0, head).map(l => ' ' + l),
59 ...before.slice(head, before.length - tail).map(l => '-' + l),
60 ...after.slice(head, after.length - tail).map(l => '+' + l),
61 ...after.slice(after.length - tail).map(l => ' ' + l),
62 ]
63 if (lines.length <= maxLines) return lines.join('\n')
64 return [...lines.slice(0, maxLines), moreLines(lines.length - maxLines)].join('\n')
65}
66
67export function buildCardRequest(areaName: string, level: Level, edit: EditText): { system: string; prompt: string } {
68 return {
69 system: SYSTEMS[level],
70 prompt: `Area: ${areaName}\nFile: ${edit.file}\nChange:\n${diffOf(edit, MAX_DIFF_LINES)}`,
71 }
72}
73
74// Areas made only of secret paths are left out entirely (they keep their default names), so neither
75// their folders nor their ids reach Haiku. ids lists the areas the prompt names.
76export function buildNamingRequest(drafts: AreaDraft[]): { system: string; prompt: string; ids: string[] } {
77 const named = drafts.filter(d => !(d.paths.every(isSecretPath) && d.files.every(f => isSecretPath(f.path))))
78 const lines = named.map(d => {
79 const folders = d.paths.filter(p => !isSecretPath(p)).map(p => p || '(repo root)').join(', ')
80 const files = sampleFiles(d.files.filter(f => !isSecretPath(f.path)), 5).map(f => f.path).join(', ')
81 return `${d.id}: folders ${folders}; files ${files}`
82 })
83 return { system: NAMING_SYSTEM, prompt: lines.join('\n'), ids: named.map(d => d.id) }
84}
85
86export function flagPromptText(card: FlawCard): string {
87 const concern = card.haikuSeesFlaw ? card.summary : 'something in this change looks wrong to them'
88 return `Apprentice flag on ${card.file}: the user reviewed this change and is concerned: "${concern}". Check whether it was intended, and fix it if not.`
89}
90src/views/card.ts 92 lines1import { MORE_LINES, moreLines } from '../cards/prompt.ts'
2import type { Card } from '../core/types.ts'
3import { line, type Elements } from './elements.ts'
4
5// maxRows and columns: the band's rows and width (AbovePrompt's e.props.maxRows and bodyColumns). A taller card
6// scrolls and loses its hotkeys, so a spot-the-flaw hunk is trimmed to fit.
7export type CardView = { card: Card; areaName: string; revealed: { isRight: boolean } | null; maxRows?: number; columns?: number }
8
9// Rows a flaw card takes besides its hunk: border (2), header, buttons, footer, the "more lines" marker, one spare.
10const FLAW_CARD_ROWS = 7
11// Columns around the code: the card's border and padding (4), and the code block's own inset (2).
12const CODE_INSET = 6
13
14// Rows one diff line takes once it wraps; with no width known, each line is one row.
15function rowsOf(text: string, width: number | undefined): number {
16 return width === undefined ? 1 : Math.max(1, Math.ceil(text.length / width))
17}
18
19export function fitHunk(hunk: string, maxRows: number | undefined, columns?: number): string {
20 if (maxRows === undefined) return hunk
21 const width = columns === undefined ? undefined : Math.max(10, columns - CODE_INSET)
22 const lines = hunk.split('\n')
23 const marker = MORE_LINES.exec(lines.at(-1) ?? '')
24 const body = marker ? lines.slice(0, -1) : lines
25 const total = body.length + (marker ? Number(marker[1]) : 0)
26 const room = Math.max(3, maxRows - FLAW_CARD_ROWS)
27 let used = 0
28 let kept = 0
29 while (kept < body.length && used + rowsOf(body[kept], width) <= room) used += rowsOf(body[kept++], width)
30 if (kept === body.length) return hunk
31 // Always show at least one line, even when it wraps past the room.
32 kept = Math.max(1, kept)
33 return [...body.slice(0, kept), moreLines(total - kept)].join('\n')
34}
35export type CardHandlers = {
36 onAnswer: (index: number) => void
37 onFlaw: (pressed: 'flag' | 'looks-fine') => void
38 onSkip: () => void
39 onQuiet: () => void
40}
41
42function header(card: Card, areaName: string): string {
43 const base = `Apprentice · ${areaName} · ${card.level} card`
44 return card.kind === 'flaw' ? base + ' · spot the flaw' : base
45}
46
47function resultLine(card: Card, isRight: boolean): string {
48 if (isRight) return 'Right.'
49 if (card.kind === 'question') return `Not quite. The answer was ${card.answer + 1} · ${card.options[card.answer]}.`
50 return card.haikuSeesFlaw ? 'Not quite. Haiku sees a flaw here.' : 'Not quite. Haiku thinks this change looks fine.'
51}
52
53function footer(E: Elements, h: CardHandlers): unknown {
54 return E.Box({
55 flexDirection: 'row', columnGap: 2,
56 children: [
57 E.Button({ key: 'skip', label: 'Skip', plain: true, dimColor: true, onPress: h.onSkip }),
58 E.Button({ key: 'quiet', label: 'Quiet 1h', plain: true, dimColor: true, onPress: h.onQuiet }),
59 ],
60 })
61}
62
63export function renderCard(E: Elements, view: CardView, h: CardHandlers): unknown {
64 const { card, areaName, revealed, maxRows, columns } = view
65 const children: unknown[] = [line(E, header(card, areaName), { dimColor: true })]
66 if (revealed) {
67 children.push(line(E, resultLine(card, revealed.isRight), { bold: true, color: revealed.isRight ? 'green' : 'yellow' }))
68 if (card.kind === 'flaw') children.push(line(E, card.summary))
69 children.push(line(E, card.explanation))
70 children.push(line(E, "Haiku's view, not a verdict on the code.", { dimColor: true }))
71 } else if (card.kind === 'question') {
72 children.push(line(E, card.question))
73 children.push(E.Box({
74 flexDirection: 'row', columnGap: 2,
75 children: card.options.map((option, i) =>
76 E.Button({ key: `ans-${i}`, label: `${i + 1} · ${option}`, hotkey: String(i + 1), onPress: () => h.onAnswer(i) })),
77 }))
78 children.push(footer(E, h))
79 } else {
80 children.push(E.Code({ key: 'hunk', source: fitHunk(card.hunk, maxRows, columns), path: card.file, format: 'diff' }))
81 children.push(E.Box({
82 flexDirection: 'row', columnGap: 2,
83 children: [
84 E.Button({ key: 'flaw-ok', label: '1 · Looks fine', hotkey: '1', onPress: () => h.onFlaw('looks-fine') }),
85 E.Button({ key: 'flaw-flag', label: '2 · Flag to Claude', hotkey: '2', onPress: () => h.onFlaw('flag') }),
86 ],
87 }))
88 children.push(footer(E, h))
89 }
90 return E.Box({ flexDirection: 'column', borderStyle: 'round', paddingX: 1, children })
91}
92src/views/wrapped.ts 32 lines1import type { Wrapped } from '../core/wrapped.ts'
2import { line, type Elements } from './elements.ts'
3
4function tile(E: Elements, label: string, value: number): unknown {
5 return E.Box({
6 flexDirection: 'column', borderStyle: 'round', paddingX: 1,
7 children: [line(E, label, { dimColor: true }), line(E, String(value), { bold: true })],
8 })
9}
10
11export function renderWrapped(E: Elements, w: Wrapped, repoName: string, week: string, h: { onCopy: () => void }): unknown {
12 const children: unknown[] = [line(E, `Your week in ${repoName}`, { bold: true }), line(E, week, { dimColor: true })]
13 if (w.answered === 0) {
14 children.push(line(E, 'No cards answered this week yet.'))
15 return E.Box({ flexDirection: 'column', children })
16 }
17 children.push(E.Box({
18 flexDirection: 'row', columnGap: 2,
19 children: [
20 tile(E, 'Areas mastered', w.mastered),
21 tile(E, 'Flaws you caught', w.flawsCaught),
22 tile(E, 'Cards answered', w.answered),
23 tile(E, 'Dark zones left', w.darkZones),
24 ],
25 }))
26 if (w.biggestJump) {
27 children.push(line(E, `Biggest jump: ${w.biggestJump.name}, ${w.biggestJump.from}% to ${w.biggestJump.to}%`))
28 }
29 children.push(E.Button({ key: 'copy', label: 'Copy', onPress: h.onCopy }))
30 return E.Box({ flexDirection: 'column', children })
31}
32