@erclx/canon 4.8.1 → 4.9.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.8.1",
4
+ "version": "4.9.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -95,12 +95,22 @@ canon teach lesson <topic> --json \
95
95
  It writes nothing and reports four things:
96
96
 
97
97
  - `lesson`, the numbered path the lesson takes. Write it there.
98
- - `stylesheet` with `stylesheetExists` and `stylesheetHref`. Write the stylesheet only when it reports absent, which is the first lesson in the workspace. Every lesson after that links the file already on disk and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
98
+ - `stylesheet` with `stylesheetExists` and `stylesheetHref`. Link it at `stylesheetHref` and seed it through the verb below when `stylesheetExists` is false, which is the first lesson in the workspace. Every lesson after that links the file already on disk and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
99
99
  - `success`, the mission's success lines, carried here so Step 5 needs no second read.
100
100
  - `quiz`, one entry per question, carrying `order` and `answer`.
101
101
 
102
102
  Write the correct option first, then present the options in the order `order` reports, reading it as authored indices where `0` is the correct one. Take the order as given. Position drawn here rather than chosen is the whole reason the verb exists, and a lesson that reorders on its own judgment puts the answer back in the first slot.
103
103
 
104
+ Seed the stylesheet through the verb rather than authoring a palette, on the first lesson in a workspace:
105
+
106
+ ```bash
107
+ canon teach stylesheet <topic> --json
108
+ ```
109
+
110
+ It writes the design tokens as custom properties and the components built on them, from the one source every other rendered surface reads. Add lesson rules under the seed and reach a value through its property rather than restating the hex, which is what let each workspace fork the palette from every other. It refuses to overwrite, so running it again on a workspace that has grown its own rules is safe and reports `written` as false.
111
+
112
+ Report it rather than proceeding silently when the verb does not resolve, which is an installed CLI predating it. Do not fall back to writing a palette by hand.
113
+
104
114
  Add every term the lesson defines to `GLOSSARY.md` through the verb, which places the entries alphabetically in the shape the standard fixes:
105
115
 
106
116
  ```bash
@@ -22,6 +22,10 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
22
22
  | `canon indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
23
  | `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
24
  | `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
+ | `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
26
+ | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
27
+ | `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
28
+ | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
25
29
  | `canon slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck, reporting any unrecognized layout name on stderr |
26
30
  | `canon slides list` | List the available slide layouts (`--json` for the catalog) |
27
31
  | `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github` |
@@ -36,6 +40,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
36
40
  | `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
37
41
  | `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
38
42
  | `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
43
+ | `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
39
44
  | `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
40
45
  | `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
41
46
  | `canon records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
@@ -112,11 +117,11 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
112
117
  | `demo` | `compile`, `run` |
113
118
  | `inventory` | `run` |
114
119
  | `wiki` | `init` |
115
- | `design` | `render` |
120
+ | `design` | `regen`, `css`, `render`, `install`, `sync` |
116
121
  | `slides` | `render`, `list` |
117
122
  | `tasks` | `archive`, `validate` |
118
123
  | `intake` | `list`, `answer` |
119
- | `teach` | `list`, `open`, `resource`, `glossary` |
124
+ | `teach` | `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet` |
120
125
  | `comments` | `scan` |
121
126
  | `context` | `audit` |
122
127
  | `markdown` | `audit` |
@@ -137,7 +142,9 @@ Common patterns:
137
142
 
138
143
  `migrate rename` moves a project off the retired `aitk` name. It reports until `--write` is passed, and `--scope target` rewrites the toolkit-owned folders alone, reporting every other citation as one the project owns rather than editing prose somebody wrote. A project installing `canon` fresh never needs it.
139
144
 
140
- `migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first. A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. Running it twice rewrites nothing, which is the check that the exclusions and the markers fired.
145
+ `migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first.
146
+
147
+ A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
141
148
 
142
149
  ## Version skew
143
150
 
@@ -27,7 +27,29 @@ number bands behind that placement, where `900-999` is the range a
27
27
  project-authored rule takes and everything below it belongs to the toolkit.
