SLOPSHOPPER

spec-pane

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

newpaneguardcommandprompttimer
★ 6v1.0.0MITupdated 2026-10-10pierrebelin/claude-code-toolkit/mods/spec-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spec-pane
│ ┃ Spec ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ✻ Spec ✕ ⏺ Read(src/auth.ts) │ ┃ ⎿ No /business-spec in this session yet. ⎿ 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 │ │ › /spec-pane │ ⎿ spec-pane: Spec pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Spec
✻ Spec ✕ ⎿ No /business-spec in this session yet.
README

Claude Code Toolkit

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.

Quick start

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.

How it works

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
  1. /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.
  2. /cctoolkit:plan-implementation — turns the spec into a technical plan, split into small batches.
  3. /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.

Watch a batch run

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.

run-lot-pane during a batch

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.

Opinionated by design

This kit encodes one way of working. It will:

  • write the test before the code, every time;
  • keep changes surgical — no drive-by refactors;
  • tie every business rule to the tests that cover it;
  • never commit — you decide when.

If your team works differently, half of it will feel like noise. Full list: skills/README.md.

Learn more

Install, update, dependenciesINSTALL.md
A feature from spec to reportexamples/
Every skill and agentskills/README.md
Autonomous runs, finishing a batch by handdocs/TOOLING.md
Layouts and configurationpresets/README.md
Other repos configuring a coding agentRESOURCES.md

Licence

MIT. Take what you want, closed-source projects included.

Source 2 files
hooks/register.tsx 556 lines
1import { 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}
556
types/index.d.ts 45 lines
1export 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