SLOPSHOPPER

quest-board

Three daily quests from the repo's TODOs, open issues and failing tests, in a pane, with a toast for each one cleared

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-03sivori/claude-mods/plugins/quest-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quest-board
│ ┃ Daily quests ✕ › fix the failing auth test a╭─────────────────────────────────────╮ │ ┃ app · 2025-10-09 │ quest-board │ │ ┃ ⏺ Read(src/auth.ts) │ ⚔ New quest: Get `bun test` passing │ │ ┃ ☐ TEST Get `bun test` passing ⎿ Read 6 lines ╰─────────────────────────────────────╯ │ ┃ bun test ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 0/1 done [ Reroll ][ Check ] ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /quests │ ⎿ quest-board: Quest board open: 0/1 done today. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ quest-board: ⚔ quests 0/1

Draws

Pane · Daily quests
app · 2025-10-09 ☐ TEST Get `bun test` passing bun test 0/1 done [ Reroll ][ Check ]
README

claude-mods

Small mods for Claude Code: status lines, a band above the prompt, a docked pane, and a radio. Each one is a plugin of function hooks.

radio, backlog-band, spend-meter and commit-drift in a Claude Code terminal (illustration)

Install

claude plugin marketplace add sivori/claude-mods
claude plugin install radio@claude-mods        # or any mod below

Update later with claude plugin marketplace update claude-mods.

To try one without installing, clone the repo and run claude --plugin-dir ./plugins/<mod>.

The mods

radio

/tune streams internet radio inside Claude Code: SomaFM, lo-fi, classical and choral. (The command is /tune because Claude Code has a built-in /radio.) All your sessions share one player, so music started in two sessions never plays twice. Switching stations in any session replaces the current stream, and the status line shows what's playing everywhere. While something plays, a row of buttons sits above the prompt: ◀ ■ ▶ to change or stop, − + for volume. Stopping keeps the row, with a play button that resumes the last station.

/tune              toggle (resumes the last station); /tune listen just plays
/tune groove       pick by name, genre ("jazz", "choral") or number
/tune next | prev | stop | list
/tune vol 30       also vol +10 / vol -10
/tune band         hide or show the buttons

Plays through mpv (brew install mpv), which changes volume without a gap; otherwise ffplay from ffmpeg, which restarts the stream on each volume change.

By default the music keeps playing after you close Claude Code, until /tune stop. Turn on stopWithLastSession in /config to have it stop once no session is open; a watchdog catches a crashed session too, within about a minute.

spend-meter

Session cost, context fill and 5-hour rate-limit use in the status line ($1.23 · ctx 42% · 5h 24%). It toasts once as spend passes $5, $10, $25, $50 and $100.

commit-drift

Uncommitted file count and time since the last commit in the status line, for whichever repo you're editing. After an editing turn on a tree that hasn't been committed for 30+ minutes, it nudges you (at most every 10 minutes).

backlog-band

Shows the open ## Now items of the active repo's BACKLOG.md above the prompt. /later <item> appends to ## Next, and /backlog-band hides or shows the band. It expects this format:

## Now
- [ ] Blocking or imminent work

## Next
- [ ] The normal queue

## Someday
- [ ] Ideas

## Done
- [x] 2026-08-01 Completed

day-recap

So you never start cold. While you work it records your prompts, the files edited and the commits made. Once the session has been quiet for 10 minutes, it asks the model for a two-line recap (what got done, the next step) and saves it per repo. When you next open Claude Code in that repo, the recap sits above the prompt until your first message:

↩ Last time in dash · yesterday 5:12pm
  Done: Added the spend chart and matched its colors.
  Next: Write a test for the empty-data case.

/recap writes it now, and /recap last brings back the one from last time. A session that did nothing leaves the earlier recap in place. If you quit before the recap is written, the next session writes it from the recorded activity with Haiku.

quest-board

