@erclx/canon 4.79.0 → 4.81.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 (36) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-rollout/SKILL.md +1 -1
  3. package/claude/skills/design-extract/SKILL.md +5 -1
  4. package/claude/skills/git-pr/SKILL.md +3 -3
  5. package/claude/skills/git-pr/references/labels.md +1 -1
  6. package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
  7. package/claude/skills/sketch-design/SKILL.md +92 -0
  8. package/claude/skills/teach-workspace/SKILL.md +23 -1
  9. package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
  10. package/docs/agents/audits.md +2 -2
  11. package/docs/agents/commands.md +6 -4
  12. package/docs/agents/design-board.md +13 -11
  13. package/docs/agents/index.md +1 -1
  14. package/docs/agents/install-and-sync.md +3 -3
  15. package/docs/agents/label-coverage.md +1 -1
  16. package/docs/agents/teach.md +16 -0
  17. package/docs/target-projects.md +1 -1
  18. package/docs/workflow/ai-workflow.md +1 -0
  19. package/docs/workflow/visual-design-workflow.md +1 -0
  20. package/package.json +1 -1
  21. package/src/audits/baseline.ts +17 -5
  22. package/src/claude/cases/misc.ts +5 -0
  23. package/src/commands/design.ts +24 -8
  24. package/src/commands/sync.ts +1 -1
  25. package/src/commands/teach.ts +118 -0
  26. package/src/design/board.ts +130 -47
  27. package/src/gate/measures.ts +4 -4
  28. package/src/labels/map.ts +19 -5
  29. package/src/legacy-path.ts +15 -0
  30. package/src/migrate/plan.ts +1 -0
  31. package/src/migrate/surface-roots.ts +5 -6
  32. package/src/project-root.ts +16 -0
  33. package/src/surface-root.ts +7 -14
  34. package/src/sync/stamp.ts +22 -17
  35. package/src/targets/sweep.ts +6 -2
  36. package/src/teach/render.ts +126 -0
@@ -11,12 +11,17 @@ import { buildDesignCss } from '@/design/css'
11
11
  import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
12
12
  import { renderDesignDoc } from '@/design/render'
13
13
  import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
14
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
14
+ import {
15
+ checkoutMismatchWarning,
16
+ isOwnCheckout,
17
+ PROJECT_ROOT,
18
+ } from '@/project-root'
15
19
  import { creationRel } from '@/record-root'
16
20
  import { surfaceDir } from '@/surface-root'
17
21
  import { recordStamp, runDomainSync } from '@/sync/engine'
18
22
  import { resolveTarget } from '@/target'
19
23
  import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
24
+ import { mainWorktreeRoot } from '@/worktree'
20
25
 
