@erclx/canon 5.61.0 → 5.63.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 (87) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +2 -1
  5. package/claude/skills/create-skill/references/skill-practice.md +1 -0
  6. package/claude/skills/design-identity/references/card-route.md +3 -3
  7. package/claude/skills/design-record/references/design.md +1 -1
  8. package/claude/skills/design-taste/REQUIREMENT.md +2 -1
  9. package/claude/skills/design-taste/SKILL.md +6 -5
  10. package/claude/skills/design-taste/references/adopted.md +136 -8
  11. package/claude/skills/design-taste/references/craft.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +8 -2
  13. package/claude/skills/design-taste/references/preflight.md +3 -3
  14. package/claude/skills/design-taste/references/tells.md +26 -5
  15. package/claude/skills/plan-feature/SKILL.md +2 -2
  16. package/claude/skills/plan-feature/references/plan.md +24 -3
  17. package/claude/skills/review-craft/SKILL.md +1 -1
  18. package/claude/skills/role-worker/SKILL.md +2 -1
  19. package/claude/skills/target-update/SKILL.md +9 -1
  20. package/claude/skills/ui-checklist/SKILL.md +1 -1
  21. package/claude/skills/ui-craft/REQUIREMENT.md +29 -0
  22. package/claude/skills/ui-craft/SKILL.md +44 -0
  23. package/{governance/rules/ui → claude/skills/ui-craft/references}/a11y.md +2 -10
  24. package/claude/skills/ui-craft/references/adopted.md +27 -0
  25. package/{governance/rules/ui/surface-capture.md → claude/skills/ui-craft/references/capture.md} +5 -15
  26. package/claude/skills/ui-craft/references/controls.md +18 -0
  27. package/claude/skills/ui-craft/references/copy.md +45 -0
  28. package/{governance/rules/ui → claude/skills/ui-craft/references}/forms.md +0 -9
  29. package/{governance/rules/ui → claude/skills/ui-craft/references}/keyboard.md +3 -16
  30. package/{governance/rules/ui → claude/skills/ui-craft/references}/motion.md +0 -10
  31. package/claude/skills/ui-craft/references/pointer.md +13 -0
  32. package/{governance/rules/ui/ux-completeness.md → claude/skills/ui-craft/references/states.md} +2 -20
  33. package/claude/skills/ux-audit/SKILL.md +1 -1
  34. package/claude/skills/write-human/references/adopted.md +4 -1
  35. package/docs/agents/install-and-sync.md +47 -13
  36. package/docs/index.md +1 -1
  37. package/docs/target/migrations.md +6 -8
  38. package/docs/target/sync.md +21 -1
  39. package/docs/workflow/ai-workflow.md +1 -1
  40. package/docs/workflow/index.md +3 -3
  41. package/docs/workflow/skill-map.md +1 -0
  42. package/docs/workflow/visual-design-workflow.md +58 -32
  43. package/governance/detect.toml +1 -37
  44. package/governance/ledger/lib/test-scope.md +1 -1
  45. package/governance/ledger/{ui → skills/ui-craft}/a11y.md +11 -9
  46. package/governance/ledger/{ui/surface-capture.md → skills/ui-craft/capture.md} +15 -15
  47. package/governance/ledger/skills/ui-craft/controls.md +31 -0
  48. package/governance/ledger/skills/ui-craft/copy.md +102 -0
  49. package/governance/ledger/{ui → skills/ui-craft}/forms.md +3 -3
  50. package/governance/ledger/skills/ui-craft/keyboard.md +67 -0
  51. package/governance/ledger/{ui → skills/ui-craft}/motion.md +3 -3
  52. package/governance/ledger/skills/ui-craft/pointer.md +24 -0
  53. package/governance/ledger/{ui/ux-completeness.md → skills/ui-craft/states.md} +14 -26
  54. package/governance/ledger/ui/craft.md +19 -0
  55. package/governance/renames.toml +17 -3
  56. package/governance/rules/lib/test-scope.md +1 -1
  57. package/governance/rules/ui/craft.md +22 -0
  58. package/governance/splits.toml +13 -0
  59. package/package.json +1 -1
  60. package/src/claude/cases/misc.ts +5 -0
  61. package/src/claude/skills-audit.ts +150 -12
  62. package/src/commands/claude.ts +5 -0
  63. package/src/commands/sync.ts +3 -1
  64. package/src/commands/tooling.ts +332 -10
  65. package/src/gate/measures.ts +4 -3
  66. package/src/gov/adapter.ts +53 -4
  67. package/src/gov/audit.ts +6 -1
  68. package/src/gov/splits.ts +36 -0
  69. package/src/inventory/subjects.ts +1 -1
  70. package/src/sync/check.ts +9 -1
  71. package/src/sync/stamp.ts +135 -11
  72. package/src/tooling/disposition.ts +167 -0
  73. package/src/tooling/inject.ts +17 -2
  74. package/src/tooling/package.ts +22 -13
  75. package/src/tooling/scan.ts +86 -7
  76. package/src/tooling/stamp.ts +51 -7
  77. package/tooling/web/reference.md +3 -1
  78. package/governance/ledger/ui/copy.md +0 -49
  79. package/governance/ledger/ui/design-taste.md +0 -19
  80. package/governance/ledger/ui/interface-casing.md +0 -47
  81. package/governance/ledger/ui/keyboard.md +0 -68
  82. package/governance/ledger/ui/link-behavior.md +0 -29
  83. package/governance/rules/ui/copy.md +0 -28
  84. package/governance/rules/ui/design-taste.md +0 -18
  85. package/governance/rules/ui/interface-casing.md +0 -29
  86. package/governance/rules/ui/link-behavior.md +0 -19
  87. /package/tooling/web/{configs → seeds}/e2e/home.spec.ts +0 -0
