bareloop 0.21.0 → 0.22.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
@@ -43,6 +43,8 @@ import {
43
43
  } from './kinds.js';
44
44
  import { workBranchName, prepareWorkBranch } from './workbranch.js';
45
45
  import { TRANSPORT_MAX_ATTEMPTS, isTransportFailure } from './transport.js';
46
+ import { checkCloseAbsolutePaths, checkCloseByteSignature, checkStageByteSignature, checkCloseDirRequired } from './close-integrity.js';
47
+ import { resolveCloseTimeoutMs, closeTimeoutBanner, TIMING_PREFLIGHT_CEILING_MS } from './closetimeout.js';
46
48
 
47
49
  const require = createRequire(import.meta.url);
48
50
  const { Loop, Retry, wireGate, HaltError } = require('bare-agent');
@@ -640,7 +642,14 @@ ${scoutBlob || '(no scout notes)'}`;
640
642
  * ladder (src/ladder.js). Arbiter territory exactly as the count it replaced was —
641
643
  * the runner sets it, the plan cannot express it, and no step may tighten or raise
642
644
  * it.
643
- * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
645
+ * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory).
646
+ * OPTIONAL as of PRD item 27/M3: absent, this run autosets it from a $0 seed
647
+ * timing pass (or the spec's own signed `closeTimeoutMs` override) — an
648
+ * explicit value here still wins outright (backward compatibility/test
649
+ * control only, F133: `scripts/run-u.mjs`/`src/cli.js` never pass this).
650
+ * @param {string|null} [opts.closeDir] the close's own books directory (PRD
651
+ * item 27/M3 Part B), forwarded to every close invocation as
652
+ * `BARELOOP_CLOSE_DIR`
644
653
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
645
654
  * @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md):
646
655
  * `false` on a FRESH run (no `resumeSeed`) skips the survey entirely — `scoutBlob`
@@ -811,12 +820,18 @@ ${scoutBlob || '(no scout notes)'}`;
811
820
  * precisely the state the person rejected), and the words reach the PLANNER as new
812
821
  * authoring. It is never a `humanRuling` — that answers a close's human STAGE, and
813
822
  * a green-class job has none. Empty words are refused at the same seam every door is.
823
+ * @param {boolean} [opts.resumable=true] PRD item 27(c)/F130 — does THIS runner
824
+ * support `--resume`? Forwarded verbatim from `runJob`; `run-u.mjs` leaves it at
825
+ * the default (byte-identical to before this param existed), the bundle CLI
826
+ * passes `false` so the three `--resume`-naming readouts below (`MONEY_OPTIONS`,
827
+ * the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
828
+ * in v1" instead of naming a flag that would fail if typed.
814
829
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
815
830
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
816
831
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
817
832
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
818
833
  */
819
- 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, readShim = false, scout = true, 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 }) {
834
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, 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, resumable = true }) {
820
835
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
821
836
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
822
837
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -916,6 +931,159 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
916
931
  return 'close-unsupported';
917
932
  }
918
933
 
