bare-agent 0.28.0 → 0.30.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/README.md CHANGED
@@ -94,7 +94,7 @@ Every piece works alone — take what you need, ignore the rest. Two axes: **Act
94
94
 
95
95
  ### Recurse — break a hard task into a tree *(the RLM primitive)*
96
96
 
97
- `recurse(task, ctx, opts)` does **decompose → fan-out → verify → synthesize** in one call — Recursive Language Models as a single import, composed *around* the Loop (never a new engine). The default is **model-driven**: the worker is handed a `spawn_child` tool and decides whether to split, bounded by depth + bareguard (no second guard layer). Forced fan-out (`count` / `mode:'fanout'`) and data-driven width (`mode:'partition'`, measured from a corpus) are opt-in. Give workers a stance with `opts.persona` (prepended to every worker, carries down the tree, deliberately kept out of the isolated verifier), and tell them *where they are* with `opts.context` (a read-only paths/cwd blob threaded to every worker so a sliced child can locate its artifact — facts, not a stance). For a leaf that should self-correct, pass `opts.refineLeaf` (opt-in): a definite leaf becomes a bounded generate→sense→regenerate loop driven by *your* deterministic sensor (test/compile/lint), feeding the gap back (with escalating temperature on models that accept it; on a temperature-fixed model like `claude-sonnet-5` the gap critique carries recovery, and the receipt records the effective temps). The headline guarantee: **aggregation is code, never a model-stated number**, and a dead worker or exhausted guard returns an honest `{ incomplete, missingSlices }` — never a faked pass.
97
+ `recurse(task, ctx, opts)` does **decompose → fan-out → verify → synthesize** in one call — Recursive Language Models as a single import, composed *around* the Loop (never a new engine). The default is **model-driven**: the worker is handed a `spawn_child` tool and decides whether to split, bounded by depth + bareguard (no second guard layer). Forced fan-out (`count` / `mode:'fanout'`) and data-driven width (`mode:'partition'`, measured from a corpus) are opt-in. Give workers a stance with `opts.persona` (prepended to every worker, carries down the tree, deliberately kept out of the isolated verifier), and tell them *where they are* with `opts.context` (a read-only paths/cwd blob threaded to every worker so a sliced child can locate its artifact — facts, not a stance). For a leaf that should self-correct, pass `opts.refineLeaf` (opt-in): a definite leaf becomes a bounded generate→sense→regenerate loop driven by *your* deterministic sensor (test/compile/lint — one that judges the *returned* result, never a worker side-effect it could game), feeding the gap back (with escalating temperature on models that accept it; on a temperature-fixed model like `claude-sonnet-5` the gap critique carries recovery, and the receipt records the effective temps). On a temperature-fixed model, `refineLeaf.rejectedBuffer` adds a second lever — it feeds the model's own prior *failed attempts* back verbatim ("write something structurally different"), the directed-diversity complement to temperature's random diversity (adaptive by default; the two are antagonistic, so it holds temperature flat when it engages). The headline guarantee: **aggregation is code, never a model-stated number**, and a dead worker or exhausted guard returns an honest `{ incomplete, missingSlices }` — never a faked pass.
98
98
 
99
99
  Over a corpus, context reaches a worker as a **handle routed by question shape** (`opts.retrieval`):
100
100
 
@@ -161,6 +161,7 @@ const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate, {
161
161
  if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx };
162
162
  if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx };
163
163
  if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope
164
+ if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write
164
165
  return defaultActionTranslator(toolName, args, ctx);
165
166
  },
166
167
  });
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.28.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.30.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -38,7 +38,7 @@ Eight entry points:
38
38
  | Count / answer "how many / all" over a corpus, honestly | recurse(task, ctx, `{ corpus, retrieval: 'scan' }`) — scans every slice, CODE-counts |
39
39
  | Give recurse workers a persona/role (senior-dev stance) | recurse(task, ctx, `{ persona }`) — prepended to every worker, carries down the tree; not applied to the verifier |
40
40
  | Tell recurse workers WHERE they are (paths/cwd) so a slice can find its file | recurse(task, ctx, `{ context }`) — read-only blob on every worker's task message + the Planner + verifier; carries down (facts, not a stance) |
41
- | Let a recurse LEAF retry its own failure with a deterministic check | recurse(task, ctx, `{ refineLeaf: { sensor } }`) — leaf becomes a bounded generate→sense→regenerate loop; your sensor (test/compile/lint), gap fed back, escalating temperature |
41
+ | Let a recurse LEAF retry its own failure with a deterministic check | recurse(task, ctx, `{ refineLeaf: { sensor } }`) — leaf becomes a bounded generate→sense→regenerate loop; your sensor (test/compile/lint) judges the RETURNED result, gap fed back, escalating temperature; `rejectedBuffer` feeds prior failed attempts back on a temp-fixed model |
42
42
  | Track task state (pending/running/done/failed) | StateMachine |
43
43
  | Run agent turns on a schedule (cron, timers) | Scheduler |
44
44
  | Require human approval before dangerous actions | Checkpoint |
@@ -65,7 +65,8 @@ Eight entry points:
65
65
  | Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
66
66
  | Control Android/iOS devices | createMobileTools + Loop |
67
67
  | Control mobile (token-efficient, disk-based) | `baremobile` CLI session — snapshots to `.baremobile/*.yml` |
68
- | Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/run/exec) + Loop({ policy }) — gate `shell_write` via `fs.writeScope` with an actionTranslator |
68
+ | Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/**edit**/run/exec) + Loop({ policy }) — gate `shell_write`/`shell_edit` via `fs.writeScope` with an actionTranslator |
69
+ | Change one span of a big file without re-emitting the whole thing | `shell_edit({path, oldText, newText})` — anchored exact-once replace; the surgical alternative to whole-file `shell_write` (BA-13). Gate it as `{type:'edit'}` |
69
70
  | Auto-discover MCP servers from IDE configs | createMCPBridge |
70
71
  | Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
71
72
  | Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
@@ -423,6 +424,7 @@ const { policy, onToolResult } = wireGate(gate, {
423
424
  if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
424
425
  if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
425
426
  if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope (reads args.path)
427
+ if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write (reads args.path)
426
428
  return { type: toolName, args, _ctx: ctx }; // fall through to defaultActionTranslator
427
429
  },
428
430
  });
