SLOPSHOPPER

GAME MODE · Choice

The status summary's resume prompt as the prompt box's suggestion (Tab), its decisions as buttons that refine it, Jev-ranked in the background; skill…

newbandcommandtoastpromptmodel
★ 2v0.3.0MITupdated 2026-10-10Reasonofmoon/bitgame-mods/plugins/game-choice
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-choice
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /choice ⎿ game-choice: 아직 Jev를 부른 적이 없습니다. 상태 요약이 있는 답변이 끝나거나, 입력하다 멈추면 부릅니다. ⎿ game-choice: 카드: 없음 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

GAME MODE · Choice

When Claude ends an answer with a [상태 요약] (status summary), its item 4 — the prompt to paste next time — becomes the prompt box's dim suggestion: press Tab to take it. Item 3's decisions become buttons above the prompt:

다음 할 일 결정  섀도 모드 · Claude 순서 (Jev 순위 숨김)  [닫기]
Q1. 테스트 범위를 어떻게 할까요?  [관련 파일만]  [전체]
Q2. 커밋을 나눌까요?  [✓ 논리 단위로]  [한 번에]
다듬은 프롬프트가 입력창 제안에 있습니다 (Tab)

Each pick appends that option's sentence to the suggestion at once. When every decision is picked, a fork of the session rewrites the prompt into one self-contained paragraph with the choices settled, and that becomes the suggestion. Submitting anything clears the card.

The summary format it reads (item 3 is machine-read):

[상태 요약]
1. 지금까지 한 일:
2. 아직 남은 일:
3. 내가 결정해야 할 것:
   - Q: 테스트 범위를 어떻게 할까요?
     - 관련 파일만 => 관련 파일 단위로만 테스트를 돌려라.
     - 전체 => 전체 테스트를 돌려라.
4. 다음에 이어서 할 때 붙여넣을 프롬프트:
   - (one self-contained paragraph)
5. 산출물/파일/링크:

The last [상태 요약] in the answer counts. A decision needs at least two label => sentence options; an item 4 that is empty or (해당 없음) makes no card.

Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.296.

Jev ranking and shadow mode

The decisions are sent to Jev (TypeSafe System One, api.typesafe.ai) with items 1, 2 and 4 as context (at most 3000 characters). Jev returns each option's probability and its confidence. Without TYPESAFE_API_KEY, on an HTTP error, or after 8 seconds, the buttons keep Claude's order and the header says why.

Shadow mode (on by default) keeps Jev's odds and order off the buttons, so the pick is yours, and records what an auto-approver would have picked: every decision at ≥ 90% with confidence ≥ 80%, and no option naming a risky act (commit, push, merge, deploy, delete, publish, send, cost, settings — in Korean or English). Nothing is ever picked or sent on its strength.

Each pick is kept (last 500) in the plugin's store and mirrored to ~/.claude/game-choice/picks.json: the options and their odds, Jev's first choice, your pick, whether odds were hidden, and the shadow verdict. scripts/analyze-picks.mjs reads it and reports Jev's first-choice hit rate, calibration per probability band, the shadow hit rate, and whether all four bars for turning auto-approval on are met (30 cards, 90% each). Fewer than 10 shadow-eligible cards means another week of shadow mode, not lower bars.

node plugins/game-choice/scripts/analyze-picks.mjs [path/to/picks.json]

Skill suggestions

While you type (after an 800 ms pause, drafts of 12 characters or more that do not start with /), the draft and your non-built-in commands (up to 254, the ones sharing most words with the draft first) go to Jev. A match at or above the threshold shows 스킬 제안: /<name> 72% with 적용 (fill /<name> <draft>), 맞춤 다듬기 (a fork rewrites the draft for that skill, with what the session knows) and ×.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-choice@bitgame-mods

Set TYPESAFE_API_KEY in the environment Claude Code starts from for Jev ranking and skill suggestions; the resume suggestion and the buttons work without it.

Settings and commands

shadowMode (true)hide Jev's odds and order on the buttons; record what auto would have picked
skillThreshold (50)show a skill only at this probability (%) or above, 10–95
skillSuggest (true)send drafts to Jev to match them against your skills; false sends no drafts
/choicethe last Jev call (purpose, time, key present, HTTP status, failure, top odds) and the current card's state and shadow verdict

The last Jev call is also written to ~/.claude/game-choice/last-jev.json (never the key).

Known limits

  • Drafts leave the machine. With skillSuggest on and a key set, what you type is sent to typesafe.ai; turn it off for private work.
  • It reads only the [상태 요약] format above; another heading or numbering makes no card.
  • The suggestion cannot show while a turn runs; it appears once the prompt box is free.
  • The engine's own next-prompt suggestion is replaced while a card is up.

한국어

답변 끝의 [상태 요약]에서 4번 재개 프롬프트를 입력창의 흐린 제안(Tab)으로 띄우고, 3번 결정을 버튼으로 보여 줍니다. 고를 때마다 그 문장이 제안에 붙고, 다 고르면 포크가 하나의 단락으로 다듬어 줍니다. Jev(typesafe.ai)가 뒤에서 옵션 확률을 매기며, 섀도 모드(기본)에서는 확률을 숨기고 "자동이었다면 무엇을 골랐을지"만 ~/.claude/game-choice/picks.json에 기록합니다. 입력 중에는 맞는 스킬을 제안합니다(초안이 typesafe.ai로 전송되므로 원치 않으면 skillSuggest를 끄세요).