934
+ // ── close-absolute-path (PRD item 27(c), F129's general fix): the SAME
935
+ // detector `src/bundle.js` already applies at export time, now applied for
936
+ // EVERY job at run start, before the close-first precheck and before any
937
+ // provider call. A close script whose CONTENT bakes in an absolute path
938
+ // that exists on disk judges the wrong tree by construction (F129) — the
939
+ // spec's own `cmd` field naming the script is the address and stays legal;
940
+ // only the script's bytes are judged. `readCloseScripts`/
941
+ // `checkCloseAbsolutePaths` live in `src/close-integrity.js` so this and
942
+ // `exportBundle` never drift into two spellings of the same rule.
943
+ {
944
+ const integrity = checkCloseAbsolutePaths(job, workdir);
945
+ if (!integrity.ok) {
946
+ const detail = integrity.reds.map((r) => `${r.stage}: "${r.literal}"`).join('; ');
947
+ emit('escalation', {
948
+ category: 'close-absolute-path', decisionReady: true,
949
+ decision: 'A close script bakes in an absolute path that exists on disk — it would judge the wrong tree by construction (F129), so nothing was run and nothing was spent.',
950
+ options: ['fix the close script to read the cwd it is given, never a baked-in path', 'abandon the task'],
951
+ detail,
952
+ });
953
+ return 'close-absolute-path';
954
+ }
955
+ }
956
+
957
+ // ── close-tampered (PRD item 27/M2, N4's own general fix): the job's
958
+ // signature covers a close STAGE's byte-exact CONTENT (`close[].sha256`),
959
+ // not only the `cmd` path naming it — `jobSpecHash` already covers this
960
+ // field with no hash-function change (it sits inside the signed spec), so
961
+ // the only thing left to verify is that the SCRIPT ON DISK still is what
962
+ // was signed. Same seam as `close-absolute-path` immediately above (run
963
+ // start, $0, before the close-first precheck and before any provider
964
+ // call) and the same detector module — `checkCloseByteSignature` reuses
965
+ // `readCloseScripts`, so this and the runtime re-verify inside
966
+ // `runCloseStages` below can never drift into two spellings of "which
967
+ // bytes were signed".
968
+ {
969
+ const sig = checkCloseByteSignature(job, workdir);
970
+ if (!sig.ok) {
971
+ const detail = sig.reds.map((r) => `${r.stage}: expected ${r.expected.slice(0, 12)}… got ${r.actual ? `${r.actual.slice(0, 12)}…` : '<unreadable>'}`).join('; ');
972
+ emit('escalation', {
973
+ category: 'close-tampered', decisionReady: true,
974
+ decision: "A close script's bytes no longer match its signed sha256 — it would run something other than what was approved, so nothing was run and nothing was spent.",
975
+ options: ['restore the close script to its signed bytes', 're-sign the close if the change is intentional (a new spec hash needs re-approval)', 'abandon the task'],
976
+ detail,
977
+ });
978
+ return 'close-tampered';
979
+ }
980
+ }
981
+
982
+ // ── close-dir-required (PRD item 27/M3 Part B): a close script that reads
983
+ // its own books directory (`BARELOOP_CLOSE_DIR`) and gets none would run
984
+ // silently against `undefined` — refuse at the same $0 seam as the two
985
+ // checks immediately above, before any tokens spend.
986
+ {
987
+ const dirCheck = checkCloseDirRequired(job, workdir, closeDir);
988
+ if (!dirCheck.ok) {
989
+ const detail = dirCheck.reds.map((r) => `${r.stage}: ${r.path}`).join('; ');
990
+ emit('escalation', {
991
+ category: 'close-dir-required', decisionReady: true,
992
+ decision: 'A close script reads BARELOOP_CLOSE_DIR (its own books directory, deliberately outside the patient tree) but the runner was given none — nothing was run and nothing was spent.',
993
+ options: ['run this job through a runner that sets closeDir (run-u.mjs, or the bundle CLI)', 'abandon the task'],
994
+ detail,
995
+ });
996
+ return 'close-dir-required';
997
+ }
998
+ }
999
+
1000
+ // ── close timeout: autoset + signed override (PRD item 27/M3). hamr,
1001
+ // 2026-09-06: "both … autoset and can be override, same like api pricing".
1002
+ // Constants (`CLOSE_TIMEOUT_FLOOR_MS`, `CLOSE_TIMEOUT_K`) are hamr's own
1003
+ // arbiter constants (2026-09-07) — never agent- or adopter-settable.
1004
+ //
1005
+ // Precedence, all $0, before any provider call:
1006
+ // 1. an EXPLICIT `closeTimeoutMs` runtime option (the pre-M3 shell/test
1007
+ // knob) wins outright — kept for backward compatibility and for tests
1008
+ // that force a specific close-timeout scenario; it is never the signed
1009
+ // spec field and prints its own honest word rather than "signed
1010
+ // override". F133 (run mtqwmb9l): this used to also be how
1011
+ // `scripts/run-u.mjs` fed its OWN pre-resolved ceiling through, which
1012
+ // printed the number correctly here but under the wrong word
1013
+ // ("explicit runner override") beside run-u's own already-correct
1014
+ // "estimated"/"signed override" line — two banners for one number,
1015
+ // the second one lying about its source. Fixed by making this the
1016
+ // ONLY caller of `closeTimeoutMs` an operator/runner is meant to use:
1017
+ // `run-u.mjs` no longer resolves-and-passes it at all (see its own
1018
+ // comment); this tier is test/back-compat-only again.
1019
+ // 2. the SIGNED spec field `job.closeTimeoutMs` — the operator's own
1020
+ // number, which may legally sit above OR below the autoset estimate
1021
+ // (F113's rates-passthrough shape).
1022
+ // 3. AUTOSET — a $0 timing pass runs every stage once (ignoring verdicts;
1023
+ // first-red-wins stays for the real precheck below), under the
1024
+ // generous provisional ceiling `TIMING_PREFLIGHT_CEILING_MS` so a
1025
+ // genuinely hung stage still ends. `max(FLOOR, K × slowest)` becomes
1026
+ // the ceiling every close run in this call uses, and a stage that
1027
+ // timed out DURING THE TIMING PASS is `close-timing-red` — the close
1028
+ // cannot run on this machine, and the provider was never called.
1029
+ //
1030
+ // The pass is SKIPPED whenever an override already exists (case 1 or 2):
1031
+ // running every stage a second time just to throw the reading away would
1032
+ // waste real machine time before any provider call, and — measured risk —
1033
+ // a close stage a test or an operator deliberately made slow/hanging would
1034
+ // then block for up to 10 minutes for no purpose. "Guesstimate only when no
1035
+ // rate is given" is the same doctrine F113 already applies to pricing.
1036
+ {
1037
+ const explicitOverride = typeof closeTimeoutMs === 'number' && Number.isFinite(closeTimeoutMs) ? closeTimeoutMs : null;
1038
+ if (explicitOverride !== null) {
1039
+ // A caller-passed runtime option wins outright and skips the timing
1040
+ // pass entirely — this is the pre-M3 shell/test knob (backward
1041
+ // compatibility only, F133: no production runner passes this anymore —
1042
+ // `scripts/run-u.mjs` and `src/cli.js` both leave this call site
1043
+ // nothing to autoset from but the autoset/signed-override tiers below).
1044
+ closeTimeoutMs = explicitOverride;
1045
+ emit('close-timing', { perStage: [], slowestMs: null, slowestName: null, ceilingMs: closeTimeoutMs, source: 'explicit' });
1046
+ console.log(closeTimeoutBanner({ ceilingMs: closeTimeoutMs, source: 'explicit' }));
1047
+ } else {
1048
+ const resolved = await resolveCloseTimeoutMs({ job, stages: stagedClose, cwd: workdir, redact: scrub });
1049
+ if (resolved.timedOut) {
1050
+ // `timedOut` is only ever true alongside a real `timing` reading
1051
+ // (`resolveCloseTimeoutMs` never sets one without the other) — the
1052
+ // cast states that correlation for the checker, which cannot infer
1053
+ // it across the two independent optional fields on its own.
1054
+ const timing = /** @type {NonNullable<typeof resolved.timing>} */ (resolved.timing);
1055
+ const detail = timing.perStage.filter((s) => s.timedOut).map((s) => s.name).join(', ');
1056
+ emit('close-timing', { perStage: timing.perStage, slowestMs: timing.slowestMs, slowestName: timing.slowestName, ceilingMs: null, source: 'estimated' });
1057
+ emit('escalation', {
1058
+ category: 'close-timing-red', decisionReady: true,
1059
+ decision: `A close stage did not finish within the timing preflight's own ceiling (${TIMING_PREFLIGHT_CEILING_MS}ms) — the close cannot run on this machine in a boundable time, so nothing was run and nothing was spent.`,
1060
+ options: ['investigate why the stage hangs (infra/network/resource issue)', 'sign an explicit closeTimeoutMs override once the real duration is known', 'abandon the task'],
1061
+ detail,
1062
+ });
1063
+ return 'close-timing-red';
1064
+ }
1065
+ closeTimeoutMs = /** @type {number} */ (resolved.closeTimeoutMs);
1066
+ emit('close-timing', { ...(resolved.timing ?? { perStage: [], slowestMs: null, slowestName: null, anyTimedOut: false }), ceilingMs: closeTimeoutMs, source: resolved.source });
1067
+ console.log(closeTimeoutBanner({ ceilingMs: closeTimeoutMs, source: resolved.source, slowestMs: resolved.timing?.slowestMs, slowestName: resolved.timing?.slowestName }));
1068
+ }
1069
+ }
1070
+
1071
+ // ── the wall must be able to fund at least one close under the EFFECTIVE
1072
+ // ceiling just computed (PRD item 27/M3): `job.maxWallMs`'s own static
1073
+ // floor (`MIN_WALL_MS`, src/job.js) assumed the library's un-autoset
1074
+ // default — with autoset, the real per-stage ceiling is only known here, at
1075
+ // run start, so the honest shape is a run-start check, never a silent
1076
+ // clamp (a budget under one close cannot fund its own close, verbatim the
1077
+ // reasoning `maxWallMs`'s own validator red already states).
1078
+ if (typeof job.maxWallMs === 'number' && job.maxWallMs < closeTimeoutMs) {
1079
+ emit('escalation', {
1080
+ category: 'wall-under-close-timeout', decisionReady: true,
1081
+ decision: `maxWallMs (${job.maxWallMs}ms) is under the effective close timeout (${closeTimeoutMs}ms) — a budget under one close cannot fund its own close, so nothing was run and nothing was spent.`,
1082
+ options: ['raise maxWallMs to at least the printed close timeout and rerun (a spec edit, so the new hash needs re-approval)', 'sign a lower closeTimeoutMs override if the measured duration allows it (same re-approval)', 'abandon the task'],
1083
+ });
1084
+ return 'wall-under-close-timeout';
1085
+ }
1086
+
919
1087
  // ── THE SIGNER'S ANSWER (N4 §1.4), read before anything costs anything.
