@erclx/canon 5.66.0 → 5.68.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/CHANGELOG.md +23 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
- package/claude/skills/pr-evidence/SKILL.md +7 -1
- package/claude/skills/pr-evidence/references/evidence.md +33 -3
- package/claude/skills/review-craft/references/ui.md +30 -10
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +4 -6
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +36 -0
- package/claude/skills/ui-record/SKILL.md +114 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +67 -0
- package/claude/skills/ui-record/references/pr-clip.md +84 -0
- package/claude/skills/ui-record/references/take-craft.md +99 -0
- package/docs/agents/records.md +2 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +5 -2
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/pr.ts +252 -85
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +302 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/markdown/scan.ts +10 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/pr/evidence.ts +208 -78
- package/src/pr/frames.ts +42 -4
- package/src/pr/pixels.ts +44 -0
- package/src/records/backup.ts +28 -4
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +207 -0
- package/src/take/film.ts +211 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/pointer.ts +168 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +97 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +255 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/claude/skills/review-ui/scripts/pw.sh +0 -60
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Take craft
|
|
3
|
+
description: The filming rules a take plan follows, the frame-stepping limits, and the pointer and zoom rules the camera relies on, each with its source
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Take craft
|
|
7
|
+
|
|
8
|
+
Read at Step 3 of `ui-record`, before writing the first beat. `canon take --help` carries every step kind, the target forms, and each refusal code, so this file carries the judgment and leaves the vocabulary to the verb.
|
|
9
|
+
|
|
10
|
+
## Contents
|
|
11
|
+
|
|
12
|
+
- [The plan](#the-plan)
|
|
13
|
+
- [One action per take](#one-action-per-take)
|
|
14
|
+
- [Frame-stepping limits](#frame-stepping-limits)
|
|
15
|
+
- [Targets](#targets)
|
|
16
|
+
- [The pointer](#the-pointer)
|
|
17
|
+
- [The zoom](#the-zoom)
|
|
18
|
+
- [Sources](#sources)
|
|
19
|
+
|
|
20
|
+
## The plan
|
|
21
|
+
|
|
22
|
+
A plan holds the approved beats in order. Each beat names its setup, which runs before the first kept frame, and its steps, which are filmed.
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"beats": [
|
|
27
|
+
{
|
|
28
|
+
"name": "Open the kit",
|
|
29
|
+
"setup": [{ "kind": "click", "target": "text=Design" }],
|
|
30
|
+
"steps": [
|
|
31
|
+
{ "kind": "click", "target": "[aria-label='Open 01-kit']" },
|
|
32
|
+
{ "kind": "wait", "ms": 900 }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## One action per take
|
|
40
|
+
|
|
41
|
+
- Film one action per beat, such as one click, one fill, or one scroll, so the edit can cut, reorder, or drop a beat without re-filming its neighbors
|
|
42
|
+
- Keep a take as long as its beat. The verb opens and closes each one on a 0.6 s hold, which gives the edit a still to cut on, so add no hold of your own around the action.
|
|
43
|
+
- Add a `wait` after an action whose result animates, loads, or settles, long enough for the result to finish on screen
|
|
44
|
+
- Reach the beat's start state through `setup`, since every beat opens the page fresh and nothing a setup step does is filmed
|
|
45
|
+
- Write the beat's `name` as the operator approved it, since it names the clip's folder
|
|
46
|
+
|
|
47
|
+
## Frame-stepping limits
|
|
48
|
+
|
|
49
|
+
A step beat runs the page on a virtual clock, one frame per step. Most pages film this way, and some inputs are known to stall it.
|
|
50
|
+
|
|
51
|
+
- Typing films by stepping on a canvas board, though an earlier stepper stalled on it. Keep `fill` text short, and expect a fallback to the screencast for a beat whose frames stall past 2 s.
|
|
52
|
+
- A scroll has not been proven under frame stepping, and a smooth scroll can jump. Give a scroll its own beat so a fallback costs that beat alone.
|
|
53
|
+
- An animated GIF can freeze, so expect one to hold its first frame
|
|
54
|
+
- Set `"route": "screencast"` on a beat known to stall, rather than paying for the stall before the fallback
|
|
55
|
+
|
|
56
|
+
A fallback films the beat again from a fresh load, replaying its setup and the steps the stepper already ran. A beat whose steps persist in the app, such as a fill that renames something, would then start from the changed state, so set `"route": "screencast"` on it when it risks a stall.
|
|
57
|
+
|
|
58
|
+
A screencast beat films in real time at 1920x1080, which goes soft at the 1.8x zoom. Tell the operator which beats ran that way.
|
|
59
|
+
|
|
60
|
+
### Reading a take back
|
|
61
|
+
|
|
62
|
+
Two rules hold once a beat is filmed.
|
|
63
|
+
|
|
64
|
+
- A stepped still is 3840x2160 and opens at 2000 wide. Crop it for text too small to read there, with `ffmpeg -v error -y -i <dir>/still.jpg -vf crop=<w>:<h>:<x>:<y> <dir>/crop.png` on the still's own pixels.
|
|
65
|
+
- Read when something happened from `log.json` and `camera.json`, both on the clip's clock, never from a clock printed in the page, which starts before the first kept frame
|
|
66
|
+
|
|
67
|
+
## Targets
|
|
68
|
+
|
|
69
|
+
- Prefer a stable attribute, such as an `aria-label`, a `data-testid`, or an `id`, over a position like `:nth-child`
|
|
70
|
+
- Use `text=<words>` for a control whose only stable handle is its label
|
|
71
|
+
- Use a `move` to a point only for a target the page reveals on hover, and follow it with the click
|
|
72
|
+
- Read each target off the served page before writing it. The verb refuses `target-missing` rather than filming a guess, and a refusal costs a reload.
|
|
73
|
+
|
|
74
|
+
## The pointer
|
|
75
|
+
|
|
76
|
+
- Draw no pointer in the page. The edit draws one from the log at a constant size, and two pointers on one frame leave the viewer guessing which one acted.
|
|
77
|
+
- Start from a state where the app paints no cursor of its own, since a painted one films into the clip and grows with every zoom
|
|
78
|
+
- Check that each click lands inside its target's box on the closing still. The verb aims at the box's center, and an element covering it takes the click instead.
|
|
79
|
+
|
|
80
|
+
## The zoom
|
|
81
|
+
|
|
82
|
+
The camera track already carries the zoom, so these rules decide what to film for it to aim at.
|
|
83
|
+
|
|
84
|
+
- Earn a zoom with an action. The camera aims only at clicks and typing, and a wait, a move, or a scroll aims nowhere, so a result too small to read needs a click on it.
|
|
85
|
+
- Expect a scroll to end a zoom, since the content moves under the camera
|
|
86
|
+
- Expect a target filling most of the view to get less zoom, and one filling the page to get none
|
|
87
|
+
- The push reaches 90% of its depth in about 633 ms, inside the 1 s ceiling a push onto an action keeps
|
|
88
|
+
|
|
89
|
+
## Sources
|
|
90
|
+
|
|
91
|
+
| Rule | Source |
|
|
92
|
+
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| Zoom on a click | Screen Studio's [auto zoom guide](https://screen.studio/guide/auto-zoom) keys its zoom on click positions |
|
|
94
|
+
| The 1 s ceiling | Material Design 3's [duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs) end at 1000 ms, the top of the ambient band |
|
|
95
|
+
| The 633 ms push | Twelve moves in a Screen Studio clip reached 90% in a median of 667 ms, measured when the camera was tuned |
|
|
96
|
+
| The typing and scroll risk | Takes of a canvas design board filmed while the stepper was built |
|
|
97
|
+
| Jumping scrolls and GIFs | [hasuwini77/demo-reel#28](https://github.com/hasuwini77/demo-reel/pull/28), read through a search summary rather than the page |
|
|
98
|
+
|
|
99
|
+
The pointer rules carry no number. One pointer and a click inside its box follow from the log recording each target's box.
|
package/docs/agents/records.md
CHANGED
|
@@ -28,6 +28,7 @@ printf '.canon/\n' >> .gitignore
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
- `push` commits every top-level folder under `.canon/` except `tmp`, `ordinal-locks`, and `.records.git`, then pushes. New folders list under `added`.
|
|
31
|
+
- `push` backs up a video workspace under `video/` without its media, skipping `takes/`, `renders/`, `snapshots/`, `node_modules/`, and any `.mp4`, `.webm`, or `.mov` file.
|
|
31
32
|
- `pull` restores the folders on a new machine after the setup above. It refuses rather than overwrite records the history lacks.
|
|
32
33
|
- `size` reports one row per folder, heaviest first, with file count, bytes, and growth over 7 and 30 days. After a `pull` every file reads as new.
|
|
33
34
|
- `prune-tmp` lists scratch untouched for 14 days and deletes it only with `--write`. It never offers `tmp/handoff/` or `tmp/pr/poll/`.
|
|
@@ -58,4 +59,5 @@ Branch on `reason` in the `--json` record, not the exit code.
|
|
|
58
59
|
|
|
59
60
|
- `canon records ordinal <kind> <slug> --claim`: reserve the next intake or groundwork ordinal
|
|
60
61
|
- `canon records validate <kind> [record] --help`: where a record disagrees with its standard, plus a `structure` report that never sets the exit
|
|
62
|
+
- `canon records validate sessions [stem]`: each session map's words against its cap, under a `length` report that never sets the exit
|
|
61
63
|
- `canon records stale --help`: when the requirements and design records were last reviewed
|
|
@@ -32,7 +32,7 @@ Assert the orchestrator role in one warm session with `canon:role-orchestrator`.
|
|
|
32
32
|
|
|
33
33
|
- To pick otherwise, rewrite that line as `overridden at execution to <pick>,` plus the measurement, and add one line to the open task's `## Findings`
|
|
34
34
|
|
|
35
|
-
- `canon records validate plans [plan]` reports a plan breaking its standard, plus `structure` misses that never set the exit, and takes `groundwork` and `
|
|
35
|
+
- `canon records validate plans [plan]` reports a plan breaking its standard, plus `structure` misses that never set the exit, and takes `groundwork`, `intake`, and `sessions`
|
|
36
36
|
- `canon records push` and `canon records pull` carry `.canon/` to and from a private repository. See [records](../agents/records.md).
|
|
37
37
|
- `canon records size`, `canon records prune-tmp`, and `canon records stale canonical` report folder size, idle scratch, and releases since the requirements and design records were last read
|
|
38
38
|
- `canon tasks archive` moves a shipped plan to `.canon/plans/archive/` and retargets the task's `Plan:` line
|
|
@@ -19,6 +19,7 @@ These skills serve a request rather than a moment. With the [skill map](skill-ma
|
|
|
19
19
|
| `canon:draw-figure` | A figure or architecture view inside a doc |
|
|
20
20
|
| `canon:ci-workflow` | A GitHub Actions workflow file |
|
|
21
21
|
| `canon:design-identity` | A logo mark and social card |
|
|
22
|
+
| `canon:ui-record` | Real footage of the app for a video |
|
|
22
23
|
|
|
23
24
|
## Answer a question at any point
|
|
24
25
|
|
|
@@ -55,6 +55,7 @@ Groups run in the order a project meets them. Every name `canon claude skills li
|
|
|
55
55
|
| `canon:document-health` | Documents must answer for length and staleness |
|
|
56
56
|
| `canon:ux-audit` | To read UI source for missing states |
|
|
57
57
|
| `canon:ux-measure` | To measure paint, processor, and layout cost |
|
|
58
|
+
| `canon:app-drive` | To drive a built app with the operator |
|
|
58
59
|
|
|
59
60
|
## Ship it
|
|
60
61
|
|
|
@@ -43,8 +43,9 @@ Run `canon drive --help` for the step kinds and the run file shape.
|
|
|
43
43
|
Two reviews run beside each other and finish on their own clocks.
|
|
44
44
|
|
|
45
45
|
- `canon:review-pr` reads the code and the stills in the evidence comment, and posts under `## Review`.
|
|
46
|
-
- `canon:review-ui` never reads the diff. It drives each checklist box in the running app through the pinned `@playwright/cli
|
|
46
|
+
- `canon:review-ui` never reads the diff. It drives each checklist box in the running app through `canon browser`, which runs the pinned `@playwright/cli`, and posts under `## UI review`.
|
|
47
47
|
- Every box gets one verdict, being pass, fail, needs eyes, or not driven.
|
|
48
|
+
- The pass refuses rather than download a browser. Install it once per machine with `canon browser install-browser chromium`.
|
|
48
49
|
|
|
49
50
|
After the boxes, `review-ui` sweeps every route they reached:
|
|
50
51
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.68.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
"axe-core": "4.14.0",
|
|
55
55
|
"commander": "^13.1.0",
|
|
56
56
|
"execa": "^9.5.2",
|
|
57
|
-
"
|
|
57
|
+
"pixelmatch": "8.0.0",
|
|
58
|
+
"playwright-core": "1.64.1",
|
|
59
|
+
"pngjs": "7.0.0"
|
|
58
60
|
},
|
|
59
61
|
"devDependencies": {
|
|
60
62
|
"@astrojs/check": "^0.9.8",
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
67
69
|
"@types/bun": "^1.2.10",
|
|
68
70
|
"@types/micromatch": "^4.0.10",
|
|
71
|
+
"@types/pngjs": "6.0.5",
|
|
69
72
|
"astro": "^6.1.9",
|
|
70
73
|
"husky": "^9.1.7",
|
|
71
74
|
"micromatch": "^4.0.8",
|
package/src/audits/catalog.ts
CHANGED
|
@@ -451,7 +451,11 @@ function testOrderCounts(record: unknown): Record<string, number> | undefined {
|
|
|
451
451
|
})
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
/**
|
|
454
|
+
/**
|
|
455
|
+
* The record kinds whose findings the aggregate counts, and the corpus each
|
|
456
|
+
* reads. `sessions` is left out because it reports length alone and never a
|
|
457
|
+
* finding, so a row for it would always count zero.
|
|
458
|
+
*/
|
|
455
459
|
const RECORD_KINDS: readonly (readonly [string, Corpus])[] = [
|
|
456
460
|
['plans', 'per-machine'],
|
|
457
461
|
['groundwork', 'per-machine'],
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned agent browser CLI every skill driving a page reaches through
|
|
3
|
+
* `canon browser`, so every call in a run and every run on a machine reach one
|
|
4
|
+
* version. `open` is forced onto the bundled Chromium, since the CLI's default
|
|
5
|
+
* channel is a branded Chrome most machines lack, and it refuses rather than
|
|
6
|
+
* downloading when that browser is absent, since a dispatched reviewer that
|
|
7
|
+
* installs mid-pass spends its budget on the download.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const AGENT_CLI = '@playwright/cli@0.1.22'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Chromium revision that CLI version requires. It moves with the pin, and
|
|
14
|
+
* it is separate from the revision `INSTALL_BROWSER` fetches for the engine.
|
|
15
|
+
*/
|
|
16
|
+
export const CHROMIUM_REVISION = '1247'
|
|
17
|
+
|
|
18
|
+
export const INSTALL_AGENT_BROWSER = 'canon browser install-browser chromium'
|
|
19
|
+
|
|
20
|
+
export type AgentCall =
|
|
21
|
+
| { readonly kind: 'run'; readonly args: readonly string[] }
|
|
22
|
+
| { readonly kind: 'refused'; readonly message: string }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rewrites the caller's words into the child's, reading the revision only for
|
|
26
|
+
* `open`, the one verb that launches a browser. Global flags may lead the verb,
|
|
27
|
+
* as in `-s=<name> --raw open <url>`.
|
|
28
|
+
*/
|
|
29
|
+
export function planAgentCall(
|
|
30
|
+
args: readonly string[],
|
|
31
|
+
hasRevision: () => boolean,
|
|
32
|
+
): AgentCall {
|
|
33
|
+
const verbAt = args.findIndex((arg) => !arg.startsWith('-'))
|
|
34
|
+
if (verbAt === -1 || args[verbAt] !== 'open') return { kind: 'run', args }
|
|
35
|
+
|
|
36
|
+
if (!hasRevision()) {
|
|
37
|
+
return {
|
|
38
|
+
kind: 'refused',
|
|
39
|
+
message: `Chromium revision ${CHROMIUM_REVISION} is not installed. Run once, outside the pass: ${INSTALL_AGENT_BROWSER}`,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const namesBrowser = args.some(
|
|
44
|
+
(arg) => arg === '--browser' || arg.startsWith('--browser='),
|
|
45
|
+
)
|
|
46
|
+
if (namesBrowser) return { kind: 'run', args }
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
kind: 'run',
|
|
50
|
+
args: [
|
|
51
|
+
...args.slice(0, verbAt + 1),
|
|
52
|
+
'--browser=chromium',
|
|
53
|
+
...args.slice(verbAt + 1),
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Where Playwright keeps downloaded browsers, by its own lookup order. */
|
|
59
|
+
export function browsersRoot(
|
|
60
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
61
|
+
platform: string,
|
|
62
|
+
home: string,
|
|
63
|
+
): string {
|
|
64
|
+
const override = env.PLAYWRIGHT_BROWSERS_PATH
|
|
65
|
+
if (override !== undefined && override !== '') return override
|
|
66
|
+
if (platform === 'darwin') return `${home}/Library/Caches/ms-playwright`
|
|
67
|
+
return `${home}/.cache/ms-playwright`
|
|
68
|
+
}
|
|
@@ -149,6 +149,26 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
|
|
|
149
149
|
prompt: 'Tell me the paint and layout cost of this page right now.',
|
|
150
150
|
expect: 'ux-measure',
|
|
151
151
|
},
|
|
152
|
+
{
|
|
153
|
+
prompt:
|
|
154
|
+
'We just tagged the release. Spin up a copy of the build and use it with me as its users would, then file whatever breaks.',
|
|
155
|
+
expect: 'app-drive',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
prompt:
|
|
159
|
+
'Before we plan the next wave, put the built app through every goal in the requirements with me and write up what we hit.',
|
|
160
|
+
expect: 'app-drive',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
prompt:
|
|
164
|
+
'I need real footage of the board import flow for the launch video. Film it from our app so HyperFrames can zoom in on the clicks.',
|
|
165
|
+
expect: 'ui-record',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
prompt:
|
|
169
|
+
'Shoot the takes for the storyboard we wrote, one clip per beat of the app doing its thing.',
|
|
170
|
+
expect: 'ui-record',
|
|
171
|
+
},
|
|
152
172
|
{
|
|
153
173
|
prompt:
|
|
154
174
|
'I am building this branch for another session. What am I on the hook for, and what is off limits?',
|
package/src/cli/help.ts
CHANGED
|
@@ -42,6 +42,8 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
42
42
|
['serve [dir]', 'Serve a directory on localhost, print the link'],
|
|
43
43
|
['inventory [subj]', 'Report one computed property across every route'],
|
|
44
44
|
['drive <url> <run>', 'Walk a page through interactions, measure each'],
|
|
45
|
+
['browser [args]', 'Drive the pinned agent browser CLI'],
|
|
46
|
+
['take <url> <plan>', 'Film a web app with a click log and camera track'],
|
|
45
47
|
['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
|
|
46
48
|
],
|
|
47
49
|
},
|
|
@@ -100,6 +102,7 @@ const EXAMPLES = [
|
|
|
100
102
|
'canon capture assets/captures/install.html --selector .window --out assets/frames',
|
|
101
103
|
'canon inventory focus --json',
|
|
102
104
|
'canon drive http://localhost:4173 run.json --json',
|
|
105
|
+
'canon take http://localhost:4173 plan.json --out takes --json',
|
|
103
106
|
'pbpaste | canon feedback',
|
|
104
107
|
'canon transcripts https://youtu.be/VIDEO_ID',
|
|
105
108
|
'canon tasks --help --all',
|
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { register as capture } from '@/commands/capture'
|
|
|
14
14
|
import { register as serve } from '@/commands/serve'
|
|
15
15
|
import { register as inventory } from '@/commands/inventory'
|
|
16
16
|
import { register as driver } from '@/commands/driver'
|
|
17
|
+
import { register as browser } from '@/commands/browser'
|
|
18
|
+
import { register as take } from '@/commands/take'
|
|
17
19
|
import { register as feedback } from '@/commands/feedback'
|
|
18
20
|
import { register as transcripts } from '@/commands/transcripts'
|
|
19
21
|
import { register as tasks } from '@/commands/tasks'
|
|
@@ -78,6 +80,8 @@ capture(program)
|
|
|
78
80
|
serve(program)
|
|
79
81
|
inventory(program)
|
|
80
82
|
driver(program)
|
|
83
|
+
browser(program)
|
|
84
|
+
take(program)
|
|
81
85
|
feedback(program)
|
|
82
86
|
transcripts(program)
|
|
83
87
|
tasks(program)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { constants, homedir } from 'node:os'
|
|
4
|
+
import type { Command } from 'commander'
|
|
5
|
+
import {
|
|
6
|
+
AGENT_CLI,
|
|
7
|
+
CHROMIUM_REVISION,
|
|
8
|
+
INSTALL_AGENT_BROWSER,
|
|
9
|
+
browsersRoot,
|
|
10
|
+
planAgentCall,
|
|
11
|
+
} from '@/browser/agent-cli'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every byte on stdout and stderr belongs to the child, which skills read
|
|
15
|
+
* snapshot paths and `--raw eval` values off, so this verb frames nothing and
|
|
16
|
+
* marks its own refusals with a prefix rather than a record.
|
|
17
|
+
*/
|
|
18
|
+
const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
|
|
19
|
+
|
|
20
|
+
export function register(program: Command): void {
|
|
21
|
+
program
|
|
22
|
+
.command('browser')
|
|
23
|
+
.description('Drive the pinned agent browser CLI')
|
|
24
|
+
.argument('[args...]', 'Words passed to the browser CLI untouched')
|
|
25
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
26
|
+
.allowUnknownOption()
|
|
27
|
+
.allowExcessArguments(true)
|
|
28
|
+
.passThroughOptions()
|
|
29
|
+
.addHelpText(
|
|
30
|
+
'after',
|
|
31
|
+
[
|
|
32
|
+
'',
|
|
33
|
+
`Runs ${AGENT_CLI} through bunx in the current directory, so every`,
|
|
34
|
+
'skill driving a page reaches one version. Snapshots land under',
|
|
35
|
+
'.playwright-cli/ in the folder it runs in.',
|
|
36
|
+
'',
|
|
37
|
+
'open is forced onto the bundled Chromium unless the call names a',
|
|
38
|
+
`--browser, and refuses rather than downloading when revision ${CHROMIUM_REVISION}`,
|
|
39
|
+
'is absent. Install it once, outside any pass, with:',
|
|
40
|
+
` ${INSTALL_AGENT_BROWSER}`,
|
|
41
|
+
'',
|
|
42
|
+
"Stdout, stderr, and the exit code are the browser CLI's. A refusal of",
|
|
43
|
+
"this verb's own reads 'canon browser:' on stderr.",
|
|
44
|
+
'',
|
|
45
|
+
'Exit codes:',
|
|
46
|
+
" n the browser CLI's own exit code",
|
|
47
|
+
' 1 refused, with the reason on stderr',
|
|
48
|
+
'',
|
|
49
|
+
'Examples:',
|
|
50
|
+
' canon browser --version',
|
|
51
|
+
' canon browser -s=canon-12 open http://localhost:4321',
|
|
52
|
+
' canon browser -s=canon-12 --raw eval "document.title"',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n'),
|
|
55
|
+
)
|
|
56
|
+
.action(async (args: string[]) => {
|
|
57
|
+
process.exitCode = await runBrowser(args)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runBrowser(args: readonly string[]): Promise<number> {
|
|
62
|
+
if (Bun.which('bunx') === null) {
|
|
63
|
+
return refuse('bunx is not on PATH, so the pinned CLI cannot run')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const root = browsersRoot(process.env, process.platform, homedir())
|
|
67
|
+
const call = planAgentCall(args, () =>
|
|
68
|
+
existsSync(`${root}/chromium-${CHROMIUM_REVISION}`),
|
|
69
|
+
)
|
|
70
|
+
if (call.kind === 'refused') return refuse(call.message)
|
|
71
|
+
|
|
72
|
+
const child = spawn('bunx', ['-y', AGENT_CLI, ...call.args], {
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
})
|
|
75
|
+
// A killed pass must not leave a browser running behind the parent, which a
|
|
76
|
+
// shell `exec` never had between the caller and the child.
|
|
77
|
+
const forward = (signal: NodeJS.Signals) => () => child.kill(signal)
|
|
78
|
+
const handlers = FORWARDED_SIGNALS.map((signal) => {
|
|
79
|
+
const handler = forward(signal)
|
|
80
|
+
process.on(signal, handler)
|
|
81
|
+
return [signal, handler] as const
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
return await new Promise<number>((resolve) => {
|
|
86
|
+
child.on('error', (error) => {
|
|
87
|
+
process.stderr.write(`canon browser: ${error.message}\n`)
|
|
88
|
+
resolve(1)
|
|
89
|
+
})
|
|
90
|
+
child.on('exit', (code, signal) => {
|
|
91
|
+
resolve(code ?? 128 + (signal ? constants.signals[signal] : 0))
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
} finally {
|
|
95
|
+
for (const [signal, handler] of handlers) process.off(signal, handler)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function refuse(message: string): number {
|
|
100
|
+
process.stderr.write(`canon browser: ${message}\n`)
|
|
101
|
+
return 1
|
|
102
|
+
}
|
package/src/commands/intake.ts
CHANGED
|
@@ -10,6 +10,14 @@ import {
|
|
|
10
10
|
readFolder,
|
|
11
11
|
type Selection,
|
|
12
12
|
} from '@/intake/folder'
|
|
13
|
+
import {
|
|
14
|
+
type BaselineOutcome,
|
|
15
|
+
type CheckOutcome,
|
|
16
|
+
checkDrive,
|
|
17
|
+
type DriveFindings,
|
|
18
|
+
type DriveRefused,
|
|
19
|
+
recordBaseline,
|
|
20
|
+
} from '@/intake/drive-check'
|
|
13
21
|
import { isUnread } from '@/intake/items'
|
|
14
22
|
import {
|
|
15
23
|
intro,
|
|
@@ -37,6 +45,12 @@ interface ListCommandOptions {
|
|
|
37
45
|
readonly root?: string
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
interface DriveCheckCommandOptions {
|
|
49
|
+
readonly baseline?: boolean
|
|
50
|
+
readonly json?: boolean
|
|
51
|
+
readonly root?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
interface AnswerCommandOptions {
|
|
41
55
|
readonly cluster?: string
|
|
42
56
|
readonly json?: boolean
|
|
@@ -143,6 +157,67 @@ export function register(program: Command): void {
|
|
|
143
157
|
.action(async (slug: string, opts: AnswerCommandOptions) => {
|
|
144
158
|
process.exitCode = await runAnswer(slug, opts)
|
|
145
159
|
})
|
|
160
|
+
|
|
161
|
+
intake
|
|
162
|
+
.command('drive-check')
|
|
163
|
+
.description(
|
|
164
|
+
'Check an app drive against its goals, the checkout, and its evidence',
|
|
165
|
+
)
|
|
166
|
+
.argument('<slug>', 'Intake folder name, as in app-drive-1-0')
|
|
167
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
168
|
+
.option(
|
|
169
|
+
'--baseline',
|
|
170
|
+
'Record the main checkout status before the drive, rather than check',
|
|
171
|
+
)
|
|
172
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
173
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
174
|
+
.addHelpText(
|
|
175
|
+
'after',
|
|
176
|
+
[
|
|
177
|
+
'',
|
|
178
|
+
'With --baseline it stores the main checkout status at',
|
|
179
|
+
'.canon/tmp/app-drive/<slug>/baseline.json, any ordinal dropped from',
|
|
180
|
+
'the slug, so it runs before the intake folder exists. Without it, it',
|
|
181
|
+
'reads drive.json in the intake folder and fails the drive on:',
|
|
182
|
+
' unmapped a ## Goals bullet the record carries no entry for',
|
|
183
|
+
' unreasoned a goal marked not-driven with no reason',
|
|
184
|
+
' unknown an entry naming no goal the requirements hold',
|
|
185
|
+
' changed a checkout path that differs from the baseline',
|
|
186
|
+
' strayEvidence an evidence path outside the folder evidence/',
|
|
187
|
+
' missingEvidence an evidence path that holds no file',
|
|
188
|
+
' worktreeLeft the drive worktree the record names, still on disk',
|
|
189
|
+
'',
|
|
190
|
+
'A goal matches by its text with any bold label and trailing period',
|
|
191
|
+
'dropped. A changed path is named and never reverted.',
|
|
192
|
+
'',
|
|
193
|
+
'Read `reason` off the record: `recorded` for a baseline, `clean` or',
|
|
194
|
+
'`failed` for a check, and a refusal otherwise.',
|
|
195
|
+
'',
|
|
196
|
+
'Refusals, read off `reason`:',
|
|
197
|
+
' no-intake the project holds no intake folder',
|
|
198
|
+
' no-folder no folder matches the slug',
|
|
199
|
+
' ambiguous-slug a bare slug matches more than one folder',
|
|
200
|
+
' bad-input the slug could leave the scratch folder',
|
|
201
|
+
' no-record the folder carries no drive.json',
|
|
202
|
+
' bad-record drive.json does not parse or breaks its shape',
|
|
203
|
+
' no-requirements the project carries no requirements record',
|
|
204
|
+
' no-baseline no baseline was recorded for the slug',
|
|
205
|
+
' unreadable-tree git cannot read the main checkout status',
|
|
206
|
+
'',
|
|
207
|
+
'Exit codes:',
|
|
208
|
+
' 0 the baseline was recorded, or the drive is clean',
|
|
209
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
210
|
+
' 2 the drive failed, with every finding in the record',
|
|
211
|
+
'',
|
|
212
|
+
'Examples:',
|
|
213
|
+
' canon intake drive-check app-drive-1-0 --baseline --json',
|
|
214
|
+
' canon intake drive-check app-drive-1-0 --json',
|
|
215
|
+
'',
|
|
216
|
+
].join('\n'),
|
|
217
|
+
)
|
|
218
|
+
.action(async (slug: string, opts: DriveCheckCommandOptions) => {
|
|
219
|
+
process.exitCode = await runDriveCheck(slug, opts)
|
|
220
|
+
})
|
|
146
221
|
}
|
|
147
222
|
|
|
148
223
|
function collectSelection(value: string, previous: string[]): string[] {
|
|
@@ -262,9 +337,23 @@ async function runAnswer(
|
|
|
262
337
|
return reportAnswer(outcome, emitJson, root)
|
|
263
338
|
}
|
|
264
339
|
|
|
340
|
+
async function runDriveCheck(
|
|
341
|
+
slug: string,
|
|
342
|
+
opts: DriveCheckCommandOptions,
|
|
343
|
+
): Promise<number> {
|
|
344
|
+
const emitJson = opts.json ?? false
|
|
345
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
346
|
+
|
|
347
|
+
if (opts.baseline) {
|
|
348
|
+
return reportBaseline(await recordBaseline(root, slug), emitJson, root)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return reportDriveCheck(await checkDrive(root, slug), emitJson, root)
|
|
352
|
+
}
|
|
353
|
+
|
|
265
354
|
function reportRefusal(
|
|
266
355
|
title: string,
|
|
267
|
-
refused: IntakeRefused,
|
|
356
|
+
refused: IntakeRefused | DriveRefused,
|
|
268
357
|
emitJson: boolean,
|
|
269
358
|
root: string,
|
|
270
359
|
): number {
|
|
@@ -421,3 +510,94 @@ function reportAnswer(
|
|
|
421
510
|
|
|
422
511
|
return 0
|
|
423
512
|
}
|
|
513
|
+
|
|
514
|
+
function reportBaseline(
|
|
515
|
+
outcome: BaselineOutcome,
|
|
516
|
+
emitJson: boolean,
|
|
517
|
+
root: string,
|
|
518
|
+
): number {
|
|
519
|
+
if (!outcome.ok) {
|
|
520
|
+
return reportRefusal('canon intake drive-check', outcome, emitJson, root)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const path = relative(root, outcome.path)
|
|
524
|
+
|
|
525
|
+
if (emitJson) {
|
|
526
|
+
process.stdout.write(
|
|
527
|
+
`${JSON.stringify({
|
|
528
|
+
ok: true,
|
|
529
|
+
root,
|
|
530
|
+
slug: outcome.slug,
|
|
531
|
+
reason: 'recorded',
|
|
532
|
+
baseline: path,
|
|
533
|
+
entries: outcome.entries,
|
|
534
|
+
})}\n`,
|
|
535
|
+
)
|
|
536
|
+
return 0
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
intro('canon intake drive-check')
|
|
540
|
+
logStep('Baseline')
|
|
541
|
+
logInfo(`Recorded ${outcome.entries} status line(s).`)
|
|
542
|
+
logAdd(path)
|
|
543
|
+
outro()
|
|
544
|
+
|
|
545
|
+
return 0
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const FINDING_LABELS: Record<keyof DriveFindings, string> = {
|
|
549
|
+
unmapped: 'Goals with no entry',
|
|
550
|
+
unreasoned: 'Goals not driven, with no reason',
|
|
551
|
+
unknown: 'Entries naming no goal',
|
|
552
|
+
changed: 'Changed in the checkout',
|
|
553
|
+
strayEvidence: 'Evidence outside evidence/',
|
|
554
|
+
missingEvidence: 'Evidence holding no file',
|
|
555
|
+
worktreeLeft: 'Drive worktree still on disk',
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function reportDriveCheck(
|
|
559
|
+
outcome: CheckOutcome,
|
|
560
|
+
emitJson: boolean,
|
|
561
|
+
root: string,
|
|
562
|
+
): number {
|
|
563
|
+
if (!outcome.ok) {
|
|
564
|
+
return reportRefusal('canon intake drive-check', outcome, emitJson, root)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const code = outcome.reason === 'clean' ? 0 : 2
|
|
568
|
+
|
|
569
|
+
if (emitJson) {
|
|
570
|
+
process.stdout.write(
|
|
571
|
+
`${JSON.stringify({
|
|
572
|
+
ok: true,
|
|
573
|
+
root,
|
|
574
|
+
slug: outcome.slug,
|
|
575
|
+
reason: outcome.reason,
|
|
576
|
+
goals: outcome.goals,
|
|
577
|
+
findings: outcome.findings,
|
|
578
|
+
})}\n`,
|
|
579
|
+
)
|
|
580
|
+
return code
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
intro('canon intake drive-check')
|
|
584
|
+
|
|
585
|
+
if (outcome.reason === 'clean') {
|
|
586
|
+
logStep('Clean')
|
|
587
|
+
logInfo(
|
|
588
|
+
`${outcome.goals} goal(s) mapped, the checkout untouched, the evidence in place.`,
|
|
589
|
+
)
|
|
590
|
+
outro()
|
|
591
|
+
return code
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
for (const [key, label] of Object.entries(FINDING_LABELS)) {
|
|
595
|
+
const list = outcome.findings[key as keyof DriveFindings]
|
|
596
|
+
if (list.length === 0) continue
|
|
597
|
+
logStep(label)
|
|
598
|
+
for (const entry of list) logError(entry)
|
|
599
|
+
}
|
|
600
|
+
outro()
|
|
601
|
+
|
|
602
|
+
return code
|
|
603
|
+
}
|