bareloop 0.5.0 → 0.6.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.
package/src/ralph.js CHANGED
@@ -4,7 +4,138 @@
4
4
  // arbiter). Stateless across runs and stdlib-only by design: it must stay too
5
5
  // dumb to be gamed. Do not make it smarter.
6
6
 
7
- import { spawnSync } from 'node:child_process';
7
+ import { spawn } from 'node:child_process';
8
+
9
+ // ── the close's child process, run WITHOUT freezing the host (F68) ───────────
10
+ // The close used to run through `spawnSync`, which blocks the host event loop for
11
+ // the child's entire duration: F68 measured 9 loop-freeze records in one green run
12
+ // (worst 74.3s, bracketing a ~65s close exactly) and 4 more bracketing three
13
+ // check-preflights — "the close-runner freezes the loop it reports to". While a
14
+ // close ran, the stall fuse's timers could not fire, the lag sampler recorded a
15
+ // block, and any in-flight instrument shared the freeze.
16
+ //
17
+ // ONLY THE WAIT IS ASYNC. Every observable semantic below is spawnSync's own,
18
+ // replicated field for field, because the close is the arbiter and its reading of
19
+ // the world must not shift by a byte:
20
+ // - the same `{error, status, signal, stdout, stderr}` result shape;
21
+ // - the same 1 MiB PER-STREAM output ceiling, reported the same way spawnSync
22
+ // reports it — an `ENOBUFS` error with the child killed (so an over-long close
23
+ // stays `failed`/broken-close, exactly as before, rather than silently
24
+ // becoming a red on truncated output);
25
+ // - the same deadline behaviour: SIGTERM at `timeoutMs`, surfaced as an
26
+ // `ETIMEDOUT` error (which runClose reads BEFORE any exit code, so the
27
+ // forbidden zone's `timed-out` band is unchanged);
28
+ // - the same stdin: closed immediately, so a close that reads stdin sees EOF
29
+ // instead of hanging on a pipe nobody will ever write to.
30
+ // The one deliberate difference is prose: a spawn fault names itself `spawn …`
31
+ // rather than `spawnSync …` in its message, because that is now what happened.
32
+ // Nothing parses that string (it rides out as an escalation `detail`).
33
+ const CLOSE_MAX_BUFFER = 1024 * 1024; // spawnSync's default maxBuffer, per stream
34
+ const CLOSE_KILL_SIGNAL = 'SIGTERM'; // spawnSync's default killSignal
35
+ // SIGTERM is a REQUEST, and a close is free to refuse it — a runner installing its
36
+ // own handler, a wrapper that traps to clean up. There is no second deadline after
37
+ // the first, so a refused SIGTERM leaves the wait below pending FOREVER: no `close`
38
+ // event ever arrives, and the run's only remaining stop is the out-of-process
39
+ // watchdog. The grace is the escalation — long enough for an honest close to flush
40
+ // and leave on its own terms, then SIGKILL, which nothing can refuse. Verdict
41
+ // semantics are untouched: the FIRST fault already named the outcome, and the kill
42
+ // only enforces it. (spawnSync sends killSignal and then simply keeps waiting; this
43
+ // is the one place the arbiter's clock is deliberately harder than its reference.)
44
+ const CLOSE_KILL_GRACE_MS = 2000;
45
+
46
+ /**
47
+ * @param {string} cmd
48
+ * @param {string[]} args
49
+ * @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
50
+ * @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
51
+ */
52
+ function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
53
+ return new Promise((resolve) => {
54
+ const child = spawn(cmd, args, { env, cwd });
55
+ /** @type {Buffer[]} */ const outChunks = [];
56
+ /** @type {Buffer[]} */ const errChunks = [];
57
+ let outBytes = 0, errBytes = 0;
58
+ /** @type {(Error & {code?: string})|null} */
59
+ let error = null;
60
+ /** @type {NodeJS.Timeout|undefined} */
61
+ let timer;
62
+ /** @type {NodeJS.Timeout|undefined} */
63
+ let killTimer;
64
+
65
+ // The FIRST fault wins and kills the child, exactly as spawnSync does: a close
66
+ // that blew the buffer and then hit the deadline is one story, not two. The
67
+ // escalation rides inside that same first-fault-wins guard, so a child is asked
68
+ // once and, if it refuses, ended once.
69
+ const fault = (/** @type {string} */ code) => {
70
+ if (error) return;
71
+ error = Object.assign(new Error(`spawn ${cmd} ${code}`), { code, syscall: `spawn ${cmd}`, path: cmd, spawnargs: args });
72
+ try { child.kill(CLOSE_KILL_SIGNAL); } catch { /* already gone — nothing to kill */ }
73
+ // …and SIGKILL if it is still there after the grace. `unref` so this timer can
74
+ // never hold the host's event loop open by itself (F68's whole point) — and it
75
+ // is cleared on `close`, so it cannot fire at a child that already left.
76
+ killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone in the meantime */ } }, CLOSE_KILL_GRACE_MS);
77
+ killTimer.unref();
78
+ };
79
+
80
+ child.stdout.on('data', (/** @type {Buffer} */ c) => { outBytes += c.length; outChunks.push(c); if (outBytes > CLOSE_MAX_BUFFER) fault('ENOBUFS'); });
81
+ child.stderr.on('data', (/** @type {Buffer} */ c) => { errBytes += c.length; errChunks.push(c); if (errBytes > CLOSE_MAX_BUFFER) fault('ENOBUFS'); });
82
+ // A spawn failure (ENOENT, an unreadable cwd) arrives as an event here and as
83
+ // `result.error` in spawnSync; `close` still fires after it, so there is ONE
84
+ // resolution path and the promise can never be left pending.
85
+ child.on('error', (e) => { if (!error) error = e; });
86
+ child.on('close', (status, signal) => {
87
+ clearTimeout(timer);
88
+ clearTimeout(killTimer);
89
+ resolve({
90
+ error, status, signal,
91
+ // Concat-then-decode, never per-chunk: a multi-byte character straddling a
92
+ // chunk boundary must not become two replacement characters — spawnSync
93
+ // decodes the whole buffer once, and the judged/gapKeep patterns read this.
94
+ stdout: Buffer.concat(outChunks).toString('utf8'),
95
+ stderr: Buffer.concat(errChunks).toString('utf8'),
96
+ });
97
+ });
98
+ if (typeof timeoutMs === 'number' && timeoutMs > 0 && Number.isFinite(timeoutMs)) timer = setTimeout(() => fault('ETIMEDOUT'), timeoutMs);
99
+ // stdin closed at once (spawnSync with no `input` hands the child EOF): a close
100
+ // that reads stdin gets the same EOF instead of blocking on an idle pipe. The
101
+ // error swallow is for the ordinary case where the child has already exited.
102
+ child.stdin?.on('error', () => {});
103
+ child.stdin?.end();
104
+ });
105
+ }
106
+
107
+ /**
108
+ * What the close child must NOT be able to read (operator ruling, 2026-07-31:
109
+ * "strip it when unnecessary, minimize unneeded exposure").
110
+ *
111
+ * The close child executes WORKER-AUTHORED CODE. `pytest`, `npm test`, `tsc`
112
+ * run whatever the worker just wrote into the tree, in a child that — before
113
+ * this — inherited the whole operator environment, credentials included. None
114
+ * of git/npm/tsc/pytest/mypy needs an API key to judge a tree, so the exposure
115
+ * bought nothing and is removed. This is EXPOSURE REDUCTION, not a sandbox:
116
+ * see the threat-model note in the PRD (v1.41) and README for what remains.
117
+ *
118
+ * Two rules, deliberately kept BOTH ways round:
119
+ * - `names` / `prefixes` — the knowns. A literal denylist is a chase (every
120
+ * new provider mints a new variable), so it is not load-bearing on its own;
121
+ * it exists for names the shape rule cannot see (`AWS_ACCESS_KEY_ID`).
122
+ * - `shape` — the NAME-SHAPE rule, which is what bounds the chase: anything
123
+ * whose name ends in the credential suffixes goes, whoever minted it and
124
+ * whether or not anyone here has heard of it. Case-insensitive; no `g` flag,
125
+ * so `.test` stays stateless and safe to reuse across every key.
126
+ *
127
+ * It is arbiter territory like the rest of this file: no drafted plan or config
128
+ * can express, widen, or opt out of it.
129
+ * @type {Readonly<{names: readonly string[], prefixes: readonly string[], shape: RegExp}>}
130
+ */
131
+ export const CLOSE_ENV_DENY = Object.freeze({
132
+ names: Object.freeze([
133
+ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
134
+ 'ANTHROPIC_AUTH_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'NPM_TOKEN',
135
+ ]),
136
+ prefixes: Object.freeze(['AWS_']),
137
+ shape: /(_API_KEY|_SECRET|_TOKEN|_PASSWORD|_CREDENTIALS?)$/i,
138
+ });
8
139
 