28
28
 
29
29
  `canon sync --check` does not report an orphaned entry. It skips every one, so
30
- the destination reaches `canon gov sync` alone, the one domain sync verb left.
30
+ the destination reaches `canon gov sync` alone among the two per-file domain
31
+ syncs, since design's own orphans are the target's overrides and are meant to
32
+ stay where they are.
33
+
34
+ `canon design install` copies one toolkit-owned file to
35
+ `.claude/design/base.css` and creates no override. A project overrides a value
36
+ by writing `.claude/design/project/` itself, which `canon design sync` never
37
+ touches, because that subfolder is project-authored by location the way
38
+ `.claude/rules/project/` is. An override named exactly like the shipped file is
39
+ still the project's.
40
+
41
+ The override ships absent rather than empty. An empty file is one the project
42
+ did not ask for and did not write, the reconciliation already handles a missing
43
+ side, and an empty override invites a target to fill it before it has an
44
+ opinion.
45
+
46
+ Nothing arrives on a project that has not run `canon design install`. The
47
+ domain is detected by that folder existing, so `canon sync` skips it entirely on
48
+ a target that never installed it, and the unstamped line at the end of
49
+ `canon sync --check` stays quiet about it for the same reason. Governance is
50
+ named there when it is absent and design is not, because a managed project
51
+ without governance has yet to install what every project carries, while one
52
+ without design chose that.
31
53
 
32
54
  When the target's install recorded a stack, `canon gov sync` also reports a
33
55
  rule that stack lists and `.claude/rules/` does not hold, as a `missing` entry
@@ -125,6 +125,8 @@ It reports four things. `lesson` is the numbered path the lesson takes, derived
125
125
 
126
126
  The stylesheet is reported rather than written. A verb that wrote it on every lesson would discard whatever the last lesson added, and the second lesson in a workspace is the one that needs the file the first one left.
127
127
 
128
+ `canon teach stylesheet <topic>` is what writes it, seeded from the design source so a workspace renders in the system every other surface does. It refuses to overwrite, reporting `written` as false where the workspace already carries one, and `--force` takes the seed back over it. Each workspace used to carry a hand-authored palette, which is how the course styling forked once per workspace, so a lesson adds its own rules under the seed and reaches a value through its custom property rather than restating the hex.
129
+
128
130
  Each `quiz` entry carries `order`, the authored option indices in presentation order where index `0` is the correct answer, and `answer`, the one-based position that answer lands in. Both travel together because a caller deriving the position itself is a caller that can derive it wrongly.
129
131
 
130
132
  The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
@@ -128,15 +128,17 @@ When the toolkit updates, target projects pull changes per domain. There is one
128
128
 
129
129
  Session records moved out of `.claude/` and into a root of their own. What is committed stays where it is, and everything gitignored, being the task board, the plans, the memory pen, the review reports, and the scratch folder, now lives under `.canon/`, which a single ignore entry covers.
130
130
 
131
- **`canon migrate records` is not in a published release yet.** It ships with the change that split the roots, so an installed binary answers `unknown command` until the next release carries it. Until then, run it out of a canon checkout against the project by path:
131
+ `4.7.0` carries `canon migrate records`, so run these three lines from inside the project. Run `canon upgrade` first regardless of what you hold, since the sweep learned to pass over the records themselves after that release and a `4.7.0` binary rewrites them.
132
132
 
133
133
  ```bash
134
134
  canon tooling sync claude . --write
135
- bun src/cli.ts migrate records --root /path/to/project --json
136
- bun src/cli.ts migrate records --root /path/to/project --write --json
135
+ canon migrate records --json
136
+ canon migrate records --write --json
137
137
  ```
138
138
 
