bareloop 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,89 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] — 2026-07-21
11
+
12
+ ### Added
13
+
14
+ - **Layer R — the root, the within-run ratchet (`src/root.js`; design record
15
+ `docs/plans/2026-07-19-layer-r-design.md`, interview-locked 2026-07-19).** The shell
16
+ mechanically detects fixation — consecutive attempts rewriting the same file(s)
17
+ without moving the close's kept-failure set — and injects an escalating note into the
18
+ next attempt's prompt: a capped summary first, then the worker's own prior failed
19
+ edit content verbatim (the BA-14 rejected-edit-buffer shape, rewritten for our
20
+ attempt loop). Fixation-gated (inert when not stuck — RSI §3.3: the lift is a
21
+ fixation phenomenon, an honest null on a strong unstuck model), shell-authored (the
22
+ worker authors nothing, gains no verb), within-run only (state dies with the run;
23
+ inheritance stays verdict-gated). Red-set comparison strips the spec-reporter's
24
+ per-run duration stamps (POC-measured: kept lines are never byte-stable) —
25
+ comparison-only, the delivered gap is untouched. Spine event `root-injected` carries
26
+ stage/mode/streak/paths, never content (append-only law). Ships **OFF by default**
27
+ (`layerRoot: false` on `runJob`/`interpret`; pass `true` for the ON/experimental arm) —
28
+ decided 2026-07-21: fixation is extinct on every current job (F41), so ON has never won
29
+ its own A/B; the default flip to `true` defers to the first Layer 2 job that produces
30
+ natural fixation (see `docs/01-product/LAYERS.md` Layer R note).
31
+
32
+ ### Fixed
33
+
34
+ - **F44 — fresh whole-branch review: three correctness bugs + cleanups, all validated and
35
+ mutation-proven.** (1) A casualty job-end laundered unknown spend as complete: `1b0720c`
36
+ put `spentUsd` on every path, so a provider-red TRANSPORT THROW reported
37
+ `spendComplete: true` (F6 violation — the failed call's cost is unknown) and the battery
38
+ scripts' `spentUsd == null` casualty-STOP never fired. Fixed both sides: the transport
39
+ provider-red now reports `spendComplete: false` (the metered draft-truncation keeps
40
+ `true`), and the frozen battery/probe scripts key their STOP on
41
+ `spendComplete === false || spentUsd == null`. (2) Layer R's outcome probe mislabeled a
42
+ missed-anchor edit as "landed" when its `newText` appeared elsewhere (substring
43
+ false-positive) — now discriminated by the tool result, exact-equality for writes, one
44
+ read not two. (3) Layer R false-fired on a progressing text-mode worker with no gapKeep
45
+ (write-overlap is constant-true when the single target is rewritten every attempt) — a
46
+ `writesInformative` flag now requires a known-unmoved red-set when writes carry no
47
+ information. Cleanups: double redaction, unbounded `attempts` retention, a dead ternary.
48
+ (2) and (3) are latent behind the OFF-by-default `layerRoot`.
49
+
50
+ - **Layer R settled its note on the gate's allow, which is written BEFORE the tool runs
51
+ (F43).** An `allow` record states INTENT, never that bytes reached a file:
52
+ `shell_edit` returns an anchor miss as a refusal *result* and a byte-cap overflow as a
53
+ throw, both leaving the file untouched with the allow already on the audit. The
54
+ verbatim note could therefore present content to the worker as "your own previous
55
+ changes — they landed" while that text was provably absent from the file it named
56
+ (reproduced end-to-end: 3 allowed edits, 0 bytes changed). Fixed by splitting the two
57
+ axes rather than merging them — the DETECTOR keeps reading intent (an edit that never
58
+ applied is still repetition; a tree-diff detector measured blind to it on every
59
+ attempt), while the NOTE settles on the observed file through `Loop`'s
60
+ `onToolResult` seam. An unapplied repeat now names the missed anchor instead, which is
61
+ the mechanical gap genre (F38). `commitWrite` is replaced by `settleWrite(landed)`.
62
+ - **Guarded the `maxTurns` LLM-round invariant (F43 follow-up).** bareguard's `maxTurns`
63
+ ticks on every `gate.record`; our cap means "LLM rounds" only because the sole record
64
+ path is the LLM one (tool calls take `gate.check`, which does not tick). That was
65
+ correct but unguarded — wiring tool results into `gate.record` would silently halve the
66
+ LLM budget (the F37 lower-silent-ceiling class). Added a guard test pinning "every
67
+ `gate.record` is `type:llm`" (mutation-proven: wiring tool records turns it red) and a
68
+ config comment marking the invariant load-bearing. No behavior change; no upstream ask —
69
+ bareguard already offers the correct counters.
70
+ - **F41 — the disposition: armed-and-inert, field read deferred.** Before any ON/OFF
71
+ battery spent money, two cheap reads measured the disease's base rate: the archive
72
+ sweep (`poc/layer-r-base-rate.mjs`, $0 — every surviving spine is OFF-arm by
73
+ construction) read 0 fixated in 10 pairs on jobs #2/#4; two frozen probes on a
74
+ rebuilt job #1 patient (`scripts/run-probe-layer-r.mjs`, $10.12 total) read 0
75
+ fixated in 4 pairs — including against a three-plant tree (three subsystems, one fix
76
+ cannot green) that forced a full 8→4→green ladder across three judged attempts.
77
+ Every pair was healthy navigation. F21's fixation was a broken-loop symptom, cured
78
+ by the F20/F21/F30/BA-13 fixes. Because ON has therefore never won its own A/B,
79
+ Layer R ships **OFF by default** (decided 2026-07-21; `layerRoot: true` is the ON
80
+ arm) — measured cost-free when enabled and healthy (zero injections across all
81
+ probe runs); the repetition-drop ON/OFF read, and the default-flip decision, defer
82
+ to the first run whose spine records `root-injected`. No learning claim is minted.
83
+
84
+ ### Changed
85
+
86
+ - **`jobs/litectx-maintainer.json` `budgetUsd` 1.5 → 4.5** (probe 2, re-signed): $1.50
87
+ funded exactly ONE judged attempt on the real patient — a run could red at attempt 1
88
+ and die at the gate mid-attempt-2, structurally unable to produce across-attempt
89
+ evidence. The advertised budget must fund the attempts the cap promises.
90
+
8
91
  ## [0.3.0] — 2026-07-19
9
92
 
10
93
  ### Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <p align="center">
14
14
  <img src="https://img.shields.io/github/package-json/v/hamr0/bareloop?label=version&color=2a4f8c" alt="version (auto from package.json)">
15
15
  <img src="https://img.shields.io/badge/license-Apache%202.0-2a4f8c" alt="license: Apache 2.0">
16
- <img src="https://img.shields.io/badge/status-N2%20%C2%B7%20headless%20loop-2a6d3a" alt="status: N2, headless loop">
16
+ <img src="https://img.shields.io/badge/status-Layer%20R%20%C2%B7%20ratchet%20armed-2a6d3a" alt="status: Layer R, ratchet armed">
17
17
  </p>
18
18
 
19
19
  **"Automate this job — I don't know the best workflow."** For tasks that are **repeated,
@@ -25,10 +25,15 @@ inheritance with ledger-counted attribution.
25
25
  The pitch in one line: **workflows that earn their own design, with receipts** — every
26
26
  inherited rule carries the green that minted it and the contrast that attributed it.
27
27
 
28
- > **Status: N2 — the headless loop is real.** `runJob()` takes a signed job spec through
29
- > draft → per-step worker loops → close verdicts → draft PR, under one budget the agent
30
- > can only tighten. Rungs N0–N2 have landed (roadmap below); next is executed
31
- > inheritance (N3), where the ladder's kill-switch question gets its answer.
28
+ > **Status: Layer R — the within-run ratchet is built and armed.** `runJob()` takes a
29
+ > signed job spec through draft → per-step worker loops → close verdicts → draft PR,
30
+ > under one budget the agent can only tighten; the shell now also detects a worker
31
+ > stuck repeating itself and feeds back its own failed attempts (escalating
32
+ > summary→verbatim). Two frozen probes found the disease in remission under the current
33
+ > loop (F41: 0 fixated pairs in 14, jobs #1/#2/#4), so the ratchet ships inert-by-
34
+ > measurement with its field read deferred to the first run that trips it. Next is
35
+ > Layer 2 (plan-v1 micro-wheels), then executed inheritance (N3) — the ladder's
36
+ > kill-switch question.
32
37
 
33
38
  ## Quick start
34
39
 
@@ -98,6 +103,8 @@ ladder, and the stop is a result.
98
103
  | **N0** ✅ | Port + outer shell + spine (token-free) — v0.1.0 |
99
104
  | **N1** ✅ | Job/close schema + validator — v0.2.0 |
100
105
  | **N2** ✅ | Single-job headless loop — job #1 minimal (review→fix→PR, hard greens only) — v0.3.0 |
106
+ | **Layer R** ✅ | Within-run ratchet: shell-side fixation detector (intent) + rejected-edit feedback (outcome), **shipped OFF by default** (F41: fixation in remission, so ON has never won its A/B; F43 split the two axes; field read + default decision defer to Layer 2) — v0.4.0 |
107
+ | **Layer 2** | plan-v1 micro-wheels — the road: bounded steps with in-run verifiable exits (the F39-measured semantic converter) |
101
108
  | **N3** | Executed inheritance + contrast-bit extractor — **kill-switch: rules must transmit across non-identical runs** |
102
109
  | **N4** | Verdict classes complete (soft/HITL minting) |
103
110
  | **N5** | Scheduler + budget ops |
@@ -285,7 +285,32 @@ the close judges the tree; on-green `remember` retains the worker's change summa
285
285
  Worker-caused close crashes feed back as `worker-crash` gaps via the gate audit (F32 —
286
286
  see `ralph` above).
287
287
 
288
- ### `runJob(spec, { approvals, workdir, provider, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd? })` → outcome — `src/run.js`
288
+ **Layer R — the within-run ratchet (`layerRoot`, default `false`; design record
289
+ 2026-07-19).** The shell mechanically detects fixation across attempts — consecutive
290
+ attempts that rewrote the same file(s) without moving the close's kept-failure set
291
+ (`closeGapKeep` lines, duration stamps stripped before comparing; without a gapKeep the
292
+ detector degrades to write-overlap alone and the spine event names the mode) — and
293
+ injects an escalating note into the next prompt: first a capped summary naming the
294
+ repeated files, then (if still stuck) the worker's OWN prior failed edit content
295
+ verbatim, scrubbed and capped with trims announced. Inert unless stuck; the worker
296
+ authors nothing and gains no verb. Two axes, deliberately separate (F43): the DETECTOR
297
+ reads INTENT (the gate audit's allow-set — an edit whose anchor missed is still the
298
+ worker reaching for that file again, and a tree-diff detector measured blind to exactly
299
+ that), while the NOTE's claims about file contents read OUTCOME (settled post-execution
300
+ through `Loop`'s `onToolResult`). So a repeat that never applied is told its anchor
301
+ missed, and only content actually in the file is ever described as having landed. Spine event `root-injected` carries
302
+ `{stage, mode, streak, paths, redSetSize}` — counts and paths only, NEVER content (the
303
+ spine is append-only). State dies with the run: this is within-run scratch, not
304
+ across-run memory. `layerRoot: true` (on `interpret` and `runJob`) is the ON/experimental
305
+ arm; the default is OFF. Status (F41): the ratchet is MEASURED inert on every current
306
+ job — two frozen probes + an archive sweep read 0 fixated pairs in 14 (the F21 repetition
307
+ disease was a broken-loop symptom, since cured). Because ON has therefore never won its
308
+ own A/B, it ships **OFF by default (decided 2026-07-21)** — armed and correct, but not
309
+ default-enabled until a stuck job (Layer 2, or a manufactured-fixation probe) shows ON
310
+ beats OFF. Flip the default to `true` the day that evidence lands; a `root-injected` event
311
+ on your spine (when you pass `layerRoot: true`) is a signal worth reading, not noise.
312
+
313
+ ### `runJob(spec, { approvals, workdir, provider, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd?, layerRoot? })` → outcome — `src/run.js`
289
314
 
290
315
  The N2 runner — the shell's top layer; composes everything below it and interprets
291
316
  nothing itself. Sequence: **approval gate** (human-signs-always — refuses an unapproved
@@ -312,7 +337,15 @@ round the API cut off mid-generation (`truncated:max_tokens`, bare-agent 0.27.0/
312
337
  before which it laundered into a clean finish, F25); in both cases no verdict exists and the
313
338
  failed round's spend is only partly known (F6). `cap-halt` is drafting spend consuming the whole job budget before a
314
339
  valid config existed. Both are decision-ready escalations with a terminal `job-end`:
315
- the spine never dangles. A text-mode job invoked without `opts.target` is a `job-red`
340
+ the spine never dangles. **Every `job-end` carries the money, on every path**:
341
+ `{ outcome, spentUsd, spendComplete }` (plus `step`/`cause`/`detail` where the outcome has
342
+ them). `spentUsd` is the accumulated sum of PRICED rounds ONLY — never an estimate from
343
+ token counts or averages (cap-not-estimate) — and it is stated even on the pre-token reds,
344
+ where the honest figure is a real `0`. `spendComplete` says whether that figure is EXACT:
345
+ `false` means one or more rounds came back unpriced (F6), so `spentUsd` is a FLOOR
346
+ ("at least $X", true total unknowable) and must not be read as a total. Both fields are
347
+ present on all outcomes, so a consumer never branches on field presence and never has to
348
+ launder a missing `spentUsd` into `$0`. A text-mode job invoked without `opts.target` is a `job-red`
316
349
  before ANY provider call (and `interpret` itself throws a TypeError for direct
317
350
  callers) — reds-before-tokens applies to the call, not just the spec.
318
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/interpret.js CHANGED
@@ -12,12 +12,14 @@
12
12
 
13
13
  import { createRequire } from 'node:module';
14
14
  import { writeFileSync, readFileSync } from 'node:fs';
15
+ import { createHash } from 'node:crypto';
15
16
  import { join, resolve, sep } from 'node:path';
16
17
  import { homedir } from 'node:os';
17
18
  import { Gate, redact } from 'bareguard';
18
19
  import { LiteCtx, compress } from 'litectx';
19
20
  import { validateConfig, diffPaths, globToPrefix, SECRET_PATTERNS } from './validate.js';
20
21
  import { ralph } from './ralph.js';
22
+ import { createRoot } from './root.js';
21
23
 
22
24
  /** @typedef {Error & {category?: string, lib?: string}} CategorizedError the failure map's carrier: ralph relays by `category`, and by `lib` when the thrower knows which library owns the failure (the ledger prefers it over prose) */
23
25
 
@@ -213,11 +215,18 @@ const toolAction = (name, args, workdir) => {
213
215
  * @param {'text'|'tools'} [opts.mode] middle mode (2b): 'text' (default) writes the ONE
214
216
  * target from the response artifact; 'tools' gives the worker Gate-governed file
215
217
  * tools — SPEC-side territory (the step declares it; the config cannot express it)
218
+ * @param {boolean} [opts.layerRoot=false] Layer R (the within-run ratchet, src/root.js):
219
+ * shell-assembled fixation detection over the run's own books, injecting an
220
+ * escalating summary→verbatim note when consecutive attempts rewrite the same
221
+ * files without moving the reds. Inert when the worker is not stuck (RSI §3.3).
222
+ * Defaults OFF (decided 2026-07-21): fixation is extinct on every current job
223
+ * (F41), so ON is unproven — the field read defers to Layer 2. `true` is the
224
+ * ON/experimental arm; never a worker-visible knob
216
225
  * @param {string[]} [opts.tools] the spec's tool grant (subset of read|grep|write,
217
226
  * job-v1 validated); defaults to the full menu in tool mode
218
227
  * @returns {Promise<'green'|'escalated'|'config-red'>}
219
228
  */
220
- export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor, closeTimeoutMs, mode = 'text', tools, closeState, closeExpect, closeJudged, closeGapKeep }) {
229
+ export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor, closeTimeoutMs, mode = 'text', tools, closeState, closeExpect, closeJudged, closeGapKeep, layerRoot = false }) {
221
230
  // Reds-before-tokens: text mode writes ONE artifact — a missing target is a
222
231
  // caller bug that must be loud NOW, not a TypeError after a paid worker call
223
232
  // that ralph would misfile as interpreter-red (the gate skips an absent path,
@@ -279,7 +288,17 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
279
288
  : {}),
