@erclx/canon 5.67.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.
@@ -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,
@@ -162,6 +169,7 @@ interface EvidenceOptions extends ReadOptions {
162
169
  readonly preview?: string
163
170
  readonly checklist?: string
164
171
  readonly local?: string
172
+ readonly reel?: string
165
173
  readonly check?: boolean
166
174
  }
167
175
 
@@ -272,6 +280,7 @@ interface FramesOptions extends ReadOptions {
272
280
  readonly head?: string
273
281
  readonly pass?: string
274
282
  readonly theme?: string
283
+ readonly reel?: string
275
284
  readonly drop?: boolean
276
285
  readonly prune?: string
277
286
  }
@@ -287,6 +296,8 @@ type FramesCommandRefusal =
287
296
  | 'bad-theme'
288
297
  | 'bad-days'
289
298
  | 'unreadable-frame'
299
+ | 'unreadable-reel'
300
+ | 'reel-too-large'
290
301
  | 'read-only'
291
302
  | 'gh-missing'
292
303
  | 'gh-failed'
@@ -294,22 +305,25 @@ type FramesCommandRefusal =
294
305
 
295
306
  const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
296
307
  'bad-mode':
297
- '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.',
298
309
  'no-number':
299
- '--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.',
300
311
  'bad-box':
301
- '--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.',
302
313
  'bad-reference':
303
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.',
304
315
  'bad-head':
305
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.',
306
317
  'bad-pass':
307
- '--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.',
308
319
  'bad-theme':
309
320
  '--theme takes dark or light and goes with --add --box, so a second shot of one box never replaces the first.',
310
321
  'bad-days': '--prune takes a positive whole number of days.',
311
322
  'unreadable-frame':
312
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.`,
313
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.`,
314
328
  'gh-missing':
315
329
  'gh is not on the path, so the branch could not be read or written.',
@@ -415,7 +429,7 @@ type EvidenceRefusal =
415
429
 
416
430
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
417
431
  'check-writes':
418
- '--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.',
419
433
  'gh-failed':
420
434
  'gh could not answer for this repository. Name the pull request number.',
421
435
  'unreadable-checklist':
@@ -752,6 +766,10 @@ export function register(program: Command): void {
752
766
  '--local <url>',
753
767
  "Add this worktree's running server to the address line, from canon pr local",
754
768
  )
