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

Playful badges, each unlocked once ever, from real events in your sessions.
🏆 Achievement unlocked: <name> toast on unlock. Progress is persistent across sessions.| Badge | Unlocked by |
|---|---|
| First Blood | First tool call |
| Centurion | 100 Bash commands |
| Wordsmith | 50 Edit calls |
| Night Owl | Finishing a turn between 0:00 and 5:00 |
| Early Bird | Finishing a turn between 5:00 and 7:00 |
| Marathon | A single turn of 10 minutes or more |
| Subagent Wrangler | 5 subagents spawned in one session |
| Green Light | A passing test command (npm test, pnpm test, yarn test, vitest, jest, pytest, cargo test, go test, swift test, claude plugin test) |
| Persistent | A Bash command that failed earlier now succeeds |
| Shipper | A successful git push |
| Clean Slate | git status showing a clean working tree |
| Command | Effect |
|---|---|
/achievements | Lists unlocked badges and hints for the locked ones |
/achievements reset | Clears all progress |
claude --plugin-dir /path/to/ModsTools/mods/achievement
git status output and rtk's compacted form.claude plugin validate mods/achievement
claude plugin test mods/achievement # 14 testshooks/register.ts 69 lines1import 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}
69hooks/rules.ts 86 lines1export 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