280
289
  },
281
290
  budget: { maxCostUsd: config.gate.budgetUsd },
282
- // text mode is ~1-2 rounds per attempt; tool mode is N rounds (read→write→…)
291
+ // text mode is ~1-2 rounds per attempt; tool mode is N rounds (read→write→…).
292
+ // maxTurns ticks on every gate.RECORD (bareguard limits.js:88), and this
293
+ // number means "LLM rounds" ONLY because the sole record path is the LLM one
294
+ // (onLlmResult → gate.record{type:'llm'}); tool calls take gate.CHECK, which
295
+ // does not tick. That invariant is load-bearing: wiring wireGate's own
296
+ // onToolResult into gate.record would silently start ticking maxTurns on
297
+ // tools and halve the LLM budget (the F37 lower-silent-ceiling class). It is
298
+ // pinned by the "every record is type:llm" guard test — do not wire tool
299
+ // records without switching this bound (bareguard offers no llm-only counter;
300
+ // it has maxTurns=all and maxToolRounds=tools-only, neither of which is
301
+ // llm-only once tools also record). Cap semantics are arbiter territory.
283
302
  limits: { maxTurns: TURNS_PER_ATTEMPT * (capRuns + 1) },
284
303
  audit: { path: join(workdir, 'gate-audit.jsonl') },
285
304
  humanChannel: async () => ({ decision: 'terminate' }), // no human mid-run: a tripped cap terminates → decision-ready escalation
