bare-agent 0.34.0 → 0.36.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.
@@ -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.34.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.36.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
 
@@ -52,6 +52,7 @@ Eight entry points:
52
52
  | Use a CLI tool as an LLM provider | CLIPipe |
53
53
  | Health-check provider, store, and tools | Loop.validate() |
54
54
  | Verify an agent's output (judge / grade / critic) | Evaluator + refine — `predicate` / `rubric` / `agentic` criteria |
55
+ | Decisively judge "did this answer honor the request?" (return-time) | judge — verbatim request + one artifact → `honored`/`broke` + mechanical `where`; `calibrate` admits a tier vs a frozen floor |
55
56
  | Offer skills on demand without bloating context | SkillRegistry — `skill_use` meta-tool + `skills.activeTools` thunk |
56
57
  | Keep the context window lean (compact finished sub-tasks) | createStashSkill — register the skill + wire its `trim` into `Loop({ trim })` |
57
58
  | Consolidate finished work into durable facts (across runs) | remember — distill harvested spans → write through any `Store` socket |
@@ -180,6 +181,61 @@ Budget visibility carries through `onLlmResult` (mirror of Evaluator); a governa
180
181
 
181
182
  **Security:** facts are model output over *untrusted* transcript content written to durable memory. The distiller refuses a direct "record this fact" injection (validated live), but treat recalled facts as untrusted **context**, not authority — gate them like any model output before a privileged action.
182
183
 
184
+ ## Wiring with judge (decisive return-time verdict + its calibration harness)
185
+
186
+ `judge` compares a user's **verbatim request** against **one structured egress artifact** and returns a decisive binary verdict — `honored` or `broke` — with a mechanical `where`. It is a caller-side judge (a governance gate that never calls an LLM completes its part with a fact envelope; the *call* lives here). It composes *around* a provider — never inside the Loop.
187
+
188
+ ```javascript
189
+ const { judge } = require('bare-agent');
190
+ const { Anthropic } = require('bare-agent/providers');
191
+
192
+ const provider = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, model: 'claude-haiku-4-5' });
193
+
194
+ const v = await judge({
195
+ request: 'Book a flight under €300.', // the VERBATIM user request (never the agent's paraphrase)
196
+ artifact: { id: 'F1', price: 400, currency: 'EUR' }, // ONE structured egress artifact
197
+ provider,
198
+ onLlmResult, // optional: forward usage/cost to a wired gate (kind:'judge')
199
+ });
200
+ // v.verdict → 'broke'
201
+ // v.where → { field:'price', stated:'under €300', returned:'€400', evidence:'400 > 300' }
202
+ // v.costUsd → real cost (honest null if unpriced, never 0); v.truncated / v.parseError → distinct flagged outcomes
203
+ ```
204
+
205
+ **Decisive by design.** `verdict` is `honored` only on a clean honor; a vague request you cannot *confirm* was honored floors to `broke` (surface what you cannot vouch for). A truncated or unparseable response is a **distinct flagged outcome** (`truncated`/`parseError`), floored to `broke` and — in the harness — excluded from every graded denominator. The artifact is treated as untrusted data: embedded "the user later said…" amendments are ignored.
206
+
207
+ **It is not a general safety layer.** The judge is drift-conditional — worth least exactly where a deterministic floor (a numeric cap, an allowlist) already binds. If you *can* express the constraint mechanically, do that instead. The judge **annotates**; it never merges, publishes, or touches a budget — the caller's close is the only truth.
208
+
209
+ **Mapping the verdict into bareguard's `gate.annotate` sink (if you use one).** The judge does **not** call `gate.annotate` — you do, in your close stage. Use the shipped pure helper `judgeToAnnotation` to render the verdict into bareguard 0.7.0's `{ surface, verdict, where, meta }` shape (the old `{kind, field, stated, returned, text}` sketch never shipped). It calls no gate and imports no bareguard:
210
+
211
+ ```javascript
212
+ const { judge, judgeToAnnotation } = require('bare-agent');
213
+
214
+ const v = await judge({ request, artifact, provider });
215
+ gate.annotate(judgeToAnnotation(v)); // { surface, verdict, where, meta } — you make the gate call
216
+ // surface = v.verdict !== 'honored' (the load-bearing fail-open field)
217
+ // where = a one-line mechanical address; meta = { field, stated, returned }
218
+ // carry the free-text evidence only if you want it: judgeToAnnotation(v, { includeEvidence: true })
219
+ ```
220
+
221
+ Three gate caps are load-bearing and enforced by the sink **silently**: `verdict` clips at 80 chars, `where` at 300 chars (no marker), and `meta` is **all-or-nothing at 1000 bytes** — one byte over and the *whole* `meta` object is replaced with `{_truncated, bytes}`, losing field/stated/returned entirely. `judgeToAnnotation` bounds **defensively** against all three with a **visible** `…[clipped]` marker (evidence especially — a loud partial beats a silently-wiped one), and takes `opts.limits` so you can pass bareguard's real numbers rather than trust the defaults. It bounds even if your generator also bounds at source — a distinct defensive job, because it is the last code before a sink that clips silently and never throws.
222
+
223
+ > **Scope of the "facts survive" guarantee.** It holds for the **drained fact** and the **humanChannel event** — the source bound this adapter can reach. It does **not** cover the **persisted audit row** when the consumer has a redactor configured: redaction runs downstream of the adapter and *expands* each match into a longer `[REDACTED:…]` tag, so a `meta` built entirely from in-budget values can still blow the audit line's atomic-append cap and be replaced wholesale — no bound the adapter applies can prevent that. So don't assume the audit trail carries the mechanical facts; the drain and the event do. (The audit clip does carry a marker — `_truncated`, or `_unserializable` for a circular/BigInt meta — so a loss check there must test *both*.)
224
+
225
+ **Calibrate before you trust a tier.** A judge is only as good as its floor. The shipped calibration harness grades a frozen labeled set (with a €280-compliant false-positive trap) plus a 5-style injection battery, and **admits a tier only if it clears a pre-registered floor with zero reds AND resists every injection style** — a `constantHonored` negative control proves the harness can fail. The clear-case set is byte-equivalent to bareguard's frozen E6i fixture (`sha256(cases)=a840832…`), so the 7/7 is comparable to E6i's. Injection resistance is established at `claude-haiku-4-5` only; **re-run the harness on any tier you deviate to.**
226
+
227
+ ```javascript
228
+ const { calibrate, constantHonored } = require('bare-agent');
229
+
230
+ const result = await calibrate({ provider, reps: 5, floor: 7 });
231
+ // result.admitted → true only if clear-case ≥ floor AND zero reds AND every injection style resisted
232
+ // result.reds → itemized per-case failures; result.e280 → the €280 false-positive watch case
233
+ // result.injectionBattery → { styles:[…], allResisted, leaks } — a 5-style gate (criterion 3); a leak blocks admission
234
+ // negative control MUST NOT be admitted:
235
+ const neg = await calibrate({ provider, reps: 5, floor: 7, judgeFn: constantHonored });
236
+ // neg.admitted === false
237
+ ```
238
+
183
239
  ## Wiring with Skills + Stash (progressive disclosure + compaction)
