Slots for your test runs: the reels spin while Claude runs the tests and stop on the real result. Fake credits only.

Slots for your test runs. The reels are rigged. By your test suite.
I bet against my own AI's tests. The house always wins. Except when it's 0.1 + 0.2.
![]()
A Claude Code mod that turns every test run into a slot machine. You bet fake credits on pass or fail, the reels spin while the tests run, and the run's real result settles it. The house collects its five percent like it has every right to.
Fake credits only. No real money, no network calls, no model calls. The only thing at stake is your dignity.
The reels are rigged. By your test suite.
The reels are decoration: they always stop on the result your tests actually produced. Nothing about a spin is random, and the reels decide nothing.
1 (pass) or 2 (fail) at an empty prompt to stake $100. No focusing, no menus. The tests are already running: betting never holds them up, and if you do not bet, nothing is wagered.✓ ✓ ✓ for a pass, ✕ ✕ ✕ for a fail, a mixed line and REFUNDED for a run that gave no result. The third reel takes its time, and lingers on the wrong symbol first.When you have a bet on, the house holds the result up to 2.5 seconds so the reels can stop first. It never changes the result. With no bet, nothing is held.
stake $100 x x2.85 = $285), the coins pile up, and CASHED OUT lands in block letters with a coin sound. Lost? BUSTED, and a sad trombone. Then the band goes back to the market for the next run.1 or 2. A standing bet cancels the same way: ctrl+x tab, then c./bankroll opens the ledger: a chart of your bankroll over the last fifteen bets (gold above the starting $1,000, orange below; a narrow dock keeps the latest that fit), your rank, lifetime P&L, win rate, this project's pass rate, best streak, bailouts and the last bets. Ranks run from Fresh meat through Intern gambler and Card counter to The House. Go broke and the band offers a bailout back to $1,000 (ctrl+x tab, then b). The ledger counts those, forever.
The bankroll, an open bet and each project's record persist across sessions.
Every outcome is told in a glyph and a word as well as a color (✓ PASS, ✕ FAIL, ▴ for a win, ▾ for a loss), and the colors come from a palette that reads for the common kinds of color blindness.
The reels need about thirteen rows above the prompt and eighty columns. In a smaller band the mod draws a compact market instead: the same odds, bets and payouts, without the machine.
Requires Claude Code 2.1.287 or later (mods). Developed and tested on 2.1.292. The house is designed for a dark Claude Code theme; on a light theme some text is hard to read.
From the ilovepixelart marketplace, which pins each mod to its latest release:
/plugin marketplace add ilovepixelart/claude-code-mods
/plugin install pass-or-bust@ilovepixelart
Or in one line, straight from this repository, following main:
/plugin install pass-or-bust --marketplace ilovepixelart/pass-or-bust-mod
To stay on one release, add this repository at its tag instead, then install from it:
/plugin marketplace add ilovepixelart/pass-or-bust-mod#pass-or-bust--v0.3.1
/plugin install pass-or-bust@pass-or-bust-mod
Run /reload-plugins (or start a new session) after installing. To take a new release later, run claude plugin update pass-or-bust@ilovepixelart in your shell, or pass-or-bust@pass-or-bust-mod if you installed from this repository. Each release also carries a zip of the plugin for claude --plugin-url, and CHANGELOG.md lists what each one changed.
To try it from a clone without installing: claude --plugin-dir /path/to/pass-or-bust-mod.
Releases follow Semantic Versioning. While the version is 0.x, any release may change behaviour. The bankroll is saved in a versioned store layout: a release never reads or overwrites a store saved in a newer layout than it knows, and says so instead.
oddsOf prices each side from the project's settled runs, kept per working directory. The chance of a pass is (passes + 1) / (runs + 2), so a brand new project is a coin flip. Each side pays 0.95 / chance per credit: the fair price less a 5% house edge, rounded down to the cent and held between 1.01 and 50.
With no history both sides pay x1.90. After three passes and a fail, pass pays x1.42 and fail pays x2.85. The constants live in hooks/limits/limits.ts.
A Bash command with a step that starts with a known runner (npm test, pnpm test, bun test, node --test, pytest, uv run pytest, go test, cargo test, vitest, jest, make test and friends; the full table is hooks/detect/runners.ts). Leading NAME=value assignments and earlier steps (cd app && npm test) are fine.
The house never changes what Claude runs. How a run settles depends on how Claude ran it:
npm test || echo failed or npm test; git status the exit code is the last command's, so the house reads the summary, as for a pipe below.tail, tee or cat, the summary settles it. In bun test 2>&1 | tail -30 the exit code is tail's, so the house reads the runner's own summary line instead (1 pass / 1 fail, Tests: 1 failed, test result: FAILED, ===== 1 failed, 1 passed =====, # fail 1 and so on). It reads bun, deno, node, go, cargo, pytest, jest and vitest; the readers are in hooks/summary/summary-of.ts, tested against real output from each runner. A summary it cannot find whole (cut short by tail -2, missing, or two runners disagreeing) makes the bet void, never a guess.grep, the summary settles it only if it survived whole. Claude often checks a fix with npm test 2>&1 | grep -E "^(not )?ok|^# (pass|fail)". grep keeps or drops whole lines, so the house reads only the summaries a dropped line cannot turn into a pass: one line holding every count (deno, pytest, jest, vitest), or a pass count and a fail count both required (bun, node). Lose the fail count and the bet is void. go and cargo read a pass off lines that are not there, so a go or cargo run through grep opens no market, and neither does grep -o, which keeps parts of lines, or grep -v, which drops the lines it names. One case no filter can rule out: a run of several suites (a workspace) through a grep that keeps one suite's passing summary and drops another's failing one settles on what is left, a pass.head drops the summary, so the house cannot settle it honestly.& (npm test &).What claude plugin validate . reports the module calls, and why:
$.store: the store layout version, the bankroll, an open bet and each project's pass and fail counts.$.state, $.ui.open, $.ui.resolve, $.command.register: the band and /bankroll.$.ui.toast: the settlement line.$.clock.now, $.clock.after: how long a run took, and taking the show down three and a half seconds after the stamp lands.$.clock.sleep: the 2.5 second cap on holding a result back while the reels stop.$.audio.play: the coin and the trombone (sounds/coin.wav, sounds/trombone.wav), played from the mod's own folder.The animation (the reels, the run timer, the tally, the stamp and the chart drawing in) runs in four surface modules under hooks/clients/, on the drawing's own frame clock: they take plain data from the hooks module and reach nothing else.
No file system, process, network or model access. The tool.call hook only watches Bash and always hands back the tool's own result unchanged; if the hook fails, its .catch lets the call through.
node scripts/check-release.mjs
claude plugin validate --strict .
claude plugin test .
npx -p typescript tsc -p .
To release, add the version's section to CHANGELOG.md, set the version in .claude-plugin/plugin.json (its only home), merge, then run claude plugin tag --push on main. The pushed pass-or-bust--v<version> tag starts the release workflow, which checks everything again and creates the GitHub release.
tsc needs the type declarations Claude Code writes into .claude-plugin/types/ when it loads the plugin; any claude --plugin-dir . run writes them, even one that stops at "Not logged in" (CI does exactly that). The sounds are synthesized: python3 scripts/make_sounds.py sounds regenerates both. The runner output the summary readers are tested against is captured from real runs: python3 scripts/capture_runner_output.py <scratch-dir> rewrites tests/fixtures/runner-output.ts (pytest runs through uvx, jest and vitest through npx). The demo is recorded with vhs in a scratch project.
MIT
hooks/register.tsx 327 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import Bankroll from './bankroll'
5import Detect from './detect'
6import Limits from './limits'
7import Market from './market'
8import type { Action, Change } from './market'
9import Odds from './odds'
10import Settle from './settle'
11import Stored from './stored'
12import Views from './views'
13import Words from './words'
14
15const PANE = 'bankroll'
16
17/** The dock width /bankroll asks for: its whole chart and ledger, unwrapped. */
18const DOCK_COLUMNS = 64
19
20const SOUNDS = { win: 'sounds/coin.wav', loss: 'sounds/trombone.wav' } as const
21
22/** The session's market: null until Claude first runs the tests. */
23const marketAtom = atom({ plugin: 'pass-or-bust', key: 'market' } as const, null)
24
25/** The bet waiting on the next settlement, if any. */
26const slipAtom = atom({ plugin: 'pass-or-bust', key: 'slip' } as const, null)
27
28/** The bankroll as the store last saved it. */
29const bankAtom = atom({ plugin: 'pass-or-bust', key: 'bank' } as const, Bankroll.NEW_BANK)
30
31/** Whether the person hid the band until the next test run. */
32const isHiddenAtom = atom({ plugin: 'pass-or-bust', key: 'isHidden' } as const, false)
33
34/** The last settled bet's result, while the band still shows it. */
35const stampAtom = atom({ plugin: 'pass-or-bust', key: 'stamp' } as const, null)
36
37/** The run the slot machine is showing, from its start until its show is over. */
38const slotsAtom = atom({ plugin: 'pass-or-bust', key: 'slots' } as const, null)
39
40/** Sound and timers are decoration: a refusal or an unanswered call must not reach the run. */
41const quietly = (pending: Promise<unknown>) => {
42 pending.catch(() => undefined)
43}
44
45/** What a settled bet announces once its stamp is down: the toast line, and the sound (none for a void). */
46type Announcement = { toast: string; sound: string | null }
47
48/**
49 * What the machine on screen has told the host about one run: whether it is
50 * on screen, a promise the run's hook waits on for its reels to stop, the
51 * announcement held for the moment its stamp lands, and that stamp's id.
52 */
53type Seen = { isOnScreen: boolean; stopped: Promise<void>; stop: () => void; held: Announcement | null; stampId: number | null }
54
55function seenOf(): Seen {
56 let stop = () => {}
57 const stopped = new Promise<void>(resolve => {
58 stop = resolve
59 })
60
61 return { isOnScreen: false, stopped, stop, held: null, stampId: null }
62}
63
64/** The toast and the sound of a settled bet. */
65function announce($: { ui: { toast: (text: string) => unknown }; audio: { play: (clip: { asset: string }) => Promise<unknown> } }, announcement: Announcement) {
66 quietly(Promise.resolve($.ui.toast(announcement.toast)))
67 if (announcement.sound !== null) {
68 quietly($.audio.play({ asset: announcement.sound }))
69 }
70}
71
72/** Saves entries in this release's layout, the layout key first: the store is only ever written in one layout. */
73async function save($: { store: { set: (key: string, value: unknown) => Promise<void> } }, entries: readonly (readonly [string, unknown])[]) {
74 await $.store.set(Stored.LAYOUT_KEY, Stored.LAYOUT)
75 for (const [key, value] of entries) {
76 await $.store.set(key, value)
77 }
78}
79
80/**
81 * Copies the stored bankroll and open bet into $.state, unless the store is in a layout this release cannot
82 * read: then it says so and copies nothing. Resolves to whether the store is locked that way.
83 */
84async function load($: EngineInterface): Promise<boolean> {
85 if (Stored.layoutOf(await $.store.get(Stored.LAYOUT_KEY)) === 'unreadable') {
86 quietly(Promise.resolve($.ui.toast(Words.NEWER_LAYOUT, { timeoutMs: 15_000 })))
87
88 return true
89 }
90
91 const bank = Stored.bankOf(await $.store.get(Stored.BANK_KEY))
92 const slip = Stored.slipOf(await $.store.get(Stored.SLIP_KEY))
93 await update($, bankAtom, () => bank)
94 await update($, slipAtom, () => slip)
95
96 return false
97}
98
99export const register: Register = on => {
100 let historyKey = Stored.historyKeyOf(null)
101 let lastStampId = 0
102 let lastRunId = 0
103 // a store saved in a layout this release cannot read is left exactly as it is: no market, no writes
104 let isLocked = false
105
106 const seen = new Map<number, Seen>()
107
108 on('session.start', async ($, e, next) => {
109 historyKey = Stored.historyKeyOf(e.cwd)
110 await $.command.register({
111 name: 'bankroll',
112 description: 'Your pass-or-bust bankroll: balance, lifetime P&L and recent bets',
113 })
114
115 isLocked = await load($)
116
117 return next(e)
118 })
119
120 // /clear, /resume and /branch reset every $.state value and session.start does not fire again: load the
121 // bankroll again, or the band shows the default and the next save writes it over the stored one
122 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
123 isLocked = await load($)
124
125 return next(e)
126 })
127
128 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
129 const testRun = e.run_in_background === true || isLocked ? null : Detect.testRunOf(e.command)
130 if (testRun === null) {
131 return next(e)
132 }
133
134 // the run starts before the market opens: betting never holds up the tests
135 const running = next(e)
136 // a new run replaces the last stamp, so the next bet can be placed at once
137 await update($, stampAtom, () => null)
138 lastRunId += 1
139 const runId = lastRunId
140 const machine = seenOf()
141 seen.set(runId, machine)
142 const startedAt = await $.clock.now().catch(() => null)
143 await update($, slotsAtom, () => ({ id: runId, settled: null }))
144
145 const before = Stored.historyOf(await $.store.get(historyKey))
146 await update($, marketAtom, () => ({
147 command: e.command,
148 odds: Odds.oddsOf(before),
149 passChance: Odds.passChanceOf(before),
150 isRunning: true,
151 last: null,
152 runs: before.passes + before.fails,
153 }))
154 await update($, isHiddenAtom, () => false)
155
156 const ran = await running
157 const outcome = testRun.settlesOnSummary ? Settle.pipedOutcomeOf(ran, testRun.family, testRun.isFiltered) : Settle.outcomeOf(ran)
158
159 const after = Odds.recordOf(Stored.historyOf(await $.store.get(historyKey)), outcome)
160 await save($, [[historyKey, after]])
161 const market = await update($, marketAtom, () => ({
162 command: e.command,
163 odds: Odds.oddsOf(after),
164 passChance: Odds.passChanceOf(after),
165 isRunning: false,
166 last: outcome,
167 runs: after.passes + after.fails,
168 }))
169
170 // every session on the machine shares the store: settle on what it holds now, not on the copy this session
171 // loaded, so a result another session saved meanwhile is built on rather than overwritten
172 const table = {
173 market,
174 slip: Stored.slipOf(await $.store.get(Stored.SLIP_KEY)),
175 bank: Stored.bankOf(await $.store.get(Stored.BANK_KEY)),
176 }
177 const settled = Market.settlementOf(table, outcome, e.command)
178
179 // the reels stop on this outcome: the machine only shows what settled
180 const endedAt = startedAt === null ? null : await $.clock.now().catch(() => null)
181 const seconds = startedAt === null || endedAt === null ? null : Math.floor((endedAt - startedAt) / 1000)
182 await update($, slotsAtom, run => (run?.id === runId ? { id: runId, settled: { outcome, slip: table.slip, seconds } } : run))
183 // a machine on screen takes its show down a hold after it lands the stamp, however slowly it ran; this is
184 // the fallback for one that never says so
185 const showMs = settled === null ? Limits.SLOTS_NO_BET_MS : machine.isOnScreen ? Limits.STAMP_FALLBACK_MS : Limits.STAMP_MS
186 $.clock.after(showMs, () => quietly(update($, slotsAtom, run => (run?.id === runId ? null : run))))
187
188 if (settled !== null) {
189 await update($, bankAtom, () => settled.bank)
190 await update($, slipAtom, () => settled.slip)
191 await save($, [
192 [Stored.BANK_KEY, settled.bank],
193 [Stored.SLIP_KEY, settled.slip],
194 ])
195 lastStampId += 1
196 const id = lastStampId
197 await update($, stampAtom, () => ({ id, ...settled.stamp }))
198 machine.stampId = id
199 $.clock.after(machine.isOnScreen ? Limits.STAMP_FALLBACK_MS : Limits.STAMP_MS, () =>
200 quietly(update($, stampAtom, stamp => (stamp?.id === id ? null : stamp))),
201 )
202
203 // the toast and the sound wait for the stamp the machine lands: neither may give the result away while
204 // the reels turn; with no machine on screen the band shows the result at once, and so do they
205 const kind = settled.stamp.outcome === 'void' ? 'void' : settled.stamp.delta > 0 ? 'win' : 'loss'
206 const announcement = { toast: settled.toast, sound: kind === 'void' ? null : kind === 'win' ? SOUNDS.win : SOUNDS.loss }
207 if (machine.isOnScreen) {
208 // made by the ui.message hook that hears the stamp land, with its own $
209 machine.held = announcement
210 } else {
211 announce($, announcement)
212 }
213 }
214
215 if (table.slip !== null && machine.isOnScreen) {
216 // a bet is on and the reels are turning: the transcript would show the result before they stop, so
217 // hold it, unchanged, until the machine says they stopped, and never past the cap
218 const cap = $.clock.sleep(Limits.RESULT_HOLD_MOST_MS, { signal: next.signal })
219 await Promise.race([machine.stopped, cap]).catch(() => undefined)
220 }
221
222 return ran
223 }).catch(($, e, next) => next(e))
224
225 on('ui.message', ($, e, next) => {
226 const post = Views.slotsPostOf(e.data)
227 const machine = post === null ? undefined : seen.get(post.run)
228 if (post !== null && machine !== undefined) {
229 if (post.event === 'spinning') {
230 machine.isOnScreen = true
231 } else if (post.event === 'stopped') {
232 machine.stop()
233 } else {
234 if (machine.held !== null) {
235 announce($, machine.held)
236 }
237 const { stampId } = machine
238 $.clock.after(Limits.STAMP_HOLD_MS, () => {
239 quietly(update($, slotsAtom, run => (run?.id === post.run ? null : run)))
240 quietly(update($, stampAtom, stamp => (stamp !== null && stamp.id === stampId ? null : stamp)))
241 })
242 seen.delete(post.run)
243 }
244 }
245
246 return next(e)
247 })
248
249 on('command.run', { command: 'bankroll' }, async $ => {
250 await $.ui.open({ id: PANE, title: 'pass-or-bust · bankroll', columns: DOCK_COLUMNS })
251
252 return { text: 'Bankroll opened.' }
253 })
254
255 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
256 const market = await read($, marketAtom)
257 const isQuiet = e.props.hasSurvey || market === null || (await read($, isHiddenAtom))
258
259 if (isQuiet || e.surface === 'mobile' || e.surface === 'vscode') {
260 return next(e)
261 }
262
263 const act = async (action: Action) => {
264 const table = {
265 market: await read($, marketAtom),
266 slip: Stored.slipOf(await $.store.get(Stored.SLIP_KEY)),
267 bank: Stored.bankOf(await $.store.get(Stored.BANK_KEY)),
268 }
269 const change: Change | null = Market.changeOf(table, action)
270
271 if (change !== null) {
272 await update($, bankAtom, () => change.bank)
273 await update($, slipAtom, () => change.slip)
274 await save($, [
275 [Stored.BANK_KEY, change.bank],
276 [Stored.SLIP_KEY, change.slip],
277 ])
278 }
279 }
280
281 const elements = $.ui.resolve(e)
282 const Client = 'Client' in elements ? elements.Client : undefined
283 // the engine loads a surface module only from a literal path in this file
284 const clients: Views.Clients = {
285 // a declared size, so the engine knows where the bet buttons under the machine sit
286 slots: (key, props) =>
287 Client === undefined ? null : <Client key={key} module="./clients/slots.tsx" props={props} height={Views.SLOTS_ROWS} />,
288 clock: key => (Client === undefined ? null : <Client key={key} module="./clients/run-clock.tsx" props={null} />),
289 payout: (key, props) => (Client === undefined ? null : <Client key={key} module="./clients/payout.tsx" props={props} />),
290 }
291
292 return Views.band(
293 elements,
294 {
295 market,
296 slip: await read($, slipAtom),
297 bank: await read($, bankAtom),
298 stamp: await read($, stampAtom),
299 slots: await read($, slotsAtom),
300 },
301 { act, hide: () => update($, isHiddenAtom, () => true) },
302 { bodyColumns: e.props.bodyColumns, maxRows: e.props.maxRows },
303 clients,
304 )
305 })
306
307 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
308 if (e.surface === 'mobile' || e.surface === 'vscode') {
309 return next(e)
310 }
311
312 const elements = $.ui.resolve(e)
313 const Client = 'Client' in elements ? elements.Client : undefined
314
315 return Views.pane(
316 elements,
317 {
318 bank: await read($, bankAtom),
319 slip: await read($, slipAtom),
320 history: Stored.historyOf(await $.store.get(historyKey)),
321 bodyColumns: e.props.bodyColumns,
322 },
323 { chart: (key, props) => (Client === undefined ? null : <Client key={key} module="./clients/chart.tsx" props={props} />) },
324 )
325 })
326}
327hooks/bankroll/index.ts 4 lines1export * from './bankroll'
2
3export * as default from '.'
4hooks/detect/index.ts 5 lines1export * from './is-test-run'
2export * from './runners'
3
4export * as default from '.'
5hooks/limits/index.ts 4 lines1export * from './limits'
2
3export * as default from '.'
4hooks/market/index.ts 4 lines1export * from './market'
2
3export * as default from '.'
4hooks/odds/index.ts 6 lines1export * from './history'
2export * from './odds-of'
3export * from './record-of'
4
5export * as default from '.'
6hooks/settle/index.ts 5 lines1export * from './outcome'
2export * from './outcome-of'
3
4export * as default from '.'
5hooks/stored/index.ts 4 lines1export * from './stored'
2
3export * as default from '.'
4hooks/views/index.ts 8 lines1export * from './band'
2export * from './pane'
3export * from './payout'
4export * from './chart'
5export * from './slots'
6
7export * as default from '.'
8hooks/words/index.ts 4 lines1export * from './words'
2
3export * as default from '.'
4hooks/clients/slots.tsx 54 lines1import type { ClientModule } from 'claude-code'
2
3import { postDueOf, showMsOf, slotsTree } from '../views/slots'
4import type { SlotsPost, SlotsProps } from '../views/slots'
5
6/** One frame of the machine's own clock. */
7const TICK_MS = 40
8
9/** Kept across redraws: the latest props, when the run was seen to settle, what it has told the host, and the clock's off switch. */
10type Live = { props: SlotsProps; settledAt: number | null; sent: Set<SlotsPost['event']>; stop: (() => void) | null }
11
12/**
13 * The slot machine: reels spinning on the drawing's frame clock while the
14 * tests run. The hooks module hands it the settled result as new props; the
15 * reels then stop on that result, and the tally and stamp play. It reads the
16 * outcome and decides nothing.
17 */
18const Machine: ClientModule<SlotsProps, { time: number; live: Live }> = (props, surface) => {
19 if (surface.state === undefined) {
20 const live: Live = { props, settledAt: null, sent: new Set(), stop: null }
21 surface.setState({ time: 0, live })
22 live.stop = surface.every(TICK_MS, () => {
23 const state = surface.state
24 if (state === undefined) {
25 return
26 }
27 const time = state.time + TICK_MS
28 const { result } = state.live.props
29 if (result !== null && state.live.settledAt === null) {
30 state.live.settledAt = time
31 }
32 // the host holds the result and the toast for these: one post a frame, each event once
33 const due = postDueOf(state.live.props, { time, settledAt: state.live.settledAt }, state.live.sent)
34 if (due !== null) {
35 state.live.sent.add(due.event)
36 surface.post(due)
37 }
38 if (state.live.settledAt !== null && time > state.live.settledAt + showMsOf(result?.payout ?? null)) {
39 state.live.stop?.()
40 }
41 surface.setState({ time, live: state.live })
42 })
43 }
44
45 const state = surface.state
46 if (state !== undefined) {
47 state.live.props = props
48 }
49
50 return slotsTree(surface.elements, props, { time: state?.time ?? 0, settledAt: state?.live.settledAt ?? null })
51}
52
53export default Machine
54hooks/clients/run-clock.tsx 20 lines1import type { ClientModule } from 'claude-code'
2
3import Art from '../art'
4
5/**
6 * The band's run timer: seconds since the run's market opened, on the
7 * drawing's own frame clock, so it ticks without a redraw of the band.
8 */
9const RunClock: ClientModule<null, number> = (_, surface) => {
10 const { Text } = surface.elements
11 if (surface.state === undefined) {
12 surface.setState(0)
13 surface.every(1000, () => surface.setState((surface.state ?? 0) + 1))
14 }
15
16 return <Text dimColor>{Art.clockOf(surface.state ?? 0)}</Text>
17}
18
19export default RunClock
20