@erclx/canon 4.80.0 → 4.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/design-extract/SKILL.md +5 -1
  3. package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
  4. package/claude/skills/sketch-design/SKILL.md +92 -0
  5. package/claude/skills/teach-workspace/SKILL.md +23 -1
  6. package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
  7. package/docs/agents/commands.md +11 -5
  8. package/docs/agents/context-audit.md +1 -1
  9. package/docs/agents/context-classify.md +99 -0
  10. package/docs/agents/design-board.md +13 -11
  11. package/docs/agents/index.md +2 -1
  12. package/docs/agents/teach.md +16 -0
  13. package/docs/target-projects.md +15 -0
  14. package/docs/workflow/ai-workflow.md +4 -2
  15. package/docs/workflow/visual-design-workflow.md +1 -0
  16. package/governance/rules/claude/545-decisions.md +12 -0
  17. package/package.json +1 -1
  18. package/src/claude/cases/misc.ts +5 -0
  19. package/src/claude/seeds.ts +1 -0
  20. package/src/commands/claude.ts +26 -5
  21. package/src/commands/context.ts +425 -0
  22. package/src/commands/design.ts +24 -8
  23. package/src/commands/teach.ts +118 -0
  24. package/src/context/classify/extract.ts +450 -0
  25. package/src/context/classify/ollama.ts +172 -0
  26. package/src/context/classify/patterns.ts +114 -0
  27. package/src/context/classify/prompts.ts +73 -0
  28. package/src/context/classify/run.ts +348 -0
  29. package/src/context/classify/settings.ts +196 -0
  30. package/src/context/folders.ts +1 -0
  31. package/src/design/board.ts +130 -47
  32. package/src/project-root.ts +16 -0
  33. package/src/surface-root.ts +1 -0
  34. package/src/teach/render.ts +126 -0
  35. package/standards/decisions.md +100 -0
  36. package/standards/index.md +1 -0
  37. package/tooling/claude/reference.md +1 -0
  38. package/tooling/claude/seeds/CLAUDE.md +1 -0
  39. package/tooling/claude/seeds/canon/decisions/index.md +8 -0
@@ -2,6 +2,7 @@ import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
4
4
  import { type NavOutcome, generateNav } from '@/teach/nav'
