@erclx/canon 5.47.0 → 5.49.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 (67) 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/git-pr/references/closing-issues.md +2 -0
  4. package/claude/skills/plan-feature/SKILL.md +18 -15
  5. package/claude/skills/plan-feature/references/batches.md +5 -5
  6. package/claude/skills/plan-feature/references/plan.md +82 -33
  7. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  8. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  9. package/claude/skills/plan-intake/SKILL.md +8 -0
  10. package/claude/skills/plan-intake/references/intake.md +9 -1
  11. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  12. package/claude/skills/plan-ready/SKILL.md +3 -3
  13. package/claude/skills/plan-ready/references/assembly.md +7 -7
  14. package/claude/skills/plan-ready/references/ready.md +8 -6
  15. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  16. package/claude/skills/review-ui/SKILL.md +7 -14
  17. package/claude/skills/review-ui/references/close-out.md +1 -1
  18. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  19. package/claude/skills/review-ui/references/post.md +36 -3
  20. package/claude/skills/review-ui/references/tick.md +4 -1
  21. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  22. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  23. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  24. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  30. package/claude/skills/role-planner/SKILL.md +1 -1
  31. package/claude/skills/role-reviewer/SKILL.md +1 -1
  32. package/claude/skills/role-worker/SKILL.md +4 -2
  33. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  34. package/claude/skills/session-map/references/session.md +1 -1
  35. package/claude/skills/task-board/references/board.md +2 -2
  36. package/claude/skills/task-board/references/tasks.md +5 -3
  37. package/claude/skills/task-complete/SKILL.md +1 -0
  38. package/claude/skills/task-complete/references/archive.md +2 -1
  39. package/docs/agents/migrate.md +2 -2
  40. package/docs/agents/records.md +1 -1
  41. package/docs/target/migrations.md +4 -2
  42. package/docs/workflow/operating-model.md +2 -2
  43. package/docs/workflow/parallel-features.md +2 -2
  44. package/package.json +1 -1
  45. package/src/commands/claude.ts +4 -0
  46. package/src/commands/migrate.ts +5 -4
  47. package/src/commands/pr.ts +712 -58
  48. package/src/commands/records.ts +45 -8
  49. package/src/commands/tasks.ts +21 -1
  50. package/src/hooks/post-merge.ts +11 -0
  51. package/src/migrate/session-defaults.ts +20 -5
  52. package/src/pr/drive-copy.ts +218 -0
  53. package/src/pr/lift.ts +19 -0
  54. package/src/pr/local.ts +26 -0
  55. package/src/pr/merge.ts +175 -0
  56. package/src/pr/rest.ts +3 -0
  57. package/src/pr/review-scope.ts +40 -1
  58. package/src/pr/tick.ts +21 -1
  59. package/src/records/structure.ts +147 -0
  60. package/src/records/validate.ts +113 -12
  61. package/src/tasks/archive.ts +154 -8
  62. package/src/tasks/board.ts +68 -13
  63. package/src/tasks/closes.ts +9 -5
  64. package/src/tasks/validate.ts +43 -4
  65. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
  66. package/tooling/claude/reference.md +3 -1
  67. package/tooling/claude/seeds/.claude/settings.json +1 -1
