@erclx/canon 4.80.0 → 4.82.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 (39) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/design-extract/SKILL.md +5 -1
  3. package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
  4. package/claude/skills/sketch-design/SKILL.md +92 -0
  5. package/claude/skills/teach-workspace/SKILL.md +23 -1
  6. package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
  7. package/docs/agents/commands.md +11 -5
  8. package/docs/agents/context-audit.md +1 -1
  9. package/docs/agents/context-classify.md +99 -0
  10. package/docs/agents/design-board.md +13 -11
  11. package/docs/agents/index.md +2 -1
  12. package/docs/agents/teach.md +16 -0
  13. package/docs/target-projects.md +15 -0
  14. package/docs/workflow/ai-workflow.md +4 -2
  15. package/docs/workflow/visual-design-workflow.md +1 -0
  16. package/governance/rules/claude/545-decisions.md +12 -0
  17. package/package.json +1 -1
  18. package/src/claude/cases/misc.ts +5 -0
  19. package/src/claude/seeds.ts +1 -0
  20. package/src/commands/claude.ts +26 -5
  21. package/src/commands/context.ts +425 -0
  22. package/src/commands/design.ts +24 -8
  23. package/src/commands/teach.ts +118 -0
  24. package/src/context/classify/extract.ts +450 -0
  25. package/src/context/classify/ollama.ts +172 -0
  26. package/src/context/classify/patterns.ts +114 -0
  27. package/src/context/classify/prompts.ts +73 -0
  28. package/src/context/classify/run.ts +348 -0
  29. package/src/context/classify/settings.ts +196 -0
  30. package/src/context/folders.ts +1 -0
  31. package/src/design/board.ts +130 -47
  32. package/src/project-root.ts +16 -0
  33. package/src/surface-root.ts +1 -0
  34. package/src/teach/render.ts +126 -0
  35. package/standards/decisions.md +100 -0
  36. package/standards/index.md +1 -0
  37. package/tooling/claude/reference.md +1 -0
  38. package/tooling/claude/seeds/CLAUDE.md +1 -0
  39. package/tooling/claude/seeds/canon/decisions/index.md +8 -0
@@ -17,6 +17,11 @@ export const MISC_CASES: readonly SkillCase[] = [
17
17
  'This project has no logo yet. Draft one and give me a social card to go with it.',
18
18
  expect: 'draft-identity',
19
19
  },
