bareloop 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/exits.js ADDED
@@ -0,0 +1,163 @@
1
+ // Layer 2: the exit evaluator — the SHELL's own fixed code for the closed
2
+ // exit menu (PRD v1.12 §3). An exit is declarative data; nothing here ever
3
+ // executes agent-authored text. Doctrine wired in, not promised:
4
+ //
5
+ // - exits verify FORM, not truth: progress gates only; the operator's close
6
+ // stays the one arbiter (checks decide nothing, mint nothing).
7
+ // - `tree-changed` reads OUTCOME — real bytes vs a pre-step snapshot taken
8
+ // by `snapshotScope` — never git status (always shows the planted bug,
9
+ // F45) and never gate intent (the F43 identical-refire trap: an allowed
10
+ // write that lands the same bytes is NOT a change).
11
+ // - a failing exit's detail is a MECHANICAL gap (a named wall, a count) —
12
+ // the genre that converts (F38/F46); details carry names and counts only,
13
+ // never file bodies (they ride the spine, which is append-only forever).
14
+ // - `check-passes` delegates through the `runCheck` seam the runner wires
15
+ // to the full runClose machinery; the evaluator itself never spawns. An
16
+ // unwired or throwing check fails CLOSED (the broken-close class: an
17
+ // unrunnable check is a stop, never a silent pass).
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { readFile, readdir } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import { globToPrefix } from './validate.js';
23
+
24
+ /** The check gap is ALREADY bounded upstream by runClose's boundGap (head +
25
+ * gapKeep block ≤ 8192 + tail ≈ 10KB, with the failing-test NAMES preserved).
26
+ * The evaluator must NOT re-truncate it: a 400-char clamp deletes exactly those
27
+ * `not ok`/`FAILED` names — the mechanical gap the F46 conversion mechanism
28
+ * feeds the worker (F28 reintroduced). This cap is a defensive backstop above
29
+ * boundGap's own envelope: a normal bounded gap passes through intact, and only
30
+ * a pathological seam that returned an unbounded gap would ever be trimmed. */
31
+ const CHECK_GAP_MAX = 12000;
32
+
33
+ /** @typedef {{ type: string, pass: boolean, detail?: string, fault?: string }} ExitResult
34
+ * `fault` present means the INSTRUMENT could not judge (unwired snapshot/seam,
35
+ * a check in runClose's forbidden zone) — it carries the runClose verdict name
36
+ * ('failed'|'timed-out'|'killed'|'crashed') so the micro-loop escalates through
37
+ * the same CLOSE_FAULTS taxonomy instead of feeding a fake gap to the worker.
38
+ * A plain pass:false with no fault is honest worker feedback. */
39
+
40
+ /**
41
+ * Snapshot the file state under a scope prefix — the "before" side of
42
+ * `tree-changed`. Content hashes, not mtimes: only real bytes count as
43
+ * outcome. A scope directory that does not exist yet snapshots empty (the
44
+ * step creating it IS the change). Never throws on a missing dir.
45
+ * @param {string} dir absolute workdir (the run directory)
46
+ * @param {string} scope a validated scope glob (e.g. `tests/**`)
47
+ * @returns {Promise<Map<string, string>>} relative path → sha256
48
+ */
49
+ export async function snapshotScope(dir, scope) {
50
+ const prefix = globToPrefix(scope);
51
+ /** @type {Map<string, string>} */
52
+ const snap = new Map();
53
+ /** @type {string[]} */
54
+ let entries;
55
+ try {
56
+ entries = /** @type {string[]} */ (await readdir(join(dir, prefix), { recursive: true, encoding: 'utf8' }));
57
+ } catch {
58
+ return snap; // missing scope dir = empty snapshot, never a throw
59
+ }
60
+ for (const rel of entries) {
61
+ const full = join(dir, prefix, rel);
62
+ let body;
63
+ try { body = await readFile(full); } catch { continue; } // directories and vanished files skip
64
+ snap.set(`${prefix}/${rel.replaceAll('\\', '/')}`, createHash('sha256').update(body).digest('hex'));
65
+ }
66
+ return snap;
67
+ }
68
+
69
+ /**
70
+ * Evaluate a step's exits — AND-only (decision 8): the step passes iff EVERY
71
+ * listed exit passes. All exits are evaluated (never short-circuit): the gap
72
+ * must name every wall, not the first one hit.
73
+ * @param {Array<Record<string, any>>} exits validated exit items
74
+ * @param {{ dir: string, snapshot?: Map<string, string>, runCheck?: (name: string) => Promise<{pass: boolean, gap?: string}> }} ctx
75
+ * `dir`: the run directory; `snapshot`: the pre-step `snapshotScope` result
76
+ * (required by tree-changed — absent fails closed); `runCheck`: the runner's
77
+ * runClose-backed seam (required by check-passes — absent fails closed).
78
+ * @returns {Promise<{ pass: boolean, results: ExitResult[] }>}
79
+ */
80
+ export async function evalExits(exits, { dir, snapshot, runCheck }) {
81
+ /** @type {ExitResult[]} */
82
+ const results = [];
83
+ for (const e of exits) {
84
+ results.push(await evalOne(e, { dir, snapshot, runCheck }));
85
+ }
86
+ return { pass: results.every((r) => r.pass), results };
87
+ }
88
+
89
+ /**
90
+ * @param {Record<string, any>} e
91
+ * @param {{ dir: string, snapshot?: Map<string, string>, runCheck?: (name: string) => Promise<{pass: boolean, gap?: string}> }} ctx
92
+ * @returns {Promise<ExitResult>}
93
+ */
94
+ async function evalOne(e, { dir, snapshot, runCheck }) {
95
+ if (e.type === 'artifact-written') {
96
+ let body;
97
+ try { body = await readFile(join(dir, e.path), 'utf8'); } catch {
98
+ return { type: e.type, pass: false, detail: `${e.path} was not written (does not exist)` };
99
+ }
100
+ if (body.length === 0) return { type: e.type, pass: false, detail: `${e.path} is empty — a zero-byte artifact is not progress` };
101
+ if (e.pattern !== undefined && !new RegExp(e.pattern, 'm').test(body)) {
102
+ return { type: e.type, pass: false, detail: `${e.path} exists but does not match the declared pattern ${e.pattern}` };
103
+ }
104
+ return { type: e.type, pass: true };
105
+ }
106
+
107
+ if (e.type === 'tree-changed') {
108
+ if (!(snapshot instanceof Map)) {
109
+ // fail CLOSED as a FAULT: without the before-side this instrument is
110
+ // blind — it must never read "changed" (F45) and never feed the miss to
111
+ // the worker as if it were inaction
112
+ return { type: e.type, pass: false, fault: 'failed', detail: `no pre-step snapshot for ${e.scope} — the tree-changed instrument is unwired (instrument fault, not worker inaction)` };
113
+ }
114
+ const now = await snapshotScope(dir, e.scope);
115
+ // deletions count only WITHIN this exit's own scope: the step's `snapshot`
116
+ // is merged across every tree-changed scope, so an unscoped keys() walk
117
+ // would read a SIBLING scope's files (absent from this re-scan) as deletions
118
+ // and pass an unchanged scope by borrowing the other's change.
119
+ const prefix = globToPrefix(e.scope);
120
+ const underScope = (/** @type {string} */ p) => p === prefix || p.startsWith(`${prefix}/`);
121
+ let changed = 0;
122
+ for (const [path, hash] of now) if (snapshot.get(path) !== hash) changed++;
123
+ for (const path of snapshot.keys()) if (underScope(path) && !now.has(path)) changed++; // deletions are real changes
124
+ if (changed === 0) {
125
+ return { type: e.type, pass: false, detail: `0 files changed under ${e.scope} — the tree is byte-identical to the step start (an identical re-write is not a change)` };
126
+ }
127
+ return { type: e.type, pass: true, detail: `${changed} file(s) changed under ${e.scope}` };
128
+ }
129
+
130
+ if (e.type === 'json-valid') {
131
+ let body;
132
+ try { body = await readFile(join(dir, e.path), 'utf8'); } catch {
133
+ return { type: e.type, pass: false, detail: `${e.path} was not written (does not exist)` };
134
+ }
135
+ try { JSON.parse(body); } catch (err) {
136
+ return { type: e.type, pass: false, detail: `${e.path} is not valid JSON: ${String(/** @type {Error} */ (err).message).slice(0, 200)}` };
137
+ }
138
+ return { type: e.type, pass: true };
139
+ }
140
+
141
+ if (e.type === 'check-passes') {
142
+ if (typeof runCheck !== 'function') {
143
+ return { type: e.type, pass: false, fault: 'failed', detail: `check "${e.name}" cannot run — runCheck is not wired (instrument fault: an unrunnable check is a stop, never a silent pass)` };
144
+ }
145
+ try {
146
+ const r = await runCheck(e.name);
147
+ if (r.pass) return { type: e.type, pass: true };
148
+ // the seam's fault (a runClose forbidden-zone verdict) rides through by
149
+ // NAME: 'crashed' keeps its F32 routing (worker-crash after writes —
150
+ // the F46 broken-test mechanism), 'timed-out' keeps its own decision
151
+ if (typeof (/** @type {any} */ (r).fault) === 'string') {
152
+ return { type: e.type, pass: false, fault: /** @type {any} */ (r).fault, detail: `check "${e.name}": ${(r.gap ?? '').slice(0, CHECK_GAP_MAX)}` };
153
+ }
154
+ return { type: e.type, pass: false, detail: `check "${e.name}" red: ${(r.gap ?? '').slice(0, CHECK_GAP_MAX)}` };
155
+ } catch (err) {
156
+ return { type: e.type, pass: false, fault: 'failed', detail: `check "${e.name}" crashed the seam: ${String(/** @type {Error} */ (err).message).slice(0, 200)}` };
157
+ }
158
+ }
159
+
160
+ // belt — the validator already reds unknown types; the evaluator must not
161
+ // silently pass what it cannot judge
162
+ return { type: String(e.type), pass: false, fault: 'failed', detail: `unknown exit type "${e.type}" — the evaluator cannot judge it (fail closed)` };
163
+ }
package/src/index.js CHANGED
@@ -16,7 +16,13 @@ export { makeSpine } from './spine.js';
16
16
  // exports map admits only ".", so a deep import cannot reach them).
