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.
@@ -0,0 +1,538 @@
1
+ // THE READ SHIM (L1 pointer + L4 cap) — a per-worker delivery ledger over the
2
+ // read tool. ARM-gated and OFF by default; nothing here runs unless `readShim`
3
+ // names an arm on the run (src/planrun.js wires it, src/run.js plumbs it). WHICH
4
+ // levers an arm carries is `readShimArm` below, and the reasoning for each arm's
5
+ // membership sits with it.
6
+ //
7
+ // The measured shape ($0 replay, 1,844 real `shell_read` actions from 143
8
+ // archived gate audits, cap 24,576 B):
9
+ // - 48% of all reads re-read a file that had not changed since the worker
10
+ // last saw it — those bytes are already in the transcript, re-sent and
11
+ // re-billed every round after.
12
+ // - cap + pointer, keyed on WHAT WAS DELIVERED: 51,853,154 → 17,834,409 bytes
13
+ // (65.6% saved) with ZERO untruthful responses.
14
+ // - cap + pointer, keyed on PATH+HASH (the naive design): 8.2 points cheaper
15
+ // and **250 untruthful responses** — median 73,348 bytes hidden each, 234 of
16
+ // them over 10 KB, worst case a worker holding 24,576 of 105,426 bytes told
17
+ // "unchanged, you already have it". That is BA-17 read-blinding, and the
18
+ // extra savings ARE the lie.
19
+ //
20
+ // So the ledger keys on DELIVERY, never on the path's content hash alone: a
21
+ // pointer is legal only once every byte of the CURRENT content has been handed
22
+ // over. A partly-seen file gets its next unseen slice instead — the response the
23
+ // naive design converts into a lie (194 of them across the corpus).
24
+ //
25
+ // What the shim can and cannot claim, stated plainly: it is truthful about what
26
+ // THIS SEAM delivered. The underlying tool applies its own `maxBytes` bound and
27
+ // says so in its own notice; the shim treats that returned text as the content,
28
+ // because it is exactly what the worker gets. It never claims the worker holds
29
+ // bytes the tool itself withheld.
30
+ //
31
+ // L2 (ship a diff when the content changed) is here — its own lever, live under
32
+ // the `'diff'` and `true` arms — and deliberately narrow. It never fired on the real corpus and was worth 3–5% on
33
+ // the money replay, so it is built to be worthless-but-harmless rather than
34
+ // clever: it fires only where the ledger already proves the worker holds the
35
+ // complete previous version, and only when the diff is smaller than the slice
36
+ // it would replace. A diff is a statement ABOUT bytes the worker has — assert
37
+ // it over bytes it never had and you have re-invented the path+hash lie in a
38
+ // costlier form, because a worker told "line 900 changed" about a file it only
39
+ // ever saw 24 KB of has no way to notice it was told a fiction.
40
+
41
+ import { createHash } from 'node:crypto';
42
+ import { readFileSync } from 'node:fs';
43
+
44
+ /** The delivery cap, in bytes. Equal to `NATIVE_READ_CAP` by construction — the
45
+ * native path's CLI display bound and this cap bound the SAME seam, so one
46
+ * number keeps them from double-truncating each other (see planrun's wiring). */
47
+ export const READ_SHIM_CAP = 24 * 1024;
48
+
49
+ // ── THE ARMS (Phase 2 pre-registration, 2026-08-18) ────────────────────────
50
+ // The frozen battery names four arms — A0 off, A1 cap+pointer+slice+G1, A2 diff
51
+ // only, A3 everything — and a single boolean can express exactly two of them.
52
+ // So `readShim` is an ARM NAME that still accepts the two booleans it used to:
53
+ // `false` is A0 and `true` is A3, both byte-identical to what they meant before
54
+ // this file learned the word "arm", because a flag whose meaning shifts under a
55
+ // caller written against the old spelling is a silent treatment change.
56
+ //
57
+ // Every lever is a separate field rather than a level on a dial, because the
58
+ // arms are not nested: A2 is not "less of" A1, it is a different lever entirely.
59
+ //
60
+ // Why A2 carries neither the pointer nor G1, stated so it can be argued with
61
+ // rather than discovered in a result:
62
+ // - POINTER: the prereg's table lists "pointer" inside A1 and A2 as "diff
63
+ // only". The pointer is the lever that answers the 48% of reads that
64
+ // re-read an UNCHANGED file — the single biggest saving in the replay. Fold
65
+ // it into A2 and A2 stops being the diff's own read: A2-vs-A1 would compare
66
+ // (diff + the big saving) against (cap + the big saving) and attribute the
67
+ // shared term to neither. So an unchanged re-read under A2 re-delivers.
68
+ // - G1: G1 exists because a CAP blinds a worker that cannot aim — it forces
69
+ // the retrieval pair onto any step granting `read`. A2 caps nothing, so
70
+ // there is nothing to be blind about, and firing it would make A2 a
71
+ // different treatment than the prereg names (a narrower admissible plan
72
+ // space, on top of the diff). An admission rule is not free.
73
+ /** @typedef {{on: boolean, cap: boolean, pointer: boolean, diff: boolean, g1: boolean}} ReadShimArm */
74
+ /** @type {ReadShimArm} */
75
+ const ARM_OFF = Object.freeze({ on: false, cap: false, pointer: false, diff: false, g1: false });
76
+ /** @type {ReadShimArm} */
77
+ const ARM_CAP = Object.freeze({ on: true, cap: true, pointer: true, diff: false, g1: true });
78
+ /** @type {ReadShimArm} */
79
+ const ARM_DIFF = Object.freeze({ on: true, cap: false, pointer: false, diff: true, g1: false });
80
+ /** @type {ReadShimArm} */
81
+ const ARM_ALL = Object.freeze({ on: true, cap: true, pointer: true, diff: true, g1: true });
82
+
83
+ /** The legal spellings, in one place so an error message cannot drift from the
84
+ * set it describes. */
85
+ export const READ_SHIM_ARMS = Object.freeze(['false (A0 — off)', "'cap' (A1)", "'diff' (A2)", 'true (A3 — all levers)']);
86
+
87
+ /**
88
+ * Resolve the `readShim` flag into its arm. THROWS on anything else — the BA-4
89
+ * param-guard class, and deliberately not a red.
90
+ *
91
+ * An unrecognised value is OPERATOR input, arriving before a single token is
92
+ * spent, and there is exactly one safe way to fail on it: loudly, at once. The
93
+ * failure this guard exists to prevent is `readShim: 'diff '` (or 'Diff', or
94
+ * 'cap+diff') coerced by truthiness into A3 — a battery row that RAN the wrong
95
+ * arm, recorded the arm it was asked for, and is invisible in the results
96
+ * afterwards. That is the blind-instrument class this programme keeps paying
97
+ * for, and it would corrupt the contrast rather than merely fail a run.
98
+ *
99
+ * A red was the alternative and is wrong here twice over: a red is a verdict on
100
+ * the AGENT's work (the plan, the artifact), and this is the operator's own
101
+ * argument; and a red would need a terminal outcome name, which is arbiter
102
+ * territory and out of this change's scope.
103
+ * @param {boolean|'cap'|'diff'|undefined} [flag]
104
+ * @returns {ReadShimArm}
105
+ */
106
+ export function readShimArm(flag) {
107
+ if (flag === undefined || flag === false) return ARM_OFF;
108
+ if (flag === true) return ARM_ALL;
109
+ if (flag === 'cap') return ARM_CAP;
110
+ if (flag === 'diff') return ARM_DIFF;
111
+ throw new TypeError(`readShim: unknown arm ${JSON.stringify(flag)} — legal values are ${READ_SHIM_ARMS.join(', ')}`);
112
+ }
113
+
114
+ /** F19 at the bound rather than the verb (the NATIVE_READ_STRATEGY precedent):
115
+ * a cap the worker does not know about is a worker that reads the same file
116
+ * three times wondering why it keeps starting over. It states the bound, what a
117
+ * re-read does, and the two verbs that read a symbol whole — the capability the
118
+ * cap makes mandatory (G1). */
119
+ 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>) — 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.';
120
+
121
+ /** The DIFF arm's own line. It says nothing about a 24KB limit and nothing about
122
+ * continuation reads, because under A2 neither exists — a persona describing
123
+ * machinery that is off is a lie to the worker, and a worker rationing its reads
124
+ * against an imaginary cap is a different treatment than the arm names.
125
+ *
126
+ * It does not repeat the steer at ctx_recall/ctx_get either: that steer is the
127
+ * CAP's compensation (G1's other half), and offering it here would import half
128
+ * of A1 into A2. The delivery itself carries `diffNotice`'s framing inline; this
129
+ * line exists so the first diff is not a surprise. */
130
+ 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 — 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.';
131
+
132
+ /**
133
+ * The persona line this arm is entitled to — empty when the arm changes nothing
134
+ * the worker can perceive. One arm, one line: the two are never concatenated,
135
+ * because a cap arm has no diff and a diff arm has no cap.
136
+ * @param {ReadShimArm} arm
137
+ * @returns {string}
138
+ */
139
+ export function readShimStrategy(arm) {
140
+ if (arm.cap) return READ_SHIM_STRATEGY;
141
+ if (arm.diff) return READ_SHIM_DIFF_STRATEGY;
142
+ return '';
143
+ }
144
+
145
+ /** A slice that ends the file needs no steer — the worker has it all now. */
146
+ const doneNotice = (start, total) =>
147
+ `\n\n[bareloop: bytes ${start}-${total} of ${total} shown — the rest of this file, which you had not seen. You now hold all of it.]`;
148
+
149
+ /** The capped-slice notice. TRUSTED framing (the `bareloop:` prefix, the same
150
+ * register as the native truncation notice): a worker that reads this as
151
+ * untrusted spill goes hunting instead of aiming, which is the failure BA-17
152
+ * already paid for. It names the two verbs that read a symbol IN FULL, because a
153
+ * capped worker with no way to aim is read-blinding on purpose — G1 in
154
+ * `validatePlan` makes sure those verbs are actually granted. */
155
+ const capNotice = (start, end, total) =>
156
+ `\n\n[bareloop: bytes ${start}-${end} of ${total} shown — this read is capped at ${READ_SHIM_CAP} bytes. `
157
+ + 'To read a specific function IN FULL use ctx_recall(<symbol>) then ctx_get(<pointer>); to find a line use shell_grep(<pattern>). '
158
+ + `Reading this path again continues from byte ${end}.]`;
159
+
160
+ /** The pointer. Legal ONLY when the ledger says every current byte was handed
161
+ * over — the one claim this module exists to keep honest. */
162
+ const pointer = (path, total) =>
163
+ `[bareloop: ${path} is unchanged since you read it — you already hold all ${total} bytes of it. Nothing new to show.]`;
164
+
165
+ /** The diff's own TRUSTED framing, same `bareloop:` register as the notices
166
+ * above. It says what the payload IS (a diff, not the file) and what it is
167
+ * against (the copy the worker already holds), because a worker that reads a
168
+ * hunk as the file's contents has been read-blinded by a saving. */
169
+ const diffNotice = (path, total) =>
170
+ `[bareloop: ${path} changed since you read it. Below is a DIFF against the version you already hold IN FULL — not the file. `
171
+ + "Lines starting '-' were removed, '+' were added, ' ' are unchanged context; '@@ -a +b @@' gives the line numbers in the old and new file. "
172
+ + `Apply it to the copy you have and you hold the current ${total} bytes; there is nothing else to read.]`;
173
+
174
+ /** Lines of unchanged context on each side of a hunk — enough to place the
175
+ * change without re-sending the file around it. */
176
+ const DIFF_CONTEXT = 2;
177
+
178
+ /** The LCS table is old-lines × new-lines cells. Bounded because this runs
179
+ * INSIDE a tool call the worker is waiting on: a 20k-line file rewritten whole
180
+ * is 400M cells, which is a stall and a heap spike in exchange for a diff that
181
+ * would blow the size bound anyway. Over the bound we decline and re-deliver —
182
+ * the same answer, reached without the arithmetic. */
183
+ const DIFF_CELL_BOUND = 1_000_000;
184
+
185
+ /**
186
+ * A line-based diff of `oldText` → `newText`, or null if it cannot be produced
187
+ * within `maxBytes`. Stdlib only, and deliberately not byte-compatible with
188
+ * GNU `diff -u`: hunk headers here are plain 1-based `-start,count +start,count`
189
+ * with no attempt to match GNU's empty-range convention, because the reader is
190
+ * a model reading prose, not `patch`.
191
+ *
192
+ * The byte budget is enforced WHILE rendering, not checked afterwards, so an
193
+ * enormous diff is abandoned rather than built and then thrown away.
194
+ * @param {string} oldText
195
+ * @param {string} newText
196
+ * @param {number} maxBytes
197
+ * @returns {string|null}
198
+ */
199
+ function lineDiff(oldText, newText, maxBytes) {
200
+ const a = oldText.split('\n');
201
+ const b = newText.split('\n');
202
+ // Common head and tail come off first: real edits touch a fraction of a file,
203
+ // and trimming is what keeps the quadratic middle small enough to be legal.
204
+ let head = 0;
205
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
206
+ let tail = 0;
207
+ while (tail < a.length - head && tail < b.length - head
208
+ && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
209
+ const ar = a.slice(head, a.length - tail);
210
+ const br = b.slice(head, b.length - tail);
211
+ if (ar.length * br.length > DIFF_CELL_BOUND) return null;
212
+
213
+ // Longest common subsequence over the changed middle, classic DP.
214
+ const w = br.length + 1;
215
+ const dp = new Uint32Array((ar.length + 1) * w);
216
+ for (let i = ar.length - 1; i >= 0; i--) {
217
+ for (let j = br.length - 1; j >= 0; j--) {
218
+ dp[i * w + j] = ar[i] === br[j]
219
+ ? dp[(i + 1) * w + j + 1] + 1
220
+ : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
221
+ }
222
+ }
223
+ /** @type {{t: string, line: string, ai: number, bi: number}[]} */
224
+ const rows = [];
225
+ let ai = 0, bi = 0;
226
+ const push = (/** @type {string} */ t, /** @type {string} */ line) => {
227
+ rows.push({ t, line, ai, bi });
228
+ if (t !== '+') ai++;
229
+ if (t !== '-') bi++;
230
+ };
231
+ for (let k = 0; k < head; k++) push(' ', a[k]);
232
+ let i = 0, j = 0;
233
+ while (i < ar.length && j < br.length) {
234
+ if (ar[i] === br[j]) { push(' ', ar[i]); i++; j++; }
235
+ else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { push('-', ar[i]); i++; }
236
+ else { push('+', br[j]); j++; }
237
+ }
238
+ while (i < ar.length) { push('-', ar[i]); i++; }
239
+ while (j < br.length) { push('+', br[j]); j++; }
240
+ for (let k = a.length - tail; k < a.length; k++) push(' ', a[k]);
241
+
242
+ // Group the changed rows into hunks, each padded with context; two changes
243
+ // closer than twice the context share one hunk rather than repeating lines.
244
+ const changed = rows.map((r, k) => (r.t === ' ' ? -1 : k)).filter((k) => k >= 0);
245
+ if (changed.length === 0) return null;
246
+ /** @type {[number, number][]} */
247
+ const hunks = [];
248
+ for (const k of changed) {
249
+ const last = hunks[hunks.length - 1];
250
+ if (last && k - last[1] <= DIFF_CONTEXT * 2 + 1) last[1] = k;
251
+ else hunks.push([k, k]);
252
+ }
253
+
254
+ let out = '';
255
+ let bytes = 0;
256
+ const emit = (/** @type {string} */ s) => {
257
+ bytes += Buffer.byteLength(s, 'utf8');
258
+ out += s;
259
+ return bytes <= maxBytes;
260
+ };
261
+ for (const [lo, hi] of hunks) {
262
+ const from = Math.max(0, lo - DIFF_CONTEXT);
263
+ const to = Math.min(rows.length - 1, hi + DIFF_CONTEXT);
264
+ let aCount = 0, bCount = 0;
265
+ for (let k = from; k <= to; k++) {
266
+ if (rows[k].t !== '+') aCount++;
267
+ if (rows[k].t !== '-') bCount++;
268
+ }
269
+ if (!emit(`@@ -${rows[from].ai + 1},${aCount} +${rows[from].bi + 1},${bCount} @@\n`)) return null;
270
+ for (let k = from; k <= to; k++) if (!emit(`${rows[k].t}${rows[k].line}\n`)) return null;
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /** A UTF-8 sequence split across a slice boundary decodes to a replacement
276
+ * character on BOTH sides — the byte is then delivered twice and read as
277
+ * garbage once. Walk back to the last lead byte instead. `end` moves by at most
278
+ * 3, which no cap this module ships can drive to zero progress. */
279
+ function utf8Boundary(buf, end) {
280
+ if (end >= buf.length) return buf.length;
281
+ let e = end;
282
+ while (e > 0 && (buf[e] & 0xc0) === 0x80) e--;
283
+ return e;
284
+ }
285
+
286
+ /** The stale-serve's TRUSTED framing, same `bareloop:` register as the notices
287
+ * above. Everything it has to say is load-bearing and none of it can be dropped
288
+ * for brevity:
289
+ * - the pointer is STALE, so the worker knows why it is not getting a chunk;
290
+ * - the range may now hold DIFFERENT code than the symbol it asked for. This
291
+ * is the sentence that makes the serve legal at all. litectx refuses to
292
+ * slice a drifted range itself, and says why in `StalePointerError`'s own
293
+ * docstring: an UNLABELLED line slice can silently be another symbol's body,
294
+ * which is worse than an exception. Labelled, it is a raw slice the worker
295
+ * can judge; unlabelled it would be the same lie the ledger exists to stop;
296
+ * - which lines it actually got, and how long the file is now;
297
+ * - that NOTHING here was recorded as delivered, so the worker cannot read the
298
+ * serve as progress against the read seam's cap;
299
+ * - how to get a real pointer back.
300
+ * No `]` appears before the end: the bracket is the notice's own terminator. */
301
+ const staleServeNotice = (path, startLine, shownEnd, lines, cut, cap) =>
302
+ `[bareloop: the ctx_get pointer for ${path} lines ${startLine}-${shownEnd} is STALE — that file changed on disk after it was indexed, `
303
+ + 'so the recorded line range may now hold DIFFERENT code than the symbol you asked for. '
304
+ + `Rather than nothing, below are lines ${startLine}-${shownEnd} of ${path} exactly as they stand on disk right now `
305
+ + `(0-based line indexes, as ctx_recall prints them; the file has ${lines} lines, so its last index is ${lines - 1})`
306
+ + `${cut ? `, cut at the ${cap}-byte read limit` : ''}. `
307
+ + 'This is a raw line slice — not a chunk, not the whole file — and none of it was recorded as delivered to you, '
308
+ + 'so a shell_read of this path is unaffected. Re-run ctx_recall(<symbol>) for a fresh pointer.]';
309
+
310
+ /** The two refusals. Both are RESULTS the worker reads, never throws — a stale
311
+ * pointer on a vanished or shrunken file is ordinary worker feedback, and a
312
+ * throw here would look exactly like a verb that was never reached (the F18
313
+ * blindness rule that put `emit` in `createCtxTools` in the first place). */
314
+ const staleAbsentNotice = (path, startLine, endLine, detail) =>
315
+ `[bareloop: the ctx_get pointer for ${path} lines ${startLine}-${endLine} is stale (${detail}), `
316
+ + `and ${path} is no longer on disk — there is nothing to show you. Re-run ctx_recall(<symbol>) for a fresh pointer.]`;
317
+
318
+ const stalePastEofNotice = (path, startLine, endLine, lines, detail) =>
319
+ `[bareloop: the ctx_get pointer for ${path} lines ${startLine}-${endLine} is stale (${detail}), `
320
+ + `and that file ${lines === 0 ? 'is now empty' : `is now only ${lines} lines long, so its last 0-based index is ${lines - 1}`}, `
321
+ + 'so that range is entirely past its end — there is nothing to show you. '
322
+ + 'Re-run ctx_recall(<symbol>) for a fresh pointer.]';
323
+
324
+ /** @typedef {{file: string, path: string, startLine: number, endLine: number, detail: string}} StaleReq */
325
+ /** @typedef {{text: string, outcome: 'stale-served'|'stale-absent'|'stale-past-eof'}} StaleServe */
326
+
327
+ /**
328
+ * Create the shim: one delivery ledger, and the two seams that speak for it.
329
+ *
330
+ * `wrapRead` is the read tool's wrapper (the ledger's only writer). `serveStale`
331
+ * is the answer to a stale `ctx_get` pointer, and it deliberately holds NO
332
+ * reference to the ledger — see its own note. Both live in one object because
333
+ * they are one worker's shim: the read seam and the retrieval seam the cap
334
+ * steers a capped worker towards.
335
+ * @param {{cap?: number, arm?: ReadShimArm}} [opts] `cap`: delivery bound in bytes (the
336
+ * shipped value is `READ_SHIM_CAP`; the option exists so the native path can pin the
337
+ * two bounds to one number rather than two constants drifting apart). `arm`: which
338
+ * levers are live (`readShimArm`). Defaults to ALL of them, so every caller written
339
+ * before the arms existed keeps the behaviour it was written against.
340
+ */
341
+ export function createReadShim({ cap = READ_SHIM_CAP, arm = ARM_ALL } = {}) {
342
+ /** path → what the worker has been handed of the CURRENT content. `full` is
343
+ * the delivered text itself, kept ONLY while the worker holds the whole file
344
+ * (null otherwise) because that is the only state a diff is legal from.
345
+ *
346
+ * MEMORY COST, stated rather than waved at: one copy of every fully-delivered
347
+ * file, for the lifetime of one worker. The ceiling is real but it is not a
348
+ * new one — those exact bytes are already sitting in the worker's transcript,
349
+ * where they are the expensive copy (they are billed every round; this one is
350
+ * billed never). A partly-delivered file stores nothing, so the pathological
351
+ * case — a worker paging through many huge files — is precisely the case that
352
+ * holds no copies. The map dies with the worker along with the rest of the
353
+ * ledger; nothing here is process-wide.
354
+ * @type {Map<string, {hash: string, total: number, delivered: number, full: string|null}>} */
355
+ const ledger = new Map();
356
+
357
+ /**
358
+ * THE STALE-POINTER SERVE. When `ctx_get` throws `StalePointerError` — the file
359
+ * changed on disk after indexing — hand back the REQUESTED LINE RANGE read
360
+ * fresh from disk, labelled, instead of nothing.
361
+ *
362
+ * WHY IT EXISTS: the cap's own strategy line steers a capped worker at
363
+ * `ctx_recall` → `ctx_get` for a whole function. A worker that has just EDITED
364
+ * the file it is working on — the normal case — then gets a stale pointer and
365
+ * zero bytes. Measured on the A1 arm's three green runs: 10 `ctx_get` calls, 5
366
+ * stale, 0 bytes each, every one a file the worker had just written. The round
367
+ * is paid for and returns nothing, and the steer is what walked it there.
368
+ *
369
+ * WHY IT NEVER TOUCHES THE LEDGER, and why that is the whole design: the
370
+ * ledger's `full` is the licence for the pointer response ("you already hold
371
+ * all of it"), and it is held ONLY while `delivered === total`. A ranged serve
372
+ * hands over a SLICE — of a file the read seam may never have delivered a byte
373
+ * of, from the MIDDLE rather than the next unseen prefix. Record it and the
374
+ * next read answers "unchanged, you already have it" to a worker holding 90
375
+ * lines of 900: exactly the untruthful-pointer failure this module was built
376
+ * to prevent (250 of them in the $0 replay, median 73,348 bytes hidden). So
377
+ * `ledger` is not in scope here — not "we are careful not to write it", but no
378
+ * reference at all — and the cost is that a later `shell_read` may re-deliver
379
+ * bytes the serve already showed. That is the fail-safe direction: the shim
380
+ * over-delivers rather than lies.
381
+ *
382
+ * Gated on the CAP lever, not on `arm.on`: the serve is the cap's own
383
+ * compensation (the same argument G1 and `READ_SHIM_STRATEGY` are made of). An
384
+ * arm that caps nothing never steered the worker at `ctx_get` and must keep
385
+ * `ctx_get`'s untouched contract.
386
+ *
387
+ * Never throws — a vanished or shrunken file is a refusal RESULT.
388
+ * @param {StaleReq} req `file` is the ABSOLUTE path the gate already judged
389
+ * for this call; `path` is the repo-relative spelling shown to the worker.
390
+ * @returns {StaleServe|null} null when this arm carries no cap
391
+ */
392
+ const serveStale = ({ file, path, startLine, endLine, detail }) => {
393
+ if (!arm.cap) return null;
394
+ // THE LINE SPACE, verified against real litectx rather than read off a
395
+ // comment: `ctx_recall`/`ctx_get` handles are 0-BASED and INCLUSIVE (a probe
396
+ // indexed a file, took recall's pointer, and matched `get`'s text against
397
+ // `lines.slice(start, end + 1)` — the 1-based reading returned the wrong two
398
+ // lines). The worker's numbers are echoed back in that same space, because
399
+ // `LINE_SPACE` already told it that is the space it is in, and a serve that
400
+ // silently renumbered would be the off-by-one that doc exists to prevent.
401
+ const a = Math.max(0, Math.floor(Number(startLine) || 0));
402
+ const b = Math.max(a, Math.floor(Number(endLine) || a));
403
+ let content;
404
+ try {
405
+ content = readFileSync(file, 'utf8');
406
+ } catch {
407
+ return { text: staleAbsentNotice(path, a, b, detail), outcome: 'stale-absent' };
408
+ }
409
+ const lines = content.split('\n');
410
+ // A trailing newline yields a final empty element that is not a line.
411
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
412
+ if (a >= lines.length) {
413
+ return { text: stalePastEofNotice(path, a, b, lines.length, detail), outcome: 'stale-past-eof' };
414
+ }
415
+ const shownEnd = Math.min(b, lines.length - 1);
416
+ let body = `${lines.slice(a, shownEnd + 1).join('\n')}\n`;
417
+ // The same cap as every other delivery this module makes, on the same
418
+ // UTF-8-safe boundary. A requested range is worker-chosen and unbounded.
419
+ const buf = Buffer.from(body, 'utf8');
420
+ const cut = buf.length > cap;
421
+ if (cut) body = buf.subarray(0, utf8Boundary(buf, cap)).toString('utf8');
422
+ return {
423
+ text: `${staleServeNotice(path, a, shownEnd, lines.length, cut, cap)}\n\n${body}`,
424
+ outcome: 'stale-served',
425
+ };
426
+ };
427
+
428
+ /**
429
+ * Wrap a read tool with the delivery ledger. MUTATES `tool.execute` and returns
430
+ * the tool, which is safe because the caller builds fresh tool objects per
431
+ * worker (`createShellTools()` inside `mkWorker`) — so the ledger's lifetime is
432
+ * one worker, matching the fresh-Loop-per-step reset the replay segmented on.
433
+ * A process-wide ledger would carry one step's coverage into the next step's
434
+ * transcript, which never held those bytes.
435
+ * @param {{name: string, execute: (args: any) => Promise<any>}} tool the read tool
436
+ * @returns {{name: string, execute: (args: any) => Promise<any>}} the same tool
437
+ */
438
+ const wrapRead = (tool) => {
439
+ // An OFF arm wraps nothing at all — not a wrapper that passes through, which
440
+ // would still be a `tool.execute` this module owns. A0 has to be the seam
441
+ // untouched, and "untouched" is the assertion, not "behaves the same".
442
+ if (!arm.on) return tool;
443
+ const inner = tool.execute;
444
+ tool.execute = async (/** @type {any} */ args) => {
445
+ const path = typeof args?.path === 'string' ? args.path : null;
446
+ const r = await inner(args);
447
+ // Anything that is not text (or a call with no path to key on) is the tool's
448
+ // own business — pass it through and record nothing. A throw never reaches
449
+ // here at all: the worker gets the failure, and the ledger stays clean of a
450
+ // path nobody ever received bytes for.
451
+ if (path === null || typeof r !== 'string') return r;
452
+
453
+ const buf = Buffer.from(r, 'utf8');
454
+ const total = buf.length;
455
+ const hash = createHash('sha256').update(buf).digest('hex');
456
+ const seen = ledger.get(path);
457
+ // Same content as last time — and only then does coverage mean anything.
458
+ // Any change (grown, shrunk, edited) resets to zero: coverage that survives
459
+ // a content change is the masking direction, because a file that shrank
460
+ // under stale coverage would answer as "fully delivered".
461
+ //
462
+ // Without the CAP lever (A2) there is no such thing as partial coverage: a
463
+ // read hands over the file, so `start` is always 0 and `end` is always the
464
+ // end. Everything below then falls out unchanged — the slice IS the file,
465
+ // and the diff's budget below is the whole re-delivery it replaces, which is
466
+ // the same sentence the capped arm's budget says, at a different size.
467
+ const start = arm.cap && seen && seen.hash === hash ? seen.delivered : 0;
468
+ const end = arm.cap ? utf8Boundary(buf, Math.min(start + cap, total)) : total;
469
+
470
+ // L2, THE DIFF LEVER. Two guards, both refusals rather than adjustments:
471
+ // - `seen.full !== null` IS the coverage guard: the previous bytes are
472
+ // retained only when every one of them was delivered, so a partly-seen
473
+ // old version has nothing to diff against and re-delivers from 0.
474
+ // - the budget is the slice this diff REPLACES, `end - start` bytes of a
475
+ // fresh capped re-delivery. Bounding by the cap alone would let a diff
476
+ // be several times a small file and still ship; bounding by the slice
477
+ // makes "smaller than what it replaces" true by construction, and is
478
+ // never looser than the cap.
479
+ // A consequence worth naming rather than tuning away: the framing itself is
480
+ // ~350 bytes, so a file smaller than that can never produce a legal diff and
481
+ // always re-delivers whole. That is the rule working, not a gap — a diff of
482
+ // a 78-byte file costs more than the file.
483
+ //
484
+ // A rendered diff is always COMPLETE — the renderer returns the whole thing
485
+ // or null, never a prefix — which is what licenses the ledger line below:
486
+ // the worker holds the new content whole, so the next unchanged read is a
487
+ // truthful pointer. Anything less than complete falls through to the slice
488
+ // path and records only what that path actually handed over.
489
+ if (arm.diff && seen && seen.hash !== hash && seen.full !== null) {
490
+ const d = lineDiff(seen.full, r, Math.max(0, (end - start) - Buffer.byteLength(diffNotice(path, total), 'utf8') - 2));
491
+ if (d !== null) {
492
+ ledger.set(path, { hash, total, delivered: total, full: r });
493
+ return `${diffNotice(path, total)}\n\n${d}`;
494
+ }
495
+ }
496
+
497
+ // The POINTER lever. Reachable only when the ledger already covers the
498
+ // current content — under A2 that is an unchanged re-read of a file handed
499
+ // over whole, and the arm answers it by handing the bytes over again. Saying
500
+ // "you already have it" there would be TRUE and still wrong: it is the
501
+ // pointer's saving, and A2 is the diff's own read (see the arm table above).
502
+ //
503
+ // `seen.hash === hash` is spelled out rather than left implied by `start`: on a
504
+ // ZERO-BYTE content `start >= total` is 0 >= 0 and holds with no ledger entry at
505
+ // all, so the FIRST read of an empty file — and, worse, a read of a file that was
506
+ // just TRUNCATED to empty since the worker was handed its contents — answered
507
+ // "unchanged since you read it, you already hold all of it". Both are untruthful
508
+ // pointers on a delivery that never happened, which is the one thing this module
509
+ // exists to make impossible. With the guard, an empty file falls through to the
510
+ // slice path and is delivered (as nothing) truthfully.
511
+ if (arm.pointer && seen && seen.hash === hash && start >= total) {
512
+ ledger.set(path, { hash, total, delivered: total, full: r });
513
+ return pointer(path, total);
514
+ }
515
+ ledger.set(path, { hash, total, delivered: end, full: end === total ? r : null });
516
+ const slice = buf.subarray(start, end).toString('utf8');
517
+ // A first read that fits under the cap is the tool's own bytes, untouched —
518
+ // no notice, no reframing. That is the overwhelming majority of reads, and
519
+ // it keeps the shim invisible where it buys nothing.
520
+ if (start === 0 && end === total) return slice;
521
+ return slice + (end < total ? capNotice(start, end, total) : doneNotice(start, total));
522
+ };
523
+ return tool;
524
+ };
525
+
526
+ return { arm, cap, wrapRead, serveStale };
527
+ }
528
+
529
+ /**
530
+ * The one-tool spelling, kept for every caller written before the shim became an
531
+ * object: wrap this read tool with its own private ledger and hand it back.
532
+ * @param {{name: string, execute: (args: any) => Promise<any>}} tool
533
+ * @param {{cap?: number, arm?: ReadShimArm}} [opts]
534
+ * @returns {{name: string, execute: (args: any) => Promise<any>}} the same tool
535
+ */
536
+ export function wrapReadTool(tool, opts = {}) {
537
+ return createReadShim(opts).wrapRead(tool);
538
+ }
package/src/reuse.js CHANGED
@@ -1332,6 +1332,7 @@ export function resumeTreeGate({ head, seed, dirty }) {
1332
1332
  * @param {number} [opts.strikeLimit] forwarded (a plan step's strike ceiling)
1333
1333
  * @param {number} [opts.closeTimeoutMs] forwarded
1334
1334
  * @param {boolean} [opts.layerRoot] forwarded
1335
+ * @param {boolean|'cap'|'diff'} [opts.readShim] the read shim's ARM (A0/A1/A2/A3), forwarded verbatim
1335
1336
  * @param {any} [opts.resume] RESUME (module C) — a `readResume` reading of the KILLED
1336
1337
  * run's own spine. Its `approvalHash` must match this run's, or the run refuses:
1337
1338
  * a resume continues ONE signed run, not any run.
@@ -1348,7 +1349,7 @@ export async function runReuse(opts) {
1348
1349
  const {
1349
1350
  job, approvals, registryDir, patient, workdir, provider, selectionProvider,
1350
1351
  emit, ask, pinned = null, shortlist = null, forceCold = false,
1351
- providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot,
1352
+ providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot, readShim,
1352
1353
  runJob = shippedRunJob, now = () => Date.now(),
1353
1354
  } = opts;
1354
1355
  const runid = opts.runid ?? Date.now().toString(36);
@@ -1516,6 +1517,7 @@ export async function runReuse(opts) {
1516
1517
  ...(strikeLimit !== undefined ? { strikeLimit } : {}),
1517
1518
  ...(closeTimeoutMs !== undefined ? { closeTimeoutMs } : {}),
1518
1519
  ...(layerRoot !== undefined ? { layerRoot } : {}),
1520
+ ...(readShim !== undefined ? { readShim } : {}),
1519
1521
  // the fold, and whether the fold is EXACT. `priorSpendComplete` used to stop at
1520
1522
  // the `try-start` record: it was computed, written down, and then not handed to
1521
1523
  // the one component whose `job-end` the row's own `spendComplete` is read off —
package/src/run.js CHANGED
@@ -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
@@ -356,7 +378,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
356
378
  // accounts it natively (F12) and the job-end money contract is unchanged.
357
379
  {
358
380
  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,
381
+ 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
382
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
361
383
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
362
384
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact