Thinking and ideation skills: mentor (any-field mentoring — takes a position, tests yours, hands over the judgment rule), brainstorming (≥3 options before…

English · 한국어
Skills for the thinking that happens before the work: generating options instead of settling on the first one, questioning whether the problem is even stated correctly, attacking your own plan hard enough that reality can't do it first, and preparing a negotiation before you walk in. mentor is the entry point when the question is about your own judgment rather than a deliverable, in any field; the rest stand alone.
/plugin install think@newkayak12-claude-skills
/plugin uninstall think@newkayak12-claude-skills
trophy rides along. From this version, the first interactive session after you install or update this plugin installs trophy (achievements) once, in user scope, if you don't have it. Nothing is sent until you say yes; uninstalling trophy is respected (it is never reinstalled). To opt out beforehand:
mkdir -p ~/.claude/plugins/.newkayak12-trophy-ride.done. Needssh(Windows without one is not covered).
| I want to… | Skill |
|---|---|
| Get a senior mentor's judgment in any field — career, craft, work, study, relationships, life decisions | mentor |
| Design something when the solution shape is still unclear | brainstorming |
| Have a plan I already hold interrogated before building | grill |
| Check whether I'm even solving the right problem | redefine-problem |
| Break an approach down to what's checkable and rebuild from there | back-to-basics |
| Have my plan attacked with the strongest objections | devils-advocate |
| Turn scattered notes into a structure I can write or present from | untangle-thoughts |
| Have counsel on my side for a salary talk, a contract, or a hard conversation | negotiate |
mentorA mentor for any field — career, engineering craft, work, study, working relationships, life decisions. It takes a position and tests yours, which is what separates it from a counselling persona (no position) and from a tradeoff comparison (cognition:tradeoff-articulator, which scores options against criteria). The first move is classification, because most questions arrive misfiled — a values question in a decision costume, a fear described as a constraint, an inherited standard treated as a fact:
| What you bring | What it usually is |
|---|---|
| "A인가 B인가" | a values question wearing a decision costume |
| "어쩔 수 없었어" | a choice being described as a constraint |
| "해야 하는데 못 하겠어" | a fear, or a goal that was never yours |
| "이게 맞나?" about a standard | an inherited standard, never audited |
| a confident plan | a decision already made, seeking cover |
| pain rather than judgment | not a judgment question — it stops examining and listens |
승진 제안을 받았는데 다들 받으라고 해. 답이 아니라 이 질문 자체를 좀 따져줘.
왜 찜찜한지 내가 모르겠어.
Fifteen moves, drawn from philosophical practice — elenchus, distinction, dichotomy of control, bad faith, genealogy, impartial spectator, phronesis, via negativa, steelman, aporia, plus five absorbed from the retired self plugin (ranking under scarcity, avoidance inversion, owning the motive, role vs person, projection check) — applied one or two at a time, never as a tour, and never named out loud (references/moves.md carries a worked exchange for each). The technique stays invisible; a skill it hands over to is always named.
What separates it from a wise-sounding prose generator is a set of refusals: no aphorism or quotation as a conclusion, no three-option menu, no abstraction without one particular from your own account under it, no flattery, and no repetition of the hard thing once it has been said exactly once. Aporia is an allowed outcome — ending in confusion sharper than the confusion you arrived with beats a tidy conclusion you don't own. It routes across the whole repo (cognition: for premises and grounds, self: for values and avoidance, leadership:/portfolio: for career questions). Career and technical questions stay with it — it examines the judgment and hands only the facts (benchmarks, spec comparisons, implementation) to develop:/planning:, named.
Closing shape, when the conversation reaches one:
[내 생각] 한 문장, 완충 없이
[가장 강한 반론] 내 입장에 대한 것, 내가 직접 세운다
[뒤집힐 조건] 무엇을 보면 내가 틀렸다고 인정할지
[멈출 것 하나] 더할 것이 아니라 뺄 것
[다음에 알아볼 것] 답이 아니라 규칙
brainstormingTurns an idea into a design, with divergence and convergence explicitly separated: quantity rules the first half, criteria rule the second. It gates implementation — no code, scaffolding, or implementation-skill calls until the design is approved, even for "simple" projects. Design output means diagrams, component descriptions, data-model tables, trade-off comparisons; API signatures, library calls, and runnable pseudocode mean you've already left design.
알림 시스템을 새로 만들어야 해. 이메일/푸시/인앱을 하나로 묶고 싶은데
어떤 구조가 가능한지 옵션부터 넓게 뽑아줘.
Divergence tools (1–2 at a time): vanilla, constraint relaxation, SCAMPER, analogy, opposite-of — at least three options before any judgment, including one expected to lose. Convergence is gated on written kill-criteria — constraint violation, missing success criteria, uncontrolled dependency, reversibility, team fit — narrowing to 2–3 options, not 1; a strong pull toward one option routes through cognition:bias-auditor first. Questions come one at a time. After approval it hands off to write:plans, never to an implementation skill.
Output shape:
[맥락] what exists, which pattern the design must follow
[문제] one sentence, confirmed with you
[옵션] 3–5, no verdicts yet
[기준] kill-criteria filled in for this decision
[후보] 2–3 survivors + one trade-off table
[설계] architecture · components · data flow · errors · tests → approval → write:plans
Debate mode (opt-in, about 3× tokens). For a one-way door, a cross-team choice, or finalists the criteria don't separate, Claude offers it in one line; or ask for it ("토론으로", "debate"). Claude shows three personas (swap any), each runs as its own subagent to propose options, then they attack and rebut each other for up to two rounds; Claude still cuts with the kill-criteria and adds [충돌] — who clashed where. Every utterance is logged to .claude/brainstorming/<slug>.jsonl. With fewer than two agents left it finishes in normal mode and says so.
/brainstorming-status pane (Claude Code, interactive sessions only; never required). Reads the newest debate log: tabs Timeline (↑↓ scroll, Tab onto a persona to filter, who is speaking now), Options (for/against per option), Result. A band line shows the debate while it runs (Brainstorming · 2/3 · speaking: X), an end card after. Closes on Esc, q, or the command again.
The band while a debate runs: which round and who is speaking.
Timeline: every proposal, attack and rebuttal in order, so you see how an option won or lost.
Options: for and against per option at a glance.
grillInterrogates a plan you already hold, before anything is built. It reads the README and the code the plan touches first: facts get looked up, only decisions are asked. The whole open frontier comes in one numbered round, each question with a recommended answer and a one-line why; the next round asks only what your answers opened, at most three rounds, then whatever is left is listed as open. Not a brainstorming mode — that skill asks one question at a time and diverges on options; this one presses on a plan. One vague question to sharpen goes to cognition:question-upgrader.
새 기능 설계하기 전에 나 좀 빡세게 질문해줘 — 알림 기능 추가하려고
On a small app whose README states its stack, scale and mail provider, a run without this skill opened no files and asked 18–22 generic questions, some answered by the README, none with a recommendation; with it, both runs read the README first, listed what it settled as known, and asked 7–8 questions, each with a recommended answer.
redefine-problemDoubts the problem before solving it. Where brainstorming produces more solutions to a given problem, this asks whether the problem is stated right. It separates symptom from mechanism ("does this end when the state goes away, or come back wearing a different face?"), completes a hidden- assumption table as a hard gate before any output, then applies 2–3 of seven reframing techniques — never all seven.
기능을 계속 내는데 리텐션이 안 움직여. 세 가지 접근을 다 해봤는데
전부 뭔가 어긋난 느낌이야. 문제 정의부터 다시 봐줘.
Output shape:
1. Stated problem (원문 그대로)
2. Symptom vs problem — 증상 / 메커니즘 가설 / 우리가 다룰 것
3. Hidden assumptions — | Assumption | Why it might be false | Confidence |
4. Reframed versions — 2-4개, 각 한 문장
5. Most promising reframe + why
6. Unlocking question — 답이 나오면 접근이 가장 크게 바뀔 단 하나의 질문
A reframe that doesn't change your approach is a paraphrase, not a reframe.
back-to-basicsBreaks an approach down to a checkable floor, rebuilds from it, and states the conditions under which the rebuild is wrong. Every assumption is listed first, then pushed through "why?" until it ends in something checkable and sorted as checked, inherited, or unverified — "that's how it's done" is a finding, not a floor. The rebuild uses only what survived, names what it gives up, and may come out identical to today's approach, which means the constraints were real. It questions the approach and its constraints; redefine-problem questions the problem statement, and devils-advocate does the full counterargument work on the rebuild. Reserve it for novel situations and large bets — it's overkill for routine decisions.
우리 배포가 왜 2주 걸려야 하는지 처음부터 다시 따져줘.
물리적으로 필수인 단계랑 관행으로 남은 단계를 분리하고 싶어.
devils-advocateProduces the strongest objections against a position — steel-manned, specific to this proposal, never hedged and never balanced. Three counterarguments by default, fewer if only fewer are real (it never pads to a count), each labeled with type (structural / assumption / execution / timing), severity, and a real precedent — or an honest "no clear precedent — speculative concern" rather than a fabricated one. It hunts unstated assumptions first, since the sharpest objection usually targets one of them, and always closes with one core vulnerability and a reversibility call — that line is what tells you whether the objections must be resolved before starting or can be learned after.
모놀리식을 MSA로 쪼개자는 제안이야. 가장 강한 반론 세 개랑
그중 진짜 치명적인 게 뭔지 짚어줘.
Output shape:
Position / Steel-man
숨은 가정 1-3
반론 1..3 — [type] · severity · 선례 (또는 "no clear precedent")
[다중 페르소나 공격 — 아키텍처·조직·GTM·정책 결정일 때만, 2-3명]
핵심 취약점 — 가장 눈에 띄는 문제가 아니라 가장 깊은 구조적 결함
가역성 — reversible | one-way door
Multi-persona attack (CFO, on-call/SRE, competitor, legal, junior, customer) is skipped for narrow technical choices — a regulatory critique of "Redis vs Memcached" is theater. Path-forward suggestions only appear if you ask for improvement rather than critique.
untangle-thoughtsTakes scattered notes, half-formed ideas, and stream-of-consciousness and produces structure: absorb → extract atoms → cluster → rank → structure → surface gaps → deliver. It preserves your intent rather than imposing a narrative, adds no ideas you didn't express, and always flags contradictions and open questions instead of smoothing them over. Output starts with the structure — never with a summary of your input read back to you.
독서 기록 앱 만들고 싶은데 생각이 산만해. 소셜 기능도 넣고 싶고
혼자 쓸 수도 있어야 하고. 아웃라인으로 정리해줘.
It picks the output shape before structuring:
| Situation | Technique |
|---|---|
| Goal is a document, essay, or presentation | Outline |
| You say "map", or input has no clear hierarchy | Text mind map |
| 5+ ideas with named cross-links, knowledge-base goal | Zettelkasten-style linking |
| You need only the core message | Core claim extraction |
negotiateCounsel on your side: it acts like the lawyer and negotiator you don't have, and prepares before the conversation rather than improvising in it. Four steps — case prep (counterpart's interests, your BATNA and walk-away line, tradeables), terms review (clauses sorted into missing, risky, negotiable, top asks ranked), strategy with exact lines to say, and a concession order in which every give is traded for something. The BATNA is settled before any strategy, and it will not invent a competing offer for you.
연봉 협상을 앞두고 있어. 시장가보다 낮게 받고 있는 상황이고
매니저는 예산이 묶여 있다고 말해. 어떻게 접근해야 할지 준비해줘.
Voss techniques (accusation audit, mirroring, labeling, calibrated questions) are tools for the conversation itself; the Ackerman ladder — 65 % → 85 % → 95 % → 100 %, with a precise final number and a non-monetary add-on — applies to money only. Output keys: CASE, TERMS, OPEN, SAY, CONCEDE, CHECK. Not legal advice; have a lawyer read any binding contract before signing.
| Skill | Recommended | Optional |
|---|---|---|
mentor | think-tool (classifying what kind of question it is) | sequential-thinking, mcp-reasoner |
brainstorming | — | think-tool, sequential-thinking, mcp-reasoner |
grill | — | think-tool |
redefine-problem | think-tool (required gate: assumption enumeration) | sequential-thinking |
back-to-basics | think-tool (assumption listing, checked vs inherited) | mcp-reasoner |
devils-advocate | — | think-tool, mcp-reasoner, sequential-thinking |
untangle-thoughts | think-tool (gap surfacing) | sequential-thinking |
negotiate | think-tool (counterpart interests, BATNA check) | sequential-thinking |
Add the remote SSE endpoints in Claude settings → MCP Servers.
redefine-problem first if the question itself feels wrong.mentor when the thing needing examination is your own judgment; once the question is stated right and a comparison is what's left, it hands over to cognition:tradeoff-articulator.develop:architecture-workflow (engineering handoff) or planning:roadmap-planning (sequencing a product/strategy decision into a roadmap).write:plans (design review format) turns the divergence and stress-test output into a reviewable design doc.hooks/mod.tsx 253 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Snap } from '../types'
5import { byPersona, newest, page, parse, summarize } from './debate'
6
7// Read only: the think:brainstorming skill writes the debate log; the mod never writes it.
8const snap = atom({ plugin: 'think', key: 'snap' } as const, null as Snap | null)
9const view = atom({ plugin: 'think', key: 'view' } as const, 'timeline' as 'timeline' | 'options' | 'result')
10const filter = atom({ plugin: 'think', key: 'filter' } as const, null as string | null)
11const offset = atom({ plugin: 'think', key: 'offset' } as const, 0)
12const lang = atom({ plugin: 'think', key: 'lang' } as const, 'en' as 'en' | 'ko')
13const seen = atom({ plugin: 'think', key: 'seen' } as const, [] as string[])
14
15const PANE = 'brainstorming-status'
16const OPEN = { id: PANE, title: 'Brainstorming', focus: true, closeOnEscape: true } as const
17const DIR = '.claude/brainstorming'
18const MAX_BYTES = 4 * 1024 * 1024
19const LIVE_MS = 30 * 60 * 1000
20const TICK_MS = 3000
21const ROWS = 12
22
23const en = {
24 tabTimeline: 'Timeline', tabOptions: 'Options', tabResult: 'Result',
25 band: 'Brainstorming · {phase}/3 · speaking: {who}', bandIdle: 'Brainstorming · {phase}/3',
26 speaking: 'Speaking now: {who}', ended: 'Debate ended', all: 'All', close: 'close',
27 noLog: 'No debate log in this folder (.claude/brainstorming).', tooBig: 'This debate log is too large to show.',
28 forAgainst: '+{pros} / −{cons}', cutBy: 'cut: {why}', noOptions: 'No options yet.', noResult: 'No result yet.',
29 fallback: 'Fell back to normal mode', card: 'Brainstorming · debate ended', open: 'Open', dismiss: 'Dismiss',
30 cmdDesc: 'Open the brainstorming debate pane', opened: 'pane opened', closed: 'pane closed',
31}
32
33export const STRINGS: Record<'en' | 'ko', Record<keyof typeof en, string>> = {
34 en,
35 ko: {
36 tabTimeline: '타임라인', tabOptions: '옵션', tabResult: '결과',
37 band: '브레인스토밍 · {phase}/3 · 발언 중: {who}', bandIdle: '브레인스토밍 · {phase}/3',
38 speaking: '발언 중: {who}', ended: '토론 끝', all: '전체', close: '닫기',
39 noLog: '이 폴더에 토론 로그가 없습니다 (.claude/brainstorming).', tooBig: '토론 로그가 너무 커서 보여줄 수 없습니다.',
40 forAgainst: '찬 {pros} / 반 {cons}', cutBy: '탈락: {why}', noOptions: '아직 옵션이 없습니다.', noResult: '아직 결과가 없습니다.',
41 fallback: '일반 모드로 전환됨', card: '브레인스토밍 · 토론 끝', open: '열기', dismiss: '닫기',
42 cmdDesc: '브레인스토밍 토론 창 열기', opened: '창을 열었습니다', closed: '창을 닫았습니다',
43 },
44}
45type Key = keyof typeof en
46
47const fmt = (text: string, vars: Record<string, string | number> = {}) => text.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ''))
48const isKorean = (language: unknown) => typeof language === 'string' && /^(ko|korean|한국어)/i.test(language)
49
50export const register: Register = on => {
51 let isRunning = false
52
53 on('session.start', async ($, e, next) => {
54 if ((await $.session.surfaces()).length === 0) return next(e)
55 const startMs = await $.clock.now()
56
57 async function tick() {
58 if (isRunning) return
59 isRunning = true
60 try {
61 const prev = await read($, snap)
62 let entries: { name: string; kind: string; size: number; mtimeMs: number }[]
63 try {
64 entries = await $.fs.list(DIR)
65 } catch {
66 if (prev !== null) await update($, snap, () => null)
67 return
68 }
69 const f = newest(entries)
70 if (f === undefined) {
71 if (prev !== null) await update($, snap, () => null)
72 return
73 }
74 if (prev !== null && prev.slug === f.name && prev.mtimeMs === f.mtimeMs && prev.size === f.size) {
75 const live = !prev.debate.ended && (await $.clock.now()) - f.mtimeMs <= LIVE_MS
76 if (live !== prev.live) await update($, snap, () => ({ ...prev, live }))
77 return
78 }
79 if (f.size > MAX_BYTES) return
80 let body: string
81 try {
82 body = await $.fs.read(`${DIR}/${f.name}`)
83 } catch {
84 return // the last good view stays
85 }
86 const debate = summarize(parse(body))
87 const live = !debate.ended && (await $.clock.now()) - f.mtimeMs <= LIVE_MS
88 await update($, snap, () => ({ slug: f.name, debate, mtimeMs: f.mtimeMs, size: f.size, live, fresh: f.mtimeMs >= startMs }))
89 } catch {
90 // fail open: the last view stays
91 } finally {
92 isRunning = false
93 }
94 }
95
96 try {
97 const language = (await $.settings.read()).language
98 await update($, lang, () => (isKorean(language) ? 'ko' : 'en'))
99 } catch {
100 // English
101 }
102 await $.command.register({ name: PANE, description: STRINGS[await read($, lang)].cmdDesc })
103 await tick()
104 $.clock.every(TICK_MS, tick)
105 return next(e)
106 })
107
108 // the command toggles: open, or close when already open
109 on('command.run', { command: PANE }, async $ => {
110 const s = STRINGS[await read($, lang)]
111 if ((await $.ui.panes()).some(p => p.id === PANE)) {
112 await $.ui.close({ id: PANE })
113 return { text: s.closed }
114 }
115 await $.ui.open(OPEN)
116 return { text: s.opened }
117 })
118
119 // ↑↓ move the timeline's own window
120 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
121 if ((await read($, view)) !== 'timeline') return next(e)
122 await update($, offset, o => Math.max(0, o + e.by))
123 return {}
124 }).catch(($, e, next) => next(e))
125
126 // Tab onto a persona chip filters by it
127 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
128 const el = e.element
129 if (typeof el === 'string' && el.startsWith('who:')) {
130 const who = el.slice(4)
131 await update($, filter, () => (who === '' ? null : who))
132 await update($, offset, () => 0)
133 }
134 return next(e)
135 }).catch(($, e, next) => next(e))
136
137 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
138 const { Box, Text, Button } = $.ui.resolve(e)
139 const lang$ = await read($, lang)
140 const s = (k: Key, v?: Record<string, string | number>) => fmt(STRINGS[lang$][k], v)
141 const kind = await read($, view)
142 const sn = await read($, snap)
143 const closeBtn = <Button key="close" plain hotkey="q" label={s('close')} onPress={() => $.ui.close({ id: PANE })} />
144 if (sn === null) return <Box flexDirection="column"><Text>{s('noLog')}</Text>{closeBtn}</Box>
145 const d = sn.debate
146 const items = [{ key: 'timeline', label: s('tabTimeline') }, { key: 'options', label: s('tabOptions') }, { key: 'result', label: s('tabResult') }]
147
148 let body
149 if (kind === 'timeline') {
150 const who = await read($, filter)
151 const lines = byPersona(d.lines, who)
152 const win = page(lines, await read($, offset), ROWS)
153 const chips = [null, ...d.personas]
154 body = (
155 <Box flexDirection="column">
156 <Box columnGap={2}>
157 {chips.map(p => (
158 <Button key={`who:${p ?? ''}`} plain dimColor={who !== p} label={`${who === p ? '● ' : ''}${p ?? s('all')}`}
159 onPress={async () => { await update($, filter, () => p); await update($, offset, () => 0) }} />
160 ))}
161 </Box>
162 <Box flexDirection="column" marginTop={1}>
163 {win.items.map((l, i) => (
164 // one plain span per line: a nested span split a wide first glyph on the terminal
165 <Text key={`l${win.offset + i}`} wrap="truncate-end" color={l.persona === d.speaking ? 'claude' : undefined}>
166 {`r${l.round} ${l.persona} · ${l.kind}${l.to ? ` → ${l.to}` : ''}: ${l.text}`}
167 </Text>
168 ))}
169 </Box>
170 {lines.length > ROWS && <Text dimColor>{`${win.offset + 1}-${win.offset + win.items.length}/${lines.length}`}</Text>}
171 </Box>
172 )
173 } else if (kind === 'options') {
174 body = d.options.length === 0 ? <Text dimColor>{s('noOptions')}</Text> : (
175 <Box flexDirection="column">
176 {d.options.map(o => (
177 <Box key={o.id} flexDirection="column">
178 <Box>
179 <Box flexShrink={1}><Text wrap="truncate-end" dimColor={o.cut !== null}>{`${o.id} ${o.title}`}</Text></Box>
180 <Box flexShrink={0} marginLeft={2}><Text color={o.pros >= o.cons ? 'success' : 'warning'}>{s('forAgainst', { pros: o.pros, cons: o.cons })}</Text></Box>
181 </Box>
182 {o.cut !== null && <Text color="error" wrap="truncate-end">{` ${s('cutBy', { why: o.cut })}`}</Text>}
183 </Box>
184 ))}
185 </Box>
186 )
187 } else {
188 body = d.result === null ? <Text dimColor>{s('noResult')}</Text> : (
189 <Box flexDirection="column">
190 {d.fallback && <Text color="warning">{s('fallback')}</Text>}
191 <Text>{d.result}</Text>
192 </Box>
193 )
194 }
195 return (
196 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
197 <Box>
198 <Box flexShrink={1} flexGrow={1}><Text bold wrap="truncate-end">{sn.slug.replace(/\.jsonl$/, '')}</Text></Box>
199 <Box flexShrink={0} marginLeft={2}><Text color={d.ended ? 'success' : 'claude'}>{d.ended ? s('ended') : d.speaking ? s('speaking', { who: d.speaking }) : `${d.phase}/3`}</Text></Box>
200 <Box flexShrink={0} marginLeft={2}>{closeBtn}</Box>
201 </Box>
202 <Box marginY={1}>
203 {/* a tab that does not fit moves whole to the next line, never splits */}
204 <Box columnGap={2} flexWrap="wrap">
205 {items.map((t, i) => (
206 <Box key={`tab-${t.key}`} flexShrink={0}><Button key={t.key} plain hotkey={String(i + 1)} dimColor={kind !== t.key} label={`${kind === t.key ? '● ' : ''}${t.label}`}
207 onPress={() => update($, view, () => t.key as 'timeline' | 'options' | 'result')} /></Box>
208 ))}
209 </Box>
210 </Box>
211 {body}
212 </Box>
213 )
214 })
215
216 // band while a debate is live; one end card after a debate this session saw finish
217 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
218 if (e.props.hasSurvey) return next(e)
219 const sn = await read($, snap)
220 if (sn === null) return next(e)
221 const lang$ = await read($, lang)
222 const s = (k: Key, v?: Record<string, string | number>) => fmt(STRINGS[lang$][k], v)
223 const d = sn.debate
224 const { Box, Text, Button } = $.ui.resolve(e)
225 if (sn.live) {
226 return (
227 <Box flexDirection="column">
228 <Box>
229 <Box flexShrink={1}><Text dimColor wrap="truncate-end">{d.speaking ? s('band', { phase: d.phase, who: d.speaking }) : s('bandIdle', { phase: d.phase })}</Text></Box>
230 <Box flexShrink={0} marginLeft={1}><Button key="open" label={s('open')} onPress={() => $.ui.open(OPEN)} /></Box>
231 </Box>
232 {await next(e)}
233 </Box>
234 )
235 }
236 if (d.ended && sn.fresh && !(await read($, seen)).includes(sn.slug)) {
237 const done = () => update($, seen, l => [...l, sn.slug])
238 return (
239 <Box flexDirection="column">
240 <Box>
241 <Box flexShrink={0}><Text bold color={d.fallback ? 'warning' : 'success'}>{d.fallback ? `${s('card')} · ${s('fallback')}` : s('card')}</Text></Box>
242 {d.result && <Box flexShrink={1} marginLeft={1}><Text dimColor wrap="truncate-end">{`— ${d.result}`}</Text></Box>}
243 <Box flexShrink={0} marginLeft={1}><Button key="result" label={s('open')} onPress={async () => { await done(); await update($, view, () => 'result'); await $.ui.open(OPEN) }} /></Box>
244 <Box flexShrink={0} marginLeft={1}><Button key="dismiss" label={s('dismiss')} onPress={done} /></Box>
245 </Box>
246 {await next(e)}
247 </Box>
248 )
249 }
250 return next(e)
251 })
252}
253hooks/debate.ts 79 lines1/*
2 * Reads a brainstorming debate log (.claude/brainstorming/<slug>.jsonl, written by the
3 * think:brainstorming skill, references/debate.md). Plain functions; the mod only draws them.
4 */
5import type { Debate, Line, Option } from '../types'
6
7const FOR = new Set(['rebut', 'support'])
8
9// one JSON object per line; a torn or foreign line is skipped
10export function parse(body: string): Line[] {
11 const out: Line[] = []
12 for (const raw of body.split('\n')) {
13 if (raw.trim() === '') continue
14 try {
15 const o = JSON.parse(raw) as Record<string, unknown>
16 if (typeof o.kind !== 'string' || typeof o.persona !== 'string') continue
17 out.push({
18 ts: typeof o.ts === 'string' ? o.ts : '',
19 round: typeof o.round === 'number' ? o.round : 0,
20 persona: o.persona,
21 to: typeof o.to === 'string' ? o.to : '',
22 kind: o.kind,
23 text: typeof o.text === 'string' ? o.text : '',
24 })
25 } catch {
26 // skip
27 }
28 }
29 return out
30}
31
32// 1 diverge (personas, options, merge) · 2 attack/rebut · 3 cut/result
33const phaseOf = (kind: string): 1 | 2 | 3 =>
34 kind === 'attack' || FOR.has(kind) ? 2 : kind === 'cut' || kind === 'result' || kind === 'fallback' ? 3 : 1
35
36export function summarize(lines: Line[]): Debate {
37 const personas: string[] = []
38 for (const l of lines) if (l.persona !== 'Claude' && !personas.includes(l.persona)) personas.push(l.persona)
39 // merged options win; before the merge, the personas' own proposals
40 const merged = lines.filter(l => l.kind === 'merge' && l.to !== '')
41 const source = merged.length > 0 ? merged : lines.filter(l => l.kind === 'option' && l.to !== '')
42 const options: Option[] = []
43 for (const l of source) if (!options.some(o => o.id === l.to)) options.push({ id: l.to, title: l.text, by: l.persona, pros: 0, cons: 0, cut: null })
44 for (const l of lines) {
45 const o = options.find(x => x.id === l.to)
46 if (o === undefined) continue
47 if (l.kind === 'attack') o.cons += 1
48 else if (FOR.has(l.kind)) o.pros += 1
49 else if (l.kind === 'cut') o.cut = l.text
50 }
51 const last = lines.at(-1)
52 const end = [...lines].reverse().find(l => l.kind === 'result' || l.kind === 'fallback')
53 const ended = end !== undefined
54 return {
55 lines,
56 personas,
57 options,
58 phase: lines.reduce<1 | 2 | 3>((p, l) => Math.max(p, phaseOf(l.kind)) as 1 | 2 | 3, 1),
59 speaking: ended || last === undefined ? null : last.persona,
60 ended,
61 result: end?.text ?? null,
62 fallback: end?.kind === 'fallback',
63 }
64}
65
66export const byPersona = (lines: Line[], who: string | null) => (who === null ? lines : lines.filter(l => l.persona === who))
67
68// a window of `rows` lines starting at `offset`, the offset clamped to the list
69export function page<T>(items: T[], offset: number, rows: number): { items: T[]; offset: number } {
70 const max = Math.max(0, items.length - rows)
71 const at = Math.min(max, Math.max(0, offset))
72 return { items: items.slice(at, at + rows), offset: at }
73}
74
75// the newest .jsonl in the folder listing
76export function newest<F extends { name: string; kind: string; mtimeMs: number }>(entries: F[]): F | undefined {
77 return entries.filter(f => f.kind === 'file' && f.name.endsWith('.jsonl')).sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
78}
79types/index.d.ts 30 lines1// the shapes of hooks/debate.ts the mod keeps in state (self-contained: the contract may not import)
2export type Line = { ts: string; round: number; persona: string; to: string; kind: string; text: string }
3export type Option = { id: string; title: string; by: string; pros: number; cons: number; cut: string | null }
4export type Debate = {
5 lines: Line[]
6 personas: string[]
7 options: Option[]
8 phase: 1 | 2 | 3
9 speaking: string | null
10 ended: boolean
11 result: string | null
12 fallback: boolean
13}
14// what the tick keeps: the newest debate log, summarized
15export type Snap = { slug: string; debate: Debate; mtimeMs: number; size: number; live: boolean; fresh: boolean }
16
17declare module 'claude-code' {
18 interface PluginState {
19 think: {
20 snap: Snap | null
21 view: 'timeline' | 'options' | 'result'
22 filter: string | null
23 offset: number
24 lang: 'en' | 'ko'
25 // slugs whose end card the person dismissed or opened
26 seen: string[]
27 }
28 }
29}
30