920
1088
  //
921
1089
  // Three gates, and each refuses rather than guesses. A decision that is not one
@@ -1300,6 +1468,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1300
1468
  'revise the goal/spec so the work fits the time (same re-approval)',
1301
1469
  'abandon the task',
1302
1470
  ];
1471
+ /** PRD item 27(c)/F130 — the honest escalation tail. `run-u.mjs` supports
1472
+ * `--resume`; the exported bundle CLI does not (v1) and would print a flag
1473
+ * that fails if typed. `resumable` (default `true`, `run-u`'s existing
1474
+ * lines unchanged) picks which sentence names the resume lever. */
1475
+ const resumeToCapNote = resumable
1476
+ ? 'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)'
1477
+ : 'resume is `run-u`-only in v1 — top up budgetUsd and re-fire the bundle from the start (a spec edit, so the new hash needs re-approval)';
1303
1478
  /** MONEY's levers (PRD v1.46 §2), the same three shapes the wall's are, because a
1304
1479
  * money cut is the same KIND of stop: the run is out of an operator-owned
1305
1480
  * allowance, not out of capability, and the last verdict rendered stands. hamr's
@@ -1310,12 +1485,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1310
1485
  * hash, so raising it is a new spec version somebody signs — the library only ever
1311
1486
  * names the lever, and never adjusts a budget itself (the permanent hard line). */
