bareloop 0.5.1 → 0.6.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/src/planrun.js CHANGED
@@ -14,20 +14,22 @@
14
14
  // (fs.deny on the gate audit / .smoke / .litectx, unchanged).
15
15
 
16
16
  import { createRequire } from 'node:module';
17
- import { readFileSync } from 'node:fs';
17
+ import { readFileSync, readdirSync } from 'node:fs';
18
18
  import { createHash } from 'node:crypto';
19
- import { join, resolve } from 'node:path';
20
- import { Gate, redact } from 'bareguard';
19
+ import { join, resolve, relative } from 'node:path';
20
+ import { Gate } from 'bareguard';
21
21
  import { LiteCtx } from 'litectx';
22
- import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
23
- import { validatePlan } from './plan.js';
24
- import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
22
+ import { runClose, runStages, ralph, CLOSE_FAULTS } from './ralph.js';
23
+ import { validatePlan, legalScopes, stageClose } from './plan.js';
24
+ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU, STEP_MODELS } from './plan.js';
25
25
  import { snapshotScope, evalExits } from './exits.js';
26
26
  import { createRoot } from './root.js';
27
- import { TOOL_MENU } from './job.js';
28
- import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, RETRIEVAL_STRATEGY, EDIT_STRATEGY } from './interpret.js';
29
- import { globToPrefix, SECRET_PATTERNS } from './validate.js';
27
+ import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
28
+ import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
29
+ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
30
+ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
30
31
  import { extractArtifact } from './text.js';
32
+ import { createClock, isWallTimeout } from './clock.js';
31
33
 
32
34
  const require = createRequire(import.meta.url);
33
35
  const { Loop, wireGate, HaltError } = require('bare-agent');
@@ -50,6 +52,11 @@ const SCOUT_BLOB_MAX = 8000;
50
52
  const NATIVE_READ_CAP = 24 * 1024;
51
53
  /** native-only strategy: tell the worker WHY whole-file reads fail here and to navigate by symbol */
52
54
  const NATIVE_READ_STRATEGY = '\nINTERFACE LIMIT: on this surface a whole-file read of a large file is TRUNCATED before you see it — a shell_read of a file over ~24KB returns only its start followed by a truncation notice. This is NOT the whole file. To read a function IN FULL, always use ctx_recall(<symbol>) then ctx_get(<pointer>) — that returns the entire function no matter how large the file is. To locate a line, use shell_grep(<pattern>). Never try to understand a file over ~400 lines by reading it whole; recall its symbols instead.';
55
+ /** F59 — below this a scout survey is treated as absent. Sized off the archive, not
56
+ * taste: the 15 truncated scouts produced 0/74/86 bytes; every real survey was
57
+ * 5991-8056. 200 sits between the two populations with an order of magnitude of
58
+ * daylight on each side, so it cannot be a tuned-to-one-case threshold. */
59
+ const SCOUT_MIN_BYTES = 200;
53
60
  /** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
54
61
  const ARTIFACT_MAX = 2000;
55
62
  /** the wallet floor below which a replan is a stop, not an adaptation (review
@@ -57,9 +64,101 @@ const ARTIFACT_MAX = 2000;
57
64
  * just burns another draft and mislabels the money-cut as "exits still red"
58
65
  * (F45 class) — the honest terminal there is cap-halt. */
59
66
  const MONEY_MIN = 0.001;
67
+ /**
68
+ * A (PRD v1.27; design record §3.4, answered §6.2) — the replan trigger. A step
69
+ * that has consumed this share of the run's REMAINING money or time with its exits
70
+ * still red gets no further attempt; the planner re-allocates what is left instead.
71
+ *
72
+ * 50% is hamr's number (*"50% is fine"*), and it was **re-confirmed with its own
73
+ * inertness on the table**: F63 replayed 18 archived spines / 54 steps / 101 judged
74
+ * attempts and found it would have fired 0 times (near misses 0.35 · 0.35 · 0.40 ·
75
+ * 0.45). Shown that, hamr's call was *"keep 0.5"* (2026-07-26).
76
+ *
77
+ * So this is a GUARD against a step that eats the run, deliberately set above the
78
+ * observed population rather than tuned to it — lowering it to 0.35 because that is
79
+ * where those four points sit would be fitting the number to the data (the standing
80
+ * no-fit-to-pass rule), and threshold-setting is arbiter territory either way. It
81
+ * has not hardened by surviving unexamined; it survived being examined.
82
+ *
83
+ * This changes the replan TRIGGER only. The hard ceiling of ONE replan is
84
+ * unchanged — unlimited replanning launders thrash as adaptation (v1.12).
85
+ */
86
+ const VARIANCE_THRESHOLD = 0.5;
60
87
 
61
88
  /** @typedef {Error & {category?: string, lib?: string}} CategorizedError */
62
89
 
90
+ /**
91
+ * The materials block: what the run has to spend, as a BALANCE and nothing else.
92
+ *
93
+ * hamr's correction (design record addendum 2, verbatim): *"why would you tell it
94
+ * every round that you have x time per round? what if there is a heavier round?
95
+ * why don't you tell it same like cost, you have x left from cost/time instead of
96
+ * making it race against time?"*
97
+ *
98
+ * So: NO rate, NO per-round allowance, NO derived round count. F57 measured a 150x
99
+ * spread on the verification gaps (3.8s to 561s), so a per-round constant
100
+ * describes almost no real round — a heavy round would break the planner's
101
+ * arithmetic with nothing to tell it. A balance is self-correcting: after a heavy
102
+ * round the number is simply lower. A rate is also a stopwatch, and racing a clock
103
+ * is the rush-or-fake incentive the v1.12 §5 prompt contract exists to remove.
104
+ *
105
+ * At DRAFT the balance is the total, so this is scale only — the planner is NOT
106
+ * asked to allocate time, because sizing time at draft requires the rate that was
107
+ * just removed. A plan that does not fit is what the meter is for (addendum 2).
108
+ * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [m]
109
+ */
110
+ function materialsBlock(m) {
111
+ if (!m) return '';
112
+ const lines = [];
113
+ if (typeof m.balanceUsd === 'number') lines.push(`- money left for the whole run: $${m.balanceUsd.toFixed(2)}`);
114
+ if (typeof m.remainingMs === 'number') lines.push(`- time left for the whole run: ${Math.round(m.remainingMs / 60_000)} minutes`);
115
+ if (!lines.length) return '';
116
+ // The progress line only exists at REPLAN: it is the variance the meter read,
117
+ // and it is the adaptation channel (addendum 2). At draft there is no progress.
118
+ if (m.progress) lines.push(`- where the run got to: ${m.progress}`);
119
+ return `\nWhat this run has left to spend — plan within it:\n${lines.join('\n')}\nThese are totals for the run, not per-step allowances. Nothing here is a rate: a step's cost depends on what it does.\n`;
120
+ }
121
+
122
+ /**
123
+ * W-2 — the progress TREND, read off the two most recent close gaps.
124
+ *
125
+ * A run stopped by its wall hands the human a lever choice, and the two levers
126
+ * point opposite ways: more TIME only helps work that was still moving, and a goal
127
+ * the run cannot reach does not become reachable by being given longer. The
128
+ * cheapest honest discriminator is already in the fix loop's hand — consecutive
129
+ * close output. A byte-identical gap means the last attempt moved nothing the
130
+ * arbiter can see; a different one means the tree was still changing under it.
131
+ *
132
+ * Deliberately NOT a new instrument (no red counting, no red-set diffing): two
133
+ * strings the loop already holds, compared for equality. `unknown` is the honest
134
+ * reading when only one grade exists — never rounded up to "stalled", which would
135
+ * recommend rewriting a goal on no evidence at all (F6's rule, applied to a trend
136
+ * instead of a number).
137
+ * @param {string[]} gaps close gaps, oldest first
138
+ * @returns {{trend: 'stalled'|'moving'|'unknown', reading: string, lever: string}}
139
+ */
140
+ function gapTrend(gaps) {
141
+ if (gaps.length < 2) {
142
+ return {
143
+ trend: 'unknown',
144
+ reading: 'only one close grade exists, so there is nothing to compare',
145
+ lever: 'read the last close output before choosing between more time and a different goal',
146
+ };
147
+ }
148
+ const [prev, last] = gaps.slice(-2);
149
+ return prev === last
150
+ ? {
151
+ trend: 'stalled',
152
+ reading: 'the last two close outputs are byte-identical — the previous attempt moved nothing the close can see',
153
+ lever: 'more time alone is unlikely to help; revise the goal/spec first',
154
+ }
155
+ : {
156
+ trend: 'moving',
157
+ reading: 'the last two close outputs differ — the tree was still changing under the close when time ran out',
158
+ lever: 'the work was progressing; raising maxWallMs is the lever that fits',
159
+ };
160
+ }
161
+
63
162
  /**
64
163
  * The plan-drafting prompt: a schema DESCRIPTION built from the live validator
65
164
  * menus — never a copyable example (the drafter must author, not echo; the
@@ -67,10 +166,27 @@ const MONEY_MIN = 0.001;
67
166
  * arbiter territory the planner never sees.
68
167
  * @param {any} job @param {string} scoutBlob @param {any[]|null} reds
69
168
  * @param {number} maxStepRounds @param {string|null} failure replan context
169
+ * @param {string[]|undefined} scopes the offered `tree-changed` menu (`legalScopes`).
170
+ * Choose-don't-describe (§4): the agent picks a value, never authors a glob and
171
+ * guesses its shape. Omitted, it derives from the signed fence — the SAME
172
+ * fail-closed derivation `validatePlan` uses, so prompt and validator can never
173
+ * disagree about what was offered.
174
+ * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
175
+ * what the run has LEFT (T/A). Omitted → no materials block at all, which is the
176
+ * pre-T behaviour and keeps every existing caller byte-identical.
177
+ * @param {number} [capRuns] the shell's attempt cap — the ceiling `attempts` may only
178
+ * TIGHTEN. Interpolated the way every sibling bound is: `validatePlan` reds outside
179
+ * `1..capRuns`, and a prompt that says only "integer" spends a round teaching it.
180
+ * Defaults to validatePlan's own default so existing callers are unchanged.
70
181
  */