139
- Once the release lands, the same three lines read `canon migrate records` from inside the project. Either way the first line takes the `.canon/` ignore entry, and the verb refuses until the project has it, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. The second reports the plan and the third applies it, moving the folders and repointing every tracked file that cites one.
139
+ The first line takes the `.canon/` ignore entry, and the verb refuses until the project has it, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. The second reports the plan and the third applies it, moving the folders and repointing every tracked file that cites one.
140
+
141
+ Order matters between the first line and the two under it. The sync prunes the twelve old `.claude/` ignore entries down to the one `.canon/` line, which leaves every record still at the old root visible to git and therefore to the verb. The sweep passes over them on purpose, reporting a count of what it left alone rather than reading the memory pen and the groundwork trails as source.
140
142
 
141
143
  Read the `ok` field out of the `--json` record rather than the exit code. A shell profile that wraps `canon` in a function takes its status from whatever the function runs last, so an absent subcommand and a clean run can both exit 0, and a reader watching the exit alone concludes the move happened.
142
144
 
@@ -24,6 +24,8 @@ The toolkit seed in `tooling/claude/seeds/.claude/DESIGN.md` ships a token-table
24
24
 
25
25
  The `canon:claude-design-extract` skill drafts the file, sourcing tokens from a project's existing prose and CLI UI surfaces, or proposing them from `.claude/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 `.claude/context/design.md`.
26
26
 
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
+
27
29
  A cell no source anchors ends in `? verify`, and the preview shows that marker beside the value rather than folding it in, so a swatch and a font sample stay built from the value alone. A confidence line above the sections names how many cells are anchored against how many are tagged, which is what tells a reader whether they are looking at a record of the code or a proposal about it. It reads the columns a source could anchor and leaves out the row names, so the ratio is not diluted by cells no tag could ever reach. The proposal path tags nearly all of them, so that count reads low on day one by design.
28
30
 
29
31
  ### Tools
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.8.1",
4
+ "version": "4.9.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,14 +1,80 @@
1
- import { existsSync } from 'node:fs'
2
- import { resolve } from 'node:path'
1
+ import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { basename, join, relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { creationRel } from '@/record-root'
4
+ import {
5
+ createDesignAdapter,
6
+ DESIGN_INSTALL_DIR,
7
+ DESIGN_PROJECT_SUBDIR,
8
+ } from '@/design/adapter'
9
+ import { buildDesignCss } from '@/design/css'
5
10
  import { renderDesignDoc } from '@/design/render'
6
- import { palette } from '@/ui'
11
+ import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
12
+ import { PROJECT_ROOT } from '@/project-root'
13
+ import { creationRel } from '@/record-root'
14
+ import { recordStamp, runDomainSync } from '@/sync/engine'
15
+ import { resolveTarget } from '@/target'
16
+ import { intro, logAdd, logError, logInfo, outro, palette } from '@/ui'
7
17
 
8
18
  export function register(program: Command): void {
9
19
  const design = program
10
20
  .command('design')
11
- .description('Design system commands (render)')
21
+ .description('Design system commands (regen, css, render, install, sync)')
22
+
23
+ design
24
+ .command('regen')
25
+ .description(
26
+ 'Rewrite .claude/DESIGN.md and the base stylesheet from src/design/tokens.ts',
27
+ )
28
+ .addHelpText(
29
+ 'after',
30
+ [
31
+ '',
32
+ 'Unlike install and sync, this runs against the toolkit checkout rather',
33
+ 'than a target. It rewrites the record and the base stylesheet from the',
34
+ 'token module and refuses where the record is absent, which is what the',
35
+ 'CLI installed into a project as a dependency looks like.',
36
+ '',
37
+ ].join('\n'),
38
+ )
39
+ .action(() => {
40
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
41
+
42
+ // Both outputs resolve from `PROJECT_ROOT`, which is the installed
43
+ // package directory when the CLI runs out of a target's `node_modules`.
44
+ // The record is the one output that is already committed here and ships
45
+ // with no package, so its absence is what separates the two.
46
+ if (!existsSync(join(PROJECT_ROOT, DESIGN_DOCUMENT))) {
47
+ process.stderr.write(
48
+ `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} No ${DESIGN_DOCUMENT} at ${PROJECT_ROOT}. Regen runs in the toolkit checkout, not against a target.\n${GREY}└${NC}\n`,
49
+ )
50
+ process.exitCode = 1
51
+ return
52
+ }
53
+
54
+ process.stderr.write(
55
+ `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Regenerate design source${NC}\n`,
56
+ )
57
+ const result = regenDesign(PROJECT_ROOT)
58
+ for (const path of [result.documentPath, result.cssPath]) {
59
+ process.stderr.write(
60
+ `${GREY}│${NC} ${GREEN}✓${NC} ${relative(PROJECT_ROOT, path)}\n`,
61
+ )
62
+ }
63
+ process.stderr.write(`${GREY}└${NC}\n`)
64
+ })
65
+
66
+ design
67
+ .command('css')
68
+ .description('Emit the design tokens and components as CSS on stdout')
69
+ .option(
70
+ '--no-components',
71
+ 'Custom properties only, without the component rules',
72
+ )
73
+ .action((opts: { components: boolean }) => {
74
+ process.stdout.write(
75
+ buildDesignCss(undefined, { components: opts.components }),
76
+ )
77
+ })
12
78
 
13
79
  design
14
80
  .command('render')
@@ -38,4 +104,80 @@ export function register(program: Command): void {
38
104
  `${GREY}│${NC} ${GREEN}✓${NC} ${result.htmlPath}\n${GREY}│${NC} ${GREEN}✓${NC} ${result.cssPath}\n${GREY}└${NC}\n`,
39
105
  )
40
106
  })
