bareloop 0.10.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/reuse.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
2
- // docs/plans/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
2
+ // docs/02-features/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
3
3
  //
4
4
  // hamr's sentence is the whole specification of this file, verbatim:
5
5
  //
@@ -46,14 +46,20 @@ import { existsSync } from 'node:fs';
46
46
  import { join } from 'node:path';
47
47
  import { createHash } from 'node:crypto';
48
48
  import { createRequire } from 'node:module';
49
- import { loadRegistry, saveBridge, appendGreen, appendRed, mintBridge, registryExists } from './bridges.js';
49
+ import { loadRegistry, loadBridge, saveBridge, appendGreen, appendRed, mintBridge, registryExists, reuseEligibility, recordDoor, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
50
50
  import { renderListing, selectionPrompt } from './selection.js';
51
51
  import { closeStagesOf } from './plan.js';
52
52
  import { MIN_WALL_MS, jobSpecHash } from './job.js';
53
53
  import { isObj, isNonEmptyString } from './validate.js';
54
54
  import { readGrade } from './trend.js';
55
+
56
+ /** the paused-run TERMINAL and its spine record share one name (`runPlan` returns
57
+ * it and `runJob` writes it onto `job-end`), spelled once in `declaredclose.js`
58
+ * beside the close-verdict word it translates, so the emitters, the checkpoint
59
+ * list, and the TTL gate below cannot drift from each other */
60
+ import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
55
61
  import { extractArtifact, priceOf } from './text.js';
56
- import { runJob as shippedRunJob } from './run.js';
62
+ import { runJob as shippedRunJob, ACCOUNTED_ROUND_TYPES } from './run.js';
57
63
 
58
64
  const require = createRequire(import.meta.url);
59
65
  const { Loop } = require('bare-agent');
@@ -84,6 +90,34 @@ const { Loop } = require('bare-agent');
84
90
  */
85
91
  export const REUSE_GRADED_RED = Object.freeze(['escalated']);
86
92
 
93
+ /**
94
+ * WHICH TERMINALS ARE A CHECKPOINT — the list a runner hands `readResume` as
95
+ * `resumableOutcomes`, minted HERE rather than in whichever script happens to
96
+ * launch a run.
97
+ *
98
+ * `readResume` deliberately takes it as a parameter and defaults to empty, and
99
+ * that stays true: the reuse loop's own semantics depend on the empty default.
100
+ * What did not exist was a canonical ANSWER, so `scripts/run-u.mjs` kept its own
101
+ * copy — and the exported bundle (PRD v1.44 §2: a thin runner with bareloop as a
102
+ * dependency) would have had to keep a third. The same reasoning that put the
103
+ * pause TTL in the library (OPEN-2, hamr, 2026-08-13) puts this here.
104
+ *
105
+ * Every entry is a stop that left WORK ON DISK and an allowance unspent — money,
106
+ * time, a self-healed stall, and now a person who has not answered yet. A verdict
107
+ * already rendered is never on this list: `green`/`escalated` are graded rows, and
108
+ * re-buying one would pay twice for an answer already in hand. (The signer's own
109
+ * `hitl-cancel` terminal used to be the other example; the door was deleted on
110
+ * 2026-08-17 — a person who does not want to carry on pauses, and the checkpoint
111
+ * they keep is retired by the TTL rather than by a terminal.)
112
+ *
113
+ * The human half is SPLICED IN from `declaredclose.js` rather than spelled again,
114
+ * because the try loop below reads the two halves differently and a second copy is
115
+ * how they would come to disagree about which member is which: the first three are
116
+ * allowances the loop's remaining tries are meant to spend against, and the human
117
+ * ones are the stop no further try can change (see `hardStop`).
118
+ */
119
+ export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', ...HUMAN_CHECKPOINTS]);
120
+
87
121
  /**
88
122
  * v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
89
123
  * close-stage names, in the same order (`bridges.loadGate` rule 2). One predicate,
@@ -304,7 +338,8 @@ export function reuseSpecHash(resolved) {
304
338
  * @param {boolean} [opts.forceCold]
305
339
  * @param {string[]} [opts.exclude] names already tried this run
306
340
  * @returns {Promise<any>} `{ choice, reason, called, refused, forcedCold, red, costUsd,
307
- * spendComplete, candidates, listing }`
341
+ * spendComplete, candidates, quarantineSkips, listing }` — `quarantineSkips` names every
342
+ * workflow held back for want of a signer's accept, with its reason (module 6)
308
343
  */
309
344
  export async function selectBridge({ registry, job, ask, provider, pinned = null, shortlist = null, forceCold = false, exclude = [] }) {
310
345
  const r = /** @type {any} */ (registry);
@@ -312,22 +347,48 @@ export async function selectBridge({ registry, job, ask, provider, pinned = null
312
347
  const skipReds = Array.isArray(r?.reds) ? r.reds : [];
313
348
  const excluded = new Set(exclude);
314
349
  const shortSet = Array.isArray(shortlist) ? new Set(shortlist) : null;
315
- const candidates = all.filter((/** @type {any} */ b) => !excluded.has(b.name) && (!shortSet || shortSet.has(b.name)));
350
+ const offered = all.filter((/** @type {any} */ b) => !excluded.has(b.name) && (!shortSet || shortSet.has(b.name)));
351
+
352
+ // SOFTGREEN MODULE 6 — a HELD workflow is not offered, and the skip is stated.
353
+ // Its green was rendered by the judged floor and no signer has accepted it yet,
354
+ // so it has earned no reuse; handing it to the picker would spend the credit
355
+ // before it was granted. The reason travels TWO ways — into the listing the
356
+ // picker reads (as a distinct note, never dressed as an unreadable file) and out
357
+ // to the caller — because a shelf that quietly shrinks teaches the operator that
358
+ // the registry is empty when it is merely waiting on them.
359
+ /** @type {{name: string|null, reason: string}[]} */
360
+ const quarantineSkips = [];
361
+ const candidates = offered.filter((/** @type {any} */ b) => {
362
+ const e = reuseEligibility(b);
363
+ if (!e.ok) quarantineSkips.push({ name: isNonEmptyString(b.name) ? b.name : null, reason: e.reason });
364
+ return e.ok;
365
+ });
316
366
 
317
367
  /** the shape every return here holds, so a caller never branches on field presence */
318
- const base = { choice: null, reason: '', called: false, refused: false, forcedCold: false, red: null, costUsd: 0, spendComplete: true, candidates: candidates.map((/** @type {any} */ b) => b.name), listing: null, pinned };
368
+ const base = { choice: null, reason: '', called: false, refused: false, forcedCold: false, red: null, costUsd: 0, spendComplete: true, candidates: candidates.map((/** @type {any} */ b) => b.name), quarantineSkips, listing: null, pinned };
319
369
 
320
370
  if (forceCold) {
321
371
  return { ...base, forcedCold: true, reason: 'the operator forced a cold draft — no workflow was offered and no selection call was made' };
322
372
  }
323
373
  if (candidates.length === 0) {
324
- return { ...base, reason: exclude.length || shortSet ? 'no workflow remains to offer (every candidate was excluded or outside the shortlist)' : 'the registry holds no reusable workflow' };
374
+ return {
375
+ ...base,
376
+ // "nothing to reuse" and "everything is waiting on YOU" are different facts,
377
+ // and the empty-shelf sentence must not be said about the second
378
+ reason: quarantineSkips.length
379
+ ? `every workflow this run could have started from is HELD: ${quarantineSkips.map((s) => `${s.name} (${s.reason})`).join('; ')}`
380
+ : (exclude.length || shortSet ? 'no workflow remains to offer (every candidate was excluded or outside the shortlist)' : 'the registry holds no reusable workflow'),
381
+ };
325
382
  }
326
383
 
327
384
  // the listing the MODEL reads is the listing a human reads — same function, same text,
328
385
  // and the skip reds travel with it so a picker is never handed a set shorter than the
329
386
  // directory without being told
330
- const listing = renderListing({ ok: skipReds.length === 0, reds: skipReds, bridges: candidates });
387
+ const listing = renderListing({
388
+ ok: skipReds.length === 0 && quarantineSkips.length === 0,
389
+ reds: [...skipReds, ...quarantineSkips.map((s) => ({ code: QUARANTINED_CODE, path: s.name, detail: s.reason }))],
390
+ bridges: candidates,
391
+ });
331
392
  const prompt = selectionPrompt(listing, ask, pinned);
332
393
 
333
394
  let result;
@@ -418,13 +479,24 @@ function readTry(events) {
418
479
  };
419
480
  }
420
481
 
421
- /** the three-way verdict read of a runJob outcome, in ONE place: a green, the one
422
- * GRADED red that demotes (D6), or a casualty that keeps its own name. Both the live
423
- * try loop and the resume reader classify through this — two spellings of "what kind
424
- * of answer was that" is how a casualty ends up demoting a recipe.
425
- * @param {string} outcome @returns {'green'|'red'|'casualty'} */
482
+ /** the verdict read of a runJob outcome, in ONE place: a green, the one GRADED red
483
+ * that demotes (D6), a CHECKPOINT waiting on a person, or a casualty that keeps its
484
+ * own name. Both the live try loop and the resume reader classify through this — two
485
+ * spellings of "what kind of answer was that" is how a casualty ends up demoting a
486
+ * recipe.
487
+ *
488
+ * `checkpoint` is its own name rather than a fourth flavour of casualty (2026-08-17).
489
+ * A casualty is a run that DIED — a transport fault, a governance cut, a broken close
490
+ * — and a reader that sees one is being told the attempt is over. A human checkpoint
491
+ * is the opposite fact in every respect: nothing failed, nothing is discarded, the
492
+ * work and the money are exactly where they were, and the allowance still unspent is
493
+ * a person's answer. It behaves like neither a red nor a casualty everywhere it
494
+ * matters already (`hardStop` ends the loop on it, the box is left untouched), so the
495
+ * ROW saying "casualty" was the one place the machinery still mis-described itself.
496
+ * @param {string} outcome @returns {'green'|'red'|'checkpoint'|'casualty'} */
426
497
  function verdictClassOf(outcome) {
427
498
  if (outcome === 'green' || outcome === 'already-green') return 'green';
499
+ if (HUMAN_CHECKPOINTS.includes(outcome)) return 'checkpoint';
428
500
  return REUSE_GRADED_RED.includes(outcome) ? 'red' : 'casualty';
429
501
  }
430
502
 
@@ -522,6 +594,46 @@ function readWorkBranch(seen) {
522
594
  return null;
523
595
  }
524
596
 
597
+ /**
598
+ * F102 — the SIGNER'S ANSWER this window received and never paid for.
599
+ *
600
+ * Two records, two questions, and the pairing is the whole reading:
601
+ * `human-decision` — a decision reached this leg (with the person's own
602
+ * words, and when THEY said them)
603
+ * `human-decision-spent` — work was bought for it (a fix round, an accept that
604
+ * greened, a pause honoured)
605
+ *
606
+ * A decision with no spend after it is still owed to the person. That is the state
607
+ * F102 found unrepresentable: the resumed leg re-ran the close, arrived at the human
608
+ * stage and asked the byte-identical question, because "a decision is pending" and
609
+ * "nobody has looked yet" were the same empty checkpoint.
610
+ *
611
+ * The LAST unspent one wins — a leg emits at most one, and a window that reaches
612
+ * across a resume must act on the newest. `receivedAt` prefers the carried stamp (the
613
+ * moment the PERSON answered, forwarded leg to leg) and falls back to the record's own
614
+ * `ts`, which is that same moment on the leg that first heard it.
615
+ * @param {any[]} seen the window's own events, in file order
616
+ * @returns {{decision: string, text: string|null, receivedAt: string|null, source: string|null}|null}
617
+ */
618
+ function readPendingDecision(seen) {
619
+ for (let i = seen.length - 1; i >= 0; i -= 1) {
620
+ const e = seen[i];
621
+ if (!isObj(e)) continue;
622
+ // a spend seen while walking backwards settles everything older: the decision it
623
+ // paid for is the newest one there is, and nothing before it can still be owed
624
+ if (e.type === 'human-decision-spent') return null;
625
+ if (e.type !== 'human-decision' || !isNonEmptyString(e.decision)) continue;
626
+ const at = isNonEmptyString(e.receivedAt) ? String(e.receivedAt) : (isNonEmptyString(e.ts) ? String(e.ts) : null);
627
+ return {
628
+ decision: String(e.decision),
629
+ text: typeof e.text === 'string' ? e.text : null,
630
+ receivedAt: at,
631
+ source: isNonEmptyString(e.source) ? String(e.source) : null,
632
+ };
633
+ }
634
+ return null;
635
+ }
636
+
525
637
  /**
526
638
  * The dead leg's CLOSE GRADES, in order — the trend baselines a resumed leg
527
639
  * inherits (PRD v1.46 §3, and hamr's approved #2: *a resumed run's trend judges the
@@ -608,8 +720,11 @@ function readGradeSeed(seen) {
608
720
  * 4. **Which envelope this spine belongs to** — the `reuse-start` record's own
609
721
  * approval hash, so a resume can prove it is continuing the SAME signed run.
610
722
  *
611
- * **F45 governs the arithmetic here.** A window's spend is summed from `worker-round`
612
- * ONLY — the same event `runJob`'s own ledger accounts, no other. The selection calls
723
+ * **F45 governs the arithmetic here.** A window's spend is summed from
724
+ * `ACCOUNTED_ROUND_TYPES` ONLY — the ledger's OWN list (src/run.js), imported rather
725
+ * than re-spelled, so a record `runJob` charges and this reader has never heard of
726
+ * cannot silently widen a resumed run's ceiling by exactly that amount. It was one
727
+ * event (`worker-round`) until the close itself could spend (`judge-round`). The selection calls
613
728
  * are real money and are counted, but as the RUN's cost and never as a try's: a
614
729
  * picker's tokens are not a worker's, and attributing them to a try would misstate
615
730
  * both the fold and the row. `worker-turn` (native attribution, cost null by design)
@@ -770,7 +885,7 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
770
885
  // a priced worker round outside every try window: nothing in this runner emits
771
886
  // one, so it is a writer this reader does not model. It is COUNTED and reported
772
887
  // rather than dropped — the blind-instrument class is what F45 is about.
773
- if (ev.type === 'worker-round') {
888
+ if (ACCOUNTED_ROUND_TYPES.includes(ev.type)) {
774
889
  strayRounds += 1;
775
890
  if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) strayCostUsd += ev.costUsd;
776
891
  else selectionComplete = false;
@@ -778,8 +893,11 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
778
893
  continue;
779
894
  }
780
895
  open.seen.push(ev);
781
- if (ev.type === 'worker-round') {
782
- // the ONE event the live ledger accounts (src/run.js) — same rule, same spelling
896
+ if (ACCOUNTED_ROUND_TYPES.includes(ev.type)) {
897
+ // the events the live ledger accounts (src/run.js), read from ITS OWN list rather
898
+ // than re-spelled here: the fold is what stops a resume silently widening a signed
899
+ // budget, so a record the ledger charges and this reader does not know about is a
900
+ // ceiling that grows by exactly the close's own spend (`judge-round`, softgreen)
783
901
  if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) open.roundsUsd += ev.costUsd;
784
902
  else open.roundsComplete = false;
785
903
  } else if (ev.type === 'job-end') {
@@ -967,6 +1085,25 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
967
1085
  priorRounds: open.declaredRounds !== null && typeof windowRounds === 'number'
968
1086
  ? open.declaredRounds + windowRounds
969
1087
  : null,
1088
+ // F102 — the DECISION this leg was given and never paid for. A person's
1089
+ // answer is state, and state that lives only in a process dies with it: the
1090
+ // paused run `msx7a3rj` took the rerun door, stopped with `iterationsUsed: 0`,
1091
+ // and its resume asked the byte-identical question again because the
1092
+ // checkpoint could not tell "pending" from "nobody has looked yet".
1093
+ //
1094
+ // The two records are the run's own (`human-decision` / `human-decision-spent`,
1095
+ // src/planrun.js) and the reading is the plain one: the last decision received
1096
+ // with nothing after it that bought it. Deliberately NOT re-derived from a
1097
+ // terminal or a phase — the writer says both facts, and a reader inventing a
1098
+ // third rule is how two instruments come to disagree.
1099
+ //
1100
+ // THIS WINDOW ONLY, unlike the plan seed and the work branch beside it. Those
1101
+ // are facts about the TREE and survive an attempt that died before recording
1102
+ // anything; a decision is a fact about a PERSON and about the tree they were
1103
+ // shown, and reaching back into an abandoned attempt to re-apply one could
1104
+ // hand a leg an answer given about a tree two attempts ago. Stated as the
1105
+ // narrower rule rather than discovered as a mis-applied accept.
1106
+ pendingDecision: readPendingDecision(open.seen),
970
1107
  };
971
1108
  if (open.bridge) tried.push(open.bridge);
972
1109
  }
@@ -1023,6 +1160,81 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
1023
1160
  };
1024
1161
  }
1025
1162
 
1163
+ /**
1164
+ * THE PAUSE TTL (2026-08-12 §2): a hitl checkpoint is kept for 60 days.
1165
+ *
1166
+ * OPEN-2, RULED (hamr, in-turn, 2026-08-13): **the TTL lives in the LIBRARY**, so
1167
+ * the exported bundle (a headless runner with bareloop as a dependency, PRD v1.44
1168
+ * §2) inherits it instead of every runner re-implementing it. A script-only TTL
1169
+ * would vanish on export — the one place the rule is most needed and least
1170
+ * likely to be re-typed.
1171
+ */
1172
+ export const PAUSE_TTL_MS = 60 * 24 * 60 * 60_000;
1173
+
1174
+ /**
1175
+ * The AGE gate for a paused checkpoint — the sibling of `resumeTreeGate`, and it
1176
+ * asks the other question a 60-day-old checkpoint raises. The tree gate asks
1177
+ * *is this still the tree the run left?*; this asks *is this still a decision
1178
+ * anyone is waiting on?*
1179
+ *
1180
+ * THREE things it deliberately does NOT do:
1181
+ *
1182
+ * - it does not widen any other resume rule. The 2026-08-12 ruling is about a
1183
+ * person deciding, so `applies: false` on every other terminal: a cap-halt
1184
+ * checkpoint is a different stop under a different allowance, and quietly
1185
+ * ageing it out would be a governance change nobody ruled.
1186
+ * - it does not treat an unreadable stamp as young (F6). A checkpoint that
1187
+ * cannot say WHEN it paused has an unknown age, and unknown is refused rather
1188
+ * than rounded down to fresh — the direction that fails safe.
1189
+ * - it does not skip silently. An expired checkpoint comes back as a refusal
1190
+ * that NAMES the age and the TTL, because the operator's next move (start
1191
+ * fresh) depends on knowing which of the two it hit.
1192
+ *
1193
+ * The clock is injected for the same reason `runPlan`'s is: a rule measured in
1194
+ * months cannot otherwise be tested at all.
1195
+ *
1196
+ * @param {any[]} events the paused run's own spine, parsed, in file order
1197
+ * @param {{now?: () => number, ttlMs?: number}} [opts]
1198
+ * @returns {{ok: boolean, applies: boolean, ageMs: number|null, ttlMs: number, pausedAt: string|null, detail: string|null}}
1199
+ */
1200
+ export function checkpointAgeGate(events, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
1201
+ const list = Array.isArray(events) ? events.filter(isObj) : [];
1202
+ const end = list.filter((e) => e.type === 'job-end').at(-1) ?? null;
1203
+ const base = { applies: false, ageMs: null, ttlMs, pausedAt: null, detail: null };
1204
+ if (end?.outcome !== HITL_PAUSE) return { ...base, ok: true };
1205
+
1206
+ // the PAUSE RECORD's own timestamp, and its terminal's as the fallback: the
1207
+ // pause is what the human is answering, so its stamp is when the waiting began
1208
+ const pause = list.filter((e) => e.type === HITL_PAUSE).at(-1) ?? end;
1209
+ const pausedAt = typeof pause.ts === 'string' ? pause.ts : null;
1210
+ const atMs = pausedAt === null ? NaN : Date.parse(pausedAt);
1211
+ if (!Number.isFinite(atMs)) {
1212
+ return {
1213
+ ...base,
1214
+ ok: false,
1215
+ applies: true,
1216
+ detail: 'this checkpoint does not record when it paused, so its age is unknown — and an unknown age is not a '
1217
+ + `young one (F6). The TTL is ${Math.round(ttlMs / 86_400_000)} days; start a fresh run rather than resuming a `
1218
+ + 'checkpoint nothing can date.',
1219
+ };
1220
+ }
1221
+ const ageMs = Math.max(0, now() - atMs);
1222
+ const days = (/** @type {number} */ ms) => Math.floor(ms / 86_400_000);
1223
+ if (ageMs > ttlMs) {
1224
+ return {
1225
+ ...base,
1226
+ ok: false,
1227
+ applies: true,
1228
+ ageMs,
1229
+ pausedAt,
1230
+ detail: `this run paused ${days(ageMs)} days ago and the pause TTL is ${days(ttlMs)} days — the tree, the `
1231
+ + 'toolchain and the job itself have had two months to move, so the evidence the signer would be answering '
1232
+ + 'about is no longer the evidence the run produced. Start a fresh run.',
1233
+ };
1234
+ }
1235
+ return { ...base, ok: true, applies: true, ageMs, pausedAt };
1236
+ }
1237
+
1026
1238
  /**
1027
1239
  * The PATIENT gate for a resume, which is deliberately NOT the fresh-launch one.
1028
1240
  *
@@ -1374,8 +1586,35 @@ export async function runReuse(opts) {
1374
1586
  runid: `${runid}-t${n}`, patient, at: new Date(now()).toISOString(),
1375
1587
  costUsd: read.spentUsd, spendComplete: read.spendComplete, wallMs, rounds,
1376
1588
  ...(trySpecHash ? { specHash: trySpecHash } : {}),
1589
+ // the CLASS this run was signed under, carried to the one place a green is
1590
+ // built (softgreen module 6). It decides whether the minted version is HELD;
1591
+ // `appendRed` ignores it, because a red minted nothing to hold.
1592
+ verdictType: job.verdictType,
1377
1593
  };
1378
- if (verdictClass === 'green' && read.plan === null) {
1594
+ if (outcome === 'already-green') {
1595
+ // A GREEN THAT PREDATES THE RUN mints nothing, and the guard keys on the
1596
+ // TERMINAL rather than on the absence of a plan. `already-green` means the
1597
+ // close was satisfied at the precheck — before this leg wrote a byte — so
1598
+ // whatever plan is on the spine did not produce it, and minting a version
1599
+ // from one would be learning credit for work that did not happen.
1600
+ //
1601
+ // It used to be covered by accident: a cold already-green never drafts, so
1602
+ // `read.plan` was null and the branch below caught it. N4 broke that
1603
+ // accident — a hitl try that PAUSED and came back with the signer's
1604
+ // `accept` lands `already-green` with its predecessor's `plan-accepted`
1605
+ // sitting in the same try window. A rule with no wired detector is prose.
1606
+ bridgeWrites.push({
1607
+ name,
1608
+ action: 'none',
1609
+ file: null,
1610
+ reds: [{
1611
+ code: 'green-predates-run',
1612
+ path: 'outcome',
1613
+ detail: 'the close was already satisfied before this run did anything (already-green) — nothing here earned '
1614
+ + 'a version, and a plan on the spine did not produce this green',
1615
+ }],
1616
+ });
1617
+ } else if (verdictClass === 'green' && read.plan === null) {
1379
1618
  // a green with no plan on the spine cannot mint a version — the artifact that
1380
1619
  // inherits is the one that RAN, and there is nothing here to inherit. Reported,
1381
1620
  // never faked with a placeholder.
@@ -1388,7 +1627,33 @@ export async function runReuse(opts) {
1388
1627
  closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
1389
1628
  toolsUsed: [...new Set((read.plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
1390
1629
  };
1391
- row.bridgeWrite = writeGreen(bridge, meta, { ...record, plan: read.plan });
1630
+ // ONE spelling of what a green writes, shared with the U runner (2B):
1631
+ // `writeGreenRow` builds the write record and saves it; this leg still owns
1632
+ // the reporting, exactly as `commit` did when the logic lived in here.
1633
+ const w = writeGreenRow({ registryDir, bridge, meta, record: { ...record, plan: read.plan } });
1634
+ bridgeWrites.push(w);
1635
+ emit('bridge-write', w);
1636
+ row.bridgeWrite = w;
1637
+ } else if (HUMAN_CHECKPOINTS.includes(outcome)) {
1638
+ // A PAUSE IS NOT A RESULT, so the box gets nothing — not a casualty row, not a red,
1639
+ // and the workflow is left exactly as it was. The recipe has not been judged: the
1640
+ // close is not FINISHED rendering its verdict, and the answer that would finish it
1641
+ // is a person's. Filing it as history would put a row on the entry that reads as a
1642
+ // run this workflow failed to carry, and the signer's `accept` moments later would
1643
+ // leave that row standing as evidence against a workflow that in fact greened.
1644
+ // Recorded as an explicit no-write for the already-green branch's reason: a reader
1645
+ // never has to branch on absence.
1646
+ bridgeWrites.push({
1647
+ name,
1648
+ action: 'none',
1649
+ file: null,
1650
+ reds: [{
1651
+ code: 'run-paused-for-decision',
1652
+ path: 'outcome',
1653
+ detail: `the run is waiting on a person (${outcome}) — nothing has been judged yet, so there is no result to `
1654
+ + 'file against this workflow. The row is written by whatever the resumed run finally earns.',
1655
+ }],
1656
+ });
1392
1657
  } else if (bridge) {
1393
1658
  // R1: the recipe is NOT edited by a red. `outcome` rides in verbatim — a casualty
1394
1659
  // keeps its own name, and only the literal 'red' demotes (D6).
@@ -1402,89 +1667,18 @@ export async function runReuse(opts) {
1402
1667
  return row;
1403
1668
  };
1404
1669
 
1405
- /** save a bridge result and record the write, whatever produced it
1670
+ /** save a bridge result and record the write, whatever produced it — the record
1671
+ * itself is built by `commitWrite` (the one spelling of the write-record shape,
1672
+ * shared with `writeGreenRow`); this closure only adds the reporting
1406
1673
  * @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name @param {string} action
1407
1674
  * @param {object} [extra] fields that make the write's own story visible on the record */
1408
1675
  const commit = (r, name, action, extra = {}) => {
1409
- if (!r.ok) {
1410
- const w = { name, action: 'none', file: null, reds: r.reds, ...extra };
1411
- bridgeWrites.push(w);
1412
- emit('bridge-write', w);
1413
- return w;
1414
- }
1415
- const s = saveBridge(registryDir, r.bridge);
1416
- const w = { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
1676
+ const w = commitWrite(registryDir, r, name, action, extra);
1417
1677
  bridgeWrites.push(w);
1418
1678
  emit('bridge-write', w);
1419
1679
  return w;
1420
1680
  };
1421
1681
 
1422
- /** a refused write, recorded exactly like a committed one so a reader never has to
1423
- * branch on presence @param {string} name @param {any} red @param {object} [extra] */
1424
- const refuseWrite = (name, red, extra = {}) => {
1425
- const w = { name, action: 'none', file: null, reds: [red], ...extra };
1426
- bridgeWrites.push(w);
1427
- emit('bridge-write', w);
1428
- return w;
1429
- };
1430
-
1431
- /**
1432
- * KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
1433
- * LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
1434
- * whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
1435
- * The load gate reads that field, so the gate's verb check goes slightly stale across
1436
- * versions. It is not a safety hole — the gate only ever gets more permissive there,
1437
- * and the TWEAKED plan still passes the full `validatePlan` against the job's signed
1438
- * menu at draft time, which is the check that actually decides. Fixing it properly
1439
- * means `appendGreen` taking a label update, which is module 1's contract and hamr's
1440
- * call, not something to change from the caller.
1441
- * @param {any|null} bridge @param {any} meta @param {any} record
1442
- */
1443
- function writeGreen(bridge, meta, record) {
1444
- // the BRIDGE leg needs no shape check: `runJob`'s own load gate already proved this
1445
- // entry is the same kind of recipe as this job, at the door, before a token was spent
1446
- // (a mismatch never gets here — it returns `recipe-stale`).
1447
- if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
1448
-
1449
- // the COLD leg's green. `mintBridge` is for a name nobody holds; a name that already
1450
- // holds greens APPENDS, because a cold run of a job that already has a bridge is
1451
- // another version of that workflow, and overwriting it would destroy the green that
1452
- // minted it.
1453
- //
1454
- // But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
1455
- // slug. Appending across two close shapes does two wrong things at once: it counts a
1456
- // green another close rendered toward this entry's distinct-patient status (a false
1457
- // `proven`), and it hands the load gate — which matches on exactly these stage names
1458
- // — a plan that satisfied a different verification. So the shape is checked here with
1459
- // the gate's own predicate, and a mismatch neither appends NOR discards the green: it
1460
- // forks to a deterministic derived name, with the fork ON the record.
1461
- const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
1462
- const entries = loadRegistry(registryDir).bridges;
1463
- const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
1464
- if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
1465
-
1466
- const forked = existing !== undefined;
1467
- const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
1468
- /** the fork's own story, carried on every write record it produces */
1469
- const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
1470
- const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
1471
- if (held) {
1472
- // the derived name is derived FROM the shape, so a readable entry there matches by
1473
- // construction; a mismatch would mean two different closes hashed the same, and
1474
- // that is refused rather than appended blind
1475
- return sameCloseShape(held, stageNames)
1476
- ? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
1477
- : refuseWrite(target, { code: 'shape-fork-collision', path: `${target}.json`, detail: `the derived name for close [${stageNames.join(' → ')}] is already held by an entry of a different shape — refusing to append this green to a recipe another close greened` }, mark);
1478
- }
1479
- // A file of that name that `loadRegistry` could not READ is not an absent entry — it
1480
- // is an entry nobody can see, and minting over it destroys whatever greens it held.
1481
- // `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
1482
- if (existsSync(join(registryDir, `${target}.json`))) {
1483
- return refuseWrite(target, { code: 'mint-collision', path: `${target}.json`, detail: 'a file of this name exists but could not be read as a bridge — refusing to mint over it, because whatever greens it holds are not recoverable once overwritten. Repair or move it, then re-run.' }, mark);
1484
- }
1485
- return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
1486
- }
1487
-
1488
1682
  /**
1489
1683
  * The stops no further try can change. A spec nobody signed, a spec that will not
1490
1684
  * validate, and a degraded primitive are properties of the RUN, not of the recipe — the
@@ -1495,6 +1689,31 @@ export async function runReuse(opts) {
1495
1689
  * @param {any} row @returns {any|null} the terminal, or null to carry on
1496
1690
  */
1497
1691
  const hardStop = (row) => {
1692
+ // THE HUMAN HALF, read before the table and off the LIBRARY's own list: a run
1693
+ // waiting on a person is not one of the stops below, which are properties of the
1694
+ // run that would reproduce for $0. This one is the opposite — a try that did real
1695
+ // work and reached the one stage a machine cannot render. Buying the next try (and
1696
+ // then the cold leg) would spend the envelope re-asking a question already put to
1697
+ // someone who has not replied yet, and the second attempt could not be graded any
1698
+ // sooner than the first. The allowance still unspent is a person, so the loop stops
1699
+ // and hands the run back; the checkpoint on disk is untouched and resumable, which
1700
+ // is the runner script's half (N4 §1.6, PAUSE_TTL_MS).
1701
+ if (HUMAN_CHECKPOINTS.includes(row.runOutcome)) {
1702
+ return done(row.runOutcome, {
1703
+ category: row.runOutcome,
1704
+ decision: 'The run reached a stage only a person can render, and it is waiting on that answer. Every further '
1705
+ + 'try and the cold leg would run the same job to the same question, so the loop stops here rather than '
1706
+ + 'buying attempts nobody can grade yet. Nothing is lost: the run kept everything it did, and it resumes '
1707
+ + 'from this checkpoint once the decision is in.',
1708
+ options: [
1709
+ 'read the evidence package the pause recorded, then answer it and resume this run',
1710
+ 'leave it: an unanswered checkpoint expires on its own once the pause TTL is up',
1711
+ ],
1712
+ // NOT re-filed, for `interpreter-red`'s reason: the pause already escalated from
1713
+ // its own site inside the run, under this same name and with the evidence package
1714
+ // attached. A second copy here would count one person's pending decision twice.
1715
+ }, false);
1716
+ }
1498
1717
  const decisions = {
1499
1718
  'unapproved-spec': {
1500
1719
  decision: `No approval record matches the per-try spec (hash ${trySpecHash}). Tightening the caps with an envelope makes a NEW spec version, and a new version is signed, not inherited — nothing was run.`,
@@ -1720,7 +1939,10 @@ export async function runReuse(opts) {
1720
1939
  });
1721
1940
  selection.push({ n, ...sel, listing: undefined });
1722
1941
  account(sel.costUsd, sel.spendComplete);
1723
- emit('selection-result', { n, choice: sel.choice, reason: sel.reason, called: sel.called, refused: sel.refused, costUsd: sel.costUsd, candidates: sel.candidates });
1942
+ // `quarantineSkips` rides the spine beside the candidates for the same reason it rides
1943
+ // the listing: a shelf that quietly shrank is indistinguishable from an empty one, and
1944
+ // "held, waiting on you" is the fact the operator needs to see (softgreen module 6)
1945
+ emit('selection-result', { n, choice: sel.choice, reason: sel.reason, called: sel.called, refused: sel.refused, costUsd: sel.costUsd, candidates: sel.candidates, quarantineSkips: sel.quarantineSkips });
1724
1946
 
1725
1947
  if (sel.refused) {
1726
1948
  return done('selection-refused', {
@@ -1774,3 +1996,199 @@ export async function runReuse(opts) {
1774
1996
  + ` close ${t.closeReached ? 'reached' : 'NEVER REACHED'}`).join('\n'),
1775
1997
  });
1776
1998
  }
1999
+
2000
+ // ── the GREEN's registry write, shared by both runners ──────────────────────
2001
+
2002
+ /** save a bridge result and BUILD the write record — the ONE spelling of the
2003
+ * write-record shape (`{name, action, file, reds, …extra}`), used by `writeGreenRow`
2004
+ * below AND by `runReuse`'s own red/casualty `commit`, so a field added to one can
2005
+ * never silently miss the other. Reporting (push + emit) stays each caller's.
2006
+ * @param {string} registryDir @param {{ok: boolean, reds: any[], bridge: any}} r
2007
+ * @param {string|null} name @param {string} action @param {object} [extra] */
2008
+ function commitWrite(registryDir, r, name, action, extra = {}) {
2009
+ if (!r.ok) return { name, action: 'none', file: null, reds: r.reds, ...extra };
2010
+ const s = saveBridge(registryDir, r.bridge);
2011
+ return { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
2012
+ }
2013
+
2014
+ /**
2015
+ * WHAT A GREEN WRITES, in ONE place. Lifted verbatim out of `runReuse`'s own
2016
+ * closure when the U runner needed it too (2B) — two spellings of "what a green
2017
+ * writes" would be the two-transforms class applied to the ledger itself, which is
2018
+ * the failure `greenParts` exists one level down to prevent.
2019
+ *
2020
+ * Two legs, and the split is which caller already holds an entry:
2021
+ * - A BRIDGE IN HAND (the reuse leg) APPENDS. No shape check is needed: `runJob`'s
2022
+ * load gate already proved at the door, before a token, that this entry is the
2023
+ * same kind of recipe as this job (a mismatch returns `recipe-stale` and never
2024
+ * gets here).
2025
+ * - NO BRIDGE (the cold leg, and every U run) mints — unless a name already holds
2026
+ * greens, in which case it APPENDS, because a cold run of a job that already has
2027
+ * a bridge is another version of that workflow and overwriting it would destroy
2028
+ * the green that minted it.
2029
+ *
2030
+ * But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
2031
+ * slug. Appending across two close shapes does two wrong things at once: it counts a
2032
+ * green another close rendered toward this entry's distinct-patient status (a false
2033
+ * `proven`), and it hands the load gate — which matches on exactly these stage names
2034
+ * — a plan that satisfied a different verification. So the shape is checked with the
2035
+ * gate's own predicate, and a mismatch neither appends NOR discards the green: it
2036
+ * forks to a deterministic derived name, with the fork ON the record.
2037
+ *
2038
+ * THE HOLD IS NOT DECIDED HERE. `quarantined` comes off `greenParts` reading the
2039
+ * class the run was signed under (`record.verdictType`, module 6), so a caller that
2040
+ * forgets to pass it mints an UNHELD green rather than a held one — which is why
2041
+ * both callers pass the spec's own field and never a literal.
2042
+ *
2043
+ * KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
2044
+ * LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
2045
+ * whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
2046
+ * The load gate reads that field, so the gate's verb check goes slightly stale across
2047
+ * versions. It is not a safety hole — the gate only ever gets more permissive there,
2048
+ * and the TWEAKED plan still passes the full `validatePlan` against the job's signed
2049
+ * menu at draft time, which is the check that actually decides. Fixing it properly
2050
+ * means `appendGreen` taking a label update, which is module 1's contract and hamr's
2051
+ * call, not something to change from the caller.
2052
+ *
2053
+ * Returns the WRITE RECORD both runners report — never throws, and a refusal is a
2054
+ * record with `action: 'none'` and named reds rather than an absence a reader has to
2055
+ * branch on.
2056
+ * @param {{registryDir: string, bridge?: any|null, meta: any, record: any}} o
2057
+ * @returns {{name: string|null, action: string, file: string|null, reds: any[], [k: string]: any}}
2058
+ */
2059
+ export function writeGreenRow({ registryDir, bridge = null, meta, record }) {
2060
+ /** the shared record builder, with this write's registry bound in
2061
+ * @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name
2062
+ * @param {string} action @param {object} [extra] */
2063
+ const commit = (r, name, action, extra = {}) => commitWrite(registryDir, r, name, action, extra);
2064
+ /** a refused write, recorded exactly like a committed one @param {string} name
2065
+ * @param {any} red @param {object} [extra] */
2066
+ const refuseWrite = (name, red, extra = {}) => ({ name, action: 'none', file: null, reds: [red], ...extra });
2067
+
2068
+ if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
2069
+
2070
+ const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
2071
+ const entries = loadRegistry(registryDir).bridges;
2072
+ const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
2073
+ if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
2074
+
2075
+ const forked = existing !== undefined;
2076
+ const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
2077
+ /** the fork's own story, carried on every write record it produces */
2078
+ const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
2079
+ const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
2080
+ if (held) {
2081
+ // the derived name is derived FROM the shape, so a readable entry there matches by
2082
+ // construction; a mismatch would mean two different closes hashed the same, and
2083
+ // that is refused rather than appended blind
2084
+ return sameCloseShape(held, stageNames)
2085
+ ? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
2086
+ : refuseWrite(target, { code: 'shape-fork-collision', path: `${target}.json`, detail: `the derived name for close [${stageNames.join(' → ')}] is already held by an entry of a different shape — refusing to append this green to a recipe another close greened` }, mark);
2087
+ }
2088
+ // A file of that name that `loadRegistry` could not READ is not an absent entry — it
2089
+ // is an entry nobody can see, and minting over it destroys whatever greens it held.
2090
+ // `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
2091
+ if (existsSync(join(registryDir, `${target}.json`))) {
2092
+ return refuseWrite(target, { code: 'mint-collision', path: `${target}.json`, detail: 'a file of this name exists but could not be read as a bridge — refusing to mint over it, because whatever greens it holds are not recoverable once overwritten. Repair or move it, then re-run.' }, mark);
2093
+ }
2094
+ return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
2095
+ }
2096
+
2097
+ /**
2098
+ * THE U RUNNER'S TERMINAL WRITE (2B) — does the run that just ended earn a registry
2099
+ * row, and if so, write it.
2100
+ *
2101
+ * `scripts/run-u.mjs` has always kept standalone bridge FILES and never a registry,
2102
+ * so its review door could describe a held learning credit it had no row to release:
2103
+ * `--decide accept` reached `recordDoor`, found no green row for the runid and died
2104
+ * `no-row-for-run`. This is the row, and STORAGE IS ALL IT IS — selection, promotion
2105
+ * and reuse execution stay parked (layer-3-reuse). Nothing here reads a row back.
2106
+ *
2107
+ * FOUR REFUSALS, each one a guard that already existed somewhere and is kept:
2108
+ * - NO REGISTRY, no row. The registry path is operator-supplied and never
2109
+ * conjured; without one the door says, in words, that nothing was released.
2110
+ * - ALREADY-GREEN mints nothing. The close was satisfied before this run did
2111
+ * anything, so nothing here earned a version — the reuse-credit leak block, from
2112
+ * the writing side rather than the door's.
2113
+ * - A GREEN WITH NO PLAN mints nothing. The artifact that inherits is the one that
2114
+ * RAN, and there is none (R1).
2115
+ * - A CLASS WHOSE CREDIT IS NOT HELD mints nothing HERE. That is `green` today, and
2116
+ * it is main's behaviour kept rather than a new rule: this runner has never
2117
+ * written a registry row for a green-class run, and widening what enters the
2118
+ * registry is a reuse-rung question, not a side effect of fixing a door. The
2119
+ * predicate is `quarantinesCredit` — the ONE spelling of "is this class held" —
2120
+ * so the row this runner mints and the row a door releases can never disagree
2121
+ * about which classes they are talking about.
2122
+ *
2123
+ * The row is born HELD for exactly the reason any judged green is: `greenParts` reads
2124
+ * `verdictType` off the record. Nothing here sets the flag.
2125
+ * @param {{registryDir: string|null|undefined, job: any, name: string|null,
2126
+ * outcome: string, plan: any, record: any}} o
2127
+ * @returns {{minted: boolean, reason: string|null, write: any|null}}
2128
+ */
2129
+ export function writeRunGreenRow({ registryDir, job, name, outcome, plan, record }) {
2130
+ if (!isNonEmptyString(registryDir)) return { minted: false, reason: 'no-registry', write: null };
2131
+ if (outcome !== 'green') {
2132
+ return { minted: false, reason: outcome === 'already-green' ? 'green-predates-run' : 'not-green', write: null };
2133
+ }
2134
+ if (!isObj(plan)) return { minted: false, reason: 'no-plan-executed', write: null };
2135
+ if (!quarantinesCredit(job?.verdictType)) return { minted: false, reason: 'credit-not-held', write: null };
2136
+
2137
+ const target = isNonEmptyString(name) ? name : job.job;
2138
+ const meta = {
2139
+ name: target,
2140
+ goal: job.goal,
2141
+ specHash: record?.specHash ?? null,
2142
+ closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
2143
+ toolsUsed: [...new Set((plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
2144
+ };
2145
+ const write = writeGreenRow({
2146
+ registryDir,
2147
+ bridge: null,
2148
+ meta,
2149
+ // the class the run was SIGNED under, from the spec — never a literal, so the
2150
+ // hold and the class can never drift apart
2151
+ record: { ...record, plan, verdictType: job.verdictType },
2152
+ });
2153
+ return { minted: write.action !== 'none', reason: write.action === 'none' ? 'write-refused' : null, write };
2154
+ }
2155
+
2156
+ // ── the REVIEW DOOR's registry half (softgreen module 6) ────────────────────
2157
+
2158
+ /**
2159
+ * Apply a signer's door decision to the workflow a run wrote its row against:
2160
+ * record the disposition, and — on `accept` over a HELD judged green — release the
2161
+ * learning credit that green has been carrying unspent (PRD v1.71 §3).
2162
+ *
2163
+ * This is the ONE seam between a person's answer and the box. It is deliberately
2164
+ * separate from `runReuse`: the door opens AFTER the run has ended (a green run is
2165
+ * over, non-blocking, and the person may answer minutes or days later), so the
2166
+ * decision cannot ride the run's own return path. The runner calls this with the
2167
+ * runid it printed at the door.
2168
+ *
2169
+ * Never throws and never conjures: a missing registry, a missing entry, a
2170
+ * malformed one and a runid with no green row all come back as named reds. The
2171
+ * loop's verdict is untouched in every branch — this writes a disposition, never a
2172
+ * result.
2173
+ *
2174
+ * @param {object} opts
2175
+ * @param {string} opts.registryDir the operator-supplied registry directory
2176
+ * @param {string} opts.name the workflow the run wrote to
2177
+ * @param {string} opts.runid the RUN's id, exactly as the bridge row records it
2178
+ * @param {string} opts.decision one of the three doors
2179
+ * @param {string|null} [opts.at] when the person answered
2180
+ * @returns {{ok: boolean, reds: any[], released: boolean, file: string|null}}
2181
+ */
2182
+ export function applyDoorDecision({ registryDir, name, runid, decision, at = null }) {
2183
+ if (!isNonEmptyString(registryDir) || !registryExists(registryDir)) {
2184
+ return { ok: false, reds: [{ code: 'registry-missing', path: String(registryDir), detail: 'the registry path is operator-supplied and is never conjured' }], released: false, file: null };
2185
+ }
2186
+ if (!isNonEmptyString(name)) return { ok: false, reds: [{ code: 'invalid-value', path: 'name', detail: 'the workflow this run wrote its row against' }], released: false, file: null };
2187
+ const file = join(registryDir, `${name}.json`);
2188
+ const loaded = loadBridge(file);
2189
+ if (!loaded.ok) return { ok: false, reds: loaded.reds, released: false, file: null };
2190
+ const r = recordDoor(loaded.bridge, { runid, decision, at });
2191
+ if (!r.ok) return { ok: false, reds: r.reds, released: false, file: null };
2192
+ const s = saveBridge(registryDir, r.bridge);
2193
+ return { ok: s.ok, reds: s.reds, released: s.ok && r.released, file: s.file };
2194
+ }