@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -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/plan-feature/references/plan.md +1 -1
  11. package/claude/skills/plan-intake/references/intake.md +12 -2
  12. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
  13. package/claude/skills/pr-evidence/SKILL.md +7 -1
  14. package/claude/skills/pr-evidence/references/evidence.md +33 -3
  15. package/claude/skills/review-craft/references/ui.md +30 -10
  16. package/claude/skills/review-ui/SKILL.md +5 -4
  17. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  18. package/claude/skills/review-ui/references/driving.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  20. package/claude/skills/role-planner/SKILL.md +2 -2
  21. package/claude/skills/role-worker/SKILL.md +4 -6
  22. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  23. package/claude/skills/session-map/SKILL.md +8 -1
  24. package/claude/skills/session-map/references/session.md +19 -5
  25. package/claude/skills/ui-record/REQUIREMENT.md +36 -0
  26. package/claude/skills/ui-record/SKILL.md +114 -0
  27. package/claude/skills/ui-record/references/config.md +40 -0
  28. package/claude/skills/ui-record/references/handoff.md +67 -0
  29. package/claude/skills/ui-record/references/pr-clip.md +84 -0
  30. package/claude/skills/ui-record/references/take-craft.md +99 -0
  31. package/docs/agents/records.md +2 -0
  32. package/docs/workflow/parallel-features.md +1 -1
  33. package/docs/workflow/skill-map-on-request.md +1 -0
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/docs/workflow/visual-design-workflow.md +2 -1
  36. package/package.json +5 -2
  37. package/src/audits/catalog.ts +5 -1
  38. package/src/browser/agent-cli.ts +68 -0
  39. package/src/claude/cases/workflow.ts +20 -0
  40. package/src/cli/help.ts +3 -0
  41. package/src/cli.ts +4 -0
  42. package/src/commands/browser.ts +102 -0
  43. package/src/commands/intake.ts +181 -1
  44. package/src/commands/pr.ts +252 -85
  45. package/src/commands/records.ts +18 -1
  46. package/src/commands/take.ts +302 -0
  47. package/src/context/requirements.ts +1 -11
  48. package/src/git/status.ts +73 -0
  49. package/src/intake/drive-check.ts +355 -0
  50. package/src/intake/folder.ts +1 -1
  51. package/src/markdown/scan.ts +10 -0
  52. package/src/pr/drive-copy.ts +97 -84
  53. package/src/pr/evidence.ts +208 -78
  54. package/src/pr/frames.ts +42 -4
  55. package/src/pr/pixels.ts +44 -0
  56. package/src/records/backup.ts +28 -4
  57. package/src/records/session-map.ts +75 -0
  58. package/src/records/validate.ts +64 -8
  59. package/src/take/camera.ts +309 -0
  60. package/src/take/encode.ts +207 -0
  61. package/src/take/film.ts +211 -0
  62. package/src/take/fixtures/fade.html +49 -0
  63. package/src/take/fixtures/fetch.html +38 -0
  64. package/src/take/log.ts +109 -0
  65. package/src/take/perform.ts +200 -0
  66. package/src/take/plan.ts +193 -0
  67. package/src/take/pointer.ts +168 -0
  68. package/src/take/reel.ts +215 -0
  69. package/src/take/screencast.ts +97 -0
  70. package/src/take/shell.ts +287 -0
  71. package/src/take/stepper.ts +255 -0
  72. package/src/take/zoom/NOTICE.md +45 -0
  73. package/src/take/zoom/math-utils.ts +69 -0
  74. package/src/take/zoom/region-utils.ts +55 -0
  75. package/src/take/zoom/spring.ts +133 -0
  76. package/src/tasks/archive.ts +1 -3
  77. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -34,6 +34,7 @@ import {
34
34
  type StructureFinding,
35
35
  validateRecords,
36
36
  } from '@/records/validate'
