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…

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.
/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.
| I want to… | Do |
|---|---|
| See my achievements and progress | /achievements (a pane: Achievements, Triggers and Failures tabs) |
| See what failed | the 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 conversation | the 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.
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:
| Event | Properties |
|---|---|
skill_used | skill name, plugin name, day, count |
trigger_result | skill name, day, counts of: prompt had its trigger phrase and the skill ran / did not run / ran without a phrase |
achievement_unlocked | achievement id |
plugins_installed | plugin 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. |
$exception | the 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).
trophy:listPrints 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).
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.anyUnlocked and lang, so claude plugin validate passes again (0.6.0 failed it; the mod still loaded and worked). No behaviour change.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.✘ 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.diag_* rows listed in the telemetry table./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).hooks/mod.tsx 666 lines1import { 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}
666data/achievements.ts 85 lines1import 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]
85data/triggers.ts 1078 lines1// 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]
1078hooks/logic.ts 283 lines1// 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}
283hooks/failures.ts 179 lines1// 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}
179types/index.d.ts 24 lines1declare 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