SLOPSHOPPER

recipe-card

Spots fresh solves worth keeping and offers /recipe to save them to the skills-os library.

newbandrowscommand
v0.1.0no licenseupdated 2026-10-07wpvip-awesomeface/claude-mods/recipe-card
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recipe-card
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /recipe ⎿ recipe-card: Writing up the recipe card… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

The mods I run in Claude Code, each in its own folder. Clone this repo and run one script to get the same setup on any machine.

Mods are small plugins that add live panes, status bands and slash commands to Claude Code. They hot-reload when you edit them.

Install

git clone https://github.com/wpvip-awesomeface/claude-mods.git ~/.claude/mods
~/.claude/mods/install.sh

Then start a new Claude Code session. Add --all to also turn on the personal mods in fun/ (that's the full backup restore).

The script:

  • turns the core mods on for new sessions (via CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json)
  • turns on hot reload (CLAUDE_CODE_PLUGIN_DIR_WATCH=1)
  • links the mods command into ~/.local/bin

Turn mods on or off

mods                 # list mods and whether they're on
mods on bento        # load one in every new session
mods off workbench   # stop loading it
mods on all          # the core mods
mods on fun          # the personal mods in fun/

The mods

ModWhat you seeSaves tokens?
bentoA band above the prompt showing context use, turns and subagentsYes, directly
turn-timer"Speed Radar": how long each reply tookIndirectly
recipe-cardA card under each answer showing the route line, with /recipe to save a solveYes, over time
workbench (in fun/)A side pane with your open PRs, next meeting, pizza and a dinoNo, it's for focus and fun

bento

What: a lunchbox band above the prompt. The box fills as context grows and shows context %, tokens, turn count and how many subagents ("helpers") the session has started.

Why: cost is roughly turns × context size, and both are invisible by default. Bento makes them visible, and warns you once at 70% context or 60 turns that it's time to start fresh.

Commands:

  • /doggybag or /handoff: writes a 5–10 line handoff (goal, state, links, next step) and copies it to your clipboard. It runs as one tool-less fork over the cached transcript, so it's cheap. Paste it into a new session.
  • /hush: hides every mod at once, for screenshares and recordings.
  • /bento: shows or hides the band.

turn-timer (Speed Radar)

What: shows how long each reply took (Speed Radar: ⏱ 2m 15s), drawn above the route line.

Why: slow replies are usually long, tool-heavy turns. Seeing the time makes it easy to spot when a session is dragging and should be split up. It stays visible during /hush, since it's safe to show in demos.

recipe-card

What: reads the route line at the end of each answer (◎ shape · † graveyard · ◫ library · ≡ rung · ✦ extract) and draws it as a card. When an answer was a fresh solve worth keeping, it offers /recipe to save it as a reusable tool.

Why: the cheapest task is the one you've already solved. Saving solves to a library, and dead ends to a graveyard, keeps future sessions from paying to re-solve them.

Needs: a skills library that uses the route line. Without one, the card has nothing to read.

workbench (fun)

Heads up: this is a personal mod, built around the author's own workflow (their demo sites, calendar and sense of humor). Most people won't need or want it. It's here so the author's full setup is backed up, and as an example if you want to build your own.

It lives in fun/, and the installer leaves it off unless you pass --all or run mods on workbench.

What: a side pane with:

  • greg: your open PRs, plus PRs merged in the last 3 hours, checked every 3 minutes
  • birdy: a countdown to your next meeting, a nudge 10 minutes before, and /prep to get ready for it
  • linkydoos: one-click links to each demo site, repo and its open PRs
  • fun mode (/fun, off by default): a quote of the day, pizza and mochi the dino

Why: keeps PR status and your calendar in view, so you don't spend turns asking Claude to check them.

Needs: the gh CLI signed in, and a Google Calendar connector for birdy. The demo links and the company email domain are set at the top of fun/workbench/hooks/register.tsx, so change those for your own setup. Edit the quote list with /quotes.

Commands: /workbench, /greg-check, /prep, /quotes, /fun.

Token kit

Not a mod, but it lives here too: token-kit/ has the CLAUDE.md rules and a read guard hook that blocks whole-file reads over 400 lines once and returns an outline. To have an agent set all of it up, point it at AGENT_SETUP.md.

Notes

  • Each mod's code is in <mod>/hooks/register.tsx.
  • */.claude-plugin/types/ is generated by Claude Code on each machine and is not committed. Your editor may show type errors until a session has loaded the mods once.
  • More on cutting token spend: Token Spend Playbook.
Source 2 files
hooks/register.tsx 192 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Card } from '../types'
5
6// Muted Dracula pink for the name, greys for everything else (matches bento).
7// TEXT for glyphs and words, DATA a step darker for values.
8const NAME = '#C291B3'
9const TEXT = '#D4D4D4'
10const DATA = '#A3A3A3'
11const SKILLOS = '#FF8FB1' // Dracula-style pink salmon for the SkillOS label on the route line
12const RADAR = '#8BE9FD' // Dracula cyan for turn-timer's Speed Radar row
13
14// turn-timer's per-reply durations (read-only here; turn-timer owns them).
15const times = atom({ plugin: 'turn-timer', key: 'times' } as const, [] as { tail: string; ms: number }[])
16const tailOf = (text: string) => text.replace(/\s+/g, ' ').trim().slice(-160)
17const took = (ms: number) => {
18  const total = Math.max(1, Math.round(ms / 1000))
19  const h = Math.floor(total / 3600)
20  const m = Math.floor((total % 3600) / 60)
21  const s = total % 60
22  return [h && `${h}h`, (h || m) && `${m}m`, `${s}s`].filter(Boolean).join(' ')
23}
24
25const card = atom({ plugin: 'recipe-card', key: 'card' } as const, null)
26const saved = atom({ plugin: 'recipe-card', key: 'saved' } as const, 0)
27const isHidden = atom({ plugin: 'recipe-card', key: 'isHidden' } as const, false)
28
29// The route line every substantive answer ends with, in glyphs:
30// ◎ <shape> · † ✓/–/⊘ · ◫ ✓/–/⊘ [tool] · ≡ n · ✦ ✓/– <reason>
31// (the older emoji form, 🧭 · 🪦 · 📚 · 🪜 · 🧰 with ✅/➖/🔒, still parses).
32const ROUTE_GLYPHS = /^◎\s*(\S+)\s*·\s*†\s*(✓|–|⊘)\s*·\s*◫\s*(✓|–|⊘)\s*([^·]*?)\s*·\s*≡\s*(\d)\s*·\s*✦\s*(✓|–)\s*(.*)$/u
33const ROUTE_EMOJI = /🧭\s*(\S+).*?📚\s*(✅|➖|🔒)[^·]*·\s*🪜\s*(\d).*?🧰\s*(✅|➖)\s*(.*)$/mu
34
35type Route = { shape: string; graveyard: string; library: string; tool: string; rung: string; extract: string; reason: string }
36
37const YES = new Set(['✓', '✅'])
38const MISS = new Set(['–', '➖'])
39
40// One line read as a glyph route line; an unmatched group reads as ''.
41const glyphRoute = (line: string): Route | null => {
42  const g = line.match(ROUTE_GLYPHS)
43  if (!g) return null
44  const [, shape = '', graveyard = '', library = '', tool = '', rung = '', extract = '', reason = ''] = g
45  return { shape, graveyard, library, tool, rung, extract, reason: reason.trim() }
46}
47
48// The last line of an answer, when it is a route line.
49const routeOf = (answer: string): Route | null => {
50  const last = answer.trimEnd().split('\n').pop()?.trim() ?? ''
51  const g = glyphRoute(last)
52  if (g) return g
53  const m = answer.match(ROUTE_EMOJI)
54  if (m) return { shape: m[1] ?? '', graveyard: '', library: m[2] ?? '', tool: '', rung: m[3] ?? '', extract: m[4] ?? '', reason: (m[5] ?? '').trim() }
55  return null
56}
57
58const spot = (answer: string, at: number): Card | null => {
59  const r = routeOf(answer)
60  if (!r) return null
61
62  if (YES.has(r.extract)) return { shape: r.shape, reason: r.reason, why: 'extract', at }
63  // No library tool and a real climb: likely a fresh solve worth a second look.
64  if (MISS.has(r.library) && Number(r.rung) >= 3) return { shape: r.shape, reason: r.reason, why: 'fresh', at }
65
66  return null
67}
68
69const recipePrompt = (c: Card) => [
70  'Run the toolsmith skill on the solve we just finished',
71  `(${c.shape}: ${c.reason}).`,
72  'Decide whether it should become a skills-os library tool. If yes, generalize it, ship the mandatory files,',
73  'log it, and commit to the skills-os library. If no, say why in one line.',
74].join(' ')
75
76export const register: Register = on => {
77  on('session.start', async ($, e, next) => {
78    await $.command.register({ name: 'recipe', description: 'Save the last fresh solve as a skills-os recipe (toolsmith)' })
79
80    return next(e)
81  })
82
83  on('command.run', { command: 'recipe' }, async $ => {
84    const c = await read($, card)
85    const text = c ? recipePrompt(c) : recipePrompt({ shape: 'solve', reason: 'the most recent fresh solve in this session', why: 'fresh', at: 0 })
86
87    await update($, card, () => null)
88    await update($, saved, n => n + 1)
89    void $.prompt.submit({ text })
90
91    return { text: 'Writing up the recipe card…' }
92  })
93
94  // /hush (owned by bento) hides the card band too.
95  on('state.set', { plugin: 'bento', key: 'hushed' }, async ($, e, next) => {
96    const result = await next(e)
97    await update($, isHidden, () => !!e.value)
98    return result
99  }).catch(($, e, next) => next(e)) // a failed hide never blocks /hush itself
100
101  on('turn.complete', async ($, e, next) => {
102    const result = await next(e)
103    if (e.agentId || !e.answer) return result
104
105    const found = spot(e.answer, await $.clock.now())
106    if (found) {
107      await update($, card, () => found)
108    }
109
110    return result
111  })
112
113  // Draws the route line at the end of a reply as its own quiet row: glyphs in TEXT,
114  // values in DATA, with a little room above. The rest of the reply draws as usual.
115  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
116    const text = e.props.text
117    const lines = text.trimEnd().split('\n')
118    const last = lines[lines.length - 1]?.trim() ?? ''
119    const r = glyphRoute(last)
120    if (!r) return next(e)
121
122    const { shape, graveyard, library, tool, rung, extract, reason } = r
123    const rest = lines.slice(0, -1).join('\n').trimEnd()
124    const drawn = rest ? await next({ ...e, props: { ...e.props, text: rest } }) : null
125    const { Box, Text } = $.ui.resolve(e)
126    const dot = <Text color={DATA}> · </Text>
127    // Spell the line out in plain words: the glyphs read too small next to body text.
128    const pair = (label: string, value: string) => (
129      <Text><Text color={TEXT}>{label} </Text><Text color={DATA}>{value}</Text></Text>
130    )
131    const status = (v: string, yes: string, miss: string) => (v === '✓' ? yes : v === '⊘' ? 'locked' : miss)
132    const libraryValue = library === '✓' ? (tool || 'hit') : status(library, 'hit', 'no tool')
133    const tail = tailOf(text)
134    const timed = [...(await read($, times))].reverse().find(t => t.tail === tail)
135
136    return (
137      <Box flexDirection="column">
138        {drawn}
139        {timed && (
140          <Box marginTop={1} paddingX={1}>
141            <Text><Text bold color={RADAR}>Speed Radar: </Text><Text color={DATA}>⏱ {took(timed.ms)}</Text></Text>
142          </Box>
143        )}
144        <Box marginTop={timed ? 0 : 1} paddingX={1}>
145          <Text>
146            <Text bold color={SKILLOS}>SkillOS </Text>
147            {pair('task', shape)}{dot}
148            {pair('dead ends', status(graveyard, 'found', 'none'))}{dot}
149            {pair('library', libraryValue)}{dot}
150            {pair('rung', rung)}{dot}
151            {pair('save as tool', status(extract, 'yes', 'no'))}
152            {reason && dot}{reason && <Text color={DATA}>{reason}</Text>}
153          </Text>
154        </Box>
155      </Box>
156    )
157  })
158
159  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160    const c = await read($, card)
161
162    if (e.props.hasSurvey || !c || (await read($, isHidden))) {
163      return next(e)
164    }
165
166    const { Box, Text, Button } = $.ui.resolve(e)
167    const dot = <Text color={DATA}> · </Text>
168    const reason = c.reason.length > 44 ? `${c.reason.slice(0, 43)}…` : c.reason
169    const below = await next(e)
170
171    return (
172      <Box flexDirection="column">
173        <Box gap={1}>
174          <Text>
175            <Text bold color={NAME}>▭ recipe card </Text>
176            <Text color={TEXT}>{c.why === 'extract' ? 'worth keeping' : 'fresh solve'}</Text>
177            {dot}<Text color={DATA}>{c.shape}</Text>
178            {reason && dot}{reason && <Text color={DATA}>{reason}</Text>}
179          </Text>
180          <Button key="save" label="Save recipe" onPress={async () => {
181            await update($, card, () => null)
182            await update($, saved, n => n + 1)
183            void $.prompt.submit({ text: recipePrompt(c) })
184          }} />
185          <Button key="skip" label="Skip" onPress={() => update($, card, () => null)} />
186        </Box>
187        {below}
188      </Box>
189    )
190  })
191}
192
types/index.d.ts 12 lines
1export type Card = { shape: string; reason: string; why: 'extract' | 'fresh'; at: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'recipe-card': { card: Card | null; saved: number; isHidden: boolean }
6    // Owned by turn-timer; read here to draw the time above the route line.
7    'turn-timer': { times: { tail: string; ms: number }[] }
8    // Owned by bento; watched here so /hush hides the card too.
9    'bento': { hushed: boolean }
10  }
11}
12