bareloop 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +407 -0
- package/README.md +28 -11
- package/bareloop.context.md +510 -28
- package/package.json +2 -2
- package/src/bridges.js +571 -0
- package/src/clock.js +57 -5
- package/src/index.js +29 -0
- package/src/ladder.js +196 -0
- package/src/ledger.js +27 -0
- package/src/plan.js +75 -7
- package/src/planrun.js +762 -146
- package/src/ralph.js +148 -28
- package/src/reuse.js +1602 -0
- package/src/run.js +73 -6
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/trend.js +405 -0
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +25 -3
- package/types/index.d.ts +3 -0
- package/types/ladder.d.ts +86 -0
- package/types/plan.d.ts +18 -3
- package/types/planrun.d.ts +108 -9
- package/types/ralph.d.ts +82 -4
- package/types/reuse.d.ts +388 -0
- package/types/run.d.ts +50 -3
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/trend.d.ts +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"bare-agent": "^0.35.0",
|
|
27
27
|
"bareguard": "^0.12.0",
|
|
28
|
-
"litectx": "^0.
|
|
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
|
+
}
|