bareloop 0.2.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/src/ralph.js CHANGED
@@ -8,7 +8,35 @@ import { spawnSync } from 'node:child_process';
8
8
 
9
9
  /**
10
10
  * Run a close command; its exit code is the verdict (hard green, PRD §4).
11
- * 0 → satisfied; nonzero → needs_revision (gap fed back); spawn error → failed (terminal).
11
+ * `expect` → satisfied; any other exit → needs_revision (gap fed back).
12
+ *
13
+ * THE FORBIDDEN ZONE (PRD v1.11, F17; adaptlearn F25/V10). The two clean bands
14
+ * are green (exit == expect, judgment rendered) and red (exit != expect,
15
+ * judgment rendered). Every other outcome rendered NO JUDGMENT and is therefore
16
+ * NOT A VERDICT — it gets its own name and escalates; coercing one into a
17
+ * verdict IS the instrument fault (law #8 generalized):
18
+ * - `failed` — the close cannot RUN (spawn error). → broken-close
19
+ * - `timed-out` — it ran and never finished judging. → close-timeout
20
+ * - `killed` — it died by signal (status null, no spawn error). → close-killed
21
+ * - `crashed` — it ran, exited, and judged nothing. → close-crashed
22
+ * `timed-out` is split OUT of `failed` deliberately: "raise the timeout" and
23
+ * "fix the argv" are different human decisions, so pooling them erases the
24
+ * decision information the escalation exists to carry.
25
+ *
26
+ * `judged` is the judgment-rendered signal, and it is the hard one. Exit code
27
+ * ALONE cannot separate a crash-at-load from an honest red — they are
28
+ * byte-identical at this seam, and against `node --test` so are the test counts
29
+ * (node synthesizes ONE failing test for the file that crashed, so a crash
30
+ * reports `tests 1 / fail 1`, exactly like a one-assertion failure). So the
31
+ * signal cannot be "zero judged"; it must be a FLOOR against a declared
32
+ * baseline: litectx runs ~390 tests, and a run claiming it judged 1 did not
33
+ * judge. `judged.pattern` extracts one integer from the close's own (redacted)
34
+ * output; below `judged.min` the close crashed, WHATEVER its exit code says —
35
+ * and that check runs on the GREEN side too: a close that exits 0 having judged
36
+ * nothing is a confident fake green, the only real failure there is (law #8).
37
+ * `judged` is OPTIONAL — a linter or a hitl close may have no count to give —
38
+ * and its absence stamps `unaudited: true` so the blind spot is NAMED on the
39
+ * record rather than passed off as a trustworthy exit code.
12
40
  *
13
41
  * `redact` scrubs the close's own output at the SOURCE — before the gap becomes
14
42
  * a spine event or a worker prompt — so a secret a checked command echoes
@@ -18,22 +46,179 @@ import { spawnSync } from 'node:child_process';
18
46
  * primitive (bareguard's, wired by the interpreter), never an emergent
19
47
  * component — so V4 holds. The shell's canonical emission IS the redacted text;
20
48
  * a benign gap is returned byte-identical (default: identity).
49
+ * `cwd` is where the close RUNS, and it is load-bearing (F8): a close is a
50
+ * command in a repository — `npm test`, `make check` — and every one of them is
51
+ * cwd-relative. Run it anywhere but the workdir and the arbiter judges the wrong
52
+ * tree: exit-code-is-truth silently becomes exit-code-of-some-other-repo-is-truth.
53
+ * The default (the runner's own cwd) exists only for callers with an absolute
54
+ * close; the runner and the interpreter always pass the workdir.
55
+ *
56
+ * `gapKeep` (F28) is a regex SOURCE whose matching lines are PRESERVED through
57
+ * the gap bound in addition to the head/tail sample: a large TAP suite prints
58
+ * its `not ok` lines in the middle of the stream, exactly where `boundGap`
59
+ * elides — so the failing-test NAMES (the causal input the worker navigates on)
60
+ * were deleted in transit and the worker was told "5 fail" and never WHICH. It
61
+ * is arbiter territory like the rest here: the drafted config cannot express it.
62
+ *
21
63
  * @param {string[]} close argv, e.g. ['node', '--test', 'close/']
22
64
  * @param {(s: string) => string} [redact] source scrubber; identity by default
65
+ * @param {{ timeoutMs?: number, cwd?: string, expect?: number, judged?: {pattern: string, min: number}, gapKeep?: string }} [opts]
66
+ * close wall-clock cap (operator-set via the runner, never the config's — the
67
+ * agent must not author its arbiter's clock), the directory the close runs in
68
+ * (the workdir — the tree it is judging), the exit code the SIGNED spec calls
69
+ * success, the judgment-rendered signal, and the kept-failures pattern. All
70
+ * five are arbiter territory: the drafted workflow config cannot express any.
71
+ * @returns {{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
72
+ * gap?: string, exitCode?: number, signal?: string, detail?: string,
73
+ * judgedCount?: number|null, unaudited?: boolean}}
23
74
  */
