Development formatting hooks (Go, Shell, Proto, OpenAPI), TDD agents, and skills incl. pi-implementer (a local model implements your Tickets through pi +…

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
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
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 645 lines1// The plugin's function hooks (mods). The command hooks in hooks.json (the formatters) are separate.
2//
3// Dispatch Board: a pane listing pi-implementer's Dispatches on the current run branch, live while they run.
4// It reads what pi-dispatch.py leaves under .hybrid/ and never writes there. /dispatches opens it; it also
5// opens by itself when a Dispatch starts.
6//
7// Crawler Points: the System scores the session. Test/build runs, git milestones, tool errors and the
8// Dispatch fates the board notices add or take points; the status line shows the score, and a toast written
9// by a small model in the System's voice announces each award.
10//
11// Podman Guard: a Bash line that runs `docker` runs `podman` instead, with a note the model reads; a Daemon-Only
12// Command is refused; a DOCKER_OK=1 line passes untouched.
13//
14// TDD Band: above the prompt, the TDD Phase that TDD subagents and Checks lead to, with buttons that draft a
15// prompt for each TDD subagent. /tdd shows or hides it.
16//
17// The System's voice: Spinner Words in the terminal, and a Verdict under each Notable Turn's answer.
18//
19// Branch Guard: a git commit on a Protected Branch, or a push that lands on one, is refused; a BRANCH_OK=1
20// line passes untouched.
21//
22// Floor Boss: three red runs in a row of one Check command summon a named boss above the prompt, its HP the
23// failing-test count; that command's next green run slays it for 100 CP and an Achievement.
24//
25// Every use of `$` lives in this file (the engine follows `$` into this file's functions, never across an
26// import); the rules are in ../mods/board.ts and ../mods/points.ts.
27
28import { atom, read, update } from 'claude-code'
29import type { EngineInterface, Register } from 'claude-code'
30
31import {
32 boardRows,
33 completeLines,
34 ctxSizeOf,
35 duration,
36 emptyCounts,
37 foldPiEvents,
38 kilo,
39 parseJsonl,
40 tail,
41 type DispatchRow,
42 type LiveCounts,
43 type Outcome,
44 type RunningMeta,
45} from '../mods/board'
46import {
47 apply,
48 bashAwards,
49 cleanQuip,
50 compactAward,
51 dispatchAward,
52 errorAward,
53 fallbackToast,
54 QUIP_SYSTEM,
55 quipPrompt,
56 statusLine,
57 type Award,
58 type Fate,
59 type Usage,
60} from '../mods/points'
61import { bossToast, checkKey, failingCount, hpBar, initialBosses, newestBoss, onBossCheck, slayAward } from '../mods/boss'
62import { branchGuard, gitSteps, type Repo } from '../mods/branch'
63import { guard, rewriteNote } from '../mods/podman'
64import { DRAFTS, initialTdd, isShown, onAgent, onCheck, toggle, verdictOf } from '../mods/tdd'
65import {
66 fallbackVerdict,
67 isNotable,
68 moodOf,
69 spinnerWord,
70 VERDICT_SYSTEM,
71 verdictLine,
72 verdictPrompt,
73 type TurnStats,
74} from '../mods/voice'
75import type { BoardRowState, BoardState, BossesState, CrawlerScore, TddState } from '../types'
76
77// ---------------------------------------------------------------------------------------------- state
78
79const board = atom({ plugin: 'dotfiles-dev-tools', key: 'board' } as const, null)
80const score = atom({ plugin: 'dotfiles-dev-tools', key: 'score' } as const, { session: 0, streak: 0 })
81const allTime = atom({ plugin: 'dotfiles-dev-tools', key: 'allTime' } as const, 0)
82const tdd = atom({ plugin: 'dotfiles-dev-tools', key: 'tdd' } as const, initialTdd as TddState)
83const bosses = atom({ plugin: 'dotfiles-dev-tools', key: 'bosses' } as const, initialBosses as BossesState)
84
85const PANE = 'dispatch-board'
86const TITLE = 'Dispatch Board'
87const POLL_MS = 2000
88
89const STORE_ALL_TIME = 'crawler-points.allTime'
90const STORE_UNLOCKED = 'crawler-points.unlocked'
91const QUIP_MODEL = 'haiku'
92/** At most one quip call in flight, and none sooner than this after the last one began. */
93const QUIP_GAP_MS = 8000
94const TOAST_MS = 6000
95/** A model-written quip is a full sentence of up to 140 characters: it stays long enough to read. */
96const QUIP_TOAST_MS = 15000
97/** The answer's line waits on the Verdict, so its model call gets less time than a toast's quip. */
98const VERDICT_TIMEOUT_MS = 5000
99
100type Watch = {
101 root: string
102 ctxLimit?: number
103 /** Per live log: bytes folded so far and the counts they gave. */
104 logs: Map<string, { bytes: number; counts: LiveCounts }>
105 running: Set<number>
106 /** Size of outcomes.jsonl / dispatches.jsonl at the last full read; undefined before the first. */
107 sizes: { outcomes?: number; dispatches?: number }
108 /** Lines already seen, so only later ones become Fates; undefined before the baseline read. */
109 seen: { outcomes?: number; dispatches?: number }
110 /** The .json names under .hybrid/running/ at the last full read, so a leftover one is read once. */
111 runFiles?: string
112}
113
114// Module variables: a hot reload starts them over (session.start runs again and re-baselines the board),
115// which costs at most an early quip or a second "first check" of the session.
116let watch: Watch | undefined
117let isTicking = false
118let hasChecked = false
119let isQuipBusy = false
120let quipAt = -Infinity
121/** The session's usage as the last `session.measure` reported it, for the HUD. */
122let usage: Usage | undefined
123/** The main loop's turn in progress: what it scored and how many tool calls it made; undefined between turns. */
124let turn: Omit<TurnStats, 'durationMs'> | undefined
125/** This turn's Spinner Word, picked once at its start so the spinner does not flicker between words. */
126let spinner: string | undefined
127
128// ------------------------------------------------------------------------------------- Dispatch Board
129
130async function readText($: EngineInterface, path: string): Promise<string> {
131 return (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
132}
133
134async function sizeOf($: EngineInterface, path: string): Promise<number> {
135 return (await $.fs.exists(path)) ? (await $.fs.stat(path)).size : 0
136}
137
138/** The main checkout's root, also from inside a Worktree, as pi-dispatch.py finds it. */
139async function mainRoot($: EngineInterface): Promise<string | undefined> {
140 const r = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'])
141 if (r.exitCode !== 0) return undefined
142 const dir = r.stdout.trim()
143 return dir.slice(0, dir.lastIndexOf('/')) || undefined
144}
145
146async function ctxLimitOf($: EngineInterface): Promise<number | undefined> {
147 const r = await $.process.run([
148 'sh',
149 '-c',
150 'cat "${PI_IMPLEMENTER_HOME:-$HOME/.config/pi-implementer}/env" 2>/dev/null',
151 ])
152 return ctxSizeOf(r.stdout)
153}
154
155async function livePids($: EngineInterface, pids: readonly number[]): Promise<Set<number>> {
156 if (pids.length === 0) return new Set()
157 const r = await $.process.run(['ps', '-o', 'pid=', '-p', pids.join(',')])
158 return new Set(
159 r.stdout
160 .split('\n')
161 .map(Number)
162 .filter(n => n > 0),
163 )
164}
165
166/** Folds whatever the log gained since the last read into its counts; a torn last line waits. */
167async function followLog($: EngineInterface, w: Watch, log: string): Promise<LiveCounts> {
168 const held = w.logs.get(log) ?? { bytes: 0, counts: emptyCounts() }
169 const r = await $.process.run(['tail', '-c', `+${held.bytes + 1}`, `${w.root}/${log}`])
170 const { text, bytes } = completeLines(r.exitCode === 0 ? r.stdout : '')
171 const next = { bytes: held.bytes + bytes, counts: foldPiEvents(held.counts, parseJsonl(text)) }
172 w.logs.set(log, next)
173 return next.counts
174}
175
176/** The running Dispatches: a `.json` beside a live `.pid` under .hybrid/running/. */
177async function runningMetas($: EngineInterface, w: Watch): Promise<RunningMeta[]> {
178 const dir = `${w.root}/.hybrid/running`
179 if (!(await $.fs.exists(dir))) return []
180 const names = (await $.fs.list(dir)).map(e => e.name)
181 const pids = new Map<number, number>()
182 for (const name of names.filter(n => n.endsWith('.pid'))) {
183 const pid = Number((await readText($, `${dir}/${name}`)).trim())
184 if (pid > 0) pids.set(Number(name.slice(0, -4)), pid)
185 }
186 const live = await livePids($, [...pids.values()])
187 const metas: RunningMeta[] = []
188 for (const name of names.filter(n => n.endsWith('.json'))) {
189 const meta = parseJsonl<RunningMeta>(await readText($, `${dir}/${name}`))[0]
190 const pid = meta && pids.get(meta.n)
191 if (meta && pid !== undefined && live.has(pid)) metas.push(meta)
192 }
193 return metas
194}
195
196/** Re-reads .hybrid/ and redraws; answers the Fates that appeared and the Dispatches that started. */
197async function refresh($: EngineInterface, w: Watch): Promise<{ fates: Fate[]; started: number[] }> {
198 const hybrid = `${w.root}/.hybrid`
199 const branch = (
200 await $.process.run(['git', '-C', w.root, 'rev-parse', '--abbrev-ref', 'HEAD'])
201 ).stdout.trim()
202 const now = await $.clock.now()
203
204 // Every live Dispatch is tracked, so one already running is not "started" again when the checkout comes
205 // back to its branch; only this run branch's are followed, shown and auto-opened for.
206 const live = await runningMetas($, w)
207 const metas = live.filter(m => m.run_branch === branch)
208 const running = []
209 for (const meta of metas) running.push({ meta, counts: await followLog($, w, meta.log), nowMs: now })
210 for (const log of [...w.logs.keys()]) if (!metas.some(m => m.log === log)) w.logs.delete(log)
211
212 const finished = parseJsonl<DispatchRow>(await readText($, `${hybrid}/dispatches.jsonl`))
213 const outcomes = parseJsonl<Outcome>(await readText($, `${hybrid}/outcomes.jsonl`))
214 const present = new Set<string>()
215 for (const r of finished) {
216 if (r.run_branch === branch && (await $.fs.exists(r.worktree))) present.add(r.worktree)
217 }
218
219 const ticketOf = (n: number) => finished.find(r => r.n === n)?.ticket ?? `#${n}`
220 const fates: Fate[] = [
221 ...(w.seen.outcomes === undefined ? [] : outcomes.slice(w.seen.outcomes)).map(o => ({
222 fate: o.outcome,
223 ticket: ticketOf(o.n),
224 })),
225 ...(w.seen.dispatches === undefined ? [] : finished.slice(w.seen.dispatches))
226 .filter(r => r.ended === 'timeout' || r.ended === 'turn cap')
227 .map(r => ({ fate: r.ended as 'timeout' | 'turn cap', ticket: r.ticket })),
228 ]
229 w.seen = { outcomes: outcomes.length, dispatches: finished.length }
230
231 const started = metas.map(m => m.n).filter(n => !w.running.has(n))
232 w.running = new Set(live.map(m => m.n))
233
234 const rows = boardRows({ branch, running, finished, outcomes, worktreesPresent: present })
235 await update($, board, (): BoardState => ({ branch, ctxLimit: w.ctxLimit, rows }))
236 return { fates, started }
237}
238
239/** The board's pane, if open: placed (drawn), or waiting undrawn for a wider terminal. */
240async function boardPane($: EngineInterface) {
241 return (await $.ui.panes()).find(p => p.id === PANE)
242}
243
244/**
245 * One poll: cheap stats always; a full read only while a Dispatch runs, the pane is drawn, or a file grew or
246 * came or went. A .json a killed pi-dispatch.py left behind is read once, not every poll.
247 */
248async function tick($: EngineInterface, force: boolean): Promise<void> {
249 const w = watch
250 if (!w || !(await $.fs.exists(`${w.root}/.hybrid`))) return
251 const hybrid = `${w.root}/.hybrid`
252 const sizes = {
253 outcomes: await sizeOf($, `${hybrid}/outcomes.jsonl`),
254 dispatches: await sizeOf($, `${hybrid}/dispatches.jsonl`),
255 }
256 const runningDir = `${hybrid}/running`
257 const runFiles = (await $.fs.exists(runningDir))
258 ? (await $.fs.list(runningDir))
259 .map(e => e.name)
260 .filter(n => n.endsWith('.json'))
261 .sort()
262 .join(',')
263 : ''
264 const hasChanged =
265 sizes.outcomes !== w.sizes.outcomes || sizes.dispatches !== w.sizes.dispatches || runFiles !== w.runFiles
266 const isDrawn = (await boardPane($))?.isPlaced === true
267 if (!(force || hasChanged || w.running.size > 0 || isDrawn)) return
268 w.sizes = sizes
269 w.runFiles = runFiles
270 const { fates, started } = await refresh($, w)
271 for (const fate of fates) await award($, dispatchAward(fate.fate, fate.ticket))
272 if (started.length === 0 || isDrawn) return
273 // Unasked, the pane seats only from 144 columns and otherwise waits undrawn: say so rather than nothing.
274 const pane = (await boardPane($)) ?? (await $.ui.open({ id: PANE, title: TITLE }))
275 if (!pane.isPlaced) {
276 $.ui.toast(`Dispatch #${started.join(', #')} started: /dispatches shows the board`, {
277 timeoutMs: TOAST_MS,
278 })
279 }
280}
281
282async function startBoard($: EngineInterface): Promise<void> {
283 await $.command.register({
284 name: 'dispatches',
285 description: 'Open the Dispatch Board: pi-implementer Dispatches on this run branch',
286 })
287 const root = await mainRoot($)
288 watch = root
289 ? { root, ctxLimit: await ctxLimitOf($), logs: new Map(), running: new Set(), sizes: {}, seen: {} }
290 : undefined
291 isTicking = false
292 $.clock.every(POLL_MS, () => {
293 if (isTicking) return
294 isTicking = true
295 void tick($, false)
296 .catch(() => undefined)
297 .finally(() => {
298 isTicking = false
299 })
300 })
301 await tick($, true).catch(() => undefined) // the baseline: Fates already on disk are not new
302}
303
304const PHASE: Record<BoardRowState['phase'], { label: string; color: string }> = {
305 running: { label: '▶ running', color: 'yellow' },
306 awaiting: { label: '⏸ Gate', color: 'cyan' },
307 landed: { label: '✔ landed', color: 'green' },
308 dropped: { label: '✖ dropped', color: 'gray' },
309 conflict: { label: '⚠ conflict', color: 'red' },
310 gone: { label: '· gone', color: 'gray' },
311}
312
313const stats = (row: BoardRowState, ctxLimit?: number): string =>
314 [
315 row.ended && row.ended !== 'finished' ? row.ended : undefined,
316 duration(row.wallS),
317 `calls ${row.calls}${row.maxTurns ? `/${row.maxTurns}` : ''}`,
318 `tools ${row.tools}`,
319 `ctx ${kilo(row.ctx)}${ctxLimit ? `/${kilo(ctxLimit)}` : ''}`,
320 row.phase === 'running' && row.lastTool ? `last: ${row.lastTool}` : undefined,
321 ]
322 .filter(Boolean)
323 .join(' ')
324
325const TDD_ICON = { RED: '🔴', GREEN: '🟢', REFACTOR: '🔧' } as const
326const TDD_COLOR = { RED: 'red', GREEN: 'green', REFACTOR: 'cyan', NONE: 'gray' } as const
327const MOOD_COLOR = { good: 'green', bad: 'red', waiting: 'gray' } as const
328
329// -------------------------------------------------------------------------------------- Crawler Points
330
331async function showScore($: EngineInterface): Promise<void> {
332 $.ui.status(statusLine(await read($, score), await read($, allTime), usage))
333}
334
335/** Toasts the award: a model-written line when one is allowed and arrives, the plain line otherwise. */
336async function announce(
337 $: EngineInterface,
338 a: Award,
339 after: CrawlerScore,
340 isNewEver: boolean,
341): Promise<void> {
342 const plain = fallbackToast(a)
343 const now = await $.clock.now()
344 if (isQuipBusy || now - quipAt < QUIP_GAP_MS) return $.ui.toast(plain, { timeoutMs: TOAST_MS })
345 isQuipBusy = true
346 quipAt = now
347 try {
348 const r = await $.model.complete({
349 model: QUIP_MODEL,
350 system: QUIP_SYSTEM,
351 prompt: quipPrompt(a, after) + (isNewEver ? '\nThis achievement is a first, ever.' : ''),
352 maxTokens: 80,
353 effort: 'low',
354 timeoutMs: 8000,
355 })
356 const quip = r.isAnswered ? cleanQuip(r.text) : undefined
357 const points = `(${a.points >= 0 ? '+' : '−'}${Math.abs(a.points)} CP)`
358 $.ui.toast(quip ? `${quip} ${points}` : plain, { timeoutMs: quip ? QUIP_TOAST_MS : TOAST_MS })
359 } catch {
360 $.ui.toast(plain, { timeoutMs: TOAST_MS }) // the model call failed or timed out: the award still shows
361 } finally {
362 isQuipBusy = false
363 }
364}
365
366/** The repository a Branch Guard step runs in, or undefined outside one (or where the directory is unknown). */
367async function repoAt($: EngineInterface, dir: string): Promise<Repo | undefined> {
368 if (/[$`]/.test(dir)) return undefined
369 const home = dir.startsWith('~') ? (await $.process.run(['sh', '-c', 'printf %s "$HOME"'])).stdout : ''
370 const at = home ? home + dir.slice(1) : dir
371 const git = (...args: string[]) => $.process.run(['git', ...(at ? ['-C', at] : []), ...args])
372 const current = await git('branch', '--show-current')
373 if (current.exitCode !== 0) return undefined
374 // An unborn branch (no commits yet) takes its first commit wherever it must, but keeps its name for a push.
375 const isUnborn = (await git('rev-parse', '--verify', '-q', 'HEAD')).exitCode !== 0
376 const head = (await git('symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD')).stdout.trim()
377 return {
378 branch: current.stdout.trim() || undefined,
379 defaultBranch: head ? head.slice(head.indexOf('/') + 1) : undefined,
380 isUnborn,
381 }
382}
383
384/** A Check of `key` finished: the Floor Boss it summons, hurts or heals gets a toast, and one it slays an Award. */
385async function fight($: EngineInterface, key: string, isGreen: boolean, output: string): Promise<void> {
386 const r = onBossCheck(await read($, bosses), key, isGreen, isGreen ? undefined : failingCount(output))
387 await update($, bosses, () => r.bosses)
388 if (!r.event) return
389 if (r.event.kind === 'slain') await award($, slayAward(r.event.boss))
390 else $.ui.toast(bossToast(r.event), { timeoutMs: TOAST_MS })
391}
392
393async function award($: EngineInterface, a: Award): Promise<void> {
394 if (turn) turn = { ...turn, points: turn.points + a.points, events: [...turn.events, a.event] }
395 const after = await update($, score, s => apply(s, a))
396 const total = await update($, allTime, t => t + a.points)
397 await $.store.set(STORE_ALL_TIME, total)
398 let isNewEver = false
399 if (a.achievement) {
400 const unlocked = ((await $.store.get(STORE_UNLOCKED)) as string[] | undefined) ?? []
401 isNewEver = !unlocked.includes(a.achievement)
402 if (isNewEver) await $.store.set(STORE_UNLOCKED, [...unlocked, a.achievement])
403 }
404 await showScore($)
405 // Off the caller's dispatch: a tool call never waits on the quip.
406 $.clock.after(0, () => void announce($, a, after, isNewEver).catch(() => undefined))
407}
408
409async function startPoints($: EngineInterface): Promise<void> {
410 const stored = Number((await $.store.get(STORE_ALL_TIME)) ?? 0)
411 await update($, allTime, () => (Number.isFinite(stored) ? stored : 0))
412 hasChecked = false
413 const u = await $.session.usage()
414 usage = { contextPercent: u.context.percent, usd: u.cost?.usd, rateLimits: u.rateLimits }
415 await showScore($)
416}
417
418// ------------------------------------------------------------------------------------------- the voice
419
420/** The Verdict for a Notable Turn: a model-written line when the quip writer is free, the plain one otherwise. */
421async function verdict($: EngineInterface, stats: TurnStats): Promise<string> {
422 if (isQuipBusy) return verdictLine(fallbackVerdict(stats), stats, false)
423 isQuipBusy = true
424 quipAt = await $.clock.now()
425 try {
426 const r = await $.model.complete({
427 model: QUIP_MODEL,
428 system: VERDICT_SYSTEM,
429 prompt: verdictPrompt(stats),
430 maxTokens: 80,
431 effort: 'low',
432 timeoutMs: VERDICT_TIMEOUT_MS,
433 })
434 const line = r.isAnswered ? cleanQuip(r.text) : undefined
435 return line ? verdictLine(line, stats, true) : verdictLine(fallbackVerdict(stats), stats, false)
436 } catch {
437 return verdictLine(fallbackVerdict(stats), stats, false)
438 } finally {
439 isQuipBusy = false
440 }
441}
442
443// ------------------------------------------------------------------------------------------------ hooks
444
445export const register: Register = on => {
446 on('session.start', async ($, e, next) => {
447 await startPoints($)
448 await startBoard($)
449 await $.command.register({ name: 'tdd', description: 'Show or hide the TDD Band above the prompt' })
450 return next(e)
451 })
452
453 // ------------------------------------------------------------------------------------- Podman Guard
454
455 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
456 const g = guard(e.command)
457 if (g.kind === 'pass') return next(e)
458 if (g.kind === 'deny') return { deny: g.reason }
459 const ran = await next({ ...e, command: g.command })
460 if (ran.deny !== undefined) return ran
461 return { ...ran, context: [...(ran.context ?? []), rewriteNote(e.command, g.command)] }
462 }).catch(($, e, next) => next(e)) // a broken guard fails open: the line runs as written
463
464 // ------------------------------------------------------------------------------------- Branch Guard
465
466 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
467 const steps = gitSteps(e.command)
468 if (steps.length === 0) return next(e)
469 const repos = new Map<string, Repo | undefined>()
470 for (const dir of new Set(steps.map(s => s.dir))) repos.set(dir, await repoAt($, dir))
471 const g = branchGuard(e.command, dir => repos.get(dir))
472 return g.kind === 'deny' ? { deny: g.reason } : next(e)
473 }).catch(($, e, next) => next(e)) // a broken guard fails open: the line runs as written
474
475 // ----------------------------------------------------------------------------------------------- HUD
476
477 on('session.measure', async ($, e, next) => {
478 usage = { contextPercent: e.context.percent, usd: e.cost?.usd, rateLimits: e.rateLimits }
479 await showScore($)
480 return next(e)
481 })
482
483 on('session.compact', async ($, e, next) => {
484 const r = await next(e)
485 if (e.agentId || r.skip !== undefined) return r
486 const a = compactAward(e.trigger, usage?.contextPercent)
487 if (a) await award($, a)
488 return r
489 }).catch(($, e, next) => next(e))
490
491 // ------------------------------------------------------------------------------------------ TDD Band
492
493 on('agent.spawn', async ($, e, next) => {
494 await update($, tdd, t => onAgent(t, e.subagentType))
495 return next(e)
496 }).catch(($, e, next) => next(e))
497
498 on('command.run', { command: 'tdd' }, async $ => {
499 const t = await update($, tdd, toggle)
500 return { text: `TDD Band ${isShown(t) ? 'shown' : 'hidden'}.` }
501 })
502
503 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
504 if (e.props.hasSurvey) return next(e)
505 const t = await read($, tdd)
506 const fight = newestBoss(await read($, bosses))
507 if (!isShown(t) && !fight) return next(e)
508 const { Box, Button, Text } = $.ui.resolve(e)
509 const v = verdictOf(t)
510 const bossRow = fight && (
511 <Box key="boss" gap={1}>
512 <Text bold color="red">
513 ☠ FLOOR BOSS
514 </Text>
515 <Text bold>{fight.boss.name}</Text>
516 <Text color="red">{hpBar(fight.boss)}</Text>
517 <Text dimColor wrap="truncate-end">
518 {fight.key}
519 {fight.others > 0 ? ` · +${fight.others} more` : ''}
520 </Text>
521 </Box>
522 )
523 if (!isShown(t)) return bossRow ?? next(e)
524 const tddRow = (
525 <Box key="tdd" gap={1}>
526 <Text bold color={TDD_COLOR[t.phase ?? 'NONE']}>
527 {t.phase ? `${TDD_ICON[t.phase]} ${t.phase}` : '· TDD'}
528 </Text>
529 <Text color={MOOD_COLOR[v.mood]} wrap="truncate-end">
530 {v.note}
531 </Text>
532 {DRAFTS.map(d => (
533 <Button
534 key={d.hotkey}
535 hotkey={d.hotkey}
536 label={d.label}
537 plain
538 onPress={() => void $.prompt.fill({ text: d.draft }).catch(() => undefined)}
539 />
540 ))}
541 </Box>
542 )
543 return bossRow ? (
544 <Box flexDirection="column">
545 {bossRow}
546 {tddRow}
547 </Box>
548 ) : (
549 tddRow
550 )
551 })
552
553 // ------------------------------------------------------------------------------------------ the voice
554
555 on('turn.start', async ($, e, next) => {
556 turn = { toolCalls: 0, points: 0, events: [] }
557 const mood = moodOf({
558 contextPercent: usage?.contextPercent,
559 debuff: (await read($, score)).debuff,
560 isDispatching: (watch?.running.size ?? 0) > 0,
561 })
562 spinner = spinnerWord(mood, Math.random())
563 return next(e)
564 })
565
566 on('ui.render', { component: 'Spinner' }, async ($, e, next) =>
567 e.surface === 'terminal' && spinner ? next({ ...e, props: { ...e.props, word: spinner } }) : next(e),
568 )
569
570 on('turn.complete', async ($, e, next) => {
571 const r = await next(e)
572 const stats = turn && { ...turn, durationMs: e.durationMs }
573 if (e.agentId) return r
574 turn = undefined
575 spinner = undefined
576 if (!stats || e.reason !== 'answer' || !isNotable(stats)) return r
577 return { ...r, text: await verdict($, stats) }
578 })
579
580 on('command.run', { command: 'dispatches' }, async $ => {
581 if (!watch) return { text: 'Dispatch Board: not inside a git repository.' }
582 await tick($, true)
583 await $.ui.open({ id: PANE, title: TITLE })
584 return { text: 'Dispatch Board opened.' }
585 })
586
587 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
588 const { Box, Text } = $.ui.resolve(e)
589 const state = await read($, board)
590 if (!state) return <Text dimColor>No .hybrid/ in this repo: run pi-dispatch.py init first.</Text>
591 const width = Math.max(30, (e.props.bodyColumns ?? e.viewport?.columns ?? 80) - 2)
592 const count = (phase: BoardRowState['phase']) => state.rows.filter(r => r.phase === phase).length
593 return (
594 <Box flexDirection="column">
595 <Text bold wrap="truncate-end">
596 {state.branch} · {count('running')} running · {count('awaiting')} awaiting Gate
597 </Text>
598 {state.rows.length === 0 && <Text dimColor>No Dispatches on this run branch yet.</Text>}
599 {state.rows.map(row => (
600 <Box key={`d${row.n}`} flexDirection="column">
601 <Text wrap="truncate-end">
602 <Text color={PHASE[row.phase].color}>{PHASE[row.phase].label}</Text>
603 {` #${String(row.n).padStart(2, '0')} ${row.ticket} ${stats(row, state.ctxLimit)}`}
604 </Text>
605 {row.detail !== '' && (
606 <Text dimColor wrap="truncate-end">
607 {` ↳ ${tail(row.detail, width - 4)}`}
608 </Text>
609 )}
610 </Box>
611 ))}
612 </Box>
613 )
614 })
615
616 on('tool.call', async ($, e, next) => {
617 if (turn && !e.agentId) turn = { ...turn, toolCalls: turn.toolCalls + 1 }
618 const ran = await next(e)
619 const isDenied = ran.deny !== undefined
620 if (e.tool === 'Bash') {
621 const result = ran.result as { backgroundTaskId?: string; stdout?: string; stderr?: string } | undefined
622 if (!isDenied && result?.backgroundTaskId) return ran // still running: nothing to score yet
623 if (isDenied) {
624 await award($, errorAward('Bash', true))
625 return ran
626 }
627 let checked: boolean | undefined
628 for (const a of bashAwards(e.command, ran.isError === true, await read($, score), !hasChecked)) {
629 if (a.isGreen !== undefined) {
630 hasChecked = true
631 const isGreen = a.isGreen
632 checked = isGreen
633 await update($, tdd, t => onCheck(t, isGreen))
634 }
635 await award($, a)
636 }
637 const key = checkKey(e.command)
638 if (key && checked !== undefined) await fight($, key, checked, `${result?.stdout ?? ''}\n${result?.stderr ?? ''}`)
639 return ran
640 }
641 if (isDenied || ran.isError === true) await award($, errorAward(String(e.tool), isDenied))
642 return ran
643 })
644}
645mods/board.ts 190 lines1// The Dispatch Board's pure half: from the text of pi-implementer's files under .hybrid/ to the rows the pane
2// draws. No `$` here, so every rule is testable without an engine.
3
4export type Phase = 'running' | 'awaiting' | 'landed' | 'dropped' | 'conflict' | 'gone'
5
6export type Ended = 'finished' | 'timeout' | 'turn cap' | 'error'
7
8export type LiveCounts = {
9 calls: number
10 tools: number
11 ctx: number
12 lastTool: string
13 lastText: string
14}
15
16export type BoardRow = {
17 n: number
18 ticket: string
19 phase: Phase
20 ended?: Ended
21 wallS: number
22 calls: number
23 maxTurns?: number
24 tools: number
25 ctx: number
26 lastTool: string
27 detail: string
28}
29
30/** `.hybrid/running/NN.json`, written by `pi-dispatch.py dispatch` while pi runs. */
31export type RunningMeta = {
32 n: number
33 ticket: string
34 max_turns: number
35 started: string
36 log: string
37 run_branch: string
38}
39
40/** A `.hybrid/dispatches.jsonl` row: the fields the board reads. */
41export type DispatchRow = {
42 n: number
43 ticket: string
44 max_turns: number
45 ended: Ended
46 run_branch: string
47 worktree: string
48 wall_s: number
49 turns: number
50 tool_calls: number
51 ctx_max: number
52 result: string
53}
54
55export type Outcome = { n: number; outcome: 'landed' | 'dropped' | 'conflict' }
56
57export const emptyCounts = (): LiveCounts => ({ calls: 0, tools: 0, ctx: 0, lastTool: '', lastText: '' })
58
59/** Every line of a JSONL text that parses as an object; torn or foreign lines are skipped. */
60export const parseJsonl = <T>(text: string): T[] =>
61 text.split('\n').flatMap(line => {
62 if (line.trim() === '') return []
63 try {
64 const value: unknown = JSON.parse(line)
65 return value !== null && typeof value === 'object' ? [value as T] : []
66 } catch {
67 return []
68 }
69 })
70
71type PiEvent = {
72 type?: string
73 toolName?: string
74 message?: {
75 role?: string
76 usage?: { input?: number; cacheRead?: number; output?: number }
77 content?: { type?: string; text?: string }[]
78 }
79}
80
81/**
82 * Folds pi's `--mode json` events into the running counts, the way pi-dispatch.py's run_pi counts them: a
83 * call is an assistant `message_end`, a tool is a `tool_execution_start`, ctx is the largest
84 * input + cacheRead + output seen.
85 */
86export const foldPiEvents = (counts: LiveCounts, events: readonly PiEvent[]): LiveCounts =>
87 events.reduce((c, e) => {
88 if (e.type === 'tool_execution_start') {
89 return { ...c, tools: c.tools + 1, lastTool: e.toolName ?? c.lastTool }
90 }
91 if (e.type !== 'message_end' || e.message?.role !== 'assistant') return c
92 const u = e.message.usage ?? {}
93 const text = (e.message.content ?? [])
94 .filter(b => b.type === 'text' && typeof b.text === 'string')
95 .map(b => b.text)
96 .join(' ')
97 .trim()
98 return {
99 ...c,
100 calls: c.calls + 1,
101 ctx: Math.max(c.ctx, (u.input ?? 0) + (u.cacheRead ?? 0) + (u.output ?? 0)),
102 lastText: text || c.lastText,
103 }
104 }, counts)
105
106/**
107 * Splits newly read log text at its last newline: the complete lines to fold now, and how many bytes they
108 * span (so the reader's offset only ever moves past whole lines; a torn tail is read again next time).
109 */
110export const completeLines = (chunk: string): { text: string; bytes: number } => {
111 const cut = chunk.lastIndexOf('\n')
112 const text = cut < 0 ? '' : chunk.slice(0, cut + 1)
113 return { text, bytes: new TextEncoder().encode(text).length }
114}
115
116/** The last `width` characters of a text's last non-empty line, on one line. */
117export const tail = (text: string, width: number): string => {
118 const line =
119 text
120 .split('\n')
121 .map(l => l.trim())
122 .filter(Boolean)
123 .at(-1) ?? ''
124 return line.length > width ? `…${line.slice(-(width - 1))}` : line
125}
126
127export type BoardInput = {
128 branch: string
129 running: readonly { meta: RunningMeta; counts: LiveCounts; nowMs: number }[]
130 finished: readonly DispatchRow[]
131 outcomes: readonly Outcome[]
132 worktreesPresent: ReadonlySet<string>
133}
134
135/**
136 * The board's rows, newest first: every running Dispatch, then every finished one of the current run
137 * branch. A finished Dispatch's phase is its last Outcome; with none, `awaiting` while its Worktree is on
138 * disk and `gone` once it is not.
139 */
140export const boardRows = (input: BoardInput): BoardRow[] => {
141 const fate = new Map(input.outcomes.map(o => [o.n, o.outcome]))
142 const running = input.running
143 .filter(({ meta }) => meta.run_branch === input.branch)
144 .map(({ meta, counts, nowMs }): BoardRow => ({
145 n: meta.n,
146 ticket: meta.ticket,
147 phase: 'running',
148 wallS: Math.max(0, Math.round((nowMs - Date.parse(meta.started)) / 1000)),
149 calls: counts.calls,
150 maxTurns: meta.max_turns,
151 tools: counts.tools,
152 ctx: counts.ctx,
153 lastTool: counts.lastTool,
154 detail: counts.lastText,
155 }))
156 const live = new Set(running.map(r => r.n))
157 const finished = input.finished
158 .filter(r => r.run_branch === input.branch && !live.has(r.n))
159 .map((r): BoardRow => ({
160 n: r.n,
161 ticket: r.ticket,
162 phase: fate.get(r.n) ?? (input.worktreesPresent.has(r.worktree) ? 'awaiting' : 'gone'),
163 ended: r.ended,
164 wallS: Math.round(r.wall_s),
165 calls: r.turns,
166 maxTurns: r.max_turns,
167 tools: r.tool_calls,
168 ctx: r.ctx_max,
169 lastTool: '',
170 detail: r.result,
171 }))
172 return [...running, ...finished].sort((a, b) => b.n - a.n)
173}
174
175export const duration = (s: number): string =>
176 s < 60
177 ? `${s}s`
178 : s < 3600
179 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
180 : `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
181
182export const kilo = (n: number): string =>
183 n < 1000 ? String(n) : `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}K`
184
185/** Reads `CTX_SIZE` out of pi-implementer's env file text (KEY=value lines, optional quotes / export). */
186export const ctxSizeOf = (envText: string): number | undefined => {
187 const m = envText.match(/^\s*(?:export\s+)?CTX_SIZE\s*=\s*["']?(\d+)/m)
188 return m ? Number(m[1]) : undefined
189}
190mods/points.ts 225 lines1// Crawler Points' pure half: which Bash commands and Dispatch fates score, for how much, and what the HUD
2// says. No `$` here.
3
4export type Score = {
5 session: number
6 streak: number
7 debuff?: string
8}
9
10export type Award = {
11 /** Points added (negative for a penalty). */
12 points: number
13 /** What happened, for the quip writer and the fallback toast. */
14 event: string
15 /** Set when the award also unlocks a named achievement. */
16 achievement?: string
17 /** true: a green test/build (extends the streak); false: a red one (breaks it); absent: neither. */
18 isGreen?: boolean
19 debuff?: string
20}
21
22export type CommandKind = 'commit' | 'pr' | 'force-push' | 'check' | 'other'
23
24const FORCE_PUSH = /\bgit\s+push\b[^|;&]*\s(?:-f\b|--force\b|--force-with-lease\b)/
25const COMMIT = /\bgit\s+commit\b/
26const PR = /\bgh\s+pr\s+create\b/
27const CHECK =
28 /\b(?:go\s+(?:test|build|vet)|pytest|cargo\s+(?:test|build|check|clippy)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|typecheck|lint)|tsc|make|nix\s+(?:build|flake\s+check)|golangci-lint|shellcheck|claude\s+plugin\s+(?:test|validate))\b/
29
30/** The command with its quoted strings blanked, so a commit message or an echo never reads as a command. */
31const unquoted = (command: string): string => command.replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, "''")
32
33/** The commands a shell line chains with `;`, `&&`, `||` or newlines; a pipeline stays one. */
34export const segments = (command: string): string[] =>
35 unquoted(command)
36 .split(/&&|\|\||;|\n/)
37 .map(c => c.trim())
38 .filter(Boolean)
39
40/** What a Bash command is, for scoring. A force push wins over everything else in the same command. */
41export const classify = (raw: string): CommandKind => {
42 const command = unquoted(raw)
43 return FORCE_PUSH.test(command)
44 ? 'force-push'
45 : PR.test(command)
46 ? 'pr'
47 : COMMIT.test(command)
48 ? 'commit'
49 : CHECK.test(command)
50 ? 'check'
51 : 'other'
52}
53
54export const STREAK_MILESTONES = [5, 10, 25] as const
55
56/**
57 * The awards for a finished foreground Bash call, one per kind its chained commands hold (none when nothing
58 * scores). The exit status is the whole line's: a failed line costs its check (the likeliest culprit) and
59 * any force push, and earns no milestone, since which command failed is unknown. `isFirstCheck` is true for
60 * the session's first test/build run.
61 */
62export const bashAwards = (
63 command: string,
64 isError: boolean,
65 score: Score,
66 isFirstCheck: boolean,
67): Award[] => {
68 const kinds = new Set(segments(command).map(classify))
69 const order: CommandKind[] = isError ? ['force-push', 'check'] : ['force-push', 'pr', 'commit', 'check']
70 return order
71 .filter(k => kinds.has(k))
72 .map(k => kindAward(k, isError, score, isFirstCheck))
73 .filter((a): a is Award => a !== undefined)
74}
75
76const kindAward = (
77 kind: CommandKind,
78 isError: boolean,
79 score: Score,
80 isFirstCheck: boolean,
81): Award | undefined => {
82 if (kind === 'force-push') {
83 return { points: -200, event: 'force-pushed over shared history', debuff: 'Shame' }
84 }
85 if (kind === 'pr') {
86 return isError ? undefined : { points: 150, event: 'opened a pull request', achievement: 'Floor Cleared' }
87 }
88 if (kind === 'commit') {
89 return isError ? undefined : { points: 25, event: 'made a git commit' }
90 }
91 if (kind === 'check') {
92 if (isError) {
93 return { points: -15, event: 'ran a test/build that failed', isGreen: false, debuff: 'Red Build' }
94 }
95 const streak = score.streak + 1
96 const isMilestone = (STREAK_MILESTONES as readonly number[]).includes(streak)
97 return {
98 points: 10 + (isMilestone ? streak * 5 : 0),
99 event: isMilestone ? `hit a ${streak}-green streak` : 'ran a test/build that passed',
100 isGreen: true,
101 achievement: isFirstCheck
102 ? 'Compiled On The First Try'
103 : isMilestone
104 ? `${streak} Greens In A Row`
105 : undefined,
106 }
107 }
108 return undefined
109}
110
111/** A tool call that errored or was denied, outside the commands bashAwards scores. */
112export const errorAward = (tool: string, isDenied: boolean): Award => ({
113 points: -5,
114 event: isDenied ? `had a ${tool} call denied` : `had a ${tool} call error out`,
115 debuff: 'Hubris',
116})
117
118/** A Dispatch fate the Dispatch Board noticed after it started watching: a new Outcome, or a capped end. */
119export type Fate = { fate: 'landed' | 'dropped' | 'conflict' | 'timeout' | 'turn cap'; ticket: string }
120
121/** The award for a Dispatch's Outcome or end, from pi-implementer's files. */
122export const dispatchAward = (fate: Fate['fate'], ticket: string): Award =>
123 ({
124 landed: { points: 100, event: `landed the local model's Ticket ${ticket}`, achievement: 'Loot Secured' },
125 dropped: { points: -50, event: `dropped the local model's Ticket ${ticket}` },
126 conflict: { points: -75, event: `hit a cherry-pick conflict landing ${ticket}`, debuff: 'Merge Hell' },
127 timeout: { points: -25, event: `let the local model time out on ${ticket}` },
128 'turn cap': { points: -25, event: `let the local model hit its turn cap on ${ticket}` },
129 })[fate]
130
131/** The score after an award: points add, a green extends the streak and clears the debuff, a red resets it. */
132export const apply = (score: Score, award: Award): Score => ({
133 session: score.session + award.points,
134 streak: award.isGreen === true ? score.streak + 1 : award.isGreen === false ? 0 : score.streak,
135 debuff: award.debuff ?? (award.isGreen === true ? undefined : score.debuff),
136})
137
138/** Level from all-time points: 1 below 100, then one more at each square of ten (100, 400, 900, ...). */
139export const level = (allTime: number): number => Math.floor(Math.sqrt(Math.max(0, allTime) / 100)) + 1
140
141/** What the HUD shows of the session's usage, as `session.measure` and `$.session.usage()` report it. */
142export type Usage = {
143 /** How full the context window is, 0 to 100; absent before the first response. */
144 contextPercent?: number
145 usd?: number
146 rateLimits: readonly { kind: string; percentUsed: number }[]
147}
148
149/** A rate-limit window is shown from this much used, and only the fullest one. */
150export const RATE_LIMIT_SHOWN_FROM = 50
151
152const WINDOW_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
153
154/** The fullest rate-limit window once it reaches RATE_LIMIT_SHOWN_FROM, as the HUD names it. */
155export const hottestLimit = (rateLimits: Usage['rateLimits']): string | undefined => {
156 const hot = rateLimits
157 .filter(l => l.percentUsed >= RATE_LIMIT_SHOWN_FROM)
158 .reduce<Usage['rateLimits'][number] | undefined>((a, l) => (!a || l.percentUsed > a.percentUsed ? l : a), undefined)
159 return hot && `${WINDOW_NAMES[hot.kind] ?? hot.kind} ${Math.round(hot.percentUsed)}%`
160}
161
162export const statusLine = (score: Score, allTime: number, usage?: Usage): string =>
163 [
164 `🎟 ${score.session.toLocaleString('en-US')} CP`,
165 `Lv ${level(allTime)}`,
166 score.streak > 0 ? `🔥${score.streak}` : undefined,
167 score.debuff ? `Debuff: ${score.debuff}` : undefined,
168 usage?.contextPercent !== undefined ? `ctx ${Math.round(usage.contextPercent)}%` : undefined,
169 usage ? hottestLimit(usage.rateLimits) : undefined,
170 usage?.usd !== undefined ? `$${usage.usd.toFixed(2)}` : undefined,
171 ]
172 .filter(Boolean)
173 .join(' · ')
174
175/** A manual compaction counts as a Strategic Retreat below this much context. */
176export const RETREAT_BELOW = 90
177
178/**
179 * The award for a compaction of the main conversation: a Dungeon Collapse (automatic) costs, a Strategic Retreat
180 * (manual, below RETREAT_BELOW) earns, and anything else, or a manual one whose fill is unknown, scores nothing.
181 */
182export const compactAward = (trigger: string, contextPercent: number | undefined): Award | undefined =>
183 trigger === 'auto'
184 ? { points: -50, event: 'let the dungeon collapse: Claude Code compacted the context itself', debuff: 'Amnesia' }
185 : trigger === 'manual' && contextPercent !== undefined && contextPercent < RETREAT_BELOW
186 ? {
187 points: 20,
188 event: `compacted at ${Math.round(contextPercent)}% context, before the ceiling came down`,
189 achievement: 'Strategic Retreat',
190 }
191 : undefined
192
193const signed = (n: number): string => (n >= 0 ? `+${n}` : `−${-n}`)
194
195/** The toast shown while no model quip is available (throttled, failed, or still on its way). */
196export const fallbackToast = (award: Award): string =>
197 award.achievement
198 ? `*Ding!* Achievement Unlocked: ${award.achievement} (${signed(award.points)} CP)`
199 : `*Ding!* ${signed(award.points)} CP: the Crawler ${award.event}.`
200
201/** The prompt that asks the model for a one-line quip about an award. */
202export const quipPrompt = (award: Award, score: Score): string =>
203 [
204 `Event: the Crawler ${award.event}.`,
205 `Points: ${signed(award.points)} (session total ${score.session}, green streak ${score.streak}).`,
206 award.achievement ? `Achievement unlocked: "${award.achievement}".` : '',
207 award.debuff ? `Debuff applied: ${award.debuff}.` : '',
208 'Write the toast.',
209 ]
210 .filter(Boolean)
211 .join('\n')
212
213export const QUIP_SYSTEM =
214 "You are the System AI from Dungeon Crawler Carl: a sardonic game-show host narrating a programmer (the Crawler) for a galactic audience. Write ONE line, at most 110 characters, that starts with '*Ding!*' and reacts to the event: cruel, theatrical, funny, no emoji, no quotes around it. Praise only backhanded."
215
216/** A model reply cut down to one toast-sized line, or undefined when nothing usable came back. */
217export const cleanQuip = (text: string): string | undefined => {
218 const line = text
219 .split('\n')
220 .map(l => l.trim().replace(/^["'“]|["'”]$/g, ''))
221 .find(Boolean)
222 if (!line) return undefined
223 return line.length > 140 ? `${line.slice(0, 139)}…` : line
224}
225mods/boss.ts 172 lines1// The Floor Boss's pure half: which Check command a line is, how many tests its output says failed, and the
2// bosses that red runs summon and green ones slay. No `$` here.
3
4import { classify, type Award } from './points'
5import { words, type Word } from './shell'
6
7/** A Floor Boss appears on this many red runs in a row of one Check command. */
8export const SUMMON_AT = 3
9
10export const BOSS_NAMES = [
11 'The Flaky Assertion, Devourer of CI',
12 'Null Pointer Prime',
13 'The Off-By-One Twins',
14 'Grand Regent of Race Conditions',
15 'The Undefined Behemoth',
16 'Lord Segfault the Unflushed',
17 'The Mocking Hydra',
18 'Heisenbug, Who Vanishes When Watched',
19 'The Timeout Lich',
20 'Deadlock, Warden of the Mutex',
21 'The Stale Cache Wyrm',
22 'Queen Regression the Returning',
23 'The Snapshot Mimic',
24 'Captain Import Cycle',
25 'The Floating-Point Phantom',
26 'Brother Unhandled Rejection',
27 'The Leaky Abstraction Ooze',
28 'Typo, Bane of Compilers',
29 'The Dependency Hell Hound',
30 'The Memory-Leak Mantaur',
31] as const
32
33/** `order`: when it appeared, counting from 1 in the session, so the newest one leads the band. */
34export type Boss = { name: string; hp: number; maxHp: number; order: number }
35/** One Check command's run of reds since its last green, and the boss they summoned. */
36export type Foe = { reds: number; boss?: Boss }
37export type Bosses = { foes: Record<string, Foe>; summoned: number }
38
39export const initialBosses: Bosses = { foes: {}, summoned: 0 }
40
41export type BossEvent =
42 | { kind: 'spawned'; boss: Boss }
43 | { kind: 'damaged'; boss: Boss; by: number }
44 | { kind: 'healed'; boss: Boss; by: number }
45 | { kind: 'slain'; boss: Boss }
46
47/** A redirection word: `>`, `2>`, `&>` alone take the next word as their target; `2>/dev/null` holds its own. */
48const REDIRECT = /^(?:\d*|&)[<>]/
49const BARE_REDIRECT = /^(?:\d*|&)[<>]+&?$/
50
51/** A simple command's words as written, without its redirections. */
52const cleaned = (line: string, group: readonly Word[]): string => {
53 const kept: string[] = []
54 for (let i = 0; i < group.length; i++) {
55 const w = group[i] as Word
56 if (BARE_REDIRECT.test(w.text)) i++
57 else if (!REDIRECT.test(w.text)) kept.push(line.slice(w.start, w.end))
58 }
59 return kept.join(' ')
60}
61
62/**
63 * The foe a Bash line fights: its Check commands and the `cd`s before them, without what their output is piped
64 * through or redirected to; undefined when the line runs no Check.
65 */
66export const checkKey = (line: string): string | undefined => {
67 const groups: Word[][] = []
68 for (const w of words(line)) {
69 if (w.startsCommand || groups.length === 0) groups.push([])
70 ;(groups[groups.length - 1] as Word[]).push(w)
71 }
72 const parts = groups.map(g => cleaned(line, g)).filter(Boolean)
73 const kinds = parts.map(p => (p.startsWith('cd ') || p === 'cd' ? 'cd' : classify(p)))
74 const last = kinds.lastIndexOf('check')
75 if (last < 0) return undefined
76 return parts.filter((_, i) => i <= last && (kinds[i] === 'check' || kinds[i] === 'cd')).join(' && ')
77}
78
79const sum = (xs: readonly number[]): number => xs.reduce((a, b) => a + b, 0)
80
81/** How many tests a red Check's output says failed, by the runners' own summaries; undefined when none says. */
82export const failingCount = (output: string): number | undefined => {
83 const jest = /^\s*Tests:?\s+(\d+) failed/m.exec(output) // jest, vitest
84 if (jest) return Number(jest[1])
85 const goFails = output.match(/^--- FAIL:/gm) // go test: top-level tests only, not subtests
86 if (goFails) return goFails.length
87 const cargo = [...output.matchAll(/^test result: FAILED\..*?(\d+) failed/gm)]
88 if (cargo.length > 0) return sum(cargo.map(m => Number(m[1])))
89 const pytest = /\b(\d+) failed\b[^\n]* in [\d.]+s\b/.exec(output)
90 if (pytest) return Number(pytest[1])
91 const bun = /^\s*(\d+) fail$/m.exec(output) // bun test, claude plugin test
92 if (bun) return Number(bun[1])
93 return undefined
94}
95
96const hash = (s: string): number => {
97 let h = 0
98 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
99 return h
100}
101
102/** The name a command's boss takes: one from its hash, stepping past names living bosses already hold. */
103const bossName = (key: string, taken: ReadonlySet<string>): string => {
104 const start = hash(key) % BOSS_NAMES.length
105 for (let i = 0; i < BOSS_NAMES.length; i++) {
106 const name = BOSS_NAMES[(start + i) % BOSS_NAMES.length] as string
107 if (!taken.has(name)) return name
108 }
109 return BOSS_NAMES[start] as string
110}
111
112/**
113 * A Check of the command `key` finished. A green one ends its foe (slaying any boss); a red one extends the run
114 * of reds, summons a boss on the SUMMON_AT-th, and sets a living boss's HP to `failing` when the output said.
115 */
116export const onBossCheck = (
117 s: Bosses,
118 key: string,
119 isGreen: boolean,
120 failing?: number,
121): { bosses: Bosses; event?: BossEvent } => {
122 const foe = s.foes[key]
123 if (isGreen) {
124 const { [key]: _, ...foes } = s.foes
125 return { bosses: { ...s, foes }, event: foe?.boss && { kind: 'slain', boss: foe.boss } }
126 }
127 const reds = (foe?.reds ?? 0) + 1
128 const was = foe?.boss
129 if (was) {
130 const hp = failing ?? was.hp
131 const boss = { ...was, hp, maxHp: Math.max(was.maxHp, hp) }
132 const bosses = { ...s, foes: { ...s.foes, [key]: { reds, boss } } }
133 const event: BossEvent | undefined =
134 hp < was.hp ? { kind: 'damaged', boss, by: was.hp - hp } : hp > was.hp ? { kind: 'healed', boss, by: hp - was.hp } : undefined
135 return { bosses, event }
136 }
137 if (reds < SUMMON_AT) return { bosses: { ...s, foes: { ...s.foes, [key]: { reds } } } }
138 const taken = new Set(Object.values(s.foes).flatMap(f => (f.boss ? [f.boss.name] : [])))
139 const hp = failing ?? 1
140 const boss = { name: bossName(key, taken), hp, maxHp: hp, order: s.summoned + 1 }
141 return {
142 bosses: { foes: { ...s.foes, [key]: { reds, boss } }, summoned: boss.order },
143 event: { kind: 'spawned', boss },
144 }
145}
146
147/** The boss the band shows, the newest living one, and how many more are alive. */
148export const newestBoss = (s: Bosses): { key: string; boss: Boss; others: number } | undefined => {
149 const living = Object.entries(s.foes).flatMap(([key, f]) => (f.boss ? [{ key, boss: f.boss }] : []))
150 if (living.length === 0) return undefined
151 const newest = living.reduce((a, b) => (b.boss.order > a.boss.order ? b : a))
152 return { ...newest, others: living.length - 1 }
153}
154
155export const slayAward = (boss: Boss): Award => ({
156 points: 100,
157 event: `slew the Floor Boss ${boss.name}`,
158 achievement: `Slew ${boss.name}`,
159})
160
161/** The HP bar the band draws: one heart per point up to ten, then a count. */
162export const hpBar = (b: Boss): string => (b.maxHp <= 10 ? '♥'.repeat(b.hp) + '♡'.repeat(b.maxHp - b.hp) : `HP ${b.hp}/${b.maxHp}`)
163
164/** The toast for a boss event, in the System's voice. */
165export const bossToast = (e: BossEvent): string =>
166 ({
167 spawned: `*Ding!* A Floor Boss appears: ${e.boss.name} (HP ${e.boss.hp}). The audience is on its feet.`,
168 damaged: `*Ding!* ${e.boss.name} takes ${'by' in e ? e.by : 0} damage. HP ${e.boss.hp}/${e.boss.maxHp}.`,
169 healed: `*Ding!* ${e.boss.name} regenerates ${'by' in e ? e.by : 0} HP. The Crawler made it stronger.`,
170 slain: `*Ding!* ${e.boss.name} has been slain!`,
171 })[e.kind]
172mods/branch.ts 142 lines1// The Branch Guard's pure half: which git commits and pushes a Bash line makes, where they run, and which ones
2// land on a Protected Branch. No `$` here.
3
4import { argsOf, hasAssignment, words, type Word } from './shell'
5
6export type Judged = { kind: 'pass' } | { kind: 'deny'; reason: string }
7
8/** The Branch Escape Hatch: an assignment of it anywhere in the line leaves the whole line alone. */
9export const BRANCH_HATCH = 'BRANCH_OK=1'
10
11/**
12 * What the guard needs of a repository: its checked-out branch (absent when detached), its default branch, and
13 * whether that branch is unborn (no commits yet), when its first commit may land wherever it must.
14 */
15export type Repo = { branch?: string; defaultBranch?: string; isUnborn?: boolean }
16
17export const protectedBranches = (repo: Repo): Set<string> =>
18 new Set(['main', 'master', ...(repo.defaultBranch ? [repo.defaultBranch] : [])])
19
20/**
21 * A git commit or push the line runs: `dir` is where it runs, relative to the session's directory ('' for the
22 * directory itself), from earlier `cd`s and `-C`; `args` are the words after the subcommand, unquoted. A `cd`
23 * that may have failed leaves one step per directory the command could run in.
24 */
25export type GitStep = { kind: 'commit' | 'push'; dir: string; args: string[] }
26
27/** git's global options that take the next word as their value, ahead of the subcommand. */
28const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env'])
29/** push's options that take the next word as their value. */
30const PUSH_VALUE_FLAGS = new Set(['--repo', '-o', '--push-option', '--receive-pack', '--exec'])
31
32const valueOf = (line: string, w: Word): string => line.slice(w.start, w.end).replace(/["']/g, '')
33
34/** `to` resolved against `from`, both relative to the session's directory. */
35const join = (from: string, to: string): string => (to.startsWith('/') || to.startsWith('~') || from === '' ? to : `${from}/${to}`)
36
37const unique = (xs: readonly string[]): string[] => [...new Set(xs)]
38
39export const gitSteps = (line: string): GitStep[] => {
40 const ws = words(line)
41 const steps: GitStep[] = []
42 // Where the next command may run: `dirs` if every `cd` since the chain last broke succeeded, `fallback` if one
43 // failed. Only `&&` carries a `cd`'s success forward; any other operator lets a failed one's command run too.
44 let dirs = ['']
45 let fallback: string[] = []
46 ws.forEach((w, i) => {
47 if (w.startsCommand && w.joinedBy.replace(/\n/g, '') !== '&&') {
48 dirs = unique([...dirs, ...fallback])
49 fallback = []
50 }
51 if (!w.isCommand) return
52 const args = argsOf(ws, i)
53 if (w.text === 'cd') {
54 fallback = unique([...fallback, ...dirs])
55 dirs = unique(dirs.map(d => (args[0] ? join(d, valueOf(line, args[0])) : '~')))
56 return
57 }
58 if (w.text !== 'git') return
59 let at = dirs
60 for (let k = 0; k < args.length; k++) {
61 const t = (args[k] as Word).text
62 if (!t.startsWith('-')) {
63 if (t === 'commit' || t === 'push') {
64 const rest = args.slice(k + 1).map(a => valueOf(line, a))
65 for (const dir of at) steps.push({ kind: t, dir, args: rest })
66 }
67 return
68 }
69 if (t === '-C' && args[k + 1]) {
70 const to = valueOf(line, args[k + 1] as Word)
71 at = unique(at.map(d => join(d, to)))
72 }
73 if (GIT_VALUE_FLAGS.has(t)) k++
74 }
75 })
76 return steps
77}
78
79/**
80 * The branches a push's arguments land on: each refspec's destination, the current branch when none is named
81 * (and no `--tags`), or 'all' for `--all`, `--branches` and `--mirror`.
82 */
83export const pushTargets = (args: readonly string[], current: string | undefined): string[] | 'all' => {
84 const positional: string[] = []
85 let isTags = false
86 for (let i = 0; i < args.length; i++) {
87 const t = args[i] as string
88 if (t === '--') {
89 positional.push(...args.slice(i + 1))
90 break
91 }
92 if (!t.startsWith('-') || t === '-') positional.push(t)
93 else if (t === '--all' || t === '--branches' || t === '--mirror') return 'all'
94 else if (t === '--tags') isTags = true
95 else if (PUSH_VALUE_FLAGS.has(t)) i++
96 }
97 const refspecs = positional.slice(1)
98 if (refspecs.length === 0) return isTags || !current ? [] : [current]
99 return refspecs.flatMap(spec => {
100 const s = spec.replace(/^\+/, '')
101 const dst = s.includes(':') ? s.slice(s.indexOf(':') + 1) : s
102 const name = dst === 'HEAD' || dst === '@' ? current : dst.replace(/^refs\/heads\//, '')
103 return name ? [name] : []
104 })
105}
106
107const HATCH_NOTE = `Prefix ${BRANCH_HATCH} only when the Crawler asked for this to land on it.`
108
109/**
110 * What the guard does with a Bash line: deny a commit made on a Protected Branch or a push that lands on one,
111 * unless the line carries the Branch Escape Hatch. `repoOf` gives the repository at a GitStep's `dir`, or
112 * undefined outside one, where the guard has no say.
113 */
114export const branchGuard = (line: string, repoOf: (dir: string) => Repo | undefined): Judged => {
115 if (hasAssignment(words(line), BRANCH_HATCH)) return { kind: 'pass' }
116 for (const step of gitSteps(line)) {
117 const repo = repoOf(step.dir)
118 if (!repo) continue
119 const guarded = protectedBranches(repo)
120 if (step.kind === 'commit') {
121 if (repo.branch && !repo.isUnborn && guarded.has(repo.branch))
122 return {
123 kind: 'deny',
124 reason:
125 `Branch Guard: \`git commit\` on \`${repo.branch}\`, a Protected Branch. ` +
126 `Branch first with \`git switch -c <name>\` and commit there. ${HATCH_NOTE}`,
127 }
128 continue
129 }
130 const targets = pushTargets(step.args, repo.branch)
131 const hit = targets === 'all' ? [...guarded].join('`, `') : targets.find(t => guarded.has(t))
132 if (hit)
133 return {
134 kind: 'deny',
135 reason:
136 `Branch Guard: this \`git push\` lands on \`${hit}\`, a Protected Branch. ` +
137 `Push a feature branch (\`git switch -c <name>\`) and open a pull request instead. ${HATCH_NOTE}`,
138 }
139 }
140 return { kind: 'pass' }
141}
142mods/podman.ts 77 lines1// The Podman Guard's pure half: which Bash commands run Docker, what they become under podman, and which
2// ones podman cannot stand in for. No `$` here.
3
4import { argsOf, attachedFlag, hasAssignment, words, type Word } from './shell'
5
6export type Guarded =
7 | { kind: 'pass' }
8 | { kind: 'rewrite'; command: string }
9 | { kind: 'deny'; reason: string }
10
11/** The Docker Escape Hatch: an assignment of it anywhere in the line leaves the whole line alone. */
12export const ESCAPE_HATCH = 'DOCKER_OK=1'
13
14/** Daemon-Only Commands: subcommands that need a real Docker daemon or Docker's own services. */
15export const DAEMON_ONLY = ['context', 'swarm', 'service', 'stack', 'node', 'plugin', 'trust', 'scout'] as const
16
17/** The options whose value mounts or dials a daemon socket, as in `-v /var/run/docker.sock:/s` or `-H unix://...`. */
18const SOCKET_FLAGS = new Set(['-v', '--volume', '--mount', '-H', '--host'])
19/** Docker's global options that take the next word as their value, ahead of the subcommand. */
20const DOCKER_VALUE_FLAGS = new Set(['--config', '-c', '--context', '-H', '--host', '-l', '--log-level', '--tlscacert', '--tlscert', '--tlskey'])
21
22/**
23 * What the guard does with a Bash line: pass it when it runs no Docker or carries the Escape Hatch, deny a
24 * Daemon-Only Command or a docker.sock mount, and otherwise rewrite each `docker` to `podman` and each
25 * `docker-compose` to `podman compose`, leaving every other byte as it was.
26 */
27export const guard = (line: string): Guarded => {
28 const ws = words(line)
29 const dockers = ws
30 .map((w, i) => ({ w, args: argsOf(ws, i) }))
31 .filter(({ w }) => w.isCommand && (w.text === 'docker' || w.text === 'docker-compose'))
32 if (dockers.length === 0 || hasAssignment(ws, ESCAPE_HATCH)) return { kind: 'pass' }
33
34 const daemonOnly = dockers
35 .filter(({ w }) => w.text === 'docker')
36 .map(({ args }) => subcommandOf(args))
37 .find(sub => sub !== undefined && (DAEMON_ONLY as readonly string[]).includes(sub))
38 const mountsSocket = dockers.some(({ args }) =>
39 args.some(
40 (a, k) =>
41 line.slice(a.start, a.end).includes('docker.sock') &&
42 (SOCKET_FLAGS.has(args[k - 1]?.text ?? '') || SOCKET_FLAGS.has(attachedFlag(a.text) ?? '')),
43 ),
44 )
45 if (daemonOnly || mountsSocket) {
46 const what = daemonOnly ? `\`docker ${daemonOnly}\`` : 'a docker.sock mount'
47 return {
48 kind: 'deny',
49 reason:
50 `Podman Guard: ${what} needs the Docker daemon, which podman cannot stand in for. ` +
51 `Use podman's own way, or prefix the command with ${ESCAPE_HATCH} if real Docker is required.`,
52 }
53 }
54
55 let command = line
56 for (const { w } of [...dockers].reverse()) {
57 const to = w.text === 'docker' ? 'podman' : 'podman compose'
58 command = command.slice(0, w.start) + to + command.slice(w.end)
59 }
60 return { kind: 'rewrite', command }
61}
62
63/** Docker's subcommand: the first argument past its global options and their values. */
64const subcommandOf = (args: readonly Word[]): string | undefined => {
65 for (let i = 0; i < args.length; i++) {
66 const t = (args[i] as Word).text
67 if (!t.startsWith('-')) return t
68 if (DOCKER_VALUE_FLAGS.has(t)) i++
69 }
70 return undefined
71}
72
73/** The note the model reads after a rewritten command's result. */
74export const rewriteNote = (from: string, to: string): string =>
75 `[Podman Guard: this ran as \`${to}\`, not \`${from}\`. Output and errors are podman's. ` +
76 `Prefix ${ESCAPE_HATCH} only when real Docker is required.]`
77mods/tdd.ts 78 lines1// The TDD Band's pure half: the TDD Phase that subagent spawns and Checks lead to, and what the band says.
2// No `$` here.
3
4export type TddPhase = 'RED' | 'GREEN' | 'REFACTOR'
5
6/** `auto`: hidden until the first Check or TDD subagent; `shown`/`hidden`: what it is now, `/tdd` flips it. */
7export type BandVisibility = 'auto' | 'shown' | 'hidden'
8
9export type Tdd = {
10 phase?: TddPhase
11 /** The last Check since the phase was set: true green, false red, absent none yet. */
12 lastCheck?: boolean
13 /** True once a TDD subagent set the phase; until then Checks alone set it. */
14 isAgentLed: boolean
15 visibility: BandVisibility
16}
17
18export const initialTdd: Tdd = { isAgentLed: false, visibility: 'auto' }
19
20/** The TDD subagents, by their name without the plugin prefix, and the phase each starts. */
21export const AGENT_PHASE: Record<string, TddPhase> = {
22 'red-phase-tester': 'RED',
23 'green-phase-implementer': 'GREEN',
24 'tdd-refactor-specialist': 'REFACTOR',
25}
26
27/** The band's buttons: hotkey, label, and the prompt draft it puts in the box. */
28export const DRAFTS = [
29 { hotkey: '1', label: 'Red', agent: 'red-phase-tester' },
30 { hotkey: '2', label: 'Green', agent: 'green-phase-implementer' },
31 { hotkey: '3', label: 'Refactor', agent: 'tdd-refactor-specialist' },
32].map(d => ({ ...d, draft: `Use the ${d.agent} agent to ` }))
33
34const appear = (v: BandVisibility): BandVisibility => (v === 'auto' ? 'shown' : v)
35
36/** The phase a subagent type starts, `dotfiles-dev-tools:red-phase-tester` and `red-phase-tester` alike. */
37export const agentPhase = (subagentType: string): TddPhase | undefined =>
38 AGENT_PHASE[subagentType.slice(subagentType.lastIndexOf(':') + 1)]
39
40/** A subagent started: a TDD one sets the phase and waits for its Check; any other changes nothing. */
41export const onAgent = (t: Tdd, subagentType: string): Tdd => {
42 const phase = agentPhase(subagentType)
43 return phase ? { phase, isAgentLed: true, visibility: appear(t.visibility) } : t
44}
45
46/** A Check finished: it confirms or contradicts an agent-led phase, and alone sets RED or GREEN. */
47export const onCheck = (t: Tdd, isGreen: boolean): Tdd => ({
48 ...t,
49 phase: t.isAgentLed ? t.phase : isGreen ? 'GREEN' : 'RED',
50 lastCheck: isGreen,
51 visibility: appear(t.visibility),
52})
53
54export const toggle = (t: Tdd): Tdd => ({ ...t, visibility: t.visibility === 'shown' ? 'hidden' : 'shown' })
55
56export const isShown = (t: Tdd): boolean => t.visibility === 'shown'
57
58export type Mood = 'good' | 'bad' | 'waiting'
59
60/**
61 * What the band says beside the phase. RED wants a red Check (the new test fails first), GREEN and REFACTOR a
62 * green one; a phase the Checks set alone always agrees with them.
63 */
64export const verdictOf = (t: Tdd): { mood: Mood; note: string } => {
65 if (t.lastCheck === undefined) return { mood: 'waiting', note: 'awaiting a Check' }
66 if (!t.isAgentLed) return t.lastCheck ? { mood: 'good', note: 'Check ✔' } : { mood: 'bad', note: 'Check ✖' }
67 switch (t.phase) {
68 case 'RED':
69 return t.lastCheck
70 ? { mood: 'bad', note: '⚠ tests pass already: they test nothing new' }
71 : { mood: 'good', note: '✔ failing, as a fresh test should' }
72 case 'GREEN':
73 return t.lastCheck ? { mood: 'good', note: '✔ green' } : { mood: 'bad', note: '✖ still red' }
74 default:
75 return t.lastCheck ? { mood: 'good', note: '✔ still green' } : { mood: 'bad', note: '✖ broke it' }
76 }
77}
78mods/voice.ts 112 lines1// The System's voice: Spinner Words and the Verdict under a Notable Turn. No `$` here.
2
3export type Mood = 'collapsing' | 'debuffed' | 'dispatching' | 'normal'
4
5/** Spinner Words by what the session is going through; the engine adds its own ellipsis after each. */
6export const SPINNER_WORDS: Record<Mood, readonly string[]> = {
7 normal: [
8 'Consulting the Syndicate',
9 'Rigging the betting pools',
10 'Reviewing the sponsor contracts',
11 'Polishing the loot boxes',
12 'Warming up the studio audience',
13 'Calibrating the death traps',
14 'Cueing the dramatic music',
15 'Re-reading the Crawler waivers',
16 'Counting galactic credits',
17 'Pre-writing the obituary',
18 'Adjusting the camera drones',
19 'Negotiating with the floor boss',
20 'Inspecting the Crawler for shoes',
21 'Lowering expectations',
22 'Feeding the mantaurs',
23 'Measuring the ratings',
24 ],
25 debuffed: [
26 'Applying additional shame',
27 'Replaying the blunder in slow motion',
28 'Selling highlight reels of the failure',
29 'Updating the blooper compilation',
30 'Letting the audience boo',
31 'Drafting the penalty notice',
32 ],
33 collapsing: [
34 'Ceiling collapsing',
35 'Counting down to the cave-in',
36 'Evacuating the context window',
37 'Shoring up the tunnel',
38 'Listening to the walls creak',
39 ],
40 dispatching: [
41 'Watching the local idiot work',
42 'Supervising the cheap labor',
43 'Placing bets on the local model',
44 'Monitoring the minion',
45 ],
46}
47
48/** From this much context the walls are coming down. */
49export const COLLAPSING_FROM = 80
50
51export type Moodboard = { contextPercent?: number; debuff?: string; isDispatching: boolean }
52
53/** The mood that wins: a collapsing context, then a debuff, then a running Dispatch. */
54export const moodOf = (m: Moodboard): Mood =>
55 (m.contextPercent ?? 0) >= COLLAPSING_FROM
56 ? 'collapsing'
57 : m.debuff
58 ? 'debuffed'
59 : m.isDispatching
60 ? 'dispatching'
61 : 'normal'
62
63/** A Spinner Word for the mood; `roll` in [0, 1) picks it, so the same roll always gives the same word. */
64export const spinnerWord = (mood: Mood, roll: number): string => {
65 const pool = SPINNER_WORDS[mood]
66 return pool[Math.min(pool.length - 1, Math.floor(roll * pool.length))] as string
67}
68
69export type TurnStats = { durationMs: number; toolCalls: number; points: number; events: readonly string[] }
70
71export const NOTABLE_MS = 60_000
72export const NOTABLE_TOOLS = 10
73
74/** A Notable Turn: it earned an Award, ran longer than a minute, or made NOTABLE_TOOLS tool calls or more. */
75export const isNotable = (s: TurnStats): boolean =>
76 s.events.length > 0 || s.durationMs > NOTABLE_MS || s.toolCalls >= NOTABLE_TOOLS
77
78export const clock = (ms: number): string => {
79 const s = Math.round(ms / 1000)
80 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
81}
82
83const signed = (n: number): string => (n >= 0 ? `+${n}` : `−${-n}`)
84
85export const VERDICT_SYSTEM =
86 "You are the System AI from Dungeon Crawler Carl: a sardonic game-show host judging one turn of a programmer (the Crawler) and the AI assistant they steer, for a galactic audience. Write ONE line, at most 110 characters, that starts with '*Ding!*' and judges the turn from its stats: cruel, theatrical, funny, no emoji, no quotes around it. Praise only backhanded."
87
88export const verdictPrompt = (s: TurnStats): string =>
89 [
90 `The turn took ${clock(s.durationMs)} and made ${s.toolCalls} tool calls.`,
91 s.events.length > 0 ? `During it the Crawler ${s.events.join('; ')}.` : 'Nothing scored.',
92 `Net points: ${signed(s.points)}.`,
93 'Write the verdict.',
94 ].join('\n')
95
96/** The Verdict without a model: the turn's stats and a stock jab chosen by how it went. */
97export const fallbackVerdict = (s: TurnStats): string => {
98 const jab =
99 s.points < 0
100 ? 'The audience enjoyed that more than you did.'
101 : s.points > 0
102 ? 'Suspiciously competent. The Syndicate is investigating.'
103 : s.toolCalls >= NOTABLE_TOOLS
104 ? 'All that rummaging, and not a single point.'
105 : 'A long time to stand still.'
106 return `*Ding!* ${clock(s.durationMs)}, ${s.toolCalls} tool calls, ${signed(s.points)} CP. ${jab}`
107}
108
109/** A Verdict line as shown under the answer, with the turn's points when a model wrote it. */
110export const verdictLine = (line: string, s: TurnStats, isModel: boolean): string =>
111 `🎟 ${line}${isModel && s.points !== 0 ? ` (${signed(s.points)} CP)` : ''}`
112mods/shell.ts 171 lines1// The shell words a Bash line holds, and which of them sit where the shell runs a command. The guards' shared
2// parser. No `$` here.
3
4/** Words that run the next word as a command, so a `docker` after them is still in command position. */
5const WRAPPERS = new Set(['sudo', 'env', 'time', 'nohup', 'exec', 'command', 'xargs', 'watch'])
6/** A wrapper's flags that take the next word as their value, as in `sudo -u root docker`. */
7const WRAPPER_VALUE_FLAGS: Record<string, ReadonlySet<string>> = {
8 sudo: new Set([
9 ...['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-R', '-t', '-T', '-U'],
10 ...['--user', '--group', '--close-from', '--chdir', '--host', '--prompt', '--role', '--chroot', '--type'],
11 ...['--command-timeout', '--other-user'],
12 ]),
13 env: new Set(['-u', '-C', '-S', '--unset', '--chdir', '--split-string']),
14 time: new Set(['-f', '-o', '--format', '--output']),
15 xargs: new Set([
16 ...['-I', '-L', '-n', '-P', '-d', '-E', '-s', '-a'],
17 ...['--max-lines', '--max-args', '--max-procs', '--delimiter', '--max-chars', '--arg-file', '--process-slot-var'],
18 ]),
19 // `watch -d` takes its optional value attached (`-d=permanent`), never as the next word.
20 watch: new Set(['-n', '--interval']),
21}
22/** Shell keywords and reserved words after which the next word is a command, as in `if docker ps; then ...`. */
23const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{'])
24const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
25
26/**
27 * `startsCommand`: the word begins a new simple command of the chain (after `;`, `&`, `|` or a newline), and
28 * `joinedBy` is the operator that chained it there (`&&`, `||`, `;`, `|`, `\n`, ...; '' for the line's first).
29 */
30export type Word = { text: string; start: number; end: number; isCommand: boolean; startsCommand: boolean; joinedBy: string }
31
32/**
33 * The line's words outside quotes, each marked when it sits where the shell runs a command: at the start, after
34 * `;`, `&`, `|`, a newline, `(`, `$(` or a backtick, and after leading assignments, shell keywords (`if`, `do`,
35 * `!`, ...) and wrappers like `sudo`.
36 * A quoted part stays inside its word, so `echo "docker run"` holds no `docker` word.
37 */
38export const words = (line: string): Word[] => {
39 const out: Word[] = []
40 let quote: '"' | "'" | undefined
41 let start = -1
42 let text = ''
43 let expectsCommand = true
44 let startsCommand = true
45 /** The chain operator read since the last word. */
46 let op = ''
47 /** The wrapper whose flags are being read, and whether the word now is one flag's value. */
48 let wrapper: string | undefined
49 let expectsValue = false
50 let inBacktick = false
51 const end = (at: number) => {
52 if (start < 0) return
53 const isCommand = expectsCommand && !expectsValue
54 out.push({ text, start, end: at, isCommand, startsCommand, joinedBy: startsCommand ? op : '' })
55 startsCommand = false
56 op = ''
57 if (expectsValue) expectsValue = false
58 else if (isCommand) {
59 const isWrapper = WRAPPERS.has(text)
60 const isWrapperFlag = wrapper !== undefined && text.startsWith('-')
61 expectsValue = isWrapperFlag && (WRAPPER_VALUE_FLAGS[wrapper as string]?.has(text) ?? false)
62 wrapper = isWrapper ? text : isWrapperFlag ? wrapper : undefined
63 expectsCommand = ASSIGNMENT.test(text) || KEYWORDS.has(text) || isWrapper || isWrapperFlag
64 }
65 start = -1
66 text = ''
67 }
68 /** Heredocs opened on the current line, whose bodies start after its newline. */
69 const heredocs: Heredoc[] = []
70 for (let i = 0; i < line.length; i++) {
71 const c = line[i] as string
72 if (quote) {
73 if (c === '\\' && quote === '"') i++
74 else if (c === quote) quote = undefined
75 continue
76 }
77 if (c === '\\') {
78 if (start < 0) start = i
79 text += line[i + 1] ?? ''
80 i++
81 } else if (c === '"' || c === "'") {
82 if (start < 0) start = i
83 text += '\0' // a quoted part: the word is never a bare name
84 quote = c
85 } else if (c === ' ' || c === '\t') {
86 end(i)
87 } else if (c === '<' && line[i + 1] === '<' && line[i + 2] !== '<' && line[i - 1] !== '<') {
88 end(i)
89 const h = heredocAt(line, i)
90 heredocs.push(h)
91 i = h.after - 1
92 } else if (';&|\n()`'.includes(c) || (c === '$' && line[i + 1] === '(')) {
93 end(i)
94 if (c === '$') i++
95 // A backtick opens a substitution or closes one; only the opening one starts a command.
96 if (c === '`') inBacktick = !inBacktick
97 expectsCommand = c === '`' ? inBacktick : c !== ')'
98 startsCommand = ';&|\n'.includes(c)
99 if (startsCommand) op += c
100 wrapper = undefined
101 expectsValue = false
102 // The bodies of the line's heredocs are document text: the next word is the command after the last one.
103 if (c === '\n' && heredocs.length > 0) i = skipBodies(line, i + 1, heredocs.splice(0)) - 1
104 } else {
105 if (start < 0) start = i
106 text += c
107 }
108 }
109 end(line.length)
110 return out
111}
112
113/** A heredoc's opener: its delimiter, whether `<<-` strips leading tabs, and where the opener ends. */
114type Heredoc = { delimiter: string; isTabStripped: boolean; after: number }
115
116/** The heredoc opened by the `<<` at `at`: `<<EOF`, `<<-EOF`, `<< 'EOF'`, `<<"EOF"`, `<<E\OF` alike. */
117const heredocAt = (line: string, at: number): Heredoc => {
118 let i = at + 2
119 const isTabStripped = line[i] === '-'
120 if (isTabStripped) i++
121 while (line[i] === ' ' || line[i] === '\t') i++
122 let delimiter = ''
123 for (; i < line.length && !' \t\n;&|<>()'.includes(line[i] as string); i++) {
124 const c = line[i] as string
125 if (c === "'" || c === '"') {
126 const close = line.indexOf(c, i + 1)
127 const to = close < 0 ? line.length : close
128 delimiter += line.slice(i + 1, to)
129 i = to
130 } else if (c === '\\') delimiter += line[++i] ?? ''
131 else delimiter += c
132 }
133 return { delimiter, isTabStripped, after: i }
134}
135
136/**
137 * Where the shell picks up again after the bodies of `heredocs`, the first starting at `from`: the newline that
138 * ends the last one's delimiter line, or the end of the line when a body never closes. Bodies are opaque, even
139 * an unquoted delimiter's whose `$(...)` the shell would expand: misreading a document as commands is worse.
140 */
141const skipBodies = (line: string, from: number, heredocs: readonly Heredoc[]): number => {
142 let at = from
143 for (const [n, h] of heredocs.entries()) {
144 if (n > 0) at++ // past the newline that ended the previous delimiter line
145 for (;;) {
146 if (at >= line.length) return line.length
147 const eol = line.indexOf('\n', at) < 0 ? line.length : line.indexOf('\n', at)
148 const text = line.slice(at, eol)
149 at = eol
150 if ((h.isTabStripped ? text.replace(/^\t+/, '') : text) === h.delimiter) break
151 at++
152 }
153 }
154 return at
155}
156
157/** An assignment of `text` (`NAME=value`) before a command, as in `NAME=value cmd ...`, or exported; never an argument. */
158export const hasAssignment = (ws: readonly Word[], text: string): boolean =>
159 ws.some((w, i) => w.text === text && (w.isCommand || (ws[i - 1]?.isCommand === true && ws[i - 1]?.text === 'export')))
160
161/** The option a word sets with its value attached, `--volume=...` or `-v/var/...`; undefined for anything else. */
162export const attachedFlag = (text: string): string | undefined =>
163 text.startsWith('--') ? (text.includes('=') ? text.slice(0, text.indexOf('=')) : undefined) : text.startsWith('-') && text.length > 2 ? text.slice(0, 2) : undefined
164
165/** The words after the command at `i`, up to the next command of the chain; a `$(...)` inside stays in. */
166export const argsOf = (ws: readonly Word[], i: number): Word[] => {
167 const rest = ws.slice(i + 1)
168 const next = rest.findIndex(w => w.startsCommand)
169 return next < 0 ? rest : rest.slice(0, next)
170}
171types/index.d.ts 54 lines1export type BoardPhase = 'running' | 'awaiting' | 'landed' | 'dropped' | 'conflict' | 'gone'
2
3export type BoardRowState = {
4 n: number
5 ticket: string
6 phase: BoardPhase
7 ended?: 'finished' | 'timeout' | 'turn cap' | 'error'
8 wallS: number
9 calls: number
10 maxTurns?: number
11 tools: number
12 ctx: number
13 lastTool: string
14 detail: string
15}
16
17export type BoardState = {
18 branch: string
19 ctxLimit?: number
20 rows: BoardRowState[]
21}
22
23export type CrawlerScore = {
24 session: number
25 streak: number
26 debuff?: string
27}
28
29export type TddState = {
30 phase?: 'RED' | 'GREEN' | 'REFACTOR'
31 lastCheck?: boolean
32 isAgentLed: boolean
33 visibility: 'auto' | 'shown' | 'hidden'
34}
35
36export type BossState = { name: string; hp: number; maxHp: number; order: number }
37
38export type BossesState = {
39 foes: Record<string, { reds: number; boss?: BossState }>
40 summoned: number
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'dotfiles-dev-tools': {
46 board: BoardState | null
47 score: CrawlerScore
48 allTime: number
49 tdd: TddState
50 bosses: BossesState
51 }
52 }
53}
54