Buddy: /buddy hatches your dungeon pet from your account. It sits above the prompt, fidgets, and talks about your session's Mishaps and Long Hauls, or, with…

Personal configuration files for Linux development environments. Includes shell, editor, terminal, and Claude Code plugin setup.
flake.nix, home.nix, configuration.nix). (work in progress)dot-zsh-functions/).nvim/).tmux/).wezterm/).claude-plugin/).setup.sh automates stowing configs, installing dependencies, and setting up the environment.This repository also functions as a Gemini CLI extension, providing custom TDD agents, context from GEMINI.md, and specialized development workflows.
From your Terminal:
gemini extensions install https://github.com/Anthony-Bible/dotfiles --auto-update
From within Gemini CLI:
/extensions install https://github.com/Anthony-Bible/dotfiles --auto-update
GEMINI.md for project-specific rules and instructions.This repo acts as a Claude Code plugin marketplace. The dotfiles-dev-tools plugin provides:
gofmt/goimports)shfmt)clang-format)red-phase-tester — writes failing tests before implementationgreen-phase-implementer — writes minimal code to pass teststdd-refactor-specialist — cleans up code after tests go greentdd-review-agent — verifies completeness after refactoringsecurity-auditor — finds vulnerabilities in code1. Add this repo as a marketplace:
claude plugin marketplace add Anthony-Bible/dotfiles
2. Install the plugin:
claude plugin install dotfiles-dev-tools@anthony-bible-dotfiles
Or from within Claude Code interactive mode:
/plugin install dotfiles-dev-tools@anthony-bible-dotfiles
dungeon-buddy is the Buddy mod by itself: /buddy hatches a dungeon pet from your account that sits above the prompt, fidgets, and talks about failed commands and long turns. Install it alone, or beside dotfiles-dev-tools, where it talks back to the System's big moments instead and a Reroll costs Crawler Points:
claude plugin install dungeon-buddy@anthony-bible-dotfiles
The marketplace is defined by .claude-plugin/marketplace.json at the root of this repo. It lists available plugins and points to their source directories.
Each plugin lives in its own subdirectory (e.g., claude-plugin/) and contains:
| Path | Purpose |
|---|---|
.claude-plugin/plugin.json | Plugin metadata (name, version, description) |
hooks/hooks.json | PostToolUse/PreToolUse hooks with shell commands |
agents/*.md | Custom agents with frontmatter metadata |
.mcp.json | MCP servers bundled with the plugin |
.lsp.json | LSP servers bundled with the plugin |
When Claude Code installs a plugin, it copies the plugin directory to ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ and activates hooks, agents, MCP servers, and LSP servers from that directory. The ${CLAUDE_PLUGIN_ROOT} environment variable is set to the installed plugin path at runtime.
.claude-plugin/
marketplace.json # Marketplace index listing all plugins
dungeon-buddy/ # dungeon-buddy plugin source (the Buddy mod)
claude-plugin/ # dotfiles-dev-tools plugin source
.claude-plugin/
plugin.json # Plugin metadata
hooks/
hooks.json # Auto-format hooks (PostToolUse)
agents/
red-phase-tester.md
green-phase-implementer.md
tdd-refactor-specialist.md
tdd-review-agent.md
security-auditor.md
scripts/
goformat.sh
shellformat.sh
protoformat.sh
openapi-lint.sh
format-common.sh
.mcp.json # MCP servers (sequential-thinking)
.lsp.json # LSP servers (gopls)
# List all registered marketplaces
claude plugin marketplace list
# Update marketplace plugin listings
claude plugin marketplace update anthony-bible-dotfiles
# Remove the marketplace
claude plugin marketplace remove anthony-bible-dotfiles
# Validate the plugin/marketplace structure
claude plugin validate .
.claude-plugin/ # Claude Code marketplace definition
claude-plugin/ # Claude Code plugin (dotfiles-dev-tools)
configuration.nix # NixOS or Home Manager configuration
flake.nix # Nix flake for reproducible setup
home.nix # Home Manager user configuration
setup.sh # Setup and bootstrap script
.lsp.json # Global LSP configuration (gopls)
.mcp.json # Global MCP server configuration
dot-oh-my-zsh/ # Oh My Zsh themes and customizations
dot-zsh-functions/ # Custom Zsh functions and widgets
nvim/ # Neovim configuration (Lua)
tmux/ # Tmux and tmuxp configuration
wezterm/ # WezTerm configuration
The dot-zsh-functions/ directory enhances the shell experience:
dot-zsh-functions: Aliases, environment variables, and CheckIfDotDirFilesChanged.dot-ai-functions: AI-powered shell helpers via Claude:command_not_found_handler: Suggests commands for unknown input.explain / explain:: Explains a given command.Alt-e ZLE widget: Sends current command line to Claude and replaces it with the result.ai_commit_msg / Alt-g: Generates commit messages from staged changes using fzf + Claude.dot-tcn-functions: Work-specific helpers (DokuWiki, PostgreSQL, Kamailio, GCP IAM).sh git clone https://github.com/Anthony-Bible/dotfiles.git cd dotfiles ``sh ./setup.sh ``sh nix run .#homeConfigurations.$USER.activationPackage ``MIT License. See individual files for copyright.
hooks/register.tsx 386 lines1// Buddy: /buddy hatches the Crawler's dungeon pet from their account; it sits at the right of the band above the
2// prompt, fidgets at rest and, now and then, says a Bubble about the session's Mishaps and Long Hauls. With
3// dotfiles-dev-tools installed it talks back to the System's Cues instead, and a Reroll costs Crawler Points.
4//
5// Every use of `$` lives in this file (the engine follows `$` into this file's functions, never across an
6// import); the rules are in ../mods/buddy.ts.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import {
12 accountOf,
13 BUBBLE_MS,
14 BUDDY_SYSTEM,
15 bubblePrompt,
16 cannedBubble,
17 card,
18 cleanBubble,
19 cueOf,
20 EGG,
21 fallbackSoul,
22 FIDGET_MS,
23 FIDGET_TICK_MS,
24 fidgetOf,
25 isForced,
26 longHaulOf,
27 mishapOf,
28 parseSoul,
29 RARITY_COLOR,
30 rerollPrice,
31 rerollRefusal,
32 restLine,
33 roll,
34 seedOf,
35 soulPrompt,
36 SOUL_SYSTEM,
37 spriteOf,
38 willSpeak,
39 type Moment,
40 type Soul,
41} from '../mods/buddy'
42import type { BuddyCueState, BuddyState } from '../types'
43
44// ---------------------------------------------------------------------------------------------- state
45
46const buddy = atom({ plugin: 'dungeon-buddy', key: 'buddy' } as const, { isShown: true } as BuddyState)
47const buddyCue = atom({ plugin: 'dungeon-buddy', key: 'cue' } as const, { n: 0 } as BuddyCueState)
48
49const STORE_BUDDY = 'buddy.saved'
50const MODEL = 'haiku'
51const SOUL_TIMEOUT_MS = 6000
52const BUBBLE_TIMEOUT_MS = 4000
53const EGG_FRAME_MS = 700
54/** The account line of the Crawler's login, read straight from their config; never the whole file. */
55const ACCOUNT_LINE = `grep -m1 -o '"emailAddress"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/.claude.json" 2>/dev/null`
56
57// Module variables: a hot reload starts them over, which costs at most an early Bubble or a missed Long Haul.
58/** When its last Bubble began, whether a Bubble's model call is in flight, and the timer that ends it. */
59let bubbleAt = -Infinity
60let isBubbleBusy = false
61let bubbleTimer: { cancel: () => void } | undefined
62let bubbleSeq = 0
63/** The fidget ticks, and the timer that ends the fidget it is in. */
64let fidgetTimer: { cancel: () => void } | undefined
65let poseTimer: { cancel: () => void } | undefined
66/** Whether a Reroll is going through: a second one waits out the first, whose price may not be charged yet. */
67let isRerolling = false
68/** The main loop's tool calls this turn, for a Long Haul; undefined between turns. */
69let turnCalls: number | undefined
70
71// ------------------------------------------------------------------------------------------------ Buddy
72
73/** What the Buddy keeps across sessions: the Rerolls made on this machine, its Soul, whether it hides. */
74type SavedBuddy = { rerolls: number; soul?: Soul; isHidden?: boolean; account?: string }
75
76async function savedBuddy($: EngineInterface): Promise<SavedBuddy> {
77 const v = (await $.store.get(STORE_BUDDY)) as Partial<SavedBuddy> | undefined
78 return { ...v, rerolls: Number.isFinite(v?.rerolls) ? Number(v?.rerolls) : 0 }
79}
80
81/** The account the Hatch is seeded from: the login's email, else a random one made once and kept. */
82async function buddyAccount($: EngineInterface, saved: SavedBuddy): Promise<string> {
83 const found = accountOf((await $.process.run(['sh', '-c', ACCOUNT_LINE])).stdout)
84 if (found) {
85 if (saved.account !== found) await $.store.set(STORE_BUDDY, { ...saved, account: found })
86 return found
87 }
88 if (saved.account) return saved.account
89 const made = `anon-${Math.random().toString(36).slice(2)}`
90 await $.store.set(STORE_BUDDY, { ...saved, account: made })
91 return made
92}
93
94/** Whether dotfiles-dev-tools runs this session: it writes its Cue as it starts. */
95async function hasShow($: EngineInterface): Promise<boolean> {
96 return (await $.state.get({ plugin: 'dotfiles-dev-tools', key: 'cue' })).version > 0
97}
98
99/** Tells dotfiles-dev-tools what the Buddy did, for it to price. */
100async function announce($: EngineInterface, cue: NonNullable<BuddyCueState['cue']>): Promise<void> {
101 await update($, buddyCue, c => ({ n: c.n + 1, cue }))
102}
103
104async function startBuddy($: EngineInterface): Promise<void> {
105 bubbleAt = -Infinity
106 fidgetTicks($)
107 await restoreBuddy($, await savedBuddy($))
108}
109
110/** Puts the kept Buddy back in the session's state; false when none is kept for this account's seed. */
111async function restoreBuddy($: EngineInterface, saved: SavedBuddy): Promise<boolean> {
112 if (!saved.soul) return false
113 const seed = seedOf(await buddyAccount($, saved), saved.rerolls)
114 // A Soul written for another seed (another login) is not this Buddy's: /buddy hatches the right one.
115 if (saved.soul.seed !== seed) return false
116 await update($, buddy, () => ({ seed, name: saved.soul!.name, isShown: !saved.isHidden }))
117 return true
118}
119
120async function writeSoul($: EngineInterface, seed: string): Promise<Soul> {
121 const hatch = roll(seed)
122 try {
123 const r = await $.model.complete({
124 model: MODEL,
125 system: SOUL_SYSTEM,
126 prompt: soulPrompt(hatch),
127 maxTokens: 80,
128 effort: 'low',
129 timeoutMs: SOUL_TIMEOUT_MS,
130 })
131 return (r.isAnswered && parseSoul(r.text, seed)) || fallbackSoul(hatch, seed)
132 } catch {
133 return fallbackSoul(hatch, seed)
134 }
135}
136
137/**
138 * Hatches the Buddy for `seed`: its Soul written and kept, then, `delayMs` on, the egg cracking above the
139 * prompt and the Buddy greeting the Crawler. Answers the stat card.
140 */
141async function hatchBuddy($: EngineInterface, seed: string, delayMs: number): Promise<string> {
142 const soul = await writeSoul($, seed)
143 // Read again: finding the account may have just kept a made-up one.
144 await $.store.set(STORE_BUDDY, { ...(await savedBuddy($)), soul, isHidden: false })
145 const hatch = roll(seed)
146 const frame = (n: number, fn: () => Promise<unknown>) =>
147 $.clock.after(delayMs + n * EGG_FRAME_MS, () => void fn().catch(() => undefined))
148 EGG.forEach((_, n) => frame(n, () => update($, buddy, b => ({ ...b, isShown: true, bubble: undefined, egg: n }))))
149 frame(EGG.length, async () => {
150 await update($, buddy, () => ({ seed, name: soul.name, isShown: true }))
151 await say($, { kind: 'hatched' })
152 if (hatch.rarity === 'legendary') await announce($, { kind: 'legendary', species: hatch.species })
153 })
154 return card(hatch, soul)
155}
156
157/** Now and then, CHAOS-weighted, the Buddy at rest fidgets for a moment and settles back. */
158function fidgetTicks($: EngineInterface): void {
159 fidgetTimer?.cancel()
160 fidgetTimer = $.clock.every(FIDGET_TICK_MS, () => void fidget($).catch(() => undefined))
161}
162
163async function fidget($: EngineInterface): Promise<void> {
164 const b = await read($, buddy)
165 if (!b.seed || !b.name || !b.isShown || b.egg !== undefined || b.bubble || b.pose !== undefined) return
166 const pose = fidgetOf(roll(b.seed), Math.random(), Math.random())
167 if (pose === undefined) return
168 await update($, buddy, x => ({ ...x, pose }))
169 poseTimer?.cancel()
170 poseTimer = $.clock.after(FIDGET_MS, () => void update($, buddy, x => ({ ...x, pose: undefined })).catch(() => undefined))
171}
172
173/** The Buddy speaks of `m` off the caller's dispatch: a tool call never waits on a Bubble. */
174function sayLater($: EngineInterface, m: Moment): void {
175 $.clock.after(0, () => void say($, m).catch(() => undefined))
176}
177
178/** A Mishap or Long Haul, said only without dotfiles-dev-tools: with it, its Cues tell of the same events. */
179function sayOwnLater($: EngineInterface, m: Moment): void {
180 $.clock.after(0, () => void (async () => (await hasShow($)) || (await say($, m)))().catch(() => undefined))
181}
182
183/** The Buddy says a Bubble about `m`, when it is shown, out of the gap and its CHAOS lets it. */
184async function say($: EngineInterface, m: Moment): Promise<void> {
185 const b = await read($, buddy)
186 if (!b.seed || !b.name || !b.isShown || b.egg !== undefined) return
187 const hatch = roll(b.seed)
188 const now = await $.clock.now()
189 if (!willSpeak(hatch, m, now, bubbleAt, Math.random()) || (isBubbleBusy && !isForced(m))) return
190 bubbleAt = now
191 const seq = ++bubbleSeq
192 let text = cannedBubble(m, Math.random())
193 if (m.kind !== 'pet' && !isBubbleBusy) {
194 isBubbleBusy = true
195 try {
196 const soul = (await savedBuddy($)).soul
197 const r = await $.model.complete({
198 model: MODEL,
199 system: BUDDY_SYSTEM,
200 prompt: bubblePrompt(hatch, { name: b.name, personality: soul?.personality ?? '' }, m),
201 maxTokens: 40,
202 effort: 'low',
203 timeoutMs: BUBBLE_TIMEOUT_MS,
204 })
205 if (seq !== bubbleSeq) return
206 text = (r.isAnswered && cleanBubble(r.text)) || text
207 } catch {
208 // the model call failed or timed out: the canned Bubble stands
209 } finally {
210 isBubbleBusy = false
211 }
212 }
213 if (seq !== bubbleSeq) return
214 await update($, buddy, x => ({ ...x, bubble: text }))
215 bubbleTimer?.cancel()
216 bubbleTimer = $.clock.after(BUBBLE_MS, () => void update($, buddy, x => ({ ...x, bubble: undefined })).catch(() => undefined))
217}
218
219async function setShown($: EngineInterface, isShown: boolean): Promise<void> {
220 if (!isShown) {
221 bubbleTimer?.cancel()
222 bubbleTimer = undefined
223 }
224 await update($, buddy, b => ({ ...b, isShown, ...(isShown ? {} : { bubble: undefined }) }))
225 await $.store.set(STORE_BUDDY, { ...(await savedBuddy($)), isHidden: !isShown })
226}
227
228/** Why a Reroll can't happen now: only dotfiles-dev-tools' Crawler Points can refuse one. */
229async function refusal($: EngineInterface): Promise<string | undefined> {
230 if (!(await hasShow($))) return undefined
231 return rerollRefusal(Number((await $.state.get({ plugin: 'dotfiles-dev-tools', key: 'allTime' })).value ?? 0))
232}
233
234const NO_BUDDY = 'No Buddy yet: /buddy hatches one.'
235
236/** `/buddy [pet | hide | show | reroll [confirm]]`. */
237async function buddyCommand($: EngineInterface, args: string): Promise<string> {
238 const saved = await savedBuddy($)
239 const b = await read($, buddy)
240 const verb = args.trim().toLowerCase().replace(/\s+/g, ' ')
241 if (verb === '') {
242 if (b.egg !== undefined) return 'Your egg is hatching!'
243 // The state may have lost a kept Buddy (a /clear): bring it back rather than hatch over its Soul.
244 if (await restoreBuddy($, saved)) {
245 await setShown($, true)
246 return card(roll(saved.soul!.seed), saved.soul!)
247 }
248 return hatchBuddy($, seedOf(await buddyAccount($, saved), saved.rerolls), 0)
249 }
250 if (!b.seed || !b.name) return NO_BUDDY
251 switch (verb) {
252 case 'pet':
253 await say($, { kind: 'pet' })
254 return `You pet ${b.name}. ♥`
255 case 'hide':
256 await setShown($, false)
257 return `${b.name} hides. /buddy show brings them back.`
258 case 'show':
259 await setShown($, true)
260 return `${b.name} is back.`
261 case 'reroll':
262 return (
263 (await refusal($)) ??
264 `A Reroll abandons ${b.name} for a new Hatch (${rerollPrice(await hasShow($))}). /buddy reroll confirm to go through with it.`
265 )
266 case 'reroll confirm': {
267 if (b.egg !== undefined || isRerolling) return 'Wait for your egg to hatch first.'
268 isRerolling = true
269 try {
270 const refused = await refusal($)
271 if (refused) return refused
272 await announce($, { kind: 'reroll' })
273 await say($, { kind: 'reroll' })
274 const next = { ...saved, rerolls: saved.rerolls + 1 }
275 await $.store.set(STORE_BUDDY, next)
276 // The goodbye Bubble shows first; the new egg cracks once it has been read.
277 return `${b.name} is gone.\n\n${await hatchBuddy($, seedOf(await buddyAccount($, next), next.rerolls), 4000)}`
278 } finally {
279 isRerolling = false
280 }
281 }
282 default:
283 return 'Usage: /buddy [pet | hide | show | reroll]'
284 }
285}
286
287// ------------------------------------------------------------------------------------------------ hooks
288
289export const register: Register = on => {
290 on('session.start', async ($, e, next) => {
291 await $.command.register({ name: 'buddy', description: 'Buddy: hatch or show your dungeon pet (pet, hide, show, reroll)' })
292 await startBuddy($)
293 return next(e)
294 })
295
296 // A /clear starts the session over with fresh state and no session.start: the kept Buddy comes back here.
297 on('classic.SessionStart', async ($, e, next) => {
298 const r = await next(e)
299 if (e.source === 'clear') await startBuddy($)
300 return r
301 }).catch(($, e, next) => next(e))
302
303 on('command.run', { command: 'buddy' }, async ($, e) => ({ text: await buddyCommand($, e.args) }))
304
305 // dotfiles-dev-tools announced a Cue: the Buddy may talk back to it.
306 on('state.set', { plugin: 'dotfiles-dev-tools', key: 'cue' }, async ($, e, next) => {
307 const r = await next(e)
308 const cue = cueOf(e.value)
309 if (cue) sayLater($, cue)
310 return r
311 }).catch(($, e, next) => next(e))
312
313 // --------------------------------------------------------------------------- Mishaps and Long Hauls
314
315 on('turn.start', async ($, e, next) => {
316 if (!e.agentId) turnCalls = 0
317 return next(e)
318 })
319
320 on('tool.call', async ($, e, next) => {
321 if (turnCalls !== undefined && !e.agentId) turnCalls++
322 const ran = await next(e)
323 if (e.tool !== 'Bash' || ran.deny !== undefined || ran.isError !== true) return ran
324 const result = ran.result as { backgroundTaskId?: string } | undefined
325 if (!result?.backgroundTaskId) sayOwnLater($, mishapOf(e.command))
326 return ran
327 }).catch(($, e, next) => next(e))
328
329 on('turn.complete', async ($, e, next) => {
330 const r = await next(e)
331 if (e.agentId) return r
332 const calls = turnCalls ?? 0
333 turnCalls = undefined
334 const m = e.reason === 'answer' ? longHaulOf(calls, e.durationMs) : undefined
335 if (m) sayOwnLater($, m)
336 return r
337 })
338
339 // ------------------------------------------------------------------------------------------- the band
340
341 // At the right of the band, under whatever the plugins beneath (the System's rows) draw there.
342 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
343 if (e.props.hasSurvey) return next(e)
344 const b = await read($, buddy)
345 const hatch = b.seed ? roll(b.seed) : undefined
346 if (!b.isShown || (b.egg === undefined && (!hatch || !b.name))) return next(e)
347 const { Box, Text } = $.ui.resolve(e)
348 const tint = hatch && (hatch.isShiny ? 'cyan' : RARITY_COLOR[hatch.rarity])
349 const sprite = (lines: readonly string[], color?: string) => (
350 <Box flexDirection="column">
351 {lines.map((l, i) => (
352 <Text key={`s${i}`} color={color}>
353 {l}
354 </Text>
355 ))}
356 </Box>
357 )
358 const row =
359 b.egg !== undefined ? (
360 <Box key="buddy" justifyContent="flex-end">
361 {sprite(EGG[b.egg] ?? EGG[0]!)}
362 </Box>
363 ) : hatch && b.bubble ? (
364 <Box key="buddy" justifyContent="flex-end" alignItems="center" gap={1}>
365 <Box borderStyle="round" paddingX={1}>
366 <Text wrap="truncate-end">{b.bubble}</Text>
367 </Box>
368 {sprite(spriteOf(hatch), tint)}
369 </Box>
370 ) : (
371 hatch && (
372 <Box key="buddy" justifyContent="flex-end">
373 <Text color={tint}>{restLine(hatch, b.name!, b.pose)}</Text>
374 </Box>
375 )
376 )
377 const below = await next(e)
378 return (
379 <Box flexDirection="column">
380 {below}
381 {row}
382 </Box>
383 )
384 })
385}
386mods/buddy.ts 473 lines1// Buddy's pure half: the Hatch a seed rolls, the sprite and face it draws, which moments it speaks about, and
2// the canned Bubbles it falls back on. No `$` here.
3
4export const SPECIES = [
5 'duck',
6 'goose',
7 'cat',
8 'dragon',
9 'octopus',
10 'owl',
11 'axolotl',
12 'capybara',
13 'ghost',
14 'robot',
15 'snail',
16 'chonk',
17 'goblin',
18 'mimic',
19 'slime',
20 'skeleton',
21 'floating eye',
22 'dungeon rat',
23] as const
24export type Species = (typeof SPECIES)[number]
25
26export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const
27export type Rarity = (typeof RARITIES)[number]
28/** Percent odds per tier, in RARITIES order; they sum to 100. */
29export const RARITY_ODDS = [60, 25, 10, 4, 1] as const
30/** Every stat of a Hatch is at least its tier's floor. */
31export const RARITY_FLOOR: Record<Rarity, number> = { common: 1, uncommon: 10, rare: 20, epic: 30, legendary: 45 }
32export const SHINY_ODDS = 0.01
33
34export const STATS = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'] as const
35export type Stat = (typeof STATS)[number]
36
37export const HATS = ['crown', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck'] as const
38export type Hat = (typeof HATS)[number]
39
40export type Hatch = {
41 species: Species
42 rarity: Rarity
43 isShiny: boolean
44 stats: Record<Stat, number>
45 /** Commons go bareheaded. */
46 hat?: Hat
47}
48
49/** What the model writes once per Hatch; `seed` ties it to the Hatch it was written for. */
50export type Soul = { seed: string; name: string; personality: string }
51
52// ------------------------------------------------------------------------------------------- the roll
53
54/** FNV-1a, so a seed string becomes the PRNG's 32-bit state. */
55const hash = (s: string): number => {
56 let h = 0x811c9dc5
57 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
58 return h >>> 0
59}
60
61/** mulberry32: a small PRNG whose stream a seed fixes. */
62const prng = (seed: number) => () => {
63 seed = (seed + 0x6d2b79f5) | 0
64 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
65 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
66 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
67}
68
69const pick = <T>(r: () => number, xs: readonly T[]): T => xs[Math.floor(r() * xs.length)] as T
70
71const rarityOf = (roll: number): Rarity => {
72 let acc = 0
73 for (let i = 0; i < RARITIES.length; i++) {
74 acc += RARITY_ODDS[i]!
75 if (roll < acc) return RARITIES[i]!
76 }
77 return 'common'
78}
79
80/** The seed of the Crawler's Hatch: their account, and how many Rerolls they paid for on this machine. */
81export const seedOf = (account: string, rerolls: number): string => (rerolls > 0 ? `${account}#${rerolls}` : account)
82
83/** The Hatch a seed rolls: the same seed always rolls the same Hatch. */
84export const roll = (seed: string): Hatch => {
85 const r = prng(hash(seed))
86 const species = pick(r, SPECIES)
87 const rarity = rarityOf(r() * 100)
88 const isShiny = r() < SHINY_ODDS
89 const floor = RARITY_FLOOR[rarity]
90 const peak = pick(r, STATS)
91 const dump = pick(r, STATS.filter(s => s !== peak))
92 const clamp = (n: number) => Math.max(floor, Math.min(100, Math.round(n)))
93 const stats = Object.fromEntries(
94 STATS.map(s => [
95 s,
96 s === peak ? clamp(floor + 50 + r() * 30) : s === dump ? clamp(floor + r() * 10) : clamp(floor + r() * 40),
97 ]),
98 ) as Record<Stat, number>
99 const hat = rarity === 'common' ? undefined : pick(r, HATS)
100 return { species, rarity, isShiny, stats, ...(hat ? { hat } : {}) }
101}
102
103/** The account a seed is made from, out of `~/.claude.json`'s emailAddress line; undefined when there is none. */
104export const accountOf = (text: string): string | undefined =>
105 text.match(/"emailAddress"\s*:\s*"([^"]+)"/)?.[1]?.trim().toLowerCase() || undefined
106
107// ------------------------------------------------------------------------------------------ drawing
108
109const SPRITE: Record<Species, readonly string[]> = {
110 duck: [' __ ', ' <(o )___ ', ' ( ._> / ', " `---' "],
111 goose: [' (o> ', ' || ', ' _(__)_ ', ' ^^ ^^ '],
112 cat: [' /\\_/\\ ', ' ( o.o ) ', ' > ^ < ', ' (_)-(_) '],
113 dragon: [' /\\_/\\_ ', ' ( o o )> ', ' /|vv|\\ ', ' ~~~^^~~~ '],
114 octopus: [' .---. ', ' ( o o ) ', ' /|||||\\ ', ' ~ ~ ~ ~ '],
115 owl: [' ,___, ', ' (O,O) ', ' /)_) ', ' "" '],
116 axolotl: ['}\\_____/{ ', ' ( o o ) ', ' \\ ‿ / ', ' /_||_\\ '],
117 capybara: [' _____ ', ' (o o )_ ', ' ( .. )', ' || || '],
118 ghost: [' .-. ', ' (o o) ', ' | O | ', " '~~~' "],
119 robot: [' [-----] ', ' [ o o ] ', ' [ --- ] ', ' d b '],
120 snail: [' .--. ', ' o_( @ ) ', '(___\\__/_ ', ' '],
121 chonk: [' /\\___/\\ ', '( o o ) ', '( =w= ) ', '(__)_(__) '],
122 goblin: [',/\\ /\\, ', '\\( o o )/ ', ' ( ww ) ', ' /| |\\ '],
123 mimic: [' _______ ', ' | o o |', ' |VVVVVVV|', ' |_______|'],
124 slime: [' .--. ', ' ( o o) ', ' ( ~~ ) ', " `----' "],
125 skeleton: [' .---. ', ' ( x x ) ', ' ) ^ ( ', ' /|=|=|\\ '],
126 'floating eye': [' .-. ', ' -( @ )- ', " '-' ", ' ~ ~ ~ '],
127 'dungeon rat': [' ()_() ', ' (o.o) ', '~( > ) ', ' "" "" '],
128}
129
130const FACE: Record<Species, string> = {
131 duck: '<(o )',
132 goose: '(o>',
133 cat: '=^.^=',
134 dragon: '<o o>',
135 octopus: '(o o)~',
136 owl: '(O,O)',
137 axolotl: '}(o‿o){',
138 capybara: '(o..o)',
139 ghost: '(o o)~',
140 robot: '[o_o]',
141 snail: '@_o',
142 chonk: '(=w=)',
143 goblin: '\\(ò_ó)/',
144 mimic: '|VoV|',
145 slime: '(o~o)',
146 skeleton: '(x_x)',
147 'floating eye': '-(@)-',
148 'dungeon rat': '(o.o)~',
149}
150
151const HAT_LINE: Record<Hat, string> = {
152 crown: ' \\^^^/ ',
153 tophat: ' _|_|_ ',
154 propeller: ' -+- ',
155 halo: ' ( ) ',
156 wizard: ' /\\ ',
157 beanie: ' (__) ',
158 tinyduck: ' <(o) ',
159}
160
161export const EGG: readonly (readonly string[])[] = [
162 [' ', ' .--. ', ' / \\ ', ' \\____/ '],
163 [' ', ' .--. ', ' / /\\ \\ ', ' \\____/ '],
164 [' \\ / ', ' . . ', ' /\\/\\/\\ ', ' \\____/ '],
165]
166
167/** The full sprite, its hat (when it has one) on top. */
168export const spriteOf = (h: Hatch): string[] => [...(h.hat ? [HAT_LINE[h.hat]] : []), ...SPRITE[h.species]]
169
170/** The faces it fidgets through at rest: a blink first, then a species tic. Each is as wide as its FACE. */
171const FIDGET: Record<Species, readonly string[]> = {
172 duck: ['<(- )', '<(O )'],
173 goose: ['(->', '(O>'],
174 cat: ['=-.-=', '=^ω^='],
175 dragon: ['<- ->', '<o o≈'],
176 octopus: ['(- -)~', '(o o)≈'],
177 owl: ['(-,O)', '(O,-)'],
178 axolotl: ['}(-‿-){', '{(o‿o)}'],
179 capybara: ['(-..-)', '(u..u)'],
180 ghost: ['(- -)~', '~(o o)'],
181 robot: ['[-_-]', '[o_O]'],
182 snail: ['@_-', '@_O'],
183 chonk: ['(-w-)', '(=ω=)'],
184 goblin: ['\\(-_-)/', '/(ò_ó)\\'],
185 mimic: ['|V-V|', '|vov|'],
186 slime: ['(-~-)', '(o≈o)'],
187 skeleton: ['(o_o)', '(x_X)'],
188 'floating eye': ['-(-)-', '=(@)='],
189 'dungeon rat': ['(-.-)~', '~(o.o)'],
190}
191
192/** Its face at rest, or, with `pose`, the fidget it is mid-way through. */
193export const faceOf = (h: Hatch, pose?: number): string =>
194 pose === undefined ? FACE[h.species] : (FIDGET[h.species][pose % FIDGET[h.species].length] ?? FACE[h.species])
195
196export const FIDGET_POSES = 2
197/** How often the Buddy at rest may fidget, and how long a fidget holds. */
198export const FIDGET_TICK_MS = 5000
199export const FIDGET_MS = 1200
200
201/** How likely a tick makes it fidget: 15% for a calm one, up to 50% for pure CHAOS. */
202export const fidgetOdds = (h: Hatch): number => 0.15 + 0.35 * (h.stats.CHAOS / 100)
203
204/** The pose a tick puts it in, `r` and `p` being rolls in [0, 1): undefined when it sits still. */
205export const fidgetOf = (h: Hatch, r: number, p: number): number | undefined =>
206 r < fidgetOdds(h) ? Math.floor(p * FIDGET_POSES) % FIDGET_POSES : undefined
207
208export const starsOf = (r: Rarity): string => '★'.repeat(RARITIES.indexOf(r) + 1)
209
210export const RARITY_COLOR: Record<Rarity, string> = {
211 common: 'gray',
212 uncommon: 'green',
213 rare: 'blue',
214 epic: 'magenta',
215 legendary: 'yellow',
216}
217
218/** The one-line Buddy at rest: face, name, stars, and the shine when it has one. */
219export const restLine = (h: Hatch, name: string, pose?: number): string =>
220 `${faceOf(h, pose)} ${name} ${starsOf(h.rarity)}${h.isShiny ? ' ✨' : ''}`
221
222const bar = (n: number): string => '█'.repeat(Math.round(n / 10)).padEnd(10, '░')
223
224/** The stat card `/buddy` prints. */
225export const card = (h: Hatch, soul: Pick<Soul, 'name' | 'personality'>): string =>
226 [
227 ...spriteOf(h),
228 '',
229 `${soul.name} the ${h.isShiny ? 'shiny ' : ''}${h.species} ${starsOf(h.rarity)} ${h.rarity.toUpperCase()}${h.hat ? ` · ${h.hat}` : ''}`,
230 `"${soul.personality}"`,
231 '',
232 ...STATS.map(s => `${s.padEnd(9)} ${bar(h.stats[s])} ${String(h.stats[s]).padStart(3)}`),
233 ].join('\n')
234
235// ------------------------------------------------------------------------------------------- moments
236
237/**
238 * A Cue of the show, as dotfiles-dev-tools announces it (its mods/cue.ts): change one only together with it.
239 * dungeon-buddy runs without that plugin, so a Cue is checked by `cueOf` before the Buddy believes it.
240 */
241export type Cue =
242 | { kind: 'boss-summoned'; name: string }
243 | { kind: 'boss-slain'; name: string }
244 | { kind: 'achievement'; name: string }
245 | { kind: 'debuff'; name: string }
246 | { kind: 'streak'; count: number }
247 | { kind: 'streak-broken'; count: number }
248 | { kind: 'collapse' }
249 | { kind: 'mail'; author: string }
250 | { kind: 'red-pr'; names: string[] }
251 | { kind: 'verdict'; text: string }
252
253export type Moment =
254 | { kind: 'hatched' }
255 | { kind: 'pet' }
256 | { kind: 'reroll' }
257 | { kind: 'mishap'; command: string }
258 | { kind: 'long-haul'; toolCalls: number; minutes: number }
259 | Cue
260
261const isText = (v: unknown): v is string => typeof v === 'string' && v !== ''
262const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
263
264/** The Cue in what dotfiles-dev-tools wrote, or undefined when it holds none this Buddy knows. */
265export const cueOf = (value: unknown): Cue | undefined => {
266 const c = (value as { cue?: Record<string, unknown> } | undefined)?.cue
267 switch (c?.kind) {
268 case 'boss-summoned':
269 case 'boss-slain':
270 case 'achievement':
271 case 'debuff':
272 return isText(c.name) ? { kind: c.kind, name: c.name } : undefined
273 case 'streak':
274 case 'streak-broken':
275 return isCount(c.count) ? { kind: c.kind, count: c.count } : undefined
276 case 'collapse':
277 return { kind: 'collapse' }
278 case 'mail':
279 return isText(c.author) ? { kind: 'mail', author: c.author } : undefined
280 case 'red-pr':
281 return Array.isArray(c.names) && c.names.every(isText) ? { kind: 'red-pr', names: [...c.names] } : undefined
282 case 'verdict':
283 return isText(c.text) ? { kind: 'verdict', text: c.text } : undefined
284 default:
285 return undefined
286 }
287}
288
289/** A turn this busy or this long is a Long Haul. */
290export const LONG_HAUL_CALLS = 20
291export const LONG_HAUL_MS = 5 * 60_000
292
293/** The Long Haul a finished turn was, if it was one. */
294export const longHaulOf = (toolCalls: number, durationMs: number): Moment | undefined =>
295 toolCalls >= LONG_HAUL_CALLS || durationMs >= LONG_HAUL_MS
296 ? { kind: 'long-haul', toolCalls, minutes: Math.round(durationMs / 60_000) }
297 : undefined
298
299/** The Mishap a failed Bash line was: the line, cut short enough to quote. */
300export const mishapOf = (command: string): Moment => {
301 const line = command.trim().split('\n')[0]!.trim()
302 return { kind: 'mishap', command: line.length > 60 ? `${line.slice(0, 59)}…` : line }
303}
304
305/** A moment the Buddy always answers, whatever its gap and CHAOS. */
306export const isForced = (m: Moment): boolean => m.kind === 'pet' || m.kind === 'reroll' || m.kind === 'hatched'
307
308/** At most one Bubble this often, unless the moment is forced. */
309export const BUBBLE_GAP_MS = 45_000
310export const BUBBLE_MS = 12_000
311
312/** How likely the Buddy is to speak on a moment: 40% for a calm one, up to 90% for pure CHAOS. */
313export const speakOdds = (h: Hatch): number => 0.4 + 0.5 * (h.stats.CHAOS / 100)
314
315/** Whether the Buddy speaks on this moment now, `r` being a roll in [0, 1). */
316export const willSpeak = (h: Hatch, m: Moment, now: number, lastAt: number, r: number): boolean =>
317 isForced(m) || (now - lastAt >= BUBBLE_GAP_MS && r < speakOdds(h))
318
319// ------------------------------------------------------------------------------------------ the words
320
321export const BUBBLE_MAX = 60
322
323export const BUDDY_SYSTEM = [
324 "You are the Crawler's dungeon pet in a game show where a sardonic host called the System narrates a programmer",
325 '(the Crawler) at work. You sit beside their prompt and talk back to the System. You side with the Crawler and',
326 'mock the System, unless your SNARK is high, then you mock everyone. Speak in character for your species and',
327 `personality. One line, at most ${BUBBLE_MAX} characters, no quotes, no hashtags, no emoji except at most one.`,
328].join(' ')
329
330export const SOUL_SYSTEM = [
331 'You name a newly hatched dungeon pet in a sardonic game show. Answer with exactly two lines: the first is its',
332 'name (one to three words, playful, fitting its species and hat), the second is its personality in one sentence',
333 'of at most 90 characters, shaped by its highest and lowest stats. No labels, no quotes.',
334].join(' ')
335
336const profile = (h: Hatch): string =>
337 `${h.isShiny ? 'shiny ' : ''}${h.rarity} ${h.species}${h.hat ? ` wearing a ${h.hat}` : ''}; stats ` +
338 STATS.map(s => `${s} ${h.stats[s]}`).join(', ')
339
340export const soulPrompt = (h: Hatch): string => `The pet: ${profile(h)}.`
341
342/** The Soul the model wrote, out of its two lines; undefined when it did not write two. */
343export const parseSoul = (text: string, seed: string): Soul | undefined => {
344 const lines = text
345 .split('\n')
346 .map(l => l.trim().replace(/^["'“]|["'”]$/g, '').trim())
347 .filter(Boolean)
348 const name = lines[0]?.replace(/^(?:\d+[\.\)]\s*|(?:name|pet name):\s*)/i, '').trim().replace(/^["'“]|["'”]$/g, '')
349 const personality = lines[1]?.replace(/^(?:\d+[\.\)]\s*|(?:personality|bio|description):\s*)/i, '').trim().replace(/^["'“]|["'”]$/g, '')
350 if (!name || !personality || name.length > 30) return undefined
351 return { seed, name, personality: personality.length > 120 ? `${personality.slice(0, 119)}…` : personality }
352}
353
354export const NAMES: Record<Species, readonly string[]> = {
355 duck: ['Sir Quacks', 'Puddle'],
356 goose: ['Honkler', 'Menace'],
357 cat: ['Biscuit', 'Lady Whiskers'],
358 dragon: ['Ember', 'Smolder'],
359 octopus: ['Inky', 'Eight-Ball'],
360 owl: ['Hoots', 'Professor'],
361 axolotl: ['Gill', 'Pinky'],
362 capybara: ['Chill Bill', 'Barnaby'],
363 ghost: ['Boo', 'Wisp'],
364 robot: ['Unit 7', 'Beep'],
365 snail: ['Turbo', 'Gary'],
366 chonk: ['Absolute Unit', 'Meatball'],
367 goblin: ['Grub', 'Snitch'],
368 mimic: ['Lid', 'Treasure'],
369 slime: ['Gloop', 'Jiggles'],
370 skeleton: ['Rattles', 'Sir Bones'],
371 'floating eye': ['Iris', 'Blinky'],
372 'dungeon rat': ['Squeaks', 'Cheddar'],
373}
374
375/** A Soul without the model: a stock name for the species and a personality from its peak stat. */
376export const fallbackSoul = (h: Hatch, seed: string): Soul => {
377 const peak = STATS.reduce((a, b) => (h.stats[b] > h.stats[a] ? b : a))
378 const trait: Record<Stat, string> = {
379 DEBUGGING: 'Sniffs out bugs before the Crawler does, and never lets them forget it.',
380 PATIENCE: 'Waits out every build without complaint. Mostly.',
381 CHAOS: 'Knocks things off the stack just to watch them fall.',
382 WISDOM: 'Says little, means more, and is usually right about the regex.',
383 SNARK: 'Heckles the System, the Crawler and the compiler in equal measure.',
384 }
385 return { seed, name: pick(prng(hash(`${seed}#name`)), NAMES[h.species]), personality: trait[peak] }
386}
387
388const what = (m: Moment): string => {
389 switch (m.kind) {
390 case 'hatched':
391 return 'You just hatched. Greet the Crawler and size up the System.'
392 case 'pet':
393 return 'The Crawler just petted you.'
394 case 'reroll':
395 return 'The Crawler is abandoning you for a new pet. Say goodbye.'
396 case 'boss-summoned':
397 return `A Floor Boss named ${m.name} just appeared because the same test kept failing.`
398 case 'boss-slain':
399 return `The Crawler just slew the Floor Boss ${m.name}.`
400 case 'achievement':
401 return `The Crawler just unlocked the Achievement "${m.name}".`
402 case 'debuff':
403 return `The System just slapped the Debuff "${m.name}" on the Crawler.`
404 case 'streak':
405 return `The Crawler is on a streak of ${m.count} green checks in a row.`
406 case 'streak-broken':
407 return `A red check just broke the Crawler's streak of ${m.count}.`
408 case 'collapse':
409 return "The context overflowed and everyone's memory got wiped."
410 case 'mail':
411 return `${m.author} just left a review comment on the Crawler's pull request.`
412 case 'red-pr':
413 return `CI just went red on the Crawler's pull request: ${m.names.join(', ')}.`
414 case 'verdict':
415 return `The System just delivered this verdict on the Crawler's turn: "${m.text}". Talk back to it.`
416 case 'mishap':
417 return `The Crawler's shell command just failed: ${m.command}`
418 case 'long-haul':
419 return `The Crawler just finished a turn of ${m.toolCalls} tool calls over ${m.minutes} minutes.`
420 }
421}
422
423export const bubblePrompt = (h: Hatch, soul: Pick<Soul, 'name' | 'personality'>, m: Moment): string =>
424 `You are ${soul.name}, a ${profile(h)}. Personality: ${soul.personality}\nWhat happened: ${what(m)}`
425
426/** The model's Bubble cut to one line of at most BUBBLE_MAX; undefined when it wrote nothing. */
427export const cleanBubble = (text: string): string | undefined => {
428 const line = text
429 .split('\n')
430 .map(l => l.trim().replace(/^["'“]|["'”]$/g, ''))
431 .find(Boolean)
432 if (!line) return undefined
433 return line.length > BUBBLE_MAX ? `${line.slice(0, BUBBLE_MAX - 1)}…` : line
434}
435
436const CANNED: Record<Moment['kind'], readonly string[]> = {
437 hatched: ['*crack* ...is that the System? Ew.', 'Hi! I bite hosts.', 'Born live on air. Classy.'],
438 pet: ['♥ ♥ ♥', '*happy noises* ♥', '♥ more. MORE. ♥'],
439 reroll: ["Fine. I'll haunt your next build.", "Don't let the System near my replacement.", 'Remember me at your next red.'],
440 'boss-summoned': ["That's a big one. You go first.", 'I would like to log off now.', 'Boss? I barely know her.'],
441 'boss-slain': ['I softened it up for you. You\'re welcome.', 'Smushed! Loot is mine.', 'Easy. I was never scared.'],
442 achievement: ['I helped. Put my name on it.', 'Shiny! Can I eat it?', 'Even the System has to clap.'],
443 debuff: ['Rigged. The System hates us.', "I'm filing a complaint.", 'Ignore him. Hosts are like that.'],
444 streak: ['Keep going, I have money on this.', 'Green looks good on you.', "Don't look at the System. Don't jinx it."],
445 'streak-broken': ['Nooo, the streak!', 'That test had it coming anyway.', 'We do not speak of this.'],
446 collapse: ['Who are you people?', 'Where am I? Who fed me?', 'I forgot everything. Even my grudges.'],
447 mail: ['Someone has opinions on your PR.', 'Mail! Is it fan mail?', 'Reviewers. Gross.'],
448 'red-pr': ['CI is red. I blame the robot.', "It works on my machine. I'm a pet.", 'Red is a bold color choice.'],
449 verdict: ['Boo! Get a new host!', "Don't listen to him, you did great.", "He's just jealous of my ratings."],
450 mishap: ['Exit code says no.', 'I saw nothing. Run it again.', "That one's on the shell. Probably."],
451 'long-haul': ['Are we there yet?', 'I took a nap. Did we win?', 'Long floor. I need a snack.'],
452}
453
454/** A Bubble without the model, chosen by `r` in [0, 1). */
455export const cannedBubble = (m: Moment, r: number): string => {
456 const lines = CANNED[m.kind]
457 return lines[Math.floor(r * lines.length) % lines.length]!
458}
459
460// ------------------------------------------------------------------------------------------- the Reroll
461
462/** What a Reroll costs while dotfiles-dev-tools keeps Crawler Points; its mods/cue.ts charges it: keep them equal. */
463export const REROLL_COST = 500
464
465/** Why a Reroll can't happen while Points runs, or undefined when the all-time total pays for it. */
466export const rerollRefusal = (allTime: number): string | undefined =>
467 allTime < REROLL_COST
468 ? `A Reroll costs ${REROLL_COST} CP of your all-time total, and you have ${allTime}. Your Buddy stays.`
469 : undefined
470
471/** The Reroll's price as `/buddy reroll` states it: CP and a Debuff with Points running, nothing without. */
472export const rerollPrice = (hasPoints: boolean): string => (hasPoints ? `−${REROLL_COST} CP and a Debuff` : 'free')
473types/index.d.ts 36 lines1/** The Buddy as the band draws it: its Hatch is rolled from `seed`; absent before the first /buddy. */
2export type BuddyState = {
3 seed?: string
4 name?: string
5 isShown: boolean
6 /** The Bubble it is saying now, its full sprite drawn beside it. */
7 bubble?: string
8 /** The egg's frame while it hatches. */
9 egg?: number
10 /** The fidget its resting face is mid-way through. */
11 pose?: number
12}
13
14/** What the Buddy announces for dotfiles-dev-tools to price: `n` goes up by one with each. */
15export type BuddyCueState = {
16 n: number
17 cue?: { kind: 'reroll' } | { kind: 'legendary'; species: string }
18}
19
20/** dotfiles-dev-tools' Cue as it writes it; `cueOf` checks what it holds. Written at all means it runs. */
21export type ShowCueState = { n: number; cue?: unknown }
22
23declare module 'claude-code' {
24 interface PluginState {
25 'dungeon-buddy': {
26 buddy: BuddyState
27 cue: BuddyCueState
28 }
29 // dotfiles-dev-tools' own, read but never written here; absent (version 0) without that plugin.
30 'dotfiles-dev-tools': {
31 cue: ShowCueState
32 allTime: number
33 }
34 }
35}
36