bareloop 0.1.0 → 0.3.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,21 +8,211 @@ 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.
40
+ *
41
+ * `redact` scrubs the close's own output at the SOURCE — before the gap becomes
42
+ * a spine event or a worker prompt — so a secret a checked command echoes
43
+ * (a 401 dumping an `Authorization: Bearer …` header) never enters the
44
+ * append-only spine (the hard line). It is INJECTED, not imported: the shell
45
+ * stays stdlib-only and un-gameable, and the redactor is a fixed shell
46
+ * primitive (bareguard's, wired by the interpreter), never an emergent
47
+ * component — so V4 holds. The shell's canonical emission IS the redacted text;
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
+ *
12
63
  * @param {string[]} close argv, e.g. ['node', '--test', 'close/']
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}}
13
74
  */
14
- export function runClose(close) {
75
+ export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
15
76
  const env = { ...process.env };
16
77
  delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
17
- const r = spawnSync(close[0], close.slice(1), { env, encoding: 'utf8', timeout: 120_000 });
18
- if (r.error) return { verdict: 'failed', detail: String(r.error) };
19
- 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 }) };
20
116
  // The gap must never be falsy: every consumer guards with `if (gap)` — an
21
117
  // empty-output red would silently kill gap feedback, after-red hooks, AND
22
118
  // stall detection, leaving the worker re-prompted byte-identically to the cap.
