SLOPSHOPPER

gemini-review

Has Gemini review every git commit the model runs, from the staged diff and the conversation, and stops a commit with a blocking finding: a bug, data loss, a…

newguardcommandtoastprocessnetwork
A shopper browsing a rack in a slop shop
README

gemini-review

The model writes code, commits it, and nobody reads the diff before it lands: a hardcoded key, a bug the change brings in, or a change that is not what you asked for goes into history. This mod has Gemini review every commit the model makes. Before a Bash git commit runs, Gemini reads the change the commit records and the conversation. A blocking finding stops the commit and the model reads why; a minor finding lets the commit run and the model reads it after the result.

What it does

  1. The mod hooks the Bash tool. A command with a git commit in it (the commit skill included) is reviewed before it runs; any other command runs untouched.
  2. It reads the command without a shell: cd <dir> and git -C <dir> set the directory, git add before the commit in the same command says what gets staged, and commit -a, add -u and add -A say that every tracked or untracked change goes in. Paths given to the commit itself (git commit -m x -- a.ts) mean git records those paths from the working tree and nothing else, and the review reads just those. A commit message in a heredoc or in quotes is not read as a command. git commit --help, -h and --dry-run record nothing and are not reviewed. A command that runs anything else before the commit (echo x >> f && git commit -am ...) is stopped before it runs, and the model reads that it must commit in a Bash call of its own. The review reads the change before the command runs, so what those steps change would not be in it: such a commit went through with an empty diff and no review (measured on 2.1.278). Commands after the commit (&& git push) are allowed.
  3. It collects the change with git, run by argv in the Bash tool's directory ($.session.cwd() follows a Bash cd, measured on 2.1.278): the index, a path the command stages from the working tree (the index still holds its older content until git add runs), and each new file whole, at most 200 of them. Each git call has 20 seconds. When the change is empty, the commit runs with no review and no line.
  4. It sends the diff and the conversation in one generateContent request with a schema: a list of findings, each blocker or minor, with a file, a line and a message. The diff always goes whole. When the conversation is over maxInputChars (2,000,000 characters by default), its longest tool outputs are cut to one common length, each keeping its head and tail. gemini-core builds the request with the key, the model and the thinking level it holds for gemini-review, and reads the answer.
  5. blocker means a bug the change introduces, data loss, a security hole, a secret or credential in the diff, or a change that contradicts what you asked for. Everything else is minor.
  6. The verdict:
  7. a blocker: the command does not run; the model reads each blocker and the minor notes, with the instruction to fix and commit again, or, when a finding is wrong, to tell you why and run the same command with GEMINI_REVIEW_SKIP=1 in front;
  8. only minor findings: the commit runs and the model reads the notes after the result;
  9. no finding: the commit runs and the model reads one line saying the review found nothing.
  10. When the review cannot answer (no key, an HTTP error, a malformed answer or one cut at the output limit, a git error, a diff over 1,500,000 characters), the commit runs, a transcript line says why, and the model reads the reason.
  11. Gemini answers HTTP 503 ("high demand") now and then, often after 10 seconds or more. gemini-core then has the mod ask again after 1 s, 2 s and 3 s, at most four attempts in all, and no attempt starts whose wait would end past 60 s. A hook's 10-second budget counts its $.clock waits but not its requests, so the waits stay short. After a 429 or a key error, gemini-core hands over the request with its next key, when it holds one.

In a live check on 2.1.278 with gemini-3.8-flash, a commit of a file with sk_live_... was stopped with sub/pay.ts:1: Hardcoded live Stripe secret key committed in source code, a git add pay.ts sub.ts && git commit after the fix ran, a GEMINI_REVIEW_SKIP=1 commit ran unreviewed, and an invalid key let the commit run with Gemini HTTP 400: API key not valid. Of eight reviews in that session, four got an answer (one after two 503s, 42.5 seconds in all) and four got only 503 answers and let the commit run.

What it shows

After each review a toast stays for 10 seconds, and /gemini-review shows the last one:

gemini-review: reviewed 1 file(s) · 1 blocker, 0 minor · 2k in, 515 out · sent to Gemini free tier

The sent to Gemini free tier part appears only on the free tier. A transcript line follows every review, so you read what the model was told. The line holds the findings alone, without the instruction:

gemini-review: commit reviewed: 2 file(s), nothing to report gemini-review: commit reviewed with 1 minor note(s): pay.ts:12: Name the constant. gemini-review: commit stopped: reviewed 1 file(s) · 1 blocker, 0 minor · 2k in, 515 out gemini-review: commit ran without a review: the model used GEMINI_REVIEW_SKIP=1

The context and the line are separate channels: the model never reads the line, and you never read the context.

Command

/gemini-review on or off, the model, thinking level and tier gemini-core holds, whether a key is set, the last review /gemini-review on | off off: commits run without a review; on is refused while gemini-core has no key /gemini-review reset off again, the default

The review is off after an install, so nothing goes to Gemini before you set a key and turn it on.

The key, the tier, the model (default gemini-3.8-flash) and the thinking level belong to gemini-core:

/gemini-core model review gemini-3.7-flash /gemini-core thinking review low /gemini-core paid

Free tier or paid tier

