Before a git push triggers the automatic second-model review, shows its estimated token cost (green / amber / red) and lets you choose Review or Skip.

Install (Claude Code 2.1.287 or later): /plugin install review-cost --marketplace samuelbfernandes/moddeck
The toolkit's git pre-push hook runs an automatic second-model review (Codex by default) of your pushed diff. This mod shows the estimated token cost first and lets you choose Review or Skip. The level is always a word: Green (small), Amber (medium), Red (many).
Nothing to type. When Claude runs git push where the review would run (docs/THEORY_REVIEW.md or git config toolkit.theoryPaths), you see:
Codex review · about 36k tokens
● Amber ▰▰▰▰▰▰▱▱▱▱ 19k to 52k
1 file · 580 changed lines · about 34 KB
Largest: R/model.R +580 -0
Estimate, not a measurement.
Skip still pushes, marked UNREVIEWED.
Review with Codex before pushing?
Green only shows a one-line toast (Codex review · about 4.1k tokens · Green) and goes ahead. Review is first for Green/Amber; Skip is first for Red, so Enter does not spend many tokens. Esc cancels the push ("Push cancelled."). The choice reaches the hook via git config --local toolkit.theoryDecision <review|skip>:<seconds> (valid 10 minutes). No files are written.
Switch it on or off while you work: /review-cost on, /review-cost off; /review-cost alone says which. Off means a push is let through at once and nothing is estimated. The switch is remembered between sessions and mirrored in the shared state value review-cost.isOff (boolean, default false), which the moddeck side bar reads with $.state.get({ plugin: 'review-cost', key: 'isOff' }). An unknown word changes nothing.
Turn off completely: /plugin disable review-cost. Skip one review without this mod: SKIP_THEORY_REVIEW=1 git push.
greenMaxTokens (25000), redMinTokens (60000), askWhen (amber-red or always), inputUsdPerMTok / outputUsdPerMTok (0 = no dollars; set both to add "About $0.10 at API prices"; nothing is ever guessed), coloredBanner (on). Diff cap: git config toolkit.theoryMaxBytes (default 120000).
toolkit.theoryUsage) the estimate is scaled by your history.coloredBanner off if it ever looks wrong).git push (also git -C dir push, cd x && git push); not bash -c "git push", aliases or scripts. --dry-run, branch deletions, cd ~/x and $VAR paths are left alone.git is spawned); how the dialog looks was only tested with a mocked engine.hooks/register.tsx 168 lines1// review-cost: before `git push` triggers the automatic second-model review, show what it
2// is expected to cost (green / amber / red) and let the person choose Review or Skip.
3//
4// WHAT IT TOUCHES: nothing but git config. The choice is handed to the shell pre-push hook as
5// `git config --local toolkit.theoryDecision <review|skip>:<epochSeconds>`; the hook honours it
6// for 10 minutes and clears it. No files, no network, only `git` is spawned.
7//
8// FAIL OPEN: this is a convenience, never a gate. If anything here throws (a git call, the
9// estimate, the dialog code) the `.catch` lets the push through untouched: `next(e)` is called
10// exactly once on every path (when `next` had already been called the same `next(e)` replays
11// that result instead of running the push twice). The ONE deliberate refusal is the person
12// cancelling the question (Esc, dismissed dialog, free text): the push is denied with
13// CANCEL_TEXT and they run it again. A headless run (no surface to ask on) is not asked.
14import { atom, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17import { findPush, joinPath } from './command'
18import { readConfig } from './config'
19import { estimate } from './estimate'
20import type { Level } from './estimate'
21import { ASK_HEADER, CANCEL_TEXT, askOptions, question, toastText } from './format'
22import { planReview, writeDecision } from './git'
23import type { Run } from './git'
24
25const GIT_TIMEOUT_MS = 20_000
26const BANNER = /^\u25CF (Green|Amber|Red)\b/
27const COMMAND = 'review-cost'
28
29/** Mirror of the persisted on/off switch ($.store 'off'); other mods read it with $.state.get. */
30const isOff = atom({ plugin: 'review-cost', key: 'isOff' } as const, false)
31
32/** The one place the switch changes. */
33async function setSwitch($: EngineInterface, off: boolean): Promise<void> {
34 if (off) await $.store.set('off', true)
35 else await $.store.delete('off')
36 await update($, isOff, () => off)
37}
38
39async function storedOff($: EngineInterface): Promise<boolean> {
40 try {
41 return (await $.store.get('off')) === true
42 } catch {
43 return false
44 }
45}
46
47/** `/review-cost [on|off]`: nothing says the state; an unknown word changes nothing. */
48async function runCommand($: EngineInterface, args: string): Promise<string> {
49 const word = args.trim().toLowerCase().replace(/\.$/, '').trim()
50 if (word === '') return (await storedOff($)) ? 'Review cost · off' : 'Review cost · on'
51 if (word !== 'on' && word !== 'off') return `Review cost · unknown word "${args.trim()}" · use /review-cost on or /review-cost off, nothing changed`
52 try {
53 await setSwitch($, word === 'off')
54 } catch {
55 return 'Review cost · that did not work, nothing changed'
56 }
57 return word === 'on' ? 'Review cost · on' : 'Review cost · off · pushes are no longer estimated · /review-cost on turns it back on'
58}
59
60export const register: Register = (on, options) => {
61 const cfg = readConfig(options)
62
63 on('session.start', async ($, e, next) => {
64 try {
65 const stored = (await $.store.get('off')) === true // the switch outlives the session
66 await update($, isOff, () => stored)
67 } catch {
68 // the switch then reads as on
69 }
70 try {
71 await $.command.register({
72 name: COMMAND,
73 description: 'Switch the push review-cost estimate on or off: /review-cost [on|off]',
74 argumentHint: '[on|off]',
75 immediate: true,
76 })
77 } catch {
78 // fail-open: without the command the mod still works
79 }
80 return next(e)
81 })
82
83 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, e.args) })).catch(() => ({
84 text: 'Review cost · that did not work, nothing changed',
85 }))
86
87 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
88 if (await storedOff($)) return next(e) // switched off: estimate nothing
89 const push = findPush(e.command)
90 if (push === undefined || push.skipEnv) return next(e)
91 if ((await $.env.get('SKIP_THEORY_REVIEW')) === '1') return next(e)
92
93 const session = await safe(() => $.session.cwd())
94 const dir = session === undefined ? push.dir : push.dir === undefined ? session : joinPath(session, push.dir)
95 const run: Run = (argv, cwd) => $.process.run(argv, { cwd, timeoutMs: GIT_TIMEOUT_MS })
96
97 const planned = await planReview(run, dir)
98 if (!('plan' in planned)) return next(e) // no review would run: stay out of the way
99 const { plan } = planned
100 const est = estimate({
101 diffBytes: plan.diffBytes,
102 files: plan.files.length,
103 reviewer: plan.reviewer,
104 history: plan.history,
105 thresholds: cfg.thresholds,
106 })
107
108 let choice: 'review' | 'skip'
109 if (est.level === 'green' && !cfg.askAlways) {
110 $.ui.toast(toastText(plan.reviewer, est), { timeoutMs: 6000 }) // small: no interruption
111 choice = 'review'
112 } else {
113 const surfaces = await safe(() => $.session.surfaces())
114 if (surfaces !== undefined && surfaces.length === 0) return next(e) // headless: no one to ask
115 const answer = await ask($, question(plan, est, cfg.thresholds.redMin, cfg.prices), est.level)
116 if (answer === undefined) return { deny: CANCEL_TEXT }
117 choice = answer
118 }
119
120 const now = await $.clock.now()
121 const isWritten = await safe(() => writeDecision(run, plan.root, choice, Math.floor(now / 1000)))
122 if (choice === 'skip' && !isWritten) $.ui.toast('Could not record Skip. The review will run.', { timeoutMs: 8000 })
123 return next(e)
124 }).catch(($, e, next) => next(e))
125
126 // The level line again, colored, above our dialog. A tree the surface cannot draw falls back
127 // to the engine's own dialog, which still carries the level word and the bar in its text.
128 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
129 if (!cfg.coloredBanner) return next(e)
130 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
131 const first = (e.props.questions[0] ?? {}) as { question?: unknown; header?: unknown }
132 const text = typeof first.question === 'string' ? (first.question.split('\n')[1] ?? '') : ''
133 const level = BANNER.exec(text)?.[1]
134 if (first.header !== ASK_HEADER || level === undefined) return next(e)
135 const { Box, Text } = $.ui.resolve(e)
136 const color = level === 'Green' ? 'success' : level === 'Amber' ? 'warning' : 'error'
137 return (
138 <Box flexDirection="column">
139 <Text bold color={color}>
140 {text}
141 </Text>
142 {await next(e)}
143 </Box>
144 )
145 }).catch(($, e, next) => next(e))
146}
147
148/** The person's choice, or undefined for cancel / dismissed / free text. */
149async function ask($: EngineInterface, text: string, level: Level) {
150 const o = askOptions(level)
151 try {
152 const answer = await $.ui.ask(text, { options: o.options, header: ASK_HEADER })
153 if (answer === o.review) return 'review' as const
154 if (answer === o.skip) return 'skip' as const
155 return undefined
156 } catch {
157 return undefined
158 }
159}
160
161async function safe<T>(f: () => T | Promise<T>): Promise<T | undefined> {
162 try {
163 return await f()
164 } catch {
165 return undefined
166 }
167}
168hooks/command.ts 150 lines1// Finds a real `git push` in a Bash command line. A small quote-aware lexer, not a shell:
2// it splits on ; && || | & newline and parentheses, so `echo "git push"`, `xgit push`,
3// `git pushall` and `git stash push` are not pushes, while `cd x && git push`,
4// `git -C "C:/x y" push origin main` and `SKIP_THEORY_REVIEW=1 git push` are.
5// Not seen (honest limit): `bash -c "git push"`, aliases, scripts that push.
6
7export type Push = {
8 dir: string | undefined // directory the push runs in, from `cd` / `git -C`; undefined = the session's
9 skipEnv: boolean // the command itself sets SKIP_THEORY_REVIEW=1
10}
11
12const SEPARATORS = new Set([';', '&', '|', '(', ')', '\n', '\r'])
13
14/** Words per simple command. Quotes group, a backslash escapes only space and quotes (Windows paths keep theirs). */
15export function segments(command: string): string[][] {
16 const out: string[][] = []
17 let words: string[] = []
18 let word = ''
19 let isWord = false
20 let quote: '"' | "'" | undefined
21 const endWord = () => {
22 if (isWord) words.push(word)
23 word = ''
24 isWord = false
25 }
26 const endSegment = () => {
27 endWord()
28 if (words.length) out.push(words)
29 words = []
30 }
31 for (let i = 0; i < command.length; i++) {
32 const c = command[i]!
33 if (quote) {
34 if (c === quote) quote = undefined
35 else if (c === '\\' && quote === '"' && (command[i + 1] === '"' || command[i + 1] === '\\')) word += command[++i]
36 else word += c
37 } else if (c === '"' || c === "'") {
38 quote = c
39 isWord = true
40 } else if (c === '\\' && /[ "']/.test(command[i + 1] ?? '')) {
41 word += command[++i]
42 isWord = true
43 } else if (SEPARATORS.has(c)) endSegment()
44 else if (c === ' ' || c === '\t') endWord()
45 else {
46 word += c
47 isWord = true
48 }
49 }
50 endSegment()
51 return out
52}
53
54const ABSOLUTE = /^([A-Za-z]:)?[\\/]/
55const UNKNOWN_DIR = /^[~-]|[$`%]/ // `~`, `-`, variables: cannot be resolved without a shell
56
57/** Joins two path pieces with forward slashes; an absolute `rel` replaces `base`. */
58export function joinPath(base: string | undefined, rel: string): string {
59 const r = rel.replace(/\\/g, '/')
60 if (base === undefined || ABSOLUTE.test(rel)) return r
61 return `${base.replace(/\/+$/, '')}/${r}`
62}
63
64const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
65const KEYWORDS = new Set(['{', '}', '!', 'then', 'do', 'else', 'time', 'command', 'exec'])
66const GIT_PROGRAM = /(^|[\\/])git(\.exe)?$/i
67const GLOBAL_WITH_VALUE = new Set(['-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--super-prefix', '--config-env'])
68const PUSH_WITH_VALUE = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
69
70type Parsed = { push: boolean; dir: string | undefined; skipEnv: boolean; lost: boolean }
71
72function isSkipAssign(word: string) {
73 return word === 'SKIP_THEORY_REVIEW=1'
74}
75
76/** Leading `VAR=x`, `env [-i] VAR=x`, `time`, `command`: returns the index of the program and whether the skip variable was set. */
77function programAt(words: string[]): { at: number; skipEnv: boolean } {
78 let at = 0
79 let skipEnv = false
80 for (;;) {
81 const w = words[at]
82 if (w === undefined) break
83 if (ASSIGN.test(w)) skipEnv ||= isSkipAssign(w)
84 else if (w === 'env' || KEYWORDS.has(w)) {
85 // `env` takes options and assignments before the program
86 while (w === 'env' && words[at + 1] !== undefined && (words[at + 1]!.startsWith('-') || ASSIGN.test(words[at + 1]!))) {
87 at++
88 skipEnv ||= isSkipAssign(words[at]!)
89 }
90 } else break
91 at++
92 }
93 return { at, skipEnv }
94}
95
96/** One simple command, given the directory earlier `cd`s left us in. */
97function parseSegment(words: string[], dir: string | undefined, lost: boolean): Parsed {
98 const none = (d = dir, l = lost): Parsed => ({ push: false, dir: d, skipEnv: false, lost: l })
99 const { at, skipEnv } = programAt(words)
100 const program = words[at]
101 if (program === undefined) return none()
102 if (program === 'cd' || program === 'pushd') {
103 const target = words.slice(at + 1).find(w => !w.startsWith('-') || w === '-')
104 if (target === undefined) return none()
105 return UNKNOWN_DIR.test(target) ? none(dir, true) : none(joinPath(dir, target))
106 }
107 if (!GIT_PROGRAM.test(program)) return none()
108
109 let d = dir
110 let i = at + 1
111 for (; i < words.length; i++) {
112 const w = words[i]!
113 if (w === '-C') {
114 const target = words[++i]
115 if (target === undefined) return none()
116 if (UNKNOWN_DIR.test(target)) return none(dir, true)
117 d = joinPath(d, target)
118 } else if (GLOBAL_WITH_VALUE.has(w)) i++
119 else if (!w.startsWith('-')) break
120 }
121 if (words[i] !== 'push') return none()
122 return pushArgs(words.slice(i + 1), d, skipEnv, lost)
123}
124
125/** The arguments after `push`: dry runs and deletions are not pushes of new work. */
126function pushArgs(args: string[], dir: string | undefined, skipEnv: boolean, lost: boolean): Parsed {
127 const not = (): Parsed => ({ push: false, dir, skipEnv, lost })
128 for (let i = 0; i < args.length; i++) {
129 const a = args[i]!
130 if (a === '--dry-run' || a === '--delete') return not()
131 if (PUSH_WITH_VALUE.has(a)) i++
132 else if (/^-[A-Za-z]+$/.test(a) && /[nd]/.test(a)) return not() // -n dry run, -d delete, in a cluster
133 else if (!a.startsWith('-') && a.startsWith(':') && a.length > 1) return not() // :branch deletes it
134 }
135 return { push: true, dir, skipEnv, lost }
136}
137
138/** The first real, reviewable `git push` in the command line, or undefined. */
139export function findPush(command: string): Push | undefined {
140 let dir: string | undefined
141 let lost = false
142 for (const words of segments(command)) {
143 const p = parseSegment(words, dir, lost)
144 dir = p.dir
145 lost = p.lost
146 if (p.push) return lost ? undefined : { dir: p.dir, skipEnv: p.skipEnv }
147 }
148 return undefined
149}
150hooks/config.ts 28 lines1import type { PluginOptions } from 'claude-code'
2
3import { GREEN_MAX_DEFAULT, RED_MIN_DEFAULT } from './estimate'
4import type { Thresholds } from './estimate'
5import type { Prices } from './format'
6
7export type Config = {
8 thresholds: Thresholds
9 askAlways: boolean
10 prices: Prices
11 coloredBanner: boolean
12}
13
14const positive = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback)
15const nonNegative = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
16
17/** Options from plugin.json userConfig; anything unusable falls back to the default. */
18export function readConfig(options: PluginOptions): Config {
19 const greenMax = positive(options.greenMaxTokens, GREEN_MAX_DEFAULT)
20 const redMin = Math.max(greenMax + 1, positive(options.redMinTokens, RED_MIN_DEFAULT)) // red always above green
21 return {
22 thresholds: { greenMax, redMin },
23 askAlways: options.askWhen === 'always',
24 prices: { inputUsdPerMTok: nonNegative(options.inputUsdPerMTok), outputUsdPerMTok: nonNegative(options.outputUsdPerMTok) },
25 coloredBanner: options.coloredBanner !== false,
26 }
27}
28hooks/estimate.ts 107 lines1// Token estimate for one automatic review. PURE: no `$`, no git, easy to test.
2//
3// EVERY NUMBER BELOW IS A HEURISTIC, chosen by reasoning about what a review run sends and
4// reads. None was measured against a live Codex (or Claude) review. The calibration step
5// replaces guesswork with the person's own past runs once three or more are recorded.
6
7export type Level = 'green' | 'amber' | 'red'
8export type Reviewer = 'codex' | 'claude'
9export type Usage = { bytes: number; tokens: number } // one past review: diff bytes sent, tokens used
10
11export const PROMPT_TOKENS = 1800 // review instructions + JSON schema, sent on every run
12export const BYTES_PER_TOKEN = 3.5 // code and diff text, a common rule of thumb
13export const LOW_OVERHEAD = 6000 // session overhead even when nothing else is opened
14export const HIGH_FACTOR = 3 // the reviewer runs read-only and may open files: the input can triple...
15export const HIGH_OVERHEAD = 12000 // ...plus a larger session overhead
16export const OUTPUT_LOW = 1500 // tokens written, short verdict
17export const OUTPUT_HIGH = 5000 // tokens written, long findings list
18export const MIN_HISTORY = 3 // past reviews needed before calibrating
19export const HISTORY_WINDOW = 10 // only the most recent are used
20export const FACTOR_MIN = 0.3 // calibration never scales by less than this...
21export const FACTOR_MAX = 4 // ...or more than this
22export const GREEN_MAX_DEFAULT = 25_000
23export const RED_MIN_DEFAULT = 60_000
24
25export type Thresholds = { greenMax: number; redMin: number }
26export type Estimate = {
27 low: number // total tokens, optimistic
28 expected: number // total tokens, midpoint of low and high (scaled when calibrated)
29 high: number // total tokens, pessimistic
30 input: number // of `expected`: tokens read (prompt, diff, files the reviewer opens)
31 output: number // of `expected`: tokens written
32 level: Level
33 calibratedFrom: number // how many past reviews calibrated it; 0 = pure heuristic
34 factor: number // the calibration factor applied (1 when not calibrated)
35}
36
37type Raw = { low: number; expected: number; high: number; output: number }
38
39/** The uncalibrated numbers for a diff of `diffBytes` bytes. */
40function raw(diffBytes: number): Raw {
41 const input = PROMPT_TOKENS + Math.ceil(Math.max(0, diffBytes) / BYTES_PER_TOKEN)
42 const low = input + LOW_OVERHEAD + OUTPUT_LOW
43 const high = HIGH_FACTOR * input + HIGH_OVERHEAD + OUTPUT_HIGH
44 return { low, high, expected: (low + high) / 2, output: (OUTPUT_LOW + OUTPUT_HIGH) / 2 }
45}
46
47export function levelOf(expected: number, t: Thresholds): Level {
48 if (expected <= t.greenMax) return 'green'
49 return expected >= t.redMin ? 'red' : 'amber'
50}
51
52const median = (xs: number[]) => {
53 const s = [...xs].sort((a, b) => a - b)
54 const mid = s.length >> 1
55 return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
56}
57
58/** Median of actual / predicted over the last HISTORY_WINDOW usable entries; undefined with too few. */
59function calibration(history: readonly Usage[]): { factor: number; n: number } | undefined {
60 const usable = history.filter(h => Number.isFinite(h.bytes) && h.bytes >= 0 && Number.isFinite(h.tokens) && h.tokens > 0)
61 if (usable.length < MIN_HISTORY) return undefined
62 const recent = usable.slice(-HISTORY_WINDOW)
63 const ratio = median(recent.map(h => h.tokens / raw(h.bytes).expected))
64 return { factor: Math.min(FACTOR_MAX, Math.max(FACTOR_MIN, ratio)), n: recent.length }
65}
66
67/**
68 * `files` is accepted for the caller's convenience but does not enter the formula: how many
69 * files the reviewer opens is not knowable from a diff, and the fixed allowances cover it.
70 * `reviewer` does not change the numbers either (Claude and Codex use the same formula);
71 * it only changes how the caller labels them. `history` is the past actuals, oldest first.
72 */
73export function estimate(args: {
74 diffBytes: number
75 files: number
76 reviewer: Reviewer
77 history: readonly Usage[]
78 thresholds?: Thresholds
79}): Estimate {
80 const r = raw(args.diffBytes)
81 const cal = calibration(args.history)
82 const factor = cal?.factor ?? 1
83 const expected = Math.round(r.expected * factor)
84 const output = Math.min(expected, Math.round(r.output * factor))
85 const thresholds = args.thresholds ?? { greenMax: GREEN_MAX_DEFAULT, redMin: RED_MIN_DEFAULT }
86 return {
87 low: Math.round(r.low * factor),
88 expected,
89 high: Math.round(r.high * factor),
90 input: expected - output,
91 output,
92 level: levelOf(expected, thresholds),
93 calibratedFrom: cal?.n ?? 0,
94 factor,
95 }
96}
97
98/** Parse `<diffBytes>:<tokens>` lines (git config toolkit.theoryUsage); junk is ignored. */
99export function parseUsage(lines: readonly string[]): Usage[] {
100 const out: Usage[] = []
101 for (const line of lines) {
102 const m = /^\s*(\d+):(\d+)\s*$/.exec(line)
103 if (m) out.push({ bytes: Number(m[1]), tokens: Number(m[2]) })
104 }
105 return out
106}
107hooks/format.ts 99 lines1// Everything the person reads. Pure functions: easy to test, no `$`.
2//
3// SHAPE (kept short, Apple-style): a one-line headline, a level line, then a few plain lines.
4// COLOR: `$.ui.ask` takes plain strings (no styling), so the level line carries the level
5// WORD ("Amber") and a small bar and works without color. A separate `ui.render` hook
6// (register.tsx) adds a colored copy of that line above the dialog where the surface draws it.
7import { changedLines } from './git'
8import type { Plan } from './git'
9import type { Estimate, Level, Reviewer } from './estimate'
10
11export const ASK_HEADER = 'Review cost' // the dialog's chip (12 characters at most); also how the render hook finds our dialog
12export const TOP_FILES = 3
13const BAR_WIDTH = 10
14
15export type Prices = { inputUsdPerMTok: number; outputUsdPerMTok: number }
16
17export const reviewerName = (r: Reviewer) => (r === 'claude' ? 'Claude' : 'Codex')
18
19export function fmtTokens(n: number): string {
20 if (n < 1000) return String(Math.round(n))
21 if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
22 return `${Math.round(n / 1000)}k`
23}
24
25export function fmtBytes(n: number): string {
26 if (n < 1024) return `${n} B`
27 return n < 10 * 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`
28}
29
30/** `▰▰▰▰▰▱▱▱▱▱`: how far the expected cost is toward the red threshold (always at least one block). */
31export function bar(expected: number, redMin: number): string {
32 const filled = Math.max(1, Math.min(BAR_WIDTH, Math.ceil((BAR_WIDTH * expected) / Math.max(1, redMin))))
33 return `${'\u25B0'.repeat(filled)}${'\u25B1'.repeat(BAR_WIDTH - filled)}`
34}
35
36export const levelWord = (l: Level) => l[0]!.toUpperCase() + l.slice(1)
37
38/** Dollars only when BOTH prices are set: the person's plan pricing is unknown, so nothing is ever guessed. */
39export function dollars(est: Estimate, prices: Prices): string | undefined {
40 const { inputUsdPerMTok: i, outputUsdPerMTok: o } = prices
41 if (!(i > 0) || !(o > 0)) return undefined
42 const usd = (est.input * i + est.output * o) / 1_000_000
43 return usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`
44}
45
46export const headline = (reviewer: Reviewer, est: Estimate) =>
47 `${reviewerName(reviewer)} review \u00B7 about ${fmtTokens(est.expected)} tokens`
48
49/** The second line: `● Amber ▰▰▰▰▱▱▱▱▱▱ 21k to 88k`. The colored copy above the dialog is this line. */
50export const levelLine = (est: Estimate, redMin: number) =>
51 `\u25CF ${levelWord(est.level)} ${bar(est.expected, redMin)} ${fmtTokens(est.low)} to ${fmtTokens(est.high)}`
52
53export const toastText = (reviewer: Reviewer, est: Estimate) =>
54 `${reviewerName(reviewer)} review \u00B7 about ${fmtTokens(est.expected)} tokens \u00B7 ${levelWord(est.level)}`
55
56const shorten = (path: string, max = 36) => (path.length <= max ? path : `...${path.slice(path.length - max + 3)}`)
57
58function largest(plan: Plan): string {
59 const sorted = [...plan.files].sort((a, b) => changedLines(b) - changedLines(a) || a.path.localeCompare(b.path))
60 const named = sorted.slice(0, TOP_FILES).map(f => `${shorten(f.path)} +${f.added} -${f.removed}`)
61 const more = plan.files.length - TOP_FILES
62 return `Largest: ${named.join(', ')}${more > 0 ? `, and ${more} more` : ''}`
63}
64
65export function askOptions(level: Level): { options: [string, string]; review: string; skip: string } {
66 // The FIRST option is the one the dialog highlights, so Enter takes it. Review for green and
67 // amber (the safe default: the push gets its check); Skip for red (many tokens: the person
68 // has to choose to spend them).
69 return level === 'red'
70 ? { options: ['Skip', 'Review anyway'], review: 'Review anyway', skip: 'Skip' }
71 : { options: ['Review', 'Skip'], review: 'Review', skip: 'Skip' }
72}
73
74export function question(plan: Plan, est: Estimate, redMin: number, prices: Prices): string {
75 const files = plan.files.length
76 const lines = plan.files.reduce((n, f) => n + changedLines(f), 0)
77 const usd = dollars(est, prices)
78 const cost = [usd ? `About ${usd} at API prices` : '', plan.reviewer === 'claude' ? 'Counts against your Claude plan' : '']
79 .filter(Boolean)
80 .join(' \u00B7 ')
81 return [
82 headline(plan.reviewer, est),
83 levelLine(est, redMin),
84 '',
85 `${files} file${files === 1 ? '' : 's'} \u00B7 ${lines} changed lines \u00B7 about ${fmtBytes(plan.diffBytes)}` +
86 (plan.capped ? ` (capped at ${fmtBytes(plan.maxBytes)})` : ''),
87 largest(plan),
88 ...(cost ? [cost] : []),
89 est.calibratedFrom > 0
90 ? `Estimate from your last ${est.calibratedFrom} reviews, not a measurement.`
91 : 'Estimate, not a measurement.',
92 '',
93 'Skip still pushes, marked UNREVIEWED.',
94 `Review with ${reviewerName(plan.reviewer)} before pushing?`,
95 ].join('\n')
96}
97
98export const CANCEL_TEXT = 'Push cancelled. Run it again to choose Review or Skip.'
99hooks/git.ts 126 lines1// Works out what the git pre-push hook WOULD review, using read-only `git` calls only
2// (argv arrays through $.process.run: no shell, no Node fs, nothing but git is spawned).
3import { parseUsage } from './estimate'
4import type { Reviewer, Usage } from './estimate'
5
6export type RunResult = { exitCode: number; stdout: string }
7export type Run = (argv: readonly string[], cwd?: string) => Promise<RunResult>
8
9/** Well-known id of git's empty tree: the base of a branch with nothing to compare to. */
10export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbd2d4f'
11export const DEFAULT_PATHS = 'R src'
12export const DEFAULT_MAX_BYTES = 120_000
13export const THEORY_FILE = 'docs/THEORY_REVIEW.md'
14
15// Diff size ESTIMATE: a unified diff costs about 60 bytes per changed line (the line plus its
16// share of context and hunk headers) and about 200 bytes per file (diff/index/---/+++ headers).
17// A rough rule of thumb, not a measurement; binary files count as headers only.
18export const BYTES_PER_CHANGED_LINE = 60
19export const BYTES_PER_FILE = 200
20
21export type FileStat = { path: string; added: number; removed: number }
22export type Plan = {
23 root: string
24 reviewer: Reviewer
25 files: FileStat[]
26 diffBytes: number // estimated, after the cap
27 capped: boolean
28 maxBytes: number
29 history: Usage[]
30}
31export type Planned = { plan: Plan } | { why: string }
32
33const git = (run: Run, cwd: string | undefined, ...args: string[]) => run(['git', ...args], cwd)
34const first = (r: RunResult) => r.stdout.split('\n')[0]!.replace(/\r$/, '').trim()
35
36/** `git config --get-regexp ^toolkit\.theory` as key -> values (git lowercases key names; last value wins for single keys). */
37export function parseSettings(stdout: string): Map<string, string[]> {
38 const m = new Map<string, string[]>()
39 for (const raw of stdout.split('\n')) {
40 const line = raw.replace(/\r$/, '')
41 const sp = line.search(/\s/)
42 const key = (sp < 0 ? line : line.slice(0, sp)).toLowerCase()
43 if (!key.startsWith('toolkit.theory')) continue
44 const list = m.get(key) ?? []
45 list.push(sp < 0 ? '' : line.slice(sp).trim())
46 m.set(key, list)
47 }
48 return m
49}
50
51/** `git diff --numstat` lines: "<added>\t<removed>\t<path>"; binary files show "-". */
52export function parseNumstat(stdout: string): FileStat[] {
53 const out: FileStat[] = []
54 for (const raw of stdout.split('\n')) {
55 const m = /^(\d+|-)\t(\d+|-)\t(.+?)\r?$/.exec(raw)
56 if (m) out.push({ path: m[3]!, added: m[1] === '-' ? 0 : Number(m[1]), removed: m[2] === '-' ? 0 : Number(m[2]) })
57 }
58 return out
59}
60
61export const changedLines = (f: FileStat) => f.added + f.removed
62export const diffBytesOf = (files: readonly FileStat[]) =>
63 files.reduce((sum, f) => sum + changedLines(f) * BYTES_PER_CHANGED_LINE + BYTES_PER_FILE, 0)
64
65/** Merge base of HEAD and the upstream, else of HEAD and the default branch, else the empty tree. */
66async function baseOf(run: Run, root: string): Promise<string> {
67 const upstream = await git(run, root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
68 const candidates: string[] = upstream.exitCode === 0 && first(upstream) ? [first(upstream)] : []
69 if (candidates.length === 0) {
70 const head = await git(run, root, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')
71 if (head.exitCode === 0 && first(head)) candidates.push(first(head))
72 candidates.push('origin/main', 'origin/master', 'main', 'master')
73 }
74 for (const ref of candidates) {
75 const base = await git(run, root, 'merge-base', 'HEAD', ref)
76 if (base.exitCode === 0 && first(base)) return first(base)
77 }
78 return EMPTY_TREE
79}
80
81/** Decides whether a review would run for this directory and, if so, what it would read. */
82export async function planReview(run: Run, dir: string | undefined): Promise<Planned> {
83 const top = await git(run, dir, 'rev-parse', '--show-toplevel')
84 if (top.exitCode !== 0 || !first(top)) return { why: 'not a git repository' }
85 const root = first(top)
86
87 const cfg = parseSettings((await git(run, root, 'config', '--get-regexp', '^toolkit\\.theory')).stdout)
88 const one = (key: string) => (cfg.get(key) ?? []).at(-1)?.trim() ?? ''
89 if (one('toolkit.theoryreview').toLowerCase() === 'false') return { why: 'toolkit.theoryReview is false' }
90
91 const configured = one('toolkit.theorypaths')
92 if (configured === '') {
93 const listed = await git(run, root, 'ls-files', '--cached', '--others', '--exclude-standard', '--', THEORY_FILE)
94 if (listed.exitCode !== 0 || listed.stdout.trim() === '') return { why: 'no docs/THEORY_REVIEW.md and no toolkit.theoryPaths' }
95 }
96
97 if ((await git(run, root, 'rev-parse', '--verify', '--quiet', 'HEAD')).exitCode !== 0) return { why: 'no commits yet' }
98 const base = await baseOf(run, root)
99 const paths = (configured || DEFAULT_PATHS).split(/\s+/).filter(Boolean)
100 const diff = await git(run, root, 'diff', '--numstat', '--no-renames', base, 'HEAD', '--', ...paths)
101 if (diff.exitCode !== 0) return { why: 'could not diff against the base' }
102 const files = parseNumstat(diff.stdout)
103 if (files.length === 0) return { why: 'nothing to review in the theory paths' }
104
105 const limit = Number(one('toolkit.theorymaxbytes'))
106 const maxBytes = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_MAX_BYTES
107 const total = diffBytesOf(files)
108 return {
109 plan: {
110 root,
111 reviewer: one('toolkit.theoryreviewer').toLowerCase() === 'claude' ? 'claude' : 'codex',
112 files,
113 diffBytes: Math.min(total, maxBytes),
114 capped: total > maxBytes,
115 maxBytes,
116 history: parseUsage(cfg.get('toolkit.theoryusage') ?? []),
117 },
118 }
119}
120
121/** Records the decision for the pre-push hook: `<review|skip>:<epochSeconds>`, in the repo's local config. */
122export async function writeDecision(run: Run, root: string, choice: 'review' | 'skip', epochSeconds: number): Promise<boolean> {
123 const r = await git(run, root, 'config', '--local', 'toolkit.theoryDecision', `${choice}:${epochSeconds}`)
124 return r.exitCode === 0
125}
126types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 'review-cost': {
4 /** The persisted on/off switch ($.store 'off'), mirrored here so the moddeck side bar can show it. Default false (on). */
5 isOff: boolean
6 }
7 }
8}
9