SLOPSHOPPER

side-bet

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

newbandrowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-bet
› 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 › /bets ╭─ SIDE BET ──────────────────────────────────── PLAY MONEY ─╮ │ Bankroll 1,000 chips (tops up to 1,000 daily) │ │ Record 0W 0L 0 void │ │ Best win none yet │ │ Calibration no decided bets yet │ │ Base rates tests 0/1 passed · asked 0/1 · median files 3 │ ├────────────────────────────────────────────────────────────┤ │ Play money only: never bought, never cashed out │ ╰────────────────────────────────────────────────────────────╯ 🎲 SIDE BET PLAY MONEY 1,000 chips · 100/bet ● OPEN · locks at first tool call MARKET LINE YES / OVER NO / UNDER Tests pass? – 1: Yes 33¢ pays 285 2: No 67¢ pays 142 Files touched 3.5 3: Over 33¢ pays 285 4: Under 67¢ pays 142 Asks permission? – 5: Yes 33¢ pays 285 6: No 67¢ pays 142 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🎲 SIDE BET PLAY MONEY 1,000 chips · 100/bet ● OPEN · locks at first tool call MARKET LINE YES / OVER NO / UNDER Tests pass? – 1: Yes 33¢ pays 285 2: No 67¢ pays 142 Files touched 3.5 3: Over 33¢ pays 285 4: Under 67¢ pays 142 Asks permission? – 5: Yes 33¢ pays 285 6: No 67¢ pays 142
Command output
╭─ SIDE BET ──────────────────────────────────── PLAY MONEY ─╮ │ Bankroll 1,000 chips (tops up to 1,000 daily) │ │ Record 0W 0L 0 void │ │ Best win none yet │ │ Calibration no decided bets yet │ │ Base rates tests 0/1 passed · asked 0/1 · median files 3 │ ├────────────────────────────────────────────────────────────┤ │ Play money only: never bought, never cashed out │ ╰────────────────────────────────────────────────────────────╯
README

claude-mods

Claude Code mods by @sreshtalluri.

session-receipt: a receipt for every Claude Code session

Install

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

Mods

ModWhat 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-asksAfter 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.

session-receipt

session-receipt demo

Command
/receiptThis session: model, time, turns, tools, files, tokens, cache hit, cost, and how many times Claude said you were absolutely right.
/receipt weekThe last seven days: sessions, busiest day, top project, usual model, total.
/receipt lastThe previous session's receipt.
  • Copy image puts a 1200×1200 PNG on your clipboard on macOS, Windows and Linux. If nothing can draw it, the image is saved and its path is shown.
  • Every session's receipt is saved to ~/.claude/receipts/ when it ends. Nothing leaves your machine.
  • In the terminal it's a text slip; in the desktop app it's drawn as paper.

Adding a mod

  1. Put it in plugins/<name>/ (with .claude-plugin/plugin.json).
  2. Add an entry to .claude-plugin/marketplace.json.
  3. 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.

License

MIT

Source 3 files
hooks/register.tsx 313 lines
1import { 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}
313
hooks/logic.ts 170 lines
1import 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}
170
types/index.d.ts 20 lines
1export 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