bare-agent 0.27.0 → 0.29.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
@@ -123,7 +123,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
123
123
 
124
124
  **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value.
125
125
 
126
- **The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).
126
+ **The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them and surfaced on **every** `Loop.run()` return), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. The same honesty now covers **every** non-clean terminal signal (BA-13): a safety `refusal` returns `error: 'refusal'` and a blown context window returns `error: 'context_exceeded'` (both were previously laundered into an empty success), while a resumable `pause_turn` makes the loop resume rather than terminate. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).
127
127
 
128
128
  **Thinking blocks survive the round-trip.** `claude-sonnet-5` and Opus 4.7+ run adaptive thinking **by default** — they return `thinking` blocks whether or not you ask — and Anthropic's contract is that those blocks come back **unchanged, signature included**, when a tool-use conversation continues. bare-agent used to drop every one, silently (the API returns 200 either way). They now ride the transcript on `Message.providerBlocks` and are replayed verbatim, with no flag to set. `new Anthropic({ thinking })` is a separate opt-in for pinning the mode or reaching `display`/`effort` — it does *not* enable thinking, which is already on. **This is a protocol/data-loss fix, not a capability one: a head-to-head with thinking fully preserved produced no better outcomes, and we say so rather than sell it (BA-7).**
129
129
 
@@ -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.27.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.29.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
 
