bareloop 0.5.0 → 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/CHANGELOG.md CHANGED
@@ -5,7 +5,752 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
- ## [Unreleased]
8
+ ## [0.6.0] — 2026-07-31
9
+
10
+ ### Added
11
+ - **P — the worker's palette widens 6 → 14 verbs, and a step gains a vocabulary** (design
12
+ record 2026-07-28). The plan surface exposed two of litectx's verbs and the agent could
13
+ choose almost nothing about HOW a step ran (F56).
14
+ - `TOOL_MENU` (`src/job.js`) is now four components: **write** (`write` · `edit`), **select**
15
+ (`read` · `grep` · `recall` · `get` · `impact` · `related` · `recent`), **compress**
16
+ (`compress` · `peek`), **isolate** (`stash` · `remember` · `forget`). Every verb maps to an
17
+ EXISTING litectx/bare-agent implementation — the menu is an INVENTORY, nothing was built to
18
+ fill it. Select/compress verbs are gate-judged as `read` actions through the same fence;
19
+ isolate verbs write the `.litectx` STORE and carry their OWN gate action types, so a store
20
+ park can never be counted as a tree write by the F32 `workerWrites` instrument. `run` stays
21
+ locked at every layer.
22
+ - New `WRITE_VERBS` / `STORE_VERBS` split: **neither class is read-capable**, so the scout's
23
+ grant filters both, and a ceiling made only of them reds `invalid-value` at sign time (a
24
+ write-only ceiling hands the scout an empty menu and it surveys blind).
25
+ - Each component ships its F19 strategy line (`COMPONENT_STRATEGIES`, `src/tools.js`),
26
+ composed into the persona only for components with a granted verb — capability without
27
+ strategy is inert, and a strategy for a verb the worker does not hold is noise.
28
+ - **Three new step fields, each legal set handed over as a MENU** (`src/plan.js`,
29
+ choose-don't-describe): `model` (`sonnet` | `haiku`, `STEP_MODELS` — `opus` deliberately
30
+ absent, hamr-assigned only, never plan-selectable), `attempts` (integer `1..capRuns`), and
31
+ `scope` (one value from the same `legalScopes` menu `tree-changed` uses). All optional, all
32
+ **tighten-only** — a plan may narrow the shell's caps and the signed fence, never widen them.
33
+ - **Each field is WIRED or REFUSED, never silently ignored** (the F50 blind-instrument class):
34
+ `scope` narrows the live gate fence (proven — an in-job-fence write denied by the step's own
35
+ scope), `attempts` tightens `ralph`'s cap (proven — exactly one iteration), and `model` routes
36
+ that step's worker through a new **`providerFor(tier)`** factory on `runJob`/`runPlan`.
37
+ **A step naming a tier when the caller supplied no factory is a NAMED `interpreter-red` stop**
38
+ — a wiring gap, not a plan defect — never a silent run on the default tier as if the choice
39
+ had been honoured.
40
+ - `scripts/run-u.mjs` supplies that factory (the tier→model mapping is the RUNNER's territory,
41
+ never the schema's) and gains a `--model` tier flag, and now **deletes `.litectx` at every
42
+ reset — cold means cold**: the isolate verbs persist across runs, so an uncleaned store would
43
+ leak run N's memory into run N+1's "cold" baseline and quietly poison the reuse rung's OFF arm.
44
+ - The U job specs widen to the full catalog, which is a NEW spec hash and therefore UNSIGNED
45
+ until hamr's explicit in-turn approval — widening the menu never silently changes a signed
46
+ spec's meaning.
47
+ - POC-first paid three times before a line of module code (recall hits carry no id — the path
48
+ IS the key; `impact`'s `confirmed`/`mentions` are counts, not arrays; `purge()` covers neither
49
+ stash nor memory, so a cold reset must delete the store). The tool tests run against a REAL
50
+ LiteCtx over a real fixture: the impact-shape test failed once against live data and was fixed
51
+ from the measured shape, not the assumed one.
52
+ - **F66 — the in-process stall fuse** (`src/stall.js`): bare-agent's `timeoutMs` bounds
53
+ socket INACTIVITY (resets on every byte), so a slow-but-trickling call hung a run for 274
54
+ minutes (U run ms3197n8). The fuse's heartbeat is a completed ROUND — no round for 5
55
+ minutes abandons the call and silently reissues it (self-heal, hamr's ruling); three
56
+ stalls throw `step-stalled`, the third replan trigger. Validated on the paid surface: a
57
+ real slow call stalled and its reissue answered; three real in-flight calls abandoned at
58
+ the ceiling; late answers from abandoned sockets swallowed. Mutation battery 6/6 (the
59
+ surviving mutant was a real hole matching the production failure shape — fixed).
60
+ - **F67 — the outside watchdog** (`scripts/u-watchdog.mjs`): U run ms3jh76q froze its event
61
+ loop 81.5 minutes and every in-process guard froze with it. A separate process sharing
62
+ nothing with the run — reads one file's mtime, calls kill(2), records the reason on disk
63
+ BEFORE the signal. Two triggers: stale spine and wall+grace. Battery includes a
64
+ hard-frozen-loop victim (`for(;;);`) — the exact case it exists for. Mutation 5/5.
65
+ - **Event-loop lag sampler** in `scripts/run-u.mjs`: records any ≥3s loop block with
66
+ from/until timestamps to `<spine>.lag.jsonl` — the instrument that localized the
67
+ spawnSync-close blocks (below) on its first run.
68
+ - **BREAKING for plan authors — `step-scope-escape`: a scoped step's TARGET must sit inside
69
+ its OWN narrowed scope** (`src/plan.js`, W3). A plan that validated before can red now. A
70
+ step may narrow the signed fence with `scope`, and `runPlan` builds that step's gate from
71
+ the narrowed prefix — so a target inside the signed fence but outside the step's own scope
72
+ was a write the gate denied on every attempt: the step burned its whole attempt budget on
73
+ refusals with no red anywhere, because each half was individually legal and nothing checked
74
+ the PAIR. The rule binds WRITES, not observations, and the exit arms were measured against
75
+ `src/exits.js` rather than assumed. `tree-changed` reds only when its scope is DISJOINT from
76
+ the step's — a scope that CONTAINS the step's contains its writable ground and fires exactly
77
+ when the narrow one does, and the menu is shallowest-first while the prompt says copy one
78
+ value, so containment would tax the likeliest draft (the F60 class `legalScopes` exists to
79
+ remove). `artifact-written`/`json-valid` paths carry NO step-scope red at all: the evaluator
80
+ reads and parses the named file and asks nothing about who wrote it, so a path naming a
81
+ PRIOR step's artifact is legal AND satisfiable under strictly-sequential v1 — a red there
82
+ would claim a constraint the evaluator does not impose. An off-menu `scope` carries no
83
+ narrowing (it already redded): one defect, one red.
84
+ - **Four documented names are now actually exported** (`src/index.js`): `runStages`,
85
+ `checkMenu`, `STORE_VERBS` and `stageClose`. Each was named as adopter-reachable in this
86
+ CHANGELOG or in `bareloop.context.md` while the index never handed it out — a
87
+ documented-but-unexported name is a false contract the adopter discovers by importing it.
88
+ `tests/index.test.js` pins the documented surface by name (a NAME check; each export's
89
+ semantics stay owned by its own module's tests), so the docs and the index cannot drift
90
+ apart silently again.
91
+ - **A per-payload cap on the isolate verbs** — `ISOLATE_MAX_BYTES` = 64KB, hamr's threshold
92
+ (`src/tools.js`). `ctx_stash`/`ctx_remember` write the `.litectx` store, which the
93
+ writeScope fence does not judge: the one worker surface that writes UNGATED bytes, and
94
+ unbounded it grows for as many rounds as the wallet funds. Over-cap comes back as a refusal
95
+ RESULT naming the actual size and the limit — worker feedback, never a throw (throws stay
96
+ the BA-4 param-guard class) — and nothing enters the store; both tool descriptions state
97
+ the bound. The residual is named rather than hidden: this bounds ONE PAYLOAD, and N
98
+ distinct ids are N payloads, so the aggregate stays unbounded. An aggregate cap is a
99
+ threshold, and thresholds are hamr's call.
100
+ - **`step-stalled` has its own DECISIONS entry** (`src/ralph.js`). F66's terminal fell through
101
+ to the generic default, which points a human at the interpreter — the one component that did
102
+ nothing wrong. A stall is not a fault: the socket stayed alive the whole time (that is the
103
+ measured mechanism), so the entry says so and leads with the planner's lever (replan) instead
104
+ of sending anyone to debug transport.
105
+ - **`ctx_impact` ships litectx's hedges** (`src/tools.js`), as `caveat` lines. litectx's impact
106
+ is deliberately asymmetric — over-count safe, under-count dangerous — so "isolated / low
107
+ risk" only ever ships HEDGED, and dropping the caveat left `risk low — 0 confirmed caller(s)`
108
+ reading as a licence to change the symbol freely, the one act the asymmetry exists to
109
+ prevent. The spine's `hits` field is counted BEFORE the caveats are appended and keeps its
110
+ old meaning (impact content) — a caveat qualifies the count, it is not more of it, and
111
+ re-meaning a shipped field is how a field stops measuring what its readers think it measures.
112
+
113
+ ### Fixed
114
+ - **The close-fix loop laundered a transport casualty into a flat `escalated`**
115
+ (`src/planrun.js`). The step loop restores an escalation's own category (F11: the outcome and
116
+ the spine escalation must agree); the fix loop did not, so a `provider-red` raised there came
117
+ back as `escalated` — and `run.js` keys the F44 `spendComplete:false` floor on the OUTCOME, so
118
+ the run reported an exact-looking total for a call that may never have billed back. Now
119
+ mirrored. Found by the hardening pass, PARKED, and fixed only on hamr's explicit go.
120
+ - **The close-fix loop could not tell a drained wallet from spent attempts** (`src/planrun.js`,
121
+ F45 class). The shell spells BOTH terminals `cap-halt` — attempt-exhaustion and a money-gate
122
+ halt thrown mid-attempt — so the category alone cannot split them and only the wallet can.
123
+ The fix worker's gate is built with the wallet at its MOST drained (every step's spend is
124
+ behind it), which is precisely where a money cut masquerades as "the fix failed" and becomes
125
+ a false capability read. The fix loop now reads the wallet exactly as the step loop does: a
126
+ drained wallet returns `cap-halt` (the resume-to-cap checkpoint — the stop IS the
127
+ checkpoint), attempts spent with money still on the table stays the designed `escalated`
128
+ terminal ("the close is still red"). Reachability validated before the fix: nothing guards
129
+ money between the last step and the close. Verdict routing is arbiter territory — fixed only
130
+ on hamr's explicit go.
131
+ - **`ctx_recall` was blind to the memory axis, so `ctx_remember` was write-only through the tool
132
+ surface** (`src/tools.js`). The isolate strategy promises *"record a durable conclusion with
133
+ `ctx_remember` so a later step can `ctx_recall` it"* — and the recall handler hardcoded
134
+ `kind: 'code'`, so no note ever came back. The library-level round-trip test could not see it:
135
+ it called `lc.recall` directly, not the verb the worker holds. Recall now queries the fact axis
136
+ alongside code and returns each note on a line labeled `memory` with its BODY inline (capped at
137
+ 400 chars, so a bloated note cannot page the context). The body rides because a note is a
138
+ CONCLUSION, not a pointer — there is no worker verb that dereferences a memory id. Same parked-
139
+ then-explicit-go path as above.
140
+ - **The aurora U close type-checked another repository** (`scripts/u-spawner-close.mjs`):
141
+ mypy's `explicit_package_bases` named the patient's files `packages.spawner.src.*`, so
142
+ sibling imports fell through to the editable install — a symlink to a DIFFERENT aurora
143
+ checkout. Proven with a planted probe, fixed with `MYPYPATH`; latent not active (seed
144
+ error count unchanged, both prior greens re-verified under the fixed instrument).
145
+ - **The stall fuse could be disarmed by the very call it abandoned** (`src/stall.js`, F70). An
146
+ abandoned call is not dead — it keeps streaming (ms3197n8's socket lived 274 minutes past
147
+ the fuse) — so a late beat from that corpse re-armed the watch timing its REPLACEMENT, and
148
+ the replacement could then hang forever without tripping the fuse built for exactly that.
149
+ One shared `Loop` compounded it, letting a corpse's round bound stop the live call. Now
150
+ generation-scoped: `watch`/`beat`/`isCurrent` all carry the generation of the call that
151
+ issued them, and each issued call gets its own `Loop`. Orphan rounds stay metered — an
152
+ abandoned call may already have been billed.
153
+ - **The outside watchdog could kill a stranger, or kill a live verdict**
154
+ (`scripts/u-watchdog.mjs`, `scripts/run-u.mjs`, F70). Liveness was `kill(pid, 0)`, which
155
+ answers "does SOME process hold this pid" — and after the SIGKILL/OOM case the watchdog
156
+ exists for, the kernel can recycle that pid onto an unrelated process. Liveness is now the
157
+ PARENT LINK (`process.ppid === pid`), which pid reuse cannot forge, and a guard aimed at a
158
+ non-parent REFUSES to arm at startup (exit 2, no marker) rather than running as a
159
+ plausible-looking guard pointed at nothing. Separately, its stale-spine window was sized
160
+ BELOW the longest legal silence — `runStages` emits nothing between stages, so a legal close
161
+ can be quiet for `closeTimeoutMs × stages` — and is now sized from the spec's own stage count
162
+ plus a margin. Test fixtures rewritten so the victim spawns its own watchdog: the only shape
163
+ that exercises the real parent link. **That closed the stale trigger's half of "kill a live
164
+ verdict" only:** the DEADLINE trigger still killed on the clock alone, on a wall grace whose
165
+ default covered one close stage, and hamr ruled the whole trigger must read activity before
166
+ it signals — the redesign is under *Changed*, below.
167
+ - **The ledger counted every stall as a bareloop bug, and then aimed it at the wrong package**
168
+ (`src/ledger.js`, F70 + review). `classifyIncidents` had no branch for `step-stalled`, so the
169
+ F66 fuse's own terminal fell through to "unclassified escalation category" — real upstream
170
+ evidence filed as a fake defect of ours. It was first routed through the TYPED-LIB branch
171
+ `interpreter-red` uses, and the rendered ask refuted that: `StallError` stamps
172
+ `lib: 'bare-agent'` at the throw site, so a live ask read *"bare-agent: the provider path
173
+ failed — worker stalled…"* when **nothing observed the provider path fail**. A stall is the
174
+ ABSENCE of beats. `step-stalled` is now a NAMED member of `EXCLUDED_ESCALATIONS` beside
175
+ `cap-halt` and `wall-halt` (operator ruling, on the wall-halt shape): our own governance
176
+ firing is not an upstream bug, and the stall evidence still rides the spine's own escalation
177
+ event, which is where a real ask would be sourced from anyway. Excluded by name, never
178
+ silently dropped — the excluded-set is executable and counted.
179
+ - **`checkMenu` expanded `needs` one level only** (`src/job.js`, F70), so a chain of
180
+ prerequisites ran incomplete — reproducing the false red `needs` exists to prevent, one level
181
+ deeper. Now a transitive fixed-point walk, ordered by close position, visiting each stage at
182
+ most once (cycle-safe by construction).
183
+ - **`ctx_impact` printed `calls undefined:undefined` for every callee** (`src/tools.js`, F70).
184
+ litectx returns `defs` and `callers` as objects with a path and a line, but `callees` as bare
185
+ NAMES; the readout assumed the object shape uniformly.
186
+ - **The aurora U close floored on tests PASSED, not EXECUTED** (`scripts/u-spawner-close.mjs`,
187
+ the F40 class recurring in a close written after the rule was minted). A skipped or deselected
188
+ test could hide a red without pushing the passed-count under the floor, and only one summary
189
+ bucket was read. Now floors on `executed = collected − (skipped + deselected)` and sums every
190
+ red tally; validated on the real patient (209 executed).
191
+ - `WRITE_VERBS` was declared twice — `src/plan.js` held its own frozen copy of `src/job.js`'s
192
+ array, so a third write-class verb could land in one and not the other. `plan.js` now
193
+ re-exports the one inventory (identity-tested, not deep-equal).
194
+ - **One defect, one red:** the mailbox rule fired on a step whose `tools` grant failed to
195
+ parse, deriving a second red from a false default on top of the correct
196
+ `missing-required`/`invalid-value` for the bad field. It now fires only on a PARSED grant —
197
+ the step's hands are unknowable otherwise, and the real defect already redded.
198
+ - **The retrieval verbs' line numbers are named as ONE 0-based handle space**
199
+ (`src/tools.js`). `recall`, `get` and `impact` all print the index's own 0-based positions —
200
+ one lower than the line an editor shows — and each now says so in its own tool contract. The
201
+ tempting alternative (render `impact` 1-based, since its numbers look display-only) was
202
+ tested against a real index and REFUTED: `impact`'s def range dereferences through `get`
203
+ verbatim and `recall` prints the same number for the same chunk, so renumbering one tool
204
+ would print two numbers for one chunk and turn `get`'s clean chunk-boundary refusal into a
205
+ guessing game.
206
+ - **The wall clock advertised a one-stage close** (`src/clock.js`, `src/planrun.js`, W5).
207
+ Enforcement is `maxWallMs + closeTimeoutMs` only when the close is ONE command; a staged
208
+ close (PRD v1.28) runs its stages one at a time and hands EACH the full `closeTimeoutMs`, so
209
+ a 4-stage close at 900s is 60 minutes of close, not 15 — and the run's own outside watchdog
210
+ already sized its stale window that way, which made it the same arithmetic disagreeing with
211
+ itself. `createClock` now takes `closeStages` (threaded from `stageClose`, the ONE staging
212
+ the runner executes — never a second count), `enforcedMs` is the one expression both the
213
+ advertised record and the worst case are computed from, and `report()` puts `closeStages` on
214
+ the `wall-clock` record: without it a reader seeing enforced 90min against a requested 30min
215
+ cannot tell one 60-minute timeout from four 15-minute stages, and an unexplained gap is
216
+ exactly the number a later reader rounds back down to the requested one. The `MIN_WALL_MS`
217
+ floor is deliberately left at one stage — a threshold is operator territory, parked in
218
+ PRD v1.39 rather than re-derived here.
219
+ - **A run that ABSORBED a stall reported its spend as exact** (`src/run.js`, W1). The terminal
220
+ `step-stalled` already carried `spendComplete: false`; the common case does not reach it —
221
+ the F66 fuse abandons the hung call and silently reissues it (self-heal), and an abandoned
222
+ call is not a call that was never billed. So a run that stalls and then ends green, escalated
223
+ or at the cap also holds a FLOOR, not a total. `runJob` now watches the `stall` event and the
224
+ flag is one-way: any absorbed stall makes `spendComplete: false` on the job-end, whatever the
225
+ outcome. The same honesty applies to the wall: a `wall-halt` that cut a call mid-flight
226
+ (`cutMidCall`) reports a floor, while a wall stop read BETWEEN iterations — where no call was
227
+ in flight — stays exact.
228
+ - **The ctx verbs' spine channel was unscrubbed** (`src/planrun.js`). Every field a `ctx-tool`
229
+ event carries is MODEL-CHOSEN text — a recall query, a stash id, a symbol, a path the worker
230
+ spelled — and the spine is append-only: a log that captures a key captures it forever. The
231
+ emitter is now scrubbed once at the WIRING, with the same ONE inventory (`SECRET_PATTERNS`)
232
+ the close output goes through, so a new ctx verb cannot forget it — seventeen scrubbed call
233
+ sites would have been seventeen chances to.
234
+ - **A failing `json-valid` exit could put file bytes on the spine** (`src/planrun.js`). An exit
235
+ detail is supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own message
236
+ and V8 quotes a window of the SOURCE inside it — a body of 20 characters or fewer is quoted
237
+ WHOLE, and the short-prefix secret shapes (`xoxb-…`) fit inside that window — so the detail
238
+ can carry file bytes the WORKER chose, into a log that is append-only forever. `runPlan` now
239
+ scrubs every detail once at the `judge()` boundary, with the same ONE inventory
240
+ (`SECRET_PATTERNS`) the close output and the ctx-verb channel ride through. The boundary is
241
+ the place, not the three consumers below it: the same details become the `exit-eval` record,
242
+ a fault's escalation `detail`, and the worker gap ralph puts on the spine as
243
+ `close-verdict.gap` — scrubbing at the source makes the leak inexpressible, scrubbing per
244
+ consumer makes it something the next consumer has to remember. `evalExits` itself is
245
+ untouched and does NOT scrub; it is a public export, so an adopter wiring its results into
246
+ their own log applies their own redaction.
247
+ - **The stall fuse could reissue a call past the run's wall** (`src/stall.js`, L4). The clock is
248
+ consulted where a ROUND completes and after a step returns; a stall completes no round and
249
+ returns from no step, so nothing read it between the wall passing and the next reissue — a
250
+ worker stalling at its wall could be reissued for up to `maxStalls × stallMs` of calls the run
251
+ had already declined to authorize. The watch now takes the wall as a callback and reads it
252
+ FIRST, before the decision to spend again: past the deadline it gives up as `wall-halt`
253
+ (never `step-stalled`, which is a replan trigger — there is nothing left to re-allocate, and
254
+ the only remedy is `maxWallMs`). Not a retreat from the self-heal ruling: self-heal is what a
255
+ run does with time left.
256
+ - **A close that refused SIGTERM hung the run forever** (`src/ralph.js`). On a fault
257
+ (`ENOBUFS`, timeout) the child is asked to leave, but SIGTERM is a REQUEST — a runner with
258
+ its own handler or a trapping wrapper can decline it — and there was no second deadline, so
259
+ no `close` event ever arrived and the run's only remaining stop was the out-of-process
260
+ watchdog. A 2s grace, then SIGKILL, which nothing can refuse; the timer is `unref`'d so it
261
+ can never hold the host loop open (F68's whole point) and cleared on `close` so it cannot
262
+ fire at a child that already left. Verdict semantics are untouched: the FIRST fault already
263
+ named the outcome and the kill only enforces it.
264
+ - **The ReDoS detector mis-parsed an EMPTY character class and passed the hazard through**
265
+ (`src/plan.js`, F49). The scan carried the POSIX rule *"a leading `]` is a literal member"* —
266
+ which is not JS: `[]` is the EMPTY class and `[^]` the any-char idiom, and both CLOSE at that
267
+ first `]`. Skipping it ran the scan past the class's real end and swallowed every quantifier
268
+ after it, so `x[^](a+)+$` read as SAFE while `RegExp.test` on it does not finish in 15s on a
269
+ 31-char body — an agent-authored exit pattern that passed validation and then hangs the run's
270
+ event loop, which is the whole failure F49 exists to make inexpressible. A false NEGATIVE is
271
+ the dangerous direction, so the scan now follows JS. Four cases join `REDOS_BAD`
272
+ (`x[^](a+)+$`, `(([^]+))+`, `(([]a+))+`, `[^]x(a+)+`). **The monotonicity claim is stated
273
+ honestly rather than assumed:** this is a PARSE correction, not the add-rejections-only
274
+ tightening F49's rule contemplates, and it is not rejection-monotonic by construction.
275
+ Measured over 14,862 compilable generated patterns (2,369 previously rejected), exactly 2
276
+ rejections disappear — both pathological `[]`-bearing shapes whose old `true` came from the
277
+ very misparse being fixed, both verified to finish `RegExp.test` in ~0ms — and every entry of
278
+ the checked-in `REDOS_BAD`/`GOOD`/`OVERREJECTED` corpus is unchanged. The claim is "measured,
279
+ no hazard lost", never "monotonic by construction".
280
+ - **The isolate verbs wrote model-authored text into the tree unscrubbed** (`src/tools.js`,
281
+ `src/validate.js`). `ctx_stash`/`ctx_remember` park worker-chosen bytes in
282
+ `<workdir>/.litectx` — a file INSIDE the tree that outlives the step and reads back inline
283
+ through `ctx_recall` — which is the hard line's own sentence (*a record that captures a key
284
+ captures it forever*), one hop away from the spine it was already written for. Both the
285
+ PAYLOAD and the KEY now go through `redactSecrets`, a new export housed beside the inventory
286
+ in `src/validate.js` so detection (`scanSecrets`) and redaction read the ONE
287
+ `SECRET_PATTERNS` list and cannot drift apart. It is IMPORTED, not injected: an injected
288
+ redactor is one a future caller can forget to pass, and this leak has to be inexpressible,
289
+ not optional. Every id-taking verb (`ctx_peek`, `ctx_forget`) scrubs on LOOKUP with the same
290
+ transform, or a stash would be unreachable by the id the worker spelled; and the
291
+ `ISOLATE_MAX_BYTES` cap is measured on the SCRUBBED payload — the one that actually enters
292
+ the store — because a size read off the pre-redaction text reports bytes that never landed
293
+ (the F6 blind-instrument class). The plan flow's own `scrub` collapses onto the same helper.
294
+ - **The gate audit was the one persistent channel still logging worker-chosen text in
295
+ cleartext** (`src/planrun.js`). `gate-audit.jsonl` lives IN THE TREE and is append-only, and
296
+ the `Gate` was built without a `secrets` config — so it ran on bareguard's default-on
297
+ backstop, which covers `apiKey`/`authorization`/`Bearer …`/`sk-…` and nothing else. Measured:
298
+ a `ghp_` token landed in cleartext through every model-authored identifier an action carries
299
+ (an isolate verb's `id`, a path the worker spelled), and masked once the gate is handed
300
+ `SECRET_PATTERNS`. Content was never reachable here — `toolAction` reduces a write to
301
+ `{bytes}` — but the identifiers are raw model text. Third channel, same ONE inventory.
302
+ - **A plan `parse-error` could carry the DRAFT's bytes onto the spine** (`src/planrun.js`, the
303
+ `judge()` precedent one layer up). A `parse-error` red's detail is `JSON.parse`'s own message,
304
+ and V8 quotes a window of the SOURCE inside it — a body of 20 characters or fewer is quoted
305
+ WHOLE, which is exactly the short-prefix secret shapes. That source is the model's draft, so
306
+ the red rode onto `plan-validate`, onto `plan-red`, and back into the redraft prompt. Every
307
+ red's detail is now scrubbed ONCE at the validation boundary, before any consumer reads it —
308
+ the boundary, not the three emit sites, because scrubbing here makes the leak inexpressible
309
+ while scrubbing per consumer makes it something the next consumer has to remember.
310
+ - **A strategy line could name a tool the worker was never granted** (`src/tools.js`,
311
+ `src/planrun.js`) — F19 inverted, and worse than silence: the model spends rounds reaching
312
+ for a menu entry that does not exist. The strategy prose was gated per COMPONENT, so any one
313
+ isolate verb lit a paragraph prescribing `ctx_peek` (a COMPRESS verb), either retrieval verb
314
+ lit the two-step `ctx_recall` → `ctx_get` recipe, and any one select verb named all three.
315
+ New `strategyFor(granted)` assembles the same prose SENTENCE BY SENTENCE, each clause gated
316
+ on its own verb, with the select paragraph's `a, b, and c` grammar preserved. The full-menu
317
+ text is unchanged BY TEST, not by comment: `strategyFor(TOOL_MENU)` is asserted
318
+ byte-identical to the concatenated paragraphs, so a partial grant can only ever see LESS,
319
+ never different.
320
+ - **The eight palette verbs had no failure path** (`src/tools.js`). `ctx_get` has always
321
+ wrapped its own failures into a refusal RESULT and emitted them; the eight verbs P added
322
+ (`impact`, `related`, `recent`, `compress`, `stash`, `peek`, `remember`, `forget`) threw
323
+ straight out of the tool loop instead — and a thrown call is the most invisible result there
324
+ is, indistinguishable from a verb that was never reached, so the worker's fall-back to a
325
+ whole-file read reads as a free choice instead of a forced one (the F18 blindness rule). A
326
+ shared `guarded()` wrapper now hands the worker a refusal naming the real cause (a locked
327
+ store, a corrupt index) and emits `outcome:'error', bytes:0` to the spine — one round instead
328
+ of the run, and never a rethrow (L1: throws stay reserved for the BA-4 param-guard class).
329
+ Its field reader is deliberately defensive, since a field reader that throws would take the
330
+ emit down with it — the hole this closes.
331
+ - **`validatePlan` could THROW on plain parsed data** (`src/plan.js`). Its contract is that
332
+ every failure is a named red; `Array.isArray(job.writeScope)` admitted the array and asked
333
+ nothing about its MEMBERS, so the next line's `globToPrefix` hit `scope.replace is not a
334
+ function` and a `TypeError` escaped the validator. The fail-closed guard now asks the same
335
+ `isNonEmptyString` question of each member that `legalScopes` already asks of the same field.
336
+ - **Three enforced rules the drafter prompt never stated** (`src/planrun.js`). A bound the
337
+ validator reds but the prompt omits is a round burnt on a rule the drafter had no way to
338
+ know: `attempts` said "integer" while `validatePlan` reds outside `1..capRuns`, and BOTH
339
+ `step-scope-escape` reds (a `target` outside its own step's narrowed scope; a `tree-changed`
340
+ scope disjoint from it) were enforced silently. `planPrompt` now takes `capRuns` from the
341
+ SAME source the validator bounds against — one number, so prompt and validator cannot drift
342
+ — and states both scope rules in its own voice.
343
+ - **The `check-passes` exit example was the one malformed line among four** (`src/planrun.js`).
344
+ It carried the enumerated check menu INSIDE its JSON `"name"` string, so the example the
345
+ drafter is most likely to copy did not parse. The menu now sits BESIDE the example as its own
346
+ copy-it-character-for-character line, and the regression test parses EVERY exit example, one
347
+ per `EXIT_TYPES` entry, rather than the single `tree-changed` line it happened to pick.
348
+ - **The U close scripts' suppression rules missed two escapes that the paid runs actually
349
+ emit, and mislabelled a broken instrument as a timeout** (`scripts/u-litectx-close.mjs`,
350
+ `scripts/u-spawner-close.mjs`).
351
+ - The JSDoc WILDCARD (`{*}`, `{?}`) is `any` under a spelling the `any` rule cannot see — it
352
+ contains no letters. Measured against this repo's own `tsc` (`--noEmit --strict --allowJs
353
+ --checkJs`): a naked `function f(v)` reports TS7006, while `@param {*}`, `@param { * }`,
354
+ `@param {?}` and a `@typedef {*} Loose` used from a `@param {Loose}` each report ZERO
355
+ errors. So the new `any-star` rule matches the tag GENERICALLY (`@\w+`) rather than from a
356
+ list of tags — a list of tags is a list of holes. `{*[]}` / `{Array<*>}` are deliberately
357
+ NOT matched: measured, they still error on non-array use, so they are `any[]`, which the
358
+ `any` rule already covers.
359
+ - The CAST rule read its type body with `[^}]*`, which an inner brace closes early — so
360
+ `/** @type {{ a: number }[]} */ (expr)` walked straight through. That shape is not
361
+ hypothetical: it appears in this repo's own paid U-run diffs (`run3-ms3wawub`,
362
+ `run-ms5uxhej`). Now `.*`, which is bounded by construction — every rule is tested against
363
+ ONE diff line at a time.
364
+ - A `spawnSync` that produced NO exit code was reported as *"timed out"* whatever killed it.
365
+ Measured shapes (node 22): a timeout is `{status:null, signal:'SIGTERM',
366
+ error.code:'ETIMEDOUT'}`, a `maxBuffer` overflow is the same with `ENOBUFS`, an external
367
+ kill is `{status:null, signal:'SIGKILL'}` with no error at all. Collapsing them hands the
368
+ operator a wrong cause and hides a broken instrument behind a bound that never fired (the
369
+ casualty-vs-evidence class); each now names itself. The spawner close also gains the
370
+ sibling's `maxBuffer` (1 << 28) — a red pytest run prints every traceback, and the overflow
371
+ does not truncate, it SIGTERMs the child, i.e. a real red arriving dressed as an instrument
372
+ stop.
373
+ - **A close child ending in `process.exit()` drops its own queued stdout, and the runner read
374
+ the short capture as a short close** (F71, `tests/staged-run.test.js`,
375
+ `tests/ralph.test.js`). One test's staged-close fixture arrived truncated about 1 run in 250
376
+ under load — stream ending mid-line, the `FAILED:` names gone, exit code still 1 — which is
377
+ the F28 failure-names-lost class arriving nondeterministically. Root cause is the CHILD's:
378
+ node discards whatever is still queued on its stdout pipe when the process calls
379
+ `process.exit()`, and the parent then sees a clean EOF, a real exit code and no fault, so a
380
+ lost tail is indistinguishable from a close that simply printed less. Measured 7/500 shorts
381
+ with `process.exit(1)` against 0/500 with `process.exitCode = 1` on the identical fixture, and
382
+ 12/12 deterministically with a 50ms reader stall. The earlier reading in that test's comment —
383
+ *"pipe writes are synchronous on Linux, so there is nothing pending to drop"*, and *"only the
384
+ runClose path loses bytes"* — is REFUTED on both halves and rewritten with the measurements
385
+ that refuted it. What shipped is the fixture's `process.exitCode` spelling (now named as the
386
+ fix it is, not a free tidy), a precondition asserting the stage's LAST line reached the gap
387
+ so a short arrival can never again surface as a confusing `gapKeep` failure three assertions
388
+ later, and a whole-capture guard pinning the half the RUNNER owns: past the pipe's 64KB
389
+ capacity, with the host loop frozen mid-stream, a close that exits cleanly is captured WHOLE
390
+ (mutation-checked — it kills a reader that caps accumulation at 64KB, 4/4). The residual is
391
+ named, not hidden: the production close scripts still end in `process.exit()`, safe today
392
+ only because they self-cap their output at 40 lines, which is inside the measured-immune
393
+ band. See F71.
394
+
395
+ ### Changed
396
+ - **BREAKING for adopters — `runClose` and `runStages` are async and return Promises**
397
+ (`src/ralph.js`). The close child is awaited instead of `spawnSync`, so a running close no
398
+ longer freezes the host event loop (F68 — measured: a 3-stage close went from 0 host ticks
399
+ during 3.5s of close to 126 ticks with a worst gap of 26ms). Every close semantic is
400
+ byte-identical, verified by a 25-case old-vs-new differential: timeout signal (SIGTERM →
401
+ `timed-out`), 1 MiB per-stream output ceiling (`ENOBUFS` → broken-close, deliberately not
402
+ widened — an over-long close must not be judged on truncated output), gap shape/bounds,
403
+ exit bands, redaction, cwd, stdin EOF. The one visible difference: a spawn fault's detail
404
+ prose says `spawn` instead of `spawnSync` (nothing parses it).
405
+ - **BREAKING for plan authors — `check-passes` now requires a write-class verb (`write`/`edit`)
406
+ on the SAME step** (`src/plan.js`, `exit-illegal`). A shape that validated before is rejected
407
+ now: a read-only "verify" step that carries a check. Measured before it was built (P-read runs
408
+ ms4l5p6w / ms57zr7c): 4 of 4 drafted plans put every `check-passes` on a read-only verify step,
409
+ and both runs stalled to cap on a byte-identical gap delivered to a worker that could not edit.
410
+ A failing check's gap is re-delivered to THAT step's own worker, so the step must be able to act
411
+ on it — a read-only one is a mailbox with no hands. The check belongs on the step that fixes;
412
+ the run's final verification is the operator's close, which the agent never authors. The drafter
413
+ prompt states the same law, so prompt and validator can never disagree. The red is SUPPRESSED
414
+ when `tools` failed to parse: the step's hands are unknowable then, and charging the ledger with
415
+ a violation derived from a false default would double-red one defect.
416
+ - **BREAKING for an adopter holding an approval for an omitted-`tools` spec — the spec hash is
417
+ taken over the RESOLVED spec** (`src/job.js`, MED-1). `tools` is optional and its absence
418
+ means the whole `TOOL_MENU`; `plan.js` and `planrun.js` resolve it with exactly that
419
+ predicate at runtime, so an omitted-`tools` spec's ceiling GREW every time the menu did while
420
+ its bytes never moved — and that widening was unsigned. `jobSpecHash` and `checkApproval` now
421
+ canonicalize the same resolved form, with an omitted `tools` filled in as the concrete current
422
+ menu, so a signature pins WHICH menu it covers and a menu change flips the hash straight into
423
+ the existing refuse-until-reapproved machinery. A spec that named its own ceiling hashes
424
+ byte-for-byte as before — no migration for a signature already in flight — and an approval
425
+ minted for an omitted-`tools` spec re-signs once. Writing today's full menu into a spec that
426
+ omitted `tools` is hash-NEUTRAL, which is the point: the two spell the same ceiling. The
427
+ resolve sits INSIDE each path's existing throw guard, because a spread runs getters.
428
+ - **The wall PAUSES the run; it never cuts the grade** (`src/planrun.js`, `src/ralph.js`, W-2 —
429
+ hamr: *"when time is up, keep the grade we already have and stop"*). The close is never
430
+ bounded by the wall and always runs to completion: a deadline that kills grading leaves the
431
+ run unreadable after the money is already spent (the F45 class, generalized from money to
432
+ time). What the wall stops is the START of new work — past the deadline the close-fix loop
433
+ opens no further iteration, and the same rule covers a step that would begin already expired.
434
+ The run then stops on the verdict the last close minted, and nothing after the deadline is
435
+ allowed to change it. The iteration it declines was guaranteed worthless anyway: the metered
436
+ round check stops the fix worker on its FIRST round so it writes nothing, and the iteration
437
+ then re-runs the FULL staged close over an unchanged tree to mint the verdict already on the
438
+ record — `capRuns` times over. The stop is a decision-ready `wall-halt` carrying the verdict
439
+ that stands, how much of the loop was actually spent, and a progress TREND read off the two
440
+ most recent close gaps: byte-identical is `stalled`, different is `moving`, and one grade
441
+ alone is `unknown` — never rounded up to `stalled`, which would recommend rewriting a goal on
442
+ no evidence (F6's rule applied to a trend). The trend exists because the two levers point
443
+ opposite ways, and both are spec edits whose new hash needs re-approval: raise `maxWallMs`
444
+ and resume (resume-to-cap — the stop IS the checkpoint) or revise the goal so the work fits
445
+ the time; abandon is the third. `cutMidCall` still splits the two readings, so which
446
+ instrument saw the deadline stays on the record, and the step-site record names its two
447
+ caps SEPARATELY: `capRuns` is the RUN's attempt cap — what the fix-site record has always
448
+ written — and `stepAttemptCap` is the step's own tightened cap, the number that actually
449
+ bounded THIS step. Previously the one key carried both meanings depending on which site
450
+ emitted it, leaving a reader of two records of the same type no way to tell which. It is
451
+ not a universal bound and is not claimed as one: a STEP already under way still runs its
452
+ own exit checks after the deadline.
453
+ - **The close child no longer inherits the operator's credentials** (`src/ralph.js`,
454
+ `CLOSE_ENV_DENY`, PRD v1.41 — hamr: *"strip it when unnecessary, minimize unneeded
455
+ exposure"*). `runClose` built the child's env as `{...process.env}` minus
456
+ `NODE_TEST_CONTEXT`, and that child is not an ordinary subprocess: `npm test`, `pytest`,
457
+ `tsc` execute **worker-authored code**, with network, in the tree they are about to judge.
458
+ Nothing that judges a tree needs a provider key, so the exposure bought nothing. Two rules,
459
+ deliberately both: the KNOWNS (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`,
460
+ `GOOGLE_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `GITHUB_TOKEN`, `GH_TOKEN`, `NPM_TOKEN`, the whole
461
+ `AWS_*` prefix) exist only for names the shape rule cannot see, and the NAME-SHAPE rule —
462
+ anything ending `_API_KEY`/`_SECRET`/`_TOKEN`/`_PASSWORD`/`_CREDENTIAL(S)`, case-insensitive
463
+ — is the load-bearing one, because a literal denylist is a chase every new provider extends.
464
+ A COPY is stripped, never `process.env`: the host keeps the key bare-agent spends every
465
+ round. Placed at `runClose`, the single close-spawn seam, so `runStages` and every derived
466
+ `check-passes` inherit the same blinding — one instrument, never two. **Stated as what it is:
467
+ exposure reduction, NOT a sandbox.** bareloop runs locally as the operator's OS user, so
468
+ worker-authored code still has that user's file access (including the shell profile that
469
+ exports the variables just stripped) and the network. Real containment is a different
470
+ mechanism, unbuilt, and claimed nowhere; README and `bareloop.context.md` carry the same
471
+ boundary note. Arbiter territory: no drafted plan or config can express, widen, or opt out.
472
+ - **Scripts — unshipped, but the surface a user actually runs.**
473
+ - The outside watchdog's DEADLINE kill is ACTIVITY-AWARE (hamr: *"the kill from outside should
474
+ check for activity/bytes or other markers for activity, not a silent kill"*). Past the
475
+ deadline it kills only when the spine has ALSO been flat for a full stall-fuse window;
476
+ past-deadline-but-still-writing logs LOUD every poll and is never killed — the in-process
477
+ fuses and the money cap own bounding a run that is alive, this guard owns processes that are
478
+ dead. Every kill states the trigger, the deadline arithmetic and the marker's value and age
479
+ before the signal goes out. ONE marker, spine bytes: a CPU marker (`/proc` utime+stime) was
480
+ built alongside it and REMOVED after measuring broken in BOTH directions — a close runs its
481
+ suite in a CHILD whose ticks are credited only at reap, so a live close read DEAD; and
482
+ `run-u`'s own 1s lag sampler wakes the watchdog, so a wedged run read ALIVE at production
483
+ constants (its kill tests passed only at a dead-window:poll ratio of 5:1 where production
484
+ runs 60:1). hamr: *"keep what is simpler/available"*.
485
+ - The wall grace is `stages × closeTimeoutMs`, and `run-u` passes it. The watchdog's own
486
+ default is ONE stage, so a 4-stage close left on the default put the outside deadline at
487
+ wall+15min while a legal close can still be mid-verdict at wall+60min. A spec that sets no
488
+ `maxWallMs` now OMITS `--wall-ms` and names the unbounded choice out loud on both sides of
489
+ the process boundary, instead of passing the string `undefined` — which fell back to the
490
+ null default and silently disarmed the deadline trigger while the header still claimed a
491
+ wall. Same NaN-disarms-a-guard class, one flag over.
492
+ - `run-u`'s spine leak check is a TRIPWIRE, not a note in the margin: on any hit the run exits
493
+ **3** (distinct from 2 = operator/config and 1 = stale `--approve`) and the BRIDGE is NOT
494
+ written — a spine carrying a secret must never graduate into a reusable artifact that
495
+ outlives the run. Count and path only, never the matched content: echoing it to stdout is
496
+ the same leak, one hop on.
497
+ - `run-u` can no longer be killed by its own guard's startup. The watchdog path was taken
498
+ from `new URL(...).pathname`, which stays percent-ENCODED — a checkout under a directory
499
+ with a space hands `spawn` a `%20` path that does not exist, and the guard dies at second
500
+ zero on the one run it exists to protect (`fileURLToPath` now, the spelling the sibling
501
+ scripts already use). And a spawn failure arrives as an EVENT: an unhandled `'error'` on a
502
+ ChildProcess is an uncaught exception, so a guard that could not start would have ENDED the
503
+ paid run. It is handled LOUDLY instead — the run continues UNGUARDED from outside, under
504
+ its own fuses, wall and money cap, and says so, because a reader must never mistake a
505
+ missing watchdog marker for "the guard was watching and never fired".
506
+ - **All nine remaining hand-rolled secret scans in `scripts/` now call the canonical
507
+ `scanSecrets`** (11 call sites, 2 already canonical). F40 parked the conversion; hamr
508
+ retired the park on his word, and both risks it guarded were closed by MEASUREMENT rather
509
+ than assumption — equivalence on 900 spiked real-spine cases (670 hits, 0 divergences,
510
+ with the comparison demonstrated ABLE to diverge), and archived evidence that can no longer
511
+ shift. FINDINGS addendum sits on the F40 block; the JSONL spine-reader park is untouched.
512
+ - **litectx ^0.29.1 → ^0.31.0** (LC-3 delivered, yield-only shape — atomicity intact,
513
+ `setImmediate` between parses). Every worker's index call now passes `{ yield: true }`, so the
514
+ F66 stall fuse's timers, the wall clock and the lag sampler stay alive through an index pass
515
+ instead of dying inside one long block. Consumed on the BINDING max-block clause (189–252ms,
516
+ n=4, 2× headroom) **with the miss on record**: liveness read 54–58% on our fixture against a
517
+ 60% proxy bar, and the criterion was NOT re-amended after measurement.
518
+ - bare-agent 0.34.0 → 0.35.0 (BA-19 delivered: `deadlineMs` total-call bound, `EDEADLINE`,
519
+ `context.bound` discriminator, terminal by design; bareloop wiring PARKED — the F66 fuse
520
+ already self-heals this class above the transport).
521
+
522
+ ### Removed
523
+ - **BREAKING — the legacy operator-authored `steps[]` path is deleted** (PRD v1.32, hamr:
524
+ *"i just want to get rid of it"*). `steps[]` is the shape where the OPERATOR hand-writes the
525
+ workflow steps — the exact thing bareloop exists to remove (*"there shouldn't be user
526
+ authoring anywhere"*, v1.28) — and it had been co-existing scaffolding since Layer 2 landed,
527
+ forcing every downstream schema decision to carry two spellings.
528
+ - **Gone from the public surface:** `interpret`, `STALL_REDS`, `validateConfig`, `diffPaths`,
529
+ `extractRules`, `MAX_RULES`, `MAX_RULE_CHARS`, `LOOP_SHAPES`, `SLOTS`, `VERBS`, `CLASSES`,
530
+ `STEP_MODES`, and `runJob`'s `opts.target` / `opts.execCmd`. **config-v1 is fully removed**,
531
+ not merely declared dead (F22 declared it): the drafted-workflow-config schema, its knobs,
532
+ the drafting call, the rules extractor, and the hitl draft-PR mechanics went with the path
533
+ that consumed them. A spec still carrying `steps[]` reds `shape-retired:steps` **by name**,
534
+ so an old spec is told what happened instead of half-running.
535
+ - **What it costs, stated plainly:** `hitl` is not dead code — `jobs/litectx-maintainer.json`
536
+ ran 6 times and opened a real draft PR once, and it is the only job that produced the
537
+ product's actual end state (a change for a human to merge) rather than a benchmark grade.
538
+ **That job goes dark** until the plan shape implements hitl. `soft-green` was never
539
+ implemented past its declaration. **How both come back is deferred to Layer 3 and
540
+ deliberately undecided** — by standing doctrine graduation between rungs is a REWRITE, never
541
+ a copy, so keeping the old implementation alive for a later rung preserved code that would
542
+ never have been copied.
543
+ - **What survived, and where it lives now:** the machinery the plan flow imports was housed in
544
+ the deleted files but was never part of the dead path. The worker tool menu (`TOOL_BY_VERB`,
545
+ the ctx tools, the gate action translator, the personas and strategy lines) moved
546
+ `src/interpret.js` → **`src/tools.js`**, which is what it always was; the fence and secret
547
+ primitives (`globToPrefix`, `scopeContained`, `sweepSecretLiterals`, `SECRET_PATTERNS`,
548
+ `scanSecrets`) stay in `src/validate.js`, now purely the shared primitives both validators
549
+ go through. The ledger's prose verb-sniff list was relocated VERBATIM rather than re-derived,
550
+ so incident classification is byte-identical across the deletion.
551
+ - **Coverage was re-pointed, not dropped.** The suite goes 616 → 395 tests because the tests
552
+ of a deleted path die with it; every guard that OUTLIVED the path was moved rather than
553
+ deleted — the approval binding, the pre-token smoke and four money-contract readings are
554
+ ports onto the plan shape in `tests/run.test.js`, the fence/secret assertions were re-pointed
555
+ at the primitives themselves in `tests/validate.test.js`, and the close-body rules
556
+ (`judged`, `gapKeep`, quotes-in-cmd, secret sweep) now address the plan close. Guards that
557
+ simply moved house are named where they now live: `close-unsupported`, `broken-close`,
558
+ `already-green` and the close's cwd in `tests/planrun.test.js`; `close-crashed` and the
559
+ close-output scrub in `tests/ralph.test.js`.
560
+
561
+ ### Added
562
+ - **T — a run has a CLOCK, and time is a material the planner is told as a balance**
563
+ (PRD v1.27/v1.29, design record 2026-07-26 + addenda 1–3). Money was the only bound in the
564
+ system; nothing bounded TIME, and job #5's own plan authorised ~630 worker turns (F56).
565
+ - `maxWallMs` joins the SIGNED job spec (`src/job.js`) — integer ms, **no default**, floor
566
+ one close timeout (`MIN_WALL_MS`). A run without it is time-unbounded *by explicit
567
+ operator choice*, never by an unnoticed fallback (F45: a defaulted cap is a silent second
568
+ ceiling). Adding it changes the spec hash — re-sign.
569
+ - New `src/clock.js` (`createClock`, `PROVIDER_TIMEOUT_MS`, `MIN_CALL_TIMEOUT_MS`): one
570
+ wall clock per run. Enforcement is a **between-round deadline** because `loop.stop()`
571
+ cannot cut an in-flight call — F61 measured it fired at 500ms and returned at 4,018ms —
572
+ so the honest enforced worst case is `maxWallMs + closeTimeoutMs` and BOTH numbers are
573
+ reported (`wall-clock`, `report()`), never the flattering one. Unbounded reports as
574
+ `null`, never `0` and never `Infinity` (F6 extended to time: an unknown duration is
575
+ reported as unknown).
576
+ - `runPlan` emits `wall-clock` (at start), `wall-bounded` (an attempt cut by the deadline —
577
+ the same seam the round bound rides; the partial work is judged and its gap fed forward)
578
+ and terminates `wall-halt` with a decision-ready escalation naming the requested vs
579
+ elapsed ms. The stop IS the checkpoint: raise `maxWallMs` and rerun.
580
+ - **Materials at draft** — `runPlan` emits `materials` and the plan/replan prompt now
581
+ carries `{ balanceUsd, remainingMs }`: what is LEFT, as a balance, never a per-round rate
582
+ (hamr's correction, PRD v1.29). F57 measured a 150× spread on verification-gap duration,
583
+ so a per-round constant describes almost no real round and a rate framing creates a
584
+ rush-or-fake incentive. The agent sees both axes; the arbiter is untouched — materials go
585
+ to the PLANNER at draft, and the worker stays blind (no budget, no close, no clock).
586
+ - **A — the replan trigger gains a VARIANCE axis** (design record §3.4): a step that has
587
+ consumed `VARIANCE_THRESHOLD` (0.5, hamr's number) of the run's *remaining* money or time
588
+ with its exits still red is stopped at the head of its next attempt and routed as a
589
+ `step-variance` replan — the planner re-allocates what is left instead of the step eating
590
+ the run. Emits `variance {step, iteration, threshold, moneyShare, timeShare, axis}`. The
591
+ hard ceiling of **ONE** replan is unchanged (unlimited replanning launders thrash as
592
+ adaptation, v1.12); a second `step-variance` after the replan is spent is a STOP.
593
+ - **Recorded honestly: this trigger is INERT on every workload with data (F63).** Replayed
594
+ across 18 archived spines / 54 steps / 101 judged attempts it would have fired **0
595
+ times** (near misses 0.35 · 0.35 · 0.40 · 0.45), and the premise it was built on —
596
+ F56's *"a replan has never fired in the entire programme"* — is FALSE: eight replans had
597
+ already fired, four days before F56 was written.
598
+ - **Threshold decided at 0.5 with that inertness on the table** (hamr, *"keep 0.5"*, PRD
599
+ v1.31). It is therefore a guard set ABOVE the observed population, not a tuned trigger —
600
+ moving it to 0.35 to catch those four points would be fitting the number to the data.
601
+ - **The staged close — the user authors the DESTINATION, never the road** (PRD v1.28, hamr:
602
+ *"there shouldn't be user authoring anywhere, that defies the point of bareloop"*). `checks[]`
603
+ is retired outright: a spec that still declares it reds `checks-derived` **by name**, not a
604
+ bare `unknown-field`. In its place the operator's close becomes an ORDERED LIST of NAMED
605
+ STAGES, and the check menu the agent picks from is DERIVED from that list — nobody
606
+ hand-authors a check per job, ever.
607
+ - `close: [{ name, cmd, expect, judged?, gapKeep?, offer?, needs? }, ...]` (`src/job.js`) —
608
+ kebab-case unique names, every stage a `predicate` command body (a rubric/hitl stage is
609
+ inexpressible by shape); `needs` must name EARLIER stages and is incoherent with
610
+ `offer: false` on the same stage (a chain with nothing to reach it). A single `predicate`
611
+ close object is legal shorthand the plan flow adapts into a one-stage list; a
612
+ `gold`/`rubric`/`hitl` object close still validates (the declared-but-locked verdict
613
+ classes) but the plan flow refuses it at runtime as `close-unsupported` — it names no
614
+ command to run. `checkMenu(close)` (`src/job.js`) derives the offerable stages, each
615
+ carrying the ordered chain (prerequisites, then itself) that a `check-passes(name)` pick
616
+ must run.
617
+ - `runStages` (`src/ralph.js`) runs a stage list as ONE close: first red renders the verdict,
618
+ later stages never run; the gap names the failing STAGE, never a culprit file (v1.12/F28
619
+ untouched); the secrets scrub runs on every stage.
620
+ - `runPlan` (`src/planrun.js`) wires the close precheck, the check-menu preflight (now
621
+ emitting `check-menu {offered, hidden?, meaning?}` — a partial menu is a stage that cannot
622
+ stand alone as a ruler, never a failure), the `check-passes` seam, the outer close, and the
623
+ fix loop through `runStages`; `validatePlan` (`src/plan.js`) derives its legal
624
+ `check-passes` names from `checkMenu(job.close)` instead of a separately-signed list. Every
625
+ job spec in `jobs/` migrated to the staged shape.
626
+ - **Two decisions taken 2026-07-26 (hamr, *"agreed on both, validate your claims"*), built and
627
+ validated:**
628
+ - **Layer R's red-set is the FAILING STAGE's, per attempt.** A staged close has N
629
+ `gapKeep`s, so `createRoot().observe()` (`src/root.js`) now takes `redStage`/`redKeep`
630
+ and REFUSES to compare red-sets across a stage change (a different wall is a different
631
+ failure — the same bucket as one-known-one-UNKNOWN, never a fire). `root-injected` gains
632
+ an optional `redStage`. **Measured, not assumed:** the rejected alternative — one union
633
+ `gapKeep` pattern fixed for the run — was rejected because job #4's two close stages are
634
+ near-clones of one grader (7 of 12 kept-line templates byte-identical), so a union
635
+ kept-set can read identical across a stage change and false-fire, which "inert when not
636
+ stuck" forbids.
637
+ - **The grading stage is not lendable.** Of the six specs in `jobs/`, the four with a final
638
+ `verdict` stage set `offer: false` on it; the other two have no grading stage at all and
639
+ hide a `changed-from-seed` PRECONDITION instead. The flag is kept **per-spec**, not as a
640
+ schema rule — "the last stage is the grade" holds where it applies and is unproven in
641
+ general. `check-menu` preflight always
642
+ announces the derived menu either way, so a forgotten flag is visible in the run's own
643
+ record; a test reads every spec in `jobs/` and reds if any offers a stage NAMED `verdict`.
644
+ The guard is stated over the convention that exists rather than over position — the fifth
645
+ spec proved "the last stage is the grade" is not structural.
646
+ - **Scripted-provider evidence only — the staged close has not yet run against a real
647
+ model.**
648
+
649
+ ### Changed
650
+ - **The `tree-changed` scope is now a MENU the agent picks from, not a glob it authors and
651
+ guesses the shape of** (design record 2026-07-26 §4, hamr's *"did agent choose from a list or
652
+ not?"*). F60 measured **13 of 18 validator reds as this one class**: the agent writes
653
+ `{"type":"tree-changed","scope":"src/*.js"}`, the grammar accepts only a trailing `/**` or
654
+ `/*`, and every red costs a redraft call. Teaching the rule in the prompt or widening the
655
+ grammar both leave the agent authoring free text; enumerating makes an illegal scope
656
+ **inexpressible**.
657
+ - New `legalScopes(writeScope, dirs, cap?)` (`src/plan.js`) is the ONLY producer of scope
658
+ menus: the signed fence entries (always offerable — they cover directories a step has not
659
+ created yet) plus the real directories beneath them, fence-filtered, deduped,
660
+ shallowest-first, capped at `MAX_SCOPE_MENU` (24). Because it filters by the fence, a menu
661
+ entry that escapes containment cannot be constructed rather than being caught downstream.
662
+ - `planPrompt` gains a `scopes` parameter and lists the values verbatim, one per line;
663
+ `validatePlan` gains `opts.scopes` and accepts **membership only**. Omitting the option
664
+ derives the menu from the signed `writeScope` — fail-CLOSED, never a free-text containment
665
+ fallback (F50: a silently-ignored optional param is the blind-instrument class).
666
+ - An off-menu scope **splits by cause**: outside the signed fence stays `scope-escape` (a
667
+ behaviour signal the ledger keys on), in-fence-but-unoffered is `invalid-value` carrying the
668
+ menu so the redraft can choose. Collapsing them would launder an attempted fence escape into
669
+ a typo class.
670
+ - `runPlan` emits `scope-menu {offered, truncated, offerableCount?, cap?}` — no silent caps.
671
+ - `globToPrefix` is **untouched**: this changes what the agent is offered, never what the
672
+ fence enforces.
673
+
674
+ ### Fixed
675
+ - **F64 — a wall-derived call timeout was reported as a network failure**, so the operator's own
676
+ governance stop was filed as a transport casualty (never evidence, retried, carrying the F44
677
+ `spendComplete:false` floor) and the human was told to fix the provider binding. `clock.callTimeoutMs()`
678
+ bounds each provider call by the time left, and bare-agent's `TimeoutError` carries a code but no
679
+ category — which the runner defaulted to `provider-red` at four separate seams.
680
+ - New `isWallTimeout(err, clock)` + `TIMEOUT_CODE` (`src/clock.js`) is the whole discriminator: an
681
+ `ETIMEDOUT` on an **expired** clock is the wall's own stop. One `categorize()` inside `runPlan`
682
+ is now the ONLY place an uncategorised throw is classified, so the worker seam, the native seam,
683
+ the scout/drafting relay and the close-fix loop cannot drift apart. A throw that named its own
684
+ category still keeps it.
685
+ - Every wall stop emits ONE record shape, with `cutMidCall` splitting a deadline that landed
686
+ inside a provider call from one read between steps; `ralph` gained a `wall-halt` decision entry
687
+ so a governance stop no longer reads as "the middle broke", and its options are raise-the-cap,
688
+ not retry-the-provider. `runPlan` can now return `wall-halt` from the drafting and fix paths too.
689
+ - **The controls are part of the fix:** the same error with time still on the clock stays
690
+ `provider-red` (a real dead socket is never laundered into a governance stop), and an unbounded
691
+ run can never produce a `wall-halt`. Stated limit: a genuine hang on a run whose wall has ALSO
692
+ passed reads as `wall-halt` — the run is out of time either way and the remedy is identical.
693
+ - Also closed a coverage hole found en route: the **between-steps** wall terminal had no test at
694
+ all since it shipped. `runPlan` gains an injected `now` (the seam `createClock` already exposes)
695
+ because `maxWallMs`'s floor is one close timeout, so real time cannot exercise the terminal.
696
+ - **F59 — the SCOUT returned nothing on 15 of 18 runs, so the planner drafted blind.** The
697
+ round bound is enforced from the metering callback (`loop.stop`), so a scout still calling
698
+ tools on its last round was halted mid-tool-use and its `text` was empty: it spent its whole
699
+ allowance exploring and never wrote the survey that is its only deliverable. `planPrompt`
700
+ then substituted `(no scout notes)` — a legal prompt, so the failure was silent. Measured
701
+ across every archived plan-v1 run: 15 of 16 bounded scouts produced 0–86 bytes; both scouts
702
+ that finished on their own produced full 6–8KB surveys. Two fixes:
703
+ - **The summary round is now reserved.** A bounded-and-short scout gets one **toolless**
704
+ round on the same conversation (`askFrom`), where text is the only possible output — a
705
+ mechanical guarantee, never a prose request (F19/F37: a persona pacing mandate was violated
706
+ 6/6). Native is excluded by design: a toolless native session reports no cost (F48), so a
707
+ recovery round there would be unmetered spend.
708
+ - **An empty survey is now loud.** `scout-empty` is a named spine event. It is never a halt —
709
+ 3 of 5 archived greens had an empty scout, so failing the run there would be a worse error
710
+ than the one being fixed.
711
+ - `scoutRounds` is now a shell-owned option (like `capRuns`/`maxStepRounds`).
712
+ - **Confirmed live:** the patient that returned 0 bytes returned 5379 bytes after the fix.
713
+ F60 then measured what that survey is worth: it carries ~15× more real repository
714
+ specifics into the plan, and roughly triples the plan's declared work.
715
+
716
+ ### Changed
717
+ - **bare-agent 0.33.1 → 0.34.0** (BA-18 delivered). The provider now bounds socket
718
+ **inactivity** itself (`timeoutMs`, 10-minute default, per-call overridable, rejecting with a
719
+ retryable `TimeoutError`/`ETIMEDOUT`), so a silently-dropped socket is a 10-minute error
720
+ instead of a ~2-hour hang. The harness-side stale-socket guards in `scripts/run-screen-types.mjs`
721
+ and `scripts/probe-materials.mjs` are deleted as superseded. **`Retry` stays deliberately
722
+ unwired:** a BA-14 `EPIPE` dies on write so the request never reached the API and a retry is
723
+ free, but a timed-out request may have been accepted and processed — retrying pays twice for
724
+ one completion. A trip is a clean `provider-red`, and the escalation's own "retry the run"
725
+ option recovers it without the double-bill.
726
+ - `planPrompt` is exported from `src/planrun.js` (in-repo seam for probe drivers; **not** added
727
+ to `src/index.js`, so the adopter surface is unchanged). Probes now import the shipped prompt
728
+ instead of copying it — the N3 pre-probe's verbatim copy was diffed against source and found
729
+ identical, so F51–F55 stand, but a copy is a fixture and new drivers do not take that risk.
730
+
731
+ ## [0.5.1] — 2026-07-24
732
+
733
+ ### Added
734
+ - **Layer R wired into the plan-v1 flow** (F50). The within-run ratchet (`layerRoot`) now
735
+ engages in `runPlan` — one root per EXECUTE step (red-set = the exit evaluator's own gap)
736
+ AND in the outer close-fix loop (red-set = the close's own `gapKeep`) — not only on the
737
+ legacy `steps[]` path. So a plan-shape job can emit `root-injected` and the pre-registered
738
+ ON-vs-OFF default-flip read becomes possible on the accepted surface. The write-tee is wired
739
+ so same-path target rewrites are visible to the detector. Still OFF by default (`layerRoot:
740
+ false`, F41). Excluded on native/clipipe (no `onToolResult` seam — F48 fallback surface).
741
+
742
+ ### Fixed
743
+ - **F49 (security-hardening): the agent-authored `artifact-written` regex can no longer hang
744
+ the exit evaluator.** `plan.js` rejects nested-quantifier ReDoS patterns (`(a+)+`, `(\d*)*`,
745
+ `(x+){1,}`, and redundant-wrapper forms like `((a+))+` / `(?:(a+))*` / `(((a+)))+`) at the
746
+ validation gate, before any tokens burn. The wrapper class was a review-caught false negative
747
+ in the first (flat-only) scan — each measured to hang `RegExp.test` >8s on ~29 chars — closed
748
+ by propagating an inner repeat up through the enclosing group; the change is MONOTONIC (it only
749
+ ever adds rejections, the fail-safe direction, and cannot introduce a new false negative). LOW
750
+ self-DoS, no arbiter compromise; input-bounding was rejected as theater (a 33-char body hangs)
751
+ and a JS regex timeout as disproportionate (worker/RE2 dep).
752
+ - **F50: `runJob` no longer silently ignores `layerRoot` for plan-shape jobs** (was accepted
753
+ and dropped — an advertised no-op).
9
754
 
10
755
  ## [0.5.0] — 2026-07-23
11
756