bareloop 0.6.0 → 0.8.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 +690 -0
- package/README.md +30 -11
- package/bareloop.context.md +658 -38
- package/package.json +2 -2
- package/src/bridges.js +571 -0
- package/src/clock.js +57 -5
- package/src/exits.js +8 -2
- package/src/index.js +29 -0
- package/src/job.js +43 -6
- package/src/ladder.js +196 -0
- package/src/ledger.js +84 -9
- package/src/plan.js +106 -107
- package/src/planrun.js +1040 -168
- package/src/ralph.js +156 -29
- package/src/reuse.js +1665 -0
- package/src/run.js +92 -6
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/trend.js +405 -0
- package/src/validate.js +121 -0
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +25 -3
- package/types/exits.d.ts +14 -0
- package/types/index.d.ts +3 -0
- package/types/job.d.ts +13 -2
- package/types/ladder.d.ts +86 -0
- package/types/plan.d.ts +41 -41
- package/types/planrun.d.ts +208 -9
- package/types/ralph.d.ts +98 -4
- package/types/reuse.d.ts +392 -0
- package/types/run.d.ts +63 -3
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/trend.d.ts +108 -0
- package/types/validate.d.ts +50 -0
package/src/planrun.js
CHANGED
|
@@ -19,15 +19,18 @@ import { createHash } from 'node:crypto';
|
|
|
19
19
|
import { join, resolve, relative } from 'node:path';
|
|
20
20
|
import { Gate } from 'bareguard';
|
|
21
21
|
import { LiteCtx } from 'litectx';
|
|
22
|
-
import { runClose, runStages, ralph, CLOSE_FAULTS } from './ralph.js';
|
|
22
|
+
import { runClose, runStages, ralph, CLOSE_FAULTS, boundGap } from './ralph.js';
|
|
23
23
|
import { validatePlan, legalScopes, stageClose } from './plan.js';
|
|
24
|
-
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU
|
|
25
|
-
import { snapshotScope, evalExits } from './exits.js';
|
|
24
|
+
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU } from './plan.js';
|
|
25
|
+
import { snapshotScope, evalExits, CHECK_GAP_MAX } 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,138 @@ 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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
/** The red-set for the replan brief's copy of a step's exit gap: every non-blank
|
|
161
|
+
* line. It is `\S` and not the close stage's own `gapKeep` for the reason Layer R
|
|
162
|
+
* already pays at the step seam (see `createRoot` below) — a shipped `gapKeep` is
|
|
163
|
+
* `^`-anchored (`^BAREAGENT `, `^red`, `^FAILED`) and the exit evaluator wraps the
|
|
164
|
+
* stage's output in `check "x" red: …`, so the anchor no longer sits where the
|
|
165
|
+
* pattern expects it. A gap that reaches here is ALREADY a red-set: `judge` builds
|
|
166
|
+
* it out of the failing exits only, so every line in it is a red line and keeping
|
|
167
|
+
* all of them is the correct instrument, not a widened one. */
|
|
168
|
+
const REPLAN_GAP_KEEP = '\\S';
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* This step's own last EXIT results, rendered for the REPLAN brief.
|
|
172
|
+
*
|
|
173
|
+
* The label says exits and not "the close", and the distinction is the whole of one
|
|
174
|
+
* fix: `res.gap` is `lastGap`, the join of EVERY failing exit's detail in exit order.
|
|
175
|
+
* With `MAX_EXITS_PER_STEP` at 2 and the mandatory `tree-changed` pairing on a write
|
|
176
|
+
* step, a step that wrote nothing leads the block with the EVALUATOR's own prose
|
|
177
|
+
* (`0 files changed under src/** …`) — captured verbatim from a real replan prompt,
|
|
178
|
+
* under a label reading "the verification's own output". The close stage's output is
|
|
179
|
+
* one of the details here, never reliably all of them. The payload is left alone: "the
|
|
180
|
+
* step wrote nothing" is exactly what a replanner should be told, and stripping it to
|
|
181
|
+
* make an old label true would trade a real fact for a tidy sentence.
|
|
182
|
+
*
|
|
183
|
+
* u-mshcpdg4: a run one strict error from done, killed with $1.10 and 82 seconds
|
|
184
|
+
* unspent. The close named the remaining work exactly — `src/recurse.js(978,115)`
|
|
185
|
+
* — and the worker read that every attempt. The REPLANNER, which is the component
|
|
186
|
+
* that chooses which files the next plan targets, was handed only the trend line
|
|
187
|
+
* (`still progressing — typecheck 30 → 15 → 15 → 1`): converging, and silent about
|
|
188
|
+
* WHERE. It re-targeted `src/loop.js`, already at zero errors; that step wrote
|
|
189
|
+
* nothing and struck out. F85 gave the brief the trajectory; this gives it the
|
|
190
|
+
* artifact the trajectory is a summary OF.
|
|
191
|
+
*
|
|
192
|
+
* Handed over as TEXT, never as a parsed file list, and the trap is worth naming
|
|
193
|
+
* because the parse is the obvious-looking fix: line 1 of that very gap reads
|
|
194
|
+
* `reports 1 error(s) in src/recurse.js, src/loop.js` — every file in SCOPE — and
|
|
195
|
+
* only line 2 names the culprit. The `never wrote` advisory that used to sit here
|
|
196
|
+
* did exactly that parse, and on this gap it resolved to `src/loop.js` — the file
|
|
197
|
+
* already CLEAN — then said so as a directive beside the artifact that said the
|
|
198
|
+
* opposite. It was deleted with this change (hamr, 2026-08-05): two readers of one
|
|
199
|
+
* question, the parsed one wrong, is the same failure the variance meter just had.
|
|
200
|
+
* Nothing generic can tell a summary line from a detail line, and the
|
|
201
|
+
* shapes differ per close anyway (tsc file:line, pytest test ids, a count close
|
|
202
|
+
* that names no file at all). A model reading the artifact can make that
|
|
203
|
+
* distinction; a regex reproduces the bug. So: no extraction, no digest, no
|
|
204
|
+
* inference — the artifact, bounded and scrubbed.
|
|
205
|
+
*
|
|
206
|
+
* Two boundaries, both reused rather than respelled:
|
|
207
|
+
* - SCRUB. A prompt is an egress point and this repo has ONE secret inventory
|
|
208
|
+
* (`SECRET_PATTERNS` via `redactSecrets`). The gap arrives already scrubbed
|
|
209
|
+
* from `judge`, which makes this defense in depth rather than the only guard —
|
|
210
|
+
* and defense in depth at the egress is exactly where it belongs, because the
|
|
211
|
+
* next caller of this helper will not remember the upstream one.
|
|
212
|
+
* - BOUND, and it is a BACKSTOP rather than an envelope. What arrives here has
|
|
213
|
+
* ALREADY been bounded: `runClose` ran `boundGap` with the stage's own `gapKeep`,
|
|
214
|
+
* which deliberately rescues the `not ok`/`FAILED` names out of the elided middle
|
|
215
|
+
* — the mechanical gap F46's conversion mechanism feeds the worker. Re-bounding
|
|
216
|
+
* that with a fresh 400/1500 envelope deletes exactly those names a second time,
|
|
217
|
+
* which is F28 reintroduced; measured on a 120-red-line close output, the first
|
|
218
|
+
* bound rescued 78 names and this one dropped 12 of them. src/exits.js states the
|
|
219
|
+
* same rule for the same artifact one seam earlier, and this now uses ITS number:
|
|
220
|
+
* `CHECK_GAP_MAX`, imported rather than respelled, so the two seams cannot drift
|
|
221
|
+
* on how big a check gap may be. Under it the gap passes through VERBATIM.
|
|
222
|
+
*
|
|
223
|
+
* A backstop is still needed, and the headroom is why the ceiling is not smaller.
|
|
224
|
+
* `res.gap` is `lastGap`, the JOIN of up to `MAX_EXITS_PER_STEP` failing exit
|
|
225
|
+
* details, and the check-passes branch slices EACH at `CHECK_GAP_MAX` — so what
|
|
226
|
+
* arrives is bounded per detail, never in total. Measured, the real maximum is
|
|
227
|
+
* comfortably under: `runClose`'s own envelope tops out at ~10.1KB (400 head +
|
|
228
|
+
* 8192 keep + 1500 tail + labels), and joined with a `tree-changed` line that is
|
|
229
|
+
* ~10.2KB, so the shipped path passes through untouched with ~1.8KB to spare. The
|
|
230
|
+
* backstop therefore fires only where src/exits.js says it should: a seam that
|
|
231
|
+
* returned something never bounded at all.
|
|
232
|
+
*
|
|
233
|
+
* Over the line `boundGap` runs with `REPLAN_GAP_KEEP`, the same envelope the
|
|
234
|
+
* close path uses, and every trim announces itself (F28). Note what that keep-set
|
|
235
|
+
* cannot do: `\S` matches every non-blank line, so the keep block degenerates into
|
|
236
|
+
* "the first 50 lines" and re-surfaces nothing from the middle. That is the honest
|
|
237
|
+
* limit of a red-set that cannot know the stage's own pattern (see
|
|
238
|
+
* `REPLAN_GAP_KEEP`), and it is affordable precisely because it now applies only
|
|
239
|
+
* to a gap nothing upstream ever bounded — on that gap ANY envelope loses names,
|
|
240
|
+
* and the choice is a bounded prompt over an unbounded one.
|
|
241
|
+
*
|
|
242
|
+
* No gap → the empty string, so the brief renders byte-identically to the pre-F86
|
|
243
|
+
* one. A labelled empty section would be an invitation to explain an absence the
|
|
244
|
+
* run never observed (a stall never judged its exits at all).
|
|
245
|
+
*
|
|
246
|
+
* @param {string | null | undefined} gap the step's last exit gap text
|
|
247
|
+
* @returns {string} the labelled block, or '' when there is nothing to show
|
|
248
|
+
*/
|
|
249
|
+
export function closeGapBlock(gap) {
|
|
250
|
+
if (typeof gap !== 'string' || gap.trim() === '') return '';
|
|
251
|
+
const scrubbed = redactSecrets(gap);
|
|
252
|
+
// BACKSTOP, not an envelope: over the line only. Scrubbing can only grow a string
|
|
253
|
+
// (a mask is wider than most literals it replaces), so the length is measured
|
|
254
|
+
// AFTER it — bounding on the raw length would let a gap cross the ceiling on its
|
|
255
|
+
// way through the one transformation that runs unconditionally.
|
|
256
|
+
return '\nWhat this step\'s exits reported on its last attempt (their own output, verbatim):\n'
|
|
257
|
+
+ (scrubbed.length > CHECK_GAP_MAX ? boundGap(scrubbed, REPLAN_GAP_KEEP) : scrubbed);
|
|
160
258
|
}
|
|
161
259
|
|
|
162
260
|
/**
|
|
@@ -174,19 +272,42 @@ function gapTrend(gaps) {
|
|
|
174
272
|
* @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
|
|
175
273
|
* what the run has LEFT (T/A). Omitted → no materials block at all, which is the
|
|
176
274
|
* pre-T behaviour and keeps every existing caller byte-identical.
|
|
177
|
-
* @param {
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
275
|
+
* @param {object|null} [startingDraft] Layer 3 (D4) — a loaded bridge's plan, handed over
|
|
276
|
+
* as the draft to TWEAK. Omitted/null is the COLD path and renders byte-identically to
|
|
277
|
+
* the pre-Layer-3 prompt: the block is additive, never surgery on the prompt's interior,
|
|
278
|
+
* so the two paths cannot drift (the F47 works-both-ways rule). Only the runner passes
|
|
279
|
+
* it, and only for the FIRST draft phase — a replan drafts from the run's own state.
|
|
280
|
+
* @param {{ seedRed?: string[], priorChecks?: string[] }} [checkFacts] the shape-lottery
|
|
281
|
+
* gate rules, STATED as well as enforced (the mailbox precedent: a rule only the
|
|
282
|
+
* validator knows costs a redraft per draft). `seedRed`: preflight-red check names —
|
|
283
|
+
* Rule A-v2's law (final write step only, framed wide). `priorChecks`: the predecessor
|
|
284
|
+
* plan's carried checks, replan phase only — Rule B's law (keep every one). Empty/omitted
|
|
285
|
+
* renders byte-identically to the pre-rules prompt.
|
|
181
286
|
*/
|
|
182
|
-
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials,
|
|
287
|
+
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}) {
|
|
183
288
|
const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
|
|
184
289
|
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
290
|
+
// The shape-lottery laws, stated where the check menu is offered (the mailbox
|
|
291
|
+
// precedent: a law only the validator knows costs a redraft per draft). Both
|
|
292
|
+
// render only when their FACT arrived — empty facts keep the prompt
|
|
293
|
+
// byte-identical to the pre-rules render.
|
|
294
|
+
const seedRedLaw = Array.isArray(checkFacts.seedRed) && checkFacts.seedRed.length
|
|
295
|
+
? `\n Check(s) RED at seed right now: ${JSON.stringify(checkFacts.seedRed)}. A failing check
|
|
296
|
+
is the goal itself, not a milestone: it may ONLY appear on your plan's FINAL write step.
|
|
297
|
+
Make that step wide — its action covers the whole goal, free to edit any file it reports,
|
|
298
|
+
iterating until the check passes. A failing check on any earlier step is rejected.`
|
|
299
|
+
: '';
|
|
300
|
+
const priorChecksLaw = Array.isArray(checkFacts.priorChecks) && checkFacts.priorChecks.length
|
|
301
|
+
? `\n Your previous plan carried these check-passes exits: ${JSON.stringify(checkFacts.priorChecks)}.
|
|
302
|
+
Keep every one of them in this new plan — a redraft that drops one of them is rejected:
|
|
303
|
+
the other exit forms verify form only, and a form-only green is unearned.`
|
|
304
|
+
: '';
|
|
185
305
|
// W4: the menu comes off the STAGED close — the same one derivation `runPlan`
|
|
186
306
|
// executes and `validatePlan` accepts against. Reading `job.close` raw here left
|
|
187
307
|
// the legacy object form offering nothing while the runner preflighted a stage.
|
|
188
308
|
// (`?? []`: a close naming no command stages nothing, so it offers nothing)
|
|
189
|
-
const
|
|
309
|
+
const closeStages = stageClose(job.close) ?? [];
|
|
310
|
+
const checkNames = checkMenu(closeStages).map((m) => m.name);
|
|
190
311
|
const doc = `DRAFT-PLAN
|
|
191
312
|
You are planning how to accomplish a goal in a repository, as an ordered list of bounded
|
|
192
313
|
steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
|
|
@@ -200,8 +321,6 @@ strictly in array order. Each step (no other fields exist):
|
|
|
200
321
|
- "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)
|
|
201
322
|
- "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
|
|
202
323
|
- "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
|
|
203
|
-
- "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
324
|
- "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
|
|
206
325
|
A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
|
|
207
326
|
inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
|
|
@@ -221,6 +340,12 @@ strictly in array order. Each step (no other fields exist):
|
|
|
221
340
|
the step must be able to act on the report. Do not plan a separate read-only
|
|
222
341
|
"verify" step — attach the check to the step that does the fixing; the final
|
|
223
342
|
verification after your plan is not yours to author.
|
|
343
|
+
A check judges the WHOLE goal, never just this step's deliverable: its report
|
|
344
|
+
can name files beyond the step's target. A step carrying a check-passes exit
|
|
345
|
+
must therefore be free to edit every file the check can report on — never
|
|
346
|
+
write an action that forbids editing other in-scope files (e.g. "do not
|
|
347
|
+
modify any file outside X"): a step ordered to leave a file alone while its
|
|
348
|
+
exit reds on that file can never finish, on any attempt.${seedRedLaw}${priorChecksLaw}
|
|
224
349
|
Reference checks by NAME only; you cannot author or modify one.
|
|
225
350
|
|
|
226
351
|
Offered "scope" values for tree-changed — copy ONE of these exactly, character for
|
|
@@ -234,6 +359,12 @@ ${materialsBlock(materials)}
|
|
|
234
359
|
Repository survey (from a read-only scout):
|
|
235
360
|
${scoutBlob || '(no scout notes)'}`;
|
|
236
361
|
let p = doc;
|
|
362
|
+
// BEFORE the failure/reds appendices, so the last word to a redrafting planner stays
|
|
363
|
+
// the validator's "fix every red". The execution probe injected at the provider seam
|
|
364
|
+
// and could only append at the very end; it recorded that ordering as its own known
|
|
365
|
+
// wart. Owning the prompt is what lets it be fixed — and on the first draft (no
|
|
366
|
+
// failure, no reds) the render is byte-identical to the probe's.
|
|
367
|
+
if (startingDraft) p += startingDraftBlock(closeStages.length, startingDraft);
|
|
237
368
|
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
369
|
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
370
|
return p;
|
|
@@ -264,7 +395,17 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
264
395
|
* @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
|
|
265
396
|
* (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
|
|
266
397
|
* interpreter-red STOP — never silently run the default tier as if the choice was honoured.
|
|
267
|
-
* @param {number} [opts.capRuns] shell-owned
|
|
398
|
+
* @param {number} [opts.capRuns] shell-owned iteration cap, RETIRED as a governor
|
|
399
|
+
* (v1.46 §4). It stopped bounding a plan step when the strike ladder landed, and it
|
|
400
|
+
* stopped bounding the close-fix loop when the close trend rule did. It survives as
|
|
401
|
+
* that loop's fallback for the ONE case the trend cannot read — a close whose output
|
|
402
|
+
* carries no number at all — because a governor that cannot see the variable must
|
|
403
|
+
* not be the governor, and the honest fallback is the cruder bound it replaced
|
|
404
|
+
* rather than "unbounded". It lifts the moment a stage reports a comparable number.
|
|
405
|
+
* @param {number} [opts.strikeLimit] shell-owned STRIKE ceiling for a plan step's
|
|
406
|
+
* ladder (src/ladder.js). Arbiter territory exactly as the count it replaced was —
|
|
407
|
+
* the runner sets it, the plan cannot express it, and no step may tighten or raise
|
|
408
|
+
* it.
|
|
268
409
|
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
269
410
|
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
270
411
|
* @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
|
|
@@ -279,15 +420,101 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
279
420
|
* first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
|
|
280
421
|
* read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
|
|
281
422
|
* worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
|
|
423
|
+
* @param {unknown} [opts.bridge] Layer 3 (D3/D4) — a bridge-v1 registry entry to REUSE as the
|
|
424
|
+
* drafter's starting point. Absent is the COLD path and is byte-identical to the pre-Layer-3
|
|
425
|
+
* flow. Present, the D2-split LOAD GATE runs at the door, before the close precheck and
|
|
426
|
+
* before any token: on a pass the newest version's plan (the run-as-executed artifact)
|
|
427
|
+
* rides into the FIRST draft prompt only; on a fail the run returns the distinct terminal
|
|
428
|
+
* `recipe-stale` having spent nothing. Selecting the entry — the listing, the pin, the
|
|
429
|
+
* LLM's pick — is the CALLER's, and so is falling back to cold: a silent automatic
|
|
430
|
+
* fall-back would spend a run's budget on a decision nobody made.
|
|
431
|
+
* @param {number} [opts.priorWallMs=0] RESUME (module C) — wall time a PREVIOUS, killed
|
|
432
|
+
* attempt of this same try already consumed, folded into the run clock (see
|
|
433
|
+
* `createClock`'s `priorElapsedMs`). The SIGNED `maxWallMs` is never rewritten: it is in
|
|
434
|
+
* the spec hash, and a resume that edited it would need a signature nobody typed. The
|
|
435
|
+
* restarted attempt simply starts already partly spent, so a kill can never buy a fresh
|
|
436
|
+
* allotment of the operator's wall.
|
|
437
|
+
* @param {any} [opts.resumeSeed] RESUME (module C v2) — the FINEST honest checkpoint of a
|
|
438
|
+
* KILLED run of this same try, read off its own spine by `readResume` (src/reuse.js) and
|
|
439
|
+
* by nothing else: `{ phase, plan, completedSteps: [{id, seq, by}] }`. hamr's ruling is
|
|
440
|
+
* the whole contract — *"it should allow resume and start last step instead from the
|
|
441
|
+
* beginning, why would i want to waste more money on something i already started"* — so
|
|
442
|
+
* with a seed this flow re-pays for NO paid unit the dead run already bought:
|
|
443
|
+
*
|
|
444
|
+
* - the SCOUT does not run (`scout-skipped`, never silence). Its survey is not on the
|
|
445
|
+
* spine (only its byte count is), so the ONE consequence is stated rather than
|
|
446
|
+
* discovered: a replan after a resume drafts from an empty survey plus the failure
|
|
447
|
+
* brief. Re-scouting to refill it would re-pay the exact call this exists to save.
|
|
448
|
+
* - the PLAN is not re-drafted. It is reloaded from the seed and RE-VALIDATED against
|
|
449
|
+
* the current signed spec; a plan that no longer validates is `plan-red` by name
|
|
450
|
+
* (`resume-plan-red`), never run. It is then emitted as this leg's own
|
|
451
|
+
* `plan-accepted` (`phase: 'resume'`) — R1 mints a bridge version from the plan AS
|
|
452
|
+
* EXECUTED, read back off the spine, so a leg that greens without one would mint
|
|
453
|
+
* nothing.
|
|
454
|
+
* - a step whose exit was already SATISFIED is skipped, in prefix order, each with its
|
|
455
|
+
* own `step-skipped` record naming the event that proves it. Never a fake
|
|
456
|
+
* step-start/step-end pair, and never silence: a reader must be able to tell skipped
|
|
457
|
+
* from run (the resume-to-cap invariant).
|
|
458
|
+
*
|
|
459
|
+
* The worker's conversation is deliberately NOT replayed — a transcript is not a
|
|
460
|
+
* checkpoint, and every attempt is fresh by the loop's own design. Absent is the
|
|
461
|
+
* ordinary path and is byte-identical to a run that was never killed.
|
|
462
|
+
* @param {{stage?: string|null, value?: number|null}[]} [opts.resumeGrades] RESUME — the
|
|
463
|
+
* close GRADES the dead leg recorded, oldest first (`readResume`'s `restart.grades`).
|
|
464
|
+
* A resumed leg is the same run continuing, so its halt readouts must judge the whole
|
|
465
|
+
* chain: without this the trend restarts at this leg's first close and can report
|
|
466
|
+
* `flat` — "revise the goal" — on a run that was converging when its allowance ran
|
|
467
|
+
* out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
|
|
468
|
+
* bounds and mints none of its strikes. Empty/omitted is the cold path.
|
|
469
|
+
* @param {{count?: number, grantUsed?: boolean}|null} [opts.resumeReplans] RESUME — the
|
|
470
|
+
* replan LEDGER the chain has already spent (`readResume`'s `restart.replans` /
|
|
471
|
+
* `restart.replanGrantUsed`), seeding the ceiling below. `resumeGrades`' sibling and
|
|
472
|
+
* deliberately NOT its twin: a grade seed feeds a READOUT, this one feeds a BOUND.
|
|
473
|
+
*
|
|
474
|
+
* Without it the ceiling is reborn on every call, and a resume is another call:
|
|
475
|
+
* measured, a leg that spent both replans and step-redded handed its checkpoint to a
|
|
476
|
+
* second leg that replanned twice more. Two became four by being killed once, which is
|
|
477
|
+
* the creep the latch exists to make impossible ("unlimited replanning launders thrash
|
|
478
|
+
* as adaptation", PRD v1.12).
|
|
479
|
+
*
|
|
480
|
+
* WHY THIS SEED SPENDS THE LEG'S BOUND WHERE `resumeGrades` DOES NOT. src/trend.js's
|
|
481
|
+
* THE SEED refuses to seed the fix loop's ITERATIONS from history "because the leg's
|
|
482
|
+
* own bounds must not be spent by history" — and that is right, because an attempt
|
|
483
|
+
* allowance is a LEG bound: a restarted leg buys its own attempts with its own money.
|
|
484
|
+
* The replan ceiling is a RUN bound by doctrine. It bounds how many times the WORKFLOW
|
|
485
|
+
* may be redrawn before redrawing it is thrash, and that question spans exactly the
|
|
486
|
+
* chain the halt readout already spans. The two seeds therefore disagree on purpose;
|
|
487
|
+
* they are not the same rule applied inconsistently.
|
|
488
|
+
*
|
|
489
|
+
* CHAIN SEMANTICS, and the one place this differs from the grade seed's mechanism.
|
|
490
|
+
* `readGradeSeed`'s documented KNOWN LIMIT is that it reads ONE spine, so a resume of a
|
|
491
|
+
* resume inherits leg 2's grades and not leg 1's — the chain shortens by one leg per
|
|
492
|
+
* resume, which is fail-safe for a READOUT (a shorter chain can only under-claim a
|
|
493
|
+
* direction). It is the DANGEROUS direction for a ceiling: an under-claimed ledger is a
|
|
494
|
+
* refilled allowance, and every kill would buy one. So this seed does NOT reuse that
|
|
495
|
+
* mechanism. It follows the MONEY fold instead (`priorSpentUsd`, `try-start`/
|
|
496
|
+
* `job-start`): each leg DECLARES the ledger it inherited on its own spine, and the
|
|
497
|
+
* next reader adds only that leg's own `replan` records to the declared number. Leg 3
|
|
498
|
+
* therefore inherits the whole chain, not leg 2's slice.
|
|
499
|
+
*
|
|
500
|
+
* `count` seeds `replans` (and, above zero, latches `replanned`); `grantUsed` seeds the
|
|
501
|
+
* F85-C variance latch, so a killed leg cannot re-earn an extra the arbiter has already
|
|
502
|
+
* granted. Null/omitted is the cold path and is byte-identical to a run nobody resumed.
|
|
503
|
+
* @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
|
|
504
|
+
* ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
|
|
505
|
+
* cut, an inherited resume floor); the money-halt readout states it beside the remaining
|
|
506
|
+
* it quotes, because `budget − floor` is a CEILING and printing it bare reads as exact
|
|
507
|
+
* (F6). Defaults to exact, which is what every pre-record caller was implicitly saying.
|
|
282
508
|
* @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
|
|
283
509
|
* default; a caller supplies this to drive time deterministically (the same seam `createClock`
|
|
284
510
|
* already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
|
|
285
511
|
* whose floor is one close timeout).
|
|
286
512
|
* @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' |
|
|
513
|
+
* 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
|
|
514
|
+
* 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
|
|
515
|
+
* `step-red:<id>`
|
|
289
516
|
*/
|
|
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 }) {
|
|
517
|
+
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 = [], resumeReplans = null }) {
|
|
291
518
|
workdir = resolve(workdir);
|
|
292
519
|
// ONE spelling of the redaction, housed next to the inventory it reads
|
|
293
520
|
// (src/validate.js) — the same helper the isolate verbs scrub the litectx store
|
|
@@ -350,6 +577,46 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
350
577
|
return 'interpreter-red';
|
|
351
578
|
}
|
|
352
579
|
|
|
580
|
+
// ── THE BRIDGE LOAD GATE (Layer 3, D2 as SPLIT by the 2026-08-01 addendum).
|
|
581
|
+
// Asked AT THE DOOR: *"is this the right KIND of recipe?"* — verdict type, close-stage
|
|
582
|
+
// kinds, verbs within the signed menu, and NOTHING instance-bound (paths, scopes and
|
|
583
|
+
// targets are expected to be yesterday's bricks; the full validatePlan judges the
|
|
584
|
+
// TWEAKED draft at draft time, D4).
|
|
585
|
+
//
|
|
586
|
+
// It sits FIRST among the things that cost anything, and above the clock and the close
|
|
587
|
+
// precheck deliberately: the gate is $0 and deterministic while a precheck is real wall
|
|
588
|
+
// time (four stages at the shipped timeout is an hour). It sits BELOW the two guards
|
|
589
|
+
// above it because those say the JOB cannot run at all, which outranks "this recipe does
|
|
590
|
+
// not fit". Consequence, named rather than discovered later: a refused run never learns
|
|
591
|
+
// whether the tree was already green — the caller's cold rerun does.
|
|
592
|
+
//
|
|
593
|
+
// The entry is re-validated here even though the caller is contracted to pass a valid
|
|
594
|
+
// one. Not defensive decoration: the very next thing this code does is reach into
|
|
595
|
+
// `versions.at(-1).plan`, and a half-written file that reached a reader anyway must come
|
|
596
|
+
// back as named reds, never as a TypeError out of the runner (`loadRegistry` already
|
|
597
|
+
// skips-and-reports for the same reason).
|
|
598
|
+
let startingDraft = null;
|
|
599
|
+
if (bridge != null) {
|
|
600
|
+
const bv = validateBridge(bridge);
|
|
601
|
+
const gate = bv.ok ? loadGate(bv.bridge, job) : { ok: false, reds: bv.reds };
|
|
602
|
+
const name = typeof (/** @type {any} */ (bridge)?.name) === 'string' ? /** @type {any} */ (bridge).name : null;
|
|
603
|
+
if (!gate.ok) {
|
|
604
|
+
emit('bridge-gate', { outcome: 'recipe-stale', name, reds: gate.reds });
|
|
605
|
+
emit('escalation', {
|
|
606
|
+
category: 'recipe-stale', decisionReady: true,
|
|
607
|
+
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.`,
|
|
608
|
+
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)'],
|
|
609
|
+
detail: gate.reds.map((/** @type {any} */ r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
|
|
610
|
+
});
|
|
611
|
+
return 'recipe-stale';
|
|
612
|
+
}
|
|
613
|
+
// `versions` is oldest-first and non-empty by the validator's entry bar, so the newest
|
|
614
|
+
// green's plan-as-executed is the one that inherits (R1) — never the founding one.
|
|
615
|
+
const newest = bv.bridge.versions.at(-1);
|
|
616
|
+
startingDraft = newest.plan;
|
|
617
|
+
emit('bridge-loaded', { name: bv.bridge.name, versions: bv.bridge.versions.length, runid: newest.runid });
|
|
618
|
+
}
|
|
619
|
+
|
|
353
620
|
// T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
|
|
354
621
|
// precheck. The precheck is real wall time and F62 measured close/check gaps as
|
|
355
622
|
// high as 328s on an archived run: a clock created after it would under-report
|
|
@@ -370,7 +637,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
370
637
|
// one. `stagedClose` is the SAME staging the runner executes (never a second
|
|
371
638
|
// count), and it is non-null here by the close-unsupported guard above — a close
|
|
372
639
|
// that names no command escalated before this line, so there is no null to thread.
|
|
373
|
-
|
|
640
|
+
//
|
|
641
|
+
// RESUME (module C): `priorWallMs` is time a killed attempt of this same try already
|
|
642
|
+
// consumed. It folds into the clock's start, never into `maxWallMs` — the cap stays the
|
|
643
|
+
// signed one in the record and in the hash, and only the REMAINDER is left to run.
|
|
644
|
+
const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, priorElapsedMs: priorWallMs, ...(now ? { now } : {}) });
|
|
374
645
|
const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
|
|
375
646
|
emit('wall-clock', {
|
|
376
647
|
...clock.report(closeTimeoutForReport),
|
|
@@ -379,6 +650,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
379
650
|
: 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
|
|
380
651
|
});
|
|
381
652
|
|
|
653
|
+
/**
|
|
654
|
+
* The per-call TIME bounds, in ONE place so the two `loop.run` sites cannot drift
|
|
655
|
+
* (the two-transforms class, F9). bare-agent forwards a run's options straight into
|
|
656
|
+
* `provider.generate` (loop.js:732), so both knobs land on the request:
|
|
657
|
+
*
|
|
658
|
+
* timeoutMs BA-18, IDLE: `req.setTimeout`, reset by every byte. Bounds a silent
|
|
659
|
+
* socket and nothing else.
|
|
660
|
+
* deadlineMs BA-19, TOTAL: a plain timer no byte resets. This is the bound for
|
|
661
|
+
* the F66 shape — a call that trickles forever while the run's own
|
|
662
|
+
* wall passes (274 minutes, with the idle bound correctly armed).
|
|
663
|
+
*
|
|
664
|
+
* `deadlineMs` is OMITTED, never zeroed, when the run is unbounded: the operator set
|
|
665
|
+
* no wall, so there is no ceiling to derive, and inventing one is the silent second
|
|
666
|
+
* ceiling F45 exists to forbid. Omission is also the honest spelling upstream — an
|
|
667
|
+
* absent knob inherits the provider's own default (disabled), while an explicitly
|
|
668
|
+
* garbage value THROWS there (`resolveTimeoutMs`, provider-http.js:53).
|
|
669
|
+
*
|
|
670
|
+
* The NATIVE (clipipe) worker does not call this and never did: the CLI owns the
|
|
671
|
+
* transport, there is no `ClientRequest` for either timer to arm, and that path
|
|
672
|
+
* passes neither bound today. Stated rather than assumed — an unbounded native call
|
|
673
|
+
* is bounded by the F66 stall fuse and the outside watchdog, not by this.
|
|
674
|
+
*/
|
|
675
|
+
const callBounds = () => {
|
|
676
|
+
const deadlineMs = clock.callDeadlineMs();
|
|
677
|
+
return { timeoutMs: clock.callTimeoutMs(), ...(deadlineMs === null ? {} : { deadlineMs }) };
|
|
678
|
+
};
|
|
679
|
+
|
|
382
680
|
/**
|
|
383
681
|
* F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
|
|
384
682
|
* drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
|
|
@@ -424,6 +722,20 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
424
722
|
'revise the goal/spec so the work fits the time (same re-approval)',
|
|
425
723
|
'abandon the task',
|
|
426
724
|
];
|
|
725
|
+
/** MONEY's levers (PRD v1.46 §2), the same three shapes the wall's are, because a
|
|
726
|
+
* money cut is the same KIND of stop: the run is out of an operator-owned
|
|
727
|
+
* allowance, not out of capability, and the last verdict rendered stands. hamr's
|
|
728
|
+
* ask was "halt and feedback… and needs to be accurate", so the readout that
|
|
729
|
+
* carries these also carries the trend that says WHICH of the first two fits.
|
|
730
|
+
*
|
|
731
|
+
* The first lever is a top-up the OPERATOR performs. `budgetUsd` is in the spec
|
|
732
|
+
* hash, so raising it is a new spec version somebody signs — the library only ever
|
|
733
|
+
* names the lever, and never adjusts a budget itself (the permanent hard line). */
|
|
734
|
+
const MONEY_OPTIONS = [
|
|
735
|
+
'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)',
|
|
736
|
+
'revise the goal/spec so the work fits the budget (same re-approval)',
|
|
737
|
+
'abandon the task',
|
|
738
|
+
];
|
|
427
739
|
|
|
428
740
|
const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
|
|
429
741
|
/** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
|
|
@@ -433,11 +745,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
433
745
|
* the run's answer is whatever the close last said, and nothing after the deadline
|
|
434
746
|
* is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
|
|
435
747
|
let lastCloseVerdict;
|
|
748
|
+
/** PRD v1.46 §2 — the RUN's own close trend, fed by every grade the arbiter
|
|
749
|
+
* renders (the precheck is the seed the work is measured against, so it is the
|
|
750
|
+
* first reading, not a discarded one). Read only by the halt readouts; it decides
|
|
751
|
+
* nothing and bounds nothing — the fix loop builds its OWN reader over its own
|
|
752
|
+
* grades, because "did this run make progress" and "is this loop out of ideas"
|
|
753
|
+
* are two questions and one instrument answering both is how they come to
|
|
754
|
+
* disagree. Per stage by construction (src/trend.js's accuracy law).
|
|
755
|
+
*
|
|
756
|
+
* On a RESUME it is SEEDED with the grades the dead leg recorded, so both halt
|
|
757
|
+
* readouts judge the whole chain rather than this leg alone: a leg that re-grades
|
|
758
|
+
* an unchanged tree is flat on its own evidence while the RUN — the thing the human
|
|
759
|
+
* is deciding about — may have been converging when its allowance ran out. Cold, the
|
|
760
|
+
* seed is empty and this is byte-identical to the pre-resume reader. */
|
|
761
|
+
const runTrend = createTrend({ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name), seed: resumeGrades });
|
|
762
|
+
/** the run-level MONEY record, one shape wherever the wallet stops the run —
|
|
763
|
+
* W-2's `emitWallHalt` in a money coat, and deliberately its mirror image. hamr's
|
|
764
|
+
* ruling for TIME was "keep the grade we already have and stop"; a money cut is
|
|
765
|
+
* the same stop with a different allowance behind it, so it keeps the same grade,
|
|
766
|
+
* states the same kind of trend, and hands over the same shape of lever list.
|
|
767
|
+
* `spentUsd` is NOT re-derived here: runJob's ledger owns that number and states
|
|
768
|
+
* it on `job-end` (F6 — a second, weaker arithmetic for the same figure is how
|
|
769
|
+
* two instruments come to disagree about one run's money). What this record can
|
|
770
|
+
* say exactly is the SIGNED ceiling and what is left against it. */
|
|
771
|
+
const emitMoneyHalt = (/** @type {object} */ extra) => {
|
|
772
|
+
const t = runTrend.verdict();
|
|
773
|
+
return emit('money-halt', {
|
|
774
|
+
meaning: 'not under cap — not "can\'t"',
|
|
775
|
+
budgetUsd: job.budgetUsd,
|
|
776
|
+
remainingUsd: remainingUsd(),
|
|
777
|
+
// …and whether that figure is EXACT. `remainingUsd` is `budget − spent`, so a run
|
|
778
|
+
// whose spend is a FLOOR has a remaining that is a CEILING, and quoting it bare to
|
|
779
|
+
// four decimals reads as the number. The four causes live in the ledger one level
|
|
780
|
+
// up (unpriced, self-healed stall, mid-call cut, an inherited floor), which is why
|
|
781
|
+
// this is READ from there and not re-derived here — a second, weaker arithmetic for
|
|
782
|
+
// the same question is how two instruments come to disagree. `emitWallHalt` has
|
|
783
|
+
// carried its honest bound since W-2; this is the same duty on the money side.
|
|
784
|
+
spendComplete: spendComplete(),
|
|
785
|
+
verdict: lastCloseVerdict?.verdict ?? null,
|
|
786
|
+
...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
|
|
787
|
+
trend: t.trend,
|
|
788
|
+
motion: t.motion,
|
|
789
|
+
reading: t.reading,
|
|
790
|
+
lever: t.lever,
|
|
791
|
+
series: t.series,
|
|
792
|
+
options: MONEY_OPTIONS,
|
|
793
|
+
...extra,
|
|
794
|
+
});
|
|
795
|
+
};
|
|
436
796
|
/** the close, as ONE verdict: every stage in order, first red wins */
|
|
437
797
|
const judgeClose = async () => {
|
|
438
798
|
const v = await runStages(stagedClose, scrub, closeOpts);
|
|
439
799
|
closeStage = v.stage;
|
|
440
800
|
lastCloseVerdict = v;
|
|
801
|
+
// Only a RED grade is a reading. A `satisfied` ends the run and a close FAULT
|
|
802
|
+
// rendered no judgment at all (CLOSE_FAULTS) — folding either into the series
|
|
803
|
+
// would put a non-number where the instrument expects a graded one.
|
|
804
|
+
if (v.verdict === 'needs_revision') runTrend.record({ gap: v.gap });
|
|
441
805
|
return v;
|
|
442
806
|
};
|
|
443
807
|
|
|
@@ -459,6 +823,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
459
823
|
// The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
|
|
460
824
|
// ruler and the real inspection cannot drift apart.
|
|
461
825
|
const menu = checkMenu(stagedClose);
|
|
826
|
+
/** Rule A-v2's fact: check names RED at preflight (seed-red). A seed-red
|
|
827
|
+
* check is the GOAL, not a guard — validatePlan confines it to the final
|
|
828
|
+
* write step, and the drafting prompt states the same law. Recorded HERE,
|
|
829
|
+
* from the same preflight the spine records, never re-derived mid-run: the
|
|
830
|
+
* rule is defined on the SEED verdicts (a check a step turned green mid-run
|
|
831
|
+
* is still the goal the plan exists to flip).
|
|
832
|
+
* @type {string[]} */
|
|
833
|
+
const seedRed = [];
|
|
462
834
|
emit('check-menu', {
|
|
463
835
|
offered: menu.map((m) => m.name),
|
|
464
836
|
...(menu.length < stagedClose.length
|
|
@@ -467,6 +839,25 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
467
839
|
});
|
|
468
840
|
for (const m of menu) {
|
|
469
841
|
const v = await runStages(m.run, scrub, closeOpts);
|
|
842
|
+
if (v.verdict === 'needs_revision') {
|
|
843
|
+
seedRed.push(m.name);
|
|
844
|
+
// …and the preflight grade is a READING, for the same reason the precheck one
|
|
845
|
+
// above is: it is the seed the run's work is measured against. The precheck
|
|
846
|
+
// only ever reds at the close's FIRST failing stage (first-red-wins), so on
|
|
847
|
+
// every shipped close — each of which opens with `changed-from-seed` — the
|
|
848
|
+
// numeric stages have no baseline at all without it. A step's first grade
|
|
849
|
+
// would then have nothing to compare against and the whole run would read
|
|
850
|
+
// `unknown` on exactly the stop the readout exists for.
|
|
851
|
+
//
|
|
852
|
+
// ONCE PER STAGE, though. The precheck and this loop grade the SAME unchanged
|
|
853
|
+
// tree back to back with no work in between, so a stage both reach reports the
|
|
854
|
+
// same number twice — and a repeated baseline in a series reads as an attempt
|
|
855
|
+
// that achieved nothing (`verdict 2 → 2 → 1`). That is a phantom flat step
|
|
856
|
+
// handed to a human and to the replanner, which is the exact class of false
|
|
857
|
+
// story this change exists to remove. Asked of the reader's own books, so
|
|
858
|
+
// there is no second record of what has been graded.
|
|
859
|
+
if (!runTrend.report().stages.some((s) => s.stage === v.stage)) runTrend.record({ gap: v.gap });
|
|
860
|
+
}
|
|
470
861
|
emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
|
|
471
862
|
const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
|
|
472
863
|
if (f) {
|
|
@@ -521,7 +912,24 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
521
912
|
const v = await runStages(chain, scrub, closeOpts);
|
|
522
913
|
emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
|
|
523
914
|
if (v.verdict === 'satisfied') return { pass: true };
|
|
524
|
-
if (v.verdict === 'needs_revision')
|
|
915
|
+
if (v.verdict === 'needs_revision') {
|
|
916
|
+
// The run trend is fed by EVERY grade the arbiter renders, and a check IS an
|
|
917
|
+
// arbiter-rendered close grade: the same `runStages`, the same chain, the same
|
|
918
|
+
// stage names, so it lands in the same per-stage buckets and the accuracy law
|
|
919
|
+
// holds unchanged. Without this the reader is blind to everything a STEP ever
|
|
920
|
+
// achieves — it sees only the precheck and the outer fix loop — which is why
|
|
921
|
+
// u-msh70zla's meter, firing mid-step, had nothing to report about a step that
|
|
922
|
+
// had just gone 24 → 15 → 14.
|
|
923
|
+
//
|
|
924
|
+
// Folded HERE, on `v.gap`, and the seam is load-bearing: `evalExits` wraps this
|
|
925
|
+
// gap as `check "<name>" red: …`, and THAT line carries the word "red" with no
|
|
926
|
+
// number on it, so `readGrade` reads the wrapper instead of the wall and every
|
|
927
|
+
// step grade donates a null. Measured on u-msh70zla's own archived gaps: raw
|
|
928
|
+
// reads `typecheck 24 → 15 → 14`, wrapped reads nothing at all. Same seam the
|
|
929
|
+
// Layer R note below already calls out for a check's `^`-anchored gapKeep.
|
|
930
|
+
runTrend.record({ gap: v.gap });
|
|
931
|
+
return { pass: false, gap: v.gap };
|
|
932
|
+
}
|
|
525
933
|
return { pass: false, fault: v.verdict, gap: v.detail };
|
|
526
934
|
};
|
|
527
935
|
|
|
@@ -536,13 +944,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
536
944
|
* denied, the wallet as its budget), granted tools only (the menu IS the
|
|
537
945
|
* grant), per-attempt round bound via loop.stop() (F20), every round metered
|
|
538
946
|
* 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
|
|
947
|
+
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
|
|
540
948
|
* `root` (Layer R): when present, the worker's write-class actions are teed
|
|
541
949
|
* for the within-run ratchet — staged before the gate decides, discarded on
|
|
542
950
|
* deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
|
|
543
951
|
* Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
|
|
952
|
+
*
|
|
953
|
+
* `attempts` is how many iterations this worker will be asked for. `null` is the
|
|
954
|
+
* STRIKE LADDER (src/ladder.js), where that count does not exist by design — see
|
|
955
|
+
* the turn-allowance note below.
|
|
544
956
|
*/
|
|
545
957
|
async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
|
|
958
|
+
/**
|
|
959
|
+
* The gate's LLM-turn allowance THROUGH iteration `i` — the old expression
|
|
960
|
+
* `attemptRounds * (attempts + 1)` with the iteration count made explicit
|
|
961
|
+
* (`i = attempts` reproduces it exactly, so a bounded worker is unchanged to
|
|
962
|
+
* the number).
|
|
963
|
+
* @param {number} i
|
|
964
|
+
*/
|
|
965
|
+
const turnsThrough = (i) => attemptRounds * (i + 1);
|
|
966
|
+
// A pre-multiplied ceiling could only ever pre-pay a KNOWN iteration count, and
|
|
967
|
+
// the strike ladder has none: iterations float under money and the wall. So on
|
|
968
|
+
// that path the allowance is GRANTED PER ITERATION at `setIteration` — the seam
|
|
969
|
+
// that already resets the per-iteration round counter — leaving the belt exactly
|
|
970
|
+
// as tight per iteration as the pre-multiply made it, without inventing a
|
|
971
|
+
// ceiling nobody signed. It is monotonic (`Math.max`), so nothing can lower it.
|
|
972
|
+
//
|
|
973
|
+
// Rebuilding the Gate per iteration was the alternative and was REJECTED: the
|
|
974
|
+
// audit is run_id-scoped, so a fresh gate would reset F32's crash-attribution
|
|
975
|
+
// write set ("files you have written this run") and Layer R's cross-attempt
|
|
976
|
+
// write history to a single iteration — two instruments broken to avoid one
|
|
977
|
+
// addition. The REAL per-iteration bound is unchanged either way: `loop.stop()`
|
|
978
|
+
// at `roundsThisAttempt >= attemptRounds` (the `wasBounded` mechanism).
|
|
979
|
+
const maxTurns = turnsThrough(attempts ?? 1);
|
|
546
980
|
const gate = new Gate({
|
|
547
981
|
fs: {
|
|
548
982
|
// P: a step's `scope` narrows the fence — the per-step prefixes are always a
|
|
@@ -553,7 +987,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
553
987
|
deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
|
|
554
988
|
},
|
|
555
989
|
budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
|
|
556
|
-
limits: { maxTurns
|
|
990
|
+
limits: { maxTurns },
|
|
557
991
|
audit: { path: auditPath },
|
|
558
992
|
// The gate audit is the THIRD persistent, append-only channel (beside the
|
|
559
993
|
// spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
|
|
@@ -569,21 +1003,35 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
569
1003
|
});
|
|
570
1004
|
await gate.init();
|
|
571
1005
|
// 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
|
-
|
|
1006
|
+
// the same audit read as interpret's (never git status, F45). ONE scan and ONE
|
|
1007
|
+
// filter behind both readings: a future write-class verb that slipped into one
|
|
1008
|
+
// and not the other would make the two instruments disagree about what a write
|
|
1009
|
+
// is, which is the exact class the tee's `type`-not-name test exists to close.
|
|
1010
|
+
const auditWrites = () => {
|
|
1011
|
+
const paths = new Set();
|
|
1012
|
+
let records = 0;
|
|
574
1013
|
try {
|
|
575
|
-
const paths = new Set();
|
|
576
1014
|
for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
|
|
577
1015
|
if (!line) continue;
|
|
578
1016
|
let rec;
|
|
579
1017
|
try { rec = JSON.parse(line); } catch { continue; }
|
|
580
1018
|
if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
|
|
581
1019
|
&& (rec.action?.type === 'write' || rec.action?.type === 'edit')
|
|
582
|
-
&& typeof rec.action.path === 'string') paths.add(rec.action.path);
|
|
1020
|
+
&& typeof rec.action.path === 'string') { paths.add(rec.action.path); records += 1; }
|
|
583
1021
|
}
|
|
584
|
-
|
|
585
|
-
|
|
1022
|
+
} catch { /* no audit yet → nothing written yet */ }
|
|
1023
|
+
return { paths: [...paths], records };
|
|
586
1024
|
};
|
|
1025
|
+
const workerWrites = () => auditWrites().paths;
|
|
1026
|
+
/**
|
|
1027
|
+
* The ladder's write signal: the cumulative RECORD count, never the path set.
|
|
1028
|
+
* A plan step rewrites its one target every attempt, so the path set is constant
|
|
1029
|
+
* after iteration 1 and a set-delta would read every later iteration as idle —
|
|
1030
|
+
* the same Finding-3 trap Layer R documents, and it would strike a working
|
|
1031
|
+
* worker out on its second attempt. The record count advances on every allowed
|
|
1032
|
+
* write-class action, same path or not.
|
|
1033
|
+
*/
|
|
1034
|
+
const writeCount = () => auditWrites().records;
|
|
587
1035
|
// Layer R tee (design record 2026-07-19; created per-step by executeStep).
|
|
588
1036
|
// The translator STAGES write/edit content and snapshots the pre-write hash
|
|
589
1037
|
// BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
|
|
@@ -652,6 +1100,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
652
1100
|
let roundsThisAttempt = 0;
|
|
653
1101
|
/** @type {number|string|undefined} */
|
|
654
1102
|
let attemptBounded;
|
|
1103
|
+
/**
|
|
1104
|
+
* Open an iteration: label the rounds, reset the per-iteration round counter,
|
|
1105
|
+
* and — on the unbounded ladder only — GRANT this iteration's turn allowance to
|
|
1106
|
+
* the gate. Raising `limits.maxTurns` is documented bareguard config read live
|
|
1107
|
+
* by `Limits.preCheck()` on every record; the alternative (a fresh Gate per
|
|
1108
|
+
* iteration) is the one that breaks instruments, so this is the smaller change.
|
|
1109
|
+
* Monotonic by construction: a re-entry can only ever hold the allowance, never
|
|
1110
|
+
* lower it. A bounded worker's ceiling is untouched — it was pre-paid in full.
|
|
1111
|
+
* @param {number|string} i
|
|
1112
|
+
*/
|
|
1113
|
+
const setIteration = (i) => {
|
|
1114
|
+
roundIteration = i;
|
|
1115
|
+
roundsThisAttempt = 0;
|
|
1116
|
+
if (attempts === null && typeof i === 'number') {
|
|
1117
|
+
gate.limits.maxTurns = Math.max(gate.limits.maxTurns, turnsThrough(i));
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
655
1120
|
const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
|
|
656
1121
|
const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
|
|
657
1122
|
// F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
|
|
@@ -752,7 +1217,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
752
1217
|
}
|
|
753
1218
|
return r;
|
|
754
1219
|
};
|
|
755
|
-
return { ask, askFrom, workerWrites, setIteration
|
|
1220
|
+
return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
|
|
756
1221
|
}
|
|
757
1222
|
|
|
758
1223
|
// ── LOOP path: the injected provider (anthropic-api and every other
|
|
@@ -763,7 +1228,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
763
1228
|
// onLlmResult meters exactly like an API round, so the drafter's spend is
|
|
764
1229
|
// never invisible (F6/F44). The gate policy is wired but idle (no tools).
|
|
765
1230
|
const loopProvider = native
|
|
766
|
-
? /** @type {any} */ (nativeProvider)({ policy, maxTurns
|
|
1231
|
+
? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
|
|
767
1232
|
: (workerProvider ?? provider);
|
|
768
1233
|
// F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
|
|
769
1234
|
// not call duration (provider-http.js `req.setTimeout` resets on every byte),
|
|
@@ -870,7 +1335,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
870
1335
|
*/
|
|
871
1336
|
const askFrom = async (msgs, prompt) => {
|
|
872
1337
|
try {
|
|
873
|
-
return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000,
|
|
1338
|
+
return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
874
1339
|
} catch (e) {
|
|
875
1340
|
throw categorize(e).err;
|
|
876
1341
|
}
|
|
@@ -878,7 +1343,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
878
1343
|
const ask = async (prompt, defs = toolDefs) => {
|
|
879
1344
|
let r;
|
|
880
1345
|
try {
|
|
881
|
-
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000,
|
|
1346
|
+
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
|
|
882
1347
|
} catch (e) {
|
|
883
1348
|
throw categorize(e).err;
|
|
884
1349
|
}
|
|
@@ -895,7 +1360,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
895
1360
|
}
|
|
896
1361
|
return r;
|
|
897
1362
|
};
|
|
898
|
-
return { ask, askFrom, workerWrites, setIteration
|
|
1363
|
+
return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
|
|
899
1364
|
}
|
|
900
1365
|
|
|
901
1366
|
/** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
|
|
@@ -906,10 +1371,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
906
1371
|
const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
|
|
907
1372
|
const { category } = categorize(e);
|
|
908
1373
|
const detail = String(e?.message ?? e);
|
|
909
|
-
if (category === 'cap-halt')
|
|
1374
|
+
if (category === 'cap-halt') {
|
|
1375
|
+
emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
|
|
1376
|
+
// v1.46 §2 — the money record rides beside the taxonomy record exactly as the
|
|
1377
|
+
// wall's does one line down. Outside the loops there is usually no series yet,
|
|
1378
|
+
// and the readout says `unknown` rather than inventing a direction (F6).
|
|
1379
|
+
emitMoneyHalt({ phase, cutMidCall: true });
|
|
1380
|
+
}
|
|
910
1381
|
if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
|
|
911
1382
|
const DECIDE = {
|
|
912
|
-
'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`,
|
|
1383
|
+
'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, MONEY_OPTIONS],
|
|
913
1384
|
'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
|
|
914
1385
|
// A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
|
|
915
1386
|
// surfaces — but it is named, never laundered into provider-red. The socket
|
|
@@ -933,48 +1404,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
933
1404
|
// ── 1. SCOUT — read-only by construction: the write-class verbs are simply
|
|
934
1405
|
// not in its menu (the menu is the grant), and its gate fences zero paths
|
|
935
1406
|
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
|
-
|
|
1407
|
+
// RESUME (module C v2): the survey is a PAID unit the killed run already bought, and
|
|
1408
|
+
// the plan it briefed is being reloaded rather than re-drafted — so there is nothing
|
|
1409
|
+
// left for it to brief. Skipping it is the whole point of the ruling ("why would i
|
|
1410
|
+
// want to waste more money on something i already started"), and the skip is a RECORD
|
|
1411
|
+
// rather than silence, with the one consequence named on it: the survey text is not on
|
|
1412
|
+
// the spine (only its byte count ever was), so a replan after a resume drafts from an
|
|
1413
|
+
// empty survey plus its failure brief. Re-scouting to refill it would re-pay exactly
|
|
1414
|
+
// the call this exists to save.
|
|
1415
|
+
if (resumeSeed) {
|
|
1416
|
+
emit('scout-skipped', {
|
|
1417
|
+
reason: 'resumed', phase: resumeSeed.phase ?? null,
|
|
1418
|
+
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.',
|
|
1419
|
+
});
|
|
1420
|
+
} else {
|
|
1421
|
+
emit('scout-start', { rounds: scoutRounds });
|
|
1422
|
+
try {
|
|
1423
|
+
const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
|
|
1424
|
+
scout.setIteration(1);
|
|
1425
|
+
const r = await scout.ask([
|
|
1426
|
+
'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.',
|
|
1427
|
+
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
1428
|
+
`Goal:\n${job.goal}`,
|
|
1429
|
+
pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
|
|
1430
|
+
].filter(Boolean).join('\n\n'));
|
|
1431
|
+
scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
|
|
1432
|
+
// F59 — RESERVE the summary round. The round bound is enforced from the
|
|
1433
|
+
// metering callback (loop.stop), so a scout still calling tools on its last
|
|
1434
|
+
// round is halted mid-tool-use and `text` is empty: it spends the whole
|
|
1435
|
+
// allowance exploring and never writes the survey that is its ONLY
|
|
1436
|
+
// deliverable. Measured on 15 of 18 archived runs, which then drafted from
|
|
1437
|
+
// `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
|
|
1438
|
+
// The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
|
|
1439
|
+
// carries the exploration): with no tools offered, text is the only possible
|
|
1440
|
+
// output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
|
|
1441
|
+
// a pacing mandate in the persona was violated 6/6).
|
|
1442
|
+
// The trigger is BOUNDED-and-short, never short alone: a terse survey that
|
|
1443
|
+
// finished on its own was not cut off and has nothing to recover. The archive
|
|
1444
|
+
// separates cleanly on exactly this pair — every truncated scout was bounded
|
|
1445
|
+
// with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
|
|
1446
|
+
if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
|
|
1447
|
+
emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
|
|
1448
|
+
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.');
|
|
1449
|
+
const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
|
|
1450
|
+
if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
|
|
1451
|
+
}
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
return relay(e, 'scout');
|
|
966
1454
|
}
|
|
967
|
-
|
|
968
|
-
|
|
1455
|
+
emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
|
|
1456
|
+
// F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
|
|
1457
|
+
// out of 18 and read by nobody: an instrument that works but has no consumer is a
|
|
1458
|
+
// log line, not evidence. A survey that is still empty after its reserved round is
|
|
1459
|
+
// now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
|
|
1460
|
+
// archived greens had an empty scout, so failing the run here would be a worse
|
|
1461
|
+
// error than the one being fixed.
|
|
1462
|
+
if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
|
|
969
1463
|
}
|
|
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
1464
|
// F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
|
|
979
1465
|
// see — halt at the boundary rather than run the whole plan blind (the caller
|
|
980
1466
|
// emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
|
|
@@ -999,11 +1485,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
999
1485
|
// is actually left, which is the adaptation channel.
|
|
1000
1486
|
const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
|
|
1001
1487
|
emit('materials', { phase, ...materials });
|
|
1488
|
+
// D5 — the bridge rides into the FIRST draft phase ONLY. A replan drafts from the
|
|
1489
|
+
// RUN'S OWN STATE (what failed, what is left): re-handing the recipe there would
|
|
1490
|
+
// answer the replan's question with the material the run just demonstrated does not
|
|
1491
|
+
// work, and would spend the one revision the ceiling allows on a second unmeasured
|
|
1492
|
+
// arm. Exactly the rule the execution probe held at the provider seam, moved to where
|
|
1493
|
+
// the phase is known rather than inferred from the prompt's own text.
|
|
1494
|
+
// The REDRAFT (a validator rejection inside this same phase) keeps it: one red must
|
|
1495
|
+
// not silently convert a warm run to a cold one.
|
|
1496
|
+
const starting = phase === 'draft' ? startingDraft : null;
|
|
1497
|
+
// The shape-lottery facts, gate AND prompt from the ONE object (the scope-menu
|
|
1498
|
+
// precedent: what is stated and what is enforced can never drift apart).
|
|
1499
|
+
// `seedRed` rides every phase — the rule is defined on the SEED verdicts.
|
|
1500
|
+
// `priorChecks` is the REPLAN gate only: the accepted plan's carried checks,
|
|
1501
|
+
// which the redraft may move (A-v2 decides where) but never shed (Rule B —
|
|
1502
|
+
// u-msdsmkid's replan dropped both checks and "greened" on form alone).
|
|
1503
|
+
const checkFacts = {
|
|
1504
|
+
seedRed,
|
|
1505
|
+
...(phase === 'replan' && plan
|
|
1506
|
+
? { 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)))] }
|
|
1507
|
+
: {}),
|
|
1508
|
+
};
|
|
1002
1509
|
const draftPlan = async (/** @type {any[]|null} */ reds) => {
|
|
1003
1510
|
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), []);
|
|
1511
|
+
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts), []);
|
|
1007
1512
|
return extractArtifact(r.text).code ?? '';
|
|
1008
1513
|
};
|
|
1009
1514
|
// Scrubbed HERE, once, before any consumer reads them — the judge() precedent
|
|
@@ -1016,7 +1521,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1016
1521
|
// emit sites: scrubbing here makes the leak inexpressible, scrubbing each
|
|
1017
1522
|
// consumer makes it something the next consumer has to remember.
|
|
1018
1523
|
const validate = (/** @type {string} */ t) => {
|
|
1019
|
-
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu,
|
|
1524
|
+
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
|
|
1020
1525
|
return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
|
|
1021
1526
|
};
|
|
1022
1527
|
let text = await draftPlan(null);
|
|
@@ -1030,16 +1535,76 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1030
1535
|
return pv;
|
|
1031
1536
|
};
|
|
1032
1537
|
let plan;
|
|
1033
|
-
|
|
1034
|
-
|
|
1538
|
+
/** RESUME: how many leading steps of the reloaded plan are already SATISFIED and are
|
|
1539
|
+
* therefore skipped. Zero on every other path, and reset to zero by a replan — the new
|
|
1540
|
+
* plan's steps are not the dead plan's steps, even where an id repeats. */
|
|
1541
|
+
let skipCount = 0;
|
|
1542
|
+
if (resumeSeed) {
|
|
1543
|
+
// The plan is RELOADED, not re-drafted — but it is re-validated against the spec that
|
|
1544
|
+
// is signed NOW. The dead run validated it against the spec it was signed under, and
|
|
1545
|
+
// between the kill and the resume the menu, the fence or the close can have moved
|
|
1546
|
+
// (registry/schema drift). A stale plan is refused BY NAME with zero spent; running
|
|
1547
|
+
// it would execute a plan the current signature does not admit, which is the one
|
|
1548
|
+
// thing no reconstruction is allowed to do.
|
|
1549
|
+
// The shape-lottery opts (seedRed/priorChecks) are deliberately NOT passed:
|
|
1550
|
+
// they are DRAFTING laws — this gate guards signature drift, and refusing a
|
|
1551
|
+
// paid, previously-legal plan over a shape rule minted after its draft would
|
|
1552
|
+
// burn the very work resume exists to keep (hamr: "why would i want to waste
|
|
1553
|
+
// more money on something i already started"). Omission = rules inactive, by
|
|
1554
|
+
// the validator's contract.
|
|
1555
|
+
const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu });
|
|
1556
|
+
// scrubbed at the boundary for `validate()`'s own reason: a `parse-error` detail
|
|
1557
|
+
// quotes a window of the SOURCE, and the spine is append-only forever
|
|
1558
|
+
const reds = pv.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
|
|
1035
1559
|
if (!pv.ok) {
|
|
1036
|
-
|
|
1560
|
+
emit('resume-plan-red', { reds, phase: resumeSeed.phase ?? null });
|
|
1561
|
+
for (const r of reds) emit('plan-red', r);
|
|
1562
|
+
emit('escalation', {
|
|
1563
|
+
category: 'plan-red', decisionReady: true, phase: 'resume',
|
|
1564
|
+
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.',
|
|
1565
|
+
options: [
|
|
1566
|
+
'rerun WITHOUT --resume (the run drafts a cold plan against the current spec — the killed run\'s steps are then not inherited)',
|
|
1567
|
+
'restore the spec version the dead run was signed under and resume again',
|
|
1568
|
+
],
|
|
1569
|
+
detail: reds.map((r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
|
|
1570
|
+
});
|
|
1037
1571
|
return 'plan-red';
|
|
1038
1572
|
}
|
|
1039
1573
|
plan = /** @type {any} */ (pv.plan);
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1574
|
+
// The skip set is a PREFIX match on the plan's own step ids, never a count: a count
|
|
1575
|
+
// would skip whatever happens to sit at that index. Steps run strictly sequentially
|
|
1576
|
+
// and idx only advances on a satisfied exit, so the dead run's completions ARE a
|
|
1577
|
+
// prefix — and a seed that does not line up stops the skipping there and says so on
|
|
1578
|
+
// the record, which re-runs work rather than skipping work nobody did (the safe
|
|
1579
|
+
// direction of the two).
|
|
1580
|
+
const completed = Array.isArray(resumeSeed.completedSteps) ? resumeSeed.completedSteps : [];
|
|
1581
|
+
while (skipCount < completed.length && skipCount < plan.steps.length
|
|
1582
|
+
&& completed[skipCount]?.id === plan.steps[skipCount].id) skipCount += 1;
|
|
1583
|
+
emit('resume-seed', {
|
|
1584
|
+
phase: resumeSeed.phase ?? null,
|
|
1585
|
+
planSteps: plan.steps.map((/** @type {any} */ s) => s.id),
|
|
1586
|
+
completed: completed.map((/** @type {any} */ c) => c?.id ?? null),
|
|
1587
|
+
skipping: skipCount,
|
|
1588
|
+
...(skipCount < completed.length
|
|
1589
|
+
? { 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` }
|
|
1590
|
+
: {}),
|
|
1591
|
+
});
|
|
1592
|
+
// this leg's own plan-accepted: R1 mints a bridge version from the plan AS EXECUTED,
|
|
1593
|
+
// read back off the spine, so a resumed leg that greens without one would mint
|
|
1594
|
+
// nothing. `phase` says where it came from — it was accepted, not drafted.
|
|
1595
|
+
emit('plan-accepted', { plan, phase: 'resume' });
|
|
1596
|
+
} else {
|
|
1597
|
+
try {
|
|
1598
|
+
const pv = await obtainPlan('draft', null);
|
|
1599
|
+
if (!pv.ok) {
|
|
1600
|
+
for (const r of pv.reds) emit('plan-red', r);
|
|
1601
|
+
return 'plan-red';
|
|
1602
|
+
}
|
|
1603
|
+
plan = /** @type {any} */ (pv.plan);
|
|
1604
|
+
emit('plan-accepted', { plan });
|
|
1605
|
+
} catch (e) {
|
|
1606
|
+
return relay(e, 'plan');
|
|
1607
|
+
}
|
|
1043
1608
|
}
|
|
1044
1609
|
if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
|
|
1045
1610
|
|
|
@@ -1049,8 +1614,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1049
1614
|
const artifacts = [];
|
|
1050
1615
|
/** @type {{id: string, outcome: string}[]} */
|
|
1051
1616
|
const stepOutcomes = [];
|
|
1052
|
-
|
|
1053
|
-
|
|
1617
|
+
/** RESUME — the replan ledger the CHAIN has already spent, belted the way every
|
|
1618
|
+
* other declared fold entering the arbiter's arithmetic is (`priorSpentUsd`,
|
|
1619
|
+
* `priorWallMs`, `declaredRounds`): a garbage or negative seed reads as 0 rather
|
|
1620
|
+
* than as a NaN that would poison every later comparison, and a negative one must
|
|
1621
|
+
* never cancel a real replan and widen the ceiling (reuse's `> 0` belt, F-4). */
|
|
1622
|
+
const seededReplans = typeof resumeReplans?.count === 'number' && Number.isFinite(resumeReplans.count) && resumeReplans.count > 0
|
|
1623
|
+
? Math.floor(resumeReplans.count) : 0;
|
|
1624
|
+
// Seeded, not reset. These three were locals reborn on every `runPlan` call — and a
|
|
1625
|
+
// resume IS another call, so the ceiling was refilled by every kill (see the
|
|
1626
|
+
// `resumeReplans` param doc for the measurement and for why this bound spans the
|
|
1627
|
+
// chain while the trend's iteration bound deliberately does not).
|
|
1628
|
+
let replanned = seededReplans > 0;
|
|
1629
|
+
/** F85 (C): how many replans this run has actually drafted, and whether the
|
|
1630
|
+
* arbiter has already spent its ONE extra grant. Two variables and not one
|
|
1631
|
+
* counter compared against a limit, deliberately — a latch cannot be widened by
|
|
1632
|
+
* arithmetic, and the ceiling is the thing this rung must not move (v1.12). */
|
|
1633
|
+
let replans = seededReplans;
|
|
1634
|
+
// the F85-C latch travels BESIDE the count and is not derived from it: a leg can
|
|
1635
|
+
// inherit a spent ordinary ceiling with the grant still unearned (count 1, false),
|
|
1636
|
+
// and deriving one from the other would collapse those two states into one.
|
|
1637
|
+
let varianceGrantUsed = resumeReplans?.grantUsed === true;
|
|
1638
|
+
// `replanned` stays the shipped boolean this record has always carried; `replans`
|
|
1639
|
+
// rides BESIDE it (append-only — a field's meaning is never repurposed).
|
|
1640
|
+
//
|
|
1641
|
+
// Both now count the CHAIN rather than the leg, and that IS a meaning change — made
|
|
1642
|
+
// rather than shadowed by a second field because `replans` shipped only on this
|
|
1643
|
+
// unreleased branch, so no archived spine carries the leg reading for a reader to
|
|
1644
|
+
// be confused by. The leg's own number is not lost by it: a leg emits one `replan`
|
|
1645
|
+
// record per replan it drafts, so `chain − records-in-this-window` is the fold it
|
|
1646
|
+
// inherited, and that is exactly the arithmetic `readResume` runs to rebuild the
|
|
1647
|
+
// ledger for the next leg. A second field stating a number already derivable from
|
|
1648
|
+
// the window would be a second reader of one question (F6's structural cousin).
|
|
1649
|
+
const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned, replans });
|
|
1054
1650
|
/** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
|
|
1055
1651
|
* step's attempt. It carries that stop's own record fields, and its presence is what
|
|
1056
1652
|
* lets the step loop's terminal tell this reading apart from F64's mid-call one
|
|
@@ -1082,23 +1678,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1082
1678
|
const root = layerRoot && !native
|
|
1083
1679
|
? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
|
|
1084
1680
|
: null;
|
|
1085
|
-
// P: the
|
|
1086
|
-
// optional field is the F50 blind-instrument class. `
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
|
|
1681
|
+
// P: the widened step fields, each wired or refused — a silently-ignored
|
|
1682
|
+
// optional field is the F50 blind-instrument class. `scope` narrows the fence to
|
|
1683
|
+
// a menu value (a subset by construction); `model` swaps the worker's provider
|
|
1684
|
+
// via the runner's factory — absent factory it must STOP, not silently run the
|
|
1685
|
+
// default tier as if the choice had been honoured. `attempts` is the third and
|
|
1686
|
+
// is now TOLERATED-INERT (see validatePlan): the ladder governs iterations by
|
|
1687
|
+
// progress, and a field that could only ever TIGHTEN a count is the wrong shape
|
|
1688
|
+
// for that — drafters measurably tightened it to 3, then to 2 on replan, which
|
|
1689
|
+
// is the opposite of the heal a converging step needs.
|
|
1092
1690
|
if (step.model !== undefined && !providerFor) {
|
|
1093
1691
|
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
1692
|
err.category = 'interpreter-red';
|
|
1095
1693
|
throw err;
|
|
1096
1694
|
}
|
|
1097
1695
|
const w = await mkWorker({
|
|
1098
|
-
granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts:
|
|
1696
|
+
granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: null, writable: true, root,
|
|
1099
1697
|
fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
|
|
1100
1698
|
workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
|
|
1101
1699
|
});
|
|
1700
|
+
// ONE ladder per step: the seen-set and the strike count are this step's own
|
|
1701
|
+
// history, and a shared one would strike a fresh step for a gap an earlier step
|
|
1702
|
+
// had already seen. The write signal is the gate audit's own record count (F32's
|
|
1703
|
+
// filter), never git status and never a tree diff.
|
|
1704
|
+
const ladder = createLadder({ limit: strikeLimit, writeCount: w.writeCount });
|
|
1102
1705
|
let lastText = '';
|
|
1103
1706
|
let iterationNow = 0;
|
|
1104
1707
|
// The meter's baseline: what the RUN had left when this step began. Shares are
|
|
@@ -1118,15 +1721,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1118
1721
|
const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
|
|
1119
1722
|
const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
|
|
1120
1723
|
if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
|
|
1724
|
+
// F85 — the meter REPORTS progress; it does not decide on it. The
|
|
1725
|
+
// condition above is the whole trigger and stays the whole trigger: a
|
|
1726
|
+
// converging step that is eating the run is still stopped, because this is
|
|
1727
|
+
// a governance instrument over an operator-owned allowance and a governor
|
|
1728
|
+
// that suppressed itself whenever the work looked promising would be
|
|
1729
|
+
// judging capability with the budget. What was missing was never a term in
|
|
1730
|
+
// the condition — it was the READING. u-msh70zla stopped a step whose close
|
|
1731
|
+
// had gone 24 → 15 → 14 and then told the replanner its exits were
|
|
1732
|
+
// "unmoved", so the replanner threw the progress away and re-targeted a
|
|
1733
|
+
// file that was already clean.
|
|
1734
|
+
//
|
|
1735
|
+
// The reading comes off `runTrend` — the SAME instance the money halt reads
|
|
1736
|
+
// (src/trend.js's ONE PER SERIES). A second reader for one question is how
|
|
1737
|
+
// two readers come to disagree, which is precisely the defect being fixed.
|
|
1738
|
+
// It is per-stage, numbers-and-stage-names only, and `unknown` stays
|
|
1739
|
+
// `unknown` — a close that reports no number donates nothing (F6).
|
|
1740
|
+
const t = runTrend.verdict();
|
|
1121
1741
|
emit('variance', {
|
|
1122
1742
|
step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
|
|
1123
1743
|
moneyShare: Number(moneyShare.toFixed(3)),
|
|
1124
1744
|
timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
|
|
1125
1745
|
axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
|
|
1746
|
+
// ADDED fields, never repurposed ones (the spine is append-only forever).
|
|
1747
|
+
// Same four names and same shapes the money halt already emits, so one
|
|
1748
|
+
// reader parses a progress reading wherever it appears.
|
|
1749
|
+
trend: t.trend, motion: t.motion, reading: t.reading, series: t.series,
|
|
1126
1750
|
});
|
|
1127
1751
|
const err = /** @type {CategorizedError} */ (new Error(
|
|
1128
1752
|
`step "${step.id}" consumed ${Math.round(Math.max(moneyShare, timeShare) * 100)}% of the run's remaining `
|
|
1129
|
-
+ `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red`
|
|
1753
|
+
+ `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red. `
|
|
1754
|
+
// The escalation `detail` is this message (ralph passes it through), and
|
|
1755
|
+
// the replan brief quotes the detail — so this sentence is what a human
|
|
1756
|
+
// AND the redrafting planner read. It states what the run achieved; it
|
|
1757
|
+
// never states what to do about it, which is the operator's lever list.
|
|
1758
|
+
+ `Run progress: ${t.reading}.`));
|
|
1130
1759
|
err.category = 'step-variance';
|
|
1131
1760
|
throw err;
|
|
1132
1761
|
}
|
|
@@ -1162,16 +1791,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1162
1791
|
stepsDone: idx,
|
|
1163
1792
|
stepsPlanned: plan.steps.length,
|
|
1164
1793
|
attemptsUsed: iteration - 1,
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
|
|
1172
|
-
|
|
1794
|
+
// The ladder's own state, because the ladder is what was governing this
|
|
1795
|
+
// step when the wall stopped it. This record used to carry `capRuns` and
|
|
1796
|
+
// `stepAttemptCap` — two counts that no longer bound anything here — and a
|
|
1797
|
+
// field name that means something it no longer measures is worse than an
|
|
1798
|
+
// absent one: a reader cannot tell it went stale. The FIX site's copy of this
|
|
1799
|
+
// record dropped `capRuns` for the same reason at v1.46 §4, when the count
|
|
1800
|
+
// stopped governing that loop too — neither wall record quotes a denominator
|
|
1801
|
+
// now, because neither loop has one.
|
|
1802
|
+
strikes: ladder.report().strikes,
|
|
1803
|
+
strikeLimit: ladder.report().limit,
|
|
1804
|
+
distinctGaps: ladder.report().distinctGaps,
|
|
1173
1805
|
};
|
|
1174
|
-
// No trend here, and none invented:
|
|
1806
|
+
// No trend here, and none invented: the trend instrument reads CLOSE grades,
|
|
1175
1807
|
// and a step that never ran has no grade of its own to compare. The lever the
|
|
1176
1808
|
// human needs is on the run-level record either way.
|
|
1177
1809
|
const err = /** @type {CategorizedError} */ (new Error(
|
|
@@ -1201,6 +1833,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1201
1833
|
].filter(Boolean).join('\n\n'));
|
|
1202
1834
|
lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
|
|
1203
1835
|
};
|
|
1836
|
+
let lastGap = '';
|
|
1204
1837
|
const judge = async () => {
|
|
1205
1838
|
const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
|
|
1206
1839
|
// Scrubbed HERE, once, before the results are read by anyone. An exit detail is
|
|
@@ -1224,15 +1857,38 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1224
1857
|
if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
|
|
1225
1858
|
if (pass) return { verdict: 'satisfied' };
|
|
1226
1859
|
// AND-only: the gap names EVERY failing wall (mechanical genre, F38)
|
|
1227
|
-
|
|
1860
|
+
lastGap = results.filter((r) => !r.pass).map((r) => r.detail).join('\n');
|
|
1861
|
+
return { verdict: 'needs_revision', gap: lastGap };
|
|
1228
1862
|
};
|
|
1229
|
-
const outcome = await ralph({ middle, judge,
|
|
1230
|
-
|
|
1863
|
+
const outcome = await ralph({ middle, judge, ladder, emit: emitL, workerWrites: w.workerWrites });
|
|
1864
|
+
// gap + writes ride out for the replan brief's mismatch line (the books this
|
|
1865
|
+
// step already kept; the escalation's detail carries the meter/strike sentence,
|
|
1866
|
+
// never the gap, so the brief cannot recover this fact from anywhere else)
|
|
1867
|
+
return { outcome, artifact: lastText, ladder, writes: w.workerWrites(), gap: lastGap };
|
|
1231
1868
|
};
|
|
1232
1869
|
|
|
1233
1870
|
let idx = 0;
|
|
1234
1871
|
while (idx < plan.steps.length) {
|
|
1235
1872
|
const step = plan.steps[idx];
|
|
1873
|
+
// RESUME: this step's exit was already satisfied before the kill. It is not re-run —
|
|
1874
|
+
// that is the money and the time the ruling is about — and it is not silent either:
|
|
1875
|
+
// the record is its OWN type carrying the event that proves it, so a reader can tell
|
|
1876
|
+
// a skipped step from a run one (the resume-to-cap invariant, which a fake
|
|
1877
|
+
// step-start/step-end pair would destroy). The step's ARTIFACT is not reconstructed:
|
|
1878
|
+
// its text was a prompt ingredient, never a checkpoint, and its real product is on
|
|
1879
|
+
// disk where the next step will find it.
|
|
1880
|
+
if (idx < skipCount) {
|
|
1881
|
+
const proof = resumeSeed.completedSteps[idx];
|
|
1882
|
+
emit('step-skipped', {
|
|
1883
|
+
step: step.id,
|
|
1884
|
+
provenBy: proof?.by ?? null,
|
|
1885
|
+
provenSeq: typeof proof?.seq === 'number' ? proof.seq : null,
|
|
1886
|
+
meaning: 'the killed run satisfied this step\'s exits — it is not re-run and not re-paid',
|
|
1887
|
+
});
|
|
1888
|
+
stepOutcomes.push({ id: step.id, outcome: 'skipped' });
|
|
1889
|
+
idx += 1;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1236
1892
|
let res;
|
|
1237
1893
|
try {
|
|
1238
1894
|
res = await executeStep(step);
|
|
@@ -1247,7 +1903,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1247
1903
|
const err = /** @type {CategorizedError} */ (e);
|
|
1248
1904
|
const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
|
|
1249
1905
|
stepOutcomes.push({ id: step.id, outcome: category });
|
|
1250
|
-
if (category === 'cap-halt')
|
|
1906
|
+
if (category === 'cap-halt') {
|
|
1907
|
+
emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
|
|
1908
|
+
// …and the MONEY record beside it, as every other cap-halt site emits one. A
|
|
1909
|
+
// readout a human gets at three seams and not at the fourth is the readout being
|
|
1910
|
+
// a coincidence of where the stop happened. UNREACHABLE today — nothing in
|
|
1911
|
+
// mkWorker/gate.init/snapshotScope buys a round, so no wallet can drain here —
|
|
1912
|
+
// which is why this is symmetry rather than a fix, and why it stays a `HaltError`
|
|
1913
|
+
// branch instead of a widened one.
|
|
1914
|
+
emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
1915
|
+
}
|
|
1251
1916
|
emit('escalation', {
|
|
1252
1917
|
category, decisionReady: true, phase: `step:${step.id}`,
|
|
1253
1918
|
decision: category === 'cap-halt'
|
|
@@ -1296,12 +1961,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1296
1961
|
// pricing-red at the step-end guard above, so isUnpriced() is false here.)
|
|
1297
1962
|
//
|
|
1298
1963
|
// A (PRD v1.27) adds the SECOND trigger: `step-variance` — the meter stopped a
|
|
1299
|
-
// step that had eaten a declared share of the run
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
// replan ceiling — this changes the trigger, never the ceiling (v1.12).
|
|
1964
|
+
// step that had eaten a declared share of the run. That is the case
|
|
1965
|
+
// exhaustion-only could never reach (F56: a replan has fired zero times in the
|
|
1966
|
+
// programme), and it is a genuine re-allocation rather than a stop. Both
|
|
1967
|
+
// triggers still need FUNDS LEFT, and both are bounded by the replan ceiling.
|
|
1304
1968
|
const cat = lastEscalation?.category;
|
|
1969
|
+
/** F85 — the run's measured trajectory at the moment of this stop, read ONCE
|
|
1970
|
+
* here off the same instance the meter and the money halt read. Both consumers
|
|
1971
|
+
* below are the replanner's: the trigger sentence and the brief. */
|
|
1972
|
+
const stopTrend = runTrend.verdict();
|
|
1305
1973
|
// F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
|
|
1306
1974
|
// model call MAX_STALLS times and never got a round back. hamr's ruling is that
|
|
1307
1975
|
// a stall must self-heal rather than end the run ("killing and coming back is
|
|
@@ -1309,32 +1977,114 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1309
1977
|
// demonstrably stopped working does the run adapt instead — by replanning, not
|
|
1310
1978
|
// by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
|
|
1311
1979
|
// other two: this changes the trigger, never the ceiling (v1.12).
|
|
1980
|
+
//
|
|
1981
|
+
// F85 rewrote the variance branch. It used to be the FIXED string "…with its
|
|
1982
|
+
// exits unmoved", printed for every step-variance stop whatever had happened —
|
|
1983
|
+
// and on u-msh70zla it was simply false: the exits had moved 24 → 15 → 14. The
|
|
1984
|
+
// sentence now states the meter's fact (a share of the run) and then the trend
|
|
1985
|
+
// instrument's MEASURED reading, which is the same reading the `variance` record
|
|
1986
|
+
// and the money halt carry. Nothing here asserts a direction the reader did not
|
|
1987
|
+
// report: `unknown` says unknown.
|
|
1312
1988
|
const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
|
|
1313
|
-
: cat === 'step-variance' ?
|
|
1989
|
+
: cat === 'step-variance' ? `the meter stopped a step that was consuming the run — ${stopTrend.reading}`
|
|
1314
1990
|
: cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
|
|
1315
1991
|
: null;
|
|
1316
|
-
|
|
1992
|
+
// F85 (C) — the ARBITER's one additional replan. A second `step-variance` after
|
|
1993
|
+
// the ceiling is spent used to be a hard stop, and on a run that was still
|
|
1994
|
+
// converging that stop threw away work the run had already paid for. So the
|
|
1995
|
+
// arbiter may grant ONE more, and every clause of that sentence is a constraint:
|
|
1996
|
+
//
|
|
1997
|
+
// * ONE. Bounded, and bounded by a latch rather than a comparison, so the
|
|
1998
|
+
// ceiling cannot creep: unlimited replanning launders thrash as adaptation
|
|
1999
|
+
// (v1.12). A third variance stop is the stop, however well it is going.
|
|
2000
|
+
// * the ARBITER. Read mechanically off `runTrend` — the same instance the
|
|
2001
|
+
// meter and the money halt read. The agent never asks for it, never sees it
|
|
2002
|
+
// offered, and cannot influence it: no self-adjusted budgets, ever.
|
|
2003
|
+
// * `converging`, the trend instrument's OWN category. No new number is
|
|
2004
|
+
// invented here — threshold-setting is the operator's, and a fresh
|
|
2005
|
+
// comparison would be a second reader of the one question (src/trend.js).
|
|
2006
|
+
// `flat` and `unknown` are refusals; an unknown is never rounded up into a
|
|
2007
|
+
// reason to spend (F6).
|
|
2008
|
+
// * step-variance only. An exhaustion or a stall after the ceiling is
|
|
2009
|
+
// unchanged — this widens nothing but the case it was measured on.
|
|
2010
|
+
const grantExtraReplan = replanned
|
|
2011
|
+
&& cat === 'step-variance'
|
|
2012
|
+
&& !varianceGrantUsed
|
|
2013
|
+
&& stopTrend.trend === 'converging';
|
|
2014
|
+
if ((!replanned || grantExtraReplan) && replanTrigger && remainingUsd() > MONEY_MIN) {
|
|
2015
|
+
if (grantExtraReplan) varianceGrantUsed = true;
|
|
1317
2016
|
replanned = true;
|
|
1318
|
-
|
|
2017
|
+
replans += 1;
|
|
2018
|
+
emit('replan', {
|
|
2019
|
+
step: step.id, reason: replanTrigger, trigger: cat,
|
|
2020
|
+
// ADDED fields (append-only): which replan this is, and — only when the
|
|
2021
|
+
// arbiter granted one past the ordinary ceiling — the reading it granted it
|
|
2022
|
+
// on. Absent on the ordinary replan, so a reader can never mistake the
|
|
2023
|
+
// ceiling for a grant.
|
|
2024
|
+
//
|
|
2025
|
+
// The index is the RUN's, so on a resumed leg it opens above 1 — the ceiling
|
|
2026
|
+
// it is counted against spans the chain, and an index that restarted at 1 on
|
|
2027
|
+
// every leg would read as a fresh allowance on the one record that could have
|
|
2028
|
+
// shown it was not.
|
|
2029
|
+
replan: replans,
|
|
2030
|
+
...(grantExtraReplan ? { granted: stopTrend.trend } : {}),
|
|
2031
|
+
});
|
|
1319
2032
|
// The brief names the trigger HONESTLY, one branch per trigger: it is the
|
|
1320
2033
|
// only channel the redrafting planner adapts to, so a wrong diagnosis here
|
|
1321
2034
|
// is handed to the one component whose whole job is to respond to it. A
|
|
1322
2035
|
// stall in particular never judged its exits at all — the exhaustion
|
|
1323
2036
|
// sentence would invent a red the run never saw (F28's rule, replan side).
|
|
2037
|
+
// The exhaustion branch is the one the ladder rewrote. "It ran N attempts and
|
|
2038
|
+
// its exits were still red" described a bound that no longer exists AND said
|
|
2039
|
+
// nothing about the mechanism — the planner could not tell a step that was
|
|
2040
|
+
// converging from one repeating itself, which are opposite problems with
|
|
2041
|
+
// opposite fixes. The ladder's own books answer that, and the balance says what
|
|
2042
|
+
// the stop left on the table (the u-msd916dh reading: $1.30 and ~6min unspent
|
|
2043
|
+
// behind a step that was still shrinking its error count). Time is reported as
|
|
2044
|
+
// UNBOUNDED when the operator set no wall — never rendered as a number (F6
|
|
2045
|
+
// extends to time).
|
|
2046
|
+
const leftOver = `$${remainingUsd().toFixed(2)} and `
|
|
2047
|
+
+ `${clock.bounded ? `${Math.round(clock.remainingMs() / 60_000)} minute(s)` : 'time UNBOUNDED'} `
|
|
2048
|
+
+ 'of the run were still unspent when it stopped.';
|
|
2049
|
+
// F85: the meter's branch states the METER's fact and then the run's measured
|
|
2050
|
+
// one, in that order and as two separate claims — "it was stopped for eating
|
|
2051
|
+
// the run" and "here is what the run achieved" are different things, and the
|
|
2052
|
+
// old sentence quietly fused them into a verdict on the work.
|
|
1324
2053
|
const why = cat === 'step-variance'
|
|
1325
|
-
? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
|
|
2054
|
+
? 'It was stopped by the run\'s meter for consuming too large a share of what was left. '
|
|
2055
|
+
+ `Its work so far, from the close's own numbers: ${stopTrend.reading}. ${leftOver}`
|
|
1326
2056
|
: cat === 'step-stalled'
|
|
1327
2057
|
? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
|
|
1328
|
-
:
|
|
2058
|
+
: `${res.ladder.brief()} ${leftOver}`;
|
|
2059
|
+
// F86 — this step's OWN last exit results, verbatim (u-mshcpdg4). Everything
|
|
2060
|
+
// above this line is the run's narration of the stop: the meter's share
|
|
2061
|
+
// sentence, the trend's direction, the never-wrote fact, the escalation detail.
|
|
2062
|
+
// None of them says WHERE the remaining work is, because none of them is the
|
|
2063
|
+
// close. The close's output is in HERE — as one of the failing exits' details,
|
|
2064
|
+
// beside the evaluator's own (a step that wrote nothing leads with
|
|
2065
|
+
// `tree-changed`), which is why the block is labelled by its exits rather than
|
|
2066
|
+
// by the close. It goes over as text — see `closeGapBlock` for why a parsed
|
|
2067
|
+
// file list would reproduce the exact bug it is fixing. Empty gap → empty
|
|
2068
|
+
// string → the brief is byte-identical to the pre-F86 one.
|
|
2069
|
+
const gapBlock = closeGapBlock(res?.gap);
|
|
1329
2070
|
const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
|
|
1330
2071
|
+ `${why}\n`
|
|
1331
2072
|
+ `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
|
|
1332
|
-
+ `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}
|
|
2073
|
+
+ `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`
|
|
2074
|
+
+ gapBlock;
|
|
1333
2075
|
// The progress line IS the adaptation channel (addendum 2): the balance rides
|
|
1334
2076
|
// in via obtainPlan's live read, and this says where the run got to. Both are
|
|
1335
2077
|
// the planner re-allocating what remains across what is left — never a rate.
|
|
2078
|
+
//
|
|
2079
|
+
// F85 adds the second half. This line used to count STEPS only ("step 1 of 1
|
|
2080
|
+
// did not finish; 0 step(s) completed before it") — structurally true and
|
|
2081
|
+
// silent about outcome, so a replanner reading it could only conclude that
|
|
2082
|
+
// nothing had been achieved. On u-msh70zla it concluded exactly that and threw
|
|
2083
|
+
// three attempts of real convergence away. The structural sentence stays (a
|
|
2084
|
+
// plan's shape is what the planner re-allocates); the measured one joins it.
|
|
1336
2085
|
const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
|
|
1337
|
-
+ `${artifacts.length} step(s) completed before it
|
|
2086
|
+
+ `${artifacts.length} step(s) completed before it; `
|
|
2087
|
+
+ `close trend so far: ${stopTrend.reading}`;
|
|
1338
2088
|
let pv;
|
|
1339
2089
|
try {
|
|
1340
2090
|
pv = await obtainPlan('replan', failure, progress);
|
|
@@ -1354,6 +2104,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1354
2104
|
// it names steps the current plan does not contain)
|
|
1355
2105
|
artifacts.length = 0;
|
|
1356
2106
|
idx = 0;
|
|
2107
|
+
// and for exactly the same reason a RESUME's skip set dies here: an id the new
|
|
2108
|
+
// plan happens to re-use names a step this run never did, so inheriting the
|
|
2109
|
+
// dead run's green for it would skip work nobody has done
|
|
2110
|
+
skipCount = 0;
|
|
1357
2111
|
continue;
|
|
1358
2112
|
}
|
|
1359
2113
|
// F64 — the wall stopped this attempt from INSIDE a provider call (the derived
|
|
@@ -1378,10 +2132,18 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1378
2132
|
// stays a step-red (the stop is a result).
|
|
1379
2133
|
if (cat === 'cap-halt') {
|
|
1380
2134
|
planExecuted();
|
|
1381
|
-
|
|
2135
|
+
if (remainingUsd() > MONEY_MIN) return `step-red:${step.id}`;
|
|
2136
|
+
// v1.46 §2: the money cut is decision-ready HERE too, and for the same reason
|
|
2137
|
+
// W-2 gave the wall a record at every site it stops a run — a category that
|
|
2138
|
+
// hands the human a readout at one seam and silence at another is the readout
|
|
2139
|
+
// being a coincidence of where the stop happened.
|
|
2140
|
+
emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
2141
|
+
return 'cap-halt';
|
|
1382
2142
|
}
|
|
1383
|
-
// A
|
|
1384
|
-
//
|
|
2143
|
+
// A `step-variance` that earned no replan above is a STOP, and the stop is the
|
|
2144
|
+
// result. Reached in three ways now: the ordinary ceiling with the run reading
|
|
2145
|
+
// `flat` or `unknown`; the arbiter's one grant already spent (F85's bound); or
|
|
2146
|
+
// the wallet at dust. Each rides out as `step-red:<id>`, NOT as its own top-level
|
|
1385
2147
|
// outcome. The category is a replan TRIGGER, never a run verdict: leaking it
|
|
1386
2148
|
// upward would mint an outcome run.js and the ledger's class table do not know,
|
|
1387
2149
|
// and an unmapped category is counted as a library bug (ledger.js) when this is a
|
|
@@ -1423,6 +2185,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1423
2185
|
* without consulting the clock a second time (two clock reads for one event is
|
|
1424
2186
|
* how the two instruments come to disagree). */
|
|
1425
2187
|
let wallStop = null;
|
|
2188
|
+
/** how many fix ITERATIONS the loop actually bought — counted here rather than
|
|
2189
|
+
* read back off the trend reader, which lives inside the try and is out of scope
|
|
2190
|
+
* by the time a terminal needs the number. */
|
|
2191
|
+
let fixIterationsUsed = 0;
|
|
1426
2192
|
try {
|
|
1427
2193
|
// Layer R for the close-fix loop — the plan flow's single ralph loop judged
|
|
1428
2194
|
// by the REAL close (the plan flow's single ralph loop, and the
|
|
@@ -1436,16 +2202,85 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1436
2202
|
const fixRoot = layerRoot && !native
|
|
1437
2203
|
? createRoot({ redact: scrub, writesInformative: true })
|
|
1438
2204
|
: null;
|
|
1439
|
-
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
const
|
|
2205
|
+
// `attempts: null` is the STRIKE-LADDER shape (mkWorker's own contract): the fix
|
|
2206
|
+
// loop's iteration count no longer exists at construction time, so the gate's
|
|
2207
|
+
// turn allowance is granted per iteration at `setIteration` instead of being
|
|
2208
|
+
// pre-multiplied by a number nobody signed. The per-iteration bound is
|
|
2209
|
+
// unchanged — `loop.stop()` at `attemptRounds` — which is the belt that actually
|
|
2210
|
+
// holds. (v1.46 §4: `capRuns` retires as the governor.)
|
|
2211
|
+
const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: null, writable: true, root: fixRoot });
|
|
2212
|
+
// ── v1.46 §4: the fix loop's GOVERNOR — progress, not a count ──
|
|
2213
|
+
//
|
|
2214
|
+
// MEASURED reason, the same shape as the step ladder's. The fixed `capRuns`
|
|
2215
|
+
// could not tell converging work from thrash, so it stopped both at the same
|
|
2216
|
+
// place: u-msew1uy5 was cut mid-convergence at fix iteration 3 while
|
|
2217
|
+
// reuse-msc6w93z sat dead flat at 2 errors for SEVEN fix verdicts until the wall
|
|
2218
|
+
// killed it. The $0 replay over all 8 archived fix loops came back clean both
|
|
2219
|
+
// directions — 0 greens harmed (every historical green converted in <= 2
|
|
2220
|
+
// verdicts), 1 waste case caught.
|
|
2221
|
+
//
|
|
2222
|
+
// The reader is SEEDED with the grade the loop opened on, because that is what
|
|
2223
|
+
// the first fix attempt is measured against; without it the first attempt would
|
|
2224
|
+
// have nothing to be compared to and the loop would spend a free iteration
|
|
2225
|
+
// establishing what it already knew.
|
|
2226
|
+
//
|
|
2227
|
+
// `blindCap: capRuns` is not the count surviving in disguise. It binds ONLY
|
|
2228
|
+
// while the instrument has never been able to compare anything (a close whose
|
|
2229
|
+
// output carries no number at all), which is precisely the case where a strike
|
|
2230
|
+
// would be minted out of ignorance. The moment a stage reports a comparable
|
|
2231
|
+
// number the count is gone, and money and the wall are what remain.
|
|
2232
|
+
//
|
|
2233
|
+
// DELIBERATELY NOT SEEDED with `resumeGrades`, and this is the one place the
|
|
2234
|
+
// resume chain stops. The two readers answer two different questions (see
|
|
2235
|
+
// `runTrend` above, and src/trend.js's "ONE PER SERIES"): the halt readout asks
|
|
2236
|
+
// *was the RUN converging when its allowance cut it*, which spans both legs; this
|
|
2237
|
+
// governor asks *is THIS loop out of ideas*, which is leg-local by definition.
|
|
2238
|
+
// Seeding it was built and measured: a resumed loop that struck out dead flat
|
|
2239
|
+
// rendered its own terminal as "still progressing — verdict 2 → 1 → 1 → 1 → 1",
|
|
2240
|
+
// because the chain HAD fallen before the handover — a record contradicting
|
|
2241
|
+
// itself, offering a conditional top-up ("if the trend above says it was still
|
|
2242
|
+
// converging") on the exact run that had just proven it was not. It would also
|
|
2243
|
+
// have moved the STRIKE rule on a resume (an attempt judged against the best the
|
|
2244
|
+
// run ever reached rather than against the grade this loop opened on) — an
|
|
2245
|
+
// unmeasured governance change, which is arbiter-adjacent territory. Both are the
|
|
2246
|
+
// same error: one instance answering two questions.
|
|
2247
|
+
const fixTrend = createTrend({
|
|
2248
|
+
stageOrder: stagedClose.map((/** @type {any} */ s) => s.name),
|
|
2249
|
+
limit: FIX_STRIKE_LIMIT,
|
|
2250
|
+
blindCap: capRuns,
|
|
2251
|
+
});
|
|
2252
|
+
fixTrend.record({ gap: post.gap ?? '' });
|
|
2253
|
+
/** ralph's `ladder` seam, filled by the trend reader instead of the step
|
|
2254
|
+
* ladder's repeat/write pair. ONE exhaustion terminal, two triggers (ralph's own
|
|
2255
|
+
* rule): the category stays `cap-halt` and the outcome stays `escalated`, so the
|
|
2256
|
+
* ledger's class table, bridge grading and every downstream reader are untouched
|
|
2257
|
+
* — only the TRIGGER changed. What it overrides is the terminal's PROSE: the
|
|
2258
|
+
* step ladder's copy offers a replan, and there is no planner at the close. */
|
|
2259
|
+
const fixGovernor = {
|
|
2260
|
+
record: (/** @type {{iteration: number, gap?: string}} */ o) => {
|
|
2261
|
+
fixIterationsUsed = o.iteration;
|
|
2262
|
+
return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
|
|
2263
|
+
},
|
|
2264
|
+
struckOut: fixTrend.struckOut,
|
|
2265
|
+
report: fixTrend.report,
|
|
2266
|
+
brief: () => fixTrend.verdict().reading,
|
|
2267
|
+
terminal: () => {
|
|
2268
|
+
const rep = fixTrend.report();
|
|
2269
|
+
const t = fixTrend.verdict();
|
|
2270
|
+
return {
|
|
2271
|
+
decision: rep.blind
|
|
2272
|
+
? `${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?`
|
|
2273
|
+
: `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
|
|
2274
|
+
options: [
|
|
2275
|
+
'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
|
|
2276
|
+
'top up budgetUsd and rerun with --resume, if the trend above says it was still converging',
|
|
2277
|
+
'abandon the task',
|
|
2278
|
+
],
|
|
2279
|
+
};
|
|
2280
|
+
},
|
|
2281
|
+
};
|
|
1446
2282
|
/** @param {number} iteration @param {string} [gap] */
|
|
1447
2283
|
const middle = async (iteration, gap) => {
|
|
1448
|
-
if (gap !== undefined) gaps.push(gap);
|
|
1449
2284
|
// T/W-2 — the wall, read before a NEW fix attempt is started, and never
|
|
1450
2285
|
// around the close itself. hamr's ruling: *"when time is up, keep the grade
|
|
1451
2286
|
// we already have and stop … run tests (free) and when done if original time
|
|
@@ -1466,15 +2301,36 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1466
2301
|
// wall that kills grading leaves the run unreadable after the money is spent
|
|
1467
2302
|
// (the F45 class), so the one thing this must never do is stop a close.
|
|
1468
2303
|
if (clock.expired()) {
|
|
1469
|
-
|
|
2304
|
+
// ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
|
|
2305
|
+
// This site used to run its own `gapTrend`: byte equality of the last two
|
|
2306
|
+
// close gaps, reading `stalled`/`moving` beside the money halt's per-stage
|
|
2307
|
+
// `flat`/`converging` on the very same run. Two readers for one question is
|
|
2308
|
+
// how the two come to disagree, and the byte reader could only ever report
|
|
2309
|
+
// MOTION — that something changed, never which way. Folded into src/trend.js,
|
|
2310
|
+
// where a per-stage NUMBER decides whenever one exists and the byte
|
|
2311
|
+
// comparison survives as the reading inside `unknown` (never as a direction:
|
|
2312
|
+
// "the output changed" is not "it got better", which is F6 in a trend's coat).
|
|
2313
|
+
// `runTrend` is the run's own reader — the same instance the money halt reads
|
|
2314
|
+
// — so a resume's inherited baselines reach this readout too.
|
|
2315
|
+
const t = runTrend.verdict();
|
|
1470
2316
|
wallStop = {
|
|
1471
2317
|
cutMidCall: false,
|
|
1472
2318
|
phase: 'fix',
|
|
1473
2319
|
iterationsUsed: iteration - 1,
|
|
1474
|
-
capRuns
|
|
2320
|
+
// `capRuns` used to ride here as the DENOMINATOR ("0 of 3"). It retired as
|
|
2321
|
+
// this loop's governor (v1.46 §4), so quoting it as the total would state a
|
|
2322
|
+
// bound that no longer decides anything — the iterations spent are the fact,
|
|
2323
|
+
// and there is no longer a number they are "of".
|
|
1475
2324
|
verdict: lastCloseVerdict?.verdict,
|
|
1476
2325
|
...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
|
|
1477
2326
|
trend: t.trend,
|
|
2327
|
+
// MOTION rides as its OWN field rather than being mapped onto `trend`.
|
|
2328
|
+
// Mapping `unchanged` → `flat` would dress a byte comparison as a measured
|
|
2329
|
+
// direction on the spine, which is precisely what the unification refuses;
|
|
2330
|
+
// a separate field keeps the old signal's substance without the false
|
|
2331
|
+
// promotion. (Grepped first: no src/ or scripts/ reader consumed the old
|
|
2332
|
+
// `stalled`/`moving` values — only this file's own tests, updated with it.)
|
|
2333
|
+
motion: t.motion,
|
|
1478
2334
|
};
|
|
1479
2335
|
// The detail carries what the human needs to pick a lever and nothing else:
|
|
1480
2336
|
// the verdict that stands, how much of the loop was actually spent, and the
|
|
@@ -1484,7 +2340,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1484
2340
|
`the wall-clock cap passed before fix attempt ${iteration} could start. `
|
|
1485
2341
|
+ `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
|
|
1486
2342
|
+ `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
|
|
1487
|
-
+ `after ${iteration - 1}
|
|
2343
|
+
+ `after ${iteration - 1} fix iteration(s). `
|
|
1488
2344
|
+ `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
|
|
1489
2345
|
err.category = 'wall-halt';
|
|
1490
2346
|
throw err;
|
|
@@ -1513,7 +2369,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1513
2369
|
fixOutcome = await ralph({
|
|
1514
2370
|
// the staged close rides in as the JUDGE seam (one verdict, every stage in
|
|
1515
2371
|
// order) — ralph's single-command path stays exactly what it was
|
|
1516
|
-
|
|
2372
|
+
// v1.46 §4: the PROGRESS governor rides in where `capRuns` used to. ralph's
|
|
2373
|
+
// two exhaustion rules are alternatives and never both at once, so the count
|
|
2374
|
+
// is not passed — it survives only inside the governor, as the bound for the
|
|
2375
|
+
// case the trend instrument is blind.
|
|
2376
|
+
middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
|
|
1517
2377
|
closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
|
|
1518
2378
|
});
|
|
1519
2379
|
} catch (e) {
|
|
@@ -1544,7 +2404,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1544
2404
|
// Attempts spent with money still on the table stays the designed terminal
|
|
1545
2405
|
// ("close still red") and keeps riding out as `escalated` below.
|
|
1546
2406
|
// (MED-4, fixed with hamr's explicit go, 2026-07-30.)
|
|
1547
|
-
if (remainingUsd() <= MONEY_MIN)
|
|
2407
|
+
if (remainingUsd() <= MONEY_MIN) {
|
|
2408
|
+
// v1.46 §2 — and THIS is the site hamr's ask was about: the money runs out
|
|
2409
|
+
// deepest in the run, on the loop that holds a real grade. So the readout keeps
|
|
2410
|
+
// that grade, names the stage that rendered it, and carries the run's own
|
|
2411
|
+
// per-stage trend so the choice between "top up" and "revise the goal" is made
|
|
2412
|
+
// on evidence rather than on how the operator felt about the last gap.
|
|
2413
|
+
emitMoneyHalt({
|
|
2414
|
+
phase: 'fix',
|
|
2415
|
+
cutMidCall: false,
|
|
2416
|
+
iterationsUsed: fixIterationsUsed,
|
|
2417
|
+
});
|
|
2418
|
+
return 'cap-halt';
|
|
2419
|
+
}
|
|
1548
2420
|
} else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
|
|
1549
2421
|
// The step loop's category restoration (F11), mirrored: ralph returns the flat
|
|
1550
2422
|
// 'escalated' on a middle throw while its escalation carries the real name — a
|