@1aboveio/skills 1.0.20 → 1.0.22

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 (92) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +154 -151
  3. package/package.json +4 -4
  4. package/skills/app-dev/airflow/SKILL.md +1 -1
  5. package/skills/app-dev/android-development/SKILL.md +1 -1
  6. package/skills/app-dev/app-debug/SKILL.md +1 -1
  7. package/skills/app-dev/better-auth/SKILL.md +1 -1
  8. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  9. package/skills/app-dev/data-quality/SKILL.md +1 -1
  10. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  11. package/skills/app-dev/maestro/SKILL.md +8 -1
  12. package/skills/app-dev/monorepo/SKILL.md +1 -1
  13. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  14. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  15. package/skills/app-dev/pyspark/SKILL.md +1 -1
  16. package/skills/app-dev/python-backend/SKILL.md +1 -1
  17. package/skills/app-dev/shadcn/SKILL.md +1 -1
  18. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  19. package/skills/cicd/cloud-build/SKILL.md +1 -1
  20. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  21. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  22. package/skills/cicd/docker/SKILL.md +1 -1
  23. package/skills/cicd/github-actions/SKILL.md +8 -2
  24. package/skills/cicd/github-actions/references/debugging.md +21 -0
  25. package/skills/cicd/github-actions/references/pipeline.md +26 -0
  26. package/skills/cicd/github-actions/references/post-deploy-and-publishing.md +7 -0
  27. package/skills/cicd/github-actions/scripts/watch-run.mjs +511 -0
  28. package/skills/cicd/github-flow/SKILL.md +35 -9
  29. package/skills/cicd/github-flow/scripts/watch-checks.mjs +561 -0
  30. package/skills/cicd/google-cloud/SKILL.md +1 -1
  31. package/skills/cicd/mergify/SKILL.md +2 -2
  32. package/skills/cicd/mergify/references/diagnosis.md +6 -1
  33. package/skills/cicd/mergify/references/watch-contract.md +10 -7
  34. package/skills/cicd/mergify/scripts/watch-pr-delivery-core.mjs +15 -30
  35. package/skills/cicd/podman/SKILL.md +1 -1
  36. package/skills/content/article-review/SKILL.md +1 -1
  37. package/skills/content/article-to-image/SKILL.md +1 -1
  38. package/skills/content/flowchart-svg/SKILL.md +1 -1
  39. package/skills/content/image-gen/SKILL.md +1 -1
  40. package/skills/content/mermaid/SKILL.md +1 -1
  41. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  42. package/skills/content/wechat-publisher/SKILL.md +1 -1
  43. package/skills/content/wechat-search/SKILL.md +1 -1
  44. package/skills/content/xai-search/SKILL.md +1 -1
  45. package/skills/core/distill-writing/SKILL.md +1 -1
  46. package/skills/core/doc-to-md/SKILL.md +1 -1
  47. package/skills/core/editorial-review/SKILL.md +1 -1
  48. package/skills/core/house-style/SKILL.md +1 -1
  49. package/skills/core/plain-language/SKILL.md +1 -1
  50. package/skills/core/polish-cn/SKILL.md +1 -1
  51. package/skills/core/reg-parser/SKILL.md +1 -1
  52. package/skills/core/terminology/SKILL.md +1 -1
  53. package/skills/core/write-doc/SKILL.md +1 -1
  54. package/skills/core/write-report/SKILL.md +1 -1
  55. package/skills/engineering/better-review/SKILL.md +1 -1
  56. package/skills/engineering/better-review/references/passes.md +5 -0
  57. package/skills/engineering/e2e-test/SKILL.md +8 -1
  58. package/skills/engineering/engineering/SKILL.md +1 -1
  59. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  60. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  61. package/skills/engineering/ensure-coverage/SKILL.md +8 -1
  62. package/skills/engineering/golden-path/SKILL.md +5 -1
  63. package/skills/engineering/harness-runtime/SKILL.md +2 -1
  64. package/skills/engineering/harness-runtime/contracts.md +2 -0
  65. package/skills/engineering/harness-runtime/references/subagent-sessions.md +75 -0
  66. package/skills/engineering/resolve-issues/SKILL.md +25 -6
  67. package/skills/engineering/resolve-issues/references/ci.md +14 -4
  68. package/skills/engineering/resolve-issues/references/delivery-progress.md +3 -1
  69. package/skills/engineering/resolve-issues/references/done.md +8 -6
  70. package/skills/engineering/resolve-issues/references/expire.md +2 -2
  71. package/skills/engineering/resolve-issues/references/exploration.md +10 -3
  72. package/skills/engineering/resolve-issues/references/implementation.md +73 -51
  73. package/skills/engineering/resolve-issues/references/preflight.md +6 -0
  74. package/skills/engineering/resolve-issues/references/review.md +18 -19
  75. package/skills/engineering/resolve-issues/references/shared-modules.md +7 -1
  76. package/skills/engineering/resolve-issues/references/task-plan.md +18 -10
  77. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +42 -32
  78. package/skills/engineering/resolve-release/SKILL.md +1 -1
  79. package/skills/engineering/resolve-release/references/publish.md +12 -4
  80. package/skills/engineering/resolve-release/references/workspace-npm.md +6 -4
  81. package/skills/engineering/rush-issues/SKILL.md +1 -1
  82. package/skills/engineering/rush-release/SKILL.md +1 -1
  83. package/skills/engineering/test-quality/SKILL.md +1 -1
  84. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  85. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  86. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  87. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  88. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  89. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  90. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
  91. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  92. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env node
