@erclx/canon 5.48.0 → 5.50.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 (89) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
  4. package/claude/skills/canvas-board/SKILL.md +8 -9
  5. package/claude/skills/context-fold/references/context.md +4 -3
  6. package/claude/skills/design-record/REQUIREMENT.md +33 -0
  7. package/claude/skills/design-record/SKILL.md +84 -0
  8. package/claude/skills/design-record/references/folder.md +69 -0
  9. package/claude/skills/design-taste/SKILL.md +1 -1
  10. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
  11. package/claude/skills/markdown-craft/SKILL.md +6 -1
  12. package/claude/skills/markdown-craft/references/markdown.md +3 -3
  13. package/claude/skills/markdown-craft/references/tables.md +30 -0
  14. package/claude/skills/pick-by-eye/SKILL.md +10 -8
  15. package/claude/skills/plan-feature/SKILL.md +31 -16
  16. package/claude/skills/plan-feature/references/batches.md +5 -5
  17. package/claude/skills/plan-feature/references/plan.md +82 -33
  18. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  19. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  20. package/claude/skills/plan-intake/SKILL.md +8 -0
  21. package/claude/skills/plan-intake/references/intake.md +9 -1
  22. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  23. package/claude/skills/plan-ready/SKILL.md +3 -3
  24. package/claude/skills/plan-ready/references/assembly.md +7 -7
  25. package/claude/skills/plan-ready/references/ready.md +8 -6
  26. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  27. package/claude/skills/review-ui/SKILL.md +4 -11
  28. package/claude/skills/review-ui/references/close-out.md +1 -1
  29. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  30. package/claude/skills/review-ui/references/post.md +24 -3
  31. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  32. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  33. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  34. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  35. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  36. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  37. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  38. package/claude/skills/role-planner/SKILL.md +1 -1
  39. package/claude/skills/role-worker/SKILL.md +4 -2
  40. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  41. package/claude/skills/session-map/references/session.md +1 -1
  42. package/claude/skills/session-worktree/SKILL.md +1 -1
  43. package/claude/skills/task-board/references/board.md +2 -2
  44. package/claude/skills/task-board/references/tasks.md +1 -1
  45. package/docs/agents/index.md +1 -1
  46. package/docs/agents/markdown-audit.md +5 -2
  47. package/docs/agents/records.md +1 -1
  48. package/docs/target/migrations.md +7 -0
  49. package/docs/target/projects.md +1 -1
  50. package/docs/workflow/parallel-features.md +2 -2
  51. package/docs/workflow/skill-map.md +2 -1
  52. package/docs/workflow/visual-design-workflow.md +2 -1
  53. package/governance/ledger/ui/design-reference.md +1 -0
  54. package/governance/rules/ui/design-reference.md +1 -0
  55. package/package.json +1 -1
  56. package/src/claude/cases/misc.ts +5 -0
  57. package/src/commands/claude.ts +26 -1
  58. package/src/commands/gate.ts +57 -16
  59. package/src/commands/markdown.ts +98 -19
  60. package/src/commands/pr.ts +411 -23
  61. package/src/commands/records.ts +45 -8
  62. package/src/commands/tasks.ts +12 -0
  63. package/src/context/audit.ts +6 -5
  64. package/src/design/budget.ts +1 -1
  65. package/src/design/components.ts +1 -1
  66. package/src/design/document.ts +1 -1
  67. package/src/design/regen.ts +1 -1
  68. package/src/design/render.ts +1 -1
  69. package/src/gate/hand-suite.ts +52 -0
  70. package/src/gate/measures.ts +9 -2
  71. package/src/gate/sequencer.ts +17 -8
  72. package/src/hooks/post-merge.ts +11 -0
  73. package/src/markdown/gated.ts +5 -7
  74. package/src/markdown/scope.ts +60 -10
  75. package/src/markdown/structure.ts +1 -1
  76. package/src/markdown/tables.ts +150 -0
  77. package/src/pr/drive-copy.ts +218 -0
  78. package/src/pr/local.ts +26 -0
  79. package/src/pr/rest.ts +2 -0
  80. package/src/records/structure.ts +147 -0
  81. package/src/records/validate.ts +113 -12
  82. package/src/tasks/archive.ts +137 -4
  83. package/src/tasks/board.ts +68 -13
  84. package/src/tasks/validate.ts +54 -14
  85. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
  86. package/tooling/claude/reference.md +1 -0
  87. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
  88. package/tooling/claude/seeds/CLAUDE.md +9 -0
  89. /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