71
- function planPrompt(job, scoutBlob, reds, maxStepRounds, failure) {
182
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, capRuns = 3) {
183
+ const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
72
184
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
73
- const checkNames = (job.checks ?? []).map((/** @type {any} */ c) => c.name);
185
+ // W4: the menu comes off the STAGED close — the same one derivation `runPlan`
186
+ // executes and `validatePlan` accepts against. Reading `job.close` raw here left
187
+ // the legacy object form offering nothing while the runner preflighted a stage.
188
+ // (`?? []`: a close naming no command stages nothing, so it offers nothing)
189
+ const checkNames = checkMenu(stageClose(job.close) ?? []).map((m) => m.name);
74
190
  const doc = `DRAFT-PLAN
75
191
  You are planning how to accomplish a goal in a repository, as an ordered list of bounded
76
192
  steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
@@ -81,21 +197,40 @@ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] }
81
197
  strictly in array order. Each step (no other fields exist):
82
198
  - "id": kebab-case slug, unique
83
199
  - "action": the step's task, precise enough for a worker that sees ONLY this step
84
- - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit are the write-class verbs)
200
+ - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)
85
201
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
86
202
  - "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
+ - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
206
+ A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
207
+ inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
208
+ CONTAINS it is fine) — otherwise every write the step could make is denied, or the
209
+ exit can never pass, on every attempt.
87
210
  - "exit": 1..${MAX_EXITS_PER_STEP} form checks that ALL must pass (AND), each one of:
88
211
  {"type":"artifact-written","path":"...","pattern":"optional regex"}
89
- {"type":"tree-changed","scope":"a scope inside ${JSON.stringify(job.writeScope)}"}
212
+ {"type":"tree-changed","scope":"<copy one value from the offered scopes below>"}
90
213
  {"type":"json-valid","path":"..."}
91
- {"type":"check-passes","name":"one of ${JSON.stringify(checkNames)}"}
214
+ {"type":"check-passes","name":"<copy one name from the check menu below>"}
215
+ Check menu — a check-passes "name" is exactly one of ${JSON.stringify(checkNames)}; copy
216
+ it character for character. No other name exists, and you cannot add one.
92
217
  A check-passes on a write-granted step MUST be paired with a tree-changed exit
93
218
  (the repository starts green — a lone check would pass on the untouched tree).
219
+ A check-passes may ONLY appear on a step whose tools include write or edit: a
220
+ failing check is reported back to THAT step's worker for another attempt, so
221
+ the step must be able to act on the report. Do not plan a separate read-only
222
+ "verify" step — attach the check to the step that does the fixing; the final
223
+ verification after your plan is not yours to author.
94
224
  Reference checks by NAME only; you cannot author or modify one.
95
225
 
226
+ Offered "scope" values for tree-changed — copy ONE of these exactly, character for
227
+ character. No other value is accepted, and patterns of your own (like "src/*.js")
228
+ are not accepted:
229
+ ${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
230
+
96
231
  Goal:
97
232
  ${job.goal}
98
-
233
+ ${materialsBlock(materials)}
99
234
  Repository survey (from a read-only scout):
100
235
  ${scoutBlob || '(no scout notes)'}`;
101
236
  let p = doc;
@@ -105,7 +240,7 @@ ${scoutBlob || '(no scout notes)'}`;
105
240
  }
106
241
 
107
242
  /**
108
- * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close/checks[]).
243
+ * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close).
109
244
  * Called by runJob after the approval gate, validation, and the smoke — this
110
245
  * function owns the plan flow only; the caller owns the job-end record and the
111
246
  * one ledger (every provider round is emitted here as `worker-round`, which
@@ -126,9 +261,15 @@ ${scoutBlob || '(no scout notes)'}`;
126
261
  * @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
127
262
  * @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
128
263
  * plan flow bails IN-FLIGHT on the first unpriced round instead of burning the whole plan
264
+ * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
265
+ * (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
266
+ * interpreter-red STOP — never silently run the default tier as if the choice was honoured.
129
267
  * @param {number} [opts.capRuns] shell-owned per-step attempt cap
130
268
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
131
269
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
270
+ * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
271
+ * reserved — a scout that spends every round on tools gets one toolless round to write its
272
+ * survey, because the bound halts it mid-tool-use and text is its only deliverable)
132
273
  * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
133
274
  * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
134
275
  * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
@@ -138,13 +279,34 @@ ${scoutBlob || '(no scout notes)'}`;
138
279
  * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
139
280
  * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
140
281
  * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
282
+ * @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
283
+ * default; a caller supplies this to drive time deterministically (the same seam `createClock`
284
+ * already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
285
+ * whose floor is one close timeout).
141
286
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
142
287
  * 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
143
- * 'provider-red' | 'interpreter-red' | `step-red:<id>`
288
+ * 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' | `step-red:<id>`
144
289
  */
145
- export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false }) {
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 }) {
146
291
  workdir = resolve(workdir);
147
- const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
292
+ // ONE spelling of the redaction, housed next to the inventory it reads
293
+ // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
294
+ // with. Two hand-spelled copies of "what a secret looks like" is exactly how
295
+ // detection and redaction drift apart.
296
+ const scrub = redactSecrets;
297
+ // The ctx verbs' spine channel, scrubbed at the WIRING. Every field a ctx-tool
298
+ // event carries is MODEL-CHOSEN text — a recall query, a stash id, a symbol, a
299
+ // path the worker spelled — and the spine is append-only: a log that captures a
300
+ // key captures it forever. Scrubbing the emitter (once, at construction) rather
301
+ // than the seventeen call sites is what makes the leak inexpressible: a new ctx
302
+ // verb cannot forget it. Same ONE inventory (SECRET_PATTERNS) the close output
303
+ // is scrubbed with — never a second spelling of what a secret looks like.
304
+ // Shallow by shape: the events are flat records of strings, string arrays
305
+ // (`paths`) and numbers, and numbers pass through untouched.
306
+ const emitCtx = (/** @type {string} */ type, /** @type {any} */ data) => emit(type, Object.fromEntries(
307
+ Object.entries(data ?? {}).map(([k, v]) => [k, typeof v === 'string' ? scrub(v)
308
+ : (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? scrub(x) : x)) : v)]),
309
+ ));
148
310
 
149
311
  // Which worker surface? `clipipe-subscription` drives tools NATIVELY (the CLI
150
312
  // owns the turn cycle, BA-16); every other provider runs the Loop. The close,
@@ -158,10 +320,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
158
320
  // hard-class), and a gold close carries no `cmd`; running `close.cmd.trim()`
159
321
  // on it would TypeError out of runJob with NO job-end (the spine would dangle,
160
322
  // no spend recorded). Refuse a non-predicate close cleanly, before any tokens.
161
- if (job.close.type !== 'predicate') {
323
+ // A STAGED close (PRD v1.28) is an ordered list of command stages and is the
324
+ // go-forward shape; the object form survives only for the declared-but-locked
325
+ // verdict classes (gold/rubric/hitl), which name no command to run.
326
+ // W4: the staging itself lives in ONE place (`stageClose`), shared with
327
+ // `planPrompt` and `validatePlan` — a second copy here is exactly how the
328
+ // runner came to execute a stage neither of the other two could see.
329
+ const stagedClose = stageClose(job.close);
330
+ if (!stagedClose) {
162
331
  emit('escalation', {
163
332
  category: 'close-unsupported', decisionReady: true,
164
- decision: `The job's close is a ${job.close.type} close — the plan flow executes a predicate close only (a command whose exit code is truth).`,
333
+ decision: `The job's close is a ${job.close.type} close — the plan flow executes commands whose exit codes are truth (a staged close, or a single predicate).`,
165
334
  options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
166
335
  });