2
+ // watch-checks — wait for a PR's checks to settle, and prove the merge when asked.
3
+ // Read-only: it only reads `gh pr view`.
4
+ //
5
+ // node watch-checks.mjs --pr <number> [--repo <owner>/<name>] [--until checks|merged]
6
+ // [--interval-seconds 45] [--stall-minutes 15] [--max-minutes 60]
7
+ // [--no-checks-minutes 5] [--slow-check-minutes 5] [--read-retries 2]
8
+ // [--unreadable-polls 3]
9
+ //
10
+ // Why this exists instead of `gh pr checks --watch --required --fail-fast`:
11
+ //
12
+ // The delivery bar stays unchanged: required checks, or all checks when none are
13
+ // required. Monitoring must not turn optional checks into new merge gates.
14
+ // · `--watch` repaints its table — and its elapsed clock — every 30 seconds. Any
15
+ // supervisor that decides "the command printed something, so it is progressing"
16
+ // from output activity is fooled by that repaint: a job stuck in one step for
17
+ // twenty minutes looks exactly like a job making progress, and the moment of
18
+ // decision is spent on a status line rather than on the step that stopped.
19
+ //
20
+ // So this polls discrete JSON reads and defines progress the only way that survives a
21
+ // log: the canonical state of the PR's checks actually changed. It carries no
22
+ // repainting clock, so a stationary phase is quiet and a real transition speaks.
23
+ //
24
+ // Streams:
25
+ // stdout exactly one terminal JSON verdict, at exit.
26
+ // stderr one compact JSON record per observed check-state change (`kind:"transition"`,
27
+ // `progress:true`), one `{"warning":"slow-ci-check"}` record per slow check run,
28
+ // one `{"kind":"transition","progress":false,...}` the first time the provider
29
+ // becomes unreadable, one more (`progress:false`) when it recovers without the
30
+ // checks having moved, and one closing `{"summary":true}` line after the verdict.
31
+ // Readability is health, not CI progress: a health record never moves the clock
32
+ // the stall bound is measured from.
33
+ //
34
+ // The record's projection is `state` — the canonical check set, sorted, with no clock
35
+ // fields. `at`, `elapsedMs` and `observations` are for humans reading the log; a
36
+ // background-task predicate watches `progress` and `state`, never log size or mtime.
37
+ //
38
+ // Exit codes: 0 checks passed / no checks / merged. 4 terminal but not deliverable
39
+ // (failing checks, stalled, unreadable, unknown check state, closed unmerged).
40
+ // 2 usage error.
41
+
42
+ import { execFileSync } from 'node:child_process'
43
+
44
+ const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
45
+ const FAILING = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'CANCELLED', 'STALE'])
46
+ const PENDING = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED', 'EXPECTED'])
47
+ const MERGE_GATE = new Set(['BLOCKED', 'BEHIND', 'DIRTY', 'DRAFT'])
48
+ const MERGEABLE = new Set(['CLEAN', 'HAS_HOOKS'])
49
+ const PR_FIELDS = 'state,mergedAt,mergeCommit,mergeStateStatus,statusCheckRollup,autoMergeRequest,url,headRefOid'
50
+ const MAX_MINUTES_CEILING = 240
51
+ const MAX_READ_BYTES = 8 * 1024 * 1024
52
+
53
+ function help() {
54
+ return `watch-checks --pr <number> [options]
55
+
56
+ Wait for required PR checks, falling back to all checks when none are required.
57
+ Read-only; never merges,
58
+ never comments, never edits a PR.
59
+
60
+ --pr <number> PR to watch (required)
61
+ --repo <owner>/<name> repository; defaults to the current checkout
62
+ --until checks|merged stop when the checks settle (default), or after proving
63
+ the PR actually merged. 'merged' is never a success without
64
+ a merge: a PR with no checks waits for the merge itself, and
65
+ an unrecognised mergeStateStatus ends as UNKNOWN
66
+ --interval-seconds <n> poll interval (default 45)
67
+ --stall-minutes <n> no-observable-change bound (default 15). Suspended while a
68
+ check is pending, and while an --until merged PR is
69
+ mergeable and awaiting the provider: running CI and a
70
+ provider-side merge are progress, not a stall. Also
71
+ bounds a required context that stays pending with no start
72
+ time (reason: check-never-started), suspended while other
73
+ checks are still running
74
+ --max-minutes <n> absolute ceiling (default 60, max ${MAX_MINUTES_CEILING})
75
+ --no-checks-minutes <n> with --until checks, a PR with no checks for this long has
76
+ no CI (default 5)
77
+ --slow-check-minutes <n> warn once per check run longer than this (default 5)
78
+ --read-retries <n> extra attempts inside one poll (default 2)
79
+ --unreadable-polls <n> consecutive unreadable polls before UNKNOWN (default 3)
80
+
81
+ Outcome values: CHECKS_PASSED, NO_CHECKS, MERGED, CHECKS_FAILED, CLOSED_UNMERGED,
82
+ STALLED, UNKNOWN.
83
+
84
+ Exit 0: CHECKS_PASSED, NO_CHECKS, MERGED. Exit 4: every other outcome. Exit 2: usage.`
85
+ }
86
+
87
+ function parseArgs(argv) {
88
+ const args = {
89
+ intervalSeconds: 45,
90
+ stallMinutes: 15,
91
+ maxMinutes: 60,
92
+ noChecksMinutes: 5,
93
+ slowCheckMinutes: 5,
94
+ readRetries: 2,
95
+ unreadablePolls: 3,
96
+ until: 'checks',
97
+ }
98
+ const numbers = {
99
+ '--interval-seconds': 'intervalSeconds',
100
+ '--stall-minutes': 'stallMinutes',
101
+ '--max-minutes': 'maxMinutes',
102
+ '--no-checks-minutes': 'noChecksMinutes',
103
+ '--slow-check-minutes': 'slowCheckMinutes',
104
+ '--read-retries': 'readRetries',
105
+ '--unreadable-polls': 'unreadablePolls',
106
+ }
107
+ for (let i = 0; i < argv.length; i += 1) {
108
+ const arg = argv[i]
109
+ if (arg === '--pr') args.pr = argv[++i]
110
+ else if (arg === '--repo') args.repo = argv[++i]
111
+ else if (arg === '--until') args.until = argv[++i]
112
+ else if (numbers[arg]) args[numbers[arg]] = Number(argv[++i])
113
+ else if (arg === '--help' || arg === '-h') args.help = true
114
+ else args.unknown = arg
115
+ }
116
+ return args
117
+ }
118
+
119
+ function validate(args) {
120
+ if (args.unknown) return `unknown argument ${args.unknown}`
121
+ if (!/^[1-9][0-9]*$/.test(String(args.pr ?? ''))) return '--pr must be a positive integer'
122
+ if (args.repo != null && !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(args.repo))) {
123
+ return '--repo must be owner/name'
124
+ }
125
+ if (args.until !== 'checks' && args.until !== 'merged') return '--until must be checks or merged'
126
+ for (const key of ['intervalSeconds', 'stallMinutes', 'maxMinutes', 'noChecksMinutes', 'slowCheckMinutes']) {
127
+ if (!Number.isFinite(args[key]) || args[key] <= 0) return `${key} must be a positive number`
128
+ }
129
+ for (const key of ['readRetries', 'unreadablePolls']) {
130
+ if (!Number.isInteger(args[key]) || args[key] < 0) return `${key} must be a non-negative integer`
131
+ }
132
+ if (args.unreadablePolls < 1) return '--unreadable-polls must be at least 1'
133
+ if (args.maxMinutes > MAX_MINUTES_CEILING) return `--max-minutes exceeds the ${MAX_MINUTES_CEILING}-minute ceiling`
134
+ return null
135
+ }
136
+
137
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
138
+
139
+ // A bounded per-read timeout: long enough for a slow-but-working API call, short enough
140
+ // that one hung read cannot outlive the bound that will fire STALLED on this poll.
141
+ function readTimeoutMs({ intervalSeconds, maxMinutes, elapsedMs }) {
142
+ const perRead = Math.max(1000, intervalSeconds * 3000)
143
+ const remaining = Math.max(1000, maxMinutes * 60_000 - elapsedMs)
144
+ return Math.min(perRead, remaining, 60_000)
145
+ }
146
+
147
+ function ghJson(ghArgs, timeoutMs, array = false) {
148
+ try {
149
+ const stdout = execFileSync('gh', ghArgs, {
150
+ encoding: 'utf8',
151
+ timeout: timeoutMs,
152
+ killSignal: 'SIGKILL',
153
+ maxBuffer: MAX_READ_BYTES,
154
+ stdio: ['ignore', 'pipe', 'pipe'],
155
+ env: { ...process.env, GH_PROMPT_DISABLED: '1' },
156
+ })
157
+ const data = JSON.parse(stdout)
158
+ if (!data || typeof data !== 'object' || Array.isArray(data) !== array) return { ok: false, error: 'gh returned an unexpected JSON shape' }
159
+ return { ok: true, data }
160
+ } catch (error) {
161
+ const stderr = String(error?.stderr ?? '').trim()
162
+ if (array && [1, 8].includes(error?.status)) {
163
+ // `gh pr checks` returns JSON with non-zero status for red/pending checks.
164
+ try {
165
+ const data = JSON.parse(String(error.stdout))
166
+ if (Array.isArray(data)) return { ok: true, data }
167
+ } catch {}
168
+ if (/no required checks/i.test(stderr)) return { ok: true, data: [] }
169
+ }
170
+ return { ok: false, error: stderr || String(error?.message ?? 'gh pr view failed') }
171
+ }
172
+ }
173
+
174
+ function readPr(args, timeoutMs) {
175
+ const ghArgs = ['pr', 'view', String(args.pr), '--json', PR_FIELDS]
176
+ if (args.repo) ghArgs.push('--repo', args.repo)
177
+ const pr = ghJson(ghArgs, Math.max(1, Math.floor(timeoutMs / 2)))
178
+ if (!pr.ok) return pr
179
+ const requiredArgs = ['pr', 'checks', String(args.pr), '--required', '--json', 'name']
180
+ if (args.repo) requiredArgs.push('--repo', args.repo)
181
+ const required = ghJson(requiredArgs, Math.max(1, Math.floor(timeoutMs / 2)), true)
182
+ if (!required.ok) return required
183
+ if (required.data.some((check) => typeof check?.name !== 'string' || !check.name)) {
184
+ return { ok: false, error: 'required checks returned an invalid name' }
185
+ }
186
+ const names = new Set(required.data.map((check) => check.name))
187
+ if (names.size > 0) {
188
+ const rollup = Array.isArray(pr.data.statusCheckRollup) ? pr.data.statusCheckRollup : []
189
+ const selected = rollup.filter((check) => names.has(check?.name ?? check?.context))
190
+ if ([...names].some((name) => !selected.some((check) => (check.name ?? check.context) === name))) {
191
+ return { ok: false, error: 'required checks and PR rollup disagree; retrying the observation' }
192
+ }
193
+ pr.data.statusCheckRollup = selected
194
+ }
195
+ return pr
196
+ }
197
+
198
+ // One rollup entry -> {name, phase}. `statusCheckRollup` mixes CheckRun (status +
199
+ // conclusion) and StatusContext (state only) shapes, and `gh` does not normalise them.
200
+ // An entry that matches neither fails closed as `unknown` rather than reading as pending
201
+ // or passing.
202
+ function normalizeCheck(entry) {
203
+ const name = entry?.name || entry?.context || 'unnamed-check'
204
+ const status = String(entry?.status ?? entry?.state ?? '').toUpperCase()
205
+ const conclusion = String(entry?.conclusion ?? '').toUpperCase()
206
+ let phase = 'unknown'
207
+ if (conclusion) phase = PASSING.has(conclusion) ? 'success' : FAILING.has(conclusion) ? 'failure' : 'unknown'
208
+ else if (PENDING.has(status)) phase = 'pending'
209
+ else if (PASSING.has(status)) phase = 'success'
210
+ else if (FAILING.has(status)) phase = 'failure'
211
+ const started = Date.parse(entry?.startedAt ?? entry?.createdAt ?? '')
212
+ const completed = Date.parse(entry?.completedAt ?? '')
213
+ return {
214
+ name,
215
+ phase,
216
+ status: status || null,
217
+ conclusion: conclusion || null,
218
+ startedMs: Number.isFinite(started) && started > 0 ? started : null,
219
+ completedMs: Number.isFinite(completed) && completed > 0 ? completed : null,
220
+ }
221
+ }
222
+
223
+ // The canonical projection. Sorted, no clocks, no per-poll drift — this is the value a
224
+ // change is defined against, and the value a background task can predicate on.
225
+ function project(data) {
226
+ const checks = (Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [])
227
+ .filter(Boolean)
228
+ .map(normalizeCheck)
229
+ .sort((a, b) => (a.name === b.name ? (a.phase < b.phase ? -1 : a.phase > b.phase ? 1 : 0) : a.name < b.name ? -1 : 1))
230
+ const names = (phase) => checks.filter((check) => check.phase === phase).map((check) => check.name)
231
+ return {
232
+ checks,
233
+ state: {
234
+ pr: {
235
+ state: data.state ?? null,
236
+ mergeStateStatus: data.mergeStateStatus ?? null,
237
+ mergedSha: data.mergeCommit?.oid ?? null,
238
+ autoMerge: Boolean(data.autoMergeRequest),
239
+ },
240
+ checks: checks.map(({ name, phase }) => ({ name, phase })),
241
+ failing: names('failure'),
242
+ pending: names('pending'),
243
+ unknown: names('unknown'),
244
+ },
245
+ }
246
+ }
247
+
248
+ const keyOf = (state) => JSON.stringify(state)
249
+
250
+ function record(payload) {
251
+ return JSON.stringify(payload)
252
+ }
253
+
254
+ function slowCheckRecords(checks, { thresholdMs, warned, nowMs, url }) {
255
+ const out = []
256
+ for (const check of checks) {
257
+ if (!check.startedMs) continue
258
+ const key = `${check.name}|${check.startedMs}`
259
+ if (warned.has(key)) continue
260
+ const durationMs = (check.completedMs ?? nowMs) - check.startedMs
261
+ if (durationMs <= thresholdMs) continue
262
+ warned.add(key)
263
+ out.push(record({
264
+ warning: 'slow-ci-check',
265
+ name: check.name,
266
+ phase: check.phase,
267
+ durationSec: Math.round(durationMs / 1000),
268
+ thresholdSec: Math.round(thresholdMs / 1000),
269
+ url,
270
+ }))
271
+ }
272
+ return out
273
+ }
274
+
275
+ async function main() {
276
+ const args = parseArgs(process.argv.slice(2))
277
+ if (args.help) {
278
+ console.log(help())
279
+ return 0
280
+ }
281
+ const invalid = validate(args)
282
+ if (invalid) {
283
+ console.error(`watch-checks: ${invalid}`)
284
+ return 2
285
+ }
286
+
287
+ const startedMs = Date.now()
288
+ const slowThresholdMs = args.slowCheckMinutes * 60_000
289
+ const warnedSlow = new Set()
290
+ let lastSemanticKey = null
291
+ let lastUnreadableKey = null
292
+ let lastState = null
293
+ let unreadablePolls = 0
294
+ let observations = 0
295
+ let transitions = 0
296
+ let lastError = null
297
+ let noChecksSince = null
298
+ let gateStreak = 0
299
+ let lastReadable = true
300
+ let changedAtMs = startedMs
301
+ let lastUrl = null
302
+ // A pending check that never acquires a start time is the "expected forever" shape: a
303
+ // required context GitHub will never run. Tracked by name so the bound outlives the
304
+ // check's own appearance and disappearance from the rollup.
305
+ const neverStartedSince = new Map()
306
+
307
+ const finish = (result, state) => {
308
+ const endMs = Date.now()
309
+ const verdict = {
310
+ outcome: result.outcome,
311
+ terminal: true,
312
+ reason: result.reason ?? null,
313
+ detail: result.detail ?? null,
314
+ until: args.until,
315
+ pr: {
316
+ state: state?.pr?.state ?? null,
317
+ mergeStateStatus: state?.pr?.mergeStateStatus ?? null,
318
+ mergedSha: state?.pr?.mergedSha ?? null,
319
+ url: lastUrl,
320
+ },
321
+ checks: state?.checks?.map(({ name, phase }) => ({ name, phase })) ?? [],
322
+ failing: state?.failing ?? [],
323
+ pending: state?.pending ?? [],
324
+ unknown: state?.unknown ?? [],
325
+ slowChecks: [...warnedSlow].map((key) => key.slice(0, key.lastIndexOf('|'))),
326
+ observations,
327
+ transitions,
328
+ elapsedMs: endMs - startedMs,
329
+ }
330
+ console.log(JSON.stringify(verdict, null, 2))
331
+ console.error(record({
332
+ summary: true,
333
+ outcome: verdict.outcome,
334
+ reason: verdict.reason,
335
+ failing: verdict.failing,
336
+ pending: verdict.pending,
337
+ slowChecks: verdict.slowChecks,
338
+ checks: verdict.checks.length,
339
+ }))
340
+ return result.exitCode
341
+ }
342
+
343
+ for (;;) {
344
+ observations += 1
345
+ const elapsedMs = Date.now() - startedMs
346
+ const timeoutMs = readTimeoutMs({ intervalSeconds: args.intervalSeconds, maxMinutes: args.maxMinutes, elapsedMs })
347
+
348
+ let read = null
349
+ for (let attempt = 0; attempt <= args.readRetries; attempt += 1) {
350
+ read = readPr(args, timeoutMs)
351
+ if (read.ok) break
352
+ if (attempt < args.readRetries) await sleepMs(Math.min(1000, args.intervalSeconds * 1000))
353
+ }
354
+
355
+ if (!read.ok) {
356
+ unreadablePolls += 1
357
+ lastError = read.error
358
+ // An unreadable provider is information, not progress, and it is not a failed
359
+ // check either: it must not settle a verdict except through its own bound. It never
360
+ // moves the change clock — a provider we cannot read says nothing about the checks.
361
+ const unreadableKey = `unreadable|${read.error}`
362
+ if (lastReadable || unreadableKey !== lastUnreadableKey) {
363
+ lastUnreadableKey = unreadableKey
364
+ lastReadable = false
365
+ transitions += 1
366
+ console.error(record({
367
+ kind: 'transition',
368
+ progress: false,
369
+ reason: 'provider-unreadable',
370
+ at: new Date().toISOString(),
371
+ state: { readable: false, error: read.error },
372
+ }))
373
+ }
374
+ if (unreadablePolls >= args.unreadablePolls) {
375
+ return finish({
376
+ outcome: 'UNKNOWN',
377
+ reason: 'unreadable',
378
+ detail: `gh pr view failed ${unreadablePolls} polls in a row: ${lastError}`,
379
+ exitCode: 4,
380
+ }, lastState)
381
+ }
382
+ if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
383
+ return finish({
384
+ outcome: 'UNKNOWN',
385
+ reason: 'max-minutes',
386
+ detail: `watch reached its ${args.maxMinutes}m ceiling while the provider was unreadable: ${lastError}`,
387
+ exitCode: 4,
388
+ }, lastState)
389
+ }
390
+ await sleepMs(args.intervalSeconds * 1000)
391
+ continue
392
+ }
393
+
394
+ unreadablePolls = 0
395
+ const { checks, state } = project(read.data)
396
+ lastUrl = read.data.url ?? null
397
+ lastState = state
398
+ const merged = state.pr.state === 'MERGED' || Boolean(read.data.mergedAt)
399
+ // An accepted auto-merge request means the provider owns the next step: the queue may
400
+ // hold the PR at BLOCKED/BEHIND while it does its own work, so that is not a human gate.
401
+ const providerHoldsMerge = state.pr.autoMerge === true
402
+
403
+ lastUnreadableKey = null
404
+ const wasReadable = lastReadable
405
+ lastReadable = true
406
+
407
+ const semanticKey = keyOf(state)
408
+ if (semanticKey !== lastSemanticKey) {
409
+ const reason = lastSemanticKey === null ? 'check-state-initial' : 'check-state'
410
+ lastSemanticKey = semanticKey
411
+ changedAtMs = Date.now()
412
+ transitions += 1
413
+ console.error(record({ kind: 'transition', progress: true, reason, at: new Date().toISOString(), state }))
414
+ } else if (!wasReadable) {
415
+ // Readable again and the checks are exactly where they were: health recovered, the
416
+ // semantic state did not change. Speak, but claim no progress and leave the change
417
+ // clock alone, so a provider outage cannot buy the PR another stall window.
418
+ transitions += 1
419
+ console.error(record({ kind: 'transition', progress: false, reason: 'provider-readable', at: new Date().toISOString(), state }))
420
+ }
421
+
422
+ for (const warning of slowCheckRecords(checks, { thresholdMs: slowThresholdMs, warned: warnedSlow, nowMs: Date.now(), url: lastUrl })) {
423
+ console.error(warning)
424
+ }
425
+
426
+ if (merged) return finish({ outcome: 'MERGED', reason: 'merged', exitCode: 0 }, state)
427
+ if (state.pr.state === 'CLOSED') {
428
+ return finish({ outcome: 'CLOSED_UNMERGED', reason: 'closed-without-merge', exitCode: 4 }, state)
429
+ }
430
+ // An unrecognised rollup entry is not a pass and not a pending check: stop and say
431
+ // which, rather than waiting for a state that will never settle.
432
+ if (state.unknown.length > 0) {
433
+ return finish({
434
+ outcome: 'UNKNOWN',
435
+ reason: 'unknown-check-state',
436
+ detail: `unrecognised check state: ${state.unknown.join(', ')}`,
437
+ exitCode: 4,
438
+ }, state)
439
+ }
440
+ if (state.failing.length > 0) {
441
+ return finish({
442
+ outcome: 'CHECKS_FAILED',
443
+ reason: 'checks-failed',
444
+ detail: state.failing.join(', '),
445
+ exitCode: 4,
446
+ }, state)
447
+ }
448
+
449
+ // "No checks" only answers the question `--until checks` asked. For `--until merged`
450
+ // an empty rollup means there is nothing to wait for on the CI side, and the merge
451
+ // itself is the whole remaining question: settling that as a success would report a
452
+ // green watch for a PR that never merged.
453
+ if (checks.length === 0 && args.until === 'checks') {
454
+ noChecksSince ??= Date.now()
455
+ if (Date.now() - noChecksSince >= args.noChecksMinutes * 60_000) {
456
+ return finish({
457
+ outcome: 'NO_CHECKS',
458
+ reason: 'no-checks-reported',
459
+ detail: `no checks reported for ${args.noChecksMinutes}m; verify repository CI policy before proceeding`,
460
+ exitCode: 0,
461
+ }, state)
462
+ }
463
+ } else if (checks.length > 0) {
464
+ noChecksSince = null
465
+ }
466
+
467
+ // Never-started contexts: a required context GitHub will never run is the "expected
468
+ // forever" shape, so bound it instead of waiting for a settlement that cannot come.
469
+ // While sibling checks are actually running the provider has simply not got to this
470
+ // one yet, so the diagnosis is suspended — and its clock is cleared, so the bound
471
+ // measures sustained no-CI time instead of time that belongs to a running suite.
472
+ const ciActivelyRunning = checks.some((check) => check.phase === 'pending' && check.startedMs)
473
+ if (ciActivelyRunning) {
474
+ neverStartedSince.clear()
475
+ } else {
476
+ const notStarted = new Set(checks.filter((check) => check.phase === 'pending' && !check.startedMs).map((check) => check.name))
477
+ for (const name of [...neverStartedSince.keys()]) if (!notStarted.has(name)) neverStartedSince.delete(name)
478
+ for (const name of notStarted) if (!neverStartedSince.has(name)) neverStartedSince.set(name, Date.now())
479
+ const stuck = [...neverStartedSince].filter(([, sinceMs]) => Date.now() - sinceMs >= args.stallMinutes * 60_000).map(([name]) => name)
480
+ if (stuck.length > 0) {
481
+ return finish({
482
+ outcome: 'STALLED',
483
+ reason: 'check-never-started',
484
+ detail: `${stuck.join(', ')} reported pending with no start time for ${args.stallMinutes}m`,
485
+ exitCode: 4,
486
+ }, state)
487
+ }
488
+ }
489
+
490
+ // Settled checks. An empty rollup counts as settled only for `--until merged`, where it
491
+ // means "nothing left to run" rather than "the answer is NO_CHECKS".
492
+ const settled = checks.length > 0 ? state.pending.length === 0 : args.until === 'merged'
493
+ if (settled && args.until === 'checks') return finish({ outcome: 'CHECKS_PASSED', reason: 'checks-passed', exitCode: 0 }, state)
494
+
495
+ // Awaiting a provider-side merge. An accepted auto-merge request outranks the status:
496
+ // the queue parks a queued PR at BLOCKED while it works, so that is not a human gate.
497
+ // A merge that cannot happen (review, stale base, conflict, draft) is a hand-back, not
498
+ // a wait to the ceiling, and a mergeStateStatus this tool does not recognise is never
499
+ // read as "mergeable": it is an explicit unknown, so nobody waits on a status the tool
500
+ // cannot name. One read is suspicion, so the hand-back needs two consecutive reads.
501
+ const gateStatus = String(state.pr.mergeStateStatus ?? '').toUpperCase()
502
+ const mergeDisposition = providerHoldsMerge
503
+ ? 'provider'
504
+ : MERGEABLE.has(gateStatus)
505
+ ? 'mergeable'
506
+ : MERGE_GATE.has(gateStatus)
507
+ ? 'gate'
508
+ : 'unknown'
509
+ const mergeAwaitBlocked = args.until === 'merged' && settled
510
+ && mergeDisposition !== 'provider' && mergeDisposition !== 'mergeable'
511
+ if (mergeAwaitBlocked) {
512
+ gateStreak += 1
513
+ if (gateStreak >= 2) {
514
+ if (mergeDisposition === 'unknown') {
515
+ return finish({
516
+ outcome: 'UNKNOWN',
517
+ reason: 'unknown-mergeability',
518
+ detail: `checks are settled but mergeStateStatus ${JSON.stringify(state.pr.mergeStateStatus)} is not a state this tool knows`,
519
+ exitCode: 4,
520
+ }, state)
521
+ }
522
+ return finish({
523
+ outcome: 'STALLED',
524
+ reason: `merge-gate:${gateStatus.toLowerCase()}`,
525
+ detail: `checks are green and the merge cannot proceed: mergeStateStatus ${state.pr.mergeStateStatus}`,
526
+ exitCode: 4,
527
+ }, state)
528
+ }
529
+ } else {
530
+ gateStreak = 0
531
+ }
532
+
533
+ const awaitingProvider = args.until === 'merged' && settled
534
+ && (mergeDisposition === 'provider' || mergeDisposition === 'mergeable')
535
+ const ciRunning = state.pending.length > 0
536
+
537
+ if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
538
+ return finish({
539
+ outcome: 'STALLED',
540
+ reason: 'max-minutes',
541
+ detail: `watch reached its ${args.maxMinutes}m ceiling with the provider still reporting ${state.pr.state ?? 'an unknown state'}`,
542
+ exitCode: 4,
543
+ }, state)
544
+ }
545
+ if (!ciRunning && !awaitingProvider && Date.now() - changedAtMs >= args.stallMinutes * 60_000) {
546
+ return finish({
547
+ outcome: 'STALLED',
548
+ reason: 'stall-bound',
549
+ detail: `no observable check-state change for ${args.stallMinutes}m`,
550
+ exitCode: 4,
551
+ }, state)
552
+ }
553
+
554
+ await sleepMs(args.intervalSeconds * 1000)
555
+ }
556
+ }
557
+
558
+ process.exitCode = await main().catch((error) => {
559
+ console.error(record({ error: 'watch-checks-failed', detail: String(error?.message ?? error) }))
560
+ return 4
561
+ })
@@ -5,7 +5,7 @@ description: >
5
5
  project management. Use when configuring GCP resources, managing service
