@erclx/canon 4.20.1 → 4.21.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.20.1",
4
+ "version": "4.21.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -27,10 +27,10 @@ For inline review comments, read them via `gh api` on the PR's review comments.
27
27
  Also read the CI check status so the fixes cover failing checks, not only review comments:
28
28
 
29
29
  ```bash
30
- gh pr checks <number>
30
+ canon pr checks <number> --json
31
31
  ```
32
32
 
33
- Treat a failing check as a finding to resolve alongside the review comments. When no checks are configured, `gh pr checks` reports none and the flow continues on the review findings alone.
33
+ Read the verdict off the record's `state` rather than off the exit. Treat `failing` as a finding to resolve alongside the review comments. A `pending` covers a tip whose runs have yet to conclude and a tip carrying no run at all, which the record separates on `matched`, and neither is a green to continue on. Fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb.
34
34
 
35
35
  ## Step 2: address each finding
36
36
 
@@ -161,10 +161,10 @@ gh pr comment <number> --body-file .canon/tmp/address-review/reply-<number>.md
161
161
 
162
162
  ## Step 7: confirm resolution
163
163
 
164
- After the follow-up push, watch CI on the PR. Poll `gh pr checks <number>`
165
- until no check is pending, then read the final status. When every finding is
166
- addressed and all checks pass, post one closing comment so the thread has a
167
- clear terminal state:
164
+ After the follow-up push, watch CI on the PR. Poll
165
+ `canon pr checks <number> --json` until the record's `state` leaves `pending`,
166
+ then read it. When every finding is addressed and the state is `passing`, post
167
+ one closing comment so the thread has a clear terminal state:
168
168
 
169
169
  ```bash
170
170
  gh pr comment <number> --body "✅ All review findings addressed, CI green."
@@ -72,6 +72,8 @@ The report is also where the count in `## Parallelism` is legible. That threshol
72
72
 
73
73
  The count reads low, and it errs in the direction that breaks the trigger. A review's `commit.oid` is stamped with the head at submission rather than with the commit the reviewer read, so an author pushing between the diff read and the post leaves the pass recorded against a commit it never saw, and `SEEN` then fires on a head still awaiting its first look at that delta. Measured on `#1299` on 2026-08-31, where a pass written against `5653721` landed stamped `a5ceb40` and the delta it skipped was a real fix. So a `SEEN` on a head you do not recognize is worth one `gh pr view --json reviews` before it is believed, and a wave past three is likelier to trip the fallback late than early.
74
74
 
75
+ The other side of that comparison used to lag as well, which made the two errors compound rather than cancel. `gh pr view --json headRefOid` answers from the pull request object and that object trails the branch ref by up to a minute after a push, reporting nothing about the trail, so a pass stamped ahead of the commit it read was compared against a head stamped behind the commit that exists. Two sessions were wrong off that field on 2026-09-01: a reviewing session posted a finding calling a pushed commit unpushed, and a worker fired a green claim it retracted on its own thread minutes later. The head now comes from `canon pr head`, which resolves the tip through `git ls-remote` and reports which commit each source names, and the object's head stays behind it as the fallback for a target whose CLI predates the verb. The review stamp is unchanged, so the paragraph above still holds on its own half.
76
+
75
77
  ## The watch beside it
76
78
 
77
79
  `${CLAUDE_SKILL_DIR}/scripts/watch.sh` is a long-running loop rather than a scheduled prompt. It reads the open pull request list and the session roster together every sixty seconds and prints one line per new pull request, per worker whose status changed, and per worker that dropped out of the roster. Start it in the background and read what it emits. It writes nothing.
