@erclx/canon 5.66.0 → 5.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/CHANGELOG.md +11 -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/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 +34 -0
- package/claude/skills/ui-record/SKILL.md +103 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +48 -0
- package/claude/skills/ui-record/references/take-craft.md +92 -0
- package/docs/agents/records.md +1 -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 +1 -1
- 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 +15 -6
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +266 -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/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 +170 -0
- package/src/take/film.ts +206 -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/screencast.ts +94 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +252 -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,266 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { Command } from 'commander'
|
|
4
|
+
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
5
|
+
import { readTakePlan } from '@/take/plan'
|
|
6
|
+
import type { TakePlanRefusal } from '@/take/plan'
|
|
7
|
+
import type { TakeRefusal } from '@/take/film'
|
|
8
|
+
import {
|
|
9
|
+
intro,
|
|
10
|
+
logError,
|
|
11
|
+
logInfo,
|
|
12
|
+
logStep,
|
|
13
|
+
logWarn,
|
|
14
|
+
outro,
|
|
15
|
+
plural,
|
|
16
|
+
} from '@/cli/ui'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Holds wiring only. Every browser reference sits behind `loadTake`, because
|
|
20
|
+
* `src/cli.ts` imports this module at startup and resolving the engine there
|
|
21
|
+
* would put a browser launch in front of every other command.
|
|
22
|
+
*/
|
|
23
|
+
type Take = typeof import('@/take/film')
|
|
24
|
+
|
|
25
|
+
interface RunOptions {
|
|
26
|
+
readonly out: string
|
|
27
|
+
readonly json?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const INSTALL_FFMPEG =
|
|
31
|
+
'install FFmpeg from your package manager, such as apt install ffmpeg or brew install ffmpeg'
|
|
32
|
+
|
|
33
|
+
/** What a reader does about each way the plan produced no take. */
|
|
34
|
+
const PLAN_REFUSALS: Record<TakePlanRefusal, string> = {
|
|
35
|
+
'unreadable-plan':
|
|
36
|
+
'The plan file is not valid JSON, so no beat could be read.',
|
|
37
|
+
'no-beats': 'The plan names no beat, so there is nothing to film.',
|
|
38
|
+
'bad-beat':
|
|
39
|
+
'A beat is missing its name or carries a setup that is not a list.',
|
|
40
|
+
'no-steps': 'A beat names no step, so its take would film nothing.',
|
|
41
|
+
'unknown-route': 'A beat names a route this build does not record on.',
|
|
42
|
+
'bad-step': 'A step is missing something the take needs to perform it.',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TAKE_REFUSALS: Record<TakeRefusal, string> = {
|
|
46
|
+
'ffmpeg-missing': 'FFmpeg is not installed, so no clip could be encoded.',
|
|
47
|
+
'browser-missing': 'The headless shell is not installed in this project.',
|
|
48
|
+
'server-unreachable': 'Nothing answered at the URL, so no beat was filmed.',
|
|
49
|
+
'target-missing': 'A step names a target the page does not carry.',
|
|
50
|
+
'take-failed': 'The take failed against a reachable page.',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function register(program: Command): void {
|
|
54
|
+
program
|
|
55
|
+
.command('take')
|
|
56
|
+
.description(
|
|
57
|
+
'Film a web app beat by beat with a click log and camera track',
|
|
58
|
+
)
|
|
59
|
+
.argument('<url>', 'Address to film')
|
|
60
|
+
.argument('<plan>', 'JSON file naming the beats and their steps')
|
|
61
|
+
.requiredOption('--out <dir>', 'Folder the clips, logs, and tracks land in')
|
|
62
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
63
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
64
|
+
.addHelpText(
|
|
65
|
+
'after',
|
|
66
|
+
[
|
|
67
|
+
'',
|
|
68
|
+
'Films each beat from a fresh load of the page into its own folder under',
|
|
69
|
+
'--out, numbered in plan order: clip.mp4, log.json, and camera.json.',
|
|
70
|
+
'take.json beside them lists every beat and the route that filmed it.',
|
|
71
|
+
'',
|
|
72
|
+
'A step beat is filmed by frame stepping in chrome-headless-shell, at',
|
|
73
|
+
'3840x2160 and exactly 60 fps, with the page clock advancing one frame',
|
|
74
|
+
'per step. A frame slower than 2 s falls back to a real-time screencast',
|
|
75
|
+
'at 1920x1080 for that beat, and the record says so. A screencast beat',
|
|
76
|
+
'skips the stepper.',
|
|
77
|
+
'',
|
|
78
|
+
'No cursor is drawn in a clip. The edit draws it from the log at a',
|
|
79
|
+
'constant size, and the camera track carries the cursor and click ring',
|
|
80
|
+
'per frame beside the view. Canon renders nothing past the clip.',
|
|
81
|
+
'',
|
|
82
|
+
' {',
|
|
83
|
+
' "beats": [',
|
|
84
|
+
' { "name": "open the kit",',
|
|
85
|
+
' "setup": [{ "kind": "click", "target": "text=Design" }],',
|
|
86
|
+
' "steps": [',
|
|
87
|
+
' { "kind": "click", "target": "#kit" },',
|
|
88
|
+
' { "kind": "wait", "ms": 400 }',
|
|
89
|
+
' ] }',
|
|
90
|
+
' ]',
|
|
91
|
+
' }',
|
|
92
|
+
'',
|
|
93
|
+
'Step kinds:',
|
|
94
|
+
' move <target>|<x,y> glide the pointer to a target or a point',
|
|
95
|
+
' click <target> glide to the target and press it',
|
|
96
|
+
' fill <target> <text> press the target and type the text',
|
|
97
|
+
' scroll <target> <dy> glide to the target and wheel dy pixels',
|
|
98
|
+
' wait <ms> hold, for a state the page reaches on its own',
|
|
99
|
+
'',
|
|
100
|
+
'A target is a CSS selector, or text=<words> for the innermost element',
|
|
101
|
+
'whose whole text is those words. Setup steps run before the first kept',
|
|
102
|
+
'frame. Every beat opens and closes on a 0.6 s hold.',
|
|
103
|
+
'',
|
|
104
|
+
'Needs a reachable page, FFmpeg, and the headless shell. Install with:',
|
|
105
|
+
` ${INSTALL_BROWSER}`,
|
|
106
|
+
'',
|
|
107
|
+
'Reasons:',
|
|
108
|
+
' no-plan-file nothing readable at the plan path',
|
|
109
|
+
' unreadable-plan the plan file is not valid JSON',
|
|
110
|
+
' no-beats the plan names no beat',
|
|
111
|
+
' bad-beat a beat has no name or a setup that is not a list',
|
|
112
|
+
' no-steps a beat names no step',
|
|
113
|
+
' unknown-route a beat names a route other than step or screencast',
|
|
114
|
+
' bad-step a step is missing what the take needs to perform it',
|
|
115
|
+
' engine-missing the browser engine is not installed in this project',
|
|
116
|
+
' ffmpeg-missing FFmpeg is not on the PATH',
|
|
117
|
+
' browser-missing the headless shell or browser binary is not installed',
|
|
118
|
+
' server-unreachable nothing answered at the URL',
|
|
119
|
+
' target-missing a step names a target the page does not carry',
|
|
120
|
+
' take-failed the take failed against a reachable page',
|
|
121
|
+
'',
|
|
122
|
+
'Exit codes:',
|
|
123
|
+
' 0 every beat was filmed',
|
|
124
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
125
|
+
'',
|
|
126
|
+
'Examples:',
|
|
127
|
+
' canon take http://localhost:4173 plan.json --out .canon/tmp/demo/takes',
|
|
128
|
+
' canon take http://localhost:4173 plan.json --out takes --json',
|
|
129
|
+
'',
|
|
130
|
+
].join('\n'),
|
|
131
|
+
)
|
|
132
|
+
.action(async (url: string, plan: string, opts: RunOptions) => {
|
|
133
|
+
process.exitCode = await runTake(url, plan, opts)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runTake(
|
|
138
|
+
url: string,
|
|
139
|
+
planPath: string,
|
|
140
|
+
opts: RunOptions,
|
|
141
|
+
): Promise<number> {
|
|
142
|
+
const emitJson = opts.json ?? false
|
|
143
|
+
const path = resolve(planPath)
|
|
144
|
+
|
|
145
|
+
intro(`canon take ${url}`)
|
|
146
|
+
|
|
147
|
+
let source: string
|
|
148
|
+
try {
|
|
149
|
+
source = readFileSync(path, 'utf8')
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return refuse(
|
|
152
|
+
emitJson,
|
|
153
|
+
url,
|
|
154
|
+
'no-plan-file',
|
|
155
|
+
`No plan file at ${path}. ${error instanceof Error ? error.message : String(error)}`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const read = readTakePlan(source)
|
|
160
|
+
if (read.kind === 'refused') {
|
|
161
|
+
return refuse(
|
|
162
|
+
emitJson,
|
|
163
|
+
url,
|
|
164
|
+
read.reason,
|
|
165
|
+
`${PLAN_REFUSALS[read.reason]} ${read.detail}`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { plan } = read
|
|
170
|
+
logStep('Scope')
|
|
171
|
+
logInfo(
|
|
172
|
+
`${plural(plan.beats.length, 'beat')} into ${resolve(opts.out)}: ${plan.beats.map((beat) => beat.name).join(', ')}`,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const taker = await loadTake()
|
|
176
|
+
if (!taker) {
|
|
177
|
+
logStep('Browser')
|
|
178
|
+
logError('the browser engine is not installed in this project')
|
|
179
|
+
logWarn(`Install it with: ${INSTALL_BROWSER}`)
|
|
180
|
+
outro()
|
|
181
|
+
emit(emitJson, {
|
|
182
|
+
url,
|
|
183
|
+
plan: path,
|
|
184
|
+
reason: 'engine-missing',
|
|
185
|
+
install: INSTALL_BROWSER,
|
|
186
|
+
})
|
|
187
|
+
return 1
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
logStep('Filming')
|
|
191
|
+
const result = await taker.take({ url, plan, out: opts.out })
|
|
192
|
+
|
|
193
|
+
if (result.status === 'failed') {
|
|
194
|
+
logStep('Refused')
|
|
195
|
+
logWarn(TAKE_REFUSALS[result.reason])
|
|
196
|
+
if (result.beat) logWarn(`in beat ${result.beat}`)
|
|
197
|
+
if (result.reason === 'server-unreachable') {
|
|
198
|
+
logWarn(`Start whatever serves ${url}, then run this again.`)
|
|
199
|
+
}
|
|
200
|
+
if (result.reason === 'browser-missing') {
|
|
201
|
+
logWarn(`Install the headless shell with: ${INSTALL_BROWSER}`)
|
|
202
|
+
}
|
|
203
|
+
if (result.reason === 'ffmpeg-missing') logWarn(`Then ${INSTALL_FFMPEG}.`)
|
|
204
|
+
logWarn(result.message.split('\n')[0] ?? '')
|
|
205
|
+
outro()
|
|
206
|
+
emit(emitJson, {
|
|
207
|
+
url,
|
|
208
|
+
plan: path,
|
|
209
|
+
reason: result.reason,
|
|
210
|
+
message: result.message,
|
|
211
|
+
...(result.beat ? { beat: result.beat } : {}),
|
|
212
|
+
...(result.reason === 'browser-missing'
|
|
213
|
+
? { install: INSTALL_BROWSER }
|
|
214
|
+
: {}),
|
|
215
|
+
})
|
|
216
|
+
return 1
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const beat of result.beats) {
|
|
220
|
+
logInfo(
|
|
221
|
+
`${beat.dir} ${beat.route} ${plural(beat.frames, 'frame')} ${beat.seconds} s`,
|
|
222
|
+
)
|
|
223
|
+
if (beat.fallback) logWarn(` filmed by screencast: ${beat.fallback}`)
|
|
224
|
+
}
|
|
225
|
+
outro()
|
|
226
|
+
|
|
227
|
+
emit(emitJson, {
|
|
228
|
+
url,
|
|
229
|
+
plan: path,
|
|
230
|
+
out: result.out,
|
|
231
|
+
beats: result.beats,
|
|
232
|
+
durationMs: result.durationMs,
|
|
233
|
+
})
|
|
234
|
+
return 0
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Frames a refusal on stderr and puts the record on stdout, so an operator
|
|
239
|
+
* reading the terminal sees the reason rather than a command that appeared to
|
|
240
|
+
* do nothing.
|
|
241
|
+
*/
|
|
242
|
+
function refuse(
|
|
243
|
+
emitJson: boolean,
|
|
244
|
+
url: string,
|
|
245
|
+
reason: string,
|
|
246
|
+
message: string,
|
|
247
|
+
): number {
|
|
248
|
+
logStep('Refused')
|
|
249
|
+
logWarn(message)
|
|
250
|
+
outro()
|
|
251
|
+
emit(emitJson, { url, reason, message })
|
|
252
|
+
return 1
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function loadTake(): Promise<Take | undefined> {
|
|
256
|
+
try {
|
|
257
|
+
return await import('@/take/film')
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (isEngineMissing(error)) return undefined
|
|
260
|
+
throw error
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function emit(json: boolean, record: unknown): void {
|
|
265
|
+
if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
266
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
|
-
import { bodyLines } from '@/markdown/scan'
|
|
3
|
+
import { bodyLines, wordCount } from '@/markdown/scan'
|
|
4
4
|
import { surfaceDir } from '@/roots/surface'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -11,16 +11,6 @@ export function requirementsRel(root: string): string {
|
|
|
11
11
|
return relative(root, surfaceDir(root, 'REQUIREMENTS.md'))
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* Counts whitespace-delimited tokens, the unit the record's word cap is stated
|
|
16
|
-
* in. A code span counts as the tokens it holds, so a line citing a long path
|
|
17
|
-
* pays for it the way a reader does. A caller gating on the count hands in
|
|
18
|
-
* prose with fenced lines already dropped.
|
|
19
|
-
*/
|
|
20
|
-
export function wordCount(text: string): number {
|
|
21
|
-
return text.match(/\S+/g)?.length ?? 0
|
|
22
|
-
}
|
|
23
|
-
|
|
24
14
|
export interface RequirementsReport {
|
|
25
15
|
readonly rel: string
|
|
26
16
|
/** The whole record's word count, frontmatter left out. */
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { $ } from 'bun'
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
6
|
+
|
|
7
|
+
export interface StatusEntry {
|
|
8
|
+
readonly path: string
|
|
9
|
+
readonly status: string
|
|
10
|
+
/** The content digest, or null where no regular file stands at the path. */
|
|
11
|
+
readonly digest: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function digestOf(path: string): string | null {
|
|
15
|
+
try {
|
|
16
|
+
if (!statSync(path).isFile()) return null
|
|
17
|
+
return createHash('sha1').update(readFileSync(path)).digest('hex')
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every entry `git status` reports, untracked files listed one by one so a
|
|
25
|
+
* file a drive adds inside a tracked folder names itself, each with a content
|
|
26
|
+
* digest so a file already dirty at the baseline still reads as changed when
|
|
27
|
+
* a drive writes it again. Undefined when git cannot read the tree.
|
|
28
|
+
*/
|
|
29
|
+
export async function readWorktreeStatus(
|
|
30
|
+
worktree: string,
|
|
31
|
+
): Promise<readonly StatusEntry[] | undefined> {
|
|
32
|
+
const result =
|
|
33
|
+
await $`git -C ${worktree} status --porcelain=v1 -z --untracked-files=all`
|
|
34
|
+
.env(gitEnv())
|
|
35
|
+
.quiet()
|
|
36
|
+
.nothrow()
|
|
37
|
+
if (result.exitCode !== 0) return undefined
|
|
38
|
+
|
|
39
|
+
const tokens = result.stdout.toString().split('\0')
|
|
40
|
+
const entries: StatusEntry[] = []
|
|
41
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
42
|
+
const token = tokens[index] ?? ''
|
|
43
|
+
if (token.length < 4) continue
|
|
44
|
+
const status = token.slice(0, 2)
|
|
45
|
+
const path = token.slice(3)
|
|
46
|
+
// A rename or copy carries its source as the next token.
|
|
47
|
+
if (status.includes('R') || status.includes('C')) index++
|
|
48
|
+
entries.push({ path, status, digest: digestOf(join(worktree, path)) })
|
|
49
|
+
}
|
|
50
|
+
return entries
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The paths whose status or content differs from the baseline, in the order
|
|
55
|
+
* git reports them. An entry gone since the baseline is not reported, since a
|
|
56
|
+
* worker committing its own edit mid-pass clears it the same way.
|
|
57
|
+
*/
|
|
58
|
+
export function changedSince(
|
|
59
|
+
baseline: readonly StatusEntry[],
|
|
60
|
+
current: readonly StatusEntry[],
|
|
61
|
+
): readonly string[] {
|
|
62
|
+
const before = new Map(baseline.map((entry) => [entry.path, entry]))
|
|
63
|
+
return current
|
|
64
|
+
.filter((entry) => {
|
|
65
|
+
const prior = before.get(entry.path)
|
|
66
|
+
return (
|
|
67
|
+
prior === undefined ||
|
|
68
|
+
prior.status !== entry.status ||
|
|
69
|
+
prior.digest !== entry.digest
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
.map((entry) => entry.path)
|
|
73
|
+
}
|