bareloop 0.5.1 → 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/src/validate.js CHANGED
@@ -1,48 +1,16 @@
1
- // Workflow-config validator — a deterministic predicate that runs before any
2
- // tokens burn (PRD §4: schema-validated, config-red before tokens). Every
3
- // failure is a distinct named red { code, path, detail } so the spine can carry
4
- // `config-red: missing-required:gate.writeScope`. An invalid config is a red,
5
- // not a crash: this module never throws on bad input.
1
+ // The shared validation primitives — the fence transform, containment, the
2
+ // secret-shape inventory, and the small type guards. Both validators
3
+ // (`validateJob`, `validatePlan`) go through this ONE module, because a scope
4
+ // that validated under one transform and enforced under another is the F9
5
+ // red-class: green at the gate, gate-red on every write.
6
6
  //
7
- // The verb vocabulary is BOUND from litectx (adaptlearn F1 — consume, don't
8
- // build; design law #10); this schema exposes the adaptlearn-proven 4-verb
9
- // subset. The close and the provider are deliberately not expressible here:
10
- // they arrive as unknown-field reds — the shell owns both (design law #1).
7
+ // This file WAS the config-v1 workflow-config validator. config-v1 died in F22
8
+ // (of its 7 knobs only `loop.shape` was ever live, on a never-green run) and
9
+ // was DELETED with the operator-authored steps[] path on 2026-07-26 (PRD
10
+ // v1.32). What remains is what the live path imports — the primitives were
11
+ // housed here, they were never part of the dead schema.
11
12
 
12
- import { COMPRESS_LEVELS, KINDS, WRITE_KINDS } from 'litectx';
13
-
14
- export const LOOP_SHAPES = Object.freeze(['refine', 'plan']);
15
- export const SLOTS = Object.freeze(['before-attempt', 'after-red', 'on-green']);
16
- export const VERBS = Object.freeze(['recall', 'compress', 'stash', 'remember']);
17
- const TOP_FIELDS = ['schema', 'loop', 'memory', 'hooks', 'gate', 'escalation'];
18
- // Unknown-field guard, PER SECTION — not just at the top (F17). The arbiter
19
- // split is guarded "both directions by inexpressibility": the drafted config
20
- // must be UNABLE to say certain things. A guard that stops at the top level
21
- // does not deliver that — `gate: { judged: {...} }` (the close's own honesty
22
- // check) validated green, as would `gate.maxCostUsd`. Nothing consumed those
23
- // keys, so nothing broke; the guarantee is that nothing ever can. Hook ops are
24
- // guarded separately by VERB_PARAMS.
25
- const SECTION_FIELDS = Object.freeze({
26
- loop: ['shape', 'maxIterations'],
27
- memory: ['store', 'recall', 'compressLevel'],
28
- 'memory.recall': ['k', 'kinds'],
29
- gate: ['budgetUsd', 'writeScope'],
30
- escalation: ['mode'],
31
- });
32
- const MAX_OPS_PER_SLOT = 2;
33
-
34
- // per-verb parameter contracts: name → check(value) (op field itself excluded).
35
- // remember's kinds are NARROWER than recall's: bound from litectx WRITE_KINDS
36
- // (the set remember() itself validates against — adaptlearn F5's drift lesson:
37
- // the export you bind can be wider than the function you call), minus doc:
38
- // the doc/upload axis stays gated out deliberately.
39
- export const REMEMBER_KINDS = WRITE_KINDS.filter((k) => k !== 'doc'); // exported: the drafting prompt advertises THIS menu (one source, no drift)
40
- const VERB_PARAMS = {
41
- recall: { k: (v) => Number.isInteger(v) && v >= 1 && v <= 20, kinds: isKinds },
42
- compress: { level: (v) => COMPRESS_LEVELS.includes(v) },
43
- stash: {},
44
- remember: { kind: (v) => REMEMBER_KINDS.includes(v) },
45
- };
13
+ import { redact } from 'bareguard';
46
14
 