769
+ .option(
770
+ '--reel <link>',
771
+ 'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
772
+ )
755
773
  .option(
756
774
  '--check',
757
775
  'Report which post-pull-request steps the thread is still owed, rendering nothing',
@@ -782,6 +800,19 @@ export function register(program: Command): void {
782
800
  'the hosted and local previews with " · ", carrying whichever apply,',
783
801
  'then the Base and Head line when there are screenshots.',
784
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
+ '',
785
816
  '--preview puts the hosted address on that line. With no evidence in',
786
817
  'the diff, the body is the heading, that line, and the marker alone,',
787
818
  'reported as ok, so the preview still lands in one comment a later',
@@ -811,6 +842,13 @@ export function register(program: Command): void {
811
842
  'comment. Together with --checklist the link and the checklist land in',
812
843
  'one comment.',
813
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
+ '',
814
852
  "A render at a new head also resets the pull request body's UI pass",
815
853
  'line, the one `canon pr tick` writes under ## Testing, to unchecked',
816
854
  'naming that head. It touches no other body text, leaves a line already',
@@ -820,6 +858,7 @@ export function register(program: Command): void {
820
858
  'present only when that comment holds it:',
821
859
  ' preview the hosted **Preview:** address the comment carries',
822
860
  ' local the **Local preview:** address beside it',
861
+ ' reel the embedded reel, as `link` and the `head` it was filmed at',
823
862
  ' checklist the checklist between its delimiters, ticks included',
824
863
  ' boxes each checklist box with its number, tick, stamp, and taste',
825
864
  ' mark, the numbering `canon pr tick` takes',
@@ -829,6 +868,16 @@ export function register(program: Command): void {
829
868
  'unread thread refuses as gh-failed on both reasons rather than',
830
869
  'reporting the fields absent.',
831
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
+ '',
832
881
  '--check reads the same changed set and thread, renders no body, and',
833
882
  'reports what the ship chain still owes the pull request:',
834
883
  ' settled nothing is owed',
@@ -839,7 +888,7 @@ export function register(program: Command): void {
839
888
  'request changed, and the marked comment carries no **Preview:**',
840
889
  'address. A failed deploy reads the same as a skipped one.',
841
890
  'The record carries `owed` and the same marked fields. --check refuses',
842
- 'with --preview, --local, or --checklist as check-writes.',
891
+ 'with --preview, --local, --checklist, or --reel as check-writes.',
843
892
  '',
844
893
  'Refusals, read off `reason`:',
845
894
  ' unreadable-checklist --checklist named a path that could not be read,',
@@ -862,6 +911,7 @@ export function register(program: Command): void {
862
911
  ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
863
912
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
864
913
  ' canon pr evidence 1341 --local http://localhost:5173 --json',
914
+ ' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
865
915
  ' canon pr evidence 1341 --check --json',
866
916
  '',
867
917
  ].join('\n'),
@@ -1396,6 +1446,10 @@ export function register(program: Command): void {
1396
1446
  '--theme <theme>',
1397
1447
  'The theme the frame was shot at, dark or light, with --add, keeping two shots of one box apart',
1398
1448
  )
1449
+ .option(
1450
+ '--reel <file>',
1451
+ "Push this animated WebP as the pass's reel, in place of --add",
1452
+ )
1399
1453
  .option('--drop', "Rewrite the branch without this pull request's frames")
1400
1454
  .option(
1401
1455
  '--prune <days>',
@@ -1417,6 +1471,11 @@ export function register(program: Command): void {
1417
1471
  'segment keeps a second pass at one head and box from replacing an image',
1418
1472
  'an earlier comment shows. The first --add creates the branch.',
1419
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
+ '',
1420
1479
  '--drop and --prune write one root commit and force-move the ref,',
1421
1480
  'since a delete commit would keep every image reachable through history.',
1422
1481
  'A rewrite that empties the branch deletes it. --prune keeps a pull',
@@ -1430,15 +1489,17 @@ export function register(program: Command): void {
1430
1489
  '',
1431
1490
  'Read `reason` on the JSON record:',
1432
1491
  ' ok an add pushed, or a drop or prune finished',
1433
- ' bad-mode not exactly one of --add, --drop, --prune',
1434
- ' no-number --add or --drop named no pull request',
1435
- ' 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',
1436
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',
1437
1496
  ' bad-head --head is not 7 to 40 lowercase hex characters',
1438
- ' bad-pass --add without a compact UTC --pass stamp',
1497
+ ' bad-pass --add or --reel without a compact UTC --pass stamp',
1439
1498
  ' bad-theme --theme is not dark or light, or no --add, or rides a --reference',
1440
1499
  ' bad-days --prune without a positive number of days',
1441
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`,
1442
1503
  ' read-only GitHub refused the write, as on a fork token',
1443
1504
  ' unreadable-tip the branch could not be read',
1444
1505
  ' push-failed a write failed and the ref did not move',
@@ -1453,6 +1514,7 @@ export function register(program: Command): void {
1453
1514
  '',
1454
1515
  'Examples:',
1455
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',
1456
1518
  ' canon pr frames 1341 --drop --json',
1457
1519
  ' canon pr frames --prune 30 --json',
1458
1520
  '',
@@ -2366,6 +2428,31 @@ function everyChangedPath(files: PullFiles): string[] {
2366
2428
  return [...files.present.keys(), ...files.removed]
2367
2429
  }
2368
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
+
2369
2456
  /**
2370
2457
  * The commit the pull request diverged from its base at, from the compare
2371
2458
  * endpoint, since a checkout on another branch resolves its own trunk tip
@@ -2386,25 +2473,30 @@ async function readPullMergeBase(
2386
2473
  return sha === null || sha.trim() === '' ? undefined : sha.trim()
2387
2474
  }
2388
2475
 
2389
- /** How many width reads run at once, since each downloads a whole image. */
2390
- 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
2391
2478
 
2392
- type WidthRead =
2393
- | { readonly kind: 'read'; readonly width?: number }
2479
+ type ImageRead =
2480
+ | {
2481
+ readonly kind: 'read'
2482
+ readonly width?: number
2483
+ readonly bytes?: Uint8Array
2484
+ }
2394
2485
  | { readonly kind: 'failed' }
2395
2486
 
2396
2487
  /**
2397
- * The pixel width of a PNG at `sha`, read over the contents API like every
2398
- * other evidence read. The endpoint ignores a `Range` header, so the whole
2399
- * file arrives and only its first bytes are read. A format other than PNG
2400
- * reads without a width, and a refused or timed-out call reads as failed, so
2401
- * 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.
2402
2494
  */
2403
- async function readImageWidth(
2495
+ async function readImage(
2404
2496
  cwd: string,
2405
2497
  path: string,
2406
2498
  sha: string,
2407
- ): Promise<WidthRead> {
2499
+ ): Promise<ImageRead> {
2408
2500
  if (!/\.png$/i.test(path)) return { kind: 'read' }
2409
2501
  try {
2410
2502
  const result = await execa(
@@ -2423,8 +2515,11 @@ async function readImageWidth(
2423
2515
  encoding: 'buffer',
2424
2516
  },
2425
2517
  )
2426
- const width = readPngWidth(result.stdout.subarray(0, PNG_HEADER_BYTES))
2427
- 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 }
2428
2523
  } catch {
2429
2524
  return { kind: 'failed' }
2430
2525
  }
@@ -2452,66 +2547,79 @@ async function mapPool<T, R>(
2452
2547
 
2453
2548
  interface SizedEvidence {
2454
2549
  readonly reading: EvidenceReading
2455
- /** 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. */
2551
+ readonly unread: readonly string[]
2552
+ }
2553
+
2554
+ interface MeasuredItem {
2555
+ readonly item: EvidenceItem
2456
2556
  readonly unread: readonly string[]
2457
2557
  }
2458
2558
 
2459
- /** Each item with the pixel width of its head image and, when it has one, its base image. */
2460
- async function withImageWidths(
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(
2461
2600
  cwd: string,
2462
2601
  reading: EvidenceReading,
2463
2602
  base: string | undefined,
2464
2603
  head: string,
2465
2604
  ): Promise<SizedEvidence> {
2466
2605
  if (reading.kind === 'refused') return { reading, unread: [] }
2467
- const jobs = reading.states.flatMap((entry, stateIndex) =>
2468
- entry.items.flatMap((item, itemIndex) => [
2469
- {
2470
- stateIndex,
2471
- itemIndex,
2472
- side: 'head' as const,
2473
- path: item.path,
2474
- sha: head,
2475
- },
2476
- ...(item.added || base === undefined
2477
- ? []
2478
- : [
2479
- {
2480
- stateIndex,
2481
- itemIndex,
2482
- side: 'base' as const,
2483
- path: item.path,
2484
- sha: base,
2485
- },
2486
- ]),
2487
- ]),
2606
+ const measured = await mapPool(
2607
+ reading.states.flatMap((entry) => entry.items),
2608
+ IMAGE_READ_CONCURRENCY,
2609
+ (item) => measureItem(cwd, item, base, head),
2488
2610
  )
2489
- const reads = await mapPool(jobs, WIDTH_READ_CONCURRENCY, (job) =>
2490
- readImageWidth(cwd, job.path, job.sha),
2491
- )
2492
- const unread: string[] = []
2493
- const states = reading.states.map((entry, stateIndex) => ({
2494
- ...entry,
2495
- items: entry.items.map((item, itemIndex): EvidenceItem => {
2496
- const widths: { width?: number; baseWidth?: number } = {}
2497
- jobs.forEach((job, index) => {
2498
- const read = reads[index]
2499
- if (
2500
- job.stateIndex !== stateIndex ||
2501
- job.itemIndex !== itemIndex ||
2502
- read === undefined
2503
- ) {
2504
- return
2505
- }
2506
- if (read.kind === 'failed') unread.push(`${job.side}:${job.path}`)
2507
- else if (read.width !== undefined) {
2508
- widths[job.side === 'head' ? 'width' : 'baseWidth'] = read.width
2509
- }
2510
- })
2511
- return { ...item, ...widths }
2512
- }),
2513
- }))
2514
- 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
+ }
2515
2623
  }
2516
2624
 
2517
2625
  async function runEvidence(
@@ -2528,7 +2636,8 @@ async function runEvidence(
2528
2636
  isCheck &&
2529
2637
  (opts.preview !== undefined ||
2530
2638
  opts.local !== undefined ||
2531
- opts.checklist !== undefined)
2639
+ opts.checklist !== undefined ||
2640
+ opts.reel !== undefined)
2532
2641
  ) {
2533
2642
  return refuseWith(
2534
2643
  'check-writes',
@@ -2581,9 +2690,10 @@ async function runEvidence(
2581
2690
 
2582
2691
  // The check never reads the merge base, so it skips the compare call and
2583
2692
  // a failed one cannot hold a draft lift for a reason the check never had.
2584
- const [pullFiles, base] = await Promise.all([
2693
+ const [pullFiles, base, keepsCaptures] = await Promise.all([
2585
2694
  listPullFiles(root, identity.number),
2586
2695
  isCheck ? undefined : readPullMergeBase(root, identity.base, identity.head),
2696
+ readBaseKeepsCaptures(root, identity.base),
2587
2697
  ])
2588
2698
  if (pullFiles === undefined) {
2589
2699
  return refuseWith(
@@ -2598,6 +2708,11 @@ async function runEvidence(
2598
2708
  [...pullFiles.present.keys()],
2599
2709
  async (path) => pullFiles.present.get(path) === false,
2600
2710
  )
2711
+ const uncaptured = readUncaptured(
2712
+ everyChangedPath(pullFiles),
2713
+ loadPaintedPatterns(root),
2714
+ keepsCaptures,
2715
+ )
2601
2716
 
2602
2717
  let existingCases = 0
2603
2718
  let commentId: number | undefined
@@ -2623,12 +2738,14 @@ async function runEvidence(
2623
2738
  carriedPreview = findEvidencePreview(comments)
2624
2739
  carriedChecklist = findEvidenceChecklist(comments)
2625
2740
  carriedLocal = findEvidenceLocal(comments)
2741
+ const carriedReel = findEvidenceReel(comments)
2626
2742
 
2627
2743
  // What the marked comment already shows a reviewer, kept apart from the
2628
2744
  // flags this call passed so a reader learns what is posted.
2629
2745
  const carried = {
2630
2746
  ...(carriedPreview !== undefined && { preview: carriedPreview }),
2631
2747
  ...(carriedLocal !== undefined && { local: carriedLocal }),
2748
+ ...(carriedReel !== undefined && { reel: carriedReel }),
2632
2749
  ...(carriedChecklist !== undefined && {
2633
2750
  checklist: carriedChecklist,
2634
2751
  boxes: readChecklistBoxes(carriedChecklist),
@@ -2643,6 +2760,16 @@ async function runEvidence(
2643
2760
  servesChange(deploy.filter, everyChangedPath(pullFiles))
2644
2761
  const isUnserved = deploy.kind === 'found' && !deployServesChange
2645
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
+
2646
2773
  if (isCheck) {
2647
2774
  const owed = readOwed({
2648
2775
  hasEvidenceChange: grouped.kind === 'read',
@@ -2656,6 +2783,7 @@ async function runEvidence(
2656
2783
  ? 'Nothing the ship chain posts after the pull request is missing.'
2657
2784
  : `missing ${owed.join(', ')}`,
2658
2785
  )
2786
+ warnUncaptured()
2659
2787
  outro()
2660
2788
  if (emitJson) {
2661
2789
  process.stdout.write(
@@ -2664,6 +2792,7 @@ async function runEvidence(
2664
2792
  number: identity.number,
2665
2793
  reason: owed.length === 0 ? 'settled' : 'owed',
2666
2794
  owed,
2795
+ ...uncapturedField,
2667
2796
  ...(commentId !== undefined && { commentId }),
2668
2797
  ...carried,
2669
2798
  })}\n`,
@@ -2691,6 +2820,7 @@ async function runEvidence(
2691
2820
  logInfo(
2692
2821
  'No changed path carries an evidence/ segment, so there is nothing to post.',
2693
2822
  )
2823
+ warnUncaptured()
2694
2824
  outro()
2695
2825
  if (emitJson) {
2696
2826
  process.stdout.write(
@@ -2698,6 +2828,7 @@ async function runEvidence(
2698
2828
  root,
2699
2829
  ...(identity.number !== undefined && { number: identity.number }),
2700
2830
  reason: 'no-evidence',
2831
+ ...uncapturedField,
2701
2832
  ...carried,
2702
2833
  })}\n`,
2703
2834
  )
@@ -2717,12 +2848,13 @@ async function runEvidence(
2717
2848
 
2718
2849
  const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
2719
2850
  const local = opts.local ?? carriedLocal
2851
+ const reel = nextEvidenceReel(opts.reel, identity.head, comments)
2720
2852
  const unsettled = suppliedChecklist ?? carriedChecklist
2721
2853
  const checklist =
2722
2854
  unsettled === undefined
2723
2855
  ? undefined
2724
2856
  : settleChecklist(unsettled, identity.head, findEvidenceHead(comments))
2725
- const sized = await withImageWidths(root, grouped, base, identity.head)
2857
+ const sized = await withImageReads(root, grouped, base, identity.head)
2726
2858
  const states = sized.reading.kind === 'read' ? sized.reading.states : []
2727
2859
  const body = renderEvidenceBody(
2728
2860
  states,
@@ -2732,6 +2864,7 @@ async function runEvidence(
2732
2864
  preview,
2733
2865
  checklist,
2734
2866
  local,
2867
+ reel,
2735
2868
  )
2736
2869
 
2737
2870
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -2789,6 +2922,13 @@ async function runEvidence(
2789
2922
  }
2790
2923
  if (preview !== undefined) logInfo(`preview ${preview}`)
2791
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
+ }
2792
2932
  if (checklist !== undefined) {
2793
2933
  logInfo(
2794
2934
  suppliedChecklist === undefined
@@ -2796,6 +2936,7 @@ async function runEvidence(
2796
2936
  : 'checklist folded in below the comparison',
2797
2937
  )
2798
2938
  }
2939
+ warnUncaptured()
2799
2940
 
2800
2941
  outro()
2801
2942
 
@@ -2811,6 +2952,7 @@ async function runEvidence(
2811
2952
  states,
2812
2953
  ...(sized.unread.length > 0 && { widthUnread: sized.unread }),
2813
2954
  ...(uiPass !== undefined && { uiPass }),
2955
+ ...uncapturedField,
2814
2956
  ...(commentId !== undefined && { commentId }),
2815
2957
  ...carried,
2816
2958
  })}\n`,
@@ -3672,6 +3814,7 @@ function framesArgumentRefusal(
3672
3814
  ): FramesCommandRefusal | undefined {
3673
3815
  const modes = [
3674
3816
  opts.add !== undefined,
3817
+ opts.reel !== undefined,
3675
3818
  opts.drop === true,
3676
3819
  opts.prune !== undefined,
3677
3820
  ]
@@ -3681,6 +3824,12 @@ function framesArgumentRefusal(
3681
3824
  return POSITIVE_WHOLE.test(opts.prune) ? undefined : 'bad-days'
3682
3825
  }
3683
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
+ }
3684
3833
  if (
3685
3834
  opts.add !== undefined &&
3686
3835
  (opts.box === undefined) === (opts.reference === undefined)
@@ -3697,7 +3846,7 @@ function framesArgumentRefusal(
3697
3846
  }
3698
3847
  if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
3699
3848
  if (
3700
- opts.add !== undefined &&
3849
+ (opts.add !== undefined || opts.reel !== undefined) &&
3701
3850
  (opts.pass === undefined || !isFramePass(opts.pass))
3702
3851
  ) {
3703
3852
  return 'bad-pass'
@@ -3765,6 +3914,10 @@ async function runFrames(
3765
3914
  if (opts.add !== undefined && bytes === undefined) {
3766
3915
  return refuseFrames('unreadable-frame')
3767
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')
3768
3921
 
3769
3922
  if (Bun.which('gh') === null) return refuseFrames('gh-missing')
3770
3923
  const runner = ghApiRunner(root)
@@ -3840,14 +3993,19 @@ async function runFrames(
3840
3993
  box,
3841
3994
  reference,
3842
3995
  theme,
3843
- bytes: bytes ?? new Uint8Array(),
3996
+ reel: reel !== undefined,
3997
+ bytes: reel?.bytes ?? bytes ?? new Uint8Array(),
3844
3998
  })
3845
3999
  if (result.kind === 'refused') return refuseWrite(result.reason)
3846
4000
  const link = frameLink(repo, result.path)
3847
4001
  return finish(
3848
4002
  {
3849
4003
  number: pull,
3850
- ...(box === undefined ? { reference } : { box }),
4004
+ ...(reel !== undefined
4005
+ ? { reel: true }
4006
+ : box === undefined
4007
+ ? { reference }
4008
+ : { box }),
3851
4009
  ...(theme === undefined ? {} : { theme }),
3852
4010
  head,
3853
4011
  pass,