167
336
  return 'close-unsupported';
@@ -181,12 +350,100 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
181
350
  return 'interpreter-red';
182
351
  }
183
352
 
184
- const closeArgv = job.close.cmd.trim().split(/\s+/);
185
- const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep };
353
+ // T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
354
+ // precheck. The precheck is real wall time and F62 measured close/check gaps as
355
+ // high as 328s on an archived run: a clock created after it would under-report
356
+ // elapsed by that much and hand the planner a balance the run does not have. Time
357
+ // the operator's own instruments too, or the budget is measured against a
358
+ // different run than the one that happened.
359
+ //
360
+ // `job.maxWallMs` is operator input with budgetUsd's status and has NO DEFAULT
361
+ // (hamr's ruling): absent means honestly time-unbounded, and the record says so
362
+ // rather than leaving a reader to infer it. Both numbers are reported — addendum 1
363
+ // measured that a deadline is only readable BETWEEN rounds, so enforcement is
364
+ // requested + the close's own worst case and quoting one number would be F6 in a
365
+ // time coat.
366
+ //
367
+ // W5 — that worst case is per STAGE. `runStages` hands each stage the full
368
+ // `closeTimeoutMs`, so a run whose deadline trips just before a 4-stage close can
369
+ // overshoot by four of them, and the clock is told the count rather than assuming
370
+ // one. `stagedClose` is the SAME staging the runner executes (never a second
371
+ // count), and it is non-null here by the close-unsupported guard above — a close
372
+ // that names no command escalated before this line, so there is no null to thread.
373
+ const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, ...(now ? { now } : {}) });
374
+ const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
375
+ emit('wall-clock', {
376
+ ...clock.report(closeTimeoutForReport),
377
+ meaning: clock.bounded
378
+ ? 'a between-round deadline; a close already in flight when it trips runs to completion'
379
+ : 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
380
+ });
381
+
382
+ /**
383
+ * F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
384
+ * drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
385
+ * bare-agent's `TimeoutError` (`callTimeoutMs()` is derived from the wall) is a
386
+ * GOVERNANCE stop and rides out as `wall-halt` — filing it `provider-red` would
387
+ * discard the row as a casualty (never evidence, F45/F48) and hand the human a
388
+ * transport-debugging option list for a run that simply ran out of time. A
389
+ * thrower that named its own category keeps it (the standing typed-attribution
390
+ * rule); only an UNNAMED throw is classified here.
391
+ * @param {any} e
392
+ * @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
393
+ * plus the category as a plain string (so a caller reading it never has to
394
+ * re-widen an optional field back to a value)
395
+ */
396
+ const categorize = (e) => {
397
+ const err = /** @type {CategorizedError} */ (e);
398
+ const category = e instanceof HaltError ? 'cap-halt'
399
+ : (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
400
+ err.category = category;
401
+ return { err, category };
402
+ };
403
+
404
+ /** the run-level TIME record, one shape wherever the wall stops the run.
405
+ * `cutMidCall` splits the two readings: `true` = the deadline landed INSIDE a
406
+ * provider call (F64, seen as its timeout), `false` = read between steps, the
407
+ * ordinary path. Both are the same stop; conflating them would hide which
408
+ * instrument saw it. */
409
+ const emitWallHalt = (/** @type {object} */ extra) => emit('wall-halt', {
410
+ ...clock.report(closeTimeoutForReport),
411
+ meaning: 'not under cap — not "can\'t"',
412
+ cutMidCall: false,
413
+ ...extra,
414
+ });
415
+ /** the wall's decision-ready levers, BYTE-IDENTICAL to ralph's own `wall-halt`
416
+ * entry (ralph.js DECISIONS). One category must hand a human ONE option list: which
417
+ * site read the clock is an implementation detail, and a shorter list here quietly
418
+ * withheld the second lever (revise the goal) from every stop this file escalates.
419
+ * Both levers are spec edits — a changed spec hash the runner refuses until it is
420
+ * re-approved — which is why each carries its own re-approval note, and why the
421
+ * caller's detail carries the progress trend that says which lever fits. */
422
+ const WALL_OPTIONS = [
423
+ 'raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
424
+ 'revise the goal/spec so the work fits the time (same re-approval)',
425
+ 'abandon the task',
426
+ ];
427
+
428
+ const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
429
+ /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
430
+ let closeStage;
431
+ /** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
432
+ * the arbiter has already rendered rather than re-deriving one: past the deadline
433
+ * the run's answer is whatever the close last said, and nothing after the deadline
434
+ * is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
435
+ let lastCloseVerdict;
436
+ /** the close, as ONE verdict: every stage in order, first red wins */
437
+ const judgeClose = async () => {
438
+ const v = await runStages(stagedClose, scrub, closeOpts);
439
+ closeStage = v.stage;
440
+ lastCloseVerdict = v;
441
+ return v;
442
+ };
186
443
 
187
444
  // ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
188
445
  // record, zero tokens; a forbidden-zone verdict escalates before any spend
189
- const pre = runClose(closeArgv, scrub, closeOpts);
446
+ const pre = await judgeClose();
190
447
  emit('close-precheck', { ...pre });
191
448
  if (pre.verdict === 'satisfied') return 'already-green';
192
449
  const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
@@ -195,16 +452,25 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
195
452
  return 'close-red';
196
453
  }
197
454
 
198
- // ── 0b. checks preflight ($0, deterministic): every SIGNED check runs once
199
- // before tokens — an unrunnable check would fault mid-plan after real spend
200
- // (a frozen rule without a wired detector is prose; here the detector runs
201
- // first). Red and green are both fine: checks decide nothing.
202
- for (const c of job.checks ?? []) {
203
- const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
204
- emit('check-preflight', { name: c.name, verdict: v.verdict });
455
+ // ── 0b. check preflight ($0, deterministic): every OFFERABLE stage runs once
456
+ // before tokens — an unrunnable ruler would fault mid-plan after real spend (a
457
+ // frozen rule without a wired detector is prose; here the detector runs
458
+ // first). Red and green are both fine: checks decide nothing and mint nothing.
459
+ // The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
460
+ // ruler and the real inspection cannot drift apart.
461
+ const menu = checkMenu(stagedClose);
462
+ emit('check-menu', {
463
+ offered: menu.map((m) => m.name),
464
+ ...(menu.length < stagedClose.length
465
+ ? { hidden: stagedClose.filter((s2) => s2.offer === false).map((s2) => s2.name), meaning: 'a stage that cannot stand alone as a ruler (a precondition) is not offered — a partial menu is acceptable, never a failure' }
466
+ : {}),
467
+ });
468
+ for (const m of menu) {
469
+ const v = await runStages(m.run, scrub, closeOpts);
470
+ emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
205
471
  const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
206
472
  if (f) {
207
- emit('escalation', { category: f.category, decisionReady: true, decision: `Signed check "${c.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${c.name}: ${v.detail ?? ''}` });
473
+ emit('escalation', { category: f.category, decisionReady: true, decision: `Close stage "${m.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${m.name}: ${v.detail ?? ''}` });
208
474
  return 'check-red';
209
475
  }
210
476
  }
@@ -213,16 +479,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
213
479
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
214
480
  const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
215
481
  const auditPath = join(workdir, 'gate-audit.jsonl');
216
- const checksByName = new Map((job.checks ?? []).map((/** @type {any} */ c) => [c.name, c]));
482
+ const chainByName = new Map(menu.map((m) => [m.name, m.run]));
483
+ // Choose-don't-describe (§4): the offered `tree-changed` scopes, enumerated from
484
+ // the signed fence plus the directories that actually exist beneath it. ONE menu
485
+ // object feeds both the drafting prompt and the validator, so what was offered
486
+ // and what is accepted can never drift apart. Discovery is best-effort — a
487
+ // missing or unreadable fence directory degrades to the signed entries alone
488
+ // (which `snapshotScope` already handles as an empty snapshot), never a throw.
489
+ const discoveredDirs = job.writeScope.flatMap((/** @type {string} */ g) => {
490
+ const prefix = globToPrefix(g);
491
+ try {
492
+ return readdirSync(join(workdir, prefix), { recursive: true, withFileTypes: true })
493
+ .filter((d) => d.isDirectory())
494
+ .map((d) => `${prefix}/${relative(join(workdir, prefix), join(d.parentPath ?? d.path, d.name))}`);
495
+ } catch { return []; }
496
+ });
497
+ const scopeMenu = legalScopes(job.writeScope, discoveredDirs);
498
+ // No silent caps: MAX_SCOPE_MENU can drop deep directories, and a menu that
499
+ // reads as complete when it is not is the blind-instrument class. Report BOTH
500
+ // counts so a truncation is visible in the record rather than inferred.
501
+ const offerable = legalScopes(job.writeScope, discoveredDirs, Infinity).length;
502
+ emit('scope-menu', {
503
+ offered: scopeMenu,
504
+ ...(offerable > scopeMenu.length
505
+ ? { truncated: true, offerableCount: offerable, cap: MAX_SCOPE_MENU, meaning: 'deep directories were dropped from the menu; they remain reachable via a parent scope' }
506
+ : { truncated: false }),
507
+ });
217
508
 
218
509
  /** the check-passes seam evalExits delegates to: the FULL runClose machinery
219
510
  * per signed check; a forbidden-zone verdict rides out as `fault` by name so
220
511
  * the micro-loop escalates (or F32-routes a crash) instead of faking a gap */
221
512
  const runCheck = async (/** @type {string} */ name) => {
222
- const c = checksByName.get(name);
223
- if (!c) return { pass: false, fault: 'failed', gap: `no signed check named "${name}"` };
224
- const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
225
- emit('check-run', { name, verdict: v.verdict, ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
513
+ const chain = chainByName.get(name);
514
+ // UNREACHABLE through runPlan and deliberately kept: validatePlan reds
515
+ // `check-unknown` against `checkMenu(spec.close)` — the SAME derivation that
516
+ // built this map from the SAME close — so a name that got here was already
517
+ // proven to be on the menu. Defence in depth against a future second
518
+ // derivation, not a live path; the reachable guard is the validator's, and
519
+ // that one is mutation-covered.
520
+ if (!chain) return { pass: false, fault: 'failed', gap: `no offered close stage named "${name}"` };
521
+ const v = await runStages(chain, scrub, closeOpts);
522
+ emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
226
523
  if (v.verdict === 'satisfied') return { pass: true };
227
524
  if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
228
525
  return { pass: false, fault: v.verdict, gap: v.detail };
@@ -239,22 +536,35 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
239
536
  * denied, the wallet as its budget), granted tools only (the menu IS the
240
537
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
241
538
  * with a phase label (F12).
242
- * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null}} o
539
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
243
540
  * `root` (Layer R): when present, the worker's write-class actions are teed
244
541
  * for the within-run ratchet — staged before the gate decides, discarded on
245
542
  * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
246
543
  * Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
247
544
  */
248
- async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null }) {
545
+ async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
249
546
  const gate = new Gate({
250
547
  fs: {
251
- writeScope: writable ? fencePrefixes : [],
548
+ // P: a step's `scope` narrows the fence — the per-step prefixes are always a
549
+ // SUBSET of the signed fence (validator: scope ∈ the same menu tree-changed
550
+ // uses), so this can only tighten, never widen
551
+ writeScope: writable ? (fence ?? fencePrefixes) : [],
252
552
  readScope: [workdir],
253
553
  deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
254
554
  },
255
555
  budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
256
556
  limits: { maxTurns: attemptRounds * (attempts + 1) },
257
557
  audit: { path: auditPath },
558
+ // The gate audit is the THIRD persistent, append-only channel (beside the
559
+ // spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
560
+ // SAME ONE inventory. bareguard's default-on backstop covers only
561
+ // apiKey/authorization/`Bearer …`/`sk-…` — measured: a `ghp_` token landed
562
+ // in cleartext through every model-authored identifier the action carries
563
+ // (an isolate verb's `id`, a path the worker spelled), and masked under
564
+ // these patterns. Content is already unreachable here (`toolAction` reduces
565
+ // a write to `{bytes}`), but the identifiers are raw model text, and a log
566
+ // that captures a key captures it forever.
567
+ secrets: { patterns: SECRET_PATTERNS },
258
568
  humanChannel: async () => ({ decision: 'terminate' }),
259
569
  });
260
570
  await gate.init();
@@ -362,11 +672,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
362
672
  }
