@1aboveio/skills 1.0.21 → 1.0.23

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 (112) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +175 -153
  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 +1 -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 +4 -2
  24. package/skills/cicd/github-actions/references/debugging.md +21 -0
  25. package/skills/cicd/github-actions/references/post-deploy-and-publishing.md +7 -0
  26. package/skills/cicd/github-actions/scripts/watch-run.mjs +511 -0
  27. package/skills/cicd/github-flow/SKILL.md +35 -9
  28. package/skills/cicd/github-flow/scripts/watch-checks.mjs +561 -0
  29. package/skills/cicd/google-cloud/SKILL.md +1 -1
  30. package/skills/cicd/mergify/SKILL.md +2 -2
  31. package/skills/cicd/mergify/references/diagnosis.md +6 -1
  32. package/skills/cicd/mergify/references/watch-contract.md +10 -7
  33. package/skills/cicd/mergify/scripts/watch-pr-delivery-core.mjs +15 -30
  34. package/skills/cicd/podman/SKILL.md +1 -1
  35. package/skills/content/article-review/SKILL.md +1 -1
  36. package/skills/content/article-to-image/SKILL.md +1 -1
  37. package/skills/content/flowchart-svg/SKILL.md +1 -1
  38. package/skills/content/image-gen/SKILL.md +1 -1
  39. package/skills/content/mermaid/SKILL.md +1 -1
  40. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  41. package/skills/content/wechat-publisher/SKILL.md +1 -1
  42. package/skills/content/wechat-search/SKILL.md +1 -1
  43. package/skills/content/xai-search/SKILL.md +1 -1
  44. package/skills/core/distill-writing/SKILL.md +1 -1
  45. package/skills/core/doc-to-md/SKILL.md +1 -1
  46. package/skills/core/editorial-review/SKILL.md +1 -1
  47. package/skills/core/house-style/SKILL.md +1 -1
  48. package/skills/core/plain-language/SKILL.md +1 -1
  49. package/skills/core/polish-cn/SKILL.md +1 -1
  50. package/skills/core/reg-parser/SKILL.md +1 -1
  51. package/skills/core/terminology/SKILL.md +1 -1
  52. package/skills/core/write-doc/SKILL.md +1 -1
  53. package/skills/core/write-report/SKILL.md +1 -1
  54. package/skills/engineering/better-review/SKILL.md +1 -1
  55. package/skills/engineering/e2e-test/SKILL.md +1 -1
  56. package/skills/engineering/engineering/SKILL.md +1 -1
  57. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  58. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  59. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  60. package/skills/engineering/golden-path/SKILL.md +1 -1
  61. package/skills/engineering/harness-runtime/SKILL.md +2 -1
  62. package/skills/engineering/harness-runtime/contracts.md +2 -0
  63. package/skills/engineering/harness-runtime/references/subagent-sessions.md +75 -0
  64. package/skills/engineering/resolve-issues/SKILL.md +2 -2
  65. package/skills/engineering/resolve-issues/references/ci.md +2 -4
  66. package/skills/engineering/resolve-issues/references/delivery-progress.md +1 -1
  67. package/skills/engineering/resolve-issues/references/done.md +3 -6
  68. package/skills/engineering/resolve-issues/references/expire.md +2 -2
  69. package/skills/engineering/resolve-issues/references/implementation.md +52 -41
  70. package/skills/engineering/resolve-issues/references/review.md +18 -19
  71. package/skills/engineering/resolve-issues/references/shared-modules.md +7 -1
  72. package/skills/engineering/resolve-issues/references/task-plan.md +18 -10
  73. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +42 -32
  74. package/skills/engineering/resolve-release/SKILL.md +1 -1
  75. package/skills/engineering/resolve-release/references/publish.md +12 -4
  76. package/skills/engineering/resolve-release/references/workspace-npm.md +6 -4
  77. package/skills/engineering/rush-issues/SKILL.md +1 -1
  78. package/skills/engineering/rush-release/SKILL.md +1 -1
  79. package/skills/engineering/test-quality/SKILL.md +1 -1
  80. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  81. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  82. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  83. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  84. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  85. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  86. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +76 -156
  87. package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +37 -170
  88. package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +32 -15
  89. package/skills/risk-compliance/portfolio-payment-analysis/references/workflow-and-contracts.md +71 -0
  90. package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +10 -1
  91. package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.css +1 -0
  92. package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/charts.js +60 -0
  93. package/skills/risk-compliance/portfolio-payment-analysis/scripts/assets/tabs.js +2 -0
  94. package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +28 -137
  95. package/skills/risk-compliance/portfolio-payment-analysis/scripts/echarts_report.py +43 -0
  96. package/skills/risk-compliance/portfolio-payment-analysis/scripts/interactive_report.py +3 -0
  97. package/skills/risk-compliance/portfolio-payment-analysis/scripts/issuer_data.py +52 -0
  98. package/skills/risk-compliance/portfolio-payment-analysis/scripts/payment_amounts.py +20 -0
  99. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +6 -157
  100. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_base.py +164 -0
  101. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render_custom.py +30 -0
  102. package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_data.py +39 -0
  103. package/skills/risk-compliance/portfolio-payment-analysis/scripts/report_model.py +45 -0
  104. package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_dual.py +54 -0
  105. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_detail.py +27 -0
  106. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vamp_opened_disputes.py +30 -0
  107. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/LICENSE +222 -0
  108. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/NOTICE +5 -0
  109. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts-provenance.json +6 -0
  110. package/skills/risk-compliance/portfolio-payment-analysis/scripts/vendor/echarts.min.js +45 -0
  111. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  112. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ // watch-run — wait for one GitHub Actions run and report only the job/step transitions it