20
+ {
21
+ prompt:
22
+ "I found two sites whose look I want us to draw from. Put them side by side and let's settle which one the design should follow before anyone writes a token.",
23
+ expect: 'sketch-design',
24
+ },
20
25
  {
21
26
  prompt: 'Fire up the dev server the way this project documents it.',
22
27
  expect: 'project-commands',
@@ -20,6 +20,7 @@ const HOOKS = 'hooks'
20
20
  export const SUBDIRS: readonly string[] = [
21
21
  HOOKS,
22
22
  'context',
23
+ 'decisions',
23
24
  'diagrams',
24
25
  'memory',
25
26
  'tasks',
@@ -61,6 +61,7 @@ import { copyPreservingMode } from '@/copy'
61
61
  import { execScript } from '@/exec'
62
62
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
63
63
  import { recordDir } from '@/record-root'
64
+ import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
64
65
  import { isDirectory, resolveTarget } from '@/target'
65
66
  import { injectGitignore, pruneGitignore } from '@/tooling/inject'
66
67
  import {
@@ -148,7 +149,27 @@ const SEEDED_FILES: readonly string[] = [
148
149
  'REQUIREMENTS.md',
149
150
  'DESIGN.md',
150
151
  ]
151
- const SEEDED_DIRS: readonly string[] = ['memory', 'tasks', 'wireframes']
152
+ const SEEDED_DIRS: readonly string[] = [
153
+ 'decisions',
154
+ 'memory',
155
+ 'tasks',
156
+ 'wireframes',
157
+ ]
158
+
159
+ /**
160
+ * Where a `SEEDED_DIRS` entry resolves for the sync presence check.
161
+ *
162
+ * `wireframes` and `decisions` are tracked surface entries, resolved under
163
+ * `canon/` ahead of `.claude/`, while `memory` and `tasks` are session records
164
+ * resolved under `.canon/` ahead of `.claude/`. Routing every name through
165
+ * `recordDir` reported a migrated target's `canon/wireframes/` or
166
+ * `canon/decisions/` as missing, since that resolver never checks `canon/`.
167
+ */
168
+ export function seededDirPath(resolved: string, name: string): string {
169
+ return SURFACE_ENTRIES.includes(name)
170
+ ? surfaceDir(resolved, name)
171
+ : recordDir(resolved, name)
172
+ }
152
173
  const USER_DIR = join('tooling', 'claude', 'user')
153
174
  const STATUSLINE = 'statusline-command.sh'
154
175
  const PLUGIN_MANIFEST = join('claude', '.claude-plugin', 'plugin.json')
@@ -580,16 +601,16 @@ async function runSync(target: string): Promise<number> {
580
601
 
581
602
  // A record folder resolves at either root, so a migrated target is reported as
582
603
  // seeded rather than sent to `canon claude init` to re-create records it
583
- // already holds. The three seeded files and `wireframes` are tracked and stay
584
- // at `.claude/`, which the resolver answers for them anyway, since nothing
585
- // ever creates a second root copy for a name that does not move.
604
+ // already holds. `wireframes` and `decisions` are tracked surface entries
605
+ // instead, resolved through `surfaceDir`, since a migrated target holds them
606
+ // at `canon/` rather than under either record root.
586
607
  logStep('Seeded')
587
608
  for (const name of SEEDED_FILES) {
588
609
  if (existsSync(join(resolved, '.claude', name))) logInfo(name)
589
610
  else logWarn(`${name} missing. Run \`canon claude init\``)
590
611
  }
591
612
  for (const name of SEEDED_DIRS) {
592
- if (isDirectory(recordDir(resolved, name))) logInfo(`${name}/`)
613
+ if (isDirectory(seededDirPath(resolved, name))) logInfo(`${name}/`)
593
614
  else logWarn(`${name}/ missing. Run \`canon claude init\``)
594
615
  }
595
616
 
@@ -25,6 +25,26 @@ import {
25
25
  testableCount,
26
26
  } from '@/context/architecture'
27
27
  import { auditCitations, type CitationReport } from '@/context/citations'
28
+ import {
29
+ CANONICAL_DOC_TYPES,
30
+ type CanonicalDocType,
31
+ } from '@/context/classify/extract'
32
+ import {
33
+ classifyDiff,
34
+ classifySweep,
35
+ type ClassifyOutcome,
36
+ type ClassifyRefusal,
37
+ type DiffFinding,
38
+ type DiffRecord,
39
+ type SweepFinding,
40
+ type SweepRecord,
41
+ } from '@/context/classify/run'
42
+ import {
43
+ CLASSIFIER_CONFIG_REL,
44
+ type ClassifierBackend,
45
+ resolveClassifier,
46
+ writeClassifierConfig,
47
+ } from '@/context/classify/settings'
28
48
  import {
29
49
  type AuditedFolder,
30
50
  DEFAULT_FOLDERS,
@@ -122,6 +142,411 @@ export function register(program: Command): void {
122
142
  .action(async (path: string | undefined, opts: AuditCommandOptions) => {
123
143
  process.exitCode = await runAudit(path, opts)
124
144
  })
145
+
146
+ const classify = context
147
+ .command('classify')
148
+ .description(
149
+ 'Classify canonical-doc content as keep, replace/rewrite, history, or move',
150
+ )
151
+ .helpOption('-h, --help', 'Show this help message')
152
+
153
+ classify
154
+ .command('diff')
155
+ .description(
156
+ 'Classify the chunks a git range changed, each with the section it landed in',
157
+ )
158
+ .argument('[path]', 'Project root, defaulting to the current directory')
159
+ .helpOption('-h, --help', 'Show this help message')
160
+ .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
161
+ .option(
162
+ '--doc-types <list>',
163
+ `Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
164
+ )
165
+ .option(
166
+ '--backend <name>',
167
+ 'Override the resolved model backend for this run',
168
+ )
169
+ .option('--model <name>', 'Override the resolved model name for this run')
170
+ .option('--json', 'Add a machine-readable record on stdout')
171
+ .addHelpText(
172
+ 'after',
173
+ [
174
+ '',
175
+ 'The regex layer always runs. The model layer runs only when a',
176
+ 'backend resolves through `canon context classifier show`, and a',
177
+ 'configured-but-unreachable backend warns and falls back to regex',
178
+ 'rather than failing the run.',
179
+ '',
180
+ 'Exit codes:',
181
+ ' 0 the run completed, whatever the findings say',
182
+ ' 1 refused: a bad range or a file this could not read',
183
+ '',
184
+ 'Examples:',
185
+ ' canon context classify diff',
186
+ ' canon context classify diff --base origin/main --json',
187
+ ' canon context classify diff --doc-types context,wireframes',
188
+ '',
189
+ ].join('\n'),
190
+ )
191
+ .action(async (path: string | undefined, opts: ClassifyDiffOptions) => {
192
+ process.exitCode = await runClassifyDiff(path, opts)
193
+ })
194
+
195
+ classify
196
+ .command('sweep')
197
+ .description(
198
+ 'Classify every section of the five canonical doc types, split at H3',
199
+ )
200
+ .argument('[path]', 'Project root, defaulting to the current directory')
201
+ .helpOption('-h, --help', 'Show this help message')
202
+ .option(
203
+ '--doc-types <list>',
204
+ `Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
205
+ )
206
+ .option(
207
+ '--backend <name>',
208
+ 'Override the resolved model backend for this run',
209
+ )
210
+ .option('--model <name>', 'Override the resolved model name for this run')
211
+ .option('--json', 'Add a machine-readable record on stdout')
212
+ .addHelpText(
213
+ 'after',
214
+ [
215
+ '',
216
+ 'Exit codes:',
217
+ ' 0 the run completed, whatever the findings say',
218
+ ' 1 refused: a file this could not read',
219
+ '',
220
+ 'Examples:',
221
+ ' canon context classify sweep',
222
+ ' canon context classify sweep --doc-types design,requirements --json',
223
+ '',
224
+ ].join('\n'),
225
+ )
226
+ .action(async (path: string | undefined, opts: ClassifySweepOptions) => {
227
+ process.exitCode = await runClassifySweep(path, opts)
228
+ })
229
+
230
+ const classifier = context
231
+ .command('classifier')
232
+ .description('Read or write the project classifier setting')
233
+ .helpOption('-h, --help', 'Show this help message')
234
+
235
+ classifier
236
+ .command('show')
237
+ .description(
238
+ 'Report the backend and model that would run, and which source decided it',
239
+ )
240
+ .argument('[path]', 'Project root, defaulting to the current directory')
241
+ .helpOption('-h, --help', 'Show this help message')
242
+ .option('--json', 'Add a machine-readable record on stdout')
243
+ .action((path: string | undefined, opts: { json?: boolean }) => {
244
+ runClassifierShow(path, opts)
245
+ })
246
+
247
+ classifier
248
+ .command('set')
249
+ .description('Write the project classifier setting')
250
+ .argument('[path]', 'Project root, defaulting to the current directory')
251
+ .helpOption('-h, --help', 'Show this help message')
252
+ .requiredOption('--backend <name>', 'ollama or off')
253
+ .option('--model <name>', 'Model name, required when --backend is ollama')
254
+ .option('--json', 'Add a machine-readable record on stdout')
255
+ .action((path: string | undefined, opts: ClassifierSetOptions) => {
256
+ process.exitCode = runClassifierSet(path, opts)
257
+ })
258
+ }
259
+
260
+ interface ClassifyDiffOptions {
261
+ readonly json?: boolean
262
+ readonly base?: string
263
+ readonly docTypes?: string
264
+ readonly backend?: string
265
+ readonly model?: string
266
+ }
267
+
268
+ interface ClassifySweepOptions {
269
+ readonly json?: boolean
270
+ readonly docTypes?: string
271
+ readonly backend?: string
272
+ readonly model?: string
273
+ }
274
+
275
+ interface ClassifierSetOptions {
276
+ readonly json?: boolean
277
+ readonly backend: string
278
+ readonly model?: string
279
+ }
280
+
281
+ function parseDocTypes(
282
+ list: string | undefined,
283
+ ): readonly CanonicalDocType[] | string {
284
+ if (!list) return CANONICAL_DOC_TYPES
285
+
286
+ const names = list
287
+ .split(',')
288
+ .map((name) => name.trim())
289
+ .filter(Boolean)
290
+
291
+ const invalid = names.filter(
292
+ (name) => !CANONICAL_DOC_TYPES.includes(name as CanonicalDocType),
293
+ )
294
+ if (invalid.length > 0) {
295
+ return `--doc-types takes ${CANONICAL_DOC_TYPES.join(', ')}: ${invalid.join(', ')}`
296
+ }
297
+
298
+ return names as CanonicalDocType[]
299
+ }
300
+
301
+ /**
302
+ * Validates `--backend` up front, so a typo'd value refuses rather than
303
+ * falling through `resolveBackend`'s tier chain to the environment, the
304
+ * file, or the default, which reads as the flag having been ignored.
305
+ */
306
+ function parseBackendFlag(value: string | undefined): string | undefined {
307
+ if (value === undefined || value === 'ollama' || value === 'off') {
308
+ return undefined
309
+ }
310
+ return `--backend takes ollama or off: ${value}`
311
+ }
312
+
313
+ /** Widens the extraction refusals with the CLI's own argument-parsing failure. */
314
+ type CliRefusal = ClassifyRefusal | 'bad-flags'
315
+
316
+ function refuseClassify(
317
+ reason: CliRefusal,
318
+ message: string,
319
+ emitJson: boolean,
320
+ ): number {
321
+ intro('canon context classify')
322
+ logStep('Refused')
323
+ logWarn(message)
324
+ outro()
325
+
326
+ if (emitJson) {
327
+ process.stdout.write(
328
+ `${JSON.stringify({ decision: 'refused', reason, message })}\n`,
329
+ )
330
+ }
331
+ return 1
332
+ }
333
+
334
+ /** How each not-off model-layer state reads in the report. */
335
+ const MODEL_LAYER_LABEL: Record<string, string> = {
336
+ ran: 'ran',
337
+ 'skipped-no-model': 'skipped, no model resolved for the configured backend',
338
+ 'skipped-unreachable': 'skipped, the configured backend did not answer',
339
+ }
340
+
341
+ function reportLayers(record: DiffRecord | SweepRecord): void {
342
+ logStep('Layers')
343
+ logInfo('regex: ran')
344
+
345
+ if (record.modelLayer === 'off') {
346
+ logInfo('model: off, no backend configured')
347
+ return
348
+ }
349
+
350
+ logInfo(
351
+ `model: ${MODEL_LAYER_LABEL[record.modelLayer]} (${record.backend ?? 'none'}${record.model ? `, ${record.model}` : ''})`,
352
+ )
353
+ if (record.modelLayer !== 'ran') {
354
+ logWarn('Falling back to the regex layer alone for this run.')
355
+ }
356
+ }
357
+
358
+ function reportFindings(
359
+ findings: readonly (DiffFinding | SweepFinding)[],
360
+ label: (finding: DiffFinding | SweepFinding) => string,
361
+ ): void {
362
+ logStep('Findings')
363
+
364
+ if (findings.length === 0) {
365
+ logInfo('Nothing met the extraction floor under the requested doc types.')
366
+ return
367
+ }
368
+
369
+ const flagged = findings.filter((finding) => finding.verdict !== 'KEEP')
370
+ logInfo(
371
+ `${plural(findings.length, 'item')} read, ${plural(flagged.length, 'flagged')}`,
372
+ )
373
+
374
+ if (flagged.length === 0) return
375
+
376
+ pipeOutput(
377
+ flagged
378
+ .map((finding) => {
379
+ const chosen =
380
+ finding.decidedBy === 'model' ? finding.model : finding.regex
381
+ return `${label(finding)} ${finding.verdict} (${finding.decidedBy})\n ${chosen?.quote ? `"${chosen.quote}" ` : ''}${chosen?.reason ?? ''}`
382
+ })
383
+ .join('\n'),
384
+ )
385
+ }
386
+
387
+ async function runClassifyDiff(
388
+ path: string | undefined,
389
+ opts: ClassifyDiffOptions,
390
+ ): Promise<number> {
391
+ const root = resolve(path ?? process.cwd())
392
+ const emitJson = opts.json ?? false
393
+
394
+ const docTypes = parseDocTypes(opts.docTypes)
395
+ if (typeof docTypes === 'string') {
396
+ return refuseClassify('bad-flags', docTypes, emitJson)
397
+ }
398
+
399
+ const backendError = parseBackendFlag(opts.backend)
400
+ if (backendError !== undefined) {
401
+ return refuseClassify('bad-flags', backendError, emitJson)
402
+ }
403
+
404
+ const result: ClassifyOutcome<DiffRecord> = await classifyDiff(
405
+ root,
406
+ opts.base,
407
+ {
408
+ docTypes,
409
+ flags: { backend: opts.backend, model: opts.model },
410
+ },
411
+ )
412
+
413
+ if (result.kind === 'refused') {
414
+ return refuseClassify(result.reason, result.message, emitJson)
415
+ }
416
+
417
+ intro('canon context classify diff')
418
+ reportLayers(result.record)
419
+ reportFindings(result.record.findings, (finding) => finding.file)
420
+ outro()
421
+
422
+ if (emitJson) {
423
+ process.stdout.write(
424
+ `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
425
+ )
426
+ }
427
+
428
+ return 0
429
+ }
430
+
431
+ async function runClassifySweep(
432
+ path: string | undefined,
433
+ opts: ClassifySweepOptions,
434
+ ): Promise<number> {
435
+ const root = resolve(path ?? process.cwd())
436
+ const emitJson = opts.json ?? false
437
+
438
+ const docTypes = parseDocTypes(opts.docTypes)
439
+ if (typeof docTypes === 'string') {
440
+ return refuseClassify('bad-flags', docTypes, emitJson)
441
+ }
442
+
443
+ const backendError = parseBackendFlag(opts.backend)
444
+ if (backendError !== undefined) {
445
+ return refuseClassify('bad-flags', backendError, emitJson)
446
+ }
447
+
448
+ const result: ClassifyOutcome<SweepRecord> = await classifySweep(root, {
449
+ docTypes,
450
+ flags: { backend: opts.backend, model: opts.model },
451
+ })
452
+
453
+ if (result.kind === 'refused') {
454
+ return refuseClassify(result.reason, result.message, emitJson)
455
+ }
456
+
457
+ intro('canon context classify sweep')
458
+ reportLayers(result.record)
459
+ reportFindings(
460
+ result.record.findings,
461
+ (finding) => `${finding.file}:${(finding as SweepFinding).heading}`,
462
+ )
463
+ outro()
464
+
465
+ if (emitJson) {
466
+ process.stdout.write(
467
+ `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
468
+ )
469
+ }
470
+
471
+ return 0
472
+ }
473
+
474
+ function runClassifierShow(
475
+ path: string | undefined,
476
+ opts: { json?: boolean },
477
+ ): void {
478
+ const root = resolve(path ?? process.cwd())
479
+ const resolution = resolveClassifier(root, {})
480
+
481
+ intro('canon context classifier show')
482
+
483
+ if (resolution.kind === 'off') {
484
+ logInfo(
485
+ `Model layer off (source: ${resolution.source}). Regex layer always runs.`,
486
+ )
487
+ } else if (resolution.kind === 'no-model') {
488
+ logWarn(
489
+ `Backend ${resolution.backend} configured (source: ${resolution.source}) with no model name. \`classify\` runs regex only.`,
490
+ )
491
+ } else {
492
+ logInfo(
493
+ `Backend ${resolution.backend}, model ${resolution.model} (source: ${resolution.source}).`,
494
+ )
495
+ }
496
+
497
+ logInfo(
498
+ `Reads ${CLASSIFIER_CONFIG_REL} when neither a flag nor an environment variable decides.`,
499
+ )
500
+ outro()
501
+
502
+ if (opts.json) {
503
+ process.stdout.write(`${JSON.stringify(resolution)}\n`)
504
+ }
505
+ }
506
+
507
+ function runClassifierSet(
508
+ path: string | undefined,
509
+ opts: ClassifierSetOptions,
510
+ ): number {
511
+ const root = resolve(path ?? process.cwd())
512
+ const emitJson = opts.json ?? false
513
+
514
+ if (opts.backend !== 'ollama' && opts.backend !== 'off') {
515
+ return refuseClassify(
516
+ 'bad-flags',
517
+ '--backend takes ollama or off.',
518
+ emitJson,
519
+ )
520
+ }
521
+
522
+ if (opts.backend === 'ollama' && !opts.model) {
523
+ return refuseClassify(
524
+ 'bad-flags',
525
+ '--model is required when --backend is ollama.',
526
+ emitJson,
527
+ )
528
+ }
529
+
530
+ const backend: ClassifierBackend | 'off' = opts.backend
531
+ writeClassifierConfig(
532
+ root,
533
+ backend,
534
+ backend === 'off' ? undefined : opts.model,
535
+ )
536
+
537
+ intro('canon context classifier set')
538
+ logInfo(
539
+ `Wrote ${CLASSIFIER_CONFIG_REL}: backend ${opts.backend}${opts.model ? `, model ${opts.model}` : ''}.`,
540
+ )
541
+ outro()
542
+
543
+ if (emitJson) {
544
+ process.stdout.write(
545
+ `${JSON.stringify({ decision: 'ok', backend: opts.backend, model: opts.model })}\n`,
546
+ )
547
+ }
548
+
549
+ return 0
125
550
  }
126
551
 
127
552
  function parseFolders(list: string | undefined): string[] | string {
@@ -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`,