Every review sends the diff and the conversation: your prompts, the commands the model ran and the contents of the files it read. On the free tier Google may use them and human reviewers may read them; the gemini-core README quotes the Gemini API Additional Terms. On a project you would not show to Google, use a key with billing enabled and set /gemini-core paid.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install gemini-review@kilimcininkoroglu-mods

It depends on gemini-core, which claude plugin install adds. Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Set the Gemini key and the tier in gemini-core, as its After installing section says, then restart Claude Code.
  2. Run /gemini-review on. Without a key it answers still off: gemini-core has no Gemini key and stays off.
  3. Run /gemini-review. The first line reads on · <model> · thinking ... · <tier> tier · key set.
  4. When a commit runs with commit ran without a review: Gemini HTTP 429, the model has no quota on your key. Pick another with /gemini-core model review.

After an update from 0.1.x: claude plugin update does not add gemini-core (measured on 2.1.278), so run claude plugin install gemini-core@kilimcininkoroglu-mods once. Version 0.2.0 moved the key, tier and model to gemini-core; the apiKey, tier and model options and the settings /gemini-review free|paid|model stored before are no longer read, so set them again in gemini-core. Version 0.3.0 made the review off by default: after an update from an earlier version it is off unless you ran /gemini-review on before, so run /gemini-review on once.

Options

OptionDefaultWhat it sets
maxInputChars2000000Characters of conversation sent at most, 10,000 to 4,000,000; the diff always goes whole

A value outside the range, or one that is not a whole number, falls back to the default.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=gemini-review}, tool.call{tool=Bash} ❯ ./register.ts calls: $.clock.now (via askGemini), $.clock.sleep (via askGemini), $.command.register, $.gemini.enroll, $.gemini.read (via askGemini), $.gemini.request (via askGemini), $.gemini.settings (via review, runCommand, storeEnabled), $.http.fetch (via askGemini), $.process.run (via collectDiff, git), $.session.cwd (via review), $.session.messages (via review), $.store.delete (via runCommand), $.store.get (via isEnabled), $.store.set (via storeEnabled), $.ui.log, $.ui.toast (via verdictOf)

Reach L3, reaches the network.

  1. Reads: each Bash command; at a commit, the repository's diff and new files through git, and the conversation (messages, tool inputs and outputs); its own $.store; from gemini-core, the request with the key
  2. Runs: read-only git by argv, no shell: rev-parse, diff, ls-files; at most 200 new files are read
  3. Sends: the diff and the conversation, one request per commit (up to four after a 503, and once more per extra key after a 429 or a key error), to the URL gemini-core builds (generativelanguage.googleapis.com) with the key in the x-goog-api-key header, never in the URL
  4. Persists: in $.store, the on/off setting; the last review line lives in memory
  5. Hostile input: a diff or a conversation can steer Gemini's findings, so a hostile change can pass or a sound one be stopped; the model reads the findings as a denial or a note and can skip a wrong one; paths from the command reach git as argv after --, never through a shell

Limits

  • A review is a model's opinion. It can miss a problem, and a wrong blocker is passed with the skip prefix, which the model uses by its own decision.
  • Only the model's Bash commits are reviewed. A commit from your terminal, from a script the model runs, from an alias or through another tool is not.
  • The command reading covers the usual forms. A cd inside a subshell, variables in paths and globs that git expands are not resolved.
  • A subagent's commit sends only the diff, because which transcript $.session.messages() answers inside a subagent was not verified.
  • Every review sends the whole conversation, so a long session makes each review larger and slower. $.session.messages() answers the newest 4096 messages.
  • The Bash hook ran 42.5 seconds without a limit in the live check. A longer limit was not measured.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 6 files
