Prepare to code. A Souls-like for your terminal: YOU DIED when a command fails, runes for every edit, a bloodstain to retrieve, BONFIRE LIT on commit, and…

Prepare to code.
A Claude Code mod that turns your terminal into a Souls-like. Commands fail, you die. You earn runes, you drop them. You fix the bug, you get them back. Mostly.
Y O U D I E D
slain by npm test · ᚱ 3,412 dropped
| When | What happens |
|---|---|
| A command fails | YOU DIED in blood red above the prompt. Your runes drop at a bloodstain (a stash of the runes you lost, tagged with the command that killed you). |
| You succeed at the command that killed you | You pick the runes back up. Die again first and the old one is lost forever. |
| Claude edits a file | Runes. An Edit whose old_string isn't there is a death too. |
| Tests pass | ENEMY FELLED. If they were failing before: GREAT ENEMY FELLED and a pile of runes. |
git commit | BONFIRE LIT. Checkpoint saved. |
A destructive command (rm -rf, git push --force, git reset --hard, DROP TABLE…) asks permission | A fog wall appears under the dialog. |
| You deny a tool call | Parried! |
| A subagent's command fails | Your summoned phantom has died. |
| Claude is thinking | The spinner is Panic-rolling, Praising the sun, Reading item descriptions, *Waiting for the elevator*… |
| A turn ends | Rested for 12s, Parried for 3s, Got gud for 1m 4s |
| A session starts | A message from another Tarnished: "Be wary of Friday deploy, therefore try rubber duck" (appraised 412 times) |
You type git gud as a prompt | It's dropped. Thou art already gud. |
Successes in a row build a streak: +25% runes per five, up to double. Dying breaks it.
Your level, runes, deaths and bloodstain live in the status line (Lv 3 · ᚱ 1,240 · ☠ 12 · 🩸 ᚱ 3,400 to recover) and persist across sessions, so the death counter only ever goes up.
A grep that finds nothing doesn't kill you (probing commands are exempt). Turn on hardcore mode and it does.
Type /bonfire help in a session for the full guide. The short version:
ᚱ)./bonfire level./bonfire help — the in-game guide./bonfire — rest a moment: runes held, deaths, enemies felled, bonfires lit, where your bloodstain is, and a verdict./bonfire level — spend runes to rise a level (cost grows each time). Level gives you a title: Hollow, Wanderer, Tarnished, Knight, Champion, Elden Lord./bonfire trophies — your achievements: First Blood, Fall Guy, Git Gud, Great Hunter, Untouchable, Fog Diver, Ascended./bonfire rest — rest for real: compacts the conversation. The enemies respawn./soapstone — read a message from another player./soapstone <text> — scrawl your own. A future you will find it at the start of a later session.At the prompt of a Claude Code terminal session:
/plugin install git-gud --marketplace enriquesanchez-elastic/git-gud
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install git-gud --marketplace enriquesanchez-elastic/git-gud
In the desktop app: click + next to the prompt box → Plugins → Add marketplace with enriquesanchez-elastic/git-gud → Add plugin.
Mods are early access in Claude Code: hooks modules load only where function hooks are enabled, and the API may change between releases.
Set them in /config (each is a row there) or under pluginConfigs["git-gud"].options in ~/.claude/settings.json.
| Option | Default | |
|---|---|---|
hardcore | false | Every failed command kills you, even probes like grep, test and git diff. |
speak | false | Say "You died" with the system voice (macOS say). Deeply unnecessary. Recommended. |
messages | true | A soapstone message when each session starts. |
claude --plugin-dir . # run it from source, hot-reloads on save
claude plugin validate . # what the engine will refuse, before it does
claude plugin test . # the tests in tests/
.claude-plugin/types/ holds the generated claude-code type declarations. It is gitignored; regenerate it with your Claude Code install before running tsc -p ..
All the words live in hooks/lore.ts: spinner verbs, turn verbs, soapstone templates and words, and the regexes that decide what counts as tests, a commit, a probe or a fog wall. PRs adding better messages are very welcome. Keep them short, keep them about code.
A fan-made parody, not affiliated with or endorsed by FromSoftware or Bandai Namco. No game assets, sounds or logos are included; the banners are plain text.
MIT licensed.
hooks/register.tsx 547 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitGudBannerKind, GitGudStats } from '../types'
5
6import {
7 appraisals,
8 BANNER_COLORS,
9 BANNER_TITLES,
10 between,
11 DURATION_WORDS,
12 GIT_GUD_REPLIES,
13 isFogWall,
14 GUIDE,
15 killerOf,
16 kindOf,
17 levelCost,
18 newTrophies,
19 pick,
20 runesText,
21 soapstone,
22 SPINNER_WORDS,
23 streakMultiplier,
24 titleOf,
25 TROPHIES,
26} from './lore'
27
28/**
29 * git-gud: Claude Code as a Souls-like.
30 *
31 * A failed command is a death (YOU DIED above the prompt), and the runes you
32 * earned by editing drop where you fell; succeed at the command that killed
33 * you to retrieve them, die again first and they are gone. Passing tests fell
34 * an enemy (a GREAT one if they were failing), a commit lights a bonfire, the
35 * spinner and turn footer speak the games' verbs, and every session opens with
36 * a message from another Tarnished. `/bonfire` shows your stats (and `rest`
37 * compacts: the enemies respawn); `/soapstone` reads or writes messages.
38 */
39
40const PLUGIN = 'git-gud'
41const STATS_KEY = 'stats'
42const MESSAGES_KEY = 'messages'
43const BANNER_MS = 5000
44const MAX_MESSAGES = 50
45const MAX_MESSAGE_CHARS = 120
46
47const EDIT_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
48
49const banner = atom({ plugin: 'git-gud', key: 'banner' } as const, null)
50
51const FRESH: GitGudStats = {
52 deaths: 0,
53 runes: 0,
54 felled: 0,
55 greatFelled: 0,
56 bonfires: 0,
57 bloodstain: null,
58 testsFailing: false,
59 level: 1,
60 streak: 0,
61 deathStreak: 0,
62 fogsCrossed: 0,
63 trophies: [],
64}
65
66const rng = () => Math.random()
67
68/** A stable pick per input, so a turn's spinner does not flicker. */
69function hashPick<T>(list: readonly T[], seed: string): T {
70 let h = 2166136261
71
72 for (let i = 0; i < seed.length; i += 1) {
73 h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
74 }
75
76 return list[(h >>> 0) % list.length] as T
77}
78
79const basename = (path: string) => path.split(/[\\/]/).pop() ?? path
80
81/** What a tool call is filed under, for deaths and bloodstains alike. */
82function killerOfCall(e: { tool: string; [k: string]: unknown }): string {
83 if (e.tool === 'Bash' && typeof e.command === 'string') {
84 return killerOf(e.command)
85 }
86
87 const path = e.file_path ?? e.notebook_path
88
89 return typeof path === 'string' ? `${e.tool} ${basename(path)}` : e.tool
90}
91
92function asStats(value: unknown): GitGudStats {
93 const merged: GitGudStats =
94 value && typeof value === 'object'
95 ? { ...FRESH, ...(value as Partial<GitGudStats>) }
96 : { ...FRESH }
97
98 merged.trophies = Array.isArray(merged.trophies)
99 ? merged.trophies.filter(t => typeof t === 'string')
100 : []
101
102 return merged
103}
104
105/** The module's own working copy; `$.store` is the record across sessions. */
106let stats: GitGudStats = { ...FRESH }
107let isLoaded = false
108let saving: Promise<void> = Promise.resolve()
109let bannerId = 0
110let isHardcore = false
111let isSpeaking = false
112
113async function load($: EngineInterface) {
114 if (!isLoaded) {
115 stats = asStats(await $.store.get(STATS_KEY).catch(() => undefined))
116 isLoaded = true
117 }
118}
119
120/** Applies a change to the stats, saves them in order, refreshes the status. */
121async function change($: EngineInterface, fn: (s: GitGudStats) => void) {
122 await load($)
123 fn(stats)
124
125 for (const trophy of newTrophies(stats)) {
126 stats.trophies = [...stats.trophies, trophy.id]
127 $.ui.toast(`🏆 Trophy: ${trophy.name}. ${trophy.hint}`, { timeoutMs: 6000 })
128 }
129
130 const snapshot = { ...stats }
131 saving = saving
132 .then(() => $.store.set(STATS_KEY, snapshot))
133 .catch(() => undefined)
134 showStatus($)
135}
136
137function showStatus($: EngineInterface) {
138 const stain = stats.bloodstain
139 ? ` · 🩸 ᚱ ${runesText(stats.bloodstain.runes)} to recover`
140 : ''
141
142 const bonus = streakMultiplier(stats.streak)
143 const streak = bonus > 1 ? ` · 🔥 ×${bonus}` : ''
144
145 $.ui.status(
146 `Lv ${stats.level} · ᚱ ${runesText(stats.runes)} · ☠ ${runesText(stats.deaths)}${stain}${streak}`,
147 )
148}
149
150async function raise(
151 $: EngineInterface,
152 kind: GitGudBannerKind,
153 subtitle: string,
154) {
155 bannerId += 1
156 const id = bannerId
157 await update($, banner, () => ({ id, kind, subtitle }))
158
159 $.clock.after(BANNER_MS, () => {
160 void update($, banner, current =>
161 current?.id === id ? null : current,
162 ).catch(() => undefined)
163 })
164}
165
166/** You died: drop the runes where you fell, lose the old bloodstain. */
167async function die($: EngineInterface, killer: string, isTests: boolean) {
168 let dropped = 0
169 let lost = 0
170
171 await change($, s => {
172 s.deaths += 1
173 s.deathStreak += 1
174 s.streak = 0
175 lost = s.bloodstain?.runes ?? 0
176 dropped = s.runes
177 s.bloodstain = dropped > 0 ? { runes: dropped, killer } : null
178 s.runes = 0
179 s.testsFailing ||= isTests
180 })
181
182 const runes = dropped > 0 ? ` · ᚱ ${runesText(dropped)} dropped` : ''
183 await raise($, 'died', `slain by ${killer}${runes}`)
184
185 if (lost > 0) {
186 $.ui.toast(
187 `Your old bloodstain fades. ᚱ ${runesText(lost)} lost forever.`,
188 { timeoutMs: 6000 },
189 )
190 }
191
192 if (isSpeaking) {
193 void $.audio.speak('You died').catch(() => undefined)
194 }
195}
196
197/** A success: runes for the deed, and the bloodstain if this was the foe. */
198async function prevail(
199 $: EngineInterface,
200 killer: string,
201 earned: number,
202): Promise<void> {
203 let retrieved = 0
204
205 await change($, s => {
206 s.runes += Math.round(earned * streakMultiplier(s.streak))
207 s.streak += 1
208 s.deathStreak = 0
209
210 if (s.bloodstain && s.bloodstain.killer === killer) {
211 retrieved = s.bloodstain.runes
212 s.runes += retrieved
213 s.bloodstain = null
214 }
215 })
216
217 if (retrieved > 0) {
218 $.ui.toast(
219 `ᚱ ${runesText(retrieved)} retrieved from your bloodstain.`,
220 { timeoutMs: 6000 },
221 )
222 }
223}
224
225
226async function judgeBash(
227 $: EngineInterface,
228 agentId: string | undefined,
229 command: string,
230 ran: { deny?: string; isError?: true },
231) {
232 if (ran.deny !== undefined) {
233 $.ui.toast('⚔ Parried! The command was turned away.')
234
235 return
236 }
237
238 const kind = kindOf(command)
239 const killer = killerOf(command)
240
241 if (ran.isError === true) {
242 if (kind === 'probe' && !isHardcore) {
243 return
244 }
245
246 if (agentId !== undefined) {
247 $.ui.toast('👻 Your summoned phantom has died.')
248
249 return
250 }
251
252 await die($, killer, kind === 'tests')
253
254 return
255 }
256
257 if (agentId !== undefined) {
258 return
259 }
260
261 if (isFogWall(command)) {
262 await change($, s => {
263 s.fogsCrossed += 1
264 })
265 }
266
267 switch (kind) {
268 case 'tests': {
269 const isGreat = stats.testsFailing
270 const earned = isGreat ? between(2000, 3000, rng) : between(800, 1200, rng)
271
272 await change($, s => {
273 s.felled += 1
274 s.greatFelled += isGreat ? 1 : 0
275 s.testsFailing = false
276 })
277 await prevail($, killer, earned)
278 await raise($, isGreat ? 'great' : 'felled', `ᚱ +${runesText(earned)}`)
279
280 return
281 }
282 case 'commit': {
283 const earned = between(400, 600, rng)
284
285 await change($, s => {
286 s.bonfires += 1
287 })
288 await prevail($, killer, earned)
289 await raise($, 'bonfire', `checkpoint saved · ᚱ +${runesText(earned)}`)
290
291 return
292 }
293 case 'probe':
294 await prevail($, killer, between(1, 5, rng))
295
296 return
297 case 'other':
298 await prevail($, killer, between(10, 60, rng))
299
300 return
301 }
302}
303
304
305export const register: Register = (on, options) => {
306 isHardcore = options.hardcore === true
307 isSpeaking = options.speak === true
308 const isMessaging = options.messages !== false
309
310 on('session.start', async ($, e, next) => {
311 isLoaded = false
312 await load($)
313 showStatus($)
314
315 await $.command.register({
316 name: 'bonfire',
317 description:
318 'Rest at a bonfire: your level, deaths, runes and bloodstain. `level` spends runes to rise, `trophies` lists achievements, `rest` compacts (enemies respawn).',
319 argumentHint: '[help|level|trophies|rest]',
320 })
321
322 await $.command.register({
323 name: 'soapstone',
324 description: 'Read a message from another Tarnished, or write one for a future you.',
325 argumentHint: '[message]',
326 })
327
328 const welcomed = await $.store.get('welcomed').catch(() => undefined)
329
330 if (welcomed !== true) {
331 $.ui.toast('Welcome, Tarnished. Type /bonfire help to learn the ways.', { timeoutMs: 10000 })
332 await $.store.set('welcomed', true).catch(() => undefined)
333 }
334
335 if (isMessaging) {
336 const own = (await $.store.get(MESSAGES_KEY).catch(() => [])) as unknown
337 const mine = Array.isArray(own) ? own.filter(m => typeof m === 'string') : []
338 const isOwn = mine.length > 0 && rng() < 0.4
339
340 $.ui.toast(
341 isOwn
342 ? `✎ A message from a past you: "${pick(mine, rng)}"`
343 : `✎ "${soapstone(rng)}" (appraised ${appraisals(rng)} times)`,
344 { timeoutMs: 8000 },
345 )
346 }
347
348 return next(e)
349 })
350
351 // Failed shell commands kill you; good ones fell enemies and light bonfires.
352 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
353 const command = typeof e.command === 'string' ? e.command : ''
354
355 if (isFogWall(command)) {
356 try {
357 $.ui.notice(e.tool_use_id, '🌫 A fog wall. Only those who mean it may pass.')
358 } catch {
359 // no dialog open for this call: the fog is just atmosphere
360 }
361 }
362
363 const ran = await next(e)
364
365 try {
366 await judgeBash($, e.agentId, command, ran)
367 } catch {
368 // the lore never gets in the way of the work
369 }
370
371 return ran
372 })
373
374 // Edits earn runes; an Edit whose old_string is not there is a death too.
375 on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
376 const ran = await next(e)
377
378 try {
379 const killer = killerOfCall(e)
380
381 if (ran.deny !== undefined || e.agentId !== undefined) {
382 return ran
383 }
384
385 if (ran.isError === true) {
386 await die($, killer, false)
387 } else {
388 await prevail($, killer, between(50, 300, rng))
389 }
390 } catch {
391 // the lore never gets in the way of the work
392 }
393
394 return ran
395 }).catch(($, e, next) => next(e))
396
397 // The banner above the prompt.
398 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
399 const shown = await read($, banner)
400
401 if (shown === null || e.props.hasSurvey) {
402 return next(e)
403 }
404
405 const { Box, Text } = $.ui.resolve(e)
406
407 return (
408 <Box
409 flexDirection="column"
410 alignItems="center"
411 width={e.props.bodyColumns}
412 paddingY={1}
413 >
414 <Text bold color={BANNER_COLORS[shown.kind]} size="title">
415 {BANNER_TITLES[shown.kind]}
416 </Text>
417 <Text dimColor>{shown.subtitle}</Text>
418 </Box>
419 )
420 })
421
422 // The spinner and the turn's closing line speak the games' verbs.
423 on('ui.render', { component: 'Spinner' }, ($, e, next) =>
424 next({ ...e, props: { ...e.props, word: hashPick(SPINNER_WORDS, e.props.word) } }),
425 )
426
427 on('ui.render', { component: 'TurnDuration' }, ($, e, next) =>
428 next({
429 ...e,
430 props: {
431 ...e.props,
432 word: hashPick(DURATION_WORDS, `${e.props.word}:${e.props.durationMs}`),
433 },
434 }),
435 )
436
437 // "git gud" is not a prompt. It is a way of life.
438 on('prompt.submit', ($, e, next) => {
439 const text = e.text.trim()
440
441 if (/^git\s+gud[.!]*$/i.test(text)) {
442 return { drop: pick(GIT_GUD_REPLIES, rng) }
443 }
444
445 if (/^praise\s+the\s+sun[.!]*$/i.test(text)) {
446 $.ui.toast('\\[T]/ Praise the Sun!')
447 }
448
449 return next(e)
450 }).catch(($, e, next) => next(e))
451
452 on('command.run', { command: 'bonfire' }, async ($, e) => {
453 await load($)
454
455 const arg = (e.args ?? '').trim().toLowerCase()
456
457 if (arg === 'help' || arg === 'guide') {
458 return { text: GUIDE }
459 }
460
461 if (arg === 'level') {
462 const cost = levelCost(stats.level)
463
464 if (stats.runes < cost) {
465 return {
466 text: `Not enough runes. Level ${stats.level + 1} costs ᚱ ${runesText(cost)}; you hold ᚱ ${runesText(stats.runes)}.`,
467 }
468 }
469
470 await change($, s => {
471 s.runes -= cost
472 s.level += 1
473 })
474
475 return {
476 text: `🔥 Level ${stats.level}. You are now a ${titleOf(stats.level)}. (-ᚱ ${runesText(cost)}, next level costs ᚱ ${runesText(levelCost(stats.level))})`,
477 }
478 }
479
480 if (arg === 'trophies') {
481 const rows = TROPHIES.map(t =>
482 stats.trophies.includes(t.id) ? `- 🏆 **${t.name}**: ${t.hint}` : `- ▫️ ${t.name}: ${t.hint}`,
483 )
484
485 return { text: [`**Trophies** ${stats.trophies.length}/${TROPHIES.length}`, '', ...rows].join('\n') }
486 }
487
488 const isResting = arg === 'rest'
489 const stain = stats.bloodstain
490 ? `ᚱ ${runesText(stats.bloodstain.runes)} at \`${stats.bloodstain.killer}\``
491 : 'none'
492 const verdict =
493 stats.deaths === 0
494 ? 'Not a single death. Suspicious.'
495 : stats.felled >= stats.deaths
496 ? 'You have been getting gud.'
497 : 'Skill issue, but you persevere.'
498
499 const lines = [
500 '🔥 **Bonfire.** You sit by the fire for a moment.',
501 '',
502 '| | |',
503 '| --- | --- |',
504 `| Level | ${stats.level} · ${titleOf(stats.level)} (next: ᚱ ${runesText(levelCost(stats.level))}) |`,
505 `| Streak | ${stats.streak} (×${streakMultiplier(stats.streak)}) |`,
506 `| Trophies | ${stats.trophies.length}/${TROPHIES.length} |`,
507 `| Runes held | ᚱ ${runesText(stats.runes)} |`,
508 `| Deaths | ☠ ${runesText(stats.deaths)} |`,
509 `| Enemies felled | ${stats.felled} (${stats.greatFelled} great) |`,
510 `| Bonfires lit | ${stats.bonfires} |`,
511 `| Bloodstain | ${stain} |`,
512 '',
513 `_${verdict}_`,
514 ]
515
516 if (isResting) {
517 lines.push('', 'You rest. Your context is compacted. The enemies respawn.')
518 void $.session
519 .compact({ instructions: 'Keep the current task, decisions and open problems.' })
520 .then(({ skip }) => {
521 if (skip !== undefined) {
522 $.ui.toast(`The bonfire would not let you rest: ${skip}`)
523 }
524 })
525 .catch(() => $.ui.toast('The bonfire flickers. You could not rest right now.'))
526 }
527
528 return { text: lines.join('\n') }
529 })
530
531 on('command.run', { command: 'soapstone' }, async ($, e) => {
532 const message = (e.args ?? '').trim().slice(0, MAX_MESSAGE_CHARS)
533
534 if (message === '') {
535 return { text: `✎ "${soapstone(rng)}" _(appraised ${appraisals(rng)} times)_` }
536 }
537
538 const stored = (await $.store.get(MESSAGES_KEY).catch(() => [])) as unknown
539 const list = Array.isArray(stored) ? stored.filter(m => typeof m === 'string') : []
540 await $.store.set(MESSAGES_KEY, [...list, message].slice(-MAX_MESSAGES))
541
542 return {
543 text: `✎ You scrawl "${message}" on the ground. A future you may find it.`,
544 }
545 })
546}
547hooks/lore.ts 403 lines1/**
2 * Everything git-gud says, and how it reads a shell command. Pure functions:
3 * no `$`, so the tests can pin them down.
4 */
5
6import type { GitGudStats } from '../types'
7
8export type Rng = () => number
9
10export const pick = <T>(list: readonly T[], rng: Rng): T =>
11 list[Math.floor(rng() * list.length) % list.length] as T
12
13export const between = (min: number, max: number, rng: Rng): number =>
14 min + Math.floor(rng() * (max - min + 1))
15
16/** What the spinner says while Claude works. */
17export const SPINNER_WORDS = [
18 'Kindling',
19 'Rolling',
20 'Panic-rolling',
21 'Fat-rolling',
22 'Parrying',
23 'Backstabbing',
24 'Two-handing',
25 'Praising the sun',
26 'Summoning a phantom',
27 'Farming runes',
28 'Leveling Vigor',
29 'Hollowing',
30 'Reading item descriptions',
31 'Gitting gud',
32 'Chugging estus',
33 'Coping',
34 'Poise-breaking',
35 'Traversing the fog',
36 'Waiting for the elevator',
37 'Grinding',
38 'Respeccing',
39 'Seeking grace',
40 'Trying finger',
41 'Kicking down the ladder',
42 'Checking for mimics',
43 'Hitting the wall (illusory?)',
44 'Jumping with R1',
45 'Spamming the roll button',
46] as const
47
48/** The past-tense word on the line that closes a turn ("Rested for 12s"). */
49export const DURATION_WORDS = [
50 'Rested',
51 'Parried',
52 'Backstabbed',
53 'Kindled',
54 'Persevered',
55 'Survived',
56 'Endured',
57 'Got gud',
58 'Rolled',
59 'Grinded',
60] as const
61
62const TEMPLATES = [
63 '{w} ahead',
64 'No {w} ahead',
65 '{w} required ahead',
66 'Be wary of {w}',
67 'Try {w}',
68 'Likely {w}',
69 'First off, {w}',
70 'Seek {w}',
71 'Still no {w}...',
72 'Why is it always {w}?',
73 'If only I had {w}...',
74 "Didn't expect {w}...",
75 'Visions of {w}...',
76 'Could this be {w}?',
77 'Time for {w}',
78 'Behold, {w}!',
79 'Praise the {w}!',
80 'Let there be {w}',
81 'Ahh, {w}...',
82] as const
83
84const WORDS = [
85 'tests',
86 'a flaky test',
87 'merge conflict',
88 'legacy code',
89 'regex',
90 'null',
91 'undefined',
92 'off-by-one',
93 'production',
94 'Friday deploy',
95 'documentation',
96 'the intern',
97 'rm -rf',
98 'force push',
99 'dependency hell',
100 'YAML',
101 'tech debt',
102 'rubber duck',
103 'coffee',
104 'sleep',
105 'cache invalidation',
106 'node_modules',
107 'senior engineer',
108 'a semicolon',
109 'timezones',
110 'tabs',
111 'spaces',
112 'hotfix',
113 'rollback',
114 'scope creep',
115 'TODO',
116 'console.log',
117 'segfault',
118 'race condition',
119 'green build',
120 'red build',
121 'the README',
122 'a rewrite in Rust',
123 'Stack Overflow',
124 'hole',
125 'finger',
126 'liar',
127 'treasure',
128 'shortcut',
129 'dog',
130 'lever',
131] as const
132
133const CONJUNCTIONS = [
134 ' and then ',
135 ' but ',
136 ', therefore ',
137 ', in short ',
138 ' or ',
139 ', only ',
140 ', by the way, ',
141 ', all the more ',
142] as const
143
144/** A message scrawled on the ground by another Tarnished. */
145export function soapstone(rng: Rng): string {
146 const phrase = () =>
147 pick(TEMPLATES, rng).replace(/\{w\}/g, () => pick(WORDS, rng))
148
149 const first = phrase()
150
151 if (rng() >= 0.3) {
152 return first
153 }
154
155 const second = phrase()
156
157 return first + pick(CONJUNCTIONS, rng) + second[0]?.toLowerCase() + second.slice(1)
158}
159
160/** The rating under a message ("Appraised 412 times"). */
161export const appraisals = (rng: Rng): number => between(0, 999, rng)
162
163/** `1234567` → `1,234,567`, without Intl (the hooks environment may lack it). */
164export function runesText(n: number): string {
165 return String(Math.trunc(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
166}
167
168/**
169 * Splits a shell command at `&&`, `||`, `;` and newlines (and at a lone `|`
170 * when `pipes` is set), leaving quoted operators alone: the `&&` in
171 * `git commit -m "lint && format"` is prose, not a second command.
172 */
173function splitCommand(command: string, pipes: boolean): string[] {
174 const parts: string[] = []
175 let current = ''
176 let quote = ''
177
178 for (let i = 0; i < command.length; i += 1) {
179 const c = command[i] as string
180 const two = command.slice(i, i + 2)
181
182 if (quote !== '') {
183 current += c
184
185 if (c === '\\' && quote === '"') {
186 i += 1
187 current += command[i] ?? ''
188 } else if (c === quote) {
189 quote = ''
190 }
191 } else if (c === '"' || c === "'") {
192 quote = c
193 current += c
194 } else if (two === '&&' || two === '||') {
195 parts.push(current)
196 current = ''
197 i += 1
198 } else if (c === ';' || c === '\n' || (pipes && c === '|')) {
199 parts.push(current)
200 current = ''
201 } else {
202 current += c
203 }
204 }
205
206 parts.push(current)
207
208 return parts.map(part => part.trim()).filter(part => part !== '')
209}
210
211/** The last segment of a compound command: `cd app && npm test` → `npm test`. */
212export function lastSegment(command: string): string {
213 const parts = splitCommand(command, false)
214
215 return parts[parts.length - 1] ?? command.trim()
216}
217
218/** The first stage of a pipeline: `grep foo . | head` → `grep foo .`. */
219const firstStage = (segment: string): string =>
220 splitCommand(segment, true)[0] ?? segment
221
222/**
223 * The name a death is filed under: the first two words of the command that
224 * ran last, so `npm test -- --watch=false` and `npm test` are the same foe.
225 */
226export function killerOf(command: string): string {
227 return lastSegment(command).split(/\s+/).slice(0, 2).join(' ')
228}
229
230/** Commands whose failure is an answer, not a death: grep found nothing. */
231const PROBE =
232 /^(sudo\s+)?(grep|egrep|fgrep|rg|ag|find|fd|test|\[|\[\[|ls|which|command|type|diff|cmp|stat|file|pgrep|pidof|head|tail|wc|git\s+(diff|grep|status|log|show|rev-parse|ls-files|branch|remote|config))\b/
233
234const TESTS =
235 /\b(jest|vitest|mocha|pytest|rspec|phpunit|ctest|tox|nox|karma|cypress\s+run|playwright\s+test|(cargo|go|deno|bun|mix|dotnet|swift|gradle|mvn|make|rake)\s+test|(npm|pnpm|yarn|bun)\s+(run\s+)?test[\w:-]*|python3?\s+-m\s+(pytest|unittest))\b/
236
237const COMMIT = /\bgit\s+commit\b/
238
239/** Commands that only talk: what they print is never a deed. */
240const SPEECH = /^(sudo\s+)?(echo|printf)\b/
241
242const FOG_WALL =
243 /\bgit\s+push\b.*(\s(--force(-with-lease)?|-f)\b|\s\+\S)|\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-zA-Z]*f|\bdrop\s+(table|database|schema)\b|\bkubectl\s+delete\b|\bterraform\s+destroy\b/i
244
245export type CommandKind = 'tests' | 'commit' | 'probe' | 'other'
246
247/** What the command that ran last was: earlier segments and prose do not count. */
248export function kindOf(command: string): CommandKind {
249 const segment = lastSegment(command)
250
251 if (SPEECH.test(segment)) {
252 return 'other'
253 }
254
255 if (TESTS.test(segment)) {
256 return 'tests'
257 }
258
259 if (COMMIT.test(segment)) {
260 return 'commit'
261 }
262
263 return PROBE.test(firstStage(segment)) ? 'probe' : 'other'
264}
265
266/** `rm` with both a recursive and a force flag, in any spelling. */
267function isRmRf(segment: string): boolean {
268 const words = segment.split(/\s+/)
269 const at = words.findIndex(w => w === 'rm' || w.endsWith('/rm'))
270
271 if (at === -1) {
272 return false
273 }
274
275 const flags = words.slice(at + 1).filter(w => w.startsWith('-'))
276 const isRecursive = flags.some(f => f === '--recursive' || /^-[a-zA-Z]*[rR]/.test(f))
277 const isForced = flags.some(f => f === '--force' || /^-[a-zA-Z]*f/.test(f))
278
279 return isRecursive && isForced
280}
281
282/** A command past which there is no return: a fog wall. */
283export const isFogWall = (command: string): boolean =>
284 splitCommand(command, false).some(
285 segment => !SPEECH.test(segment) && (isRmRf(segment) || FOG_WALL.test(segment)),
286 )
287
288/** Runes needed to rise from `level` to the next. */
289export const levelCost = (level: number): number => 500 * level
290
291/** Runes multiplier for a streak: +25% per five successes, capped at double. */
292export const streakMultiplier = (streak: number): number =>
293 1 + Math.min(Math.floor(streak / 5), 4) * 0.25
294
295const TITLES = [
296 [16, 'Elden Lord'],
297 [11, 'Champion'],
298 [7, 'Knight'],
299 [4, 'Tarnished'],
300 [2, 'Wanderer'],
301 [1, 'Hollow'],
302] as const
303
304/** What a level makes you. */
305export const titleOf = (level: number): string =>
306 TITLES.find(([min]) => level >= min)?.[1] ?? 'Hollow'
307
308export type Trophy = {
309 id: string
310 name: string
311 /** What to do to earn it. */
312 hint: string
313 earned: (s: GitGudStats) => boolean
314}
315
316export const TROPHIES: readonly Trophy[] = [
317 { id: 'first-blood', name: 'First Blood', hint: 'Die once.', earned: s => s.deaths >= 1 },
318 { id: 'fall-guy', name: 'Fall Guy', hint: 'Die three times in a row.', earned: s => s.deathStreak >= 3 },
319 { id: 'git-gud', name: 'Git Gud', hint: 'Light 10 bonfires.', earned: s => s.bonfires >= 10 },
320 { id: 'great-hunter', name: 'Great Hunter', hint: 'Fell a great enemy.', earned: s => s.greatFelled >= 1 },
321 { id: 'untouchable', name: 'Untouchable', hint: 'Win 20 in a row without dying.', earned: s => s.streak >= 20 },
322 { id: 'fog-diver', name: 'Fog Diver', hint: 'Survive a fog wall.', earned: s => s.fogsCrossed >= 1 },
323 { id: 'ascended', name: 'Ascended', hint: 'Reach level 5.', earned: s => s.level >= 5 },
324]
325
326/** Trophies the stats now qualify for that are not yet recorded. */
327export const newTrophies = (s: GitGudStats): Trophy[] =>
328 TROPHIES.filter(t => !s.trophies.includes(t.id) && t.earned(s))
329
330/** The in-game manual, shown by `/bonfire help`. Markdown. */
331export const GUIDE = `**git-gud: how to play**
332
333**The status line**
334\`Lv 3 · ᚱ 1,240 · ☠ 12 · 🩸 ᚱ 300 to recover · 🔥 ×1.25\`
335- \`Lv\`: your level. 🔥 \`×\`: your streak bonus (only shown above ×1).
336- \`ᚱ 1,240\`: your runes. Runes are the game's points: you earn them by working.
337- \`☠ 12\`: how many times you have died, ever (it only goes up).
338- \`🩸 ᚱ 300 to recover\`: runes you dropped when you last died, waiting to be picked up again.
339
340**Runes: points you can lose**
341How you win them:
342- Edit or Write succeeds: ᚱ 50–300
343- Tests pass: ᚱ 800–1,200
344- Tests pass after they were failing (GREAT ENEMY): ᚱ 2,000–3,000
345- \`git commit\`: ᚱ 400–600
346- Any other command that succeeds: ᚱ 10–60
347- A probing command that succeeds (\`grep\`, \`ls\`…): ᚱ 1–5
348- Your streak multiplies all of these (×1 up to ×2). Subagents' work earns nothing.
349
350How you lose them:
351- Runes are at risk. If a command **fails**, you **die** (YOU DIED) and every rune you hold is dropped.
352
353**The bloodstain: getting them back**
354Think of it as a lost wallet. When you die, the runes you dropped are left at a bloodstain (the name comes from Dark Souls), tagged with the command that killed you.
355- Succeed at **that same command** and you pick the runes back up.
356- Die again before you do and that stash is gone for good. Only one stash exists at a time.
357- Example: \`npm test\` fails while you hold 800 runes. You drop all 800 (🩸 ᚱ 800 to recover). Fix the bug, run \`npm test\` green, and the 800 come back, plus the reward for passing.
358- Probing commands (\`grep\`, \`ls\`, \`git diff\`…) don't kill you unless hardcore mode is on.
359
360**Enemies and bonfires**
361- Tests pass: **ENEMY FELLED**. If they were failing before: **GREAT ENEMY FELLED**.
362- \`git commit\`: **BONFIRE LIT**.
363- Destructive commands (\`rm -rf\`, \`git push --force\`…) raise a fog wall. Surviving one counts.
364
365**Streaks**
366Every success in a row adds to your streak: +25% runes per five, up to ×2. Any death resets it.
367
368**Levels**
369\`/bonfire level\` spends runes to rise a level (the cost grows each time) and earns you a title.
370
371**Trophies**
372\`/bonfire trophies\` lists seven achievements and how to earn them.
373
374**Commands**
375- \`/bonfire\`: your stats. \`/bonfire level\`, \`/bonfire trophies\`, \`/bonfire help\`.
376- \`/bonfire rest\`: compacts the conversation (the enemies respawn).
377- \`/soapstone\`: read a message. \`/soapstone <text>\`: leave one for a future you.
378
379**Options** (in \`/config\`): \`hardcore\` (every failure kills), \`speak\` (say "You died" aloud), \`messages\` (a soapstone message at session start).`
380
381/** What a prompt of nothing but "git gud" is told. */
382export const GIT_GUD_REPLIES = [
383 'Thou art already gud. Prompt dropped.',
384 'There is no "git gud" command. There is only "git gud". (Prompt dropped.)',
385 'You whisper "git gud" into the void. The void says: skill issue.',
386] as const
387
388/** The spaced-out titles of each banner. */
389export const BANNER_TITLES = {
390 died: 'Y O U D I E D',
391 felled: 'E N E M Y F E L L E D',
392 great: 'G R E A T E N E M Y F E L L E D',
393 bonfire: 'B O N F I R E L I T',
394} as const
395
396/** Raw colors in the games' spirit: blood red, rune gold, ember orange. */
397export const BANNER_COLORS = {
398 died: '#b3121b',
399 felled: '#d8b25a',
400 great: '#e8c46a',
401 bonfire: '#e07b2a',
402} as const
403types/index.d.ts 47 lines1/** Which banner is on screen: the big centred line above the prompt. */
2export type GitGudBannerKind = 'died' | 'felled' | 'great' | 'bonfire'
3
4/** The banner above the prompt, shown for a few seconds then cleared. */
5export type GitGudBanner = {
6 /** Increments per banner, so a stale timer never clears a newer one. */
7 id: number
8 kind: GitGudBannerKind
9 /** The dim line under the title (what killed you, runes gained). */
10 subtitle: string
11}
12
13/** Runes dropped on death, waiting where you fell. */
14export type GitGudBloodstain = {
15 runes: number
16 /** The command that killed you: succeed at it again to retrieve them. */
17 killer: string
18}
19
20/** Lifetime stats, kept in `$.store` across sessions. */
21export type GitGudStats = {
22 deaths: number
23 runes: number
24 felled: number
25 greatFelled: number
26 bonfires: number
27 bloodstain: GitGudBloodstain | null
28 /** The last test run died: the next green one is a GREAT enemy felled. */
29 testsFailing: boolean
30 /** Raised by spending runes at `/bonfire level`. Starts at 1. */
31 level: number
32 /** Successes in a row; each five add 25% to runes earned (max 2x). */
33 streak: number
34 /** Deaths in a row, reset by any success. */
35 deathStreak: number
36 /** Fog-wall commands that were survived. */
37 fogsCrossed: number
38 /** Ids of earned trophies. */
39 trophies: string[]
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'git-gud': { banner: GitGudBanner | null }
45 }
46}
47