bareloop 0.6.0 → 0.8.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/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
- const startedAt = now();
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/exits.js CHANGED
@@ -33,8 +33,14 @@ import { globToPrefix } from './validate.js';
33
33
  * `not ok`/`FAILED` names — the mechanical gap the F46 conversion mechanism
34
34
  * feeds the worker (F28 reintroduced). This cap is a defensive backstop above
35
35
  * boundGap's own envelope: a normal bounded gap passes through intact, and only
36
- * a pathological seam that returned an unbounded gap would ever be trimmed. */
37
- const CHECK_GAP_MAX = 12000;
36
+ * a pathological seam that returned an unbounded gap would ever be trimmed.
37
+ *
38
+ * Exported for the ONE reason `boundGap`/`GAP_KEEP_TRIM_MARKER` are (src/ralph.js):
39
+ * the replan brief re-bounds this same artifact one seam later (`closeGapBlock`), and
40
+ * a second hand-spelled ceiling there would be a second answer to "how big may a
41
+ * check gap be" — the two would drift on exactly the property that matters, whether
42
+ * the `not ok`/`FAILED` names survive. */
43
+ export const CHECK_GAP_MAX = 12000;
38
44
 
39
45
  /** @typedef {{ type: string, pass: boolean, detail?: string, fault?: string }} ExitResult
40
46
  * `fault` present means the INSTRUMENT could not judge (unwired snapshot/seam,
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/job.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // judgment call. Minting policy is product doctrine, not job-authorable.
15
15
 
16
16
  import { createHash } from 'node:crypto';
17
- import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
17
+ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
18
18
 
19
19
  // The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
20
20
  // they are read at call time, so a mutable export would let adopter code
@@ -123,7 +123,12 @@ const CLOSE_FIELDS = {
123
123
  };
124
124
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
125
125
 
126
- /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red — `verb` rides request-reds as structured data (the ledger keys on it, never on prose) */
126
+ /** @typedef {{code: string, path: string, detail?: string, verb?: string, lib?: string}} Red — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
127
+ * `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
128
+ * (the typed-lib rule): one code, two catalogues — a locked TOOL verb is demand
129
+ * against the worker-surface package, a locked VERDICT type is demand against
130
+ * bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
131
+ * filed as an upstream bug (the BA-2 misattribution class). */
127
132
 
