bareloop 0.5.1 → 0.7.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,153 @@
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 STRUCTURALLY cannot see — either no
122
+ * `_`-bounded credential suffix at all (`AWS_ACCESS_KEY_ID`, `PGPASSWORD`,
123
+ * `MYSQL_PWD`), a `*_KEY` that is not `*_API_KEY` (`SSH_PRIVATE_KEY`), or a
124
+ * BARE spelling with nothing in front of the suffix (`TOKEN`, `SECRET`).
125
+ * Names are matched EXACTLY, which is what keeps connection config out of
126
+ * the strip (`PGHOST`, `SECRETS_DIR`, `TOKENIZERS_PARALLELISM` all survive).
127
+ * - `shape` — the NAME-SHAPE rule, which is what bounds the chase: anything
128
+ * whose name ends in the credential suffixes goes, whoever minted it and
129
+ * whether or not anyone here has heard of it. Case-insensitive; no `g` flag,
130
+ * so `.test` stays stateless and safe to reuse across every key.
131
+ *
132
+ * It is arbiter territory like the rest of this file: no drafted plan or config
133
+ * can express, widen, or opt out of it.
134
+ * @type {Readonly<{names: readonly string[], prefixes: readonly string[], shape: RegExp}>}
135
+ */
136
+ export const CLOSE_ENV_DENY = Object.freeze({
137
+ names: Object.freeze([
138
+ // provider keys
139
+ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
140
+ 'ANTHROPIC_AUTH_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'NPM_TOKEN',
141
+ // credential spellings the shape rule structurally cannot see, because their
142
+ // names carry no `_`-bounded suffix (operator ruling 2026-07-31, "add", on a
143
+ // delta-review finding that MEASURED every one of these reaching the child).
144
+ // Documented tools' own variables, not invented ones:
145
+ 'PGPASSWORD', 'PGPASSFILE', 'PGSSLKEY', // libpq (`PGPASS` is NOT a libpq variable — the FILE is ~/.pgpass, named by PGPASSFILE)
146
+ 'MYSQL_PWD', 'REDISCLI_AUTH', // mysql client, redis-cli
147
+ 'STRIPE_KEY', 'STRIPE_SECRET_KEY', // `*_KEY` is not `*_API_KEY`: the shape rule misses it
148
+ 'SSH_PRIVATE_KEY', 'PRIVATE_KEY', 'SECRET_KEY',
149
+ 'TOKEN', 'SECRET', 'API_KEY', 'PASSWORD', 'PASSWD', // the bare spellings; a suffix rule needs a prefix to bound
150
+ ]),
151
+ prefixes: Object.freeze(['AWS_']),
152
+ shape: /(_API_KEY|_SECRET|_TOKEN|_PASSWORD|_CREDENTIALS?)$/i,
153
+ });
8
154
 
9
155
  /**
10
156
  * Run a close command; its exit code is the verdict (hard green, PRD §4).
@@ -68,18 +214,26 @@ import { spawnSync } from 'node:child_process';
68
214
  * (the workdir — the tree it is judging), the exit code the SIGNED spec calls
69
215
  * success, the judgment-rendered signal, and the kept-failures pattern. All
70
216
  * five are arbiter territory: the drafted workflow config cannot express any.
71
- * @returns {{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
217
+ * @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
72
218
  * gap?: string, exitCode?: number, signal?: string, detail?: string,
73
- * judgedCount?: number|null, unaudited?: boolean}}
219
+ * judgedCount?: number|null, unaudited?: boolean}>}
74
220
  */
75
- export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
221
+ export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
76
222
  const env = { ...process.env };
77
223
  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 });
224
+ // …and the operator's credentials go with it (CLOSE_ENV_DENY above). A COPY is
225
+ // stripped, never `process.env` itself: the host still needs its key (bare-agent
226
+ // reads it every round) — only the child that runs worker-authored code is blinded.
227
+ for (const name of Object.keys(env)) {
228
+ if (CLOSE_ENV_DENY.names.includes(name)
229
+ || CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
230
+ || CLOSE_ENV_DENY.shape.test(name)) delete env[name];
231
+ }
232
+ const r = await spawnClose(close[0], close.slice(1), { env, cwd, timeoutMs });
79
233
 
80
234
  // ── forbidden zone, before any exit code is believed ──────────────────────
