bareloop 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -5,6 +5,99 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.13.0] — 2026-08-23
9
+
10
+ ### Fixed (2026-08-23 — the release round: a pointer that lied, a close with no home, a rule that charged for its own refusal)
11
+
12
+ - **The pointer no longer lies about a file emptied since it was read.** `src/readshim.js`'s
13
+ pointer branch tested `start >= total` alone, so `0 >= 0` held with **no ledger entry at all** —
14
+ the first read of an empty file and a re-read of a file TRUNCATED TO EMPTY since delivery were
15
+ indistinguishable, and both answered "you already hold all 0 bytes". A worker would have gone on
16
+ believing in content that was gone. The guard now requires a ledger entry whose hash still
17
+ matches. This is precisely the untruthful-pointer class the module exists to prevent, found
18
+ inside the module built to prevent it, and reproduced live before the fix.
19
+ - **`judge-round` carries its rate provenance.** The judged floor made it the first spend a CLOSE
20
+ has ever had, and `src/ledger.js` documented the write side as forwarding `rateSource` onto it —
21
+ but the emit never spread the field, so every judge round would have read UNKNOWN forever. NOT
22
+ rounded up: `src/kinds.js`'s `onJudgeCost` payload still does not carry the field, so it reads
23
+ UNKNOWN today, and both the emit and the ledger header now say so plainly.
24
+ - **A torn spine line stops reading as exact spend.** `scripts/run-battery-readshim.mjs` marked
25
+ unparseable lines `__corrupt` "never guessed at", then counted them, dropped them, and reported
26
+ the row's spend as EXACT while `corruptLines` reached no readout. `spendComplete` now goes false
27
+ when any line was unreadable (spend renders `>=`) and the audit prints the count per row.
28
+ - **G1's ceiling half is refused at $0 instead of after the drafter is paid.** `validatePlan` reds
29
+ `read-blind` when a capped step grants `read` without the retrieval pair — but a step cannot
30
+ grant what the SIGNED CEILING does not offer, so against a spec whose `tools` lack `recall`/`get`
31
+ **every** draft red identically and the drafter was paid for each doomed cycle. `runPlan` now
32
+ throws at the same $0 door as the arm guard, in the same class: an operator param-guard
33
+ `TypeError`, never a model-output red — nothing the agent authored is wrong when the operator
34
+ asks for a shim the signature cannot satisfy. Tighten-only; the only configuration it rejects
35
+ already failed 100% of the time, just later and for money. `RETRIEVAL_PAIR` is now exported from
36
+ `src/plan.js` rather than respelled, so the rule and its pre-flight cannot drift apart.
37
+
38
+ ### Changed (2026-08-23)
39
+
40
+ - **The aurora close moved to a tree development cannot mutate** (`jobs/aurora-u-spawner-types.json`,
41
+ F110). Its stages had pointed at a scratch worktree, which was removed during ordinary branch
42
+ work — every stage silently became `node: cannot find module`. The home before that was a shared
43
+ working checkout, where a close landing mid-edit dies as an instrument fault rather than a
44
+ verdict. Neither is a home: a checkout is *supposed* to go dirty and a scratch worktree is
45
+ *supposed* to be removable. **A close is the arbiter's instrument, so its host must have the
46
+ arbiter's lifetime.** Now a worktree pinned at a release tag, never edited, with its own
47
+ dependencies. Re-signing was required and given in-turn.
48
+
49
+ ### Added (2026-08-18 — the read shim, flag-gated OFF)
50
+
51
+ - **`src/readshim.js` — a per-worker delivery ledger over the read tool**, off unless the new
52
+ `readShim` flag is set on `runJob`/`runPlan`. A read over 24KB (`READ_SHIM_CAP`) delivers its
53
+ next unseen slice plus a trusted steer at `ctx_recall`/`ctx_get`; a re-read of a file the
54
+ worker holds WHOLE and unchanged returns a short pointer instead of the bytes; a re-read of
55
+ a file it holds only PART of returns the next slice, never a pointer; any content change
56
+ re-delivers from the start. Measured on a $0 replay of 1,844 real `shell_read` actions from
57
+ 143 archived gate audits: 48% of reads re-read unchanged bytes, and cap+pointer keyed on what
58
+ was DELIVERED saved 65.6% of read payload with **zero** untruthful responses — where the
59
+ naive path+hash key saved 8.2 points more by telling **250 lies**, median 73,348 bytes hidden
60
+ each (BA-17 read-blinding, reproduced).
61
+ - **G1 at the validation gate (`read-blind`)**: under the shim, a plan step granting `read` must
62
+ also grant `recall` and `get`. Capping a worker with no retrieval verb is BA-17 on purpose —
63
+ the cap is what makes the pair mandatory. Stated in the drafting prompt as well as enforced
64
+ (the mailbox precedent).
65
+ - **One flag, four ARMS, default OFF** (the `layerRoot` precedent, F41). `readShim` names which
66
+ levers run, one value per row of the frozen Phase 2 pre-registration: `false` = A0 (off),
67
+ `'cap'` = A1 (cap + pointer + next-unseen-slice + G1), `'diff'` = A2 (the diff lever alone — no
68
+ cap, no pointer, no G1), `true` = A3 (every lever). The two booleans mean exactly what they
69
+ meant before the arms existed, so nothing written against them changed meaning. With the shim
70
+ off every observable — validator reds, persona, delivered bytes — is byte-identical to the
71
+ pre-shim run: the frozen A0 arm has to be exactly today, and a guard firing under a disabled
72
+ shim would make the baseline a treatment arm. Each arm's persona line describes only the
73
+ machinery actually installed (A2 is never told about a 24KB limit that is not in force). An
74
+ unrecognised value THROWS at the entry (`readShimArm`) rather than coercing into a truthy
75
+ shim — a mis-spelled arm would run one treatment under another's label and be invisible in the
76
+ results afterwards. The default-flip is not in this change: it waits on a paid contrast.
77
+ - **A stale `ctx_get` pointer serves the requested line range from disk** (2026-08-19), under a
78
+ CAPPING arm only. The cap's own strategy line steers a capped worker at `ctx_recall` →
79
+ `ctx_get` for a whole function; `ctx_get` is content-hash gated and throws `StalePointerError`
80
+ the moment the file changed after indexing — which is the normal case for a worker that just
81
+ EDITED the file it is working on. Measured on the A1 arm's three green runs: 10 `ctx_get`
82
+ calls, 5 stale, 0 bytes each, every one a just-written file. A paid round returning nothing,
83
+ down a path the cap itself chose. The serve hands back the requested 0-based inclusive line
84
+ range read fresh from disk, under TRUSTED framing that says the pointer was stale, that the
85
+ range may now hold DIFFERENT code than the symbol asked for, which lines arrived, and that
86
+ nothing was recorded as delivered. litectx refuses to slice a drifted range itself because an
87
+ UNLABELLED slice can silently be another symbol's body — the label is what makes it legal.
88
+ Detection is on the exported `StalePointerError` CLASS, never on message prose. A vanished
89
+ file or a range now entirely past the end is a refusal RESULT naming what happened, never a
90
+ throw. It **never touches the delivery ledger**: a slice is not the file, and a ledger that
91
+ recorded one would answer the next read with "you already have it" to a worker holding 90
92
+ lines of 900 — the untruthful-pointer failure the shim exists to prevent. The cost of that is
93
+ a later `shell_read` may re-deliver bytes the serve already showed; the shim over-delivers
94
+ rather than lies. A0 and A2 are untouched: with no capping arm the hook is not wired and
95
+ `ctx_get`'s contract is byte-identical.
96
+ - On the native surface the shim REPLACES the `NATIVE_READ_CAP` wrapper rather than layering over
97
+ it (both bound the same seam at the same 24KB, and a shim outside the native wrapper would
98
+ ledger already-truncated text as a whole file — the exact lie it exists to prevent). L2
99
+ (diff-on-change) is deliberately not built: it never fired on the real corpus.
100
+
8
101
  ## [0.12.0] — 2026-08-23
