bare-agent 0.24.0 → 0.26.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
@@ -119,7 +119,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
119
119
 
120
120
  > **⚠️ Cost is open by design — wire a cap.** `recurse()` adds no intrinsic total-work limit. On the model-driven default a node can spawn up to ~100 children per level, each recursing to `maxDepth` (default 3), so **token / $ spend compounds and is bounded only by your gate** — not by recurse. Run it **with bareguard** (`ctx.policy`, which enforces depth/budget/call caps) **or with some token/USD cap** for any non-trivial or untrusted task; ungoverned, a weak model that over-decomposes *will* burn tokens. For a hard local brake without a gate, set `maxDepth: 1` (flat, no nesting). The forced modes (`mode:'fanout'` / `'partition'`) are bounded by a deterministic count + concurrency cap; the open path is the model-driven default.
121
121
 
122
- **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. `require('bare-agent/bareguard')`
122
+ **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. `require('bare-agent/bareguard')`
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
 
@@ -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.24.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
4
+ > v0.26.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.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
 
@@ -59,6 +59,7 @@ Eight entry points:
59
59
  | Catch typed errors programmatically | ProviderError, ToolError, TimeoutError, CircuitOpenError |
60
60
  | Cache identical planner calls | Planner({ cacheTTL: 60000 }) |
61
61
  | Stream CLIPipe output in real-time | CLIPipeProvider({ onChunk: fn }) |
62
+ | Get real usage + cost from a CLI provider | CLIPipeProvider({ parse: 'claude-json' }) |
62
63
  | Browse the web (inline snapshots) | createBrowsingTools + Loop |
63
64
  | Browse the web (token-efficient, disk-based) | `barebrowse` CLI session — snapshots to `.barebrowse/*.yml` |
64
65
  | Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