Three daily quests in a pane, drawn from the active repo: a TODO/FIXME comment to resolve, an open GitHub issue to close (needs gh), and a test command that failed to get passing. It picks one of each kind when it can, keeps the same board all day, and draws a new one tomorrow. A quest clears by itself when the TODO line is gone, the issue is closed or the test command passes. Each cleared quest gets a toast, and clearing all three gets a bigger one. The status line shows ⚔ quests 1/3.

/quests             open the board
/quests reroll      swap the unfinished quests for new ones
/quests check       re-check the issues now (otherwise every 10 minutes)

commonplace-pane

A docked pane of public-domain paintings from the Art Institute of Chicago. Its "weather" follows your repo: calm landscapes while the tree is clean, overcast as changes pile up or commands fail, shipwrecks in a storm. The painting rotates every 20 minutes, and /commonplace next hangs a new one.

The images need a terminal with the kitty graphics protocol (kitty, Ghostty); elsewhere the pane shows the title and artist. It uses macOS sips to convert images. Paintings are cached in ~/.cache/commonplace-pane/, which keeps the newest 30 and deletes the rest after each new painting. Two optional settings live in /config:

  • contact: an email or URL sent in the museum's AIC-User-Agent header, as the Art Institute asks of API clients.
  • seedDir: a folder of seed-*.jpg paintings to fall back on when the museum is unreachable.

Developing

Each mod is plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and a hooks/register.ts(x) module. To check one:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

License

MIT

