@erclx/canon 4.86.0 → 4.87.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.
@@ -22,7 +22,7 @@ Claude Code reads both and writes the implementation. Works for CLI tools, inter
22
22
 
23
23
  The toolkit seed in `tooling/claude/seeds/canon/DESIGN.md` ships a token-table template with a starting set of roles, and `standards/design.md` carries the same tables under `## Template` with placeholder rows. The column headers are what the renderer parses, so they stay verbatim in either, while the rows and values are the project's own.
24
24
 
25
- The `canon:design-extract` skill drafts the file, sourcing tokens from a project's existing prose and CLI UI surfaces, or proposing them from `canon/REQUIREMENTS.md` and a `## Personality` paragraph when no UI code exists yet. `canon design render` writes an HTML plus CSS preview to `.canon/review/design/` for eyeballing the current system without leaving Claude Code. See `canon/context/design.md`.
25
+ The `canon:design-extract` skill drafts the file, sourcing tokens from a project's existing prose and CLI UI surfaces, or proposing them from `canon/REQUIREMENTS.md` and a `## Personality` paragraph when no UI code exists yet. `canon design render` writes an HTML plus CSS preview to `.canon/tmp/render/design/` for eyeballing the current system without leaving Claude Code. See `canon/context/design.md`.
26
26
 
27
27
  A project wanting the toolkit's own values rather than its own runs `canon design install`, which copies one stylesheet to `.claude/design/base.css` carrying the token set as custom properties and two components built on them. That file is toolkit-owned and `canon design sync` refreshes it, so a project overrides a value in `.claude/design/project/` instead, which sync never touches. Nothing arrives without that install, and the two channels are independent: a record drafted by the extract skill is the project's own, and the installed stylesheet is the toolkit's.
