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/src/planrun.js CHANGED
@@ -21,7 +21,7 @@ import { Gate } from 'bareguard';
21
21
  import { LiteCtx } from 'litectx';
22
22
  import { runClose, runStages, ralph, CLOSE_FAULTS, boundGap } from './ralph.js';
23
23
  import { validatePlan, legalScopes, closeStagesOf } from './plan.js';
24
- import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU } from './plan.js';
24
+ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU, RETRIEVAL_PAIR } from './plan.js';
25
25
  import { snapshotScope, evalExits, CHECK_GAP_MAX } from './exits.js';
26
26
  import { createRoot } from './root.js';
27
27
  import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
@@ -29,6 +29,7 @@ import { createLadder, STRIKE_LIMIT } from './ladder.js';
29
29
  import { createTrend, FIX_STRIKE_LIMIT } from './trend.js';
30
30
  import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
31
31
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
32
+ import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
32
33
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
33
34
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
34
35
  import { extractArtifact } from './text.js';
@@ -296,6 +297,26 @@ export function closeGapBlock(gap) {
296
297
  */
297
298
  export const BOUND_REASON_MAX = 200;
298
299
 
300
+ /**
301
+ * BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
302
+ * `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
303
+ * `pricing` on every metering payload; both metering callbacks below forward it onto the
304
+ * spine through THIS one helper, because two sites spelling one rule are two instruments
305
+ * (the ripgrep fix that landed in ci.yml but not publish.yml).
306
+ *
307
+ * Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
308
+ * the one place that decides what counts as a guess. And an ABSENT provenance stays
309
+ * absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
310
+ * in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
311
+ * invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
312
+ * priced", which is a different fact from "nobody told us".
313
+ * @param {any} arg an `onLlmResult` / `onTurn` payload
314
+ * @returns {{rateSource?: string|null}} the field to spread into the spine record
315
+ */
316
+ export function rateSourceFields(arg) {
317
+ return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
318
+ }
319
+
299
320
  /**
300
321
  * What the NEXT attempt is told about the bound that cut the previous one.
301
322
  *
@@ -374,8 +395,12 @@ export function boundedNote(bounded, rounds) {
374
395
  * REQUIREMENT the plan must meet, never a replacement for the goal: the signed goal and
375
396
  * the signed close are unchanged, and what the person added is what the machine could
376
397
  * not see.
398
+ * @param {boolean|'cap'|'diff'} [readShim] the read shim's ARM. Does it carry G1? Then G1
399
+ * is STATED as well as enforced (the mailbox precedent again: a law only the validator
400
+ * knows costs a redraft per draft). The arms without it — `false` and `'diff'` — render
401
+ * byte-identically to the pre-shim prompt.
377
402
  */
378
- export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}, doorNote = null) {
403
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}, doorNote = null, readShim = false) {
379
404
  const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
380
405
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
381
406
  // The shape-lottery laws, stated where the check menu is offered (the mailbox
@@ -399,6 +424,16 @@ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes,
399
424
  // (`?? []`: a close naming no command stages nothing, so it offers nothing)
400
425
  const closeStages = closeStagesOf(job) ?? [];
401
426
  const checkNames = checkMenu(closeStages).map((m) => m.name);
427
+ // G1, stated where the grant is offered. Only under an arm that CARRIES G1 —
428
+ // with the shim off, or under the diff-only arm that caps nothing, the rule
429
+ // does not exist and neither does this sentence (the A0 and A2 renders). The
430
+ // sentence also states the 24KB bound as its reason, which is the second
431
+ // reason it cannot render for A2: that bound is not in force there.
432
+ const readShimLaw = readShimArm(readShim).g1
433
+ ? `\n A step granting "read" must also grant "recall" and "get": a read of a large file
434
+ returns only its next 24KB, and those two verbs are the only way to read a symbol in
435
+ full. A step that grants "read" without both is rejected.`
436
+ : '';
402
437
  const doc = `DRAFT-PLAN
403
438
  You are planning how to accomplish a goal in a repository, as an ordered list of bounded
404
439
  steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
@@ -409,7 +444,7 @@ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] }
409
444
  strictly in array order. Each step (no other fields exist):
410
445
  - "id": kebab-case slug, unique
411
446
  - "action": the step's task, precise enough for a worker that sees ONLY this step
412
- - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (read/grep are the worker's ONLY way to see the tree — there is no shell; write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)
447
+ - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (read/grep are the worker's ONLY way to see the tree — there is no shell; write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)${readShimLaw}
413
448
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
414
449
  - "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
415
450
  - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
@@ -518,6 +553,17 @@ ${scoutBlob || '(no scout notes)'}`;
518
553
  * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
519
554
  * reserved — a scout that spends every round on tools gets one toolless round to write its
520
555
  * survey, because the bound halts it mid-tool-use and text is its only deliverable)
