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/planrun.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
// Layer 2 executor — the plan-v1 run, end to end (PRD v1.12; design record
|
|
2
|
+
// 2026-07-21). SCOUT (read-only, hard-bounded) → PLAN (the decompose call,
|
|
3
|
+
// gated by validatePlan before tokens burn) → EXECUTE (per-step micro-loops:
|
|
4
|
+
// the same ralph(), judge = the exit evaluator — the F46 mechanism) → ONE
|
|
5
|
+
// replan → the operator's close, the only truth. The checks referenced by
|
|
6
|
+
// `check-passes` run under the FULL runClose machinery (forbidden zone,
|
|
7
|
+
// judged floor, redaction, gapKeep); they decide nothing and mint nothing —
|
|
8
|
+
// a check result is a progress gate and a gap source, nothing more.
|
|
9
|
+
//
|
|
10
|
+
// Prompt contract (v1.12 §5), held here: a step worker sees its action, the
|
|
11
|
+
// absolute repo root, its target, prior steps' artifacts labeled by id, its
|
|
12
|
+
// gap, and a cut-off notice. It NEVER sees the budget, the close command, a
|
|
13
|
+
// check's command, the validator, other steps' grants, or the arbiter's books
|
|
14
|
+
// (fs.deny on the gate audit / .smoke / .litectx, unchanged).
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
import { Gate, redact } from 'bareguard';
|
|
20
|
+
import { LiteCtx } from 'litectx';
|
|
21
|
+
import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
|
|
22
|
+
import { validatePlan } from './plan.js';
|
|
23
|
+
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
|
|
24
|
+
import { snapshotScope, evalExits } from './exits.js';
|
|
25
|
+
import { TOOL_MENU } from './job.js';
|
|
26
|
+
import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, RETRIEVAL_STRATEGY, EDIT_STRATEGY } from './interpret.js';
|
|
27
|
+
import { globToPrefix, SECRET_PATTERNS } from './validate.js';
|
|
28
|
+
import { extractArtifact } from './text.js';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { Loop, wireGate, HaltError } = require('bare-agent');
|
|
32
|
+
const { createShellTools } = require('bare-agent/tools');
|
|
33
|
+
|
|
34
|
+
/** the scout's hard round bound — read-only survey, never a worker (v1.12 §1) */
|
|
35
|
+
const SCOUT_ROUNDS = 8;
|
|
36
|
+
/** blob bound: the scout's output is a PROMPT ingredient for every later call */
|
|
37
|
+
const SCOUT_BLOB_MAX = 8000;
|
|
38
|
+
/**
|
|
39
|
+
* NATIVE-ONLY read cap (F48/BA-16). On the clipipe-subscription surface the claude CLI
|
|
40
|
+
* TRUNCATES a large tool result before the model ever sees it (~40-50KB / ~line 550,
|
|
41
|
+
* measured), spilling the remainder to a `~/.claude/.../tool-results/` file the fence denies
|
|
42
|
+
* AND wrapping it in a "read this in chunks" notice the model correctly distrusts as prompt
|
|
43
|
+
* injection — so a whole-file `shell_read` of a large file blinds the worker (0-write stall,
|
|
44
|
+
* F48). We bound OUR read result below the CLI cap and hand back a TRUSTED notice steering to
|
|
45
|
+
* `ctx_get` (ranged retrieval survives the cap: one function per fetch). API path is untouched
|
|
46
|
+
* — there the full result rides straight into context, so no cap and RETRIEVAL_STRATEGY alone.
|
|
47
|
+
*/
|
|
48
|
+
const NATIVE_READ_CAP = 24 * 1024;
|
|
49
|
+
/** native-only strategy: tell the worker WHY whole-file reads fail here and to navigate by symbol */
|
|
50
|
+
const NATIVE_READ_STRATEGY = '\nINTERFACE LIMIT: on this surface a whole-file read of a large file is TRUNCATED before you see it — a shell_read of a file over ~24KB returns only its start followed by a truncation notice. This is NOT the whole file. To read a function IN FULL, always use ctx_recall(<symbol>) then ctx_get(<pointer>) — that returns the entire function no matter how large the file is. To locate a line, use shell_grep(<pattern>). Never try to understand a file over ~400 lines by reading it whole; recall its symbols instead.';
|
|
51
|
+
/** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
|
|
52
|
+
const ARTIFACT_MAX = 2000;
|
|
53
|
+
/** the wallet floor below which a replan is a stop, not an adaptation (review
|
|
54
|
+
* #5): a money-gate halt drains the wallet to ~0, so replanning against dust
|
|
55
|
+
* just burns another draft and mislabels the money-cut as "exits still red"
|
|
56
|
+
* (F45 class) — the honest terminal there is cap-halt. */
|
|
57
|
+
const MONEY_MIN = 0.001;
|
|
58
|
+
|
|
59
|
+
/** @typedef {Error & {category?: string, lib?: string}} CategorizedError */
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The plan-drafting prompt: a schema DESCRIPTION built from the live validator
|
|
63
|
+
* menus — never a copyable example (the drafter must author, not echo; the
|
|
64
|
+
* run.js draftPrompt precedent). Check NAMES only: a check's command is
|
|
65
|
+
* arbiter territory the planner never sees.
|
|
66
|
+
* @param {any} job @param {string} scoutBlob @param {any[]|null} reds
|
|
67
|
+
* @param {number} maxStepRounds @param {string|null} failure replan context
|
|
68
|
+
*/
|
|
69
|
+
function planPrompt(job, scoutBlob, reds, maxStepRounds, failure) {
|
|
70
|
+
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
71
|
+
const checkNames = (job.checks ?? []).map((/** @type {any} */ c) => c.name);
|
|
72
|
+
const doc = `DRAFT-PLAN
|
|
73
|
+
You are planning how to accomplish a goal in a repository, as an ordered list of bounded
|
|
74
|
+
steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
|
|
75
|
+
ANY unknown field, wrong enum value, or out-of-bounds number is rejected. Output ONLY the
|
|
76
|
+
JSON object, no fences, no commentary.
|
|
77
|
+
|
|
78
|
+
Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] } — steps run
|
|
79
|
+
strictly in array order. Each step (no other fields exist):
|
|
80
|
+
- "id": kebab-case slug, unique
|
|
81
|
+
- "action": the step's task, precise enough for a worker that sees ONLY this step
|
|
82
|
+
- "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit are the write-class verbs)
|
|
83
|
+
- "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
|
|
84
|
+
- "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
|
|
85
|
+
- "exit": 1..${MAX_EXITS_PER_STEP} form checks that ALL must pass (AND), each one of:
|
|
86
|
+
{"type":"artifact-written","path":"...","pattern":"optional regex"}
|
|
87
|
+
{"type":"tree-changed","scope":"a scope inside ${JSON.stringify(job.writeScope)}"}
|
|
88
|
+
{"type":"json-valid","path":"..."}
|
|
89
|
+
{"type":"check-passes","name":"one of ${JSON.stringify(checkNames)}"}
|
|
90
|
+
A check-passes on a write-granted step MUST be paired with a tree-changed exit
|
|
91
|
+
(the repository starts green — a lone check would pass on the untouched tree).
|
|
92
|
+
Reference checks by NAME only; you cannot author or modify one.
|
|
93
|
+
|
|
94
|
+
Goal:
|
|
95
|
+
${job.goal}
|
|
96
|
+
|
|
97
|
+
Repository survey (from a read-only scout):
|
|
98
|
+
${scoutBlob || '(no scout notes)'}`;
|
|
99
|
+
let p = doc;
|
|
100
|
+
if (failure) p += `\n\nWhat happened when the previous plan ran:\n${failure}\nPlan differently — a repeat of the same steps will fail the same way.`;
|
|
101
|
+
if (reds) p += `\n\nYour previous plan was REJECTED with these reds (code:path):\n${JSON.stringify(reds)}\nFix every red. Output ONLY the corrected JSON object.`;
|
|
102
|
+
return p;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Execute a validateJob-GREEN plan-shape job (goal/verdictType/close/checks[]).
|
|
107
|
+
* Called by runJob after the approval gate, validation, and the smoke — this
|
|
108
|
+
* function owns the plan flow only; the caller owns the job-end record and the
|
|
109
|
+
* one ledger (every provider round is emitted here as `worker-round`, which
|
|
110
|
+
* the caller's metered emit accounts — F12).
|
|
111
|
+
*
|
|
112
|
+
* @param {any} job the validated plan-shape spec
|
|
113
|
+
* @param {object} opts
|
|
114
|
+
* @param {string} opts.workdir the run directory (the fence's root)
|
|
115
|
+
* @param {any} opts.provider shell-owned LLM binding (the Loop path — `anthropic-api`)
|
|
116
|
+
* @param {(o: {policy: Function, onTurn?: Function, maxTurns: number, hasTools: boolean}) => any} [opts.nativeProvider]
|
|
117
|
+
* NATIVE clipipe factory (BA-16): required when `job.provider === 'clipipe-subscription'`.
|
|
118
|
+
* The runner builds a FRESH provider per worker and picks the mode by `hasTools`:
|
|
119
|
+
* `true` → native tool mode (`toolProtocol:'claude-mcp'`, wire `policy`+`onTurn`+`maxTurns`);
|
|
120
|
+
* `false` → the drafter has no tools, so a native session would report NO cost — return a
|
|
121
|
+
* metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
|
|
122
|
+
* so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
|
|
123
|
+
* @param {(type: string, data?: object) => object} opts.emit spine emitter (the caller's METERED emit)
|
|
124
|
+
* @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
|
|
125
|
+
* @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
|
|
126
|
+
* plan flow bails IN-FLIGHT on the first unpriced round instead of burning the whole plan
|
|
127
|
+
* @param {number} [opts.capRuns] shell-owned per-step attempt cap
|
|
128
|
+
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
129
|
+
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
130
|
+
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
131
|
+
* 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
|
|
132
|
+
* 'provider-red' | 'interpreter-red' | `step-red:<id>`
|
|
133
|
+
*/
|
|
134
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40 }) {
|
|
135
|
+
workdir = resolve(workdir);
|
|
136
|
+
const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
|
|
137
|
+
|
|
138
|
+
// Which worker surface? `clipipe-subscription` drives tools NATIVELY (the CLI
|
|
139
|
+
// owns the turn cycle, BA-16); every other provider runs the Loop. The close,
|
|
140
|
+
// the checks, and the exit evaluator are provider-independent (commands and
|
|
141
|
+
// form checks) — ONLY the worker differs, so the whole plan flow is shared.
|
|
142
|
+
const native = job.provider === 'clipipe-subscription';
|
|
143
|
+
|
|
144
|
+
// ── close-unsupported (F17 guard, mirrored from the legacy path at run.js):
|
|
145
|
+
// the plan flow executes a PREDICATE close only — a command whose exit code is
|
|
146
|
+
// truth. validateJob admits a GOLD close under verdictType green (gold is
|
|
147
|
+
// hard-class), and a gold close carries no `cmd`; running `close.cmd.trim()`
|
|
148
|
+
// on it would TypeError out of runJob with NO job-end (the spine would dangle,
|
|
149
|
+
// no spend recorded). Refuse a non-predicate close cleanly, before any tokens.
|
|
150
|
+
if (job.close.type !== 'predicate') {
|
|
151
|
+
emit('escalation', {
|
|
152
|
+
category: 'close-unsupported', decisionReady: true,
|
|
153
|
+
decision: `The job's close is a ${job.close.type} close — the plan flow executes a predicate close only (a command whose exit code is truth).`,
|
|
154
|
+
options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
|
|
155
|
+
});
|
|
156
|
+
return 'close-unsupported';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── native wiring: a clipipe-subscription job needs the native provider
|
|
160
|
+
// FACTORY (native governance is constructor-time + per-worker, so the runner
|
|
161
|
+
// cannot reuse one injected instance). A missing factory is an adopter wiring
|
|
162
|
+
// gap, never a silent fall-back to the Loop path (that would run a
|
|
163
|
+
// subscription job on the metered API — the wrong bill on the wrong surface).
|
|
164
|
+
if (native && typeof nativeProvider !== 'function') {
|
|
165
|
+
emit('escalation', {
|
|
166
|
+
category: 'interpreter-red', decisionReady: true,
|
|
167
|
+
decision: 'This job declares provider clipipe-subscription (native tool mode), but no native provider factory was wired into the runner.',
|
|
168
|
+
options: ['wire a native CLIPipeProvider factory (opts.nativeProvider)', 'change the job provider to a Loop-driven one'],
|
|
169
|
+
});
|
|
170
|
+
return 'interpreter-red';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const closeArgv = job.close.cmd.trim().split(/\s+/);
|
|
174
|
+
const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep };
|
|
175
|
+
|
|
176
|
+
// ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
|
|
177
|
+
// record, zero tokens; a forbidden-zone verdict escalates before any spend
|
|
178
|
+
const pre = runClose(closeArgv, scrub, closeOpts);
|
|
179
|
+
emit('close-precheck', { ...pre });
|
|
180
|
+
if (pre.verdict === 'satisfied') return 'already-green';
|
|
181
|
+
const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
182
|
+
if (preFault) {
|
|
183
|
+
emit('escalation', { category: preFault.category, decisionReady: true, decision: preFault.decision, options: preFault.options, detail: pre.detail });
|
|
184
|
+
return 'close-red';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── 0b. checks preflight ($0, deterministic): every SIGNED check runs once
|
|
188
|
+
// before tokens — an unrunnable check would fault mid-plan after real spend
|
|
189
|
+
// (a frozen rule without a wired detector is prose; here the detector runs
|
|
190
|
+
// first). Red and green are both fine: checks decide nothing.
|
|
191
|
+
for (const c of job.checks ?? []) {
|
|
192
|
+
const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
|
|
193
|
+
emit('check-preflight', { name: c.name, verdict: v.verdict });
|
|
194
|
+
const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
|
|
195
|
+
if (f) {
|
|
196
|
+
emit('escalation', { category: f.category, decisionReady: true, decision: `Signed check "${c.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${c.name}: ${v.detail ?? ''}` });
|
|
197
|
+
return 'check-red';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lc = new LiteCtx({ root: workdir });
|
|
202
|
+
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
203
|
+
const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
|
|
204
|
+
const auditPath = join(workdir, 'gate-audit.jsonl');
|
|
205
|
+
const checksByName = new Map((job.checks ?? []).map((/** @type {any} */ c) => [c.name, c]));
|
|
206
|
+
|
|
207
|
+
/** the check-passes seam evalExits delegates to: the FULL runClose machinery
|
|
208
|
+
* per signed check; a forbidden-zone verdict rides out as `fault` by name so
|
|
209
|
+
* the micro-loop escalates (or F32-routes a crash) instead of faking a gap */
|
|
210
|
+
const runCheck = async (/** @type {string} */ name) => {
|
|
211
|
+
const c = checksByName.get(name);
|
|
212
|
+
if (!c) return { pass: false, fault: 'failed', gap: `no signed check named "${name}"` };
|
|
213
|
+
const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
|
|
214
|
+
emit('check-run', { name, verdict: v.verdict, ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
|
|
215
|
+
if (v.verdict === 'satisfied') return { pass: true };
|
|
216
|
+
if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
|
|
217
|
+
return { pass: false, fault: v.verdict, gap: v.detail };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** the last escalation ralph emitted — the replan trigger reads its category
|
|
221
|
+
* (only exhaustion replans; an instrument stop stays a stop) */
|
|
222
|
+
let lastEscalation = /** @type {any} */ (null);
|
|
223
|
+
/** @type {(type: string, data?: object) => object} */
|
|
224
|
+
const emitL = (type, data) => { if (type === 'escalation') lastEscalation = data; return emit(type, data); };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Assemble one bounded worker: fresh Gate (the fence, the arbiter's books
|
|
228
|
+
* denied, the wallet as its budget), granted tools only (the menu IS the
|
|
229
|
+
* grant), per-attempt round bound via loop.stop() (F20), every round metered
|
|
230
|
+
* with a phase label (F12).
|
|
231
|
+
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean}} o
|
|
232
|
+
*/
|
|
233
|
+
async function mkWorker({ granted, phase, attemptRounds, attempts, writable }) {
|
|
234
|
+
const gate = new Gate({
|
|
235
|
+
fs: {
|
|
236
|
+
writeScope: writable ? fencePrefixes : [],
|
|
237
|
+
readScope: [workdir],
|
|
238
|
+
deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
|
|
239
|
+
},
|
|
240
|
+
budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
|
|
241
|
+
limits: { maxTurns: attemptRounds * (attempts + 1) },
|
|
242
|
+
audit: { path: auditPath },
|
|
243
|
+
humanChannel: async () => ({ decision: 'terminate' }),
|
|
244
|
+
});
|
|
245
|
+
await gate.init();
|
|
246
|
+
// F32's instrument, run_id-scoped, write AND edit, allow-decision only —
|
|
247
|
+
// the same audit read as interpret's (never git status, F45)
|
|
248
|
+
const workerWrites = () => {
|
|
249
|
+
try {
|
|
250
|
+
const paths = new Set();
|
|
251
|
+
for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
|
|
252
|
+
if (!line) continue;
|
|
253
|
+
let rec;
|
|
254
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
255
|
+
if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
|
|
256
|
+
&& (rec.action?.type === 'write' || rec.action?.type === 'edit')
|
|
257
|
+
&& typeof rec.action.path === 'string') paths.add(rec.action.path);
|
|
258
|
+
}
|
|
259
|
+
return [...paths];
|
|
260
|
+
} catch { return []; }
|
|
261
|
+
};
|
|
262
|
+
const { policy, onLlmResult } = wireGate(gate, { actionTranslator: (/** @type {string} */ n, /** @type {any} */ a) => toolAction(n, a, workdir) });
|
|
263
|
+
/** @type {number|string|undefined} */
|
|
264
|
+
let roundIteration;
|
|
265
|
+
let roundsThisAttempt = 0;
|
|
266
|
+
/** @type {number|string|undefined} */
|
|
267
|
+
let attemptBounded;
|
|
268
|
+
const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
|
|
269
|
+
const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
|
|
270
|
+
// F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
|
|
271
|
+
// TRUSTED truncation notice steering to ctx_get — the CLI's own truncation blinds the worker
|
|
272
|
+
// (spilled + injection-flagged). Fresh tool objects per mkWorker call, so mutating is per-worker.
|
|
273
|
+
if (native) {
|
|
274
|
+
const rd = shell.find((/** @type {{name: string}} */ t) => t.name === TOOL_BY_VERB.read);
|
|
275
|
+
if (rd) {
|
|
276
|
+
const inner = rd.execute;
|
|
277
|
+
rd.execute = async (/** @type {any} */ args) => {
|
|
278
|
+
const r = await inner(args);
|
|
279
|
+
if (typeof r === 'string' && Buffer.byteLength(r, 'utf8') > NATIVE_READ_CAP) {
|
|
280
|
+
const head = Buffer.from(r, 'utf8').subarray(0, NATIVE_READ_CAP).toString('utf8');
|
|
281
|
+
return head + `\n\n[bareloop: file truncated at ${NATIVE_READ_CAP} bytes — this interface will not display more of a single read. To read a specific function IN FULL use ctx_recall(<symbol>) then ctx_get(<pointer>); to find a line use shell_grep(<pattern>).]`;
|
|
282
|
+
}
|
|
283
|
+
return r;
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
|
|
288
|
+
? createCtxTools(lc, workdir, emit).filter((t) => grantedNames.has(t.name))
|
|
289
|
+
: [];
|
|
290
|
+
if (ctx.length) await lc.index();
|
|
291
|
+
const toolDefs = [...shell, ...ctx];
|
|
292
|
+
const system = PERSONA_TOOLS + (granted.includes('edit') ? EDIT_STRATEGY : '') + (ctx.length ? RETRIEVAL_STRATEGY : '')
|
|
293
|
+
+ (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
|
|
294
|
+
/** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
|
|
295
|
+
const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
|
|
296
|
+
|
|
297
|
+
if (native && toolDefs.length > 0) {
|
|
298
|
+
// ── NATIVE clipipe TOOL session (BA-16): the CLI owns the turn cycle, so
|
|
299
|
+
// the arbiter clips onto the PROVIDER — policy (the SAME wireGate fence,
|
|
300
|
+
// proven to deny out-of-scope), onTurn (metering), maxTurns (the
|
|
301
|
+
// per-session round bound). Money is null per turn and AUTHORITATIVE at
|
|
302
|
+
// session close (the round-level F12 figure the CLI does not expose; the
|
|
303
|
+
// session total is honest — the per-session reconciliation). Only workers
|
|
304
|
+
// WITH tools take this path: a native session with NO tools fires no
|
|
305
|
+
// onTurn and reports no cost (live-verified), so the toolless drafter runs
|
|
306
|
+
// the metered claude-json TEXT path below instead (never unmetered spend).
|
|
307
|
+
/** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
|
|
308
|
+
const nativeMetered = async (arg) => {
|
|
309
|
+
const session = (arg?.kind ?? 'turn') === 'session';
|
|
310
|
+
// per-turn events are ATTRIBUTION ONLY (`worker-turn`, never accounted —
|
|
311
|
+
// a native turn's cost is null BY DESIGN and F6 must not read it as
|
|
312
|
+
// unpriced); the session-close event carries the authoritative cost and
|
|
313
|
+
// IS the one accounted `worker-round` the ledger sums (F12 at the surface
|
|
314
|
+
// the CLI actually meters).
|
|
315
|
+
emit(session ? 'worker-round' : 'worker-turn', {
|
|
316
|
+
phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
|
|
317
|
+
costUsd: session ? (arg?.costUsd ?? null) : null, pricing: arg?.pricing ?? null,
|
|
318
|
+
tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
|
|
319
|
+
usage: usageOf(arg?.usage),
|
|
320
|
+
});
|
|
321
|
+
return onLlmResult(arg);
|
|
322
|
+
};
|
|
323
|
+
const provider2 = /** @type {any} */ (nativeProvider)({ policy, onTurn: nativeMetered, maxTurns: attemptRounds, hasTools: true });
|
|
324
|
+
const loop = new Loop({ provider: provider2, system }); // no Loop policy / no cacheMessages: the CLI owns the transcript
|
|
325
|
+
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
|
326
|
+
const ask = async (prompt, defs = toolDefs) => {
|
|
327
|
+
let r;
|
|
328
|
+
try {
|
|
329
|
+
r = await loop.run([{ role: 'user', content: prompt }], defs, { maxTokens: 32000 });
|
|
330
|
+
} catch (e) {
|
|
331
|
+
const err = /** @type {CategorizedError} */ (e);
|
|
332
|
+
err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
// a maxTurns session is a BOUNDED attempt, not an escalation — the same
|
|
336
|
+
// role loop.stop() plays on the Loop path: judge the partial work and
|
|
337
|
+
// feed the gap forward (the CLI preserves lastText, BA-5).
|
|
338
|
+
if (r.error === 'max_turns') {
|
|
339
|
+
attemptBounded = roundIteration;
|
|
340
|
+
emit('attempt-bounded', { phase, iteration: roundIteration, cap: attemptRounds, native: true });
|
|
341
|
+
return r;
|
|
342
|
+
}
|
|
343
|
+
if (r.error) {
|
|
344
|
+
const err = /** @type {CategorizedError} */ (new Error(`native session: ${r.error}`));
|
|
345
|
+
// halt → cap-halt, denial streak → gate-red; a bridge/session terminal
|
|
346
|
+
// (bridge-failed, session_timeout, session:*) is provider-owned transport
|
|
347
|
+
err.category = r.error.startsWith('halt:') ? 'cap-halt'
|
|
348
|
+
: r.error.startsWith('denied:') ? 'gate-red'
|
|
349
|
+
: 'provider-red';
|
|
350
|
+
err.lib = 'bare-agent';
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
353
|
+
return r;
|
|
354
|
+
};
|
|
355
|
+
return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── LOOP path: the injected provider (anthropic-api and every other
|
|
359
|
+
// Loop-driven binding), OR — for a native worker with NO tools (the plan
|
|
360
|
+
// drafter) — a claude-json structured-output CLIPipe from the factory. Native
|
|
361
|
+
// tool mode cannot meter a toolless session (no onTurn, no cost); the
|
|
362
|
+
// claude-json TEXT path reports a real per-call cost that the Loop's
|
|
363
|
+
// onLlmResult meters exactly like an API round, so the drafter's spend is
|
|
364
|
+
// never invisible (F6/F44). The gate policy is wired but idle (no tools).
|
|
365
|
+
const loopProvider = native
|
|
366
|
+
? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
|
|
367
|
+
: provider;
|
|
368
|
+
/** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
|
|
369
|
+
const metered = async (arg) => {
|
|
370
|
+
emit('worker-round', {
|
|
371
|
+
phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
|
|
372
|
+
costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
|
|
373
|
+
tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
|
|
374
|
+
usage: usageOf(arg?.usage),
|
|
375
|
+
});
|
|
376
|
+
if ((arg?.kind ?? 'turn') === 'turn') {
|
|
377
|
+
roundsThisAttempt += 1;
|
|
378
|
+
if (roundsThisAttempt >= attemptRounds) {
|
|
379
|
+
attemptBounded = roundIteration;
|
|
380
|
+
emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
|
|
381
|
+
loop.stop();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return onLlmResult(arg);
|
|
385
|
+
};
|
|
386
|
+
const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered });
|
|
387
|
+
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
|
388
|
+
const ask = async (prompt, defs = toolDefs) => {
|
|
389
|
+
let r;
|
|
390
|
+
try {
|
|
391
|
+
r = await loop.run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000 });
|
|
392
|
+
} catch (e) {
|
|
393
|
+
const err = /** @type {CategorizedError} */ (e);
|
|
394
|
+
err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
// same error-return taxonomy as interpret's ask (one map, same doctrine):
|
|
398
|
+
// halt → cap-halt, denial streak → gate-red, API truncation → provider-red
|
|
399
|
+
if (r.error) {
|
|
400
|
+
const err = /** @type {CategorizedError} */ (new Error(`worker loop: ${r.error}`));
|
|
401
|
+
err.category = r.error.startsWith('halt:') ? 'cap-halt'
|
|
402
|
+
: r.error.startsWith('denied:') ? 'gate-red'
|
|
403
|
+
: r.error.startsWith('truncated:') ? 'provider-red'
|
|
404
|
+
: 'interpreter-red';
|
|
405
|
+
err.lib = 'bare-agent';
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
return r;
|
|
409
|
+
};
|
|
410
|
+
return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** relay a throw from OUTSIDE ralph (scout/plan drafting) as its honest category */
|
|
414
|
+
const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
|
|
415
|
+
const category = e instanceof HaltError ? 'cap-halt' : (typeof e?.category === 'string' ? e.category : 'provider-red');
|
|
416
|
+
if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(e?.message ?? e) });
|
|
417
|
+
emit('escalation', {
|
|
418
|
+
category, decisionReady: true, phase,
|
|
419
|
+
decision: category === 'cap-halt' ? `The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.` : `The ${phase} call failed (${category}) — no result exists.`,
|
|
420
|
+
options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['retry the run', 'fix the provider binding', 'abandon the run'],
|
|
421
|
+
detail: String(e?.message ?? e),
|
|
422
|
+
...(typeof e?.lib === 'string' ? { lib: e.lib } : {}),
|
|
423
|
+
});
|
|
424
|
+
return category === 'cap-halt' ? 'cap-halt' : 'provider-red';
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// ── 1. SCOUT — read-only by construction: the write-class verbs are simply
|
|
428
|
+
// not in its menu (the menu is the grant), and its gate fences zero paths
|
|
429
|
+
let scoutBlob = '';
|
|
430
|
+
emit('scout-start', { rounds: SCOUT_ROUNDS });
|
|
431
|
+
try {
|
|
432
|
+
const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v)), phase: 'scout', attemptRounds: SCOUT_ROUNDS, attempts: 1, writable: false });
|
|
433
|
+
scout.setIteration(1);
|
|
434
|
+
const r = await scout.ask([
|
|
435
|
+
'Survey this repository READ-ONLY for the goal below. Report: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Be concise — your notes brief a planner that cannot see the repository.',
|
|
436
|
+
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
437
|
+
`Goal:\n${job.goal}`,
|
|
438
|
+
pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
|
|
439
|
+
].filter(Boolean).join('\n\n'));
|
|
440
|
+
scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
return relay(e, 'scout');
|
|
443
|
+
}
|
|
444
|
+
emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
|
|
445
|
+
// F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
|
|
446
|
+
// see — halt at the boundary rather than run the whole plan blind (the caller
|
|
447
|
+
// emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
|
|
448
|
+
if (isUnpriced()) return 'pricing-red';
|
|
449
|
+
|
|
450
|
+
// ── 2. PLAN — the decompose call; the planner NEVER sees the repo (no tools,
|
|
451
|
+
// scout blob only — what keeps the plan a plan and not a second worker).
|
|
452
|
+
// One shot + one redraft with the reds fed back (the drafting precedent).
|
|
453
|
+
/** draft + validate with one redraft; emits plan-validate per phase. The
|
|
454
|
+
* drafter is built FRESH per call (review #4): its Gate budget snapshots the
|
|
455
|
+
* CURRENT wallet, never a stale pre-execute allocation. A replan draft after
|
|
456
|
+
* the steps have spent is therefore bounded by what is ACTUALLY left, so the
|
|
457
|
+
* total run spend can never exceed the signed budget (advertised == enforced,
|
|
458
|
+
* the hard line) — a drafter built once at full budget would let the replan
|
|
459
|
+
* draft spend against money the steps had already consumed.
|
|
460
|
+
* @param {string} phase @param {string|null} failure */
|
|
461
|
+
const obtainPlan = async (phase, failure) => {
|
|
462
|
+
const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
|
|
463
|
+
const draftPlan = async (/** @type {any[]|null} */ reds) => {
|
|
464
|
+
drafter.setIteration(reds ? 'redraft' : 'draft');
|
|
465
|
+
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure), []);
|
|
466
|
+
return extractArtifact(r.text).code ?? '';
|
|
467
|
+
};
|
|
468
|
+
let text = await draftPlan(null);
|
|
469
|
+
let pv = validatePlan(text, { job, maxStepRounds });
|
|
470
|
+
emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
|
|
471
|
+
if (!pv.ok) {
|
|
472
|
+
text = await draftPlan(pv.reds);
|
|
473
|
+
pv = validatePlan(text, { job, maxStepRounds });
|
|
474
|
+
emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
|
|
475
|
+
}
|
|
476
|
+
return pv;
|
|
477
|
+
};
|
|
478
|
+
let plan;
|
|
479
|
+
try {
|
|
480
|
+
const pv = await obtainPlan('draft', null);
|
|
481
|
+
if (!pv.ok) {
|
|
482
|
+
for (const r of pv.reds) emit('plan-red', r);
|
|
483
|
+
return 'plan-red';
|
|
484
|
+
}
|
|
485
|
+
plan = /** @type {any} */ (pv.plan);
|
|
486
|
+
emit('plan-accepted', { plan });
|
|
487
|
+
} catch (e) {
|
|
488
|
+
return relay(e, 'plan');
|
|
489
|
+
}
|
|
490
|
+
if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
|
|
491
|
+
|
|
492
|
+
// ── 3. EXECUTE — strictly sequential micro-loops; judge = the exit
|
|
493
|
+
// evaluator through ralph's shell-owned seam; artifacts feed forward (F21)
|
|
494
|
+
/** @type {{id: string, text: string}[]} */
|
|
495
|
+
const artifacts = [];
|
|
496
|
+
/** @type {{id: string, outcome: string}[]} */
|
|
497
|
+
const stepOutcomes = [];
|
|
498
|
+
let replanned = false;
|
|
499
|
+
const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
|
|
500
|
+
|
|
501
|
+
/** @param {any} step */
|
|
502
|
+
const executeStep = async (step) => {
|
|
503
|
+
emit('step-start', { step: step.id, rounds: step.rounds, tools: step.tools });
|
|
504
|
+
// the before-side of every tree-changed exit, taken at STEP start: the
|
|
505
|
+
// step's cumulative work is what the exit judges (outcome, never intent)
|
|
506
|
+
const snapshot = new Map();
|
|
507
|
+
for (const e of step.exit) {
|
|
508
|
+
if (e.type === 'tree-changed') for (const [k, v] of await snapshotScope(workdir, e.scope)) snapshot.set(k, v);
|
|
509
|
+
}
|
|
510
|
+
const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true });
|
|
511
|
+
let lastText = '';
|
|
512
|
+
let iterationNow = 0;
|
|
513
|
+
/** @param {number} iteration @param {string} [gap] */
|
|
514
|
+
const middle = async (iteration, gap) => {
|
|
515
|
+
w.setIteration(iteration);
|
|
516
|
+
iterationNow = iteration;
|
|
517
|
+
const r = await w.ask([
|
|
518
|
+
step.action,
|
|
519
|
+
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root — a relative path resolves against a different directory and will be denied by the gate.`,
|
|
520
|
+
step.target && `Write your deliverable to: ${resolve(workdir, step.target)}`,
|
|
521
|
+
artifacts.length > 0 && `Working context (read-only) — prior steps' results:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
|
|
522
|
+
gap && `Previous attempt failed this step's checks:\n${gap}`,
|
|
523
|
+
w.wasBounded() === iteration - 1
|
|
524
|
+
&& `Your previous attempt was CUT OFF after ${step.rounds} tool rounds. Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.`,
|
|
525
|
+
].filter(Boolean).join('\n\n'));
|
|
526
|
+
lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
|
|
527
|
+
};
|
|
528
|
+
const judge = async () => {
|
|
529
|
+
const { pass, results } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
|
|
530
|
+
emit('exit-eval', { step: step.id, iteration: iterationNow, results });
|
|
531
|
+
// an instrument fault rides out by its runClose verdict NAME: ralph
|
|
532
|
+
// escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
|
|
533
|
+
const faulty = results.find((r) => r.fault);
|
|
534
|
+
if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
|
|
535
|
+
if (pass) return { verdict: 'satisfied' };
|
|
536
|
+
// AND-only: the gap names EVERY failing wall (mechanical genre, F38)
|
|
537
|
+
return { verdict: 'needs_revision', gap: results.filter((r) => !r.pass).map((r) => r.detail).join('\n') };
|
|
538
|
+
};
|
|
539
|
+
const outcome = await ralph({ middle, judge, capRuns, emit: emitL, workerWrites: w.workerWrites });
|
|
540
|
+
return { outcome, artifact: lastText };
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
let idx = 0;
|
|
544
|
+
while (idx < plan.steps.length) {
|
|
545
|
+
const step = plan.steps[idx];
|
|
546
|
+
let res;
|
|
547
|
+
try {
|
|
548
|
+
res = await executeStep(step);
|
|
549
|
+
} catch (e) {
|
|
550
|
+
// Reachable ONLY by mkWorker/gate/index SETUP faults: ralph catches middle
|
|
551
|
+
// throws and returns 'escalated', so a provider throw never reaches here.
|
|
552
|
+
// An uncategorized setup fault is interpreter-red (broken infra), never
|
|
553
|
+
// relay's provider-red default — and the RECORDED outcome must match the
|
|
554
|
+
// escalation the human reads (review #6, F11 misfiling: a spine that says
|
|
555
|
+
// interpreter-red while the escalation says provider-red is two
|
|
556
|
+
// instruments disagreeing about the same event).
|
|
557
|
+
const err = /** @type {CategorizedError} */ (e);
|
|
558
|
+
const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
|
|
559
|
+
stepOutcomes.push({ id: step.id, outcome: category });
|
|
560
|
+
if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
|
|
561
|
+
emit('escalation', {
|
|
562
|
+
category, decisionReady: true, phase: `step:${step.id}`,
|
|
563
|
+
decision: category === 'cap-halt'
|
|
564
|
+
? `The budget gate tripped while building step "${step.id}" — the wallet cannot fund the plan flow.`
|
|
565
|
+
: `Step "${step.id}" could not be set up (${category}) — the worker, gate, or index failed before the step ran.`,
|
|
566
|
+
options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['fix the interpreter/environment', 'retry the run', 'abandon the run'],
|
|
567
|
+
detail: String(err?.message ?? err),
|
|
568
|
+
...(typeof err?.lib === 'string' ? { lib: err.lib } : {}),
|
|
569
|
+
});
|
|
570
|
+
planExecuted();
|
|
571
|
+
return category;
|
|
572
|
+
}
|
|
573
|
+
stepOutcomes.push({ id: step.id, outcome: res.outcome });
|
|
574
|
+
emit('step-end', { step: step.id, outcome: res.outcome });
|
|
575
|
+
if (isUnpriced()) { planExecuted(); return 'pricing-red'; } // F6: a step round came back unpriced — halt before the next
|
|
576
|
+
if (res.outcome === 'green') {
|
|
577
|
+
artifacts.push({ id: step.id, text: res.artifact });
|
|
578
|
+
idx += 1;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// ONE replan, and only for EXHAUSTION with FUNDS LEFT (review #5): ralph
|
|
582
|
+
// emits cap-halt for BOTH attempt-exhaustion AND a money-gate halt
|
|
583
|
+
// mid-attempt — but a drained wallet is a stop, not an adaptation. A
|
|
584
|
+
// money-gate halt necessarily drained the wallet (the worker's cap WAS the
|
|
585
|
+
// whole remaining wallet), so replanning against it burns another draft and
|
|
586
|
+
// mislabels the money-cut as "exits still red" (F45 class); attempt-
|
|
587
|
+
// exhaustion leaves money on the table. An instrument/governance stop that
|
|
588
|
+
// is not cap-halt never replans either. (An unpriced step already returned
|
|
589
|
+
// pricing-red at the step-end guard above, so isUnpriced() is false here.)
|
|
590
|
+
const cat = lastEscalation?.category;
|
|
591
|
+
if (!replanned && cat === 'cap-halt' && remainingUsd() > MONEY_MIN) {
|
|
592
|
+
replanned = true;
|
|
593
|
+
emit('replan', { step: step.id, reason: 'step exhausted its attempts with exits still red' });
|
|
594
|
+
const failure = `Step "${step.id}" (${step.action}) ran ${capRuns} attempts and its exits were still red. `
|
|
595
|
+
+ `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
|
|
596
|
+
+ `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
|
|
597
|
+
let pv;
|
|
598
|
+
try {
|
|
599
|
+
pv = await obtainPlan('replan', failure);
|
|
600
|
+
} catch (e) {
|
|
601
|
+
planExecuted();
|
|
602
|
+
return relay(e, 'replan');
|
|
603
|
+
}
|
|
604
|
+
if (!pv.ok) {
|
|
605
|
+
for (const r of pv.reds) emit('plan-red', r);
|
|
606
|
+
planExecuted();
|
|
607
|
+
return 'plan-red';
|
|
608
|
+
}
|
|
609
|
+
plan = /** @type {any} */ (pv.plan);
|
|
610
|
+
emit('plan-accepted', { plan, phase: 'replan' });
|
|
611
|
+
// the new plan's steps are NOT the old plan's steps — its abandoned greens
|
|
612
|
+
// must not ride forward as this plan's "prior steps' results" (stale, and
|
|
613
|
+
// it names steps the current plan does not contain)
|
|
614
|
+
artifacts.length = 0;
|
|
615
|
+
idx = 0;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
// A money-gate halt (wallet drained) is an honest cap-halt terminal, never a
|
|
619
|
+
// step-red: the exits never ran because the money ran out, not because the
|
|
620
|
+
// work failed. Attempt-exhaustion WITH funds after the one replan is spent
|
|
621
|
+
// stays a step-red (the stop is a result).
|
|
622
|
+
if (cat === 'cap-halt') {
|
|
623
|
+
planExecuted();
|
|
624
|
+
return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
|
|
625
|
+
}
|
|
626
|
+
// Any OTHER terminal escalation category is NOT a capability failure: a
|
|
627
|
+
// provider-red is a transport CASUALTY, a gate-red/interpreter-red/close
|
|
628
|
+
// fault is an instrument stop. Each rides out under its OWN name (F11: the
|
|
629
|
+
// returned outcome and the emitted escalation must agree) so run.js labels
|
|
630
|
+
// it honestly — a provider-red carries the F44 spendComplete:false floor,
|
|
631
|
+
// never laundered into step-red tier data. Mirrors the setup-fault catch.
|
|
632
|
+
planExecuted();
|
|
633
|
+
return typeof cat === 'string' ? cat : `step-red:${step.id}`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── 4. THE CLOSE — the operator's signed command, the only truth. Red →
|
|
637
|
+
// the gap feeds ONE bounded fix loop judged by the REAL close (v1.12 §4);
|
|
638
|
+
// still red → the escalation ralph already emitted stands.
|
|
639
|
+
const post = runClose(closeArgv, scrub, closeOpts);
|
|
640
|
+
emit('outer-close', { ...post });
|
|
641
|
+
if (post.verdict === 'satisfied') {
|
|
642
|
+
planExecuted();
|
|
643
|
+
return 'green';
|
|
644
|
+
}
|
|
645
|
+
const postFault = Object.hasOwn(CLOSE_FAULTS, post.verdict) ? CLOSE_FAULTS[post.verdict] : undefined;
|
|
646
|
+
if (postFault) {
|
|
647
|
+
emit('escalation', { category: postFault.category, decisionReady: true, decision: postFault.decision, options: postFault.options, detail: post.detail });
|
|
648
|
+
planExecuted();
|
|
649
|
+
return 'close-red';
|
|
650
|
+
}
|
|
651
|
+
emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
|
|
652
|
+
let fixOutcome;
|
|
653
|
+
try {
|
|
654
|
+
const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true });
|
|
655
|
+
/** @param {number} iteration @param {string} [gap] */
|
|
656
|
+
const middle = async (iteration, gap) => {
|
|
657
|
+
w.setIteration(iteration);
|
|
658
|
+
await w.ask([
|
|
659
|
+
'The job\'s final verification is failing. Fix the repository so it passes.',
|
|
660
|
+
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
661
|
+
artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
|
|
662
|
+
!gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
|
|
663
|
+
gap && `Previous attempt failed the verification:\n${gap}`,
|
|
664
|
+
].filter(Boolean).join('\n\n'));
|
|
665
|
+
};
|
|
666
|
+
fixOutcome = await ralph({
|
|
667
|
+
middle, close: closeArgv, capRuns, emit: emitL, redact: scrub,
|
|
668
|
+
closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep,
|
|
669
|
+
workerWrites: w.workerWrites,
|
|
670
|
+
});
|
|
671
|
+
} catch (e) {
|
|
672
|
+
planExecuted();
|
|
673
|
+
return relay(e, 'fix');
|
|
674
|
+
}
|
|
675
|
+
planExecuted();
|
|
676
|
+
return fixOutcome === 'green' ? 'green' : 'escalated';
|
|
677
|
+
}
|