9
140
  /**
10
141
  * Run a close command; its exit code is the verdict (hard green, PRD §4).
@@ -68,18 +199,26 @@ import { spawnSync } from 'node:child_process';
68
199
  * (the workdir — the tree it is judging), the exit code the SIGNED spec calls
69
200
  * success, the judgment-rendered signal, and the kept-failures pattern. All
70
201
  * five are arbiter territory: the drafted workflow config cannot express any.
71
- * @returns {{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
202
+ * @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
72
203
  * gap?: string, exitCode?: number, signal?: string, detail?: string,
73
- * judgedCount?: number|null, unaudited?: boolean}}
204
+ * judgedCount?: number|null, unaudited?: boolean}>}
74
205
  */
75
- export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
206
+ export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
76
207
  const env = { ...process.env };
77
208
  delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
78
- const r = spawnSync(close[0], close.slice(1), { env, cwd, encoding: 'utf8', timeout: timeoutMs });
209
+ // …and the operator's credentials go with it (CLOSE_ENV_DENY above). A COPY is
210
+ // stripped, never `process.env` itself: the host still needs its key (bare-agent
211
+ // reads it every round) — only the child that runs worker-authored code is blinded.
212
+ for (const name of Object.keys(env)) {
213
+ if (CLOSE_ENV_DENY.names.includes(name)
214
+ || CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
215
+ || CLOSE_ENV_DENY.shape.test(name)) delete env[name];
216
+ }
217
+ const r = await spawnClose(close[0], close.slice(1), { env, cwd, timeoutMs });
79
218
 
