bareloop 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,570 @@
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, onCount?: ((kind: 'pointered'|'capped', bytesWithheld: number) => void)|null}} [opts]
336
+ * `cap`: delivery bound in bytes (the shipped value is `READ_SHIM_CAP`; the option
337
+ * exists so the native path can pin the two bounds to one number rather than two
338
+ * constants drifting apart). `arm`: which levers are live (`readShimArm`). Defaults to
339
+ * ALL of them, so every caller written before the arms existed keeps the behaviour it
340
+ * was written against. `onCount`: MEMORY-CACHE — fired once per pointer/capped-slice
341
+ * response with exactly the bytes withheld on THAT response, for a caller (e.g.
342
+ * `runPlan`) that sums several workers' shims into one run-level readout. Never called
343
+ * for a diff response (L2 is its own lever, uncounted here) or a first read under the
344
+ * cap (nothing was withheld).
345
+ */
346
+ export function createReadShim({ cap = READ_SHIM_CAP, arm = ARM_ALL, onCount = null } = {}) {
347
+ /** path → what the worker has been handed of the CURRENT content. `full` is
348
+ * the delivered text itself, kept ONLY while the worker holds the whole file
349
+ * (null otherwise) because that is the only state a diff is legal from.
350
+ *
351
+ * MEMORY COST, stated rather than waved at: one copy of every fully-delivered
352
+ * file, for the lifetime of one worker. The ceiling is real but it is not a
353
+ * new one — those exact bytes are already sitting in the worker's transcript,
354
+ * where they are the expensive copy (they are billed every round; this one is
355
+ * billed never). A partly-delivered file stores nothing, so the pathological
356
+ * case — a worker paging through many huge files — is precisely the case that
357
+ * holds no copies. The map dies with the worker along with the rest of the
358
+ * ledger; nothing here is process-wide.
359
+ * @type {Map<string, {hash: string, total: number, delivered: number, full: string|null}>} */
360
+ const ledger = new Map();
361
+
362
+ // MEMORY-CACHE counters (this worker's own ledger, summed by a caller across
363
+ // workers for a run-level readout — see `onCount` above). Counted only where
364
+ // it is EXACT, never estimated: a pointer withholds exactly `total` bytes (the
365
+ // whole delivery it replaces) and a capped slice withholds exactly `total - end`
366
+ // (the tail this call did not send) — both numbers the ledger already computes
367
+ // for its own bookkeeping below, read here rather than re-derived.
368
+ let pointered = 0;
369
+ let capped = 0;
370
+ let bytesWithheld = 0;
371
+ /** @param {'pointered'|'capped'} kind @param {number} bytes */
372
+ const count = (kind, bytes) => {
373
+ if (kind === 'pointered') pointered++; else capped++;
374
+ bytesWithheld += bytes;
375
+ if (onCount) onCount(kind, bytes);
376
+ };
377
+
378
+ /**
379
+ * THE STALE-POINTER SERVE. When `ctx_get` throws `StalePointerError` — the file
380
+ * changed on disk after indexing — hand back the REQUESTED LINE RANGE read
381
+ * fresh from disk, labelled, instead of nothing.
382
+ *
383
+ * WHY IT EXISTS: the cap's own strategy line steers a capped worker at
384
+ * `ctx_recall` → `ctx_get` for a whole function. A worker that has just EDITED
385
+ * the file it is working on — the normal case — then gets a stale pointer and
386
+ * zero bytes. Measured on the A1 arm's three green runs: 10 `ctx_get` calls, 5
387
+ * stale, 0 bytes each, every one a file the worker had just written. The round
388
+ * is paid for and returns nothing, and the steer is what walked it there.
389
+ *
390
+ * WHY IT NEVER TOUCHES THE LEDGER, and why that is the whole design: the
391
+ * ledger's `full` is the licence for the pointer response ("you already hold
392
+ * all of it"), and it is held ONLY while `delivered === total`. A ranged serve
393
+ * hands over a SLICE — of a file the read seam may never have delivered a byte
394
+ * of, from the MIDDLE rather than the next unseen prefix. Record it and the
395
+ * next read answers "unchanged, you already have it" to a worker holding 90
396
+ * lines of 900: exactly the untruthful-pointer failure this module was built
397
+ * to prevent (250 of them in the $0 replay, median 73,348 bytes hidden). So
398
+ * `ledger` is not in scope here — not "we are careful not to write it", but no
399
+ * reference at all — and the cost is that a later `shell_read` may re-deliver
400
+ * bytes the serve already showed. That is the fail-safe direction: the shim
401
+ * over-delivers rather than lies.
402
+ *
403
+ * Gated on the CAP lever, not on `arm.on`: the serve is the cap's own
404
+ * compensation (the same argument G1 and `READ_SHIM_STRATEGY` are made of). An
405
+ * arm that caps nothing never steered the worker at `ctx_get` and must keep
406
+ * `ctx_get`'s untouched contract.
407
+ *
408
+ * Never throws — a vanished or shrunken file is a refusal RESULT.
409
+ * @param {StaleReq} req `file` is the ABSOLUTE path the gate already judged
410
+ * for this call; `path` is the repo-relative spelling shown to the worker.
411
+ * @returns {StaleServe|null} null when this arm carries no cap
412
+ */
413
+ const serveStale = ({ file, path, startLine, endLine, detail }) => {
414
+ if (!arm.cap) return null;
415
+ // THE LINE SPACE, verified against real litectx rather than read off a
416
+ // comment: `ctx_recall`/`ctx_get` handles are 0-BASED and INCLUSIVE (a probe
417
+ // indexed a file, took recall's pointer, and matched `get`'s text against
418
+ // `lines.slice(start, end + 1)` — the 1-based reading returned the wrong two
419
+ // lines). The worker's numbers are echoed back in that same space, because
420
+ // `LINE_SPACE` already told it that is the space it is in, and a serve that
421
+ // silently renumbered would be the off-by-one that doc exists to prevent.
422
+ const a = Math.max(0, Math.floor(Number(startLine) || 0));
423
+ const b = Math.max(a, Math.floor(Number(endLine) || a));
424
+ let content;
425
+ try {
426
+ content = readFileSync(file, 'utf8');
427
+ } catch {
428
+ return { text: staleAbsentNotice(path, a, b, detail), outcome: 'stale-absent' };
429
+ }
430
+ const lines = content.split('\n');
431
+ // A trailing newline yields a final empty element that is not a line.
432
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
433
+ if (a >= lines.length) {
434
+ return { text: stalePastEofNotice(path, a, b, lines.length, detail), outcome: 'stale-past-eof' };
435
+ }
436
+ const shownEnd = Math.min(b, lines.length - 1);
437
+ let body = `${lines.slice(a, shownEnd + 1).join('\n')}\n`;
438
+ // The same cap as every other delivery this module makes, on the same
439
+ // UTF-8-safe boundary. A requested range is worker-chosen and unbounded.
440
+ const buf = Buffer.from(body, 'utf8');
441
+ const cut = buf.length > cap;
442
+ if (cut) body = buf.subarray(0, utf8Boundary(buf, cap)).toString('utf8');
443
+ return {
444
+ text: `${staleServeNotice(path, a, shownEnd, lines.length, cut, cap)}\n\n${body}`,
445
+ outcome: 'stale-served',
446
+ };
447
+ };
448
+
449
+ /**
450
+ * Wrap a read tool with the delivery ledger. MUTATES `tool.execute` and returns
451
+ * the tool, which is safe because the caller builds fresh tool objects per
452
+ * worker (`createShellTools()` inside `mkWorker`) — so the ledger's lifetime is
453
+ * one worker, matching the fresh-Loop-per-step reset the replay segmented on.
454
+ * A process-wide ledger would carry one step's coverage into the next step's
455
+ * transcript, which never held those bytes.
456
+ * @param {{name: string, execute: (args: any) => Promise<any>}} tool the read tool
457
+ * @returns {{name: string, execute: (args: any) => Promise<any>}} the same tool
458
+ */
459
+ const wrapRead = (tool) => {
460
+ // An OFF arm wraps nothing at all — not a wrapper that passes through, which
461
+ // would still be a `tool.execute` this module owns. A0 has to be the seam
462
+ // untouched, and "untouched" is the assertion, not "behaves the same".
463
+ if (!arm.on) return tool;
464
+ const inner = tool.execute;
465
+ tool.execute = async (/** @type {any} */ args) => {
466
+ const path = typeof args?.path === 'string' ? args.path : null;
467
+ const r = await inner(args);
468
+ // Anything that is not text (or a call with no path to key on) is the tool's
469
+ // own business — pass it through and record nothing. A throw never reaches
470
+ // here at all: the worker gets the failure, and the ledger stays clean of a
471
+ // path nobody ever received bytes for.
472
+ if (path === null || typeof r !== 'string') return r;
473
+
474
+ const buf = Buffer.from(r, 'utf8');
475
+ const total = buf.length;
476
+ const hash = createHash('sha256').update(buf).digest('hex');
477
+ const seen = ledger.get(path);
478
+ // Same content as last time — and only then does coverage mean anything.
479
+ // Any change (grown, shrunk, edited) resets to zero: coverage that survives
480
+ // a content change is the masking direction, because a file that shrank
481
+ // under stale coverage would answer as "fully delivered".
482
+ //
483
+ // Without the CAP lever (A2) there is no such thing as partial coverage: a
484
+ // read hands over the file, so `start` is always 0 and `end` is always the
485
+ // end. Everything below then falls out unchanged — the slice IS the file,
486
+ // and the diff's budget below is the whole re-delivery it replaces, which is
487
+ // the same sentence the capped arm's budget says, at a different size.
488
+ const start = arm.cap && seen && seen.hash === hash ? seen.delivered : 0;
489
+ const end = arm.cap ? utf8Boundary(buf, Math.min(start + cap, total)) : total;
490
+
491
+ // L2, THE DIFF LEVER. Two guards, both refusals rather than adjustments:
492
+ // - `seen.full !== null` IS the coverage guard: the previous bytes are
493
+ // retained only when every one of them was delivered, so a partly-seen
494
+ // old version has nothing to diff against and re-delivers from 0.
495
+ // - the budget is the slice this diff REPLACES, `end - start` bytes of a
496
+ // fresh capped re-delivery. Bounding by the cap alone would let a diff
497
+ // be several times a small file and still ship; bounding by the slice
498
+ // makes "smaller than what it replaces" true by construction, and is
499
+ // never looser than the cap.
500
+ // A consequence worth naming rather than tuning away: the framing itself is
501
+ // ~350 bytes, so a file smaller than that can never produce a legal diff and
502
+ // always re-delivers whole. That is the rule working, not a gap — a diff of
503
+ // a 78-byte file costs more than the file.
504
+ //
505
+ // A rendered diff is always COMPLETE — the renderer returns the whole thing
506
+ // or null, never a prefix — which is what licenses the ledger line below:
507
+ // the worker holds the new content whole, so the next unchanged read is a
508
+ // truthful pointer. Anything less than complete falls through to the slice
509
+ // path and records only what that path actually handed over.
510
+ if (arm.diff && seen && seen.hash !== hash && seen.full !== null) {
511
+ const d = lineDiff(seen.full, r, Math.max(0, (end - start) - Buffer.byteLength(diffNotice(path, total), 'utf8') - 2));
512
+ if (d !== null) {
513
+ ledger.set(path, { hash, total, delivered: total, full: r });
514
+ return `${diffNotice(path, total)}\n\n${d}`;
515
+ }
516
+ }
517
+
518
+ // The POINTER lever. Reachable only when the ledger already covers the
519
+ // current content — under A2 that is an unchanged re-read of a file handed
520
+ // over whole, and the arm answers it by handing the bytes over again. Saying
521
+ // "you already have it" there would be TRUE and still wrong: it is the
522
+ // pointer's saving, and A2 is the diff's own read (see the arm table above).
523
+ //
524
+ // `seen.hash === hash` is spelled out rather than left implied by `start`: on a
525
+ // ZERO-BYTE content `start >= total` is 0 >= 0 and holds with no ledger entry at
526
+ // all, so the FIRST read of an empty file — and, worse, a read of a file that was
527
+ // just TRUNCATED to empty since the worker was handed its contents — answered
528
+ // "unchanged since you read it, you already hold all of it". Both are untruthful
529
+ // pointers on a delivery that never happened, which is the one thing this module
530
+ // exists to make impossible. With the guard, an empty file falls through to the
531
+ // slice path and is delivered (as nothing) truthfully.
532
+ if (arm.pointer && seen && seen.hash === hash && start >= total) {
533
+ ledger.set(path, { hash, total, delivered: total, full: r });
534
+ // Withheld = the whole delivery this pointer replaces: `total` bytes the
535
+ // worker asked to be re-sent and was not, because it already holds them.
536
+ count('pointered', total);
537
+ return pointer(path, total);
538
+ }
539
+ ledger.set(path, { hash, total, delivered: end, full: end === total ? r : null });
540
+ const slice = buf.subarray(start, end).toString('utf8');
541
+ // A first read that fits under the cap is the tool's own bytes, untouched —
542
+ // no notice, no reframing. That is the overwhelming majority of reads, and
543
+ // it keeps the shim invisible where it buys nothing.
544
+ if (start === 0 && end === total) return slice;
545
+ // Withheld = the tail beyond this slice (`total - end`), not the slice
546
+ // itself — the slice IS what was sent; the remainder is what was not.
547
+ if (end < total) count('capped', total - end);
548
+ return slice + (end < total ? capNotice(start, end, total) : doneNotice(start, total));
549
+ };
550
+ return tool;
551
+ };
552
+
553
+ /** MEMORY-CACHE: this worker's own counts, exact — never re-derived, never
554
+ * estimated. `bytesWithheld` sums only the `pointered`/`capped` cases above;
555
+ * the diff lever (L2) is a different response shape and is not counted here. */
556
+ const summary = () => ({ pointered, capped, bytesWithheld });
557
+
558
+ return { arm, cap, wrapRead, serveStale, summary };
559
+ }
560
+
561
+ /**
562
+ * The one-tool spelling, kept for every caller written before the shim became an
563
+ * object: wrap this read tool with its own private ledger and hand it back.
564
+ * @param {{name: string, execute: (args: any) => Promise<any>}} tool
565
+ * @param {{cap?: number, arm?: ReadShimArm}} [opts]
566
+ * @returns {{name: string, execute: (args: any) => Promise<any>}} the same tool
567
+ */
568
+ export function wrapReadTool(tool, opts = {}) {
569
+ return createReadShim(opts).wrapRead(tool);
570
+ }
package/src/reuse.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
2
- // docs/02-features/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
2
+ // docs/product/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
3
3
  //
