@erclx/canon 5.13.0 → 5.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/SKILL.md +3 -2
  3. package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
  4. package/claude/skills/deprecation-migration/SKILL.md +115 -0
  5. package/claude/skills/deprecation-migration/references/adopted.md +50 -0
  6. package/claude/skills/git-followup/REQUIREMENT.md +1 -0
  7. package/claude/skills/git-followup/SKILL.md +3 -0
  8. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  9. package/claude/skills/git-pr/SKILL.md +1 -1
  10. package/claude/skills/git-pr/references/evidence.md +2 -2
  11. package/claude/skills/git-ship/REQUIREMENT.md +3 -0
  12. package/claude/skills/git-ship/SKILL.md +12 -1
  13. package/claude/skills/review-pr/SKILL.md +1 -1
  14. package/claude/skills/review-pr/references/close-out.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +1 -1
  16. package/claude/skills/review-ui/REQUIREMENT.md +5 -2
  17. package/claude/skills/review-ui/SKILL.md +12 -4
  18. package/claude/skills/review-ui/references/driving.md +5 -3
  19. package/claude/skills/review-ui/references/post.md +3 -2
  20. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  22. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  23. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  24. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  25. package/docs/agents/commands.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/markdown-audit.md +1 -1
  28. package/docs/agents/pr-evidence.md +5 -0
  29. package/docs/agents/pr-frames.md +112 -0
  30. package/docs/agents/pr-reads.md +1 -1
  31. package/docs/agents/sandbox.md +3 -3
  32. package/docs/agents/state-scoped-risk.md +2 -2
  33. package/docs/workflow/skill-map.md +15 -14
  34. package/package.json +4 -5
  35. package/scripts/core/check-color-source.sh +11 -1
  36. package/scripts/core/install-check.sh +13 -4
  37. package/scripts/lib/sandbox-dispatch.sh +1 -1
  38. package/scripts/lib/sandbox-fixtures.sh +2 -2
  39. package/scripts/lib/sandbox-path.sh +1 -1
  40. package/scripts/manage-sandbox.sh +2 -2
  41. package/src/claude/cases/misc.ts +5 -0
  42. package/src/claude/skills-audit.ts +1 -0
  43. package/src/commands/pr.ts +452 -0
  44. package/src/commands/sandbox.ts +2 -2
  45. package/src/context/architecture.ts +5 -2
  46. package/src/context/citations.ts +1 -1
  47. package/src/gate/boundaries.ts +509 -0
  48. package/src/gate/measures.ts +8 -19
  49. package/src/gate/stages.ts +12 -28
  50. package/src/pr/frames.ts +323 -0
  51. package/src/sandbox/census.ts +1 -1
  52. package/src/sandbox/coverage.ts +1 -1
  53. package/src/sandbox/expect.ts +0 -1
  54. package/src/shipped/references.ts +8 -10
  55. package/src/targets/pulls.ts +2 -2
  56. package/src/tooling/manifest.ts +2 -3
  57. package/src/tooling/seeds.ts +37 -0
  58. package/tooling/claude/manifest.toml +3 -3
  59. package/tsconfig.json +1 -1
  60. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  61. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  62. package/scripts/core/check-capability-seeding.sh +0 -134
  63. package/scripts/core/check-ignore-parity.sh +0 -141
  64. package/scripts/core/check-plugin-boundary.sh +0 -45
  65. package/scripts/core/check-seed-independence.sh +0 -62
  66. package/scripts/core/check-skill-paths.sh +0 -24
  67. package/scripts/core/list-seed-roots.sh +0 -18
  68. package/scripts/lib/tooling.sh +0 -37