Source 2 files
hooks/register.tsx 312 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Quest, QuestKind } from '../types'
5
6const board = atom({ plugin: 'quest-board', key: 'board' } as const, null)
7
8const PANE = 'quest-board'
9const QUESTS_PER_DAY = 3
10// Issues are checked over the network: no more often than this.
11const ISSUE_CHECK_MS = 10 * 60_000
12const TODO = /\b(TODO|FIXME|HACK|XXX)\b[:(\s-]*(.*)$/
13const TEST_COMMAND =
14  /\b((npm|pnpm|yarn|bun)\s+(run\s+)?test|npx\s+(jest|vitest)|pytest|go\s+test|cargo\s+test|make\s+test|rspec|mix\s+test|vitest|jest|claude\s+plugin\s+test)\b/
15const LABEL: Record<QuestKind, string> = { todo: 'TODO ', issue: 'ISSUE', test: 'TEST ' }
16const CHEERS = ['Nice.', 'Clean hit.', 'Textbook.', 'Another one.', 'Momentum!']
17
18/** The local day as `YYYY-MM-DD`. */
19export function dayOf(ms: number): string {
20  const d = new Date(ms)
21  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
22}
23
24/** The test invocation inside a shell command (`cd x && npm test -- -u` → `npm test -- -u`). */
25export function testCommandOf(command: string): string | undefined {
26  const part = command.split(/&&|\|\||;|\|/).find(one => TEST_COMMAND.test(one))
27  return part?.trim().replace(/\s+/g, ' ').slice(0, 80)
28}
29
30/** `git grep -n` lines (`file:line:text`) to TODO quests. */
31export function parseTodos(stdout: string): Quest[] {
32  const quests: Quest[] = []
33  for (const line of stdout.split('\n')) {
34    const at = /^([^:]+):(\d+):(.*)$/.exec(line)
35    const found = at && TODO.exec(at[3] ?? '')
36    if (!at || !found) continue
37    const text = (found[2] ?? '').replace(/\s*(\*\/|-->|#\})\s*$/, '').trim()
38    if (text.length < 4) continue
39    const [, file = '', lineNo = ''] = at
40    quests.push({
41      id: `todo:${file}:${text}`,
42      kind: 'todo',
43      title: text.length > 70 ? `${text.slice(0, 69)}…` : text,
44      where: `${file}:${lineNo}`,
45      needle: (at[3] ?? '').trim(),
46      isDone: false,
47    })
48  }
49  return quests
50}
51
52function hash(text: string): number {
53  let h = 2166136261
54  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
55  return h >>> 0
56}
57
58/**
59 * Up to `count` quests: one of each kind first (a failing test, then an issue,
60 * then a TODO), the rest round-robin; the same seed draws the same quests.
61 */
62export function pick(pool: Quest[], seed: string, count = QUESTS_PER_DAY): Quest[] {
63  const kinds: QuestKind[] = ['test', 'issue', 'todo']
64  const groups = kinds.map(kind =>
65    pool
66      .filter(quest => quest.kind === kind)
67      .sort((a, b) => hash(seed + a.id) - hash(seed + b.id)),
68  )
69  const picked: Quest[] = []
70  while (picked.length < count && groups.some(group => group.length > 0)) {
71    for (const group of groups) {
72      const next = group.shift()
73      if (next !== undefined && picked.length < count) picked.push(next)
74    }
75  }
76  return picked
77}
78
79async function run($: EngineInterface, argv: string[], cwd: string) {
80  return $.process.run(argv, { cwd, timeoutMs: 15_000 }).catch(() => undefined)
81}
82
83async function gather($: EngineInterface, root: string): Promise<Quest[]> {
84  const todos = await run($, ['git', 'grep', '-n', '-I', '-E', '\\b(TODO|FIXME|HACK|XXX)\\b'], root)
85  const issues = await run($, ['gh', 'issue', 'list', '--state', 'open', '--limit', '30', '--json', 'number,title'], root)
86  const failing = ((await $.store.get(`failing:${root}`)) as string[] | undefined) ?? []
87
88  const pool = todos?.exitCode === 0 ? parseTodos(todos.stdout).slice(0, 300) : []
89  if (issues?.exitCode === 0) {
90    try {
91      for (const issue of JSON.parse(issues.stdout) as { number: number; title: string }[]) {
92        pool.push({ id: `issue:${issue.number}`, kind: 'issue', title: issue.title, where: `#${issue.number}`, isDone: false })
93      }
94    } catch {
95      // gh printed something other than JSON: no issue quests today.
96    }
97  }
98  for (const command of failing) {
99    pool.push({ id: `test:${command}`, kind: 'test', title: `Get \`${command}\` passing`, where: command, isDone: false })
100  }
101  return pool
102}
103
104// The repo the board follows. A module variable: a reload re-reads it from state.
105let root: string | undefined
106let lastIssueCheck = -Infinity
107
108async function save($: EngineInterface, next: Board) {
109  await update($, board, () => next)
110  await $.store.set(`board:${next.root}`, next)
111  const done = next.quests.filter(quest => quest.isDone).length
112  $.ui.status(next.quests.length === 0 ? undefined : `⚔ quests ${done}/${next.quests.length}`)
113}
114
115async function celebrate($: EngineInterface, before: Board, after: Board) {
116  const wasDone = new Set(before.quests.filter(quest => quest.isDone).map(quest => quest.id))
117  const fresh = after.quests.filter(quest => quest.isDone && !wasDone.has(quest.id))
118  if (fresh.length === 0) return
119  const done = after.quests.filter(quest => quest.isDone).length
120  const total = after.quests.length
121  if (done === total) {
122    $.ui.toast(`🎉 All ${total} daily quests cleared! The board refills tomorrow.`, { timeoutMs: 10_000 })
123    return
124  }
125  const cheer = CHEERS[hash(fresh[0]?.id ?? '') % CHEERS.length]
126  for (const quest of fresh) {
127    $.ui.toast(`🏆 Quest complete: ${quest.title} (${done}/${total}). ${cheer}`, { timeoutMs: 7000 })
128  }
129}
130
131/** Today's board for `at`: kept when drawn today, else a fresh draw. */
132async function load($: EngineInterface, at: string) {
133  const today = dayOf(await $.clock.now())
134  const kept = (await $.store.get(`board:${at}`)) as Board | undefined
135  if (kept?.day === today) {
136    await save($, kept)
137    return
138  }
139  const quests = pick(await gather($, at), `${today}:${at}`)
140  await save($, { day: today, root: at, project: at.split('/').pop() ?? at, quests })
141}
142
143/** Marks TODOs that are gone and issues that are closed. */
144async function check($: EngineInterface, force = false) {
145  const current = await read($, board)
146  if (current === null) return
147  const now = await $.clock.now()
148  const checkIssues = force || now - lastIssueCheck >= ISSUE_CHECK_MS
149  if (checkIssues) lastIssueCheck = now
150
151  const quests: Quest[] = []
152  for (const quest of current.quests) {
153    if (quest.isDone) {
154      quests.push(quest)
155    } else if (quest.kind === 'todo' && quest.needle !== undefined) {
156      const file = quest.where.replace(/:\d+$/, '')
157      const found = await run($, ['git', 'grep', '-q', '-F', '-e', quest.needle, '--', file], current.root)
158      quests.push({ ...quest, isDone: found?.exitCode === 1 })
159    } else if (quest.kind === 'issue' && checkIssues) {
160      const seen = await run($, ['gh', 'issue', 'view', quest.where.slice(1), '--json', 'state', '-q', '.state'], current.root)
161      quests.push({ ...quest, isDone: seen?.exitCode === 0 && seen.stdout.trim() === 'CLOSED' })
162    } else {
163      quests.push(quest)
164    }
165  }
166  const next = { ...current, quests }
167  await save($, next)
168  await celebrate($, current, next)
169}
170
171/** Swaps the unfinished quests for new ones from today's pool. */
172async function reroll($: EngineInterface): Promise<number> {
173  const current = await read($, board)
174  if (current === null) return 0
175  const kept = current.quests.filter(quest => quest.isDone)
176  const old = new Set(current.quests.map(quest => quest.id))
177  const pool = (await gather($, current.root)).filter(quest => !old.has(quest.id))
178  const seed = `${current.day}:${current.root}:${await $.clock.now()}`
179  const drawn = pick(pool, seed, QUESTS_PER_DAY - kept.length)
180  await save($, { ...current, quests: [...kept, ...drawn] })
181  return drawn.length
182}
183
184/** A test command ran: a failure is remembered (and can become a quest), a pass can clear one. */
185async function sawTest($: EngineInterface, command: string, passed: boolean) {
186  if (root === undefined) return
187  const key = `failing:${root}`
188  const failing = ((await $.store.get(key)) as string[] | undefined) ?? []
189  const rest = failing.filter(one => one !== command)
190  await $.store.set(key, passed ? rest : [...rest, command].slice(-10))
191
192  const current = await read($, board)
193  if (current === null || current.root !== root) return
194  const id = `test:${command}`
195  const has = current.quests.some(quest => quest.id === id)
196  if (passed && has) {
197    const next = {
198      ...current,
199      quests: current.quests.map(quest => (quest.id === id ? { ...quest, isDone: true } : quest)),
200    }
201    await save($, next)
202    await celebrate($, current, next)
203  } else if (!passed && !has && current.quests.length < QUESTS_PER_DAY) {
204    const quest: Quest = { id, kind: 'test', title: `Get \`${command}\` passing`, where: command, isDone: false }
205    await save($, { ...current, quests: [...current.quests, quest] })
206    $.ui.toast(`⚔ New quest: ${quest.title}`)
207  }
208}
209
210export const register: Register = on => {
211  on('session.start', async ($, e, next) => {
212    await $.command.register({
213      name: 'quests',
214      description: "Open today's quest board; /quests reroll swaps the unfinished ones",
215      argumentHint: '[reroll | check]',
216    })
217    const started = await next(e)
218    root = (await $.session.repo())?.root
219    if (root === undefined) return started
220    await load($, root)
221    $.clock.after(0, () => void check($, true))
222    const current = await read($, board)
223    if (current?.quests.some(quest => !quest.isDone)) {
224      void $.ui.open({ id: PANE, title: 'Daily quests' })
225    }
226    return started
227  })
228
229  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
230    const ran = await next(e)
231    const command = testCommandOf(e.command)
232    if (command === undefined || ran.deny !== undefined) return ran
233    if (ran.isError) {
234      await sawTest($, command, false)
235    } else if (ran.result.backgroundTaskId === undefined && !ran.result.interrupted) {
236      await sawTest($, command, true)
237    }
238    return ran
239  })
240
241  on('turn.complete', async ($, e, next) => {
242    const done = await next(e)
243    if (e.agentId !== undefined || root === undefined) return done
244    const current = await read($, board)
245    // Past midnight: tomorrow's board.
246    if (current !== null && current.day !== dayOf(await $.clock.now())) await load($, root)
247    await check($)
248    return done
249  })
250
251  on('command.run', { command: 'quests' }, async ($, e) => {
252    if (root === undefined) return { text: 'Quests need a git repo; this session has none.' }
253    const arg = e.args.trim()
254    if (arg === 'reroll') {
255      const drawn = await reroll($)
256      return { text: drawn === 0 ? 'Nothing new to draw.' : `Drew ${drawn} new quest${drawn === 1 ? '' : 's'}.` }
257    }
258    if (arg === 'check') await check($, true)
259    await $.ui.open({ id: PANE, title: 'Daily quests' })
260    const current = await read($, board)
261    const done = current?.quests.filter(quest => quest.isDone).length ?? 0
262    return { text: `Quest board open: ${done}/${current?.quests.length ?? 0} done today.` }
263  })
264
265  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266    const { Box, Button, Text } = $.ui.resolve(e)
267    const current = await read($, board)
268
269    if (current === null || current.quests.length === 0) {
270      return (
271        <Box flexDirection="column">
272          <Text dimColor>No quests today: no TODOs, open issues or failing tests found.</Text>
273          <Text dimColor>Enjoy the clean slate.</Text>
274        </Box>
275      )
276    }
277
278    const done = current.quests.filter(quest => quest.isDone).length
279    const isCleared = done === current.quests.length
280
281    return (
282      <Box flexDirection="column">
283        <Text bold wrap="truncate-end">
284          {current.project} · {current.day}
285        </Text>
286        {current.quests.map(quest => (
287          <Box key={quest.id} flexDirection="column" marginTop={1}>
288            <Text wrap="truncate-end" dimColor={quest.isDone} strikethrough={quest.isDone}>
289              {quest.isDone ? '☑ ' : '☐ '}
290              <Text color={quest.kind === 'test' ? 'red' : quest.kind === 'issue' ? 'cyan' : 'yellow'}>
291                {LABEL[quest.kind]}
292              </Text>{' '}
293              {quest.title}
294            </Text>
295            <Text dimColor wrap="truncate-start">
296              {'    '}
297              {quest.where}
298            </Text>
299          </Box>
300        ))}
301        <Box marginTop={1}>
302          <Text color={isCleared ? 'green' : undefined} bold={isCleared}>
303            {isCleared ? '🎉 Board cleared! ' : `${done}/${current.quests.length} done `}
304          </Text>
305          {!isCleared && <Button key="reroll" label="Reroll" onPress={() => void reroll($)} />}
306          <Button key="check" label="Check" onPress={() => void check($, true)} />
307        </Box>
308      </Box>
309    )
310  })
311}
312
types/index.d.ts 29 lines
1export type QuestKind = 'todo' | 'issue' | 'test'
2
3export type Quest = {
4  /** Stable within a day: `todo:<file>:<text>`, `issue:<n>`, `test:<command>`. */
5  id: string
6  kind: QuestKind
7  /** What the board shows. */
8  title: string
9  /** Where it lives: `file:line`, `#42`, or the test command. */
10  where: string
11  /** The TODO line's trimmed text, matched to tell when it is gone. */
12  needle?: string
13  isDone: boolean
14}
15
16export type Board = {
17  /** The local day the quests were drawn for, `YYYY-MM-DD`. */
18  day: string
19  root: string
20  project: string
21  quests: Quest[]
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'quest-board': { board: Board | null }
27  }
28}
29