bareloop 0.3.0 → 0.4.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 +83 -0
- package/README.md +12 -5
- package/bareloop.context.md +35 -2
- package/package.json +1 -1
- package/src/interpret.js +167 -3
- package/src/ralph.js +7 -1
- package/src/root.js +305 -0
- package/src/run.js +48 -19
- package/types/interpret.d.ts +9 -1
- package/types/ralph.d.ts +1 -0
- package/types/root.d.ts +96 -0
- package/types/run.d.ts +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,89 @@ 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]
|
|
9
|
+
|
|
10
|
+
## [0.4.0] — 2026-07-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Layer R — the root, the within-run ratchet (`src/root.js`; design record
|
|
15
|
+
`docs/plans/2026-07-19-layer-r-design.md`, interview-locked 2026-07-19).** The shell
|
|
16
|
+
mechanically detects fixation — consecutive attempts rewriting the same file(s)
|
|
17
|
+
without moving the close's kept-failure set — and injects an escalating note into the
|
|
18
|
+
next attempt's prompt: a capped summary first, then the worker's own prior failed
|
|
19
|
+
edit content verbatim (the BA-14 rejected-edit-buffer shape, rewritten for our
|
|
20
|
+
attempt loop). Fixation-gated (inert when not stuck — RSI §3.3: the lift is a
|
|
21
|
+
fixation phenomenon, an honest null on a strong unstuck model), shell-authored (the
|
|
22
|
+
worker authors nothing, gains no verb), within-run only (state dies with the run;
|
|
23
|
+
inheritance stays verdict-gated). Red-set comparison strips the spec-reporter's
|
|
24
|
+
per-run duration stamps (POC-measured: kept lines are never byte-stable) —
|
|
25
|
+
comparison-only, the delivered gap is untouched. Spine event `root-injected` carries
|
|
26
|
+
stage/mode/streak/paths, never content (append-only law). Ships **OFF by default**
|
|
27
|
+
(`layerRoot: false` on `runJob`/`interpret`; pass `true` for the ON/experimental arm) —
|
|
28
|
+
decided 2026-07-21: fixation is extinct on every current job (F41), so ON has never won
|
|
29
|
+
its own A/B; the default flip to `true` defers to the first Layer 2 job that produces
|
|
30
|
+
natural fixation (see `docs/01-product/LAYERS.md` Layer R note).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **F44 — fresh whole-branch review: three correctness bugs + cleanups, all validated and
|
|
35
|
+
mutation-proven.** (1) A casualty job-end laundered unknown spend as complete: `1b0720c`
|
|
36
|
+
put `spentUsd` on every path, so a provider-red TRANSPORT THROW reported
|
|
37
|
+
`spendComplete: true` (F6 violation — the failed call's cost is unknown) and the battery
|
|
38
|
+
scripts' `spentUsd == null` casualty-STOP never fired. Fixed both sides: the transport
|
|
39
|
+
provider-red now reports `spendComplete: false` (the metered draft-truncation keeps
|
|
40
|
+
`true`), and the frozen battery/probe scripts key their STOP on
|
|
41
|
+
`spendComplete === false || spentUsd == null`. (2) Layer R's outcome probe mislabeled a
|
|
42
|
+
missed-anchor edit as "landed" when its `newText` appeared elsewhere (substring
|
|
43
|
+
false-positive) — now discriminated by the tool result, exact-equality for writes, one
|
|
44
|
+
read not two. (3) Layer R false-fired on a progressing text-mode worker with no gapKeep
|
|
45
|
+
(write-overlap is constant-true when the single target is rewritten every attempt) — a
|
|
46
|
+
`writesInformative` flag now requires a known-unmoved red-set when writes carry no
|
|
47
|
+
information. Cleanups: double redaction, unbounded `attempts` retention, a dead ternary.
|
|
48
|
+
(2) and (3) are latent behind the OFF-by-default `layerRoot`.
|
|
49
|
+
|
|
50
|
+
- **Layer R settled its note on the gate's allow, which is written BEFORE the tool runs
|
|
51
|
+
(F43).** An `allow` record states INTENT, never that bytes reached a file:
|
|
52
|
+
`shell_edit` returns an anchor miss as a refusal *result* and a byte-cap overflow as a
|
|
53
|
+
throw, both leaving the file untouched with the allow already on the audit. The
|
|
54
|
+
verbatim note could therefore present content to the worker as "your own previous
|
|
55
|
+
changes — they landed" while that text was provably absent from the file it named
|
|
56
|
+
(reproduced end-to-end: 3 allowed edits, 0 bytes changed). Fixed by splitting the two
|
|
57
|
+
axes rather than merging them — the DETECTOR keeps reading intent (an edit that never
|
|
58
|
+
applied is still repetition; a tree-diff detector measured blind to it on every
|
|
59
|
+
attempt), while the NOTE settles on the observed file through `Loop`'s
|
|
60
|
+
`onToolResult` seam. An unapplied repeat now names the missed anchor instead, which is
|
|
61
|
+
the mechanical gap genre (F38). `commitWrite` is replaced by `settleWrite(landed)`.
|
|
62
|
+
- **Guarded the `maxTurns` LLM-round invariant (F43 follow-up).** bareguard's `maxTurns`
|
|
63
|
+
ticks on every `gate.record`; our cap means "LLM rounds" only because the sole record
|
|
64
|
+
path is the LLM one (tool calls take `gate.check`, which does not tick). That was
|
|
65
|
+
correct but unguarded — wiring tool results into `gate.record` would silently halve the
|
|
66
|
+
LLM budget (the F37 lower-silent-ceiling class). Added a guard test pinning "every
|
|
67
|
+
`gate.record` is `type:llm`" (mutation-proven: wiring tool records turns it red) and a
|
|
68
|
+
config comment marking the invariant load-bearing. No behavior change; no upstream ask —
|
|
69
|
+
bareguard already offers the correct counters.
|
|
70
|
+
- **F41 — the disposition: armed-and-inert, field read deferred.** Before any ON/OFF
|
|
71
|
+
battery spent money, two cheap reads measured the disease's base rate: the archive
|
|
72
|
+
sweep (`poc/layer-r-base-rate.mjs`, $0 — every surviving spine is OFF-arm by
|
|
73
|
+
construction) read 0 fixated in 10 pairs on jobs #2/#4; two frozen probes on a
|
|
74
|
+
rebuilt job #1 patient (`scripts/run-probe-layer-r.mjs`, $10.12 total) read 0
|
|
75
|
+
fixated in 4 pairs — including against a three-plant tree (three subsystems, one fix
|
|
76
|
+
cannot green) that forced a full 8→4→green ladder across three judged attempts.
|
|
77
|
+
Every pair was healthy navigation. F21's fixation was a broken-loop symptom, cured
|
|
78
|
+
by the F20/F21/F30/BA-13 fixes. Because ON has therefore never won its own A/B,
|
|
79
|
+
Layer R ships **OFF by default** (decided 2026-07-21; `layerRoot: true` is the ON
|
|
80
|
+
arm) — measured cost-free when enabled and healthy (zero injections across all
|
|
81
|
+
probe runs); the repetition-drop ON/OFF read, and the default-flip decision, defer
|
|
82
|
+
to the first run whose spine records `root-injected`. No learning claim is minted.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **`jobs/litectx-maintainer.json` `budgetUsd` 1.5 → 4.5** (probe 2, re-signed): $1.50
|
|
87
|
+
funded exactly ONE judged attempt on the real patient — a run could red at attempt 1
|
|
88
|
+
and die at the gate mid-attempt-2, structurally unable to produce across-attempt
|
|
89
|
+
evidence. The advertised budget must fund the attempts the cap promises.
|
|
90
|
+
|
|
8
91
|
## [0.3.0] — 2026-07-19
|
|
9
92
|
|
|
10
93
|
### Fixed
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<p align="center">
|
|
14
14
|
<img src="https://img.shields.io/github/package-json/v/hamr0/bareloop?label=version&color=2a4f8c" alt="version (auto from package.json)">
|
|
15
15
|
<img src="https://img.shields.io/badge/license-Apache%202.0-2a4f8c" alt="license: Apache 2.0">
|
|
16
|
-
<img src="https://img.shields.io/badge/status-
|
|
16
|
+
<img src="https://img.shields.io/badge/status-Layer%20R%20%C2%B7%20ratchet%20armed-2a6d3a" alt="status: Layer R, ratchet armed">
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
19
|
**"Automate this job — I don't know the best workflow."** For tasks that are **repeated,
|
|
@@ -25,10 +25,15 @@ inheritance with ledger-counted attribution.
|
|
|
25
25
|
The pitch in one line: **workflows that earn their own design, with receipts** — every
|
|
26
26
|
inherited rule carries the green that minted it and the contrast that attributed it.
|
|
27
27
|
|
|
28
|
-
> **Status:
|
|
29
|
-
> draft → per-step worker loops → close verdicts → draft PR,
|
|
30
|
-
> can only tighten
|
|
31
|
-
>
|
|
28
|
+
> **Status: Layer R — the within-run ratchet is built and armed.** `runJob()` takes a
|
|
29
|
+
> signed job spec through draft → per-step worker loops → close verdicts → draft PR,
|
|
30
|
+
> under one budget the agent can only tighten; the shell now also detects a worker
|
|
31
|
+
> stuck repeating itself and feeds back its own failed attempts (escalating
|
|
32
|
+
> summary→verbatim). Two frozen probes found the disease in remission under the current
|
|
33
|
+
> loop (F41: 0 fixated pairs in 14, jobs #1/#2/#4), so the ratchet ships inert-by-
|
|
34
|
+
> measurement with its field read deferred to the first run that trips it. Next is
|
|
35
|
+
> Layer 2 (plan-v1 micro-wheels), then executed inheritance (N3) — the ladder's
|
|
36
|
+
> kill-switch question.
|
|
32
37
|
|
|
33
38
|
## Quick start
|
|
34
39
|
|
|
@@ -98,6 +103,8 @@ ladder, and the stop is a result.
|
|
|
98
103
|
| **N0** ✅ | Port + outer shell + spine (token-free) — v0.1.0 |
|
|
99
104
|
| **N1** ✅ | Job/close schema + validator — v0.2.0 |
|
|
100
105
|
| **N2** ✅ | Single-job headless loop — job #1 minimal (review→fix→PR, hard greens only) — v0.3.0 |
|
|
106
|
+
| **Layer R** ✅ | Within-run ratchet: shell-side fixation detector (intent) + rejected-edit feedback (outcome), **shipped OFF by default** (F41: fixation in remission, so ON has never won its A/B; F43 split the two axes; field read + default decision defer to Layer 2) — v0.4.0 |
|
|
107
|
+
| **Layer 2** | plan-v1 micro-wheels — the road: bounded steps with in-run verifiable exits (the F39-measured semantic converter) |
|
|
101
108
|
| **N3** | Executed inheritance + contrast-bit extractor — **kill-switch: rules must transmit across non-identical runs** |
|
|
102
109
|
| **N4** | Verdict classes complete (soft/HITL minting) |
|
|
103
110
|
| **N5** | Scheduler + budget ops |
|
package/bareloop.context.md
CHANGED
|
@@ -285,7 +285,32 @@ the close judges the tree; on-green `remember` retains the worker's change summa
|
|
|
285
285
|
Worker-caused close crashes feed back as `worker-crash` gaps via the gate audit (F32 —
|
|
286
286
|
see `ralph` above).
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
**Layer R — the within-run ratchet (`layerRoot`, default `false`; design record
|
|
289
|
+
2026-07-19).** The shell mechanically detects fixation across attempts — consecutive
|
|
290
|
+
attempts that rewrote the same file(s) without moving the close's kept-failure set
|
|
291
|
+
(`closeGapKeep` lines, duration stamps stripped before comparing; without a gapKeep the
|
|
292
|
+
detector degrades to write-overlap alone and the spine event names the mode) — and
|
|
293
|
+
injects an escalating note into the next prompt: first a capped summary naming the
|
|
294
|
+
repeated files, then (if still stuck) the worker's OWN prior failed edit content
|
|
295
|
+
verbatim, scrubbed and capped with trims announced. Inert unless stuck; the worker
|
|
296
|
+
authors nothing and gains no verb. Two axes, deliberately separate (F43): the DETECTOR
|
|
297
|
+
reads INTENT (the gate audit's allow-set — an edit whose anchor missed is still the
|
|
298
|
+
worker reaching for that file again, and a tree-diff detector measured blind to exactly
|
|
299
|
+
that), while the NOTE's claims about file contents read OUTCOME (settled post-execution
|
|
300
|
+
through `Loop`'s `onToolResult`). So a repeat that never applied is told its anchor
|
|
301
|
+
missed, and only content actually in the file is ever described as having landed. Spine event `root-injected` carries
|
|
302
|
+
`{stage, mode, streak, paths, redSetSize}` — counts and paths only, NEVER content (the
|
|
303
|
+
spine is append-only). State dies with the run: this is within-run scratch, not
|
|
304
|
+
across-run memory. `layerRoot: true` (on `interpret` and `runJob`) is the ON/experimental
|
|
305
|
+
arm; the default is OFF. Status (F41): the ratchet is MEASURED inert on every current
|
|
306
|
+
job — two frozen probes + an archive sweep read 0 fixated pairs in 14 (the F21 repetition
|
|
307
|
+
disease was a broken-loop symptom, since cured). Because ON has therefore never won its
|
|
308
|
+
own A/B, it ships **OFF by default (decided 2026-07-21)** — armed and correct, but not
|
|
309
|
+
default-enabled until a stuck job (Layer 2, or a manufactured-fixation probe) shows ON
|
|
310
|
+
beats OFF. Flip the default to `true` the day that evidence lands; a `root-injected` event
|
|
311
|
+
on your spine (when you pass `layerRoot: true`) is a signal worth reading, not noise.
|
|
312
|
+
|
|
313
|
+
### `runJob(spec, { approvals, workdir, provider, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd?, layerRoot? })` → outcome — `src/run.js`
|
|
289
314
|
|
|
290
315
|
The N2 runner — the shell's top layer; composes everything below it and interprets
|
|
291
316
|
nothing itself. Sequence: **approval gate** (human-signs-always — refuses an unapproved
|
|
@@ -312,7 +337,15 @@ round the API cut off mid-generation (`truncated:max_tokens`, bare-agent 0.27.0/
|
|
|
312
337
|
before which it laundered into a clean finish, F25); in both cases no verdict exists and the
|
|
313
338
|
failed round's spend is only partly known (F6). `cap-halt` is drafting spend consuming the whole job budget before a
|
|
314
339
|
valid config existed. Both are decision-ready escalations with a terminal `job-end`:
|
|
315
|
-
the spine never dangles.
|
|
340
|
+
the spine never dangles. **Every `job-end` carries the money, on every path**:
|
|
341
|
+
`{ outcome, spentUsd, spendComplete }` (plus `step`/`cause`/`detail` where the outcome has
|
|
342
|
+
them). `spentUsd` is the accumulated sum of PRICED rounds ONLY — never an estimate from
|
|
343
|
+
token counts or averages (cap-not-estimate) — and it is stated even on the pre-token reds,
|
|
344
|
+
where the honest figure is a real `0`. `spendComplete` says whether that figure is EXACT:
|
|
345
|
+
`false` means one or more rounds came back unpriced (F6), so `spentUsd` is a FLOOR
|
|
346
|
+
("at least $X", true total unknowable) and must not be read as a total. Both fields are
|
|
347
|
+
present on all outcomes, so a consumer never branches on field presence and never has to
|
|
348
|
+
launder a missing `spentUsd` into `$0`. A text-mode job invoked without `opts.target` is a `job-red`
|
|
316
349
|
before ANY provider call (and `interpret` itself throws a TypeError for direct
|
|
317
350
|
callers) — reds-before-tokens applies to the call, not just the spec.
|
|
318
351
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/interpret.js
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
14
|
import { writeFileSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
15
16
|
import { join, resolve, sep } from 'node:path';
|
|
16
17
|
import { homedir } from 'node:os';
|
|
17
18
|
import { Gate, redact } from 'bareguard';
|
|
18
19
|
import { LiteCtx, compress } from 'litectx';
|
|
19
20
|
import { validateConfig, diffPaths, globToPrefix, SECRET_PATTERNS } from './validate.js';
|
|
20
21
|
import { ralph } from './ralph.js';
|
|
22
|
+
import { createRoot } from './root.js';
|
|
21
23
|
|
|
22
24
|
/** @typedef {Error & {category?: string, lib?: string}} CategorizedError the failure map's carrier: ralph relays by `category`, and by `lib` when the thrower knows which library owns the failure (the ledger prefers it over prose) */
|
|
23
25
|
|
|
@@ -213,11 +215,18 @@ const toolAction = (name, args, workdir) => {
|
|
|
213
215
|
* @param {'text'|'tools'} [opts.mode] middle mode (2b): 'text' (default) writes the ONE
|
|
214
216
|
* target from the response artifact; 'tools' gives the worker Gate-governed file
|
|
215
217
|
* tools — SPEC-side territory (the step declares it; the config cannot express it)
|
|
218
|
+
* @param {boolean} [opts.layerRoot=false] Layer R (the within-run ratchet, src/root.js):
|
|
219
|
+
* shell-assembled fixation detection over the run's own books, injecting an
|
|
220
|
+
* escalating summary→verbatim note when consecutive attempts rewrite the same
|
|
221
|
+
* files without moving the reds. Inert when the worker is not stuck (RSI §3.3).
|
|
222
|
+
* Defaults OFF (decided 2026-07-21): fixation is extinct on every current job
|
|
223
|
+
* (F41), so ON is unproven — the field read defers to Layer 2. `true` is the
|
|
224
|
+
* ON/experimental arm; never a worker-visible knob
|
|
216
225
|
* @param {string[]} [opts.tools] the spec's tool grant (subset of read|grep|write,
|
|
217
226
|
* job-v1 validated); defaults to the full menu in tool mode
|
|
218
227
|
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
219
228
|
*/
|
|
220
|
-
export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor, closeTimeoutMs, mode = 'text', tools, closeState, closeExpect, closeJudged, closeGapKeep }) {
|
|
229
|
+
export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor, closeTimeoutMs, mode = 'text', tools, closeState, closeExpect, closeJudged, closeGapKeep, layerRoot = false }) {
|
|
221
230
|
// Reds-before-tokens: text mode writes ONE artifact — a missing target is a
|
|
222
231
|
// caller bug that must be loud NOW, not a TypeError after a paid worker call
|
|
223
232
|
// that ralph would misfile as interpreter-red (the gate skips an absent path,
|
|
@@ -279,7 +288,17 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
279
288
|
: {}),
|
|
280
289
|
},
|
|
281
290
|
budget: { maxCostUsd: config.gate.budgetUsd },
|
|
282
|
-
// text mode is ~1-2 rounds per attempt; tool mode is N rounds (read→write→…)
|
|
291
|
+
// text mode is ~1-2 rounds per attempt; tool mode is N rounds (read→write→…).
|
|
292
|
+
// maxTurns ticks on every gate.RECORD (bareguard limits.js:88), and this
|
|
293
|
+
// number means "LLM rounds" ONLY because the sole record path is the LLM one
|
|
294
|
+
// (onLlmResult → gate.record{type:'llm'}); tool calls take gate.CHECK, which
|
|
295
|
+
// does not tick. That invariant is load-bearing: wiring wireGate's own
|
|
296
|
+
// onToolResult into gate.record would silently start ticking maxTurns on
|
|
297
|
+
// tools and halve the LLM budget (the F37 lower-silent-ceiling class). It is
|
|
298
|
+
// pinned by the "every record is type:llm" guard test — do not wire tool
|
|
299
|
+
// records without switching this bound (bareguard offers no llm-only counter;
|
|
300
|
+
// it has maxTurns=all and maxToolRounds=tools-only, neither of which is
|
|
301
|
+
// llm-only once tools also record). Cap semantics are arbiter territory.
|
|
283
302
|
limits: { maxTurns: TURNS_PER_ATTEMPT * (capRuns + 1) },
|
|
284
303
|
audit: { path: join(workdir, 'gate-audit.jsonl') },
|
|
285
304
|
humanChannel: async () => ({ decision: 'terminate' }), // no human mid-run: a tripped cap terminates → decision-ready escalation
|
|
@@ -294,6 +313,13 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
294
313
|
// exactly as it did before F32 — attribution can only ADD a recovery path, never
|
|
295
314
|
// swallow an instrument stop.
|
|
296
315
|
const auditPath = join(workdir, 'gate-audit.jsonl');
|
|
316
|
+
// NOTE (Finding 4, deferred): this re-reads and re-parses the whole audit each
|
|
317
|
+
// call. That is O(1) reads when Layer R is OFF (called only at the rare crashed
|
|
318
|
+
// verdict), but O(attempts) when Layer R is ON, and the audit can accumulate
|
|
319
|
+
// across steps/runs. An incremental byte-cursor reader would make it O(total)
|
|
320
|
+
// — deferred as cleanup-tier (only bites the OFF-by-default arm) until it can
|
|
321
|
+
// carry its own multi-path-accumulation tests; the simple full-parse is kept
|
|
322
|
+
// because it is obviously correct.
|
|
297
323
|
const workerWrites = () => {
|
|
298
324
|
try {
|
|
299
325
|
const paths = new Set();
|
|
@@ -308,7 +334,135 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
308
334
|
return [...paths];
|
|
309
335
|
} catch { return []; }
|
|
310
336
|
};
|
|
311
|
-
|
|
337
|
+
// Layer R — the root (within-run ratchet, design record 2026-07-19). Shell-
|
|
338
|
+
// assembled from the arbiter's own books: per-attempt write-sets come from
|
|
339
|
+
// the SAME workerWrites audit read (allow-decision only, so a denied write is
|
|
340
|
+
// never counted), red-sets from the close's kept-failure lines. The verbatim
|
|
341
|
+
// stage needs the content the audit deliberately does not keep (bytes-only),
|
|
342
|
+
// so it is teed below at the one seam that sees it — memory-only, scrubbed,
|
|
343
|
+
// never the spine.
|
|
344
|
+
const root = layerRoot ? createRoot({ gapKeep: closeGapKeep, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }), writesInformative: mode === 'tools' }) : null;
|
|
345
|
+
// The tee rides the translator and resolves paths exactly as toolAction does,
|
|
346
|
+
// so tee keys match audit paths byte-for-byte. The translator fires BEFORE the
|
|
347
|
+
// gate decides (wireGate calls translate() inside its policy, then awaits
|
|
348
|
+
// gate.check), so it can only STAGE: the audit-path filter downstream is not
|
|
349
|
+
// enough on its own, because the tee is last-write-wins PER PATH and a path the
|
|
350
|
+
// audit legitimately lists as allowed can end the attempt holding the content
|
|
351
|
+
// of a LATER write the gate rejected — the verbatim note would then swear that
|
|
352
|
+
// code landed when it is not in the file (Finding 6, 2026-07-20). Settlement
|
|
353
|
+
// is split across the two seams that actually know (Finding 7): the gate
|
|
354
|
+
// verdict can only DISCARD (a deny means the tool never runs), and the
|
|
355
|
+
// post-execution seam supplies the outcome an allow cannot — see `policy` and
|
|
356
|
+
// `onToolOutcome` below.
|
|
357
|
+
/** @param {string} n @param {any} a */
|
|
358
|
+
const teeingTranslator = (n, a) => {
|
|
359
|
+
// Classify ONCE and decide by the action TYPE, not a hardcoded name list — a
|
|
360
|
+
// third enumeration of the write verbs (workerWrites already filters on
|
|
361
|
+
// action.type write|edit) would let a future granted write-class verb enter
|
|
362
|
+
// the gate audit but silently bypass the tee (the recurring blind-instrument
|
|
363
|
+
// class). Reuse act.path so the tee key matches the audit path byte-for-byte
|
|
364
|
+
// (both from this same resolution). Content is read off the RAW args and never
|
|
365
|
+
// put on the action object — the gate audit stays bytes-only (the secrets law:
|
|
366
|
+
// what an append-only log captures, it captures forever).
|
|
367
|
+
const act = toolAction(n, a, workdir);
|
|
368
|
+
if (root && (act.type === 'write' || act.type === 'edit')) {
|
|
369
|
+
// act.path is always present on the write/edit branch (cast: toolAction's
|
|
370
|
+
// object literals widen `type` to string, so tsc cannot narrow the union)
|
|
371
|
+
const path = /** @type {string} */ (act.path);
|
|
372
|
+
const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
|
|
373
|
+
root.stageWrite(path, content);
|
|
374
|
+
// Finding 7 — the OUTCOME probe. The gate's allow is an INTENT record
|
|
375
|
+
// written before the tool runs; shell_edit returns an anchor miss as a
|
|
376
|
+
// refusal RESULT (bare-agent tools/shell.js:190) and a byte-cap overflow
|
|
377
|
+
// as a throw (:204), both leaving the file untouched with the allow
|
|
378
|
+
// already on the audit. Snapshot the file hash HERE (pre-execution) so the
|
|
379
|
+
// post-execution seam can settle the note against ground truth. Carry the
|
|
380
|
+
// action TYPE: for the file-unchanged case, a write and an edit answer
|
|
381
|
+
// "did it land?" differently (Finding 2). Raw content, never the scrubbed
|
|
382
|
+
// copy — the comparison is against real bytes; transient, never on a
|
|
383
|
+
// prompt, the spine, or the audit.
|
|
384
|
+
pendingProbe = { path, content, type: act.type, before: fileHash(path) };
|
|
385
|
+
}
|
|
386
|
+
return act;
|
|
387
|
+
};
|
|
388
|
+
/**
|
|
389
|
+
* Content hash of a file, or null when it does not exist / cannot be read —
|
|
390
|
+
* null is the honest unknown here, and a missing file BEFORE a write is the
|
|
391
|
+
* normal new-file case (it compares unequal to the hash after, which is
|
|
392
|
+
* exactly right: the write landed).
|
|
393
|
+
* @param {string} p
|
|
394
|
+
*/
|
|
395
|
+
const fileHash = (p) => {
|
|
396
|
+
try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
|
|
397
|
+
};
|
|
398
|
+
/** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
|
|
399
|
+
let pendingProbe = null;
|
|
400
|
+
const { policy: gatePolicy, onLlmResult } = wireGate(gate, mode === 'tools' ? { actionTranslator: teeingTranslator } : {});
|
|
401
|
+
// Settle the stage on the verdict. wireGate's policy returns true on allow,
|
|
402
|
+
// a string on deny, and THROWS HaltError on halt — and bare-agent processes
|
|
403
|
+
// tool calls strictly sequentially (`for (const tc of result.toolCalls) { …
|
|
404
|
+
// await this.policy(…) }`, loop.js), so at most one stage is ever in flight.
|
|
405
|
+
// The catch discards and RE-THROWS: cap-halt routing reads that throw, and a
|
|
406
|
+
// swallowed halt would launder a tripped budget into a plain deny. A non-write
|
|
407
|
+
// action stages nothing, so both settlements are no-ops for it.
|
|
408
|
+
const policy = root && mode === 'tools'
|
|
409
|
+
? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
|
|
410
|
+
try {
|
|
411
|
+
const verdict = await gatePolicy(n, a, c);
|
|
412
|
+
// A DENY is settled here and only here: the tool never executes, so the
|
|
413
|
+
// outcome seam below never fires for it. An ALLOW is deliberately NOT
|
|
414
|
+
// settled — the bytes have not been written yet, and settling on the
|
|
415
|
+
// verdict is precisely the Finding 7 defect (validated 2026-07-20: three
|
|
416
|
+
// allowed edits, zero bytes changed, and the note swore they landed).
|
|
417
|
+
if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
|
|
418
|
+
return verdict;
|
|
419
|
+
} catch (e) {
|
|
420
|
+
root.discardWrite();
|
|
421
|
+
pendingProbe = null;
|
|
422
|
+
throw e;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
: gatePolicy;
|
|
426
|
+
/**
|
|
427
|
+
* Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
|
|
428
|
+
* after every `tool.execute` (bare-agent loop.js:986), success or error, and
|
|
429
|
+
* bare-agent processes tool calls strictly sequentially, so exactly one probe
|
|
430
|
+
* is ever in flight. A probe that never reaches here (the BA-12 spin-guard
|
|
431
|
+
* early return, or a HaltError out of a tool body) leaves the stage pending
|
|
432
|
+
* and unsettled — correct, because in both cases nothing landed, and the
|
|
433
|
+
* attempt boundary drops it.
|
|
434
|
+
*
|
|
435
|
+
* NOT wired: `wireGate`'s own `onToolResult`, which would start feeding
|
|
436
|
+
* `gate.record` per-tool records. That changes what `limits.maxTurns` /
|
|
437
|
+
* `maxToolRounds` count and therefore when a run halts (the guarded
|
|
438
|
+
* llm-only-turns invariant at the Gate config above) — cap semantics are
|
|
439
|
+
* arbiter territory, so it is deliberately not wired here.
|
|
440
|
+
* @param {{result?: any}} [info] the tool's return value (bare-agent loop.js:988)
|
|
441
|
+
*/
|
|
442
|
+
const onToolOutcome = async (info) => {
|
|
443
|
+
if (!root || !pendingProbe) return;
|
|
444
|
+
const { path: p, content, type, before } = pendingProbe;
|
|
445
|
+
pendingProbe = null;
|
|
446
|
+
// ONE read serves both the after-hash and the content check (Finding 5: the
|
|
447
|
+
// old path read the file twice). null = unreadable/absent.
|
|
448
|
+
let after = null;
|
|
449
|
+
try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
|
|
450
|
+
let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
|
|
451
|
+
if (!landed && after !== null) {
|
|
452
|
+
// The bytes did not change — but the action may still have left `content`
|
|
453
|
+
// in the file, and write vs edit answer that differently (Finding 2):
|
|
454
|
+
// - write: a whole-file write always writes; identical bytes means the
|
|
455
|
+
// content genuinely IS the file (exact equality, never a substring —
|
|
456
|
+
// substring false-positived when newText appeared elsewhere).
|
|
457
|
+
// - edit: a no-byte-change edit is EITHER an idempotent apply (newText===
|
|
458
|
+
// oldText, "edited …: 1 replacement") OR a missed anchor ("shell_edit:
|
|
459
|
+
// … no change made") — indistinguishable on disk, so the tool RESULT is
|
|
460
|
+
// the only honest signal. An anchor miss is NOT landed.
|
|
461
|
+
if (type === 'write') landed = after === content;
|
|
462
|
+
else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
|
|
463
|
+
}
|
|
464
|
+
root.settleWrite(landed);
|
|
465
|
+
};
|
|
312
466
|
// Money is metered as it is SPENT — per ROUND, not per attempt (F12). A
|
|
313
467
|
// multi-round attempt that halts (or throws) never returns, so its rounds
|
|
314
468
|
// never reach `worker-result`: the real run bought $1.4375 of tokens inside a
|
|
@@ -428,6 +582,7 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
428
582
|
system: mode === 'tools' ? PERSONA_TOOLS + (usesEdit ? EDIT_STRATEGY : '') + (usesCtx ? RETRIEVAL_STRATEGY : '') : PERSONA,
|
|
429
583
|
policy,
|
|
430
584
|
onLlmResult: meteredOnLlmResult,
|
|
585
|
+
onToolResult: onToolOutcome,
|
|
431
586
|
});
|
|
432
587
|
|
|
433
588
|
/** @param {string} slot */
|
|
@@ -576,6 +731,11 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
576
731
|
const middle = async (iteration, gap) => {
|
|
577
732
|
roundIteration = iteration; // stamps every round of this attempt (F12)
|
|
578
733
|
roundsThisAttempt = 0; // the bound is PER ATTEMPT — a fresh conversation, a fresh budget (F20)
|
|
734
|
+
// Layer R: finalize the previous attempt from the books and ask the ratchet.
|
|
735
|
+
// Inert (null) unless consecutive attempts rewrote the same files without
|
|
736
|
+
// moving the reds; the event carries stage/paths/counts, NEVER content.
|
|
737
|
+
const rootInj = root ? root.observe({ iteration, gap, writes: workerWrites() }) : null;
|
|
738
|
+
if (rootInj) emit('root-injected', rootInj.event);
|
|
579
739
|
if (gap) gaps.push(gap);
|
|
580
740
|
if (revisor && !revised && gaps.length >= STALL_REDS) {
|
|
581
741
|
emit('stall-detected', { iteration, consecutiveReds: gaps.length });
|
|
@@ -632,6 +792,9 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
632
792
|
// attempt and describing the tree are different claims; this is the second.
|
|
633
793
|
!gap && closeState && `The close is currently failing. This is its output on the tree as it stands (not an attempt of yours):\n${closeState}`,
|
|
634
794
|
context.text && `Possibly relevant notes:\n${context.text}`, gap && `Previous attempt failed the test suite:\n${gap}`,
|
|
795
|
+
// Layer R: the ratchet note rides directly after the gap it qualifies —
|
|
796
|
+
// same channel discipline as the gap itself (bounded, trims announced)
|
|
797
|
+
rootInj && rootInj.note,
|
|
635
798
|
artifactNote && `Previous attempt never reached the close:\n${artifactNote}`,
|
|
636
799
|
// F20: a bounded attempt must SAY it was bounded. Otherwise the worker reads its own
|
|
637
800
|
// truncated transcript as a finished one, learns nothing from being cut off, and spends
|
|
@@ -682,6 +845,7 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
682
845
|
throw err;
|
|
683
846
|
}
|
|
684
847
|
writeFileSync(t, code);
|
|
848
|
+
root?.noteWrite(t, code); // Layer R tee, text mode: same seam role as the translator's
|
|
685
849
|
emit('artifact-written', { iteration, path: t });
|
|
686
850
|
};
|
|
687
851
|
|
package/src/ralph.js
CHANGED
|
@@ -143,6 +143,12 @@ const GAP_HEAD = 400, GAP_TAIL = 1500;
|
|
|
143
143
|
// matches it says so with an explicit marker — silent truncation is the disease
|
|
144
144
|
// this whole fix cures (F28), never a cure that truncates silently in turn.
|
|
145
145
|
const GAP_KEEP_MAX_LINES = 50, GAP_KEEP_MAX_BYTES = 8192;
|
|
146
|
+
// The exact phrase the trim announcement carries when kept failures are dropped
|
|
147
|
+
// by the cap above. Exported so Layer R's detector (src/root.js) keys off the
|
|
148
|
+
// SAME string the shell emits — a magic string on the reading side would drift
|
|
149
|
+
// silently the day this wording changes (the blind-instrument class). Reference
|
|
150
|
+
// only: the shell's behavior is unchanged, the label below is byte-identical.
|
|
151
|
+
export const GAP_KEEP_TRIM_MARKER = 'more elided by the';
|
|
146
152
|
|
|
147
153
|
/**
|
|
148
154
|
* Bound an over-long close stream to a head sample + elided middle + tail. When
|
|
@@ -173,7 +179,7 @@ function boundGap(s, keepPattern) {
|
|
|
173
179
|
bytes += line.length + 1;
|
|
174
180
|
}
|
|
175
181
|
if (!matched.length) return `${base}\n${tail}`; // pattern matched nothing — behave as the plain bound
|
|
176
|
-
const label = `kept failures matching /${keepPattern}/ (${matched.length}${trimmed ? `, ${trimmed}
|
|
182
|
+
const label = `kept failures matching /${keepPattern}/ (${matched.length}${trimmed ? `, ${trimmed} ${GAP_KEEP_TRIM_MARKER} ${GAP_KEEP_MAX_LINES}-line/${GAP_KEEP_MAX_BYTES}-byte cap` : ''})`;
|
|
177
183
|
return `${base}\n── ${label} ──\n${matched.join('\n')}\n── end kept failures ──\n${tail}`;
|
|
178
184
|
}
|
|
179
185
|
|
package/src/root.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
|
|
2
|
+
// docs/plans/2026-07-19-layer-r-design.md). One state that survives attempts
|
|
3
|
+
// inside ONE run so a never-green run stops repeating itself (F21). Role is
|
|
4
|
+
// CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
|
|
5
|
+
// the semantic converter is Layer 2's job, not this module's.
|
|
6
|
+
//
|
|
7
|
+
// Ownership: the SHELL authors the root, mechanically, from the arbiter's own
|
|
8
|
+
// books — per-attempt write-sets (the gate audit, via the F32 workerWrites
|
|
9
|
+
// seam) and the close's kept-failure lines. The worker authors nothing and
|
|
10
|
+
// gains no verb; delivery is injection into the next attempt's prompt, so the
|
|
11
|
+
// adaptlearn-F6 write-only-decoy class is structurally excluded.
|
|
12
|
+
//
|
|
13
|
+
// Engagement is FIXATION-GATED (RSI-LEARNINGS §3.3: the rejected-edit buffer's
|
|
14
|
+
// lift is a fixation phenomenon and an honest null on a strong model when not
|
|
15
|
+
// stuck — pay only when stuck). Content ESCALATES: first detection injects a
|
|
16
|
+
// capped mechanical summary; a second consecutive detection injects the
|
|
17
|
+
// worker's own prior failed edit content verbatim ("write something
|
|
18
|
+
// STRUCTURALLY DIFFERENT" — the BA-14 mechanism, 50%→100% under fixation).
|
|
19
|
+
// Both stages fire on DIFFERENT attempts, so every stuck episode yields a
|
|
20
|
+
// per-stage read for the acceptance battery.
|
|
21
|
+
//
|
|
22
|
+
// Scope: within-run scratch, discarded at run end — NEVER across-run memory
|
|
23
|
+
// (only a green mints that, and this module touches no inheritance channel).
|
|
24
|
+
// Spine events carry counts and paths only, never content: verbatim edit text
|
|
25
|
+
// is worker-authored and the spine is append-only (what a log captures, it
|
|
26
|
+
// captures forever).
|
|
27
|
+
|
|
28
|
+
// The shell's kept-failures trim announcement (ralph): its presence in a gap means
|
|
29
|
+
// failures were dropped past the gap cap, so the visible kept lines are an
|
|
30
|
+
// UNRELIABLE red-set for comparison. Imported (never re-spelled) so the detector
|
|
31
|
+
// keys off the exact string the shell emits — a magic string here would drift.
|
|
32
|
+
import { GAP_KEEP_TRIM_MARKER } from './ralph.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip a trailing duration stamp from a kept-failure line. Real `node --test`
|
|
36
|
+
* spec-reporter lines are NEVER byte-stable across runs (`✖ multiplies
|
|
37
|
+
* (1.738ms)` vs `(1.637ms)` — POC 2026-07-19), so naive equality reads every
|
|
38
|
+
* attempt as "reds moved" and the detector never fires. COMPARISON-ONLY: the
|
|
39
|
+
* gap the worker sees is never rewritten.
|
|
40
|
+
* @param {string} line
|
|
41
|
+
*/
|
|
42
|
+
export const normalizeRedLine = (line) => line.replace(/ \(\d+(?:\.\d+)?m?s\)\s*$/, '');
|
|
43
|
+
|
|
44
|
+
// Bounds — every trim is ANNOUNCED in the note (F28: silent truncation is the
|
|
45
|
+
// disease; a cure that truncates silently is the same disease).
|
|
46
|
+
/**
|
|
47
|
+
* A teed write: the content the worker emitted, plus whether it actually reached
|
|
48
|
+
* the file. `landed` is the OUTCOME axis (Finding 7) — the gate's allow is an
|
|
49
|
+
* intent record written before the tool runs, and a shell_edit whose anchor
|
|
50
|
+
* misses returns a refusal RESULT with the file untouched (bare-agent
|
|
51
|
+
* tools/shell.js:190). A note that says "these are your changes" is a claim
|
|
52
|
+
* about the file's contents and must be true, so it reads this flag, never the
|
|
53
|
+
* audit. The DETECTOR deliberately does not: see `observe`.
|
|
54
|
+
* @typedef {{content: string, trimmed: boolean, landed: boolean}} TeeEntry
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const TEE_CAP_BYTES = 2000; // per-file content retained for the verbatim stage
|
|
58
|
+
const VERBATIM_MAX_FILES = 3; // files surfaced per verbatim injection
|
|
59
|
+
const SUMMARY_MAX_PATHS = 10; // paths named in the summary line
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The root collector. One per run; state dies with the run.
|
|
63
|
+
*
|
|
64
|
+
* Call order (wired by the interpreter's middle):
|
|
65
|
+
* observe({iteration: 1, writes}) → null (nothing to compare)
|
|
66
|
+
* stageWrite(path, content) → stage, before the tool runs
|
|
67
|
+
* settleWrite(landed) | discardWrite() → the outcome, after it ran
|
|
68
|
+
* noteWrite(path, content) → stage+settle, text mode (which
|
|
69
|
+
* writes before it reports)
|
|
70
|
+
* observe({iteration: N, gap, writes}) → null | {stage, note, event}
|
|
71
|
+
* `writes` is the CUMULATIVE allow-decision write/edit path list from the gate
|
|
72
|
+
* audit (the F32 instrument); the per-attempt set is the delta between calls —
|
|
73
|
+
* so a gate-DENIED write is never counted (the tool never ran at all).
|
|
74
|
+
*
|
|
75
|
+
* Finding 7 — the two axes, deliberately NOT merged. An allow-decision records
|
|
76
|
+
* what the worker REACHED FOR, which is the right instrument for "is it
|
|
77
|
+
* repeating itself"; whether those bytes reached the file is a different
|
|
78
|
+
* question, and only the note (a claim about file contents) needs it. Collapsing
|
|
79
|
+
* them either way is a bug: settling the note on the allow makes it lie, and
|
|
80
|
+
* running the detector off the file makes it blind to a worker re-firing an
|
|
81
|
+
* identical edit whose anchor never matches — measured null on every attempt.
|
|
82
|
+
*
|
|
83
|
+
* @param {{gapKeep?: string, redact?: (s: string) => string, writesInformative?: boolean}} [opts]
|
|
84
|
+
* the signed spec's kept-failures pattern (the red-set source; without it the
|
|
85
|
+
* detector degrades to write-overlap alone and the event says so), the shell's
|
|
86
|
+
* secret scrubber (verbatim content is scrubbed before it rides a prompt), and
|
|
87
|
+
* `writesInformative` (Finding 3, default true): whether the per-attempt
|
|
88
|
+
* write-set carries information. In TOOL mode the worker CHOOSES which files to
|
|
89
|
+
* write, so re-writing the same file is a real repetition signal; in TEXT mode
|
|
90
|
+
* the one target is rewritten every attempt by construction, so write-overlap
|
|
91
|
+
* is constant-true and carries none — with no red-set to compare, the detector
|
|
92
|
+
* cannot tell repetition from progress and must stay inert (it would otherwise
|
|
93
|
+
* steer a progressing worker off course, violating "inert when not stuck").
|
|
94
|
+
*/
|
|
95
|
+
export function createRoot({ gapKeep, redact = (s) => s, writesInformative = true } = {}) {
|
|
96
|
+
const keepRe = gapKeep ? new RegExp(gapKeep, 'm') : null;
|
|
97
|
+
/**
|
|
98
|
+
* The comparable red-set, or null when it CANNOT be trusted — one clean UNKNOWN
|
|
99
|
+
* covering three cases, so the detector honestly degrades to write-overlap alone
|
|
100
|
+
* rather than making the strong "reds unchanged" claim off a blind instrument:
|
|
101
|
+
* - no gapKeep pattern at all (no red-set instrument);
|
|
102
|
+
* - a TRIMMED window (ralph dropped failures past the cap): failures beyond the
|
|
103
|
+
* window can move while the visible lines stay identical (Finding 5);
|
|
104
|
+
* - zero matches on a judged gap: an empty kept-set is UNKNOWN, not "zero
|
|
105
|
+
* failures" — [] === [] would false-read as "reds unchanged" (Finding 2).
|
|
106
|
+
* @param {string} gap @returns {string[]|null} normalized kept lines, or null when untrustworthy
|
|
107
|
+
*/
|
|
108
|
+
const keptSet = (gap) => {
|
|
109
|
+
if (!keepRe || gap.includes(GAP_KEEP_TRIM_MARKER)) return null;
|
|
110
|
+
const lines = [...new Set(gap.split('\n').filter((l) => keepRe.test(l)).map(normalizeRedLine))].sort();
|
|
111
|
+
return lines.length ? lines : null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** @type {{writes: string[], reds: string[]|null, tee: Map<string, TeeEntry>}[]} finalized attempts */
|
|
115
|
+
const attempts = [];
|
|
116
|
+
/** @type {Set<string>} cumulative audit write-set at the last observe */
|
|
117
|
+
let prevCumulative = new Set();
|
|
118
|
+
/** @type {Map<string, TeeEntry>} tee for the attempt currently running */
|
|
119
|
+
let tee = new Map();
|
|
120
|
+
/** @type {{path: string, entry: TeeEntry}|null} the write awaiting its outcome */
|
|
121
|
+
let pending = null;
|
|
122
|
+
let streak = 0;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Capture a write/edit's content as PENDING — staged against the gate verdict
|
|
126
|
+
* that has not been rendered yet (Finding 6). Scrub at CAPTURE, BEFORE
|
|
127
|
+
* truncating (repo doctrine: secrets scrub at the source, and staging IS the
|
|
128
|
+
* source — a staged secret must be scrubbed even if the write is later
|
|
129
|
+
* discarded). Every SECRET_PATTERN is a prefix+min-length shape (sk-…{16,}),
|
|
130
|
+
* so a secret straddling the cap would lose the bytes that make it match and a
|
|
131
|
+
* partial token would ride the note unredacted — redact the FULL content
|
|
132
|
+
* first, then cap the already-scrubbed string.
|
|
133
|
+
* @param {string} path @param {string} content
|
|
134
|
+
*/
|
|
135
|
+
const stageWrite = (path, content) => {
|
|
136
|
+
const scrubbed = redact(content);
|
|
137
|
+
const trimmed = scrubbed.length > TEE_CAP_BYTES;
|
|
138
|
+
pending = { path, entry: { content: scrubbed.slice(0, TEE_CAP_BYTES), trimmed, landed: false } };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
stageWrite,
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The staged write RAN — record what it actually did (Finding 7). Retained
|
|
146
|
+
* either way: an attempted-but-unapplied edit is still the worker reaching
|
|
147
|
+
* for the same file, which is what the detector measures, and dropping it
|
|
148
|
+
* here would blind the ratchet to the purest fixation there is (a worker
|
|
149
|
+
* re-firing an identical edit whose anchor never matches — measured null on
|
|
150
|
+
* every attempt under a tree-diff detector, 2026-07-20). Only the NOTE's
|
|
151
|
+
* wording turns on `landed`. Last write per path wins (the tree's final
|
|
152
|
+
* state for that attempt is what failed the close).
|
|
153
|
+
* @param {boolean} landed whether the content is now in the file
|
|
154
|
+
*/
|
|
155
|
+
settleWrite(landed) {
|
|
156
|
+
if (pending) tee.set(pending.path, { ...pending.entry, landed });
|
|
157
|
+
pending = null;
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The staged write was DENIED or HALTED: it is not in the file, so it must
|
|
162
|
+
* never be surfaced as "your own change that landed". Drops the stage and
|
|
163
|
+
* leaves whatever DID land for that path untouched.
|
|
164
|
+
*/
|
|
165
|
+
discardWrite() { pending = null; },
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Stage and commit in one call — the post-allow write site (text mode
|
|
169
|
+
* gate-checks and writes before it calls this, so the verdict is already in).
|
|
170
|
+
* @param {string} path @param {string} content
|
|
171
|
+
*/
|
|
172
|
+
noteWrite(path, content) {
|
|
173
|
+
stageWrite(path, content);
|
|
174
|
+
if (pending) tee.set(pending.path, { ...pending.entry, landed: true });
|
|
175
|
+
pending = null;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Called at the START of attempt `iteration`, with the PREVIOUS attempt's
|
|
180
|
+
* gap. Finalizes that attempt from the books, runs the detector, and
|
|
181
|
+
* returns the injection for the attempt about to run — or null (inert).
|
|
182
|
+
* @param {{iteration: number, gap?: string, writes: string[]}} o
|
|
183
|
+
* @returns {null | {stage: 'summary'|'verbatim', note: string, event: object}}
|
|
184
|
+
*/
|
|
185
|
+
observe({ iteration, gap, writes }) {
|
|
186
|
+
const cumulative = new Set(writes);
|
|
187
|
+
if (!gap) { // first attempt: nothing judged yet, nothing to compare
|
|
188
|
+
prevCumulative = cumulative;
|
|
189
|
+
tee = new Map();
|
|
190
|
+
pending = null;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
// finalize the attempt that just failed: its writes are the audit delta
|
|
194
|
+
const delta = [...cumulative].filter((p) => !prevCumulative.has(p));
|
|
195
|
+
// an edit to a file already written in an earlier attempt is not in the
|
|
196
|
+
// delta — the tee saw it, and a teed landed path IS this attempt's work
|
|
197
|
+
for (const p of tee.keys()) if (cumulative.has(p) && !delta.includes(p)) delta.push(p);
|
|
198
|
+
// `gap` is guaranteed truthy here (the `if (!gap) return` guard above handled
|
|
199
|
+
// the empty case); reds === null comes only from keptSet's own UNKNOWN logic.
|
|
200
|
+
attempts.push({ writes: delta, reds: keptSet(gap), tee });
|
|
201
|
+
// The detector only ever compares the last two finalized attempts, so older
|
|
202
|
+
// ones (and their teed content) are dead state — keep just the two (Finding 7).
|
|
203
|
+
if (attempts.length > 2) attempts.shift();
|
|
204
|
+
prevCumulative = cumulative;
|
|
205
|
+
tee = new Map();
|
|
206
|
+
// a stage whose verdict never arrived (the attempt ended mid-flight) is not
|
|
207
|
+
// a landed write and must not survive into the next attempt's tee
|
|
208
|
+
pending = null;
|
|
209
|
+
|
|
210
|
+
if (attempts.length < 2) return null;
|
|
211
|
+
const [prev, last] = attempts.slice(-2);
|
|
212
|
+
const overlap = last.writes.filter((p) => prev.writes.includes(p));
|
|
213
|
+
// Both UNKNOWN → no trustworthy red-set either side: write-overlap alone
|
|
214
|
+
// decides (writes-only mode, named below). One known and one UNKNOWN → the
|
|
215
|
+
// red-set is not provably the same, so it counts as MOVEMENT, never a fire
|
|
216
|
+
// (a real red-set followed by a crash/empty gap is new information, not
|
|
217
|
+
// repetition). Both known → the honest set comparison.
|
|
218
|
+
const redsSame = last.reds === null && prev.reds === null ? null
|
|
219
|
+
: last.reds === null || prev.reds === null ? false
|
|
220
|
+
: JSON.stringify(last.reds) === JSON.stringify(prev.reds);
|
|
221
|
+
// Finding 3: when writes carry no information (text mode — the one target is
|
|
222
|
+
// rewritten every attempt regardless of progress), write-overlap alone can
|
|
223
|
+
// never establish fixation; only a KNOWN-unmoved red-set can. So the
|
|
224
|
+
// degraded writes-only path (redsSame === null) is available to fire only
|
|
225
|
+
// when writes ARE informative (tool mode). With informative writes the prior
|
|
226
|
+
// behavior stands: overlap + reds-not-known-to-have-moved.
|
|
227
|
+
const redsGate = writesInformative ? redsSame !== false : redsSame === true;
|
|
228
|
+
const fixated = overlap.length > 0 && redsGate;
|
|
229
|
+
if (!fixated) { streak = 0; return null; }
|
|
230
|
+
streak += 1;
|
|
231
|
+
|
|
232
|
+
const stage = streak === 1 ? 'summary' : 'verbatim';
|
|
233
|
+
const mode = redsSame === null ? 'writes-only' : 'reds+writes';
|
|
234
|
+
const shownPaths = overlap.slice(0, SUMMARY_MAX_PATHS);
|
|
235
|
+
const morePaths = overlap.length - shownPaths.length;
|
|
236
|
+
const event = {
|
|
237
|
+
iteration, stage, mode, streak,
|
|
238
|
+
paths: shownPaths, ...(morePaths > 0 ? { pathsElided: morePaths } : {}),
|
|
239
|
+
redSetSize: last.reds ? last.reds.length : null,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Finding 7: what the worker REACHED FOR fired the detector; what it
|
|
243
|
+
// ACHIEVED decides the wording. Only paths this attempt actually teed can
|
|
244
|
+
// answer the outcome question — a path known solely from the audit is
|
|
245
|
+
// unattributable, so it falls back to the neutral (landed) phrasing rather
|
|
246
|
+
// than guessing.
|
|
247
|
+
const attempted = overlap.filter((p) => last.tee.has(p));
|
|
248
|
+
const noneApplied = attempted.length > 0
|
|
249
|
+
&& attempted.every((p) => !(/** @type {TeeEntry} */ (last.tee.get(p)).landed));
|
|
250
|
+
|
|
251
|
+
if (stage === 'summary') {
|
|
252
|
+
const redsClause = mode === 'reds+writes'
|
|
253
|
+
? 'and the set of failing tests did not change at all'
|
|
254
|
+
: 'with no visible progress';
|
|
255
|
+
// The unapplied case is the MECHANICAL genre (F38: gaps that name a wall
|
|
256
|
+
// convert; semantic ones stall) — naming the missed anchor is strictly
|
|
257
|
+
// more actionable than "form a different hypothesis", because the
|
|
258
|
+
// hypothesis may have been right and only the anchor wrong.
|
|
259
|
+
const body = noneApplied
|
|
260
|
+
? `Your last two attempts both tried to change ${shownPaths.join(', ')}`
|
|
261
|
+
+ `${morePaths > 0 ? ` (and ${morePaths} more)` : ''} and NEITHER EDIT APPLIED — `
|
|
262
|
+
+ 'the text you anchored on was not found in the file, so nothing changed. '
|
|
263
|
+
+ 'Re-read the file and quote the exact text (whitespace and indentation included) before editing again.'
|
|
264
|
+
: `Your last two attempts both rewrote ${shownPaths.join(', ')}`
|
|
265
|
+
+ `${morePaths > 0 ? ` (and ${morePaths} more)` : ''} ${redsClause}. `
|
|
266
|
+
+ 'Do NOT retry the same change — form a DIFFERENT hypothesis before your next edit.';
|
|
267
|
+
return { stage, event, note: `RATCHET: you are repeating yourself. ${body}` };
|
|
268
|
+
}
|
|
269
|
+
// verbatim: surface the worker's own last content for the repeated paths —
|
|
270
|
+
// scrubbed, capped, every trim announced. Grouped by OUTCOME, because the
|
|
271
|
+
// two groups need opposite advice: content that landed and failed must not
|
|
272
|
+
// be written again, while content that never applied may have been the
|
|
273
|
+
// right change aimed at the wrong anchor — telling that worker to "write
|
|
274
|
+
// something structurally different" would steer it away from a correct fix.
|
|
275
|
+
const files = attempted.slice(0, VERBATIM_MAX_FILES);
|
|
276
|
+
const moreFiles = attempted.length - files.length;
|
|
277
|
+
/** @param {string} p */
|
|
278
|
+
const block = (p) => {
|
|
279
|
+
const t = /** @type {TeeEntry} */ (last.tee.get(p));
|
|
280
|
+
// t.content was already scrubbed by redact() in stageWrite before storage
|
|
281
|
+
// (Finding 6): re-redacting here is redundant work over already-masked text.
|
|
282
|
+
return `--- ${p}${t.trimmed ? ` (truncated to first ${TEE_CAP_BYTES} chars)` : ''} ---\n${t.content}`;
|
|
283
|
+
};
|
|
284
|
+
const landedFiles = files.filter((p) => /** @type {TeeEntry} */ (last.tee.get(p)).landed);
|
|
285
|
+
const unappliedFiles = files.filter((p) => !(/** @type {TeeEntry} */ (last.tee.get(p)).landed));
|
|
286
|
+
const sections = [];
|
|
287
|
+
if (landedFiles.length) {
|
|
288
|
+
sections.push('These are your OWN previous changes — they landed, and they did NOT fix the failing set:\n'
|
|
289
|
+
+ landedFiles.map(block).join('\n')
|
|
290
|
+
+ '\nDo not write these again. Write something STRUCTURALLY DIFFERENT, or change a different file.');
|
|
291
|
+
}
|
|
292
|
+
if (unappliedFiles.length) {
|
|
293
|
+
sections.push('This is text you tried to write, and it did NOT apply — the anchor was not found, '
|
|
294
|
+
+ 'so the file is UNCHANGED and none of this is in it:\n'
|
|
295
|
+
+ unappliedFiles.map(block).join('\n')
|
|
296
|
+
+ '\nRe-read the file and copy the exact text you mean to replace, character for character, before editing again.');
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
stage, event,
|
|
300
|
+
note: `RATCHET: you are STILL repeating yourself. ${sections.join('\n\n')}`
|
|
301
|
+
+ (moreFiles > 0 ? `\n(and ${moreFiles} more repeated file${moreFiles > 1 ? 's' : ''}, elided)` : ''),
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
package/src/run.js
CHANGED
|
@@ -183,6 +183,12 @@ async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
|
|
|
183
183
|
* boundary — the Gate still caps within a step, but an unpriced result cannot
|
|
184
184
|
* be summed, so the run stops rather than counting it $0 (F6).
|
|
185
185
|
*
|
|
186
|
+
* EVERY terminal `job-end` states the money: `spentUsd` (the sum of PRICED
|
|
187
|
+
* rounds only — never an estimate; a real 0 on the pre-token reds) plus
|
|
188
|
+
* `spendComplete`, false when any round came back unpriced and the figure is
|
|
189
|
+
* therefore a FLOOR, not the total. Both are always present, so a consumer
|
|
190
|
+
* never branches on field presence nor launders a blank into $0.
|
|
191
|
+
*
|
|
186
192
|
* @param {object|string} rawSpec the job spec (job-v1), text or parsed
|
|
187
193
|
* @param {object} opts
|
|
188
194
|
* @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
|
|
@@ -194,21 +200,41 @@ async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
|
|
|
194
200
|
* @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
|
|
195
201
|
* @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
|
|
196
202
|
* @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
|
|
197
|
-
*
|
|
203
|
+
* @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
|
|
204
|
+
* territory, threaded to every predicate step's interpreter. Defaults OFF
|
|
205
|
+
* (decided 2026-07-21): fixation is extinct on every current job (F41), so
|
|
206
|
+
* ON has never won its own A/B — the acceptance read defers to Layer 2 (or
|
|
207
|
+
* a manufactured-fixation probe). `true` is the ON/experimental arm; the
|
|
208
|
+
* default is the OFF arm. (Shell-owned seam, same doctrine as the provider
|
|
209
|
+
* binding.)
|
|
198
210
|
* @returns {Promise<string>} outcome: 'green' | 'escalated' | 'unapproved-spec' |
|
|
199
211
|
* 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
|
|
200
212
|
* 'cap-halt' | 'close-unsupported' | `step-red:<id>`
|
|
201
213
|
*/
|
|
202
|
-
export async function runJob(rawSpec, { approvals, workdir, target, provider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec }) {
|
|
214
|
+
export async function runJob(rawSpec, { approvals, workdir, target, provider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec, layerRoot = false }) {
|
|
215
|
+
// 0. the ledger's counters, declared FIRST so that every job-end — including
|
|
216
|
+
// the pre-token reds below — can state a real figure. An omitted `spentUsd` is
|
|
217
|
+
// not a zero: a consumer reads `undefined` and either crashes or launders it
|
|
218
|
+
// into $0 (F12's class, at the terminal record instead of mid-attempt).
|
|
219
|
+
let spentUsd = 0;
|
|
220
|
+
let unpriced = false;
|
|
221
|
+
// The money on the terminal record, and whether the money is EXACT. `spentUsd`
|
|
222
|
+
// is the accumulated sum of PRICED rounds ONLY — never an estimate derived
|
|
223
|
+
// from tokens or averages (cap-not-estimate). When any round came back
|
|
224
|
+
// unpriced (F6) that sum is a FLOOR, not the total, and `spendComplete: false`
|
|
225
|
+
// says so machine-readably instead of dressing a floor up as exact. Emitted on
|
|
226
|
+
// EVERY job-end (true when everything was priced) so no consumer ever has to
|
|
227
|
+
// branch on field presence.
|
|
228
|
+
const spend = () => ({ spentUsd, spendComplete: !unpriced });
|
|
203
229
|
// 1. human-signs-always — before ANY provider call (N1 decision #1)
|
|
204
230
|
if (!checkApproval(rawSpec, approvals)) {
|
|
205
|
-
emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version' });
|
|
231
|
+
emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
|
|
206
232
|
return 'unapproved-spec';
|
|
207
233
|
}
|
|
208
234
|
const jv = validateJob(rawSpec, { shellCapUsd });
|
|
209
235
|
if (!jv.ok) {
|
|
210
236
|
for (const r of jv.reds) emit('job-red', r);
|
|
211
|
-
emit('job-end', { outcome: 'job-red' });
|
|
237
|
+
emit('job-end', { outcome: 'job-red', ...spend() });
|
|
212
238
|
return 'job-red';
|
|
213
239
|
}
|
|
214
240
|
const job = /** @type {any} */ (jv.job);
|
|
@@ -218,7 +244,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
218
244
|
if ((typeof target !== 'string' || !target)
|
|
219
245
|
&& job.steps.some((/** @type {any} */ s) => s.close.type === 'predicate' && (s.mode ?? 'text') === 'text')) {
|
|
220
246
|
emit('job-red', { code: 'missing-required', path: 'opts.target', detail: 'text-mode steps write ONE artifact — pass opts.target (reds-before-tokens)' });
|
|
221
|
-
emit('job-end', { outcome: 'job-red' });
|
|
247
|
+
emit('job-end', { outcome: 'job-red', ...spend() });
|
|
222
248
|
return 'job-red';
|
|
223
249
|
}
|
|
224
250
|
emit('job-start', { job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd, steps: job.steps.map((/** @type {any} */ s) => s.id) });
|
|
@@ -228,14 +254,12 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
228
254
|
emit('primitive-smoke', smoke);
|
|
229
255
|
if (!smoke.ok) {
|
|
230
256
|
emit('escalation', { category: 'smoke-red', decisionReady: true, decision: `The ${smoke.primitive} primitive failed its known-answer check — no run verdict is trustworthy on a degraded primitive.`, options: ['fix the primitive/store', 'abandon the run'], detail: smoke.detail });
|
|
231
|
-
emit('job-end', { outcome: 'smoke-red' });
|
|
257
|
+
emit('job-end', { outcome: 'smoke-red', ...spend() });
|
|
232
258
|
return 'smoke-red';
|
|
233
259
|
}
|
|
234
260
|
|
|
235
261
|
// 3. the ONE ledger. Unpriced is never free (F6): a null cost can't be
|
|
236
262
|
// summed, so it flags a stop instead of accumulating $0.
|
|
237
|
-
let spentUsd = 0;
|
|
238
|
-
let unpriced = false;
|
|
239
263
|
/** @param {number|null|undefined} c */
|
|
240
264
|
const account = (c) => { if (typeof c === 'number' && Number.isFinite(c)) spentUsd += c; else unpriced = true; };
|
|
241
265
|
/** @type {(type: string, data?: object) => object} */
|
|
@@ -256,7 +280,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
256
280
|
};
|
|
257
281
|
const pricingRed = () => {
|
|
258
282
|
emit('escalation', { category: 'pricing-red', decisionReady: true, decision: 'A provider result carried no priced cost — the hard cap cannot govern spend it cannot see (unpriced is never free, F6).', options: ['bind a priced provider/model', 'abandon the run'], spentUsd });
|
|
259
|
-
emit('job-end', { outcome: 'pricing-red' });
|
|
283
|
+
emit('job-end', { outcome: 'pricing-red', ...spend() });
|
|
260
284
|
return 'pricing-red';
|
|
261
285
|
};
|
|
262
286
|
|
|
@@ -294,7 +318,11 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
294
318
|
};
|
|
295
319
|
const providerRed = () => {
|
|
296
320
|
emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The provider path threw before a result existed — spend for the failed call is unknown (F6), and no drafting verdict exists.', options: ['fix the provider binding', 'retry the run', 'abandon the run'], detail: transportRed, spentUsd });
|
|
297
|
-
|
|
321
|
+
// A transport THROW never returned a usage figure, so the floor is NOT the total
|
|
322
|
+
// (F6): spendComplete must be false — spend()'s `!unpriced` only knows about priced
|
|
323
|
+
// rounds that came back unpriced, not a call that never returned at all. Leaving it
|
|
324
|
+
// true would have the job-end contradict the escalation's own "spend … is unknown".
|
|
325
|
+
emit('job-end', { outcome: 'provider-red', ...spend(), spendComplete: false });
|
|
298
326
|
return 'provider-red';
|
|
299
327
|
};
|
|
300
328
|
const capNow = () => Math.min(shellCapUsd, job.budgetUsd - spentUsd);
|
|
@@ -345,7 +373,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
345
373
|
// not on trial. Mirrors the worker path (interpret.js) and the human's options: the round
|
|
346
374
|
// was metered, so this is a fault with a known cost, not a spend-unknown transport throw.
|
|
347
375
|
emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The drafting round was truncated at the output cap (BA-6) — the API cut the config off mid-generation, so no valid config exists. Not the drafter\'s competence (never a config-red), and not silently retried.', options: ['raise the drafting cap and rerun', 'retry the run', 'abandon the run'], detail: draftTruncated, spentUsd });
|
|
348
|
-
emit('job-end', { outcome: 'provider-red' });
|
|
376
|
+
emit('job-end', { outcome: 'provider-red', ...spend() });
|
|
349
377
|
return 'provider-red';
|
|
350
378
|
}
|
|
351
379
|
if (unpriced) return pricingRed();
|
|
@@ -355,11 +383,11 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
355
383
|
// the honest stop is a cap story, never a config-red blaming the drafter
|
|
356
384
|
emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', spentUsd, budgetUsd: job.budgetUsd });
|
|
357
385
|
emit('escalation', { category: 'cap-halt', decisionReady: true, decision: `Drafting spend ($${spentUsd.toFixed(4)}) consumed the job budget ($${job.budgetUsd}) before a valid config existed.`, options: ['raise the job budget and rerun', 'abandon the run'], spentUsd });
|
|
358
|
-
emit('job-end', { outcome: 'cap-halt' });
|
|
386
|
+
emit('job-end', { outcome: 'cap-halt', ...spend() });
|
|
359
387
|
return 'cap-halt';
|
|
360
388
|
}
|
|
361
389
|
for (const r of cv.reds) emit('config-red', r);
|
|
362
|
-
emit('job-end', { outcome: 'config-red' });
|
|
390
|
+
emit('job-end', { outcome: 'config-red', ...spend() });
|
|
363
391
|
return 'config-red';
|
|
364
392
|
}
|
|
365
393
|
config = cv.config;
|
|
@@ -402,14 +430,14 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
402
430
|
// baseline — loud, and never allowed to un-open a real PR
|
|
403
431
|
if (pr.strandedOn) emit('workdir-red', { step: step.id, branch: pr.strandedOn, detail: 'the run could not return the checkout to its starting branch — the next run would build on this one' });
|
|
404
432
|
emit('escalation', { category: 'hitl-close', decisionReady: true, step: step.id, prompt: step.close.prompt, spentUsd, decision: step.close.prompt, options: ['approve', 'reject'], pr: { url: pr.url, branch, error: pr.red?.detail ?? null } });
|
|
405
|
-
emit('job-end', { outcome: 'escalated', step: step.id,
|
|
433
|
+
emit('job-end', { outcome: 'escalated', step: step.id, ...spend() });
|
|
406
434
|
return 'escalated'; // by design: the human acts outside the run, forever
|
|
407
435
|
}
|
|
408
436
|
if (step.close.type !== 'predicate') {
|
|
409
437
|
// honest refusal: gold/rubric EXECUTION lands with the verdict classes
|
|
410
438
|
// (N4); a fake verdict here would poison every contrast downstream
|
|
411
439
|
emit('escalation', { category: 'close-unsupported', decisionReady: true, step: step.id, decision: `Step "${step.id}" has a ${step.close.type} close — N2 executes predicate and hitl closes only.`, options: ['restate the close as a predicate', 'wait for the verdict-classes rung'] });
|
|
412
|
-
emit('job-end', { outcome: 'close-unsupported', step: step.id });
|
|
440
|
+
emit('job-end', { outcome: 'close-unsupported', step: step.id, ...spend() });
|
|
413
441
|
return 'close-unsupported';
|
|
414
442
|
}
|
|
415
443
|
emit('step-start', { step: step.id, remainingUsd: job.budgetUsd - spentUsd, spentUsd, mode: step.mode ?? 'text' });
|
|
@@ -436,7 +464,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
436
464
|
const fault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
437
465
|
if (fault) {
|
|
438
466
|
emit('escalation', { category: fault.category, decisionReady: true, step: step.id, decision: fault.decision, options: fault.options, detail: pre.detail, spentUsd });
|
|
439
|
-
emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category,
|
|
467
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category, ...spend() });
|
|
440
468
|
return `step-red:${step.id}`;
|
|
441
469
|
}
|
|
442
470
|
// The precheck's gap goes to the worker as what it IS: the close's output on
|
|
@@ -458,21 +486,22 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, em
|
|
|
458
486
|
// arbiter territory (PRD v1.11; F28 for gapKeep).
|
|
459
487
|
mode: step.mode ?? 'text', tools: step.tools,
|
|
460
488
|
closeExpect: step.close.expect, closeJudged: step.close.judged, closeGapKeep: step.close.gapKeep,
|
|
489
|
+
layerRoot,
|
|
461
490
|
});
|
|
462
491
|
} catch (e) {
|
|
463
492
|
// ralph belts throws INSIDE the loop; this belts the interpreter's own
|
|
464
493
|
// setup (gate.init, store ctor) — the spine must terminate, never dangle
|
|
465
494
|
emit('escalation', { category: 'interpreter-red', decisionReady: true, step: step.id, decision: 'The interpreter broke outside the loop — no harness verdict is trustworthy until it is fixed.', options: ['fix the interpreter/run directory', 'abandon the run'], detail: String(/** @type {Error} */ (e)?.message ?? e), spentUsd });
|
|
466
|
-
emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red',
|
|
495
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red', ...spend() });
|
|
467
496
|
return `step-red:${step.id}`;
|
|
468
497
|
}
|
|
469
498
|
emit('step-end', { step: step.id, outcome, spentUsd });
|
|
470
499
|
if (unpriced) return pricingRed();
|
|
471
500
|
if (outcome !== 'green') {
|
|
472
|
-
emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome,
|
|
501
|
+
emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome, ...spend() });
|
|
473
502
|
return `step-red:${step.id}`;
|
|
474
503
|
}
|
|
475
504
|
}
|
|
476
|
-
emit('job-end', { outcome: 'green',
|
|
505
|
+
emit('job-end', { outcome: 'green', ...spend() });
|
|
477
506
|
return 'green';
|
|
478
507
|
}
|
package/types/interpret.d.ts
CHANGED
|
@@ -42,11 +42,18 @@
|
|
|
42
42
|
* @param {'text'|'tools'} [opts.mode] middle mode (2b): 'text' (default) writes the ONE
|
|
43
43
|
* target from the response artifact; 'tools' gives the worker Gate-governed file
|
|
44
44
|
* tools — SPEC-side territory (the step declares it; the config cannot express it)
|
|
45
|
+
* @param {boolean} [opts.layerRoot=false] Layer R (the within-run ratchet, src/root.js):
|
|
46
|
+
* shell-assembled fixation detection over the run's own books, injecting an
|
|
47
|
+
* escalating summary→verbatim note when consecutive attempts rewrite the same
|
|
48
|
+
* files without moving the reds. Inert when the worker is not stuck (RSI §3.3).
|
|
49
|
+
* Defaults OFF (decided 2026-07-21): fixation is extinct on every current job
|
|
50
|
+
* (F41), so ON is unproven — the field read defers to Layer 2. `true` is the
|
|
51
|
+
* ON/experimental arm; never a worker-visible knob
|
|
45
52
|
* @param {string[]} [opts.tools] the spec's tool grant (subset of read|grep|write,
|
|
46
53
|
* job-v1 validated); defaults to the full menu in tool mode
|
|
47
54
|
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
48
55
|
*/
|
|
49
|
-
export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor, closeTimeoutMs, mode, tools, closeState, closeExpect, closeJudged, closeGapKeep }: {
|
|
56
|
+
export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor, closeTimeoutMs, mode, tools, closeState, closeExpect, closeJudged, closeGapKeep, layerRoot }: {
|
|
50
57
|
task: string;
|
|
51
58
|
target?: string | undefined;
|
|
52
59
|
close: string[];
|
|
@@ -75,6 +82,7 @@ export function interpret(configRaw: object | string, { task, target, close, wor
|
|
|
75
82
|
}>) | undefined;
|
|
76
83
|
closeState?: string | undefined;
|
|
77
84
|
mode?: "text" | "tools" | undefined;
|
|
85
|
+
layerRoot?: boolean | undefined;
|
|
78
86
|
tools?: string[] | undefined;
|
|
79
87
|
}): Promise<"green" | "escalated" | "config-red">;
|
|
80
88
|
/** @typedef {Error & {category?: string, lib?: string}} CategorizedError the failure map's carrier: ralph relays by `category`, and by `lib` when the thrower knows which library owns the failure (the ledger prefers it over prose) */
|
package/types/ralph.d.ts
CHANGED
|
@@ -134,6 +134,7 @@ export function ralph({ middle, close, capRuns, emit, redact, closeTimeoutMs, cw
|
|
|
134
134
|
gapKeep?: string | undefined;
|
|
135
135
|
workerWrites?: (() => string[]) | undefined;
|
|
136
136
|
}): Promise<"green" | "escalated">;
|
|
137
|
+
export const GAP_KEEP_TRIM_MARKER: "more elided by the";
|
|
137
138
|
/**
|
|
138
139
|
* The forbidden zone, named (PRD v1.11). Each row is an outcome in which the
|
|
139
140
|
* close rendered NO JUDGMENT — so it is not a verdict, it never feeds a gap
|
package/types/root.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root collector. One per run; state dies with the run.
|
|
3
|
+
*
|
|
4
|
+
* Call order (wired by the interpreter's middle):
|
|
5
|
+
* observe({iteration: 1, writes}) → null (nothing to compare)
|
|
6
|
+
* stageWrite(path, content) → stage, before the tool runs
|
|
7
|
+
* settleWrite(landed) | discardWrite() → the outcome, after it ran
|
|
8
|
+
* noteWrite(path, content) → stage+settle, text mode (which
|
|
9
|
+
* writes before it reports)
|
|
10
|
+
* observe({iteration: N, gap, writes}) → null | {stage, note, event}
|
|
11
|
+
* `writes` is the CUMULATIVE allow-decision write/edit path list from the gate
|
|
12
|
+
* audit (the F32 instrument); the per-attempt set is the delta between calls —
|
|
13
|
+
* so a gate-DENIED write is never counted (the tool never ran at all).
|
|
14
|
+
*
|
|
15
|
+
* Finding 7 — the two axes, deliberately NOT merged. An allow-decision records
|
|
16
|
+
* what the worker REACHED FOR, which is the right instrument for "is it
|
|
17
|
+
* repeating itself"; whether those bytes reached the file is a different
|
|
18
|
+
* question, and only the note (a claim about file contents) needs it. Collapsing
|
|
19
|
+
* them either way is a bug: settling the note on the allow makes it lie, and
|
|
20
|
+
* running the detector off the file makes it blind to a worker re-firing an
|
|
21
|
+
* identical edit whose anchor never matches — measured null on every attempt.
|
|
22
|
+
*
|
|
23
|
+
* @param {{gapKeep?: string, redact?: (s: string) => string, writesInformative?: boolean}} [opts]
|
|
24
|
+
* the signed spec's kept-failures pattern (the red-set source; without it the
|
|
25
|
+
* detector degrades to write-overlap alone and the event says so), the shell's
|
|
26
|
+
* secret scrubber (verbatim content is scrubbed before it rides a prompt), and
|
|
27
|
+
* `writesInformative` (Finding 3, default true): whether the per-attempt
|
|
28
|
+
* write-set carries information. In TOOL mode the worker CHOOSES which files to
|
|
29
|
+
* write, so re-writing the same file is a real repetition signal; in TEXT mode
|
|
30
|
+
* the one target is rewritten every attempt by construction, so write-overlap
|
|
31
|
+
* is constant-true and carries none — with no red-set to compare, the detector
|
|
32
|
+
* cannot tell repetition from progress and must stay inert (it would otherwise
|
|
33
|
+
* steer a progressing worker off course, violating "inert when not stuck").
|
|
34
|
+
*/
|
|
35
|
+
export function createRoot({ gapKeep, redact, writesInformative }?: {
|
|
36
|
+
gapKeep?: string;
|
|
37
|
+
redact?: (s: string) => string;
|
|
38
|
+
writesInformative?: boolean;
|
|
39
|
+
}): {
|
|
40
|
+
stageWrite: (path: string, content: string) => void;
|
|
41
|
+
/**
|
|
42
|
+
* The staged write RAN — record what it actually did (Finding 7). Retained
|
|
43
|
+
* either way: an attempted-but-unapplied edit is still the worker reaching
|
|
44
|
+
* for the same file, which is what the detector measures, and dropping it
|
|
45
|
+
* here would blind the ratchet to the purest fixation there is (a worker
|
|
46
|
+
* re-firing an identical edit whose anchor never matches — measured null on
|
|
47
|
+
* every attempt under a tree-diff detector, 2026-07-20). Only the NOTE's
|
|
48
|
+
* wording turns on `landed`. Last write per path wins (the tree's final
|
|
49
|
+
* state for that attempt is what failed the close).
|
|
50
|
+
* @param {boolean} landed whether the content is now in the file
|
|
51
|
+
*/
|
|
52
|
+
settleWrite(landed: boolean): void;
|
|
53
|
+
/**
|
|
54
|
+
* The staged write was DENIED or HALTED: it is not in the file, so it must
|
|
55
|
+
* never be surfaced as "your own change that landed". Drops the stage and
|
|
56
|
+
* leaves whatever DID land for that path untouched.
|
|
57
|
+
*/
|
|
58
|
+
discardWrite(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Stage and commit in one call — the post-allow write site (text mode
|
|
61
|
+
* gate-checks and writes before it calls this, so the verdict is already in).
|
|
62
|
+
* @param {string} path @param {string} content
|
|
63
|
+
*/
|
|
64
|
+
noteWrite(path: string, content: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* Called at the START of attempt `iteration`, with the PREVIOUS attempt's
|
|
67
|
+
* gap. Finalizes that attempt from the books, runs the detector, and
|
|
68
|
+
* returns the injection for the attempt about to run — or null (inert).
|
|
69
|
+
* @param {{iteration: number, gap?: string, writes: string[]}} o
|
|
70
|
+
* @returns {null | {stage: 'summary'|'verbatim', note: string, event: object}}
|
|
71
|
+
*/
|
|
72
|
+
observe({ iteration, gap, writes }: {
|
|
73
|
+
iteration: number;
|
|
74
|
+
gap?: string;
|
|
75
|
+
writes: string[];
|
|
76
|
+
}): null | {
|
|
77
|
+
stage: "summary" | "verbatim";
|
|
78
|
+
note: string;
|
|
79
|
+
event: object;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
export function normalizeRedLine(line: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* A teed write: the content the worker emitted, plus whether it actually reached
|
|
85
|
+
* the file. `landed` is the OUTCOME axis (Finding 7) — the gate's allow is an
|
|
86
|
+
* intent record written before the tool runs, and a shell_edit whose anchor
|
|
87
|
+
* misses returns a refusal RESULT with the file untouched (bare-agent
|
|
88
|
+
* tools/shell.js:190). A note that says "these are your changes" is a claim
|
|
89
|
+
* about the file's contents and must be true, so it reads this flag, never the
|
|
90
|
+
* audit. The DETECTOR deliberately does not: see `observe`.
|
|
91
|
+
*/
|
|
92
|
+
export type TeeEntry = {
|
|
93
|
+
content: string;
|
|
94
|
+
trimmed: boolean;
|
|
95
|
+
landed: boolean;
|
|
96
|
+
};
|
package/types/run.d.ts
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
* boundary — the Gate still caps within a step, but an unpriced result cannot
|
|
18
18
|
* be summed, so the run stops rather than counting it $0 (F6).
|
|
19
19
|
*
|
|
20
|
+
* EVERY terminal `job-end` states the money: `spentUsd` (the sum of PRICED
|
|
21
|
+
* rounds only — never an estimate; a real 0 on the pre-token reds) plus
|
|
22
|
+
* `spendComplete`, false when any round came back unpriced and the figure is
|
|
23
|
+
* therefore a FLOOR, not the total. Both are always present, so a consumer
|
|
24
|
+
* never branches on field presence nor launders a blank into $0.
|
|
25
|
+
*
|
|
20
26
|
* @param {object|string} rawSpec the job spec (job-v1), text or parsed
|
|
21
27
|
* @param {object} opts
|
|
22
28
|
* @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
|
|
@@ -28,12 +34,18 @@
|
|
|
28
34
|
* @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
|
|
29
35
|
* @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
|
|
30
36
|
* @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
|
|
31
|
-
*
|
|
37
|
+
* @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
|
|
38
|
+
* territory, threaded to every predicate step's interpreter. Defaults OFF
|
|
39
|
+
* (decided 2026-07-21): fixation is extinct on every current job (F41), so
|
|
40
|
+
* ON has never won its own A/B — the acceptance read defers to Layer 2 (or
|
|
41
|
+
* a manufactured-fixation probe). `true` is the ON/experimental arm; the
|
|
42
|
+
* default is the OFF arm. (Shell-owned seam, same doctrine as the provider
|
|
43
|
+
* binding.)
|
|
32
44
|
* @returns {Promise<string>} outcome: 'green' | 'escalated' | 'unapproved-spec' |
|
|
33
45
|
* 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
|
|
34
46
|
* 'cap-halt' | 'close-unsupported' | `step-red:<id>`
|
|
35
47
|
*/
|
|
36
|
-
export function runJob(rawSpec: object | string, { approvals, workdir, target, provider, emit, capRuns, shellCapUsd, closeTimeoutMs, execCmd }: {
|
|
48
|
+
export function runJob(rawSpec: object | string, { approvals, workdir, target, provider, emit, capRuns, shellCapUsd, closeTimeoutMs, execCmd, layerRoot }: {
|
|
37
49
|
approvals: unknown;
|
|
38
50
|
workdir: string;
|
|
39
51
|
target?: string | undefined;
|
|
@@ -43,6 +55,7 @@ export function runJob(rawSpec: object | string, { approvals, workdir, target, p
|
|
|
43
55
|
shellCapUsd?: number | undefined;
|
|
44
56
|
closeTimeoutMs?: number | undefined;
|
|
45
57
|
execCmd?: ExecCmd | undefined;
|
|
58
|
+
layerRoot?: boolean | undefined;
|
|
46
59
|
}): Promise<string>;
|
|
47
60
|
export type Red = {
|
|
48
61
|
code: string;
|