107
+
108
+ design
109
+ .command('install')
110
+ .description('Install the base stylesheet into a project')
111
+ .argument('[target]', 'Target directory', '.')
112
+ .helpOption('-h, --help', 'Show this help message')
113
+ .addHelpText(
114
+ 'after',
115
+ [
116
+ '',
117
+ 'Installs one toolkit-owned file and creates no override. A project',
118
+ `overrides a value by writing ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/`,
119
+ 'itself, which sync never touches. Nothing arrives on a project that',
120
+ 'has not run this, so a design value reaches a target on an install',
121
+ 'rather than on the next sync.',
122
+ '',
123
+ ].join('\n'),
124
+ )
125
+ .action(async (target: string) => {
126
+ process.exitCode = await runInstall(target)
127
+ })
128
+
129
+ design
130
+ .command('sync')
131
+ .description(
132
+ 'Update the base stylesheet already installed under .claude/design/',
133
+ )
134
+ .argument('[target]', 'Target directory', '.')
135
+ .helpOption('-h, --help', 'Show this help message')
136
+ .action(async (target: string) => {
137
+ process.exitCode = await runDomainSync(
138
+ createDesignAdapter(PROJECT_ROOT),
139
+ target,
140
+ { protectedRoot: PROJECT_ROOT },
141
+ )
142
+ })
143
+ }
144
+
145
+ /**
146
+ * Copies the base and stops. The override folder is deliberately not created:
147
+ * an empty override is a file the project did not ask for and did not write,
148
+ * the three-way merge already handles a missing side, and an empty file invites
149
+ * a target to fill it before it has an opinion.
150
+ */
151
+ async function runInstall(target: string): Promise<number> {
152
+ intro('canon design install')
153
+
154
+ const resolved = resolveTarget(target, PROJECT_ROOT)
155
+ if (typeof resolved === 'number') {
156
+ outro()
157
+ return resolved
158
+ }
159
+
160
+ const source = join(PROJECT_ROOT, DESIGN_BASE_CSS)
161
+ if (!existsSync(source)) {
162
+ logError(
163
+ `No base stylesheet at ${DESIGN_BASE_CSS}. Run canon design regen.`,
164
+ )
165
+ outro()
166
+ return 1
167
+ }
168
+
169
+ const dir = join(resolved, DESIGN_INSTALL_DIR)
170
+ mkdirSync(dir, { recursive: true })
171
+
172
+ const dest = join(dir, basename(source))
173
+ copyFileSync(source, dest)
174
+ logAdd(relative(resolved, dest))
175
+
176
+ await recordStamp(createDesignAdapter(PROJECT_ROOT), resolved, new Date())
177
+
178
+ logInfo(
179
+ `Override a value in ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, which sync leaves alone.`,
180
+ )
181
+ outro()
182
+ return 0
41
183
  }
