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/planrun.js CHANGED
@@ -30,13 +30,15 @@ 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
37
  import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
37
38
  import {
38
39
  seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
39
- HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling,
40
+ HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling, resolveHumanRuling,
41
+ REVIEW_DOOR, doorOpens, mechanicalStages,
40
42
  } from './kinds.js';
41
43
  import { workBranchName, prepareWorkBranch } from './workbranch.js';
42
44
 
@@ -133,6 +135,24 @@ function materialsBlock(m) {
133
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`;
134
136
  }
135
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
+
136
156
  /**
137
157
  * Layer 3 (D4) — the MECHANICAL START. A loaded bridge is handed to the drafter as a
138
158
  * STARTING DRAFT, not as a contract: it is appended to the ordinary drafting prompt and
@@ -348,8 +368,14 @@ export function boundedNote(bounded, rounds) {
348
368
  * Rule A-v2's law (final write step only, framed wide). `priorChecks`: the predecessor
349
369
  * plan's carried checks, replan phase only — Rule B's law (keep every one). Empty/omitted
350
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.
351
377
  */
352
- 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) {
353
379
  const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
354
380
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
355
381
  // The shape-lottery laws, stated where the check menu is offered (the mailbox
@@ -429,7 +455,7 @@ ${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
429
455
 
430
456
  Goal:
431
457
  ${job.goal}
432
- ${materialsBlock(materials)}
458
+ ${doorNoteBlock(doorNote)}${materialsBlock(materials)}
433
459
  Repository survey (from a read-only scout):
434
460
  ${scoutBlob || '(no scout notes)'}`;
435
461
  let p = doc;
@@ -462,6 +488,13 @@ ${scoutBlob || '(no scout notes)'}`;
462
488
  * `false` → the drafter has no tools, so a native session would report NO cost — return a
463
489
  * metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
464
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.
465
498
  * @param {(type: string, data?: object) => object} opts.emit spine emitter (the caller's METERED emit)
466
499
  * @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
467
500
  * @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
@@ -601,12 +634,40 @@ ${scoutBlob || '(no scout notes)'}`;
601
634
  * is, before anything is read at all. After the fix loop opens the ruling is gone, so once the mechanical
602
635
  * stages pass again the run PAUSES for a second review rather than converting the same
603
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.
604
665
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
605
666
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
606
667
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
607
668
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
608
669
  */
609
- 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, humanRuling = 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 }) {
610
671
  workdir = resolve(workdir);
611
672
  // ONE spelling of the redaction, housed next to the inventory it reads
612
673
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
@@ -670,7 +731,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
670
731
  // answered. It is a terminal of its own rather than an `interpreter-red` — no
671
732
  // library failed, the operator's own input is what could not be read.
672
733
  const hasHumanStage = stagedClose.some((/** @type {any} */ s) => s?.kind === HUMAN_KIND);
673
- const ruling = normalizeHumanRuling(humanRuling);
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);
674
740
  const decisionRed = (/** @type {string} */ detail) => {
675
741
  emit('escalation', {
676
742
  category: HITL_DECISION_RED, decisionReady: true,
@@ -689,11 +755,92 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
689
755
  + 'nothing to rule on — the verdict class is a promise about the CLOSE, and only a close that asks a person '
690
756
  + 'can be answered by one');
691
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
+
692
788
  /** the ruling still to be spent. Cleared the moment the fix loop opens: the
693
789
  * human's words are what OPENS that loop, and once the worker holds them the
694
790
  * next machine-clean tree is a new question for the person, not the old one. */
695
791
  let liveRuling = ruling.ruling;
696
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
+
697
844
  // ── PAUSE: the third door, and it costs nothing at all (2026-08-12 §1, doors
698
845
  // re-cut 2026-08-17). Before the branch, the seed, the precheck and the clock,
699
846
  // because a person who answered "not now" has asked for nothing to be done: no
@@ -716,6 +863,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
716
863
  // the leg that asked, and this leg measured nothing, so it states nothing (F6:
717
864
  // absent, never an empty list dressed as a reading).
718
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');
719
872
  emit(HITL_PAUSE, {
720
873
  stage: stagedClose.find((/** @type {any} */ s) => s?.kind === HUMAN_KIND)?.name ?? null,
721
874
  humanDecision: 'pause',
@@ -760,7 +913,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
760
913
  //
761
914
  // The entry is re-validated here even though the caller is contracted to pass a valid
762
915
  // one. Not defensive decoration: the very next thing this code does is reach into
763
- // `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
764
917
  // back as named reds, never as a TypeError out of the runner (`loadRegistry` already
765
918
  // skips-and-reports for the same reason).
766
919
  let startingDraft = null;
@@ -779,8 +932,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
779
932
  return 'recipe-stale';
780
933
  }
781
934
  // `versions` is oldest-first and non-empty by the validator's entry bar, so the newest
782
- // green's plan-as-executed is the one that inherits (R1) — never the founding one.
783
- 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
+ }
784
952
  startingDraft = newest.plan;
785
953
  emit('bridge-loaded', { name: bv.bridge.name, versions: bv.bridge.versions.length, runid: newest.runid });
786
954
  }
@@ -809,10 +977,55 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
809
977
  // RESUME (module C): `priorWallMs` is time a killed attempt of this same try already
810
978
  // consumed. It folds into the clock's start, never into `maxWallMs` — the cap stays the
811
979
  // signed one in the record and in the hash, and only the REMAINDER is left to run.
812
- 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 } : {}) });
813
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
+ });
814
1022
  emit('wall-clock', {
815
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,
816
1029
  meaning: clock.bounded
817
1030
  ? 'a between-round deadline; a close already in flight when it trips runs to completion'
818
1031
  : 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
@@ -927,6 +1140,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
927
1140
  // arbiter-owned fact reaches a stage: through the ctx, never through the
928
1141
  // declaration. A command close has no human stage to hand it to.
929
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
+ }),
930
1156
  })