47
15
  /**
48
16
  * Map a schema writeScope entry to its enforcement prefix. bareguard
@@ -94,31 +62,6 @@ export function scopeContained(s) {
94
62
  return prefix.split('/').every((seg) => seg !== '..');
95
63
  }
96
64
 
97
- /**
98
- * The per-entry write-scope legality law, one home for all three call sites
99
- * (workflow gate.writeScope, the job fence, the operator's job-v1 writeScope):
100
- * a non-empty string, no mid-path wildcard (enforcement is prefix-containment,
101
- * F9), contained under the run dir (law #1). The chains that need per-check
102
- * reds still spell the three steps; fenceOk uses this so a fence can never be
103
- * blessed that the job validator would reject.
104
- * @param {unknown} s
105
- */
106
- function legalScopeEntry(s) {
107
- return isNonEmptyString(s) && !globToPrefix(s).includes('*') && scopeContained(s);
108
- }
109
-
110
- // Each verb is legal ONLY in the slot where it has effect: recall/compress
111
- // build the attempt context (consumed by before-attempt only), stash parks the
112
- // gap (which exists only after-red), remember is verdict-gated retention
113
- // (on-green only, design law #2). An op that validates green but is inert at
114
- // runtime would still emit hook-op events — a live-looking knob with zero
115
- // effect, polluting the contrast evidence the extractor attributes by
116
- // (design law #3; the F16 credit-loss class).
117
- const VERB_SLOT = { recall: 'before-attempt', compress: 'before-attempt', stash: 'after-red', remember: 'on-green' };
118
-
119
- function isKinds(v) {
120
- return Array.isArray(v) && v.length > 0 && v.every((k) => KINDS.includes(k));
121
- }
122
65
  /** Shared with the job validator — one definition of "a JSON object" for both
123
66
  * documents (the sibling-validator drift class).
124
67
  * @param {unknown} v */
