bareloop 0.11.0 → 0.12.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/bridges.js CHANGED
@@ -35,11 +35,44 @@
35
35
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
36
36
  import { join } from 'node:path';
37
37
  import { isObj, isNonEmptyString, sweepNestedQuantifiers } from './validate.js';
38
- import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
38
+ import { TOOL_MENU, LOCKED_TOOLS, VERDICT_TYPES } from './job.js';
39
39
  import { closeStagesOf } from './plan.js';
40
+ // the three doors, in their ONE spelling. A second copy of "what a person may
41
+ // answer" here would be a second vocabulary to drift from the one the runner
42
+ // applies — the same rule the outcome classes already live under. `kinds.js` is
43
+ // already in this module's transitive graph (job → declaredclose → kinds), so
44
+ // this costs nothing at load and buys a single source for the door names.
45
+ import { HUMAN_DECISIONS } from './kinds.js';
40
46
 
41
47
  export const BRIDGE_SCHEMA = 'bridge-v1';
42
48
 
49
+ /**
50
+ * SOFTGREEN MODULE 6 — the verdict classes whose greens are minted QUARANTINED.
51
+ *
52
+ * The standing ruling (PRD v1.53, given a mechanism by v1.71 §3): *softgreen
53
+ * passes are quarantined from learning credit until the judged floor is proven*,
54
+ * and **the signer's `accept` at the review door is what releases it**.
55
+ *
56
+ * `soft-green` and NOT `hitl`: the hold is about the young JUDGE, not about every
57
+ * class that is not `green`. A hitl close is rendered by a PERSON at close time —
58
+ * there is no unproven ruler in it to distrust. A green earns its credit at its
59
+ * own close, exactly as it always has.
60
+ */
61
+ export const QUARANTINED_VERDICTS = Object.freeze(['soft-green']);
62
+
63
+ /** the red CODE a hold travels under — the gate emits it, the listing splits the
64
+ * held rows out of the broken ones on it, and the runner reads it back. One
65
+ * spelling, because three literals is how a hold silently becomes a breakage. */
66
+ export const QUARANTINED_CODE = 'quarantined';
67
+
68
+ /** does a green under this verdict class earn its learning credit at the close, or
69
+ * does it wait for a person? Absent/unknown means green's behaviour, byte for byte
70
+ * — the pre-softgreen path is the default and stays untouched.
71
+ * @param {unknown} verdictType @returns {boolean} */
72
+ export function quarantinesCredit(verdictType) {
73
+ return typeof verdictType === 'string' && QUARANTINED_VERDICTS.includes(verdictType);
74
+ }
75
+
43
76
  /** @typedef {{code: string, path: string, detail?: string}} Red */
44
77
 