9
102
 
10
103
  ### Added (N4 slice 2 — the softgreen rung: a close that JUDGES, and a signer who has the last word)
@@ -918,7 +918,7 @@ Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
918
918
  `coordination-red` — a failure between units (scope contention, step order, store
919
919
  races), never to be folded into worker/interpreter reds.
920
920
 
921
- ### `runJob(spec, { approvals, workdir, provider, nativeProvider?, providerFor?, emit, capRuns?, strikeLimit?, shellCapUsd?, closeTimeoutMs?, layerRoot?, bridge?, priorSpentUsd?, priorSpendComplete?, priorWallMs?, resumeSeed?, resumeGrades?, resumeReplans?, resumeBranch?, humanRuling?, heldRuling?, reviewDoor?, doorRerun? })` → outcome — `src/run.js`
921
+ ### `runJob(spec, { approvals, workdir, provider, nativeProvider?, providerFor?, emit, capRuns?, strikeLimit?, shellCapUsd?, closeTimeoutMs?, layerRoot?, readShim?, bridge?, priorSpentUsd?, priorSpendComplete?, priorWallMs?, resumeSeed?, resumeGrades?, resumeReplans?, resumeBranch?, humanRuling?, heldRuling?, reviewDoor?, doorRerun? })` → outcome — `src/run.js`
922
922
 