@@ -0,0 +1,175 @@
1
+ import type { ChecksGate } from '@/pr/lift'
2
+ import { readSection } from '@/pr/paths'
3
+ import { UI_PASS_MARKER } from '@/pr/tick'
4
+
5
+ /** Why the verb did not merge, in the order the refusals are read. */
6
+ export type MergeRefusal =
7
+ | 'pull-closed'
8
+ | 'pull-draft'
9
+ | 'missing-summary'
10
+ | 'missing-key-changes'
11
+ | 'ci-failing'
12
+ | 'ci-conflicted'
13
+ | 'ci-pending'
14
+ | 'head-moved'
15
+ | 'not-mergeable'
16
+ | 'gh-failed'
17
+
18
+ export interface MergePull {
19
+ readonly isOpen: boolean
20
+ readonly isDraft: boolean
21
+ readonly title: string
22
+ readonly body: string
23
+ /** The head the merge is pinned to, so a push after the read refuses. */
24
+ readonly head: string
25
+ }
26
+
27
+ export interface MergeRequest {
28
+ readonly title: string
29
+ readonly description: string
30
+ readonly sha: string
31
+ }
32
+
33
+ /** What the merge endpoint answered, with 409 and 405 told apart from a merge. */
34
+ export type MergeOutcome =
35
+ | { readonly kind: 'merged'; readonly sha: string }
36
+ | { readonly kind: 'head-moved' }
37
+ | { readonly kind: 'not-mergeable' }
38
+ | { readonly kind: 'failed' }
39
+
40
+ /**
41
+ * Everything the verb reads or writes, injected so every refusal runs without
42
+ * a network. A read that failed returns undefined, and the verb refuses
43
+ * `gh-failed` rather than reading absence as a pass.
44
+ */
45
+ export interface MergeApi {
46
+ readPull(): Promise<MergePull | undefined>
47
+ readChecks(head: string): Promise<ChecksGate | undefined>
48
+ putMerge(request: MergeRequest): Promise<MergeOutcome>
49
+ }
50
+
51
+ export interface MergeOptions {
52
+ readonly number: number
53
+ readonly isDryRun: boolean
54
+ }
55
+
56
+ export interface MergeRecord {
57
+ readonly reason: 'merged' | 'dry-run' | MergeRefusal
58
+ readonly isOk: boolean
59
+ readonly number: number
60
+ readonly title?: string
61
+ readonly description?: string
62
+ /** The squash commit, present only on a merge. */
63
+ readonly sha?: string
64
+ }
65
+
66
+ export type Composed =
67
+ | { readonly kind: 'composed'; readonly description: string }
68
+ | {
69
+ readonly kind: 'refused'
70
+ readonly reason: 'missing-summary' | 'missing-key-changes'
71
+ }
72
+
73
+ /** A section's text with the UI pass line dropped, or undefined when nothing is left. */
74
+ function keptSection(body: string, title: string): string | undefined {
75
+ const text = readSection(body, title)
76
+ ?.split('\n')
77
+ .filter((line) => !line.includes(UI_PASS_MARKER))
78
+ .join('\n')
79
+ .trim()
80
+ return text === undefined || text === ''
81
+ ? undefined
82
+ : `## ${title}\n\n${text}`
83
+ }
84
+
85
+ /**
86
+ * The squash description: Summary, Key changes, and Technical context, in that
87
+ * order, under their headings. Testing, the reviewer requests, and the UI pass
88
+ * line describe the review rather than the change, so none reaches the trunk.
89
+ * Technical context is optional, and an empty one leaves no bare heading.
90
+ */
91
+ export function composeDescription(body: string): Composed {
92
+ const summary = keptSection(body, 'Summary')
93
+ if (summary === undefined)
94
+ return { kind: 'refused', reason: 'missing-summary' }
95
+ const keyChanges = keptSection(body, 'Key changes')
96
+ if (keyChanges === undefined) {
97
+ return { kind: 'refused', reason: 'missing-key-changes' }
98
+ }
99
+ const context = keptSection(body, 'Technical context')
100
+ return {
101
+ kind: 'composed',
102
+ description: [summary, keyChanges, context]
103
+ .filter((block) => block !== undefined)
104
+ .join('\n\n'),
105
+ }
106
+ }
107
+
108
+ /** The suffix the web merge adds and the trunk log carries. */
109
+ export function mergeTitle(title: string, number: number): string {
110
+ return `${title} (#${number})`
111
+ }
112
+
113
+ function refused(
114
+ reason: MergeRefusal,
115
+ number: number,
116
+ message: { title?: string; description?: string } = {},
117
+ ): MergeRecord {
118
+ return { reason, isOk: false, number, ...message }
119
+ }
120
+
121
+ /**
122
+ * Squash-merges one ready pull request with a message carrying only the
123
+ * change. Every refusal is read before the one write, and the write sends the
124
+ * head it read as `sha`, so GitHub refuses rather than merging a commit pushed
125
+ * after the checks were read. The verb reads the draft flag and not the review
126
+ * comments, since a ready pull request already passed the lift's gates.
127
+ */
128
+ export async function merge(
129
+ api: MergeApi,
130
+ opts: MergeOptions,
131
+ ): Promise<MergeRecord> {
132
+ const { number } = opts
133
+
134
+ const pull = await api.readPull()
135
+ if (pull === undefined) return refused('gh-failed', number)
136
+ if (!pull.isOpen) return refused('pull-closed', number)
137
+ if (pull.isDraft) return refused('pull-draft', number)
138
+
139
+ const title = mergeTitle(pull.title, number)
140
+ const composed = composeDescription(pull.body)
141
+ if (composed.kind === 'refused') {
142
+ return refused(composed.reason, number, { title })
143
+ }
144
+ const message = { title, description: composed.description }
145
+
146
+ const checks = await api.readChecks(pull.head)
147
+ if (checks === undefined) return refused('gh-failed', number, message)
148
+ if (checks.state === 'failing') return refused('ci-failing', number, message)
149
+ if (checks.state !== 'passing') {
150
+ return refused(
151
+ checks.isConflicted ? 'ci-conflicted' : 'ci-pending',
152
+ number,
153
+ message,
154
+ )
155
+ }
156
+
157
+ if (opts.isDryRun)
158
+ return { reason: 'dry-run', isOk: true, number, ...message }
159
+
160
+ const outcome = await api.putMerge({ ...message, sha: pull.head })
161
+ if (outcome.kind === 'merged') {
162
+ return {
163
+ reason: 'merged',
164
+ isOk: true,
165
+ number,
166
+ ...message,
167
+ sha: outcome.sha,
168
+ }
169
+ }
170
+ return refused(
171
+ outcome.kind === 'failed' ? 'gh-failed' : outcome.kind,
172
+ number,
173
+ message,
174
+ )
175
+ }
package/src/pr/rest.ts CHANGED
@@ -13,6 +13,7 @@ import type { ReviewRow } from '@/pr/review-scope'
13
13
  /** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
14
14
  export interface RestPull {
15
15
  readonly number?: number
16
+ readonly title?: string
16
17
  readonly body?: string | null
17
18
  readonly head?: { readonly ref?: string; readonly sha?: string }
18
19
  readonly base?: { readonly ref?: string }
@@ -24,6 +25,8 @@ export interface RestPull {
24
25
 
25
26
  /** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
26
27
  export interface RestReview {
28
+ /** The id the update call `pulls/<n>/reviews/<id>` names. */
29
+ readonly id?: number
27
30
  readonly body?: string | null
