SLOPSHOPPER

achievement

Playful badges unlocked once ever from real events (tool calls, turns, subagents, tests, git); /achievements lists them

newguardcommandtoastagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · achievement
› fix the failing auth test and add an audit log call ╭─────────────────────────────────╮ │ achievement │ ⏺ Read(src/auth.ts) │ 🏆 Achievement unlocked: │ ⎿ Read 6 lines │ Shipper │ ⏺ Update(src/auth.ts) ╰─────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭───────────────────────────────────╮ ⏺ Bash(bun test) │ achievement │ ⎿ 3 pass, 1 fail │ 🏆 Achievement unlocked: Night │ │ Owl │ ● Done. refresh now rejects expired claims and logs an audit event. ╰───────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /achievements ⎿ achievement: Achievements: 3/11 ⎿ achievement: ✓ First Blood ⎿ achievement: ○ Centurion — Run 100 Bash commands ⎿ achievement: ○ Wordsmith — Make 50 Edit calls ⎿ achievement: ✓ Night Owl ⎿ achievement: ○ Early Bird — Finish a turn between 5:00 and 7:00 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

achievement

Playful badges, each unlocked once ever, from real events in your sessions.

What it does

  • Watches tool calls, main-agent turns and subagent spawns; shows a 🏆 Achievement unlocked: <name> toast on unlock. Progress is persistent across sessions.
  • Badges:
BadgeUnlocked by
First BloodFirst tool call
Centurion100 Bash commands
Wordsmith50 Edit calls
Night OwlFinishing a turn between 0:00 and 5:00
Early BirdFinishing a turn between 5:00 and 7:00
MarathonA single turn of 10 minutes or more
Subagent Wrangler5 subagents spawned in one session
Green LightA passing test command (npm test, pnpm test, yarn test, vitest, jest, pytest, cargo test, go test, swift test, claude plugin test)
PersistentA Bash command that failed earlier now succeeds
ShipperA successful git push
Clean Slategit status showing a clean working tree

Commands

CommandEffect
/achievementsLists unlocked badges and hints for the locked ones
/achievements resetClears all progress

Install

claude --plugin-dir /path/to/ModsTools/mods/achievement

Limits

  • Failures are detected from the Bash result's error flag; only the last 30 failed commands are remembered for Persistent.
  • Clean Slate recognises English and French git status output and rtk's compacted form.
  • Denied tool calls count for nothing.

Develop

