@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-teach/SKILL.md +11 -1
- package/docs/agents/commands.md +10 -3
- package/docs/agents/install-and-sync.md +23 -1
- package/docs/agents/teach.md +2 -0
- package/docs/target-projects.md +6 -4
- package/docs/visual-design-workflow.md +2 -0
- package/package.json +1 -1
- package/src/commands/design.ts +147 -5
- package/src/commands/migrate.ts +27 -4
- package/src/commands/sync.ts +3 -5
- package/src/commands/teach.ts +72 -0
- package/src/design/adapter.ts +59 -0
- package/src/design/base.css +123 -0
- package/src/design/components.ts +118 -0
- package/src/design/contrast.ts +81 -0
- package/src/design/css.ts +142 -0
- package/src/design/document.ts +143 -0
- package/src/design/regen.ts +56 -0
- package/src/design/render.ts +43 -9
- package/src/design/tokens.ts +315 -0
- package/src/gate/stages.ts +28 -0
- package/src/migrate/records.ts +71 -1
- package/src/slides/styles.ts +42 -15
- package/src/sync/check.ts +41 -0
- package/src/sync/engine.ts +28 -7
- package/src/sync/stamp.ts +5 -4
- package/src/sync/target.ts +4 -1
- package/src/teach/workspace.ts +57 -0
|
@@ -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`.
|
|
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
|
package/docs/agents/commands.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
package/docs/agents/teach.md
CHANGED
|
@@ -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.
|
package/docs/target-projects.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
135
|
+
canon migrate records --json
|
|
136
|
+
canon migrate records --write --json
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
|
|
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
package/src/commands/design.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
}
|
package/src/commands/migrate.ts
CHANGED
|
@@ -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
|
-
|
|
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',
|
package/src/commands/sync.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
buildCheckReport,
|
|
8
8
|
type CheckReport,
|
|
9
9
|
hasDrift,
|
|
10
|
-
|
|
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
|
|
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)
|
package/src/commands/teach.ts
CHANGED
|
@@ -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
|
+
}
|