SLOPSHOPPER

dotfiles-dev-tools

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

newpanebandspinnerguardcommand
v0.7.0no licenseupdated 2026-10-09Anthony-Bible/dotfiles/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dotfiles-dev-tools
│ ┃ Dispatch Board ✕ › fix the failing auth test and add an audit log call │ ┃ No .hybrid/ in this repo: run pi-dispatch.py │ ┃ init first. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by dotfiles-dev-tools: Branch Guard: this `git pus │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ 🎟 OK (−15 CP) │ │ › /dispatches │ ⎿ dotfiles-dev-tools: Dispatch Board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ dotfiles-dev-tools: 🎟 -15 CP · Lv 1 · Debuff: Red Build · ctx 49% · $0.42

Draws

Pane · Dispatch Board
No .hybrid/ in this repo: run pi-dispatch.py init first.
README

Dotfiles

Personal configuration files for Linux development environments. Includes shell, editor, terminal, and Claude Code plugin setup.

Features

  • Nix & Home Manager: Reproducible environment setup using Nix flakes and Home Manager (flake.nix, home.nix, configuration.nix). (work in progress)
  • Shell Customization: Zsh (with Oh My Zsh), custom functions, and AI-powered helpers (see dot-zsh-functions/).
  • Neovim: Lua-based configuration with plugins and custom keybindings (nvim/).
  • Tmux: Custom tmux and tmuxp session management (tmux/).
  • WezTerm: Terminal emulator configuration (wezterm/).
  • Claude Code Plugin: Auto-formatting hooks and TDD agents available as a Claude Code marketplace plugin (see claude-plugin/).
  • Setup Script: setup.sh automates stowing configs, installing dependencies, and setting up the environment.

Gemini CLI Extension

This repository also functions as a Gemini CLI extension, providing custom TDD agents, context from GEMINI.md, and specialized development workflows.

Installing the Extension

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

Features

  • TDD Agents: Ported from the Claude plugin, available as sub-agents in Gemini CLI.
  • Contextual Knowledge: Automatically includes GEMINI.md for project-specific rules and instructions.
  • Deadpool Mode: Experience the "Uncensored Chaos Edition" for a more... colorful development experience.

Claude Code Plugin

This repo acts as a Claude Code plugin marketplace. The dotfiles-dev-tools plugin provides:

  • Auto-formatting hooks that run after every file write/edit:
  • Go (gofmt/goimports)
  • Shell scripts (shfmt)
  • Protobuf (clang-format)
  • OpenAPI specs (linting)
  • TDD agents for the full red-green-refactor cycle:
  • red-phase-tester — writes failing tests before implementation
  • green-phase-implementer — writes minimal code to pass tests
  • tdd-refactor-specialist — cleans up code after tests go green
  • tdd-review-agent — verifies completeness after refactoring
  • security-auditor — finds vulnerabilities in code

Installing the Plugin

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

How the Marketplace Works

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:

PathPurpose
.claude-plugin/plugin.jsonPlugin metadata (name, version, description)
hooks/hooks.jsonPostToolUse/PreToolUse hooks with shell commands
agents/*.mdCustom agents with frontmatter metadata
.mcp.jsonMCP servers bundled with the plugin
.lsp.jsonLSP 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.

Plugin Structure

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

Other Marketplace Commands

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

Directory Structure

.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

Zsh Functions

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

Getting Started

Prerequisites

Installation

  1. Clone the repository: ``sh git clone https://github.com/Anthony-Bible/dotfiles.git cd dotfiles ``
  1. Run the setup script: ``sh ./setup.sh ``
  1. Activate Home Manager configuration: ``sh nix run .#homeConfigurations.$USER.activationPackage ``
  1. Install the Claude Code plugin (see Installing the Plugin above).

License

MIT License. See individual files for copyright.

Source 10 files
hooks/register.tsx 645 lines
1// 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}
645
mods/board.ts 190 lines
1// 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}
190
mods/points.ts 225 lines
1// 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}
225
mods/boss.ts 172 lines
1// 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]
172
mods/branch.ts 142 lines
1// 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}
142
mods/podman.ts 77 lines
1// 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.]`
77
mods/tdd.ts 78 lines
1// 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}
78
mods/voice.ts 112 lines
1// 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)` : ''}`
112
mods/shell.ts 171 lines
1// 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}
171
types/index.d.ts 54 lines
1export 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