@erclx/canon 5.71.0 → 5.72.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.72.1](https://github.com/erclx/canon/compare/v5.72.0...v5.72.1) (2026-10-27)
4
+
5
+
6
+ ### Reverts
7
+
8
+ * **playwright:** drop the apt archive cache from [#2809](https://github.com/erclx/canon/issues/2809) ([#2810](https://github.com/erclx/canon/issues/2810)) ([2833270](https://github.com/erclx/canon/commit/2833270559d3afbd098caecbfcc395b7082f4570))
9
+
10
+ ## [5.72.0](https://github.com/erclx/canon/compare/v5.71.0...v5.72.0) (2026-10-27)
11
+
12
+
13
+ ### Features
14
+
15
+ * **feedback:** post a checked follow-up on an open feedback issue ([#2805](https://github.com/erclx/canon/issues/2805)) ([b014d5e](https://github.com/erclx/canon/commit/b014d5ed5a5501b0902aa4adf794dd50d715d7aa))
16
+ * **feedback:** read each issue's comments in triage ([#2802](https://github.com/erclx/canon/issues/2802)) ([ab2c761](https://github.com/erclx/canon/commit/ab2c7616c094ba43f4045c48f9fdbbe7bfb5b976))
17
+ * **intake:** offer to close an issue when its item is declined ([#2807](https://github.com/erclx/canon/issues/2807)) ([95df7da](https://github.com/erclx/canon/commit/95df7da6e6769b240e2565e24b12dc35f9233993))
18
+ * **pr:** carry a UI close only across pushes no box can observe ([#2804](https://github.com/erclx/canon/issues/2804)) ([c5102ea](https://github.com/erclx/canon/commit/c5102eaf5e6367b7335b8b66650ad03ca06dad41))
19
+ * **ui-film:** film one beat per group and skip negative boxes ([#2806](https://github.com/erclx/canon/issues/2806)) ([fb24ed3](https://github.com/erclx/canon/commit/fb24ed36c23e1e3bcb5c34b3d89c2321707873cd))
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **pr:** drive rendered markdown outside doc folders in carry rule ([#2808](https://github.com/erclx/canon/issues/2808)) ([c1f1582](https://github.com/erclx/canon/commit/c1f1582b284eb25ed359915223e52182af073d97))
25
+
3
26
  ## [5.71.0](https://github.com/erclx/canon/compare/v5.70.0...v5.71.0) (2026-10-27)
4
27
 
5
28
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.71.0",
4
+ "version": "5.72.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -14,7 +14,7 @@ Asked to report, a session also starts diagnosing, which spends the context the
14
14
  ## Must
15
15
 
16
16
  - Build the report from what the session already holds, name the surface and its type, and write the stated fallback for a field it cannot fill
17
- - Carry the whole request in the issue body alone, since a body and a comment that differ leave triage choosing
17
+ - Carry the whole request in the issue body, and what came after filing in a follow-up the CLI checks for links and for whether the fix still holds, never a hand-written comment
18
18
  - Open a GitHub issue on the toolkit repository through the CLI and print the returned URL on its own line
19
19
  - Surface the block and the reason when the issue call fails or the CLI is absent, so the report still exists
20
20
  - Hold a report the operator marks sensitive, since the issue is public
@@ -17,6 +17,19 @@ Format a `## Toolkit feedback` block from the current session, then open it as a
17
17
 
18
18
  ## Step 1: build the block
19
19
 
20
+ ### A follow-up or a new issue
21
+
22
+ Pick the form before filling any field, from what the session already holds.
23
+
24
+ - A follow-up, when the session cites an open feedback issue and the new case sits on its surface, such as a fix landing elsewhere, a second instance, or a finding that overturns the proposed fix
25
+ - A new issue, when no feedback issue is cited or the case sits on a different surface
26
+
27
+ For a follow-up, fill the `## Toolkit feedback follow-up` block in `${CLAUDE_SKILL_DIR}/references/template.md` and go to Step 2. For a new issue, build the report below.
28
+
29
+ A follow-up carries `### Observed`, `### Changes the fix`, and `### Links`, and the CLI refuses one missing any of them. `### Changes the fix` opens with yes or no, since a yes tells triage the body's fix no longer stands. `### Links` holds at least one URL or issue reference such as `frameboard#156`, never a bare name.
30
+
31
+ ### The report fields
32
+
20
33
  From the conversation so far, identify:
21
34
 
22
35
  - Target project name or kind, never its path, which names one machine's folder and nothing triage routes on. Name a private project by kind, such as `a Next.js app`, since reports leave the machine.
@@ -56,7 +69,7 @@ Keep Observed and Expected to one or two lines each, so the report scans. Length
56
69
 
57
70
  A GitHub issue form renders a submitted field as `### <label>`, so one shape reaches the toolkit whichever route a report takes, and the CLI parses both with one parser. The retired `**Surface:**` form is refused, so a block carrying it has to be rewritten.
58
71
 
59
- The issue body is the request. Put everything the toolkit needs to act on it there, since a comment is easy to miss and a body and a comment that differ leave triage choosing. A comment carries only what happens after filing.
72
+ The issue body is the request. Put everything the toolkit needs to act on it there, since a comment is easy to miss and a body and a comment that differ leave triage choosing. A comment carries only what happens after filing, and goes out as a follow-up through `canon feedback --on`, never a hand-written `gh issue comment`.
60
73
 
61
74
  ## Step 2: ship to the toolkit
62
75
 
@@ -78,14 +91,25 @@ cat <<'EOF' | canon feedback
78
91
  EOF
79
92
  ```
80
93
 
94
+ A follow-up pipes the same way with the issue number, and the CLI refuses a closed issue or one without the `feedback` label before posting anything:
95
+
96
+ ```bash
97
+ cat <<'EOF' | canon feedback --on <issue>
98
+ ## Toolkit feedback follow-up
99
+
100
+ ...
101
+ EOF
102
+ ```
103
+
81
104
  The CLI opens a GitHub issue on the toolkit repo, labeled `feedback` and each domain label the surface type names, such as `skills` for a plugin skill, and prints the issue URL for this skill to report on its own line. It needs `gh` authenticated, and `git-issue` is not the route, since it files on the current repository.
82
105
 
83
106
  ### Reading the outcome
84
107
 
85
108
  Read the outcome off stdout, never off the exit code, since a shell wrapper can flatten it. Stdout takes one of three shapes:
86
109
 
87
- - A line opening with `https://` is the filed issue. Report the URL.
110
+ - A line opening with `https://` is the filed issue, or the posted comment for a follow-up. Report the URL.
88
111
  - A block opening with `## Toolkit feedback` is a failed call. Show it in chat with the stderr reason and `📋 Paste this into an issue at https://github.com/erclx/canon/issues/new`.
112
+ - The same block on a follow-up is a failed comment. Show it the same way, pointing the paste line at the issue's own URL.
89
113
  - Empty stdout is a refusal. Step 3 says how to repair it from stderr.
90
114
 
91
115
  If `canon` is not on PATH, fall back: print the block in chat and tell the user `📋 Copy the block above into a toolkit-repo session.`
@@ -95,4 +119,5 @@ If `canon` is not on PATH, fall back: print the block in chat and tell the user
95
119
  A report that was not filed reads one of two ways on stdout, and stderr carries the reason in both:
96
120
 
97
121
  - Empty stdout, with a named missing field on stderr. Add that heading with a real value or its stated fallback, then re-run. Do not report the defect as filed.
122
+ - Empty stdout on a follow-up naming a field, an unreadable number, a closed issue, or a missing label. Repair the field or number, or file a new issue for a closed or unlabeled one.
98
123
  - The block on stdout, with `gh` absent or its call failed on stderr, which names which, since the two repairs differ. Show the block in chat with the reason and the paste line from Step 2.
@@ -51,3 +51,25 @@ Step 1 of `canon-feedback`. The session reads this file on reaching the point wh
51
51
 
52
52
  <one link per line>
53
53
  ```
54
+
55
+ ## Follow-up block
56
+
57
+ Step 1 fills this block instead of the report when the new case sits on an open feedback issue's surface, and Step 2 pipes it to `canon feedback --on <issue>`.
58
+
59
+ ```markdown
60
+ ## Toolkit feedback follow-up
61
+
62
+ ### Observed
63
+
64
+ <one or two lines on what happened after filing>
65
+
66
+ ### Changes the fix
67
+
68
+ <yes or no, then one line on what replaces the proposed fix when yes>
69
+
70
+ ### Links
71
+
72
+ <one URL or issue reference per line, such as frameboard#156>
73
+ ```
74
+
75
+ Keep Observed to one or two lines, as in the report. The CLI checks the fields and their openings but not their length, so the density is this template's rule alone.
@@ -15,15 +15,16 @@ The write fails where no prose rule catches it. A worker's file-editing tools re
15
15
 
16
16
  - Offer every item with an empty slot, batched by cluster in the folder's numbering and capped at four items, so a stop leaves whole clusters unread
17
17
  - Open each question with a lead-in an operator who has not opened the file can follow, then rank the item's own suggestion first and give every option its cost
18
- - Land each selection through a verb that resolves the root in-process and rewrites the whole line, one cluster per call
19
- - Pass the cluster and the label exactly as the heading spells them, letter suffix included
18
+ - Land each selection through a verb that resolves the root in-process and rewrites the whole line, one cluster per call, with the label as the heading spells it
20
19
  - Report what stays unread by count after each cluster, and stop when there is no folder or no unread item
20
+ - Offer to close the issue an item headed `(#NNN)` names once its answer begins `decline`, with the drafted comment shown first, so closing takes one word
21
21
 
22
22
  ## Must not
23
23
 
24
24
  - Fill a slot the operator did not answer, or infer an answer from the conversation having happened
25
25
  - Overwrite a slot already carrying an answer
26
- - Write anywhere but an item's answer slot, the index included
26
+ - Write anywhere but an item's answer slot, the index included, bar one exception, which is closing a declined item's issue on GitHub after a yes given for that issue in the turn its draft was shown
27
+ - Close an issue when the operator says no to the offer, gives no answer, or when the only yes came from another issue
27
28
 
28
29
  ## Out of scope
29
30
 
@@ -21,7 +21,7 @@ Read `${CLAUDE_SKILL_DIR}/../plan-intake/references/intake.md` before writing an
21
21
  - If the named folder has no unread item, stop: `❌ Every item in <slug> carries an answer. Nothing to ask.`
22
22
  - Never fill a slot the operator did not answer. An abandoned batch leaves every unreached item unread, which is what the empty slot already means.
23
23
  - Never infer an answer from the conversation having happened. A selection reaches the file through the verb or not at all.
24
- - Do not promote an item, edit a verdict, or write outside the answer slots. Promoting runs through `task-board` after the answers land.
24
+ - Do not promote an item, edit a verdict, or write outside the answer slots, bar the close Step 5 offers on an explicit yes. Promoting runs through `task-board` after the answers land.
25
25
 
26
26
  ## Step 1: pick the folder
27
27
 
@@ -72,7 +72,37 @@ canon intake answer <slug> --cluster <file> --set <label>=<answer> --set <label>
72
72
 
73
73
  The verb refuses the whole batch when any item in it already carries an answer, since a filled slot is a decision already made. On that refusal, drop the named item and send the rest.
74
74
 
75
- ## Step 5: continue or stop
75
+ ## Step 5: offer to close declined issues
76
+
77
+ Run this after Step 4 lands a cluster, over the items that batch just answered. Closing is a public write, so each close waits on a yes given for that issue.
78
+
79
+ An item qualifies when both hold:
80
+
81
+ - The answer's first word is `decline`. Whatever follows it, as in `decline, but ask frameboard first`, stays on the item and still qualifies.
82
+ - The title ends in `(#NNN)`. Read that trailing number alone, so a title naming two issues, or a number sitting in prose, gets no offer.
83
+
84
+ Skip the whole step and say why when `gh auth status` fails or `gh` is absent. The answers already landed stay.
85
+
86
+ For each qualifying item:
87
+
88
+ 1. Run `gh issue view <NNN> --json state --jq .state`. When it reads `CLOSED`, name the issue as already closed and offer nothing.
89
+ 2. Draft the comment as one or two sentences condensed from the item's `problem` and `fix`, giving the reason and a pointer to what covers it.
90
+ 3. Run the scan in `${CLAUDE_SKILL_DIR}/../git-pr/references/publish.md` against the draft, since nothing else checks text on its way to the remote.
91
+ 4. Show the draft and ask for a yes or no on that issue in the same turn, through the question surface Step 3 used.
92
+ 5. On a yes, close with the draft passed through a quoted heredoc. On a no, or no answer, leave the issue open.
93
+
94
+ ```bash
95
+ gh issue close <NNN> --reason "not planned" --comment "$(cat <<'EOF'
96
+ <comment>
97
+ EOF
98
+ )"
99
+ ```
100
+
101
+ The quoted heredoc matters because `problem` and `fix` carry file paths in code spans. Inside a double-quoted argument the shell would run each backtick pair as a command and post a comment with the paths missing.
102
+
103
+ Never carry a yes from one issue or one turn to another.
104
+
105
+ ## Step 6: continue or stop
76
106
 
77
107
  After each cluster, state how many clusters still carry unread items and continue to the next. Stop when the operator says to stop, and report what is left rather than pressing on.
78
108
 
@@ -85,10 +115,13 @@ Report per cluster as it lands, then close with the folder's state:
85
115
  ```plaintext
86
116
  ✅ <N> answered in <file>
87
117
  <label>. <answer>
118
+ 🔒 Closed #<NNN>, or left open: #<NNN>
88
119
 
89
120
  📋 <N> cluster(s) still carrying unread items in <slug>
90
121
  ```
91
122
 
123
+ Omit the 🔒 line for a cluster that offered no close.
124
+
92
125
  Close the run with the path so the reader can open what changed:
93
126
 
94
127
  ```plaintext
@@ -26,7 +26,7 @@ A pass stops rather than repeat a verdict that already covers the head.
26
26
  - An open verdict at the head stops the same way, unless a `## Review response` or `## Post-review findings` comment is newer, the launch names an address, or it drove no box, as `post.md` defines
27
27
  - The hosted preview was not built from the head: stop and post nothing, naming `built`, `tip`, and `canon pr preview <number> --json` for the dispatcher
28
28
  - Post, then run `canon pr lift <number>` on a close. Never merge or ready the PR by hand, and edit the evidence comment only through `canon pr tick`.
29
- - A push after a close owes a re-drive only when it changed a painted path, since the lift carries the close across the rest.
29
+ - A push after a close owes a re-drive unless every path it changed is inert, the list `references/tick.md` gives, since the lift carries the close across those alone.
30
30
 
31
31
  ## Step 1: resolve the pull request
32
32
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Tick the passed boxes
3
- description: Step 8 of review-ui, which ticks each box that passed through canon pr tick once the verdict has posted, and reports a stale head instead of retrying
3
+ description: Step 8 of review-ui, which ticks each box that passed through canon pr tick once the verdict has posted, and reports an owed re-drive or a stale head instead of retrying
4
4
  ---
5
5
 
6
6
  # Tick the passed boxes
@@ -15,14 +15,26 @@ canon pr tick <number> --boxes <n,...> --head <head> --json
15
15
 
16
16
  Number the boxes off the `boxes` field Step 2 kept. Leave a box that failed, needs eyes, or was not driven empty.
17
17
 
18
- The verb refuses a taste box and a head that is no longer the remote tip, writing nothing in either case. Branch on the record's `reason`, which reads `ticked` on success.
18
+ ## When the tip moved
19
+
20
+ A push that landed while this pass drove carries the ticks to the tip when it changed only paths no box observes. The record then reads `ticked` with the head it came from in `carriedFrom`.
21
+
22
+ - Tests, stories, snapshots, evidence captures, and `canon/design/` carry.
23
+ - Markdown carries only in the root and under `canon/`, `.claude/`, `.github/`, and `docs/`.
24
+ - Markdown anywhere else, such as a page a stack renders, is driven.
25
+
26
+ The verb refuses a taste box and a head the tip moved past in any other way, writing nothing in either case. Branch on the record's `reason`.
19
27
 
20
28
  - `count-mismatch`: nothing was written. With `ticked` below `passed`, re-run once with every passed box. With it above, a box ticked earlier has failed, so report it on `Ticks:` for the operator.
21
- - `stale-head`: a push landed while this pass drove. Report it on the `Ticks:` line of Step 10 and never retry at the new tip, since the boxes were driven at the old one.
29
+ - `redrive-owed`: a push since `<head>` changed a path a box can observe. Report `driven`, `head`, and `drivenPaths` on the `Ticks:` line of Step 10, and never retry at the tip.
30
+ - `stale-head`: the driven head was rewritten away, by a force-push or a rebase, or the gap could not be read. Report it on the `Ticks:` line and never retry at the new tip.
22
31
  - `body-refused`: the comment is ticked and the body is not. Retry the same call once, then report the partial result on the `Ticks:` line rather than claiming both.
23
32
  - Any other refusal: report the `reason` on the `Ticks:` line and tick nothing further.
24
- - A binary whose `canon pr` lacks `tick` predates the verb. Report that on the `Ticks:` line and tick nothing.
25
33
 
26
- The record's `countCheck` reads `matched` when the verdict's passed count agreed with the ticks, and `unchecked` when the verdict did not cover the head or carried no count line. Report an `unchecked` on the `Ticks:` line.
34
+ A binary whose `canon pr` lacks `tick` predates the verb. Report that on the `Ticks:` line and tick nothing.
35
+
36
+ ## What a tick reports
37
+
38
+ The record's `countCheck` reads `matched` when the verdict's passed count agreed with the ticks, and `unchecked` when the verdict covered neither the tip nor the head a carry came from, or carried no count line. Report an `unchecked` on the `Ticks:` line.
27
39
 
28
40
  The same call also writes the one UI pass line in the body's `## Testing`, such as `UI pass, 17 of 17 boxes passed at <sha>, 1 taste box left to the operator`, so the operator sees the result without opening the evidence comment. Never edit that line by hand.
@@ -200,7 +200,7 @@ export const scopeFallback = (payload: Payload, now: number): Scope => {
200
200
 
201
201
  /**
202
202
  * Reads the UI family off the verb's record, so the poll and the lift agree on
203
- * which head a close covers, a close carried across unpainted pushes included.
203
+ * which head a close covers, a close carried across inert pushes included.
204
204
  * A record carrying no `ui` object is a refusal or a CLI predating the field,
205
205
  * and reads as unread, which leaves the baseline's value standing.
206
206
  */
@@ -74,6 +74,10 @@ Run `canon pr lift <number> --json` after posting `## Review closed` or `## UI r
74
74
 
75
75
  Skip that message when the reason names the other review as open or missing, since the other reviewer's close runs the verb again.
76
76
 
77
+ ### A re-drive owed
78
+
79
+ Message the controller when `canon pr tick` refuses `redrive-owed`, naming the driven commit, the tip in `head`, and the `drivenPaths`. A push the boxes can observe landed after the pass drove, so the next UI pass is a dispatch only the controller orders.
80
+
77
81
  ### Every other message
78
82
 
79
83
  The rest of the channel is what this session never sends.
@@ -30,13 +30,37 @@ The mode holds only while the caller names it. An ordinary run that finds a pull
30
30
 
31
31
  ## Choose the beats
32
32
 
33
- Take a beat from each checklist box that asks the reviewer to click, fill, or scroll something, in checklist order, up to three beats.
33
+ Film where motion is the claim. Filter the boxes first, then spend the three beats so each feature area shows its change once before any area shows two.
34
+
35
+ ### Filter the boxes
36
+
37
+ A box is filmable when it asks the reviewer to click, fill, or scroll something and its result is a visible change.
34
38
 
35
39
  - Skip a box ending in `(taste)`, since no driver judges it
36
40
  - Skip a box that asks only to look at a state the page opens on, since a screenshot already shows it
41
+ - Skip a negative box, one asserting that something does not happen, such as "no drop line draws", since footage of nothing shows nothing
42
+
43
+ Read the box's text for the negative. A box asserting an unchanged result, such as "the board goes to all projects as before", is not negative, since the action still produces a visible change. Rank it behind a box in the same group that names new behavior.
44
+
45
+ ### Order the beats
46
+
47
+ A group is the bold feature-area label above at most five boxes, as `ui-checklist` defines it.
48
+
49
+ 1. Take one beat per group in checklist order, from the highest-ranked filmable box of each group, up to three beats.
50
+ 2. When beats remain after every group has one, take a second pass in checklist order over the boxes left, highest-ranked first within each group, up to three beats in all.
51
+
52
+ A checklist with one group gives up to three beats from it. A checklist with four groups gives three beats, one from each of the first three.
53
+
54
+ ### Plan the beats
55
+
56
+ Write one plan entry for each beat chosen, then skip when none was.
57
+
37
58
  - Name each beat after its box's text, cut to the action
38
59
  - Write the plan per `${CLAUDE_SKILL_DIR}/references/take-craft.md`, finding each target on the served page rather than guessing it
39
- - With no box left, skip as `no-interactive-box` and film nothing
60
+ - With no interactive box, skip as `no-interactive-box` and film nothing
61
+ - With interactive boxes that the filter removed, whether negative, static, or marked `(taste)`, skip as `no-visible-change` and film nothing
62
+
63
+ The negative test is a judgment over the box's text. A misread box costs a weaker reel and never a wrong pass, since the still check below guards the footage and the UI reviewer drives every box regardless.
40
64
 
41
65
  Keep the whole reel at or under 12 seconds. Each beat opens and closes on a 0.6 s hold, so give each action's `wait` only what its result needs to settle.
42
66
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.71.0",
4
+ "version": "5.72.1",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -2,11 +2,20 @@ import { Option, type Command } from 'commander'
2
2
  import {
3
3
  deriveDomainLabels,
4
4
  deriveTitle,
5
+ followUpProblem,
5
6
  missingField,
6
7
  missingFieldMessage,
7
8
  } from '@/feedback/body'
8
- import { createGithubIssue } from '@/feedback/github'
9
- import { issueFailureMessage } from '@/feedback/github-format'
9
+ import {
10
+ createGithubIssue,
11
+ postIssueComment,
12
+ readFeedbackIssue,
13
+ } from '@/feedback/github'
14
+ import {
15
+ commentFailureMessage,
16
+ issueFailureMessage,
17
+ issueReadFailureMessage,
18
+ } from '@/feedback/github-format'
10
19
  import { frameError, frameSuccess } from '@/cli/ui'
11
20
 
12
21
  function readStdin(): Promise<string> {
@@ -20,6 +29,61 @@ function readStdin(): Promise<string> {
20
29
  })
21
30
  }
22
31
 
32
+ /**
33
+ * Triage lists open `feedback` issues only, so a comment anywhere else is
34
+ * never read. Both checks run before the post, since a public comment cannot
35
+ * be taken back by a later refusal.
36
+ */
37
+ async function postFollowUp(issue: number, body: string): Promise<void> {
38
+ const problem = followUpProblem(body)
39
+ if (problem) {
40
+ frameError(problem)
41
+ process.exitCode = 1
42
+ return
43
+ }
44
+
45
+ // An absent gh can post nothing, so the block goes out for a hand paste. A
46
+ // failed read is a refusal to repair, since the number may name no issue.
47
+ const read = await readFeedbackIssue(issue)
48
+ if (!read.ok && read.reason === 'missing-binary') {
49
+ frameError(commentFailureMessage(read, issue))
50
+ process.stdout.write(`${body}\n`)
51
+ process.exitCode = 1
52
+ return
53
+ }
54
+ if (!read.ok) {
55
+ frameError(issueReadFailureMessage(read, issue))
56
+ process.exitCode = 1
57
+ return
58
+ }
59
+ if (read.state !== 'OPEN') {
60
+ frameError(
61
+ `Issue #${issue} is ${read.state.toLowerCase()}, and triage reads open feedback issues only. File a new report instead.`,
62
+ )
63
+ process.exitCode = 1
64
+ return
65
+ }
66
+ if (!read.labels.includes('feedback')) {
67
+ frameError(
68
+ `Issue #${issue} carries no feedback label, so it is not a toolkit feedback report. Check the number and re-run.`,
69
+ )
70
+ process.exitCode = 1
71
+ return
72
+ }
73
+
74
+ const result = await postIssueComment(issue, body)
75
+ if (result.ok) {
76
+ frameSuccess('canon feedback', result.url)
77
+ process.stdout.write(`${result.url}\n`)
78
+ return
79
+ }
80
+ frameError(commentFailureMessage(result, issue))
81
+ process.stdout.write(`${body}\n`)
82
+ process.exitCode = 1
83
+ }
84
+
85
+ const ISSUE_NUMBER = /^[1-9]\d*$/
86
+
23
87
  export function register(program: Command): void {
24
88
  program
25
89
  .command('feedback')
@@ -27,10 +91,21 @@ export function register(program: Command): void {
27
91
  .description(
28
92
  'Open a GitHub issue on the toolkit repo from a feedback block on stdin',
29
93
  )
94
+ .option(
95
+ '--on <issue>',
96
+ 'Post a follow-up block as a comment on an open feedback issue instead',
97
+ )
30
98
  // A cached plugin still pipes `--github`, and commander rejects an unknown
31
99
  // option, so the flag stays accepted and hidden for one release.
32
100
  .addOption(new Option('--github').hideHelp())
33
- .action(async () => {
101
+ .action(async (opts: { on?: string }) => {
102
+ if (opts.on !== undefined && !ISSUE_NUMBER.test(opts.on)) {
103
+ frameError(
104
+ `--on takes an issue number, such as --on 2801, not "${opts.on}".`,
105
+ )
106
+ process.exitCode = 1
107
+ return
108
+ }
34
109
  if (process.stdin.isTTY) {
35
110
  frameError(
36
111
  'No feedback on stdin. Pipe a markdown block: pbpaste | canon feedback',
@@ -45,6 +120,11 @@ export function register(program: Command): void {
45
120
  return
46
121
  }
47
122
 
123
+ if (opts.on !== undefined) {
124
+ await postFollowUp(Number(opts.on), body)
125
+ return
126
+ }
127
+
48
128
  // A report missing its surface is no more useful in the tracker than
49
129
  // anywhere else, so validation runs before any `gh` call.
50
130
  const absent = missingField(body)
@@ -98,6 +98,7 @@ import {
98
98
  } from '@/git/status'
99
99
  import { machineListenerReader } from '@/listeners/machine'
100
100
  import { loadPaintedPatterns } from '@/pr/painted'
101
+ import { readCarry } from '@/pr/carry'
101
102
  import { LOCAL_CONFIG_REL, readHealthPaths } from '@/pr/health'
102
103
  import {
103
104
  dropAddressLine,
@@ -228,6 +229,7 @@ type TickReason =
228
229
  | TickRefusal
229
230
  | 'count-mismatch'
230
231
  | 'stale-head'
232
+ | 'redrive-owed'
231
233
  | 'bad-boxes'
232
234
  | 'no-comment'
233
235
  | 'body-refused'
@@ -236,7 +238,9 @@ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
236
238
  'body-refused':
237
239
  'The boxes are ticked on the evidence comment, but gh could not edit the pull request body, so its UI pass line still shows the earlier result. Re-run the tick to retry the body.',
238
240
  'stale-head':
239
- 'The head named is not the branch tip on the remote, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
241
+ 'The head named is not the branch tip on the remote, and the tip does not descend from it or the gap could not be read, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
242
+ 'redrive-owed':
243
+ 'A push since the driven head changed a path a box can observe, so the pass does not carry to the tip. Nothing was written. Re-drive the checklist at the tip rather than retrying the tick there.',
240
244
  'bad-boxes':
241
245
  '--boxes takes box numbers as a comma-separated list of positive whole numbers, such as 1,3, and --head takes the commit that was driven.',
242
246
  'count-mismatch':
@@ -700,8 +704,9 @@ export function register(program: Command): void {
700
704
  '',
701
705
  '`ui` also carries the newest `## UI review` pass (`heading`, `state`,',
702
706
  '`commit`, `source`), `isBound`, which is whether the evidence comment',
703
- 'carries a checklist, and `carriedFrom` or `paintedPaths` when the lift',
704
- 'carried a close forward or refused to. An unbound family reads covered.',
707
+ 'carries a checklist, and `carriedFrom` when the lift carried a close',
708
+ 'forward, or `drivenPaths` and the painted subset `paintedPaths` when it',
709
+ 'refused to. An unbound family reads covered.',
705
710
  '',
706
711
  'Both families post as pull request reviews, so this reads',
707
712
  '`pulls/<n>/reviews` alone, as the lift does. Issue comments are not',
@@ -944,11 +949,15 @@ export function register(program: Command): void {
944
949
  ' ui the newest `## UI review` is closed at the head, bound only',
945
950
  ' where `canon pr evidence --check` carries a checklist. A close',
946
951
  ' on an earlier commit still holds when it is an ancestor of the',
947
- ' head and no painted path changed since, and the record names',
948
- ' it as `carriedFrom`. A painted change refuses `ui-behind` and',
949
- ' lists the paths as `paintedPaths`. A close whose count line',
950
- ' shows boxes not driven and none passed, failed, or needing',
951
- ' eyes refuses `ui-undriven`, carried or not',
952
+ ' head and every path changed since is one no box can observe,',
953
+ ' being tests, stories, snapshots, markdown in the root and in',
954
+ ' `canon/`, `.claude/`, `.github/`, and `docs/`, evidence captures,',
955
+ ' and `canon/design/`, and the record names it as `carriedFrom`.',
956
+ ' Any other change refuses `ui-behind`, listing the paths as',
957
+ ' `drivenPaths` and the painted subset as `paintedPaths`, since',
958
+ ' a box drives behavior a `.ts` file holds too. A close whose',
959
+ ' count line shows boxes not driven and none passed, failed,',
960
+ ' or needing eyes refuses `ui-undriven`, carried or not',
952
961
  ' evidence the evidence check reads settled',
953
962
  ' standing the head is fresh against the base, not stale or conflicted',
954
963
  ' ci every check run on the tip passed, waited on up to --wait',
@@ -1295,14 +1304,23 @@ export function register(program: Command): void {
1295
1304
  'driver can run is ticked, counts taste boxes apart, and is the only',
1296
1305
  'body text this verb writes, so hand edits elsewhere survive.',
1297
1306
  '',
1298
- 'Before writing it reads the newest UI verdict. One that covers the tip',
1299
- 'and carries a count line must match the ticks this call leaves at the',
1300
- 'tip, so a re-drive passes every box that passed, since a new head',
1301
- 'clears the older ticks. Any other verdict leaves the check unrun.',
1307
+ 'A --head behind the remote tip carries to the tip when every path',
1308
+ 'changed since is one no box can observe, being tests, stories,',
1309
+ 'snapshots, markdown in the root and in `canon/`, `.claude/`,',
1310
+ '`.github/`, and `docs/`, evidence captures, and `canon/design/`. The',
1311
+ 'boxes are then stamped at the tip and the record names `carriedFrom`.',
1312
+ 'Any other changed path, markdown elsewhere included, owes a re-drive.',
1313
+ '',
1314
+ 'Before writing it reads the newest UI verdict. One that covers the tip,',
1315
+ 'or the driven head on a carry, and carries a count line must match the',
1316
+ 'ticks this call leaves at the tip, so a re-drive passes every box that',
1317
+ 'passed, since a new head clears the older ticks. Any other verdict',
1318
+ 'leaves the check unrun.',
1302
1319
  '',
1303
1320
  'Read `reason` on the JSON record:',
1304
1321
  ' ticked the boxes were ticked and the body line written, with the',
1305
- ' tip in `head` and `countCheck` as matched or unchecked',
1322
+ ' tip in `head`, `countCheck` as matched or unchecked, and',
1323
+ ' `carriedFrom` when --head was carried to the tip',
1306
1324
  ' count-mismatch the verdict passed a different number of boxes than',
1307
1325
  ' this call leaves ticked at the tip, nothing written,',
1308
1326
  ' `passed` and `ticked` carry the two counts. Fewer ticks',
@@ -1310,7 +1328,10 @@ export function register(program: Command): void {
1310
1328
  ' operator an untick of a box that has since failed',
1311
1329
  ' body-refused the comment is ticked but gh could not edit the body,',
1312
1330
  ' exit 1 with `commentTicked`. Re-run to retry the body.',
1313
- ' stale-head --head is not the remote branch tip, nothing written',
1331
+ ' redrive-owed a push since --head changed a path a box can observe,',
1332
+ ' nothing written, with `driven`, `head`, and `drivenPaths`',
1333
+ ' stale-head --head is behind the tip with no readable linear gap,',
1334
+ ' as a force-push or a rebase leaves, nothing written',
1314
1335
  ' taste-box a named box ends in (taste), nothing written',
1315
1336
  ' no-box a named number is past the checklist, nothing written',
1316
1337
  ' no-checklist the evidence comment carries no checklist',
@@ -3475,9 +3496,9 @@ const POSITIVE_WHOLE = /^[1-9]\d*$/
3475
3496
  const MIN_TICK_HEAD_LENGTH = 7
3476
3497
 
3477
3498
  /**
3478
- * Ticks the named boxes on the marked comment. It refuses a head that is not
3479
- * the remote tip and a taste box before writing, so both rules hold as checks
3480
- * rather than as prose a driver can talk itself out of.
3499
+ * Ticks the named boxes on the marked comment. It refuses a head the tip moved
3500
+ * past in a way a box can observe, and a taste box, before writing, so both
3501
+ * rules hold as checks rather than as prose a driver can talk itself out of.
3481
3502
  */
3482
3503
  async function runTick(
3483
3504
  number: string | undefined,
@@ -3518,7 +3539,26 @@ async function runTick(
3518
3539
  root,
3519
3540
  )
3520
3541
  }
3521
- if (!reading.tip.startsWith(head)) return refuseTick('stale-head')
3542
+ const carry = await readCarry(
3543
+ (from, to) => readChangedPaths(gitRunner(root), from, to),
3544
+ head,
3545
+ reading.tip,
3546
+ )
3547
+ if (carry.kind === 'moved') {
3548
+ return refuseWith(
3549
+ 'redrive-owed',
3550
+ TICK_REFUSALS['redrive-owed'],
3551
+ emitJson,
3552
+ root,
3553
+ { driven: head, head: reading.tip, drivenPaths: carry.drivenPaths },
3554
+ )
3555
+ }
3556
+ // A gap that could not be read refuses as a rewrite does, which is what the
3557
+ // verb answered for every moved tip before the carry.
3558
+ if (carry.kind === 'not-ancestor' || carry.kind === 'failed') {
3559
+ return refuseTick('stale-head')
3560
+ }
3561
+ const carriedFrom = carry.kind === 'carried' ? carry.carriedFrom : undefined
3522
3562
 
3523
3563
  const comments = await listComments(root, String(identity.number))
3524
3564
  if (comments === undefined) return refuseTick('gh-failed')
@@ -3534,8 +3574,9 @@ async function runTick(
3534
3574
  if (result.kind === 'refused') return refuseTick(result.reason)
3535
3575
 
3536
3576
  // Read ahead of the write so a short list refuses before it leaves the
3537
- // comment half ticked. A verdict that does not cover the tip, carries no
3538
- // count line, or cannot be read leaves the check unrun and says so.
3577
+ // comment half ticked. A verdict that covers neither the tip nor the head a
3578
+ // carry came from, carries no count line, or cannot be read leaves the check
3579
+ // unrun and says so.
3539
3580
  const ticked = countTicksAtHead(
3540
3581
  readChecklistBoxes(findEvidenceChecklist([{ body: result.body }]) ?? ''),
3541
3582
  reading.tip,
@@ -3547,7 +3588,10 @@ async function runTick(
3547
3588
  const isCounted =
3548
3589
  verdict.counts !== undefined &&
3549
3590
  verdict.commit !== undefined &&
3550
- coversHead(verdict.commit, reading.tip)
3591
+ (coversHead(verdict.commit, reading.tip) ||
3592
+ (carriedFrom !== undefined &&
3593
+ (coversHead(verdict.commit, carriedFrom) ||
3594
+ coversHead(carriedFrom, verdict.commit))))
3551
3595
  if (isCounted && verdict.counts.passed !== ticked) {
3552
3596
  return refuseWith(
3553
3597
  'count-mismatch',
@@ -3622,6 +3666,7 @@ async function runTick(
3622
3666
  reason: 'ticked',
3623
3667
  countCheck,
3624
3668
  head: reading.tip,
3669
+ ...(carriedFrom !== undefined && { carriedFrom }),
3625
3670
  boxes: numbers.map(Number),
3626
3671
  commentId,
3627
3672
  })}\n`,
@@ -94,6 +94,56 @@ export function missingFieldMessage(field: string): string {
94
94
  return `Feedback report is missing its "### ${field}" section. Every report needs ${REQUIRED_FIELDS.map((name) => `### ${name}`).join(', ')}.`
95
95
  }
96
96
 
97
+ /**
98
+ * The fields a follow-up comment carries. A follow-up reports what happened
99
+ * after filing, so it names no surface and proposes no fix of its own. It says
100
+ * whether the body's fix still stands, since triage reads the body as the
101
+ * request and a silent overturn leaves it acting on the old one.
102
+ */
103
+ export const FOLLOW_UP_FIELDS = [
104
+ 'Observed',
105
+ 'Changes the fix',
106
+ 'Links',
107
+ ] as const
108
+
109
+ const YES_OR_NO = /^(?:yes|no)\b/i
110
+
111
+ /**
112
+ * A URL, or an issue reference such as `#2801`, `frameboard#156`, or
113
+ * `erclx/canon#2801`. A bare name is what the hand-written follow-up carried,
114
+ * and nothing a reader can open from it.
115
+ */
116
+ const REFERENCE = /https?:\/\/\S+|(?:^|[\s(])(?:[\w.-]+\/)?[\w.-]*#\d+\b/
117
+
118
+ export function missingFollowUpField(body: string): string | undefined {
119
+ const sections = parseSections(body)
120
+ for (const field of FOLLOW_UP_FIELDS) {
121
+ const value = sections.get(field.toLowerCase())
122
+ if (!value) return field
123
+ }
124
+ return undefined
125
+ }
126
+
127
+ /**
128
+ * The sentence a refused follow-up reads, or `undefined` when it carries every
129
+ * field in a checkable shape. One problem at a time, as `missingField` does.
130
+ */
131
+ export function followUpProblem(body: string): string | undefined {
132
+ const absent = missingFollowUpField(body)
133
+ if (absent) {
134
+ return `Follow-up is missing its "### ${absent}" section. Every follow-up needs ${FOLLOW_UP_FIELDS.map((name) => `### ${name}`).join(', ')}.`
135
+ }
136
+ const changes = readField(body, 'Changes the fix') ?? ''
137
+ if (!YES_OR_NO.test(changes)) {
138
+ return 'Follow-up "### Changes the fix" must open with yes or no, so triage can tell whether the issue body still holds.'
139
+ }
140
+ const links = readField(body, 'Links') ?? ''
141
+ if (!REFERENCE.test(links)) {
142
+ return 'Follow-up "### Links" carries no URL or issue reference. Write each as a URL or as owner/repo#number.'
143
+ }
144
+ return undefined
145
+ }
146
+
97
147
  function surfaceField(body: string): string | undefined {
98
148
  return readField(body, 'Surface')?.split('\n')[0]?.trim()
99
149
  }
@@ -37,6 +37,61 @@ export function buildIssueArgs(opts: CreateIssueOptions): string[] {
37
37
  return args
38
38
  }
39
39
 
40
+ export function buildCommentArgs(issue: number, body: string): string[] {
41
+ return ['issue', 'comment', String(issue), '--body', body, '--repo', REPO]
42
+ }
43
+
44
+ export function buildIssueReadArgs(issue: number): string[] {
45
+ return [
46
+ 'issue',
47
+ 'view',
48
+ String(issue),
49
+ '--json',
50
+ 'state,labels',
51
+ '--repo',
52
+ REPO,
53
+ ]
54
+ }
55
+
56
+ export interface IssueRead {
57
+ readonly ok: true
58
+ readonly state: string
59
+ readonly labels: readonly string[]
60
+ }
61
+
62
+ /**
63
+ * Reads the `gh issue view --json state,labels` record. A record of any other
64
+ * shape is a failed read rather than an issue with no labels, since the caller
65
+ * refuses on a missing label and that refusal would then name the wrong cause.
66
+ */
67
+ export function parseIssueRead(stdout: string): IssueRead | IssueFailure {
68
+ let record: unknown
69
+ try {
70
+ record = JSON.parse(stdout)
71
+ } catch {
72
+ return {
73
+ ok: false,
74
+ reason: 'command-failed',
75
+ detail: 'gh printed no JSON issue record',
76
+ }
77
+ }
78
+ const { state, labels } = (record ?? {}) as {
79
+ state?: unknown
80
+ labels?: unknown
81
+ }
82
+ if (typeof state !== 'string' || !Array.isArray(labels)) {
83
+ return {
84
+ ok: false,
85
+ reason: 'command-failed',
86
+ detail: 'gh printed an issue record with no state or labels',
87
+ }
88
+ }
89
+ const names = labels
90
+ .map((label: unknown) => (label as { name?: unknown } | null)?.name)
91
+ .filter((name): name is string => typeof name === 'string')
92
+ return { ok: true, state, labels: names }
93
+ }
94
+
40
95
  /**
41
96
  * Collapses to one line. Both callers write the detail inside a framed error or
42
97
  * a colored warning, and a newline in the middle of either breaks the frame and
@@ -75,3 +130,25 @@ export function issueFailureMessage(failure: IssueFailure): string {
75
130
  }
76
131
  return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}. File it manually at ${ISSUE_URL} instead.`
77
132
  }
133
+
134
+ export function commentFailureMessage(
135
+ failure: IssueFailure,
136
+ issue: number,
137
+ ): string {
138
+ const target = `https://github.com/${REPO}/issues/${issue}`
139
+ if (failure.reason === 'missing-binary') {
140
+ return `gh is not installed, so no follow-up was posted. Install gh, or comment at ${target}`
141
+ }
142
+ return `gh could not post the follow-up: ${failure.detail ?? 'no diagnostic on stderr'}. Comment manually at ${target} instead.`
143
+ }
144
+
145
+ /**
146
+ * A read that `gh` ran and failed is most often a number naming no issue, so
147
+ * the sentence asks for the number rather than pointing a paste at it.
148
+ */
149
+ export function issueReadFailureMessage(
150
+ failure: IssueFailure,
151
+ issue: number,
152
+ ): string {
153
+ return `gh could not read issue #${issue}: ${failure.detail ?? 'no diagnostic on stderr'}. Check the number and re-run.`
154
+ }
@@ -2,34 +2,73 @@ import { execa } from 'execa'
2
2
  import { gitEnv } from '@/git/env'
3
3
  import { PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
+ buildCommentArgs,
5
6
  buildIssueArgs,
7
+ buildIssueReadArgs,
6
8
  failureDetail,
9
+ parseIssueRead,
7
10
  type CreateIssueOptions,
8
11
  type CreateIssueResult,
12
+ type IssueFailure,
13
+ type IssueRead,
9
14
  } from '@/feedback/github-format'
10
15
 
11
16
  const GH_TIMEOUT_MS = 30_000
12
17
 
13
- export async function createGithubIssue(
14
- opts: CreateIssueOptions,
15
- ): Promise<CreateIssueResult> {
18
+ /**
19
+ * Runs `gh` and returns its trimmed stdout, or the failure the operator reads.
20
+ * See src/worktrees/reclaim.ts for why gh needs the stripped environment.
21
+ */
22
+ async function runGh(
23
+ args: string[],
24
+ ): Promise<{ ok: true; stdout: string } | IssueFailure> {
16
25
  if (Bun.which('gh') === null) return { ok: false, reason: 'missing-binary' }
17
26
  try {
18
- // See src/worktrees/reclaim.ts for why gh needs the stripped environment.
19
- const result = await execa('gh', buildIssueArgs(opts), {
27
+ const result = await execa('gh', args, {
20
28
  cwd: PROJECT_ROOT,
21
29
  timeout: GH_TIMEOUT_MS,
22
30
  env: gitEnv(),
23
31
  extendEnv: false,
24
32
  })
25
- const url = result.stdout.trim()
26
- if (url) return { ok: true, url }
27
- return {
28
- ok: false,
29
- reason: 'command-failed',
30
- detail: 'gh exited zero and printed no issue URL',
31
- }
33
+ return { ok: true, stdout: result.stdout.trim() }
32
34
  } catch (error) {
33
35
  return { ok: false, reason: 'command-failed', detail: failureDetail(error) }
34
36
  }
35
37
  }
38
+
39
+ async function runForUrl(
40
+ args: string[],
41
+ emptyDetail: string,
42
+ ): Promise<CreateIssueResult> {
43
+ const result = await runGh(args)
44
+ if (!result.ok) return result
45
+ if (result.stdout) return { ok: true, url: result.stdout }
46
+ return { ok: false, reason: 'command-failed', detail: emptyDetail }
47
+ }
48
+
49
+ export function createGithubIssue(
50
+ opts: CreateIssueOptions,
51
+ ): Promise<CreateIssueResult> {
52
+ return runForUrl(
53
+ buildIssueArgs(opts),
54
+ 'gh exited zero and printed no issue URL',
55
+ )
56
+ }
57
+
58
+ export async function readFeedbackIssue(
59
+ issue: number,
60
+ ): Promise<IssueRead | IssueFailure> {
61
+ const result = await runGh(buildIssueReadArgs(issue))
62
+ if (!result.ok) return result
63
+ return parseIssueRead(result.stdout)
64
+ }
65
+
66
+ export function postIssueComment(
67
+ issue: number,
68
+ body: string,
69
+ ): Promise<CreateIssueResult> {
70
+ return runForUrl(
71
+ buildCommentArgs(issue, body),
72
+ 'gh exited zero and printed no comment URL',
73
+ )
74
+ }
@@ -0,0 +1,64 @@
1
+ import { DESIGN_FOLDER, isEvidencePath } from '@/pr/evidence'
2
+ import type { ChangedRead } from '@/pr/lift'
3
+ import { DEFAULT_PAINTED } from '@/pr/painted'
4
+ import { coversHead } from '@/pr/review-scope'
5
+
6
+ /**
7
+ * The globs no checklist box can observe: tests, stories, snapshots, and
8
+ * markdown where a project keeps docs. The rule lists what is inert rather than
9
+ * what is driven, so a kind nobody listed, such as a `.json` data file, fails
10
+ * closed. Markdown elsewhere, such as an Astro page or content entry, renders,
11
+ * so it is driven like `.mdx`.
12
+ */
13
+ export const DEFAULT_INERT: readonly string[] = [
14
+ ...DEFAULT_PAINTED.unpainted,
15
+ '*.md',
16
+ 'canon/**/*.md',
17
+ '.claude/**/*.md',
18
+ '.github/**/*.md',
19
+ 'docs/**/*.md',
20
+ ]
21
+
22
+ /**
23
+ * An evidence capture and a design record are inert too, the two paths
24
+ * `readUncaptured` skips, since a recapture or a design note changes no
25
+ * behavior a box drives.
26
+ */
27
+ export function isInert(path: string): boolean {
28
+ return (
29
+ isEvidencePath(path) ||
30
+ path.startsWith(DESIGN_FOLDER) ||
31
+ DEFAULT_INERT.some((glob) => new Bun.Glob(glob).match(path))
32
+ )
33
+ }
34
+
35
+ /** Whether a UI verdict at one commit still holds at a later one. */
36
+ export type Carry =
37
+ | { readonly kind: 'covered' }
38
+ | { readonly kind: 'carried'; readonly carriedFrom: string }
39
+ | { readonly kind: 'moved'; readonly drivenPaths: readonly string[] }
40
+ | { readonly kind: 'not-ancestor' }
41
+ | { readonly kind: 'failed' }
42
+
43
+ /**
44
+ * Carries a verdict driven at `from` to `to` only across a gap that changed
45
+ * nothing a box drives. A box drives behavior, which lives in any source file
46
+ * and not only in a painted one, so every path outside the inert set moves the
47
+ * verdict. A `from` that `to` does not descend from never carries.
48
+ */
49
+ export async function readCarry(
50
+ readChanged: (from: string, to: string) => Promise<ChangedRead | undefined>,
51
+ from: string,
52
+ to: string,
53
+ ): Promise<Carry> {
54
+ if (coversHead(from, to)) return { kind: 'covered' }
55
+
56
+ const changed = await readChanged(from, to)
57
+ if (changed === undefined) return { kind: 'failed' }
58
+ if (!changed.isAncestor) return { kind: 'not-ancestor' }
59
+
60
+ const drivenPaths = changed.paths.filter((path) => !isInert(path))
61
+ return drivenPaths.length > 0
62
+ ? { kind: 'moved', drivenPaths }
63
+ : { kind: 'carried', carriedFrom: from }
64
+ }
@@ -607,7 +607,7 @@ export function readOwed(input: OwedInput): OwedStep[] {
607
607
  * Design references are redrawn rather than captured, so recapturing them
608
608
  * neither owes nor stands in for an evidence capture.
609
609
  */
610
- const DESIGN_FOLDER = 'canon/design/'
610
+ export const DESIGN_FOLDER = 'canon/design/'
611
611
 
612
612
  /**
613
613
  * The painted paths a pull request changed while no evidence image did, in a
package/src/pr/lift.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  resolveUiScope,
7
7
  type UiCounts,
8
8
  } from '@/pr/review-scope'
9
+ import { readCarry } from '@/pr/carry'
9
10
  import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
10
11
 
11
12
  /** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
@@ -49,6 +50,8 @@ export interface GateResult {
49
50
  readonly carriedFrom?: string
50
51
  /** The painted paths that kept a UI close from carrying forward. */
51
52
  readonly paintedPaths?: readonly string[]
53
+ /** Every path a box can observe that kept a UI close from carrying forward. */
54
+ readonly drivenPaths?: readonly string[]
52
55
  }
53
56
 
54
57
  /** How an earlier commit sits against the head, and what changed since. */
@@ -125,6 +128,7 @@ export interface LiftRecord {
125
128
  readonly failed?: LiftRefusal
126
129
  readonly carriedFrom?: string
127
130
  readonly paintedPaths?: readonly string[]
131
+ readonly drivenPaths?: readonly string[]
128
132
  readonly isLifted: boolean
129
133
  /** Whether the caller can treat the pull request as ready afterwards. */
130
134
  readonly isOk: boolean
@@ -164,11 +168,13 @@ export function reviewGate(
164
168
  function carried(gates: readonly GateResult[]): {
165
169
  carriedFrom?: string
166
170
  paintedPaths?: readonly string[]
171
+ drivenPaths?: readonly string[]
167
172
  } {
168
173
  const ui = gates.find((gate) => gate.gate === 'ui')
169
174
  return {
170
175
  ...(ui?.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
171
176
  ...(ui?.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
177
+ ...(ui?.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
172
178
  }
173
179
  }
174
180
 
@@ -189,10 +195,11 @@ function droveNothing(counts: UiCounts | undefined): boolean {
189
195
  /**
190
196
  * Binds only where the evidence record carries a checklist to drive.
191
197
  *
192
- * A close behind the head still holds when it is an ancestor of the head and
193
- * no painted path changed since, since a push that paints nothing leaves the
194
- * checklist the reviewer drove as true as it was. A close that is not an
195
- * ancestor, which a force-push or a rebase leaves, never carries.
198
+ * A close behind the head still holds across the gap `readCarry` carries,
199
+ * one changing no path a box can observe. A box drives behavior, so a push to
200
+ * a non-painted source file moves the verdict too, and `drivenPaths` names
201
+ * every such path while `paintedPaths` keeps the painted subset. A close that
202
+ * is not an ancestor, which a force-push or a rebase leaves, never carries.
196
203
  */
197
204
  export async function uiGate(
198
205
  api: Pick<LiftApi, 'readChanged'>,
@@ -211,17 +218,27 @@ export async function uiGate(
211
218
  // Ahead of the head test so a close carried across commits is refused too.
212
219
  // A verdict whose count line did not parse reads as unchecked and goes on.
213
220
  if (droveNothing(scope.counts)) return fail('ui', 'ui-undriven')
214
- if (coversHead(scope.commit, head)) return pass('ui')
215
221
 
216
- const changed = await api.readChanged(scope.commit, head)
217
- if (changed === undefined) return fail('ui', 'gh-failed')
218
- if (!changed.isAncestor) return fail('ui', 'ui-behind')
222
+ const carry = await readCarry(
223
+ (from, to) => api.readChanged(from, to),
224
+ scope.commit,
225
+ head,
226
+ )
227
+ if (carry.kind === 'covered') return pass('ui')
228
+ if (carry.kind === 'failed') return fail('ui', 'gh-failed')
229
+ if (carry.kind === 'not-ancestor') return fail('ui', 'ui-behind')
230
+ if (carry.kind === 'carried') {
231
+ return { ...pass('ui'), carriedFrom: carry.carriedFrom }
232
+ }
219
233
 
220
- const paintedPaths = changed.paths.filter((path) => isPainted(path, painted))
221
- if (paintedPaths.length > 0) {
222
- return { ...fail('ui', 'ui-behind'), paintedPaths }
234
+ const paintedPaths = carry.drivenPaths.filter((path) =>
235
+ isPainted(path, painted),
236
+ )
237
+ return {
238
+ ...fail('ui', 'ui-behind'),
239
+ ...(paintedPaths.length > 0 && { paintedPaths }),
240
+ drivenPaths: carry.drivenPaths,
223
241
  }
224
- return { ...pass('ui'), carriedFrom: scope.commit }
225
242
  }
226
243
 
227
244
  function standingGate(standing: Standing): GateResult {
@@ -47,6 +47,7 @@ export interface UiState extends UiScope, FamilyState {
47
47
  readonly isBound: boolean
48
48
  readonly carriedFrom?: string
49
49
  readonly paintedPaths?: readonly string[]
50
+ readonly drivenPaths?: readonly string[]
50
51
  }
51
52
 
52
53
  export interface ReviewStateRecord {
@@ -119,6 +120,7 @@ async function readAt(
119
120
  isBound: hasChecklist,
120
121
  ...(ui.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
121
122
  ...(ui.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
123
+ ...(ui.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
122
124
  },
123
125
  },
124
126
  }