923
923
  The last seven are the RESUME fold and are documented under *Resuming a killed run* below; they
924
924
  default to `0` / `true` / `0` / `null` / `[]` / `null` / `null`, so a fresh run passes none of them.
@@ -937,6 +937,56 @@ plan-red | check-red | close-red | close-unsupported | recipe-stale | branch-red
937
937
  provider-red | interpreter-red | cap-halt | wall-halt | step-stalled | hitl-pause |
938
938
  hitl-decision-red | step-red:<id>`.
939
939
 
940
+ **`readShim` (default `false`) — the capped read seam, per ARM.** The value names which levers
941
+ run — the four arms of the frozen Phase 2 pre-registration:
942
+
943
+ | value | arm | cap + next-unseen-slice | pointer | diff on a changed re-read | G1 (`read-blind`) | persona line |
944
+ |---|---|---|---|---|---|---|
945
+ | `false` *(default)* | A0 | — | — | — | — | none |
946
+ | `'cap'` | A1 | ✓ | ✓ | — | ✓ | `READ_SHIM_STRATEGY` |
947
+ | `'diff'` | A2 | — | — | ✓ | — | `READ_SHIM_DIFF_STRATEGY` |
948
+ | `true` | A3 | ✓ | ✓ | ✓ | ✓ | `READ_SHIM_STRATEGY` |
949
+
950
+ Anything else **throws** (`readShimArm`) at `runJob`/`runPlan`/`validatePlan` before a token is
951
+ spent — a mis-spelled arm coerced by truthiness would run one treatment under another's label and
952
+ be invisible in the results afterwards.
953
+
954
+ A **capping arm against a signed ceiling that offers no `recall`/`get` also throws at that same
955
+ $0 door**, and for the same reason: G1 below requires the retrieval pair on any step granting
956
+ `read`, but a step cannot grant what `spec.tools` does not offer — so every draft would red
957
+ `read-blind` identically while the drafter was paid for each doomed cycle. The message names the
958
+ missing verbs and both exits (re-sign the spec with them, or run with the shim off). Like the arm
959
+ throw, it is an **operator param-guard `TypeError`, never a model-output red**: nothing the agent
960
+ authored is wrong when the operator asks for a shim the signature cannot satisfy. It narrows
961
+ nothing that works — the only configuration it rejects already failed 100% of the time, later and
962
+ for money. `RETRIEVAL_PAIR` is exported from `src/plan.js` so the rule and this pre-flight read one
963
+ spelling of which verbs count.
964
+
965
+ Under a CAPPING arm (A1/A3) the shim also answers a **stale `ctx_get` pointer**: the cap steers the
966
+ worker at `ctx_recall`/`ctx_get`, and `ctx_get` refuses a file that changed after indexing — the
967
+ normal case once the worker has edited it. Instead of nothing, the shim serves that pointer's
968
+ 0-based inclusive line range read fresh from disk, capped the same way, under a trusted notice
969
+ saying the pointer was stale, that the range may now hold DIFFERENT code than the symbol asked
970
+ for, and that nothing was recorded as delivered. A vanished file or a range past the end is a
971
+ refusal result, never a throw. The serve never writes the delivery ledger, so it can never mint a
972
+ "you already hold it" pointer for bytes the read seam did not hand over. A0 and A2 do not wire it
973
+ and `ctx_get` behaves exactly as it always did.
974
+
975
+ `false` is the shipped behaviour and is byte-identical to a run without the flag, guards included;
976
+ `true` means exactly what it meant before the arms existed, so nothing written against the boolean
977
+ changed meaning. Where the CAP runs, `shell_read` delivers at most `READ_SHIM_CAP` (24KB) per call
978
+ with a trusted steer at `ctx_recall`/`ctx_get`; a re-read continues from where the last one
979
+ stopped, and once the worker holds the whole unchanged file a re-read returns a pointer instead of
980
+ the bytes (keyed on what was DELIVERED — a partly-seen file NEVER gets a pointer, which is the
981
+ measured correctness point); and `validatePlan` reds `read-blind` on any step granting `read`
982
+ without `recall` and `get`. Where only the DIFF runs, nothing is capped (a first read delivers
983
+ whole, whatever the size), an unchanged re-read re-delivers rather than pointing, and G1 does not
984
+ exist — G1 is the cap's compensation, and an arm that caps nothing has nothing to compensate for.
985
+ The ledger is per worker, so it resets with every step. On the native (clipipe) surface a CAPPING
986
+ arm replaces the CLI-display cap rather than stacking on it; a non-capping arm leaves that wrapper
987
+ installed *inside* the shim, so native never loses its bound. Default-flip pending a paid contrast;
988
+ see CHANGELOG for the replay numbers behind it.
989
+
940
990
  **The two hitl terminals (N4 slice 1, doors re-cut 2026-08-18)** are the class's whole surface
941
991
  at this layer, and each is a CLEAN exit (`spendComplete` stays true — only the two casualties floor). `hitl-pause` is a
942
992
  decision-ready CHECKPOINT: the close reached a stage no machine can render, so the run stops
@@ -1393,6 +1443,53 @@ reserved round bound, with the LAST round reserved for a toolless summary: the b
1393
1443
  a scout mid-tool-use, and text is its only deliverable, so without that reservation it
1394
1444
  spends its whole allowance exploring and reports nothing (F59 — measured on 15 of 18 runs).
1395
1445
 
1446
+ **Every cost figure bareloop reports is an ESTIMATE, not a billed amount (BA-21).** No
1447
+ vanilla LLM API returns a price — providers report TOKENS — so `spentUsd`, every
1448
+ `worker-round` cost, every halt readout and every ledger dollar is a token count
1449
+ multiplied by a RATE, never a provider invoice. Expect it to read somewhat high, and do
1450
+ not reconcile it against a bill expecting a match. There is deliberately **no per-model
1451
+ price table** behind it, and the reason is worth knowing before you decide how far to
1452
+ trust the number: a hand-curated table is a treadmill that rots SILENTLY — a price moves
1453
+ or a new model ships, the row goes stale, and the figure under-reports with no detector to
1454
+ say so. bare-agent instead matches a recognized Claude TIER (haiku / sonnet) by model id,
1455
+ and falls back to a conservative ceiling rate for anything it does not recognize. Both
1456
+ deliberately OVER-report rather than under-report, which is the fail-safe direction for a
1457
+ cap: a run halts slightly early rather than overspending unnoticed. **The consequence
1458
+ bites at the budget, and it is not softened here:** `budgetUsd` and `shellCapUsd` are
1459
+ enforced against these estimates, so an inaccurate rate is a cap that binds earlier or
1460
+ later than you intended. The cap is exact about the number it holds; the number is a
1461
+ guess.
1462
+
1463
+ **If you need accuracy, pass your own rates — the supported path, not a workaround.**
1464
+ bare-agent's `Loop` takes `rates: {in, out, cacheReadMult?, cacheWriteMult?}` — USD per 1K
1465
+ tokens, the two multipliers applying to the input rate for the cache-read and cache-write
1466
+ tiers (defaulting to Anthropic's 0.1× / 1.25×). A caller-supplied rate is recorded as
1467
+ VOUCHED rather than as a guess, and it silences bare-agent's own guesstimate warning.
1468
+ **Named, not papered over:** `runJob`/`runPlan` construct their own `Loop` and do not yet
1469
+ accept a `rates` passthrough, so today that option is reachable only by a caller driving
1470
+ bare-agent directly — a bareloop-run job is priced by the guesstimate, full stop. The
1471
+ passthrough is an open follow-up, and until it lands every paragraph above describes the
1472
+ only pricing a `runJob` adopter gets.
1473
+
1474
+ **The provenance is on the record, per round (`rateSource`).** The field arrives with
1475
+ bare-agent **>= 0.37**; under the pinned `^0.36.0` no provider payload carries it yet, so
1476
+ every priced round this library writes today reads UNKNOWN provenance — correctly, and by
1477
+ the same rule that governs the archive. (The one exception is the native per-turn
1478
+ `worker-turn`, whose `null` is bareloop's OWN statement rather than a forwarded one: that
1479
+ surface prices the SESSION, so a turn had no rate to guess.) Once the pin moves, every `worker-round` /
1480
+ `worker-turn` the plan flow writes carries bare-agent's own label beside `pricing`:
1481
+ `'provider'` (the provider reported its own authoritative cost — the native CLI surface)
1482
+ and `'caller'` (you passed the rate) are VOUCHED; `'tier'` (a recognized Claude tier,
1483
+ where **both** of bareloop's own production models land) and `'default'` (the blind
1484
+ ceiling) are GUESSES; `null` means nothing was priced, so there was no rate to have
1485
+ guessed. An ABSENT field is UNKNOWN provenance — every round archived before this signal
1486
+ existed, and it is never backfilled, never reconstructed, and never rounded up to vouched.
1487
+ Read it with `rateProvenance(record)` and `spendProvenance(events)` (`src/ledger.js`),
1488
+ whose predicate is an ALLOW-LIST of the two vouched values: anything else, including a
1489
+ value a future bare-agent adds, reads as a GUESS. **Reporting only** — `pricing` stays
1490
+ strictly two-valued (`'priced'`|`'unpriced'`), the `pricing-red` halt still keys on cost
1491
+ alone, and nothing about provenance decides, halts or refuses anything.
1492
+
1396
1493
  **What the worker is told (tool mode):** the absolute repository root (F10 — bare-agent's
1397
1494
  shell tools resolve relative paths against the PROCESS cwd, so a worker with no root is
1398
1495
  blind), the close's CURRENT output as the tree's state (F13 — never framed as "your previous
@@ -1977,7 +2074,14 @@ the same refusal emits, so counting the escalation too would double every refusa
1977
2074
  `suggestedAsk` on every row is a template seed for an upstream ask — filing stays human;
1978
2075
  status rows (`open → filed → fixed → consumed`) are human-appended, and the fold shows
1979
2076
  the latest per key. Pure pieces exported for custom folds: `classifyIncidents(events,
1980
- {spine?})`, `foldLedger(rows)`, `ledgerDeltas(fold, occurrences)`. CLI lands at N5; the
2077
+ {spine?})`, `foldLedger(rows)`, `ledgerDeltas(fold, occurrences)`. Riding with them,
2078
+ and deliberately NOT a ledger class: `rateProvenance(record)` → `vouched|guessed|unpriced|unknown`
2079
+ and `spendProvenance(events)` → per-provenance `{rounds, usd, unpricedRounds}` buckets answer
2080
+ "how much of this run's spend was priced by a rate nobody vouched for" (BA-21 — see *Every cost
2081
+ figure bareloop reports is an ESTIMATE* above). A bucket's `usd` sums FINITE costs only and is a
2082
+ FLOOR whenever its `unpricedRounds` is non-zero (F6 — an unknown cost is counted, never summed as
2083
+ $0). `VOUCHED_RATE_SOURCES` is the frozen allow-list they key on. **Reporting only**: no incident,
2084
+ no class, no halt. CLI lands at N5; the
1981
2085
  panel reads the same file at N6.