@@ -0,0 +1,218 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { cpSync, existsSync, readFileSync, rmSync, statSync } from 'node:fs'
3
+ import { isAbsolute, join } from 'node:path'
4
+ import { $ } from 'bun'
5
+ import { gitEnv } from '@/git/env'
6
+
7
+ /** Where a project names the folders its running preview saves into. */
8
+ export const PREVIEW_CONFIG_REL = join('canon', 'config', 'preview.json')
9
+
10
+ /** The variable a preview server reads for the copy root when the project names none. */
11
+ export const DEFAULT_PREVIEW_ENV = 'CANON_PREVIEW_ROOT'
12
+
13
+ /** The type vocabulary of `git-branch`'s branch standard, which the slug transform drops. */
14
+ const BRANCH_TYPES = new Set([
15
+ 'feat',
16
+ 'fix',
17
+ 'refactor',
18
+ 'docs',
19
+ 'chore',
20
+ 'perf',
21
+ 'test',
22
+ 'style',
23
+ 'build',
24
+ 'ci',
25
+ 'revert',
26
+ ])
27
+
28
+ export interface PreviewConfig {
29
+ readonly writes: readonly string[]
30
+ readonly env: string
31
+ }
32
+
33
+ export type PreviewConfigRead =
34
+ | { readonly kind: 'absent' }
35
+ | { readonly kind: 'read'; readonly config: PreviewConfig }
36
+ | {
37
+ readonly kind: 'refused'
38
+ readonly reason: 'bad-config' | 'unsafe-path'
39
+ readonly detail: string
40
+ }
41
+
42
+ export interface StatusEntry {
43
+ readonly path: string
44
+ readonly status: string
45
+ /** The content digest, or null where no regular file stands at the path. */
46
+ readonly digest: string | null
47
+ }
48
+
49
+ /**
50
+ * The branch-to-slug transform every branch-derived name shares: a leading
51
+ * type segment comes off when a further segment follows it, and each
52
+ * remaining `/` becomes `-`. The worker reads its branch off the worktree and
53
+ * the reviewer off the pull request's head ref, so this one function is what
54
+ * lands both on the same folder.
55
+ */
56
+ export function branchSlug(branch: string): string {
57
+ const segments = branch.split('/')
58
+ const rest =
59
+ segments.length > 1 && BRANCH_TYPES.has(segments[0] ?? '')
60
+ ? segments.slice(1)
61
+ : segments
62
+ return rest.join('-')
63
+ }
64
+
65
+ function isContained(path: string): boolean {
66
+ return (
67
+ path.length > 0 && !isAbsolute(path) && !path.split(/[\\/]/).includes('..')
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Reads `canon/config/preview.json` as `{ "writes": [...], "env": "<NAME>" }`.
73
+ * A file that does not parse refuses rather than reading as absent, since a
74
+ * typo there would otherwise drive the real folders while the pass reports a
75
+ * copy. A `writes` path that is absolute or climbs with `..` refuses, so the
76
+ * copy never reads or writes outside its two roots.
77
+ */
78
+ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
79
+ const path = join(worktree, PREVIEW_CONFIG_REL)
80
+ if (!existsSync(path)) return { kind: 'absent' }
81
+
82
+ let parsed: unknown
83
+ try {
84
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
85
+ } catch {
86
+ return {
87
+ kind: 'refused',
88
+ reason: 'bad-config',
89
+ detail: `${PREVIEW_CONFIG_REL} does not parse as JSON.`,
90
+ }
91
+ }
92
+ if (typeof parsed !== 'object' || parsed === null) {
93
+ return {
94
+ kind: 'refused',
95
+ reason: 'bad-config',
96
+ detail: `${PREVIEW_CONFIG_REL} is not an object.`,
97
+ }
98
+ }
99
+
100
+ const { writes, env } = parsed as Record<string, unknown>
101
+ if (
102
+ !Array.isArray(writes) ||
103
+ !writes.every((item): item is string => typeof item === 'string')
104
+ ) {
105
+ return {
106
+ kind: 'refused',
107
+ reason: 'bad-config',
108
+ detail: `${PREVIEW_CONFIG_REL} carries no "writes" list of paths.`,
109
+ }
110
+ }
111
+ if (env !== undefined && (typeof env !== 'string' || env === '')) {
112
+ return {
113
+ kind: 'refused',
114
+ reason: 'bad-config',
115
+ detail: `${PREVIEW_CONFIG_REL} names an "env" that is not a variable name.`,
116
+ }
117
+ }
118
+
119
+ const unsafe = writes.find((item) => !isContained(item))
120
+ if (unsafe !== undefined) {
121
+ return {
122
+ kind: 'refused',
123
+ reason: 'unsafe-path',
124
+ detail: `${PREVIEW_CONFIG_REL} names "${unsafe}", which leaves the worktree.`,
125
+ }
126
+ }
127
+
128
+ return {
129
+ kind: 'read',
130
+ config: { writes, env: env ?? DEFAULT_PREVIEW_ENV },
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Replaces the copy root with a fresh copy of each declared path, so the last
136
+ * pass's drives never reach the next one. A path the worktree does not hold
137
+ * is named rather than failing the copy.
138
+ */
139
+ export function buildDriveCopy(
140
+ worktree: string,
141
+ copyRoot: string,
142
+ writes: readonly string[],
143
+ ): { readonly copied: readonly string[]; readonly missing: readonly string[] } {
144
+ rmSync(copyRoot, { recursive: true, force: true })
145
+ const copied: string[] = []
146
+ const missing: string[] = []
147
+ for (const path of writes) {
148
+ const source = join(worktree, path)
149
+ if (!existsSync(source)) {
150
+ missing.push(path)
151
+ continue
152
+ }
153
+ cpSync(source, join(copyRoot, path), { recursive: true })
154
+ copied.push(path)
155
+ }
156
+ return { copied, missing }
157
+ }
158
+
159
+ function digestOf(path: string): string | null {
160
+ try {
161
+ if (!statSync(path).isFile()) return null
162
+ return createHash('sha1').update(readFileSync(path)).digest('hex')
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Every entry `git status` reports, untracked files listed one by one so a
170
+ * file a drive adds inside a tracked folder names itself, each with a content
171
+ * digest so a file already dirty at the baseline still reads as changed when
172
+ * a drive writes it again. Undefined when git cannot read the tree.
173
+ */
174
+ export async function readWorktreeStatus(
175
+ worktree: string,
176
+ ): Promise<readonly StatusEntry[] | undefined> {
177
+ const result =
178
+ await $`git -C ${worktree} status --porcelain=v1 -z --untracked-files=all`
179
+ .env(gitEnv())
180
+ .quiet()
181
+ .nothrow()
182
+ if (result.exitCode !== 0) return undefined
183
+
184
+ const tokens = result.stdout.toString().split('\0')
185
+ const entries: StatusEntry[] = []
186
+ for (let index = 0; index < tokens.length; index++) {
187
+ const token = tokens[index] ?? ''
188
+ if (token.length < 4) continue
189
+ const status = token.slice(0, 2)
190
+ const path = token.slice(3)
191
+ // A rename or copy carries its source as the next token.
192
+ if (status.includes('R') || status.includes('C')) index++
193
+ entries.push({ path, status, digest: digestOf(join(worktree, path)) })
194
+ }
195
+ return entries
196
+ }
197
+
198
+ /**
199
+ * The paths whose status or content differs from the baseline, in the order
200
+ * git reports them. An entry gone since the baseline is not reported, since a
201
+ * worker committing its own edit mid-pass clears it the same way.
202
+ */
203
+ export function changedSince(
204
+ baseline: readonly StatusEntry[],
205
+ current: readonly StatusEntry[],
206
+ ): readonly string[] {
207
+ const before = new Map(baseline.map((entry) => [entry.path, entry]))
208
+ return current
209
+ .filter((entry) => {
210
+ const prior = before.get(entry.path)
211
+ return (
212
+ prior === undefined ||
213
+ prior.status !== entry.status ||
214
+ prior.digest !== entry.digest
215
+ )
216
+ })
217
+ .map((entry) => entry.path)
218
+ }
package/src/pr/local.ts CHANGED
@@ -64,3 +64,29 @@ export function dropAddressLine(body: string): string | undefined {
64
64
  if (isGapAbove && (lines[first] ?? '').trim() === '') lines.splice(first, 1)
65
65
  return lines.join('\n')
66
66
  }
67
+
68
+ /**
69
+ * A loopback URL with any port and path, and the backticks or angle brackets a
70
+ * pass may have wrapped it in. These hosts resolve on one machine for one
71
+ * session, so no later reader of the thread can open them.
72
+ */
73
+ const LOOPBACK_URL =
74
+ /[`<]?https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/[^\s`>,]*)?[`>]?/g
75
+
76
+ /**
77
+ * Rewrites the loopback address on a UI review's `Driven at` line to "a local
78
+ * preview", keeping the head and the renderer. Only that line is read, so a
79
+ * loopback URL a box reason quotes from the page stays as content. Undefined
80
+ * when no line changes, which is also what a second mask of the same body
81
+ * returns.
82
+ */
83
+ export function maskLocalAddress(body: string): string | undefined {
84
+ let changed = false
85
+ const lines = body.split('\n').map((line) => {
86
+ if (!line.startsWith('Driven at ')) return line
87
+ const masked = line.replace(LOOPBACK_URL, 'a local preview')
88
+ if (masked !== line) changed = true
89
+ return masked
90
+ })
91
+ return changed ? lines.join('\n') : undefined
92
+ }
package/src/pr/rest.ts CHANGED
@@ -25,6 +25,8 @@ export interface RestPull {
25
25
 
26
26
  /** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
27
27
  export interface RestReview {
28
+ /** The id the update call `pulls/<n>/reviews/<id>` names. */
29
+ readonly id?: number
28
30
  readonly body?: string | null
29
31
  readonly commit_id?: string | null
30
32
  readonly submitted_at?: string | null
@@ -0,0 +1,147 @@
1
+ import { type DensityKind, densityFindings } from '@/markdown/density'
2
+ import { bodyLines } from '@/markdown/scan'
3
+ import { isSectionMarker } from '@/markdown/structure'
4
+
5
+ /**
6
+ * The criteria `plan.md`'s `### Structure` states for every hand-written
7
+ * record. `S6`, a full path in a section a parser reads, is not here, since
8
+ * `canon tasks plan-reach` already reports a bare filename as unclaimed.
9
+ */
10
+ export const STRUCTURE_CRITERIA = ['S1', 'S2', 'S3', 'S4', 'S5'] as const
11
+
12
+ export type StructureCriterion = (typeof STRUCTURE_CRITERIA)[number]
13
+
14
+ /** One place a record misses a criterion, or an intake field spilling past its line. */
15
+ export interface StructureEntry {
16
+ readonly criterion: StructureCriterion | 'intake-spill'
17
+ /** The line as an editor numbers it, frontmatter included. */
18
+ readonly line: number
19
+ readonly message: string
20
+ }
21
+
22
+ /**
23
+ * The density limits a record criterion takes over. The paragraph limits are
24
+ * left out, since the record standard caps no paragraph and names no run.
25
+ */
26
+ const CRITERION_OF: Partial<Record<DensityKind, StructureCriterion>> = {
27
+ 'bullet-run': 'S2',
28
+ 'h3-opening': 'S3',
29
+ 'bullet-length': 'S4',
30
+ 'heading-depth': 'S5',
31
+ }
32
+
33
+ const MESSAGE: Record<StructureCriterion, string> = {
34
+ S1: 'is a bold label standing alone as a section marker. Write it as an H2.',
35
+ S2: 'opens a run of more than 5 plain bullets. Group them under H3s.',
36
+ S3: 'opens with no lead sentence carrying what its bullets share.',
37
+ S4: 'is a bullet past 200 characters. Make it a short paragraph, or split it.',
38
+ S5: 'is an H4 or deeper. Fold it into its H3.',
39
+ }
40
+
41
+ /**
42
+ * Every place a record misses `S1` to `S5`, in line order.
43
+ *
44
+ * `S2` to `S5` are the markdown density limits read on the record's own
45
+ * terms, so a labeled field run stays exempt from `S2` here as it does there.
46
+ * `S1` is any line `isSectionMarker` reads as a bold section marker, which an
47
+ * old plan carries on every section and still parses through.
48
+ */
49
+ export function structureFindings(text: string): StructureEntry[] {
50
+ const lines = bodyLines(text)
51
+ const found: StructureEntry[] = []
52
+
53
+ for (const line of lines) {
54
+ if (!line.fenced && isSectionMarker(line.text)) {
55
+ found.push({ criterion: 'S1', line: line.number, message: MESSAGE.S1 })
56
+ }
57
+ }
58
+
59
+ for (const density of densityFindings(lines)) {
60
+ const criterion = CRITERION_OF[density.kind]
61
+ if (criterion) {
62
+ found.push({ criterion, line: density.line, message: MESSAGE[criterion] })
63
+ }
64
+ }
65
+
66
+ return found.sort((a, b) => a.line - b.line)
67
+ }
68
+
69
+ /** An item heading, as `readItems` in `src/intake/items.ts` reads one. */
70
+ const ITEM_HEADING = /^###\s+\d+[a-z]*\.\s/i
71
+
72
+ /** A bolded field bullet, the one line `readItems` takes from a field. */
73
+ const FIELD = /^-\s+\*\*([^*]+):\*\*/
74
+
75
+ /** Any heading, which closes the item above it. */
76
+ const SECTION = /^#{1,6}\s/
77
+
78
+ /**
79
+ * Every block of content sitting under an intake field rather than on its
80
+ * line, one entry at the block's first line.
81
+ *
82
+ * `canon intake list` reads a field's own line and nothing under it, so a
83
+ * nested list or a paragraph below a field never reaches the operator
84
+ * answering in chat. A trailing paragraph after the last field is reported
85
+ * too, since it is dropped the same way.
86
+ */
87
+ export function intakeSpill(text: string): StructureEntry[] {
88
+ const found: StructureEntry[] = []
89
+ let item: string | undefined
90
+ let field: string | undefined
91
+ let spilling = false
92
+
93
+ for (const line of bodyLines(text)) {
94
+ if (line.fenced) continue
95
+
96
+ const heading = ITEM_HEADING.exec(line.text)
97
+ if (heading || SECTION.test(line.text)) {
98
+ item = heading
99
+ ? line.text.replace(/^###\s+/, '').split('.')[0]
100
+ : undefined
101
+ field = undefined
102
+ spilling = false
103
+ continue
104
+ }
105
+
106
+ if (item === undefined || line.text.trim() === '') continue
107
+
108
+ const match = FIELD.exec(line.text)
109
+ if (match) {
110
+ field = match[1].trim()
111
+ spilling = false
112
+ continue
113
+ }
114
+
115
+ if (spilling) continue
116
+ spilling = true
117
+
118
+ const under = field ? `the ${field} field of item ${item}` : `item ${item}`
119
+ found.push({
120
+ criterion: 'intake-spill',
121
+ line: line.number,
122
+ message: `sits under ${under}, and canon intake list reads only a field's own line. Keep the content on the line, or split the item.`,
123
+ })
124
+ }
125
+
126
+ return found
127
+ }
128
+
129
+ /**
130
+ * The structure report for an intake cluster file. Item headings open on
131
+ * their fields by the template's design, so `S3` skips them, and a field
132
+ * carries its whole content on one line, so `S4` skips it.
133
+ */
134
+ export function intakeStructure(text: string): StructureEntry[] {
135
+ const lines = text.split('\n')
136
+ const exempt = (entry: StructureEntry): boolean => {
137
+ const source = lines[entry.line - 1] ?? ''
138
+ if (entry.criterion === 'S3') return ITEM_HEADING.test(source)
139
+ if (entry.criterion === 'S4') return FIELD.test(source)
140
+ return false
141
+ }
142
+
143
+ return [
144
+ ...structureFindings(text).filter((entry) => !exempt(entry)),
145
+ ...intakeSpill(text),
146
+ ].sort((a, b) => a.line - b.line)
147
+ }
@@ -3,6 +3,11 @@ import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
5
  import { linesOutsideFences } from '@/markdown/scan'
6
+ import {
7
+ intakeStructure,
8
+ type StructureEntry,
9
+ structureFindings,
10
+ } from '@/records/structure'
6
11
  import {
7
12
  recordDir as resolveRecordDir,
8
13
  recordDirs as resolveRecordDirs,
@@ -27,7 +32,11 @@ export type RecordKind = (typeof RECORD_KINDS)[number]
27
32
  * union covering only what the walk returns would type a record the command can
28
33
  * emit as impossible.
29
34
  */
30
- export const VALIDATE_REFUSALS = ['no-folder', 'unknown-kind'] as const
35
+ export const VALIDATE_REFUSALS = [
36
+ 'no-folder',
37
+ 'no-record',
38
+ 'unknown-kind',
39
+ ] as const
31
40
 
32
41
  export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
33
42
 
@@ -61,11 +70,24 @@ export interface Finding {
61
70
  readonly message: string
62
71
  }
63
72
 
73
+ /**
74
+ * A place a record misses a structure criterion. It sits apart from `Finding`
75
+ * so it never sets the exit, since every record written before the criteria
76
+ * misses `S1` and a gate on it would fail them all.
77
+ */
78
+ export interface StructureFinding extends StructureEntry {
79
+ /** The record the entry sits in, relative to the validated folder. */
80
+ readonly record: string
81
+ /** The file inside a folder record, or the record itself for a plan. */
82
+ readonly subject: string
83
+ }
84
+
64
85
  export interface ValidateReport {
65
86
  readonly ok: true
66
87
  readonly kind: RecordKind
67
88
  readonly records: number
68
89
  readonly findings: readonly Finding[]
90
+ readonly structure: readonly StructureFinding[]
69
91
  }
70
92
 
71
93
  export interface ValidateRefused {
@@ -267,7 +289,7 @@ export function sectionMarker(line: string): PlanSection | undefined {
267
289
 
268
290
  /** The spelling a finding names, which is the one the standard's template ships. */
269
291
  export function preferredMarker(section: PlanSection): string {
270
- return section === 'Summary' ? '## Summary' : `**${section}:**`
292
+ return `## ${section}`
271
293
  }
272
294
 
273
295
  export function splitPlanSections(text: string): Map<string, string[]> {
@@ -915,6 +937,42 @@ const FOLDER_CHECK: Readonly<
915
937
  teach: checkWorkspace,
916
938
  }
917
939
 
940
+ /**
941
+ * The files of a folder record the structure report reads. A groundwork
942
+ * track's `evidence/`, `scripts/`, and `clones/` sit below the top level and
943
+ * hold copies and raw output, so only its index and numbered files are read.
944
+ * An intake folder's reserved files hold no items, so only clusters are.
945
+ */
946
+ const STRUCTURE_FILES: Readonly<
947
+ Partial<Record<RecordKind, (file: string) => boolean>>
948
+ > = {
949
+ groundwork: (file) => file === GROUNDWORK_INDEX || NUMBERED_FILE.test(file),
950
+ intake: (file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
951
+ }
952
+
953
+ async function folderStructure(
954
+ dir: string,
955
+ kind: RecordKind,
956
+ slug: string,
957
+ ): Promise<StructureFinding[]> {
958
+ const reads = STRUCTURE_FILES[kind]
959
+ if (!reads) return []
960
+
961
+ const report = kind === 'intake' ? intakeStructure : structureFindings
962
+ const files = (await listMarkdown(dir)).filter(reads)
963
+ const perFile = await Promise.all(
964
+ files.map(async (file) =>
965
+ report(await readFile(join(dir, file), 'utf8')).map((entry) => ({
966
+ ...entry,
967
+ record: slug,
968
+ subject: file,
969
+ })),
970
+ ),
971
+ )
972
+
973
+ return perFile.flat()
974
+ }
975
+
918
976
  function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
919
977
  return { ok: false, reason, message }
920
978
  }
@@ -923,17 +981,42 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
923
981
  async function validateFiles(
924
982
  dir: string,
925
983
  kind: RecordKind,
984
+ files: readonly string[],
926
985
  check: (name: string, text: string) => Finding[],
927
986
  ): Promise<ValidateReport> {
928
- const files = await listMarkdown(dir)
929
-
930
987
  const perFile = await Promise.all(
931
- files.map(async (file) =>
932
- check(file, await readFile(join(dir, file), 'utf8')),
933
- ),
988
+ files.map(async (file) => {
989
+ const text = await readFile(join(dir, file), 'utf8')
990
+ return {
991
+ findings: check(file, text),
992
+ structure: structureFindings(text).map((entry) => ({
993
+ ...entry,
994
+ record: file,
995
+ subject: file,
996
+ })),
997
+ }
998
+ }),
934
999
  )
935
1000
 
936
- return { ok: true, kind, records: files.length, findings: perFile.flat() }
1001
+ return {
1002
+ ok: true,
1003
+ kind,
1004
+ records: files.length,
1005
+ findings: perFile.flatMap((one) => one.findings),
1006
+ structure: perFile.flatMap((one) => one.structure),
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Narrows a listing to the one record a caller named, by a plan's stem or a
1012
+ * folder's slug, so a writing skill reads its own record and not every old one.
1013
+ */
1014
+ function narrow(
1015
+ names: readonly string[],
1016
+ record: string | undefined,
1017
+ ): readonly string[] {
1018
+ if (record === undefined) return names
1019
+ return names.filter((name) => name === record || name === `${record}.md`)
937
1020
  }
938
1021
 
939
1022
  /**
@@ -944,6 +1027,7 @@ async function validateFiles(
944
1027
  export async function validateRecords(
945
1028
  root: string,
946
1029
  kind: RecordKind,
1030
+ record?: string,
947
1031
  ): Promise<ValidateOutcome> {
948
1032
  const dir = recordsDir(root, kind)
949
1033
 
@@ -954,13 +1038,30 @@ export async function validateRecords(
954
1038
  )
955
1039
  }
956
1040
 
957
- if (kind === 'plans') return validateFiles(dir, kind, checkPlan)
1041
+ const names = narrow(
1042
+ kind === 'plans' ? await listMarkdown(dir) : await listFolders(dir),
1043
+ record,
1044
+ )
1045
+
1046
+ if (record !== undefined && names.length === 0) {
1047
+ return refuse('no-record', `No ${kind} record named ${record} in ${dir}.`)
1048
+ }
1049
+
1050
+ if (kind === 'plans') return validateFiles(dir, kind, names, checkPlan)
958
1051
 
959
- const folders = await listFolders(dir)
960
1052
  const check = FOLDER_CHECK[kind]
961
1053
  const perFolder = await Promise.all(
962
- folders.map((slug) => check(join(dir, slug), slug)),
1054
+ names.map(async (slug) => ({
1055
+ findings: await check(join(dir, slug), slug),
1056
+ structure: await folderStructure(join(dir, slug), kind, slug),
1057
+ })),
963
1058
  )
964
1059
 
965
- return { ok: true, kind, records: folders.length, findings: perFolder.flat() }
1060
+ return {
1061
+ ok: true,
1062
+ kind,
1063
+ records: names.length,
1064
+ findings: perFolder.flatMap((one) => one.findings),
1065
+ structure: perFolder.flatMap((one) => one.structure),
1066
+ }
966
1067
  }