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…

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.
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]
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 ×.
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.
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 |
/choice | the 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).
skillSuggest on and a key set, what you type is sent to typesafe.ai; turn it off for private work.[상태 요약] format above; another heading or numbering makes no card.답변 끝의 [상태 요약]에서 4번 재개 프롬프트를 입력창의 흐린 제안(Tab)으로 띄우고, 3번 결정을 버튼으로 보여 줍니다. 고를 때마다 그 문장이 제안에 붙고, 다 고르면 포크가 하나의 단락으로 다듬어 줍니다. Jev(typesafe.ai)가 뒤에서 옵션 확률을 매기며, 섀도 모드(기본)에서는 확률을 숨기고 "자동이었다면 무엇을 골랐을지"만 ~/.claude/game-choice/picks.json에 기록합니다. 입력 중에는 맞는 스킬을 제안합니다(초안이 typesafe.ai로 전송되므로 원치 않으면 skillSuggest를 끄세요).
hooks/register.tsx 430 lines1// 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}
430hooks/core.ts 311 lines1// 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)}%`)
311types/index.d.ts 71 lines1export 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