80
219
  // ── forbidden zone, before any exit code is believed ──────────────────────
81
220
  if (r.error) {
82
- // spawnSync reports BOTH "cannot run" and "ran too long" through `error`;
221
+ // The runner reports BOTH "cannot run" and "ran too long" through `error`;
83
222
  // they are different human decisions, so they get different names.
84
223
  const code = /** @type {NodeJS.ErrnoException} */ (r.error).code;
85
224
  if (code === 'ETIMEDOUT') return { verdict: 'timed-out', detail: `close exceeded ${timeoutMs}ms and was killed — it never finished judging` };
@@ -131,6 +270,56 @@ export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, e
131
270
  };
132
271
  }
133
272
 
273
+ /**
274
+ * Run a STAGED close (PRD v1.28) — an ordered list of named command stages, run
275
+ * in order until one renders a verdict that is not `satisfied`. The return is
276
+ * runClose's own shape, so every consumer (the precheck, ralph's judge, the
277
+ * escalation's CLOSE_FAULTS routing) is unchanged: a staged close is still ONE
278
+ * verdict, not a scoreboard.
279
+ *
280
+ * Two additions, both mechanical information the worker can act on:
281
+ * - `stage` names which stage rendered the verdict. The close's output must
282
+ * never name the CULPRIT FILE (v1.12/F28 — the worker finds that itself), and
283
+ * naming the wall it hit is the opposite of that: it is the mechanical genre
284
+ * that converts (F38/F46), not the answer.
285
+ * - `stages` is the ordered record of what actually ran. A stage after the
286
+ * first red never runs, so a later green can never mask an earlier failure.
287
+ *
288
+ * The same function runs a DERIVED CHECK: a check is its stage's chain
289
+ * (`checkMenu` puts the prerequisites first), so there is one execution path for
290
+ * the close and for the rulers derived from it — never two, which is the F9
291
+ * two-transforms class applied to the arbiter.
292
+ *
293
+ * @param {any[]} stages the stage list (already validated by validateJob)
294
+ * @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
295
+ * @param {{timeoutMs?: number, cwd?: string}} [opts]
296
+ * @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
297
+ */
298
+ export async function runStages(stages, redact = (s) => s, { timeoutMs, cwd } = {}) {
299
+ const ran = [];
300
+ let last;
301
+ for (const st of stages) {
302
+ // Sequential by construction (F68): stages run one at a time under a single
303
+ // await, so nothing here ever has two closes in flight against one tree.
304
+ const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
305
+ timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
306
+ });
307
+ ran.push({ name: st.name, verdict: r.verdict, ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}) });
308
+ last = r;
309
+ if (r.verdict !== 'satisfied') {
310
+ return {
311
+ ...r,
312
+ stage: st.name,
313
+ stages: ran,
314
+ // The gap says WHICH wall, then the wall's own output. A worker handed
315
+ // three stages' worth of output cannot tell which one it must satisfy.
316
+ ...(r.gap ? { gap: `close stage "${st.name}" failed:\n${r.gap}` } : {}),
317
+ };
318
+ }
319
+ }
320
+ return { ...last, stages: ran };
321
+ }
322
+
134
323
  // Tail-biased bound: a test runner's useful output (the assertion diff, the
