@erclx/canon 5.63.0 → 5.64.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 (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +1 -1
  4. package/claude/skills/context-fold/REQUIREMENT.md +2 -3
  5. package/claude/skills/context-fold/SKILL.md +1 -11
  6. package/claude/skills/context-fold/references/completion-report.md +8 -9
  7. package/claude/skills/create-skill/references/skill-practice.md +16 -1
  8. package/claude/skills/design-taste/references/adopted.md +17 -97
  9. package/claude/skills/design-taste/references/tells.md +2 -2
  10. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  11. package/claude/skills/document-health/SKILL.md +3 -14
  12. package/claude/skills/document-health/references/axes.md +14 -16
  13. package/docs/agents/audits.md +2 -6
  14. package/docs/agents/context-audit.md +1 -1
  15. package/governance/ledger/skills/api-design/SKILL.md +62 -0
  16. package/governance/ledger/skills/code-craft/SKILL.md +53 -0
  17. package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
  18. package/governance/ledger/skills/design-taste/SKILL.md +64 -0
  19. package/governance/ledger/skills/design-taste/craft.md +66 -0
  20. package/governance/ledger/skills/design-taste/kinds.md +58 -0
  21. package/governance/ledger/skills/design-taste/systems.md +42 -0
  22. package/governance/ledger/skills/design-taste/tells.md +103 -0
  23. package/package.json +1 -1
  24. package/src/audits/catalog.ts +0 -52
  25. package/src/claude/skills-audit.ts +117 -41
  26. package/src/cli/retired.ts +8 -0
  27. package/src/commands/claude.ts +11 -2
  28. package/src/commands/context.ts +0 -425
  29. package/src/context/citations.ts +9 -3
  30. package/src/gate/measures.ts +1 -1
  31. package/src/git/files.ts +8 -3
  32. package/src/markdown/gated.ts +1 -1
  33. package/src/markdown/scope.ts +0 -1
  34. package/claude/skills/context-fold/references/classify.md +0 -87
  35. package/src/context/classify/extract.ts +0 -443
  36. package/src/context/classify/ollama.ts +0 -172
  37. package/src/context/classify/patterns.ts +0 -75
  38. package/src/context/classify/prompts.ts +0 -74
  39. package/src/context/classify/run.ts +0 -348
  40. package/src/context/classify/settings.ts +0 -196
@@ -2,7 +2,6 @@ import type { SkillsAuditRefusal } from '@/claude/skills-audit'
2
2
  import type { RankRefusal } from '@/claude/skills-rank'
3
3
  import type { ReachRefusal } from '@/claude/skills-reach'
4
4
  import type { ContextAuditRefusal } from '@/context/audit'
5
- import type { SweepVerdict } from '@/context/classify/patterns'
6
5
  import type { AuditRefusal } from '@/deps/audit'
7
6
  import type { RuleAuditRefusal } from '@/gov/audit'
8
7
  import type { RestatedRefusal } from '@/gov/restated'
@@ -161,45 +160,6 @@ function requirementsCounts(
161
160
  return { requirementsOverWords: record.words > record.wordCap ? 1 : 0 }
162
161
  }
163
162
 
164
- /**
165
- * The retained key for each verdict the sweep can return. A record rather than
166
- * a list, so the compiler fails here when the classifier gains a verdict,
167
- * instead of that verdict's sections dropping out of the tally.
168
- */
169
- const PLACEMENT_KEYS: Record<SweepVerdict, string> = {
170
- KEEP: 'keep',
171
- REWRITE: 'rewrite',
172
- MOVE: 'move',
173
- }
174
-
175
- function isPlacementVerdict(value: unknown): value is SweepVerdict {
176
- return typeof value === 'string' && Object.hasOwn(PLACEMENT_KEYS, value)
177
- }
178
-
179
- /**
180
- * Tallies the sweep's findings by verdict, with a zero for a verdict nothing
181
- * returned, so a baseline reads the same three keys on every run.
182
- *
183
- * `KEEP` is retained beside the two that ask for a change. It moves when a
184
- * section is added or split, which is what tells a falling `rewrite` count
185
- * from a corpus that shrank.
186
- */
187
- function placementCounts(record: unknown): Record<string, number> | undefined {
188
- const findings = asObject(record)?.findings
189
- if (!Array.isArray(findings)) return undefined
190
-
191
- const counts: Record<string, number> = Object.fromEntries(
192
- Object.values(PLACEMENT_KEYS).map((key) => [key, 0]),
193
- )
194
- for (const finding of findings) {
195
- const verdict = asObject(finding)?.verdict
196
- if (!isPlacementVerdict(verdict)) return undefined
197
- counts[PLACEMENT_KEYS[verdict]] += 1
198
- }
199
-
200
- return counts
201
- }
202
-
203
163
  function contextCounts(record: unknown): Record<string, number> | undefined {
204
164
  const root = asObject(record)
205
165
  if (root === undefined) return undefined
@@ -526,18 +486,6 @@ export const AUDITS: readonly AuditSpec[] = [
526
486
  absentReasons: ['no-folders'] satisfies ContextAuditRefusal[],
527
487
  counts: contextCounts,
528
488
  },
529
- {
530
- id: 'placement',
531
- label: 'Context placement',
532
- // Pinned to the regex layer rather than the resolved classifier setting.
533
- // A count that moves with whichever model a machine runs is not
534
- // comparable across clones, and the model layer turns a sub-second read
535
- // into a multi-minute one. `document-health` is where the model reads.
536
- argv: ['context', 'classify', 'sweep', '--backend', 'off', '--json'],
537
- gatingExits: [],
538
- corpus: 'tracked',
539
- counts: placementCounts,
540
- },
541
489
  {
542
490
  id: 'markdown',
543
491
  label: 'Markdown corpus',
@@ -5,8 +5,8 @@ import { plural } from '@/cli/ui'
5
5
  import {
6
6
  compareLedger,
7
7
  countLedgerRows,
8
+ type HeadingCount,
8
9
  type LedgerMismatch,
9
- measureRule,
10
10
  } from '@/gov/audit'
11
11
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
12
12
  import {
@@ -84,19 +84,49 @@ export const LEDGER_SKILLS: readonly string[] = [
84
84
  join(SHIPPED_CORPUS, 'ui-craft'),
85
85
  ]
86
86
 
87
+ function references(...names: string[]): string[] {
88
+ return names.map((name) => join('references', `${name}.md`))
89
+ }
90
+
91
+ export interface BulletLedgerScope {
92
+ /** References left off, each a checklist or glossary naming no new source. */
93
+ readonly exempt: readonly string[]
94
+ /** True when `SKILL.md` carries directives of its own to ledger. */
95
+ readonly body: boolean
96
+ }
97
+
87
98
  /**
88
99
  * The ledger-listed skills whose every reference but the source record carries
89
100
  * 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.
101
+ * in a practice folder does. A reference is ledgered unless exempted by name,
102
+ * so one added later fails until its rows land rather than passing unread. The
103
+ * ledgers sit outside the plugin as rule ledgers do, so a target pays nothing
104
+ * for them.
92
105
  */
93
- export const BULLET_LEDGER_SKILLS: readonly string[] = [
94
- join(SHIPPED_CORPUS, 'ui-craft'),
95
- ]
106
+ export const BULLET_LEDGER_SKILLS: Readonly<Record<string, BulletLedgerScope>> =
107
+ {
108
+ [join(SHIPPED_CORPUS, 'ui-craft')]: { exempt: [], body: false },
109
+ [join(SHIPPED_CORPUS, 'api-design')]: { exempt: [], body: true },
110
+ [join(SHIPPED_CORPUS, 'code-craft')]: { exempt: [], body: true },
111
+ [join(SHIPPED_CORPUS, 'deprecation-migration')]: { exempt: [], body: true },
112
+ [join(SHIPPED_CORPUS, 'design-taste')]: {
113
+ exempt: references('preflight', 'vocabulary'),
114
+ body: true,
115
+ },
116
+ }
96
117
 
97
- /** Where a per-bullet ledger lives, as `<skill>/<reference>.md` below it. */
118
+ /** Where a per-bullet ledger lives, as `<skill>/<file>.md` below it. */
98
119
  export const SKILL_LEDGERS = join('governance', 'ledger', 'skills')
99
120
 
121
+ /**
122
+ * The ledger a ledgered file pairs with, keyed by the file's own name, so a
123
+ * reference drops its `references/` segment and a `SKILL.md` keeps its name.
124
+ */
125
+ export function skillLedgerPath(folder: string, file: string): string {
126
+ const inside = relative('references', file)
127
+ return join(SKILL_LEDGERS, folder, inside.startsWith('..') ? file : inside)
128
+ }
129
+
100
130
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
101
131
  export const PRACTICE_SECTIONS: readonly string[] = [
102
132
  'Excuses and rebuttals',
@@ -104,6 +134,16 @@ export const PRACTICE_SECTIONS: readonly string[] = [
104
134
  'Before handing over',
105
135
  ]
106
136
 
137
+ /**
138
+ * The H2s that end a listed body's concern groups. The closing sections
139
+ * restate the groups' rules as excuses, signs, and checks, and the delegates
140
+ * section routes, so neither states a rule of its own to source.
141
+ */
142
+ export const BODY_CLOSERS: readonly string[] = [
143
+ ...PRACTICE_SECTIONS,
144
+ 'What this delegates',
145
+ ]
146
+
107
147
  /** The source ledger every practice skill carries beside its body. */
108
148
  export const PRACTICE_LEDGER = join('references', 'adopted.md')
109
149
 
@@ -157,6 +197,12 @@ const ISSUE_NUMBER = /(?<![\w&/#])#\d+\b/g
157
197
 
158
198
  const TOP_LEVEL_BULLET = /^(?:[-*+]|\d+[.)])\s/
159
199
 
200
+ /**
201
+ * A line a skill ledger pairs with a row: a top-level `- ` bullet as a rule
202
+ * writes one, or a top-level ordered step, which states a directive as surely.
203
+ */
204
+ const LEDGERED_LINE = /^(?:- |\d+[.)]\s)/
205
+
160
206
  const WORD = /\S*[\p{L}\p{N}]\S*/gu
161
207
 
162
208
  export interface SkillFinding {
@@ -213,8 +259,10 @@ interface SkillSource {
213
259
  * requirement is where the standard sends a date.
214
260
  */
215
261
  readonly bodies: readonly SkillText[]
216
- /** One entry per reference of a skill on the per-bullet ledger list. */
262
+ /** One entry per ledgered file of a skill on the per-bullet ledger list. */
217
263
  readonly referenceLedgers: readonly ReferenceLedger[]
264
+ /** Exempted references the skill no longer carries, which exempt nothing. */
265
+ readonly staleExemptions: readonly string[]
218
266
  }
219
267
 
220
268
  interface ReferenceLedger {
@@ -342,15 +390,22 @@ async function readSkill(
342
390
 
343
391
  const fields = parseFrontmatter(body)
344
392
  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
- : []
393
+ const scope = BULLET_LEDGER_SKILLS[skillRel]
394
+ const referenceLedgers =
395
+ scope === undefined
396
+ ? []
397
+ : await readReferenceLedgers(root, skillRel, folder, [
398
+ ...(scope.body ? [{ path: 'SKILL.md', text: body }] : []),
399
+ ...references
400
+ .map((path, index) => ({ path, text: referenceTexts[index] ?? '' }))
401
+ .filter(
402
+ ({ path }) =>
403
+ path !== PRACTICE_LEDGER && !scope.exempt.includes(path),
404
+ ),
405
+ ])
406
+ const staleExemptions = (scope?.exempt ?? [])
407
+ .filter((path) => !references.includes(path))
408
+ .map((path) => join(skillRel, path))
354
409
 
355
410
  return {
356
411
  rel: skillRel,
@@ -373,44 +428,61 @@ async function readSkill(
373
428
  })),
374
429
  ],
375
430
  referenceLedgers,
431
+ staleExemptions,
376
432
  }
377
433
  }
378
434
 
379
435
  /**
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.
436
+ * Pairs each ledgered file's directives with its ledger's rows through the rule
437
+ * audit's own comparison, so a skill file and a rule mismatch alike.
384
438
  */
385
439
  async function readReferenceLedgers(
386
440
  root: string,
387
441
  skillRel: string,
388
442
  folder: string,
389
- references: readonly string[],
390
- texts: readonly string[],
443
+ files: readonly { path: string; text: string }[],
391
444
  ): Promise<ReferenceLedger[]> {
392
445
  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
- }),
446
+ files.map(async ({ path, text }) => {
447
+ const ledgerRel = skillLedgerPath(folder, path)
448
+ const ledgerPath = join(root, ledgerRel)
449
+ const mismatches = existsSync(ledgerPath)
450
+ ? compareLedger(
451
+ ledgerGroups(text, path === 'SKILL.md'),
452
+ countLedgerRows(await Bun.file(ledgerPath).text()),
453
+ )
454
+ : undefined
455
+ return { rel: join(skillRel, path), ledgerRel, mismatches }
456
+ }),
411
457
  )
412
458
  }
413
459
 
460
+ /**
461
+ * Directives per H2, `null` for any above the first. A contents list repeats
462
+ * the headings rather than stating a rule, so `## Contents` takes no rows. A
463
+ * nested bullet elaborates its parent, so only a top-level line counts. A body
464
+ * stops at its first closer, and a ledger table under one then reads as rows
465
+ * with no bullets rather than passing unread.
466
+ */
467
+ function ledgerGroups(text: string, isBody: boolean): HeadingCount[] {
468
+ const counts = new Map<string | null, number>()
469
+ let heading: string | null = null
470
+
471
+ for (const line of bodyLines(text)) {
472
+ if (line.fenced) continue
473
+ const section = SECTION.exec(line.text)?.[1]
474
+ if (section !== undefined) {
475
+ if (isBody && BODY_CLOSERS.includes(section)) break
476
+ heading = section
477
+ continue
478
+ }
479
+ if (heading === CONTENTS_SECTION || !LEDGERED_LINE.test(line.text)) continue
480
+ counts.set(heading, (counts.get(heading) ?? 0) + 1)
481
+ }
482
+
483
+ return [...counts].map(([key, count]) => ({ heading: key, count }))
484
+ }
485
+
414
486
  /**
415
487
  * A key carrying an empty value declares nothing, so blank reads as absent
416
488
  * rather than as a name that disagrees with every folder.
@@ -603,6 +675,10 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
603
675
  ...sections,
604
676
  ...ledgerFindings(source),
605
677
  ...source.referenceLedgers.flatMap(referenceLedgerFindings),
678
+ ...source.staleExemptions.map((rel) => ({
679
+ rel,
680
+ detail: 'missing file: exempt from a ledger',
681
+ })),
606
682
  ]
607
683
  }
608
684
 
@@ -9,6 +9,8 @@ export interface RetiredVerb {
9
9
  const DESIGN_MOVED =
10
10
  'the design values now live in the stylesheet your canon/DESIGN.md lead names'
11
11
 
12
+ const CONTEXT_MOVED = '`canon context audit`, which reports placement findings'
13
+
12
14
  /**
13
15
  * Every verb a release dropped, with the release and where the work went. The
14
16
  * router reads this ahead of the parser, and `canon migrate status` reads it to
@@ -41,6 +43,12 @@ export const RETIRED_VERBS: readonly RetiredVerb[] = [
41
43
  release: '5.53.0',
42
44
  movedTo: 'a tool built for composed video',
43
45
  },
46
+ { path: ['context', 'classify'], release: '5.64.0', movedTo: CONTEXT_MOVED },
47
+ {
48
+ path: ['context', 'classifier'],
49
+ release: '5.64.0',
50
+ movedTo: CONTEXT_MOVED,
51
+ },
44
52
  ]
45
53
 
46
54
  /**
@@ -168,6 +168,15 @@ interface RoutingOptions {
168
168
  const SEEDED_FILES: readonly string[] = ['REQUIREMENTS.md', 'DESIGN.md']
169
169
  const SEEDED_DIRS: readonly string[] = ['tasks']
170
170
 
171
+ /**
172
+ * Where a `SEEDED_FILES` entry resolves for the sync presence check, at either
173
+ * surface root, so a target holding its records under `canon/` is not sent to
174
+ * `canon claude init`.
175
+ */
176
+ export function seededFilePath(resolved: string, name: string): string {
177
+ return surfaceDir(resolved, name)
178
+ }
179
+
171
180
  /**
172
181
  * Where a `SEEDED_DIRS` entry resolves for the sync presence check.
173
182
  *
@@ -679,7 +688,7 @@ async function runSync(target: string): Promise<number> {
679
688
  // already holds.
680
689
  logStep('Seeded')
681
690
  for (const name of SEEDED_FILES) {
682
- if (existsSync(join(resolved, '.claude', name))) logInfo(name)
691
+ if (existsSync(seededFilePath(resolved, name))) logInfo(name)
683
692
  else logWarn(`${name} missing. Run \`canon claude init\``)
684
693
  }
685
694
  for (const name of SEEDED_DIRS) {
@@ -1771,7 +1780,7 @@ function reportPracticeShape(report: SkillsAudit): void {
1771
1780
  `A listed practice skill closes with ${PRACTICE_SECTIONS.map((section) => `## ${section}`).join(', ')}, and every skill on the wider ledger list carries ${PRACTICE_LEDGER}.`,
1772
1781
  )
1773
1782
  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.`,
1783
+ `A skill on the per-bullet list, ${plural(Object.keys(BULLET_LEDGER_SKILLS).length, 'skill')} here, also matches each reference it does not exempt, and a listed body, to a ledger under ${SKILL_LEDGERS}, one row per bullet under each H2.`,
1775
1784
  )
1776
1785
  logInfo(
1777
1786
  `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.`,