bareloop 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +746 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -234
- package/package.json +3 -3
- package/src/clock.js +181 -0
- package/src/exits.js +8 -2
- package/src/index.js +23 -17
- package/src/job.js +231 -108
- package/src/ledger.js +28 -3
- package/src/plan.js +356 -20
- package/src/planrun.js +978 -98
- package/src/ralph.js +226 -7
- package/src/root.js +32 -12
- package/src/run.js +61 -390
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/validate.js +35 -250
- package/types/clock.d.ts +109 -0
- package/types/index.d.ts +4 -6
- package/types/job.d.ts +83 -8
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +107 -7
- package/types/planrun.d.ts +51 -3
- package/types/ralph.d.ts +62 -4
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +9 -17
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/ralph.js
CHANGED
|
@@ -4,7 +4,138 @@
|
|
|
4
4
|
// arbiter). Stateless across runs and stdlib-only by design: it must stay too
|
|
5
5
|
// dumb to be gamed. Do not make it smarter.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
// ── the close's child process, run WITHOUT freezing the host (F68) ───────────
|
|
10
|
+
// The close used to run through `spawnSync`, which blocks the host event loop for
|
|
11
|
+
// the child's entire duration: F68 measured 9 loop-freeze records in one green run
|
|
12
|
+
// (worst 74.3s, bracketing a ~65s close exactly) and 4 more bracketing three
|
|
13
|
+
// check-preflights — "the close-runner freezes the loop it reports to". While a
|
|
14
|
+
// close ran, the stall fuse's timers could not fire, the lag sampler recorded a
|
|
15
|
+
// block, and any in-flight instrument shared the freeze.
|
|
16
|
+
//
|
|
17
|
+
// ONLY THE WAIT IS ASYNC. Every observable semantic below is spawnSync's own,
|
|
18
|
+
// replicated field for field, because the close is the arbiter and its reading of
|
|
19
|
+
// the world must not shift by a byte:
|
|
20
|
+
// - the same `{error, status, signal, stdout, stderr}` result shape;
|
|
21
|
+
// - the same 1 MiB PER-STREAM output ceiling, reported the same way spawnSync
|
|
22
|
+
// reports it — an `ENOBUFS` error with the child killed (so an over-long close
|
|
23
|
+
// stays `failed`/broken-close, exactly as before, rather than silently
|
|
24
|
+
// becoming a red on truncated output);
|
|
25
|
+
// - the same deadline behaviour: SIGTERM at `timeoutMs`, surfaced as an
|
|
26
|
+
// `ETIMEDOUT` error (which runClose reads BEFORE any exit code, so the
|
|
27
|
+
// forbidden zone's `timed-out` band is unchanged);
|
|
28
|
+
// - the same stdin: closed immediately, so a close that reads stdin sees EOF
|
|
29
|
+
// instead of hanging on a pipe nobody will ever write to.
|
|
30
|
+
// The one deliberate difference is prose: a spawn fault names itself `spawn …`
|
|
31
|
+
// rather than `spawnSync …` in its message, because that is now what happened.
|
|
32
|
+
// Nothing parses that string (it rides out as an escalation `detail`).
|
|
33
|
+
const CLOSE_MAX_BUFFER = 1024 * 1024; // spawnSync's default maxBuffer, per stream
|
|
34
|
+
const CLOSE_KILL_SIGNAL = 'SIGTERM'; // spawnSync's default killSignal
|
|
35
|
+
// SIGTERM is a REQUEST, and a close is free to refuse it — a runner installing its
|
|
36
|
+
// own handler, a wrapper that traps to clean up. There is no second deadline after
|
|
37
|
+
// the first, so a refused SIGTERM leaves the wait below pending FOREVER: no `close`
|
|
38
|
+
// event ever arrives, and the run's only remaining stop is the out-of-process
|
|
39
|
+
// watchdog. The grace is the escalation — long enough for an honest close to flush
|
|
40
|
+
// and leave on its own terms, then SIGKILL, which nothing can refuse. Verdict
|
|
41
|
+
// semantics are untouched: the FIRST fault already named the outcome, and the kill
|
|
42
|
+
// only enforces it. (spawnSync sends killSignal and then simply keeps waiting; this
|
|
43
|
+
// is the one place the arbiter's clock is deliberately harder than its reference.)
|
|
44
|
+
const CLOSE_KILL_GRACE_MS = 2000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} cmd
|
|
48
|
+
* @param {string[]} args
|
|
49
|
+
* @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
|
|
50
|
+
* @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
|
|
51
|
+
*/
|
|
52
|
+
function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const child = spawn(cmd, args, { env, cwd });
|
|
55
|
+
/** @type {Buffer[]} */ const outChunks = [];
|
|
56
|
+
/** @type {Buffer[]} */ const errChunks = [];
|
|
57
|
+
let outBytes = 0, errBytes = 0;
|
|
58
|
+
/** @type {(Error & {code?: string})|null} */
|
|
59
|
+
let error = null;
|
|
60
|
+
/** @type {NodeJS.Timeout|undefined} */
|
|
61
|
+
let timer;
|
|
62
|
+
/** @type {NodeJS.Timeout|undefined} */
|
|
63
|
+
let killTimer;
|
|
64
|
+
|
|
65
|
+
// The FIRST fault wins and kills the child, exactly as spawnSync does: a close
|
|
66
|
+
// that blew the buffer and then hit the deadline is one story, not two. The
|
|
67
|
+
// escalation rides inside that same first-fault-wins guard, so a child is asked
|
|
68
|
+
// once and, if it refuses, ended once.
|
|
69
|
+
const fault = (/** @type {string} */ code) => {
|
|
70
|
+
if (error) return;
|
|
71
|
+
error = Object.assign(new Error(`spawn ${cmd} ${code}`), { code, syscall: `spawn ${cmd}`, path: cmd, spawnargs: args });
|
|
72
|
+
try { child.kill(CLOSE_KILL_SIGNAL); } catch { /* already gone — nothing to kill */ }
|
|
73
|
+
// …and SIGKILL if it is still there after the grace. `unref` so this timer can
|
|
74
|
+
// never hold the host's event loop open by itself (F68's whole point) — and it
|
|
75
|
+
// is cleared on `close`, so it cannot fire at a child that already left.
|
|
76
|
+
killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone in the meantime */ } }, CLOSE_KILL_GRACE_MS);
|
|
77
|
+
killTimer.unref();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
child.stdout.on('data', (/** @type {Buffer} */ c) => { outBytes += c.length; outChunks.push(c); if (outBytes > CLOSE_MAX_BUFFER) fault('ENOBUFS'); });
|
|
81
|
+
child.stderr.on('data', (/** @type {Buffer} */ c) => { errBytes += c.length; errChunks.push(c); if (errBytes > CLOSE_MAX_BUFFER) fault('ENOBUFS'); });
|
|
82
|
+
// A spawn failure (ENOENT, an unreadable cwd) arrives as an event here and as
|
|
83
|
+
// `result.error` in spawnSync; `close` still fires after it, so there is ONE
|
|
84
|
+
// resolution path and the promise can never be left pending.
|
|
85
|
+
child.on('error', (e) => { if (!error) error = e; });
|
|
86
|
+
child.on('close', (status, signal) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
clearTimeout(killTimer);
|
|
89
|
+
resolve({
|
|
90
|
+
error, status, signal,
|
|
91
|
+
// Concat-then-decode, never per-chunk: a multi-byte character straddling a
|
|
92
|
+
// chunk boundary must not become two replacement characters — spawnSync
|
|
93
|
+
// decodes the whole buffer once, and the judged/gapKeep patterns read this.
|
|
94
|
+
stdout: Buffer.concat(outChunks).toString('utf8'),
|
|
95
|
+
stderr: Buffer.concat(errChunks).toString('utf8'),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
if (typeof timeoutMs === 'number' && timeoutMs > 0 && Number.isFinite(timeoutMs)) timer = setTimeout(() => fault('ETIMEDOUT'), timeoutMs);
|
|
99
|
+
// stdin closed at once (spawnSync with no `input` hands the child EOF): a close
|
|
100
|
+
// that reads stdin gets the same EOF instead of blocking on an idle pipe. The
|
|
101
|
+
// error swallow is for the ordinary case where the child has already exited.
|
|
102
|
+
child.stdin?.on('error', () => {});
|
|
103
|
+
child.stdin?.end();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What the close child must NOT be able to read (operator ruling, 2026-07-31:
|
|
109
|
+
* "strip it when unnecessary, minimize unneeded exposure").
|
|
110
|
+
*
|
|
111
|
+
* The close child executes WORKER-AUTHORED CODE. `pytest`, `npm test`, `tsc`
|
|
112
|
+
* run whatever the worker just wrote into the tree, in a child that — before
|
|
113
|
+
* this — inherited the whole operator environment, credentials included. None
|
|
114
|
+
* of git/npm/tsc/pytest/mypy needs an API key to judge a tree, so the exposure
|
|
115
|
+
* bought nothing and is removed. This is EXPOSURE REDUCTION, not a sandbox:
|
|
116
|
+
* see the threat-model note in the PRD (v1.41) and README for what remains.
|
|
117
|
+
*
|
|
118
|
+
* Two rules, deliberately kept BOTH ways round:
|
|
119
|
+
* - `names` / `prefixes` — the knowns. A literal denylist is a chase (every
|
|
120
|
+
* new provider mints a new variable), so it is not load-bearing on its own;
|
|
121
|
+
* it exists for names the shape rule cannot see (`AWS_ACCESS_KEY_ID`).
|
|
122
|
+
* - `shape` — the NAME-SHAPE rule, which is what bounds the chase: anything
|
|
123
|
+
* whose name ends in the credential suffixes goes, whoever minted it and
|
|
124
|
+
* whether or not anyone here has heard of it. Case-insensitive; no `g` flag,
|
|
125
|
+
* so `.test` stays stateless and safe to reuse across every key.
|
|
126
|
+
*
|
|
127
|
+
* It is arbiter territory like the rest of this file: no drafted plan or config
|
|
128
|
+
* can express, widen, or opt out of it.
|
|
129
|
+
* @type {Readonly<{names: readonly string[], prefixes: readonly string[], shape: RegExp}>}
|
|
130
|
+
*/
|
|
131
|
+
export const CLOSE_ENV_DENY = Object.freeze({
|
|
132
|
+
names: Object.freeze([
|
|
133
|
+
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
|
|
134
|
+
'ANTHROPIC_AUTH_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'NPM_TOKEN',
|
|
135
|
+
]),
|
|
136
|
+
prefixes: Object.freeze(['AWS_']),
|
|
137
|
+
shape: /(_API_KEY|_SECRET|_TOKEN|_PASSWORD|_CREDENTIALS?)$/i,
|
|
138
|
+
});
|
|
8
139
|
|
|
9
140
|
/**
|
|
10
141
|
* Run a close command; its exit code is the verdict (hard green, PRD §4).
|
|
@@ -68,18 +199,26 @@ import { spawnSync } from 'node:child_process';
|
|
|
68
199
|
* (the workdir — the tree it is judging), the exit code the SIGNED spec calls
|
|
69
200
|
* success, the judgment-rendered signal, and the kept-failures pattern. All
|
|
70
201
|
* five are arbiter territory: the drafted workflow config cannot express any.
|
|
71
|
-
* @returns {{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
|
|
202
|
+
* @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
|
|
72
203
|
* gap?: string, exitCode?: number, signal?: string, detail?: string,
|
|
73
|
-
* judgedCount?: number|null, unaudited?: boolean}}
|
|
204
|
+
* judgedCount?: number|null, unaudited?: boolean}>}
|
|
74
205
|
*/
|
|
75
|
-
export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
|
|
206
|
+
export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
|
|
76
207
|
const env = { ...process.env };
|
|
77
208
|
delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
|
|
78
|
-
|
|
209
|
+
// …and the operator's credentials go with it (CLOSE_ENV_DENY above). A COPY is
|
|
210
|
+
// stripped, never `process.env` itself: the host still needs its key (bare-agent
|
|
211
|
+
// reads it every round) — only the child that runs worker-authored code is blinded.
|
|
212
|
+
for (const name of Object.keys(env)) {
|
|
213
|
+
if (CLOSE_ENV_DENY.names.includes(name)
|
|
214
|
+
|| CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
|
|
215
|
+
|| CLOSE_ENV_DENY.shape.test(name)) delete env[name];
|
|
216
|
+
}
|
|
217
|
+
const r = await spawnClose(close[0], close.slice(1), { env, cwd, timeoutMs });
|
|
79
218
|
|
|
80
219
|
// ── forbidden zone, before any exit code is believed ──────────────────────
|
|
81
220
|
if (r.error) {
|
|
82
|
-
//
|
|
221
|
+
// The runner reports BOTH "cannot run" and "ran too long" through `error`;
|
|
83
222
|
// they are different human decisions, so they get different names.
|
|
84
223
|
const code = /** @type {NodeJS.ErrnoException} */ (r.error).code;
|
|
85
224
|
if (code === 'ETIMEDOUT') return { verdict: 'timed-out', detail: `close exceeded ${timeoutMs}ms and was killed — it never finished judging` };
|
|
@@ -131,6 +270,56 @@ export function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, e
|
|
|
131
270
|
};
|
|
132
271
|
}
|
|
133
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Run a STAGED close (PRD v1.28) — an ordered list of named command stages, run
|
|
275
|
+
* in order until one renders a verdict that is not `satisfied`. The return is
|
|
276
|
+
* runClose's own shape, so every consumer (the precheck, ralph's judge, the
|
|
277
|
+
* escalation's CLOSE_FAULTS routing) is unchanged: a staged close is still ONE
|
|
278
|
+
* verdict, not a scoreboard.
|
|
279
|
+
*
|
|
280
|
+
* Two additions, both mechanical information the worker can act on:
|
|
281
|
+
* - `stage` names which stage rendered the verdict. The close's output must
|
|
282
|
+
* never name the CULPRIT FILE (v1.12/F28 — the worker finds that itself), and
|
|
283
|
+
* naming the wall it hit is the opposite of that: it is the mechanical genre
|
|
284
|
+
* that converts (F38/F46), not the answer.
|
|
285
|
+
* - `stages` is the ordered record of what actually ran. A stage after the
|
|
286
|
+
* first red never runs, so a later green can never mask an earlier failure.
|
|
287
|
+
*
|
|
288
|
+
* The same function runs a DERIVED CHECK: a check is its stage's chain
|
|
289
|
+
* (`checkMenu` puts the prerequisites first), so there is one execution path for
|
|
290
|
+
* the close and for the rulers derived from it — never two, which is the F9
|
|
291
|
+
* two-transforms class applied to the arbiter.
|
|
292
|
+
*
|
|
293
|
+
* @param {any[]} stages the stage list (already validated by validateJob)
|
|
294
|
+
* @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
|
|
295
|
+
* @param {{timeoutMs?: number, cwd?: string}} [opts]
|
|
296
|
+
* @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
|
|
297
|
+
*/
|
|
298
|
+
export async function runStages(stages, redact = (s) => s, { timeoutMs, cwd } = {}) {
|
|
299
|
+
const ran = [];
|
|
300
|
+
let last;
|
|
301
|
+
for (const st of stages) {
|
|
302
|
+
// Sequential by construction (F68): stages run one at a time under a single
|
|
303
|
+
// await, so nothing here ever has two closes in flight against one tree.
|
|
304
|
+
const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
|
|
305
|
+
timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
|
|
306
|
+
});
|
|
307
|
+
ran.push({ name: st.name, verdict: r.verdict, ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}) });
|
|
308
|
+
last = r;
|
|
309
|
+
if (r.verdict !== 'satisfied') {
|
|
310
|
+
return {
|
|
311
|
+
...r,
|
|
312
|
+
stage: st.name,
|
|
313
|
+
stages: ran,
|
|
314
|
+
// The gap says WHICH wall, then the wall's own output. A worker handed
|
|
315
|
+
// three stages' worth of output cannot tell which one it must satisfy.
|
|
316
|
+
...(r.gap ? { gap: `close stage "${st.name}" failed:\n${r.gap}` } : {}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { ...last, stages: ran };
|
|
321
|
+
}
|
|
322
|
+
|
|
134
323
|
// Tail-biased bound: a test runner's useful output (the assertion diff, the
|
|
135
324
|
// failing case name) is at the END; head-only truncation fed the worker the
|
|
136
325
|
// preamble and dropped the cause (N2 queue, F2). Head sample kept so "what ran"
|
|
@@ -289,6 +478,36 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
|
|
|
289
478
|
['widen the write scope deliberately', 'change the middle/harness', 'abandon the task']],
|
|
290
479
|
'provider-red': ['The provider path failed mid-run (transport, not logic) — no verdict exists and the spend for the failed call is unknown (F6).',
|
|
291
480
|
['retry the run', 'fix the provider binding', 'abandon the task']],
|
|
481
|
+
// A (PRD v1.27) — the METER stopped this attempt before it started: the
|
|
482
|
+
// step had already eaten a declared share of the run with its exits
|
|
483
|
+
// unmoved. Not a fault, not a cap: the caller reads this category and
|
|
484
|
+
// REPLANS, so the message must not say anything broke.
|
|
485
|
+
'step-variance': ['A step consumed a large share of the run with its exits still red — the meter stopped it before another attempt.',
|
|
486
|
+
['let the planner re-allocate what is left (replan)', 'raise the budget and rerun', 'abandon the task']],
|
|
487
|
+
// T/F64 — the run's WALL CLOCK stopped this: either the caller's deadline
|
|
488
|
+
// came back as the provider's own timeout (F64, mid-attempt) or the caller
|
|
489
|
+
// read the clock and declined to START another attempt (W-2, between them).
|
|
490
|
+
// A governance stop either way, not a fault and not a transport casualty:
|
|
491
|
+
// the message must not send a human to debug a socket, and the remedy is the
|
|
492
|
+
// operator's cap, not a retry. The wording says neither "mid-attempt" nor
|
|
493
|
+
// "between attempts", because one entry serves both and a category that
|
|
494
|
+
// narrates the wrong half is worse than one that narrates neither.
|
|
495
|
+
// The two levers are BOTH spec edits (a changed spec hash the runner refuses
|
|
496
|
+
// until it is re-approved), and they point opposite ways — which is why the
|
|
497
|
+
// caller's detail carries the progress trend that says which one fits.
|
|
498
|
+
'wall-halt': ['The run reached its wall-clock cap — time ran out, not capability. Nothing is discarded: the last verdict rendered stands, and the stop is the checkpoint.',
|
|
499
|
+
['raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
|
|
500
|
+
'revise the goal/spec so the work fits the time (same re-approval)',
|
|
501
|
+
'abandon the task']],
|
|
502
|
+
// F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
|
|
503
|
+
// three times over, and reissuing the call did not recover it. It borrows both
|
|
504
|
+
// siblings' rules. Like `step-variance`, the caller reads this category and
|
|
505
|
+
// REPLANS, so the planner's lever leads. Like `wall-halt`, nothing broke — the
|
|
506
|
+
// socket stayed alive the whole time (that is the measured mechanism), so the
|
|
507
|
+
// message must not send a human to debug transport, and the default's "fix the
|
|
508
|
+
// interpreter" would aim a human at the one component that did nothing wrong.
|
|
509
|
+
'step-stalled': ['The model stopped producing rounds and reissuing the call did not recover it — a stall, not a fault.',
|
|
510
|
+
['let the planner re-allocate what is left (replan)', 'retry the run', 'check provider status', 'abandon the task']],
|
|
292
511
|
};
|
|
293
512
|
// Object.hasOwn, not bare lookup: a category named after an
|
|
294
513
|
// Object.prototype member ("toString") would return the inherited
|
|
@@ -311,7 +530,7 @@ export async function ralph({ middle, close, judge, capRuns, emit, redact, close
|
|
|
311
530
|
emit('middle-done', { iteration });
|
|
312
531
|
const v = judge
|
|
313
532
|
? await judge()
|
|
314
|
-
: runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
|
|
533
|
+
: await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
|
|
315
534
|
// Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
|
|
316
535
|
// still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
|
|
317
536
|
// precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
|
package/src/root.js
CHANGED
|
@@ -105,13 +105,14 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
|
|
|
105
105
|
* failures" — [] === [] would false-read as "reds unchanged" (Finding 2).
|
|
106
106
|
* @param {string} gap @returns {string[]|null} normalized kept lines, or null when untrustworthy
|
|
107
107
|
*/
|
|
108
|
-
const keptSet = (gap) => {
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
const keptSet = (gap, pattern) => {
|
|
109
|
+
const re = pattern ? new RegExp(pattern, 'm') : keepRe;
|
|
110
|
+
if (!re || gap.includes(GAP_KEEP_TRIM_MARKER)) return null;
|
|
111
|
+
const lines = [...new Set(gap.split('\n').filter((l) => re.test(l)).map(normalizeRedLine))].sort();
|
|
111
112
|
return lines.length ? lines : null;
|
|
112
113
|
};
|
|
113
114
|
|
|
114
|
-
/** @type {{writes: string[], reds: string[]|null, tee: Map<string, TeeEntry>}[]} finalized attempts */
|
|
115
|
+
/** @type {{writes: string[], reds: string[]|null, stage: string|undefined, tee: Map<string, TeeEntry>}[]} finalized attempts */
|
|
115
116
|
const attempts = [];
|
|
116
117
|
/** @type {Set<string>} cumulative audit write-set at the last observe */
|
|
117
118
|
let prevCumulative = new Set();
|
|
@@ -179,10 +180,16 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
|
|
|
179
180
|
* Called at the START of attempt `iteration`, with the PREVIOUS attempt's
|
|
180
181
|
* gap. Finalizes that attempt from the books, runs the detector, and
|
|
181
182
|
* returns the injection for the attempt about to run — or null (inert).
|
|
182
|
-
*
|
|
183
|
+
*
|
|
184
|
+
* `redStage`/`redKeep` are the STAGED-CLOSE seam (PRD v1.28): a close is an
|
|
185
|
+
* ordered list of named stages, the first red renders the verdict, so the
|
|
186
|
+
* pattern that designates failures belongs to whichever stage rendered it
|
|
187
|
+
* THIS attempt. Callers with a single red-set source (the per-step exit
|
|
188
|
+
* evaluator) pass neither and keep the constructor's run-wide pattern.
|
|
189
|
+
* @param {{iteration: number, gap?: string, writes: string[], redStage?: string, redKeep?: string}} o
|
|
183
190
|
* @returns {null | {stage: 'summary'|'verbatim', note: string, event: object}}
|
|
184
191
|
*/
|
|
185
|
-
observe({ iteration, gap, writes }) {
|
|
192
|
+
observe({ iteration, gap, writes, redStage, redKeep }) {
|
|
186
193
|
const cumulative = new Set(writes);
|
|
187
194
|
if (!gap) { // first attempt: nothing judged yet, nothing to compare
|
|
188
195
|
prevCumulative = cumulative;
|
|
@@ -197,7 +204,7 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
|
|
|
197
204
|
for (const p of tee.keys()) if (cumulative.has(p) && !delta.includes(p)) delta.push(p);
|
|
198
205
|
// `gap` is guaranteed truthy here (the `if (!gap) return` guard above handled
|
|
199
206
|
// the empty case); reds === null comes only from keptSet's own UNKNOWN logic.
|
|
200
|
-
attempts.push({ writes: delta, reds: keptSet(gap), tee });
|
|
207
|
+
attempts.push({ writes: delta, reds: keptSet(gap, redKeep), stage: redStage, tee });
|
|
201
208
|
// The detector only ever compares the last two finalized attempts, so older
|
|
202
209
|
// ones (and their teed content) are dead state — keep just the two (Finding 7).
|
|
203
210
|
if (attempts.length > 2) attempts.shift();
|
|
@@ -210,12 +217,22 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
|
|
|
210
217
|
if (attempts.length < 2) return null;
|
|
211
218
|
const [prev, last] = attempts.slice(-2);
|
|
212
219
|
const overlap = last.writes.filter((p) => prev.writes.includes(p));
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
220
|
+
// A CHANGED failing stage is movement by definition — a different wall of
|
|
221
|
+
// the same inspection is a different failure, so the two red-sets are not
|
|
222
|
+
// comparable and repetition is not provable. Same bucket as one-known-one-
|
|
223
|
+
// UNKNOWN below, and for the same reason. This guard is what makes a single
|
|
224
|
+
// union pattern wrong: the stages of a real close are near-clones of one
|
|
225
|
+
// grader (job #4: 7 of 12 kept-line templates byte-identical), so a union
|
|
226
|
+
// kept-set can be IDENTICAL across a stage change and read as repetition —
|
|
227
|
+
// a false fire, which "inert when not stuck" forbids.
|
|
228
|
+
//
|
|
229
|
+
// Otherwise: both UNKNOWN → no trustworthy red-set either side, write-overlap
|
|
230
|
+
// alone decides (writes-only mode, named below). One known and one UNKNOWN →
|
|
231
|
+
// the red-set is not provably the same, so it counts as MOVEMENT, never a
|
|
232
|
+
// fire (a real red-set followed by a crash/empty gap is new information, not
|
|
217
233
|
// repetition). Both known → the honest set comparison.
|
|
218
|
-
const redsSame = last.
|
|
234
|
+
const redsSame = last.stage !== prev.stage ? false
|
|
235
|
+
: last.reds === null && prev.reds === null ? null
|
|
219
236
|
: last.reds === null || prev.reds === null ? false
|
|
220
237
|
: JSON.stringify(last.reds) === JSON.stringify(prev.reds);
|
|
221
238
|
// Finding 3: when writes carry no information (text mode — the one target is
|
|
@@ -237,6 +254,9 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
|
|
|
237
254
|
iteration, stage, mode, streak,
|
|
238
255
|
paths: shownPaths, ...(morePaths > 0 ? { pathsElided: morePaths } : {}),
|
|
239
256
|
redSetSize: last.reds ? last.reds.length : null,
|
|
257
|
+
// which stage of the close designated those failures — absent for the
|
|
258
|
+
// single-source callers, so the record never claims a stage it lacks
|
|
259
|
+
...(last.stage !== undefined ? { redStage: last.stage } : {}),
|
|
240
260
|
};
|
|
241
261
|
|
|
242
262
|
// Finding 7: what the worker REACHED FOR fired the detector; what it
|