23
- return { verdict: 'needs_revision', gap: (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
+
147
+ /**
148
+ * Bound an over-long close stream to a head sample + elided middle + tail. When
149
+ * `keepPattern` is set (F28), lines of the WHOLE stream matching it are also
150
+ * carried in a clearly-delimited block between head and tail — capped — so the
151
+ * failing-test names buried in the middle still reach the worker.
152
+ * @param {string} s the (redacted) close output
153
+ * @param {string} [keepPattern] regex source; matching lines survive the elision
154
+ */
155
+ function boundGap(s, keepPattern) {
156
+ if (s.length <= GAP_HEAD + GAP_TAIL + 100) return s;
157
+ const head = s.slice(0, GAP_HEAD);
158
+ const tail = s.slice(-GAP_TAIL);
159
+ const elided = s.length - GAP_HEAD - GAP_TAIL;
160
+ const base = `${head}\n…[${elided} chars truncated]…`;
161
+ if (!keepPattern) return `${base}\n${tail}`;
162
+
163
+ // Scan the WHOLE stream, keep matching lines in original order, cap by lines
164
+ // AND bytes. `re.test` with no `g` flag is stateless, so reuse is safe.
165
+ const re = new RegExp(keepPattern, 'm');
166
+ /** @type {string[]} */
167
+ const matched = [];
168
+ let bytes = 0, trimmed = 0;
169
+ for (const line of s.split('\n')) {
170
+ if (!re.test(line)) continue;
171
+ if (matched.length >= GAP_KEEP_MAX_LINES || bytes + line.length + 1 > GAP_KEEP_MAX_BYTES) { trimmed += 1; continue; }
172
+ matched.push(line);
173
+ bytes += line.length + 1;
174
+ }
175
+ 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} more elided by the ${GAP_KEEP_MAX_LINES}-line/${GAP_KEEP_MAX_BYTES}-byte cap` : ''})`;
177
+ return `${base}\n── ${label} ──\n${matched.join('\n')}\n── end kept failures ──\n${tail}`;
24
178
  }
25
179
 
180
+ /**
181
+ * The forbidden zone, named (PRD v1.11). Each row is an outcome in which the
182
+ * close rendered NO JUDGMENT — so it is not a verdict, it never feeds a gap
183
+ * back, and it is never retried. They are kept SEPARATE on purpose: pooling
184
+ * them would erase the decision information the escalation exists to carry —
185
+ * "raise the timeout", "fix the argv", and "re-run, it was OOM-killed" are
186
+ * three different human answers.
187
+ * Exported because the RUNNER's close-first precheck runs the same arbiter
188
+ * before any tokens: two maps would be two instruments, and two instruments
189
+ * disagreeing about the same outcome is the fault this whole addendum exists
190
+ * to close.
191
+ * @type {Record<string, {category: string, decision: string, options: string[]}>}
192
+ */
193
+ export const CLOSE_FAULTS = Object.freeze({
194
+ failed: {
195
+ category: 'broken-close',
196
+ decision: 'The close itself cannot run — no verdict is trustworthy until it is fixed.',
197
+ options: ['fix the close command', 'abandon the task'],
198
+ },
199
+ 'timed-out': {
200
+ category: 'close-timeout',
201
+ decision: 'The close ran but never finished judging — it hit the timeout and was killed. It did not fail; it did not answer.',
202
+ options: ['raise the close timeout', 'make the close faster', 'abandon the task'],
203
+ },
204
+ killed: {
205
+ category: 'close-killed',
206
+ decision: 'The close died by signal (OOM, or an external kill) — it rendered no judgment, so its exit tells you nothing about the tree.',
207
+ options: ['re-run (a transient kill)', 'fix the close environment (memory, sandbox limits)', 'abandon the task'],
208
+ },
209
+ crashed: {
210
+ category: 'close-crashed',
211
+ decision: 'The close exited without judging anything — it crashed rather than failed. Its exit code is NOT a verdict, in either direction.',
212
+ 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'],
213
+ },
214
+ });
215
+
26
216
  /**
27
217
  * The loop: `while close-red and under-cap: run the middle`. Stops at first
28
218
  * green (within-run tuning past a visible close is the fit-to-pass surface,
@@ -42,10 +232,30 @@ export function runClose(close) {
42
232
  * @param {string[]} opts.close argv whose exit code is truth
43
233
  * @param {number} opts.capRuns budget: max middle runs
44
234
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
235
+ * @param {(s: string) => string} [opts.redact] source scrubber for close output
236
+ * (secrets never enter the spine); injected so the shell stays stdlib-only
237
+ * @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell/operator
238
+ * territory — the workflow config cannot express it)
239
+ * @param {string} [opts.cwd] the directory every close runs in — the tree it is
240
+ * judging (F8: a cwd-relative close run elsewhere judges the wrong repository)
241
+ * @param {number} [opts.expect] the exit code the SIGNED spec calls success
242
+ * @param {{pattern: string, min: number}} [opts.judged] the judgment-rendered signal
243
+ * @param {string} [opts.gapKeep] kept-failures pattern (F28): matching close-output
244
+ * lines survive the gap bound so a large suite's `not ok` names reach the worker
245
+ * @param {() => string[]} [opts.workerWrites] the crash-attribution instrument (F32):
246
+ * answers "which files has the WORKER written so far this run?" — the runner wires
247
+ * it to the gate audit's allow-decision writes. Injected like `redact` so the shell
248
+ * stays stdlib-only and dumb; consulted ONLY at a crashed verdict. With no seam, or
249
+ * with zero writes, a crash stays what it always was: an instrument stop.
45
250
  * @returns {Promise<'green'|'escalated'>}
46
251
  */
47
- export async function ralph({ middle, close, capRuns, emit }) {
252
+ export async function ralph({ middle, close, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
48
253
  emit('run-start', { capRuns, close: close.join(' ') });
254
+ // The blind spot is NAMED, never hidden: with no judgment-rendered signal this
255
+ // close cannot tell a crash from an honest red (they are byte-identical at the
256
+ // exit-code seam), so the record says so out loud rather than passing the exit
257
+ // code off as trustworthy (PRD v1.11).
258
+ 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' });
49
259
  const verdicts = [];
50
260
  let gap;
51
261
  for (let iteration = 1; iteration <= capRuns; iteration++) {
@@ -61,6 +271,8 @@ export async function ralph({ middle, close, capRuns, emit }) {
61
271
  ['raise the cap and rerun', 'change the middle/harness', 'abandon the task']],
62
272
  'gate-red': ['The gate denied an action mid-run — the harness asked for something outside its scope.',
63
273
  ['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
274
+ 'provider-red': ['The provider path failed mid-run (transport, not logic) — no verdict exists and the spend for the failed call is unknown (F6).',
275
+ ['retry the run', 'fix the provider binding', 'abandon the task']],
64
276
  };
65
277
  // Object.hasOwn, not bare lookup: a category named after an
66
278
  // Object.prototype member ("toString") would return the inherited
@@ -71,26 +283,51 @@ export async function ralph({ middle, close, capRuns, emit }) {
71
283
  emit('escalation', {
72
284
  category, decisionReady: true, verdicts,
73
285
  spend: { runs: iteration, capRuns },
286
+ // the thrower names its OWNER too when it knows one (interpret's worker
287
+ // loop → bare-agent): the ledger prefers the typed field over sniffing
288
+ // this detail string for library names
289
+ ...(e && typeof e.lib === 'string' ? { lib: e.lib } : {}),
74
290
  decision, options, detail: String(e.message || e),
75
291
  });
76
292
  emit('run-end', { outcome: 'escalated', iterations: iteration });
77
293
  return 'escalated';
78
294
  }
79
295
  emit('middle-done', { iteration });
80
- const v = runClose(close);
81
- verdicts.push(v.verdict);
296
+ const v = runClose(close, redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
297
+ // Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
298
+ // still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
299
+ // precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
300
+ // before any tokens), is the worker's own broken edit: the most recoverable red there
301
+ // is, and the one red this loop could never feed back. The close's raw reading stays
302
+ // 'crashed' on the record; the ROUTING is what attribution changes. Decided before
303
+ // the satisfied check so an exit-0 crash (fake green, law #8) is caught the same way.
304
+ const crashFiles = v.verdict === 'crashed' && workerWrites ? workerWrites() : [];
305
+ const workerCrashed = crashFiles.length > 0;
306
+ verdicts.push(workerCrashed ? 'worker-crash' : v.verdict);
82
307
  emit('close-verdict', { iteration, ...v });
83
308
  if (v.verdict === 'satisfied') {
84
309
  emit('run-end', { outcome: 'green', iterations: iteration });
85
310
  return 'green';
86
311
  }
87
- if (v.verdict === 'failed') {
312
+ if (workerCrashed) {
313
+ emit('worker-crash', { iteration, category: 'worker-crash', files: crashFiles, detail: v.detail });
314
+ // the file list is bounded and the trim ANNOUNCED — silent truncation is the F28 disease
315
+ const shown = crashFiles.slice(0, 30);
316
+ const more = crashFiles.length - shown.length;
317
+ gap = `Your edit CRASHED the test suite — it can no longer even load and judge (${v.detail}). `
318
+ + `Files you have written this run: ${shown.join(', ')}${more > 0 ? ` (and ${more} more)` : ''}. `
319
+ + 'Fix or revert your change so the suite can run again — nothing can pass while the suite cannot load.';
320
+ continue;
321
+ }
322
+ // Forbidden zone: no judgment was rendered, so there is no verdict. Escalate
323
+ // by the outcome's OWN name and never retry — retrying a broken arbiter is
324
+ // the §5b violation adaptlearn found live in its shipped shell (F25/Z-3).
325
+ const fault = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
326
+ if (fault) {
88
327
  emit('escalation', {
89
- category: 'broken-close', decisionReady: true, verdicts,
328
+ category: fault.category, decisionReady: true, verdicts,
90
329
  spend: { runs: iteration, capRuns },
91
- decision: 'The close itself cannot run — no verdict is trustworthy until it is fixed.',
92
- options: ['fix the close command', 'abandon the task'],
93
- detail: v.detail,
330
+ decision: fault.decision, options: fault.options, detail: v.detail,
94
331
  });
95
332
  emit('run-end', { outcome: 'escalated', iterations: iteration });
96
333
  return 'escalated';