bareloop 0.12.0 → 0.14.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/src/run.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
4
4
  // and the decision-ready stops. It composes the plan flow below it and
5
5
  // interprets nothing itself.
6
- // Design record: docs/02-features/2026-07-12-n2-headless-loop-design.md.
6
+ // Design record: docs/product/2026-07-12-n2-headless-loop-design.md.
7
7
  //
8
8
  // The legacy operator-authored `steps[]` path was DELETED 2026-07-26 (PRD
9
9
  // v1.32): the drafting call, config-v1, the per-step interpret loops and the
@@ -16,6 +16,7 @@ import { LiteCtx } from 'litectx';
16
16
  import { validateJob, jobSpecHash, checkApproval } from './job.js';
17
17
  import { runPlan } from './planrun.js';
18
18
  import { SMOKE_STORE, resolveHumanRuling } from './kinds.js';
19
+ import { readShimArm } from './readshim.js';
19
20
 
20
21
  /** @typedef {{code: string, path: string, detail?: string}} Red */
21
22
 
@@ -156,6 +157,19 @@ async function primitiveSmoke(workdir) {
156
157
  * collision walk would mint a `-2` beside the work the resume exists to continue. The
157
158
  * fold's precedent exactly: the chain's state is DECLARED, never re-derived. Forwarded
158
159
  * verbatim to the plan flow; absent is the cold path.
160
+ * @param {boolean|'cap'|'diff'} [opts.readShim=false] THE READ SHIM (src/readshim.js) —
161
+ * WHICH ARM to run, threaded to the plan flow. The Phase 2 pre-registration's four:
162
+ * `false` A0 — off (the default)
163
+ * `'cap'` A1 — cap + pointer + next-unseen-slice + G1, no diff
164
+ * `'diff'` A2 — the diff lever alone: no cap, no pointer, no G1
165
+ * `true` A3 — every lever
166
+ * Anything else THROWS below, before a token is spent: a mis-spelled arm coerced
167
+ * by truthiness into A3 would run one treatment under another's label and be
168
+ * invisible in the results afterwards.
169
+ * OFF is byte-identical to the pre-shim run in every observable (G1 included): the
170
+ * frozen A0 baseline arm has to be exactly today, so a guard firing under a disabled
171
+ * shim would make the baseline a treatment arm. The default-flip belongs to a
172
+ * paid contrast nobody has approved yet (the `layerRoot` precedent, F41).
159
173
  * @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
160
174
  * territory, threaded to the plan flow. Defaults OFF
161
175
  * (decided 2026-07-21): fixation is extinct on every current job (F41), so
@@ -187,7 +201,15 @@ async function primitiveSmoke(workdir) {
187
201
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
188
202
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
189
203
  */
190
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
204
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, readShim = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
205
+ // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
206
+ // before the ledger, before the approval gate, before a byte of the spec is read.
207
+ // An unrecognised spelling throws here at zero cost instead of being coerced by
208
+ // truthiness into A3 and running a whole paid battery row under an arm label it
209
+ // never executed — a wrong result that looks like a fine one, which is the exact
210
+ // blind-instrument failure this programme keeps logging. Only the guard runs
211
+ // here; the flag itself is threaded onward to the plan flow as written.
212
+ readShimArm(readShim);
191
213
  // 0. the ledger's counters, declared FIRST so that every job-end — including
192
214
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
193
215
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -232,6 +254,15 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
232
254
  // outcome — keying on the outcome would floor the exact stop too, which is the same
233
255
  // dishonesty pointing the other way.
234
256
  let cutMidCall = false;
257
+ // F115 — did any worker call in this run absorb a transport-class retry
258
+ // (hamr's ruling: one retry, transport-only, planrun.js `withTransportRetry`)?
259
+ // The FIRST attempt of a retried call may already have been billed and threw
260
+ // before returning any usage figure — the same unknown a self-healed stall
261
+ // and a mid-call wall cut carry, so the same one-way floor applies even when
262
+ // the retry recovered and the run finishes green. Read off the `transport-retry`
263
+ // spine record's presence alone, never its `recovered` field: a recovered
264
+ // retry still hides the first attempt's possible spend.
265
+ let transportRetried = false;
235
266
  // The money on the terminal record, and whether the money is EXACT. `spentUsd`
236
267
  // is the accumulated sum of PRICED rounds ONLY — never an estimate derived
237
268
  // from tokens or averages (cap-not-estimate). When any round came back
@@ -241,7 +272,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
241
272
  // branch on field presence.
242
273
  // ONE spelling of the four causes, so the terminal and the in-flight money readout can
243
274
  // never disagree about whether the same run's figure is exact.
244
- const spendComplete = () => !unpriced && !stalled && !cutMidCall && !priorFloor;
275
+ const spendComplete = () => !unpriced && !stalled && !cutMidCall && !priorFloor && !transportRetried;
245
276
  const spend = () => ({
246
277
  spentUsd,
247
278
  spendComplete: spendComplete(),
@@ -342,6 +373,10 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
342
373
  // One-way, and read by `spend()` — the run ends on this record either way, but the
343
374
  // between-rounds stop must keep its exact figure rather than inheriting a floor.
344
375
  if (type === 'wall-halt' && /** @type {any} */ (data)?.cutMidCall === true) cutMidCall = true;
376
+ // F115 — one-way, same lane as `stall`/`cutMidCall`: a transport retry's
377
+ // FIRST attempt threw before any usage figure came back, so the priced sum
378
+ // is a floor for the rest of the run regardless of `recovered`.
379
+ if (type === 'transport-retry') transportRetried = true;
345
380
  return emit(type, data);
346
381
  };
347
382
  const pricingRed = () => {
@@ -356,7 +391,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
356
391
  // accounts it natively (F12) and the job-end money contract is unchanged.
357
392
  {
358
393
  const outcome = await runPlan(job, {
359
- workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
394
+ workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, readShim, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
360
395
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
361
396
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
362
397
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
package/src/selection.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
2
- // pick (design record docs/02-features/2026-08-01-layer-3-reuse-design.md, D3).
2
+ // pick (design record docs/product/2026-08-01-layer-3-reuse-design.md, D3).
3
3
  //
4
4
  // hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
5
5
  // user can choose or auto-choose, or do we want llm to always decide (we still need to
package/src/tools.js CHANGED
@@ -21,7 +21,7 @@ const require = createRequire(import.meta.url);
21
21
  const { createShellTools } = require('bare-agent/tools');
22
22
  // litectx's rank-tiered render — a free function, not a ctx method (its contract's
23
23
  // own recipe: the caller slices the chunk body, compress renders the signature tier)
24
- const { compress: compressNode } = require('litectx');
24
+ const { compress: compressNode, StalePointerError } = require('litectx');
25
25
 
26
26
  import { extractArtifact, priceOf } from './text.js';
27
27
  // The isolate verbs write MODEL-AUTHORED text into `<workdir>/.litectx` — a file
@@ -243,8 +243,13 @@ const LINE_SPACE = 'Line numbers here are 0-BASED index positions from the repos
243
243
  * RESULT is invisible cannot be judged: a `ctx_get` that silently reds (stale pointer,
244
244
  * bad range) looks exactly like one that worked, and the worker's fallback to a
245
245
  * whole-file read looks like a free choice instead of a forced one.
246
+ * @param {{onStalePointer?: ((req: {file: string, path: string, startLine: number, endLine: number, detail: string}) => {text: string, outcome: string}|null)|null}} [opts]
247
+ * `onStalePointer`: the read shim's stale-pointer serve, wired ONLY by a capping
248
+ * arm (see `createReadShim`). Absent — every caller today except that one — this
249
+ * whole seam is byte-identical to what it was, which is what keeps A0 the
250
+ * untouched baseline an experiment has already run against.
246
251
  */
247
- export function createCtxTools(lc, workdir, emit) {
252
+ export function createCtxTools(lc, workdir, emit, { onStalePointer = null } = {}) {
248
253
  // ctx_get's failure contract, generalised to the palette (review 2026-07-31).
249
254
  // `emit` exists in this file because a verb whose RESULT is invisible cannot be
250
255
  // judged — and a THROWN call is the most invisible result there is: it looks
@@ -337,6 +342,33 @@ export function createCtxTools(lc, workdir, emit) {
337
342
  // StalePointerError: the file changed after indexing, so these lines now describe
338
343
  // DIFFERENT code. Its message IS the recovery instruction — hand it to the worker.
339
344
  const detail = String(/** @type {Error} */ (e)?.message || e);
345
+ // …and under a CAPPING read-shim arm, the recovery instruction is not
346
+ // enough. That arm's own strategy line steers the worker HERE for a whole
347
+ // function, and a worker that just edited the file it is working on — the
348
+ // normal case — gets a stale pointer and zero bytes for a paid round
349
+ // (measured: 5 of 10 ctx_get calls across the A1 arm's three green runs).
350
+ // So the shim serves the requested LINE RANGE from disk, labelled; see
351
+ // `serveStale`. The branch is on the TYPED error, never on the message
352
+ // prose — a prose sniff would fire on any failure whose wording happens
353
+ // to match, and litectx exports the class precisely so it need not.
354
+ if (onStalePointer && e instanceof StalePointerError) {
355
+ // The gate judged `resolve(workdir, expandHome(path))` for this call
356
+ // (toolAction, 'ctx_get'); the serve reads that same absolute path and
357
+ // no other, so it can never reach a byte the fence did not allow.
358
+ const served = onStalePointer({
359
+ file: resolve(workdir, expandHome(String(p))),
360
+ path: rel,
361
+ startLine: Number(startLine),
362
+ endLine: Number(endLine),
363
+ detail,
364
+ });
365
+ if (served && typeof served.text === 'string') {
366
+ // No `detail` on the event: the served TEXT is file content and the
367
+ // spine is append-only. Bytes and the outcome are what a reader needs.
368
+ emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: served.outcome, bytes: Buffer.byteLength(served.text) });
369
+ return served.text;
370
+ }
371
+ }
340
372
  emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'stale', bytes: 0, detail });
341
373
  return `stale pointer: ${detail}`;
342
374
  }
@@ -0,0 +1,71 @@
1
+ // hamr's ruling, verbatim: "one retry for transport layer failure and
2
+ // reporting with the rest end of gate." (F115, docs/logs/FINDINGS.md.)
3
+ //
4
+ // A transport failure is fetch THROWING — the request never produced an HTTP
5
+ // response at all (a TLS fault mid-record, a reset socket, a DNS/network
6
+ // failure). That is a DIFFERENT unknown from an HTTP response the provider
7
+ // DID send (4xx/5xx/429): a response means the provider answered, so
8
+ // bare-agent's own retry policy for those stays exactly what it is — this
9
+ // module never widens that. Retrying only the no-response case is the whole
10
+ // point: a call that dies mid-read may already have been billed (retry can
11
+ // pay twice), so the retry budget stays as tight as hamr authorized it —
12
+ // exactly one extra attempt, never configurable upward from a job spec or
13
+ // argv (tighten-only doctrine).
14
+ //
15
+ // Scope: this module wires ONE seam — the worker Loop built in
16
+ // src/planrun.js's `newLoop` (~line 2239), which is the ONE factory behind
17
+ // every scout/drafter/step/fix-worker call. It deliberately does NOT reach
18
+ // the judge loops (src/judged.js), the native/CLI loop (src/planrun.js
19
+ // ~2089), or the authoring scout (src/authorscout.js) — none of those are in
20
+ // scope of hamr's ruling and each has its own transport story.
21
+
22
+ /** The fixed retry budget: ONE extra attempt after the first transport
23
+ * throw. Never read from a job spec or CLI flag — raising it is arbiter
24
+ * territory (only a code change hamr signs, never a run-time knob). */
25
+ export const TRANSPORT_RETRIES = 1;
26
+
27
+ /** bare-agent's `Retry.call` counts ATTEMPTS, not retries. */
28
+ export const TRANSPORT_MAX_ATTEMPTS = TRANSPORT_RETRIES + 1;
29
+
30
+ /**
31
+ * Is `err` a transport-class failure — the request never produced an HTTP
32
+ * response? True for: a TLS fault mid-record ("bad record mac" and kin), a
33
+ * reset/broken/timed-out socket (ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED,
34
+ * ENETUNREACH — by code on the error itself or on `err.cause`), and
35
+ * `fetch failed` wrapping a network `cause`. False for anything carrying an
36
+ * HTTP status (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
37
+ * `err.retryable === false` — those are provider RESPONSES, not transport
38
+ * casualties, and bare-agent's own policy governs them unchanged.
39
+ * @param {any} err
40
+ * @returns {boolean}
41
+ */
42
+ export function isTransportFailure(err) {
43
+ if (err == null) return false;
44
+ // An explicit HTTP response is never a transport casualty, no matter what
45
+ // its message says — the provider answered.
46
+ const status = err.status ?? err.statusCode;
47
+ if (typeof status === 'number') return false;
48
+ if (err.retryable === false) return false;
49
+
50
+ const codeOf = (e) => (e && typeof e.code === 'string' ? e.code : null);
51
+ const TRANSPORT_CODES = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNREFUSED', 'ENETUNREACH', 'EAI_AGAIN']);
52
+ const code = codeOf(err) ?? codeOf(err.cause);
53
+ if (code && TRANSPORT_CODES.has(code)) return true;
54
+
55
+ const message = String(err.message ?? err ?? '');
56
+ if (/bad record mac/i.test(message)) return true;
57
+ if (TRANSPORT_CODES.has(message.trim())) return true;
58
+ for (const c of TRANSPORT_CODES) if (message.includes(c)) return true;
59
+ if (/fetch failed/i.test(message)) {
60
+ // "fetch failed" alone is Node's generic wrapper — it is only a
61
+ // transport casualty when its cause names a network fault, never on
62
+ // message text alone (a fetch can also fail on a non-network throw
63
+ // upstream, and that must not silently qualify).
64
+ const causeCode = codeOf(err.cause);
65
+ if (causeCode) return true;
66
+ const causeMsg = String(err.cause?.message ?? '');
67
+ if (/bad record mac/i.test(causeMsg) || [...TRANSPORT_CODES].some((c) => causeMsg.includes(c))) return true;
68
+ return false;
69
+ }
70
+ return false;
71
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Summarize what a run DID with its tools, from its gate-audit records
3
+ * alone. Pure and read-only: no IO, no spine writes, no verdict.
4
+ *
5
+ * Counting rules:
6
+ * - `llm` rows are provider rounds, never tool calls — always excluded.
7
+ * - a tool call counts toward `totalCalls`/`byTool`/`uniquePaths`/repeats
8
+ * only when `decision === 'allow'`; a `deny` is tallied separately in
9
+ * `denied` and touches nothing else (denied ≠ done).
10
+ * - `byTool` is keyed by `action.args.tool` when present, else
11
+ * `action.type` (an edit or ctx_remember has no `args.tool`).
12
+ * - exact repeats: see {@link repeatKey}. A first occurrence of an action
13
+ * is not itself a "repeat" — only the 2nd, 3rd, ... occurrences count,
14
+ * so `repeats` can never exceed `totalCalls - byTool-group-count`.
15
+ * @param {Array<{run_id?: string, action?: {type?: string, path?: string, args?: any}, decision?: string|null}>} events
16
+ * @param {{runId?: string}} [opts]
17
+ * @returns {{totalCalls: number, byTool: Record<string, number>, denied: number,
18
+ * uniquePaths: number, repeats: number, repeatPct: number|null}}
19
+ */
20
+ export function runBehaviour(events: Array<{
21
+ run_id?: string;
22
+ action?: {
23
+ type?: string;
24
+ path?: string;
25
+ args?: any;
26
+ };
27
+ decision?: string | null;
28
+ }>, { runId }?: {
29
+ runId?: string;
30
+ }): {
31
+ totalCalls: number;
32
+ byTool: Record<string, number>;
33
+ denied: number;
34
+ uniquePaths: number;
35
+ repeats: number;
36
+ repeatPct: number | null;
37
+ };
38
+ /**
39
+ * Render a {@link runBehaviour} summary as the agreed printable block
40
+ * (2–4 short lines). A zero-call run formats plainly ("0 tool calls") rather
41
+ * than a NaN-laced percentage — an empty run is a complete, reportable
42
+ * answer, not a division error.
43
+ * @param {ReturnType<typeof runBehaviour>} summary
44
+ * @returns {string}
45
+ */
46
+ export function formatBehaviour(summary: ReturnType<typeof runBehaviour>): string;
package/types/index.d.ts CHANGED
@@ -9,8 +9,9 @@ export { runJob, ACCOUNTED_ROUND_TYPES } from "./run.js";
9
9
  export { BRIDGE_SCHEMA, validateBridge, deriveStatus, listingRow, loadGate, mintBridge, appendGreen, appendRed, loadBridge, loadRegistry, saveBridge, makeRegistry, registryExists, QUARANTINED_VERDICTS, QUARANTINED_CODE, quarantinesCredit, newestEligibleVersion, reuseEligibility, recordDoor } from "./bridges.js";
10
10
  export { renderListing, selectionPrompt } from "./selection.js";
11
11
  export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow } from "./reuse.js";
12
- export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from "./ledger.js";
12
+ export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES, VOUCHED_RATE_SOURCES, rateProvenance, spendProvenance } from "./ledger.js";
13
13
  export { workBranchName, prepareWorkBranch, WORK_BRANCH_PREFIX, WORK_BRANCH_RE } from "./workbranch.js";
14
+ export { createReadShim, wrapReadTool, readShimArm, readShimStrategy, READ_SHIM_CAP, READ_SHIM_STRATEGY, READ_SHIM_DIFF_STRATEGY, READ_SHIM_ARMS } from "./readshim.js";
14
15
  export { runDeclaredClose, runStage, seedRead, seedAtHead, seedListing, changedSet, makeSeedTrees, LIVE_KINDS, STOP_FAULTS, JUDGED_MARKER, EXIT_GREEN, EXIT_RED, EXIT_STOP, HUMAN_DECISIONS, SEED_EXEMPT_KINDS, JUDGED_FLOOR_KIND, normalizeHumanRuling, resolveHumanRuling, JUDGE_ATTEMPTS, MAX_JUDGED_PATHS, REVIEW_DOOR, DOOR_OPEN_OUTCOMES, REVIEW_DOOR_CLASSES, doorOpens, mechanicalStages } from "./kinds.js";
15
16
  export { answerReviewDoor, doorRecordOf, doorAgeGate } from "./reviewdoor.js";
16
17
  export { JUDGE_MODEL, JUDGE_MAX_TOKENS, JUDGE_RULES, JUDGE_RULE_IDS, LOCATE_AXES, LOCATE_LABEL, validateCard, validateFacts, locatePrompt, runLocate, decide, defaultJudgeLoop, CALIBRATION_SIZE, CASE_VERDICTS, validateCalibrationSet, validateJudgedArtifacts, expectedOf } from "./judged.js";
@@ -21,4 +22,5 @@ export { runAuthorScout, buildSeedListing, seedFileList, classifySurvey, AUTHOR_
21
22
  export { scrubRaw, RAW_PERSIST_MAX, RAW_TRIM_MARKER } from "./text.js";
22
23
  export { authorClose, authorPrompt, declarationSchema, makeLoopGenerate, MAX_REVISIONS, QUESTION_SETS, GREEN_QUESTIONS, SOFTGREEN_QUESTIONS, CLASS_STATEMENTS, questionsFor, requiredAnswersFor } from "./authorflow.js";
23
24
  export { validateCloseDecl, runDeclaredStages, declaredStages, isDeclaredClose, guardNames, closeGrade, DECLARED_GENRES, DECLARED_GAP_PREFIX, DECLARED_GAP_KEEP, DECLARED_CLOSE_CLASSES, CLOSE_DECL_FIELDS, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED, HUMAN_CHECKPOINTS } from "./declaredclose.js";
25
+ export { runBehaviour, formatBehaviour } from "./behaviour.js";
24
26
  export { runInterview, authorCloseForJob, assembleSpec, prepareSigning, refusalEvents, refuseLockedKind, GENRE, REFUSAL_LIB, REFUSAL_CATEGORY, AUTHORED_SPEC_FIELDS } from "./authorjob.js";
package/types/ledger.d.ts CHANGED
@@ -48,8 +48,66 @@ export function updateLedger({ ledgerFile, spineFiles }: {
48
48
  appended: object[];
49
49
  fold: Record<string, FoldEntry>;
50
50
  };
51
+ /**
52
+ * Read one spend record's rate PROVENANCE. Four-way, and the two absent-ish cases are
53
+ * deliberately distinct facts:
54
+ * - `'vouched'` — a `VOUCHED_RATE_SOURCES` value: somebody stands behind the rate.
55
+ * - `'guessed'` — a rate was used and nobody vouched for it (`'tier'`, `'default'`, or
56
+ * any future value we do not recognise).
57
+ * - `'unpriced'` — `rateSource: null`: nothing was priced, so there was no rate to have
58
+ * guessed. Upstream said so; we are not inferring it.
59
+ * - `'unknown'` — the record carries no `rateSource` at all. Every round archived before
60
+ * this signal existed lands here and is NEVER backfilled: an absent label
61
+ * is reported as unknown, never reconstructed, and never rounded up to
62
+ * vouched (F6's unknown-is-never-zero, applied to provenance).
63
+ * @param {any} record a `worker-round` / `judge-round` / `worker-turn` spine record
64
+ * @returns {'vouched'|'guessed'|'unpriced'|'unknown'}
65
+ */
66
+ export function rateProvenance(record: any): "vouched" | "guessed" | "unpriced" | "unknown";
67
+ /** @typedef {{rounds: number, usd: number, unpricedRounds: number}} ProvenanceBucket */
68
+ /**
69
+ * How much of a run's spend was priced by a guess. Pure fold over one spine's events,
70
+ * REPORTING ONLY — it mints nothing, halts nothing and refuses nothing.
71
+ *
72
+ * `usd` sums only FINITE costs; a round whose cost is not a finite number is counted in
73
+ * `unpricedRounds` instead of being summed as $0 (F6 — unpriced is never free), so a
74
+ * bucket's `usd` is a FLOOR whenever its `unpricedRounds` is non-zero.
75
+ * @param {any[]} events one spine's parsed events
76
+ * @returns {{vouched: ProvenanceBucket, guessed: ProvenanceBucket, unpriced: ProvenanceBucket, unknown: ProvenanceBucket}}
77
+ */
78
+ export function spendProvenance(events: any[]): {
79
+ vouched: ProvenanceBucket;
80
+ guessed: ProvenanceBucket;
81
+ unpriced: ProvenanceBucket;
82
+ unknown: ProvenanceBucket;
83
+ };
51
84
  /** severity order, worst-first (the fold's display order; frequency ranks within) */
52
85
  export const LEDGER_CLASSES: readonly string[];
86
+ /**
87
+ * The two rate sources somebody VOUCHED for: `'provider'` (the provider reported its own
88
+ * authoritative cost, e.g. the claude CLI's `total_cost_usd`) and `'caller'` (we passed
89
+ * the rate in ourselves). Everything else bare-agent can report is a built-in guess.
90
+ *
91
+ * An ALLOW-LIST of vouched values, never a deny-list of guessed ones — this is the
92
+ * load-bearing line of the whole signal, for two reasons.
93
+ *
94
+ * First: BOTH of bareloop's production models resolve to `'tier'`, not to `'default'`
95
+ * (`claude-sonnet-5` and `claude-haiku-4-5-…` each match a recognized Claude tier by
96
+ * model-id substring, so bare-agent prices them off a hardcoded tier rate that could
97
+ * have drifted from Anthropic's). A "is this a guess?" test spelled
98
+ * `rateSource === 'default'` would therefore read EVERY normal bareloop round as
99
+ * confidently priced and see none of the population it exists to measure — this repo's
100
+ * blind-instrument class, seven recorded instances, and not becoming the eighth.
101
+ *
102
+ * Second: written this way, any value bare-agent adds later that we do not recognise
103
+ * reads as a GUESS rather than as vouched. The fail-safe direction, by construction.
104
+ */
105
+ export const VOUCHED_RATE_SOURCES: readonly string[];
106
+ export type ProvenanceBucket = {
107
+ rounds: number;
108
+ usd: number;
109
+ unpricedRounds: number;
110
+ };
53
111
  export type Sample = {
54
112
  spine: string;
55
113
  seq: number;
package/types/plan.d.ts CHANGED
@@ -72,7 +72,7 @@ export function closeStagesOf(job: any): any[] | null;
72
72
  * job spec. Never throws on JSON text or plain parsed data; every failure is
73
73
  * a named red. Returns the parsed plan on ok (single parse), null on any red.
74
74
  * @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
75
- * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[] }} [opts] `job`: the
75
+ * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[], readShim?: boolean|'cap'|'diff' }} [opts] `job`: the
76
76
  * validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
77
77
  * menu all come from it — a missing or non-plan-shape job fails CLOSED);
78
78
  * `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
@@ -94,14 +94,23 @@ export function closeStagesOf(job: any): any[] | null;
94
94
  * only). Rule B — a redraft may not drop one (`check-shed`): exits without
95
95
  * the check verify FORM alone, so a shed lets the run "green" unearned
96
96
  * (u-msdsmkid mechanism b; the 3 archived sheds were all on step-red runs).
97
+ * `readShim` (G1): the read shim's ARM for this run — `false` (default), `'cap'`,
98
+ * `'diff'` or `true`. The arms that CAP (`'cap'`, `true`) add one rule: a step
99
+ * granting `read` must also grant `recall` and `get` (`read-blind`), because a
100
+ * capped read is only navigable with them. The arms that do not cap (`false`,
101
+ * `'diff'`) render byte-identically to the pre-shim validator, so the frozen A0
102
+ * baseline stays a baseline and A2 isolates the diff instead of importing an
103
+ * admission rule with it. An unrecognised value THROWS (`readShimArm`) — a param
104
+ * guard on the caller's own argument, never a plan red.
97
105
  * @returns {{ ok: boolean, reds: Red[], plan: object|null }}
98
106
  */
99
- export function validatePlan(input: object | string, { job, maxStepRounds, scopes, seedRed, priorChecks }?: {
107
+ export function validatePlan(input: object | string, { job, maxStepRounds, scopes, seedRed, priorChecks, readShim }?: {
100
108
  job?: any;
101
109
  maxStepRounds?: number;
102
110
  scopes?: string[];
103
111
  seedRed?: string[];
104
112
  priorChecks?: string[];
113
+ readShim?: boolean | "cap" | "diff";
105
114
  }): {
106
115
  ok: boolean;
107
116
  reds: Red[];
@@ -122,6 +131,14 @@ export const MAX_PLAN_STEPS: 8;
122
131
  * count. Shallow entries survive the cap (see `legalScopes`) because they are
123
132
  * the useful ones — a deep leaf is reachable via its parent. */
124
133
  export const MAX_SCOPE_MENU: 24;
134
+ /** G1's pair (read shim only): the two verbs that can reach INTO a file the cap
135
+ * cut short — `recall` names where a symbol lives, `get` reads that symbol in
136
+ * full. Named here because the rule that needs them lives here; the cap that
137
+ * makes them mandatory lives in src/readshim.js.
138
+ * EXPORTED because `runPlan` refuses the same pair at $0 (see the G1 ceiling
139
+ * guard there): the rule and its pre-flight must read ONE spelling of which
140
+ * verbs count, never two that can drift apart. */
141
+ export const RETRIEVAL_PAIR: readonly string[];
125
142
  /** P (design record 2026-07-28): the signed per-step model-tier menu. Closed by
126
143
  * construction — the agent picks a tier, never names an arbitrary model string
127
144
  * (the tier→model mapping and any effort params are the RUNNER's, per provider).
@@ -78,6 +78,25 @@
78
78
  * @returns {string} the labelled block, or '' when there is nothing to show
79
79
  */
80
80
  export function closeGapBlock(gap: string | null | undefined): string;
81
+ /**
82
+ * BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
83
+ * `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
84
+ * `pricing` on every metering payload; both metering callbacks below forward it onto the
85
+ * spine through THIS one helper, because two sites spelling one rule are two instruments
86
+ * (the ripgrep fix that landed in ci.yml but not publish.yml).
87
+ *
88
+ * Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
89
+ * the one place that decides what counts as a guess. And an ABSENT provenance stays
90
+ * absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
91
+ * in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
92
+ * invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
93
+ * priced", which is a different fact from "nobody told us".
94
+ * @param {any} arg an `onLlmResult` / `onTurn` payload
95
+ * @returns {{rateSource?: string|null}} the field to spread into the spine record
96
+ */
97
+ export function rateSourceFields(arg: any): {
98
+ rateSource?: string | null;
99
+ };
81
100
  /**
82
101
  * What the NEXT attempt is told about the bound that cut the previous one.
83
102
  *
@@ -146,6 +165,10 @@ export function boundedNote(bounded: {
146
165
  * REQUIREMENT the plan must meet, never a replacement for the goal: the signed goal and
147
166
  * the signed close are unchanged, and what the person added is what the machine could
148
167
  * not see.
168
+ * @param {boolean|'cap'|'diff'} [readShim] the read shim's ARM. Does it carry G1? Then G1
169
+ * is STATED as well as enforced (the mailbox precedent again: a law only the validator
170
+ * knows costs a redraft per draft). The arms without it — `false` and `'diff'` — render
171
+ * byte-identically to the pre-shim prompt.
149
172
  */
150
173
  export function planPrompt(job: any, scoutBlob: string, reds: any[] | null, maxStepRounds: number, failure: string | null, scopes: string[] | undefined, materials?: {
151
174
  balanceUsd?: number | null;
@@ -154,7 +177,7 @@ export function planPrompt(job: any, scoutBlob: string, reds: any[] | null, maxS
154
177
  }, startingDraft?: object | null, checkFacts?: {
155
178
  seedRed?: string[];
156
179
  priorChecks?: string[];
157
- }, doorNote?: string | null): string;
180
+ }, doorNote?: string | null, readShim?: boolean | "cap" | "diff"): string;
158
181
  /**
159
182
  * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close).
160
183
  * Called by runJob after the approval gate, validation, and the smoke — this
@@ -203,6 +226,17 @@ export function planPrompt(job: any, scoutBlob: string, reds: any[] | null, maxS
203
226
  * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
204
227
  * reserved — a scout that spends every round on tools gets one toolless round to write its
205
228
  * survey, because the bound halts it mid-tool-use and text is its only deliverable)
229
+ * @param {boolean|'cap'|'diff'} [opts.readShim=false] THE READ SHIM (src/readshim.js) — the
230
+ * ARM to run, one of the Phase 2 pre-registration's four: `false` (A0, off — the default),
231
+ * `'cap'` (A1: cap + pointer + next-unseen-slice + G1), `'diff'` (A2: the diff lever alone,
232
+ * no cap, no pointer, no G1), `true` (A3: every lever). Anything else THROWS at the entry
233
+ * below rather than coercing into a truthy shim — a mis-spelled arm that silently ran A3
234
+ * is a corrupted battery row, invisible afterwards.
235
+ * OFF is byte-identical to the pre-shim run in every observable, G1 included: the frozen
236
+ * A0 baseline arm must be exactly today's behaviour, and a guard firing under a disabled
237
+ * shim would quietly make the baseline a treatment arm. The default-flip waits on a paid
238
+ * contrast that has not been approved (the `layerRoot` precedent, F41 — an unproven lever
239
+ * ships OFF and earns its default).
206
240
  * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
207
241
  * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
208
242
  * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
@@ -352,7 +386,7 @@ export function planPrompt(job: any, scoutBlob: string, reds: any[] | null, maxS
352
386
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
353
387
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
354
388
  */
355
- export function runPlan(job: any, { workdir, provider, nativeProvider, providerFor, judgeProvider, emit, remainingUsd, isUnpriced, spendComplete, capRuns, strikeLimit, closeTimeoutMs, maxStepRounds, layerRoot, scoutRounds, bridge, now, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, priorSpentUsd, reviewDoor, doorRerun }: {
389
+ export function runPlan(job: any, { workdir, provider, nativeProvider, providerFor, judgeProvider, emit, remainingUsd, isUnpriced, spendComplete, capRuns, strikeLimit, closeTimeoutMs, maxStepRounds, layerRoot, readShim, scoutRounds, bridge, now, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, priorSpentUsd, reviewDoor, doorRerun }: {
356
390
  workdir: string;
357
391
  provider: any;
358
392
  nativeProvider?: ((o: {
@@ -371,6 +405,7 @@ export function runPlan(job: any, { workdir, provider, nativeProvider, providerF
371
405
  closeTimeoutMs?: number | undefined;
372
406
  maxStepRounds?: number | undefined;
373
407
  scoutRounds?: number | undefined;
408
+ readShim?: boolean | "cap" | "diff" | undefined;
374
409
  layerRoot?: boolean | undefined;
375
410
  bridge?: unknown;
376
411
  priorWallMs?: number | undefined;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Resolve the `readShim` flag into its arm. THROWS on anything else — the BA-4
3
+ * param-guard class, and deliberately not a red.
4
+ *
5
+ * An unrecognised value is OPERATOR input, arriving before a single token is
6
+ * spent, and there is exactly one safe way to fail on it: loudly, at once. The
7
+ * failure this guard exists to prevent is `readShim: 'diff '` (or 'Diff', or
8
+ * 'cap+diff') coerced by truthiness into A3 — a battery row that RAN the wrong
9
+ * arm, recorded the arm it was asked for, and is invisible in the results
10
+ * afterwards. That is the blind-instrument class this programme keeps paying
11
+ * for, and it would corrupt the contrast rather than merely fail a run.
12
+ *
13
+ * A red was the alternative and is wrong here twice over: a red is a verdict on
14
+ * the AGENT's work (the plan, the artifact), and this is the operator's own
15
+ * argument; and a red would need a terminal outcome name, which is arbiter
16
+ * territory and out of this change's scope.
17
+ * @param {boolean|'cap'|'diff'|undefined} [flag]
18
+ * @returns {ReadShimArm}
19
+ */
20
+ export function readShimArm(flag?: boolean | "cap" | "diff" | undefined): ReadShimArm;
21
+ /**
22
+ * The persona line this arm is entitled to — empty when the arm changes nothing
23
+ * the worker can perceive. One arm, one line: the two are never concatenated,
24
+ * because a cap arm has no diff and a diff arm has no cap.
25
+ * @param {ReadShimArm} arm
26
+ * @returns {string}
27
+ */
28
+ export function readShimStrategy(arm: ReadShimArm): string;
29
+ /** @typedef {{file: string, path: string, startLine: number, endLine: number, detail: string}} StaleReq */
30
+ /** @typedef {{text: string, outcome: 'stale-served'|'stale-absent'|'stale-past-eof'}} StaleServe */
31
+ /**
32
+ * Create the shim: one delivery ledger, and the two seams that speak for it.
33
+ *
34
+ * `wrapRead` is the read tool's wrapper (the ledger's only writer). `serveStale`
35
+ * is the answer to a stale `ctx_get` pointer, and it deliberately holds NO
36
+ * reference to the ledger — see its own note. Both live in one object because
37
+ * they are one worker's shim: the read seam and the retrieval seam the cap
38
+ * steers a capped worker towards.
39
+ * @param {{cap?: number, arm?: ReadShimArm, onCount?: ((kind: 'pointered'|'capped', bytesWithheld: number) => void)|null}} [opts]
40
+ * `cap`: delivery bound in bytes (the shipped value is `READ_SHIM_CAP`; the option
41
+ * exists so the native path can pin the two bounds to one number rather than two
42
+ * constants drifting apart). `arm`: which levers are live (`readShimArm`). Defaults to
43
+ * ALL of them, so every caller written before the arms existed keeps the behaviour it
44
+ * was written against. `onCount`: MEMORY-CACHE — fired once per pointer/capped-slice
45
+ * response with exactly the bytes withheld on THAT response, for a caller (e.g.
46
+ * `runPlan`) that sums several workers' shims into one run-level readout. Never called
47
+ * for a diff response (L2 is its own lever, uncounted here) or a first read under the
48
+ * cap (nothing was withheld).
49
+ */
50
+ export function createReadShim({ cap, arm, onCount }?: {
51
+ cap?: number;
52
+ arm?: ReadShimArm;
53
+ onCount?: ((kind: "pointered" | "capped", bytesWithheld: number) => void) | null;
54
+ }): {
55
+ arm: ReadShimArm;
56
+ cap: number;
57
+ wrapRead: (tool: {
58
+ name: string;
59
+ execute: (args: any) => Promise<any>;
60
+ }) => {
61
+ name: string;
62
+ execute: (args: any) => Promise<any>;
63
+ };
64
+ serveStale: ({ file, path, startLine, endLine, detail }: StaleReq) => StaleServe | null;
65
+ summary: () => {
66
+ pointered: number;
67
+ capped: number;
68
+ bytesWithheld: number;
69
+ };
70
+ };
71
+ /**
72
+ * The one-tool spelling, kept for every caller written before the shim became an
73
+ * object: wrap this read tool with its own private ledger and hand it back.
74
+ * @param {{name: string, execute: (args: any) => Promise<any>}} tool
75
+ * @param {{cap?: number, arm?: ReadShimArm}} [opts]
76
+ * @returns {{name: string, execute: (args: any) => Promise<any>}} the same tool
77
+ */
78
+ export function wrapReadTool(tool: {
79
+ name: string;
80
+ execute: (args: any) => Promise<any>;
81
+ }, opts?: {
82
+ cap?: number;
83
+ arm?: ReadShimArm;
84
+ }): {
85
+ name: string;
86
+ execute: (args: any) => Promise<any>;
87
+ };
88
+ /** The delivery cap, in bytes. Equal to `NATIVE_READ_CAP` by construction — the
89
+ * native path's CLI display bound and this cap bound the SAME seam, so one
90
+ * number keeps them from double-truncating each other (see planrun's wiring). */
91
+ export const READ_SHIM_CAP: number;
92
+ /** The legal spellings, in one place so an error message cannot drift from the
93
+ * set it describes. */
94
+ export const READ_SHIM_ARMS: readonly string[];
95
+ /** F19 at the bound rather than the verb (the NATIVE_READ_STRATEGY precedent):
96
+ * a cap the worker does not know about is a worker that reads the same file
97
+ * three times wondering why it keeps starting over. It states the bound, what a
98
+ * re-read does, and the two verbs that read a symbol whole — the capability the
99
+ * cap makes mandatory (G1). */
100
+ export const READ_SHIM_STRATEGY: "\nREAD LIMIT: a shell_read of a file over 24KB returns only the next 24KB of it, followed by a notice saying which bytes you got. This is NOT the whole file. Reading the same path again continues where the last read stopped, and once you hold all of it a re-read says so instead of re-sending the bytes. To read a function IN FULL, use ctx_recall(<symbol>) then ctx_get(<pointer>) \u2014 that returns the entire function however large its file is. To locate a line, use shell_grep(<pattern>). Never try to understand a large file by reading it whole.";
101
+ /** The DIFF arm's own line. It says nothing about a 24KB limit and nothing about
102
+ * continuation reads, because under A2 neither exists — a persona describing
103
+ * machinery that is off is a lie to the worker, and a worker rationing its reads
104
+ * against an imaginary cap is a different treatment than the arm names.
105
+ *
106
+ * It does not repeat the steer at ctx_recall/ctx_get either: that steer is the
107
+ * CAP's compensation (G1's other half), and offering it here would import half
108
+ * of A1 into A2. The delivery itself carries `diffNotice`'s framing inline; this
109
+ * line exists so the first diff is not a surprise. */
110
+ export const READ_SHIM_DIFF_STRATEGY: "\nRE-READS: if you shell_read a file you have already been shown in full and it has changed since, the result is a unified DIFF against the copy you already hold \u2014 not the file itself. It is labelled as one. Lines starting '-' were removed, '+' were added, ' ' are unchanged context; apply it to the copy you have and you hold the current file.";
111
+ export type StaleReq = {
112
+ file: string;
113
+ path: string;
114
+ startLine: number;
115
+ endLine: number;
116
+ detail: string;
117
+ };
118
+ export type StaleServe = {
119
+ text: string;
120
+ outcome: "stale-served" | "stale-absent" | "stale-past-eof";
121
+ };
122
+ export type ReadShimArm = {
123
+ on: boolean;
124
+ cap: boolean;
125
+ pointer: boolean;
126
+ diff: boolean;
127
+ g1: boolean;
128
+ };