Side pane following a /business-spec: decisions and answers, open questions, use cases, business rules, sections; full content unfolded inline on click

From a business need to tested, audited code — Claude Code does the work, you make the decisions.
cctoolkit is a Claude Code plugin for DDD / clean-architecture repos. You describe a feature; Claude writes the spec with you, plans it, then builds it batch by batch under strict TDD and audits the result before you commit.
.NET first; Python, Java/Kotlin and TypeScript repos work through their layout and test tags.
claude plugin marketplace add pierrebelin/claude-code-toolkit --scope project
claude plugin install cctoolkit@cctoolkit --scope project
Restart Claude Code, then run /cctoolkit:kit-init: it reads your repo, asks a few questions and sets everything up.
Requirements, options and what kit-init writes: INSTALL.md.
Three commands, one per step. Each one writes a file the next one reads.
flowchart LR
S["<b>1. Spec</b><br/>/business-spec"] -->|SPEC.md| P["<b>2. Plan</b><br/>/plan-implementation"]
P -->|"PLAN.md + one sheet per batch"| R["<b>3. Build</b><br/>/run-lot"]
R -->|"code, tests, report"| C(["you review and commit"])
C -.->|next batch| R
/cctoolkit:business-spec — Claude asks you one question at a time, then writes a short business spec: rules, use cases, no technical detail. A second agent tries to poke holes in it./cctoolkit:plan-implementation — turns the spec into a technical plan, split into small batches./cctoolkit:run-lot <sheet> — builds one batch on its own: tests first, then code, then an audit. Comes back DONE, or with what needs your eyes.Inside run-lot, each behaviour of the batch is one loop:
flowchart LR
I["run-lot / implement-tdd"] --> R
R["RED"] -->|"compact contract:<br/>RM/CU, scenario, level"| A["tdd-test-author subagent<br/>writes the red test"]
A -->|"red test<br/>no production code"| R
R --> G["GREEN + REFACTOR"]
G -->|"compact contract:<br/>red test, signatures,<br/>invariants, expected cost"| B["tdd-implementer subagent<br/>writes the production code<br/>tests read-only"]
B -->|"green + cost stated<br/>or BLOCKED"| G
G --> C["COST<br/>validated by the orchestrator"]
C -.->|"next behaviour"| R
C ==>|"whole batch green"| V["audit<br/>ddd-tdd-auditor"]
The orchestrator writes neither tests nor code: it splits the work, checks the diffs, validates the cost and settles the design. One agent writes the failing test, another makes it pass without touching it, a third audits.
Commit, /clear, next batch. A batch that stops short is finished by hand with /cctoolkit:implement-tdd then /cctoolkit:verify-ddd-tdd (docs/TOOLING.md).
Want to see the files? examples/ walks one small feature from spec to report.
A batch takes tens of minutes and a dozen agents. The run-lot-pane mod shows it live inside Claude Code: each phase, which agent is working, the tests going red then green, the cost, and the final verdict.

