bare-agent 0.30.0 → 0.32.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/recurse.js CHANGED
@@ -238,6 +238,13 @@ function auditSafeCtx(ctx, overrides = {}) {
238
238
  * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
239
239
  * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
240
240
  * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
241
+ * **Broken sensor ≠ failing model (BA-15):** a sensor that THROWS (non-Halt) or returns a MALFORMED verdict
242
+ * (anything but `{pass: boolean}` or a valid tri-state `status`) is a faulty ARBITER — the loop stops at the
243
+ * FIRST broken close (never retries against it) and returns a labeled `{incomplete, blocker:'broken-sensor'}`
244
+ * (+ `receipts.blockerDetail`), with `best` preserving the model's last attempt. A `HaltError` thrown by the
245
+ * sensor stays a clean governance halt. The sensor's EXECUTION environment is the caller's: run untrusted /
246
+ * model-generated checks in an isolated child process WITH A TIMEOUT — a sensor that hangs forever hangs the
247
+ * leaf (no bareguard checkpoint fires between sensor start and return; confirmed by `poc/rlmplans-hung-sensor.mjs`).
241
248
  * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
242
249
  * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
243
250
  * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
@@ -306,8 +313,17 @@ function auditSafeCtx(ctx, overrides = {}) {
306
313
  * @property {Verdict|null} verdict
307
314
  * @property {boolean} incomplete
308
315
  * @property {boolean} halted
309
- * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
310
- * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
316
+ * @property {string} [blocker] - Set when this node stopped for a specific non-model reason (mirrors
317
+ * `RecurseResult.blocker`): `'governance-deny'` (BA-11) — its Loop short-circuited a consecutive-policy-deny
318
+ * spin; `'broken-sensor'` (BA-15) — the caller's `refineLeaf.sensor` threw or returned a malformed verdict;
319
+ * `'broken-verifier'` (BA-15) — the caller's `opts.evaluate` did (the default Evaluator path is never labeled).
320
+ * @property {string} [blockerDetail] - (BA-15) with a `broken-*` blocker: what the arbiter did (threw with
321
+ * which message, or which malformed shape it returned) — the actionable half of the label.
322
+ * @property {{blocker: string, blockerDetail?: string, blockerTask?: string}} [blockerFrom] - (BA-15) a
323
+ * DESCENDANT's blocker, surfaced here so an aggregating node still reports the fault upward. Deliberately
324
+ * SEPARATE from this node's own `blocker` (which means "THIS node's arbiter/Loop broke"): stamping a
325
+ * descendant's label onto every ancestor made the receipts tree accuse nodes whose sensor never ran, and
326
+ * re-labelled a parent `governance-deny` when only one child was denied. `blockerTask` names the culprit.
311
327
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
312
328
  * @property {{iterations: number, passed: boolean, temperatures: (number|null)[], rejectedBuffer: boolean}} [refineLeaf] - (BA-8) when
313
329
  * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
@@ -341,6 +357,18 @@ function auditSafeCtx(ctx, overrides = {}) {
341
357
  * @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
342
358
  * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
343
359
  * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
360
+ * `'broken-sensor'` (BA-15): the caller's `refineLeaf.sensor` threw or returned a malformed verdict — the
361
+ * ARBITER is faulty, not the model; fix the sensor and re-run (`receipts.blockerDetail` says what it did).
362
+ * `'broken-verifier'` (BA-15): same fault class at the verify slot — the caller's `opts.evaluate` threw
363
+ * (non-Halt) or returned a malformed verdict; the default Evaluator path is never labeled (its failures are
364
+ * provider-class faults). For both `broken-*` blockers `best` preserves the model's last non-empty output
365
+ * (BA-5) — the arbiter broke, so treat it as best-effort work rather than a graded pass.
366
+ * @property {string} [blockerDetail] - (BA-15) with a `broken-*` blocker: what the arbiter did — the
367
+ * ACTIONABLE half of the label, surfaced on the result (not only in `receipts`) so a caller branching on
368
+ * `blocker` can report the cause without walking the receipts tree.
369
+ * @property {string} [blockerTask] - (BA-15) when the blocker was INHERITED from a descendant in a nested run:
370
+ * which sub-task actually broke. Without it a nested failure reports "a sensor broke" with no way to find
371
+ * which one.
344
372
  * @property {RecurseNode} receipts - The audit node for this call (RC-10).
345
373
  */
346
374
 
@@ -586,16 +614,16 @@ async function recurse(task, ctx = {}, opts = {}) {
586
614
  const missingSlices = node.spawned.filter(c => c.incomplete).map(c => c.task);
587
615
  if (missingSlices.length > 0) {
588
616
  node.incomplete = true;
589
- return { incomplete: true, best: result, missingSlices, receipts: node };
617
+ // BA-15: carry a child's blocker up, so a nested broken sensor still names itself at the top.
618
+ return incompleteWithBlocker(node, result, { missingSlices });
590
619
  }
591
620
 
592
621
  // Verify: a SEPARATE-context judge, never the generator grading itself. Runs when a contract is given, the
593
622
  // caller supplied a verifier, OR the task is critical (the forced-verify safety rail).
594
623
  const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
595
624
  if (wantVerify) {
596
- const verdict = await verify(task, result, ctx, opts);
597
- node.verdict = verdict;
598
- return { result, verdict, receipts: node };
625
+ // `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
626
+ return await verifyOrBlock(task, result, ctx, opts, node);
599
627
  }
600
628
 
601
629
  return { result, verdict: null, receipts: node };
@@ -603,12 +631,221 @@ async function recurse(task, ctx = {}, opts = {}) {
603
631
  if (err instanceof HaltError) {
604
632
  node.halted = true;
605
633
  node.incomplete = true;
606
- return { incomplete: true, best: result, receipts: node };
634
+ // BA-15: a halt AFTER the children ran (e.g. mid-synthesize) must still name a child's broken sensor —
635
+ // otherwise the caller reads a governance problem where their own arbiter crashed.
636
+ return incompleteWithBlocker(node, result);
607
637
  }
608
638
  throw err;
609
639
  }
610
640
  }
611
641
 
642
+ /** Valid tri-state `Verdict.status` values a caller arbiter (sensor/verifier) may return in lieu of a boolean `pass`. */
643
+ const SENSOR_STATUS = new Set(['satisfied', 'needs_revision', 'failed']);
644
+
645
+ /**
646
+ * BA-15 — a TYPED signal that a caller arbiter (the `refineLeaf.sensor` or the `opts.evaluate` verifier)
647
+ * broke (threw non-Halt, or returned a malformed verdict). Classified by `instanceof` + `.tag`, NOT by
648
+ * re-parsing an `Error.message` prefix — so an intermediate layer that rewords the message (the exact class
649
+ * of the prior loop.js HaltError-wrapping bug) or a Loop/provider error whose text happens to begin
650
+ * `broken-sensor: ` can never be mis-labeled. Module-local: it is always thrown AND caught inside recurse.js
651
+ * (never propagates to a caller), so it needs no `errors.js` entry or public export.
652
+ */
653
+ class BrokenArbiterError extends Error {
654
+ /**
655
+ * @param {'broken-sensor'|'broken-verifier'} tag - which seam broke.
656
+ * @param {string} detail - what the arbiter did (surfaced as `receipts.blockerDetail`).
657
+ * @param {{cause?: any}} [options] - `cause` preserves the original error (stack/type) for debugging.
658
+ */
659
+ constructor(tag, detail, options = {}) {
660
+ super(`${tag}: ${detail}`, options.cause !== undefined ? { cause: options.cause } : undefined);
661
+ this.name = 'BrokenArbiterError';
662
+ this.tag = tag;
663
+ this.detail = detail;
664
+ }
665
+ }
666
+
667
+ /**
668
+ * BA-15 — validate a caller arbiter's return at a close seam (the `refineLeaf.sensor` AND the caller
669
+ * `opts.evaluate` verifier). A verdict is well-formed iff it is an object carrying a boolean `pass` OR a
670
+ * valid tri-state `status` (the two shapes `refine`/callers branch on). Returns `null` when well-formed,
671
+ * else a short description of the malformation ("named, never coerced" — a garbage verdict otherwise reads
672
+ * as pass:false with critique:null at the sensor seam, or rides a converged-shaped return at the verify slot).
673
+ * @param {any} v
674
+ * @returns {string|null}
675
+ */
676
+ function verdictShapeFault(v) {
677
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
678
+ return `returned ${v === null ? 'null' : Array.isArray(v) ? 'an array' : `a ${typeof v}`}`;
679
+ }
680
+ // A PRESENT `pass` counts whatever its type (`1`/`0`/`'yes'` are a long-standing yes-no convention, and
681
+ // `refine` has always branched on its TRUTHINESS). BA-15 exists to catch a verdict carrying NO usable
682
+ // signal — not to reject one that answers clearly in a different dialect: demanding a strict boolean
683
+ // silently flipped previously-CONVERGING adopter sensors to a permanent first-attempt block.
684
+ if (v.pass != null || SENSOR_STATUS.has(v.status)) return null;
685
+ const keys = Object.keys(v).slice(0, 5).join(', ');
686
+ return `returned an object with neither a usable \`pass\` nor a valid \`status\` (keys: ${keys || 'none'})`;
687
+ }
688
+
689
+ /** Upper bound on a `blockerDetail` fragment — it rides into receipts and, via a wired gate, onto disk. */
690
+ const DETAIL_MAX = 200;
691
+
692
+ /**
693
+ * BA-15 — a readable one-liner for ANYTHING thrown by a caller arbiter. `String(err)` on a non-Error throw
694
+ * (a test harness rejecting with a raw `{code:'ENOENT', path}` result is the common case) yields the useless
695
+ * `[object Object]`, defeating `blockerDetail`'s entire purpose: naming what broke so the operator can fix it.
696
+ * @param {any} err
697
+ * @returns {string}
698
+ */
699
+ function describeThrown(err) {
700
+ const clamp = (s) => (s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}… (truncated)` : s);
701
+ if (err instanceof Error && typeof err.message === 'string' && err.message) return clamp(err.message);
702
+ if (err === null || typeof err !== 'object') return clamp(String(err));
703
+ // Deliberately NOT a whole-object dump. A thrown non-Error is typically a spawn/exec RESULT, which routinely
704
+ // carries a full stdout buffer and an env snapshot — and `blockerDetail` rides into `receipts`, which a wired
705
+ // gate serializes VERBATIM into a plaintext audit log (the F16/BA-1 lesson: never let caller data of unknown
706
+ // shape reach the audit unfiltered). Take only the conventional diagnostic fields, clamped.
707
+ try {
708
+ const picked = ['name', 'code', 'errno', 'syscall', 'path', 'status', 'signal', 'message']
709
+ .filter(k => typeof err[k] === 'string' || typeof err[k] === 'number')
710
+ .map(k => `${k}=${String(err[k])}`)
711
+ .join(' ');
712
+ if (picked) return clamp(picked);
713
+ } catch { /* a throwing accessor / Proxy trap — fall through to the type tag */ }
714
+ return Object.prototype.toString.call(err);
715
+ }
716
+
717
+ /**
718
+ * BA-15 — run a caller arbiter (the `refineLeaf.sensor` or the `opts.evaluate` verifier) and NAME a broken one
719
+ * rather than let it launder. A non-`HaltError` throw or a malformed return (per {@link verdictShapeFault}) is
720
+ * re-thrown as a typed {@link BrokenArbiterError} the call sites classify by `instanceof` + `.tag` (NEVER by
721
+ * re-parsing the message text); a `HaltError` passes straight through (clean
722
+ * governance exit, BA-2). One helper so the sensor and verifier seams stay byte-identical (same guidance
723
+ * string, same fault taxonomy) — a divergence between them was the copy-paste risk this replaces.
724
+ * A verdict that carries a valid `status` but no boolean `pass` is NORMALIZED to add `pass = status ===
725
+ * 'satisfied'` (the same derivation as `evaluator.js`): the advertised `{status}` contract must actually
726
+ * WORK — `refine.js` stops the leaf on `verdict.pass`, so a bare `{status:'satisfied'}` would otherwise never
727
+ * satisfy, burn every iteration, and report `passed:false` (a satisfied close mislabeled as non-recovery).
728
+ * @template {{pass?: boolean, status?: string}} T
729
+ * @param {'broken-sensor'|'broken-verifier'} tag - the fault channel, carried on the thrown error's `.tag`.
730
+ * @param {() => (T | Promise<T>)} call - invokes the arbiter (already bound to its result/ctx args).
731
+ * @returns {Promise<T>} the arbiter's well-formed verdict (with `pass` derived from `status` when absent).
732
+ */
733
+ async function runArbiter(tag, call) {
734
+ const who = tag === 'broken-sensor' ? 'sensor' : 'evaluate';
735
+ const shape = `a ${who === 'sensor' ? 'sensor' : 'verifier'} must return {pass} or {status: 'satisfied'|'needs_revision'|'failed'}`;
736
+ /** A throw from the arbiter's own body (sync or async rejection) — "the sensor threw". */
737
+ const threw = (err) => {
738
+ if (err instanceof HaltError || err instanceof BrokenArbiterError) return err;
739
+ // `cause` keeps the original error (stack + type) reachable for debugging; `detail` stays the short label.
740
+ return new BrokenArbiterError(tag, `${who} threw: ${describeThrown(err)}`, { cause: err });
741
+ };
742
+ /** A throw from READING the value the arbiter returned — "the verdict is unreadable", a different fault. */
743
+ const unreadable = (err) => {
744
+ if (err instanceof HaltError || err instanceof BrokenArbiterError) return err;
745
+ return new BrokenArbiterError(tag, `${who} returned a verdict whose properties could not be read: ${describeThrown(err)} — ${shape}`, { cause: err });
746
+ };
747
+
748
+ // Typed `any` deliberately: this block probes an UNTRUSTED caller return (it may be a promise, a plain
749
+ // verdict, or a hostile Proxy), so the generic `T | Promise<T>` narrowing does not apply until it is settled.
750
+ /** @type {any} */
751
+ let raw;
752
+ try {
753
+ raw = call();
754
+ } catch (err) { throw threw(err); }
755
+
756
+ // `await`ing directly would conflate two different faults: the await PROBES `.then` on the returned value,
757
+ // so an accessor-backed/Proxy verdict throws during the await and gets reported as "the sensor threw" —
758
+ // sending the operator hunting a `throw` in a sensor that returned perfectly normally. Probe the thenable
759
+ // separately (a throw HERE is the returned value being unreadable) and only then await (a rejection THERE
760
+ // is genuinely the arbiter's body failing).
761
+ let thenable;
762
+ try {
763
+ thenable = raw !== null && (typeof raw === 'object' || typeof raw === 'function') && typeof raw.then === 'function';
764
+ } catch (err) { throw unreadable(err); }
765
+
766
+ /** @type {any} */
767
+ let v = raw;
768
+ if (thenable) {
769
+ try {
770
+ v = await raw;
771
+ } catch (err) { throw threw(err); }
772
+ }
773
+ // The SHAPE INSPECTION is guarded SEPARATELY: reading `.status`/`.pass`/`Object.keys` on a returned Proxy or
774
+ // accessor-backed object can itself throw. Unguarded that escapes untyped (the very uncaught crash this seam
775
+ // prevents, one step later) — but folding it into the call's own catch is also wrong: it reports "the sensor
776
+ // threw" for a sensor that RETURNED NORMALLY, sending the operator hunting a `throw` that does not exist.
777
+ // The fault is in the returned VALUE, so it is named as such.
778
+ try {
779
+ const fault = verdictShapeFault(v);
780
+ if (fault) throw new BrokenArbiterError(tag, `${who} ${fault} — ${shape}`);
781
+ // Derive `pass` from a status-only verdict so the advertised `{status}` shape actually gates `refine`, which
782
+ // branches on `verdict.pass`. NEVER mutate the caller's object — and never flatten it either: an object
783
+ // spread copies OWN enumerable properties only, so a class-instance verdict whose `status`/`critique` are
784
+ // PROTOTYPE getters passes the shape check above and then comes out the other side with those fields
785
+ // ERASED (critique lost ⇒ every retry re-sends the plain task with zero feedback — the exact burn BA-15
786
+ // exists to prevent). Copy descriptors onto the SAME prototype so accessor-backed fields survive.
787
+ if (v.pass == null && typeof v.status === 'string') {
788
+ const copy = Object.create(Object.getPrototypeOf(v), Object.getOwnPropertyDescriptors(v));
789
+ Object.defineProperty(copy, 'pass', {
790
+ value: v.status === 'satisfied', writable: true, enumerable: true, configurable: true,
791
+ });
792
+ return /** @type {T} */ (copy);
793
+ }
794
+ return v;
795
+ } catch (err) { throw unreadable(err); }
796
+ }
797
+
798
+ /**
799
+ * BA-15 — surface a CHILD's blocker at the PARENT. A nested tree aggregates a dead child into
800
+ * `{incomplete, missingSlices}`; without this the child's `broken-sensor` (or `governance-deny`) label is
801
+ * dropped at the first parent, so a top-level caller branching on `result.blocker` sees nothing and debugs
802
+ * the model instead of its own sensor — the exact laundering BA-15 exists to close, reintroduced one level up.
803
+ * `broken-sensor` wins over `governance-deny`: it names a fault in the CALLER's own code, which is both more
804
+ * actionable and cheaper to fix than a gate decision.
805
+ *
806
+ * `blockerTask` names WHICH descendant broke. Without it a nested run reports "a sensor broke" with no way to
807
+ * find which one — half a fix. (It is also why the label is NOT stamped onto the parent's own `blocker`; see
808
+ * {@link incompleteWithBlocker}.)
809
+ *
810
+ * `'broken-verifier'` is deliberately NOT matched here: `forChild` strips `evaluate`, so a child never runs a
811
+ * caller verifier and no child node can carry that label. Matching it would be unreachable code asserting a
812
+ * capability that does not exist.
813
+ * @param {RecurseNode[]} spawned - this node's child receipts.
814
+ * @returns {{blocker: string, blockerDetail?: string, blockerTask?: string}|null}
815
+ */
816
+ function inheritedBlocker(spawned) {
817
+ const pick = spawned.find(c => c.incomplete && c.blocker === 'broken-sensor')
818
+ || spawned.find(c => c.incomplete && c.blocker);
819
+ if (!pick || !pick.blocker) return null;
820
+ return {
821
+ blocker: pick.blocker,
822
+ ...(pick.blockerDetail && { blockerDetail: pick.blockerDetail }),
823
+ ...(pick.task && { blockerTask: pick.task }),
824
+ };
825
+ }
826
+
827
+ /**
828
+ * BA-15 — the ONE shared "this node is incomplete because a descendant was" return. Used by every aggregating
829
+ * path (worker / partition / fanout) on BOTH its `missingSlices` branch AND its `HaltError` catch: the halt
830
+ * branches originally skipped the inherit entirely, so a gate tripping AFTER a broken-sensor child (e.g. mid-
831
+ * synthesize) dropped the label and the caller read a governance problem where their own sensor had crashed.
832
+ *
833
+ * The inherited label rides the RETURNED result (the caller-facing API — that surfacing is the whole point)
834
+ * but is recorded on the node as `blockerFrom`, NOT by overwriting `node.blocker`. `node.blocker` means "THIS
835
+ * node's own arbiter/Loop broke"; stamping a descendant's label onto every ancestor made the receipts tree
836
+ * accuse nodes whose sensor never ran, and re-labelled a parent `governance-deny` when only one child was
837
+ * denied — pointing the operator at the wrong node to re-gate.
838
+ * @param {RecurseNode} node
839
+ * @param {any} best
840
+ * @param {{missingSlices?: string[]}} [extra] - extra fields for the non-halt aggregation branch.
841
+ * @returns {RecurseResult}
842
+ */
843
+ function incompleteWithBlocker(node, best, extra = {}) {
844
+ const inherited = inheritedBlocker(node.spawned);
845
+ if (inherited) node.blockerFrom = inherited;
846
+ return /** @type {RecurseResult} */ ({ incomplete: true, best, ...extra, ...(inherited || {}), receipts: node });
847
+ }
848
+
612
849
  /**
613
850
  * BA-8 leaf-refine — run a DEFINITE leaf as a bounded generate→sense→regenerate loop (relayfact F17). Reuses the
614
851
  * existing `refine.js` primitive (the Outcomes iterate→grade→revise port): each attempt is a FRESH leaf Loop
@@ -665,6 +902,11 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
665
902
  // a dropped attempt is stored as `null` ("provider default"). Indexed by iteration (refine calls once each).
666
903
  /** @type {(number|null)[]} */
667
904
  const effectiveTemps = [];
905
+ // BA-15/BA-5: the last attempt's text, kept OUTSIDE refine so a broken-sensor stop can still preserve the
906
+ // model's work — when the ARBITER breaks, the work was never judged; destroying it would punish the model
907
+ // for the caller's fault (refine's own history is lost on the throw).
908
+ /** @type {string|null} */
909
+ let lastAttemptText = null;
668
910
  // One attempt = a fresh leaf Loop (no spawn tool: a retry is a direct correction, not a re-decomposition) at the
669
911
  // iteration's temperature, with the GAP fed forward as fresh feedback. A governance halt → throw so refine stops.
670
912
  const attempt = async ({ iteration, critique, history }) => {
@@ -700,15 +942,29 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
700
942
  const dropped = /** @type {{temperatureDropped?: boolean}} */ (out).temperatureDropped;
701
943
  effectiveTemps[iteration] = dropped ? null : temperature;
702
944
  accrueTokens(out.metrics ? out.metrics.tokens : null);
945
+ // BA-5: capture BEFORE the throws. The Loop returns its last non-empty text on EVERY terminating path
946
+ // (halt/deny/truncation/refusal), so a throw placed above this line would discard the text of the very
947
+ // attempt that terminated — leaving `best:null` on a FIRST-attempt halt, the exact loss the catch-branch
948
+ // preservation exists to prevent. (Caught by review: the earlier tests only halted on attempt 2, where a
949
+ // prior clean attempt had already populated this.)
950
+ lastAttemptText = out.text || lastAttemptText;
703
951
  if (typeof out.error === 'string' && out.error.startsWith('halt:')) throw new HaltError('refine-leaf attempt halted', { rule: out.error.slice('halt:'.length) });
704
952
  if (out.error) throw new Error(out.error); // a non-halt worker fault → honest incomplete
705
953
  return out.text;
706
954
  };
707
955
 
956
+ // BA-15: the sensor call is WRAPPED (via runArbiter) so a broken arbiter is NAMED, never coerced. A non-Halt
957
+ // throw (the caller's test runner crashed — ENOENT, syntax error in the harness) and a malformed return are
958
+ // the same fault class: "didn't judge", which must never collapse into "judged-and-failed" (the model's
959
+ // fault) or a bare {incomplete} (indistinguishable from a provider death). The throw stops refine at the
960
+ // FIRST broken close — retrying against a broken arbiter burns every remaining attempt for nothing (each
961
+ // retry would carry critique:null, i.e. the plain task again). HaltError passes through (governance, BA-2).
962
+ const evaluate = (result, c) => runArbiter('broken-sensor', () => sensor(result, { task, context: opts.context, contract: c.contract }));
963
+
708
964
  try {
709
965
  const outcome = await refine({
710
966
  attempt,
711
- evaluate: (result, c) => sensor(result, { task, context: opts.context, contract: c.contract }),
967
+ evaluate,
712
968
  contract: typeof opts.contract === 'string' ? opts.contract : undefined,
713
969
  maxIterations,
714
970
  });
@@ -722,9 +978,8 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
722
978
  // Optional rubric layer on top of the deterministic sensor (RC-7): forced for critical, or a contract/override.
723
979
  const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
724
980
  if (wantVerify) {
725
- const verdict = await verify(task, result, ctx, opts);
726
- node.verdict = verdict;
727
- return { result, verdict, receipts: node };
981
+ // `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
982
+ return await verifyOrBlock(task, result, ctx, opts, node);
728
983
  }
729
984
  // No rubric layer ⇒ the sensor's final verdict IS the node verdict (a non-pass is surfaced, not hidden).
730
985
  node.verdict = outcome.verdict || null;
@@ -733,23 +988,43 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
733
988
  node.tokens = tokensSum; // record whatever attempts DID spend, on both the halt and fault paths
734
989
  // The refineLeaf receipt must ride EVERY terminating path, not just the clean one (same invariant as BA-10's
735
990
  // `temperatureDropped`): a leaf that ran attempts then halted/faulted still spent tokens and may have engaged
736
- // the buffer. `effectiveTemps[iteration]` is set BEFORE each attempt's throw, so it reflects every attempt
737
- // made; `passed:false` because the catch is only reached on a throw (a pass returns from the try above).
738
- node.refineLeaf = { iterations: effectiveTemps.length, passed: false, temperatures: effectiveTemps.slice(), rejectedBuffer: bufferUsed };
991
+ // the buffer. `effectiveTemps[iteration]` is set BEFORE each attempt's throw, so it reflects every attempt made.
992
+ // NOT unconditional: the refine loop may have COMPLETED (receipt already written, possibly `passed:true`) and
993
+ // the throw come from the verify layer below it — clobbering that to `passed:false` would report a sensor that
994
+ // never closed when it did, misattributing a verify-slot halt to a non-converging leaf.
995
+ if (!node.refineLeaf) {
996
+ node.refineLeaf = { iterations: effectiveTemps.length, passed: false, temperatures: effectiveTemps.slice(), rejectedBuffer: bufferUsed };
997
+ }
998
+ // BA-5: EVERY terminating branch preserves the model's last non-empty attempt (`lastAttemptText`), never
999
+ // `null` — matching the plain-worker path (`best: out.text || null`) and the "bounds PRESERVE work"
1000
+ // invariant. A refine leaf is the sole cross-attempt text channel in a ralph-style retry; dropping it on a
1001
+ // halt/deny/fault (as this path did pre-BA-15) loses attempt N's only bridge to attempt N+1.
739
1002
  if (err instanceof HaltError) {
740
1003
  node.halted = true;
741
1004
  node.incomplete = true;
742
- return { incomplete: true, best: null, receipts: node };
1005
+ return { incomplete: true, best: lastAttemptText, receipts: node };
743
1006
  }
744
1007
  // BA-11: a deny-spin inside a refine attempt (the Loop short-circuited after N consecutive governance
745
1008
  // denials, rethrown at recurse.js as `denied:<tool>`) is a LABELED governance block, not a model fault.
746
1009
  if (typeof err?.message === 'string' && err.message.startsWith('denied:')) {
747
1010
  node.incomplete = true;
748
1011
  node.blocker = 'governance-deny';
749
- return { incomplete: true, best: null, blocker: 'governance-deny', receipts: node };
1012
+ return { incomplete: true, best: lastAttemptText, blocker: 'governance-deny', receipts: node };
1013
+ }
1014
+ // BA-15: the caller's SENSOR broke — a faulty arbiter, not a model failure. Named (like BA-11's
1015
+ // governance-deny) so the caller fixes the sensor instead of debugging the model; `best` preserves the
1016
+ // model's last non-empty attempt (BA-5) — the arbiter, not the model, is at fault.
1017
+ if (err instanceof BrokenArbiterError && err.tag === 'broken-sensor') {
1018
+ node.incomplete = true;
1019
+ node.blocker = 'broken-sensor';
1020
+ node.blockerDetail = err.detail;
1021
+ // `blockerDetail` rides the RESULT too, not just receipts: it is the actionable half of the label (WHAT
1022
+ // the sensor did), and a parent reading a child's return — or a caller branching on the documented
1023
+ // `{blocker}` shape — should not have to walk the receipts tree to get it.
1024
+ return { incomplete: true, best: lastAttemptText, blocker: 'broken-sensor', blockerDetail: err.detail, receipts: node };
750
1025
  }
751
1026
  node.incomplete = true;
752
- return { incomplete: true, best: null, receipts: node };
1027
+ return { incomplete: true, best: lastAttemptText, receipts: node };
753
1028
  }
754
1029
  }
