SLOPSHOPPER

review-pair

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

newbandguardcommandtoastprompt
★ 1v0.1.0no licenseupdated 2026-10-10hunterbrewer04/claude-toolkit/plugins/review-pair
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-pair
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /review-pair ⎿ review-pair: review-pair for app ⎿ review-pair: decision: not set ⎿ review-pair: reviewer: none ⎿ review-pair: rounds: 0 ⎿ review-pair: outstanding: none ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

BrewKit

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.

Install

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

Plugins

school

Coursework tooling.

SkillWhat it does
course-setupScaffold a new course folder and clean Canvas file dumps; owns the school.json registry
grade-calcExact grade math from syllabus weights: current grade, what-ifs, target scores
rubric-checkGrade a draft against its assignment spec before submitting
study-guideTurn lecture PDFs into a self-contained interactive HTML study package
notebooklm-course-syncKeep a course's NotebookLM notebook in sync with local files
sapling-ai-detectorScan text for AI-generated content with a per-sentence report

work

Client and project delivery.

SkillWhat it does
linear-assistantCreate, update, and query Linear issues, projects, milestones, and cycles

personal

Personal-life tooling.

SkillWhat it does
apple-calendarRead and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server

misc

Everything cross-cutting.

SkillWhat it does
claude-toolkitAdd, sync, and set up this repo's components across machines
claude-documentationGenerate consistent README docs for skills, hooks, and sub-agents
skill-builderBuild a new skill through a structured, validated process
docxCreate, read, and edit Word documents, including tracked changes and comments
notebooklmFull programmatic NotebookLM API: notebooks, sources, artifacts, downloads
tailnetMove 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.

dev-flow

My development workflow chain, split across two sessions with a context clear in between.

SkillWhat it does
specTurn an idea into an approved specification
planBreak an approved spec into waves of file-disjoint tasks
implementExecute the plan across persistent subagent slots in git worktrees
reviewWhole-branch review pass with specialist fan-out
testRun 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.

meta-builders

SkillWhat it does
skill-creatorCreate and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric

sidebar

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.

checklist

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.

questions

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.

processes

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.

review-pair

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.

Status line

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.

Machine-local configuration

Nothing machine-specific is committed here. Two files live outside the repo:

FileUsed byNotes
~/.claude/tailnet-servers.jsontailnetServer registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema
~/.claude/settings.jsoneverythingPermissions, env, enabled plugins, status line wiring
Source 2 files
hooks/register.tsx 968 lines
1import 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}
968
types/index.d.ts 87 lines
1export 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