5
+ import { type RenderOutcome, renderLessonBody } from '@/teach/render'
5
6
  import {
6
7
  defineTerms,
7
8
  type ListOutcome,
@@ -81,6 +82,10 @@ interface NavCommandOptions {
81
82
  readonly root?: string
82
83
  }
83
84
 
85
+ interface RenderCommandOptions {
86
+ readonly json?: boolean
87
+ }
88
+
84
89
  export function register(program: Command): void {
85
90
  const teach = program
86
91
  .command('teach')
@@ -357,6 +362,36 @@ export function register(program: Command): void {
357
362
  .action(async (topic: string | undefined, opts: NavCommandOptions) => {
358
363
  process.exitCode = await runNav(topic, opts)
359
364
  })
365
+
366
+ teach
367
+ .command('render')
368
+ .description('Render a lesson body block list to HTML')
369
+ .helpOption('-h, --help', 'Show this help message')
370
+ .option('--json', 'Emit a machine-readable record on stdout')
371
+ .addHelpText(
372
+ 'after',
373
+ [
374
+ '',
375
+ 'Exit codes:',
376
+ ' 0 the block list rendered',
377
+ ' 1 refused, with the reason on stderr or in the JSON record',
378
+ '',
379
+ 'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
380
+ 'list, or raw block, and renders it through the same components the',
381
+ 'fixture lesson is generated from. Content the three cannot express',
382
+ 'takes type raw, carrying its own html verbatim, unescaped.',
383
+ '',
384
+ 'Takes no topic and no --root: the verb is a stateless transform,',
385
+ 'reading nothing off a workspace on disk.',
386
+ '',
387
+ 'Examples:',
388
+ ' echo \'[{"type":"heading","level":1,"text":"Compass bearings"}]\' | canon teach render --json',
389
+ '',
390
+ ].join('\n'),
391
+ )
392
+ .action(async (opts: RenderCommandOptions) => {
393
+ process.exitCode = await runRender(opts)
394
+ })
360
395
  }
361
396
 
362
397
  interface StylesheetCommandOptions {
@@ -403,6 +438,17 @@ function collect(value: string, previous: string[]): string[] {
403
438
  return [...previous, value]
404
439
  }
405
440
 
441
+ function readStdin(): Promise<string> {
442
+ return new Promise((resolveStream, rejectStream) => {
443
+ const chunks: Buffer[] = []
444
+ process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
445
+ process.stdin.on('end', () =>
446
+ resolveStream(Buffer.concat(chunks).toString('utf8')),
447
+ )
448
+ process.stdin.on('error', rejectStream)
449
+ })
450
+ }
451
+
406
452
  /**
407
453
  * Splits every pair or reports the ones that carry no separator. Both halves
408
454
  * are reported together, so a caller passing four pairs learns about all the
@@ -648,6 +694,78 @@ async function runNav(
648
694
  return reportNav(await generateNav(root, topic), emitJson, root)
649
695
  }
650
696
 
697
+ async function runRender(opts: RenderCommandOptions): Promise<number> {
698
+ const emitJson = opts.json ?? false
699
+
700
+ if (process.stdin.isTTY) {
701
+ return reportRenderRefusal(
702
+ badInput(
703
+ "No blocks on stdin. Pipe a JSON array: echo '[...]' | canon teach render",
704
+ ),
705
+ emitJson,
706
+ )
707
+ }
708
+
709
+ const body = (await readStdin()).trim()
710
+
711
+ if (!body) {
712
+ return reportRenderRefusal(
713
+ badInput('Empty stdin. Pipe a JSON array of blocks.'),
714
+ emitJson,
715
+ )
716
+ }
717
+
718
+ let parsed: unknown
719
+ try {
720
+ parsed = JSON.parse(body)
721
+ } catch {
722
+ return reportRenderRefusal(badInput('Malformed JSON on stdin.'), emitJson)
723
+ }
724
+
725
+ if (!Array.isArray(parsed)) {
726
+ return reportRenderRefusal(
727
+ badInput('Stdin must be a JSON array of blocks.'),
728
+ emitJson,
729
+ )
730
+ }
731
+
732
+ return reportRender(renderLessonBody(parsed), emitJson)
733
+ }
734
+
735
+ function reportRenderRefusal(refused: TeachRefused, emitJson: boolean): number {
736
+ if (emitJson) {
737
+ process.stderr.write(`${refused.message}\n`)
738
+ process.stdout.write(
739
+ `${JSON.stringify({
740
+ ok: false,
741
+ reason: refused.reason,
742
+ message: refused.message,
743
+ })}\n`,
744
+ )
745
+ return 1
746
+ }
747
+
748
+ intro('canon teach render')
749
+ logStep('Refused')
750
+ logError(refused.message)
751
+ outro()
752
+ return 1
753
+ }
754
+
755
+ function reportRender(outcome: RenderOutcome, emitJson: boolean): number {
756
+ if (!outcome.ok) return reportRenderRefusal(outcome, emitJson)
757
+
758
+ if (emitJson) {
759
+ process.stdout.write(
760
+ `${JSON.stringify({ ok: true, html: outcome.html })}\n`,
761
+ )
762
+ return 0
763
+ }
764
+
765
+ process.stdout.write(`${outcome.html}\n`)
766
+ return 0
767
+ }
768
+
651
769
  function reportNav(
652
770
  outcome: NavOutcome,
653
771
  emitJson: boolean,
@@ -0,0 +1,450 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename, relative, resolve } from 'node:path'
3
+ import { $ } from 'bun'
4
+ import { resolveFolders } from '@/context/folders'
5
+ import { gitEnv } from '@/git-env'
6
+ import { listChangedFiles, resolveBaseRef } from '@/git-files'
7
+ import { surfaceDir } from '@/surface-root'
8
+
9
+ /**
10
+ * The five canonical doc types the classifier reaches, matching the file
11
+ * pattern the groundwork spike scoped its fixture to
12
+ * (`scripts/hunks.py`'s `DOC_PATH`) and the file-type catalog both system
13
+ * prompts state.
14
+ */
15
+ export const CANONICAL_DOC_TYPES = [
16
+ 'context',
17
+ 'architecture',
18
+ 'wireframes',
19
+ 'design',
20
+ 'requirements',
21
+ ] as const
22
+
23
+ export type CanonicalDocType = (typeof CANONICAL_DOC_TYPES)[number]
24
+
25
+ const DOC_PATTERNS: Record<CanonicalDocType, RegExp> = {
26
+ context: /(^|\/)context\/.+\.md$/,
27
+ architecture: /(^|\/)ARCHITECTURE\.md$/,
28
+ wireframes: /(^|\/)wireframes\/.+\.md$/,
29
+ design: /(^|\/)DESIGN\.md$/,
30
+ requirements: /(^|\/)REQUIREMENTS\.md$/,
31
+ }
32
+
33
+ const INDEX_NAME = 'index.md'
34
+
35
+ /** The two doc types `resolveFolders` discovers, paired with the folder name it takes. */
36
+ const MULTI_DOC_TYPES: readonly { type: CanonicalDocType; name: string }[] = [
37
+ { type: 'context', name: 'context' },
38
+ { type: 'wireframes', name: 'wireframes' },
39
+ ]
40
+
41
+ /** The three doc types that resolve to one file each, paired with `surfaceDir`'s entry name. */
42
+ const SINGLE_DOC_TYPES: readonly { type: CanonicalDocType; entry: string }[] = [
43
+ { type: 'architecture', entry: 'ARCHITECTURE.md' },
44
+ { type: 'design', entry: 'DESIGN.md' },
45
+ { type: 'requirements', entry: 'REQUIREMENTS.md' },
46
+ ]
47
+
48
+ /**
49
+ * Which canonical doc type a repo-relative path belongs to, regardless of
50
+ * which surface root (`canon/` or `.claude/`) it resolved under.
51
+ */
52
+ export function docTypeOf(file: string): CanonicalDocType | undefined {
53
+ if (basename(file) === INDEX_NAME) return undefined
54
+
55
+ return CANONICAL_DOC_TYPES.find((type) => DOC_PATTERNS[type].test(file))
56
+ }
57
+
58
+ /**
59
+ * A hunk of 25 words or more of added text, the groundwork fixture's own
60
+ * floor (`hunks.py`'s `MIN_ADDED_WORDS`). A trivial edit, a typo fix, a
61
+ * one-line link repair, is not worth a model call or a regex read, and the
62
+ * spike's own labelled set never sampled anything smaller.
63
+ */
64
+ export const MIN_ADDED_WORDS = 25
65
+
66
+ /** `hunks.py`'s `MAX_SECTION_WORDS`, so a chunk's context never blows the model's context window. */
67
+ const MAX_SECTION_WORDS = 900
68
+
69
+ export interface DiffChunk {
70
+ /** Repo-relative path. */
71
+ readonly file: string
72
+ readonly docType: CanonicalDocType
73
+ /** Empty string when the hunk added text with nothing removed. */
74
+ readonly removed: string
75
+ readonly added: string
76
+ /** The heading-delimited section the hunk's added lines landed in, current content. */
77
+ readonly sectionAfter: string
78
+ }
79
+
80
+ export type ExtractRefusal = 'bad-range' | 'unreadable-file'
81
+
82
+ export type DiffExtraction =
83
+ | { readonly kind: 'ok'; readonly chunks: readonly DiffChunk[] }
84
+ | {
85
+ readonly kind: 'refused'
86
+ readonly reason: ExtractRefusal
87
+ readonly message: string
88
+ }
89
+
90
+ async function gitRaw(
91
+ root: string,
92
+ args: string[],
93
+ ): Promise<string | undefined> {
94
+ const result = await $`git -C ${root} ${args}`.env(gitEnv()).quiet().nothrow()
95
+ return result.exitCode === 0 ? result.text() : undefined
96
+ }
97
+
98
+ interface RawHunk {
99
+ /** 1-based line number in the new (working tree) file where the hunk starts. */
100
+ readonly newStart: number
101
+ readonly added: readonly string[]
102
+ readonly removed: readonly string[]
103
+ }
104
+
105
+ const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
106
+
107
+ /**
108
+ * Reads a single-file, zero-context unified diff into its hunks. Ported from
109
+ * `hunks.py`'s header-and-accumulate loop.
110
+ */
111
+ function parseHunks(diffText: string): RawHunk[] {
112
+ const hunks: { newStart: number; added: string[]; removed: string[] }[] = []
113
+
114
+ for (const line of diffText.split('\n')) {
115
+ const header = HUNK_HEADER.exec(line)
116
+ if (header) {
117
+ hunks.push({ newStart: Number(header[1]), added: [], removed: [] })
118
+ continue
119
+ }
120
+
121
+ const current = hunks.at(-1)
122
+ if (!current) continue
123
+
124
+ if (line.startsWith('+') && !line.startsWith('+++')) {
125
+ current.added.push(line.slice(1))
126
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
127
+ current.removed.push(line.slice(1))
128
+ }
129
+ }
130
+
131
+ return hunks
132
+ }
133
+
134
+ function wordCount(text: string): number {
135
+ return text.split(/\s+/).filter(Boolean).length
136
+ }
137
+
138
+ const HEADING = /^(#{1,6})\s/
139
+
140
+ /**
141
+ * The heading-delimited section a zero-based line index sits in, in the
142
+ * current file content: the nearest heading at or above the line, through
143
+ * the next heading at the same level or shallower. Ported from `hunks.py`'s
144
+ * `_section_around`.
145
+ *
146
+ * Returns an empty string when the index sits above every heading, which a
147
+ * malformed doc or one carrying no heading at all can produce and which the
148
+ * caller reads as "no section to show," never as a refusal.
149
+ */
150
+ export function sectionAround(
151
+ lines: readonly string[],
152
+ lineIndex: number,
153
+ ): string {
154
+ let headingIndex: number | undefined
155
+ for (let j = Math.min(lineIndex, lines.length - 1); j >= 0; j--) {
156
+ if (HEADING.test(lines[j])) {
157
+ headingIndex = j
158
+ break
159
+ }
160
+ }
161
+ if (headingIndex === undefined) return ''
162
+
163
+ const level = HEADING.exec(lines[headingIndex])?.[1].length ?? 1
164
+ let end = lines.length
165
+ for (let k = headingIndex + 1; k < lines.length; k++) {
166
+ const match = HEADING.exec(lines[k])
167
+ if (match && match[1].length <= level) {
168
+ end = k
169
+ break
170
+ }
171
+ }
172
+
173
+ const section = lines.slice(headingIndex, end).join('\n')
174
+ const words = section.split(/\s+/).filter(Boolean)
175
+ if (words.length <= MAX_SECTION_WORDS) return section
176
+
177
+ return `${words.slice(0, MAX_SECTION_WORDS).join(' ')} …[section truncated]`
178
+ }
179
+
180
+ /**
181
+ * Every diff-mode chunk in the range `ref..working tree`, across the
182
+ * requested canonical doc types.
183
+ *
184
+ * Reads the working tree rather than `HEAD`, matching `listChangedFiles`: a
185
+ * `docs-fold` run checking a session's own edits sees them before they are
186
+ * committed. A file the range deleted contributes nothing, since there is no
187
+ * current section left to read a hunk's chunk against.
188
+ */
189
+ export async function extractDiffChunks(
190
+ root: string,
191
+ ref: string | undefined,
192
+ docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
193
+ ): Promise<DiffExtraction> {
194
+ const base = await resolveBaseRef(root, ref)
195
+ if (base === undefined) {
196
+ return {
197
+ kind: 'refused',
198
+ reason: 'bad-range',
199
+ message: `could not resolve a merge base for ${ref ?? 'the trunk'}`,
200
+ }
201
+ }
202
+
203
+ const changed = await listChangedFiles(root, base)
204
+ if (changed === undefined) {
205
+ return {
206
+ kind: 'refused',
207
+ reason: 'bad-range',
208
+ message: 'git could not list the files changed since the base',
209
+ }
210
+ }
211
+
212
+ const scoped = changed
213
+ .map((file) => ({ file, docType: docTypeOf(file) }))
214
+ .filter(
215
+ (entry): entry is { file: string; docType: CanonicalDocType } =>
216
+ entry.docType !== undefined && docTypes.includes(entry.docType),
217
+ )
218
+
219
+ const chunks: DiffChunk[] = []
220
+
221
+ for (const { file, docType } of scoped) {
222
+ const absolute = resolve(root, file)
223
+ if (!existsSync(absolute)) continue
224
+
225
+ let content: string
226
+ try {
227
+ content = readFileSync(absolute, 'utf8')
228
+ } catch {
229
+ return {
230
+ kind: 'refused',
231
+ reason: 'unreadable-file',
232
+ message: `could not read ${file}`,
233
+ }
234
+ }
235
+
236
+ const patch = await gitRaw(root, ['diff', '-U0', base, '--', file])
237
+ if (patch === undefined) continue
238
+
239
+ const lines = content.split('\n')
240
+ for (const hunk of parseHunks(patch)) {
241
+ const added = hunk.added.join('\n')
242
+ if (wordCount(added) < MIN_ADDED_WORDS) continue
243
+
244
+ chunks.push({
245
+ file,
246
+ docType,
247
+ removed: hunk.removed.join('\n'),
248
+ added,
249
+ sectionAfter: sectionAround(lines, hunk.newStart - 1),
250
+ })
251
+ }
252
+ }
253
+
254
+ return { kind: 'ok', chunks }
255
+ }
256
+
257
+ export interface SweepSection {
258
+ /** Repo-relative path. */
259
+ readonly file: string
260
+ readonly docType: CanonicalDocType
261
+ readonly heading: string
262
+ readonly body: string
263
+ }
264
+
265
+ /**
266
+ * Splits a file's headings into sweep sections, at H3 where a document
267
+ * carries H3 headings and at H2 otherwise, per the groundwork decision that
268
+ * a large H2 section splits further. An H2 carrying its own prose ahead of
269
+ * its first child H3 contributes that prose as a section of its own, headed
270
+ * by the H2, since it is content nothing else will sweep.
271
+ */
272
+ export function sweepSections(
273
+ lines: readonly string[],
274
+ ): { heading: string; body: string }[] {
275
+ const headings: { index: number; level: number }[] = []
276
+ for (let i = 0; i < lines.length; i++) {
277
+ const match = HEADING.exec(lines[i])
278
+ if (match && match[1].length >= 2 && match[1].length <= 3) {
279
+ headings.push({ index: i, level: match[1].length })
280
+ }
281
+ }
282
+
283
+ const sections: { heading: string; body: string }[] = []
284
+
285
+ // An H3 sitting before the document's first H2, or in a document that
286
+ // carries no H2 at all, has no enclosing section for the main loop below
287
+ // to attribute it to. Sweep it as a section of its own instead of
288
+ // dropping it, which is the shape `canon/context/development/scratch.md`
289
+ // takes: an H1 followed directly by eight H3s and no H2 anywhere.
290
+ const firstH2 = headings.findIndex((h) => h.level === 2)
291
+ const leadingBound = firstH2 === -1 ? headings.length : firstH2
292
+
293
+ for (let i = 0; i < leadingBound; i++) {
294
+ const { index, level } = headings[i]
295
+ if (level !== 3) continue
296
+
297
+ const nextBoundary = headings.find((h, k) => k > i && h.level <= 3)
298
+ const end = nextBoundary?.index ?? lines.length
299
+ sections.push(bodyOf(lines, index, end))
300
+ }
301
+
302
+ for (let i = 0; i < headings.length; i++) {
303
+ const { index, level } = headings[i]
304
+ if (level !== 2) continue
305
+
306
+ const nextH2 = headings.findIndex((h, j) => j > i && h.level === 2)
307
+ const sectionEnd = nextH2 === -1 ? lines.length : headings[nextH2].index
308
+
309
+ const firstChildH3 = headings.findIndex(
310
+ (h, j) => j > i && h.level === 3 && h.index < sectionEnd,
311
+ )
312
+
313
+ if (firstChildH3 === -1) {
314
+ sections.push(bodyOf(lines, index, sectionEnd))
315
+ continue
316
+ }
317
+
318
+ if (
319
+ firstChildH3 > i + 1 ||
320
+ hasContent(lines, index + 1, headings[firstChildH3].index)
321
+ ) {
322
+ sections.push(bodyOf(lines, index, headings[firstChildH3].index))
323
+ }
324
+
325
+ for (let j = firstChildH3; j < headings.length; j++) {
326
+ const { index: h3Index, level: h3Level } = headings[j]
327
+ if (h3Index >= sectionEnd) break
328
+ if (h3Level !== 3) continue
329
+
330
+ const nextBoundary = headings.find(
331
+ (h, k) => k > j && h.index < sectionEnd && h.level <= 3,
332
+ )
333
+ const end = nextBoundary?.index ?? sectionEnd
334
+ sections.push(bodyOf(lines, h3Index, end))
335
+ }
336
+ }
337
+
338
+ return sections
339
+ }
340
+
341
+ function hasContent(
342
+ lines: readonly string[],
343
+ start: number,
344
+ end: number,
345
+ ): boolean {
346
+ return lines.slice(start, end).some((line) => line.trim() !== '')
347
+ }
348
+
349
+ function bodyOf(
350
+ lines: readonly string[],
351
+ start: number,
352
+ end: number,
353
+ ): { heading: string; body: string } {
354
+ const headingText = lines[start].replace(/^#+\s*/, '').trim()
355
+ return {
356
+ heading: headingText,
357
+ body: lines.slice(start, end).join('\n').trim(),
358
+ }
359
+ }
360
+
361
+ export type SweepExtraction =
362
+ | { readonly kind: 'ok'; readonly sections: readonly SweepSection[] }
363
+ | {
364
+ readonly kind: 'refused'
365
+ readonly reason: ExtractRefusal
366
+ readonly message: string
367
+ }
368
+
369
+ function readSections(
370
+ path: string,
371
+ docType: CanonicalDocType,
372
+ rel: string,
373
+ ): SweepSection[] | undefined {
374
+ let content: string
375
+ try {
376
+ content = readFileSync(path, 'utf8')
377
+ } catch {
378
+ return undefined
379
+ }
380
+
381
+ return sweepSections(content.split('\n')).map((section) => ({
382
+ file: rel,
383
+ docType,
384
+ ...section,
385
+ }))
386
+ }
387
+
388
+ /**
389
+ * Every sweep-mode section across the requested canonical doc types.
390
+ *
391
+ * `context` and `wireframes` resolve through `resolveFolders`, the same
392
+ * folder discovery `canon context audit` uses, so a domain split into a
393
+ * folder of its own is swept the same way it is audited. The three
394
+ * single-file types resolve through `surfaceDir`, agreeing with
395
+ * `architectureRel`'s own resolution.
396
+ */
397
+ export async function extractSweepSections(
398
+ root: string,
399
+ docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
400
+ ): Promise<SweepExtraction> {
401
+ const sections: SweepSection[] = []
402
+
403
+ const multi = MULTI_DOC_TYPES.filter((entry) => docTypes.includes(entry.type))
404
+
405
+ if (multi.length > 0) {
406
+ const { folders } = await resolveFolders(
407
+ root,
408
+ multi.map((entry) => entry.name),
409
+ )
410
+ for (const folder of folders) {
411
+ const docType = multi.find((entry) => entry.name === folder.name)?.type
412
+ if (!docType) continue
413
+
414
+ for (const path of folder.entries) {
415
+ const rel = relative(root, path)
416
+ const found = readSections(path, docType, rel)
417
+ if (found === undefined) {
418
+ return {
419
+ kind: 'refused',
420
+ reason: 'unreadable-file',
421
+ message: `could not read ${rel}`,
422
+ }
423
+ }
424
+ sections.push(...found)
425
+ }
426
+ }
427
+ }
428
+
429
+ const single = SINGLE_DOC_TYPES.filter((candidate) =>
430
+ docTypes.includes(candidate.type),
431
+ )
432
+
433
+ for (const { type, entry } of single) {
434
+ const path = surfaceDir(root, entry)
435
+ if (!existsSync(path)) continue
436
+
437
+ const rel = relative(root, path)
438
+ const found = readSections(path, type, rel)
439
+ if (found === undefined) {
440
+ return {
441
+ kind: 'refused',
442
+ reason: 'unreadable-file',
443
+ message: `could not read ${rel}`,
444
+ }
445
+ }
446
+ sections.push(...found)
447
+ }
448
+
449
+ return { kind: 'ok', sections }
450
+ }