SLOPSHOPPER

after-build-quiz

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

newguardstatusprocesstimer
v0.1.0no licenseupdated 2026-10-10VSDeadShot/after-build-quiz
A shopper browsing a rack in a slop shop
Source 8 files
hooks/register.ts 187 lines
1import 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}
187
hooks/detect.ts 298 lines
1import { 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}
298
hooks/paths.ts 99 lines
1import { 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
99
hooks/queue.ts 274 lines
1import { 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}
274
hooks/settings.ts 38 lines
1import 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})
38
hooks/sweep.ts 120 lines
1import { 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}
120
hooks/git.ts 55 lines
1import 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}
55
hooks/trivial.ts 54 lines
1// 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