81
235
  if (r.error) {
82
- // spawnSync reports BOTH "cannot run" and "ran too long" through `error`;
236
+ // The runner reports BOTH "cannot run" and "ran too long" through `error`;
83
237
  // they are different human decisions, so they get different names.
84
238
  const code = /** @type {NodeJS.ErrnoException} */ (r.error).code;
85
239
  if (code === 'ETIMEDOUT') return { verdict: 'timed-out', detail: `close exceeded ${timeoutMs}ms and was killed — it never finished judging` };
@@ -131,6 +285,56 @@ export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, e
131
285
  };
132
286
  }
133
287
 
288
+ /**
289
+ * Run a STAGED close (PRD v1.28) — an ordered list of named command stages, run
290
+ * in order until one renders a verdict that is not `satisfied`. The return is
291
+ * runClose's own shape, so every consumer (the precheck, ralph's judge, the
292
+ * escalation's CLOSE_FAULTS routing) is unchanged: a staged close is still ONE
293
+ * verdict, not a scoreboard.
294
+ *
295
+ * Two additions, both mechanical information the worker can act on:
296
+ * - `stage` names which stage rendered the verdict. The close's output must
297
+ * never name the CULPRIT FILE (v1.12/F28 — the worker finds that itself), and
298
+ * naming the wall it hit is the opposite of that: it is the mechanical genre
299
+ * that converts (F38/F46), not the answer.
300
+ * - `stages` is the ordered record of what actually ran. A stage after the
301
+ * first red never runs, so a later green can never mask an earlier failure.
302
+ *
303
+ * The same function runs a DERIVED CHECK: a check is its stage's chain
304
+ * (`checkMenu` puts the prerequisites first), so there is one execution path for
305
+ * the close and for the rulers derived from it — never two, which is the F9
306
+ * two-transforms class applied to the arbiter.
307
+ *
308
+ * @param {any[]} stages the stage list (already validated by validateJob)
309
+ * @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
310
+ * @param {{timeoutMs?: number, cwd?: string}} [opts]
311
+ * @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
312
+ */
313
+ export async function runStages(stages, redact = (s) => s, { timeoutMs, cwd } = {}) {
314
+ const ran = [];
315
+ let last;
316
+ for (const st of stages) {
317
+ // Sequential by construction (F68): stages run one at a time under a single
318
+ // await, so nothing here ever has two closes in flight against one tree.
319
+ const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
320
+ timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
321
+ });
322
+ ran.push({ name: st.name, verdict: r.verdict, ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}) });
323
+ last = r;
324
+ if (r.verdict !== 'satisfied') {
325
+ return {
326
+ ...r,
327
+ stage: st.name,
328
+ stages: ran,
329
+ // The gap says WHICH wall, then the wall's own output. A worker handed
330
+ // three stages' worth of output cannot tell which one it must satisfy.
331
+ ...(r.gap ? { gap: `close stage "${st.name}" failed:\n${r.gap}` } : {}),
332
+ };
333
+ }
334
+ }
335
+ return { ...last, stages: ran };
336
+ }
337
+
134
338
  // Tail-biased bound: a test runner's useful output (the assertion diff, the
135
339
  // failing case name) is at the END; head-only truncation fed the worker the
136
340
  // preamble and dropped the cause (N2 queue, F2). Head sample kept so "what ran"
@@ -219,6 +423,27 @@ export const CLOSE_FAULTS = Object.freeze({
219
423
  },
220
424
  });
221
425
 