24
- export function runClose(close, redact = (s) => s) {
75
+ export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
25
76
  const env = { ...process.env };
26
77
  delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
27
- const r = spawnSync(close[0], close.slice(1), { env, encoding: 'utf8', timeout: 120_000 });
28
- if (r.error) return { verdict: 'failed', detail: String(r.error) };
29
- if (r.status === 0) return { verdict: 'satisfied' };
78
+ const r = spawnSync(close[0], close.slice(1), { env, cwd, encoding: 'utf8', timeout: timeoutMs });
79
+
80
+ // ── forbidden zone, before any exit code is believed ──────────────────────
81
+ if (r.error) {
82
+ // spawnSync reports BOTH "cannot run" and "ran too long" through `error`;
83
+ // they are different human decisions, so they get different names.
84
+ const code = /** @type {NodeJS.ErrnoException} */ (r.error).code;
85
+ if (code === 'ETIMEDOUT') return { verdict: 'timed-out', detail: `close exceeded ${timeoutMs}ms and was killed — it never finished judging` };
86
+ return { verdict: 'failed', detail: String(r.error) };
87
+ }
88
+ // No spawn error and no exit status: the close died by signal (OOM, an
89
+ // external kill). It did not fail — it never rendered an opinion.
90
+ if (r.status === null) {
91
+ return { verdict: 'killed', signal: r.signal ?? undefined, detail: `close died by signal ${r.signal ?? 'unknown'} — no judgment was rendered` };
92
+ }
93
+
94
+ // Redact at the SOURCE, before anything is read back: the count is extracted
95
+ // from scrubbed text, so a secret in the close's output can never ride out on
96
+ // the judged path any more than it can on the gap path (the hard line).
97
+ const out = redact(r.stdout || '');
98
+ const err = redact(r.stderr || '');
99
+
100
+ // ── the judgment-rendered signal: did this close actually judge anything? ──
101
+ // Checked on BOTH bands — an exit-0 close that judged nothing is a fake green.
102
+ let judgedCount;
103
+ if (judged) {
104
+ const m = new RegExp(judged.pattern, 'm').exec(`${out}\n${err}`);
105
+ const n = m ? Number(m[1]) : NaN;
106
+ judgedCount = Number.isFinite(n) ? n : null; // honest null: no number rendered (F6)
107
+ if (judgedCount === null || judgedCount < judged.min) {
108
+ return {
109
+ verdict: 'crashed', judgedCount, exitCode: r.status,
110
+ detail: `close judged ${judgedCount ?? 'nothing'} of a declared floor of ${judged.min} — it exited ${r.status} without rendering judgment (a crash, not a verdict)`,
111
+ };
112
+ }
113
+ }
114
+
115
+ if (r.status === expect) return { verdict: 'satisfied', ...(judged ? { judgedCount } : { unaudited: true }) };
30
116
  // The gap must never be falsy: every consumer guards with `if (gap)` — an
31
117
  // empty-output red would silently kill gap feedback, after-red hooks, AND
32
118
  // stall detection, leaving the worker re-prompted byte-identically to the cap.
33
- // Redact BEFORE slicing so a token straddling the 2000-char bound can't survive.
34
- return { verdict: 'needs_revision', gap: redact(r.stderr || r.stdout || '').slice(0, 2000) || '(close exited nonzero with no output)', exitCode: r.status };
119
+ // Bound AFTER redaction so a token straddling the bound can't survive.
120
+ // BOTH streams, not one (F28 hazard): the old `err || out` returned stderr
121
+ // ALONE whenever it was non-empty, so a close writing its `not ok` lines to
122
+ // stdout while emitting any stderr noise lost the failures entirely, before
123
+ // the bound ever ran. Combine them (out then err, matching the judged path's
124
+ // `${out}\n${err}`); both empty still falls to the no-output message.
125
+ const combined = [out, err].filter(Boolean).join('\n');
126
+ return {
127
+ verdict: 'needs_revision',
128
+ gap: boundGap(combined, gapKeep) || `(close exited ${r.status}, expected ${expect}, with no output)`,
129
+ exitCode: r.status,
130
+ ...(judged ? { judgedCount } : { unaudited: true }),
131
+ };
132
+ }
133
+
134
+ // Tail-biased bound: a test runner's useful output (the assertion diff, the
135
+ // failing case name) is at the END; head-only truncation fed the worker the
136
+ // preamble and dropped the cause (N2 queue, F2). Head sample kept so "what ran"
137
+ // stays visible; the truncation marker is hygiene, not load-bearing (F3).
138
+ const GAP_HEAD = 400, GAP_TAIL = 1500;
139
+ // The keep-block cap (F28): a large suite's `not ok` lines live in the elided
140
+ // middle, so gapKeep re-surfaces them — but a pathological close (thousands of
141
+ // matching lines) could rebuild the very 67KB bloat the bound exists to prevent.
142
+ // Hard-capped by BOTH lines and bytes, whichever binds first; when the cap trims
143
+ // matches it says so with an explicit marker — silent truncation is the disease
144
+ // this whole fix cures (F28), never a cure that truncates silently in turn.
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';
152
+
153
+ /**
154
+ * Bound an over-long close stream to a head sample + elided middle + tail. When
155
+ * `keepPattern` is set (F28), lines of the WHOLE stream matching it are also
156
+ * carried in a clearly-delimited block between head and tail — capped — so the
157
+ * failing-test names buried in the middle still reach the worker.
158
+ * @param {string} s the (redacted) close output
159
+ * @param {string} [keepPattern] regex source; matching lines survive the elision
160
+ */
161
+ function boundGap(s, keepPattern) {
162
+ if (s.length <= GAP_HEAD + GAP_TAIL + 100) return s;
163
+ const head = s.slice(0, GAP_HEAD);
164
+ const tail = s.slice(-GAP_TAIL);
165
+ const elided = s.length - GAP_HEAD - GAP_TAIL;
166
+ const base = `${head}\n…[${elided} chars truncated]…`;
167
+ if (!keepPattern) return `${base}\n${tail}`;
168
+
169
+ // Scan the WHOLE stream, keep matching lines in original order, cap by lines
170
+ // AND bytes. `re.test` with no `g` flag is stateless, so reuse is safe.
171
+ const re = new RegExp(keepPattern, 'm');
172
+ /** @type {string[]} */
173
+ const matched = [];
174
+ let bytes = 0, trimmed = 0;
175
+ for (const line of s.split('\n')) {
176
+ if (!re.test(line)) continue;
177
+ if (matched.length >= GAP_KEEP_MAX_LINES || bytes + line.length + 1 > GAP_KEEP_MAX_BYTES) { trimmed += 1; continue; }
178
+ matched.push(line);
179
+ bytes += line.length + 1;
180
+ }
181
+ if (!matched.length) return `${base}\n${tail}`; // pattern matched nothing — behave as the plain bound
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` : ''})`;
183
+ return `${base}\n── ${label} ──\n${matched.join('\n')}\n── end kept failures ──\n${tail}`;
35
184
  }