1312
1487
  const MONEY_OPTIONS = [
1313
- 'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)',
1488
+ resumeToCapNote,
1314
1489
  'revise the goal/spec so the work fits the budget (same re-approval)',
1315
1490
  'abandon the task',
1316
1491
  ];
1317
1492
 
1318
- const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
1493
+ const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, closeDir };
1319
1494
  /** D8 — the DECLARED close's seed, filled in below before anything runs.
1320
1495
  * `null` for a command close, which has no baseline to measure against. */
1321
1496
  /** @type {{seedRef: string}|null} */
@@ -1329,7 +1504,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1329
1504
  * The command path is byte-identical to what it was: same call, same options.
1330
1505
  * @param {any[]} stages
1331
1506
  */
1332
- const runCloseStages = (stages) => (declared
1507
+ const runCloseStagesInner = (stages) => (declared
1333
1508
  ? runDeclaredStages(stages, scrub, {
1334
1509
  ...closeOpts,
1335
1510
  seedRef: /** @type {any} */ (declaredCtx).seedRef,
@@ -1359,6 +1534,34 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1359
1534
  }),
1360
1535
  })
1361
1536
  : runStages(stages, scrub, closeOpts));
1537
+ /**
1538
+ * PRD item 27/M2 — the re-verify that runs before EVERY close run, precheck
1539
+ * included: `runCloseStagesInner` is the ONE seam every close execution in
1540
+ * this runner goes through (the precheck's `judgeClose`, the preflight
1541
+ * loop, the check-passes seam, and the close-fix loop via its own `judge`
1542
+ * seam), so wrapping it here covers all of them without threading a
1543
+ * verify call through each call site separately — the same reasoning
1544
+ * `checkCloseAbsolutePaths`'s run-start placement already relies on (W4:
1545
+ * "the staging itself lives in ONE place").
1546
+ *
1547
+ * A mismatch never reaches the executor: it returns a `close-tampered`
1548
+ * verdict in the SAME shape `runClose`/`runStages` render (`{verdict,
1549
+ * stage, detail}`), so every downstream reader that already keys off
1550
+ * `Object.hasOwn(CLOSE_FAULTS, v.verdict)` (the precheck, the preflight
1551
+ * loop, the post-fix read, and ralph's own judge-fault routing) treats it
1552
+ * exactly like `crashed`/`timed-out`/`killed` — a forbidden-zone outcome,
1553
+ * never retried, never fed back as a gap.
1554
+ * @param {any[]} stages
1555
+ */
1556
+ const runCloseStages = async (stages) => {
1557
+ const sig = checkStageByteSignature(stages, workdir);
1558
+ if (!sig.ok) {
1559
+ const r = sig.reds[0];
1560
+ const detail = sig.reds.map((rr) => `${rr.stage}: expected ${rr.expected.slice(0, 12)}… got ${rr.actual ? `${rr.actual.slice(0, 12)}…` : '<unreadable>'}`).join('; ');
1561
+ return { verdict: 'close-tampered', stage: r?.stage, detail };
1562
+ }
1563
+ return runCloseStagesInner(stages);
1564
+ };
1362
1565
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
1363
1566
  let closeStage;
