SLOPSHOPPER

apprentice

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

newpanebandguardcommandtoast
v0.1.1Apache-2.0updated 2026-10-23isAlmogK/apprentice-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · apprentice
│ ┃ Apprentice ✕ › fix the failing auth test and add an audit log call │ ┃ Mapping your repo… │ ⏺ 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 │ │ › /apprentice │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Apprentice
Mapping your repo…
README

🎓 apprentice

Learn the code Claude is changing, while it changes it.

Claude changed billing/ 14 times this week. Do you still understand it?

License: Apache-2.0 Claude Code Status Surfaces Sponsor

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 validate passes. 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.

Why I'm building this

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 changedShows what you still understandTeaches 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.

How it works

Claude edits a file ─▶ its area dims ─▶ Haiku writes one card ─▶ you answer ─▶ the area relights
What it doesUses Haiku
🗺️ MapGroups 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
🌘 DimEvery Claude edit (Edit, Write, MultiEdit) lowers its area's score by the share of the area's lines it changedNo
🃏 CardAfter 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 diffYes, at most 6 an hour
💡 RelightA 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 downNo

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.

Install

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

What you see

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.

Commands

CommandDoes
/apprenticeOpens the map (maps the repo first if needed)
/apprentice wrappedOpens this week's Codebase Wrapped
/apprentice quiet [minutes]Pauses cards, 60 minutes by default. Dimming continues
/apprentice remapRegroups 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.

Privacy

Everything stays on your machine except what Apprentice asks Haiku, through Claude Code.

Goes to Haiku:

  • For each card: one edit's diff (at most 120 lines), the file's path and the area's name.
  • Once per map: the area folder paths and up to five file names per area, to name the areas, with secret-looking paths left out.

Never goes to Haiku:

  • Paths that look secret (.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.
  • Files git ignores. If git can't tell, the file is treated as ignored.
  • File contents beyond the edit's diff.

Scores, answers and skipped cards are stored locally in Claude Code's mod store. At most 6 card requests go to Haiku an hour.

Requirements

  • Claude Code v2.1.296 or later.
  • The Claude Code Desktop app or the terminal. The VS Code extension isn't supported in v1, because it doesn't draw the band above the prompt.
  • A git repo.

Good to know:

  • In the Desktop app, /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.
  • replay-theater draws its Replay row in the band without leaving room for other mods, so with it installed, Apprentice's card disappears when a turn with edits ends. Turn replay-theater off with claude plugin disable replay-theater@claude-code-playground-mods to keep cards on screen until you answer them.

FAQ

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.

Roadmap

PhaseScopeState
v1Knowledge map (Desktop and terminal), question and spot-the-flaw cards, Flag to Claude, Codebase Wrapped, rename and merge areas✅ Built
LaunchHand check in a live session ✅, listing on claude-mods.com, first release🚧 In progress
LaterLines between areas that change together, VS Code support, cards for edits made by people, a team view for onboardingPlanned

Docs

  • 📘 How it works today: areas, scoring, cards, storage and surfaces as the code stands
  • 🧭 Design spec: the why, the user flow and the decisions, with an "As built" section
  • 🗂️ Implementation plan: the task-by-task plan it was built from

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.

Contributing

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/.

Sponsor

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.

About me

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.

License

Apache-2.0


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.

Source 17 files
hooks/register.ts 517 lines
1import { 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}
517
src/core/areas.ts 208 lines
1import 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}
208
src/core/git.ts 76 lines
1import 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}
76
src/core/types.ts 52 lines
1export 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}
52
src/core/understanding.ts 76 lines
1import 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}
76
src/core/wrapped.ts 60 lines
1import 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}
60
src/cards/exclude.ts 14 lines
1const 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}
14
src/cards/policy.ts 7 lines
1export 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}
7
src/cards/parse.ts 53 lines
1import 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}
53
src/cards/prompt.ts 90 lines
1import 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}
90
src/views/card.ts 92 lines
1import { 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}
92
src/views/wrapped.ts 32 lines
1import 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