28
31
  readonly commit_id?: string | null
29
32
  readonly submitted_at?: string | null
@@ -136,11 +136,21 @@ export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
136
136
 
137
137
  export type UiHeading = (typeof UI_HEADINGS)[number]
138
138
 
139
+ /** The four totals on a verdict's count line. */
140
+ export interface UiCounts {
141
+ readonly failed: number
142
+ readonly eyes: number
143
+ readonly passed: number
144
+ readonly notDriven: number
145
+ }
146
+
139
147
  export interface UiScope {
140
148
  readonly heading?: UiHeading
141
149
  readonly state: ReviewState
142
150
  /** The head the verdict covered, a full or abbreviated sha. */
143
151
  readonly commit?: string
152
+ /** The counts the verdict wrote, absent when its count line does not parse. */
153
+ readonly counts?: UiCounts
144
154
  /** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
145
155
  readonly source: ScopeSource
146
156
  }
@@ -152,6 +162,32 @@ export interface UiScope {
152
162
  */
153
163
  const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
154
164
 
165
+ /**
166
+ * The count line `review-ui`'s `post.md` fixes as the verdict's first line
167
+ * under the heading. It is model-written prose, so a body that drifts from the
168
+ * form reads as no counts and a caller treats that as unchecked.
169
+ */
170
+ const UI_COUNTS =
171
+ /^(\d+) failed · (\d+) need your eyes · (\d+) passed · (\d+) not driven$/
172
+
173
+ export function readUiCounts(body: string): UiCounts | undefined {
174
+ for (const raw of body.split('\n').slice(1, 6)) {
175
+ const match = UI_COUNTS.exec(raw.trim())
176
+ if (match === null) continue
177
+ const [, failed, eyes, passed, notDriven] = match.map(Number)
178
+ if (
179
+ failed === undefined ||
180
+ eyes === undefined ||
181
+ passed === undefined ||
182
+ notDriven === undefined
183
+ ) {
184
+ return undefined
185
+ }
186
+ return { failed, eyes, passed, notDriven }
187
+ }
188
+ return undefined
189
+ }
190
+
155
191
  function lastLine(body: string): string | undefined {
156
192
  const lines = body.split('\n')
157
193
 
@@ -185,9 +221,11 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
185
221
  const line = lastLine(row.body ?? '')
186
222
  const match = line === undefined ? null : UI_MARKER.exec(line)
187
223
  const marked = match?.[1]
224
+ const counts = readUiCounts(row.body ?? '')
225
+ const counted = counts === undefined ? {} : { counts }
188
226
 
189
227
  if (marked !== undefined) {
190
- return { heading, state, commit: marked, source: 'marker' }
228
+ return { heading, state, commit: marked, ...counted, source: 'marker' }
191
229
  }
192
230
 
193
231
  const oid = row.commit?.oid
@@ -195,6 +233,7 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
195
233
  heading,
196
234
  state,
197
235
  ...(oid === undefined || oid === '' ? {} : { commit: oid }),
236
+ ...counted,
198
237
  source: 'fallback',
199
238
  }
200
239
  }
package/src/pr/tick.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ChecklistBox,
2
3
  CHECKLIST_END,
3
4
  CHECKLIST_START,
4
5
  parseBoxLine,
@@ -7,7 +8,7 @@ import {
7
8
  writeBoxLine,
8
9
  } from '@/pr/evidence'
9
10
 
10
- const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
11
+ export const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
11
12
  const TESTING_HEADING = /^## Testing\s*$/
12
13
  const SECTION_HEADING = /^## /
13
14
  const REVIEWER_HEADING = /^## For the reviewer\s*$/
@@ -48,6 +49,25 @@ export function uiPassLine(summary: UiPassSummary): string {
48
49
  return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
49
50
  }
50
51
 
52
+ /**
53
+ * The driven boxes ticked at the head. A tick counts as this pass's only when
54
+ * stamped at the head, since an unstamped one is the author's claim and an
55
+ * older stamp names another head. Taste boxes are left out, as the body line
56
+ * leaves them out.
57
+ */
58
+ export function countTicksAtHead(
59
+ boxes: readonly ChecklistBox[],
60
+ head: string,
61
+ ): number {
62
+ return boxes.filter(
63
+ (box) =>
64
+ !box.isTaste &&
65
+ box.isTicked &&
66
+ box.stamp !== undefined &&
67
+ head.startsWith(box.stamp),
68
+ ).length
69
+ }
70
+
51
71
  /** The short sha the marked line names, absent when the body carries no such line. */
52
72
  export function readUiPassHead(body: string): string | undefined {
53
73
  const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
@@ -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
  }