@feigi/fleet-ctl 3.95.3 → 3.96.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.
@@ -240,7 +240,7 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`, with `pr` and `head` `null`: the
244
244
  pre-code outcome, before you touch code. A block or ambiguity you find after
245
245
  implementation began — the work cannot finish without a ruling or human hands
246
246
  you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
@@ -240,7 +240,7 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`, with `pr` and `head` `null`: the
244
244
  pre-code outcome, before you touch code. A block or ambiguity you find after
245
245
  implementation began — the work cannot finish without a ruling or human hands
246
246
  you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
@@ -240,7 +240,7 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`, with `pr` and `head` `null`: the
244
244
  pre-code outcome, before you touch code. A block or ambiguity you find after
245
245
  implementation began — the work cannot finish without a ruling or human hands
246
246
  you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
@@ -240,7 +240,7 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`, with `pr` and `head` `null`: the
244
244
  pre-code outcome, before you touch code. A block or ambiguity you find after
245
245
  implementation began — the work cannot finish without a ruling or human hands
246
246
  you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
@@ -240,7 +240,7 @@ including if the `Sizing:` line was written before the run and corrected after;
240
240
  `sizing.verdict` and `sizing.signal` are that line's verdict and signal.
241
241
  Nothing in the PR body separates a measured verdict from one typed in early;
242
242
  that field is the only thing that does, and an unreported ordering costs the
243
- covariate. A bail is `outcome` `bailed`, its cause in `bailCause`: the
243
+ covariate. A bail is `outcome` `bailed`, its cause in `bailCause`, with `pr` and `head` `null`: the
244
244
  pre-code outcome, before you touch code. A block or ambiguity you find after
245
245
  implementation began — the work cannot finish without a ruling or human hands
246
246
  you do not have — is `outcome` `blocked` instead, with `pr`, `head` and `bailCause` `null` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.95.3",