@@ -379,6 +380,8 @@ if (result.error?.startsWith('halt:')) {
379
380
 
380
381
  Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
381
382
 
383
+ **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.
384
+
382
385
  Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
383
386
 
384
387
  **`actionTranslator` for bash/fs primitive activation (v0.10.1+).** Bareguard's `bashCheck` / `fsCheck` / `netCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'fetch'`. The default action shape is `{type: toolName, args, _ctx}` which matches `tools.denylist` / `tools.allowlist` but does NOT activate those primitives. Adopters who want both pass `wireGate(gate, { actionTranslator })`. Since bareguard 0.4.1+, the primitives read fields from either flat (`action.cmd`) or nested (`action.args.cmd` / `.command`) shapes, so you can pass args through verbatim:
@@ -737,9 +740,11 @@ new Ollama({ model: 'llama3.2', url: 'http://localhost:11434' })
737
740
  // CLIPipe — pipe prompts to any CLI tool via stdin/stdout
738
741
  new CLIPipe({ command: 'claude', args: ['--print'], systemPromptFlag: '--system-prompt', timeout: 30000 })
739
742
  new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
743
+ // CLIPipe structured output (v0.26.0+) — map a CLI's JSON envelope to real usage + cost
744
+ new CLIPipe({ command: 'claude', args: ['-p', '--output-format', 'json'], parse: 'claude-json' })
740
745
  ```
741
746
 
742
- All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. CLIPipe always returns `toolCalls: []` and zero usage (CLI tools don't report tokens), and omits `model`.
747
+ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
743
748
 
744
749
  **Temperature graceful degradation (BA-10).** Newer models reject ANY non-default `temperature` with a `400` (`claude-sonnet-5`: `` `temperature` is deprecated for this model. ``; OpenAI o1/gpt-5-class: `Unsupported value: 'temperature' … Only the default (1) …`). All four providers detect that specific 400 (message names `temperature` as unsupported/deprecated AND a temperature was sent), **drop the param, warn once per instance, and retry once** — so a call that would otherwise throw succeeds at the model's default temperature. Keyed off the API error text, not a model list. A genuine out-of-range 400 is NOT degraded (it re-throws — dropping it would mask a caller bug). When a drop happens the result carries `temperatureDropped: true` (an optional `GenerateResult`/`Loop.run` field) so a caller can report the effective temperature — `recurse`'s `refineLeaf` uses it for an honest receipt. Dormant on models that accept temperature (byte-identical to before).
745
750
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.d.ts CHANGED
@@ -52,6 +52,16 @@ export type LoopOptions = {
52
52
  */
53
53
  onLlmResult?: Function | undefined;
54
54
  onToolResult?: Function | undefined;
55
+ /**
56
+ * - BA-11 safety net (default 3). Short-circuit the run when
57
+ * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
58
+ * not a recoverable tool error, so a model that keeps retrying variants of a denied action would
59
+ * otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
60
+ * tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
61
+ * The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
62
+ * throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
63
+ */
64
+ maxConsecutiveDenials?: number | undefined;
55
65
  /**
56
66
  * - Removed in v0.8; presence throws a migration error.
57
67
  */
@@ -79,6 +89,7 @@ export class Loop {
79
89
  throwOnError: boolean;
80
90
  store: import("../types").Store | null;
81
91
  policy: Function | null;
92
+ maxConsecutiveDenials: number;
82
93
  assemble: Function | null;
83
94
  trim: Function | null;
84
95
  onLlmResult: Function | null;
@@ -228,6 +239,13 @@ export function estimateCost(model: string | null, usage: Usage | null): number
228
239
  * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
229
240
  * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
230
241
  * @property {Function} [onToolResult]
242
+ * @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
243
+ * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
244
+ * not a recoverable tool error, so a model that keeps retrying variants of a denied action would
245
+ * otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
246
+ * tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
247
+ * The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
248
+ * throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
231
249
  * @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
232
250
  */
233
251
  /** @type {Record<string, {in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}>} */
package/src/loop.js CHANGED
@@ -49,6 +49,13 @@ const { ToolError, HaltError } = require('./errors');
49
49
  * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
50
50
  * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
51
51
  * @property {Function} [onToolResult]
52
+ * @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
53
+ * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
54
+ * not a recoverable tool error, so a model that keeps retrying variants of a denied action would
55
+ * otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any
56
+ * tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y).
57
+ * The run returns cleanly with `error: 'denied:<tool>'` (mirrors the halt return; never throws even under
58
+ * throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior).
52
59
  * @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
53
60
  */
54
61
 
@@ -146,6 +153,23 @@ function estimateCost(model, usage) {
146
153
  return Number.isFinite(cost) ? cost : null;
147
154
  }
148
155
 
156
+ /**
157
+ * Resolve the priced USD for a round. A provider MAY report its own authoritative `costUsd` on the
158
+ * GenerateResult (e.g. CLIPipeProvider `parse:'claude-json'` surfacing the claude CLI's own
159
+ * `total_cost_usd` — a real price with NO local rate table). When present as a FINITE number it wins
160
+ * over the rate-table estimate — including `0`, a valid priced value (a subscription/marginal-$0 run),
161
+ * which stays 'priced', never demoted to the null/unpriced sentinel. A non-finite provider cost
162
+ * (±Inf/NaN) is NOT a price → fall through to estimateCost (same couldn't-price guard as above).
163
+ * @param {any} result - the GenerateResult from provider.generate()
164
+ * @param {string|null} model
165
+ * @param {Usage|null} usage
166
+ * @returns {number|null}
167
+ */
168
+ function resolveRoundCost(result, model, usage) {
169
+ if (result && Number.isFinite(result.costUsd)) return result.costUsd;
170
+ return estimateCost(model, usage);
171
+ }
172
+
149
173
  // R-C6: default instruction for the provider-bound `ctx.summarize` lent to the assemble seam.
150
174
  const DEFAULT_SUMMARY_INSTRUCTION =
151
175
  'You are a precise conversation summarizer. Produce a concise, factual summary of the following ' +
@@ -212,6 +236,13 @@ class Loop {
212
236
  throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
213
237
  }
214
238
  this.policy = options.policy || null;
239
+ // BA-11 deny-spin guard. Default 3; 0/Infinity/non-finite disables (restores advisory-deny behavior).
240
+ // Validated only if provided so an explicit 0 is honored as "off" (Infinity also disables).
241
+ if (options.maxConsecutiveDenials != null
242
+ && (typeof options.maxConsecutiveDenials !== 'number' || options.maxConsecutiveDenials < 0 || Number.isNaN(options.maxConsecutiveDenials))) {
243
+ throw new Error('[Loop] options.maxConsecutiveDenials must be a non-negative number (0 or Infinity disables)');
244
+ }
245
+ this.maxConsecutiveDenials = options.maxConsecutiveDenials != null ? options.maxConsecutiveDenials : 3;
215
246
  if (options.assemble != null && typeof options.assemble !== 'function') {
216
247
  throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
217
248
  }
@@ -360,6 +391,9 @@ class Loop {
360
391
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
361
392
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
362
393
  let temperatureDropped = false;
394
+ // BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
395
+ // this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
396
+ let consecutiveDenials = 0;
363
397
 
364
398
  // The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
365
399
  // returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
@@ -455,7 +489,7 @@ class Loop {
455
489
  const result = await loop.provider.generate(prompt, [], { temperature: 0, ...genOpts });
456
490
  const usage = (result && result.usage) || null;
457
491
  const model = (result && result.model) || loop.provider.model || null;
458
- const cost = estimateCost(model, usage);
492
+ const cost = resolveRoundCost(result, model, usage);
459
493
  if (cost !== null) { totalCost += cost; pricedAny = true; }
460
494
  addUsage(usage); // summarize tokens are real spend → count them in the cumulative meter
461
495
  metrics.context.summaries++; // §3.6 CE-activity rollup
@@ -599,7 +633,7 @@ class Loop {
599
633
  // Prefer the model the response reports (robust when provider.model is absent or varies per
600
634
  // response — e.g. FallbackProvider, or a CircuitBreaker-wrapped provider that drops .model).
601
635
  const model = result.model || this.provider.model || null;
602
- const roundCost = estimateCost(model, lastUsage);
636
+ const roundCost = resolveRoundCost(result, model, lastUsage);
603
637
  if (roundCost !== null) totalCost += roundCost;
604
638
 
605
639
  // Meter this round: count the turn, accumulate the four token tiers, and classify pricing —
@@ -731,10 +765,30 @@ class Loop {
731
765
  : `[Loop] Tool "${tc.name}" denied by policy`;
732
766
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: reason });
733
767
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, denied: true, reason } });
768
+ // BA-11: a governance deny is not a recoverable tool error. Count consecutive denials; when the
769
+ // model keeps retrying denied actions (proven live: 8 in a row before giving up) short-circuit the
770
+ // run rather than let it burn the budget to the cap. The streak resets on any ALLOWED tool call
771
+ // (below), so a legit deny-then-pivot (deny X → allow Y) never trips this. 0/Infinity disables.
772
+ consecutiveDenials += 1;
773
+ if (this.maxConsecutiveDenials > 0 && Number.isFinite(this.maxConsecutiveDenials)
774
+ && consecutiveDenials >= this.maxConsecutiveDenials) {
775
+ const denyTag = `denied:${tc.name}`;
776
+ // Pair any still-dangling tool_calls from this round so the returned transcript stays
777
+ // provider-valid (same seal the halt path uses), then exit cleanly — no throw even under
778
+ // throwOnError, mirroring the governance-halt contract.
779
+ sealDanglingToolCalls(msgs, denyTag);
780
+ this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
781
+ this._safeEmit({ type: 'loop:done', data: { text: '', denied: true, rule: denyTag, cost: totalCost } });
782
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics() };
783
+ }
734
784
  continue;
735
785
  }
736
786
  }
737
787
 
788
+ // BA-11: reaching here means this tool call PASSED policy (or there is no policy) — progress, so the
789
+ // consecutive-deny streak resets. A single deny followed by an allowed call never trips the guard.
790
+ consecutiveDenials = 0;
791
+
738
792
  const toolStartedAt = Date.now();
739
793
  let toolResult;
740
794
  let toolError;
@@ -30,6 +30,10 @@ export type CLIPipeOptions = {
30
30
  * - Called with each stdout chunk as it streams.
31
31
  */
32
32
  onChunk?: ((chunk: string) => void) | undefined;
33
+ /**
34
+ * - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
35
+ */
36
+ parse?: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | undefined;
33
37
  };
34
38
  /** @typedef {import('../types').Message} Message */
35
39
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -43,6 +47,7 @@ export type CLIPipeOptions = {
43
47
  * @property {number} [timeout=30000] - Timeout in milliseconds.
44
48
  * @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
45
49
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
50
+ * @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
46
51
  */
47
52
  export class CLIPipeProvider {
48
53
  /**
@@ -58,6 +63,7 @@ export class CLIPipeProvider {
58
63
  timeout: number;
59
64
  systemPromptFlag: string | null;
60
65
  onChunk: ((chunk: string) => void) | null;
66
+ parse: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | null;
61
67
  /**
62
68
  * Generate a response by piping messages to the CLI command.
63
69
  * @param {Message[]} messages - Conversation messages in OpenAI format.
@@ -70,6 +76,15 @@ export class CLIPipeProvider {
70
76
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
71
77
  */
72
78
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
79
+ /**
80
+ * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
81
+ * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
82
+ * ProviderError — never a silent fall-back to raw text.
83
+ * @param {string} stdout - Trimmed stdout from the CLI.
84
+ * @returns {GenerateResult}
85
+ * @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype).
86
+ */
87
+ _parseClaudeJson(stdout: string): GenerateResult;
73
88
  /**
74
89
  * Convert OpenAI-format messages to a plain text prompt.
75
90
  * @param {Message[]} messages
@@ -16,6 +16,7 @@ const { ProviderError } = require('./errors');
16
16
  * @property {number} [timeout=30000] - Timeout in milliseconds.
17
17
  * @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
18
18
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
19
+ * @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
19
20
  */
20
21
 
21
22
  class CLIPipeProvider {
@@ -33,6 +34,10 @@ class CLIPipeProvider {
33
34
  this.timeout = options.timeout ?? 30000;
34
35
  this.systemPromptFlag = options.systemPromptFlag || null;
35
36
  this.onChunk = options.onChunk || null;
37
+ if (options.parse != null && options.parse !== 'claude-json' && typeof options.parse !== 'function') {
38
+ throw new Error("[CLIPipeProvider] options.parse must be 'claude-json' or a function");
39
+ }
40
+ this.parse = options.parse || null;
36
41
  }
37
42
 
38
43
  /**
@@ -61,14 +66,77 @@ class CLIPipeProvider {
61
66
  }
62
67
 
63
68
  const prompt = this._formatPrompt(promptMessages);
64
- const text = await this._spawn(prompt, extraArgs);
69
+ const stdout = await this._spawn(prompt, extraArgs);
70
+
71
+ if (this.parse === 'claude-json') return this._parseClaudeJson(stdout);
72
+ if (typeof this.parse === 'function') {
73
+ const partial = this.parse(stdout) || {};
74
+ return {
75
+ text: '',
76
+ toolCalls: [],
77
+ ...partial,
78
+ usage: { inputTokens: 0, outputTokens: 0, ...(partial.usage || {}) },
79
+ };
80
+ }
65
81
  return {
66
- text,
82
+ text: stdout,
67
83
  toolCalls: [],
68
84
  usage: { inputTokens: 0, outputTokens: 0 },
69
85
  };
70
86
  }
71
87
 
88
+ /**
89
+ * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
90
+ * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
91
+ * ProviderError — never a silent fall-back to raw text.
92
+ * @param {string} stdout - Trimmed stdout from the CLI.
93
+ * @returns {GenerateResult}
94
+ * @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype).
95
+ */
96
+ _parseClaudeJson(stdout) {
97
+ let obj;
98
+ try {
99
+ obj = JSON.parse(stdout);
100
+ } catch (_) {
101
+ const preview = stdout.length > 200 ? `${stdout.slice(0, 200)}…` : stdout;
102
+ throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected JSON on stdout, got: ${preview}`, /** @type {any} */ ({ status: 0 }));
103
+ }
104
+ if (!obj || typeof obj !== 'object') {
105
+ throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected a JSON object, got ${obj === null ? 'null' : typeof obj}`, /** @type {any} */ ({ status: 0 }));
106
+ }
107
+ if (obj.is_error === true || obj.subtype !== 'success') {
108
+ const detail = typeof obj.result === 'string' ? obj.result : JSON.stringify(obj.result ?? null);
109
+ throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${obj.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
110
+ }
111
+
112
+ const u = (obj.usage && typeof obj.usage === 'object') ? obj.usage : {};
113
+ /** @type {import('../types').Usage} */
114
+ const usage = {
115
+ inputTokens: Number(u.input_tokens) || 0,
116
+ outputTokens: Number(u.output_tokens) || 0,
117
+ };
118
+ // Absent cache tiers mean the model didn't cache — omit rather than emit a synthetic 0 (per Usage docs).
119
+ if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
120
+ if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
121
+
122
+ // `modelUsage` is an object keyed by model id (e.g. {"claude-opus-4-8[1m]": {...}}) — take the first key.
123
+ const model = (obj.modelUsage && typeof obj.modelUsage === 'object')
124
+ ? (Object.keys(obj.modelUsage)[0] ?? null)
125
+ : null;
126
+
127
+ /** @type {GenerateResult} */
128
+ const result = {
129
+ text: typeof obj.result === 'string' ? obj.result : '',
130
+ toolCalls: [],
131
+ usage,
132
+ model,
133
+ };
134
+ // The CLI's own price is authoritative (subscription runs report an equivalent cost even at $0
135
+ // marginal) — feeds bareguard's USD axis with no local rate table. Only a finite number counts.
136
+ if (Number.isFinite(obj.total_cost_usd)) result.costUsd = obj.total_cost_usd;
137
+ return result;
138
+ }
139
+
72
140
  /**
73
141
  * Convert OpenAI-format messages to a plain text prompt.
74
142
  * @param {Message[]} messages
package/src/recurse.d.ts CHANGED
@@ -215,6 +215,11 @@ export type RecurseNode = {
215
215
  verdict: Verdict | null;
216
216
  incomplete: boolean;
217
217
  halted: boolean;
218
+ /**
219
+ * - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
220
+ * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
221
+ */
222
+ blocker?: string | undefined;
218
223
  /**
219
224
  * - The worker Loop's `metrics.tokens`.
220
225
  */
@@ -287,6 +292,12 @@ export type RecurseResult = {
287
292
  * back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
288
293
  */
289
294
  missingSlices?: string[] | undefined;
295
+ /**
296
+ * - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
297
+ * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
298
+ * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
299
+ */
300
+ blocker?: string | undefined;
290
301
  /**
291
302
  * - The audit node for this call (RC-10).
292
303
  */
@@ -424,6 +435,8 @@ export type Slice = {
424
435
  * @property {Verdict|null} verdict
425
436
  * @property {boolean} incomplete
426
437
  * @property {boolean} halted
438
+ * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
439
+ * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
427
440
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
428
441
  * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
429
442
  * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
@@ -449,6 +462,9 @@ export type Slice = {
449
462
  * @property {any} [best] - The best partial answer when `incomplete` (RC-9).
450
463
  * @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
451
464
  * back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
465
+ * @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
466
+ * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
467
+ * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
452
468
  * @property {RecurseNode} receipts - The audit node for this call (RC-10).
453
469
  */
454
470
  /**
package/src/recurse.js CHANGED
@@ -290,6 +290,8 @@ function auditSafeCtx(ctx, overrides = {}) {
290
290
  * @property {Verdict|null} verdict
291
291
  * @property {boolean} incomplete
292
292
  * @property {boolean} halted
293
+ * @property {string} [blocker] - (BA-11) set to `'governance-deny'` when this node stopped because its Loop
294
+ * short-circuited a consecutive-policy-deny spin (not a model failure). Mirrors `RecurseResult.blocker`.
293
295
  * @property {object|null} tokens - The worker Loop's `metrics.tokens`.
294
296
  * @property {{iterations: number, passed: boolean, temperatures: (number|null)[]}} [refineLeaf] - (BA-8) when this
295
297
  * leaf ran as a bounded refine loop: how many attempts it took and whether the deterministic sensor finally
@@ -316,6 +318,9 @@ function auditSafeCtx(ctx, overrides = {}) {
316
318
  * @property {any} [best] - The best partial answer when `incomplete` (RC-9).
317
319
  * @property {string[]} [missingSlices] - When `incomplete` because a child failed: the sub-task(s) that came
318
320
  * back incomplete (§9 scenario 1) — the anti-survivor-sum signal, not a quiet undercount.
321
+ * @property {string} [blocker] - Present when `incomplete` for a specific, actionable reason. `'governance-deny'`
322
+ * (BA-11): the worker's Loop short-circuited after N consecutive policy denials rather than burn to the
323
+ * budget cap — the caller can widen scope / re-gate / escalate instead of reading it as a model failure.
319
324
  * @property {RecurseNode} receipts - The audit node for this call (RC-10).
320
325
  */
321
326
 
@@ -510,6 +515,15 @@ async function recurse(task, ctx = {}, opts = {}) {
510
515
  node.incomplete = true;
511
516
  return { incomplete: true, best: out.text || null, receipts: node };
512
517
  }
518
+ // BA-11: a deny-spin short-circuit. The Loop stopped the worker after N consecutive governance denials
519
+ // (a governance deny is not a recoverable tool error — retrying variants would burn to the budget cap;
520
+ // probe-16: 16 calls, sensor never reached → incomplete). Surface it as a clean, LABELED incomplete so a
521
+ // caller can tell a governance block apart from a model failure and act (widen scope, re-gate, escalate).
522
+ if (typeof out.error === 'string' && out.error.startsWith('denied:')) {
523
+ node.incomplete = true;
524
+ node.blocker = 'governance-deny';
525
+ return { incomplete: true, best: out.text || null, blocker: 'governance-deny', receipts: node };
526
+ }
513
527
  if (out.error) {
514
528
  node.incomplete = true;
515
529
  return { incomplete: true, best: out.text || null, receipts: node };
@@ -673,6 +687,13 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
673
687
  node.incomplete = true;
674
688
  return { incomplete: true, best: null, receipts: node };
675
689
  }
690
+ // BA-11: a deny-spin inside a refine attempt (the Loop short-circuited after N consecutive governance
691
+ // denials, rethrown at recurse.js as `denied:<tool>`) is a LABELED governance block, not a model fault.
692
+ if (typeof err?.message === 'string' && err.message.startsWith('denied:')) {
693
+ node.incomplete = true;
694
+ node.blocker = 'governance-deny';
695
+ return { incomplete: true, best: null, blocker: 'governance-deny', receipts: node };
696
+ }
676
697
  node.incomplete = true;
677
698
  return { incomplete: true, best: null, receipts: node };
678
699
  }
package/types/index.d.ts CHANGED
@@ -86,6 +86,14 @@ export interface GenerateResult {
86
86
  * temperature, not the one requested — callers reporting an effective temperature must honor this.
87
87
  */
88
88
  temperatureDropped?: boolean;
89
+ /**
90
+ * Authoritative per-call cost in USD, reported by the provider itself — e.g. CLIPipeProvider
91
+ * `parse:'claude-json'` surfacing the claude CLI's own `total_cost_usd`, a real price with no local
92
+ * rate table. When a FINITE number the Loop prefers it over `estimateCost` and treats the round as
93
+ * priced (feeding bareguard's USD budget axis). `0` is a valid priced value (a subscription/marginal-$0
94
+ * run) — distinct from omitted/null, which means "couldn't price" and falls back to the rate table.
95
+ */
96
+ costUsd?: number;
89
97
  }
90
98
 
91
99
  /** A conversation message in OpenAI chat format. */