@@ -6,6 +6,7 @@ import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
6
6
  import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
7
7
  import {
8
8
  ignoresDestination,
9
+ isRecordArtifact,
9
10
  planRecordsMove,
10
11
  type RecordsPlan,
11
12
  } from '@/migrate/records'
@@ -176,17 +177,24 @@ async function runRecords(opts: RecordsOptions): Promise<number> {
176
177
  return 1
177
178
  }
178
179
 
179
- const plan = planRecordsMove(root, await readSources(root, files))
180
+ // Filtered before the read rather than inside the planner, because the
181
+ // records are the largest thing in the tree and `readSources` awaits one file
182
+ // at a time. This repository's own record tree is 9,744 files at 83M, and the
183
+ // backup history under it is object files read whole and discarded as binary.
184
+ const toSweep = files.filter((path) => !isRecordArtifact(path))
185
+ const records = files.length - toSweep.length
186
+
187
+ const plan = planRecordsMove(root, await readSources(root, toSweep))
180
188
 
181
189
  // stdout, so the record pipes clean. `pipeOutput` frames to stderr, which is
182
190
  // where this command's report belongs and where a JSON record does not.
183
191
  if (opts.json) {
184
192
  process.stdout.write(
185
- `${JSON.stringify(toRecordsRecord(plan, opts.write))}\n`,
193
+ `${JSON.stringify(toRecordsRecord(plan, records, opts.write))}\n`,
186
194
  )
187
195
  }
188
196
 
189
- reportRecords(plan)
197
+ reportRecords(plan, records)
190
198
 