363
673
  }
364
674
  const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
365
- ? createCtxTools(lc, workdir, emit).filter((t) => grantedNames.has(t.name))
675
+ ? createCtxTools(lc, workdir, emitCtx).filter((t) => grantedNames.has(t.name))
366
676
  : [];
367
- if (ctx.length) await lc.index();
677
+ // LC-3 (litectx 0.31.0): cooperative yield — index() is async but its work is sync CPU,
678
+ // and the default pass holds THIS event loop (the fuse's timers, the wall clock, the lag
679
+ // sampler all live here) for its full duration: measured 4.4s at 4.5% liveness on a
680
+ // 155-file force pass. With yield:true the worst single block measured 189–252ms (n=4),
681
+ // below anything a second/minute-scale timer can perceive. Store output is byte-identical.
682
+ if (ctx.length) await lc.index({ yield: true });
368
683
  const toolDefs = [...shell, ...ctx];
369
- const system = PERSONA_TOOLS + (granted.includes('edit') ? EDIT_STRATEGY : '') + (ctx.length ? RETRIEVAL_STRATEGY : '')
684
+ // F19 per VERB (P, corrected 2026-07-31): a strategy SENTENCE rides only with
685
+ // the verb it names. Gating per COMPONENT was close but over-reached — a
686
+ // worker granted only `impact` must not be steered to tools it lacks, and the
687
+ // isolate/retrieval paragraphs named other components' verbs outright. The
688
+ // assembly lives in tools.js beside the prose it composes, and is asserted
689
+ // byte-identical to the full-component paragraphs on a full grant.
690
+ const system = PERSONA_TOOLS + strategyFor(granted)
370
691
  + (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
371
692
  /** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
372
693
  const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
@@ -400,14 +721,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
400
721
  const provider2 = /** @type {any} */ (nativeProvider)({ policy, onTurn: nativeMetered, maxTurns: attemptRounds, hasTools: true });
401
722
  const loop = new Loop({ provider: provider2, system }); // no Loop policy / no cacheMessages: the CLI owns the transcript
402
723
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
724
+ /** F59: unavailable on native — a toolless native session fires no onTurn and
725
+ * reports NO cost (F48/BA-16 live-verified), so a recovery round there would be
726
+ * unmetered spend. The caller treats a null return as "no recovery possible". */
727
+ const askFrom = async () => null;
403
728
  const ask = async (prompt, defs = toolDefs) => {
404
729
  let r;
405
730
  try {
406
731
  r = await loop.run([{ role: 'user', content: prompt }], defs, { maxTokens: 32000 });
407
732
  } catch (e) {
408
- const err = /** @type {CategorizedError} */ (e);
409
- err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
410
- throw err;
733
+ throw categorize(e).err;
411
734
  }
412
735
  // a maxTurns session is a BOUNDED attempt, not an escalation — the same
413
736
  // role loop.stop() plays on the Loop path: judge the partial work and
@@ -429,7 +752,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
429
752
  }
430
753
  return r;
431
754
  };
432
- return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
755
+ return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
433
756
  }
434
757
 
435
758
  // ── LOOP path: the injected provider (anthropic-api and every other
@@ -441,35 +764,123 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
441
764
  // never invisible (F6/F44). The gate policy is wired but idle (no tools).
442
765
  const loopProvider = native
443
766
  ? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
