@jaybeeuu/agent-cortex 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +87 -8
  3. package/agents/README.md +4 -4
  4. package/agents/guppi/agent.md +162 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph/agent.md +17 -16
  9. package/agents/ralph/claude/wake.md +4 -4
  10. package/agents/ralph/pi/wake.md +15 -20
  11. package/agents/ralph-plan/agent.md +6 -0
  12. package/bin/agent-cortex.mjs +20 -1
  13. package/bin/installers/claude.mjs +1 -20
  14. package/bin/installers/pi.mjs +8 -2
  15. package/extensions/interrupt-bookmark/README.md +129 -0
  16. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  17. package/extensions/interrupt-bookmark/index.ts +354 -0
  18. package/extensions/interrupt-bookmark/package.json +20 -0
  19. package/extensions/post-merge-watch/README.md +133 -0
  20. package/extensions/post-merge-watch/index.test.ts +247 -0
  21. package/extensions/post-merge-watch/index.ts +123 -0
  22. package/extensions/post-merge-watch/package.json +16 -0
  23. package/extensions/post-merge-watch/watch.test.ts +469 -0
  24. package/extensions/post-merge-watch/watch.ts +431 -0
  25. package/extensions/session-clear/README.md +74 -0
  26. package/extensions/session-clear/index.test.ts +524 -0
  27. package/extensions/session-clear/index.ts +159 -0
  28. package/extensions/session-clear/package.json +16 -0
  29. package/extensions/skill-stats/README.md +84 -3
  30. package/extensions/skill-stats/index.ts +62 -3
  31. package/extensions/skill-stats/package.json +1 -1
  32. package/extensions/skill-stats/rollup.test.ts +643 -0
  33. package/extensions/skill-stats/rollup.ts +415 -0
  34. package/extensions/skill-stats/timeline.ts +101 -0
  35. package/lib/cli.mjs +42 -1
  36. package/lib/self-update.mjs +553 -0
  37. package/package.json +1 -1
  38. package/scripts/lib/manifest.mjs +29 -0
  39. package/skills/engineering/implement-task/SKILL.md +124 -0
  40. package/skills/engineering/tdd/SKILL.md +2 -0
  41. package/skills/planning/create-task/REFERENCE.md +15 -6
  42. package/skills/planning/create-task/SKILL.md +1 -1
  43. package/skills/planning/create-task/pipeline.json +8 -0
  44. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  45. package/skills/planning/create-task/templates/code.md +8 -3
  46. package/skills/planning/create-task/templates/document.md +8 -3
  47. package/skills/planning/create-task/templates/fix.md +8 -3
  48. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  49. package/skills/planning/create-task/templates/post-merge.md +67 -0
  50. package/skills/planning/create-task/templates/review.md +8 -3
  51. package/skills/planning/create-task/templates/verify.md +8 -3
  52. package/skills/productivity/bd-tool/SKILL.md +55 -0
  53. package/skills/review/review-defects/EXAMPLES.md +198 -0
  54. package/skills/review/review-defects/SKILL.md +121 -0
  55. package/skills/review/review-report/EXAMPLES.md +114 -0
  56. package/skills/review/review-report/SKILL.md +138 -0
  57. package/skills/review/review-report/scripts/report.mjs +1032 -0
  58. package/skills/style/style-tests/EXAMPLES.md +30 -0
  59. package/skills/style/style-tests/SKILL.md +10 -6
  60. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  61. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  62. package/skills/workflow/plan/SKILL.md +27 -3
  63. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  64. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  65. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  66. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  67. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  68. package/skills/workflow/ralph/REFERENCE.md +124 -27
  69. package/skills/workflow/ralph/SKILL.md +24 -15
  70. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  71. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  72. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  73. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  74. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  75. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  76. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  77. package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
  78. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  79. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  80. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  81. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  82. package/skills/workflow/score-bead/FORMAT.md +73 -0
  83. package/skills/workflow/score-bead/SKILL.md +112 -0
  84. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  85. package/skills/workflow/using-agent-skills/SKILL.md +21 -8
  86. package/token-map.README.md +27 -0
  87. package/token-map.json +16 -4