17
17
  export { ralph, runClose, CLOSE_FAULTS } from './ralph.js';
18
18
  export { validateConfig, diffPaths, globToPrefix, scanSecrets, LOOP_SHAPES, SLOTS, VERBS } from './validate.js';
19
- export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, STEP_MODES, TOOL_MENU, LOCKED_TOOLS } from './job.js';
19
+ export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, STEP_MODES, TOOL_MENU, LOCKED_TOOLS, VERDICT_TYPES, LOCKED_VERDICTS } from './job.js';
20
+ // Layer 2: the plan-v1 validator — the agent-authored plan doc's gate; the
21
+ // two-doc split's third validator never happens (plan-v1 gates the PLAN, the
22
+ // job spec stays the arbiter's only home).
23
+ export { validatePlan, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from './plan.js';
24
+ export { snapshotScope, evalExits } from './exits.js';
25
+ export { runPlan } from './planrun.js';
20
26
  export { interpret, STALL_REDS } from './interpret.js';
21
27
  export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
22
28
  export { runJob } from './run.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
 
@@ -41,18 +43,38 @@ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete content
41
43
  // compound: that run's context grew 2k → 121k tokens and its last round cost $0.25.
42
44
  // Telling the worker it will be re-run with the close's verdict makes an early,
43
45
  // cheap, wrong attempt the rational move — which is exactly what the loop wants.
44
- const PERSONA_TOOLS = 'You are a senior engineer working in a repository through file tools. '
46
+ export const PERSONA_TOOLS = 'You are a senior engineer working in a repository through file tools. '
45
47
  + 'ALWAYS use absolute paths — relative paths resolve against the process, not the repository, and will be denied. '
46
48
  + 'You are ONE attempt inside an automated loop: when you finish, a test suite runs and, if it still fails, you are called again with its output. '
47
49
  + 'So do not try to be certain before acting. Read only what you need to form your best hypothesis, make the change with the write tool, and stop. '
48
50
  + 'A wrong cheap attempt is corrected by the next round; exhaustive reading is not — every file you read is re-sent on every later round and the run has a hard budget it can exhaust before you ever write. '
49
51
  + 'Make the required changes with the write tool, then reply with a short summary of what you changed. Never put file contents in your reply.';
50
52
 
53
+ // The retrieval verbs only pay if the worker reaches for them INSTEAD of paging a file.
54
+ // F18 measured the failure they exist to fix: the worker read one 117 KB file NINE times
55
+ // and dragged 1.37 MB of source through context to reach an 8-line function. The tool
56
+ // descriptions carry the mechanics; the persona has to carry the STRATEGY, or a model
57
+ // with a familiar `read` and an unfamiliar `recall` will simply keep reading.
58
+ // (Module-level + exported since Layer 2: the plan-step worker shares the SAME
59
+ // personas/strategies — two spellings of the loop contract would drift, F16.)
60
+ export const RETRIEVAL_STRATEGY = '\nYou also have a repository index. To read a function, do NOT read its whole file: '
61
+ + 'call ctx_recall with the function name to get a pointer, then ctx_get with that pointer to read that function alone '
62
+ + '(it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does). '
63
+ + 'Reserve shell_read for whole files that are genuinely small, and for files you cannot name a symbol in yet. '
64
+ + 'Search only finds what you can NAME: a failing test names the symptom, not the cause, so read the failing test first, '
65
+ + 'see which function it calls, and recall THAT.';
66
+ // BA-13: like retrieval, the edit verb only pays if the worker reaches for it INSTEAD
67
+ // of a whole-file rewrite. F31 measured the default: 4 of 5 big-file whole-writes
68
+ // broke the tree, and every one was a rewrite to change ~one line. The tool
69
+ // description carries the mechanics; the persona carries the strategy.
70
+ export const EDIT_STRATEGY = '\nPrefer the edit tool over whole-file writes: quote the EXACT text to change (it must match exactly once) and its replacement. '
71
+ + 'Rewriting a whole file to change one line is how trees get broken and budgets get burned — reserve the write tool for genuinely new files.';
72
+
51
73
  // ---- tool mode (2b): the spec-side grant menu mapped to the underlying tools ----
52
74
  // read/grep/write are bare-agent's shell tools; recall/get are litectx's retrieval
53
75
  // verbs (F19), composed from the SAME LiteCtx the memory hooks already use.
54
- const TOOL_BY_VERB = Object.freeze({ read: 'shell_read', grep: 'shell_grep', write: 'shell_write', edit: 'shell_edit', recall: 'ctx_recall', get: 'ctx_get' });
55
- const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get']);
76
+ export const TOOL_BY_VERB = Object.freeze({ read: 'shell_read', grep: 'shell_grep', write: 'shell_write', edit: 'shell_edit', recall: 'ctx_recall', get: 'ctx_get' });
77
+ export const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get']);
56
78
 
57
79
  /**
58
80
  * The retrieval pair (F19). `shell_read` cannot seek — it starts at byte zero — so a
@@ -68,7 +90,7 @@ const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get']);
68
90
  * bad range) looks exactly like one that worked, and the worker's fallback to a
69
91
  * whole-file read looks like a free choice instead of a forced one.
70
92
  */
71
- function createCtxTools(lc, workdir, emit) {
93
+ export function createCtxTools(lc, workdir, emit) {
72
94
  return [
73
95
  {
74
96
  name: 'ctx_recall',
@@ -146,7 +168,7 @@ function createCtxTools(lc, workdir, emit) {
146
168
  /** @param {string} p */
147
169
  const expandHome = (p) => (p === '~' || p.startsWith('~/')) ? join(homedir(), p.slice(1)) : p;
148
170
  /** @param {string} name @param {any} args @param {string} [workdir] */
149
- const toolAction = (name, args, workdir) => {
171
+ export const toolAction = (name, args, workdir) => {
150
172
  if (name === 'shell_write') return { type: 'write', path: resolve(expandHome(String(args?.path ?? ''))), args: { bytes: String(args?.content ?? '').length } };
151
173
  // shell_edit (BA-13) is judged as bareguard's own 'edit' action type — the SAME
152
174
  // fs.writeScope fence as write, decided upstream (bareguard FS_TYPES). `bytes` is
@@ -213,11 +235,18 @@ const toolAction = (name, args, workdir) => {
213
235
  * @param {'text'|'tools'} [opts.mode] middle mode (2b): 'text' (default) writes the ONE
214
236
  * target from the response artifact; 'tools' gives the worker Gate-governed file
215
237
  * tools — SPEC-side territory (the step declares it; the config cannot express it)
238
+ * @param {boolean} [opts.layerRoot=false] Layer R (the within-run ratchet, src/root.js):
239
+ * shell-assembled fixation detection over the run's own books, injecting an
240
+ * escalating summary→verbatim note when consecutive attempts rewrite the same
241
+ * files without moving the reds. Inert when the worker is not stuck (RSI §3.3).
242
+ * Defaults OFF (decided 2026-07-21): fixation is extinct on every current job
243
+ * (F41), so ON is unproven — the field read defers to Layer 2. `true` is the
244
+ * ON/experimental arm; never a worker-visible knob
216
245
  * @param {string[]} [opts.tools] the spec's tool grant (subset of read|grep|write,
217
246
  * job-v1 validated); defaults to the full menu in tool mode
218
247
  * @returns {Promise<'green'|'escalated'|'config-red'>}
219
248
  */
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 }) {
249
+ 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
250
  // Reds-before-tokens: text mode writes ONE artifact — a missing target is a
222
251
  // caller bug that must be loud NOW, not a TypeError after a paid worker call
223
252
  // that ralph would misfile as interpreter-red (the gate skips an absent path,
@@ -279,7 +308,17 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
279
308
  : {}),
280
309
  },
281
310
  budget: { maxCostUsd: config.gate.budgetUsd },
282
- // text mode is ~1-2 rounds per attempt; tool mode is N rounds (read→write→…)
311
+ // text mode is ~1-2 rounds per attempt; tool mode is N rounds (read→write→…).
312
+ // maxTurns ticks on every gate.RECORD (bareguard limits.js:88), and this
313
+ // number means "LLM rounds" ONLY because the sole record path is the LLM one
314
+ // (onLlmResult → gate.record{type:'llm'}); tool calls take gate.CHECK, which
315
+ // does not tick. That invariant is load-bearing: wiring wireGate's own
316
+ // onToolResult into gate.record would silently start ticking maxTurns on
317
+ // tools and halve the LLM budget (the F37 lower-silent-ceiling class). It is
318
+ // pinned by the "every record is type:llm" guard test — do not wire tool
319
+ // records without switching this bound (bareguard offers no llm-only counter;
320
+ // it has maxTurns=all and maxToolRounds=tools-only, neither of which is
321
+ // llm-only once tools also record). Cap semantics are arbiter territory.
283
322
  limits: { maxTurns: TURNS_PER_ATTEMPT * (capRuns + 1) },
284
323
  audit: { path: join(workdir, 'gate-audit.jsonl') },
285
324
  humanChannel: async () => ({ decision: 'terminate' }), // no human mid-run: a tripped cap terminates → decision-ready escalation
@@ -294,6 +333,13 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
294
333
  // exactly as it did before F32 — attribution can only ADD a recovery path, never
295
334
  // swallow an instrument stop.
296
335
  const auditPath = join(workdir, 'gate-audit.jsonl');
336
+ // NOTE (Finding 4, deferred): this re-reads and re-parses the whole audit each
337
+ // call. That is O(1) reads when Layer R is OFF (called only at the rare crashed
338
+ // verdict), but O(attempts) when Layer R is ON, and the audit can accumulate
339
+ // across steps/runs. An incremental byte-cursor reader would make it O(total)
340
+ // — deferred as cleanup-tier (only bites the OFF-by-default arm) until it can
341
+ // carry its own multi-path-accumulation tests; the simple full-parse is kept
342
+ // because it is obviously correct.
297
343
  const workerWrites = () => {
298
344
  try {
299
345
  const paths = new Set();
@@ -308,7 +354,135 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
308
354
  return [...paths];
309
355
  } catch { return []; }
310
356
  };
311
- const { policy, onLlmResult } = wireGate(gate, mode === 'tools' ? { actionTranslator: (/** @type {string} */ n, /** @type {any} */ a) => toolAction(n, a, workdir) } : {});
357
+ // Layer R — the root (within-run ratchet, design record 2026-07-19). Shell-
358
+ // assembled from the arbiter's own books: per-attempt write-sets come from
359
+ // the SAME workerWrites audit read (allow-decision only, so a denied write is
360
+ // never counted), red-sets from the close's kept-failure lines. The verbatim
361
+ // stage needs the content the audit deliberately does not keep (bytes-only),
362
+ // so it is teed below at the one seam that sees it — memory-only, scrubbed,
363
+ // never the spine.
364
+ const root = layerRoot ? createRoot({ gapKeep: closeGapKeep, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }), writesInformative: mode === 'tools' }) : null;
365
+ // The tee rides the translator and resolves paths exactly as toolAction does,
366
+ // so tee keys match audit paths byte-for-byte. The translator fires BEFORE the
367
+ // gate decides (wireGate calls translate() inside its policy, then awaits
368
+ // gate.check), so it can only STAGE: the audit-path filter downstream is not
369
+ // enough on its own, because the tee is last-write-wins PER PATH and a path the
370
+ // audit legitimately lists as allowed can end the attempt holding the content
371
+ // of a LATER write the gate rejected — the verbatim note would then swear that
372
+ // code landed when it is not in the file (Finding 6, 2026-07-20). Settlement
373
+ // is split across the two seams that actually know (Finding 7): the gate
374
+ // verdict can only DISCARD (a deny means the tool never runs), and the
375
+ // post-execution seam supplies the outcome an allow cannot — see `policy` and
376
+ // `onToolOutcome` below.
377
+ /** @param {string} n @param {any} a */
378
+ const teeingTranslator = (n, a) => {
379
+ // Classify ONCE and decide by the action TYPE, not a hardcoded name list — a
380
+ // third enumeration of the write verbs (workerWrites already filters on
381
+ // action.type write|edit) would let a future granted write-class verb enter
382
+ // the gate audit but silently bypass the tee (the recurring blind-instrument
383
+ // class). Reuse act.path so the tee key matches the audit path byte-for-byte
384
+ // (both from this same resolution). Content is read off the RAW args and never
385
+ // put on the action object — the gate audit stays bytes-only (the secrets law:
386
+ // what an append-only log captures, it captures forever).
387
+ const act = toolAction(n, a, workdir);
388
+ if (root && (act.type === 'write' || act.type === 'edit')) {
389
+ // act.path is always present on the write/edit branch (cast: toolAction's
390
+ // object literals widen `type` to string, so tsc cannot narrow the union)
391
+ const path = /** @type {string} */ (act.path);
392
+ const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
393
+ root.stageWrite(path, content);
394
+ // Finding 7 — the OUTCOME probe. The gate's allow is an INTENT record
395
+ // written before the tool runs; shell_edit returns an anchor miss as a
396
+ // refusal RESULT (bare-agent tools/shell.js:190) and a byte-cap overflow
397
+ // as a throw (:204), both leaving the file untouched with the allow
398
+ // already on the audit. Snapshot the file hash HERE (pre-execution) so the
399
+ // post-execution seam can settle the note against ground truth. Carry the
400
+ // action TYPE: for the file-unchanged case, a write and an edit answer
401
+ // "did it land?" differently (Finding 2). Raw content, never the scrubbed
402
+ // copy — the comparison is against real bytes; transient, never on a
403
+ // prompt, the spine, or the audit.
404
+ pendingProbe = { path, content, type: act.type, before: fileHash(path) };
405
+ }
406
+ return act;
407
+ };
408
+ /**
409
+ * Content hash of a file, or null when it does not exist / cannot be read —
410
+ * null is the honest unknown here, and a missing file BEFORE a write is the
411
+ * normal new-file case (it compares unequal to the hash after, which is
412
+ * exactly right: the write landed).
413
+ * @param {string} p
414
+ */
415
+ const fileHash = (p) => {
416
+ try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
417
+ };
418
+ /** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
419
+ let pendingProbe = null;
420
+ const { policy: gatePolicy, onLlmResult } = wireGate(gate, mode === 'tools' ? { actionTranslator: teeingTranslator } : {});
421
+ // Settle the stage on the verdict. wireGate's policy returns true on allow,
422
+ // a string on deny, and THROWS HaltError on halt — and bare-agent processes
423
+ // tool calls strictly sequentially (`for (const tc of result.toolCalls) { …
424
+ // await this.policy(…) }`, loop.js), so at most one stage is ever in flight.
425
+ // The catch discards and RE-THROWS: cap-halt routing reads that throw, and a
426
+ // swallowed halt would launder a tripped budget into a plain deny. A non-write
427
+ // action stages nothing, so both settlements are no-ops for it.
428
+ const policy = root && mode === 'tools'
429
+ ? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
430
+ try {
431
+ const verdict = await gatePolicy(n, a, c);
432
+ // A DENY is settled here and only here: the tool never executes, so the
433
+ // outcome seam below never fires for it. An ALLOW is deliberately NOT
434
+ // settled — the bytes have not been written yet, and settling on the
435
+ // verdict is precisely the Finding 7 defect (validated 2026-07-20: three
436
+ // allowed edits, zero bytes changed, and the note swore they landed).
437
+ if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
438
+ return verdict;
439
+ } catch (e) {
440
+ root.discardWrite();
441
+ pendingProbe = null;
442
+ throw e;
443
+ }
444
+ }
445
+ : gatePolicy;
446
+ /**
447
+ * Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
448
+ * after every `tool.execute` (bare-agent loop.js:986), success or error, and
449
+ * bare-agent processes tool calls strictly sequentially, so exactly one probe
450
+ * is ever in flight. A probe that never reaches here (the BA-12 spin-guard
451
+ * early return, or a HaltError out of a tool body) leaves the stage pending
452
+ * and unsettled — correct, because in both cases nothing landed, and the
453
+ * attempt boundary drops it.
454
+ *
455
+ * NOT wired: `wireGate`'s own `onToolResult`, which would start feeding
456
+ * `gate.record` per-tool records. That changes what `limits.maxTurns` /
457
+ * `maxToolRounds` count and therefore when a run halts (the guarded
458
+ * llm-only-turns invariant at the Gate config above) — cap semantics are
459
+ * arbiter territory, so it is deliberately not wired here.
460
+ * @param {{result?: any}} [info] the tool's return value (bare-agent loop.js:988)
461
+ */
462
+ const onToolOutcome = async (info) => {
463
+ if (!root || !pendingProbe) return;
464
+ const { path: p, content, type, before } = pendingProbe;
465
+ pendingProbe = null;
466
+ // ONE read serves both the after-hash and the content check (Finding 5: the
467
+ // old path read the file twice). null = unreadable/absent.
468
+ let after = null;
469
+ try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
470
+ let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
471
+ if (!landed && after !== null) {
472
+ // The bytes did not change — but the action may still have left `content`
473
+ // in the file, and write vs edit answer that differently (Finding 2):
474
+ // - write: a whole-file write always writes; identical bytes means the
475
+ // content genuinely IS the file (exact equality, never a substring —
476
+ // substring false-positived when newText appeared elsewhere).
477
+ // - edit: a no-byte-change edit is EITHER an idempotent apply (newText===
478
+ // oldText, "edited …: 1 replacement") OR a missed anchor ("shell_edit:
479
+ // … no change made") — indistinguishable on disk, so the tool RESULT is
480
+ // the only honest signal. An anchor miss is NOT landed.
481
+ if (type === 'write') landed = after === content;
482
+ else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
483
+ }
484
+ root.settleWrite(landed);
485
+ };
312
486
  // Money is metered as it is SPENT — per ROUND, not per attempt (F12). A
313
487
  // multi-round attempt that halts (or throws) never returns, so its rounds
314
488
  // never reach `worker-result`: the real run bought $1.4375 of tokens inside a
@@ -404,23 +578,6 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
404
578
  })()
405
579
  : [];
406
580
 
407
- // The retrieval verbs only pay if the worker reaches for them INSTEAD of paging a file.
408
- // F18 measured the failure they exist to fix: the worker read one 117 KB file NINE times
409
- // and dragged 1.37 MB of source through context to reach an 8-line function. The tool
410
- // descriptions carry the mechanics; the persona has to carry the STRATEGY, or a model
411
- // with a familiar `read` and an unfamiliar `recall` will simply keep reading.
412
- const RETRIEVAL_STRATEGY = '\nYou also have a repository index. To read a function, do NOT read its whole file: '
413
- + 'call ctx_recall with the function name to get a pointer, then ctx_get with that pointer to read that function alone '
414
- + '(it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does). '
415
- + 'Reserve shell_read for whole files that are genuinely small, and for files you cannot name a symbol in yet. '
416
- + 'Search only finds what you can NAME: a failing test names the symptom, not the cause, so read the failing test first, '
417
- + 'see which function it calls, and recall THAT.';
418
- // BA-13: like retrieval, the edit verb only pays if the worker reaches for it INSTEAD
419
- // of a whole-file rewrite. F31 measured the default: 4 of 5 big-file whole-writes
420
- // broke the tree, and every one was a rewrite to change ~one line. The tool
421
- // description carries the mechanics; the persona carries the strategy.
422
- const EDIT_STRATEGY = '\nPrefer the edit tool over whole-file writes: quote the EXACT text to change (it must match exactly once) and its replacement. '
423
- + 'Rewriting a whole file to change one line is how trees get broken and budgets get burned — reserve the write tool for genuinely new files.';
424
581
  const usesCtx = toolDefs.some((/** @type {{name: string}} */ t) => CTX_TOOLS.includes(t.name));
425
582
  const usesEdit = toolDefs.some((/** @type {{name: string}} */ t) => t.name === 'shell_edit');
426
583
  const loop = new Loop({
@@ -428,6 +585,7 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
428
585
  system: mode === 'tools' ? PERSONA_TOOLS + (usesEdit ? EDIT_STRATEGY : '') + (usesCtx ? RETRIEVAL_STRATEGY : '') : PERSONA,
429
586
  policy,
430
587
  onLlmResult: meteredOnLlmResult,
588
+ onToolResult: onToolOutcome,
431
589
  });
432
590
 
433
591
  /** @param {string} slot */
@@ -576,6 +734,11 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
576
734
  const middle = async (iteration, gap) => {
577
735
  roundIteration = iteration; // stamps every round of this attempt (F12)
578
736
  roundsThisAttempt = 0; // the bound is PER ATTEMPT — a fresh conversation, a fresh budget (F20)
737
+ // Layer R: finalize the previous attempt from the books and ask the ratchet.
738
+ // Inert (null) unless consecutive attempts rewrote the same files without
739
+ // moving the reds; the event carries stage/paths/counts, NEVER content.
740
+ const rootInj = root ? root.observe({ iteration, gap, writes: workerWrites() }) : null;
741
+ if (rootInj) emit('root-injected', rootInj.event);
579
742
  if (gap) gaps.push(gap);
580
743
  if (revisor && !revised && gaps.length >= STALL_REDS) {
581
744
  emit('stall-detected', { iteration, consecutiveReds: gaps.length });
@@ -632,6 +795,9 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
632
795
  // attempt and describing the tree are different claims; this is the second.
633
796
  !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
797
  context.text && `Possibly relevant notes:\n${context.text}`, gap && `Previous attempt failed the test suite:\n${gap}`,
798
+ // Layer R: the ratchet note rides directly after the gap it qualifies —
799
+ // same channel discipline as the gap itself (bounded, trims announced)
800
+ rootInj && rootInj.note,
635
801
  artifactNote && `Previous attempt never reached the close:\n${artifactNote}`,
636
802
  // F20: a bounded attempt must SAY it was bounded. Otherwise the worker reads its own
637
803
  // truncated transcript as a finished one, learns nothing from being cut off, and spends
@@ -682,6 +848,7 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
682
848
  throw err;
683
849
  }
684
850
  writeFileSync(t, code);
851
+ root?.noteWrite(t, code); // Layer R tee, text mode: same seam role as the translator's
685
852
  emit('artifact-written', { iteration, path: t });
686
853
  };
687
854