bareloop 0.11.0 → 0.13.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
@@ -46,7 +46,7 @@ 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';
@@ -59,7 +59,7 @@ import { readGrade } from './trend.js';
59
59
  * list, and the TTL gate below cannot drift from each other */
60
60
  import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
61
61
  import { extractArtifact, priceOf } from './text.js';
62
- import { runJob as shippedRunJob } from './run.js';
62
+ import { runJob as shippedRunJob, ACCOUNTED_ROUND_TYPES } from './run.js';
63
63
 
64
64
  const require = createRequire(import.meta.url);
65
65
  const { Loop } = require('bare-agent');
@@ -338,7 +338,8 @@ export function reuseSpecHash(resolved) {
338
338
  * @param {boolean} [opts.forceCold]
339
339
  * @param {string[]} [opts.exclude] names already tried this run
340
340
  * @returns {Promise<any>} `{ choice, reason, called, refused, forcedCold, red, costUsd,
341
- * 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)
342
343
  */
343
344
  export async function selectBridge({ registry, job, ask, provider, pinned = null, shortlist = null, forceCold = false, exclude = [] }) {
344
345
  const r = /** @type {any} */ (registry);
@@ -346,22 +347,48 @@ export async function selectBridge({ registry, job, ask, provider, pinned = null
346
347
  const skipReds = Array.isArray(r?.reds) ? r.reds : [];
347
348
  const excluded = new Set(exclude);
348
349
  const shortSet = Array.isArray(shortlist) ? new Set(shortlist) : null;
349
- 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
+ });
350
366
 
351
367
  /** the shape every return here holds, so a caller never branches on field presence */
352
- 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 };
353
369
 
354
370
  if (forceCold) {
355
371
  return { ...base, forcedCold: true, reason: 'the operator forced a cold draft — no workflow was offered and no selection call was made' };
356
372
  }
357
373
  if (candidates.length === 0) {
358
- 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
+ };
359
382
  }
360
383
 
361
384
  // the listing the MODEL reads is the listing a human reads — same function, same text,
362
385
  // and the skip reds travel with it so a picker is never handed a set shorter than the
363
386
  // directory without being told
364
- 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
+ });
365
392
  const prompt = selectionPrompt(listing, ask, pinned);
366
393
 
367
394
  let result;
@@ -567,6 +594,46 @@ function readWorkBranch(seen) {
567
594
  return null;
568
595
  }
569
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
+
570
637
  /**
571
638
  * The dead leg's CLOSE GRADES, in order — the trend baselines a resumed leg
572
639
  * inherits (PRD v1.46 §3, and hamr's approved #2: *a resumed run's trend judges the
@@ -653,8 +720,11 @@ function readGradeSeed(seen) {
653
720
  * 4. **Which envelope this spine belongs to** — the `reuse-start` record's own
654
721
  * approval hash, so a resume can prove it is continuing the SAME signed run.
655
722
  *
656
- * **F45 governs the arithmetic here.** A window's spend is summed from `worker-round`
657
- * 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
658
728
  * are real money and are counted, but as the RUN's cost and never as a try's: a
659
729
  * picker's tokens are not a worker's, and attributing them to a try would misstate
660
730
  * both the fold and the row. `worker-turn` (native attribution, cost null by design)
@@ -815,7 +885,7 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
815
885
  // a priced worker round outside every try window: nothing in this runner emits
816
886
  // one, so it is a writer this reader does not model. It is COUNTED and reported
817
887
  // rather than dropped — the blind-instrument class is what F45 is about.
818
- if (ev.type === 'worker-round') {
888
+ if (ACCOUNTED_ROUND_TYPES.includes(ev.type)) {
819
889
  strayRounds += 1;
820
890
  if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) strayCostUsd += ev.costUsd;
821
891
  else selectionComplete = false;
@@ -823,8 +893,11 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
823
893
  continue;
824
894
  }
825
895
  open.seen.push(ev);
826
- if (ev.type === 'worker-round') {
827
- // 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)
828
901
  if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) open.roundsUsd += ev.costUsd;
829
902
  else open.roundsComplete = false;
830
903
  } else if (ev.type === 'job-end') {
@@ -1012,6 +1085,25 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
1012
1085
  priorRounds: open.declaredRounds !== null && typeof windowRounds === 'number'
1013
1086
  ? open.declaredRounds + windowRounds
1014
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),
1015
1107
  };
1016
1108
  if (open.bridge) tried.push(open.bridge);
1017
1109
  }
@@ -1240,6 +1332,7 @@ export function resumeTreeGate({ head, seed, dirty }) {
1240
1332
  * @param {number} [opts.strikeLimit] forwarded (a plan step's strike ceiling)
1241
1333
  * @param {number} [opts.closeTimeoutMs] forwarded
1242
1334
  * @param {boolean} [opts.layerRoot] forwarded
1335
+ * @param {boolean|'cap'|'diff'} [opts.readShim] the read shim's ARM (A0/A1/A2/A3), forwarded verbatim
1243
1336
  * @param {any} [opts.resume] RESUME (module C) — a `readResume` reading of the KILLED
1244
1337
  * run's own spine. Its `approvalHash` must match this run's, or the run refuses:
1245
1338
  * a resume continues ONE signed run, not any run.
@@ -1256,7 +1349,7 @@ export async function runReuse(opts) {
1256
1349
  const {
1257
1350
  job, approvals, registryDir, patient, workdir, provider, selectionProvider,
1258
1351
  emit, ask, pinned = null, shortlist = null, forceCold = false,
1259
- providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot,
1352
+ providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot, readShim,
1260
1353
  runJob = shippedRunJob, now = () => Date.now(),
1261
1354
  } = opts;
1262
1355
  const runid = opts.runid ?? Date.now().toString(36);
@@ -1424,6 +1517,7 @@ export async function runReuse(opts) {
1424
1517
  ...(strikeLimit !== undefined ? { strikeLimit } : {}),
1425
1518
  ...(closeTimeoutMs !== undefined ? { closeTimeoutMs } : {}),
1426
1519
  ...(layerRoot !== undefined ? { layerRoot } : {}),
1520
+ ...(readShim !== undefined ? { readShim } : {}),
1427
1521
  // the fold, and whether the fold is EXACT. `priorSpendComplete` used to stop at
1428
1522
  // the `try-start` record: it was computed, written down, and then not handed to
1429
1523
  // the one component whose `job-end` the row's own `spendComplete` is read off —
@@ -1494,6 +1588,10 @@ export async function runReuse(opts) {
1494
1588
  runid: `${runid}-t${n}`, patient, at: new Date(now()).toISOString(),
1495
1589
  costUsd: read.spentUsd, spendComplete: read.spendComplete, wallMs, rounds,
1496
1590
  ...(trySpecHash ? { specHash: trySpecHash } : {}),
1591
+ // the CLASS this run was signed under, carried to the one place a green is
1592
+ // built (softgreen module 6). It decides whether the minted version is HELD;
1593
+ // `appendRed` ignores it, because a red minted nothing to hold.
1594
+ verdictType: job.verdictType,
1497
1595
  };
1498
1596
  if (outcome === 'already-green') {
1499
1597
  // A GREEN THAT PREDATES THE RUN mints nothing, and the guard keys on the
@@ -1531,7 +1629,13 @@ export async function runReuse(opts) {
1531
1629
  closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
1532
1630
  toolsUsed: [...new Set((read.plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
1533
1631
  };
1534
- row.bridgeWrite = writeGreen(bridge, meta, { ...record, plan: read.plan });
1632
+ // ONE spelling of what a green writes, shared with the U runner (2B):
1633
+ // `writeGreenRow` builds the write record and saves it; this leg still owns
1634
+ // the reporting, exactly as `commit` did when the logic lived in here.
1635
+ const w = writeGreenRow({ registryDir, bridge, meta, record: { ...record, plan: read.plan } });
1636
+ bridgeWrites.push(w);
1637
+ emit('bridge-write', w);
1638
+ row.bridgeWrite = w;
1535
1639
  } else if (HUMAN_CHECKPOINTS.includes(outcome)) {
1536
1640
  // A PAUSE IS NOT A RESULT, so the box gets nothing — not a casualty row, not a red,
1537
1641
  // and the workflow is left exactly as it was. The recipe has not been judged: the
@@ -1565,89 +1669,18 @@ export async function runReuse(opts) {
1565
1669
  return row;
1566
1670
  };
1567
1671
 
1568
- /** save a bridge result and record the write, whatever produced it
1672
+ /** save a bridge result and record the write, whatever produced it — the record
1673
+ * itself is built by `commitWrite` (the one spelling of the write-record shape,
1674
+ * shared with `writeGreenRow`); this closure only adds the reporting
1569
1675
  * @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name @param {string} action
1570
1676
  * @param {object} [extra] fields that make the write's own story visible on the record */
1571
1677
  const commit = (r, name, action, extra = {}) => {
1572
- if (!r.ok) {
1573
- const w = { name, action: 'none', file: null, reds: r.reds, ...extra };
1574
- bridgeWrites.push(w);
1575
- emit('bridge-write', w);
1576
- return w;
1577
- }
1578
- const s = saveBridge(registryDir, r.bridge);
1579
- const w = { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
1580
- bridgeWrites.push(w);
1581
- emit('bridge-write', w);
1582
- return w;
1583
- };
1584
-
1585
- /** a refused write, recorded exactly like a committed one so a reader never has to
1586
- * branch on presence @param {string} name @param {any} red @param {object} [extra] */
1587
- const refuseWrite = (name, red, extra = {}) => {
1588
- const w = { name, action: 'none', file: null, reds: [red], ...extra };
1678
+ const w = commitWrite(registryDir, r, name, action, extra);
1589
1679
  bridgeWrites.push(w);
1590
1680
  emit('bridge-write', w);
1591
1681
  return w;
1592
1682
  };
1593
1683
 
1594
- /**
1595
- * KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
1596
- * LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
1597
- * whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
1598
- * The load gate reads that field, so the gate's verb check goes slightly stale across
1599
- * versions. It is not a safety hole — the gate only ever gets more permissive there,
1600
- * and the TWEAKED plan still passes the full `validatePlan` against the job's signed
1601
- * menu at draft time, which is the check that actually decides. Fixing it properly
1602
- * means `appendGreen` taking a label update, which is module 1's contract and hamr's
1603
- * call, not something to change from the caller.
1604
- * @param {any|null} bridge @param {any} meta @param {any} record
1605
- */
1606
- function writeGreen(bridge, meta, record) {
1607
- // the BRIDGE leg needs no shape check: `runJob`'s own load gate already proved this
1608
- // entry is the same kind of recipe as this job, at the door, before a token was spent
1609
- // (a mismatch never gets here — it returns `recipe-stale`).
1610
- if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
1611
-
1612
- // the COLD leg's green. `mintBridge` is for a name nobody holds; a name that already
1613
- // holds greens APPENDS, because a cold run of a job that already has a bridge is
1614
- // another version of that workflow, and overwriting it would destroy the green that
1615
- // minted it.
1616
- //
1617
- // But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
1618
- // slug. Appending across two close shapes does two wrong things at once: it counts a
1619
- // green another close rendered toward this entry's distinct-patient status (a false
1620
- // `proven`), and it hands the load gate — which matches on exactly these stage names
1621
- // — a plan that satisfied a different verification. So the shape is checked here with
1622
- // the gate's own predicate, and a mismatch neither appends NOR discards the green: it
1623
- // forks to a deterministic derived name, with the fork ON the record.
1624
- const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
1625
- const entries = loadRegistry(registryDir).bridges;
1626
- const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
1627
- if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
1628
-
1629
- const forked = existing !== undefined;
1630
- const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
1631
- /** the fork's own story, carried on every write record it produces */
1632
- const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
1633
- const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
1634
- if (held) {
1635
- // the derived name is derived FROM the shape, so a readable entry there matches by
1636
- // construction; a mismatch would mean two different closes hashed the same, and
1637
- // that is refused rather than appended blind
1638
- return sameCloseShape(held, stageNames)
1639
- ? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
1640
- : 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);
1641
- }
1642
- // A file of that name that `loadRegistry` could not READ is not an absent entry — it
1643
- // is an entry nobody can see, and minting over it destroys whatever greens it held.
1644
- // `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
1645
- if (existsSync(join(registryDir, `${target}.json`))) {
1646
- 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);
1647
- }
1648
- return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
1649
- }
1650
-
1651
1684
  /**
1652
1685
  * The stops no further try can change. A spec nobody signed, a spec that will not
1653
1686
  * validate, and a degraded primitive are properties of the RUN, not of the recipe — the
@@ -1908,7 +1941,10 @@ export async function runReuse(opts) {
1908
1941
  });
1909
1942
  selection.push({ n, ...sel, listing: undefined });
1910
1943
  account(sel.costUsd, sel.spendComplete);
1911
- emit('selection-result', { n, choice: sel.choice, reason: sel.reason, called: sel.called, refused: sel.refused, costUsd: sel.costUsd, candidates: sel.candidates });
1944
+ // `quarantineSkips` rides the spine beside the candidates for the same reason it rides
1945
+ // the listing: a shelf that quietly shrank is indistinguishable from an empty one, and
1946
+ // "held, waiting on you" is the fact the operator needs to see (softgreen module 6)
1947
+ 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 });
1912
1948
 
1913
1949
  if (sel.refused) {
1914
1950
  return done('selection-refused', {
@@ -1962,3 +1998,199 @@ export async function runReuse(opts) {
1962
1998
  + ` close ${t.closeReached ? 'reached' : 'NEVER REACHED'}`).join('\n'),
1963
1999
  });
1964
2000
  }
2001
+
2002
+ // ── the GREEN's registry write, shared by both runners ──────────────────────
2003
+
2004
+ /** save a bridge result and BUILD the write record — the ONE spelling of the
2005
+ * write-record shape (`{name, action, file, reds, …extra}`), used by `writeGreenRow`
2006
+ * below AND by `runReuse`'s own red/casualty `commit`, so a field added to one can
2007
+ * never silently miss the other. Reporting (push + emit) stays each caller's.
2008
+ * @param {string} registryDir @param {{ok: boolean, reds: any[], bridge: any}} r
2009
+ * @param {string|null} name @param {string} action @param {object} [extra] */
2010
+ function commitWrite(registryDir, r, name, action, extra = {}) {
2011
+ if (!r.ok) return { name, action: 'none', file: null, reds: r.reds, ...extra };
2012
+ const s = saveBridge(registryDir, r.bridge);
2013
+ return { name: r.bridge.name, action: s.ok ? action : 'none', file: s.file, reds: s.reds, ...extra };
2014
+ }
2015
+
2016
+ /**
2017
+ * WHAT A GREEN WRITES, in ONE place. Lifted verbatim out of `runReuse`'s own
2018
+ * closure when the U runner needed it too (2B) — two spellings of "what a green
2019
+ * writes" would be the two-transforms class applied to the ledger itself, which is
2020
+ * the failure `greenParts` exists one level down to prevent.
2021
+ *
2022
+ * Two legs, and the split is which caller already holds an entry:
2023
+ * - A BRIDGE IN HAND (the reuse leg) APPENDS. No shape check is needed: `runJob`'s
2024
+ * load gate already proved at the door, before a token, that this entry is the
2025
+ * same kind of recipe as this job (a mismatch returns `recipe-stale` and never
2026
+ * gets here).
2027
+ * - NO BRIDGE (the cold leg, and every U run) mints — unless a name already holds
2028
+ * greens, in which case it APPENDS, because a cold run of a job that already has
2029
+ * a bridge is another version of that workflow and overwriting it would destroy
2030
+ * the green that minted it.
2031
+ *
2032
+ * But the name is the JOB's slug, and a job's CLOSE can be re-signed under that same
2033
+ * slug. Appending across two close shapes does two wrong things at once: it counts a
2034
+ * green another close rendered toward this entry's distinct-patient status (a false
2035
+ * `proven`), and it hands the load gate — which matches on exactly these stage names
2036
+ * — a plan that satisfied a different verification. So the shape is checked with the
2037
+ * gate's own predicate, and a mismatch neither appends NOR discards the green: it
2038
+ * forks to a deterministic derived name, with the fork ON the record.
2039
+ *
2040
+ * THE HOLD IS NOT DECIDED HERE. `quarantined` comes off `greenParts` reading the
2041
+ * class the run was signed under (`record.verdictType`, module 6), so a caller that
2042
+ * forgets to pass it mints an UNHELD green rather than a held one — which is why
2043
+ * both callers pass the spec's own field and never a literal.
2044
+ *
2045
+ * KNOWN LIMIT, named rather than discovered later: `appendGreen` carries the entry's
2046
+ * LABEL (`goal`, `closeStageNames`, `toolsUsed`) forward untouched, so a new version
2047
+ * whose plan uses a verb the founding green did not is not reflected in `toolsUsed`.
2048
+ * The load gate reads that field, so the gate's verb check goes slightly stale across
2049
+ * versions. It is not a safety hole — the gate only ever gets more permissive there,
2050
+ * and the TWEAKED plan still passes the full `validatePlan` against the job's signed
2051
+ * menu at draft time, which is the check that actually decides. Fixing it properly
2052
+ * means `appendGreen` taking a label update, which is module 1's contract and hamr's
2053
+ * call, not something to change from the caller.
2054
+ *
2055
+ * Returns the WRITE RECORD both runners report — never throws, and a refusal is a
2056
+ * record with `action: 'none'` and named reds rather than an absence a reader has to
2057
+ * branch on.
2058
+ * @param {{registryDir: string, bridge?: any|null, meta: any, record: any}} o
2059
+ * @returns {{name: string|null, action: string, file: string|null, reds: any[], [k: string]: any}}
2060
+ */
2061
+ export function writeGreenRow({ registryDir, bridge = null, meta, record }) {
2062
+ /** the shared record builder, with this write's registry bound in
2063
+ * @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name
2064
+ * @param {string} action @param {object} [extra] */
2065
+ const commit = (r, name, action, extra = {}) => commitWrite(registryDir, r, name, action, extra);
2066
+ /** a refused write, recorded exactly like a committed one @param {string} name
2067
+ * @param {any} red @param {object} [extra] */
2068
+ const refuseWrite = (name, red, extra = {}) => ({ name, action: 'none', file: null, reds: [red], ...extra });
2069
+
2070
+ if (bridge) return commit(appendGreen(bridge, record), bridge.name, 'appendGreen');
2071
+
2072
+ const stageNames = Array.isArray(meta.closeStageNames) ? meta.closeStageNames : [];
2073
+ const entries = loadRegistry(registryDir).bridges;
2074
+ const existing = entries.find((/** @type {any} */ b) => b.name === meta.name);
2075
+ if (existing && sameCloseShape(existing, stageNames)) return commit(appendGreen(existing, record), meta.name, 'appendGreen');
2076
+
2077
+ const forked = existing !== undefined;
2078
+ const target = forked ? shapeForkName(meta.name, stageNames) : meta.name;
2079
+ /** the fork's own story, carried on every write record it produces */
2080
+ const mark = forked ? { shapeForked: true, forkedFrom: meta.name, closeStageNames: [...stageNames] } : {};
2081
+ const held = forked ? entries.find((/** @type {any} */ b) => b.name === target) : undefined;
2082
+ if (held) {
2083
+ // the derived name is derived FROM the shape, so a readable entry there matches by
2084
+ // construction; a mismatch would mean two different closes hashed the same, and
2085
+ // that is refused rather than appended blind
2086
+ return sameCloseShape(held, stageNames)
2087
+ ? commit(appendGreen(held, record), target, 'appendGreen-shape-forked', mark)
2088
+ : 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);
2089
+ }
2090
+ // A file of that name that `loadRegistry` could not READ is not an absent entry — it
2091
+ // is an entry nobody can see, and minting over it destroys whatever greens it held.
2092
+ // `loadRegistry` skips-and-reports for that reason; the mint must not undo the skip.
2093
+ if (existsSync(join(registryDir, `${target}.json`))) {
2094
+ 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);
2095
+ }
2096
+ return commit(mintBridge({ ...meta, name: target }, record), target, forked ? 'mint-shape-forked' : 'mint', mark);
2097
+ }
2098
+
2099
+ /**
2100
+ * THE U RUNNER'S TERMINAL WRITE (2B) — does the run that just ended earn a registry
2101
+ * row, and if so, write it.
2102
+ *
2103
+ * `scripts/run-u.mjs` has always kept standalone bridge FILES and never a registry,
2104
+ * so its review door could describe a held learning credit it had no row to release:
2105
+ * `--decide accept` reached `recordDoor`, found no green row for the runid and died
2106
+ * `no-row-for-run`. This is the row, and STORAGE IS ALL IT IS — selection, promotion
2107
+ * and reuse execution stay parked (layer-3-reuse). Nothing here reads a row back.
2108
+ *
2109
+ * FOUR REFUSALS, each one a guard that already existed somewhere and is kept:
2110
+ * - NO REGISTRY, no row. The registry path is operator-supplied and never
2111
+ * conjured; without one the door says, in words, that nothing was released.
2112
+ * - ALREADY-GREEN mints nothing. The close was satisfied before this run did
2113
+ * anything, so nothing here earned a version — the reuse-credit leak block, from
2114
+ * the writing side rather than the door's.
2115
+ * - A GREEN WITH NO PLAN mints nothing. The artifact that inherits is the one that
2116
+ * RAN, and there is none (R1).
2117
+ * - A CLASS WHOSE CREDIT IS NOT HELD mints nothing HERE. That is `green` today, and
2118
+ * it is main's behaviour kept rather than a new rule: this runner has never
2119
+ * written a registry row for a green-class run, and widening what enters the
2120
+ * registry is a reuse-rung question, not a side effect of fixing a door. The
2121
+ * predicate is `quarantinesCredit` — the ONE spelling of "is this class held" —
2122
+ * so the row this runner mints and the row a door releases can never disagree
2123
+ * about which classes they are talking about.
2124
+ *
2125
+ * The row is born HELD for exactly the reason any judged green is: `greenParts` reads
2126
+ * `verdictType` off the record. Nothing here sets the flag.
2127
+ * @param {{registryDir: string|null|undefined, job: any, name: string|null,
2128
+ * outcome: string, plan: any, record: any}} o
2129
+ * @returns {{minted: boolean, reason: string|null, write: any|null}}
2130
+ */
2131
+ export function writeRunGreenRow({ registryDir, job, name, outcome, plan, record }) {
2132
+ if (!isNonEmptyString(registryDir)) return { minted: false, reason: 'no-registry', write: null };
2133
+ if (outcome !== 'green') {
2134
+ return { minted: false, reason: outcome === 'already-green' ? 'green-predates-run' : 'not-green', write: null };
2135
+ }
2136
+ if (!isObj(plan)) return { minted: false, reason: 'no-plan-executed', write: null };
2137
+ if (!quarantinesCredit(job?.verdictType)) return { minted: false, reason: 'credit-not-held', write: null };
2138
+
2139
+ const target = isNonEmptyString(name) ? name : job.job;
2140
+ const meta = {
2141
+ name: target,
2142
+ goal: job.goal,
2143
+ specHash: record?.specHash ?? null,
2144
+ closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
2145
+ toolsUsed: [...new Set((plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
2146
+ };
2147
+ const write = writeGreenRow({
2148
+ registryDir,
2149
+ bridge: null,
2150
+ meta,
2151
+ // the class the run was SIGNED under, from the spec — never a literal, so the
2152
+ // hold and the class can never drift apart
2153
+ record: { ...record, plan, verdictType: job.verdictType },
2154
+ });
2155
+ return { minted: write.action !== 'none', reason: write.action === 'none' ? 'write-refused' : null, write };
2156
+ }
2157
+
2158
+ // ── the REVIEW DOOR's registry half (softgreen module 6) ────────────────────
2159
+
2160
+ /**
2161
+ * Apply a signer's door decision to the workflow a run wrote its row against:
2162
+ * record the disposition, and — on `accept` over a HELD judged green — release the
2163
+ * learning credit that green has been carrying unspent (PRD v1.71 §3).
2164
+ *
2165
+ * This is the ONE seam between a person's answer and the box. It is deliberately
2166
+ * separate from `runReuse`: the door opens AFTER the run has ended (a green run is
2167
+ * over, non-blocking, and the person may answer minutes or days later), so the
2168
+ * decision cannot ride the run's own return path. The runner calls this with the
2169
+ * runid it printed at the door.
2170
+ *
2171
+ * Never throws and never conjures: a missing registry, a missing entry, a
2172
+ * malformed one and a runid with no green row all come back as named reds. The
2173
+ * loop's verdict is untouched in every branch — this writes a disposition, never a
2174
+ * result.
2175
+ *
2176
+ * @param {object} opts
2177
+ * @param {string} opts.registryDir the operator-supplied registry directory
2178
+ * @param {string} opts.name the workflow the run wrote to
2179
+ * @param {string} opts.runid the RUN's id, exactly as the bridge row records it
2180
+ * @param {string} opts.decision one of the three doors
2181
+ * @param {string|null} [opts.at] when the person answered
2182
+ * @returns {{ok: boolean, reds: any[], released: boolean, file: string|null}}
2183
+ */
2184
+ export function applyDoorDecision({ registryDir, name, runid, decision, at = null }) {
2185
+ if (!isNonEmptyString(registryDir) || !registryExists(registryDir)) {
2186
+ 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 };
2187
+ }
2188
+ 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 };
2189
+ const file = join(registryDir, `${name}.json`);
2190
+ const loaded = loadBridge(file);
2191
+ if (!loaded.ok) return { ok: false, reds: loaded.reds, released: false, file: null };
2192
+ const r = recordDoor(loaded.bridge, { runid, decision, at });
2193
+ if (!r.ok) return { ok: false, reds: r.reds, released: false, file: null };
2194
+ const s = saveBridge(registryDir, r.bridge);
2195
+ return { ok: s.ok, reds: s.reds, released: s.ok && r.released, file: s.file };
2196
+ }