SLOPSHOPPER

knowledge-pane

Side pane browsing the business knowledge base: contexts, use cases and their rules, decisions, ADRs, history, a staged merge or sync; and following a…

panebandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · knowledge-pane
│ ┃ Knowledge ✕ › 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 │ ┃ b: back h: home ⏺ 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 │ │ › /knowledge-pane │ ⎿ knowledge-pane: Knowledge pane opened — no knowledge base under │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Knowledge
✻ Spec ✕ No /business-spec in this session yet. b: back h: home
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. What the product does is kept in a business knowledge base beside the code, so every new spec starts from it.

.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-plan or /run-lot"]
    R -->|"code, tests, report"| C(["you review and commit"])
  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. Build — each batch on its own: tests first, then code, then an audit. Two ways to go through the plan: all at once, or one batch at a time.

The whole plan, or batch by batch

Whole planBatch by batch
Command/cctoolkit:run-plan <plan>/cctoolkit:run-lot <sheet>
Runsevery batch, in dependency orderone batch
You step inonce, at the endafter each batch: review, /clear, next
Stopsa batch that cannot pass is set aside, only its dependents wait; batches set aside for one shared cause stop the planthe batch comes back DONE, or with what needs your eyes
Fitsa plan you trust, a run over lunch or overnighta new plan, a tricky batch, a team that reviews every commit

Whole plan runs the batches in the order the plan allows. Nothing waits for you: an ambiguity is decided by the agent that meets it, and what was decided without you opens the run report, to read after the plan. A batch that still cannot pass is set aside with its partial work saved, the next run picking it up where it stopped, and the batches that do not depend on it carry on. Each batch that passes gets its own commit; you review them together at the end.

Batch by batch keeps you in the loop: each batch ends on a report and its own commit, you read it and start the next one with a clean context.

On a new plan, or a batch you are unsure of, go batch by batch until the first ones come back clean.

Inside run-lot, each behaviour of the batch is one loop:

flowchart LR
    I["run-lot"] --> R
    I -->|"compact contract:<br/>RM/CU, scenario, signatures,<br/>invariants, expected cost"| A["tdd-cycle subagent"]
    A --> R["RED<br/>writes the red test"]
    R --> K["checkpoint<br/>the hook checks the disk:<br/>red, on an assertion"]
    K --> G["GREEN + REFACTOR<br/>writes the production code"]
    G -->|"green + cost stated<br/>or BLOCKED"| I
    I -.->|"next behaviour"| A
    I ==>|"whole batch green"| V["audit<br/>ddd-tdd-auditor"]

The orchestrator writes neither tests nor code: it splits the work, checks the reports and settles the design. One agent per behaviour writes the failing test, passes a checkpoint read off the disk, then makes it pass; a test it changes after that checkpoint is recorded with its assertions and judged by the audit. Another agent audits.

A batch that stops short: fix the cause and run /cctoolkit:run-lot on the same sheet again, it resumes from the cycles already done. /cctoolkit:implement-tdd launches the chain from a plan path, /cctoolkit:verify-ddd-tdd audits a batch on demand.

Want to see the files? examples/ walks one small feature from spec to report.

A business knowledge base beside the code

What the product does and why lives in the repo, under docs/business/ by default: one folder per bounded context, one file per use case with its rules, the business decisions, the ADRs, and a history of every change. Each use case, rule and decision has a global id (CAT-RM-003) that the specs and plans cite.

flowchart LR
    K[("knowledge base")] -->|read by levels| S["/business-spec<br/>writes a delta"]
    S --> P["plan, build"]
    P -->|"plan delivered:<br/>merge staged"| M["/knowledge-merge<br/>you confirm"]
    M --> K
    H["code changed<br/>outside the kit"] -->|"/knowledge-sync"| K
  • /cctoolkit:knowledge-sync builds the base from your code the first time, then catches up with what changed outside the kit. Nothing is written until you confirm.
  • /cctoolkit:business-spec reads the base first and writes the spec as a delta: what the feature adds, changes or retires.
  • When a whole plan is delivered, run-plan stages the merge; /cctoolkit:knowledge-merge shows the diff and writes it on your word.
  • /cctoolkit:ask answers how the product behaves, or why, from the base, each fact cited by its id.

The base is plain Markdown, so you can review and commit it like code. The worked example is under examples/docs/business/.

Watch it run

A batch takes tens of minutes and a dozen agents, a whole plan several times that. The run-lot-pane mod shows it live inside Claude Code. On a plan, it opens on the plan: each batch with its state, time and tokens, and the supervisor checking between batches.

run-lot-pane following a whole plan

Open a batch and it shows each phase, which agent is working, the tests going red then green, the tokens spent, and the final verdict. The pane also steers the run: review what it decided without you, leave a note for a batch's agents (keys).

run-lot-pane during a batch

claude plugin install run-lot-pane@cctoolkit --scope project

Two more mods — a browser of the knowledge base that spends no model context and shows the spec live 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;
  • one commit per DONE batch, nothing else — the workflow commits the batch files only, your earlier changes stay out; never a push.

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/
The knowledge base's formatknowledge-format.md
Every skill and agentskills/README.md
Layouts and configurationpresets/README.md

Licence

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

