@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +3 -2
- package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
- package/claude/skills/deprecation-migration/SKILL.md +115 -0
- package/claude/skills/deprecation-migration/references/adopted.md +50 -0
- package/claude/skills/git-followup/REQUIREMENT.md +1 -0
- package/claude/skills/git-followup/SKILL.md +3 -0
- package/claude/skills/git-pr/REQUIREMENT.md +1 -0
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +2 -2
- package/claude/skills/git-ship/REQUIREMENT.md +3 -0
- package/claude/skills/git-ship/SKILL.md +12 -1
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/close-out.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/review-ui/REQUIREMENT.md +5 -2
- package/claude/skills/review-ui/SKILL.md +12 -4
- package/claude/skills/review-ui/references/driving.md +5 -3
- package/claude/skills/review-ui/references/post.md +3 -2
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
- package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
- package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
- package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
- package/docs/agents/commands.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +5 -0
- package/docs/agents/pr-frames.md +112 -0
- package/docs/agents/pr-reads.md +1 -1
- package/docs/agents/sandbox.md +3 -3
- package/docs/agents/state-scoped-risk.md +2 -2
- package/docs/workflow/skill-map.md +15 -14
- package/package.json +4 -5
- package/scripts/core/check-color-source.sh +11 -1
- package/scripts/core/install-check.sh +13 -4
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-fixtures.sh +2 -2
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/manage-sandbox.sh +2 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +1 -0
- package/src/commands/pr.ts +452 -0
- package/src/commands/sandbox.ts +2 -2
- package/src/context/architecture.ts +5 -2
- package/src/context/citations.ts +1 -1
- package/src/gate/boundaries.ts +509 -0
- package/src/gate/measures.ts +8 -19
- package/src/gate/stages.ts +12 -28
- package/src/pr/frames.ts +323 -0
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +1 -1
- package/src/sandbox/expect.ts +0 -1
- package/src/shipped/references.ts +8 -10
- package/src/targets/pulls.ts +2 -2
- package/src/tooling/manifest.ts +2 -3
- package/src/tooling/seeds.ts +37 -0
- package/tooling/claude/manifest.toml +3 -3
- package/tsconfig.json +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
- package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
- package/scripts/core/check-capability-seeding.sh +0 -134
- package/scripts/core/check-ignore-parity.sh +0 -141
- package/scripts/core/check-plugin-boundary.sh +0 -45
- package/scripts/core/check-seed-independence.sh +0 -62
- package/scripts/core/check-skill-paths.sh +0 -24
- package/scripts/core/list-seed-roots.sh +0 -18
- package/scripts/lib/tooling.sh +0 -37
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
// Reports pull request movement since the last run. Reads only.
|
|
2
|
+
//
|
|
3
|
+
// Run as `bun poll.ts`. The pure pieces are exported so a test drives the code
|
|
4
|
+
// a loop runs, and `main` runs only when this file is the entry point.
|
|
5
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { baseBranch, baseRef, mainRoot, run } from './repo'
|
|
9
|
+
|
|
10
|
+
interface Comment {
|
|
11
|
+
body?: null | string
|
|
12
|
+
createdAt?: null | string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Review {
|
|
16
|
+
body?: null | string
|
|
17
|
+
commit?: { oid?: null | string } | null
|
|
18
|
+
submittedAt?: null | string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Payload {
|
|
22
|
+
comments?: Comment[]
|
|
23
|
+
headRefOid?: string
|
|
24
|
+
reviews?: Review[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Four fields describing the last review pass, in the order the line carries them. */
|
|
28
|
+
export interface Scope {
|
|
29
|
+
age: number
|
|
30
|
+
commit: string
|
|
31
|
+
readAt: number
|
|
32
|
+
state: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Row {
|
|
36
|
+
age: number
|
|
37
|
+
head: string
|
|
38
|
+
heading: string
|
|
39
|
+
merges: string
|
|
40
|
+
number: string
|
|
41
|
+
passAt: number
|
|
42
|
+
prior: string
|
|
43
|
+
replyAt: number
|
|
44
|
+
resp: number
|
|
45
|
+
ui: string
|
|
46
|
+
unmatchedCount: number
|
|
47
|
+
unmatchedHeading: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One baseline line, kept as the strings it was written with. */
|
|
51
|
+
export interface Baseline {
|
|
52
|
+
head: string
|
|
53
|
+
merges: string
|
|
54
|
+
number: string
|
|
55
|
+
prior: string
|
|
56
|
+
resp: string
|
|
57
|
+
state: string
|
|
58
|
+
ui: string
|
|
59
|
+
unmatched: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// An open pass this old has nobody on it. The cycle it has to clear is a review
|
|
63
|
+
// landing and a worker pushing a follow-up, measured between ten and thirty
|
|
64
|
+
// minutes across a day of runs on 2026-08-14, so two hours sits about four times
|
|
65
|
+
// past the slowest observed and still well inside a working session. A project
|
|
66
|
+
// whose workers run longer than that raises it here.
|
|
67
|
+
export const STALE_AFTER = 7200
|
|
68
|
+
|
|
69
|
+
// The heading strings are owned elsewhere and pinned here. `review-pr` writes
|
|
70
|
+
// the review pair, `review-address` the reply family, `canon pr evidence` the
|
|
71
|
+
// evidence heading, and `review-ui` the UI pair. All of these ship separately,
|
|
72
|
+
// so a heading added in one of them breaks a test here that no check reaches
|
|
73
|
+
// across. The review family reaches this file through `canon pr review-state`,
|
|
74
|
+
// so its two headings are pinned only for the fallback that answers a target
|
|
75
|
+
// whose CLI predates that verb.
|
|
76
|
+
const REVIEW_HEADINGS = ['## Review', '## Review closed']
|
|
77
|
+
const REPLY_HEADINGS = [
|
|
78
|
+
'## Review response',
|
|
79
|
+
'## Rebase',
|
|
80
|
+
'## Post-review findings',
|
|
81
|
+
]
|
|
82
|
+
// An evidence comment answers no review, so it stays out of the reply family
|
|
83
|
+
// and is excluded from the unmatched filter on its own.
|
|
84
|
+
const KNOWN_HEADINGS = [...REVIEW_HEADINGS, ...REPLY_HEADINGS, '## Evidence']
|
|
85
|
+
const UI_HEADINGS = ['## UI review', '## UI review closed']
|
|
86
|
+
const UI_MARKER = /^<!-- review-ui: head=([0-9a-f]{7,40}) -->$/
|
|
87
|
+
|
|
88
|
+
const firstLine = (body: null | string | undefined): string =>
|
|
89
|
+
(body ?? '').split('\n')[0].replace(/\r$/, '')
|
|
90
|
+
|
|
91
|
+
const toEpoch = (stamp: null | string | undefined): null | number => {
|
|
92
|
+
if (stamp === null || stamp === undefined) return null
|
|
93
|
+
const ms = Date.parse(stamp)
|
|
94
|
+
|
|
95
|
+
return Number.isNaN(ms) ? null : Math.floor(ms / 1000)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ageOf = (at: number, now: number): number => (at === 0 ? 0 : now - at)
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The count alone answers whether a reply is new to this script, which is not
|
|
102
|
+
* the same question as whether it is newer than the pass it answers, so the
|
|
103
|
+
* newest reply's stamp comes out of the same selection.
|
|
104
|
+
*/
|
|
105
|
+
export const replyState = (
|
|
106
|
+
comments: Comment[],
|
|
107
|
+
): { count: number; newestAt: number } => {
|
|
108
|
+
const replies = comments.filter((comment) =>
|
|
109
|
+
REPLY_HEADINGS.includes(firstLine(comment.body)),
|
|
110
|
+
)
|
|
111
|
+
const stamps = replies
|
|
112
|
+
.map((reply) => toEpoch(reply.createdAt))
|
|
113
|
+
.filter((stamp): stamp is number => stamp !== null)
|
|
114
|
+
|
|
115
|
+
return { count: replies.length, newestAt: Math.max(0, ...stamps) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A comment matching no family is the gap this filter surfaces rather than
|
|
120
|
+
* absorbs: a worker inventing a new heading would otherwise reach this script
|
|
121
|
+
* as silence, indistinguishable from no comment at all.
|
|
122
|
+
*/
|
|
123
|
+
export const unmatchedState = (
|
|
124
|
+
comments: Comment[],
|
|
125
|
+
): { count: number; heading: string } => {
|
|
126
|
+
const unclassified = comments
|
|
127
|
+
.map((comment) => firstLine(comment.body))
|
|
128
|
+
.filter((line) => line.startsWith('## ') && !KNOWN_HEADINGS.includes(line))
|
|
129
|
+
|
|
130
|
+
return { count: unclassified.length, heading: unclassified.at(-1) ?? 'none' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reads the verb's record. `source` is what this branches on rather than an
|
|
135
|
+
* exit status, since a shell profile can wrap canon in a function that flattens
|
|
136
|
+
* every refusal to zero. A refusal record carries `reason` and no `source`, and
|
|
137
|
+
* yields null here, which sends the read to the fallback.
|
|
138
|
+
*/
|
|
139
|
+
export const scopeFromVerb = (record: unknown, now: number): null | Scope => {
|
|
140
|
+
if (typeof record !== 'object' || record === null) return null
|
|
141
|
+
const fields = record as Record<string, unknown>
|
|
142
|
+
if (typeof fields.source !== 'string' || fields.source === '') return null
|
|
143
|
+
|
|
144
|
+
const submitted = fields.submittedAt
|
|
145
|
+
const at =
|
|
146
|
+
submitted === null || submitted === undefined
|
|
147
|
+
? 0
|
|
148
|
+
: toEpoch(String(submitted))
|
|
149
|
+
if (at === null) return null
|
|
150
|
+
|
|
151
|
+
const read = fields.readAt
|
|
152
|
+
const readAt =
|
|
153
|
+
read === null || read === undefined ? at : toEpoch(String(read))
|
|
154
|
+
if (readAt === null) return null
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
age: ageOf(at, now),
|
|
158
|
+
commit: typeof fields.commit === 'string' ? fields.commit : 'none',
|
|
159
|
+
readAt,
|
|
160
|
+
state: typeof fields.state === 'string' ? fields.state : 'none',
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The fallback for a target whose CLI predates the verb. It reads the
|
|
166
|
+
* submission stamps, so it carries the defect the verb closes: a push landing
|
|
167
|
+
* between a pass's read and its post moves the stamp onto a commit that pass
|
|
168
|
+
* never opened. It does not parse the marker, since a second reader of that
|
|
169
|
+
* format here is the drift the verb exists to prevent.
|
|
170
|
+
*/
|
|
171
|
+
export const scopeFallback = (payload: Payload, now: number): Scope => {
|
|
172
|
+
const last = (payload.reviews ?? [])
|
|
173
|
+
.filter((review) => REVIEW_HEADINGS.includes(firstLine(review.body)))
|
|
174
|
+
.at(-1)
|
|
175
|
+
if (last === undefined) {
|
|
176
|
+
return { age: 0, commit: 'none', readAt: 0, state: 'none' }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const at = toEpoch(last.submittedAt) ?? 0
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
age: ageOf(at, now),
|
|
183
|
+
commit: last.commit?.oid ?? 'none',
|
|
184
|
+
readAt: at,
|
|
185
|
+
state: firstLine(last.body) === '## Review' ? 'open' : 'closed',
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The UI family is posted as a pull request review, so it is read off
|
|
191
|
+
* `reviews`. The newest UI heading decides the state, and the head it covered
|
|
192
|
+
* comes from the marker on the body's last line, since the submission stamp
|
|
193
|
+
* moves onto a commit pushed while the pass drove. A marker may name a short
|
|
194
|
+
* sha, so coverage is a prefix match.
|
|
195
|
+
*/
|
|
196
|
+
export const uiState = (payload: Payload, head: string): string => {
|
|
197
|
+
const last = (payload.reviews ?? [])
|
|
198
|
+
.filter((review) => UI_HEADINGS.includes(firstLine(review.body)))
|
|
199
|
+
.at(-1)
|
|
200
|
+
if (last === undefined) return 'none'
|
|
201
|
+
|
|
202
|
+
const heading = firstLine(last.body) === '## UI review' ? 'open' : 'closed'
|
|
203
|
+
const lastLine = (last.body ?? '')
|
|
204
|
+
.split('\n')
|
|
205
|
+
.map((line) => line.replace(/\r$/, ''))
|
|
206
|
+
.filter((line) => line !== '')
|
|
207
|
+
.at(-1)
|
|
208
|
+
const marked = lastLine === undefined ? null : UI_MARKER.exec(lastLine)
|
|
209
|
+
const commit = marked?.[1] ?? last.commit?.oid ?? 'none'
|
|
210
|
+
const covered = commit !== 'none' && head.startsWith(commit)
|
|
211
|
+
|
|
212
|
+
return `${heading}-${covered ? 'head' : 'behind'}-${commit.slice(0, 7)}`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Takes the first eight fields and ignores the rest, so a short or padded line still reads. */
|
|
216
|
+
export const parseBaseline = (line: string): Baseline => {
|
|
217
|
+
const fields = line.split(' ')
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
head: fields[1] ?? '',
|
|
221
|
+
merges: fields[4] ?? '',
|
|
222
|
+
number: fields[0] ?? '',
|
|
223
|
+
prior: fields[2] ?? '',
|
|
224
|
+
resp: fields[3] ?? '',
|
|
225
|
+
state: fields[5] ?? '',
|
|
226
|
+
ui: fields[7] ?? '',
|
|
227
|
+
unmatched: fields[6] ?? '',
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const formatBaseline = (entry: Baseline): string =>
|
|
232
|
+
[
|
|
233
|
+
entry.number,
|
|
234
|
+
entry.head,
|
|
235
|
+
entry.prior,
|
|
236
|
+
entry.resp,
|
|
237
|
+
entry.merges,
|
|
238
|
+
entry.state,
|
|
239
|
+
entry.unmatched,
|
|
240
|
+
entry.ui,
|
|
241
|
+
].join(' ')
|
|
242
|
+
|
|
243
|
+
const short = (head: string): string => head.slice(0, 7)
|
|
244
|
+
|
|
245
|
+
export interface Classification {
|
|
246
|
+
baseline: Baseline
|
|
247
|
+
isChanged: boolean
|
|
248
|
+
lines: string[]
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ClassifyContext {
|
|
252
|
+
base: string
|
|
253
|
+
/** Commits between the prior pass and the head, zero when the range does not resolve. */
|
|
254
|
+
countSince: (prior: string, head: string) => number
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A pull request first seen here may already carry a pass, when it opened and
|
|
259
|
+
* was reviewed between two runs, so it reports SEEN rather than a new pull
|
|
260
|
+
* request that invites a first pass the thread already has.
|
|
261
|
+
*/
|
|
262
|
+
const classifyFirstSighting = (
|
|
263
|
+
row: Row,
|
|
264
|
+
context: ClassifyContext,
|
|
265
|
+
): Classification => {
|
|
266
|
+
const baseline: Baseline = {
|
|
267
|
+
head: row.head,
|
|
268
|
+
merges: row.merges,
|
|
269
|
+
number: row.number,
|
|
270
|
+
prior: row.prior,
|
|
271
|
+
resp: String(row.resp),
|
|
272
|
+
state: row.heading,
|
|
273
|
+
ui: row.ui,
|
|
274
|
+
unmatched: String(row.unmatchedCount),
|
|
275
|
+
}
|
|
276
|
+
const at = short(row.head)
|
|
277
|
+
let line = `OPENED #${row.number} at ${at}, ${row.merges} against ${context.base}`
|
|
278
|
+
if (row.prior === row.head) {
|
|
279
|
+
line = `SEEN #${row.number} at ${at}, first sighting, already covered by a pass`
|
|
280
|
+
} else if (row.merges === 'unknown') {
|
|
281
|
+
line = `OPENED #${row.number} at ${at}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { baseline, isChanged: true, lines: [line] }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const classifyUi = (row: Row, old: Baseline): string[] => {
|
|
288
|
+
if (row.ui === '' || row.ui === 'none' || row.ui === old.ui) return []
|
|
289
|
+
|
|
290
|
+
const heading = row.ui.split('-')[0]
|
|
291
|
+
const commit = row.ui.split('-').at(-1)
|
|
292
|
+
if (row.ui.startsWith('open-head-')) {
|
|
293
|
+
return [`UI-OPEN #${row.number} open at ${commit}`]
|
|
294
|
+
}
|
|
295
|
+
if (row.ui.startsWith('closed-head-')) {
|
|
296
|
+
return [`UI-CLOSED #${row.number} closed at ${commit}`]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return [
|
|
300
|
+
`UI-STALE #${row.number} ${heading} at ${commit}, behind ${short(row.head)}`,
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The heading field is the one column a classification rewrites. */
|
|
305
|
+
export const classify = (
|
|
306
|
+
row: Row,
|
|
307
|
+
old: Baseline | undefined,
|
|
308
|
+
context: ClassifyContext,
|
|
309
|
+
): Classification => {
|
|
310
|
+
if (old === undefined) return classifyFirstSighting(row, context)
|
|
311
|
+
|
|
312
|
+
const lines: string[] = []
|
|
313
|
+
let state = row.heading
|
|
314
|
+
|
|
315
|
+
// A conflict arrives from the base moving, not from the branch, so it is
|
|
316
|
+
// reported on the transition rather than only when the head changes.
|
|
317
|
+
if (row.merges === 'conflict' && old.merges !== 'conflict') {
|
|
318
|
+
lines.push(`CONFLICT #${row.number} no longer merges into ${context.base}`)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A rising count is what is new here. It fires on a tracked pull request
|
|
322
|
+
// only: a first sighting takes whatever already sits on the thread as its
|
|
323
|
+
// baseline rather than flagging history retroactively.
|
|
324
|
+
if (row.unmatchedCount > (Number.parseInt(old.unmatched, 10) || 0)) {
|
|
325
|
+
lines.push(
|
|
326
|
+
`UNMATCHED #${row.number} posted under '${row.unmatchedHeading}'`,
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The UI pass reports on its transition, since its state moves on a post or
|
|
331
|
+
// on a push and either is the moment it is owed an action.
|
|
332
|
+
lines.push(...classifyUi(row, old))
|
|
333
|
+
|
|
334
|
+
const oldResp = Number.parseInt(old.resp, 10)
|
|
335
|
+
if (row.head !== old.head) {
|
|
336
|
+
if (row.prior === 'none') {
|
|
337
|
+
lines.push(
|
|
338
|
+
`MOVED #${row.number} -> ${short(row.head)}, never reviewed`,
|
|
339
|
+
)
|
|
340
|
+
} else if (row.prior === row.head) {
|
|
341
|
+
// An out-of-band pass reviewed this head before the poll saw it move, so
|
|
342
|
+
// the range is empty because it is covered rather than because it broke.
|
|
343
|
+
lines.push(
|
|
344
|
+
`SEEN #${row.number} -> ${short(row.head)}, already covered by the last pass`,
|
|
345
|
+
)
|
|
346
|
+
} else {
|
|
347
|
+
const since = context.countSince(row.prior, row.head)
|
|
348
|
+
lines.push(
|
|
349
|
+
since > 0
|
|
350
|
+
? `MOVED #${row.number} -> ${short(row.head)}, ${since} commit(s) since your last pass`
|
|
351
|
+
: `MOVED #${row.number} -> ${short(row.head)}, range unresolved, likely force-pushed`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
} else if (
|
|
355
|
+
row.resp > oldResp &&
|
|
356
|
+
(row.passAt === 0 || row.replyAt > row.passAt)
|
|
357
|
+
) {
|
|
358
|
+
// The count says the reply is new to this script and the stamp says it is
|
|
359
|
+
// newer than the pass, and both are needed. A worker answers a finding and
|
|
360
|
+
// the reviewing session closes out seconds later, so the count alone
|
|
361
|
+
// reports an answered thread on the next run. A pull request with no pass
|
|
362
|
+
// reads as stamp zero and reports. The gate sits in the condition so a
|
|
363
|
+
// suppressed reply falls through to STALLED below.
|
|
364
|
+
lines.push(`RESPONSE #${row.number} answered with no new commit`)
|
|
365
|
+
} else if (
|
|
366
|
+
row.heading === 'open' &&
|
|
367
|
+
old.state !== 'reported' &&
|
|
368
|
+
row.prior === row.head &&
|
|
369
|
+
row.age >= STALE_AFTER
|
|
370
|
+
) {
|
|
371
|
+
// The last pass is open, it covers the head so no commit followed it, and
|
|
372
|
+
// no reply came either. Any two of these describe an ordinary review
|
|
373
|
+
// waiting on a worker, so the age carries the third. STALLED reports a
|
|
374
|
+
// standing condition, so the written marker keeps it from firing on every
|
|
375
|
+
// later run.
|
|
376
|
+
lines.push(
|
|
377
|
+
`STALLED #${row.number} open at ${short(row.head)}, no commit or reply in ${Math.floor(row.age / 3600)}h`,
|
|
378
|
+
)
|
|
379
|
+
state = 'reported'
|
|
380
|
+
} else if (row.heading === 'open' && old.state === 'reported') {
|
|
381
|
+
// Carry the marker rather than the freshly derived heading, or the state
|
|
382
|
+
// re-enters next run and STALLED oscillates instead of reporting once.
|
|
383
|
+
state = 'reported'
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const baseline: Baseline = {
|
|
387
|
+
head: row.head,
|
|
388
|
+
merges: row.merges,
|
|
389
|
+
number: row.number,
|
|
390
|
+
prior: row.prior,
|
|
391
|
+
resp: String(row.resp),
|
|
392
|
+
state,
|
|
393
|
+
ui: row.ui === '' ? old.ui : row.ui,
|
|
394
|
+
unmatched: String(row.unmatchedCount),
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { baseline, isChanged: lines.length > 0, lines }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A pull request this run could not read keeps the line it had, so the GONE
|
|
402
|
+
* sweep does not read the gap as a merge. Its heading is blanked to a value no
|
|
403
|
+
* branch matches unless it already reads `reported`, since echoing it intact
|
|
404
|
+
* would classify a pull request this run never reached.
|
|
405
|
+
*/
|
|
406
|
+
export const carryForward = (old: Baseline): Baseline => ({
|
|
407
|
+
...old,
|
|
408
|
+
state: old.state === 'reported' ? 'reported' : 'carried',
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
const parseJson = (text: string): unknown => {
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(text)
|
|
414
|
+
} catch {
|
|
415
|
+
return null
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
type Snapshot = { baseline: Baseline } | { row: Row }
|
|
420
|
+
|
|
421
|
+
const nowSeconds = (): number => Math.floor(Date.now() / 1000)
|
|
422
|
+
|
|
423
|
+
const readHead = (number: string, payload: Payload): string => {
|
|
424
|
+
// The tip is the authority for the head, not the pull request object, which
|
|
425
|
+
// lags the ref by up to a minute after a push. The object's head is the
|
|
426
|
+
// fallback for a target on an older binary with no `pr head` verb.
|
|
427
|
+
const record = parseJson(
|
|
428
|
+
run('canon', ['pr', 'head', number, '--json']).stdout,
|
|
429
|
+
)
|
|
430
|
+
const tip =
|
|
431
|
+
typeof record === 'object' && record !== null
|
|
432
|
+
? (record as Record<string, unknown>).tip
|
|
433
|
+
: undefined
|
|
434
|
+
|
|
435
|
+
return typeof tip === 'string' && tip !== ''
|
|
436
|
+
? tip
|
|
437
|
+
: (payload.headRefOid ?? '')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* `gh pr view --json mergeable` reports UNKNOWN until GitHub finishes
|
|
442
|
+
* computing it, which is exactly when a poll asks. merge-tree answers locally
|
|
443
|
+
* against the base this machine has. It exits non-zero on a ref it cannot
|
|
444
|
+
* resolve as well as on a real conflict, so both sides are checked first.
|
|
445
|
+
*/
|
|
446
|
+
const readMerges = (number: string, head: string, ref: string): string => {
|
|
447
|
+
run('git', ['fetch', '-q', 'origin', `pull/${number}/head`])
|
|
448
|
+
if (
|
|
449
|
+
!run('git', ['cat-file', '-e', `${head}^{commit}`]).isOk ||
|
|
450
|
+
!run('git', ['rev-parse', '--verify', '-q', ref]).isOk
|
|
451
|
+
) {
|
|
452
|
+
return 'unknown'
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return run('git', ['merge-tree', '--write-tree', ref, head]).isOk
|
|
456
|
+
? 'clean'
|
|
457
|
+
: 'conflict'
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const buildRow = (
|
|
461
|
+
number: string,
|
|
462
|
+
payload: Payload,
|
|
463
|
+
head: string,
|
|
464
|
+
ref: string,
|
|
465
|
+
): Row => {
|
|
466
|
+
const now = nowSeconds()
|
|
467
|
+
const replies = replyState(payload.comments ?? [])
|
|
468
|
+
const unmatched = unmatchedState(payload.comments ?? [])
|
|
469
|
+
const record = parseJson(
|
|
470
|
+
run('canon', ['pr', 'review-state', number, '--json']).stdout,
|
|
471
|
+
)
|
|
472
|
+
const scope = scopeFromVerb(record, now) ?? scopeFallback(payload, now)
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
age: scope.age,
|
|
476
|
+
head,
|
|
477
|
+
heading: scope.state,
|
|
478
|
+
merges: readMerges(number, head, ref),
|
|
479
|
+
number,
|
|
480
|
+
passAt: scope.readAt,
|
|
481
|
+
prior: scope.commit,
|
|
482
|
+
replyAt: replies.newestAt,
|
|
483
|
+
resp: replies.count,
|
|
484
|
+
ui: uiState(payload, head),
|
|
485
|
+
unmatchedCount: unmatched.count,
|
|
486
|
+
unmatchedHeading: unmatched.heading,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const countSince =
|
|
491
|
+
(number: string) =>
|
|
492
|
+
(prior: string, head: string): number => {
|
|
493
|
+
// The range needs both commits local, and a force-push leaves the prior one
|
|
494
|
+
// unreachable, so a range that does not resolve counts as zero.
|
|
495
|
+
run('git', ['fetch', '-q', 'origin', `pull/${number}/head`])
|
|
496
|
+
const log = run('git', ['log', '--oneline', `${prior}..${head}`])
|
|
497
|
+
|
|
498
|
+
return log.isOk ? log.stdout.split('\n').filter((l) => l !== '').length : 0
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const main = (): number => {
|
|
502
|
+
const root = mainRoot()
|
|
503
|
+
if (root === null) {
|
|
504
|
+
console.error('poll: not a git repository, so nothing is classified')
|
|
505
|
+
|
|
506
|
+
return 1
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The baseline is per-machine mutable state, so it stays in gitignored
|
|
510
|
+
// scratch even though the script is tracked.
|
|
511
|
+
const stateDir = join(root, '.canon', 'tmp', 'pr', 'poll')
|
|
512
|
+
mkdirSync(stateDir, { recursive: true })
|
|
513
|
+
const statePath = join(stateDir, 'baseline.txt')
|
|
514
|
+
appendFileSync(statePath, '')
|
|
515
|
+
|
|
516
|
+
const ref = baseRef()
|
|
517
|
+
const base = baseBranch(ref)
|
|
518
|
+
const known = new Map<string, Baseline>()
|
|
519
|
+
const previous = readFileSync(statePath, 'utf8')
|
|
520
|
+
.split('\n')
|
|
521
|
+
.filter((line) => line !== '')
|
|
522
|
+
.map(parseBaseline)
|
|
523
|
+
for (const entry of previous) {
|
|
524
|
+
if (!known.has(entry.number)) known.set(entry.number, entry)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
run('git', ['fetch', '-q', 'origin', base])
|
|
528
|
+
|
|
529
|
+
// A failed list reads as no open pull requests, which reports every tracked
|
|
530
|
+
// one as GONE. That is a louder wrong answer than the one this script was
|
|
531
|
+
// fixed for, so the run aborts rather than classify on it.
|
|
532
|
+
const listed = run('gh', [
|
|
533
|
+
'pr',
|
|
534
|
+
'list',
|
|
535
|
+
'--state',
|
|
536
|
+
'open',
|
|
537
|
+
'--json',
|
|
538
|
+
'number',
|
|
539
|
+
'--jq',
|
|
540
|
+
'.[].number',
|
|
541
|
+
])
|
|
542
|
+
if (!listed.isOk) {
|
|
543
|
+
console.error('poll: the open pull request list could not be read')
|
|
544
|
+
console.error(
|
|
545
|
+
'poll: nothing is classified this run and the baseline is unchanged',
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return 1
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const snapshots: Snapshot[] = []
|
|
552
|
+
for (const number of listed.stdout.split(/\s+/).filter((n) => n !== '')) {
|
|
553
|
+
// A query that failed and a pull request with no reviews both arrive as
|
|
554
|
+
// an empty result, so one query per pull request gives the failure a
|
|
555
|
+
// single place to surface.
|
|
556
|
+
const view = run('gh', [
|
|
557
|
+
'pr',
|
|
558
|
+
'view',
|
|
559
|
+
number,
|
|
560
|
+
'--json',
|
|
561
|
+
'headRefOid,reviews,comments',
|
|
562
|
+
])
|
|
563
|
+
const payload = view.isOk
|
|
564
|
+
? (parseJson(view.stdout) as Payload | null)
|
|
565
|
+
: null
|
|
566
|
+
const old = known.get(number)
|
|
567
|
+
const unreadable = (reason: string): void => {
|
|
568
|
+
if (old === undefined) {
|
|
569
|
+
console.error(
|
|
570
|
+
`poll: #${number} ${reason}, and it has no last known state, so it goes unclassified`,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
console.error(
|
|
576
|
+
`poll: #${number} ${reason}, so it keeps its last known state and goes unclassified`,
|
|
577
|
+
)
|
|
578
|
+
snapshots.push({ baseline: carryForward(old) })
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (payload === null || typeof payload !== 'object') {
|
|
582
|
+
unreadable('could not be read')
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const head = readHead(number, payload)
|
|
587
|
+
if (head === '') {
|
|
588
|
+
unreadable('returned no head')
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
snapshots.push({ row: buildRow(number, payload, head, ref) })
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
let isChanged = false
|
|
596
|
+
const final: string[] = []
|
|
597
|
+
const seen = new Set<string>()
|
|
598
|
+
for (const snapshot of snapshots) {
|
|
599
|
+
if ('baseline' in snapshot) {
|
|
600
|
+
seen.add(snapshot.baseline.number)
|
|
601
|
+
final.push(formatBaseline(snapshot.baseline))
|
|
602
|
+
continue
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const { row } = snapshot
|
|
606
|
+
seen.add(row.number)
|
|
607
|
+
const result = classify(row, known.get(row.number), {
|
|
608
|
+
base,
|
|
609
|
+
countSince: countSince(row.number),
|
|
610
|
+
})
|
|
611
|
+
for (const line of result.lines) console.log(line)
|
|
612
|
+
if (result.isChanged) isChanged = true
|
|
613
|
+
final.push(formatBaseline(result.baseline))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
for (const entry of previous) {
|
|
617
|
+
if (seen.has(entry.number)) continue
|
|
618
|
+
console.log(`GONE #${entry.number} merged or closed`)
|
|
619
|
+
isChanged = true
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (!isChanged) console.log('No movement.')
|
|
623
|
+
writeFileSync(statePath, final.map((line) => `${line}\n`).join(''))
|
|
624
|
+
|
|
625
|
+
return 0
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (import.meta.main) process.exitCode = main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Shared by the poll and the watch. Imports are `node:` builtins only, since
|
|
2
|
+
// the plugin ships `claude/` alone and a cached plugin root carries no
|
|
3
|
+
// `node_modules`.
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
export interface RunResult {
|
|
7
|
+
isOk: boolean
|
|
8
|
+
stdout: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Runs a command to completion. A command that cannot start reads as failed. */
|
|
12
|
+
export const run = (command: string, args: string[]): RunResult => {
|
|
13
|
+
const result = spawnSync(command, args, {
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return { isOk: result.status === 0, stdout: result.stdout ?? '' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The main worktree root rather than the current one, so a script started from
|
|
23
|
+
* a linked worktree reads the same repository and state as one started from
|
|
24
|
+
* main.
|
|
25
|
+
*/
|
|
26
|
+
export const mainRoot = (): null | string => {
|
|
27
|
+
const listed = run('git', ['worktree', 'list', '--porcelain'])
|
|
28
|
+
if (!listed.isOk) return null
|
|
29
|
+
|
|
30
|
+
const first = listed.stdout
|
|
31
|
+
.split('\n')
|
|
32
|
+
.find((line) => line.startsWith('worktree '))
|
|
33
|
+
|
|
34
|
+
return first === undefined ? null : first.slice('worktree '.length)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The repository `.git` path, which is the field a session roster row carries. */
|
|
38
|
+
export const gitPath = (root: string): string => `${root}/.git`
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The base ref is read rather than assumed, since this ships to projects that
|
|
42
|
+
* do not all call it `main`. A wrong base is not a visible failure: merge-tree
|
|
43
|
+
* reports every pull request as conflicted against a ref that does not resolve.
|
|
44
|
+
*/
|
|
45
|
+
export const baseRef = (): string => {
|
|
46
|
+
const symbolic = run('git', [
|
|
47
|
+
'symbolic-ref',
|
|
48
|
+
'--quiet',
|
|
49
|
+
'--short',
|
|
50
|
+
'refs/remotes/origin/HEAD',
|
|
51
|
+
])
|
|
52
|
+
const ref = symbolic.stdout.trim()
|
|
53
|
+
if (symbolic.isOk && ref !== '') return ref
|
|
54
|
+
|
|
55
|
+
const repo = run('gh', [
|
|
56
|
+
'repo',
|
|
57
|
+
'view',
|
|
58
|
+
'--json',
|
|
59
|
+
'defaultBranchRef',
|
|
60
|
+
'--jq',
|
|
61
|
+
'.defaultBranchRef.name',
|
|
62
|
+
])
|
|
63
|
+
const name = repo.isOk ? repo.stdout.trim() : ''
|
|
64
|
+
|
|
65
|
+
return `origin/${name === '' ? 'main' : name}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const baseBranch = (ref: string): string =>
|
|
69
|
+
ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
|