@@ -294,6 +313,13 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
294
313
  // exactly as it did before F32 — attribution can only ADD a recovery path, never
295
314
  // swallow an instrument stop.
296
315
  const auditPath = join(workdir, 'gate-audit.jsonl');
316
+ // NOTE (Finding 4, deferred): this re-reads and re-parses the whole audit each
317
+ // call. That is O(1) reads when Layer R is OFF (called only at the rare crashed
318
+ // verdict), but O(attempts) when Layer R is ON, and the audit can accumulate
319
+ // across steps/runs. An incremental byte-cursor reader would make it O(total)
320
+ // — deferred as cleanup-tier (only bites the OFF-by-default arm) until it can
321
+ // carry its own multi-path-accumulation tests; the simple full-parse is kept
322
+ // because it is obviously correct.
297
323
  const workerWrites = () => {
298
324
  try {
299
325
  const paths = new Set();
@@ -308,7 +334,135 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
308
334
  return [...paths];
309
335
  } catch { return []; }
310
336
  };
311
- const { policy, onLlmResult } = wireGate(gate, mode === 'tools' ? { actionTranslator: (/** @type {string} */ n, /** @type {any} */ a) => toolAction(n, a, workdir) } : {});
337
+ // Layer R — the root (within-run ratchet, design record 2026-07-19). Shell-
338
+ // assembled from the arbiter's own books: per-attempt write-sets come from
339
+ // the SAME workerWrites audit read (allow-decision only, so a denied write is
340
+ // never counted), red-sets from the close's kept-failure lines. The verbatim
341
+ // stage needs the content the audit deliberately does not keep (bytes-only),
342
+ // so it is teed below at the one seam that sees it — memory-only, scrubbed,
343
+ // never the spine.
344
+ const root = layerRoot ? createRoot({ gapKeep: closeGapKeep, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }), writesInformative: mode === 'tools' }) : null;
345
+ // The tee rides the translator and resolves paths exactly as toolAction does,
346
+ // so tee keys match audit paths byte-for-byte. The translator fires BEFORE the
347
+ // gate decides (wireGate calls translate() inside its policy, then awaits
348
+ // gate.check), so it can only STAGE: the audit-path filter downstream is not
349
+ // enough on its own, because the tee is last-write-wins PER PATH and a path the
350
+ // audit legitimately lists as allowed can end the attempt holding the content
351
+ // of a LATER write the gate rejected — the verbatim note would then swear that
352
+ // code landed when it is not in the file (Finding 6, 2026-07-20). Settlement
353
+ // is split across the two seams that actually know (Finding 7): the gate
354
+ // verdict can only DISCARD (a deny means the tool never runs), and the
355
+ // post-execution seam supplies the outcome an allow cannot — see `policy` and
356
+ // `onToolOutcome` below.
357
+ /** @param {string} n @param {any} a */
358
+ const teeingTranslator = (n, a) => {
359
+ // Classify ONCE and decide by the action TYPE, not a hardcoded name list — a
360
+ // third enumeration of the write verbs (workerWrites already filters on
361
+ // action.type write|edit) would let a future granted write-class verb enter
362
+ // the gate audit but silently bypass the tee (the recurring blind-instrument
363
+ // class). Reuse act.path so the tee key matches the audit path byte-for-byte
364
+ // (both from this same resolution). Content is read off the RAW args and never
365
+ // put on the action object — the gate audit stays bytes-only (the secrets law:
366
+ // what an append-only log captures, it captures forever).
367
+ const act = toolAction(n, a, workdir);
368
+ if (root && (act.type === 'write' || act.type === 'edit')) {
369
+ // act.path is always present on the write/edit branch (cast: toolAction's
370
+ // object literals widen `type` to string, so tsc cannot narrow the union)
371
+ const path = /** @type {string} */ (act.path);
372
+ const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
373
+ root.stageWrite(path, content);
374
+ // Finding 7 — the OUTCOME probe. The gate's allow is an INTENT record
375
+ // written before the tool runs; shell_edit returns an anchor miss as a
376
+ // refusal RESULT (bare-agent tools/shell.js:190) and a byte-cap overflow
377
+ // as a throw (:204), both leaving the file untouched with the allow
378
+ // already on the audit. Snapshot the file hash HERE (pre-execution) so the
379
+ // post-execution seam can settle the note against ground truth. Carry the
380
+ // action TYPE: for the file-unchanged case, a write and an edit answer
381
+ // "did it land?" differently (Finding 2). Raw content, never the scrubbed
382
+ // copy — the comparison is against real bytes; transient, never on a
383
+ // prompt, the spine, or the audit.
384
+ pendingProbe = { path, content, type: act.type, before: fileHash(path) };
385
+ }
386
+ return act;
387
+ };
388
+ /**
389
+ * Content hash of a file, or null when it does not exist / cannot be read —
390
+ * null is the honest unknown here, and a missing file BEFORE a write is the
391
+ * normal new-file case (it compares unequal to the hash after, which is
392
+ * exactly right: the write landed).
393
+ * @param {string} p
394
+ */
395
+ const fileHash = (p) => {
396
+ try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
397
+ };
398
+ /** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
399
+ let pendingProbe = null;
400
+ const { policy: gatePolicy, onLlmResult } = wireGate(gate, mode === 'tools' ? { actionTranslator: teeingTranslator } : {});
401
+ // Settle the stage on the verdict. wireGate's policy returns true on allow,
402
+ // a string on deny, and THROWS HaltError on halt — and bare-agent processes
403
+ // tool calls strictly sequentially (`for (const tc of result.toolCalls) { …
404
+ // await this.policy(…) }`, loop.js), so at most one stage is ever in flight.
405
+ // The catch discards and RE-THROWS: cap-halt routing reads that throw, and a
406
+ // swallowed halt would launder a tripped budget into a plain deny. A non-write
407
+ // action stages nothing, so both settlements are no-ops for it.
408
+ const policy = root && mode === 'tools'
409
+ ? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
410
+ try {
411
+ const verdict = await gatePolicy(n, a, c);
412
+ // A DENY is settled here and only here: the tool never executes, so the
413
+ // outcome seam below never fires for it. An ALLOW is deliberately NOT
414
+ // settled — the bytes have not been written yet, and settling on the
415
+ // verdict is precisely the Finding 7 defect (validated 2026-07-20: three
416
+ // allowed edits, zero bytes changed, and the note swore they landed).
417
+ if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
418
+ return verdict;
419
+ } catch (e) {
420
+ root.discardWrite();
421
+ pendingProbe = null;
422
+ throw e;
423
+ }
424
+ }
425
+ : gatePolicy;
426
+ /**
427
+ * Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
428
+ * after every `tool.execute` (bare-agent loop.js:986), success or error, and
429
+ * bare-agent processes tool calls strictly sequentially, so exactly one probe
430
+ * is ever in flight. A probe that never reaches here (the BA-12 spin-guard
431
+ * early return, or a HaltError out of a tool body) leaves the stage pending
432
+ * and unsettled — correct, because in both cases nothing landed, and the
433
+ * attempt boundary drops it.
434
+ *
435
+ * NOT wired: `wireGate`'s own `onToolResult`, which would start feeding
436
+ * `gate.record` per-tool records. That changes what `limits.maxTurns` /
437
+ * `maxToolRounds` count and therefore when a run halts (the guarded
438
+ * llm-only-turns invariant at the Gate config above) — cap semantics are
439
+ * arbiter territory, so it is deliberately not wired here.
440
+ * @param {{result?: any}} [info] the tool's return value (bare-agent loop.js:988)
441
+ */
442
+ const onToolOutcome = async (info) => {
443
+ if (!root || !pendingProbe) return;
444
+ const { path: p, content, type, before } = pendingProbe;
445
+ pendingProbe = null;
446
+ // ONE read serves both the after-hash and the content check (Finding 5: the
447
+ // old path read the file twice). null = unreadable/absent.
448
+ let after = null;
449
+ try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
450
+ let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
451
+ if (!landed && after !== null) {
452
+ // The bytes did not change — but the action may still have left `content`
453
+ // in the file, and write vs edit answer that differently (Finding 2):
454
+ // - write: a whole-file write always writes; identical bytes means the
455
+ // content genuinely IS the file (exact equality, never a substring —
456
+ // substring false-positived when newText appeared elsewhere).
457
+ // - edit: a no-byte-change edit is EITHER an idempotent apply (newText===
458
+ // oldText, "edited …: 1 replacement") OR a missed anchor ("shell_edit:
459
+ // … no change made") — indistinguishable on disk, so the tool RESULT is
460
+ // the only honest signal. An anchor miss is NOT landed.
461
+ if (type === 'write') landed = after === content;
462
+ else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
463
+ }
464
+ root.settleWrite(landed);
465
+ };
312
466
  // Money is metered as it is SPENT — per ROUND, not per attempt (F12). A
313
467
  // multi-round attempt that halts (or throws) never returns, so its rounds
314
468
  // never reach `worker-result`: the real run bought $1.4375 of tokens inside a
@@ -428,6 +582,7 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
428
582
  system: mode === 'tools' ? PERSONA_TOOLS + (usesEdit ? EDIT_STRATEGY : '') + (usesCtx ? RETRIEVAL_STRATEGY : '') : PERSONA,