Source 2 files
hooks/register.tsx 2144 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type {
5  Base,
6  ContextRow,
7  Decision,
8  HistoryRow,
9  IndexState,
10  KObject,
11  Kind,
12  Level,
13  Merge,
14  MergeObject,
15  Navigation,
16  OpenQuestion,
17  SpecDoc,
18  SpecItem,
19  SpecKind,
20  SpecSection,
21  Sync,
22  Tracked,
23  View,
24} from '../types'
25
26const PANE = 'knowledge-pane'
27const TITLE = 'Knowledge'
28// Wider than any terminal: the dock opens at its ceiling, 45 % of the terminal's columns.
29const PANE_COLUMNS = 1000
30const SPEC_TITLE = 'Spec'
31const TICK_MS = 3_000
32const DEFAULT_ROOT = 'docs/business'
33const CONFIG = '.claude/kit.config.json'
34const INDEX_JSONL = '.index/knowledge.jsonl'
35const INDEX_FILES = '.index/files.json'
36const SKIPPED = ['INDEX.md', 'HISTORY.md']
37const KINDS: Kind[] = ['CU', 'RM', 'D', 'ADR']
38const LEVELS: Level[] = ['high', 'medium', 'low']
39const KIND_NAMES: Record<Kind, string> = { CU: 'use case', RM: 'rule', D: 'decision', ADR: 'ADR' }
40const HISTORY_SHOWN = 5
41const RESULTS_SHOWN = 40
42const ID_PATTERN = /^(?:[A-Z][A-Z0-9]{1,5}-(?:CU|RM|D)-[\w?]+|ADR-[\w?]+)$/
43const INDEX_COMMAND = 'cctoolkit knowledge-index'
44const SKILL = 'business-spec'
45const SPEC_PATTERN = /(?:^|[\\/])todo[\\/][^\\/]+[\\/]SPEC(?:-[^\\/]+)?\.md$/
46// The open questions: the section whose heading carries the anchor, else, in a spec written before
47// the anchors, the title the `/plan-implementation` gate reads (`skills/plan-implementation/SKILL.md`).
48const OPEN_QUESTIONS_ANCHOR = 'open-questions'
49const OPEN_QUESTIONS_TITLE = /^12\. (?:Open questions|Questions ouvertes)/
50// A spec's use cases and rules: `CU-01`, or a knowledge-base id `CAT-CU-01`.
51const SPEC_ID = /^(?:[A-Z][A-Z0-9]{1,5}-(?:CU|RM)-\d+|(?:CU|RM)-[\w.]+)$/
52const isRule = (id: string): boolean => /(?:^|-)RM-/.test(id)
53const isUseCase = (id: string): boolean => /(?:^|-)CU-/.test(id)
54const SEVERITY_PATTERN = /\*\*(?:s[ée]v[ée]rit[ée]|severity)\*\*\s*:?\s*([^·\n]+)/i
55const RECOMMENDED_PATTERN = /\((?:recommand[ée]e?|recommended)\)/i
56const EDITING_TOOLS = ['Edit', 'Write', 'MultiEdit']
57const BLOCKING = ['bloquant', 'blocking']
58const MAJOR = ['majeur', 'major', 'warning']
59
60// The terminal's grid: the view's lines at two cells, a block's rows one level in, under its title.
61const INDENT = '  '
62const ROW = 4
63const rule = (cells: number): string => '─'.repeat(Math.max(0, cells))
64
65const HOME: Navigation = { stack: [{ kind: 'home' }], query: '', seed: '', expanded: [], collapsed: [] }
66const IDLE: Tracked = { isArmed: false, topic: null, path: null, mtimeMs: 0, doc: null, readAt: null }
67
68// The search field starts from the query each time the search comes back on top.
69const seeded = (next: Navigation): Navigation => (next.stack[next.stack.length - 1]?.kind === 'search' ? { ...next, seed: next.query } : next)
70
71export const emptyBase = (repo: string, root: string): Base => ({
72  repo,
73  root,
74  isDemo: false,
75  isPresent: false,
76  index: 'missing',
77  staleFiles: [],
78  contexts: [],
79  adrCount: null,
80  objects: [],
81  overviews: {},
82  history: [],
83  merges: [],
84  syncs: [],
85  readAt: null,
86  signature: '',
87})
88
89const base = atom({ plugin: 'knowledge-pane', key: 'base' } as const, emptyBase('', DEFAULT_ROOT))
90const nav = atom({ plugin: 'knowledge-pane', key: 'nav' } as const, HOME)
91const full = atom({ plugin: 'knowledge-pane', key: 'full' } as const, {} as Record<string, string>)
92const now = atom({ plugin: 'knowledge-pane', key: 'now' } as const, 0)
93const isShown = atom({ plugin: 'knowledge-pane', key: 'isShown' } as const, false)
94const tracked = atom({ plugin: 'knowledge-pane', key: 'tracked' } as const, IDLE)
95const decisions = atom({ plugin: 'knowledge-pane', key: 'decisions' } as const, [] as Decision[])
96
97export const fold = (text: string): string =>
98  text
99    .normalize('NFD')
100    .replace(/[̀-ͯ]/g, '')
101    .toLowerCase()
102    .trim()
103
104export const plain = (text: string): string => text.replace(/\*\*/g, '').replace(/`/g, '').replace(/<!--.*?-->/g, '').trimEnd()
105
106export const fit = (text: string, width: number): string => {
107  const flat = text.replace(/\s+/g, ' ').trim()
108  const room = Math.max(1, width)
109  return flat.length > room ? `${flat.slice(0, room - 1)}…` : flat
110}
111
112export const ago = (ms: number): string => {
113  const seconds = Math.max(0, Math.round(ms / 1000))
114  if (seconds < 60) return `${seconds}s`
115  const minutes = Math.floor(seconds / 60)
116  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
117}
118
119const counted = (count: number, one: string, many = `${one}s`): string => `${count} ${count === 1 ? one : many}`
120
121const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : [])
122
123const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
124
125const cellsOf = (line: string): string[] =>
126  line
127    .trim()
128    .replace(/^\|/, '')
129    .replace(/\|$/, '')
130    .split('|')
131    .map(cell => cell.trim())
132
133const isSeparator = (line: string): boolean => /^\|?\s*:?-{3,}/.test(line.trim())
134
135// An object heading as `lib/knowledge.py` (`HEADING`) reads it: `<#…> <id> — <title>`, the em dash only.
136// The anchor ending it (`<!-- kit:new -->`) is not part of the title.
137const HEADING = /^(#{1,6})\s+(\S+)\s+—\s+(.*?)\s*(?:<!--.*-->)?\s*$/
138
139export const headingOf = (line: string): { level: number; id: string; title: string } | null => {
140  const found = HEADING.exec(line)
141  return found === null ? null : { level: (found[1] ?? '').length, id: found[2] ?? '', title: (found[3] ?? '').trim() }
142}
143
144// A `## ` heading: its number when it has one, its title, the `<!-- kit:<name> -->` anchor ending it.
145const SECTION = /^##\s+(?:(\d+)\.\s*)?(.*?)\s*(?:<!--\s*kit:([\w-]+)\s*-->|<!--.*-->)?\s*$/
146
147export const sectionOf = (line: string): { number: string; title: string; anchor: string | undefined } | null => {
148  const found = SECTION.exec(line)
149  return found === null ? null : { number: found[1] ?? '', title: (found[2] ?? '').trim(), anchor: found[3] }
150}
151
152export const sectionLabel = (section: { number: string; title: string }): string => (section.number ? `${section.number}. ${section.title}` : section.title)
153
154// Fields of `.index/knowledge.jsonl`, a frozen literal of the kit (`docs/FROZEN-LITERALS.md`).
155export const parseIndex = (jsonl: string): KObject[] =>
156  jsonl.split('\n').flatMap(line => {
157    if (line.trim() === '') return []
158    try {
159      const row = JSON.parse(line) as Record<string, unknown>
160      const kind = row.kind as Kind
161      if (typeof row.id !== 'string' || !KINDS.includes(kind)) return []
162      const level = LEVELS.includes(row.level as Level) ? (row.level as Level) : null
163      return [
164        {
165          id: row.id,
166          kind,
167          context: textOf(row.context),
168          title: textOf(row.title),
169          text: textOf(row.text),
170          level: row.status === 'observed' ? (level ?? 'medium') : null,
171          path: textOf(row.path),
172          line: typeof row.line === 'number' ? row.line : 0,
173          end: typeof row.end === 'number' ? row.end : 0,
174          cites: strings(row.cites),
175          usecases: strings(row.usecases),
176          handlers: strings(row.handlers),
177          citedBy: strings(row.cited_by),
178          contexts: strings(row.contexts),
179        },
180      ]
181    } catch {
182      return []
183    }
184  })
185
186// The table under a `<!-- kit:cols … -->` anchor, header row dropped, read by position.
187export const tableAfter = (text: string, cols: string): string[][] => {
188  const lines = text.split('\n')
189  const at = lines.findIndex(line => line.includes(`<!-- kit:cols ${cols} -->`))
190  if (at < 0) return []
191  const rows: string[][] = []
192  for (const line of lines.slice(at + 1)) {
193    if (!line.trim().startsWith('|')) {
194      if (rows.length > 0 || line.trim() !== '') break
195      continue
196    }
197    if (!isSeparator(line)) rows.push(cellsOf(line))
198  }
199  return rows.slice(1)
200}
201
202const numberOf = (cell: string | undefined): number => Number.parseInt(cell ?? '', 10) || 0
203
204export const parseIndexMd = (text: string): { contexts: ContextRow[]; adrCount: number | null } => ({
205  contexts: tableAfter(text, 'context,prefix,usecases,rules,decisions,summary').map(cells => ({
206    name: (/^\[([^\]]+)\]/.exec(cells[0] ?? '')?.[1] ?? cells[0] ?? '').trim(),
207    prefix: cells[1] ?? '',
208    usecases: numberOf(cells[2]),
209    rules: numberOf(cells[3]),
210    decisions: numberOf(cells[4]),
211    summary: cells[5] ?? '',
212  })),
213  adrCount: (() => {
214    const found = /^ADR: (\d+)\.\s*$/m.exec(text)
215    return found === null ? null : Number(found[1])
216  })(),
217})
218
219export const parseHistory = (context: string, text: string): HistoryRow[] =>
220  tableAfter(text, 'date,feature,delta').map(cells => ({
221    context,
222    date: cells[0] ?? '',
223    feature: cells[1] ?? '',
224    delta: (cells[2] ?? '').split(/\s+/).filter(token => token !== '' && token !== '—'),
225  }))
226
227export const deltaId = (token: string): string => token.replace(/^[+~-]/, '')
228
229export const historyOf = (rows: HistoryRow[], id: string): HistoryRow[] => rows.filter(row => row.delta.some(token => deltaId(token) === id))
230
231// An object's own lines, heading excluded: from its heading to the next heading of the same or a higher level.
232export const sliceObject = (text: string, id: string): string | null => {
233  const lines = text.split('\n')
234  const at = lines.findIndex(line => headingOf(line)?.id === id)
235  if (at < 0) return null
236  const level = headingOf(lines[at] ?? '')?.level ?? 1
237  const rest = lines.slice(at + 1)
238  const stop = rest.findIndex(line => {
239    const found = /^(#{1,6})\s/.exec(line)
240    return found !== null && (found[1] ?? '').length <= level
241  })
242  return (stop < 0 ? rest : rest.slice(0, stop)).join('\n').trim()
243}
244
245// `[mtime_ns, size]` per base file in `.index/files.json`: a file whose stamp moved, or one not there, is not indexed yet.
246export const staleOf = (files: { rel: string; size: number; mtimeMs: number }[], cache: unknown): string[] => {
247  // Keys written on Windows by an older kit carry backslashes: read with `/`, as the walk names them.
248  const known = Object.fromEntries(
249    Object.entries(((cache ?? {}) as { files?: Record<string, { stamp?: unknown }> }).files ?? {}).map(([rel, one]) => [rel.replace(/\\/g, '/'), one]),
250  )
251  const seen = new Set(files.map(one => one.rel))
252  const moved = files
253    .filter(one => {
254      const stamp = known[one.rel]?.stamp
255      if (!Array.isArray(stamp)) return true
256      const [ns, size] = stamp as number[]
257      return size !== one.size || Math.abs((ns ?? 0) / 1e6 - one.mtimeMs) >= 1
258    })
259    .map(one => one.rel)
260  return [...moved, ...Object.keys(known).filter(rel => !seen.has(rel))].sort()
261}
262
263export type AskTarget = { ids: string[] } | { handler: string }
264
265export const askTarget = (command: string): AskTarget | null => {
266  const asked = /\bcctoolkit\s+ask\b([^|;&\n]*)/.exec(command)
267  if (asked === null) return null
268  const args = (asked[1] ?? '').replace(/["']/g, ' ')
269  const ids = /--id(?:=|\s+)([A-Za-z0-9?,-]+)/.exec(args)
270  if (ids !== null) {
271    const list = (ids[1] ?? '').split(',').filter(one => one !== '')
272    return list.length > 0 ? { ids: list } : null
273  }
274  const handler = /--handler(?:=|\s+)([\w.]+)/.exec(args)
275  return handler === null ? null : { handler: handler[1] ?? '' }
276}
277
278export const handlerTarget = (objects: KObject[], handler: string): KObject | undefined =>
279  objects.find(one => one.kind === 'CU' && one.handlers.includes(handler)) ?? objects.find(one => one.handlers.includes(handler))
280
281export const search = (objects: KObject[], query: string): KObject[] => {
282  const words = fold(query).split(/\s+/).filter(word => word !== '')
283  if (words.length === 0) return []
284  const scored = objects.flatMap(one => {
285    const id = fold(one.id)
286    const haystack = `${id} ${fold(one.title)} ${one.handlers.map(fold).join(' ')}`
287    if (!words.every(word => haystack.includes(word))) return []
288    const first = words[0] ?? ''
289    const score = id === first ? 0 : id.includes(first) ? 1 : 2
290    return [{ one, score }]
291  })
292  return scored.sort((a, b) => a.score - b.score || a.one.id.localeCompare(b.one.id)).map(entry => entry.one)
293}
294
295export const isOwnRule = (rule: KObject, cu: KObject): boolean => rule.kind === 'RM' && rule.path === cu.path
296
297export const isSharedRule = (rule: KObject): boolean => rule.kind === 'RM' && /(?:^|[\\/])rules\.md$/.test(rule.path)
298
299export const contextParts = (objects: KObject[], name: string) => {
300  const mine = objects.filter(one => one.context === name)
301  const usecases = mine.filter(one => one.kind === 'CU').sort((a, b) => a.id.localeCompare(b.id))
302  return {
303    usecases: usecases.map(cu => ({ cu, rules: mine.filter(one => isOwnRule(one, cu)).sort((a, b) => a.id.localeCompare(b.id)) })),
304    shared: mine.filter(isSharedRule).sort((a, b) => a.id.localeCompare(b.id)),
305    decisions: mine.filter(one => one.kind === 'D').sort((a, b) => a.id.localeCompare(b.id)),
306    adrs: objects.filter(one => one.kind === 'ADR' && one.contexts.includes(name)).sort((a, b) => a.id.localeCompare(b.id)),
307  }
308}
309
310export type OverviewPart = { title: string; body: string }
311
312export const parseOverview = (text: string): { role: string; parts: OverviewPart[] } => {
313  const parts: OverviewPart[] = []
314  const role: string[] = []
315  let current: { title: string; lines: string[] } | null = null
316  let isTableHead = false
317  for (const line of text.split('\n')) {
318    if (/^#\s/.test(line)) continue
319    const heading = sectionOf(line)
320    if (heading !== null) {
321      if (current !== null) parts.push({ title: current.title, body: current.lines.join('\n').trim() })
322      current = { title: sectionLabel(heading), lines: [] }
323      continue
324    }
325    if (/^\s*<!--.*-->\s*$/.test(line)) {
326      isTableHead = line.includes('kit:cols')
327      continue
328    }
329    let shown = line
330    if (line.trim().startsWith('|')) {
331      if (isSeparator(line)) continue
332      if (isTableHead) {
333        isTableHead = false
334        continue
335      }
336      shown = cellsOf(line).join(' — ')
337    }
338    ;(current === null ? role : current.lines).push(shown)
339  }
340  if (current !== null) parts.push({ title: current.title, body: current.lines.join('\n').trim() })
341  return { role: role.join('\n').trim(), parts }
342}
343
344export const isSpecPath = (path: string): boolean => SPEC_PATTERN.test(path)
345
346// On Windows a tool's `file_path` comes as `C:\x\repo\todo\…`, the root maybe as `C:/x/repo`:
347// both are compared with `/`, the drive's case aside.
348const slashed = (path: string): string => path.replace(/\\/g, '/')
349const isDrive = (path: string): boolean => /^[A-Za-z]:/.test(path)
350export const isAbsolutePath = (path: string): boolean => /^(?:[A-Za-z]:)?[\\/]/.test(path)
351
352export const relativePath = (path: string, root: string | null): string => {
353  if (root === null) return path
354  const [file, base] = [slashed(path), slashed(root).replace(/\/$/, '')]
355  const cased = (one: string): string => (isDrive(base) ? one.toLowerCase() : one)
356  return cased(file).startsWith(`${cased(base)}/`) ? file.slice(base.length + 1) : path
357}
358
359export const severityTone = (severity: string | null): 'error' | 'warning' | 'inactive' => {
360  const folded = fold(severity ?? '')
361  if (BLOCKING.some(word => folded.startsWith(word))) return 'error'
362  if (MAJOR.some(word => folded.startsWith(word))) return 'warning'
363  return 'inactive'
364}
365
366// A table read by its header names, folded.
367export const parseTable = (body: string): Record<string, string>[] => {
368  const rows = body.split('\n').filter(line => line.trim().startsWith('|') && !isSeparator(line))
369  const [header, ...data] = rows
370  if (header === undefined) return []
371  const names = cellsOf(header).map(fold)
372  return data.map(row => Object.fromEntries(cellsOf(row).map((cell, index) => [names[index] ?? `${index}`, cell])))
373}
374
375const cellOf = (row: Record<string, string>, ...names: string[]): string =>
376  Object.entries(row).find(([name]) => names.some(one => name.startsWith(one)))?.[1] ?? ''
377
378export const parseOpenQuestions = (body: string): OpenQuestion[] =>
379  parseTable(body)
380    .map(row => ({
381      id: cellOf(row, '#'),
382      severity: cellOf(row, 'sev'),
383      question: cellOf(row, 'question'),
384      impact: cellOf(row, 'impact'),
385      options: cellOf(row, 'option'),
386    }))
387    .filter(question => question.question !== '')
388
389const trimBlank = (lines: string[]): string => lines.join('\n').trim()
390
391export const questionsSection = (sections: SpecSection[]): SpecSection | undefined =>
392  sections.find(one => one.anchor === OPEN_QUESTIONS_ANCHOR) ??
393  sections.find(one => one.anchor === undefined && OPEN_QUESTIONS_TITLE.test(`${one.number}. ${one.title}`))
394
395// A use case or a rule of a spec: an object heading of level 3 with a spec id.
396const specItemOf = (line: string) => {
397  const found = headingOf(line)
398  return found !== null && found.level === 3 && SPEC_ID.test(found.id) ? found : null
399}
400
401// A section without a number (`## Notes`) is keyed by its position, shown by its title alone.
402export const sectionKey = (section: SpecSection, index: number): string => section.number || `#${index + 1}`
403
404export const sectionCounts = (doc: SpecDoc): Record<string, number> => {
405  const questions = questionsSection(doc.sections)
406  return Object.fromEntries(
407    doc.sections.map((section, index) => [
408      sectionKey(section, index),
409      section === questions ? doc.openQuestions.length : section.body.split('\n').filter(line => specItemOf(line) !== null).length,
410    ]),
411  )
412}
413
414export const parseSpec = (text: string): SpecDoc => {
415  const lines = text.split('\n')
416  const title = (lines.find(line => /^#\s/.test(line)) ?? '').replace(/^#\s+/, '').trim()
417  const summary = lines
418    .filter(line => line.startsWith('>'))
419    .map(line => line.replace(/^>\s?/, '').trim())
420    .join(' ')
421    .trim()
422  const sections: SpecSection[] = []
423  const items: SpecItem[] = []
424  let section: { number: string; title: string; anchor: string | undefined; lines: string[] } | null = null
425  let item: { id: string; name: string; lines: string[] } | null = null
426  const closeItem = () => {
427    if (item === null) return
428    const body = trimBlank(item.lines)
429    const severity = isRule(item.id) ? (SEVERITY_PATTERN.exec(body)?.[1]?.trim() ?? null) : null
430    items.push({ id: item.id, name: item.name, tag: severity, body })
431    item = null
432  }
433  const closeSection = () => {
434    closeItem()
435    if (section !== null) sections.push({ number: section.number, title: section.title, ...(section.anchor === undefined ? {} : { anchor: section.anchor }), body: trimBlank(section.lines) })
436    section = null
437  }
438  for (const line of lines) {
439    if (/^##\s/.test(line)) {
440      closeSection()
441      const heading = sectionOf(line)
442      if (heading !== null) section = { ...heading, lines: [] }
443      continue
444    }
445    if (section === null) continue
446    section.lines.push(line)
447    const found = specItemOf(line)
448    if (found !== null) {
449      closeItem()
450      item = { id: found.id, name: found.title, lines: [] }
451    } else if (/^###\s/.test(line)) closeItem()
452    else if (item !== null) item.lines.push(line)
453  }
454  closeSection()
455  const questions = questionsSection(sections)
456  return {
457    title,
458    summary,
459    sections,
460    useCases: items.filter(one => isUseCase(one.id)),
461    rules: items.filter(one => isRule(one.id)),
462    openQuestions: questions === undefined ? [] : parseOpenQuestions(questions.body),
463  }
464}
465
466export const isSpecSkill = (skill: unknown): boolean =>
467  typeof skill === 'string' && (skill === SKILL || skill.endsWith(`:${SKILL}`))
468
469export const promptTopic = (text: string): string | null | undefined => {
470  const typed = /^\s*\/(?:[\w-]+:)?business-spec\b(.*)$/s.exec(text)
471  if (typed !== null) return (typed[1] ?? '').trim() || null
472  const expanded = /<command-name>\/?(?:[\w-]+:)?business-spec<\/command-name>/.test(text)
473  if (!expanded) return undefined
474  return /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1]?.trim() || null
475}
476
477type Asked = { question?: unknown; header?: unknown; options?: unknown }
478
479const labelsOf = (options: unknown): string[] =>
480  Array.isArray(options)
481    ? options.map(option => (option as { label?: unknown }).label).filter((label): label is string => typeof label === 'string')
482    : []
483
484export const recommendedOf = (options: string[]): string | null =>
485  options.find(option => RECOMMENDED_PATTERN.test(option)) ?? options[0] ?? null
486
487export const askedOf = (id: string, questions: unknown): Decision[] =>
488  (Array.isArray(questions) ? (questions as Asked[]) : [])
489    .filter(one => typeof one.question === 'string')
490    .map((one, index) => {
491      const options = labelsOf(one.options)
492      return {
493        id: `${id}-${index}`,
494        header: typeof one.header === 'string' ? one.header : '',
495        question: one.question as string,
496        options,
497        recommended: recommendedOf(options),
498        answer: null,
499        note: null,
500        isPending: true,
501      }
502    })
503
504type Answered = { answers?: unknown; annotations?: unknown; response?: unknown }
505
506export const withAnswers = (asked: Decision[], result: unknown): Decision[] => {
507  const answered = (result ?? {}) as Answered
508  const answers = (answered.answers ?? {}) as Record<string, unknown>
509  const annotations = (answered.annotations ?? {}) as Record<string, { notes?: unknown } | undefined>
510  const response = typeof answered.response === 'string' && answered.response.trim() !== '' ? answered.response.trim() : null
511  return asked.map(decision => {
512    const answer = answers[decision.question]
513    const notes = annotations[decision.question]?.notes
514    return {
515      ...decision,
516      answer: typeof answer === 'string' && answer !== '' ? answer : response,
517      note: typeof notes === 'string' && notes.trim() !== '' ? notes.trim() : null,
518      isPending: false,
519    }
520  })
521}
522
523export const isUnanswered = (decision: Decision): boolean => !decision.isPending && decision.answer === null
524
525export const isOffRecommendation = (decision: Decision): boolean =>
526  decision.answer !== null && decision.recommended !== null && !decision.answer.split(', ').includes(decision.recommended)
527
528// The desktop sets its blocks apart by shade, not by line: a translucent grey surface reads on a light or a dark
529// theme alike; what waits on the person takes a faint wash of its tone: a merge or sync staged and the index
530// behind in amber, a blocking open question in red, a decision waiting for its answer in orange.
531const SURFACE = '#80808014'
532const WARNING_WASH = '#d99a1e24'
533const WASH: Record<string, string> = { warning: WARNING_WASH, error: '#d6454524', claude: '#d977571f' }
534// A block's padding, in cells; a washed row reaches one cell into it on each side, so its text keeps the column
535// of the rows around it while its tone spans the whole width.
536const BLOCK = { paddingX: 2, paddingY: 'sm' } as const
537const BLEED = { marginX: -1, paddingX: 1 } as const
538
539const STATUS = { good: '#0ca30c', warning: '#fab219', critical: '#d03b3b', specified: '#3b82c4' } as const
540const CELL_INK = '#0b0b0b'
541const CELL = 18
542const CELL_GAP = 3
543const SVG_STYLE =
544  '<style>text{font:11px system-ui,-apple-system,"Segoe UI",sans-serif}.ink{fill:#52514e}.head{fill:#52514e;font-weight:600}.muted{fill:#52514e;opacity:.7}' +
545  '.empty{fill:#52514e;fill-opacity:.08}.rule{stroke:#52514e;stroke-opacity:.35}.pending{fill:none;stroke:#52514e;stroke-dasharray:3 2}.none{fill:#c3c2b7}' +
546  '.glyph{font-size:10px;font-weight:600}.hit{fill:#52514e;fill-opacity:0}g.mark:hover .hit{fill-opacity:.14}' +
547  '@media (prefers-color-scheme: dark){.ink,.head,.muted{fill:#c3c2b7}.empty{fill:#c3c2b7}.rule,.pending{stroke:#c3c2b7}.none{fill:#52514e}.hit{fill:#c3c2b7}}</style>'
548
549export const escapeXml = (text: string): string =>
550  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
551
552export const svgHeight = (source: string): number => Number(/<svg[^>]*\sheight="(\d+)"/.exec(source)?.[1] ?? 0)
553
554export const svgWidth = (source: string): number => Number(/<svg[^>]*\swidth="(\d+)"/.exec(source)?.[1] ?? 0)
555
556const px = (value: number): string => `${Math.round(value * 10) / 10}`
557
558const svgOf = (width: number, height: number, body: string): string =>
559  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${SVG_STYLE}${body}</svg>`
560
561type Paint = string | 'pending' | 'none'
562
563// One square with its glyph and its tooltip, a hit target wider than it.
564const cellSvg = (x: number, y: number, fill: Paint, glyph: string, title: string[]): string => {
565  const square =
566    fill === 'pending'
567      ? `<rect class="pending" x="${px(x + 0.5)}" y="${px(y + 0.5)}" width="${CELL - 1}" height="${CELL - 1}" rx="4"/>`
568      : fill === 'none'
569        ? `<rect class="none" x="${x}" y="${y}" width="${CELL}" height="${CELL}" rx="4"/>`
570        : `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}" rx="4" fill="${fill}"/>`
571  const isInked = fill !== 'pending' && fill !== 'none'
572  const text =
573    glyph === ''
574      ? ''
575      : `<text class="glyph${isInked ? '' : ' ink'}" x="${px(x + CELL / 2)}" y="${px(y + CELL / 2 + 3.5)}" text-anchor="middle"${isInked ? ` fill="${CELL_INK}"` : ''}>${escapeXml(glyph)}</text>`
576  return `<g class="mark"><title>${title.map(escapeXml).join('&#10;')}</title><rect class="hit" x="${x - 1.5}" y="${y - 1.5}" width="${CELL + 3}" height="${CELL + 3}" rx="5"/>${square}${text}</g>`
577}
578
579const swatchSvg = (x: number, y: number, fill: Paint): string =>
580  fill === 'pending'
581    ? `<rect class="pending" x="${x + 0.5}" y="${y - 8.5}" width="8" height="8" rx="2"/>`
582    : fill === 'none'
583      ? `<rect class="none" x="${x}" y="${y - 9}" width="9" height="9" rx="2"/>`
584      : `<rect x="${x}" y="${y - 9}" width="9" height="9" rx="2" fill="${fill}"/>`
585
586const headSvg = (title: string, count: string, left: number, width: number): string =>
587  `<text class="head" x="${left}" y="12">${escapeXml(title)}</text>` +
588  (count === '' ? '' : `<text class="muted" x="${left + width}" y="12" text-anchor="end">${escapeXml(count)}</text>`)
589
590const STEP = 21
591const LABEL_WIDTH = 190
592const HEAD_HEIGHT = 62
593const LEGEND_HEIGHT = 30
594// The legend's last words end near x = 340: a matrix of few columns is drawn that wide all the same.
595const LEGEND_WIDTH = 345
596
597export const shortId = (id: string): string => id.replace(/^[A-Z][A-Z0-9]{1,5}-(?=(?:CU|RM|D)-)/, '')
598
599// The rules a context's use cases apply, as columns: each use case's own rules in its order, then the shared ones.
600export const matrixColumns = (objects: KObject[], name: string): KObject[] => {
601  const { usecases, shared } = contextParts(objects, name)
602  const ids = new Set(usecases.map(one => one.cu.id))
603  const own = usecases.flatMap(one => one.rules)
604  const taken = new Set([...own, ...shared].map(one => one.id))
605  const elsewhere = objects.filter(one => one.kind === 'RM' && !taken.has(one.id) && one.usecases.some(cu => ids.has(cu)))
606  return [...own, ...shared.filter(rule => rule.usecases.some(cu => ids.has(cu))), ...elsewhere.sort((a, b) => a.id.localeCompare(b.id))]
607}
608
609export const matrixSvg = (objects: KObject[], name: string): string => {
610  const rows = contextParts(objects, name).usecases.map(one => one.cu)
611  const columns = matrixColumns(objects, name)
612  if (rows.length === 0 || columns.length === 0) return ''
613  const width = Math.max(LEGEND_WIDTH, LABEL_WIDTH + columns.length * STEP + 24)
614  const height = HEAD_HEIGHT + rows.length * STEP + LEGEND_HEIGHT
615  const heads = columns
616    .map((rule, index) => {
617      const x = LABEL_WIDTH + index * STEP + CELL / 2
618      const place = isSharedRule(rule) ? 'cross-cutting' : rule.context === name ? 'own' : rule.context
619      return `<g class="mark"><title>${escapeXml(`${rule.id} — ${rule.title} · ${place}`)}</title><text class="${isSharedRule(rule) ? 'head' : 'ink'}" transform="translate(${x + 4},${HEAD_HEIGHT - 6}) rotate(-55)">${escapeXml(shortId(rule.id))}</text></g>`
620    })
621    .join('')
622  const lines = rows
623    .map((cu, row) => {
624      const y = HEAD_HEIGHT + row * STEP
625      const label = `<g class="mark"><title>${escapeXml(`${cu.id} — ${cu.title}`)}</title><text class="ink" x="0" y="${y + 13}">${escapeXml(fit(`${shortId(cu.id)} ${cu.title}`, 30))}</text></g>`
626      const squares = columns
627        .map((rule, index) => {
628          const x = LABEL_WIDTH + index * STEP
629          if (!rule.usecases.includes(cu.id)) return `<rect class="empty" x="${x}" y="${y}" width="${CELL}" height="${CELL}" rx="4"/>`
630          const status = rule.level === null ? 'specified' : `observed ${rule.level}`
631          const place = isOwnRule(rule, cu) ? 'own rule' : isSharedRule(rule) ? 'cross-cutting' : `from ${rule.context}`
632          return cellSvg(x, y, rule.level === null ? STATUS.specified : STATUS.warning, rule.level?.[0] ?? '', [`${cu.id} applies ${rule.id}`, rule.title, `${place} · ${status}`])
633        })
634        .join('')
635      return label + squares
636    })
637    .join('')
638  const firstShared = columns.findIndex(isSharedRule)
639  const divider =
640    firstShared > 0
641      ? `<line class="rule" x1="${LABEL_WIDTH + firstShared * STEP - 2}" y1="${HEAD_HEIGHT - 4}" x2="${LABEL_WIDTH + firstShared * STEP - 2}" y2="${HEAD_HEIGHT + rows.length * STEP - 3}"/>`
642      : ''
643  const legendY = HEAD_HEIGHT + rows.length * STEP + 18
644  const legend =
645    `${swatchSvg(0, legendY, STATUS.specified)}<text class="ink" x="13" y="${legendY}">specified</text>` +
646    `${swatchSvg(84, legendY, STATUS.warning)}<text class="ink" x="97" y="${legendY}">observed (h · m · l)</text>` +
647    `<text class="muted" x="230" y="${legendY}">bold: cross-cutting</text>`
648  return svgOf(width, height, `${headSvg('Use cases × rules', '', 0, width)}${heads}${divider}${lines}${legend}`)
649}
650
651export const matrixAlt = (objects: KObject[], name: string): string => {
652  const rows = contextParts(objects, name).usecases
653  const columns = matrixColumns(objects, name)
654  const applied = rows.map(({ cu }) => `${cu.id} applies ${columns.filter(rule => rule.usecases.includes(cu.id)).map(rule => rule.id).join(', ') || 'no rule'}`)
655  return `${name}: ${counted(rows.length, 'use case')} × ${counted(columns.length, 'rule')}. ${applied.join('; ')}.`
656}
657
658const SVG_WIDTH = 560
659const COLUMN_GAP = 24
660const LEGEND_CHAR_PX = 6.2
661const LEGEND_LINE = 16
662
663type Cell = { fill: Paint; glyph: string; title: string[] }
664
665type Block = { svg: string; height: number }
666
667const cells = (items: Cell[], left: number, y: number, width: number): Block => {
668  const perRow = Math.max(1, Math.floor((width + CELL_GAP) / (CELL + CELL_GAP)))
669  const svg = items
670    .map((item, index) => cellSvg(left + (index % perRow) * (CELL + CELL_GAP), y + Math.floor(index / perRow) * (CELL + CELL_GAP), item.fill, item.glyph, item.title))
671    .join('')
672  return { svg, height: Math.ceil(items.length / perRow) * (CELL + CELL_GAP) - CELL_GAP }
673}
674
675const legend = (items: { fill: Paint; text: string }[], left: number, y: number, width: number): Block => {
676  let x = 0
677  let line = 0
678  const svg = items
679    .map(item => {
680      const room = 13 + [...item.text].length * LEGEND_CHAR_PX
681      if (x > 0 && x + room > width) {
682        x = 0
683        line += 1
684      }
685      const at = left + x
686      const base = y + line * LEGEND_LINE
687      x += room + 16
688      return `${swatchSvg(at, base, item.fill)}<text class="ink" x="${at + 13}" y="${base}">${escapeXml(item.text)}</text>`
689    })
690    .join('')
691  return { svg, height: (line + 1) * LEGEND_LINE }
692}
693
694type Severity = 'blocking' | 'major' | 'other'
695
696const SEVERITIES: { key: Severity; icon: string; fill: Paint }[] = [
697  { key: 'blocking', icon: '✖', fill: STATUS.critical },
698  { key: 'major', icon: '▲', fill: STATUS.warning },
699  { key: 'other', icon: '●', fill: 'none' },
700]
701
702const severityOf = (question: OpenQuestion): Severity => {
703  const tone = severityTone(question.severity)
704  return tone === 'error' ? 'blocking' : tone === 'warning' ? 'major' : 'other'
705}
706
707const bySeverity = (questions: OpenQuestion[]) =>
708  SEVERITIES.map(one => ({ ...one, items: questions.filter(question => severityOf(question) === one.key) }))
709
710const decisionParts = (asked: Decision[]) => {
711  const pending = asked.filter(one => one.isPending)
712  const none = asked.filter(isUnanswered)
713  const other = asked.filter(one => !one.isPending && !isUnanswered(one) && isOffRecommendation(one))
714  const kept = asked.filter(one => !one.isPending && !isUnanswered(one) && !isOffRecommendation(one))
715  return { pending, none, other, kept }
716}
717
718const nameOf = (decision: Decision): string => decision.header || decision.question
719
720const block = (title: string, count: string, items: Cell[], keys: { fill: Paint; text: string }[], left: number, width: number): Block => {
721  const grid = cells(items, left, 20, width)
722  const key = legend(keys, left, 20 + grid.height + 18, width)
723  return { svg: headSvg(title, count, left, width) + grid.svg + key.svg, height: 20 + grid.height + 6 + key.height }
724}
725
726const answerOf = (decision: Decision): string[] =>
727  [
728    nameOf(decision),
729    decision.header === '' ? '' : decision.question,
730    decision.isPending ? 'waiting for the answer' : decision.answer === null ? 'no answer' : `answer: ${decision.answer}`,
731    decision.recommended === null ? '' : `recommended: ${decision.recommended}`,
732  ].filter(one => one !== '')
733
734export const specGauge = (asked: Decision[], doc: SpecDoc | null): string => {
735  if (asked.length === 0 && doc === null) return ''
736  const isPair = doc !== null && asked.length > 0
737  const width = isPair ? (SVG_WIDTH - COLUMN_GAP) / 2 : SVG_WIDTH
738  const blocks: Block[] = []
739  if (doc !== null) {
740    if (doc.openQuestions.length === 0) {
741      blocks.push({
742        svg:
743          headSvg('Open questions', '', 0, width) +
744          `<rect x="0" y="20" width="${CELL}" height="${CELL}" rx="4" fill="${STATUS.good}"/><text class="glyph" x="${CELL / 2}" y="${20 + CELL / 2 + 3.5}" text-anchor="middle" fill="${CELL_INK}">✓</text><text class="ink" x="${CELL + 8}" y="33">no open question</text>`,
745        height: 20 + CELL,
746      })
747    } else {
748      const groups = bySeverity(doc.openQuestions)
749      blocks.push(
750        block(
751          'Open questions',
752          counted(doc.openQuestions.length, 'open', 'open'),
753          groups.flatMap(group =>
754            group.items.map(question => ({
755              fill: group.fill,
756              glyph: group.icon,
757              title: [`${question.id} · ${group.key}`, plain(question.question), question.impact === '' ? '' : `impact: ${plain(question.impact)}`].filter(one => one !== ''),
758            })),
759          ),
760          groups.filter(group => group.items.length > 0).map(group => ({ fill: group.fill, text: `${group.icon} ${group.items.length} ${group.key}` })),
761          0,
762          width,
763        ),
764      )
765    }
766  }
767  if (asked.length > 0) {
768    const { pending, none, other, kept } = decisionParts(asked)
769    blocks.push(
770      block(
771        'Decisions',
772        `${kept.length + other.length} of ${asked.length} answered`,
773        [
774          ...kept.map(one => ({ fill: STATUS.good, glyph: '✓', title: answerOf(one) })),
775          ...other.map(one => ({ fill: STATUS.warning, glyph: '≠', title: answerOf(one) })),
776          ...none.map(one => ({ fill: 'none', glyph: '–', title: answerOf(one) })),
777          ...pending.map(one => ({ fill: 'pending', glyph: '?', title: answerOf(one) })),
778        ],
779        [
780          ...(kept.length > 0 ? [{ fill: STATUS.good, text: `✓ ${kept.length} recommended kept` }] : []),
781          ...(other.length > 0 ? [{ fill: STATUS.warning, text: `≠ ${other.length} other choice` }] : []),
782          ...(none.length > 0 ? [{ fill: 'none', text: `– ${none.length} no answer` }] : []),
783          ...(pending.length > 0 ? [{ fill: 'pending', text: `? ${pending.length} pending` }] : []),
784        ],
785        isPair ? width + COLUMN_GAP : 0,
786        width,
787      ),
788    )
789  }
790  const height = Math.ceil(Math.max(...blocks.map(one => one.height)) + 4)
791  return svgOf(SVG_WIDTH, height, blocks.map(one => one.svg).join(''))
792}
793
794export const specGaugeAlt = (asked: Decision[], doc: SpecDoc | null): string => {
795  const parts: string[] = []
796  if (doc !== null) {
797    const groups = bySeverity(doc.openQuestions).filter(group => group.items.length > 0)
798    parts.push(
799      doc.openQuestions.length === 0
800        ? 'no open question'
801        : `${counted(doc.openQuestions.length, 'open question')} (${groups.map(group => `${group.items.length} ${group.key}`).join(', ')})`,
802    )
803  }
804  if (asked.length > 0) {
805    const { pending, none, other, kept } = decisionParts(asked)
806    const split = [
807      kept.length > 0 ? `${kept.length} recommended kept` : '',
808      other.length > 0 ? `${other.length} other choice${other.length === 1 ? '' : 's'}` : '',
809      none.length > 0 ? `${none.length} without answer` : '',
810      pending.length > 0 ? `${pending.length} pending` : '',
811    ].filter(one => one !== '')
812    parts.push(`${counted(asked.length, 'decision')} (${split.join(', ')})`)
813  } else parts.push('no decision')
814  if (doc === null) parts.push('spec not written yet')
815  return `Spec state: ${parts.join(', ')}.`
816}
817
818const blockingCount = (questions: OpenQuestion[]): number => questions.filter(one => severityTone(one.severity) === 'error').length
819
820export const questionsTone = (questions: OpenQuestion[]): 'error' | 'warning' | 'inactive' => {
821  const tones = questions.map(one => severityTone(one.severity))
822  return tones.includes('error') ? 'error' : tones.includes('warning') ? 'warning' : 'inactive'
823}
824
825const join = (...parts: string[]): string => parts.filter(part => part !== '').join('/')
826
827const listOf = async ($: EngineInterface, path: string) => (await $.fs.list(path).catch(() => [])) ?? []
828
829type FileStamp = { rel: string; size: number; mtimeMs: number }
830
831// Every `.md` of the base but INDEX.md and HISTORY.md, dot folders (staging, index) left out, a linked file read
832// through its link, a linked folder not entered — as `lib/knowledge.py` `markdown_files` walks them.
833async function baseFiles($: EngineInterface, repo: string, root: string, sub = '', depth = 0): Promise<FileStamp[]> {
834  if (depth > 32) return []
835  const entries = await listOf($, join(repo, root, sub))
836  const found: FileStamp[] = []
837  for (const entry of entries) {
838    const rel = join(sub, entry.name)
839    if (entry.kind === 'dir') {
840      if (!entry.name.startsWith('.')) found.push(...(await baseFiles($, repo, root, rel, depth + 1)))
841      continue
842    }
843    if (!entry.name.endsWith('.md') || SKIPPED.includes(entry.name)) continue
844    if (entry.kind === 'file') found.push({ rel: join(root, rel), size: entry.size, mtimeMs: entry.mtimeMs })
845    else if (entry.isLink) {
846      const target = await $.fs.stat(join(repo, root, rel)).catch(() => null)
847      if (target?.kind === 'file') found.push({ rel: join(root, rel), size: target.size, mtimeMs: target.mtimeMs })
848    }
849  }
850  return found
851}
852
853async function filesUnder($: EngineInterface, path: string, sub = '', depth = 0): Promise<{ rel: string; mtimeMs: number }[]> {
854  if (depth > 6) return []
855  const found: { rel: string; mtimeMs: number }[] = []
856  for (const entry of await listOf($, join(path, sub))) {
857    const rel = join(sub, entry.name)
858    if (entry.kind === 'dir') found.push(...(await filesUnder($, path, rel, depth + 1)))
859    else if (entry.kind === 'file') found.push({ rel, mtimeMs: entry.mtimeMs })
860  }
861  return found
862}
863
864const readText = async ($: EngineInterface, path: string): Promise<string | null> => {
865  try {
866    // A Windows checkout may carry CRLF: every parser below splits on `\n`.
867    return (await $.fs.read(path)).replace(/\r\n?/g, '\n')
868  } catch {
869    return null
870  }
871}
872
873const mtimeOf = async ($: EngineInterface, path: string): Promise<number> => (await $.fs.stat(path).catch(() => null))?.mtimeMs ?? 0
874
875// What a tick compares: a change of any of it reloads the base.
876async function signatureOf($: EngineInterface, repo: string, root: string): Promise<{ signature: string; files: FileStamp[] }> {
877  const at = join(repo, root)
878  if ((await $.fs.stat(at).catch(() => null))?.kind !== 'dir') return { signature: 'absent', files: [] }
879  const files = await baseFiles($, repo, root)
880  const staging = [
881    ...(await filesUnder($, join(at, '.merge'))).map(one => `m:${one.rel}:${one.mtimeMs}`),
882    ...(await filesUnder($, join(at, '.sync'))).map(one => `s:${one.rel}:${one.mtimeMs}`),
883  ]
884  const marks = [
885    `i:${await mtimeOf($, join(at, INDEX_JSONL))}`,
886    `x:${await mtimeOf($, join(at, 'INDEX.md'))}`,
887    ...files.map(one => `f:${one.rel}:${one.size}:${one.mtimeMs}`),
888    ...staging,
889  ]
890  return { signature: marks.sort().join('|'), files }
891}
892
893async function loadMerge($: EngineInterface, at: string, code: string): Promise<Merge> {
894  const dir = join(at, '.merge', code)
895  const feature = (await readText($, join(dir, '.feature')))?.trim() ?? code
896  let objects: MergeObject[] = []
897  try {
898    const packet = JSON.parse((await readText($, join(dir, '.packet.json'))) ?? '{}') as { objects?: Record<string, unknown>[] }
899    objects = (packet.objects ?? [])
900      .filter(one => one.status === undefined || one.status === 'todo')
901      .map(one => ({ id: textOf(one.id), kind: textOf(one.kind), change: textOf(one.change), context: textOf(one.context), title: textOf(one.title) }))
902      .filter(one => one.id !== '')
903  } catch {
904    objects = []
905  }
906  const entries = await listOf($, dir)
907  const staged = entries.filter(entry => entry.kind === 'dir' && !entry.name.startsWith('.')).map(entry => entry.name)
908  const sources = [
909    ...(await Promise.all(staged.map(async name => (await filesUnder($, join(dir, name))).map(one => join(dir, name, one.rel))))).flat(),
910    ...(await filesUnder($, join(dir, '.drafts'))).map(one => join(dir, '.drafts', one.rel)),
911  ]
912  const after: Record<string, string> = {}
913  for (const path of sources) {
914    if (!path.endsWith('.md')) continue
915    const text = await readText($, path)
916    if (text === null) continue
917    for (const object of objects) {
918      if (after[object.id] !== undefined || object.change === 'retired') continue
919      const found = sliceObject(text, object.id)
920      if (found !== null) after[object.id] = found
921    }
922  }
923  return { code, isPartial: / partial$/.test(feature), isStaged: staged.length > 0, objects, after }
924}
925
926async function loadSyncs($: EngineInterface, at: string): Promise<Sync[]> {
927  const syncs: Sync[] = []
928  for (const entry of await listOf($, join(at, '.sync'))) {
929    if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue
930    const files = (await filesUnder($, join(at, '.sync', entry.name))).map(one => one.rel).sort()
931    if (files.length > 0) syncs.push({ context: entry.name, files })
932  }
933  return syncs
934}
935
936export async function loadBase(
937  $: EngineInterface,
938  repo: string,
939  root: string,
940  isDemo: boolean,
941  walked?: { signature: string; files: FileStamp[] },
942): Promise<Base> {
943  const at = join(repo, root)
944  const { signature, files } = walked ?? (await signatureOf($, repo, root))
945  const isPresent = signature !== 'absent'
946  const empty = { ...emptyBase(repo, root), isDemo, signature, readAt: await $.clock.now() }
947  if (!isPresent) return empty
948  const jsonl = await readText($, join(at, INDEX_JSONL))
949  const objects = jsonl === null ? [] : parseIndex(jsonl)
950  let index: IndexState = jsonl === null ? 'missing' : 'ok'
951  let staleFiles: string[] = []
952  if (jsonl !== null && !isDemo) {
953    let cache: unknown = {}
954    try {
955      cache = JSON.parse((await readText($, join(at, INDEX_FILES))) ?? '{}')
956    } catch {
957      cache = {}
958    }
959    staleFiles = staleOf(files, cache)
960    if (staleFiles.length > 0) index = 'stale'
961  }
962  const indexMd = await readText($, join(at, 'INDEX.md'))
963  const parsed = indexMd === null ? { contexts: [], adrCount: null } : parseIndexMd(indexMd)
964  const named = new Set(parsed.contexts.map(one => one.name))
965  const extra = [...new Set(objects.filter(one => one.kind !== 'ADR' && one.context !== '').map(one => one.context))]
966    .filter(name => !named.has(name))
967    .map(name => ({ name, prefix: '', usecases: 0, rules: 0, decisions: 0, summary: '' }))
968  const contexts = [...parsed.contexts, ...extra]
969  const overviews: Record<string, string> = {}
970  const history: HistoryRow[] = []
971  for (const context of contexts) {
972    const overview = await readText($, join(at, context.name, 'OVERVIEW.md'))
973    if (overview !== null) overviews[context.name] = overview
974    const rows = await readText($, join(at, context.name, 'HISTORY.md'))
975    if (rows !== null) history.push(...parseHistory(context.name, rows))
976  }
977  history.sort((a, b) => b.date.localeCompare(a.date))
978  const merges: Merge[] = []
979  for (const entry of await listOf($, join(at, '.merge'))) {
980    if (entry.kind !== 'dir') continue
981    merges.push(await loadMerge($, at, entry.name))
982  }
983  return {
984    ...empty,
985    isPresent,
986    index,
987    staleFiles,
988    contexts,
989    adrCount: parsed.adrCount ?? (objects.length > 0 ? objects.filter(one => one.kind === 'ADR').length : null),
990    objects,
991    overviews,
992    history,
993    merges,
994    syncs: await loadSyncs($, at),
995  }
996}
997
998async function reload($: EngineInterface, walked?: { signature: string; files: FileStamp[] }): Promise<Base> {
999  const current = await read($, base)
1000  const next = await loadBase($, current.repo, current.root, current.isDemo, walked)
1001  await update($, base, () => next)
1002  await update($, full, () => ({}))
1003  // The view on top keeps its whole text: the index holds a use case's head only.
1004  const { stack } = await read($, nav)
1005  const top = stack[stack.length - 1]
1006  if (top?.kind === 'object' || top?.kind === 'change') await fetchFull($, top.id)
1007  return next
1008}
1009
1010// Where `/cctoolkit:kit-init` keeps the base: `knowledge.root` of the repo's kit config, `docs/business` by default.
1011async function rootOf($: EngineInterface, repo: string): Promise<string> {
1012  try {
1013    const config = JSON.parse((await readText($, join(repo, CONFIG))) ?? '{}') as { product?: unknown; knowledge?: { root?: unknown } }
1014    const root = config.knowledge?.root
1015    if (typeof root !== 'string' || root.trim() === '') return DEFAULT_ROOT
1016    return root.replaceAll('{product}', typeof config.product === 'string' && config.product !== '' ? config.product : '*').replace(/^[\\/]+|[\\/]+$/g, '')
1017  } catch {
1018    return DEFAULT_ROOT
1019  }
1020}
1021
1022async function switchTo($: EngineInterface, repo: string, root: string, isDemo: boolean): Promise<Base> {
1023  const next = await loadBase($, repo, root, isDemo)
1024  await update($, base, () => next)
1025  await update($, full, () => ({}))
1026  await update($, nav, () => HOME)
1027  return next
1028}
1029
1030// An object's text whole, read from its file at `path:line` — the index keeps a use case's head only.
1031async function fetchFull($: EngineInterface, id: string): Promise<void> {
1032  const current = await read($, base)
1033  const object = current.objects.find(one => one.id === id)
1034  if (object === undefined || (await read($, full))[id] !== undefined) return
1035  const text = await readText($, join(current.repo, object.path))
1036  const lines = text?.split('\n') ?? []
1037  const body = text === null ? object.text : (sliceObject(text, id) ?? lines.slice(object.line, object.end).join('\n').trim())
1038  await update($, full, known => ({ ...known, [id]: body }))
1039}
1040
1041async function push($: EngineInterface, view: View): Promise<void> {
1042  if (view.kind === 'object' || view.kind === 'change') await fetchFull($, view.id)
1043  await update($, nav, current => {
1044    const stack = current.stack.length > 0 ? current.stack : HOME.stack
1045    const top = stack[stack.length - 1]
1046    if (top !== undefined && JSON.stringify(top) === JSON.stringify(view)) return current
1047    return seeded({ ...HOME, ...current, stack: [...stack, view], expanded: [] })
1048  })
1049}
1050
1051async function back($: EngineInterface): Promise<void> {
1052  await update($, nav, current => seeded({ ...HOME, ...current, stack: current.stack.length > 1 ? current.stack.slice(0, -1) : HOME.stack, expanded: [] }))
1053}
1054
1055async function home($: EngineInterface): Promise<void> {
1056  await update($, nav, current => ({ ...HOME, query: current.query }))
1057}
1058
1059// The stack without the spec view, so it is never on it twice.
1060const withoutSpec = (current: Navigation): View[] => {
1061  const stack = current.stack.filter(one => one.kind !== 'spec')
1062  return stack.length > 0 ? stack : HOME.stack
1063}
1064
1065// The plugin's own close never reaches its own ui.close hook: the flag is set here.
1066async function closePane($: EngineInterface): Promise<void> {
1067  await $.ui.close({ id: PANE }).catch(() => undefined)
1068  await update($, isShown, () => false)
1069}
1070
1071async function openPane($: EngineInterface): Promise<void> {
1072  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS }).catch(() => null)
1073  if (opened?.isPlaced) await update($, isShown, () => true)
1074}
1075
1076async function showSpec($: EngineInterface): Promise<void> {
1077  await update($, nav, (current): Navigation => ({ ...HOME, ...current, stack: [...withoutSpec({ ...HOME, ...current }), { kind: 'spec' }], expanded: [] }))
1078  await openPane($)
1079}
1080
1081async function arm($: EngineInterface, topic: string | null): Promise<void> {
1082  await update($, tracked, () => ({ ...IDLE, isArmed: true, topic }))
1083  await update($, decisions, () => [])
1084  await showSpec($)
1085}
1086
1087async function reloadSpec($: EngineInterface, path: string): Promise<void> {
1088  const stat = await $.fs.stat(path).catch(() => null)
1089  const text = stat === null ? null : await readText($, path)
1090  if (stat === null || text === null) {
1091    await update($, tracked, state => ({ ...state, path }))
1092    return
1093  }
1094  const at = await $.clock.now()
1095  await update($, tracked, state => ({ ...state, isArmed: true, path, mtimeMs: stat.mtimeMs, doc: parseSpec(text), readAt: at }))
1096}
1097
1098async function tick($: EngineInterface): Promise<void> {
1099  const shown = await read($, isShown)
1100  const spec = await read($, tracked)
1101  if (!shown && !spec.isArmed) return
1102  const at = await $.clock.now()
1103  await update($, now, () => at)
1104  if (spec.path !== null) {
1105    const stat = await $.fs.stat(spec.path).catch(() => null)
1106    if (stat !== null && stat.mtimeMs !== spec.mtimeMs) await reloadSpec($, spec.path)
1107  }
1108  const current = await read($, base)
1109  if (!shown || current.repo === '') return
1110  const walked = await signatureOf($, current.repo, current.root)
1111  if (walked.signature !== current.signature) await reload($, walked)
1112}
1113
1114export const register: Register = on => {
1115  let repo: string | null = null
1116
1117  // On a desktop, a button above the prompt opens and closes the pane once the repo holds a base or a spec is
1118  // followed: its tab has no way back once closed but the commands.
1119  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1120    if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
1121    if (!(await read($, base)).isPresent && !(await read($, tracked)).isArmed) return next(e)
1122    const { Box, Button } = $.ui.resolve(e)
1123    const shown = await read($, isShown)
1124    const { stack } = { ...HOME, ...(await read($, nav)) }
1125    const label = stack[stack.length - 1]?.kind === 'spec' ? `◫ ${SPEC_TITLE}` : `◫ ${TITLE}`
1126    return (
1127      <Box>
1128        <Button key="toggle-knowledge-pane" label={label} variant={shown ? 'primary' : 'secondary'} onPress={() => (shown ? closePane($) : openPane($))} />
1129      </Box>
1130    )
1131  })
1132
1133  on('ui.close', async ($, e, next) => {
1134    const result = await next(e)
1135    if (e.id === PANE) await update($, isShown, () => false)
1136    return result
1137  })
1138
1139  on('session.start', async ($, e, next) => {
1140    const panes = await $.ui.panes().catch(() => [])
1141    await update($, isShown, () => panes.some(pane => pane.id === PANE))
1142    repo = e.cwd
1143    await $.command.register({
1144      name: 'knowledge-pane',
1145      description: 'Browse the knowledge base: a context, an id or words to search; demo shows the kit example',
1146    })
1147    await $.command.register({
1148      name: 'spec-pane',
1149      description: 'Open the /business-spec view of the knowledge pane (a SPEC path follows that spec, clear forgets it)',
1150    })
1151    $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
1152    const current = await read($, base)
1153    if (!current.isDemo || current.repo === '') await switchTo($, e.cwd, await rootOf($, e.cwd), false).catch(() => undefined)
1154    if ((await read($, tracked)).isArmed) void showSpec($)
1155    return next(e)
1156  })
1157
1158  on('command.run', { command: 'knowledge-pane' }, async ($, e) => {
1159    const asked = e.args.trim()
1160    if (asked === 'demo') {
1161      await switchTo($, `${$.plugin.root}/demo`, DEFAULT_ROOT, true)
1162      await openPane($)
1163      return { text: 'Knowledge pane: demo base, the kit example (Catalog).' }
1164    }
1165    let current = await read($, base)
1166    if (current.isDemo || current.repo === '' || asked === '') {
1167      const where = repo ?? current.repo
1168      current = await switchTo($, where, await rootOf($, where), false)
1169    }
1170    if (asked !== '') {
1171      const context = current.contexts.find(one => fold(one.name) === fold(asked))
1172      if (context !== undefined) await push($, { kind: 'context', name: context.name })
1173      else if (ID_PATTERN.test(asked.toUpperCase())) await push($, { kind: 'object', id: asked.toUpperCase() })
1174      else {
1175        await update($, nav, current => ({ ...current, query: asked }))
1176        await push($, { kind: 'search' })
1177      }
1178    }
1179    await openPane($)
1180    return { text: current.isPresent ? 'Knowledge pane opened.' : `Knowledge pane opened — no knowledge base under ${current.root}.` }
1181  })
1182
1183  on('command.run', { command: 'spec-pane' }, async ($, e) => {
1184    if (e.args.trim() === 'clear') {
1185      await update($, tracked, () => IDLE)
1186      await update($, decisions, () => [])
1187      await update($, nav, current => seeded({ ...HOME, ...current, stack: withoutSpec({ ...HOME, ...current }), expanded: [] }))
1188      return { text: 'Spec pane cleared.' }
1189    }
1190    const asked = e.args.trim().replace(/^@/, '')
1191    if (asked !== '') {
1192      const path = isAbsolutePath(asked) || repo === null ? asked : `${repo}/${asked}`
1193      if (!isSpecPath(path)) return { text: `Not a spec: ${asked} (expected todo/<code>/SPEC-<code>.md or todo/<slug>/SPEC.md).` }
1194      await update($, tracked, () => ({ ...IDLE, isArmed: true }))
1195      await update($, decisions, () => [])
1196      await reloadSpec($, path)
1197      if ((await read($, tracked)).doc === null) return { text: `Spec not found: ${asked}.` }
1198    }
1199    await showSpec($)
1200    return { text: 'Spec pane opened.' }
types/index.d.ts 118 lines
1export type Kind = 'CU' | 'RM' | 'D' | 'ADR'
2
3export type Level = 'high' | 'medium' | 'low'
4
5// One line of `<root>/.index/knowledge.jsonl`, written by the kit's `lib/knowledge_index.py`.
6export type KObject = {
7  id: string
8  kind: Kind
9  context: string
10  title: string
11  text: string
12  level: Level | null
13  path: string
14  line: number
15  end: number
16  cites: string[]
17  usecases: string[]
18  handlers: string[]
19  citedBy: string[]
20  contexts: string[]
21}
22
23export type ContextRow = { name: string; prefix: string; usecases: number; rules: number; decisions: number; summary: string }
24
25export type HistoryRow = { context: string; date: string; feature: string; delta: string[] }
26
27export type MergeObject = { id: string; kind: string; change: string; context: string; title: string }
28
29// `<root>/.merge/<CODE>/`: prepared while only `.drafts/` holds files, staged once `stage` laid the whole files.
30export type Merge = { code: string; isPartial: boolean; isStaged: boolean; objects: MergeObject[]; after: Record<string, string> }
31
32export type Sync = { context: string; files: string[] }
33
34export type IndexState = 'ok' | 'missing' | 'stale'
35
36export type Base = {
37  repo: string
38  root: string
39  isDemo: boolean
40  isPresent: boolean
41  index: IndexState
42  staleFiles: string[]
43  contexts: ContextRow[]
44  adrCount: number | null
45  objects: KObject[]
46  overviews: Record<string, string>
47  history: HistoryRow[]
48  merges: Merge[]
49  syncs: Sync[]
50  readAt: number | null
51  signature: string
52}
53
54export type Decision = {
55  id: string
56  header: string
57  question: string
58  options: string[]
59  recommended: string | null
60  answer: string | null
61  note: string | null
62  isPending: boolean
63}
64
65export type SpecItem = { id: string; name: string; tag: string | null; body: string }
66
67// `anchor`: the `<!-- kit:<name> -->` ending the heading, when it carries one.
68export type SpecSection = { number: string; title: string; anchor?: string; body: string }
69
70export type OpenQuestion = { id: string; severity: string; question: string; impact: string; options: string }
71
72export type SpecDoc = {
73  title: string
74  summary: string
75  sections: SpecSection[]
76  useCases: SpecItem[]
77  rules: SpecItem[]
78  openQuestions: OpenQuestion[]
79}
80
81// The `/business-spec` of the session: armed when it starts, its SPEC file followed once written.
82export type Tracked = {
83  isArmed: boolean
84  topic: string | null
85  path: string | null
86  mtimeMs: number
87  doc: SpecDoc | null
88  readAt: number | null
89}
90
91export type SpecKind = 'useCase' | 'rule' | 'section' | 'openQuestion' | 'decision'
92
93export type View =
94  | { kind: 'home' }
95  | { kind: 'context'; name: string }
96  | { kind: 'object'; id: string }
97  | { kind: 'merge'; code: string }
98  | { kind: 'change'; code: string; id: string }
99  | { kind: 'search' }
100  | { kind: 'spec' }
101
102// `seed`: the text the search field is drawn with, set as the search comes on top — never per keystroke, so a redraw cannot overwrite typing.
103export type Navigation = { stack: View[]; query: string; seed: string; expanded: string[]; collapsed: string[] }
104
105declare module 'claude-code' {
106  interface PluginState {
107    'knowledge-pane': {
108      base: Base
109      nav: Navigation
110      full: Record<string, string>
111      now: number
112      isShown: boolean
113      tracked: Tracked
114      decisions: Decision[]
115    }
116  }
117}
118