@@ -152,8 +95,13 @@ const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
152
95
  * Scan a RAW text stream (a spine file, a close's output, a transcript) for
153
96
  * known secret shapes and return the literal matches. The ONE spelling of the
154
97
  * text-side scan, for the same reason SECRET_PATTERNS is the ONE inventory: a
155
- * hand-rolled copy that misses a shape is a leak on the very output it guards
156
- * (seven copies of this expression lived in scripts/ before it landed here).
98
+ * hand-rolled copy that misses a shape is a leak on the very output it guards.
99
+ * The dedup is now COMPLETE and the count is grepped, not remembered: all 11
100
+ * call sites in scripts/ go through this function, and zero hand-rolled copies
101
+ * of the expression remain anywhere in the repo. (F40 landed the helper and
102
+ * converted 2 of 7 sites; the 5 it left alone plus 4 that accreted afterwards
103
+ * were converted in one sweep, byte-identical behaviour — measured equivalent
104
+ * on 900 spiked real spines, 0 divergences.)
157
105
  * `sweepSecretLiterals` is the config-tree twin — same shapes, tree walk.
158
106
  * Never throws; returns [] for a missing stream.
159
107
  * @param {unknown} raw
@@ -166,6 +114,23 @@ export function scanSecrets(raw) {
166
114
  return SECRET_PATTERNS.flatMap((re) => text.match(new RegExp(re.source, `${re.flags.replace('g', '')}g`)) ?? []);
167
115
  }
168
116
 
117
+ /**
118
+ * Mask known secret shapes in a text. The REDACTION twin of `scanSecrets` and
119
+ * the ONE spelling of it: detection and redaction read the same inventory, so a
120
+ * shape the validator reds can never be a shape the redactor passes (a divergence
121
+ * there is a leak on the very output the validator was guarding). Housed beside
122
+ * the inventory rather than at each consumer for the same reason `scanSecrets`
123
+ * is — a hand-rolled copy is how the two drift.
124
+ * Consumers: the plan flow's spine/close/prompt `scrub`, and the isolate verbs,
125
+ * which write model-authored text into `<workdir>/.litectx` — a file INSIDE the
126
+ * tree that outlives the run and reads back through ctx_recall.
127
+ * @param {unknown} raw
128
+ * @returns {string}
129
+ */
130
+ export function redactSecrets(raw) {
131
+ return redact(String(raw ?? ''), { patterns: SECRET_PATTERNS });
132
+ }
133
+
169
134
  /**
170
135
  * Red every string in a config tree that carries a known secret-token shape.
171
136
  * @param {any} root
@@ -190,183 +155,3 @@ export function sweepSecretLiterals(root, red) {
190
155
  }
191
156
  })(root, '');
192
157
  }
193
-
194
- /**
195
- * Validate a workflow config against schema v1. Returns the parsed config on
196
- * ok (single parse — callers never re-parse; null on any red).
197
- * @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
198
- * @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
199
- * ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
200
- * the shell passes min(shell cap, job budgetUsd) — the ceiling chain
201
- * workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
202
- * (operator law, job-v1) — every workflow scope must fit inside it.
203
- * @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
204
- */
205
- export function validateConfig(input, { shellCapUsd = 2, jobWriteScope } = {}) {
206
- /** @type {Array<{code: string, path: string, detail?: string}>} */
207
- const reds = [];
208
- /** @type {(code: string, path: string, detail?: string) => void} */
209
- const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
210
-
211
- let c = input;
212
- if (typeof c === 'string') {
213
- try { c = JSON.parse(c); } catch (e) {
214
- return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], config: null };
215
- }
216
- }
217
- if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }], config: null };
218
-
219
- // 1. shape — unknown top-level fields red here (smuggled close/provider included)
220
- for (const key of Object.keys(c)) {
221
- if (!TOP_FIELDS.includes(key)) red('unknown-field', key);
222
- }
223
- if (c.schema === undefined) red('missing-required', 'schema');
224
- else if (c.schema !== 'v1') red('invalid-value', 'schema', `expected "v1", got ${JSON.stringify(c.schema)}`);
225
-
226
- // 2. required bindings + 3. bounds, section by section
227
- const loop = isObj(c.loop) ? c.loop : {};
228
- if (loop.shape === undefined) red('missing-required', 'loop.shape');
229
- else if (!LOOP_SHAPES.includes(loop.shape)) red('invalid-value', 'loop.shape', `menu: ${LOOP_SHAPES.join('|')}`);
230
- if (loop.maxIterations !== undefined
231
- && !(Number.isInteger(loop.maxIterations) && loop.maxIterations >= 1 && loop.maxIterations <= 8)) {
232
- red('bounds', 'loop.maxIterations', '1..8');
233
- }
234
-
235
- const memory = isObj(c.memory) ? c.memory : {};
236
- if (memory.store === undefined) red('missing-required', 'memory.store');
237
- else if (memory.store !== 'litectx') red('invalid-value', 'memory.store', 'v1 binds "litectx"');
238
- if (isObj(memory.recall)) {
239
- const { k, kinds } = memory.recall;
240
- if (k !== undefined && !(Number.isInteger(k) && k >= 1 && k <= 20)) red('bounds', 'memory.recall.k', '1..20');
241
- if (kinds !== undefined && !isKinds(kinds)) red('invalid-value', 'memory.recall.kinds', `subset of ${KINDS.join('|')}`);
242
- }
243
- if (memory.compressLevel !== undefined && !COMPRESS_LEVELS.includes(memory.compressLevel)) {
244
- red('invalid-value', 'memory.compressLevel', COMPRESS_LEVELS.join('|'));
245
- }
246
-
247
- const gate = isObj(c.gate) ? c.gate : {};
248
- if (gate.budgetUsd === undefined) red('missing-required', 'gate.budgetUsd');
249
- else if (!(typeof gate.budgetUsd === 'number' && gate.budgetUsd > 0 && gate.budgetUsd <= shellCapUsd)) {
250
- red('bounds', 'gate.budgetUsd', `0 < budget <= shell cap ${shellCapUsd}`);
251
- }
252
- if (gate.writeScope === undefined) red('missing-required', 'gate.writeScope');
253
- else if (!(Array.isArray(gate.writeScope) && gate.writeScope.length > 0
254
- && gate.writeScope.every(isNonEmptyString))) {
255
- red('invalid-value', 'gate.writeScope', 'non-empty array of glob strings');
256
- } else if (!gate.writeScope.every((s) => !globToPrefix(s).includes('*'))) {
257
- // adaptlearn F9: enforcement (bareguard fs.writeScope) is prefix-containment — a wildcard
258
- // anywhere but a trailing /** or /* is inexpressible there, so it would validate green
259
- // and then gate-red EVERY write at runtime. Reds-before-tokens means rejecting it here.
260
- red('invalid-value', 'gate.writeScope', 'wildcards only as a trailing "/**" or "/*" (enforcement is prefix-containment, adaptlearn F9)');
261
- } else if (!gate.writeScope.every(scopeContained)) {
262
- // A scope that can reach the arbiter's inputs is the config-level
263
- // fit-to-pass surface (design law #1). Reds-before-tokens.
264
- red('invalid-value', 'gate.writeScope', 'must be a proper subdirectory of the run directory — no absolute paths, no ".." segments, not the run dir itself (design law #1)');
265
- } else if (jobWriteScope !== undefined && jobWriteScope !== null) {
266
- // Two-layer fence: the job spec's writeScope is operator law; the authored
267
- // config may tighten it, never exceed it (the budget pattern, on paths).
268
- // null/undefined are the legitimate "no fence" spellings (job optional at
269
- // N2, and the only forms that survive JSON transit) — the layer is skipped,
270
- // never a deadlock. A PRESENT-but-malformed fence fails CLOSED with its own
271
- // red, attributed to jobWriteScope (NOT the innocent workflow field — the
272
- // ledger's contrast attribution must not charge the agent's config for the
273
- // operator's broken spec).
274
- const fenceOk = Array.isArray(jobWriteScope) && jobWriteScope.length > 0 && jobWriteScope.every(legalScopeEntry);
275
- if (!fenceOk) {
276
- const shown = JSON.stringify(jobWriteScope).slice(0, 200); // bound: a malformed fence is untrusted caller input, and this rides the append-only spine (ralph's 2000-char precedent)
277
- red('fence-invalid', 'jobWriteScope', `not a validateJob-green fence (${shown}) — validate the job spec before threading its fence`);
278
- } else {
279
- // Boundary-aware: "src2" is not inside "src" — prefix means PATH prefix.
280
- const fence = jobWriteScope.map(globToPrefix);
281
- const inside = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
282
- gate.writeScope.forEach((/** @type {string} */ s, /** @type {number} */ i) => {
283
- if (!inside(globToPrefix(s))) {
284
- red('scope-escape', `gate.writeScope.${i}`, `"${s}" is outside the job's fence [${jobWriteScope.join(', ')}] — the workflow may tighten the operator's bound, never exceed it`);
285
- }
286
- });
287
- }
288
- }
289
-
290
- const escalation = isObj(c.escalation) ? c.escalation : {};
291
- if (escalation.mode === undefined) red('missing-required', 'escalation.mode');
292
- else if (escalation.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
293
-
294
- // 4. verb legality inside the slots
295
- if (c.hooks !== undefined) {
296
- const hooks = isObj(c.hooks) ? c.hooks : {};
297
- if (!isObj(c.hooks)) red('invalid-value', 'hooks', 'must be an object of slots');
298
- for (const [slot, ops] of Object.entries(hooks)) {
299
- const at = `hooks.${slot}`;
300
- if (!SLOTS.includes(slot)) { red('unknown-field', at); continue; }
301
- if (!Array.isArray(ops)) { red('invalid-value', at, 'must be an array of ops'); continue; }
302
- if (ops.length > MAX_OPS_PER_SLOT) { red('slot-overflow', at, `max ${MAX_OPS_PER_SLOT} ops`); continue; }
303
- ops.forEach((op, i) => {
304
- const opAt = `${at}.${i}`;
305
- if (!isObj(op) || !VERBS.includes(op.op)) { red('verb-illegal', opAt, `verbs: ${VERBS.join('|')}`); return; }
306
- if (VERB_SLOT[op.op] !== slot) {
307
- red('verb-placement', opAt, `${op.op} is legal only in ${VERB_SLOT[op.op]} — the one slot where it has effect (an inert op is a fake knob in the contrast evidence)`);
308
- return;
309
- }
310
- const params = VERB_PARAMS[op.op];
311
- for (const [key, value] of Object.entries(op)) {
312
- if (key === 'op') continue;
313
- // Object.hasOwn, not `in`: params named after Object.prototype members
314
- // ("constructor", "toString") must not smuggle past the unknown-param red.
315
- if (!Object.hasOwn(params, key)) red('verb-params', `${opAt}.${key}`, `unknown param for ${op.op}`);
316
- else if (!params[key](value)) red('verb-params', `${opAt}.${key}`, `invalid value for ${op.op}.${key}`);
317
- }
318
- });
319
- }
320
- }
321
-
322
- // 4b. inexpressibility, at every depth the arbiter can be reached from
323
- for (const [section, allowed] of Object.entries(SECTION_FIELDS)) {
324
- const obj = section === 'memory.recall' ? memory.recall : c[section];
325
- if (!isObj(obj)) continue;
326
- for (const key of Object.keys(obj)) {
327
- if (!allowed.includes(key)) red('unknown-field', `${section}.${key}`);
328
- }
329
- }
330
-
331
- // 5. secrets sweep — same guard as the job spec (the agent-authored document
332
- // is the riskier one; a green config is a ledger fact and inherits forever)
333
- sweepSecretLiterals(c, red);
334
-
335
- return { ok: reds.length === 0, reds, config: reds.length === 0 ? c : null };
336
- }
337
-
338
- /**
339
- * Changed JSON paths between two configs — the one-knob mutation checker
340
- * (a legal mutant has exactly one). A subtree present on only one side counts
341
- * as ONE path (its root), so "add an op to a slot" is one knob, not two params.
342
- * @param {object} a
343
- * @param {object} b
344
- * @returns {string[]} sorted changed paths, dot-notation with array indices
345
- */
346
- export function diffPaths(a, b) {
347
- /** @type {string[]} */
348
- const paths = [];
349
- walk(a, b, '');
350
- return paths.sort();
351
-
352
- /**
353
- * @param {any} x
354
- * @param {any} y
355
- * @param {string} at
356
- */
357
- function walk(x, y, at) {
358
- if (x === y) return;
359
- const bothObj = isObj(x) && isObj(y);
360
- const bothArr = Array.isArray(x) && Array.isArray(y);
361
- if (!bothObj && !bothArr) {
362
- if (JSON.stringify(x) !== JSON.stringify(y)) paths.push(at || '$');
363
- return;
364
- }
365
- const keys = new Set([...Object.keys(x), ...Object.keys(y)]);
366
- for (const key of keys) {
367
- const p = at ? `${at}.${key}` : key;
368
- if (!(key in x) || !(key in y)) paths.push(p); // added/removed subtree = one knob
369
- else walk(x[key], y[key], p);
370
- }
371
- }
372
- }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * F64 — is this throw the run's OWN deadline coming back dressed as a provider
3
+ * error? `callTimeoutMs()` hands the provider a bound derived from the wall, so
4
+ * when the wall is what shrank that bound, the resulting `TimeoutError` is a
5
+ * GOVERNANCE stop, not a casualty. Filing it as `provider-red` would discard it
6
+ * (a casualty is never evidence, F45/F48) and attach the F44
7
+ * `spendComplete:false` floor to a run that simply ran out of time.
8
+ *
9
+ * The discriminator is the wall itself, not the derived number: on a bounded
10
+ * clock a trip can only precede expiry when the bound came from the provider's
11
+ * own default (`remainingMs() > PROVIDER_TIMEOUT_MS`), and that case is transport
12
+ * by construction. So "already expired" IS the wall-derived set. An unbounded run
13
+ * can never produce one, and needs no separate `bounded` term to say so —
14
+ * `expired()` is false by construction with no cap. (A mutation run proved the
15
+ * extra term inert; the unbounded case is guarded by its own test instead, which
16
+ * is where that invariant belongs.)
17
+ *
18
+ * A genuine dead socket on a run whose wall has ALSO passed reads as a wall-halt.
19
+ * That is deliberate and honest: the run is out of time either way, the remedy is
20
+ * the same (raise `maxWallMs`), and the alternative — calling a governance stop
21
+ * transport on the chance that it was — is the failure this closes.
22
+ *
23
+ * @param {any} err the throw as caught
24
+ * @param {Clock} clock the run's clock
25
+ * @returns {boolean}
26
+ */
27
+ export function isWallTimeout(err: any, clock: Clock): boolean;
28
+ /**
29
+ * @typedef {object} Clock
30
+ * @property {boolean} bounded whether the operator set a cap at all
31
+ * @property {number|null} requestedMs the operator's number; `null` when unbounded
32
+ * @property {number} closeStages how many close stages one verdict can pay for (W5)
33
+ * @property {() => number} elapsedMs wall time since the clock was created
34
+ * @property {() => number} remainingMs what is left, floored at 0; `Infinity` when unbounded
35
+ * @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
36
+ * @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
37
+ * @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
38
+ * @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
39
+ */
40
+ /**
41
+ * Start a wall clock for one run.
42
+ * @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number }} [opts]
43
+ * `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
44
+ * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
45
+ * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
46
+ * decoration. Defaults to 1, the single-predicate close. `now`: injected for
47
+ * tests — the default is the real clock.
48
+ * @returns {Clock}
49
+ */
50
+ export function createClock({ maxWallMs, closeStages, now }?: {
51
+ maxWallMs?: number | null;
52
+ closeStages?: number;
53
+ now?: () => number;
54
+ }): Clock;
55
+ /** BA-18's default request/idle timeout (`AnthropicProvider.timeoutMs`), the
56
+ * ceiling every derived per-call timeout starts from. Kept in sync deliberately:
57
+ * a value ABOVE the provider's own default would be inert. */
58
+ export const PROVIDER_TIMEOUT_MS: 600000;
59
+ /**
60
+ * The floor for a derived per-call timeout. Below this a call cannot plausibly
61
+ * succeed, and a timeout that is certain to trip manufactures a `provider-red`
62
+ * out of a time budget — a transport CASUALTY, which is never evidence
63
+ * (F45/F48). The deadline check is what must stop a run out of time; the per-call
64
+ * timeout exists to bound a HANG, not to enforce the budget's last second.
65
+ */
66
+ export const MIN_CALL_TIMEOUT_MS: 30000;
67
+ /** bare-agent's rejection code when its request/idle timeout trips (BA-18,
68
+ * `provider-http.js` → `TimeoutError`). The error carries a `code` and NO
69
+ * `category`, so every consumer that defaults an uncategorised throw would file
70
+ * it as transport. */
71
+ export const TIMEOUT_CODE: "ETIMEDOUT";
72
+ export type Clock = {
73
+ /**
74
+ * whether the operator set a cap at all
75
+ */
76
+ bounded: boolean;
77
+ /**
78
+ * the operator's number; `null` when unbounded
79
+ */
80
+ requestedMs: number | null;
81
+ /**
82
+ * how many close stages one verdict can pay for (W5)
83
+ */
84
+ closeStages: number;
85
+ /**
86
+ * wall time since the clock was created
87
+ */
88
+ elapsedMs: () => number;
89
+ /**
90
+ * what is left, floored at 0; `Infinity` when unbounded
91
+ */
92
+ remainingMs: () => number;
93
+ /**
94
+ * whether the deadline has passed (always false when unbounded)
95
+ */
96
+ expired: () => boolean;
97
+ /**
98
+ * the HONEST worst case
99
+ */
100
+ enforcedMs: (closeTimeoutMs: number) => number | null;
101
+ /**
102
+ * the derived per-call bound
103
+ */
104
+ callTimeoutMs: (providerDefaultMs?: number) => number;
105
+ /**
106
+ * one spine-safe shape carrying both numbers
107
+ */
108
+ report: (closeTimeoutMs: number) => object;
109
+ };
package/types/index.d.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  export { makeSpine } from "./spine.js";
2
2
  export { runPlan } from "./planrun.js";