1364
1567
  /** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
@@ -3462,7 +3665,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3462
3665
  : `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
3463
3666
  options: [
3464
3667
  'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
3465
- 'top up budgetUsd and rerun with --resume, if the trend above says it was still converging',
3668
+ resumable
3669
+ ? 'top up budgetUsd and rerun with --resume, if the trend above says it was still converging'
3670
+ : 'resume is `run-u`-only in v1 — top up budgetUsd and re-fire the bundle from the start, if the trend above says it was still converging',
3466
3671
  'abandon the task',
3467
3672
  ],
3468
3673
  };
@@ -3576,7 +3781,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3576
3781
  // is not passed — it survives only inside the governor, as the bound for the
3577
3782
  // case the trend instrument is blind.
3578
3783
  middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
3579
- closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
3784
+ closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites, closeDir,
3580
3785
  });
3581
3786
  } catch (e) {
3582
3787
  planExecuted();
package/src/ralph.js CHANGED
@@ -208,17 +208,24 @@ export const CLOSE_ENV_DENY = Object.freeze({
208
208
  *
209
209
  * @param {string[]} close argv, e.g. ['node', '--test', 'close/']
210
210
  * @param {(s: string) => string} [redact] source scrubber; identity by default
211
- * @param {{ timeoutMs?: number, cwd?: string, expect?: number, judged?: {pattern: string, min: number}, gapKeep?: string }} [opts]
211
+ * @param {{ timeoutMs?: number, cwd?: string, expect?: number, judged?: {pattern: string, min: number}, gapKeep?: string, closeDir?: string|null }} [opts]
212
212
  * close wall-clock cap (operator-set via the runner, never the config's — the
213
213
  * agent must not author its arbiter's clock), the directory the close runs in
214
214
  * (the workdir — the tree it is judging), the exit code the SIGNED spec calls
215
215
  * success, the judgment-rendered signal, and the kept-failures pattern. All
216
216
  * five are arbiter territory: the drafted workflow config cannot express any.
217
+ * `closeDir` (PRD item 27/M3 Part B) is the close's OWN books directory —
218
+ * pristine copies, calibration thresholds, logs — deliberately outside the
219
+ * patient tree (the worker must never read the arbiter's books). Handed to
220
+ * the child as `BARELOOP_CLOSE_DIR` (`CLOSE_DIR_ENV_VAR`,
221
+ * src/close-integrity.js); a close script that needs one and gets none
222
+ * instrument-stops itself, by the script's own contract — this seam only
223
+ * ever THREADS the value, it never invents one.
217
224
  * @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
218
225
  * gap?: string, exitCode?: number, signal?: string, detail?: string,
219
226
  * judgedCount?: number|null, unaudited?: boolean}>}
220
227
  */
221
- export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
228
+ export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep, closeDir = null } = {}) {
222
229
  const env = { ...process.env };
223
230
  delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
224
231
  // …and the operator's credentials go with it (CLOSE_ENV_DENY above). A COPY is
@@ -229,6 +236,13 @@ export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000,
229
236
  || CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
230
237
  || CLOSE_ENV_DENY.shape.test(name)) delete env[name];
231
238
  }
239
+ // PRD item 27/M3 Part B — a `BARELOOP_` name, deliberately not matched by
240
+ // any CLOSE_ENV_DENY rule above (checked: no name/prefix/suffix entry there
241
+ // touches it), set AFTER the strip loop so it can never be stripped by a
242
+ // future denylist edit either. Only set when the runner actually has one —
243
+ // an empty/absent `closeDir` leaves the child's env exactly as it was
244
+ // before this rung, so a script that never reads the variable is unaffected.
245
+ if (typeof closeDir === 'string' && closeDir.length > 0) env.BARELOOP_CLOSE_DIR = closeDir;
232
246
  const r = await spawnClose(close[0], close.slice(1), { env, cwd, timeoutMs });
233
247
 
234
248
  // ── forbidden zone, before any exit code is believed ──────────────────────
@@ -322,17 +336,17 @@ export const stageGap = (name, gap) => `close stage "${name}" failed:\n${gap}`;
322
336
  *
323
337
  * @param {any[]} stages the stage list (already validated by validateJob)
324
338
  * @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
325
- * @param {{timeoutMs?: number, cwd?: string}} [opts]
339
+ * @param {{timeoutMs?: number, cwd?: string, closeDir?: string|null}} [opts]
326
340
  * @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
327
341
  */
328
- export async function runStages(stages, redact = (s) => s, /** @type {{timeoutMs?: number, cwd?: string}} */ { timeoutMs, cwd } = {}) {
342
+ export async function runStages(stages, redact = (s) => s, /** @type {{timeoutMs?: number, cwd?: string, closeDir?: string|null}} */ { timeoutMs, cwd, closeDir } = {}) {
329
343
  const ran = [];
330
344
  let last;
331
345
  for (const st of stages) {
332
346
  // Sequential by construction (F68): stages run one at a time under a single
333
347
  // await, so nothing here ever has two closes in flight against one tree.
334
348
  const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
335
- timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
349
+ timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep, closeDir,
336
350
  });
337
351
  ran.push({ name: st.name, verdict: r.verdict, ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}) });
338
352
  last = r;
