Quizzes you on your own commits so you learn from what gets built.

hooks/register.ts 187 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { detectCommits, parseCommit } from './detect.ts'
4import type { DetectInput } from './detect.ts'
5import { checkDataDir, resolveDataDir } from './paths.ts'
6import type { DataDirVerdict } from './paths.ts'
7import { loadQueue, statusText } from './queue.ts'
8import type { QueueIO } from './queue.ts'
9import { readSettings } from './settings.ts'
10import type { Settings } from './settings.ts'
11import { sweepRepos } from './sweep.ts'
12
13// Hard rule (AGENTS): no hook throws, blocks or noticeably slows a command.
14// A commit command costs two in-process calls before it runs; everything else
15// happens on a timer after its result has gone back.
16//
17// The engine requires every function `$` is passed to be declared at the top
18// of this file, so the per-load state travels in `State`.
19
20type State = {
21 settings: Settings
22 trackedCwd: string | undefined
23 detections: Promise<void> // one at a time: no in-process races on the queue file
24 dataDirVerdict: DataDirVerdict | undefined
25 shownOnce: Set<string>
26}
27
28const oneLine = (error: unknown): string => String(error).replace(/\s+/g, ' ').trim()
29
30function noticeOnce($: EngineInterface, state: State, kind: string, text: string) {
31 if (state.shownOnce.has(kind)) return
32 state.shownOnce.add(kind)
33 $.ui.log(text)
34}
35
36function debug($: EngineInterface, text: string) {
37 $.ui.log(`after-build-quiz: ${text}`, { to: 'debug' })
38}
39
40function ioOf($: EngineInterface): QueueIO {
41 return {
42 read: async path => {
43 const text = await $.fs.read(path)
44 if (typeof text !== 'string') throw new Error(`${path} did not read as text`)
45 return text
46 },
47 write: (path, text) => $.fs.write(path, text),
48 exists: path => $.fs.exists(path),
49 }
50}
51
52async function dataDirOf($: EngineInterface, state: State): Promise<string> {
53 return resolveDataDir(state.settings.dataDir, {
54 USERPROFILE: await $.env.get('USERPROFILE'),
55 HOME: await $.env.get('HOME'),
56 })
57}
58
59async function isoNow($: EngineInterface): Promise<string> {
60 return new Date(await $.clock.now()).toISOString()
61}
62
63// The data folder, once it is known to be outside any repo; undefined means
64// do not write (told once if it is inside a repo, retried if it could not be checked).
65async function safeDataDir($: EngineInterface, state: State): Promise<string | undefined> {
66 const dataDir = await dataDirOf($, state)
67 const verdict = state.dataDirVerdict ?? await checkDataDir(dataDir, {
68 run: (argv, init) => $.process.run(argv, init),
69 exists: path => $.fs.exists(path),
70 })
71 if (!verdict.isSafe) {
72 if (!verdict.isInsideRepo) {
73 debug($, `data folder not checked: ${verdict.reason}`) // try again next time
74 return undefined
75 }
76 state.dataDirVerdict = verdict
77 noticeOnce($, state, 'dataDir', `after-build-quiz: ${dataDir} is inside a git repo; quizzes are not saved there. Set dataDir to a folder outside any repo.`)
78 return undefined
79 }
80 state.dataDirVerdict = verdict
81 return dataDir
82}
83
84async function detectAfter($: EngineInterface, state: State, input: Omit<DetectInput, 'now'>) {
85 try {
86 const dataDir = await safeDataDir($, state)
87 if (dataDir === undefined) return
88
89 const outcome = await detectCommits({
90 run: (argv, init) => $.process.run(argv, init),
91 io: ioOf($),
92 dataDir,
93 isWindows: /^[A-Za-z]:[\\/]/.test(input.sessionCwd),
94 minChangedLines: state.settings.minChangedLines,
95 queueCap: state.settings.queueCap,
96 }, { ...input, now: await isoNow($) })
97 if (outcome.notice !== undefined) noticeOnce($, state, 'queue', outcome.notice)
98 if (outcome.queue !== undefined) $.ui.status(statusText(outcome.queue))
99 } catch (error) {
100 debug($, `commit detection failed: ${oneLine(error)}`)
101 }
102}
103
104async function sweepAfter($: EngineInterface, state: State, sessionCwd: string) {
105 try {
106 const dataDir = await safeDataDir($, state)
107 if (dataDir === undefined) return
108
109 const outcome = await sweepRepos({
110 run: (argv, init) => $.process.run(argv, init),
111 io: ioOf($),
112 exists: path => $.fs.exists(path),
113 dataDir,
114 isWindows: /^[A-Za-z]:[\\/]/.test(sessionCwd),
115 minChangedLines: state.settings.minChangedLines,
116 queueCap: state.settings.queueCap,
117 }, { sessionCwd, now: await isoNow($) })
118 for (const error of outcome.errors) debug($, `sweep: ${error}`)
119 if (outcome.notice !== undefined) noticeOnce($, state, 'queue', outcome.notice)
120 if (outcome.queue !== undefined) $.ui.status(statusText(outcome.queue))
121 } catch (error) {
122 debug($, `sweep failed: ${oneLine(error)}`)
123 }
124}
125
126async function watch<R>($: EngineInterface, state: State, command: string, isBackground: boolean, run: () => Promise<R>): Promise<R> {
127 const parsed = isBackground ? undefined : parseCommit(command)
128 if (parsed === undefined) return run()
129 const before = {
130 command: parsed,
131 trackedCwd: state.trackedCwd,
132 sessionCwd: await $.session.cwd(),
133 startedAt: await $.clock.now(),
134 }
135 const ran = await run()
136 $.clock.after(0, () => {
137 state.detections = state.detections.then(() => detectAfter($, state, before)).catch(() => {})
138 })
139 return ran
140}
141
142// The status line reads the queue file only: no git, no model, no writes.
143async function showStatus($: EngineInterface, state: State) {
144 try {
145 const loaded = await loadQueue(ioOf($), await dataDirOf($, state), await isoNow($), { isReadOnly: true })
146 if (loaded.notice !== undefined) noticeOnce($, state, 'queue', loaded.notice)
147 $.ui.status(statusText(loaded.queue))
148 } catch (error) {
149 debug($, `status line not set: ${oneLine(error)}`)
150 }
151}
152
153export const register: Register = (on, options) => {
154 const state: State = {
155 settings: readSettings(options),
156 trackedCwd: undefined,
157 detections: Promise.resolve(),
158 dataDirVerdict: undefined,
159 shownOnce: new Set(),
160 }
161
162 on('tool.call', { tool: 'Bash' }, ($, e, next) =>
163 watch($, state, e.command, e.run_in_background === true, () => next(e)),
164 ).catch(($, e, next) => next(e))
165
166 on('tool.call', { tool: 'PowerShell' }, ($, e, next) =>
167 watch($, state, e.command, e.run_in_background === true, () => next(e)),
168 ).catch(($, e, next) => next(e))
169
170 on('classic.CwdChanged', ($, e, next) => {
171 state.trackedCwd = e.new_cwd
172 return next(e)
173 }).catch(($, e, next) => next(e))
174
175 // The status from the queue file first; the catch-up sweep after, on a timer,
176 // in the same one-at-a-time line as commit detections.
177 on('session.start', async ($, e, next) => {
178 const started = await next(e)
179 await showStatus($, state)
180 const sessionCwd = e.cwd
181 $.clock.after(0, () => {
182 state.detections = state.detections.then(() => sweepAfter($, state, sessionCwd)).catch(() => {})
183 })
184 return started
185 }).catch(($, e, next) => next(e))
186}
187hooks/detect.ts 298 lines1import { git } from './git.ts'
2import type { Run } from './git.ts'
3import { isAbsolute, normalizeDir } from './paths.ts'
4import { COMMIT_FORMAT, enqueue, loadQueue, parseCommitLine, saveQueue } from './queue.ts'
5import type { Queue, QueueEntry, QueueIO } from './queue.ts'
6import { classify } from './trivial.ts'
7
8// Spots a `git commit` inside a Bash or PowerShell command line. A false match
9// costs nothing (detection only queues a HEAD that actually moved), so when in
10// doubt this leans towards matching.
11
12export type ParsedCommit = {
13 isAmend: boolean
14 dashC?: string // the `git -C <path>` of the commit, as typed (normalizeDir before use)
15 cdTarget?: string // the last cd / Set-Location before the commit, as typed
16}
17
18const HERE_STRING = /@(['"])[\s\S]*?\r?\n\1@/g // PowerShell @' … '@ and @" … "@
19const HEREDOC = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1[\s\S]*?\r?\n\s*\2(?=\s|\)|$)/g // bash <<'EOF' … EOF
20
21const CD = new Set(['cd', 'chdir', 'pushd', 'set-location', 'sl', 'push-location'])
22const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
23const COMMIT_OPTIONS_WITH_VALUE = new Set([
24 '-m', '--message', '-F', '--file', '-C', '--reuse-message', '-c', '--reedit-message',
25 '--author', '--date', '-t', '--template', '--fixup', '--squash', '--cleanup', '--trailer',
26])
27
28// Splits into segments at unquoted &&, ||, ;, |, &, newlines, braces and
29// parentheses; each segment is its list of unquoted words.
30const segmentsOf = (cmd: string): string[][] => {
31 const segments: string[][] = []
32 let words: string[] = []
33 let word: string | undefined
34 let quote: '"' | "'" | undefined
35
36 const endWord = () => {
37 if (word !== undefined) words.push(word)
38 word = undefined
39 }
40 const endSegment = () => {
41 endWord()
42 if (words.length > 0) segments.push(words)
43 words = []
44 }
45
46 for (let i = 0; i < cmd.length; i++) {
47 const char = cmd[i]!
48 if (quote !== undefined) {
49 if (char === quote) quote = undefined
50 else if (quote === '"' && char === '\\' && cmd[i + 1] === '"') word = (word ?? '') + cmd[++i]
51 else word = (word ?? '') + char
52 continue
53 }
54 if (char === '"' || char === "'") {
55 quote = char
56 word = word ?? ''
57 } else if ('&|;\n(){}'.includes(char)) {
58 endSegment()
59 } else if (/\s/.test(char)) {
60 endWord()
61 } else {
62 word = (word ?? '') + char
63 }
64 }
65 endSegment()
66 return segments
67}
68
69const commitIn = (words: readonly string[]): Omit<ParsedCommit, 'cdTarget'> | undefined => {
70 let i = 0
71 while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++
72 if (!/^git(\.exe)?$/i.test(words[i] ?? '')) return undefined
73 i++
74
75 let dashC: string | undefined
76 while ((words[i] ?? '').startsWith('-')) {
77 const option = words[i]!
78 if (GIT_OPTIONS_WITH_VALUE.has(option)) {
79 if (option === '-C') dashC = words[i + 1]
80 i += 2
81 } else {
82 i++
83 }
84 }
85 if (words[i] !== 'commit') return undefined
86
87 let isAmend = false
88 for (let j = i + 1; j < words.length; j++) {
89 if (COMMIT_OPTIONS_WITH_VALUE.has(words[j]!)) j++
90 else if (words[j] === '--amend') isAmend = true
91 }
92 return dashC === undefined ? { isAmend } : { isAmend, dashC }
93}
94
95const cdTargetIn = (words: readonly string[]): string | undefined => {
96 if (!CD.has((words[0] ?? '').toLowerCase())) return undefined
97 const target = words.slice(1).find(word => !word.startsWith('-'))
98 return target
99}
100
101export const parseCommit = (cmd: string): ParsedCommit | undefined => {
102 const flat = cmd.replace(HERE_STRING, ' HERE ').replace(HEREDOC, ' HERE ')
103 let cdTarget: string | undefined
104 for (const words of segmentsOf(flat)) {
105 const commit = commitIn(words)
106 if (commit !== undefined) return cdTarget === undefined ? commit : { ...commit, cdTarget }
107 cdTarget = cdTargetIn(words) ?? cdTarget
108 }
109 return undefined
110}
111
112const resolveAgainst = (path: string, base: string): string => {
113 if (isAbsolute(path)) return path
114 const relative = path.replace(/^\.\//, '')
115 return relative === '.' || relative === '' ? base : `${base}/${relative}`
116}
117
118// Where to look for the repo the commit landed in, most likely first.
119export const repoCandidates = (
120 parsed: ParsedCommit,
121 trackedCwd: string | undefined,
122 sessionCwd: string,
123): string[] => {
124 const start = trackedCwd ?? sessionCwd
125 const cd = parsed.cdTarget === undefined ? undefined : resolveAgainst(parsed.cdTarget, start)
126 const dashC = parsed.dashC === undefined ? undefined : resolveAgainst(parsed.dashC, cd ?? start)
127 const ordered = [dashC, cd, trackedCwd, sessionCwd].filter((dir): dir is string => dir !== undefined)
128 return [...new Set(ordered)]
129}
130
131
132export type DetectDeps = {
133 run: Run
134 io: QueueIO
135 dataDir: string
136 isWindows: boolean
137 minChangedLines: number
138 queueCap: number
139}
140
141export type DetectInput = {
142 command: ParsedCommit
143 trackedCwd: string | undefined // from CwdChanged, as it was before the command ran
144 sessionCwd: string // $.session.cwd(), as it was before the command ran
145 startedAt: number // epoch ms, before the command ran
146 now: string // ISO, for queuedAt
147}
148
149export type DetectOutcome = { queued: number; queue?: Queue; notice?: string }
150
151export type Head = { top: string; sha: string; branch: string } // branch is "HEAD" when detached
152
153// The first candidate git recognises as a repo, with its HEAD and branch, in
154// one call. undefined when none is a repo, or the first repo found has no
155// commits yet (unborn HEAD).
156export const findHead = async (run: Run, candidates: readonly string[], isWindows: boolean): Promise<Head | undefined> => {
157 for (const dir of candidates) {
158 const found = await git(run, dir, ['rev-parse', '--show-toplevel', 'HEAD', '--abbrev-ref', 'HEAD'])
159 const [top, sha, branch] = found.stdout.split(/\r?\n/).filter(line => line !== '')
160 if (found.exitCode === 0 && top !== undefined && sha !== undefined && branch !== undefined) {
161 return { top: normalizeDir(top, isWindows) ?? top, sha, branch }
162 }
163 if (top !== undefined) return undefined
164 }
165 return undefined
166}
167
168// lastSeen key: one per repo and branch.
169export const sightingKey = (head: Head): string => `${head.top}::${head.branch}`
170
171// Only my commits, matched exactly: -F with the angle brackets of the ident.
172// No user.email configured: no filter.
173export const authorArgs = async (run: Run, top: string): Promise<string[]> => {
174 const email = await git(run, top, ['config', '--get', 'user.email'])
175 const me = email.exitCode === 0 ? email.stdout.trim() : ''
176 return me === '' ? [] : ['-F', `--author=<${me}>`]
177}
178
179export const logMine = (run: Run, top: string, cap: number, author: readonly string[], range: readonly string[]) =>
180 git(run, top, ['log', '--no-merges', `--format=${COMMIT_FORMAT}`, '-n', String(cap), ...author, ...range])
181
182// git lists newest first; we queue oldest first.
183export const commitsIn = (logStdout: string): ParsedLogCommit[] =>
184 logStdout.split(/\r?\n/).flatMap(line => parseCommitLine(line) ?? []).reverse()
185
186type ParsedLogCommit = NonNullable<ReturnType<typeof parseCommitLine>>
187
188const repoName = (top: string): string => top.split('/').filter(part => part !== '').at(-1) ?? top
189
190// show → classify → enqueue, for each commit worth a quiz.
191export const queueWorthy = async (
192 run: Run,
193 top: string,
194 queue: Queue,
195 commits: readonly ParsedLogCommit[],
196 options: {
197 source: QueueEntry['source']
198 now: string
199 minChangedLines: number
200 queueCap: number
201 isAmend: (made: ParsedLogCommit) => boolean
202 },
203): Promise<{ queue: Queue; queued: number }> => {
204 let next = queue
205 let queued = 0
206 for (const made of commits) {
207 const shown = await git(run, top, ['show', '-w', '--numstat', '--format=', made.hash])
208 if (shown.exitCode !== 0) continue
209 const verdict = classify(shown.stdout, made.parents.length, options.minChangedLines)
210 if (verdict.kind !== 'queue') continue
211 next = enqueue(next, {
212 repo: top,
213 repoName: repoName(top),
214 hash: made.hash,
215 parent: made.parents[0] ?? null,
216 subject: made.subject,
217 committedAt: made.committedAt,
218 queuedAt: options.now,
219 source: options.source,
220 codeLines: verdict.codeLines,
221 codeFiles: verdict.codeFiles,
222 }, { isAmend: options.isAmend(made), cap: options.queueCap, now: options.now })
223 queued++
224 }
225 return { queue: next, queued }
226}
227
228// Record HEAD as seen for this repo and branch, and the repo as active now.
229export const markSeen = (queue: Queue, head: Head, now: string): Queue => ({
230 ...queue,
231 lastSeen: { ...queue.lastSeen, [sightingKey(head)]: head.sha },
232 seenAt: { ...queue.seenAt, [head.top]: now },
233})
234
235const sinceArg = (startedAt: number): string => {
236 const second = Math.floor(startedAt / 1000) * 1000 - 1000 // committer dates have whole seconds
237 return `--since=${new Date(second).toISOString().replace('.000Z', 'Z')}`
238}
239
240const detect = async (deps: DetectDeps, input: DetectInput): Promise<DetectOutcome> => {
241 const { run, io, dataDir, isWindows } = deps
242 const { command } = input
243
244 // A directory the command names must be recognised, or we skip: never guess.
245 const dashC = command.dashC === undefined ? undefined : normalizeDir(command.dashC, isWindows)
246 const cdTarget = command.cdTarget === undefined ? undefined : normalizeDir(command.cdTarget, isWindows)
247 if ((command.dashC !== undefined && dashC === undefined) || (command.cdTarget !== undefined && cdTarget === undefined)) {
248 return { queued: 0 }
249 }
250 const absolute = (dir: string | undefined) => (dir !== undefined && isAbsolute(dir) ? dir : undefined)
251 const tracked = absolute(input.trackedCwd === undefined ? undefined : normalizeDir(input.trackedCwd, isWindows))
252 const session = absolute(normalizeDir(input.sessionCwd, isWindows))
253 const start = tracked ?? session
254 const isRelativeNamed = [dashC, cdTarget].some(dir => dir !== undefined && !isAbsolute(dir))
255 if (isRelativeNamed && start === undefined) return { queued: 0 }
256
257 const candidates = repoCandidates({ ...command, dashC, cdTarget }, tracked, start ?? '').filter(isAbsolute)
258 const head = await findHead(run, candidates, isWindows)
259 if (head === undefined) return { queued: 0 }
260
261 const loaded = await loadQueue(io, dataDir, input.now)
262 const withNotice = (outcome: DetectOutcome): DetectOutcome =>
263 loaded.notice === undefined ? outcome : { ...outcome, notice: loaded.notice }
264 if (!loaded.isWritable) return withNotice({ queued: 0 })
265
266 const lastSeen = loaded.queue.lastSeen[sightingKey(head)]
267 if (lastSeen === head.sha) return withNotice({ queued: 0 }) // HEAD did not move: nothing was committed
268
269 // lastSeen..HEAD for a repo and branch seen before; otherwise (first sight,
270 // or lastSeen pruned) only commits made since the command started.
271 const author = await authorArgs(run, head.top)
272 let log = lastSeen === undefined ? undefined : await logMine(run, head.top, deps.queueCap, author, [`${lastSeen}..HEAD`])
273 if (log === undefined || log.exitCode !== 0) {
274 log = await logMine(run, head.top, deps.queueCap, author, [sinceArg(input.startedAt), 'HEAD'])
275 }
276 if (log.exitCode !== 0) return withNotice({ queued: 0 })
277
278 const { queue, queued } = await queueWorthy(run, head.top, loaded.queue, commitsIn(log.stdout), {
279 source: 'claude-code',
280 now: input.now,
281 minChangedLines: deps.minChangedLines,
282 queueCap: deps.queueCap,
283 isAmend: () => command.isAmend,
284 })
285 const saved = await saveQueue(io, dataDir, markSeen(queue, head, input.now))
286 return withNotice(saved.isSaved ? { queued, queue: saved.queue } : { queued })
287}
288
289// After a commit command ran: did HEAD move, and which new commits are worth a
290// quiz? Never throws; any failure means nothing is queued.
291export const detectCommits = async (deps: DetectDeps, input: DetectInput): Promise<DetectOutcome> => {
292 try {
293 return await detect(deps, input)
294 } catch {
295 return { queued: 0 }
296 }
297}
298hooks/paths.ts 99 lines1import { git } from './git.ts'
2import type { Run } from './git.ts'
3
4// Injected so tests can fake it; the plugin passes $.fs.exists.
5export type Exists = (path: string) => Promise<boolean>
6
7export type HomeEnv = { USERPROFILE?: string; HOME?: string }
8
9// isInsideRepo tells a real refusal (tell the user once) from "could not check" (try again later).
10export type DataDirVerdict = { isSafe: true } | { isSafe: false; isInsideRepo: boolean; reason: string }
11
12const FOLDER = '.after-build-quiz'
13
14export const toForwardSlashes = (path: string): string => {
15 const slashed = path.replace(/\\/g, '/')
16 return /^[A-Za-z]:\/$/.test(slashed) || slashed === '/' ? slashed : slashed.replace(/\/+$/, '')
17}
18
19export const isAbsolute = (path: string): boolean => /^[A-Za-z]:\//.test(path) || path.startsWith('/')
20
21export const resolveDataDir = (setting: string, env: HomeEnv): string => {
22 if (setting !== '') {
23 const dir = toForwardSlashes(setting)
24 if (!isAbsolute(dir)) {
25 throw new Error(`after-build-quiz: dataDir must be an absolute path, got "${setting}"`)
26 }
27 return dir
28 }
29 const home = env.USERPROFILE || env.HOME
30 if (!home) throw new Error('after-build-quiz: neither USERPROFILE nor HOME is set; set dataDir')
31 return `${toForwardSlashes(home)}/${FOLDER}`
32}
33
34const parentOf = (path: string): string | undefined => {
35 if (/^[A-Za-z]:\/$/.test(path) || path === '/') return undefined
36 const cut = path.lastIndexOf('/')
37 if (cut < 0) return undefined
38 const parent = path.slice(0, cut)
39 if (parent === '') return '/'
40 return /^[A-Za-z]:$/.test(parent) ? `${parent}/` : parent
41}
42
43const nearestExisting = async (dir: string, exists: Exists): Promise<string | undefined> => {
44 for (let at: string | undefined = dir; at !== undefined; at = parentOf(at)) {
45 if (await exists(at)) return at
46 }
47 return undefined
48}
49
50// Quiz data must never land in a repo: refuse when dataDir (or, before it
51// exists, its nearest existing parent) is inside a git work tree.
52export const checkDataDir = async (
53 dataDir: string,
54 io: { run: Run; exists: Exists },
55): Promise<DataDirVerdict> => {
56 const probe = await nearestExisting(dataDir, io.exists)
57 if (probe === undefined) {
58 return { isSafe: false, isInsideRepo: false, reason: `no existing folder on the path to ${dataDir}` }
59 }
60 try {
61 const result = await git(io.run, probe, ['rev-parse', '--is-inside-work-tree'])
62 if (result.exitCode === 0 && result.stdout.trim() === 'true') {
63 return { isSafe: false, isInsideRepo: true, reason: `${dataDir} is inside a git work tree` }
64 }
65 return { isSafe: true }
66 } catch (error) {
67 return { isSafe: false, isInsideRepo: false, reason: `could not run git to check ${dataDir}: ${String(error)}` }
68 }
69}
70
71const driveRoot = (letter: string, rest: string): string =>
72 `${letter.toUpperCase()}:/${rest.replace(/\/+$/, '')}`
73
74// A directory as git -C needs it: `C:/x` on Windows (from `C:\x`, `c:/x`, Git
75// Bash `/c/x`, `/cygdrive/c/x`), `/x` elsewhere; relative paths stay relative,
76// slashes forward. Anything else (UNC, `\x`, `~`, `$VAR`, `%VAR%`, `C:`, `C:x`,
77// other POSIX roots on Windows) is undefined: the caller skips, never guesses.
78export const normalizeDir = (path: string, isWindows: boolean): string | undefined => {
79 const raw = path.trim()
80 if (raw === '' || raw.startsWith('\\') || raw.startsWith('~') || /[$%]/.test(raw)) return undefined
81 const slashed = raw.replace(/\\/g, '/')
82 if (slashed.startsWith('//')) return undefined
83
84 const drive = /^([A-Za-z]):(.*)$/.exec(slashed)
85 if (drive !== null) {
86 if (!isWindows || !drive[2]!.startsWith('/')) return undefined
87 return driveRoot(drive[1]!, drive[2]!.slice(1))
88 }
89
90 if (slashed.startsWith('/')) {
91 if (!isWindows) return slashed === '/' ? '/' : slashed.replace(/\/+$/, '')
92 const mounted = /^\/cygdrive\/([A-Za-z])(?:\/(.*))?$/.exec(slashed) ?? /^\/([A-Za-z])(?:\/(.*))?$/.exec(slashed)
93 return mounted === null ? undefined : driveRoot(mounted[1]!, mounted[2] ?? '')
94 }
95
96 return slashed.replace(/^(\.\/)+/, '').replace(/\/+$/, '') || '.'
97}
98
99hooks/queue.ts 274 lines1import { git } from './git.ts'
2import type { Run } from './git.ts'
3import type { Exists } from './paths.ts'
4
5// The pending-quiz queue: pure transforms plus crash-safe load/save over an
6// injected fs (the plugin passes $.fs). See SPEC behaviour 1–3 and "Storage".
7// Nothing here throws into the user's workflow.
8
9export type QueueEntry = {
10 repo: string
11 repoName: string
12 hash: string
13 parent: string | null // first parent; an amend keeps the parent of the commit it replaces
14 subject: string
15 committedAt: string // committer date (%cI): what "newest" and "oldest" mean
16 queuedAt: string
17 source: 'claude-code' | 'sweep'
18 codeLines: number
19 codeFiles: number
20}
21
22export type ExpiredEntry = {
23 repo: string
24 repoName: string
25 hash: string
26 subject: string
27 reason: 'cap' | 'missing'
28 detail?: string
29 expiredAt: string
30}
31
32export type Queue = {
33 version: 1
34 seq: number // incremented on every save; on load the higher seq wins
35 pending: QueueEntry[] // sorted oldest → newest by committedAt
36 expired: ExpiredEntry[] // the last EXPIRED_KEPT
37 expiredCount: number
38 lastSeen: Record<string, string> // `<repo root>::<branch>` → last commit seen
39 seenAt: Record<string, string> // repo root → last activity (ISO); ranks repos for the sweep
40}
41
42export type QueueIO = {
43 read: (path: string) => Promise<string>
44 write: (path: string, text: string) => Promise<void>
45 exists: (path: string) => Promise<boolean>
46}
47
48export type ParsedCommit = {
49 hash: string
50 parents: string[]
51 authoredAt: string
52 committedAt: string
53 subject: string
54}
55
56const EXPIRED_KEPT = 50
57
58export const emptyQueue = (): Queue => ({
59 version: 1, seq: 0, pending: [], expired: [], expiredCount: 0, lastSeen: {}, seenAt: {},
60})
61
62// `git log -1 --format=<this>`: hash, parents, author date, committer date, subject.
63export const COMMIT_FORMAT = '%H%x1f%P%x1f%aI%x1f%cI%x1f%s'
64
65export const parseCommitLine = (stdout: string): ParsedCommit | undefined => {
66 const [hash, parents, authoredAt, committedAt, ...subject] = (stdout.split(/\r?\n/)[0] ?? '').split('\x1f')
67 if (hash === undefined || !/^[0-9a-f]{40,64}$/.test(hash) || committedAt === undefined || authoredAt === undefined) {
68 return undefined
69 }
70 return {
71 hash,
72 parents: (parents ?? '').split(' ').filter(parent => parent !== ''),
73 authoredAt,
74 committedAt,
75 subject: subject.join('\x1f'),
76 }
77}
78
79const timeOf = (when: string): number => {
80 const time = Date.parse(when)
81 return Number.isNaN(time) ? 0 : time
82}
83
84const byCommitTime = (a: QueueEntry, b: QueueEntry): number =>
85 timeOf(a.committedAt) - timeOf(b.committedAt) || timeOf(a.queuedAt) - timeOf(b.queuedAt)
86
87const expire = (
88 queue: Queue,
89 entry: QueueEntry,
90 reason: ExpiredEntry['reason'],
91 now: string,
92 detail?: string,
93): Queue => {
94 const record: ExpiredEntry = {
95 repo: entry.repo, repoName: entry.repoName, hash: entry.hash, subject: entry.subject, reason,
96 ...(detail === undefined ? {} : { detail }),
97 expiredAt: now,
98 }
99 return {
100 ...queue,
101 pending: queue.pending.filter(pending => pending.hash !== entry.hash),
102 expired: [...queue.expired, record].slice(-EXPIRED_KEPT),
103 expiredCount: queue.expiredCount + 1,
104 }
105}
106
107export const enqueue = (
108 queue: Queue,
109 entry: QueueEntry,
110 options: { isAmend: boolean; cap: number; now: string },
111): Queue => {
112 if (queue.pending.some(pending => pending.hash === entry.hash)) return queue
113
114 // An amended commit has the same parent as the commit it replaces.
115 const kept = options.isAmend && entry.parent !== null
116 ? queue.pending.filter(pending => !(pending.repo === entry.repo && pending.parent === entry.parent))
117 : queue.pending
118
119 let next: Queue = { ...queue, pending: [...kept, entry].sort(byCommitTime) }
120 while (next.pending.length > options.cap) next = expire(next, next.pending[0]!, 'cap', options.now)
121 return next
122}
123
124// Newest pending commit that still exists. Missing ones expire on the way;
125// the one returned stays pending (a skipped quiz is still there next time).
126export const selectNewest = async (
127 queue: Queue,
128 check: { run: Run; exists: Exists },
129 now: string,
130): Promise<{ queue: Queue; entry?: QueueEntry }> => {
131 let current = queue
132 for (const candidate of [...queue.pending].reverse()) {
133 try {
134 if (!(await check.exists(candidate.repo))) {
135 current = expire(current, candidate, 'missing', now, 'repo folder not found')
136 continue
137 }
138 const found = await git(check.run, candidate.repo, ['cat-file', '-e', `${candidate.hash}^{commit}`])
139 if (found.exitCode === 0) return { queue: current, entry: candidate }
140 current = expire(current, candidate, 'missing', now, 'commit not found')
141 } catch {
142 return { queue: current, entry: candidate } // could not verify: never expire on uncertainty
143 }
144 }
145 return { queue: current }
146}
147
148const isRecord = (value: unknown): value is Record<string, unknown> =>
149 typeof value === 'object' && value !== null && !Array.isArray(value)
150
151const isEntry = (value: unknown): boolean =>
152 isRecord(value) &&
153 typeof value.repo === 'string' && typeof value.hash === 'string' && typeof value.committedAt === 'string' &&
154 (typeof value.parent === 'string' || value.parent === null)
155
156const isQueue = (value: unknown): value is Queue =>
157 isRecord(value) &&
158 value.version === 1 &&
159 Number.isInteger(value.seq) && (value.seq as number) >= 0 &&
160 Array.isArray(value.pending) && value.pending.every(isEntry) &&
161 Array.isArray(value.expired) && value.expired.every(isRecord) &&
162 Number.isInteger(value.expiredCount) &&
163 isRecord(value.lastSeen) && Object.values(value.lastSeen).every(hash => typeof hash === 'string') &&
164 (value.seenAt === undefined ||
165 (isRecord(value.seenAt) && Object.values(value.seenAt).every(when => typeof when === 'string')))
166
167type FileState =
168 | { state: 'absent' }
169 | { state: 'unreadable'; reason: string }
170 | { state: 'invalid'; text: string }
171 | { state: 'valid'; queue: Queue }
172
173export const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
174
175const readState = async (io: QueueIO, path: string): Promise<FileState> => {
176 let text: string
177 try {
178 if (!(await io.exists(path))) return { state: 'absent' }
179 text = await io.read(path)
180 } catch (error) {
181 return { state: 'unreadable', reason: oneLine(String(error)) }
182 }
183 try {
184 const value: unknown = JSON.parse(text)
185 // a file from before seenAt existed loads with an empty one
186 return isQueue(value) ? { state: 'valid', queue: { ...value, seenAt: value.seenAt ?? {} } } : { state: 'invalid', text }
187 } catch {
188 return { state: 'invalid', text }
189 }
190}
191
192const filesOf = (dataDir: string) => ({ main: `${dataDir}/queue.json`, tmp: `${dataDir}/queue.json.tmp` })
193
194// isReadOnly: never write, not even the copy-aside (the status line only reads).
195export const loadQueue = async (
196 io: QueueIO,
197 dataDir: string,
198 now: string,
199 options: { isReadOnly?: boolean } = {},
200): Promise<{ queue: Queue; notice?: string; isWritable: boolean }> => {
201 const { main, tmp } = filesOf(dataDir)
202 const mainState = await readState(io, main)
203 const tmpState = await readState(io, tmp)
204
205 const valid = [mainState, tmpState].flatMap(file => (file.state === 'valid' ? [file.queue] : []))
206 if (valid.length > 0) {
207 const newest = valid.reduce((best, queue) => (queue.seq > best.seq ? queue : best))
208 return { queue: newest, isWritable: true }
209 }
210
211 if (mainState.state === 'unreadable') {
212 return {
213 queue: emptyQueue(),
214 isWritable: false,
215 notice: `after-build-quiz: could not read queue.json (${mainState.reason}); using an empty queue and not overwriting it this session`,
216 }
217 }
218
219 if (mainState.state === 'invalid' && options.isReadOnly === true) {
220 return {
221 queue: emptyQueue(),
222 isWritable: false,
223 notice: 'after-build-quiz: queue.json is corrupt; it will be copied aside at the next commit',
224 }
225 }
226
227 if (mainState.state === 'invalid') {
228 const asideName = `queue.corrupt-${now.replace(/[:.]/g, '-')}.json`
229 try {
230 await io.write(`${dataDir}/${asideName}`, mainState.text)
231 } catch {
232 return {
233 queue: emptyQueue(),
234 isWritable: false,
235 notice: 'after-build-quiz: queue.json is corrupt and could not be copied aside; not overwriting it this session',
236 }
237 }
238 return {
239 queue: emptyQueue(),
240 isWritable: true,
241 notice: `after-build-quiz: queue.json was corrupt; saved it as ${asideName} and started a fresh queue`,
242 }
243 }
244
245 return { queue: emptyQueue(), isWritable: true }
246}
247
248// Crash-safe without rename ($.fs has none): the full queue goes to the tmp
249// copy first, then to queue.json, so at every moment one of them is complete;
250// loadQueue takes the valid one with the higher seq.
251export const saveQueue = async (
252 io: QueueIO,
253 dataDir: string,
254 queue: Queue,
255): Promise<{ isSaved: boolean; queue: Queue }> => {
256 const { main, tmp } = filesOf(dataDir)
257 const next: Queue = { ...queue, seq: queue.seq + 1 }
258 const text = `${JSON.stringify(next, null, 2)}\n`
259 try {
260 await io.write(tmp, text)
261 await io.write(main, text)
262 return { isSaved: true, queue: next }
263 } catch {
264 return { isSaved: false, queue }
265 }
266}
267
268// The status line: "N quizzes pending", or nothing when none are.
269export const statusText = (queue: Queue): string | undefined => {
270 const count = queue.pending.length
271 if (count === 0) return undefined
272 return count === 1 ? '1 quiz pending' : `${count} quizzes pending`
273}
274hooks/settings.ts 38 lines1import type { PluginOptions } from 'claude-code'
2
3export type Settings = {
4 dataDir: string
5 minChangedLines: number
6 queueCap: number
7 model: string
8 devMode: boolean
9}
10
11// Mirrors the defaults in .claude-plugin/plugin.json userConfig.
12export const DEFAULTS: Readonly<Settings> = {
13 dataDir: '',
14 minChangedLines: 10,
15 queueCap: 10,
16 model: 'claude-opus-5-5',
17 devMode: false,
18}
19
20const wholeNumber = (name: string, value: unknown, min: number, fallback: number): number => {
21 if (value === undefined) return fallback
22 if (typeof value !== 'number' || !Number.isInteger(value) || value < min) {
23 throw new Error(`after-build-quiz: ${name} must be a whole number >= ${min}, got ${String(value)}`)
24 }
25 return value
26}
27
28const text = (value: unknown, fallback: string): string =>
29 typeof value === 'string' ? value.trim() : fallback
30
31export const readSettings = (options: PluginOptions): Settings => ({
32 dataDir: text(options.dataDir, DEFAULTS.dataDir),
33 minChangedLines: wholeNumber('minChangedLines', options.minChangedLines, 0, DEFAULTS.minChangedLines),
34 queueCap: wholeNumber('queueCap', options.queueCap, 1, DEFAULTS.queueCap),
35 model: text(options.model, DEFAULTS.model) || DEFAULTS.model,
36 devMode: typeof options.devMode === 'boolean' ? options.devMode : DEFAULTS.devMode,
37})
38hooks/sweep.ts 120 lines1import { authorArgs, commitsIn, findHead, logMine, markSeen, queueWorthy, sightingKey } from './detect.ts'
2import type { Head } from './detect.ts'
3import type { Run } from './git.ts'
4import { isAbsolute, normalizeDir } from './paths.ts'
5import type { Exists } from './paths.ts'
6import { loadQueue, oneLine, saveQueue } from './queue.ts'
7import type { Queue, QueueIO } from './queue.ts'
8
9// The session-start catch-up for commits made outside Claude Code (terminal,
10// IDE): for the session repo and the most recently active known repos, take
11// my commits in lastSeen..HEAD on the current branch. First sight of a repo
12// or branch, and a pruned lastSeen, only record a baseline. See SPEC behaviour 1.
13
14export const SWEEP_REPO_CAP = 20 // the session repo counts within it
15
16export type SweepDeps = {
17 run: Run
18 io: QueueIO
19 exists: Exists
20 dataDir: string
21 isWindows: boolean
22 minChangedLines: number
23 queueCap: number
24}
25
26export type SweepInput = { sessionCwd: string; now: string }
27
28export type SweepOutcome = { queued: number; queue?: Queue; notice?: string; errors: string[] }
29
30const rootOf = (key: string): string => key.slice(0, key.lastIndexOf('::'))
31
32const timeOf = (when: string | undefined): number => {
33 const time = when === undefined ? NaN : Date.parse(when)
34 return Number.isNaN(time) ? 0 : time
35}
36
37// Known repos other than the session's (already tried, found or not), most
38// recently active first.
39const otherRoots = (queue: Queue, session: readonly (string | undefined)[], limit: number): string[] =>
40 [...new Set(Object.keys(queue.lastSeen).map(rootOf))]
41 .filter(root => !session.includes(root))
42 .sort((a, b) => timeOf(queue.seenAt[b]) - timeOf(queue.seenAt[a]) || a.localeCompare(b))
43 .slice(0, Math.max(0, limit))
44
45export const sweepRepos = async (deps: SweepDeps, input: SweepInput): Promise<SweepOutcome> => {
46 const { run, io, dataDir, isWindows } = deps
47 const errors: string[] = []
48 try {
49 const loaded = await loadQueue(io, dataDir, input.now)
50 const withNotice = (outcome: SweepOutcome): SweepOutcome =>
51 loaded.notice === undefined ? outcome : { ...outcome, notice: loaded.notice }
52 if (!loaded.isWritable) return withNotice({ queued: 0, errors })
53
54 let queue = loaded.queue
55 let queued = 0
56 let isChanged = false
57
58 const sweepOne = async (head: Head, isSession: boolean) => {
59 const mark = () => {
60 queue = markSeen(queue, head, input.now)
61 isChanged = true
62 }
63 const lastSeen = queue.lastSeen[sightingKey(head)]
64 if (lastSeen === undefined) return mark() // first sight of this repo or branch: baseline only
65 if (lastSeen === head.sha) {
66 if (isSession) mark() // nothing new, but the session repo is active now
67 return
68 }
69 const log = await logMine(run, head.top, deps.queueCap, await authorArgs(run, head.top), [`${lastSeen}..HEAD`])
70 if (log.exitCode !== 0) return mark() // lastSeen pruned: new baseline, no guessing
71
72 // An outside amend: the commit we last saw is pending, and a new commit
73 // has its parent. 2f's isAmend rule replaces the stale entry.
74 const replaced = queue.pending.find(entry => entry.hash === lastSeen && entry.repo === head.top)
75 const result = await queueWorthy(run, head.top, queue, commitsIn(log.stdout), {
76 source: 'sweep',
77 now: input.now,
78 minChangedLines: deps.minChangedLines,
79 queueCap: deps.queueCap,
80 isAmend: made => replaced !== undefined && made.parents[0] === replaced.parent,
81 })
82 queue = result.queue
83 queued += result.queued
84 mark()
85 }
86
87 let sessionTop: string | undefined
88 const sessionDir = normalizeDir(input.sessionCwd, isWindows)
89 if (sessionDir !== undefined && isAbsolute(sessionDir)) {
90 try {
91 const head = await findHead(run, [sessionDir], isWindows)
92 if (head !== undefined) {
93 sessionTop = head.top
94 await sweepOne(head, true)
95 }
96 } catch (error) {
97 errors.push(`${sessionDir}: ${oneLine(String(error))}`)
98 }
99 }
100
101 for (const root of otherRoots(queue, [sessionTop, sessionDir], SWEEP_REPO_CAP - (sessionTop === undefined ? 0 : 1))) {
102 try {
103 if (!(await deps.exists(root))) continue // folder gone: no git call
104 const head = await findHead(run, [root], isWindows)
105 if (head === undefined || head.top === sessionTop) continue // no longer a repo, or no commits
106 await sweepOne(head, false)
107 } catch (error) {
108 errors.push(`${root}: ${oneLine(String(error))}`)
109 }
110 }
111
112 if (!isChanged) return withNotice({ queued, errors })
113 const saved = await saveQueue(io, dataDir, queue)
114 return withNotice(saved.isSaved ? { queued, queue: saved.queue, errors } : { queued, errors })
115 } catch (error) {
116 errors.push(oneLine(String(error)))
117 return { queued: 0, errors }
118 }
119}
120hooks/git.ts 55 lines1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3// Injected so tests can fake it; the plugin passes $.process.run.
4export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
5
6// The plugin's only way to run git. Read-only subcommands alone, checked before
7// anything runs; see SPEC "Never" and AGENTS "Hard rules". Do not widen.
8export class GitRefused extends Error {
9 constructor(args: readonly string[], why: string) {
10 super(`after-build-quiz: refused git ${args.join(' ')}: ${why}`)
11 this.name = 'GitRefused'
12 }
13}
14
15const READ_ONLY = new Set(['rev-parse', 'log', 'show', 'cat-file', 'config'])
16
17// log/show run diff drivers from .gitattributes unless told not to
18const NO_DRIVERS = ['--no-ext-diff', '--no-textconv']
19
20const refusal = (args: readonly string[]): string | undefined => {
21 const [subcommand, ...rest] = args
22 if (subcommand === undefined) return 'no subcommand'
23 if (subcommand.startsWith('-')) return 'global options are not allowed'
24 if (!READ_ONLY.has(subcommand)) return `${subcommand} is not read-only`
25
26 if (subcommand === 'config') {
27 return rest.length === 2 && rest[0] === '--get' ? undefined : 'only config --get <key> is allowed'
28 }
29 if (subcommand === 'log' || subcommand === 'show') {
30 const bad = rest.find(arg =>
31 /^--output(=|$)/.test(arg) || /^-o/.test(arg) || arg === '--ext-diff' || arg === '--textconv',
32 )
33 return bad === undefined ? undefined : `${bad} can write files or run drivers`
34 }
35 if (subcommand === 'cat-file') {
36 const bad = rest.find(arg => arg === '--textconv' || arg === '--filters')
37 return bad === undefined ? undefined : `${bad} runs drivers`
38 }
39 return undefined
40}
41
42export const git = async (
43 run: Run,
44 repo: string,
45 args: readonly string[],
46 init?: ProcessRunInit,
47): Promise<ProcessRunResult> => {
48 const why = refusal(args)
49 if (why !== undefined) throw new GitRefused(args, why)
50
51 const [subcommand, ...rest] = args as [string, ...string[]]
52 const withGuards = subcommand === 'log' || subcommand === 'show' ? [subcommand, ...NO_DRIVERS, ...rest] : args
53 return run(['git', '-C', repo, '--no-pager', ...withGuards], init)
54}
55hooks/trivial.ts 54 lines1// Decides whether a commit is worth a quiz, from `git show -w --numstat` output
2// (whitespace-only changes count as 0) and its parent count. See SPEC behaviour 2.
3
4export type Verdict =
5 | { kind: 'queue'; codeLines: number; codeFiles: number }
6 | { kind: 'trivial'; reason: 'merge' | 'docs-only' }
7 | { kind: 'trivial'; reason: 'below-threshold'; codeLines: number }
8
9const DOC_EXTENSIONS = /\.(md|mdx|txt|rst)$/i
10const DOC_NAMES = /^(LICENSE|CHANGELOG)/i
11const LOCKFILES = new Set([
12 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'poetry.lock', 'uv.lock', 'Cargo.lock', 'go.sum',
13])
14
15// `src/{a.ts => b.md}` and `old.txt => new.ts` are classified by the new path.
16const newPath = (path: string): string =>
17 path
18 .replace(/\{[^{}]* => ([^{}]*)\}/g, '$1')
19 .split(' => ')
20 .at(-1)!
21 .replace(/\/{2,}/g, '/')
22
23const isNonCode = (path: string): boolean => {
24 const name = path.slice(path.lastIndexOf('/') + 1)
25 return (
26 DOC_EXTENSIONS.test(name) ||
27 DOC_NAMES.test(name) ||
28 LOCKFILES.has(name) ||
29 path.startsWith('docs/') ||
30 path.includes('/docs/')
31 )
32}
33
34export const classify = (numstat: string, parentCount: number, minChangedLines: number): Verdict => {
35 if (parentCount >= 2) return { kind: 'trivial', reason: 'merge' }
36
37 let files = 0
38 let codeFiles = 0
39 let codeLines = 0
40 for (const line of numstat.split(/\r?\n/)) {
41 const [added, deleted, ...rest] = line.split('\t')
42 if (rest.length === 0) continue
43 files++
44 const isBinary = added === '-' || deleted === '-'
45 if (isBinary || isNonCode(newPath(rest.join('\t')))) continue
46 codeFiles++
47 codeLines += Number(added) + Number(deleted)
48 }
49
50 if (files > 0 && codeFiles === 0) return { kind: 'trivial', reason: 'docs-only' }
51 if (codeLines < minChangedLines) return { kind: 'trivial', reason: 'below-threshold', codeLines }
52 return { kind: 'queue', codeLines, codeFiles }
53}
54