hooks/register.ts 178 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { changeText, ENABLED_KEY, NO_KEY_ON, parseCommand, RESET_TEXT, statusText } from './command.ts'
3import { combinedText, diffCommands, fileCount, findCommit, newFileDiff, SKIP_VARIABLE, type CommitPlan } from './commit.ts'
4import { CONSUMER, configFrom, DEADLINE_MS, DEFAULT_MODEL, type Config } from './config.ts'
5import { buildReviewBody, cleanContext, denyText, failedContext, minorContext, parseFindings, passedLog, summaryText, type Answer, type Finding } from './review.ts'
6import { renderTranscript } from './transcript.ts'
7
8/** The last review's line, for the status. */
9type State = { last?: string }
10
11/** What the review decided: stop the commit, or let it run with a note for the model. */
12type Verdict = { deny?: string; context?: string }
13
14/** Gemini's answer and the tier it went to, or why there is none. */
15type Asked = { answer: Answer; tier: 'free' | 'paid' } | { error: string }
16
17/** At most this many new files are read into the diff. */
18const MAX_NEW_FILES = 200
19
20/** A diff over this is not sent, and the commit runs unreviewed. */
21const MAX_DIFF_CHARS = 1_500_000
22
23function errorText(err: unknown): string {
24  return err instanceof Error ? err.message : String(err)
25}
26
27/** Off until the user turns it on, so a fresh install sends nothing to Gemini. */
28async function isEnabled($: EngineInterface): Promise<boolean> {
29  return (await $.store.get(ENABLED_KEY)) === true
30}
31
32/** Stores on or off; `on` is refused while gemini-core has no key. */
33async function storeEnabled($: EngineInterface, enabled: boolean): Promise<string> {
34  if (enabled && !(await $.gemini.settings({ consumer: CONSUMER })).hasKey) return NO_KEY_ON
35  await $.store.set(ENABLED_KEY, enabled)
36  return changeText(enabled)
37}
38
39/** Runs a git command and answers its output; another exit code throws with git's message. */
40async function git($: EngineInterface, argv: readonly string[], cwd: string, okCodes: readonly number[] = [0]): Promise<string> {
41  const r = await $.process.run(argv, { cwd, timeoutMs: 20_000 })
42  if (!okCodes.includes(r.exitCode)) throw new Error(`${argv.slice(0, 3).join(' ')} failed: ${r.stderr.trim().slice(0, 200)}`)
43  return r.stdout
44}
45
46function resolveDir(base: string, dir: string | undefined): string {
47  if (dir === undefined) return base
48  return dir.startsWith('/') ? dir : `${base}/${dir}`
49}
50
51/** The change the commit records: the index, what the command stages, and new files whole. */
52async function collectDiff($: EngineInterface, plan: CommitPlan, cwd: string): Promise<string> {
53  const hasHead = (await $.process.run(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'], { cwd, timeoutMs: 20_000 })).exitCode === 0
54  const { diffs, untracked } = diffCommands(plan, hasHead)
55  const parts: string[] = []
56  for (const argv of diffs) parts.push(await git($, argv, cwd))
57  const files = untracked === undefined ? [] : (await git($, untracked, cwd)).split('\n').filter(Boolean)
58  for (const file of files.slice(0, MAX_NEW_FILES)) parts.push(await git($, newFileDiff(file), cwd, [0, 1]))
59  const diff = parts.filter(p => p.trim() !== '').join('\n')
60  if (diff.length > MAX_DIFF_CHARS) throw new Error(`the diff is over ${MAX_DIFF_CHARS} characters`)
61  return diff
62}
63
64/**
65 * gemini-core builds the request and reads each answer; the request is sent
66 * here, again after a 503 while it allows, and with the next key after a 429
67 * or a key error.
68 */
69async function askGemini($: EngineInterface, body: Record<string, unknown>): Promise<Asked> {
70  const prepared = await $.gemini.request({ consumer: CONSUMER, body })
71  if ('error' in prepared) return prepared
72  const started = await $.clock.now()
73  let http = prepared.http
74  for (let attempt = 1; ; attempt++) {
75    const r = await $.http.fetch(http.url, http.init)
76    const read = await $.gemini.read({ http, status: r.status, ok: r.ok, text: r.text, attempt, elapsedMs: (await $.clock.now()) - started, deadlineMs: DEADLINE_MS })
77    if ('answer' in read) return { answer: read.answer, tier: prepared.tier }
78    if ('error' in read) return read
79    if ('next' in read) http = read.next
80    else await $.clock.sleep(read.retryInMs)
81  }
82}
83
84function notReviewed($: EngineInterface, state: State, reason: string): Verdict {
85  state.last = `not reviewed: ${reason}`
86  $.ui.log(`commit ran without a review: ${reason}`)
87  return { context: failedContext(reason) }
88}
89
90function verdictOf($: EngineInterface, state: State, tier: 'free' | 'paid', findings: readonly Finding[], files: number, summary: string): Verdict {
91  state.last = summary
92  const blockers = findings.filter(f => f.severity === 'blocker')
93  const minors = findings.filter(f => f.severity === 'minor')
94  $.ui.toast(tier === 'free' ? `${summary} · sent to Gemini free tier` : summary, { timeoutMs: 10_000 })
95  if (blockers.length > 0) {
96    $.ui.log(`commit stopped: ${summary}`)
97    return { deny: denyText(blockers, minors) }
98  }
99  // The context goes to the model, the log line to the person: neither reads the other's channel.
100  $.ui.log(passedLog(minors, files))
101  return { context: minors.length === 0 ? cleanContext(files) : minorContext(minors) }
102}
103
104/**
105 * Asks Gemini about the change. Anything that keeps the review from an
106 * answer lets the commit run with a note, as the user chose.
107 */
108async function review($: EngineInterface, state: State, config: Config, plan: CommitPlan, agentId: string | undefined): Promise<Verdict> {
109  try {
110    // Without a key no git runs and nothing is read.
111    if (!(await $.gemini.settings({ consumer: CONSUMER })).hasKey) return notReviewed($, state, 'no Gemini key: set GEMINI_API_KEY or the gemini-core apiKey option')
112    const diff = await collectDiff($, plan, resolveDir(await $.session.cwd(), plan.cwd))
113    if (diff.trim() === '') return {}
114    // A subagent's call sends no conversation: which transcript it would get was not verified.
115    const transcript = agentId === undefined ? renderTranscript(await $.session.messages(), config.maxInputChars) : undefined
116    const asked = await askGemini($, buildReviewBody(transcript, diff))
117    if ('error' in asked) return notReviewed($, state, asked.error)
118    const findings = parseFindings(asked.answer)
119    const files = fileCount(diff)
120    return verdictOf($, state, asked.tier, findings, files, summaryText(files, findings, asked.answer))
121  } catch (err) {
122    return notReviewed($, state, errorText(err))
123  }
124}
125
126/** Adds a note the model reads after the tool's result; an error or a denial is left as it is. */
127function withContext(r: ToolCallResult, text: string): ToolCallResult {
128  if (r.deny !== undefined || r.isError === true) return r
129  return { ...r, context: [...(r.context ?? []), text] }
130}
131
132async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
133  const command = parseCommand(args)
134  if (command.kind === 'error') return command.text
135  if (command.kind === 'reset') {
136    await $.store.delete(ENABLED_KEY)
137    return RESET_TEXT
138  }
139  if (command.kind === 'set') return storeEnabled($, command.enabled)
140  return statusText(await isEnabled($), await $.gemini.settings({ consumer: CONSUMER }), state.last)
141}
142
143export const register: Register = (on, options) => {
144  const config = configFrom(options)
145  const state: State = {}
146
147  on('session.start', async ($, e, next) => {
148    const r = await next(e)
149    await $.gemini.enroll({ consumer: CONSUMER, defaultModel: DEFAULT_MODEL })
150    await $.command.register({
151      name: 'gemini-review',
152      description: 'Gemini commit review: status, on, off, reset; /gemini-core sets the model, thinking and tier (gemini-review)',
153      argumentHint: '[on | off | reset]',
154    })
155    return r
156  })
157
158  on('command.run', { command: 'gemini-review' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
159
160  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
161    const plan = findCommit(e.command)
162    if (plan === undefined || !(await isEnabled($))) return next(e)
163    if (plan.skip) {
164      state.last = `skipped by the model (${SKIP_VARIABLE}=1)`
165      $.ui.log(`commit ran without a review: the model used ${SKIP_VARIABLE}=1`)
166      return next(e)
167    }
168    if (plan.before.length > 0) {
169      state.last = 'stopped: git commit came after other commands in one call'
170      return { deny: combinedText(plan.before) }
171    }
172    const verdict = await review($, state, config, plan, e.agentId)
173    if (verdict.deny !== undefined) return { deny: verdict.deny }
174    const r = await next(e)
175    return verdict.context === undefined ? r : withContext(r, verdict.context)
176  })
177}
178
hooks/command.ts 46 lines
1/** The setting /gemini-review changes, and the reading of its argument. */
2import type { EngineInterface } from 'claude-code'
3
4/** What gemini-core says this mod runs with. */
5export type GeminiSettings = Awaited<ReturnType<EngineInterface['gemini']['settings']>>
6
7export type Command = { kind: 'status' } | { kind: 'reset' } | { kind: 'set'; enabled: boolean } | { kind: 'error'; text: string }
8
9export const USAGE = 'expects on, off, or reset; /gemini-core sets the model, the thinking level and the tier'
10
11/** The store key of the on/off setting. */
12export const ENABLED_KEY = 'enabled'
13
14const WORDS: Record<string, Command> = {
15  '': { kind: 'status' },
16  status: { kind: 'status' },
17  reset: { kind: 'reset' },
18  on: { kind: 'set', enabled: true },
19  off: { kind: 'set', enabled: false },
20}
21
22/** Reads the argument of /gemini-review. */
23export function parseCommand(args: string): Command {
24  return WORDS[args.trim()] ?? { kind: 'error', text: USAGE }
25}
26
27/** What `on` answers while gemini-core has no key; nothing is stored. */
28export const NO_KEY_ON = 'still off: gemini-core has no Gemini key. Set GEMINI_API_KEY or the gemini-core apiKey option, restart Claude Code, then run /gemini-review on'
29
30/** What `reset` answers: the review is off until it is turned on. */
31export const RESET_TEXT = 'off: back to the default; /gemini-review on turns it on'
32
33/** The line /gemini-review prints for a change. */
34export function changeText(enabled: boolean): string {
35  return enabled ? 'on: every commit the model makes is reviewed' : 'off: commits run without a review'
36}
37
38/** The status of /gemini-review, with what gemini-core says it runs with. */
39export function statusText(enabled: boolean, s: GeminiSettings, last: string | undefined): string {
40  const key = s.hasKey ? 'key set' : 'no key: set GEMINI_API_KEY or the gemini-core apiKey option'
41  const lines = [`${enabled ? 'on' : 'off'} · ${s.model} · thinking ${s.thinking ?? 'model default'} · ${s.tier} tier · ${key}`]
42  if (!enabled) lines.push('off until /gemini-review on; the model, the thinking level and the tier are /gemini-core settings')
43  if (last !== undefined) lines.push(`last: ${last}`)
44  return lines.join('\n')
45}
46
hooks/commit.ts 260 lines
1/**
2 * Reads a Bash command: whether it runs `git commit`, in which directory,
3 * what it stages on the way, and the git commands that show that change.
4 */
5
6/** What a commit in a command will record, as far as the command says. */
7export type CommitPlan = {
8  /** A `cd <dir>` before the commit or `git -C <dir>`; relative to the shell's directory. */
9  cwd?: string
10  /** `GEMINI_REVIEW_SKIP=1` in front of the commit. */
11  skip: boolean
12  /** Every tracked change: `commit -a`, `add -u`, `add -A`. */
13  tracked: boolean
14  /** Every untracked file: `add -A`. */
15  untracked: boolean
16  /** Paths `git add` names before the commit. */
17  paths: string[]
18  /** The commit's own pathspec: git then records these paths from the working tree and nothing else. */
19  only: string[]
20  /**
21   * The commands before the commit other than `cd` and `git add`. They run
22   * after the review reads the diff, so a file they change is not in it.
23   */
24  before: string[]
25}
26
27export const SKIP_VARIABLE = 'GEMINI_REVIEW_SKIP'
28
29/** Drops the body and the end line of every heredoc, so text inside a commit message is not read as a command. */
30export function stripHeredocs(text: string): string {
31  const out: string[] = []
32  let end: string | undefined
33  for (const line of text.split('\n')) {
34    if (end !== undefined) {
35      if (line.trim() === end) end = undefined
36      continue
37    }
38    out.push(line)
39    end = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(line)?.[2]
40  }
41  return out.join('\n')
42}
43
44type Scan = { segments: string[][]; tokens: string[]; token: string; started: boolean; quote: string | undefined }
45
46function endToken(s: Scan): void {
47  if (s.started) s.tokens.push(s.token)
48  s.token = ''
49  s.started = false
50}
51
52function endSegment(s: Scan): void {
53  endToken(s)
54  if (s.tokens.length > 0) s.segments.push(s.tokens)
55  s.tokens = []
56}
57
58/** One character inside quotes; returns how many characters it used. */
59function quoted(s: Scan, text: string, i: number): number {
60  const c = text[i] ?? ''
61  if (c === s.quote) s.quote = undefined
62  else if (c === '\\' && s.quote === '"' && i + 1 < text.length) {
63    s.token += text[i + 1]
64    return 2
65  } else s.token += c
66  return 1
67}
68
69const SEPARATORS = new Set([';', '&', '|', '\n', '(', ')'])
70
71/** An `&` that belongs to a redirection (`2>&1`, `<&3`, `&>out`), not a separator. */
72function isRedirectAmp(text: string, i: number): boolean {
73  return text[i] === '&' && (text[i - 1] === '>' || text[i - 1] === '<' || text[i + 1] === '>')
74}
75
76/** One character outside quotes; returns how many characters it used. */
77function unquoted(s: Scan, text: string, i: number): number {
78  const c = text[i] ?? ''
79  if (SEPARATORS.has(c) && !isRedirectAmp(text, i)) endSegment(s)
80  else if (c === ' ' || c === '\t') endToken(s)
81  else if (c === '"' || c === "'") {
82    s.quote = c
83    s.started = true
84  } else if (c === '\\' && i + 1 < text.length) {
85    s.token += text[i + 1]
86    s.started = true
87    return 2
88  } else {
89    s.token += c
90    s.started = true
91  }
92  return 1
93}
94
95/** A redirection such as `>out`, `2>&1`, `&>out`, `<in`; with a bare operator the target is the next word. */
96const REDIRECT = /^(?:\d*|&)[<>]/
97const BARE_REDIRECT = /^(?:\d*|&)[<>]+$/
98
99function withoutRedirections(words: readonly string[]): string[] {
100  const kept: string[] = []
101  for (let i = 0; i < words.length; i++) {
102    const word = words[i] ?? ''
103    if (!REDIRECT.test(word)) kept.push(word)
104    else if (BARE_REDIRECT.test(word)) i++
105  }
106  return kept
107}
108
109/** The simple commands of a command line, each as its words with the quotes and redirections removed. */
110export function splitCommand(text: string): string[][] {
111  const s: Scan = { segments: [], tokens: [], token: '', started: false, quote: undefined }
112  const source = stripHeredocs(text)
113  for (let i = 0; i < source.length; ) i += s.quote === undefined ? unquoted(s, source, i) : quoted(s, source, i)
114  endSegment(s)
115  return s.segments.map(withoutRedirections).filter(words => words.length > 0)
116}
117
118type Git = { env: string[]; dir?: string; sub: string; args: string[] }
119
120/** The leading variable assignments (`NAME=value`) of a simple command. */
121function assignments(words: readonly string[]): string[] {
122  const env: string[] = []
123  while (/^[A-Za-z_]\w*=/.test(words[env.length] ?? '')) env.push(words[env.length] ?? '')
124  return env
125}
126
127/** git's own options before the subcommand, from `start`: the `-C` directory and where the subcommand is. */
128function gitOptions(words: readonly string[], start: number): { dir?: string; at: number } {
129  let i = start
130  let dir: string | undefined
131  while ((words[i] ?? '').startsWith('-')) {
132    const option = words[i++]
133    if (option === '-C') dir = words[i++]
134    else if (option === '-c') i++
135  }
136  return { ...(dir === undefined ? {} : { dir }), at: i }
137}
138
139/** A `git` call with its leading variable assignments, its `-C` directory, subcommand and arguments. */
140function gitCall(words: readonly string[]): Git | undefined {
141  const env = assignments(words)
142  if (words[env.length] !== 'git') return undefined
143  const { dir, at } = gitOptions(words, env.length + 1)
144  const sub = words[at]
145  return sub === undefined ? undefined : { env, ...(dir === undefined ? {} : { dir }), sub, args: words.slice(at + 1) }
146}
147
148/** Options of `git commit` whose value is the next word. */
149const COMMIT_VALUES = new Set(['-m', '-F', '-C', '-c', '-t', '--message', '--file', '--author', '--date', '--template', '--reuse-message', '--reedit-message', '--fixup', '--squash', '--cleanup'])
150
151/** Commit options after which nothing is recorded. */
152const NO_RECORD = new Set(['--help', '-h', '--dry-run'])
153
154type Staged = { tracked: boolean; untracked: boolean; paths: string[]; only: string[] }
155
156/** One option word of `git commit`; returns how many words it used. */
157function commitOption(word: string, next: string | undefined, into: Staged): number {
158  if (word === '--all') into.tracked = true
159  if (COMMIT_VALUES.has(word)) return next === undefined ? 1 : 2
160  if (/^-[a-zA-Z]+$/.test(word)) {
161    if (word.includes('a')) into.tracked = true
162    if (/[mFCct]$/.test(word)) return 2
163  }
164  return 1
165}
166
167/** What `git commit <args>` records beyond the index; undefined when it records nothing. */
168function commitArgs(args: readonly string[], into: Staged): boolean {
169  for (let i = 0; i < args.length; ) {
170    const word = args[i] ?? ''
171    if (NO_RECORD.has(word)) return false
172    if (word === '--') {
173      into.only.push(...args.slice(i + 1))
174      break
175    }
176    if (word.startsWith('-')) i += commitOption(word, args[i + 1], into)
177    else {
178      into.only.push(word)
179      i++
180    }
181  }
182  return true
183}
184
185/** What `git add <args>` stages. */
186function addArgs(args: readonly string[], into: Staged): void {
187  for (const word of args) {
188    if (word === '-A' || word === '--all') {
189      into.tracked = true
190      into.untracked = true
191    } else if (word === '-u' || word === '--update') into.tracked = true
192    else if (!word.startsWith('-')) into.paths.push(word)
193  }
194}
195
196/** The plan for a `git commit` call, given what came before it; undefined when it records nothing. */
197function commitPlan(git: Git, staged: Staged, cwd: string | undefined, before: string[]): CommitPlan | undefined {
198  if (!commitArgs(git.args, staged)) return undefined
199  const dir = git.dir ?? cwd
200  return { ...staged, before, skip: git.env.includes(`${SKIP_VARIABLE}=1`), ...(dir === undefined ? {} : { cwd: dir }) }
201}
202
203/** The first `git commit` of a command and what it will record, or undefined when the command commits nothing. */
204export function findCommit(command: string): CommitPlan | undefined {
205  const staged: Staged = { tracked: false, untracked: false, paths: [], only: [] }
206  const before: string[] = []
207  let cwd: string | undefined
208  for (const words of splitCommand(command)) {
209    const git = gitCall(words)
210    if (git?.sub === 'commit') return commitPlan(git, staged, cwd, before)
211    if (git?.sub === 'add') addArgs(git.args, staged)
212    else if (words[0] === 'cd' && words.length === 2) cwd = words[1]
213    else before.push(git === undefined ? (words[0] ?? '') : `git ${git.sub}`)
214  }
215  return undefined
216}
217
218/** Why a commit that follows other commands in one call is refused, so the model commits in a call of its own. */
219export function combinedText(before: readonly string[]): string {
220  const names = [...new Set(before)].map(name => `\`${name}\``).join(', ')
221  return `gemini-review stopped this command before it ran: it runs ${names} before git commit, and the review reads the change before the command runs, so what those steps change would not be reviewed. Run those steps in one Bash call, then git commit (with cd and git add if needed) in a Bash call of its own.`
222}
223
224const DIFF = ['git', 'diff', '--no-color', '--no-ext-diff']
225
226/**
227 * The diffs of tracked files the commit records. A path the command stages
228 * is read from the working tree, never from the index, because the index
229 * still holds its older content until `git add` runs. Without a HEAD, the
230 * working tree is read as a diff over the index.
231 */
232function trackedDiffs(plan: CommitPlan, hasHead: boolean): string[][] {
233  const cached = [...DIFF, '--cached']
234  // `git diff HEAD -- p` holds the index too, so it replaces `--cached -- p`.
235  const tree = (paths: readonly string[]) => (hasHead ? [[...DIFF, 'HEAD', '--', ...paths]] : [[...cached, '--', ...paths], [...DIFF, '--', ...paths]])
236  if (plan.only.length > 0) return tree(plan.only)
237  if (plan.tracked) return tree([':/'])
238  if (plan.paths.length === 0) return [cached]
239  if (!hasHead) return [cached, [...DIFF, '--', ...plan.paths]]
240  return [[...cached, '--', ':/', ...plan.paths.map(p => `:(exclude)${p}`)], ...tree(plan.paths)]
241}
242
243/** The git commands whose output is the change the commit records, and the listing of its new files. */
244export function diffCommands(plan: CommitPlan, hasHead: boolean): { diffs: string[][]; untracked?: string[] } {
245  const diffs = trackedDiffs(plan, hasHead)
246  const listing = ['git', 'ls-files', '--others', '--exclude-standard']
247  if (plan.untracked) return { diffs, untracked: listing }
248  return plan.paths.length > 0 ? { diffs, untracked: [...listing, '--', ...plan.paths] } : { diffs }
249}
250
251/** The diff of one untracked file against nothing. */
252export function newFileDiff(path: string): string[] {
253  return [...DIFF, '--no-index', '--', '/dev/null', path]
254}
255
256/** How many files a diff touches. */
257export function fileCount(diff: string): number {
258  return diff.split('\n').filter(line => line.startsWith('diff --git ')).length
259}
260
hooks/config.ts 24 lines
1/** The plugin options with their defaults. */
2import type { PluginOptions } from 'claude-code'
3
4export type Config = { maxInputChars: number }
5
6export const DEFAULTS: Config = { maxInputChars: 2_000_000 }
7
8/** The plugin name gemini-core knows this mod by, and the model it uses until /gemini-core sets another. */
9export const CONSUMER = 'gemini-review'
10export const DEFAULT_MODEL = 'gemini-3.8-flash'
11
12/** No new Gemini attempt starts once this much has passed; the Bash hook ran 42.5 s without a limit (measured). */
13export const DEADLINE_MS = 60_000
14
15function numberOption(options: PluginOptions, key: string, fallback: number, min: number, max: number): number {
16  const value = options[key]
17  return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : fallback
18}
19
20/** The plugin options, each out-of-range or missing one replaced by its default. */
21export function configFrom(options: PluginOptions): Config {
22  return { maxInputChars: numberOption(options, 'maxInputChars', DEFAULTS.maxInputChars, 10_000, 4_000_000) }
23}
24
hooks/review.ts 128 lines
1/** The review Gemini is asked for, its answer, and what the model and the user read. */
2import { SKIP_VARIABLE } from './commit.ts'
3import type { EngineInterface } from 'claude-code'
4
5/** Gemini's answer as gemini-core reads it. */
6export type Answer = Extract<Awaited<ReturnType<EngineInterface['gemini']['read']>>, { answer: unknown }>['answer']
7
8export type Severity = 'blocker' | 'minor'
9
10export type Finding = { severity: Severity; file: string; line?: number; message: string }
11
12const SEVERITIES: readonly Severity[] = ['blocker', 'minor']
13
14export const REVIEW_TASK = `You review a git commit a coding agent (Claude, in Claude Code) is about to make for a user. Below are the conversation so far, which says what the user asked for, and the diff the commit records.
15
16Report problems in the diff only, never in code the diff does not change. For each, give its severity:
17- blocker: a bug the change introduces, data loss, a security hole, a secret or credential in the diff (an API key, a password, a private key, a token, a .env file), or a change that contradicts what the user asked for.
18- minor: anything else worth saying: style, naming, a missing test, a small risk.
19Report a blocker only with evidence in the diff or the conversation; when unsure, it is minor. An empty list is the right answer for a sound commit. Write each message in the language of the conversation, in one or two sentences, naming the fix.`
20
21/** The generateContent body asking for findings on the diff, in a schema Gemini must follow. */
22export function buildReviewBody(transcript: string | undefined, diff: string): Record<string, unknown> {
23  const conversation = transcript === undefined ? 'The conversation is not available; only the diff is.' : `The conversation:\n\n${transcript}`
24  return {
25    contents: [{ role: 'user', parts: [{ text: `${REVIEW_TASK}\n\n${conversation}\n\nThe diff the commit records:\n\n${diff}` }] }],
26    generationConfig: {
27      responseMimeType: 'application/json',
28      responseSchema: {
29        type: 'OBJECT',
30        properties: {
31          findings: {
32            type: 'ARRAY',
33            items: {
34              type: 'OBJECT',
35              properties: {
36                severity: { type: 'STRING', enum: [...SEVERITIES] },
37                file: { type: 'STRING' },
38                line: { type: 'INTEGER' },
39                message: { type: 'STRING' },
40              },
41              required: ['severity', 'file', 'message'],
42            },
43          },
44        },
45        required: ['findings'],
46      },
47    },
48  }
49}
50
51function isRecord(value: unknown): value is Record<string, unknown> {
52  return typeof value === 'object' && value !== null && !Array.isArray(value)
53}
54
55function findingOf(value: unknown): Finding {
56  if (!isRecord(value)) throw new Error('a finding is not an object')
57  const severity = SEVERITIES.find(s => s === value.severity)
58  if (severity === undefined) throw new Error(`unknown severity ${JSON.stringify(value.severity)}`)
59  if (typeof value.file !== 'string' || typeof value.message !== 'string' || value.message.trim() === '') throw new Error('a finding has no file or message')
60  const line = typeof value.line === 'number' && Number.isInteger(value.line) && value.line > 0 ? value.line : undefined
61  return { severity, file: value.file, message: value.message.trim(), ...(line === undefined ? {} : { line }) }
62}
63
64/** Reads Gemini's findings; an answer outside the schema throws. */
65export function parseFindings(answer: Answer): Finding[] {
66  if (answer.finishReason === 'MAX_TOKENS') throw new Error('the review hit the output token limit')
67  let value: unknown
68  try {
69    value = JSON.parse(answer.text)
70  } catch {
71    throw new Error('the review is not JSON')
72  }
73  if (!isRecord(value) || !Array.isArray(value.findings)) throw new Error('the review has no findings list')
74  return value.findings.map(findingOf)
75}
76
77function where(f: Finding): string {
78  return f.line === undefined ? f.file : `${f.file}:${f.line}`
79}
80
81function bullets(findings: readonly Finding[]): string {
82  return findings.map(f => `- ${where(f)}: ${f.message}`).join('\n')
83}
84
85/** What the model reads when the commit is stopped. */
86export function denyText(blockers: readonly Finding[], minors: readonly Finding[]): string {
87  const notes = minors.length === 0 ? '' : `\n\nMinor notes, not blocking:\n${bullets(minors)}`
88  return [
89    `gemini-review stopped this commit: Gemini found ${blockers.length} blocking problem(s) in the change.`,
90    bullets(blockers),
91    `Fix them, stage the fix and run the commit again. If a finding is wrong, tell the user why, then run the same command with the prefix ${SKIP_VARIABLE}=1 (for example: ${SKIP_VARIABLE}=1 git commit ...).${notes}`,
92  ].join('\n')
93}
94
95/** What the model reads after a commit that passed with minor notes. */
96export function minorContext(minors: readonly Finding[]): string {
97  return `gemini-review let this commit run with ${minors.length} minor note(s):\n${bullets(minors)}\nThey did not stop the commit; tell the user about the ones worth fixing.`
98}
99
100/** What the model reads after a commit Gemini found nothing in, so it knows the review ran. */
101export function cleanContext(files: number): string {
102  return `gemini-review: Gemini reviewed the ${files} file(s) of this commit and found nothing to report.`
103}
104
105/**
106 * The transcript line after a commit that passed: the findings alone, without the instruction the model
107 * reads. The engine adds the mod name.
108 */
109export function passedLog(minors: readonly Finding[], files: number): string {
110  if (minors.length === 0) return `commit reviewed: ${files} file(s), nothing to report`
111  return `commit reviewed with ${minors.length} minor note(s): ${minors.map(f => `${f.file}${f.line === undefined ? '' : `:${f.line}`}: ${f.message}`).join(' · ')}`
112}
113
114/** What the model reads after a commit that ran without a review. */
115export function failedContext(reason: string): string {
116  return `gemini-review could not review this commit and let it run: ${reason}`
117}
118
119function tokens(n: number): string {
120  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
121}
122
123/** `reviewed 3 files · 1 blocker, 2 minor · 12k in, 1k out` */
124export function summaryText(files: number, findings: readonly Finding[], answer: Answer): string {
125  const blockers = findings.filter(f => f.severity === 'blocker').length
126  return `reviewed ${files} file(s) · ${blockers} blocker, ${findings.length - blockers} minor · ${tokens(answer.inputTokens)} in, ${tokens(answer.outputTokens)} out`
127}
128
hooks/transcript.ts 66 lines
1/** The conversation as Gemini reads it, cut to a character limit. */
2import type { SessionMessage, ToolUseSummary } from 'claude-code'
3
4/** The output of each tool call, by tool_use_id: its result block, else the call's own text. */
5function outputs(messages: readonly SessionMessage[]): Map<string, { text: string; isError: boolean }> {
6  const out = new Map<string, { text: string; isError: boolean }>()
7  for (const m of messages) {
8    for (const use of m.toolUses) out.set(use.tool_use_id, { text: use.text ?? '', isError: use.isError === true })
9  }
10  for (const m of messages) {
11    for (const r of m.toolResults ?? []) out.set(r.tool_use_id, { text: r.text, isError: r.isError })
12  }
13  return out
14}
15
16/** Cuts a text to about `max` characters: its head and its tail around one note. */
17export function clip(text: string, max: number): string {
18  if (text.length <= max) return text
19  const half = Math.max(0, Math.floor(max / 2))
20  return `${text.slice(0, half)}\n[… ${text.length - 2 * half} chars omitted …]\n${text.slice(text.length - half)}`
21}
22
23function callLines(use: ToolUseSummary, output: { text: string; isError: boolean } | undefined, cap: number): string[] {
24  const status = output?.isError === true ? 'error' : 'output'
25  return [`  [call] ${use.tool} ${JSON.stringify(use.input) ?? '{}'}`, `  ${status}: ${clip(output?.text ?? '', cap)}`]
26}
27
28function render(messages: readonly SessionMessage[], byUse: ReadonlyMap<string, { text: string; isError: boolean }>, cap: number): string {
29  const lines: string[] = []
30  messages.forEach((m, i) => {
31    lines.push(`#${i + 1} ${m.role}: ${m.text}`)
32    for (const use of m.toolUses) lines.push(...callLines(use, byUse.get(use.tool_use_id), cap))
33  })
34  return lines.join('\n')
35}
36
37/** The longest output length that makes the transcript fit, found by bisection. */
38function outputCap(fixed: number, lengths: readonly number[], max: number): number | undefined {
39  const size = (cap: number): number => fixed + lengths.reduce((sum, n) => sum + Math.min(n, cap + 40), 0)
40  if (size(0) > max) return undefined
41  let low = 0
42  let high = Math.max(0, ...lengths)
43  while (low < high) {
44    const mid = Math.ceil((low + high) / 2)
45    if (size(mid) <= max) low = mid
46    else high = mid - 1
47  }
48  return low
49}
50
51/**
52 * Every message in order, each tool call with its input and output. Over
53 * `maxChars`, the longest outputs are cut to one common length; a
54 * conversation over it without any output throws.
55 */
56export function renderTranscript(messages: readonly SessionMessage[], maxChars: number): string {
57  const byUse = outputs(messages)
58  const full = render(messages, byUse, Infinity)
59  if (full.length <= maxChars) return full
60  const lengths = messages.flatMap(m => m.toolUses.map(u => byUse.get(u.tool_use_id)?.text.length ?? 0))
61  const fixed = full.length - lengths.reduce((a, b) => a + b, 0)
62  const cap = outputCap(fixed, lengths, maxChars)
63  if (cap === undefined) throw new Error(`the conversation is over ${maxChars} characters even without tool output`)
64  return render(messages, byUse, cap)
65}
66