A roguelike pane played by your real session: context is HP, errors spawn monsters, green tests slay them, commits open chests, PRs are floor bosses


A roguelike that plays itself from your real Claude Code session. Open it with /dungeon and watch your work turn into a dungeon crawl.
| In your session | In the dungeon |
|---|---|
| Context window remaining | The party's HP |
| A failing command or test run | A monster spawns, named after the error: TypeError Wraith, ENOENT Imp, Red Test Tonberry. A search that finds nothing (rg, grep, diff) doesn't count |
| Another failure while it's alive | It feeds on the error and grows stronger, up to a cap |
| A passing test run, typecheck or lint | An attack. A clean run is a LIMIT BREAK that slays the foe and drops loot |
| A real commit (from Claude Code's git metadata, not the command text) | A treasure chest |
| A pull request opened | The floor boss appears |
| The pull request merged | The boss falls and the stairs to the next floor open |
| Each finished turn | XP; levels grow the party |
| A compaction | Resting at a bonfire: HP restored, kill streak spent |
| Running out of context | A wipe. The run goes into the Hall of Fame and a new party gathers; it can't wipe again until context frees up |
Runs, best floor, best level and the top five runs are kept across sessions in the Hall of Fame. When the pane is closed, a one-line ticker above the prompt shows HP and the current foe. Press × to hide it, or run /dungeon ticker to toggle it.
The mod only watches. It never blocks or rewrites a tool call, and it ignores subagents and backgrounded commands. HP updates when each turn finishes. The battle log never shows command lines, which can contain secrets.
| A failing test spawns a monster | A clean run is a LIMIT BREAK, the commit opens a chest |
|---|---|
![]() | ![]() |
/dungeon opens the dungeon pane./dungeon fame opens the Hall of Fame./dungeon ticker shows or hides the ticker./plugin marketplace add ccdwyer/claude-mods
/plugin install context-dungeon@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=dungeon}ui.close{id=context-dungeon}tool.callturn.completesession.compactsession.endui.render{component=PanerequestId=context-dungeon}ui.render{component=AbovePrompt}Engine calls it makes: $.clock.now, $.command.register, $.session.usage, $.state.get, $.state.set, $.store.get, $.store.set (via remember), $.ui.open (via openPane), $.ui.resolve, $.ui.toast (via openPane).
A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod uses that only for the behaviour described above.
It runs entirely on your machine. It sends nothing over the network. Game stats are kept in Claude Code's local plugin store.
The mod collects no analytics or telemetry, and its author receives no data from it.
Full policy: PRIVACY.md.
MIT
hooks/register.tsx 340 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fame, Lifetime, Run } from '../types'
5import {
6 fameOf, freshLifetime, freshRun, hasProgress, isCheckCommand, isExcusedFailure, onBonfire, onBossAppears,
7 onCommit, onContext, onEdit, onFailure, onFloorCleared, onPass, onTurn, tally, xpToNext,
8} from './game'
9import type { Step } from './game'
10
11const PANE = 'context-dungeon'
12// The old single-key tally, read but never written again.
13const LIFETIME_KEY = 'lifetime'
14// Each finished run is stored under its own key with this prefix.
15const RUN_PREFIX = 'run:'
16const LOG_KEEP = 60
17
18const run = atom({ plugin: 'context-dungeon', key: 'run' } as const, null)
19const log = atom({ plugin: 'context-dungeon', key: 'log' } as const, [])
20const isPaneOpen = atom({ plugin: 'context-dungeon', key: 'isPaneOpen' } as const, false)
21const isTickerHidden = atom({ plugin: 'context-dungeon', key: 'isTickerHidden' } as const, false)
22const view = atom({ plugin: 'context-dungeon', key: 'view' } as const, 'room')
23const lifetime = atom({ plugin: 'context-dungeon', key: 'lifetime' } as const, null)
24
25type Engine = EngineInterface
26type BashOutcome = {
27 backgroundTaskId?: string
28 gitOperation?: { commit?: { kind?: string }; pr?: { action?: string } }
29}
30
31// Every change to the run is computed inside one atomic update, so overlapping
32// tool calls each land on the latest run instead of overwriting each other.
33// `now` is only needed when a run may start or end; the hot path skips the clock.
34async function apply($: Engine, transition: (r: Run) => Step, now = 0) {
35 let lines: string[] = []
36 let fame: Fame | undefined
37 await update($, run, cur => {
38 const step = transition(cur ?? freshRun(now))
39 lines = step.lines
40 fame = step.fame
41 return step.run
42 })
43 if (lines.length > 0) await update($, log, all => [...all, ...lines].slice(-LOG_KEEP))
44 if (fame !== undefined) await remember($, fame)
45}
46
47// A stored run counts only when every field the tally sums is there.
48const isFame = (v: unknown): v is Fame => {
49 if (typeof v !== 'object' || v === null) return false
50 const f = v as Record<string, unknown>
51 return ['floor', 'level', 'kills', 'chests', 'bosses', 'at'].every(k => typeof f[k] === 'number' && Number.isFinite(f[k])) && typeof f.fate === 'string'
52}
53
54// The lifetime tally: the old single-key tally plus every stored run.
55async function loadLifetime($: Engine): Promise<Lifetime> {
56 const base = (await $.store.get(LIFETIME_KEY)) as Lifetime | undefined
57 const runs: Fame[] = []
58 for (const key of await $.store.keys()) {
59 if (!key.startsWith(RUN_PREFIX)) continue
60 const value = await $.store.get(key)
61 if (isFame(value)) runs.push(value)
62 }
63 return tally(base, runs)
64}
65
66// Only a finished run touches the store, and only by adding a key of its own:
67// sessions ending at the same moment each add theirs, so no run is lost.
68async function remember($: Engine, fame: Fame): Promise<void> {
69 try {
70 const id = `${RUN_PREFIX}${fame.at}-${Math.random().toString(36).slice(2, 10)}`
71 await $.store.set(id, fame)
72 const life = await loadLifetime($)
73 await update($, lifetime, () => life)
74 } catch {
75 // The hall of fame is optional.
76 }
77}
78
79async function openPane($: Engine) {
80 try {
81 const opened = await $.ui.open({ id: PANE, title: 'Context Dungeon' })
82 await update($, isPaneOpen, () => opened.isPlaced)
83 if (!opened.isPlaced) $.ui.toast('Context Dungeon: widen the terminal to enter the dungeon')
84 } catch {
85 await update($, isPaneOpen, () => false)
86 }
87}
88
89const bar = (pct: number, width: number) => {
90 const full = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
91 return '█'.repeat(full) + '░'.repeat(width - full)
92}
93const hpColor = (hp: number) => (hp > 50 ? 'green' : hp > 20 ? 'yellow' : 'red')
94const fit = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(0, room - 1))}…` : text)
95
96// The room: walls, the party on the left, the foe (or the stairs) on the right.
97function room(r: Run, width: number): { text: string; color?: string; dim?: boolean }[][] {
98 const w = Math.max(16, Math.min(width, 44))
99 const inner = w - 2
100 const party = r.level >= 10 ? '@@@@' : r.level >= 5 ? '@@@' : r.level >= 2 ? '@@' : '@'
101 const right = r.foe ? r.foe.glyph : '>'
102 const gap = Math.max(1, inner - 2 - party.length - 2 - right.length)
103 const wall = [{ text: '#'.repeat(w), dim: true }]
104 const empty = [{ text: '#', dim: true }, { text: ' '.repeat(inner) }, { text: '#', dim: true }]
105 const middle = [
106 { text: '#', dim: true },
107 { text: ' ' },
108 { text: party, color: 'cyan' },
109 { text: ' '.repeat(gap) },
110 { text: right, color: r.foe ? 'red' : 'yellow' },
111 { text: ' ' },
112 { text: '#', dim: true },
113 ]
114 return [wall, empty, middle, empty, wall]
115}
116
117export const register: Register = on => {
118 on('session.start', async ($, e, next) => {
119 try {
120 await $.command.register({
121 name: 'dungeon',
122 description: 'Context Dungeon: open the dungeon pane (args: fame, ticker)',
123 immediate: true,
124 })
125 const life = await loadLifetime($)
126 await update($, lifetime, () => life)
127 const now = await $.clock.now()
128 await update($, run, cur => cur ?? freshRun(now))
129 } catch {
130 // The dungeon is optional; the session is not.
131 }
132 return next(e)
133 })
134
135 on('command.run', { command: 'dungeon' }, async ($, e) => {
136 const arg = e.args.trim().toLowerCase()
137 if (arg === 'ticker') {
138 const hidden = await update($, isTickerHidden, was => !was)
139 return { text: `Context Dungeon: ticker ${hidden ? 'hidden' : 'shown'}.` }
140 }
141 await update($, view, () => (arg === 'fame' ? 'fame' : 'room'))
142 await openPane($)
143 return { text: 'Context Dungeon: you descend into the dungeon.' }
144 })
145
146 on('ui.close', { id: PANE }, async ($, e, next) => {
147 await update($, isPaneOpen, () => false)
148 return next(e)
149 })
150
151 // Pure observation: every call runs exactly as it would have; the dungeon only watches.
152 on('tool.call', async ($, e, next) => {
153 const ran = await next(e)
154 if (ran.deny !== undefined || e.agentId !== undefined) return ran
155 try {
156 const seed = e.tool_use_id ?? 'call'
157 if (e.tool === 'Bash') {
158 const command = String(e.command ?? '')
159 // Only a successful result carries Bash's own record; an errored one is just text.
160 const result = (ran.isError === true ? {} : (ran.result ?? {})) as BashOutcome
161 // A backgrounded command has not finished: it is neither a pass nor a fail yet.
162 if (result.backgroundTaskId !== undefined) return ran
163 const text = String(ran.text ?? '')
164 const failed = ran.isError === true
165 const commit = result.gitOperation?.commit?.kind
166 const pr = result.gitOperation?.pr?.action
167 const isCheck = !failed && isCheckCommand(command)
168 const isFoe = failed && !isExcusedFailure(command, text)
169 const isChest = commit === 'committed' || commit === 'amended' || commit === 'cherry-picked'
170 const isPr = pr === 'created' || pr === 'merged'
171 // Nothing for the dungeon: hand the result straight back.
172 if (!isCheck && !isFoe && !isChest && !isPr) return ran
173 await apply($, r => {
174 let step: Step = { run: r, lines: [] }
175 const then = (f: (x: Run) => Step) => {
176 const s = f(step.run)
177 step = { run: s.run, lines: [...step.lines, ...s.lines] }
178 }
179 if (isChest) then(x => onCommit(x, seed))
180 if (pr === 'created') then(x => onBossAppears(x, seed))
181 if (pr === 'merged') then(x => onFloorCleared(x, seed))
182 if (isCheck) then(x => onPass(x, text, seed))
183 if (isFoe) then(x => onFailure(x, text))
184 return step
185 })
186 } else if ((e.tool === 'Edit' || e.tool === 'Write') && ran.isError !== true) {
187 const staged = (ran.result as { staged?: boolean } | undefined)?.staged === true
188 if (!staged) await apply($, r => onEdit(r, String(e.file_path ?? ''), seed))
189 }
190 } catch {
191 // The dungeon never gets in the way of real work.
192 }
193 return ran
194 })
195
196 on('turn.complete', async ($, e, next) => {
197 const done = await next(e)
198 if (e.agentId !== undefined) return done
199 try {
200 const used = (await $.session.usage()).context.percent
201 const now = await $.clock.now()
202 await apply($, r => {
203 const turn = onTurn(r)
204 if (used === undefined) return turn
205 const after = onContext(turn.run, 100 - used, now)
206 return { run: after.run, lines: [...turn.lines, ...after.lines], fame: after.fame }
207 }, now)
208 } catch {
209 // Ignore: usage may be unavailable mid-shutdown.
210 }
211 return done
212 })
213
214 on('session.compact', async ($, e, next) => {
215 const done = await next(e)
216 if (e.agentId !== undefined || e.trigger === 'precompute' || done.messages === undefined) return done
217 try {
218 await apply($, onBonfire)
219 } catch {
220 // Ignore.
221 }
222 return done
223 })
224
225 // Leaving the session ends the run with honour: it goes in the hall of fame.
226 // /clear scatters the party: the next session's dungeon starts fresh.
227 on('session.end', async ($, e, next) => {
228 try {
229 // Take the run and clear it in one step, so a tool call finishing meanwhile cannot be lost or revived.
230 let ended: Run | null = null
231 await update($, run, cur => {
232 ended = cur
233 return null
234 })
235 await update($, log, () => [])
236 const r = ended as Run | null
237 if (r !== null && hasProgress(r)) {
238 await remember($, fameOf(r, e.reason === 'clear' ? 'scattered by /clear' : 'retired to the inn', await $.clock.now()))
239 }
240 } catch {
241 // Ignore.
242 }
243 return next(e)
244 })
245
246 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
247 const { Box, Text, Button } = $.ui.resolve(e)
248 const r = (await read($, run)) ?? freshRun(0)
249 const lines = await read($, log)
250 const life = (await read($, lifetime)) ?? freshLifetime()
251 const which = await read($, view)
252 const cols = Math.max(20, e.props.bodyColumns)
253 const rows = e.viewport?.rows ?? 30
254 const toggle = (
255 <Button
256 key="view"
257 label={which === 'fame' ? 'room' : 'hall of fame'}
258 onPress={() => update($, view, v => (v === 'fame' ? 'room' : 'fame'))}
259 />
260 )
261
262 if (which === 'fame') {
263 return (
264 <Box flexDirection="column">
265 <Text bold color="yellow">Hall of Fame</Text>
266 <Text dimColor>
267 {fit(`${life.runs} runs · ${life.kills} foes slain · ${life.bosses} bosses · ${life.wipes} wipes · best floor ${life.bestFloor} · best Lv ${life.bestLevel}`, cols)}
268 </Text>
269 {life.fame.length === 0 && <Text dimColor>No legends yet. Finish a run to be remembered.</Text>}
270 {life.fame.map((f, i) => (
271 <Text>{fit(`${i + 1}. Floor ${f.floor} · Lv ${f.level} · ${f.kills} slain · ${f.bosses} bosses · ${f.fate}`, cols)}</Text>
272 ))}
273 <Box>{toggle}</Box>
274 </Box>
275 )
276 }
277
278 const barWidth = Math.max(8, Math.min(30, cols - 22))
279 const logRoom = Math.max(3, rows - 14)
280 return (
281 <Box flexDirection="column">
282 <Text>
283 {fit(`Floor ${r.floor} · Lv ${r.level} (${r.xp}/${xpToNext(r.level)} XP) · ${r.gil} gil · streak ${r.streak}`, cols)}
284 </Text>
285 <Box>
286 <Text>HP </Text>
287 <Text color={hpColor(r.hp)}>{bar(r.hp, barWidth)}</Text>
288 <Text> {r.hp}% context left</Text>
289 </Box>
290 {room(r, cols).map(row => (
291 <Box>
292 {row.map(cell => (
293 <Text color={cell.color} dimColor={cell.dim}>{cell.text}</Text>
294 ))}
295 </Box>
296 ))}
297 {r.foe !== null ? (
298 <Box>
299 <Text color="red">{r.foe.isBoss === true ? '☠' : '⚔'} {fit(r.foe.name, cols - 24)} </Text>
300 <Text color="red">{bar((100 * r.foe.hp) / r.foe.maxHp, 10)}</Text>
301 <Text dimColor> {r.foe.hp}/{r.foe.maxHp}</Text>
302 </Box>
303 ) : (
304 <Text dimColor>The room is quiet. Stairs lead down ( > ).</Text>
305 )}
306 <Text dimColor>{fit(`Loot: ${r.loot.slice(-4).join(', ') || 'nothing yet'}`, cols)}</Text>
307 <Text dimColor>{'─'.repeat(Math.min(cols, 44))}</Text>
308 {lines.length === 0 && <Text dimColor>Your adventure begins. Run a test, make a commit…</Text>}
309 {lines.slice(-logRoom).map(line => (
310 <Text>{fit(line, cols)}</Text>
311 ))}
312 <Box>{toggle}</Box>
313 </Box>
314 )
315 })
316
317 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
318 if (e.props.hasSurvey || (await read($, isPaneOpen)) || (await read($, isTickerHidden))) return next(e)
319 const r = await read($, run)
320 if (r === null) return next(e)
321 const { Box, Text, Button } = $.ui.resolve(e)
322 const cols = e.props.bodyColumns ?? 80
323 const foe = r.foe !== null ? ` · ⚔ ${r.foe.name}` : ''
324 // The band is shared: draw our line above whatever the other plugins draw.
325 const below = await next(e)
326 return (
327 <Box flexDirection="column">
328 <Box>
329 <Text dimColor>Lv{r.level} F{r.floor} HP </Text>
330 <Text color={hpColor(r.hp)}>{bar(r.hp, 10)}</Text>
331 <Text dimColor>{fit(` ${r.hp}%${foe} `, Math.max(10, cols - 40))}</Text>
332 <Button key="enter" label="enter" onPress={() => openPane($)} />
333 <Button key="hide" label="×" plain onPress={() => update($, isTickerHidden, () => true)} />
334 </Box>
335 {below}
336 </Box>
337 )
338 })
339}
340hooks/game.ts 500 lines1// The rules of the dungeon: pure functions from a run and an event to the next run
2// and the battle-log lines it earned. No `$` here, so the rules test on their own.
3import type { Fame, Foe, Lifetime, Run } from '../types'
4
5export type Step = { run: Run; lines: string[]; fame?: Fame }
6
7const CREATURES = ['Wraith', 'Imp', 'Golem', 'Drake', 'Lich', 'Tonberry', 'Behemoth', 'Malboro', 'Cactuar', 'Ghoul', 'Wyvern', 'Mimic']
8const GLYPHS: Record<string, string> = {
9 Wraith: 'W', Imp: 'i', Golem: 'G', Drake: 'D', Lich: 'L', Tonberry: 't',
10 Behemoth: 'B', Malboro: 'M', Cactuar: 'c', Ghoul: 'g', Wyvern: 'V', Mimic: 'm',
11}
12const BOSSES = ['Review Behemoth', 'CI Ultima Weapon', 'Merge Conflict Malboro', 'Gatekeeper Margit', 'Ragnaros of the Main Branch']
13const LOOT = [
14 'Potion', 'Hi-Potion', 'Ether', 'Phoenix Down', 'Elixir', 'Megalixir', 'Ribbon', 'Genji Glove',
15 'Flask of Crimson Tears', 'Golden Rune', 'Smithing Stone', 'Hearthstone', 'Thunderfury (shard)',
16 'Mythril Sword', 'Masamune', 'Ultima Weapon', 'Rusty Key', 'Stale Bread',
17]
18const HEROES = ['Cloud', 'Tifa', 'Squall', 'Zidane', 'Terra', 'Ramza', 'the Tarnished']
19
20export const XP_PER_TURN = 5
21export const xpToNext = (level: number) => level * 20
22// At or below this much context left (percent) the party falls.
23export const WIPE_AT = 2
24// A foe stops growing here, however many errors it eats.
25export const MAX_FOE_HP = 12
26// Context samples ignored after a compaction, until a fresh response reports.
27export const REST_SKIPS = 1
28
29export const freshRun = (now: number, hp = 100): Run => ({
30 floor: 1, level: 1, xp: 0, gil: 0, hp, streak: 0, kills: 0, chests: 0, bosses: 0,
31 loot: [], foe: null, startedAt: now, isExhausted: false, restSkips: 0,
32})
33
34export const freshLifetime = (): Lifetime => ({
35 runs: 0, kills: 0, chests: 0, bosses: 0, wipes: 0, bestFloor: 1, bestLevel: 1, fame: [],
36})
37
38// Whether a run did anything worth remembering. Passive turn XP is not an adventure.
39export const hasProgress = (r: Run) => r.kills > 0 || r.chests > 0 || r.bosses > 0 || r.floor > 1 || r.foe?.isBoss === true
40
41// A small stable hash, so the same error always summons the same creature.
42export function pick<T>(list: readonly T[], seed: string): T {
43 let h = 2166136261
44 for (let i = 0; i < seed.length; i += 1) h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
45 return list[(h >>> 0) % list.length] as T
46}
47
48// The monster's first name comes from the error itself, never from the command
49// (a command line can carry tokens and passwords).
50export function errorKind(text: string): string {
51 const named = /\b([A-Z][A-Za-z]*(?:Error|Exception))\b/.exec(text)
52 if (named) return named[1] as string
53 const code = /\b(E[A-Z]{3,}[A-Z0-9]*)\b/.exec(text)
54 if (code) return code[1] as string
55 if (/\b(fail(ed|ing|ures?)?)\b/i.test(text)) return 'Red Test'
56 if (/command not found/i.test(text)) return 'Missing Binary'
57 const exit = /exit (?:code )?(\d+)/i.exec(text)
58 if (exit) return `Exit ${exit[1]}`
59 return 'Nameless'
60}
61
62export function summon(kind: string, floor: number, seed: string): Foe {
63 const creature = pick(CREATURES, seed)
64 const maxHp = 2 + Math.min(floor, 4)
65 return { name: `${kind} ${creature}`, glyph: GLYPHS[creature] ?? '?', hp: maxHp, maxHp }
66}
67
68const hero = (run: Run, seed: string) => (run.level >= 10 ? 'the party' : pick(HEROES, seed))
69
70function gainXp(run: Run, xp: number, lines: string[]): Run {
71 let next = { ...run, xp: run.xp + xp }
72 while (next.xp >= xpToNext(next.level)) {
73 next = { ...next, xp: next.xp - xpToNext(next.level), level: next.level + 1 }
74 lines.push(`★ Level up! The party reaches Lv ${next.level}.`)
75 }
76 return next
77}
78
79// A tool call failed: a monster appears, or the one already here grows stronger.
80export function onFailure(run: Run, text: string): Step {
81 const lines: string[] = []
82 const kind = errorKind(text)
83 if (run.foe === null) {
84 const foe = summon(kind, run.floor, `${kind}:${run.kills}`)
85 lines.push(`! A wild ${foe.name} appears!`)
86 return { run: { ...run, foe }, lines }
87 }
88 if (run.foe.maxHp >= MAX_FOE_HP) {
89 lines.push(`! ${run.foe.name} shrugs off the ${kind}.`)
90 return { run, lines }
91 }
92 const foe = { ...run.foe, maxHp: run.foe.maxHp + 1, hp: run.foe.hp + 1 }
93 lines.push(`! ${foe.name} feeds on the ${kind}. It grows stronger.`)
94 return { run: { ...run, foe }, lines }
95}
96
97// Output that still reports failures, even from a zero exit: a runner's summary
98// with a failure count, a FAIL/FAILED line, or a failed-test mark. Test titles
99// that merely mention failures do not count.
100const ANSI = /\x1b\[[0-9;]*m/g
101export function reportsFailures(raw: string): boolean {
102 const text = raw.replace(ANSI, '')
103 if (/^\s*(FAIL(ED)?\b|--- FAIL)/m.test(text)) return true
104 if (/^\s*[✕✗×] /m.test(text)) return true
105 if (/^\s*[1-9]\d* failing\b/m.test(text)) return true
106 if (/^FAILED \((failures|errors)=\d+/m.test(text)) return true
107 return text.split('\n').some(line => {
108 if (!/\b[1-9]\d* (failed|failures?|errors?)\b/i.test(line)) return false
109 return /^\s*(Tests?|Test Suites|Test Files|Examples?|Specs?):/i.test(line) || /\b\d+ passed\b/.test(line) || /={3,}/.test(line)
110 })
111}
112
113// A check passed. A clean run is a finishing blow on a common foe; a boss takes two hits.
114export function onPass(run: Run, text: string, seed: string): Step {
115 const lines: string[] = []
116 const isClean = !reportsFailures(text)
117 if (run.foe === null) {
118 if (!isClean) return { run, lines: [`… The checks report failures. Something stirs in the dark.`] }
119 const next = gainXp(run, 2, lines)
120 lines.unshift(`✓ ${hero(run, seed)} keeps watch. The checks are green.`)
121 return { run: next, lines }
122 }
123 // A boss already held at 1 HP waits for the merge; another green run changes nothing.
124 if (run.foe.isBoss === true && run.foe.hp <= 1) return { run, lines }
125 const damage = !isClean ? 1 : run.foe.isBoss === true ? Math.ceil(run.foe.maxHp / 2) : run.foe.hp
126 const foe = { ...run.foe, hp: run.foe.hp - damage }
127 if (foe.hp > 0) {
128 lines.push(`⚔ ${hero(run, seed)} attacks! ${damage} damage to ${foe.name}.`)
129 return { run: { ...run, foe }, lines }
130 }
131 if (foe.isBoss === true) {
132 lines.push(`⚔ ${foe.name} reels, but a boss falls only when the PR is merged.`)
133 return { run: { ...run, foe: { ...foe, hp: 1 } }, lines }
134 }
135 const drop = pick(LOOT, `${seed}:${run.kills}`)
136 lines.push(isClean ? `✦ LIMIT BREAK! ${hero(run, seed)} slays ${foe.name}!` : `⚔ ${foe.name} falls.`)
137 lines.push(` It drops: ${drop}.`)
138 let next: Run = {
139 ...run, foe: null, kills: run.kills + 1, streak: run.streak + 1,
140 loot: [...run.loot, drop].slice(-12), gil: run.gil + 10 * run.floor,
141 }
142 next = gainXp(next, 5 + foe.maxHp * 2, lines)
143 return { run: next, lines }
144}
145
146export function onCommit(run: Run, seed: string): Step {
147 const lines: string[] = []
148 const drop = pick(LOOT, `chest:${seed}:${run.chests}`)
149 const gil = 25 * run.floor
150 lines.push(`▣ A treasure chest! Inside: ${drop} and ${gil} gil.`)
151 const next = gainXp({ ...run, chests: run.chests + 1, gil: run.gil + gil, loot: [...run.loot, drop].slice(-12) }, 8, lines)
152 return { run: next, lines }
153}
154
155// A pull request opened: the floor boss steps out. A lesser foe flees before it.
156export function onBossAppears(run: Run, seed: string): Step {
157 if (run.foe?.isBoss === true) return { run, lines: [`☠ ${run.foe.name} already blocks the stairs.`] }
158 const name = pick(BOSSES, `${seed}:${run.floor}`)
159 const maxHp = 6 + 2 * Math.min(run.floor, 5)
160 const lines = [`☠ FLOOR BOSS: ${name}! The pull request is raised.`]
161 if (run.foe !== null) lines.push(` ${run.foe.name} flees before it.`)
162 return { run: { ...run, foe: { name, glyph: 'Ω', hp: maxHp, maxHp, isBoss: true } }, lines }
163}
164
165// A pull request merged: the floor is cleared and the stairs open.
166export function onFloorCleared(run: Run, seed: string): Step {
167 const lines: string[] = []
168 const name = run.foe?.isBoss === true ? run.foe.name : pick(BOSSES, `${seed}:${run.floor}`)
169 lines.push(`☠ The PR is merged. ${name} is vanquished!`)
170 lines.push(` The stairs open. Floor ${run.floor + 1}.`)
171 const next = gainXp(
172 { ...run, bosses: run.bosses + 1, floor: run.floor + 1, foe: null, gil: run.gil + 100 * run.floor },
173 25 * run.floor,
174 lines,
175 )
176 return { run: next, lines }
177}
178
179export function onEdit(run: Run, file: string, seed: string): Step {
180 const lines: string[] = []
181 const name = file.split('/').pop() ?? file
182 const spell = pick(['Refactor', 'Haste', 'Protect', 'Cure', 'Libra', 'Scan', 'Esuna'], seed)
183 lines.push(`✧ ${hero(run, seed)} casts ${spell} on ${name}.`)
184 return { run: gainXp(run, 1, lines), lines }
185}
186
187// Context changed: HP follows it. The first time it runs out the party falls; a
188// new party starts at the real HP left and cannot fall again until context frees up.
189export function onContext(run: Run, remaining: number, now: number): Step {
190 const left = Math.max(0, Math.min(100, remaining))
191 const hp = Math.round(left)
192 // Right after a bonfire the reading may predate the compaction: never wipe on it.
193 if (run.restSkips > 0) return { run: { ...run, restSkips: run.restSkips - 1 }, lines: [] }
194 if (left > WIPE_AT) return { run: { ...run, hp, isExhausted: false }, lines: [] }
195 if (run.isExhausted) return { run: { ...run, hp }, lines: [] }
196 return {
197 run: { ...freshRun(now, hp), isExhausted: true },
198 lines: [`✝ The party has fallen on floor ${run.floor}. Context exhausted.`, ` A new party gathers at the entrance.`],
199 // Like leaving, a wipe joins the hall of fame only if the run did something.
200 fame: hasProgress(run) ? fameOf(run, 'wiped out (context exhausted)', now) : undefined,
201 }
202}
203
204export function onTurn(run: Run): Step {
205 const lines: string[] = []
206 return { run: gainXp(run, XP_PER_TURN, lines), lines }
207}
208
209// A compaction is a rest at a bonfire: HP restored, but the streak is spent.
210export function onBonfire(run: Run): Step {
211 const lines = [`🔥 The party rests at a bonfire. HP restored.`]
212 if (run.streak > 0) lines.push(` The ${run.streak}-kill streak fades into the embers.`)
213 return { run: { ...run, hp: 100, streak: 0, isExhausted: false, restSkips: REST_SKIPS }, lines }
214}
215
216export function fameOf(run: Run, fate: string, now: number): Fame {
217 return { floor: run.floor, level: run.level, kills: run.kills, chests: run.chests, bosses: run.bosses, fate, at: now }
218}
219
220// A finished run joins the lifetime tally and, if it ranks, the hall of fame.
221export function record(life: Lifetime, fame: Fame): Lifetime {
222 const ranked = [...life.fame, fame].sort((a, b) => b.floor - a.floor || b.level - a.level || b.kills - a.kills).slice(0, 5)
223 return {
224 runs: life.runs + 1,
225 kills: life.kills + fame.kills,
226 chests: life.chests + fame.chests,
227 bosses: life.bosses + fame.bosses,
228 wipes: life.wipes + (fame.fate.startsWith('wiped') ? 1 : 0),
229 bestFloor: Math.max(life.bestFloor, fame.floor),
230 bestLevel: Math.max(life.bestLevel, fame.level),
231 fame: ranked,
232 }
233}
234
235// The lifetime tally: an old single-key tally (if any) plus every run stored under
236// its own key. Each session only ever adds keys, so two sessions ending together
237// cannot overwrite each other's runs. Runs are folded oldest first.
238export function tally(base: Lifetime | undefined, runs: readonly Fame[]): Lifetime {
239 return [...runs].sort((a, b) => a.at - b.at || a.fate.localeCompare(b.fate)).reduce(record, base ?? freshLifetime())
240}
241
242// --- Reading shell lines -------------------------------------------------------
243// Exit status belongs to whichever command ran last, which only a shell knows for
244// a compound line. So the dungeon judges single commands only: a line with
245// `&&`, `||`, `;` or `|` outside quotes never attacks and is never excused.
246
247type Parsed = { words: string[]; isCompound: boolean }
248
249// Splits into words, honouring quotes and comments, and notes any operator that
250// joins commands: `&&`, `||`, `;`, `|`, a background `&`, command substitution,
251// or a second line. Redirections (`2>&1`, `&>`, `>`) stay part of the command.
252export function parse(raw: string): Parsed {
253 // `cd dir && cmd` is one command run somewhere else.
254 const cd = /^\s*cd\s+("[^"]*"|'[^']*'|[^\s;&|]+)\s*&&\s*/.exec(raw)
255 const command = cd !== null && !/\$\(|`/.test(cd[1] as string) ? raw.slice(cd[0].length) : raw
256 const words: string[] = []
257 let word = ''
258 let quote: string | null = null
259 let isCompound = false
260 let hasWord = false
261 let pendingLine = false
262 const end = () => {
263 if (hasWord) words.push(word)
264 word = ''
265 hasWord = false
266 }
267 for (let i = 0; i < command.length; i += 1) {
268 const ch = command[i] as string
269 if (quote !== null) {
270 if (ch === quote) quote = null
271 else if (ch === '\\' && quote === '"' && i + 1 < command.length) word += command[(i += 1)]
272 else {
273 // Inside double quotes a substitution still runs another command.
274 if (quote === '"' && (ch === '`' || (ch === '$' && command[i + 1] === '('))) isCompound = true
275 word += ch
276 }
277 continue
278 }
279 if (pendingLine && !/\s/.test(ch)) {
280 isCompound = true
281 pendingLine = false
282 }
283 if (ch === "'" || ch === '"') {
284 quote = ch
285 hasWord = true
286 } else if (ch === '\\' && i + 1 < command.length) {
287 if (command[i + 1] !== '\n') word += command[i + 1]
288 i += 1
289 hasWord = hasWord || command[i] !== '\n'
290 } else if (ch === '#' && !hasWord) {
291 while (i + 1 < command.length && command[i + 1] !== '\n') i += 1
292 } else if (ch === '\n') {
293 end()
294 if (words.length > 0) pendingLine = true
295 } else if (/\s/.test(ch)) {
296 end()
297 } else if (ch === '&') {
298 const prev = command[i - 1]
299 const next = command[i + 1]
300 if (prev === '>' || next === '>') {
301 word += ch
302 hasWord = true
303 } else {
304 isCompound = true
305 if (next === '&') i += 1
306 end()
307 }
308 } else if (ch === ';' || ch === '|' || ch === '`' || (ch === '$' && command[i + 1] === '(')) {
309 isCompound = true
310 end()
311 } else {
312 word += ch
313 hasWord = true
314 }
315 }
316 end()
317 // Drop redirections and their targets: `> out.txt`, `2>&1`, `&>log`, `< in`.
318 const kept: string[] = []
319 for (let i = 0; i < words.length; i += 1) {
320 const w = words[i] as string
321 if (/^(\d*(>>?|<)|&>>?)$/.test(w)) i += 1
322 else if (!/^(\d*[<>]|&>)/.test(w)) kept.push(w)
323 }
324 return { words: kept, isCompound }
325}
326
327// Wrappers and the flags they take, peeled off to find the real executable.
328const WRAPPERS = new Set(['npx', 'bunx', 'pnpx', 'time', 'sudo', 'env', 'nice', 'exec', 'nohup'])
329const VALUED = new Set(['-n', '-u', '-g', '-C', '--prefix', '--filter', '-F', '--workspace', '--dir', '--cwd', '--package', '--cache', '-f', '--file', '--manifest-path', '-Z', '--directory'])
330const LAUNCHERS: Record<string, string> = { uv: 'run', poetry: 'run', pipenv: 'run', rye: 'run', hatch: 'run', pdm: 'run', bundle: 'exec' }
331
332function skipFlags(words: string[], from: number): number {
333 let i = from
334 while (i < words.length && (words[i] as string).startsWith('-')) {
335 i += VALUED.has(words[i] as string) ? 2 : 1
336 }
337 return i
338}
339
340export function invocation(words: readonly string[]): { exe: string; args: string[] } {
341 const w = [...words]
342 let i = 0
343 for (;;) {
344 while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i] as string)) i += 1
345 const head = w[i]
346 if (head === undefined) break
347 if (WRAPPERS.has(head)) {
348 i = skipFlags(w, i + 1)
349 continue
350 }
351 if ((head === 'pnpm' || head === 'yarn' || head === 'bun' || head === 'npm') && w[skipFlags(w, i + 1)] === 'exec') {
352 i = skipFlags(w, skipFlags(w, i + 1) + 1)
353 continue
354 }
355 // Launchers that run another program: `uv run pytest`, `bundle exec rspec`.
356 const launched = LAUNCHERS[head]
357 if (launched !== undefined && w[skipFlags(w, i + 1)] === launched) {
358 i = skipFlags(w, skipFlags(w, i + 1) + 1)
359 continue
360 }
361 break
362 }
363 const exe = (w[i] ?? '').split('/').pop() ?? ''
364 return { exe, args: w.slice(i + 1) }
365}
366
367// Flags that make a runner print, list or watch instead of running checks once.
368const NOT_A_RUN: Record<string, RegExp> = {
369 jest: /^(--listTests|--showConfig|--watch|--watchAll|--init)$/,
370 vitest: /^(--watch|-w|list|init|watch|dev)$/,
371 mocha: /^(--watch|-w)$/,
372 pytest: /^(--collect-only|--co|--fixtures|--markers)$/,
373 rspec: /^(--dry-run|--init)$/,
374 phpunit: /^(--list-tests|--list-suites)$/,
375 tsc: /^(-v|--showConfig|--init|--watch|-w|--listFilesOnly)$/,
376 eslint: /^(--fix|--fix-dry-run|--init|--print-config|--inspect-config)$/,
377 mypy: /^(--install-types)$/,
378 pyright: /^(--watch|-w|--createstub)$/,
379}
380const RUNNERS = new Set(Object.keys(NOT_A_RUN))
381
382// The subcommand after global flags, toolchain selectors (`+nightly`) and make variables.
383function subcommand(args: string[]): { sub: string | undefined; rest: string[] } {
384 let i = 0
385 while (i < args.length) {
386 const a = args[i] as string
387 if (a.startsWith('+') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) i += 1
388 else if (a.startsWith('-')) i += VALUED.has(a) ? 2 : 1
389 else break
390 }
391 return { sub: args[i], rest: args.slice(i + 1) }
392}
393
394// After `--`, `-w` is a watch flag unless a number follows it (Jest's `-w 2` is workers).
395// `--watch` / `--watchAll`, alone or with a value that turns it on (`--watch=false` runs once).
396const isWatchOn = (a: string) => /^(--watch|--watchAll)(=(?!(false|0|off|no)$).*)?$/i.test(a)
397// Flags forwarded after `--` that make the runner list or dry-run instead of running.
398const FORWARDED_NOT_A_RUN = /^(--listTests|--list-tests|--list|--collect-only|--co|--dry-run|--showConfig)$/
399
400function passesWatch(args: string[]): boolean {
401 const at = args.indexOf('--')
402 if (at === -1) return false
403 const extra = args.slice(at + 1)
404 return extra.some((a, i) => isWatchOn(a) || FORWARDED_NOT_A_RUN.test(a) || (a === '-w' && !/^\d+$/.test(extra[i + 1] ?? '')))
405}
406
407function isCheckInvocation(exe: string, args: string[]): boolean {
408 if (args.some(a => /^(--version|--help|-h)$/.test(a))) return false
409 // Watching never finishes a run, wherever the flag sits.
410 if (args.some(isWatchOn) || passesWatch(args)) return false
411 if (RUNNERS.has(exe)) return !args.some(a => (NOT_A_RUN[exe] as RegExp).test(a))
412 if (exe === 'ruff') return args[0] === 'check' && !args.includes('--fix')
413 if (exe === 'biome') return ['check', 'lint', 'ci'].includes(args[0] ?? '') && !args.includes('--write')
414 if (exe === 'ktlint') return !args.some(a => a === '-F' || a === '--format')
415 if (exe === 'swiftlint') return !['autocorrect', 'version', 'rules'].includes(args[0] ?? '') && !args.includes('--fix')
416 if (['npm', 'pnpm', 'yarn', 'bun'].includes(exe)) {
417 const { sub, rest } = subcommand(args)
418 const task = sub === 'run' || sub === 'run-script' ? subcommand(rest).sub : sub
419 return /^(test|tests|typecheck|type-check|tsc|lint|check)(:[\w-]+)?$/.test(task ?? '')
420 }
421 if (['go', 'cargo', 'swift', 'dotnet', 'deno'].includes(exe)) {
422 const { sub, rest } = subcommand(args)
423 // Compile-only and list-only modes print names and exit 0 without running anything.
424 const listing = rest.some(a => /^(--list|--list-tests|-list(=.*)?|--no-run)$/.test(a) || (exe === 'dotnet' && a === '-t'))
425 return sub === 'test' && !(exe === 'go' && rest.includes('-c')) && !listing
426 }
427 if (exe === 'node') return args.includes('--test') || (args[0] === '--run' && /^(test|tests|lint|typecheck)(:[\w-]+)?$/.test(args[1] ?? ''))
428 if (/^python(\d+(\.\d+)?)?$/.test(exe)) {
429 if (args[0] !== '-m') return false
430 if (args[1] === 'unittest') return true
431 return (args[1] === 'pytest' || args[1] === 'mypy') && isCheckInvocation(args[1], args.slice(2))
432 }
433 if (exe === 'xcodebuild') return args.includes('test') || args.includes('test-without-building')
434 if (exe === 'gradle' || exe === 'gradlew') return !args.some(a => a === '--dry-run' || a === '-m') && args.some(a => /^(.*:)?(test|check|lint)([A-Z]\w*)?$/.test(a) && !/Classes$/.test(a))
435 if (exe === 'make') {
436 // -n / --dry-run / --just-print / --recon print the recipe without running it.
437 // A short-option cluster (`-sn`, `-nk`) dry-runs too.
438 if (args.some(a => /^(--dry-run|--just-print|--recon|--question)$/.test(a) || /^-[a-zA-Z]*[nq][a-zA-Z]*$/.test(a))) return false
439 return /^(test|check|lint)$/.test(subcommand(args).sub ?? '')
440 }
441 return false
442}
443
444// Commands whose non-zero exit is an answer (no match, files differ, false), not an error.
445const PREDICATES = new Set(['rg', 'grep', 'egrep', 'fgrep', 'ag', 'ack', 'fd', 'diff', 'cmp', 'test', '[', '[[', 'which', 'type', 'pgrep', 'false'])
446// A predicate that printed one of these hit a real error, not a negative answer.
447// Only the tools' own diagnostic lines count; diff hunks and matches are content.
448const DIAGNOSTIC = /^(fatal:|error:|usage:|(rg|grep|egrep|fgrep|diff|cmp|fd|jq|test|\[|which|pgrep)(: | error)|regex parse error)|integer expression expected|unary operator expected|No such file or directory|Permission denied|command not found|: not found$/i
449// Diff and match lines are content. A leading space is a diff context line only
450// when the output is a diff; otherwise it is an indented diagnostic.
451const isDiff = (text: string) => /^(diff --git |@@ |--- |\+\+\+ )/m.test(text)
452// A shell's own complaint (`zsh:1: …`, `-bash: …`, `bash: line 0: …`) is always a diagnostic.
453const SHELL_DIAGNOSTIC = /^-?(ba|z|k|da|fi)?sh(:\d+)?: /
454const isContent = (line: string, diff: boolean) =>
455 (diff && /^([+\- ]|@@|diff --git|index )/.test(line)) || /^[^:\s]+:\d+:/.test(line)
456const hasDiagnostic = (text: string) => {
457 const diff = isDiff(text)
458 return text.split('\n').some(line => SHELL_DIAGNOSTIC.test(line) || (!isContent(line, diff) && DIAGNOSTIC.test(line.trim())))
459}
460
461function isPredicateInvocation(words: string[], exe: string, args: string[]): boolean {
462 if (PREDICATES.has(exe)) return true
463 const plain = words.filter(w => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w))
464 if (plain[0] === 'command' && plain[1] === '-v') return true
465 if (exe === 'jq') return args.some(a => a === '-e' || a === '--exit-status')
466 if (exe === 'git') {
467 let i = 0
468 while (i < args.length && (args[i] as string).startsWith('-')) i += ['-C', '-c', '--git-dir', '--work-tree'].includes(args[i] as string) ? 2 : 1
469 const sub = args[i]
470 return sub === 'grep' || sub === 'diff' || (sub === 'merge-base' && args.includes('--is-ancestor'))
471 }
472 return false
473}
474
475// A successful single command that ran a test, typecheck or lint.
476export function isCheckCommand(command: string): boolean {
477 const { words, isCompound } = parse(command)
478 if (isCompound) return false
479 // `MAKEFLAGS=-n make test` dry-runs: the variable is dropped before the executable is found.
480 // Its value is short-option clusters (GNU make allows the first without a dash), or long options.
481 const makeflags = words.find(w => w.startsWith('MAKEFLAGS='))
482 if (makeflags !== undefined) {
483 const flags = makeflags.slice('MAKEFLAGS='.length).split(/\s+/)
484 if (flags.some((f, i) => /^--(dry-run|just-print|recon)$/.test(f) || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(f) || (i === 0 && /^[a-zA-Z]*n[a-zA-Z]*$/.test(f)))) return false
485 }
486 const { exe, args } = invocation(words)
487 return isCheckInvocation(exe, args)
488}
489
490// A failed command whose exit code is an answer, or a compound line whose failure
491// cannot be pinned on a command: neither summons anything.
492export function isExcusedFailure(command: string, errorText: string): boolean {
493 // An interrupted run was the person stopping it, not the code failing.
494 if (/\binterrupted\b|Interrupted by user/i.test(errorText.slice(0, 400))) return true
495 const { words, isCompound } = parse(command)
496 if (isCompound) return !/\b([A-Z][A-Za-z]*(Error|Exception)|E[A-Z]{3,}[A-Z0-9]*)\b/.test(errorText)
497 const { exe, args } = invocation(words)
498 return isPredicateInvocation(words, exe, args) && !hasDiagnostic(errorText)
499}
500types/index.d.ts 49 lines1export type Foe = { name: string; glyph: string; hp: number; maxHp: number; isBoss?: boolean }
2
3export type Run = {
4 floor: number
5 level: number
6 xp: number
7 gil: number
8 // Context window remaining, 0-100.
9 hp: number
10 // Foes slain since the last bonfire (compaction).
11 streak: number
12 kills: number
13 chests: number
14 bosses: number
15 loot: string[]
16 foe: Foe | null
17 startedAt: number
18 // The party fell to a full context; no further wipes until context frees up.
19 isExhausted: boolean
20 // Context samples to ignore after a bonfire: they may predate the compaction.
21 restSkips: number
22}
23
24export type Fame = { floor: number; level: number; kills: number; chests: number; bosses: number; fate: string; at: number }
25
26export type Lifetime = {
27 runs: number
28 kills: number
29 chests: number
30 bosses: number
31 wipes: number
32 bestFloor: number
33 bestLevel: number
34 fame: Fame[]
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'context-dungeon': {
40 run: Run | null
41 log: string[]
42 isPaneOpen: boolean
43 isTickerHidden: boolean
44 view: 'room' | 'fame'
45 lifetime: Lifetime | null
46 }
47 }
48}
49