claude plugin install run-lot-pane@cctoolkit --scope project
Two more mods — a live view of the spec as it's written, and a context meter telling you when to /clear: mods/README.md.
This kit encodes one way of working. It will:
If your team works differently, half of it will feel like noise. Full list: skills/README.md.
| Install, update, dependencies | INSTALL.md |
| A feature from spec to report | examples/ |
| Every skill and agent | skills/README.md |
| Autonomous runs, finishing a batch by hand | docs/TOOLING.md |
| Layouts and configuration | presets/README.md |
| Other repos configuring a coding agent | RESOURCES.md |
MIT. Take what you want, closed-source projects included.
hooks/register.tsx 556 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Kind, Navigation, OpenQuestion, SpecDoc, SpecItem, SpecSection, Tracked } from '../types'
5
6const PANE = 'spec-pane'
7const TITLE = 'Spec'
8const SKILL = 'business-spec'
9const TICK_MS = 3_000
10const SPEC_PATTERN = /(?:^|\/)todo\/[^/]+\/SPEC(?:-[^/]+)?\.md$/
11const OPEN_QUESTIONS = ['questions ouvertes', 'open questions']
12const ITEM_PATTERN = /^###\s+((?:CU|RM)-[\w.]+)\s*[—–-]\s*(.*)$/
13const SECTION_PATTERN = /^##\s+(\d+)\.\s*(.*?)\s*(?:<!--.*-->)?\s*$/
14const SEVERITY_PATTERN = /\*\*(?:s[ée]v[ée]rit[ée]|severity)\*\*\s*:?\s*([^·\n]+)/i
15const RECOMMENDED_PATTERN = /\((?:recommand[ée]e?|recommended)\)/i
16const EDITING_TOOLS = ['Edit', 'Write', 'MultiEdit']
17const BLOCKING = ['bloquant', 'blocking']
18const MAJOR = ['majeur', 'major', 'warning']
19const GROUPS = ['decisions', 'openQuestions', 'useCases', 'rules', 'sections'] as const
20
21const IDLE: Tracked = { isArmed: false, topic: null, path: null, mtimeMs: 0, doc: null, readAt: null }
22const HOME: Navigation = { expanded: [], collapsed: [] }
23
24const tracked = atom({ plugin: 'spec-pane', key: 'tracked' } as const, IDLE)
25const decisions = atom({ plugin: 'spec-pane', key: 'decisions' } as const, [] as Decision[])
26const nav = atom({ plugin: 'spec-pane', key: 'nav' } as const, HOME)
27const now = atom({ plugin: 'spec-pane', key: 'now' } as const, 0)
28
29export const fold = (text: string): string =>
30 text
31 .normalize('NFD')
32 .replace(/[̀-ͯ]/g, '')
33 .toLowerCase()
34 .trim()
35
36export const isSpecPath = (path: string): boolean => SPEC_PATTERN.test(path)
37
38export const relativePath = (path: string, root: string | null): string =>
39 root !== null && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
40
41export const severityTone = (severity: string | null): 'error' | 'warning' | 'inactive' => {
42 const folded = fold(severity ?? '')
43 if (BLOCKING.some(word => folded.startsWith(word))) return 'error'
44 if (MAJOR.some(word => folded.startsWith(word))) return 'warning'
45 return 'inactive'
46}
47
48export const plain = (text: string): string => text.replace(/\*\*/g, '').replace(/`/g, '')
49
50export const fit = (text: string, width: number): string => {
51 const flat = text.replace(/\s+/g, ' ').trim()
52 const room = Math.max(1, width)
53 return flat.length > room ? `${flat.slice(0, room - 1)}…` : flat
54}
55
56export const ago = (ms: number): string => {
57 const seconds = Math.max(0, Math.round(ms / 1000))
58 if (seconds < 60) return `${seconds}s`
59 const minutes = Math.floor(seconds / 60)
60 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
61}
62
63const cellsOf = (line: string): string[] =>
64 line
65 .trim()
66 .replace(/^\|/, '')
67 .replace(/\|$/, '')
68 .split('|')
69 .map(cell => cell.trim())
70
71const isSeparator = (line: string): boolean => /^\|?\s*:?-{3,}/.test(line.trim())
72
73export const parseTable = (body: string): Record<string, string>[] => {
74 const rows = body.split('\n').filter(line => line.trim().startsWith('|') && !isSeparator(line))
75 const [header, ...data] = rows
76 if (header === undefined) return []
77 const names = cellsOf(header).map(fold)
78 return data.map(row => Object.fromEntries(cellsOf(row).map((cell, index) => [names[index] ?? `${index}`, cell])))
79}
80
81const cellOf = (row: Record<string, string>, ...names: string[]): string =>
82 Object.entries(row).find(([name]) => names.some(one => name.startsWith(one)))?.[1] ?? ''
83
84export const parseOpenQuestions = (body: string): OpenQuestion[] =>
85 parseTable(body)
86 .map(row => ({
87 id: cellOf(row, '#'),
88 severity: cellOf(row, 'sev'),
89 question: cellOf(row, 'question'),
90 impact: cellOf(row, 'impact'),
91 options: cellOf(row, 'option'),
92 }))
93 .filter(question => question.question !== '')
94
95const trimBlank = (lines: string[]): string => lines.join('\n').trim()
96
97export const parseSpec = (text: string): SpecDoc => {
98 const lines = text.split('\n')
99 const title = (lines.find(line => /^#\s/.test(line)) ?? '').replace(/^#\s+/, '').trim()
100 const summary = lines
101 .filter(line => line.startsWith('>'))
102 .map(line => line.replace(/^>\s?/, '').trim())
103 .join(' ')
104 .trim()
105 const sections: SpecSection[] = []
106 const items: SpecItem[] = []
107 let section: { number: string; title: string; lines: string[] } | null = null
108 let item: { id: string; name: string; lines: string[] } | null = null
109 const closeItem = () => {
110 if (item === null) return
111 const body = trimBlank(item.lines)
112 const severity = item.id.startsWith('RM-') ? (SEVERITY_PATTERN.exec(body)?.[1]?.trim() ?? null) : null
113 items.push({ id: item.id, name: item.name, tag: severity, body })
114 item = null
115 }
116 const closeSection = () => {
117 closeItem()
118 if (section !== null) sections.push({ number: section.number, title: section.title, body: trimBlank(section.lines) })
119 section = null
120 }
121 for (const line of lines) {
122 const heading = /^##\s/.test(line) ? SECTION_PATTERN.exec(line) : null
123 if (/^##\s/.test(line)) {
124 closeSection()
125 if (heading !== null) section = { number: heading[1] ?? '', title: (heading[2] ?? '').trim(), lines: [] }
126 continue
127 }
128 if (section === null) continue
129 section.lines.push(line)
130 const found = ITEM_PATTERN.exec(line)
131 if (found !== null) {
132 closeItem()
133 item = { id: found[1] ?? '', name: (found[2] ?? '').trim(), lines: [] }
134 } else if (/^###\s/.test(line)) closeItem()
135 else if (item !== null) item.lines.push(line)
136 }
137 closeSection()
138 const questions =
139 sections.find(one => OPEN_QUESTIONS.some(title => fold(one.title).startsWith(title))) ?? sections.find(one => one.number === '12')
140 return {
141 title,
142 summary,
143 sections,
144 useCases: items.filter(one => one.id.startsWith('CU-')),
145 rules: items.filter(one => one.id.startsWith('RM-')),
146 openQuestions: questions === undefined ? [] : parseOpenQuestions(questions.body),
147 }
148}
149
150export const isSpecSkill = (skill: unknown): boolean =>
151 typeof skill === 'string' && (skill === SKILL || skill.endsWith(`:${SKILL}`))
152
153export const promptTopic = (text: string): string | null | undefined => {
154 const typed = /^\s*\/(?:[\w-]+:)?business-spec\b(.*)$/s.exec(text)
155 if (typed !== null) return (typed[1] ?? '').trim() || null
156 const expanded = /<command-name>\/?(?:[\w-]+:)?business-spec<\/command-name>/.test(text)
157 if (!expanded) return undefined
158 return /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1]?.trim() || null
159}
160
161type Asked = { question?: unknown; header?: unknown; options?: unknown }
162
163const labelsOf = (options: unknown): string[] =>
164 Array.isArray(options)
165 ? options.map(option => (option as { label?: unknown }).label).filter((label): label is string => typeof label === 'string')
166 : []
167
168export const recommendedOf = (options: string[]): string | null =>
169 options.find(option => RECOMMENDED_PATTERN.test(option)) ?? options[0] ?? null
170
171export const askedOf = (id: string, questions: unknown): Decision[] =>
172 (Array.isArray(questions) ? (questions as Asked[]) : [])
173 .filter(one => typeof one.question === 'string')
174 .map((one, index) => {
175 const options = labelsOf(one.options)
176 return {
177 id: `${id}-${index}`,
178 header: typeof one.header === 'string' ? one.header : '',
179 question: one.question as string,
180 options,
181 recommended: recommendedOf(options),
182 answer: null,
183 note: null,
184 isPending: true,
185 }
186 })
187
188type Answered = { answers?: unknown; annotations?: unknown; response?: unknown }
189
190export const withAnswers = (asked: Decision[], result: unknown): Decision[] => {
191 const answered = (result ?? {}) as Answered
192 const answers = (answered.answers ?? {}) as Record<string, unknown>
193 const annotations = (answered.annotations ?? {}) as Record<string, { notes?: unknown } | undefined>
194 const response = typeof answered.response === 'string' && answered.response.trim() !== '' ? answered.response.trim() : null
195 return asked.map(decision => {
196 const answer = answers[decision.question]
197 const notes = annotations[decision.question]?.notes
198 return {
199 ...decision,
200 answer: typeof answer === 'string' && answer !== '' ? answer : response,
201 note: typeof notes === 'string' && notes.trim() !== '' ? notes.trim() : null,
202 isPending: false,
203 }
204 })
205}
206
207export const isOffRecommendation = (decision: Decision): boolean =>
208 decision.answer !== null && decision.recommended !== null && !decision.answer.split(', ').includes(decision.recommended)
209
210const blockingCount = (questions: OpenQuestion[]): number => questions.filter(one => severityTone(one.severity) === 'error').length
211
212async function openPane($: EngineInterface): Promise<void> {
213 await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
214}
215
216async function arm($: EngineInterface, topic: string | null): Promise<void> {
217 await update($, tracked, () => ({ ...IDLE, isArmed: true, topic }))
218 await update($, decisions, () => [])
219 await update($, nav, () => HOME)
220 await openPane($)
221}
222
223async function reload($: EngineInterface, path: string): Promise<void> {
224 try {
225 const stat = await $.fs.stat(path)
226 const text = await $.fs.read(path)
227 const at = await $.clock.now()
228 await update($, tracked, state => ({ ...state, isArmed: true, path, mtimeMs: stat.mtimeMs, doc: parseSpec(text), readAt: at }))
229 } catch {
230 await update($, tracked, state => ({ ...state, path }))
231 }
232}
233
234async function tick($: EngineInterface): Promise<void> {
235 const state = await read($, tracked)
236 if (!state.isArmed) return
237 const at = await $.clock.now()
238 await update($, now, () => at)
239 if (state.path === null) return
240 const stat = await $.fs.stat(state.path).catch(() => null)
241 if (stat !== null && stat.mtimeMs !== state.mtimeMs) await reload($, state.path)
242}
243
244export const register: Register = on => {
245 let root: string | null = null
246
247 on('session.start', async ($, e, next) => {
248 root = e.cwd
249 await $.command.register({
250 name: 'spec-pane',
251 description: 'Open the /business-spec pane (a SPEC path follows that spec, clear forgets it)',
252 })
253 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
254 if ((await read($, tracked)).isArmed) void openPane($)
255 return next(e)
256 })
257
258 on('command.run', { command: 'spec-pane' }, async ($, e) => {
259 if (e.args.trim() === 'clear') {
260 await update($, tracked, () => IDLE)
261 await update($, decisions, () => [])
262 await update($, nav, () => HOME)
263 return { text: 'Spec pane cleared.' }
264 }
265 const asked = e.args.trim().replace(/^@/, '')
266 if (asked !== '') {
267 const path = asked.startsWith('/') || root === null ? asked : `${root}/${asked}`
268 if (!isSpecPath(path)) return { text: `Not a spec: ${asked} (expected todo/<code>/SPEC-<code>.md or todo/<slug>/SPEC.md).` }
269 await update($, tracked, () => ({ ...IDLE, isArmed: true }))
270 await update($, decisions, () => [])
271 await update($, nav, () => HOME)
272 await reload($, path)
273 if ((await read($, tracked)).doc === null) return { text: `Spec not found: ${asked}.` }
274 }
275 await openPane($)
276 return { text: 'Spec pane opened.' }
277 })
278
279 on('prompt.submit', async ($, e, next) => {
280 const topic = promptTopic(e.text)
281 if (topic !== undefined) await arm($, topic)
282 return next(e)
283 }).catch(($, e, next) => next(e))
284
285 on('tool.call', async ($, e, next) => {
286 if (e.agentId !== undefined) return next(e)
287 const input = e as unknown as Record<string, unknown>
288
289 if (e.tool === 'Skill') {
290 if (isSpecSkill(input.skill)) {
291 const args = typeof input.args === 'string' && input.args.trim() !== '' ? input.args.trim() : null
292 const state = await read($, tracked)
293 if (!state.isArmed || state.path !== null || state.topic !== args) await arm($, args)
294 }
295 return next(e)
296 }
297
298 if (e.tool === 'AskUserQuestion') {
299 if (!(await read($, tracked)).isArmed) return next(e)
300 const asked = askedOf(e.tool_use_id ?? `q${await $.clock.now()}`, input.questions)
301 await update($, decisions, list => [...list, ...asked])
302 const answered = await next(e)
303 const done = withAnswers(asked, 'deny' in answered && answered.deny !== undefined ? {} : answered.result)
304 await update($, decisions, list => list.map(one => done.find(other => other.id === one.id) ?? one))
305 return answered
306 }
307
308 const path = input.file_path
309 const isEdit = EDITING_TOOLS.includes(e.tool)
310 if (typeof path !== 'string' || !isSpecPath(path) || (!isEdit && e.tool !== 'Read')) return next(e)
311 const state = await read($, tracked)
312 if (e.tool === 'Read' && (!state.isArmed || state.path !== null)) return next(e)
313 const answered = await next(e)
314 if (!state.isArmed) await openPane($)
315 await reload($, path)
316 return answered
317 }).catch(($, e, next) => next(e))
318
319 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
320 const { Box, Button, Text } = $.ui.resolve(e)
321 const state = await read($, tracked)
322 const asked = await read($, decisions)
323 const where: Navigation = { ...HOME, ...(await read($, nav)) }
324 const at = await read($, now)
325 const doc = state.doc
326 const columns = e.props.bodyColumns
327 const slot = (kind: Kind, id: string) => `${kind}:${id}`
328 const isExpanded = (kind: Kind, id: string) => where.expanded.includes(slot(kind, id))
329 const expand = (kind: Kind, id: string) => () =>
330 void update($, nav, current => {
331 const expanded = current.expanded ?? []
332 const one = slot(kind, id)
333 return { ...HOME, ...current, expanded: expanded.includes(one) ? expanded.filter(other => other !== one) : [...expanded, one] }
334 })
335 const foldAll = () => void update($, nav, current => ({ ...HOME, ...current, expanded: [] }))
336 const toggle = (group: string) => () =>
337 void update($, nav, current => ({
338 ...current,
339 collapsed: current.collapsed.includes(group) ? current.collapsed.filter(one => one !== group) : [...current.collapsed, group],
340 }))
341 const isOpen = (group: string) => !where.collapsed.includes(group)
342
343 const close = <Button key="close" label="✕" plain dimColor onPress={() => $.ui.close({ id: PANE })} />
344 const header = (title: string, trail?: string) => (
345 <Box key="header" height={1}>
346 <Box flexGrow={1} flexShrink={1}>
347 <Text wrap="truncate-end">
348 <Text color="claude">✻ </Text>
349 {trail !== undefined && <Text color="inactive">{trail} › </Text>}
350 <Text bold>{title}</Text>
351 </Text>
352 </Box>
353 {close}
354 </Box>
355 )
356 const paragraph = (key: string, text: string, color?: string) =>
357 text
358 .split('\n')
359 .filter((line, index, all) => line.trim() !== '' || (index > 0 && all[index - 1]?.trim() !== ''))
360 .map((line, index) => (
361 <Text key={`${key}-${index}`} color={color} wrap="wrap">
362 {plain(line) || ' '}
363 </Text>
364 ))
365
366 const detail = (kind: Kind, id: string): JSX.Element[] | null => {
367 if (kind === 'decision') {
368 const decision = asked.find(one => one.id === id)
369 if (decision === undefined) return null
370 return [
371 <Text key="d-question" bold wrap="wrap">
372 {decision.question}
373 </Text>,
374 ...decision.options.map((option, index) => {
375 const isChosen = decision.answer?.split(', ').includes(option) ?? false
376 return (
377 <Text key={`d-option-${index}`} wrap="wrap" color={isChosen ? 'success' : 'inactive'}>
378 {isChosen ? ' ✓ ' : ' '}
379 {option}
380 {option === decision.recommended ? ' ★' : ''}
381 </Text>
382 )
383 }),
384 <Box key="d-answer" marginTop={1}>
385 <Text wrap="wrap" color={decision.isPending ? 'claude' : isOffRecommendation(decision) ? 'warning' : undefined}>
386 {decision.isPending ? 'Waiting for the answer…' : `Answer: ${decision.answer ?? '—'}`}
387 </Text>
388 </Box>,
389 ...(decision.note === null ? [] : [<Text key="d-note" color="inactive" wrap="wrap">{`Note: ${decision.note}`}</Text>]),
390 ]
391 }
392 if (doc === null) return null
393 if (kind === 'openQuestion') {
394 const question = doc.openQuestions.find(one => one.id === id)
395 if (question === undefined) return null
396 return [
397 <Text key="q-severity" color={severityTone(question.severity)}>
398 {question.severity}
399 </Text>,
400 <Box key="q-impact-title" marginTop={1}>
401 <Text bold>Impact</Text>
402 </Box>,
403 ...paragraph('q-impact', question.impact),
404 <Box key="q-options-title" marginTop={1}>
405 <Text bold>Options</Text>
406 </Box>,
407 ...paragraph('q-options', question.options),
408 ]
409 }
410 if (kind === 'section') {
411 const section = doc.sections.find(one => one.number === id)
412 return section === undefined ? null : paragraph('s', section.body)
413 }
414 const item = (kind === 'useCase' ? doc.useCases : doc.rules).find(one => one.id === id)
415 return item === undefined ? null : paragraph('i', item.body)
416 }
417
418 if (!state.isArmed && doc === null) {
419 return (
420 <Box flexDirection="column" marginTop={1}>
421 {header(TITLE)}
422 <Text color="inactive" wrap="truncate-end">
423 {' ⎿ '}No /business-spec in this session yet.
424 </Text>
425 </Box>
426 )
427 }
428
429 const group = (key: (typeof GROUPS)[number], title: string, count: string, rows: JSX.Element[], tone?: string) => (
430 <Box key={`group-${key}`} flexDirection="column" marginTop={1}>
431 <Box height={1}>
432 <Button key={`toggle-${key}`} plain dimColor onPress={toggle(key)}>
433 {isOpen(key) ? '▾' : '▸'}
434 </Button>
435 <Text bold> {title}</Text>
436 <Text color={tone ?? 'inactive'}> {count}</Text>
437 </Box>
438 {isOpen(key) && rows}
439 </Box>
440 )
441 const unfolded = (kind: Kind, id: string): JSX.Element[] => {
442 if (!isExpanded(kind, id)) return []
443 const body = detail(kind, id)
444 return body === null
445 ? []
446 : [
447 <Box key={`detail-${kind}-${id}`} flexDirection="column" marginLeft={4} marginBottom={1}>
448 {body}
449 </Box>,
450 ]
451 }
452 const entry = (kind: Kind, id: string, children: JSX.Element[]) => [
453 <Box key={`row-${kind}-${id}`} height={1}>
454 <Text color="inactive">{isExpanded(kind, id) ? '▾ ' : ' '}</Text>
455 <Button key={`open-${kind}-${id}`} plain onPress={expand(kind, id)}>
456 {children}
457 </Button>
458 </Box>,
459 ...unfolded(kind, id),
460 ]
461
462 const pending = asked.filter(one => one.isPending).length
463 const decisionRows = asked.flatMap(decision =>
464 entry('decision', decision.id, [
465 <Text key="mark" color={decision.isPending ? 'claude' : isOffRecommendation(decision) ? 'warning' : 'success'}>
466 {decision.isPending ? '? ' : isOffRecommendation(decision) ? '≠ ' : '✓ '}
467 </Text>,
468 <Text key="text" wrap="truncate-end">
469 {fit(
470 `${decision.header !== '' ? `${decision.header} · ` : ''}${decision.isPending ? decision.question : (decision.answer ?? '—')}`,
471 columns - 4,
472 )}
473 </Text>,
474 ]),
475 )
476
477 const openQuestions = doc?.openQuestions ?? []
478 const blocking = blockingCount(openQuestions)
479 const questionRows = openQuestions.flatMap(question => [
480 <Box key={`row-openQuestion-${question.id}`}>
481 <Text color="inactive">{isExpanded('openQuestion', question.id) ? '▾ ' : ' '}</Text>
482 <Button key={`open-openQuestion-${question.id}`} plain onPress={expand('openQuestion', question.id)}>
483 <Text color={severityTone(question.severity)}>● {question.id}</Text>
484 </Button>
485 <Text> </Text>
486 <Box flexShrink={1} flexGrow={1}>
487 <Text key={`question-${question.id}`} wrap="wrap">
488 {plain(question.question)}
489 </Text>
490 </Box>
491 </Box>,
492 ...unfolded('openQuestion', question.id),
493 ])
494 const itemRows = (kind: Kind, items: SpecItem[]) =>
495 items.flatMap(item =>
496 entry(kind, item.id, [
497 <Text key="id" color="inactive">
498 {item.id}{' '}
499 </Text>,
500 <Text key="name" wrap="truncate-end">
501 {fit(item.name, columns - 3 - item.id.length)}
502 </Text>,
503 ]),
504 )
505 const sectionRows = (doc?.sections ?? []).flatMap(section =>
506 entry('section', section.number, [
507 <Text key="text" wrap="truncate-end">
508 {fit(`${section.number}. ${section.title}`, columns - 2)}
509 </Text>,
510 ]),
511 )
512
513 const location = state.path === null ? 'waiting for the SPEC file…' : relativePath(state.path, root)
514 const freshness = state.readAt === null || at === 0 ? '' : ` · read ${ago(at - state.readAt)} ago`
515
516 return (
517 <Box flexDirection="column" marginTop={1}>
518 {header(doc?.title || state.topic || TITLE, doc?.title ? TITLE : undefined)}
519 <Text color="inactive" wrap="truncate-start">
520 {' ⎿ '}
521 {location}
522 {freshness}
523 </Text>
524 {where.expanded.length > 0 && (
525 <Box key="fold" height={1}>
526 <Text>{' '}</Text>
527 <Button key="fold-all" hotkey="b" plain dimColor onPress={foldAll}>
528 <Text>{`fold all (${where.expanded.length} open)`}</Text>
529 </Button>
530 </Box>
531 )}
532 {doc !== null && doc.summary !== '' && (
533 <Box key="summary" marginTop={1}>
534 <Text color="inactive" wrap="wrap">
535 {plain(doc.summary)}
536 </Text>
537 </Box>
538 )}
539 {asked.length > 0 &&
540 group('decisions', 'Decisions', `${asked.length}${pending > 0 ? ` · ${pending} pending` : ''}`, decisionRows, pending > 0 ? 'claude' : undefined)}
541 {doc !== null &&
542 group(
543 'openQuestions',
544 'Open questions',
545 openQuestions.length === 0 ? 'none' : `${openQuestions.length}${blocking > 0 ? ` · ${blocking} blocking` : ''}`,
546 questionRows,
547 blocking > 0 ? 'error' : openQuestions.length > 0 ? 'warning' : 'success',
548 )}
549 {doc !== null && doc.useCases.length > 0 && group('useCases', 'Use cases', `${doc.useCases.length}`, itemRows('useCase', doc.useCases))}
550 {doc !== null && doc.rules.length > 0 && group('rules', 'Business rules', `${doc.rules.length}`, itemRows('rule', doc.rules))}
551 {doc !== null && doc.sections.length > 0 && group('sections', 'Sections', `${doc.sections.length}`, sectionRows)}
552 </Box>
553 )
554 })
555}
556types/index.d.ts 45 lines1export type Decision = {
2 id: string
3 header: string
4 question: string
5 options: string[]
6 recommended: string | null
7 answer: string | null
8 note: string | null
9 isPending: boolean
10}
11
12export type SpecItem = { id: string; name: string; tag: string | null; body: string }
13
14export type SpecSection = { number: string; title: string; body: string }
15
16export type OpenQuestion = { id: string; severity: string; question: string; impact: string; options: string }
17
18export type SpecDoc = {
19 title: string
20 summary: string
21 sections: SpecSection[]
22 useCases: SpecItem[]
23 rules: SpecItem[]
24 openQuestions: OpenQuestion[]
25}
26
27export type Tracked = {
28 isArmed: boolean
29 topic: string | null
30 path: string | null
31 mtimeMs: number
32 doc: SpecDoc | null
33 readAt: number | null
34}
35
36export type Kind = 'useCase' | 'rule' | 'section' | 'openQuestion' | 'decision'
37
38export type Navigation = { expanded: string[]; collapsed: string[] }
39
40declare module 'claude-code' {
41 interface PluginState {
42 'spec-pane': { tracked: Tracked; decisions: Decision[]; nav: Navigation; now: number }
43 }
44}
45