SLOPSHOPPER

review-cost

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.

newrowsguardcommandtoastprocess
★ 1v0.3.1MITupdated 2026-10-12samuelbfernandes/moddeck/mods/review-cost
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-cost
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /review-cost ⎿ review-cost: Review cost · on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

review-cost

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

Use

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.

Settings

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

Limits

  • The token numbers are a rule of thumb, never measured against a live Codex run: prompt 1800 + diff/3.5 tokens, a wide range. After 3 past reviews (toolkit.theoryUsage) the estimate is scaled by your history.
  • Defaults: under ~16 KB Green, up to ~77 KB Amber, more Red; the 120000-byte cap tops out near 84k tokens.
  • The question is plain text; a colored copy of the level line is drawn above it where the surface allows (coloredBanner off if it ever looks wrong).
  • Detects 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.
  • Fails open: any error lets the push through unasked; a headless run is not asked.
  • Not verified live: no session, no real Codex, nothing run on Windows (paths are handled as text, only git is spawned); how the dialog looks was only tested with a mocked engine.
Source 7 files
hooks/register.tsx 168 lines
1// 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}
168
hooks/command.ts 150 lines
1// 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}
150
hooks/config.ts 28 lines
1import 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}
28
hooks/estimate.ts 107 lines
1// 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}
107
hooks/format.ts 99 lines
1// 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.'
99
hooks/git.ts 126 lines
1// 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}
126
types/index.d.ts 9 lines
1declare 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