@erclx/canon 4.65.0 → 4.67.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-frames-read/REQUIREMENT.md +34 -0
- package/claude/skills/canon-frames-read/SKILL.md +46 -0
- package/claude/skills/claude-worktree/SKILL.md +18 -4
- package/docs/agents/commands.md +4 -1
- package/docs/agents/demo.md +17 -1
- package/docs/agents/index.md +1 -1
- package/docs/target-projects.md +19 -0
- package/docs/workflow/ai-workflow.md +9 -5
- package/package.json +1 -1
- package/src/claude/cases/claude-workflow.ts +5 -0
- package/src/commands/claude.ts +15 -1
- package/src/commands/demo.ts +97 -1
- package/src/commands/design.ts +12 -3
- package/src/commands/docs.ts +4 -1
- package/src/commands/gov.ts +24 -4
- package/src/commands/migrate.ts +94 -14
- package/src/commands/sandbox.ts +7 -1
- package/src/commands/snippets.ts +8 -2
- package/src/commands/tooling.ts +10 -8
- package/src/demo/container.ts +95 -0
- package/src/design/components.ts +1157 -4
- package/src/design/css.ts +44 -17
- package/src/exec.ts +5 -1
- package/src/migrate/plan.ts +13 -5
- package/src/migrate/rename.ts +209 -94
- package/src/migrate/skill-names.ts +89 -0
- package/src/project-root.ts +17 -0
- package/src/sync/engine.ts +4 -0
- package/src/target.ts +5 -1
- package/src/teach/fonts.ts +28 -0
- package/src/teach/nav.ts +11 -1
- package/src/teach/workspace.ts +4 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canon-frames-read
|
|
3
|
+
description: Why a recording gets read back through numbered frames rather than left for a person to open, and why the report stops at description and never reaches a verdict
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Canon frames read requirement
|
|
7
|
+
|
|
8
|
+
## Gap
|
|
9
|
+
|
|
10
|
+
`ffmpeg` already ships as a dependency of the demo path, converting the raw recording to mp4 and gif, but nothing reads a recording back today. A demo run's own output is verified only when a person opens the video by hand, so a broken recording ships until someone happens to look.
|
|
11
|
+
|
|
12
|
+
## Must
|
|
13
|
+
|
|
14
|
+
- Call `canon demo frames <video> --json` rather than reading the video any other way
|
|
15
|
+
- Read every path the record's `frames` array names, in order, with the Read tool
|
|
16
|
+
- Report one plain description per frame, covering the visible UI state, on-screen text, cursor position, and what changed since the frame before it
|
|
17
|
+
- Branch on the record's `reason` for a refusal (`video-missing`, `converter-missing`, `extraction-failed`) and report it rather than guessing what the recording shows
|
|
18
|
+
|
|
19
|
+
## Must not
|
|
20
|
+
|
|
21
|
+
- Write a pass-fail judgment, a "looks correct" line, or a "looks broken" line anywhere in the report. A frame read is evidence a person weighs, not a verdict this skill hands them.
|
|
22
|
+
- Drive the application. This skill only reads files the verb already wrote.
|
|
23
|
+
- Edit, trim, or otherwise modify the recording.
|
|
24
|
+
- Assume this skill's own invocation frequency needs no check. Nothing names it as a step after `canon demo run` beyond an operator typing it or a body pointing here by hand, so a review pass some months in should read that back rather than take it on faith.
|
|
25
|
+
|
|
26
|
+
## Guards
|
|
27
|
+
|
|
28
|
+
- No recording path given: stop rather than guessing which video to read
|
|
29
|
+
|
|
30
|
+
## Out of scope
|
|
31
|
+
|
|
32
|
+
- Recording the video itself, which `canon demo run` owns
|
|
33
|
+
- Judging whether the recording is correct, which nothing in the toolkit does yet
|
|
34
|
+
- Sampling fewer frames than the verb's `--fps` default produces, since nothing has measured a long or high-fps recording as a real problem yet
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canon-frames-read
|
|
3
|
+
description: Pulls numbered still frames from a recorded video through `canon demo frames`, reads each one with the Read tool, and reports one plain description per frame. Never judges the recording, since a frame read is evidence rather than a verdict. Use when asked to "check the recording", "read the demo frames", "see what the video shows", or right after `canon demo run` writes a video and nobody has opened it yet. Do NOT use to record the video, which is `canon-record`, or to state whether the recording looks correct or broken, which is out of scope for every surface in the toolkit today.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Canon frames read
|
|
7
|
+
|
|
8
|
+
## Guards
|
|
9
|
+
|
|
10
|
+
- If no recording path is given, stop: `❌ No recording path. Pass the video canon demo run wrote.`
|
|
11
|
+
- Never write a pass-fail judgment, a "looks correct" line, or a "looks broken" line, anywhere in the report. A frame read is evidence a person weighs, not a verdict this skill hands them.
|
|
12
|
+
- Never drive the application. Everything this skill touches is the frame files the verb already wrote, and it opens no browser and clicks nothing.
|
|
13
|
+
- Never edit, trim, or otherwise modify the recording. The frames verb writes new files beside it, and this skill only reads what that writes.
|
|
14
|
+
|
|
15
|
+
## Step 1: extract frames
|
|
16
|
+
|
|
17
|
+
Run:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
canon demo frames <video> --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Branch on the record's `reason` rather than the exit code:
|
|
24
|
+
|
|
25
|
+
- `video-missing`: report that the path does not resolve and stop.
|
|
26
|
+
- `converter-missing`: report that ffmpeg is not installed and stop. `canon demo frames --help` names the install line.
|
|
27
|
+
- `extraction-failed`: report the record's `message` and stop.
|
|
28
|
+
- No `reason` key, meaning frames were written: continue to Step 2 with the record's `frames` array.
|
|
29
|
+
|
|
30
|
+
## Step 2: read every frame
|
|
31
|
+
|
|
32
|
+
Read each path in `frames`, in array order, with the Read tool. Do not skip any and do not sample a subset, since a gap in the sequence is a gap in what the report covers.
|
|
33
|
+
|
|
34
|
+
## Step 3: report
|
|
35
|
+
|
|
36
|
+
One numbered list entry per frame, each a plain description of what is on screen: the visible UI state, on-screen text, cursor position, and anything that changed from the entry before it. Skip a frame that is identical to its predecessor rather than repeating the same sentence.
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
1. <plain description of frame 1>
|
|
40
|
+
2. <plain description of frame 2>
|
|
41
|
+
...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Close with the frame count and the source video path. Say nothing about whether the recording succeeded or looks right. That call belongs to whoever reads the report.
|
|
45
|
+
|
|
46
|
+
Say so plainly if `canon demo frames` is not available, rather than reading the video some other way. The verb ships with the CLI and this skill ships with the plugin, so a project carrying one and not the other is a real state.
|
|
@@ -118,14 +118,28 @@ A linked worktree is a second working directory over one repository, and every e
|
|
|
118
118
|
|
|
119
119
|
Report the state on one line. Do not install. Entering a worktree to read is as common as entering one to run, and an install is slow, needs a network, and picks an ecosystem on the session's behalf.
|
|
120
120
|
|
|
121
|
-
Read the worktree root and evaluate node and python independently, each emitting its own line regardless of the other's state:
|
|
121
|
+
Read the worktree root and evaluate node and python independently against a literal presence test, each emitting its own line regardless of the other's state:
|
|
122
122
|
|
|
123
|
-
- Node. `package.json
|
|
124
|
-
- Python.
|
|
123
|
+
- Node. `[ -f package.json ] && [ ! -d node_modules ]`: `Dependencies are not installed. Run <install> before any build, test, or server command.` `[ -f package.json ] && [ -d node_modules ]`: `Node dependencies are installed.`
|
|
124
|
+
- Python. `{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && [ ! -d .venv ]`: `No virtual environment. Create and populate one before running anything.` `{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && [ -d .venv ]`: `Python dependencies are installed.`
|
|
125
|
+
|
|
126
|
+
`[ -d node_modules ]` reads a symlinked `node_modules` as present, which is the correct read rather than a regression.
|
|
125
127
|
|
|
126
128
|
Name the ecosystem in both installed lines rather than leaving `Dependencies are installed.` unqualified. Both checks can fire on one project, so an unqualified line reported the same sentence twice for a dual-root project with both folders present, and a reader could not tell which half each line answered.
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
Take `<install>` from the lockfile beside the manifest, checked in this order, first match wins:
|
|
131
|
+
|
|
132
|
+
| Lockfile | Install command |
|
|
133
|
+
| ------------------------- | --------------- |
|
|
134
|
+
| `bun.lock` or `bun.lockb` | `bun install` |
|
|
135
|
+
| `pnpm-lock.yaml` | `pnpm install` |
|
|
136
|
+
| `yarn.lock` | `yarn install` |
|
|
137
|
+
| `package-lock.json` | `npm install` |
|
|
138
|
+
| none of the above | `bun install` |
|
|
139
|
+
|
|
140
|
+
The order matters only when more than one lockfile sits beside the manifest, such as a project mid-migration between package managers. It is fixed rather than derived from anything about the project, so a reader hitting that rare case checks which manager the project actually uses rather than trusting the row the table picked first.
|
|
141
|
+
|
|
142
|
+
Emit the closing line only on its own direct test, run ahead of the two checks above rather than reached by falling through them unmatched: `[ ! -f package.json ] && [ ! -f pyproject.toml ] && [ ! -f requirements.txt ]`: `No package manifest, so there is nothing to install.` A dual-root project matches both checks above, and a fallthrough test would route it here by accident.
|
|
129
143
|
|
|
130
144
|
The closing line is what keeps the step honest on a stack this skill cannot read. Entry is not stack-aware, and silence is indistinguishable from a check that passed.
|
|
131
145
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -51,6 +51,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
51
51
|
| `canon records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
|
|
52
52
|
| `canon records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
|
|
53
53
|
| `canon migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
|
|
54
|
+
| `canon migrate skill-names` | Move the prefixed skill folders onto their two-word names and rewrite every citation, reporting the plan without `--write` (`--root`, `--json`) |
|
|
54
55
|
| `canon migrate records` | Move the gitignored session records to `.canon/` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
|
|
55
56
|
| `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
|
|
56
57
|
| `canon migrate rule-layout` | Move a target's installed rules from the flat `.claude/rules/<subdir>/` layout to `.claude/rules/canon/<subdir>/`, reporting the plan without `--write` (`--root`, `--json`) |
|
|
@@ -138,7 +139,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
138
139
|
| `secrets` | `scan` |
|
|
139
140
|
| `deps` | `audit` |
|
|
140
141
|
| `labels` | `audit` |
|
|
141
|
-
| `migrate` | `rename`, `records`
|
|
142
|
+
| `migrate` | `rename`, `skill-names`, `records` |
|
|
142
143
|
| `autoship` | `classify` |
|
|
143
144
|
| `pr` | `key-changes`, `head`, `checks` |
|
|
144
145
|
| `audits` | `run`, `list` |
|
|
@@ -153,6 +154,8 @@ Common patterns:
|
|
|
153
154
|
|
|
154
155
|
`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.
|
|
155
156
|
|
|
157
|
+
`migrate skill-names` moves the plugin skills that carried a `claude-` prefix onto their two-word names and rewrites every citation of one. It shares its engine with `migrate rename` and differs in what it is scanning for: a skill name is a whole name rather than a word stem, so it matches only where the word ends, which is what keeps a longer word that merely opens with a skill name, such as `claude-worktrees`, out of it. It takes no `--scope`, since the skill folders it moves are authored in the toolkit and no target holds a copy of that catalog, which leaves a target's own citations of a renamed skill as the whole of what it rewrites there. The changelog and the eval transcripts are passed over on the argument the other sweep already carries, that each records what shipped or what a session ran under whatever name was current then.
|
|
158
|
+
|
|
156
159
|
`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.
|
|
157
160
|
|
|
158
161
|
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 nearest non-blank 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.
|
package/docs/agents/demo.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Demo
|
|
3
|
-
description: Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, the pointer the recording paints, and what each refusal reports
|
|
3
|
+
description: Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, reading numbered frames back out of a recording, the pointer the recording paints, and what each refusal reports
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Demo
|
|
@@ -13,6 +13,7 @@ Two verbs, and they are separate because the artifact between them is edited.
|
|
|
13
13
|
canon demo compile .canon/tmp/screencast/inline-edit.md
|
|
14
14
|
canon demo run demos/inline-edit.json
|
|
15
15
|
canon demo run demos/inline-edit.json --cursor ~/cursors/theme --out assets
|
|
16
|
+
canon demo frames demos/inline-edit.webm --fps 2
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
## The draft and the plan are different files
|
|
@@ -53,6 +54,18 @@ When `ffmpeg` is on PATH, the run also writes an mp4 beside the webm, since webm
|
|
|
53
54
|
|
|
54
55
|
A step waits on its `waitFor` selector becoming visible and then holds for its own `holdMs`, which is what puts a finished state on screen long enough to read. `navigate` uses the plan's URL unless the step names its own.
|
|
55
56
|
|
|
57
|
+
## What frames does
|
|
58
|
+
|
|
59
|
+
`canon demo frames` pulls numbered PNG stills back out of a recorded video through the same `ffmpeg` binary the mp4 and gif conversion already shells to. Nothing reads a recording back today, so a broken one ships until a person opens it, and this verb is what a skill calls to read one instead. It writes one frame a second by default, sampling at a rate matched to how long a tuned recording runs rather than at a rate tuned for any one clip. Frames land beside the video by default, named `<video-basename>-frame-<NNN>.png`, so they fall under the same `demos/*.png` gitignore entry the still already uses.
|
|
60
|
+
|
|
61
|
+
| Option | Behavior |
|
|
62
|
+
| ----------------- | -------------------------------------------------------- |
|
|
63
|
+
| `-o, --out <dir>` | Directory to write frames into, default beside the video |
|
|
64
|
+
| `--fps <n>` | Frames extracted per second of video, default `1` |
|
|
65
|
+
| `--json` | Add a record on stdout carrying every frame path written |
|
|
66
|
+
|
|
67
|
+
`canon-frames-read` is the routed way to call this verb and read the frames back: it runs the verb, reads each returned frame with the Read tool, and reports one plain description per frame. It never renders a verdict, since a frame read is evidence a person weighs rather than a pass or fail this toolkit states on their behalf.
|
|
68
|
+
|
|
56
69
|
## The pointer is painted inside the page
|
|
57
70
|
|
|
58
71
|
The browser engine offers an annotation of its own that draws a dot on the interacted element and a title naming the API call it made, and this recorder does not turn it on. It paints no cursor, so a run relying on it looks like the pointer teleports between targets, and the two overlays below supersede it: a real cursor where the dot is a marker, and the beat's narration where the title reads `Mouse move`. Running both put four overlays on the frame, and the two the engine drew were the two a viewer reads as noise.
|
|
@@ -77,6 +90,9 @@ Every refusal exits 1 and names its reason in the `--json` record, so a skill br
|
|
|
77
90
|
| `plan-unreadable` | The plan is not JSON, or a step names a kind nothing drives |
|
|
78
91
|
| `browser-missing` | The browser binary is not installed, with `install` carrying the command |
|
|
79
92
|
| `engine-missing` | The browser package itself did not resolve |
|
|
93
|
+
| `video-missing` | No file at the path given to `frames` |
|
|
94
|
+
| `converter-missing` | `ffmpeg` is not on PATH, so `frames` extracted nothing |
|
|
95
|
+
| `extraction-failed` | `ffmpeg` exited non-zero, with `message` carrying its stderr |
|
|
80
96
|
|
|
81
97
|
## The browser reaches every target
|
|
82
98
|
|
package/docs/agents/index.md
CHANGED
|
@@ -16,7 +16,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
16
16
|
- [Context audit checks](context-audit-checks.md): What each non-gating check reports, the unit each checkpoint is measured in, the architecture record's length gate and claim coverage, which folders each check reaches, and what moved to the attribute tier
|
|
17
17
|
- [Context audit](context-audit.md): Running the audit, its flags and folder scope, the exit codes, the citation gate, and the widened gate the seed stage runs
|
|
18
18
|
- [Self-stated counts](counts.md): Reading a sentence that asserts a closed catalog's size, how a match is decided, the plausibility filter that keeps a generic word from matching a subset, and why the sweep reports rather than gates
|
|
19
|
-
- [Demo](demo.md): Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, the pointer the recording paints, and what each refusal reports
|
|
19
|
+
- [Demo](demo.md): Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, reading numbered frames back out of a recording, the pointer the recording paints, and what each refusal reports
|
|
20
20
|
- [Docs](docs.md): How canon docs resolves the toolkit's own reference surface from an install root, and how a split domain is named
|
|
21
21
|
- [Driver](driver.md): Walking a page through named interactions, the probe catalog and the false finding each one carries, why viewport heights are never defaulted, and what each refusal reports
|
|
22
22
|
- [Merge gate](gate.md): Running the gate this repository verifies a branch with, what the stage table holds and what stays a script, how the changed set scopes three stages, and why a stage that cannot read its input reports rather than passing
|
package/docs/target-projects.md
CHANGED
|
@@ -173,6 +173,25 @@ canon migrate rule-layout --write --json
|
|
|
173
173
|
|
|
174
174
|
The first line reports the plan and the second applies it, relocating each rule and carrying an edited one's recorded hash forward so it still reports as edited on the next sync rather than reading clean. Run it once, ahead of `canon gov sync` or `canon gov install`, on any project scaffolded before this move landed.
|
|
175
175
|
|
|
176
|
+
### Rename the skill citations, once
|
|
177
|
+
|
|
178
|
+
Twenty-five plugin skills dropped their `claude-` prefix for two-word names, so `canon:claude-docs` answers as `canon:docs-fold` and `canon:claude-tasks` as `canon:task-board`. A project that installed governance or tooling before that release holds files naming the old ones, and the plugin answers to none of them.
|
|
179
|
+
|
|
180
|
+
Two of those files run rather than sit there. `.husky/post-merge` prints a command for a person to type, and `.claude/hooks/pr-create-log.sh` hands a session a message naming a skill, so a stale copy tells someone to invoke something that no longer exists. A rule under `.claude/rules/canon/core/` names skills too, though a rule is read rather than run.
|
|
181
|
+
|
|
182
|
+
Resync what the toolkit owns, then sweep what the project wrote:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
canon gov sync
|
|
186
|
+
canon tooling sync <stack> . --write
|
|
187
|
+
canon migrate skill-names --json
|
|
188
|
+
canon migrate skill-names --write --json
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Run the second line once per stack the project holds, since the two hooks above arrive from different ones. The third reports the plan and the fourth applies it.
|
|
192
|
+
|
|
193
|
+
The order carries the reason. A sync replaces each toolkit-owned copy with one already carrying the new names, and the sweep afterwards reaches the prose the project wrote itself. Sweeping first rewrites those installed files in place, which moves them off the hash the install recorded, so the next sync reads them as edited by the project and leaves them alone.
|
|
194
|
+
|
|
176
195
|
### Check first
|
|
177
196
|
|
|
178
197
|
`canon sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next.
|
|
@@ -204,6 +204,7 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
204
204
|
| `canon:setup-smoke` | After `setup-verify` passes, to check the dev and preview servers, end-to-end tests, and the screenshot harness |
|
|
205
205
|
| `canon:claude-design-extract` | Before the first UI feature, to draft `.claude/DESIGN.md` |
|
|
206
206
|
| `canon:claude-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
|
|
207
|
+
| `canon:repo-metadata` | When the GitHub About text, homepage, or topics may have drifted, to reconcile them against the README |
|
|
207
208
|
|
|
208
209
|
### Decide what to build
|
|
209
210
|
|
|
@@ -265,11 +266,13 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
265
266
|
|
|
266
267
|
### Run several tracks at once
|
|
267
268
|
|
|
268
|
-
| Skill | When to use
|
|
269
|
-
| -------------------------- |
|
|
270
|
-
| `canon:claude-orchestrate` | To assert the control session that owns the queue and reviews each worker's PR
|
|
271
|
-
| `canon:
|
|
272
|
-
| `canon:
|
|
269
|
+
| Skill | When to use |
|
|
270
|
+
| -------------------------- | ------------------------------------------------------------------------------- |
|
|
271
|
+
| `canon:claude-orchestrate` | To assert the control session that owns the queue and reviews each worker's PR |
|
|
272
|
+
| `canon:claude-planner` | To assert the planner role for a cold session writing one plan under one task |
|
|
273
|
+
| `canon:claude-worker` | To assert the worker role for a cold session building one branch under one plan |
|
|
274
|
+
| `canon:session-resume` | At the start of a session, to pick up what a previous one left |
|
|
275
|
+
| `canon:session-map` | At the close of a session, to write the handoff a compaction would destroy |
|
|
273
276
|
|
|
274
277
|
### Keep the project current with the toolkit
|
|
275
278
|
|
|
@@ -311,6 +314,7 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
311
314
|
| `canon:canon-cli` | Before running an unfamiliar verb, a sync, or an install, to learn which command to run, which reference doc covers it, or what it overwrites, merges, or leaves alone |
|
|
312
315
|
| `canon:index-lookup` | To find where a topic is documented across the tracked `index.md` catalogs |
|
|
313
316
|
| `canon:youtube-transcripts` | When a video transcript is wanted in the repo as context |
|
|
317
|
+
| `canon:canon-frames-read` | To read a recorded demo back frame by frame and report what each one shows, with no verdict on whether the recording looks right |
|
|
314
318
|
| `canon:claude-teach` | To learn a subject across sessions, in a workspace that holds the progress |
|
|
315
319
|
| `canon:write-human` | Before drafting or revising prose, for voice, rhythm, and density |
|
|
316
320
|
| `canon:restate-plainly` | When an answer or a document has to be read again in plain words |
|
package/package.json
CHANGED
|
@@ -38,6 +38,11 @@ export const CLAUDE_WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
38
38
|
'Sketch out a plan for adding this new capability before we touch any code.',
|
|
39
39
|
expect: 'claude-feature',
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
prompt:
|
|
43
|
+
'The video just finished recording. Read it back and tell me what each part shows.',
|
|
44
|
+
expect: 'canon-frames-read',
|
|
45
|
+
},
|
|
41
46
|
{
|
|
42
47
|
prompt:
|
|
43
48
|
'We need to measure this properly before committing to an approach.',
|
package/src/commands/claude.ts
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
} from '@/claude/settings'
|
|
51
51
|
import { copyPreservingMode } from '@/copy'
|
|
52
52
|
import { execScript } from '@/exec'
|
|
53
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
53
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
54
54
|
import { recordDir } from '@/record-root'
|
|
55
55
|
import { isDirectory, resolveTarget } from '@/target'
|
|
56
56
|
import { injectGitignore, pruneGitignore } from '@/tooling/inject'
|
|
@@ -620,15 +620,21 @@ async function sameContent(src: string, dest: string): Promise<boolean> {
|
|
|
620
620
|
* human listing stays on the timeline. Only the human mode opens a frame.
|
|
621
621
|
*/
|
|
622
622
|
async function runSeedsList(opts: SeedsListOptions): Promise<number> {
|
|
623
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
624
|
+
// path that opens one and bare on the two that return first and open none.
|
|
625
|
+
// One position cannot serve all three.
|
|
626
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
623
627
|
const listings = listSeeds(PROJECT_ROOT)
|
|
624
628
|
|
|
625
629
|
if (opts.json) {
|
|
630
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
626
631
|
const withContent = await readSeedContents(listings)
|
|
627
632
|
process.stdout.write(`${JSON.stringify(withContent)}\n`)
|
|
628
633
|
return 0
|
|
629
634
|
}
|
|
630
635
|
|
|
631
636
|
if (opts.names) {
|
|
637
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
632
638
|
process.stdout.write(
|
|
633
639
|
listings.map((listing) => listing.target).join('\n') + '\n',
|
|
634
640
|
)
|
|
@@ -637,6 +643,7 @@ async function runSeedsList(opts: SeedsListOptions): Promise<number> {
|
|
|
637
643
|
|
|
638
644
|
const { GREY, NC } = palette(process.stderr)
|
|
639
645
|
intro('canon claude')
|
|
646
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
640
647
|
logStep('Seed docs')
|
|
641
648
|
for (const listing of listings) {
|
|
642
649
|
logInfo(`${listing.target} ${GREY}← ${listing.source}${NC}`)
|
|
@@ -646,14 +653,20 @@ async function runSeedsList(opts: SeedsListOptions): Promise<number> {
|
|
|
646
653
|
}
|
|
647
654
|
|
|
648
655
|
function runSkillsList(opts: SkillsListOptions): number {
|
|
656
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
657
|
+
// path that opens one and bare on the two that return first and open none.
|
|
658
|
+
// One position cannot serve all three.
|
|
659
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
649
660
|
const listings = listSkills(PROJECT_ROOT)
|
|
650
661
|
|
|
651
662
|
if (opts.json) {
|
|
663
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
652
664
|
process.stdout.write(`${JSON.stringify({ skills: listings })}\n`)
|
|
653
665
|
return 0
|
|
654
666
|
}
|
|
655
667
|
|
|
656
668
|
if (opts.names) {
|
|
669
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
657
670
|
process.stdout.write(
|
|
658
671
|
listings.map((listing) => listing.name).join('\n') + '\n',
|
|
659
672
|
)
|
|
@@ -661,6 +674,7 @@ function runSkillsList(opts: SkillsListOptions): number {
|
|
|
661
674
|
}
|
|
662
675
|
|
|
663
676
|
intro('canon claude')
|
|
677
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
664
678
|
logStep('Plugin skills')
|
|
665
679
|
for (const listing of listings) {
|
|
666
680
|
logInfo(listing.name)
|
package/src/commands/demo.ts
CHANGED
|
@@ -4,7 +4,12 @@ import type { Command } from 'commander'
|
|
|
4
4
|
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
5
5
|
import { parseDraft } from '@/demo/beats'
|
|
6
6
|
import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
convertToGif,
|
|
9
|
+
convertToMp4,
|
|
10
|
+
extractFrames,
|
|
11
|
+
INSTALL_CONVERTER,
|
|
12
|
+
} from '@/demo/container'
|
|
8
13
|
import { DEFAULT_CURSORS } from '@/demo/cursors'
|
|
9
14
|
import { loadCursorTheme } from '@/demo/theme'
|
|
10
15
|
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
@@ -34,6 +39,12 @@ interface RunOptions {
|
|
|
34
39
|
readonly json?: boolean
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
interface FramesOptions {
|
|
43
|
+
readonly out?: string
|
|
44
|
+
readonly fps: string
|
|
45
|
+
readonly json?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
export function register(program: Command): void {
|
|
38
49
|
const demo = program
|
|
39
50
|
.command('demo')
|
|
@@ -111,6 +122,38 @@ export function register(program: Command): void {
|
|
|
111
122
|
.action(async (plan: string, opts: RunOptions) => {
|
|
112
123
|
process.exitCode = await runDrive(plan, opts)
|
|
113
124
|
})
|
|
125
|
+
|
|
126
|
+
demo
|
|
127
|
+
.command('frames')
|
|
128
|
+
.description('Pull numbered still frames from a recorded video')
|
|
129
|
+
.argument('<video>', 'Video written by canon demo run')
|
|
130
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
131
|
+
.option(
|
|
132
|
+
'-o, --out <dir>',
|
|
133
|
+
'Directory to write frames into, default beside the video',
|
|
134
|
+
)
|
|
135
|
+
.option('--fps <n>', 'Frames extracted per second of video', '1')
|
|
136
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
137
|
+
.addHelpText(
|
|
138
|
+
'after',
|
|
139
|
+
[
|
|
140
|
+
'',
|
|
141
|
+
'Needs ffmpeg on PATH, the same binary demo run already shells to for',
|
|
142
|
+
`mp4 and gif conversion. Install it with: ${INSTALL_CONVERTER}`,
|
|
143
|
+
'',
|
|
144
|
+
'Exit codes:',
|
|
145
|
+
' 0 frames were written',
|
|
146
|
+
' 1 refused, with the reason on stderr',
|
|
147
|
+
'',
|
|
148
|
+
'Examples:',
|
|
149
|
+
' canon demo frames demos/inline-edit.webm',
|
|
150
|
+
' canon demo frames demos/inline-edit.webm --fps 2 --out frames',
|
|
151
|
+
'',
|
|
152
|
+
].join('\n'),
|
|
153
|
+
)
|
|
154
|
+
.action(async (video: string, opts: FramesOptions) => {
|
|
155
|
+
process.exitCode = await runFrames(video, opts)
|
|
156
|
+
})
|
|
114
157
|
}
|
|
115
158
|
|
|
116
159
|
function runCompile(draftPath: string, opts: CompileOptions): number {
|
|
@@ -344,6 +387,59 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
344
387
|
return 0
|
|
345
388
|
}
|
|
346
389
|
|
|
390
|
+
async function runFrames(
|
|
391
|
+
videoPath: string,
|
|
392
|
+
opts: FramesOptions,
|
|
393
|
+
): Promise<number> {
|
|
394
|
+
intro('canon demo frames')
|
|
395
|
+
|
|
396
|
+
const source = resolve(process.cwd(), videoPath)
|
|
397
|
+
if (!existsSync(source)) {
|
|
398
|
+
logStep('Video')
|
|
399
|
+
logError(`${videoPath} not found`)
|
|
400
|
+
outro()
|
|
401
|
+
emit(opts.json, { video: source, reason: 'video-missing' })
|
|
402
|
+
return 1
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
logStep('Video')
|
|
406
|
+
logInfo(display(source))
|
|
407
|
+
|
|
408
|
+
const outDir = opts.out ? resolve(process.cwd(), opts.out) : undefined
|
|
409
|
+
const fps = Number(opts.fps)
|
|
410
|
+
|
|
411
|
+
logStep('Frames')
|
|
412
|
+
const extracted = await extractFrames(source, outDir, { fps })
|
|
413
|
+
if (extracted.status === 'skipped') {
|
|
414
|
+
logError('ffmpeg is not installed, so no frames were written.')
|
|
415
|
+
logWarn(`Install it with: ${INSTALL_CONVERTER}`)
|
|
416
|
+
outro()
|
|
417
|
+
emit(opts.json, { video: source, reason: extracted.reason })
|
|
418
|
+
return 1
|
|
419
|
+
}
|
|
420
|
+
if (extracted.status === 'failed') {
|
|
421
|
+
logError(`frame extraction failed: ${extracted.reason}`)
|
|
422
|
+
outro()
|
|
423
|
+
emit(opts.json, {
|
|
424
|
+
video: source,
|
|
425
|
+
reason: 'extraction-failed',
|
|
426
|
+
message: extracted.reason,
|
|
427
|
+
})
|
|
428
|
+
return 1
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const framePath of extracted.framePaths) logInfo(display(framePath))
|
|
432
|
+
logInfo(`${plural(extracted.framePaths.length, 'frame')} at ${fps} fps`)
|
|
433
|
+
outro()
|
|
434
|
+
|
|
435
|
+
emit(opts.json, {
|
|
436
|
+
video: source,
|
|
437
|
+
frames: extracted.framePaths,
|
|
438
|
+
fps,
|
|
439
|
+
})
|
|
440
|
+
return 0
|
|
441
|
+
}
|
|
442
|
+
|
|
347
443
|
type CursorChoice =
|
|
348
444
|
| { status: 'ready'; value: typeof DEFAULT_CURSORS; label: string }
|
|
349
445
|
| { status: 'failed'; reason: string }
|
package/src/commands/design.ts
CHANGED
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
import { buildDesignCss } from '@/design/css'
|
|
10
10
|
import { renderDesignDoc } from '@/design/render'
|
|
11
11
|
import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
|
|
12
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
12
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
13
13
|
import { creationRel } from '@/record-root'
|
|
14
14
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
15
15
|
import { resolveTarget } from '@/target'
|
|
16
|
-
import { intro, logAdd, logError, logInfo, outro, palette } from '@/ui'
|
|
16
|
+
import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
|
|
17
17
|
|
|
18
18
|
export function register(program: Command): void {
|
|
19
19
|
const design = program
|
|
@@ -39,13 +39,21 @@ export function register(program: Command): void {
|
|
|
39
39
|
.action(() => {
|
|
40
40
|
const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
|
|
41
41
|
|
|
42
|
+
// The guard below catches a `PROJECT_ROOT` with no record at all, which
|
|
43
|
+
// is an installed package. It cannot catch a second real checkout that
|
|
44
|
+
// has one, so both paths carry the warning. It is a frame-interior line,
|
|
45
|
+
// so each emits it after its own opener rather than ahead of the branch.
|
|
46
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
47
|
+
|
|
42
48
|
// Both outputs resolve from `PROJECT_ROOT`, which is the installed
|
|
43
49
|
// package directory when the CLI runs out of a target's `node_modules`.
|
|
44
50
|
// The record is the one output that is already committed here and ships
|
|
45
51
|
// with no package, so its absence is what separates the two.
|
|
46
52
|
if (!existsSync(join(PROJECT_ROOT, DESIGN_DOCUMENT))) {
|
|
53
|
+
process.stderr.write(`${GREY}┌${NC}\n`)
|
|
54
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
47
55
|
process.stderr.write(
|
|
48
|
-
`${GREY}
|
|
56
|
+
`${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
57
|
)
|
|
50
58
|
process.exitCode = 1
|
|
51
59
|
return
|
|
@@ -54,6 +62,7 @@ export function register(program: Command): void {
|
|
|
54
62
|
process.stderr.write(
|
|
55
63
|
`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Regenerate design source${NC}\n`,
|
|
56
64
|
)
|
|
65
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
57
66
|
const result = regenDesign(PROJECT_ROOT)
|
|
58
67
|
for (const path of [result.documentPath, result.cssPath]) {
|
|
59
68
|
process.stderr.write(
|
package/src/commands/docs.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Command } from 'commander'
|
|
|
2
2
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
3
3
|
import { listTopics, readTopic, resolveTopic } from '@/docs/read'
|
|
4
4
|
import { execScript } from '@/exec'
|
|
5
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
5
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
6
6
|
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
7
7
|
|
|
8
8
|
export function register(program: Command): void {
|
|
@@ -42,6 +42,9 @@ export function register(program: Command): void {
|
|
|
42
42
|
function get(topic: string): number {
|
|
43
43
|
intro('canon docs')
|
|
44
44
|
|
|
45
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
46
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
47
|
+
|
|
45
48
|
const resolved = resolveTopic(PROJECT_ROOT, topic)
|
|
46
49
|
|
|
47
50
|
if (!resolved) {
|
package/src/commands/gov.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type CountsReport,
|
|
9
9
|
scanCounts,
|
|
10
10
|
} from '@/counts/scan'
|
|
11
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
11
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
12
12
|
import { creationRel, SCRATCH } from '@/record-root'
|
|
13
13
|
import { createGovAdapter } from '@/gov/adapter'
|
|
14
14
|
import { regenConsumedRules } from '@/gov/consumed'
|
|
@@ -203,8 +203,11 @@ export function register(program: Command): void {
|
|
|
203
203
|
'',
|
|
204
204
|
].join('\n'),
|
|
205
205
|
)
|
|
206
|
-
.action(async (opts: RegenOptions) => {
|
|
207
|
-
process.exitCode = await runRegen(
|
|
206
|
+
.action(async (opts: RegenOptions, cmd: Command) => {
|
|
207
|
+
process.exitCode = await runRegen(
|
|
208
|
+
opts,
|
|
209
|
+
cmd.getOptionValueSource('root') === 'cli',
|
|
210
|
+
)
|
|
208
211
|
})
|
|
209
212
|
|
|
210
213
|
gov
|
|
@@ -1052,10 +1055,15 @@ function selectedSections(opts: ListOptions): {
|
|
|
1052
1055
|
* untouched by it.
|
|
1053
1056
|
*/
|
|
1054
1057
|
function runList(opts: ListOptions): number {
|
|
1058
|
+
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
1059
|
+
// path that opens one and bare on the `--json` path, which returns first and
|
|
1060
|
+
// opens none. One position cannot serve both.
|
|
1061
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
1055
1062
|
const catalog = buildGovCatalog(PROJECT_ROOT)
|
|
1056
1063
|
const sections = selectedSections(opts)
|
|
1057
1064
|
|
|
1058
1065
|
if (opts.json) {
|
|
1066
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
1059
1067
|
process.stdout.write(
|
|
1060
1068
|
`${JSON.stringify({
|
|
1061
1069
|
...(sections.stacks ? { stacks: catalog.stacks } : {}),
|
|
@@ -1067,6 +1075,7 @@ function runList(opts: ListOptions): number {
|
|
|
1067
1075
|
}
|
|
1068
1076
|
|
|
1069
1077
|
intro('canon gov list')
|
|
1078
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
1070
1079
|
|
|
1071
1080
|
if (sections.stacks) {
|
|
1072
1081
|
logStep('Stacks')
|
|
@@ -1092,7 +1101,18 @@ function runList(opts: ListOptions): number {
|
|
|
1092
1101
|
* the only work that stage does now. The installed set is readable on disk, so
|
|
1093
1102
|
* printing it would only add noise to every `bun run check`.
|
|
1094
1103
|
*/
|
|
1095
|
-
async function runRegen(
|
|
1104
|
+
async function runRegen(
|
|
1105
|
+
opts: RegenOptions,
|
|
1106
|
+
rootPassed: boolean,
|
|
1107
|
+
): Promise<number> {
|
|
1108
|
+
// An operator naming `--root` names their own target on purpose, so only the
|
|
1109
|
+
// default can silently regenerate against a checkout nobody is standing in.
|
|
1110
|
+
// The value alone cannot separate the two, since the flag carries a default.
|
|
1111
|
+
if (!rootPassed) {
|
|
1112
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
1113
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1096
1116
|
const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
|
|
1097
1117
|
|
|
1098
1118
|
if (!result.ok) {
|