@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
@@ -32,17 +32,22 @@ import {
32
32
  findEvidenceHead,
33
33
  findEvidenceLocal,
34
34
  findEvidencePreview,
35
+ findEvidenceReel,
35
36
  type EvidenceItem,
36
37
  type EvidenceReading,
37
38
  groupEvidence,
38
39
  hasMarkedEvidenceComment,
40
+ isEvidencePath,
39
41
  PNG_HEADER_BYTES,
40
42
  readChecklistBoxes,
41
43
  readOwed,
42
44
  readPngWidth,
45
+ readUncaptured,
46
+ nextEvidenceReel,
43
47
  renderEvidenceBody,
44
48
  settleChecklist,
45
49
  } from '@/pr/evidence'
50
+ import { isWithinTolerance } from '@/pr/pixels'
46
51
  import {
47
52
  dropFramesWithLegacy,
48
53
  FRAMES_BRANCH,
@@ -56,7 +61,9 @@ import {
56
61
  LEGACY_FRAMES_BRANCH,
57
62
  pruneFramesWithLegacy,
58
63
  pushFrame,
64
+ REEL_MAX_BYTES,
59
65
  readFrame,
66
+ readReel,
60
67
  isDesignReference,
61
68
  referenceSlug,
62
69
  type TreeEntry,
@@ -83,14 +90,12 @@ import {
83
90
  type MergeRequest,
84
91
  } from '@/pr/merge'
85
92
  import { listWorktrees } from '@/git/worktree'
93
+ import { branchSlug, buildDriveCopy, loadPreviewConfig } from '@/pr/drive-copy'
86
94
  import {
87
- branchSlug,
88
- buildDriveCopy,
89
95
  changedSince,
90
- loadPreviewConfig,
91
96
  readWorktreeStatus,
92
97
  type StatusEntry,
93
- } from '@/pr/drive-copy'
98
+ } from '@/git/status'
94
99
  import { machineListenerReader } from '@/listeners/machine'
95
100
  import { loadPaintedPatterns } from '@/pr/painted'
96
101
  import { LOCAL_CONFIG_REL, readHealthPaths } from '@/pr/health'
@@ -164,6 +169,7 @@ interface EvidenceOptions extends ReadOptions {
164
169
  readonly preview?: string
165
170
  readonly checklist?: string
166
171
  readonly local?: string
172
+ readonly reel?: string
167
173
  readonly check?: boolean
168
174
  }
169
175
 
@@ -274,6 +280,7 @@ interface FramesOptions extends ReadOptions {
274
280
  readonly head?: string
275
281
  readonly pass?: string
276
282
  readonly theme?: string
283
+ readonly reel?: string
277
284
  readonly drop?: boolean
278
285
  readonly prune?: string
279
286
  }
@@ -289,6 +296,8 @@ type FramesCommandRefusal =
289
296
  | 'bad-theme'
290
297
  | 'bad-days'
291
298
  | 'unreadable-frame'
299
+ | 'unreadable-reel'
300
+ | 'reel-too-large'
292
301
  | 'read-only'
293
302
  | 'gh-missing'
294
303
  | 'gh-failed'
@@ -296,22 +305,25 @@ type FramesCommandRefusal =
296
305
 
297
306
  const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
298
307
  'bad-mode':
299
- 'Pass exactly one of --add, --drop, or --prune. --prune reads every pull request on the branch, so it takes no number.',
308
+ 'Pass exactly one of --add, --reel, --drop, or --prune. --prune reads every pull request on the branch, so it takes no number.',
300
309
  'no-number':
301
- '--add and --drop write for one pull request, so name its number.',
310
+ '--add, --reel, and --drop write for one pull request, so name its number.',
302
311
  'bad-box':
303
- '--add takes --box <n>, a positive whole number naming the checklist box, or --reference, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, never both.',
312
+ '--add takes --box <n>, a positive whole number naming the checklist box, or --reference, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, never both. --reel takes neither, since a pass has one reel.',
304
313
  'bad-reference':
305
314
  '--reference takes <page>/<frame> or a tracked canon/design/<surface>/<name> path, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
306
315
  'bad-head':
307
316
  '--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
308
317
  'bad-pass':
309
- '--add takes --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
318
+ '--add and --reel take --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
310
319
  'bad-theme':
311
320
  '--theme takes dark or light and goes with --add --box, so a second shot of one box never replaces the first.',
312
321
  'bad-days': '--prune takes a positive whole number of days.',
313
322
  'unreadable-frame':
314
323
  'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
324
+ 'unreadable-reel':
325
+ 'The file named by --reel is missing, empty, or not a WebP, so it would not play in the comment. Nothing is pushed.',
326
+ 'reel-too-large': `The file named by --reel is past ${REEL_MAX_BYTES / 1_000_000} MB, the most GitHub renders as an image in a comment. Film fewer or shorter beats. Nothing is pushed.`,
315
327
  'read-only': `GitHub refused the write to ${FRAMES_BRANCH}. The token cannot write this repository, which is what a fork pull request's workflow token gets, so describe the frame in words instead.`,
316
328
  'gh-missing':
317
329
  'gh is not on the path, so the branch could not be read or written.',
@@ -417,7 +429,7 @@ type EvidenceRefusal =
417
429
 
418
430
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
419
431
  'check-writes':
420
- '--check is a read and renders no body, so it takes none of --preview, --local, or --checklist. Run the check and the render as two calls.',
432
+ '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, or --reel. Run the check and the render as two calls.',
421
433
  'gh-failed':
422
434
  'gh could not answer for this repository. Name the pull request number.',
423
435
  'unreadable-checklist':
@@ -754,6 +766,10 @@ export function register(program: Command): void {
754
766
  '--local <url>',
755
767
  "Add this worktree's running server to the address line, from canon pr local",
756
768
  )
769
+ .option(
770
+ '--reel <link>',
771
+ 'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
772
+ )
757
773
  .option(
758
774
  '--check',
759
775
  'Report which post-pull-request steps the thread is still owed, rendering nothing',
@@ -784,6 +800,19 @@ export function register(program: Command): void {
784
800
  'the hosted and local previews with " · ", carrying whichever apply,',
785
801
  'then the Base and Head line when there are screenshots.',
786
802
  '',
803
+ 'Each case shows its head image at the comment width under a bold',
804
+ 'label, with its base folded beneath in a <details> block, and an',
805
+ 'added case shows its head alone. Every state opens while the comment',
806
+ 'carries at most six head images and closes past that.',
807
+ '',
808
+ 'Each modified PNG is compared with its base pixel by pixel. A case',
809
+ 'whose densest 16px square holds 6 or fewer differing pixels moves to',
810
+ 'one closed "Within tolerance" block after the states, still named and',
811
+ 'embedded, and stops counting toward the six. Its item on the record',
812
+ 'carries `withinTolerance`, which is absent for an added case, a',
813
+ 'format other than PNG, or a pair with a failed read. None of those',
814
+ 'folds, and neither does a pair that differs in size or will not decode.',
815
+ '',
787
816
  '--preview puts the hosted address on that line. With no evidence in',
788
817
  'the diff, the body is the heading, that line, and the marker alone,',
789
818
  'reported as ok, so the preview still lands in one comment a later',
@@ -813,6 +842,13 @@ export function register(program: Command): void {
813
842
  'comment. Together with --checklist the link and the checklist land in',
814
843
  'one comment.',
815
844
  '',
845
+ '--reel embeds an animated reel on its own line under the addresses,',
846
+ 'labeled with the head this render describes as the one it was filmed',
847
+ 'at. Without --reel, a reel the marked comment already embeds is carried',
848
+ 'forward under the head it names, so a stale reel never reads as',
849
+ 'current, and a passed one replaces it. Like --local it rides on a',
850
+ 'checklist or a comparison and never opens a comment alone.',
851
+ '',
816
852
  "A render at a new head also resets the pull request body's UI pass",
817
853
  'line, the one `canon pr tick` writes under ## Testing, to unchecked',
818
854
  'naming that head. It touches no other body text, leaves a line already',
@@ -822,6 +858,7 @@ export function register(program: Command): void {
822
858
  'present only when that comment holds it:',
823
859
  ' preview the hosted **Preview:** address the comment carries',
824
860
  ' local the **Local preview:** address beside it',
861
+ ' reel the embedded reel, as `link` and the `head` it was filmed at',
825
862
  ' checklist the checklist between its delimiters, ticks included',
826
863
  ' boxes each checklist box with its number, tick, stamp, and taste',
827
864
  ' mark, the numbering `canon pr tick` takes',
@@ -831,6 +868,16 @@ export function register(program: Command): void {
831
868
  'unread thread refuses as gh-failed on both reasons rather than',
832
869
  'reporting the fields absent.',
833
870
  '',
871
+ 'The ok, no-evidence, and --check records carry `uncaptured`, the',
872
+ 'painted paths the pull request changed while no evidence image did, in',
873
+ "a project whose base branch's tree holds one. Painted is the set",
874
+ '`canon/config/painted.json` tunes, minus any path under an evidence/',
875
+ 'segment or canon/design/, and any changed evidence image empties the',
876
+ 'list. The field is absent when the base tree holds no capture or could',
877
+ 'not be read, a cut-short listing showing none included, so absent means',
878
+ 'unchecked rather than clean. It never adds to `owed`, since no step of',
879
+ 'the ship chain can take a capture.',
880
+ '',
834
881
  '--check reads the same changed set and thread, renders no body, and',
835
882
  'reports what the ship chain still owes the pull request:',
836
883
  ' settled nothing is owed',
@@ -841,7 +888,7 @@ export function register(program: Command): void {
841
888
  'request changed, and the marked comment carries no **Preview:**',
842
889
  'address. A failed deploy reads the same as a skipped one.',
843
890
  'The record carries `owed` and the same marked fields. --check refuses',
844
- 'with --preview, --local, or --checklist as check-writes.',
891
+ 'with --preview, --local, --checklist, or --reel as check-writes.',
845
892
  '',
846
893
  'Refusals, read off `reason`:',
847
894
  ' unreadable-checklist --checklist named a path that could not be read,',
@@ -864,6 +911,7 @@ export function register(program: Command): void {
864
911
  ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
865
912
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
866
913
  ' canon pr evidence 1341 --local http://localhost:5173 --json',
914
+ ' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
867
915
  ' canon pr evidence 1341 --check --json',
868
916
  '',
869
917
  ].join('\n'),
@@ -1306,18 +1354,28 @@ export function register(program: Command): void {
1306
1354
  [
1307
1355
  '',
1308
1356
  'A project names what its preview writes in',
1309
- '`canon/config/preview.json` as `{ "writes": [path], "env": "<NAME>" }`.',
1357
+ '`canon/config/preview.json` as',
1358
+ '`{ "writes": [path], "keep": [path], "env": "<NAME>" }`.',
1310
1359
  'Each `writes` path is relative to the worktree, and `env`, defaulting',
1311
1360
  'to `CANON_PREVIEW_ROOT`, is the variable the server reads for the copy',
1312
1361
  'root. The server resolves each declared path under that root.',
1313
1362
  '',
1363
+ 'Each `keep` path sits below a `writes` path, never equal to one, and',
1364
+ 'names state the running server owns there, such as a run file. A',
1365
+ "rebuild leaves it in the copy and never copies the worktree's own, so",
1366
+ 'list it for a server that writes under a declared path. The key is',
1367
+ 'optional.',
1368
+ '',
1314
1369
  'The copy lives at `.canon/tmp/pr/review-ui/<slug>/board/` at the main',
1315
1370
  'worktree root, where `<slug>` is the branch with a leading type',
1316
1371
  'segment dropped and each `/` made `-`. With a number the branch is the',
1317
1372
  "pull request's head, and without one it is the checkout's, so the",
1318
1373
  'worker starting its server before a pull request exists and the',
1319
1374
  'reviewer naming the number land on one copy. Every run but --check',
1320
- "rebuilds it from the worktree, dropping the last pass's drives.",
1375
+ "resets each declared path from the worktree, dropping the last pass's",
1376
+ 'drives, and leaves the rest of the copy root and each `keep` path as',
1377
+ 'they are. A page added only to the copy, inside a declared path, is',
1378
+ 'wiped.',
1321
1379
  '',
1322
1380
  '--baseline records `git status` for the worktree under',
1323
1381
  '`.canon/tmp/pr/review-ui/<number>/`, and --check reports each path',
@@ -1388,6 +1446,10 @@ export function register(program: Command): void {
1388
1446
  '--theme <theme>',
1389
1447
  'The theme the frame was shot at, dark or light, with --add, keeping two shots of one box apart',
1390
1448
  )
1449
+ .option(
1450
+ '--reel <file>',
1451
+ "Push this animated WebP as the pass's reel, in place of --add",
1452
+ )
1391
1453
  .option('--drop', "Rewrite the branch without this pull request's frames")
1392
1454
  .option(
1393
1455
  '--prune <days>',
@@ -1409,6 +1471,11 @@ export function register(program: Command): void {
1409
1471
  'segment keeps a second pass at one head and box from replacing an image',
1410
1472
  'an earlier comment shows. The first --add creates the branch.',
1411
1473
  '',
1474
+ `--reel <file> pushes one animated WebP of at most ${REEL_MAX_BYTES / 1_000_000} MB to`,
1475
+ 'pr-<number>/<short-head>/<pass>/reel.webp beside the box frames, taking',
1476
+ '--pass and --head the way --add does, and carries `reel: true` in place of',
1477
+ 'a box. A drop or a prune clears it with the rest of the folder.',
1478
+ '',
1412
1479
  '--drop and --prune write one root commit and force-move the ref,',
1413
1480
  'since a delete commit would keep every image reachable through history.',
1414
1481
  'A rewrite that empties the branch deletes it. --prune keeps a pull',
@@ -1422,15 +1489,17 @@ export function register(program: Command): void {
1422
1489
  '',
1423
1490
  'Read `reason` on the JSON record:',
1424
1491
  ' ok an add pushed, or a drop or prune finished',
1425
- ' bad-mode not exactly one of --add, --drop, --prune',
1426
- ' no-number --add or --drop named no pull request',
1427
- ' bad-box --add with both --box and --reference, or neither',
1492
+ ' bad-mode not exactly one of --add, --reel, --drop, --prune',
1493
+ ' no-number --add, --reel, or --drop named no pull request',
1494
+ ' bad-box --add with both --box and --reference, or neither, or --reel with either',
1428
1495
  ' bad-reference --reference is not <page>/<frame> or canon/design/<surface>/<name> in the safe name form, or the tracked path is no file',
1429
1496
  ' bad-head --head is not 7 to 40 lowercase hex characters',
1430
- ' bad-pass --add without a compact UTC --pass stamp',
1497
+ ' bad-pass --add or --reel without a compact UTC --pass stamp',
1431
1498
  ' bad-theme --theme is not dark or light, or no --add, or rides a --reference',
1432
1499
  ' bad-days --prune without a positive number of days',
1433
1500
  ' unreadable-frame the --add file is missing, empty, or not a PNG',
1501
+ ' unreadable-reel the --reel file is missing, empty, or not a WebP',
1502
+ ` reel-too-large the --reel file is past ${REEL_MAX_BYTES / 1_000_000} MB`,
1434
1503
  ' read-only GitHub refused the write, as on a fork token',
1435
1504
  ' unreadable-tip the branch could not be read',
1436
1505
  ' push-failed a write failed and the ref did not move',
@@ -1445,6 +1514,7 @@ export function register(program: Command): void {
1445
1514
  '',
1446
1515
  'Examples:',
1447
1516
  ' canon pr frames 1341 --add frames/3.png --box 3 --pass 20261002T154450Z --json',
1517
+ ' canon pr frames 1341 --reel reel.webp --pass 20261002T154450Z --json',
1448
1518
  ' canon pr frames 1341 --drop --json',
1449
1519
  ' canon pr frames --prune 30 --json',
1450
1520
  '',
@@ -2358,6 +2428,31 @@ function everyChangedPath(files: PullFiles): string[] {
2358
2428
  return [...files.present.keys(), ...files.removed]
2359
2429
  }
2360
2430
 
2431
+ /**
2432
+ * Whether the base branch's tree holds an evidence image, read recursively
2433
+ * over the API so the answer is the same from any checkout. GitHub cuts a
2434
+ * large listing short, and a cut listing proves captures only when one
2435
+ * arrived before the cut, so it reads as unread otherwise. Undefined on any
2436
+ * failed read.
2437
+ */
2438
+ async function readBaseKeepsCaptures(
2439
+ cwd: string,
2440
+ baseRef: string | undefined,
2441
+ ): Promise<boolean | undefined> {
2442
+ if (baseRef === undefined || baseRef === '') return undefined
2443
+ const stdout = await gh(cwd, [
2444
+ 'api',
2445
+ `repos/{owner}/{repo}/git/trees/${baseRef}?recursive=1`,
2446
+ '--jq',
2447
+ '.truncated, (.tree[] | select(.type == "blob") | .path)',
2448
+ ])
2449
+ if (stdout === null) return undefined
2450
+ const [truncated, ...paths] = stdout.split('\n').filter(Boolean)
2451
+ if (truncated !== 'true' && truncated !== 'false') return undefined
2452
+ if (paths.some(isEvidencePath)) return true
2453
+ return truncated === 'true' ? undefined : false
2454
+ }
2455
+
2361
2456
  /**
2362
2457
  * The commit the pull request diverged from its base at, from the compare
2363
2458
  * endpoint, since a checkout on another branch resolves its own trunk tip
@@ -2378,25 +2473,30 @@ async function readPullMergeBase(
2378
2473
  return sha === null || sha.trim() === '' ? undefined : sha.trim()
2379
2474
  }
2380
2475
 
2381
- /** How many width reads run at once, since each downloads a whole image. */
2382
- const WIDTH_READ_CONCURRENCY = 4
2476
+ /** How many cases are read at once. Each reads its images one after another, so this bounds both the downloads in flight and the pairs held for comparison. */
2477
+ const IMAGE_READ_CONCURRENCY = 4
2383
2478
 
2384
- type WidthRead =
2385
- | { readonly kind: 'read'; readonly width?: number }
2479
+ type ImageRead =
2480
+ | {
2481
+ readonly kind: 'read'
2482
+ readonly width?: number
2483
+ readonly bytes?: Uint8Array
2484
+ }
2386
2485
  | { readonly kind: 'failed' }
2387
2486
 
2388
2487
  /**
2389
- * The pixel width of a PNG at `sha`, read over the contents API like every
2390
- * other evidence read. The endpoint ignores a `Range` header, so the whole
2391
- * file arrives and only its first bytes are read. A format other than PNG
2392
- * reads without a width, and a refused or timed-out call reads as failed, so
2393
- * the caller can keep the ceiling width and still report the cause.
2488
+ * A PNG at `sha`, read over the contents API like every other evidence read,
2489
+ * with its pixel width from the header. The endpoint ignores a `Range` header,
2490
+ * so the whole file arrives, and its bytes are kept for the pixel comparison.
2491
+ * A format other than PNG reads with neither, and a refused or timed-out call
2492
+ * reads as failed, so the caller can keep the ceiling width and still report
2493
+ * the cause.
2394
2494
  */
2395
- async function readImageWidth(
2495
+ async function readImage(
2396
2496
  cwd: string,
2397
2497
  path: string,
2398
2498
  sha: string,
2399
- ): Promise<WidthRead> {
2499
+ ): Promise<ImageRead> {
2400
2500
  if (!/\.png$/i.test(path)) return { kind: 'read' }
2401
2501
  try {
2402
2502
  const result = await execa(
@@ -2415,8 +2515,11 @@ async function readImageWidth(
2415
2515
  encoding: 'buffer',
2416
2516
  },
2417
2517
  )
2418
- const width = readPngWidth(result.stdout.subarray(0, PNG_HEADER_BYTES))
2419
- return width === undefined ? { kind: 'read' } : { kind: 'read', width }
2518
+ const bytes = result.stdout
2519
+ const width = readPngWidth(bytes.subarray(0, PNG_HEADER_BYTES))
2520
+ return width === undefined
2521
+ ? { kind: 'read', bytes }
2522
+ : { kind: 'read', width, bytes }
2420
2523
  } catch {
2421
2524
  return { kind: 'failed' }
2422
2525
  }
@@ -2444,66 +2547,79 @@ async function mapPool<T, R>(
2444
2547
 
2445
2548
  interface SizedEvidence {
2446
2549
  readonly reading: EvidenceReading
2447
- /** Each image whose width read failed, as `head:<path>` or `base:<path>`, which rendered at the ceiling. */
2550
+ /** Each image whose read failed, as `head:<path>` or `base:<path>`, which rendered at the ceiling and left its case uncompared. */
2448
2551
  readonly unread: readonly string[]
2449
2552
  }
2450
2553
 
2451
- /** Each item with the pixel width of its head image and, when it has one, its base image. */
2452
- async function withImageWidths(
2554
+ interface MeasuredItem {
2555
+ readonly item: EvidenceItem
2556
+ readonly unread: readonly string[]
2557
+ }
2558
+
2559
+ /**
2560
+ * One case with the pixel width of its head image and, when it has one, its
2561
+ * base image, and whether the two match within tolerance. The pair is compared
2562
+ * as soon as both reads land, so its bytes are dropped before the pool takes
2563
+ * the next case. A case missing either PNG carries no verdict.
2564
+ */
2565
+ async function measureItem(
2566
+ cwd: string,
2567
+ item: EvidenceItem,
2568
+ base: string | undefined,
2569
+ head: string,
2570
+ ): Promise<MeasuredItem> {
2571
+ const headRead = await readImage(cwd, item.path, head)
2572
+ const baseRead =
2573
+ item.added || base === undefined
2574
+ ? undefined
2575
+ : await readImage(cwd, item.path, base)
2576
+ const unread = [
2577
+ ...(headRead.kind === 'failed' ? [`head:${item.path}`] : []),
2578
+ ...(baseRead?.kind === 'failed' ? [`base:${item.path}`] : []),
2579
+ ]
2580
+ const headBytes = headRead.kind === 'read' ? headRead.bytes : undefined
2581
+ const baseBytes = baseRead?.kind === 'read' ? baseRead.bytes : undefined
2582
+ return {
2583
+ item: {
2584
+ ...item,
2585
+ ...(headRead.kind === 'read' &&
2586
+ headRead.width !== undefined && { width: headRead.width }),
2587
+ ...(baseRead?.kind === 'read' &&
2588
+ baseRead.width !== undefined && { baseWidth: baseRead.width }),
2589
+ ...(headBytes !== undefined &&
2590
+ baseBytes !== undefined && {
2591
+ withinTolerance: isWithinTolerance(baseBytes, headBytes),
2592
+ }),
2593
+ },
2594
+ unread,
2595
+ }
2596
+ }
2597
+
2598
+ /** Each item measured by `measureItem`, in the reading's order. */
2599
+ async function withImageReads(
2453
2600
  cwd: string,
2454
2601
  reading: EvidenceReading,
2455
2602
  base: string | undefined,
2456
2603
  head: string,
2457
2604
  ): Promise<SizedEvidence> {
2458
2605
  if (reading.kind === 'refused') return { reading, unread: [] }
2459
- const jobs = reading.states.flatMap((entry, stateIndex) =>
2460
- entry.items.flatMap((item, itemIndex) => [
2461
- {
2462
- stateIndex,
2463
- itemIndex,
2464
- side: 'head' as const,
2465
- path: item.path,
2466
- sha: head,
2467
- },
2468
- ...(item.added || base === undefined
2469
- ? []
2470
- : [
2471
- {
2472
- stateIndex,
2473
- itemIndex,
2474
- side: 'base' as const,
2475
- path: item.path,
2476
- sha: base,
2477
- },
2478
- ]),
2479
- ]),
2480
- )
2481
- const reads = await mapPool(jobs, WIDTH_READ_CONCURRENCY, (job) =>
2482
- readImageWidth(cwd, job.path, job.sha),
2606
+ const measured = await mapPool(
2607
+ reading.states.flatMap((entry) => entry.items),
2608
+ IMAGE_READ_CONCURRENCY,
2609
+ (item) => measureItem(cwd, item, base, head),
2483
2610
  )
2484
- const unread: string[] = []
2485
- const states = reading.states.map((entry, stateIndex) => ({
2486
- ...entry,
2487
- items: entry.items.map((item, itemIndex): EvidenceItem => {
2488
- const widths: { width?: number; baseWidth?: number } = {}
2489
- jobs.forEach((job, index) => {
2490
- const read = reads[index]
2491
- if (
2492
- job.stateIndex !== stateIndex ||
2493
- job.itemIndex !== itemIndex ||
2494
- read === undefined
2495
- ) {
2496
- return
2497
- }
2498
- if (read.kind === 'failed') unread.push(`${job.side}:${job.path}`)
2499
- else if (read.width !== undefined) {
2500
- widths[job.side === 'head' ? 'width' : 'baseWidth'] = read.width
2501
- }
2502
- })
2503
- return { ...item, ...widths }
2504
- }),
2505
- }))
2506
- return { reading: { kind: 'read', states }, unread }
2611
+ let offset = 0
2612
+ const states = reading.states.map((entry) => {
2613
+ const items = measured
2614
+ .slice(offset, offset + entry.items.length)
2615
+ .map((read) => read.item)
2616
+ offset += entry.items.length
2617
+ return { ...entry, items }
2618
+ })
2619
+ return {
2620
+ reading: { kind: 'read', states },
2621
+ unread: measured.flatMap((entry) => entry.unread),
2622
+ }
2507
2623
  }
2508
2624
 
2509
2625
  async function runEvidence(
@@ -2520,7 +2636,8 @@ async function runEvidence(
2520
2636
  isCheck &&
2521
2637
  (opts.preview !== undefined ||
2522
2638
  opts.local !== undefined ||
2523
- opts.checklist !== undefined)
2639
+ opts.checklist !== undefined ||
2640
+ opts.reel !== undefined)
2524
2641
  ) {
2525
2642
  return refuseWith(
2526
2643
  'check-writes',
@@ -2573,9 +2690,10 @@ async function runEvidence(
2573
2690
 
2574
2691
  // The check never reads the merge base, so it skips the compare call and
2575
2692
  // a failed one cannot hold a draft lift for a reason the check never had.
2576
- const [pullFiles, base] = await Promise.all([
2693
+ const [pullFiles, base, keepsCaptures] = await Promise.all([
2577
2694
  listPullFiles(root, identity.number),
2578
2695
  isCheck ? undefined : readPullMergeBase(root, identity.base, identity.head),
2696
+ readBaseKeepsCaptures(root, identity.base),
2579
2697
  ])
2580
2698
  if (pullFiles === undefined) {
2581
2699
  return refuseWith(
@@ -2590,6 +2708,11 @@ async function runEvidence(
2590
2708
  [...pullFiles.present.keys()],
2591
2709
  async (path) => pullFiles.present.get(path) === false,
2592
2710
  )
2711
+ const uncaptured = readUncaptured(
2712
+ everyChangedPath(pullFiles),
2713
+ loadPaintedPatterns(root),
2714
+ keepsCaptures,
2715
+ )
2593
2716
 
2594
2717
  let existingCases = 0
2595
2718
  let commentId: number | undefined
@@ -2615,12 +2738,14 @@ async function runEvidence(
2615
2738
  carriedPreview = findEvidencePreview(comments)
2616
2739
  carriedChecklist = findEvidenceChecklist(comments)
2617
2740
  carriedLocal = findEvidenceLocal(comments)
2741
+ const carriedReel = findEvidenceReel(comments)
2618
2742
 
2619
2743
  // What the marked comment already shows a reviewer, kept apart from the
2620
2744
  // flags this call passed so a reader learns what is posted.
2621
2745
  const carried = {
2622
2746
  ...(carriedPreview !== undefined && { preview: carriedPreview }),
2623
2747
  ...(carriedLocal !== undefined && { local: carriedLocal }),
2748
+ ...(carriedReel !== undefined && { reel: carriedReel }),
2624
2749
  ...(carriedChecklist !== undefined && {
2625
2750
  checklist: carriedChecklist,
2626
2751
  boxes: readChecklistBoxes(carriedChecklist),
@@ -2635,6 +2760,16 @@ async function runEvidence(
2635
2760
  servesChange(deploy.filter, everyChangedPath(pullFiles))
2636
2761
  const isUnserved = deploy.kind === 'found' && !deployServesChange
2637
2762
 
2763
+ // Reported on every record that read the changed set, and never owed,
2764
+ // since no step of the ship chain can take the capture a skip left out.
2765
+ const uncapturedField = uncaptured !== undefined && { uncaptured }
2766
+ const warnUncaptured = (): void => {
2767
+ if (uncaptured === undefined || uncaptured.length === 0) return
2768
+ logWarn(
2769
+ `${plural(uncaptured.length, 'painted path')} changed with no evidence capture: ${uncaptured.join(', ')}`,
2770
+ )
2771
+ }
2772
+
2638
2773
  if (isCheck) {
2639
2774
  const owed = readOwed({
2640
2775
  hasEvidenceChange: grouped.kind === 'read',
@@ -2648,6 +2783,7 @@ async function runEvidence(
2648
2783
  ? 'Nothing the ship chain posts after the pull request is missing.'
2649
2784
  : `missing ${owed.join(', ')}`,
2650
2785
  )
2786
+ warnUncaptured()
2651
2787
  outro()
2652
2788
  if (emitJson) {
2653
2789
  process.stdout.write(
@@ -2656,6 +2792,7 @@ async function runEvidence(
2656
2792
  number: identity.number,
2657
2793
  reason: owed.length === 0 ? 'settled' : 'owed',
2658
2794
  owed,
2795
+ ...uncapturedField,
2659
2796
  ...(commentId !== undefined && { commentId }),
2660
2797
  ...carried,
2661
2798
  })}\n`,
@@ -2683,6 +2820,7 @@ async function runEvidence(
2683
2820
  logInfo(
2684
2821
  'No changed path carries an evidence/ segment, so there is nothing to post.',
2685
2822
  )
2823
+ warnUncaptured()
2686
2824
  outro()
2687
2825
  if (emitJson) {
2688
2826
  process.stdout.write(
@@ -2690,6 +2828,7 @@ async function runEvidence(
2690
2828
  root,
2691
2829
  ...(identity.number !== undefined && { number: identity.number }),
2692
2830
  reason: 'no-evidence',
2831
+ ...uncapturedField,
2693
2832
  ...carried,
2694
2833
  })}\n`,
2695
2834
  )
@@ -2709,12 +2848,13 @@ async function runEvidence(
2709
2848
 
2710
2849
  const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
2711
2850
  const local = opts.local ?? carriedLocal
2851
+ const reel = nextEvidenceReel(opts.reel, identity.head, comments)
2712
2852
  const unsettled = suppliedChecklist ?? carriedChecklist
2713
2853
  const checklist =
2714
2854
  unsettled === undefined
2715
2855
  ? undefined
2716
2856
  : settleChecklist(unsettled, identity.head, findEvidenceHead(comments))
2717
- const sized = await withImageWidths(root, grouped, base, identity.head)
2857
+ const sized = await withImageReads(root, grouped, base, identity.head)
2718
2858
  const states = sized.reading.kind === 'read' ? sized.reading.states : []
2719
2859
  const body = renderEvidenceBody(
2720
2860
  states,
@@ -2724,6 +2864,7 @@ async function runEvidence(
2724
2864
  preview,
2725
2865
  checklist,
2726
2866
  local,
2867
+ reel,
2727
2868
  )
2728
2869
 
2729
2870
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -2781,6 +2922,13 @@ async function runEvidence(
2781
2922
  }
2782
2923
  if (preview !== undefined) logInfo(`preview ${preview}`)
2783
2924
  if (local !== undefined) logInfo(`local preview ${local}`)
2925
+ if (reel !== undefined) {
2926
+ logInfo(
2927
+ opts.reel === undefined
2928
+ ? `reel carried forward, filmed at ${reel.head.slice(0, 7)}`
2929
+ : 'reel embedded under the addresses',
2930
+ )
2931
+ }
2784
2932
  if (checklist !== undefined) {
2785
2933
  logInfo(
2786
2934
  suppliedChecklist === undefined
@@ -2788,6 +2936,7 @@ async function runEvidence(
2788
2936
  : 'checklist folded in below the comparison',
2789
2937
  )
2790
2938
  }
2939
+ warnUncaptured()
2791
2940
 
2792
2941
  outro()
2793
2942
 
@@ -2803,6 +2952,7 @@ async function runEvidence(
2803
2952
  states,
2804
2953
  ...(sized.unread.length > 0 && { widthUnread: sized.unread }),
2805
2954
  ...(uiPass !== undefined && { uiPass }),
2955
+ ...uncapturedField,
2806
2956
  ...(commentId !== undefined && { commentId }),
2807
2957
  ...carried,
2808
2958
  })}\n`,
@@ -3664,6 +3814,7 @@ function framesArgumentRefusal(
3664
3814
  ): FramesCommandRefusal | undefined {
3665
3815
  const modes = [
3666
3816
  opts.add !== undefined,
3817
+ opts.reel !== undefined,
3667
3818
  opts.drop === true,
3668
3819
  opts.prune !== undefined,
3669
3820
  ]
@@ -3673,6 +3824,12 @@ function framesArgumentRefusal(
3673
3824
  return POSITIVE_WHOLE.test(opts.prune) ? undefined : 'bad-days'
3674
3825
  }
3675
3826
  if (number === undefined || !POSITIVE_WHOLE.test(number)) return 'no-number'
3827
+ if (
3828
+ opts.reel !== undefined &&
3829
+ (opts.box !== undefined || opts.reference !== undefined)
3830
+ ) {
3831
+ return 'bad-box'
3832
+ }
3676
3833
  if (
3677
3834
  opts.add !== undefined &&
3678
3835
  (opts.box === undefined) === (opts.reference === undefined)
@@ -3689,7 +3846,7 @@ function framesArgumentRefusal(
3689
3846
  }
3690
3847
  if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
3691
3848
  if (
3692
- opts.add !== undefined &&
3849
+ (opts.add !== undefined || opts.reel !== undefined) &&
3693
3850
  (opts.pass === undefined || !isFramePass(opts.pass))
3694
3851
  ) {
3695
3852
  return 'bad-pass'
@@ -3757,6 +3914,10 @@ async function runFrames(
3757
3914
  if (opts.add !== undefined && bytes === undefined) {
3758
3915
  return refuseFrames('unreadable-frame')
3759
3916
  }
3917
+ const reel =
3918
+ opts.reel === undefined ? undefined : await readReel(resolve(opts.reel))
3919
+ if (reel?.kind === 'unreadable') return refuseFrames('unreadable-reel')
3920
+ if (reel?.kind === 'too-large') return refuseFrames('reel-too-large')
3760
3921
 
3761
3922
  if (Bun.which('gh') === null) return refuseFrames('gh-missing')
3762
3923
  const runner = ghApiRunner(root)
@@ -3832,14 +3993,19 @@ async function runFrames(
3832
3993
  box,
3833
3994
  reference,
3834
3995
  theme,
3835
- bytes: bytes ?? new Uint8Array(),
3996
+ reel: reel !== undefined,
3997
+ bytes: reel?.bytes ?? bytes ?? new Uint8Array(),
3836
3998
  })
3837
3999
  if (result.kind === 'refused') return refuseWrite(result.reason)
3838
4000
  const link = frameLink(repo, result.path)
3839
4001
  return finish(
3840
4002
  {
3841
4003
  number: pull,
3842
- ...(box === undefined ? { reference } : { box }),
4004
+ ...(reel !== undefined
4005
+ ? { reel: true }
4006
+ : box === undefined
4007
+ ? { reference }
4008
+ : { box }),
3843
4009
  ...(theme === undefined ? {} : { theme }),
3844
4010
  head,
3845
4011
  pass,
@@ -4485,6 +4651,7 @@ async function runDriveCopy(
4485
4651
  target.worktree,
4486
4652
  copyRoot,
4487
4653
  config.config.writes,
4654
+ config.config.keep,
4488
4655
  )
4489
4656
  logStep('Copied')
4490
4657
  logInfo(`${config.config.env}=${copyRoot}`)