@@ -443,6 +457,21 @@ export const CLOSE_FAULTS = Object.freeze({
443
457
  decision: 'The close exited without judging anything — it crashed rather than failed. Its exit code is NOT a verdict, in either direction.',
444
458
  options: ['fix what makes the close crash at startup', 'fix the close argv', 'lower the declared judgment floor if the suite legitimately shrank', 'abandon the task'],
445
459
  },
460
+ // PRD item 27/M2 (`docs/product/CLOSE-INTEGRITY-BUILD.md`) — a close
461
+ // script's bytes no longer match what was signed. Distinct from every row
462
+ // above: those are read off HOW THE CHILD PROCESS TERMINATED, and this one
463
+ // is decided BEFORE the process is even spawned (`runCloseStages`,
464
+ // `src/planrun.js`, re-verifies before every close run and short-circuits
465
+ // to this verdict on a mismatch, the same forbidden-zone shape as a
466
+ // crash). Never `close-red` (a judged "no" from a working close) and never
467
+ // `close-crashed` (an instrument fault the close itself hit) — this is a
468
+ // THIRD thing: the close that ran would not have been the one the operator
469
+ // approved.
470
+ 'close-tampered': {
471
+ category: 'close-tampered',
472
+ decision: "A close script's bytes no longer match its signed sha256 — it would run something other than what was approved, so no verdict from it can be trusted.",
473
+ options: ['restore the close script to its signed bytes', 're-sign the close if the change is intentional (a new spec hash needs re-approval)', 'abandon the task'],
474
+ },
446
475
  });
447
476
 
448
477
  /**
@@ -529,9 +558,13 @@ export const CLOSE_FAULTS = Object.freeze({
529
558
  * it to the gate audit's allow-decision writes. Injected like `redact` so the shell
530
559
  * stays stdlib-only and dumb; consulted ONLY at a crashed verdict. With no seam, or
531
560
  * with zero writes, a crash stays what it always was: an instrument stop.
561
+ * @param {string|null} [opts.closeDir] the close's own books directory
562
+ * (PRD item 27/M3 Part B), forwarded to the raw `close` path's `runClose`
563
+ * call unchanged; a caller supplying `judge` instead already closed over
564
+ * its own `closeDir` and this param does nothing for it.
532
565
  * @returns {Promise<'green'|'escalated'>}
533
566
  */
534
- export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
567
+ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites, closeDir }) {
535
568
  // PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
536
569
  // thing this shell throws for; a WORKER's bad reach comes back as a refusal
537
570
  // RESULT instead, and that rule is about feedback, not about the API contract).
@@ -660,7 +693,7 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
660
693
  emit('middle-done', { iteration });
661
694
  const v = judge
662
695
  ? await judge()
663
- : await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
696
+ : await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep, closeDir });
664
697
  // Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
665
698
  // still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
666
699
  // precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