429
583
  policy,
430
584
  onLlmResult: meteredOnLlmResult,
585
+ onToolResult: onToolOutcome,
431
586
  });
432
587
 
433
588
  /** @param {string} slot */
@@ -576,6 +731,11 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
576
731
  const middle = async (iteration, gap) => {
577
732
  roundIteration = iteration; // stamps every round of this attempt (F12)
578
733
  roundsThisAttempt = 0; // the bound is PER ATTEMPT — a fresh conversation, a fresh budget (F20)
734
+ // Layer R: finalize the previous attempt from the books and ask the ratchet.
735
+ // Inert (null) unless consecutive attempts rewrote the same files without
736
+ // moving the reds; the event carries stage/paths/counts, NEVER content.
737
+ const rootInj = root ? root.observe({ iteration, gap, writes: workerWrites() }) : null;
738
+ if (rootInj) emit('root-injected', rootInj.event);
579
739
  if (gap) gaps.push(gap);
580
740
  if (revisor && !revised && gaps.length >= STALL_REDS) {
581
741
  emit('stall-detected', { iteration, consecutiveReds: gaps.length });
@@ -632,6 +792,9 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
632
792
  // attempt and describing the tree are different claims; this is the second.
633
793
  !gap && closeState && `The close is currently failing. This is its output on the tree as it stands (not an attempt of yours):\n${closeState}`,
634
794
  context.text && `Possibly relevant notes:\n${context.text}`, gap && `Previous attempt failed the test suite:\n${gap}`,
795
+ // Layer R: the ratchet note rides directly after the gap it qualifies —
796
+ // same channel discipline as the gap itself (bounded, trims announced)
797
+ rootInj && rootInj.note,
635
798
  artifactNote && `Previous attempt never reached the close:\n${artifactNote}`,
636
799
  // F20: a bounded attempt must SAY it was bounded. Otherwise the worker reads its own
637
800
  // truncated transcript as a finished one, learns nothing from being cut off, and spends
@@ -682,6 +845,7 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
682
845
  throw err;
683
846
  }
684
847
  writeFileSync(t, code);
848
+ root?.noteWrite(t, code); // Layer R tee, text mode: same seam role as the translator's
685
849
  emit('artifact-written', { iteration, path: t });
686
850
  };
687
851
 
package/src/ralph.js CHANGED
@@ -143,6 +143,12 @@ const GAP_HEAD = 400, GAP_TAIL = 1500;
143
143
  // matches it says so with an explicit marker — silent truncation is the disease
144
144
  // this whole fix cures (F28), never a cure that truncates silently in turn.
145
145
  const GAP_KEEP_MAX_LINES = 50, GAP_KEEP_MAX_BYTES = 8192;
146
+ // The exact phrase the trim announcement carries when kept failures are dropped
147
+ // by the cap above. Exported so Layer R's detector (src/root.js) keys off the
148
+ // SAME string the shell emits — a magic string on the reading side would drift
149
+ // silently the day this wording changes (the blind-instrument class). Reference
150
+ // only: the shell's behavior is unchanged, the label below is byte-identical.
151
+ export const GAP_KEEP_TRIM_MARKER = 'more elided by the';
146
152
 
