SLOPSHOPPER

laurels

KOZMOS Laurels: 32 achievements earned from what the session does (Hydra, Night Owl, Cache Wizard, Bug Squasher, Polyglot, Centurion, Committed and more), kept…

newpanebandguardcommandtoast
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/laurels
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · laurels
│ ┃ KOZMOS · Laurels ✕ › fix the failing auth test and a╭─────────────────────────────────╮ │ ┃ 🏆 LAURELS ▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 2/32 │ laurels │ │ ┃ ⏺ Read(src/auth.ts) │ 🏆 Shipwright unlocked — │ │ ┃ ✦ First Light Oct 9 · Finished your very fi ⎿ Read 6 lines │ shipped │ │ ┃ ⛵ Shipwright Oct 9 · A successful git push. ⏺ Update(src/auth.ts) ╰─────────────────────────────────╯ │ ┃ ○ Architect ▰▰▰▱▱▱▱▱▱▱ 3/10 · Write or edit ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ ○ Polyglot ▰▰▱▱▱▱▱▱▱▱ 1/5 · Edit files with ⏺ Bash(bun test) │ laurels │ │ ┃ ○ Devotee ▰▱▱▱▱▱▱▱▱▱ 1/7 · Finish turns on ⎿ 3 pass, 1 fail │ 🏆 First Light unlocked — the first of │ │ ┃ ○ Centurion ▰▱▱▱▱▱▱▱▱▱ 9/100 · Make 100 too │ many │ │ ┃ ○ Big Spender ▱▱▱▱▱▱▱▱▱▱ $0.42/$10 · Spend ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ ○ Needle Finder ▱▱▱▱▱▱▱▱▱▱ 1/25 · Run 25 Gr │ ┃ ○ Archaeologist ▱▱▱▱▱▱▱▱▱▱ 1/50 · Read 50 f ✻ Worked for 42s · done 4:20 PM │ ┃ ○ Regular ▱▱▱▱▱▱▱▱▱▱ 1/100 · Finish 100 tur │ ┃ ○ Hydra ▱▱▱▱▱▱▱▱▱▱ 0/5 · Have five subagent › /laurels │ ┃ ○ Delegator ▱▱▱▱▱▱▱▱▱▱ 0/10 · Spawn ten sub ⎿ laurels: Laurels: 2/32 unlocked. │ ┃ ○ Night Owl · Work between 00:00 and 05:00. │ ┃ ○ Early Bird · Work between 05:00 and 07:00 │ ┃ ○ Weekend Warrior · Finish a turn on Saturd │ ┃ ○ Marathon ▱▱▱▱▱▱▱▱▱▱ 0h00/2h · Keep one se │ ┃ ○ Penny Pincher · Run ten tools in a turn t │ ┃ ○ Cache Wizard · Have a turn read over 90% │ ┃ ○ Context Surfer · Start a turn with the co │ ┃ ○ Bug Squasher · Make a failing shell comma │ ┃ ○ Speed Demon · Finish a turn with 3+ tools │ ┃ ○ Committed · Commit with git from the shel │ ┃ ○ Test Pilot · Run the tests and see them p ⟨Claude Code's own drawing⟩ ▣ client module ./burst.tsx✕ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./burst.tsx✕
Pane · KOZMOS · Laurels
🏆 LAURELS ▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 2/32 ✦ First Light Oct 9 · Finished your very first turn. ⛵ Shipwright Oct 9 · A successful git push. ○ Architect ▰▰▰▱▱▱▱▱▱▱ 3/10 · Write or edit ten files in on ○ Polyglot ▰▰▱▱▱▱▱▱▱▱ 1/5 · Edit files with five different ○ Devotee ▰▱▱▱▱▱▱▱▱▱ 1/7 · Finish turns on seven different ○ Centurion ▰▱▱▱▱▱▱▱▱▱ 9/100 · Make 100 tool calls in a ses ○ Big Spender ▱▱▱▱▱▱▱▱▱▱ $0.42/$10 · Spend $10 in one sessi ○ Needle Finder ▱▱▱▱▱▱▱▱▱▱ 1/25 · Run 25 Grep or Glob searc ○ Archaeologist ▱▱▱▱▱▱▱▱▱▱ 1/50 · Read 50 files in a sessio ○ Regular ▱▱▱▱▱▱▱▱▱▱ 1/100 · Finish 100 turns in all. ○ Hydra ▱▱▱▱▱▱▱▱▱▱ 0/5 · Have five subagents running at the ○ Delegator ▱▱▱▱▱▱▱▱▱▱ 0/10 · Spawn ten subagents in a sess ○ Night Owl · Work between 00:00 and 05:00. ○ Early Bird · Work between 05:00 and 07:00. ○ Weekend Warrior · Finish a turn on Saturday or Sunday. ○ Marathon ▱▱▱▱▱▱▱▱▱▱ 0h00/2h · Keep one session going for ○ Penny Pincher · Run ten tools in a turn that costs under ○ Cache Wizard · Have a turn read over 90% of its input fro ○ Context Surfer · Start a turn with the context over 90% f ○ Bug Squasher · Make a failing shell command pass. ○ Speed Demon · Finish a turn with 3+ tools in under 10 s. ○ Committed · Commit with git from the shell. ○ Test Pilot · Run the tests and see them pass. ○ Taskmaster ▱▱▱▱▱▱▱▱▱▱ 0/10 · Complete ten todos or tasks. ○ Web Crawler ▱▱▱▱▱▱▱▱▱▱ 0/10 · Search or fetch the web ten ○ Switchboard ▱▱▱▱▱▱▱▱▱▱ 0/3 · Use tools of three different ○ Undaunted · Push through five failed tools in one turn. ○ Deep Thinker · Let one turn run ten minutes. ○ Haiku · Get a tool-free answer in under 5 s. ○ Clean Slate · Make 20 tool calls without a single failure ○ Token Tsunami ▱▱▱▱▱▱▱▱▱▱ 0% · Move a million tokens in a ○ Lab Notes · Edit a notebook cell.
README

Laurels

Achievements for what you and Claude get up to: 32 badges, each with a glyph, a description and a check over the session's events. Counters and unlock dates are kept across sessions.