3
+ "version": "3.96.0",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -2507,7 +2507,9 @@ function recordFiled(id, subject) {
2507
2507
  // reads OPEN and its closingIssuesReferences in its
2508
2508
  // own repository name N;
2509
2509
  // the Check-step record (below) holds for <head>
2510
- // impl-N=bailed bailCause is not null; the receipt names it
2510
+ // impl-N=bailed bailCause is not null; the receipt names it.
2511
+ // report-schema.mjs already refuses a bail that
2512
+ // names a pr or a head
2511
2513
  // impl-N=blocked nothing to check: report-schema.mjs already
2512
2514
  // refuses a block that names a bailCause, a pr or
2513
2515
  // a head, or gives no reason in bailDetail
@@ -1,7 +1,9 @@
1
- // Scraper for per-member model/effort facts. Pure over omp's own subagent
2
- // transcripts: no clock, no network, no gh. The one column that looks beside
3
- // them is `report`, which asks whether the member's report file is in the
4
- // scratch root (`--scratch <dir>`, default the root scratch-root.mjs prints).
1
+ // Scraper for per-member model/effort facts. Pure over omp's own transcripts:
2
+ // no clock, no network, no gh. Two columns look beside the subagent ones:
3
+ // `work_s` also reads the controller's own `<session>.jsonl` next to the
4
+ // session directory, and `report` asks whether the member's report file is in
5
+ // the scratch root (`--scratch <dir>`, default the root scratch-root.mjs
6
+ // prints).
5
7
  //
6
8
  // The transcript parsing (fold, model/effort extraction, ticket/pr naming,
7
9
  // and the `gh pr create` result an implementer's `pr` comes from)
@@ -10,7 +12,7 @@
10
12
  // them from this file needs to change.
11
13
 
12
14
  import { readdirSync, readFileSync, statSync } from "node:fs";
13
- import { join, basename } from "node:path";
15
+ import { join, basename, dirname } from "node:path";
14
16
 
15
17
  import { readOmpSession, normalizeModel, parseMemberName, isOmpSessionDirName } from "./member-record.mjs";
16
18
  import { stripLevel } from "./tier-roles.mjs";
@@ -75,7 +77,13 @@ export function rowsForSession(sessionDir, stats = {}, { verifyMain = verifyMain
75
77
  for (const f of jsonlNames) {
76
78
  try { newest = Math.max(newest, statSync(join(sessionDir, f)).mtimeMs); } catch { /* raced away */ }
77
79
  }
78
- const recs = readOmpSession(sessionDir); // may throw — a wrong-root refusal, not a per-file fault
80
+ const activity = { stamps: [], unread: 0 };
81
+ const recs = readOmpSession(sessionDir, activity); // may throw — a wrong-root refusal, not a per-file fault
82
+ const controller = controllerStamps(sessionDir);
83
+ const workUnknown = typeof controller === "string" ? controller
84
+ : activity.unread ? `${activity.unread} unreadable transcript${activity.unread === 1 ? "" : "s"}` : null;
85
+ const halts = workUnknown ? [] : haltsOf(activity.stamps.concat(controller));
86
+ stats.workSUnknown = workUnknown;
79
87
  stats.seen = jsonlNames.length;
80
88
  stats.dropped = jsonlNames.length - recs.length;
81
89
  stats.torn = 0;
@@ -93,9 +101,78 @@ export function rowsForSession(sessionDir, stats = {}, { verifyMain = verifyMain
93
101
  tokensCacheWrite1h: r.tokens_cache_write_1h ?? "", cost: r.cost == null ? "" : Number(r.cost.toFixed(6)),
94
102
  ending: endings[i],
95
103
  dispatchModel: dispatchModel(r.resolvedModelIdentity),
104
+ workS: workUnknown ? null : r.transcript.timed ? workSeconds(r.transcript.workParts, halts) : "",
96
105
  }));
97
106
  }
98
107
 
108
+ // The `work_s` column: a member's working time in whole seconds. Its parts
109
+ // are the fold's (member-record.mjs): each run of entries between two
110
+ // `session_exit` entries, from its first entry to its last, so omp's idle
111
+ // wait before each exit and the wait until a wake are in no part. Every halt
112
+ // is then subtracted where it falls inside a part. `wall_s` stays the raw
113
+ // span, teardown included.
114
+ //
115
+ // A halt is a stretch over HALT_MS in which no transcript of the run records
116
+ // an entry: every transcript in the session directory, nested ones and those
117
+ // that book no row included, and the controller's own `<session>.jsonl` beside
118
+ // the directory, the one that keeps writing while no member runs. Halts are
119
+ // the gaps between consecutive instants of that one sorted list, so no two
120
+ // overlap, and a part never shares time with another part of the same member:
121
+ // no overlap is subtracted twice.
122
+ //
123
+ // Without the controller transcript no halt can be told from a member's own
124
+ // quiet stretch, and with any transcript of the run unreadable, or a line of
125
+ // the controller's lost, entries are missing from the instants, so a stretch
126
+ // they wrote through reads as a halt. Each way every row's `work_s` is `null`:
127
+ // unknown, written blank, and never written over a recorded value
128
+ // (mergeRows); `stats.workSUnknown` names the cause. A transcript with no
129
+ // timestamped entry reads `""`, blank because there is nothing to time.
130
+ export const HALT_MS = 60 * 60 * 1000;
131
+
132
+ // The controller transcript's entry instants, in epoch ms, or a string naming
133
+ // why they cannot be had. It is not a member transcript, so a line is read for
134
+ // its timestamp alone. An unparseable LAST line is a write in progress and is
135
+ // skipped, as the member fold skips it; an unparseable line before it is lost
136
+ // data that may have been the entry breaking a stretch, so a halt there could
137
+ // not be told from the run writing, and the instants are unknown.
138
+ function controllerStamps(sessionDir) {
139
+ let text;
140
+ try { text = readFileSync(join(dirname(sessionDir), `${basename(sessionDir)}.jsonl`), "utf8"); }
141
+ catch (e) { return e?.code === "ENOENT" ? "no controller transcript" : `unreadable controller transcript: ${e?.code ?? e}`; }
142
+ const stamps = [];
143
+ const lines = text.split("\n");
144
+ for (let i = 0; i < lines.length; i++) {
145
+ if (!lines[i].trim()) continue;
146
+ let d;
147
+ try { d = JSON.parse(lines[i]); }
148
+ catch { if (i !== lines.length - 1) return "an unparseable controller transcript line"; continue; }
149
+ const ms = typeof d?.timestamp === "string" ? Date.parse(d.timestamp) : NaN;
150
+ if (Number.isFinite(ms)) stamps.push(ms);
151
+ }
152
+ return stamps;
153
+ }
154
+
155
+ // Every gap over HALT_MS between consecutive instants, as [from, to].
156
+ export function haltsOf(stamps) {
157
+ const sorted = [...stamps].sort((a, b) => a - b);
158
+ const halts = [];
159
+ for (let i = 1; i < sorted.length; i++) {
160
+ if (sorted[i] - sorted[i - 1] > HALT_MS) halts.push([sorted[i - 1], sorted[i]]);
161
+ }
162
+ return halts;
163
+ }
164
+
165
+ // The parts' summed length less each halt's share inside them, in whole seconds.
166
+ export function workSeconds(parts, halts) {
167
+ let ms = 0;
168
+ for (const [from, to] of parts) {
169
+ if (!(to > from)) continue;
170
+ ms += to - from;
171
+ for (const [h0, h1] of halts) ms -= Math.max(0, Math.min(to, h1) - Math.max(from, h0));
172
+ }
173
+ return Math.round(ms / 1000);
174
+ }
175
+
99
176
  // The `dispatch_model` column: the model fixed at dispatch, `session_init`'s
100
177
  // own model (the record's `resolvedModelIdentity`), with its provider prefix
101
178
  // and `:<level>` suffix stripped. `model` is the member's last turn, which a
@@ -120,7 +197,7 @@ export const COLUMNS = [
120
197
  "session", "run_date", "role", "member", "model", "effort", "ticket", "pr",
121
198
  "tokens_cache_create", "tokens_out", "wall_s", "turns", "agent", "harness",
122
199
  "subagent_type", "report", "tokens_in", "tokens_cache_read", "tokens_cache_write_1h", "cost",
123
- "ending", "dispatch_model",
200
+ "ending", "dispatch_model", "work_s",
124
201
  ];
125
202
 
126
203
  // `report`: what reportStatus() says of the row's member under `scratch` — a
@@ -235,7 +312,7 @@ function verifyMainWithGit(sha) {
235
312
  const FIELD = {
236
313
  tokens_cache_create: "tokensCacheCreate", tokens_out: "tokensOut", wall_s: "wallS",
237
314
  subagent_type: "subagentType", tokens_in: "tokensIn", tokens_cache_read: "tokensCacheRead",
238
- tokens_cache_write_1h: "tokensCacheWrite1h", dispatch_model: "dispatchModel",
315
+ tokens_cache_write_1h: "tokensCacheWrite1h", dispatch_model: "dispatchModel", work_s: "workS",
239
316
  };
240
317
  const field = (c) => FIELD[c] ?? c;
241
318
  // Keyed on the transcript's path-relative stem, not the member name: `member`
@@ -249,20 +326,30 @@ const key = (r) => `${r.session}\0${r.agent}`;
249
326
  // regeneration both safe, and it is the reason no backfill-only code path is
250
327
  // needed: the idempotency backfill wants is the idempotency a re-run wants.
251
328
  //
252
- // One cell is the exception: an incoming `unknown` ending keeps the ending the
253
- // replaced row recorded. `unknown` is this scrape failing to say (a verifier
254
- // that is offline, a transcript gone unreadable), not a reading, so it never
329
+ // Two cells are the exception, each keeping what the replaced row recorded
330
+ // where this scrape could not say. An incoming `unknown` ending keeps the
331
+ // recorded ending: `unknown` is this scrape failing to say (a verifier that is
332
+ // offline, a transcript gone unreadable), not a reading, so it never
255
333
  // overwrites one: a recorded `bail:fixed` would otherwise flip to `unknown`
256
- // on any re-scrape run without network. `stats.endingsKept` counts them.
334
+ // on any re-scrape run without network. `stats.endingsKept` counts them. An
335
+ // incoming `null` work_s (halts that could not be found) keeps a
336
+ // recorded `work_s` the same way; `stats.workSKept` counts those.
257
337
  export function mergeRows(existing, incoming, stats = {}) {
258
338
  const by = new Map((existing ?? []).map((r) => [key(r), r]));
259
339
  stats.endingsKept = 0;
340
+ stats.workSKept = 0;
260
341
  for (const r of incoming ?? []) {
261
342
  const old = by.get(key(r));
343
+ let row = r;
262
344
  if (r.ending === "unknown" && old?.ending && old.ending !== "unknown") {
263
- by.set(key(r), { ...r, ending: old.ending });
345
+ row = { ...row, ending: old.ending };
264
346
  stats.endingsKept++;
265
- } else by.set(key(r), r);
347
+ }
348
+ if (r.workS === null && (old?.workS ?? "") !== "") {
349
+ row = { ...row, workS: old.workS };
350
+ stats.workSKept++;
351
+ }
352
+ by.set(key(r), row);
266
353
  }
267
354
  return [...by.values()].sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
268
355
  }
@@ -406,8 +493,12 @@ if (isCLI(import.meta.url)) {
406
493
  // a healthy one — 0-of-2 was indistinguishable from full success.
407
494
  const torn = stats.torn ? `, ${stats.torn} torn mid-write` : "";
408
495
  const kept = stats.endingsKept ? `, ${stats.endingsKept} read unknown and kept the ending already recorded` : "";
496
+ // Whatever left every row's `work_s` unknown, and how many rows kept the
497
+ // value already recorded, after the yield rather than inside it.
498
+ const workKept = stats.workSKept ? `, ${stats.workSKept} kept the work_s already recorded` : "";
499
+ const work = stats.workSUnknown ? `; work_s unknown (${stats.workSUnknown})${workKept}` : "";
409
500
  process.stderr.write(
410
501
  `${NAME}: scraped ${stats.seen - stats.dropped} of ${stats.seen} members ` +
411
- `(${stats.dropped} dropped${torn}${kept}); ${file} now holds ${merged.length} rows\n`,
502
+ `(${stats.dropped} dropped${torn}${kept}); ${file} now holds ${merged.length} rows${work}\n`,
412
503
  );
413
504
  }
@@ -358,6 +358,13 @@ function createdPrNumbers(text) {
358
358
  export function foldOmpTranscript(jsonlText, filePath) {
359
359
  let model = null, thinking = null, task = null, resolvedModelIdentity = null, agent = null;
360
360
  let firstTs = null, lastTs = null;
361
+ // For the `work_s` column (member-outcomes.mjs): every timestamped entry's
362
+ // instant, and the member's working parts — each run of timestamped entries
363
+ // between two `session_exit` entries, as [first, last] in epoch ms. The
364
+ // `session_exit` entry itself closes a part and never extends one, so the
365
+ // idle wait before it and the wait until the next entry are in no part.
366
+ const stamps = [], workParts = [];
367
+ let partFirst = null, partLast = null;
361
368
  let input = 0, cacheWrite = 0, cacheWrite1h = 0, cacheRead = 0, output = 0, cost = 0, turns = 0;
362
369
  let sawCost = false, unsplit = false, unpriced = false;
363
370
  let malformedNonLastLines = 0;
@@ -383,10 +390,16 @@ export function foldOmpTranscript(jsonlText, filePath) {
383
390
  try { d = JSON.parse(raw); }
384
391
  catch { if (i !== lines.length - 1) malformedNonLastLines++; continue; }
385
392
  assertOmpShaped(d, filePath);
386
- if (typeof d.timestamp === "string") { firstTs ??= d.timestamp; lastTs = d.timestamp; }
393
+ let ms = NaN;
394
+ if (typeof d.timestamp === "string") { firstTs ??= d.timestamp; lastTs = d.timestamp; ms = Date.parse(d.timestamp); }
395
+ if (Number.isFinite(ms)) stamps.push(ms);
396
+ if (d.type === "custom" && d.customType === "session_exit") {
397
+ sessionExit = true;
398
+ if (partFirst !== null) workParts.push([partFirst, partLast]);
399
+ partFirst = partLast = null;
400
+ } else if (Number.isFinite(ms)) { partFirst ??= ms; partLast = ms; }
387
401
  if (d.type === "thinking_level_change" && typeof d.thinkingLevel === "string") thinking = d.thinkingLevel;
388
402
  if (d.type === "model_change" && d.resolvedModelIsFallback === true) providerFallback = true;
389
- if (d.type === "custom" && d.customType === "session_exit") sessionExit = true;
390
403
  if (d.type === "session_init") {
391
404
  if (typeof d.task === "string") task = d.task;
392
405
  if (typeof d.resolvedModelIdentity === "string") resolvedModelIdentity = d.resolvedModelIdentity;
@@ -433,6 +446,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
433
446
  }
434
447
  if (d.type === "message" && m?.role === "user" && messageText(m).trimStart().startsWith("[budget notice]")) budgetNotice = true;
435
448
  }
449
+ if (partFirst !== null) workParts.push([partFirst, partLast]);
436
450
  const span = firstTs && lastTs ? (Date.parse(lastTs) - Date.parse(firstTs)) / 1000 : 0;
437
451
  return {
438
452
  model, thinking, task, resolvedModelIdentity, agent,
@@ -453,6 +467,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
453
467
  malformedNonLastLines,
454
468
  openedPrs: [...openedPrs],
455
469
  startedAt: firstTs,
470
+ stamps, workParts,
456
471
  reportWrites,
457
472
  lastStopReason, providerFallback, sessionExit, budgetNotice,
458
473
  };
@@ -867,7 +882,13 @@ export function ompSessionTranscripts(sessionDir) {
867
882
  // wrong-shape refusal (assertOmpShaped, inside foldOmpTranscript) must
868
883
  // propagate all the way out of readMembers, uncaught. Only the directory
869
884
  // listing and the raw file read are given the ordinary per-member tolerance.
870
- export function readOmpSession(sessionDir) {
885
+ //
886
+ // `activity`, when given, is `{ stamps: [], unread: 0 }`: `stamps` collects the
887
+ // entry instants of EVERY transcript read here, including one that yields no
888
+ // record, and `unread` counts the transcripts that could not be read at all,
889
+ // whose instants are missing from `stamps`. It is the session-wide view
890
+ // member-outcomes.mjs reads halts off.
891
+ export function readOmpSession(sessionDir, activity = null) {
871
892
  let transcripts;
872
893
  try { transcripts = ompSessionTranscripts(sessionDir); }
873
894
  catch { return []; }
@@ -876,19 +897,21 @@ export function readOmpSession(sessionDir) {
876
897
  for (const { file, agent, spawnDepth } of transcripts) {
877
898
  let text;
878
899
  try { text = readFileSync(file, "utf8"); }
879
- catch { continue; }
900
+ catch { if (activity) activity.unread++; continue; }
880
901
  const folded = foldOmpTranscript(text, file); // may throw — see comment above
902
+ if (activity) for (const ms of folded.stamps) activity.stamps.push(ms);
881
903
  const rec = ompMemberRecord(folded, agent, spawnDepth);
882
904
  if (!rec) continue;
883
905
  // How the transcript ENDED, for the `ending` column (member-outcomes.mjs):
884
906
  // the fold's end-of-transcript signals, and whether the member wrote its
885
- // yield sidecar beside the transcript.
907
+ // yield sidecar beside the transcript; and its working parts, for `work_s`.
886
908
  rows.push({ ...rec, session, agent, transcript: {
887
909
  startedAt: folded.startedAt, reportWrites: folded.reportWrites, openedPrs: folded.openedPrs,
888
910
  lastStopReason: folded.lastStopReason, providerFallback: folded.providerFallback,
889
911
  sessionExit: folded.sessionExit, budgetNotice: folded.budgetNotice,
890
912
  malformedNonLastLines: folded.malformedNonLastLines,
891
913
  hasYield: existsSync(file.replace(/\.jsonl$/, ".md")),
914
+ timed: folded.stamps.length > 0, workParts: folded.workParts,
892
915
  } });
893
916
  }
894
917
  return rows;
@@ -25,7 +25,8 @@
25
25
  // `$.bailCause: "<cause>" is not null, with outcome "blocked" — a block
26
26
  // is no bail`, gives no reason in `bailDetail`, or names a `pr` or a
27
27
  // `head`, as `$.pr: <pr> is not null, with outcome "blocked" — a block
28
- // reports no PR`
28
+ // reports no PR`, or whose `bailed` outcome names a `pr` or a `head`, as
29
+ // `$.pr: <pr> is not null, with outcome "bailed" — a bail reports no PR`
29
30
  // 2 usage: no or unknown subcommand, a wrong argument count, an unknown
30
31
  // family, any flag; or <file> could not be read; or print could not
31
32
  // write the schema to stdout. Refused on stderr, nothing on stdout
@@ -185,10 +186,11 @@ export function mergeBotVerdicts(report) {
185
186
  // no schema keyword states. For an impl report: a `blocked` outcome names no
186
187
  // `bailCause`, since a block is no bail, gives its reason in `bailDetail`,
187
188
  // text that is not only whitespace, and names no `pr` or `head`, since a block
188
- // reports no PR — a PR it opened is outcome `pr`, which settle checks. For a
189
- // merge-bot report: each PR gets one verdict — merged, held, conflict-held or
190
- // refused — the last it reached in the pass, so a PR named twice, in one list
191
- // or in two, is a violation at its second entry.
189
+ // reports no PR; a `bailed` outcome names no `pr` or `head` either, since a
190
+ // bail is the pre-code outcome — a PR the member opened is outcome `pr`, which
191
+ // settle checks. For a merge-bot report: each PR gets one verdict — merged,
192
+ // held, conflict-held or refused — the last it reached in the pass, so a PR
193
+ // named twice, in one list or in two, is a violation at its second entry.
192
194
  export function reportViolation(family, value) {
193
195
  const found = validate(SCHEMAS[family], value);
194
196
  if (found !== null) return found;
@@ -199,6 +201,11 @@ export function reportViolation(family, value) {
199
201
  if (value[field] !== null) return `$.${field}: ${show(value[field])} is not null, with outcome "blocked" — a block reports no PR`;
200
202
  }
201
203
  }
204
+ if (family === "impl" && value.outcome === "bailed") {
205
+ for (const field of ["pr", "head"]) {
206
+ if (value[field] !== null) return `$.${field}: ${show(value[field])} is not null, with outcome "bailed" — a bail reports no PR`;
207
+ }
208
+ }
202
209
  if (family !== "merge-bot") return null;
203
210
  const first = new Map();
204
211
  for (const { pr, path } of mergeBotVerdicts(value)) {