Pairs each builder session with a read-only reviewer session that reviews every commit.

My personal Claude Code setup, packaged as a plugin marketplace so I can install exactly the pieces a given machine needs.
This is a public snapshot of how I extend Claude Code. It ships 11 plugins covering 20 skills, 1 sub-agent, 4 hooks, 5 mods, and a custom status line.
Most pieces follow my own paths and conventions, so treat them as reference patterns to borrow from rather than drop-in installs.
/plugin marketplace add hunterbrewer04/claude-toolkit
/plugin install school@brewkit
Install only the plugins that machine actually needs. Nothing here assumes the others are present.
Coursework tooling.
| Skill | What it does |
|---|---|
| course-setup | Scaffold a new course folder and clean Canvas file dumps; owns the school.json registry |
| grade-calc | Exact grade math from syllabus weights: current grade, what-ifs, target scores |
| rubric-check | Grade a draft against its assignment spec before submitting |
| study-guide | Turn lecture PDFs into a self-contained interactive HTML study package |
| notebooklm-course-sync | Keep a course's NotebookLM notebook in sync with local files |
| sapling-ai-detector | Scan text for AI-generated content with a per-sentence report |
Client and project delivery.
| Skill | What it does |
|---|---|
| linear-assistant | Create, update, and query Linear issues, projects, milestones, and cycles |
Personal-life tooling.
| Skill | What it does |
|---|---|
| apple-calendar | Read and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server |
Everything cross-cutting.
| Skill | What it does |
|---|---|
| claude-toolkit | Add, sync, and set up this repo's components across machines |
| claude-documentation | Generate consistent README docs for skills, hooks, and sub-agents |
| skill-builder | Build a new skill through a structured, validated process |
| docx | Create, read, and edit Word documents, including tracked changes and comments |
| notebooklm | Full programmatic NotebookLM API: notebooks, sources, artifacts, downloads |
| tailnet | Move files to tailnet servers, serve files over Tailscale, Taildrop to a phone |
Also ships three hooks that apply everywhere: a PreToolUse guard against committing .env files, a SessionStart agent-state tracker, and a Stop desktop notification.
My development workflow chain, split across two sessions with a context clear in between.
| Skill | What it does |
|---|---|
| spec | Turn an idea into an approved specification |
| plan | Break an approved spec into waves of file-disjoint tasks |
| implement | Execute the plan across persistent subagent slots in git worktrees |
| review | Whole-branch review pass with specialist fan-out |
| test | Run the plan's verification section, then commit and open the PR |
Includes the code-reviewer sub-agent used by the review step, and a SessionStart resume hook.
| Skill | What it does |
|---|---|
| skill-creator | Create and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric |
A mod: a docked sidebar for the widget mods. Widgets publish their section and handle their own buttons; the sidebar only draws, so a new widget needs no change here. The layout option picks how: tabs (the default) gives each widget its own pane, shown as tabs titled with the widget's badge (Checklist 93%), opened when the widget has something, focused when it asks for attention, and closed when it empties; stacked draws every section in one pane that opens itself on attention. The order option sets the tab order or the stacking. /sidebar opens it, /sidebar close hides it.
A mod: a live checklist of the current plan, drawn as a section in the sidebar. Claude posts the steps through its own checklist tool and checks each one off as it finishes, subagents working a step show under it with their status and tool-call count, and the status line carries done/total. /checklist prints the list, /checklist clear empties it.
A mod: questions Claude asks without stopping the work. Each one comes with the default Claude goes with meanwhile and gets an answer field in the sidebar. An answer reaches Claude on its next step, with a note to redo anything built on the default. Questions still open at the end of a turn get a toast.
A mod: the background commands and dev servers Claude started this session, shown in the sidebar with their ports and uptime and a button that kills one. Nothing is ever killed automatically; anything still running when the session ends gets a toast.
A mod: a second Claude session that reviews every commit the session you are working in makes. The first commit in a repo asks once (always, not now, never). On always, a Sonnet reviewer opens in a herdr pane beside you (or as a background session outside herdr) and gets each new commit range. Blockers go back to the builder, which fixes them, for up to two rounds; nits and clean reviews go to a Review tab in the sidebar, and nits are posted as one PR comment when the builder opens a PR. The reviewer cannot use the Edit or Write tools and closes when the last builder in that repo ends. /review-pair shows the status; on, off, forget and stop change it.
statusline/ holds an agnoster-inspired three-row status line: where you are, what you are running, and what you are burning. Point statusLine.command in settings.json at statusline-command.sh.
Nothing machine-specific is committed here. Two files live outside the repo:
| File | Used by | Notes |
|---|---|---|
~/.claude/tailnet-servers.json | tailnet | Server registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema |
~/.claude/settings.json | everything | Permissions, env, enabled plugins, status line wiring |
hooks/register.tsx 968 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type {
4 Consent,
5 Outstanding,
6 Pending,
7 Repo,
8 RepoRun,
9 Reviewer,
10 ReviewerRef,
11 SessionState,
12 SidebarRow,
13 SidebarSection,
14 Verdict,
15} from '../types'
16
17const TAG = '[review-pair]'
18const COMMAND = 'review-pair'
19const SECTION = { plugin: 'review-pair', key: 'section' } as const
20const SESSION = { plugin: 'review-pair', key: 'session' } as const
21const CONSENTS = { plugin: 'review-pair', key: 'consents' } as const
22const ROUND_CAP = 2
23const KEEP_REVIEWS = 20
24const RECENT = 5
25const LEASE_MS = 90_000
26const STALE_MS = 15 * 60_000
27const BACKOFF_MS = 10 * 60_000
28const BUILDER_TTL_MS = 12 * 60 * 60_000
29const SPAWN_TIMEOUT_MS = 70_000
30const UPDATE_TRIES = 20
31const SHELL_WAIT_TRIES = 12
32const REVIEWER_PROMPT = 'You are the review-pair reviewer for this repo. Wait for review requests.'
33const GIT_WORD = /\bgit\b/
34const PR_CREATE = /\bgh\s+pr\s+create\b/
35const PR_URL = /https:\/\/github\.com\/[^\s"')]+\/pull\/\d+/
36const HEADER = /\[review-pair\][*`\s]*(BLOCKERS|NITS|CLEAN)[*`\s]+([^\s*`]+)/
37const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit']
38const EDIT_REASON = 'review-pair reviewer is read-only: it never edits files.'
39const BUSY = 'busy'
40
41type Lease = { by: string; at: number }
42type Found = Reviewer | typeof BUSY | undefined
43type Head = { head: string; branch: string }
44
45export const register: Register = on => {
46 on('session.start', async ($, e, next) => {
47 if (!(await isReviewer($))) {
48 await $.command.register({
49 name: COMMAND,
50 description: 'Show or change the review-pair reviewer for this repo',
51 argumentHint: 'status|on|off|forget|stop',
52 })
53 await publish($)
54 }
55
56 return next(e)
57 })
58
59 on('tool.call', async ($, e, next) => {
60 if (await isReviewer($)) {
61 return EDIT_TOOLS.includes(e.tool) ? { deny: EDIT_REASON } : next(e)
62 }
63 if (e.tool !== 'Bash') return next(e)
64
65 const isGit = GIT_WORD.test(e.command)
66 const isPr = PR_CREATE.test(e.command)
67 if (!isGit && !isPr) return next(e)
68
69 const repo = await safe(() => repoOf($))
70 if (repo === undefined) return next(e)
71 const before = await safe(() => probe($, repo.top))
72
73 const started = await $.clock.now()
74 const ran = await next(e)
75 if (ran.deny !== undefined) return ran
76
77 await safe(async () => {
78 if (isPr && ran.isError !== true) {
79 const url = PR_URL.exec(outputOf(ran))?.[0]
80 if (url !== undefined) await sendPr($, repo, url)
81 }
82 const after = await safe(() => probe($, repo.top))
83 if (before !== undefined && after !== undefined) await observe($, repo, before, after, started)
84 })
85
86 return ran
87 }).catch((_$, e, next) => (next.called ? next(e) : { deny: 'review-pair: its guard failed.' }))
88
89 on('prompt.compose', async ($, e, next) => {
90 const composed = await next(e)
91 if (!(await isReviewer($))) return composed
92 const repo = (await $.env.get('REVIEW_PAIR_REPO')) ?? 'this repo'
93
94 return {
95 sections: [...composed.sections, { id: 'review-pair:reviewer', text: reviewerGuide(repo), scope: 'session' }],
96 }
97 })
98
99 on('session.receive', async ($, e, next) => {
100 if (await isReviewer($)) return next(e)
101 if (!e.text.includes(TAG)) return next(e)
102 $.ui.log(`review-pair delivery from ${e.origin.kind}: ${e.text.slice(0, 80)}`, { to: 'debug' })
103 if (e.origin.kind !== 'peer' && e.origin.kind !== 'peer-send-message') return next(e)
104 if (e.agentId !== undefined) return next(e)
105
106 // Peer deliveries arrive wrapped in a <cross-session-message ...> tag line; the header is the first line inside.
107 const lines = e.text.split('\n').filter(line => !/^\s*<\/?cross-session-message\b/.test(line))
108 const first = lines.findIndex(line => line.trim() !== '')
109 const head = HEADER.exec(lines[first] ?? '')
110 if (head === null) return next(e)
111 const verdict = head[1] as Verdict
112 const range = head[2] ?? ''
113 const [left, right] = range.split('..')
114 if (left === undefined || right === undefined || left === '' || right === '') return next(e)
115 const body = lines.slice(first + 1).map(line => line.trim()).filter(line => line !== '')
116 const now = await $.clock.now()
117
118 const matched = await update($, s => {
119 const key = matchRepo(s, left, right)
120 if (key === undefined) return undefined
121 const run = s.repos[key] as RepoRun
122 const queued = run.queue
123 run.queue = null
124 run.outstanding = null
125 let pass = false
126 if (verdict === 'BLOCKERS') {
127 run.rounds += 1
128 run.findings = body
129 if (run.rounds <= ROUND_CAP) {
130 pass = true
131 } else {
132 run.capped = true
133 }
134 } else {
135 run.rounds = 0
136 run.capped = false
137 run.findings = []
138 s.nits[key] = verdict === 'NITS' ? body : []
139 }
140 s.reviews = [{ repo: run.name, range, verdict, at: now }, ...s.reviews].slice(0, KEEP_REVIEWS)
141
142 return { repo: { key, top: run.top, name: run.name } as Repo, queued, pass }
143 })
144 if (matched === undefined) return next(e)
145
146 if (matched.queued !== null) await sendReview($, matched.repo, matched.queued)
147 await publish($)
148
149 if (verdict === 'BLOCKERS') {
150 if (matched.pass) {
151 $.ui.toast('review-pair: blockers found')
152 return next(e)
153 }
154 $.ui.toast('review-pair: blockers persist after 2 rounds, needs you')
155 return { consumed: 'review-pair round cap' }
156 }
157
158 return { consumed: `review-pair ${verdict.toLowerCase()}` }
159 }).catch((_$, e, next) => next(e))
160
161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162 if (e.props.hasSurvey) return next(e)
163 const consents = await readConsents($)
164 const first = consents[0]
165 if (first === undefined) return next(e)
166
167 const { Box, Text, Button } = $.ui.resolve(e)
168 const press = (choice: 'always' | 'not now' | 'never'): (() => void) => () =>
169 void answer($, choice).catch(() => $.ui.toast('review-pair: could not save that answer'))
170
171 return Box({
172 children: [
173 Text({ children: `Start a reviewer for ${first.repo.name}?` }),
174 Button({ key: 'review-pair:always', label: 'always', onPress: press('always') }),
175 Button({ key: 'review-pair:not-now', label: 'not now', onPress: press('not now') }),
176 Button({ key: 'review-pair:never', label: 'never', onPress: press('never') }),
177 ],
178 })
179 })
180
181 on('command.run', { command: COMMAND }, async ($, e) => {
182 if (await isReviewer($)) return { text: 'this is the reviewer session.' }
183
184 return { text: await runCommand($, e.args) }
185 }).catch(() => ({ text: 'the command failed.' }))
186
187 on('session.end', async ($, e, next) => {
188 if (!(await isReviewer($)) && e.reason !== 'clear') await teardown($, e.sessionId)
189
190 return next(e)
191 })
192}
193
194async function isReviewer($: EngineInterface): Promise<boolean> {
195 return (await $.env.get('REVIEW_PAIR_ROLE')) === 'reviewer'
196}
197
198async function safe<T>(work: () => Promise<T>): Promise<T | undefined> {
199 try {
200 return await work()
201 } catch {
202 return undefined
203 }
204}
205
206async function quiet(work: () => Promise<unknown>): Promise<void> {
207 await safe(work)
208}
209
210// Repo identity: the absolute common git dir, so worktrees share one reviewer.
211async function repoOf($: EngineInterface): Promise<Repo | undefined> {
212 const cwd = await $.session.cwd()
213 const common = await $.process.run(['git', 'rev-parse', '--git-common-dir'], { cwd })
214 if (common.exitCode !== 0) return undefined
215 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
216 if (top.exitCode !== 0) return undefined
217 const topDir = top.stdout.trim()
218
219 return { key: resolveFrom(cwd, common.stdout.trim()), top: topDir, name: basename(topDir) }
220}
221
222// HEAD and branch of the repo at its top level, whatever the session's cwd is.
223async function probe($: EngineInterface, top: string): Promise<Head | undefined> {
224 const head = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd: top })
225 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: top })
226 if (head.exitCode !== 0 || branch.exitCode !== 0) return undefined
227
228 return { head: head.stdout.trim(), branch: branch.stdout.trim() }
229}
230
231async function isAncestor($: EngineInterface, older: string, newer: string, cwd: string): Promise<boolean> {
232 const run = await $.process.run(['git', 'merge-base', '--is-ancestor', older, newer], { cwd })
233
234 return run.exitCode === 0
235}
236
237// Commits in before..after whose commit time falls in the call (two seconds of slack). Newest first, as git lists them.
238async function commitsMadeSince(
239 $: EngineInterface,
240 repo: Repo,
241 before: string,
242 after: string,
243 started: number,
244): Promise<string[]> {
245 const log = await $.process.run(['git', 'log', '--format=%H %ct', `${before}..${after}`], { cwd: repo.top })
246 if (log.exitCode !== 0) return []
247 const floor = Math.floor(started / 1000) - 2
248 const made: string[] = []
249 for (const line of log.stdout.split('\n')) {
250 const match = /^([0-9a-f]+) (\d+)$/.exec(line.trim())
251 if (match === null) continue
252 if (Number(match[2]) >= floor) made.push(match[1] as string)
253 }
254
255 return made
256}
257
258// The parent of the oldest new commit is where the range starts; a root commit or a failed lookup falls back to the old head.
259async function rangeStartOf($: EngineInterface, cwd: string, oldest: string, fallback: string): Promise<string> {
260 const parent = await $.process.run(['git', 'rev-parse', `${oldest}^`], { cwd })
261 const sha = parent.stdout.trim()
262
263 return parent.exitCode === 0 && sha !== '' ? sha : fallback
264}
265
266async function subjectOf($: EngineInterface, cwd: string): Promise<string> {
267 const subject = await $.process.run(['git', 'log', '-1', '--format=%s'], { cwd })
268
269 return subject.exitCode === 0 ? subject.stdout.trim() : ''
270}
271
272// Decides whether this git call moved HEAD in a way that is a new range to review.
273async function observe($: EngineInterface, repo: Repo, before: Head, after: Head, started: number): Promise<void> {
274 const s = await getSession($)
275 const seen = s.repos[repo.key]?.seen ?? null
276 let start = before.head
277 if (
278 seen !== null &&
279 seen.branch === after.branch &&
280 seen.head !== before.head &&
281 (await isAncestor($, seen.head, before.head, repo.top))
282 ) {
283 start = seen.head
284 }
285
286 const sameBranch = before.branch === after.branch
287 const movedBack = after.head !== before.head && (await isAncestor($, after.head, before.head, repo.top))
288 // A switch that also commits (git checkout -b feat && git commit) reviews only the commits this call made; a plain checkout reviews nothing.
289 const newBranchCommit =
290 !sameBranch && after.head !== before.head && (await isAncestor($, before.head, after.head, repo.top))
291 if (newBranchCommit) {
292 const made = await commitsMadeSince($, repo, before.head, after.head, started)
293 if (made.length > 0) {
294 const from = await rangeStartOf($, repo.top, made[made.length - 1] as string, before.head)
295 const subject = await subjectOf($, repo.top)
296 await newRange($, repo, { before: from, after: after.head, branch: after.branch, subject })
297 }
298 } else if (sameBranch && !movedBack && after.head !== start) {
299 const subject = await subjectOf($, repo.top)
300 await newRange($, repo, { before: start, after: after.head, branch: after.branch, subject })
301 }
302
303 await update($, next => {
304 runOf(next, repo).seen = { head: after.head, branch: after.branch }
305 })
306}
307
308async function sendPr($: EngineInterface, repo: Repo, url: string): Promise<void> {
309 const reviewer = await readReviewer($, repo)
310 if (reviewer === undefined) return
311 if (!(await sendTo($, reviewer.name, `${TAG} PR ${url}`))) {
312 $.ui.toast(`review-pair: could not reach the reviewer for ${repo.name}`)
313 }
314}
315
316function outputOf(ran: { text?: string; result?: unknown }): string {
317 if (typeof ran.text === 'string') return ran.text
318 return typeof ran.result === 'string' ? ran.result : JSON.stringify(ran.result ?? '')
319}
320
321function resolveFrom(base: string, path: string): string {
322 const joined = path.startsWith('/') ? path : `${base}/${path}`
323 const parts: string[] = []
324 for (const part of joined.split('/')) {
325 if (part === '' || part === '.') continue
326 if (part === '..') parts.pop()
327 else parts.push(part)
328 }
329
330 return `/${parts.join('/')}`
331}
332
333function basename(path: string): string {
334 return path.split('/').filter(part => part !== '').pop() ?? path
335}
336
337function rangeOf(pending: Pending): string {
338 return `${pending.before}..${pending.after}`
339}
340
341function mergePending(first: Pending | null, latest: Pending): Pending {
342 if (first === null) return latest
343
344 return { before: first.before, after: latest.after, branch: latest.branch, subject: latest.subject }
345}
346
347// Merges ranges in the order given, oldest first.
348function mergeAll(ranges: (Pending | null | undefined)[]): Pending | null {
349 let merged: Pending | null = null
350 for (const range of ranges) {
351 if (range === null || range === undefined) continue
352 merged = mergePending(merged, range)
353 }
354
355 return merged
356}
357
358function isDead(outstanding: Outstanding, reviewer: Reviewer | undefined, now: number): boolean {
359 if (now - outstanding.sentAt > STALE_MS) return true
360 if (reviewer === undefined || outstanding.reviewer === null) return true
361
362 return outstanding.reviewer.name !== reviewer.name || outstanding.reviewer.id !== idOf(reviewer)
363}
364
365function idOf(reviewer: Reviewer): string {
366 return reviewer.mode === 'herdr' ? reviewer.paneId : reviewer.bgId
367}
368
369function decisionKey(key: string): string {
370 return `decision:${key}`
371}
372
373function reviewerKey(key: string): string {
374 return `reviewer:${key}`
375}
376
377function spawningKey(key: string): string {
378 return `spawning:${key}`
379}
380
381function failureKey(key: string): string {
382 return `failure:${key}`
383}
384
385function builderKey(key: string, sessionId: string): string {
386 return `builders:${key}:${sessionId}`
387}
388
389function localDay(ms: number): string {
390 const d = new Date(ms)
391 const pad = (n: number): string => String(n).padStart(2, '0')
392
393 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
394}
395
396function fnv1a(text: string): string {
397 let hash = 0x811c9dc5
398 for (let i = 0; i < text.length; i += 1) {
399 hash ^= text.charCodeAt(i)
400 hash = Math.imul(hash, 0x01000193)
401 }
402
403 return (hash >>> 0).toString(16).padStart(8, '0')
404}
405
406// Reviewer name: the repo's basename plus a stable hash of its key, so two repos named app differ.
407function reviewerName(repo: Repo): string {
408 // herdr agent names are at most 32 characters: 9 for the prefix, 7 for the hash, 16 for the repo.
409 const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 16).replace(/-+$/, '') || 'repo'
410
411 return `reviewer-${slug}-${fnv1a(repo.key).slice(0, 6)}`
412}
413
414function emptySession(): SessionState {
415 return { repos: {}, reviews: [], nits: {} }
416}
417
418async function getSession($: EngineInterface): Promise<SessionState> {
419 const read = await $.state.get(SESSION)
420
421 return read.value ?? emptySession()
422}
423
424// Read, decide and write in one state update: a write that lost a race is read again and decided again.
425async function update<T>($: EngineInterface, change: (s: SessionState) => T): Promise<T> {
426 for (let tries = 0; tries < UPDATE_TRIES; tries += 1) {
427 const read = await $.state.get(SESSION)
428 const s = read.value ?? emptySession()
429 const result = change(s)
430 const written = await $.state.set(SESSION, s, { ifVersion: read.version })
431 if (written.isSet) return result
432 }
433 throw new Error('review-pair: session state kept changing')
434}
435
436async function readConsents($: EngineInterface): Promise<Consent[]> {
437 const read = await $.state.get(CONSENTS)
438
439 return read.value ?? []
440}
441
442function runOf(s: SessionState, repo: Repo): RepoRun {
443 const existing = s.repos[repo.key]
444 if (existing !== undefined) return existing
445 const created: RepoRun = {
446 name: repo.name,
447 top: repo.top,
448 notNow: false,
449 outstanding: null,
450 queue: null,
451 rounds: 0,
452 capped: false,
453 findings: [],
454 seen: null,
455 }
456 s.repos[repo.key] = created
457
458 return created
459}
460
461function matchRepo(s: SessionState, left: string, right: string): string | undefined {
462 const hit = Object.entries(s.repos).find(
463 ([, run]) =>
464 run.outstanding !== null && shaMatch(run.outstanding.before, left) && shaMatch(run.outstanding.after, right),
465 )
466
467 return hit?.[0]
468}
469
470// Two SHAs match when the shorter is a prefix of the longer; both must be at least 7 characters.
471function shaMatch(full: string, given: string): boolean {
472 const a = given.toLowerCase()
473 const b = full.toLowerCase()
474 if (a.length < 7 || b.length < 7) return false
475
476 return b.startsWith(a) || a.startsWith(b)
477}
478
479async function readReviewer($: EngineInterface, repo: Repo): Promise<Reviewer | undefined> {
480 return (await $.store.get(reviewerKey(repo.key))) as Reviewer | undefined
481}
482
483async function newRange($: EngineInterface, repo: Repo, pending: Pending): Promise<void> {
484 const decision = await $.store.get(decisionKey(repo.key))
485 if (decision === 'never') return
486
487 const s = await getSession($)
488 if (s.repos[repo.key]?.notNow === true) return
489
490 if (decision !== 'always') {
491 await addConsent($, { repo, pending })
492
493 return
494 }
495
496 const now = await $.clock.now()
497 const reviewer = await readReviewer($, repo)
498 const outstanding = s.repos[repo.key]?.outstanding ?? null
499 if (outstanding !== null && !isDead(outstanding, reviewer, now)) {
500 await update($, next => {
501 const run = runOf(next, repo)
502 run.queue = mergeAll([run.queue, pending])
503 })
504 await publish($)
505
506 return
507 }
508
509 await sendReview($, repo, pending)
510}
511
512async function addConsent($: EngineInterface, consent: Consent): Promise<void> {
513 const consents = await readConsents($)
514 const index = consents.findIndex(one => one.repo.key === consent.repo.key)
515 const next =
516 index === -1
517 ? [...consents, consent]
518 : consents.map((one, at) =>
519 at === index ? { repo: one.repo, pending: mergePending(one.pending, consent.pending) } : one,
520 )
521 await $.state.set(CONSENTS, next)
522 await publish($)
523}
524
525async function answer($: EngineInterface, choice: 'always' | 'not now' | 'never'): Promise<void> {
526 const consents = await readConsents($)
527 const first = consents[0]
528 if (first === undefined) return
529 await $.state.set(CONSENTS, consents.slice(1))
530
531 if (choice === 'not now') {
532 await update($, s => {
533 runOf(s, first.repo).notNow = true
534 })
535 } else {
536 await $.store.set(decisionKey(first.repo.key), choice)
537 }
538
539 if (choice === 'always') await sendReview($, first.repo, first.pending)
540 await publish($)
541}
542
543// Sends the merged range to a reviewer. A range that cannot go out is parked in the queue, never dropped.
544async function sendReview($: EngineInterface, repo: Repo, pending: Pending): Promise<void> {
545 for (let attempt = 0; attempt < 2; attempt += 1) {
546 const found = await ensureReviewer($, repo)
547 if (found === BUSY || found === undefined) {
548 await park($, repo, pending)
549
550 return
551 }
552
553 const merged = await reserve($, repo, pending, found)
554 const text = `${TAG} REVIEW ${rangeOf(merged)}\n${merged.branch}: ${merged.subject}`
555 if (await sendTo($, found.name, text)) {
556 await addBuilder($, repo)
557 await publish($)
558
559 return
560 }
561 await dropReviewer($, repo, found)
562 }
563
564 await park($, repo, pending)
565 $.ui.toast(`review-pair: the reviewer for ${repo.name} is not reachable`)
566 await publish($)
567}
568
569// Records the outstanding range with the reviewer it went to, merging outstanding, queue and new range.
570async function reserve($: EngineInterface, repo: Repo, pending: Pending, reviewer: Reviewer): Promise<Pending> {
571 const now = await $.clock.now()
572 const ref: ReviewerRef = { name: reviewer.name, id: idOf(reviewer) }
573
574 return update($, s => {
575 const run = runOf(s, repo)
576 const merged = mergeAll([run.outstanding, run.queue, pending]) ?? pending
577 run.outstanding = { ...merged, sentAt: now, reviewer: ref }
578 run.queue = null
579
580 return merged
581 })
582}
583
584async function park($: EngineInterface, repo: Repo, pending: Pending): Promise<void> {
585 await update($, s => {
586 const run = runOf(s, repo)
587 run.queue = mergeAll([run.outstanding, run.queue, pending])
588 run.outstanding = null
589 })
590 await publish($)
591}
592
593async function sendTo($: EngineInterface, name: string, text: string): Promise<boolean> {
594 try {
595 const sent = await $.session.send({ to: name, text })
596
597 return sent.isDelivered
598 } catch {
599 return false
600 }
601}
602
603async function addBuilder($: EngineInterface, repo: Repo): Promise<void> {
604 const id = await $.session.id()
605 await $.store.set(builderKey(repo.key, id), { at: await $.clock.now() })
606}
607
608async function otherBuilders($: EngineInterface, repoKey: string, selfId: string, now: number): Promise<number> {
609 const prefix = `builders:${repoKey}:`
610 let count = 0
611 for (const key of await $.store.keys()) {
612 if (!key.startsWith(prefix) || key === `${prefix}${selfId}`) continue
613 const row = (await $.store.get(key)) as { at?: number } | undefined
614 if (row !== undefined && typeof row.at === 'number' && now - row.at < BUILDER_TTL_MS) count += 1
615 }
616
617 return count
618}
619
620async function backingOff($: EngineInterface, repo: Repo, now: number): Promise<boolean> {
621 const failure = (await $.store.get(failureKey(repo.key))) as { at?: number } | undefined
622
623 return typeof failure?.at === 'number' && now - failure.at < BACKOFF_MS
624}
625
626// Returns a live reviewer, BUSY when another session holds the spawn lease, or undefined when none can start.
627async function ensureReviewer($: EngineInterface, repo: Repo): Promise<Found> {
628 const me = await $.session.id()
629 const now = await $.clock.now()
630 const day = localDay(now)
631 const current = await readReviewer($, repo)
632 if (current !== undefined && current.day === day) return current
633
634 const lease = (await $.store.get(spawningKey(repo.key))) as Lease | undefined
635 if (lease !== undefined && lease.by !== me && now - lease.at < LEASE_MS) return BUSY
636 if (await backingOff($, repo, now)) return undefined
637
638 await $.store.set(spawningKey(repo.key), { by: me, at: now })
639 const held = (await $.store.get(spawningKey(repo.key))) as Lease | undefined
640 if (held?.by !== me) return BUSY
641
642 try {
643 if (current !== undefined) await dropReviewer($, repo, current)
644 const started = await spawnReviewer($, repo, day, now)
645 if (started !== undefined) {
646 await $.store.set(reviewerKey(repo.key), started)
647 await $.store.delete(failureKey(repo.key))
648 }
649
650 return started
651 } finally {
652 await $.store.delete(spawningKey(repo.key))
653 }
654}
655
656async function permissionMode($: EngineInterface): Promise<string | undefined> {
657 const settings = await $.settings.read()
658 const permissions = settings.permissions
659 if (typeof permissions !== 'object' || permissions === null) return undefined
660 const mode = (permissions as Record<string, unknown>).defaultMode
661
662 return typeof mode === 'string' && mode !== '' ? mode : undefined
663}
664
665async function spawnReviewer($: EngineInterface, repo: Repo, day: string, now: number): Promise<Reviewer | undefined> {
666 const name = reviewerName(repo)
667 const mode = await permissionMode($)
668 const claudeArgs = [
669 '-n', name, '--model', 'sonnet', '--plugin-dir', $.plugin.root,
670 ...(mode === undefined ? [] : ['--permission-mode', mode]),
671 ]
672 const env = { REVIEW_PAIR_ROLE: 'reviewer', REVIEW_PAIR_REPO: repo.key }
673 const herdr = (await $.env.get('HERDR_ENV')) === '1'
674 const herdrPane = await $.env.get('HERDR_PANE_ID')
675
676 try {
677 if (herdr && herdrPane) return await spawnInHerdr($, repo, herdrPane, name, claudeArgs, day, now)
678
679 const bg = await $.process.run(['claude', '--bg', ...claudeArgs, REVIEWER_PROMPT], {
680 cwd: repo.top,
681 env,
682 timeoutMs: SPAWN_TIMEOUT_MS,
683 })
684 const bgId = bg.exitCode === 0 ? /backgrounded\s*·\s*(\S+)\s*·/.exec(bg.stdout)?.[1] : undefined
685 if (bgId === undefined) return failed($, repo, now, `bg exit ${bg.exitCode}: ${(bg.stderr || bg.stdout).slice(0, 160)}`)
686 $.ui.toast(`review-pair: reviewer started for ${repo.name}`)
687
688 return { name, day, mode: 'bg', bgId }
689 } catch (error) {
690 return failed($, repo, now, `threw: ${String(error).slice(0, 160)}`)
691 }
692}
693
694async function spawnInHerdr(
695 $: EngineInterface,
696 repo: Repo,
697 herdrPane: string,
698 name: string,
699 claudeArgs: string[],
700 day: string,
701 now: number,
702): Promise<Reviewer | undefined> {
703 let paneId: string | undefined
704 let started = false
705 try {
706 const split = await $.process.run([
707 'herdr', 'pane', 'split', herdrPane,
708 '--direction', 'right', '--ratio', '0.65', '--cwd', repo.top, '--no-focus',
709 '--env', 'REVIEW_PAIR_ROLE=reviewer', '--env', `REVIEW_PAIR_REPO=${repo.key}`,
710 ])
711 paneId = split.exitCode === 0 ? paneIdOf(split.stdout) : undefined
712 if (paneId === undefined) return failed($, repo, now, `split exit ${split.exitCode}: ${(split.stderr || split.stdout).slice(0, 160)}`)
713
714 // A freshly split pane's shell may still be starting: herdr answers agent_pane_busy until it is.
715 const startArgv = ['herdr', 'agent', 'start', name, '--kind', 'claude', '--pane', paneId, '--timeout', '60000', '--', ...claudeArgs]
716 let start = await $.process.run(startArgv, { timeoutMs: SPAWN_TIMEOUT_MS })
717 let outcome = agentStartOf(start)
718 for (let tries = 0; outcome === 'busy' && tries < SHELL_WAIT_TRIES; tries++) {
719 await $.process.run(['sleep', '0.5'])
720 start = await $.process.run(startArgv, { timeoutMs: SPAWN_TIMEOUT_MS })
721 outcome = agentStartOf(start)
722 }
723 if (outcome === 'failed' || outcome === 'busy') return failed($, repo, now, `agent start exit ${start.exitCode}: ${(start.stdout || start.stderr).slice(0, 160)}`)
724
725 // A reviewer waiting on a workspace trust prompt keeps its pane; the queued message is delivered once it answers.
726 started = true
727 if (outcome === 'not-ready') {
728 $.ui.toast(`review-pair: reviewer for ${repo.name} is waiting on a trust prompt in the right pane`)
729 } else {
730 $.ui.toast(`review-pair: reviewer started for ${repo.name}`)
731 }
732
733 return { name, day, mode: 'herdr', paneId }
734 } finally {
735 if (!started && paneId !== undefined) {
736 const orphan = paneId
737 await quiet(() => $.process.run(['herdr', 'pane', 'close', orphan]))
738 }
739 }
740}
741
742async function failed($: EngineInterface, repo: Repo, now: number, reason: string): Promise<undefined> {
743 await $.store.set(failureKey(repo.key), { at: now, reason })
744 $.ui.toast(`review-pair: could not start a reviewer for ${repo.name} (${reason})`)
745
746 return undefined
747}
748
749type AgentStart = 'started' | 'not-ready' | 'busy' | 'failed'
750
751// Only an agent_started result is a start. agent_not_ready means the agent is up but blocked on a prompt;
752// agent_pane_busy means the pane's shell is not ready yet. herdr exits non-zero on errors, so the JSON decides.
753function agentStartOf(run: { exitCode: number; stdout: string }): AgentStart {
754 try {
755 const parsed = JSON.parse(run.stdout) as { result?: { type?: unknown }; error?: { code?: unknown } }
756 if (parsed.result?.type === 'agent_started') return run.exitCode === 0 ? 'started' : 'failed'
757 if (parsed.error?.code === 'agent_not_ready') return 'not-ready'
758 if (parsed.error?.code === 'agent_pane_busy') return 'busy'
759 } catch {
760 return 'failed'
761 }
762
763 return 'failed'
764}
765
766function paneIdOf(stdout: string): string | undefined {
767 try {
768 const parsed = JSON.parse(stdout) as { result?: { pane?: { pane_id?: unknown } } }
769 const id = parsed.result?.pane?.pane_id
770
771 return typeof id === 'string' ? id : undefined
772 } catch {
773 return undefined
774 }
775}
776
777async function closeReviewer($: EngineInterface, reviewer: Reviewer): Promise<void> {
778 if (reviewer.mode === 'herdr') {
779 await quiet(() => $.process.run(['herdr', 'pane', 'close', reviewer.paneId]))
780 } else {
781 await quiet(() => $.process.run(['claude', 'stop', reviewer.bgId]))
782 }
783}
784
785async function dropReviewer($: EngineInterface, repo: Repo, reviewer: Reviewer): Promise<void> {
786 await closeReviewer($, reviewer)
787 await $.store.delete(reviewerKey(repo.key))
788}
789
790async function teardown($: EngineInterface, sessionId: string): Promise<void> {
791 const now = await $.clock.now()
792 const s = await getSession($)
793 for (const [key, run] of Object.entries(s.repos)) {
794 await $.store.delete(builderKey(key, sessionId))
795 if ((await otherBuilders($, key, sessionId, now)) > 0) continue
796
797 await stopReviewer($, { key, top: run.top, name: run.name })
798 }
799 await publish($)
800}
801
802async function runCommand($: EngineInterface, args: string): Promise<string> {
803 const repo = await repoOf($)
804 if (repo === undefined) return 'this session is not in a git repo.'
805 const word = args.trim().split(/\s+/)[0] ?? ''
806 const decision = (await $.store.get(decisionKey(repo.key))) as string | undefined
807
808 if (word === '' || word === 'status') {
809 const reviewer = await readReviewer($, repo)
810 const s = await getSession($)
811 const run = s.repos[repo.key]
812 return [
813 `review-pair for ${repo.name}`,
814 `decision: ${decision ?? 'not set'}`,
815 `reviewer: ${reviewer === undefined ? 'none' : `${reviewer.name} (${reviewer.mode}, ${reviewer.day})`}`,
816 `rounds: ${run?.rounds ?? 0}`,
817 `outstanding: ${run?.outstanding === null || run?.outstanding === undefined ? 'none' : rangeOf(run.outstanding)}`,
818 ].join('\n')
819 }
820
821 if (word === 'on') {
822 await $.store.set(decisionKey(repo.key), 'always')
823 await dropConsent($, repo, true)
824
825 return `always for ${repo.name}. The reviewer starts at the next commit.`
826 }
827
828 if (word === 'off') {
829 await $.store.set(decisionKey(repo.key), 'never')
830 await dropConsent($, repo, false)
831 await stopReviewer($, repo)
832
833 return `never for ${repo.name}. Reviewer stopped.`
834 }
835
836 if (word === 'forget') {
837 await $.store.delete(decisionKey(repo.key))
838 await dropConsent($, repo, false)
839 await stopReviewer($, repo)
840
841 return `decision and reviewer record cleared for ${repo.name}.`
842 }
843
844 if (word === 'stop') {
845 await stopReviewer($, repo, true)
846
847 return `reviewer stopped for ${repo.name}.`
848 }
849
850 return 'Usage: /review-pair [status|on|off|forget|stop]'
851}
852
853// Removes the repo's pending consent band. `lift` also clears a session-level "not now" so the next range can start a reviewer.
854async function dropConsent($: EngineInterface, repo: Repo, lift: boolean): Promise<void> {
855 const consents = await readConsents($)
856 const kept = consents.filter(one => one.repo.key !== repo.key)
857 if (kept.length !== consents.length) await $.state.set(CONSENTS, kept)
858 if (lift) {
859 await update($, s => {
860 const run = s.repos[repo.key]
861 if (run !== undefined) run.notNow = false
862 })
863 }
864 await publish($)
865}
866
867// Closes the reviewer and clears the lease, backoff, outstanding range and queue for the repo.
868// `keep` parks unreviewed commits for the next reviewer (stop); without it they are dropped (off, forget).
869async function stopReviewer($: EngineInterface, repo: Repo, keep = false): Promise<void> {
870 const reviewer = await readReviewer($, repo)
871 if (reviewer !== undefined) await dropReviewer($, repo, reviewer)
872 await $.store.delete(spawningKey(repo.key))
873 await $.store.delete(failureKey(repo.key))
874 await update($, s => {
875 const run = s.repos[repo.key]
876 if (run !== undefined) {
877 run.queue = keep ? mergeAll([run.outstanding, run.queue]) : null
878 run.outstanding = null
879 }
880 })
881 await publish($)
882}
883
884async function publish($: EngineInterface): Promise<void> {
885 const s = await getSession($)
886 const consents = await readConsents($)
887 await $.state.set(SECTION, build(s, consents))
888}
889
890function build(s: SessionState, consents: readonly Consent[]): SidebarSection {
891 const runs = Object.values(s.repos)
892 const blocked = runs.filter(run => run.rounds > 0).length
893 const pendingCount = runs.filter(run => run.outstanding !== null).length
894 const rows: SidebarRow[] = []
895
896 for (const consent of consents) {
897 rows.push({ kind: 'item', icon: '?', tone: 'accent', text: `Start a reviewer for ${consent.repo.name}?` })
898 }
899 for (const run of runs) {
900 if (run.outstanding !== null) {
901 rows.push({
902 kind: 'item',
903 icon: '…',
904 tone: 'muted',
905 state: 'active',
906 text: `${run.name} ${rangeOf(run.outstanding)} in review`,
907 })
908 }
909 if (run.capped) {
910 rows.push({ kind: 'text', text: `${run.name}: needs you`, tone: 'error', bold: true })
911 for (const finding of run.findings) rows.push({ kind: 'text', text: finding, tone: 'error' })
912 }
913 }
914 for (const review of s.reviews.slice(0, RECENT)) {
915 rows.push({
916 kind: 'item',
917 icon: verdictIcon(review.verdict),
918 tone: verdictTone(review.verdict),
919 text: `${review.repo} ${review.range} ${review.verdict}`,
920 })
921 }
922 for (const nits of Object.values(s.nits)) {
923 for (const nit of nits) rows.push({ kind: 'text', text: nit, tone: 'muted' })
924 }
925
926 const isEmpty = s.reviews.length === 0 && pendingCount === 0 && consents.length === 0
927 let badge: string | undefined
928 if (blocked > 0) badge = blocked === 1 ? '1 blocker' : `${blocked} blockers`
929 else if (pendingCount > 0) badge = `${pendingCount} pending`
930
931 return {
932 version: 1,
933 title: 'Review',
934 ...(badge === undefined ? {} : { badge }),
935 ...(blocked > 0 ? { tone: 'error' as const } : {}),
936 isEmpty,
937 attention: blocked,
938 rows,
939 }
940}
941
942function verdictIcon(verdict: Verdict): string {
943 if (verdict === 'BLOCKERS') return '!'
944 if (verdict === 'NITS') return '~'
945
946 return '✓'
947}
948
949function verdictTone(verdict: Verdict): 'error' | 'warn' | 'done' {
950 if (verdict === 'BLOCKERS') return 'error'
951 if (verdict === 'NITS') return 'warn'
952
953 return 'done'
954}
955
956function reviewerGuide(repo: string): string {
957 return [
958 `You are the review-pair reviewer for the repo at ${repo}. You never edit files and never change git or GitHub state beyond the one summary comment described below.`,
959 'On a message starting with "[review-pair] REVIEW <range>", run `git log <range>` and `git diff <range>` in the repo and review those changes.',
960 'For a diff over about 400 changed lines, spawn a fresh dev-flow:code-reviewer subagent on the range and review its output before you reply.',
961 'A blocker is a bug, a security issue, data loss, a broken build or tests, or a broken API contract. Everything else is a nit.',
962 'Reply with SendMessage to the `from` address of the request. The first line must be exactly "[review-pair] BLOCKERS <range>", "[review-pair] NITS <range>" or "[review-pair] CLEAN <range>".',
963 'After the first line, list each finding on its own line as `file:line - problem - fix`. Send CLEAN with no findings when there is nothing to report.',
964 'On a message starting with "[review-pair] PR <url>", post ONE summary comment of the outstanding nits with `gh pr comment <url> --body "..."`. The comment carries no attribution text.',
965 'Keep earlier decisions in mind: do not re-flag a nit the builder has accepted.',
966 ].join(' ')
967}
968types/index.d.ts 87 lines1export type SidebarTone = 'accent' | 'done' | 'muted' | 'error' | 'warn' | 'text'
2
3export type SidebarRow =
4 | { kind: 'progress'; fraction: number; label?: string }
5 | { kind: 'item'; icon: string; tone?: SidebarTone; number?: number; text: string;
6 state?: 'normal' | 'active' | 'done';
7 sub?: { icon?: string; tone?: SidebarTone; text: string };
8 action?: { key: string; label: string } }
9 | { kind: 'input'; key: string; placeholder?: string; hint?: string }
10 | { kind: 'button'; key: string; label: string; primary?: boolean }
11 | { kind: 'text'; text: string; tone?: SidebarTone; bold?: boolean }
12 | { kind: 'divider'; label: string }
13
14export type SidebarSection = {
15 version: 1
16 title: string
17 badge?: string
18 tone?: SidebarTone
19 isEmpty: boolean
20 attention: number
21 rows: SidebarRow[]
22}
23
24/** The repository a builder works in: its common git dir (the identity), its top level and name. */
25export type Repo = { key: string; top: string; name: string }
26
27/** One range of commits waiting for, or under, review. */
28export type Pending = { before: string; after: string; branch: string; subject: string }
29
30/** The reviewer session the mod started for a repo, recorded in $.store per repo. */
31export type Reviewer =
32 | { name: string; day: string; mode: 'herdr'; paneId: string }
33 | { name: string; day: string; mode: 'bg'; bgId: string }
34
35/** Which reviewer a range was sent to: its name and its bgId or paneId. */
36export type ReviewerRef = { name: string; id: string }
37
38/** A range sent to a reviewer and not yet answered. */
39export type Outstanding = Pending & { sentAt: number; reviewer: ReviewerRef | null }
40
41/** The last HEAD this builder saw in a repo, with its branch. */
42export type Seen = { head: string; branch: string }
43
44export type Verdict = 'BLOCKERS' | 'NITS' | 'CLEAN'
45
46export type Review = { repo: string; range: string; verdict: Verdict; at: number }
47
48/** Per repo state of this builder session. */
49export type RepoRun = {
50 name: string
51 top: string
52 /** Session-level "not now" from the consent band. */
53 notNow: boolean
54 /** The range sent to the reviewer and not yet answered. */
55 outstanding: Outstanding | null
56 /** Ranges that arrived while a review was outstanding or a reviewer was busy, merged into one range. */
57 queue: Pending | null
58 /** Consecutive BLOCKERS rounds. */
59 rounds: number
60 /** True once the round cap was hit; the tab says "needs you". */
61 capped: boolean
62 /** Finding lines of the latest BLOCKERS reply. */
63 findings: string[]
64 /** The last HEAD and branch seen by a git probe in this repo. */
65 seen: Seen | null
66}
67
68export type SessionState = {
69 repos: Record<string, RepoRun>
70 reviews: Review[]
71 /** Latest nits per repo key. */
72 nits: Record<string, string[]>
73}
74
75/** A repo whose first range is waiting for the always / not now / never answer. */
76export type Consent = { repo: Repo; pending: Pending }
77
78declare module 'claude-code' {
79 interface PluginState {
80 'review-pair': {
81 section: SidebarSection
82 session: SessionState
83 consents: Consent[]
84 }
85 }
86}
87