147
153
  /**
148
154
  * Bound an over-long close stream to a head sample + elided middle + tail. When
@@ -173,7 +179,7 @@ function boundGap(s, keepPattern) {
173
179
  bytes += line.length + 1;
174
180
  }
175
181
  if (!matched.length) return `${base}\n${tail}`; // pattern matched nothing — behave as the plain bound
176
- const label = `kept failures matching /${keepPattern}/ (${matched.length}${trimmed ? `, ${trimmed} more elided by the ${GAP_KEEP_MAX_LINES}-line/${GAP_KEEP_MAX_BYTES}-byte cap` : ''})`;
182
+ const label = `kept failures matching /${keepPattern}/ (${matched.length}${trimmed ? `, ${trimmed} ${GAP_KEEP_TRIM_MARKER} ${GAP_KEEP_MAX_LINES}-line/${GAP_KEEP_MAX_BYTES}-byte cap` : ''})`;
177
183
  return `${base}\n── ${label} ──\n${matched.join('\n')}\n── end kept failures ──\n${tail}`;
178
184
  }
179
185
 
package/src/root.js ADDED
@@ -0,0 +1,305 @@
1
+ // Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
2
+ // docs/plans/2026-07-19-layer-r-design.md). One state that survives attempts
3
+ // inside ONE run so a never-green run stops repeating itself (F21). Role is
4
+ // CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
5
+ // the semantic converter is Layer 2's job, not this module's.
6
+ //
7
+ // Ownership: the SHELL authors the root, mechanically, from the arbiter's own
8
+ // books — per-attempt write-sets (the gate audit, via the F32 workerWrites
9
+ // seam) and the close's kept-failure lines. The worker authors nothing and
10
+ // gains no verb; delivery is injection into the next attempt's prompt, so the
11
+ // adaptlearn-F6 write-only-decoy class is structurally excluded.
12
+ //
13
+ // Engagement is FIXATION-GATED (RSI-LEARNINGS §3.3: the rejected-edit buffer's
14
+ // lift is a fixation phenomenon and an honest null on a strong model when not
15
+ // stuck — pay only when stuck). Content ESCALATES: first detection injects a
16
+ // capped mechanical summary; a second consecutive detection injects the
17
+ // worker's own prior failed edit content verbatim ("write something
18
+ // STRUCTURALLY DIFFERENT" — the BA-14 mechanism, 50%→100% under fixation).
19
+ // Both stages fire on DIFFERENT attempts, so every stuck episode yields a
20
+ // per-stage read for the acceptance battery.
21
+ //
22
+ // Scope: within-run scratch, discarded at run end — NEVER across-run memory
23
+ // (only a green mints that, and this module touches no inheritance channel).
24
+ // Spine events carry counts and paths only, never content: verbatim edit text
25
+ // is worker-authored and the spine is append-only (what a log captures, it
26
+ // captures forever).
27
+
28
+ // The shell's kept-failures trim announcement (ralph): its presence in a gap means
29
+ // failures were dropped past the gap cap, so the visible kept lines are an
30
+ // UNRELIABLE red-set for comparison. Imported (never re-spelled) so the detector
31
+ // keys off the exact string the shell emits — a magic string here would drift.
32
+ import { GAP_KEEP_TRIM_MARKER } from './ralph.js';
33
+
34
+ /**
35
+ * Strip a trailing duration stamp from a kept-failure line. Real `node --test`
36
+ * spec-reporter lines are NEVER byte-stable across runs (`✖ multiplies
37
+ * (1.738ms)` vs `(1.637ms)` — POC 2026-07-19), so naive equality reads every
38
+ * attempt as "reds moved" and the detector never fires. COMPARISON-ONLY: the
39
+ * gap the worker sees is never rewritten.
40
+ * @param {string} line
41
+ */
42
+ export const normalizeRedLine = (line) => line.replace(/ \(\d+(?:\.\d+)?m?s\)\s*$/, '');
43
+
44
+ // Bounds — every trim is ANNOUNCED in the note (F28: silent truncation is the
45
+ // disease; a cure that truncates silently is the same disease).
46
+ /**
47
+ * A teed write: the content the worker emitted, plus whether it actually reached
48
+ * the file. `landed` is the OUTCOME axis (Finding 7) — the gate's allow is an
49
+ * intent record written before the tool runs, and a shell_edit whose anchor
50
+ * misses returns a refusal RESULT with the file untouched (bare-agent
51
+ * tools/shell.js:190). A note that says "these are your changes" is a claim
52
+ * about the file's contents and must be true, so it reads this flag, never the
53
+ * audit. The DETECTOR deliberately does not: see `observe`.
54
+ * @typedef {{content: string, trimmed: boolean, landed: boolean}} TeeEntry
55
+ */
56
+
57
+ const TEE_CAP_BYTES = 2000; // per-file content retained for the verbatim stage
58
+ const VERBATIM_MAX_FILES = 3; // files surfaced per verbatim injection
59
+ const SUMMARY_MAX_PATHS = 10; // paths named in the summary line
60
+
61
+ /**
62
+ * The root collector. One per run; state dies with the run.
63
+ *
64
+ * Call order (wired by the interpreter's middle):
65
+ * observe({iteration: 1, writes}) → null (nothing to compare)
66
+ * stageWrite(path, content) → stage, before the tool runs
67
+ * settleWrite(landed) | discardWrite() → the outcome, after it ran
68
+ * noteWrite(path, content) → stage+settle, text mode (which
69
+ * writes before it reports)
70
+ * observe({iteration: N, gap, writes}) → null | {stage, note, event}
71
+ * `writes` is the CUMULATIVE allow-decision write/edit path list from the gate
72
+ * audit (the F32 instrument); the per-attempt set is the delta between calls —
73
+ * so a gate-DENIED write is never counted (the tool never ran at all).
74
+ *
75
+ * Finding 7 — the two axes, deliberately NOT merged. An allow-decision records
76
+ * what the worker REACHED FOR, which is the right instrument for "is it
77
+ * repeating itself"; whether those bytes reached the file is a different
78
+ * question, and only the note (a claim about file contents) needs it. Collapsing
79
+ * them either way is a bug: settling the note on the allow makes it lie, and
80
+ * running the detector off the file makes it blind to a worker re-firing an
81
+ * identical edit whose anchor never matches — measured null on every attempt.
82
+ *
83
+ * @param {{gapKeep?: string, redact?: (s: string) => string, writesInformative?: boolean}} [opts]
84
+ * the signed spec's kept-failures pattern (the red-set source; without it the
85
+ * detector degrades to write-overlap alone and the event says so), the shell's
86
+ * secret scrubber (verbatim content is scrubbed before it rides a prompt), and
87
+ * `writesInformative` (Finding 3, default true): whether the per-attempt
88
+ * write-set carries information. In TOOL mode the worker CHOOSES which files to
89
+ * write, so re-writing the same file is a real repetition signal; in TEXT mode
90
+ * the one target is rewritten every attempt by construction, so write-overlap
91
+ * is constant-true and carries none — with no red-set to compare, the detector
92
+ * cannot tell repetition from progress and must stay inert (it would otherwise
93
+ * steer a progressing worker off course, violating "inert when not stuck").
94
+ */
95
+ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = true } = {}) {
96
+ const keepRe = gapKeep ? new RegExp(gapKeep, 'm') : null;
97
+ /**
98
+ * The comparable red-set, or null when it CANNOT be trusted — one clean UNKNOWN
99
+ * covering three cases, so the detector honestly degrades to write-overlap alone
100
+ * rather than making the strong "reds unchanged" claim off a blind instrument:
101
+ * - no gapKeep pattern at all (no red-set instrument);
102
+ * - a TRIMMED window (ralph dropped failures past the cap): failures beyond the
103
+ * window can move while the visible lines stay identical (Finding 5);
104
+ * - zero matches on a judged gap: an empty kept-set is UNKNOWN, not "zero
105
+ * failures" — [] === [] would false-read as "reds unchanged" (Finding 2).
106
+ * @param {string} gap @returns {string[]|null} normalized kept lines, or null when untrustworthy
107
+ */
108
+ const keptSet = (gap) => {
109
+ if (!keepRe || gap.includes(GAP_KEEP_TRIM_MARKER)) return null;
110
+ const lines = [...new Set(gap.split('\n').filter((l) => keepRe.test(l)).map(normalizeRedLine))].sort();
111
+ return lines.length ? lines : null;
112
+ };
113
+
114
+ /** @type {{writes: string[], reds: string[]|null, tee: Map<string, TeeEntry>}[]} finalized attempts */
115
+ const attempts = [];
116
+ /** @type {Set<string>} cumulative audit write-set at the last observe */
117
+ let prevCumulative = new Set();
118
+ /** @type {Map<string, TeeEntry>} tee for the attempt currently running */
119
+ let tee = new Map();
120
+ /** @type {{path: string, entry: TeeEntry}|null} the write awaiting its outcome */
121
+ let pending = null;
122
+ let streak = 0;
123
+
124
+ /**
125
+ * Capture a write/edit's content as PENDING — staged against the gate verdict
126
+ * that has not been rendered yet (Finding 6). Scrub at CAPTURE, BEFORE
127
+ * truncating (repo doctrine: secrets scrub at the source, and staging IS the
128
+ * source — a staged secret must be scrubbed even if the write is later
129
+ * discarded). Every SECRET_PATTERN is a prefix+min-length shape (sk-…{16,}),
130
+ * so a secret straddling the cap would lose the bytes that make it match and a
131
+ * partial token would ride the note unredacted — redact the FULL content
132
+ * first, then cap the already-scrubbed string.
133
+ * @param {string} path @param {string} content
134
+ */
135
+ const stageWrite = (path, content) => {
136
+ const scrubbed = redact(content);
137
+ const trimmed = scrubbed.length > TEE_CAP_BYTES;
138
+ pending = { path, entry: { content: scrubbed.slice(0, TEE_CAP_BYTES), trimmed, landed: false } };
139
+ };
140
+
141
+ return {
142
+ stageWrite,
143
+
144
+ /**
145
+ * The staged write RAN — record what it actually did (Finding 7). Retained
146
+ * either way: an attempted-but-unapplied edit is still the worker reaching
147
+ * for the same file, which is what the detector measures, and dropping it
148
+ * here would blind the ratchet to the purest fixation there is (a worker
149
+ * re-firing an identical edit whose anchor never matches — measured null on
150
+ * every attempt under a tree-diff detector, 2026-07-20). Only the NOTE's
151
+ * wording turns on `landed`. Last write per path wins (the tree's final
152
+ * state for that attempt is what failed the close).
153
+ * @param {boolean} landed whether the content is now in the file
154
+ */
155
+ settleWrite(landed) {
156
+ if (pending) tee.set(pending.path, { ...pending.entry, landed });
157
+ pending = null;
158
+ },
159
+
160
+ /**
161
+ * The staged write was DENIED or HALTED: it is not in the file, so it must
162
+ * never be surfaced as "your own change that landed". Drops the stage and
163
+ * leaves whatever DID land for that path untouched.
164
+ */
165
+ discardWrite() { pending = null; },
166
+
167
+ /**
168
+ * Stage and commit in one call — the post-allow write site (text mode
169
+ * gate-checks and writes before it calls this, so the verdict is already in).
170
+ * @param {string} path @param {string} content
171
+ */
172
+ noteWrite(path, content) {
173
+ stageWrite(path, content);
174
+ if (pending) tee.set(pending.path, { ...pending.entry, landed: true });
175
+ pending = null;
176
+ },
177
+
178
+ /**
179
+ * Called at the START of attempt `iteration`, with the PREVIOUS attempt's
180
+ * gap. Finalizes that attempt from the books, runs the detector, and
181
+ * returns the injection for the attempt about to run — or null (inert).
182
+ * @param {{iteration: number, gap?: string, writes: string[]}} o
183
+ * @returns {null | {stage: 'summary'|'verbatim', note: string, event: object}}
184
+ */
185
+ observe({ iteration, gap, writes }) {
186
+ const cumulative = new Set(writes);
187
+ if (!gap) { // first attempt: nothing judged yet, nothing to compare
188
+ prevCumulative = cumulative;
189
+ tee = new Map();
190
+ pending = null;
191
+ return null;
192
+ }
193
+ // finalize the attempt that just failed: its writes are the audit delta
194
+ const delta = [...cumulative].filter((p) => !prevCumulative.has(p));
195
+ // an edit to a file already written in an earlier attempt is not in the
196
+ // delta — the tee saw it, and a teed landed path IS this attempt's work
197
+ for (const p of tee.keys()) if (cumulative.has(p) && !delta.includes(p)) delta.push(p);
198
+ // `gap` is guaranteed truthy here (the `if (!gap) return` guard above handled
199
+ // the empty case); reds === null comes only from keptSet's own UNKNOWN logic.
200
+ attempts.push({ writes: delta, reds: keptSet(gap), tee });
201
+ // The detector only ever compares the last two finalized attempts, so older
202
+ // ones (and their teed content) are dead state — keep just the two (Finding 7).
203
+ if (attempts.length > 2) attempts.shift();
204
+ prevCumulative = cumulative;
205
+ tee = new Map();
206
+ // a stage whose verdict never arrived (the attempt ended mid-flight) is not
207
+ // a landed write and must not survive into the next attempt's tee
208
+ pending = null;
209
+
210
+ if (attempts.length < 2) return null;
211
+ const [prev, last] = attempts.slice(-2);
212
+ const overlap = last.writes.filter((p) => prev.writes.includes(p));
213
+ // Both UNKNOWN → no trustworthy red-set either side: write-overlap alone
214
+ // decides (writes-only mode, named below). One known and one UNKNOWN → the
215
+ // red-set is not provably the same, so it counts as MOVEMENT, never a fire
216
+ // (a real red-set followed by a crash/empty gap is new information, not
217
+ // repetition). Both known → the honest set comparison.
218
+ const redsSame = last.reds === null && prev.reds === null ? null
219
+ : last.reds === null || prev.reds === null ? false
220
+ : JSON.stringify(last.reds) === JSON.stringify(prev.reds);
221
+ // Finding 3: when writes carry no information (text mode — the one target is
222
+ // rewritten every attempt regardless of progress), write-overlap alone can
223
+ // never establish fixation; only a KNOWN-unmoved red-set can. So the
224
+ // degraded writes-only path (redsSame === null) is available to fire only
225
+ // when writes ARE informative (tool mode). With informative writes the prior
226
+ // behavior stands: overlap + reds-not-known-to-have-moved.
227
+ const redsGate = writesInformative ? redsSame !== false : redsSame === true;
228
+ const fixated = overlap.length > 0 && redsGate;
229
+ if (!fixated) { streak = 0; return null; }
230
+ streak += 1;
231
+
232
+ const stage = streak === 1 ? 'summary' : 'verbatim';
233
+ const mode = redsSame === null ? 'writes-only' : 'reds+writes';
234
+ const shownPaths = overlap.slice(0, SUMMARY_MAX_PATHS);
235
+ const morePaths = overlap.length - shownPaths.length;
236
+ const event = {
237
+ iteration, stage, mode, streak,
238
+ paths: shownPaths, ...(morePaths > 0 ? { pathsElided: morePaths } : {}),
239
+ redSetSize: last.reds ? last.reds.length : null,
240
+ };
241
+
242
+ // Finding 7: what the worker REACHED FOR fired the detector; what it
243
+ // ACHIEVED decides the wording. Only paths this attempt actually teed can
244
+ // answer the outcome question — a path known solely from the audit is
245
+ // unattributable, so it falls back to the neutral (landed) phrasing rather
246
+ // than guessing.
247
+ const attempted = overlap.filter((p) => last.tee.has(p));
248
+ const noneApplied = attempted.length > 0
249
+ && attempted.every((p) => !(/** @type {TeeEntry} */ (last.tee.get(p)).landed));
250
+
251
+ if (stage === 'summary') {
252
+ const redsClause = mode === 'reds+writes'
253
+ ? 'and the set of failing tests did not change at all'
254
+ : 'with no visible progress';
255
+ // The unapplied case is the MECHANICAL genre (F38: gaps that name a wall
256
+ // convert; semantic ones stall) — naming the missed anchor is strictly
257
+ // more actionable than "form a different hypothesis", because the
258
+ // hypothesis may have been right and only the anchor wrong.
259
+ const body = noneApplied
260
+ ? `Your last two attempts both tried to change ${shownPaths.join(', ')}`
261
+ + `${morePaths > 0 ? ` (and ${morePaths} more)` : ''} and NEITHER EDIT APPLIED — `
262
+ + 'the text you anchored on was not found in the file, so nothing changed. '
263
+ + 'Re-read the file and quote the exact text (whitespace and indentation included) before editing again.'
264
+ : `Your last two attempts both rewrote ${shownPaths.join(', ')}`
265
+ + `${morePaths > 0 ? ` (and ${morePaths} more)` : ''} ${redsClause}. `
266
+ + 'Do NOT retry the same change — form a DIFFERENT hypothesis before your next edit.';
267
+ return { stage, event, note: `RATCHET: you are repeating yourself. ${body}` };
268
+ }
269
+ // verbatim: surface the worker's own last content for the repeated paths —
270
+ // scrubbed, capped, every trim announced. Grouped by OUTCOME, because the
271
+ // two groups need opposite advice: content that landed and failed must not
272
+ // be written again, while content that never applied may have been the
273
+ // right change aimed at the wrong anchor — telling that worker to "write
274
+ // something structurally different" would steer it away from a correct fix.
275
+ const files = attempted.slice(0, VERBATIM_MAX_FILES);
276
+ const moreFiles = attempted.length - files.length;
277
+ /** @param {string} p */
278
+ const block = (p) => {
279
+ const t = /** @type {TeeEntry} */ (last.tee.get(p));
280
+ // t.content was already scrubbed by redact() in stageWrite before storage
281
+ // (Finding 6): re-redacting here is redundant work over already-masked text.
282
+ return `--- ${p}${t.trimmed ? ` (truncated to first ${TEE_CAP_BYTES} chars)` : ''} ---\n${t.content}`;
283
+ };
284
+ const landedFiles = files.filter((p) => /** @type {TeeEntry} */ (last.tee.get(p)).landed);
285
+ const unappliedFiles = files.filter((p) => !(/** @type {TeeEntry} */ (last.tee.get(p)).landed));
286
+ const sections = [];
287
+ if (landedFiles.length) {
288
+ sections.push('These are your OWN previous changes — they landed, and they did NOT fix the failing set:\n'
289
+ + landedFiles.map(block).join('\n')
290
+ + '\nDo not write these again. Write something STRUCTURALLY DIFFERENT, or change a different file.');
291
+ }
292
+ if (unappliedFiles.length) {
293
+ sections.push('This is text you tried to write, and it did NOT apply — the anchor was not found, '
294
+ + 'so the file is UNCHANGED and none of this is in it:\n'
295
+ + unappliedFiles.map(block).join('\n')
296
+ + '\nRe-read the file and copy the exact text you mean to replace, character for character, before editing again.');
297
+ }
298
+ return {
299
+ stage, event,
300
+ note: `RATCHET: you are STILL repeating yourself. ${sections.join('\n\n')}`
301
+ + (moreFiles > 0 ? `\n(and ${moreFiles} more repeated file${moreFiles > 1 ? 's' : ''}, elided)` : ''),
302
+ };
303
+ },
304
+ };
305
+ }
package/src/run.js CHANGED
@@ -183,6 +183,12 @@ async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
183
183
  * boundary — the Gate still caps within a step, but an unpriced result cannot