36
185
 
186
+ /**
187
+ * The forbidden zone, named (PRD v1.11). Each row is an outcome in which the
188
+ * close rendered NO JUDGMENT — so it is not a verdict, it never feeds a gap
189
+ * back, and it is never retried. They are kept SEPARATE on purpose: pooling
190
+ * them would erase the decision information the escalation exists to carry —
191
+ * "raise the timeout", "fix the argv", and "re-run, it was OOM-killed" are
192
+ * three different human answers.
193
+ * Exported because the RUNNER's close-first precheck runs the same arbiter
194
+ * before any tokens: two maps would be two instruments, and two instruments
195
+ * disagreeing about the same outcome is the fault this whole addendum exists
196
+ * to close.
197
+ * @type {Record<string, {category: string, decision: string, options: string[]}>}
198
+ */
199
+ export const CLOSE_FAULTS = Object.freeze({
200
+ failed: {
201
+ category: 'broken-close',
202
+ decision: 'The close itself cannot run — no verdict is trustworthy until it is fixed.',
203
+ options: ['fix the close command', 'abandon the task'],
204
+ },
205
+ 'timed-out': {
206
+ category: 'close-timeout',
207
+ decision: 'The close ran but never finished judging — it hit the timeout and was killed. It did not fail; it did not answer.',
208
+ options: ['raise the close timeout', 'make the close faster', 'abandon the task'],
209
+ },
210
+ killed: {
211
+ category: 'close-killed',
212
+ decision: 'The close died by signal (OOM, or an external kill) — it rendered no judgment, so its exit tells you nothing about the tree.',
213
+ options: ['re-run (a transient kill)', 'fix the close environment (memory, sandbox limits)', 'abandon the task'],
214
+ },
215
+ crashed: {
216
+ category: 'close-crashed',
217
+ decision: 'The close exited without judging anything — it crashed rather than failed. Its exit code is NOT a verdict, in either direction.',
218
+ options: ['fix what makes the close crash at startup', 'fix the close argv', 'lower the declared judgment floor if the suite legitimately shrank', 'abandon the task'],
219
+ },
220
+ });
221
+
37
222
  /**
38
223
  * The loop: `while close-red and under-cap: run the middle`. Stops at first
39
224
  * green (within-run tuning past a visible close is the fit-to-pass surface,
@@ -55,10 +240,28 @@ export function runClose(close, redact = (s) => s) {
55
240
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
56
241
  * @param {(s: string) => string} [opts.redact] source scrubber for close output
57
242
  * (secrets never enter the spine); injected so the shell stays stdlib-only
243
+ * @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell/operator
244
+ * territory — the workflow config cannot express it)
245
+ * @param {string} [opts.cwd] the directory every close runs in — the tree it is
246
+ * judging (F8: a cwd-relative close run elsewhere judges the wrong repository)
247
+ * @param {number} [opts.expect] the exit code the SIGNED spec calls success
248
+ * @param {{pattern: string, min: number}} [opts.judged] the judgment-rendered signal
249
+ * @param {string} [opts.gapKeep] kept-failures pattern (F28): matching close-output
250
+ * lines survive the gap bound so a large suite's `not ok` names reach the worker
251
+ * @param {() => string[]} [opts.workerWrites] the crash-attribution instrument (F32):
252
+ * answers "which files has the WORKER written so far this run?" — the runner wires
253
+ * it to the gate audit's allow-decision writes. Injected like `redact` so the shell
254
+ * stays stdlib-only and dumb; consulted ONLY at a crashed verdict. With no seam, or
255
+ * with zero writes, a crash stays what it always was: an instrument stop.
58
256
  * @returns {Promise<'green'|'escalated'>}
59
257
  */
