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…

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.
git commit in it (the commit skill included) is reviewed before it runs; any other command runs untouched.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.$.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.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.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.GEMINI_REVIEW_SKIP=1 in front;$.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.
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.
/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
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.
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.
/gemini-review on. Without a key it answers still off: gemini-core has no Gemini key and stays off./gemini-review. The first line reads on · <model> · thinking ... · <tier> tier · key set.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.
| Option | Default | What it sets |
|---|---|---|
maxInputChars | 2000000 | Characters 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.
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.
cd inside a subshell, variables in paths and globs that git expands are not resolved.$.session.messages() answers inside a subagent was not verified.$.session.messages() answers the newest 4096 messages.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
hooks/register.ts 178 lines1import 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}
178hooks/command.ts 46 lines1/** 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}
46hooks/commit.ts 260 lines1/**
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}
260hooks/config.ts 24 lines1/** 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}
24hooks/review.ts 128 lines1/** 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}
128hooks/transcript.ts 66 lines1/** 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