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/planrun.js CHANGED
@@ -30,11 +30,16 @@ import { createTrend, FIX_STRIKE_LIMIT } from './trend.js';
30
30
  import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
31
31
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
32
32
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
33
- import { validateBridge, loadGate } from './bridges.js';
33
+ import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
34
34
  import { extractArtifact } from './text.js';
35
+ import { defaultJudgeLoop } from './judged.js';
35
36
  import { createClock, isWallTimeout } from './clock.js';
36
- import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade } from './declaredclose.js';
37
- import { seedAtHead, seedListing, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES } from './kinds.js';
37
+ import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
38
+ import {
39
+ seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
40
+ HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling, resolveHumanRuling,
41
+ REVIEW_DOOR, doorOpens, mechanicalStages,
42
+ } from './kinds.js';
38
43
  import { workBranchName, prepareWorkBranch } from './workbranch.js';
39
44
 
40
45
  const require = createRequire(import.meta.url);
@@ -65,6 +70,11 @@ const NATIVE_READ_STRATEGY = '\nINTERFACE LIMIT: on this surface a whole-file re
65
70
  const SCOUT_MIN_BYTES = 200;
66
71
  /** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
67
72
  const ARTIFACT_MAX = 2000;
73
+ /** how many changed paths ride the hitl pause's evidence package. Bounded like
74
+ * every other list this repo puts on a spine, and the trim ANNOUNCED (F28): the
75
+ * person needs to see WHAT moved, and a run that touched a thousand files is
76
+ * telling them something a thousand lines would not. */
77
+ const PAUSE_CHANGED_CAP = 50;
68
78
  /** the wallet floor below which a replan is a stop, not an adaptation (review
69
79
  * #5): a money-gate halt drains the wallet to ~0, so replanning against dust
70
80
  * just burns another draft and mislabels the money-cut as "exits still red"
@@ -125,6 +135,24 @@ function materialsBlock(m) {
125
135
  return `\nWhat this run has left to spend — plan within it:\n${lines.join('\n')}\nThese are totals for the run, not per-step allowances. Nothing here is a rate: a step's cost depends on what it does.\n`;
126
136
  }
127
137
 
138
+ /**
139
+ * MODULE 8 — the signer's words, when this leg is a RERUN taken at a previous run's
140
+ * review door (PRD v1.71 §3.4: *"redo/rerun comes with new authoring"*).
141
+ *
142
+ * It is stated as a REQUIREMENT beside the goal, not as a replacement for it: the
143
+ * signed goal and the signed close are unchanged, and what the person added is the
144
+ * half a machine could not see. The block also says what the previous run's verdict
145
+ * WAS, because the planner would otherwise draft against a tree that already passes
146
+ * the close with no idea why it is being asked to do more.
147
+ *
148
+ * Empty/absent renders '' — every existing prompt is byte-identical.
149
+ * @param {string|null|undefined} note
150
+ */
151
+ function doorNoteBlock(note) {
152
+ if (typeof note !== 'string' || !note.trim()) return '';
153
+ return `\nA previous run of this job satisfied the close, and the person who owns it REVIEWED the\nresult and asked for another pass. Their words, which are a requirement this plan must\nmeet on top of the goal above (the goal and the close are unchanged):\n${note.trim()}\nThe close will judge the result exactly as it judged the last one — passing it again is\nnecessary and is not sufficient.\n`;
154
+ }
155
+
128
156
  /**
129
157
  * Layer 3 (D4) — the MECHANICAL START. A loaded bridge is handed to the drafter as a
130
158
  * STARTING DRAFT, not as a contract: it is appended to the ordinary drafting prompt and
@@ -340,8 +368,14 @@ export function boundedNote(bounded, rounds) {
340
368
  * Rule A-v2's law (final write step only, framed wide). `priorChecks`: the predecessor
341
369
  * plan's carried checks, replan phase only — Rule B's law (keep every one). Empty/omitted
342
370
  * renders byte-identically to the pre-rules prompt.
371
+ * @param {string|null} [doorNote] MODULE 8 — the signer's own words, when a previous run
372
+ * of this spec ended at the review door and they took the RERUN door. Additive and
373
+ * omitted-by-default, so every existing caller renders byte-identically. It is a
374
+ * REQUIREMENT the plan must meet, never a replacement for the goal: the signed goal and
375
+ * the signed close are unchanged, and what the person added is what the machine could
376
+ * not see.
343
377
  */
