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

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.
claude plugin marketplace add pierrebelin/claude-code-toolkit --scope project
claude plugin install cctoolkit@cctoolkit --scope project
Restart Claude Code, then run /cctoolkit:kit-init: it reads your repo, asks a few questions and sets everything up.
Requirements, options and what kit-init writes: INSTALL.md.
Three commands, one per step. Each one writes a file the next one reads.
flowchart LR
S["<b>1. Spec</b><br/>/business-spec"] -->|SPEC.md| P["<b>2. Plan</b><br/>/plan-implementation"]
P -->|"PLAN.md + one sheet per batch"| R["<b>3. Build</b><br/>/run-plan or /run-lot"]
R -->|"code, tests, report"| C(["you review and commit"])
/cctoolkit:business-spec — Claude asks you one question at a time, then writes a short business spec: rules, use cases, no technical detail. A second agent tries to poke holes in it./cctoolkit:plan-implementation — turns the spec into a technical plan, split into small batches.| Whole plan | Batch by batch | |
|---|---|---|
| Command | /cctoolkit:run-plan <plan> | /cctoolkit:run-lot <sheet> |
| Runs | every batch, in dependency order | one batch |
| You step in | once, at the end | after each batch: review, /clear, next |
| Stops | a batch that cannot pass is set aside, only its dependents wait; batches set aside for one shared cause stop the plan | the batch comes back DONE, or with what needs your eyes |
| Fits | a plan you trust, a run over lunch or overnight | a 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.
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.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/.
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.

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

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.
This kit encodes one way of working. It will:
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.
| Install, update, dependencies | INSTALL.md |
| A feature from spec to report | examples/ |
| The knowledge base's format | knowledge-format.md |
| Every skill and agent | skills/README.md |
| Layouts and configuration | presets/README.md |
MIT. Take what you want, closed-source projects included.
hooks/register.tsx 2144 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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(' ')}</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 lines1export 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