1982
2086
 
1983
2087
  ## Architecture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -82,7 +82,12 @@ export { renderListing, selectionPrompt } from './selection.js';
82
82
  // credit it has no way to release. STORAGE ONLY — nothing here selects, promotes or reuses
83
83
  // a bridge; those stay parked on `layer-3-reuse`.
84
84
  export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow } from './reuse.js';
85
- export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';
85
+ // BA-21 pricing provenance rides out with the ledger because it is the same job: reading
86
+ // a run's own spend record honestly. `rateProvenance`/`spendProvenance` answer "how much of
87
+ // this was priced by a rate nobody vouched for" — REPORTING ONLY, no halt, no refusal.
88
+ // Exported because an adopter enforcing a budget against these numbers is entitled to know
89
+ // which of them are guesses (see bareloop.context.md, "Every cost figure … is an ESTIMATE").
90
+ export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES, VOUCHED_RATE_SOURCES, rateProvenance, spendProvenance } from './ledger.js';
86
91
  // THE WORK BRANCH (PRD v1.57 §3). `workBranchName` is exported so an operator runner can
87
92
  // SHOW, before the approval gate, which branch the run will work on — the same reason the
88
93
  // resolved per-try hash is printed there: a bound the human is agreeing to should not be
@@ -91,6 +96,16 @@ export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASS
91
96
  // still there before spending anything. The runner applies the rule itself either way —