4
4
  // hamr's sentence is the whole specification of this file, verbatim:
5
5
  //
@@ -115,8 +115,30 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
115
115
  * how they would come to disagree about which member is which: the first three are
116
116
  * allowances the loop's remaining tries are meant to spend against, and the human
117
117
  * ones are the stop no further try can change (see `hardStop`).
118
+ *
119
+ * `provider-red` folded in 2026-08-25 (PRD v1.80 TODO #4, F115 "Ruled: one transport
120
+ * retry"; hamr's ruling verbatim: *"joins resume anyways and you decide but you are
121
+ * given an honest readout and with 1 retry"*). It is a MACHINE halt, not a human one
122
+ * — it does NOT go into `HUMAN_CHECKPOINTS` — but it is a checkpoint by the same
123
+ * test every other member here passes: the close never got the say it was asked
124
+ * for (no verdict was rendered, so it is not a graded red — see `REUSE_GRADED_RED`
125
+ * above), and the scout+draft and every finished step it bought stand on disk
126
+ * exactly like a cap-halt or a wall-halt. The one transport retry
127
+ * (`src/planrun.js`'s `withTransportRetry`) already covers the common case; when
128
+ * that retry ALSO fails, throwing the run away because the transport hiccuped
129
+ * twice is throwing away real work for no reason a machine can judge. Deliberately
130
+ * NO cost/step threshold gates this: a threshold is a number picked from a small
131
+ * observed sample (F115 logged exactly 7 rows), which is arbiter territory
132
+ * reserved for hamr — the operator decides case by case, every time, off the
133
+ * honest readout `scripts/run-u.mjs` prints.
134
+ *
135
+ * This entry was built 2026-08-24 as a LOCAL widening in `scripts/run-u.mjs`
136
+ * (`RESUMABLE_HALTS = [...CHECKPOINT_OUTCOMES, 'provider-red']`) because this file
137
+ * sat outside that pass's scope — the fold here is the follow-up, so this repo
138
+ * does not end up with two lists that can drift, which is the exact failure this
139
+ * constant exists to end (see the paragraph above).
118
140
  */
119
- export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', ...HUMAN_CHECKPOINTS]);
141
+ export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', ...HUMAN_CHECKPOINTS]);
120
142
 
121
143
  /**
122
144
  * v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
@@ -1332,6 +1354,7 @@ export function resumeTreeGate({ head, seed, dirty }) {
1332
1354
  * @param {number} [opts.strikeLimit] forwarded (a plan step's strike ceiling)
1333
1355
  * @param {number} [opts.closeTimeoutMs] forwarded
1334
1356
  * @param {boolean} [opts.layerRoot] forwarded
1357
+ * @param {boolean|'cap'|'diff'} [opts.readShim] the read shim's ARM (A0/A1/A2/A3), forwarded verbatim
1335
1358
  * @param {any} [opts.resume] RESUME (module C) — a `readResume` reading of the KILLED
1336
1359
  * run's own spine. Its `approvalHash` must match this run's, or the run refuses:
1337
1360
  * a resume continues ONE signed run, not any run.
@@ -1348,7 +1371,7 @@ export async function runReuse(opts) {
1348
1371
  const {
1349
1372
  job, approvals, registryDir, patient, workdir, provider, selectionProvider,
1350
1373
  emit, ask, pinned = null, shortlist = null, forceCold = false,
1351
- providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot,
1374
+ providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot, readShim,
1352
1375
  runJob = shippedRunJob, now = () => Date.now(),
1353
1376
  } = opts;
1354
1377
  const runid = opts.runid ?? Date.now().toString(36);
@@ -1516,6 +1539,7 @@ export async function runReuse(opts) {
1516
1539
  ...(strikeLimit !== undefined ? { strikeLimit } : {}),
1517
1540
  ...(closeTimeoutMs !== undefined ? { closeTimeoutMs } : {}),
1518
1541
  ...(layerRoot !== undefined ? { layerRoot } : {}),
1542
+ ...(readShim !== undefined ? { readShim } : {}),
1519
1543
  // the fold, and whether the fold is EXACT. `priorSpendComplete` used to stop at
1520
1544
  // the `try-start` record: it was computed, written down, and then not handed to
1521
1545
  // the one component whose `job-end` the row's own `spendComplete` is read off —
package/src/root.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
2
- // docs/02-features/2026-07-19-layer-r-design.md). One state that survives attempts
2
+ // docs/product/2026-07-19-layer-r-design.md). One state that survives attempts
3
3
  // inside ONE run so a never-green run stops repeating itself (F21). Role is
4
4
  // CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
5
5
  // the semantic converter is Layer 2's job, not this module's.