3
+ // actually makes. Read-only: it only reads `gh run view`.
4
+ //
5
+ // node watch-run.mjs --run <id> [--repo <owner>/<name>]
6
+ // [--interval-seconds 30] [--stall-minutes 15] [--max-minutes 60]
7
+ // [--slow-step-minutes 5] [--read-retries 2] [--unreadable-polls 3]
8
+ //
9
+ // Why this exists instead of `gh run watch`:
10
+ //
11
+ // `gh run watch` repaints a status block — and an elapsed clock — every 30 seconds. To
12
+ // anything that reads output activity as progress (a log tail, a file mtime, an agent
13
+ // skim), that repaint is indistinguishable from a run doing work, and the run's own steps
14
+ // are exactly what stops being visible: a job wedged in one step for twenty minutes reads
15
+ // the same as a job moving.
16
+ //
17
+ // So this polls discrete JSON reads and treats only a change in the canonical run state as
18
+ // progress. Each change is reported as the job/step transition that caused it, with the
19
+ // duration of the step that moved, and a slow step is called out once while it is still
20
+ // running rather than discovered afterwards.
21
+ //
22
+ // Streams:
23
+ // stdout exactly one terminal JSON verdict, at exit.
24
+ // stderr one compact JSON record per observed run-state change (`kind:"transition"`,
25
+ // `progress:true`, with a `changes` list of job/step transitions), one
26
+ // `{"warning":"slow-ci-step"}` per slow job or step, one provider-unreadable
27
+ // record when reads fail, and one closing `{"summary":true}` line.
28
+ //
29
+ // The record's projection is `state` — run status/conclusion plus each job's status and
30
+ // current step, no clocks. `at`, `elapsedMs` and `observations` are for humans; a
31
+ // background-task predicate watches `progress`/`state`, never log size or mtime.
32
+ //
33
+ // Exit codes: 0 the run succeeded (or was neutral/skipped). 4 failed, cancelled, timed out,
34
+ // stalled, unreadable, or an unknown run state. 2 usage error.
35
+
36
+ import { execFileSync } from 'node:child_process'
37
+
38
+ const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
39
+ const STATUSES = new Set(['REQUESTED', 'WAITING', 'PENDING', 'QUEUED', 'IN_PROGRESS', 'COMPLETED'])
40
+ const CONCLUSIONS = new Set([...PASSING, 'FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE'])
41
+ const RUN_FIELDS = 'status,conclusion,jobs,workflowName,headSha,url,attempt'
42
+ const MAX_MINUTES_CEILING = 240
43
+ const MAX_READ_BYTES = 16 * 1024 * 1024
44
+ const MAX_REPORTED_CHANGES = 25
45
+
46
+ function help() {
47
+ return `watch-run --run <id> [options]
48
+
49
+ Wait for one Actions run, reporting job and step transitions. Read-only; it never
50
+ re-runs, cancels, dispatches, or edits anything.
51
+
52
+ --run <id> run to watch (required)
53
+ --repo <owner>/<name> repository; defaults to the current checkout
54
+ --interval-seconds <n> poll interval (default 30)
55
+ --stall-minutes <n> no-observable-change bound while nothing is in progress
56
+ (default 15); suspended while a job or step is running
57
+ --max-minutes <n> absolute ceiling (default 60, max ${MAX_MINUTES_CEILING})
58
+ --slow-step-minutes <n> warn once per job or step running longer (default 5)
59
+ --read-retries <n> extra attempts inside one poll (default 2)
60
+ --unreadable-polls <n> consecutive unreadable polls before UNKNOWN (default 3)
61
+
62
+ Outcome values: SUCCESS, NEUTRAL, FAILED, CANCELLED, STALLED, UNKNOWN.
63
+
64
+ Exit 0: SUCCESS, NEUTRAL. Exit 4: every other outcome. Exit 2: usage.`
65
+ }
66
+
67
+ function parseArgs(argv) {
68
+ const args = {
69
+ intervalSeconds: 30,
70
+ stallMinutes: 15,
71
+ maxMinutes: 60,
72
+ slowStepMinutes: 5,
73
+ readRetries: 2,
74
+ unreadablePolls: 3,
75
+ }
76
+ const numbers = {
77
+ '--interval-seconds': 'intervalSeconds',
78
+ '--stall-minutes': 'stallMinutes',
79
+ '--max-minutes': 'maxMinutes',
80
+ '--slow-step-minutes': 'slowStepMinutes',
81
+ '--read-retries': 'readRetries',
82
+ '--unreadable-polls': 'unreadablePolls',
83
+ }
84
+ for (let i = 0; i < argv.length; i += 1) {
85
+ const arg = argv[i]
86
+ if (arg === '--run') args.run = argv[++i]
87
+ else if (arg === '--repo') args.repo = argv[++i]
88
+ else if (numbers[arg]) args[numbers[arg]] = Number(argv[++i])
89
+ else if (arg === '--help' || arg === '-h') args.help = true
90
+ else args.unknown = arg
91
+ }
92
+ return args
93
+ }
94
+
95
+ function validate(args) {
96
+ if (args.unknown) return `unknown argument ${args.unknown}`
97
+ if (!/^[1-9][0-9]*$/.test(String(args.run ?? ''))) return '--run must be a positive integer'
98
+ if (args.repo != null && !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(args.repo))) {
99
+ return '--repo must be owner/name'
100
+ }
101
+ for (const key of ['intervalSeconds', 'stallMinutes', 'maxMinutes', 'slowStepMinutes']) {
102
+ if (!Number.isFinite(args[key]) || args[key] <= 0) return `${key} must be a positive number`
103
+ }
104
+ for (const key of ['readRetries', 'unreadablePolls']) {
105
+ if (!Number.isInteger(args[key]) || args[key] < 0) return `${key} must be a non-negative integer`
106
+ }
107
+ if (args.unreadablePolls < 1) return '--unreadable-polls must be at least 1'
108
+ if (args.maxMinutes > MAX_MINUTES_CEILING) return `--max-minutes exceeds the ${MAX_MINUTES_CEILING}-minute ceiling`
109
+ return null
110
+ }
111
+
112
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
113
+
114
+ function readTimeoutMs({ intervalSeconds, maxMinutes, elapsedMs }) {
115
+ const perRead = Math.max(1000, intervalSeconds * 3000)
116
+ const remaining = Math.max(1000, maxMinutes * 60_000 - elapsedMs)
117
+ return Math.min(perRead, remaining, 60_000)
118
+ }
119
+
120
+ function ghJson(ghArgs, timeoutMs) {
121
+ try {
122
+ const stdout = execFileSync('gh', ghArgs, {
123
+ encoding: 'utf8',
124
+ timeout: timeoutMs,
125
+ killSignal: 'SIGKILL',
126
+ maxBuffer: MAX_READ_BYTES,
127
+ stdio: ['ignore', 'pipe', 'pipe'],
128
+ env: { ...process.env, GH_PROMPT_DISABLED: '1' },
129
+ })
130
+ const data = JSON.parse(stdout)
131
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { ok: false, error: 'gh run view returned no object' }
132
+ return { ok: true, data }
133
+ } catch (error) {
134
+ const stderr = String(error?.stderr ?? '').trim()
135
+ return { ok: false, error: stderr || String(error?.message ?? 'gh run view failed') }
136
+ }
137
+ }
138
+
139
+ function readRun(args, timeoutMs) {
140
+ const ghArgs = ['run', 'view', String(args.run), '--json', RUN_FIELDS]
141
+ if (args.repo) ghArgs.push('--repo', args.repo)
142
+ return ghJson(ghArgs, timeoutMs)
143
+ }
144
+
145
+ const upper = (value) => String(value ?? '').toUpperCase()
146
+ const timeMs = (value) => {
147
+ const parsed = Date.parse(value ?? '')
148
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
149
+ }
150
+
151
+ function normalizeSteps(job) {
152
+ return (Array.isArray(job?.steps) ? job.steps : []).map((step, index) => ({
153
+ index: Number.isFinite(step?.number) ? step.number : index + 1,
154
+ name: step?.name || `step-${index + 1}`,
155
+ status: upper(step?.status) || null,
156
+ conclusion: upper(step?.conclusion) || null,
157
+ startedMs: timeMs(step?.startedAt),
158
+ completedMs: timeMs(step?.completedAt),
159
+ }))
160
+ }
161
+
162
+ function normalizeJob(job) {
163
+ const steps = normalizeSteps(job)
164
+ const current = steps.filter((step) => step.status === 'IN_PROGRESS').at(-1)
165
+ ?? steps.filter((step) => step.status !== null).at(-1)
166
+ ?? null
167
+ return {
168
+ id: String(job?.databaseId ?? job?.url ?? job?.name ?? 'unnamed-job'),
169
+ name: job?.name || 'unnamed-job',
170
+ status: upper(job?.status) || null,
171
+ conclusion: upper(job?.conclusion) || null,
172
+ startedMs: timeMs(job?.startedAt),
173
+ completedMs: timeMs(job?.completedAt),
174
+ url: typeof job?.url === 'string' && job.url.startsWith('http') ? job.url : null,
175
+ steps,
176
+ current,
177
+ }
178
+ }
179
+
180
+ // The canonical projection: run status/conclusion plus each job's status and the step it is
181
+ // on. Sorted by job name, no clocks — this is what "the run changed" means, and what a
182
+ // background task can predicate on.
183
+ function project(data) {
184
+ const jobs = (Array.isArray(data.jobs) ? data.jobs : [])
185
+ .filter(Boolean)
186
+ .map(normalizeJob)
187
+ .sort((a, b) => {
188
+ const key = (job) => JSON.stringify([job.name, job.id, job.status, job.conclusion,
189
+ job.steps.map((step) => [step.index, step.name, step.status, step.conclusion])])
190
+ return key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0
191
+ })
192
+ const state = {
193
+ run: {
194
+ attempt: data.attempt ?? null,
195
+ status: upper(data.status) || null,
196
+ conclusion: upper(data.conclusion) || null,
197
+ workflow: data.workflowName ?? null,
198
+ sha: data.headSha ?? null,
199
+ },
200
+ jobs: jobs.map((job) => ({
201
+ id: job.id,
202
+ name: job.name,
203
+ status: job.status,
204
+ conclusion: job.conclusion,
205
+ step: job.current?.name ?? null,
206
+ stepStatus: job.current?.status ?? null,
207
+ })),
208
+ running: jobs.flatMap((job) => job.steps
209
+ .filter((step) => step.status === 'IN_PROGRESS')
210
+ .map((step) => `${job.name} / ${step.name}`)),
211
+ waiting: jobs.filter((job) => job.status !== 'COMPLETED' && job.status !== 'IN_PROGRESS')
212
+ .map((job) => `${job.name}:${job.status ?? 'unknown'}`),
213
+ }
214
+ // The change key is finer than the emitted projection: a step moving is a change even
215
+ // when the job and run statuses are unchanged. It is never emitted — only compared.
216
+ const key = JSON.stringify([
217
+ state.run,
218
+ jobs.map((job) => [job.id, job.name, job.status, job.conclusion, job.steps.map((step) => [step.index, step.name, step.status, step.conclusion])]),
219
+ ])
220
+ return { jobs, state, key }
221
+ }
222
+
223
+ const label = (status, conclusion) => conclusion || status || 'unknown'
224
+
225
+ // The transitions between two projections, as lines a human can read in a log tail.
226
+ function describeChanges(previousState, previousJobs, { jobs, state }) {
227
+ const changes = []
228
+ if (previousState && previousJobs) {
229
+ if (previousState.run.status !== state.run.status || previousState.run.conclusion !== state.run.conclusion) {
230
+ changes.push(`run: ${label(previousState.run.status, previousState.run.conclusion)} -> ${label(state.run.status, state.run.conclusion)}`)
231
+ }
232
+ if (previousState.run.attempt !== state.run.attempt) {
233
+ changes.push(`run attempt: ${previousState.run.attempt} -> ${state.run.attempt}`)
234
+ }
235
+ const before = new Map(previousJobs.map((job) => [job.id, job]))
236
+ for (const job of jobs) {
237
+ const prior = before.get(job.id)
238
+ if (!prior) changes.push(`job ${job.name}: appeared (${label(job.status, job.conclusion)})`)
239
+ else if (prior.status !== job.status || prior.conclusion !== job.conclusion) {
240
+ changes.push(`job ${job.name}: ${label(prior.status, prior.conclusion)} -> ${label(job.status, job.conclusion)}`)
241
+ }
242
+ const priorSteps = new Map((prior?.steps ?? []).map((step) => [step.index, label(step.status, step.conclusion)]))
243
+ for (const step of job.steps) {
244
+ const was = priorSteps.get(step.index)
245
+ const now = label(step.status, step.conclusion)
246
+ if (!was) changes.push(`step ${job.name} / ${step.name}: appeared (${now})`)
247
+ else if (was !== now) changes.push(`step ${job.name} / ${step.name}: ${was} -> ${now}`)
248
+ }
249
+ }
250
+ }
251
+ if (previousState && changes.length === 0) changes.push('run state changed (job, step, or run metadata)')
252
+ const extra = Math.max(0, changes.length - MAX_REPORTED_CHANGES)
253
+ return {
254
+ changes: changes.slice(0, MAX_REPORTED_CHANGES),
255
+ changesOmitted: extra,
256
+ }
257
+ }
258
+
259
+ // One warning per job run and per step run, emitted while they are still running.
260
+ function slowRecords(jobs, { thresholdMs, warned, nowMs, runUrl }) {
261
+ const out = []
262
+ const emit = (kind, key, { durationMs, ...fields }) => {
263
+ if (warned.has(key) || durationMs <= thresholdMs) return
264
+ warned.add(key)
265
+ out.push(JSON.stringify({
266
+ warning: 'slow-ci-step',
267
+ kind,
268
+ durationSec: Math.round(durationMs / 1000),
269
+ thresholdSec: Math.round(thresholdMs / 1000),
270
+ ...fields,
271
+ }))
272
+ }
273
+ for (const job of jobs) {
274
+ const jobEnd = job.completedMs ?? nowMs
275
+ if (job.startedMs) {
276
+ emit('job', `job|${job.name}|${job.startedMs}`, {
277
+ job: job.name,
278
+ step: null,
279
+ status: label(job.status, job.conclusion),
280
+ durationMs: jobEnd - job.startedMs,
281
+ url: job.url ?? runUrl,
282
+ })
283
+ }
284
+ for (const step of job.steps) {
285
+ if (!step.startedMs) continue
286
+ emit('step', `step|${job.name}|${step.name}|${step.startedMs}`, {
287
+ job: job.name,
288
+ step: step.name,
289
+ status: label(step.status, step.conclusion),
290
+ durationMs: (step.completedMs ?? nowMs) - step.startedMs,
291
+ url: job.url ?? runUrl,
292
+ })
293
+ }
294
+ }
295
+ return out
296
+ }
297
+
298
+ function failureDetail(jobs) {
299
+ const failed = jobs.filter((job) => job.conclusion && !PASSING.has(job.conclusion))
300
+ const named = failed.map((job) => {
301
+ const steps = job.steps.filter((step) => step.conclusion && !PASSING.has(step.conclusion)).map((step) => step.name)
302
+ return steps.length > 0 ? `${job.name} (${job.conclusion}: ${steps.join(', ')})` : `${job.name} (${job.conclusion})`
303
+ })
304
+ return {
305
+ failedJobs: named,
306
+ detail: named.length > 0 ? named.join('; ') : 'the run finished without a conclusion',
307
+ }
308
+ }
309
+
310
+ async function main() {
311
+ const args = parseArgs(process.argv.slice(2))
312
+ if (args.help) {
313
+ console.log(help())
314
+ return 0
315
+ }
316
+ const invalid = validate(args)
317
+ if (invalid) {
318
+ console.error(`watch-run: ${invalid}`)
319
+ return 2
320
+ }
321
+
322
+ const startedMs = Date.now()
323
+ const slowThresholdMs = args.slowStepMinutes * 60_000
324
+ const warnedSlow = new Set()
325
+ let lastKey = null
326
+ let lastState = null
327
+ let previousState = null
328
+ let previousJobs = null
329
+ let lastRunUrl = null
330
+ let unreadablePolls = 0
331
+ let observations = 0
332
+ let transitions = 0
333
+ let lastError = null
334
+ let changedAtMs = startedMs
335
+ let lastReadable = true
336
+ let lastUnreadableKey = null
337
+ let latestJobs = []
338
+
339
+ const finish = (result) => {
340
+ const endMs = Date.now()
341
+ const failures = failureDetail(latestJobs)
342
+ const verdict = {
343
+ outcome: result.outcome,
344
+ terminal: true,
345
+ reason: result.reason ?? null,
346
+ detail: result.detail ?? null,
347
+ run: {
348
+ id: Number(args.run),
349
+ attempt: lastState?.run?.attempt ?? null,
350
+ status: lastState?.run?.status ?? null,
351
+ conclusion: lastState?.run?.conclusion ?? null,
352
+ workflow: lastState?.run?.workflow ?? null,
353
+ sha: lastState?.run?.sha ?? null,
354
+ url: lastRunUrl,
355
+ },
356
+ jobs: lastState?.jobs ?? [],
357
+ failedJobs: failures.failedJobs,
358
+ slowJobs: [...warnedSlow].filter((key) => key.startsWith('job|')).map((key) => key.slice(4, key.lastIndexOf('|'))),
359
+ slowSteps: [...warnedSlow].filter((key) => key.startsWith('step|')).map((key) => {
360
+ const [, job, ...rest] = key.split('|')
361
+ return `${job} / ${rest.slice(0, -1).join('|')}`
362
+ }),
363
+ observations,
364
+ transitions,
365
+ elapsedMs: endMs - startedMs,
366
+ }
367
+ console.log(JSON.stringify(verdict, null, 2))
368
+ console.error(JSON.stringify({
369
+ summary: true,
370
+ outcome: verdict.outcome,
371
+ reason: verdict.reason,
372
+ failedJobs: verdict.failedJobs,
373
+ slowSteps: verdict.slowSteps,
374
+ running: lastState?.running ?? [],
375
+ }))
376
+ return result.exitCode
377
+ }
378
+
379
+ for (;;) {
380
+ observations += 1
381
+ const elapsedMs = Date.now() - startedMs
382
+ const timeoutMs = readTimeoutMs({ intervalSeconds: args.intervalSeconds, maxMinutes: args.maxMinutes, elapsedMs })
383
+
384
+ let read = null
385
+ for (let attempt = 0; attempt <= args.readRetries; attempt += 1) {
386
+ read = readRun(args, timeoutMs)
387
+ if (read.ok) break
388
+ if (attempt < args.readRetries) await sleepMs(Math.min(1000, args.intervalSeconds * 1000))
389
+ }
390
+
391
+ if (!read.ok) {
392
+ unreadablePolls += 1
393
+ lastError = read.error
394
+ const key = `unreadable|${read.error}`
395
+ if (lastReadable || key !== lastUnreadableKey) {
396
+ lastUnreadableKey = key
397
+ lastReadable = false
398
+ transitions += 1
399
+ console.error(JSON.stringify({
400
+ kind: 'transition',
401
+ progress: false,
402
+ reason: 'provider-unreadable',
403
+ at: new Date().toISOString(),
404
+ state: { readable: false, error: read.error },
405
+ }))
406
+ }
407
+ if (unreadablePolls >= args.unreadablePolls) {
408
+ return finish({ outcome: 'UNKNOWN', reason: 'unreadable', detail: `gh run view failed ${unreadablePolls} polls in a row: ${lastError}`, exitCode: 4 })
409
+ }
410
+ if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
411
+ return finish({ outcome: 'UNKNOWN', reason: 'max-minutes', detail: `watch reached its ${args.maxMinutes}m ceiling while the provider was unreadable: ${lastError}`, exitCode: 4 })
412
+ }
413
+ await sleepMs(args.intervalSeconds * 1000)
414
+ continue
415
+ }
416
+
417
+ unreadablePolls = 0
418
+ const { jobs, state, key } = project(read.data)
419
+ latestJobs = jobs
420
+ lastState = state
421
+ lastRunUrl = read.data.url ?? null
422
+
423
+ if (!STATUSES.has(state.run.status)) {
424
+ return finish({ outcome: 'UNKNOWN', reason: 'unknown-run-state', detail: `unsupported run status: ${state.run.status ?? 'missing'}`, exitCode: 4 })
425
+ }
426
+ if ((state.run.conclusion && !CONCLUSIONS.has(state.run.conclusion)) ||
427
+ (state.run.status === 'COMPLETED' && !state.run.conclusion)) {
428
+ return finish({ outcome: 'UNKNOWN', reason: 'unknown-run-conclusion', detail: `unsupported run conclusion: ${state.run.conclusion ?? 'missing'}`, exitCode: 4 })
429
+ }
430
+
431
+ const malformed = jobs.find((job) => !STATUSES.has(job.status) ||
432
+ (job.conclusion && !CONCLUSIONS.has(job.conclusion)) ||
433
+ job.steps.some((step) => !STATUSES.has(step.status) ||
434
+ (step.conclusion && !CONCLUSIONS.has(step.conclusion))))
435
+ if (malformed) {
436
+ return finish({ outcome: 'UNKNOWN', reason: 'unknown-job-state', detail: `unsupported job or step state in ${malformed.name}`, exitCode: 4 })
437
+ }
438
+
439
+ if (key !== lastKey || !lastReadable) {
440
+ const stateChanged = key !== lastKey
441
+ const { changes, changesOmitted } = describeChanges(previousState, previousJobs, { jobs, state })
442
+ const reason = lastKey === null ? 'run-state-initial' : lastReadable ? 'run-state' : 'provider-readable'
443
+ lastKey = key
444
+ if (stateChanged) changedAtMs = Date.now()
445
+ transitions += 1
446
+ lastReadable = true
447
+ console.error(JSON.stringify({
448
+ kind: 'transition',
449
+ progress: stateChanged,
450
+ reason,
451
+ at: new Date().toISOString(),
452
+ ...(reason === 'run-state-initial' ? {} : { changes: stateChanged ? changes : [], changesOmitted: stateChanged ? changesOmitted : 0 }),
453
+ state,
454
+ }))
455
+ }
456
+ previousState = state
457
+ previousJobs = jobs
458
+
459
+ for (const warning of slowRecords(jobs, { thresholdMs: slowThresholdMs, warned: warnedSlow, nowMs: Date.now(), runUrl: lastRunUrl })) {
460
+ console.error(warning)
461
+ }
462
+
463
+ if (state.run.status === 'COMPLETED') {
464
+ const conclusion = state.run.conclusion
465
+ if (PASSING.has(conclusion)) {
466
+ return finish({
467
+ outcome: conclusion === 'SUCCESS' ? 'SUCCESS' : 'NEUTRAL',
468
+ reason: `run-${String(conclusion).toLowerCase()}`,
469
+ exitCode: 0,
470
+ })
471
+ }
472
+ const failures = failureDetail(jobs)
473
+ return finish({
474
+ outcome: conclusion === 'CANCELLED' ? 'CANCELLED' : 'FAILED',
475
+ reason: `run-${String(conclusion || 'unknown').toLowerCase()}`,
476
+ detail: failures.detail,
477
+ exitCode: 4,
478
+ })
479
+ }
480
+
481
+ const inProgress = state.running.length > 0 || jobs.some((job) => job.status === 'IN_PROGRESS')
482
+
483
+ if (Date.now() - startedMs >= args.maxMinutes * 60_000) {
484
+ return finish({
485
+ outcome: 'STALLED',
486
+ reason: 'max-minutes',
487
+ detail: `watch reached its ${args.maxMinutes}m ceiling with the run still ${state.run.status ?? 'unknown'}`,
488
+ exitCode: 4,
489
+ })
490
+ }
491
+ // Nothing progressed and nothing is running: this is the shape that hides behind a
492
+ // repainting status line — a job queued for a runner that will never match it.
493
+ if (!inProgress && Date.now() - changedAtMs >= args.stallMinutes * 60_000) {
494
+ return finish({
495
+ outcome: 'STALLED',
496
+ reason: 'no-progress',
497
+ detail: state.waiting.length > 0
498
+ ? `no job or step started for ${args.stallMinutes}m; waiting on ${state.waiting.join(', ')}`
499
+ : `no observable run-state change for ${args.stallMinutes}m`,
500
+ exitCode: 4,
501
+ })
502
+ }
503
+
504
+ await sleepMs(args.intervalSeconds * 1000)
505
+ }
506
+ }
507
+
508
+ process.exitCode = await main().catch((error) => {
509
+ console.error(JSON.stringify({ error: 'watch-run-failed', detail: String(error?.message ?? error) }))
510
+ return 4
511
+ })
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: github-flow
3
3
  description: "Deliver a change after the commit: push, open a PR, wait for checks, and merge to main through the repo's flow (direct push, pull request, or merge queue). Use whenever you commit or finish a change, when CONTRIBUTING.md has a Delivery section, and for asks like 'commit this', 'push it', 'open a PR', 'ship it', 'land it', 'get it merged'. Also: declare a repo's delivery flow, or write the CI conventions and Delivery section into CONTRIBUTING.md on request. NOT for writing workflow YAML (github-actions), Mergify config or a stuck queued PR (mergify), cutting a release (resolve-release), or PRs that resolve-issues or rush-issues deliver."