184
184
  * be summed, so the run stops rather than counting it $0 (F6).
185
185
  *
186
+ * EVERY terminal `job-end` states the money: `spentUsd` (the sum of PRICED
187
+ * rounds only — never an estimate; a real 0 on the pre-token reds) plus
188
+ * `spendComplete`, false when any round came back unpriced and the figure is
189
+ * therefore a FLOOR, not the total. Both are always present, so a consumer
190
+ * never branches on field presence nor launders a blank into $0.
191
+ *
186
192
  * @param {object|string} rawSpec the job spec (job-v1), text or parsed
187
193
  * @param {object} opts
188
194
  * @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
@@ -194,21 +200,41 @@ async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
194
200
  * @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
195
201
  * @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
196
202
  * @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
197
- * (shell-owned seam, same doctrine as the provider binding)
203
+ * @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
204
+ * territory, threaded to every predicate step's interpreter. Defaults OFF
205
+ * (decided 2026-07-21): fixation is extinct on every current job (F41), so
206
+ * ON has never won its own A/B — the acceptance read defers to Layer 2 (or
207
+ * a manufactured-fixation probe). `true` is the ON/experimental arm; the
208
+ * default is the OFF arm. (Shell-owned seam, same doctrine as the provider
209
+ * binding.)
198
210
  * @returns {Promise<string>} outcome: 'green' | 'escalated' | 'unapproved-spec' |
199
211
  * 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
200
212
  * 'cap-halt' | 'close-unsupported' | `step-red:<id>`
201
213
  */
202
- export async function runJob(rawSpec, { approvals, workdir, target, provider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec }) {
214
+ export async function runJob(rawSpec, { approvals, workdir, target, provider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec, layerRoot = false }) {
215
+ // 0. the ledger's counters, declared FIRST so that every job-end — including
216
+ // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
217
+ // not a zero: a consumer reads `undefined` and either crashes or launders it
218
+ // into $0 (F12's class, at the terminal record instead of mid-attempt).
219
+ let spentUsd = 0;
220
+ let unpriced = false;
221
+ // The money on the terminal record, and whether the money is EXACT. `spentUsd`
222
+ // is the accumulated sum of PRICED rounds ONLY — never an estimate derived
223
+ // from tokens or averages (cap-not-estimate). When any round came back
224
+ // unpriced (F6) that sum is a FLOOR, not the total, and `spendComplete: false`
225
+ // says so machine-readably instead of dressing a floor up as exact. Emitted on
226
+ // EVERY job-end (true when everything was priced) so no consumer ever has to
227
+ // branch on field presence.
228
+ const spend = () => ({ spentUsd, spendComplete: !unpriced });
203
229
  // 1. human-signs-always — before ANY provider call (N1 decision #1)
204
230
  if (!checkApproval(rawSpec, approvals)) {
205
- emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version' });
231
+ emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
206
232
  return 'unapproved-spec';
207
233
  }
208
234
  const jv = validateJob(rawSpec, { shellCapUsd });
209
235
  if (!jv.ok) {
210
236
  for (const r of jv.reds) emit('job-red', r);
211
- emit('job-end', { outcome: 'job-red' });
237
+ emit('job-end', { outcome: 'job-red', ...spend() });
212
238
  return 'job-red';
213
239
  }
214
240
  const job = /** @type {any} */ (jv.job);
@@ -218,7 +244,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
218
244
  if ((typeof target !== 'string' || !target)
219
245
  && job.steps.some((/** @type {any} */ s) => s.close.type === 'predicate' && (s.mode ?? 'text') === 'text')) {
220
246
  emit('job-red', { code: 'missing-required', path: 'opts.target', detail: 'text-mode steps write ONE artifact — pass opts.target (reds-before-tokens)' });
221
- emit('job-end', { outcome: 'job-red' });
247
+ emit('job-end', { outcome: 'job-red', ...spend() });
222
248
  return 'job-red';
223
249
  }
224
250
  emit('job-start', { job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd, steps: job.steps.map((/** @type {any} */ s) => s.id) });
@@ -228,14 +254,12 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
228
254
  emit('primitive-smoke', smoke);
229
255
  if (!smoke.ok) {
230
256
  emit('escalation', { category: 'smoke-red', decisionReady: true, decision: `The ${smoke.primitive} primitive failed its known-answer check — no run verdict is trustworthy on a degraded primitive.`, options: ['fix the primitive/store', 'abandon the run'], detail: smoke.detail });
231
- emit('job-end', { outcome: 'smoke-red' });
257
+ emit('job-end', { outcome: 'smoke-red', ...spend() });
232
258
  return 'smoke-red';
233
259
  }
234
260
 
235
261
  // 3. the ONE ledger. Unpriced is never free (F6): a null cost can't be
236
262
  // summed, so it flags a stop instead of accumulating $0.
237
- let spentUsd = 0;
238
- let unpriced = false;
239
263
  /** @param {number|null|undefined} c */
240
264
  const account = (c) => { if (typeof c === 'number' && Number.isFinite(c)) spentUsd += c; else unpriced = true; };
241
265
  /** @type {(type: string, data?: object) => object} */
@@ -256,7 +280,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
256
280
  };
257
281
  const pricingRed = () => {
258
282
  emit('escalation', { category: 'pricing-red', decisionReady: true, decision: 'A provider result carried no priced cost — the hard cap cannot govern spend it cannot see (unpriced is never free, F6).', options: ['bind a priced provider/model', 'abandon the run'], spentUsd });
259
- emit('job-end', { outcome: 'pricing-red' });
283
+ emit('job-end', { outcome: 'pricing-red', ...spend() });
260
284
  return 'pricing-red';
261
285
  };
262
286
 
@@ -294,7 +318,11 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
294
318
  };
