@erclx/canon 5.25.0 → 5.26.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 (123) hide show
  1. package/README.md +2 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canvas/SKILL.md +1 -1
  4. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  5. package/claude/skills/context-fold/SKILL.md +8 -6
  6. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  7. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  8. package/claude/skills/design-taste/SKILL.md +4 -2
  9. package/claude/skills/design-taste/references/adopted.md +40 -0
  10. package/claude/skills/design-taste/references/kinds.md +1 -1
  11. package/claude/skills/design-taste/references/systems.md +1 -7
  12. package/claude/skills/design-taste/references/tells.md +2 -24
  13. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  14. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  15. package/claude/skills/document-health/SKILL.md +21 -0
  16. package/claude/skills/document-health/references/axes.md +12 -0
  17. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  18. package/claude/skills/draft-identity/SKILL.md +1 -1
  19. package/claude/skills/draft-screencast/SKILL.md +9 -7
  20. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  21. package/claude/skills/draft-slides/SKILL.md +31 -20
  22. package/claude/skills/git-issue/references/issue.md +10 -0
  23. package/claude/skills/git-pr/references/evidence.md +8 -0
  24. package/claude/skills/git-pr/references/labels.md +12 -0
  25. package/claude/skills/git-split/SKILL.md +19 -8
  26. package/claude/skills/markdown-propose/references/format.md +9 -0
  27. package/claude/skills/memory-review/references/later-phases.md +7 -0
  28. package/claude/skills/review-ui/references/post.md +4 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  30. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  31. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  32. package/claude/skills/search-craft/SKILL.md +3 -1
  33. package/claude/skills/search-craft/references/adopted.md +30 -0
  34. package/claude/skills/target-setup/references/detect.md +8 -0
  35. package/claude/skills/target-setup/references/indexes.md +13 -0
  36. package/claude/skills/target-setup/references/verify.md +8 -0
  37. package/claude/skills/test-first/SKILL.md +2 -0
  38. package/claude/skills/test-first/references/adopted.md +36 -0
  39. package/claude/skills/write-human/SKILL.md +1 -1
  40. package/claude/skills/write-human/references/machine-tells.md +13 -0
  41. package/docs/agents/canvas.md +3 -1
  42. package/docs/agents/capture.md +1 -1
  43. package/docs/agents/commands.md +3 -3
  44. package/docs/agents/index.md +2 -2
  45. package/docs/agents/records-stale.md +22 -9
  46. package/docs/agents/records.md +1 -1
  47. package/docs/agents/sandbox.md +1 -1
  48. package/docs/agents/skills-audit.md +10 -3
  49. package/docs/agents/slides.md +15 -3
  50. package/docs/workflow/ai-workflow.md +1 -1
  51. package/docs/workflow/index.md +1 -0
  52. package/docs/workflow/parallel-features.md +1 -1
  53. package/docs/workflow/skill-map-on-request.md +42 -0
  54. package/docs/workflow/skill-map.md +1 -34
  55. package/package.json +1 -1
  56. package/scripts/lib/sandbox-dispatch.sh +1 -1
  57. package/scripts/lib/sandbox-git.sh +1 -9
  58. package/scripts/lib/sandbox-path.sh +1 -1
  59. package/scripts/sandbox-hook.sh +99 -0
  60. package/src/canvas/capture.ts +1 -9
  61. package/src/canvas/client/inspector/color-field.tsx +368 -0
  62. package/src/canvas/client/inspector/fields.css +163 -22
  63. package/src/canvas/client/inspector/values.ts +123 -0
  64. package/src/canvas/client/inspector.tsx +46 -69
  65. package/src/capture/render.ts +45 -7
  66. package/src/capture/sources.ts +43 -0
  67. package/src/census/count.ts +1 -1
  68. package/src/claude/cases/workflow.ts +5 -0
  69. package/src/claude/seeds.ts +1 -1
  70. package/src/claude/skills-audit.ts +68 -14
  71. package/src/client-commands.ts +1 -1
  72. package/src/commands/claude.ts +27 -2
  73. package/src/commands/feedback.ts +3 -3
  74. package/src/commands/records.ts +88 -6
  75. package/src/commands/sandbox.ts +50 -10
  76. package/src/commands/slides.ts +31 -77
  77. package/src/context/audit.ts +1 -1
  78. package/src/design/tokens.ts +2 -18
  79. package/src/docs/read.ts +1 -1
  80. package/src/{github.ts → feedback/github.ts} +1 -1
  81. package/src/gate/measures.ts +6 -4
  82. package/src/gov/citations.ts +1 -1
  83. package/src/gov/payload.ts +1 -1
  84. package/src/gov/superseded.ts +2 -2
  85. package/src/markdown/ceiling.ts +1 -1
  86. package/src/record-root.ts +1 -0
  87. package/src/records/backup.ts +1 -0
  88. package/src/records/push-guard.ts +1 -1
  89. package/src/records/stale-canonical.ts +146 -0
  90. package/src/records/stale.ts +1 -1
  91. package/src/sandbox/hooks.ts +128 -0
  92. package/src/sandbox/provision.ts +596 -0
  93. package/src/sandbox/tree.ts +103 -4
  94. package/src/secrets/marker.ts +2 -2
  95. package/src/secrets/scan.ts +1 -1
  96. package/src/shipped/references.ts +2 -2
  97. package/src/slides/convert/export.ts +2 -16
  98. package/src/slides/decks.ts +110 -0
  99. package/src/standards/read.ts +1 -1
  100. package/src/sync/seeds-report.ts +1 -1
  101. package/src/tasks/answers.ts +1 -1
  102. package/src/tasks/archive.ts +1 -1
  103. package/src/teach/nav.ts +1 -1
  104. package/src/tooling/inject.ts +1 -1
  105. package/src/web/readme-citations.ts +1 -1
  106. package/standards/architecture.md +1 -0
  107. package/standards/canvas.md +1 -1
  108. package/standards/requirements.md +2 -0
  109. package/standards/skill-practice.md +2 -1
  110. package/standards/skill.md +1 -0
  111. package/scripts/manage-sandbox.sh +0 -472
  112. package/src/slides/layouts.ts +0 -478
  113. package/src/slides/parse.ts +0 -84
  114. package/src/slides/render.ts +0 -110
  115. package/src/slides/styles.ts +0 -96
  116. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  117. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  118. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  119. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  120. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  121. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  122. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  123. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
