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/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
  }
@@ -1494,6 +1586,10 @@ export async function runReuse(opts) {
1494
1586
  runid: `${runid}-t${n}`, patient, at: new Date(now()).toISOString(),
1495
1587
  costUsd: read.spentUsd, spendComplete: read.spendComplete, wallMs, rounds,
1496
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,
1497
1593
  };
1498
1594
  if (outcome === 'already-green') {
1499
1595
  // A GREEN THAT PREDATES THE RUN mints nothing, and the guard keys on the
@@ -1531,7 +1627,13 @@ export async function runReuse(opts) {
1531
1627
  closeStageNames: (closeStagesOf(job) ?? []).map((/** @type {any} */ s) => s.name),
1532
1628
  toolsUsed: [...new Set((read.plan.steps ?? []).flatMap((/** @type {any} */ s) => s.tools ?? []))],
1533
1629
  };
1534
- 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;
1535
1637
  } else if (HUMAN_CHECKPOINTS.includes(outcome)) {
1536
1638
  // A PAUSE IS NOT A RESULT, so the box gets nothing — not a casualty row, not a red,
1537
1639
  // and the workflow is left exactly as it was. The recipe has not been judged: the
@@ -1565,89 +1667,18 @@ export async function runReuse(opts) {
1565
1667
  return row;
1566
1668
  };
1567
1669
 
1568
- /** 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
1569
1673
  * @param {{ok: boolean, reds: any[], bridge: any}} r @param {string|null} name @param {string} action
1570
1674
  * @param {object} [extra] fields that make the write's own story visible on the record */
1571
1675
  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 };
1676
+ const w = commitWrite(registryDir, r, name, action, extra);
1589
1677
  bridgeWrites.push(w);
1590
1678
  emit('bridge-write', w);
1591
1679
  return w;
1592
1680
  };
1593
1681
 
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
1682
  /**
1652
1683
  * The stops no further try can change. A spec nobody signed, a spec that will not
1653
1684
  * validate, and a degraded primitive are properties of the RUN, not of the recipe — the
@@ -1908,7 +1939,10 @@ export async function runReuse(opts) {
1908
1939
  });
1909
1940
  selection.push({ n, ...sel, listing: undefined });
1910
1941
  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 });
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 });
1912
1946
 
1913
1947
  if (sel.refused) {
1914
1948
  return done('selection-refused', {
@@ -1962,3 +1996,199 @@ export async function runReuse(opts) {
1962
1996
  + ` close ${t.closeReached ? 'reached' : 'NEVER REACHED'}`).join('\n'),
1963
1997
  });
1964
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
+ }