295
319
  const providerRed = () => {
296
320
  emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The provider path threw before a result existed — spend for the failed call is unknown (F6), and no drafting verdict exists.', options: ['fix the provider binding', 'retry the run', 'abandon the run'], detail: transportRed, spentUsd });
297
- emit('job-end', { outcome: 'provider-red' });
321
+ // A transport THROW never returned a usage figure, so the floor is NOT the total
322
+ // (F6): spendComplete must be false — spend()'s `!unpriced` only knows about priced
323
+ // rounds that came back unpriced, not a call that never returned at all. Leaving it
324
+ // true would have the job-end contradict the escalation's own "spend … is unknown".
325
+ emit('job-end', { outcome: 'provider-red', ...spend(), spendComplete: false });
298
326
  return 'provider-red';
299
327
  };
300
328
  const capNow = () => Math.min(shellCapUsd, job.budgetUsd - spentUsd);
@@ -345,7 +373,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
345
373
  // not on trial. Mirrors the worker path (interpret.js) and the human's options: the round
346
374
  // was metered, so this is a fault with a known cost, not a spend-unknown transport throw.
347
375
  emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The drafting round was truncated at the output cap (BA-6) — the API cut the config off mid-generation, so no valid config exists. Not the drafter\'s competence (never a config-red), and not silently retried.', options: ['raise the drafting cap and rerun', 'retry the run', 'abandon the run'], detail: draftTruncated, spentUsd });
348
- emit('job-end', { outcome: 'provider-red' });
376
+ emit('job-end', { outcome: 'provider-red', ...spend() });
349
377
  return 'provider-red';
350
378
  }
351
379
  if (unpriced) return pricingRed();
@@ -355,11 +383,11 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
355
383
  // the honest stop is a cap story, never a config-red blaming the drafter
356
384
  emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', spentUsd, budgetUsd: job.budgetUsd });
357
385
  emit('escalation', { category: 'cap-halt', decisionReady: true, decision: `Drafting spend ($${spentUsd.toFixed(4)}) consumed the job budget ($${job.budgetUsd}) before a valid config existed.`, options: ['raise the job budget and rerun', 'abandon the run'], spentUsd });
358
- emit('job-end', { outcome: 'cap-halt' });
386
+ emit('job-end', { outcome: 'cap-halt', ...spend() });
359
387
  return 'cap-halt';
360
388
  }
361
389
  for (const r of cv.reds) emit('config-red', r);
362
- emit('job-end', { outcome: 'config-red' });
390
+ emit('job-end', { outcome: 'config-red', ...spend() });
363
391
  return 'config-red';
364
392
  }
365
393
  config = cv.config;
@@ -402,14 +430,14 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
402
430
  // baseline — loud, and never allowed to un-open a real PR
403
431
  if (pr.strandedOn) emit('workdir-red', { step: step.id, branch: pr.strandedOn, detail: 'the run could not return the checkout to its starting branch — the next run would build on this one' });
404
432
  emit('escalation', { category: 'hitl-close', decisionReady: true, step: step.id, prompt: step.close.prompt, spentUsd, decision: step.close.prompt, options: ['approve', 'reject'], pr: { url: pr.url, branch, error: pr.red?.detail ?? null } });
405
- emit('job-end', { outcome: 'escalated', step: step.id, spentUsd });
433
+ emit('job-end', { outcome: 'escalated', step: step.id, ...spend() });
406
434
  return 'escalated'; // by design: the human acts outside the run, forever
407
435
  }
408
436
  if (step.close.type !== 'predicate') {
409
437
  // honest refusal: gold/rubric EXECUTION lands with the verdict classes
410
438
  // (N4); a fake verdict here would poison every contrast downstream
411
439
  emit('escalation', { category: 'close-unsupported', decisionReady: true, step: step.id, decision: `Step "${step.id}" has a ${step.close.type} close — N2 executes predicate and hitl closes only.`, options: ['restate the close as a predicate', 'wait for the verdict-classes rung'] });
412
- emit('job-end', { outcome: 'close-unsupported', step: step.id });
440
+ emit('job-end', { outcome: 'close-unsupported', step: step.id, ...spend() });
413
441
  return 'close-unsupported';
414
442
  }
415
443
  emit('step-start', { step: step.id, remainingUsd: job.budgetUsd - spentUsd, spentUsd, mode: step.mode ?? 'text' });
@@ -436,7 +464,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
436
464
  const fault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
437
465
  if (fault) {
438
466
  emit('escalation', { category: fault.category, decisionReady: true, step: step.id, decision: fault.decision, options: fault.options, detail: pre.detail, spentUsd });
439
- emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category, spentUsd });
467
+ emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category, ...spend() });
440
468
  return `step-red:${step.id}`;
441
469
  }
442
470
  // The precheck's gap goes to the worker as what it IS: the close's output on
@@ -458,21 +486,22 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
458
486
  // arbiter territory (PRD v1.11; F28 for gapKeep).
459
487
  mode: step.mode ?? 'text', tools: step.tools,
460
488
  closeExpect: step.close.expect, closeJudged: step.close.judged, closeGapKeep: step.close.gapKeep,
489
+ layerRoot,
461
490
  });
462
491
  } catch (e) {
463
492
  // ralph belts throws INSIDE the loop; this belts the interpreter's own
464
493
  // setup (gate.init, store ctor) — the spine must terminate, never dangle
465
494
  emit('escalation', { category: 'interpreter-red', decisionReady: true, step: step.id, decision: 'The interpreter broke outside the loop — no harness verdict is trustworthy until it is fixed.', options: ['fix the interpreter/run directory', 'abandon the run'], detail: String(/** @type {Error} */ (e)?.message ?? e), spentUsd });
466
- emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red', spentUsd });
495
+ emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red', ...spend() });
467
496
  return `step-red:${step.id}`;
468
497
  }
469
498
  emit('step-end', { step: step.id, outcome, spentUsd });
470
499
  if (unpriced) return pricingRed();
471
500
  if (outcome !== 'green') {
472
- emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome, spentUsd });
501
+ emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome, ...spend() });
473
502
  return `step-red:${step.id}`;
474
503
  }
475
504
  }
476
- emit('job-end', { outcome: 'green', spentUsd });
505
+ emit('job-end', { outcome: 'green', ...spend() });
477
506
  return 'green';
478
507
  }
@@ -42,11 +42,18 @@
42
42
  * @param {'text'|'tools'} [opts.mode] middle mode (2b): 'text' (default) writes the ONE
43
43
  * target from the response artifact; 'tools' gives the worker Gate-governed file
44
44
  * tools — SPEC-side territory (the step declares it; the config cannot express it)
45
+ * @param {boolean} [opts.layerRoot=false] Layer R (the within-run ratchet, src/root.js):
46
+ * shell-assembled fixation detection over the run's own books, injecting an
47
+ * escalating summary→verbatim note when consecutive attempts rewrite the same
48
+ * files without moving the reds. Inert when the worker is not stuck (RSI §3.3).
49
+ * Defaults OFF (decided 2026-07-21): fixation is extinct on every current job
50
+ * (F41), so ON is unproven — the field read defers to Layer 2. `true` is the
51
+ * ON/experimental arm; never a worker-visible knob
45
52
  * @param {string[]} [opts.tools] the spec's tool grant (subset of read|grep|write,
46
53
  * job-v1 validated); defaults to the full menu in tool mode
47
54
  * @returns {Promise<'green'|'escalated'|'config-red'>}
48
55
  */