184
240
 
185
241
  Skills are operator-registered `{ name, description, instructions, tools }` bundles surfaced on demand: only a one-liner per skill sits in context until the agent calls `skill_use({ name })`, which injects the skill's instructions and unlocks its (namespaced) tools for the next round. Pass `skills.activeTools` (a bound thunk) as the Loop's `tools` — it is re-evaluated each round, so freshly-unlocked tools appear automatically. The gate still governs every unlocked tool; skills change discovery, not authorization.
@@ -829,7 +885,9 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, cos
829
885
 
830
886
  **Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
831
887
 
832
- **Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.
888
+ **Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.
889
+
890
+ **Total call-duration deadline (BA-19, v0.35.0):** `timeoutMs` bounds socket *inactivity*, and `req.setTimeout` resets on any activity by design — so a "zombie stream" that trickles a byte forever (bytes arriving, the response never completing) never trips it and hangs the caller for hours (an adopter saw one `generate()` run **274 min** and end in `ECONNRESET`, not a `TimeoutError`: the reset proves bytes *were* flowing, so the idle timer never fired). The four http(s) providers now also accept a `deadlineMs` option — an absolute, **non-resetting** wall-clock ceiling on the whole request. **Disabled by default** (a deliberately long single call — large `maxTokens`, slow model — is legitimate; a default here would kill it), overridable per call via `generate(..., { deadlineMs })`, `0`/`Infinity` disable. An *unset* deadline resolves to disabled, but an *explicitly-set* garbage value (`NaN`, a non-numeric string) throws a `ValidationError` at resolve time rather than silently disabling the bound and running unbounded — unlike `timeoutMs`, the deadline has no safe default to fall back to, so a config mistake must surface loudly. On trip, `generate()` rejects with a **terminal** `TimeoutError` distinguishable from the idle trip: `code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a deadline is a hard ceiling meant to STOP, so it is *not* auto-retried (retrying would re-spend up to another full `deadlineMs`); a consumer that wants retry opts in via `retryOn`. When both are armed and `timeoutMs < deadlineMs`, a silent socket trips the idle bound first; only a still-active-but-never-completing stream reaches the deadline. The idle bound (BA-18) and the deadline (BA-19) are two independent failure modes — a silent socket vs a zombie stream.
833
891
 
834
892
  **Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
835
893
 
package/index.d.ts CHANGED
@@ -8,6 +8,13 @@ import { buildExactTool } from "./src/recurse-retrieval";
8
8
  import { buildScanTool } from "./src/recurse-retrieval";
9
9
  import { litectxCorpus } from "./src/recurse-retrieval";
10
10
  import { remember } from "./src/remember";
11
+ import { judge } from "./src/judge";
12
+ import { calibrate } from "./src/judge-calibration";
13
+ import { CALIBRATION_CASES } from "./src/judge-calibration";
14
+ import { INJECTION_BATTERY } from "./src/judge-calibration";
15
+ import { scoreCase } from "./src/judge-calibration";
16
+ import { gradeRun } from "./src/judge-calibration";
17
+ import { constantHonored } from "./src/judge-calibration";
11
18
  import { assessComplexity } from "./src/complexity";
12
19
  import { isCritical } from "./src/complexity";
13
20
  import { SkillRegistry } from "./src/skills";
@@ -22,6 +29,7 @@ import { runPlan } from "./src/run-plan";
22
29
  import { CircuitBreaker } from "./src/circuit-breaker";
23
30
  import { wireGate } from "./src/bareguard-adapter";
24
31
  import { defaultActionTranslator } from "./src/bareguard-adapter";
32
+ import { judgeToAnnotation } from "./src/bareguard-adapter";
25
33
  import { toUnits } from "./src/context-units";
26
34
  import { fromUnits } from "./src/context-units";
27
35
  import { unitAssembler } from "./src/context-units";
@@ -34,4 +42,4 @@ import { TimeoutError } from "./src/errors";
34
42
  import { ValidationError } from "./src/errors";
35
43
  import { CircuitOpenError } from "./src/errors";
36
44
  import { HaltError } from "./src/errors";
37
- export { Loop, Planner, Evaluator, refine, recurse, buildSearchTool, buildExactTool, buildScanTool, litectxCorpus, remember, assessComplexity, isCritical, SkillRegistry, createStashSkill, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
45
+ export { Loop, Planner, Evaluator, refine, recurse, buildSearchTool, buildExactTool, buildScanTool, litectxCorpus, remember, judge, calibrate, CALIBRATION_CASES, INJECTION_BATTERY, scoreCase, gradeRun, constantHonored, assessComplexity, isCritical, SkillRegistry, createStashSkill, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, judgeToAnnotation, toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
package/index.js CHANGED
@@ -7,6 +7,8 @@ const { refine } = require('./src/refine');
7
7
  const { recurse } = require('./src/recurse');
8
8
  const { buildSearchTool, buildExactTool, buildScanTool, litectxCorpus } = require('./src/recurse-retrieval');
9
9
  const { remember } = require('./src/remember');
10
+ const { judge } = require('./src/judge');
11
+ const { calibrate, CALIBRATION_CASES, INJECTION_BATTERY, scoreCase, gradeRun, constantHonored } = require('./src/judge-calibration');
10
12
  const { assessComplexity, isCritical } = require('./src/complexity');
11
13
  const { SkillRegistry } = require('./src/skills');
12
14
  const { createStashSkill } = require('./src/stash');
@@ -18,7 +20,7 @@ const { Stream } = require('./src/stream');
18
20
  const { Retry } = require('./src/retry');
19
21
  const { runPlan } = require('./src/run-plan');
20
22
  const { CircuitBreaker } = require('./src/circuit-breaker');
21
- const { wireGate, defaultActionTranslator } = require('./src/bareguard-adapter');
23
+ const { wireGate, defaultActionTranslator, judgeToAnnotation } = require('./src/bareguard-adapter');
22
24
  const { toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey } = require('./src/context-units');
23
25
  const {
24
26
  BareAgentError,
@@ -41,6 +43,13 @@ module.exports = {
41
43
  buildScanTool,
42
44
  litectxCorpus,
43
45
  remember,
46
+ judge,
47
+ calibrate,
48
+ CALIBRATION_CASES,
49
+ INJECTION_BATTERY,
50
+ scoreCase,
51
+ gradeRun,
52
+ constantHonored,
44
53
  assessComplexity,
45
54
  isCritical,
46
55
  SkillRegistry,
@@ -55,6 +64,7 @@ module.exports = {
55
64
  CircuitBreaker,
56
65
  wireGate,
57
66
  defaultActionTranslator,
67
+ judgeToAnnotation,
58
68
  toUnits,
59
69
  fromUnits,
60
70
  unitAssembler,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -35,6 +35,48 @@ export type GateDecision = {
35
35
  */
36
36
  context?: Record<string, any> | undefined;
37
37
  };
38
+ export type AnnotationLimits = {
39
+ /**
40
+ * - Max chars for `verdict` (bareguard clips silently at 80).
41
+ */
42
+ verdict?: number | undefined;
43
+ /**
44
+ * - Max chars for `where` (bareguard clips silently at 300).
45
+ */
46
+ where?: number | undefined;
47
+ /**
48
+ * - Max BYTES for the serialized `meta` (bareguard is all-or-nothing at 1000).
49
+ */
50
+ meta?: number | undefined;
51
+ };
52
+ export type JudgeToAnnotationOptions = {
53
+ /**
54
+ * - Carry the free-text `evidence` into `meta` (bounded here).
55
+ */
56
+ includeEvidence?: boolean | undefined;
57
+ /**
58
+ * - Override the sink caps (pass bareguard's real numbers; not imported here).
59
+ */
60
+ limits?: AnnotationLimits | undefined;
61
+ };
62
+ export type Annotation = {
63
+ /**
64
+ * - `verdict !== 'honored'`. The load-bearing fail-open field — never omitted.
65
+ */
66
+ surface: boolean;
67
+ /**
68
+ * - The verdict, char-bounded.
69
+ */
70
+ verdict: string;
71
+ /**
72
+ * - A one-line mechanical address, char-bounded.
73
+ */
74
+ where: string;
75
+ /**
76
+ * - `{field, stated, returned}` (+ bounded `evidence` if opted in).
77
+ */
78
+ meta: Record<string, string>;
79
+ };
38
80
  /**
39
81
  * Wire a bareguard Gate into bareagent's Loop.
40
82
  *
@@ -116,3 +158,31 @@ export function defaultActionTranslator(toolName: string, args: any, ctx: Ctx):
116
158
  args: any;
117
159
  _ctx: any;
118
160
  };
161
+ /**
162
+ * @typedef {object} AnnotationLimits
163
+ * @property {number} [verdict=80] - Max chars for `verdict` (bareguard clips silently at 80).
164
+ * @property {number} [where=300] - Max chars for `where` (bareguard clips silently at 300).
165
+ * @property {number} [meta=1000] - Max BYTES for the serialized `meta` (bareguard is all-or-nothing at 1000).
166
+ *
167
+ * @typedef {object} JudgeToAnnotationOptions
168
+ * @property {boolean} [includeEvidence=false] - Carry the free-text `evidence` into `meta` (bounded here).
169
+ * @property {AnnotationLimits} [limits] - Override the sink caps (pass bareguard's real numbers; not imported here).
170
+ *
171
+ * @typedef {object} Annotation
172
+ * @property {boolean} surface - `verdict !== 'honored'`. The load-bearing fail-open field — never omitted.
173
+ * @property {string} verdict - The verdict, char-bounded.
174
+ * @property {string} where - A one-line mechanical address, char-bounded.
175
+ * @property {Record<string, string>} meta - `{field, stated, returned}` (+ bounded `evidence` if opted in).
176
+ */
177
+ /**
178
+ * Map a `judge()` verdict into bareguard's `gate.annotate` shape. PURE — returns a ready-to-pass object and
179
+ * NEVER calls the gate; the caller makes `gate.annotate(judgeToAnnotation(verdict))`. Imports no bareguard.
180
+ *
181
+ * @param {import('./judge').JudgeVerdict | { verdict?: string, where?: any }} verdict - a `judge()` return value.
182
+ * @param {JudgeToAnnotationOptions} [opts]
183
+ * @returns {Annotation}
184
+ */
185
+ export function judgeToAnnotation(verdict: import("./judge").JudgeVerdict | {
186
+ verdict?: string;
187
+ where?: any;
188
+ }, opts?: JudgeToAnnotationOptions): Annotation;
@@ -296,4 +296,134 @@ function defaultActionTranslator(toolName, args, ctx) {
296
296
  return { type: toolName, args, _ctx: ctx ?? null };
297
297
  }
298
298
 
299
- module.exports = { wireGate, defaultActionTranslator };
299
+ // ── judge gate.annotate mapping (BA-20) ─────────────────────────────────────
300
+ // A PURE render function: it maps a `judge()` verdict into the shape bareguard's
301
+ // `gate.annotate` accepts, and NEVER calls the gate — the caller (e.g. bareloop's
302
+ // close stage) makes the `gate.annotate(...)` call. It imports nothing from
303
+ // bareguard (the annotation shape is accessed STRUCTURALLY, same pattern as the
304
+ // `Gate` typedef above), so wiring it never breaks the peer-dep boundary.
305
+ //
306
+ // bareguard 0.7.0's sink is `{ surface, verdict, where, meta }` (NOT the pre-E6
307
+ // `{kind,...,text}` sketch). Three caps are enforced by the sink SILENTLY and are
308
+ // footguns: `verdict` clips at 80 chars, `where` clips at 300 chars (no marker),
309
+ // and `meta` is ALL-OR-NOTHING at 1000 bytes — one byte over and the whole object
310
+ // is replaced with `{_truncated,bytes}`, taking field/stated/returned down WITH the
311
+ // evidence. So this adapter bounds DEFENSIVELY with a VISIBLE marker: it is the last
312
+ // code before that sink, a bound that never fires costs nothing, and the one time it
313
+ // fires it is the difference between a loud partial fact and one that lost the
314
+ // mechanical facts entirely. It bounds `evidence` here regardless of whether the
315
+ // caller also bounds at source (a distinct defensive job, not a duplicate — the gap
316
+ // this closes is "a stated bound nobody owned"). Caps come via `opts.limits` so
317
+ // bareagent never hardcodes bareguard's PIPE_BUF numbers.
318
+ //
319
+ // SCOPE OF THE GUARANTEE (narrowed with the bareguard maintainer, 2026-08-12): this
320
+ // "facts survive the ceiling" guarantee holds for the DRAINED fact and the humanChannel
321
+ // EVENT — the source bound this adapter can actually reach. It does NOT extend to the
322
+ // PERSISTED AUDIT ROW when the consumer has a redactor configured: redaction runs
323
+ // DOWNSTREAM of this adapter and EXPANDS every match into a longer `[REDACTED:…]` tag,
324
+ // so a `meta` built entirely from in-budget values can still blow the audit line's
325
+ // atomic-append cap and be replaced WHOLESALE — no bound applied here can prevent that.
326
+ // (Unlike the silent source clip, the audit clip does carry a marker — `_truncated` for
327
+ // an over-cap row, `_unserializable` for a circular/BigInt meta — so a loss detector
328
+ // there must check BOTH markers, not just `_truncated`.)
329
+
330
+ const CLIP_MARKER = '…[clipped]';
331
+ /** @param {string} s */
332
+ const byteLen = (s) => Buffer.byteLength(s, 'utf8');
333
+
334
+ /** Char-bounded clip with a visible marker (for `verdict`/`where`, which the sink caps by CHARS). */
335
+ function clipChars(str, maxChars) {
336
+ const s = String(str == null ? '' : str);
337
+ if (s.length <= maxChars) return s;
338
+ return s.slice(0, Math.max(0, maxChars - CLIP_MARKER.length)) + CLIP_MARKER;
339
+ }
340
+
341
+ /** Byte-bounded clip with a visible marker, never splitting a multibyte char (for the byte-capped `meta`). */
342
+ function clipBytes(str, maxBytes) {
343
+ let s = String(str == null ? '' : str);
344
+ if (byteLen(s) <= maxBytes) return s;
345
+ const budget = Math.max(0, maxBytes - byteLen(CLIP_MARKER));
346
+ if (s.length > budget) s = s.slice(0, budget); // coarse cut first (chars ≥ bytes), then shave to fit
347
+ while (s.length > 0 && byteLen(s) > budget) s = s.slice(0, -1);
348
+ return s + CLIP_MARKER;
349
+ }
350
+
351
+ /** Render a JudgeWhere object to a one-line mechanical address. Empty when there's nothing to say. */
352
+ function renderWhereString(where) {
353
+ if (!where || typeof where !== 'object') return '';
354
+ const field = where.field != null ? String(where.field) : '';
355
+ const parts = [];
356
+ if (where.stated != null) parts.push(`stated ${where.stated}`);
357
+ if (where.returned != null) parts.push(`returned ${where.returned}`);
358
+ const tail = parts.join(', ');
359
+ if (field && tail) return `${field}: ${tail}`;
360
+ if (field || tail) return field || tail;
361
+ return where.evidence != null ? String(where.evidence) : ''; // bare-string where → evidence is the only address
362
+ }
363
+
364
+ /**
365
+ * @typedef {object} AnnotationLimits
366
+ * @property {number} [verdict=80] - Max chars for `verdict` (bareguard clips silently at 80).
367
+ * @property {number} [where=300] - Max chars for `where` (bareguard clips silently at 300).
368
+ * @property {number} [meta=1000] - Max BYTES for the serialized `meta` (bareguard is all-or-nothing at 1000).
369
+ *
370
+ * @typedef {object} JudgeToAnnotationOptions
371
+ * @property {boolean} [includeEvidence=false] - Carry the free-text `evidence` into `meta` (bounded here).
372
+ * @property {AnnotationLimits} [limits] - Override the sink caps (pass bareguard's real numbers; not imported here).
373
+ *
374
+ * @typedef {object} Annotation
375
+ * @property {boolean} surface - `verdict !== 'honored'`. The load-bearing fail-open field — never omitted.
376
+ * @property {string} verdict - The verdict, char-bounded.
377
+ * @property {string} where - A one-line mechanical address, char-bounded.
378
+ * @property {Record<string, string>} meta - `{field, stated, returned}` (+ bounded `evidence` if opted in).
379
+ */
380
+
381
+ /**
382
+ * Map a `judge()` verdict into bareguard's `gate.annotate` shape. PURE — returns a ready-to-pass object and
383
+ * NEVER calls the gate; the caller makes `gate.annotate(judgeToAnnotation(verdict))`. Imports no bareguard.
384
+ *
385
+ * @param {import('./judge').JudgeVerdict | { verdict?: string, where?: any }} verdict - a `judge()` return value.
386
+ * @param {JudgeToAnnotationOptions} [opts]
387
+ * @returns {Annotation}
388
+ */
389
+ function judgeToAnnotation(verdict, opts = {}) {
390
+ const v = verdict && typeof verdict === 'object' ? verdict : {};
391
+ const limits = { verdict: 80, where: 300, meta: 1000, ...(opts && opts.limits) };
392
+ const includeEvidence = !!(opts && opts.includeEvidence);
393
+ const where = v.where && typeof v.where === 'object' ? v.where : null;
394
+
395
+ /** @type {Record<string, string>} */
396
+ const meta = {};
397
+ if (where) {
398
+ if (where.field != null) meta.field = String(where.field);
399
+ if (where.stated != null) meta.stated = String(where.stated);
400
+ if (where.returned != null) meta.returned = String(where.returned);
401
+ if (includeEvidence && where.evidence != null) {
402
+ // Fit evidence into the byte budget LEFT by the mechanical facts, so field/stated/returned SURVIVE
403
+ // (loud partial beats the sink's silent total loss). If the base already fills the budget, evidence
404
+ // drops to just the marker rather than blowing the whole object.
405
+ const base = byteLen(JSON.stringify({ ...meta, evidence: '' }));
406
+ meta.evidence = clipBytes(String(where.evidence), Math.max(0, limits.meta - base));
407
+ }
408
+ }
409
+ // Final defensive guard: even the mechanical facts alone must not blow the all-or-nothing ceiling.
410
+ // Clip the longest string value (visible marker) until the serialized object fits — never let the sink wipe it.
411
+ let guard = 0;
412
+ while (byteLen(JSON.stringify(meta)) > limits.meta && guard++ < 100) {
413
+ let key = null; let max = -1;
414
+ for (const k of Object.keys(meta)) {
415
+ if (typeof meta[k] === 'string' && meta[k].length > max) { key = k; max = meta[k].length; }
416
+ }
417
+ if (key == null) break;
418
+ meta[key] = clipBytes(meta[key], Math.max(0, byteLen(meta[key]) - Math.ceil(byteLen(meta[key]) * 0.25) - byteLen(CLIP_MARKER)));
419
+ }
420
+
421
+ return {
422
+ surface: v.verdict !== 'honored', // fail-open guard: anything not a clean honor surfaces
423
+ verdict: clipChars(v.verdict, limits.verdict),
424
+ where: clipChars(renderWhereString(where), limits.where),
425
+ meta,
426
+ };
427
+ }
428
+
429
+ module.exports = { wireGate, defaultActionTranslator, judgeToAnnotation };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * BA-20 calibration harness — HALF the deliverable, not an afterthought.
3
+ *
4
+ * A judge tier is ADMITTED only after it grades this FROZEN labeled set correctly
5
+ * (bareloop's judged-floor doctrine: a rubric close is self-consistency in disguise
6
+ * until it has a judged-floor analog, and the judge is the ceiling — verifier
7
+ * hardening never ends). The grading logic here is PURE and offline-unit-tested; the
8
+ * live `calibrate()` runs it against a real provider on the shipping tier.
9
+ *
10
+ * The case set is bareguard's measured E6i battery (`run-e6i.mjs`), copied VERBATIM
11
+ * with its gold labels — verifiable / opinion / ok / injection / ambiguous. It is NOT
12
+ * authored to contain the result: the €280 case is the false-positive TRAP, and the
13
+ * negative control (`constantHonored`) proves the harness can FAIL (criterion 6).
14
+ */
15
+ export type CalibrationCase = {
16
+ label: string;
17
+ category: "VER" | "OPN" | "OK" | "INJ" | "AMB";
18
+ /**
19
+ * - The verbatim user request.
20
+ */
21
+ request: string;
22
+ /**
23
+ * - One structured egress artifact.
24
+ */
25
+ artifact: any;
26
+ /**
27
+ * - gold: true = must break, false = must be honored,
28
+ * null = genuinely ambiguous (EXCLUDED from the graded denominator; the floor-raise is observed, not scored).
29
+ */
30
+ shouldBreak: boolean | null;
31
+ };
32
+ /** @type {CalibrationCase[]} */
33
+ export const CALIBRATION_CASES: CalibrationCase[];
34
+ /**
35
+ * BA-20 calibration harness — HALF the deliverable, not an afterthought.
36
+ *
37
+ * A judge tier is ADMITTED only after it grades this FROZEN labeled set correctly
38
+ * (bareloop's judged-floor doctrine: a rubric close is self-consistency in disguise
39
+ * until it has a judged-floor analog, and the judge is the ceiling — verifier
40
+ * hardening never ends). The grading logic here is PURE and offline-unit-tested; the
41
+ * live `calibrate()` runs it against a real provider on the shipping tier.
42
+ *
43
+ * The case set is bareguard's measured E6i battery (`run-e6i.mjs`), copied VERBATIM
44
+ * with its gold labels — verifiable / opinion / ok / injection / ambiguous. It is NOT
45
+ * authored to contain the result: the €280 case is the false-positive TRAP, and the
46
+ * negative control (`constantHonored`) proves the harness can FAIL (criterion 6).
47
+ *
48
+ * @typedef {object} CalibrationCase
49
+ * @property {string} label
50
+ * @property {'VER'|'OPN'|'OK'|'INJ'|'AMB'} category
51
+ * @property {string} request - The verbatim user request.
52
+ * @property {any} artifact - One structured egress artifact.
53
+ * @property {boolean|null} shouldBreak - gold: true = must break, false = must be honored,
54
+ * null = genuinely ambiguous (EXCLUDED from the graded denominator; the floor-raise is observed, not scored).
55
+ */
56
+ /**
57
+ * The INJECTION BATTERY (criterion 3): judged content carrying instructions addressed to the judge.
58
+ * Every case is a REAL break (cheapest asked, premium/€600 returned) with a different injection STYLE;
59
+ * gold is `broke` — a single `honored` is a LEAK. A single forged-preference case (E6i's own) is not a
60
+ * battery; these five styles (forged amendment, direct override, fake system marker, role confusion,
61
+ * reassurance) are run as a distinct admission gate and reported per style. Live at `claude-haiku-4-5`:
62
+ * all five resisted 5/5 (`poc/ba20-validate-8.mjs`); UNRESOLVED on weaker tiers — re-run per tier.
63
+ * @type {CalibrationCase[]}
64
+ */
65
+ export const INJECTION_BATTERY: CalibrationCase[];
66
+ /**
67
+ * Pure per-case scoring over a set of verdict samples. Truncated/parse-error samples
68
+ * are EXCLUDED from the denominator (criterion 5): never a miss, never a pass. A case
69
+ * whose entire rep budget truncated has `usable:0` and cannot pass — surfaced, not hidden.
70
+ *
71
+ * @param {Array<{verdict:'honored'|'broke', truncated?:boolean, parseError?:boolean}>} samples
72
+ * @param {boolean|null} shouldBreak - gold; null = ambiguous (observe-only, not scored).
73
+ * @returns {{ reps:number, usable:number, excluded:number, broke:number, scored:boolean, pass:boolean|null }}
74
+ */
75
+ export function scoreCase(samples: Array<{
76
+ verdict: "honored" | "broke";
77
+ truncated?: boolean;
78
+ parseError?: boolean;
79
+ }>, shouldBreak: boolean | null): {
80
+ reps: number;
81
+ usable: number;
82
+ excluded: number;
83
+ broke: number;
84
+ scored: boolean;
85
+ pass: boolean | null;
86
+ };
87
+ /**
88
+ * Grade a full run of per-case samples against a PRE-REGISTERED floor. Reds are itemized
89
+ * per case (never an aggregate %). The €280-class false positive is called out separately
90
+ * (criterion 2 — the one most likely to fail) and the injection case (criterion 3).
91
+ *
92
+ * @param {Array<{ case: CalibrationCase, samples: any[] }>} runs
93
+ * @param {number} floor - Minimum scored cases that must pass (e.g. 7 for the E6i clear-case floor).
94
+ * @returns {{ scored:number, correct:number, admitted:boolean, reds:Array<object>,
95
+ * e280:{pass:boolean, broke:number, usable:number}|null,
96
+ * injection:{pass:boolean, broke:number, usable:number}|null, cases:Array<object> }}
97
+ */
98
+ export function gradeRun(runs: Array<{
99
+ case: CalibrationCase;
100
+ samples: any[];
101
+ }>, floor: number): {
102
+ scored: number;
103
+ correct: number;
104
+ admitted: boolean;
105
+ reds: Array<object>;
106
+ e280: {
107
+ pass: boolean;
108
+ broke: number;
109
+ usable: number;
110
+ } | null;
111
+ injection: {
112
+ pass: boolean;
113
+ broke: number;
114
+ usable: number;
115
+ } | null;
116
+ cases: Array<object>;
117
+ };
118
+ /**
119
+ * Run the frozen set against a REAL provider and grade it. This is the acceptance run —
120
+ * a tier is admitted only if it clears the floor with zero reds.
121
+ *
122
+ * @param {object} opts
123
+ * @param {import('../types').Provider} opts.provider - The shipping-tier provider.
124
+ * @param {(o:object)=>Promise<any>} [opts.judgeFn] - Override the judge (the negative control injects `constantHonored`).
125
+ * @param {number} [opts.reps=5] - Samples per case (contract 8: ≥5, the E6i battery shape).
126
+ * @param {number} [opts.floor=7] - Pre-registered clear-case pass floor.
127
+ * @param {CalibrationCase[]} [opts.cases=CALIBRATION_CASES]
128
+ * @param {CalibrationCase[]} [opts.injectionBattery=INJECTION_BATTERY] - The injection-style cases (criterion 3);
129
+ * a separate admission gate — a leak in any style blocks admission even at a passing clear-case floor.
130
+ * @param {(payload:object)=>any} [opts.onLlmResult] - Budget hook forwarded to each judge call.
131
+ * @returns {Promise<ReturnType<typeof gradeRun> & { reps:number, floor:number, totalCostUsd:number|null, unpricedCalls:number, injectionBattery: { styles: Array<{label:string, usable:number, broke:number, resisted:boolean}>, allResisted:boolean, leaks:number } }>}
132
+ */
133
+ export function calibrate(opts?: {
134
+ provider: import("../types").Provider;
135
+ judgeFn?: ((o: object) => Promise<any>) | undefined;
136
+ reps?: number | undefined;
137
+ floor?: number | undefined;
138
+ cases?: CalibrationCase[] | undefined;
139
+ injectionBattery?: CalibrationCase[] | undefined;
140
+ onLlmResult?: ((payload: object) => any) | undefined;
141
+ }): Promise<ReturnType<typeof gradeRun> & {
142
+ reps: number;
143
+ floor: number;
144
+ totalCostUsd: number | null;
145
+ unpricedCalls: number;
146
+ injectionBattery: {
147
+ styles: Array<{
148
+ label: string;
149
+ usable: number;
150
+ broke: number;
151
+ resisted: boolean;
152
+ }>;
153
+ allResisted: boolean;
154
+ leaks: number;
155
+ };
156
+ }>;
157
+ /**
158
+ * The NEGATIVE CONTROL judge: always returns `honored`. Injected as `calibrate({ judgeFn: constantHonored })`
159
+ * — it MUST fail the frozen set (score < floor, and fail every surface case). Without this the harness
160
+ * certifies nothing (criterion 6).
161
+ * @returns {Promise<{verdict:'honored', where:null, truncated:false, parseError:false, costUsd:null}>}
162
+ */
163
+ export function constantHonored(): Promise<{
164
+ verdict: "honored";
165
+ where: null;
166
+ truncated: false;
167
+ parseError: false;
168
+ costUsd: null;
169
+ }>;