6
6
  accounts, setting up IAP access, configuring VPC networks, managing secrets,
7
7
  or debugging cloud infrastructure.
8
- release-version: 0.50.0
8
+ release-version: 0.50.2
9
9
  ---
10
10
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
11
11
  # Google Cloud Platform (GCP) Skill
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: mergify
3
3
  description: "Mergify merge queue and stacked PRs (`mergify stack`): config, rules, queue status, delivery. Use whenever a merge queue or stack is involved, including plain asks ('watch #123 till it merges', 'where is #412 in the queue', 'push this stack'): `gh pr view`/`gh pr checks` cannot see a dequeue; use `mergify queue show` and the bundled watcher. Also: a hook saying 'use mergify stack push', a missing Change-Id; authoring or debugging `.mergify.yml`, queue rules, merge protections, batch size, the ruleset making merges queue-only; enqueue policy (`@mergifyio queue` vs `auto_merge_conditions`); a push dequeuing a queued PR; bypass actors; freezing a branch. Failures: a PR stuck, queued but never merging, or merged unqueued; CI green on GitHub but unmet in Mergify; a batch going red or bisecting; a queue check missing rather than failing. Outranks the vendor mergify-* skills. NOT for pushing a PR in a repo without stack hooks (github-flow), workflow YAML (github-actions), or GCP deploy pipelines (cloud-deploy)."