444
- : provider;
445
- /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
446
- const metered = async (arg) => {
447
- emit('worker-round', {
448
- phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
449
- costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
450
- tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
451
- usage: usageOf(arg?.usage),
452
- });
453
- if ((arg?.kind ?? 'turn') === 'turn') {
454
- roundsThisAttempt += 1;
455
- if (roundsThisAttempt >= attemptRounds) {
456
- attemptBounded = roundIteration;
457
- emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
458
- loop.stop();
767
+ : (workerProvider ?? provider);
768
+ // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
769
+ // not call duration (provider-http.js `req.setTimeout` resets on every byte),
770
+ // so a connection that stays alive while producing nothing is invisible to it:
771
+ // U run ms3197n8 hung 274 minutes inside ONE call and died ECONNRESET with the
772
+ // bound correctly passed. The heartbeat here is a completed ROUND, which is the
773
+ // one signal that cannot be faked by a live socket. One watch PER WORKER, so
774
+ // the ceiling counts stalls across the step rather than resetting each call.
775
+ // L4 — the watch is handed the run's WALL. Self-heal is what a run does with
776
+ // time left: past the deadline a reissue is spend the run already declined to
777
+ // authorize, and this is the one path no other clock read covers (a stall
778
+ // completes no round and returns from no step, which are the only two places
779
+ // `clock.expired()` is otherwise consulted). Same clock object as every other
780
+ // reader — never a second time source.
781
+ const stallWatch = createStallWatch({
782
+ onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
783
+ expired: () => clock.expired(),
784
+ });
785
+ /**
786
+ * ONE Loop PER ISSUED CALL, and every callback on it scoped to that call's
787
+ * generation. After a stall there are TWO calls alive: the abandoned one, whose
788
+ * socket keeps streaming rounds (ms3197n8's lived 274min past the point we moved
789
+ * on), and the reissue. Sharing one Loop between them shares `_stopped`, so a
790
+ * corpse's round bound would cut the LIVE call mid-flight; sharing one callback
791
+ * left the corpse beating the watch that was timing its replacement, which meant
792
+ * the replacement could hang forever without tripping — the fuse feeding itself
793
+ * from the thing it abandoned. A Loop is a plain object over the shared provider,
794
+ * holds no cross-run state (`run()` takes the transcript per call), so one per
795
+ * issue costs nothing.
796
+ *
797
+ * RESIDUAL, documented not fixed: both generations still execute tools through
798
+ * the SAME Gate (`policy`) and the same audit — deliberately, because the audit
799
+ * is what `workerWrites` reads and a per-generation gate would split the run's
800
+ * own books. Concurrent tool calls across a stall therefore interleave on one
801
+ * fence. The fence itself is decision-per-call and order-independent, so the
802
+ * denial semantics hold; what is not modelled is two calls writing the same path
803
+ * in the same window. Latent today (Layer R off, and a stall is rare).
804
+ * @param {number} gen the generation `stallWatch` handed this call
805
+ */
806
+ const newLoop = (gen) => {
807
+ /** @type {any} */
808
+ let self = null;
809
+ /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
810
+ const metered = async (arg) => {
811
+ // The meter fires for EVERY generation, live or abandoned. An orphaned round
812
+ // was really billed, and spend that no instrument sees is the one thing this
813
+ // repo never ships (F6/F44 — unknown is reported as unknown, never as zero;
814
+ // stall.js says it plainly: a reissue can pay twice, and the wallet is what
815
+ // caps that).
816
+ emit('worker-round', {
817
+ phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
818
+ costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
819
+ tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
820
+ usage: usageOf(arg?.usage),
821
+ });
822
+ // Everything BELOW here belongs to the live call only. A superseded call must
823
+ // not beat the watch (that is the whole watchdog), must not spend the step's
824
+ // round bound (stall.js: "the step's round bound is untouched" by a reissue —
825
+ // the wallet is what bounds a reissue's spend), and must not announce a bound
826
+ // it cannot own.
827
+ if (!stallWatch.isCurrent(gen)) return onLlmResult(arg);
828
+ // F66 — the heartbeat is ANY result coming back from the model layer, not
829
+ // only a `turn`. The question the watchdog asks is "is the model still
830
+ // answering", and a non-turn result answers it just as well; gating the beat
831
+ // on `kind` would arm a false stall on any round shape we don't enumerate
832
+ // here, which is the blind-instrument class in miniature.
833
+ stallWatch.beat(gen);
834
+ if ((arg?.kind ?? 'turn') === 'turn') {
835
+ roundsThisAttempt += 1;
836
+
837
+ // The wall deadline rides the SAME seam as the round bound, because it is
838
+ // the only seam that exists: loop.stop() is read at the round boundary and
839
+ // cannot cut an in-flight call (F61/C1, measured — fired at 500ms, returned
840
+ // at 4,018ms). Stopping here judges the partial work and feeds the gap
841
+ // forward exactly as a round-bounded attempt does — this seam never decides a
842
+ // terminal. Three sites do, each reading the clock with nothing in flight: the
843
+ // step loop after a step returns (between steps), the head of a step's attempt
844
+ // (a step that would BEGIN past the deadline is never funded), and the head of
845
+ // a close-fix iteration (W-2 — the run stops on the verdict already minted).
846
+ if (clock.expired()) {
847
+ attemptBounded = roundIteration;
848
+ emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
849
+ self.stop();
850
+ } else if (roundsThisAttempt >= attemptRounds) {
851
+ attemptBounded = roundIteration;
852
+ emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
853
+ self.stop();
854
+ }
459
855
  }
460
- }
461
- return onLlmResult(arg);
856
+ return onLlmResult(arg);
857
+ };
858
+ self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
859
+ return self;
462
860
  };
463
- const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
464
861
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
862
+ /**
863
+ * F59 — continue an EXISTING conversation for exactly one TOOLLESS round.
864
+ * `defs` is `[]`, so text is the only output the model can produce: the summary
865
+ * round is guaranteed by CONSTRUCTION, never requested in prose (F19/F37 — a
866
+ * pacing mandate in the persona was violated 6/6). Meters like any other round
867
+ * (F12); the bound has already fired, so this is a deliberate +1 paid once.
868
+ * @param {any[]} msgs the prior conversation (loop.run's `msgs`)
869
+ * @param {string} prompt
870
+ */
871
+ const askFrom = async (msgs, prompt) => {
872
+ try {
873
+ return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
874
+ } catch (e) {
875
+ throw categorize(e).err;
876
+ }
877
+ };
465
878
  const ask = async (prompt, defs = toolDefs) => {
466
879
  let r;
467
880
  try {
468
- r = await loop.run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000 });
881
+ r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
469
882
  } catch (e) {
470
- const err = /** @type {CategorizedError} */ (e);
471
- err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
472
- throw err;
883
+ throw categorize(e).err;
473
884
  }
474
885
  // same error-return taxonomy as interpret's ask (one map, same doctrine):
475
886
  // halt → cap-halt, denial streak → gate-red, API truncation → provider-red
@@ -484,29 +895,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
484
895
  }
485
896
  return r;
486
897
  };
487
- return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
898
+ return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
488
899
  }
489
900
 
490
- /** relay a throw from OUTSIDE ralph (scout/plan drafting) as its honest category */
901
+ /** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
902
+ * category. Three terminals, never collapsed: the budget gate, the wall clock
903
+ * (F64 — the scout and the drafter run under the same derived call timeout the
904
+ * worker does, so this path needs the same split or it is the blinder route),
905
+ * and transport. */
491
906
  const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
492
- const category = e instanceof HaltError ? 'cap-halt' : (typeof e?.category === 'string' ? e.category : 'provider-red');
493
- if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(e?.message ?? e) });
907
+ const { category } = categorize(e);
908
+ const detail = String(e?.message ?? e);
909
+ if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
910
+ if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
911
+ const DECIDE = {
912
+ 'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, ['raise the job budget and rerun', 'abandon the run']],
913
+ 'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
914
+ // A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
915
+ // surfaces — but it is named, never laundered into provider-red. The socket
916
+ // did not fail here; the model stopped answering and reissuing did not help,
917
+ // and those are different diagnoses with different remedies (F45/F48: a
918
+ // casualty is never evidence, and a governance stop is never a casualty).
919
+ 'step-stalled': [`The model stopped producing rounds during ${phase} and reissuing the call did not recover it.`, ['retry the run', 'check provider status', 'abandon the run']],
920
+ };
921
+ const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
922
+ ?? [`The ${phase} call failed (${category}) — no result exists.`, ['retry the run', 'fix the provider binding', 'abandon the run']];
494
923
  emit('escalation', {
495
- category, decisionReady: true, phase,
496
- decision: category === 'cap-halt' ? `The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.` : `The ${phase} call failed (${category}) — no result exists.`,
497
- options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['retry the run', 'fix the provider binding', 'abandon the run'],
498
- detail: String(e?.message ?? e),
924
+ category, decisionReady: true, phase, decision, options, detail,
499
925
  ...(typeof e?.lib === 'string' ? { lib: e.lib } : {}),
500
926
  });
501
- return category === 'cap-halt' ? 'cap-halt' : 'provider-red';
927
+ // `step-stalled` rides out as ITSELF, not as provider-red. Naming the
928
+ // escalation while returning a casualty label would launder a governance stop
929
+ // into transport noise at exactly the layer the readout reads.
930
+ return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' ? category : 'provider-red';
502
931
  };
503
932
 
504
933
  // ── 1. SCOUT — read-only by construction: the write-class verbs are simply
505
934
  // not in its menu (the menu is the grant), and its gate fences zero paths
506
935
  let scoutBlob = '';
507
- emit('scout-start', { rounds: SCOUT_ROUNDS });
936
+ emit('scout-start', { rounds: scoutRounds });
508
937
  try {
509
- const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v)), phase: 'scout', attemptRounds: SCOUT_ROUNDS, attempts: 1, writable: false });
938
+ const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
510
939
  scout.setIteration(1);
511
940
  const r = await scout.ask([
512
941
  '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.',
@@ -515,10 +944,37 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
515
944
  pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
516
945
  ].filter(Boolean).join('\n\n'));
517
946
  scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
947
+ // F59 — RESERVE the summary round. The round bound is enforced from the
948
+ // metering callback (loop.stop), so a scout still calling tools on its last
949
+ // round is halted mid-tool-use and `text` is empty: it spends the whole
950
+ // allowance exploring and never writes the survey that is its ONLY
951
+ // deliverable. Measured on 15 of 18 archived runs, which then drafted from
952
+ // `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
953
+ // The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
954
+ // carries the exploration): with no tools offered, text is the only possible
955
+ // output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
956
+ // a pacing mandate in the persona was violated 6/6).
957
+ // The trigger is BOUNDED-and-short, never short alone: a terse survey that
958
+ // finished on its own was not cut off and has nothing to recover. The archive
959
+ // separates cleanly on exactly this pair — every truncated scout was bounded
960
+ // with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
961
+ if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
962
+ emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
963
+ 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.');
964
+ const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
965
+ if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
966
+ }
518
967
  } catch (e) {
519
968
  return relay(e, 'scout');
520
969
  }
