bareloop 0.6.0 → 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 +407 -0
- package/README.md +28 -11
- package/bareloop.context.md +510 -28
- package/package.json +2 -2
- package/src/bridges.js +571 -0
- package/src/clock.js +57 -5
- package/src/index.js +29 -0
- package/src/ladder.js +196 -0
- package/src/ledger.js +27 -0
- package/src/plan.js +75 -7
- package/src/planrun.js +762 -146
- package/src/ralph.js +148 -28
- package/src/reuse.js +1602 -0
- package/src/run.js +73 -6
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/trend.js +405 -0
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +25 -3
- package/types/index.d.ts +3 -0
- package/types/ladder.d.ts +86 -0
- package/types/plan.d.ts +18 -3
- package/types/planrun.d.ts +108 -9
- package/types/ralph.d.ts +82 -4
- package/types/reuse.d.ts +388 -0
- package/types/run.d.ts +50 -3
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/trend.d.ts +108 -0
package/src/planrun.js
CHANGED
|
@@ -25,9 +25,12 @@ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_
|
|
|
25
25
|
import { snapshotScope, evalExits } from './exits.js';
|
|
26
26
|
import { createRoot } from './root.js';
|
|
27
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';
|
|
28
30
|
import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
|
|
29
31
|
import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
|
|
30
32
|
import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
|
|
33
|
+
import { validateBridge, loadGate } from './bridges.js';
|
|
31
34
|
import { extractArtifact } from './text.js';
|
|
32
35
|
import { createClock, isWallTimeout } from './clock.js';
|
|
33
36
|
|
|
@@ -120,43 +123,74 @@ function materialsBlock(m) {
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/**
|
|
123
|
-
*
|
|
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.
|
|
124
130
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* arbiter can see; a different one means the tree was still changing under it.
|
|
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.
|
|
131
136
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* @
|
|
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
|
|
139
145
|
*/
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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);
|
|
147
192
|
}
|
|
148
|
-
|
|
149
|
-
return prev === last
|
|
150
|
-
? {
|
|
151
|
-
trend: 'stalled',
|
|
152
|
-
reading: 'the last two close outputs are byte-identical — the previous attempt moved nothing the close can see',
|
|
153
|
-
lever: 'more time alone is unlikely to help; revise the goal/spec first',
|
|
154
|
-
}
|
|
155
|
-
: {
|
|
156
|
-
trend: 'moving',
|
|
157
|
-
reading: 'the last two close outputs differ — the tree was still changing under the close when time ran out',
|
|
158
|
-
lever: 'the work was progressing; raising maxWallMs is the lever that fits',
|
|
159
|
-
};
|
|
193
|
+
return out;
|
|
160
194
|
}
|
|
161
195
|
|
|
162
196
|
/**
|
|
@@ -174,19 +208,42 @@ function gapTrend(gaps) {
|
|
|
174
208
|
* @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
|
|
175
209
|
* what the run has LEFT (T/A). Omitted → no materials block at all, which is the
|
|
176
210
|
* pre-T behaviour and keeps every existing caller byte-identical.
|
|
177
|
-
* @param {
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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.
|
|
181
222
|
*/
|
|
182
|
-
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials,
|
|
223
|
+
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}) {
|
|
183
224
|
const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
|
|
184
225
|
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
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
|
+
: '';
|
|
185
241
|
// W4: the menu comes off the STAGED close — the same one derivation `runPlan`
|
|
186
242
|
// executes and `validatePlan` accepts against. Reading `job.close` raw here left
|
|
187
243
|
// the legacy object form offering nothing while the runner preflighted a stage.
|
|
188
244
|
// (`?? []`: a close naming no command stages nothing, so it offers nothing)
|
|
189
|
-
const
|
|
245
|
+
const closeStages = stageClose(job.close) ?? [];
|
|
246
|
+
const checkNames = checkMenu(closeStages).map((m) => m.name);
|
|
190
247
|
const doc = `DRAFT-PLAN
|
|
191
248
|
You are planning how to accomplish a goal in a repository, as an ordered list of bounded
|
|
192
249
|
steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
|
|
@@ -201,7 +258,6 @@ strictly in array order. Each step (no other fields exist):
|
|
|
201
258
|
- "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
|
|
202
259
|
- "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
|
|
203
260
|
- "model" (optional): ${STEP_MODELS.join(' | ')} — a cheaper tier for mechanical steps; omit for the default
|
|
204
|
-
- "attempts" (optional): integer 1..${capRuns} — TIGHTEN this step's retry cap below the shell's; you may never raise it
|
|
205
261
|
- "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
|
|
206
262
|
A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
|
|
207
263
|
inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
|
|
@@ -221,6 +277,12 @@ strictly in array order. Each step (no other fields exist):
|
|
|
221
277
|
the step must be able to act on the report. Do not plan a separate read-only
|
|
222
278
|
"verify" step — attach the check to the step that does the fixing; the final
|
|
223
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}
|
|
224
286
|
Reference checks by NAME only; you cannot author or modify one.
|
|
225
287
|
|
|
226
288
|
Offered "scope" values for tree-changed — copy ONE of these exactly, character for
|
|
@@ -234,6 +296,12 @@ ${materialsBlock(materials)}
|
|
|
234
296
|
Repository survey (from a read-only scout):
|
|
235
297
|
${scoutBlob || '(no scout notes)'}`;
|
|
236
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);
|
|
237
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.`;
|
|
238
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.`;
|
|
239
307
|
return p;
|
|
@@ -264,7 +332,17 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
264
332
|
* @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
|
|
265
333
|
* (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
|
|
266
334
|
* interpreter-red STOP — never silently run the default tier as if the choice was honoured.
|
|
267
|
-
* @param {number} [opts.capRuns] shell-owned
|
|
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.
|
|
268
346
|
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
269
347
|
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
270
348
|
* @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
|
|
@@ -279,15 +357,67 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
279
357
|
* first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
|
|
280
358
|
* read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
|
|
281
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.
|
|
282
411
|
* @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
|
|
283
412
|
* default; a caller supplies this to drive time deterministically (the same seam `createClock`
|
|
284
413
|
* already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
|
|
285
414
|
* whose floor is one close timeout).
|
|
286
415
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
287
|
-
* 'check-red' | 'close-red' | 'close-unsupported' | '
|
|
288
|
-
* 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
|
|
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>`
|
|
289
419
|
*/
|
|
290
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, now }) {
|
|
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 = [] }) {
|
|
291
421
|
workdir = resolve(workdir);
|
|
292
422
|
// ONE spelling of the redaction, housed next to the inventory it reads
|
|
293
423
|
// (src/validate.js) — the same helper the isolate verbs scrub the litectx store
|
|
@@ -350,6 +480,46 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
350
480
|
return 'interpreter-red';
|
|
351
481
|
}
|
|
352
482
|
|
|
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
|
+
|
|
353
523
|
// T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
|
|
354
524
|
// precheck. The precheck is real wall time and F62 measured close/check gaps as
|
|
355
525
|
// high as 328s on an archived run: a clock created after it would under-report
|
|
@@ -370,7 +540,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
370
540
|
// one. `stagedClose` is the SAME staging the runner executes (never a second
|
|
371
541
|
// count), and it is non-null here by the close-unsupported guard above — a close
|
|
372
542
|
// that names no command escalated before this line, so there is no null to thread.
|
|
373
|
-
|
|
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 } : {}) });
|
|
374
548
|
const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
|
|
375
549
|
emit('wall-clock', {
|
|
376
550
|
...clock.report(closeTimeoutForReport),
|
|
@@ -379,6 +553,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
379
553
|
: 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
|
|
380
554
|
});
|
|
381
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
|
+
|
|
382
583
|
/**
|
|
383
584
|
* F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
|
|
384
585
|
* drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
|
|
@@ -424,6 +625,20 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
424
625
|
'revise the goal/spec so the work fits the time (same re-approval)',
|
|
425
626
|
'abandon the task',
|
|
426
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
|
+
];
|
|
427
642
|
|
|
428
643
|
const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
|
|
429
644
|
/** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
|
|
@@ -433,11 +648,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
433
648
|
* the run's answer is whatever the close last said, and nothing after the deadline
|
|
434
649
|
* is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
|
|
435
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
|
+
};
|
|
436
699
|
/** the close, as ONE verdict: every stage in order, first red wins */
|
|
437
700
|
const judgeClose = async () => {
|
|
438
701
|
const v = await runStages(stagedClose, scrub, closeOpts);
|
|
439
702
|
closeStage = v.stage;
|
|
440
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 });
|
|
441
708
|
return v;
|
|
442
709
|
};
|
|
443
710
|
|
|
@@ -459,6 +726,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
459
726
|
// The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
|
|
460
727
|
// ruler and the real inspection cannot drift apart.
|
|
461
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 = [];
|
|
462
737
|
emit('check-menu', {
|
|
463
738
|
offered: menu.map((m) => m.name),
|
|
464
739
|
...(menu.length < stagedClose.length
|
|
@@ -467,6 +742,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
467
742
|
});
|
|
468
743
|
for (const m of menu) {
|
|
469
744
|
const v = await runStages(m.run, scrub, closeOpts);
|
|
745
|
+
if (v.verdict === 'needs_revision') seedRed.push(m.name);
|
|
470
746
|
emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
|
|
471
747
|
const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
|
|
472
748
|
if (f) {
|
|
@@ -536,13 +812,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
536
812
|
* denied, the wallet as its budget), granted tools only (the menu IS the
|
|
537
813
|
* grant), per-attempt round bound via loop.stop() (F20), every round metered
|
|
538
814
|
* with a phase label (F12).
|
|
539
|
-
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} 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
|
|
540
816
|
* `root` (Layer R): when present, the worker's write-class actions are teed
|
|
541
817
|
* for the within-run ratchet — staged before the gate decides, discarded on
|
|
542
818
|
* deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
|
|
543
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.
|
|
544
824
|
*/
|
|
545
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);
|
|
546
848
|
const gate = new Gate({
|
|
547
849
|
fs: {
|
|
548
850
|
// P: a step's `scope` narrows the fence — the per-step prefixes are always a
|
|
@@ -553,7 +855,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
553
855
|
deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
|
|
554
856
|
},
|
|
555
857
|
budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
|
|
556
|
-
limits: { maxTurns
|
|
858
|
+
limits: { maxTurns },
|
|
557
859
|
audit: { path: auditPath },
|
|
558
860
|
// The gate audit is the THIRD persistent, append-only channel (beside the
|
|
559
861
|
// spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
|
|
@@ -569,21 +871,35 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
569
871
|
});
|
|
570
872
|
await gate.init();
|
|
571
873
|
// F32's instrument, run_id-scoped, write AND edit, allow-decision only —
|
|
572
|
-
// the same audit read as interpret's (never git status, F45)
|
|
573
|
-
|
|
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;
|
|
574
881
|
try {
|
|
575
|
-
const paths = new Set();
|
|
576
882
|
for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
|
|
577
883
|
if (!line) continue;
|
|
578
884
|
let rec;
|
|
579
885
|
try { rec = JSON.parse(line); } catch { continue; }
|
|
580
886
|
if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
|
|
581
887
|
&& (rec.action?.type === 'write' || rec.action?.type === 'edit')
|
|
582
|
-
&& typeof rec.action.path === 'string') paths.add(rec.action.path);
|
|
888
|
+
&& typeof rec.action.path === 'string') { paths.add(rec.action.path); records += 1; }
|
|
583
889
|
}
|
|
584
|
-
|
|
585
|
-
|
|
890
|
+
} catch { /* no audit yet → nothing written yet */ }
|
|
891
|
+
return { paths: [...paths], records };
|
|
586
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;
|
|
587
903
|
// Layer R tee (design record 2026-07-19; created per-step by executeStep).
|
|
588
904
|
// The translator STAGES write/edit content and snapshots the pre-write hash
|
|
589
905
|
// BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
|
|
@@ -652,6 +968,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
652
968
|
let roundsThisAttempt = 0;
|
|
653
969
|
/** @type {number|string|undefined} */
|
|
654
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
|
+
};
|
|
655
988
|
const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
|
|
656
989
|
const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
|
|
657
990
|
// F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
|
|
@@ -752,7 +1085,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
752
1085
|
}
|
|
753
1086
|
return r;
|
|
754
1087
|
};
|
|
755
|
-
return { ask, askFrom, workerWrites, setIteration
|
|
1088
|
+
return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
|
|
756
1089
|
}
|
|
757
1090
|
|
|
758
1091
|
// ── LOOP path: the injected provider (anthropic-api and every other
|
|
@@ -763,7 +1096,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
763
1096
|
// onLlmResult meters exactly like an API round, so the drafter's spend is
|
|
764
1097
|
// never invisible (F6/F44). The gate policy is wired but idle (no tools).
|
|
765
1098
|
const loopProvider = native
|
|
766
|
-
? /** @type {any} */ (nativeProvider)({ policy, maxTurns
|
|
1099
|
+
? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
|
|
767
1100
|
: (workerProvider ?? provider);
|
|
768
1101
|
// F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
|
|
769
1102
|
// not call duration (provider-http.js `req.setTimeout` resets on every byte),
|
|
@@ -870,7 +1203,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
870
1203
|
*/
|
|
871
1204
|
const askFrom = async (msgs, prompt) => {
|
|
872
1205
|
try {
|
|
873
|
-
return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000,
|
|
1206
|
+
return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
874
1207
|
} catch (e) {
|
|
875
1208
|
throw categorize(e).err;
|
|
876
1209
|
}
|
|
@@ -878,7 +1211,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
878
1211
|
const ask = async (prompt, defs = toolDefs) => {
|
|
879
1212
|
let r;
|
|
880
1213
|
try {
|
|
881
|
-
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000,
|
|
1214
|
+
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
882
1215
|
} catch (e) {
|
|
883
1216
|
throw categorize(e).err;
|
|
884
1217
|
}
|
|
@@ -895,7 +1228,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
895
1228
|
}
|
|
896
1229
|
return r;
|
|
897
1230
|
};
|
|
898
|
-
return { ask, askFrom, workerWrites, setIteration
|
|
1231
|
+
return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
|
|
899
1232
|
}
|
|
900
1233
|
|
|
901
1234
|
/** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
|
|
@@ -906,10 +1239,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
906
1239
|
const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
|
|
907
1240
|
const { category } = categorize(e);
|
|
908
1241
|
const detail = String(e?.message ?? e);
|
|
909
|
-
if (category === 'cap-halt')
|
|
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
|
+
}
|
|
910
1249
|
if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
|
|
911
1250
|
const DECIDE = {
|
|
912
|
-
'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`,
|
|
1251
|
+
'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, MONEY_OPTIONS],
|
|
913
1252
|
'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
|
|
914
1253
|
// A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
|
|
915
1254
|
// surfaces — but it is named, never laundered into provider-red. The socket
|
|
@@ -933,48 +1272,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
933
1272
|
// ── 1. SCOUT — read-only by construction: the write-class verbs are simply
|
|
934
1273
|
// not in its menu (the menu is the grant), and its gate fences zero paths
|
|
935
1274
|
let scoutBlob = '';
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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');
|
|
966
1322
|
}
|
|
967
|
-
|
|
968
|
-
|
|
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 });
|
|
969
1331
|
}
|
|
970
|
-
emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
|
|
971
|
-
// F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
|
|
972
|
-
// out of 18 and read by nobody: an instrument that works but has no consumer is a
|
|
973
|
-
// log line, not evidence. A survey that is still empty after its reserved round is
|
|
974
|
-
// now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
|
|
975
|
-
// archived greens had an empty scout, so failing the run here would be a worse
|
|
976
|
-
// error than the one being fixed.
|
|
977
|
-
if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
|
|
978
1332
|
// F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
|
|
979
1333
|
// see — halt at the boundary rather than run the whole plan blind (the caller
|
|
980
1334
|
// emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
|
|
@@ -999,11 +1353,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
999
1353
|
// is actually left, which is the adaptation channel.
|
|
1000
1354
|
const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
|
|
1001
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
|
+
};
|
|
1002
1377
|
const draftPlan = async (/** @type {any[]|null} */ reds) => {
|
|
1003
1378
|
drafter.setIteration(reds ? 'redraft' : 'draft');
|
|
1004
|
-
|
|
1005
|
-
// one source, so the prompt and the validator cannot drift apart
|
|
1006
|
-
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, capRuns), []);
|
|
1379
|
+
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts), []);
|
|
1007
1380
|
return extractArtifact(r.text).code ?? '';
|
|
1008
1381
|
};
|
|
1009
1382
|
// Scrubbed HERE, once, before any consumer reads them — the judge() precedent
|
|
@@ -1016,7 +1389,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1016
1389
|
// emit sites: scrubbing here makes the leak inexpressible, scrubbing each
|
|
1017
1390
|
// consumer makes it something the next consumer has to remember.
|
|
1018
1391
|
const validate = (/** @type {string} */ t) => {
|
|
1019
|
-
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu,
|
|
1392
|
+
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
|
|
1020
1393
|
return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
|
|
1021
1394
|
};
|
|
1022
1395
|
let text = await draftPlan(null);
|
|
@@ -1030,16 +1403,76 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1030
1403
|
return pv;
|
|
1031
1404
|
};
|
|
1032
1405
|
let plan;
|
|
1033
|
-
|
|
1034
|
-
|
|
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));
|
|
1035
1427
|
if (!pv.ok) {
|
|
1036
|
-
|
|
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
|
+
});
|
|
1037
1439
|
return 'plan-red';
|
|
1038
1440
|
}
|
|
1039
1441
|
plan = /** @type {any} */ (pv.plan);
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
+
}
|
|
1043
1476
|
}
|
|
1044
1477
|
if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
|
|
1045
1478
|
|
|
@@ -1082,23 +1515,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1082
1515
|
const root = layerRoot && !native
|
|
1083
1516
|
? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
|
|
1084
1517
|
: null;
|
|
1085
|
-
// P: the
|
|
1086
|
-
// optional field is the F50 blind-instrument class. `
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
|
|
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.
|
|
1092
1527
|
if (step.model !== undefined && !providerFor) {
|
|
1093
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`));
|
|
1094
1529
|
err.category = 'interpreter-red';
|
|
1095
1530
|
throw err;
|
|
1096
1531
|
}
|
|
1097
1532
|
const w = await mkWorker({
|
|
1098
|
-
granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts:
|
|
1533
|
+
granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: null, writable: true, root,
|
|
1099
1534
|
fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
|
|
1100
1535
|
workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
|
|
1101
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 });
|
|
1102
1542
|
let lastText = '';
|
|
1103
1543
|
let iterationNow = 0;
|
|
1104
1544
|
// The meter's baseline: what the RUN had left when this step began. Shares are
|
|
@@ -1162,16 +1602,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1162
1602
|
stepsDone: idx,
|
|
1163
1603
|
stepsPlanned: plan.steps.length,
|
|
1164
1604
|
attemptsUsed: iteration - 1,
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
|
|
1172
|
-
|
|
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,
|
|
1173
1616
|
};
|
|
1174
|
-
// No trend here, and none invented:
|
|
1617
|
+
// No trend here, and none invented: the trend instrument reads CLOSE grades,
|
|
1175
1618
|
// and a step that never ran has no grade of its own to compare. The lever the
|
|
1176
1619
|
// human needs is on the run-level record either way.
|
|
1177
1620
|
const err = /** @type {CategorizedError} */ (new Error(
|
|
@@ -1201,6 +1644,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1201
1644
|
].filter(Boolean).join('\n\n'));
|
|
1202
1645
|
lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
|
|
1203
1646
|
};
|
|
1647
|
+
let lastGap = '';
|
|
1204
1648
|
const judge = async () => {
|
|
1205
1649
|
const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
|
|
1206
1650
|
// Scrubbed HERE, once, before the results are read by anyone. An exit detail is
|
|
@@ -1224,15 +1668,38 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1224
1668
|
if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
|
|
1225
1669
|
if (pass) return { verdict: 'satisfied' };
|
|
1226
1670
|
// AND-only: the gap names EVERY failing wall (mechanical genre, F38)
|
|
1227
|
-
|
|
1671
|
+
lastGap = results.filter((r) => !r.pass).map((r) => r.detail).join('\n');
|
|
1672
|
+
return { verdict: 'needs_revision', gap: lastGap };
|
|
1228
1673
|
};
|
|
1229
|
-
const outcome = await ralph({ middle, judge,
|
|
1230
|
-
|
|
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 };
|
|
1231
1679
|
};
|
|
1232
1680
|
|
|
1233
1681
|
let idx = 0;
|
|
1234
1682
|
while (idx < plan.steps.length) {
|
|
1235
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
|
+
}
|
|
1236
1703
|
let res;
|
|
1237
1704
|
try {
|
|
1238
1705
|
res = await executeStep(step);
|
|
@@ -1247,7 +1714,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1247
1714
|
const err = /** @type {CategorizedError} */ (e);
|
|
1248
1715
|
const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
|
|
1249
1716
|
stepOutcomes.push({ id: step.id, outcome: category });
|
|
1250
|
-
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
|
+
}
|
|
1251
1727
|
emit('escalation', {
|
|
1252
1728
|
category, decisionReady: true, phase: `step:${step.id}`,
|
|
1253
1729
|
decision: category === 'cap-halt'
|
|
@@ -1321,13 +1797,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1321
1797
|
// is handed to the one component whose whole job is to respond to it. A
|
|
1322
1798
|
// stall in particular never judged its exits at all — the exhaustion
|
|
1323
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.';
|
|
1324
1812
|
const why = cat === 'step-variance'
|
|
1325
1813
|
? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
|
|
1326
1814
|
: cat === 'step-stalled'
|
|
1327
1815
|
? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
|
|
1328
|
-
:
|
|
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
|
+
: '';
|
|
1329
1824
|
const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
|
|
1330
1825
|
+ `${why}\n`
|
|
1826
|
+
+ missedLine
|
|
1331
1827
|
+ `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
|
|
1332
1828
|
+ `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
|
|
1333
1829
|
// The progress line IS the adaptation channel (addendum 2): the balance rides
|
|
@@ -1354,6 +1850,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1354
1850
|
// it names steps the current plan does not contain)
|
|
1355
1851
|
artifacts.length = 0;
|
|
1356
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;
|
|
1357
1857
|
continue;
|
|
1358
1858
|
}
|
|
1359
1859
|
// F64 — the wall stopped this attempt from INSIDE a provider call (the derived
|
|
@@ -1378,7 +1878,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1378
1878
|
// stays a step-red (the stop is a result).
|
|
1379
1879
|
if (cat === 'cap-halt') {
|
|
1380
1880
|
planExecuted();
|
|
1381
|
-
|
|
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';
|
|
1382
1888
|
}
|
|
1383
1889
|
// A second `step-variance` after the one replan is spent is a STOP, and the stop
|
|
1384
1890
|
// is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
|
|
@@ -1423,6 +1929,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1423
1929
|
* without consulting the clock a second time (two clock reads for one event is
|
|
1424
1930
|
* how the two instruments come to disagree). */
|
|
1425
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;
|
|
1426
1936
|
try {
|
|
1427
1937
|
// Layer R for the close-fix loop — the plan flow's single ralph loop judged
|
|
1428
1938
|
// by the REAL close (the plan flow's single ralph loop, and the
|
|
@@ -1436,16 +1946,85 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1436
1946
|
const fixRoot = layerRoot && !native
|
|
1437
1947
|
? createRoot({ redact: scrub, writesInformative: true })
|
|
1438
1948
|
: null;
|
|
1439
|
-
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
const
|
|
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
|
+
};
|
|
1446
2026
|
/** @param {number} iteration @param {string} [gap] */
|
|
1447
2027
|
const middle = async (iteration, gap) => {
|
|
1448
|
-
if (gap !== undefined) gaps.push(gap);
|
|
1449
2028
|
// T/W-2 — the wall, read before a NEW fix attempt is started, and never
|
|
1450
2029
|
// around the close itself. hamr's ruling: *"when time is up, keep the grade
|
|
1451
2030
|
// we already have and stop … run tests (free) and when done if original time
|
|
@@ -1466,15 +2045,36 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1466
2045
|
// wall that kills grading leaves the run unreadable after the money is spent
|
|
1467
2046
|
// (the F45 class), so the one thing this must never do is stop a close.
|
|
1468
2047
|
if (clock.expired()) {
|
|
1469
|
-
|
|
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();
|
|
1470
2060
|
wallStop = {
|
|
1471
2061
|
cutMidCall: false,
|
|
1472
2062
|
phase: 'fix',
|
|
1473
2063
|
iterationsUsed: iteration - 1,
|
|
1474
|
-
capRuns
|
|
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".
|
|
1475
2068
|
verdict: lastCloseVerdict?.verdict,
|
|
1476
2069
|
...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
|
|
1477
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,
|
|
1478
2078
|
};
|
|
1479
2079
|
// The detail carries what the human needs to pick a lever and nothing else:
|
|
1480
2080
|
// the verdict that stands, how much of the loop was actually spent, and the
|
|
@@ -1484,7 +2084,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1484
2084
|
`the wall-clock cap passed before fix attempt ${iteration} could start. `
|
|
1485
2085
|
+ `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
|
|
1486
2086
|
+ `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
|
|
1487
|
-
+ `after ${iteration - 1}
|
|
2087
|
+
+ `after ${iteration - 1} fix iteration(s). `
|
|
1488
2088
|
+ `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
|
|
1489
2089
|
err.category = 'wall-halt';
|
|
1490
2090
|
throw err;
|
|
@@ -1513,7 +2113,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1513
2113
|
fixOutcome = await ralph({
|
|
1514
2114
|
// the staged close rides in as the JUDGE seam (one verdict, every stage in
|
|
1515
2115
|
// order) — ralph's single-command path stays exactly what it was
|
|
1516
|
-
|
|
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,
|
|
1517
2121
|
closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
|
|
1518
2122
|
});
|
|
1519
2123
|
} catch (e) {
|
|
@@ -1544,7 +2148,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1544
2148
|
// Attempts spent with money still on the table stays the designed terminal
|
|
1545
2149
|
// ("close still red") and keeps riding out as `escalated` below.
|
|
1546
2150
|
// (MED-4, fixed with hamr's explicit go, 2026-07-30.)
|
|
1547
|
-
if (remainingUsd() <= MONEY_MIN)
|
|
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
|
+
}
|
|
1548
2164
|
} else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
|
|
1549
2165
|
// The step loop's category restoration (F11), mirrored: ralph returns the flat
|
|
1550
2166
|
// 'escalated' on a middle throw while its escalation carries the real name — a
|