426
+ /**
427
+ * The exhaustion-by-PROGRESS seam, as a contract rather than "whatever one module
428
+ * happens to return". Two governors implement it and they read different signals —
429
+ * the step ladder's repeats-and-writes (src/ladder.js) and the close-fix loop's
430
+ * per-stage graded numbers (src/trend.js) — which is the whole reason ralph takes an
431
+ * interface here and interprets nothing itself.
432
+ *
433
+ * `record` returns the governor's own reading, which ralph emits verbatim and never
434
+ * inspects; each reading names its `governor` so two instruments under one spine type
435
+ * can never be averaged into one number. `report` is the only shape ralph itself
436
+ * reads. `terminal` is optional and overrides the exhaustion PROSE only — never the
437
+ * category, never the outcome.
438
+ *
439
+ * @typedef {object} LoopGovernor
440
+ * @property {(o: {iteration: number, gap?: string}) => object} record
441
+ * @property {() => boolean} struckOut
442
+ * @property {() => {strikes: number, limit: number, iterations: number, distinctGaps?: number}} report
443
+ * @property {() => string} brief
444
+ * @property {() => {decision: string, options: string[]}} [terminal]
445
+ */
446
+
222
447
  /**
223
448
  * The loop: `while close-red and under-cap: run the middle`. Stops at first
224
449
  * green (within-run tuning past a visible close is the fit-to-pass surface,
@@ -244,7 +469,28 @@ export const CLOSE_FAULTS = Object.freeze({
244
469
  * zone, worker-crash routing (F32), and the cap taxonomy apply unchanged.
245
470
  * Injected by run.js only; inexpressible in any config or plan — the agent
246
471
  * never authors its judge any more than its close.
247
- * @param {number} opts.capRuns budget: max middle runs
472
+ * @param {number} [opts.capRuns] budget: max middle runs. Required UNLESS `ladder`
473
+ * is supplied — the two exhaustion rules are alternatives, never both at once.
474
+ * The "unless" is ENFORCED at entry (a param guard that throws), not merely
475
+ * documented: an optional-in-the-contract bound with nothing checking it made a
476
+ * loop that never ran report a governance stop that never happened.
477
+ * @param {LoopGovernor} [opts.ladder]
478
+ * the PROGRESS governor (src/ladder.js) that replaces the fixed count for a plan
479
+ * STEP's micro-loop. With it, the loop is bounded by strikes rather than by a
480
+ * number of runs: iterations float, and money, the wall, the variance meter and
481
+ * the stall fuse keep every bit of their current authority (each still ends the
482
+ * loop through the middle's own throw). Ralph still interprets nothing — it hands
483
+ * the ladder the iteration's gap and reads back a boolean.
484
+ *
485
+ * The CLOSE-FIX loop now supplies one too (PRD v1.46 §4, once its own $0 replay
486
+ * over all 8 archived fix loops came back clean: 0 greens harmed, 1 waste case
487
+ * caught). Its governor reads a different signal — the close's own per-stage
488
+ * graded numbers (src/trend.js), not repeats and writes — which is exactly why
489
+ * this is a SEAM and not a hard-coded rule: ralph still interprets nothing.
490
+ * An optional `terminal()` on the governor overrides the exhaustion prose, because
491
+ * the step ladder's copy offers the human a replan and there is no planner at the
492
+ * close. The category and the outcome are NOT overridable: one exhaustion terminal,
493
+ * two triggers.
248
494
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
249
495
  * @param {(s: string) => string} [opts.redact] source scrubber for close output
250
496
  * (secrets never enter the spine); injected so the shell stays stdlib-only
@@ -263,8 +509,30 @@ export const CLOSE_FAULTS = Object.freeze({
263
509
  * with zero writes, a crash stays what it always was: an instrument stop.
264
510
  * @returns {Promise<'green'|'escalated'>}
265
511
  */