60
- export async function ralph({ middle, close, capRuns, emit, redact }) {
258
+ export async function ralph({ middle, close, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
61
259
  emit('run-start', { capRuns, close: close.join(' ') });
260
+ // The blind spot is NAMED, never hidden: with no judgment-rendered signal this
261
+ // close cannot tell a crash from an honest red (they are byte-identical at the
262
+ // exit-code seam), so the record says so out loud rather than passing the exit
263
+ // code off as trustworthy (PRD v1.11).
264
+ if (!judged) emit('close-unaudited', { close: close.join(' '), meaning: 'no judgment-rendered signal declared — a crash-at-load is indistinguishable from an honest red for this close' });
62
265
  const verdicts = [];
63
266
  let gap;
64
267
  for (let iteration = 1; iteration <= capRuns; iteration++) {
@@ -74,6 +277,8 @@ export async function ralph({ middle, close, capRuns, emit, redact }) {
74
277
  ['raise the cap and rerun', 'change the middle/harness', 'abandon the task']],
75
278
  'gate-red': ['The gate denied an action mid-run — the harness asked for something outside its scope.',
76
279
  ['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
280
+ 'provider-red': ['The provider path failed mid-run (transport, not logic) — no verdict exists and the spend for the failed call is unknown (F6).',
281
+ ['retry the run', 'fix the provider binding', 'abandon the task']],
77
282
  };
78
283
  // Object.hasOwn, not bare lookup: a category named after an
79
284
  // Object.prototype member ("toString") would return the inherited
@@ -84,26 +289,51 @@ export async function ralph({ middle, close, capRuns, emit, redact }) {
84
289
  emit('escalation', {
85
290
  category, decisionReady: true, verdicts,
86
291
  spend: { runs: iteration, capRuns },
292
+ // the thrower names its OWNER too when it knows one (interpret's worker
293
+ // loop → bare-agent): the ledger prefers the typed field over sniffing
294
+ // this detail string for library names
295
+ ...(e && typeof e.lib === 'string' ? { lib: e.lib } : {}),
87
296
  decision, options, detail: String(e.message || e),
88
297
  });
89
298
  emit('run-end', { outcome: 'escalated', iterations: iteration });
90
299
  return 'escalated';
91
300
  }
92
301
  emit('middle-done', { iteration });
93
- const v = runClose(close, redact);
94
- verdicts.push(v.verdict);
302
+ const v = runClose(close, redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
303
+ // Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
304
+ // still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
305
+ // precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
306
+ // before any tokens), is the worker's own broken edit: the most recoverable red there
307
+ // is, and the one red this loop could never feed back. The close's raw reading stays
308
+ // 'crashed' on the record; the ROUTING is what attribution changes. Decided before
309
+ // the satisfied check so an exit-0 crash (fake green, law #8) is caught the same way.
310
+ const crashFiles = v.verdict === 'crashed' && workerWrites ? workerWrites() : [];
311
+ const workerCrashed = crashFiles.length > 0;
312
+ verdicts.push(workerCrashed ? 'worker-crash' : v.verdict);
95
313
  emit('close-verdict', { iteration, ...v });
96
314
  if (v.verdict === 'satisfied') {
97
315
  emit('run-end', { outcome: 'green', iterations: iteration });
98
316
  return 'green';
99
317
  }
100
- if (v.verdict === 'failed') {
318
+ if (workerCrashed) {
319
+ emit('worker-crash', { iteration, category: 'worker-crash', files: crashFiles, detail: v.detail });
320
+ // the file list is bounded and the trim ANNOUNCED — silent truncation is the F28 disease
321
+ const shown = crashFiles.slice(0, 30);
322
+ const more = crashFiles.length - shown.length;
323
+ gap = `Your edit CRASHED the test suite — it can no longer even load and judge (${v.detail}). `
324
+ + `Files you have written this run: ${shown.join(', ')}${more > 0 ? ` (and ${more} more)` : ''}. `
325
+ + 'Fix or revert your change so the suite can run again — nothing can pass while the suite cannot load.';
326
+ continue;
327
+ }
328
+ // Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
329
+ // by the outcome's OWN name and never retry — retrying a broken arbiter is
330
+ // the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
331
+ const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
332
+ if (fault) {
101
333
  emit('escalation', {
102
- category: 'broken-close', decisionReady: true, verdicts,
334
+ category: fault.category, decisionReady: true, verdicts,
103
335
  spend: { runs: iteration, capRuns },
104
- decision: 'The close itself cannot run — no verdict is trustworthy until it is fixed.',
105
- options: ['fix the close command', 'abandon the task'],
106
- detail: v.detail,
336
+ decision: fault.decision, options: fault.options, detail: v.detail,
107
337
  });
108
338
  emit('run-end', { outcome: 'escalated', iterations: iteration });
109
339
  return 'escalated';
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
+ }