@@ -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 })` |
@@ -388,7 +389,15 @@ Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full m
388
389
 
389
390
  **A TRUNCATED round is reported, never laundered into a finish (BA-6, v0.27+).** Every provider now surfaces `GenerateResult.stopReason` — normalized across all five to one neutral vocabulary (`end_turn`, `max_tokens`, `tool_use`, `stop_sequence`, `refusal`, `pause_turn`, `context_exceeded`; `null` when the provider doesn't report one, e.g. CLIPipe). Before this, **no** provider read its finish-reason field, so a round the API **cut off at the output cap** hit the Loop's *"no tool calls ⇒ final answer"* rule and came back as a **clean finish with `error: null`** — a truncation indistinguishable from a model that chose to stop. It now returns **`result.error === 'truncated:max_tokens'`** with the partial text preserved (BA-5). Check `result.error === null` for success, as always. Note the **default `maxTokens` is 4096** and is deliberately unchanged: raising it silently lifts every adopter's ceiling and bill, which is the same class of sin as the silent truncation. If you run a reasoning model on hard tasks, **raise it yourself** — `loop.run(msgs, tools, { maxTokens: 16000 })` (it's a `run()` option, forwarded to the provider), and now you'll get a loud error instead of a quiet empty answer when you don't.
390
391
 
391
- **A truncated round's tool calls are REFUSED, and this is load-bearing.** A **complete** tool call always arrives tagged `tool_use`, **never** `max_tokens` (measured on the real API). So a tool call riding a truncated round was **cut off mid-generation with arguments missing** — which is exactly how a `shell_write` whose `content` never arrived emptied a 1789-line file (BA-4). The Loop therefore never executes the tool calls of a `max_tokens` round: it returns `truncated:max_tokens` instead. Refusing discards nothing legitimate, and it closes that data-loss path for **every** tool you grant, not just `shell_write`. `pause_turn` (a *resumable* server-tool state), `refusal` and `context_exceeded` are surfaced but deliberately **not** treated as truncations.
392
+ **A truncated round's tool calls are REFUSED, and this is load-bearing.** A **complete** tool call always arrives tagged `tool_use`, **never** `max_tokens` (measured on the real API). So a tool call riding a truncated round was **cut off mid-generation with arguments missing** — which is exactly how a `shell_write` whose `content` never arrived emptied a 1789-line file (BA-4). The Loop therefore never executes the tool calls of a `max_tokens` round: it returns `truncated:max_tokens` instead. Refusing discards nothing legitimate, and it closes that data-loss path for **every** tool you grant, not just `shell_write`.
393
+
394
+ **Every non-clean terminal stop reason is error-tagged, and `stopReason` rides every return (BA-13, v0.28+).** BA-6 fixed exactly one stop reason (`max_tokens`); the others still laundered a non-finish into a clean `error: null`. They no longer do:
395
+ - `refusal` → **`result.error === 'refusal'`** (partial text preserved). A safety refusal is **not** an empty success. `RECITATION` fires on entirely benign prompts, so this is reachable on ordinary runs.
396
+ - `context_exceeded` → **`result.error === 'context_exceeded'`** (ran out of context window — a spend/limit story, distinct from the output cap).
397
+ - `pause_turn` → the Loop **resumes** the turn (a resumable server-tool pause is neither terminal nor an error). Resuming appends the paused assistant turn (its partial text + provider-native server-tool blocks) and re-requests — the documented Anthropic pause_turn protocol, so the server continues where it left off rather than restarting. A pause that never progresses is caught by the same `HARD_ROUND_LIMIT` / gate `maxTurns` bounds as any non-advancing loop.
398
+ - The tool calls of a `refusal` / `context_exceeded` round are **refused, not executed** — the same BA-4 closure as `max_tokens`.
399
+
400
+ And **`result.stopReason`** (the neutral value, or `null`) is now present on **every** `Loop.run()` return, not only the clean-finish path — branch on it when you need *why* a run ended, not just *whether* it succeeded. **`error` stays the sole success signal** (`result.error === null`), and this is deliberately error-tagged rather than "surface `stopReason` only": `recurse` and any consumer following that invariant branch on `error`, so a refused worker now propagates an honest `{ incomplete }` up the tree with **zero recurse changes**. **Behavior change to note:** a refused round that previously returned `error: null` + empty text now returns `error: 'refusal'` — the point of the fix, but branch on `error`, not emptiness.
392
401
 
393
402
  **Deny-spin short-circuit (`maxConsecutiveDenials`, default 3, v0.25+).** A *non-halt* deny (a `policy` verdict that isn't `true` — e.g. a `humanChannel: deny`, an allowlist miss, a `content`/`fs.writeScope` block) is **advisory**: it's fed back to the model as a tool result so the model can pivot to a different allowed tool. But a model that keeps retrying the *same* denied action would otherwise spin every round until your `budget.maxCostUsd` finally halts it — burning the whole cap with no progress (this bit a coding agent whose write kept tripping `content.askPatterns`). The Loop now counts **consecutive** denials (any allowed call resets the streak, preserving the pivot) and short-circuits at `maxConsecutiveDenials` with `result.error === 'denied:<tool>'` (a clean return, transcript sealed — never a throw). Check `result.error?.startsWith('denied:')` to distinguish a governance block from a completed run; set `maxConsecutiveDenials: 0` (or `Infinity`) on `new Loop({...})` to restore the pure-advisory behavior. Under `recurse`, a short-circuited worker returns a **labeled** `{ incomplete: true, blocker: 'governance-deny' }` (and `receipts.blocker`) so you can widen scope / re-gate / escalate rather than read it as a model failure.
394
403
 
@@ -415,6 +424,7 @@ const { policy, onToolResult } = wireGate(gate, {
415
424
  if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
416
425
  if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
417
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)
418
428
  return { type: toolName, args, _ctx: ctx }; // fall through to defaultActionTranslator
419
429
  },
420
430
  });
@@ -1239,6 +1249,7 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1239
1249
  |---|---|
1240
1250
  | `shell_read` | Read a file (utf8, 256KB cap) or list a directory (tab-separated). `~` expands to home. |
1241
1251
  | `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. |
1252
+ | `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. |
1242
1253
  | `shell_grep` | JavaScript regex search across files. Walks directories, skips binary files, returns `{hits: [{file, line, text}], truncated, fileCount}`. |
1243
1254
  | `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.** |
1244
1255
  | `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). |
@@ -1247,6 +1258,8 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1247
1258
 
1248
1259
  > **⚠️ `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.
1249
1260
 
