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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -23,9 +23,9 @@
23
23
  "prepublishOnly": "npm run build:types"
24
24
  },
25
25
  "dependencies": {
26
- "bare-agent": "^0.33.1",
26
+ "bare-agent": "^0.35.0",
27
27
  "bareguard": "^0.12.0",
28
- "litectx": "^0.29.1"
28
+ "litectx": "^0.32.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.0.0",
package/src/bridges.js ADDED
@@ -0,0 +1,571 @@
1
+ // Layer 3 — the REUSE registry (design record:
2
+ // docs/plans/2026-08-01-layer-3-reuse-design.md, D1/D2/D6, frozen 2026-08-01).
3
+ //
4
+ // A BRIDGE is the plan a green actually executed, kept so the next run of the same
5
+ // SHAPE starts from it instead of cold. This module is the box and the label on it:
6
+ // storage (D1), the derived status (D6), and the LOAD GATE (D2 as split by the
7
+ // 2026-08-01 addendum). It authors nothing and decides nothing about a run — the
8
+ // arbiter is untouched, and every function here is either a pure derivation or an
9
+ // explicit read/write of a plain JSON file.
10
+ //
11
+ // Three rulings are load-bearing and are enforced by shape, not by convention:
12
+ //
13
+ // - **R1 — only a green writes the box.** `appendGreen` mints a version AND a
14
+ // history row; `appendRed` writes a history row and NEVER touches `versions`.
15
+ // A red demotes, it does not edit the recipe.
16
+ // - **D6 — status is DERIVED, never stored.** Storing it would let a hand-edit or
17
+ // a half-written file claim a promotion no green paid for. There is deliberately
18
+ // NO probability score: n=1 is an anecdote in either direction (F24's
19
+ // withdrawal), and a percentage over 2–3 runs is fake precision. If a score ever
20
+ // earns its way in, its threshold is hamr's, from a measured base rate.
21
+ // - **F6 — unpriced is never free.** `costUsd`/`wallMs`/`rounds` are `number` or
22
+ // an EXPLICIT `null`; the key is required either way, so "unknown" has to be
23
+ // said rather than omitted, and `??0` can never launder it into a floor. The
24
+ // same discipline extends to time. `spendComplete` travels with every row (F44),
25
+ // and an unknown cost cannot claim a complete spend.
26
+ //
27
+ // Storage is a DIRECTORY of plain files — no database, no new dependency, no
28
+ // default location (the registry path is operator-supplied, and a missing one reds
29
+ // rather than being conjured). Layout stays minimal until something reads it back:
30
+ // housekeeping must not be dressed as a design tension while nothing consumes it.
31
+ //
32
+ // Demoted and failed-since entries are never deleted — the ledger runs both
33
+ // directions (PRD §2: the green that minted it AND the contrast that attributed it).
34
+
35
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { isObj, isNonEmptyString } from './validate.js';
38
+ import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
39
+ import { stageClose } from './plan.js';
40
+
41
+ export const BRIDGE_SCHEMA = 'bridge-v1';
42
+
43
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
44
+
45
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
46
+ /** the exact field set of a bridge entry — anything else is a smuggle channel
47
+ * (a stored `status`, a stored score, a hand-written note that outranks the
48
+ * history). Same inexpressibility guard both config documents use. */
49
+ const BRIDGE_FIELDS = ['schema', 'name', 'goal', 'specHash', 'closeStageNames', 'toolsUsed', 'versions', 'history'];
50
+ /** a VERSION is one green's plan-as-executed plus that green's receipts.
51
+ * `specHash` is optional and per-version on purpose: the five real probe-era
52
+ * bridges carry THREE distinct spec hashes across greens of the same job, so a
53
+ * single top-level hash cannot hold them without dropping provenance. */
54
+ const VERSION_FIELDS = ['plan', 'runid', 'greenAt', 'patient', 'costUsd', 'wallMs', 'rounds', 'specHash'];
55
+ /** a HISTORY row is one run against this bridge, green or not */
56
+ const HISTORY_FIELDS = ['at', 'runid', 'patient', 'outcome', 'failingStage', 'costUsd', 'spendComplete', 'wallMs', 'rounds'];
57
+
58
+ /**
59
+ * D6's status ladder, walked in history order. Pure, total, and never throws —
60
+ * it is called on whatever a file happens to contain.
61
+ *
62
+ * - **candidate** — at least one green.
63
+ * - **proven** — greens on ≥2 DISTINCT patients (R2: the same SHAPE on a second
64
+ * instance; two greens on ONE patient is a lookup table, which the memorization
65
+ * audit kills).
66
+ * - a **red** on a PROVEN entry drops it to candidate, and re-promotion then needs
67
+ * greens on two distinct patients AGAIN — that is what "until a new green on a
68
+ * second distinct patient re-proves" means, so the demoting red clears the
69
+ * distinct-patient set rather than leaving the old count standing to re-promote
70
+ * the entry on the first repeat green.
71
+ * - a red on a CANDIDATE changes nothing: there is nothing below the entry bar, and
72
+ * the entry stays in the record with the red beside it.
73
+ * - a **CASUALTY is not a red.** Only the literal outcome `'red'` demotes; every
74
+ * other non-green outcome (`provider-red`, `wall-halt`, `close-crashed`, …) is a
75
+ * casualty, and casualties are never evidence in either direction.
76
+ *
77
+ * @param {unknown} history the bridge's per-run rows, oldest first
78
+ * @returns {'candidate'|'proven'|null} null = below the entry bar (no green yet)
79
+ */
80
+ export function deriveStatus(history) {
81
+ if (!Array.isArray(history)) return null;
82
+ let everGreen = false;
83
+ /** distinct patients greened SINCE the last demoting red */
84
+ const patients = new Set();
85
+ for (const row of history) {
86
+ if (!isObj(row)) continue;
87
+ const r = /** @type {Record<string, any>} */ (row);
88
+ if (r.outcome === 'green') {
89
+ if (!isNonEmptyString(r.patient)) continue; // an unattributable green cannot prove a second instance
90
+ everGreen = true;
91
+ patients.add(r.patient);
92
+ } else if (r.outcome === 'red' && patients.size >= 2) {
93
+ patients.clear();
94
+ }
95
+ }
96
+ if (!everGreen) return null;
97
+ return patients.size >= 2 ? 'proven' : 'candidate';
98
+ }
99
+
100
+ /**
101
+ * Shared numeric-or-explicitly-unknown check (F6). The KEY must be present: an
102
+ * absent cost is not the same claim as a known-unknown one, and letting it be
103
+ * absent is exactly how `?? 0` gets written downstream.
104
+ * @param {Record<string, any>} o
105
+ * @param {string} key
106
+ * @param {string} at path prefix
107
+ * @param {(code: string, path: string, detail?: string) => void} red
108
+ * @param {boolean} [integer] wallMs/rounds are integers; costUsd is not
109
+ */
110
+ function nullableNumber(o, key, at, red, integer = false) {
111
+ if (!(key in o)) { red('missing-required', `${at}${key}`, 'number, or an EXPLICIT null for unknown — an omitted key is how an unknown becomes a silent 0 (F6)'); return; }
112
+ const v = o[key];
113
+ if (v === null) return;
114
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || (integer && !Number.isInteger(v))) {
115
+ red('invalid-value', `${at}${key}`, `${integer ? 'non-negative integer' : 'non-negative number'} or null`);
116
+ }
117
+ }
118
+
119
+ /** nullable STRING with the same present-key discipline (specHash, greenAt, at, failingStage)
120
+ * @param {Record<string, any>} o @param {string} key @param {string} at
121
+ * @param {(code: string, path: string, detail?: string) => void} red */
122
+ function nullableString(o, key, at, red) {
123
+ if (!(key in o)) { red('missing-required', `${at}${key}`, 'a string, or an EXPLICIT null for unknown'); return; }
124
+ const v = o[key];
125
+ if (v !== null && !isNonEmptyString(v)) red('invalid-value', `${at}${key}`, 'a non-empty string, or null');
126
+ }
127
+
128
+ /**
129
+ * Validate a bridge entry (`schema: "bridge-v1"`). Never throws on JSON text or
130
+ * plain parsed data — the same ingest contract both config validators hold; every
131
+ * failure is a named red. Returns the parsed entry on ok, null on any red.
132
+ * @param {object|string|unknown} input parsed entry, or raw JSON text
133
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
134
+ */
135
+ export function validateBridge(input) {
136
+ /** @type {Red[]} */
137
+ const reds = [];
138
+ /** @type {(code: string, path: string, detail?: string) => void} */
139
+ const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
140
+
141
+ let b = input;
142
+ if (typeof b === 'string') {
143
+ try { b = JSON.parse(b); } catch (e) {
144
+ return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], bridge: null };
145
+ }
146
+ }
147
+ if (!isObj(b)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'a bridge entry is a JSON object' }], bridge: null };
148
+ const e = /** @type {Record<string, any>} */ (b);
149
+
150
+ for (const key of Object.keys(e)) if (!BRIDGE_FIELDS.includes(key)) red('unknown-field', key, `fields: ${BRIDGE_FIELDS.join(', ')}`);
151
+
152
+ if (e.schema === undefined) red('missing-required', 'schema');
153
+ else if (e.schema !== BRIDGE_SCHEMA) red('invalid-value', 'schema', `expected "${BRIDGE_SCHEMA}", got ${JSON.stringify(e.schema)}`);
154
+ // the name IS the filename (saveBridge builds the path from it), so the slug is
155
+ // checked here rather than trusted from a distance — deliberately the same
156
+ // alphabet as the job validator's, never a looser one ('.' would admit '..')
157
+ if (!isNonEmptyString(e.name) || !SLUG_RE.test(e.name)) red('invalid-value', 'name', 'kebab-case slug — the registry filename is built from it');
158
+ if (!isNonEmptyString(e.goal)) red('invalid-value', 'goal', 'the job sentence this bridge greened — the listing shows it (D3)');
159
+ nullableString(e, 'specHash', '', red);
160
+
161
+ if (!Array.isArray(e.closeStageNames) || e.closeStageNames.length === 0) {
162
+ red('invalid-value', 'closeStageNames', 'non-empty ordered array of stage names — the load gate compares the job\'s close against it');
163
+ } else {
164
+ e.closeStageNames.forEach((/** @type {unknown} */ n, /** @type {number} */ i) => {
165
+ if (!isNonEmptyString(n) || !SLUG_RE.test(n)) red('invalid-value', `closeStageNames.${i}`, 'kebab-case stage name');
166
+ });
167
+ }
168
+
169
+ if (!Array.isArray(e.toolsUsed) || e.toolsUsed.length === 0 || !e.toolsUsed.every(isNonEmptyString)) {
170
+ red('invalid-value', 'toolsUsed', 'non-empty array of verbs — the union of the plan\'s step tools, judged against the signed menu at load');
171
+ } else {
172
+ // a verb outside the menu could never pass the load gate against ANY job, so it
173
+ // is a broken entry rather than a stale one — and a LOCKED verb in a stored
174
+ // recipe is the arbiter's line, not a matching question
175
+ const bad = e.toolsUsed.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t));
176
+ if (bad.length) red('invalid-value', 'toolsUsed', `not in the tool menu: ${bad.join(', ')}${bad.some((/** @type {string} */ t) => LOCKED_TOOLS.includes(t)) ? ` (locked-but-listed: ${LOCKED_TOOLS.join(', ')} — a stored recipe can never carry one)` : ''} — menu: ${TOOL_MENU.join('|')}`);
177
+ }
178
+
179
+ if (!Array.isArray(e.versions)) red('invalid-value', 'versions', 'array of {plan, runid, greenAt, patient, costUsd, wallMs, rounds}');
180
+ else if (e.versions.length === 0) red('entry-bar', 'versions', 'a bridge enters the registry only on a green (D6): no version, no entry — failed plans are never stored');
181
+ else e.versions.forEach((/** @type {unknown} */ v, /** @type {number} */ i) => validateVersion(v, `versions.${i}.`, red));
182
+
183
+ if (!Array.isArray(e.history)) red('invalid-value', 'history', 'array of per-run rows');
184
+ else e.history.forEach((/** @type {unknown} */ h, /** @type {number} */ i) => validateHistoryRow(h, `history.${i}.`, red));
185
+
186
+ // R1's coherence, checked only once everything it reads is itself valid (a
187
+ // second red on an already-broken field would bury the real defect): every green
188
+ // mints exactly one version, and a red mints none.
189
+ if (reds.length === 0) {
190
+ const greens = e.history.filter((/** @type {any} */ h) => h.outcome === 'green').length;
191
+ if (greens !== e.versions.length) {
192
+ red('history-mismatch', 'history', `${greens} green row(s) but ${e.versions.length} version(s) — a green mints exactly one version and a red mints none (R1)`);
193
+ }
194
+ }
195
+
196
+ return { ok: reds.length === 0, reds, bridge: reds.length === 0 ? e : null };
197
+ }
198
+
199
+ /** @param {unknown} v @param {string} at @param {(code: string, path: string, detail?: string) => void} red */
200
+ function validateVersion(v, at, red) {
201
+ if (!isObj(v)) { red('invalid-value', at.slice(0, -1), 'a version is an object'); return; }
202
+ const o = /** @type {Record<string, any>} */ (v);
203
+ for (const key of Object.keys(o)) if (!VERSION_FIELDS.includes(key)) red('unknown-field', `${at}${key}`, `fields: ${VERSION_FIELDS.join(', ')}`);
204
+ if (o.plan === undefined) red('missing-required', `${at}plan`, 'the plan AS EXECUTED (post-replan) — the run-as-executed artifact is what inherits');
205
+ else if (!isObj(o.plan)) red('invalid-value', `${at}plan`, 'the plan object as executed');
206
+ if (!isNonEmptyString(o.runid)) red('invalid-value', `${at}runid`);
207
+ if (!isNonEmptyString(o.patient)) red('invalid-value', `${at}patient`, 'the instance this green ran against — the distinct-patient count IS the status (D6)');
208
+ nullableString(o, 'greenAt', at, red);
209
+ if ('specHash' in o && o.specHash !== null && !isNonEmptyString(o.specHash)) red('invalid-value', `${at}specHash`, 'the hash signed when this version greened');
210
+ nullableNumber(o, 'costUsd', at, red);
211
+ nullableNumber(o, 'wallMs', at, red, true);
212
+ nullableNumber(o, 'rounds', at, red, true);
213
+ }
214
+
215
+ /** @param {unknown} h @param {string} at @param {(code: string, path: string, detail?: string) => void} red */
216
+ function validateHistoryRow(h, at, red) {
217
+ if (!isObj(h)) { red('invalid-value', at.slice(0, -1), 'a history row is an object'); return; }
218
+ const o = /** @type {Record<string, any>} */ (h);
219
+ for (const key of Object.keys(o)) if (!HISTORY_FIELDS.includes(key)) red('unknown-field', `${at}${key}`, `fields: ${HISTORY_FIELDS.join(', ')}`);
220
+ nullableString(o, 'at', at, red);
221
+ if (!isNonEmptyString(o.runid)) red('invalid-value', `${at}runid`);
222
+ if (!isNonEmptyString(o.patient)) red('invalid-value', `${at}patient`);
223
+ // 'green' | 'red' | a casualty class. The set is deliberately OPEN: the
224
+ // escalation categories are the runner's vocabulary and a closed copy here would
225
+ // be a second spelling to drift (an unknown category must be COUNTED, never
226
+ // silently dropped). Only 'red' demotes; everything else non-green is a casualty.
227
+ if (!isNonEmptyString(o.outcome)) red('invalid-value', `${at}outcome`, "'green', 'red', or a casualty class");
228
+ else if (o.outcome === 'green' && o.failingStage !== null) red('invalid-value', `${at}failingStage`, 'a green names no failing stage');
229
+ nullableString(o, 'failingStage', at, red);
230
+ nullableNumber(o, 'costUsd', at, red);
231
+ nullableNumber(o, 'wallMs', at, red, true);
232
+ nullableNumber(o, 'rounds', at, red, true);
233
+ if (typeof o.spendComplete !== 'boolean') red('invalid-value', `${at}spendComplete`, 'boolean — it travels with the spend on every row (F44)');
234
+ else if (o.costUsd === null && o.spendComplete === true) {
235
+ red('invalid-value', `${at}spendComplete`, 'an UNKNOWN cost cannot be a COMPLETE spend — a bare floor that reads as exact is F6 in an honest coat');
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Shared run-record contract for both appenders. One home, so a green's receipts
241
+ * and a red's receipts can never drift into two shapes.
242
+ * @param {unknown} record
243
+ * @param {(code: string, path: string, detail?: string) => void} red
244
+ */
245
+ function validateRunRecord(record, red) {
246
+ if (!isObj(record)) { red('invalid-value', 'record', 'a run record is an object'); return; }
247
+ const o = /** @type {Record<string, any>} */ (record);
248
+ if (!isNonEmptyString(o.runid)) red('invalid-value', 'runid');
249
+ if (!isNonEmptyString(o.patient)) red('invalid-value', 'patient');
250
+ nullableString(o, 'at', '', red);
251
+ nullableNumber(o, 'costUsd', '', red);
252
+ nullableNumber(o, 'wallMs', '', red, true);
253
+ nullableNumber(o, 'rounds', '', red, true);
254
+ if (typeof o.spendComplete !== 'boolean') red('invalid-value', 'spendComplete', 'boolean — it travels with the spend (F44)');
255
+ else if (o.costUsd === null && o.spendComplete === true) red('invalid-value', 'spendComplete', 'an unknown cost cannot be a complete spend (F6)');
256
+ }
257
+
258
+ /**
259
+ * The two rows a green writes, built in ONE place: the version (R1's
260
+ * run-as-executed artifact) and its history row. `mintBridge` and `appendGreen`
261
+ * both go through here — two spellings of a green would be the two-transforms
262
+ * class applied to the ledger itself.
263
+ * @param {Record<string, any>} o a run record already through validateRunRecord
264
+ * @returns {{version: Record<string, any>, row: Record<string, any>}}
265
+ */
266
+ function greenParts(o) {
267
+ const at = o.at ?? null;
268
+ /** @type {Record<string, any>} */
269
+ const version = { plan: o.plan, runid: o.runid, greenAt: at, patient: o.patient, costUsd: o.costUsd, wallMs: o.wallMs, rounds: o.rounds };
270
+ if (o.specHash !== undefined) version.specHash = o.specHash;
271
+ return { version, row: { at, runid: o.runid, patient: o.patient, outcome: 'green', failingStage: null, costUsd: o.costUsd, spendComplete: o.spendComplete, wallMs: o.wallMs, rounds: o.rounds } };
272
+ }
273
+
274
+ /**
275
+ * Shared green-record contract (the run record plus its plan).
276
+ * @param {unknown} record
277
+ * @param {(code: string, path: string, detail?: string) => void} red
278
+ */
279
+ function validateGreenRecord(record, red) {
280
+ validateRunRecord(record, red);
281
+ const o = /** @type {Record<string, any>} */ (isObj(record) ? record : {});
282
+ if (o.plan === undefined) red('missing-required', 'plan', 'the plan AS EXECUTED (post-replan) — a green mints the artifact that actually ran, not the one proposed');
283
+ else if (!isObj(o.plan)) red('invalid-value', 'plan', 'the plan object as executed');
284
+ }
285
+
286
+ /**
287
+ * FIRST GREEN MINTS: build a new registry entry from one green. The entry bar is
288
+ * a green (D6) — there is deliberately no way to create an entry without one, so
289
+ * a failed plan cannot enter the registry even by accident.
290
+ * @param {unknown} meta `{ name, goal, specHash, closeStageNames, toolsUsed }` —
291
+ * `specHash` may be an explicit null (one probe-era bridge on disk has none), and
292
+ * the KEY is required so an unknown hash has to be said rather than omitted.
293
+ * @param {unknown} record `{ runid, patient, at, plan, costUsd, spendComplete, wallMs, rounds, specHash? }`
294
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
295
+ */
296
+ export function mintBridge(meta, record) {
297
+ /** @type {Red[]} */
298
+ const reds = [];
299
+ /** @type {(code: string, path: string, detail?: string) => void} */
300
+ const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
301
+ if (!isObj(meta)) return { ok: false, reds: [{ code: 'invalid-value', path: 'meta', detail: 'a bridge label is an object { name, goal, specHash, closeStageNames, toolsUsed }' }], bridge: null };
302
+ const m = /** @type {Record<string, any>} */ (meta);
303
+ validateGreenRecord(record, red);
304
+ if (reds.length) return { ok: false, reds, bridge: null };
305
+ const { version, row } = greenParts(/** @type {Record<string, any>} */ (record));
306
+ const entry = {
307
+ schema: BRIDGE_SCHEMA,
308
+ name: m.name,
309
+ goal: m.goal,
310
+ // NOT `?? null`: an ABSENT key is a different claim from a declared unknown,
311
+ // and validateBridge reds it as missing-required rather than laundering it
312
+ specHash: m.specHash,
313
+ closeStageNames: Array.isArray(m.closeStageNames) ? [...m.closeStageNames] : m.closeStageNames,
314
+ toolsUsed: Array.isArray(m.toolsUsed) ? [...m.toolsUsed] : m.toolsUsed,
315
+ versions: [version],
316
+ history: [row],
317
+ };
318
+ const v = validateBridge(entry);
319
+ return v.ok ? { ok: true, reds: [], bridge: entry } : { ok: false, reds: v.reds, bridge: null };
320
+ }
321
+
322
+ /**
323
+ * A green on an EXISTING bridge: the plan AS EXECUTED becomes its next version,
324
+ * and the run gets a history row. Pure — returns a NEW entry; the input is never
325
+ * mutated, so a caller that reds still holds the bridge it read from disk.
326
+ * @param {unknown} bridge a validated bridge entry
327
+ * @param {unknown} record `{ runid, patient, at, plan, costUsd, spendComplete, wallMs, rounds, specHash? }`
328
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
329
+ */
330
+ export function appendGreen(bridge, record) {
331
+ /** @type {Red[]} */
332
+ const reds = [];
333
+ /** @type {(code: string, path: string, detail?: string) => void} */
334
+ const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
335
+ const base = validateBridge(bridge);
336
+ if (!base.ok) return { ok: false, reds: [{ code: 'bridge-invalid', path: 'bridge', detail: base.reds.map((r) => `${r.code}:${r.path}`).join(', ') }], bridge: null };
337
+ validateGreenRecord(record, red);
338
+ if (reds.length) return { ok: false, reds, bridge: null };
339
+ const { version, row } = greenParts(/** @type {Record<string, any>} */ (record));
340
+ return { ok: true, reds: [], bridge: { ...base.bridge, versions: [...base.bridge.versions, version], history: [...base.bridge.history, row] } };
341
+ }
342
+
343
+ /**
344
+ * A red (or a casualty): a history row ONLY. R1 — the bridge file is never edited
345
+ * in place by a red; the recipe that greened stays exactly as it greened, and the
346
+ * status derivation reads the demotion off the history.
347
+ * @param {unknown} bridge a validated bridge entry
348
+ * @param {unknown} record `{ runid, patient, at, outcome, failingStage, costUsd, spendComplete, wallMs, rounds }`
349
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
350
+ */
351
+ export function appendRed(bridge, record) {
352
+ /** @type {Red[]} */
353
+ const reds = [];
354
+ /** @type {(code: string, path: string, detail?: string) => void} */
355
+ const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
356
+ const base = validateBridge(bridge);
357
+ if (!base.ok) return { ok: false, reds: [{ code: 'bridge-invalid', path: 'bridge', detail: base.reds.map((r) => `${r.code}:${r.path}`).join(', ') }], bridge: null };
358
+ validateRunRecord(record, red);
359
+ const o = /** @type {Record<string, any>} */ (isObj(record) ? record : {});
360
+ if (!isNonEmptyString(o.outcome)) red('invalid-value', 'outcome', "'red' or a casualty class");
361
+ else if (o.outcome === 'green') red('invalid-value', 'outcome', 'a green goes through appendGreen — it must mint its version, and a green recorded here would leave the box unwritten (R1)');
362
+ nullableString(o, 'failingStage', '', red);
363
+ if (reds.length) return { ok: false, reds, bridge: null };
364
+
365
+ const next = {
366
+ ...base.bridge,
367
+ history: [...base.bridge.history, { at: o.at ?? null, runid: o.runid, patient: o.patient, outcome: o.outcome, failingStage: o.failingStage, costUsd: o.costUsd, spendComplete: o.spendComplete, wallMs: o.wallMs, rounds: o.rounds }],
368
+ };
369
+ return { ok: true, reds: [], bridge: next };
370
+ }
371
+
372
+ /**
373
+ * One row of D3's listing: what the user (and the selecting LLM) sees per bridge —
374
+ * name, the job sentence it greened, status, greens/reds, and the cost/time BAND of
375
+ * its greens. Deliberately no score (D6).
376
+ *
377
+ * The cost band SKIPS unknowns and says how many it skipped: an aggregate that
378
+ * treats a null as 0 reports a floor that reads as exact, which is F6 in an honest
379
+ * coat. With no priced green at all, every bound is null — never 0.
380
+ *
381
+ * Total and never throws: the listing is a display surface, and one malformed
382
+ * entry must not take the whole listing down (loadRegistry already reports it).
383
+ * @param {unknown} bridge
384
+ * @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}}}
385
+ */
386
+ export function listingRow(bridge) {
387
+ const e = /** @type {Record<string, any>} */ (isObj(bridge) ? bridge : {});
388
+ const history = Array.isArray(e.history) ? e.history.filter(isObj) : [];
389
+ const versions = Array.isArray(e.versions) ? e.versions.filter(isObj) : [];
390
+ const nums = (/** @type {string} */ key) => versions.map((v) => v[key]).filter((/** @type {unknown} */ v) => typeof v === 'number');
391
+ const usd = nums('costUsd');
392
+ const wall = nums('wallMs');
393
+ return {
394
+ name: isNonEmptyString(e.name) ? e.name : null,
395
+ goal: isNonEmptyString(e.goal) ? e.goal : null,
396
+ status: deriveStatus(history),
397
+ greens: history.filter((h) => h.outcome === 'green').length,
398
+ // 'red' EXACTLY — a casualty is not a red, in the listing for the same reason
399
+ // it is not one in the status ladder (provider-red rows are never evidence)
400
+ reds: history.filter((h) => h.outcome === 'red').length,
401
+ lastOutcome: isNonEmptyString(history.at(-1)?.outcome) ? history.at(-1).outcome : null,
402
+ greenCost: {
403
+ minUsd: usd.length ? Math.min(...usd) : null,
404
+ maxUsd: usd.length ? Math.max(...usd) : null,
405
+ minWallMs: wall.length ? Math.min(...wall) : null,
406
+ maxWallMs: wall.length ? Math.max(...wall) : null,
407
+ unpricedCount: versions.length - usd.length,
408
+ untimedCount: versions.length - wall.length,
409
+ },
410
+ };
411
+ }
412
+
413
+ /**
414
+ * D2's LOAD-TIME gate, as SPLIT by the 2026-08-01 addendum. Exactly three checks —
415
+ * *"is this the right KIND of recipe?"*, asked at the box:
416
+ *
417
+ * 1. **verdict type matches.** v1 admits `green` only, and a bridge only ever
418
+ * exists because a green minted it, so the bridge side is `green` by
419
+ * construction and the check reads the JOB's declared type. **The day a second
420
+ * verdict class is admitted, the bridge entry must carry its own verdictType
421
+ * and this becomes a two-sided comparison** — recorded here so it cannot be
422
+ * quietly assumed to already be one.
423
+ * 2. **close-stage KINDS match.** v1 simplification: the same stage NAMES in the
424
+ * same ORDER is the definition of the same kinds. The probes' R2-legal pair
425
+ * matched on four identical stage names over different patients, which is the
426
+ * material this rule was ruled on; a real kind-comparison (what each stage
427
+ * INSPECTS, independent of what it was named) is a later rung's problem and is
428
+ * not pretended here.
429
+ * 3. **every stored verb fits THIS job's signed menu.** An omitted `tools` means
430
+ * the concrete current TOOL_MENU — the same predicate `resolveSpec` and
431
+ * `validatePlan` read (MED-1), so what the gate judges is what was signed.
432
+ *
433
+ * **NOTHING about paths, scopes or targets.** Those are instance-bound and are
434
+ * EXPECTED to red: every recipe from a different day names yesterday's bricks, and
435
+ * that is what a recipe IS, not a flaw. The pre-probe measured the drafter
436
+ * replacing them unaided (3/3 legal after tweak), while the gate as originally
437
+ * frozen would have refused all six of them at the door. The full `validatePlan`
438
+ * still judges the TWEAKED draft at draft time — nothing executes unvalidated, and
439
+ * no second, looser path exists for an inherited plan (D4).
440
+ *
441
+ * A failing gate is the **recipe-stale / wrong-kind** OUTCOME — a distinct result
442
+ * with cold drafting offered, never a throw.
443
+ * @param {unknown} bridge
444
+ * @param {unknown} job the validateJob-green spec this run is signed against
445
+ * @returns {{ ok: boolean, reds: Red[] }}
446
+ */
447
+ export function loadGate(bridge, job) {
448
+ /** @type {Red[]} */
449
+ const reds = [];
450
+ /** @type {(path: string, detail: string) => void} */
451
+ const red = (path, detail) => { reds.push({ code: 'recipe-stale', path, detail }); };
452
+ if (!isObj(bridge)) return { ok: false, reds: [{ code: 'recipe-stale', path: 'bridge', detail: 'not a bridge entry' }] };
453
+ if (!isObj(job)) return { ok: false, reds: [{ code: 'recipe-stale', path: 'job', detail: 'not a job spec' }] };
454
+ const b = /** @type {Record<string, any>} */ (bridge);
455
+ const j = /** @type {Record<string, any>} */ (job);
456
+
457
+ // 1. verdict type
458
+ if (j.verdictType !== 'green') {
459
+ red('verdictType', `this job declares ${JSON.stringify(j.verdictType)}; a v1 bridge is minted by a green verdict only`);
460
+ }
461
+
462
+ // 2. close-stage kinds (v1: names in order)
463
+ const stages = (stageClose(j.close) ?? []).map((/** @type {any} */ s) => (isObj(s) ? s.name : null));
464
+ const stored = Array.isArray(b.closeStageNames) ? b.closeStageNames : null;
465
+ if (!stored || stored.length !== stages.length || !stored.every((/** @type {unknown} */ n, /** @type {number} */ i) => n === stages[i])) {
466
+ red('closeStageNames', `bridge close [${(stored ?? ['<none>']).join(' → ')}] vs this job's close [${(stages.length ? stages : ['<none>']).join(' → ')}] — same names in the same order is v1's definition of the same kinds`);
467
+ }
468
+
469
+ // 3. verbs within the signed menu (MED-1: an omitted `tools` IS the full menu)
470
+ const menu = Array.isArray(j.tools) ? j.tools : TOOL_MENU;
471
+ const used = Array.isArray(b.toolsUsed) ? b.toolsUsed : null;
472
+ if (!used) red('toolsUsed', 'the bridge does not record the verbs it used, so they cannot be judged against the signed menu');
473
+ else {
474
+ const outside = used.filter((/** @type {string} */ t) => !menu.includes(t));
475
+ if (outside.length) red('toolsUsed', `${outside.join(', ')} outside this job's signed menu [${menu.join('|')}] — the agent never widens its own grant`);
476
+ }
477
+
478
+ return { ok: reds.length === 0, reds };
479
+ }
480
+
481
+ // --- storage (D1): plain files in an operator-supplied directory ------------
482
+
483
+ /** @param {string} dir @returns {boolean} */
484
+ function isDir(dir) {
485
+ try { return statSync(dir).isDirectory(); } catch { return false; }
486
+ }
487
+
488
+ /**
489
+ * Read one bridge file. Never throws — a missing/unreadable file and a malformed
490
+ * one are both named reds, because the registry is read at selection time and one
491
+ * bad file must not take the run down.
492
+ * @param {string} file
493
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
494
+ */
495
+ export function loadBridge(file) {
496
+ let text;
497
+ try { text = readFileSync(file, 'utf8'); } catch (err) {
498
+ return { ok: false, reds: [{ code: 'read-error', path: file, detail: String(/** @type {Error} */ (err).message) }], bridge: null };
499
+ }
500
+ const r = validateBridge(text);
501
+ return r.ok ? r : { ok: false, reds: r.reds.map((x) => ({ ...x, path: `${file}:${x.path}` })), bridge: null };
502
+ }
503
+
504
+ /**
505
+ * Read a whole registry directory. A malformed entry is SKIPPED AND REPORTED —
506
+ * one red per bad file, the good entries still returned, sorted by name for a
507
+ * stable listing. `ok` is false when anything was skipped, so a caller can surface
508
+ * the skip instead of silently listing fewer workflows than exist on disk.
509
+ * @param {string} dir the operator-supplied registry path (no default location)
510
+ * @returns {{ ok: boolean, reds: Red[], bridges: any[] }}
511
+ */
512
+ export function loadRegistry(dir) {
513
+ if (!isDir(dir)) return { ok: false, reds: [{ code: 'registry-missing', path: dir, detail: 'the registry path is operator-supplied and is never conjured — create it, or point at the right one' }], bridges: [] };
514
+ /** @type {Red[]} */
515
+ const reds = [];
516
+ /** @type {any[]} */
517
+ const bridges = [];
518
+ let files;
519
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); } catch (err) {
520
+ return { ok: false, reds: [{ code: 'registry-unreadable', path: dir, detail: String(/** @type {Error} */ (err).message) }], bridges: [] };
521
+ }
522
+ for (const f of files) {
523
+ const file = join(dir, f);
524
+ const r = loadBridge(file);
525
+ if (r.ok) bridges.push(r.bridge);
526
+ else reds.push({ code: 'entry-invalid', path: file, detail: r.reds.map((x) => x.code).join(', ') });
527
+ }
528
+ bridges.sort((a, b) => String(a.name).localeCompare(String(b.name)));
529
+ return { ok: reds.length === 0, reds, bridges };
530
+ }
531
+
532
+ /**
533
+ * Write one bridge entry, ATOMICALLY (temp + rename): a registry read concurrent
534
+ * with a write must see the old entry or the new one, never a half-written file
535
+ * that then reads as a malformed skip. An INVALID entry is refused before anything
536
+ * touches the disk — the registry never holds a file its own validator rejects.
537
+ * @param {string} dir the operator-supplied registry directory (must exist)
538
+ * @param {unknown} bridge
539
+ * @returns {{ ok: boolean, reds: Red[], file: string|null }}
540
+ */
541
+ export function saveBridge(dir, bridge) {
542
+ const v = validateBridge(bridge);
543
+ if (!v.ok) return { ok: false, reds: v.reds, file: null };
544
+ if (!isDir(dir)) return { ok: false, reds: [{ code: 'registry-missing', path: dir, detail: 'the registry path is operator-supplied and is never conjured' }], file: null };
545
+ const file = join(dir, `${v.bridge.name}.json`);
546
+ const tmpFile = `${file}.${process.pid}.tmp`;
547
+ try {
548
+ writeFileSync(tmpFile, `${JSON.stringify(v.bridge, null, 2)}\n`);
549
+ renameSync(tmpFile, file);
550
+ } catch (err) {
551
+ rmSync(tmpFile, { force: true });
552
+ return { ok: false, reds: [{ code: 'write-error', path: file, detail: String(/** @type {Error} */ (err).message) }], file: null };
553
+ }
554
+ return { ok: true, reds: [], file };
555
+ }
556
+
557
+ /**
558
+ * Create a registry directory. Separate from `saveBridge` ON PURPOSE: saving must
559
+ * never conjure a registry from a typo'd path, so making one is an explicit act.
560
+ * @param {string} dir
561
+ * @returns {string} the directory
562
+ */
563
+ export function makeRegistry(dir) {
564
+ mkdirSync(dir, { recursive: true });
565
+ return dir;
566
+ }
567
+
568
+ /** @param {string} dir @returns {boolean} does a registry directory exist */
569
+ export function registryExists(dir) {
570
+ return existsSync(dir) && isDir(dir);
571
+ }