92
97
  // neither export is a way to opt out of it.
93
98
  export { workBranchName, prepareWorkBranch, WORK_BRANCH_PREFIX, WORK_BRANCH_RE } from './workbranch.js';
99
+ // THE READ SHIM (arm-gated, OFF by default via `runJob`'s `readShim`). The cap is exported
100
+ // because bareloop.context.md names it, and documented-but-unexported is a false contract;
101
+ // the wrapper goes with it so a caller building its own worker seam wraps the SAME ledger
102
+ // rather than reimplementing the one rule that matters (a pointer keys on what was
103
+ // DELIVERED, never on path+hash).
104
+ //
105
+ // `readShimArm` is exported for the same reason the cap is: a battery driver has to be able
106
+ // to name and check the arm it is about to run BEFORE it launches, and the legal set living
107
+ // only inside a runner would leave the driver guessing at spellings the guard will reject.
108
+ export { createReadShim, wrapReadTool, readShimArm, readShimStrategy, READ_SHIM_CAP, READ_SHIM_STRATEGY, READ_SHIM_DIFF_STRATEGY, READ_SHIM_ARMS } from './readshim.js';
94
109
  // ── CLOSE AUTHORING v1 (gate 4) — the user declares what done means ──────────
95
110
  // The public surface is settled ONCE, here, at M4 (M2's header flagged the