21
26
  export function register(program: Command): void {
22
27
  const design = program
@@ -133,33 +138,44 @@ export function register(program: Command): void {
133
138
  design
134
139
  .command('board')
135
140
  .description(
136
- 'Generate the design board, an index over this repository’s own design surfaces',
141
+ 'Generate the design board, an index over a project’s design surfaces',
137
142
  )
138
143
  .option(
139
144
  '-o, --out <path>',
140
145
  'Output directory',
141
146
  creationRel(process.cwd(), 'review', 'board'),
142
147
  )
148
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
143
149
  .addHelpText(
144
150
  'after',
145
151
  [
146
152
  '',
147
- 'Like regen, this runs against the toolkit checkout rather than a',
148
- 'target: it reads five sources already on disk relative to the',
149
- 'project root and writes a static page set, never installed or',
150
- 'synced. Open it with canon serve <out>.',
153
+ 'Reads its sources from --root, defaulting to the main worktree of',
154
+ 'whatever project the caller stands in, and writes a static page',
155
+ 'set, never installed or synced. The surfaces panel’s landing-page',
156
+ 'half and the whole components panel render only when that root is',
157
+ 'this toolkit’s own checkout. Open the result with canon serve <out>.',
151
158
  '',
152
159
  ].join('\n'),
153
160
  )
154
- .action((opts: { out: string }) => {
161
+ .action(async (opts: { out: string; root?: string }) => {
155
162
  const outDir = resolve(process.cwd(), opts.out)
163
+ const root = opts.root
164
+ ? resolve(process.cwd(), opts.root)
165
+ : await mainWorktreeRoot()
166
+ const isToolkitCheckout = isOwnCheckout(root)
156
167
  const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
157
168
  const mismatch = checkoutMismatchWarning(process.cwd())
158
169
  process.stderr.write(
159
170
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Generate design board${NC}\n`,
160
171
  )
161
172
  if (mismatch !== undefined) logWarn(mismatch)
162
- const result = generateBoard(PROJECT_ROOT, outDir, process.cwd())
173
+ const result = generateBoard(
174
+ root,
175
+ outDir,
176
+ process.cwd(),
177
+ isToolkitCheckout,
178
+ )
163
179
  if (!result.ok) {
164
180
  process.stderr.write(
165
181
  `${GREY}│${NC} ${RED}✗${NC} ${result.detail}\n${GREY}└${NC}\n`,
@@ -101,7 +101,7 @@ function renderCheck(report: CheckReport): void {
101
101
 
102
102
  if (report.stampAtLegacyPath) {
103
103
  logWarn(
104
- 'Stamp found at a retired path. Move it to .claude/canon/config.json.',
104
+ 'Stamp found at a retired path. Move it to canon/config/config.json.',
105
105
  )
106
106
  }
107
107
 
@@ -2,6 +2,7 @@ import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
4
4
  import { type NavOutcome, generateNav } from '@/teach/nav'
5
+ import { type RenderOutcome, renderLessonBody } from '@/teach/render'
5
6
  import {
6
7
  defineTerms,
7
8
  type ListOutcome,
@@ -81,6 +82,10 @@ interface NavCommandOptions {
81
82
  readonly root?: string
82
83
  }
83
84
 
85
+ interface RenderCommandOptions {
86
+ readonly json?: boolean
87
+ }
88
+
84
89
  export function register(program: Command): void {
85
90
  const teach = program
86
91
  .command('teach')
@@ -357,6 +362,36 @@ export function register(program: Command): void {
357
362
  .action(async (topic: string | undefined, opts: NavCommandOptions) => {
358
363
  process.exitCode = await runNav(topic, opts)
359
364
  })
365
+
366
+ teach
367
+ .command('render')
368
+ .description('Render a lesson body block list to HTML')
369
+ .helpOption('-h, --help', 'Show this help message')
370
+ .option('--json', 'Emit a machine-readable record on stdout')
371
+ .addHelpText(
372
+ 'after',
373
+ [
374
+ '',
375
+ 'Exit codes:',
376
+ ' 0 the block list rendered',
377
+ ' 1 refused, with the reason on stderr or in the JSON record',
378
+ '',
379
+ 'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
380
+ 'list, or raw block, and renders it through the same components the',
381
+ 'fixture lesson is generated from. Content the three cannot express',
382
+ 'takes type raw, carrying its own html verbatim, unescaped.',
383
+ '',
384
+ 'Takes no topic and no --root: the verb is a stateless transform,',
385
+ 'reading nothing off a workspace on disk.',
386
+ '',
387
+ 'Examples:',
388
+ ' echo \'[{"type":"heading","level":1,"text":"Compass bearings"}]\' | canon teach render --json',
389
+ '',
390
+ ].join('\n'),
391
+ )
392
+ .action(async (opts: RenderCommandOptions) => {
393
+ process.exitCode = await runRender(opts)
394
+ })
360
395
  }
361
396
 
362
397
  interface StylesheetCommandOptions {
@@ -403,6 +438,17 @@ function collect(value: string, previous: string[]): string[] {
403
438
  return [...previous, value]
404
439
  }
405
440
 
441
+ function readStdin(): Promise<string> {
442
+ return new Promise((resolveStream, rejectStream) => {
443
+ const chunks: Buffer[] = []
444
+ process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
445
+ process.stdin.on('end', () =>
446
+ resolveStream(Buffer.concat(chunks).toString('utf8')),
447
+ )
448
+ process.stdin.on('error', rejectStream)
449
+ })
450
+ }
451
+
406
452
  /**
407
453
  * Splits every pair or reports the ones that carry no separator. Both halves
408
454
  * are reported together, so a caller passing four pairs learns about all the
@@ -648,6 +694,78 @@ async function runNav(
648
694
  return reportNav(await generateNav(root, topic), emitJson, root)
649
695
  }
650
696
 
697
+ async function runRender(opts: RenderCommandOptions): Promise<number> {
698
+ const emitJson = opts.json ?? false
699
+
700
+ if (process.stdin.isTTY) {
701
+ return reportRenderRefusal(
702
+ badInput(
703
+ "No blocks on stdin. Pipe a JSON array: echo '[...]' | canon teach render",
704
+ ),
705
+ emitJson,
706
+ )
707
+ }
708
+
709
+ const body = (await readStdin()).trim()
710
+
711
+ if (!body) {
712
+ return reportRenderRefusal(
713
+ badInput('Empty stdin. Pipe a JSON array of blocks.'),
714
+ emitJson,
715
+ )
716
+ }
717
+
718
+ let parsed: unknown
719
+ try {
720
+ parsed = JSON.parse(body)
721
+ } catch {
722
+ return reportRenderRefusal(badInput('Malformed JSON on stdin.'), emitJson)
723
+ }
724
+
725
+ if (!Array.isArray(parsed)) {
726
+ return reportRenderRefusal(
727
+ badInput('Stdin must be a JSON array of blocks.'),
728
+ emitJson,
729
+ )
730
+ }
731
+
732
+ return reportRender(renderLessonBody(parsed), emitJson)
733
+ }
734
+
735
+ function reportRenderRefusal(refused: TeachRefused, emitJson: boolean): number {
736
+ if (emitJson) {
737
+ process.stderr.write(`${refused.message}\n`)
738
+ process.stdout.write(
739
+ `${JSON.stringify({
740
+ ok: false,
741
+ reason: refused.reason,
742
+ message: refused.message,
743
+ })}\n`,
744
+ )
745
+ return 1
746
+ }
747
+
748
+ intro('canon teach render')
749
+ logStep('Refused')
750
+ logError(refused.message)
751
+ outro()
752
+ return 1
753
+ }
754
+
755
+ function reportRender(outcome: RenderOutcome, emitJson: boolean): number {
756
+ if (!outcome.ok) return reportRenderRefusal(outcome, emitJson)
757
+
758
+ if (emitJson) {
759
+ process.stdout.write(
760
+ `${JSON.stringify({ ok: true, html: outcome.html })}\n`,
761
+ )
762
+ return 0
763
+ }
764
+
765
+ process.stdout.write(`${outcome.html}\n`)
766
+ return 0
767
+ }
768
+
651
769
  function reportNav(
652
770
  outcome: NavOutcome,
653
771
  emitJson: boolean,
@@ -11,13 +11,17 @@ import { join, relative, sep } from 'node:path'
11
11
  import { DESIGN_DOCUMENT } from '@/design/regen'
12
12
  import { renderDesignDoc } from '@/design/render'
13
13
  import { colorValue } from '@/design/tokens'
14
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
14
15
  import { recordDir } from '@/record-root'
16
+ import { surfaceDir } from '@/surface-root'
15
17
 
16
18
  /**
17
- * The one named site for this repository's wireframe corpus, so the surface
18
- * move retargets a single constant rather than a literal repeated per panel.
19
- * The board reads this checkout alone, which has moved, so the new root is
20
- * spelled directly rather than resolved.
19
+ * The wireframes panel's creation-root spelling, used in its own empty-state
20
+ * message and by this module's tests to seed a fixture. The panel itself
21
+ * resolves its actual read path through `surfaceDir`, which also reads
22
+ * `.claude/wireframes/` for a target that has not run the surface-roots
23
+ * migration, so this constant names where a fresh file lands rather than
24
+ * where every root's corpus necessarily sits.
21
25
  */
22
26
  export const WIREFRAME_DIR = join('canon', 'wireframes')
23
27
 
@@ -34,26 +38,6 @@ const WEB_DIST_ENTRY = 'index.html'
34
38
  const WEB_GALLERY_DIST = join('web', 'gallery-dist')
35
39
  const WEB_GALLERY_DIST_ENTRY = 'index.html'
36
40
 
37
- interface WireframeEntry {
38
- readonly path: string
39
- readonly describes: string
40
- }
41
-
42
- /**
43
- * The six files the wireframes panel renders, each beside the surface it
44
- * describes. `index.md` at either level is a catalog rather than a wireframe
45
- * and is excluded, which is why this list holds six rather than the eight
46
- * files the corpus carries today.
47
- */
48
- const WIREFRAMES: readonly WireframeEntry[] = [
49
- { path: 'landing-page.md', describes: 'The canon.erclx.dev landing page' },
50
- { path: 'slides.md', describes: 'The SLIDES.md render' },
51
- { path: 'teach/root.md', describes: 'A teach workspace root listing' },
52
- { path: 'teach/contents.md', describes: 'A workspace contents page' },
53
- { path: 'teach/lesson.md', describes: 'A lesson page and quiz stepper' },
54
- { path: 'teach/chrome.md', describes: 'The shared teach chrome' },
55
- ]
56
-
57
41
  const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
58
42
 
59
43
  export interface BoardPanel {
@@ -233,7 +217,8 @@ iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); bord
233
217
  <body>
234
218
  <header>
235
219
  <h1>Design board</h1>
236
- <p class="meta">Generated by <code>canon design board</code>. Repository-local, never installed into a target.</p>
220
+ <p class="meta">Generated by <code>canon design board</code>. Reads from a caller-resolved root, never installed or synced.</p>
221
+ <p class="meta">Drafting a new candidate: <code>canon:draft-and-pick</code>. Auditing a rendered surface: <code>canon:ux-audit</code>.</p>
237
222
  </header>
238
223
  <nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
239
224
  <main>
@@ -282,7 +267,7 @@ ${body}
282
267
  }
283
268
 
284
269
  function writeTokensPanel(root: string, outDir: string): void {
285
- const sourcePath = join(root, DESIGN_DOCUMENT)
270
+ const sourcePath = surfaceDir(root, 'DESIGN.md')
286
271
  const dir = join(outDir, 'tokens')
287
272
 
288
273
  if (!existsSync(sourcePath)) {
@@ -300,18 +285,56 @@ function writeTokensPanel(root: string, outDir: string): void {
300
285
  renderDesignDoc(sourcePath, dir)
301
286
  }
302
287
 
288
+ interface WireframeFile {
289
+ readonly relPath: string
290
+ readonly text: string
291
+ readonly description: string | undefined
292
+ }
293
+
294
+ /**
295
+ * Every wireframe under whichever root the corpus resolves at, sorted for a
296
+ * stable render order across runs. `surfaceDir` reads `.claude/wireframes/`
297
+ * for a target that has not run the surface-roots migration, the same
298
+ * fallback `recordDir` already gives the surfaces panel's teach half.
299
+ * `index.md` at any depth is a catalog rather than a wireframe and is
300
+ * excluded. A glob emits OS-native separators, and the corpus nests one
301
+ * level deep, so the label always reads with forward slashes regardless of
302
+ * platform.
303
+ */
304
+ function listWireframes(root: string): readonly WireframeFile[] {
305
+ const dir = surfaceDir(root, 'wireframes')
306
+ if (!existsSync(dir)) return []
307
+
308
+ const glob = new Bun.Glob('**/*.md')
309
+ const relPaths = [...glob.scanSync({ cwd: dir })]
310
+ .filter((relPath) => relPath.split(sep).pop() !== 'index.md')
311
+ .map((relPath) => relPath.split(sep).join('/'))
312
+ .sort()
313
+
314
+ return relPaths.map((relPath) => {
315
+ const text = readFileSync(join(dir, relPath), 'utf8')
316
+ return {
317
+ relPath,
318
+ text,
319
+ description: readField(parseFrontmatter(text), 'description'),
320
+ }
321
+ })
322
+ }
323
+
303
324
  function writeWireframesPanel(root: string, outDir: string): void {
304
325
  const dir = join(outDir, 'wireframes')
305
326
  mkdirSync(dir, { recursive: true })
306
327
 
307
- const sections = WIREFRAMES.map((entry) => {
308
- const sourcePath = join(root, WIREFRAME_DIR, entry.path)
309
- if (!existsSync(sourcePath)) {
310
- return `<h2>${escapeHtml(entry.path)}</h2>\n<p class="empty">Missing from ${WIREFRAME_DIR}/.</p>`
311
- }
312
- const text = readFileSync(sourcePath, 'utf8')
313
- return `<h2>${escapeHtml(entry.path)}</h2>\n<p>${escapeHtml(entry.describes)}</p>\n<pre>${escapeHtml(text)}</pre>`
314
- }).join('\n')
328
+ const files = listWireframes(root)
329
+ const sections =
330
+ files.length === 0
331
+ ? `<p class="empty">No wireframe files under ${WIREFRAME_DIR}/.</p>`
332
+ : files
333
+ .map((file) => {
334
+ const description = file.description ?? file.relPath
335
+ return `<h2>${escapeHtml(file.relPath)}</h2>\n<p>${escapeHtml(description)}</p>\n<pre>${escapeHtml(file.text)}</pre>`
336
+ })
337
+ .join('\n')
315
338
 
316
339
  writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
317
340
  }
@@ -322,15 +345,21 @@ function copyBuilt(source: string, dest: string): void {
322
345
  cpSync(source, dest, { recursive: true })
323
346
  }
324
347
 
325
- function writeSurfacesPanel(root: string, outDir: string): void {
348
+ function writeSurfacesPanel(
349
+ root: string,
350
+ outDir: string,
351
+ isToolkitCheckout: boolean,
352
+ ): void {
326
353
  const dir = join(outDir, 'surfaces')
327
354
  mkdirSync(dir, { recursive: true })
328
355
 
329
356
  const distSource = join(root, WEB_DIST)
330
- const landingBody = existsSync(join(distSource, WEB_DIST_ENTRY))
331
- ? (copyBuilt(distSource, join(dir, 'landing')),
332
- '<iframe src="landing/index.html" loading="lazy"></iframe>')
333
- : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
357
+ const landingBody = !isToolkitCheckout
358
+ ? `<p class="empty">The landing page panel only renders in this toolkit's own checkout.</p>`
359
+ : existsSync(join(distSource, WEB_DIST_ENTRY))
360
+ ? (copyBuilt(distSource, join(dir, 'landing')),
361
+ '<iframe src="landing/index.html" loading="lazy"></iframe>')
362
+ : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
334
363
 
335
364
  const teachSource = recordDir(root, 'teach')
336
365
  const teachBody = existsSync(join(teachSource, 'index.html'))
@@ -347,15 +376,21 @@ function writeSurfacesPanel(root: string, outDir: string): void {
347
376
  )
348
377
  }
349
378
 
350
- function writeComponentsPanel(root: string, outDir: string): void {
379
+ function writeComponentsPanel(
380
+ root: string,
381
+ outDir: string,
382
+ isToolkitCheckout: boolean,
383
+ ): void {
351
384
  const dir = join(outDir, 'components')
352
385
  mkdirSync(dir, { recursive: true })
353
386
 
354
387
  const gallerySource = join(root, WEB_GALLERY_DIST)
355
- const body = existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
356
- ? (copyBuilt(gallerySource, join(dir, 'gallery')),
357
- '<iframe src="gallery/index.html" loading="lazy"></iframe>')
358
- : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
388
+ const body = !isToolkitCheckout
389
+ ? `<p class="empty">The components panel only renders in this toolkit's own checkout.</p>`
390
+ : existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
391
+ ? (copyBuilt(gallerySource, join(dir, 'gallery')),
392
+ '<iframe src="gallery/index.html" loading="lazy"></iframe>')
393
+ : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
359
394
 
360
395
  writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
361
396
  }
@@ -416,13 +451,54 @@ function writeCandidatesPanel(root: string, outDir: string): void {
416
451
  const sections = found
417
452
  .map(
418
453
  ({ folder, images }) =>
419
- `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${folder}/${image}" alt="${escapeHtml(image)}">`).join('\n')}`,
454
+ `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${escapeHtml(`${folder}/${image}`)}" alt="${escapeHtml(image)}">`).join('\n')}`,
420
455
  )
421
456
  .join('\n')
422
457
 
423
458
  writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
424
459
  }
425
460
 
461
+ function writeReferencesPanel(root: string, outDir: string): void {
462
+ const dir = join(outDir, 'references')
463
+ mkdirSync(dir, { recursive: true })
464
+
465
+ const referencesDir = recordDir(root, 'review', 'references')
466
+ if (!existsSync(referencesDir)) {
467
+ writeFileSync(
468
+ join(dir, 'index.html'),
469
+ panelPage(
470
+ 'References',
471
+ `<p class="empty">No ${relative(root, referencesDir)} folder yet.</p>`,
472
+ ),
473
+ )
474
+ return
475
+ }
476
+
477
+ const images = imagesIn(referencesDir)
478
+ if (images.length === 0) {
479
+ writeFileSync(
480
+ join(dir, 'index.html'),
481
+ panelPage(
482
+ 'References',
483
+ `<p class="empty">${relative(root, referencesDir)} exists but carries no image yet.</p>`,
484
+ ),
485
+ )
486
+ return
487
+ }
488
+
489
+ for (const image of images) {
490
+ cpSync(join(referencesDir, image), join(dir, image))
491
+ }
492
+
493
+ const sections = images
494
+ .map(
495
+ (image) => `<img src="${escapeHtml(image)}" alt="${escapeHtml(image)}">`,
496
+ )
497
+ .join('\n')
498
+
499
+ writeFileSync(join(dir, 'index.html'), panelPage('References', sections))
500
+ }
501
+
426
502
  /**
427
503
  * Generates the board's page set into `outDir`, clearing whatever was there.
428
504
  *
@@ -433,11 +509,16 @@ function writeCandidatesPanel(root: string, outDir: string): void {
433
509
  * `cwd` is the caller's own working directory, resolved and passed in
434
510
  * explicitly rather than read here, so a test can exercise the checkout-
435
511
  * mismatch case without touching the process's real cwd.
512
+ *
513
+ * `isToolkitCheckout` gates the surfaces panel's landing-page half and the
514
+ * whole components panel, both of which read this repository's own build
515
+ * output rather than anything a target project produces.
436
516
  */
437
517
  export function generateBoard(
438
518
  root: string,
439
519
  outDir: string,
440
520
  cwd: string,
521
+ isToolkitCheckout: boolean,
441
522
  ): BoardOutcome {
442
523
  if (wouldDeleteRoot([root, cwd], outDir)) {
443
524
  return {
@@ -460,13 +541,15 @@ export function generateBoard(
460
541
  path: 'candidates/index.html',
461
542
  },
462
543
  { id: 'components', title: 'Components', path: 'components/index.html' },
544
+ { id: 'references', title: 'References', path: 'references/index.html' },
463
545
  ]
464
546
 
465
547
  writeTokensPanel(root, outDir)
466
- writeSurfacesPanel(root, outDir)
548
+ writeSurfacesPanel(root, outDir, isToolkitCheckout)
467
549
  writeWireframesPanel(root, outDir)
468
550
  writeCandidatesPanel(root, outDir)
469
- writeComponentsPanel(root, outDir)
551
+ writeComponentsPanel(root, outDir, isToolkitCheckout)
552
+ writeReferencesPanel(root, outDir)
470
553
 
471
554
  const indexPath = join(outDir, 'index.html')
472
555
  writeFileSync(indexPath, shellHtml(panels))
@@ -126,11 +126,11 @@ export const SANDBOX_ASSERTED_FLOOR = 26
126
126
  * Where the retained counts the audit stage compares each run against are
127
127
  * read, relative to `root`.
128
128
  *
129
- * A function rather than a spelled constant, since the read now resolves at
129
+ * A function rather than a spelled constant, since the read resolves at
130
130
  * either surface root and a constant naming one of them would be believed of
131
- * a project that has moved. `canon audits run` owns writing it, and the write
132
- * stays at the creation default for this batch, so the two can disagree for
133
- * exactly the release window `src/surface-root.ts` documents.
131
+ * a project that has moved. `canon audits run` owns writing it, at
132
+ * `canon/config/baseline.json` now that the write has flipped, falling back to
133
+ * `.claude/canon/baseline.json` for a target that has not moved.
134
134
  */
135
135
  export function auditsBaselineRel(root: string): string {
136
136
  return relative(root, surfaceDir(root, 'canon', 'baseline.json'))
package/src/labels/map.ts CHANGED
@@ -1,14 +1,23 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { resolveExisting } from '@/legacy-path'
3
4
 
4
5
  /**
5
- * Where a project declares its pull request label map, spelled once.
6
+ * Where a project declares its pull request label map.
6
7
  *
7
8
  * The file has already moved once, and that relocation rewrote every mention
8
- * across four surfaces. Nothing in code spells it anywhere else, so the next
9
- * move is one edit here rather than a sweep.
9
+ * across four surfaces. `LEGACY_MAP_REL` below is the one other place code
10
+ * spells it, kept only as a read fallback, so the next move is still one edit
11
+ * to the write path rather than a sweep.
10
12
  */
11
- export const MAP_REL = join('.claude', 'canon', 'pr-labels.toml')
13
+ export const MAP_REL = join('canon', 'config', 'pr-labels.toml')
14
+
15
+ /**
16
+ * Where the map lived before this move, `.claude/canon/pr-labels.toml`.
17
+ * `readLabelMap` falls back to it so a project that has not moved still reads
18
+ * its declared map.
19
+ */
20
+ const LEGACY_MAP_REL = join('.claude', 'canon', 'pr-labels.toml')
12
21
 
13
22
  /** A label name and the path prefixes that earn it, in the map's own order. */
14
23
  export interface DomainRow {
@@ -90,9 +99,14 @@ export function parseLabelMap(source: string): LabelMap {
90
99
 
91
100
  /** Reads the map a project declares at `root`, or says why it could not. */
92
101
  export function readLabelMap(root: string): LabelMap {
102
+ const path = resolveExisting([
103
+ join(root, MAP_REL),
104
+ join(root, LEGACY_MAP_REL),
105
+ ])
106
+
93
107
  let source: string
94
108
  try {
95
- source = readFileSync(join(root, MAP_REL), 'utf8')
109
+ source = readFileSync(path, 'utf8')
96
110
  } catch {
97
111
  return { kind: 'refused', reason: 'no-map' }
98
112
  }
@@ -0,0 +1,15 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ /**
4
+ * Where a fallback chain resolves for reading: the first path that exists,
5
+ * current first, or the first candidate when none exists yet, which is
6
+ * always the write target.
7
+ *
8
+ * `src/audits/baseline.ts`, `src/labels/map.ts`, and `src/sync/stamp.ts` each
9
+ * trail a file that moved, and each used to reason about its own chain
10
+ * separately. The tuple type keeps a caller from passing an empty list, since
11
+ * every chain here has a write target to fall back to.
12
+ */
13
+ export function resolveExisting(paths: readonly [string, ...string[]]): string {
14
+ return paths.find((path) => existsSync(path)) ?? paths[0]
15
+ }
@@ -44,6 +44,7 @@ export interface RenamePlan {
44
44
  const TOOLKIT_OWNED: readonly string[] = [
45
45
  '.claude/aitk/',
46
46
  '.claude/canon/',
47
+ 'canon/config/',
47
48
  '.claude/hooks/',
48
49
  '.claude/rules/',
49
50
  '.claude/tooling/',
@@ -20,13 +20,12 @@ const TO_ROOT: SurfaceRoot = 'canon'
20
20
  /**
21
21
  * Every entry this move relocates, at the name `.claude/` gives it.
22
22
  *
23
- * Read from `SURFACE_ENTRIES` rather than restated, less the one entry that
24
- * stays: `canon` names the install stamp folder, which batch 3 owns and which
25
- * `.claude/canon/` continues to hold until that batch lands.
23
+ * Read from `SURFACE_ENTRIES` rather than restated. `canon` names the install
24
+ * stamp folder and is the one entry whose name differs by root, which
25
+ * `sourcePrefix` and `destinationPrefix` already resolve through `spell`
26
+ * rather than restating the variant here.
26
27
  */
27
- export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES.filter(
28
- (entry) => entry !== 'canon',
29
- )
28
+ export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES
30
29
 
31
30
  function sourcePrefix(entry: string): string {
32
31
  return join(FROM_ROOT, spell(FROM_ROOT, entry))
@@ -28,6 +28,22 @@ const readPackageName = (root: string): string | undefined => {
28
28
  }
29
29
  }
30
30
 
31
+ /**
32
+ * Whether `root` is a checkout of this same package, judged by package name
33
+ * rather than by path equality against `PROJECT_ROOT`.
34
+ *
35
+ * A linked worktree tracks the identical `package.json` the main worktree
36
+ * does, so this reads true for either. Comparing `root` against `PROJECT_ROOT`
37
+ * directly reads true only for the one directory this process happened to
38
+ * load its own source from, which is wrong for a caller whose CLI runs out of
39
+ * a linked worktree while `root` correctly points at the main one, or at
40
+ * another worktree of the same repository.
41
+ */
42
+ export function isOwnCheckout(root: string): boolean {
43
+ const ownName = readPackageName(PROJECT_ROOT)
44
+ return ownName !== undefined && readPackageName(root) === ownName
45
+ }
46
+
31
47
  /**
32
48
  * Walks upward from `startDir` for the nearest ancestor `package.json`
33
49
  * sharing this package's own `name`, and reports its path when that ancestor