128
133
  /**
129
134
  * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
@@ -278,6 +283,21 @@ function validateClose(close, at, red) {
278
283
  return true;
279
284
  }
280
285
 
286
+ /**
287
+ * The F49 reject's OPERATOR-side detail. ONE spelling for both regex fields —
288
+ * they differ only in which untimed evaluator the pattern reaches — so the two
289
+ * reds can never drift into saying different things about the same hazard.
290
+ *
291
+ * The second sentence is the part an operator cannot derive: on the agent side
292
+ * F49 is self-DoS, but these two patterns are compiled and run in BARELOOP'S OWN
293
+ * PROCESS, where the hang blocks the event loop the in-process stall fuse lives
294
+ * on (F67 — a guard sharing the fate of the process it guards). The run then
295
+ * dies to the outside watchdog and reads as a model stall, so without this red a
296
+ * bad regex in a SIGNED spec is diagnosed as a provider problem.
297
+ * @param {string} evaluator the consumer that would run it, named concretely
298
+ */
299
+ const redosDetail = (evaluator) => `nested unbounded quantifier (e.g. (a+)+ , (\\d*)* , (x+){1,}) — a catastrophic-backtracking footgun that can hang ${evaluator}, untimed (F49). It runs in bareloop's own process, so the hang blocks the event loop the stall fuse lives on (F67) and the run dies to the outside watchdog looking like a model stall; rewrite without a repeated group inside a repeat`;
300
+
281
301
  /**
282
302
  * The predicate BODY contract — cmd/expect/judged/gapKeep. Shared verbatim by
283
303
  * the predicate close and the Layer 2 named checks (decision 1: a check runs
@@ -330,6 +350,13 @@ function predicateBody(o, at, red) {
330
350
  // the fake green this floor exists to catch). Alternation stays
331
351
  // fully expressible with non-capturing branches: `(?:a|b) (\d+)`.
332
352
  else if (groups > 1) red('invalid-value', `${at}.judged.pattern`, `${groups} capture groups — the count is read from group 1 only; use exactly one capture group and non-capturing (?:…) branches`);
353
+ // F49, operator side: `runClose` execs this against the close's whole
354
+ // stdout+stderr with no timeout. Gated on `groups >= 0` — that is the
355
+ // COMPILED flag in disguise (the catch sets -1), and the scan documents
356
+ // that it assumes syntactically valid input.
357
+ if (groups >= 0 && hasNestedQuantifier(j.pattern)) {
358
+ red('invalid-value', `${at}.judged.pattern`, redosDetail('the close reader (runClose)'));
359
+ }
333
360
  }
334
361
  if (!Number.isInteger(j.min) || j.min < 1) {
335
362
  red('invalid-value', `${at}.judged.min`, 'integer >= 1 — a floor of 0 is satisfied by judging nothing, which is the check it is meant to make');
@@ -348,8 +375,15 @@ function predicateBody(o, at, red) {
348
375
  if (o.gapKeep !== undefined) {
349
376
  if (!isNonEmptyString(o.gapKeep)) red('invalid-value', `${at}.gapKeep`, 'regex source string — lines matching it survive the gap bound (F28)');
350
377
  else {
351
- try { new RegExp(o.gapKeep, 'm'); }
378
+ let compiled = false;
379
+ try { new RegExp(o.gapKeep, 'm'); compiled = true; }
352
380
  catch { red('invalid-value', `${at}.gapKeep`, 'must compile as a RegExp'); }
381
+ // F49, operator side and the measured one: `boundGap` runs this per LINE
382
+ // over the whole close stream, untimed. Measured: `(a+)+$` against a
383
+ // 40-char line does not finish in 20s.
384
+ if (compiled && hasNestedQuantifier(o.gapKeep)) {
385
+ red('invalid-value', `${at}.gapKeep`, redosDetail('the gap bound (boundGap), once per line of close output'));
386
+ }
353
387
  }
354
388
  }
355
389
  }
@@ -486,8 +520,9 @@ function validatePlanShape(spec, red, reds) {
486
520
  else if (LOCKED_VERDICTS.includes(spec.verdictType)) {
487
521
  // declared-but-locked (disclosure ≠ admission, the tool-menu pattern):
488
522
  // the type rides as a structured field — the ledger keys admission demand
489
- // on it, never on prose
490
- reds.push({ code: 'request-red', path: 'verdictType', verb: spec.verdictType, detail: `"${spec.verdictType}" is declared-but-locked — not at this rung (v1 admits green only); this red IS the admission evidence, never a grant` });
523
+ // on it, never on prose. `lib: 'bareloop'`: the refused catalogue is OURS
524
+ // (VERDICT_TYPES), so this demand is never an upstream ask.
525
+ reds.push({ code: 'request-red', path: 'verdictType', verb: spec.verdictType, lib: 'bareloop', detail: `"${spec.verdictType}" is declared-but-locked — not at this rung (v1 admits green only); this red IS the admission evidence, never a grant` });
491
526
  demanded = CLASS_BY_VERDICT[spec.verdictType];
492
527
  } else if (!VERDICT_TYPES.includes(spec.verdictType)) {
493
528
  red('invalid-value', 'verdictType', `menu: ${VERDICT_TYPES.join('|')} — an unknown type is a typo, never a request`);
@@ -529,7 +564,9 @@ function validatePlanShape(spec, red, reds) {
529
564
  red('invalid-value', 'tools', `non-empty unique subset of ${TOOL_MENU.join('|')}`);
530
565
  } else {
531
566
  for (const t of spec.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
532
- reds.push({ code: 'request-red', path: 'tools', verb: t, detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
567
+ // `lib: 'bare-agent'`: a locked TOOL verb is demand against the
568
+ // worker-surface package that would have to expose it (the original case)
569
+ reds.push({ code: 'request-red', path: 'tools', verb: t, lib: 'bare-agent', detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
533
570
  }
534
571
  const unknown = spec.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
535
572
  if (unknown.length) red('invalid-value', 'tools', `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
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',
@@ -90,9 +117,15 @@ const ASKS = /** @type {Record<string, (o: {lib: string, verb: string, detail: s
90
117
  'runtime-red': (o) => `${o.lib}: \`${o.verb}\` threw at runtime — ${o.detail}`,
91
118
  'provider-red': (o) => `bare-agent: the provider path failed — ${o.detail}`,
92
119
  'pricing-red': (o) => `bare-agent: a provider result carried no priced cost (unpriced is never free, F6) — ${o.detail}`,
93
- 'capability-gap': (o) => `bare-agent: locked \`${o.verb}\` was requested and the run cap-halted — admission candidate`,
120
+ // same rule as request-red below (capability-gap is its cap-halted form): the
121
+ // ask is seeded at the STAMPED target, so a bareloop-catalogue refusal cannot
122
+ // render as "bare-agent: …" — fixing the occurrence's lib alone would leave the
123
+ // misattribution intact in the one field a human actually files from
124
+ 'capability-gap': (o) => `${o.lib}: locked \`${o.verb}\` was requested and the run cap-halted — admission candidate`,
94
125
  'broken-close': (o) => `job owner: the close itself cannot run — ${o.detail}`,
95
- 'request-red': (o) => `admission: locked \`${o.verb}\` requested by a job spec — demand evidence, never a grant`,
126
+ // the target is the STAMPED lib, never a hardcoded package: a bareloop-catalogue
127
+ // refusal (a locked verdict type) must not seed an ask that reads "bare-agent: …"
128
+ 'request-red': (o) => `${o.lib}: admission — locked \`${o.verb}\` requested by a job spec — demand evidence, never a grant`,
96
129
  'retention-red': (o) => `litectx: on-green retention failed — ${o.detail}`,
97
130
  'config-red': (o) => `bareloop: drafting friction at ${o.verb} — ${o.detail}`,
98
131
  });
@@ -133,9 +166,20 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
133
166
  // a repeated signature indicts the drafting prompt/schema, i.e. bareloop
134
167
  add(ev, 'config-red', 'bareloop', String(ev.path ?? 'config').split('.')[0] || 'config', `${ev.code} at ${ev.path}: ${ev.detail ?? ''}`);
135
168
  } else if (ev.type === 'job-red' && ev.code === 'request-red') {
136
- // the structured field wins; the prose-quoted verb stays as the fallback
137
- // for spines written before the field existed
138
- add(ev, 'request-red', 'bare-agent', ev.verb ?? (String(ev.detail ?? '').match(QUOTED_VERB_RE) ?? [])[1] ?? 'unknown', ev.detail);
169
+ // the structured fields win; the prose-quoted verb stays as the fallback
170
+ // for spines written before the field existed, and the hardcoded
171
+ // 'bare-agent' lib stays as the fallback for the same reason — it was the
172
+ // only territory `request-red` had when the field did not exist.
173
+ //
174
+ // One code, TWO territories: a locked TOOL verb is demand against the
175
+ // worker-surface package; a locked VERDICT type (and, when D13 lands, a
176
+ // genre refusal) is demand against bareloop's OWN catalogue and must never
177
+ // seed an upstream ask. The lib is stamped at the emit site (src/job.js)
178
+ // per the typed-lib rule — deriving it here from the code or the path is
179
+ // exactly the BA-2 misattribution class. A new bareloop-territory
180
+ // request-red therefore routes correctly by stamping its own lib, with no
181
+ // change to this branch.
182
+ add(ev, 'request-red', ev.lib ?? 'bare-agent', ev.verb ?? (String(ev.detail ?? '').match(QUOTED_VERB_RE) ?? [])[1] ?? 'unknown', ev.detail);
139
183
  } else if (ev.type === 'escalation') {
140
184
  if (ev.category === 'broken-close') {
141
185
  add(ev, 'broken-close', 'consumer', 'close', ev.detail);
@@ -184,10 +228,41 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
184
228
 
185
229
  if (capHalted) {
186
230
  // capability-gap: the run hit its cap in a spine that also asked for a
187
- // locked verb — the strongest admission evidence there is (design table)
188
- for (const verb of new Set(occs.filter((o) => o.class === 'request-red').map((o) => o.verb))) {
189
- const halt = events.find((e) => e.type === 'cap-halt');
190
- add(halt, 'capability-gap', 'bare-agent', verb, `locked "${verb}" requested and the run cap-halted`);
231
+ // locked verb — the strongest admission evidence there is (design table).
232
+ // STILL DORMANT, exactly as the module header says: a request-red is a
233
+ // job-validation red, validateJob returns not-ok on any red and run.js
234
+ // returns on a validation red, so a request-red and a cap-halt cannot share
235
+ // one spine today. What follows is a latent-consistency fix at a documented
236
+ // dormant seam, not a live bug fix.
237
+ //
238
+ // The territory comes from the OCCURRENCE, never from a hardcoded package.
239
+ // capability-gap inherits request-red's two territories: a locked TOOL verb
240
+ // is demand against the worker-surface package, a locked VERDICT type is
241
+ // demand against bareloop's OWN catalogue. The request-red rows above
242
+ // already carry the lib stamped at their emit site (src/job.js), so reading
243
+ // it here is the typed-lib rule; re-hardcoding 'bare-agent' would seed an
244
+ // upstream ask for our own refusal — the BA-2 misattribution class. The
245
+ // sibling request-red site was fixed on this branch and this one was missed;
246
+ // a fix landing at one of two identical sites is the class already paid for
247
+ // once here (the ripgrep install that landed in ci.yml but not publish.yml).
248
+ //
249
+ // The dedup key is (verb, lib), not verb alone. The ledger's own occurrence
250
+ // key already carries lib, so folding on the verb would merge two
251
+ // territories into one row and attribute it to whichever lib the Set
252
+ // happened to hold first — an arbitrary target, which IS the defect. One
253
+ // gap per (territory, verb) costs nothing and each carries an honest one.
254
+ // The key is JSON, not a joined string: a delimiter that can appear in
255
+ // either half makes ("a-b","c") and ("a","b-c") the same key, and picking
256
+ // an "impossible" delimiter is a guess about names neither of these two
257
+ // vocabularies has promised to keep.
258
+ const halt = events.find((e) => e.type === 'cap-halt');
259
+ const seen = new Set();
260
+ // .filter() snapshots before add() appends, so the loop cannot feed itself
261
+ for (const o of occs.filter((x) => x.class === 'request-red')) {
262
+ const dedup = JSON.stringify([o.lib, o.verb]);
263
+ if (seen.has(dedup)) continue;
264
+ seen.add(dedup);
265
+ add(halt, 'capability-gap', o.lib, o.verb, `locked "${o.verb}" requested and the run cap-halted`);
191
266
  }
192
267
  }
193
268
  return occs;