SLOPSHOPPER

git-gud

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…

newbandspinnerguardcommandtoast
v0.1.0MITupdated 2026-10-14enriquesanchez-elastic/git-gud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-gud
› fix the failing auth test and add an audit log call ╭──────────────────────────────────╮ │ git-gud │ ⏺ Read(src/auth.ts) │ 🏆 Trophy: First Blood. Die │ ⎿ Read 6 lines │ once. │ ⏺ Update(src/auth.ts) ╰──────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ git-gud │ ⎿ 3 pass, 1 fail │ 🏆 Trophy: Fog Diver. Survive a fog │ │ wall. │ ● Done. refresh now rejects expired claims and logs an audit event. ╰──────────────────────────────────────────╯ ✻ Got gud for 42s · done 4:20 PM › /bonfire ⎿ git-gud: 🔥 **Bonfire.** You sit by the fire for a moment. ⎿ git-gud: ⎿ git-gud: | | | ⎿ git-gud: | --- | --- | ⎿ git-gud: | Level | 1 · Hollow (next: ᚱ 500) | ⎿ git-gud: | Streak | 3 (×1) | ✻ Traversing the fog… Y O U D I E D slain by bun test · ᚱ 467 dropped ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ git-gud: Lv 1 · ᚱ 59 · ☠ 1 · 🩸 ᚱ 467 to recover

Draws

Band
Y O U D I E D slain by bun test · ᚱ 467 dropped
README

git-gud

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

What it does

WhenWhat happens
A command failsYOU 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 youYou pick the runes back up. Die again first and the old one is lost forever.
Claude edits a fileRunes. An Edit whose old_string isn't there is a death too.
Tests passENEMY FELLED. If they were failing before: GREAT ENEMY FELLED and a pile of runes.
git commitBONFIRE LIT. Checkpoint saved.
A destructive command (rm -rf, git push --force, git reset --hard, DROP TABLE…) asks permissionA fog wall appears under the dialog.
You deny a tool callParried!
A subagent's command failsYour summoned phantom has died.
Claude is thinkingThe spinner is Panic-rolling, Praising the sun, Reading item descriptions, *Waiting for the elevator*…
A turn endsRested for 12s, Parried for 3s, Got gud for 1m 4s
A session startsA message from another Tarnished: "Be wary of Friday deploy, therefore try rubber duck" (appraised 412 times)
You type git gud as a promptIt'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.

New here?

Type /bonfire help in a session for the full guide. The short version:

  1. Work normally. Edits and commands earn runes (ᚱ).
  2. When something fails you die and drop your runes at a bloodstain (🩸).
  3. Succeed at the same command that killed you to get them back.
  4. Win several in a row to build a 🔥 streak. Spend runes on levels with /bonfire level.

Commands

  • /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.

Install

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.

Options

Set them in /config (each is a row there) or under pluginConfigs["git-gud"].options in ~/.claude/settings.json.

OptionDefault
hardcorefalseEvery failed command kills you, even probes like grep, test and git diff.
speakfalseSay "You died" with the system voice (macOS say). Deeply unnecessary. Recommended.
messagestrueA soapstone message when each session starts.

Developing

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.

Fine print

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.

Source 3 files
hooks/register.tsx 547 lines
1import { 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}
547
hooks/lore.ts 403 lines
1/**
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
403
types/index.d.ts 47 lines
1/** 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