266
- export async function ralph({ middle, close, judge, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
267
- emit('run-start', { capRuns, close: judge ? 'judge:injected' : /** @type {string[]} */ (close).join(' ') });
512
+ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
513
+ // PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
514
+ // thing this shell throws for; a WORKER's bad reach comes back as a refusal
515
+ // RESULT instead, and that rule is about feedback, not about the API contract).
516
+ // `capRuns` became optional in this signature when `ladder` arrived, and nothing
517
+ // enforced the "unless". MEASURED with neither supplied: `1 <= undefined` is
518
+ // false, so the middle ran 0 times and ralph still emitted `cap-halt` and an
519
+ // escalation reading `undefined/undefined runs spent, close still red` — a
520
+ // governance stop narrated for a run that never happened, on the public export.
521
+ // A bound that reports halting a loop it never entered is exactly the
522
+ // blind-instrument class this repo keeps paying for, so the malformed call dies
523
+ // at the door instead of minting a record. The message names BOTH alternatives:
524
+ // either one repairs the call, and naming one would send the caller to the wrong
525
+ // repair.
526
+ if (!ladder && !(Number.isInteger(capRuns) && /** @type {number} */ (capRuns) > 0)) {
527
+ // quoted only for a string, so `"3"` reads as the type error it is while
528
+ // `NaN`/`undefined` read as themselves (JSON.stringify renders both as a lie)
529
+ const got = typeof capRuns === 'string' ? JSON.stringify(capRuns) : String(capRuns);
530
+ throw new Error(`ralph: capRuns must be a positive integer (got ${got}) unless a \`ladder\` governor is supplied — they are alternative exhaustion rules and a loop with neither cannot stop honestly`);
531
+ }
532
+ emit('run-start', {
533
+ ...(ladder ? { strikeLimit: ladder.report().limit } : { capRuns }),
534
+ close: judge ? 'judge:injected' : /** @type {string[]} */ (close).join(' '),
535
+ });
268
536
  // The blind spot is NAMED, never hidden: with no judgment-rendered signal this
269
537
  // close cannot tell a crash from an honest red (they are byte-identical at the
270
538
  // exit-code seam), so the record says so out loud rather than passing the exit
@@ -274,7 +542,22 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
274
542
  if (!judged && !judge) emit('close-unaudited', { close: /** @type {string[]} */ (close).join(' '), meaning: 'no judgment-rendered signal declared — a crash-at-load is indistinguishable from an honest red for this close' });
275
543
  const verdicts = [];
276
544
  let gap;
277
- for (let iteration = 1; iteration <= capRuns; iteration++) {
545
+ // ONE exhaustion terminal, two triggers — the same three records in the same
546
+ // order either way, so the count path and the ladder path cannot drift into
547
+ // reporting the stop differently. The CATEGORY is `cap-halt` for both, and
548
+ // deliberately: downstream counts escalation categories against an executable
549
+ // excluded-set (ledger.js), and the step loop routes its ONE replan off exactly
550
+ // this name. Only the trigger changed; the taxonomy did not.
551
+ /** @param {number} iteration @param {object} halt @param {object} spend @param {string} decision @param {string[]} options */
552
+ const exhausted = (iteration, halt, spend, decision, options) => {
553
+ emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', ...halt });
554
+ emit('escalation', { category: 'cap-halt', decisionReady: true, verdicts, spend, decision, options });
555
+ emit('run-end', { outcome: 'escalated', iterations: iteration });
556
+ return /** @type {'escalated'} */ ('escalated');
557
+ };
558
+ // With a ladder the count is not the bound: the loop runs until the ladder strikes
559
+ // out, or until the middle throws (money, wall, variance, stall — all unchanged).
560
+ for (let iteration = 1; ladder || iteration <= /** @type {number} */ (capRuns); iteration++) {
278
561
  emit('iteration-start', { iteration });
279
562
  try {
280
563
  await middle(iteration, gap);
@@ -289,6 +572,36 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
289
572
  ['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
290
573
  '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
574
  ['retry the run', 'fix the provider binding', 'abandon the task']],
575
+ // A (PRD v1.27) — the METER stopped this attempt before it started: the
576
+ // step had already eaten a declared share of the run with its exits
577
+ // unmoved. Not a fault, not a cap: the caller reads this category and
578
+ // REPLANS, so the message must not say anything broke.
579
+ 'step-variance': ['A step consumed a large share of the run with its exits still red — the meter stopped it before another attempt.',
580
+ ['let the planner re-allocate what is left (replan)', 'raise the budget and rerun', 'abandon the task']],
581
+ // T/F64 — the run's WALL CLOCK stopped this: either the caller's deadline
582
+ // came back as the provider's own timeout (F64, mid-attempt) or the caller
583
+ // read the clock and declined to START another attempt (W-2, between them).
584
+ // A governance stop either way, not a fault and not a transport casualty:
585
+ // the message must not send a human to debug a socket, and the remedy is the
586
+ // operator's cap, not a retry. The wording says neither "mid-attempt" nor
587
+ // "between attempts", because one entry serves both and a category that
588
+ // narrates the wrong half is worse than one that narrates neither.
589
+ // The two levers are BOTH spec edits (a changed spec hash the runner refuses
590
+ // until it is re-approved), and they point opposite ways — which is why the
591
+ // caller's detail carries the progress trend that says which one fits.
592
+ '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.',
593
+ ['raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
594
+ 'revise the goal/spec so the work fits the time (same re-approval)',
595
+ 'abandon the task']],
596
+ // F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
597
+ // three times over, and reissuing the call did not recover it. It borrows both
598
+ // siblings' rules. Like `step-variance`, the caller reads this category and
599
+ // REPLANS, so the planner's lever leads. Like `wall-halt`, nothing broke — the
600
+ // socket stayed alive the whole time (that is the measured mechanism), so the
601
+ // message must not send a human to debug transport, and the default's "fix the
602
+ // interpreter" would aim a human at the one component that did nothing wrong.
603
+ 'step-stalled': ['The model stopped producing rounds and reissuing the call did not recover it — a stall, not a fault.',
604
+ ['let the planner re-allocate what is left (replan)', 'retry the run', 'check provider status', 'abandon the task']],
292
605
  };
293
606
  // Object.hasOwn, not bare lookup: a category named after an
294
607
  // Object.prototype member ("toString") would return the inherited
@@ -311,7 +624,7 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
311
624
  emit('middle-done', { iteration });
312
625
  const v = judge
313
626
  ? await judge()
314
- : runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
627
+ : await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
315
628
  // Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
316
629
  // still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
317
630
  // precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
@@ -335,30 +648,56 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
335
648
  gap = `Your edit CRASHED the test suite — it can no longer even load and judge (${v.detail}). `
336
649
  + `Files you have written this run: ${shown.join(', ')}${more > 0 ? ` (and ${more} more)` : ''}. `
337
650
  + 'Fix or revert your change so the suite can run again — nothing can pass while the suite cannot load.';
338
- continue;
651
+ } else {
652
+ // Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
653
+ // by the outcome's OWN name and never retry — retrying a broken arbiter is
654
+ // the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
655
+ const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
656
+ if (fault) {
657
+ emit('escalation', {
658
+ category: fault.category, decisionReady: true, verdicts,
659
+ spend: { runs: iteration, capRuns },
660
+ decision: fault.decision, options: fault.options, detail: v.detail,
661
+ });
662
+ emit('run-end', { outcome: 'escalated', iterations: iteration });
663
+ return 'escalated';
664
+ }
665
+ gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
339
666
  }
340
- // Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
341
- // by the outcome's OWN name and never retry — retrying a broken arbiter is
342
- // the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
343
- const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
344
- if (fault) {
345
- emit('escalation', {
346
- category: fault.category, decisionReady: true, verdicts,
347
- spend: { runs: iteration, capRuns },
348
- decision: fault.decision, options: fault.options, detail: v.detail,
349
- });
350
- emit('run-end', { outcome: 'escalated', iterations: iteration });
351
- return 'escalated';
667
+ // ── the ladder reads THIS red iteration, whichever gap it ended up with (a
668
+ // worker-crash gap is feedback the attempt really received, so it is a reading
669
+ // like any other). ONE call site, after both branches have settled `gap`: two
670
+ // would let a future branch quietly opt out of the bound.
671
+ if (ladder) {
672
+ const r = ladder.record({ iteration, gap });
673
+ emit('ladder', r);
674
+ if (ladder.struckOut()) {
675
+ const rep = ladder.report();
676
+ // The governor may name its own decision/options; it may NOT name its own
677
+ // category or outcome. A stop is spelled the same either way (`cap-halt`,
678
+ // 'escalated') because downstream counts categories against an executable
679
+ // excluded-set — only the PROSE a human reads is governor-specific.
680
+ const term = typeof ladder.terminal === 'function' ? ladder.terminal() : null;
681
+ return exhausted(iteration,
682
+ {
683
+ strikes: rep.strikes,
684
+ strikeLimit: rep.limit,
685
+ iterations: rep.iterations,
686
+ ...(rep.distinctGaps === undefined ? {} : { distinctGaps: rep.distinctGaps }),
687
+ },
688
+ { runs: iteration, strikes: rep.strikes, strikeLimit: rep.limit },
689
+ term?.decision ?? `${rep.strikes}/${rep.limit} strikes — the step stopped making progress. ${ladder.brief()} Continue, change approach, or stop?`,
690
+ term?.options ?? ['let the planner re-allocate what is left (replan)', 'change approach', 'abandon the task']);
691
+ }
352
692
  }
353
- gap = v.gap; // feedback for the next middle run; the shell itself learns nothing
354
693
  }
355
- emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', capRuns });
356
- emit('escalation', {
357
- category: 'cap-halt', decisionReady: true, verdicts,
358
- spend: { runs: capRuns, capRuns },
359
- decision: `${capRuns}/${capRuns} runs spent, close still red. Continue, change approach, or stop?`,
360
- options: ['raise the cap and rerun', 'change the middle/harness', 'abandon the task'],
361
- });
362
- emit('run-end', { outcome: 'escalated', iterations: capRuns });
363
- return 'escalated';
694
+ // The FIXED-COUNT exhaustion, reached only without a ladder. Both callers inside this
695
+ // package now supply one (the step loop's `createLadder`, the close-fix loop's
696
+ // progress governor), so the only route left is the public export — kept because it IS
697
+ // the public export, and because a `capRuns` bound with no reachable exhaustion would
698
+ // be a bound in name only.
699
+ const spent = /** @type {number} */ (capRuns);
700
+ return exhausted(spent, { capRuns }, { runs: spent, capRuns },
701
+ `${spent}/${spent} runs spent, close still red. Continue, change approach, or stop?`,
702
+ ['raise the cap and rerun', 'change the middle/harness', 'abandon the task']);
364
703
  }