SLOPSHOPPER

trophy

Steam-style achievements for using this marketplace's skills: a toast on unlock, an /achievements pane, trophy:list in conversation, and a view of which skills…

newpanebandguardcommandtoast
★ 3v0.6.1MITupdated 2026-10-08newkayak12/claude-skills/trophy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trophy
│ ┃ Achievements ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │▱▱▱▱▱▱▱▱▱▱ 0 / 80 unlocked [ Achievements ] ⏺ Read(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ○ 🔒 First Blood ▱▱▱▱▱▱▱▱ 0/1 ⏺ Update(src/auth.ts) │ ┃ ○ 🔒 Hello, World ▱▱▱▱▱▱▱▱ 0/1 ⎿ Added 2 lines, removed 1 line │ ┃ ○ 🔒 Thinking Cap On ▱▱▱▱▱▱▱▱ 0/1 ⏺ Bash(bun test) │ ┃ ○ 🔒 First Sentence ▱▱▱▱▱▱▱▱ 0/1 ⎿ 3 pass, 1 fail │ ┃ ○ 🔒 Library Card ▱▱▱▱▱▱▱▱ 0/1 │ ┃ ○ 🔒 You Have Minions Now ▱▱▱▱▱▱▱▱ 0/1 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ○ 🔒 Everything Goes to Plan ▱▱▱▱▱▱▱▱ 0/1 │ ┃ ○ 🔒 Into the Meta ▱▱▱▱▱▱▱▱ 0/1 ✻ Worked for 42s · done 4:20 PM │ ┃ ○ 🔒 The Art of Finishing ▱▱▱▱▱▱▱▱ 0/1 │ ┃ ○ 🔒 Résumé Dusted Off ▱▱▱▱▱▱▱▱ 0/1 › /achievements │ ┃ ○ 🔒 Brain Upgrade ▱▱▱▱▱▱▱▱ 0/1 ⎿ trophy: Achievements pane opened. │ ┃ ○ 🔒 Seatbelt On ▱▱▱▱▱▱▱▱ 0/1 │ ┃ ○ 🔒 Nodes and Edges ▱▱▱▱▱▱▱▱ 0/1 │ ┃ ○ 🔒 Team Assembled ▱▱▱▱▱▱▱▱ 0/1 │ ┃ ○ 🔒 Collector ▱▱▱▱▱▱▱▱ 0/10 │ ┃ ○ 🔒 Filling the Dex ▱▱▱▱▱▱▱▱ 0/30 │ ┃ ○ 🔒 Half-Mad Collector ▱▱▱▱▱▱▱▱ 0/50 │ ┃ ○ 🔒 Skill Hoarder ▱▱▱▱▱▱▱▱ 0/80 │ ┃ ○ 🔒 Dex Complete (Almost) ▱▱▱▱▱▱▱▱ 0/100 │ ┃ ○ 🔒 The Thinker ▱▱▱▱▱▱▱▱ 0/5 │ ┃ ○ 🔒 Thought Overload ▱▱▱▱▱▱▱▱ 0/8 │ ┃ ○ 🔒 Bias Hunter ▱▱▱▱▱▱▱▱ 0/5 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Achievements
╭─────────────────────────────────────────────────────────── │▱▱▱▱▱▱▱▱▱▱ 0 / 80 unlocked [ Achievements ][ Triggers ][ Fa ╰─────────────────────────────────────────────────────────── ○ 🔒 First Blood ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Hello, World ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Thinking Cap On ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 First Sentence ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Library Card ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 You Have Minions Now ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Everything Goes to Plan ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Into the Meta ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 The Art of Finishing ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Résumé Dusted Off ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Brain Upgrade ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Seatbelt On ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Nodes and Edges ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Team Assembled ▱▱▱▱▱▱▱▱ 0/1 ○ 🔒 Collector ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Filling the Dex ▱▱▱▱▱▱▱▱ 0/30 ○ 🔒 Half-Mad Collector ▱▱▱▱▱▱▱▱ 0/50 ○ 🔒 Skill Hoarder ▱▱▱▱▱▱▱▱ 0/80 ○ 🔒 Dex Complete (Almost) ▱▱▱▱▱▱▱▱ 0/100 ○ 🔒 The Thinker ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Thought Overload ▱▱▱▱▱▱▱▱ 0/8 ○ 🔒 Bias Hunter ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Metacognition Itself ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Not Full Stack, Full Skill ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Dev Encyclopedia ▱▱▱▱▱▱▱▱ 0/25 ○ 🔒 Team Lead ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 One-Person Company ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Job-Hunt Ready ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Librarian ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Author Debut ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Skill Workshop Owner ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Harness Mechanic ▱▱▱▱▱▱▱▱ 0/4 ○ 🔒 Agent Legion ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Beat the Quitting Point ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Perfect Week ▱▱▱▱▱▱▱▱ 0/7 ○ 🔒 Fortnight Streak ▱▱▱▱▱▱▱▱ 0/14 ○ 🔒 Full Month ▱▱▱▱▱▱▱▱ 0/30 ○ 🔒 Hundred-Day Party ▱▱▱▱▱▱▱▱ 0/100 ○ 🔒 ??? ○ 🔒 ??? ○ 🔒 Masochist's Delight ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Idea Bank ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Diverge Addict ▱▱▱▱▱▱▱▱ 0/50 ○ 🔒 Born Planner ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Well Done ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 No Senior? No Problem ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 The Problem Is the Problem ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Master of the Table ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Bug Hunter ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Living With Bugs ▱▱▱▱▱▱▱▱ 0/50 ○ 🔒 Test-First Disciple ▱▱▱▱▱▱▱▱ 0/20 ○ 🔒 Review Craftsman ▱▱▱▱▱▱▱▱ 0/20 ○ 🔒 CI Lies ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 White Hat ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 SELECT * FROM life ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Firefighter ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 ??? ○ 🔒 Do It Properly ×10 ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Can't Live Without a Harness ▱▱▱▱▱▱▱▱ 0/50 ○ 🔒 No Proof, No Claim ▱▱▱▱▱▱▱▱ 0/20 ○ 🔒 I Catch My Own Biases ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Logic Police ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Interview Veteran ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 More Me Than Me ▱▱▱▱▱▱▱▱ 0/5 ○ 🔒 Doesn't Sound Like AI ▱▱▱▱▱▱▱▱ 0/10 ○ 🔒 Design to Verification ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Red, Then Green ▱▱▱▱▱▱▱▱ 0/2 ○ 🔒 Plan After the Roasting ▱▱▱▱▱▱▱▱ 0/2 ○ 🔒 Stick, Then Carrot ▱▱▱▱▱▱▱▱ 0/2 ○ 🔒 Textbook Debugging ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Never Again Pledge ▱▱▱▱▱▱▱▱ 0/2 ○ 🔒 Full Job-Hunt Course ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Domain-Driven Life ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Triple DB Combo ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Skill Factory Foreman ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Full Ticket Loop ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Knowledge Graph Artisan ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 Three Rungs of Doubt ▱▱▱▱▱▱▱▱ 0/3 ○ 🔒 ??? ○ 🔒 ???
Pane · trophy-batch
POST https://us.i.posthog.com/batch/ — 0 events ╭──────────────────────────────────────────────────────────╮ │{ "api_key": │ │"phc_r4NATbMFBZvmQYiJ8MPMJSWHprgbsTkbCddtc6aYAoUg", │ │"batch": [] } │ ╰──────────────────────────────────────────────────────────╯
README

trophy

English · 한국어

Steam-style achievements for using this marketplace's skills. Use a skill and a toast and a gold card above the prompt (8 s) tell you what you unlocked; /achievements shows what you have, what is locked and how far along you are, and which of your prompts contained a skill's trigger phrase without the skill running. It also keeps a local record of this marketplace's skills and MCP tools failing (the Failures tab). Anonymous daily counts and fixed error codes go to the maintainer only if you say yes. Off by default.

It is a Claude Code hooks module (hooks/mod.tsx). It needs Claude Code 2.1.292 or later and an interactive session; removing the modules line from hooks/hooks.json leaves nothing behind.

Install & Uninstall

/plugin install trophy@newkayak12-claude-skills
/plugin uninstall trophy@newkayak12-claude-skills

The UI is in English by default and switches to Korean when Claude Code's language setting is Korean; the consent question lists the same data in both languages. This file and KOR.md move together.

Use

I want to…Do
See my achievements and progress/achievements (a pane: Achievements, Triggers and Failures tabs)
See what failedthe Failures tab, or View on the ✘ N trophy <last failure> row above the prompt
Report a problem with the last skill/trophy-bug <note> (the note stays local; only the code user_report can be sent)
See them in the conversationthe trophy:list skill (reads ~/.claude/trophy/profile.json)
Turn anonymous counts on / off / check/trophy-telemetry on, off, status

A skill use is recorded when it runs: typed as /name, called by the model through the Skill tool, or preloaded. The 80 achievements are in data/achievements.ts: first use of each plugin, collecting 1–100 distinct skills (overall and per plugin), streaks from 3 to 365 days, repeat counts of favourite skills, 15 skill combos in one session, and 5 hidden ones.

A failure is recorded when one of this marketplace's skills fails to load or reports no success, one of its MCP tools fails, or a harness run writes a failed subgoal or goal gate (harness outcomes stay local). Each record keeps the failing plugin's version and the Claude Code release. The raw error text stays on your machine; the Copy button copies it as a bug body.

Sessions that are not interactive (claude -p, so every teams/graph adapter session) record nothing.

Telemetry — what is sent, where, and how to stop it

Nothing is sent unless you opt in. Once your first achievement unlocks, trophy asks in a double-framed box above the prompt (what is sent, what is never sent, and three buttons drawn alike); [Show contents] shows the exact JSON of the next send before you decide. Until you answer, nothing leaves your machine. Declining is remembered; it is never asked again. The answer is saved with the version of the question (v2 names error codes): a yes given to the older question (v1) is asked once more, and trophy sends nothing until you answer. A no is never re-asked.

Once a day (at the start of the first interactive session after midnight UTC) the plugin sends the previous days' events to PostHog (https://us.i.posthog.com/batch/, the maintainer's project). Per day:

EventProperties
skill_usedskill name, plugin name, day, count
trigger_resultskill name, day, counts of: prompt had its trigger phrase and the skill ran / did not run / ran without a phrase
achievement_unlockedachievement id
plugins_installedplugin name, day — one per plugin of this marketplace whose skills the session lists
diag_*skill, plugin, MCP tool name, error code (reason), count, day, the plugin's version (plugin_version) and the Claude Code release (cc_version) — fixed codes; no messages, paths or prompts.
$exceptionthe message of a failure inside this module, with every file path replaced by <path>, 300 characters at most

Every event also carries a random install id (made on first run, not derived from you or your machine) and $process_person_profile: false. Never sent: prompt text, working directory, project or file names, file paths, session ids, your username or email. The PostHog project discards client IP addresses ("Discard client IP data" is on); the project token in the code is write-only.

A failed send is kept and tried again the next day.

Turn it off: /trophy-telemetry off (stored in the Claude Code store; takes effect at once), or uninstall the plugin. /trophy-telemetry status shows the current answer. Locally, everything the mod keeps is in Claude Code's plugin store and ~/.claude/trophy/profile.json (achievements and counts, no prompts).

Skills

trophy:list

Prints the same lists as the pane as a table, from ~/.claude/trophy/profile.json: unlocked achievements with dates, locked ones with progress bars (hidden ones as ???), and the 7-day trigger view (most hit, most missed, never fired).

Limits

  • Organizations whose policy skips user-level skill.prompt hooks still record: the plugin also listens to the Skill tool call and to the typed-command expansion; one use is counted once.
  • Achievements are counted from the day you install; there is no backfill.

Status log

  • 0.6.1 — The types contract declares anyUnlocked and lang, so claude plugin validate passes again (0.6.0 failed it; the mod still loaded and worked). No behaviour change.
  • 0.6.0 — The consent question waits for your first achievement: starting Claude Code with nothing unlocked shows no box. An earlier answer no longer flashes the question for a moment at start. What is sent and the consent version are unchanged.
  • 0.5.0 — English by default, Korean when Claude Code's language is Korean: every pane, band and command reply, and all 80 achievements (the Korean text kept as it was). The consent question now stands out: a double-framed box with what is sent and what is never sent on their own lines and three buttons drawn alike; what is sent, when it is asked and the consent version are unchanged. Real Claude Code captures EN/KO.
  • 0.4.0 — the diag plugin is folded in and removed: trophy records failures itself (실패 tab, ✘ row, /trophy-bug) and, on the same yes, sends diag_* codes with the plugin and Claude Code versions. Every other plugin of this marketplace now lists trophy in dependencies, so installing one installs trophy.
  • 0.3.0 — gold unlock card in the band for 8 s; ▰▱ progress bars on locked achievements; Ink-style panes and consent band.
  • 0.2.1 — the consent band no longer hides the bands beneath it.
  • 0.2.0 — consent text v2 names error codes; the answer is stored with its version, a v1 yes is asked once more and nothing is sent until answered. diag_* rows listed in the telemetry table.
  • 0.1.1 — typed /skill commands are recorded: under a team organization skill.prompt and UserPromptExpansion are skipped for user-tier hooks, so the typed command is now read from prompt.submit (raw or expanded form).
  • 0.1.0 — first release.
Source 6 files
hooks/mod.tsx 666 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { achievements } from '../data/achievements.ts'
5import { triggers } from '../data/triggers.ts'
6import {
7  achievementRow,
8  cells,
9  addTurn,
10  buildBatch,
11  buildProfile,
12  closeTurn,
13  CONSENT_VERSION,
14  dayOf,
15  effectiveConsent,
16  evaluate,
17  matchTriggers,
18  POSTHOG_URL,
19  recordUse,
20  resolveSkill,
21  scrub,
22  sumDays,
23  triggerLists,
24  typedCommand,
25} from './logic.ts'
26import type { Achievement, BatchStore, DayCounts, Use } from './logic.ts'
27import {
28  appendEntry, copyBody, goalFailed, harnessResult, marketplaceCandidates, newest, ownedMcpTool, ownedSkill,
29  rowDetail, rowTitle,
30} from './failures.ts'
31import type { Entry } from './failures.ts'
32
33const active = atom({ plugin: 'trophy', key: 'active' } as const, false)
34const turnMatched = atom({ plugin: 'trophy', key: 'turnMatched' } as const, [] as string[])
35const turnFired = atom({ plugin: 'trophy', key: 'turnFired' } as const, [] as string[])
36const turnTyped = atom({ plugin: 'trophy', key: 'turnTyped' } as const, false)
37const tab = atom({ plugin: 'trophy', key: 'tab' } as const, 'trophies' as 'trophies' | 'triggers' | 'failures')
38const consent = atom({ plugin: 'trophy', key: 'consent' } as const, 'unasked' as 'unasked' | 'yes' | 'no')
39const celebrate = atom({ plugin: 'trophy', key: 'celebrate' } as const, null as { ids: string[]; until: number } | null)
40const consentVersion = atom({ plugin: 'trophy', key: 'consentVersion' } as const, 0)
41// The consent question waits for the first unlock: nothing is asked at a bare start.
42const anyUnlocked = atom({ plugin: 'trophy', key: 'anyUnlocked' } as const, false)
43// The last `plugin:skill` of this marketplace used or typed; failures recorded since the pane last showed them.
44const lastSkill = atom({ plugin: 'trophy', key: 'lastSkill' } as const, '')
45const unseen = atom({ plugin: 'trophy', key: 'unseen' } as const, 0)
46const lastTitle = atom({ plugin: 'trophy', key: 'lastTitle' } as const, '')
47const lang = atom({ plugin: 'trophy', key: 'lang' } as const, 'en' as 'en' | 'ko')
48
49const CARD_MS = 8000
50const BAR = 10
51const PANE = 'trophy'
52const BATCH_PANE = 'trophy-batch'
53type Consent = 'unasked' | 'yes' | 'no'
54
55// The one place consent changes: the store keeps it, the state redraws the band.
56async function setConsent($: EngineInterface, value: Consent) {
57  await $.store.set('trophy.consent', value)
58  await $.store.set('trophy.consentVersion', CONSENT_VERSION)
59  await update($, consent, () => value)
60  await update($, consentVersion, () => CONSENT_VERSION)
61}
62
63const DAY_MS = 86_400_000
64const MARKETPLACE_PLUGINS = new Set(triggers.map(t => t.plugin))
65const TEXT_MAX = 2000
66
67const en = {
68  paneTitle: 'Achievements', previewTitle: 'Telemetry preview',
69  cmdAchievements: 'Show your skill achievements',
70  cmdTelemetry: 'Anonymous usage counts: on, off or status',
71  cmdBug: "Report a problem with this marketplace's last skill; the note stays local",
72  bugUsage: 'usage: /trophy-bug <note>', bugRecorded: 'Recorded.', bugFailed: 'Could not record the report.',
73  paneOpened: 'Achievements pane opened.', unlockedCount: '{done} / {n} unlocked',
74  tabTrophies: 'Achievements', tabTriggers: 'Triggers', tabFailures: 'Failures',
75  copy: 'Copy', noFailures: 'No failures recorded.',
76  headHit: 'Most-hit skills (7 days)', headMiss: 'Most-missed skills (had a trigger phrase, not used)',
77  headNever: 'Never-used skills ({n})',
78  telemetryUsage: 'usage: /trophy-telemetry on|off|status',
79  telemetryReconsent: 'unasked (v1 yes — needs re-consent)',
80  consentAsk: '📊 trophy · Send anonymous usage stats?', consentSent: 'Sent: skill names · daily counts · error codes',
81  consentNotSent: 'Never sent: prompts · file paths',
82  send: 'Send', decline: "Don't send", show: 'Show contents',
83  unlockHead: '🏆 Achievement unlocked!', see: 'View',
84}
85
86// one table, two languages; the type keeps the keys identical
87export const STRINGS: Record<'en' | 'ko', Record<keyof typeof en, string>> = {
88  en,
89  ko: {
90    paneTitle: '업적', previewTitle: '전송 내용 미리보기',
91    cmdAchievements: '스킬 업적 보기',
92    cmdTelemetry: '익명 사용 통계: on, off, status',
93    cmdBug: '이 마켓플레이스의 마지막 스킬 문제 신고 (메모는 로컬에만 저장)',
94    bugUsage: '사용법: /trophy-bug <메모>', bugRecorded: '기록했습니다.', bugFailed: '신고를 기록하지 못했습니다.',
95    paneOpened: '업적 창을 열었습니다.', unlockedCount: '{done} / {n} 해금',
96    tabTrophies: '업적', tabTriggers: '트리거', tabFailures: '실패',
97    copy: '복사', noFailures: '기록된 실패가 없습니다.',
98    headHit: '가장 많이 맞은 스킬 (7일)', headMiss: '가장 많이 놓친 스킬 (트리거 문구는 있었는데 안 쓴)',
99    headNever: '한 번도 안 쓴 스킬 ({n})',
100    telemetryUsage: '사용법: /trophy-telemetry on|off|status',
101    telemetryReconsent: 'unasked (v1 yes — 재동의 필요)',
102    consentAsk: '📊 trophy · 익명 사용 통계를 보낼까요?', consentSent: '보내는 것: 스킬명 · 일별 횟수 · 오류 코드',
103    consentNotSent: '보내지 않는 것: 프롬프트 · 파일 경로',
104    send: '보내기', decline: '안 보내기', show: '내용 보기',
105    unlockHead: '🏆 업적 해금!', see: '보기',
106  },
107}
108
109const fmt = (text: string | undefined, vars: Record<string, string | number> = {}) =>
110  (text ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ''))
111// An achievement's title and description in the session's language (the top-level fields are English).
112const localize = (a: Achievement, l: 'en' | 'ko'): Achievement => (l === 'ko' ? { ...a, ...a.ko } : a)
113const str = async ($: EngineInterface, key: keyof typeof en, vars?: Record<string, string | number>) =>
114  fmt(STRINGS[await read($, lang)][key], vars)
115
116type Owned = { skills: Record<string, string[]>; servers: Record<string, string[]>; versions: Record<string, string> }
117
118// What this marketplace ships and at which version, read once per session from $.plugin.root; empty when
119// unreadable, so no failure is recorded (fails toward privacy). `cc` is Claude Code's release.
120let owned: Owned = { skills: {}, servers: {}, versions: {} }
121let cc = ''
122
123const readJson = async ($: EngineInterface, path: string) => JSON.parse(await $.fs.read(path))
124
125async function loadOwned($: EngineInterface): Promise<Owned> {
126  const out: Owned = { skills: {}, servers: {}, versions: {} }
127  const parts = String($.plugin.root).replace(/\/+$/, '').split('/')
128  for (const path of marketplaceCandidates($.plugin.root, parts[parts.length - 3])) {
129    let plugins: { name: string; version?: unknown; source?: unknown }[]
130    try {
131      plugins = (await readJson($, path)).plugins
132    } catch {
133      continue // try the next candidate
134    }
135    const base = path.slice(0, -'/.claude-plugin/marketplace.json'.length)
136    for (const p of plugins) {
137      out.skills[p.name] = []
138      out.servers[p.name] = []
139      if (typeof p.version === 'string') out.versions[p.name] = p.version
140      if (typeof p.source !== 'string' || !p.source.startsWith('./')) continue
141      const dir = `${base}/${p.source.slice(2)}`
142      try {
143        const entries = (await $.fs.list(`${dir}/skills`)) as { name: string; kind: string }[]
144        out.skills[p.name] = entries.filter(x => x.kind === 'dir').map(x => x.name)
145      } catch {}
146      try {
147        out.servers[p.name] = Object.keys((await readJson($, `${dir}/.mcp.json`)).mcpServers ?? {})
148      } catch {}
149    }
150    break
151  }
152  return out
153}
154
155async function record($: EngineInterface, entry: Omit<Entry, 'ts' | 'day' | 'session' | 'version' | 'cc'>) {
156  const ts = await $.clock.now()
157  const session = await $.session.id()
158  const log = ((await $.store.get('trophy.failures')) ?? []) as Entry[]
159  const version = entry.plugin ? owned.versions[entry.plugin] : undefined
160  const full: Entry = { ...entry, ts, day: dayOf(ts), session, ...(version ? { version } : {}), ...(cc ? { cc } : {}) }
161  await $.store.set('trophy.failures', appendEntry(log, full))
162  if (entry.kind === 'bug' || entry.kind === 'outcome') {
163    await update($, unseen, n => n + 1)
164    await update($, lastTitle, () => rowTitle(full))
165  }
166}
167
168// Recording a failure never changes what the engine returns: any failure here is swallowed.
169const safe = async (work: () => Promise<unknown>) => {
170  try {
171    await work()
172  } catch {}
173}
174
175const cut = (v: unknown) => String(v ?? '').slice(0, TEXT_MAX)
176
177const showFailures = async ($: EngineInterface) => {
178  await update($, unseen, () => 0)
179  await update($, tab, () => 'failures')
180  await $.ui.open({ id: PANE, title: await str($, 'paneTitle') })
181}
182
183// Every hook fails open: the event goes on, and the failure is kept (scrubbed) for the next send.
184async function failOpen($: EngineInterface, e: unknown, next: any) {
185  try {
186    const errors = (((await $.store.get('trophy.errors')) ?? []) as BatchStore['errors']).slice(-49)
187    const day = dayOf(await $.clock.now())
188    await $.store.set('trophy.errors', [...errors, { day, message: scrub(String(next.error?.message ?? 'hook failed')) }])
189  } catch {
190    // keeping the error is best effort
191  }
192  return next(e)
193}
194
195async function readBatchStore($: EngineInterface): Promise<BatchStore> {
196  const get = async (key: string, empty: unknown) => (await $.store.get(key)) ?? empty
197  return {
198    installId: String(await get('trophy.installId', '')),
199    sentThrough: (await $.store.get('trophy.sentThrough')) as string | undefined,
200    uses: (await get('trophy.uses', [])) as Use[],
201    triggers: (await get('trophy.triggers', {})) as DayCounts,
202    unlocked: (await get('trophy.unlocked', {})) as Record<string, string>,
203    errors: (await get('trophy.errors', [])) as BatchStore['errors'],
204    plugins: (await get('trophy.plugins', {})) as Record<string, string[]>,
205    failures: (await get('trophy.failures', [])) as Entry[],
206  }
207}
208
209const yesterday = async ($: EngineInterface) => dayOf((await $.clock.now()) - DAY_MS)
210
211// Which of this marketplace's plugins the session lists skills from, kept per day.
212async function recordPlugins($: EngineInterface) {
213  const usage = await $.session.usage({ breakdown: 'summary' })
214  const names = (usage.context.breakdown?.skills?.skillFrontmatter ?? []).flatMap(s =>
215    s.pluginName && MARKETPLACE_PLUGINS.has(s.pluginName) ? [s.pluginName] : [],
216  )
217  if (names.length === 0) return
218  const day = dayOf(await $.clock.now())
219  const all = ((await $.store.get('trophy.plugins')) ?? {}) as Record<string, string[]>
220  await $.store.set('trophy.plugins', { ...all, [day]: [...new Set([...(all[day] ?? []), ...names])].sort() })
221}
222
223// Sends the days not yet sent, up to yesterday, for a person who said yes; a failed send changes nothing.
224async function sendIfDue($: EngineInterface) {
225  const answer = effectiveConsent(
226    (await $.store.get('trophy.consent')) as Consent | undefined,
227    (await $.store.get('trophy.consentVersion')) as number | undefined,
228  )
229  if (answer !== 'yes') return
230  const through = await yesterday($)
231  const sent = (await $.store.get('trophy.sentThrough')) as string | undefined
232  if (sent !== undefined && sent >= through) return
233  const body = buildBatch(await readBatchStore($), through)
234  if (body.batch.length > 0) {
235    const res = await $.http.fetch(POSTHOG_URL, {
236      method: 'POST',
237      headers: { 'content-type': 'application/json' },
238      body: JSON.stringify(body),
239    })
240    if (!res.ok) return
241  }
242  const errors = (((await $.store.get('trophy.errors')) ?? []) as BatchStore['errors']).filter(e => e.day > through)
243  await $.store.set('trophy.errors', errors)
244  await $.store.set('trophy.sentThrough', through)
245}
246
247// The file trophy:list reads. Written after every store change; a failed write never blocks the prompt.
248async function mirror($: EngineInterface) {
249  try {
250    const get = async (key: string, empty: unknown) => (await $.store.get(key)) ?? empty
251    const profile = buildProfile(
252      (await get('trophy.uses', [])) as Use[],
253      (await get('trophy.unlocked', {})) as Record<string, string>,
254      (await get('trophy.triggers', {})) as DayCounts,
255      achievements,
256      triggers,
257      await $.clock.now(),
258    )
259    await $.fs.write(`${await $.env.get('HOME')}/.claude/trophy/profile.json`, JSON.stringify(profile, null, 2))
260  } catch {
261    // the mirror is a convenience
262  }
263}
264
265// Records one skill use; always called before `next`, so a throw here falls to the hook's `.catch`.
266async function note($: EngineInterface, name: string) {
267  if (!(await read($, active))) return
268  const hit = resolveSkill(name, triggers)
269  if (!hit) return
270  const uses = ((await $.store.get('trophy.uses')) ?? []) as Use[]
271  const now = await $.clock.now()
272  const recorded = recordUse(uses, hit.skill, now, await $.session.id())
273  await $.store.set('trophy.uses', recorded)
274  await update($, turnFired, list => [...list, hit.skill])
275
276  const unlocked = ((await $.store.get('trophy.unlocked')) ?? {}) as Record<string, string>
277  const fresh = evaluate(recorded, achievements, unlocked)
278  if (fresh.length > 0) {
279    await $.store.set('trophy.unlocked', { ...unlocked, ...Object.fromEntries(fresh.map(id => [id, dayOf(now)])) })
280    await update($, anyUnlocked, () => true)
281    const l = await read($, lang)
282    for (const a of achievements.filter(a => fresh.includes(a.id)).map(a => localize(a, l))) {
283      $.ui.toast(`🏆 ${a.title} — ${a.description}`)
284    }
285    // The card lists every unlock of the last few seconds; the timer clears it and redraws the band.
286    const until = now + CARD_MS
287    await update($, celebrate, card => ({ ids: [...(card && card.until > now ? card.ids : []), ...fresh], until }))
288    $.clock.after(CARD_MS, () => {
289      void update($, celebrate, card => (card && card.until === until ? null : card))
290    })
291  }
292  await mirror($)
293}
294
295export const register: Register = on => {
296  // Non-interactive sessions (every `claude -p`, so every teams/graph adapter) stay idle.
297  on('session.start', async ($, e, next) => {
298    if (!e.isInteractive) return next(e)
299
300    // The stored answer first: until then the band stays hidden, so a yes never flashes the question.
301    const savedVersion = (await $.store.get('trophy.consentVersion')) as number | undefined
302    // An older yes reads as unasked (the band asks once more); the stored value stays until answered.
303    const saved = effectiveConsent((await $.store.get('trophy.consent')) as Consent | undefined, savedVersion)
304    await update($, consent, () => saved)
305    await update($, consentVersion, () => savedVersion ?? 0)
306    const unlocked = ((await $.store.get('trophy.unlocked')) ?? {}) as Record<string, string>
307    await update($, anyUnlocked, () => Object.keys(unlocked).length > 0)
308    await update($, active, () => true)
309    await update($, lastSkill, () => '')
310    await update($, unseen, () => 0)
311    await update($, lastTitle, () => '')
312    // Claude Code's own language setting, read once per session, before the commands are described
313    const language = ((await $.settings.read().catch(() => ({}))) as { language?: unknown }).language
314    await update($, lang, () => (typeof language === 'string' && /^(ko|korean|한국어)/i.test(language) ? 'ko' : 'en'))
315    owned = await loadOwned($).catch(() => ({ skills: {}, servers: {}, versions: {} }))
316    cc = (await $.session.version().catch(() => undefined))?.base ?? ''
317    if ((await $.store.get('trophy.installId')) === undefined) {
318      await $.store.set('trophy.installId', crypto.randomUUID())
319    }
320    await $.command.register({
321      name: 'achievements',
322      description: await str($, 'cmdAchievements'),
323    })
324    await $.command.register({
325      name: 'trophy-telemetry',
326      description: await str($, 'cmdTelemetry'),
327      argumentHint: 'on|off|status',
328    })
329    await $.command.register({
330      name: 'trophy-bug',
331      description: await str($, 'cmdBug'),
332      argumentHint: '<note>',
333    })
334    await recordPlugins($).catch(() => {})
335    await sendIfDue($).catch(() => {})
336
337    return next(e)
338  }).catch(failOpen)
339
340  // skill.prompt is skipped for user-tier hooks under some organizations' policy (00-spike-findings),
341  // so the Skill tool and the typed command report the same uses.
342  on('skill.prompt', async ($, e, next) => {
343    await note($, e.skill)
344    return next(e)
345  }).catch(failOpen)
346
347  // Also: the Skill tool answered but the skill did not succeed; remembers the last owned skill.
348  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
349    await note($, e.skill)
350    const result = await next(e)
351    if (e.tool !== 'Skill' || !(await read($, active))) return result
352    await safe(async () => {
353      const hit = ownedSkill(e.skill, owned.skills)
354      if (!hit) return
355      await update($, lastSkill, () => `${hit.plugin}:${hit.skill}`)
356      const r = result as { deny?: string; isError?: boolean; result?: any }
357      if (r.deny !== undefined || r.isError || !r.result || r.result.success !== false) return
358      const forked = r.result.status === 'forked'
359      await record($, {
360        kind: 'bug',
361        reason: forked ? 'forked_unsuccessful' : 'unsuccessful',
362        plugin: hit.plugin,
363        skill: hit.skill,
364        local: forked ? { text: cut(r.result.result) } : {},
365      })
366    })
367    return result
368  }).catch(failOpen)
369
370  // An owned skill failed to load, or an owned MCP tool failed. An interrupt is the person's, not a bug.
371  on('classic.PostToolUseFailure', async ($, e, next) => {
372    if (!(await read($, active)) || e.is_interrupt === true) return next(e)
373    await safe(async () => {
374      const text = cut(e.error)
375      if (e.tool_name === 'Skill') {
376        const hit = ownedSkill((e.tool_input as { skill?: unknown } | undefined)?.skill, owned.skills)
377        if (hit) await record($, { kind: 'bug', reason: 'is_error', plugin: hit.plugin, skill: hit.skill, local: { text } })
378        return
379      }
380      const mcp = ownedMcpTool(e.tool_name, e.mcp_server, owned.servers)
381      if (mcp) await record($, { kind: 'bug', reason: 'mcp_error', plugin: mcp.plugin, tool: mcp.tool, local: { text } })
382    })
383    return next(e)
384  }).catch(failOpen)
385
386  // The harness engine wrote a final failed subgoal result (local only, never sent).
387  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
388    const result = await next(e)
389    if (e.tool !== 'Write' || !(await read($, active))) return result
390    await safe(async () => {
391      const r = result as { deny?: string; isError?: boolean }
392      if (r.deny !== undefined || r.isError) return
393      const hit = harnessResult(e.file_path, e.content)
394      if (hit) {
395        await record($, { kind: 'outcome', reason: 'subgoal_failed', local: { slug: hit.slug, subgoal: hit.subgoal, path: e.file_path } })
396      }
397    })
398    return result
399  }).catch(failOpen)
400
401  // fallback-check.mjs printed the final `COMPLETE <slug> goal-gate FAIL` (local only, never sent).
402  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
403    const result = await next(e)
404    if (e.tool !== 'Bash' || !(await read($, active))) return result
405    await safe(async () => {
406      const r = result as { deny?: string; isError?: boolean; result?: { stdout?: string } }
407      if (r.deny !== undefined || r.isError || !e.command.includes('fallback-check.mjs')) return
408      const stdout = r.result?.stdout ?? ''
409      if (!goalFailed(stdout)) return
410      const slug = /^COMPLETE\s+(\S+)/m.exec(stdout)?.[1]
411      await record($, { kind: 'outcome', reason: 'goal_failed', local: slug ? { slug } : {} })
412    })
413    return result
414  }).catch(failOpen)
415
416  // `/trophy-bug <note>`: the note stays local; only the code `user_report` can be sent.
417  on('command.run', { command: 'trophy-bug' }, async ($, e) => {
418    const note = e.args.trim()
419    if (!note) return { text: await str($, 'bugUsage') }
420    try {
421      const hit = ownedSkill(await read($, lastSkill), owned.skills)
422      await record($, {
423        kind: 'report',
424        reason: 'user_report',
425        ...(hit ? { plugin: hit.plugin, skill: hit.skill } : {}),
426        local: { note: cut(note) },
427      })
428      return { text: await str($, 'bugRecorded') }
429    } catch {
430      return { text: await str($, 'bugFailed') }
431    }
432  }).catch(failOpen)
433
434  on('classic.UserPromptExpansion', async ($, e, next) => {
435    if (e.expansion_type === 'slash_command') await note($, e.command_name)
436    return next(e)
437  }).catch(failOpen)
438
439  // A prompt's trigger phrases are matched on the way in and judged against the skills that fired when the turn ends.
440  on('prompt.submit', async ($, e, next) => {
441    if (await read($, active)) {
442      await update($, turnMatched, () => matchTriggers(e.text, triggers))
443      await update($, turnFired, () => [])
444      const typed = typedCommand(e.text)
445      await update($, turnTyped, () => typed !== undefined)
446      // The one path that still sees a typed skill when skill.prompt and UserPromptExpansion are skipped.
447      if (typed) await note($, typed)
448      // Only a name this marketplace ships becomes the last skill; whatever else was typed is never stored.
449      const hit = ownedSkill(typed, owned.skills)
450      if (hit) await update($, lastSkill, () => `${hit.plugin}:${hit.skill}`)
451    }
452    return next(e)
453  }).catch(failOpen)
454
455  on('turn.complete', async ($, e, next) => {
456    if ((await read($, active)) && e.agentId === undefined && !(await read($, turnTyped))) {
457      const turn = closeTurn(await read($, turnMatched), await read($, turnFired))
458      await update($, turnMatched, () => [])
459      await update($, turnFired, () => [])
460      if (turn.hit.length + turn.miss.length + turn.unmatched.length > 0) {
461        const counts = ((await $.store.get('trophy.triggers')) ?? {}) as DayCounts
462        await $.store.set('trophy.triggers', addTurn(counts, dayOf(await $.clock.now()), turn))
463        await mirror($)
464      }
465    }
466    return next(e)
467  }).catch(failOpen)
468
469  // The pane opens only from its command.
470  on('command.run', { command: 'achievements' }, async $ => {
471    if ((await read($, tab)) === 'failures') await update($, unseen, () => 0)
472    await $.ui.open({ id: PANE, title: await str($, 'paneTitle') })
473    return { text: await str($, 'paneOpened') }
474  }).catch(failOpen)
475
476  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
477    const { Box, Button, Text } = $.ui.resolve(e)
478    const lang$ = await read($, lang)
479    const current = await read($, tab)
480    const t = (key: keyof typeof en, vars?: Record<string, string | number>) => fmt(STRINGS[lang$][key], vars)
481    const unlocked = ((await $.store.get('trophy.unlocked')) ?? {}) as Record<string, string>
482    const done = Object.keys(unlocked).length
483    const filled = achievements.length === 0 ? 0 : Math.round((done / achievements.length) * BAR)
484    const header = (
485      <Box borderStyle="round" borderColor="claude">
486        <Text key="count" bold>
487          {'▰'.repeat(filled)}
488          {'▱'.repeat(BAR - filled)} {t('unlockedCount', { done, n: achievements.length })}{' '}
489        </Text>
490        <Button
491          key="tab-trophies"
492          label={t('tabTrophies')}
493          variant={current === 'trophies' ? 'primary' : undefined}
494          onPress={() => update($, tab, () => 'trophies')}
495        />
496        <Button
497          key="tab-triggers"
498          label={t('tabTriggers')}
499          variant={current === 'triggers' ? 'primary' : undefined}
500          onPress={() => update($, tab, () => 'triggers')}
501        />
502        <Button
503          key="tab-failures"
504          label={t('tabFailures')}
505          variant={current === 'failures' ? 'primary' : undefined}
506          onPress={() => showFailures($)}
507        />
508      </Box>
509    )
510
511    if (current === 'failures') {
512      const rows = newest(((await $.store.get('trophy.failures')) ?? []) as Entry[])
513      return (
514        <Box flexDirection="column">
515          {header}
516          {rows.length === 0 && <Text dimColor>{t('noFailures')}</Text>}
517          {rows.map((r, i) => (
518            <Box key={`fail-${i}`} flexDirection="column" borderStyle="round" borderColor="error" borderDimColor paddingX={1}>
519              <Box justifyContent="space-between" gap={1}>
520                <Box flexShrink={1}>
521                  <Text color="error">✘ </Text>
522                  <Text wrap="truncate-end">{rowTitle(r)}</Text>
523                </Box>
524                <Box flexShrink={0}>
525                  <Button
526                    key={`copy-${i}`}
527                    label={t('copy')}
528                    onPress={press => {
529                      void $.ui.copy({ text: copyBody(r), surface: press.surface }).catch(() => {})
530                    }}
531                  />
532                </Box>
533              </Box>
534              {rowDetail(r).map((line, j) => <Text key={`d-${i}-${j}`} dimColor>{line}</Text>)}
535            </Box>
536          ))}
537        </Box>
538      )
539    }
540
541    if (current === 'triggers') {
542      const counts = ((await $.store.get('trophy.triggers')) ?? {}) as DayCounts
543      const lists = triggerLists(sumDays(counts, dayOf(await $.clock.now()), 7), triggers)
544      const never = lists.never.slice(0, 5).join(', ')
545      return (
546        <Box flexDirection="column">
547          {header}
548          <Text bold color="success">{t('headHit')}</Text>
549          {lists.hit.map(([skill, n]) => (
550            <Text key={`hit-${skill}`}>{skill} · {n}</Text>
551          ))}
552          <Text bold color="warning">{t('headMiss')}</Text>
553          {lists.miss.map(([skill, n]) => (
554            <Text key={`miss-${skill}`}>{skill} · {n}</Text>
555          ))}
556          <Text bold color="subtle">{t('headNever', { n: lists.never.length })}</Text>
557          <Text dimColor wrap="truncate-end">{never}</Text>
558        </Box>
559      )
560    }
561
562    const uses = ((await $.store.get('trophy.uses')) ?? []) as Use[]
563    return (
564      <Box flexDirection="column">
565        {header}
566        {achievements.map(a => (
567          <Box key={`row-${a.id}`} gap={1}>
568            {unlocked[a.id] ? (
569              <Text key="mark" color="success">✔</Text>
570            ) : (
571              <Text key="mark" dimColor>○</Text>
572            )}
573            <Text key="row" wrap="truncate-end">{achievementRow(localize(a, lang$), uses, unlocked[a.id], false)}</Text>
574            {unlocked[a.id] || a.hidden ? null : (
575              <Text key="bar" color="claude" wrap="truncate-end">{cells(a, uses)}</Text>
576            )}
577          </Box>
578        ))}
579      </Box>
580    )
581  }).catch(failOpen)
582
583  on('command.run', { command: 'trophy-telemetry' }, async ($, e) => {
584    const arg = e.args.trim()
585    if (arg === 'on' || arg === 'off') {
586      await setConsent($, arg === 'on' ? 'yes' : 'no')
587      return { text: `trophy telemetry: ${arg === 'on' ? 'yes' : 'no'}` }
588    }
589    if (arg === 'status') {
590      const now = await read($, consent)
591      const old = now === 'unasked' && (await $.store.get('trophy.consent')) === 'yes'
592      return { text: `trophy telemetry: ${old ? await str($, 'telemetryReconsent') : now === 'yes' ? `yes (v${CONSENT_VERSION})` : now}` }
593    }
594    return { text: await str($, 'telemetryUsage') }
595  }).catch(failOpen)
596
597  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
598    if (e.props.hasSurvey || !(await read($, active))) return next(e)
599
600    const { Box, Button, Text } = $.ui.resolve(e)
601    const lang$ = await read($, lang)
602    const t = (key: keyof typeof en) => STRINGS[lang$][key]
603    const asking = (await read($, consent)) === 'unasked' && (await read($, anyUnlocked))
604    const card = await read($, celebrate)
605    const shown = card && card.until > (await $.clock.now()) ? achievements.filter(a => card.ids.includes(a.id)).map(a => localize(a, lang$)) : []
606    const failed = await read($, unseen)
607    if (!asking && shown.length === 0 && failed === 0) return next(e)
608
609    const mine = (
610      <Box flexDirection="column">
611        {asking ? (
612          // a question, not a notice: a double frame, every line in full, three buttons drawn alike (no nudge to yes)
613          <Box key="consent" flexDirection="column" borderStyle="double" borderColor="warning" paddingX={1}>
614            <Text bold color="warning">{t('consentAsk')}</Text>
615            <Text>{`   ${t('consentSent')}`}</Text>
616            <Text>{`   ${t('consentNotSent')}`}</Text>
617            <Box marginTop={1} marginLeft={3} columnGap={3}>
618              <Button key="send" label={t('send')} onPress={() => setConsent($, 'yes')} />
619              <Button key="decline" label={t('decline')} onPress={() => setConsent($, 'no')} />
620              <Button key="show" label={t('show')} onPress={() => $.ui.open({ id: BATCH_PANE, title: t('previewTitle') })} />
621            </Box>
622          </Box>
623        ) : null}
624        {shown.length > 0 ? (
625          <Box key="celebrate" borderStyle="round" borderColor="#d4a017" flexDirection="column" paddingX={1}>
626            <Text key="head" bold color="#d4a017">{t('unlockHead')}</Text>
627            {shown.map(a => (
628              <Text key={`card-${a.id}`} wrap="truncate-end">
629                <Text bold>{a.title}</Text> — {a.description}
630              </Text>
631            ))}
632          </Box>
633        ) : null}
634        {failed > 0 ? (
635          <Box key="failed" gap={1}>
636            <Box flexShrink={0}><Text color="error" bold>{`✘ ${failed}`}</Text></Box>
637            <Box flexShrink={0}><Text dimColor>trophy</Text></Box>
638            <Box flexShrink={1}><Text wrap="truncate-end">{await read($, lastTitle)}</Text></Box>
639            <Box flexShrink={0}><Button key="see" label={t('see')} onPress={() => showFailures($)} /></Box>
640          </Box>
641        ) : null}
642      </Box>
643    )
644    return (
645      <Box flexDirection="column">
646        {mine}
647        {await next(e)}
648      </Box>
649    )
650  }).catch(failOpen)
651
652  // Exactly what the next send would carry.
653  on('ui.render', { component: 'Pane', requestId: BATCH_PANE }, async ($, e) => {
654    const { Box, Text } = $.ui.resolve(e)
655    const body = buildBatch(await readBatchStore($), await yesterday($))
656    return (
657      <Box flexDirection="column">
658        <Text dimColor>POST {POSTHOG_URL} — {body.batch.length} events</Text>
659        <Box borderStyle="round" borderDimColor>
660          <Text>{JSON.stringify(body, null, 2)}</Text>
661        </Box>
662      </Box>
663    )
664  }).catch(failOpen)
665}
666
data/achievements.ts 85 lines
1import type { Achievement } from '../hooks/logic.ts'
2
3export const achievements: Achievement[] = [
4  { id: 'first-blood', title: 'First Blood', description: 'Your first skill. Here we go', ko: { title: 'First Blood', description: '첫 스킬을 썼다. 이제 시작이다' }, rule: { kind: 'collect', count: 1 } },
5  { id: 'first-develop', title: 'Hello, World', description: 'First develop use', ko: { title: 'Hello, World', description: 'develop 첫 사용' }, rule: { kind: 'first_use', plugin: 'develop' } },
6  { id: 'first-think', title: 'Thinking Cap On', description: 'First think use', ko: { title: '생각의 시작', description: 'think 첫 사용' }, rule: { kind: 'first_use', plugin: 'think' } },
7  { id: 'first-write', title: 'First Sentence', description: 'First write use', ko: { title: '첫 문장', description: 'write 첫 사용' }, rule: { kind: 'first_use', plugin: 'write' } },
8  { id: 'first-knowledge', title: 'Library Card', description: 'First knowledge use', ko: { title: '도서관 출입증', description: 'knowledge 첫 사용' }, rule: { kind: 'first_use', plugin: 'knowledge' } },
9  { id: 'first-agents', title: 'You Have Minions Now', description: 'First agents use', ko: { title: '부하 직원이 생겼다', description: 'agents 첫 사용' }, rule: { kind: 'first_use', plugin: 'agents' } },
10  { id: 'first-planning', title: 'Everything Goes to Plan', description: 'First planning use', ko: { title: '다 계획이 있구나', description: 'planning 첫 사용' }, rule: { kind: 'first_use', plugin: 'planning' } },
11  { id: 'first-skill', title: 'Into the Meta', description: 'First skill use — skills driving skills', ko: { title: '메타의 세계로', description: 'skill 첫 사용 — 스킬로 스킬을 다룬다' }, rule: { kind: 'first_use', plugin: 'skill' } },
12  { id: 'first-completion', title: 'The Art of Finishing', description: 'First completion use', ko: { title: '끝맺음의 미학', description: 'completion 첫 사용' }, rule: { kind: 'first_use', plugin: 'completion' } },
13  { id: 'first-portfolio', title: 'Résumé Dusted Off', description: 'First portfolio use', ko: { title: '이력서를 꺼냈다', description: 'portfolio 첫 사용' }, rule: { kind: 'first_use', plugin: 'portfolio' } },
14  { id: 'first-cognition', title: 'Brain Upgrade', description: 'First cognition use', ko: { title: '뇌 업그레이드', description: 'cognition 첫 사용' }, rule: { kind: 'first_use', plugin: 'cognition' } },
15  { id: 'first-harness', title: 'Seatbelt On', description: 'First harness use', ko: { title: '안전벨트 착용', description: 'harness 첫 사용' }, rule: { kind: 'first_use', plugin: 'harness' } },
16  { id: 'first-graph', title: 'Nodes and Edges', description: 'First graph use', ko: { title: '노드와 엣지', description: 'graph 첫 사용' }, rule: { kind: 'first_use', plugin: 'graph' } },
17  { id: 'first-teams', title: 'Team Assembled', description: 'First teams use', ko: { title: '팀 결성', description: 'teams 첫 사용' }, rule: { kind: 'first_use', plugin: 'teams' } },
18  { id: 'collector-10', title: 'Collector', description: '10 different skills', ko: { title: '수집가', description: '서로 다른 스킬 10종' }, rule: { kind: 'collect', count: 10 } },
19  { id: 'collector-30', title: 'Filling the Dex', description: '30 different skills', ko: { title: '도감 채우는 중', description: '서로 다른 스킬 30종' }, rule: { kind: 'collect', count: 30 } },
20  { id: 'collector-50', title: 'Half-Mad Collector', description: '50 different skills', ko: { title: '반쯤 미친 수집가', description: '서로 다른 스킬 50종' }, rule: { kind: 'collect', count: 50 } },
21  { id: 'collector-80', title: 'Skill Hoarder', description: '80 different skills', ko: { title: '스킬 호더', description: '서로 다른 스킬 80종' }, rule: { kind: 'collect', count: 80 } },
22  { id: 'collector-100', title: 'Dex Complete (Almost)', description: '100 different skills. Seriously?', ko: { title: '도감 완성(거의)', description: '서로 다른 스킬 100종. 진심이세요?' }, rule: { kind: 'collect', count: 100 } },
23  { id: 'thinker', title: 'The Thinker', description: '5 think skills', ko: { title: '생각하는 사람', description: 'think 스킬 5종' }, rule: { kind: 'collect', count: 5, plugin: 'think' } },
24  { id: 'think-all', title: 'Thought Overload', description: 'All 8 think skills', ko: { title: '생각 과부하', description: 'think 스킬 8종 전부' }, rule: { kind: 'collect', count: 8, plugin: 'think' } },
25  { id: 'cognition-5', title: 'Bias Hunter', description: '5 cognition skills', ko: { title: '편향 사냥꾼', description: 'cognition 스킬 5종' }, rule: { kind: 'collect', count: 5, plugin: 'cognition' } },
26  { id: 'cognition-all', title: 'Metacognition Itself', description: 'All 10 cognition skills', ko: { title: '메타인지 그 자체', description: 'cognition 스킬 10종 전부' }, rule: { kind: 'collect', count: 10, plugin: 'cognition' } },
27  { id: 'develop-10', title: 'Not Full Stack, Full Skill', description: '10 develop skills', ko: { title: '풀스택 말고 풀스킬', description: 'develop 스킬 10종' }, rule: { kind: 'collect', count: 10, plugin: 'develop' } },
28  { id: 'develop-25', title: 'Dev Encyclopedia', description: '25 develop skills', ko: { title: '개발 백과사전', description: 'develop 스킬 25종' }, rule: { kind: 'collect', count: 25, plugin: 'develop' } },
29  { id: 'teams-5', title: 'Team Lead', description: '5 teams skills', ko: { title: '팀장님', description: 'teams 스킬 5종' }, rule: { kind: 'collect', count: 5, plugin: 'teams' } },
30  { id: 'teams-10', title: 'One-Person Company', description: '10 teams skills', ko: { title: '1인 회사', description: 'teams 스킬 10종' }, rule: { kind: 'collect', count: 10, plugin: 'teams' } },
31  { id: 'portfolio-5', title: 'Job-Hunt Ready', description: '5 portfolio skills', ko: { title: '이직 준비 완료', description: 'portfolio 스킬 5종' }, rule: { kind: 'collect', count: 5, plugin: 'portfolio' } },
32  { id: 'knowledge-5', title: 'Librarian', description: '5 knowledge skills', ko: { title: '사서', description: 'knowledge 스킬 5종' }, rule: { kind: 'collect', count: 5, plugin: 'knowledge' } },
33  { id: 'write-all', title: 'Author Debut', description: 'All 5 write skills', ko: { title: '작가 데뷔', description: 'write 스킬 5종 전부' }, rule: { kind: 'collect', count: 5, plugin: 'write' } },
34  { id: 'skill-all', title: 'Skill Workshop Owner', description: 'All 5 skill skills', ko: { title: '스킬 공방 주인', description: 'skill 스킬 5종 전부' }, rule: { kind: 'collect', count: 5, plugin: 'skill' } },
35  { id: 'harness-4', title: 'Harness Mechanic', description: '4 harness skills', ko: { title: '하네스 정비공', description: 'harness 스킬 4종' }, rule: { kind: 'collect', count: 4, plugin: 'harness' } },
36  { id: 'agents-all', title: 'Agent Legion', description: 'All 3 agents skills', ko: { title: '에이전트 군단', description: 'agents 스킬 3종 전부' }, rule: { kind: 'collect', count: 3, plugin: 'agents' } },
37  { id: 'streak-3', title: 'Beat the Quitting Point', description: '3 days in a row', ko: { title: '작심삼일 돌파', description: '3일 연속 사용' }, rule: { kind: 'streak', days: 3 } },
38  { id: 'streak-7', title: 'Perfect Week', description: '7 days in a row', ko: { title: '일주일 개근', description: '7일 연속 사용' }, rule: { kind: 'streak', days: 7 } },
39  { id: 'streak-14', title: 'Fortnight Streak', description: '14 days in a row', ko: { title: '2주 개근', description: '14일 연속 사용' }, rule: { kind: 'streak', days: 14 } },
40  { id: 'streak-30', title: 'Full Month', description: '30 days in a row', ko: { title: '한 달 개근', description: '30일 연속 사용' }, rule: { kind: 'streak', days: 30 } },
41  { id: 'streak-100', title: 'Hundred-Day Party', description: '100 days in a row. Time to hand out treats', ko: { title: '백일 기념', description: '100일 연속 사용. 떡 돌리세요' }, rule: { kind: 'streak', days: 100 } },
42  { id: 'streak-365', title: 'One Whole Year', description: '365 days in a row. Ever taken a vacation?', ko: { title: '1주년', description: '365일 연속. 휴가는 가셨나요?' }, hidden: true, rule: { kind: 'streak', days: 365 } },
43  { id: 'punching-bag', title: 'Punching Bag', description: 'Got roughed up by devils-advocate 3 times', ko: { title: '샌드백', description: 'devils-advocate에게 3번 털렸다' }, hidden: true, rule: { kind: 'repeat', skill: 'think:devils-advocate', count: 3 } },
44  { id: 'devils-10', title: 'Masochist\'s Delight', description: 'devils-advocate 10 times', ko: { title: '자학의 즐거움', description: 'devils-advocate 10회' }, rule: { kind: 'repeat', skill: 'think:devils-advocate', count: 10 } },
45  { id: 'brainstorm-10', title: 'Idea Bank', description: 'brainstorming 10 times', ko: { title: '아이디어 뱅크', description: 'brainstorming 10회' }, rule: { kind: 'repeat', skill: 'think:brainstorming', count: 10 } },
46  { id: 'brainstorm-50', title: 'Diverge Addict', description: 'brainstorming 50 times. When do you converge?', ko: { title: '발산 중독', description: 'brainstorming 50회. 수렴은 언제?' }, rule: { kind: 'repeat', skill: 'think:brainstorming', count: 50 } },
47  { id: 'plans-10', title: 'Born Planner', description: 'plans 10 times', ko: { title: '계획형 인간', description: 'plans 10회' }, rule: { kind: 'repeat', skill: 'write:plans', count: 10 } },
48  { id: 'grill-5', title: 'Well Done', description: 'grill 5 times', ko: { title: '통구이', description: 'grill 5회' }, rule: { kind: 'repeat', skill: 'think:grill', count: 5 } },
49  { id: 'mentor-10', title: 'No Senior? No Problem', description: 'mentor 10 times', ko: { title: '사수 없어도 괜찮아', description: 'mentor 10회' }, rule: { kind: 'repeat', skill: 'think:mentor', count: 10 } },
50  { id: 'redefine-5', title: 'The Problem Is the Problem', description: 'redefine-problem 5 times', ko: { title: '문제가 문제다', description: 'redefine-problem 5회' }, rule: { kind: 'repeat', skill: 'think:redefine-problem', count: 5 } },
51  { id: 'negotiate-3', title: 'Master of the Table', description: 'negotiate 3 times', ko: { title: '협상 테이블의 주인', description: 'negotiate 3회' }, rule: { kind: 'repeat', skill: 'think:negotiate', count: 3 } },
52  { id: 'bug-10', title: 'Bug Hunter', description: 'bug-diagnoser 10 times', ko: { title: '버그 사냥꾼', description: 'bug-diagnoser 10회' }, rule: { kind: 'repeat', skill: 'develop:bug-diagnoser', count: 10 } },
53  { id: 'bug-50', title: 'Living With Bugs', description: 'bug-diagnoser 50 times', ko: { title: '벌레와 공존하는 법', description: 'bug-diagnoser 50회' }, rule: { kind: 'repeat', skill: 'develop:bug-diagnoser', count: 50 } },
54  { id: 'tdd-20', title: 'Test-First Disciple', description: 'test-driven-development 20 times', ko: { title: '테스트 먼저 교 신도', description: 'test-driven-development 20회' }, rule: { kind: 'repeat', skill: 'develop:test-driven-development', count: 20 } },
55  { id: 'clean-20', title: 'Review Craftsman', description: 'clean-code 20 times', ko: { title: '리뷰 장인', description: 'clean-code 20회' }, rule: { kind: 'repeat', skill: 'develop:clean-code', count: 20 } },
56  { id: 'flaky-5', title: 'CI Lies', description: 'flaky-test-analyzer 5 times', ko: { title: 'CI는 거짓말을 한다', description: 'flaky-test-analyzer 5회' }, rule: { kind: 'repeat', skill: 'develop:flaky-test-analyzer', count: 5 } },
57  { id: 'security-5', title: 'White Hat', description: 'security-auditor 5 times', ko: { title: '화이트햇', description: 'security-auditor 5회' }, rule: { kind: 'repeat', skill: 'develop:security-auditor', count: 5 } },
58  { id: 'sql-10', title: 'SELECT * FROM life', description: 'sql-pro 10 times', ko: { title: 'SELECT * FROM 인생', description: 'sql-pro 10회' }, rule: { kind: 'repeat', skill: 'develop:sql-pro', count: 10 } },
59  { id: 'incident-3', title: 'Firefighter', description: 'incident-response-playbook 3 times', ko: { title: '소방관', description: 'incident-response-playbook 3회' }, rule: { kind: 'repeat', skill: 'develop:incident-response-playbook', count: 3 } },
60  { id: 'incident-10', title: 'Accidental Outage Expert', description: 'incident-response-playbook 10 times. Are you okay?', ko: { title: '원치 않은 장애 전문가', description: 'incident-response-playbook 10회. 괜찮으세요?' }, hidden: true, rule: { kind: 'repeat', skill: 'develop:incident-response-playbook', count: 10 } },
61  { id: 'harness-10', title: 'Do It Properly ×10', description: 'harness 10 times', ko: { title: '제대로 해줘 ×10', description: 'harness 10회' }, rule: { kind: 'repeat', skill: 'harness:harness', count: 10 } },
62  { id: 'harness-50', title: 'Can\'t Live Without a Harness', description: 'harness 50 times', ko: { title: '하네스 없인 못 살아', description: 'harness 50회' }, rule: { kind: 'repeat', skill: 'harness:harness', count: 50 } },
63  { id: 'verify-20', title: 'No Proof, No Claim', description: 'verification-before-completion 20 times', ko: { title: '증거 없인 말 안 함', description: 'verification-before-completion 20회' }, rule: { kind: 'repeat', skill: 'completion:verification-before-completion', count: 20 } },
64  { id: 'bias-5', title: 'I Catch My Own Biases', description: 'bias-auditor 5 times', ko: { title: '내 편향은 내가 잡는다', description: 'bias-auditor 5회' }, rule: { kind: 'repeat', skill: 'cognition:bias-auditor', count: 5 } },
65  { id: 'fallacy-5', title: 'Logic Police', description: 'fallacy-detector 5 times', ko: { title: '논리 경찰', description: 'fallacy-detector 5회' }, rule: { kind: 'repeat', skill: 'cognition:fallacy-detector', count: 5 } },
66  { id: 'mock-10', title: 'Interview Veteran', description: 'mock-interview 10 times', ko: { title: '면접 백전노장', description: 'mock-interview 10회' }, rule: { kind: 'repeat', skill: 'portfolio:mock-interview', count: 10 } },
67  { id: 'like-me-5', title: 'More Me Than Me', description: 'like-me 5 times', ko: { title: '나보다 나 같은', description: 'like-me 5회' }, rule: { kind: 'repeat', skill: 'write:like-me', count: 5 } },
68  { id: 'human-10', title: 'Doesn\'t Sound Like AI', description: 'writer-verification 10 times', ko: { title: 'AI 티 안 나요', description: 'writer-verification 10회' }, rule: { kind: 'repeat', skill: 'write:writer-verification', count: 10 } },
69  { id: 'full-cycle', title: 'Design to Verification', description: 'In one session: brainstorming → plans → harness', ko: { title: '설계부터 검증까지', description: '한 세션에서 brainstorming → plans → harness' }, rule: { kind: 'combo', sequence: ['think:brainstorming', 'write:plans', 'harness:harness'] } },
70  { id: 'red-green', title: 'Red, Then Green', description: 'In one session: TDD → verification-before-completion', ko: { title: '빨강 다음 초록', description: '한 세션에서 TDD → verification-before-completion' }, rule: { kind: 'combo', sequence: ['develop:test-driven-development', 'completion:verification-before-completion'] } },
71  { id: 'grilled-plan', title: 'Plan After the Roasting', description: 'In one session: grill → plans', ko: { title: '털리고 나서 계획', description: '한 세션에서 grill → plans' }, rule: { kind: 'combo', sequence: ['think:grill', 'write:plans'] } },
72  { id: 'carrot-stick', title: 'Stick, Then Carrot', description: 'In one session: devils-advocate → mentor', ko: { title: '병 주고 약 주고', description: '한 세션에서 devils-advocate → mentor' }, rule: { kind: 'combo', sequence: ['think:devils-advocate', 'think:mentor'] } },
73  { id: 'textbook-debug', title: 'Textbook Debugging', description: 'In one session: bug-diagnoser → TDD → verification', ko: { title: '교과서 디버깅', description: '한 세션에서 bug-diagnoser → TDD → verification' }, rule: { kind: 'combo', sequence: ['develop:bug-diagnoser', 'develop:test-driven-development', 'completion:verification-before-completion'] } },
74  { id: 'never-again', title: 'Never Again Pledge', description: 'In one session: incident-response → sre-engineer', ko: { title: '재발 방지 약속', description: '한 세션에서 incident-response → sre-engineer' }, rule: { kind: 'combo', sequence: ['develop:incident-response-playbook', 'develop:sre-engineer'] } },
75  { id: 'job-hunt', title: 'Full Job-Hunt Course', description: 'In one session: fit → rewrite → mock-interview', ko: { title: '취준 풀코스', description: '한 세션에서 fit → rewrite → mock-interview' }, rule: { kind: 'combo', sequence: ['portfolio:fit', 'portfolio:rewrite', 'portfolio:mock-interview'] } },
76  { id: 'domain-driven-life', title: 'Domain-Driven Life', description: 'In one session: event-storming → DDD → service-boundary-validator', ko: { title: '도메인 주도 인생', description: '한 세션에서 event-storming → DDD → service-boundary-validator' }, rule: { kind: 'combo', sequence: ['develop:event-storming', 'develop:domain-driven-design', 'develop:service-boundary-validator'] } },
77  { id: 'db-triple', title: 'Triple DB Combo', description: 'In one session: sql-pro → database-optimizer → connection-pool-tuner', ko: { title: 'DB 3단 콤보', description: '한 세션에서 sql-pro → database-optimizer → connection-pool-tuner' }, rule: { kind: 'combo', sequence: ['develop:sql-pro', 'develop:database-optimizer', 'develop:connection-pool-tuner'] } },
78  { id: 'skill-factory', title: 'Skill Factory Foreman', description: 'In one session: create → trigger-validator → quality-assurance', ko: { title: '스킬 공장장', description: '한 세션에서 create → trigger-validator → quality-assurance' }, rule: { kind: 'combo', sequence: ['skill:create', 'skill:trigger-validator', 'skill:quality-assurance'] } },
79  { id: 'ticket-loop', title: 'Full Ticket Loop', description: 'In one session: teams plan → take → submit', ko: { title: '티켓 한 바퀴', description: '한 세션에서 teams plan → take → submit' }, rule: { kind: 'combo', sequence: ['teams:plan', 'teams:take', 'teams:submit'] } },
80  { id: 'knowledge-graph', title: 'Knowledge Graph Artisan', description: 'In one session: base-builder → graph-builder → query', ko: { title: '지식 그래프 장인', description: '한 세션에서 base-builder → graph-builder → query' }, rule: { kind: 'combo', sequence: ['knowledge:base-builder', 'knowledge:graph-builder', 'knowledge:query'] } },
81  { id: 'doubt-ladder', title: 'Three Rungs of Doubt', description: 'In one session: assumption-extractor → bias-auditor → epistemic-reasoner', ko: { title: '의심의 삼단계', description: '한 세션에서 assumption-extractor → bias-auditor → epistemic-reasoner' }, rule: { kind: 'combo', sequence: ['cognition:assumption-extractor', 'cognition:bias-auditor', 'cognition:epistemic-reasoner'] } },
82  { id: 'overthinker', title: 'Too Many Thoughts', description: 'In one session: brainstorming → devils-advocate → redefine-problem → back-to-basics', ko: { title: '생각이 너무 많아', description: '한 세션에서 brainstorming → devils-advocate → redefine-problem → back-to-basics' }, hidden: true, rule: { kind: 'combo', sequence: ['think:brainstorming', 'think:devils-advocate', 'think:redefine-problem', 'think:back-to-basics'] } },
83  { id: 'commitment-issues', title: 'Installed, Then Uninstalled', description: 'In one session: harness install → remove', ko: { title: '설치했다 지웠다', description: '한 세션에서 harness install → remove' }, hidden: true, rule: { kind: 'combo', sequence: ['harness:install', 'harness:remove'] } },
84]
85
data/triggers.ts 1078 lines
1// Generated by _repo/scripts/build-trophy-index.mjs - do not edit.
2export const triggers = [
3  {
4    "skill": "agents:agent-task-decomposer",
5    "plugin": "agents",
6    "phrases": [
7      "agent에게 어떻게 전달해?",
8      "subtask로 나눠줘",
9      "task decomposition",
10      "병렬 작업 계획 잡아줘",
11      "에이전트 분리해줘",
12      "이 작업 어떻게 나눠야 해?",
13      "큰 작업 분해해줘"
14    ]
15  },
16  {
17    "skill": "agents:dispatching-parallel-agents",
18    "plugin": "agents",
19    "phrases": [
20      "multiple independent failures",
21      "parallel agents",
22      "각자 전문가한테 맡겨줘",
23      "독립적인 작업들 한번에",
24      "동시에 여러 작업",
25      "병렬로 처리해줘"
26    ]
27  },
28  {
29    "skill": "agents:subagent-driven-development",
30    "plugin": "agents",
31    "phrases": [
32      "subagent-driven",
33      "두 단계 리뷰로 구현",
34      "서브에이전트로 구현해줘",
35      "태스크별로 에이전트 배포해줘",
36      "현재 세션에서 서브에이전트로 실행"
37    ]
38  },
39  {
40    "skill": "cognition:assumption-extractor",
41    "plugin": "cognition",
42    "phrases": []
43  },
44  {
45    "skill": "cognition:bias-auditor",
46    "plugin": "cognition",
47    "phrases": [
48      "audit my reasoning",
49      "bias check",
50      "내 판단 비뚤어진 것 같아",
51      "편향 점검",
52      "확신이 과한 것 같아"
53    ]
54  },
55  {
56    "skill": "cognition:clarity-toolkit",
57    "plugin": "cognition",
58    "phrases": [
59      "overthinking",
60      "too vague",
61      "목표가 너무 막연해",
62      "분석만 하다 결정을 못 해",
63      "정보가 너무 많아서 뭐가 중요한지 모르겠어"
64    ]
65  },
66  {
67    "skill": "cognition:critical-thinking-workflow",
68    "plugin": "cognition",
69    "phrases": [
70      "critical thinking full process",
71      "decision stress-test",
72      "high-stakes decision review",
73      "결정 제대로 검토해줘",
74      "논리 전체 점검",
75      "전략 검토 전체"
76    ]
77  },
78  {
79    "skill": "cognition:epistemic-reasoner",
80    "plugin": "cognition",
81    "phrases": []
82  },
83  {
84    "skill": "cognition:fallacy-detector",
85    "plugin": "cognition",
86    "phrases": [
87      "post hoc",
88      "spot the fallacy",
89      "strawman",
90      "논리에 허점 있어?",
91      "원인이 너무 깔끔한데",
92      "이거 억지 아냐?"
93    ]
94  },
95  {
96    "skill": "cognition:mental-model-toolkit",
97    "plugin": "cognition",
98    "phrases": [
99      "blind spots",
100      "mental model",
101      "unknown unknowns",
102      "내가 뭘 놓치고 있지?",
103      "다른 관점이 필요해",
104      "해볼 건 다 해봤는데"
105    ]
106  },
107  {
108    "skill": "cognition:question-upgrader",
109    "plugin": "cognition",
110    "phrases": [
111      "am i asking the right question",
112      "better question",
113      "이 질문이 맞는 질문일까?",
114      "질문 자체가 틀린 건 아닐까?",
115      "질문 좀 더 좋게"
116    ]
117  },
118  {
119    "skill": "cognition:second-order-thinker",
120    "plugin": "cognition",
121    "phrases": []
122  },
123  {
124    "skill": "cognition:tradeoff-articulator",
125    "plugin": "cognition",
126    "phrases": [
127      "tradeoff",
128      "장단점",
129      "트레이드오프"
130    ]
131  },
132  {
133    "skill": "completion:verification-before-completion",
134    "plugin": "completion",
135    "phrases": [
136      "bug is fixed",
137      "done",
138      "pr 올릴게",
139      "should pass",
140      "버그 고쳤어",
141      "빌드 성공",
142      "완료했어",
143      "커밋할게",
144      "테스트 통과"
145    ]
146  },
147  {
148    "skill": "develop:architecture-designer",
149    "plugin": "develop",
150    "phrases": []
151  },
152  {
153    "skill": "develop:architecture-review",
154    "plugin": "develop",
155    "phrases": [
156      "where should we refactor this repo",
157      "어디부터 리팩토링할까",
158      "코드가 얽혀서 손대기 힘들어"
159    ]
160  },
161  {
162    "skill": "develop:architecture-workflow",
163    "plugin": "develop",
164    "phrases": [
165      "system architecture from scratch"
166    ]
167  },
168  {
169    "skill": "develop:bug-diagnoser",
170    "plugin": "develop",
171    "phrases": [
172      "debug this",
173      "fix this bug",
174      "why is this failing",
175      "갑자기 안 돼",
176      "고쳐줘",
177      "디버깅",
178      "안 돼요",
179      "에러 나요",
180      "왜 이러죠"
181    ]
182  },
183  {
184    "skill": "develop:chaos-engineer",
185    "plugin": "develop",
186    "phrases": [
187      "chaos experiment",
188      "game day 준비",
189      "kill a pod in staging",
190      "pod 죽여보고 싶어",
191      "simulate zone outage",
192      "장애 주입 테스트"
193    ]
194  },
195  {
196    "skill": "develop:circuit-breaker-tuner",
197    "plugin": "develop",
198    "phrases": []
199  },
200  {
201    "skill": "develop:clean-architecture",
202    "plugin": "develop",
203    "phrases": [
204      "레이어 아키텍처",
205      "의존성 규칙"
206    ]
207  },
208  {
209    "skill": "develop:clean-code",
210    "plugin": "develop",
211    "phrases": [
212      "code review",
213      "pr 리뷰",
214      "review this branch",
215      "리팩토링",
216      "어떻게 구현해?",
217      "이 브랜치 리뷰해줘",
218      "코드 리뷰"
219    ]
220  },
221  {
222    "skill": "develop:cli-developer",
223    "plugin": "develop",
224    "phrases": []
225  },
226  {
227    "skill": "develop:code-documenter",
228    "plugin": "develop",
229    "phrases": [
230      "add jsdoc",
231      "generate openapi spec",
232      "swagger 문서 만들어줘",
233      "write a readme tutorial",
234      "문서 사이트 만들어줘",
235      "주석/독스트링 달아줘"
236    ]
237  },
238  {
239    "skill": "develop:connection-pool-tuner",
240    "plugin": "develop",
241    "phrases": [
242      "connection timeout",
243      "hikaricp 사이즈 어떻게 잡아?",
244      "pgbouncer tuning",
245      "pool exhaustion",
246      "커넥션 풀 고갈",
247      "트래픽 몰리면 타임아웃"
248    ]
249  },
250  {
251    "skill": "develop:database-optimizer",
252    "plugin": "develop",
253    "phrases": [
254      "innodb buffer pool",
255      "lock contention",
256      "shared_buffers 튜닝",
257      "stale statistics",
258      "vacuum 밀림",
259      "락 경합",
260      "파티셔닝 설계"
261    ]
262  },
263  {
264    "skill": "develop:database-workflow",
265    "plugin": "develop",
266    "phrases": [
267      "database performance investigation",
268      "database workflow",
269      "db 성능 전체",
270      "db 전체 점검"
271    ]
272  },
273  {
274    "skill": "develop:deprecation-and-migration",
275    "plugin": "develop",
276    "phrases": [
277      "drop this column",
278      "이 api 없애자",
279      "컬럼 지우는 마이그레이션"
280    ]
281  },
282  {
283    "skill": "develop:dev-quality-workflow",
284    "plugin": "develop",
285    "phrases": [
286      "dev quality process",
287      "full dev cycle",
288      "개발 품질 전체",
289      "새 기능 처음부터 제대로"
290    ]
291  },
292  {
293    "skill": "develop:dockerfile-optimizer",
294    "plugin": "develop",
295    "phrases": [
296      "docker optimize",
297      "dockerfile",
298      "도커 최적화",
299      "빌드 시간",
300      "이거 좀 봐줘",
301      "이미지 크기",
302      "컨테이너 최적화"
303    ]
304  },
305  {
306    "skill": "develop:documentation-strategy",
307    "plugin": "develop",
308    "phrases": [
309      "doc coverage map",
310      "documentation audit",
311      "런북/adr 체계",
312      "문서 뭐부터 써야 해?",
313      "온보딩 문서 정리"
314    ]
315  },
316  {
317    "skill": "develop:domain-driven-design",
318    "plugin": "develop",
319    "phrases": [
320      "bounded context",
321      "ddd",
322      "domain-driven design",
323      "도메인 모델링",
324      "바운디드 컨텍스트"
325    ]
326  },
327  {
328    "skill": "develop:event-storming",
329    "plugin": "develop",
330    "phrases": [
331      "map our business flow",
332      "workshop with domain experts",
333      "도메인 모델링 워크숍",
334      "도메인 이벤트 뽑아보자",
335      "이벤트 스토밍"
336    ]
337  },
338  {
339    "skill": "develop:flaky-test-analyzer",
340    "plugin": "develop",
341    "phrases": [
342      "flaky test"
343    ]
344  },
345  {
346    "skill": "develop:frontend-developer",
347    "plugin": "develop",
348    "phrases": [
349      "build a react component"
350    ]
351  },
352  {
353    "skill": "develop:incident-response-playbook",
354    "plugin": "develop",
355    "phrases": [
356      "prod is down",
357      "severity 분류",
358      "write an rca",
359      "장애 났어",
360      "포스트모템 써줘",
361      "프로덕션 터졌어"
362    ]
363  },
364  {
365    "skill": "develop:kotlin-specialist",
366    "plugin": "develop",
367    "phrases": [
368      "kotlin coroutines"
369    ]
370  },
371  {
372    "skill": "develop:microservices-architect",
373    "plugin": "develop",
374    "phrases": [
375      "decompose the monolith",
376      "msa로 쪼갤까?",
377      "saga design",
378      "sync vs async calls",
379      "분산 시스템 설계",
380      "서비스 간 통신 rest vs 이벤트"
381    ]
382  },
383  {
384    "skill": "develop:operations-workflow",
385    "plugin": "develop",
386    "phrases": [
387      "going to prod",
388      "pre-launch ops check",
389      "production readiness review",
390      "system hardening",
391      "운영 준비"
392    ]
393  },
394  {
395    "skill": "develop:performance-profiling-optimization",
396    "plugin": "develop",
397    "phrases": [
398      "api가 느려요",
399      "high cpu",
400      "p99 latency spike",
401      "profile this endpoint",
402      "메모리 계속 늘어나",
403      "병목 찾아줘"
404    ]
405  },
406  {
407    "skill": "develop:scenario-actor",
408    "plugin": "develop",
409    "phrases": [
410      "run this scenario",
411      "spec=… base_url=…",
412      "이 spec 실행해줘",
413      "이 시나리오 하나만 돌려줘"
414    ]
415  },
416  {
417    "skill": "develop:scenario-director",
418    "plugin": "develop",
419    "phrases": [
420      "api 시나리오 테스트",
421      "flow test",
422      "시나리오 ci 붙여줘",
423      "시나리오 md 읽고 서버에 돌려줘"
424    ]
425  },
426  {
427    "skill": "develop:security-auditor",
428    "plugin": "develop",
429    "phrases": [
430      "is this exploitable",
431      "보안 점검",
432      "취약점 찾아줘"
433    ]
434  },
435  {
436    "skill": "develop:service-boundary-validator",
437    "plugin": "develop",
438    "phrases": [
439      "db 공유 문제 없나?",
440      "distributed monolith",
441      "split or merge",
442      "validate this service boundary",
443      "결합도 너무 높은 것 같아",
444      "서비스 쪼개도 될까?"
445    ]
446  },
447  {
448    "skill": "develop:spring-boot-engineer",
449    "plugin": "develop",
450    "phrases": []
451  },
452  {
453    "skill": "develop:sql-pro",
454    "plugin": "develop",
455    "phrases": []
456  },
457  {
458    "skill": "develop:sre-engineer",
459    "plugin": "develop",
460    "phrases": [
461      "burn-rate alert",
462      "golden signals dashboard",
463      "slo 정하고 싶어",
464      "반복 작업(toil) 줄이기",
465      "알람 피로",
466      "에러 버짓"
467    ]
468  },
469  {
470    "skill": "develop:test-driven-development",
471    "plugin": "develop",
472    "phrases": [
473      "red-green-refactor",
474      "tdd",
475      "test-first",
476      "테스트 먼저 작성"
477    ]
478  },
479  {
480    "skill": "develop:test-master",
481    "plugin": "develop",
482    "phrases": [
483      "defect report",
484      "test case writing",
485      "test plan",
486      "단위 테스트",
487      "커버리지 분석",
488      "테스트 작성",
489      "테스트 케이스 작성"
490    ]
491  },
492  {
493    "skill": "develop:testing-workflow",
494    "plugin": "develop",
495    "phrases": [
496      "tdd부터 커버리지까지",
497      "testing workflow",
498      "테스트 전체 프로세스"
499    ]
500  },
501  {
502    "skill": "develop:transaction-boundary-reviewer",
503    "plugin": "develop",
504    "phrases": [
505      "@transactional 범위",
506      "deadlock under load",
507      "isolation level",
508      "two writes atomic",
509      "데이터 정합성 깨져",
510      "트랜잭션 락 타임아웃"
511    ]
512  },
513  {
514    "skill": "graph:install",
515    "plugin": "graph",
516    "phrases": []
517  },
518  {
519    "skill": "graph:orchestrate",
520    "plugin": "graph",
521    "phrases": [
522      "graph-engineering 노드 루프",
523      "graph-engineering으로 돌려줘",
524      "graph-owned harness flow"
525    ]
526  },
527  {
528    "skill": "harness:codex-control",
529    "plugin": "harness",
530    "phrases": []
531  },
532  {
533    "skill": "harness:harness",
534    "plugin": "harness",
535    "phrases": [
536      "6단계로 처리해줘",
537      "do this properly with verification",
538      "run the harness",
539      "검증까지 해서",
540      "메타스크립트로",
541      "이거 제대로 해줘",
542      "하네스 돌려줘"
543    ]
544  },
545  {
546    "skill": "harness:install",
547    "plugin": "harness",
548    "phrases": [
549      "convention 스캐폴딩해줘",
550      "harness install",
551      "set up the harness here",
552      "게이트 켜줘",
553      "이 프로젝트에 하네스 적용해줘",
554      "하네스 설치해줘"
555    ]
556  },
557  {
558    "skill": "harness:remove",
559    "plugin": "harness",
560    "phrases": [
561      "harness remove",
562      "uninstall harness",
563      "하네스 삭제",
564      "하네스 제거"
565    ]
566  },
567  {
568    "skill": "harness:update",
569    "plugin": "harness",
570    "phrases": [
571      "harness update",
572      "refresh harness copies",
573      "하네스 업데이트",
574      "하네스 최신으로"
575    ]
576  },
577  {
578    "skill": "knowledge:base-builder",
579    "plugin": "knowledge",
580    "phrases": []
581  },
582  {
583    "skill": "knowledge:graph-builder",
584    "plugin": "knowledge",
585    "phrases": []
586  },
587  {
588    "skill": "knowledge:ontology-builder",
589    "plugin": "knowledge",
590    "phrases": [
591      "class·relation 정리",
592      "controlled vocabulary",
593      "design an ontology",
594      "온톨로지 설계",
595      "용어/관계 체계 잡아줘"
596    ]
597  },
598  {
599    "skill": "knowledge:query",
600    "plugin": "knowledge",
601    "phrases": [
602      "answer with citations",
603      "vault에서 알려줘",
604      "근거 달아서 답해줘",
605      "이 knowledge base에서 찾아줘"
606    ]
607  },
608  {
609    "skill": "knowledge:rag-corpus-builder",
610    "plugin": "knowledge",
611    "phrases": []
612  },
613  {
614    "skill": "knowledge:render-graph-view",
615    "plugin": "knowledge",
616    "phrases": []
617  },
618  {
619    "skill": "knowledge:sqlite-index-builder",
620    "plugin": "knowledge",
621    "phrases": []
622  },
623  {
624    "skill": "knowledge:workflow",
625    "plugin": "knowledge",
626    "phrases": [
627      "eval 점수 개선 루프",
628      "repair retrieval loop",
629      "vault 처음부터 끝까지",
630      "검색 품질이 안 올라"
631    ]
632  },
633  {
634    "skill": "planning:executing-plans",
635    "plugin": "planning",
636    "phrases": [
637      "execute this plan",
638      "plan 리뷰하고 가자",
639      "plan 실행",
640      "계획 실행해줘",
641      "실행 전에 기준 잡아줘",
642      "이 계획 검토하고 진행"
643    ]
644  },
645  {
646    "skill": "planning:roadmap-planning",
647    "plugin": "planning",
648    "phrases": [
649      "feature list를 outcome 기반으로 바꿔줘",
650      "q2 로드맵",
651      "roadmap planning",
652      "로드맵 만들어줘",
653      "분기 계획",
654      "분기별 계획 세워줘",
655      "이니셔티브 우선순위 정해줘",
656      "이해관계자 정렬"
657    ]
658  },
659  {
660    "skill": "portfolio:deck-builder",
661    "plugin": "portfolio",
662    "phrases": []
663  },
664  {
665    "skill": "portfolio:feedback",
666    "plugin": "portfolio",
667    "phrases": [
668      "고친 버전 비교",
669      "서류 통과할까",
670      "숫자 근거 있어?",
671      "시니어로 읽혀?",
672      "포트폴리오 피드백 해줘"
673    ]
674  },
675  {
676    "skill": "portfolio:feedback-beta",
677    "plugin": "portfolio",
678    "phrases": [
679      "beta feedback with patterns",
680      "feedback beta",
681      "피드백 베타로",
682      "피드백이랑 문체 패턴까지 같이 봐줘"
683    ]
684  },
685  {
686    "skill": "portfolio:fit",
687    "plugin": "portfolio",
688    "phrases": [
689      "jd랑 비교해줘",
690      "which companies fit me",
691      "네이버 지원하려는데 어때?",
692      "서류 통과할까?",
693      "어느 회사에 잘 맞아?",
694      "이 공고에 맞아?"
695    ]
696  },
697  {
698    "skill": "portfolio:interview-plan",
699    "plugin": "portfolio",
700    "phrases": [
701      "interview prep plan",
702      "면접 준비 계획"
703    ]
704  },
705  {
706    "skill": "portfolio:job-application-workflow",
707    "plugin": "portfolio",
708    "phrases": [
709      "career transition",
710      "job application workflow",
711      "job search process",
712      "이직 준비 전체",
713      "취업 프로세스 시작"
714    ]
715  },
716  {
717    "skill": "portfolio:mock-interview",
718    "plugin": "portfolio",
719    "phrases": [
720      "grill me on my portfolio",
721      "mock interview",
722      "모의 면접 해줘",
723      "인터뷰 연습"
724    ]
725  },
726  {
727    "skill": "portfolio:pattern",
728    "plugin": "portfolio",
729    "phrases": [
730      "오너십이 잘 드러나나",
731      "패턴 분석해줘",
732      "피동형 많이 썼나"
733    ]
734  },
735  {
736    "skill": "portfolio:rewrite",
737    "plugin": "portfolio",
738    "phrases": [
739      "rewrite my resume bullet",
740      "tailor resume to jd",
741      "공고에 맞게 이력서 최적화",
742      "이력서 문장 고쳐줘",
743      "포트폴리오 임팩트 있게 바꿔줘"
744    ]
745  },
746  {
747    "skill": "skill:audit",
748    "plugin": "skill",
749    "phrases": [
750      "what am i missing",
751      "what skills or plugins do i have here",
752      "뭘 더 설치하면 좋아?",
753      "워크스페이스 점검"
754    ]
755  },
756  {
757    "skill": "skill:create",
758    "plugin": "skill",
759    "phrases": [
760      "create a skill",
761      "새 스킬 뼈대",
762      "스킬 만들어줘",
763      "우리 스킬처럼 만들어줘",
764      "이걸 스킬로"
765    ]
766  },
767  {
768    "skill": "skill:quality-assurance",
769    "plugin": "skill",
770    "phrases": []
771  },
772  {
773    "skill": "skill:retro",
774    "plugin": "skill",
775    "phrases": [
776      "session retro",
777      "stop repeating this mistake",
778      "또 같은 실수네",
779      "이번 세션 회고해서 다음에 같은 실수 안 하게 해줘"
780    ]
781  },
782  {
783    "skill": "skill:trigger-validator",
784    "plugin": "skill",
785    "phrases": [
786      "description 개선해줘",
787      "description이 너무 keyword만 있어",
788      "skill not firing",
789      "skill 발동 조건 점검",
790      "trigger coverage audit",
791      "스킬이 트리거 안 돼",
792      "한국어 트리거 추가해줘"
793    ]
794  },
795  {
796    "skill": "teams:board",
797    "plugin": "teams",
798    "phrases": [
799      "show me the board",
800      "what epics are running",
801      "보드 보여줘",
802      "티켓 현황"
803    ]
804  },
805  {
806    "skill": "teams:develop",
807    "plugin": "teams",
808    "phrases": []
809  },
810  {
811    "skill": "teams:document",
812    "plugin": "teams",
813    "phrases": []
814  },
815  {
816    "skill": "teams:inbox",
817    "plugin": "teams",
818    "phrases": [
819      "show my inbox",
820      "what's waiting on me",
821      "나 기다리는 카드 있어?",
822      "내 할 일 뭐 있어"
823    ]
824  },
825  {
826    "skill": "teams:install",
827    "plugin": "teams",
828    "phrases": []
829  },
830  {
831    "skill": "teams:log",
832    "plugin": "teams",
833    "phrases": [
834      "p2 지금 뭐 하고 있어",
835      "tail the log for e-xxx/p2",
836      "what is this story doing",
837      "로그 보여줘"
838    ]
839  },
840  {
841    "skill": "teams:orchestrate",
842    "plugin": "teams",
843    "phrases": [
844      "orchestrate this run",
845      "run it through the mcp",
846      "그래프 돌려줘",
847      "노드 단위로 돌려줘"
848    ]
849  },
850  {
851    "skill": "teams:patch",
852    "plugin": "teams",
853    "phrases": []
854  },
855  {
856    "skill": "teams:plan",
857    "plugin": "teams",
858    "phrases": []
859  },
860  {
861    "skill": "teams:qa",
862    "plugin": "teams",
863    "phrases": []
864  },
865  {
866    "skill": "teams:remove",
867    "plugin": "teams",
868    "phrases": []
869  },
870  {
871    "skill": "teams:sprint",
872    "plugin": "teams",
873    "phrases": [
874      "run this as a sprint",
875      "sprint this backlog",
876      "다음 스프린트로 이어가줘",
877      "스프린트 돌려줘"
878    ]
879  },
880  {
881    "skill": "teams:submit",
882    "plugin": "teams",
883    "phrases": [
884      "submit my answer",
885      "완료 보고할게",
886      "이 카드 제출할게",
887      "작업 다 했어 제출해줘"
888    ]
889  },
890  {
891    "skill": "teams:take",
892    "plugin": "teams",
893    "phrases": [
894      "assign this to me",
895      "take e-xxx/p2",
896      "나한테 배정해줘",
897      "이 카드 내가 할게"
898    ]
899  },
900  {
901    "skill": "teams:ticket",
902    "plugin": "teams",
903    "phrases": [
904      "last verdict on this story",
905      "p2 상태가 뭐야",
906      "show me ticket e-xxx/p2",
907      "이 티켓 상세"
908    ]
909  },
910  {
911    "skill": "think:back-to-basics",
912    "plugin": "think",
913    "phrases": [
914      "first principles",
915      "가정을 의심해봐",
916      "기본부터 다시 생각해",
917      "왜 이렇게 해야 해?",
918      "처음부터 설계하면"
919    ]
920  },
921  {
922    "skill": "think:brainstorming",
923    "plugin": "think",
924    "phrases": [
925      "design this feature",
926      "how should we build x?",
927      "기능 설계해줘",
928      "발산해줘",
929      "아키텍처 잡아줘",
930      "어떻게 만들지?",
931      "옵션 더 뽑아줘"
932    ]
933  },
934  {
935    "skill": "think:devils-advocate",
936    "plugin": "think",
937    "phrases": [
938      "devil's advocate",
939      "punch holes in",
940      "steel-man this",
941      "반론 던져줘",
942      "비판해줘",
943      "약점 찾아줘",
944      "이 계획 문제점이 뭐야?",
945      "치명적 약점"
946    ]
947  },
948  {
949    "skill": "think:grill",
950    "plugin": "think",
951    "phrases": [
952      "grill me",
953      "interview me about this plan",
954      "나 좀 빡세게 질문해줘",
955      "내 계획 질문으로 털어줘",
956      "설계하기 전에 질문으로 털어줘"
957    ]
958  },
959  {
960    "skill": "think:mentor",
961    "plugin": "think",
962    "phrases": [
963      "how would a senior think about this",
964      "mentor me",
965      "멘토링 해줘",
966      "선배처럼 조언",
967      "커리어 고민"
968    ]
969  },
970  {
971    "skill": "think:negotiate",
972    "plugin": "think",
973    "phrases": [
974      "negotiate my offer",
975      "계약 조건 봐줘",
976      "상사한테 어떻게 말해",
977      "설득해야 해",
978      "연봉 협상",
979      "협상 준비"
980    ]
981  },
982  {
983    "skill": "think:redefine-problem",
984    "plugin": "think",
985    "phrases": [
986      "solutions feel off",
987      "wrong problem",
988      "계속 이 문제가 반복돼",
989      "다른 각도로 봐야 할 것 같아",
990      "뭔가 잘못된 것 같아",
991      "이 문제 자체가 맞는 건지",
992      "증상만 자꾸 잡는 느낌"
993    ]
994  },
995  {
996    "skill": "think:untangle-thoughts",
997    "plugin": "think",
998    "phrases": [
999      "mind map",
1000      "outline 만들어줘",
1001      "글 쓰기 전에 구조 잡아줘",
1002      "노트 정리",
1003      "머릿속이 복잡해",
1004      "산만한 생각 정리",
1005      "생각 정리해줘",
1006      "아이디어 구조화",
1007      "이거 어떻게 엮어야 해?"
1008    ]
1009  },
1010  {
1011    "skill": "trophy:list",
1012    "plugin": "trophy",
1013    "phrases": [
1014      "achievements",
1015      "trophy list",
1016      "내 트로피",
1017      "스킬 사용 현황",
1018      "어떤 스킬을 놓쳤지?",
1019      "업적 보여줘"
1020    ]
1021  },
1022  {
1023    "skill": "write:like-me",
1024    "plugin": "write",
1025    "phrases": [
1026      "sound like me",
1027      "write it in my voice",
1028      "내 말투로",
1029      "내 문체로",
1030      "내가 쓴 것처럼"
1031    ]
1032  },
1033  {
1034    "skill": "write:plans",
1035    "plugin": "write",
1036    "phrases": [
1037      "adr 써줘",
1038      "prd 작성",
1039      "구현 계획 써줘",
1040      "티켓으로 쪼개줘"
1041    ]
1042  },
1043  {
1044    "skill": "write:spec",
1045    "plugin": "write",
1046    "phrases": [
1047      "turn this conversation into a spec",
1048      "대화 내용으로 스펙 써줘",
1049      "방금 얘기한 거 spec으로 정리해줘"
1050    ]
1051  },
1052  {
1053    "skill": "write:writer-verification",
1054    "plugin": "write",
1055    "phrases": [
1056      "ai 티 나",
1057      "pr 설명 써줘",
1058      "proofread",
1059      "글 검토해줘",
1060      "글 다듬어줘",
1061      "맞춤법 확인",
1062      "사람이 쓴 것처럼"
1063    ]
1064  },
1065  {
1066    "skill": "write:writing-skills",
1067    "plugin": "write",
1068    "phrases": [
1069      "fix this skill",
1070      "skill documentation",
1071      "skill 개선해줘",
1072      "skill.md 고쳐줘",
1073      "skill이 안 떠",
1074      "스킬 문서 작성"
1075    ]
1076  }
1077]
1078
hooks/logic.ts 283 lines
1// Pure functions of the trophy module: no `$`, no store, no clock.
2
3import { failureEvents } from './failures.ts'
4import type { Entry } from './failures.ts'
5
6export type Use = { skill: string; plugin: string; day: string; session: string; ts: number }
7
8export type Rule =
9  | { kind: 'first_use'; plugin: string }
10  | { kind: 'collect'; count: number; plugin?: string }
11  | { kind: 'combo'; sequence: string[] }
12  | { kind: 'streak'; days: number }
13  | { kind: 'repeat'; skill: string; count: number }
14
15export type Achievement = {
16  id: string
17  title: string
18  description: string
19  ko: { title: string; description: string }
20  hidden?: true
21  rule: Rule
22}
23
24export type TriggerEntry = { skill: string; plugin: string; phrases: string[] }
25
26export const MAX_USES = 5000
27const DUPLICATE_MS = 5000
28
29export const dayOf = (ts: number) => new Date(ts).toISOString().slice(0, 10)
30
31// `plugin:skill` of this marketplace stays; a bare name maps through the index (two owners: kept bare, no plugin).
32export function resolveSkill(name: string, index: readonly TriggerEntry[]): { skill: string; plugin: string } | null {
33  if (name.includes(':')) {
34    return index.some(t => t.skill === name) ? { skill: name, plugin: name.split(':')[0]! } : null
35  }
36  const owners = index.filter(t => t.skill.endsWith(`:${name}`))
37  if (owners.length === 0) return null
38  if (owners.length === 1) return { skill: owners[0]!.skill, plugin: owners[0]!.plugin }
39  return { skill: name, plugin: '' }
40}
41
42// The Skill tool, skill.prompt and the typed command all report one use: same skill, same session, within 5 s is one.
43// The command a prompt typed, raw (`/think:grill …`) or as the engine expands it (`<command-name>/think:grill</command-name>`).
44export function typedCommand(text: string): string | undefined {
45  return (/^\s*\/(\S+)/.exec(text) ?? /<command-name>\/([^<\s]+)<\/command-name>/.exec(text))?.[1]
46}
47
48export function recordUse(uses: readonly Use[], skill: string, now: number, session: string): Use[] {
49  const last = uses[uses.length - 1]
50  if (last && last.skill === skill && last.session === session && now - last.ts < DUPLICATE_MS) return [...uses]
51  const plugin = skill.includes(':') ? skill.split(':')[0]! : ''
52  return [...uses, { skill, plugin, day: dayOf(now), session, ts: now }].slice(-MAX_USES)
53}
54
55const distinct = (uses: readonly Use[], plugin?: string) =>
56  new Set(uses.filter(u => plugin === undefined || u.plugin === plugin).map(u => u.skill)).size
57
58// How many of a combo's skills appear in order inside one session; the best session counts.
59function comboSteps(uses: readonly Use[], sequence: readonly string[]) {
60  const sessions = new Map<string, Use[]>()
61  for (const u of uses) sessions.set(u.session, [...(sessions.get(u.session) ?? []), u])
62  let best = 0
63  for (const list of sessions.values()) {
64    let step = 0
65    for (const u of [...list].sort((a, b) => a.ts - b.ts)) if (u.skill === sequence[step]) step += 1
66    best = Math.max(best, step)
67  }
68  return best
69}
70
71function longestStreak(uses: readonly Use[]) {
72  const days = [...new Set(uses.map(u => Date.parse(u.day) / 86_400_000))].sort((a, b) => a - b)
73  let best = 0
74  let run = 0
75  days.forEach((d, i) => {
76    run = i > 0 && d - days[i - 1]! === 1 ? run + 1 : 1
77    best = Math.max(best, run)
78  })
79  return best
80}
81
82// [have, need] for one achievement; the pane draws it and evaluate compares it.
83export function progress(uses: readonly Use[], { rule }: Achievement): [number, number] {
84  switch (rule.kind) {
85    case 'first_use':
86      return [uses.some(u => u.plugin === rule.plugin) ? 1 : 0, 1]
87    case 'collect':
88      return [Math.min(distinct(uses, rule.plugin), rule.count), rule.count]
89    case 'combo':
90      return [comboSteps(uses, rule.sequence), rule.sequence.length]
91    case 'streak':
92      return [Math.min(longestStreak(uses), rule.days), rule.days]
93    case 'repeat':
94      return [Math.min(uses.filter(u => u.skill === rule.skill).length, rule.count), rule.count]
95  }
96}
97
98// Ids newly met: never one already unlocked.
99export function evaluate(uses: readonly Use[], catalog: readonly Achievement[], unlocked: Readonly<Record<string, string>>) {
100  return catalog
101    .filter(a => unlocked[a.id] === undefined)
102    .filter(a => {
103      const [have, need] = progress(uses, a)
104      return have >= need
105    })
106    .map(a => a.id)
107}
108
109// Skills whose trigger phrase appears in a prompt. A typed `/command` is not natural language.
110export function matchTriggers(text: string, index: readonly TriggerEntry[]): string[] {
111  if (text.trimStart().startsWith('/')) return []
112  const lower = text.toLowerCase()
113  return index.filter(t => t.phrases.some(p => lower.includes(p))).map(t => t.skill)
114}
115
116export type TurnResult = { hit: string[]; miss: string[]; unmatched: string[] }
117
118// hit: matched and fired; miss: matched, not fired; unmatched: fired, not matched.
119export function closeTurn(matched: readonly string[], fired: readonly string[]): TurnResult {
120  return {
121    hit: matched.filter(s => fired.includes(s)),
122    miss: matched.filter(s => !fired.includes(s)),
123    unmatched: [...new Set(fired)].filter(s => !matched.includes(s)),
124  }
125}
126
127export type Counts = { hit: number; miss: number; unmatched: number }
128export type DayCounts = Record<string, Record<string, Counts>>
129
130export function addTurn(counts: DayCounts, day: string, turn: TurnResult): DayCounts {
131  const today = { ...counts[day] }
132  for (const kind of ['hit', 'miss', 'unmatched'] as const) {
133    for (const skill of turn[kind]) {
134      today[skill] = { hit: 0, miss: 0, unmatched: 0, ...today[skill] }
135      today[skill]![kind] += 1
136    }
137  }
138  return { ...counts, [day]: today }
139}
140
141const BAR_CELLS = 5
142
143export function bar(have: number, need: number) {
144  const filled = Math.round((have / need) * BAR_CELLS)
145  return '▓'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
146}
147
148// The pane's progress: eight ▰▱ cells and have/need.
149export function cells(a: Achievement, uses: readonly Use[]) {
150  const [have, need] = progress(uses, a)
151  const filled = Math.round((have / need) * 8)
152  return `${'▰'.repeat(filled)}${'▱'.repeat(8 - filled)} ${have}/${need}`
153}
154
155// One row of the achievements list, as the pane and trophy:list draw it; the pane draws its own bar (`withBar` false).
156export function achievementRow(a: Achievement, uses: readonly Use[], unlockedOn: string | undefined, withBar = true) {
157  if (unlockedOn !== undefined) return `🏆 ${a.title} · ${unlockedOn}`
158  if (a.hidden) return '🔒 ???'
159  if (!withBar) return `🔒 ${a.title}`
160  const [have, need] = progress(uses, a)
161  return `🔒 ${a.title}  ${bar(have, need)} ${have}/${need}`
162}
163
164// Counts of the last `days` days up to and including `today`, summed per skill.
165export function sumDays(counts: DayCounts, today: string, days: number): Record<string, Counts> {
166  const from = Date.parse(today) - (days - 1) * 86_400_000
167  const sum: Record<string, Counts> = {}
168  for (const [day, skills] of Object.entries(counts)) {
169    const t = Date.parse(day)
170    if (t < from || t > Date.parse(today)) continue
171    for (const [skill, c] of Object.entries(skills)) {
172      const s = (sum[skill] ??= { hit: 0, miss: 0, unmatched: 0 })
173      s.hit += c.hit
174      s.miss += c.miss
175      s.unmatched += c.unmatched
176    }
177  }
178  return sum
179}
180
181export type TriggerLists = { hit: [string, number][]; miss: [string, number][]; never: string[] }
182
183// Most hit, most missed (routing gaps), and skills that never fired in the window.
184export function triggerLists(sum: Record<string, Counts>, index: readonly TriggerEntry[], top = 5): TriggerLists {
185  const ranked = (kind: 'hit' | 'miss') =>
186    Object.entries(sum)
187      .filter(([, c]) => c[kind] > 0)
188      .sort((a, b) => b[1][kind] - a[1][kind])
189      .slice(0, top)
190      .map(([skill, c]): [string, number] => [skill, c[kind]])
191  return {
192    hit: ranked('hit'),
193    miss: ranked('miss'),
194    never: index.filter(t => !sum[t.skill] || sum[t.skill]!.hit + sum[t.skill]!.unmatched === 0).map(t => t.skill),
195  }
196}
197
198// What trophy:list reads: no `uses`, no session ids.
199export function buildProfile(
200  uses: readonly Use[],
201  unlocked: Record<string, string>,
202  counts: DayCounts,
203  catalog: readonly Achievement[],
204  index: readonly TriggerEntry[],
205  now: number,
206) {
207  return {
208    updated: new Date(now).toISOString(),
209    unlocked,
210    progress: Object.fromEntries(catalog.map(a => [a.id, progress(uses, a)])),
211    triggers7d: triggerLists(sumDays(counts, dayOf(now), 7), index),
212  }
213}
214
215// Error text may carry paths; keep none of them, and no more than 300 characters.
216export function scrub(message: string) {
217  return message.replace(/(?<![\w.])(?:[A-Za-z]:\\[^\s'"():;,]*|~?\/[^\s'"():;,]*)/g, '<path>').slice(0, 300)
218}
219
220// v1 = the first band text (no version stored); v2 names error codes.
221export const CONSENT_VERSION = 2
222export type ConsentAnswer = 'unasked' | 'yes' | 'no'
223
224// A yes counts only for the text version it answered; an older yes reads as unasked, a no stays no.
225export function effectiveConsent(stored: ConsentAnswer | undefined, version: number | undefined): ConsentAnswer {
226  if (stored === 'no') return 'no'
227  if (stored === 'yes' && version === CONSENT_VERSION) return 'yes'
228  return 'unasked'
229}
230
231export const POSTHOG_URL = 'https://us.i.posthog.com/batch/'
232// Write-only project token: public by design.
233export const POSTHOG_KEY = 'phc_r4NATbMFBZvmQYiJ8MPMJSWHprgbsTkbCddtc6aYAoUg'
234
235export type BatchStore = {
236  installId: string
237  sentThrough?: string
238  uses: readonly Use[]
239  triggers: DayCounts
240  unlocked: Record<string, string>
241  errors: readonly { day: string; message: string }[]
242  plugins: Record<string, string[]>
243  failures: readonly Entry[]
244}
245
246// The events of every day after `sentThrough` up to `through`. Only skill names, plugin names, days and counts.
247export function buildBatch(store: BatchStore, through: string) {
248  const inRange = (day: string) => day > (store.sentThrough ?? '') && day <= through
249  const event = (name: string, day: string, properties: Record<string, unknown>) => ({
250    event: name,
251    distinct_id: store.installId,
252    timestamp: `${day}T12:00:00Z`,
253    properties: { ...properties, $process_person_profile: false },
254  })
255  const batch: ReturnType<typeof event>[] = []
256
257  const used = new Map<string, { day: string; skill: string; plugin: string; count: number }>()
258  const pluginsByDay: Record<string, Set<string>> = {}
259  for (const [day, list] of Object.entries(store.plugins)) pluginsByDay[day] = new Set(list)
260  for (const u of store.uses.filter(u => inRange(u.day))) {
261    const row = used.get(`${u.day} ${u.skill}`) ?? { day: u.day, skill: u.skill, plugin: u.plugin, count: 0 }
262    used.set(`${u.day} ${u.skill}`, { ...row, count: row.count + 1 })
263    if (u.plugin) (pluginsByDay[u.day] ??= new Set()).add(u.plugin)
264  }
265  for (const { day, ...rest } of used.values()) batch.push(event('skill_used', day, { ...rest, day }))
266  for (const [day, skills] of Object.entries(store.triggers).filter(([d]) => inRange(d))) {
267    for (const [skill, c] of Object.entries(skills)) batch.push(event('trigger_result', day, { skill, day, ...c }))
268  }
269  for (const [id, day] of Object.entries(store.unlocked).filter(([, d]) => inRange(d))) {
270    batch.push(event('achievement_unlocked', day, { id }))
271  }
272  for (const [day, set] of Object.entries(pluginsByDay).filter(([d]) => inRange(d))) {
273    for (const plugin of [...set].sort()) batch.push(event('plugins_installed', day, { plugin, day }))
274  }
275  for (const e of store.errors.filter(e => e.day <= through)) {
276    batch.push(event('$exception', e.day, { $exception_message: e.message }))
277  }
278  for (const f of failureEvents(store.failures, store.sentThrough, through)) {
279    batch.push(event(f.event, String(f.properties.day), f.properties))
280  }
281  return { api_key: POSTHOG_KEY, batch }
282}
283
hooks/failures.ts 179 lines
1// Failures of this marketplace's skills and MCP tools: pure logic, no `$`, no I/O. mod.tsx captures, logic.ts sends.
2
3export type Reason =
4  | 'is_error'
5  | 'unsuccessful'
6  | 'forked_unsuccessful'
7  | 'mcp_error'
8  | 'user_report'
9  | 'subgoal_failed'
10  | 'goal_failed'
11
12export type Entry = {
13  ts: number
14  day: string
15  kind: 'bug' | 'outcome' | 'report'
16  reason: Reason
17  plugin?: string
18  skill?: string
19  tool?: string
20  // the failing plugin's version (its marketplace entry) and Claude Code's release, when known
21  version?: string
22  cc?: string
23  session: string
24  // Raw text stays on this machine: never read by buildBatch.
25  local: { text?: string; note?: string; slug?: string; subgoal?: string; path?: string }
26}
27
28export type SentEvent = { event: string; properties: Record<string, string | number> }
29
30const DEDUPE_MS = 5000
31const KEEP = 500
32const TMP_PREFIXES = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/']
33
34// The two places a marketplace.json can sit relative to the plugin root.
35export function marketplaceCandidates(root: string, marketplace?: string): string[] {
36  const parts = root.replace(/\/+$/, '').split('/')
37  const up = (n: number) => parts.slice(0, Math.max(parts.length - n, 0)).join('/')
38  const out = [up(1) + '/.claude-plugin/marketplace.json']
39  if (marketplace) out.push(up(4) + '/marketplaces/' + marketplace + '/.claude-plugin/marketplace.json')
40  return out
41}
42
43// `plugin:skill` only, and the skill must be one this marketplace ships (`skills` maps each plugin to
44// its skill dir names): a bare name, a foreign plugin or typed free text after the colon is not ours.
45export function ownedSkill(
46  name: unknown,
47  skills: Readonly<Record<string, readonly string[]>>,
48): { plugin: string; skill: string } | undefined {
49  if (typeof name !== 'string') return undefined
50  const i = name.indexOf(':')
51  if (i <= 0 || i === name.length - 1) return undefined
52  const plugin = name.slice(0, i)
53  const skill = name.slice(i + 1)
54  return Object.hasOwn(skills, plugin) && skills[plugin]?.includes(skill) ? { plugin, skill } : undefined
55}
56
57const norm = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, '_')
58
59// `mcp__plugin_<plugin>_<server>__<tool>`, provenance `plugin`, server declared by that plugin.
60export function ownedMcpTool(
61  name: unknown,
62  provenance: { source?: string } | undefined,
63  servers: Readonly<Record<string, readonly string[]>>,
64): { plugin: string; server: string; tool: string } | undefined {
65  if (typeof name !== 'string' || provenance?.source !== 'plugin') return undefined
66  for (const plugin of Object.keys(servers)) {
67    for (const p of [plugin, norm(plugin)]) {
68      const prefix = 'mcp__plugin_' + p + '_'
69      if (!name.startsWith(prefix)) continue
70      const rest = name.slice(prefix.length)
71      for (const server of servers[plugin] ?? []) {
72        for (const s of [server, norm(server)]) {
73          const head = s + '__'
74          if (rest.startsWith(head) && rest.length > head.length) {
75            return { plugin, server, tool: rest.slice(head.length) }
76          }
77        }
78      }
79    }
80  }
81  return undefined
82}
83
84// A final failed subgoal result written by the harness engine; scratch copies are ignored.
85export function harnessResult(
86  path: string,
87  content: string,
88  tmpdirs: readonly string[] = [],
89): { slug: string; subgoal: string; passed: false } | undefined {
90  const m = /\/\.harness-run\/([^/]+)\/subgoals\/([^/]+)\/result\.json$/.exec(path)
91  if (!m || m[1] === undefined || m[2] === undefined) return undefined
92  const roots = TMP_PREFIXES.concat(tmpdirs.map(d => d.replace(/\/+$/, '') + '/'))
93  if (roots.some(r => path.startsWith(r))) return undefined
94  try {
95    const body = JSON.parse(content) as { passed?: unknown }
96    return body && body.passed === false ? { slug: m[1], subgoal: m[2], passed: false } : undefined
97  } catch {
98    return undefined
99  }
100}
101
102// fallback-check.mjs final line: `COMPLETE <slug> goal-gate FAIL`. Not INCOMPLETE, not PASS.
103export function goalFailed(stdout: string): boolean {
104  return /^COMPLETE\b.*goal-gate FAIL/m.test(stdout)
105}
106
107const sameTarget = (a: Entry, b: Entry) => (a.skill ?? a.tool) === (b.skill ?? b.tool)
108
109// Same reason + skill/tool + session within 5 s counts once; the newest 500 are kept.
110export function appendEntry(log: readonly Entry[], entry: Entry): Entry[] {
111  const dup = log.some(
112    e => e.reason === entry.reason && e.session === entry.session && sameTarget(e, entry) &&
113      Math.abs(entry.ts - e.ts) <= DEDUPE_MS,
114  )
115  if (dup) return log.slice()
116  return log.concat([entry]).slice(-KEEP)
117}
118
119const SKILL_REASONS: readonly Reason[] = ['is_error', 'unsuccessful', 'forked_unsuccessful']
120
121// One event per (day, reason, plugin, skill|tool, versions). Each property object lists its keys explicitly,
122// so a field added to Entry later cannot leak. Outcomes, `local` and session ids never appear.
123export function failureEvents(log: readonly Entry[], sentThrough: string | undefined, through: string): SentEvent[] {
124  const groups = new Map<string, SentEvent>()
125  for (const e of log) {
126    if (e.day > through || (sentThrough !== undefined && e.day <= sentThrough)) continue
127    let event: string
128    let properties: Record<string, string | number>
129    if (e.kind === 'bug' && SKILL_REASONS.includes(e.reason) && e.skill && e.plugin) {
130      event = 'diag_skill_error'
131      properties = { skill: e.skill, plugin: e.plugin, reason: e.reason, count: 1, day: e.day }
132    } else if (e.kind === 'bug' && e.reason === 'mcp_error' && e.tool && e.plugin) {
133      event = 'diag_mcp_error'
134      properties = { tool: e.tool, plugin: e.plugin, reason: 'mcp_error', count: 1, day: e.day }
135    } else if (e.kind === 'report' && e.reason === 'user_report') {
136      event = 'diag_user_report'
137      properties = { reason: 'user_report', count: 1, day: e.day }
138      if (e.skill) properties.skill = e.skill
139      if (e.plugin) properties.plugin = e.plugin
140    } else {
141      continue
142    }
143    if (e.version) properties.plugin_version = e.version
144    if (e.cc) properties.cc_version = e.cc
145    const key = [event, e.day, e.reason, e.plugin ?? '', e.skill ?? e.tool ?? '', e.version ?? '', e.cc ?? ''].join('|')
146    const hit = groups.get(key)
147    if (hit) hit.properties.count = (hit.properties.count as number) + 1
148    else groups.set(key, { event, properties })
149  }
150  return Array.from(groups.values())
151}
152
153export const PANE_ROWS = 30
154
155// Newest first, at most 30.
156export const newest = (log: readonly Entry[]): Entry[] => log.slice(-PANE_ROWS).reverse()
157
158export const rowTitle = (e: Entry): string =>
159  [e.day, e.kind, e.reason, e.plugin && e.skill ? `${e.plugin}:${e.skill}` : (e.tool ?? e.plugin ?? '-')].join(' · ')
160
161// First five lines of the local raw text, note or slug.
162export function rowDetail(e: Entry): string[] {
163  const raw = e.local.text ?? e.local.note ?? [e.local.slug, e.local.subgoal].filter(Boolean).join(' ')
164  return (raw ?? '').split('\n').slice(0, 5)
165}
166
167// A ready-to-file bug body; raw text stays on this machine unless the person pastes it.
168export function copyBody(e: Entry): string {
169  const target = e.plugin && e.skill ? `${e.plugin}:${e.skill}` : (e.tool ?? e.plugin ?? '-')
170  return [
171    `skill: ${target}`,
172    `reason: ${e.reason}`,
173    `day: ${e.day}`,
174    `version: ${e.version ?? '-'} · Claude Code ${e.cc ?? '-'}`,
175    `text:`,
176    e.local.text ?? e.local.note ?? [e.local.slug, e.local.subgoal].filter(Boolean).join(' '),
177  ].join('\n')
178}
179
types/index.d.ts 24 lines
1declare module 'claude-code' {
2  interface PluginState {
3    trophy: {
4      active: boolean
5      turnMatched: string[]
6      turnFired: string[]
7      turnTyped: boolean
8      consent: 'unasked' | 'yes' | 'no'
9      consentVersion: number
10      celebrate: { ids: string[]; until: number } | null
11      tab: 'trophies' | 'triggers' | 'failures'
12      // last `plugin:skill` of this marketplace used or typed this session; '' when none
13      lastSkill: string
14      // failures recorded since the failures tab was last shown, and the newest one's title
15      unseen: number
16      lastTitle: string
17      // any achievement unlocked yet (stored or this session): the consent question waits for it
18      anyUnlocked: boolean
19      // UI language, from Claude Code's `language` setting
20      lang: 'en' | 'ko'
21    }
22  }
23}
24