521
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 });
522
978
  // F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
523
979
  // see — halt at the boundary rather than run the whole plan blind (the caller
524
980
  // emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
@@ -534,20 +990,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
534
990
  * total run spend can never exceed the signed budget (advertised == enforced,
535
991
  * the hard line) — a drafter built once at full budget would let the replan
536
992
  * draft spend against money the steps had already consumed.
537
- * @param {string} phase @param {string|null} failure */
538
- const obtainPlan = async (phase, failure) => {
993
+ * @param {string} phase @param {string|null} failure
994
+ * @param {string} [progress] the meter's read, replan only (A's adaptation channel) */
995
+ const obtainPlan = async (phase, failure, progress) => {
539
996
  const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
997
+ // The BALANCE, read live at each draft — never a snapshot and never a rate
998
+ // (addendum 2). At draft it is the whole budget (scale); at replan it is what
999
+ // is actually left, which is the adaptation channel.
1000
+ const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
1001
+ emit('materials', { phase, ...materials });
540
1002
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
541
1003
  drafter.setIteration(reds ? 'redraft' : 'draft');
542
- const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure), []);
1004
+ // capRuns is the SAME number validatePlan bounds `attempts` against below —
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), []);
543
1007
  return extractArtifact(r.text).code ?? '';
544
1008
  };
1009
+ // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
1010
+ // and the same V8-quoting class it was written for: `parse-error`'s detail is
1011
+ // JSON.parse's own message, and V8 quotes a window of the SOURCE inside it (a
1012
+ // body of 20 chars or fewer is quoted whole). That source is the DRAFT, which
1013
+ // the model wrote, so a red can carry model bytes onto `plan-validate`, onto
1014
+ // `plan-red`, and back into the redraft prompt — and the spine is append-only
1015
+ // forever (the hard line). The boundary is the right place and not the three
1016
+ // emit sites: scrubbing here makes the leak inexpressible, scrubbing each
1017
+ // consumer makes it something the next consumer has to remember.
1018
+ const validate = (/** @type {string} */ t) => {
1019
+ const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, capRuns });
1020
+ return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
1021
+ };
545
1022
  let text = await draftPlan(null);
546
- let pv = validatePlan(text, { job, maxStepRounds });
1023
+ let pv = validate(text);
547
1024
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
548
1025
  if (!pv.ok) {
549
1026
  text = await draftPlan(pv.reds);
550
- pv = validatePlan(text, { job, maxStepRounds });
1027
+ pv = validate(text);
551
1028
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
552
1029
  }
553
1030
  return pv;
@@ -574,6 +1051,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
574
1051
  const stepOutcomes = [];
575
1052
  let replanned = false;
576
1053
  const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
1054
+ /** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
1055
+ * step's attempt. It carries that stop's own record fields, and its presence is what
1056
+ * lets the step loop's terminal tell this reading apart from F64's mid-call one
1057
+ * without consulting the clock a second time (two clock reads for one event is how
1058
+ * the two instruments come to disagree). Exactly the fix loop's `wallStop`, one
1059
+ * layer up. */
1060
+ let stepWallStop = null;
577
1061
 
578
1062
  /** @param {any} step */
579
1063
  const executeStep = async (step) => {
@@ -598,11 +1082,105 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
598
1082
  const root = layerRoot && !native
599
1083
  ? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
600
1084
  : null;
601
- const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true, root });
1085
+ // P: the three widened step fields, each wired or refused — a silently-ignored
1086
+ // optional field is the F50 blind-instrument class. `attempts` tightens the
1087
+ // shell cap (validator already bounds it); `scope` narrows the fence to a
1088
+ // menu value (a subset by construction); `model` swaps the worker's provider
1089
+ // via the runner's factory — absent factory it must STOP, not silently run
1090
+ // the default tier as if the choice had been honoured.
1091
+ const stepCap = step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns;
1092
+ if (step.model !== undefined && !providerFor) {
1093
+ 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
+ err.category = 'interpreter-red';
1095
+ throw err;
1096
+ }
1097
+ const w = await mkWorker({
1098
+ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: stepCap, writable: true, root,
1099
+ fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
1100
+ workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
1101
+ });
602
1102
  let lastText = '';
603
1103
  let iterationNow = 0;
1104
+ // The meter's baseline: what the RUN had left when this step began. Shares are
1105
+ // computed against this, never against the original budget — a late step is
1106
+ // measured against what actually remains for it, which is what "eating the run"
1107
+ // means. Read once, here, so the denominator cannot drift mid-step.
1108
+ const stepStartUsd = remainingUsd();
1109
+ const stepStartMs = clock.bounded ? clock.remainingMs() : Infinity;
604
1110
  /** @param {number} iteration @param {string} [gap] */
605
1111
  const middle = async (iteration, gap) => {
1112
+ // A — the variance check, at the HEAD of an attempt so no work is discarded
1113
+ // (attempts 1..n-1 accrued the spend; their writes are already on disk). Never
1114
+ // fires on attempt 1: the share is 0 there by construction. Throwing with a
1115
+ // category is the seam ralph already owns; it routes the category and the step
1116
+ // loop reads it as a REPLAN, not a stop.
1117
+ if (iteration > 1) {
1118
+ const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
1119
+ const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
1120
+ if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
1121
+ emit('variance', {
1122
+ step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
1123
+ moneyShare: Number(moneyShare.toFixed(3)),
1124
+ timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
1125
+ axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
1126
+ });
1127
+ const err = /** @type {CategorizedError} */ (new Error(
1128
+ `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`));
1130
+ err.category = 'step-variance';
1131
+ throw err;
1132
+ }
1133
+ }
1134
+ // T/W-2 at the STEP site, and it sits BELOW the meter deliberately. The meter is
1135
+ // the instrument for a step that was ALREADY RUNNING when the deadline passed:
1136
+ // that step reads a time share of 1 and routes to the ONE replan exactly as it
1137
+ // always has. The case it is structurally blind to is a step that STARTS past the
1138
+ // deadline — its share is measured against what the run had left when it began,
1139
+ // which is zero, so the share is zero forever and the step burns every attempt it
1140
+ // is allowed. (Measured on the real flow: three attempts, three judged exits, all
1141
+ // past the deadline.) The two are exhaustive and do not overlap: past the deadline
1142
+ // a step that began with time left reads a share of exactly 1, so the meter always
1143
+ // fires first for it — which is why this check only ever reaches attempt 1, and why
1144
+ // the routing of a step that was already running is untouched.
1145
+ //
1146
+ // Every one of those attempts is worthless by construction: the metered clock
1147
+ // check stops the worker on its FIRST round, so it writes nothing, and the judge
1148
+ // still runs the step's exits — themselves close stages, at the full close timeout
1149
+ // each — to re-mint the red already on the record. hamr's ruling, the same one the
1150
+ // close-fix loop applies: when time is up, keep the grade we already have and
1151
+ // stop. Resume-to-cap extended from money to time — the stop IS the checkpoint.
1152
+ //
1153
+ // The placement IS the mechanism, and the in-flight case falls out of it with
1154
+ // nothing added: a deadline landing DURING an attempt still lets that attempt and
1155
+ // its judge run to completion, because the clock is not consulted again until the
1156
+ // next attempt is about to start. Grading real work always completes — a wall that
1157
+ // kills grading leaves the run unreadable after the money is spent (F45).
1158
+ if (clock.expired()) {
1159
+ stepWallStop = {
1160
+ cutMidCall: false,
1161
+ phase: `step:${step.id}`,
1162
+ stepsDone: idx,
1163
+ stepsPlanned: plan.steps.length,
1164
+ attemptsUsed: iteration - 1,
1165
+ // BOTH, honestly named. `capRuns` means the RUN's attempt cap on every
1166
+ // wall-halt record — the fix site writes exactly that — so this site must
1167
+ // not quietly put the step's TIGHTENED cap under the same key: two records
1168
+ // of one type disagreeing about what a number counts, with nothing on the
1169
+ // record saying which, is the two-instruments class. The tightening is real
1170
+ // information (it is what bounded THIS step) and keeps its own name.
1171
+ capRuns,
1172
+ stepAttemptCap: stepCap,
1173
+ };
1174
+ // No trend here, and none invented: `gapTrend` reads consecutive CLOSE output,
1175
+ // and a step that never ran has no grade of its own to compare. The lever the
1176
+ // human needs is on the run-level record either way.
1177
+ const err = /** @type {CategorizedError} */ (new Error(
1178
+ `the wall-clock cap passed before step "${step.id}" attempt ${iteration} could start — `
1179
+ + `the step was never funded to run. ${idx} of ${plan.steps.length} step(s) completed before it. `
1180
+ + 'Nothing after the deadline is allowed to change the verdict the run already has.'));
1181
+ err.category = 'wall-halt';
1182
+ throw err;
1183
+ }
606
1184
  w.setIteration(iteration);
607
1185
  iterationNow = iteration;