@@ -706,7 +708,11 @@ const out = await recurse('Audit auth.js, billing.js, gateway.js for authz bugs'
706
708
 
707
709
  **Worker context (`opts.context`, v0.23.0):** a read-only working-context string (paths/cwd) PREPENDED to every worker's TASK message as a `Working context:` block — so a sliced child can **locate its artifact** (the Planner paraphrases the goal into subtasks and drops absolute paths; without this, workers guess `.`/`~`/`/tmp` and get denied). Forwarded to the Planner as `info` (path-aware slices) and shown to the verifier too (neutral FACTS, not a stance — distinct from `persona`, which is a privileged SYSTEM-prompt stance). Carries down the tree. **Security:** it still becomes part of the prompt, so pass caller-trusted run-state only, never untrusted/end-user text (lower-privilege than `persona` — user message, not system — but still an injection surface). Absent ⇒ the task message is unchanged.
708
710
 
709
- **Leaf self-correction (`opts.refineLeaf`, v0.23.0, opt-in):** turn a **definite leaf** (a node offered no `spawn_child` — `simple` tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass: `{ sensor, maxIterations?, temperatures? }`. `sensor(result, { task, context, contract }) → Verdict` is YOUR **deterministic** close (test/compile/lint — not a model judge); on a non-pass its `critique` (the gap, not the transcript) is fed FRESH into the next attempt and — **on models that accept `temperature`** — the **retry temperature ESCALATES** (default `[0.2, 0.7, 1.0]` — load-bearing there: a weak model at a flat temperature regenerates identical wrong code and ignores even crisp feedback). On a **temperature-fixed model** (e.g. `claude-sonnet-5`, which 400s any non-default temperature) the provider silently drops the param (see below), the escalation lever is inert, and the fed-back gap critique carries recovery alone; `receipts.refineLeaf.temperatures` then records the EFFECTIVE temps — a `null` marks an attempt that ran at the model's default (never the ignored requested value). Each attempt is gate-checked + metered; a HaltError mid-loop → clean `{ incomplete }`; honest non-recovery → `receipts.refineLeaf.passed === false` (never a faked pass); `receipts.tokens` sums all attempts. The error-keyed `recall` stays YOUR tool (`opts.tools`), keyed off the fed-back critique — bareagent stays litectx-agnostic. Carries down (engages at the leaves). Absent ⇒ a leaf is a single pass.
711
+ **Leaf self-correction (`opts.refineLeaf`, v0.23.0, opt-in):** turn a **definite leaf** (a node offered no `spawn_child` — `simple` tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass: `{ sensor, maxIterations?, temperatures?, rejectedBuffer? }`. `sensor(result, { task, context, contract }) → Verdict` is YOUR **deterministic** close (test/compile/lint — not a model judge); on a non-pass its `critique` (the gap, not the transcript) is fed FRESH into the next attempt and — **on models that accept `temperature`** — the **retry temperature ESCALATES** (default `[0.2, 0.7, 1.0]` — load-bearing there: a weak model at a flat temperature regenerates identical wrong code and ignores even crisp feedback). On a **temperature-fixed model** (e.g. `claude-sonnet-5`, which 400s any non-default temperature) the provider silently drops the param (see below), the escalation lever is inert, and the fed-back gap critique carries recovery alone; `receipts.refineLeaf.temperatures` then records the EFFECTIVE temps — a `null` marks an attempt that ran at the model's default (never the ignored requested value). Each attempt is gate-checked + metered; a HaltError mid-loop → clean `{ incomplete }`; honest non-recovery → `receipts.refineLeaf.passed === false` (never a faked pass); `receipts.tokens` sums all attempts. The error-keyed `recall` stays YOUR tool (`opts.tools`), keyed off the fed-back critique — bareagent stays litectx-agnostic. Carries down (engages at the leaves). Absent ⇒ a leaf is a single pass.
712
+
713
+ > **Sensor integrity (`refineLeaf.sensor`):** the sensor must judge the **returned result** (tamper-proof — build/run the returned string in isolation), never a worker **side-effect** a worker with edit tools could game (writing a passing file then returning junk, or editing the failing test itself). The loop optimizes against whatever the sensor reads — keep the close outside what the worker can write. (RSI field lesson: reward-hacking appeared in every optimization loop with a gameable close.)
714
+
715
+ > **`rejectedBuffer` (BA-14, v0.30.0):** a second lever for a **temperature-fixed** model, where escalation is inert. Instead of only the latest critique, it surfaces the model's OWN prior failed attempts VERBATIM — *"you wrote these, they failed X — write something STRUCTURALLY DIFFERENT."* This is **directed** diversity (attack the specific repeated mistake); escalation is **random** diversity, and the two are **antagonistic** — temperature monotonically degrades the buffer (`poc/ba14b`: flat-0.2 100% → 0.7 70% → 1.0 50%), so when the buffer engages the retry temperature is **held flat** at `temperatures[0]`, never escalated. Trigger: `true` = force on (also on temperature-accepting models); `false` = force off (pure BA-8 escalation); **unset = adaptive** — engage only once a prior attempt's temperature was dropped (i.e. a temp-fixed model where escalation is inert and the buffer is the sole lever). On a temperature-accepting model the default leaves behavior byte-identical. `receipts.refineLeaf.rejectedBuffer` reports whether it engaged. Efficacy is a **weak-model / fixation** phenomenon (live on `claude-sonnet-5` it engaged 6/6 but recovered no better than critique-only — cost-neutral, hence adaptive-not-always-on).
710
716
 
711
717
  ```javascript
712
718
  const out = await recurse('Fix the failing function in calc.js', ctx, {
@@ -1247,6 +1253,7 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1247
1253
  |---|---|
1248
1254
  | `shell_read` | Read a file (utf8, 256KB cap) or list a directory (tab-separated). `~` expands to home. |
1249
1255
  | `shell_write` | Write (or `append:true`) UTF-8 text to a file, creating parent dirs. 5MB cap. No shell, so it gates cleanly through `fs.writeScope` once translated to `{type:'write'}`. **`content` is REQUIRED** — see the truncation guard below. |
1256
+ | `shell_edit` | Anchored exact-string replace — the **surgical** alternative to whole-file `shell_write` (BA-13). `{path, oldText, newText}`: `oldText` must occur **exactly once** (quote surrounding lines to be unique); `newText` is spliced in **verbatim** (`""` deletes). Returns a compact `edited <path>: 1 replacement` receipt, never the body. 0 or 2+ matches → a refusal **returned as the tool result** (the model re-anchors; file untouched). Gates through `fs.writeScope` translated to `{type:'edit'}` — see the note below. |
1250
1257
  | `shell_grep` | JavaScript regex search across files. Walks directories, skips binary files, returns `{hits: [{file, line, text}], truncated, fileCount}`. |
1251
1258
  | `shell_run` | Run a command with an **argv array** via `child_process.execFile` (no shell, no metacharacter interpretation). Returns `{stdout, stderr, code, timedOut}`. **Use this when you need a policy allowlist.** |
1252
1259
  | `shell_exec` | Run a raw shell command string via `/bin/sh -c` (or `cmd.exe`). Returns the same shape. **Shell metacharacters are interpreted — naive allowlists are bypassable.** Use only when you genuinely need shell features (pipes, redirects, globs). |
@@ -1255,6 +1262,8 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1255
1262
 
1256
1263
  > **⚠️ `shell_write` requires `content` — and a gate cannot cover for it (v0.27+).** `content` used to default to `''`, so a tool call that OMITTED it silently overwrote the target with **zero bytes** and returned `"wrote 0 bytes to <path>"` as success. That is the ordinary shape of a model hitting its **output-token cap** mid-generation on a long file — observed live emptying a 1789-line source file. **No policy can catch it:** a 0-byte write is a *legal* write, and bareguard's `fs` primitive judges `{type:'write', path}` without inspecting the body (the gate correctly `allow`s it). `shell_write` now **rejects** an absent, `null`, or non-string `content` and leaves the file byte-identical; the error tells the model to retry with the full content. An explicit `content: ""` still empties the file — that one is deliberate.
1257
1264
 
1265
+ > **`shell_edit` — the surgical write (BA-13).** Changing one line of an 800-line file with `shell_write` forces the model to re-emit **all 800 lines** as tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every revision, and the maximal broken-tree surface (a truncated rewrite mangles the lines it never meant to touch — the BA-4/BA-6 class). `shell_edit({path, oldText, newText})` emits only the anchor + replacement. Semantics worth knowing: `oldText` must match **exactly once** (a 0/2+ match is a refusal *returned as the tool result*, so the loop continues and the model widens the anchor — not a throw, so a repeated-identical miss is bounded by maxTurns/budget, not the spin guard); missing/empty `oldText` or missing/non-string `newText` **throw** (BA-4 guards; `newText:""` is a legal deletion); the write is **atomic** (sibling temp + rename, mode preserved) so an fs failure never leaves a partial file; and `newText` is a **literal splice** (a `$&`/`$1` in it lands verbatim, unlike `String.replace`). Gate it exactly like `shell_write` but as `{type:'edit'}` — **bareguard gates `edit` by the same `fs.writeScope` as `write` with zero config** (its FS primitive's `FS_TYPES` already includes `edit`).
1266
+
1258
1267
  > **⚠️ `shell_exec` injection caveat.** `"ls"` passes a base-command allowlist like `args.command.split(/\s+/)[0]`, but so does `"ls;rm -rf /tmp/x"` — the shell runs both. **A base-command allowlist is NOT safe for `shell_exec`.** For policy-gated use, prefer `shell_run({argv})` and allow-list on `args.argv[0]` — there is no shell in that path, so metacharacters are just literal argument bytes. Use `shell_exec` only when the agent needs pipes/redirects/globs, and gate it at a higher level (human approval, narrow intent).
1259
1268
 
1260
1269
  ```javascript
@@ -60,6 +60,8 @@ const actionTranslator = (toolName, args, ctx) => {
60
60
  case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
61
61
  // shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
62
62
  case 'shell_write': return { type: 'write', path: args?.path, args, _ctx: ctx ?? null };
63
+ // shell_edit is an anchored edit — bareguard gates {type:'edit'} by the SAME fs.writeScope as write.
64
+ case 'shell_edit': return { type: 'edit', path: args?.path, args, _ctx: ctx ?? null };
63
65
  default: return { type: toolName, args, _ctx: ctx ?? null };
64
66
  }
65
67
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.js CHANGED
@@ -701,6 +701,11 @@ class Loop {
701
701
  const generate = () => this.provider.generate(toSend, activeTools, options);
702
702
  result = this.retry ? await this.retry.call(generate) : await generate();
703
703
  } catch (err) {
704
+ // A HaltError is a governance exit, not a provider failure — re-throw it (like every other seam) so it
705
+ // reaches the outer handler, which seals dangling tool_calls and returns error:`halt:<rule>`. Without
706
+ // this, throwOnError:false laundered a provider-surfaced HaltError into a generic `error:<message>`,
707
+ // indistinguishable from a real fault (the retry never retries it — DEFAULT_RETRY_ON is false for it).
708
+ if (err instanceof HaltError) throw err;
704
709
  this._reportError('provider', err, { round });
705
710
  if (this.throwOnError) throw err;
706
711
  // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
package/src/recurse.d.ts CHANGED
@@ -94,13 +94,29 @@ export type RecurseOptions = {
94
94
  * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
95
95
  * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
96
96
  * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
97
- * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
97
+ * `null`, and the fed-back gap critique carries recovery alone UNLESS the rejected-attempt buffer engages
98
+ * (below). `maxIterations` defaults to
98
99
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
99
100
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
100
101
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
101
102
  * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
102
103
  * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
103
104
  * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
105
+ * **Sensor integrity (RSI-learnings #1/#5, "audit the close"):** the `sensor` MUST judge the RETURNED result
106
+ * (tamper-proof — e.g. build/run the returned string in an isolated context, as `poc/ba8-leaf-refine.mjs` does),
107
+ * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
108
+ * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
109
+ * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
110
+ * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
111
+ * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
112
+ * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
113
+ * is RANDOM diversity; the two are ANTAGONISTIC (`poc/ba14b-temp-with-buffer.mjs`: temperature monotonically
114
+ * degrades the buffer, 100%→70%→50% across 0.2/0.7/1.0), so when the buffer engages the retry temperature is
115
+ * HELD at `temperatures[0]` (flat-low), never escalated. `true` = force on (also on temperature-accepting
116
+ * models); `false` = force off (pure BA-8 escalation); UNSET = ADAPTIVE — engage only once a prior attempt's
117
+ * temperature was dropped (a temperature-fixed model, BA-10, where escalation is inert and the buffer is the
118
+ * sole lever — `poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). `receipts.refineLeaf.rejectedBuffer`
119
+ * reports whether any iteration injected it. Bounded by `maxIterations` (the buffer never outgrows it).
104
120
  */
105
121
  refineLeaf?: {
106
122
  sensor: (result: any, ctx: {
@@ -110,6 +126,7 @@ export type RecurseOptions = {
110
126
  }) => (Verdict | Promise<Verdict>);
111
127
  maxIterations?: number;
112
128
  temperatures?: number[];
129
+ rejectedBuffer?: boolean;
113
130
  } | undefined;
114
131
  /**
115
132
  * - Definition of done (A3). When present, the verifier grades against THIS,
@@ -225,15 +242,20 @@ export type RecurseNode = {
225
242
  */
226
243
  tokens: object | null;
227
244
  /**
228
- * - (BA-8) when this
229
- * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
245
+ * - (BA-8) when
246
+ * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
230
247
  * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
231
248
  * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
249
+ * `rejectedBuffer` (BA-14): whether any iteration injected the rejected-attempt buffer (prior failed attempts
250
+ * surfaced verbatim); when it engages the REQUESTED retry temperature is held flat at `temperatures[0]`, never
251
+ * escalated (ba14b antagonism) — but a temperature-fixed model still drops it, so the EFFECTIVE temp recorded
252
+ * above is `null`, not `temperatures[0]`.
232
253
  */
233
254
  refineLeaf?: {
234
255
  iterations: number;
235
256
  passed: boolean;
236
257
  temperatures: (number | null)[];
258
+ rejectedBuffer: boolean;
237
259
  } | undefined;
238
260
  model: string | null;
239
261
  /**
@@ -362,7 +384,7 @@ export type Slice = {
362
384
  * TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
363
385
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
364
386
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
365
- * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
387
+ * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[], rejectedBuffer?: boolean}} [refineLeaf]
366
388
  * (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
367
389
  * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
368
390
  * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
@@ -371,13 +393,29 @@ export type Slice = {
371
393
  * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
372
394
  * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
373
395
  * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
374
- * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
396
+ * `null`, and the fed-back gap critique carries recovery alone UNLESS the rejected-attempt buffer engages
397
+ * (below). `maxIterations` defaults to
375
398
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
376
399
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
377
400
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
378
401
  * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
379
402
  * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
380
403
  * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
404
+ * **Sensor integrity (RSI-learnings #1/#5, "audit the close"):** the `sensor` MUST judge the RETURNED result
405
+ * (tamper-proof — e.g. build/run the returned string in an isolated context, as `poc/ba8-leaf-refine.mjs` does),
406
+ * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
407
+ * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
408
+ * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
409
+ * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
410
+ * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
411
+ * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
412
+ * is RANDOM diversity; the two are ANTAGONISTIC (`poc/ba14b-temp-with-buffer.mjs`: temperature monotonically
413
+ * degrades the buffer, 100%→70%→50% across 0.2/0.7/1.0), so when the buffer engages the retry temperature is
414
+ * HELD at `temperatures[0]` (flat-low), never escalated. `true` = force on (also on temperature-accepting
415
+ * models); `false` = force off (pure BA-8 escalation); UNSET = ADAPTIVE — engage only once a prior attempt's
416
+ * temperature was dropped (a temperature-fixed model, BA-10, where escalation is inert and the buffer is the
417
+ * sole lever — `poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). `receipts.refineLeaf.rejectedBuffer`
418
+ * reports whether any iteration injected it. Bounded by `maxIterations` (the buffer never outgrows it).
381
419
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
382
420
  * not the loose task, and verification always runs.
383
421
  * @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
@@ -438,10 +476,14 @@ export type Slice = {
438
476
  * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
439
477
  * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
440
478
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
441
- * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
442
- * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
479
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[], rejectedBuffer: boolean}} [refineLeaf] - (BA-8) when
480
+ * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
443
481
  * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
444
482
  * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
483
+ * `rejectedBuffer` (BA-14): whether any iteration injected the rejected-attempt buffer (prior failed attempts
484
+ * surfaced verbatim); when it engages the REQUESTED retry temperature is held flat at `temperatures[0]`, never
485
+ * escalated (ba14b antagonism) — but a temperature-fixed model still drops it, so the EFFECTIVE temp recorded
486
+ * above is `null`, not `temperatures[0]`.
445
487
  * @property {string|null} model
446
488
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
447
489
  * or null/absent for a plain reasoning node.
package/src/recurse.js CHANGED
@@ -216,7 +216,7 @@ function auditSafeCtx(ctx, overrides = {}) {
216
216
  * TRUSTED run-state (paths/cwd); do NOT pass untrusted / end-user-controlled text here.
217
217
  * @property {ToolDef[]} [tools] - Handle tools offered to EVERY worker (RC-5 pull-default: litectx
218
218
  * `recall`/`get`, wired at build step 7). Workers query on demand; never the whole corpus.
219
- * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[]}} [refineLeaf]
219
+ * @property {{sensor: (result: any, ctx: {task: string, context: string|undefined, contract: string|null}) => (Verdict|Promise<Verdict>), maxIterations?: number, temperatures?: number[], rejectedBuffer?: boolean}} [refineLeaf]
220
220
  * (Opt-in, BA-8 / relayfact F17) Turn a DEFINITE LEAF (a node that is offered no `spawn_child` — `simple`
221
221
  * tier or at `maxDepth`) into a bounded generate→sense→regenerate loop instead of a single pass, so a failed
222
222
  * slice can self-correct. `sensor` is a DETERMINISTIC close (test/compile/lint — NOT a model judge, R-S8) that
@@ -225,13 +225,29 @@ function auditSafeCtx(ctx, overrides = {}) {
225
225
  * (`temperatures`, default `[0.2,0.7,1.0]`) — the live-validated lever that lets a weak model escape a
226
226
  * repeat-the-same-mistake rut (`poc/ba8-leaf-refine.mjs`: 0/5 → 2-3/5; flat temp recovers 0/5). On a
227
227
  * temperature-fixed model (BA-10) the provider drops the param, `receipts.refineLeaf.temperatures` records
228
- * `null`, and the fed-back gap critique carries recovery alone. `maxIterations` defaults to
228
+ * `null`, and the fed-back gap critique carries recovery alone UNLESS the rejected-attempt buffer engages
229
+ * (below). `maxIterations` defaults to
229
230
  * `temperatures.length`; the REAL bound is bareguard (each attempt is gate-checked + metered). CARRIES DOWN the
230
231
  * tree (preserved by `forChild`), so it engages at the leaves of a Family-A decomposition. Recovery is PARTIAL
231
232
  * (a stubborn blind spot may persist) — `receipts.refineLeaf.passed` reports honestly. Does NOT apply to a node
232
233
  * that delegates (its children + the tree verify own quality), nor to the scan/fanout/partition dispatch paths.
233
234
  * Absent ⇒ a leaf is a single pass (byte-identical to pre-BA-8). An error-keyed `recall` is the CALLER's tool
234
235
  * (`opts.tools`) keyed off the fed-back critique — bareagent stays litectx-agnostic.
236
+ * **Sensor integrity (RSI-learnings #1/#5, "audit the close"):** the `sensor` MUST judge the RETURNED result
237
+ * (tamper-proof — e.g. build/run the returned string in an isolated context, as `poc/ba8-leaf-refine.mjs` does),
238
+ * NEVER a worker side-effect a worker with edit tools could GAME (writing a passing file then returning junk, or
239
+ * editing the failing test itself). A gameable close is the reward-hacking surface every RSI system in the field
240
+ * got bitten by; the loop optimizes against WHATEVER the sensor reads, so keep it outside what the worker can write.
241
+ * **`rejectedBuffer` (BA-14):** a SkillOpt-shaped rejected-attempt buffer — instead of only the LATEST critique,
242
+ * surface the model's OWN prior failed attempts verbatim ("you wrote these, they failed X — write something
243
+ * STRUCTURALLY DIFFERENT"). This is DIRECTED diversity (attack the specific repeated mistake), where escalation
244
+ * is RANDOM diversity; the two are ANTAGONISTIC (`poc/ba14b-temp-with-buffer.mjs`: temperature monotonically
245
+ * degrades the buffer, 100%→70%→50% across 0.2/0.7/1.0), so when the buffer engages the retry temperature is
246
+ * HELD at `temperatures[0]` (flat-low), never escalated. `true` = force on (also on temperature-accepting
247
+ * models); `false` = force off (pure BA-8 escalation); UNSET = ADAPTIVE — engage only once a prior attempt's
248
+ * temperature was dropped (a temperature-fixed model, BA-10, where escalation is inert and the buffer is the
249
+ * sole lever — `poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). `receipts.refineLeaf.rejectedBuffer`
250
+ * reports whether any iteration injected it. Bounded by `maxIterations` (the buffer never outgrows it).
235
251
  * @property {string} [contract] - Definition of done (A3). When present, the verifier grades against THIS,
236
252
  * not the loose task, and verification always runs.
237
253
  * @property {(result: any, ctx: {contract: string|null, task: string}) => (Verdict|Promise<Verdict>)} [evaluate]
@@ -293,10 +309,14 @@ function auditSafeCtx(ctx, overrides = {}) {
293
309
  * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
294
310
  * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
295
311
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
296
- * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
297
- * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
312
+ * @property {{iterations: number, passed: boolean, temperatures: (number|null)[], rejectedBuffer: boolean}} [refineLeaf] - (BA-8) when
313
+ * this leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
298
314
  * passed (false = honest non-recovery, not a faked success). `temperatures` are the EFFECTIVE per-attempt temps
299
315
  * (BA-10): a `null` marks an attempt the model ran at its DEFAULT because it rejected the requested temperature.
316
+ * `rejectedBuffer` (BA-14): whether any iteration injected the rejected-attempt buffer (prior failed attempts
317
+ * surfaced verbatim); when it engages the REQUESTED retry temperature is held flat at `temperatures[0]`, never
318
+ * escalated (ba14b antagonism) — but a temperature-fixed model still drops it, so the EFFECTIVE temp recorded
319
+ * above is `null`, not `temperatures[0]`.
300
320
  * @property {string|null} model
301
321
  * @property {string|null} [retrieval] - (§10 step 7) the retrieval mode this node ran (`scan`/`search`/`exact`),
302
322
  * or null/absent for a plain reasoning node.
@@ -608,10 +628,25 @@ async function recurse(task, ctx = {}, opts = {}) {
608
628
  async function recurseRefineLeaf(task, ctx, opts, state) {
609
629
  const { provider, system, handleTools, depth, critical, node, sensor } = state;
610
630
  node.model = provider.model || null;
611
- const cfg = /** @type {{maxIterations?: number, temperatures?: number[]}} */ (opts.refineLeaf || {});
631
+ const cfg = /** @type {{maxIterations?: number, temperatures?: number[], rejectedBuffer?: boolean}} */ (opts.refineLeaf || {});
612
632
  const temps = Array.isArray(cfg.temperatures) && cfg.temperatures.length ? cfg.temperatures : DEFAULT_REFINE_TEMPS;
613
633
  const maxIterations = Number.isInteger(cfg.maxIterations) && /** @type {number} */ (cfg.maxIterations) > 0
614
634
  ? /** @type {number} */ (cfg.maxIterations) : temps.length;
635
+ // BA-14 rejected-attempt buffer: surface the model's OWN prior failed attempts verbatim ("you wrote these,
636
+ // they failed X — write something DIFFERENT") — a SkillOpt-shaped directed-diversity lever. `rejectedBuffer`:
637
+ // `true` = force on (also on temperature-accepting models); `false` = force off; unset = ADAPTIVE (engage only
638
+ // once a prior attempt's temperature was DROPPED, i.e. a temperature-fixed model where BA-8 escalation is inert
639
+ // and the buffer is the only lever — ba14 D>C). Escalation and the buffer are ANTAGONISTIC (ba14b: temp
640
+ // monotonically degrades the buffer 100→70→50% across 0.2/0.7/1.0), so when the buffer engages we HOLD temps[0].
641
+ const bufferForced = cfg.rejectedBuffer === true;
642
+ const bufferDisabled = cfg.rejectedBuffer === false;
643
+ let bufferUsed = false; // receipt: did any iteration actually inject the ledger?
644
+ const LEDGER_ENTRY_CAP = 600, LEDGER_WHY_CAP = 400;
645
+ const formatLedger = (/** @type {Array<{result: any, verdict: any}>} */ history) => history.map((h, i) => {
646
+ const code = String(h.result == null ? '' : h.result).replace(/```[a-zA-Z]*\n?/g, '').trim().slice(0, LEDGER_ENTRY_CAP);
647
+ const why = h.verdict && typeof h.verdict.critique === 'string' ? h.verdict.critique.slice(0, LEDGER_WHY_CAP) : '';
648
+ return `--- Rejected attempt ${i + 1} (already failed — do NOT reproduce) ---\n${code}${why ? `\nFailed: ${why}` : ''}`;
649
+ }).join('\n\n');
615
650
 
616
651
  // A refine leaf runs N Loops, so its receipts.tokens SUMS every attempt's spend (not just the last) — the
617
652
  // honest cost of the node. The 4-tier tokens object (`{input,output,cacheCreation,cacheRead}`, loop.js) is flat
@@ -632,8 +667,16 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
632
667
  const effectiveTemps = [];
633
668
  // One attempt = a fresh leaf Loop (no spawn tool: a retry is a direct correction, not a re-decomposition) at the
634
669
  // iteration's temperature, with the GAP fed forward as fresh feedback. A governance halt → throw so refine stops.
635
- const attempt = async ({ iteration, critique }) => {
636
- const temperature = temps[Math.min(iteration, temps.length - 1)];
670
+ const attempt = async ({ iteration, critique, history }) => {
671
+ const hist = Array.isArray(history) ? history : [];
672
+ // ADAPTIVE trigger: a prior attempt whose temperature the model rejected (BA-10) records `null` in
673
+ // effectiveTemps → escalation is inert on this (temperature-fixed) model, so engage the buffer. Forced-on
674
+ // engages regardless (incl. temperature-accepting models). Needs ≥1 prior attempt to have something to buffer.
675
+ const tempDropped = effectiveTemps.some((t) => t === null);
676
+ const useBuffer = hist.length > 0 && !bufferDisabled && (bufferForced || tempDropped);
677
+ // ba14b: temperature is antagonistic to the buffer's directed diversity — HOLD temps[0] when it engages;
678
+ // otherwise escalate (BA-8, the no-memory lever). On a temperature-fixed model both collapse to the default.
679
+ const temperature = useBuffer ? temps[0] : temps[Math.min(iteration, temps.length - 1)];
637
680
  const loop = new Loop({
638
681
  provider, system,
639
682
  policy: ctx.policy || undefined,
@@ -642,9 +685,15 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
642
685
  throwOnError: false,
643
686
  });
644
687
  const base = withContext(task, opts.context);
645
- const userText = critique
646
- ? `${base}\n\nYour previous attempt FAILED these checks:\n${critique}\n\nReturn a corrected result that passes ALL of them.`
647
- : base;
688
+ let userText;
689
+ if (useBuffer) {
690
+ bufferUsed = true;
691
+ userText = `${base}\n\nYou have already tried the following and each FAILED. Do NOT reproduce them — write a STRUCTURALLY DIFFERENT result that passes ALL checks:\n\n${formatLedger(hist)}`;
692
+ } else if (critique) {
693
+ userText = `${base}\n\nYour previous attempt FAILED these checks:\n${critique}\n\nReturn a corrected result that passes ALL of them.`;
694
+ } else {
695
+ userText = base;
696
+ }
648
697
  const out = await loop.run([{ role: 'user', content: userText }], handleTools, { ctx: auditSafeCtx(ctx, { depth }), temperature });
649
698
  // `temperatureDropped` is set on the Loop result only when the model rejected the requested temperature
650
699
  // (BA-10); it's absent on the error/halt return shapes, so read it through a narrow cast.
@@ -667,7 +716,7 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
667
716
  // `temperatures` = the EFFECTIVE temps (BA-10): a `null` marks an attempt whose requested temperature the
668
717
  // model rejected and ran at its default — so the receipt never claims a value the model ignored. On a
669
718
  // temperature-accepting model this equals the requested `temps.slice(0, iterations)` (byte-identical receipt).
670
- node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: effectiveTemps.slice(0, outcome.iterations) };
719
+ node.refineLeaf = { iterations: outcome.iterations, passed: !!(outcome.verdict && outcome.verdict.pass), temperatures: effectiveTemps.slice(0, outcome.iterations), rejectedBuffer: bufferUsed };
671
720
  const result = outcome.result;
672
721
 
673
722
  // Optional rubric layer on top of the deterministic sensor (RC-7): forced for critical, or a contract/override.
@@ -682,6 +731,11 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
682
731
  return { result, verdict: outcome.verdict || null, receipts: node };
683
732
  } catch (err) {
684
733
  node.tokens = tokensSum; // record whatever attempts DID spend, on both the halt and fault paths
734
+ // The refineLeaf receipt must ride EVERY terminating path, not just the clean one (same invariant as BA-10's
735
+ // `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 };
685
739
  if (err instanceof HaltError) {
686
740
  node.halted = true;
687
741
  node.incomplete = true;
package/src/refine.d.ts CHANGED
@@ -1,15 +1,25 @@
1
1
  export type Verdict = import("./evaluator").Verdict;
2
2
  export type RefineOptions = {
3
3
  /**
4
- * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
5
- * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
6
- * transcript — anchoring on a wrong answer defeats the independent verifier.
4
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null and `history` is empty. Fresh-feedback
5
+ * (D6/A1) is the consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
6
+ * failed transcript — anchoring on a wrong answer defeats the independent verifier. `history` is a fresh
7
+ * shallow COPY of every prior `{result, verdict}` in order (a SkillOpt rejected-attempt buffer, BA-14): a
8
+ * consumer MAY surface the failed attempts verbatim ("you already wrote these, they failed X — write something
9
+ * different"), the lever that recovers a temperature-fixed model's fixation rut where escalation is inert
10
+ * (`poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). *Structurally* mutating the copy (push/splice/reorder)
11
+ * is safe; the `{result, verdict}` entries are SHARED references into refine's internal history (and the returned
12
+ * `outcome.history`), so treat them as READ-ONLY — deep-mutating an entry corrupts the outcome.
7
13
  */
8
14
  attempt: (args: {
9
15
  iteration: number;
10
16
  lastResult: any;
11
17
  critique: string | null;
12
18
  contract: string | null;
19
+ history: Array<{
20
+ result: any;
21
+ verdict: Verdict;
22
+ }>;
13
23
  }) => any;
14
24
  /**
15
25
  * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
@@ -56,10 +66,16 @@ export type RefineOutcome = {
56
66
  /** @typedef {import('./evaluator').Verdict} Verdict */
57
67
  /**
58
68
  * @typedef {object} RefineOptions
59
- * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null}) => any} attempt
60
- * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
61
- * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
62
- * transcript — anchoring on a wrong answer defeats the independent verifier.
69
+ * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null, history: Array<{result: any, verdict: Verdict}>}) => any} attempt
70
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null and `history` is empty. Fresh-feedback
71
+ * (D6/A1) is the consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
72
+ * failed transcript — anchoring on a wrong answer defeats the independent verifier. `history` is a fresh
73
+ * shallow COPY of every prior `{result, verdict}` in order (a SkillOpt rejected-attempt buffer, BA-14): a
74
+ * consumer MAY surface the failed attempts verbatim ("you already wrote these, they failed X — write something
75
+ * different"), the lever that recovers a temperature-fixed model's fixation rut where escalation is inert
76
+ * (`poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). *Structurally* mutating the copy (push/splice/reorder)
77
+ * is safe; the `{result, verdict}` entries are SHARED references into refine's internal history (and the returned
78
+ * `outcome.history`), so treat them as READ-ONLY — deep-mutating an entry corrupts the outcome.
63
79
  * @property {(result: any, ctx: {iteration: number, contract: string|null}) => (Verdict | Promise<Verdict>)} evaluate
64
80
  * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
65
81
  * @property {string} [contract] - The shared definition of done (A3/D10), forwarded to BOTH `attempt` and
package/src/refine.js CHANGED
@@ -4,10 +4,16 @@
4
4
 
5
5
  /**
6
6
  * @typedef {object} RefineOptions
7
- * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null}) => any} attempt
8
- * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
9
- * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
10
- * transcript — anchoring on a wrong answer defeats the independent verifier.
7
+ * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null, history: Array<{result: any, verdict: Verdict}>}) => any} attempt
8
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null and `history` is empty. Fresh-feedback
9
+ * (D6/A1) is the consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the
10
+ * failed transcript — anchoring on a wrong answer defeats the independent verifier. `history` is a fresh
11
+ * shallow COPY of every prior `{result, verdict}` in order (a SkillOpt rejected-attempt buffer, BA-14): a
12
+ * consumer MAY surface the failed attempts verbatim ("you already wrote these, they failed X — write something
13
+ * different"), the lever that recovers a temperature-fixed model's fixation rut where escalation is inert
14
+ * (`poc/ba14-rejected-buffer.mjs`: flat-temp 50%→100%). *Structurally* mutating the copy (push/splice/reorder)
15
+ * is safe; the `{result, verdict}` entries are SHARED references into refine's internal history (and the returned
16
+ * `outcome.history`), so treat them as READ-ONLY — deep-mutating an entry corrupts the outcome.
11
17
  * @property {(result: any, ctx: {iteration: number, contract: string|null}) => (Verdict | Promise<Verdict>)} evaluate
12
18
  * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
13
19
  * @property {string} [contract] - The shared definition of done (A3/D10), forwarded to BOTH `attempt` and
@@ -49,7 +55,7 @@ async function refine(options) {
49
55
  let lastVerdict = null;
50
56
 
51
57
  for (let iteration = 0; iteration < maxIterations; iteration++) {
52
- const result = await attempt({ iteration, lastResult, critique: lastVerdict ? lastVerdict.critique : null, contract });
58
+ const result = await attempt({ iteration, lastResult, critique: lastVerdict ? lastVerdict.critique : null, contract, history: history.slice() });
53
59
  const verdict = await evaluate(result, { iteration, contract });
54
60
  history.push({ result, verdict });
55
61
  lastResult = result;
package/tools/shell.d.ts CHANGED
@@ -5,6 +5,7 @@ declare namespace _exports {
5
5
  export { createShellTools };
6
6
  export { _grepCore };
7
7
  export { writeFile as _writeFile };
8
+ export { editFile as _editFile };
8
9
  }
9
10
  export = _exports;
10
11
  type GrepArgs = {
@@ -97,3 +98,41 @@ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
97
98
  append?: boolean;
98
99
  maxBytes?: number;
99
100
  }): Promise<string>;
101
+ /**
102
+ * Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
103
+ * Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
104
+ * tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
105
+ * revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
106
+ * to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
107
+ *
108
+ * TWO error classes, deliberately split:
109
+ * - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
110
+ * the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
111
+ * call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
112
+ * guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
113
+ * short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
114
+ * the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
115
+ * - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
116
+ *
117
+ * BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
118
+ * `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
119
+ * absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
120
+ * deletion; an absent `newText` is not.
121
+ *
122
+ * ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
123
+ * the original's mode → rename over the original. Any throw before the rename leaves the original
124
+ * byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
125
+ * drop the executable bit.
126
+ *
127
+ * LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
128
+ * `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
129
+ * `newText` lands verbatim.
130
+ * @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
131
+ * @returns {Promise<string>}
132
+ */
133
+ declare function editFile({ path: rawPath, oldText, newText, maxBytes }: {
134
+ path: string;
135
+ oldText: string;
136
+ newText: string;
137
+ maxBytes?: number;
138
+ }): Promise<string>;
package/tools/shell.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * shell_read — read a file or list a directory
8
8
  * shell_grep — regex search across files (JS regex, no grep/rg/findstr)
9
9
  * shell_write — write/overwrite (or append to) a file, creating parent dirs (no shell)
10
+ * shell_edit — anchored exact-string replace: change one span without rewriting the whole file
10
11
  * shell_run — run a command via an argv array (no shell, allowlist-friendly on argv[0])
11
12
  * shell_exec — run a raw shell command with timeout + max buffer
12
13
  *
@@ -16,11 +17,13 @@
16
17
  * GATING WITH bareguard's fs/bash PRIMITIVES: these tools carry tool-named actions by default
17
18
  * (`{ type:'shell_write' }`), which match `tools.allowlist`/`tools.denylist` but do NOT activate the
18
19
  * `fs`/`bash` primitives — those need `action.type ∈ {read,write,edit,bash}` with `action.path`/`action.cmd`.
19
- * To gate `shell_write` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it touches
20
- * disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
21
- * mapping (`shell_write` → `{ type:'write', path }`, `shell_read`/`shell_grep` → `{ type:'read', path }`,
22
- * `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). A write tool alone is NOT auto-gated — validated by
23
- * poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
20
+ * To gate `shell_write`/`shell_edit` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
21
+ * touches disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
22
+ * mapping (`shell_write` → `{ type:'write', path }`, `shell_edit` → `{ type:'edit', path }`, `shell_read`/`shell_grep`
23
+ * `{ type:'read', path }`, `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). bareguard gates `edit` by
24
+ * `fs.writeScope` identically to `write` (its FS primitive's `FS_TYPES` includes `edit`), so a consumer that
25
+ * fences `write` gets `edit` fenced by the same scope with ZERO extra config. A write/edit tool alone is NOT
26
+ * auto-gated — validated by poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
24
27
  *
25
28
  * CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY (no
26
29
  * `realpath`/symlink resolution), so a symlink that lives INSIDE the allowed scope but points OUTSIDE it is
@@ -33,6 +36,7 @@
33
36
 
34
37
  const fs = require('node:fs/promises');
35
38
  const path = require('node:path');
39
+ const crypto = require('node:crypto');
36
40
  const { exec, execFile } = require('node:child_process');
37
41
  const { Worker } = require('node:worker_threads');
38
42
 
@@ -127,6 +131,101 @@ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
127
131
  return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
128
132
  }
129
133
 
134
+ /**
135
+ * Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
136
+ * Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
137
+ * tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
138
+ * revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
139
+ * to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
140
+ *
141
+ * TWO error classes, deliberately split:
142
+ * - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
143
+ * the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
144
+ * call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
145
+ * guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
146
+ * short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
147
+ * the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
148
+ * - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
149
+ *
150
+ * BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
151
+ * `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
152
+ * absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
153
+ * deletion; an absent `newText` is not.
154
+ *
155
+ * ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
156
+ * the original's mode → rename over the original. Any throw before the rename leaves the original
157
+ * byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
158
+ * drop the executable bit.
159
+ *
160
+ * LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
161
+ * `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
162
+ * `newText` lands verbatim.
163
+ * @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
164
+ * @returns {Promise<string>}
165
+ */
166
+ async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
167
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
168
+ throw new Error('shell_edit requires a non-empty "path" string');
169
+ }
170
+ if (typeof oldText !== 'string' || oldText.length === 0) {
171
+ throw new Error(
172
+ 'shell_edit requires a non-empty "oldText" string to anchor the edit — refusing to edit, the file is unchanged. '
173
+ + `Got ${oldText === undefined ? 'no oldText argument' : oldText === '' ? 'an empty string' : `oldText of type ${oldText === null ? 'null' : typeof oldText}`}.`,
174
+ );
175
+ }
176
+ if (typeof newText !== 'string') {
177
+ throw new Error(
178
+ 'shell_edit requires a "newText" string (pass newText:"" to delete the anchored text) — refusing to edit, the file is unchanged. '
179
+ + `Got ${newText === undefined ? 'no newText argument' : `newText of type ${newText === null ? 'null' : typeof newText}`}`
180
+ + '. If your output was cut short, retry with the full newText.',
181
+ );
182
+ }
183
+
184
+ const resolved = path.resolve(expandHome(rawPath));
185
+ // fs-layer errors (ENOENT for a missing file, EISDIR for a directory) throw — same surface as shell_read.
186
+ const content = await fs.readFile(resolved, 'utf8');
187
+
188
+ // Literal, non-overlapping occurrence count (split on a string does no regex interpretation).
189
+ const occurrences = content.split(oldText).length - 1;
190
+ if (occurrences === 0) {
191
+ return `shell_edit: oldText not found in ${resolved} — no change made. Quote the exact text to replace `
192
+ + `(check whitespace and indentation), or read the file to re-anchor.`;
193
+ }
194
+ if (occurrences > 1) {
195
+ return `shell_edit: oldText occurs ${occurrences}× in ${resolved} — the anchor must match exactly once. `
196
+ + `Widen it with surrounding lines so it is unique. No change made.`;
197
+ }
198
+
199
+ const idx = content.indexOf(oldText);
200
+ const patched = content.slice(0, idx) + newText + content.slice(idx + oldText.length);
201
+
202
+ const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
203
+ const bytes = Buffer.byteLength(patched, 'utf8');
204
+ if (bytes > cap) {
205
+ throw new Error(`shell_edit result is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
206
+ }
207
+
208
+ // Atomic replace: a sibling temp (same dir → same filesystem → rename is atomic) with the original's mode.
209
+ const stat = await fs.stat(resolved);
210
+ const tmp = `${resolved}.shell_edit-${crypto.randomBytes(9).toString('hex')}.tmp`;
211
+ try {
212
+ // flag 'wx' (O_CREAT|O_EXCL) — never follow or clobber a pre-planted file/symlink at the temp path; a
213
+ // colliding name fails the write instead. Create owner-only (0o600) so the patched body — which may hold
214
+ // a secret from a sensitive source file — is never briefly world-readable in the window before chmod sets
215
+ // the original's real mode. (This temp pattern is new to shell_edit, so it carries its own hardening.)
216
+ await fs.writeFile(tmp, patched, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
217
+ await fs.chmod(tmp, stat.mode & 0o777);
218
+ await fs.rename(tmp, resolved);
219
+ } catch (err) {
220
+ await fs.rm(tmp, { force: true }).catch(() => {});
221
+ throw err;
222
+ }
223
+
224
+ const removed = oldText.split('\n').length - 1;
225
+ const added = newText.split('\n').length - 1;
226
+ return `edited ${resolved}: 1 replacement (-${removed}/+${added} lines)`;
227
+ }
228
+
130
229
  // Probe the first 1KB for NUL bytes to skip binary files in grep walks.
131
230
  /** @param {string} filePath */
132
231
  async function isProbablyText(filePath) {
@@ -453,6 +552,30 @@ function createShellTools() {
453
552
  execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) =>
454
553
  writeFile(/** @type {any} */ (args)),
455
554
  },
555
+ {
556
+ name: 'shell_edit',
557
+ description: 'Replace an exact, unique text span in a file — the surgical alternative to shell_write, which ' +
558
+ 'rewrites the ENTIRE file. Give oldText (the exact text to replace — it must occur EXACTLY ONCE, so quote ' +
559
+ 'enough surrounding lines to be unique) and newText (its replacement; pass "" to delete). Matched literally ' +
560
+ '(no regex; whitespace and indentation are significant). Returns a compact "edited <path>: 1 replacement" ' +
561
+ 'receipt, never the file body. If oldText matches 0 or 2+ times the file is left unchanged and the reason is ' +
562
+ 'returned so you can re-anchor. The file must already exist. No shell — gate by path with an fs.writeScope ' +
563
+ 'policy (translate to {type:"edit"}).',
564
+ parameters: {
565
+ type: 'object',
566
+ properties: {
567
+ path: { type: 'string', description: 'File to edit. ~ expands to home. The file must already exist.' },
568
+ oldText: { type: 'string', description: 'Exact text to find — must occur exactly once. Quote surrounding lines to disambiguate. Matched literally (no regex); whitespace and indentation are significant.' },
569
+ newText: { type: 'string', description: 'Replacement text, inserted verbatim (a literal splice — $ is not special). Pass "" to delete the anchored text. Required — a call without it is REJECTED, not treated as a deletion.' },
570
+ maxBytes: { type: 'integer', description: 'Reject if the resulting file would exceed this many bytes (default 5242880).' },
571
+ },
572
+ required: ['path', 'oldText', 'newText'],
573
+ },
574
+ // The args are model-authored and UNTRUSTED — oldText/newText may be absent (an output-token-capped
575
+ // generation), so the boundary type stays loose and editFile enforces the BA-4 contract at runtime.
576
+ execute: async (/** @type {{path: string, oldText?: string, newText?: string, maxBytes?: number}} */ args) =>
577
+ editFile(/** @type {any} */ (args)),
578
+ },
456
579
  {
457
580
  name: 'shell_run',
458
581
  description: 'Run a command with an argv array (no shell, no interpolation) and return {stdout, stderr, code, timedOut}. Use this when a policy allowlist needs to match on argv[0] — no shell metacharacter injection is possible. Default timeout 30s, max output 1MB.',
@@ -493,4 +616,4 @@ function createShellTools() {
493
616
  return { tools };
494
617
  }
495
618
 
496
- module.exports = { createShellTools, _grepCore, _writeFile: writeFile };
619
+ module.exports = { createShellTools, _grepCore, _writeFile: writeFile, _editFile: editFile };