344
- export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}) {
378
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}, doorNote = null) {
345
379
  const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
346
380
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
347
381
  // The shape-lottery laws, stated where the check menu is offered (the mailbox
@@ -421,7 +455,7 @@ ${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
421
455
 
422
456
  Goal:
423
457
  ${job.goal}
424
- ${materialsBlock(materials)}
458
+ ${doorNoteBlock(doorNote)}${materialsBlock(materials)}
425
459
  Repository survey (from a read-only scout):
426
460
  ${scoutBlob || '(no scout notes)'}`;
427
461
  let p = doc;
@@ -454,6 +488,13 @@ ${scoutBlob || '(no scout notes)'}`;
454
488
  * `false` → the drafter has no tools, so a native session would report NO cost — return a
455
489
  * metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
456
490
  * so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
491
+ * @param {any} [opts.judgeProvider] softgreen — the provider a JUDGED close stage runs its
492
+ * locate call through, pinned by the operator to `JUDGE_MODEL` (src/judged.js). Separate
493
+ * from `provider`/`providerFor` on purpose: the judge tier is not a step knob and never
494
+ * agent-selectable, and the worker's binding is not a legal stand-in for it. Absent is not
495
+ * a fall-back — a judged stage with no seam instrument-STOPS as a wiring gap, the same
496
+ * answer a native job with no `nativeProvider` gets. A close with no judged stage never
497
+ * reads it.
457
498
  * @param {(type: string, data?: object) => object} opts.emit spine emitter (the caller's METERED emit)
458
499
  * @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
459
500
  * @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
@@ -581,12 +622,52 @@ ${scoutBlob || '(no scout notes)'}`;
581
622
  * it the deterministic name would collide with that very branch and the collision walk
582
623
  * would mint a `-2` beside the progress the resume exists to keep. Absent is the cold
583
624
  * path. A recorded branch that no longer exists is a STOP, never a fresh start.
625
+ * @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
626
+ * answer at a hitl pause (2026-08-12 §1, re-cut 2026-08-17: accept | rerun <text> | pause), carried in
627
+ * by the runner on the leg that RESUMES a paused run. Absent on every ordinary run and
628
+ * on the leg that pauses; it is never authored, never defaulted, and never inferred.
629
+ *
630
+ * It is spent ONCE, on this leg's close readings up to the moment the fix loop opens:
631
+ * `accept` greens the human stage on fresh evidence (OPEN-3 — the mechanical stages
632
+ * re-run first, because the tree can change while a run is paused), `rerun` reds it
633
+ * with the human's own words as the gap, and `pause` keeps the checkpoint exactly as it
634
+ * is, before anything is read at all. After the fix loop opens the ruling is gone, so once the mechanical
635
+ * stages pass again the run PAUSES for a second review rather than converting the same
636
+ * sentence forever.
637
+ * @param {{decision: string, text?: string|null, receivedAt?: string|null}|null} [opts.heldRuling]
638
+ * F102 — the decision the CHECKPOINT carries (`readResume`'s `restart.pendingDecision`):
639
+ * an answer a person already gave, on a leg that stopped before a single round was
640
+ * bought for it. It is applied exactly as a fresh one is, through the same seam, so
641
+ * the person is never asked the same question twice; the spine records that it came
642
+ * from a record rather than from a person, and `receivedAt` says when they said it.
643
+ *
644
+ * A leg handed BOTH is refused (`hitl-decision-red`, naming the held decision): two
645
+ * answers to one question is ambiguity, and a merge would pick a winner nobody chose.
646
+ * Absent is every ordinary run and every leg the operator answers directly.
647
+ * @param {number} [opts.priorSpentUsd=0] F103 — money the CHAIN spent before this leg,
648
+ * for the `engagement` record's money half ONLY. It bounds nothing here: the wallet
649
+ * is the ledger's one level up, and `remainingUsd` already nets this fold against the
650
+ * signed budget on every path (the signed budget is the chain's ceiling, and a rerun
651
+ * spends what remains of it — never a refill). Reporting, never governance.
652
+ * @param {boolean|null} [opts.reviewDoor] MODULE 8 — does this run END at the review
653
+ * door (PRD v1.71 §3)? `true`/`false` is the operator's explicit choice in either
654
+ * direction; absent is the CLASS default (`doorOpens`): always for soft-green,
655
+ * whose credit is held until a person accepts, and never for green-class unless
656
+ * asked. It changes what the run RECORDS and never what it returns — the door is
657
+ * a disposition, and the loop's verdict is not the door's to touch.
658
+ * @param {{text: string, fromRunid?: string|null, receivedAt?: string|null}|null} [opts.doorRerun]
659
+ * MODULE 8 — the signer took the RERUN door on a previous run: a FRESH ENGAGEMENT
660
+ * (§3.4) against the same signed spec, carrying their words. Two things follow, and
661
+ * only two: the already-green shortcut is refused (the tree passing the close is
662
+ * precisely the state the person rejected), and the words reach the PLANNER as new
663
+ * authoring. It is never a `humanRuling` — that answers a close's human STAGE, and
664
+ * a green-class job has none. Empty words are refused at the same seam every door is.
584
665
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
585
666
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
586
667
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
587
- * 'step-stalled' | `step-red:<id>`
668
+ * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
588
669
  */
589
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null }) {
670
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
590
671
  workdir = resolve(workdir);
591
672
  // ONE spelling of the redaction, housed next to the inventory it reads
592
673
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
@@ -638,6 +719,171 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
638
719
  return 'close-unsupported';
639
720
  }
640
721
 
722
+ // ── THE SIGNER'S ANSWER (N4 §1.4), read before anything costs anything.
723
+ //
724
+ // Three gates, and each refuses rather than guesses. A decision that is not one
725
+ // of the three doors — or a `rerun` whose text is empty — is refused at the
726
+ // seam that ACCEPTS it, because the whole of what a rerun gives the fix worker
727
+ // is the human's words, and an empty gap re-runs the worker as though nothing
728
+ // had been said (the POC's negative control measured exactly that). A decision
729
+ // handed to a close with no human stage is refused too: there is nothing for it
730
+ // to rule on, and silently ignoring it would leave a person believing they had
731
+ // answered. It is a terminal of its own rather than an `interpreter-red` — no
732
+ // library failed, the operator's own input is what could not be read.
733
+ const hasHumanStage = stagedClose.some((/** @type {any} */ s) => s?.kind === HUMAN_KIND);
734
+ // …and the DOOR's rerun rides into the same resolver (module 8): it is not a
735
+ // ruling and is never read as one, but "is this leg a fresh engagement" has ONE
736
+ // spelling, and the clock split below reads it. Resolved HERE, before anything is
737
+ // spent, so a door-rerun leg can never be recorded as `cold` or opened onto the
738
+ // wall of the leg the person just rejected (F103).
739
+ const ruling = resolveHumanRuling(humanRuling, heldRuling, doorRerun);
740
+ const decisionRed = (/** @type {string} */ detail) => {
741
+ emit('escalation', {
742
+ category: HITL_DECISION_RED, decisionReady: true,
743
+ decision: 'The decision handed to this run could not be applied, so nothing was run and nothing was spent.',
744
+ options: [
745
+ `re-run the resume with one of: ${HUMAN_DECISIONS.join(' | ')} (a rerun carries the text the worker converts)`,
746
+ 'abandon the task',
747
+ ],
748
+ detail: scrub(detail),
749
+ });
750
+ return HITL_DECISION_RED;
751
+ };
752
+ if (!ruling.ok) return decisionRed(/** @type {string} */ (ruling.why));
753
+ if (ruling.ruling !== null && !hasHumanStage) {
754
+ return decisionRed(`this job's close has no ${HUMAN_KIND} stage, so a signer's "${ruling.ruling.decision}" has `
755
+ + 'nothing to rule on — the verdict class is a promise about the CLOSE, and only a close that asks a person '
756
+ + 'can be answered by one');
757
+ }
758
+ // ── THE RERUN DOOR, one level out (module 8). A previous run ended at the
759
+ // review door, the person said "I don't like it, go again", and this leg IS the
760
+ // fresh engagement (§3.4). It is validated HERE, at the same seam and against the
761
+ // same rulebook the close's own doors use — empty words are refused before a
762
+ // branch is cut, before a scout is bought, before anything costs anything, because
763
+ // the whole of what a rerun gives the run is the words.
764
+ //
765
+ // Deliberately NOT a `humanRuling`: that answers a close's human STAGE and is
766
+ // refused for a close that has none, which is every green-class job. The two
767
+ // decisions look alike on a screen and are different facts.
768
+ /** @type {{text: string, fromRunid: string|null, receivedAt: string|null}|null} */
769
+ let doorWords = null;
770
+ if (doorRerun != null) {
771
+ const dn = normalizeHumanRuling({ decision: 'rerun', text: /** @type {any} */ (doorRerun)?.text ?? null });
772
+ if (!dn.ok) return decisionRed(`the rerun door was taken on a previous run, and ${dn.why}`);
773
+ doorWords = {
774
+ text: /** @type {string} */ (/** @type {any} */ (dn.ruling).text),
775
+ fromRunid: typeof (/** @type {any} */ (doorRerun).fromRunid) === 'string' ? /** @type {any} */ (doorRerun).fromRunid : null,
776
+ receivedAt: typeof (/** @type {any} */ (doorRerun).receivedAt) === 'string' ? /** @type {any} */ (doorRerun).receivedAt : null,
777
+ };
778
+ emit('door-rerun', {
779
+ // the person's words are the gap, so they ride the same scrub every other
780
+ // operator-authored string on this append-only log does
781
+ text: scrub(doorWords.text),
782
+ fromRunid: doorWords.fromRunid,
783
+ ...(doorWords.receivedAt === null ? {} : { receivedAt: doorWords.receivedAt }),
784
+ meaning: 'a FRESH ENGAGEMENT against the same signed spec — the previous run\'s verdict is untouched, and this leg buys its own clock',
785
+ });
786
+ }
787
+
788
+ /** the ruling still to be spent. Cleared the moment the fix loop opens: the
789
+ * human's words are what OPENS that loop, and once the worker holds them the
790
+ * next machine-clean tree is a new question for the person, not the old one. */
791
+ let liveRuling = ruling.ruling;
792
+
793
+ // ── F102: THE DECISION IS WRITTEN DOWN, because a wall is a serialization
794
+ // boundary and a decision that lives only in this process dies with it.
795
+ //
796
+ // Two records, and they answer two DIFFERENT questions:
797
+ // `human-decision` — what this leg was handed, and by whom
798
+ // `human-decision-spent` — what actually BOUGHT it
799
+ //
800
+ // The split is the whole finding. `liveRuling` is cleared when the fix loop
801
+ // OPENS (N4 §1.5, unchanged below) — that is a rule about this leg's close
802
+ // readings. Whether the person must be asked AGAIN is a different question, and
803
+ // F102's incident is exactly where the two answers differ: the rerun opened the
804
+ // fix loop and then wall-halted with `iterationsUsed: 0`, so the loop had
805
+ // "spent" a decision no worker ever saw and the resume re-asked the byte-
806
+ // identical question. So the spend marker fires on the WORK: a round bought for
807
+ // the fix, an accept that greened, a pause honoured. Anything short of that and
808
+ // the words are still owed to the person, and the checkpoint says so.
809
+ //
810
+ // The source travels with it (`operator` | `checkpoint`): "a person answered
811
+ // this leg" and "a record answered this leg" are different facts, and one
812
+ // spelling for both is F101's class in a decision's coat.
813
+ /** the decision this leg still OWES the person — the door, held until work buys
814
+ * it. @type {string|null} */
815
+ let owedRuling = null;
816
+ if (liveRuling !== null) {
817
+ owedRuling = liveRuling.decision;
818
+ emit('human-decision', {
819
+ decision: liveRuling.decision,
820
+ // the human's words are the gap, so they ride the same scrub every other
821
+ // model- or operator-authored string on this spine does (append-only: a log
822
+ // that captures a key captures it forever)
823
+ text: liveRuling.text === null ? null : scrub(liveRuling.text),
824
+ source: ruling.source,
825
+ // WHEN THE PERSON SAID IT, carried across the boundary rather than re-stamped.
826
+ // Omitted on a fresh answer, where this record's own `ts` IS the receipt —
827
+ // a decorative copy of `ts` would be indistinguishable from a carried one.
828
+ ...(ruling.receivedAt === null ? {} : { receivedAt: ruling.receivedAt }),
829
+ meaning: ruling.source === 'checkpoint'
830
+ ? 'held by the checkpoint and applied directly — the person is not asked again (F102)'
831
+ : 'answered on this invocation',
832
+ });
833
+ }
834
+ /** the decision is PAID FOR — by the work it commissioned, never by the loop
835
+ * merely opening. Idempotent: the first payment is the one that settles it.
836
+ * @param {string} reason */
837
+ const spendRuling = (reason) => {
838
+ if (owedRuling === null) return;
839
+ const decision = owedRuling;
840
+ owedRuling = null;
841
+ emit('human-decision-spent', { decision, reason });
842
+ };
843
+
844
+ // ── PAUSE: the third door, and it costs nothing at all (2026-08-12 §1, doors
845
+ // re-cut 2026-08-17). Before the branch, the seed, the precheck and the clock,
846
+ // because a person who answered "not now" has asked for nothing to be done: no
847
+ // gap, no continuation, no worker round. It is deliberately NOT routed through
848
+ // the close — running four stages to arrive back at the question the person has
849
+ // just declined to answer is wall time spent on an answer already in hand.
850
+ //
851
+ // hamr's ruling, verbatim: *"what's the point of cancel anyways? pause can
852
+ // resume — that would be more honest"*. So this mints the SAME checkpoint
853
+ // terminal the machine-side pause mints, which is what keeps it resumable under
854
+ // the existing TTL: the run re-enters at the start of its last step whenever
855
+ // somebody comes back, and if nobody does, the checkpoint expires on its own.
856
+ // That expiry IS the case cancel used to serve, without forcing a person into a
857
+ // forever-decision at the moment they least want to make one.
858
+ //
859
+ // The record is EXPLICIT (`humanDecision`) rather than a silent re-pause: "a
860
+ // person looked and kept it" and "nobody has looked yet" are two different
861
+ // facts, and one record spelling both is how a reader comes to confuse them.
862
+ // The evidence package is not re-assembled here — it is already on the spine of
863
+ // the leg that asked, and this leg measured nothing, so it states nothing (F6:
864
+ // absent, never an empty list dressed as a reading).
865
+ if (liveRuling?.decision === 'pause') {
866
+ // ANSWERED IN FULL, here. A pause commissions nothing, so there is nothing left
867
+ // owing and nothing for a later leg to hold over the person — the same three
868
+ // doors are open the next time somebody looks (doors addendum, v1.73 ruling 5:
869
+ // the pause door is allowance-free in EVERY state, including a wall-exhausted
870
+ // one, which is why this sits above the clock as well as above the wallet).
871
+ spendRuling('paused');
872
+ emit(HITL_PAUSE, {
873
+ stage: stagedClose.find((/** @type {any} */ s) => s?.kind === HUMAN_KIND)?.name ?? null,
874
+ humanDecision: 'pause',
875
+ decisionReady: true,
876
+ // EXPLICITLY null, never absent: every gap consumer guards with `if (gap)`,
877
+ // and the field states the ruling rather than leaving it to be inferred.
878
+ gap: null,
879
+ decision: 'The signer looked and paused: nothing was asked for, so nothing was run and nothing was spent. The '
880
+ + 'checkpoint stands exactly as it was — the work, the plan and the money are where the paused leg left them.',
881
+ options: [...HUMAN_DECISIONS],
882
+ meaning: 'not a verdict — the run is still paused, the clock is still stopped, and the same three doors are open',
883
+ });
884
+ return HITL_PAUSE;
885
+ }
886
+
641
887
  // ── native wiring: a clipipe-subscription job needs the native provider
642
888
  // FACTORY (native governance is constructor-time + per-worker, so the runner
643
889
  // cannot reuse one injected instance). A missing factory is an adopter wiring
@@ -667,7 +913,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
667
913
  //
668
914
  // The entry is re-validated here even though the caller is contracted to pass a valid
669
915
  // one. Not defensive decoration: the very next thing this code does is reach into
670
- // `versions.at(-1).plan`, and a half-written file that reached a reader anyway must come
916
+ // the newest eligible version's `plan`, and a half-written file that reached a reader anyway must come
671
917
  // back as named reds, never as a TypeError out of the runner (`loadRegistry` already
672
918
  // skips-and-reports for the same reason).
673
919
  let startingDraft = null;
@@ -686,8 +932,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
686
932
  return 'recipe-stale';
687
933
  }
688
934
  // `versions` is oldest-first and non-empty by the validator's entry bar, so the newest
689
- // green's plan-as-executed is the one that inherits (R1) — never the founding one.
690
- const newest = bv.bridge.versions.at(-1);
935
+ // RELEASED green's plan-as-executed is the one that inherits (R1) — never the founding
936
+ // one, and never a HELD one (softgreen module 6: a judged green earns no reuse until a
937
+ // person accepts it). Selection already skips a wholly-held entry with a stated reason;
938
+ // this is the same rule at the point of CONSUMPTION, so a second caller handing a
939
+ // bridge in directly cannot spend credit no signer granted.
940
+ const newest = newestEligibleVersion(bv.bridge);
941
+ if (newest === null) {
942
+ const reds = [{ code: QUARANTINED_CODE, path: 'versions', detail: reuseEligibility(bv.bridge).reason }];
943
+ emit('bridge-gate', { outcome: QUARANTINED_CODE, name, reds });
944
+ emit('escalation', {
945
+ category: 'recipe-stale', decisionReady: true,
946
+ decision: `The selected workflow${name ? ` "${name}"` : ''} is HELD: its green was rendered by the judged floor and nobody has accepted it yet, so it has earned no reuse. Nothing was spent.`,
947
+ options: ['accept that run at the review door, then rerun', 'rerun COLD (draft a new plan from scratch)', 'select a different workflow from the registry'],
948
+ detail: reds.map((r) => `${r.code}:${r.path} — ${r.detail}`).join('\n'),
949
+ });
950
+ return 'recipe-stale';
951
+ }
691
952
  startingDraft = newest.plan;
692
953
  emit('bridge-loaded', { name: bv.bridge.name, versions: bv.bridge.versions.length, runid: newest.runid });
693
954
  }
@@ -716,10 +977,55 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
716
977
  // RESUME (module C): `priorWallMs` is time a killed attempt of this same try already
717
978
  // consumed. It folds into the clock's start, never into `maxWallMs` — the cap stays the
718
979
  // signed one in the record and in the hash, and only the REMAINDER is left to run.
719
- const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, priorElapsedMs: priorWallMs, ...(now ? { now } : {}) });
980
+ //
981
+ // F103 — TWO COUNTERS, AND NEITHER DOES THE OTHER'S JOB. hamr, verbatim:
982
+ // *"redo/rerun comes with new authoring for money+time and keeps accounting of
983
+ // this far and this session separate counters"*.
984
+ //
985
+ // CHAIN — every leg of this job added up. It is what the halt readout
986
+ // reports and what a person reads, and it is never lost.
987
+ // ENGAGEMENT — what BOUNDS this leg. A resume of the same engagement folds
988
+ // (W-2 exactly as it was: a kill may never buy a second wall). A
989
+ // RERUN does not, because the person did not decide on the run's
990
+ // clock: `msx7a3rj` took the door and inherited 87 seconds from a
991
+ // leg that had already ended, on a worker measured to read nine
992
+ // rounds before its first write. That engagement was not short, it
993
+ // was structurally impossible, and it paid real money to find out.
994
+ //
995
+ // The cap itself is untouched on both paths — `maxWallMs` is the signed number,
996
+ // in the record and in the hash, and only a re-sign changes what a rerun can buy.
997
+ // MONEY IS DELIBERATELY NOT SYMMETRIC and is folded on every path (`remainingUsd`,
998
+ // one level up): the signed budget is the CHAIN's ceiling, a signature for $5
999
+ // never silently authorizes $10, and the fresh engagement spends what remains of
1000
+ // it. The asymmetry is the point — time was the axis F103 measured, and a refilled
1001
+ // wallet is the failure F45 measured.
1002
+ const chainWallMs = typeof priorWallMs === 'number' && Number.isFinite(priorWallMs) && priorWallMs > 0 ? priorWallMs : 0;
1003
+ const engagementPriorWallMs = ruling.fresh ? 0 : chainWallMs;
1004
+ const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, priorElapsedMs: engagementPriorWallMs, ...(now ? { now } : {}) });
720
1005
  const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
1006
+ emit('engagement', {
1007
+ kind: ruling.fresh ? 'rerun' : (chainWallMs > 0 || resumeSeed !== null ? 'resume' : 'cold'),
1008
+ chainWallMs,
1009
+ engagementPriorWallMs,
1010
+ wallCapMs: job.maxWallMs ?? null,
1011
+ // the money half of the same pair, stated where the time half is so a reader
1012
+ // never has to assemble one leg's accounting from two records. The BOUND lives
1013
+ // one level up (the ledger owns the wallet); these are its two readings.
1014
+ // belted like every other number entering a record: a garbage fold reads 0
1015
+ // rather than putting a NaN where a reader expects money (F6's arithmetic half)
1016
+ chainSpentUsd: typeof priorSpentUsd === 'number' && Number.isFinite(priorSpentUsd) && priorSpentUsd > 0 ? priorSpentUsd : 0,
1017
+ budgetUsd: job.budgetUsd,
1018
+ meaning: ruling.fresh
1019
+ ? 'a rerun is a FRESH ENGAGEMENT: its own clock, the same signed wallet (F103)'
1020
+ : 'the same engagement continuing: the wall folds, as it always has (W-2)',
1021
+ });
721
1022
  emit('wall-clock', {
722
1023
  ...clock.report(closeTimeoutForReport),
1024
+ // the CHAIN view, beside the engagement's own elapsed rather than folded into
1025
+ // it: on a rerun leg `elapsedMs` is this engagement's and this is the job's,
1026
+ // and one number meaning both is exactly how a decision came to inherit 87
1027
+ // seconds.
1028
+ chainWallMs,
723
1029
  meaning: clock.bounded
724
1030
  ? 'a between-round deadline; a close already in flight when it trips runs to completion'
725
1031
  : 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
@@ -827,7 +1133,27 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
827
1133
  * @param {any[]} stages
828
1134
  */
829
1135
  const runCloseStages = (stages) => (declared
830
- ? runDeclaredStages(stages, scrub, { ...closeOpts, seedRef: /** @type {any} */ (declaredCtx).seedRef })
1136
+ ? runDeclaredStages(stages, scrub, {
1137
+ ...closeOpts,
1138
+ seedRef: /** @type {any} */ (declaredCtx).seedRef,
1139
+ // the signer's answer reaches the human stage the same way every other
1140
+ // arbiter-owned fact reaches a stage: through the ctx, never through the
1141
+ // declaration. A command close has no human stage to hand it to.
1142
+ humanRuling: liveRuling,
1143
+ // SOFTGREEN — the judged stage's paid seam and its meter, the same way: through
1144
+ // the ctx, never the declaration. The seam is null unless the operator wired a
1145
+ // pinned judge provider (a judged stage then STOPS as a wiring gap rather than
1146
+ // grading on the worker's model). The meter emits a distinct `judge-round`
1147
+ // record carrying the call's own honest cost — the SAME emit the worker rounds
1148
+ // ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
1149
+ // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1150
+ // the same F6 pricing halt rather than being laundered into $0.
1151
+ judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
1152
+ onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
1153
+ stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1154
+ costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1155
+ }),
1156
+ })
831
1157
  : runStages(stages, scrub, closeOpts));
832
1158
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
833
1159
  let closeStage;
@@ -884,6 +1210,100 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
884
1210
  ...extra,
885
1211
  });
886
1212
  };
1213
+ /** WHAT THIS RUN MOVED, read once and read the same way for both screens a
1214
+ * person can arrive at (the pause and the review door). An unreadable set comes
1215
+ * back as its own `stop` rather than an empty list, so the caller can say
1216
+ * UNKNOWN instead of "nothing changed" (F6), and the cap is applied here so the
1217
+ * two screens cannot drift apart on how much they show.
1218
+ * @returns {Promise<{cs: any, paths: string[], shown: string[]}>} */
1219
+ const changedEvidence = async () => {
1220
+ const cs = declaredCtx ? await changedSet(workdir, declaredCtx.seedRef) : { stop: 'no seed', paths: [] };
1221
+ const paths = cs.stop === null ? cs.paths : [];
1222
+ return { cs, paths, shown: paths.slice(0, PAUSE_CHANGED_CAP) };
1223
+ };
1224
+
1225
+ /**
1226
+ * THE PAUSE (N4 §1.3/§1.4, rulings 1 and 2) — a decision-ready checkpoint, and
1227
+ * never a bare "approve?". What the person is shown is assembled here rather
1228
+ * than by whatever surface renders it: every mechanical stage's own result, the
1229
+ * question the close itself declared, and the set of files this run changed.
1230
+ *
1231
+ * The changed set is the "before/after" half, at the granularity a library can
1232
+ * state honestly — WHICH files moved. Rendering the lines belongs to the
1233
+ * surface that has a screen; carrying the list is what makes the surface's job
1234
+ * possible without it re-deriving the run's own facts.
1235
+ * @param {any} v the close verdict that paused @returns {Promise<void>}
1236
+ */
1237
+ const emitHitlPause = async (v) => {
1238
+ const { cs, paths, shown } = await changedEvidence();
1239
+ emit(HITL_PAUSE, {
1240
+ stage: v.stage ?? null,
1241
+ ask: v.ask ?? null,
1242
+ decisionReady: true,
1243
+ // ruling 2: the whole evidence package, never a bare "approve?"
1244
+ stages: v.stages ?? [],
1245
+ changed: {
1246
+ paths: shown,
1247
+ // the trim is ANNOUNCED (F28), and an unreadable set is UNKNOWN, never
1248
+ // an empty list dressed as "nothing changed" (F6)
1249
+ ...(paths.length > shown.length ? { more: paths.length - shown.length } : {}),
1250
+ ...(cs.stop === null ? {} : { unreadable: scrub(String(cs.stop)) }),
1251
+ },
1252
+ decision: `The close reached its human stage and is waiting on you: ${v.ask ?? 'review the result'}`,
1253
+ options: [...HUMAN_DECISIONS],
1254
+ meaning: 'not a verdict — the run is paused, the clock is stopped, and the last thing it did stands until you answer',
1255
+ });
1256
+ };
1257
+
1258
+ /**
1259
+ * THE REVIEW DOOR (softgreen module 8, PRD v1.71 §3) — the pause machinery one
1260
+ * level out: a run that reaches a VERDICT-BEARING terminal offers the person the
1261
+ * same three doors, off the same evidence package, at the end of every run the
1262
+ * door is open for.
1263
+ *
1264
+ * THE LAW, hamr verbatim: *"it's important not to change the loop self verdict."*
1265
+ * This writes a RECORD and returns nothing. The caller's very next statement is
1266
+ * `return 'green'` / `return 'already-green'` — unchanged, unconditional, and
1267
+ * pinned by test. The close mints the verdict; the door records a disposition,
1268
+ * and `src/reviewdoor.js` is where a person's answer is applied, later, off this
1269
+ * record. Nothing here waits, blocks, or spends: a green run that is never
1270
+ * answered is still a green run.
1271
+ *
1272
+ * WHICH RUNS: always the classes whose credit is HELD (soft-green — module 6's
1273
+ * quarantine has no other release), and otherwise only when the operator asks
1274
+ * (`reviewDoor`). `doorOpens` owns that question, in one place.
1275
+ * @param {string} outcome the verdict the close minted @param {any} v that close verdict
1276
+ */
1277
+ const emitReviewDoor = async (outcome, v) => {
1278
+ if (!doorOpens(job, reviewDoor)) return;
1279
+ const cls = job?.verdictType ?? 'green';
1280
+ const held = quarantinesCredit(cls);
1281
+ // the same evidence a pause carries, assembled by the same helper — one
1282
+ // assembly of one run's facts, however the person arrives at it
1283
+ const { cs, paths, shown } = await changedEvidence();
1284
+ emit(REVIEW_DOOR, {
1285
+ outcome,
1286
+ verdictType: cls,
1287
+ // …and whether this run's credit is being HELD pending the answer. Read from
1288
+ // the ONE place that decides it (module 6), never re-spelled here.
1289
+ quarantined: held,
1290
+ stages: v?.stages ?? [],
1291
+ // the stages an `accept` will RE-RUN, named up front: the person is told what
1292
+ // their answer is about to be checked against before they give it
1293
+ mechanical: mechanicalStages(stagedClose).map((/** @type {any} */ s) => s?.name ?? null),
1294
+ changed: {
1295
+ paths: shown,
1296
+ ...(paths.length > shown.length ? { more: paths.length - shown.length } : {}),
1297
+ ...(cs.stop === null ? {} : { unreadable: scrub(String(cs.stop)) }),
1298
+ },
1299
+ decisionReady: true,
1300
+ decision: `The close minted ${outcome} and it STANDS. What happens to the work is yours: accept it, rerun it with your own words as the gap, or pause and come back.`
1301
+ + (held ? ' This green was rendered by the judged floor, so it has earned no reuse until you accept it.' : ''),
1302
+ options: [...HUMAN_DECISIONS],
1303
+ meaning: 'not a verdict — the verdict is already minted and nothing an answer does can change it; this records a disposition',
1304
+ });
1305
+ };
1306
+
887
1307
  /** the close, as ONE verdict: every stage in order, first red wins */
888
1308
  const judgeClose = async () => {
889
1309
  const v = await runCloseStages(stagedClose);
@@ -1056,7 +1476,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1056
1476
  // record, zero tokens; a forbidden-zone verdict escalates before any spend
1057
1477
  const pre = await judgeClose();
1058
1478
  emit('close-precheck', { ...pre });
1059
- if (pre.verdict === 'satisfied') return 'already-green';
1479
+ // …and if a signer's `accept` is what greened the human stage, it is PAID FOR:
1480
+ // the answer was applied to the tree they were shown, and no later leg may hold
1481
+ // it over a tree they have not seen (F102's persistence must never become a
1482
+ // decision that outlives its evidence).
1483
+ if (pre.verdict === 'satisfied') {
1484
+ // ── THE RERUN DOOR REFUSES THIS SHORTCUT (module 8). A tree that already
1485
+ // passes the close is EXACTLY the state the person rejected when they took the
1486
+ // rerun door: returning `already-green` here would answer "go again" with "there
1487
+ // is nothing to do", spend nothing, and hand back the verdict they just declined
1488
+ // to take. So the run proceeds — a plan is drafted with their words in it, the
1489
+ // work happens, and the close judges it again at the end, as it judges everything.
1490
+ if (doorWords === null) { spendRuling('accepted'); await emitReviewDoor('already-green', pre); return 'already-green'; }
1491
+ emit('door-rerun-open', {
1492
+ fromRunid: doorWords.fromRunid,
1493
+ meaning: 'the close is already satisfied and the run continues anyway — the signer asked for different work, not for a re-grade of the same work',
1494
+ });
1495
+ }
1496
+ // …and the pause's own arm of the same gate. Reaching a human stage HERE means
1497
+ // every mechanical stage already passes on the untouched tree: the machine half
1498
+ // of this job is done and the only thing left is a person. Pausing costs $0 and
1499
+ // is the honest answer; running a plan first would spend a budget to arrive at
1500
+ // exactly this question. No `planExecuted` — there is no plan yet, and an empty
1501
+ // one on the spine would be a record of work nobody did.
1502
+ if (pre.verdict === HUMAN_PAUSE) { await emitHitlPause(pre); return HITL_PAUSE; }
1060
1503
  const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
1061
1504
  if (preFault) {
1062
1505
  emit('escalation', { category: preFault.category, decisionReady: true, decision: preFault.decision, options: preFault.options, detail: pre.detail });
@@ -1576,6 +2019,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1576
2019
  // repo never ships (F6/F44 — unknown is reported as unknown, never as zero;
1577
2020
  // stall.js says it plainly: a reissue can pay twice, and the wallet is what
1578
2021
  // caps that).
2022
+ // F102 — the decision is spent BY THE WORK IT COMMISSIONED. This is the
2023
+ // moment a round is really bought for the fix, which is the only moment the
2024
+ // human's words have actually reached a worker. A wall or a cap that lands
2025
+ // before it leaves the words owed, and the checkpoint carries them on.
2026
+ if (phase === 'fix') spendRuling('fix-round');
1579
2027
  emit('worker-round', {
1580
2028
  phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
1581
2029
  costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
@@ -1828,7 +2276,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1828
2276
  };
1829
2277
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
1830
2278
  drafter.setIteration(reds ? 'redraft' : 'draft');
1831
- const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts), []);
2279
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null), []);
1832
2280
  return extractArtifact(r.text).code ?? '';
1833
2281
  };
1834
2282
  // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
@@ -2576,9 +3024,21 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2576
3024
  const post = await judgeClose();
2577
3025
  emit('outer-close', { ...post });
2578
3026
  if (post.verdict === 'satisfied') {
3027
+ spendRuling('accepted');
2579
3028
  planExecuted();
3029
+ await emitReviewDoor('green', post);
2580
3030
  return 'green';
2581
3031
  }
3032
+ // ── THE THIRD BRANCH (N4 §1.3): between `satisfied` and the fix loop, because
3033
+ // a pause is neither. It is not green (nobody said so), not a red (nobody has
3034
+ // written a gap), and not a CLOSE_FAULT (nothing broke) — so it must be caught
3035
+ // before the fix loop, which would otherwise buy a worker round to convert a
3036
+ // wall that does not exist yet.
3037
+ if (post.verdict === HUMAN_PAUSE) {
3038
+ await emitHitlPause(post);
3039
+ planExecuted();
3040
+ return HITL_PAUSE;
3041
+ }
2582
3042
  const postFault = Object.hasOwn(CLOSE_FAULTS, post.verdict) ? CLOSE_FAULTS[post.verdict] : undefined;
2583
3043
  if (postFault) {
2584
3044
  emit('escalation', { category: postFault.category, decisionReady: true, decision: postFault.decision, options: postFault.options, detail: post.detail });
@@ -2586,6 +3046,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2586
3046
  return 'close-red';
2587
3047
  }
2588
3048
  emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
3049
+ // ── THE RULING IS SPENT HERE (N4 §1.5). The human's words are what OPENS this
3050
+ // loop; the worker now holds them. Leaving the ruling live would make the human
3051
+ // stage red on every judgment for the rest of the leg, so the loop would keep
3052
+ // converting one sentence until the governor or the money stopped it. Cleared,
3053
+ // the next machine-clean tree pauses for a SECOND review — which is the honest
3054
+ // cycle: a person asked for a change, the change happened, the person looks
3055
+ // again. A mechanical red in between is still converted normally, because
3056
+ // first-red-wins reaches it before the person's stage.
3057
+ liveRuling = null;
2589
3058
  let fixOutcome;
2590
3059
  /** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
2591
3060
  * iterations. It carries that stop's own record fields, and its presence is what
@@ -2667,6 +3136,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2667
3136
  const fixGovernor = {
2668
3137
  record: (/** @type {{iteration: number, gap?: string}} */ o) => {
2669
3138
  fixIterationsUsed = o.iteration;
3139
+ // A PAUSE IS NEVER GRADED. ralph records every non-satisfied iteration,
3140
+ // and a pause is one — but it carries no gap and no number, so feeding it
3141
+ // to the trend would mint a strike out of a non-verdict and could strike
3142
+ // the loop out into a `cap-halt` that never happened. The reading says
3143
+ // what it is and the meter is left untouched; `middle` stops the loop on
3144
+ // the next iteration, before anything is bought.
3145
+ if (lastCloseVerdict?.verdict === HUMAN_PAUSE) {
3146
+ return { governor: 'close-trend', trend: 'unknown', reading: 'the close is waiting on a person — not a grade', iteration: o.iteration, paused: true };
3147
+ }
2670
3148
  return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
2671
3149
  },
2672
3150
  struckOut: fixTrend.struckOut,
@@ -2708,6 +3186,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2708
3186
  // until the next iteration is about to start. The close is never bounded — a
2709
3187
  // wall that kills grading leaves the run unreadable after the money is spent
2710
3188
  // (the F45 class), so the one thing this must never do is stop a close.
3189
+ // ── THE CLOSE IS WAITING ON A PERSON. Read here, in the same place and for
3190
+ // the same reason the wall is: past this point the iteration is guaranteed
3191
+ // to be worthless, because the machine half of the close already passes and
3192
+ // nothing a worker writes can answer a question only a person can. So the
3193
+ // loop stops ON that reading — the run pauses again, with everything it has
3194
+ // done kept, exactly as the first pause did.
3195
+ if (lastCloseVerdict?.verdict === HUMAN_PAUSE) {
3196
+ const err = /** @type {CategorizedError} */ (new Error(
3197
+ `the close is waiting on the signer at stage "${lastCloseVerdict.stage ?? 'unknown'}" — every mechanical `
3198
+ + `stage passes, so fix attempt ${iteration} could not change the answer`));
3199
+ err.category = HITL_PAUSE;
3200
+ throw err;
3201
+ }
2711
3202
  if (clock.expired()) {
2712
3203
  // ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
2713
3204
  // This site used to run its own `gapTrend`: byte equality of the last two
@@ -2789,6 +3280,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2789
3280
  return relay(e, 'fix');
2790
3281
  }
2791
3282
  planExecuted();
3283
+ // The fix loop ran into the person again (the `middle` stop above). Same
3284
+ // terminal as the first pause, and never `escalated`: nothing failed, and the
3285
+ // work this loop did is exactly what the signer is now being asked about.
3286
+ if (fixOutcome !== 'green' && lastEscalation?.category === HITL_PAUSE) {
3287
+ await emitHitlPause(lastCloseVerdict);
3288
+ return HITL_PAUSE;
3289
+ }
2792
3290
  if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
2793
3291
  // F64 in the close-fix loop: the same governance stop, and it must not ride out
2794
3292
  // as a bare `escalated` either — that reads as "the fix failed" when the fix was
@@ -2833,5 +3331,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2833
3331
  // an exact-looking total for a call that never billed back.
2834
3332
  return lastEscalation.category;
2835
3333
  }
3334
+ // …and the fix loop's own green reaches the SAME door. A green the loop had to
3335
+ // work for is exactly as much a green as one the close minted first time, and a
3336
+ // door that opened on only one of them would be a door that quietly graded how
3337
+ // the verdict was arrived at.
3338
+ if (fixOutcome === 'green') await emitReviewDoor('green', lastCloseVerdict);
2836
3339
  return fixOutcome === 'green' ? 'green' : 'escalated';
2837
3340
  }