49
- export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor, closeTimeoutMs, mode, tools, closeState, closeExpect, closeJudged, closeGapKeep }: {
56
+ export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor, closeTimeoutMs, mode, tools, closeState, closeExpect, closeJudged, closeGapKeep, layerRoot }: {
50
57
  task: string;
51
58
  target?: string | undefined;
52
59
  close: string[];
@@ -75,6 +82,7 @@ export function interpret(configRaw: object | string, { task, target, close, wor
75
82
  }>) | undefined;
76
83
  closeState?: string | undefined;
77
84
  mode?: "text" | "tools" | undefined;
85
+ layerRoot?: boolean | undefined;
78
86
  tools?: string[] | undefined;
79
87
  }): Promise<"green" | "escalated" | "config-red">;
80
88
  /** @typedef {Error & {category?: string, lib?: string}} CategorizedError the failure map's carrier: ralph relays by `category`, and by `lib` when the thrower knows which library owns the failure (the ledger prefers it over prose) */
package/types/ralph.d.ts CHANGED
@@ -134,6 +134,7 @@ export function ralph({ middle, close, capRuns, emit, redact, closeTimeoutMs, cw
134
134
  gapKeep?: string | undefined;
135
135
  workerWrites?: (() => string[]) | undefined;
136
136
  }): Promise<"green" | "escalated">;
137
+ export const GAP_KEEP_TRIM_MARKER: "more elided by the";
137
138
  /**
138
139
  * The forbidden zone, named (PRD v1.11). Each row is an outcome in which the
139
140
  * close rendered NO JUDGMENT — so it is not a verdict, it never feeds a gap
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The root collector. One per run; state dies with the run.
3
+ *
4
+ * Call order (wired by the interpreter's middle):
5
+ * observe({iteration: 1, writes}) → null (nothing to compare)
6
+ * stageWrite(path, content) → stage, before the tool runs
7
+ * settleWrite(landed) | discardWrite() → the outcome, after it ran
8
+ * noteWrite(path, content) → stage+settle, text mode (which
9
+ * writes before it reports)
10
+ * observe({iteration: N, gap, writes}) → null | {stage, note, event}
11
+ * `writes` is the CUMULATIVE allow-decision write/edit path list from the gate
12
+ * audit (the F32 instrument); the per-attempt set is the delta between calls —
13
+ * so a gate-DENIED write is never counted (the tool never ran at all).
14
+ *
15
+ * Finding 7 — the two axes, deliberately NOT merged. An allow-decision records
16
+ * what the worker REACHED FOR, which is the right instrument for "is it
17
+ * repeating itself"; whether those bytes reached the file is a different
18
+ * question, and only the note (a claim about file contents) needs it. Collapsing
19
+ * them either way is a bug: settling the note on the allow makes it lie, and
20
+ * running the detector off the file makes it blind to a worker re-firing an
21
+ * identical edit whose anchor never matches — measured null on every attempt.
22
+ *
23
+ * @param {{gapKeep?: string, redact?: (s: string) => string, writesInformative?: boolean}} [opts]
24
+ * the signed spec's kept-failures pattern (the red-set source; without it the
25
+ * detector degrades to write-overlap alone and the event says so), the shell's
26
+ * secret scrubber (verbatim content is scrubbed before it rides a prompt), and
27
+ * `writesInformative` (Finding 3, default true): whether the per-attempt
28
+ * write-set carries information. In TOOL mode the worker CHOOSES which files to
29
+ * write, so re-writing the same file is a real repetition signal; in TEXT mode
30
+ * the one target is rewritten every attempt by construction, so write-overlap
31
+ * is constant-true and carries none — with no red-set to compare, the detector
32
+ * cannot tell repetition from progress and must stay inert (it would otherwise
33
+ * steer a progressing worker off course, violating "inert when not stuck").
34
+ */
35
+ export function createRoot({ gapKeep, redact, writesInformative }?: {
36
+ gapKeep?: string;
37
+ redact?: (s: string) => string;
38
+ writesInformative?: boolean;
39
+ }): {
40
+ stageWrite: (path: string, content: string) => void;
41
+ /**
42
+ * The staged write RAN — record what it actually did (Finding 7). Retained
43
+ * either way: an attempted-but-unapplied edit is still the worker reaching
44
+ * for the same file, which is what the detector measures, and dropping it
45
+ * here would blind the ratchet to the purest fixation there is (a worker
46
+ * re-firing an identical edit whose anchor never matches — measured null on
47
+ * every attempt under a tree-diff detector, 2026-07-20). Only the NOTE's
48
+ * wording turns on `landed`. Last write per path wins (the tree's final
49
+ * state for that attempt is what failed the close).
50
+ * @param {boolean} landed whether the content is now in the file
51
+ */
52
+ settleWrite(landed: boolean): void;
53
+ /**
54
+ * The staged write was DENIED or HALTED: it is not in the file, so it must
55
+ * never be surfaced as "your own change that landed". Drops the stage and
56
+ * leaves whatever DID land for that path untouched.
57
+ */
58
+ discardWrite(): void;
59
+ /**
60
+ * Stage and commit in one call — the post-allow write site (text mode
61
+ * gate-checks and writes before it calls this, so the verdict is already in).
62
+ * @param {string} path @param {string} content
63
+ */
64
+ noteWrite(path: string, content: string): void;
65
+ /**
66
+ * Called at the START of attempt `iteration`, with the PREVIOUS attempt's
67
+ * gap. Finalizes that attempt from the books, runs the detector, and
68
+ * returns the injection for the attempt about to run — or null (inert).
69
+ * @param {{iteration: number, gap?: string, writes: string[]}} o
70
+ * @returns {null | {stage: 'summary'|'verbatim', note: string, event: object}}
71
+ */
72
+ observe({ iteration, gap, writes }: {
73
+ iteration: number;
74
+ gap?: string;
75
+ writes: string[];
76
+ }): null | {
77
+ stage: "summary" | "verbatim";
78
+ note: string;
79
+ event: object;
80
+ };
81
+ };
82
+ export function normalizeRedLine(line: string): string;
83
+ /**
84
+ * A teed write: the content the worker emitted, plus whether it actually reached
85
+ * the file. `landed` is the OUTCOME axis (Finding 7) — the gate's allow is an
86
+ * intent record written before the tool runs, and a shell_edit whose anchor
87
+ * misses returns a refusal RESULT with the file untouched (bare-agent
88
+ * tools/shell.js:190). A note that says "these are your changes" is a claim
89
+ * about the file's contents and must be true, so it reads this flag, never the
90
+ * audit. The DETECTOR deliberately does not: see `observe`.
91
+ */
92
+ export type TeeEntry = {
93
+ content: string;
94
+ trimmed: boolean;
95
+ landed: boolean;
96
+ };
package/types/run.d.ts CHANGED
@@ -17,6 +17,12 @@
17
17
  * boundary — the Gate still caps within a step, but an unpriced result cannot
18
18
  * be summed, so the run stops rather than counting it $0 (F6).
19
19
  *
20
+ * EVERY terminal `job-end` states the money: `spentUsd` (the sum of PRICED
21
+ * rounds only — never an estimate; a real 0 on the pre-token reds) plus
22
+ * `spendComplete`, false when any round came back unpriced and the figure is
23
+ * therefore a FLOOR, not the total. Both are always present, so a consumer
24
+ * never branches on field presence nor launders a blank into $0.
25
+ *
20
26
  * @param {object|string} rawSpec the job spec (job-v1), text or parsed
21
27
  * @param {object} opts
22
28
  * @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
@@ -28,12 +34,18 @@
28
34
  * @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
29
35
  * @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
30
36
  * @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
31
- * (shell-owned seam, same doctrine as the provider binding)
37
+ * @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
38
+ * territory, threaded to every predicate step's interpreter. Defaults OFF
39
+ * (decided 2026-07-21): fixation is extinct on every current job (F41), so
40
+ * ON has never won its own A/B — the acceptance read defers to Layer 2 (or
41
+ * a manufactured-fixation probe). `true` is the ON/experimental arm; the
42
+ * default is the OFF arm. (Shell-owned seam, same doctrine as the provider
43
+ * binding.)
32
44
  * @returns {Promise<string>} outcome: 'green' | 'escalated' | 'unapproved-spec' |
33
45
  * 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
34
46
  * 'cap-halt' | 'close-unsupported' | `step-red:<id>`
35
47
  */
36
- export function runJob(rawSpec: object | string, { approvals, workdir, target, provider, emit, capRuns, shellCapUsd, closeTimeoutMs, execCmd }: {
48
+ export function runJob(rawSpec: object | string, { approvals, workdir, target, provider, emit, capRuns, shellCapUsd, closeTimeoutMs, execCmd, layerRoot }: {
37
49
  approvals: unknown;
38
50
  workdir: string;
39
51
  target?: string | undefined;
@@ -43,6 +55,7 @@ export function runJob(rawSpec: object | string, { approvals, workdir, target, p
43
55
  shellCapUsd?: number | undefined;
44
56
  closeTimeoutMs?: number | undefined;
45
57
  execCmd?: ExecCmd | undefined;
58
+ layerRoot?: boolean | undefined;
46
59
  }): Promise<string>;
47
60
  export type Red = {
48
61
  code: string;