omp-conductor 0.19.6 → 0.20.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 (71) hide show
  1. package/REFERENCE.md +27 -2
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7832
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
package/src/to-spec.ts CHANGED
@@ -34,6 +34,28 @@
34
34
  * returned before persisting, because the harness is permissive by design
35
35
  * and the store vets only what this module hands it.
36
36
  *
37
+ * Who stamps the observation time (#1000): the dispatcher, never the agent.
38
+ * A to-spec groomer runs with reading tools only and therefore has no clock,
39
+ * so the `source.freshAt` it used to be asked for was always a guess — and a
40
+ * guess landing ahead of the daemon's clock discarded the whole verdict as a
41
+ * clock lie, twice in one afternoon on verdicts that were otherwise correct.
42
+ * The schema still accepts the key (it is `.strict()`, so refusing an older
43
+ * prompt's output would recreate exactly that discard) but ignores its value:
44
+ * `parseToSpecResult` stamps the batch's launch time — or the settle time
45
+ * when no launch time is known — and that stamp is what persists.
46
+ *
47
+ * Sizing is evidence, not a feeling (#1041): a `PROMOTABLE` verdict carries
48
+ * `sizingEvidence`, and a `NEEDS DECOMPOSITION` verdict carries an ordered
49
+ * `decomposition`. #1035 was persisted `PROMOTABLE` with nine acceptance
50
+ * criteria across four lifecycle files totalling 28,812 lines; attempt 1
51
+ * exhausted 181 of its 180 turns. The verdict had asserted one-budget fit —
52
+ * what was missing was anything that could falsify the assertion. So the
53
+ * contract now asks for the falsifier itself (distinct behaviours, the
54
+ * production modules and test surfaces each needs, shared-file sequencing)
55
+ * and there is deliberately no numeric threshold anywhere: a line or
56
+ * criterion count that decided verdicts would be gamed by splitting prose,
57
+ * and the judgement is the groomer's.
58
+ *
37
59
  * One contract, every surface (#883): this Zod schema is the single source of
38
60
  * truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
39
61
  * `parseToSpecResult` persists against it, and the drift test pins the
@@ -65,6 +87,11 @@ export type ToSpecVerdict = (typeof TO_SPEC_VERDICTS)[number];
65
87
  * never read as promotable/considered. 24 hours: a groomed candidate must
66
88
  * have looked at the code within a day, and a re-grooming pass a day later
67
89
  * re-reads the live source anyway.
90
+ *
91
+ * Since #1000 the observation time is the dispatcher's own stamp, so this
92
+ * ceiling can only fire for a batch that genuinely ran longer than a day —
93
+ * a real signal about the batch rather than a verdict on the agent's
94
+ * arithmetic.
68
95
  */
69
96
  export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
70
97
 
@@ -78,10 +105,47 @@ const ToSpecSourceSchema = z
78
105
  freshAt: z
79
106
  .number()
80
107
  .int()
81
- .describe("Epoch milliseconds when the source was observed — required so staleness is judgeable without trusting prose."),
108
+ .optional()
109
+ .describe(
110
+ "Ignored — do not send it. Conductor stamps the observation time itself from the batch window (#1000); a groomer has reading tools only and no clock, so any value here is a guess. The key is still accepted so an older prompt's output is never refused for sending one.",
111
+ ),
112
+ })
113
+ .strict()
114
+ .describe("The authoritative source and ref every verdict must stand on; conductor stamps its freshness (#1000).");
115
+
116
+ /**
117
+ * One ordered child of a decomposition proposal (#1041). A `NEEDS
118
+ * DECOMPOSITION` verdict used to be free to say "split this up" in prose,
119
+ * which is a verdict nobody can act on: the operator still has to do the
120
+ * decomposition. Each child therefore carries exactly what filing it as an
121
+ * issue needs — a title, the exact write lane it owns, what it waits on, the
122
+ * silent fake it invites, and the commands that prove it — so the proposal is
123
+ * a filing instruction rather than an opinion.
124
+ */
125
+ const ToSpecDecompositionChildSchema = z
126
+ .object({
127
+ title: z.string().trim().min(1).describe("The child issue's title — one slice, stated as the behaviour it lands."),
128
+ writeLane: z
129
+ .array(z.string().trim().min(1))
130
+ .min(1)
131
+ .describe("The files/dirs this child alone writes — its `## Exact write lane`."),
132
+ dependsOn: z
133
+ .array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
134
+ .describe(
135
+ "What this child waits on: an earlier child's title, or an existing issue number. Children sharing a file in their write lanes must be serialised here — two concurrent slices writing one core module is the collision the file lane exists to prevent. `[]` for the first child.",
136
+ ),
137
+ likelySilentFake: z
138
+ .string()
139
+ .trim()
140
+ .min(1)
141
+ .describe("The one thing most likely to be silently faked in this child, and how to prove it is not."),
142
+ proofCommands: z
143
+ .array(z.string().trim().min(1))
144
+ .min(1)
145
+ .describe("The focused commands that prove this child, each with its cwd when it matters."),
82
146
  })
83
147
  .strict()
84
- .describe("The authoritative source/ref/freshness every verdict must stand on.");
148
+ .describe("One ordered child slice a decomposition proposal names (#1041).");
85
149
 
86
150
  /**
87
151
  * The one strict contract a grooming result must satisfy before it may
@@ -143,6 +207,21 @@ const ToSpecResultSchema = z
143
207
  .describe(
144
208
  'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
145
209
  ),
210
+ sizingEvidence: z
211
+ .string()
212
+ .trim()
213
+ .min(1)
214
+ .optional()
215
+ .describe(
216
+ "Required for PROMOTABLE: the source-backed one-budget analysis — the distinct behaviours/state transitions this slice introduces, the production modules and focused test surfaces each of them needs, the shared-file sequencing between them, and why that fits one configured worker attempt. A small file count is not this evidence when the files are high-fanout lifecycle modules with large integration suites (#1041).",
217
+ ),
218
+ decomposition: z
219
+ .array(ToSpecDecompositionChildSchema)
220
+ .min(2)
221
+ .optional()
222
+ .describe(
223
+ "Required for NEEDS DECOMPOSITION and accepted on no other verdict: the ordered children this candidate splits into, first to last, each naming its title, write lane, dependencies, silent fake and proof commands. Children sharing a write-lane path must serialise through `dependsOn` (#1041).",
224
+ ),
146
225
  proposedBrief: z
147
226
  .string()
148
227
  .trim()
@@ -166,6 +245,15 @@ const ToSpecResultSchema = z
166
245
  if (value.reasonNotToPromote !== undefined) {
167
246
  ctx.addIssue({ code: "custom", message: "PROMOTABLE must not carry reasonNotToPromote" });
168
247
  }
248
+ // #1041: the one-budget claim is only a claim until something could
249
+ // falsify it. #1035 asserted it and burned 181 of 180 turns.
250
+ if (value.sizingEvidence === undefined) {
251
+ ctx.addIssue({
252
+ code: "custom",
253
+ message:
254
+ "PROMOTABLE requires sizingEvidence: the distinct behaviours, their production modules and test surfaces, the shared-file sequencing, and why that fits one worker attempt",
255
+ });
256
+ }
169
257
  } else {
170
258
  if (value.reasonNotToPromote === undefined) {
171
259
  ctx.addIssue({ code: "custom", message: `${verdict} requires reasonNotToPromote` });
@@ -180,6 +268,40 @@ const ToSpecResultSchema = z
180
268
  message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
181
269
  });
182
270
  }
271
+ // A decomposition proposal is a filing instruction or it is nothing
272
+ // (#1041): NEEDS DECOMPOSITION owes ordered children, and every other
273
+ // verdict owes none — children beside PROMOTABLE would mean the verdict
274
+ // contradicts itself.
275
+ if (verdict === "NEEDS DECOMPOSITION") {
276
+ if (value.decomposition === undefined) {
277
+ ctx.addIssue({
278
+ code: "custom",
279
+ message:
280
+ "NEEDS DECOMPOSITION requires decomposition: the ordered children, each with its title, write lane, dependsOn, likelySilentFake and proofCommands",
281
+ });
282
+ }
283
+ } else if (value.decomposition !== undefined) {
284
+ ctx.addIssue({ code: "custom", message: `decomposition is only valid with verdict "NEEDS DECOMPOSITION", not ${verdict}` });
285
+ }
286
+ // Two children writing one core module concurrently is the collision the
287
+ // file lane exists to prevent, so the later of any file-sharing pair must
288
+ // name what it waits on. Order is the array's order — child n may only
289
+ // depend on something before it.
290
+ const children = value.decomposition ?? [];
291
+ for (let i = 1; i < children.length; i++) {
292
+ const child = children[i]!;
293
+ if (child.dependsOn.length > 0) continue;
294
+ const own = new Set(child.writeLane.map((path) => path.trim()));
295
+ for (let j = 0; j < i; j++) {
296
+ const shared = children[j]!.writeLane.map((path) => path.trim()).find((path) => own.has(path));
297
+ if (shared === undefined) continue;
298
+ ctx.addIssue({
299
+ code: "custom",
300
+ message: `decomposition child ${i + 1} shares ${shared} with child ${j + 1} and must serialise on it through dependsOn`,
301
+ });
302
+ break;
303
+ }
304
+ }
183
305
  if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
184
306
  ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
185
307
  }
@@ -193,7 +315,16 @@ const ToSpecResultSchema = z
193
315
  * output with (`outputSchema` + `schemaMode: "strict"`, #772). */
194
316
  export const TO_SPEC_SCHEMA: object = z.toJSONSchema(ToSpecResultSchema) as object;
195
317
 
196
- export type ToSpecResult = z.infer<typeof ToSpecResultSchema>;
318
+ /**
319
+ * The validated result every reader gets. `source.freshAt` is a required
320
+ * number here even though the schema no longer asks the agent for one:
321
+ * {@link parseToSpecResult} stamps the dispatcher's own observation time onto
322
+ * every result it returns (#1000), so a persisted verdict always carries a
323
+ * freshness witness — just never the agent's guess.
324
+ */
325
+ export type ToSpecResult = Omit<z.infer<typeof ToSpecResultSchema>, "source"> & {
326
+ source: { name: string; ref: string; freshAt: number };
327
+ };
197
328
 
198
329
  /** Why a result could not be trusted; each persists as a blocked record. */
199
330
  export type ToSpecFailure =
@@ -207,6 +338,14 @@ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: fals
207
338
  export interface ToSpecEvidence {
208
339
  kind: "to-spec";
209
340
  result: ToSpecResult;
341
+ /**
342
+ * What the ready gate found missing when a valid PROMOTABLE verdict was
343
+ * refused mechanical promotion (#1041). A sibling of `result`, never a
344
+ * replacement for it: the verdict stays recoverable, so the candidate reads
345
+ * as groomed-but-rejected rather than ungroomed, and the tick can name what
346
+ * an operator must fix. Absent on every promoted or unjudged row.
347
+ */
348
+ readyGate?: { missing: string[]; checkedAt: number };
210
349
  }
211
350
 
212
351
  /** The row's `evidence` when a result was refused. */
@@ -274,15 +413,27 @@ function extractJson(input: string): string {
274
413
 
275
414
  /**
276
415
  * Parse and validate a groomer's raw output against the to-spec contract.
277
- * `now` is explicit so staleness and determinism are testable: the same
278
- * input at the same observation time always yields the same outcome. On
279
- * failure the outcome says *why* in one of the three refusal classes:
280
- * unparseable/schema-breaking output is `malformed`, output that never names
281
- * an authoritative source (or lacks name/ref/freshAt) is `missing-source`,
282
- * and a source observed more than `TO_SPEC_MAX_SOURCE_AGE_MS` ago is
283
- * `stale-source`. A `freshAt` in the future is a clock lie, hence malformed.
416
+ * `now` is the settle time, explicit so staleness and determinism are
417
+ * testable: the same input over the same batch window always yields the same
418
+ * outcome. On failure the outcome says *why* in one of the three refusal
419
+ * classes: unparseable/schema-breaking output is `malformed`, output that
420
+ * never names an authoritative source (or lacks name/ref) is
421
+ * `missing-source`, and a batch whose window is wider than
422
+ * `TO_SPEC_MAX_SOURCE_AGE_MS` is `stale-source`.
423
+ *
424
+ * The observation time is the dispatcher's, never the agent's (#1000).
425
+ * `opts.launchedAt` is when the batch was launched — the conservative (older)
426
+ * end of its wall-clock window, so staleness is never understated — and
427
+ * `now` stands in when no launch time is known. Whatever the agent put in
428
+ * `source.freshAt` is overwritten by that stamp before the result is
429
+ * returned, so the persisted evidence carries the dispatcher's own witness
430
+ * and a guessed timestamp can no longer discard a correct verdict.
284
431
  */
285
- export function parseToSpecResult(input: string, now: number): ParseToSpecOutcome {
432
+ export function parseToSpecResult(
433
+ input: string,
434
+ now: number,
435
+ opts?: { launchedAt?: number },
436
+ ): ParseToSpecOutcome {
286
437
  const body = extractJson(input);
287
438
  if (body.length === 0) {
288
439
  return { ok: false, failure: { kind: "malformed", detail: "empty answer — no JSON found" } };
@@ -300,25 +451,31 @@ export function parseToSpecResult(input: string, now: number): ParseToSpecOutcom
300
451
  if (!parsed.success) {
301
452
  const detail = parsed.error.issues[0]?.message ?? "schema violation";
302
453
  const source = raw.source;
303
- if (!isObject(source) || source.name === undefined || source.ref === undefined || source.freshAt === undefined) {
454
+ // `freshAt` is no longer part of this test: the dispatcher stamps it, so
455
+ // a source is complete when it names what was read and at which ref.
456
+ if (!isObject(source) || source.name === undefined || source.ref === undefined) {
304
457
  return { ok: false, failure: { kind: "missing-source", detail: `no authoritative source — ${detail}` } };
305
458
  }
306
459
  return { ok: false, failure: { kind: "malformed", detail } };
307
460
  }
308
- const freshAt = parsed.data.source.freshAt;
309
- if (freshAt > now) {
310
- return { ok: false, failure: { kind: "malformed", detail: `source freshAt ${freshAt} lies in the future of ${now}` } };
311
- }
312
- if (now - freshAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
461
+ // The launch bound when the dispatcher knows it and it precedes the settle
462
+ // time a launch stamped after `now` is clock skew, not evidence, so the
463
+ // settle time stands in rather than producing a negative age (#1000).
464
+ const launchedAt = opts?.launchedAt;
465
+ const observedAt =
466
+ launchedAt !== undefined && Number.isFinite(launchedAt) && launchedAt <= now ? launchedAt : now;
467
+ if (now - observedAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
313
468
  return {
314
469
  ok: false,
315
470
  failure: {
316
471
  kind: "stale-source",
317
- detail: `source observed at ${freshAt} is ${now - freshAt}ms old — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
472
+ detail:
473
+ `the batch that read the source launched at ${observedAt}, ${now - observedAt}ms before this item ` +
474
+ `settled — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
318
475
  },
319
476
  };
320
477
  }
321
- return { ok: true, result: parsed.data };
478
+ return { ok: true, result: { ...parsed.data, source: { ...parsed.data.source, freshAt: observedAt } } };
322
479
  }
323
480
 
324
481
  /** What one grooming pass did to the row, so the caller can report it. */
@@ -335,6 +492,10 @@ export interface ToSpecGroomingRequest {
335
492
  /** Observation time for staleness and the recorded row; `Date.now()` when
336
493
  * omitted (tests pass it to keep reprocessing deterministic). */
337
494
  now?: number;
495
+ /** When the batch that produced this output was launched, on the
496
+ * dispatcher's own clock (#1000). It becomes the source observation time in
497
+ * preference to `now`; omit it and the settle time stands in. */
498
+ launchedAt?: number;
338
499
  }
339
500
 
340
501
  function failureEvidence(failure: ToSpecFailure, input: string): string {
@@ -354,10 +515,14 @@ function failureEvidence(failure: ToSpecFailure, input: string): string {
354
515
  * `evidence` (`to-spec-failure`) — never as promotable/considered — except
355
516
  * when a prior valid result exists: malformed reprocessing then keeps that
356
517
  * prior row instead of erasing it. Nothing here touches labels or issues.
518
+ *
519
+ * `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
520
+ * it, not anything the agent wrote, becomes the recorded source observation
521
+ * time.
357
522
  */
358
523
  export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
359
524
  const now = request.now ?? Date.now();
360
- const parsed = parseToSpecResult(request.input, now);
525
+ const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
361
526
  if (parsed.ok) {
362
527
  const evidence: ToSpecEvidence = { kind: "to-spec", result: parsed.result };
363
528
  store.upsertGrooming({
@@ -404,7 +569,15 @@ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined
404
569
  }
405
570
  if (!isObject(raw) || raw.kind !== "to-spec") return undefined;
406
571
  const parsed = ToSpecResultSchema.safeParse(raw.result);
407
- return parsed.success ? parsed.data : undefined;
572
+ if (!parsed.success) return undefined;
573
+ const freshAt = parsed.data.source.freshAt;
574
+ // A row with no stamped observation time cannot have its staleness judged,
575
+ // and freshness is precisely the field nobody may forge (#1000) — so the row
576
+ // reads as not-durably-groomed and becomes re-groomable, rather than
577
+ // carrying a fabricated timestamp that would read fresh forever. Every row
578
+ // `recordToSpecGrooming` writes is stamped; this is the hand-edited case.
579
+ if (freshAt === undefined) return undefined;
580
+ return { ...parsed.data, source: { ...parsed.data.source, freshAt } };
408
581
  }
409
582
 
410
583
  /**
@@ -486,6 +486,40 @@ export function prVerificationFrom(
486
486
  return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
487
487
  }
488
488
 
489
+ /**
490
+ * Whether one raw `gh pr view --json ...statusCheckRollup` payload collected
491
+ * zero rollup contexts (#999).
492
+ *
493
+ * {@link prVerificationFrom} maps zero collected checks to `pending` — "GitHub
494
+ * has not reported any checks yet" — which is the right refusal when it is
495
+ * true, and a silent stall when it is not. It is not always true: a head whose
496
+ * PR was opened by `github-actions[bot]` carries a second, empty
497
+ * `action_required` check suite beside the completed one, and the rollup read
498
+ * can resolve that empty suite and collect nothing while 18 check-runs
499
+ * (one of them failed) sit on the same SHA. So the caller uses this to treat
500
+ * an empty rollup as *unresolvable* and re-prove from the REST plane, which
501
+ * flattens every page of both `check_runs` and `statuses` for the exact head
502
+ * and sees all of them.
503
+ *
504
+ * A missing rollup counts as zero collected, exactly as
505
+ * {@link prVerificationFrom}'s `?? []` does. A body this predicate cannot read
506
+ * — unparseable, not a JSON object (a bare list is not a `pr view` payload),
507
+ * or a rollup that is not a list — is not its problem: it answers false so the
508
+ * existing verdict stands unchanged.
509
+ */
510
+ export function rollupCollectedNoChecks(raw: string): boolean {
511
+ let parsed: unknown;
512
+ try {
513
+ parsed = JSON.parse(raw) as unknown;
514
+ } catch {
515
+ return false;
516
+ }
517
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
518
+ const rollup = (parsed as GhPrVerification).statusCheckRollup;
519
+ if (rollup === undefined || rollup === null) return true;
520
+ return Array.isArray(rollup) && rollup.length === 0;
521
+ }
522
+
489
523
  function failedCheck(raw: string): GhCheck | undefined {
490
524
  const checks = (JSON.parse(raw) as GhPrVerification).statusCheckRollup ?? [];
491
525
  return checks.find((check) => checkVerdict(check) === "failed");
@@ -2024,6 +2058,22 @@ export function makeTracker(
2024
2058
  "state,isDraft,headRefOid,statusCheckRollup",
2025
2059
  ]);
2026
2060
  const verification = prVerificationFrom(raw, expectedHead, opts);
2061
+ if (verification.status === "pending" && rollupCollectedNoChecks(raw)) {
2062
+ // An empty rollup is not proof that the head has no checks (#999):
2063
+ // a PR opened by `github-actions[bot]` carries a second, empty
2064
+ // `action_required` suite beside the completed one, the rollup read
2065
+ // resolves that suite, collects nothing, and this verdict reads
2066
+ // "GitHub has not reported any checks yet" over a head with 18
2067
+ // check-runs and a failure among them. A commit that carries an
2068
+ // empty suite beside a completed one is not a commit with no
2069
+ // checks, so zero collected contexts is unresolvable rather than
2070
+ // decisive: re-prove from REST, which flattens every page of both
2071
+ // `check_runs` and `statuses` for the exact head. REST that cannot
2072
+ // answer leaves this pending exactly as it stands — fail-closed,
2073
+ // never green by omission.
2074
+ const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
2075
+ return rest ?? verification;
2076
+ }
2027
2077
  if (verification.status !== "failed") return verification;
2028
2078
 
2029
2079
  const detailsUrl = failedCheck(raw)?.detailsUrl;