bareloop 0.5.1 → 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/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,198 @@
1
+ /**
2
+ * D6's status ladder, walked in history order. Pure, total, and never throws —
3
+ * it is called on whatever a file happens to contain.
4
+ *
5
+ * - **candidate** — at least one green.
6
+ * - **proven** — greens on ≥2 DISTINCT patients (R2: the same SHAPE on a second
7
+ * instance; two greens on ONE patient is a lookup table, which the memorization
8
+ * audit kills).
9
+ * - a **red** on a PROVEN entry drops it to candidate, and re-promotion then needs
10
+ * greens on two distinct patients AGAIN — that is what "until a new green on a
11
+ * second distinct patient re-proves" means, so the demoting red clears the
12
+ * distinct-patient set rather than leaving the old count standing to re-promote
13
+ * the entry on the first repeat green.
14
+ * - a red on a CANDIDATE changes nothing: there is nothing below the entry bar, and
15
+ * the entry stays in the record with the red beside it.
16
+ * - a **CASUALTY is not a red.** Only the literal outcome `'red'` demotes; every
17
+ * other non-green outcome (`provider-red`, `wall-halt`, `close-crashed`, …) is a
18
+ * casualty, and casualties are never evidence in either direction.
19
+ *
20
+ * @param {unknown} history the bridge's per-run rows, oldest first
21
+ * @returns {'candidate'|'proven'|null} null = below the entry bar (no green yet)
22
+ */
23
+ export function deriveStatus(history: unknown): "candidate" | "proven" | null;
24
+ /**
25
+ * Validate a bridge entry (`schema: "bridge-v1"`). Never throws on JSON text or
26
+ * plain parsed data — the same ingest contract both config validators hold; every
27
+ * failure is a named red. Returns the parsed entry on ok, null on any red.
28
+ * @param {object|string|unknown} input parsed entry, or raw JSON text
29
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
30
+ */
31
+ export function validateBridge(input: object | string | unknown): {
32
+ ok: boolean;
33
+ reds: Red[];
34
+ bridge: any;
35
+ };
36
+ /**
37
+ * FIRST GREEN MINTS: build a new registry entry from one green. The entry bar is
38
+ * a green (D6) — there is deliberately no way to create an entry without one, so
39
+ * a failed plan cannot enter the registry even by accident.
40
+ * @param {unknown} meta `{ name, goal, specHash, closeStageNames, toolsUsed }` —
41
+ * `specHash` may be an explicit null (one probe-era bridge on disk has none), and
42
+ * the KEY is required so an unknown hash has to be said rather than omitted.
43
+ * @param {unknown} record `{ runid, patient, at, plan, costUsd, spendComplete, wallMs, rounds, specHash? }`
44
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
45
+ */
46
+ export function mintBridge(meta: unknown, record: unknown): {
47
+ ok: boolean;
48
+ reds: Red[];
49
+ bridge: any;
50
+ };
51
+ /**
52
+ * A green on an EXISTING bridge: the plan AS EXECUTED becomes its next version,
53
+ * and the run gets a history row. Pure — returns a NEW entry; the input is never
54
+ * mutated, so a caller that reds still holds the bridge it read from disk.
55
+ * @param {unknown} bridge a validated bridge entry
56
+ * @param {unknown} record `{ runid, patient, at, plan, costUsd, spendComplete, wallMs, rounds, specHash? }`
57
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
58
+ */
59
+ export function appendGreen(bridge: unknown, record: unknown): {
60
+ ok: boolean;
61
+ reds: Red[];
62
+ bridge: any;
63
+ };
64
+ /**
65
+ * A red (or a casualty): a history row ONLY. R1 — the bridge file is never edited
66
+ * in place by a red; the recipe that greened stays exactly as it greened, and the
67
+ * status derivation reads the demotion off the history.
68
+ * @param {unknown} bridge a validated bridge entry
69
+ * @param {unknown} record `{ runid, patient, at, outcome, failingStage, costUsd, spendComplete, wallMs, rounds }`
70
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
71
+ */
72
+ export function appendRed(bridge: unknown, record: unknown): {
73
+ ok: boolean;
74
+ reds: Red[];
75
+ bridge: any;
76
+ };
77
+ /**
78
+ * One row of D3's listing: what the user (and the selecting LLM) sees per bridge —
79
+ * name, the job sentence it greened, status, greens/reds, and the cost/time BAND of
80
+ * its greens. Deliberately no score (D6).
81
+ *
82
+ * The cost band SKIPS unknowns and says how many it skipped: an aggregate that
83
+ * treats a null as 0 reports a floor that reads as exact, which is F6 in an honest
84
+ * coat. With no priced green at all, every bound is null — never 0.
85
+ *
86
+ * Total and never throws: the listing is a display surface, and one malformed
87
+ * entry must not take the whole listing down (loadRegistry already reports it).
88
+ * @param {unknown} bridge
89
+ * @returns {{name: string|null, goal: string|null, status: 'candidate'|'proven'|null, greens: number, reds: number, lastOutcome: string|null, greenCost: {minUsd: number|null, maxUsd: number|null, minWallMs: number|null, maxWallMs: number|null, unpricedCount: number, untimedCount: number}}}
90
+ */
91
+ export function listingRow(bridge: unknown): {
92
+ name: string | null;
93
+ goal: string | null;
94
+ status: "candidate" | "proven" | null;
95
+ greens: number;
96
+ reds: number;
97
+ lastOutcome: string | null;
98
+ greenCost: {
99
+ minUsd: number | null;
100
+ maxUsd: number | null;
101
+ minWallMs: number | null;
102
+ maxWallMs: number | null;
103
+ unpricedCount: number;
104
+ untimedCount: number;
105
+ };
106
+ };
107
+ /**
108
+ * D2's LOAD-TIME gate, as SPLIT by the 2026-08-01 addendum. Exactly three checks —
109
+ * *"is this the right KIND of recipe?"*, asked at the box:
110
+ *
111
+ * 1. **verdict type matches.** v1 admits `green` only, and a bridge only ever
112
+ * exists because a green minted it, so the bridge side is `green` by
113
+ * construction and the check reads the JOB's declared type. **The day a second
114
+ * verdict class is admitted, the bridge entry must carry its own verdictType
115
+ * and this becomes a two-sided comparison** — recorded here so it cannot be
116
+ * quietly assumed to already be one.
117
+ * 2. **close-stage KINDS match.** v1 simplification: the same stage NAMES in the
118
+ * same ORDER is the definition of the same kinds. The probes' R2-legal pair
119
+ * matched on four identical stage names over different patients, which is the
120
+ * material this rule was ruled on; a real kind-comparison (what each stage
121
+ * INSPECTS, independent of what it was named) is a later rung's problem and is
122
+ * not pretended here.
123
+ * 3. **every stored verb fits THIS job's signed menu.** An omitted `tools` means
124
+ * the concrete current TOOL_MENU — the same predicate `resolveSpec` and
125
+ * `validatePlan` read (MED-1), so what the gate judges is what was signed.
126
+ *
127
+ * **NOTHING about paths, scopes or targets.** Those are instance-bound and are
128
+ * EXPECTED to red: every recipe from a different day names yesterday's bricks, and
129
+ * that is what a recipe IS, not a flaw. The pre-probe measured the drafter
130
+ * replacing them unaided (3/3 legal after tweak), while the gate as originally
131
+ * frozen would have refused all six of them at the door. The full `validatePlan`
132
+ * still judges the TWEAKED draft at draft time — nothing executes unvalidated, and
133
+ * no second, looser path exists for an inherited plan (D4).
134
+ *
135
+ * A failing gate is the **recipe-stale / wrong-kind** OUTCOME — a distinct result
136
+ * with cold drafting offered, never a throw.
137
+ * @param {unknown} bridge
138
+ * @param {unknown} job the validateJob-green spec this run is signed against
139
+ * @returns {{ ok: boolean, reds: Red[] }}
140
+ */
141
+ export function loadGate(bridge: unknown, job: unknown): {
142
+ ok: boolean;
143
+ reds: Red[];
144
+ };
145
+ /**
146
+ * Read one bridge file. Never throws — a missing/unreadable file and a malformed
147
+ * one are both named reds, because the registry is read at selection time and one
148
+ * bad file must not take the run down.
149
+ * @param {string} file
150
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
151
+ */
152
+ export function loadBridge(file: string): {
153
+ ok: boolean;
154
+ reds: Red[];
155
+ bridge: any;
156
+ };
157
+ /**
158
+ * Read a whole registry directory. A malformed entry is SKIPPED AND REPORTED —
159
+ * one red per bad file, the good entries still returned, sorted by name for a
160
+ * stable listing. `ok` is false when anything was skipped, so a caller can surface
161
+ * the skip instead of silently listing fewer workflows than exist on disk.
162
+ * @param {string} dir the operator-supplied registry path (no default location)
163
+ * @returns {{ ok: boolean, reds: Red[], bridges: any[] }}
164
+ */
165
+ export function loadRegistry(dir: string): {
166
+ ok: boolean;
167
+ reds: Red[];
168
+ bridges: any[];
169
+ };
170
+ /**
171
+ * Write one bridge entry, ATOMICALLY (temp + rename): a registry read concurrent
172
+ * with a write must see the old entry or the new one, never a half-written file
173
+ * that then reads as a malformed skip. An INVALID entry is refused before anything
174
+ * touches the disk — the registry never holds a file its own validator rejects.
175
+ * @param {string} dir the operator-supplied registry directory (must exist)
176
+ * @param {unknown} bridge
177
+ * @returns {{ ok: boolean, reds: Red[], file: string|null }}
178
+ */
179
+ export function saveBridge(dir: string, bridge: unknown): {
180
+ ok: boolean;
181
+ reds: Red[];
182
+ file: string | null;
183
+ };
184
+ /**
185
+ * Create a registry directory. Separate from `saveBridge` ON PURPOSE: saving must
186
+ * never conjure a registry from a typo'd path, so making one is an explicit act.
187
+ * @param {string} dir
188
+ * @returns {string} the directory
189
+ */
190
+ export function makeRegistry(dir: string): string;
191
+ /** @param {string} dir @returns {boolean} does a registry directory exist */
192
+ export function registryExists(dir: string): boolean;
193
+ export const BRIDGE_SCHEMA: "bridge-v1";
194
+ export type Red = {
195
+ code: string;
196
+ path: string;
197
+ detail?: string;
198
+ };
@@ -0,0 +1,131 @@
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
+ * BOTH derived bounds land here, because both are derived from the same wall:
24
+ * BA-18's idle timeout (`callTimeoutMs`) and BA-19's total-duration deadline
25
+ * (`callDeadlineMs`). Adding the second CODE does not widen the rule — the
26
+ * discriminator is still `clock.expired()`, never the code. That matters for the
27
+ * deadline specifically: a provider someone else constructed with its own
28
+ * `deadlineMs` could throw this code on a run with time left, and that is transport,
29
+ * exactly as it was before.
30
+ *
31
+ * @param {any} err the throw as caught
32
+ * @param {Clock} clock the run's clock
33
+ * @returns {boolean}
34
+ */
35
+ export function isWallTimeout(err: any, clock: Clock): boolean;
36
+ /**
37
+ * @typedef {object} Clock
38
+ * @property {boolean} bounded whether the operator set a cap at all
39
+ * @property {number|null} requestedMs the operator's number; `null` when unbounded
40
+ * @property {number} closeStages how many close stages one verdict can pay for (W5)
41
+ * @property {() => number} elapsedMs wall time since the clock was created
42
+ * @property {() => number} remainingMs what is left, floored at 0; `Infinity` when unbounded
43
+ * @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
44
+ * @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
45
+ * @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
46
+ * @property {() => number|null} callDeadlineMs the derived per-call TOTAL-duration ceiling; `null` when unbounded
47
+ * @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
48
+ */
49
+ /**
50
+ * Start a wall clock for one run.
51
+ * @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number, priorElapsedMs?: number }} [opts]
52
+ * `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
53
+ * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
54
+ * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
55
+ * decoration. Defaults to 1, the single-predicate close. `now`: injected for
56
+ * tests — the default is the real clock. `priorElapsedMs`: time this attempt's
57
+ * PREDECESSOR already consumed (see below).
58
+ * @returns {Clock}
59
+ */
60
+ export function createClock({ maxWallMs, closeStages, now, priorElapsedMs }?: {
61
+ maxWallMs?: number | null;
62
+ closeStages?: number;
63
+ now?: () => number;
64
+ priorElapsedMs?: number;
65
+ }): Clock;
66
+ /** BA-18's default request/idle timeout (`AnthropicProvider.timeoutMs`), the
67
+ * ceiling every derived per-call timeout starts from. Kept in sync deliberately:
68
+ * a value ABOVE the provider's own default would be inert. */
69
+ export const PROVIDER_TIMEOUT_MS: 600000;
70
+ /**
71
+ * The floor for a derived per-call timeout. Below this a call cannot plausibly
72
+ * succeed, and a timeout that is certain to trip manufactures a `provider-red`
73
+ * out of a time budget — a transport CASUALTY, which is never evidence
74
+ * (F45/F48). The deadline check is what must stop a run out of time; the per-call
75
+ * timeout exists to bound a HANG, not to enforce the budget's last second.
76
+ */
77
+ export const MIN_CALL_TIMEOUT_MS: 30000;
78
+ /** bare-agent's rejection code when its request/idle timeout trips (BA-18,
79
+ * `provider-http.js` → `TimeoutError`). The error carries a `code` and NO
80
+ * `category`, so every consumer that defaults an uncategorised throw would file
81
+ * it as transport. */
82
+ export const TIMEOUT_CODE: "ETIMEDOUT";
83
+ /** bare-agent's rejection code when its TOTAL-duration deadline trips (BA-19,
84
+ * `provider-http.js` → `applyRequestDeadline`). A DIFFERENT code from the idle
85
+ * bound's on purpose — upstream splits them precisely so a consumer routing
86
+ * governance stops apart from transport casualties can tell which timer fired. Like
87
+ * `TIMEOUT_CODE` it carries no `category`, so every default-to-transport catch site
88
+ * would file it as a casualty. */
89
+ export const DEADLINE_CODE: "EDEADLINE";
90
+ export type Clock = {
91
+ /**
92
+ * whether the operator set a cap at all
93
+ */
94
+ bounded: boolean;
95
+ /**
96
+ * the operator's number; `null` when unbounded
97
+ */
98
+ requestedMs: number | null;
99
+ /**
100
+ * how many close stages one verdict can pay for (W5)
101
+ */
102
+ closeStages: number;
103
+ /**
104
+ * wall time since the clock was created
105
+ */
106
+ elapsedMs: () => number;
107
+ /**
108
+ * what is left, floored at 0; `Infinity` when unbounded
109
+ */
110
+ remainingMs: () => number;
111
+ /**
112
+ * whether the deadline has passed (always false when unbounded)
113
+ */
114
+ expired: () => boolean;
115
+ /**
116
+ * the HONEST worst case
117
+ */
118
+ enforcedMs: (closeTimeoutMs: number) => number | null;
119
+ /**
120
+ * the derived per-call bound
121
+ */
122
+ callTimeoutMs: (providerDefaultMs?: number) => number;
123
+ /**
124
+ * the derived per-call TOTAL-duration ceiling; `null` when unbounded
125
+ */
126
+ callDeadlineMs: () => number | null;
127
+ /**
128
+ * one spine-safe shape carrying both numbers
129
+ */
130
+ report: (closeTimeoutMs: number) => object;
131
+ };