96
111
  // naming collision and deferred it rather than exporting piecemeal).
package/src/ledger.js CHANGED
@@ -404,3 +404,105 @@ export function updateLedger({ ledgerFile, spineFiles }) {
404
404
  });
405
405
  return { appended, fold: foldLedger(rows.concat(appended)) };
406
406
  }
407
+
408
+ // ── BA-21 pricing provenance: the READ side (REPORTING ONLY) ─────────────────
409
+ // bare-agent >=0.37 rides `rateSource` beside `pricing` on every metering payload,
410
+ // and the plan flow forwards it verbatim onto `worker-round`/`judge-round`/`worker-turn`
411
+ // (`rateSourceFields`, src/planrun.js — the write side).
412
+ //
413
+ // NAMED rather than papered over: `judge-round`'s own payload (src/kinds.js's
414
+ // `onJudgeCost({...})`) does not carry `rateSource` yet, so every judge round reads
415
+ // UNKNOWN provenance today — correctly, by the same rule that governs the archive, and
416
+ // the day that payload forwards the field the emit site already spreads it.
417
+ //
418
+ // `pricing` is UNCHANGED and still strictly two-valued ('priced'|'unpriced'). Nothing
419
+ // below reads it, nothing below decides anything, and no new escalation category exists:
420
+ // the `pricing-red` halt in src/run.js keys on cost alone and stays the ONLY pricing
421
+ // signal with teeth. This is a READOUT — how much of a run's spend was priced by a rate
422
+ // nobody vouched for — and coupling it to run control would be a new refusal, which is
423
+ // arbiter territory.
424
+
425
+ /**
426
+ * The two rate sources somebody VOUCHED for: `'provider'` (the provider reported its own
427
+ * authoritative cost, e.g. the claude CLI's `total_cost_usd`) and `'caller'` (we passed
428
+ * the rate in ourselves). Everything else bare-agent can report is a built-in guess.
429
+ *
430
+ * An ALLOW-LIST of vouched values, never a deny-list of guessed ones — this is the
431
+ * load-bearing line of the whole signal, for two reasons.
432
+ *
433
+ * First: BOTH of bareloop's production models resolve to `'tier'`, not to `'default'`
434
+ * (`claude-sonnet-5` and `claude-haiku-4-5-…` each match a recognized Claude tier by
435
+ * model-id substring, so bare-agent prices them off a hardcoded tier rate that could
436
+ * have drifted from Anthropic's). A "is this a guess?" test spelled
437
+ * `rateSource === 'default'` would therefore read EVERY normal bareloop round as
438
+ * confidently priced and see none of the population it exists to measure — this repo's
439
+ * blind-instrument class, seven recorded instances, and not becoming the eighth.
440
+ *
441
+ * Second: written this way, any value bare-agent adds later that we do not recognise
442
+ * reads as a GUESS rather than as vouched. The fail-safe direction, by construction.
443
+ */
444
+ export const VOUCHED_RATE_SOURCES = Object.freeze(['provider', 'caller']);
445
+
446
+ /**
447
+ * Read one spend record's rate PROVENANCE. Four-way, and the two absent-ish cases are
448
+ * deliberately distinct facts:
449
+ * - `'vouched'` — a `VOUCHED_RATE_SOURCES` value: somebody stands behind the rate.
450
+ * - `'guessed'` — a rate was used and nobody vouched for it (`'tier'`, `'default'`, or
451
+ * any future value we do not recognise).
452
+ * - `'unpriced'` — `rateSource: null`: nothing was priced, so there was no rate to have
453
+ * guessed. Upstream said so; we are not inferring it.
454
+ * - `'unknown'` — the record carries no `rateSource` at all. Every round archived before
455
+ * this signal existed lands here and is NEVER backfilled: an absent label
456
+ * is reported as unknown, never reconstructed, and never rounded up to
457
+ * vouched (F6's unknown-is-never-zero, applied to provenance).
458
+ * @param {any} record a `worker-round` / `judge-round` / `worker-turn` spine record
459
+ * @returns {'vouched'|'guessed'|'unpriced'|'unknown'}
460
+ */
461
+ export function rateProvenance(record) {
462
+ if (!record || typeof record !== 'object' || !('rateSource' in record)) return 'unknown';
463
+ const source = /** @type {any} */ (record).rateSource;
464
+ if (source === undefined) return 'unknown'; // a key carrying no value taught us nothing
465
+ if (source === null) return 'unpriced';
466
+ return VOUCHED_RATE_SOURCES.includes(source) ? 'vouched' : 'guessed';
467
+ }
468
+
469
+ /** The spend records a run's money is read from. `worker-round` and `judge-round` are the
470
+ * ACCOUNTED types — exactly `ACCOUNTED_ROUND_TYPES` in src/run.js, which is the ledger that
471
+ * enforces the budget (F12). `judge-round` is the softgreen rung's judged floor: the first
472
+ * spend a CLOSE has ever had, and a provenance readout that skipped it would be blind to the
473
+ * one seam whose rate nobody has audited. `worker-turn` is the native surface's per-turn
474
+ * attribution — unpriced by design, included so the readout covers every metering row the
475
+ * plan flow writes rather than only the ones carrying money. The attempt-level totals
476
+ * (`worker-result`, `worker-plan`) are ECHOES of these same rounds and are excluded, for
477
+ * the same reason the ledger in run.js excludes them: counting them double-counts.
478
+ *
479
+ * Re-spelled rather than imported (src/run.js imports this module's siblings and a cycle
480
+ * buys nothing here) — `tests/ledger.test.js` asserts the two spellings agree, because a
481
+ * spend-slicing instrument that misses a writer is the F45 class this list exists to avoid. */
482
+ const SPEND_RECORD_TYPES = Object.freeze(['worker-round', 'judge-round', 'worker-turn']);
483
+
484
+ /** @typedef {{rounds: number, usd: number, unpricedRounds: number}} ProvenanceBucket */
485
+
486
+ /**
487
+ * How much of a run's spend was priced by a guess. Pure fold over one spine's events,
488
+ * REPORTING ONLY — it mints nothing, halts nothing and refuses nothing.
489
+ *
490
+ * `usd` sums only FINITE costs; a round whose cost is not a finite number is counted in
491
+ * `unpricedRounds` instead of being summed as $0 (F6 — unpriced is never free), so a
492
+ * bucket's `usd` is a FLOOR whenever its `unpricedRounds` is non-zero.
493
+ * @param {any[]} events one spine's parsed events
494
+ * @returns {{vouched: ProvenanceBucket, guessed: ProvenanceBucket, unpriced: ProvenanceBucket, unknown: ProvenanceBucket}}
495
+ */
496
+ export function spendProvenance(events) {
497
+ /** @returns {ProvenanceBucket} */
498
+ const bucket = () => ({ rounds: 0, usd: 0, unpricedRounds: 0 });
499
+ const out = { vouched: bucket(), guessed: bucket(), unpriced: bucket(), unknown: bucket() };
500
+ for (const ev of events ?? []) {
501
+ if (!ev || !SPEND_RECORD_TYPES.includes(ev.type)) continue;
502
+ const b = out[rateProvenance(ev)];
503
+ b.rounds += 1;
504
+ if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) b.usd += ev.costUsd;
505
+ else b.unpricedRounds += 1;
506
+ }
507
+ return out;
508
+ }
package/src/plan.js CHANGED
@@ -27,6 +27,7 @@
27
27
  import { TOOL_MENU, LOCKED_TOOLS, WRITE_VERBS, checkMenu } from './job.js';
