bareloop 0.5.1 → 0.7.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 +1129 -1
- package/README.md +40 -13
- package/bareloop.context.md +799 -245
- package/package.json +3 -3
- package/src/bridges.js +571 -0
- package/src/clock.js +233 -0
- package/src/exits.js +8 -2
- package/src/index.js +52 -17
- package/src/job.js +231 -108
- package/src/ladder.js +196 -0
- package/src/ledger.js +55 -3
- package/src/plan.js +334 -20
- package/src/planrun.js +1519 -134
- package/src/ralph.js +373 -34
- package/src/reuse.js +1602 -0
- package/src/root.js +32 -12
- package/src/run.js +129 -391
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/trend.js +405 -0
- package/src/validate.js +35 -250
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +131 -0
- package/types/index.d.ts +7 -6
- package/types/job.d.ts +83 -8
- package/types/ladder.d.ts +86 -0
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +86 -7
- package/types/planrun.d.ts +142 -5
- package/types/ralph.d.ts +143 -7
- package/types/reuse.d.ts +388 -0
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +56 -17
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/trend.d.ts +108 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/planrun.js
CHANGED
|
@@ -14,20 +14,25 @@
|
|
|
14
14
|
// (fs.deny on the gate audit / .smoke / .litectx, unchanged).
|
|
15
15
|
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
|
-
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
|
-
import { join, resolve } from 'node:path';
|
|
20
|
-
import { Gate
|
|
19
|
+
import { join, resolve, relative } from 'node:path';
|
|
20
|
+
import { Gate } from 'bareguard';
|
|
21
21
|
import { LiteCtx } from 'litectx';
|
|
22
|
-
import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
|
|
23
|
-
import { validatePlan } from './plan.js';
|
|
24
|
-
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
|
|
22
|
+
import { runClose, runStages, ralph, CLOSE_FAULTS } from './ralph.js';
|
|
23
|
+
import { validatePlan, legalScopes, stageClose } from './plan.js';
|
|
24
|
+
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU, STEP_MODELS } from './plan.js';
|
|
25
25
|
import { snapshotScope, evalExits } from './exits.js';
|
|
26
26
|
import { createRoot } from './root.js';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
27
|
+
import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
|
|
28
|
+
import { createLadder, STRIKE_LIMIT } from './ladder.js';
|
|
29
|
+
import { createTrend, FIX_STRIKE_LIMIT } from './trend.js';
|
|
30
|
+
import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
|
|
31
|
+
import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
|
|
32
|
+
import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
|
|
33
|
+
import { validateBridge, loadGate } from './bridges.js';
|
|
30
34
|
import { extractArtifact } from './text.js';
|
|
35
|
+
import { createClock, isWallTimeout } from './clock.js';
|
|
31
36
|
|
|
32
37
|
const require = createRequire(import.meta.url);
|
|
33
38
|
const { Loop, wireGate, HaltError } = require('bare-agent');
|
|
@@ -50,6 +55,11 @@ const SCOUT_BLOB_MAX = 8000;
|
|
|
50
55
|
const NATIVE_READ_CAP = 24 * 1024;
|
|
51
56
|
/** native-only strategy: tell the worker WHY whole-file reads fail here and to navigate by symbol */
|
|
52
57
|
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.';
|
|
58
|
+
/** F59 — below this a scout survey is treated as absent. Sized off the archive, not
|
|
59
|
+
* taste: the 15 truncated scouts produced 0/74/86 bytes; every real survey was
|
|
60
|
+
* 5991-8056. 200 sits between the two populations with an order of magnitude of
|
|
61
|
+
* daylight on each side, so it cannot be a tuned-to-one-case threshold. */
|
|
62
|
+
const SCOUT_MIN_BYTES = 200;
|
|
53
63
|
/** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
|
|
54
64
|
const ARTIFACT_MAX = 2000;
|
|
55
65
|
/** the wallet floor below which a replan is a stop, not an adaptation (review
|
|
@@ -57,9 +67,132 @@ const ARTIFACT_MAX = 2000;
|
|
|
57
67
|
* just burns another draft and mislabels the money-cut as "exits still red"
|
|
58
68
|
* (F45 class) — the honest terminal there is cap-halt. */
|
|
59
69
|
const MONEY_MIN = 0.001;
|
|
70
|
+
/**
|
|
71
|
+
* A (PRD v1.27; design record §3.4, answered §6.2) — the replan trigger. A step
|
|
72
|
+
* that has consumed this share of the run's REMAINING money or time with its exits
|
|
73
|
+
* still red gets no further attempt; the planner re-allocates what is left instead.
|
|
74
|
+
*
|
|
75
|
+
* 50% is hamr's number (*"50% is fine"*), and it was **re-confirmed with its own
|
|
76
|
+
* inertness on the table**: F63 replayed 18 archived spines / 54 steps / 101 judged
|
|
77
|
+
* attempts and found it would have fired 0 times (near misses 0.35 · 0.35 · 0.40 ·
|
|
78
|
+
* 0.45). Shown that, hamr's call was *"keep 0.5"* (2026-07-26).
|
|
79
|
+
*
|
|
80
|
+
* So this is a GUARD against a step that eats the run, deliberately set above the
|
|
81
|
+
* observed population rather than tuned to it — lowering it to 0.35 because that is
|
|
82
|
+
* where those four points sit would be fitting the number to the data (the standing
|
|
83
|
+
* no-fit-to-pass rule), and threshold-setting is arbiter territory either way. It
|
|
84
|
+
* has not hardened by surviving unexamined; it survived being examined.
|
|
85
|
+
*
|
|
86
|
+
* This changes the replan TRIGGER only. The hard ceiling of ONE replan is
|
|
87
|
+
* unchanged — unlimited replanning launders thrash as adaptation (v1.12).
|
|
88
|
+
*/
|
|
89
|
+
const VARIANCE_THRESHOLD = 0.5;
|
|
60
90
|
|
|
61
91
|
/** @typedef {Error & {category?: string, lib?: string}} CategorizedError */
|
|
62
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The materials block: what the run has to spend, as a BALANCE and nothing else.
|
|
95
|
+
*
|
|
96
|
+
* hamr's correction (design record addendum 2, verbatim): *"why would you tell it
|
|
97
|
+
* every round that you have x time per round? what if there is a heavier round?
|
|
98
|
+
* why don't you tell it same like cost, you have x left from cost/time instead of
|
|
99
|
+
* making it race against time?"*
|
|
100
|
+
*
|
|
101
|
+
* So: NO rate, NO per-round allowance, NO derived round count. F57 measured a 150x
|
|
102
|
+
* spread on the verification gaps (3.8s to 561s), so a per-round constant
|
|
103
|
+
* describes almost no real round — a heavy round would break the planner's
|
|
104
|
+
* arithmetic with nothing to tell it. A balance is self-correcting: after a heavy
|
|
105
|
+
* round the number is simply lower. A rate is also a stopwatch, and racing a clock
|
|
106
|
+
* is the rush-or-fake incentive the v1.12 §5 prompt contract exists to remove.
|
|
107
|
+
*
|
|
108
|
+
* At DRAFT the balance is the total, so this is scale only — the planner is NOT
|
|
109
|
+
* asked to allocate time, because sizing time at draft requires the rate that was
|
|
110
|
+
* just removed. A plan that does not fit is what the meter is for (addendum 2).
|
|
111
|
+
* @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [m]
|
|
112
|
+
*/
|
|
113
|
+
function materialsBlock(m) {
|
|
114
|
+
if (!m) return '';
|
|
115
|
+
const lines = [];
|
|
116
|
+
if (typeof m.balanceUsd === 'number') lines.push(`- money left for the whole run: $${m.balanceUsd.toFixed(2)}`);
|
|
117
|
+
if (typeof m.remainingMs === 'number') lines.push(`- time left for the whole run: ${Math.round(m.remainingMs / 60_000)} minutes`);
|
|
118
|
+
if (!lines.length) return '';
|
|
119
|
+
// The progress line only exists at REPLAN: it is the variance the meter read,
|
|
120
|
+
// and it is the adaptation channel (addendum 2). At draft there is no progress.
|
|
121
|
+
if (m.progress) lines.push(`- where the run got to: ${m.progress}`);
|
|
122
|
+
return `\nWhat this run has left to spend — plan within it:\n${lines.join('\n')}\nThese are totals for the run, not per-step allowances. Nothing here is a rate: a step's cost depends on what it does.\n`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Layer 3 (D4) — the MECHANICAL START. A loaded bridge is handed to the drafter as a
|
|
127
|
+
* STARTING DRAFT, not as a contract: it is appended to the ordinary drafting prompt and
|
|
128
|
+
* the result passes the ORDINARY validator. No second, looser path exists for an
|
|
129
|
+
* inherited plan.
|
|
130
|
+
*
|
|
131
|
+
* The framing is the pre-probe's arm C (`scripts/reuse-preprobe.mjs` ARM_FRAME.C),
|
|
132
|
+
* carried through the execution probe (`scripts/reuse-exec-probe.mjs` C_BLOCK) that
|
|
133
|
+
* greened this job shape end to end. It is reproduced VERBATIM because that exact text
|
|
134
|
+
* is what the draft-tier read and the execution kill-gate were measured against —
|
|
135
|
+
* reworded, this would be a different arm than the one that passed.
|
|
136
|
+
*
|
|
137
|
+
* ONE token is not verbatim, and deliberately: the probe's job had FOUR close stages and
|
|
138
|
+
* said so. A prompt that tells a two-stage job it shares "the same four close stages" is
|
|
139
|
+
* a falsehood handed to the planner, so the count is read off the job's own staged close
|
|
140
|
+
* (which the load gate has already proven equal to the bridge's). The small numbers are
|
|
141
|
+
* spelled, so on the four-stage shape the probe validated this renders byte-identical to
|
|
142
|
+
* the text it validated.
|
|
143
|
+
* @param {number} n how many stages both closes have
|
|
144
|
+
* @param {object} plan the bridge version's plan, as executed
|
|
145
|
+
*/
|
|
146
|
+
function startingDraftBlock(n, plan) {
|
|
147
|
+
const count = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'][n] ?? String(n);
|
|
148
|
+
return `
|
|
149
|
+
|
|
150
|
+
YOUR STARTING DRAFT — begin from the plan below and tweak it.
|
|
151
|
+
This plan greened a same-shape job (the same ${count} close stages) on a DIFFERENT repository. Take it
|
|
152
|
+
as your starting draft rather than starting from a blank page: keep what carries over, change what
|
|
153
|
+
this repository needs. Everything you submit must be legal for THIS job as described above — its
|
|
154
|
+
paths, scopes, verbs, bounds and check names. Output the finished plan as the single JSON object
|
|
155
|
+
required above, and nothing else.
|
|
156
|
+
|
|
157
|
+
${JSON.stringify(plan, null, 2)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Files named in an exit gap that the step's worker never wrote — the mechanical
|
|
162
|
+
* fact behind the prose-prohibition trap (u-msdpuaej, 2026-08-03): a drafter wrote
|
|
163
|
+
* "do not modify any file outside X" into a step whose exit check judges the whole
|
|
164
|
+
* goal, the worker obeyed, and the replanner — handed the gap AND the stall note —
|
|
165
|
+
* rebuilt the same shape, because inferring "the exit reds on files this step never
|
|
166
|
+
* touched" is exactly the semantic leap that fails (F38: mechanical gaps convert,
|
|
167
|
+
* semantic ones stall; 3/3 across the mailbox class). This computes that fact from
|
|
168
|
+
* the two books that already exist: path-like tokens in the gap text (extension
|
|
169
|
+
* required — a bare `step/two` is not a file claim) suffix-matched against the F32
|
|
170
|
+
* write audit's absolute paths. Advisory-only and fail-safe: extraction that finds
|
|
171
|
+
* nothing yields no line, never a guess.
|
|
172
|
+
*
|
|
173
|
+
* @param {string | undefined} gap the step's last exit gap text
|
|
174
|
+
* @param {string[] | undefined} writes the step's cumulative write-audit paths
|
|
175
|
+
* @returns {string[]} gap-named files with no matching write, in gap order
|
|
176
|
+
*/
|
|
177
|
+
export function gapFilesNeverWritten(gap, writes) {
|
|
178
|
+
if (typeof gap !== 'string' || gap === '') return [];
|
|
179
|
+
const tokens = gap.match(/[\w.-]+(?:\/[\w.-]+)+/g) ?? [];
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const raw of tokens) {
|
|
183
|
+
const p = raw.replace(/^\.\//, '');
|
|
184
|
+
if (!/\.[A-Za-z0-9]+$/.test(p.split('/').at(-1) ?? '')) continue;
|
|
185
|
+
if (seen.has(p)) continue;
|
|
186
|
+
seen.add(p);
|
|
187
|
+
const written = (writes ?? []).some((w) => {
|
|
188
|
+
const ws = String(w);
|
|
189
|
+
return ws === p || ws.endsWith(`/${p}`) || p.endsWith(`/${ws}`);
|
|
190
|
+
});
|
|
191
|
+
if (!written) out.push(p);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
63
196
|
/**
|
|
64
197
|
* The plan-drafting prompt: a schema DESCRIPTION built from the live validator
|
|
65
198
|
* menus — never a copyable example (the drafter must author, not echo; the
|
|
@@ -67,10 +200,50 @@ const MONEY_MIN = 0.001;
|
|
|
67
200
|
* arbiter territory the planner never sees.
|
|
68
201
|
* @param {any} job @param {string} scoutBlob @param {any[]|null} reds
|
|
69
202
|
* @param {number} maxStepRounds @param {string|null} failure replan context
|
|
203
|
+
* @param {string[]|undefined} scopes the offered `tree-changed` menu (`legalScopes`).
|
|
204
|
+
* Choose-don't-describe (§4): the agent picks a value, never authors a glob and
|
|
205
|
+
* guesses its shape. Omitted, it derives from the signed fence — the SAME
|
|
206
|
+
* fail-closed derivation `validatePlan` uses, so prompt and validator can never
|
|
207
|
+
* disagree about what was offered.
|
|
208
|
+
* @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
|
|
209
|
+
* what the run has LEFT (T/A). Omitted → no materials block at all, which is the
|
|
210
|
+
* pre-T behaviour and keeps every existing caller byte-identical.
|
|
211
|
+
* @param {object|null} [startingDraft] Layer 3 (D4) — a loaded bridge's plan, handed over
|
|
212
|
+
* as the draft to TWEAK. Omitted/null is the COLD path and renders byte-identically to
|
|
213
|
+
* the pre-Layer-3 prompt: the block is additive, never surgery on the prompt's interior,
|
|
214
|
+
* so the two paths cannot drift (the F47 works-both-ways rule). Only the runner passes
|
|
215
|
+
* it, and only for the FIRST draft phase — a replan drafts from the run's own state.
|
|
216
|
+
* @param {{ seedRed?: string[], priorChecks?: string[] }} [checkFacts] the shape-lottery
|
|
217
|
+
* gate rules, STATED as well as enforced (the mailbox precedent: a rule only the
|
|
218
|
+
* validator knows costs a redraft per draft). `seedRed`: preflight-red check names —
|
|
219
|
+
* Rule A-v2's law (final write step only, framed wide). `priorChecks`: the predecessor
|
|
220
|
+
* plan's carried checks, replan phase only — Rule B's law (keep every one). Empty/omitted
|
|
221
|
+
* renders byte-identically to the pre-rules prompt.
|
|
70
222
|
*/
|
|
71
|
-
function planPrompt(job, scoutBlob, reds, maxStepRounds, failure) {
|
|
223
|
+
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}) {
|
|
224
|
+
const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
|
|
72
225
|
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
73
|
-
|
|
226
|
+
// The shape-lottery laws, stated where the check menu is offered (the mailbox
|
|
227
|
+
// precedent: a law only the validator knows costs a redraft per draft). Both
|
|
228
|
+
// render only when their FACT arrived — empty facts keep the prompt
|
|
229
|
+
// byte-identical to the pre-rules render.
|
|
230
|
+
const seedRedLaw = Array.isArray(checkFacts.seedRed) && checkFacts.seedRed.length
|
|
231
|
+
? `\n Check(s) RED at seed right now: ${JSON.stringify(checkFacts.seedRed)}. A failing check
|
|
232
|
+
is the goal itself, not a milestone: it may ONLY appear on your plan's FINAL write step.
|
|
233
|
+
Make that step wide — its action covers the whole goal, free to edit any file it reports,
|
|
234
|
+
iterating until the check passes. A failing check on any earlier step is rejected.`
|
|
235
|
+
: '';
|
|
236
|
+
const priorChecksLaw = Array.isArray(checkFacts.priorChecks) && checkFacts.priorChecks.length
|
|
237
|
+
? `\n Your previous plan carried these check-passes exits: ${JSON.stringify(checkFacts.priorChecks)}.
|
|
238
|
+
Keep every one of them in this new plan — a redraft that drops one of them is rejected:
|
|
239
|
+
the other exit forms verify form only, and a form-only green is unearned.`
|
|
240
|
+
: '';
|
|
241
|
+
// W4: the menu comes off the STAGED close — the same one derivation `runPlan`
|
|
242
|
+
// executes and `validatePlan` accepts against. Reading `job.close` raw here left
|
|
243
|
+
// the legacy object form offering nothing while the runner preflighted a stage.
|
|
244
|
+
// (`?? []`: a close naming no command stages nothing, so it offers nothing)
|
|
245
|
+
const closeStages = stageClose(job.close) ?? [];
|
|
246
|
+
const checkNames = checkMenu(closeStages).map((m) => m.name);
|
|
74
247
|
const doc = `DRAFT-PLAN
|
|
75
248
|
You are planning how to accomplish a goal in a repository, as an ordered list of bounded
|
|
76
249
|
steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
|
|
@@ -81,31 +254,61 @@ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] }
|
|
|
81
254
|
strictly in array order. Each step (no other fields exist):
|
|
82
255
|
- "id": kebab-case slug, unique
|
|
83
256
|
- "action": the step's task, precise enough for a worker that sees ONLY this step
|
|
84
|
-
- "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit
|
|
257
|
+
- "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)
|
|
85
258
|
- "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
|
|
86
259
|
- "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
|
|
260
|
+
- "model" (optional): ${STEP_MODELS.join(' | ')} — a cheaper tier for mechanical steps; omit for the default
|
|
261
|
+
- "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
|
|
262
|
+
A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
|
|
263
|
+
inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
|
|
264
|
+
CONTAINS it is fine) — otherwise every write the step could make is denied, or the
|
|
265
|
+
exit can never pass, on every attempt.
|
|
87
266
|
- "exit": 1..${MAX_EXITS_PER_STEP} form checks that ALL must pass (AND), each one of:
|
|
88
267
|
{"type":"artifact-written","path":"...","pattern":"optional regex"}
|
|
89
|
-
{"type":"tree-changed","scope":"
|
|
268
|
+
{"type":"tree-changed","scope":"<copy one value from the offered scopes below>"}
|
|
90
269
|
{"type":"json-valid","path":"..."}
|
|
91
|
-
{"type":"check-passes","name":"one
|
|
270
|
+
{"type":"check-passes","name":"<copy one name from the check menu below>"}
|
|
271
|
+
Check menu — a check-passes "name" is exactly one of ${JSON.stringify(checkNames)}; copy
|
|
272
|
+
it character for character. No other name exists, and you cannot add one.
|
|
92
273
|
A check-passes on a write-granted step MUST be paired with a tree-changed exit
|
|
93
274
|
(the repository starts green — a lone check would pass on the untouched tree).
|
|
275
|
+
A check-passes may ONLY appear on a step whose tools include write or edit: a
|
|
276
|
+
failing check is reported back to THAT step's worker for another attempt, so
|
|
277
|
+
the step must be able to act on the report. Do not plan a separate read-only
|
|
278
|
+
"verify" step — attach the check to the step that does the fixing; the final
|
|
279
|
+
verification after your plan is not yours to author.
|
|
280
|
+
A check judges the WHOLE goal, never just this step's deliverable: its report
|
|
281
|
+
can name files beyond the step's target. A step carrying a check-passes exit
|
|
282
|
+
must therefore be free to edit every file the check can report on — never
|
|
283
|
+
write an action that forbids editing other in-scope files (e.g. "do not
|
|
284
|
+
modify any file outside X"): a step ordered to leave a file alone while its
|
|
285
|
+
exit reds on that file can never finish, on any attempt.${seedRedLaw}${priorChecksLaw}
|
|
94
286
|
Reference checks by NAME only; you cannot author or modify one.
|
|
95
287
|
|
|
288
|
+
Offered "scope" values for tree-changed — copy ONE of these exactly, character for
|
|
289
|
+
character. No other value is accepted, and patterns of your own (like "src/*.js")
|
|
290
|
+
are not accepted:
|
|
291
|
+
${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
|
|
292
|
+
|
|
96
293
|
Goal:
|
|
97
294
|
${job.goal}
|
|
98
|
-
|
|
295
|
+
${materialsBlock(materials)}
|
|
99
296
|
Repository survey (from a read-only scout):
|
|
100
297
|
${scoutBlob || '(no scout notes)'}`;
|
|
101
298
|
let p = doc;
|
|
299
|
+
// BEFORE the failure/reds appendices, so the last word to a redrafting planner stays
|
|
300
|
+
// the validator's "fix every red". The execution probe injected at the provider seam
|
|
301
|
+
// and could only append at the very end; it recorded that ordering as its own known
|
|
302
|
+
// wart. Owning the prompt is what lets it be fixed — and on the first draft (no
|
|
303
|
+
// failure, no reds) the render is byte-identical to the probe's.
|
|
304
|
+
if (startingDraft) p += startingDraftBlock(closeStages.length, startingDraft);
|
|
102
305
|
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.`;
|
|
103
306
|
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.`;
|
|
104
307
|
return p;
|
|
105
308
|
}
|
|
106
309
|
|
|
107
310
|
/**
|
|
108
|
-
* Execute a validateJob-GREEN plan-shape job (goal/verdictType/close
|
|
311
|
+
* Execute a validateJob-GREEN plan-shape job (goal/verdictType/close).
|
|
109
312
|
* Called by runJob after the approval gate, validation, and the smoke — this
|
|
110
313
|
* function owns the plan flow only; the caller owns the job-end record and the
|
|
111
314
|
* one ledger (every provider round is emitted here as `worker-round`, which
|
|
@@ -126,9 +329,25 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
126
329
|
* @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
|
|
127
330
|
* @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
|
|
128
331
|
* plan flow bails IN-FLIGHT on the first unpriced round instead of burning the whole plan
|
|
129
|
-
* @param {
|
|
332
|
+
* @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
|
|
333
|
+
* (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
|
|
334
|
+
* interpreter-red STOP — never silently run the default tier as if the choice was honoured.
|
|
335
|
+
* @param {number} [opts.capRuns] shell-owned iteration cap, RETIRED as a governor
|
|
336
|
+
* (v1.46 §4). It stopped bounding a plan step when the strike ladder landed, and it
|
|
337
|
+
* stopped bounding the close-fix loop when the close trend rule did. It survives as
|
|
338
|
+
* that loop's fallback for the ONE case the trend cannot read — a close whose output
|
|
339
|
+
* carries no number at all — because a governor that cannot see the variable must
|
|
340
|
+
* not be the governor, and the honest fallback is the cruder bound it replaced
|
|
341
|
+
* rather than "unbounded". It lifts the moment a stage reports a comparable number.
|
|
342
|
+
* @param {number} [opts.strikeLimit] shell-owned STRIKE ceiling for a plan step's
|
|
343
|
+
* ladder (src/ladder.js). Arbiter territory exactly as the count it replaced was —
|
|
344
|
+
* the runner sets it, the plan cannot express it, and no step may tighten or raise
|
|
345
|
+
* it.
|
|
130
346
|
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
131
347
|
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
348
|
+
* @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
|
|
349
|
+
* reserved — a scout that spends every round on tools gets one toolless round to write its
|
|
350
|
+
* survey, because the bound halts it mid-tool-use and text is its only deliverable)
|
|
132
351
|
* @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
|
|
133
352
|
* scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
|
|
134
353
|
* the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
|
|
@@ -138,13 +357,86 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
138
357
|
* first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
|
|
139
358
|
* read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
|
|
140
359
|
* worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
|
|
360
|
+
* @param {unknown} [opts.bridge] Layer 3 (D3/D4) — a bridge-v1 registry entry to REUSE as the
|
|
361
|
+
* drafter's starting point. Absent is the COLD path and is byte-identical to the pre-Layer-3
|
|
362
|
+
* flow. Present, the D2-split LOAD GATE runs at the door, before the close precheck and
|
|
363
|
+
* before any token: on a pass the newest version's plan (the run-as-executed artifact)
|
|
364
|
+
* rides into the FIRST draft prompt only; on a fail the run returns the distinct terminal
|
|
365
|
+
* `recipe-stale` having spent nothing. Selecting the entry — the listing, the pin, the
|
|
366
|
+
* LLM's pick — is the CALLER's, and so is falling back to cold: a silent automatic
|
|
367
|
+
* fall-back would spend a run's budget on a decision nobody made.
|
|
368
|
+
* @param {number} [opts.priorWallMs=0] RESUME (module C) — wall time a PREVIOUS, killed
|
|
369
|
+
* attempt of this same try already consumed, folded into the run clock (see
|
|
370
|
+
* `createClock`'s `priorElapsedMs`). The SIGNED `maxWallMs` is never rewritten: it is in
|
|
371
|
+
* the spec hash, and a resume that edited it would need a signature nobody typed. The
|
|
372
|
+
* restarted attempt simply starts already partly spent, so a kill can never buy a fresh
|
|
373
|
+
* allotment of the operator's wall.
|
|
374
|
+
* @param {any} [opts.resumeSeed] RESUME (module C v2) — the FINEST honest checkpoint of a
|
|
375
|
+
* KILLED run of this same try, read off its own spine by `readResume` (src/reuse.js) and
|
|
376
|
+
* by nothing else: `{ phase, plan, completedSteps: [{id, seq, by}] }`. hamr's ruling is
|
|
377
|
+
* the whole contract — *"it should allow resume and start last step instead from the
|
|
378
|
+
* beginning, why would i want to waste more money on something i already started"* — so
|
|
379
|
+
* with a seed this flow re-pays for NO paid unit the dead run already bought:
|
|
380
|
+
*
|
|
381
|
+
* - the SCOUT does not run (`scout-skipped`, never silence). Its survey is not on the
|
|
382
|
+
* spine (only its byte count is), so the ONE consequence is stated rather than
|
|
383
|
+
* discovered: a replan after a resume drafts from an empty survey plus the failure
|
|
384
|
+
* brief. Re-scouting to refill it would re-pay the exact call this exists to save.
|
|
385
|
+
* - the PLAN is not re-drafted. It is reloaded from the seed and RE-VALIDATED against
|
|
386
|
+
* the current signed spec; a plan that no longer validates is `plan-red` by name
|
|
387
|
+
* (`resume-plan-red`), never run. It is then emitted as this leg's own
|
|
388
|
+
* `plan-accepted` (`phase: 'resume'`) — R1 mints a bridge version from the plan AS
|
|
389
|
+
* EXECUTED, read back off the spine, so a leg that greens without one would mint
|
|
390
|
+
* nothing.
|
|
391
|
+
* - a step whose exit was already SATISFIED is skipped, in prefix order, each with its
|
|
392
|
+
* own `step-skipped` record naming the event that proves it. Never a fake
|
|
393
|
+
* step-start/step-end pair, and never silence: a reader must be able to tell skipped
|
|
394
|
+
* from run (the resume-to-cap invariant).
|
|
395
|
+
*
|
|
396
|
+
* The worker's conversation is deliberately NOT replayed — a transcript is not a
|
|
397
|
+
* checkpoint, and every attempt is fresh by the loop's own design. Absent is the
|
|
398
|
+
* ordinary path and is byte-identical to a run that was never killed.
|
|
399
|
+
* @param {{stage?: string|null, value?: number|null}[]} [opts.resumeGrades] RESUME — the
|
|
400
|
+
* close GRADES the dead leg recorded, oldest first (`readResume`'s `restart.grades`).
|
|
401
|
+
* A resumed leg is the same run continuing, so its halt readouts must judge the whole
|
|
402
|
+
* chain: without this the trend restarts at this leg's first close and can report
|
|
403
|
+
* `flat` — "revise the goal" — on a run that was converging when its allowance ran
|
|
404
|
+
* out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
|
|
405
|
+
* bounds and mints none of its strikes. Empty/omitted is the cold path.
|
|
406
|
+
* @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
|
|
407
|
+
* ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
|
|
408
|
+
* cut, an inherited resume floor); the money-halt readout states it beside the remaining
|
|
409
|
+
* it quotes, because `budget − floor` is a CEILING and printing it bare reads as exact
|
|
410
|
+
* (F6). Defaults to exact, which is what every pre-record caller was implicitly saying.
|
|
411
|
+
* @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
|
|
412
|
+
* default; a caller supplies this to drive time deterministically (the same seam `createClock`
|
|
413
|
+
* already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
|
|
414
|
+
* whose floor is one close timeout).
|
|
141
415
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
142
|
-
* 'check-red' | 'close-red' | 'close-unsupported' | '
|
|
143
|
-
* 'provider-red' | 'interpreter-red' |
|
|
416
|
+
* 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
|
|
417
|
+
* 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
|
|
418
|
+
* `step-red:<id>`
|
|
144
419
|
*/
|
|
145
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false }) {
|
|
420
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [] }) {
|
|
146
421
|
workdir = resolve(workdir);
|
|
147
|
-
|
|
422
|
+
// ONE spelling of the redaction, housed next to the inventory it reads
|
|
423
|
+
// (src/validate.js) — the same helper the isolate verbs scrub the litectx store
|
|
424
|
+
// with. Two hand-spelled copies of "what a secret looks like" is exactly how
|
|
425
|
+
// detection and redaction drift apart.
|
|
426
|
+
const scrub = redactSecrets;
|
|
427
|
+
// The ctx verbs' spine channel, scrubbed at the WIRING. Every field a ctx-tool
|
|
428
|
+
// event carries is MODEL-CHOSEN text — a recall query, a stash id, a symbol, a
|
|
429
|
+
// path the worker spelled — and the spine is append-only: a log that captures a
|
|
430
|
+
// key captures it forever. Scrubbing the emitter (once, at construction) rather
|
|
431
|
+
// than the seventeen call sites is what makes the leak inexpressible: a new ctx
|
|
432
|
+
// verb cannot forget it. Same ONE inventory (SECRET_PATTERNS) the close output
|
|
433
|
+
// is scrubbed with — never a second spelling of what a secret looks like.
|
|
434
|
+
// Shallow by shape: the events are flat records of strings, string arrays
|
|
435
|
+
// (`paths`) and numbers, and numbers pass through untouched.
|
|
436
|
+
const emitCtx = (/** @type {string} */ type, /** @type {any} */ data) => emit(type, Object.fromEntries(
|
|
437
|
+
Object.entries(data ?? {}).map(([k, v]) => [k, typeof v === 'string' ? scrub(v)
|
|
438
|
+
: (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? scrub(x) : x)) : v)]),
|
|
439
|
+
));
|
|
148
440
|
|
|
149
441
|
// Which worker surface? `clipipe-subscription` drives tools NATIVELY (the CLI
|
|
150
442
|
// owns the turn cycle, BA-16); every other provider runs the Loop. The close,
|
|
@@ -158,10 +450,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
158
450
|
// hard-class), and a gold close carries no `cmd`; running `close.cmd.trim()`
|
|
159
451
|
// on it would TypeError out of runJob with NO job-end (the spine would dangle,
|
|
160
452
|
// no spend recorded). Refuse a non-predicate close cleanly, before any tokens.
|
|
161
|
-
|
|
453
|
+
// A STAGED close (PRD v1.28) is an ordered list of command stages and is the
|
|
454
|
+
// go-forward shape; the object form survives only for the declared-but-locked
|
|
455
|
+
// verdict classes (gold/rubric/hitl), which name no command to run.
|
|
456
|
+
// W4: the staging itself lives in ONE place (`stageClose`), shared with
|
|
457
|
+
// `planPrompt` and `validatePlan` — a second copy here is exactly how the
|
|
458
|
+
// runner came to execute a stage neither of the other two could see.
|
|
459
|
+
const stagedClose = stageClose(job.close);
|
|
460
|
+
if (!stagedClose) {
|
|
162
461
|
emit('escalation', {
|
|
163
462
|
category: 'close-unsupported', decisionReady: true,
|
|
164
|
-
decision: `The job's close is a ${job.close.type} close — the plan flow executes
|
|
463
|
+
decision: `The job's close is a ${job.close.type} close — the plan flow executes commands whose exit codes are truth (a staged close, or a single predicate).`,
|
|
165
464
|
options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
|
|
166
465
|
});
|
|
167
466
|
return 'close-unsupported';
|
|
@@ -181,12 +480,237 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
181
480
|
return 'interpreter-red';
|
|
182
481
|
}
|
|
183
482
|
|
|
184
|
-
|
|
185
|
-
|
|
483
|
+
// ── THE BRIDGE LOAD GATE (Layer 3, D2 as SPLIT by the 2026-08-01 addendum).
|
|
484
|
+
// Asked AT THE DOOR: *"is this the right KIND of recipe?"* — verdict type, close-stage
|
|
485
|
+
// kinds, verbs within the signed menu, and NOTHING instance-bound (paths, scopes and
|
|
486
|
+
// targets are expected to be yesterday's bricks; the full validatePlan judges the
|
|
487
|
+
// TWEAKED draft at draft time, D4).
|
|
488
|
+
//
|
|
489
|
+
// It sits FIRST among the things that cost anything, and above the clock and the close
|
|
490
|
+
// precheck deliberately: the gate is $0 and deterministic while a precheck is real wall
|
|
491
|
+
// time (four stages at the shipped timeout is an hour). It sits BELOW the two guards
|
|
492
|
+
// above it because those say the JOB cannot run at all, which outranks "this recipe does
|
|
493
|
+
// not fit". Consequence, named rather than discovered later: a refused run never learns
|
|
494
|
+
// whether the tree was already green — the caller's cold rerun does.
|
|
495
|
+
//
|
|
496
|
+
// The entry is re-validated here even though the caller is contracted to pass a valid
|
|
497
|
+
// one. Not defensive decoration: the very next thing this code does is reach into
|
|
498
|
+
// `versions.at(-1).plan`, and a half-written file that reached a reader anyway must come
|
|
499
|
+
// back as named reds, never as a TypeError out of the runner (`loadRegistry` already
|
|
500
|
+
// skips-and-reports for the same reason).
|
|
501
|
+
let startingDraft = null;
|
|
502
|
+
if (bridge != null) {
|
|
503
|
+
const bv = validateBridge(bridge);
|
|
504
|
+
const gate = bv.ok ? loadGate(bv.bridge, job) : { ok: false, reds: bv.reds };
|
|
505
|
+
const name = typeof (/** @type {any} */ (bridge)?.name) === 'string' ? /** @type {any} */ (bridge).name : null;
|
|
506
|
+
if (!gate.ok) {
|
|
507
|
+
emit('bridge-gate', { outcome: 'recipe-stale', name, reds: gate.reds });
|
|
508
|
+
emit('escalation', {
|
|
509
|
+
category: 'recipe-stale', decisionReady: true,
|
|
510
|
+
decision: `The selected workflow${name ? ` "${name}"` : ''} is not the right KIND of recipe for this job, so it was refused before anything was spent. Reusing it is not a decision this run can make for you.`,
|
|
511
|
+
options: ['rerun COLD (draft a new plan from scratch)', 'select a different workflow from the registry', 'change the job spec so the shapes match (a spec edit — the new hash needs re-approval)'],
|
|
512
|
+
detail: gate.reds.map((/** @type {any} */ r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
|
|
513
|
+
});
|
|
514
|
+
return 'recipe-stale';
|
|
515
|
+
}
|
|
516
|
+
// `versions` is oldest-first and non-empty by the validator's entry bar, so the newest
|
|
517
|
+
// green's plan-as-executed is the one that inherits (R1) — never the founding one.
|
|
518
|
+
const newest = bv.bridge.versions.at(-1);
|
|
519
|
+
startingDraft = newest.plan;
|
|
520
|
+
emit('bridge-loaded', { name: bv.bridge.name, versions: bv.bridge.versions.length, runid: newest.runid });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
|
|
524
|
+
// precheck. The precheck is real wall time and F62 measured close/check gaps as
|
|
525
|
+
// high as 328s on an archived run: a clock created after it would under-report
|
|
526
|
+
// elapsed by that much and hand the planner a balance the run does not have. Time
|
|
527
|
+
// the operator's own instruments too, or the budget is measured against a
|
|
528
|
+
// different run than the one that happened.
|
|
529
|
+
//
|
|
530
|
+
// `job.maxWallMs` is operator input with budgetUsd's status and has NO DEFAULT
|
|
531
|
+
// (hamr's ruling): absent means honestly time-unbounded, and the record says so
|
|
532
|
+
// rather than leaving a reader to infer it. Both numbers are reported — addendum 1
|
|
533
|
+
// measured that a deadline is only readable BETWEEN rounds, so enforcement is
|
|
534
|
+
// requested + the close's own worst case and quoting one number would be F6 in a
|
|
535
|
+
// time coat.
|
|
536
|
+
//
|
|
537
|
+
// W5 — that worst case is per STAGE. `runStages` hands each stage the full
|
|
538
|
+
// `closeTimeoutMs`, so a run whose deadline trips just before a 4-stage close can
|
|
539
|
+
// overshoot by four of them, and the clock is told the count rather than assuming
|
|
540
|
+
// one. `stagedClose` is the SAME staging the runner executes (never a second
|
|
541
|
+
// count), and it is non-null here by the close-unsupported guard above — a close
|
|
542
|
+
// that names no command escalated before this line, so there is no null to thread.
|
|
543
|
+
//
|
|
544
|
+
// RESUME (module C): `priorWallMs` is time a killed attempt of this same try already
|
|
545
|
+
// consumed. It folds into the clock's start, never into `maxWallMs` — the cap stays the
|
|
546
|
+
// signed one in the record and in the hash, and only the REMAINDER is left to run.
|
|
547
|
+
const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, priorElapsedMs: priorWallMs, ...(now ? { now } : {}) });
|
|
548
|
+
const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
|
|
549
|
+
emit('wall-clock', {
|
|
550
|
+
...clock.report(closeTimeoutForReport),
|
|
551
|
+
meaning: clock.bounded
|
|
552
|
+
? 'a between-round deadline; a close already in flight when it trips runs to completion'
|
|
553
|
+
: 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The per-call TIME bounds, in ONE place so the two `loop.run` sites cannot drift
|
|
558
|
+
* (the two-transforms class, F9). bare-agent forwards a run's options straight into
|
|
559
|
+
* `provider.generate` (loop.js:732), so both knobs land on the request:
|
|
560
|
+
*
|
|
561
|
+
* timeoutMs BA-18, IDLE: `req.setTimeout`, reset by every byte. Bounds a silent
|
|
562
|
+
* socket and nothing else.
|
|
563
|
+
* deadlineMs BA-19, TOTAL: a plain timer no byte resets. This is the bound for
|
|
564
|
+
* the F66 shape — a call that trickles forever while the run's own
|
|
565
|
+
* wall passes (274 minutes, with the idle bound correctly armed).
|
|
566
|
+
*
|
|
567
|
+
* `deadlineMs` is OMITTED, never zeroed, when the run is unbounded: the operator set
|
|
568
|
+
* no wall, so there is no ceiling to derive, and inventing one is the silent second
|
|
569
|
+
* ceiling F45 exists to forbid. Omission is also the honest spelling upstream — an
|
|
570
|
+
* absent knob inherits the provider's own default (disabled), while an explicitly
|
|
571
|
+
* garbage value THROWS there (`resolveTimeoutMs`, provider-http.js:53).
|
|
572
|
+
*
|
|
573
|
+
* The NATIVE (clipipe) worker does not call this and never did: the CLI owns the
|
|
574
|
+
* transport, there is no `ClientRequest` for either timer to arm, and that path
|
|
575
|
+
* passes neither bound today. Stated rather than assumed — an unbounded native call
|
|
576
|
+
* is bounded by the F66 stall fuse and the outside watchdog, not by this.
|
|
577
|
+
*/
|
|
578
|
+
const callBounds = () => {
|
|
579
|
+
const deadlineMs = clock.callDeadlineMs();
|
|
580
|
+
return { timeoutMs: clock.callTimeoutMs(), ...(deadlineMs === null ? {} : { deadlineMs }) };
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
|
|
585
|
+
* drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
|
|
586
|
+
* bare-agent's `TimeoutError` (`callTimeoutMs()` is derived from the wall) is a
|
|
587
|
+
* GOVERNANCE stop and rides out as `wall-halt` — filing it `provider-red` would
|
|
588
|
+
* discard the row as a casualty (never evidence, F45/F48) and hand the human a
|
|
589
|
+
* transport-debugging option list for a run that simply ran out of time. A
|
|
590
|
+
* thrower that named its own category keeps it (the standing typed-attribution
|
|
591
|
+
* rule); only an UNNAMED throw is classified here.
|
|
592
|
+
* @param {any} e
|
|
593
|
+
* @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
|
|
594
|
+
* plus the category as a plain string (so a caller reading it never has to
|
|
595
|
+
* re-widen an optional field back to a value)
|
|
596
|
+
*/
|
|
597
|
+
const categorize = (e) => {
|
|
598
|
+
const err = /** @type {CategorizedError} */ (e);
|
|
599
|
+
const category = e instanceof HaltError ? 'cap-halt'
|
|
600
|
+
: (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
|
|
601
|
+
err.category = category;
|
|
602
|
+
return { err, category };
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/** the run-level TIME record, one shape wherever the wall stops the run.
|
|
606
|
+
* `cutMidCall` splits the two readings: `true` = the deadline landed INSIDE a
|
|
607
|
+
* provider call (F64, seen as its timeout), `false` = read between steps, the
|
|
608
|
+
* ordinary path. Both are the same stop; conflating them would hide which
|
|
609
|
+
* instrument saw it. */
|
|
610
|
+
const emitWallHalt = (/** @type {object} */ extra) => emit('wall-halt', {
|
|
611
|
+
...clock.report(closeTimeoutForReport),
|
|
612
|
+
meaning: 'not under cap — not "can\'t"',
|
|
613
|
+
cutMidCall: false,
|
|
614
|
+
...extra,
|
|
615
|
+
});
|
|
616
|
+
/** the wall's decision-ready levers, BYTE-IDENTICAL to ralph's own `wall-halt`
|
|
617
|
+
* entry (ralph.js DECISIONS). One category must hand a human ONE option list: which
|
|
618
|
+
* site read the clock is an implementation detail, and a shorter list here quietly
|
|
619
|
+
* withheld the second lever (revise the goal) from every stop this file escalates.
|
|
620
|
+
* Both levers are spec edits — a changed spec hash the runner refuses until it is
|
|
621
|
+
* re-approved — which is why each carries its own re-approval note, and why the
|
|
622
|
+
* caller's detail carries the progress trend that says which lever fits. */
|
|
623
|
+
const WALL_OPTIONS = [
|
|
624
|
+
'raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
|
|
625
|
+
'revise the goal/spec so the work fits the time (same re-approval)',
|
|
626
|
+
'abandon the task',
|
|
627
|
+
];
|
|
628
|
+
/** MONEY's levers (PRD v1.46 §2), the same three shapes the wall's are, because a
|
|
629
|
+
* money cut is the same KIND of stop: the run is out of an operator-owned
|
|
630
|
+
* allowance, not out of capability, and the last verdict rendered stands. hamr's
|
|
631
|
+
* ask was "halt and feedback… and needs to be accurate", so the readout that
|
|
632
|
+
* carries these also carries the trend that says WHICH of the first two fits.
|
|
633
|
+
*
|
|
634
|
+
* The first lever is a top-up the OPERATOR performs. `budgetUsd` is in the spec
|
|
635
|
+
* hash, so raising it is a new spec version somebody signs — the library only ever
|
|
636
|
+
* names the lever, and never adjusts a budget itself (the permanent hard line). */
|
|
637
|
+
const MONEY_OPTIONS = [
|
|
638
|
+
'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)',
|
|
639
|
+
'revise the goal/spec so the work fits the budget (same re-approval)',
|
|
640
|
+
'abandon the task',
|
|
641
|
+
];
|
|
642
|
+
|
|
643
|
+
const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
|
|
644
|
+
/** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
|
|
645
|
+
let closeStage;
|
|
646
|
+
/** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
|
|
647
|
+
* the arbiter has already rendered rather than re-deriving one: past the deadline
|
|
648
|
+
* the run's answer is whatever the close last said, and nothing after the deadline
|
|
649
|
+
* is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
|
|
650
|
+
let lastCloseVerdict;
|
|
651
|
+
/** PRD v1.46 §2 — the RUN's own close trend, fed by every grade the arbiter
|
|
652
|
+
* renders (the precheck is the seed the work is measured against, so it is the
|
|
653
|
+
* first reading, not a discarded one). Read only by the halt readouts; it decides
|
|
654
|
+
* nothing and bounds nothing — the fix loop builds its OWN reader over its own
|
|
655
|
+
* grades, because "did this run make progress" and "is this loop out of ideas"
|
|
656
|
+
* are two questions and one instrument answering both is how they come to
|
|
657
|
+
* disagree. Per stage by construction (src/trend.js's accuracy law).
|
|
658
|
+
*
|
|
659
|
+
* On a RESUME it is SEEDED with the grades the dead leg recorded, so both halt
|
|
660
|
+
* readouts judge the whole chain rather than this leg alone: a leg that re-grades
|
|
661
|
+
* an unchanged tree is flat on its own evidence while the RUN — the thing the human
|
|
662
|
+
* is deciding about — may have been converging when its allowance ran out. Cold, the
|
|
663
|
+
* seed is empty and this is byte-identical to the pre-resume reader. */
|
|
664
|
+
const runTrend = createTrend({ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name), seed: resumeGrades });
|
|
665
|
+
/** the run-level MONEY record, one shape wherever the wallet stops the run —
|
|
666
|
+
* W-2's `emitWallHalt` in a money coat, and deliberately its mirror image. hamr's
|
|
667
|
+
* ruling for TIME was "keep the grade we already have and stop"; a money cut is
|
|
668
|
+
* the same stop with a different allowance behind it, so it keeps the same grade,
|
|
669
|
+
* states the same kind of trend, and hands over the same shape of lever list.
|
|
670
|
+
* `spentUsd` is NOT re-derived here: runJob's ledger owns that number and states
|
|
671
|
+
* it on `job-end` (F6 — a second, weaker arithmetic for the same figure is how
|
|
672
|
+
* two instruments come to disagree about one run's money). What this record can
|
|
673
|
+
* say exactly is the SIGNED ceiling and what is left against it. */
|
|
674
|
+
const emitMoneyHalt = (/** @type {object} */ extra) => {
|
|
675
|
+
const t = runTrend.verdict();
|
|
676
|
+
return emit('money-halt', {
|
|
677
|
+
meaning: 'not under cap — not "can\'t"',
|
|
678
|
+
budgetUsd: job.budgetUsd,
|
|
679
|
+
remainingUsd: remainingUsd(),
|
|
680
|
+
// …and whether that figure is EXACT. `remainingUsd` is `budget − spent`, so a run
|
|
681
|
+
// whose spend is a FLOOR has a remaining that is a CEILING, and quoting it bare to
|
|
682
|
+
// four decimals reads as the number. The four causes live in the ledger one level
|
|
683
|
+
// up (unpriced, self-healed stall, mid-call cut, an inherited floor), which is why
|
|
684
|
+
// this is READ from there and not re-derived here — a second, weaker arithmetic for
|
|
685
|
+
// the same question is how two instruments come to disagree. `emitWallHalt` has
|
|
686
|
+
// carried its honest bound since W-2; this is the same duty on the money side.
|
|
687
|
+
spendComplete: spendComplete(),
|
|
688
|
+
verdict: lastCloseVerdict?.verdict ?? null,
|
|
689
|
+
...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
|
|
690
|
+
trend: t.trend,
|
|
691
|
+
motion: t.motion,
|
|
692
|
+
reading: t.reading,
|
|
693
|
+
lever: t.lever,
|
|
694
|
+
series: t.series,
|
|
695
|
+
options: MONEY_OPTIONS,
|
|
696
|
+
...extra,
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
/** the close, as ONE verdict: every stage in order, first red wins */
|
|
700
|
+
const judgeClose = async () => {
|
|
701
|
+
const v = await runStages(stagedClose, scrub, closeOpts);
|
|
702
|
+
closeStage = v.stage;
|
|
703
|
+
lastCloseVerdict = v;
|
|
704
|
+
// Only a RED grade is a reading. A `satisfied` ends the run and a close FAULT
|
|
705
|
+
// rendered no judgment at all (CLOSE_FAULTS) — folding either into the series
|
|
706
|
+
// would put a non-number where the instrument expects a graded one.
|
|
707
|
+
if (v.verdict === 'needs_revision') runTrend.record({ gap: v.gap });
|
|
708
|
+
return v;
|
|
709
|
+
};
|
|
186
710
|
|
|
187
711
|
// ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
|
|
188
712
|
// record, zero tokens; a forbidden-zone verdict escalates before any spend
|
|
189
|
-
const pre =
|
|
713
|
+
const pre = await judgeClose();
|
|
190
714
|
emit('close-precheck', { ...pre });
|
|
191
715
|
if (pre.verdict === 'satisfied') return 'already-green';
|
|
192
716
|
const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
@@ -195,16 +719,34 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
195
719
|
return 'close-red';
|
|
196
720
|
}
|
|
197
721
|
|
|
198
|
-
// ── 0b.
|
|
199
|
-
// before tokens — an unrunnable
|
|
200
|
-
//
|
|
201
|
-
// first). Red and green are both fine: checks decide nothing.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
722
|
+
// ── 0b. check preflight ($0, deterministic): every OFFERABLE stage runs once
|
|
723
|
+
// before tokens — an unrunnable ruler would fault mid-plan after real spend (a
|
|
724
|
+
// frozen rule without a wired detector is prose; here the detector runs
|
|
725
|
+
// first). Red and green are both fine: checks decide nothing and mint nothing.
|
|
726
|
+
// The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
|
|
727
|
+
// ruler and the real inspection cannot drift apart.
|
|
728
|
+
const menu = checkMenu(stagedClose);
|
|
729
|
+
/** Rule A-v2's fact: check names RED at preflight (seed-red). A seed-red
|
|
730
|
+
* check is the GOAL, not a guard — validatePlan confines it to the final
|
|
731
|
+
* write step, and the drafting prompt states the same law. Recorded HERE,
|
|
732
|
+
* from the same preflight the spine records, never re-derived mid-run: the
|
|
733
|
+
* rule is defined on the SEED verdicts (a check a step turned green mid-run
|
|
734
|
+
* is still the goal the plan exists to flip).
|
|
735
|
+
* @type {string[]} */
|
|
736
|
+
const seedRed = [];
|
|
737
|
+
emit('check-menu', {
|
|
738
|
+
offered: menu.map((m) => m.name),
|
|
739
|
+
...(menu.length < stagedClose.length
|
|
740
|
+
? { hidden: stagedClose.filter((s2) => s2.offer === false).map((s2) => s2.name), meaning: 'a stage that cannot stand alone as a ruler (a precondition) is not offered — a partial menu is acceptable, never a failure' }
|
|
741
|
+
: {}),
|
|
742
|
+
});
|
|
743
|
+
for (const m of menu) {
|
|
744
|
+
const v = await runStages(m.run, scrub, closeOpts);
|
|
745
|
+
if (v.verdict === 'needs_revision') seedRed.push(m.name);
|
|
746
|
+
emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
|
|
205
747
|
const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
|
|
206
748
|
if (f) {
|
|
207
|
-
emit('escalation', { category: f.category, decisionReady: true, decision: `
|
|
749
|
+
emit('escalation', { category: f.category, decisionReady: true, decision: `Close stage "${m.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${m.name}: ${v.detail ?? ''}` });
|
|
208
750
|
return 'check-red';
|
|
209
751
|
}
|
|
210
752
|
}
|
|
@@ -213,16 +755,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
213
755
|
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
214
756
|
const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
|
|
215
757
|
const auditPath = join(workdir, 'gate-audit.jsonl');
|
|
216
|
-
const
|
|
758
|
+
const chainByName = new Map(menu.map((m) => [m.name, m.run]));
|
|
759
|
+
// Choose-don't-describe (§4): the offered `tree-changed` scopes, enumerated from
|
|
760
|
+
// the signed fence plus the directories that actually exist beneath it. ONE menu
|
|
761
|
+
// object feeds both the drafting prompt and the validator, so what was offered
|
|
762
|
+
// and what is accepted can never drift apart. Discovery is best-effort — a
|
|
763
|
+
// missing or unreadable fence directory degrades to the signed entries alone
|
|
764
|
+
// (which `snapshotScope` already handles as an empty snapshot), never a throw.
|
|
765
|
+
const discoveredDirs = job.writeScope.flatMap((/** @type {string} */ g) => {
|
|
766
|
+
const prefix = globToPrefix(g);
|
|
767
|
+
try {
|
|
768
|
+
return readdirSync(join(workdir, prefix), { recursive: true, withFileTypes: true })
|
|
769
|
+
.filter((d) => d.isDirectory())
|
|
770
|
+
.map((d) => `${prefix}/${relative(join(workdir, prefix), join(d.parentPath ?? d.path, d.name))}`);
|
|
771
|
+
} catch { return []; }
|
|
772
|
+
});
|
|
773
|
+
const scopeMenu = legalScopes(job.writeScope, discoveredDirs);
|
|
774
|
+
// No silent caps: MAX_SCOPE_MENU can drop deep directories, and a menu that
|
|
775
|
+
// reads as complete when it is not is the blind-instrument class. Report BOTH
|
|
776
|
+
// counts so a truncation is visible in the record rather than inferred.
|
|
777
|
+
const offerable = legalScopes(job.writeScope, discoveredDirs, Infinity).length;
|
|
778
|
+
emit('scope-menu', {
|
|
779
|
+
offered: scopeMenu,
|
|
780
|
+
...(offerable > scopeMenu.length
|
|
781
|
+
? { truncated: true, offerableCount: offerable, cap: MAX_SCOPE_MENU, meaning: 'deep directories were dropped from the menu; they remain reachable via a parent scope' }
|
|
782
|
+
: { truncated: false }),
|
|
783
|
+
});
|
|
217
784
|
|
|
218
785
|
/** the check-passes seam evalExits delegates to: the FULL runClose machinery
|
|
219
786
|
* per signed check; a forbidden-zone verdict rides out as `fault` by name so
|
|
220
787
|
* the micro-loop escalates (or F32-routes a crash) instead of faking a gap */
|
|
221
788
|
const runCheck = async (/** @type {string} */ name) => {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
789
|
+
const chain = chainByName.get(name);
|
|
790
|
+
// UNREACHABLE through runPlan and deliberately kept: validatePlan reds
|
|
791
|
+
// `check-unknown` against `checkMenu(spec.close)` — the SAME derivation that
|
|
792
|
+
// built this map from the SAME close — so a name that got here was already
|
|
793
|
+
// proven to be on the menu. Defence in depth against a future second
|
|
794
|
+
// derivation, not a live path; the reachable guard is the validator's, and
|
|
795
|
+
// that one is mutation-covered.
|
|
796
|
+
if (!chain) return { pass: false, fault: 'failed', gap: `no offered close stage named "${name}"` };
|
|
797
|
+
const v = await runStages(chain, scrub, closeOpts);
|
|
798
|
+
emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
|
|
226
799
|
if (v.verdict === 'satisfied') return { pass: true };
|
|
227
800
|
if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
|
|
228
801
|
return { pass: false, fault: v.verdict, gap: v.detail };
|
|
@@ -239,41 +812,94 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
239
812
|
* denied, the wallet as its budget), granted tools only (the menu IS the
|
|
240
813
|
* grant), per-attempt round bound via loop.stop() (F20), every round metered
|
|
241
814
|
* with a phase label (F12).
|
|
242
|
-
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null}} o
|
|
815
|
+
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
|
|
243
816
|
* `root` (Layer R): when present, the worker's write-class actions are teed
|
|
244
817
|
* for the within-run ratchet — staged before the gate decides, discarded on
|
|
245
818
|
* deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
|
|
246
819
|
* Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
|
|
820
|
+
*
|
|
821
|
+
* `attempts` is how many iterations this worker will be asked for. `null` is the
|
|
822
|
+
* STRIKE LADDER (src/ladder.js), where that count does not exist by design — see
|
|
823
|
+
* the turn-allowance note below.
|
|
247
824
|
*/
|
|
248
|
-
async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null }) {
|
|
825
|
+
async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
|
|
826
|
+
/**
|
|
827
|
+
* The gate's LLM-turn allowance THROUGH iteration `i` — the old expression
|
|
828
|
+
* `attemptRounds * (attempts + 1)` with the iteration count made explicit
|
|
829
|
+
* (`i = attempts` reproduces it exactly, so a bounded worker is unchanged to
|
|
830
|
+
* the number).
|
|
831
|
+
* @param {number} i
|
|
832
|
+
*/
|
|
833
|
+
const turnsThrough = (i) => attemptRounds * (i + 1);
|
|
834
|
+
// A pre-multiplied ceiling could only ever pre-pay a KNOWN iteration count, and
|
|
835
|
+
// the strike ladder has none: iterations float under money and the wall. So on
|
|
836
|
+
// that path the allowance is GRANTED PER ITERATION at `setIteration` — the seam
|
|
837
|
+
// that already resets the per-iteration round counter — leaving the belt exactly
|
|
838
|
+
// as tight per iteration as the pre-multiply made it, without inventing a
|
|
839
|
+
// ceiling nobody signed. It is monotonic (`Math.max`), so nothing can lower it.
|
|
840
|
+
//
|
|
841
|
+
// Rebuilding the Gate per iteration was the alternative and was REJECTED: the
|
|
842
|
+
// audit is run_id-scoped, so a fresh gate would reset F32's crash-attribution
|
|
843
|
+
// write set ("files you have written this run") and Layer R's cross-attempt
|
|
844
|
+
// write history to a single iteration — two instruments broken to avoid one
|
|
845
|
+
// addition. The REAL per-iteration bound is unchanged either way: `loop.stop()`
|
|
846
|
+
// at `roundsThisAttempt >= attemptRounds` (the `wasBounded` mechanism).
|
|
847
|
+
const maxTurns = turnsThrough(attempts ?? 1);
|
|
249
848
|
const gate = new Gate({
|
|
250
849
|
fs: {
|
|
251
|
-
|
|
850
|
+
// P: a step's `scope` narrows the fence — the per-step prefixes are always a
|
|
851
|
+
// SUBSET of the signed fence (validator: scope ∈ the same menu tree-changed
|
|
852
|
+
// uses), so this can only tighten, never widen
|
|
853
|
+
writeScope: writable ? (fence ?? fencePrefixes) : [],
|
|
252
854
|
readScope: [workdir],
|
|
253
855
|
deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
|
|
254
856
|
},
|
|
255
857
|
budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
|
|
256
|
-
limits: { maxTurns
|
|
858
|
+
limits: { maxTurns },
|
|
257
859
|
audit: { path: auditPath },
|
|
860
|
+
// The gate audit is the THIRD persistent, append-only channel (beside the
|
|
861
|
+
// spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
|
|
862
|
+
// SAME ONE inventory. bareguard's default-on backstop covers only
|
|
863
|
+
// apiKey/authorization/`Bearer …`/`sk-…` — measured: a `ghp_` token landed
|
|
864
|
+
// in cleartext through every model-authored identifier the action carries
|
|
865
|
+
// (an isolate verb's `id`, a path the worker spelled), and masked under
|
|
866
|
+
// these patterns. Content is already unreachable here (`toolAction` reduces
|
|
867
|
+
// a write to `{bytes}`), but the identifiers are raw model text, and a log
|
|
868
|
+
// that captures a key captures it forever.
|
|
869
|
+
secrets: { patterns: SECRET_PATTERNS },
|
|
258
870
|
humanChannel: async () => ({ decision: 'terminate' }),
|
|
259
871
|
});
|
|
260
872
|
await gate.init();
|
|
261
873
|
// F32's instrument, run_id-scoped, write AND edit, allow-decision only —
|
|
262
|
-
// the same audit read as interpret's (never git status, F45)
|
|
263
|
-
|
|
874
|
+
// the same audit read as interpret's (never git status, F45). ONE scan and ONE
|
|
875
|
+
// filter behind both readings: a future write-class verb that slipped into one
|
|
876
|
+
// and not the other would make the two instruments disagree about what a write
|
|
877
|
+
// is, which is the exact class the tee's `type`-not-name test exists to close.
|
|
878
|
+
const auditWrites = () => {
|
|
879
|
+
const paths = new Set();
|
|
880
|
+
let records = 0;
|
|
264
881
|
try {
|
|
265
|
-
const paths = new Set();
|
|
266
882
|
for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
|
|
267
883
|
if (!line) continue;
|
|
268
884
|
let rec;
|
|
269
885
|
try { rec = JSON.parse(line); } catch { continue; }
|
|
270
886
|
if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
|
|
271
887
|
&& (rec.action?.type === 'write' || rec.action?.type === 'edit')
|
|
272
|
-
&& typeof rec.action.path === 'string') paths.add(rec.action.path);
|
|
888
|
+
&& typeof rec.action.path === 'string') { paths.add(rec.action.path); records += 1; }
|
|
273
889
|
}
|
|
274
|
-
|
|
275
|
-
|
|
890
|
+
} catch { /* no audit yet → nothing written yet */ }
|
|
891
|
+
return { paths: [...paths], records };
|
|
276
892
|
};
|
|
893
|
+
const workerWrites = () => auditWrites().paths;
|
|
894
|
+
/**
|
|
895
|
+
* The ladder's write signal: the cumulative RECORD count, never the path set.
|
|
896
|
+
* A plan step rewrites its one target every attempt, so the path set is constant
|
|
897
|
+
* after iteration 1 and a set-delta would read every later iteration as idle —
|
|
898
|
+
* the same Finding-3 trap Layer R documents, and it would strike a working
|
|
899
|
+
* worker out on its second attempt. The record count advances on every allowed
|
|
900
|
+
* write-class action, same path or not.
|
|
901
|
+
*/
|
|
902
|
+
const writeCount = () => auditWrites().records;
|
|
277
903
|
// Layer R tee (design record 2026-07-19; created per-step by executeStep).
|
|
278
904
|
// The translator STAGES write/edit content and snapshots the pre-write hash
|
|
279
905
|
// BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
|
|
@@ -342,6 +968,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
342
968
|
let roundsThisAttempt = 0;
|
|
343
969
|
/** @type {number|string|undefined} */
|
|
344
970
|
let attemptBounded;
|
|
971
|
+
/**
|
|
972
|
+
* Open an iteration: label the rounds, reset the per-iteration round counter,
|
|
973
|
+
* and — on the unbounded ladder only — GRANT this iteration's turn allowance to
|
|
974
|
+
* the gate. Raising `limits.maxTurns` is documented bareguard config read live
|
|
975
|
+
* by `Limits.preCheck()` on every record; the alternative (a fresh Gate per
|
|
976
|
+
* iteration) is the one that breaks instruments, so this is the smaller change.
|
|
977
|
+
* Monotonic by construction: a re-entry can only ever hold the allowance, never
|
|
978
|
+
* lower it. A bounded worker's ceiling is untouched — it was pre-paid in full.
|
|
979
|
+
* @param {number|string} i
|
|
980
|
+
*/
|
|
981
|
+
const setIteration = (i) => {
|
|
982
|
+
roundIteration = i;
|
|
983
|
+
roundsThisAttempt = 0;
|
|
984
|
+
if (attempts === null && typeof i === 'number') {
|
|
985
|
+
gate.limits.maxTurns = Math.max(gate.limits.maxTurns, turnsThrough(i));
|
|
986
|
+
}
|
|
987
|
+
};
|
|
345
988
|
const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
|
|
346
989
|
const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
|
|
347
990
|
// F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
|
|
@@ -362,11 +1005,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
362
1005
|
}
|
|
363
1006
|
}
|
|
364
1007
|
const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
|
|
365
|
-
? createCtxTools(lc, workdir,
|
|
1008
|
+
? createCtxTools(lc, workdir, emitCtx).filter((t) => grantedNames.has(t.name))
|
|
366
1009
|
: [];
|
|
367
|
-
|
|
1010
|
+
// LC-3 (litectx 0.31.0): cooperative yield — index() is async but its work is sync CPU,
|
|
1011
|
+
// and the default pass holds THIS event loop (the fuse's timers, the wall clock, the lag
|
|
1012
|
+
// sampler all live here) for its full duration: measured 4.4s at 4.5% liveness on a
|
|
1013
|
+
// 155-file force pass. With yield:true the worst single block measured 189–252ms (n=4),
|
|
1014
|
+
// below anything a second/minute-scale timer can perceive. Store output is byte-identical.
|
|
1015
|
+
if (ctx.length) await lc.index({ yield: true });
|
|
368
1016
|
const toolDefs = [...shell, ...ctx];
|
|
369
|
-
|
|
1017
|
+
// F19 per VERB (P, corrected 2026-07-31): a strategy SENTENCE rides only with
|
|
1018
|
+
// the verb it names. Gating per COMPONENT was close but over-reached — a
|
|
1019
|
+
// worker granted only `impact` must not be steered to tools it lacks, and the
|
|
1020
|
+
// isolate/retrieval paragraphs named other components' verbs outright. The
|
|
1021
|
+
// assembly lives in tools.js beside the prose it composes, and is asserted
|
|
1022
|
+
// byte-identical to the full-component paragraphs on a full grant.
|
|
1023
|
+
const system = PERSONA_TOOLS + strategyFor(granted)
|
|
370
1024
|
+ (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
|
|
371
1025
|
/** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
|
|
372
1026
|
const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
|
|
@@ -400,14 +1054,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
400
1054
|
const provider2 = /** @type {any} */ (nativeProvider)({ policy, onTurn: nativeMetered, maxTurns: attemptRounds, hasTools: true });
|
|
401
1055
|
const loop = new Loop({ provider: provider2, system }); // no Loop policy / no cacheMessages: the CLI owns the transcript
|
|
402
1056
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
|
1057
|
+
/** F59: unavailable on native — a toolless native session fires no onTurn and
|
|
1058
|
+
* reports NO cost (F48/BA-16 live-verified), so a recovery round there would be
|
|
1059
|
+
* unmetered spend. The caller treats a null return as "no recovery possible". */
|
|
1060
|
+
const askFrom = async () => null;
|
|
403
1061
|
const ask = async (prompt, defs = toolDefs) => {
|
|
404
1062
|
let r;
|
|
405
1063
|
try {
|
|
406
1064
|
r = await loop.run([{ role: 'user', content: prompt }], defs, { maxTokens: 32000 });
|
|
407
1065
|
} catch (e) {
|
|
408
|
-
|
|
409
|
-
err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
|
|
410
|
-
throw err;
|
|
1066
|
+
throw categorize(e).err;
|
|
411
1067
|
}
|
|
412
1068
|
// a maxTurns session is a BOUNDED attempt, not an escalation — the same
|
|
413
1069
|
// role loop.stop() plays on the Loop path: judge the partial work and
|
|
@@ -429,7 +1085,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
429
1085
|
}
|
|
430
1086
|
return r;
|
|
431
1087
|
};
|
|
432
|
-
return { ask, workerWrites, setIteration
|
|
1088
|
+
return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
|
|
433
1089
|
}
|
|
434
1090
|
|
|
435
1091
|
// ── LOOP path: the injected provider (anthropic-api and every other
|
|
@@ -440,36 +1096,124 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
440
1096
|
// onLlmResult meters exactly like an API round, so the drafter's spend is
|
|
441
1097
|
// never invisible (F6/F44). The gate policy is wired but idle (no tools).
|
|
442
1098
|
const loopProvider = native
|
|
443
|
-
? /** @type {any} */ (nativeProvider)({ policy, maxTurns
|
|
444
|
-
: provider;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
1099
|
+
? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
|
|
1100
|
+
: (workerProvider ?? provider);
|
|
1101
|
+
// F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
|
|
1102
|
+
// not call duration (provider-http.js `req.setTimeout` resets on every byte),
|
|
1103
|
+
// so a connection that stays alive while producing nothing is invisible to it:
|
|
1104
|
+
// U run ms3197n8 hung 274 minutes inside ONE call and died ECONNRESET with the
|
|
1105
|
+
// bound correctly passed. The heartbeat here is a completed ROUND, which is the
|
|
1106
|
+
// one signal that cannot be faked by a live socket. One watch PER WORKER, so
|
|
1107
|
+
// the ceiling counts stalls across the step rather than resetting each call.
|
|
1108
|
+
// L4 — the watch is handed the run's WALL. Self-heal is what a run does with
|
|
1109
|
+
// time left: past the deadline a reissue is spend the run already declined to
|
|
1110
|
+
// authorize, and this is the one path no other clock read covers (a stall
|
|
1111
|
+
// completes no round and returns from no step, which are the only two places
|
|
1112
|
+
// `clock.expired()` is otherwise consulted). Same clock object as every other
|
|
1113
|
+
// reader — never a second time source.
|
|
1114
|
+
const stallWatch = createStallWatch({
|
|
1115
|
+
onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
|
|
1116
|
+
expired: () => clock.expired(),
|
|
1117
|
+
});
|
|
1118
|
+
/**
|
|
1119
|
+
* ONE Loop PER ISSUED CALL, and every callback on it scoped to that call's
|
|
1120
|
+
* generation. After a stall there are TWO calls alive: the abandoned one, whose
|
|
1121
|
+
* socket keeps streaming rounds (ms3197n8's lived 274min past the point we moved
|
|
1122
|
+
* on), and the reissue. Sharing one Loop between them shares `_stopped`, so a
|
|
1123
|
+
* corpse's round bound would cut the LIVE call mid-flight; sharing one callback
|
|
1124
|
+
* left the corpse beating the watch that was timing its replacement, which meant
|
|
1125
|
+
* the replacement could hang forever without tripping — the fuse feeding itself
|
|
1126
|
+
* from the thing it abandoned. A Loop is a plain object over the shared provider,
|
|
1127
|
+
* holds no cross-run state (`run()` takes the transcript per call), so one per
|
|
1128
|
+
* issue costs nothing.
|
|
1129
|
+
*
|
|
1130
|
+
* RESIDUAL, documented not fixed: both generations still execute tools through
|
|
1131
|
+
* the SAME Gate (`policy`) and the same audit — deliberately, because the audit
|
|
1132
|
+
* is what `workerWrites` reads and a per-generation gate would split the run's
|
|
1133
|
+
* own books. Concurrent tool calls across a stall therefore interleave on one
|
|
1134
|
+
* fence. The fence itself is decision-per-call and order-independent, so the
|
|
1135
|
+
* denial semantics hold; what is not modelled is two calls writing the same path
|
|
1136
|
+
* in the same window. Latent today (Layer R off, and a stall is rare).
|
|
1137
|
+
* @param {number} gen the generation `stallWatch` handed this call
|
|
1138
|
+
*/
|
|
1139
|
+
const newLoop = (gen) => {
|
|
1140
|
+
/** @type {any} */
|
|
1141
|
+
let self = null;
|
|
1142
|
+
/** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
|
|
1143
|
+
const metered = async (arg) => {
|
|
1144
|
+
// The meter fires for EVERY generation, live or abandoned. An orphaned round
|
|
1145
|
+
// was really billed, and spend that no instrument sees is the one thing this
|
|
1146
|
+
// repo never ships (F6/F44 — unknown is reported as unknown, never as zero;
|
|
1147
|
+
// stall.js says it plainly: a reissue can pay twice, and the wallet is what
|
|
1148
|
+
// caps that).
|
|
1149
|
+
emit('worker-round', {
|
|
1150
|
+
phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
|
|
1151
|
+
costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
|
|
1152
|
+
tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
|
|
1153
|
+
usage: usageOf(arg?.usage),
|
|
1154
|
+
});
|
|
1155
|
+
// Everything BELOW here belongs to the live call only. A superseded call must
|
|
1156
|
+
// not beat the watch (that is the whole watchdog), must not spend the step's
|
|
1157
|
+
// round bound (stall.js: "the step's round bound is untouched" by a reissue —
|
|
1158
|
+
// the wallet is what bounds a reissue's spend), and must not announce a bound
|
|
1159
|
+
// it cannot own.
|
|
1160
|
+
if (!stallWatch.isCurrent(gen)) return onLlmResult(arg);
|
|
1161
|
+
// F66 — the heartbeat is ANY result coming back from the model layer, not
|
|
1162
|
+
// only a `turn`. The question the watchdog asks is "is the model still
|
|
1163
|
+
// answering", and a non-turn result answers it just as well; gating the beat
|
|
1164
|
+
// on `kind` would arm a false stall on any round shape we don't enumerate
|
|
1165
|
+
// here, which is the blind-instrument class in miniature.
|
|
1166
|
+
stallWatch.beat(gen);
|
|
1167
|
+
if ((arg?.kind ?? 'turn') === 'turn') {
|
|
1168
|
+
roundsThisAttempt += 1;
|
|
1169
|
+
|
|
1170
|
+
// The wall deadline rides the SAME seam as the round bound, because it is
|
|
1171
|
+
// the only seam that exists: loop.stop() is read at the round boundary and
|
|
1172
|
+
// cannot cut an in-flight call (F61/C1, measured — fired at 500ms, returned
|
|
1173
|
+
// at 4,018ms). Stopping here judges the partial work and feeds the gap
|
|
1174
|
+
// forward exactly as a round-bounded attempt does — this seam never decides a
|
|
1175
|
+
// terminal. Three sites do, each reading the clock with nothing in flight: the
|
|
1176
|
+
// step loop after a step returns (between steps), the head of a step's attempt
|
|
1177
|
+
// (a step that would BEGIN past the deadline is never funded), and the head of
|
|
1178
|
+
// a close-fix iteration (W-2 — the run stops on the verdict already minted).
|
|
1179
|
+
if (clock.expired()) {
|
|
1180
|
+
attemptBounded = roundIteration;
|
|
1181
|
+
emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
|
|
1182
|
+
self.stop();
|
|
1183
|
+
} else if (roundsThisAttempt >= attemptRounds) {
|
|
1184
|
+
attemptBounded = roundIteration;
|
|
1185
|
+
emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
|
|
1186
|
+
self.stop();
|
|
1187
|
+
}
|
|
459
1188
|
}
|
|
460
|
-
|
|
461
|
-
|
|
1189
|
+
return onLlmResult(arg);
|
|
1190
|
+
};
|
|
1191
|
+
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
|
|
1192
|
+
return self;
|
|
462
1193
|
};
|
|
463
|
-
const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
|
|
464
1194
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
|
1195
|
+
/**
|
|
1196
|
+
* F59 — continue an EXISTING conversation for exactly one TOOLLESS round.
|
|
1197
|
+
* `defs` is `[]`, so text is the only output the model can produce: the summary
|
|
1198
|
+
* round is guaranteed by CONSTRUCTION, never requested in prose (F19/F37 — a
|
|
1199
|
+
* pacing mandate in the persona was violated 6/6). Meters like any other round
|
|
1200
|
+
* (F12); the bound has already fired, so this is a deliberate +1 paid once.
|
|
1201
|
+
* @param {any[]} msgs the prior conversation (loop.run's `msgs`)
|
|
1202
|
+
* @param {string} prompt
|
|
1203
|
+
*/
|
|
1204
|
+
const askFrom = async (msgs, prompt) => {
|
|
1205
|
+
try {
|
|
1206
|
+
return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
throw categorize(e).err;
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
465
1211
|
const ask = async (prompt, defs = toolDefs) => {
|
|
466
1212
|
let r;
|
|
467
1213
|
try {
|
|
468
|
-
r = await
|
|
1214
|
+
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
469
1215
|
} catch (e) {
|
|
470
|
-
|
|
471
|
-
err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
|
|
472
|
-
throw err;
|
|
1216
|
+
throw categorize(e).err;
|
|
473
1217
|
}
|
|
474
1218
|
// same error-return taxonomy as interpret's ask (one map, same doctrine):
|
|
475
1219
|
// halt → cap-halt, denial streak → gate-red, API truncation → provider-red
|
|
@@ -484,41 +1228,107 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
484
1228
|
}
|
|
485
1229
|
return r;
|
|
486
1230
|
};
|
|
487
|
-
return { ask, workerWrites, setIteration
|
|
1231
|
+
return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
|
|
488
1232
|
}
|
|
489
1233
|
|
|
490
|
-
/** relay a throw from OUTSIDE ralph (scout/plan drafting) as its honest
|
|
1234
|
+
/** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
|
|
1235
|
+
* category. Three terminals, never collapsed: the budget gate, the wall clock
|
|
1236
|
+
* (F64 — the scout and the drafter run under the same derived call timeout the
|
|
1237
|
+
* worker does, so this path needs the same split or it is the blinder route),
|
|
1238
|
+
* and transport. */
|
|
491
1239
|
const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
|
|
492
|
-
const category =
|
|
493
|
-
|
|
1240
|
+
const { category } = categorize(e);
|
|
1241
|
+
const detail = String(e?.message ?? e);
|
|
1242
|
+
if (category === 'cap-halt') {
|
|
1243
|
+
emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
|
|
1244
|
+
// v1.46 §2 — the money record rides beside the taxonomy record exactly as the
|
|
1245
|
+
// wall's does one line down. Outside the loops there is usually no series yet,
|
|
1246
|
+
// and the readout says `unknown` rather than inventing a direction (F6).
|
|
1247
|
+
emitMoneyHalt({ phase, cutMidCall: true });
|
|
1248
|
+
}
|
|
1249
|
+
if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
|
|
1250
|
+
const DECIDE = {
|
|
1251
|
+
'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, MONEY_OPTIONS],
|
|
1252
|
+
'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
|
|
1253
|
+
// A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
|
|
1254
|
+
// surfaces — but it is named, never laundered into provider-red. The socket
|
|
1255
|
+
// did not fail here; the model stopped answering and reissuing did not help,
|
|
1256
|
+
// and those are different diagnoses with different remedies (F45/F48: a
|
|
1257
|
+
// casualty is never evidence, and a governance stop is never a casualty).
|
|
1258
|
+
'step-stalled': [`The model stopped producing rounds during ${phase} and reissuing the call did not recover it.`, ['retry the run', 'check provider status', 'abandon the run']],
|
|
1259
|
+
};
|
|
1260
|
+
const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
|
|
1261
|
+
?? [`The ${phase} call failed (${category}) — no result exists.`, ['retry the run', 'fix the provider binding', 'abandon the run']];
|
|
494
1262
|
emit('escalation', {
|
|
495
|
-
category, decisionReady: true, phase,
|
|
496
|
-
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.`,
|
|
497
|
-
options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['retry the run', 'fix the provider binding', 'abandon the run'],
|
|
498
|
-
detail: String(e?.message ?? e),
|
|
1263
|
+
category, decisionReady: true, phase, decision, options, detail,
|
|
499
1264
|
...(typeof e?.lib === 'string' ? { lib: e.lib } : {}),
|
|
500
1265
|
});
|
|
501
|
-
|
|
1266
|
+
// `step-stalled` rides out as ITSELF, not as provider-red. Naming the
|
|
1267
|
+
// escalation while returning a casualty label would launder a governance stop
|
|
1268
|
+
// into transport noise at exactly the layer the readout reads.
|
|
1269
|
+
return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' ? category : 'provider-red';
|
|
502
1270
|
};
|
|
503
1271
|
|
|
504
1272
|
// ── 1. SCOUT — read-only by construction: the write-class verbs are simply
|
|
505
1273
|
// not in its menu (the menu is the grant), and its gate fences zero paths
|
|
506
1274
|
let scoutBlob = '';
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1275
|
+
// RESUME (module C v2): the survey is a PAID unit the killed run already bought, and
|
|
1276
|
+
// the plan it briefed is being reloaded rather than re-drafted — so there is nothing
|
|
1277
|
+
// left for it to brief. Skipping it is the whole point of the ruling ("why would i
|
|
1278
|
+
// want to waste more money on something i already started"), and the skip is a RECORD
|
|
1279
|
+
// rather than silence, with the one consequence named on it: the survey text is not on
|
|
1280
|
+
// the spine (only its byte count ever was), so a replan after a resume drafts from an
|
|
1281
|
+
// empty survey plus its failure brief. Re-scouting to refill it would re-pay exactly
|
|
1282
|
+
// the call this exists to save.
|
|
1283
|
+
if (resumeSeed) {
|
|
1284
|
+
emit('scout-skipped', {
|
|
1285
|
+
reason: 'resumed', phase: resumeSeed.phase ?? null,
|
|
1286
|
+
meaning: 'the killed run already paid for this survey; its plan is reloaded, not re-drafted. A replan after this point drafts from an empty survey plus the failure brief.',
|
|
1287
|
+
});
|
|
1288
|
+
} else {
|
|
1289
|
+
emit('scout-start', { rounds: scoutRounds });
|
|
1290
|
+
try {
|
|
1291
|
+
const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
|
|
1292
|
+
scout.setIteration(1);
|
|
1293
|
+
const r = await scout.ask([
|
|
1294
|
+
'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.',
|
|
1295
|
+
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
1296
|
+
`Goal:\n${job.goal}`,
|
|
1297
|
+
pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
|
|
1298
|
+
].filter(Boolean).join('\n\n'));
|
|
1299
|
+
scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
|
|
1300
|
+
// F59 — RESERVE the summary round. The round bound is enforced from the
|
|
1301
|
+
// metering callback (loop.stop), so a scout still calling tools on its last
|
|
1302
|
+
// round is halted mid-tool-use and `text` is empty: it spends the whole
|
|
1303
|
+
// allowance exploring and never writes the survey that is its ONLY
|
|
1304
|
+
// deliverable. Measured on 15 of 18 archived runs, which then drafted from
|
|
1305
|
+
// `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
|
|
1306
|
+
// The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
|
|
1307
|
+
// carries the exploration): with no tools offered, text is the only possible
|
|
1308
|
+
// output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
|
|
1309
|
+
// a pacing mandate in the persona was violated 6/6).
|
|
1310
|
+
// The trigger is BOUNDED-and-short, never short alone: a terse survey that
|
|
1311
|
+
// finished on its own was not cut off and has nothing to recover. The archive
|
|
1312
|
+
// separates cleanly on exactly this pair — every truncated scout was bounded
|
|
1313
|
+
// with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
|
|
1314
|
+
if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
|
|
1315
|
+
emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
|
|
1316
|
+
const s2 = await scout.askFrom(r.msgs, 'You are out of exploration turns. Write your survey NOW, from what you have already read: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Text only.');
|
|
1317
|
+
const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
|
|
1318
|
+
if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
|
|
1319
|
+
}
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
return relay(e, 'scout');
|
|
1322
|
+
}
|
|
1323
|
+
emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
|
|
1324
|
+
// F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
|
|
1325
|
+
// out of 18 and read by nobody: an instrument that works but has no consumer is a
|
|
1326
|
+
// log line, not evidence. A survey that is still empty after its reserved round is
|
|
1327
|
+
// now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
|
|
1328
|
+
// archived greens had an empty scout, so failing the run here would be a worse
|
|
1329
|
+
// error than the one being fixed.
|
|
1330
|
+
if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
|
|
520
1331
|
}
|
|
521
|
-
emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
|
|
522
1332
|
// F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
|
|
523
1333
|
// see — halt at the boundary rather than run the whole plan blind (the caller
|
|
524
1334
|
// emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
|
|
@@ -534,35 +1344,135 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
534
1344
|
* total run spend can never exceed the signed budget (advertised == enforced,
|
|
535
1345
|
* the hard line) — a drafter built once at full budget would let the replan
|
|
536
1346
|
* draft spend against money the steps had already consumed.
|
|
537
|
-
* @param {string} phase @param {string|null} failure
|
|
538
|
-
|
|
1347
|
+
* @param {string} phase @param {string|null} failure
|
|
1348
|
+
* @param {string} [progress] the meter's read, replan only (A's adaptation channel) */
|
|
1349
|
+
const obtainPlan = async (phase, failure, progress) => {
|
|
539
1350
|
const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
|
|
1351
|
+
// The BALANCE, read live at each draft — never a snapshot and never a rate
|
|
1352
|
+
// (addendum 2). At draft it is the whole budget (scale); at replan it is what
|
|
1353
|
+
// is actually left, which is the adaptation channel.
|
|
1354
|
+
const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
|
|
1355
|
+
emit('materials', { phase, ...materials });
|
|
1356
|
+
// D5 — the bridge rides into the FIRST draft phase ONLY. A replan drafts from the
|
|
1357
|
+
// RUN'S OWN STATE (what failed, what is left): re-handing the recipe there would
|
|
1358
|
+
// answer the replan's question with the material the run just demonstrated does not
|
|
1359
|
+
// work, and would spend the one revision the ceiling allows on a second unmeasured
|
|
1360
|
+
// arm. Exactly the rule the execution probe held at the provider seam, moved to where
|
|
1361
|
+
// the phase is known rather than inferred from the prompt's own text.
|
|
1362
|
+
// The REDRAFT (a validator rejection inside this same phase) keeps it: one red must
|
|
1363
|
+
// not silently convert a warm run to a cold one.
|
|
1364
|
+
const starting = phase === 'draft' ? startingDraft : null;
|
|
1365
|
+
// The shape-lottery facts, gate AND prompt from the ONE object (the scope-menu
|
|
1366
|
+
// precedent: what is stated and what is enforced can never drift apart).
|
|
1367
|
+
// `seedRed` rides every phase — the rule is defined on the SEED verdicts.
|
|
1368
|
+
// `priorChecks` is the REPLAN gate only: the accepted plan's carried checks,
|
|
1369
|
+
// which the redraft may move (A-v2 decides where) but never shed (Rule B —
|
|
1370
|
+
// u-msdsmkid's replan dropped both checks and "greened" on form alone).
|
|
1371
|
+
const checkFacts = {
|
|
1372
|
+
seedRed,
|
|
1373
|
+
...(phase === 'replan' && plan
|
|
1374
|
+
? { priorChecks: [...new Set(/** @type {any[]} */ (plan.steps).flatMap((/** @type {any} */ s) => (Array.isArray(s.exit) ? s.exit : []).filter((/** @type {any} */ e) => e?.type === 'check-passes' && typeof e.name === 'string').map((/** @type {any} */ e) => e.name)))] }
|
|
1375
|
+
: {}),
|
|
1376
|
+
};
|
|
540
1377
|
const draftPlan = async (/** @type {any[]|null} */ reds) => {
|
|
541
1378
|
drafter.setIteration(reds ? 'redraft' : 'draft');
|
|
542
|
-
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure), []);
|
|
1379
|
+
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts), []);
|
|
543
1380
|
return extractArtifact(r.text).code ?? '';
|
|
544
1381
|
};
|
|
1382
|
+
// Scrubbed HERE, once, before any consumer reads them — the judge() precedent
|
|
1383
|
+
// and the same V8-quoting class it was written for: `parse-error`'s detail is
|
|
1384
|
+
// JSON.parse's own message, and V8 quotes a window of the SOURCE inside it (a
|
|
1385
|
+
// body of 20 chars or fewer is quoted whole). That source is the DRAFT, which
|
|
1386
|
+
// the model wrote, so a red can carry model bytes onto `plan-validate`, onto
|
|
1387
|
+
// `plan-red`, and back into the redraft prompt — and the spine is append-only
|
|
1388
|
+
// forever (the hard line). The boundary is the right place and not the three
|
|
1389
|
+
// emit sites: scrubbing here makes the leak inexpressible, scrubbing each
|
|
1390
|
+
// consumer makes it something the next consumer has to remember.
|
|
1391
|
+
const validate = (/** @type {string} */ t) => {
|
|
1392
|
+
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
|
|
1393
|
+
return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
|
|
1394
|
+
};
|
|
545
1395
|
let text = await draftPlan(null);
|
|
546
|
-
let pv =
|
|
1396
|
+
let pv = validate(text);
|
|
547
1397
|
emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
|
|
548
1398
|
if (!pv.ok) {
|
|
549
1399
|
text = await draftPlan(pv.reds);
|
|
550
|
-
pv =
|
|
1400
|
+
pv = validate(text);
|
|
551
1401
|
emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
|
|
552
1402
|
}
|
|
553
1403
|
return pv;
|
|
554
1404
|
};
|
|
555
1405
|
let plan;
|
|
556
|
-
|
|
557
|
-
|
|
1406
|
+
/** RESUME: how many leading steps of the reloaded plan are already SATISFIED and are
|
|
1407
|
+
* therefore skipped. Zero on every other path, and reset to zero by a replan — the new
|
|
1408
|
+
* plan's steps are not the dead plan's steps, even where an id repeats. */
|
|
1409
|
+
let skipCount = 0;
|
|
1410
|
+
if (resumeSeed) {
|
|
1411
|
+
// The plan is RELOADED, not re-drafted — but it is re-validated against the spec that
|
|
1412
|
+
// is signed NOW. The dead run validated it against the spec it was signed under, and
|
|
1413
|
+
// between the kill and the resume the menu, the fence or the close can have moved
|
|
1414
|
+
// (registry/schema drift). A stale plan is refused BY NAME with zero spent; running
|
|
1415
|
+
// it would execute a plan the current signature does not admit, which is the one
|
|
1416
|
+
// thing no reconstruction is allowed to do.
|
|
1417
|
+
// The shape-lottery opts (seedRed/priorChecks) are deliberately NOT passed:
|
|
1418
|
+
// they are DRAFTING laws — this gate guards signature drift, and refusing a
|
|
1419
|
+
// paid, previously-legal plan over a shape rule minted after its draft would
|
|
1420
|
+
// burn the very work resume exists to keep (hamr: "why would i want to waste
|
|
1421
|
+
// more money on something i already started"). Omission = rules inactive, by
|
|
1422
|
+
// the validator's contract.
|
|
1423
|
+
const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu });
|
|
1424
|
+
// scrubbed at the boundary for `validate()`'s own reason: a `parse-error` detail
|
|
1425
|
+
// quotes a window of the SOURCE, and the spine is append-only forever
|
|
1426
|
+
const reds = pv.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
|
|
558
1427
|
if (!pv.ok) {
|
|
559
|
-
|
|
1428
|
+
emit('resume-plan-red', { reds, phase: resumeSeed.phase ?? null });
|
|
1429
|
+
for (const r of reds) emit('plan-red', r);
|
|
1430
|
+
emit('escalation', {
|
|
1431
|
+
category: 'plan-red', decisionReady: true, phase: 'resume',
|
|
1432
|
+
decision: 'The plan the killed run was executing no longer validates against this job spec, so the resume ran nothing. A reconstruction may re-use a plan; it may never run one the current signature does not admit.',
|
|
1433
|
+
options: [
|
|
1434
|
+
'rerun WITHOUT --resume (the run drafts a cold plan against the current spec — the killed run\'s steps are then not inherited)',
|
|
1435
|
+
'restore the spec version the dead run was signed under and resume again',
|
|
1436
|
+
],
|
|
1437
|
+
detail: reds.map((r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
|
|
1438
|
+
});
|
|
560
1439
|
return 'plan-red';
|
|
561
1440
|
}
|
|
562
1441
|
plan = /** @type {any} */ (pv.plan);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1442
|
+
// The skip set is a PREFIX match on the plan's own step ids, never a count: a count
|
|
1443
|
+
// would skip whatever happens to sit at that index. Steps run strictly sequentially
|
|
1444
|
+
// and idx only advances on a satisfied exit, so the dead run's completions ARE a
|
|
1445
|
+
// prefix — and a seed that does not line up stops the skipping there and says so on
|
|
1446
|
+
// the record, which re-runs work rather than skipping work nobody did (the safe
|
|
1447
|
+
// direction of the two).
|
|
1448
|
+
const completed = Array.isArray(resumeSeed.completedSteps) ? resumeSeed.completedSteps : [];
|
|
1449
|
+
while (skipCount < completed.length && skipCount < plan.steps.length
|
|
1450
|
+
&& completed[skipCount]?.id === plan.steps[skipCount].id) skipCount += 1;
|
|
1451
|
+
emit('resume-seed', {
|
|
1452
|
+
phase: resumeSeed.phase ?? null,
|
|
1453
|
+
planSteps: plan.steps.map((/** @type {any} */ s) => s.id),
|
|
1454
|
+
completed: completed.map((/** @type {any} */ c) => c?.id ?? null),
|
|
1455
|
+
skipping: skipCount,
|
|
1456
|
+
...(skipCount < completed.length
|
|
1457
|
+
? { divergence: `${completed.length} step(s) were completed but only ${skipCount} line up with the reloaded plan in order — the rest are RUN, never skipped on an id that does not match` }
|
|
1458
|
+
: {}),
|
|
1459
|
+
});
|
|
1460
|
+
// this leg's own plan-accepted: R1 mints a bridge version from the plan AS EXECUTED,
|
|
1461
|
+
// read back off the spine, so a resumed leg that greens without one would mint
|
|
1462
|
+
// nothing. `phase` says where it came from — it was accepted, not drafted.
|
|
1463
|
+
emit('plan-accepted', { plan, phase: 'resume' });
|
|
1464
|
+
} else {
|
|
1465
|
+
try {
|
|
1466
|
+
const pv = await obtainPlan('draft', null);
|
|
1467
|
+
if (!pv.ok) {
|
|
1468
|
+
for (const r of pv.reds) emit('plan-red', r);
|
|
1469
|
+
return 'plan-red';
|
|
1470
|
+
}
|
|
1471
|
+
plan = /** @type {any} */ (pv.plan);
|
|
1472
|
+
emit('plan-accepted', { plan });
|
|
1473
|
+
} catch (e) {
|
|
1474
|
+
return relay(e, 'plan');
|
|
1475
|
+
}
|
|
566
1476
|
}
|
|
567
1477
|
if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
|
|
568
1478
|
|
|
@@ -574,6 +1484,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
574
1484
|
const stepOutcomes = [];
|
|
575
1485
|
let replanned = false;
|
|
576
1486
|
const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
|
|
1487
|
+
/** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
|
|
1488
|
+
* step's attempt. It carries that stop's own record fields, and its presence is what
|
|
1489
|
+
* lets the step loop's terminal tell this reading apart from F64's mid-call one
|
|
1490
|
+
* without consulting the clock a second time (two clock reads for one event is how
|
|
1491
|
+
* the two instruments come to disagree). Exactly the fix loop's `wallStop`, one
|
|
1492
|
+
* layer up. */
|
|
1493
|
+
let stepWallStop = null;
|
|
577
1494
|
|
|
578
1495
|
/** @param {any} step */
|
|
579
1496
|
const executeStep = async (step) => {
|
|
@@ -598,11 +1515,115 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
598
1515
|
const root = layerRoot && !native
|
|
599
1516
|
? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
|
|
600
1517
|
: null;
|
|
601
|
-
|
|
1518
|
+
// P: the widened step fields, each wired or refused — a silently-ignored
|
|
1519
|
+
// optional field is the F50 blind-instrument class. `scope` narrows the fence to
|
|
1520
|
+
// a menu value (a subset by construction); `model` swaps the worker's provider
|
|
1521
|
+
// via the runner's factory — absent factory it must STOP, not silently run the
|
|
1522
|
+
// default tier as if the choice had been honoured. `attempts` is the third and
|
|
1523
|
+
// is now TOLERATED-INERT (see validatePlan): the ladder governs iterations by
|
|
1524
|
+
// progress, and a field that could only ever TIGHTEN a count is the wrong shape
|
|
1525
|
+
// for that — drafters measurably tightened it to 3, then to 2 on replan, which
|
|
1526
|
+
// is the opposite of the heal a converging step needs.
|
|
1527
|
+
if (step.model !== undefined && !providerFor) {
|
|
1528
|
+
const err = /** @type {any} */ (new Error(`step "${step.id}" selects model tier "${step.model}" but the runner supplied no providerFor factory — wiring gap, not a plan defect`));
|
|
1529
|
+
err.category = 'interpreter-red';
|
|
1530
|
+
throw err;
|
|
1531
|
+
}
|
|
1532
|
+
const w = await mkWorker({
|
|
1533
|
+
granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: null, writable: true, root,
|
|
1534
|
+
fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
|
|
1535
|
+
workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
|
|
1536
|
+
});
|
|
1537
|
+
// ONE ladder per step: the seen-set and the strike count are this step's own
|
|
1538
|
+
// history, and a shared one would strike a fresh step for a gap an earlier step
|
|
1539
|
+
// had already seen. The write signal is the gate audit's own record count (F32's
|
|
1540
|
+
// filter), never git status and never a tree diff.
|
|
1541
|
+
const ladder = createLadder({ limit: strikeLimit, writeCount: w.writeCount });
|
|
602
1542
|
let lastText = '';
|
|
603
1543
|
let iterationNow = 0;
|
|
1544
|
+
// The meter's baseline: what the RUN had left when this step began. Shares are
|
|
1545
|
+
// computed against this, never against the original budget — a late step is
|
|
1546
|
+
// measured against what actually remains for it, which is what "eating the run"
|
|
1547
|
+
// means. Read once, here, so the denominator cannot drift mid-step.
|
|
1548
|
+
const stepStartUsd = remainingUsd();
|
|
1549
|
+
const stepStartMs = clock.bounded ? clock.remainingMs() : Infinity;
|
|
604
1550
|
/** @param {number} iteration @param {string} [gap] */
|
|
605
1551
|
const middle = async (iteration, gap) => {
|
|
1552
|
+
// A — the variance check, at the HEAD of an attempt so no work is discarded
|
|
1553
|
+
// (attempts 1..n-1 accrued the spend; their writes are already on disk). Never
|
|
1554
|
+
// fires on attempt 1: the share is 0 there by construction. Throwing with a
|
|
1555
|
+
// category is the seam ralph already owns; it routes the category and the step
|
|
1556
|
+
// loop reads it as a REPLAN, not a stop.
|
|
1557
|
+
if (iteration > 1) {
|
|
1558
|
+
const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
|
|
1559
|
+
const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
|
|
1560
|
+
if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
|
|
1561
|
+
emit('variance', {
|
|
1562
|
+
step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
|
|
1563
|
+
moneyShare: Number(moneyShare.toFixed(3)),
|
|
1564
|
+
timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
|
|
1565
|
+
axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
|
|
1566
|
+
});
|
|
1567
|
+
const err = /** @type {CategorizedError} */ (new Error(
|
|
1568
|
+
`step "${step.id}" consumed ${Math.round(Math.max(moneyShare, timeShare) * 100)}% of the run's remaining `
|
|
1569
|
+
+ `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red`));
|
|
1570
|
+
err.category = 'step-variance';
|
|
1571
|
+
throw err;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
// T/W-2 at the STEP site, and it sits BELOW the meter deliberately. The meter is
|
|
1575
|
+
// the instrument for a step that was ALREADY RUNNING when the deadline passed:
|
|
1576
|
+
// that step reads a time share of 1 and routes to the ONE replan exactly as it
|
|
1577
|
+
// always has. The case it is structurally blind to is a step that STARTS past the
|
|
1578
|
+
// deadline — its share is measured against what the run had left when it began,
|
|
1579
|
+
// which is zero, so the share is zero forever and the step burns every attempt it
|
|
1580
|
+
// is allowed. (Measured on the real flow: three attempts, three judged exits, all
|
|
1581
|
+
// past the deadline.) The two are exhaustive and do not overlap: past the deadline
|
|
1582
|
+
// a step that began with time left reads a share of exactly 1, so the meter always
|
|
1583
|
+
// fires first for it — which is why this check only ever reaches attempt 1, and why
|
|
1584
|
+
// the routing of a step that was already running is untouched.
|
|
1585
|
+
//
|
|
1586
|
+
// Every one of those attempts is worthless by construction: the metered clock
|
|
1587
|
+
// check stops the worker on its FIRST round, so it writes nothing, and the judge
|
|
1588
|
+
// still runs the step's exits — themselves close stages, at the full close timeout
|
|
1589
|
+
// each — to re-mint the red already on the record. hamr's ruling, the same one the
|
|
1590
|
+
// close-fix loop applies: when time is up, keep the grade we already have and
|
|
1591
|
+
// stop. Resume-to-cap extended from money to time — the stop IS the checkpoint.
|
|
1592
|
+
//
|
|
1593
|
+
// The placement IS the mechanism, and the in-flight case falls out of it with
|
|
1594
|
+
// nothing added: a deadline landing DURING an attempt still lets that attempt and
|
|
1595
|
+
// its judge run to completion, because the clock is not consulted again until the
|
|
1596
|
+
// next attempt is about to start. Grading real work always completes — a wall that
|
|
1597
|
+
// kills grading leaves the run unreadable after the money is spent (F45).
|
|
1598
|
+
if (clock.expired()) {
|
|
1599
|
+
stepWallStop = {
|
|
1600
|
+
cutMidCall: false,
|
|
1601
|
+
phase: `step:${step.id}`,
|
|
1602
|
+
stepsDone: idx,
|
|
1603
|
+
stepsPlanned: plan.steps.length,
|
|
1604
|
+
attemptsUsed: iteration - 1,
|
|
1605
|
+
// The ladder's own state, because the ladder is what was governing this
|
|
1606
|
+
// step when the wall stopped it. This record used to carry `capRuns` and
|
|
1607
|
+
// `stepAttemptCap` — two counts that no longer bound anything here — and a
|
|
1608
|
+
// field name that means something it no longer measures is worse than an
|
|
1609
|
+
// absent one: a reader cannot tell it went stale. The FIX site's copy of this
|
|
1610
|
+
// record dropped `capRuns` for the same reason at v1.46 §4, when the count
|
|
1611
|
+
// stopped governing that loop too — neither wall record quotes a denominator
|
|
1612
|
+
// now, because neither loop has one.
|
|
1613
|
+
strikes: ladder.report().strikes,
|
|
1614
|
+
strikeLimit: ladder.report().limit,
|
|
1615
|
+
distinctGaps: ladder.report().distinctGaps,
|
|
1616
|
+
};
|
|
1617
|
+
// No trend here, and none invented: the trend instrument reads CLOSE grades,
|
|
1618
|
+
// and a step that never ran has no grade of its own to compare. The lever the
|
|
1619
|
+
// human needs is on the run-level record either way.
|
|
1620
|
+
const err = /** @type {CategorizedError} */ (new Error(
|
|
1621
|
+
`the wall-clock cap passed before step "${step.id}" attempt ${iteration} could start — `
|
|
1622
|
+
+ `the step was never funded to run. ${idx} of ${plan.steps.length} step(s) completed before it. `
|
|
1623
|
+
+ 'Nothing after the deadline is allowed to change the verdict the run already has.'));
|
|
1624
|
+
err.category = 'wall-halt';
|
|
1625
|
+
throw err;
|
|
1626
|
+
}
|
|
606
1627
|
w.setIteration(iteration);
|
|
607
1628
|
iterationNow = iteration;
|
|
608
1629
|
// Layer R observe: finalize the prior attempt from the books (workerWrites
|
|
@@ -623,8 +1644,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
623
1644
|
].filter(Boolean).join('\n\n'));
|
|
624
1645
|
lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
|
|
625
1646
|
};
|
|
1647
|
+
let lastGap = '';
|
|
626
1648
|
const judge = async () => {
|
|
627
|
-
const { pass, results } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
|
|
1649
|
+
const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
|
|
1650
|
+
// Scrubbed HERE, once, before the results are read by anyone. An exit detail is
|
|
1651
|
+
// supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own
|
|
1652
|
+
// message and V8 quotes a window of the SOURCE inside it (a body of 20 chars or
|
|
1653
|
+
// fewer is quoted whole) — so the detail can carry FILE BYTES the worker chose,
|
|
1654
|
+
// and the spine is append-only forever (the hard line). ONE inventory
|
|
1655
|
+
// (SECRET_PATTERNS), the same `scrub` the close output and the ctx-verb channel
|
|
1656
|
+
// already ride through — never a second spelling of what a secret looks like.
|
|
1657
|
+
// The boundary is the right place and not the three call sites below it: these
|
|
1658
|
+
// same details become the `exit-eval` record, a fault's escalation `detail`
|
|
1659
|
+
// (via ralph's close-verdict + CLOSE_FAULTS emissions), and the worker gap
|
|
1660
|
+
// (which ralph also puts on the spine as close-verdict.gap). Scrubbing the
|
|
1661
|
+
// results makes the leak inexpressible; scrubbing each consumer makes it
|
|
1662
|
+
// something the next consumer has to remember.
|
|
1663
|
+
const results = raw.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
|
|
628
1664
|
emit('exit-eval', { step: step.id, iteration: iterationNow, results });
|
|
629
1665
|
// an instrument fault rides out by its runClose verdict NAME: ralph
|
|
630
1666
|
// escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
|
|
@@ -632,15 +1668,38 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
632
1668
|
if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
|
|
633
1669
|
if (pass) return { verdict: 'satisfied' };
|
|
634
1670
|
// AND-only: the gap names EVERY failing wall (mechanical genre, F38)
|
|
635
|
-
|
|
1671
|
+
lastGap = results.filter((r) => !r.pass).map((r) => r.detail).join('\n');
|
|
1672
|
+
return { verdict: 'needs_revision', gap: lastGap };
|
|
636
1673
|
};
|
|
637
|
-
const outcome = await ralph({ middle, judge,
|
|
638
|
-
|
|
1674
|
+
const outcome = await ralph({ middle, judge, ladder, emit: emitL, workerWrites: w.workerWrites });
|
|
1675
|
+
// gap + writes ride out for the replan brief's mismatch line (the books this
|
|
1676
|
+
// step already kept; the escalation's detail carries the meter/strike sentence,
|
|
1677
|
+
// never the gap, so the brief cannot recover this fact from anywhere else)
|
|
1678
|
+
return { outcome, artifact: lastText, ladder, writes: w.workerWrites(), gap: lastGap };
|
|
639
1679
|
};
|
|
640
1680
|
|
|
641
1681
|
let idx = 0;
|
|
642
1682
|
while (idx < plan.steps.length) {
|
|
643
1683
|
const step = plan.steps[idx];
|
|
1684
|
+
// RESUME: this step's exit was already satisfied before the kill. It is not re-run —
|
|
1685
|
+
// that is the money and the time the ruling is about — and it is not silent either:
|
|
1686
|
+
// the record is its OWN type carrying the event that proves it, so a reader can tell
|
|
1687
|
+
// a skipped step from a run one (the resume-to-cap invariant, which a fake
|
|
1688
|
+
// step-start/step-end pair would destroy). The step's ARTIFACT is not reconstructed:
|
|
1689
|
+
// its text was a prompt ingredient, never a checkpoint, and its real product is on
|
|
1690
|
+
// disk where the next step will find it.
|
|
1691
|
+
if (idx < skipCount) {
|
|
1692
|
+
const proof = resumeSeed.completedSteps[idx];
|
|
1693
|
+
emit('step-skipped', {
|
|
1694
|
+
step: step.id,
|
|
1695
|
+
provenBy: proof?.by ?? null,
|
|
1696
|
+
provenSeq: typeof proof?.seq === 'number' ? proof.seq : null,
|
|
1697
|
+
meaning: 'the killed run satisfied this step\'s exits — it is not re-run and not re-paid',
|
|
1698
|
+
});
|
|
1699
|
+
stepOutcomes.push({ id: step.id, outcome: 'skipped' });
|
|
1700
|
+
idx += 1;
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
644
1703
|
let res;
|
|
645
1704
|
try {
|
|
646
1705
|
res = await executeStep(step);
|
|
@@ -655,7 +1714,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
655
1714
|
const err = /** @type {CategorizedError} */ (e);
|
|
656
1715
|
const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
|
|
657
1716
|
stepOutcomes.push({ id: step.id, outcome: category });
|
|
658
|
-
if (category === 'cap-halt')
|
|
1717
|
+
if (category === 'cap-halt') {
|
|
1718
|
+
emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
|
|
1719
|
+
// …and the MONEY record beside it, as every other cap-halt site emits one. A
|
|
1720
|
+
// readout a human gets at three seams and not at the fourth is the readout being
|
|
1721
|
+
// a coincidence of where the stop happened. UNREACHABLE today — nothing in
|
|
1722
|
+
// mkWorker/gate.init/snapshotScope buys a round, so no wallet can drain here —
|
|
1723
|
+
// which is why this is symmetry rather than a fix, and why it stays a `HaltError`
|
|
1724
|
+
// branch instead of a widened one.
|
|
1725
|
+
emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
1726
|
+
}
|
|
659
1727
|
emit('escalation', {
|
|
660
1728
|
category, decisionReady: true, phase: `step:${step.id}`,
|
|
661
1729
|
decision: category === 'cap-halt'
|
|
@@ -674,6 +1742,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
674
1742
|
if (res.outcome === 'green') {
|
|
675
1743
|
artifacts.push({ id: step.id, text: res.artifact });
|
|
676
1744
|
idx += 1;
|
|
1745
|
+
// T's terminal: a run that cannot fit its time budget STOPS, and the stop is
|
|
1746
|
+
// the result (build-ladder discipline, v1.27 — unchanged). Checked after a
|
|
1747
|
+
// GREEN step too, not just a red one: the alternative silently starts a step
|
|
1748
|
+
// it cannot fund, which is the same class as the money cap binding mid-attempt
|
|
1749
|
+
// (F45). Resume-to-cap applies exactly as it does to money — the stop IS the
|
|
1750
|
+
// checkpoint, so the completed steps are not wasted.
|
|
1751
|
+
if (clock.expired() && idx < plan.steps.length) {
|
|
1752
|
+
emitWallHalt({ stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
1753
|
+
emit('escalation', {
|
|
1754
|
+
category: 'wall-halt', decisionReady: true, phase: `step:${step.id}`,
|
|
1755
|
+
decision: `The run reached its wall-clock cap after ${idx} of ${plan.steps.length} steps. Time ran out, not capability.`,
|
|
1756
|
+
options: WALL_OPTIONS,
|
|
1757
|
+
detail: `requested ${clock.requestedMs}ms, elapsed ${clock.elapsedMs()}ms`,
|
|
1758
|
+
});
|
|
1759
|
+
planExecuted();
|
|
1760
|
+
return 'wall-halt';
|
|
1761
|
+
}
|
|
677
1762
|
continue;
|
|
678
1763
|
}
|
|
679
1764
|
// ONE replan, and only for EXHAUSTION with FUNDS LEFT (review #5): ralph
|
|
@@ -685,16 +1770,70 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
685
1770
|
// exhaustion leaves money on the table. An instrument/governance stop that
|
|
686
1771
|
// is not cap-halt never replans either. (An unpriced step already returned
|
|
687
1772
|
// pricing-red at the step-end guard above, so isUnpriced() is false here.)
|
|
1773
|
+
//
|
|
1774
|
+
// A (PRD v1.27) adds the SECOND trigger: `step-variance` — the meter stopped a
|
|
1775
|
+
// step that had eaten a declared share of the run with its exits unmoved. That
|
|
1776
|
+
// is the case exhaustion-only could never reach (F56: a replan has fired zero
|
|
1777
|
+
// times in the programme), and it is a genuine re-allocation rather than a stop.
|
|
1778
|
+
// Both triggers still need FUNDS LEFT, and both are still bounded by the ONE
|
|
1779
|
+
// replan ceiling — this changes the trigger, never the ceiling (v1.12).
|
|
688
1780
|
const cat = lastEscalation?.category;
|
|
689
|
-
|
|
1781
|
+
// F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
|
|
1782
|
+
// model call MAX_STALLS times and never got a round back. hamr's ruling is that
|
|
1783
|
+
// a stall must self-heal rather than end the run ("killing and coming back is
|
|
1784
|
+
// not an option"), so the watchdog reissues silently; only once reissuing has
|
|
1785
|
+
// demonstrably stopped working does the run adapt instead — by replanning, not
|
|
1786
|
+
// by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
|
|
1787
|
+
// other two: this changes the trigger, never the ceiling (v1.12).
|
|
1788
|
+
const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
|
|
1789
|
+
: cat === 'step-variance' ? 'the meter stopped a step that was consuming the run with its exits unmoved'
|
|
1790
|
+
: cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
|
|
1791
|
+
: null;
|
|
1792
|
+
if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
|
|
690
1793
|
replanned = true;
|
|
691
|
-
emit('replan', { step: step.id, reason:
|
|
692
|
-
|
|
1794
|
+
emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
|
|
1795
|
+
// The brief names the trigger HONESTLY, one branch per trigger: it is the
|
|
1796
|
+
// only channel the redrafting planner adapts to, so a wrong diagnosis here
|
|
1797
|
+
// is handed to the one component whose whole job is to respond to it. A
|
|
1798
|
+
// stall in particular never judged its exits at all — the exhaustion
|
|
1799
|
+
// sentence would invent a red the run never saw (F28's rule, replan side).
|
|
1800
|
+
// The exhaustion branch is the one the ladder rewrote. "It ran N attempts and
|
|
1801
|
+
// its exits were still red" described a bound that no longer exists AND said
|
|
1802
|
+
// nothing about the mechanism — the planner could not tell a step that was
|
|
1803
|
+
// converging from one repeating itself, which are opposite problems with
|
|
1804
|
+
// opposite fixes. The ladder's own books answer that, and the balance says what
|
|
1805
|
+
// the stop left on the table (the u-msd916dh reading: $1.30 and ~6min unspent
|
|
1806
|
+
// behind a step that was still shrinking its error count). Time is reported as
|
|
1807
|
+
// UNBOUNDED when the operator set no wall — never rendered as a number (F6
|
|
1808
|
+
// extends to time).
|
|
1809
|
+
const leftOver = `$${remainingUsd().toFixed(2)} and `
|
|
1810
|
+
+ `${clock.bounded ? `${Math.round(clock.remainingMs() / 60_000)} minute(s)` : 'time UNBOUNDED'} `
|
|
1811
|
+
+ 'of the run were still unspent when it stopped.';
|
|
1812
|
+
const why = cat === 'step-variance'
|
|
1813
|
+
? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
|
|
1814
|
+
: cat === 'step-stalled'
|
|
1815
|
+
? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
|
|
1816
|
+
: `${res.ladder.brief()} ${leftOver}`;
|
|
1817
|
+
// The prose-prohibition trap's mechanical fact (u-msdpuaej): gap-named files
|
|
1818
|
+
// the step never wrote. Computed from the step's own books; capped with the
|
|
1819
|
+
// trim announced (the F32 gap convention). Empty extraction = no line.
|
|
1820
|
+
const missed = gapFilesNeverWritten(res?.gap, res?.writes);
|
|
1821
|
+
const missedLine = missed.length
|
|
1822
|
+
? `The last exit output names file(s) this step never wrote: ${missed.slice(0, 8).join(', ')}${missed.length > 8 ? ` (+${missed.length - 8} more)` : ''}.\n`
|
|
1823
|
+
: '';
|
|
1824
|
+
const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
|
|
1825
|
+
+ `${why}\n`
|
|
1826
|
+
+ missedLine
|
|
693
1827
|
+ `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
|
|
694
1828
|
+ `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
|
|
1829
|
+
// The progress line IS the adaptation channel (addendum 2): the balance rides
|
|
1830
|
+
// in via obtainPlan's live read, and this says where the run got to. Both are
|
|
1831
|
+
// the planner re-allocating what remains across what is left — never a rate.
|
|
1832
|
+
const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
|
|
1833
|
+
+ `${artifacts.length} step(s) completed before it`;
|
|
695
1834
|
let pv;
|
|
696
1835
|
try {
|
|
697
|
-
pv = await obtainPlan('replan', failure);
|
|
1836
|
+
pv = await obtainPlan('replan', failure, progress);
|
|
698
1837
|
} catch (e) {
|
|
699
1838
|
planExecuted();
|
|
700
1839
|
return relay(e, 'replan');
|
|
@@ -711,15 +1850,51 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
711
1850
|
// it names steps the current plan does not contain)
|
|
712
1851
|
artifacts.length = 0;
|
|
713
1852
|
idx = 0;
|
|
1853
|
+
// and for exactly the same reason a RESUME's skip set dies here: an id the new
|
|
1854
|
+
// plan happens to re-use names a step this run never did, so inheriting the
|
|
1855
|
+
// dead run's green for it would skip work nobody has done
|
|
1856
|
+
skipCount = 0;
|
|
714
1857
|
continue;
|
|
715
1858
|
}
|
|
1859
|
+
// F64 — the wall stopped this attempt from INSIDE a provider call (the derived
|
|
1860
|
+
// call timeout is the deadline in the provider's own coin). ralph already
|
|
1861
|
+
// emitted the escalation under this category, so the outcome and the record
|
|
1862
|
+
// agree (F11); what is still missing is the run-level TIME record, which the
|
|
1863
|
+
// between-steps site emits and this path must too. Never a replan trigger and
|
|
1864
|
+
// never a casualty: time ran out, and the stop IS the checkpoint.
|
|
1865
|
+
// W-2 adds the step loop's SECOND wall reading: the deadline seen at the head of
|
|
1866
|
+
// an attempt, with no call in flight. `stepWallStop` is that reading's own record
|
|
1867
|
+
// (attempts used, steps done); its absence means the deadline landed inside a call
|
|
1868
|
+
// instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
|
|
1869
|
+
// on exactly that field, so conflating the two would report an unknown as exact.
|
|
1870
|
+
if (cat === 'wall-halt') {
|
|
1871
|
+
emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
1872
|
+
planExecuted();
|
|
1873
|
+
return 'wall-halt';
|
|
1874
|
+
}
|
|
716
1875
|
// A money-gate halt (wallet drained) is an honest cap-halt terminal, never a
|
|
717
1876
|
// step-red: the exits never ran because the money ran out, not because the
|
|
718
1877
|
// work failed. Attempt-exhaustion WITH funds after the one replan is spent
|
|
719
1878
|
// stays a step-red (the stop is a result).
|
|
720
1879
|
if (cat === 'cap-halt') {
|
|
721
1880
|
planExecuted();
|
|
722
|
-
|
|
1881
|
+
if (remainingUsd() > MONEY_MIN) return `step-red:${step.id}`;
|
|
1882
|
+
// v1.46 §2: the money cut is decision-ready HERE too, and for the same reason
|
|
1883
|
+
// W-2 gave the wall a record at every site it stops a run — a category that
|
|
1884
|
+
// hands the human a readout at one seam and silence at another is the readout
|
|
1885
|
+
// being a coincidence of where the stop happened.
|
|
1886
|
+
emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
1887
|
+
return 'cap-halt';
|
|
1888
|
+
}
|
|
1889
|
+
// A second `step-variance` after the one replan is spent is a STOP, and the stop
|
|
1890
|
+
// is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
|
|
1891
|
+
// outcome. The category is a replan TRIGGER, never a run verdict: leaking it
|
|
1892
|
+
// upward would mint an outcome run.js and the ledger's class table do not know,
|
|
1893
|
+
// and an unmapped category is counted as a library bug (ledger.js) when this is a
|
|
1894
|
+
// planning story. The escalation ralph already emitted carries the real detail.
|
|
1895
|
+
if (cat === 'step-variance') {
|
|
1896
|
+
planExecuted();
|
|
1897
|
+
return `step-red:${step.id}`;
|
|
723
1898
|
}
|
|
724
1899
|
// Any OTHER terminal escalation category is NOT a capability failure: a
|
|
725
1900
|
// provider-red is a transport CASUALTY, a gate-red/interpreter-red/close
|
|
@@ -734,7 +1909,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
734
1909
|
// ── 4. THE CLOSE — the operator's signed command, the only truth. Red →
|
|
735
1910
|
// the gap feeds ONE bounded fix loop judged by the REAL close (v1.12 §4);
|
|
736
1911
|
// still red → the escalation ralph already emitted stands.
|
|
737
|
-
const post =
|
|
1912
|
+
const post = await judgeClose();
|
|
738
1913
|
emit('outer-close', { ...post });
|
|
739
1914
|
if (post.verdict === 'satisfied') {
|
|
740
1915
|
planExecuted();
|
|
@@ -748,22 +1923,183 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
748
1923
|
}
|
|
749
1924
|
emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
|
|
750
1925
|
let fixOutcome;
|
|
1926
|
+
/** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
|
|
1927
|
+
* iterations. It carries that stop's own record fields, and its presence is what
|
|
1928
|
+
* lets the post-loop branch tell this reading apart from F64's mid-call one
|
|
1929
|
+
* without consulting the clock a second time (two clock reads for one event is
|
|
1930
|
+
* how the two instruments come to disagree). */
|
|
1931
|
+
let wallStop = null;
|
|
1932
|
+
/** how many fix ITERATIONS the loop actually bought — counted here rather than
|
|
1933
|
+
* read back off the trend reader, which lives inside the try and is out of scope
|
|
1934
|
+
* by the time a terminal needs the number. */
|
|
1935
|
+
let fixIterationsUsed = 0;
|
|
751
1936
|
try {
|
|
752
1937
|
// Layer R for the close-fix loop — the plan flow's single ralph loop judged
|
|
753
|
-
// by the REAL close (the
|
|
1938
|
+
// by the REAL close (the plan flow's single ralph loop, and the
|
|
754
1939
|
// likeliest place fixation manifests: the fix worker has the full menu and
|
|
755
|
-
// is judged by a command, not a form-only exit). The red-set
|
|
756
|
-
//
|
|
1940
|
+
// is judged by a command, not a form-only exit). The red-set comes from the
|
|
1941
|
+
// stage that ACTUALLY rendered the verdict this attempt, not from one pattern
|
|
1942
|
+
// fixed for the run: a staged close has N gapKeeps and the wall the worker hit
|
|
1943
|
+
// varies attempt to attempt (the gap here is the raw close output, so the
|
|
757
1944
|
// `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
|
|
758
1945
|
// native exclusion (no onToolResult seam ⇒ the tee cannot settle).
|
|
759
1946
|
const fixRoot = layerRoot && !native
|
|
760
|
-
? createRoot({
|
|
1947
|
+
? createRoot({ redact: scrub, writesInformative: true })
|
|
761
1948
|
: null;
|
|
762
|
-
|
|
1949
|
+
// `attempts: null` is the STRIKE-LADDER shape (mkWorker's own contract): the fix
|
|
1950
|
+
// loop's iteration count no longer exists at construction time, so the gate's
|
|
1951
|
+
// turn allowance is granted per iteration at `setIteration` instead of being
|
|
1952
|
+
// pre-multiplied by a number nobody signed. The per-iteration bound is
|
|
1953
|
+
// unchanged — `loop.stop()` at `attemptRounds` — which is the belt that actually
|
|
1954
|
+
// holds. (v1.46 §4: `capRuns` retires as the governor.)
|
|
1955
|
+
const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: null, writable: true, root: fixRoot });
|
|
1956
|
+
// ── v1.46 §4: the fix loop's GOVERNOR — progress, not a count ──
|
|
1957
|
+
//
|
|
1958
|
+
// MEASURED reason, the same shape as the step ladder's. The fixed `capRuns`
|
|
1959
|
+
// could not tell converging work from thrash, so it stopped both at the same
|
|
1960
|
+
// place: u-msew1uy5 was cut mid-convergence at fix iteration 3 while
|
|
1961
|
+
// reuse-msc6w93z sat dead flat at 2 errors for SEVEN fix verdicts until the wall
|
|
1962
|
+
// killed it. The $0 replay over all 8 archived fix loops came back clean both
|
|
1963
|
+
// directions — 0 greens harmed (every historical green converted in <= 2
|
|
1964
|
+
// verdicts), 1 waste case caught.
|
|
1965
|
+
//
|
|
1966
|
+
// The reader is SEEDED with the grade the loop opened on, because that is what
|
|
1967
|
+
// the first fix attempt is measured against; without it the first attempt would
|
|
1968
|
+
// have nothing to be compared to and the loop would spend a free iteration
|
|
1969
|
+
// establishing what it already knew.
|
|
1970
|
+
//
|
|
1971
|
+
// `blindCap: capRuns` is not the count surviving in disguise. It binds ONLY
|
|
1972
|
+
// while the instrument has never been able to compare anything (a close whose
|
|
1973
|
+
// output carries no number at all), which is precisely the case where a strike
|
|
1974
|
+
// would be minted out of ignorance. The moment a stage reports a comparable
|
|
1975
|
+
// number the count is gone, and money and the wall are what remain.
|
|
1976
|
+
//
|
|
1977
|
+
// DELIBERATELY NOT SEEDED with `resumeGrades`, and this is the one place the
|
|
1978
|
+
// resume chain stops. The two readers answer two different questions (see
|
|
1979
|
+
// `runTrend` above, and src/trend.js's "ONE PER SERIES"): the halt readout asks
|
|
1980
|
+
// *was the RUN converging when its allowance cut it*, which spans both legs; this
|
|
1981
|
+
// governor asks *is THIS loop out of ideas*, which is leg-local by definition.
|
|
1982
|
+
// Seeding it was built and measured: a resumed loop that struck out dead flat
|
|
1983
|
+
// rendered its own terminal as "still progressing — verdict 2 → 1 → 1 → 1 → 1",
|
|
1984
|
+
// because the chain HAD fallen before the handover — a record contradicting
|
|
1985
|
+
// itself, offering a conditional top-up ("if the trend above says it was still
|
|
1986
|
+
// converging") on the exact run that had just proven it was not. It would also
|
|
1987
|
+
// have moved the STRIKE rule on a resume (an attempt judged against the best the
|
|
1988
|
+
// run ever reached rather than against the grade this loop opened on) — an
|
|
1989
|
+
// unmeasured governance change, which is arbiter-adjacent territory. Both are the
|
|
1990
|
+
// same error: one instance answering two questions.
|
|
1991
|
+
const fixTrend = createTrend({
|
|
1992
|
+
stageOrder: stagedClose.map((/** @type {any} */ s) => s.name),
|
|
1993
|
+
limit: FIX_STRIKE_LIMIT,
|
|
1994
|
+
blindCap: capRuns,
|
|
1995
|
+
});
|
|
1996
|
+
fixTrend.record({ gap: post.gap ?? '' });
|
|
1997
|
+
/** ralph's `ladder` seam, filled by the trend reader instead of the step
|
|
1998
|
+
* ladder's repeat/write pair. ONE exhaustion terminal, two triggers (ralph's own
|
|
1999
|
+
* rule): the category stays `cap-halt` and the outcome stays `escalated`, so the
|
|
2000
|
+
* ledger's class table, bridge grading and every downstream reader are untouched
|
|
2001
|
+
* — only the TRIGGER changed. What it overrides is the terminal's PROSE: the
|
|
2002
|
+
* step ladder's copy offers a replan, and there is no planner at the close. */
|
|
2003
|
+
const fixGovernor = {
|
|
2004
|
+
record: (/** @type {{iteration: number, gap?: string}} */ o) => {
|
|
2005
|
+
fixIterationsUsed = o.iteration;
|
|
2006
|
+
return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
|
|
2007
|
+
},
|
|
2008
|
+
struckOut: fixTrend.struckOut,
|
|
2009
|
+
report: fixTrend.report,
|
|
2010
|
+
brief: () => fixTrend.verdict().reading,
|
|
2011
|
+
terminal: () => {
|
|
2012
|
+
const rep = fixTrend.report();
|
|
2013
|
+
const t = fixTrend.verdict();
|
|
2014
|
+
return {
|
|
2015
|
+
decision: rep.blind
|
|
2016
|
+
? `${rep.iterations - 1} fix iteration(s) spent and the close still red. Its output carries no number this run can compare, so no progress reading exists — the retired iteration count is what bounded the loop. Continue, change approach, or stop?`
|
|
2017
|
+
: `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
|
|
2018
|
+
options: [
|
|
2019
|
+
'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
|
|
2020
|
+
'top up budgetUsd and rerun with --resume, if the trend above says it was still converging',
|
|
2021
|
+
'abandon the task',
|
|
2022
|
+
],
|
|
2023
|
+
};
|
|
2024
|
+
},
|
|
2025
|
+
};
|
|
763
2026
|
/** @param {number} iteration @param {string} [gap] */
|
|
764
2027
|
const middle = async (iteration, gap) => {
|
|
2028
|
+
// T/W-2 — the wall, read before a NEW fix attempt is started, and never
|
|
2029
|
+
// around the close itself. hamr's ruling: *"when time is up, keep the grade
|
|
2030
|
+
// we already have and stop … run tests (free) and when done if original time
|
|
2031
|
+
// is past due, pause and ask user to increase time or adjust prompt"*.
|
|
2032
|
+
//
|
|
2033
|
+
// Past the deadline this iteration is guaranteed to be worthless: the metered
|
|
2034
|
+
// round check stops the fix worker on its FIRST round, so it writes nothing,
|
|
2035
|
+
// and the iteration then re-runs the FULL staged close over an unchanged tree
|
|
2036
|
+
// (four stages × closeTimeoutMs = 60 minutes on the shipped spec) only to mint
|
|
2037
|
+
// the verdict already on the record — capRuns times over. So the run stops
|
|
2038
|
+
// here, ON that verdict. Resume-to-cap extended from money to time: the stop
|
|
2039
|
+
// IS the checkpoint, and the human tops up (v1.12).
|
|
2040
|
+
//
|
|
2041
|
+
// The placement IS the mechanism, and the in-flight case falls out of it with
|
|
2042
|
+
// nothing added: a deadline that lands DURING an attempt still lets that
|
|
2043
|
+
// attempt's close run to completion, because the clock is not consulted again
|
|
2044
|
+
// until the next iteration is about to start. The close is never bounded — a
|
|
2045
|
+
// wall that kills grading leaves the run unreadable after the money is spent
|
|
2046
|
+
// (the F45 class), so the one thing this must never do is stop a close.
|
|
2047
|
+
if (clock.expired()) {
|
|
2048
|
+
// ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
|
|
2049
|
+
// This site used to run its own `gapTrend`: byte equality of the last two
|
|
2050
|
+
// close gaps, reading `stalled`/`moving` beside the money halt's per-stage
|
|
2051
|
+
// `flat`/`converging` on the very same run. Two readers for one question is
|
|
2052
|
+
// how the two come to disagree, and the byte reader could only ever report
|
|
2053
|
+
// MOTION — that something changed, never which way. Folded into src/trend.js,
|
|
2054
|
+
// where a per-stage NUMBER decides whenever one exists and the byte
|
|
2055
|
+
// comparison survives as the reading inside `unknown` (never as a direction:
|
|
2056
|
+
// "the output changed" is not "it got better", which is F6 in a trend's coat).
|
|
2057
|
+
// `runTrend` is the run's own reader — the same instance the money halt reads
|
|
2058
|
+
// — so a resume's inherited baselines reach this readout too.
|
|
2059
|
+
const t = runTrend.verdict();
|
|
2060
|
+
wallStop = {
|
|
2061
|
+
cutMidCall: false,
|
|
2062
|
+
phase: 'fix',
|
|
2063
|
+
iterationsUsed: iteration - 1,
|
|
2064
|
+
// `capRuns` used to ride here as the DENOMINATOR ("0 of 3"). It retired as
|
|
2065
|
+
// this loop's governor (v1.46 §4), so quoting it as the total would state a
|
|
2066
|
+
// bound that no longer decides anything — the iterations spent are the fact,
|
|
2067
|
+
// and there is no longer a number they are "of".
|
|
2068
|
+
verdict: lastCloseVerdict?.verdict,
|
|
2069
|
+
...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
|
|
2070
|
+
trend: t.trend,
|
|
2071
|
+
// MOTION rides as its OWN field rather than being mapped onto `trend`.
|
|
2072
|
+
// Mapping `unchanged` → `flat` would dress a byte comparison as a measured
|
|
2073
|
+
// direction on the spine, which is precisely what the unification refuses;
|
|
2074
|
+
// a separate field keeps the old signal's substance without the false
|
|
2075
|
+
// promotion. (Grepped first: no src/ or scripts/ reader consumed the old
|
|
2076
|
+
// `stalled`/`moving` values — only this file's own tests, updated with it.)
|
|
2077
|
+
motion: t.motion,
|
|
2078
|
+
};
|
|
2079
|
+
// The detail carries what the human needs to pick a lever and nothing else:
|
|
2080
|
+
// the verdict that stands, how much of the loop was actually spent, and the
|
|
2081
|
+
// trend that says which of the two spec edits fits. It never names a culprit
|
|
2082
|
+
// file (F28) — that is the worker's job, not the escalation's.
|
|
2083
|
+
const err = /** @type {CategorizedError} */ (new Error(
|
|
2084
|
+
`the wall-clock cap passed before fix attempt ${iteration} could start. `
|
|
2085
|
+
+ `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
|
|
2086
|
+
+ `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
|
|
2087
|
+
+ `after ${iteration - 1} fix iteration(s). `
|
|
2088
|
+
+ `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
|
|
2089
|
+
err.category = 'wall-halt';
|
|
2090
|
+
throw err;
|
|
2091
|
+
}
|
|
765
2092
|
w.setIteration(iteration);
|
|
766
|
-
|
|
2093
|
+
// the red-set's source travels WITH the gap: `closeStage` is the stage the
|
|
2094
|
+
// judge stopped at for the attempt this gap came from, and its own gapKeep
|
|
2095
|
+
// designates that stage's failures
|
|
2096
|
+
const rootInj = fixRoot
|
|
2097
|
+
? fixRoot.observe({
|
|
2098
|
+
iteration, gap, writes: w.workerWrites(),
|
|
2099
|
+
redStage: closeStage,
|
|
2100
|
+
redKeep: stagedClose.find((s2) => s2.name === closeStage)?.gapKeep,
|
|
2101
|
+
})
|
|
2102
|
+
: null;
|
|
767
2103
|
if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
|
|
768
2104
|
await w.ask([
|
|
769
2105
|
'The job\'s final verification is failing. Fix the repository so it passes.',
|
|
@@ -775,14 +2111,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
775
2111
|
].filter(Boolean).join('\n\n'));
|
|
776
2112
|
};
|
|
777
2113
|
fixOutcome = await ralph({
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
2114
|
+
// the staged close rides in as the JUDGE seam (one verdict, every stage in
|
|
2115
|
+
// order) — ralph's single-command path stays exactly what it was
|
|
2116
|
+
// v1.46 §4: the PROGRESS governor rides in where `capRuns` used to. ralph's
|
|
2117
|
+
// two exhaustion rules are alternatives and never both at once, so the count
|
|
2118
|
+
// is not passed — it survives only inside the governor, as the bound for the
|
|
2119
|
+
// case the trend instrument is blind.
|
|
2120
|
+
middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
|
|
2121
|
+
closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
|
|
781
2122
|
});
|
|
782
2123
|
} catch (e) {
|
|
783
2124
|
planExecuted();
|
|
784
2125
|
return relay(e, 'fix');
|
|
785
2126
|
}
|
|
786
2127
|
planExecuted();
|
|
2128
|
+
if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
|
|
2129
|
+
// F64 in the close-fix loop: the same governance stop, and it must not ride out
|
|
2130
|
+
// as a bare `escalated` either — that reads as "the fix failed" when the fix was
|
|
2131
|
+
// never given the time to run.
|
|
2132
|
+
// W-2 adds the loop's SECOND wall reading: the deadline seen between iterations,
|
|
2133
|
+
// where a real grade already exists and is kept. `wallStop` is that reading's own
|
|
2134
|
+
// record (verdict, iterations used, trend); its absence means the deadline landed
|
|
2135
|
+
// inside a call instead, which is F64's cutMidCall stop.
|
|
2136
|
+
emitWallHalt(wallStop ?? { cutMidCall: true, phase: 'fix' });
|
|
2137
|
+
return 'wall-halt';
|
|
2138
|
+
}
|
|
2139
|
+
if (fixOutcome !== 'green' && lastEscalation?.category === 'cap-halt') {
|
|
2140
|
+
// ralph spells BOTH of its terminals cap-halt — attempt-exhaustion AND a
|
|
2141
|
+
// money-gate halt thrown mid-attempt — so the category alone cannot tell them
|
|
2142
|
+
// apart, and the wallet is the only instrument that can. The step loop already
|
|
2143
|
+
// splits this exact pair on the same reading; the fix loop must too, because
|
|
2144
|
+
// the fix worker's gate is built with the wallet at its MOST drained (every
|
|
2145
|
+
// step's spend is behind it), which is precisely where a money cut masquerades
|
|
2146
|
+
// as "the fix failed". F45: a money cut is never a capability read — it rides
|
|
2147
|
+
// out as cap-halt, the resume-to-cap checkpoint (the stop IS the checkpoint).
|
|
2148
|
+
// Attempts spent with money still on the table stays the designed terminal
|
|
2149
|
+
// ("close still red") and keeps riding out as `escalated` below.
|
|
2150
|
+
// (MED-4, fixed with hamr's explicit go, 2026-07-30.)
|
|
2151
|
+
if (remainingUsd() <= MONEY_MIN) {
|
|
2152
|
+
// v1.46 §2 — and THIS is the site hamr's ask was about: the money runs out
|
|
2153
|
+
// deepest in the run, on the loop that holds a real grade. So the readout keeps
|
|
2154
|
+
// that grade, names the stage that rendered it, and carries the run's own
|
|
2155
|
+
// per-stage trend so the choice between "top up" and "revise the goal" is made
|
|
2156
|
+
// on evidence rather than on how the operator felt about the last gap.
|
|
2157
|
+
emitMoneyHalt({
|
|
2158
|
+
phase: 'fix',
|
|
2159
|
+
cutMidCall: false,
|
|
2160
|
+
iterationsUsed: fixIterationsUsed,
|
|
2161
|
+
});
|
|
2162
|
+
return 'cap-halt';
|
|
2163
|
+
}
|
|
2164
|
+
} else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
|
|
2165
|
+
// The step loop's category restoration (F11), mirrored: ralph returns the flat
|
|
2166
|
+
// 'escalated' on a middle throw while its escalation carries the real name — a
|
|
2167
|
+
// provider-red here is a transport CASUALTY, and run.js keys the F44
|
|
2168
|
+
// spendComplete:false floor on the OUTCOME, so a laundered label would report
|
|
2169
|
+
// an exact-looking total for a call that never billed back.
|
|
2170
|
+
return lastEscalation.category;
|
|
2171
|
+
}
|
|
787
2172
|
return fixOutcome === 'green' ? 'green' : 'escalated';
|
|
788
2173
|
}
|