@@ -187,7 +187,25 @@ snapshot() {
187
187
  continue
188
188
  fi
189
189
 
190
- head=$(jq -r '.headRefOid // empty' <<<"$payload")
190
+ # The tip is the authority for the head, not the pull request object. That
191
+ # object lags the ref by up to a minute after a push, so MOVED fired late
192
+ # and the merge-tree read below judged a commit the branch had already left
193
+ # behind. Read on the record's own field rather than on the exit, since an
194
+ # operator shell profile can wrap canon in a function that flattens it.
195
+ # The trailing assignment is load-bearing under `set -e` and `set -o
196
+ # pipefail` at the top of this file. Every refusal exits 1, and a refusal
197
+ # here is ordinary rather than exceptional, so an unguarded pipeline would
198
+ # end the whole poll on the first pull request whose branch was deleted.
199
+ head=$(canon pr head "$n" --json 2>/dev/null | jq -r '.tip // empty') || head=""
200
+
201
+ # The object's head is the fallback rather than the source. This script
202
+ # ships with the plugin and the verb ships with the CLI, so a target on an
203
+ # older binary reaches no `pr head` at all, and answering there with the
204
+ # lagging head is the behavior this poll already had.
205
+ if [ -z "$head" ]; then
206
+ head=$(jq -r '.headRefOid // empty' <<<"$payload")
207
+ fi
208
+
191
209
  if [ -z "$head" ]; then
192
210
  carry_forward "$n" "returned no head"
193
211
  continue
@@ -29,7 +29,7 @@ reader scanning the thread finds the current verdict where the last one sat.
29
29
 
30
30
  ## Step 1: resolve the PR and read context
31
31
 
32
- Resolve the PR: `gh pr view --json number,headRefName,headRefOid,title,body` for the current branch, or use a PR number the user names. The first seven characters of `headRefOid` are `<short-sha>`, which names the body file in Step 4.
32
+ Resolve the PR: `gh pr view --json number,headRefName,headRefOid,title,body` for the current branch, or use a PR number the user names. Take `<headRefOid>` from `canon pr head <number> --json`, off that record's `tip`, and fall back to the `headRefOid` field above when no record comes back, which is a target whose CLI predates the verb. The first seven characters are `<short-sha>`, which names the body file in Step 4.
33
33
 
34
34
  Read these in parallel from the project root, skipping any that do not exist:
35
35
 
@@ -30,7 +30,7 @@ Run `git diff --cached --name-only 2>/dev/null` to check for staged files. If ou
30
30
  5. Invoke `canon:git-stage` to group staged changes and commit by concern
31
31
  6. Invoke `canon:git-branch` to rename branch to match conventional format
32
32
  7. Invoke `canon:git-pr` to push branch and open pull request
33
- 8. After the PR opens, watch CI. Poll `gh pr checks <number>` until no check is pending, then read the final status. On all-pass, continue. On any failure, stop the sequence and report the failing check with its URL. Do not auto-fix. This step may output on failure, the one exception to the no-text-between-steps rule.
33
+ 8. After the PR opens, watch CI. Poll `canon pr checks <number> --json` until the record's `state` leaves `pending`, branching on that field rather than on the exit, and fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb. On `passing`, continue. On `failing`, stop the sequence and report the failing check with its URL. Do not auto-fix. This step may output on failure, the one exception to the no-text-between-steps rule.
34
34
  9. If step 1 wrote or updated at least one memory file, invoke `canon:claude-memory-review` scoped to those entries to propose fixes while session context is fresh. If the pen got nothing, skip this step.
35
35
 
36
36
  A caller wrapping this sequence may act between step 7 and step 8, which is the one gap the order leaves open, since the pull request exists there and nothing has read its checks yet. `claude-autoship` marks the pull request draft in it. Nothing else may go there, and a caller that needs a step anywhere else in the sequence is asking for a change to this body rather than for a place to stand.
@@ -71,6 +71,8 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
71
71
  | `canon labels scan` | Fail a pull request whose title or body carries a phase label, a label a code span quotes, a gitignored record path, or a session link (`--event`, `--json`) |
72
72
  | `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
73
73
  | `canon pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
74
+ | `canon pr head` | Compare the head a pull request object reports against the branch tip the remote carries, naming both shas (`--root`, `--json`) |
75
+ | `canon pr checks` | Report the check runs belonging to the branch tip, reading pending for a tip carrying none rather than clean (`--root`, `--json`) |
74
76
  | `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
75
77
  | `canon repo metadata apply` | Write an explicitly supplied description, homepage, or topic set to the remote through `gh repo edit` (`--description`, `--homepage`, `--topics`, `--root`, `--json`) |
76
78
  | `canon census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
@@ -132,6 +134,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
132
134
  | `labels` | `audit` |
133
135
  | `migrate` | `rename`, `records` |
134
136
  | `autoship` | `classify` |
137
+ | `pr` | `key-changes`, `head`, `checks` |
135
138
  | `audits` | `run`, `list` |
136
139
  | `gate` | `run` |
137
140
 
@@ -28,6 +28,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
28
28
  - [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
29
29
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
30
30
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
31
+ - [Head-sensitive pull request reads](pr-reads.md): Resolving a branch tip from the remote rather than from the pull request object, reading check runs keyed on that tip, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
31
32
  - [Records](records.md): The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
32
33
  - [Restated instructions](restated.md): Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
33
34
  - [Review classification](review-classification.md): Deciding whether a changed set needs the review pass, the two tests it runs, why an empty set refuses rather than skipping, and the written fallback a target on an older binary falls back to
@@ -0,0 +1,73 @@
1
+ ---
2
+ title: Head-sensitive pull request reads
3
+ description: Resolving a branch tip from the remote rather than from the pull request object, reading check runs keyed on that tip, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
4
+ ---
5
+
6
+ # Head-sensitive pull request reads
7
+
8
+ `canon pr head` and `canon pr checks` answer about a commit. Both resolve the branch tip from the remote with `git ls-remote` and report what they found for that sha, rather than taking the pull request object's word for which commit the branch is on.
9
+
10
+ ```bash
11
+ canon pr head
12
+ canon pr head 1341 --json
13
+ canon pr checks
14
+ canon pr checks 1341 --json
15
+ ```
16
+
17
+ ## The pull request object is not the authority for a head
18
+
19
+ `gh pr view --json headRefOid` reads a field on the pull request object. That object trails the branch ref by up to a minute after a push and carries nothing saying how far behind it is, so a session that keys a read on it is describing whichever commit GitHub last folded into the object.
20
+
21
+ Two failures on 2026-09-01 came off that one field. A reviewing session posted a finding calling a pushed commit unpushed. A worker fired a green claim and retracted it on its own thread minutes later. Neither session did anything wrong with the value it was handed. The value was stale and said so nowhere.
22
+
23
+ The remote ref carries no such lag. `git ls-remote --heads origin <branch>` asks the remote itself rather than a tracking ref, which is only as current as the last fetch, and the push worth catching is the one this process never saw.
24
+
25
+ The argument is stated here once. The shipped skill bodies call the verb rather than repeating it, because a sentence telling a session to compare two shas is a sentence a session can decide it already followed.
26
+
27
+ ## What each verb answers
28
+
29
+ `canon pr head` reports `fresh` when the object and the remote name the same commit and `stale` when they disagree, with both shas on the record. The tip is the authority and the object's head is the claim being checked against it.
30
+
31
+ `canon pr checks` reads `repos/{owner}/{repo}/commits/<tip>/check-runs` and collapses it to `passing`, `failing`, or `pending`. A failure outranks a run still going, since a run in flight cannot clear a job that already failed.
32
+
33
+ `gh pr checks` cannot be made to answer this question at all, which is the argument for the move rather than a preference between two working commands. Its `--json` field set is `bucket, completedAt, description, event, link, name, startedAt, state, workflow`, with no sha among them, so a caller cannot even learn which commit its answer describes.
34
+
35
+ ## An empty run list is not a pass
36
+
37
+ Keying the query on a sha is necessary and not sufficient. The endpoint answered with `total_count` 2 and an empty row list during a measured window on 2026-09-02, so a reader that finds no run for the tip and reports `passing` reproduces the false green behind a better query.
38
+
39
+ Both empty cases report `pending` instead: a tip carrying no run yet, and a listing whose rows all belong to some other commit. The record separates them, since `matched` counts the runs belonging to the tip and `foreign` counts the rest, and a caller that wants to tell "not started" from "still going" reads those two numbers rather than the state alone.
40
+
41
+ `matched` counts runs and not distinct checks, which is where it parts company with the list `gh pr checks` prints. A workflow that fires twice on one commit lands two runs under one name, so a pull request whose body was edited after the push reads 3 against that command's 2. Measured on 2026-09-02 against a commit whose phase-label workflow ran on the push and again on a later body edit. Read a `matched` above the check count as that, rather than as the verb disagreeing with the command.
42
+
43
+ A listing carrying even one foreign run reports `pending` whatever the matching half says. A set that describes another commit says nothing about this one, so answering off the rows that happen to match would put a verdict on a set already known to be incomplete.
44
+
45
+ A count above the rows returned reports `pending` for the same reason. The query asks for 100 rows against the endpoint's default of 30, and a commit carrying more than that comes back short, so the verb reports the tip as unread rather than collapsing the page it happens to hold. `reported` against `matched` and `foreign` is where a reader sees which of the three shapes produced the answer.
46
+
47
+ ## Reading the answer
48
+
49
+ Branch on the record rather than on the exit. Both verbs exit 0 whenever they resolved a tip, whatever the verdict, and 1 only when they refused. An operator shell profile can wrap `canon` in a function whose status comes from a trailing command, which flattens every non-zero exit to 0, so the exit is not a channel either verb puts a verdict on. `gh pr checks` reserving exit 8 for pending is the pattern being replaced rather than one to copy.
50
+
51
+ Each refusal names a different repair:
52
+
53
+ | Reason | What it means |
54
+ | ------------------ | ------------------------------------------------------------------------------------------ |
55
+ | `gh-missing` | `gh` is not on the path, so no pull request resolved |
56
+ | `gh-failed` | `gh` could not answer for this branch, so name a number instead |
57
+ | `no-branch` | The pull request carries no head branch name, so no ref could be read |
58
+ | `unresolvable-ref` | The remote read failed, which is not the same answer as an absent branch |
59
+ | `no-remote-branch` | The remote carries no branch by that name, so there is no tip to compare against |
60
+ | `no-object-head` | The pull request object reported no head, so `head` has nothing to compare the tip against |
61
+ | `runs-unreadable` | The check runs for the tip could not be read, which is unread rather than none |
62
+
63
+ A failed remote read and a branch the remote does not carry stay apart rather than collapsing into one reason. Reading the first as the second would report a network refusal as a deleted branch, and the repairs have nothing in common.
64
+
65
+ ## What it costs
66
+
67
+ One remote round trip per call, sampled here at 0.41s, 0.55s, and 0.67s on 2026-09-01 and 2026-09-02, against 0.001s for the local ref read. The spread is the network rather than the command, so read it as roughly half a second and not as a figure to compare a later reading against. Every caller pointed at these verbs already spends a `gh` round trip on the same line, so the added cost lands on a path that was never local to begin with.
68
+
69
+ One head-sensitive read is left on the object deliberately. `canon targets pulls` reads `statusCheckRollup` for every open pull request across every target, where resolving a tip per row would cost one remote read per pull request across a dozen projects, against a surface that reports a listing rather than gating a push.
70
+
71
+ The orchestrator poll spends that same read per open pull request and repeats it on a three-minute timer, which makes it the heaviest caller here rather than an exception to the paragraph above. Six open pull requests is around 120 remote reads an hour. What separates the two cases is what each reading decides rather than what it costs. The poll's head fires the review trigger, so a stale one sends a pass at a commit nobody read or withholds one that is owed. The listing decides nothing, so the same spend buys a fresher column in a report and no correctness at all.
72
+
73
+ `canon pr key-changes` also stays on the object, for a different reason. It reads the body, the file list, and `headRefOid` in one call on purpose, so the three describe one commit. That is a consistency requirement rather than a freshness one, and keying its head elsewhere would break it.
@@ -108,6 +108,13 @@ Findings travel on the PR. `claude-pr-review` posts them there.
108
108
  threads, and pushes a follow-up. `claude-pr-review` then runs again, reading only
109
109
  what the follow-up added.
110
110
 
111
+ Both halves of that loop key on a commit rather than on the pull request object,
112
+ which trails the branch ref by up to a minute after a push and says nothing
113
+ about the trail. `canon pr head` resolves the tip the review pass scopes its
114
+ delta against, and `canon pr checks` reports the runs belonging to that tip, so a
115
+ follow-up push cannot be read as green off the predecessor's completed run. See
116
+ [Head-sensitive pull request reads](agents/pr-reads.md).
117
+
111
118
  What the session channel carries is the handback instruction and the worker's
112
119
  reply to it, which is a notification layer over a record that stays on the PR. A
113
120
  reply that changes an outcome, such as a worker naming the plan question that
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.20.1",
4
+ "version": "4.21.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -88,7 +88,7 @@ function showHelp(): void {
88
88
  `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
89
89
  `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
90
90
  `${GREY}│${NC} autoship [cmd] ${GREY}# Decide whether a changed set needs the review pass (classify)${NC}`,
91
- `${GREY}│${NC} pr [cmd] ${GREY}# Read a pull request body against its own diff (key-changes)${NC}`,
91
+ `${GREY}│${NC} pr [cmd] ${GREY}# Read a pull request against its own diff and its branch tip (key-changes, head, checks)${NC}`,
92
92
  `${GREY}│${NC} repo [cmd] ${GREY}# This repository's own remote metadata (metadata propose, apply)${NC}`,
93
93
  `${GREY}│${NC} census [path] ${GREY}# Report tracked file count, extension breakdown, and line totals${NC}`,
94
94
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
@@ -15,6 +15,8 @@ import {
15
15
  compareKeyChanges,
16
16
  treeRoots,
17
17
  } from '@/pr/bijection'
18
+ import { type CheckRunListing, collapseChecks } from '@/pr/checks'
19
+ import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
18
20
  import { KEY_CHANGES } from '@/pr/paths'
19
21
  import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
20
22
 
@@ -41,6 +43,32 @@ interface KeyChangesOptions {
41
43
  readonly json?: boolean
42
44
  }
43
45
 
46
+ interface ReadOptions {
47
+ readonly root?: string
48
+ readonly json?: boolean
49
+ }
50
+
51
+ /** Why a head-sensitive read produced no answer about a commit. */
52
+ type PullRefusal = 'gh-missing' | 'gh-failed' | 'no-branch' | 'runs-unreadable'
53
+
54
+ /** What a reader does about each way the two sha-keyed verbs produced nothing. */
55
+ const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
56
+ 'gh-missing':
57
+ 'gh is not on the path, so no pull request could be resolved. Name the branch through a checkout that carries one.',
58
+ 'gh-failed':
59
+ 'gh could not answer for this branch. Name the pull request number instead.',
60
+ 'no-branch':
61
+ 'The pull request carries no head branch name, so no ref could be read for it.',
62
+ 'unresolvable-ref':
63
+ 'git could not read the remote, so the branch tip is unknown. That is a failed read rather than an absent branch, so nothing is reported about the head.',
64
+ 'no-remote-branch':
65
+ 'The remote carries no branch by that name. It was deleted or never pushed, so there is no tip to compare against.',
66
+ 'no-object-head':
67
+ 'The pull request object reported no head commit, so there is nothing to compare the tip against.',
68
+ 'runs-unreadable':
69
+ 'The check runs for this commit could not be read. An empty answer here would report a commit as having no check rather than as unread, so nothing is reported.',
70
+ }
71
+
44
72
  /** Why the read produced no comparison, ahead of the ones the compare owns. */
45
73
  type SourceRefusal =
46
74
  | 'gh-missing'
@@ -139,6 +167,75 @@ export function register(program: Command): void {
139
167
  .action(async (number: string | undefined, opts: KeyChangesOptions) => {
140
168
  process.exitCode = await runKeyChanges(number, opts)
141
169
  })
170
+
171
+ pr.command('head')
172
+ .description(
173
+ "Compare a pull request's reported head against the branch tip",
174
+ )
175
+ .argument('[number]', 'Pull request to read, defaulting to this branch')
176
+ .helpOption('-h, --help', 'Show this help message')
177
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
178
+ .option('--json', 'Add a machine-readable record on stdout')
179
+ .addHelpText(
180
+ 'after',
181
+ [
182
+ '',
183
+ 'The pull request object lags the branch ref by up to a minute after a',
184
+ 'push and reports nothing about the lag, so a session reading',
185
+ '`headRefOid` alone calls a pushed commit unpushed. This resolves the',
186
+ 'tip from the remote with `git ls-remote` and reports which commit each',
187
+ 'source names.',
188
+ '',
189
+ 'Read the verdict off the record rather than off the exit. A stale head',
190
+ 'exits 0, because an operator shell profile can wrap this binary in a',
191
+ 'function whose status comes from a trailing command.',
192
+ '',
193
+ 'Exit codes:',
194
+ ' 0 the tip resolved, whether the object agreed with it or not',
195
+ ' 1 refused, with the reason on stderr or in the JSON record',
196
+ '',
197
+ 'Examples:',
198
+ ' canon pr head',
199
+ ' canon pr head 1341 --json',
200
+ '',
201
+ ].join('\n'),
202
+ )
203
+ .action(async (number: string | undefined, opts: ReadOptions) => {
204
+ process.exitCode = await runHead(number, opts)
205
+ })
206
+
207
+ pr.command('checks')
208
+ .description('Report the check runs belonging to the branch tip')
209
+ .argument('[number]', 'Pull request to read, defaulting to this branch')
210
+ .helpOption('-h, --help', 'Show this help message')
211
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
212
+ .option('--json', 'Add a machine-readable record on stdout')
213
+ .addHelpText(
214
+ 'after',
215
+ [
216
+ '',
217
+ '`gh pr checks` cannot be made sha-aware at all: its field set carries no',
218
+ 'sha, so a caller cannot learn which commit its answer describes. This',
219
+ 'resolves the tip from the remote and reads the check runs keyed on it.',
220
+ '',
221
+ 'Pending is reported for a tip carrying no run yet as well as for one',
222
+ 'still going, since the endpoint has answered with a non-zero count and',
223
+ 'an empty row list, and reading that as passing is the false green the',
224
+ 'sha key alone does not close.',
225
+ '',
226
+ 'Exit codes:',
227
+ ' 0 the runs for the tip were read, whatever they say',
228
+ ' 1 refused, with the reason on stderr or in the JSON record',
229
+ '',
230
+ 'Examples:',
231
+ ' canon pr checks',
232
+ ' canon pr checks 1341 --json',
233
+ '',
234
+ ].join('\n'),
235
+ )
236
+ .action(async (number: string | undefined, opts: ReadOptions) => {
237
+ process.exitCode = await runChecks(number, opts)
238
+ })
142
239
  }
143
240
 
144
241
  interface PullRequestRead {
@@ -400,20 +497,287 @@ async function runKeyChanges(
400
497
  return report.unmet.length === 0 ? 0 : 2
401
498
  }
402
499
 
500
+ /** The head branch and reported head of the pull request a caller named. */
501
+ interface PullIdentity {
502
+ readonly number: number | undefined
503
+ readonly branch: string
504
+ readonly head?: string
505
+ }
506
+
507
+ type IdentityRead =
508
+ | { readonly kind: 'read'; readonly identity: PullIdentity }
509
+ | { readonly kind: 'refused'; readonly reason: PullRefusal }
510
+
511
+ /**
512
+ * Runs one `gh` invocation and hands back its stdout, or null when it failed.
513
+ *
514
+ * See src/worktrees/reclaim.ts for why gh needs the stripped environment: it
515
+ * resolves its repository through the same variables git does and they beat
516
+ * `cwd`, so a run from inside a hook would answer for another repository.
517
+ */
518
+ async function gh(
519
+ cwd: string,
520
+ args: readonly string[],
521
+ ): Promise<string | null> {
522
+ try {
523
+ const result = await execa('gh', [...args], {
524
+ cwd,
525
+ timeout: GH_TIMEOUT_MS,
526
+ env: gitEnv(),
527
+ extendEnv: false,
528
+ })
529
+ return result.stdout
530
+ } catch {
531
+ return null
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Reads the head branch and the head the pull request object reports.
537
+ *
538
+ * Both come from one call, so the branch a ref is read for and the head that
539
+ * ref is compared against describe the same object.
540
+ */
541
+ async function readIdentity(
542
+ cwd: string,
543
+ number: string | undefined,
544
+ ): Promise<IdentityRead> {
545
+ if (Bun.which('gh') === null) {
546
+ return { kind: 'refused', reason: 'gh-missing' }
547
+ }
548
+
549
+ const args = ['pr', 'view']
550
+ if (number !== undefined) args.push(number)
551
+ args.push('--json', 'number,headRefName,headRefOid')
552
+
553
+ const stdout = await gh(cwd, args)
554
+ if (stdout === null) return { kind: 'refused', reason: 'gh-failed' }
555
+
556
+ let row: { number?: number; headRefName?: string; headRefOid?: string }
557
+ try {
558
+ row = JSON.parse(stdout)
559
+ } catch {
560
+ return { kind: 'refused', reason: 'gh-failed' }
561
+ }
562
+
563
+ if (row.headRefName === undefined || row.headRefName === '') {
564
+ return { kind: 'refused', reason: 'no-branch' }
565
+ }
566
+
567
+ return {
568
+ kind: 'read',
569
+ identity: {
570
+ number: row.number,
571
+ branch: row.headRefName,
572
+ ...(row.headRefOid !== undefined && { head: row.headRefOid }),
573
+ },
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Reads the branch tip from the remote rather than from a tracking ref.
579
+ *
580
+ * A tracking ref is only as current as the last fetch, and the push this is
581
+ * meant to catch is one this process never saw.
582
+ */
583
+ function refReader(root: string) {
584
+ return async (branch: string): Promise<string | null> => {
585
+ const result = await $`git -C ${root} ls-remote --heads origin ${branch}`
586
+ .env(gitEnv())
587
+ .quiet()
588
+ .nothrow()
589
+ return result.exitCode === 0 ? result.text() : null
590
+ }
591
+ }
592
+
593
+ async function runHead(
594
+ number: string | undefined,
595
+ opts: ReadOptions,
596
+ ): Promise<number> {
597
+ const root = resolve(opts.root ?? process.cwd())
598
+ const emitJson = opts.json ?? false
599
+
600
+ intro('canon pr head')
601
+
602
+ const read = await readIdentity(root, number)
603
+ if (read.kind === 'refused') {
604
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
605
+ }
606
+
607
+ const { identity } = read
608
+ const reading = await resolveHead(
609
+ identity.branch,
610
+ identity.head,
611
+ refReader(root),
612
+ )
613
+
614
+ if (reading.kind === 'refused') {
615
+ return refuseWith(
616
+ reading.reason,
617
+ PULL_REFUSALS[reading.reason],
618
+ emitJson,
619
+ root,
620
+ )
621
+ }
622
+
623
+ logStep('Scope')
624
+ logInfo(
625
+ `${identity.number === undefined ? 'the pull request on' : `#${identity.number} on`} ${reading.branch}`,
626
+ )
627
+
628
+ logStep(reading.state === 'fresh' ? 'Fresh' : 'Stale')
629
+ if (reading.state === 'fresh') {
630
+ logInfo(
631
+ `the object and the remote both name ${reading.tip.slice(0, 8)}, so a read keyed on either describes the same commit`,
632
+ )
633
+ } else {
634
+ logWarn(
635
+ `the remote carries ${reading.tip.slice(0, 8)} and the pull request object still reports ${reading.object.slice(0, 8)}. Key every head-sensitive read on the tip.`,
636
+ )
637
+ }
638
+
639
+ outro()
640
+
641
+ if (emitJson) {
642
+ process.stdout.write(
643
+ `${JSON.stringify({
644
+ root,
645
+ ...(identity.number !== undefined && { number: identity.number }),
646
+ branch: reading.branch,
647
+ state: reading.state,
648
+ tip: reading.tip,
649
+ object: reading.object,
650
+ })}\n`,
651
+ )
652
+ }
653
+
654
+ return 0
655
+ }
656
+
657
+ async function runChecks(
658
+ number: string | undefined,
659
+ opts: ReadOptions,
660
+ ): Promise<number> {
661
+ const root = resolve(opts.root ?? process.cwd())
662
+ const emitJson = opts.json ?? false
663
+
664
+ intro('canon pr checks')
665
+
666
+ const read = await readIdentity(root, number)
667
+ if (read.kind === 'refused') {
668
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
669
+ }
670
+
671
+ const { identity } = read
672
+ const resolved = await resolveTip(identity.branch, refReader(root))
673
+ if (resolved.kind === 'refused') {
674
+ return refuseWith(
675
+ resolved.reason,
676
+ PULL_REFUSALS[resolved.reason],
677
+ emitJson,
678
+ root,
679
+ )
680
+ }
681
+
682
+ // The page size is raised rather than paged through. `collapseChecks` reads
683
+ // a count above the rows it was handed as pending, so a commit past the
684
+ // ceiling reports unread rather than clean, and 100 is the endpoint's own
685
+ // maximum against a default of 30.
686
+ const listed = await gh(root, [
687
+ 'api',
688
+ `repos/{owner}/{repo}/commits/${resolved.tip}/check-runs?per_page=100`,
689
+ ])
690
+ if (listed === null) {
691
+ return refuseWith(
692
+ 'runs-unreadable',
693
+ PULL_REFUSALS['runs-unreadable'],
694
+ emitJson,
695
+ root,
696
+ )
697
+ }
698
+
699
+ let listing: CheckRunListing
700
+ try {
701
+ listing = JSON.parse(listed)
702
+ } catch {
703
+ return refuseWith(
704
+ 'runs-unreadable',
705
+ PULL_REFUSALS['runs-unreadable'],
706
+ emitJson,
707
+ root,
708
+ )
709
+ }
710
+
711
+ const reading = collapseChecks(resolved.tip, listing)
712
+
713
+ logStep('Scope')
714
+ logInfo(
715
+ `${identity.branch} at ${reading.tip.slice(0, 8)}, ${plural(reading.matched, 'run')} belonging to it`,
716
+ )
717
+ // Named rather than folded into the count above, since a listing carrying a
718
+ // run for another commit is what makes the verdict pending on its own.
719
+ if (reading.foreign > 0) {
720
+ logWarn(
721
+ `${plural(reading.foreign, 'further run')} belonging to another commit, so this listing does not describe the tip alone.`,
722
+ )
723
+ }
724
+
725
+ logStep(
726
+ reading.state === 'passing'
727
+ ? 'Passing'
728
+ : reading.state === 'failing'
729
+ ? 'Failing'
730
+ : 'Pending',
731
+ )
732
+ if (reading.state === 'passing') {
733
+ logInfo('every run on the tip completed and none failed')
734
+ } else if (reading.state === 'failing') {
735
+ logWarn('a run on the tip failed, which no run still going can clear')
736
+ } else if (reading.matched === 0) {
737
+ logInfo(
738
+ `no run belongs to the tip yet${reading.reported > 0 ? `, against a reported count of ${reading.reported}` : ''}. That is unread rather than clean.`,
739
+ )
740
+ } else {
741
+ logInfo('a run on the tip has yet to conclude')
742
+ }
743
+
744
+ outro()
745
+
746
+ if (emitJson) {
747
+ process.stdout.write(
748
+ `${JSON.stringify({
749
+ root,
750
+ ...(identity.number !== undefined && { number: identity.number }),
751
+ branch: identity.branch,
752
+ ...reading,
753
+ })}\n`,
754
+ )
755
+ }
756
+
757
+ return 0
758
+ }
759
+
403
760
  /**
404
761
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
405
762
  * so an operator reading the terminal sees the reason rather than a command
406
763
  * that appeared to do nothing.
407
764
  */
408
765
  function refuse(reason: Refusal, emitJson: boolean, root: string): number {
766
+ return refuseWith(reason, REFUSALS[reason], emitJson, root)
767
+ }
768
+
769
+ function refuseWith(
770
+ reason: string,
771
+ message: string,
772
+ emitJson: boolean,
773
+ root: string,
774
+ ): number {
409
775
  logStep('Refused')
410
- logWarn(REFUSALS[reason])
776
+ logWarn(message)
411
777
  outro()
412
778
 
413
779
  if (emitJson) {
414
- process.stdout.write(
415
- `${JSON.stringify({ root, reason, message: REFUSALS[reason] })}\n`,
416
- )
780
+ process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
417
781
  }
418
782
  return 1
419
783
  }
@@ -0,0 +1,83 @@
1
+ import { type ChecksState, type RawCheck, rollup } from '@/targets/pulls'
2
+
3
+ /** One row of `repos/{owner}/{repo}/commits/<sha>/check-runs`. */
4
+ export interface RawCheckRun {
5
+ readonly head_sha?: string
6
+ readonly status?: string
7
+ readonly conclusion?: string | null
8
+ }
9
+
10
+ /** What that endpoint returns, count and rows apart. */
11
+ export interface CheckRunListing {
12
+ readonly total_count?: number
13
+ readonly check_runs?: readonly RawCheckRun[]
14
+ }
15
+
16
+ export interface ChecksReading {
17
+ readonly state: ChecksState
18
+ /** The commit the answer describes. */
19
+ readonly tip: string
20
+ /** Runs the listing carried for the tip. */
21
+ readonly matched: number
22
+ /** Runs the listing carried for some other commit. */
23
+ readonly foreign: number
24
+ /** What the endpoint said it holds, which can exceed the rows it returned. */
25
+ readonly reported: number
26
+ }
27
+
28
+ /**
29
+ * Puts a check-run row into the shape `rollup` reads.
30
+ *
31
+ * REST spells its statuses lowercase and `rollup` reads the GraphQL enum the
32
+ * pull request listing returns, so the vocabulary is converted at this boundary
33
+ * rather than teaching that function a second one. A null conclusion becomes an
34
+ * absent one, since `rollup` reads absence as a run that has not concluded.
35
+ */
36
+ function adapt(run: RawCheckRun): RawCheck {
37
+ return {
38
+ ...(run.status !== undefined && { status: run.status.toUpperCase() }),
39
+ ...(run.conclusion !== undefined &&
40
+ run.conclusion !== null && {
41
+ conclusion: run.conclusion.toUpperCase(),
42
+ }),
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Collapses a check-run listing to one word about one commit.
48
+ *
49
+ * Keying the query on a sha is necessary and not sufficient. The endpoint
50
+ * returned `total_count` 2 with an empty row list during a measured window on
51
+ * 2026-09-02, so a reader that finds no run for the tip and reports `passing`
52
+ * reproduces the false green behind a better query. Both that case and a
53
+ * listing carrying a run for another commit report `pending` instead.
54
+ *
55
+ * The foreign test runs ahead of the collapse rather than after it. A listing
56
+ * that describes another commit says nothing about this one, so answering off
57
+ * the matching half would report a verdict on a set known to be incomplete.
58
+ */
59
+ export function collapseChecks(
60
+ tip: string,
61
+ listing: CheckRunListing,
62
+ ): ChecksReading {
63
+ const runs = listing.check_runs ?? []
64
+ const matched = runs.filter((run) => run.head_sha === tip)
65
+ const reading = {
66
+ tip,
67
+ matched: matched.length,
68
+ foreign: runs.length - matched.length,
69
+ reported: listing.total_count ?? runs.length,
70
+ }
71
+
72
+ if (reading.foreign > 0) return { ...reading, state: 'pending' }
73
+
74
+ // A count above the rows returned is a listing this reader holds only part
75
+ // of, whether the endpoint paged or answered inconsistently. Collapsing the
76
+ // part in hand would report `passing` off a set a failing run can still be
77
+ // sitting outside of, which is the false green keyed on a sha alone.
78
+ if (reading.reported > runs.length) return { ...reading, state: 'pending' }
79
+
80
+ if (matched.length === 0) return { ...reading, state: 'pending' }
81
+
82
+ return { ...reading, state: rollup(matched.map(adapt)) ?? 'pending' }
83
+ }
package/src/pr/head.ts ADDED
@@ -0,0 +1,108 @@
1
+ /** Whether the pull request object's head still names the branch tip. */
2
+ export type HeadState = 'fresh' | 'stale'
3
+
4
+ /** Why the tip could not be resolved from the remote. */
5
+ export type TipRefusal = 'unresolvable-ref' | 'no-remote-branch'
6
+
7
+ /** Why the comparison produced no reading, adding the object's own half. */
8
+ export type HeadRefusal = TipRefusal | 'no-object-head'
9
+
10
+ export type TipReading =
11
+ | { readonly kind: 'read'; readonly tip: string }
12
+ | { readonly kind: 'refused'; readonly reason: TipRefusal }
13
+
14
+ export type HeadReading =
15
+ | {
16
+ readonly kind: 'read'
17
+ readonly state: HeadState
18
+ readonly branch: string
19
+ readonly tip: string
20
+ readonly object: string
21
+ }
22
+ | {
23
+ readonly kind: 'refused'
24
+ readonly reason: HeadRefusal
25
+ readonly branch: string
26
+ }
27
+
28
+ /**
29
+ * Hands back the stdout of `git ls-remote --heads origin <branch>`, or null
30
+ * when the command failed.
31
+ *
32
+ * Injected rather than called here, so the disagreement this module exists to
33
+ * report is a fixture rather than a live push nobody can stage in a test.
34
+ */
35
+ export type RefReader = (branch: string) => Promise<string | null>
36
+
37
+ /**
38
+ * The sha `git ls-remote` reported for exactly this branch.
39
+ *
40
+ * Matched on the whole ref rather than on a suffix, because the command takes
41
+ * its argument as a pattern: `--heads origin x` also returns `refs/heads/feat/x`,
42
+ * and reading the first line back would answer about another branch entirely.
43
+ */
44
+ function findRef(stdout: string, branch: string): string | undefined {
45
+ for (const line of stdout.split('\n')) {
46
+ const [sha, ref] = line.trim().split(/\s+/)
47
+ if (sha !== undefined && ref === `refs/heads/${branch}`) return sha
48
+ }
49
+ return undefined
50
+ }
51
+
52
+ /**
53
+ * Resolves a branch tip from the remote itself.
54
+ *
55
+ * The remote rather than the remote-tracking ref, since a tracking ref is only
56
+ * as current as the last fetch and the whole point here is answering about a
57
+ * push this process never saw. It costs a round trip, measured at 0.410s
58
+ * against 0.001s for the local read.
59
+ *
60
+ * A read that failed and a branch the remote does not carry are separated
61
+ * rather than collapsed. Reading the first as the second would report a
62
+ * network refusal as a deleted branch, which is a different repair.
63
+ */
64
+ export async function resolveTip(
65
+ branch: string,
66
+ read: RefReader,
67
+ ): Promise<TipReading> {
68
+ const stdout = await read(branch)
69
+ if (stdout === null) return { kind: 'refused', reason: 'unresolvable-ref' }
70
+
71
+ const tip = findRef(stdout, branch)
72
+ if (tip === undefined) return { kind: 'refused', reason: 'no-remote-branch' }
73
+
74
+ return { kind: 'read', tip }
75
+ }
76
+
77
+ /**
78
+ * Compares the head a pull request object reports against the branch tip.
79
+ *
80
+ * `gh pr view --json headRefOid` answers from the pull request object, which
81
+ * lags the branch ref by up to a minute after a push and reports nothing about
82
+ * the lag. A session that trusts it calls a pushed commit unpushed and fires a
83
+ * green claim against a commit CI never saw, both of which happened on
84
+ * 2026-09-01. The tip is the authority and the object's head is the claim being
85
+ * checked against it.
86
+ */
87
+ export async function resolveHead(
88
+ branch: string,
89
+ objectHead: string | undefined,
90
+ read: RefReader,
91
+ ): Promise<HeadReading> {
92
+ const resolved = await resolveTip(branch, read)
93
+ if (resolved.kind === 'refused') {
94
+ return { kind: 'refused', reason: resolved.reason, branch }
95
+ }
96
+
97
+ if (objectHead === undefined || objectHead === '') {
98
+ return { kind: 'refused', reason: 'no-object-head', branch }
99
+ }
100
+
101
+ return {
102
+ kind: 'read',
103
+ state: resolved.tip === objectHead ? 'fresh' : 'stale',
104
+ branch,
105
+ tip: resolved.tip,
106
+ object: objectHead,
107
+ }
108
+ }
@@ -87,7 +87,13 @@ interface RawPull {
87
87
  readonly statusCheckRollup?: readonly RawCheck[]
88
88
  }
89
89
 
90
- interface RawCheck {
90
+ /**
91
+ * One check as the pull request listing spells it, in the GraphQL enum.
92
+ *
93
+ * Exported because `src/pr/checks.ts` adapts the REST check-run row onto it
94
+ * rather than writing a second collapse beside `rollup`.
95
+ */
96
+ export interface RawCheck {
91
97
  readonly status?: string
92
98
  readonly conclusion?: string
93
99
  readonly state?: string