135
324
  // failing case name) is at the END; head-only truncation fed the worker the
136
325
  // preamble and dropped the cause (N2 queue, F2). Head sample kept so "what ran"
@@ -289,6 +478,36 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
289
478
  ['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
290
479
  'provider-red': ['The provider path failed mid-run (transport, not logic) — no verdict exists and the spend for the failed call is unknown (F6).',
291
480
  ['retry the run', 'fix the provider binding', 'abandon the task']],
481
+ // A (PRD v1.27) — the METER stopped this attempt before it started: the
482
+ // step had already eaten a declared share of the run with its exits
483
+ // unmoved. Not a fault, not a cap: the caller reads this category and
484
+ // REPLANS, so the message must not say anything broke.
485
+ 'step-variance': ['A step consumed a large share of the run with its exits still red — the meter stopped it before another attempt.',
486
+ ['let the planner re-allocate what is left (replan)', 'raise the budget and rerun', 'abandon the task']],
487
+ // T/F64 — the run's WALL CLOCK stopped this: either the caller's deadline
488
+ // came back as the provider's own timeout (F64, mid-attempt) or the caller
489
+ // read the clock and declined to START another attempt (W-2, between them).
490
+ // A governance stop either way, not a fault and not a transport casualty:
491
+ // the message must not send a human to debug a socket, and the remedy is the
492
+ // operator's cap, not a retry. The wording says neither "mid-attempt" nor
493
+ // "between attempts", because one entry serves both and a category that
494
+ // narrates the wrong half is worse than one that narrates neither.
495
+ // The two levers are BOTH spec edits (a changed spec hash the runner refuses
496
+ // until it is re-approved), and they point opposite ways — which is why the
497
+ // caller's detail carries the progress trend that says which one fits.
498
+ 'wall-halt': ['The run reached its wall-clock cap — time ran out, not capability. Nothing is discarded: the last verdict rendered stands, and the stop is the checkpoint.',
499
+ ['raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
500
+ 'revise the goal/spec so the work fits the time (same re-approval)',
501
+ 'abandon the task']],
502
+ // F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
503
+ // three times over, and reissuing the call did not recover it. It borrows both
504
+ // siblings' rules. Like `step-variance`, the caller reads this category and
505
+ // REPLANS, so the planner's lever leads. Like `wall-halt`, nothing broke — the
506
+ // socket stayed alive the whole time (that is the measured mechanism), so the
507
+ // message must not send a human to debug transport, and the default's "fix the
508
+ // interpreter" would aim a human at the one component that did nothing wrong.
509
+ 'step-stalled': ['The model stopped producing rounds and reissuing the call did not recover it — a stall, not a fault.',
510
+ ['let the planner re-allocate what is left (replan)', 'retry the run', 'check provider status', 'abandon the task']],
292
511
  };
293
512
  // Object.hasOwn, not bare lookup: a category named after an
294
513
  // Object.prototype member ("toString") would return the inherited
@@ -311,7 +530,7 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
311
530
  emit('middle-done', { iteration });
312
531
  const v = judge
313
532
  ? await judge()
314
- : runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
533
+ : await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
315
534
  // Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
316
535
  // still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
317
536
  // precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
package/src/root.js CHANGED
@@ -105,13 +105,14 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
105
105
  * failures" — [] === [] would false-read as "reds unchanged" (Finding 2).
106
106
  * @param {string} gap @returns {string[]|null} normalized kept lines, or null when untrustworthy
107
107
  */