556
+ * @param {boolean|'cap'|'diff'} [opts.readShim=false] THE READ SHIM (src/readshim.js) — the
557
+ * ARM to run, one of the Phase 2 pre-registration's four: `false` (A0, off — the default),
558
+ * `'cap'` (A1: cap + pointer + next-unseen-slice + G1), `'diff'` (A2: the diff lever alone,
559
+ * no cap, no pointer, no G1), `true` (A3: every lever). Anything else THROWS at the entry
560
+ * below rather than coercing into a truthy shim — a mis-spelled arm that silently ran A3
561
+ * is a corrupted battery row, invisible afterwards.
562
+ * OFF is byte-identical to the pre-shim run in every observable, G1 included: the frozen
563
+ * A0 baseline arm must be exactly today's behaviour, and a guard firing under a disabled
564
+ * shim would quietly make the baseline a treatment arm. The default-flip waits on a paid
565
+ * contrast that has not been approved (the `layerRoot` precedent, F41 — an unproven lever
566
+ * ships OFF and earns its default).
521
567
  * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
522
568
  * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
523
569
  * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
@@ -667,8 +713,37 @@ ${scoutBlob || '(no scout notes)'}`;
667
713
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
668
714
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
669
715
  */
670
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
716
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
671
717
  workdir = resolve(workdir);
718
+ // The read shim's ARM, resolved at the door — before the workdir is read, before
719
+ // the scout, before a single token. An unrecognised spelling throws HERE, where
720
+ // it costs nothing, rather than coercing into a truthy shim and running A3 under
721
+ // an A2 label for the rest of a paid row (the blind-instrument class: the result
722
+ // would be wrong and would look fine). Only the guard runs here; the flag itself
723
+ // is still threaded onward as written.
724
+ readShimArm(readShim);
725
+ // G1's OTHER half, refused at the same $0 door. `validatePlan` reds `read-blind`
726
+ // when a step grants `read` without the retrieval pair — but a step cannot grant
727
+ // what the SIGNED CEILING does not offer, so against a spec whose `tools` lack
728
+ // `recall`/`get` EVERY draft reds identically, and the drafter is paid for each
729
+ // doomed cycle before the run fails. The condition is knowable here, before a
730
+ // token: the arm is the operator's argument and the ceiling is already signed.
731
+ //
732
+ // Same class as the unknown-arm throw above and deliberately NOT a red: a red is
733
+ // a verdict on the AGENT's work, and nothing the agent authored is wrong here —
734
+ // the operator asked for a shim the signature cannot satisfy. Tighten-only, and
735
+ // it narrows nothing that works: the only configuration it rejects is one that
736
+ // already fails 100% of the time, just later and for money.
737
+ //
738
+ // Keyed on `arm.g1`, not on "is the shim on", for the same reason `plan.js` is:
739
+ // the diff-only arm caps nothing, so there is nothing to be blind about.
740
+ if (readShimArm(readShim).g1) {
741
+ const signedCeiling = Array.isArray(job?.tools) ? job.tools : [...TOOL_MENU];
742
+ const short = RETRIEVAL_PAIR.filter((v) => !signedCeiling.includes(v));
743
+ if (short.length) {
744
+ throw new TypeError(`readShim: this arm caps reads, so a step granting "read" must also grant ${RETRIEVAL_PAIR.join(' and ')} — but the signed ceiling [${signedCeiling.join(', ')}] offers no ${short.join('/')}, so no legal plan exists and every draft would red as "read-blind". Re-sign the spec with ${short.join(' and ')}, or run with the shim off.`);
745
+ }
746
+ }
672
747
  // ONE spelling of the redaction, housed next to the inventory it reads
673
748
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
674
749
  // with. Two hand-spelled copies of "what a secret looks like" is exactly how
@@ -1152,6 +1227,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1152
1227
  onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
1153
1228
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1154
1229
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1230
+ // BA-21 provenance, through the SAME helper the worker rounds use — this record
1231
+ // is on `spendProvenance`'s read list (src/ledger.js), so a judge-round that
1232
+ // never carried the field would read UNKNOWN forever inside the readout built to
1233
+ // expose exactly this seam. NAMED, not papered over: the judge cost payload
1234
+ // (src/kinds.js `onJudgeCost({...})`) does not carry `rateSource` today, so it
1235
+ // still reads UNKNOWN — correctly — until that payload forwards it too.
1236
+ ...rateSourceFields(c),
1155
1237
  }),
1156
1238
  })
1157
1239
  : runStages(stages, scrub, closeOpts));
@@ -1855,11 +1937,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1855
1937
  };
1856
1938
  const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
1857
1939
  const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
1940
+ // THE READ SHIM (arm-gated, default OFF): a per-worker delivery ledger over the
1941
+ // read tool — cap at READ_SHIM_CAP, pointer only once the CURRENT content has been
1942
+ // delivered whole, next-unseen-slice otherwise, diff on a changed re-read. WHICH of
1943
+ // those are live is the arm (`readShimArm`, Phase 2's A0/A1/A2/A3); off is no wrapper at
1944
+ // all. Same seam and the same per-worker lifetime argument as the native cap below:
1945
+ // `createShellTools()` above builds fresh tool objects for THIS worker, so the ledger
1946
+ // dies with the worker, which is the reset the archive replay segmented on.
1947
+ //
1948
+ // COMPOSITION ON NATIVE, decided: a CAPPING arm REPLACES the native wrapper, never layers
1949
+ // over it. Both bound the same seam at the same 24 KB (READ_SHIM_CAP === NATIVE_READ_CAP,
1950
+ // deliberately one number) and both hand back a trusted steer at the same two verbs, so
1951
+ // stacking them would truncate twice and append two notices — and worse, the shim on the
1952
+ // OUTSIDE would ledger the native wrapper's already-truncated text as the whole file and
1953
+ // then answer a re-read with a pointer, which is precisely the lie this module exists to
1954
+ // prevent. The shim is the strictly stronger wrapper (it keeps the CLI display bound AND
1955
+ // continues where the worker left off), so with a CAPPING arm ON it takes the seam alone.
1956
+ // An arm that brings NO cap (A2) is the other case and is handled at the block below —
1957
+ // it must not take the native bound away, because losing a bound is not one of its levers.
1958
+ const shimArm = readShimArm(readShim);
1959
+ // ONE shim per worker, holding ONE ledger, wrapping BOTH seams the cap touches:
1960
+ // the read tool below, and `ctx_get`'s stale-pointer answer further down. They
1961
+ // are one shim because the cap creates the second problem — its strategy line
1962
+ // sends a capped worker to ctx_recall/ctx_get for a whole function, and a stale
1963
+ // pointer there is the cap's own dead end, not litectx's.
1964
+ const shim = createReadShim({ arm: shimArm });
1965
+ const rdTool = shell.find((/** @type {{name: string}} */ t) => t.name === TOOL_BY_VERB.read);
1858
1966
  // F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
1859
1967
  // TRUSTED truncation notice steering to ctx_get — the CLI's own truncation blinds the worker
1860
1968
  // (spilled + injection-flagged). Fresh tool objects per mkWorker call, so mutating is per-worker.
1861
- if (native) {
1862
- const rd = shell.find((/** @type {{name: string}} */ t) => t.name === TOOL_BY_VERB.read);
1969
+ //
1970
+ // The condition is the shim's CAP, not the shim: an arm that caps nothing (A2, the diff)
1971
+ // takes the native wrapper's cap away from a native run and hands the seam back to the CLI's
1972
+ // silent truncation — a REGRESSION against A0 on that surface, produced by a lever that is
1973
+ // not about capping at all. So the native bound stays whenever the shim brings none, and it
1974
+ // is applied FIRST, INSIDE the shim: the shim's own doctrine is that it ledgers the text the
1975
+ // worker actually received (the tool's own maxBytes bound is already handled this way), so a
1976
+ // diff against a natively-truncated delivery is a diff against bytes the worker really holds.
1977
+ // Outside, it would truncate the shim's diff mid-hunk. Under A1/A3 this block does not run at
1978
+ // all, so the order is unobservable there and the composition decision below is unchanged.
1979
+ if (native && !shimArm.cap) {
1980
+ const rd = rdTool;
1863
1981
  if (rd) {
1864
1982
  const inner = rd.execute;
1865
1983
  rd.execute = async (/** @type {any} */ args) => {
@@ -1872,8 +1990,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1872
1990
  };
1873
1991
  }
1874
1992
  }
1993
+ // …and the shim goes on last, so it is the OUTER wrapper wherever both apply.
1994
+ if (shimArm.on && rdTool) shim.wrapRead(rdTool);
1995
+ // The stale-pointer serve rides ONLY with a capping arm — `serveStale` refuses
1996
+ // on any other arm anyway, and passing the hook regardless would still change
1997
+ // A0's `ctx_get` object, which has to stay the untouched baseline.
1875
1998
  const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
1876
- ? createCtxTools(lc, workdir, emitCtx).filter((t) => grantedNames.has(t.name))
1999
+ ? createCtxTools(lc, workdir, emitCtx, shimArm.cap ? { onStalePointer: shim.serveStale } : {}).filter((t) => grantedNames.has(t.name))
1877
2000
  : [];
1878
2001
  // LC-3 (litectx 0.31.0): cooperative yield — index() is async but its work is sync CPU,
1879
2002
  // and the default pass holds THIS event loop (the fuse's timers, the wall clock, the lag
@@ -1888,8 +2011,21 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1888
2011
  // isolate/retrieval paragraphs named other components' verbs outright. The
1889
2012
  // assembly lives in tools.js beside the prose it composes, and is asserted
1890
2013
  // byte-identical to the full-component paragraphs on a full grant.
1891
- const system = PERSONA_TOOLS + strategyFor(granted)
1892
- + (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
2014
+ // One bound, one strategy line: with a CAPPING arm on, the shim OWNS the read seam on
2015
+ // both surfaces (it replaced the native wrapper above), so it speaks for the cap and
2016
+ // the native line stands down — two notices describing one bound is how a worker learns
2017
+ // to distrust both. Off, this renders exactly as before.
2018
+ //
2019
+ // The lines are assembled from the SAME two facts the wrappers were: whichever bound is
2020
+ // actually installed speaks for itself, and the arm's own line rides along. A2 on the API
2021
+ // surface therefore gets the diff sentence and NOTHING about a 24KB limit — a persona
2022
+ // describing machinery that is off is a lie to the worker, and a worker rationing reads
2023
+ // against an imaginary cap is a different treatment than the arm names. A2 on native gets
2024
+ // both, because on native both bounds really are installed.
2025
+ const readLines = grantedNames.has(TOOL_BY_VERB.read)
2026
+ ? (native && !shimArm.cap ? NATIVE_READ_STRATEGY : '') + readShimStrategy(shimArm)
2027
+ : '';
2028
+ const system = PERSONA_TOOLS + strategyFor(granted) + readLines;
1893
2029
  /** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
1894
2030
  const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
1895
2031
 
@@ -1903,7 +2039,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1903
2039
  // WITH tools take this path: a native session with NO tools fires no
1904
2040
  // onTurn and reports no cost (live-verified), so the toolless drafter runs
1905
2041
  // the metered claude-json TEXT path below instead (never unmetered spend).
1906
- /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
2042
+ /** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string}} arg */
1907
2043
  const nativeMetered = async (arg) => {
1908
2044
  const session = (arg?.kind ?? 'turn') === 'session';
1909
2045
  // per-turn events are ATTRIBUTION ONLY (`worker-turn`, never accounted —
@@ -1914,6 +2050,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1914
2050
  emit(session ? 'worker-round' : 'worker-turn', {
1915
2051
  phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
1916
2052
  costUsd: session ? (arg?.costUsd ?? null) : null, pricing: arg?.pricing ?? null,
2053
+ // BA-21 provenance. The session event forwards whatever upstream reported; a
2054
+ // per-turn event says `null` — the same deliberate statement `costUsd` makes
2055
+ // one line up. A native turn is unpriced BY DESIGN (the CLI prices the SESSION),
2056
+ // so no rate was consulted and there is no guess to have made. Stated here
2057
+ // rather than forwarded because it is OUR fact about this surface, not one the
2058
+ // provider payload carries.
2059
+ ...(session ? rateSourceFields(arg) : { rateSource: null }),
1917
2060
  tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
1918
2061
  usage: usageOf(arg?.usage),
1919
2062
  });
@@ -2012,7 +2155,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2012
2155
  const newLoop = (gen) => {
2013
2156
  /** @type {any} */
2014
2157
  let self = null;
2015
- /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
2158
+ /** @param {{costUsd?: number|null, pricing?: string|null, rateSource?: string|null, usage?: any, kind?: string}} arg */
2016
2159
  const metered = async (arg) => {
2017
2160
  // The meter fires for EVERY generation, live or abandoned. An orphaned round
2018
2161
  // was really billed, and spend that no instrument sees is the one thing this
@@ -2027,6 +2170,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2027
2170
  emit('worker-round', {
2028
2171
  phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
2029
2172
  costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
2173
+ // BA-21 provenance, forwarded verbatim: `pricing` says WHETHER the round was
2174
+ // priced, `rateSource` says who stood behind the rate. Reporting only — the
2175
+ // pricing-red halt still keys on cost alone (src/run.js).
2176
+ ...rateSourceFields(arg),
2030
2177
  tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
2031
2178
  usage: usageOf(arg?.usage),
2032
2179
  });
@@ -2276,7 +2423,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2276
2423
  };
2277
2424
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
2278
2425
  drafter.setIteration(reds ? 'redraft' : 'draft');
2279
- const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null), []);
2426
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null, readShim), []);
2280
2427
  return extractArtifact(r.text).code ?? '';
2281
2428
  };
2282
2429
  // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
@@ -2289,7 +2436,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2289
2436
  // emit sites: scrubbing here makes the leak inexpressible, scrubbing each
2290
2437
  // consumer makes it something the next consumer has to remember.
2291
2438
  const validate = (/** @type {string} */ t) => {
2292
- const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
2439
+ const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, readShim, ...checkFacts });
2293
2440
  return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
2294
2441
  };
2295
2442
  let text = await draftPlan(null);
@@ -2320,7 +2467,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2320
2467
  // burn the very work resume exists to keep (hamr: "why would i want to waste
2321
2468
  // more money on something i already started"). Omission = rules inactive, by
2322
2469
  // the validator's contract.
2323
- const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu });
2470
+ // `readShim` IS passed, unlike the shape-lottery opts above: G1 is not a drafting
2471
+ // law about plan quality but a precondition of the machinery that is about to run —
2472
+ // a capped worker with no retrieval verb is blind, whether its plan is fresh or
2473
+ // reloaded. A plan drafted under the shim already passed it, so the only way this
2474
+ // refuses paid work is an operator flipping the flag between legs, which is a spec
2475
+ // change deserving the refusal. Off, it is inert, and this line renders as before.
2476
+ const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu, readShim });
2324
2477
  // scrubbed at the boundary for `validate()`'s own reason: a `parse-error` detail
2325
2478
  // quotes a window of the SOURCE, and the spine is append-only forever
2326
2479
  const reds = pv.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));