@erclx/canon 5.71.0 → 5.72.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.
- package/CHANGELOG.md +16 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +27 -2
- package/claude/skills/canon-feedback/references/template.md +22 -0
- package/claude/skills/plan-intake-answer/REQUIREMENT.md +4 -3
- package/claude/skills/plan-intake-answer/SKILL.md +35 -2
- package/claude/skills/review-ui/SKILL.md +1 -1
- package/claude/skills/review-ui/references/tick.md +17 -5
- package/claude/skills/role-orchestrator/scripts/poll.ts +1 -1
- package/claude/skills/role-reviewer/SKILL.md +4 -0
- package/claude/skills/ui-film/references/pr-clip.md +26 -2
- package/package.json +1 -1
- package/src/commands/feedback.ts +83 -3
- package/src/commands/pr.ts +66 -21
- package/src/feedback/body.ts +50 -0
- package/src/feedback/github-format.ts +77 -0
- package/src/feedback/github.ts +51 -12
- package/src/pr/carry.ts +64 -0
- package/src/pr/evidence.ts +1 -1
- package/src/pr/lift.ts +29 -12
- package/src/pr/review-state.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.72.0](https://github.com/erclx/canon/compare/v5.71.0...v5.72.0) (2026-10-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **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))
|
|
9
|
+
* **feedback:** read each issue's comments in triage ([#2802](https://github.com/erclx/canon/issues/2802)) ([ab2c761](https://github.com/erclx/canon/commit/ab2c7616c094ba43f4045c48f9fdbbe7bfb5b976))
|
|
10
|
+
* **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))
|
|
11
|
+
* **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))
|
|
12
|
+
* **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))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **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))
|
|
18
|
+
|
|
3
19
|
## [5.71.0](https://github.com/erclx/canon/compare/v5.70.0...v5.71.0) (2026-10-27)
|
|
4
20
|
|
|
5
21
|
|
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
package/src/commands/feedback.ts
CHANGED
|
@@ -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 {
|
|
9
|
-
|
|
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)
|
package/src/commands/pr.ts
CHANGED
|
@@ -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`
|
|
704
|
-
'
|
|
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
|
|
948
|
-
'
|
|
949
|
-
'
|
|
950
|
-
'
|
|
951
|
-
'
|
|
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
|
-
'
|
|
1299
|
-
'
|
|
1300
|
-
'
|
|
1301
|
-
'
|
|
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
|
|
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
|
-
'
|
|
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
|
|
3479
|
-
*
|
|
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
|
-
|
|
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
|
|
3538
|
-
// count line, or cannot be read leaves the check
|
|
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`,
|
package/src/feedback/body.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/feedback/github.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/pr/carry.ts
ADDED
|
@@ -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
|
+
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -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
|
|
193
|
-
* no
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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 =
|
|
221
|
-
|
|
222
|
-
|
|
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 {
|
package/src/pr/review-state.ts
CHANGED
|
@@ -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
|
}
|