@erclx/canon 5.77.0 → 5.79.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 (125) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -43,6 +43,8 @@ import {
43
43
  isEvidencePath,
44
44
  PNG_HEADER_BYTES,
45
45
  readChecklistBoxes,
46
+ countDrivable,
47
+ type ChecklistBox,
46
48
  readOwed,
47
49
  readPngWidth,
48
50
  readUncaptured,
@@ -51,6 +53,7 @@ import {
51
53
  settleChecklist,
52
54
  } from '@/pr/evidence'
53
55
  import { isWithinTolerance } from '@/pr/pixels'
56
+ import { REEL_MAX_BYTES } from '@/take/reel'
54
57
  import {
55
58
  dropFramesWithLegacy,
56
59
  FRAMES_BRANCH,
@@ -64,7 +67,6 @@ import {
64
67
  LEGACY_FRAMES_BRANCH,
65
68
  pruneFramesWithLegacy,
66
69
  pushFrame,
67
- REEL_MAX_BYTES,
68
70
  readFrame,
69
71
  readReel,
70
72
  isDesignReference,
@@ -83,6 +85,7 @@ import {
83
85
  type PullState,
84
86
  readBaseStanding,
85
87
  readChangedPaths,
88
+ readsDrivableBox,
86
89
  } from '@/pr/lift'
87
90
  import { close, type CloseApi, type CloseRefusal } from '@/pr/close'
88
91
  import {
@@ -110,6 +113,7 @@ import {
110
113
  type LocalRefusal,
111
114
  type LocalRunner,
112
115
  } from '@/pr/local'
116
+ import { linkKeyChanges } from '@/pr/links'
113
117
  import { KEY_CHANGES } from '@/pr/paths'
114
118
  import {
115
119
  findDeployWorkflow,
@@ -158,6 +162,7 @@ const GH_TIMEOUT_MS = 30_000
158
162
  const UNNAMED_PRINT_LIMIT = 10
159
163
 
160
164
  interface KeyChangesOptions {
165
+ readonly link?: boolean
161
166
  readonly body?: string
162
167
  readonly base?: string
163
168
  readonly root?: string
@@ -171,7 +176,8 @@ interface ReadOptions {
171
176
 
172
177
  interface EvidenceOptions extends ReadOptions {
173
178
  readonly preview?: string
174
- readonly checklist?: string
179
+ /** A path to fold in, or false from `--no-checklist` to drop the carried one. */
180
+ readonly checklist?: string | false
175
181
  readonly local?: string
176
182
  readonly reel?: string
177
183
  readonly localSkip?: string
@@ -255,7 +261,7 @@ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
255
261
  'no-box':
256
262
  'A named box is past the end of the checklist. Number boxes off `canon pr evidence --json`.',
257
263
  'taste-box':
258
- 'A named box ends in (taste), which no driver passes. Nothing was written.',
264
+ 'A named box carries (taste) among its trailing markers, which no driver passes. Nothing was written.',
259
265
  'gh-failed':
260
266
  'gh could not read or edit the marked comment on this pull request.',
261
267
  }
@@ -275,7 +281,7 @@ const LOCAL_REFUSALS: Record<
275
281
  'Nothing listening inside this worktree served an HTML page. Start the dev server here and re-run to get a link.',
276
282
  unhealthy:
277
283
  'A page answered inside this worktree, but a health path it declares failed. Start what the page reaches and re-run to get a link.',
278
- 'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run.`,
284
+ 'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run. health takes paths starting with /, and page-only takes folders relative to the repository.`,
279
285
  'no-listener-reader':
280
286
  'This machine offers neither lsof nor /proc, so no listening socket could be read. Nothing is posted.',
281
287
  'gh-failed':
@@ -438,7 +444,7 @@ type EvidenceRefusal =
438
444
 
439
445
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
440
446
  'check-writes':
441
- '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, --reel, --local-skip, or --reel-skip. Run the check and the render as two calls.',
447
+ '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, --no-checklist, --reel, --local-skip, or --reel-skip. Run the check and the render as two calls.',
442
448
  'gh-failed':
443
449
  'gh could not answer for this repository. Name the pull request number.',
444
450
  'unreadable-checklist':
@@ -499,13 +505,17 @@ export function register(program: Command): void {
499
505
  .helpOption('-h, --help', 'Show this help message')
500
506
  .option(
501
507
  '--body <path>',
502
- 'Read the body from a file rather than the API, ignoring any number',
508
+ 'Read the body from a file rather than the API, ignoring any number except the one --link needs',
503
509
  )
504
510
  .option(
505
511
  '--base <ref>',
506
512
  'Far side of the range when --body supplies the body',
507
513
  )
508
514
  .option('--root <path>', 'Repository to read, defaulting to the cwd')
515
+ .option(
516
+ '--link',
517
+ `Print the body with each ## ${KEY_CHANGES} path linked to its diff, needing a number`,
518
+ )
509
519
  .option('--json', 'Add a machine-readable record on stdout')
510
520
  .addHelpText(
511
521
  'after',
@@ -534,6 +544,13 @@ export function register(program: Command): void {
534
544
  'points the change at a locative the path does not name. Read the bullet on',
535
545
  "the record's preview before filing an unmet path as a stale claim.",
536
546
  '',
547
+ '--link prints the body and nothing else, with every path the section',
548
+ 'names that the diff changes written as a link to its row in the Files',
549
+ 'tab. The anchor is `diff-` and the SHA-256 of the path. Pass the pull',
550
+ 'request number, since the link needs it. It also rewrites the anchor',
551
+ 'of a link opening another file, and a plain run reports that link as',
552
+ "mislinked when its label is a changed path and its anchor hashes another's.",
553
+ '',
537
554
  'Refusals, read off `reason`:',
538
555
  ' no-section the body carries no `## Key changes` heading, so it',
539
556
  ' claims nothing',
@@ -551,14 +568,18 @@ export function register(program: Command): void {
551
568
  ' gh-missing `gh` is not on the path, so no pull request could be',
552
569
  ' resolved',
553
570
  ' gh-failed `gh` could not answer for this repository or branch',
571
+ ' no-number --link was given with no pull request number',
572
+ ' no-repo-url --link could not read the repository address',
573
+ ' link-json --link was given with --json, though it prints the body',
554
574
  '',
555
575
  'Exit codes:',
556
- ' 0 every claimed path is in the diff',
576
+ ' 0 every claimed path is in the diff, and every link opens its own file',
557
577
  ' 1 refused, with the reason on stderr or in the JSON record',
558
- ' 2 at least one claimed path is absent from the diff',
578
+ ' 2 a claimed path is absent from the diff, or a link opens another file',
559
579
  '',
560
580
  'Examples:',
561
581
  ' canon pr key-changes',
582
+ ' canon pr key-changes 1265 --link --body .canon/tmp/body.md',
562
583
  ' canon pr key-changes 1265 --json',
563
584
  ' canon pr key-changes --body .canon/tmp/body.md --base origin/main',
564
585
  '',
@@ -709,9 +730,11 @@ export function register(program: Command): void {
709
730
  '',
710
731
  '`ui` also carries the newest `## UI review` pass (`heading`, `state`,',
711
732
  '`commit`, `source`), `isBound`, which is whether the evidence comment',
712
- 'carries a checklist, and `carriedFrom` when the lift carried a close',
713
- 'forward, or `drivenPaths` and the painted subset `paintedPaths` when it',
714
- 'refused to. An unbound family reads covered.',
733
+ 'carries a checklist holding a box a driver can run or the newest UI',
734
+ 'pass is open, so a checklist of taste boxes alone binds nothing until',
735
+ 'a pass opens, and `carriedFrom` when the lift carried a close',
736
+ 'forward, or `drivenPaths` and the painted subset `paintedPaths` when',
737
+ 'it refused to. An unbound family reads covered.',
715
738
  '',
716
739
  'Both families post as pull request reviews, so this reads',
717
740
  '`pulls/<n>/reviews` alone, as the lift does. Issue comments are not',
@@ -772,6 +795,10 @@ export function register(program: Command): void {
772
795
  '--checklist <path>',
773
796
  'Close the body with this visual checklist file, from canon:ui-checklist',
774
797
  )
798
+ .option(
799
+ '--no-checklist',
800
+ 'Drop the checklist the marked comment carries, rendering the body without it',
801
+ )
775
802
  .option(
776
803
  '--local <url>',
777
804
  "Add this worktree's running server to the address line, from canon pr local",
@@ -809,8 +836,9 @@ export function register(program: Command): void {
809
836
  'Read `reason` on the JSON record before posting anything:',
810
837
  ' ok a body was rendered, with `commentId` set when a marked',
811
838
  ' comment already exists and should be edited in place',
812
- ' no-evidence nothing in the diff carries an evidence/ segment, which',
813
- ' is an ordinary, silent no-op rather than a refusal',
839
+ ' no-evidence nothing in the diff carries an evidence/ segment and no',
840
+ ' painted path is uncaptured, which is an ordinary,',
841
+ ' silent no-op rather than a refusal',
814
842
  ' would-empty the render holds no cases and the marked comment holds',
815
843
  ' some, so no body is printed and a person edits the comment',
816
844
  '',
@@ -853,6 +881,12 @@ export function register(program: Command): void {
853
881
  'branch with a checklist and no evidence image renders a marked body',
854
882
  'holding the checklist, so a later call finds and edits that comment.',
855
883
  '',
884
+ '--no-checklist drops the checklist the marked comment carries, so the',
885
+ 'body renders without it and the record reports no checklist fields.',
886
+ 'A marked comment holding a checklist still renders as ok rather than',
887
+ 'no-evidence, so the comment is edited rather than left showing it.',
888
+ 'Of --checklist and --no-checklist, the last one passed wins.',
889
+ '',
856
890
  '--local adds a **Local preview:** segment after any hosted one on the',
857
891
  'address line, or carries the line alone, and is carried forward the',
858
892
  'same way. It does not by itself turn no-evidence into ok, so a',
@@ -891,9 +925,13 @@ export function register(program: Command): void {
891
925
  ' checklist the checklist between its delimiters, ticks included',
892
926
  ' boxes each checklist box with its number, tick, stamp, and taste',
893
927
  ' mark, the numbering `canon pr tick` takes',
928
+ ' drivable how many boxes a driver can run, every box but one whose',
929
+ ' trailing markers hold (taste), in any order. Zero binds no',
930
+ ' UI review, so `canon pr lift` and `review-state` leave the',
931
+ ' UI family unbound',
894
932
  'These come from the comment already posted, never from the flags this',
895
933
  'call passed. A checklist the caller posted raw after a refused render',
896
- 'carries no marker, so the record reports none of the three for it. An',
934
+ 'carries no marker, so the record reports none of these for it. An',
897
935
  'unread thread refuses as gh-failed on both reasons rather than',
898
936
  'reporting the fields absent.',
899
937
  '',
@@ -907,6 +945,14 @@ export function register(program: Command): void {
907
945
  'unchecked rather than clean. It never adds to `owed`, since no step of',
908
946
  'the ship chain can take a capture.',
909
947
  '',
948
+ 'A rendered body carries the list as an **Uncaptured:** line under the',
949
+ 'addresses, each path in a code span. A non-empty list opens a comment',
950
+ 'the way a checklist does, so a head whose only news is an uncaptured',
951
+ 'path reports ok rather than no-evidence. The line is computed on every',
952
+ 'render and never carried, so it drops once a capture empties the list',
953
+ 'or a reel filmed at the head renders beside it. `canon pr lift`',
954
+ 'refuses `painted-uncaptured` on the same list.',
955
+ '',
910
956
  '--check reads the same changed set and thread, renders no body, and',
911
957
  'reports what the ship chain still owes the pull request:',
912
958
  ' settled nothing is owed',
@@ -917,8 +963,8 @@ export function register(program: Command): void {
917
963
  'request changed, and the marked comment carries no **Preview:**',
918
964
  'address. A failed deploy reads the same as a skipped one.',
919
965
  'The record carries `owed` and the same marked fields. --check refuses',
920
- 'with --preview, --local, --checklist, --reel, --local-skip, or',
921
- '--reel-skip as check-writes.',
966
+ 'with --preview, --local, --checklist, --no-checklist, --reel,',
967
+ '--local-skip, or --reel-skip as check-writes.',
922
968
  '',
923
969
  'Refusals, read off `reason`:',
924
970
  ' unreadable-checklist --checklist named a path that could not be read,',
@@ -973,7 +1019,9 @@ export function register(program: Command): void {
973
1019
  'they are read:',
974
1020
  ' review the newest `## Review` is closed at the head, off its marker',
975
1021
  ' ui the newest `## UI review` is closed at the head, bound only',
976
- ' where `canon pr evidence --check` carries a checklist. A close',
1022
+ ' where `canon pr evidence --check` carries a checklist whose',
1023
+ ' `drivable` count is not zero, a missing count binding. An',
1024
+ ' open UI review refuses `ui-open` whatever the count. A close',
977
1025
  ' on an earlier commit still holds when it is an ancestor of the',
978
1026
  ' head and every path changed since is one no box can observe,',
979
1027
  ' being tests, stories, snapshots, markdown in the root and in',
@@ -984,7 +1032,19 @@ export function register(program: Command): void {
984
1032
  ' a box drives behavior a `.ts` file holds too. A close whose',
985
1033
  ' count line shows boxes not driven and none passed, failed,',
986
1034
  ' or needing eyes refuses `ui-undriven`, carried or not',
987
- ' evidence the evidence check reads settled',
1035
+ ' evidence the evidence check reads settled, and every painted path',
1036
+ ' it lists as `uncaptured` is covered. A reel the marked',
1037
+ ' comment embeds at the head covers every path, and a',
1038
+ ' `- **Waived capture:** `<path>`: <reason>` line outside a code',
1039
+ ' fence covers the path it names as the list spells it. Only',
1040
+ ' the newest `## Review` and the newest `## UI review` count,',
1041
+ ' each closed by its own marker and not dismissed, so a pass',
1042
+ ' must restate a waiver for it to hold on a later head. A',
1043
+ ' review with no pass heading waives nothing. Any other path',
1044
+ ' refuses `painted-uncaptured`,',
1045
+ ' the gate entry naming what is left as `uncaptured` and what',
1046
+ ' a review waived as `waived`, each sorted. An absent list,',
1047
+ ' in a project whose base keeps no captures, passes',
988
1048
  ' standing the head is fresh against the base, not stale or conflicted',
989
1049
  ' ci every required check run on the tip passed, waited on up to --wait',
990
1050
  '',
@@ -1014,7 +1074,8 @@ export function register(program: Command): void {
1014
1074
  ' lifted, already-ready, redrafted, clear (--check)',
1015
1075
  ' pull-closed, review-missing, review-open, review-unmarked,',
1016
1076
  ' review-behind, ui-missing, ui-open, ui-unmarked, ui-behind,',
1017
- ' ui-undriven, evidence-owed, standing-stale, standing-conflict, standing-unknown,',
1077
+ ' ui-undriven, evidence-owed, painted-uncaptured, standing-stale,',
1078
+ ' standing-conflict, standing-unknown,',
1018
1079
  ' ci-pending (--check), ci-failing, ci-conflicted, ci-timeout,',
1019
1080
  ' head-moved, gh-failed',
1020
1081
  '',
@@ -1257,6 +1318,15 @@ export function register(program: Command): void {
1257
1318
  "refusal names the lowest page port's first failing path. An absent",
1258
1319
  'file or an empty list checks the page alone.',
1259
1320
  '',
1321
+ 'A page served by a static server answers none of those paths, so',
1322
+ 'page-only = ["web"] lists repository-relative folders whose',
1323
+ 'listeners skip them and are found on the page probe alone. The match',
1324
+ "reads the listener's working directory on whole path segments, so web",
1325
+ 'does not cover webapp, and a server started from the root with a',
1326
+ 'working-directory flag still takes the health check. Keep health',
1327
+ 'beside page-only until the binary a project runs carries this key,',
1328
+ 'since an older one refuses a file holding page-only alone.',
1329
+ '',
1260
1330
  'A process in a sibling worktree, in the main checkout seen from a',
1261
1331
  'linked worktree, or in a linked worktree seen from the main checkout',
1262
1332
  'is never reported, since its server shows another branch. Sockets are',
@@ -1282,8 +1352,9 @@ export function register(program: Command): void {
1282
1352
  ' unhealthy a page answered but a declared health path did',
1283
1353
  ' not, with `port`, `path`, and `status`, which',
1284
1354
  ' is null on a timeout',
1285
- ' invalid-config canon/config/local.toml is not valid TOML, or',
1286
- ' health is not a list of paths starting with /',
1355
+ ' invalid-config canon/config/local.toml is not valid TOML,',
1356
+ ' health is not a list of paths starting with /,',
1357
+ ' or page-only is not a list of relative folders',
1287
1358
  ' removed --remove dropped the line',
1288
1359
  ' no-comment --remove found no marked comment, a no-op',
1289
1360
  ' no-line --remove found no address line, a no-op',
@@ -1367,7 +1438,7 @@ export function register(program: Command): void {
1367
1438
  ' nothing written, with `driven`, `head`, and `drivenPaths`',
1368
1439
  ' stale-head --head is behind the tip with no readable linear gap,',
1369
1440
  ' as a force-push or a rebase leaves, nothing written',
1370
- ' taste-box a named box ends in (taste), nothing written',
1441
+ ' taste-box a named box carries (taste), nothing written',
1371
1442
  ' no-box a named number is past the checklist, nothing written',
1372
1443
  ' no-checklist the evidence comment carries no checklist',
1373
1444
  ' no-comment no comment carries the evidence marker',
@@ -1821,6 +1892,8 @@ async function runKeyChanges(
1821
1892
  const root = resolve(opts.root ?? process.cwd())
1822
1893
  const emitJson = opts.json ?? false
1823
1894
 
1895
+ if (opts.link === true) return runKeyChangesLink(number, opts, root)
1896
+
1824
1897
  intro('canon pr key-changes')
1825
1898
 
1826
1899
  const source =
@@ -1905,6 +1978,14 @@ async function runKeyChanges(
1905
1978
  for (const claim of report.unresolved) logInfo(claim.path)
1906
1979
  }
1907
1980
 
1981
+ if (report.mislinked.length > 0) {
1982
+ logStep('Mislinked')
1983
+ logWarn(
1984
+ `${plural(report.mislinked.length, 'link')} open another file's diff than the path they name. Re-run with --link to print the body with them corrected.`,
1985
+ )
1986
+ for (const link of report.mislinked) logWarn(link.path)
1987
+ }
1988
+
1908
1989
  outro()
1909
1990
 
1910
1991
  if (emitJson) {
@@ -1922,11 +2003,71 @@ async function runKeyChanges(
1922
2003
  incidental: report.incidental,
1923
2004
  unresolved: report.unresolved,
1924
2005
  evidenceUnread: report.evidenceUnread,
2006
+ mislinked: report.mislinked,
1925
2007
  })}\n`,
1926
2008
  )
1927
2009
  }
1928
2010
 
1929
- return report.unmet.length === 0 ? 0 : 2
2011
+ return report.unmet.length === 0 && report.mislinked.length === 0 ? 0 : 2
2012
+ }
2013
+
2014
+ /**
2015
+ * Prints the body with each changed path in `## Key changes` linked to its
2016
+ * diff, and nothing else, so stdout pipes straight into a body file.
2017
+ *
2018
+ * Skips the frame `intro` opens, which would land in the piped body. Refusals
2019
+ * keep the framed shape on stdout like every other mode.
2020
+ */
2021
+ async function runKeyChangesLink(
2022
+ number: string | undefined,
2023
+ opts: KeyChangesOptions,
2024
+ root: string,
2025
+ ): Promise<number> {
2026
+ const emitJson = opts.json ?? false
2027
+
2028
+ if (emitJson) {
2029
+ return refuseWith(
2030
+ 'link-json',
2031
+ '--link prints the body itself, so it takes no --json record.',
2032
+ emitJson,
2033
+ root,
2034
+ )
2035
+ }
2036
+
2037
+ const source =
2038
+ opts.body === undefined
2039
+ ? await readFromApi(root, number)
2040
+ : await readFromFile(root, process.cwd(), opts.body, opts.base)
2041
+ if (source.kind === 'refused') return refuse(source.reason, emitJson, root)
2042
+
2043
+ const pull = number ?? source.source.number?.toString()
2044
+ if (pull === undefined) {
2045
+ return refuseWith(
2046
+ 'no-number',
2047
+ 'The link needs the pull request number, so pass it as the argument.',
2048
+ emitJson,
2049
+ root,
2050
+ )
2051
+ }
2052
+
2053
+ const url = await readRepoUrl(root)
2054
+ if (url === undefined) {
2055
+ return refuseWith(
2056
+ 'no-repo-url',
2057
+ 'gh could not read the repository address, so no link was built.',
2058
+ emitJson,
2059
+ root,
2060
+ )
2061
+ }
2062
+
2063
+ process.stdout.write(
2064
+ linkKeyChanges(
2065
+ source.source.body,
2066
+ source.source.changed,
2067
+ `${url}/pull/${pull}/files`,
2068
+ ),
2069
+ )
2070
+ return 0
1930
2071
  }
1931
2072
 
1932
2073
  type IdentityRead =
@@ -2065,7 +2206,7 @@ async function listReviews(cwd: string, number: string) {
2065
2206
  return listPages<RestReview>(
2066
2207
  cwd,
2067
2208
  `repos/{owner}/{repo}/pulls/${number}/reviews`,
2068
- 'id, body, commit_id, submitted_at',
2209
+ 'id, body, commit_id, submitted_at, state',
2069
2210
  )
2070
2211
  }
2071
2212
 
@@ -2439,7 +2580,9 @@ function logReviewState(number: number, record: ReviewStateRecord): void {
2439
2580
  else logWarn(line)
2440
2581
  }
2441
2582
  if (!record.ui.isBound) {
2442
- logInfo('ui: the evidence carries no checklist, so the family is unbound')
2583
+ logInfo(
2584
+ 'ui: the evidence carries no box a driver can run, so the family is unbound',
2585
+ )
2443
2586
  }
2444
2587
  }
2445
2588
 
@@ -2448,14 +2591,18 @@ interface PullFiles {
2448
2591
  readonly present: ReadonlyMap<string, boolean>
2449
2592
  /** Each path the pull request removed, which a deploy's push filter still counts. */
2450
2593
  readonly removed: readonly string[]
2594
+ /** Each renamed path mapped to the path it sat at before the pull request. */
2595
+ readonly renamedFrom: ReadonlyMap<string, string>
2451
2596
  }
2452
2597
 
2453
2598
  /**
2454
2599
  * The paths a pull request changed, read through the paginated files endpoint
2455
2600
  * so the set is the same from any checkout and never capped at the first
2456
- * view. `renamed` and `copied` count as added, since the new path has no
2457
- * counterpart at the merge base. Returns undefined when the read fails, which
2458
- * refuses rather than rendering a short set.
2601
+ * view. `copied` counts as added, since a copy leaves its source in place and
2602
+ * has no single base. `renamed` counts as added in `present` too, so the
2603
+ * changed set stays the same, and `renamedFrom` carries the old path of each
2604
+ * rename for a reader that compares against the base. Returns undefined when
2605
+ * the read fails, which refuses rather than rendering a short set.
2459
2606
  */
2460
2607
  async function listPullFiles(
2461
2608
  cwd: string,
@@ -2466,18 +2613,22 @@ async function listPullFiles(
2466
2613
  '--paginate',
2467
2614
  `repos/{owner}/{repo}/pulls/${number}/files`,
2468
2615
  '--jq',
2469
- '.[] | [.status, .filename] | @tsv',
2616
+ '.[] | [.status, .filename, (.previous_filename // "")] | @tsv',
2470
2617
  ])
2471
2618
  if (stdout === null) return undefined
2472
2619
  const present = new Map<string, boolean>()
2473
2620
  const removed: string[] = []
2621
+ const renamedFrom = new Map<string, string>()
2474
2622
  for (const line of stdout.split('\n').filter(Boolean)) {
2475
- const [status, path] = line.split('\t')
2623
+ const [status, path, previous] = line.split('\t')
2476
2624
  if (status === undefined || path === undefined) continue
2477
2625
  if (status === 'removed') removed.push(path)
2478
2626
  else present.set(path, status !== 'modified' && status !== 'changed')
2627
+ if (status === 'renamed' && previous !== undefined && previous !== '') {
2628
+ renamedFrom.set(path, previous)
2629
+ }
2479
2630
  }
2480
- return { present, removed }
2631
+ return { present, removed, renamedFrom }
2481
2632
  }
2482
2633
 
2483
2634
  function everyChangedPath(files: PullFiles): string[] {
@@ -2625,13 +2776,14 @@ async function measureItem(
2625
2776
  head: string,
2626
2777
  ): Promise<MeasuredItem> {
2627
2778
  const headRead = await readImage(cwd, item.path, head)
2779
+ const basePath = item.basePath ?? item.path
2628
2780
  const baseRead =
2629
2781
  item.added || base === undefined
2630
2782
  ? undefined
2631
- : await readImage(cwd, item.path, base)
2783
+ : await readImage(cwd, basePath, base)
2632
2784
  const unread = [
2633
2785
  ...(headRead.kind === 'failed' ? [`head:${item.path}`] : []),
2634
- ...(baseRead?.kind === 'failed' ? [`base:${item.path}`] : []),
2786
+ ...(baseRead?.kind === 'failed' ? [`base:${basePath}`] : []),
2635
2787
  ]
2636
2788
  const headBytes = headRead.kind === 'read' ? headRead.bytes : undefined
2637
2789
  const baseBytes = baseRead?.kind === 'read' ? baseRead.bytes : undefined
@@ -2645,6 +2797,9 @@ async function measureItem(
2645
2797
  ...(headBytes !== undefined &&
2646
2798
  baseBytes !== undefined && {
2647
2799
  withinTolerance: isWithinTolerance(baseBytes, headBytes),
2800
+ ...(item.basePath !== undefined && {
2801
+ identical: Buffer.from(baseBytes).equals(headBytes),
2802
+ }),
2648
2803
  }),
2649
2804
  },
2650
2805
  unread,
@@ -2708,7 +2863,7 @@ async function runEvidence(
2708
2863
  // Read ahead of anything else, so a bad path refuses before a `gh` round
2709
2864
  // trip rather than after one.
2710
2865
  let suppliedChecklist: string | undefined
2711
- if (opts.checklist !== undefined) {
2866
+ if (typeof opts.checklist === 'string') {
2712
2867
  suppliedChecklist = await readFile(resolve(root, opts.checklist), 'utf8')
2713
2868
  .then((text) => text.trim() || undefined)
2714
2869
  .catch(() => undefined)
@@ -2765,6 +2920,7 @@ async function runEvidence(
2765
2920
  const grouped = await groupEvidence(
2766
2921
  [...pullFiles.present.keys()],
2767
2922
  async (path) => pullFiles.present.get(path) === false,
2923
+ pullFiles.renamedFrom,
2768
2924
  )
2769
2925
  const uncaptured = readUncaptured(
2770
2926
  everyChangedPath(pullFiles),
@@ -2794,7 +2950,10 @@ async function runEvidence(
2794
2950
  commentId = findEvidenceCommentId(comments)
2795
2951
  existingCases = findEvidenceCaseCount(comments)
2796
2952
  carriedPreview = findEvidencePreview(comments)
2797
- carriedChecklist = findEvidenceChecklist(comments)
2953
+ // A dropped checklist is not carried, so neither the record nor the render
2954
+ // shows it, while the marked comment holding it still counts below.
2955
+ const postedChecklist = findEvidenceChecklist(comments)
2956
+ carriedChecklist = opts.checklist === false ? undefined : postedChecklist
2798
2957
  carriedLocal = findEvidenceLocal(comments)
2799
2958
  const carriedReel = findEvidenceReel(comments)
2800
2959
 
@@ -2804,10 +2963,7 @@ async function runEvidence(
2804
2963
  ...(carriedPreview !== undefined && { preview: carriedPreview }),
2805
2964
  ...(carriedLocal !== undefined && { local: carriedLocal }),
2806
2965
  ...(carriedReel !== undefined && { reel: carriedReel }),
2807
- ...(carriedChecklist !== undefined && {
2808
- checklist: carriedChecklist,
2809
- boxes: readChecklistBoxes(carriedChecklist),
2810
- }),
2966
+ ...(carriedChecklist !== undefined && checklistFields(carriedChecklist)),
2811
2967
  }
2812
2968
 
2813
2969
  // A deploy that resolves and does not build from any changed path makes a
@@ -2866,13 +3022,18 @@ async function runEvidence(
2866
3022
  // A checklist opens a comment on its own, so it always lands in the marked
2867
3023
  // comment a later call edits. A local address rides on a checklist or a
2868
3024
  // hosted preview and never opens one alone, so a docs-only branch with a
2869
- // server up posts nothing.
3025
+ // server up posts nothing. An uncaptured painted path opens one too, so the
3026
+ // gap the lift refuses on shows on the thread.
3027
+ // A dropped checklist still opens a render, so the marked comment holding
3028
+ // it is edited without the block rather than left showing it.
2870
3029
  const hasChecklist =
2871
- suppliedChecklist !== undefined || carriedChecklist !== undefined
3030
+ suppliedChecklist !== undefined || postedChecklist !== undefined
3031
+ const hasUncaptured = uncaptured !== undefined && uncaptured.length > 0
2872
3032
  if (
2873
3033
  grouped.kind === 'refused' &&
2874
3034
  opts.preview === undefined &&
2875
- !hasChecklist
3035
+ !hasChecklist &&
3036
+ !hasUncaptured
2876
3037
  ) {
2877
3038
  logStep('Skipped')
2878
3039
  logInfo(
@@ -2933,6 +3094,7 @@ async function runEvidence(
2933
3094
  local,
2934
3095
  reel,
2935
3096
  skips,
3097
+ uncaptured,
2936
3098
  )
2937
3099
 
2938
3100
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -3030,6 +3192,16 @@ async function runEvidence(
3030
3192
  return 0
3031
3193
  }
3032
3194
 
3195
+ /** The record fields a checklist carries, with `drivable` counting the boxes a driver can run. */
3196
+ function checklistFields(checklist: string): {
3197
+ checklist: string
3198
+ boxes: ChecklistBox[]
3199
+ drivable: number
3200
+ } {
3201
+ const boxes = readChecklistBoxes(checklist)
3202
+ return { checklist, boxes, drivable: countDrivable(boxes) }
3203
+ }
3204
+
3033
3205
  async function readWorkflows(root: string): Promise<WorkflowFile[]> {
3034
3206
  const dir = join(root, '.github', 'workflows')
3035
3207
  if (!existsSync(dir)) return []
@@ -3350,6 +3522,7 @@ async function runLocal(opts: LocalOptions): Promise<number> {
3350
3522
  worktree,
3351
3523
  machineLocalRunner(),
3352
3524
  declaration.kind === 'paths' ? declaration.paths : [],
3525
+ declaration.pageOnly,
3353
3526
  )
3354
3527
  if (result.kind === 'refused' && result.reason === 'unhealthy') {
3355
3528
  const { port, path, status } = result
@@ -3966,6 +4139,18 @@ async function readRepoName(root: string): Promise<string | undefined> {
3966
4139
  }
3967
4140
  }
3968
4141
 
4142
+ /** The repository's web address off the REST repository read, for the same reason as `readRepoName`. */
4143
+ async function readRepoUrl(root: string): Promise<string | undefined> {
4144
+ const row = await gh(root, ['api', 'repos/{owner}/{repo}'])
4145
+ if (row === null) return undefined
4146
+ try {
4147
+ const url = (JSON.parse(row) as { html_url?: unknown }).html_url
4148
+ return typeof url === 'string' && url !== '' ? url : undefined
4149
+ } catch {
4150
+ return undefined
4151
+ }
4152
+ }
4153
+
3969
4154
  async function runFrames(
3970
4155
  number: string | undefined,
3971
4156
  opts: FramesOptions,
@@ -4122,7 +4307,7 @@ const LIFT_REFUSALS: Record<LiftRefusal | 'bad-wait' | 'gh-missing', string> = {
4122
4307
  'review-behind':
4123
4308
  'The newest code review closed on an earlier commit than the head.',
4124
4309
  'ui-missing':
4125
- 'The evidence comment carries a checklist and no UI review has been posted.',
4310
+ 'The evidence comment carries a checklist holding a box a driver can run, and no UI review has been posted.',
4126
4311
  'ui-open': 'The newest UI review is open, so a box still owes a fix.',
4127
4312
  'ui-unmarked':
4128
4313
  'The newest UI review carries no head marker, so the commit it covers is a GitHub stamp this gate does not trust.',
@@ -4132,6 +4317,8 @@ const LIFT_REFUSALS: Record<LiftRefusal | 'bad-wait' | 'gh-missing', string> = {
4132
4317
  'The newest UI review closed having driven no box, so it looked at nothing. Re-drive the checklist on an address that answers.',
4133
4318
  'evidence-owed':
4134
4319
  'The evidence check reads owed, so the ship chain left a step undone.',
4320
+ 'painted-uncaptured':
4321
+ 'A painted path changed with no capture, no reel at the head, and no waiver in a review. Capture it, or have a review waive it.',
4135
4322
  'standing-stale':
4136
4323
  'The base moved under a path this branch writes since it branched, so CI measured a tree that is gone.',
4137
4324
  'standing-conflict': 'The head does not merge cleanly into the base.',
@@ -4235,7 +4422,14 @@ async function readEvidenceGate(
4235
4422
  .at(-1)
4236
4423
  if (last === undefined) return undefined
4237
4424
 
4238
- let record: { reason?: unknown; owed?: unknown; checklist?: unknown }
4425
+ let record: {
4426
+ reason?: unknown
4427
+ owed?: unknown
4428
+ checklist?: unknown
4429
+ drivable?: unknown
4430
+ uncaptured?: unknown
4431
+ reel?: unknown
4432
+ }
4239
4433
  try {
4240
4434
  record = JSON.parse(last)
4241
4435
  } catch {
@@ -4243,12 +4437,27 @@ async function readEvidenceGate(
4243
4437
  }
4244
4438
  if (record.reason !== 'settled' && record.reason !== 'owed') return undefined
4245
4439
 
4440
+ const reelHead = readReelHead(record.reel)
4246
4441
  return {
4247
4442
  isSettled: record.reason === 'settled',
4248
4443
  owed: Array.isArray(record.owed) ? record.owed.map(String) : [],
4249
- hasChecklist:
4250
- typeof record.checklist === 'string' && record.checklist !== '',
4444
+ hasDrivableBox: readsDrivableBox(record),
4445
+ // Absent stays absent, since it means the base keeps no captures and
4446
+ // the gate passes as it did before the list existed.
4447
+ ...(Array.isArray(record.uncaptured) && {
4448
+ uncaptured: record.uncaptured.map(String),
4449
+ }),
4450
+ ...(reelHead !== undefined && { reelHead }),
4451
+ }
4452
+ }
4453
+
4454
+ function readReelHead(reel: unknown): string | undefined {
4455
+ if (typeof reel !== 'object' || reel === null || !('head' in reel)) {
4456
+ return undefined
4251
4457
  }
4458
+ return typeof reel.head === 'string' && reel.head !== ''
4459
+ ? reel.head
4460
+ : undefined
4252
4461
  }
4253
4462
 
4254
4463
  async function readChecksGate(