1261
+ > **`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`).
1262
+
1250
1263
  > **⚠️ `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).
1251
1264
 
1252
1265
  ```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.27.0",
3
+ "version": "0.29.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.d.ts CHANGED
@@ -136,7 +136,7 @@ export class Loop {
136
136
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
137
137
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
138
138
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
139
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
139
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
140
140
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
141
141
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
142
142
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -153,6 +153,13 @@ export class Loop {
153
153
  *
154
154
  * A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
155
155
  * fell through to the hard-round-limit return and reported that safety warning as its `error`.)
156
+ *
157
+ * BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean
158
+ * terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns
159
+ * `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial
160
+ * text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with
161
+ * empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the
162
+ * loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal.
156
163
  * @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
157
164
  * @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
158
165
  * @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
@@ -163,6 +170,7 @@ export class Loop {
163
170
  usage: Usage;
164
171
  cost: number;
165
172
  error: string | null;
173
+ stopReason: string | null;
166
174
  msgs: Message[];
167
175
  metrics: RunMetrics;
168
176
  temperatureDropped?: boolean;
@@ -193,7 +201,7 @@ export class Loop {
193
201
  * @param {string} text - User message.
194
202
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
195
203
  * @param {Record<string, any>} [options={}] - Per-run overrides.
196
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
204
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
197
205
  */
198
206
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
199
207
  text: string;
@@ -201,6 +209,7 @@ export class Loop {
201
209
  usage: Usage;
202
210
  cost: number;
203
211
  error: string | null;
212
+ stopReason: string | null;
204
213
  msgs: Message[];
205
214
  metrics: RunMetrics;
206
215
  temperatureDropped?: boolean;
package/src/loop.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { ToolError, HaltError } = require('./errors');
4
- const { isTruncated } = require('./provider-stop-reason');
4
+ const { classifyStopReason } = require('./provider-stop-reason');
5
5
 
6
6
  /** @typedef {import('../types').Provider} Provider */
7
7
  /** @typedef {import('../types').Message} Message */
@@ -346,7 +346,7 @@ class Loop {
346
346
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
347
347
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
348
348
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
349
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
349
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
350
350
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
351
351
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
352
352
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -363,6 +363,13 @@ class Loop {
363
363
  *
364
364
  * A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
365
365
  * fell through to the hard-round-limit return and reported that safety warning as its `error`.)
366
+ *
367
+ * BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean
368
+ * terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns
369
+ * `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial
370
+ * text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with
371
+ * empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the
372
+ * loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal.
366
373
  * @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
367
374
  * @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
368
375
  * @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
@@ -427,6 +434,12 @@ class Loop {
427
434
  // The caller decides what a partial result is worth; the library must not decide it is worth nothing.
428
435
  // Stays '' when the model never produced text (nothing to preserve — we never invent a placeholder).
429
436
  let lastText = '';
437
+ // BA-13: the NEUTRAL stop reason of the most recent completed round (post-provider-normalization).
438
+ // Surfaced on EVERY return so a caller can branch on WHY a run ended, not just its `error` tag — the
439
+ // load-bearing companion to the classifier (a terminal `error` says "not a clean finish"; `stopReason`
440
+ // says which kind). Stays null until the first round completes, and across a provider error / a
441
+ // pre-round stop() it holds the last round's value (or null if none ran).
442
+ let lastStopReason = null;
430
443
  // BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
431
444
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
432
445
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
@@ -463,7 +476,7 @@ class Loop {
463
476
  sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
464
477
  this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
465
478
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
466
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
479
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
467
480
  }
468
481
  return null;
469
482
  };
@@ -691,7 +704,7 @@ class Loop {
691
704
  this._reportError('provider', err, { round });
692
705
  if (this.throwOnError) throw err;
693
706
  // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
694
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
707
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
695
708
  }
696
709
 
697
710
  lastUsage = result.usage || lastUsage;
@@ -700,6 +713,9 @@ class Loop {
700
713
  // is the text most worth keeping). A tool-call-only round carries no text, so hold the last non-empty
701
714
  // one rather than letting a silent round erase an earlier account.
702
715
  if (typeof result.text === 'string' && result.text.trim() !== '') lastText = result.text;
716
+ // BA-13: capture this round's neutral stop reason for surfacing on the run's return (every exit
717
+ // path reads lastStopReason). Non-string / absent ⇒ null (the provider's pre-BA-6 degrade).
718
+ lastStopReason = typeof result.stopReason === 'string' ? result.stopReason : null;
703
719
  if (result.temperatureDropped) temperatureDropped = true;
704
720
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
705
721
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
@@ -745,21 +761,48 @@ class Loop {
745
761
  }
746
762
  }
747
763
 
748
- // BA-6: the API CUT THIS ROUND OFF at the output cap. It is NOT a finished turn, and it must not
749
- // reach the "no tool calls final answer" rule below that rule is what laundered a truncation
750
- // into a clean `error: null` completion, indistinguishable from a model that chose to stop.
751
- //
752
- // Placed AFTER metering (the tokens were really spent the gate must see them) and BEFORE tool
753
- // execution, which is the load-bearing half: a truncated round's tool calls were cut off
754
- // mid-generation, so their arguments are missing keys. Executing one is exactly how BA-4 emptied a
755
- // 1789-line file (`shell_write` reached the fs with no `content`). A COMPLETE call always arrives
756
- // tagged 'tool_use', never 'max_tokens' (measured on the real API, poc/ba6-stop-reason-mapping.mjs),
757
- // so refusing here discards nothing legitimate and closes the data-loss path for EVERY tool.
764
+ // BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
765
+ // short-circuited exactly one non-clean reason (max_tokens); this gate is the general form. It sits
766
+ // AFTER metering (the tokens were really spent the gate must see them) and BEFORE tool execution,
767
+ // which is the load-bearing half: a non-final round's tool calls were cut off mid-generation, so
768
+ // their arguments are missing keys. Executing one is exactly how BA-4 emptied a 1789-line file
769
+ // (`shell_write` reached the fs with no `content`). A COMPLETE call always arrives tagged 'tool_use',
770
+ // never a truncation/refusal (measured on the real API, poc/ba6-stop-reason-mapping.mjs), so refusing
771
+ // the tool calls of ANY non-clean terminal round discards nothing legitimate and closes the data-loss
772
+ // path for EVERY tool the BA-4 protocol-layer closure applied uniformly (see classifyStopReason).
758
773
  //
759
- // We report; the caller decides. No auto-retry at a bigger cap: that doubles spend against a budget
760
- // the gate is enforcing, and the right recovery (raise the cap? split the task? shorten it?) is the
761
- // caller's call, not the library's. `lastText` (BA-5) preserves the partial work either way.
762
- if (isTruncated(result.stopReason)) {
774
+ // We report; the caller decides. No auto-retry: that doubles spend against the gate's budget, and the
775
+ // right recovery (raise the cap? re-gate the refusal? shorten the context?) is the caller's, not the
776
+ // library's. `lastText` (BA-5) preserves the partial work on every terminal leg.
777
+ const terminal = classifyStopReason(result.stopReason);
778
+ if (terminal === 'resume') {
779
+ // BA-13: `pause_turn` — a RESUMABLE server-side tool pause (the API's server-tool loop hit its
780
+ // per-turn iteration cap mid-turn). NOT a finish, NOT an error. Resuming REQUIRES re-sending the
781
+ // paused assistant turn: the provider detects the trailing server-tool block and continues where
782
+ // it left off (the documented Anthropic pause_turn protocol — "re-send the user message and
783
+ // assistant response"). A bare `continue` WITHOUT appending re-sends byte-identical input, so the
784
+ // server restarts the turn from scratch and pauses again, spinning to HARD_ROUND_LIMIT (100 paid
785
+ // calls) instead of resuming. So append the assistant turn — its partial text plus the
786
+ // provider-native server-tool/thinking blocks (which the Anthropic provider replays via
787
+ // providerBlocks) — BEFORE continuing, exactly as the tool-execution path pushes its assistant
788
+ // turn. Only push when there is something to carry: an empty assistant turn (no text, no blocks)
789
+ // is wire-invalid, and a pause with no partial output cannot be advanced anyway — HARD_ROUND_LIMIT
790
+ // (or the gate) bounds that pathological case. No client tool_calls are pushed: a server pause
791
+ // does not carry an unpaired client call, and pushing one would orphan it (wire-invalid) — the
792
+ // same BA-4 refusal principle as the other non-clean terminal legs.
793
+ const hasText = typeof result.text === 'string' && result.text.trim() !== '';
794
+ if (hasText || result.providerBlocks) {
795
+ msgs.push({ role: 'assistant', content: result.text || null, ...(result.providerBlocks && { providerBlocks: result.providerBlocks }) });
796
+ }
797
+ this._safeEmit({ type: 'loop:resume', data: { round, stopReason: lastStopReason } });
798
+ continue;
799
+ }
800
+ if (terminal) {
801
+ // terminal ∈ {'truncated','refusal','context_exceeded'} — a non-clean terminal round. Error-tag it
802
+ // (NOT just surface stopReason): `recurse`'s worker path and the bareloop adopter both branch on
803
+ // `error`, and 0.27.0's "error is the sole success signal" invariant must stay true — an
804
+ // `error:null` + `stopReason:'refusal'` would re-breed BA-6 for these legs.
805
+ const errorTag = terminal === 'truncated' ? 'truncated:max_tokens' : terminal;
763
806
  const dropped = (result.toolCalls || []).length;
764
807
  // Seal the transcript with the partial text only. Deliberately NOT the tool_calls: pushing a call
765
808
  // we refuse to execute would orphan it (a tool_call with no tool_result is a wire-invalid
@@ -767,8 +810,8 @@ class Loop {
767
810
  if (typeof result.text === 'string' && result.text.trim() !== '') {
768
811
  msgs.push({ role: 'assistant', content: result.text });
769
812
  }
770
- this._safeEmit({ type: 'loop:done', data: { text: lastText, truncated: true, droppedToolCalls: dropped, cost: totalCost } });
771
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: 'truncated:max_tokens', msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
813
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
814
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
772
815
  }
773
816
 
774
817
  // No tool calls — LLM gave a final text response
@@ -788,7 +831,7 @@ class Loop {
788
831
  try { await flush(msgs, ctx); }
789
832
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
790
833
  }
791
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
834
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
792
835
  }
793
836
 
794
837
  // Execute tool calls
@@ -897,7 +940,7 @@ class Loop {
897
940
  sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
898
941
  this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
899
942
  this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
900
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
943
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
901
944
  }
902
945
  continue;
903
946
  }
@@ -971,7 +1014,7 @@ class Loop {
971
1014
  // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
972
1015
  // SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
973
1016
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
974
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1017
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
975
1018
  }
976
1019
  throw err;
977
1020
  }
