bareloop 0.6.0 → 0.7.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 +407 -0
- package/README.md +28 -11
- package/bareloop.context.md +510 -28
- package/package.json +2 -2
- package/src/bridges.js +571 -0
- package/src/clock.js +57 -5
- package/src/index.js +29 -0
- package/src/ladder.js +196 -0
- package/src/ledger.js +27 -0
- package/src/plan.js +75 -7
- package/src/planrun.js +762 -146
- package/src/ralph.js +148 -28
- package/src/reuse.js +1602 -0
- package/src/run.js +73 -6
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/trend.js +405 -0
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +25 -3
- package/types/index.d.ts +3 -0
- package/types/ladder.d.ts +86 -0
- package/types/plan.d.ts +18 -3
- package/types/planrun.d.ts +108 -9
- package/types/ralph.d.ts +82 -4
- package/types/reuse.d.ts +388 -0
- package/types/run.d.ts +50 -3
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/trend.d.ts +108 -0
package/src/clock.js
CHANGED
|
@@ -68,6 +68,14 @@ export const MIN_CALL_TIMEOUT_MS = 30_000;
|
|
|
68
68
|
* it as transport. */
|
|
69
69
|
export const TIMEOUT_CODE = 'ETIMEDOUT';
|
|
70
70
|
|
|
71
|
+
/** bare-agent's rejection code when its TOTAL-duration deadline trips (BA-19,
|
|
72
|
+
* `provider-http.js` → `applyRequestDeadline`). A DIFFERENT code from the idle
|
|
73
|
+
* bound's on purpose — upstream splits them precisely so a consumer routing
|
|
74
|
+
* governance stops apart from transport casualties can tell which timer fired. Like
|
|
75
|
+
* `TIMEOUT_CODE` it carries no `category`, so every default-to-transport catch site
|
|
76
|
+
* would file it as a casualty. */
|
|
77
|
+
export const DEADLINE_CODE = 'EDEADLINE';
|
|
78
|
+
|
|
71
79
|
/**
|
|
72
80
|
* F64 — is this throw the run's OWN deadline coming back dressed as a provider
|
|
73
81
|
* error? `callTimeoutMs()` hands the provider a bound derived from the wall, so
|
|
@@ -90,12 +98,20 @@ export const TIMEOUT_CODE = 'ETIMEDOUT';
|
|
|
90
98
|
* the same (raise `maxWallMs`), and the alternative — calling a governance stop
|
|
91
99
|
* transport on the chance that it was — is the failure this closes.
|
|
92
100
|
*
|
|
101
|
+
* BOTH derived bounds land here, because both are derived from the same wall:
|
|
102
|
+
* BA-18's idle timeout (`callTimeoutMs`) and BA-19's total-duration deadline
|
|
103
|
+
* (`callDeadlineMs`). Adding the second CODE does not widen the rule — the
|
|
104
|
+
* discriminator is still `clock.expired()`, never the code. That matters for the
|
|
105
|
+
* deadline specifically: a provider someone else constructed with its own
|
|
106
|
+
* `deadlineMs` could throw this code on a run with time left, and that is transport,
|
|
107
|
+
* exactly as it was before.
|
|
108
|
+
*
|
|
93
109
|
* @param {any} err the throw as caught
|
|
94
110
|
* @param {Clock} clock the run's clock
|
|
95
111
|
* @returns {boolean}
|
|
96
112
|
*/
|
|
97
113
|
export function isWallTimeout(err, clock) {
|
|
98
|
-
return !!err && err.code === TIMEOUT_CODE && clock.expired();
|
|
114
|
+
return !!err && (err.code === TIMEOUT_CODE || err.code === DEADLINE_CODE) && clock.expired();
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
/**
|
|
@@ -108,21 +124,37 @@ export function isWallTimeout(err, clock) {
|
|
|
108
124
|
* @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
|
|
109
125
|
* @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
|
|
110
126
|
* @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
|
|
127
|
+
* @property {() => number|null} callDeadlineMs the derived per-call TOTAL-duration ceiling; `null` when unbounded
|
|
111
128
|
* @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
|
|
112
129
|
*/
|
|
113
130
|
|
|
114
131
|
/**
|
|
115
132
|
* Start a wall clock for one run.
|
|
116
|
-
* @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number }} [opts]
|
|
133
|
+
* @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number, priorElapsedMs?: number }} [opts]
|
|
117
134
|
* `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
|
|
118
135
|
* run. `closeStages`: how many stages the job's close runs (W5) — each one gets
|
|
119
136
|
* the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
|
|
120
137
|
* decoration. Defaults to 1, the single-predicate close. `now`: injected for
|
|
121
|
-
* tests — the default is the real clock.
|
|
138
|
+
* tests — the default is the real clock. `priorElapsedMs`: time this attempt's
|
|
139
|
+
* PREDECESSOR already consumed (see below).
|
|
122
140
|
* @returns {Clock}
|
|
123
141
|
*/
|
|
124
|
-
export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now() } = {}) {
|
|
125
|
-
|
|
142
|
+
export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now(), priorElapsedMs = 0 } = {}) {
|
|
143
|
+
// RESUME (module C) — hamr's checkpoint ruling in a time coat: a run killed
|
|
144
|
+
// mid-try restarts THAT try under the REMAINDER of its signed per-try numbers,
|
|
145
|
+
// because "a budget ceiling folds in prior spend so re-invoking cannot silently
|
|
146
|
+
// widen it". The fold happens HERE rather than by editing `maxWallMs`, and that
|
|
147
|
+
// is the whole point: the cap the operator signed stays the number reported and
|
|
148
|
+
// the number hashed (a rewritten cap is a new spec version needing a new
|
|
149
|
+
// signature), while the clock simply starts already partly spent. A fresh
|
|
150
|
+
// allotment on every kill would let a resumed run outlive the worst case the
|
|
151
|
+
// operator signed, one kill at a time.
|
|
152
|
+
//
|
|
153
|
+
// Belted like `maxWallMs` above: a non-finite, negative or garbage fold is 0. A
|
|
154
|
+
// NaN here would make every comparison false and silently unbound the run, which
|
|
155
|
+
// is the inverse of what the fold exists to do.
|
|
156
|
+
const prior = typeof priorElapsedMs === 'number' && Number.isFinite(priorElapsedMs) && priorElapsedMs > 0 ? priorElapsedMs : 0;
|
|
157
|
+
const startedAt = now() - prior;
|
|
126
158
|
// A cap is only a cap if it is a usable positive number. validateJob already
|
|
127
159
|
// reds anything else, so this is the belt (a caller that hand-builds a spec
|
|
128
160
|
// must not be able to turn a garbage value into a silent unbounded run OR a
|
|
@@ -162,6 +194,26 @@ export function createClock({ maxWallMs = null, closeStages = 1, now = () => Dat
|
|
|
162
194
|
// would restore the ~2h OS-TCP hang on the last round of a tight budget.
|
|
163
195
|
return Math.min(providerDefaultMs, Math.max(MIN_CALL_TIMEOUT_MS, remainingMs()));
|
|
164
196
|
},
|
|
197
|
+
// BA-19 (F72 park D) — the TOTAL-duration ceiling beside the idle one. The idle
|
|
198
|
+
// bound resets on every byte, so a response that trickles forever never trips it
|
|
199
|
+
// (F66's 274-minute call, and F67's 81.5-minute freeze happened above it); this
|
|
200
|
+
// one is a plain timer that no byte resets.
|
|
201
|
+
//
|
|
202
|
+
// Three properties, each a rule this repo already paid for:
|
|
203
|
+
// - NULL when unbounded. An operator who set no wall gets no ceiling — a
|
|
204
|
+
// defaulted cap is a silent second ceiling (F45, and the reason `maxWallMs`
|
|
205
|
+
// has no default). The caller must OMIT the knob on null, not pass a number.
|
|
206
|
+
// - REMAINING, read at call time. A constant would bound the last call by the
|
|
207
|
+
// first call's budget, which is the thing `callTimeoutMs` already refuses.
|
|
208
|
+
// - NOT clamped to the provider default. That upper clamp exists on the idle
|
|
209
|
+
// bound because a value above the provider's own default is inert; a
|
|
210
|
+
// total-duration ceiling above it is the entire point of BA-19.
|
|
211
|
+
// The lower floor is shared with `callTimeoutMs` and for the same two reasons: a
|
|
212
|
+
// bound certain to trip manufactures a stop out of arithmetic, and `0` DISABLES
|
|
213
|
+
// the bound upstream (`if (!(deadlineMs > 0)) return`) — the exact inverse of the
|
|
214
|
+
// intent. A trip past the floor is still honest: the wall has expired by then, so
|
|
215
|
+
// `isWallTimeout` reads it as the governance stop it is.
|
|
216
|
+
callDeadlineMs: () => (cap === null ? null : Math.max(MIN_CALL_TIMEOUT_MS, remainingMs())),
|
|
165
217
|
report: (closeTimeoutMs) => ({
|
|
166
218
|
bounded,
|
|
167
219
|
requestedMs: cap,
|
package/src/index.js
CHANGED
|
@@ -32,4 +32,33 @@ export { validatePlan, stageClose, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEP
|
|
|
32
32
|
export { snapshotScope, evalExits } from './exits.js';
|
|
33
33
|
export { runPlan } from './planrun.js';
|
|
34
34
|
export { runJob } from './run.js';
|
|
35
|
+
// Layer 3 — the REUSE registry (bridge-v1) and D3's display half. The registry is a
|
|
36
|
+
// directory of plain files at an OPERATOR-supplied path (never a default location), the
|
|
37
|
+
// load gate is the code half of D2's split, and `renderListing`/`selectionPrompt` are
|
|
38
|
+
// pure text: they send nothing, parse nothing and decide nothing. The selection CALL and
|
|
39
|
+
// the pin/shortlist/force-cold flow are the adopter's, which is why the pieces they need
|
|
40
|
+
// are exported rather than kept internal to the runner.
|
|
41
|
+
export {
|
|
42
|
+
BRIDGE_SCHEMA, validateBridge, deriveStatus, listingRow, loadGate,
|
|
43
|
+
mintBridge, appendGreen, appendRed,
|
|
44
|
+
loadBridge, loadRegistry, saveBridge, makeRegistry, registryExists,
|
|
45
|
+
} from './bridges.js';
|
|
46
|
+
export { renderListing, selectionPrompt } from './selection.js';
|
|
47
|
+
// Layer 3 modules 4+5 — the D7 envelope and the reuse runner. `runReuse` composes
|
|
48
|
+
// `runJob` under an operator-signed envelope (three explicit numbers, tighten-only) and
|
|
49
|
+
// is the only thing in the library that WRITES the registry — minting is a graded
|
|
50
|
+
// green's privilege (R1), never a caller's. `validateEnvelope`/`resolveTrySpec` are
|
|
51
|
+
// exported because an operator runner has to show the resolved per-try spec's HASH at
|
|
52
|
+
// its approval gate: a tightened envelope is a new spec version, and the human signs it.
|
|
53
|
+
// `resolveReuse`/`reuseSpecHash` are the pair that gate must actually PRINT: the try
|
|
54
|
+
// count multiplies the worst case, so a signature over the per-try spec alone leaves it
|
|
55
|
+
// unsigned — a runner showing `jobSpecHash(resolveTrySpec(...))` prints the same hash for
|
|
56
|
+
// 0 tries and for 9.
|
|
57
|
+
// Module C (resume after a kill) exports its two operator-side halves for the same
|
|
58
|
+
// reason: `readResume` is what turns a dead run's spine back into the state a resume
|
|
59
|
+
// continues from (which tries completed, which one restarts, on what remainder), and
|
|
60
|
+
// `resumeTreeGate` is the ruling that a resumed patient is continued dirty and never
|
|
61
|
+
// reset. Both are read by a runner BEFORE the approval gate, so neither can live behind
|
|
62
|
+
// `runReuse`'s own entry.
|
|
63
|
+
export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate } from './reuse.js';
|
|
35
64
|
export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';
|
package/src/ladder.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The step ladder's governor — PROGRESS, not a count (design 2026-08-03).
|
|
2
|
+
//
|
|
3
|
+
// MEASURED reason this exists. The step loop was bounded by a fixed number of
|
|
4
|
+
// check-iterations (`capRuns`, 4 in the operator's runner). Two real spines show
|
|
5
|
+
// what that cost: u-msd916dh went 30 → 22 → 17 → 11 scope errors across its
|
|
6
|
+
// iterations and was cut with $1.30 and ~6 minutes still unspent; u-mscxuziw went
|
|
7
|
+
// 13 → 1 and was cut the same way. The count could not tell converging work from
|
|
8
|
+
// thrash, so it stopped both at the same place. And the one lever the plan could
|
|
9
|
+
// pull — a step's `attempts` field — could only ever TIGHTEN below the shell's
|
|
10
|
+
// number, so the correct heal was inexpressible: drafters measurably tightened to
|
|
11
|
+
// 3, then to 2 on replan.
|
|
12
|
+
//
|
|
13
|
+
// The replacement asks the question the count could not: **is this iteration
|
|
14
|
+
// making progress?** Two signals, read per RED iteration:
|
|
15
|
+
//
|
|
16
|
+
// gap-new the failing exits' text, normalized and hashed, against every gap
|
|
17
|
+
// hash seen so far in this step's ladder. A SEEN-SET, never last-only:
|
|
18
|
+
// a real A→B→A oscillator exists in the archive and last-only reads it
|
|
19
|
+
// as progress.
|
|
20
|
+
// wrote did the worker make real write-class actions THIS iteration — read
|
|
21
|
+
// from the gate audit's own record COUNT (the F32 instrument:
|
|
22
|
+
// run-scoped, allow-decision, write AND edit). Never git status, never
|
|
23
|
+
// a tree diff. It must be a COUNT and not the path SET, because a plan
|
|
24
|
+
// step rewrites its one target every attempt — the set is constant
|
|
25
|
+
// after iteration 1 and would read every later iteration as idle (the
|
|
26
|
+
// same Finding-3 trap Layer R documents one module over).
|
|
27
|
+
//
|
|
28
|
+
// A **strike** is a red iteration that is a repeat OR wrote nothing. Strikes are
|
|
29
|
+
// STICKY — never reset, never repaid by a good iteration in between — because the
|
|
30
|
+
// question the ceiling answers is "has this step spent enough of the run failing
|
|
31
|
+
// to make progress", and a counter that a single good iteration zeroes can be held
|
|
32
|
+
// open forever by alternating.
|
|
33
|
+
//
|
|
34
|
+
// What still bounds a converging step, so this cannot run away: the wallet (the
|
|
35
|
+
// worker's Gate carries the run's remaining budget), the run's wall clock (W-2, at
|
|
36
|
+
// the head of every attempt), the variance meter (A), and the stall fuse. Those
|
|
37
|
+
// are unchanged and each still ends the ladder on its own authority. This module
|
|
38
|
+
// replaces exactly ONE of the old bounds: exhaustion-by-count.
|
|
39
|
+
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The strike ceiling. Shell-owned, exactly where the fixed count was: the runner
|
|
44
|
+
* sets it, the agent cannot express it, and it is arbiter territory — a step's own
|
|
45
|
+
* plan may not tighten it and may not raise it. Two is hamr's number.
|
|
46
|
+
*/
|
|
47
|
+
export const STRIKE_LIMIT = 2;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The volatile bytes, stripped before hashing. COMPARISON-ONLY: the gap the worker
|
|
51
|
+
* sees is never rewritten (the `normalizeRedLine` rule, one module over).
|
|
52
|
+
*
|
|
53
|
+
* Every entry is a byte a re-run changes while the failure does not. Without them
|
|
54
|
+
* a `node --test` gap is never byte-stable (its duration stamps move every run),
|
|
55
|
+
* so every repeat would read as a novel gap and the repeat signal would be dead —
|
|
56
|
+
* an instrument that cannot see the variable.
|
|
57
|
+
* @type {[RegExp, string][]}
|
|
58
|
+
*/
|
|
59
|
+
const VOLATILE = [
|
|
60
|
+
// TAP's own timing line, dropped WHOLE: its value is the only thing on it
|
|
61
|
+
[/^[ \t]*#?[ \t]*duration_ms[ \t]*[:=].*$/gm, ''],
|
|
62
|
+
// ISO-8601 stamps, wherever they appear in a close's output
|
|
63
|
+
[/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<ts>'],
|
|
64
|
+
// any millisecond figure (`(1.738ms)`, `took 12 ms`)
|
|
65
|
+
[/\d+(?:\.\d+)?[ \t]*ms\b/g, '<ms>'],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize a gap for comparison. Deliberately NARROW: only bytes a re-run changes
|
|
70
|
+
* on its own are removed. Error COUNTS, file names and line numbers all survive —
|
|
71
|
+
* a trajectory like "30 errors" → "22 errors" must read as two different gaps, and
|
|
72
|
+
* a normalizer that scrubbed numbers would turn the converging case this module
|
|
73
|
+
* exists to protect into an instant strike.
|
|
74
|
+
* @param {string} text @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeGap(text) {
|
|
77
|
+
let s = String(text ?? '');
|
|
78
|
+
for (const [re, to] of VOLATILE) s = s.replace(re, to);
|
|
79
|
+
// runs of spaces/tabs collapse (a re-run can re-pad a column); newlines do NOT —
|
|
80
|
+
// line structure is real content
|
|
81
|
+
return s.replace(/[ \t]+/g, ' ').replace(/[ \t]+$/gm, '').trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The comparable key for a gap: the normalized text, hashed. Hashed rather than
|
|
86
|
+
* kept whole because the seen-set lives for the whole step and a close's output
|
|
87
|
+
* can be tens of KB — and because the set holds no content, nothing here can leak
|
|
88
|
+
* model or file bytes into a record.
|
|
89
|
+
* @param {string} text @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function gapKey(text) {
|
|
92
|
+
return createHash('sha256').update(normalizeGap(text), 'utf8').digest('hex');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build one ladder. ONE PER STEP: the seen-set and the strike count are the step's
|
|
97
|
+
* own history, and a ladder shared across steps would strike a fresh step for a gap
|
|
98
|
+
* an earlier one had already seen.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} opts
|
|
101
|
+
* @param {number} [opts.limit] strikes that end the step (default `STRIKE_LIMIT`)
|
|
102
|
+
* @param {() => number} opts.writeCount the CUMULATIVE count of the worker's
|
|
103
|
+
* allow-decision write/edit records so far this step (planrun reads it off the
|
|
104
|
+
* gate audit). Required, and a throw when it is missing rather than a silent
|
|
105
|
+
* degradation to repeats-only: a strike rule running on one of its two signals
|
|
106
|
+
* with nothing on the record saying so is the blind-instrument class, and this
|
|
107
|
+
* is the BA-4 param-guard class where a throw is the right answer.
|
|
108
|
+
*/
|
|
109
|
+
export function createLadder({ limit = STRIKE_LIMIT, writeCount }) {
|
|
110
|
+
if (typeof writeCount !== 'function') {
|
|
111
|
+
throw new TypeError('createLadder requires a writeCount seam — a strike rule with one signal wired is prose, not a bound');
|
|
112
|
+
}
|
|
113
|
+
/** @type {Map<string, number>} gap key → the iteration it was FIRST seen at */
|
|
114
|
+
const firstSeen = new Map();
|
|
115
|
+
/** @type {{at: number, firstAt: number}[]} */
|
|
116
|
+
const repeats = [];
|
|
117
|
+
/** @type {number[]} iterations in which the worker wrote nothing */
|
|
118
|
+
const idle = [];
|
|
119
|
+
let strikes = 0;
|
|
120
|
+
let iterations = 0;
|
|
121
|
+
let priorWrites = 0;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
/**
|
|
125
|
+
* Read one RED iteration. Called by ralph after the judge has rendered a
|
|
126
|
+
* non-green, non-fault verdict — a green never reaches here, so every reading
|
|
127
|
+
* is a failing iteration by construction.
|
|
128
|
+
* @param {{iteration: number, gap?: string}} o
|
|
129
|
+
* @returns {{governor: string, iteration: number, strike: boolean, strikes: number,
|
|
130
|
+
* limit: number, wrote: boolean, repeatOf: number|null, distinctGaps: number}}
|
|
131
|
+
*/
|
|
132
|
+
record({ iteration, gap }) {
|
|
133
|
+
iterations = iteration;
|
|
134
|
+
const key = gapKey(gap ?? '');
|
|
135
|
+
const firstAt = firstSeen.get(key);
|
|
136
|
+
const repeated = firstAt !== undefined;
|
|
137
|
+
if (repeated) repeats.push({ at: iteration, firstAt: /** @type {number} */ (firstAt) });
|
|
138
|
+
else firstSeen.set(key, iteration);
|
|
139
|
+
|
|
140
|
+
// the DELTA, not the total: the audit is append-only across the whole step
|
|
141
|
+
const total = writeCount();
|
|
142
|
+
const wrote = total > priorWrites;
|
|
143
|
+
priorWrites = total;
|
|
144
|
+
if (!wrote) idle.push(iteration);
|
|
145
|
+
|
|
146
|
+
const strike = repeated || !wrote;
|
|
147
|
+
if (strike) strikes += 1; // sticky: nothing anywhere decrements this
|
|
148
|
+
return {
|
|
149
|
+
// WHICH governor produced this reading. Two of them emit under the one
|
|
150
|
+
// `ladder` spine type now (this one, and the close-fix loop's per-stage trend
|
|
151
|
+
// reader), and their fields differ — a reader that cannot tell them apart
|
|
152
|
+
// would average two different instruments into one number.
|
|
153
|
+
governor: 'step-ladder',
|
|
154
|
+
iteration, strike, strikes, limit, wrote,
|
|
155
|
+
repeatOf: repeated ? /** @type {number} */ (firstAt) : null,
|
|
156
|
+
distinctGaps: firstSeen.size,
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
/** has the step run out of strikes? */
|
|
160
|
+
struckOut: () => strikes >= limit,
|
|
161
|
+
/** the ladder's own books — counts and iteration numbers, never gap content */
|
|
162
|
+
report: () => ({
|
|
163
|
+
iterations, strikes, limit, distinctGaps: firstSeen.size,
|
|
164
|
+
repeats: repeats.map((r) => ({ ...r })),
|
|
165
|
+
idle: [...idle],
|
|
166
|
+
}),
|
|
167
|
+
/**
|
|
168
|
+
* The mechanism, in one sentence, for the replan brief. It is the only channel
|
|
169
|
+
* the redrafting planner adapts to, so it states WHICH shape ended the step and
|
|
170
|
+
* the evidence for it — never a label on its own, and never a culprit file
|
|
171
|
+
* beyond what the close itself reported (F28): the inputs here are iteration
|
|
172
|
+
* numbers and counts, so naming one is inexpressible.
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
brief() {
|
|
176
|
+
const parts = [];
|
|
177
|
+
if (repeats.length) {
|
|
178
|
+
const last = repeats[repeats.length - 1];
|
|
179
|
+
parts.push(`it repeated itself — the exit output at iteration ${last.at} had already been seen at iteration ${last.firstAt}`);
|
|
180
|
+
}
|
|
181
|
+
if (idle.length) parts.push(`it stalled — no file was written in iteration(s) ${idle.join(', ')}`);
|
|
182
|
+
const trajectory = `${firstSeen.size} distinct exit output(s) over ${iterations} iteration(s)`;
|
|
183
|
+
// BOTH signals gate it, because "converging" is a claim about the WORK and a
|
|
184
|
+
// moving exit output is only half the evidence for one. Gated on repeats alone
|
|
185
|
+
// it fired on an idle strike-out — "no file was written in iteration(s) 1, 2"
|
|
186
|
+
// and "the step was converging" in the same paragraph, handed to the one
|
|
187
|
+
// channel the redrafting planner adapts to. A step that wrote nothing was not
|
|
188
|
+
// converging whatever its exit bytes did (a close re-run moves its own).
|
|
189
|
+
const converging = repeats.length === 0 && idle.length === 0
|
|
190
|
+
? ' Every attempt moved the exit output, so the step was converging when it struck out.'
|
|
191
|
+
: '';
|
|
192
|
+
return `The step ladder ended it on ${strikes} strike(s) of ${limit}: ${parts.join('; ')}. `
|
|
193
|
+
+ `Gap trajectory: ${trajectory}.${converging}`;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/ledger.js
CHANGED
|
@@ -79,6 +79,33 @@ const EXCLUDED_ESCALATIONS = new Set([
|
|
|
79
79
|
'smoke-red', // already counted via primitive-smoke
|
|
80
80
|
'hitl-close', // by design: a human is the close
|
|
81
81
|
'close-unsupported', // honest refusal, by design
|
|
82
|
+
// Layer 3 (D2 split): the bridge load gate refused a wrong-KIND recipe at the door.
|
|
83
|
+
// That is the mechanism WORKING — every recipe from a different day is a candidate for
|
|
84
|
+
// refusal, and the run stopped having spent nothing. Filing it upstream would aim a bug
|
|
85
|
+
// report at a library for our own gate doing its job (the step-stalled lesson).
|
|
86
|
+
'recipe-stale',
|
|
87
|
+
// Layer 3 (D7/D3) — the reuse runner's own three stops. Each is the ENVELOPE or the
|
|
88
|
+
// OPERATOR speaking, never a library failing: `reuse-exhausted` is the pre-authorized
|
|
89
|
+
// number of tries being spent (the money/time story, exactly like cap-halt);
|
|
90
|
+
// `selection-refused` is the model declining a pinned workflow, which D3 requires it to
|
|
91
|
+
// be able to do; `selection-red` is a model answer that was not the one JSON object it
|
|
92
|
+
// was asked for — a behaviour, not a transport fault (a real transport fault out of the
|
|
93
|
+
// selection call is classified as `provider-red` and is NOT excluded).
|
|
94
|
+
'reuse-exhausted',
|
|
95
|
+
'selection-refused',
|
|
96
|
+
'selection-red',
|
|
97
|
+
// Layer 3 (module C) — a RESUME that refuses to continue: the seed was signed under a
|
|
98
|
+
// different envelope, or the workflow the killed run was part-way through is no longer
|
|
99
|
+
// in the registry. Both are the operator's own state speaking (a stale signature, an
|
|
100
|
+
// edited registry), and both stop having run nothing.
|
|
101
|
+
'resume-red',
|
|
102
|
+
'registry-red', // the operator-supplied registry path does not exist — a typo, not a bug
|
|
103
|
+
'envelope-red', // the envelope does not compose with the signed spec — operator input
|
|
104
|
+
// the reuse runner also STOPS on the three answers no further try could change, and
|
|
105
|
+
// escalates each under its own name. Both are operator-input stories: an unsigned spec
|
|
106
|
+
// version and a spec that will not validate. (`smoke-red` is already excluded above.)
|
|
107
|
+
'unapproved-spec',
|
|
108
|
+
'job-red',
|
|
82
109
|
'close-timeout', // close-verdict reds: the arbiter's own named terminals
|
|
83
110
|
'close-killed', // (F17) — operator/environment stories, never a suite lib
|
|
84
111
|
'close-crashed',
|
package/src/plan.js
CHANGED
|
@@ -246,7 +246,7 @@ export function stageClose(close) {
|
|
|
246
246
|
* job spec. Never throws on JSON text or plain parsed data; every failure is
|
|
247
247
|
* a named red. Returns the parsed plan on ok (single parse), null on any red.
|
|
248
248
|
* @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
|
|
249
|
-
* @param {{ job?: any, maxStepRounds?: number, scopes?: string[],
|
|
249
|
+
* @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[] }} [opts] `job`: the
|
|
250
250
|
* validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
|
|
251
251
|
* menu all come from it — a missing or non-plan-shape job fails CLOSED);
|
|
252
252
|
* `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
|
|
@@ -254,9 +254,23 @@ export function stageClose(close) {
|
|
|
254
254
|
* `scopes`: the offered `tree-changed` menu from `legalScopes` (the SAME array
|
|
255
255
|
* the drafting prompt enumerated). Omitted, it derives from the signed
|
|
256
256
|
* writeScope — never a free-text fallback (F50).
|
|
257
|
+
*
|
|
258
|
+
* The two shape-lottery gate rules (2026-08-04) — both keyed on RECORDED
|
|
259
|
+
* facts, never an LLM judgment of "is this job small", and both inactive
|
|
260
|
+
* when their fact is omitted (resume/reuse callers stay byte-identical):
|
|
261
|
+
* `seedRed`: check names whose PREFLIGHT verdict was needs_revision. Rule
|
|
262
|
+
* A-v2 — a seed-red check is the goal itself, not a milestone; it may only
|
|
263
|
+
* sit on the plan's FINAL write step (`check-placement`). The $0 archive
|
|
264
|
+
* sweep: seed-red-check-on-an-early-step has 0 honest greens ever; the one
|
|
265
|
+
* wide closing step greens 7/7. Green-at-seed checks stay free mid-plan
|
|
266
|
+
* (the 20 TESTGEN mid-plan greens — a guard, not a goal).
|
|
267
|
+
* `priorChecks`: the check names the PREDECESSOR plan carried (replan gate
|
|
268
|
+
* only). Rule B — a redraft may not drop one (`check-shed`): exits without
|
|
269
|
+
* the check verify FORM alone, so a shed lets the run "green" unearned
|
|
270
|
+
* (u-msdsmkid mechanism b; the 3 archived sheds were all on step-red runs).
|
|
257
271
|
* @returns {{ ok: boolean, reds: Red[], plan: object|null }}
|
|
258
272
|
*/
|
|
259
|
-
export function validatePlan(input, { job, maxStepRounds = 40, scopes,
|
|
273
|
+
export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed, priorChecks } = {}) {
|
|
260
274
|
/** @type {Red[]} */
|
|
261
275
|
const reds = [];
|
|
262
276
|
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
@@ -319,6 +333,14 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
|
|
|
319
333
|
} else if (plan.steps.length > MAX_PLAN_STEPS) {
|
|
320
334
|
red('bounds', 'steps', `max ${MAX_PLAN_STEPS} steps — per-step rounds bound the spend, the step count bounds the claim on the wallet`);
|
|
321
335
|
} else {
|
|
336
|
+
// Rule A-v2's anchor: the index of the plan's FINAL write-granted step. Read
|
|
337
|
+
// off the RAW steps (a step whose grant didn't parse counts as non-write —
|
|
338
|
+
// its own missing-required/invalid-value red already fired; deriving a
|
|
339
|
+
// placement charge from the false default would be a second red for one
|
|
340
|
+
// defect). -1 when no step writes: every check then reds as a mailbox anyway.
|
|
341
|
+
const lastWriteIdx = plan.steps.reduce((/** @type {number} */ acc, /** @type {any} */ s, /** @type {number} */ i) => (
|
|
342
|
+
isObj(s) && Array.isArray(s.tools) && s.tools.some((/** @type {unknown} */ t) => WRITE_VERBS.includes(/** @type {string} */ (t))) ? i : acc), -1);
|
|
343
|
+
const seedRedNames = Array.isArray(seedRed) ? seedRed.filter(isNonEmptyString) : [];
|
|
322
344
|
const seen = new Set();
|
|
323
345
|
plan.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
|
|
324
346
|
const at = `steps.${i}`;
|
|
@@ -370,8 +392,24 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
|
|
|
370
392
|
if (s.model !== undefined && !STEP_MODELS.includes(s.model)) {
|
|
371
393
|
red('invalid-value', `${at}.model`, `menu: ${STEP_MODELS.join('|')} — the tier menu is signed; the agent picks a tier, never names a model`);
|
|
372
394
|
}
|
|
373
|
-
|
|
374
|
-
|
|
395
|
+
// `attempts` is TOLERATED-INERT (step ladder, 2026-08-03). It once tightened a
|
|
396
|
+
// fixed per-step attempt count; that count is gone — a step's iterations are
|
|
397
|
+
// governed by the strike ladder, and a field that could only ever TIGHTEN is
|
|
398
|
+
// the wrong shape for a progress rule (drafters measurably tightened it to 3,
|
|
399
|
+
// then to 2 on replan, which is the opposite of the heal a converging step
|
|
400
|
+
// needs). It is no longer OFFERED in the drafting prompt, and the runner
|
|
401
|
+
// ignores it.
|
|
402
|
+
//
|
|
403
|
+
// It is not REJECTED, and that is the load-bearing half: the frozen bridge
|
|
404
|
+
// registry's stored plans carry `attempts`, and a bridge rides into the drafter
|
|
405
|
+
// as a starting draft after passing the ordinary validator. Redding it here
|
|
406
|
+
// would refuse every stored recipe minted before today and ruin a frozen
|
|
407
|
+
// contrast experiment. The SHAPE is still checked — a known field holding
|
|
408
|
+
// garbage stays a named red — but the upper bound is gone with the cap it
|
|
409
|
+
// named: keeping `<= capRuns` would make a plan stored under one runner's
|
|
410
|
+
// number invalid under another's, for a field that decides nothing.
|
|
411
|
+
if (s.attempts !== undefined && !(Number.isInteger(s.attempts) && s.attempts >= 1)) {
|
|
412
|
+
red('bounds', `${at}.attempts`, 'integer >= 1 — the field is accepted for stored-plan compatibility and bounds nothing (the step ladder governs iterations)');
|
|
375
413
|
}
|
|
376
414
|
if (s.scope !== undefined && !scopeMenu.includes(s.scope)) {
|
|
377
415
|
red('invalid-value', `${at}.scope`, `menu: [${scopeMenu.join(', ')}] — a per-step write scope narrows the fence from the same menu tree-changed uses`);
|
|
@@ -435,8 +473,26 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
|
|
|
435
473
|
red('step-scope-escape', `${at}.target`, `"${s.target}" is inside the signed fence but outside this step's own scope "${stepScope}" — the step's gate is built from that narrowed prefix, so every write to this target would be denied`);
|
|
436
474
|
}
|
|
437
475
|
|
|
438
|
-
validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope });
|
|
476
|
+
validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope, seedRedNames, isFinalWrite: i === lastWriteIdx });
|
|
439
477
|
});
|
|
478
|
+
|
|
479
|
+
// Rule B (`check-shed`, replan gate): the caller hands over the check names
|
|
480
|
+
// the PREDECESSOR plan carried; a redraft that drops one is refused. Exits
|
|
481
|
+
// without the check verify FORM alone (tree-changed/artifact-written), so a
|
|
482
|
+
// shed lets a step "green" with the truth unjudged — u-msdsmkid's replan
|
|
483
|
+
// dropped check-passes from BOTH steps and greened unearned on form. Set
|
|
484
|
+
// semantics on NAMES: where the check sits may move (A-v2 forces it to the
|
|
485
|
+
// final write step); that it is judged at all may not. One red listing every
|
|
486
|
+
// dropped name — one defect (the shed), one red.
|
|
487
|
+
if (Array.isArray(priorChecks)) {
|
|
488
|
+
const carried = new Set(plan.steps.flatMap((/** @type {any} */ s) => (isObj(s) && Array.isArray(s.exit)
|
|
489
|
+
? s.exit.filter((/** @type {any} */ e) => isObj(e) && e.type === 'check-passes' && isNonEmptyString(e.name)).map((/** @type {any} */ e) => e.name)
|
|
490
|
+
: [])));
|
|
491
|
+
const dropped = priorChecks.filter(isNonEmptyString).filter((n) => !carried.has(n));
|
|
492
|
+
if (dropped.length) {
|
|
493
|
+
red('check-shed', 'steps', `the predecessor plan carried check-passes(${dropped.join(', ')}) and this redraft drops ${dropped.length === 1 ? 'it' : 'them'} — a replan may move a check, never shed it (the remaining exits verify form only, so a shed green would be unearned)`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
440
496
|
}
|
|
441
497
|
|
|
442
498
|
// 3. secrets sweep — the agent-authored document is the riskier entry point
|
|
@@ -452,13 +508,15 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
|
|
|
452
508
|
* @param {Record<string, any>} s the step
|
|
453
509
|
* @param {string} at step path prefix
|
|
454
510
|
* @param {(code: string, path: string, detail?: string) => void} red
|
|
455
|
-
* @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean, toolsParsed: boolean, scopeMenu: string[], stepScope: string|null, overlapsStepScope: (p: string) => boolean }} ctx
|
|
511
|
+
* @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean, toolsParsed: boolean, scopeMenu: string[], stepScope: string|null, overlapsStepScope: (p: string) => boolean, seedRedNames: string[], isFinalWrite: boolean }} ctx
|
|
456
512
|
* `stepScope`/`overlapsStepScope` (W3): the step's OWN narrowed fence when it
|
|
457
513
|
* declared a legal `scope`, null/always-true otherwise. Exits are OBSERVATIONS,
|
|
458
514
|
* not writes, so only `tree-changed` is constrained by it and only against
|
|
459
515
|
* DISJOINTness — see the arm for the measured semantics.
|
|
516
|
+
* `seedRedNames`/`isFinalWrite` (Rule A-v2): the preflight-red check names and
|
|
517
|
+
* whether THIS step is the plan's final write step — see the check-passes arm.
|
|
460
518
|
*/
|
|
461
|
-
function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope }) {
|
|
519
|
+
function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope, seedRedNames, isFinalWrite }) {
|
|
462
520
|
if (!Array.isArray(s.exit) || s.exit.length === 0) {
|
|
463
521
|
red('missing-required', `${at}.exit`, `non-empty array from the closed menu ${EXIT_TYPES.join('|')} — ALL listed exits must pass (AND-only); a step without an exit has no progress gate`);
|
|
464
522
|
return;
|
|
@@ -485,6 +543,16 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, t
|
|
|
485
543
|
// decision 1: a check the spec doesn't sign DOES NOT EXIST — and the
|
|
486
544
|
// detail names the signed menu so the replan can aim, not guess
|
|
487
545
|
red('check-unknown', eAt, `"${e.name}" is not an offered close stage — the agent references stages of the operator's close, never authors a ruler; offered: [${checkNames.join(', ') || 'none'}]`);
|
|
546
|
+
} else if (seedRedNames.includes(e.name) && writeStep && !isFinalWrite) {
|
|
547
|
+
// Rule A-v2 (`check-placement`): a check that was RED at preflight is
|
|
548
|
+
// the GOAL itself, not a milestone — it judges the whole territory, so
|
|
549
|
+
// an earlier step scoped to a slice can never satisfy it without
|
|
550
|
+
// crossing its own boundary (the bareagent-u death shape: 0 honest
|
|
551
|
+
// greens in the whole archive; the wide closing step greens 7/7). Only
|
|
552
|
+
// on a WRITE step and only when the anchor exists elsewhere: a
|
|
553
|
+
// read-only step already redded as a mailbox (one defect, one red),
|
|
554
|
+
// and a green-at-seed check is a guard, free to sit anywhere.
|
|
555
|
+
red('check-placement', eAt, `"${e.name}" was RED at preflight — a failing check is the goal itself and may only gate the plan's FINAL write step (make that step wide: free to edit any file it reports, iterating until it passes); an earlier step scoped to a slice can never satisfy a whole-goal check`);
|
|
488
556
|
}
|
|
489
557
|
} else if (e.type === 'tree-changed') {
|
|
490
558
|
hasTreeChanged = true;
|