@@ -1,19 +1,20 @@
1
1
  ---
2
- title: UX completeness rule ledger
3
- description: Sources for governance/rules/ui/ux-completeness.md, one row per bullet
2
+ title: UX completeness reference ledger
3
+ description: Sources for claude/skills/ui-craft/references/states.md, one row per bullet
4
4
  ---
5
5
 
6
- # UX completeness rule ledger
6
+ # UX completeness reference ledger
7
7
 
8
8
  The field is interaction design, and the authorities are the Nielsen Norman Group's research articles, WCAG 2.2 for timing, MDN for the `title` attribute and CSS truncation, and web.dev for layout shift.
9
9
 
10
10
  ## Feedback
11
11
 
12
- | Bullet | Source | Read |
13
- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
14
- | Confirm success and failure | [Visibility of system status](https://www.nngroup.com/articles/visibility-system-status/): "Whenever users interact with a system, they need to know whether the interaction was successful" | 2026-10-18 |
15
- | Auto-dismiss only a redundant message | [Understanding Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html): "If the user has no other means of discovering the same information (or performing the same function), then each message would need to meet this success criterion" | 2026-10-18, WCAG 2.2 |
16
- | No optimistic status | House choice: a "connected" dot shown over unsaved edits reports a state the system has not reached, and the person leaves believing their change took | 2026-10-18 |
12
+ | Bullet | Source | Read |
13
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
14
+ | Confirm success and failure | [Visibility of system status](https://www.nngroup.com/articles/visibility-system-status/): "Whenever users interact with a system, they need to know whether the interaction was successful" | 2026-10-18 |
15
+ | Auto-dismiss only a redundant message | [Understanding Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html): "If the user has no other means of discovering the same information (or performing the same function), then each message would need to meet this success criterion" | 2026-10-18, WCAG 2.2 |
16
+ | Timed messages pause and take no focus | [Fluent 2, Toast usage](https://fluent2.microsoft.design/components/web/react/core/toast/usage): a toast with no action times out "after seven seconds", and mouse users "can pause the timer by hovering". [Understanding Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html): status messages are presented "without receiving focus". The pause leaves the redundant-message bullet's rule for when a message may leave unchanged | 2026-10-23 |
17
+ | No optimistic status | House choice: a "connected" dot shown over unsaved edits reports a state the system has not reached, and the person leaves believing their change took | 2026-10-18 |
17
18
 
18
19
  ## Empty states
19
20
 
@@ -29,26 +30,13 @@ The field is interaction design, and the authorities are the Nielsen Norman Grou
29
30
  | Indicator past one second | [Progress indicators](https://www.nngroup.com/articles/progress-indicators/): "Use a progress indicator for any action that takes longer than about 1.0 second" | 2026-10-18 |
30
31
  | Mark stale content as updating | [Progress indicators](https://www.nngroup.com/articles/progress-indicators/): "Without any visual change from the system, most users will assume the action was not registered and they will try again" | 2026-10-18 |
31
32
 
32
- ## Destructive actions
33
-
34
- | Bullet | Source | Read |
35
- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
36
- | Confirm unless undoable | [Confirmation dialogs](https://www.nngroup.com/articles/confirmation-dialog/): "consider a confirmation dialog before actions that cannot be undone", and "do try your best to offer undo" | 2026-10-18 |
37
-
38
- ## Element states
39
-
40
- | Bullet | Source | Read |
41
- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
42
- | Distinct interaction states | [Visibility of system status](https://www.nngroup.com/articles/visibility-system-status/): "Did the system actually catch that button press", and an element with no pressed or hover response leaves the person unable to tell | 2026-10-18 |
43
- | No intermediate flash | [Cumulative Layout Shift](https://web.dev/articles/cls): "Unexpected layout shifts can disrupt the user experience", caused by content "dynamically added to the page before existing content" | 2026-10-18 |
44
-
45
33
  ## Overflow
46
34
 
47
- | Bullet | Source | Read |
48
- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
49
- | Ellipsis over overflow | [text-overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow): `text-overflow: ellipsis` with `overflow: hidden` and `white-space: nowrap` clips text at the box with a visible marker | 2026-10-18 |
50
- | Never `title` alone | [The title attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/title): "Use of the title attribute is highly problematic for" people using touch-only devices, keyboards, and assistive technology | 2026-10-18 |
51
- | Focus rings clear of clipping | House choice: a ring cut off by a parent's `overflow` shows on the middle items of a list and vanishes on the first and last, so the focus indicator fails exactly where the list ends | 2026-10-18 |
35
+ | Bullet | Source | Read |
36
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
37
+ | Ellipsis over overflow | [text-overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow): `text-overflow: ellipsis` with `overflow: hidden` and `white-space: nowrap` clips text at the box with a visible marker | 2026-10-18 |
38
+ | Never `title` alone | [The title attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/title): "Use of the title attribute is highly problematic for" people using touch-only devices, keyboards, and assistive technology | 2026-10-18 |
39
+ | Borders clear of clipping | House choice: a ring cut off by a parent's `overflow` shows on the middle items of a list and vanishes on the first and last, so the focus indicator fails exactly where the list ends | 2026-10-18 |
52
40
 
53
41
  ## Declined
54
42
 
@@ -0,0 +1,19 @@
1
+ ---
2
+ title: UI craft rule ledger
3
+ description: Sources for governance/rules/ui/craft.md, one row per bullet
4
+ ---
5
+
6
+ # UI craft rule ledger
7
+
8
+ The rule is a pointer to the `ui-craft` and `design-taste` skills, which carry their own sources, so both rows are house choices naming the bug the pointer prevents.
9
+
10
+ ## Authority
11
+
12
+ | Bullet | Source | Read |
13
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
14
+ | Load the skills | House choice: a rendered edit made without the behavior floor or the layer order reaches for a model's defaults, and a second copy of either in the rule drifts from the skill that owns it | 2026-10-24 |
15
+ | Report an unresolved skill | House choice: the rule ships with the CLI and the skills with the plugin, so a project holding governance alone would otherwise proceed silently without them | 2026-10-18 |
16
+
17
+ ## Declined
18
+
19
+ The fold kept both bullets and widened the first to name `ui-craft` beside `design-taste`, since the practice rules it replaced now live in that skill.
@@ -3,9 +3,11 @@
3
3
  # so `canon gov sync` replaces a held old file with the rule under its new name
4
4
  # rather than only deleting it.
5
5
  #
6
- # An entry declares one-to-one identity and nothing else. A fold of several
7
- # rules into one, or a split of one into several, is a retirement plus an
8
- # addition and takes no entry here. Add the entry in the same change as the
6
+ # An entry declares one-to-one identity, or a fold onto a pointer rule that
7
+ # now routes to the skill carrying the folded rules, so a target holding any
8
+ # of them lands on the pointer rather than losing the practice with nothing in
9
+ # its place. Any other fold of several rules into one, or a split of one into
10
+ # several, is a retirement plus an addition and takes no entry here. Add the entry in the same change as the
9
11
  # rename or renumber. Prune an entry only once its target rule is retired and
10
12
  # the retire path covers it, since a chain ending on a rule nothing ships
11
13
  # resolves to nothing and a sync deletes the held file instead. Otherwise leave
@@ -73,3 +75,15 @@
73
75
  "460-design-taste" = "design-taste"
74
76
  "465-interface-casing" = "interface-casing"
75
77
  "470-motion" = "motion"
78
+ "design-taste" = "craft"
79
+ "a11y" = "craft"
80
+ "keyboard" = "craft"
81
+ "pointer" = "craft"
82
+ "forms" = "craft"
83
+ "motion" = "craft"
84
+ "ux-controls" = "craft"
85
+ "ux-completeness" = "craft"
86
+ "surface-capture" = "craft"
87
+ "copy" = "craft"
88
+ "interface-casing" = "craft"
89
+ "link-behavior" = "craft"
@@ -20,5 +20,5 @@ paths:
20
20
 
21
21
  - Answer a question about the running page with a script against the dev server rather than with the suite.
22
22
  - Do not enable `fullyParallel` in the Playwright config.
23
- - Follow `.claude/rules/canon/ui/surface-capture.md` for capture scope.
23
+ - Follow the `canon:ui-craft` skill's capture reference for capture scope, reporting it when the skill does not resolve.
24
24
  - Follow `.claude/rules/canon/lib/e2e-reliability.md` for waits and guards.
@@ -0,0 +1,22 @@
1
+ ---
2
+ description: Route edits to a rendered surface to the ui-craft skill for how it behaves and to the design-taste skill for how it looks
3
+ paths:
4
+ - '**/*.tsx'
5
+ - '**/*.jsx'
6
+ - '**/*.vue'
7
+ - '**/*.svelte'
8
+ - '**/*.astro'
9
+ - '**/*.html'
10
+ - '**/*.mdx'
11
+ - '**/*.css'
12
+ - '**/*.scss'
13
+ - '**/content/**/*.ts'
14
+ - 'canon/DESIGN.md'
15
+ ---
16
+
17
+ # UI craft standards
18
+
19
+ ## Authority
20
+
21
+ - Load the `canon:ui-craft` skill for how a surface behaves and the `canon:design-taste` skill for how it looks. Each is the single source for its half.
22
+ - Report it rather than proceeding silently when either skill does not resolve. Both ship with the plugin and this rule ships with the CLI, so governance alone carries neither.
@@ -0,0 +1,13 @@
1
+ # Rule splits a sync tells a target about. Each key under [split] is the
2
+ # basename of a rule that shed bullets into others, and its value lists the
3
+ # basenames those bullets moved to. `canon gov sync` adds no rule, so a target
4
+ # holding the key and not a partner reads a notice naming the partner and the
5
+ # `canon gov install --add` command that installs it.
6
+ #
7
+ # This ledger is not the rename ledger. A rename entry resolves to a successor
8
+ # the sync installs, and a split key is a live rule the target keeps. Add the
9
+ # entry in the same change as the split. The notice is stateless, so a target
10
+ # installed after the split reads it too until it adds the partner. Prune an
11
+ # entry only once its key or partner is retired, which the live-rule check in
12
+ # `src/gov/splits.test.ts` forces.
13
+ [split]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.61.0",
4
+ "version": "5.63.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -17,6 +17,11 @@ export const MISC_CASES: readonly SkillCase[] = [
17
17
  'Which layer of this landing page should I settle first, and what has to stay constant across it?',
18
18
  expect: 'design-taste',
19
19
  },
20
+ {
21
+ prompt:
22
+ 'This settings form needs validation and an empty state, so what does it owe for keyboard users and error messages before I build it?',
23
+ expect: 'ui-craft',
24
+ },
20
25
  {
21
26
  prompt:
22
27
  'This project has no logo yet. Draft one and give me a social card to go with it.',
@@ -1,8 +1,20 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
2
+ import { dirname, join, relative } from 'node:path'
3
3
  import { isFamilyKey } from '@/claude/skills-families'
4
+ import { plural } from '@/cli/ui'
5
+ import {
6
+ compareLedger,
7
+ countLedgerRows,
8
+ type LedgerMismatch,
9
+ measureRule,
10
+ } from '@/gov/audit'
4
11
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
- import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
12
+ import {
13
+ type BodyLine,
14
+ bodyLines,
15
+ maskCodeSpans,
16
+ maskDisplayed,
17
+ } from '@/markdown/scan'
6
18
 
7
19
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
8
20
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -69,8 +81,22 @@ export const LEDGER_SKILLS: readonly string[] = [
69
81
  join(SHIPPED_CORPUS, 'design-taste'),
70
82
  join(SHIPPED_CORPUS, 'write-human'),
71
83
  join(SHIPPED_CORPUS, 'test-first'),
84
+ join(SHIPPED_CORPUS, 'ui-craft'),
72
85
  ]
73
86
 
87
+ /**
88
+ * The ledger-listed skills whose every reference but the source record carries
89
+ * a ledger of its own, one table row per bullet under each H2, the way a rule
90
+ * in a practice folder does. The ledgers sit outside the plugin as rule
91
+ * ledgers do, so a target pays nothing for them.
92
+ */
93
+ export const BULLET_LEDGER_SKILLS: readonly string[] = [
94
+ join(SHIPPED_CORPUS, 'ui-craft'),
95
+ ]
96
+
97
+ /** Where a per-bullet ledger lives, as `<skill>/<reference>.md` below it. */
98
+ export const SKILL_LEDGERS = join('governance', 'ledger', 'skills')
99
+
74
100
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
75
101
  export const PRACTICE_SECTIONS: readonly string[] = [
76
102
  'Excuses and rebuttals',
@@ -81,6 +107,19 @@ export const PRACTICE_SECTIONS: readonly string[] = [
81
107
  /** The source ledger every practice skill carries beside its body. */
82
108
  export const PRACTICE_LEDGER = join('references', 'adopted.md')
83
109
 
110
+ /**
111
+ * A ledger cites a source by linking a page outside the project, as an inline
112
+ * link or an autolink. A relative link points back into the skill and cites
113
+ * nothing a reader could check the rule against.
114
+ */
115
+ const EXTERNAL_LINK = /\]\(\s*https?:\/\/|<https?:\/\//
116
+
117
+ /**
118
+ * A table cell opening on the marker the governance ledgers already use for a
119
+ * rule the toolkit chose, so one check reads every ledger alike.
120
+ */
121
+ const HOUSE_CHOICE = /\|\s*House choice\b/
122
+
84
123
  /**
85
124
  * The line count past which a reference opens with a contents list, from
86
125
  * Anthropic's skill authoring guidance. Counted over the whole file the way
@@ -166,13 +205,23 @@ interface SkillSource {
166
205
  readonly requirement: string | undefined
167
206
  /** Every H2 in `SKILL.md` outside a fence, for the practice shape. */
168
207
  readonly sections: readonly string[]
169
- readonly hasLedger: boolean
208
+ /** Undefined when the folder carries no ledger at all. */
209
+ readonly ledger: string | undefined
170
210
  /**
171
211
  * `SKILL.md` and every reference below the folder, keyed by the path a
172
212
  * finding names. `REQUIREMENT.md` and `EVAL.md` are left out, since the
173
213
  * requirement is where the standard sends a date.
174
214
  */
175
215
  readonly bodies: readonly SkillText[]
216
+ /** One entry per reference of a skill on the per-bullet ledger list. */
217
+ readonly referenceLedgers: readonly ReferenceLedger[]
218
+ }
219
+
220
+ interface ReferenceLedger {
221
+ readonly rel: string
222
+ readonly ledgerRel: string
223
+ /** Undefined when the ledger file is absent. */
224
+ readonly mismatches: readonly LedgerMismatch[] | undefined
176
225
  }
177
226
 
178
227
  interface SkillText {
@@ -195,7 +244,7 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
195
244
  )
196
245
 
197
246
  const perCorpus = await Promise.all(
198
- present.map((corpus) => readCorpus(corpus.rel, corpus.dir)),
247
+ present.map((corpus) => readCorpus(root, corpus.rel, corpus.dir)),
199
248
  )
200
249
  const sources = perCorpus.flat()
201
250
 
@@ -247,21 +296,29 @@ export function auditExitCode(report: SkillsAudit): number {
247
296
  return report.missingRequirement.length > 0 ? EXIT_MISSING_REQUIREMENT : 0
248
297
  }
249
298
 
250
- async function readCorpus(rel: string, dir: string): Promise<SkillSource[]> {
299
+ async function readCorpus(
300
+ root: string,
301
+ rel: string,
302
+ dir: string,
303
+ ): Promise<SkillSource[]> {
251
304
  const paths = [
252
305
  ...new Bun.Glob('*/SKILL.md').scanSync({ cwd: dir, onlyFiles: true }),
253
306
  ].sort()
254
307
 
255
- return Promise.all(paths.map((path) => readSkill(rel, dir, dirname(path))))
308
+ return Promise.all(
309
+ paths.map((path) => readSkill(root, rel, dir, dirname(path))),
310
+ )
256
311
  }
257
312
 
258
313
  async function readSkill(
314
+ root: string,
259
315
  rel: string,
260
316
  dir: string,
261
317
  folder: string,
262
318
  ): Promise<SkillSource> {
263
319
  const skillDir = join(dir, folder)
264
320
  const requirementPath = join(skillDir, 'REQUIREMENT.md')
321
+ const ledgerPath = join(skillDir, PRACTICE_LEDGER)
265
322
 
266
323
  const references = [
267
324
  ...new Bun.Glob('references/**/*.md').scanSync({
@@ -270,11 +327,14 @@ async function readSkill(
270
327
  }),
271
328
  ].sort()
272
329
 
273
- const [body, requirement, referenceTexts] = await Promise.all([
330
+ const [body, requirement, ledger, referenceTexts] = await Promise.all([
274
331
  Bun.file(join(skillDir, 'SKILL.md')).text(),
275
332
  existsSync(requirementPath)
276
333
  ? Bun.file(requirementPath).text()
277
334
  : Promise.resolve(undefined),
335
+ existsSync(ledgerPath)
336
+ ? Bun.file(ledgerPath).text()
337
+ : Promise.resolve(undefined),
278
338
  Promise.all(
279
339
  references.map((path) => Bun.file(join(skillDir, path)).text()),
280
340
  ),
@@ -282,6 +342,15 @@ async function readSkill(
282
342
 
283
343
  const fields = parseFrontmatter(body)
284
344
  const skillRel = join(rel, folder)
345
+ const referenceLedgers = BULLET_LEDGER_SKILLS.includes(skillRel)
346
+ ? await readReferenceLedgers(
347
+ root,
348
+ skillRel,
349
+ folder,
350
+ references,
351
+ referenceTexts,
352
+ )
353
+ : []
285
354
 
286
355
  return {
287
356
  rel: skillRel,
@@ -294,7 +363,7 @@ async function readSkill(
294
363
  requirement === undefined ? undefined : headings(requirement),
295
364
  requirement,
296
365
  sections: sectionsOf(body),
297
- hasLedger: existsSync(join(skillDir, PRACTICE_LEDGER)),
366
+ ledger,
298
367
  bodies: [
299
368
  { rel: join(skillRel, 'SKILL.md'), text: body, isReference: false },
300
369
  ...references.map((path, index) => ({
@@ -303,9 +372,45 @@ async function readSkill(
303
372
  isReference: true,
304
373
  })),
305
374
  ],
375
+ referenceLedgers,
306
376
  }
307
377
  }
308
378
 
379
+ /**
380
+ * Pairs each reference's bullets with its ledger's rows through the rule
381
+ * audit's own comparison, so a reference and a rule are held to one count.
382
+ * The source record names authorities rather than stating bullets, so it is
383
+ * the one reference asked for no ledger.
384
+ */
385
+ async function readReferenceLedgers(
386
+ root: string,
387
+ skillRel: string,
388
+ folder: string,
389
+ references: readonly string[],
390
+ texts: readonly string[],
391
+ ): Promise<ReferenceLedger[]> {
392
+ return Promise.all(
393
+ references
394
+ .map((path, index) => ({ path, text: texts[index] ?? '' }))
395
+ .filter(({ path }) => path !== PRACTICE_LEDGER)
396
+ .map(async ({ path, text }) => {
397
+ const ledgerRel = join(
398
+ SKILL_LEDGERS,
399
+ folder,
400
+ relative('references', path),
401
+ )
402
+ const ledgerPath = join(root, ledgerRel)
403
+ const mismatches = existsSync(ledgerPath)
404
+ ? compareLedger(
405
+ measureRule(text).groups,
406
+ countLedgerRows(await Bun.file(ledgerPath).text()),
407
+ )
408
+ : undefined
409
+ return { rel: join(skillRel, path), ledgerRel, mismatches }
410
+ }),
411
+ )
412
+ }
413
+
309
414
  /**
310
415
  * A key carrying an empty value declares nothing, so blank reads as absent
311
416
  * rather than as a name that disagrees with every folder.
@@ -493,11 +598,44 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
493
598
  rel: source.rel,
494
599
  detail: `missing section: ${section}`,
495
600
  }))
496
- const ledger = source.hasLedger
497
- ? []
498
- : [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
499
601
 
500
- return [...sections, ...ledger]
602
+ return [
603
+ ...sections,
604
+ ...ledgerFindings(source),
605
+ ...source.referenceLedgers.flatMap(referenceLedgerFindings),
606
+ ]
607
+ }
608
+
609
+ function referenceLedgerFindings(entry: ReferenceLedger): SkillFinding[] {
610
+ if (entry.mismatches === undefined) {
611
+ return [{ rel: entry.rel, detail: `missing ledger: ${entry.ledgerRel}` }]
612
+ }
613
+ return entry.mismatches.map((mismatch) => ({
614
+ rel: entry.rel,
615
+ detail: `${mismatch.heading === null ? 'above the first H2' : `## ${mismatch.heading}`}: ${plural(mismatch.bullets, 'bullet')}, ${plural(mismatch.rows, 'ledger row')}`,
616
+ }))
617
+ }
618
+
619
+ /**
620
+ * A ledger present but citing nothing passed for as long as the check read the
621
+ * file's existence alone, so the contents are read too. A sample block shows a
622
+ * reader the shape of a row and cites nothing, so fences and code spans are
623
+ * left out.
624
+ */
625
+ function ledgerFindings(source: SkillSource): SkillFinding[] {
626
+ if (source.ledger === undefined) {
627
+ return [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
628
+ }
629
+
630
+ const cites = bodyLines(source.ledger)
631
+ .filter((line) => !line.fenced)
632
+ .map((line) => maskCodeSpans(line.text))
633
+ .some((text) => EXTERNAL_LINK.test(text) || HOUSE_CHOICE.test(text))
634
+ if (cites) return []
635
+
636
+ return [
637
+ { rel: source.rel, detail: `ledger cites nothing: ${PRACTICE_LEDGER}` },
638
+ ]
501
639
  }
502
640
 
503
641
  /**
@@ -30,6 +30,8 @@ import {
30
30
  DESCRIPTION_LIMIT,
31
31
  LEDGER_SKILLS,
32
32
  PRACTICE_LEDGER,
33
+ BULLET_LEDGER_SKILLS,
34
+ SKILL_LEDGERS,
33
35
  PRACTICE_SECTIONS,
34
36
  PRACTICE_SKILLS,
35
37
  REQUIREMENT_LIMITS,
@@ -1768,6 +1770,9 @@ function reportPracticeShape(report: SkillsAudit): void {
1768
1770
  logInfo(
1769
1771
  `A listed practice skill closes with ${PRACTICE_SECTIONS.map((section) => `## ${section}`).join(', ')}, and every skill on the wider ledger list carries ${PRACTICE_LEDGER}.`,
1770
1772
  )
1773
+ logInfo(
1774
+ `A skill on the per-bullet list, ${plural(BULLET_LEDGER_SKILLS.length, 'skill')} here, also matches each other reference to a ledger under ${SKILL_LEDGERS}, one row per bullet under each H2.`,
1775
+ )
1771
1776
  logInfo(
1772
1777
  `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.`,
1773
1778
  )
@@ -285,7 +285,9 @@ function renderTooling(report: CheckReport): void {
285
285
  if (count > 0) logWarn(`${count} ${category}`)
286
286
  }
287
287
  logInfo(
288
- 'Run `canon tooling sync --check` to see which files, `--write` to apply.',
288
+ tooling.changes === tooling.counts.customized
289
+ ? 'A customized config moved upstream, and no write touches it. Run `canon tooling diff` to see which, and `canon tooling lift <path>` to take it.'
290
+ : 'Run `canon tooling sync --check` to see which files, `--write` to apply.',
289
291
  )
290
292
  }
291
293