@@ -1,504 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Reports pull request movement since the last run. Reads only.
3
- #
4
- # The shebang is load-bearing. An earlier version ran under the operator's zsh,
5
- # where an unquoted parameter expansion does not word-split, so `set -- $line`
6
- # left every field but the first empty and every head compared unequal. The
7
- # poll reported movement that had not happened, which is the one failure that
8
- # makes a detection tool worth less than no tool.
9
- set -e
10
- set -o pipefail
11
-
12
- # The baseline is per-machine mutable state, so it stays in gitignored scratch
13
- # even though the script is tracked. Resolving the main worktree root rather
14
- # than this file's own folder keeps a poll started from a linked worktree
15
- # reading the baseline a poll started from main wrote.
16
- MAIN_ROOT="$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')"
17
- if [ -z "$MAIN_ROOT" ]; then
18
- echo "poll: not a git repository, so nothing is classified" >&2
19
- exit 1
20
- fi
21
- STATE_DIR="$MAIN_ROOT/.canon/tmp/pr/poll"
22
- mkdir -p "$STATE_DIR"
23
- STATE="$STATE_DIR/baseline.txt"
24
- touch "$STATE"
25
-
26
- # The base branch is read rather than assumed, since this ships to projects that
27
- # do not all call it `main`. A wrong base is not a visible failure: merge-tree
28
- # reports every pull request as conflicted against a ref that does not resolve.
29
- BASE_REF="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
30
- if [ -z "$BASE_REF" ]; then
31
- BASE_REF="origin/$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || echo main)"
32
- fi
33
- BASE_BRANCH="${BASE_REF#origin/}"
34
-
35
- # These eight strings are owned elsewhere and pinned here. `review-pr`
36
- # writes `## Review` and `## Review closed`, and states the full eight-heading
37
- # set once, beside the threshold it already states once. `review-address`
38
- # writes `## Review response`, `## Rebase`, and `## Post-review findings`, the
39
- # last for a finding a worker produces after a close-out rather than in answer
40
- # to one already on the thread. `canon pr evidence`, run through `git-pr` and
41
- # `git-followup`, writes `## Evidence`. `review-ui` writes `## UI review` and
42
- # `## UI review closed`. All these surfaces ship separately, so a heading added
43
- # in any of them breaks a test here that no check reaches across.
44
- #
45
- # All four families match on the first line alone so the tests stay
46
- # symmetric. The reply family carries `## Rebase` and `## Post-review
47
- # findings` beside `## Review response` because neither answers a comment
48
- # already on the thread, which is why both were kept outside the `## Review`
49
- # family rather than folded into it. `## Evidence` answers no comment either,
50
- # and it stays out of the reply family rather than joining it: an evidence
51
- # comment is not a response to a review, so counting it as one would send the
52
- # poll back for a re-review nothing asked for. It is excluded from the
53
- # unmatched filter below instead, on its own.
54
- #
55
- # The review family reaches this file through `canon pr review-state` rather
56
- # than through a filter of its own, so the two headings are pinned here only in
57
- # the fallback that answers for a target whose CLI predates that verb.
58
- # The count alone answers whether a reply is new to this script, which is not
59
- # the same question as whether it is newer than the pass it answers. The stamp
60
- # of the newest reply comes out of the same selection so the recency test costs
61
- # no second filter, and it is the one this file compares against `submittedAt`.
62
- JQ_REPLY_STATE='
63
- [ .comments[]
64
- | select((.body // "") | split("\n")[0] | rtrimstr("\r")
65
- | . == "## Review response" or . == "## Rebase"
66
- or . == "## Post-review findings")
67
- ] as $replies
68
- | ($replies | length | tostring)
69
- + " "
70
- + ([ $replies[] | .createdAt // empty | fromdateiso8601 | floor ]
71
- | max // 0 | tostring)
72
- '
73
- # A comment matching neither family above is the gap this filter exists to
74
- # surface rather than absorb: a worker inventing a sixth heading used to reach
75
- # this script as silence, indistinguishable from no comment at all. The count
76
- # is read the same way the reply count is, a rising value against the baseline
77
- # being new to this script, and the newest heading's text rides along for the
78
- # report. It carries its spaces intact rather than encoded, since it is the
79
- # last field on both sides of the pipe: `${unmatched_state#* }` below strips
80
- # only the first space, and `read -r` further down hands its last named
81
- # variable the rest of the line whole. Neither reads a heading's own spaces
82
- # as a field separator, so a heading round-trips verbatim into the report a
83
- # person reads when deciding whether to answer it by hand.
84
- JQ_UNMATCHED_STATE='
85
- [ .comments[]
86
- | (.body // "") | split("\n")[0] | rtrimstr("\r")
87
- | select(startswith("## "))
88
- | select(. != "## Review" and . != "## Review closed"
89
- and . != "## Review response" and . != "## Rebase"
90
- and . != "## Post-review findings" and . != "## Evidence")
91
- ] as $unclassified
92
- | ($unclassified | length | tostring)
93
- + " "
94
- + (($unclassified | last) // "none")
95
- '
96
-
97
- # Four fields describe the last review pass: the commit it covered, whether it
98
- # left anything owed, how long it has sat, and the instant it read. All four come
99
- # out of `canon pr review-state`, which is the one place the read-time marker
100
- # `review-pr` writes into its own body is parsed. Reading `commit.oid` and
101
- # `submittedAt` off the thread here instead is what this replaces: GitHub stamps
102
- # both when a review is submitted, so a push landing between a pass's read and
103
- # its post moves them onto a commit that pass never opened, and `SEEN` below then
104
- # reports it covered. That is the failure that loses work silently, measured on
105
- # a pull request in this toolkit on 2026-09-07.
106
- #
107
- # `review-pr` posts `## Review` exactly when a dispatch is owed, per the
108
- # threshold that skill states, so the heading is what separates a thread waiting
109
- # on a worker from one nothing is owed on. A project editing the two comment
110
- # filters above for its own headings has nothing to edit here, since the verb
111
- # owns the review family now.
112
- #
113
- # The age is derived from `submittedAt` alone and the pass instant from
114
- # `readAt // submittedAt`, which is deliberate rather than an oversight. The age
115
- # measures how long a posted comment has waited on a human, a question about the
116
- # submission, and the pass instant bounds what that pass had read, a question
117
- # about the read. jq computes the elapsed seconds itself, since `date -d` is
118
- # GNU-only and this script runs wherever the plugin is installed. A review
119
- # carrying no stamp reads as age zero and classifies nothing, which is the same
120
- # answer the carry-forward path gives a pull request this run could not read, and
121
- # reads as pass instant zero, where it sends a reply to be reported rather than
122
- # suppressed. The two readers fail in opposite directions on the same absent
123
- # field on purpose.
124
- #
125
- # `source` is the field this branches on rather than the exit status, since an
126
- # operator shell profile can wrap canon in a function that flattens every
127
- # refusal to zero. A refusal record carries `reason` and no `source`, and it
128
- # yields nothing here, which is what sends the read to the fallback below. A
129
- # record read as a bare answer instead would report every refusal as a pull
130
- # request nobody has reviewed.
131
- JQ_SCOPE_FROM_VERB='
132
- if (.source // "") == "" then empty else
133
- (.commit // "none") as $prior
134
- | (.state // "none") as $heading
135
- | (if .submittedAt == null then 0
136
- else (.submittedAt | fromdateiso8601 | floor) end) as $at
137
- | (if .readAt == null then $at
138
- else (.readAt | fromdateiso8601 | floor) end) as $read
139
- | $prior + " " + $heading
140
- + " " + (if $at == 0 then "0" else (((now | floor) - $at) | tostring) end)
141
- + " " + ($read | tostring)
142
- end
143
- '
144
- # The fallback for a target whose CLI predates the verb, mirroring the `canon pr
145
- # head` fallback below. It reads the submission stamps and therefore carries the
146
- # defect the verb closes, which is the behavior this poll already had. It does
147
- # not parse the marker: a second reader of that format here is the drift the
148
- # verb exists to prevent, and one that lags the format silently reports a
149
- # reviewed commit as unreviewed.
150
- JQ_SCOPE_FALLBACK='
151
- [ .reviews[]
152
- | select((.body // "") | split("\n")[0] | rtrimstr("\r")
153
- | . == "## Review" or . == "## Review closed")
154
- ] | last
155
- | if . == null then "none none 0 0"
156
- else (if .submittedAt == null then 0
157
- else (.submittedAt | fromdateiso8601 | floor)
158
- end) as $at
159
- | ((.commit.oid // "none")
160
- + " "
161
- + (.body | split("\n")[0] | rtrimstr("\r")
162
- | if . == "## Review" then "open" else "closed" end)
163
- + " "
164
- + (if $at == 0 then "0" else (((now | floor) - $at) | tostring) end)
165
- + " "
166
- + ($at | tostring))
167
- end
168
- '
169
-
170
- # The UI family is posted as a pull request review the way the code family is,
171
- # so it is read off `.reviews` rather than `.comments`. No verb parses it, so
172
- # the filter here is its only reader. The newest UI heading decides the state,
173
- # and the head it covered comes from the `review-ui` marker on the body's last
174
- # line, since the submission stamp moves onto a commit pushed while the pass
175
- # drove. A body carrying no marker falls back to that stamp.
176
- JQ_UI_STATE='
177
- [ .reviews[]
178
- | select((.body // "") | split("\n")[0] | rtrimstr("\r")
179
- | . == "## UI review" or . == "## UI review closed")
180
- ] | last
181
- | if . == null then "none none"
182
- else (.body | split("\n")[0] | rtrimstr("\r")
183
- | if . == "## UI review" then "open" else "closed" end) as $state
184
- | ([ .body | split("\n")[] | rtrimstr("\r") | select(. != "") ]
185
- | last // ""
186
- | (capture("^<!-- review-ui: head=(?<sha>[0-9a-f]{7,40}) -->$").sha)
187
- // null) as $marked
188
- | $state + " " + ($marked // .commit.oid // "none")
189
- end
190
- '
191
-
192
- # An open pass this old has nobody on it. The cycle it has to clear is a review
193
- # landing and a worker pushing a follow-up, measured between ten and thirty
194
- # minutes across a day of runs on 2026-08-14, so two hours sits about four times
195
- # past the slowest observed and still well inside a working session. Shorter
196
- # starts catching the slow healthy case this threshold exists to exclude, and
197
- # longer lets a dead dispatch sit past the point a person would have noticed.
198
- # A project whose workers run longer than that raises it here.
199
- STALE_AFTER=7200
200
-
201
- # A pull request this run could not read keeps the line it had, so the GONE
202
- # sweep below does not read the gap as a merge and the baseline does not lose
203
- # the head it already knew. Saying so on stderr is the point: a silent skip is
204
- # how the failure this script was fixed for went unnoticed for a session.
205
- carry_forward() {
206
- local n=$1 reason=$2 old
207
- old=$(grep "^$n " "$STATE" || true)
208
- if [ -n "$old" ]; then
209
- # Every other classification compares a field against itself on a carried
210
- # line, so it fires nothing. That is also what keeps the two stamps unread
211
- # here: the baseline never held them, so both arrive empty, and the count
212
- # test in front of them fails before either reaches a numeric comparison.
213
- # STALLED reads a heading that is carried too, so
214
- # the field is blanked to a value no branch matches. Echoing it intact would
215
- # classify a pull request this run never reached while stderr below says the
216
- # opposite, and one successful read restores it a run later. An already
217
- # reported thread keeps its marker, or an unreadable run would let the same
218
- # thread be reported a second time.
219
- echo "$old" | awk '{ if ($6 != "reported") $6 = "carried"; print }'
220
- echo "poll: #$n $reason, so it keeps its last known state and goes unclassified" >&2
221
- else
222
- echo "poll: #$n $reason, and it has no last known state, so it goes unclassified" >&2
223
- fi
224
- }
225
-
226
- snapshot() {
227
- local numbers n payload head prior resp merges scope review_scope review_state
228
- git fetch -q origin "$BASE_BRANCH" 2>/dev/null || true
229
-
230
- # A failed list reaches the caller as no open pull requests, and that reports
231
- # every tracked one as GONE. It is a louder wrong answer than the one this
232
- # script was fixed for, so the run aborts rather than classify on it.
233
- if ! numbers=$(gh pr list --state open --json number --jq '.[].number' 2>/dev/null); then
234
- echo "poll: the open pull request list could not be read" >&2
235
- return 1
236
- fi
237
-
238
- for n in $numbers; do
239
- # A query that failed and a pull request with genuinely no reviews both
240
- # arrive as an empty result. Reading the first as the second reported a
241
- # reviewed pull request as never reviewed, which routes the re-review to
242
- # the first-pass branch, where it reports and stops, so the movement sits
243
- # unreviewed until a person notices. One query per pull request gives that
244
- # failure a single place to surface.
245
- if ! payload=$(gh pr view "$n" --json headRefOid,reviews,comments 2>/dev/null); then
246
- carry_forward "$n" "could not be read"
247
- continue
248
- fi
249
-
250
- # The tip is the authority for the head, not the pull request object. That
251
- # object lags the ref by up to a minute after a push, so MOVED fired late
252
- # and the merge-tree read below judged a commit the branch had already left
253
- # behind. Read on the record's own field rather than on the exit, since an
254
- # operator shell profile can wrap canon in a function that flattens it.
255
- # The trailing assignment is load-bearing under `set -e` and `set -o
256
- # pipefail` at the top of this file. Every refusal exits 1, and a refusal
257
- # here is ordinary rather than exceptional, so an unguarded pipeline would
258
- # end the whole poll on the first pull request whose branch was deleted.
259
- head=$(canon pr head "$n" --json 2>/dev/null | jq -r '.tip // empty') || head=""
260
-
261
- # The object's head is the fallback rather than the source. This script
262
- # ships with the plugin and the verb ships with the CLI, so a target on an
263
- # older binary reaches no `pr head` at all, and answering there with the
264
- # lagging head is the behavior this poll already had.
265
- if [ -z "$head" ]; then
266
- head=$(jq -r '.headRefOid // empty' <<<"$payload")
267
- fi
268
-
269
- if [ -z "$head" ]; then
270
- carry_forward "$n" "returned no head"
271
- continue
272
- fi
273
-
274
- # Split here rather than carried whole, because the count keeps the fourth
275
- # column every baseline written so far already reads, and the stamp goes to
276
- # the end of the line beside the pass stamp it is compared against.
277
- reply_state=$(jq -r "$JQ_REPLY_STATE" <<<"$payload")
278
- resp=${reply_state%% *}
279
- reply_at=${reply_state##* }
280
-
281
- # The marker `review-pr` writes is the authority for what a pass covered,
282
- # and the verb is the only reader of it. The trailing assignment is
283
- # load-bearing under `set -e` and `set -o pipefail`: every refusal exits 1,
284
- # and a refusal here is ordinary rather than exceptional, so an unguarded
285
- # pipeline would end the whole poll on the first pull request it could not
286
- # answer for.
287
- scope=$(canon pr review-state "$n" --json 2>/dev/null) || scope=""
288
- review_scope=$(jq -r "$JQ_SCOPE_FROM_VERB" <<<"$scope" 2>/dev/null) || review_scope=""
289
- if [ -z "$review_scope" ]; then
290
- review_scope=$(jq -r "$JQ_SCOPE_FALLBACK" <<<"$payload")
291
- fi
292
- # Four space-separated fields. The commit leads, so the line below carries
293
- # it as its own third the way every baseline already reads, and the other
294
- # three ride on as the sixth, seventh, and eighth.
295
- prior=${review_scope%% *}
296
- review_state=${review_scope#* }
297
- # Split the same way as the reply state, carried as the line's tenth and
298
- # eleventh fields.
299
- unmatched_state=$(jq -r "$JQ_UNMATCHED_STATE" <<<"$payload")
300
- unmatched_count=${unmatched_state%% *}
301
- unmatched_heading=${unmatched_state#* }
302
-
303
- # One token, so the heading above stays the line's last field and keeps its
304
- # spaces. A marker may name a short sha, so coverage is a prefix match.
305
- ui_read=$(jq -r "$JQ_UI_STATE" <<<"$payload")
306
- ui_heading=${ui_read%% *}
307
- ui_commit=${ui_read#* }
308
- if [ "$ui_heading" = none ]; then
309
- ui_state=none
310
- elif [ "$ui_commit" != none ] && [[ "$head" == "$ui_commit"* ]]; then
311
- ui_state="$ui_heading-head-${ui_commit:0:7}"
312
- else
313
- ui_state="$ui_heading-behind-${ui_commit:0:7}"
314
- fi
315
-
316
- # `gh pr view --json mergeable` reports UNKNOWN until GitHub finishes
317
- # computing it, which is exactly when a poll asks. merge-tree answers
318
- # locally against the base this machine has, so it never returns UNKNOWN.
319
- git fetch -q origin "pull/$n/head" 2>/dev/null || true
320
- if ! git cat-file -e "${head}^{commit}" 2>/dev/null ||
321
- ! git rev-parse --verify -q "$BASE_REF" >/dev/null; then
322
- # merge-tree exits non-zero on a ref it cannot resolve as well as on a
323
- # real conflict, and the two are indistinguishable from its status. Both
324
- # sides are checked, because an unresolvable base reports every pull
325
- # request as conflicted rather than one, which is the louder half.
326
- merges=unknown
327
- elif git merge-tree --write-tree "$BASE_REF" "$head" >/dev/null 2>&1; then
328
- merges=clean
329
- else
330
- merges=conflict
331
- fi
332
-
333
- echo "$n $head ${prior:-none} $resp $merges $review_state $reply_at $unmatched_count $ui_state $unmatched_heading"
334
- done
335
- }
336
-
337
- # The reason is printed by whichever branch failed, since this one cannot tell
338
- # a list query from a parse and naming either would misattribute the other.
339
- if ! NEW=$(snapshot); then
340
- echo "poll: nothing is classified this run and the baseline is unchanged" >&2
341
- exit 1
342
- fi
343
- CHANGED=0
344
- # The baseline is written from here rather than from the snapshot, because the
345
- # heading field is the one column a classification rewrites. STALLED reports a
346
- # standing condition rather than a transition, so without a written marker it
347
- # would fire on every later run and the board would never read "No movement."
348
- FINAL=""
349
-
350
- while read -r n head prior resp merges heading age pass_at reply_at unmatched_count ui unmatched_heading; do
351
- [ -z "$n" ] && continue
352
- state=$heading
353
- old=$(grep "^$n " "$STATE" || true)
354
- if [ -z "$old" ]; then
355
- # A pull request first seen here may already carry a pass, when it opened
356
- # and was reviewed between two runs. Reporting it as new invites a first
357
- # pass the thread already has.
358
- if [ "$prior" = "$head" ]; then
359
- echo "SEEN #$n at ${head:0:7}, first sighting, already covered by a pass"
360
- elif [ "$merges" = unknown ]; then
361
- # `unknown` means no merge was attempted, so it is left off the line
362
- # rather than printed where a verdict belongs.
363
- echo "OPENED #$n at ${head:0:7}"
364
- else
365
- echo "OPENED #$n at ${head:0:7}, $merges against $BASE_BRANCH"
366
- fi
367
- CHANGED=1
368
- FINAL+="$n $head $prior $resp $merges $state $unmatched_count $ui"$'\n'
369
- continue
370
- fi
371
- old_head=$(echo "$old" | cut -d' ' -f2)
372
- old_resp=$(echo "$old" | cut -d' ' -f4)
373
- old_merges=$(echo "$old" | cut -d' ' -f5)
374
- # Only the marker is read off this field, so a baseline written before it
375
- # existed yields empty and reads as not yet reported, which is what it is. The
376
- # age decides the rest, so the first run after an upgrade needs no history and
377
- # classifies a thread already past the threshold rather than waiting a run.
378
- old_heading=$(echo "$old" | cut -d' ' -f6)
379
- old_unmatched=$(echo "$old" | cut -d' ' -f7)
380
- # Written from the first run that read a UI pass, so an older baseline yields
381
- # empty and the first UI state this script sees is reported as new.
382
- old_ui=$(echo "$old" | cut -d' ' -f8)
383
-
384
- # A conflict arrives from the base moving, not from the branch, so it is
385
- # reported on the transition rather than only when the head changes.
386
- if [ "$merges" = conflict ] && [ "$old_merges" != conflict ]; then
387
- echo "CONFLICT #$n no longer merges into $BASE_BRANCH"
388
- CHANGED=1
389
- fi
390
-
391
- # A rising count is what is new to this script, the same test RESPONSE
392
- # below runs against the reply family. It fires on a tracked pull request
393
- # only: a first sighting reports SEEN or OPENED and takes whatever count
394
- # already sits on the thread as its starting baseline rather than flagging
395
- # history retroactively. A carried line supplies neither, since
396
- # carry_forward re-echoes the shorter baseline shape rather than a full
397
- # snapshot line, so both sides default to zero the way old_unmatched
398
- # already does.
399
- if [ "${unmatched_count:-0}" -gt "${old_unmatched:-0}" ]; then
400
- echo "UNMATCHED #$n posted under '$unmatched_heading'"
401
- CHANGED=1
402
- fi
403
-
404
- # The UI pass reports on its transition, the way CONFLICT does, since its
405
- # state moves on a post or on a push and either one is the moment it is owed
406
- # an action. A carried line supplies no state, so nothing fires on it. A
407
- # verdict a push left behind reports separately from MOVED above, since it
408
- # owes a fresh UI pass rather than a code re-review. The state carries the
409
- # covered commit, so a second UI pass at a new head reads as a transition even
410
- # when its heading repeats.
411
- if [ -n "$ui" ] && [ "$ui" != none ] && [ "$ui" != "$old_ui" ]; then
412
- ui_heading=${ui%%-*}
413
- ui_commit=${ui##*-}
414
- case "$ui" in
415
- open-head-*)
416
- echo "UI-OPEN #$n open at $ui_commit"
417
- ;;
418
- closed-head-*)
419
- echo "UI-CLOSED #$n closed at $ui_commit"
420
- ;;
421
- *)
422
- echo "UI-STALE #$n $ui_heading at $ui_commit, behind ${head:0:7}"
423
- ;;
424
- esac
425
- CHANGED=1
426
- fi
427
-
428
- if [ "$head" != "$old_head" ]; then
429
- if [ "$prior" = "none" ]; then
430
- echo "MOVED #$n -> ${head:0:7}, never reviewed"
431
- elif [ "$prior" = "$head" ]; then
432
- # An out-of-band pass reviewed this head before the poll saw it move, so
433
- # the range is empty because it is covered rather than because it broke.
434
- # Without this the force-push branch below claims a rewrite that never
435
- # happened, which is the class of false report the shebang note names.
436
- echo "SEEN #$n -> ${head:0:7}, already covered by the last pass"
437
- else
438
- # The range needs both commits local, and a force-push leaves the prior
439
- # one unreachable. Count what resolves and say nothing when it does not,
440
- # rather than reporting a zero that reads as no new work.
441
- git fetch -q origin "pull/$n/head" 2>/dev/null || true
442
- since=$(git log --oneline "${prior}..${head}" 2>/dev/null | wc -l | tr -d ' ' || true)
443
- if [ -n "$since" ] && [ "$since" != "0" ]; then
444
- echo "MOVED #$n -> ${head:0:7}, $since commit(s) since your last pass"
445
- else
446
- echo "MOVED #$n -> ${head:0:7}, range unresolved, likely force-pushed"
447
- fi
448
- fi
449
- CHANGED=1
450
- elif [ "$resp" -gt "$old_resp" ] &&
451
- { [ "$pass_at" = 0 ] || [ "$reply_at" -gt "$pass_at" ]; }; then
452
- # The count says the reply is new to this script and the stamp says it is
453
- # newer than the pass, and both are needed. A worker answers a finding and
454
- # the reviewing session closes out seconds later, so the count alone reports
455
- # an answered thread on the next run and the session spends a turn learning
456
- # `review-pr` will refuse it. The gaps observed were 96, 24, and 35
457
- # seconds, which is a reviewing session reading a reply and posting, so this
458
- # is the ordinary handback rather than a race.
459
- #
460
- # A pull request carrying no pass at all reads as stamp zero and reports,
461
- # which is the case the count was the right test for: a reply with nothing
462
- # behind it is a worker talking to nobody and worth the turn. The test is
463
- # strictly greater to match the guard `review-pr` states on the same
464
- # two fields, which drops a reply landing inside the same second as the
465
- # pass, a narrower failure than the one it removes.
466
- #
467
- # The gate sits in the condition rather than in the body so a suppressed
468
- # reply falls through to STALLED below, which is a thread this run should
469
- # still be able to reach.
470
- echo "RESPONSE #$n answered with no new commit"
471
- CHANGED=1
472
- elif [ "$heading" = open ] && [ "$old_heading" != reported ] &&
473
- [ "$prior" = "$head" ] && [ "$age" -ge "$STALE_AFTER" ]; then
474
- # Three things at once: the last pass is open, it covers the head so no
475
- # commit followed it, and the two branches above found no reply either. The
476
- # open heading is posted exactly when a dispatch is owed, per the threshold
477
- # `review-pr` states, so a pass owing one left it here at any grade
478
- # it carried. Any two of these describe an ordinary review waiting on a
479
- # worker, which is why the age carries the third: without it every
480
- # dispatched worker is reported minutes into the work it was sent to do,
481
- # and a signal firing on the healthy path is one an operator learns to
482
- # skip. The report still asks for a confirmation rather than asserting.
483
- echo "STALLED #$n open at ${head:0:7}, no commit or reply in $((age / 3600))h"
484
- state=reported
485
- CHANGED=1
486
- elif [ "$heading" = open ] && [ "$old_heading" = reported ]; then
487
- # Nothing above fired, so the thread has not moved since it was reported.
488
- # Carry the marker rather than the freshly derived heading, or the state
489
- # re-enters next run and STALLED oscillates instead of reporting once.
490
- state=reported
491
- fi
492
- FINAL+="$n $head $prior $resp $merges $state $unmatched_count ${ui:-$old_ui}"$'\n'
493
- done <<<"$NEW"
494
-
495
- while read -r n _rest; do
496
- [ -z "$n" ] && continue
497
- echo "$NEW" | grep -q "^$n " || {
498
- echo "GONE #$n merged or closed"
499
- CHANGED=1
500
- }
501
- done <"$STATE"
502
-
503
- [ "$CHANGED" -eq 0 ] && echo "No movement."
504
- printf '%s' "$FINAL" >"$STATE"