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/CHANGELOG.md CHANGED
@@ -5,6 +5,173 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.22.0] — 2026-09-07
9
+
10
+ ### Added
11
+
12
+ - **Close integrity M1** (`docs/product/CLOSE-INTEGRITY-BUILD.md`, PRD item 27, F129/F131).
13
+ - `src/close-integrity.js`: the ONE `close-absolute-path` detector, shared by
14
+ `src/bundle.js` (export time) and a new $0 run-start precheck in `runPlan`
15
+ (`src/planrun.js`) — every job, not only exported bundles, now refuses at $0
16
+ (before the close-first precheck, before any provider call) when a close
17
+ script's CONTENT bakes in an absolute path that exists on disk. Exports
18
+ `readCloseScripts`, `checkCloseAbsolutePaths`, `absolutePathLiteralsOf` from
19
+ `src/index.js` — `readCloseScripts` is the pure reader M2's sha256 fingerprint
20
+ will reuse.
21
+ - `runJob`/`runPlan` gain an optional `resumable` flag (default `true`,
22
+ `run-u.mjs` unchanged); the bundle CLI (`src/cli.js`) passes `resumable: false`
23
+ so its escalation tail says "resume is `run-u`-only in v1" instead of naming a
24
+ `--resume` flag it does not implement (F130).
25
+
26
+ - **Hermetic test runner** (F136, closed at the runner). `scripts/hermetic.mjs`
27
+ runs `node --test` with `HOME` redirected to a fresh empty `mkdtemp` dir and
28
+ `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`/`GIT_CONFIG_NOSYSTEM` set to neutralise git
29
+ config, plus any inherited `GIT_AUTHOR_*`/`GIT_COMMITTER_*`/`EMAIL` deleted — the
30
+ local suite is now blind to this machine's identity the same way CI's runner already
31
+ is, so a test leaning on either fails here first. `npm test` now runs it in place of
32
+ a bare `node --test`; not a sandbox (PATH, `node_modules`, shelled-out binaries
33
+ untouched). See `docs/logs/FINDINGS.md` F136.
34
+
35
+ ### Fixed
36
+
37
+ - **F129, remaining 8 close scripts**: `scripts/testgen-cold-check-close.mjs`,
38
+ `testgen-close.mjs`, `types-close.mjs`, `l2poc-check-close.mjs`,
39
+ `u-bareagent-close.mjs`, `u-bareguard-close.mjs`, `u-litectx-close.mjs`,
40
+ `u-baremobile-close.mjs` now read `WORKDIR = process.cwd()` instead of a
41
+ hardcoded absolute patient path, following `u-spawner-close.mjs`'s fix
42
+ (`3b987d4`). Behaviour-preserving: `run-u` always passes `cwd` = the patient.
43
+
44
+ - **Close integrity M2 — close-bytes signature** (`docs/product/CLOSE-INTEGRITY-BUILD.md`,
45
+ PRD item 27, N4). A job's signature now covers its close scripts' CONTENT, not only
46
+ the path naming them.
47
+ - New signed spec field `close[].sha256`: hex sha256 of the script file's bytes, for
48
+ every stage whose `cmd` names an addressable script (an interpreter cmd —
49
+ `node`/`sh`/`bash`/`python`/`python3`/`npx` — or a bare directly-executable absolute
50
+ path, e.g. a `.sh` wrapper). It sits inside the signed spec, so `jobSpecHash` covers
51
+ it with no hash-function change. `validateJob` (`src/job.js`) demands it
52
+ (`missing-required`) on every addressable stage and shape-checks it
53
+ (`invalid-value`, 64 lowercase hex chars).
54
+ - `src/close-integrity.js` gains `hashCloseScriptBytes`, `checkCloseByteSignature`,
55
+ `checkStageByteSignature`, `signCloseScripts` (all exported from `src/index.js`).
56
+ `runPlan` verifies the signature at run start (alongside `close-absolute-path`, $0,
57
+ before the close-first precheck and before any provider call) AND before EVERY
58
+ close run thereafter (the `runCloseStages` seam the precheck/preflight/check-passes/
59
+ close-fix-loop all share) — a mismatch reds the new typed fault `close-tampered`
60
+ (`src/ralph.js`'s `CLOSE_FAULTS`), distinct from `close-red`/`close-crashed`.
61
+ - `src/bundle.js`'s `exportBundle` reds `close-sha-mismatch` when a spec's signed
62
+ sha256 disagrees with the script bytes actually being packed — the manifest hash
63
+ and the spec's own signature must never drift apart.
64
+ - `scripts/sign-close.mjs`: a thin CLI wrapper around `signCloseScripts` that fills
65
+ `close[].sha256` from disk and prints the old→new `jobSpecHash`; never writes
66
+ unless `--write`. The agent never writes this field — `src/authorjob.js`'s
67
+ `assembleSpec` refuses a draft carrying `sha256` the same way it refuses
68
+ `close`/`closeDecl`/`verdictType`.
69
+ - **Widened scope (orchestrator audit, folded into this build):** the addressable-script
70
+ shape test (`closeScriptCandidateToken`, `src/validate.js`) now also recognizes a
71
+ bare directly-executable absolute-path cmd (no interpreter prefix) — previously
72
+ invisible to BOTH `close-absolute-path` and this fingerprint. Every `jobs/*.json`
73
+ whose close names a script was re-signed with `scripts/sign-close.mjs --all --write`.
74
+
75
+ - **Close integrity M3 — close timeout: autoset + signed override** (PRD item 27,
76
+ hamr 2026-09-06: "both … autoset and can be override, same like api pricing"; arbiter
77
+ constants set 2026-09-07: floor 120,000ms, K = 5).
78
+ - New `src/closetimeout.js`: `CLOSE_TIMEOUT_FLOOR_MS`, `CLOSE_TIMEOUT_K`,
79
+ `TIMING_PREFLIGHT_CEILING_MS`, `timeCloseStages`, `computeCloseTimeoutCeiling`,
80
+ `resolveCloseTimeoutMs`, `closeTimeoutBanner` (all exported from `src/index.js`). A
81
+ $0 timing preflight runs every close stage once, ignoring verdicts, before the
82
+ close-first precheck and before any provider call; the per-stage ceiling every close
83
+ call in the run then uses is `max(FLOOR, K × slowest measured stage)`, printed on
84
+ every run (`close timeout: <n>s per stage (estimated from seed timing: …)`).
85
+ - New OPTIONAL signed spec field `closeTimeoutMs` (`>= CLOSE_TIMEOUT_FLOOR_MS`,
86
+ `src/job.js`): the operator's own override, which wins outright over the estimate and
87
+ may legally sit above OR below it (the rates-passthrough shape, F113 — not
88
+ tighten-only). Skipped when set: the timing pass never runs. Arbiter territory —
89
+ `src/authorjob.js`'s `assembleSpec` refuses a draft carrying it.
90
+ - New `runPlan` run-start check `wall-under-close-timeout`: refuses at $0 when
91
+ `job.maxWallMs` is under the just-resolved EFFECTIVE close timeout — the honest shape
92
+ once the real (autoset or signed) ceiling is known, never a silent clamp.
93
+ - `scripts/run-u.mjs`'s hardcoded `CLOSE_TIMEOUT_MS = 900_000` retires; the per-stage
94
+ ceiling is resolved once (via the same `resolveCloseTimeoutMs`) before the outside
95
+ watchdog (F67) is even spawned, so its stale/grace windows size off the real number.
96
+ `src/cli.js`'s bundle runner passes nothing — both runners now go through the
97
+ identical autoset/override path.
98
+ - **Part B — `BARELOOP_CLOSE_DIR`.** `runClose` (`src/ralph.js`) accepts an optional
99
+ `closeDir` and sets it as `BARELOOP_CLOSE_DIR` in the close's (already-stripped) env —
100
+ a `BARELOOP_` name matches none of `CLOSE_ENV_DENY`'s rules. New
101
+ `checkCloseDirRequired`/`CLOSE_DIR_ENV_VAR` (`src/close-integrity.js`): a close script
102
+ whose source mentions the variable and gets no `closeDir` refuses at $0
103
+ (`close-dir-required`), the same seam as `close-absolute-path`/`close-tampered`. The
104
+ four hand-authored scripts that hardcoded an external `SPINE_DIR`
105
+ (`testgen-cold-check-close.mjs`, `testgen-close.mjs`, `l2poc-check-close.mjs`,
106
+ `types-close.mjs`) now read `process.env.BARELOOP_CLOSE_DIR` and instrument-stop
107
+ (exit 97) when it is absent — no baked fallback. `scripts/u-pulselog-close.mjs`'s
108
+ hardcoded `--workdir` DEFAULT (a separate F129 cwd-class hazard — that literal existed
109
+ on disk and already tripped the shipped `close-absolute-path` guard for
110
+ `jobs/pulselog-u-types.json`) now falls back to `process.cwd()`, the same M1 template
111
+ every other close script uses. `src/cli.js` sets `closeDir` to
112
+ `<bundleDir>/runs/<runid>/close/`; `scripts/run-u.mjs` sets it to its existing
113
+ `spineDir`. Every `jobs/*.json` whose close bytes changed was re-signed
114
+ (`scripts/sign-close.mjs --all --write`).
115
+
116
+ - **F132 — a real paid fire of an exported bundle refused itself `close-tampered`
117
+ at $0** (run `mtqwmb9l` → export → `mtqwydl4`). `exportBundle`'s import rewrite
118
+ (`../src/x.js` → `'bareloop'`) changes a close script's bytes, but the written
119
+ bundle's `spec.json` kept the SOURCE spec's `close[].sha256` unchanged — two
120
+ signatures over two different byte strings, so the run-start
121
+ `checkCloseByteSignature` correctly refused a bundle that was never actually
122
+ tampered with. `exportBundle` (`src/bundle.js`) now re-signs `close[].sha256`
123
+ in the written spec over the RELOCATED bytes it actually packs, for every
124
+ stage that already carries a signature — the export-time `close-sha-mismatch`
125
+ check against the SOURCE spec/bytes is unchanged (it still guards a tampered
126
+ source).
127
+
128
+ - **F133 — double/mislabelled close-timeout banner** (run `mtqwmb9l`). `runPlan`
129
+ printed/emitted "estimated from seed timing" (`scripts/run-u.mjs`'s own
130
+ resolution, needed early to size the outside watchdog, F67) AND, on the same
131
+ run, "explicit runner override" (the library's own print, because run-u also
132
+ fed the resolved number back in as a bare `closeTimeoutMs`) — two lines for
133
+ one number, the second lying about its source. `scripts/run-u.mjs` no longer
134
+ passes `closeTimeoutMs` into `runJob` at all; `runJob`/`runPlan` resolve
135
+ (autoset, or the spec's own signed field) and announce the ceiling themselves
136
+ — the ONE banner/spine record per run. The runtime `closeTimeoutMs` option on
137
+ `runJob`/`runPlan` stays, test/backward-compat only. Same fire, second (real)
138
+ catch: run-u's own pre-fix early `emit('close-timing', …)` landed BEFORE
139
+ `job-start` on the spine (`runJob` is what emits `job-start`, and run-u called
140
+ it before ever calling `runJob`) — every spine reader assumes `job-start`
141
+ opens the file. Fixed by the same change (run-u no longer emits it early);
142
+ `tests/readshim-battery.test.js`'s archive check now tolerates `job-start`
143
+ within the first 3 records (the archived `u-mtqwmb9l.jsonl` is real and never
144
+ rewritten), and a new test pins `job-start` as literally the first record on
145
+ a fresh scripted run.
146
+
147
+ - **F135 — review-door accept re-run bypassed the close byte signature**. Every
148
+ other close execution goes through `checkStageByteSignature` before running
149
+ a stage (`runCloseStages`, `src/planrun.js`); the review door's `accept`
150
+ re-proof (`proveMechanically`, `src/reviewdoor.js`) called
151
+ `runStages`/`runDeclaredStages` directly and skipped it — a close script
152
+ swapped on disk after the run ended but before the signer's accept ran
153
+ unchecked and could mint a false accept. Fixed: `proveMechanically` now
154
+ verifies the mechanical stages' bytes against their signed `sha256` first,
155
+ refusing through the existing `door-accept-red` path on a mismatch
156
+ (`tests/reviewdoor-tamper.test.js`).
157
+
158
+ - **F134 — live re-fire validation, no code change**. Two real paid fires of the
159
+ same exported bundle (`mtr0icky` cap-halt, `mtr4t1u1` green re-fire) confirmed
160
+ M2's relocated-bytes signature and M3's close-timing banner both hold under a
161
+ second real fire, not just the one that motivated each fix; the cap-halt
162
+ itself read as a cap-shaped negative (an `Any`-suppression cheat correctly
163
+ caught by `no-suppressions`, not an instrument defect). See
164
+ `docs/logs/FINDINGS.md` F134.
165
+
166
+ - **F136 — CI-only red: patient test fixtures committed without the neutralised
167
+ git identity**. `tests/close-integrity.test.js` and `tests/close-timeout.test.js`
168
+ each added a second commit on top of `initPatientRepo`'s seed via a raw
169
+ `execFileSync('git', ...)` with no env, so it depended on the host's own git
170
+ identity — green on hamr's machine (a global config papers over it), red on
171
+ CI (none). `tests/helpers.js` now exports `patientGitEnv`/`gitInPatient`
172
+ reusing `initPatientRepo`'s identity env; both files' `makePatient` go through
173
+ it. See `docs/logs/FINDINGS.md` F136.
174
+
8
175
  ## [0.21.0] — 2026-09-06
9
176
 
10
177
  ### Added
@@ -134,7 +134,8 @@ minting claim, or the shell-owned retry cap — all unknown-field reds.
134
134
  | `conditions` | `{ providerPath?, closeVerbosity?, taskFraming?, scaffold? }` | declared keys only, string values — the environment label (consumed by the N3 lineage key; recorded on spines from run one) |
135
135
  | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
136
136
  | `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
137
- | `maxWallMs` | optional integer ms `>= MIN_WALL_MS` (one close timeout) | the run's wall clock. **NO DEFAULT, by ruling** — absent means time-unbounded *by explicit operator choice*, never by fallback (F45: a defaulted cap is a silent second ceiling). Enforcement is a BETWEEN-ROUND deadline, so the honest worst case is `maxWallMs + closeStages × closeTimeoutMs` — every stage of a staged close gets the FULL timeout — and all three numbers are reported (`loop.stop()` cannot cut an in-flight call — F61 measured 500ms→4,018ms). The `MIN_WALL_MS` floor is a ONE-stage number, so a spec with many stages can validate while its overshoot dwarfs its cap: the clock reports that honestly rather than the floor pretending to prevent it (stage-aware floor parked, PRD v1.39). Operator-only, tighten-only; adding or changing it changes the spec hash |
137
+ | `maxWallMs` | optional integer ms `>= MIN_WALL_MS` (one close timeout) | the run's wall clock. **NO DEFAULT, by ruling** — absent means time-unbounded *by explicit operator choice*, never by fallback (F45: a defaulted cap is a silent second ceiling). Enforcement is a BETWEEN-ROUND deadline, so the honest worst case is `maxWallMs + closeStages × closeTimeoutMs` — every stage of a staged close gets the FULL timeout — and all three numbers are reported (`loop.stop()` cannot cut an in-flight call — F61 measured 500ms→4,018ms). The `MIN_WALL_MS` floor is a ONE-stage, un-autoset-default number, so a spec with many stages OR a raised/autoset `closeTimeoutMs` can validate while its overshoot dwarfs its cap: `runPlan` adds a RUN-START check (PRD item 27/M3) that refuses `wall-under-close-timeout` at $0 once the EFFECTIVE per-stage ceiling is known (autoset or signed), rather than a silent clamp. Operator-only, tighten-only; adding or changing it changes the spec hash |
138
+ | `closeTimeoutMs` | optional integer ms `>= CLOSE_TIMEOUT_FLOOR_MS` (120,000 — hamr's arbiter floor, 2026-09-07) | PRD item 27/M3's SIGNED override for the close's own per-stage wall-clock cap. Absent (the normal case): the run autosets it from a $0 seed timing pass — `max(FLOOR, K × slowest measured stage)`, `K = 5` (hamr, 2026-09-07: "2 and 5 are fine"). Present: the operator's own number wins outright and may legally sit ABOVE *or below* the autoset estimate (the rates-passthrough shape, F113 — never tighten-only). Arbiter territory exactly like `budgetUsd`/`maxWallMs`/`close[].sha256`: the authoring pipeline's `assembleSpec` refuses a draft carrying it |
138
139
  | `model` | optional non-empty string, exact provider model id (e.g. `"claude-sonnet-5"`) | the WORKER's model, build-list #3. Absent means today's runner default; present it wins over a `--model` flag outright, and a flag naming a different id is refused (`resolveWorkerModel`, `src/job.js`) rather than silently overridden. Part of the signed hash like every other field. Worker only — the judge model stays library-pinned (`JUDGE_MODEL`) pending recalibration |
139
140
  | `writeScope` | array of contained globs | the operator's outer fence; the plan's own scopes must fit inside it, same containment code |
140
141
  | `steps` | RETIRED | operator-authored `steps[]` was deleted (PRD v1.32); a spec carrying it reds `shape-retired:steps` by name rather than half-running |
@@ -391,7 +392,7 @@ Append-only JSONL event emitter bound to one file. `seq` monotonic per spine, `t
391
392
  last. Consumers are pure listeners; nothing reads the file back. Returns each event as
392
393
  written.
393
394
 
394
- ### `ralph({ middle, close?, judge?, capRuns?, ladder?, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites? })` → `'green' | 'escalated'` — `src/ralph.js`
395
+ ### `ralph({ middle, close?, judge?, capRuns?, ladder?, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites?, closeDir? })` → `'green' | 'escalated'` — `src/ralph.js`
395
396
 
396
397
  The dumb outer shell: `while close-red and not exhausted: run the middle`. **Exhaustion has
397
398
  two alternative rules and you supply exactly one** — `capRuns` (a fixed number of middle runs)
@@ -422,11 +423,23 @@ set the close's `gapKeep` (job-spec, arbiter territory) to a regex like `"^not o
422
423
  matching failure lines are preserved in a capped kept-failures block regardless of where
423
424
  they print (F28: the first real firing delivered a gap with zero failure names). The gap
424
425
  also combines stdout+stderr, so a stdout failure survives stderr noise.
425
- `closeTimeoutMs` caps the close's wall clock (default 120s) — shell/operator
426
- territory, inexpressible in any config. A faulted close is asked to leave with SIGTERM and,
427
- if it is still there 2s later, ended with SIGKILL: SIGTERM is a request a close can decline
428
- (its own handler, a trapping wrapper), and without a second deadline the wait never resolves.
429
- The verdict is untouched — the FIRST fault named the outcome and the kill only enforces it.
426
+ `closeTimeoutMs` caps the close's wall clock (default 120s when nothing else supplies a
427
+ value — `runClose`'s own bare default) — shell/operator territory, inexpressible in any
428
+ config. A faulted close is asked to leave with SIGTERM and, if it is still there 2s later,
429
+ ended with SIGKILL: SIGTERM is a request a close can decline (its own handler, a trapping
430
+ wrapper), and without a second deadline the wait never resolves. The verdict is untouched —
431
+ the FIRST fault named the outcome and the kill only enforces it. **As of PRD item 27/M3, a
432
+ direct `ralph`/`runClose`/`runStages` caller still owns this number outright (`closeTimeoutMs
433
+ ?? 120_000`); the AUTOSET/override resolution lives one level up, in `runPlan`/`runJob`
434
+ (see `resolveCloseTimeoutMs` below) — `ralph` itself never runs a timing pass.**
435
+
436
+ `closeDir` (PRD item 27/M3 Part B, optional) is the close's own BOOKS DIRECTORY — pristine
437
+ copies, calibration thresholds, logs — deliberately outside the patient tree (the worker
438
+ must never read the arbiter's books, v1.12). Forwarded to every close invocation as the
439
+ env var `BARELOOP_CLOSE_DIR` (set on a COPY of `process.env`, after `CLOSE_ENV_DENY`
440
+ stripping — a `BARELOOP_` name matches none of that denylist's rules and is never stripped).
441
+ A close script that reads this variable and gets none must instrument-stop itself; nothing
442
+ in the runner invents a fallback directory.
430
443
 
431
444
  **The close child's environment is stripped of credential-shaped variables before it runs**
432
445
  (`CLOSE_ENV_DENY` in `src/ralph.js`: provider keys, `AWS_*`, any name ending
@@ -958,13 +971,33 @@ Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
958
971
  `coordination-red` — a failure between units (scope contention, step order, store
959
972
  races), never to be folded into worker/interpreter reds.
960
973
 
961
- ### `runJob(spec, { approvals, workdir, provider, nativeProvider?, providerFor?, emit, capRuns?, strikeLimit?, shellCapUsd?, closeTimeoutMs?, layerRoot?, readShim?, scout?, bridge?, priorSpentUsd?, priorSpendComplete?, priorWallMs?, resumeSeed?, resumeGrades?, resumeReplans?, resumeBranch?, humanRuling?, heldRuling?, reviewDoor?, doorRerun? })` → outcome — `src/run.js`
974
+ ### `runJob(spec, { approvals, workdir, provider, nativeProvider?, providerFor?, emit, capRuns?, strikeLimit?, shellCapUsd?, closeTimeoutMs?, closeDir?, layerRoot?, readShim?, scout?, bridge?, priorSpentUsd?, priorSpendComplete?, priorWallMs?, resumeSeed?, resumeGrades?, resumeReplans?, resumeBranch?, humanRuling?, heldRuling?, reviewDoor?, doorRerun?, resumable? })` → outcome — `src/run.js`
975
+
976
+ **`closeTimeoutMs` (PRD item 27/M3) is now OPTIONAL for every real caller.** Omit it (both
977
+ `src/cli.js`'s bundle runner and `scripts/run-u.mjs` do) and the run AUTOSETS the per-stage
978
+ close ceiling from a $0 seed timing pass, or reads the job spec's own signed override —
979
+ "guesstimate + loud sign + customer override", the same shape rates-passthrough already
980
+ uses (F113). Passing an explicit value here still wins outright over both (kept for
981
+ backward compatibility and test control) — see `resolveCloseTimeoutMs` further down.
982
+
983
+ **`closeDir` (PRD item 27/M3 Part B, optional but effectively required for any
984
+ hand-authored close that reads its own books)** — the close's own directory (pristine
985
+ copies, calibration thresholds, logs), outside the patient tree. `src/cli.js` sets it to
986
+ `<bundleDir>/runs/<runid>/close/`; `scripts/run-u.mjs` sets it to its existing `spineDir`. A
987
+ close script whose source mentions `BARELOOP_CLOSE_DIR` and gets none here refuses the run
988
+ at $0 (`close-dir-required`, before any provider call) — see `checkCloseDirRequired` below.
962
989
 
963
990
  The last seven are the RESUME fold and are documented under *Resuming a killed run* below; they
964
991
  default to `0` / `true` / `0` / `null` / `[]` / `null` / `null`, so a fresh run passes none of them.
965
992
  Three of them are folds of a bound the operator SIGNED (money, wall, replans) and one is a
966
993
  readout seed (grades) — the distinction matters and is spelled out there.
967
994
 
995
+ `resumable` (default `true`, PRD item 27(c)/F130) says whether THIS runner supports
996
+ `--resume` at all — `run-u.mjs` leaves it at the default (byte-identical to before this
997
+ flag existed); the exported bundle CLI (`src/cli.js`) passes `false` so the run's
998
+ escalation tail says "resume is `run-u`-only in v1" instead of naming a flag it does not
999
+ implement.
1000
+
968
1001
  The runner — the shell's top layer, and the ONE entry. It composes everything below it and
969
1002
  interprets nothing itself. Sequence: **approval gate** (human-signs-always — refuses an
970
1003
  unapproved spec before ANY provider call: `unapproved-spec`) → **primitive smoke** (litectx
@@ -1601,7 +1634,7 @@ package makes). Which runs open one is `doorOpens(job, reviewDoor)`:
1601
1634
  territory.
1602
1635
 
1603
1636
  **Answering one — `answerReviewDoor({ job, workdir, events, decision, text?, closeTimeoutMs?,
1604
- registryDir?, name?, runid?, at?, now?, emit? })`** (`src/reviewdoor.js`). The answer arrives
1637
+ closeDir?, registryDir?, name?, runid?, at?, now?, emit? })`** (`src/reviewdoor.js`). The answer arrives
1605
1638
  after the run has ended, possibly days later and from another process, so it cannot ride the
1606
1639
  run's return path. This is that seam. It never throws and never conjures — a fourth door, an
1607
1640
  empty `rerun`, a run that opened no door (`no-door`), an expired one (`door-expired`), a tree
@@ -2481,7 +2514,9 @@ nothing here to check it against).
2481
2514
 
2482
2515
  Enforcement is **local only** — wired into `npm test` (see `package.json`'s `test`
2483
2516
  script), never into `.github/workflows/*` (CI already runs `npm test`, so the rule is
2484
- enforced there without an ask-first CI edit). The check itself lives outside the
2517
+ enforced there without an ask-first CI edit). `npm test` itself now runs `node --test`
2518
+ through `scripts/hermetic.mjs`, which strips this machine's `HOME`/git identity so a
2519
+ test that leans on either fails locally instead of only on CI (F136). The check itself lives outside the
2485
2520
  published package, at `scripts/prompt-commit-check.mjs` (pure decision logic in
2486
2521
  `scripts/promptcommitlib.mjs`, `scripts/` is not in `package.json`'s `files`, so none of
2487
2522
  this ships):
@@ -2546,11 +2581,77 @@ written by `bareloop run`, step 6 below) is real and live but is not in the froz
2546
2581
  layout table at all. `history.jsonl`'s row also carries `bundleHash` and `approveHash`
2547
2582
  (the POC-fact correction, below) beyond the fields the original layout table named.
2548
2583
 
2584
+ #### `src/close-integrity.js` — close-absolute-path (M1) AND close-bytes signature (M2)
2585
+
2586
+ Shared by `src/bundle.js` (export time) and a $0 run-start precheck inside `runPlan`
2587
+ (`src/planrun.js`) — every job, not only exported bundles, refuses at $0 (before the
2588
+ close-first precheck, before any provider call) when a close script's CONTENT bakes in an
2589
+ absolute path that exists on disk. Two spellings of the same rule is exactly how F129
2590
+ shipped an export-only guard while every non-exported job stayed exposed.
2591
+
2592
+ **Scope (widened 2026-09-06, PRD item 27/M2, an orchestrator audit):** a close script is
2593
+ addressable — visible to `readCloseScripts`, `checkCloseAbsolutePaths`, the sha256
2594
+ fingerprint, and `signCloseScripts` — when its `cmd` is EITHER an interpreter
2595
+ (`node`/`sh`/`bash`/`python`/`python3`/`npx`) followed by a non-empty argv[1], OR a bare
2596
+ cmd whose argv[0] ITSELF is an absolute path (a `.sh`/other directly-executable close
2597
+ script, no interpreter prefix — e.g. `jobs/aurora-testgen-cold.json`'s close). A relative
2598
+ bare executable (`true`, `pytest`, `npm test`) names no file at all and stays out of
2599
+ scope, on both halves, exactly as before. The shape test itself
2600
+ (`closeScriptCandidateToken`, `src/validate.js`) is shared with `src/job.js`'s sha256
2601
+ demand so the demand and the detectors can never drift apart about which stages are in
2602
+ scope.
2603
+
2604
+ | function | args → returns | notes |
2605
+ |---|---|---|
2606
+ | `absolutePathLiteralsOf(source)` | close script text → `string[]` | the scan itself (moved here from `src/bundle.js`, which now imports it) |
2607
+ | `readCloseScripts(spec, cwd)` | resolved job spec + cwd → `{ stage, path, bytes, sha256 }[]` | resolves every addressable `close[].cmd` (see scope above) to its absolute path and bytes off disk; a relative path resolves against `cwd`. Never throws — a missing/unreadable script reports `bytes: null`. `sha256` (M2, additive) is the stage's own signed field verbatim, or `null` |
2608
+ | `checkCloseAbsolutePaths(spec, cwd)` | resolved job spec + cwd → `{ ok: true } \| { ok: false, reds: { stage, path, literal }[] }` | the run-start check `runPlan` calls before the close-first precheck |
2609
+ | `hashCloseScriptBytes(text)` | script text → sha256 hex | the ONE formula every M2 consumer uses (minting, the run-start check, the runtime re-verify, the export mismatch check) — hashing the utf8-decoded text agrees with hashing the raw file bytes because a close script is always valid UTF-8 |
2610
+ | `checkCloseByteSignature(spec, cwd)` | resolved job spec + cwd → `{ ok: true } \| { ok: false, reds: { stage, path, expected, actual }[] }` | the $0 run-start integrity check (PRD item 27): every signed `close[].sha256` must match the script's bytes off disk RIGHT NOW. `runPlan` calls this alongside `checkCloseAbsolutePaths`, before the close-first precheck and before any provider call — a mismatch reds `close-tampered`. An unreadable signed script reads `actual: null` and is STILL a red (never silently "fine") |
2611
+ | `checkStageByteSignature(stages, cwd)` | a close stage chain + cwd → same shape as above | the narrower re-verify `runPlan`'s `runCloseStages` wrapper runs before EVERY close execution (precheck, preflight, check-passes, and the close-fix loop all go through this one seam) — a mismatch short-circuits to a `close-tampered` verdict in the same shape `runClose`/`runStages` render, so it rides out through the existing `CLOSE_FAULTS` machinery exactly like `crashed`/`timed-out`/`killed` |
2612
+ | `signCloseScripts(spec, cwd)` | resolved job spec + cwd → `{ ok, spec, changes: {stage,path,old,new}[], missing: {stage,path}[] }` | the PURE minting helper: fills `close[].sha256` from disk for every addressable stage. Never writes anything itself — `scripts/sign-close.mjs` is the thin CLI wrapper that gates on `--write`. A `closeDecl` spec (no script path to sign) returns `ok:false` unchanged |
2613
+ | `checkCloseDirRequired(spec, cwd, closeDir)` | resolved job spec + cwd + the runner's `closeDir` (or `null`) → `{ ok: true } \| { ok: false, reds: { stage, path }[] }` | PRD item 27/M3 Part B: scans the same addressable-script bytes `readCloseScripts` reads for the literal substring `BARELOOP_CLOSE_DIR` — a script that mentions it needs a real `closeDir` or it would run against `undefined`. A script that never mentions it carries no demand |
2614
+ | `CLOSE_DIR_ENV_VAR` | `'BARELOOP_CLOSE_DIR'` | the ONE spelling `checkCloseDirRequired`'s scan and `runClose`'s env-set (`src/ralph.js`) both use |
2615
+
2616
+ #### `src/closetimeout.js` — close timeout: autoset + signed override (M3)
2617
+
2618
+ hamr's ruling (2026-09-06): *"both … autoset and can be override, same like api pricing"*
2619
+ — the guesstimate-plus-loud-sign-plus-customer-override shape (F113), never a hidden
2620
+ defaulted knob. Arbiter constants, hamr 2026-09-07 verbatim: **floor = 120,000 ms; K = 5
2621
+ ("2 and 5 are fine")** — never agent- or adopter-settable.
2622
+
2623
+ | export | shape | notes |
2624
+ |---|---|---|
2625
+ | `CLOSE_TIMEOUT_FLOOR_MS` | `120_000` | the floor, hamr's arbiter constant |
2626
+ | `CLOSE_TIMEOUT_K` | `5` | the multiplier, hamr's arbiter constant |
2627
+ | `LIBRARY_DEFAULT_CLOSE_TIMEOUT_MS` | `120_000` | `runClose`'s own un-autoset default (`src/ralph.js`'s `timeoutMs = 120_000`) — named separately from the floor above even though the two numbers agree today, because they are different claims |
2628
+ | `TIMING_PREFLIGHT_CEILING_MS` | `600_000` (library default × K) | the GENEROUS provisional ceiling every stage runs under DURING the timing pass itself — never the autoset/override ceiling the run then uses, which is only known after the pass completes |
2629
+ | `timeCloseStages(stages, {cwd, redact})` | → `{ perStage: {name,ms,exitCode,timedOut}[], slowestMs, slowestName, anyTimedOut }` | runs EVERY stage once, ignoring verdicts (first-red-wins stays for the real verdict precheck) — a declared/kind stage (no `.cmd`) is skipped, contributing no reading rather than a false zero. A stage that never finishes within `TIMING_PREFLIGHT_CEILING_MS` reads `timedOut: true` |
2630
+ | `computeCloseTimeoutCeiling({slowestMs, overrideMs?})` | → `{ ceilingMs, source: 'estimated'\|'override' }` | `max(FLOOR, K × slowestMs)`, unless `overrideMs` is given, in which case it wins outright (may sit above OR below the estimate — not tighten-only) |
2631
+ | `resolveCloseTimeoutMs({job, stages, cwd, redact})` | → `{ closeTimeoutMs, source, timing, timedOut }` | the ONE resolver `src/planrun.js` and `scripts/run-u.mjs` BOTH call (never two independent spellings of this precedence): a signed `job.closeTimeoutMs` wins with NO timing pass run at all; absent, the pass runs and the ceiling autosets. `timedOut: true` means a stage never finished the pass — the caller reds `close-timing-red` rather than trusting any number |
2632
+ | `closeTimeoutBanner({ceilingMs, source, slowestMs?, slowestName?})` | → the printed line | ONE spelling for `estimated`/`override`/`explicit` (the last is the pre-M3 shell/test runtime-option escape hatch, never the signed field) — printed on every run so a silent default can never hide behind this feature the way the OLD 120s/900s defaults did |
2633
+
2634
+ **`scripts/sign-close.mjs`** — `node scripts/sign-close.mjs <jobs/x.json> [--write]` or
2635
+ `--all` for every `jobs/*.json`. Prints the old→new `jobSpecHash` and each stage's
2636
+ old→new sha256; NEVER writes unless `--write` is passed (a dry run is the default). The
2637
+ agent never runs this with `--write` on its own authority — minting a close-bytes
2638
+ signature is arbiter territory exactly like a budget or a wall clock, the same as the
2639
+ authoring pipeline's own refusal (`src/authorjob.js`'s `assembleSpec`, which refuses a
2640
+ draft carrying `sha256` the same way it refuses `close`/`closeDecl`/`verdictType`).
2641
+
2642
+ **`close[].sha256`** — a signed spec field (PRD item 27/M2): hex sha256 of the bytes of
2643
+ the script file the stage's `cmd` names (only for an addressable cmd, per the scope
2644
+ above). `validateJob` (`src/job.js`) demands it as `missing-required` on every addressable
2645
+ stage and shape-checks it (`invalid-value`, 64 lowercase hex chars) even where not
2646
+ demanded. It sits INSIDE the signed spec, so `jobSpecHash` covers it with no
2647
+ hash-function change — re-signing a job (minting or changing this field) moves the spec
2648
+ hash and needs re-approval, exactly like any other semantic edit.
2649
+
2549
2650
  #### `src/bundle.js` — pure, no provider/process calls
2550
2651
 
2551
2652
  | function | args → returns | notes |
2552
2653
  |---|---|---|
2553
- | `exportBundle(o)` | `{ spec, closeScripts, registryDir, outDir, bareloopVersion }` → `{ ok, reds, dir, bundleHash, manifest }` | mints the directory above. Every check runs before anything touches disk — a red means nothing was written |
2654
+ | `exportBundle(o)` | `{ spec, closeScripts, registryDir, outDir, bareloopVersion }` → `{ ok, reds, dir, bundleHash, manifest }` | mints the directory above. Every check runs before anything touches disk — a red means nothing was written. **F132:** a stage carrying a signed `close[].sha256` gets it RE-SIGNED in the written `spec.json` over the RELOCATED bytes (post import-rewrite) it actually packs — not copied verbatim from the source spec. Without this, a script whose import gets rewritten (`../src/x.js` → `'bareloop'`) ships with a signature that covers the wrong bytes, and the bundle refuses itself as `close-tampered` at $0 on its very first run (the manifest hash and the spec signature disagree over two different byte strings for the same file) |
2554
2655
  | `bundleHash(dir)` | `dir` → sha256 hex | recomputes from what is on disk right now; throws only if `dir` itself is not a directory |
2555
2656
  | `readBundle(dir)` | `dir` → `{ ok, reds, spec, manifest, bridges, blessing, history }` | never throws; every failure is a typed red. Runs the tamper check (below) whenever both a manifest and a spec parsed |
2556
2657
  | `resolveBundleSpec(bundle, bundleDir)` | a `readBundle` result + the bundle dir → `{ spec, approveHash }` | the in-memory `$BARELOOP_BUNDLE` → absolute `bundleDir` substitution; `approveHash` is `jobSpecHash` of the *substituted* spec, never the unresolved one |
@@ -2572,7 +2673,12 @@ layout table at all. `history.jsonl`'s row also carries `bundleHash` and `approv
2572
2673
  | `close-script-collision` | `exportBundle` | two different close script paths share one basename — a flat `close/` directory would silently clobber one |
2573
2674
  | `close-import-unparsed` | `exportBundle` | a close script's import is a shape this module cannot verify at all — default import, `* as ns`, mixed default+named/namespace, a bare side-effect import, a dynamic `import()`, or a relative import that does not point into `src/` (a sibling file never ships in `close/`). Fails safe rather than silently accepting what it cannot read |
2574
2675
  | `close-import-unexported` | `exportBundle` | a close script imports a name from bareloop's own `src` that `src/index.js` does not export |
2575
- | `close-absolute-path` | `exportBundle` (F129) | a close script's source bakes in a quoted string literal that is an absolute POSIX path which `existsSync` finds real on the exporting machine and which is not under an allow-listed system prefix (`/usr/`, `/bin/`, `/sbin/`, `/lib/`, `/lib64/`, `/dev/`, `/etc/`, `/proc/`, `/sys/`, `/opt/`). **A close judges `process.cwd()` — the runner's cwd — never a path baked into the script (F8/F129):** the live defect this catches is a hardcoded `WORKDIR` making a close judge the ORIGINAL patient checkout instead of the fresh worktree it was actually pointed at, minting a fake `already-green` at $0. A nonexistent absolute-looking literal is a NAME, not a proven hazard, and is not flagged — the check is deliberately monotone and simple, not clever (see `absolutePathLiteralsOf`'s JSDoc for its named limits: string concatenation and cross-machine-only paths are invisible to it) |
2676
+ | `close-absolute-path` | `exportBundle` (F129) AND `runPlan` (PRD item 27, every job) | a close script's source bakes in a quoted string literal that is an absolute POSIX path which `existsSync` finds real on this machine and which is not under an allow-listed system prefix (`/usr/`, `/bin/`, `/sbin/`, `/lib/`, `/lib64/`, `/dev/`, `/etc/`, `/proc/`, `/sys/`, `/opt/`). **A close judges `process.cwd()` — the runner's cwd — never a path baked into the script (F8/F129):** the live defect this catches is a hardcoded `WORKDIR` making a close judge the ORIGINAL patient checkout instead of the fresh worktree it was actually pointed at, minting a fake `already-green` at $0. A nonexistent absolute-looking literal is a NAME, not a proven hazard, and is not flagged — the check is deliberately monotone and simple, not clever (see `absolutePathLiteralsOf`'s JSDoc, `src/close-integrity.js`, for its named limits: string concatenation and cross-machine-only paths are invisible to it). `runPlan` reds this at run start, before the close-first precheck and before any provider call — $0, nothing spent |
2677
+ | `close-sha-mismatch` | `exportBundle` (PRD item 27/M2) | a `close[].sha256` the spec carries does not match the bytes of the script actually being packed into the bundle — the two signatures (this one, and `bundleHash`'s manifest hash over the rewritten script) cover the same bytes by two different paths and must never drift apart silently (N4's own hazard, one layer up). Only fires when a value is PRESENT and wrong; a spec with no sha256 at all reds `missing-required` upstream, at `validateJob` |
2678
+ | `close-tampered` | `runPlan` (PRD item 27/M2) | a close script's bytes no longer match its signed `sha256` — checked at run start (alongside `close-absolute-path`, before the close-first precheck and before any provider call) AND before EVERY close run thereafter (`runCloseStages`'s wrapper in `src/planrun.js`, the one seam the precheck/preflight/check-passes/close-fix-loop all share). Distinct from `close-red` (a judged "no" from a working close) and from `close-crashed`/`close-timeout`/`close-killed` (the close ran and hit an instrument fault): this is decided BEFORE the close is even spawned, and it means the close that ran would not have been the one the operator approved. Registered in `CLOSE_FAULTS` (`src/ralph.js`) so it rides out through the same forbidden-zone machinery as every other close fault — never retried, never fed back as a gap |
2679
+ | `close-dir-required` | `runPlan` (PRD item 27/M3 Part B) | a close script's source mentions `BARELOOP_CLOSE_DIR` (it reads its own books directory) but the runner was given no `closeDir` at all — `checkCloseDirRequired` (`src/close-integrity.js`) reuses the same `readCloseScripts` reader the other two integrity checks share. $0, run start, before the close-first precheck and before any provider call |
2680
+ | `close-timing-red` | `runPlan` (PRD item 27/M3) | the $0 timing preflight (`resolveCloseTimeoutMs`/`timeCloseStages`, `src/closetimeout.js`) ran every close stage once, ignoring verdicts, and at least one never finished within the provisional ceiling (`TIMING_PREFLIGHT_CEILING_MS`, the library default × K = 600s) — the close cannot run on this machine in a boundable time. Only reachable when NEITHER a signed `job.closeTimeoutMs` NOR a caller-passed runtime override is present (both skip the pass entirely) |
2681
+ | `wall-under-close-timeout` | `runPlan` (PRD item 27/M3) | `job.maxWallMs` is set and is under the EFFECTIVE per-stage close timeout just resolved (autoset or signed) — a budget under one close cannot fund its own close. `validateJob`'s own `MIN_WALL_MS` floor only bounds the un-autoset library default; this run-start check is the honest shape once the real ceiling is known, rather than a silent clamp |
2576
2682
  | `bundle-missing` | `readBundle` | `dir` is not a directory at all |
2577
2683
  | `manifest-invalid` / `spec-invalid` | `readBundle` | `manifest.json`/`spec.json` could not be read or parsed |
2578
2684
  | `bundle-tampered` | `readBundle` | manifest and spec both parsed, but the recomputed `bundleHash` (from what's really on disk) does not match the stored one — **the load-bearing check, N4 below** |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -20,7 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "scripts": {
23
- "test": "node --test && node scripts/prompt-commit-check.mjs --range origin/main..HEAD",
23
+ "test": "node scripts/hermetic.mjs && node scripts/prompt-commit-check.mjs --range origin/main..HEAD",
24
24
  "typecheck": "tsc --noEmit",
25
25
  "build:types": "tsc",
26
26
  "prepublishOnly": "npm run build:types"
package/src/authorjob.js CHANGED
@@ -654,7 +654,21 @@ export async function authorCloseForJob({
654
654
  /** The fields `assembleSpec` writes, and therefore the fields a DRAFT must not
655
655
  * carry. Named as data so the refusal and the fold can never disagree about
656
656
  * which half of the spec is which. */
657
- export const AUTHORED_SPEC_FIELDS = Object.freeze(['close', 'closeDecl', 'verdictType']);
657
+ // `sha256` (PRD item 27/M2) rides alongside `close`/`closeDecl`/`verdictType`:
658
+ // it is arbiter territory exactly like a budget or a wall clock — the agent
659
+ // never mints a close-bytes signature (only `signCloseScripts`/hamr's own
660
+ // re-sign does), so a draft carrying the field at all is refused the same
661
+ // way one carrying a raw `close` array is. A `closeDecl` spec has no script
662
+ // path to sign in the first place (M1's own kind executor, never a shipped
663
+ // script), so this can only ever fire on a smuggled/legacy-shaped draft —
664
+ // named here anyway, because "arbiter fields are refused by name" is the
665
+ // rule, not "arbiter fields that are currently reachable are refused".
666
+ // `closeTimeoutMs` (PRD item 27/M3) joins the same list for the same reason:
667
+ // it is arbiter territory exactly like the timeout floor/multiplier
668
+ // themselves — the agent never mints a close-timeout override (only the
669
+ // operator's own signed spec edit does), so a draft carrying it is refused
670
+ // the same way one carrying a raw `close` array is.
671
+ export const AUTHORED_SPEC_FIELDS = Object.freeze(['close', 'closeDecl', 'verdictType', 'sha256', 'closeTimeoutMs']);
658
672
 
659
673
  /**
660
674
  * Fold an authored close into the OPERATOR's own half of the spec.
package/src/bundle.js CHANGED
@@ -27,6 +27,7 @@ import { jobSpecHash } from './job.js';
27
27
  import { loadRegistry } from './bridges.js';
28
28
  import { closeStagesOf } from './plan.js';
29
29
  import { shapeForkName } from './reuse.js';
30
+ import { absolutePathLiteralsOf, hashCloseScriptBytes } from './close-integrity.js';
30
31
  // The live export list IS `src/index.js`'s own `Object.keys` — never a second,
31
32
  // hand-kept copy that can drift from the real surface a close script can
32
33
  // legally import. This module is itself exported from `index.js` (a cycle by
@@ -128,50 +129,10 @@ function isRelative(spec) {
128
129
  return spec.startsWith('./') || spec.startsWith('../');
129
130
  }
130
131
 
131
- // Every quoted string literal (single/double/backtick). Backtick literals
132
- // carrying `${…}` interpolation are filtered out below — they are not a
133
- // fixed literal, so they cannot be "a path baked into the script" at all.
134
- const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/g;
135
-
136
- // A close script is entitled to name a real system path (`/usr/bin/env`,
137
- // `/etc/hosts`, …) without that being the F8/F129 hazard — only a path this
138
- // bundle would actually relocate (a patient checkout, a scratch dir) is.
139
- const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/dev/', '/etc/', '/proc/', '/sys/', '/opt/'];
140
-
141
- /**
142
- * F8/F129 — a close judges the cwd the runner gives it, never a path baked
143
- * into the script itself (`src/ralph.js`'s `cwd` is load-bearing precisely
144
- * because every close script in this repo's OWN test suite named an
145
- * absolute path, so cwd never mattered there — see F8). This scans every
146
- * quoted string literal in a close script's source for an absolute POSIX
147
- * path (`/…`) that EXISTS on the exporting machine right now and is not
148
- * under an allow-listed system prefix.
149
- *
150
- * Deliberately monotone and simple, not clever, with named limits: a
151
- * nonexistent absolute-looking string is treated as a NAME, not a path —
152
- * `existsSync` is the only oracle this has for "load-bearing", so it is not
153
- * flagged; a path built by string concatenation (never one whole literal)
154
- * is invisible to this scan; and a literal that only exists inside a `//` or
155
- * `/* *\/` comment is naturally excluded because a comment carries no quote
156
- * characters of its own around the path text.
157
- * @param {string} source close script text
158
- * @returns {string[]} the offending literal path values, in source order
159
- */
160
- function absolutePathLiteralsOf(source) {
161
- /** @type {string[]} */
162
- const hits = [];
163
- for (const m of source.matchAll(STRING_LITERAL_RE)) {
164
- const isBacktick = m[3] !== undefined;
165
- const value = m[1] ?? m[2] ?? m[3];
166
- if (value === undefined) continue;
167
- if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
168
- if (!value.startsWith('/')) continue;
169
- if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;
170
- if (!existsSync(value)) continue; // a nonexistent path is a name, not a hazard
171
- hits.push(value);
172
- }
173
- return hits;
174
- }
132
+ // F8/F129 — `absolutePathLiteralsOf` (a close judges the cwd the runner
133
+ // gives it, never a path baked into the script itself) now lives in
134
+ // `src/close-integrity.js`, shared with the run-start precheck that applies
135
+ // the SAME rule to every job, not only exports. Never a second spelling.
175
136
 
176
137
  /**
177
138
  * Every import in a close script this bundle has an opinion about: a
@@ -378,6 +339,20 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
378
339
  for (const lit of absolutePathLiteralsOf(source)) {
379
340
  red('close-absolute-path', at, `${basename(scriptPath)}: bakes in the absolute path "${lit}" — a close judges the cwd the runner gives it, never a path baked into the script (F8/F129)`);
380
341
  }
342
+ // PRD item 27/M2 — the spec's OWN sha256 (if it carries one) must agree
343
+ // with the bytes actually being packed into the bundle: the two
344
+ // signatures (this one, and `bundleHash`'s manifest hash over the
345
+ // rewritten script) cover the same bytes by two different paths, and N4
346
+ // is exactly the hazard of letting a spec's signature drift from what it
347
+ // names. A spec with no sha256 at all is a SEPARATE red (`validateJob`'s
348
+ // `missing-required`, upstream of export) — this only fires when a value
349
+ // is present and wrong.
350
+ if (isNonEmptyString(stage.sha256)) {
351
+ const actual = hashCloseScriptBytes(source);
352
+ if (actual !== stage.sha256) {
353
+ red('close-sha-mismatch', at, `${basename(scriptPath)}: signed sha256 ${stage.sha256.slice(0, 12)}… does not match the script bytes being packed (${actual.slice(0, 12)}…) — re-sign before exporting`);
354
+ }
355
+ }
381
356
  });
382
357
 
383
358
  if (reds.length > 0) return fail();
@@ -391,10 +366,19 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
391
366
 
392
367
  /** @type {Record<string, string>} */
393
368
  const files = {};
369
+ // F132 — keyed by scriptPath (not name) so the spec-rewrite pass below can
370
+ // look up each relocated stage's REWRITTEN-bytes hash without re-hashing.
371
+ // Both `sha256` here and `hashCloseScriptBytes` (src/close-integrity.js)
372
+ // hash the same way (sha256 of the UTF-8 script text) — this is not a
373
+ // second formula, just this module's own existing helper reused.
374
+ /** @type {Map<string, string>} scriptPath -> sha256 of the RELOCATED bytes */
375
+ const relocatedSha = new Map();
394
376
  for (const [scriptPath, name] of byPath) {
395
377
  const rewritten = rewriteSrcImports(scripts[scriptPath]);
396
378
  writeFileSync(join(closeDir, name), rewritten);
397
- files[`close/${name}`] = sha256(rewritten);
379
+ const hash = sha256(rewritten);
380
+ files[`close/${name}`] = hash;
381
+ relocatedSha.set(scriptPath, hash);
398
382
  }
399
383
 
400
384
  const specOut = { ...spec, close: spec.close.map((/** @type {any} */ stage) => {
@@ -404,7 +388,17 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
404
388
  if (!parsed.ok) throw new Error(`unreachable: ${stage.cmd} was validated as a relocatable close cmd`);
405
389
  const name = byPath.get(parsed.scriptPath);
406
390
  const cmd = ['node', `${BUNDLE_TOKEN}/close/${name}`, ...parsed.rest].join(' ');
407
- return { ...stage, cmd };
391
+ // F132 (run mtqwmb9l/mtqwydl4): the bundle's manifest hash covers the
392
+ // RELOCATED bytes (the import rewrite above), but the spec's own
393
+ // `close[].sha256` was left carrying the SOURCE spec's signature — two
394
+ // signatures over two different byte strings, so `checkCloseByteSignature`
395
+ // at run start correctly read the bundle as tampered even though nothing
396
+ // was. Re-sign here, over the bytes actually being packed, so the two
397
+ // signatures agree by construction. Only a stage that already carried a
398
+ // signature gets one written back — a stage export never fabricates a
399
+ // signature that was never there (M2 leaves that to the validator).
400
+ const sha256Out = typeof stage.sha256 === 'string' ? (relocatedSha.get(parsed.scriptPath) ?? stage.sha256) : stage.sha256;
401
+ return { ...stage, cmd, sha256: sha256Out };
408
402
  }) };
409
403
  const specText = `${JSON.stringify(specOut, null, 2)}\n`;
410
404
  writeFileSync(join(outDir, 'spec.json'), specText);
package/src/cli.js CHANGED
@@ -300,6 +300,14 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
300
300
  mkdirSync(runsDir, { recursive: true });
301
301
  const spineFile = join(runsDir, 'spine.jsonl');
302
302
  const emit = makeSpine(spineFile);
303
+ // PRD item 27/M3 Part B — the close's own books directory, inside the run's
304
+ // own directory so the bundle stays one self-contained unit (hamr,
305
+ // 2026-09-07: "export holds as one unit of itself"). `runJob` autosets the
306
+ // close timeout itself (no `closeTimeoutMs` passed — the bundle CLI has
307
+ // always run on the library default/autoset path, never run-u's operator
308
+ // knob).
309
+ const closeDir = join(runsDir, 'close');
310
+ mkdirSync(closeDir, { recursive: true });
303
311
 
304
312
  let outcome;
305
313
  try {
@@ -310,9 +318,13 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
310
318
  providerFor,
311
319
  judgeProvider,
312
320
  emit,
321
+ closeDir,
313
322
  shellCapUsd: runSpec.budgetUsd,
314
323
  readShim: 'cap',
315
324
  scout: true,
325
+ // F130/PRD item 27(c) — the bundle CLI has no `--resume` (v1); the
326
+ // honest tail says so instead of naming a flag that does not exist.
327
+ resumable: false,
316
328
  });
317
329
  } catch (e) {
318
330
  err(`runJob crashed: ${/** @type {Error} */ (e).message}`);