@erclx/canon 5.65.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/git-pr/references/labels.md +6 -2
  11. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  12. package/claude/skills/git-pr-close/SKILL.md +55 -0
  13. package/claude/skills/git-pr-close/references/close.md +54 -0
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/plan-intake/references/intake.md +12 -2
  16. package/claude/skills/pr-evidence/SKILL.md +2 -2
  17. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  18. package/claude/skills/review-ui/SKILL.md +5 -4
  19. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  20. package/claude/skills/review-ui/references/driving.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  22. package/claude/skills/role-planner/SKILL.md +2 -2
  23. package/claude/skills/role-worker/SKILL.md +6 -8
  24. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  25. package/claude/skills/session-map/SKILL.md +8 -1
  26. package/claude/skills/session-map/references/session.md +19 -5
  27. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  28. package/claude/skills/ui-record/SKILL.md +103 -0
  29. package/claude/skills/ui-record/references/config.md +40 -0
  30. package/claude/skills/ui-record/references/handoff.md +48 -0
  31. package/claude/skills/ui-record/references/take-craft.md +92 -0
  32. package/docs/agents/records.md +1 -0
  33. package/docs/workflow/parallel-features.md +1 -1
  34. package/docs/workflow/skill-map-on-request.md +1 -0
  35. package/docs/workflow/skill-map.md +2 -0
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/package.json +1 -1
  38. package/src/audits/catalog.ts +5 -1
  39. package/src/browser/agent-cli.ts +68 -0
  40. package/src/claude/cases/git.ts +4 -0
  41. package/src/claude/cases/workflow.ts +20 -0
  42. package/src/cli/help.ts +3 -0
  43. package/src/cli.ts +4 -0
  44. package/src/commands/browser.ts +102 -0
  45. package/src/commands/intake.ts +181 -1
  46. package/src/commands/labels.ts +152 -4
  47. package/src/commands/pr.ts +278 -8
  48. package/src/commands/records.ts +18 -1
  49. package/src/commands/take.ts +266 -0
  50. package/src/context/requirements.ts +1 -11
  51. package/src/git/status.ts +73 -0
  52. package/src/intake/drive-check.ts +355 -0
  53. package/src/intake/folder.ts +1 -1
  54. package/src/labels/apply.ts +10 -5
  55. package/src/labels/sync.ts +17 -0
  56. package/src/markdown/scan.ts +10 -0
  57. package/src/pr/close.ts +152 -0
  58. package/src/pr/drive-copy.ts +97 -84
  59. package/src/pr/health.ts +56 -0
  60. package/src/pr/local.ts +49 -8
  61. package/src/pr/rest.ts +10 -2
  62. package/src/records/session-map.ts +75 -0
  63. package/src/records/validate.ts +64 -8
  64. package/src/sandbox/replay/run.ts +30 -5
  65. package/src/take/camera.ts +309 -0
  66. package/src/take/encode.ts +170 -0
  67. package/src/take/film.ts +206 -0
  68. package/src/take/fixtures/fade.html +49 -0
  69. package/src/take/fixtures/fetch.html +38 -0
  70. package/src/take/log.ts +109 -0
  71. package/src/take/perform.ts +200 -0
  72. package/src/take/plan.ts +193 -0
  73. package/src/take/screencast.ts +94 -0
  74. package/src/take/shell.ts +287 -0
  75. package/src/take/stepper.ts +252 -0
  76. package/src/take/zoom/NOTICE.md +45 -0
  77. package/src/take/zoom/math-utils.ts +69 -0
  78. package/src/take/zoom/region-utils.ts +55 -0
  79. package/src/take/zoom/spring.ts +133 -0
  80. package/src/tasks/archive.ts +1 -3
  81. package/src/tooling/verify-stack.ts +4 -5
  82. package/tooling/base/reference.md +1 -0
  83. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  84. 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
+ }