package/src/reuse.js CHANGED
@@ -1353,6 +1353,7 @@ export function resumeTreeGate({ head, seed, dirty }) {
1353
1353
  * @param {number} [opts.capRuns] forwarded
1354
1354
  * @param {number} [opts.strikeLimit] forwarded (a plan step's strike ceiling)
1355
1355
  * @param {number} [opts.closeTimeoutMs] forwarded
1356
+ * @param {string} [opts.closeDir] forwarded (PRD item 27/M3 Part B)
1356
1357
  * @param {boolean} [opts.layerRoot] forwarded
1357
1358
  * @param {boolean|'cap'|'diff'} [opts.readShim] the read shim's ARM (A0/A1/A2/A3), forwarded verbatim
1358
1359
  * @param {any} [opts.resume] RESUME (module C) — a `readResume` reading of the KILLED
@@ -1371,7 +1372,7 @@ export async function runReuse(opts) {
1371
1372
  const {
1372
1373
  job, approvals, registryDir, patient, workdir, provider, selectionProvider,
1373
1374
  emit, ask, pinned = null, shortlist = null, forceCold = false,
1374
- providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot, readShim,
1375
+ providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, closeDir, layerRoot, readShim,
1375
1376
  runJob = shippedRunJob, now = () => Date.now(),
1376
1377
  } = opts;
1377
1378
  const runid = opts.runid ?? Date.now().toString(36);
@@ -1538,6 +1539,7 @@ export async function runReuse(opts) {
1538
1539
  ...(capRuns !== undefined ? { capRuns } : {}),
1539
1540
  ...(strikeLimit !== undefined ? { strikeLimit } : {}),
1540
1541
  ...(closeTimeoutMs !== undefined ? { closeTimeoutMs } : {}),
1542
+ ...(closeDir !== undefined ? { closeDir } : {}),
1541
1543
  ...(layerRoot !== undefined ? { layerRoot } : {}),
1542
1544
  ...(readShim !== undefined ? { readShim } : {}),
1543
1545
  // the fold, and whether the fold is EXACT. `priorSpendComplete` used to stop at
package/src/reviewdoor.js CHANGED
@@ -38,6 +38,7 @@ import { isDeclaredClose, runDeclaredStages, HITL_DECISION_RED } from './declare
38
38
  import { runStages } from './ralph.js';
39
39
  import { redactSecrets } from './validate.js';
40
40
  import { applyDoorDecision, PAUSE_TTL_MS } from './reuse.js';
41
+ import { checkStageByteSignature } from './close-integrity.js';
41
42
 
42
43
  /** the door's own record, off the run's spine — the LAST one, because a run that
43
44
  * ends at a door writes exactly one and a reader that guesses which is which is a
@@ -92,6 +93,7 @@ const red = (code, detail, path = 'decision') => [{ code, path, detail }];
92
93
  * @param {string} opts.decision one of the three doors
93
94
  * @param {string|null} [opts.text] the rerun's words — the gap, and refused when empty
94
95
  * @param {number} [opts.closeTimeoutMs] the per-stage bound for the accept's re-run
96
+ * @param {string|null} [opts.closeDir] the close's own books directory (PRD item 27/M3 Part B), forwarded to the accept's re-run
95
97
  * @param {string|null} [opts.registryDir] the workflow registry, when the runner keeps one
96
98
  * @param {string|null} [opts.name] the workflow this run wrote its row against
97
99
  * @param {string|null} [opts.runid] the RUN's id, exactly as the bridge row records it
@@ -104,7 +106,7 @@ const red = (code, detail, path = 'decision') => [{ code, path, detail }];
104
106
  * options: string[], ttlMs: number, note: string|null}>}
105
107
  */
106
108
  export async function answerReviewDoor({
107
- job, workdir, events, decision, text = null, closeTimeoutMs,
109
+ job, workdir, events, decision, text = null, closeTimeoutMs, closeDir = null,
108
110
  registryDir = null, name = null, runid = null, at = null,
109
111
  now = Date.now, ttlMs = PAUSE_TTL_MS, emit = null,
110
112
  }) {
@@ -149,7 +151,7 @@ export async function answerReviewDoor({
149
151
  // this is the only branch that runs anything, and it runs the close's own
150
152
  // mechanical stages — never a second, weaker spelling of "is it still green".
151
153
  if (out.decision === 'accept') {
152
- const proof = await proveMechanically({ job, workdir, events, closeTimeoutMs });
154
+ const proof = await proveMechanically({ job, workdir, events, closeTimeoutMs, closeDir });
153
155
  out.mechanical = proof.reading;
154
156
  if (!proof.ok) {
155
157
  out.reds = red('door-accept-red', proof.detail, proof.reading.stage ? `stage.${proof.reading.stage}` : 'close');
@@ -200,16 +202,24 @@ export async function answerReviewDoor({
200
202
 
201
203
  /**
202
204
  * The accept's re-proof: the close's MECHANICAL stages, run against the tree as it
203
- * stands now, through the SAME executors the run itself was judged by.
205
+ * stands now, through the SAME executors the run itself was judged by — the
206
+ * scripts' own bytes are verified against their signed sha256 FIRST (F135).
204
207
  *
205
208
  * The declared path measures against the run's OWN seed (`close-decl.seedRef` off
206
209
  * the spine), never a fresh one: re-basing the baseline onto today's HEAD would
207
210
  * compare the tree to itself and turn "nothing has changed since the run" into a
208
211
  * green for a `files-changed` stage that means the opposite. A run whose seed is
209
212
  * not on its spine is refused rather than guessed at.
210
- * @param {{job: any, workdir: string, events: any[], closeTimeoutMs?: number}} o
213
+ *
214
+ * F135 — the script BYTES are verified (`checkStageByteSignature`, PRD item 27/M2)
215
+ * BEFORE any stage runs, exactly the re-verify every other close execution in this
216
+ * runner already goes through (`runCloseStages`, `src/planrun.js`). Without it, a
217
+ * close script swapped on disk after the run ended but before the signer's accept
218
+ * would be re-run unchecked — a mismatch here never reaches the executor, and
219
+ * reads the same `close-tampered` shape the run-start/mid-run checks already use.
220
+ * @param {{job: any, workdir: string, events: any[], closeTimeoutMs?: number, closeDir?: string|null}} o
211
221
  */
212
- async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
222
+ async function proveMechanically({ job, workdir, events, closeTimeoutMs, closeDir = null }) {
213
223
  const stages = mechanicalStages(closeStagesOf(job) ?? []);
214
224
  if (!stages.length) {
215
225
  // Honest, and named: a close with no mechanical stage has nothing a machine
@@ -217,6 +227,16 @@ async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
217
227
  // dressed as a re-run.
218
228
  return { ok: true, detail: '', reading: { ran: 0, verdict: null, stage: null, stages: [], note: 'this close has no mechanical stage — there is nothing a machine can re-prove, so the accept rests on the person' } };
219
229
  }
230
+ const sig = checkStageByteSignature(stages, workdir);
231
+ if (!sig.ok) {
232
+ const r = sig.reds[0];
233
+ const detail = sig.reds.map((rr) => `${rr.stage}: expected ${rr.expected.slice(0, 12)}… got ${rr.actual ? `${rr.actual.slice(0, 12)}…` : '<unreadable>'}`).join('; ');
234
+ return {
235
+ ok: false,
236
+ reading: { ran: 0, verdict: 'close-tampered', stage: r?.stage ?? null, stages: [] },
237
+ detail: `the close script's bytes no longer match its signed sha256 (close-tampered) — the accept is refused rather than re-run against a script nobody vouched for. ${detail}`,
238
+ };
239
+ }
220
240
  /** @type {any} */
221
241
  let v;
222
242
  if (isDeclaredClose(job)) {
@@ -226,7 +246,7 @@ async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
226
246
  }
227
247
  v = await runDeclaredStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir, seedRef });
228
248
  } else {
229
- v = await runStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir });
249
+ v = await runStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir, closeDir });
230
250
  }
231
251
  const reading = {
232
252
  ran: stages.length,
package/src/run.js CHANGED
@@ -106,7 +106,22 @@ async function primitiveSmoke(workdir) {
106
106
  * CLOSE-FIX loop (a plan STEP runs under the strike ladder instead)
107
107
  * @param {number} [opts.strikeLimit] shell-owned strike ceiling for a plan step's ladder
108
108
  * @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
109
- * @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
109
+ * @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory).
110
+ * OPTIONAL as of PRD item 27/M3: absent, the plan flow autosets it from a $0
111
+ * seed timing pass (or reads the spec's own signed `closeTimeoutMs`
112
+ * override) — a caller passing an explicit value here still wins outright
113
+ * (backward compatibility / test control), but production callers
114
+ * (`src/cli.js`, `scripts/run-u.mjs`) pass nothing (F133, run mtqwmb9l: a
115
+ * runner that resolves this itself for its own purposes — e.g. `run-u.mjs`
116
+ * sizing its outside watchdog, F67 — must never feed the number back in
117
+ * here too, or this call's own autoset/override banner prints a SECOND,
118
+ * mislabelled "explicit runner override" line on top of the runner's own
119
+ * correctly-labelled one).
120
+ * @param {string|null} [opts.closeDir] the close's own books directory (PRD item
121
+ * 27/M3 Part B) — pristine copies, calibration thresholds, logs — outside
122
+ * the patient tree. Forwarded to every close invocation as
123
+ * `BARELOOP_CLOSE_DIR`; a close script that needs one and gets none
124
+ * instrument-stops itself (`checkCloseDirRequired`, src/close-integrity.js).
110
125
  * @param {unknown} [opts.bridge] Layer 3 — a bridge-v1 entry to reuse as the drafter's
111
126
  * STARTING DRAFT (design record 2026-08-01, D4), forwarded to the plan flow. Omitted is
112
127
  * the cold path and is byte-identical to a pre-Layer-3 run. WHICH entry (the listing, an
@@ -202,13 +217,19 @@ async function primitiveSmoke(workdir) {
202
217
  * @param {{text: string, fromRunid?: string|null, receivedAt?: string|null}|null} [opts.doorRerun]
203
218
  * MODULE 8 — this leg IS the fresh engagement a signer commissioned at a previous
204
219
  * run's review door, carrying their words. Forwarded verbatim.
220
+ * @param {boolean} [opts.resumable=true] PRD item 27(c)/F130 — does THIS runner
221
+ * support `--resume` at all? `run-u.mjs` does (the default, `true`, byte-identical
222
+ * to before this param existed); the exported bundle CLI does not (F130) and
223
+ * passes `false` so the escalation tail's option strings say "resume is
224
+ * `run-u`-only in v1" instead of naming a flag that would fail if typed. Forwarded
225
+ * verbatim to the plan flow, which owns the three `--resume`-naming readouts.
205
226
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
206
227
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
207
228
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
208
229
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
209
230
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
210
231
  */
211
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
232
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
212
233
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
213
234
  // before the ledger, before the approval gate, before a byte of the spec is read.
214
235
  // An unrecognised spelling throws here at zero cost instead of being coerced by
@@ -415,7 +436,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
415
436
  // accounts it natively (F12) and the job-end money contract is unchanged.
416
437
  {
417
438
  const outcome = await runPlan(job, {
418
- workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
439
+ workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
419
440
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
420
441
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
421
442
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact