@erclx/canon 5.43.0 → 5.44.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 (133) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/backlog-triage/references/apply.md +6 -6
  3. package/claude/skills/canon-cli/SKILL.md +4 -4
  4. package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
  5. package/claude/skills/canvas-board/SKILL.md +1 -1
  6. package/claude/skills/context-fold/references/context.md +1 -1
  7. package/claude/skills/create-skill/REQUIREMENT.md +1 -1
  8. package/claude/skills/create-skill/SKILL.md +2 -2
  9. package/claude/skills/create-skill/references/skill-requirement.md +68 -9
  10. package/claude/skills/create-skill/references/skill.md +1 -1
  11. package/claude/skills/design-identity/REQUIREMENT.md +40 -0
  12. package/claude/skills/design-identity/SKILL.md +138 -0
  13. package/claude/skills/{draft-identity → design-identity}/references/card-route.md +2 -2
  14. package/claude/skills/design-taste/REQUIREMENT.md +4 -4
  15. package/claude/skills/design-taste/SKILL.md +2 -2
  16. package/claude/skills/design-taste/references/kinds.md +1 -1
  17. package/claude/skills/design-taste/references/preflight.md +1 -1
  18. package/claude/skills/draft-and-pick/REQUIREMENT.md +8 -69
  19. package/claude/skills/draft-and-pick/SKILL.md +3 -153
  20. package/claude/skills/draft-doc/REQUIREMENT.md +9 -30
  21. package/claude/skills/draft-doc/SKILL.md +4 -76
  22. package/claude/skills/draft-figure/REQUIREMENT.md +8 -42
  23. package/claude/skills/draft-figure/SKILL.md +3 -77
  24. package/claude/skills/draft-identity/REQUIREMENT.md +8 -23
  25. package/claude/skills/draft-identity/SKILL.md +3 -130
  26. package/claude/skills/draft-ready/REQUIREMENT.md +8 -37
  27. package/claude/skills/draft-ready/SKILL.md +3 -128
  28. package/claude/skills/draft-screencast/REQUIREMENT.md +8 -23
  29. package/claude/skills/draft-screencast/SKILL.md +3 -62
  30. package/claude/skills/draft-slides/REQUIREMENT.md +8 -27
  31. package/claude/skills/draft-slides/SKILL.md +3 -72
  32. package/claude/skills/draw-figure/REQUIREMENT.md +59 -0
  33. package/claude/skills/draw-figure/SKILL.md +85 -0
  34. package/claude/skills/{draft-figure → draw-figure}/references/architecture-set.md +2 -2
  35. package/claude/skills/{draft-figure → draw-figure}/references/hand-drawn.md +2 -2
  36. package/claude/skills/{draft-figure → draw-figure}/references/mermaid-path.md +2 -2
  37. package/claude/skills/{draft-figure → draw-figure}/references/mermaid.md +1 -1
  38. package/claude/skills/make-deck/REQUIREMENT.md +44 -0
  39. package/claude/skills/make-deck/SKILL.md +80 -0
  40. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -1
  41. package/claude/skills/markdown-craft/SKILL.md +2 -2
  42. package/claude/skills/pick-by-eye/REQUIREMENT.md +86 -0
  43. package/claude/skills/pick-by-eye/SKILL.md +161 -0
  44. package/claude/skills/plan-feature/SKILL.md +2 -1
  45. package/claude/skills/plan-feature/references/plan.md +24 -10
  46. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  47. package/claude/skills/plan-intake/SKILL.md +1 -1
  48. package/claude/skills/plan-ready/REQUIREMENT.md +54 -0
  49. package/claude/skills/plan-ready/SKILL.md +136 -0
  50. package/claude/skills/{draft-ready → plan-ready}/references/assembly.md +1 -1
  51. package/claude/skills/{draft-ready → plan-ready}/references/ready.md +1 -1
  52. package/claude/skills/record-screencast/REQUIREMENT.md +3 -3
  53. package/claude/skills/record-screencast/SKILL.md +2 -2
  54. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  55. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +5 -2
  56. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  57. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +18 -13
  58. package/claude/skills/role-planner/SKILL.md +2 -1
  59. package/claude/skills/role-worker/SKILL.md +9 -0
  60. package/claude/skills/script-screencast/REQUIREMENT.md +40 -0
  61. package/claude/skills/script-screencast/SKILL.md +70 -0
  62. package/claude/skills/{draft-screencast → script-screencast}/references/beats-template.md +2 -2
  63. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  64. package/claude/skills/search-craft/SKILL.md +1 -1
  65. package/claude/skills/target-setup/SKILL.md +1 -0
  66. package/claude/skills/task-board/SKILL.md +11 -9
  67. package/claude/skills/task-board/references/board.md +15 -12
  68. package/claude/skills/teach-workspace/SKILL.md +1 -1
  69. package/claude/skills/teach-workspace/references/lesson-craft.md +2 -2
  70. package/claude/skills/ui-checklist/SKILL.md +2 -0
  71. package/claude/skills/write-doc/REQUIREMENT.md +46 -0
  72. package/claude/skills/write-doc/SKILL.md +83 -0
  73. package/claude/skills/{draft-doc → write-doc}/references/context.md +2 -2
  74. package/claude/skills/{draft-doc → write-doc}/references/docs.md +2 -2
  75. package/claude/skills/{draft-doc → write-doc}/references/readme.md +2 -2
  76. package/claude/skills/{draft-doc → write-doc}/references/wiki-kind.md +3 -3
  77. package/docs/agents/markdown-audit.md +4 -2
  78. package/docs/agents/migrate.md +2 -2
  79. package/docs/target/migrations.md +17 -4
  80. package/docs/target/projects.md +1 -1
  81. package/docs/workflow/parallel-features.md +3 -2
  82. package/docs/workflow/skill-map-on-request.md +18 -12
  83. package/docs/workflow/skill-map.md +13 -12
  84. package/docs/workflow/visual-design-workflow.md +2 -2
  85. package/governance/ledger/framework/astro.md +73 -0
  86. package/governance/ledger/framework/fastapi.md +76 -0
  87. package/governance/ledger/framework/nextjs.md +86 -0
  88. package/governance/ledger/framework/react.md +83 -0
  89. package/governance/ledger/framework/shadcn.md +60 -0
  90. package/governance/ledger/framework/tailwind.md +55 -0
  91. package/governance/rules/framework/astro.md +12 -14
  92. package/governance/rules/framework/fastapi.md +20 -24
  93. package/governance/rules/framework/nextjs.md +19 -27
  94. package/governance/rules/framework/react.md +22 -33
  95. package/governance/rules/framework/shadcn.md +11 -15
  96. package/governance/rules/framework/tailwind.md +12 -14
  97. package/governance/rules/writing/markdown.md +1 -1
  98. package/package.json +1 -1
  99. package/src/claude/cases/authoring.ts +15 -3
  100. package/src/claude/cases/misc.ts +12 -0
  101. package/src/claude/cases/workflow.ts +19 -1
  102. package/src/claude/skills-audit.ts +113 -1
  103. package/src/commands/claude.ts +34 -11
  104. package/src/commands/demo.ts +1 -1
  105. package/src/commands/markdown.ts +1 -1
  106. package/src/commands/migrate.ts +4 -0
  107. package/src/commands/pr.ts +30 -6
  108. package/src/commands/tasks.ts +383 -2
  109. package/src/context/audit.ts +1 -1
  110. package/src/demo/beats.ts +1 -1
  111. package/src/gov/audit.ts +3 -2
  112. package/src/markdown/scope.ts +45 -35
  113. package/src/migrate/record-layout.ts +1 -1
  114. package/src/migrate/skill-names.ts +68 -21
  115. package/src/pr/frames.ts +14 -3
  116. package/src/records/validate.ts +31 -0
  117. package/src/tasks/board.ts +854 -0
  118. package/src/tasks/record.ts +20 -1
  119. package/src/tasks/validate.ts +206 -9
  120. package/tooling/astro/configs/card-src/pages/og-card.astro +1 -1
  121. package/tooling/astro/reference.md +2 -2
  122. package/tooling/claude/seeds/.claude/hooks/board-validate.sh +65 -0
  123. package/tooling/claude/seeds/.claude/settings.json +4 -0
  124. /package/claude/skills/{draft-figure → draw-figure}/references/figures.md +0 -0
  125. /package/claude/skills/{draft-slides → make-deck}/references/deck.md +0 -0
  126. /package/claude/skills/{draft-and-pick → pick-by-eye}/references/live-arms.md +0 -0
  127. /package/claude/skills/{draft-doc → write-doc}/references/readme/agent-facing.md +0 -0
  128. /package/claude/skills/{draft-doc → write-doc}/references/readme/application.md +0 -0
  129. /package/claude/skills/{draft-doc → write-doc}/references/readme/cli.md +0 -0
  130. /package/claude/skills/{draft-doc → write-doc}/references/readme/library.md +0 -0
  131. /package/claude/skills/{draft-doc → write-doc}/references/readme/plugin.md +0 -0
  132. /package/claude/skills/{draft-doc → write-doc}/references/requirements.md +0 -0
  133. /package/claude/skills/{draft-doc → write-doc}/references/wiki.md +0 -0
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { isFamilyKey } from '@/claude/skills-families'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
- import { bodyLines, maskDisplayed } from '@/markdown/scan'
5
+ import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
6
6
 