Source 3 files
hooks/register.tsx 430 lines
1// game-choice: the status summary's resume prompt becomes the prompt box's dim suggestion,
2// its decisions become Jev-ranked buttons that refine that prompt, and drafts are matched to skills.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { Card, SkillCandidate, SkillHint } from '../types'
7import {
8  JEV_URL,
9  allPicked,
10  applyDecisionRanks,
11  composePrompt,
12  decisionRequest,
13  parseChoiceAnswers,
14  parseStatusSummary,
15  pct,
16  pickSkill,
17  rankedOptions,
18  shadowVerdict,
19  shortlistSkills,
20  shouldClassify,
21  skillRequest,
22} from './core'
23import type { ChoiceAnswer, JevRequest } from './core'
24
25const card = atom({ plugin: 'game-choice', key: 'card' } as const, null)
26const skill = atom({ plugin: 'game-choice', key: 'skill' } as const, null)
27const isTailoring = atom({ plugin: 'game-choice', key: 'isTailoring' } as const, false)
28
29const PICKS_KEY = 'picks'
30const MAX_PICKS = 500
31const TYPING_PAUSE_MS = 800
32const JEV_TIMEOUT_MS = 8000
33
34// Module state: a reload starts these over, which only drops an in-flight skill lookup.
35let typing = 0
36let lastClassified = ''
37let threshold = 0.5
38let shadowMode = true
39
40type JevResult =
41  | { ok: true; answers: Record<string, ChoiceAnswer> }
42  | { ok: false; failure: 'no-key' | 'error'; note: string }
43
44const LAST_JEV_KEY = 'lastJev'
45
46/** What /choice shows about the latest Jev call; never the key itself. */
47type JevTrace = {
48  at: number
49  purpose: 'decisions' | 'skill'
50  hasKey: boolean
51  ms: number
52  status: number | null
53  note: string | null
54  /** The start of the response body, for a response that did not parse. */
55  bodyHead: string | null
56  answers: Record<string, ChoiceAnswer> | null
57  /** Why ~/.claude/game-choice/last-jev.json could not be written, when it could not. */
58  fileError?: string
59}
60
61async function askJev($: EngineInterface, body: JevRequest, purpose: JevTrace['purpose']): Promise<JevResult> {
62  const started = await $.clock.now()
63  const key = await $.env.get('TYPESAFE_API_KEY')
64  const trace: JevTrace = { at: started, purpose, hasKey: Boolean(key), ms: 0, status: null, note: null, bodyHead: null, answers: null }
65  // Written before the request, so a call that never returns still shows how far it got.
66  await $.store.set(LAST_JEV_KEY, { ...trace, note: '요청 보냄, 응답 대기 중' }).catch(() => undefined)
67  let result: JevResult
68  if (!key) {
69    result = { ok: false, failure: 'no-key', note: 'TYPESAFE_API_KEY 없음' }
70  } else {
71    try {
72      const res = await $.http.fetch(JEV_URL, {
73        method: 'POST',
74        headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
75        body: JSON.stringify(body),
76      })
77      trace.status = res.status
78      const answers = res.ok ? parseChoiceAnswers(JSON.parse(res.text)) : null
79      if (!answers) trace.bodyHead = res.text.slice(0, 300)
80      result = !res.ok
81        ? { ok: false, failure: 'error', note: `HTTP ${res.status}` }
82        : answers
83          ? { ok: true, answers }
84          : { ok: false, failure: 'error', note: '응답 형식이 다름' }
85    } catch (err) {
86      result = { ok: false, failure: 'error', note: err instanceof Error ? err.message.slice(0, 120) : 'request failed' }
87    }
88  }
89  trace.ms = (await $.clock.now()) - started
90  if (result.ok) trace.answers = result.answers
91  else trace.note = result.note
92  await $.store.set(LAST_JEV_KEY, trace).catch(() => undefined)
93  await writeTrace($, trace)
94  return result
95}
96
97/**
98 * Mirrors the trace to ~/.claude/game-choice/last-jev.json so it can be read outside the
99 * session. Outside the mod's own folder on purpose: a write there would hot-reload the mod.
100 */
101async function writeTrace($: EngineInterface, trace: JevTrace): Promise<void> {
102  const failed = await writeOutside($, 'last-jev.json', trace)
103  // A failed write must not vanish: it shows in /choice instead.
104  if (failed) await $.store.set(LAST_JEV_KEY, { ...trace, fileError: failed }).catch(() => undefined)
105}
106
107/**
108 * Writes `value` as JSON to ~/.claude/game-choice/<name>, outside the mod's own folder
109 * (a write there would hot-reload the mod). Resolves the error text, or null on success.
110 */
111async function writeOutside($: EngineInterface, name: string, value: unknown): Promise<string | null> {
112  try {
113    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
114    if (!home) return 'USERPROFILE/HOME 없음'
115    await $.fs.write(`${home}/.claude/game-choice/${name}`, `${JSON.stringify(value, null, 2)}\n`)
116    return null
117  } catch (err) {
118    return err instanceof Error ? err.message.slice(0, 160) : 'write failed'
119  }
120}
121
122function describeTrace(t: JevTrace | undefined): string {
123  if (!t) return '아직 Jev를 부른 적이 없습니다. 상태 요약이 있는 답변이 끝나거나, 입력하다 멈추면 부릅니다.'
124  const lines = [
125    `마지막 Jev 호출: ${t.purpose === 'decisions' ? '결정 순위' : '스킬 제안'} · ${t.ms}ms`,
126    `키: ${t.hasKey ? '있음' : '없음 (TYPESAFE_API_KEY)'} · HTTP ${t.status ?? '-'}`,
127  ]
128  if (t.note) lines.push(`실패: ${t.note}`)
129  if (t.bodyHead) lines.push(`응답 앞부분: ${t.bodyHead}`)
130  if (t.fileError) lines.push(`기록 파일 쓰기 실패: ${t.fileError}`)
131  for (const [id, a] of Object.entries(t.answers ?? {})) {
132    const probs = Object.entries(a.probabilities)
133      .sort((x, y) => y[1] - x[1])
134      .slice(0, 5)
135      .map(([k, p]) => `${k} ${Math.round(p * 100)}%`)
136      .join(', ')
137    lines.push(`${id}: ${a.choice} (확신도 ${Math.round(a.confidence * 100)}%) — ${probs}`)
138  }
139  return lines.join('\n')
140}
141
142/** The prompt the box should offer for this card. */
143function target(c: Card): string {
144  return c.refined ?? composePrompt(c)
145}
146
147async function offer($: EngineInterface, text: string): Promise<void> {
148  try {
149    await $.prompt.suggest({ text })
150  } catch {
151    // The box may be busy or headless; the engine's next suggestion is rewritten to ours anyway.
152  }
153}
154
155function refinePrompt(c: Card): string {
156  const picks = c.decisions.map(d => {
157    const o = d.picked === null ? null : d.options[d.picked]
158    return o ? `- ${d.question} → ${o.label} (${o.append})` : ''
159  })
160  return [
161    '아래는 다음에 이어서 할 때 붙여넣을 재개 프롬프트와, 사용자가 방금 고른 결정이다.',
162    '이 대화의 맥락을 반영해 결정을 확정 사항으로 녹인 하나의 self-contained 한국어 단락으로 다시 써라.',
163    '결정 질문이나 고르지 않은 옵션은 남기지 말고, 프롬프트 본문만 출력하라.',
164    '',
165    `재개 프롬프트: ${c.resume}`,
166    '고른 결정:',
167    ...picks,
168  ].join('\n')
169}
170
171function tailorPrompt(s: SkillHint): string {
172  return [
173    `사용자가 입력 중인 초안을 /${s.name} 스킬로 실행할 프롬프트로 다듬어라.`,
174    `스킬 설명: ${s.description}`,
175    '이 대화에서 이미 알려진 파일·경로·결정을 채워 넣고, 모르는 것은 지어내지 마라.',
176    `출력은 "/${s.name} "로 시작하는 한 단락만.`,
177    '',
178    `초안: ${s.draft}`,
179  ].join('\n')
180}
181
182async function rank($: EngineInterface, c: Card): Promise<void> {
183  if (c.decisions.length === 0) return
184  // Whatever goes wrong — an exception or a call that never returns — the card must leave 'pending' and say why.
185  const asked: Promise<JevResult> = askJev($, decisionRequest(c), 'decisions').catch((err: unknown) => ({
186    ok: false as const,
187    failure: 'error' as const,
188    note: err instanceof Error ? err.message.slice(0, 120) : 'request failed',
189  }))
190  const timedOut: Promise<JevResult> = $.clock
191    .sleep(JEV_TIMEOUT_MS)
192    .then(() => ({ ok: false as const, failure: 'error' as const, note: `Jev 응답 시간 초과 (${JEV_TIMEOUT_MS / 1000}초)` }))
193  const r = await Promise.race([asked, timedOut])
194  await update($, card, cur => {
195    if (!cur || cur.resume !== c.resume) return cur // a newer summary replaced it
196    if (!r.ok) return { ...cur, rank: r.failure, rankNote: r.note, shadow: shadowVerdict(cur, false, r.note) }
197    const ranked = applyDecisionRanks(cur, r.answers)
198    return { ...cur, ...ranked, rank: 'jev' as const, rankNote: null, shadow: shadowVerdict(ranked, true) }
199  })
200}
201
202async function refine($: EngineInterface, c: Card): Promise<void> {
203  await update($, card, cur => (cur && cur.resume === c.resume ? { ...cur, isRefining: true } : cur))
204  const r = await $.model.fork({ prompt: refinePrompt(c) })
205  const done = await update($, card, cur => {
206    if (!cur || cur.resume !== c.resume) return cur
207    const text = r.isAnswered ? r.text.trim() : ''
208    return { ...cur, isRefining: false, refined: text || null }
209  })
210  if (done?.refined) await offer($, done.refined)
211  else if (!r.isAnswered) $.ui.toast(`다듬기 실패: ${r.reason}`)
212}
213
214async function pick($: EngineInterface, di: number, oi: number): Promise<void> {
215  const next = await update($, card, cur => {
216    if (!cur) return cur
217    const decisions = cur.decisions.map((d, i) => (i === di ? { ...d, picked: oi } : d))
218    return { ...cur, decisions, refined: null }
219  })
220  if (!next) return
221  const d = next.decisions[di]
222  if (d) {
223    // Kept for calibrating the order later: what was offered, at what odds, and what was chosen.
224    const stored = await $.store.get(PICKS_KEY)
225    const history = Array.isArray(stored) ? stored : []
226    const top = rankedOptions(d)[0]
227    const row = {
228      at: await $.clock.now(),
229      /** Groups the decisions of one card. */
230      cardAt: next.cardAt,
231      question: d.question,
232      options: d.options.map(o => ({ label: o.label, p: o.p })),
233      confidence: d.confidence,
234      picked: oi,
235      /** Jev's first choice (or Claude's first option when unranked). */
236      top: top?.index ?? null,
237      rank: next.rank,
238      /** Whether the person saw Claude's order without Jev's odds when picking. */
239      shadowMode,
240      /** What the shadow auto-approver would have picked for this decision, and why not when it would not. */
241      shadow: next.shadow
242        ? { eligible: next.shadow.eligible, pick: next.shadow.picks[di] ?? null, reasons: next.shadow.reasons }
243        : { eligible: false, pick: null, reasons: ['Jev 순위 전에 고름'] },
244      /** A person's pick; the auto-approver, once on, writes true and the analysis leaves those out. */
245      auto: false,
246    }
247    const picks = [...history, row].slice(-MAX_PICKS)
248    await $.store.set(PICKS_KEY, picks)
249    // Mirrored outside the session so the picks can be analysed later.
250    await writeOutside($, 'picks.json', picks)
251  }
252  await offer($, target(next))
253  if (allPicked(next)) await refine($, next)
254}
255
256async function classify($: EngineInterface, draft: string, ticket: number): Promise<void> {
257  await $.clock.sleep(TYPING_PAUSE_MS)
258  if (ticket !== typing || draft === lastClassified) return
259  lastClassified = draft
260  const commands = await $.command.list()
261  const skills: SkillCandidate[] = commands
262    .filter(c => c.source !== 'builtin')
263    .map(c => ({ name: c.name, description: c.description }))
264  if (skills.length === 0) return
265  const shortlist = shortlistSkills(draft, skills)
266  const r = await askJev($, skillRequest(draft, shortlist), 'skill')
267  if (ticket !== typing) return
268  await update($, skill, () => (r.ok ? pickSkill(r.answers, shortlist, draft, threshold) : null))
269}
270
271async function applySkill($: EngineInterface, s: SkillHint): Promise<void> {
272  await $.prompt.fill({ text: `/${s.name} ${s.draft}` })
273  await update($, skill, () => null)
274}
275
276async function tailorSkill($: EngineInterface, s: SkillHint): Promise<void> {
277  await update($, isTailoring, () => true)
278  const r = await $.model.fork({ prompt: tailorPrompt(s) })
279  await update($, isTailoring, () => false)
280  if (r.isAnswered && r.text.trim()) {
281    await $.prompt.fill({ text: r.text.trim() })
282    await update($, skill, () => null)
283  } else if (!r.isAnswered) {
284    $.ui.toast(`다듬기 실패: ${r.reason}`)
285  }
286}
287
288function rankLabel(c: Card): string {
289  if (shadowMode) {
290    // Odds and order stay hidden so the pick is the person's own; Jev still runs and is recorded.
291    if (c.rank === 'pending') return '섀도 모드 · Jev 기록 중…'
292    if (c.rank === 'jev') return '섀도 모드 · Claude 순서 (Jev 순위 숨김)'
293    return `섀도 모드 · Claude 순서 (Jev 실패: ${c.rankNote ?? '알 수 없음'})`
294  }
295  if (c.rank === 'jev') return 'Jev 확률순'
296  if (c.rank === 'pending') return 'Jev가 순위를 매기는 중…'
297  return `Claude 추천순 (${c.rankNote ?? 'Jev 없음'})`
298}
299
300function statusLine(c: Card): string {
301  if (c.isRefining) return '선택을 반영해 프롬프트를 다듬는 중…'
302  if (c.refined) return '다듬은 프롬프트가 입력창 제안에 있습니다 (Tab)'
303  return '고른 내용이 입력창 제안에 붙습니다 (Tab)'
304}
305
306export const register: Register = (on, options) => {
307  threshold = Math.min(95, Math.max(10, Number(options.skillThreshold ?? 50))) / 100
308  shadowMode = options.shadowMode !== false
309  const skillSuggest = options.skillSuggest !== false
310
311  on('session.start', async ($, e, next) => {
312    const result = await next(e)
313    await $.command.register({ name: 'choice', description: 'game-choice: 마지막 Jev 호출 결과와 지금 카드 상태' })
314    return result
315  })
316
317  on('command.run', { command: 'choice' }, async $ => {
318    const trace = (await $.store.get(LAST_JEV_KEY)) as JevTrace | undefined
319    const c = await read($, card)
320    const cardLine = c
321      ? `카드: 결정 ${c.decisions.length}개 · 순위 ${c.rank}${c.rankNote ? ` (${c.rankNote})` : ''} · 확률 ${c.decisions.flatMap(d => d.options.map(o => (o.p === null ? '-' : `${Math.round(o.p * 100)}%`))).join(' ')}`
322      : '카드: 없음'
323    const shadowLine = !c
324      ? ''
325      : c.shadow === null
326        ? `\n섀도: 판정 전${shadowMode ? ' (섀도 모드 켜짐)' : ''}`
327        : c.shadow.eligible
328          ? `\n섀도: 자동 대상 — 고를 옵션 ${c.shadow.picks.map((p, i) => `Q${i + 1}=${p === null ? '-' : String.fromCharCode(65 + p)}`).join(' ')}`
329          : `\n섀도: 자동 아님 — ${c.shadow.reasons.join('; ')}`
330    return { text: `${describeTrace(trace)}\n${cardLine}${shadowLine}` }
331  })
332
333  on('turn.complete', async ($, e, next) => {
334    const result = await next(e)
335    if (e.agentId !== undefined || e.reason !== 'answer') return result
336    const summary = parseStatusSummary(e.answer)
337    if (!summary) return result
338    const c: Card = {
339      ...summary,
340      cardAt: await $.clock.now(),
341      shadow: null,
342      rank: 'pending',
343      rankNote: null,
344      refined: null,
345      isRefining: false,
346    }
347    await update($, card, () => c)
348    // Not awaited: a suggestion cannot show while the turn runs, and this hook is still part of the turn,
349    // so waiting here held Jev back and left the buttons without odds. The box takes it once free.
350    void offer($, target(c))
351    await rank($, c)
352    return result
353  })
354
355  // The engine's own next-prompt guess gives way to the summary's resume prompt.
356  on('prompt.suggest', async ($, e, next) => {
357    if (e.origin.kind !== 'suggestion') return next(e)
358    const c = await read($, card)
359    return next(c ? { ...e, text: target(c) } : e)
360  })
361
362  on('prompt.edit', async ($, e, next) => {
363    const box = await next(e)
364    if (!skillSuggest) return box
365    typing += 1
366    if (shouldClassify(box.text)) void classify($, box.text, typing)
367    else void update($, skill, () => null)
368    return box
369  })
370
371  // Clearing stale cards must never hold up the person's prompt.
372  on('prompt.submit', async ($, e, next) => {
373    typing += 1
374    await update($, card, () => null)
375    await update($, skill, () => null)
376    return next(e)
377  }).catch(($, e, next) => next(e))
378
379  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
380    if (e.props.hasSurvey) return next(e)
381    const c = await read($, card)
382    const s = await read($, skill)
383    const tailoring = await read($, isTailoring)
384    const below = await next(e)
385    const hasDecisions = c !== null && c.decisions.length > 0
386    if (!hasDecisions && !s) return below
387
388    const { Box, Button, Text } = $.ui.resolve(e)
389    return (
390      <Box flexDirection="column">
391        {below}
392        {hasDecisions && c && (
393          <Box flexDirection="column">
394            <Box flexDirection="row" columnGap={2}>
395              <Text bold>다음 할 일 결정</Text>
396              <Text dimColor>{rankLabel(c)}</Text>
397              <Button key="close" label="닫기" onPress={() => update($, card, () => null)} />
398            </Box>
399            {c.decisions.map((d, di) => (
400              <Box key={`q${di}`} flexDirection="row" columnGap={1} flexWrap="wrap">
401                <Text>{`Q${di + 1}. ${d.question}`}</Text>
402                {(shadowMode ? d.options.map((o, index) => ({ ...o, index })) : rankedOptions(d)).map(o => (
403                  <Button
404                    key={`d${di}-o${o.index}`}
405                    label={`${d.picked === o.index ? '✓ ' : ''}${o.label}${shadowMode ? '' : pct(o.p)}`}
406                    variant={d.picked === o.index ? 'primary' : 'secondary'}
407                    onPress={() => pick($, di, o.index)}
408                  />
409                ))}
410              </Box>
411            ))}
412            <Text dimColor>{statusLine(c)}</Text>
413          </Box>
414        )}
415        {s && (
416          <Box flexDirection="row" columnGap={1} flexWrap="wrap">
417            <Text>{`스킬 제안: /${s.name}${pct(s.p)}`}</Text>
418            <Text dimColor wrap="truncate-end">
419              {s.description.slice(0, 60)}
420            </Text>
421            <Button key="skill-apply" label="적용" onPress={() => applySkill($, s)} />
422            <Button key="skill-tailor" label={tailoring ? '다듬는 중…' : '맞춤 다듬기'} onPress={() => tailorSkill($, s)} />
423            <Button key="skill-dismiss" label="×" onPress={() => update($, skill, () => null)} />
424          </Box>
425        )}
426      </Box>
427    )
428  })
429}
430
hooks/core.ts 311 lines
1// core.ts
2// Pure parts of game-choice: reading the status summary, composing the resume prompt,
3// and the Jev (TypeSafe System One) request and response shapes. No engine calls here.
4import type { Decision, DecisionOption, ShadowVerdict, SkillCandidate, SkillHint, Summary } from '../types'
5
6const SUMMARY_TAG = '[상태 요약]'
7
8function stripBullet(line: string): string {
9  return line.replace(/^\s*[-*]\s+/, '').trim()
10}
11
12/** The text of numbered section `n` ("3." … up to "4."), fences removed. */
13function section(block: string, n: number): string[] {
14  const lines = block.split(/\r?\n/).filter(l => !/^\s*```/.test(l))
15  const start = lines.findIndex(l => new RegExp(`^\\s*${n}\\.\\s`).test(l))
16  if (start < 0) return []
17  const rest = lines.slice(start + 1)
18  const end = rest.findIndex(l => /^\s*\d\.\s/.test(l))
19  return end < 0 ? rest : rest.slice(0, end)
20}
21
22/** Reads the last `[상태 요약]` in an answer; null when there is none or item 4 is empty. */
23export function parseStatusSummary(answer: string): Summary | null {
24  const at = answer.lastIndexOf(SUMMARY_TAG)
25  if (at < 0) return null
26  const block = answer.slice(at + SUMMARY_TAG.length)
27
28  const resume = section(block, 4)
29    .map(stripBullet)
30    .filter(Boolean)
31    .join(' ')
32    .trim()
33  if (!resume || resume === '(해당 없음)') return null
34
35  const decisions: Decision[] = []
36  for (const raw of section(block, 3)) {
37    const line = stripBullet(raw)
38    const q = /^Q:\s*(.+)$/.exec(line)
39    if (q?.[1]) {
40      decisions.push({ question: q[1].trim(), options: [], picked: null, confidence: null })
41      continue
42    }
43    const current = decisions.at(-1)
44    const arrow = line.indexOf('=>')
45    if (!current || arrow < 0) continue
46    const label = line.slice(0, arrow).trim()
47    const append = line.slice(arrow + 2).trim()
48    if (label && append) current.options.push({ label, append, p: null })
49  }
50  return {
51    resume,
52    decisions: decisions.filter(d => d.options.length >= 2),
53    done: bullets(section(block, 1)),
54    todo: bullets(section(block, 2)),
55  }
56}
57
58/** Top-level bullets of a section; continuation lines join the bullet above. "(해당 없음)" is dropped. */
59function bullets(lines: string[]): string[] {
60  const out: string[] = []
61  for (const raw of lines) {
62    if (!raw.trim()) continue
63    const isBullet = /^\s*[-*]\s+/.test(raw)
64    const text = stripBullet(raw)
65    if (isBullet || out.length === 0) out.push(text)
66    else out[out.length - 1] = `${out[out.length - 1]} ${text}`
67  }
68  return out.filter(t => t && t !== '(해당 없음)')
69}
70
71/** Cap on the context sent to Jev; item 4 always goes whole, items 1 and 2 are cut to fit. */
72export const STATE_MAX_CHARS = 3000
73
74/** What Jev judges the options against: the resume prompt, then what was done and what is left. */
75export function decisionState(summary: Summary): string {
76  const parts = [`Work to resume: ${summary.resume}`]
77  let room = STATE_MAX_CHARS - parts[0]!.length
78  for (const [title, items] of [
79    ['Done so far:', summary.done],
80    ['Still to do:', summary.todo],
81  ] as const) {
82    if (items.length === 0 || room <= title.length) continue
83    const lines: string[] = [title]
84    room -= title.length + 1
85    for (const item of items) {
86      const line = `- ${item}`
87      if (line.length + 1 > room) break
88      lines.push(line)
89      room -= line.length + 1
90    }
91    if (lines.length > 1) parts.push(lines.join('\n'))
92  }
93  return parts.join('\n')
94}
95
96/** The resume prompt with each picked option's sentence appended, in decision order. */
97export function composePrompt(summary: Summary): string {
98  const extra = summary.decisions
99    .map(d => (d.picked === null ? null : d.options[d.picked]?.append ?? null))
100    .filter((s): s is string => Boolean(s))
101  return [summary.resume, ...extra].join(' ')
102}
103
104export function allPicked(summary: Summary): boolean {
105  return summary.decisions.length > 0 && summary.decisions.every(d => d.picked !== null)
106}
107
108/** Options with their original index, highest probability first (original order breaks ties). */
109export function rankedOptions(d: Decision): Array<DecisionOption & { index: number }> {
110  return d.options
111    .map((o, index) => ({ ...o, index }))
112    .sort((a, b) => (b.p ?? -1) - (a.p ?? -1) || a.index - b.index)
113}
114
115// ---- Jev (TypeSafe System One) ----
116
117export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
118export const JEV_MODEL = 'jev-latest'
119export const NONE = 'none'
120
121export type ChoiceQuestion = {
122  type: 'choice'
123  instructions: string
124  criteria: Record<string, string>
125}
126
127export type JevRequest = {
128  model: string
129  state: string
130  questions: Record<string, ChoiceQuestion>
131}
132
133export type ChoiceAnswer = {
134  choice: string
135  confidence: number
136  probabilities: Record<string, number>
137}
138
139const optionKey = (i: number): string => String.fromCharCode(65 + i) // A, B, C…
140
141/** One Choice question per decision, keyed d0, d1…; options keyed A, B, C. */
142export function decisionRequest(summary: Summary): JevRequest {
143  const questions: Record<string, ChoiceQuestion> = {}
144  summary.decisions.forEach((d, i) => {
145    const criteria: Record<string, string> = {}
146    d.options.forEach((o, j) => {
147      criteria[optionKey(j)] = `${o.label} — ${o.append}`
148    })
149    questions[`d${i}`] = {
150      type: 'choice',
151      instructions: `Which option is the better next step for this work? ${d.question}`,
152      criteria,
153    }
154  })
155  return { model: JEV_MODEL, state: decisionState(summary), questions }
156}
157
158function isRecord(v: unknown): v is Record<string, unknown> {
159  return typeof v === 'object' && v !== null && !Array.isArray(v)
160}
161
162/** The Choice answers in a Jev response body; null when it is not the documented shape. */
163export function parseChoiceAnswers(body: unknown): Record<string, ChoiceAnswer> | null {
164  if (!isRecord(body) || !isRecord(body.answers)) return null
165  const out: Record<string, ChoiceAnswer> = {}
166  for (const [id, a] of Object.entries(body.answers)) {
167    if (!isRecord(a) || a.type !== 'choice' || typeof a.choice !== 'string' || !isRecord(a.probabilities)) continue
168    const probabilities: Record<string, number> = {}
169    for (const [k, p] of Object.entries(a.probabilities)) if (typeof p === 'number') probabilities[k] = p
170    out[id] = { choice: a.choice, confidence: typeof a.confidence === 'number' ? a.confidence : 0, probabilities }
171  }
172  return out
173}
174
175/** The summary with each option's probability filled from Jev's answers. */
176export function applyDecisionRanks(summary: Summary, answers: Record<string, ChoiceAnswer>): Summary {
177  return {
178    ...summary,
179    decisions: summary.decisions.map((d, i) => {
180      const a = answers[`d${i}`]
181      if (!a) return d
182      return {
183        ...d,
184        confidence: a.confidence,
185        options: d.options.map((o, j) => ({ ...o, p: a.probabilities[optionKey(j)] ?? null })),
186      }
187    }),
188  }
189}
190
191// ---- shadow auto-approval ----
192
193/** Bars an auto-approver would hold every decision of a card to. */
194export const SHADOW_MIN_P = 0.9
195export const SHADOW_MIN_CONFIDENCE = 0.8
196
197/**
198 * Acts an auto-approver must never take on its own: commit, push, merge, deploy, delete, publish,
199 * send, anything that costs money, and settings changes. Matched in every option of the card.
200 */
201export const RISK_KEYWORDS: readonly string[] = [
202  '커밋', 'commit',
203  '푸시', 'push',
204  '머지', '병합', 'merge',
205  '배포', 'deploy', 'release',
206  '삭제', 'delete', 'remove', 'rm ',
207  '발행', 'publish',
208  '전송', '보내', 'send',
209  '비용', '결제', '과금', 'eval', '$',
210  '설정', 'settings', 'config',
211]
212
213/** The first risk keyword in `text`, or null. */
214export function riskIn(text: string): string | null {
215  const t = text.toLowerCase()
216  return RISK_KEYWORDS.find(k => t.includes(k.toLowerCase())) ?? null
217}
218
219/** What an auto-approver would have done with this card. `rankNote` explains an unranked card. */
220export function shadowVerdict(summary: Summary, ranked: boolean, rankNote: string | null = null): ShadowVerdict {
221  const n = summary.decisions.length
222  if (!ranked) return { eligible: false, picks: Array<number | null>(n).fill(null), reasons: [`Jev 순위 없음${rankNote ? ` (${rankNote})` : ''}`] }
223  const reasons: string[] = []
224  const tops: Array<number | null> = summary.decisions.map((d, i) => {
225    const top = rankedOptions(d)[0]
226    const q = `Q${i + 1}`
227    if (!top || top.p === null) {
228      reasons.push(`${q} 확률 없음`)
229      return null
230    }
231    if (top.p < SHADOW_MIN_P) reasons.push(`${q} 확률 미달 (${Math.round(top.p * 100)}% < ${SHADOW_MIN_P * 100}%)`)
232    if ((d.confidence ?? 0) < SHADOW_MIN_CONFIDENCE) {
233      reasons.push(`${q} 확신도 미달 (${Math.round((d.confidence ?? 0) * 100)}% < ${SHADOW_MIN_CONFIDENCE * 100}%)`)
234    }
235    for (const o of d.options) {
236      const k = riskIn(`${o.label} ${o.append}`)
237      if (k) {
238        reasons.push(`${q} 위험 키워드 '${k.trim()}' (${o.label})`)
239        break
240      }
241    }
242    return top.index
243  })
244  const eligible = n > 0 && reasons.length === 0
245  return { eligible, picks: eligible ? tops : Array<number | null>(n).fill(null), reasons }
246}
247
248// ---- skills ----
249
250/** Jev's Choice cap is 255 options; one is kept for "none". */
251export const MAX_SKILLS = 254
252
253function words(s: string): Set<string> {
254  return new Set(
255    s
256      .toLowerCase()
257      .split(/[^\p{L}\p{N}]+/u)
258      .filter(w => w.length >= 2),
259  )
260}
261
262/** Up to `max` candidates, the ones sharing the most words with the draft first. */
263export function shortlistSkills(draft: string, skills: SkillCandidate[], max = MAX_SKILLS): SkillCandidate[] {
264  if (skills.length <= max) return skills
265  const w = words(draft)
266  const score = (s: SkillCandidate): number => {
267    let n = 0
268    for (const x of words(`${s.name} ${s.description}`)) if (w.has(x)) n++
269    return n
270  }
271  return skills
272    .map((s, i) => ({ s, i, n: score(s) }))
273    .sort((a, b) => b.n - a.n || a.i - b.i)
274    .slice(0, max)
275    .map(x => x.s)
276}
277
278export function skillRequest(draft: string, skills: SkillCandidate[]): JevRequest {
279  const criteria: Record<string, string> = {}
280  for (const s of skills) criteria[s.name] = s.description.slice(0, 300) || s.name
281  criteria[NONE] = 'No listed skill fits; an ordinary request.'
282  return {
283    model: JEV_MODEL,
284    state: draft,
285    questions: {
286      skill: {
287        type: 'choice',
288        instructions: 'Which skill (slash command) best handles this request a developer is typing?',
289        criteria,
290      },
291    },
292  }
293}
294
295/** The skill Jev picked, when it is a real skill above `threshold`; null otherwise. */
296export function pickSkill(answers: Record<string, ChoiceAnswer>, skills: SkillCandidate[], draft: string, threshold: number): SkillHint | null {
297  const a = answers.skill
298  if (!a || a.choice === NONE) return null
299  const p = a.probabilities[a.choice] ?? 0
300  const s = skills.find(x => x.name === a.choice)
301  return s && p >= threshold ? { ...s, p, draft } : null
302}
303
304/** A draft worth classifying: long enough, not already a slash command. */
305export function shouldClassify(draft: string): boolean {
306  const t = draft.trim()
307  return t.length >= 12 && !t.startsWith('/')
308}
309
310export const pct = (p: number | null): string => (p === null ? '' : ` ${Math.round(p * 100)}%`)
311
types/index.d.ts 71 lines
1export type DecisionOption = {
2  label: string
3  /** Sentence appended to the resume prompt when this option is picked. */
4  append: string
5  /** Jev probability, 0..1; null when unranked. */
6  p: number | null
7}
8
9export type Decision = {
10  question: string
11  options: DecisionOption[]
12  /** Index into `options` of the person's pick. */
13  picked: number | null
14  /** Jev's confidence for this decision, 0..1; null when unranked. */
15  confidence: number | null
16}
17
18/**
19 * What an auto-approver would have done with a card, computed once Jev has ranked it.
20 * Recorded only (shadow mode): nothing is picked or sent on its strength.
21 */
22export type ShadowVerdict = {
23  /** Every decision cleared the probability and confidence bars and no option named a risky act. */
24  eligible: boolean
25  /** Per decision, the option an auto-approver would pick (Jev's first choice); null when ineligible. */
26  picks: Array<number | null>
27  /** Why the card was not eligible, one line each (empty when eligible). */
28  reasons: string[]
29}
30
31/** The parts of a `[상태 요약]` this mod uses: items 1–4. */
32export type Summary = {
33  resume: string
34  decisions: Decision[]
35  /** Item 1, one entry per bullet: what was done. Context for Jev. */
36  done: string[]
37  /** Item 2, one entry per bullet: what is left. Context for Jev. */
38  todo: string[]
39}
40
41/** How the decision options were ordered: by Jev, or as Claude wrote them (and why). */
42export type RankState = 'pending' | 'jev' | 'no-key' | 'error'
43
44export type Card = Summary & {
45  /** When the card was made (clock ms); groups a card's decisions in picks.json. */
46  cardAt: number
47  /** The shadow auto-approver's verdict; null until Jev has answered or failed. */
48  shadow: ShadowVerdict | null
49  rank: RankState
50  /** A one-line reason when rank is 'error' or 'no-key'. */
51  rankNote: string | null
52  /** The fork-polished prompt once every decision is picked; null before. */
53  refined: string | null
54  isRefining: boolean
55}
56
57export type SkillCandidate = { name: string; description: string }
58
59/** A skill Jev matched to the draft being typed. */
60export type SkillHint = SkillCandidate & { p: number; draft: string }
61
62declare module 'claude-code' {
63  interface PluginState {
64    'game-choice': {
65      card: Card | null
66      skill: SkillHint | null
67      isTailoring: boolean
68    }
69  }
70}
71