45
78
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
@@ -50,10 +83,21 @@ const BRIDGE_FIELDS = ['schema', 'name', 'goal', 'specHash', 'closeStageNames',
50
83
  /** a VERSION is one green's plan-as-executed plus that green's receipts.
51
84
  * `specHash` is optional and per-version on purpose: the five real probe-era
52
85
  * 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'];
86
+ * single top-level hash cannot hold them without dropping provenance.
87
+ *
88
+ * `quarantined` is softgreen module 6's hold and is OPTIONAL: absent is the green
89
+ * path, byte for byte, and `false` means a signer released it (a flag, never a
90
+ * deletion — the record keeps saying that this green was once held). */
91
+ const VERSION_FIELDS = ['plan', 'runid', 'greenAt', 'patient', 'costUsd', 'wallMs', 'rounds', 'specHash', 'quarantined'];
92
+ /** a HISTORY row is one run against this bridge, green or not. `quarantined` is the
93
+ * version's twin (they are minted and released together, in one place each);
94
+ * `doors` is the REPORT CARD — every disposition a signer took on this run, in
95
+ * order, as recorded facts. Deliberately no rate and no score: D6's no-score rule
96
+ * applies to the judge exactly as it applies to the workflow. */
97
+ const HISTORY_FIELDS = ['at', 'runid', 'patient', 'outcome', 'failingStage', 'costUsd', 'spendComplete', 'wallMs', 'rounds', 'quarantined', 'doors'];
98
+ /** one door: what the person said, and when. Nothing else — a note that outranked
99
+ * the decision would be the smuggle channel the field sets exist to close. */
100
+ const DOOR_FIELDS = ['decision', 'at'];
57
101
 
58
102
  /**
59
103
  * D6's status ladder, walked in history order. Pure, total, and never throws —
@@ -70,6 +114,10 @@ const HISTORY_FIELDS = ['at', 'runid', 'patient', 'outcome', 'failingStage', 'co
70
114
  * the entry on the first repeat green.
71
115
  * - a red on a CANDIDATE changes nothing: there is nothing below the entry bar, and
72
116
  * the entry stays in the record with the red beside it.
117
+ * - a **QUARANTINED green is not evidence** (softgreen module 6). The row exists and
118
+ * the run happened, but a judged green earns nothing until the signer's `accept`
119
+ * releases it, so it neither clears the entry bar nor adds a distinct patient. An
120
+ * entry whose only greens are held derives `null` — below the bar, not demoted.
73
121
  * - a **CASUALTY is not a red.** Only the literal outcome `'red'` demotes; every
74
122
  * other non-green outcome (`provider-red`, `wall-halt`, `close-crashed`, …) is a
75
123
  * casualty, and casualties are never evidence in either direction.
@@ -86,6 +134,7 @@ export function deriveStatus(history) {
86
134
  if (!isObj(row)) continue;
87
135
  const r = /** @type {Record<string, any>} */ (row);
88
136
  if (r.outcome === 'green') {
137
+ if (r.quarantined === true) continue; // held: minted, visible, and worth nothing yet
89
138
  if (!isNonEmptyString(r.patient)) continue; // an unattributable green cannot prove a second instance
90
139
  everGreen = true;
91
140
  patients.add(r.patient);
@@ -200,9 +249,22 @@ export function validateBridge(input) {
200
249
  // second red on an already-broken field would bury the real defect): every green
201
250
  // mints exactly one version, and a red mints none.
202
251
  if (reds.length === 0) {
203
- const greens = e.history.filter((/** @type {any} */ h) => h.outcome === 'green').length;
204
- if (greens !== e.versions.length) {
205
- 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)`);
252
+ const greenRows = e.history.filter((/** @type {any} */ h) => h.outcome === 'green');
253
+ if (greenRows.length !== e.versions.length) {
254
+ red('history-mismatch', 'history', `${greenRows.length} green row(s) but ${e.versions.length} version(s) — a green mints exactly one version and a red mints none (R1)`);
255
+ } else {
256
+ // THE HOLD HAS TWO HALVES AND THEY ARE ONE FACT (softgreen module 6). The
257
+ // version carries it because the version is what INHERITS; the row carries
258
+ // it because the row is what the status ladder reads. Both are written in
259
+ // one place and released in one place, so a disagreement on disk is a
260
+ // hand-edit or a half-written file claiming a release nobody granted — and
261
+ // a hold that survives on only one half is worse than either state, because
262
+ // each reader would answer the credit question differently.
263
+ const holds = (/** @type {any[]} */ rows) => rows.filter((r) => r.quarantined === true).map((r) => r.runid).sort().join(',');
264
+ if (holds(e.versions) !== holds(greenRows)) {
265
+ red('quarantine-mismatch', 'history', `the versions hold [${holds(e.versions) || '—'}] and the green rows hold [${holds(greenRows) || '—'}]`
266
+ + ' — one hold, two halves: they are minted together and released together, so a difference is not a state this record can be in');
267
+ }
206
268
  }
207
269
  }
208
270
 
@@ -223,6 +285,9 @@ function validateVersion(v, at, red) {
223
285
  nullableNumber(o, 'costUsd', at, red);
224
286
  nullableNumber(o, 'wallMs', at, red, true);
225
287
  nullableNumber(o, 'rounds', at, red, true);
288
+ if ('quarantined' in o && typeof o.quarantined !== 'boolean') {
289
+ red('invalid-value', `${at}quarantined`, 'boolean — true while held, false once the signer released it. ABSENT is the green path (a class whose greens were never held)');
290
+ }
226
291
  }
227
292
 
228
293
  /** @param {unknown} h @param {string} at @param {(code: string, path: string, detail?: string) => void} red */
@@ -243,6 +308,23 @@ function validateHistoryRow(h, at, red) {
243
308
  nullableNumber(o, 'costUsd', at, red);
244
309
  nullableNumber(o, 'wallMs', at, red, true);
245
310
  nullableNumber(o, 'rounds', at, red, true);
311
+ // the hold, and the disposition record beside it (softgreen module 6)
312
+ if ('quarantined' in o) {
313
+ if (typeof o.quarantined !== 'boolean') red('invalid-value', `${at}quarantined`, 'boolean — true while held, false once released');
314
+ else if (o.outcome !== 'green') red('invalid-value', `${at}quarantined`, 'only a GREEN row can be held: a red and a casualty minted no credit, so there is none to withhold');
315
+ }
316
+ if ('doors' in o) {
317
+ if (!Array.isArray(o.doors)) red('invalid-value', `${at}doors`, 'array of {decision, at} — every disposition a signer took on this run, in order');
318
+ else {
319
+ o.doors.forEach((/** @type {unknown} */ d, /** @type {number} */ i) => {
320
+ if (!isObj(d)) { red('invalid-value', `${at}doors.${i}`, 'a door is an object {decision, at}'); return; }
321
+ const dd = /** @type {Record<string, any>} */ (d);
322
+ for (const key of Object.keys(dd)) if (!DOOR_FIELDS.includes(key)) red('unknown-field', `${at}doors.${i}.${key}`, `fields: ${DOOR_FIELDS.join(', ')}`);
323
+ if (!HUMAN_DECISIONS.includes(dd.decision)) red('invalid-value', `${at}doors.${i}.decision`, `one of ${HUMAN_DECISIONS.join(' | ')} — there is no fourth door`);
324
+ nullableString(dd, 'at', `${at}doors.${i}.`, red);
325
+ });
326
+ }
327
+ }
246
328
  if (typeof o.spendComplete !== 'boolean') red('invalid-value', `${at}spendComplete`, 'boolean — it travels with the spend on every row (F44)');
247
329
  else if (o.costUsd === null && o.spendComplete === true) {
248
330
  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');
@@ -281,7 +363,14 @@ function greenParts(o) {
281
363
  /** @type {Record<string, any>} */
282
364
  const version = { plan: o.plan, runid: o.runid, greenAt: at, patient: o.patient, costUsd: o.costUsd, wallMs: o.wallMs, rounds: o.rounds };
283
365
  if (o.specHash !== undefined) version.specHash = o.specHash;
284
- 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 } };
366
+ /** @type {Record<string, any>} */
367
+ const row = { at, runid: o.runid, patient: o.patient, outcome: 'green', failingStage: null, costUsd: o.costUsd, spendComplete: o.spendComplete, wallMs: o.wallMs, rounds: o.rounds };
368
+ // softgreen module 6 — the HOLD, decided in the one place a green is built, from
369
+ // the class the run was signed under. The key is ABSENT rather than `false` for
370
+ // every other class: an absent key is the pre-softgreen record byte for byte, and
371
+ // a `false` on a green would say "a signer released this", which nobody did.
372
+ if (quarantinesCredit(o.verdictType)) { version.quarantined = true; row.quarantined = true; }
373
+ return { version, row };
285
374
  }
286
375
 
287
376
  /**
@@ -294,6 +383,15 @@ function validateGreenRecord(record, red) {
294
383
  const o = /** @type {Record<string, any>} */ (isObj(record) ? record : {});
295
384
  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');
296
385
  else if (!isObj(o.plan)) red('invalid-value', 'plan', 'the plan object as executed');
386
+ // the class this green was signed under decides whether it is HELD (module 6).
387
+ // Optional — absent is green's own path — but a value OUTSIDE the radio reds
388
+ // rather than falling through to "not a quarantined class": a typo that mints a
389
+ // judged green with its credit already released is exactly the failure this
390
+ // whole module exists to prevent, and it must never be a silent one.
391
+ if (o.verdictType !== undefined && !VERDICT_TYPES.includes(o.verdictType)) {
392
+ red('invalid-value', 'verdictType', `the class this run was signed under, one of ${VERDICT_TYPES.join(' | ')} `
393
+ + '— it decides whether this green is held; an unrecognised one is a typo, and a typo must not mint an unheld judged green');
394
+ }
297
395
  }
298
396
 
299
397
  /**
@@ -382,6 +480,128 @@ export function appendRed(bridge, record) {
382
480
  return { ok: true, reds: [], bridge: next };
383
481
  }
384
482
 
483
+ // ── softgreen module 6: the hold, and the door that releases it ─────────────
484
+
485
+ /**
486
+ * The version that would INHERIT — the newest one whose credit has actually been
487
+ * released. `versions` is oldest-first, so this is normally the last of them; it
488
+ * differs only when a judged green sits on top of a released one, and in that case
489
+ * the RELEASED plan is what a reuse starts from. A held version is not a worse
490
+ * recipe, it is an unjudged one: nobody has said yet whether the ruler that passed
491
+ * it was right, and starting the next run from it would spend the credit before it
492
+ * was granted.
493
+ *
494
+ * Total and never throws — it is read at selection time and at the load door.
495
+ * @param {unknown} bridge
496
+ * @returns {any|null} null when every version is held (or there are none)
497
+ */
498
+ export function newestEligibleVersion(bridge) {
499
+ const e = /** @type {Record<string, any>} */ (isObj(bridge) ? bridge : {});
500
+ const versions = Array.isArray(e.versions) ? e.versions.filter(isObj) : [];
501
+ for (let i = versions.length - 1; i >= 0; i -= 1) if (versions[i].quarantined !== true) return versions[i];
502
+ return null;
503
+ }
504
+
505
+ /**
506
+ * May this workflow be offered for reuse at all? A held entry is SKIPPED WITH A
507
+ * REASON everywhere it is skipped — the same visible-skip discipline an unreadable
508
+ * registry file already gets, because a shelf that quietly shrinks teaches the
509
+ * operator that the registry is empty when it is merely holding.
510
+ * @param {unknown} bridge
511
+ * @returns {{ok: boolean, reason: string}} `reason` is '' when ok
512
+ */
513
+ export function reuseEligibility(bridge) {
514
+ if (newestEligibleVersion(bridge) !== null) return { ok: true, reason: '' };
515
+ const held = Array.isArray(/** @type {any} */ (bridge)?.versions) ? /** @type {any[]} */ (/** @type {any} */ (bridge).versions).length : 0;
516
+ return {
517
+ ok: false,
518
+ reason: held
519
+ ? `held: ${held === 1 ? 'its green was' : `all ${held} of its greens were`} rendered by the judged floor and no signer has accepted `
520
+ + 'one yet — a judged green earns no reuse until a person takes the door and accepts it'
521
+ : 'no version to reuse',
522
+ };
523
+ }
524
+
525
+ /**
526
+ * THE REVIEW DOOR, written down (PRD v1.71 §3). The person read the finished run
527
+ * and said one of three things; this records that on the run's own row, and — for
528
+ * `accept` on a HELD green — releases the learning credit that green has been
529
+ * carrying unspent.
530
+ *
531
+ * Three properties, and each is a ruling rather than a convenience:
532
+ *
533
+ * - **the door never changes the loop's verdict.** A green row stays a green row,
534
+ * whatever the person then does with it. `rerun` and `pause` are dispositions,
535
+ * recorded and nothing more; a rerun's own outcome is a NEW run's row.
536
+ * - **release is FORWARD-ONLY.** Nothing here can set `quarantined` back to true,
537
+ * so a signer who accepts and later reruns has recorded a disagreement — which
538
+ * is a datum worth keeping — without un-granting credit already granted. There
539
+ * is deliberately no re-hold path at all: it would be a way to walk the ledger
540
+ * backwards, which the ruling forbids.
541
+ * - **releasing is not minting.** A door aimed at a run with no green row of its
542
+ * own is a named red — never an invented entry. That is slice 1's already-green
543
+ * rule holding from the other side: `accept` confirms a verdict, it never
544
+ * creates one.
545
+ *
546
+ * Idempotent by shape: a decision identical to the last one recorded is a no-op
547
+ * that returns the entry unchanged, so a double-click at the door cannot inflate
548
+ * the report card (and `released` says whether THIS call was the one that freed it).
549
+ *
550
+ * Pure — returns a NEW entry; the input is never mutated.
551
+ * @param {unknown} bridge a validated bridge entry
552
+ * @param {unknown} door `{ runid, decision, at }` — `at` may be an explicit null
553
+ * @returns {{ ok: boolean, reds: Red[], bridge: any, released: boolean }}
554
+ */
555
+ export function recordDoor(bridge, door) {
556
+ /** @type {Red[]} */
557
+ const reds = [];
558
+ const base = validateBridge(bridge);
559
+ if (!base.ok) return { ok: false, reds: [{ code: 'bridge-invalid', path: 'bridge', detail: base.reds.map((r) => `${r.code}:${r.path}`).join(', ') }], bridge: null, released: false };
560
+ if (!isObj(door)) return { ok: false, reds: [{ code: 'invalid-value', path: 'door', detail: `a door is an object { runid, decision, at } — decision one of ${HUMAN_DECISIONS.join(' | ')}` }], bridge: null, released: false };
561
+ const d = /** @type {Record<string, any>} */ (door);
562
+ if (!isNonEmptyString(d.runid)) reds.push({ code: 'invalid-value', path: 'runid', detail: 'the run whose result the person just read' });
563
+ if (!HUMAN_DECISIONS.includes(d.decision)) {
564
+ reds.push({ code: 'invalid-value', path: 'decision', detail: `one of ${HUMAN_DECISIONS.join(' | ')} — there is no fourth door` });
565
+ }
566
+ if (d.at !== undefined && d.at !== null && !isNonEmptyString(d.at)) reds.push({ code: 'invalid-value', path: 'at', detail: 'a timestamp string, or an EXPLICIT null' });
567
+ if (reds.length) return { ok: false, reds, bridge: null, released: false };
568
+
569
+ const at = d.at ?? null;
570
+ const idx = base.bridge.history.findIndex((/** @type {any} */ h) => h.outcome === 'green' && h.runid === d.runid);
571
+ if (idx === -1) {
572
+ return {
573
+ ok: false,
574
+ reds: [{
575
+ code: 'no-row-for-run',
576
+ path: 'runid',
577
+ detail: `this workflow holds no GREEN row for run "${d.runid}", so there is no disposition to record and nothing to release. `
578
+ + 'A door answers a run that earned a row — an already-green run, a red and a casualty all earned none, and accept mints nothing',
579
+ }],
580
+ bridge: null,
581
+ released: false,
582
+ };
583
+ }
584
+
585
+ const row = base.bridge.history[idx];
586
+ const doors = Array.isArray(row.doors) ? row.doors : [];
587
+ if (doors.at(-1)?.decision === d.decision) return { ok: true, reds: [], bridge: base.bridge, released: false };
588
+
589
+ const releases = d.decision === 'accept' && row.quarantined === true;
590
+ /** @type {Record<string, any>} */
591
+ const nextRow = { ...row, doors: [...doors, { decision: d.decision, at }] };
592
+ if (releases) nextRow.quarantined = false;
593
+ const next = {
594
+ ...base.bridge,
595
+ // the version's half of the same one fact, flipped in the same breath
596
+ versions: releases
597
+ ? base.bridge.versions.map((/** @type {any} */ v) => (v.runid === d.runid && v.quarantined === true ? { ...v, quarantined: false } : v))
598
+ : base.bridge.versions,
599
+ history: base.bridge.history.map((/** @type {any} */ h, /** @type {number} */ i) => (i === idx ? nextRow : h)),
600
+ };
601
+ const v = validateBridge(next);
602
+ return v.ok ? { ok: true, reds: [], bridge: next, released: releases } : { ok: false, reds: v.reds, bridge: null, released: false };
603
+ }
604
+
385
605
  /**
386
606
  * One row of D3's listing: what the user (and the selecting LLM) sees per bridge —
387
607
  * name, the job sentence it greened, status, greens/reds, and the cost/time BAND of
@@ -394,7 +614,7 @@ export function appendRed(bridge, record) {
394
614
  * Total and never throws: the listing is a display surface, and one malformed
395
615
  * entry must not take the whole listing down (loadRegistry already reports it).
396
616
  * @param {unknown} bridge
397
- * @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}}}
617
+ * @returns {{name: string|null, goal: string|null, status: 'candidate'|'proven'|null, greens: number, reds: number, quarantinedGreens: number, doorDecisions: {runid: string|null, decision: string|null, at: string|null}[], lastOutcome: string|null, greenCost: {minUsd: number|null, maxUsd: number|null, minWallMs: number|null, maxWallMs: number|null, unpricedCount: number, untimedCount: number}}}
398
618
  */
399
619
  export function listingRow(bridge) {
400
620
  const e = /** @type {Record<string, any>} */ (isObj(bridge) ? bridge : {});
@@ -411,6 +631,13 @@ export function listingRow(bridge) {
411
631
  // 'red' EXACTLY — a casualty is not a red, in the listing for the same reason
412
632
  // it is not one in the status ladder (provider-red rows are never evidence)
413
633
  reds: history.filter((h) => h.outcome === 'red').length,
634
+ // softgreen module 6, both halves of it. `quarantinedGreens` is why an entry
635
+ // with greens can still derive no status; `doorDecisions` is the REPORT CARD —
636
+ // every disposition a signer took, verbatim and in order. Facts, not a rate: an
637
+ // agreement percentage over three runs is the fake precision D6 already refuses
638
+ // for the workflow, and the judge gets no looser rule than the recipe does.
639
+ quarantinedGreens: history.filter((h) => h.outcome === 'green' && h.quarantined === true).length,
640
+ doorDecisions: history.flatMap((h) => (Array.isArray(h.doors) ? h.doors.filter(isObj).map((/** @type {any} */ x) => ({ runid: isNonEmptyString(h.runid) ? h.runid : null, decision: x.decision ?? null, at: x.at ?? null })) : [])),
414
641
  lastOutcome: isNonEmptyString(history.at(-1)?.outcome) ? history.at(-1).outcome : null,
415
642
  greenCost: {
416
643
  minUsd: usd.length ? Math.min(...usd) : null,