108
- const keptSet = (gap) => {
109
- if (!keepRe || gap.includes(GAP_KEEP_TRIM_MARKER)) return null;
110
- const lines = [...new Set(gap.split('\n').filter((l) => keepRe.test(l)).map(normalizeRedLine))].sort();
108
+ const keptSet = (gap, pattern) => {
109
+ const re = pattern ? new RegExp(pattern, 'm') : keepRe;
110
+ if (!re || gap.includes(GAP_KEEP_TRIM_MARKER)) return null;
111
+ const lines = [...new Set(gap.split('\n').filter((l) => re.test(l)).map(normalizeRedLine))].sort();
111
112
  return lines.length ? lines : null;
112
113
  };
113
114
 
114
- /** @type {{writes: string[], reds: string[]|null, tee: Map<string, TeeEntry>}[]} finalized attempts */
115
+ /** @type {{writes: string[], reds: string[]|null, stage: string|undefined, tee: Map<string, TeeEntry>}[]} finalized attempts */
115
116
  const attempts = [];
116
117
  /** @type {Set<string>} cumulative audit write-set at the last observe */
117
118
  let prevCumulative = new Set();
@@ -179,10 +180,16 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
179
180
  * Called at the START of attempt `iteration`, with the PREVIOUS attempt's
180
181
  * gap. Finalizes that attempt from the books, runs the detector, and
181
182
  * returns the injection for the attempt about to run — or null (inert).
182
- * @param {{iteration: number, gap?: string, writes: string[]}} o
183
+ *
184
+ * `redStage`/`redKeep` are the STAGED-CLOSE seam (PRD v1.28): a close is an
185
+ * ordered list of named stages, the first red renders the verdict, so the
186
+ * pattern that designates failures belongs to whichever stage rendered it
187
+ * THIS attempt. Callers with a single red-set source (the per-step exit
188
+ * evaluator) pass neither and keep the constructor's run-wide pattern.
189
+ * @param {{iteration: number, gap?: string, writes: string[], redStage?: string, redKeep?: string}} o
183
190
  * @returns {null | {stage: 'summary'|'verbatim', note: string, event: object}}
184
191
  */
185
- observe({ iteration, gap, writes }) {
192
+ observe({ iteration, gap, writes, redStage, redKeep }) {
186
193
  const cumulative = new Set(writes);
187
194
  if (!gap) { // first attempt: nothing judged yet, nothing to compare
188
195
  prevCumulative = cumulative;
@@ -197,7 +204,7 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
197
204
  for (const p of tee.keys()) if (cumulative.has(p) && !delta.includes(p)) delta.push(p);
198
205
  // `gap` is guaranteed truthy here (the `if (!gap) return` guard above handled
199
206
  // the empty case); reds === null comes only from keptSet's own UNKNOWN logic.
200
- attempts.push({ writes: delta, reds: keptSet(gap), tee });
207
+ attempts.push({ writes: delta, reds: keptSet(gap, redKeep), stage: redStage, tee });
201
208
  // The detector only ever compares the last two finalized attempts, so older
202
209
  // ones (and their teed content) are dead state — keep just the two (Finding 7).
203
210
  if (attempts.length > 2) attempts.shift();
@@ -210,12 +217,22 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
210
217
  if (attempts.length < 2) return null;
211
218
  const [prev, last] = attempts.slice(-2);
212
219
  const overlap = last.writes.filter((p) => prev.writes.includes(p));
213
- // Both UNKNOWN → no trustworthy red-set either side: write-overlap alone
214
- // decides (writes-only mode, named below). One known and one UNKNOWN → the
215
- // red-set is not provably the same, so it counts as MOVEMENT, never a fire
216
- // (a real red-set followed by a crash/empty gap is new information, not
220
+ // A CHANGED failing stage is movement by definition — a different wall of
221
+ // the same inspection is a different failure, so the two red-sets are not
222
+ // comparable and repetition is not provable. Same bucket as one-known-one-
223
+ // UNKNOWN below, and for the same reason. This guard is what makes a single
224
+ // union pattern wrong: the stages of a real close are near-clones of one
225
+ // grader (job #4: 7 of 12 kept-line templates byte-identical), so a union
226
+ // kept-set can be IDENTICAL across a stage change and read as repetition —
227
+ // a false fire, which "inert when not stuck" forbids.
228
+ //
229
+ // Otherwise: both UNKNOWN → no trustworthy red-set either side, write-overlap
230
+ // alone decides (writes-only mode, named below). One known and one UNKNOWN →
231
+ // the red-set is not provably the same, so it counts as MOVEMENT, never a
232
+ // fire (a real red-set followed by a crash/empty gap is new information, not
217
233
  // repetition). Both known → the honest set comparison.
218
- const redsSame = last.reds === null && prev.reds === null ? null
234
+ const redsSame = last.stage !== prev.stage ? false
235
+ : last.reds === null && prev.reds === null ? null
219
236
  : last.reds === null || prev.reds === null ? false
220
237
  : JSON.stringify(last.reds) === JSON.stringify(prev.reds);
221
238
  // Finding 3: when writes carry no information (text mode — the one target is
@@ -237,6 +254,9 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
237
254
  iteration, stage, mode, streak,
238
255
  paths: shownPaths, ...(morePaths > 0 ? { pathsElided: morePaths } : {}),
239
256
  redSetSize: last.reds ? last.reds.length : null,
257
+ // which stage of the close designated those failures — absent for the
258
+ // single-source callers, so the record never claims a stage it lacks
259
+ ...(last.stage !== undefined ? { redStage: last.stage } : {}),
240
260
  };
241
261
 
242
262
  // Finding 7: what the worker REACHED FOR fired the detector; what it