28
28
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.86.0",
4
+ "version": "4.87.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -72,7 +72,7 @@ function showHelp(): void {
72
72
  `${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
73
73
  `${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
74
74
  `${GREY}│${NC} drive <url> <run> ${GREY}# Walk a page through named interactions and measure each state${NC}`,
75
- `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .canon/review/feedback/${NC}`,
75
+ `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .canon/feedback/${NC}`,
76
76
  `${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
77
77
  `${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
78
78
  `${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .canon/intake/ (list, answer)${NC}`,
@@ -16,7 +16,7 @@ import {
16
16
  isOwnCheckout,
17
17
  PROJECT_ROOT,
18
18
  } from '@/project-root'
19
- import { creationRel } from '@/record-root'
19
+ import { creationRel, SCRATCH } from '@/record-root'
20
20
  import { surfaceDir } from '@/surface-root'
21
21
  import { recordStamp, runDomainSync } from '@/sync/engine'
22
22
  import { resolveTarget } from '@/target'
@@ -113,7 +113,7 @@ export function register(program: Command): void {
113
113
  .option(
114
114
  '-o, --out <path>',
115
115
  'Output directory',
116
- creationRel(process.cwd(), 'review', 'design'),
116
+ creationRel(process.cwd(), SCRATCH, 'render', 'design'),
117
117
  )
118
118
  .action((opts: { source: string; out: string }) => {
119
119
  const sourcePath = resolve(process.cwd(), opts.source)
@@ -143,7 +143,7 @@ export function register(program: Command): void {
143
143
  .option(
144
144
  '-o, --out <path>',
145
145
  'Output directory',
146
- creationRel(process.cwd(), 'review', 'board'),
146
+ creationRel(process.cwd(), SCRATCH, 'render', 'board'),
147
147
  )
148
148
  .option('--root <path>', 'Project root, defaulting to the main worktree')
149
149
  .addHelpText(
@@ -40,20 +40,20 @@ function isToolkitSource(): boolean {
40
40
  }
41
41
 
42
42
  /**
43
- * One producer, one subfolder. The review folder carries the output of four
44
- * unrelated producers, and the filename prefix was doing the folder's job by
45
- * hand, so each writes under its own name and the enclosing folder keeps the
46
- * single ignore entry and the single backed-folder entry it already had.
43
+ * One producer, one record folder. Feedback is not a review, so it writes
44
+ * under a root folder of its own rather than inside `review/`, and `canon
45
+ * records push` backs it with no list edit, since it carries every `.canon/`
46
+ * entry it does not exclude.
47
47
  */
48
48
  function writeLocal(body: string, mismatch: string | undefined): string {
49
- // Resolved against the same root the write joins onto, so a checkout that has
50
- // not migrated its records writes this beside the ones already there rather
51
- // than opening a second root nothing reads.
52
- const relativeDir = creationRel(PROJECT_ROOT, 'review', 'feedback')
53
- const reviewDir = join(PROJECT_ROOT, relativeDir)
54
- mkdirSync(reviewDir, { recursive: true })
49
+ // Resolved against the same root the write joins onto. `creationRel` reads
50
+ // whichever root already carries a `feedback/` folder and falls back to the
51
+ // creation root when neither does.
52
+ const relativeDir = creationRel(PROJECT_ROOT, 'feedback')
53
+ const feedbackDir = join(PROJECT_ROOT, relativeDir)
54
+ mkdirSync(feedbackDir, { recursive: true })
55
55
  const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
56
- const filePath = join(reviewDir, filename)
56
+ const filePath = join(feedbackDir, filename)
57
57
  writeFileSync(filePath, `${body}\n`, 'utf8')
58
58
 
59
59
  // Manual frame rather than `frameSuccess`, since the mismatch warning is a
@@ -72,7 +72,7 @@ export function register(program: Command): void {
72
72
  program
73
73
  .command('feedback')
74
74
  .description(
75
- 'Write toolkit feedback from stdin to .canon/review/feedback/, or open a GitHub issue with --github',
75
+ 'Write toolkit feedback from stdin to .canon/feedback/, or open a GitHub issue with --github',
76
76
  )
77
77
  .option(
78
78
  '--github',
@@ -671,7 +671,7 @@ interface ScratchEvidenceOptions {
671
671
 
672
672
  /**
673
673
  * Moves the nine cited-and-unwritten folders under `.canon/tmp/` to
674
- * `.canon/review/evidence/`, and repoints the citations that name them,
674
+ * `.canon/evidence/`, and repoints the citations that name them,
675
675
  * archives included.
676
676
  */
677
677
  async function runScratchEvidence(
@@ -760,9 +760,10 @@ interface RecordLayoutOptions {
760
760
  }
761
761
 
762
762
  /**
763
- * Moves review receipts from `.canon/review/memory/` to `.canon/memory/review/`
764
- * and retired entries from `.canon/tmp/memory-archive/` to
765
- * `.canon/memory/archive/`, and repoints the citations that name either.
763
+ * Moves every row of `RECORD_LAYOUT_MOVES`: the memory pen's receipts and
764
+ * retired entries under `.canon/memory/`, and everything under
765
+ * `.canon/review/` that is not a review out to its own folder, then repoints
766
+ * the citations that name any of them.
766
767
  */
767
768
  async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
768
769
  const root = opts.root ?? process.cwd()
@@ -797,7 +798,7 @@ async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
797
798
 
798
799
  const applied = await applyRecordLayout(plan)
799
800
  logStep(
800
- `Moved ${plural(applied.moved, 'folder')} and rewrote ${plural(applied.written, 'file')}.`,
801
+ `Moved ${plural(applied.moved, 'path')} and rewrote ${plural(applied.written, 'file')}.`,
801
802
  )
802
803
 
803
804
  if (applied.failed.length > 0) {
@@ -810,9 +811,10 @@ async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
810
811
  }
811
812
 
812
813
  function reportRecordLayout(plan: RecordLayoutPlan): void {
813
- logInfo(`${plural(plan.moves.length, 'folder')} to move.`)
814
+ logInfo(`${plural(plan.moves.length, 'path')} to move.`)
814
815
  for (const move of plan.moves) {
815
- logInfo(` ${move.from} -> ${move.to}`)
816
+ const label = move.classified === undefined ? '' : ` (${move.classified})`
817
+ logInfo(` ${move.from} -> ${move.to}${label}`)
816
818
  }
817
819
 
818
820
  logInfo(
@@ -837,7 +839,11 @@ function toRecordLayoutRecord(
837
839
  return {
838
840
  ok: true,
839
841
  wrote: wrote === true,
840
- moves: plan.moves.map((move) => ({ from: move.from, to: move.to })),
842
+ moves: plan.moves.map((move) => ({
843
+ from: move.from,
844
+ to: move.to,
845
+ ...(move.classified === undefined ? {} : { classified: move.classified }),
846
+ })),
841
847
  collisions: plan.collisions,
842
848
  files: plan.entries.length,
843
849
  rewritten: plan.rewritten,
@@ -1097,7 +1103,7 @@ export function register(program: Command): void {
1097
1103
 
1098
1104
  migrate
1099
1105
  .command('scratch-evidence')
1100
- .description('Promote cited measurement folders out of tmp into review')
1106
+ .description('Promote cited measurement folders out of tmp into evidence')
1101
1107
  .helpOption('-h, --help', 'Show this help message')
1102
1108
  .option('--json', 'Add a machine-readable record on stdout')
1103
1109
  .option('--write', 'Apply the plan rather than reporting it')
@@ -1110,7 +1116,7 @@ export function register(program: Command): void {
1110
1116
  [
1111
1117
  '',
1112
1118
  'Moves nine folders cited as measurement evidence from .canon/tmp/ to',
1113
- '.canon/review/evidence/, which canon records push already backs, and',
1119
+ '.canon/evidence/<nn>-<folder>/, numbered by first appearance, and',
1114
1120
  'repoints every citation that names one, live or archived.',
1115
1121
  '',
1116
1122
  'A promoted folder is one a durable record cites and no source file',
@@ -1136,7 +1142,7 @@ export function register(program: Command): void {
1136
1142
 
1137
1143
  migrate
1138
1144
  .command('record-layout')
1139
- .description('Fold memory review receipts and archive under memory/')
1145
+ .description('Move record folders to their batch layout')
1140
1146
  .helpOption('-h, --help', 'Show this help message')
1141
1147
  .option('--json', 'Add a machine-readable record on stdout')
1142
1148
  .option('--write', 'Apply the plan rather than reporting it')
@@ -1151,8 +1157,25 @@ export function register(program: Command): void {
1151
1157
  'Moves review receipts from .canon/review/memory/ to',
1152
1158
  '.canon/memory/review/, and retired entries from',
1153
1159
  '.canon/tmp/memory-archive/ to .canon/memory/archive/, backed for',
1154
- 'the first time, and repoints every citation that names either,',
1155
- 'live or archived.',
1160
+ 'the first time.',
1161
+ '',
1162
+ 'Leaves .canon/review/ holding only reviews:',
1163
+ ' review/feedback/ -> feedback/',
1164
+ ' review/{design,board,slides,diagrams}/ -> tmp/render/<kind>/',
1165
+ ' review/references/ -> picks/references/',
1166
+ ' review/branch/review-<slug>.md -> review/branch-<slug>.md',
1167
+ ' review/ui-checklist-<slug>.md -> tmp/ui-checklist/<slug>.md',
1168
+ ' review/evidence/<slug>/ -> picks/<slug>/ or evidence/<nn>-<slug>/',
1169
+ '',
1170
+ 'An evidence folder is a pick when it directly holds an arm-<id>',
1171
+ 'capture or a design-handoff.md, and evidence otherwise. Evidence',
1172
+ 'folders are numbered by the oldest file each holds, continuing past',
1173
+ 'any ordinal evidence/ already carries. The dry run labels every',
1174
+ 'derived destination, so check the split before passing --write.',
1175
+ '',
1176
+ 'A citation reaching into a moved folder or naming a moved file is',
1177
+ 'repointed, live or archived. A bare mention of an emptied folder, such',
1178
+ 'as review/evidence/ with no slug, matches no row and stays as written.',
1156
1179
  '',
1157
1180
  'A receipt sitting at the flat review/ root, the shape memory-review',
1158
1181
  'wrote before review/memory/ existed, is reported rather than moved.',
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { creationRel } from '@/record-root'
4
+ import { creationRel, SCRATCH } from '@/record-root'
5
5
  import { LAYOUTS } from '@/slides/layouts'
6
6
  import { openDeck } from '@/slides/open'
7
7
  import { renderSlidesDoc } from '@/slides/render'
@@ -20,7 +20,7 @@ export function register(program: Command): void {
20
20
  .option(
21
21
  '-o, --out <path>',
22
22
  'Output directory',
23
- creationRel(process.cwd(), 'review', 'slides'),
23
+ creationRel(process.cwd(), SCRATCH, 'render', 'slides'),
24
24
  )
25
25
  .option('-v, --variant <variant>', 'Override variant (light or dark)')
26
26
  .option(
@@ -399,7 +399,7 @@ function isImage(name: string): boolean {
399
399
  return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
400
400
  }
401
401
 
402
- /** Every image file directly inside an evidence arm folder, one level deep. */
402
+ /** Every image file directly inside a pick folder, one level deep. */
403
403
  function imagesIn(dir: string): string[] {
404
404
  return readdirSync(dir, { withFileTypes: true })
405
405
  .filter((entry) => entry.isFile() && isImage(entry.name))
@@ -407,32 +407,39 @@ function imagesIn(dir: string): string[] {
407
407
  .sort()
408
408
  }
409
409
 
410
+ /**
411
+ * The folder under `picks/` holding operator-supplied reference images, which
412
+ * the References panel reads. It sits beside the captures of the choices it
413
+ * fed, so the candidates panel steps over it rather than listing it as a pick.
414
+ */
415
+ const REFERENCES_FOLDER = 'references'
416
+
410
417
  function writeCandidatesPanel(root: string, outDir: string): void {
411
418
  const dir = join(outDir, 'candidates')
412
419
  mkdirSync(dir, { recursive: true })
413
420
 
414
- const evidenceDir = recordDir(root, 'review', 'evidence')
415
- if (!existsSync(evidenceDir)) {
421
+ const picksDir = recordDir(root, 'picks')
422
+ if (!existsSync(picksDir)) {
416
423
  writeFileSync(
417
424
  join(dir, 'index.html'),
418
425
  panelPage(
419
426
  'Past candidates',
420
- `<p class="empty">No ${relative(root, evidenceDir)} folder yet.</p>`,
427
+ `<p class="empty">No ${relative(root, picksDir)} folder yet.</p>`,
421
428
  ),
422
429
  )
423
430
  return
424
431
  }
425
432
 
426
- const folders = readdirSync(evidenceDir, { withFileTypes: true })
427
- .filter((entry) => entry.isDirectory())
433
+ const folders = readdirSync(picksDir, { withFileTypes: true })
434
+ .filter((entry) => entry.isDirectory() && entry.name !== REFERENCES_FOLDER)
428
435
  .map((entry) => entry.name)
429
436
  .sort()
430
437
 
431
438
  const found: Array<{ folder: string; images: string[] }> = []
432
439
  for (const folder of folders) {
433
- const images = imagesIn(join(evidenceDir, folder))
440
+ const images = imagesIn(join(picksDir, folder))
434
441
  if (images.length > 0) {
435
- cpSync(join(evidenceDir, folder), join(dir, folder), { recursive: true })
442
+ cpSync(join(picksDir, folder), join(dir, folder), { recursive: true })
436
443
  found.push({ folder, images })
437
444
  }
438
445
  }
@@ -442,7 +449,7 @@ function writeCandidatesPanel(root: string, outDir: string): void {
442
449
  join(dir, 'index.html'),
443
450
  panelPage(
444
451
  'Past candidates',
445
- `<p class="empty">${folders.length} folders under ${relative(root, evidenceDir)}/ and none carries a draft-and-pick arm capture. The archival capture step has not run since it shipped.</p>`,
452
+ `<p class="empty">${folders.length} folders under ${relative(root, picksDir)}/ and none carries a draft-and-pick arm capture. The archival capture step has not run since it shipped.</p>`,
446
453
  ),
447
454
  )
448
455
  return
@@ -462,7 +469,7 @@ function writeReferencesPanel(root: string, outDir: string): void {
462
469
  const dir = join(outDir, 'references')
463
470
  mkdirSync(dir, { recursive: true })
464
471
 
465
- const referencesDir = recordDir(root, 'review', 'references')
472
+ const referencesDir = recordDir(root, 'picks', REFERENCES_FOLDER)
466
473
  if (!existsSync(referencesDir)) {
467
474
  writeFileSync(
468
475
  join(dir, 'index.html'),
@@ -0,0 +1,79 @@
1
+ /**
2
+ * How a folder under `.canon/evidence/` is numbered, shared by the two
3
+ * migrations that land folders there.
4
+ *
5
+ * A folder takes `<nn>-<slug>` in the order folders first appeared, and a new
6
+ * one continues past the highest ordinal already present. No record folder is
7
+ * tracked in git, so file modification times are the only history a target
8
+ * holds to order by.
9
+ */
10
+
11
+ import { existsSync, readdirSync, statSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { extractOrdinal } from '@/intake/folder'
14
+
15
+ const ORDINAL_WIDTH = 2
16
+
17
+ /**
18
+ * When a folder first appeared, read as the oldest modification time of any
19
+ * file under it. A folder holding no file falls back to its own.
20
+ */
21
+ export function firstAppearance(dir: string): number {
22
+ let oldest = Number.POSITIVE_INFINITY
23
+
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ const path = join(dir, entry.name)
26
+ const time = entry.isDirectory()
27
+ ? firstAppearance(path)
28
+ : statSync(path).mtimeMs
29
+ oldest = Math.min(oldest, time)
30
+ }
31
+
32
+ return Number.isFinite(oldest) ? oldest : statSync(dir).mtimeMs
33
+ }
34
+
35
+ /** The folder names directly inside `dir`, sorted, or none when it is absent. */
36
+ export function folderNames(dir: string): string[] {
37
+ if (!existsSync(dir)) return []
38
+
39
+ return readdirSync(dir, { withFileTypes: true })
40
+ .filter((entry) => entry.isDirectory())
41
+ .map((entry) => entry.name)
42
+ .sort()
43
+ }
44
+
45
+ /** A folder name with any leading ordinal removed. */
46
+ export function slugOf(name: string): string {
47
+ const ordinal = extractOrdinal(name)
48
+ return ordinal === '' ? name : name.slice(ordinal.length + 1)
49
+ }
50
+
51
+ /** The ordinal a new folder takes after every name already present. */
52
+ export function nextOrdinal(existing: readonly string[]): number {
53
+ return (
54
+ existing
55
+ .map((name) => Number(extractOrdinal(name) || 0))
56
+ .reduce((carry, ordinal) => Math.max(carry, ordinal), 0) + 1
57
+ )
58
+ }
59
+
60
+ /** The folder name an ordinal and a slug make together. */
61
+ export function numberedName(ordinal: number, slug: string): string {
62
+ return `${String(ordinal).padStart(ORDINAL_WIDTH, '0')}-${slug}`
63
+ }
64
+
65
+ /**
66
+ * Orders candidate folders by first appearance, breaking an exact tie on the
67
+ * name so two runs over one tree number it the same way.
68
+ */
69
+ export function byFirstAppearance<T extends { name: string; dir: string }>(
70
+ folders: readonly T[],
71
+ ): T[] {
72
+ return folders
73
+ .map((folder) => ({ folder, appeared: firstAppearance(folder.dir) }))
74
+ .sort(
75
+ (a, b) =>
76
+ a.appeared - b.appeared || a.folder.name.localeCompare(b.folder.name),
77
+ )
78
+ .map(({ folder }) => folder)
79
+ }