3
3
  export { runJob } from "./run.js";
4
- export { ralph, runClose, CLOSE_FAULTS } from "./ralph.js";
5
- export { validateConfig, diffPaths, globToPrefix, scanSecrets, LOOP_SHAPES, SLOTS, VERBS } from "./validate.js";
6
- export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, STEP_MODES, TOOL_MENU, LOCKED_TOOLS, VERDICT_TYPES, LOCKED_VERDICTS } from "./job.js";
7
- export { validatePlan, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from "./plan.js";
4
+ export { ralph, runClose, runStages, CLOSE_FAULTS } from "./ralph.js";
5
+ export { globToPrefix, scanSecrets } from "./validate.js";
6
+ export { validateJob, jobSpecHash, checkApproval, checkMenu, CLOSE_TYPES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS, TOOL_MENU, LOCKED_TOOLS, STORE_VERBS, VERDICT_TYPES, LOCKED_VERDICTS } from "./job.js";
7
+ export { validatePlan, stageClose, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, WRITE_VERBS } from "./plan.js";
8
8
  export { snapshotScope, evalExits } from "./exits.js";
9
- export { interpret, STALL_REDS } from "./interpret.js";
10
- export { extractRules, MAX_RULES, MAX_RULE_CHARS } from "./extract.js";
11
9
  export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from "./ledger.js";
package/types/job.d.ts CHANGED
@@ -17,9 +17,52 @@ export function validateJob(input: object | string, { shellCapUsd }?: {
17
17
  job: object | null;
18
18
  };
19
19
  /**
20
- * Content hash of a job spec — what an approval record binds to. Any edit to
21
- * the spec changes the hash, so an edited spec is unapproved by construction
22
- * (interview decision #1: human signs, always, once per spec VERSION).
20
+ * The staged close (PRD v1.28) — the close is an ORDERED LIST of named stages,
21
+ * and the check menu DERIVES from it. The principle: *the user authors the
22
+ * destination, never the road.* A user who cannot describe a workflow can still
23
+ * say what done looks like, and everything downstream of that is derived — which
24
+ * is why `checks[]` no longer exists as a field (hamr: *"there shouldn't be user
25
+ * authoring anywhere, that defies the point of bareloop"*).
26
+ *
27
+ * Two stage classes cannot be handed to the agent as a mid-build ruler, and F58
28
+ * measured both against a real close:
29
+ * - a PRECONDITION ("the seed commit exists") is not a ruler for the work; run
30
+ * as a check it passes instantly and teaches nothing. It sets `offer: false`.
31
+ * - a stage that reads what an EARLIER stage built ("the public API matches the
32
+ * emitted declarations") reds on its own for a reason that has nothing to do
33
+ * with the worker's edit. It names `needs`, and picking it runs that chain first.
34
+ * Everything else is offerable: derivation gave job #5 SEVEN rulers where the
35
+ * operator had hand-carved three (F58) — removing the operator does not cost the
36
+ * agent rulers, it hands it more.
37
+ *
38
+ * The `needs` expansion is TRANSITIVE (fixed point, not one level): a
39
+ * prerequisite may itself name one, and running an incomplete chain reproduces
40
+ * the exact false-red `needs` exists to remove, one level deeper. The expanded
41
+ * chain is ordered by the CLOSE, never by the order names were typed —
42
+ * validateStagedClose forces `needs` to name EARLIER stages, so close order IS
43
+ * a topological order. A `needs` cycle is inexpressible in a validated close for
44
+ * that same reason; the walk is still cycle-safe by construction (each stage is
45
+ * collected at most once), because a pure exported helper must terminate on
46
+ * whatever it is handed rather than hang the runner before any token burns.
47
+ *
48
+ * @param {any[]} close the validated stage list
49
+ * @returns {{name: string, run: any[]}[]} offerable stages, each with the ordered
50
+ * chain that must run for it (prerequisites first, the stage itself last)
51
+ */
52
+ export function checkMenu(close: any[]): {
53
+ name: string;
54
+ run: any[];
55
+ }[];
56
+ /**
57
+ * Content hash of a job spec — what an approval record binds to. Any SEMANTIC
58
+ * edit changes the hash, so a spec whose meaning moved is unapproved by
59
+ * construction (interview decision #1: human signs, always, once per spec
60
+ * VERSION). Not every BYTE edit: the hash is taken over the RESOLVED spec
61
+ * (resolveSpec), so writing today's full TOOL_MENU into a spec that omitted
62
+ * `tools` is hash-neutral — the two spell the same ceiling, and that identity
63
+ * is the point of MED-1, since it is what pins WHICH menu the signature covers.
64
+ * The direction that matters still bites: the menu itself changing (or the
65
+ * explicit list changing) moves the resolved form, and the hash with it.
23
66
  * @param {object} job
24
67
  * @returns {string} sha256 hex
25
68
  */
@@ -35,8 +78,11 @@ export function jobSpecHash(job: object): string;
35
78
  */
36
79
  export function checkApproval(job: object, approvals: unknown): boolean;
37
80
  export const CLOSE_TYPES: readonly string[];
38
- export const CLASSES: readonly string[];
39
- /** the hierarchy: which verdict classes a close type may claim (PRD §7) */
81
+ /** the hierarchy: which verdict classes a close type may claim (PRD §7). Read by
82
+ * the verdictType check — a declared verdict may not demand a class its close
83
+ * type cannot legally claim (green-on-rubric is laundering). The `CLASSES`
84
+ * vocabulary export went with steps[] (PRD v1.32): a class was a STEP field, and
85
+ * nothing declares one now. */
40
86
  export const CLASS_BY_CLOSE: Readonly<{
41
87
  predicate: readonly string[];
42
88
  gold: readonly string[];
@@ -44,8 +90,6 @@ export const CLASS_BY_CLOSE: Readonly<{
44
90
  hitl: readonly string[];
45
91
  }>;
46
92
  export const GOLD_COMPARE: readonly string[];
47
- /** step middle modes (2b): text = single-target artifact; tools = Gate-governed file tools */
48
- export const STEP_MODES: readonly string[];
49
93
  /** the tool grant menu (2b interview #1): read/grep/write, plus the two litectx
50
94
  * RETRIEVAL verbs (F19) — `run` stays locked-but-listed; a spec requesting it
51
95
  * reds, and that red IS the request-red evidence admission waits on (curation
@@ -65,8 +109,39 @@ export const STEP_MODES: readonly string[];
65
109
  * WRITE-class verb judged by the SAME writeScope fence as `write` (bareguard
66
110
  * action type 'edit'). Admitted because whole-file rewrite is measurably
67
111
  * unreliable at size (F31: 4 of 5 big-file whole-writes broke the tree) and
68
- * taxes output tokens ∝ file size. It admits no execution either. */
112
+ * taxes output tokens ∝ file size. It admits no execution either.
113
+ *
114
+ * P (design record 2026-07-28, hamr: full catalog) widened the menu to the four
115
+ * components — write · select · compress · isolate — every verb an EXISTING
116
+ * litectx implementation (menu-is-inventory). impact/related/recent/compress/
117
+ * peek are read-only like the retrieval pair; stash/remember/forget are
118
+ * STORE-writes (`.litectx`, never the tree — own gate action types, and the U
119
+ * runner deletes the store at reset so cold stays cold). None admits execution:
120
+ * the `run` lock is untouched. */
69
121
  export const TOOL_MENU: readonly string[];
122
+ /** the write-class split the validator and the scout filter read: tree writes
123
+ * (writeScope fence) vs store writes (isolate) — NEITHER is read-capable. */
124
+ export const WRITE_VERBS: readonly string[];
125
+ export const STORE_VERBS: readonly string[];
126
+ /**
127
+ * The floor for `maxWallMs`: one default close timeout (`runClose`'s 120_000).
128
+ * Addendum 1 MEASURED that a wall deadline can only be read BETWEEN rounds —
129
+ * `loop.stop()` does not cut an in-flight call (fired at 500ms, returned at
130
+ * 4,018ms) — so enforcement is `maxWallMs + closeStages × closeTimeoutMs` (W5: a
131
+ * staged close runs every stage under the FULL timeout, ralph.js `runStages`).
132
+ * Under this floor the overshoot exceeds the budget itself and the advertised
133
+ * number would be more wrong than right, which is the advertised-must-equal-
134
+ * enforced line.
135
+ *
136
+ * The VALUE is a one-stage, default-timeout floor and is deliberately left alone —
137
+ * a threshold is operator territory, not something this module re-derives from a
138
+ * spec's own stage count. It therefore bounds only the degenerate case; a job with
139
+ * many stages or a raised `closeTimeoutMs` can still sit far above it while its
140
+ * overshoot dwarfs its cap. The clock reports that honestly (`closeStages` and
141
+ * `enforcedMs` are both on the `wall-clock` record) rather than the floor pretending
142
+ * to prevent it.
143
+ */
144
+ export const MIN_WALL_MS: 120000;
70
145
  /** locked-but-listed tools: real capabilities deliberately outside the grant
71
146
  * menu. Requesting one is a DISTINCT `request-red` (module 4) — the ledger
72
147
  * counts admission demand, and a generic invalid-value would bury it as a typo. */
package/types/ledger.d.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  * bareloop vocabulary). Deliberate exclusions — bare cap-halt (a budget story),
4
4
  * close-verdict reds and artifact-red (worker stories, the §5b line), gate-red
5
5
  * (governance working as intended), pr-red (operator environment, not a suite
6
- * lib), smoke-red escalations (already counted via primitive-smoke), hitl-close
6
+ * lib), smoke-red escalations (already counted via primitive-smoke), step-stalled
7
+ * (our own fuse firing on an ABSENCE of beats — no observed lib failure to file),
8
+ * hitl-close
7
9
  * and close-unsupported (by design) — classify to nothing.
8
10
  * @param {any[]} events one spine's parsed events, in seq order
9
11
  * @param {{spine?: string}} [opts] spine id stamped into samples