191
199
  if (plan.collisions.length > 0) {
192
200
  logError(
@@ -229,7 +237,7 @@ function readGitignore(root: string): string | undefined {
229
237
  }
230
238
  }
231
239
 
232
- function reportRecords(plan: RecordsPlan): void {
240
+ function reportRecords(plan: RecordsPlan, records: number): void {
233
241
  logInfo(`${plural(plan.moves.length, 'folder')} to move.`)
234
242
  for (const move of plan.moves) logInfo(` ${move.from} -> ${move.to}`)
235
243
  logInfo(
@@ -237,6 +245,14 @@ function reportRecords(plan: RecordsPlan): void {
237
245
  )
238
246
  logInfo(`${plural(plan.kept, 'citation')} marked to keep the old root.`)
239
247
 
248
+ // A count rather than a list, and its own line rather than a place in
249
+ // `excluded`. That field exists so a reader can go and check a handful by
250
+ // hand, and a record tree would bury them. The count is what answers the
251
+ // question a target actually has, which is where the rest of the files went.
252
+ if (records > 0) {
253
+ logInfo(`${plural(records, 'file')} under a record root, left alone.`)
254
+ }
255
+
240
256
  if (plan.excluded.length > 0) {
241
257
  logInfo(`${plural(plan.excluded.length, 'file')} excluded from the sweep.`)
242
258
  }
@@ -244,6 +260,7 @@ function reportRecords(plan: RecordsPlan): void {
244
260
 
245
261
  function toRecordsRecord(
246
262
  plan: RecordsPlan,
263
+ records: number,
247
264
  wrote: boolean | undefined,
248
265
  ): unknown {
249
266
  return {
@@ -255,6 +272,7 @@ function toRecordsRecord(
255
272
  rewritten: plan.rewritten,
256
273
  kept: plan.kept,
257
274
  excluded: plan.excluded.length,
275
+ records,
258
276
  paths: plan.entries.map((entry) => ({
259
277
  path: entry.path,
260
278
  rewritten: entry.rewritten,
@@ -294,6 +312,11 @@ export function register(program: Command): void {
294
312
  'A line carrying canon-keep-record-root, or the line below it, keeps the',
295
313
  'old root. Prose that dates a decision needs it; a live path does not.',
296
314
  '',
315
+ 'The records themselves are never swept. Everything under .canon/ and',
316
+ 'every .claude/ record folder is left alone and reported as a count, so',
317
+ 'a run after the ignore entries collapse touches the same files as one',
318
+ 'before it.',
319
+ '',
297
320
  'Examples:',
298
321
  ' canon migrate records',
299
322
  ' canon migrate records --write',
@@ -7,7 +7,7 @@ import {
7
7
  buildCheckReport,
8
8
  type CheckReport,
9
9
  hasDrift,
10
- SCANNED_DOMAINS,
10
+ uncoveredDomains,
11
11
  } from '@/sync/check'
12
12
  import { createGitRunner, createPullRequestOpener, hasGh } from '@/sync/git'
13
13
  import {
@@ -35,6 +35,7 @@ import { describeSkew } from '@/version/skew'
35
35
 
36
36
  const SYNC_ARGS: Record<SyncDomain, readonly string[]> = {
37
37
  governance: ['gov', 'sync'],
38
+ design: ['design', 'sync'],
38
39
  claude: ['claude', 'sync'],
39
40
  }
40
41
 
@@ -195,10 +196,7 @@ function renderCheck(report: CheckReport): void {
195
196
  // naming it here repeats what that section already said under a second
196
197
  // remedy, where a scanned domain nobody installed has no section at all and
197
198
  // this line is the only place it appears.
198
- const unmigrated = report.unmigrated.map((entry) => entry.domain)
199
- const uncovered = SCANNED_DOMAINS.filter(
200
- (domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
201
- )
199
+ const uncovered = uncoveredDomains(report)
202
200
  if (uncovered.length === 0) return
203
201
 
204
202
  const { GREY, NC } = palette(process.stderr)
@@ -16,6 +16,7 @@ import {
16
16
  type Term,
17
17
  type TermOutcome,
18
18
  type WorkspaceSummary,
19
+ writeStylesheet,
19
20
  } from '@/teach/workspace'
20
21
  import {
21
22
  intro,
@@ -276,6 +277,77 @@ export function register(program: Command): void {
276
277
  .action(async (topic: string, opts: LessonCommandOptions) => {
277
278
  process.exitCode = await runLesson(topic, opts)
278
279
  })
280
+
281
+ teach
282
+ .command('stylesheet')
283
+ .description('Seed a workspace stylesheet from the design source')
284
+ .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
285
+ .helpOption('-h, --help', 'Show this help message')
286
+ .option('--force', 'Rewrite a stylesheet the workspace already carries')
287
+ .option('--json', 'Emit a machine-readable record on stdout')
288
+ .option('--root <path>', 'Teach root, defaulting to the main worktree')
289
+ .addHelpText(
290
+ 'after',
291
+ [
292
+ '',
293
+ 'Exit codes:',
294
+ ' 0 the workspace carries a stylesheet, written now or already there',
295
+ ' 1 refused, with the reason on stderr or in the JSON record',
296
+ '',
297
+ 'The file it writes carries the design tokens as custom properties and',
298
+ 'the component rules built on them, so a workspace renders in the same',
299
+ 'system every other surface does. Add lesson rules under the seed and',
300
+ 'read a value through its property rather than restating the hex.',
301
+ '',
302
+ 'An existing stylesheet is left alone, since a workspace adds to this',
303
+ 'file as it goes. Pass --force to take the seed back over it.',
304
+ '',
305
+ 'Examples:',
306
+ ' canon teach stylesheet regular-expressions --json',
307
+ '',
308
+ ].join('\n'),
309
+ )
310
+ .action(async (topic: string, opts: StylesheetCommandOptions) => {
311
+ process.exitCode = await runStylesheet(topic, opts)
312
+ })
313
+ }
314
+
315
+ interface StylesheetCommandOptions {
316
+ readonly force?: boolean
317
+ readonly json?: boolean
318
+ readonly root?: string
319
+ }
320
+
321
+ async function runStylesheet(
322
+ topic: string,
323
+ opts: StylesheetCommandOptions,
324
+ ): Promise<number> {
325
+ const emitJson = opts.json ?? false
326
+ const root = await rootFor(opts.root)
327
+ const outcome = await writeStylesheet(root, topic, opts.force ?? false)
328
+
329
+ if (!outcome.ok) {
330
+ return reportRefusal('canon teach stylesheet', outcome, emitJson, root)
331
+ }
332
+
333
+ if (emitJson) {
334
+ process.stdout.write(
335
+ `${JSON.stringify({
336
+ ok: true,
337
+ root,
338
+ slug: outcome.slug,
339
+ path: outcome.path,
340
+ written: outcome.written,
341
+ })}\n`,
342
+ )
343
+ return 0
344
+ }
345
+
346
+ intro('canon teach stylesheet')
347
+ logStep(outcome.written ? 'Written' : 'Already present, left alone')
348
+ logInfo(outcome.path)
349
+ outro()
350
+ return 0
279
351
  }
280
352
 
281
353
  function collect(value: string, previous: string[]): string[] {
@@ -0,0 +1,59 @@
1
+ import { basename, join } from 'node:path'
2
+ import { DESIGN_BASE_CSS } from '@/design/regen'
3
+ import type { InstalledFile, SyncAdapter } from '@/sync/engine'
4
+
5
+ /**
6
+ * The channel a design value reaches a target on.
7
+ *
8
+ * Base plus a target-owned override, reconciled by the three-way merge the sync
9
+ * engine already implements. Overwrite was dropped before this was built and
10
+ * the reason is a measurement rather than an argument: a headless sync in
11
+ * another domain deleted a deploy job, replaced a screenshot harness, and
12
+ * reverted a shipped fix, because naming a stack was read as consent to lose an
13
+ * edit inside it.
14
+ *
15
+ * Layering is what bounds what the merge has to arbitrate. Nothing a project
16
+ * wrote lives in the base, so the base changes freely, and the merge shrinks to
17
+ * the properties a target actually overrode.
18
+ */
19
+
20
+ /** The folder a design install writes into, under the target's `.claude/`. */
21
+ export const DESIGN_INSTALL_DIR = join('.claude', 'design')
22
+
23
+ /**
24
+ * Where a target's own values go. `projectSubdir` makes every file here
25
+ * project-authored by location rather than by the name inference, so an
26
+ * override named `base.css` is still the target's and never overwritten.
27
+ *
28
+ * It ships absent rather than empty. An empty override is a file a project did
29
+ * not ask for and did not write, the merge already handles a missing side, and
30
+ * an empty file invites a target to fill it before it has an opinion.
31
+ */
32
+ export const DESIGN_PROJECT_SUBDIR = 'project'
33
+
34
+ export function designSourceDir(root: string): string {
35
+ return join(root, 'src', 'design')
36
+ }
37
+
38
+ export function createDesignAdapter(root: string): SyncAdapter {
39
+ const base = join(root, DESIGN_BASE_CSS)
40
+
41
+ return {
42
+ banner: 'canon design sync',
43
+ label: 'design',
44
+ missingMessage:
45
+ "No design surface found in target. Run 'canon design install' first.",
46
+ unit: 'changes',
47
+ installPattern: '**/*.css',
48
+ installedRoot: (target: string) => join(target, DESIGN_INSTALL_DIR),
49
+ /**
50
+ * One shipped file, matched by name the way the gov adapter matches a rule,
51
+ * so a base that moved inside the toolkit still syncs into the place the
52
+ * target already holds it.
53
+ */
54
+ locateSource: (file: InstalledFile) =>
55
+ basename(file.path) === basename(base) ? base : undefined,
56
+ projectSubdir: DESIGN_PROJECT_SUBDIR,
57
+ stamp: { domain: 'design', toolkitRoot: root },
58
+ }
59
+ }