@@ -1001,20 +1044,20 @@ class Loop {
1001
1044
  const rule = err.rule || 'unknown';
1002
1045
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1003
1046
  this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1004
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1047
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1005
1048
  }
1006
1049
  this._reportError('trim-flush', err, { phase: 'stop' });
1007
1050
  }
1008
1051
  }
1009
1052
  this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
1010
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1053
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1011
1054
  }
1012
1055
 
1013
1056
  // Hard safety limit — should never fire under normal usage; bareguard's
1014
1057
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
1015
1058
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
1016
1059
  this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
1017
- return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1060
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1018
1061
  }
1019
1062
 
1020
1063
  /**
@@ -1087,7 +1130,7 @@ class Loop {
1087
1130
  * @param {string} text - User message.
1088
1131
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1089
1132
  * @param {Record<string, any>} [options={}] - Per-run overrides.
1090
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
1133
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
1091
1134
  */
1092
1135
  async chat(text, tools = [], options = {}) {
1093
1136
  this._history.push({ role: 'user', content: text });
@@ -20,7 +20,15 @@ export function normalizeStopReason(raw: string | null | undefined, provider: "a
20
20
  * folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
21
21
  * server-side tool flows that are working exactly as designed.
22
22
  *
23
+ * Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
24
+ * {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
25
+ *
23
26
  * @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
24
27
  * @returns {boolean}
25
28
  */
26
29
  export function isTruncated(stopReason: string | null | undefined): boolean;
30
+ /**
31
+ * @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
32
+ * @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
33
+ */
34
+ export function classifyStopReason(stopReason: string | null | undefined): "truncated" | "refusal" | "context_exceeded" | "resume" | null;
@@ -111,7 +111,10 @@ function normalizeStopReason(raw, provider, ctx = {}) {
111
111
  if (!table) return raw;
112
112
  // An unrecognized-but-present value passes through: the caller can still SEE it, and the Loop only
113
113
  // acts on the known vocabulary — so a new upstream value can never be mistaken for a truncation.
114
- const mapped = table[raw] || raw;
114
+ // Own-property only (mirror of classifyStopReason): `raw` is a provider/proxy-supplied field, so a
115
+ // value like 'toString'/'constructor' would otherwise resolve `table[raw]` to an inherited
116
+ // Object.prototype function (truthy) and be returned in place of the verbatim string.
117
+ const mapped = Object.prototype.hasOwnProperty.call(table, raw) ? table[raw] : raw;
115
118
 
116
119
  // GEMINI AND OLLAMA HAVE NO `tool_use` FINISH REASON (both measured live: Gemini returns
117
120
  // `finishReason: STOP` and Ollama `done_reason: 'stop'` on a round that emitted a complete function
@@ -138,6 +141,9 @@ function normalizeStopReason(raw, provider, ctx = {}) {
138
141
  * folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
139
142
  * server-side tool flows that are working exactly as designed.
140
143
  *
144
+ * Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
145
+ * {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
146
+ *
141
147
  * @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
142
148
  * @returns {boolean}
143
149
  */
@@ -145,4 +151,50 @@ function isTruncated(stopReason) {
145
151
  return stopReason === 'max_tokens';
146
152
  }
147
153
 
148
- module.exports = { normalizeStopReason, isTruncated };
154
+ /**
155
+ * BA-13 — classify a round's NEUTRAL stop reason into the terminal ACTION the Loop must take.
156
+ *
157
+ * BA-6 short-circuited exactly one non-clean stop reason (`max_tokens`). Every OTHER non-clean reason
158
+ * — `refusal`, `context_exceeded`, `pause_turn` — fell through the Loop's "no tool calls ⇒ final
159
+ * answer" rule and was laundered into a clean `error: null` empty success (the BA-4/5/6/7 bug class:
160
+ * an under-modeled boundary round rounding optimistically toward "done"). A `RECITATION` refusal fires
161
+ * on entirely BENIGN prompts, so this was reachable on ordinary runs, and it propagated up `recurse`'s
162
+ * agent tree as a converged sub-task.
163
+ *
164
+ * One table with an EXPLICIT pass-through default replaces the single `if (isTruncated)` — the BA-7
165
+ * lesson ("don't parse-key on a closed set") applied to termination: BA-6 added one leg, BA-13 adds
166
+ * two terminals plus one resume, and the NEXT new stop reason degrades to pass-through (status quo)
167
+ * rather than re-breeding the bug.
168
+ *
169
+ * 'truncated' `max_tokens` — cut off at the output cap (BA-6). Loop returns `error:'truncated:max_tokens'`.
170
+ * 'refusal' declined on safety grounds. Loop returns `error:'refusal'` + partial text.
171
+ * 'context_exceeded' ran out of context window. Loop returns `error:'context_exceeded'` + partial text.
172
+ * 'resume' `pause_turn` — a RESUMABLE server-tool pause. NOT terminal, NOT an error: the
173
+ * Loop CONTINUES the round loop (bounded by HARD_ROUND_LIMIT / the gate's maxTurns).
174
+ * null pass-through: `end_turn` / `stop_sequence` / `tool_use` / an unrecognized value /
175
+ * absent. The Loop's existing tool-exec / final-answer logic runs unchanged.
176
+ *
177
+ * NB: `tool_use` is already derived by {@link normalizeStopReason} from what the round carried, so it
178
+ * needs no row here — a round that stopped to call a complete tool passes through to tool execution.
179
+ */
180
+ const TERMINAL_ACTIONS = /** @type {Record<string, 'truncated'|'refusal'|'context_exceeded'|'resume'>} */ ({
181
+ max_tokens: 'truncated',
182
+ refusal: 'refusal',
183
+ context_exceeded: 'context_exceeded',
184
+ pause_turn: 'resume',
185
+ });
186
+ /**
187
+ * @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
188
+ * @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
189
+ */
190
+ function classifyStopReason(stopReason) {
191
+ if (typeof stopReason !== 'string') return null;
192
+ // Own-property only: `normalizeStopReason` passes an unrecognized value through verbatim, so a
193
+ // provider/proxy emitting stop_reason:'toString'/'constructor'/etc. would otherwise resolve to an
194
+ // inherited Object.prototype function (truthy) and be mistaken for a terminal action.
195
+ return Object.prototype.hasOwnProperty.call(TERMINAL_ACTIONS, stopReason)
196
+ ? TERMINAL_ACTIONS[stopReason]
197
+ : null;
198
+ }
199
+
200
+ module.exports = { normalizeStopReason, isTruncated, classifyStopReason };
@@ -143,7 +143,7 @@ export function buildScanTool(corpus: Slice[] | (() => Promise<Slice[]>), opts:
143
143
  /**
144
144
  * Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
145
145
  * the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
146
- * docs/01-product/litectx-enumerate-spec.md). Returns the generic async slice-source recurse's scan reads: a
146
+ * docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
147
147
  * `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
148
148
  * read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
149
149
  * litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // THE CORPUS FOR SCAN IS A GENERIC ARRAY SLICE-SOURCE (`opts.corpus`), NOT litectx: litectx has no exhaustive,
18
18
  // rank-free enumerate verb today (every read is FTS-gated). The "corpus that already LIVES in litectx" case
19
- // waits on the litectx `enumerate` verb (docs/01-product/litectx-enumerate-spec.md) and drops in behind this
19
+ // waits on the litectx `enumerate` verb (docs/01-product/prd.md) and drops in behind this
20
20
  // same slice-source socket with ZERO recurse changes — the same backend-agnostic stance as `remember`'s Store
21
21
  // socket. Composes AROUND a Loop; NEVER imported by loop.js.
22
22
 
@@ -326,7 +326,7 @@ const ENUM_PAGE = 200;
326
326
  /**
327
327
  * Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
328
328
  * the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
329
- * docs/01-product/litectx-enumerate-spec.md). Returns the generic async slice-source recurse's scan reads: a
329
+ * docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
330
330
  * `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
331
331
  * read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
332
332
  * litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
package/src/recurse.js CHANGED
@@ -708,7 +708,7 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
708
708
  * never folded into the count as a zero; a governance HaltError mid-scan → clean incomplete.
709
709
  *
710
710
  * The corpus is the generic array slice-source `opts.corpus`. Absent it, scan has nothing to read — litectx's
711
- * resident-corpus enumerate path is deferred (docs/01-product/litectx-enumerate-spec.md) — so we return an
711
+ * resident-corpus enumerate path is deferred (docs/01-product/prd.md) — so we return an
712
712
  * honest incomplete, never a fabricated zero.
713
713
  * @param {string} task
714
714
  * @param {RecurseCtx} ctx
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 };