열린 결정·할 일·만든 링크(PR·이슈)를 옆 패널에 띄워 둔다. 답이 질문으로 끝나면 그 질문을 결정 칸에 옮긴다

열린 결정, 할 일, 만든 링크를 옆 패널에 띄워 둔다. /board로 열고 닫는다.
mcp__board__update 도구로 할 일과 결정을 고친다. 보드는 시스템 프롬프트 끝(캐시 경계 뒤)에 붙고, 3단계 이상인 일은 보드에 올리라는 한 줄이 함께 붙는다.t1, d1…)는 지운 뒤에도 다시 쓰지 않는다. 보드에 없는 id를 가리키면 도구 결과에 그렇다고 알린다.decide로 닫을 때까지 남는다. 사용자가 두 번 답할 동안 열려 있으면 패널에 흐리게 (답 N번 지남)을 붙이고, Claude에게는 이미 답을 들었으면 닫으라고 적는다.gh pr|issue|release|repo|gist create, gh pr|issue comment, git push), 만드는 MCP 도구, Artifact 도구로 게시한 페이지의 결과에서만 모은다. 최근 12개.✓ N개 완료 한 줄로 접혀 있다. 그 줄을 누르거나 d를 누르면 펼치고 다시 접는다. Claude에게 보내는 보드에도 끝난 할 일은 Done (N): t1, t2 한 줄로만 적는다.d 완료 펼치기·접기, l 링크 비우기, c 완료 지우기, x 결정 모두 닫기./config): autoOpen(기본 꺼짐). 켜면 빈 보드에 처음 항목이 생길 때 패널이 저절로 열린다./clear하면 빈 보드)./plugin install board --marketplace vananaHope/vanana-modshooks/register.tsx 275 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Ids, Pin, Todo } from '../types'
5import {
6 addDecision,
7 answerDecisions,
8 applyUpdate,
9 artifactPin,
10 describeBoard,
11 isMakingMcp,
12 isStale,
13 lastQuestion,
14 MAKES_COMMAND,
15 mergePins,
16 normalizeUpdate,
17 summarize,
18 unknownIds,
19 urlPins,
20} from './board.ts'
21import type { Board } from './board.ts'
22
23const PANE = 'board'
24const TITLE = '내 보드'
25const TOOL = 'mcp__board__update'
26/** Windows의 PowerShell 도구는 다른 플랫폼의 타입에 없어서 이름 대신 정규식으로 맞춘다 */
27const SHELLS = /^(?:Bash|PowerShell)$/
28
29const decisions = atom({ plugin: 'board', key: 'decisions' } as const, [] as Decision[], { shape: 'decisions-1' })
30const todos = atom({ plugin: 'board', key: 'todos' } as const, [] as Todo[], { shape: 'todos-1' })
31const links = atom({ plugin: 'board', key: 'links' } as const, [] as Pin[], { shape: 'links-1' })
32const last = atom({ plugin: 'board', key: 'last' } as const, { t: 0, d: 0 } as Ids, { shape: 'last-1' })
33const showDone = atom({ plugin: 'board', key: 'showDone' } as const, false, { shape: 'show-done-1' })
34
35// Pinboard와 달리 "질문은 전부 올려라"를 넣지 않는다. 답의 마지막 질문은 mod가 직접 옮겨 적는다.
36const DESCRIPTION = [
37 "Keep the session's task list and open decisions on the user's board, a sidebar that stays in view while the transcript scrolls.",
38 'Use it in place of writing task lists in your reply whenever the work takes 3+ distinct steps or the user gives new instructions.',
39 'add_todos: one action per item. start_todo: the todo id you are working on now; exactly one is in progress at a time. done_todos / remove_todos: todo ids.',
40 'Update in real time; do not batch completions. Mark a todo done only after the work is actually done, including any verification it needs, never based on intent.',
41 'If blocked or partly done, leave it in progress and add a follow-up todo describing the blocker.',
42 'open_decisions: questions that need the user to choose. decide: close a decision by id once the user has answered it, including ones you did not open yourself.',
43 'The current board, with ids, is at the end of your system prompt.',
44].join(' ')
45
46const BOARD_HINT =
47 'Before starting work of 3 or more distinct steps, put the steps on the board and keep it current as you go (start, then done). Do this instead of listing the steps in your reply.'
48
49const strings = { type: 'array', items: { type: 'string' } }
50const SCHEMA = {
51 type: 'object',
52 properties: {
53 add_todos: strings,
54 start_todo: { type: 'string' },
55 done_todos: strings,
56 remove_todos: strings,
57 open_decisions: strings,
58 decide: {
59 type: 'array',
60 items: { type: 'object', properties: { id: { type: 'string' }, answer: { type: 'string' } }, required: ['id', 'answer'] },
61 },
62 },
63}
64
65type Options = { autoOpen?: boolean }
66
67const readBoard = async ($: EngineInterface): Promise<Board> => ({
68 todos: await read($, todos),
69 decisions: await read($, decisions),
70 last: await read($, last),
71})
72
73const writeBoard = async ($: EngineInterface, board: Board) => {
74 await update($, last, () => board.last)
75 await update($, todos, () => board.todos)
76 await update($, decisions, () => board.decisions)
77}
78
79const isEmpty = async ($: EngineInterface) =>
80 (await read($, decisions)).length + (await read($, todos)).length + (await read($, links)).length === 0
81
82const runtime = { autoOpen: false }
83
84/** 바꾸고, 설정이 켜져 있으면 빈 보드에 처음 생긴 항목에 패널을 연다 */
85async function capture($: EngineInterface, change: () => Promise<unknown>): Promise<void> {
86 const wasEmpty = runtime.autoOpen && (await isEmpty($))
87 await change()
88 if (wasEmpty && !(await isEmpty($))) await $.ui.open({ id: PANE, title: TITLE })
89}
90
91/** 보이면 닫고, 아니면 연다. 열었으면 true */
92async function togglePane($: EngineInterface): Promise<boolean> {
93 const pane = (await $.ui.panes()).find(item => item.id === PANE)
94 const wasOpen = !!pane && pane.isShown && pane.isPlaced
95 if (wasOpen) await $.ui.close({ id: PANE })
96 else await $.ui.open({ id: PANE, title: TITLE })
97 return !wasOpen
98}
99
100export const register: Register = (on, options) => {
101 const { autoOpen = false } = (options ?? {}) as Options
102 runtime.autoOpen = autoOpen === true
103 // 이번 턴에 Claude가 결정을 직접 올렸으면 마지막 질문을 따로 옮기지 않는다
104 let pinnedThisTurn = false
105
106 on('session.start', async ($, e, next) => {
107 await $.command.register({ name: 'board', description: '열린 결정·할 일·링크 패널 열기·닫기', immediate: true })
108 // ToolSearch 뒤에 두면 Claude가 스키마를 먼저 불러와야 해서 잘 쓰지 않는다: 처음부터 도구 목록에 둔다
109 await $.tool.register({ name: 'update', description: DESCRIPTION, inputSchema: SCHEMA, isDeferred: false })
110 return next(e)
111 })
112
113 on('command.run', { command: 'board' }, async $ => ({ text: (await togglePane($)) ? '내 보드를 열었습니다.' : '내 보드를 닫았습니다.' }))
114
115 // 보드는 시스템 프롬프트 끝(캐시 경계 뒤)에 붙어서 답에 목록을 되풀이할 필요가 없다
116 // 도구 설명만으로는 Claude가 보드를 잘 쓰지 않아서(실제 세션에서 확인) 보드 옆에 한 줄을 더 둔다
117 on('prompt.compose', async ($, e, next) => {
118 const { sections } = await next(e)
119 const text = `# User's board (${TOOL})\n${describeBoard(await readBoard($))}\n${BOARD_HINT}`
120 return { sections: [...sections, { id: 'board:board', text, scope: 'session' }] }
121 })
122
123 // 답에서 옮긴 질문은 사용자가 다음 말을 하면 닫는다: Claude는 도구를 따로 불러 닫지 않는다(시험에서 확인).
124 // Claude가 올린 결정은 남기되, 사용자가 답한 횟수를 세어 오래된 것을 드러낸다(Claude가 decide를 잊는다).
125 on('prompt.submit', async ($, e, next) => {
126 if (!e.command && e.text.trim()) {
127 await update($, decisions, old => (old.length ? answerDecisions(old) : old)).catch(() => undefined)
128 }
129 return next(e)
130 })
131
132 on('turn.start', ($, e, next) => {
133 if (!e.agentId) pinnedThisTurn = false
134 return next(e)
135 })
136
137 on('tool.call', { tool: TOOL }, async ($, e) => {
138 if (e.agentId) return { deny: '내 보드는 메인 대화만 고칩니다.' }
139 const change = normalizeUpdate(e)
140 if (change.open_decisions?.length) pinnedThisTurn = true
141 let board: Board = { todos: [], decisions: [], last: { t: 0, d: 0 } }
142 let missing: string[] = []
143 await capture($, async () => {
144 const before = await readBoard($)
145 board = applyUpdate(before, change)
146 missing = unknownIds(before, board, change)
147 await writeBoard($, board)
148 })
149 const note = missing.length ? `\nIgnored ids not on the board: ${missing.join(', ')}. Use the ids shown above.` : ''
150 return { result: describeBoard(board) + note }
151 })
152
153 // Pinboard의 Stop hook처럼 Claude를 되돌려 보내지 않는다(턴이 하나 더 들고 "Stop hook error"가 뜬다).
154 // 답이 질문으로 끝나면 그 줄을 결정 칸에 직접 옮긴다: 모델 호출 없음.
155 on('turn.complete', async ($, e, next) => {
156 const done = await next(e)
157 if (e.agentId || e.reason !== 'answer' || pinnedThisTurn) return done
158 const question = lastQuestion(e.answer)
159 if (question) await capture($, async () => writeBoard($, addDecision(await readBoard($), question, true)))
160 return done
161 })
162
163 // 무언가를 만든 명령(gh pr create, git push 등)이면 그 결과의 링크를 모은다
164 on('tool.call', { tool: SHELLS }, async ($, e, next) => {
165 const ran = await next(e)
166 const command = 'command' in e && typeof e.command === 'string' ? e.command : ''
167 if (ran.deny !== undefined || ran.isError === true || e.agentId || !MAKES_COMMAND.test(command)) return ran
168 const found = urlPins(ran.text ?? '')
169 if (found.length > 0 && found.length <= 3) await capture($, () => update($, links, old => mergePins(old, found)))
170 return ran
171 })
172
173 on('tool.call', async ($, e, next) => {
174 const ran = await next(e)
175 if (e.agentId || !('text' in ran) || ran.isError) return ran
176 if (e.tool === 'Artifact') {
177 const pin = artifactPin(e, ran.text ?? '')
178 if (pin) await capture($, () => update($, links, old => mergePins(old, [pin])))
179 return ran
180 }
181 if (!isMakingMcp(e.tool)) return ran
182 const found = urlPins(ran.text ?? '')
183 if (found.length > 0 && found.length <= 3) await capture($, () => update($, links, old => mergePins(old, found)))
184 return ran
185 })
186
187 // 보드 갱신은 대화에 흐린 한 줄로만 남고, 보드 자체는 패널에 있다
188 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
189 const { Text } = $.ui.resolve(e)
190 return <Text dimColor>{summarize(normalizeUpdate(e.props.input))}</Text>
191 })
192
193 on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) =>
194 e.props.isErrored ? next(e) : $.ui.resolve(e).Text({ children: [''] }),
195 )
196
197 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
198 const { Box, Text, Button, Link } = $.ui.resolve(e)
199 // 사방에 한 칸씩 여백
200 const inner = Math.max(10, e.props.bodyColumns - 2)
201 const allDecisions = await read($, decisions)
202 const allTodos = await read($, todos)
203 const allLinks = await read($, links)
204 const isDoneShown = await read($, showDone)
205 const doneCount = allTodos.filter(t => t.isDone).length
206
207 const header = (title: string, count: string) => (
208 <Text bold>
209 {title} <Text dimColor>{count}</Text>
210 </Text>
211 )
212 const empty = (text: string) => <Text dimColor> {text}</Text>
213 // 기호는 따로 한 칸을 차지해서 줄이 넘어가도 글 아래로 들여 쓴다
214 const item = (bullet: string, text: string, isDim = false, color?: string) => (
215 <Box flexDirection="row" width={inner}>
216 <Text dimColor={isDim} color={color}>{' ' + bullet + ' '}</Text>
217 <Box flexShrink={1} flexGrow={1}>
218 <Text dimColor={isDim} color={color} wrap="wrap">
219 {text}
220 </Text>
221 </Box>
222 </Box>
223 )
224
225 return (
226 <Box flexDirection="column" width={inner + 2} padding={1}>
227 {header('열린 결정', allDecisions.length ? String(allDecisions.length) : '')}
228 {allDecisions.length === 0 && empty('기다리는 결정이 없습니다.')}
229 {allDecisions.map(d =>
230 d.isAuto ? item('?', `${d.text} (답에서 옮김)`) : isStale(d) ? item('?', `${d.text} (답 ${d.waited}번 지남)`, true) : item('?', d.text),
231 )}
232 <Text> </Text>
233
234 {header('할 일', allTodos.length ? `${doneCount}/${allTodos.length}` : '')}
235 {allTodos.length === 0 && empty('아직 할 일이 없습니다.')}
236 {allTodos.filter(t => !t.isDone).map(t => (t.isActive ? item('▸', t.text, false, 'warning') : item('○', t.text)))}
237 {/* 끝난 할 일은 한 줄로 접어서 남은 일이 위에 있게: 그 줄을 누르면 펼친다 */}
238 {doneCount > 0 && (
239 <Box flexDirection="row" width={inner}>
240 <Text>{' '}</Text>
241 <Button
242 key="toggle-done"
243 label={`${isDoneShown ? '▾' : '▸'} ✓ ${doneCount}개 완료`}
244 hotkey="d"
245 plain
246 dimColor
247 onPress={() => update($, showDone, old => !old)}
248 />
249 </Box>
250 )}
251 {doneCount > 0 && isDoneShown && allTodos.filter(t => t.isDone).map(t => item(' ✓', t.text, true))}
252 <Text> </Text>
253
254 <Box flexDirection="row" justifyContent="space-between" width={inner}>
255 {header('링크', allLinks.length ? String(allLinks.length) : '')}
256 <Button key="clear-links" label="비우기" hotkey="l" plain dimColor onPress={() => update($, links, () => [])} />
257 </Box>
258 {allLinks.length === 0 && empty('아직 만든 것이 없습니다.')}
259 {allLinks.map(p => (
260 <Text wrap="truncate-middle">
261 {' '}
262 <Link href={p.href} label={p.label} />
263 </Text>
264 ))}
265 <Text> </Text>
266 <Box flexDirection="row" width={inner}>
267 <Button key="clear-done" label="완료 지우기" hotkey="c" plain dimColor onPress={() => update($, todos, old => old.filter(t => !t.isDone))} />
268 <Text> </Text>
269 <Button key="clear-decisions" label="결정 모두 닫기" hotkey="x" plain dimColor onPress={() => update($, decisions, () => [])} />
270 </Box>
271 </Box>
272 )
273 })
274}
275hooks/board.ts 208 lines1// 보드의 순수 로직. sirkitree/pinboard(MIT, Jerad Bitner)의 방식을 바탕으로 고쳐 썼다.
2import type { Decision, Ids, Pin, Todo } from '../types'
3
4export type Update = {
5 add_todos?: string[]
6 start_todo?: string
7 done_todos?: string[]
8 remove_todos?: string[]
9 open_decisions?: string[]
10 decide?: { id: string; answer: string }[]
11}
12
13/** last: 지금까지 붙인 가장 큰 번호. 지운 번호를 다시 쓰지 않아서 Claude가 기억하는 옛 id가 새 항목을 가리키지 않는다 */
14export type Board = { todos: Todo[]; decisions: Decision[]; last: Ids }
15
16/** 쓰고 있는 번호와 지금까지 붙인 번호 중 가장 큰 것 다음 */
17const nextNumber = (prefix: string, used: number, ids: string[]) =>
18 Math.max(used, ...ids.map(id => Number(id.slice(prefix.length)) || 0)) + 1
19
20const texts = (value: unknown): string[] =>
21 (Array.isArray(value) ? value : [value]).filter((x): x is string => typeof x === 'string').map(x => x.trim()).filter(Boolean)
22
23/** id는 따옴표를 걷어 낸다: 실제 세션에서 Claude가 start_todo에 "\"t5\""를 보냈다 */
24const ids = (value: unknown): string[] => texts(value).map(x => x.replace(/^["'`]+|["'`]+$/g, '').trim()).filter(Boolean)
25
26/**
27 * 모델이 보낸 입력을 스키마대로 걸러 낸다. 배열 대신 문자열 하나를 보내면 한 항목으로 받는다
28 * (그대로 돌면 글자마다 할 일이 생긴다). decide는 id 문자열만 보내도 받는다.
29 */
30export function normalizeUpdate(input: unknown): Update {
31 const raw = (input ?? {}) as Record<string, unknown>
32 const decide = (Array.isArray(raw.decide) ? raw.decide : [raw.decide]).flatMap(x => {
33 const item = x as { id?: unknown; answer?: unknown } | null
34 const [id] = ids(item && typeof item === 'object' ? item.id : x)
35 if (!id) return []
36 return [{ id, answer: item && typeof item === 'object' && typeof item.answer === 'string' ? item.answer : '' }]
37 })
38 const change: Update = {}
39 const add = texts(raw.add_todos)
40 const done = ids(raw.done_todos)
41 const remove = ids(raw.remove_todos)
42 const open = texts(raw.open_decisions)
43 const [start] = ids(raw.start_todo)
44 if (add.length) change.add_todos = add
45 if (start) change.start_todo = start
46 if (done.length) change.done_todos = done
47 if (remove.length) change.remove_todos = remove
48 if (open.length) change.open_decisions = open
49 if (decide.length) change.decide = decide
50 return change
51}
52
53export function applyUpdate(board: Board, change: Update): Board {
54 let { todos: t, decisions: d, last } = board
55 for (const text of change.add_todos ?? []) {
56 const n = nextNumber('t', last.t, t.map(x => x.id))
57 last = { ...last, t: n }
58 t = [...t, { id: `t${n}`, text, isDone: false }]
59 }
60 for (const text of change.open_decisions ?? []) ({ decisions: d, last } = addDecision({ todos: t, decisions: d, last }, text))
61 const done = new Set(change.done_todos ?? [])
62 const removed = new Set(change.remove_todos ?? [])
63 const decided = new Set((change.decide ?? []).map(x => x.id))
64 t = t.filter(x => !removed.has(x.id)).map(x => (done.has(x.id) ? { ...x, isDone: true } : x))
65 // 진행 중은 한 번에 하나이고, 끝난 할 일은 진행 중에서 빠진다
66 if (change.start_todo) t = t.map(x => ({ ...x, isActive: x.id === change.start_todo }))
67 t = t.map(x => (x.isDone && x.isActive ? { ...x, isActive: false } : x))
68 d = d.filter(x => !decided.has(x.id))
69 return { todos: t, decisions: d, last }
70}
71
72/** 이 변경이 가리켰지만 보드에 없는 id: Claude에게 알려서 옛 id로 헛짚었음을 알게 한다 */
73export function unknownIds(before: Board, after: Board, change: Update): string[] {
74 const todoIds = new Set([...before.todos, ...after.todos].map(x => x.id))
75 const decisionIds = new Set([...before.decisions, ...after.decisions].map(x => x.id))
76 return [
77 ...[change.start_todo ?? [], change.done_todos ?? [], change.remove_todos ?? []].flat().filter(id => !todoIds.has(id)),
78 ...(change.decide ?? []).map(x => x.id).filter(id => !decisionIds.has(id)),
79 ].filter((id, i, all) => all.indexOf(id) === i)
80}
81
82/** 같은 글의 결정이 이미 열려 있으면 그대로 둔다 */
83export function addDecision(board: Board, text: string, isAuto = false): Board {
84 if (board.decisions.some(x => x.text === text)) return board
85 const n = nextNumber('d', board.last.d, board.decisions.map(x => x.id))
86 const decision: Decision = { id: `d${n}`, text }
87 return { ...board, decisions: [...board.decisions, isAuto ? { ...decision, isAuto } : decision], last: { ...board.last, d: n } }
88}
89
90/** 사용자가 이만큼 답한 뒤에도 열려 있는 결정은 오래된 것으로 본다 */
91export const STALE_AFTER = 2
92
93export const isStale = (d: Decision) => !d.isAuto && (d.waited ?? 0) >= STALE_AFTER
94
95/** 사용자가 답을 보내면: 답에서 옮긴 결정은 닫고, Claude가 올린 결정은 기다린 횟수를 하나 올린다 */
96export const answerDecisions = (old: Decision[]): Decision[] =>
97 old.filter(d => !d.isAuto).map(d => ({ ...d, waited: (d.waited ?? 0) + 1 }))
98
99/**
100 * 시스템 프롬프트와 도구 결과에 붙는 보드. 끝난 할 일은 매 턴 쌓이기만 해서 id만 한 줄로 줄인다
101 * (패널에서는 펼쳐 볼 수 있다). 오래 열린 결정에는 이미 답을 들었으면 닫으라는 말을 붙인다.
102 */
103export function describeBoard(board: Board): string {
104 if (board.todos.length + board.decisions.length === 0) return 'Board is empty.'
105 const done = board.todos.filter(t => t.isDone).map(t => t.id)
106 return [
107 'Board now:',
108 ...board.todos.filter(t => !t.isDone).map(t => `${t.id} [${t.isActive ? '>' : ' '}] ${t.text}`),
109 ...(done.length ? [`Done (${done.length}): ${done.join(', ')}`] : []),
110 ...board.decisions.map(d =>
111 isStale(d) ? `${d.id} [?] ${d.text} (open across ${d.waited} user replies: decide it if already answered)` : `${d.id} [?] ${d.text}`,
112 ),
113 ].join('\n')
114}
115
116/** 바뀐 내용을 한 줄로: 대화에 남는 흐린 줄 */
117export function summarize(change: Update): string {
118 const parts = [
119 change.add_todos?.length && `할 일 +${change.add_todos.length}`,
120 change.start_todo && `${change.start_todo} 시작`,
121 change.done_todos?.length && `${change.done_todos.length}개 완료`,
122 change.remove_todos?.length && `할 일 -${change.remove_todos.length}`,
123 change.open_decisions?.length && `결정 +${change.open_decisions.length}`,
124 change.decide?.length && `결정 ${change.decide.length}개 닫음`,
125 ].filter(Boolean)
126 return '내 보드: ' + (parts.join(', ') || '바뀐 것 없음')
127}
128
129const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+/
130const sentencesOf = (line: string) => line.split(/(?<=[.!?。?])\s+/)
131
132/**
133 * "…할까요? 후보는 이렇습니다." 다음에 후보 목록(두 항목 이상)이 오고, 그 뒤에 추천 같은 짧은 줄이
134 * 두 줄까지 붙는 답: 목록 바로 위 줄을 돌려준다. 실제 세션의 답이 이렇게 끝났다.
135 */
136function lineAboveOptions(lines: string[]): string | undefined {
137 for (let skip = 0; skip <= 2; skip++) {
138 const end = lines.length - skip
139 const after = lines.slice(end)
140 if (after.some(l => LIST_ITEM.test(l) || isQuestion(l))) return undefined
141 let start = end
142 while (start > 0 && LIST_ITEM.test(lines[start - 1] ?? '') && !isQuestion(lines[start - 1] ?? '')) start--
143 if (end - start >= 2) {
144 const above = lines[start - 1]
145 return above !== undefined && sentencesOf(above).some(isQuestion) ? above : undefined
146 }
147 }
148 return undefined
149}
150
151/**
152 * 답이 사용자에게 묻고 끝나면 그 질문 문장을 돌려준다. 코드 블록은 빼고, 마지막 세 줄 중 물음표로 끝나는 줄이나
153 * 끝의 후보 목록 바로 위 줄을 본다. 한 줄에 여러 문장이 있으면 물음표로 끝나는 마지막 문장만 쓰고,
154 * 목록 기호와 굵게 표시는 걷어 내고, 너무 길면 자른다.
155 */
156export function lastQuestion(reply: string): string | undefined {
157 const lines = reply.replace(/```[\s\S]*?```/g, '').split('\n').map(l => l.trim()).filter(Boolean)
158 const line = lines.slice(-3).reverse().find(isQuestion) ?? lineAboveOptions(lines)
159 if (!line) return undefined
160 const sentence = sentencesOf(line).reverse().find(isQuestion) ?? line
161 const text = sentence.replace(LIST_ITEM, '').replace(/^>\s+/, '').replace(/\*\*|__|`/g, '').trim()
162 return text.length > 200 ? text.slice(0, 199) + '…' : text
163}
164
165const isQuestion = (text: string) => /[??][*_`)"'\]]*$/.test(text)
166
167// 무언가를 만드는 명령과 MCP 도구에서만 링크를 모은다. 읽기·조회 결과의 URL은 건너뛴다.
168export const MAKES_COMMAND = /\bgh\s+(?:(?:pr|issue|release|repo|gist)\s+create|(?:pr|issue)\s+comment)\b|\bgit\s+push\b/
169const MAKES_MCP = /create|draft|send|publish|share|canvas|upload/i
170const READS_MCP = /(?:^|[_-])(?:read|get|list|search|fetch|query|download)(?:[_-]|$)/i
171
172/** 서버 이름은 빼고 도구 이름만 본다: slack_read_canvas, list-shared-pages 같은 읽기 도구는 건너뛴다 */
173export function isMakingMcp(tool: string): boolean {
174 const name = /^mcp__.+?__(.+)$/.exec(tool)?.[1]
175 return name !== undefined && MAKES_MCP.test(name) && !READS_MCP.test(name)
176}
177
178const URL = /https:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[A-Za-z0-9\-._~:/?#[\]!$&'()*+,;=%@]*)?/g
179
180export const urlPins = (text: string): Pin[] =>
181 [...new Set([...text.matchAll(URL)].map(m => m[0].replace(/[)\].,;:'!?*]+$/, '')))]
182 // git push가 찍는 저장소 주소(…/repo.git)는 만든 것이 아니다
183 .filter(href => !href.includes('@') && !/\.git$/.test(href) && href.length <= 2048)
184 .map(href => {
185 const gh = /github\.com\/[^/]+\/([^/]+)\/(pull|issues)\/(\d+)/.exec(href)
186 return { href, label: gh ? `${gh[1]} ${gh[2] === 'pull' ? 'PR' : '이슈'} #${gh[3]}` : href.slice(8) }
187 })
188
189const ARTIFACT_URL = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9-]+/
190
191/**
192 * Artifact 도구로 게시한 페이지의 링크. 셸 명령도 MCP 도구도 아니어서 따로 본다.
193 * 읽기·목록·삭제·자산 올리기는 건너뛰고, 이름은 제목이나 파일 이름으로 붙인다.
194 */
195export function artifactPin(input: unknown, text: string): Pin | undefined {
196 const raw = (input ?? {}) as { action?: unknown; asset?: unknown; title?: unknown; file_path?: unknown }
197 if ((raw.action !== undefined && raw.action !== 'publish') || raw.asset === true) return undefined
198 const href = ARTIFACT_URL.exec(text)?.[0]
199 if (!href) return undefined
200 const file = typeof raw.file_path === 'string' ? raw.file_path.split(/[\\/]/).at(-1)?.replace(/\.[^.]+$/, '') : undefined
201 const name = (typeof raw.title === 'string' && raw.title.trim()) || file
202 return { href, label: name ? `아티팩트 ${name}` : href.slice(8) }
203}
204
205/** 새 링크를 앞에, 같은 주소는 하나만, 최대 12개 */
206export const mergePins = (old: Pin[], found: Pin[]): Pin[] =>
207 [...found, ...old.filter(p => !found.some(f => f.href === p.href))].slice(0, 12)
208types/index.d.ts 26 lines1export type Todo = { id: string; text: string; isDone: boolean; isActive?: boolean }
2
3/**
4 * isAuto: Claude가 올리지 않고 답의 마지막 질문을 mod가 옮겨 적은 것.
5 * waited: Claude가 올린 결정이 열린 뒤 사용자가 답을 보낸 횟수
6 */
7export type Decision = { id: string; text: string; isAuto?: boolean; waited?: number }
8
9export type Pin = { href: string; label: string }
10
11/** 할 일(t)과 결정(d)에 지금까지 붙인 가장 큰 번호 */
12export type Ids = { t: number; d: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 board: {
17 decisions: Shaped<Decision[]>
18 todos: Shaped<Todo[]>
19 links: Shaped<Pin[]>
20 last: Shaped<Ids>
21 /** 패널에서 완료한 할 일을 펼쳐 보이는지 */
22 showDone: Shaped<boolean>
23 }
24 }
25}
26