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/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}
|
|
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
|
|
|
@@ -229,7 +235,15 @@ export const CLOSE_FAULTS = Object.freeze({
|
|
|
229
235
|
*
|
|
230
236
|
* @param {object} opts
|
|
231
237
|
* @param {(iteration: number, gap?: string) => void|Promise<void>} opts.middle the emergent middle; never sees close/cap
|
|
232
|
-
* @param {string[]} opts.close argv whose exit code is truth
|
|
238
|
+
* @param {string[]} [opts.close] argv whose exit code is truth (required unless `judge` is injected)
|
|
239
|
+
* @param {() => Promise<{verdict: string, gap?: string, detail?: string}>|{verdict: string, gap?: string, detail?: string}} [opts.judge]
|
|
240
|
+
* Layer 2 (PRD v1.12 §4): the judge generalized to a SHELL-owned seam. When
|
|
241
|
+
* present it replaces runClose for this loop — a plan STEP's micro-loop is
|
|
242
|
+
* judged by the exit evaluator (declarative form checks), not a command. It
|
|
243
|
+
* returns the same verdict vocabulary runClose renders, so the forbidden
|
|
244
|
+
* zone, worker-crash routing (F32), and the cap taxonomy apply unchanged.
|
|
245
|
+
* Injected by run.js only; inexpressible in any config or plan — the agent
|
|
246
|
+
* never authors its judge any more than its close.
|
|
233
247
|
* @param {number} opts.capRuns budget: max middle runs
|
|
234
248
|
* @param {(type: string, data?: object) => object} opts.emit a spine emitter
|
|
235
249
|
* @param {(s: string) => string} [opts.redact] source scrubber for close output
|
|
@@ -249,13 +263,15 @@ export const CLOSE_FAULTS = Object.freeze({
|
|
|
249
263
|
* with zero writes, a crash stays what it always was: an instrument stop.
|
|
250
264
|
* @returns {Promise<'green'|'escalated'>}
|
|
251
265
|
*/
|
|
252
|
-
export async function ralph({ middle, close, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
|
|
253
|
-
emit('run-start', { capRuns, close: close.join(' ') });
|
|
266
|
+
export async function ralph({ middle, close, judge, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
|
|
267
|
+
emit('run-start', { capRuns, close: judge ? 'judge:injected' : /** @type {string[]} */ (close).join(' ') });
|
|
254
268
|
// The blind spot is NAMED, never hidden: with no judgment-rendered signal this
|
|
255
269
|
// close cannot tell a crash from an honest red (they are byte-identical at the
|
|
256
270
|
// exit-code seam), so the record says so out loud rather than passing the exit
|
|
257
|
-
// code off as trustworthy (PRD v1.11).
|
|
258
|
-
|
|
271
|
+
// code off as trustworthy (PRD v1.11). The judged-floor story belongs to
|
|
272
|
+
// CLOSES: an injected judge (Layer 2 exit evaluation) owns its own honesty
|
|
273
|
+
// reads, so the note would be noise there.
|
|
274
|
+
if (!judged && !judge) emit('close-unaudited', { close: /** @type {string[]} */ (close).join(' '), meaning: 'no judgment-rendered signal declared — a crash-at-load is indistinguishable from an honest red for this close' });
|
|
259
275
|
const verdicts = [];
|
|
260
276
|
let gap;
|
|
261
277
|
for (let iteration = 1; iteration <= capRuns; iteration++) {
|
|
@@ -293,7 +309,9 @@ export async function ralph({ middle, close, capRuns, emit, redact, closeTimeout
|
|
|
293
309
|
return 'escalated';
|
|
294
310
|
}
|
|
295
311
|
emit('middle-done', { iteration });
|
|
296
|
-
const v =
|
|
312
|
+
const v = judge
|
|
313
|
+
? await judge()
|
|
314
|
+
: runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
|
|
297
315
|
// Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
|
|
298
316
|
// still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
|
|
299
317
|
// precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
|
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
|
@@ -13,6 +13,7 @@ import { redact } from 'bareguard';
|
|
|
13
13
|
import { validateJob, jobSpecHash, checkApproval } from './job.js';
|
|
14
14
|
import { validateConfig, LOOP_SHAPES, SLOTS, VERBS, globToPrefix, SECRET_PATTERNS, REMEMBER_KINDS } from './validate.js';
|
|
15
15
|
import { interpret } from './interpret.js';
|
|
16
|
+
import { runPlan } from './planrun.js';
|
|
16
17
|
import { runClose, CLOSE_FAULTS } from './ralph.js';
|
|
17
18
|
import { extractArtifact, priceOf } from './text.js';
|
|
18
19
|
|
|
@@ -183,59 +184,94 @@ async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
|
|
|
183
184
|
* boundary — the Gate still caps within a step, but an unpriced result cannot
|
|
184
185
|
* be summed, so the run stops rather than counting it $0 (F6).
|
|
185
186
|
*
|
|
187
|
+
* EVERY terminal `job-end` states the money: `spentUsd` (the sum of PRICED
|
|
188
|
+
* rounds only — never an estimate; a real 0 on the pre-token reds) plus
|
|
189
|
+
* `spendComplete`, false when any round came back unpriced and the figure is
|
|
190
|
+
* therefore a FLOOR, not the total. Both are always present, so a consumer
|
|
191
|
+
* never branches on field presence nor launders a blank into $0.
|
|
192
|
+
*
|
|
186
193
|
* @param {object|string} rawSpec the job spec (job-v1), text or parsed
|
|
187
194
|
* @param {object} opts
|
|
188
195
|
* @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
|
|
189
196
|
* @param {string} opts.workdir the run directory (the fence's root)
|
|
190
197
|
* @param {string} [opts.target] the step artifact path (text-mode steps; unused by tool-mode steps)
|
|
191
|
-
* @param {any} opts.provider shell-owned LLM binding (never the config's)
|
|
198
|
+
* @param {any} opts.provider shell-owned LLM binding (never the config's; the Loop path)
|
|
199
|
+
* @param {(o: {policy: Function, onTurn?: Function, maxTurns: number, hasTools: boolean}) => any} [opts.nativeProvider]
|
|
200
|
+
* native clipipe provider factory (BA-16), required for a `clipipe-subscription` plan-shape job —
|
|
201
|
+
* threaded to runPlan, which builds one per worker (native tool mode when `hasTools`, else a
|
|
202
|
+
* metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
|
|
192
203
|
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
193
204
|
* @param {number} [opts.capRuns] shell-owned per-step iteration cap
|
|
194
205
|
* @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
|
|
195
206
|
* @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
|
|
196
207
|
* @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
|
|
197
|
-
*
|
|
208
|
+
* @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
|
|
209
|
+
* territory, threaded to every predicate step's interpreter. Defaults OFF
|
|
210
|
+
* (decided 2026-07-21): fixation is extinct on every current job (F41), so
|
|
211
|
+
* ON has never won its own A/B — the acceptance read defers to Layer 2 (or
|
|
212
|
+
* a manufactured-fixation probe). `true` is the ON/experimental arm; the
|
|
213
|
+
* default is the OFF arm. (Shell-owned seam, same doctrine as the provider
|
|
214
|
+
* binding.)
|
|
198
215
|
* @returns {Promise<string>} outcome: 'green' | 'escalated' | 'unapproved-spec' |
|
|
199
216
|
* 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
|
|
200
217
|
* 'cap-halt' | 'close-unsupported' | `step-red:<id>`
|
|
201
218
|
*/
|
|
202
|
-
export async function runJob(rawSpec, { approvals, workdir, target, provider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec }) {
|
|
219
|
+
export async function runJob(rawSpec, { approvals, workdir, target, provider, nativeProvider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec, layerRoot = false }) {
|
|
220
|
+
// 0. the ledger's counters, declared FIRST so that every job-end — including
|
|
221
|
+
// the pre-token reds below — can state a real figure. An omitted `spentUsd` is
|
|
222
|
+
// not a zero: a consumer reads `undefined` and either crashes or launders it
|
|
223
|
+
// into $0 (F12's class, at the terminal record instead of mid-attempt).
|
|
224
|
+
let spentUsd = 0;
|
|
225
|
+
let unpriced = false;
|
|
226
|
+
// The money on the terminal record, and whether the money is EXACT. `spentUsd`
|
|
227
|
+
// is the accumulated sum of PRICED rounds ONLY — never an estimate derived
|
|
228
|
+
// from tokens or averages (cap-not-estimate). When any round came back
|
|
229
|
+
// unpriced (F6) that sum is a FLOOR, not the total, and `spendComplete: false`
|
|
230
|
+
// says so machine-readably instead of dressing a floor up as exact. Emitted on
|
|
231
|
+
// EVERY job-end (true when everything was priced) so no consumer ever has to
|
|
232
|
+
// branch on field presence.
|
|
233
|
+
const spend = () => ({ spentUsd, spendComplete: !unpriced });
|
|
203
234
|
// 1. human-signs-always — before ANY provider call (N1 decision #1)
|
|
204
235
|
if (!checkApproval(rawSpec, approvals)) {
|
|
205
|
-
emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version' });
|
|
236
|
+
emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
|
|
206
237
|
return 'unapproved-spec';
|
|
207
238
|
}
|
|
208
239
|
const jv = validateJob(rawSpec, { shellCapUsd });
|
|
209
240
|
if (!jv.ok) {
|
|
210
241
|
for (const r of jv.reds) emit('job-red', r);
|
|
211
|
-
emit('job-end', { outcome: 'job-red' });
|
|
242
|
+
emit('job-end', { outcome: 'job-red', ...spend() });
|
|
212
243
|
return 'job-red';
|
|
213
244
|
}
|
|
214
245
|
const job = /** @type {any} */ (jv.job);
|
|
246
|
+
// Layer 2: the two exclusive shapes (decision 6). A plan-shape spec (goal/
|
|
247
|
+
// verdictType/close/checks[]) has no steps[] and its steps are tool-mode by
|
|
248
|
+
// construction — the text-mode target rule below is a legacy-steps rule only.
|
|
249
|
+
const planShape = job.goal !== undefined;
|
|
215
250
|
// Reds-before-tokens: a text-mode predicate step writes ONE artifact — with
|
|
216
251
|
// no target it would burn a draft + a worker call and then crash inside the
|
|
217
252
|
// middle as a misfiled interpreter-red. The spec is fine; the CALL is not.
|
|
218
|
-
if ((typeof target !== 'string' || !target)
|
|
253
|
+
if (!planShape && (typeof target !== 'string' || !target)
|
|
219
254
|
&& job.steps.some((/** @type {any} */ s) => s.close.type === 'predicate' && (s.mode ?? 'text') === 'text')) {
|
|
220
255
|
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' });
|
|
256
|
+
emit('job-end', { outcome: 'job-red', ...spend() });
|
|
222
257
|
return 'job-red';
|
|
223
258
|
}
|
|
224
|
-
emit('job-start', {
|
|
259
|
+
emit('job-start', {
|
|
260
|
+
job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd,
|
|
261
|
+
...(planShape ? { shape: 'plan', goal: job.goal } : { steps: job.steps.map((/** @type {any} */ s) => s.id) }),
|
|
262
|
+
});
|
|
225
263
|
|
|
226
264
|
// 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
|
|
227
265
|
const smoke = await primitiveSmoke(workdir);
|
|
228
266
|
emit('primitive-smoke', smoke);
|
|
229
267
|
if (!smoke.ok) {
|
|
230
268
|
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' });
|
|
269
|
+
emit('job-end', { outcome: 'smoke-red', ...spend() });
|
|
232
270
|
return 'smoke-red';
|
|
233
271
|
}
|
|
234
272
|
|
|
235
273
|
// 3. the ONE ledger. Unpriced is never free (F6): a null cost can't be
|
|
236
274
|
// summed, so it flags a stop instead of accumulating $0.
|
|
237
|
-
let spentUsd = 0;
|
|
238
|
-
let unpriced = false;
|
|
239
275
|
/** @param {number|null|undefined} c */
|
|
240
276
|
const account = (c) => { if (typeof c === 'number' && Number.isFinite(c)) spentUsd += c; else unpriced = true; };
|
|
241
277
|
/** @type {(type: string, data?: object) => object} */
|
|
@@ -256,10 +292,35 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
256
292
|
};
|
|
257
293
|
const pricingRed = () => {
|
|
258
294
|
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' });
|
|
295
|
+
emit('job-end', { outcome: 'pricing-red', ...spend() });
|
|
260
296
|
return 'pricing-red';
|
|
261
297
|
};
|
|
262
298
|
|
|
299
|
+
// ── Layer 2 dispatch: the plan flow (SCOUT → PLAN → EXECUTE → close) under
|
|
300
|
+
// the SAME approval gate, smoke, and ledger — runJob stays the one entry
|
|
301
|
+
// (N2 lock). runPlan emits every provider round as worker-round, so the
|
|
302
|
+
// metered emit above accounts the plan flow natively (F12); the job-end
|
|
303
|
+
// money contract is identical to the legacy path.
|
|
304
|
+
if (planShape) {
|
|
305
|
+
const outcome = await runPlan(job, {
|
|
306
|
+
workdir, provider, nativeProvider, emit: meter, capRuns, closeTimeoutMs,
|
|
307
|
+
remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
|
|
308
|
+
isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
|
|
309
|
+
});
|
|
310
|
+
if (unpriced) return pricingRed();
|
|
311
|
+
if (outcome.startsWith('step-red:')) {
|
|
312
|
+
emit('job-end', { outcome: 'step-red', step: outcome.slice('step-red:'.length), ...spend() });
|
|
313
|
+
} else if (outcome === 'provider-red') {
|
|
314
|
+
// F44: a transport-throw provider-red never returned a usage figure for the
|
|
315
|
+
// failed call, so the priced sum is a FLOOR, not the total — mirror the
|
|
316
|
+
// legacy providerRed(): spendComplete false, never overclaim an exact total.
|
|
317
|
+
emit('job-end', { outcome, ...spend(), spendComplete: false });
|
|
318
|
+
} else {
|
|
319
|
+
emit('job-end', { outcome, ...spend() });
|
|
320
|
+
}
|
|
321
|
+
return outcome;
|
|
322
|
+
}
|
|
323
|
+
|
|
263
324
|
// 4. sealed drafting: one shot + one redraft, reds fed back, PRICED path
|
|
264
325
|
// (through Loop, the same accounting the worker uses — never around it, F6)
|
|
265
326
|
const drafter = new Loop({ provider, system: 'You draft workflow configs as pure JSON. Output only the JSON object.' });
|
|
@@ -294,7 +355,11 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
294
355
|
};
|
|
295
356
|
const providerRed = () => {
|
|
296
357
|
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
|
-
|
|
358
|
+
// A transport THROW never returned a usage figure, so the floor is NOT the total
|
|
359
|
+
// (F6): spendComplete must be false — spend()'s `!unpriced` only knows about priced
|
|
360
|
+
// rounds that came back unpriced, not a call that never returned at all. Leaving it
|
|
361
|
+
// true would have the job-end contradict the escalation's own "spend … is unknown".
|
|
362
|
+
emit('job-end', { outcome: 'provider-red', ...spend(), spendComplete: false });
|
|
298
363
|
return 'provider-red';
|
|
299
364
|
};
|
|
300
365
|
const capNow = () => Math.min(shellCapUsd, job.budgetUsd - spentUsd);
|
|
@@ -345,7 +410,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
345
410
|
// not on trial. Mirrors the worker path (interpret.js) and the human's options: the round
|
|
346
411
|
// was metered, so this is a fault with a known cost, not a spend-unknown transport throw.
|
|
347
412
|
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' });
|
|
413
|
+
emit('job-end', { outcome: 'provider-red', ...spend() });
|
|
349
414
|
return 'provider-red';
|
|
350
415
|
}
|
|
351
416
|
if (unpriced) return pricingRed();
|
|
@@ -355,11 +420,11 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
355
420
|
// the honest stop is a cap story, never a config-red blaming the drafter
|
|
356
421
|
emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', spentUsd, budgetUsd: job.budgetUsd });
|
|
357
422
|
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' });
|
|
423
|
+
emit('job-end', { outcome: 'cap-halt', ...spend() });
|
|
359
424
|
return 'cap-halt';
|
|
360
425
|
}
|
|
361
426
|
for (const r of cv.reds) emit('config-red', r);
|
|
362
|
-
emit('job-end', { outcome: 'config-red' });
|
|
427
|
+
emit('job-end', { outcome: 'config-red', ...spend() });
|
|
363
428
|
return 'config-red';
|
|
364
429
|
}
|
|
365
430
|
config = cv.config;
|
|
@@ -402,14 +467,14 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
402
467
|
// baseline — loud, and never allowed to un-open a real PR
|
|
403
468
|
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
469
|
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,
|
|
470
|
+
emit('job-end', { outcome: 'escalated', step: step.id, ...spend() });
|
|
406
471
|
return 'escalated'; // by design: the human acts outside the run, forever
|
|
407
472
|
}
|
|
408
473
|
if (step.close.type !== 'predicate') {
|
|
409
474
|
// honest refusal: gold/rubric EXECUTION lands with the verdict classes
|
|
410
475
|
// (N4); a fake verdict here would poison every contrast downstream
|
|
411
476
|
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 });
|
|
477
|
+
emit('job-end', { outcome: 'close-unsupported', step: step.id, ...spend() });
|
|
413
478
|
return 'close-unsupported';
|
|
414
479
|
}
|
|
415
480
|
emit('step-start', { step: step.id, remainingUsd: job.budgetUsd - spentUsd, spentUsd, mode: step.mode ?? 'text' });
|
|
@@ -436,7 +501,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
436
501
|
const fault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
437
502
|
if (fault) {
|
|
438
503
|
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,
|
|
504
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category, ...spend() });
|
|
440
505
|
return `step-red:${step.id}`;
|
|
441
506
|
}
|
|
442
507
|
// The precheck's gap goes to the worker as what it IS: the close's output on
|
|
@@ -458,21 +523,22 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
458
523
|
// arbiter territory (PRD v1.11; F28 for gapKeep).
|
|
459
524
|
mode: step.mode ?? 'text', tools: step.tools,
|
|
460
525
|
closeExpect: step.close.expect, closeJudged: step.close.judged, closeGapKeep: step.close.gapKeep,
|
|
526
|
+
layerRoot,
|
|
461
527
|
});
|
|
462
528
|
} catch (e) {
|
|
463
529
|
// ralph belts throws INSIDE the loop; this belts the interpreter's own
|
|
464
530
|
// setup (gate.init, store ctor) — the spine must terminate, never dangle
|
|
465
531
|
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',
|
|
532
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red', ...spend() });
|
|
467
533
|
return `step-red:${step.id}`;
|
|
468
534
|
}
|
|
469
535
|
emit('step-end', { step: step.id, outcome, spentUsd });
|
|
470
536
|
if (unpriced) return pricingRed();
|
|
471
537
|
if (outcome !== 'green') {
|
|
472
|
-
emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome,
|
|
538
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome, ...spend() });
|
|
473
539
|
return `step-red:${step.id}`;
|
|
474
540
|
}
|
|
475
541
|
}
|
|
476
|
-
emit('job-end', { outcome: 'green',
|
|
542
|
+
emit('job-end', { outcome: 'green', ...spend() });
|
|
477
543
|
return 'green';
|
|
478
544
|
}
|