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/CHANGELOG.md +194 -0
- package/README.md +12 -5
- package/bareloop.context.md +167 -16
- package/package.json +2 -2
- package/src/exits.js +163 -0
- package/src/index.js +7 -1
- package/src/interpret.js +192 -25
- package/src/job.js +231 -88
- package/src/plan.js +237 -0
- package/src/planrun.js +677 -0
- package/src/ralph.js +25 -7
- package/src/root.js +305 -0
- package/src/run.js +88 -22
- package/types/exits.d.ts +51 -0
- package/types/index.d.ts +4 -1
- package/types/interpret.d.ts +107 -1
- package/types/job.d.ts +12 -0
- package/types/plan.d.ts +41 -0
- package/types/planrun.d.ts +49 -0
- package/types/ralph.d.ts +21 -3
- package/types/root.d.ts +96 -0
- package/types/run.d.ts +26 -3
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
|
-
|
|
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
|
|