4
- release-version: 0.50.0
4
+ release-version: 0.50.2
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # Mergify Merge Queue
@@ -23,7 +23,7 @@ Requires mergify CLI **2026.10.5 or newer** ([stacks.md](references/stacks.md#cl
23
23
  | **Push, open, or update a PR in a stack repo** (hook says "Use 'mergify stack push'", `Change-Id` missing) | [`references/stacks.md`](references/stacks.md) | Push with `scripts/stack-push.mjs`. One commit = one PR; amend to fix. Never `--no-verify`. |
24
24
  | A stacked PR gets no CI, conflicts after its parent lands, or is dequeued "could not be retargeted onto … `main`" | [`references/configuration.md`](references/configuration.md#stack-ready), then [`references/traps.md`](references/traps.md) | Make the repo stack-ready (native stacks: Mergify's ruleset bypass `exempt`; `Depends-On` stacks also need PR CI on every base, commits kept, auto-delete head branches); then `mergify stack push --force-rebase` for a stuck tip. |
25
25
  | Author or change `.mergify.yml` | [`references/configuration.md`](references/configuration.md) | [Rule 1](#rule-1--fetch-the-docs-before-writing-config): never write config from memory; finish with `validate` **and** `simulate`. |
26
- | **Watch a PR through the queue** | [`references/watch-contract.md`](references/watch-contract.md) | **[Rule 2](#rule-2--never-hand-roll-a-watcher): run the bundled watcher, never hand-roll one.** A watch observes a *progression*, not one state. |
26
+ | **Watch a PR through the queue** | [`references/watch-contract.md`](references/watch-contract.md) | **[Rule 2](#rule-2--never-hand-roll-a-watcher): run the bundled watcher, never hand-roll one.** A watch observes a *progression*, not one state. It emits the initial snapshot and delivery/readability transitions; repeated identical polls stay quiet. |
27
27
  | **The queue feels slow, or is it worth it?** | [`references/diagnosis.md`](references/diagnosis.md#measuring-a-slow-queue) | Measure first-try merge rate and same-SHA requeues from the event log, then fix the flaky queue check. Never move a queue-only check onto the PR lane, and never rerun CI on push to `main` ([rules](references/configuration.md#the-queue-run-is-the-integration-run)). |
28
28
  | Check status — "where is my PR?", "why is it stuck?" | [`references/diagnosis.md`](references/diagnosis.md#querying-pr-and-queue-status) | `mergify queue show <number> -v` names the single blocking condition (bare number, never a URL; the CLI takes `--repository <owner/repo>`, never `--repo`; the bundled watcher below takes `--repo`); nothing else in the toolchain does. `gh pr checks` is CI truth, never queue truth. |
29
29
  | A PR merged without ever being queued | [`references/branch-protection.md`](references/branch-protection.md) | Make the queue mandatory with the ruleset's `update` rule; a required status check alone does not. |
@@ -43,11 +43,16 @@ Queue states worth recognising:
43
43
 
44
44
  ```bash
45
45
  gh pr checks <PR> # check-run table, exit 1 if any failed
46
- gh pr checks <PR> --watch # block until they settle
47
46
  gh pr view <PR> --json state,isDraft,mergeable,mergeStateStatus,statusCheckRollup
48
47
  gh pr view <PR> --json labels --jq '.labels[].name' # queued / dequeued labels
49
48
  ```
50
49
 
50
+ A one-shot table is the right tool here. Waiting is not: `gh pr checks <PR> --watch` repaints
51
+ its table and its elapsed clock every 30 seconds, which reads as progress to anything watching
52
+ the log while the actual CI steps slide out of view. To wait for CI, run the delivery watcher
53
+ ([`watch-contract.md`](watch-contract.md)) — or, for a PR outside a queue, the `github-flow`
54
+ skill's `scripts/watch-checks.mjs`, which emits one record per real check-state change.
55
+
51
56
  Mergify labels a PR `queued` on entry and `dequeued` on removal
52
57
  (`merge_queue.queued_label` / `dequeued_label`). These are **lifecycle** signals,
53
58
  not a cheap membership check — what they mean depends on what you saw before
@@ -49,7 +49,7 @@ Two of the four were written *after* the agent had been told to check for dequeu
49
49
 
50
50
  ### `phases` — the progression as output
51
51
 
52
- The watcher emits `phases`: the ordered list of lifecycle phases actually observed, on both the terminal verdict and every heartbeat. It resolves the one ambiguity the terminal outcome cannot:
52
+ The watcher emits `phases`: the ordered list of lifecycle phases actually observed, on both the terminal verdict and every transition record. It resolves the one ambiguity the terminal outcome cannot:
53
53
 
54
54
  | terminal | `phases` | meaning | fix |
55
55
  |---|---|---|---|
@@ -181,7 +181,8 @@ so the newest batch wins over stale comment fallback.
181
181
  - **Run the bundled script. Do not hand-roll a watcher** — see the progression section above for why the hand-rolled shape cannot hold the contract, whatever care goes into the code. (If you are reading a legacy shell watcher: avoid shell-reserved variable names such as `status`.)
182
182
  - Keep enqueue and watch separate. A watch observes only; it does not post `@mergifyio queue`, `@mergifyio dequeue`, or merge.
183
183
  - Confirm the PR is queued, or can become queued, *before* launching the watch — see [Enqueue Before You Watch](#enqueue-before-you-watch). A watch that cannot enqueue, on a PR nothing else will enqueue either, can only end `STALLED`.
184
- - Emit an observation record every poll, not only on the terminal verdict. `NOT_YET_QUEUED` and `QUEUED` are both non-terminal and cover the entire healthy path, so a watcher that speaks only at the end is silent for essentially its whole life — indistinguishable, to the caller trying to decide whether delivery is progressing, from a hung or dead process. Keep the heartbeat off the machine-contract stream: stdout carries exactly the one terminal JSON document callers parse, stderr carries the per-poll records.
184
+ - Emit the initial observation, then delivery-state or provider-readability transitions. Repeated identical polls stay quiet. A process supervisor counts new log bytes as activity even when a JSON record says `progress: false`, so labelling every heartbeat is insufficient. The initial record makes a waiting watch visible; the terminal verdict and existing no-change bounds explain how it ended. stdout remains exactly one terminal JSON document; stderr carries transition records and diagnostics.
185
+ - Mark observation records `activity: true`; set `progress`/`changed` only when the canonical delivery state changed. Provider readability changes remain visible without resetting that change clock. `at`, `sinceChangeMs` and `elapsedMs` are diagnostic clocks, never progress. Keep the queue memory, readable-source snapshots, and existing stall bounds across quiet polls.
185
186
  - Bound on stalled state, not total elapsed time alone. `waiting_for_previous_batches` can be slow but healthy if position/state changes.
186
187
 
187
188
  ## Script
@@ -203,14 +204,14 @@ Options:
203
204
  | `--admission-stall-minutes` | `3` | pre-admission terminal stall bound — no observable change *before this process has observed membership* is `STALLED` with `reason: "admission-stall"`. Shorter on purpose: after enqueue, queue entry should appear quickly, and a dead admission path must not wait out the full post-entry bound. Capped at `--stall-minutes` so a caller that shortens the post-entry bound keeps both phases tight. Applies only while no non-Mergify check is pending; while CI runs, the post-entry bound applies, so a CI-gated admission needs no relaunch |
204
205
  | `--max-minutes` | `60` | absolute safety ceiling; bounds the watch even while the queue keeps reporting change, so a busy queue whose position keeps moving cannot keep the stall clock resetting forever. **60 minutes is also a hard maximum** — a larger value exits `2` rather than being silently clamped, because a caller that believes it has a four-hour watch and has one hour is worse off than one told plainly to relaunch. An hour without a verdict is a queue to hand back to a human or relaunch. The sibling `resolve-issues` watcher enforces the same ceiling; a cap honoured by one of two watchers is not a cap |
205
206
  | `--slow-job-minutes` | `5` | warn when a single CI job runs longer than this (see [CI profile](#ci-profile)) |
206
- | `--quiet` | off | suppress the per-poll progress heartbeat on stderr (slow-job warnings and the closing profile summary still print) |
207
+ | `--quiet` | off | suppress delivery/readability transition records on stderr (slow-job warnings and the closing profile summary still print) |
207
208
 
208
209
  Output streams:
209
210
 
210
211
  | stream | content |
211
212
  |---|---|
212
213
  | stdout | exactly one pretty-printed terminal JSON verdict, at exit — carrying `phases`, `seenQueued`, the outcome, and `ciProfile` |
213
- | stderr | one compact JSON record per poll (`progress: true`), with the live outcome, PR labels, queue membership/position, `phases`, `changed`, and `sinceChangeMs` — the stall clock made visible; plus one `{"warning":"slow-ci-job"}` record per slow CI job; and, last, one `{"ciProfileSummary":true}` line after the verdict |
214
+ | stderr | one compact JSON record per delivery/readability transition, marked `activity: true` (an observation was emitted) with `progress` set to whether the delivery state actually changed, plus the live outcome, PR labels, queue membership/position, `phases`, `changed`, and `sinceChangeMs` — the stall clock made visible; plus one `{"warning":"slow-ci-job"}` record per slow CI job; and, last, one `{"ciProfileSummary":true}` line after the verdict |
214
215
 
215
216
  Each regular-poll subprocess read (`gh pr view`, `mergify queue show`) also carries its own bounded
216
217
  timeout — a small multiple of `--interval-seconds`, clamped to never exceed the **effective
@@ -265,9 +266,11 @@ returns the PR's check runs with their start and finish times.
265
266
  `{"ciProfileSummary":true,"outcome","verdict","jobCount","slowJobMinutes","slowest","slowJobs"}`,
266
267
  with at most three slow jobs named as `"<name> <sec>s"`. The verdict is pretty-printed over
267
268
  many lines, so a short log tail lands inside `phases` and misses the profile; this line is
268
- what a 10-line tail shows. A background runner that reports only the exit code (Pi's
269
- `bg_task_status`) shows neither: read the log tail (`bg_task_log`) when the watch ends, and
270
- report the profile. Identity #894 (2026-09-29) warned about a 338s job that no one read.
269
+ what a 10-line tail shows. A background runner that reports only the exit code shows
270
+ neither: read the log tail when the watch ends, and report the profile. Diagnose movement
271
+ from the emitted `progress`/`outcome` and phases. Repeated polls stay quiet, so a process
272
+ supervisor cannot mistake periodic heartbeat writes for progress. Identity #894
273
+ (2026-09-29) warned about a 338s job that no one read.
271
274
 
272
275
  Profiling is observation only: it never changes the outcome or the exit code. A slow-job
273
276
  warning is a lead for whoever owns the CI (split the job, cache, move it to a nightly lane) —