608
1186
  // Layer R observe: finalize the prior attempt from the books (workerWrites
@@ -624,7 +1202,21 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
624
1202
  lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
625
1203
  };
626
1204
  const judge = async () => {
627
- const { pass, results } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1205
+ const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1206
+ // Scrubbed HERE, once, before the results are read by anyone. An exit detail is
1207
+ // supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own
1208
+ // message and V8 quotes a window of the SOURCE inside it (a body of 20 chars or
1209
+ // fewer is quoted whole) — so the detail can carry FILE BYTES the worker chose,
1210
+ // and the spine is append-only forever (the hard line). ONE inventory
1211
+ // (SECRET_PATTERNS), the same `scrub` the close output and the ctx-verb channel
1212
+ // already ride through — never a second spelling of what a secret looks like.
1213
+ // The boundary is the right place and not the three call sites below it: these
1214
+ // same details become the `exit-eval` record, a fault's escalation `detail`
1215
+ // (via ralph's close-verdict + CLOSE_FAULTS emissions), and the worker gap
1216
+ // (which ralph also puts on the spine as close-verdict.gap). Scrubbing the
1217
+ // results makes the leak inexpressible; scrubbing each consumer makes it
1218
+ // something the next consumer has to remember.
1219
+ const results = raw.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
628
1220
  emit('exit-eval', { step: step.id, iteration: iterationNow, results });
629
1221
  // an instrument fault rides out by its runClose verdict NAME: ralph
630
1222
  // escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
@@ -634,7 +1226,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
634
1226
  // AND-only: the gap names EVERY failing wall (mechanical genre, F38)
635
1227
  return { verdict: 'needs_revision', gap: results.filter((r) => !r.pass).map((r) => r.detail).join('\n') };
636
1228
  };
637
- const outcome = await ralph({ middle, judge, capRuns, emit: emitL, workerWrites: w.workerWrites });
1229
+ const outcome = await ralph({ middle, judge, capRuns: stepCap, emit: emitL, workerWrites: w.workerWrites });
638
1230
  return { outcome, artifact: lastText };
639
1231
  };
640
1232
 
@@ -674,6 +1266,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
674
1266
  if (res.outcome === 'green') {
675
1267
  artifacts.push({ id: step.id, text: res.artifact });
676
1268
  idx += 1;
1269
+ // T's terminal: a run that cannot fit its time budget STOPS, and the stop is
1270
+ // the result (build-ladder discipline, v1.27 — unchanged). Checked after a
1271
+ // GREEN step too, not just a red one: the alternative silently starts a step
1272
+ // it cannot fund, which is the same class as the money cap binding mid-attempt
1273
+ // (F45). Resume-to-cap applies exactly as it does to money — the stop IS the
1274
+ // checkpoint, so the completed steps are not wasted.
1275
+ if (clock.expired() && idx < plan.steps.length) {
1276
+ emitWallHalt({ stepsDone: idx, stepsPlanned: plan.steps.length });
1277
+ emit('escalation', {
1278
+ category: 'wall-halt', decisionReady: true, phase: `step:${step.id}`,
1279
+ decision: `The run reached its wall-clock cap after ${idx} of ${plan.steps.length} steps. Time ran out, not capability.`,
1280
+ options: WALL_OPTIONS,
1281
+ detail: `requested ${clock.requestedMs}ms, elapsed ${clock.elapsedMs()}ms`,
1282
+ });
1283
+ planExecuted();
1284
+ return 'wall-halt';
1285
+ }
677
1286
  continue;
678
1287
  }
679
1288
  // ONE replan, and only for EXHAUSTION with FUNDS LEFT (review #5): ralph
@@ -685,16 +1294,50 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
685
1294
  // exhaustion leaves money on the table. An instrument/governance stop that
686
1295
  // is not cap-halt never replans either. (An unpriced step already returned
687
1296
  // pricing-red at the step-end guard above, so isUnpriced() is false here.)
1297
+ //
1298
+ // 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 with its exits unmoved. That
1300
+ // is the case exhaustion-only could never reach (F56: a replan has fired zero
1301
+ // times in the programme), and it is a genuine re-allocation rather than a stop.
1302
+ // Both triggers still need FUNDS LEFT, and both are still bounded by the ONE
1303
+ // replan ceiling — this changes the trigger, never the ceiling (v1.12).
688
1304
  const cat = lastEscalation?.category;
689
- if (!replanned && cat === 'cap-halt' && remainingUsd() > MONEY_MIN) {
1305
+ // F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
1306
+ // model call MAX_STALLS times and never got a round back. hamr's ruling is that
1307
+ // a stall must self-heal rather than end the run ("killing and coming back is
1308
+ // not an option"), so the watchdog reissues silently; only once reissuing has
1309
+ // demonstrably stopped working does the run adapt instead — by replanning, not
1310
+ // by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
1311
+ // other two: this changes the trigger, never the ceiling (v1.12).
1312
+ const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
1313
+ : cat === 'step-variance' ? 'the meter stopped a step that was consuming the run with its exits unmoved'
1314
+ : cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
1315
+ : null;
1316
+ if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
690
1317
  replanned = true;
691
- emit('replan', { step: step.id, reason: 'step exhausted its attempts with exits still red' });
692
- const failure = `Step "${step.id}" (${step.action}) ran ${capRuns} attempts and its exits were still red. `
1318
+ emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
1319
+ // The brief names the trigger HONESTLY, one branch per trigger: it is the
1320
+ // only channel the redrafting planner adapts to, so a wrong diagnosis here
1321
+ // is handed to the one component whose whole job is to respond to it. A
1322
+ // stall in particular never judged its exits at all — the exhaustion
1323
+ // sentence would invent a red the run never saw (F28's rule, replan side).
1324
+ const why = cat === 'step-variance'
1325
+ ? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
1326
+ : cat === 'step-stalled'
1327
+ ? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
1328
+ : `It ran ${step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns} attempts and its exits were still red.`;
1329
+ const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
1330
+ + `${why}\n`
693
1331
  + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
694
1332
  + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
1333
+ // The progress line IS the adaptation channel (addendum 2): the balance rides
1334
+ // in via obtainPlan's live read, and this says where the run got to. Both are
1335
+ // the planner re-allocating what remains across what is left — never a rate.
1336
+ const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
1337
+ + `${artifacts.length} step(s) completed before it`;
695
1338
  let pv;
696
1339
  try {
697
- pv = await obtainPlan('replan', failure);
1340
+ pv = await obtainPlan('replan', failure, progress);
698
1341
  } catch (e) {
699
1342
  planExecuted();
700
1343
  return relay(e, 'replan');
@@ -713,6 +1356,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
713
1356
  idx = 0;
714
1357
  continue;
715
1358
  }
1359
+ // F64 — the wall stopped this attempt from INSIDE a provider call (the derived
1360
+ // call timeout is the deadline in the provider's own coin). ralph already
1361
+ // emitted the escalation under this category, so the outcome and the record
1362
+ // agree (F11); what is still missing is the run-level TIME record, which the
1363
+ // between-steps site emits and this path must too. Never a replan trigger and
1364
+ // never a casualty: time ran out, and the stop IS the checkpoint.
1365
+ // W-2 adds the step loop's SECOND wall reading: the deadline seen at the head of
1366
+ // an attempt, with no call in flight. `stepWallStop` is that reading's own record
1367
+ // (attempts used, steps done); its absence means the deadline landed inside a call
1368
+ // instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
1369
+ // on exactly that field, so conflating the two would report an unknown as exact.
1370
+ if (cat === 'wall-halt') {
1371
+ emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
1372
+ planExecuted();
1373
+ return 'wall-halt';
1374
+ }
716
1375
  // A money-gate halt (wallet drained) is an honest cap-halt terminal, never a
717
1376
  // step-red: the exits never ran because the money ran out, not because the
718
1377
  // work failed. Attempt-exhaustion WITH funds after the one replan is spent
@@ -721,6 +1380,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
721
1380
  planExecuted();
722
1381
  return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
723
1382
  }
1383
+ // A second `step-variance` after the one replan is spent is a STOP, and the stop
1384
+ // is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
1385
+ // outcome. The category is a replan TRIGGER, never a run verdict: leaking it
1386
+ // upward would mint an outcome run.js and the ledger's class table do not know,
1387
+ // and an unmapped category is counted as a library bug (ledger.js) when this is a
1388
+ // planning story. The escalation ralph already emitted carries the real detail.
1389
+ if (cat === 'step-variance') {
1390
+ planExecuted();
1391
+ return `step-red:${step.id}`;
1392
+ }
724
1393
  // Any OTHER terminal escalation category is NOT a capability failure: a
725
1394
  // provider-red is a transport CASUALTY, a gate-red/interpreter-red/close
726
1395
  // fault is an instrument stop. Each rides out under its OWN name (F11: the
@@ -734,7 +1403,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
734
1403
  // ── 4. THE CLOSE — the operator's signed command, the only truth. Red →