A few of them: First Light (first turn), Hydra (five subagents running at once), Night Owl (a turn between 00:00 and 05:00), Marathon (a two-hour session), Big Spender ($10 session), Penny Pincher (ten tools in a turn under $0.05), Cache Wizard (cache reads over 90 % of a turn's input), Context Surfer (starting a turn past 90 % context), Bug Squasher (a failed shell command later passes, same command), Polyglot (five file extensions edited), Speed Demon, Centurion, Architect, Archaeologist, Committed, Shipwright, Test Pilot, Delegator, Taskmaster, Switchboard, Undaunted, Deep Thinker, Haiku, Clean Slate, Token Tsunami, Lab Notes, Devotee, Regular and more.

On an unlock: a toast (🏆 Hydra unlocked — five agents at once) and a six-second celebration band: a hopping trophy, the name in a travelling shimmer and drifting confetti on the terminal; a medal popping in over turning rays with falling confetti on the desktop.

/laurels opens the gallery: unlocked badges in color with their dates, locked ones gray with a hint and a progress bar for counter badges, and the total (17/32). On the desktop it is a grid of medals with ribbons and a wreath; recent unlocks glow and shine. /laurels hide / show (or ✕) controls the celebration band.

Türkçe

Oturumda yaptıklarınız için başarımlar: 32 rozet, her birinin simgesi, açıklaması ve olaylar üzerinden bir kontrolü var; sayaçlar ve açılma tarihleri oturumlar arasında saklanır (Hydra, Night Owl, Cache Wizard, Bug Squasher, Polyglot, Centurion, Committed ve dahası).

Açılınca bildirim ve 6 saniyelik kutlama bandı çıkar (terminalde zıplayan kupa ve konfeti, masaüstünde dönen ışınlar ve düşen konfeti). /laurels galeriyi açar: açılanlar renkli ve tarihli, kilitliler gri, ipucu ve ilerleme çubuğuyla; toplam 17/32. Masaüstünde kurdeleli madalya ızgarası, yeni açılanlar parlar.

Source 5 files
hooks/register.tsx 578 lines
1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ElementConstructor, EngineInterface, Register, TextProps } from 'claude-code'
3
4import type { LaurelsBurst } from '../types'
5import { KZ, clamp01, costOf, fitText, mix, pips, pxOf, svg, svgBar, svgText, textWidth, tokensOf, xml } from './lib/kz.ts'
6import {
7  BADGES, badgeById, dayKey, emptyFacts, extOf, isGitCommit, isGitPush, isTestRun, mcpServerOf, newlyUnlocked,
8  normCommand, progressOf, sanitizeLife, todosDone, turnFlags,
9} from './badges.ts'
10import type { Badge, Facts, TurnFacts } from './badges.ts'
11
12const PANE = 'kz-laurels'
13const TITLE = 'KOZMOS · Laurels'
14const BURST_MS = 6000
15const RECENT_MS = 24 * 3600_000
16
17const unlockedAtom = atom({ plugin: 'laurels', key: 'unlocked' } as const, {})
18const progressAtom = atom({ plugin: 'laurels', key: 'progress' } as const, {})
19const burstAtom = atom({ plugin: 'laurels', key: 'burst' } as const, null)
20const hiddenAtom = atom({ plugin: 'laurels', key: 'isHidden' } as const, false)
21
22// Module memory; session.start fills it from $.store.
23let facts: Facts = emptyFacts()
24let unlocked: Record<string, number> = {}
25let turn: (TurnFacts & { files: number; paths: string[] }) | null = null
26let failedCommands = new Set<string>()
27let lastTodos: { content: string; status: string }[] = []
28let ctxPercent = 0
29let queue: string[] = []
30let isBursting = false
31let lastProgress = ''
32/** Badges an evaluation is unlocking right now, so a concurrent one leaves them be. */
33const claiming = new Set<string>()
34
35function sanitizeUnlocked(v: unknown): Record<string, number> {
36  const out: Record<string, number> = {}
37  if (v && typeof v === 'object') for (const [k, t] of Object.entries(v as Record<string, unknown>)) if (typeof t === 'number' && badgeById(k)) out[k] = t
38  return out
39}
40
41async function persist($: EngineInterface): Promise<void> {
42  try {
43    await $.store.set('unlocked', unlocked)
44    await $.store.set('life', facts.life)
45  } catch {
46    // Kept for this session.
47  }
48}
49
50async function showNext($: EngineInterface): Promise<void> {
51  const id = queue.shift()
52  if (id === undefined) {
53    isBursting = false
54    await update($, burstAtom, () => null)
55    return
56  }
57  isBursting = true
58  const burst: LaurelsBurst = { id, at: await $.clock.now(), more: queue.length }
59  await update($, burstAtom, () => burst)
60  $.clock.after(BURST_MS, () => void showNext($).catch(() => undefined))
61}
62
63async function evaluate($: EngineInterface): Promise<void> {
64  // Tool calls run in parallel: claim the fresh badges before the first wait,
65  // or two evaluations would both unlock (and toast) the same one.
66  const fresh = newlyUnlocked(facts, unlocked).filter(b => !claiming.has(b.id))
67  for (const b of fresh) claiming.add(b.id)
68  try {
69    const progress = progressOf(facts)
70    const key = JSON.stringify(progress)
71    if (key !== lastProgress) {
72      lastProgress = key
73      await update($, progressAtom, () => progress)
74    }
75    if (fresh.length === 0) return
76    const now = await $.clock.now()
77    for (const b of fresh) {
78      unlocked[b.id] = now
79      $.ui.toast(`🏆 ${b.name} unlocked — ${b.cheer}`, { timeoutMs: 7000 })
80      queue.push(b.id)
81    }
82    const snapshot = { ...unlocked }
83    await update($, unlockedAtom, () => snapshot)
84    await persist($)
85    if (!isBursting) await showNext($)
86    else await update($, burstAtom, b => (b ? { ...b, more: queue.length } : b))
87  } finally {
88    for (const b of fresh) claiming.delete(b.id)
89  }
90}
91
92async function poll($: EngineInterface): Promise<void> {
93  try {
94    const u = await $.session.usage()
95    facts.costUsd = u.cost?.usd ?? facts.costUsd
96    ctxPercent = u.context.percent ?? ctxPercent
97    if (u.startedAt > 0) facts.sessionMs = Math.max(0, (await $.clock.now()) - u.startedAt)
98  } catch {
99    // Figures the engine cannot give now stay as they were.
100  }
101  await countRunning($)
102  await evaluate($)
103}
104
105async function countRunning($: EngineInterface): Promise<void> {
106  try {
107    const agents = await $.agent.list()
108    const running = agents.filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting').length
109    facts.maxRunning = Math.max(facts.maxRunning, running)
110  } catch {
111    // No agent list here.
112  }
113}
114
115async function setHidden($: EngineInterface, value: boolean): Promise<void> {
116  await update($, hiddenAtom, () => value)
117  try {
118    await $.store.set('hidden', value)
119  } catch {
120    // Session only.
121  }
122}
123
124async function togglePane($: EngineInterface): Promise<boolean> {
125  if ((await $.ui.panes()).some(p => p.id === PANE)) {
126    await $.ui.close({ id: PANE })
127    return false
128  }
129  await $.ui.open({ id: PANE, title: TITLE })
130  return true
131}
132
133function str(e: unknown, k: string): string {
134  const v = (e as Record<string, unknown>)[k]
135  return typeof v === 'string' ? v : ''
136}
137
138function noteTool(tool: string, e: unknown, failed: boolean): void {
139  facts.tools++
140  facts.life.tools++
141  if (turn) turn.tools++
142  if (failed) {
143    facts.fails++
144    if (turn) turn.fails++
145  }
146  if (tool === 'Read') facts.reads++
147  if (tool === 'Grep' || tool === 'Glob') facts.searches++
148  if (tool === 'WebFetch' || tool === 'WebSearch') facts.web++
149  const server = mcpServerOf(tool)
150  if (server && !failed && !facts.mcpServers.includes(server)) facts.mcpServers.push(server)
151  if (!failed && (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit' || tool === 'MultiEdit')) {
152    const path = str(e, 'file_path') || str(e, 'notebook_path')
153    const ext = extOf(path)
154    if (ext && !facts.exts.includes(ext)) facts.exts.push(ext)
155    if (tool === 'NotebookEdit' || ext === '.ipynb') facts.flags.labNotes = true
156    if (turn && path && !turn.paths.includes(path)) {
157      turn.paths.push(path)
158      turn.files = turn.paths.length
159      facts.bestFilesInTurn = Math.max(facts.bestFilesInTurn, turn.files)
160    }
161  }
162  if (tool === 'Bash' || tool === 'PowerShell') {
163    const cmd = normCommand(str(e, 'command'))
164    if (cmd) {
165      if (failed) failedCommands.add(cmd)
166      else {
167        if (failedCommands.has(cmd)) facts.flags.bugSquasher = true
168        if (isGitCommit(cmd)) facts.flags.committed = true
169        if (isGitPush(cmd)) facts.flags.pushed = true
170        if (isTestRun(cmd)) facts.flags.testPilot = true
171      }
172    }
173  }
174  if (!failed && tool === 'TodoWrite') {
175    const todos = (e as Record<string, unknown>).todos
176    if (Array.isArray(todos)) {
177      const list = todos.map(t => ({ content: String((t as Record<string, unknown>).content ?? ''), status: String((t as Record<string, unknown>).status ?? '') }))
178      facts.life.tasksDone += todosDone(lastTodos, list)
179      lastTodos = list
180    }
181  }
182  if (!failed && tool === 'TaskUpdate' && str(e, 'status') === 'completed') facts.life.tasksDone++
183}
184
185export const register: Register = on => {
186  on('session.start', async ($, e, next) => {
187    const started = await next(e)
188    turn = null
189    failedCommands = new Set()
190    lastTodos = []
191    ctxPercent = 0
192    queue = []
193    isBursting = false
194    lastProgress = ''
195    try {
196      unlocked = sanitizeUnlocked(await $.store.get('unlocked'))
197      facts = emptyFacts(sanitizeLife(await $.store.get('life')))
198      const isHidden = (await $.store.get('hidden')) === true
199      await update($, hiddenAtom, () => isHidden)
200    } catch {
201      unlocked = {}
202      facts = emptyFacts()
203    }
204    const snapshot = { ...unlocked }
205    await update($, unlockedAtom, () => snapshot)
206    await update($, burstAtom, () => null)
207    await $.command.register({ name: 'laurels', description: 'KOZMOS: open the achievements gallery (hide / show the unlock band)', argumentHint: '[hide | show]', immediate: true })
208    await evaluate($).catch(() => undefined)
209    $.clock.every(5000, () => void poll($).catch(() => undefined))
210    return started
211  })
212
213  on('command.run', { command: 'laurels' }, async ($, e) => {
214    const verb = e.args.trim().toLowerCase()
215    if (verb === 'hide' || verb === 'show') {
216      await setHidden($, verb === 'hide')
217      return { text: verb === 'hide' ? 'Laurels celebrations hidden (toasts still show).' : 'Laurels celebrations shown.' }
218    }
219    const open = await togglePane($)
220    return { text: open ? `Laurels: ${Object.keys(unlocked).length}/${BADGES.length} unlocked.` : 'Laurels closed.' }
221  })
222
223  on('session.measure', async ($, e, next) => {
224    const r = await next(e)
225    ctxPercent = e.context.percent ?? ctxPercent
226    if (e.cost) facts.costUsd = e.cost.usd
227    if (e.changed.includes('cost')) await evaluate($)
228    return r
229  }).catch(($, e, next) => next(e))
230
231  on('turn.start', async ($, e, next) => {
232    const now = await $.clock.now()
233    turn = { startedAt: now, durationMs: 0, tools: 0, fails: 0, files: 0, paths: [], steps: 0, estUsd: 0, input: 0, cacheRead: 0, cacheWrite: 0, reason: '' }
234    if (ctxPercent >= 90) {
235      facts.flags.contextSurfer = true
236      await evaluate($)
237    }
238    return next(e)
239  }).catch(($, e, next) => next(e))
240
241  on('turn.step', async function* ($, e, next) {
242    const r = yield* next(e)
243    if (r.usage) {
244      facts.tokens += tokensOf(r.usage)
245      if (turn) {
246        turn.steps++
247        turn.estUsd += costOf(r.usage.model || e.model, r.usage)
248        if (e.agentId === undefined) {
249          turn.input += r.usage.input_tokens
250          turn.cacheRead += r.usage.cache_read_input_tokens
251          turn.cacheWrite += r.usage.cache_creation_input_tokens
252        }
253      }
254    }
255    return r
256  })
257
258  on('tool.call', async ($, e, next) => {
259    const ran = await next(e)
260    noteTool(String(e.tool), e, ran.isError === true || ran.deny !== undefined)
261    await evaluate($)
262    return ran
263  }).catch(($, e, next) => next(e))
264
265  on('agent.spawn', async ($, e, next) => {
266    const r = await next(e)
267    if ((r as { agentId?: string }).agentId) {
268      facts.agents++
269      await countRunning($)
270      await evaluate($)
271    }
272    return r
273  }).catch(($, e, next) => next(e))
274
275  on('turn.complete', async ($, e, next) => {
276    const done = await next(e)
277    if (e.agentId !== undefined || !turn) return done
278    const t = { ...turn, durationMs: e.durationMs, reason: e.reason }
279    turn = null
280    const d = new Date(t.startedAt)
281    for (const f of turnFlags(t, d.getHours(), d.getDay())) facts.flags[f] = true
282    facts.turns++
283    facts.life.turns++
284    const day = dayKey(t.startedAt)
285    if (!facts.life.days.includes(day)) facts.life.days = [...facts.life.days, day].slice(-60)
286    if (facts.tools >= 20 && facts.fails === 0) facts.flags.cleanSlate = true
287    await evaluate($)
288    await persist($)
289    return done
290  }).catch(($, e, next) => next(e))
291
292  // ---- the celebration band -------------------------------------------------
293  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
294    const drawn = await next(e)
295    const burst = await read($, burstAtom)
296    // A burst names a badge of this book (one renamed since a reload draws nothing).
297    const b = burst ? badgeById(burst.id) : undefined
298    if (!burst || !b || e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
299    const cols = Math.max(40, e.props.bodyColumns || 80)
300    let mine
301    if (e.surface === 'terminal') {
302      const { Client } = $.ui.resolve(e)
303      mine = <Client key="laurels-burst" module="./burst.tsx" width={cols - 3} height={1} props={{ cols: cols - 3, name: b.name, glyph: b.glyph, color: b.color, cheer: b.cheer, more: burst.more }} />
304    } else {
305      // Every other surface draws Svg.
306      const { Svg } = $.ui.resolve(e)
307      const W = pxOf(cols)
308      mine = <Svg source={burstSvg(b, W, burst.more)} alt={`Achievement unlocked: ${b.name}. ${b.desc}`} width={W} height={44} />
309    }
310    const { Box, Button } = $.ui.resolve(e)
311    const hide = <Button key="laurels-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
312    return (
313      <Box flexDirection="column">
314        {drawn}
315        <Box key="laurels" flexDirection="row">
316          {mine}
317          {hide}
318        </Box>
319      </Box>
320    )
321  })
322
323  // ---- the gallery ----------------------------------------------------------
324  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
325    const got = await read($, unlockedAtom)
326    const progress = await read($, progressAtom)
327    const now = await $.clock.now()
328    const ui = $.ui.resolve(e)
329    const { Box, Text } = ui
330    const cols = Math.max(36, e.props.bodyColumns || 60)
331    const ordered = orderBadges(got, progress)
332    const count = Object.keys(got).length
333
334    if ('Svg' in ui && e.surface !== 'terminal') {
335      const { Svg } = ui
336      const W = pxOf(cols, 60)
337      const head = galleryHeader(W, count, got)
338      const rows = medalRows(ordered, got, progress, now, W)
339      return (
340        <Box flexDirection="column">
341          <Svg key="lh" source={head} alt={`Laurels: ${count} of ${BADGES.length} unlocked`} width={W} height={64} />
342          {rows.map((r, i) => <Svg key={`lr${i}`} source={r.source} alt={r.alt} width={W} height={r.height} />)}
343        </Box>
344      )
345    }
346
347    const twoCols = cols >= 100
348    const colW = twoCols ? Math.floor((cols - 2) / 2) : cols
349    const lines = ordered.map(b => badgeLine(b, got[b.id], progress[b.id] ?? 0, colW, Box, Text))
350    const grid = twoCols
351      ? Array.from({ length: Math.ceil(lines.length / 2) }, (_, i) => (
352          <Box key={`g${i}`} flexDirection="row">
353            {lines.slice(i * 2, i * 2 + 2).map((line, j) => <Box key={`c${j}`} width={colW} marginRight={j === 0 ? 2 : 0}>{line}</Box>)}
354          </Box>
355        ))
356      : lines
357    return (
358      <Box flexDirection="column">
359        <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
360          <Text bold color={KZ.yellow}>🏆 LAURELS</Text>
361          <Text>
362            <Text color={KZ.yellow}>{pips(count / BADGES.length, Math.min(20, Math.max(6, cols - 30)))}</Text>
363            <Text bold> {count}/{BADGES.length}</Text>
364          </Text>
365        </Box>
366        {grid}
367      </Box>
368    )
369  })
370}
371
372// ---------------------------------------------------------------------------
373// Drawing.
374
375const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
376
377function dateOf(ms: number): string {
378  const d = new Date(ms)
379  return `${MONTHS[d.getMonth()]} ${d.getDate()}`
380}
381
382/** Unlocked first (newest first), then locked by how close they are. */
383function orderBadges(got: Readonly<Record<string, number>>, progress: Readonly<Record<string, number>>): Badge[] {
384  const done = BADGES.filter(b => got[b.id] !== undefined).sort((a, b) => Number(got[b.id]) - Number(got[a.id]))
385  const left = BADGES.filter(b => got[b.id] === undefined).sort((a, b) => (progress[b.id] ?? 0) / b.goal - (progress[a.id] ?? 0) / a.goal)
386  return [...done, ...left]
387}
388
389function fmtGoal(b: Badge, v: number): string {
390  if (b.id === 'big-spender') return `$${v.toFixed(2)}/$${b.goal}`
391  if (b.id === 'marathon') return `${Math.floor(v / 60)}h${String(Math.floor(v % 60)).padStart(2, '0')}/2h`
392  if (b.goal >= 100_000) return `${Math.round((v / b.goal) * 100)}%`
393  return `${Math.floor(v)}/${b.goal}`
394}
395
396
397function badgeLine(b: Badge, at: number | undefined, value: number, width: number, Box: ElementConstructor<BoxProps>, Text: ElementConstructor<TextProps>) {
398  if (at !== undefined) {
399    return (
400      <Box key={b.id} flexDirection="row">
401        <Box width={3} flexShrink={0}><Text color={b.color}>{b.glyph}</Text></Box>
402        <Text wrap="truncate-end">
403          <Text bold color={b.color}>{b.name}</Text>
404          <Text dimColor> {dateOf(at)} · {b.desc}</Text>
405        </Text>
406      </Box>
407    )
408  }
409  const isCounter = b.goal > 1
410  const barW = Math.max(4, Math.min(10, width - b.name.length - 18))
411  return (
412    <Box key={b.id} flexDirection="row">
413      <Box width={3} flexShrink={0}><Text dimColor>○</Text></Box>
414      <Text wrap="truncate-end">
415        <Text dimColor bold>{b.name} </Text>
416        {isCounter ? <Text color="#6b7280">{pips(clamp01(value / b.goal), barW)} {fmtGoal(b, value)} </Text> : null}
417        <Text dimColor>· {b.hint}</Text>
418      </Text>
419    </Box>
420  )
421}
422
423function galleryHeader(W: number, count: number, got: Readonly<Record<string, number>>): string {
424  const latest = Object.entries(got).sort((a, b) => b[1] - a[1])[0]
425  const lb = latest ? badgeById(latest[0]) : undefined
426  const parts: string[] = []
427  parts.push(`<defs><linearGradient id="lhG" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#f59e0b"/><stop offset="1" stop-color="#fde047"/></linearGradient></defs>`)
428  parts.push(`<rect class="p" x="0" y="0" width="${W}" height="56" rx="14"/>`)
429  parts.push(`<text x="16" y="36" font-size="24">🏆</text>`)
430  parts.push(svgText(52, 25, 'LAURELS', { size: 11, weight: 800, fill: '#d97706' }))
431  parts.push(svgText(52, 44, `${count} of ${BADGES.length} unlocked`, { size: 15, weight: 700 }))
432  const bx = Math.max(220, W * 0.45)
433  const bw = W - bx - 16
434  if (bw > 60) {
435    parts.push(svgBar(bx, 20, bw, 8, count / BADGES.length, 'url(#lhG)'))
436    if (lb && latest) parts.push(svgText(W - 16, 45, fitText(`latest · ${lb.name} · ${dateOf(latest[1])}`, 10.5, bw), { cls: 'm', size: 10.5, anchor: 'end' }))
437  }
438  return svg(W, 64, parts.join(''))
439}
440
441/** Splits text into at most `max` lines of `width` px. */
442function wrapText(s: string, size: number, width: number, max: number): string[] {
443  const lines: string[] = []
444  let cur = ''
445  for (const w of s.split(' ')) {
446    const next = cur ? `${cur} ${w}` : w
447    if (textWidth(next, size) > width && cur) {
448      lines.push(cur)
449      cur = w
450      if (lines.length === max) break
451    } else cur = next
452  }
453  if (lines.length < max && cur) lines.push(cur)
454  if (lines.length === max && textWidth(lines.join(' '), size) < textWidth(s, size) - 1) lines[max - 1] = fitText(`${lines[max - 1]} …`, size, width)
455  return lines
456}
457
458const MEDAL_CSS = `
459.lr-lockg{filter:grayscale(1);opacity:.4}
460.lr-shn{animation:lsh 3.2s ease-in-out infinite}@keyframes lsh{0%{transform:translateX(-70px)}55%,100%{transform:translateX(70px)}}
461.lr-glow{transform-box:fill-box;transform-origin:center;animation:lgl 2.4s ease-in-out infinite}@keyframes lgl{50%{transform:scale(1.08);opacity:.15}}
462.lr-pop{transform-box:fill-box;transform-origin:center;animation:lpop .6s cubic-bezier(.2,1.6,.4,1) both}@keyframes lpop{from{transform:scale(.4);opacity:0}}
463`
464
465function medal(b: Badge, cx: number, cy: number, at: number | undefined, value: number, now: number, idx: string): string {
466  const R = 30
467  if (at === undefined) {
468    let s = `<circle class="k" cx="${cx}" cy="${cy}" r="${R}"/><circle cx="${cx}" cy="${cy}" r="${R - 6}" fill="none" class="ln" stroke-width="1.5" stroke-dasharray="3 4"/>`
469    if (b.goal > 1 && value > 0) {
470      const C = 2 * Math.PI * (R - 2)
471      const p = clamp01(value / b.goal)
472      s += `<circle cx="${cx}" cy="${cy}" r="${R - 2}" fill="none" stroke="${b.color}" stroke-width="3" stroke-linecap="round" stroke-dasharray="${(C * p).toFixed(1)} ${C.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})" opacity=".8"/>`
473    }
474    s += `<text class="lr-lockg" x="${cx}" y="${cy + 8}" font-size="22" text-anchor="middle">${xml(b.glyph)}</text>`
475    return s
476  }
477  const isRecent = now - at < RECENT_MS
478  const light = mix(b.color, '#ffffff', 0.55)
479  const dark = mix(b.color, '#000000', 0.35)
480  let s = `<defs><radialGradient id="lmf-${idx}" cx=".38" cy=".32" r=".8"><stop offset="0" stop-color="${light}"/><stop offset=".55" stop-color="${b.color}"/><stop offset="1" stop-color="${dark}"/></radialGradient>` +
481    `<linearGradient id="lmr-${idx}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fde68a"/><stop offset=".5" stop-color="#d97706"/><stop offset="1" stop-color="#fbbf24"/></linearGradient>` +
482    `<clipPath id="lmc-${idx}"><circle cx="${cx}" cy="${cy}" r="${R}"/></clipPath></defs>`
483  // Ribbon tails behind the medal.
484  s += `<path d="M${cx - 13} ${cy + 18}l-6 22 8-5 5 7 5-22z" fill="${dark}"/><path d="M${cx + 13} ${cy + 18}l6 22-8-5-5 7-5-22z" fill="${b.color}"/>`
485  if (isRecent) s += `<circle class="lr-glow" cx="${cx}" cy="${cy}" r="${R + 4}" fill="${b.color}" opacity=".3"/>`
486  s += `<g class="${isRecent ? 'lr-pop' : ''}"><circle cx="${cx}" cy="${cy}" r="${R}" fill="url(#lmr-${idx})"/><circle cx="${cx}" cy="${cy}" r="${R - 5}" fill="url(#lmf-${idx})"/>`
487  // A laurel wreath: five leaves up each side of the lower rim, tangent to it.
488  for (let i = 0; i < 5; i++) {
489    for (const deg of [105 + i * 15, 75 - i * 15]) {
490      const a = (deg * Math.PI) / 180
491      const x = (cx + Math.cos(a) * (R - 8)).toFixed(1)
492      const y = (cy + Math.sin(a) * (R - 8)).toFixed(1)
493      s += `<ellipse cx="${x}" cy="${y}" rx="1.9" ry="4.2" fill="#fef3c7" opacity=".6" transform="rotate(${deg} ${x} ${y})"/>`
494    }
495  }
496  s += `<text x="${cx}" y="${cy + 8}" font-size="22" text-anchor="middle">${xml(b.glyph)}</text>`
497  if (isRecent) s += `<g clip-path="url(#lmc-${idx})"><rect class="lr-shn" x="${cx - 12}" y="${cy - R}" width="14" height="${R * 2}" fill="#ffffff" opacity=".45" transform="skewX(-20)"/></g>`
498  s += `</g>`
499  return s
500}
501
502function medalRows(list: readonly Badge[], got: Readonly<Record<string, number>>, progress: Readonly<Record<string, number>>, now: number, W: number): { source: string; height: number; alt: string }[] {
503  const gap = 8
504  const per = Math.max(2, Math.floor((W + gap) / (128 + gap)))
505  const cw = (W - gap * (per - 1)) / per
506  const H = 150
507  const rows: { source: string; height: number; alt: string }[] = []
508  for (let r = 0; r * per < list.length; r++) {
509    const parts: string[] = []
510    const alts: string[] = []
511    list.slice(r * per, r * per + per).forEach((b, i) => {
512      const x = i * (cw + gap)
513      const at = got[b.id]
514      const v = progress[b.id] ?? 0
515      const cx = x + cw / 2
516      parts.push(`<g><title>${xml(`${b.name}: ${at !== undefined ? b.desc : b.hint}`)}</title>`)
517      parts.push(`<rect class="p" x="${x}" y="0" width="${cw}" height="${H - 6}" rx="14"/>`)
518      parts.push(medal(b, cx, 42, at, v, now, b.id))
519      parts.push(svgText(cx, 96, fitText(b.name, 12, cw - 12), { size: 12, weight: 700, anchor: 'middle', cls: at !== undefined ? 't' : 'm' }))
520      const body = wrapText(at !== undefined ? b.desc : b.hint, 9.5, cw - 16, 2)
521      body.forEach((line, j) => parts.push(svgText(cx, 110 + j * 12, line, { size: 9.5, anchor: 'middle', cls: at !== undefined ? 's' : 'm' })))
522      const foot = at !== undefined ? dateOf(at) : b.goal > 1 ? fmtGoal(b, v) : 'locked'
523      parts.push(svgText(cx, 138, foot, { size: 9.5, weight: 600, anchor: 'middle', fill: at !== undefined ? b.color : undefined, cls: 'm' }))
524      parts.push(`</g>`)
525      alts.push(`${b.name} ${at !== undefined ? `unlocked ${dateOf(at)}` : `locked${b.goal > 1 ? ` ${fmtGoal(b, v)}` : ''}`}`)
526    })
527    rows.push({ source: svg(W, H, parts.join(''), MEDAL_CSS), height: H, alt: alts.join('; ') })
528  }
529  return rows
530}
531
532/** The 1-row desktop celebration: a medal popping in, rays turning, confetti falling. */
533function burstSvg(b: Badge, W: number, more: number): string {
534  const css = `
535.lr-rays{transform-box:fill-box;transform-origin:center;animation:lrray 6s linear infinite}@keyframes lrray{to{transform:rotate(360deg)}}
536.lr-bpop{transform-box:fill-box;transform-origin:center;animation:lbpop .7s cubic-bezier(.2,1.7,.4,1) both}@keyframes lbpop{from{transform:scale(.2);opacity:0}}
537.lr-slide{animation:lsl .6s cubic-bezier(.2,.8,.2,1) .15s both}@keyframes lsl{from{opacity:0;transform:translateX(-10px)}}
538.lr-cf{animation:lcf 2.2s linear infinite}@keyframes lcf{from{transform:translateY(-14px) rotate(0)}to{transform:translateY(52px) rotate(260deg)}}
539.lr-cf{transform-box:fill-box;transform-origin:center}
540.lr-sweep{animation:lsw${W} 1.8s ease-out .3s both}@keyframes lsw${W}{from{transform:translateX(-200px)}to{transform:translateX(${W + 200}px)}}
541`
542  const parts: string[] = []
543  parts.push(`<defs><linearGradient id="lbg-${b.id}" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${b.color}" stop-opacity=".28"/><stop offset=".6" stop-color="${b.color}" stop-opacity=".08"/><stop offset="1" stop-color="${b.color}" stop-opacity="0"/></linearGradient>` +
544    `<linearGradient id="lsw" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".35"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
545    `<clipPath id="lbc${W}"><rect x="0" y="0" width="${W}" height="44" rx="12"/></clipPath></defs>`)
546  parts.push(`<rect class="p" x="0" y="0" width="${W}" height="44" rx="12"/><rect x="0" y="0" width="${W}" height="44" rx="12" fill="url(#lbg-${b.id})"/>`)
547  parts.push(`<g clip-path="url(#lbc${W})">`)
548  // Confetti: seeded pieces falling at their own pace.
549  const hues = ['#f472b6', '#facc15', '#4ade80', '#22d3ee', '#a78bfa', '#fb923c']
550  for (let i = 0; i < 26; i++) {
551    const x = 220 + ((i * 97.3) % Math.max(60, W - 240))
552    const delay = ((i * 0.37) % 2.2).toFixed(2)
553    const dur = (1.8 + ((i * 0.53) % 1.4)).toFixed(2)
554    const c = hues[i % hues.length]
555    parts.push(i % 3 === 0
556      ? `<circle class="lr-cf" style="animation-delay:-${delay}s;animation-duration:${dur}s" cx="${x.toFixed(0)}" cy="0" r="1.8" fill="${c}"/>`
557      : `<rect class="lr-cf" style="animation-delay:-${delay}s;animation-duration:${dur}s" x="${x.toFixed(0)}" y="-2" width="3" height="6" rx="1" fill="${c}"/>`)
558  }
559  // Rays behind the medal.
560  const cx = 26
561  const cy = 22
562  let rays = ''
563  for (let i = 0; i < 12; i++) {
564    const a = (i * Math.PI) / 6
565    rays += `<path d="M${cx} ${cy}L${(cx + Math.cos(a - 0.12) * 26).toFixed(1)} ${(cy + Math.sin(a - 0.12) * 26).toFixed(1)}L${(cx + Math.cos(a + 0.12) * 26).toFixed(1)} ${(cy + Math.sin(a + 0.12) * 26).toFixed(1)}Z" fill="${b.color}" opacity=".22"/>`
566  }
567  parts.push(`<g class="lr-rays">${rays}</g>`)
568  parts.push(`<g class="lr-bpop"><circle cx="${cx}" cy="${cy}" r="16" fill="#f59e0b"/><circle cx="${cx}" cy="${cy}" r="13" fill="${b.color}"/><text x="${cx}" y="${cy + 6}" font-size="15" text-anchor="middle">${xml(b.glyph)}</text></g>`)
569  parts.push(`<g class="lr-slide">`)
570  parts.push(svgText(52, 17, more > 0 ? `ACHIEVEMENT UNLOCKED · +${more} MORE` : 'ACHIEVEMENT UNLOCKED', { size: 9.5, weight: 800, fill: '#d97706' }))
571  parts.push(svgText(52, 35, b.name, { size: 15, weight: 750 }))
572  parts.push(svgText(52 + textWidth(b.name, 15) + 10, 35, fitText(`— ${b.cheer}`, 12, Math.max(40, W - 120 - textWidth(b.name, 15))), { cls: 's', size: 12 }))
573  parts.push(`</g>`)
574  parts.push(`<rect class="lr-sweep" x="0" y="0" width="160" height="44" fill="url(#lsw)"/>`)
575  parts.push(`</g>`)
576  return svg(W, 44, parts.join(''), css)
577}
578
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/badges.ts 198 lines
1// Laurels' badge book, pure: the facts a session gathers, the badges read
2// over them, and the small classifiers the hooks feed them with.
3
4export type Flag =
5  | 'nightOwl' | 'earlyBird' | 'weekend' | 'pennyPincher' | 'cacheWizard' | 'contextSurfer'
6  | 'bugSquasher' | 'speedDemon' | 'committed' | 'pushed' | 'testPilot' | 'undaunted'
7  | 'deepThinker' | 'haiku' | 'cleanSlate' | 'labNotes'
8
9export type Facts = {
10  /** This session. */
11  turns: number
12  tools: number
13  fails: number
14  reads: number
15  searches: number
16  web: number
17  agents: number
18  maxRunning: number
19  costUsd: number
20  sessionMs: number
21  tokens: number
22  exts: string[]
23  mcpServers: string[]
24  bestFilesInTurn: number
25  flags: Partial<Record<Flag, true>>
26  /** Every session, kept in $.store. */
27  life: Lifetime
28}
29
30export type Lifetime = { turns: number; tools: number; tasksDone: number; days: string[] }
31
32export function emptyLife(): Lifetime {
33  return { turns: 0, tools: 0, tasksDone: 0, days: [] }
34}
35
36export function emptyFacts(life: Lifetime = emptyLife()): Facts {
37  return {
38    turns: 0, tools: 0, fails: 0, reads: 0, searches: 0, web: 0, agents: 0, maxRunning: 0,
39    costUsd: 0, sessionMs: 0, tokens: 0, exts: [], mcpServers: [], bestFilesInTurn: 0, flags: {}, life,
40  }
41}
42
43export type Badge = {
44  id: string
45  name: string
46  glyph: string
47  color: string
48  /** What it is for, shown once unlocked. */
49  desc: string
50  /** How to get it, shown while locked. */
51  hint: string
52  /** The toast's flourish: `🏆 Hydra unlocked — <cheer>`. */
53  cheer: string
54  /** Counter badges show a progress bar toward `goal`; flag badges have goal 1. */
55  goal: number
56  value: (f: Facts) => number
57}
58
59const flag = (k: Flag) => (f: Facts) => (f.flags[k] ? 1 : 0)
60
61export const BADGES: readonly Badge[] = [
62  { id: 'first-light', name: 'First Light', glyph: '✦', color: '#facc15', desc: 'Finished your very first turn.', hint: 'Finish a turn.', cheer: 'the first of many', goal: 1, value: f => Math.min(1, f.life.turns) },
63  { id: 'hydra', name: 'Hydra', glyph: '🐉', color: '#a78bfa', desc: 'Five subagents running at once.', hint: 'Have five subagents running at the same time.', cheer: 'five agents at once', goal: 5, value: f => f.maxRunning },
64  { id: 'delegator', name: 'Delegator', glyph: '◈', color: '#c084fc', desc: 'Ten subagents in one session.', hint: 'Spawn ten subagents in a session.', cheer: 'ten agents dispatched', goal: 10, value: f => f.agents },
65  { id: 'night-owl', name: 'Night Owl', glyph: '🦉', color: '#818cf8', desc: 'A turn between midnight and 5 a.m.', hint: 'Work between 00:00 and 05:00.', cheer: 'the moon approves', goal: 1, value: flag('nightOwl') },
66  { id: 'early-bird', name: 'Early Bird', glyph: '🌅', color: '#fb923c', desc: 'A turn between 5 and 7 a.m.', hint: 'Work between 05:00 and 07:00.', cheer: 'up before the sun', goal: 1, value: flag('earlyBird') },
67  { id: 'weekend-warrior', name: 'Weekend Warrior', glyph: '⚔', color: '#f472b6', desc: 'Shipped work on a weekend.', hint: 'Finish a turn on Saturday or Sunday.', cheer: 'no days off', goal: 1, value: flag('weekend') },
68  { id: 'marathon', name: 'Marathon', glyph: '🏃', color: '#34d399', desc: 'A session two hours long.', hint: 'Keep one session going for two hours.', cheer: 'two hours in the saddle', goal: 120, value: f => Math.floor(f.sessionMs / 60_000) },
69  { id: 'big-spender', name: 'Big Spender', glyph: '💎', color: '#22d3ee', desc: 'A ten-dollar session.', hint: 'Spend $10 in one session.', cheer: 'ten dollars of thought', goal: 10, value: f => Math.floor(f.costUsd * 100) / 100 },
70  { id: 'penny-pincher', name: 'Penny Pincher', glyph: '🪙', color: '#eab308', desc: 'Ten tools in one turn for under five cents.', hint: 'Run ten tools in a turn that costs under $0.05.', cheer: 'thrift, perfected', goal: 1, value: flag('pennyPincher') },
71  { id: 'cache-wizard', name: 'Cache Wizard', glyph: '🧙', color: '#60a5fa', desc: 'Cache reads over 90% of a turn’s input.', hint: 'Have a turn read over 90% of its input from cache.', cheer: 'the cache remembers', goal: 1, value: flag('cacheWizard') },
72  { id: 'context-surfer', name: 'Context Surfer', glyph: '🏄', color: '#2dd4bf', desc: 'Kept going past 90% context.', hint: 'Start a turn with the context over 90% full.', cheer: 'riding the edge of the window', goal: 1, value: flag('contextSurfer') },
73  { id: 'bug-squasher', name: 'Bug Squasher', glyph: '🐞', color: '#4ade80', desc: 'A failing command, fixed: the same command later passed.', hint: 'Make a failing shell command pass.', cheer: 'red to green', goal: 1, value: flag('bugSquasher') },
74  { id: 'polyglot', name: 'Polyglot', glyph: '🗺', color: '#f59e0b', desc: 'Edited five kinds of file in one session.', hint: 'Edit files with five different extensions.', cheer: 'five tongues spoken', goal: 5, value: f => f.exts.length },
75  { id: 'speed-demon', name: 'Speed Demon', glyph: '⚡', color: '#fde047', desc: 'Three tools in a turn under ten seconds.', hint: 'Finish a turn with 3+ tools in under 10 s.', cheer: 'blink and it’s done', goal: 1, value: flag('speedDemon') },
76  { id: 'centurion', name: 'Centurion', glyph: '🛡', color: '#f87171', desc: 'A hundred tool calls in one session.', hint: 'Make 100 tool calls in a session.', cheer: 'a hundred strikes', goal: 100, value: f => f.tools },
77  { id: 'architect', name: 'Architect', glyph: '🏛', color: '#fbbf24', desc: 'Wrote ten files in a single turn.', hint: 'Write or edit ten files in one turn.', cheer: 'ten files raised at once', goal: 10, value: f => f.bestFilesInTurn },
78  { id: 'archaeologist', name: 'Archaeologist', glyph: '🏺', color: '#d97757', desc: 'Fifty reads in one session.', hint: 'Read 50 files in a session.', cheer: 'fifty layers deep', goal: 50, value: f => f.reads },
79  { id: 'needle', name: 'Needle Finder', glyph: '⌕', color: '#93c5fd', desc: 'Twenty-five searches in one session.', hint: 'Run 25 Grep or Glob searches.', cheer: 'found it, every time', goal: 25, value: f => f.searches },
80  { id: 'committed', name: 'Committed', glyph: '⎇', color: '#22c55e', desc: 'A successful git commit.', hint: 'Commit with git from the shell.', cheer: 'history written', goal: 1, value: flag('committed') },
81  { id: 'shipwright', name: 'Shipwright', glyph: '⛵', color: '#38bdf8', desc: 'A successful git push.', hint: 'Push with git from the shell.', cheer: 'shipped', goal: 1, value: flag('pushed') },
82  { id: 'test-pilot', name: 'Test Pilot', glyph: '🧪', color: '#a3e635', desc: 'A test run that passed.', hint: 'Run the tests and see them pass.', cheer: 'all green', goal: 1, value: flag('testPilot') },
83  { id: 'taskmaster', name: 'Taskmaster', glyph: '☑', color: '#14b8a6', desc: 'Ten tasks finished.', hint: 'Complete ten todos or tasks.', cheer: 'ten boxes ticked', goal: 10, value: f => f.life.tasksDone },
84  { id: 'web-crawler', name: 'Web Crawler', glyph: '🕸', color: '#06b6d4', desc: 'Ten web searches or fetches in a session.', hint: 'Search or fetch the web ten times.', cheer: 'the web, combed', goal: 10, value: f => f.web },
85  { id: 'switchboard', name: 'Switchboard', glyph: '⬡', color: '#e879f9', desc: 'Tools from three MCP servers in one session.', hint: 'Use tools of three different MCP servers.', cheer: 'all lines connected', goal: 3, value: f => f.mcpServers.length },
86  { id: 'undaunted', name: 'Undaunted', glyph: '🔥', color: '#ef4444', desc: 'Five failures in a turn, and it still finished.', hint: 'Push through five failed tools in one turn.', cheer: 'nothing stops you', goal: 1, value: flag('undaunted') },
87  { id: 'deep-thinker', name: 'Deep Thinker', glyph: '🌀', color: '#8b5cf6', desc: 'A single turn of ten minutes or more.', hint: 'Let one turn run ten minutes.', cheer: 'ten minutes of focus', goal: 1, value: flag('deepThinker') },
88  { id: 'haiku', name: 'Haiku', glyph: '🍃', color: '#86efac', desc: 'An answer in under five seconds, no tools.', hint: 'Get a tool-free answer in under 5 s.', cheer: 'brevity is wit', goal: 1, value: flag('haiku') },
89  { id: 'clean-slate', name: 'Clean Slate', glyph: '✧', color: '#e5e7eb', desc: 'Twenty tool calls in a session, not one failed.', hint: 'Make 20 tool calls without a single failure.', cheer: 'flawless', goal: 1, value: flag('cleanSlate') },
90  { id: 'token-tsunami', name: 'Token Tsunami', glyph: '🌊', color: '#3b82f6', desc: 'A million tokens through one session.', hint: 'Move a million tokens in a session.', cheer: 'a million tokens', goal: 1_000_000, value: f => f.tokens },
91  { id: 'lab-notes', name: 'Lab Notes', glyph: '📓', color: '#fcd34d', desc: 'Edited a Jupyter notebook.', hint: 'Edit a notebook cell.', cheer: 'science, documented', goal: 1, value: flag('labNotes') },
92  { id: 'devotee', name: 'Devotee', glyph: '📅', color: '#f97316', desc: 'Worked on seven different days.', hint: 'Finish turns on seven different days.', cheer: 'a week of devotion', goal: 7, value: f => f.life.days.length },
93  { id: 'regular', name: 'Regular', glyph: '♛', color: '#eab308', desc: 'A hundred turns, all sessions together.', hint: 'Finish 100 turns in all.', cheer: 'one hundred turns', goal: 100, value: f => f.life.turns },
94]
95
96export function badgeById(id: string): Badge | undefined {
97  return BADGES.find(b => b.id === id)
98}
99
100/** Badges whose value reached the goal and are not yet in `unlocked`. */
101export function newlyUnlocked(f: Facts, unlocked: Readonly<Record<string, number>>): Badge[] {
102  return BADGES.filter(b => unlocked[b.id] === undefined && b.value(f) >= b.goal)
103}
104
105/** Each counter badge's value, capped at its goal (for progress bars). */
106export function progressOf(f: Facts): Record<string, number> {
107  const out: Record<string, number> = {}
108  for (const b of BADGES) out[b.id] = Math.min(b.goal, Math.max(0, b.value(f)))
109  return out
110}
111
112// ---------------------------------------------------------------------------
113// Turn-level checks.
114
115export type TurnFacts = {
116  startedAt: number
117  durationMs: number
118  tools: number
119  fails: number
120  files: number
121  steps: number
122  estUsd: number
123  input: number
124  cacheRead: number
125  cacheWrite: number
126  reason: string
127}
128
129/** The flags one finished main turn earns; `hour` and `weekday` are local. */
130export function turnFlags(t: TurnFacts, hour: number, weekday: number): Flag[] {
131  const out: Flag[] = []
132  const answered = t.reason === 'answer'
133  if (hour >= 0 && hour < 5) out.push('nightOwl')
134  if (hour >= 5 && hour < 7) out.push('earlyBird')
135  if (weekday === 0 || weekday === 6) out.push('weekend')
136  if (answered && t.tools >= 3 && t.durationMs < 10_000) out.push('speedDemon')
137  if (answered && t.tools >= 10 && t.steps > 0 && t.estUsd < 0.05) out.push('pennyPincher')
138  const inTotal = t.input + t.cacheRead + t.cacheWrite
139  if (inTotal >= 10_000 && t.cacheRead / inTotal > 0.9) out.push('cacheWizard')
140  if (t.durationMs >= 10 * 60_000) out.push('deepThinker')
141  if (answered && t.tools === 0 && t.durationMs < 5000) out.push('haiku')
142  if (answered && t.fails >= 5) out.push('undaunted')
143  return out
144}
145
146// ---------------------------------------------------------------------------
147// Classifiers for tool calls.
148
149export function normCommand(cmd: string): string {
150  return cmd.replace(/\s+/g, ' ').trim()
151}
152
153export function isGitCommit(cmd: string): boolean {
154  return /(^|[;&|]\s*)git(\s+-\S+(\s+\S+)?)*\s+commit\b/.test(normCommand(cmd))
155}
156
157export function isGitPush(cmd: string): boolean {
158  return /(^|[;&|]\s*)git(\s+-\S+(\s+\S+)?)*\s+push\b/.test(normCommand(cmd))
159}
160
161export function isTestRun(cmd: string): boolean {
162  return /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(pytest|vitest|jest|mocha|rspec|phpunit)\b|\b(cargo|go|dotnet|mix|deno|bun)\s+test\b|\bclaude\s+plugin\s+test\b|\bmake\s+(test|check)\b|\bgradle\w*\s+test\b|\bmvn\s+test\b/.test(cmd)
163}
164
165/** `.tsx` from `src/app.tsx`; '' for none (Makefile). */
166export function extOf(path: string): string {
167  const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
168  const i = base.lastIndexOf('.')
169  return i > 0 ? base.slice(i).toLowerCase() : ''
170}
171
172export function mcpServerOf(tool: string): string | undefined {
173  if (!tool.startsWith('mcp__')) return undefined
174  return tool.split('__')[1] || undefined
175}
176
177/** Todos newly marked completed between two TodoWrite lists (by content). */
178export function todosDone(before: readonly { content: string; status: string }[], after: readonly { content: string; status: string }[]): number {
179  const was = new Set(before.filter(t => t.status === 'completed').map(t => t.content))
180  return after.filter(t => t.status === 'completed' && !was.has(t.content)).length
181}
182
183export function dayKey(ms: number): string {
184  const d = new Date(ms)
185  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
186}
187
188export function sanitizeLife(v: unknown): Lifetime {
189  const o = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>
190  const n = (k: string) => (typeof o[k] === 'number' && Number.isFinite(o[k]) ? (o[k] as number) : 0)
191  return {
192    turns: n('turns'),
193    tools: n('tools'),
194    tasksDone: n('tasksDone'),
195    days: Array.isArray(o.days) ? (o.days as unknown[]).filter((d): d is string => typeof d === 'string').slice(-60) : [],
196  }
197}
198
hooks/burst.tsx 82 lines
1// Laurels' terminal burst: one row that celebrates an unlock for six seconds.
2// A trophy that hops, the badge glyph, its name in a travelling shimmer,
3// the cheer, and confetti drifting through the rest of the row.
4import type { ClientModule } from 'claude-code'
5
6type P = { cols: number; name: string; glyph: string; color: string; cheer: string; more: number }
7type S = { f: number }
8
9const CONFETTI = ['✦', '✧', '·', '*', '⋆', '˚', '•', '✶']
10const HUES = ['#f472b6', '#facc15', '#4ade80', '#22d3ee', '#a78bfa', '#fb923c']
11const HOP = ['🏆', '🏆', '✨', '🏆']
12
13function hash(n: number): number {
14  const s = Math.sin(n * 91.3458) * 47453.5453
15  return s - Math.floor(s)
16}
17
18/** Mixes two #rrggbb colors. */
19function mix(a: string, b: string, t: number): string {
20  const p = (h: string, i: number) => parseInt(h.slice(1 + i * 2, 3 + i * 2), 16)
21  const c = (i: number) => Math.round(p(a, i) + (p(b, i) - p(a, i)) * t).toString(16).padStart(2, '0')
22  return `#${c(0)}${c(1)}${c(2)}`
23}
24
25const Burst: ClientModule<P, S> = (props, surface) => {
26  if (surface.state === undefined) {
27    surface.setState({ f: 0 })
28    surface.every(60, () => surface.setState({ f: (surface.state?.f ?? 0) + 1 }))
29  }
30  const f = surface.state?.f ?? 0
31  const { Box, Text } = surface.elements
32  const base = /^#[0-9a-f]{6}$/i.test(props.color) ? props.color : '#facc15'
33
34  const head = `${HOP[Math.floor(f / 4) % HOP.length]} `
35  const label = ' UNLOCKED '
36  const name = props.name
37  const tail = ` — ${props.cheer}${props.more > 0 ? `  (+${props.more} more)` : ''} `
38  const used = 3 + label.length + 3 + name.length + tail.length
39  const room = Math.max(0, (props.cols || 60) - used)
40
41  // A shimmer crest walks along the name, wrapping.
42  const crest = (f * 0.6) % (name.length + 6)
43  const letters = Array.from(name).map((ch, i) => {
44    const d = Math.abs(i - crest)
45    const glow = d < 2.5 ? 1 - d / 2.5 : 0
46    return <Text key={`n${i}`} bold color={mix(base, '#ffffff', glow * 0.85)}>{ch}</Text>
47  })
48
49  // Confetti drifting right to left over the spare cells.
50  const field: { ch: string; color: string }[] = []
51  for (let x = 0; x < room; x++) {
52    const seed = Math.floor((x + f * 0.5) / 1) + 7
53    const r = hash(seed)
54    const on = r < 0.28
55    // hash() is in [0, 1): both picks are in range.
56    field.push(on ? { ch: CONFETTI[Math.floor(hash(seed + 3) * CONFETTI.length)]!, color: HUES[Math.floor(hash(seed + 5) * HUES.length)]! } : { ch: ' ', color: '#000000' })
57  }
58  const runs: { color: string; text: string }[] = []
59  for (const c of field) {
60    const last = runs[runs.length - 1]
61    if (last && (last.color === c.color || c.ch === ' ')) last.text += c.ch
62    else runs.push({ color: c.color, text: c.ch })
63  }
64
65  const sparkle = CONFETTI[f % CONFETTI.length]
66  return (
67    <Box flexDirection="row">
68      <Text wrap="truncate-end">
69        <Text>{head}</Text>
70        <Text backgroundColor={base} color="#111111" bold>{label}</Text>
71        <Text color={base}> {props.glyph} </Text>
72        {letters}
73        <Text color="#d1d5db">{tail}</Text>
74        {runs.map((r, i) => <Text key={`c${i}`} color={r.color}>{r.text}</Text>)}
75        <Text color={HUES[f % HUES.length]}>{room > 0 ? '' : sparkle}</Text>
76      </Text>
77    </Box>
78  )
79}
80
81export default Burst
82
types/index.d.ts 15 lines
1export type LaurelsBurst = { id: string; at: number; more: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    laurels: {
6      /** Badge id → unlock time (ms). */
7      unlocked: Record<string, number>
8      /** Badge id → current value toward its goal. */
9      progress: Record<string, number>
10      burst: LaurelsBurst | null
11      isHidden: boolean
12    }
13  }
14}
15