4
- release-version: 0.50.1
4
+ release-version: 0.50.3
5
5
  ---
6
6
  **Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
7
7
  # GitHub Flow
@@ -58,15 +58,37 @@ Commit only the files of this change. Leave unrelated working-tree changes alone
58
58
  - Stack repo (`mergify-hooks/` in the git hooks dir; the pre-push hook says "Use 'mergify stack
59
59
  push'"): replace steps 2–3 with the `mergify` skill's `scripts/stack-push.mjs`. Put
60
60
  `Closes #<n>` in the commit message. Never `--no-verify`.
61
- 4. Wait for the checks: `gh pr checks --watch --required --fail-fast`.
62
- - "no required checks": run it again without `--required`.
63
- - "no checks reported": wait 30 seconds and retry; after 5 minutes with none, the repo has no CI
64
- for this PR. Continue.
65
- - Red: go to [Fix a red check](#fix-a-red-check).
61
+ 4. Wait for the checks to settle:
62
+
63
+ ```bash
64
+ node "$SKILL_DIR/scripts/watch-checks.mjs" --pr <number>
65
+ ```
66
+
67
+ It preserves the existing bar: required checks, falling back to all checks when none
68
+ are required. It writes one record only when the selected check set actually changes — a stationary phase stays
69
+ quiet rather than redrawing. Read the terminal verdict:
70
+ - exit 0 `CHECKS_PASSED`: go to step 5.
71
+ - exit 0 `NO_CHECKS`: none reported within the bound. Continue only if the repository's
72
+ policy permits no CI; otherwise diagnose the missing checks.
73
+ - exit 4 `CHECKS_FAILED`: the verdict names them. Go to [Fix a red check](#fix-a-red-check).
74
+ - exit 4 `STALLED` / `UNKNOWN`: report the verdict and stop. Never merge on a guess.
75
+
76
+ Avoid redraw-based waits: the elapsed clock in `gh pr checks --watch` looks like
77
+ progress to a log monitor even while one check is stationary. Keep the repository's
78
+ existing check policy; observation does not add optional checks as merge gates.
66
79
  5. Merge once green: `gh pr merge --<mergeMethod> --delete-branch`, adding `--auto` when
67
80
  `autoMerge: true`. On a refusal for review, go to [Stop at a human gate](#stop-at-a-human-gate).
68
- 6. Confirm `gh pr view --json state,mergeCommit` shows `MERGED`, then
69
- `git switch <branch> && git pull --ff-only`.
81
+ 6. Prove the merge — a zero exit from `gh pr merge --auto` means the *request* was accepted:
82
+
83
+ ```bash
84
+ node "$SKILL_DIR/scripts/watch-checks.mjs" --pr <number> --until merged
85
+ ```
86
+
87
+ Continue only after `MERGED`, which prints `pr.mergedSha`; a checkless open PR still
88
+ needs provider merge confirmation. Exit 4 `STALLED` with `reason: merge-gate:blocked` is the
89
+ human gate; `merge-gate:behind` or `merge-gate:dirty` needs a rebase or a conflict fix. A PR
90
+ with an accepted auto-merge request is never called a gate — the provider owns the next step,
91
+ so the ceiling is the hand-back there. Then `git switch <branch> && git pull --ff-only`.
70
92
 
71
93
  ## Flow: merge-queue
72
94
 
@@ -76,7 +98,11 @@ Commit only the files of this change. Leave unrelated working-tree changes alone
76
98
  `scripts/watch-pr-delivery.mjs` to a verdict. Enqueue several ready PRs together.
77
99
  - `queue: github`: `gh pr merge --auto` with no merge method; the queue sets it.
78
100
  3. Dequeued: with Mergify, follow its dequeue-reason table. Re-enqueue only after the cause is fixed.
79
- 4. Confirm the merge and update the local default branch as in pull-request step 6.
101
+ 4. Confirm the merge, then update the local default branch as in pull-request step 6.
102
+ - `queue: mergify`: the queue watcher's `MERGED` verdict carries the merge commit. The
103
+ `queued` label disappearing is not a merge.
104
+ - `queue: github`: `watch-checks.mjs --pr <number> --until merged --max-minutes 120`. The
105
+ ceiling is the hand-back; auto-merge being accepted is not a merge.
80
106
 
81
107
  ## Fix a red check
82
108