7
7
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
8
8
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -13,6 +13,19 @@ export const DESCRIPTION_LIMIT = 1024
13
13
  /** The two headings the requirement template declares, matched at any level. */
14
14
  export const REQUIREMENT_SECTIONS: readonly string[] = ['Gap', 'Must']
15
15
 
16
+ /**
17
+ * The caps `claude/skills/create-skill/references/skill-requirement.md` states.
18
+ * Words count body tokens carrying a letter or digit, so a heading or bullet
19
+ * marker costs nothing and frontmatter is left out.
20
+ */
21
+ export const REQUIREMENT_LIMITS = { bullets: 5, words: 400 } as const
22
+
23
+ /** The section the standard retired into Must not, matched at any level. */
24
+ export const RETIRED_REQUIREMENT_SECTION = 'Guards'
25
+
26
+ /** The section that states today's failures, where history is reported. */
27
+ const GAP_SECTION = 'Gap'
28
+
16
29
  /** The one tree that ships to a target, and the only one the practice list names. */
17
30
  export const SHIPPED_CORPUS = join('claude', 'skills')
18
31
 
@@ -100,6 +113,13 @@ const SECTION = /^##\s+(.+?)\s*$/
100
113
  */
101
114
  const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/g
102
115
 
116
+ /** An issue or pull request number, which a heading's `#` never matches. */
117
+ const ISSUE_NUMBER = /(?<![\w&/#])#\d+\b/g
118
+
119
+ const TOP_LEVEL_BULLET = /^(?:[-*+]|\d+[.)])\s/
120
+
121
+ const WORD = /\S*[\p{L}\p{N}]\S*/gu
122
+
103
123
  export interface SkillFinding {
104
124
  readonly rel: string
105
125
  readonly detail: string
@@ -126,6 +146,7 @@ export interface SkillsAudit {
126
146
  readonly nameMismatch: readonly SkillFinding[]
127
147
  readonly longDescription: readonly SkillFinding[]
128
148
  readonly requirementSections: readonly SkillFinding[]
149
+ readonly requirementLimits: readonly SkillFinding[]
129
150
  readonly datedProvenance: readonly SkillFinding[]
130
151
  readonly practiceShape: readonly SkillFinding[]
131
152
  readonly referenceContents: readonly SkillFinding[]
@@ -142,6 +163,7 @@ interface SkillSource {
142
163
  readonly family: string | undefined
143
164
  /** Undefined when the folder carries no `REQUIREMENT.md` at all. */
144
165
  readonly requirementHeadings: readonly string[] | undefined
166
+ readonly requirement: string | undefined
145
167
  /** Every H2 in `SKILL.md` outside a fence, for the practice shape. */
146
168
  readonly sections: readonly string[]
147
169
  readonly hasLedger: boolean
@@ -198,6 +220,7 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
198
220
  nameMismatch: sources.flatMap(nameFindings),
199
221
  longDescription: sources.flatMap(lengthFindings),
200
222
  requirementSections: sources.flatMap(sectionFindings),
223
+ requirementLimits: sources.flatMap(limitFindings),
201
224
  datedProvenance: sources.flatMap((source) =>
202
225
  source.bodies.flatMap(dateFindings),
203
226
  ),
@@ -269,6 +292,7 @@ async function readSkill(
269
292
  family: declaredFamily(fields?.fields.metadata),
270
293
  requirementHeadings:
271
294
  requirement === undefined ? undefined : headings(requirement),
295
+ requirement,
272
296
  sections: sectionsOf(body),
273
297
  hasLedger: existsSync(join(skillDir, PRACTICE_LEDGER)),
274
298
  bodies: [
@@ -349,6 +373,94 @@ function sectionFindings(source: SkillSource): SkillFinding[] {
349
373
  ]
350
374
  }
351
375
 
376
+ /**
377
+ * Reports every cap the standard states, each as its own finding so a file
378
+ * breaching two reads as two edits. Silent on an absent requirement for the
379
+ * reason `sectionFindings` is.
380
+ */
381
+ function limitFindings(source: SkillSource): SkillFinding[] {
382
+ const { requirement, requirementHeadings } = source
383
+ if (requirement === undefined || requirementHeadings === undefined) return []
384
+
385
+ const rel = join(source.rel, 'REQUIREMENT.md')
386
+ const lines = bodyLines(requirement)
387
+ const details = [
388
+ ...bulletDetails(lines),
389
+ ...wordDetails(lines),
390
+ ...gapHistoryDetails(lines),
391
+ ...(requirementHeadings.includes(RETIRED_REQUIREMENT_SECTION)
392
+ ? [`## ${RETIRED_REQUIREMENT_SECTION}: retired, fold it into Must not`]
393
+ : []),
394
+ ]
395
+
396
+ return details.map((detail) => ({ rel, detail }))
397
+ }
398
+
399
+ /**
400
+ * Counts top-level bullets per H2, since the template holds one list per
401
+ * section. A nested bullet elaborates its parent rather than adding to it.
402
+ */
403
+ function bulletDetails(lines: readonly BodyLine[]): string[] {
404
+ const counts = new Map<string, number>()
405
+ let section: string | undefined
406
+
407
+ for (const line of lines) {
408
+ if (line.fenced) continue
409
+ const heading = SECTION.exec(line.text)?.[1]
410
+ if (heading !== undefined) {
411
+ section = heading
412
+ continue
413
+ }
414
+ if (section === undefined || !TOP_LEVEL_BULLET.test(line.text)) continue
415
+ counts.set(section, (counts.get(section) ?? 0) + 1)
416
+ }
417
+
418
+ return [...counts]
419
+ .filter(([, count]) => count > REQUIREMENT_LIMITS.bullets)
420
+ .map(
421
+ ([name, count]) =>
422
+ `## ${name}: ${count} bullets, cap ${REQUIREMENT_LIMITS.bullets}`,
423
+ )
424
+ }
425
+
426
+ function wordDetails(lines: readonly BodyLine[]): string[] {
427
+ const words = lines.reduce(
428
+ (total, line) => total + (line.text.match(WORD)?.length ?? 0),
429
+ 0,
430
+ )
431
+ if (words <= REQUIREMENT_LIMITS.words) return []
432
+ return [`${words} words, cap ${REQUIREMENT_LIMITS.words}`]
433
+ }
434
+
435
+ /**
436
+ * Gap states what fails today, so a date or a number naming the incident that
437
+ * earned a line is history git already holds. A code span is quoted data, such
438
+ * as a sample filename, and is masked before matching.
439
+ */
440
+ function gapHistoryDetails(lines: readonly BodyLine[]): string[] {
441
+ const details: string[] = []
442
+ let inGap = false
443
+
444
+ for (const line of lines) {
445
+ if (line.fenced) continue
446
+ const heading = HEADING.exec(line.text)?.[1]
447
+ if (heading !== undefined) {
448
+ inGap = heading === GAP_SECTION
449
+ continue
450
+ }
451
+ if (!inGap) continue
452
+ const masked = maskDisplayed(line.text)
453
+ for (const match of [
454
+ ...masked.matchAll(ISO_DATE),
455
+ ...masked.matchAll(ISSUE_NUMBER),
456
+ ]) {
457
+ details.push(`line ${line.number}: ${match[0]} in ${GAP_SECTION}`)
458
+ }
459
+ }
460
+
461
+ return details
462
+ }
463
+
352
464
  /**
353
465
  * A listed folder the shipped corpus does not hold, such as a renamed skill or
354
466
  * a misspelled append, would otherwise read as a pass. Silent where the
@@ -32,7 +32,9 @@ import {
32
32
  PRACTICE_LEDGER,
33
33
  PRACTICE_SECTIONS,
34
34
  PRACTICE_SKILLS,
35
+ REQUIREMENT_LIMITS,
35
36
  REQUIREMENT_SECTIONS,
37
+ RETIRED_REQUIREMENT_SECTION,
36
38
  SHIPPED_CORPUS,
37
39
  type SkillFinding,
38
40
  type SkillsAudit,
@@ -441,7 +443,7 @@ export function register(program: Command): void {
441
443
  'Refuses when neither claude/skills/ nor .claude/skills/ resolves.',
442
444
  '',
443
445
  'Only a missing REQUIREMENT.md sets a failing exit code. Name, description,',
444
- 'folder, and requirement-section findings are advisory.',
446
+ 'folder, and requirement shape and limit findings are advisory.',
445
447
  '',
446
448
  'Examples:',
447
449
  ' canon claude skills audit',
@@ -1503,6 +1505,7 @@ async function runSkillsAudit(
1503
1505
  readme: report.readme,
1504
1506
  folderName: report.folderName,
1505
1507
  requirementSections: report.requirementSections,
1508
+ requirementLimits: report.requirementLimits,
1506
1509
  datedProvenance: report.datedProvenance,
1507
1510
  practiceShape: report.practiceShape,
1508
1511
  referenceContents: report.referenceContents,
@@ -1511,6 +1514,7 @@ async function runSkillsAudit(
1511
1514
  checkpoints: {
1512
1515
  descriptionLimit: DESCRIPTION_LIMIT,
1513
1516
  requirementSections: REQUIREMENT_SECTIONS,
1517
+ requirementLimits: REQUIREMENT_LIMITS,
1514
1518
  corpora: CORPORA,
1515
1519
  practiceSkills: PRACTICE_SKILLS,
1516
1520
  ledgerSkills: LEDGER_SKILLS,
@@ -1671,21 +1675,40 @@ function reportRequirementShape(report: SkillsAudit): void {
1671
1675
  'A folder carrying no requirement is reported above rather than counted twice here.',
1672
1676
  )
1673
1677
 
1674
- if (report.requirementSections.length === 0) {
1675
- logInfo('Every requirement declares both.')
1678
+ logInfo(
1679
+ `Each list holds at most ${REQUIREMENT_LIMITS.bullets} top-level bullets, the body at most ${REQUIREMENT_LIMITS.words} words, and Gap no date or issue number outside a code span.`,
1680
+ )
1681
+ logInfo(
1682
+ `A ## ${RETIRED_REQUIREMENT_SECTION} section is retired into Must not. These limits report and never fail the run.`,
1683
+ )
1684
+
1685
+ if (
1686
+ report.requirementSections.length === 0 &&
1687
+ report.requirementLimits.length === 0
1688
+ ) {
1689
+ logInfo('Every requirement declares both and holds to the limits.')
1676
1690
  return
1677
1691
  }
1678
1692
 
1679
- logWarn(
1680
- `${plural(report.requirementSections.length, 'requirement')} short a declared section`,
1681
- )
1682
- reportFindings(report.requirementSections)
1693
+ if (report.requirementSections.length > 0) {
1694
+ logWarn(
1695
+ `${plural(report.requirementSections.length, 'requirement')} short a declared section`,
1696
+ )
1697
+ reportFindings(report.requirementSections)
1698
+ }
1699
+
1700
+ if (report.requirementLimits.length > 0) {
1701
+ logWarn(
1702
+ `${plural(report.requirementLimits.length, 'finding')} past a requirement limit`,
1703
+ )
1704
+ reportFindings(report.requirementLimits)
1705
+ }
1683
1706
  }
1684
1707
 
1685
1708
  /**
1686
- * Names where the date belongs, since the finding is a date to move rather
1687
- * than one to delete, and reports without failing the run for the same reason
1688
- * the frontmatter measures do.
1709
+ * Names where the date belongs, since a reader deleting it should know git
1710
+ * still holds it, and reports without failing the run for the same reason the
1711
+ * frontmatter measures do.
1689
1712
  */
1690
1713
  function reportProvenance(report: SkillsAudit): void {
1691
1714
  logStep('Provenance')
@@ -1693,7 +1716,7 @@ function reportProvenance(report: SkillsAudit): void {
1693
1716
  'A SKILL.md and its references state the current rule, with no ISO date outside a fence or code span.',
1694
1717
  )
1695
1718
  logInfo(
1696
- 'The incident and date that earned a rule go in REQUIREMENT.md under Gap, or stay in git.',
1719
+ 'The incident and date that earned a rule stay in git, where the log of the file finds them.',
1697
1720
  )
1698
1721
 
1699
1722
  if (report.datedProvenance.length === 0) {
@@ -62,7 +62,7 @@ export function register(program: Command): void {
62
62
  demo
63
63
  .command('compile')
64
64
  .description('Turn a screencast draft into a plan a run can drive')
65
- .argument('<draft>', 'Screencast draft written by draft-screencast')
65
+ .argument('<draft>', 'Screencast draft written by script-screencast')
66
66
  .helpOption('-h, --help', 'Show this help message')
67
67
  .option('-o, --out <dir>', 'Directory the plan is written to', DEFAULT_OUT)
68
68
  .option('-s, --slug <slug>', 'Plan name, defaulting to the draft filename')
@@ -486,7 +486,7 @@ function reportDensity(reports: readonly FileReport[], gates: boolean): void {
486
486
  `Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, and an H3 opening with neither a paragraph nor a table.`,
487
487
  )
488
488
  logInfo(
489
- 'Measured on context entries, docs, skill bodies and references, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. Every other path takes no density finding.',
489
+ 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. Every other path takes no density finding.',
490
490
  )
491
491
  logInfo(
492
492
  gates
@@ -1335,6 +1335,10 @@ export function register(program: Command): void {
1335
1335
  '',
1336
1336
  `Rewrites ${Object.keys(SKILL_NAME_MAP).length} skill names and moves the folders that carry them.`,
1337
1337
  `It also carries ${Object.keys(SKILL_CITATION_MAP).length} namespaced-only rows for a skill renamed from a single word.`,
1338
+ 'The seven draft- skills move onto verb-first names, such as',
1339
+ // The old spellings below are the rename itself. canon-keep-retired
1340
+ 'draft-doc to write-doc and draft-and-pick to pick-by-eye, and a',
1341
+ 'pointer left under each old name is never moved.',
1338
1342
  'It takes no scope. The skill folders it moves are authored in the',
1339
1343
  'toolkit and no target holds a copy of that catalog, so in a target it',
1340
1344
  'rewrites citations of a renamed skill and moves nothing.',
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { readFile, realpath } from 'node:fs/promises'
3
3
  import { basename, join, resolve } from 'node:path'
4
4
  import { $ } from 'bun'
@@ -57,6 +57,7 @@ import {
57
57
  pruneFramesWithLegacy,
58
58
  pushFrame,
59
59
  readFrame,
60
+ isDesignReference,
60
61
  referenceSlug,
61
62
  type TreeEntry,
62
63
  } from '@/pr/frames'
@@ -222,9 +223,9 @@ const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
222
223
  'no-number':
223
224
  '--add and --drop write for one pull request, so name its number.',
224
225
  'bad-box':
225
- '--add takes --box <n>, a positive whole number naming the checklist box, or --reference <page>/<frame>, never both.',
226
+ '--add takes --box <n>, a positive whole number naming the checklist box, or --reference, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, never both.',
226
227
  'bad-reference':
227
- '--reference takes <page>/<frame>, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
228
+ '--reference takes <page>/<frame> or a tracked canon/design/<surface>/<name> path, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
228
229
  'bad-head':
229
230
  '--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
230
231
  'bad-pass':
@@ -1060,7 +1061,7 @@ export function register(program: Command): void {
1060
1061
  .option('--box <n>', 'The checklist box the frame shows, with --add')
1061
1062
  .option(
1062
1063
  '--reference <address>',
1063
- 'The design reference the capture shows, as <page>/<frame>, with --add in place of --box',
1064
+ 'The design reference the capture shows, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, with --add in place of --box',
1064
1065
  )
1065
1066
  .option(
1066
1067
  '--head <sha>',
@@ -1087,7 +1088,9 @@ export function register(program: Command): void {
1087
1088
  'so nothing is checked out and no other branch is touched. A frame lands',
1088
1089
  'at pr-<number>/<short-head>/<pass>/box-<n>.png, or box-<n>-<theme>.png',
1089
1090
  'with --theme, or ref-<page>--<frame>.png for a design reference capture',
1090
- 'passed as --reference <page>/<frame> in place of --box, and `link` names that',
1091
+ 'passed as --reference <page>/<frame> in place of --box, or',
1092
+ 'ref-design--<surface>--<name>.png for a tracked canon/design/<surface>/<name>',
1093
+ 'reference, whose --add must name that same file, and `link` names that',
1091
1094
  `path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
1092
1095
  'requests and resolves until its own pull request is dropped. The pass',
1093
1096
  'segment keeps a second pass at one head and box from replacing an image',
@@ -1109,7 +1112,7 @@ export function register(program: Command): void {
1109
1112
  ' bad-mode not exactly one of --add, --drop, --prune',
1110
1113
  ' no-number --add or --drop named no pull request',
1111
1114
  ' bad-box --add with both --box and --reference, or neither',
1112
- ' bad-reference --reference is not <page>/<frame> in the safe name form',
1115
+ ' bad-reference --reference is not <page>/<frame> or canon/design/<surface>/<name> in the safe name form, or the tracked path is no file',
1113
1116
  ' bad-head --head is not 7 to 40 lowercase hex characters',
1114
1117
  ' bad-pass --add without a compact UTC --pass stamp',
1115
1118
  ' bad-theme --theme is not dark or light, or no --add, or rides a --reference',
@@ -3192,6 +3195,27 @@ async function runFrames(
3192
3195
  const invalid = framesArgumentRefusal(number, opts)
3193
3196
  if (invalid !== undefined) return refuseFrames(invalid)
3194
3197
 
3198
+ if (opts.reference !== undefined && isDesignReference(opts.reference)) {
3199
+ const tracked = resolve(
3200
+ root,
3201
+ opts.reference.endsWith('.png')
3202
+ ? opts.reference
3203
+ : `${opts.reference}.png`,
3204
+ )
3205
+ const refuseTracked = (message: string) =>
3206
+ refuseWith('bad-reference', message, emitJson, root)
3207
+ if (!statSync(tracked, { throwIfNoEntry: false })?.isFile()) {
3208
+ return refuseTracked(
3209
+ `${opts.reference} is not a file under the repository, so no tracked reference can be filed from it.`,
3210
+ )
3211
+ }
3212
+ if (opts.add === undefined || resolve(opts.add) !== tracked) {
3213
+ return refuseTracked(
3214
+ `--add must name ${tracked}, since a tracked reference files its own image and a different one would disagree with its label.`,
3215
+ )
3216
+ }
3217
+ }
3218
+
3195
3219
  const bytes =
3196
3220
  opts.add === undefined ? undefined : await readFrame(resolve(opts.add))
3197
3221
  if (opts.add !== undefined && bytes === undefined) {