735
1404
  // the gap feeds ONE bounded fix loop judged by the REAL close (v1.12 §4);
736
1405
  // still red → the escalation ralph already emitted stands.
737
- const post = runClose(closeArgv, scrub, closeOpts);
1406
+ const post = await judgeClose();
738
1407
  emit('outer-close', { ...post });
739
1408
  if (post.verdict === 'satisfied') {
740
1409
  planExecuted();
@@ -748,22 +1417,89 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
748
1417
  }
749
1418
  emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
750
1419
  let fixOutcome;
1420
+ /** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
1421
+ * iterations. It carries that stop's own record fields, and its presence is what
1422
+ * lets the post-loop branch tell this reading apart from F64's mid-call one
1423
+ * without consulting the clock a second time (two clock reads for one event is
1424
+ * how the two instruments come to disagree). */
1425
+ let wallStop = null;
751
1426
  try {
752
1427
  // Layer R for the close-fix loop — the plan flow's single ralph loop judged
753
- // by the REAL close (the closest analog to interpret.js's loop, and the
1428
+ // by the REAL close (the plan flow's single ralph loop, and the
754
1429
  // likeliest place fixation manifests: the fix worker has the full menu and
755
- // is judged by a command, not a form-only exit). The red-set is the CLOSE's
756
- // own gapKeep (the gap here is the raw close output, unwrapped — so the
1430
+ // is judged by a command, not a form-only exit). The red-set comes from the
1431
+ // stage that ACTUALLY rendered the verdict this attempt, not from one pattern
1432
+ // fixed for the run: a staged close has N gapKeeps and the wall the worker hit
1433
+ // varies attempt to attempt (the gap here is the raw close output, so the
757
1434
  // `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
758
1435
  // native exclusion (no onToolResult seam ⇒ the tee cannot settle).
759
1436
  const fixRoot = layerRoot && !native
760
- ? createRoot({ gapKeep: job.close.gapKeep, redact: scrub, writesInformative: true })
1437
+ ? createRoot({ redact: scrub, writesInformative: true })
761
1438
  : null;
762
1439
  const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
1440
+ // W-2's only input, and it is already in this loop's hand: the close gaps seen
1441
+ // so far, oldest first. `post.gap` is the grade the loop OPENED on; every later
1442
+ // entry is what ralph carried back from the close that judged the previous
1443
+ // attempt (or, after a worker-crash, F32's routed gap — still the feedback that
1444
+ // attempt actually received). Nothing is instrumented for this.
1445
+ const gaps = [post.gap ?? ''];
763
1446
  /** @param {number} iteration @param {string} [gap] */
764
1447
  const middle = async (iteration, gap) => {
1448
+ if (gap !== undefined) gaps.push(gap);
1449
+ // T/W-2 — the wall, read before a NEW fix attempt is started, and never
1450
+ // around the close itself. hamr's ruling: *"when time is up, keep the grade
1451
+ // we already have and stop … run tests (free) and when done if original time
1452
+ // is past due, pause and ask user to increase time or adjust prompt"*.
1453
+ //
1454
+ // Past the deadline this iteration is guaranteed to be worthless: the metered
1455
+ // round check stops the fix worker on its FIRST round, so it writes nothing,
1456
+ // and the iteration then re-runs the FULL staged close over an unchanged tree
1457
+ // (four stages × closeTimeoutMs = 60 minutes on the shipped spec) only to mint
1458
+ // the verdict already on the record — capRuns times over. So the run stops
1459
+ // here, ON that verdict. Resume-to-cap extended from money to time: the stop
1460
+ // IS the checkpoint, and the human tops up (v1.12).
1461
+ //
1462
+ // The placement IS the mechanism, and the in-flight case falls out of it with
1463
+ // nothing added: a deadline that lands DURING an attempt still lets that
1464
+ // attempt's close run to completion, because the clock is not consulted again
1465
+ // until the next iteration is about to start. The close is never bounded — a
1466
+ // wall that kills grading leaves the run unreadable after the money is spent
1467
+ // (the F45 class), so the one thing this must never do is stop a close.
1468
+ if (clock.expired()) {
1469
+ const t = gapTrend(gaps);
1470
+ wallStop = {
1471
+ cutMidCall: false,
1472
+ phase: 'fix',
1473
+ iterationsUsed: iteration - 1,
1474
+ capRuns,
1475
+ verdict: lastCloseVerdict?.verdict,
1476
+ ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
1477
+ trend: t.trend,
1478
+ };
1479
+ // The detail carries what the human needs to pick a lever and nothing else:
1480
+ // the verdict that stands, how much of the loop was actually spent, and the
1481
+ // trend that says which of the two spec edits fits. It never names a culprit
1482
+ // file (F28) — that is the worker's job, not the escalation's.
1483
+ const err = /** @type {CategorizedError} */ (new Error(
1484
+ `the wall-clock cap passed before fix attempt ${iteration} could start. `
1485
+ + `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
1486
+ + `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
1487
+ + `after ${iteration - 1} of ${capRuns} fix iteration(s). `
1488
+ + `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
1489
+ err.category = 'wall-halt';
1490
+ throw err;
1491
+ }
765
1492
  w.setIteration(iteration);
766
- const rootInj = fixRoot ? fixRoot.observe({ iteration, gap, writes: w.workerWrites() }) : null;
1493
+ // the red-set's source travels WITH the gap: `closeStage` is the stage the
1494
+ // judge stopped at for the attempt this gap came from, and its own gapKeep
1495
+ // designates that stage's failures
1496
+ const rootInj = fixRoot
1497
+ ? fixRoot.observe({
1498
+ iteration, gap, writes: w.workerWrites(),
1499
+ redStage: closeStage,
1500
+ redKeep: stagedClose.find((s2) => s2.name === closeStage)?.gapKeep,
1501
+ })
1502
+ : null;
767
1503
  if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
768
1504
  await w.ask([
769
1505
  'The job\'s final verification is failing. Fix the repository so it passes.',
@@ -775,14 +1511,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
775
1511
  ].filter(Boolean).join('\n\n'));
776
1512
  };
777
1513
  fixOutcome = await ralph({
778
- middle, close: closeArgv, capRuns, emit: emitL, redact: scrub,
779
- closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep,
780
- workerWrites: w.workerWrites,
1514
+ // the staged close rides in as the JUDGE seam (one verdict, every stage in
1515
+ // order) — ralph's single-command path stays exactly what it was
1516
+ middle, judge: async () => judgeClose(), capRuns, emit: emitL, redact: scrub,
1517
+ closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
781
1518
  });
782
1519
  } catch (e) {
783
1520
  planExecuted();
784
1521
  return relay(e, 'fix');
785
1522
  }
786
1523
  planExecuted();
1524
+ if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
1525
+ // F64 in the close-fix loop: the same governance stop, and it must not ride out
1526
+ // as a bare `escalated` either — that reads as "the fix failed" when the fix was
1527
+ // never given the time to run.
1528
+ // W-2 adds the loop's SECOND wall reading: the deadline seen between iterations,
1529
+ // where a real grade already exists and is kept. `wallStop` is that reading's own
1530
+ // record (verdict, iterations used, trend); its absence means the deadline landed
1531
+ // inside a call instead, which is F64's cutMidCall stop.
1532
+ emitWallHalt(wallStop ?? { cutMidCall: true, phase: 'fix' });
1533
+ return 'wall-halt';
1534
+ }
1535
+ if (fixOutcome !== 'green' && lastEscalation?.category === 'cap-halt') {
1536
+ // ralph spells BOTH of its terminals cap-halt — attempt-exhaustion AND a
1537
+ // money-gate halt thrown mid-attempt — so the category alone cannot tell them
1538
+ // apart, and the wallet is the only instrument that can. The step loop already
1539
+ // splits this exact pair on the same reading; the fix loop must too, because
1540
+ // the fix worker's gate is built with the wallet at its MOST drained (every
1541
+ // step's spend is behind it), which is precisely where a money cut masquerades
1542
+ // as "the fix failed". F45: a money cut is never a capability read — it rides
1543
+ // out as cap-halt, the resume-to-cap checkpoint (the stop IS the checkpoint).
1544
+ // Attempts spent with money still on the table stays the designed terminal
1545
+ // ("close still red") and keeps riding out as `escalated` below.
1546
+ // (MED-4, fixed with hamr's explicit go, 2026-07-30.)
1547
+ if (remainingUsd() <= MONEY_MIN) return 'cap-halt';
1548
+ } else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
1549
+ // The step loop's category restoration (F11), mirrored: ralph returns the flat
1550
+ // 'escalated' on a middle throw while its escalation carries the real name — a
1551
+ // provider-red here is a transport CASUALTY, and run.js keys the F44
1552
+ // spendComplete:false floor on the OUTCOME, so a laundered label would report
1553
+ // an exact-looking total for a call that never billed back.
1554
+ return lastEscalation.category;
1555
+ }
787
1556
  return fixOutcome === 'green' ? 'green' : 'escalated';
788
1557
  }