@@ -0,0 +1,651 @@
1
+ // @ts-check
2
+ // Post-merge check: did the CI run for the merge commit pass?
3
+ //
4
+ // Ralph's post-merge stage runs after a human merges the feature PR and closes the
5
+ // `lifecycle:feature-pr` gate. This script reads `gh run list` for the base branch's
6
+ // tip commit and answers one question with an exit code:
7
+ //
8
+ // 0 passed every run for the commit concluded successfully
9
+ // 1 failed a run concluded in failure; one P1 escalation bead is filed
10
+ // carrying the run URL and the failing job (a re-run files nothing)
11
+ // 2 inconclusive no run for the commit on the base branch, a run still on the wait
12
+ // window, a conclusion that is neither pass nor failure, or a
13
+ // repository `gh` cannot see as GitHub. Nothing is filed.
14
+ //
15
+ // The check never retries a run, never re-runs a workflow, and never touches the
16
+ // parent bead: a failure is reported to a human, not repaired by the pipeline.
17
+ //
18
+ // GitHub-only in v1: a remote `gh` cannot resolve to a GitHub repository is
19
+ // inconclusive. The repository slug and base branch come from `gh repo view`, never
20
+ // from a hardcoded constant, because this ships to arbitrary consuming repos.
21
+ //
22
+ // Zero dependencies: run with `node post-merge-check.mjs`.
23
+
24
+ import { execFile } from 'node:child_process';
25
+ import { promisify } from 'node:util';
26
+ import { pathToFileURL } from 'node:url';
27
+
28
+ const execFileAsync = promisify(execFile);
29
+
30
+ // GH_PATH / BD_PATH env overrides let machines where a binary is not on PATH pin a
31
+ // location. git lives beside them conceptually, so GIT_PATH follows the same shape.
32
+ const GH_PATH = process.env.GH_PATH || 'gh';
33
+ const BD_PATH = process.env.BD_PATH || 'bd';
34
+ const GIT_PATH = process.env.GIT_PATH || 'git';
35
+
36
+ // ─── Types ────────────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * A workflow run as `gh run list --json` reports it.
40
+ * @typedef {object} WorkflowRun
41
+ * @property {number} databaseId
42
+ * @property {string} url
43
+ * @property {string} workflowName
44
+ * @property {string} headBranch
45
+ * @property {string} headSha
46
+ * @property {string} status `completed` once the run settled
47
+ * @property {string | null} conclusion
48
+ */
49
+
50
+ /**
51
+ * A job that concluded in failure, as reported in the escalation bead.
52
+ * @typedef {object} FailingJob
53
+ * @property {string} name
54
+ * @property {string} url
55
+ */
56
+
57
+ /** @typedef {'passed' | 'failed' | 'inconclusive'} Outcome */
58
+
59
+ /** A child-process call that returns stdout. @typedef {(args: string[]) => Promise<string>} Runner */
60
+
61
+ /**
62
+ * The one escalation bead a failing run files, and whether this run filed it.
63
+ * @typedef {object} Escalation
64
+ * @property {boolean} filed
65
+ * @property {string} [beadId] Set when this run created the bead.
66
+ * @property {string} [existing] Set when a bead for this run was already filed.
67
+ * @property {string} [detail] Set when the bead could not be filed.
68
+ */
69
+
70
+ /**
71
+ * The machine-readable result, printed by the CLI and returned to tests.
72
+ * @typedef {object} CheckReport
73
+ * @property {Outcome} outcome
74
+ * @property {number} exitCode
75
+ * @property {string} reason
76
+ * @property {string} base
77
+ * @property {string | null} repository
78
+ * @property {string | null} commit
79
+ * @property {WorkflowRun[]} runs Runs for the commit on the base branch.
80
+ * @property {string[]} ignoredBranches Branches with runs for the same commit, not read.
81
+ * @property {FailingJob[]} failingJobs
82
+ * @property {Escalation} escalation
83
+ */
84
+
85
+ /**
86
+ * Injected collaborators, so a test can drive the check without a GitHub account,
87
+ * a network, or the real bead database.
88
+ * @typedef {object} CheckDeps
89
+ * @property {Runner} [gh]
90
+ * @property {Runner} [bd]
91
+ * @property {Runner} [git]
92
+ */
93
+
94
+ // ─── Contract ─────────────────────────────────────────────────────────────────
95
+
96
+ /** Exit codes are the contract: 0 passed, 1 failed, 2 inconclusive.
97
+ * @type {Record<Outcome, number>} */
98
+ export const EXIT_CODES = { passed: 0, failed: 1, inconclusive: 2 };
99
+
100
+ /** The label that keys the escalation search, so a re-run finds the bead it filed. */
101
+ export const ESCALATION_LABEL = 'lifecycle:post-merge-escalation';
102
+
103
+ /** Seconds the check waits for a run to appear and settle before it reports inconclusive. */
104
+ export const DEFAULT_WAIT_SECONDS = 300;
105
+
106
+ /** Used only when `gh` reports no default branch for the repository. */
107
+ const DEFAULT_BASE_BRANCH = 'main';
108
+
109
+ /** Seconds between two `gh run list` calls while a run is still in flight. */
110
+ const POLL_INTERVAL_MS = 10_000;
111
+
112
+ /** Every field the check reads from a run, and nothing else. */
113
+ const RUN_FIELDS = 'databaseId,url,workflowName,headBranch,headSha,status,conclusion';
114
+
115
+ /**
116
+ * Conclusions that mean the commit is good. `skipped` and `neutral` are not failures:
117
+ * this repository's release job is skipped on every non-release run.
118
+ */
119
+ const PASSING_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
120
+
121
+ /**
122
+ * Conclusions that mean the commit is bad. `cancelled`, `stale` and `action_required`
123
+ * are deliberately absent: they report no verdict on the commit, so they are
124
+ * inconclusive rather than a failure a human must be paged for.
125
+ */
126
+ const FAILING_CONCLUSIONS = new Set(['failure', 'timed_out', 'startup_failure']);
127
+
128
+ class UsageError extends Error {}
129
+
130
+ // ─── Classification ───────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * @param {WorkflowRun[]} runs
134
+ * @returns {string[]} The distinct conclusions in `runs`, in run order.
135
+ */
136
+ function conclusionsOf(runs) {
137
+ return [...new Set(runs.map((run) => run.conclusion ?? 'null'))];
138
+ }
139
+
140
+ /**
141
+ * Classify the runs for one commit. Pure: the exit-code contract is decided here,
142
+ * so the three outcomes are readable in one place.
143
+ *
144
+ * An empty run set is inconclusive, never passed — a commit with no run has not been
145
+ * verified, and reporting that as a pass would hide a broken workflow.
146
+ *
147
+ * @param {WorkflowRun[]} runs Completed runs for the commit on the base branch.
148
+ * @returns {{ outcome: Outcome, reason: string, failingRuns: WorkflowRun[], conclusions: string[] }}
149
+ */
150
+ export function classifyRuns(runs) {
151
+ if (runs.length === 0) {
152
+ return { outcome: 'inconclusive', reason: 'no-run', failingRuns: [], conclusions: [] };
153
+ }
154
+
155
+ const failingRuns = runs.filter((run) => FAILING_CONCLUSIONS.has(run.conclusion ?? ''));
156
+ if (failingRuns.length > 0) {
157
+ return { outcome: 'failed', reason: 'run-failed', failingRuns, conclusions: conclusionsOf(runs) };
158
+ }
159
+
160
+ const unrecognised = runs.filter((run) => !PASSING_CONCLUSIONS.has(run.conclusion ?? ''));
161
+ if (unrecognised.length > 0) {
162
+ return {
163
+ outcome: 'inconclusive',
164
+ reason: 'unrecognised-conclusion',
165
+ failingRuns: [],
166
+ conclusions: conclusionsOf(unrecognised),
167
+ };
168
+ }
169
+
170
+ return { outcome: 'passed', reason: 'runs-passed', failingRuns: [], conclusions: conclusionsOf(runs) };
171
+ }
172
+
173
+ // ─── Runners ──────────────────────────────────────────────────────────────────
174
+
175
+ /** @type {Runner} */
176
+ async function runGh(args) {
177
+ const { stdout } = await execFileAsync(GH_PATH, args, { encoding: 'utf-8' });
178
+ return stdout;
179
+ }
180
+
181
+ /** @type {Runner} */
182
+ async function runBd(args) {
183
+ const { stdout } = await execFileAsync(BD_PATH, args, { encoding: 'utf-8' });
184
+ return stdout;
185
+ }
186
+
187
+ /**
188
+ * A git runner bound to one working tree, so the check reads the repository it was
189
+ * pointed at rather than the process's own directory.
190
+ * @param {string} cwd
191
+ * @returns {Runner}
192
+ */
193
+ function gitRunner(cwd) {
194
+ return async (args) => {
195
+ const { stdout } = await execFileAsync(GIT_PATH, args, { cwd, encoding: 'utf-8' });
196
+ return stdout;
197
+ };
198
+ }
199
+
200
+ // ─── Repository and commit ────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Read the repository slug and its default branch through `gh`, so the check works in
204
+ * any consuming repository instead of assuming one slug and one branch. `gh` resolves
205
+ * the remote itself, which is also the GitHub-only test: a remote it cannot map to a
206
+ * GitHub host exits non-zero.
207
+ *
208
+ * @param {Runner} gh
209
+ * @param {string} [base] Caller-supplied base branch, overriding the default branch.
210
+ * @returns {Promise<{ nameWithOwner: string, base: string } | null>} null when `gh`
211
+ * cannot see a GitHub repository.
212
+ */
213
+ async function readRepository(gh, base) {
214
+ try {
215
+ const raw = await gh(['repo', 'view', '--json', 'nameWithOwner,defaultBranchRef']);
216
+ const parsed = JSON.parse(raw);
217
+ const defaultBranch = parsed?.defaultBranchRef?.name;
218
+ return {
219
+ nameWithOwner: typeof parsed?.nameWithOwner === 'string' ? parsed.nameWithOwner : '',
220
+ base: base ?? (typeof defaultBranch === 'string' && defaultBranch !== '' ? defaultBranch : DEFAULT_BASE_BRANCH),
221
+ };
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Resolve the full SHA of the merge commit. `gh run list --commit` matches on the full
229
+ * SHA and returns nothing for a short revision, so every caller-supplied revision is
230
+ * expanded through git before it reaches gh.
231
+ *
232
+ * The fetch is what makes the check read the merge that just landed rather than a stale
233
+ * local ref. A failed fetch is not fatal: the local ref still resolves, and the report
234
+ * carries `fetchFailed`.
235
+ *
236
+ * @param {Runner} git
237
+ * @param {{ commit?: string, base: string, fetch: boolean }} options
238
+ * @returns {Promise<{ sha: string, fetchFailed: boolean } | null>} null when no revision
239
+ * resolves to a commit.
240
+ */
241
+ async function resolveCommit(git, { commit, base, fetch }) {
242
+ let fetchFailed = false;
243
+ if (fetch) {
244
+ try {
245
+ await git(['fetch', '--quiet', 'origin', base]);
246
+ } catch {
247
+ fetchFailed = true;
248
+ }
249
+ }
250
+
251
+ const revisions = commit === undefined ? [`origin/${base}`, base] : [commit];
252
+ for (const revision of revisions) {
253
+ try {
254
+ const sha = (await git(['rev-parse', '--verify', '--quiet', `${revision}^{commit}`])).trim();
255
+ if (sha !== '') return { sha, fetchFailed };
256
+ } catch {
257
+ // Try the next candidate revision.
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ // ─── Runs ─────────────────────────────────────────────────────────────────────
264
+
265
+ /**
266
+ * @param {Runner} gh
267
+ * @param {string} sha
268
+ * @returns {Promise<WorkflowRun[]>}
269
+ */
270
+ async function listRuns(gh, sha) {
271
+ const raw = await gh(['run', 'list', '--commit', sha, '--limit', '30', '--json', RUN_FIELDS]);
272
+ const parsed = raw.trim() === '' ? [] : JSON.parse(raw);
273
+ return Array.isArray(parsed) ? parsed : [];
274
+ }
275
+
276
+ /**
277
+ * @param {WorkflowRun} run
278
+ * @returns {boolean}
279
+ */
280
+ function isSettled(run) {
281
+ return run.status === 'completed';
282
+ }
283
+
284
+ /**
285
+ * Wait for a run to appear for the commit on the base branch and to settle. Runs for
286
+ * other branches are ignored: a `pull_request` run on a release branch shares no
287
+ * verdict about the base branch, and gh keeps no such run for a merged commit.
288
+ *
289
+ * @param {object} options
290
+ * @param {Runner} options.gh
291
+ * @param {string} options.sha
292
+ * @param {string} options.base
293
+ * @param {number} options.waitSeconds `0` checks once instead of polling.
294
+ * @returns {Promise<{ runs: WorkflowRun[], ignoredBranches: string[], reason: string }>}
295
+ */
296
+ async function awaitRuns({ gh, sha, base, waitSeconds }) {
297
+ const deadline = Date.now() + waitSeconds * 1000;
298
+
299
+ for (;;) {
300
+ const all = await listRuns(gh, sha);
301
+ const runs = all.filter((run) => run.headBranch === base);
302
+ const ignoredBranches = [
303
+ ...new Set(all.filter((run) => run.headBranch !== base).map((run) => run.headBranch)),
304
+ ];
305
+
306
+ if (runs.length > 0 && runs.every(isSettled)) {
307
+ return { runs, ignoredBranches, reason: 'settled' };
308
+ }
309
+
310
+ if (Date.now() >= deadline) {
311
+ return { runs, ignoredBranches, reason: runs.length === 0 ? 'no-run' : 'still-running' };
312
+ }
313
+
314
+ await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, deadline - Date.now())));
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Read the jobs of a failing run and keep the ones that concluded in failure.
320
+ * @param {Runner} gh
321
+ * @param {WorkflowRun} run
322
+ * @returns {Promise<FailingJob[]>}
323
+ */
324
+ async function readFailingJobs(gh, run) {
325
+ const raw = await gh(['run', 'view', String(run.databaseId), '--json', 'jobs']);
326
+ const parsed = JSON.parse(raw);
327
+ /** @type {Array<{ name?: unknown, url?: unknown, conclusion?: unknown }>} */
328
+ const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
329
+ return jobs
330
+ .filter((job) => FAILING_CONCLUSIONS.has(typeof job.conclusion === 'string' ? job.conclusion : ''))
331
+ .map((job) => ({
332
+ name: typeof job.name === 'string' ? job.name : 'unnamed job',
333
+ url: typeof job.url === 'string' ? job.url : run.url,
334
+ }));
335
+ }
336
+
337
+ // ─── Escalation ───────────────────────────────────────────────────────────────
338
+
339
+ /**
340
+ * The marker that keeps escalation filing from paging the same human twice for one run: every
341
+ * escalation bead carries it in its description, so a second run of the check finds the bead and
342
+ * files nothing. A run id is immutable, so one bead per run is the intent; `bd list` omits closed
343
+ * beads, so the guarantee is one **open** bead per run and a resolved run can be escalated again.
344
+ * See `findFiledEscalation` for the limit this leaves.
345
+ * @param {WorkflowRun} run
346
+ * @returns {string}
347
+ */
348
+ export function runMarker(run) {
349
+ return `post-merge-run:${run.databaseId}`;
350
+ }
351
+
352
+ /**
353
+ * @param {WorkflowRun} run
354
+ * @param {string} commit
355
+ * @param {string} base
356
+ * @returns {string}
357
+ */
358
+ function escalationTitle(run, commit, base) {
359
+ return `[post-merge] ${run.workflowName || 'workflow'} failed for ${commit.slice(0, 7)} on ${base}`;
360
+ }
361
+
362
+ /**
363
+ * @param {object} input
364
+ * @param {WorkflowRun} input.run
365
+ * @param {FailingJob[]} input.jobs
366
+ * @param {string} input.commit
367
+ * @param {string} input.base
368
+ * @returns {string}
369
+ */
370
+ function escalationDescription({ run, jobs, commit, base }) {
371
+ const failingJobs =
372
+ jobs.length > 0
373
+ ? jobs.map((job) => `- Failing job: ${job.name} — ${job.url}`).join('\n')
374
+ : '- Failing job: none reported by the run';
375
+
376
+ return [
377
+ `The CI run for merge commit ${commit} on ${base} concluded \`${run.conclusion ?? 'unknown'}\`.`,
378
+ '',
379
+ `- Workflow: ${run.workflowName || 'workflow'} (run ${run.databaseId})`,
380
+ `- Run: ${run.url}`,
381
+ failingJobs,
382
+ '',
383
+ runMarker(run),
384
+ '',
385
+ '## Steps to Reproduce',
386
+ '',
387
+ `1. Open ${run.url} and read the failing job log.`,
388
+ `2. Check out ${commit} and run the failing job's commands locally.`,
389
+ '',
390
+ '## Acceptance Criteria',
391
+ '',
392
+ `- [ ] The failing job is green on a later commit of ${base}, or the merge is reverted.`,
393
+ '- [ ] This bead is closed by the human who resolved it.',
394
+ ].join('\n');
395
+ }
396
+
397
+ /**
398
+ * Find an **open** escalation this check already filed for the run.
399
+ *
400
+ * `bd list` omits closed beads, so a bead a human already resolved is not found and a later check
401
+ * of the same run files a fresh one. That repeats a signal for a run someone closed; it never
402
+ * loses one. Tracked as a known limitation — do not read this as one bead per run forever.
403
+ * @param {Runner} bd
404
+ * @param {string} marker
405
+ * @returns {Promise<string | null>} The bead id, or null when nothing was filed yet.
406
+ */
407
+ async function findFiledEscalation(bd, marker) {
408
+ const raw = await bd(['list', '--json', '--limit', '0', '-l', ESCALATION_LABEL]);
409
+ const beads = raw.trim() === '' ? [] : JSON.parse(raw);
410
+ const filed = (Array.isArray(beads) ? beads : []).find(
411
+ (bead) => typeof bead?.description === 'string' && bead.description.includes(marker),
412
+ );
413
+ return filed ? String(filed.id) : null;
414
+ }
415
+
416
+ /**
417
+ * File the P1 escalation bead for a failed run — at most one **open** bead per run, whatever the
418
+ * number of check invocations. A bead a human has closed is not found again, so this can repeat
419
+ * an escalation for a run already resolved (see `findFiledEscalation`). The bead is independent:
420
+ * it carries no parent, so the feature bead is never reopened and never gains a child.
421
+ *
422
+ * @param {object} input
423
+ * @param {Runner} input.bd
424
+ * @param {WorkflowRun} input.run
425
+ * @param {FailingJob[]} input.jobs
426
+ * @param {string} input.commit
427
+ * @param {string} input.base
428
+ * @returns {Promise<Escalation>}
429
+ */
430
+ export async function fileEscalation({ bd, run, jobs, commit, base }) {
431
+ const existing = await findFiledEscalation(bd, runMarker(run));
432
+ if (existing !== null) return { filed: false, existing };
433
+
434
+ try {
435
+ const beadId = (
436
+ await bd([
437
+ 'create',
438
+ escalationTitle(run, commit, base),
439
+ '--type', 'bug',
440
+ '--priority', '1',
441
+ '--description', escalationDescription({ run, jobs, commit, base }),
442
+ '--labels', `execution:hitl,${ESCALATION_LABEL}`,
443
+ // The bead is human-owned, so it never inherits an executor label from a parent.
444
+ '--no-inherit-labels',
445
+ '--validate',
446
+ '--silent',
447
+ ])
448
+ ).trim();
449
+ return { filed: true, beadId };
450
+ } catch (error) {
451
+ return { filed: false, detail: error instanceof Error ? error.message : String(error) };
452
+ }
453
+ }
454
+
455
+ // ─── Check ────────────────────────────────────────────────────────────────────
456
+
457
+ /**
458
+ * @param {object} input
459
+ * @param {string} input.outcome
460
+ * @param {string} input.reason
461
+ * @param {Partial<CheckReport>} [input.rest]
462
+ * @returns {CheckReport}
463
+ */
464
+ function buildReport({ outcome, reason, rest = {} }) {
465
+ /** @type {Outcome} */
466
+ const settled = outcome === 'passed' || outcome === 'failed' ? outcome : 'inconclusive';
467
+ return {
468
+ outcome: settled,
469
+ exitCode: EXIT_CODES[settled],
470
+ reason,
471
+ base: DEFAULT_BASE_BRANCH,
472
+ repository: null,
473
+ commit: null,
474
+ runs: [],
475
+ ignoredBranches: [],
476
+ failingJobs: [],
477
+ escalation: { filed: false },
478
+ ...rest,
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Run the check and return its report. Every expected failure — no repository, no run,
484
+ * a run still in flight — is a report, not a thrown error.
485
+ *
486
+ * @param {object} input
487
+ * @param {string} input.cwd Directory of the repository checkout to read.
488
+ * @param {string} [input.base] Base branch; the repository default when omitted.
489
+ * @param {string} [input.commit] Merge commit; the base branch tip when omitted.
490
+ * @param {number} [input.waitSeconds]
491
+ * @param {boolean} [input.fetch] Fetch the base branch before resolving it. Default true.
492
+ * @param {CheckDeps} [input.deps]
493
+ * @returns {Promise<CheckReport>}
494
+ */
495
+ export async function checkPostMerge({
496
+ cwd,
497
+ base,
498
+ commit,
499
+ waitSeconds = DEFAULT_WAIT_SECONDS,
500
+ fetch = true,
501
+ deps = {},
502
+ }) {
503
+ const gh = deps.gh ?? runGh;
504
+ const bd = deps.bd ?? runBd;
505
+ const git = deps.git ?? gitRunner(cwd);
506
+
507
+ const repository = await readRepository(gh, base);
508
+ if (repository === null) {
509
+ return buildReport({
510
+ outcome: 'inconclusive',
511
+ reason: 'no-github-repository',
512
+ rest: { base: base ?? DEFAULT_BASE_BRANCH },
513
+ });
514
+ }
515
+
516
+ const resolved = await resolveCommit(git, { commit, base: repository.base, fetch });
517
+ if (resolved === null) {
518
+ return buildReport({
519
+ outcome: 'inconclusive',
520
+ reason: 'unresolved-commit',
521
+ rest: { base: repository.base, repository: repository.nameWithOwner },
522
+ });
523
+ }
524
+
525
+ const sha = resolved.sha;
526
+ const baseReport = { base: repository.base, repository: repository.nameWithOwner, commit: sha };
527
+
528
+ let settled;
529
+ try {
530
+ settled = await awaitRuns({ gh, sha, base: repository.base, waitSeconds });
531
+ } catch (error) {
532
+ return buildReport({
533
+ outcome: 'inconclusive',
534
+ reason: 'gh-failed',
535
+ rest: { ...baseReport, escalation: { filed: false, detail: error instanceof Error ? error.message : String(error) } },
536
+ });
537
+ }
538
+
539
+ const { runs, ignoredBranches } = settled;
540
+
541
+ if (settled.reason !== 'settled') {
542
+ return buildReport({
543
+ outcome: 'inconclusive',
544
+ reason: settled.reason,
545
+ rest: { ...baseReport, runs, ignoredBranches },
546
+ });
547
+ }
548
+
549
+ const classification = classifyRuns(runs);
550
+ const runsReport = { ...baseReport, runs, ignoredBranches };
551
+
552
+ if (classification.outcome !== 'failed') {
553
+ return buildReport({
554
+ outcome: classification.outcome,
555
+ reason: classification.reason,
556
+ rest: runsReport,
557
+ });
558
+ }
559
+
560
+ // One escalation per failing run, keyed on the first failing run's id. The check
561
+ // reports every failing run, and files for the first so a repeated run converges.
562
+ const failingRun = classification.failingRuns[0];
563
+ const jobs = await readFailingJobs(gh, failingRun).catch(() => []);
564
+ const escalation = await fileEscalation({ bd, run: failingRun, jobs, commit: sha, base: repository.base });
565
+
566
+ return buildReport({
567
+ outcome: 'failed',
568
+ reason: classification.reason,
569
+ rest: { ...runsReport, failingJobs: jobs, escalation },
570
+ });
571
+ }
572
+
573
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
574
+
575
+ const USAGE = `Usage: node post-merge-check.mjs [--base <branch>] [--commit <sha>] [--wait <seconds>] [--no-fetch]
576
+
577
+ Checks the CI runs for the base branch's merge commit and prints a JSON report.
578
+
579
+ Exit codes: 0 passed, 1 failed (one P1 escalation bead is filed), 2 inconclusive.
580
+
581
+ --base <branch> base branch; the repository's default branch when omitted
582
+ --commit <sha> commit to check; the base branch tip when omitted
583
+ --wait <seconds> how long to wait for a run to appear and settle (default ${DEFAULT_WAIT_SECONDS});
584
+ --wait 0 checks once
585
+ --no-fetch read the local base branch ref instead of fetching it first
586
+ `;
587
+
588
+ /**
589
+ * @typedef {object} CliOptions
590
+ * @property {string} [base]
591
+ * @property {string} [commit]
592
+ * @property {number} [waitSeconds]
593
+ * @property {boolean} fetch
594
+ */
595
+
596
+ /**
597
+ * @param {string[]} argv
598
+ * @returns {CliOptions}
599
+ */
600
+ function parseArgs(argv) {
601
+ /** @type {CliOptions} */
602
+ const options = { fetch: true };
603
+
604
+ for (let i = 0; i < argv.length; i++) {
605
+ const arg = argv[i];
606
+ if (arg === '--base' && argv[i + 1] !== undefined) { options.base = argv[++i]; }
607
+ else if (arg === '--commit' && argv[i + 1] !== undefined) { options.commit = argv[++i]; }
608
+ else if (arg === '--wait' && argv[i + 1] !== undefined) { options.waitSeconds = Number(argv[++i]); }
609
+ else if (arg === '--no-fetch') { options.fetch = false; }
610
+ else { throw new UsageError(`unexpected argument: ${arg}`); }
611
+ }
612
+
613
+ if (options.waitSeconds !== undefined && !Number.isFinite(options.waitSeconds)) {
614
+ throw new UsageError('--wait needs a number of seconds');
615
+ }
616
+ return options;
617
+ }
618
+
619
+ /**
620
+ * @param {object} options
621
+ * @param {string[]} options.argv CLI arguments (without node/script).
622
+ * @param {string} [options.cwd] Repository checkout to read.
623
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
624
+ * @param {CheckDeps} [options.deps] Collaborators; injectable for tests.
625
+ * @returns {Promise<number>} process exit code.
626
+ */
627
+ export async function runCli({ argv, cwd = process.cwd(), write = (chunk) => process.stdout.write(chunk), deps = {} }) {
628
+ if (argv.includes('--help') || argv.includes('-h')) {
629
+ write(USAGE);
630
+ return 0;
631
+ }
632
+
633
+ const { base, commit, waitSeconds, fetch } = parseArgs(argv);
634
+ const report = await checkPostMerge({ cwd, base, commit, waitSeconds, fetch, deps });
635
+ write(`${JSON.stringify(report, null, 2)}\n`);
636
+ return report.exitCode;
637
+ }
638
+
639
+ // Only run the CLI when invoked directly, so tests can import the module.
640
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
641
+ runCli({ argv: process.argv.slice(2) })
642
+ .then((code) => {
643
+ process.exitCode = code;
644
+ })
645
+ .catch((error) => {
646
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
647
+ // A check that never reached a verdict is inconclusive, never a pass and never a
648
+ // failure a human must be paged for.
649
+ process.exitCode = EXIT_CODES.inconclusive;
650
+ });
651
+ }