931
1157
  : runStages(stages, scrub, closeOpts));
932
1158
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
@@ -984,6 +1210,18 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
984
1210
  ...extra,
985
1211
  });
986
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
+
987
1225
  /**
988
1226
  * THE PAUSE (N4 §1.3/§1.4, rulings 1 and 2) — a decision-ready checkpoint, and
989
1227
  * never a bare "approve?". What the person is shown is assembled here rather
@@ -997,9 +1235,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
997
1235
  * @param {any} v the close verdict that paused @returns {Promise<void>}
998
1236
  */
999
1237
  const emitHitlPause = async (v) => {
1000
- const cs = declaredCtx ? await changedSet(workdir, declaredCtx.seedRef) : { stop: 'no seed', paths: [] };
1001
- const paths = cs.stop === null ? cs.paths : [];
1002
- const shown = paths.slice(0, PAUSE_CHANGED_CAP);
1238
+ const { cs, paths, shown } = await changedEvidence();
1003
1239
  emit(HITL_PAUSE, {
1004
1240
  stage: v.stage ?? null,
1005
1241
  ask: v.ask ?? null,
@@ -1019,6 +1255,55 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1019
1255
  });
1020
1256
  };
1021
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
+
1022
1307
  /** the close, as ONE verdict: every stage in order, first red wins */
1023
1308
  const judgeClose = async () => {
1024
1309
  const v = await runCloseStages(stagedClose);
@@ -1191,7 +1476,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1191
1476
  // record, zero tokens; a forbidden-zone verdict escalates before any spend
1192
1477
  const pre = await judgeClose();
1193
1478
  emit('close-precheck', { ...pre });
1194
- 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
+ }
1195
1496
  // …and the pause's own arm of the same gate. Reaching a human stage HERE means
1196
1497
  // every mechanical stage already passes on the untouched tree: the machine half
1197
1498
  // of this job is done and the only thing left is a person. Pausing costs $0 and
@@ -1718,6 +2019,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1718
2019
  // repo never ships (F6/F44 — unknown is reported as unknown, never as zero;
1719
2020
  // stall.js says it plainly: a reissue can pay twice, and the wallet is what
1720
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');
1721
2027
  emit('worker-round', {
1722
2028
  phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
1723
2029
  costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
@@ -1970,7 +2276,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1970
2276
  };
1971
2277
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
1972
2278
  drafter.setIteration(reds ? 'redraft' : 'draft');
1973
- 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), []);
1974
2280
  return extractArtifact(r.text).code ?? '';
1975
2281
  };
1976
2282
  // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
@@ -2718,7 +3024,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2718
3024
  const post = await judgeClose();
2719
3025
  emit('outer-close', { ...post });
2720
3026
  if (post.verdict === 'satisfied') {
3027
+ spendRuling('accepted');
2721
3028
  planExecuted();
3029
+ await emitReviewDoor('green', post);
2722
3030
  return 'green';
2723
3031
  }
2724
3032
  // ── THE THIRD BRANCH (N4 §1.3): between `satisfied` and the fix loop, because
@@ -3023,5 +3331,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3023
3331
  // an exact-looking total for a call that never billed back.
3024
3332
  return lastEscalation.category;
3025
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);
3026
3339
  return fixOutcome === 'green' ? 'green' : 'escalated';
3027
3340
  }