28
28
  import { declaredStages } from './declaredclose.js';
29
29
  import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
30
+ import { READ_SHIM_CAP, readShimArm } from './readshim.js';
30
31
 
31
32
  /** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
32
33
  * shell evaluates every form with its own fixed code, never a command — the
@@ -49,6 +50,14 @@ export { WRITE_VERBS };
49
50
  * count. Shallow entries survive the cap (see `legalScopes`) because they are
50
51
  * the useful ones — a deep leaf is reachable via its parent. */
51
52
  export const MAX_SCOPE_MENU = 24;
53
+ /** G1's pair (read shim only): the two verbs that can reach INTO a file the cap
54
+ * cut short — `recall` names where a symbol lives, `get` reads that symbol in
55
+ * full. Named here because the rule that needs them lives here; the cap that
56
+ * makes them mandatory lives in src/readshim.js.
57
+ * EXPORTED because `runPlan` refuses the same pair at $0 (see the G1 ceiling
58
+ * guard there): the rule and its pre-flight must read ONE spelling of which
59
+ * verbs count, never two that can drift apart. */
60
+ export const RETRIEVAL_PAIR = Object.freeze(['recall', 'get']);
52
61
 
53
62
  /**
54
63
  * The MENU of legal `tree-changed` scopes — choose-don't-describe (design record
@@ -202,7 +211,7 @@ export function closeStagesOf(job) {
202
211
  * job spec. Never throws on JSON text or plain parsed data; every failure is
203
212
  * a named red. Returns the parsed plan on ok (single parse), null on any red.
204
213
  * @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
205
- * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[] }} [opts] `job`: the
214
+ * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[], readShim?: boolean|'cap'|'diff' }} [opts] `job`: the
206
215
  * validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
207
216
  * menu all come from it — a missing or non-plan-shape job fails CLOSED);
208
217
  * `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
@@ -224,9 +233,23 @@ export function closeStagesOf(job) {
224
233
  * only). Rule B — a redraft may not drop one (`check-shed`): exits without
225
234
  * the check verify FORM alone, so a shed lets the run "green" unearned
226
235
  * (u-msdsmkid mechanism b; the 3 archived sheds were all on step-red runs).
236
+ * `readShim` (G1): the read shim's ARM for this run — `false` (default), `'cap'`,
237
+ * `'diff'` or `true`. The arms that CAP (`'cap'`, `true`) add one rule: a step
238
+ * granting `read` must also grant `recall` and `get` (`read-blind`), because a
239
+ * capped read is only navigable with them. The arms that do not cap (`false`,
240
+ * `'diff'`) render byte-identically to the pre-shim validator, so the frozen A0
241
+ * baseline stays a baseline and A2 isolates the diff instead of importing an
242
+ * admission rule with it. An unrecognised value THROWS (`readShimArm`) — a param
243
+ * guard on the caller's own argument, never a plan red.
227
244
  * @returns {{ ok: boolean, reds: Red[], plan: object|null }}
228
245
  */