claude plugin validate mods/achievement
claude plugin test mods/achievement   # 14 tests
Source 2 files
hooks/register.ts 69 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { applyEvent, badgeName, listing, normalize } from './rules'
4import type { Ev, State } from './rules'
5
6const KEY = 'state'
7
8async function record($: EngineInterface, ev: Ev): Promise<void> {
9  const { state, unlocked } = applyEvent(normalize(await $.store.get(KEY)), ev)
10  await $.store.set(KEY, state)
11  for (const id of unlocked) $.ui.toast(`🏆 Achievement unlocked: ${badgeName(id)}`)
12}
13
14export const register: Register = on => {
15  let spawns = 0
16
17  on('session.start', async ($, e, next) => {
18    spawns = 0
19    await $.command.register({ name: 'achievements', description: 'Badges unlocked and still locked: [reset]' })
20
21    return next(e)
22  })
23
24  on('tool.call', async ($, e, next) => {
25    const ran = await next(e)
26    if (ran.deny !== undefined) return ran
27    const bash = e.tool === 'Bash'
28    await record($, {
29      kind: 'tool',
30      tool: e.tool,
31      command: bash ? e.command : undefined,
32      isError: ran.isError === true,
33      stdout: bash ? (ran.result as { stdout?: string } | undefined)?.stdout : undefined,
34    })
35
36    return ran
37  })
38
39  on('turn.complete', async ($, e, next) => {
40    if (e.agentId === undefined) {
41      await record($, { kind: 'turn', hour: new Date(await $.clock.now()).getHours(), durationMs: e.durationMs })
42    }
43
44    return next(e)
45  })
46
47  on('agent.spawn', async ($, e, next) => {
48    const started = await next(e)
49    if (started.deny !== undefined || started.agentId === undefined) return started
50    spawns += 1
51    await record($, { kind: 'spawn', count: spawns })
52
53    return started
54  })
55
56  on('command.run', { command: 'achievements' }, async ($, e) => {
57    const arg = e.args.trim()
58    if (arg === 'reset') {
59      await $.store.delete(KEY)
60
61      return { text: 'Achievements reset.' }
62    }
63    if (arg !== '') return { text: 'Usage: /achievements [reset]' }
64    const state: State = normalize(await $.store.get(KEY))
65
66    return { text: listing(state) }
67  })
68}
69
hooks/rules.ts 86 lines
1export type State = { unlocked: string[]; bash: number; edit: number; tools: number; failed: string[] }
2
3export type Ev =
4  | { kind: 'tool'; tool: string; command?: string; isError: boolean; stdout?: string }
5  | { kind: 'turn'; hour: number; durationMs: number }
6  | { kind: 'spawn'; count: number }
7
8export type Badge = { id: string; name: string; hint: string }
9
10export const BADGES: Badge[] = [
11  { id: 'first-blood', name: 'First Blood', hint: 'Make your first tool call' },
12  { id: 'centurion', name: 'Centurion', hint: 'Run 100 Bash commands' },
13  { id: 'wordsmith', name: 'Wordsmith', hint: 'Make 50 Edit calls' },
14  { id: 'night-owl', name: 'Night Owl', hint: 'Finish a turn between 0:00 and 5:00' },
15  { id: 'early-bird', name: 'Early Bird', hint: 'Finish a turn between 5:00 and 7:00' },
16  { id: 'marathon', name: 'Marathon', hint: 'Complete a single turn that lasts 10 minutes or more' },
17  { id: 'subagent-wrangler', name: 'Subagent Wrangler', hint: 'Spawn 5 subagents in one session' },
18  { id: 'green-light', name: 'Green Light', hint: 'Get a test command to pass (npm test, vitest, jest, pytest, cargo test, claude plugin test...)' },
19  { id: 'persistent', name: 'Persistent', hint: 'Re-run a failing Bash command until it succeeds' },
20  { id: 'shipper', name: 'Shipper', hint: 'Complete a successful git push' },
21  { id: 'clean-slate', name: 'Clean Slate', hint: 'Run git status on a clean working tree' },
22]
23
24const TEST_COMMAND = /(^|[\s;&|(])(npm\s+test|pnpm\s+test|yarn\s+test|vitest|jest|pytest|cargo\s+test|go\s+test|swift\s+test|claude\s+plugin\s+test)(\s|$)/
25const GIT_PUSH = /(^|[\s;&|(])git\s+push(\s|$)/
26const GIT_STATUS = /(^|[\s;&|(])git\s+status(\s|$)/
27// English and French git, and rtk's compacted `git status`.
28const CLEAN_TREE = /working tree clean|copie de travail est propre|^clean — nothing to commit/im
29const MAX_FAILED = 30
30
31export const EMPTY: State = { unlocked: [], bash: 0, edit: 0, tools: 0, failed: [] }
32
33export function normalize(value: unknown): State {
34  if (typeof value !== 'object' || value === null) return EMPTY
35  const v = value as Partial<State>
36  const num = (n: unknown) => (typeof n === 'number' && Number.isFinite(n) ? n : 0)
37  const list = (a: unknown) => (Array.isArray(a) ? a.filter((x): x is string => typeof x === 'string') : [])
38
39  return { unlocked: list(v.unlocked), bash: num(v.bash), edit: num(v.edit), tools: num(v.tools), failed: list(v.failed) }
40}
41
42function earned(s: State, e: Ev): string[] {
43  if (e.kind === 'turn') {
44    return [
45      ...(e.hour >= 0 && e.hour < 5 ? ['night-owl'] : []),
46      ...(e.hour >= 5 && e.hour < 7 ? ['early-bird'] : []),
47      ...(e.durationMs >= 600_000 ? ['marathon'] : []),
48    ]
49  }
50  if (e.kind === 'spawn') return e.count >= 5 ? ['subagent-wrangler'] : []
51  const cmd = e.tool === 'Bash' ? (e.command ?? '').trim() : ''
52
53  return [
54    ...(s.tools >= 1 ? ['first-blood'] : []),
55    ...(s.bash >= 100 ? ['centurion'] : []),
56    ...(s.edit >= 50 ? ['wordsmith'] : []),
57    ...(cmd !== '' && !e.isError && TEST_COMMAND.test(cmd) ? ['green-light'] : []),
58    ...(cmd !== '' && !e.isError && s.failed.includes(cmd) ? ['persistent'] : []),
59    ...(cmd !== '' && !e.isError && GIT_PUSH.test(cmd) ? ['shipper'] : []),
60    ...(cmd !== '' && !e.isError && GIT_STATUS.test(cmd) && CLEAN_TREE.test(e.stdout ?? '') ? ['clean-slate'] : []),
61  ]
62}
63
64export function applyEvent(prev: State, e: Ev): { state: State; unlocked: string[] } {
65  let s = prev
66  if (e.kind === 'tool') {
67    const cmd = e.tool === 'Bash' ? (e.command ?? '').trim() : ''
68    const failed = cmd === '' ? s.failed : e.isError ? [...s.failed.filter(c => c !== cmd), cmd].slice(-MAX_FAILED) : s.failed.filter(c => c !== cmd)
69    s = { ...s, tools: s.tools + 1, bash: s.bash + (e.tool === 'Bash' ? 1 : 0), edit: s.edit + (e.tool === 'Edit' ? 1 : 0), failed }
70  }
71  const unlocked = [...new Set(earned(e.kind === 'tool' ? { ...s, failed: prev.failed } : s, e))].filter(id => !s.unlocked.includes(id))
72
73  return { state: { ...s, unlocked: [...s.unlocked, ...unlocked] }, unlocked }
74}
75
76export function listing(s: State): string {
77  const done = BADGES.filter(b => s.unlocked.includes(b.id))
78  const lines = BADGES.map(b => (s.unlocked.includes(b.id) ? `✓ ${b.name}` : `○ ${b.name} — ${b.hint}`))
79
80  return [`Achievements: ${done.length}/${BADGES.length}`, ...lines].join('\n')
81}
82
83export function badgeName(id: string): string {
84  return BADGES.find(b => b.id === id)?.name ?? id
85}
86