3
  import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
4
+ import { ColorField } from '@/canvas/client/inspector/color-field'
4
5
  import {
5
6
  Field,
6
7
  ReadOnlyField,
@@ -10,6 +11,7 @@ import { Section } from '@/canvas/client/inspector/section'
10
11
  import {
11
12
  clampScrub,
12
13
  displayValue,
14
+ parseColor,
13
15
  toCssValue,
14
16
  } from '@/canvas/client/inspector/values'
15
17
  import {
@@ -75,15 +77,10 @@ const TYPE_FIELDS: readonly StyleField[] = [
75
77
  { label: 'weight', glyph: 'Wt', property: 'font-weight' },
76
78
  ]
77
79
 
78
- const FILL_FIELDS: readonly StyleField[] = [
79
- { label: 'color', glyph: 'Fg', property: 'color' },
80
- {
81
- label: 'background',
82
- glyph: 'Bg',
83
- property: 'background-color',
84
- placeholder: 'None',
85
- },
86
- ]
80
+ const FILL_FIELDS = [
81
+ { label: 'color', property: 'color' },
82
+ { label: 'background', property: 'background-color' },
83
+ ] as const
87
84
 
88
85
  function inlineValue(element: Element, property: string): string {
89
86
  return 'style' in element
@@ -127,8 +124,10 @@ function isRawInline(element: Element, property: string): boolean {
127
124
  * an edit starts from what the operator sees.
128
125
  */
129
126
  function currentValue(element: Element, property: string): string {
130
- const inline = inlineValue(element, property)
131
- if (inline) return inline
127
+ return inlineValue(element, property) || computedValue(element, property)
128
+ }
129
+
130
+ function computedValue(element: Element, property: string): string {
132
131
  return (
133
132
  element.ownerDocument.defaultView
134
133
  ?.getComputedStyle(element)
@@ -136,6 +135,19 @@ function currentValue(element: Element, property: string): string {
136
135
  )
137
136
  }
138
137
 
138
+ /**
139
+ * The color a token paints in the frame, read off a probe rather than the
140
+ * token's text, since a token's value may itself be a `var()` or a mix.
141
+ */
142
+ function tokenPaint(doc: Document, name: string): string {
143
+ const probe = doc.createElement('span')
144
+ probe.style.setProperty('color', `var(${name})`)
145
+ ;(doc.body ?? doc.documentElement).append(probe)
146
+ const painted = computedValue(probe, 'color')
147
+ probe.remove()
148
+ return painted
149
+ }
150
+
139
151
  function holdsTextAlone(element: Element): boolean {
140
152
  const tag = element.tagName.toLowerCase()
141
153
  return element.children.length === 0 && !VOID.has(tag) && !isRawText(tag)
@@ -146,46 +158,6 @@ function elementName(element: Element): string {
146
158
  return [tag, ...element.classList].join('.')
147
159
  }
148
160
 
149
- interface TokenPickerProps {
150
- readonly label: string
151
- readonly initial: string
152
- readonly isBusy: boolean
153
- readonly onCommit: (value: string) => void
154
- }
155
-
156
- function TokenPicker({
157
- label,
158
- initial,
159
- isBusy,
160
- onCommit,
161
- }: TokenPickerProps): JSX.Element | null {
162
- const colors =
163
- tokens.value?.groups?.find((group) => group.kind === 'color')?.tokens ?? []
164
- if (colors.length === 0) return null
165
- return (
166
- <select
167
- class="token-picker"
168
- aria-label={`${label} token`}
169
- title="Pick a project token"
170
- value={
171
- colors.some((token) => initial === `var(${token.name})`) ? initial : ''
172
- }
173
- disabled={isBusy}
174
- onChange={(event) => {
175
- const { value } = event.currentTarget
176
- if (value !== '' && value !== initial) onCommit(value)
177
- }}
178
- >
179
- <option value="">Token</option>
180
- {colors.map((token) => (
181
- <option key={token.name} value={`var(${token.name})`}>
182
- {token.name}
183
- </option>
184
- ))}
185
- </select>
186
- )
187
- }
188
-
189
161
  function ElementFields({
190
162
  node,
191
163
  doc,
@@ -206,6 +178,10 @@ function ElementFields({
206
178
  const rect = node.getBoundingClientRect()
207
179
  const font = currentValue(node, 'font-family')
208
180
  const text = node.textContent ?? ''
181
+ const colorTokens =
182
+ tokens.value?.groups
183
+ ?.find((group) => group.kind === 'color')
184
+ ?.tokens.map((token) => token.name) ?? []
209
185
 
210
186
  /**
211
187
  * Previews into the frame's own inline style and writes through the same
@@ -272,25 +248,26 @@ function ElementFields({
272
248
  </Section>
273
249
  <Section title="Fill">
274
250
  {FILL_FIELDS.map((field) => {
275
- const initial = currentValue(node, field.property)
251
+ const painted = computedValue(node, field.property)
252
+ const inline = inlineValue(node, field.property)
253
+ /*
254
+ * An inline value the picker cannot read stays as written. Reading
255
+ * the computed color in its place would show a token-following
256
+ * value as a literal, and an opacity edit would freeze it.
257
+ */
276
258
  return (
277
- <div key={field.property} class="fill-row is-wide">
278
- {styleField(field)}
279
- {isRawInline(node, field.property) ? (
280
- <span
281
- class="raw"
282
- title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
283
- >
284
- raw
285
- </span>
286
- ) : null}
287
- <TokenPicker
288
- label={field.label}
289
- initial={initial}
290
- isBusy={isBusy}
291
- onCommit={commit(field.property)}
292
- />
293
- </div>
259
+ <ColorField
260
+ key={field.property}
261
+ label={field.label}
262
+ color={parseColor(inline || painted)}
263
+ text={inline || painted}
264
+ painted={painted}
265
+ tokens={colorTokens}
266
+ paint={(name) => tokenPaint(doc, name)}
267
+ isRaw={isRawInline(node, field.property)}
268
+ isBusy={isBusy}
269
+ onCommit={commit(field.property)}
270
+ />
294
271
  )
295
272
  })}
296
273
  </Section>
@@ -2,9 +2,13 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { basename, dirname } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { chromium } from 'playwright-core'
5
- import type { Browser, Page } from 'playwright-core'
5
+ import type { Browser, Locator, Page } from 'playwright-core'
6
6
  import type { CaptureSource } from '@/capture/sources'
7
- import { primaryFontFamily, resolveCaptureSources } from '@/capture/sources'
7
+ import {
8
+ isGenericFamily,
9
+ resolveCaptureSources,
10
+ textFamilies,
11
+ } from '@/capture/sources'
8
12
  import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
9
13
 
10
14
  /**
@@ -97,15 +101,22 @@ async function captureOne(
97
101
  }
98
102
 
99
103
  await page.evaluate(() => document.fonts.ready.then(() => undefined))
100
- const family = primaryFontFamily(
101
- await element.evaluate((node) => getComputedStyle(node).fontFamily),
102
- )
103
- if (family && !(await resolvesFont(page, family))) {
104
+ const declarations = await renderedTextFamilies(element)
105
+ const generic = declarations.find(isGenericFamily)
106
+ if (generic !== undefined) {
104
107
  return failed(
105
108
  source,
106
- `${family} is not installed, so the capture would rewrap against a fallback`,
109
+ `${generic.split(',')[0]?.trim()} is a generic family that resolves differently per machine, so name a real font`,
107
110
  )
108
111
  }
112
+ for (const family of textFamilies(declarations)) {
113
+ if (!(await resolvesFont(page, family))) {
114
+ return failed(
115
+ source,
116
+ `${family} is not installed, so the capture would rewrap against a fallback`,
117
+ )
118
+ }
119
+ }
109
120
 
110
121
  mkdirSync(dirname(source.pngPath), { recursive: true })
111
122
  const png = await element.screenshot({ omitBackground: true })
@@ -170,6 +181,33 @@ function failed(source: CaptureSource, reason: string): CaptureResult {
170
181
  return { status: 'failed', source: sourceIdentifier(source), reason }
171
182
  }
172
183
 
184
+ /**
185
+ * The computed families of the captured element and of each element under it
186
+ * holding a non-blank text node of its own that draws a box, which is the text
187
+ * the screenshot shows. Testing the text node's box rather than the element's
188
+ * keeps out `<style>`, `<script>`, `<title>`, and anything hidden, whose
189
+ * computed family is the browser default and would refuse every source that
190
+ * styles a descendant rather than the root, and keeps in text directly inside a
191
+ * `display: contents` element, which draws no box of its own.
192
+ *
193
+ * Returns nothing when no element holds text, since a font cannot rewrap text
194
+ * that is not there, so a container of iframes skips the probe.
195
+ */
196
+ async function renderedTextFamilies(element: Locator): Promise<string[]> {
197
+ return element.evaluate((root) => {
198
+ const range = document.createRange()
199
+ const holdsText = (node: Element): boolean =>
200
+ [...node.childNodes].some((child) => {
201
+ if (child.nodeType !== Node.TEXT_NODE) return false
202
+ if ((child.textContent ?? '').trim() === '') return false
203
+ range.selectNodeContents(child)
204
+ return range.getClientRects().length > 0
205
+ })
206
+ const texts = [root, ...root.querySelectorAll('*')].filter(holdsText)
207
+ return texts.map((node) => getComputedStyle(node).fontFamily)
208
+ })
209
+ }
210
+
173
211
  /**
174
212
  * Compares the family's text metrics against a family that cannot exist. Equal
175
213
  * widths mean the browser fell through to its default for both, which is the
@@ -66,3 +66,46 @@ export function primaryFontFamily(declaration: string): string {
66
66
  const first = declaration.split(',')[0]?.trim() ?? ''
67
67
  return first.replace(/^['"]|['"]$/g, '')
68
68
  }
69
+
70
+ /**
71
+ * The CSS generic family keywords. A generic names no font, so each machine
72
+ * maps it to whatever it has, and a capture measured against one cannot
73
+ * reproduce on another.
74
+ */
75
+ export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
76
+ 'serif',
77
+ 'sans-serif',
78
+ 'monospace',
79
+ 'cursive',
80
+ 'fantasy',
81
+ 'system-ui',
82
+ 'ui-serif',
83
+ 'ui-sans-serif',
84
+ 'ui-monospace',
85
+ 'ui-rounded',
86
+ 'math',
87
+ 'emoji',
88
+ 'fangsong',
89
+ ])
90
+
91
+ /**
92
+ * Whether the first family of a computed `font-family` is a generic keyword.
93
+ * Takes the raw declaration rather than `primaryFontFamily`'s result, since
94
+ * quoting is what separates the keyword from a family that happens to share its
95
+ * name, and the stripped form has lost it. Keywords match case-insensitively.
96
+ */
97
+ export function isGenericFamily(declaration: string): boolean {
98
+ const first = declaration.split(',')[0]?.trim() ?? ''
99
+ return GENERIC_FAMILIES.has(first.toLowerCase())
100
+ }
101
+
102
+ /**
103
+ * Reduces the computed `font-family` of each rendered text element to the
104
+ * distinct families a capture has to prove resolved, in document order, so a
105
+ * frame that sets its font on any ancestor of its text is checked against the
106
+ * face the text actually draws in.
107
+ */
108
+ export function textFamilies(declarations: readonly string[]): string[] {
109
+ const families = declarations.map(primaryFontFamily).filter(Boolean)
110
+ return [...new Set(families)]
111
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { extname, join } from 'node:path'
3
- import { isBinary } from '@/binary'
3
+ import { isBinary } from '@/sweep/binary'
4
4
  import { listRepositoryFiles } from '@/git-files'
5
5
 
6
6
  export type CensusRefusal = 'no-git'
@@ -146,6 +146,11 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
146
146
  'Which of our docs have gone stale, grown too long, or ended up in the wrong file?',
147
147
  expect: 'document-health',
148
148
  },
149
+ {
150
+ prompt:
151
+ 'Are our requirements and architecture docs still true, and when were they last reviewed?',
152
+ expect: 'document-health',
153
+ },
149
154
  {
150
155
  prompt: 'Open a new entry on the task board for this piece of work.',
151
156
  expect: 'task-board',
@@ -3,7 +3,7 @@ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
4
  import { copyPreservingMode } from '@/copy'
5
5
  import { creationRel, isRecordEntry } from '@/record-root'
6
- import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
6
+ import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
7
  import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
8
8
 
9
9
  const SEEDS_DIR = join('tooling', 'claude', 'seeds')
@@ -40,6 +40,21 @@ export const PRACTICE_SKILLS: readonly string[] = [
40
40
  join(SHIPPED_CORPUS, 'code-craft'),
41
41
  join(SHIPPED_CORPUS, 'build-in-slices'),
42
42
  join(SHIPPED_CORPUS, 'deprecation-migration'),
43
+ join(SHIPPED_CORPUS, 'search-craft'),
44
+ join(SHIPPED_CORPUS, 'video-craft'),
45
+ ]
46
+
47
+ /**
48
+ * Every skill carrying the source ledger, being the practice skills and the
49
+ * skills that teach a craft without the practice closing sections. Kept apart
50
+ * from `PRACTICE_SKILLS` so citing sources never forces those sections onto a
51
+ * skill whose body is not shaped around a practice a session cuts short.
52
+ */
53
+ export const LEDGER_SKILLS: readonly string[] = [
54
+ ...PRACTICE_SKILLS,
55
+ join(SHIPPED_CORPUS, 'design-taste'),
56
+ join(SHIPPED_CORPUS, 'write-human'),
57
+ join(SHIPPED_CORPUS, 'test-first'),
43
58
  ]
44
59
 
45
60
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
@@ -52,6 +67,16 @@ export const PRACTICE_SECTIONS: readonly string[] = [
52
67
  /** The source ledger every practice skill carries beside its body. */
53
68
  export const PRACTICE_LEDGER = join('references', 'adopted.md')
54
69
 
70
+ /**
71
+ * The line count past which a reference opens with a contents list, from
72
+ * Anthropic's skill authoring guidance. Counted over the whole file the way
73
+ * `wc -l` counts it, so "over 100" holds at 101 and not at 100.
74
+ */
75
+ export const CONTENTS_THRESHOLD = 100
76
+
77
+ /** The H2 a long reference opens with, matched exactly outside fences. */
78
+ export const CONTENTS_SECTION = 'Contents'
79
+
55
80
  /**
56
81
  * The one reason this audit refuses. A project carrying neither corpus is the
57
82
  * ordinary state of a target that has not adopted either skills convention,
@@ -102,6 +127,7 @@ export interface SkillsAudit {
102
127
  readonly requirementSections: readonly SkillFinding[]
103
128
  readonly datedProvenance: readonly SkillFinding[]
104
129
  readonly practiceShape: readonly SkillFinding[]
130
+ readonly referenceContents: readonly SkillFinding[]
105
131
  }
106
132
 
107
133
  interface SkillSource {
@@ -126,6 +152,7 @@ interface SkillSource {
126
152
  interface SkillText {
127
153
  readonly rel: string
128
154
  readonly text: string
155
+ readonly isReference: boolean
129
156
  }
130
157
 
131
158
  /**
@@ -177,6 +204,9 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
177
204
  ),
178
205
  ...sources.flatMap(practiceFindings),
179
206
  ],
207
+ referenceContents: sources.flatMap((source) =>
208
+ source.bodies.flatMap(contentsFindings),
209
+ ),
180
210
  }
181
211
  }
182
212
 
@@ -233,16 +263,14 @@ async function readSkill(
233
263
  description: declared(readField(fields, 'description')),
234
264
  requirementHeadings:
235
265
  requirement === undefined ? undefined : headings(requirement),
236
- sections: bodyLines(body)
237
- .filter((line) => !line.fenced)
238
- .map((line) => SECTION.exec(line.text)?.[1])
239
- .filter((text): text is string => text !== undefined),
266
+ sections: sectionsOf(body),
240
267
  hasLedger: existsSync(join(skillDir, PRACTICE_LEDGER)),
241
268
  bodies: [
242
- { rel: join(skillRel, 'SKILL.md'), text: body },
269
+ { rel: join(skillRel, 'SKILL.md'), text: body, isReference: false },
243
270
  ...references.map((path, index) => ({
244
271
  rel: join(skillRel, path),
245
272
  text: referenceTexts[index] ?? '',
273
+ isReference: true,
246
274
  })),
247
275
  ],
248
276
  }
@@ -257,6 +285,14 @@ function declared(value: string | undefined): string | undefined {
257
285
  return trimmed === undefined || trimmed === '' ? undefined : trimmed
258
286
  }
259
287
 
288
+ /** Every H2 outside a fence, the one level both exact-section checks read. */
289
+ function sectionsOf(source: string): string[] {
290
+ return bodyLines(source)
291
+ .filter((line) => !line.fenced)
292
+ .map((line) => SECTION.exec(line.text)?.[1])
293
+ .filter((text): text is string => text !== undefined)
294
+ }
295
+
260
296
  function headings(source: string): string[] {
261
297
  return source
262
298
  .split('\n')
@@ -311,7 +347,7 @@ function missingPracticeSkills(
311
347
  ): SkillFinding[] {
312
348
  if (!hasShippedCorpus) return []
313
349
  const found = new Set(sources.map((source) => source.rel))
314
- return PRACTICE_SKILLS.filter((rel) => !found.has(rel)).map((rel) => ({
350
+ return LEDGER_SKILLS.filter((rel) => !found.has(rel)).map((rel) => ({
315
351
  rel,
316
352
  detail: 'missing skill: no folder under the shipped corpus',
317
353
  }))
@@ -323,14 +359,15 @@ function missingPracticeSkills(
323
359
  * standard never names.
324
360
  */
325
361
  function practiceFindings(source: SkillSource): SkillFinding[] {
326
- if (!PRACTICE_SKILLS.includes(source.rel)) return []
327
-
328
- const sections = PRACTICE_SECTIONS.filter(
329
- (section) => !source.sections.includes(section),
330
- ).map((section) => ({
331
- rel: source.rel,
332
- detail: `missing section: ${section}`,
333
- }))
362
+ if (!LEDGER_SKILLS.includes(source.rel)) return []
363
+
364
+ const isPractice = PRACTICE_SKILLS.includes(source.rel)
365
+ const sections = (isPractice ? PRACTICE_SECTIONS : [])
366
+ .filter((section) => !source.sections.includes(section))
367
+ .map((section) => ({
368
+ rel: source.rel,
369
+ detail: `missing section: ${section}`,
370
+ }))
334
371
  const ledger = source.hasLedger
335
372
  ? []
336
373
  : [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
@@ -338,6 +375,23 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
338
375
  return [...sections, ...ledger]
339
376
  }
340
377
 
378
+ /**
379
+ * Reads references alone, since `SKILL.md` carries its own length checkpoint
380
+ * and no contents rule. Frontmatter counts toward the length, so the count a
381
+ * finding names is the one `wc -l` prints beside it.
382
+ */
383
+ function contentsFindings(source: SkillText): SkillFinding[] {
384
+ if (!source.isReference) return []
385
+
386
+ const lines = source.text.replace(/\n$/, '').split('\n').length
387
+ if (lines <= CONTENTS_THRESHOLD) return []
388
+ if (sectionsOf(source.text).includes(CONTENTS_SECTION)) return []
389
+
390
+ return [
391
+ { rel: source.rel, detail: `${lines} lines, no ## ${CONTENTS_SECTION}` },
392
+ ]
393
+ }
394
+
341
395
  /**
342
396
  * A date inside a fence or a code span is example data a reader is shown, such
343
397
  * as a sample frontmatter block, so only prose outside both is read.
@@ -1,4 +1,4 @@
1
- import { escapeForPattern, isMarked } from '@/exempt-marker'
1
+ import { escapeForPattern, isMarked } from '@/sweep/exempt-marker'
2
2
 
3
3
  /**
4
4
  * A client command this repository quotes, with the argument spelling every
@@ -24,8 +24,11 @@ import { listSeeds, readSeedContents } from '@/claude/seeds-list'
24
24
  import {
25
25
  auditExitCode,
26
26
  auditSkills,
27
+ CONTENTS_SECTION,
28
+ CONTENTS_THRESHOLD,
27
29
  CORPORA,
28
30
  DESCRIPTION_LIMIT,
31
+ LEDGER_SKILLS,
29
32
  PRACTICE_LEDGER,
30
33
  PRACTICE_SECTIONS,
31
34
  PRACTICE_SKILLS,
@@ -1398,6 +1401,7 @@ async function runSkillsAudit(
1398
1401
  reportRequirementShape(report)
1399
1402
  reportProvenance(report)
1400
1403
  reportPracticeShape(report)
1404
+ reportReferenceContents(report)
1401
1405
  reportUnmeasured()
1402
1406
  outro()
1403
1407
  }
@@ -1421,13 +1425,16 @@ async function runSkillsAudit(
1421
1425
  requirementSections: report.requirementSections,
1422
1426
  datedProvenance: report.datedProvenance,
1423
1427
  practiceShape: report.practiceShape,
1428
+ referenceContents: report.referenceContents,
1424
1429
  },
1425
1430
  checkpoints: {
1426
1431
  descriptionLimit: DESCRIPTION_LIMIT,
1427
1432
  requirementSections: REQUIREMENT_SECTIONS,
1428
1433
  corpora: CORPORA,
1429
1434
  practiceSkills: PRACTICE_SKILLS,
1435
+ ledgerSkills: LEDGER_SKILLS,
1430
1436
  practiceSections: PRACTICE_SECTIONS,
1437
+ contentsThreshold: CONTENTS_THRESHOLD,
1431
1438
  },
1432
1439
  })}\n`,
1433
1440
  )
@@ -1626,10 +1633,10 @@ function reportProvenance(report: SkillsAudit): void {
1626
1633
  function reportPracticeShape(report: SkillsAudit): void {
1627
1634
  logStep('Practice shape')
1628
1635
  logInfo(
1629
- `A listed practice skill closes with ${PRACTICE_SECTIONS.map((section) => `## ${section}`).join(', ')}, and carries ${PRACTICE_LEDGER}.`,
1636
+ `A listed practice skill closes with ${PRACTICE_SECTIONS.map((section) => `## ${section}`).join(', ')}, and every skill on the wider ledger list carries ${PRACTICE_LEDGER}.`,
1630
1637
  )
1631
1638
  logInfo(
1632
- `The list is kept here and names ${plural(PRACTICE_SKILLS.length, 'shipped skill')}, so a skill outside ${SHIPPED_CORPUS} is never read.`,
1639
+ `Both lists are kept here and name ${plural(PRACTICE_SKILLS.length, 'practice skill')} and ${plural(LEDGER_SKILLS.length, 'ledger skill')}, so a skill outside ${SHIPPED_CORPUS} is never read.`,
1633
1640
  )
1634
1641
 
1635
1642
  if (report.practiceShape.length === 0) {
@@ -1643,6 +1650,24 @@ function reportPracticeShape(report: SkillsAudit): void {
1643
1650
  reportFindings(report.practiceShape)
1644
1651
  }
1645
1652
 
1653
+ /** Reports without failing the run, the same judgment the practice shape is. */
1654
+ function reportReferenceContents(report: SkillsAudit): void {
1655
+ logStep('Reference contents')
1656
+ logInfo(
1657
+ `A reference over ${CONTENTS_THRESHOLD} lines opens with a ## ${CONTENTS_SECTION} list, matched exactly outside fences.`,
1658
+ )
1659
+
1660
+ if (report.referenceContents.length === 0) {
1661
+ logInfo('Every long reference opens with a contents list.')
1662
+ return
1663
+ }
1664
+
1665
+ logWarn(
1666
+ `${plural(report.referenceContents.length, 'long reference')} with no contents list`,
1667
+ )
1668
+ reportFindings(report.referenceContents)
1669
+ }
1670
+
1646
1671
  /**
1647
1672
  * Stated on every run, including the run where everything above passed. A
1648
1673
  * report that lists only what it measured reads as a verdict on the standard
@@ -7,11 +7,11 @@ import {
7
7
  deriveTitle,
8
8
  missingField,
9
9
  missingFieldMessage,
10
- } from '@/commands/feedback-format'
10
+ } from '@/feedback/body'
11
11
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
12
12
  import { creationRel } from '@/record-root'
13
- import { createGithubIssue } from '@/github'
14
- import { issueFailureMessage } from '@/github-format'
13
+ import { createGithubIssue } from '@/feedback/github'
14
+ import { issueFailureMessage } from '@/feedback/github-format'
15
15
  import { frameError, frameSuccess, logWarn, palette } from '@/ui'
16
16
 
17
17
  function readStdin(): Promise<string> {