SLOPSHOPPER

think

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

newpanebandcommandtimer
★ 3v1.6.0MITupdated 2026-10-14newkayak12/claude-skills/think
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · think
│ ┃ Brainstorming ✕ › fix the failing auth test and add an audit log call │ ┃ No debate log in this folder │ ┃ (.claude/brainstorming). ⏺ Read(src/auth.ts) │ ┃ q: close ⎿ 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 │ │ › /brainstorming-status │ ⎿ think: pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Brainstorming
No debate log in this folder (.claude/brainstorming). q: close
README

think

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.

Install & Uninstall

/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. Needs sh (Windows without one is not covered).

Which skill do I want?

I want to…Skill
Get a senior mentor's judgment in any field — career, craft, work, study, relationships, life decisionsmentor
Design something when the solution shape is still unclearbrainstorming
Have a plan I already hold interrogated before buildinggrill
Check whether I'm even solving the right problemredefine-problem
Break an approach down to what's checkable and rebuild from thereback-to-basics
Have my plan attacked with the strongest objectionsdevils-advocate
Turn scattered notes into a structure I can write or present fromuntangle-thoughts
Have counsel on my side for a salary talk, a contract, or a hard conversationnegotiate

Skills

mentor

A 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 bringWhat 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 standardan inherited standard, never audited
a confident plana decision already made, seeking cover
pain rather than judgmentnot 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:

[내 생각] 한 문장, 완충 없이
[가장 강한 반론] 내 입장에 대한 것, 내가 직접 세운다
[뒤집힐 조건] 무엇을 보면 내가 틀렸다고 인정할지
[멈출 것 하나] 더할 것이 아니라 뺄 것
[다음에 알아볼 것] 답이 아니라 규칙

brainstorming

Turns 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.

status-band The band while a debate runs: which round and who is speaking.

status-timeline Timeline: every proposal, attack and rebuttal in order, so you see how an option won or lost.

status-options Options: for and against per option at a glance.

grill

Interrogates 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-problem

Doubts 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-basics

Breaks 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-advocate

Produces 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-thoughts

Takes 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:

SituationTechnique
Goal is a document, essay, or presentationOutline
You say "map", or input has no clear hierarchyText mind map
5+ ideas with named cross-links, knowledge-base goalZettelkasten-style linking
You need only the core messageCore claim extraction

negotiate

Counsel 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.

MCP

SkillRecommendedOptional
mentorthink-tool (classifying what kind of question it is)sequential-thinking, mcp-reasoner
brainstorming—think-tool, sequential-thinking, mcp-reasoner
grill—think-tool
redefine-problemthink-tool (required gate: assumption enumeration)sequential-thinking
back-to-basicsthink-tool (assumption listing, checked vs inherited)mcp-reasoner
devils-advocate—think-tool, mcp-reasoner, sequential-thinking
untangle-thoughtsthink-tool (gap surfacing)sequential-thinking
negotiatethink-tool (counterpart interests, BATNA check)sequential-thinking

Add the remote SSE endpoints in Claude settings → MCP Servers.

Related workflows

  • 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.
  • After a decision, feed it into 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.

Source 3 files
hooks/mod.tsx 253 lines
1import { 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}
253
hooks/debate.ts 79 lines
1/*
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}
79
types/index.d.ts 30 lines
1// 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