37
+ import type { SessionMapLength } from '@/records/session-map'
37
38
  import {
38
39
  intro,
39
40
  logError,
@@ -92,7 +93,7 @@ export function register(program: Command): void {
92
93
  .argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
93
94
  .argument(
94
95
  '[record]',
95
- 'One plan stem or folder slug, narrowing both findings and structure to it',
96
+ 'One plan stem, folder slug, or session map stem, narrowing the report to it',
96
97
  )
97
98
  .helpOption('-h, --help', 'Show this help message')
98
99
  .option('--json', 'Add a machine-readable record on stdout')
@@ -105,6 +106,7 @@ export function register(program: Command): void {
105
106
  ' plans filename, required sections, and the suggested-and-answer contract',
106
107
  ' groundwork README and current-state files, numbering, dating, and a half-closed track',
107
108
  ' intake overview file, numbering, dating, and the four bullets every item carries',
109
+ " sessions each session map's words against its cap, under `length` and never in the exit",
108
110
  '',
109
111
  'Structure, under `structure` and never in the exit:',
110
112
  " S1 to S5 from the plan standard, read on plans, a track's README and",
@@ -128,6 +130,7 @@ export function register(program: Command): void {
128
130
  ' canon records validate plans',
129
131
  ' canon records validate plans feature-records-by-heading',
130
132
  ' canon records validate intake --json',
133
+ ' canon records validate sessions session-orchestrator',
131
134
  '',
132
135
  ].join('\n'),
133
136
  )
@@ -828,6 +831,7 @@ function report(
828
831
  records: outcome.records,
829
832
  findings: outcome.findings,
830
833
  structure: outcome.structure,
834
+ ...(outcome.length && { length: outcome.length }),
831
835
  })}\n`,
832
836
  )
833
837
  } else {
@@ -846,6 +850,14 @@ function report(
846
850
  logStep('Structure, reported and never in the exit')
847
851
  for (const entry of outcome.structure) logInfo(describeStructure(entry))
848
852
  }
853
+
854
+ if (outcome.length) {
855
+ logStep('Length, reported and never in the exit')
856
+ for (const entry of outcome.length) {
857
+ const log = entry.over ? logWarn : logInfo
858
+ log(describeLength(entry))
859
+ }
860
+ }
849
861
  outro()
850
862
  }
851
863
 
@@ -857,6 +869,11 @@ function describe(found: Finding): string {
857
869
  return `${scope}${found.subject} ${found.message}`
858
870
  }
859
871
 
872
+ function describeLength(entry: SessionMapLength): string {
873
+ const verdict = entry.over ? 'over the cap' : 'within the cap'
874
+ return `${entry.record}: ${entry.words} of ${entry.cap} words, ${verdict}`
875
+ }
876
+
860
877
  function describeStructure(entry: StructureFinding): string {
861
878
  const scope = entry.record === entry.subject ? '' : `${entry.record}/`
862
879
  return `${entry.criterion} ${scope}${entry.subject}:${entry.line} ${entry.message}`
@@ -0,0 +1,302 @@
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 { renderReel } from '@/take/reel'
9
+ import {
10
+ intro,
11
+ logError,
12
+ logInfo,
13
+ logStep,
14
+ logWarn,
15
+ outro,
16
+ plural,
17
+ } from '@/cli/ui'
18
+
19
+ /**
20
+ * Holds wiring only. Every browser reference sits behind `loadTake`, because
21
+ * `src/cli.ts` imports this module at startup and resolving the engine there
22
+ * would put a browser launch in front of every other command.
23
+ */
24
+ type Take = typeof import('@/take/film')
25
+
26
+ interface RunOptions {
27
+ readonly out: string
28
+ readonly reel?: string
29
+ readonly json?: boolean
30
+ }
31
+
32
+ const INSTALL_FFMPEG =
33
+ 'install FFmpeg from your package manager, such as apt install ffmpeg or brew install ffmpeg'
34
+
35
+ /** What a reader does about each way the plan produced no take. */
36
+ const PLAN_REFUSALS: Record<TakePlanRefusal, string> = {
37
+ 'unreadable-plan':
38
+ 'The plan file is not valid JSON, so no beat could be read.',
39
+ 'no-beats': 'The plan names no beat, so there is nothing to film.',
40
+ 'bad-beat':
41
+ 'A beat is missing its name or carries a setup that is not a list.',
42
+ 'no-steps': 'A beat names no step, so its take would film nothing.',
43
+ 'unknown-route': 'A beat names a route this build does not record on.',
44
+ 'bad-step': 'A step is missing something the take needs to perform it.',
45
+ }
46
+
47
+ const TAKE_REFUSALS: Record<TakeRefusal, string> = {
48
+ 'ffmpeg-missing': 'FFmpeg is not installed, so no clip could be encoded.',
49
+ 'browser-missing': 'The headless shell is not installed in this project.',
50
+ 'server-unreachable': 'Nothing answered at the URL, so no beat was filmed.',
51
+ 'target-missing': 'A step names a target the page does not carry.',
52
+ 'take-failed': 'The take failed against a reachable page.',
53
+ }
54
+
55
+ export function register(program: Command): void {
56
+ program
57
+ .command('take')
58
+ .description(
59
+ 'Film a web app beat by beat with a click log and camera track',
60
+ )
61
+ .argument('<url>', 'Address to film')
62
+ .argument('<plan>', 'JSON file naming the beats and their steps')
63
+ .requiredOption('--out <dir>', 'Folder the clips, logs, and tracks land in')
64
+ .helpOption('-h, --help', 'Show this help message')
65
+ .option(
66
+ '--reel <file>',
67
+ 'Also join the beats into one animated WebP with the pointer drawn',
68
+ )
69
+ .option('--json', 'Add a machine-readable record on stdout')
70
+ .addHelpText(
71
+ 'after',
72
+ [
73
+ '',
74
+ 'Films each beat from a fresh load of the page into its own folder under',
75
+ '--out, numbered in plan order: clip.mp4, log.json, camera.json, and',
76
+ "still.jpg, the clip's closing frame. take.json beside them lists every",
77
+ 'beat and the route that filmed it.',
78
+ '',
79
+ 'A step beat is filmed by frame stepping in chrome-headless-shell, at',
80
+ '3840x2160 and exactly 60 fps, with the page clock advancing one frame',
81
+ 'per step. A frame slower than 2 s falls back to a real-time screencast',
82
+ 'at 1920x1080 for that beat, and the record says so. A screencast beat',
83
+ 'skips the stepper.',
84
+ '',
85
+ 'No cursor is drawn in a clip. The edit draws it from the log at a',
86
+ 'constant size, and the camera track carries the cursor and click ring',
87
+ 'per frame beside the view. Canon renders nothing past the clip, save',
88
+ 'the reel below.',
89
+ '',
90
+ '--reel <file> joins every beat in plan order into one looping animated',
91
+ 'WebP at 960 wide and 20 fps, keeping every third frame, with the cursor',
92
+ 'and click ring drawn from each camera track. It carries no zoom, so a',
93
+ "reviewer sees the whole page. clip.mp4 stays each beat's raw take.",
94
+ '',
95
+ ' {',
96
+ ' "beats": [',
97
+ ' { "name": "open the kit",',
98
+ ' "setup": [{ "kind": "click", "target": "text=Design" }],',
99
+ ' "steps": [',
100
+ ' { "kind": "click", "target": "#kit" },',
101
+ ' { "kind": "wait", "ms": 400 }',
102
+ ' ] }',
103
+ ' ]',
104
+ ' }',
105
+ '',
106
+ 'Step kinds:',
107
+ ' move <target>|<x,y> glide the pointer to a target or a point',
108
+ ' click <target> glide to the target and press it',
109
+ ' fill <target> <text> press the target and type the text',
110
+ ' scroll <target> <dy> glide to the target and wheel dy pixels',
111
+ ' wait <ms> hold, for a state the page reaches on its own',
112
+ '',
113
+ 'A target is a CSS selector, or text=<words> for the innermost element',
114
+ 'whose whole text is those words. Setup steps run before the first kept',
115
+ 'frame. Every beat opens and closes on a 0.6 s hold.',
116
+ '',
117
+ 'Needs a reachable page, FFmpeg, and the headless shell. Install with:',
118
+ ` ${INSTALL_BROWSER}`,
119
+ '',
120
+ 'Reasons:',
121
+ ' no-plan-file nothing readable at the plan path',
122
+ ' unreadable-plan the plan file is not valid JSON',
123
+ ' no-beats the plan names no beat',
124
+ ' bad-beat a beat has no name or a setup that is not a list',
125
+ ' no-steps a beat names no step',
126
+ ' unknown-route a beat names a route other than step or screencast',
127
+ ' bad-step a step is missing what the take needs to perform it',
128
+ ' engine-missing the browser engine is not installed in this project',
129
+ ' ffmpeg-missing FFmpeg is not on the PATH',
130
+ ' browser-missing the headless shell or browser binary is not installed',
131
+ ' server-unreachable nothing answered at the URL',
132
+ ' target-missing a step names a target the page does not carry',
133
+ ' take-failed the take failed against a reachable page',
134
+ ' reel-failed every beat was filmed and the reel did not encode',
135
+ '',
136
+ 'Exit codes:',
137
+ ' 0 every beat was filmed, and the reel written when asked for',
138
+ ' 1 refused, with the reason on stderr or in the JSON record',
139
+ '',
140
+ 'Examples:',
141
+ ' canon take http://localhost:4173 plan.json --out .canon/tmp/demo/takes',
142
+ ' canon take http://localhost:4173 plan.json --out takes --json',
143
+ ' canon take http://localhost:4173 plan.json --out takes --reel takes/reel.webp',
144
+ '',
145
+ ].join('\n'),
146
+ )
147
+ .action(async (url: string, plan: string, opts: RunOptions) => {
148
+ process.exitCode = await runTake(url, plan, opts)
149
+ })
150
+ }
151
+
152
+ async function runTake(
153
+ url: string,
154
+ planPath: string,
155
+ opts: RunOptions,
156
+ ): Promise<number> {
157
+ const emitJson = opts.json ?? false
158
+ const path = resolve(planPath)
159
+
160
+ intro(`canon take ${url}`)
161
+
162
+ let source: string
163
+ try {
164
+ source = readFileSync(path, 'utf8')
165
+ } catch (error) {
166
+ return refuse(
167
+ emitJson,
168
+ url,
169
+ 'no-plan-file',
170
+ `No plan file at ${path}. ${error instanceof Error ? error.message : String(error)}`,
171
+ )
172
+ }
173
+
174
+ const read = readTakePlan(source)
175
+ if (read.kind === 'refused') {
176
+ return refuse(
177
+ emitJson,
178
+ url,
179
+ read.reason,
180
+ `${PLAN_REFUSALS[read.reason]} ${read.detail}`,
181
+ )
182
+ }
183
+
184
+ const { plan } = read
185
+ logStep('Scope')
186
+ logInfo(
187
+ `${plural(plan.beats.length, 'beat')} into ${resolve(opts.out)}: ${plan.beats.map((beat) => beat.name).join(', ')}`,
188
+ )
189
+
190
+ const taker = await loadTake()
191
+ if (!taker) {
192
+ logStep('Browser')
193
+ logError('the browser engine is not installed in this project')
194
+ logWarn(`Install it with: ${INSTALL_BROWSER}`)
195
+ outro()
196
+ emit(emitJson, {
197
+ url,
198
+ plan: path,
199
+ reason: 'engine-missing',
200
+ install: INSTALL_BROWSER,
201
+ })
202
+ return 1
203
+ }
204
+
205
+ logStep('Filming')
206
+ const result = await taker.take({ url, plan, out: opts.out })
207
+
208
+ if (result.status === 'failed') {
209
+ logStep('Refused')
210
+ logWarn(TAKE_REFUSALS[result.reason])
211
+ if (result.beat) logWarn(`in beat ${result.beat}`)
212
+ if (result.reason === 'server-unreachable') {
213
+ logWarn(`Start whatever serves ${url}, then run this again.`)
214
+ }
215
+ if (result.reason === 'browser-missing') {
216
+ logWarn(`Install the headless shell with: ${INSTALL_BROWSER}`)
217
+ }
218
+ if (result.reason === 'ffmpeg-missing') logWarn(`Then ${INSTALL_FFMPEG}.`)
219
+ logWarn(result.message.split('\n')[0] ?? '')
220
+ outro()
221
+ emit(emitJson, {
222
+ url,
223
+ plan: path,
224
+ reason: result.reason,
225
+ message: result.message,
226
+ ...(result.beat ? { beat: result.beat } : {}),
227
+ ...(result.reason === 'browser-missing'
228
+ ? { install: INSTALL_BROWSER }
229
+ : {}),
230
+ })
231
+ return 1
232
+ }
233
+
234
+ for (const beat of result.beats) {
235
+ logInfo(
236
+ `${beat.dir} ${beat.route} ${plural(beat.frames, 'frame')} ${beat.seconds} s`,
237
+ )
238
+ if (beat.fallback) logWarn(` filmed by screencast: ${beat.fallback}`)
239
+ }
240
+
241
+ const filmed = {
242
+ url,
243
+ plan: path,
244
+ out: result.out,
245
+ beats: result.beats,
246
+ durationMs: result.durationMs,
247
+ }
248
+ if (!opts.reel) {
249
+ outro()
250
+ emit(emitJson, filmed)
251
+ return 0
252
+ }
253
+
254
+ logStep('Reel')
255
+ try {
256
+ const reel = await renderReel(result.out, result.beats, resolve(opts.reel))
257
+ logInfo(
258
+ `${reel.file} ${plural(reel.frames, 'frame')} ${reel.seconds} s ${(reel.bytes / 1e6).toFixed(1)} MB`,
259
+ )
260
+ outro()
261
+ emit(emitJson, { ...filmed, reel })
262
+ return 0
263
+ } catch (error) {
264
+ const message = error instanceof Error ? error.message : String(error)
265
+ logWarn('Every beat was filmed, and the reel did not encode.')
266
+ logWarn(message.split('\n')[0] ?? '')
267
+ outro()
268
+ emit(emitJson, { ...filmed, reason: 'reel-failed', message })
269
+ return 1
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Frames a refusal on stderr and puts the record on stdout, so an operator
275
+ * reading the terminal sees the reason rather than a command that appeared to
276
+ * do nothing.
277
+ */
278
+ function refuse(
279
+ emitJson: boolean,
280
+ url: string,
281
+ reason: string,
282
+ message: string,
283
+ ): number {
284
+ logStep('Refused')
285
+ logWarn(message)
286
+ outro()
287
+ emit(emitJson, { url, reason, message })
288
+ return 1
289
+ }
290
+
291
+ async function loadTake(): Promise<Take | undefined> {
292
+ try {
293
+ return await import('@/take/film')
294
+ } catch (error) {
295
+ if (isEngineMissing(error)) return undefined
296
+ throw error
297
+ }
298
+ }
299
+
300
+ function emit(json: boolean, record: unknown): void {
301
+ if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
302
+ }
@@ -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
+ }