755
1030
 
@@ -800,6 +1075,10 @@ async function recurseScan(task, ctx, opts, state) {
800
1075
  };
801
1076
  }
802
1077
 
1078
+ // BA-5: hoisted so a halt thrown BELOW the scan (e.g. from the verify slot) still returns the finished,
1079
+ // code-counted result as `best` instead of destroying it — re-running a scan re-pays every window judge call.
1080
+ /** @type {{count: number, matchedIds: string[]}|null} */
1081
+ let scanResult = null;
803
1082
  try {
804
1083
  const scan = await scanCount(task, corpus, {
805
1084
  provider,
@@ -812,6 +1091,7 @@ async function recurseScan(task, ctx, opts, state) {
812
1091
  node.scan = { window: scan.window, passes: scan.passes, scanned: scan.scanned, matched: scan.count };
813
1092
  // Structured, CODE-counted result — the count is authoritative; matchedIds carry the evidence (RC-10).
814
1093
  const result = { count: scan.count, matchedIds: scan.matchedIds };
1094
+ scanResult = result;
815
1095
 
816
1096
  // RC-9: a dead window means we did NOT see every slice → the count is a floor, not the answer. Report it
817
1097
  // incomplete with the partial as `best`, never a clean pass over a hole.
@@ -824,16 +1104,21 @@ async function recurseScan(task, ctx, opts, state) {
824
1104
  // structured count against the goal/contract (an isolated grader, never the scanner itself).
825
1105
  const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
826
1106
  if (wantVerify) {
827
- const verdict = await verify(task, result, ctx, opts);
828
- node.verdict = verdict;
829
- return { result, verdict, receipts: node };
1107
+ // `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
1108
+ return await verifyOrBlock(task, result, ctx, opts, node);
830
1109
  }
831
1110
  return { result, verdict: null, receipts: node };
832
1111
  } catch (err) {
833
1112
  if (err instanceof HaltError) {
834
1113
  node.halted = true;
835
1114
  node.incomplete = true;
836
- return { incomplete: true, best: null, receipts: node };
1115
+ // BA-5, PARTIAL by construction: a halt from the VERIFY slot lands here with the scan already finished,
1116
+ // so the counted result is returned instead of null (it previously survived only in receipts, forcing a
1117
+ // full re-scan). A halt DURING `scanCount` — the likelier case, since a token cap trips after many window
1118
+ // judge calls — still yields `null`: `scanCount` throws without surfacing the windows it did judge, so
1119
+ // there is nothing here to preserve. Fixing that needs `scanCount` to return its partial union on halt
1120
+ // (a retrieval-side change, not this seam's); tracked as a known limit rather than papered over here.
1121
+ return { incomplete: true, best: scanResult, receipts: node };
837
1122
  }
838
1123
  throw err;
839
1124
  }
@@ -891,6 +1176,8 @@ async function recursePartition(task, ctx, opts, state) {
891
1176
  // (never `undefined`) across every dispatch path.
892
1177
 
893
1178
  const childResults = [];
1179
+ /** @type {{count: number, matchedIds: string[]}|null} */
1180
+ let partitionResult = null;
894
1181
  try {
895
1182
  // 2b) Pre-wave checkpoint — width (the cost) is now known. A governance HaltError halts BEFORE any worker
896
1183
  // spends (bounds the burst to zero); a plain deny is advisory (allowlist-safe), same contract as fanout.
@@ -935,25 +1222,30 @@ async function recursePartition(task, ctx, opts, state) {
935
1222
  if (child.incomplete) missingSlices.push(label);
936
1223
  }
937
1224
  const result = { count: matched.size, matchedIds: [...matched] };
1225
+ partitionResult = result; // BA-5: a halt below this (e.g. verify) returns the full result, not a count-only rebuild
938
1226
  node.partition.matched = matched.size;
939
1227
 
940
1228
  if (missingSlices.length > 0) {
941
1229
  node.incomplete = true;
942
- return { incomplete: true, best: result, missingSlices, receipts: node };
1230
+ // BA-15: carry a child's blocker up (see inheritedBlocker).
1231
+ return incompleteWithBlocker(node, result, { missingSlices });
943
1232
  }
944
1233
  const wantVerify = critical || typeof opts.contract === 'string' || typeof opts.evaluate === 'function';
945
1234
  if (wantVerify) {
946
- const verdict = await verify(task, result, ctx, opts);
947
- node.verdict = verdict;
948
- return { result, verdict, receipts: node };
1235
+ // `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
1236
+ return await verifyOrBlock(task, result, ctx, opts, node);
949
1237
  }
950
1238
  return { result, verdict: null, receipts: node };
951
1239
  } catch (err) {
952
1240
  if (err instanceof HaltError) {
953
1241
  node.halted = true;
954
1242
  node.incomplete = true;
955
- const best = childResults.length ? { count: new Set(childResults.flatMap((v) => (v && Array.isArray(v.matchedIds) ? v.matchedIds : []))).size } : null;
956
- return { incomplete: true, best, receipts: node };
1243
+ // Prefer the finished result (a halt from the verify slot lands here with it already computed); else
1244
+ // rebuild a best-effort count from whatever slices did return (BA-5, never a bare null).
1245
+ const best = partitionResult
1246
+ || (childResults.length ? { count: new Set(childResults.flatMap((v) => (v && Array.isArray(v.matchedIds) ? v.matchedIds : []))).size } : null);
1247
+ // BA-15: a halt must not swallow a child's broken-sensor label (see incompleteWithBlocker).
1248
+ return incompleteWithBlocker(node, best);
957
1249
  }
958
1250
  throw err;
959
1251
  }
@@ -987,6 +1279,11 @@ async function recurseFanout(task, ctx, opts, state) {
987
1279
 
988
1280
  const childResults = [];
989
1281
  const contract = typeof opts.contract === 'string' ? opts.contract : null;
1282
+ // BA-5: declared OUTSIDE the try so a halt from the verify slot (which lands in the catch below, with the
1283
+ // reduce already computed and PAID FOR) returns the finished reduce instead of a lossy re-join of the raw
1284
+ // child strings — a different TYPE from the documented reduce output, and an 'merge' strategy's LLM call
1285
+ // thrown away. Mirrors `scanResult`/`partitionResult` on the sibling paths.
1286
+ let result;
990
1287
 
991
1288
  try {
992
1289
  // 1) Decompose into exactly `count` independent parallel steps (the NB-2 Planner seam). A non-Halt planner
@@ -1057,7 +1354,6 @@ async function recurseFanout(task, ctx, opts, state) {
1057
1354
  // 4) NB-3 reduce over the slice results. Unlike Family A there is no parent closing turn, so we ALWAYS
1058
1355
  // reduce: a `synthesize` FUNCTION is the deterministic code-reduce (§9.1); a string runs the built-in
1059
1356
  // reducer; unset defaults to lossless `'concat'`. (`childResults` always has `count` entries.)
1060
- let result;
1061
1357
  if (typeof opts.synthesize === 'function') {
1062
1358
  result = await opts.synthesize({ task, text: null, results: childResults, children: node.spawned, ctx });
1063
1359
  } else {
@@ -1077,24 +1373,27 @@ async function recurseFanout(task, ctx, opts, state) {
1077
1373
  // 5) Honest completeness (RC-9): any missing slice → incomplete, with the partial reduce as `best`.
1078
1374
  if (missingSlices.length > 0) {
1079
1375
  node.incomplete = true;
1080
- return { incomplete: true, best: result, missingSlices, receipts: node };
1376
+ // BA-15: carry a child's blocker up (see inheritedBlocker).
1377
+ return incompleteWithBlocker(node, result, { missingSlices });
1081
1378
  }
1082
1379
 
1083
1380
  // 6) Verify (RC-7): forced for critical, or when a contract/override is supplied.
1084
1381
  const wantVerify = critical || contract != null || typeof opts.evaluate === 'function';
1085
1382
  if (wantVerify) {
1086
- const verdict = await verify(task, result, ctx, opts);
1087
- node.verdict = verdict;
1088
- return { result, verdict, receipts: node };
1383
+ // `return await` (not a bare `return`) is load-bearing here — see verifyOrBlock's JSDoc.
1384
+ return await verifyOrBlock(task, result, ctx, opts, node);
1089
1385
  }
1090
1386
  return { result, verdict: null, receipts: node };
1091
1387
  } catch (err) {
1092
1388
  if (err instanceof HaltError) {
1093
1389
  node.halted = true;
1094
1390
  node.incomplete = true;
1095
- // best-effort partial: whatever slices we did collect, losslessly joined (no LLM the gate already tripped)
1096
- const best = childResults.length ? childResults.filter(v => v !== '').join('\n\n') : null;
1097
- return { incomplete: true, best: best || null, receipts: node };
1391
+ // BA-5: prefer the FINISHED reduce when the halt landed after it (the verify slot); only fall back to a
1392
+ // lossless join of the raw slices when the halt tripped before/during the reduce itself. Never a bare null.
1393
+ const rejoined = childResults.length ? childResults.filter(v => v !== '').join('\n\n') : null;
1394
+ const best = result !== undefined ? result : (rejoined || null);
1395
+ // BA-15: a halt must not swallow a child's broken-sensor label (see incompleteWithBlocker).
1396
+ return incompleteWithBlocker(node, best);
1098
1397
  }
1099
1398
  throw err;
1100
1399
  }
@@ -1161,6 +1460,16 @@ function buildSpawnTool(ctx, opts, depth, maxDepth, node, childResults) {
1161
1460
  // silently dropped or faked. The same declared value is collected for the NB-3 reducer.
1162
1461
  const value = child.incomplete ? (child.best == null ? '' : child.best) : (child.result == null ? '' : child.result);
1163
1462
  childResults.push(value);
1463
+ // BA-15: a child blocked by a BROKEN ARBITER must not read to the parent model as a generic failure.
1464
+ // Collapsed into a bare `[incomplete]`, the parent cannot tell "the model failed" from "the judge is
1465
+ // broken", so it re-spawns the identical subtask against the identical broken sensor — re-running a
1466
+ // full leaf attempt each time, up to the Loop's round limit. That is BA-15's own spend-burn ("retrying
1467
+ // against a broken arbiter carries zero feedback") reintroduced one level up, so the tool result says
1468
+ // so explicitly and tells the model not to retry.
1469
+ if (child.incomplete && child.blocker === 'broken-sensor') {
1470
+ const where = child.blockerTask ? ` (in sub-task: ${child.blockerTask})` : '';
1471
+ return `[blocked: broken-sensor] The CHECK that judges this subtask is itself broken${where} — ${child.blockerDetail || 'no detail available'}. Retrying will hit the same broken check: do NOT re-delegate this subtask; report it as blocked. Partial output: ${String(value)}`.trim();
1472
+ }
1164
1473
  if (child.incomplete) return `[incomplete] ${String(value)}`.trim();
1165
1474
  return String(value);
1166
1475
  },
@@ -1184,7 +1493,19 @@ function verify(task, result, ctx, opts) {
1184
1493
  // and an agentic critic needs the path to exercise the artifact. A caller `evaluate` gets the RAW task (it owns
1185
1494
  // its own context); only the default isolated grader is contextualized.
1186
1495
  if (typeof opts.evaluate === 'function') {
1187
- return Promise.resolve(opts.evaluate(result, { contract, task }));
1496
+ // BA-15 (verifier seam): the CALLER-supplied verifier is wrapped exactly like the refineLeaf sensor — a
1497
+ // non-Halt throw or a malformed return is a faulty ARBITER, tagged so the call sites label it (pre-fix a
1498
+ // throw crashed the whole run on the plain-worker path / laundered to a bare {incomplete} under refineLeaf,
1499
+ // and a garbage verdict rode a CONVERGED-shaped {result, verdict} out). The default Evaluator path below is
1500
+ // NOT wrapped: it constructs well-formed Verdicts by design, and its failures are provider-class faults.
1501
+ // Capture the narrowed reference in a const: `typeof opts.evaluate === 'function'` does NOT survive into the
1502
+ // nested async closure (TS re-widens `opts.evaluate` to possibly-undefined there → TS2722/TS18048).
1503
+ // BOUND to `opts`, because a bare `const evaluate = opts.evaluate` DETACHES the method: the call used to be
1504
+ // `opts.evaluate(...)` (receiver `opts`), and a caller passing a class method (`evaluate: grader.check`)
1505
+ // would suddenly get `this === undefined` and throw on its first `this.x` read — a working (if degraded)
1506
+ // integration flipped to a hard `broken-verifier` by an unrelated typecheck fix.
1507
+ const evaluate = opts.evaluate.bind(opts);
1508
+ return runArbiter('broken-verifier', () => evaluate(result, { contract, task }));
1188
1509
  }
1189
1510
  const provider = ctx.provider || opts.provider;
1190
1511
  const evaluator = new Evaluator({ provider });
@@ -1199,4 +1520,39 @@ function verify(task, result, ctx, opts) {
1199
1520
  );
1200
1521
  }
1201
1522
 
1523
+ /**
1524
+ * BA-15 (verifier seam) — run the verify slot, converting a `BrokenArbiterError` into a LABELED
1525
+ * `{ incomplete, blocker:'broken-verifier' }` return with `best` preserving the result the arbiter failed to
1526
+ * judge (BA-5: the work exists — best-effort, not a graded pass). One helper so all five dispatch paths (worker /
1527
+ * refineLeaf / scan / partition / fanout) get identical semantics. Anything else (HaltError, a default-
1528
+ * Evaluator provider fault) rethrows to the caller's own catch, exactly as before.
1529
+ *
1530
+ * MUST be called as `return await verifyOrBlock(...)` from inside each caller's `try` — a bare
1531
+ * `return verifyOrBlock(...)` returns the promise and exits the `try` before it settles, so a verifier
1532
+ * `HaltError` would escape the caller's own catch instead of landing as a clean `{ incomplete, halted }`
1533
+ * (proven by `poc/ba15-broken-sensor.mjs` [E4]).
1534
+ * @param {string} task
1535
+ * @param {any} result
1536
+ * @param {RecurseCtx} ctx
1537
+ * @param {RecurseOptions} opts
1538
+ * @param {RecurseNode} node
1539
+ * @returns {Promise<RecurseResult>}
1540
+ */
1541
+ async function verifyOrBlock(task, result, ctx, opts, node) {
1542
+ try {
1543
+ const verdict = await verify(task, result, ctx, opts);
1544
+ node.verdict = verdict;
1545
+ return { result, verdict, receipts: node };
1546
+ } catch (err) {
1547
+ // Only a typed BrokenArbiterError is a caller-verifier fault; everything else (HaltError, a default-
1548
+ // Evaluator provider fault) rethrows to the caller's own catch. Classified by type, never by message text.
1549
+ if (!(err instanceof BrokenArbiterError)) throw err;
1550
+ node.incomplete = true;
1551
+ node.blocker = 'broken-verifier';
1552
+ node.blockerDetail = err.detail;
1553
+ // `blockerDetail` rides the RESULT too (see the sensor-side sibling) — the actionable half of the label.
1554
+ return { incomplete: true, best: result, blocker: 'broken-verifier', blockerDetail: err.detail, receipts: node };
1555
+ }
1556
+ }
1557
+
1202
1558
  module.exports = { recurse };