A play-money prediction market on your own Claude session: bet on tests, files touched and permission asks each turn.

Claude Code mods by @sreshtalluri.

/plugin marketplace add sreshtalluri/claude-mods
/plugin install session-receipt@claude-mods
/plugin install daily-grind@claude-mods
/plugin install next-asks@claude-mods
Mods use Claude Code's function-hooks plugin API, which is early access, so you need a current Claude Code.
| Mod | What it does |
|---|---|
| session-receipt | /receipt prints a shareable receipt of your session. /receipt week totals your week. Copy image gives you a square PNG to post. |
| daily-grind | /grind opens a hub of daily puzzles to play while Claude works. Developer: Git Golf (reshape a commit graph under par), Heisenbug (find the buggy line), Install Order (dependency logic). Everyone: Ladder, Pixel Logic, Codebreaker. A Classics tab holds Lexer, Buckets and Daemons. Streaks, and /grind share for a shareable result. |
| next-asks | After each turn, a band above the prompt suggests up to three things worth asking next that you might not think of: a missed edge case, an unrun check, a related file. Press its number to put it in the prompt. One small Haiku call per turn. |

| Command | |
|---|---|
/receipt | This session: model, time, turns, tools, files, tokens, cache hit, cost, and how many times Claude said you were absolutely right. |
/receipt week | The last seven days: sessions, busiest day, top project, usual model, total. |
/receipt last | The previous session's receipt. |
~/.claude/receipts/ when it ends. Nothing leaves your machine.plugins/<name>/ (with .claude-plugin/plugin.json)..claude-plugin/marketplace.json.claude plugin validate plugins/<name>, claude plugin test plugins/<name> and claude plugin validate .The demo clip is a HyperFrames composition in media/receipt-demo: bun build.ts && npm run render.
MIT
hooks/register.tsx 313 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { SideBetOffer, SideBetPick, SideBetRound, SideBetSlip } from '../types'
5import { cents, FLOOR, formatSlip, formatStats, isTestCommand, learn, loadBook, MARK, offers, refill, settle, signed, STAKE } from './logic'
6import type { Book, Observed } from './logic'
7
8const round = atom({ plugin: 'side-bet', key: 'round' } as const, null)
9const BOOK = 'book'
10const EDITS = new Set(['Edit', 'Write', 'NotebookEdit'])
11
12const getBook = async ($: EngineInterface) => refill(loadBook(await $.store.get(BOOK)), await $.clock.now())
13const chips = (n: number) => n.toLocaleString('en-US')
14
15const place = async ($: EngineInterface, bet: SideBetPick) => {
16 const r = await read($, round)
17 if (!r?.isOpen) return $.ui.toast('🎲 Betting closed at the first tool call')
18 if (r.bets.some(b => b.market === bet.market)) return $.ui.toast('🎲 One bet per market')
19 const book = await getBook($)
20 if (book.chips < STAKE) return $.ui.toast(`🎲 Out of chips: tops up to ${chips(FLOOR)} tomorrow (UTC)`)
21 const left = book.chips - STAKE
22 await $.store.set(BOOK, { ...book, chips: left })
23 await update($, round, x => x && { ...x, bets: [...x.bets, bet], chips: left })
24}
25
26// Betting opens before the turn (while the prompt is written) and closes at its first tool call.
27const openNext = async ($: EngineInterface, book: Book, last?: SideBetSlip) =>
28 update($, round, () => ({ turnId: null, isOpen: true, offers: offers(book.rates), bets: [], chips: book.chips, ...(last ? { last } : {}) }))
29
30// ---- drawing -------------------------------------------------------------
31
32type CardElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Svg'>
33type BandProps = { bodyColumns: number; maxRows: number }
34
35const TITLE: Record<SideBetPick['market'], [wide: string, short: string]> = {
36 tests: ['Tests pass?', 'Tests pass?'],
37 files: ['Files touched', 'Files o/u'],
38 ask: ['Asks permission?', 'Asks perm?'],
39}
40const SHORT: Record<SideBetPick['market'], string> = { tests: 'Tests', files: 'Files', ask: 'Ask' }
41const isYes = (p: SideBetPick) => p.side === 'yes' || p.side === 'over'
42const sideColor = (p: SideBetPick) => (isYes(p) ? 'green' : 'red')
43const hotkey = (i: number, j: number) => String(i * 2 + j + 1)
44const ago = (s: SideBetSlip) =>
45 s.results.map(x => `${MARK[x.outcome]} ${TITLE[x.bet.market][1].replace('?', '')} ${x.bet.label} ${x.outcome === 'void' ? 'void' : signed(x.delta)}`).join(' · ')
46
47/** Terminal: a header, then one aligned row per market (MARKET | LINE | YES | NO), then last turn's result. */
48const terminalBand = ($: EngineInterface, { Box, Button, Text }: Elements['terminal'], r: SideBetRound, { bodyColumns: cols, maxRows }: BandProps) => {
49 const wide = cols >= 76 // room for the payout beside each price
50 const roomy = cols >= 58 // room for the LINE column and the column heads
51 const full = cols >= 100 // room for the status's explanation
52 const W = { market: roomy ? 18 : 14, line: 6, btn: 9, price: 5, pays: wide ? 10 : 0 }
53 const side = W.btn + W.price + W.pays
54 const showHeads = roomy && maxRows >= 5 + (r.last ? 1 : 0)
55 const showLast = r.last && r.last.results.length > 0 && maxRows >= 5
56
57 const cell = (p: SideBetPick, i: number, j: number) => (
58 <Box key={`${p.market}-${p.side}-cell`} width={side} flexDirection="row">
59 <Box width={W.btn}>
60 {r.isOpen ? (
61 <Button key={`${p.market}-${p.side}`} hotkey={hotkey(i, j)} plain label={p.label} onPress={() => place($, p)} />
62 ) : (
63 <Text dimColor>{` ${p.label}`}</Text>
64 )}
65 </Box>
66 <Box width={W.price}>
67 <Text color={r.isOpen ? sideColor(p) : undefined} dimColor={!r.isOpen} bold={r.isOpen}>{cents(p.p).padStart(3)}</Text>
68 </Box>
69 {wide ? (
70 <Box width={W.pays}>
71 <Text dimColor>{`pays ${p.payout}`}</Text>
72 </Box>
73 ) : null}
74 </Box>
75 )
76
77 const row = (o: SideBetOffer, i: number) => {
78 const placed = r.bets.find(b => b.market === o.market)
79 const title = roomy ? TITLE[o.market][0] : o.market === 'files' ? `${TITLE.files[1]} ${o.picks[0].line}` : TITLE[o.market][1]
80 return (
81 <Box key={o.market} flexDirection="row">
82 <Box width={W.market}>
83 <Text bold={!!placed} wrap="truncate">{title}</Text>
84 </Box>
85 {roomy ? (
86 <Box width={W.line}>
87 <Text dimColor>{o.market === 'files' ? String(o.picks[0].line) : ' –'}</Text>
88 </Box>
89 ) : null}
90 {placed ? (
91 <Text color={sideColor(placed)} wrap="truncate">
92 {`● ${placed.label.toUpperCase()} @ ${cents(placed.p)} ${STAKE} → pays ${placed.payout}${r.isOpen ? '' : ' · in play'}`}
93 </Text>
94 ) : (
95 r.isOpen ? [cell(o.picks[0], i, 0), cell(o.picks[1], i, 1)] : <Text dimColor>{` no position · ${o.picks.map(p => `${p.label} ${cents(p.p)}`).join(' / ')}`}</Text>
96 )}
97 </Box>
98 )
99 }
100
101 const staked = r.bets.length * STAKE
102 return (
103 <Box flexDirection="column">
104 <Box key="head" flexDirection="row" justifyContent="space-between">
105 <Box flexDirection="row" gap={1} flexShrink={0}>
106 <Text bold>🎲 SIDE BET</Text>
107 <Text inverse bold>{' PLAY MONEY '}</Text>
108 <Text>{`${chips(r.chips)} chips`}</Text>
109 {roomy ? <Text dimColor>{staked ? `· ${staked} at risk` : `· ${STAKE}/bet`}</Text> : null}
110 </Box>
111 <Box flexShrink={0}>
112 {r.isOpen ? (
113 // A pressed bet leaves the keyboard on the band, and only Esc hands it back to the prompt.
114 <Text color="green">{r.bets.length ? (full ? '● OPEN · esc to type your prompt' : '● OPEN · esc') : full ? '● OPEN · locks at first tool call' : '● OPEN'}</Text>
115 ) : (
116 <Text color="yellow">{full ? '◉ IN PLAY · betting closed' : '◉ CLOSED'}</Text>
117 )}
118 </Box>
119 </Box>
120 {showHeads ? (
121 <Box key="heads" flexDirection="row">
122 <Box width={W.market}><Text dimColor>MARKET</Text></Box>
123 <Box width={W.line}><Text dimColor>LINE</Text></Box>
124 <Box width={side}><Text dimColor>{' YES / OVER'}</Text></Box>
125 <Box width={side}><Text dimColor>{' NO / UNDER'}</Text></Box>
126 </Box>
127 ) : null}
128 {r.offers.map(row)}
129 {showLast ? (
130 <Box key="last" flexDirection="row" gap={1}>
131 <Box flexShrink={0}>
132 <Text dimColor>{roomy ? 'LAST TURN' : 'LAST'}</Text>
133 </Box>
134 <Text bold color={r.last!.isVoid ? undefined : r.last!.net > 0 ? 'green' : r.last!.net < 0 ? 'red' : undefined}>
135 {r.last!.isVoid ? 'refunded' : signed(r.last!.net)}
136 </Text>
137 <Text dimColor wrap="truncate">{ago(r.last!)}</Text>
138 </Box>
139 ) : null}
140 </Box>
141 )
142}
143
144const GREEN = '#16a34a'
145const RED = '#dc2626'
146
147/** A Yes/No probability bar: the Yes share in green, the rest in red, both labeled. */
148const probBar = (yes: SideBetPick, no: SideBetPick, dim: boolean) => {
149 const w = 220
150 const h = 22
151 const split = Math.round(w * Math.min(0.97, Math.max(0.03, yes.p)))
152 const op = dim ? 0.35 : 1
153 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
154 + `<defs><clipPath id="c"><rect width="${w}" height="${h}" rx="${h / 2}"/></clipPath></defs>`
155 + `<g clip-path="url(#c)" opacity="${op}"><rect width="${split}" height="${h}" fill="${GREEN}"/><rect x="${split}" width="${w - split}" height="${h}" fill="${RED}"/></g>`
156 + `<g font-family="system-ui,-apple-system,sans-serif" font-size="11" font-weight="600" fill="#fff">`
157 + `<text x="10" y="15">${yes.label} ${Math.round(yes.p * 100)}%</text>`
158 + `<text x="${w - 10}" y="15" text-anchor="end">${no.label} ${Math.round(no.p * 100)}%</text></g></svg>`
159}
160
161/** Desktop and other rich surfaces: a market card, one row per market with a probability bar and priced buttons. */
162const marketCard = ($: EngineInterface, { Box, Button, Svg, Text }: CardElements, r: SideBetRound) => {
163 const staked = r.bets.length * STAKE
164 const toWin = r.bets.reduce((n, b) => n + b.payout, 0)
165 return (
166 <Box flexDirection="column" borderStyle="round" borderColor={r.isOpen ? GREEN : 'gray'} paddingX={1} gap={1}>
167 <Box key="head" flexDirection="row" alignItems="center" gap={1}>
168 <Text bold>🎲 Side Bet</Text>
169 <Text backgroundColor="#6d28d9" color="#ffffff" bold>{' PLAY MONEY '}</Text>
170 <Box flexGrow={1} />
171 <Text bold>{`${chips(r.chips)} chips`}</Text>
172 <Text dimColor>{`· ${STAKE} per bet`}</Text>
173 <Text backgroundColor={r.isOpen ? GREEN : '#a16207'} color="#ffffff" bold>
174 {r.isOpen ? ' ● OPEN ' : ' ◉ IN PLAY · betting closed '}
175 </Text>
176 </Box>
177 {r.offers.map((o, i) => {
178 const placed = r.bets.find(b => b.market === o.market)
179 const [yes, no] = o.picks
180 return (
181 <Box key={o.market} flexDirection="row" alignItems="center" gap={2}>
182 <Box flexDirection="column" flexGrow={1}>
183 <Text bold>{TITLE[o.market][0]}</Text>
184 <Text dimColor>{o.market === 'files' ? `Over/under ${yes.line} files` : `Yes ${cents(yes.p)} · No ${cents(no.p)}`}</Text>
185 </Box>
186 <Svg source={probBar(yes, no, !r.isOpen && !placed)} alt={`${yes.label} ${cents(yes.p)}, ${no.label} ${cents(no.p)}`} width={220} height={22} />
187 {placed ? (
188 <Box borderStyle="round" borderColor={isYes(placed) ? GREEN : RED} paddingX={1}>
189 <Text color={isYes(placed) ? GREEN : RED} bold>{`${placed.label} ${cents(placed.p)} · ${STAKE} → ${placed.payout}`}</Text>
190 </Box>
191 ) : r.isOpen ? (
192 <Box flexDirection="row" gap={1}>
193 {o.picks.map((p, j) => (
194 <Button key={`${p.market}-${p.side}`} hotkey={hotkey(i, j)} variant={isYes(p) ? 'primary' : 'secondary'} label={`${p.label} ${cents(p.p)}`} onPress={() => place($, p)} />
195 ))}
196 </Box>
197 ) : (
198 <Text dimColor>no position</Text>
199 )}
200 </Box>
201 )
202 })}
203 <Box key="slip" flexDirection="row" gap={1}>
204 {r.bets.length ? (
205 <Text>{`Bet slip · ${r.bets.length} position${r.bets.length > 1 ? 's' : ''} · ${staked} staked · up to ${toWin} back`}</Text>
206 ) : r.last && r.last.results.length ? (
207 <Text dimColor>{`Last turn ${r.last.isVoid ? 'refunded' : signed(r.last.net)} · ${ago(r.last)}`}</Text>
208 ) : (
209 <Text dimColor>{r.isOpen ? 'Pick a side · betting locks at the first tool call · prices are implied odds' : 'No positions this turn'}</Text>
210 )}
211 </Box>
212 </Box>
213 )
214}
215
216export const register: Register = on => {
217 // ponytail: per-turn tallies live in the module; a hot reload mid-turn loses them (bets still settle).
218 let seen: Observed = { tests: null, files: 0, asked: false }
219 let files = new Set<string>()
220
221 on('session.start', async ($, e, next) => {
222 await $.command.register({ name: 'bets', description: 'Side bet: bankroll, record and calibration (play money)', immediate: true })
223 await openNext($, await getBook($))
224 return next(e)
225 })
226
227 on('command.run', { command: 'bets' }, async $ => {
228 const last = (await read($, round))?.last
229 const lines = [...formatStats(await getBook($)), ...(last?.results.length ? ['', ...formatSlip(last)] : [])]
230 return { text: '```\n' + lines.join('\n') + '\n```' }
231 })
232
233 // A main turn takes the open book, bets placed beforehand included (subagent runs raise no turn.start).
234 on('turn.start', async ($, e, next) => {
235 seen = { tests: null, files: 0, asked: false }
236 files = new Set()
237 const book = await getBook($)
238 await $.store.set(BOOK, book)
239 const prev = await read($, round)
240 // The last turn's slip leaves the band as the new turn starts.
241 const { last: _, ...open } = prev ?? {}
242 await update($, round, () => prev?.isOpen
243 ? { ...(open as SideBetRound), turnId: e.turnId, chips: book.chips }
244 : { turnId: e.turnId, isOpen: true, offers: offers(book.rates), bets: [], chips: book.chips })
245 return next(e)
246 })
247
248 on('tool.call', async ($, e, next) => {
249 const r = await read($, round)
250 if (r?.isOpen) await update($, round, x => x && { ...x, isOpen: false })
251 const ran = await next(e)
252 if (!r || ran.deny !== undefined) return ran
253
254 if (e.tool === 'Bash' && isTestCommand(e.command)) {
255 // A backgrounded run has no verdict yet.
256 if (ran.isError || !(ran.result as { backgroundTaskId?: string } | undefined)?.backgroundTaskId) seen.tests = !ran.isError
257 }
258 const path = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
259 if (EDITS.has(e.tool) && !ran.isError && typeof path === 'string') {
260 files.add(path)
261 seen.files = files.size
262 }
263 return ran
264 })
265
266 // An `ask` verdict on a real call means the person (or the mode's decider) was asked.
267 on('tool.check', async ($, e, next) => {
268 const verdict = await next(e)
269 if (verdict.decision === 'ask' && e.tool_use_id) seen.asked = true
270 return verdict
271 })
272
273 on('turn.complete', async ($, e, next) => {
274 const r = await read($, round)
275 if (e.agentId !== undefined || !r || r.turnId !== e.turnId) return next(e)
276 const isClean = e.reason === 'answer'
277 const book = await getBook($)
278 const out = settle(book, r.bets, isClean ? seen : null)
279 const saved: Book = isClean ? { ...out.book, rates: learn(book.rates, seen) } : out.book
280 await $.store.set(BOOK, saved)
281 const slip: SideBetSlip = { results: out.results, net: out.net, chips: saved.chips, isVoid: !isClean }
282 await openNext($, saved, r.bets.length ? slip : undefined)
283 if (r.bets.length) {
284 const legs = out.results.map(x => `${MARK[x.outcome]} ${SHORT[x.bet.market]} ${x.bet.label.toUpperCase()}${x.bet.line === undefined ? '' : ` ${x.bet.line}`} ${x.outcome === 'void' ? 'void' : signed(x.delta)}`)
285 const head = isClean ? `SETTLED ${signed(out.net)}` : 'CUT SHORT · refunded'
286 $.ui.toast(`🎲 ${head} │ ${legs.join(' · ')} │ ${chips(saved.chips)} play chips`, { timeoutMs: 8000 })
287 }
288 return next(e)
289 })
290
291 // The terminal folds a command's row onto one line; draw the /bets frame line by line instead (desktop keeps the code block).
292 on('ui.render', { component: 'CommandOutput', props: { command: 'bets' } }, async ($, e, next) => {
293 if (e.surface !== 'terminal' || e.props.isErrored) return next(e)
294 const { Box, Text } = $.ui.resolve(e)
295 const lines = e.props.text.replace(/^side-bet: /, '').split('\n').filter(l => !l.startsWith('```'))
296 return (
297 <Box flexDirection="column">
298 {lines.map((l, i) => (
299 <Text key={String(i)} color={l.includes('✓') ? 'green' : l.includes('✗') ? 'red' : undefined} dimColor={!l.includes('│')}>{l}</Text>
300 ))}
301 </Box>
302 )
303 })
304
305 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
306 const r = await read($, round)
307 if (e.props.hasSurvey || !r || (!r.isOpen && r.bets.length === 0)) return next(e)
308 const el = $.ui.resolve(e)
309 // vscode and mobile have the desktop's Box, Text, Button and Svg, so they get the card.
310 return e.surface === 'terminal' ? terminalBand($, el as Elements['terminal'], r, e.props) : marketCard($, el as CardElements, r)
311 })
312}
313hooks/logic.ts 170 lines1import type { SideBetMarket, SideBetOffer, SideBetPick, SideBetResult, SideBetSide, SideBetSlip } from '../types'
2
3export const STAKE = 100
4export const FLOOR = 1000
5export const EDGE = 0.05
6export const DAY = 86_400_000
7const HISTORY = 50
8
9export type Rate = { hits: number; n: number }
10export type Rates = { tests: Rate; ask: Rate; files: number[] }
11export type BetRecord = { wins: number; losses: number; voids: number; streak: number; bestWin: number; implied: number }
12export type Book = { chips: number; day: number; rates: Rates; record: BetRecord }
13/** What a turn did. `tests` is null when no test command ran. */
14export type Observed = { tests: boolean | null; files: number; asked: boolean }
15
16export const newBook = (): Book => ({
17 chips: FLOOR,
18 day: -1,
19 rates: { tests: { hits: 0, n: 0 }, ask: { hits: 0, n: 0 }, files: [] },
20 record: { wins: 0, losses: 0, voids: 0, streak: 0, bestWin: 0, implied: 0 },
21})
22
23/** A stored book, missing fields filled from a new one. */
24export const loadBook = (raw: unknown): Book => {
25 const b = (raw ?? {}) as Partial<Book>
26 const d = newBook()
27 return { ...d, ...b, rates: { ...d.rates, ...b.rates }, record: { ...d.record, ...b.record } }
28}
29
30/** Once a UTC day, a bankroll under the floor is topped back up to it. */
31export const refill = (book: Book, now: number): Book => {
32 const day = Math.floor(now / DAY)
33 return book.day === day ? book : { ...book, day, chips: Math.max(book.chips, FLOOR) }
34}
35
36const LAUNCH = String.raw`(?:(?:npx|bunx|uvx|pnpm(?:\s+exec)?|yarn|poetry\s+run|uv\s+run|pipenv\s+run|python3?\s+-m)\s+)?`
37const RUNNER = String.raw`(?:jest|vitest|mocha|pytest|tox|nox|rspec|phpunit|ava|playwright\s+test)`
38const SUBCMD = String.raw`(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test|(?:cargo|go|bun|deno|dotnet|mix|swift)\s+test|cargo\s+nextest|make\s+(?:test|check)|(?:\./)?gradlew?\s+test|mvn\s+test|python3?\s+-m\s+unittest|claude\s+plugin\s+test)`
39// ponytail: regex at command position (start, after ; & | or env assignments); a custom script name is missed.
40const TEST = new RegExp(String.raw`(?:^|[;&|(]\s*)(?:\w+=\S+\s+)*(?:${SUBCMD}|${LAUNCH}${RUNNER})(?![\w-])`)
41
42export const isTestCommand = (command: string) => TEST.test(command.trim())
43
44/** Laplace-smoothed base rate. */
45export const chance = (hits: number, n: number) => (hits + 1) / (n + 2)
46
47/** What a winning stake returns at probability `p`, less the house edge; `p` kept off the extremes. */
48export const payout = (p: number, stake = STAKE) => Math.round((stake * (1 - EDGE)) / Math.min(0.9, Math.max(0.05, p)))
49
50export const median = (xs: number[]) => {
51 const s = [...xs].sort((a, b) => a - b)
52 const m = s.length >> 1
53 return s.length === 0 ? null : s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2
54}
55
56/** The over/under line: the median files touched, on a half so it never pushes. */
57export const fileLine = (history: number[]) => {
58 const m = median(history)
59 return m === null ? 2.5 : Math.floor(m) + 0.5
60}
61
62const pick = (market: SideBetMarket, side: SideBetSide, label: string, p: number, line?: number): SideBetPick => ({
63 market, side, label, p, payout: payout(p), ...(line === undefined ? {} : { line }),
64})
65
66export const offers = (r: Rates): SideBetOffer[] => {
67 const tests = chance(r.tests.hits, r.tests.n)
68 const ask = chance(r.ask.hits, r.ask.n)
69 const line = fileLine(r.files)
70 const over = chance(r.files.filter(n => n > line).length, r.files.length)
71 return [
72 { market: 'tests', title: 'Tests pass?', picks: [pick('tests', 'yes', 'Yes', tests), pick('tests', 'no', 'No', 1 - tests)] },
73 { market: 'files', title: `Files touched, ${line}`, picks: [pick('files', 'over', 'Over', over, line), pick('files', 'under', 'Under', 1 - over, line)] },
74 { market: 'ask', title: 'Asks permission?', picks: [pick('ask', 'yes', 'Yes', ask), pick('ask', 'no', 'No', 1 - ask)] },
75 ]
76}
77
78/** The side that won a market, or 'void' (no test command ran). */
79export const winner = (bet: SideBetPick, o: Observed): SideBetSide | 'void' =>
80 bet.market === 'tests' ? (o.tests === null ? 'void' : o.tests ? 'yes' : 'no')
81 : bet.market === 'ask' ? (o.asked ? 'yes' : 'no')
82 : o.files > (bet.line ?? 2.5) ? 'over' : 'under'
83
84const SHORT: Record<SideBetMarket, string> = { tests: 'Tests', files: 'Files', ask: 'Ask' }
85
86/**
87 * Pays out the turn's bets (stakes were taken when placed). `o` null voids all:
88 * an aborted or errored turn. Returns the new book, the net and one line per bet.
89 */
90export const settle = (book: Book, bets: SideBetPick[], o: Observed | null) => {
91 let { chips } = book
92 const rec = { ...book.record }
93 let net = 0
94 const results: SideBetResult[] = []
95 const lines = bets.map(bet => {
96 const won = o === null ? 'void' : winner(bet, o)
97 const name = `${SHORT[bet.market]} ${bet.label}`
98 if (won === 'void') {
99 chips += STAKE
100 rec.voids += 1
101 results.push({ bet, outcome: 'void', delta: 0 })
102 return `${name} void`
103 }
104 rec.implied += bet.p
105 if (won === bet.side) {
106 chips += bet.payout
107 net += bet.payout - STAKE
108 rec.wins += 1
109 rec.streak = Math.max(rec.streak, 0) + 1
110 rec.bestWin = Math.max(rec.bestWin, bet.payout - STAKE)
111 results.push({ bet, outcome: 'won', delta: bet.payout - STAKE })
112 return `${name} won +${bet.payout - STAKE}`
113 }
114 net -= STAKE
115 rec.losses += 1
116 rec.streak = Math.min(rec.streak, 0) - 1
117 results.push({ bet, outcome: 'lost', delta: -STAKE })
118 return `${name} lost -${STAKE}`
119 })
120 return { book: { ...book, chips, record: rec }, net, lines, results }
121}
122
123/** Folds a finished turn into the base rates the odds come from. */
124export const learn = (r: Rates, o: Observed): Rates => ({
125 tests: o.tests === null ? r.tests : { hits: r.tests.hits + (o.tests ? 1 : 0), n: r.tests.n + 1 },
126 ask: { hits: r.ask.hits + (o.asked ? 1 : 0), n: r.ask.n + 1 },
127 files: [...r.files, o.files].slice(-HISTORY),
128})
129
130const pct = (x: number) => `${Math.round(x * 100)}%`
131const fmt = (n: number) => n.toLocaleString('en-US')
132
133/** A pick's price in cents: its implied probability, as a prediction market quotes it. */
134export const cents = (p: number) => `${Math.min(99, Math.max(1, Math.round(p * 100)))}¢`
135export const signed = (n: number) => (n > 0 ? `+${fmt(n)}` : n < 0 ? `−${fmt(-n)}` : '±0')
136export const MARK: Record<SideBetResult['outcome'], string> = { won: '✓', lost: '✗', void: '○' }
137
138/** Rows in a rounded frame, `title` and `tag` set into the top rule. */
139export const boxed = (title: string, tag: string, rows: string[], foot?: string) => {
140 const c = Math.max(title.length + tag.length + 5, ...rows.map(r => r.length), foot?.length ?? 0)
141 const line = (r: string) => `│ ${r.padEnd(c)} │`
142 return [
143 `╭─ ${title} ${'─'.repeat(c - title.length - tag.length - 4)} ${tag} ─╮`,
144 ...rows.map(line),
145 ...(foot ? [`├${'─'.repeat(c + 2)}┤`, line(foot)] : []),
146 `╰${'─'.repeat(c + 2)}╯`,
147 ]
148}
149
150const SLIP_NAME: Record<SideBetMarket, string> = { tests: 'Tests pass', files: 'Files o/u', ask: 'Asks perm.' }
151
152/** A settled turn as a bet slip. */
153export const formatSlip = (s: SideBetSlip) =>
154 boxed('BET SLIP', s.isVoid ? 'REFUNDED' : 'SETTLED', s.results.map(({ bet, outcome, delta }) =>
155 `${MARK[outcome]} ${`${SLIP_NAME[bet.market]}${bet.line === undefined ? '' : ` ${bet.line}`}`.padEnd(14)} ${bet.label.toUpperCase().padEnd(5)} @ ${cents(bet.p).padStart(3)} ${(outcome === 'void' ? 'void' : signed(delta)).padStart(5)}`),
156 `Net ${signed(s.net)} · bankroll ${fmt(s.chips)}`)
157
158export const formatStats = (b: Book) => {
159 const { wins, losses, voids, streak, bestWin, implied } = b.record
160 const decided = wins + losses
161 const { tests, ask, files } = b.rates
162 return boxed('SIDE BET', 'PLAY MONEY', [
163 `Bankroll ${fmt(b.chips)} chips (tops up to ${fmt(FLOOR)} daily)`,
164 `Record ${wins}W ${losses}L ${voids} void${streak ? ` · streak ${streak > 0 ? 'W' : 'L'}${Math.abs(streak)}` : ''}`,
165 `Best win ${bestWin ? `+${bestWin}` : 'none yet'}`,
166 `Calibration ${decided ? `won ${pct(wins / decided)} of ${decided} decided; prices implied ${pct(implied / decided)}` : 'no decided bets yet'}`,
167 `Base rates tests ${tests.hits}/${tests.n} passed · asked ${ask.hits}/${ask.n} · median files ${median(files) ?? 'n/a'}`,
168 ], 'Play money only: never bought, never cashed out')
169}
170types/index.d.ts 20 lines1export type SideBetMarket = 'tests' | 'files' | 'ask'
2export type SideBetSide = 'yes' | 'no' | 'over' | 'under'
3
4/** One side of a market at the odds offered: `payout` is what a winning stake returns, stake included. */
5export type SideBetPick = { market: SideBetMarket; side: SideBetSide; label: string; p: number; payout: number; line?: number }
6export type SideBetOffer = { market: SideBetMarket; title: string; picks: [SideBetPick, SideBetPick] }
7
8export type SideBetResult = { bet: SideBetPick; outcome: 'won' | 'lost' | 'void'; delta: number }
9/** A settled turn: `isVoid` when it was cut short and every stake came back. */
10export type SideBetSlip = { results: SideBetResult[]; net: number; chips: number; isVoid: boolean }
11
12/** The book for the next or current turn, for the band; `turnId` is null until that turn starts; `last` the previous turn's slip. */
13export type SideBetRound = { turnId: string | null; isOpen: boolean; offers: SideBetOffer[]; bets: SideBetPick[]; chips: number; last?: SideBetSlip }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'side-bet': { round: SideBetRound | null }
18 }
19}
20