Skip to content
Merged
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,60 @@

All notable changes to bare-agent are documented here. Format: [Keep a Changelog](https://keepachangelog.com/). Versioning: [SemVer](https://semver.org/).

## [0.31.0] - 2026-07-20

### Fixed

- **A broken `refineLeaf.sensor` is now a named blocker, never coerced into a model failure (BA-15).**

Adopter feedback (a forbidden-zone audit of the close chain) claimed — and a pre-fix POC confirmed in shipped code (`poc/ba15-broken-sensor.mjs`, deterministic/offline) — two silent coercions at the sensor seam:

- **A sensor that THROWS** (the caller's test runner crashed: ENOENT, harness syntax error) fell through to the bare `{ incomplete, best: null }` — byte-identical to a provider death, with the model's unjudged work destroyed. "Didn't judge" collapsed into "model failed."
- **A sensor that returns a MALFORMED verdict** (`{}`, a string, `{ok:true}` — anything without a boolean `pass` or valid tri-state `status`) read as `pass:false` with `critique:null`, so every retry re-sent the PLAIN task with **zero feedback**, burned all `maxIterations` against the broken arbiter, then surfaced as a **converged-shaped** `{ result, verdict: {} }` — an honest-looking model non-recovery pinned on a broken judge.

Now the sensor call is wrapped (recurse.js, the seam only — `refine.js` untouched): a non-Halt throw or malformed return **stops the loop at the FIRST broken close** and returns a labeled `{ incomplete, blocker: 'broken-sensor' }` plus `receipts.blockerDetail` (what the sensor did — threw with which message, or which malformed shape), with `best` preserving the model's last attempt (BA-5: the work was never judged, not judged-and-failed). Extends the BA-11 `blocker` taxonomy; a `HaltError` thrown by the sensor stays a clean governance halt; both documented verdict shapes (`{pass: boolean}` / `{status}`) behave byte-identically to before. A hung sensor remains the caller's responsibility (run untrusted checks in an isolated child process with a timeout — documented, no timeout knob).

**The same fault class was live at the verify slot** (found by a follow-up probe after the sensor fix — the initial "the Evaluator leg has no live laundering path" assessment was wrong for recurse's *caller* seam): a **throwing** caller `opts.evaluate` **crashed the whole `recurse()` run** as an uncaught exception on the plain-worker path (and laundered to a bare `{ incomplete }` under `refineLeaf`), while a **garbage** return rode out **converged-shaped** as `{ result, verdict: {} }`. The caller verifier is now wrapped identically (`blocker: 'broken-verifier'`, first broken close stops, `best` preserves the unjudged result) via one `verifyOrBlock` helper across all five dispatch paths (worker / refineLeaf / scan / partition / fanout). The **default Evaluator rubric path is deliberately not labeled** (it constructs well-formed Verdicts; its failures are provider-class faults — narrowest guard). En route the POC's Halt-control arm caught a real regression in the refactor itself: `return verifyOrBlock(...)` inside a `try` let a verifier `HaltError` escape the catch (a promise returned un-awaited exits the try before settling) — fixed with `return await` + a dedicated regression test.

Both seams run through one `runArbiter(tag, call)` helper and classify on a **typed** module-local `BrokenArbiterError` (`instanceof` + `.tag`/`.detail`) rather than re-parsing a `'broken-sensor: '` prefix out of `Error.message` — so an intermediate layer that rewords a message (the class of the prior loop.js HaltError-wrapping bug) or a provider error whose text happens to start with the prefix can never be mis-labeled.

Fixed in the same pass, found by a `/code-review` workflow whose findings I re-verified by hand after 7 of its 16 agents died on a spend limit (a partial verdict is not a clean bill):
- **`npm run typecheck` was FAILING** (TS2722/TS18048): the caller-verifier wrap was an async IIFE, and `typeof opts.evaluate === 'function'` does not narrow inside a nested closure. My earlier "typecheck clean" claim was wrong — the check was run with `&& echo` and I did not notice the echo never fired. CI-gating and publish-blocking; now hoisted to a narrowed const, verified clean by exit code.
- **A status-only verdict burned every iteration.** The advertised contract accepts `{status:'satisfied'}`, but `refine.js` stops on `verdict.pass` — so a satisfied status-only close never stopped, ran all `maxIterations`, and reported `passed:false`. `runArbiter` now derives `pass = status === 'satisfied'` (a copy, never mutating the caller's object), matching `evaluator.js`.
- **BA-5 violation on sibling branches.** `refineLeaf`'s HaltError / governance-deny / generic-fault returns were `best: null` while the plain-worker path preserved `best: out.text`; all now return the last non-empty attempt, so a bounded retry never loses attempt N's only bridge to N+1.
- **A detached JSDoc block** (the new helpers were inserted between `recurseRefineLeaf`'s doc comment and the function) silently dropped that ~160-line function's `@param` typing; helpers moved above the doc.
- Plus a defensive `instanceof HaltError` rethrow in `verifyOrBlock`, and the five duplicated "`await` is load-bearing" comments consolidated onto `verifyOrBlock`'s JSDoc.

A second review round (a clean 19/19-agent run) then caught four more, including a regression in the first round's own BA-5 fix:
- **The BA-5 preservation did not cover the attempt that actually terminated.** `lastAttemptText` was captured *after* the halt/error throws, so a **first-attempt** halt discarded its own text and still returned `best: null` — while the round-1 test only halted on attempt 2, where a prior clean attempt had already populated it. Capture now precedes the throws; mutation-proven.
- **`recurseScan`/`recursePartition` destroyed a finished result on a verify-slot halt.** Making `verifyOrBlock` `return await` (round 1) routed a verifier `HaltError` into their catches, which returned `best: null` — throwing away a fully code-counted scan and forcing a re-run that re-pays every window judge call. Both now preserve the computed result.
- **A child's blocker was laundered by its parent.** In a nested tree the parent aggregates a dead child into `{incomplete, missingSlices}` and dropped the child's `broken-sensor` label, so a top-level caller branching on `result.blocker` saw nothing — BA-15's own failure mode, one level up. A shared `inheritedBlocker` now carries it at all three aggregation sites (`broken-sensor` outranks `governance-deny`, being a fault in the caller's own code).
- **A verify-slot halt after a passing sensor clobbered the receipt** to `passed: false`, reporting a deterministic close that never happened.
Plus: the verdict shape inspection moved inside `runArbiter`'s try (a throwing accessor on a returned Proxy escaped untyped), and `BrokenArbiterError` now carries `cause`.

A **third** review round (a clean 40/40-agent run at a deeper setting) found ten more — every one reproduced against the pre-fix branch by `poc/ba15-round3-validate.mjs` before being touched, and every fix mutation-proven. Three were regressions **this branch introduced**, and four were BA-15's own anti-laundering guarantee failing at a boundary it did not cover:

- **A prototype-backed verdict was silently gutted.** The status-only normalization rebuilt the verdict with an object spread, which copies own enumerable properties only — so a class-instance verdict whose `status`/`critique` are prototype getters passed the shape check and came out with those fields **erased**. The critique then never reached the retry prompt, meaning every iteration re-sent the plain task with zero feedback: the exact burn BA-15 exists to prevent, reintroduced for a shape the validator explicitly blessed. Now copied via property descriptors onto the same prototype.
- **A truthy non-boolean `pass` was rejected as a broken arbiter.** Demanding a strict boolean turned a sensor returning `{pass: 1}` — a long-standing yes/no convention that `refine` has always evaluated for truthiness — into a permanent first-attempt block for a previously **converging** integration. A present `pass` now counts whatever its type; BA-15's job is a verdict carrying *no* usable signal, not one answering clearly in another dialect.
- **A method-reference verifier hard-failed.** Round 1's typecheck fix captured `opts.evaluate` into a bare local, detaching it: `evaluate: grader.check` went from `this === opts` (degraded but working) to `this === undefined` (throws on the first field read → `broken-verifier`). Re-bound to `opts`, restoring the prior behavior exactly. Note the limit that restores: `this` is the options object, never the grader — a caller wanting its own receiver must bind.
- **The halt paths dropped an inherited blocker.** `inheritedBlocker` was wired into the three `missingSlices` branches but not the three `HaltError` catches, so a gate tripping *after* a broken-sensor child (mid-synthesize, say) still reported a bare `{incomplete}`. All six paths now route through one `incompleteWithBlocker` helper — which also removes the 3× copy-pasted block whose next edit would have missed a path.
- **The spawn boundary flattened a blocked child into a generic failure.** `[incomplete] <text>` gave the parent model no way to tell "the model failed" from "the judge is broken", so it could re-delegate the same subtask into the same broken sensor, re-running a full leaf attempt each time up to the round limit — BA-15's own spend-burn, one level up. The tool result now names the blocker and tells the model not to retry.
- **A blocker was stamped onto every ancestor.** `Object.assign(node, inherited)` overwrote each parent's own `blocker`, so the receipts tree accused nodes whose sensor never ran, and a single denied child re-labelled its parent `governance-deny` — pointing the operator at the wrong node to re-gate. The inherited label now rides `receipts.blockerFrom` plus a new `blockerTask` naming the culprit, while `blocker` keeps meaning "*this* node's own arbiter broke".
- **`recurseFanout` discarded a computed reduce on a verify-slot halt** — the BA-5 hoist given to `recurseScan`/`recursePartition` in round 2 was never applied to the third path, so a halt threw away the finished reduce (and any `'merge'` strategy's paid-for LLM call) in favour of a raw re-join of child strings.
- **`blockerDetail` never rode the result**, only `receipts` — the actionable half of the label required walking the audit tree.
- **Diagnostics:** a non-`Error` throw (a harness rejecting with `{code:'ENOENT', path}`) rendered as `[object Object]`, naming nothing; and a verdict whose *accessor* throws was reported as "the sensor threw", sending the operator hunting a `throw` in a sensor that returned normally. The two faults are now distinguished around the `await` itself — which probes `.then` and so triggers the accessor before any shape check runs.
- **An unreachable branch was documented as working.** `inheritedBlocker` matched `'broken-verifier'`, but `forChild` strips `evaluate`, so no child can ever carry that label. Dead branch removed and this entry corrected — the claim above now reads `broken-sensor` only.

A `/security` pass on the round-3 changes then caught a leak the round-3 fix had itself introduced: describing a non-`Error` throw via `JSON.stringify` dumped the **whole** thrown object into `blockerDetail`, and a thrown non-`Error` is typically a spawn/exec result carrying a full stdout buffer and an env snapshot. Since `blockerDetail` rides into `receipts` — which a wired gate serializes **verbatim into a plaintext audit log** — that wrote caller secrets to disk (measured: a 200 KB detail string containing an `sk-live-…` token; the same leak class as F16/BA-1's audit-ctx provider strip). `describeThrown` now takes only conventional diagnostic fields (`name`/`code`/`errno`/`syscall`/`path`/`status`/`signal`/`message`), clamped to 200 chars — still naming the fault (`code=ENOENT`) without dumping the payload. Prototype pollution via the new descriptor-copy path was probed and is clean.

**Known limit (documented, not fixed):** a `HaltError` raised *during* `scanCount` — the likelier halt, since a token cap trips after many window judge calls — still returns `best: null`. `scanCount` throws without surfacing the windows it already judged, so there is nothing at the recurse seam to preserve; fixing it is a retrieval-side change. The comment that previously implied BA-5 coverage here has been corrected rather than left to mislead.

**Behavior change for adopters:** `opts.evaluate` is now held to its documented `Verdict` return. A verifier returning a loose object (`{score, critique}`) or a bare boolean — always a contract violation, previously silent — now yields `{incomplete, blocker:'broken-verifier'}` instead of riding out as a converged `{result, verdict}`. That is the point of the change (a converged shape carrying an ungradeable verdict is the laundering being closed), but it is a real break for callers who relied on the loose path; no shim is provided, since one would reopen the hole. A verdict carrying a **truthy non-boolean** `pass` is *not* affected — that shape is accepted (see the third round above).

**Known open (pre-existing, not from this change):** `instanceof HaltError` is realm-sensitive — a `HaltError` from a different module realm (duplicate install, VM context) would not match, at 23 sites that predate this work. Documented rather than force-fixed.

New public surfaces (MINOR): `blocker: 'broken-sensor' | 'broken-verifier'` and `blockerDetail` on `RecurseResult`; `blockerTask` on `RecurseResult` when the blocker was inherited from a descendant; `receipts.blockerDetail` and `receipts.blockerFrom`. +29 mutation-checked tests; full suite **890 pass / 0 fail**, typecheck clean (verified by exit code). Pre-fix evidence: `poc/ba15-broken-sensor.mjs` (rounds 1–2) and `poc/ba15-round3-validate.mjs` (round 3 — reproduces each finding against the unfixed branch, so a post-fix re-run flips them).

## [0.30.0] - 2026-07-16

### Added
Expand Down
Loading