229
- export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed, priorChecks } = {}) {
246
+ export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed, priorChecks, readShim = false } = {}) {
247
+ // The one thing in here that throws, and it is not plan data: an unknown arm
248
+ // name is the CALLER's own argument, checked before a single plan field is
249
+ // read. The "never throws" contract above is about the agent's artifact —
250
+ // this is the operator's, and silently coercing it would run one arm under
251
+ // another's label (the BA-4 param-guard class; see `readShimArm`).
252
+ const shimArm = readShimArm(readShim);
230
253
  /** @type {Red[]} */
231
254
  const reds = [];
232
255
  /** @type {(code: string, path: string, detail?: string) => void} */
@@ -335,6 +358,24 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed,
335
358
  }
336
359
  writeStep = s.tools.some((/** @type {string} */ t) => WRITE_VERBS.includes(t));
337
360
  toolsParsed = true;
361
+ // G1 — the read shim's admission rule, and it only exists while the CAP
362
+ // does. The shim caps a read at READ_SHIM_CAP and hands back a steer;
363
+ // capping a worker whose grant holds no retrieval verb leaves it with no
364
+ // way to reach the rest of the file, which is BA-17 read-blinding on
365
+ // purpose. So the cap is what makes the pair mandatory — the two ship
366
+ // together or neither ships.
367
+ // Fires only inside the parsed branch (the mailbox precedent: a rule
368
+ // derived from an unknowable grant charges the ledger for a violation
369
+ // the agent never committed), and only per step, because the grant IS
370
+ // the step boundary. Keyed on the ARM's g1 flag, not on "is the shim on":
371
+ // the diff-only arm (A2) caps nothing, so there is nothing to be blind
372
+ // about, and firing here would narrow A2's admissible plan space on top
373
+ // of the lever it is meant to isolate.
374
+ if (shimArm.g1 && s.tools.includes('read') && !RETRIEVAL_PAIR.every((/** @type {string} */ v) => s.tools.includes(v))) {
375
+ const short = RETRIEVAL_PAIR.filter((v) => !ceiling.includes(v));
376
+ red('read-blind', `${at}.tools`, `a step granting "read" must also grant ${RETRIEVAL_PAIR.join(' and ')} — reads are capped at ${READ_SHIM_CAP} bytes and the rest of a big file is reachable only through the retrieval verbs; a capped worker without them is blind to it`
377
+ + (short.length ? ` (the signed ceiling [${ceiling.join(', ')}] offers no ${short.join('/')}: this plan cannot satisfy the rule — the spec needs re-signing with them, or the run needs the shim off)` : ''));
378
+ }
338
379
  }
339
380
 
340
381
  // rounds ≤ the shell cap (cap-not-estimate; the step bound IS maxTurns)