bareloop 0.20.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.
@@ -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):
@@ -2511,6 +2546,292 @@ An unresolvable `--range` (no such ref — a fresh clone with no `origin`, a det
2511
2546
  checkout that never fetched) is a **skip**, printed and exited 0: a missing baseline is
2512
2547
  not a rule violation, never a red.
2513
2548
 
2549
+ ### Bundles — export a job, run it elsewhere (`src/bundle.js`, `src/cli.js`, `bin/bareloop.mjs`)
2550
+
2551
+ `docs/product/EXPORT-BUILD.md` (frozen build spec, 2026-09-05). A **bundle** is a plain
2552
+ directory that ships a proven job — spec, close scripts, and registry history — so it can
2553
+ run on a different machine without this repo. `npm pack` is its shipping form (there is no
2554
+ separate tarball step).
2555
+
2556
+ ```
2557
+ <job>.bareloop/
2558
+ manifest.json { schema:"bundle-v1", job, exportedAt, bareloopVersion, files:{...}, bundleHash }
2559
+ spec.json the job spec; close[].cmd rewritten to "node $BARELOOP_BUNDLE/close/<script> …"
2560
+ close/<script>.mjs the close script(s), verbatim except `from '../src/…'` → `from 'bareloop'`
2561
+ bridges/*.json the job's whole registry history for this job name (greens and reds)
2562
+ README.md the operator questions, in the order `bareloop run` asks them
2563
+ blessing.json absent at export; written by the run that first greens it
2564
+ history.jsonl absent at export; one line appended per `bareloop run` on this machine
2565
+ runs/<runid>/ absent at export; one per run — that run's own spine.jsonl + gate-audit.jsonl
2566
+ ```
2567
+
2568
+ **`bundleHash`** = sha256 over the sorted `path:contentSha256` lines of exactly `spec.json`
2569
+ and every `close/*` file (`hashFilesMap` in `src/bundle.js`) — nothing else. `manifest.json`,
2570
+ `README.md`, `bridges/*`, `blessing.json`, `history.jsonl` and `runs/` are all **outside**
2571
+ the hash on purpose: they are records, not the arbiter. This is the one signature a human
2572
+ approves and the CLI stores.
2573
+
2574
+ **Code-vs-spec, verified by reading `src/bundle.js`/`src/cli.js`:** `exportBundle` DOES write
2575
+ a `package.json` (name/version/`dependencies: {"bareloop": "^…"}`, `private: true`, `type:
2576
+ "module"`), outside `bundleHash` on purpose (editing it never trips `bundle-tampered`) — this
2577
+ corrects an earlier, now-stale reading of this section that claimed no `package.json` was
2578
+ written; `tests/bundle.test.js` proves both the write and the hash-exclusion directly. The
2579
+ `runs/<runid>/` directory (this run's relocated `spine.jsonl`/`gate-audit.jsonl`,
2580
+ written by `bareloop run`, step 6 below) is real and live but is not in the frozen spec's
2581
+ layout table at all. `history.jsonl`'s row also carries `bundleHash` and `approveHash`
2582
+ (the POC-fact correction, below) beyond the fields the original layout table named.
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
+
2650
+ #### `src/bundle.js` — pure, no provider/process calls
2651
+
2652
+ | function | args → returns | notes |
2653
+ |---|---|---|
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) |
2655
+ | `bundleHash(dir)` | `dir` → sha256 hex | recomputes from what is on disk right now; throws only if `dir` itself is not a directory |
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 |
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 |
2658
+ | `checkEnvelope(spec, envelope)` | `spec` (already resolved) + `{ budgetUsd?, maxWallMs? }` → `{ ok, reds }` | tighten-only: a given number must be ≤ the spec's own |
2659
+ | `bless(dir, record)` | `dir` + `{ bundleHash, runid, outcome, host?, blessedAt? }` → the blessing object | writes/overwrites `blessing.json`; there is only ever one, for the bundle's current hash |
2660
+ | `verifyBlessing(bundle)` | a `readBundle` result → `{ ok, unblessed, reds }` | `unblessed: true` (no red) means never blessed here; a red means blessed-but-stale |
2661
+ | `appendHistory(dir, row)` | `dir` + a plain object → `void` | atomic append to `history.jsonl` (`appendFileSync`, same convention as `spine.js`/`ledger.js`) |
2662
+
2663
+ **Every red code `src/bundle.js` can emit, and when:**
2664
+
2665
+ | code | fires in | meaning |
2666
+ |---|---|---|
2667
+ | `invalid-value` | `exportBundle` (`spec.job` missing, `spec.close` empty/absent, `outDir` exists and is not a directory), `checkEnvelope` (a given `budgetUsd`/`maxWallMs` is not a positive finite number) | a shape the caller must fix before minting/tightening anything |
2668
+ | `outdir-not-empty` | `exportBundle` | `outDir` exists and already holds files — export never merges into a prior bundle |
2669
+ | `registry-unreadable` | `exportBundle` | `registryDir` itself could not be read (bubbled from `loadRegistry`) |
2670
+ | `no-bridge-at-hash` | `exportBundle` | no bridge named `spec.job` in `registryDir` carries a green (bridge-level or on any stored version) at `jobSpecHash(spec)` — export ships a run that actually happened, never an unproven draft |
2671
+ | `close-cmd-unrelocatable` | `exportBundle` | a `close[].cmd` is not exactly `node <absolute path>.mjs …` — the one shape this module can relocate under `$BARELOOP_BUNDLE` |
2672
+ | `close-script-missing` | `exportBundle` | a stage's `cmd` names a script `closeScripts` carries no source for |
2673
+ | `close-script-collision` | `exportBundle` | two different close script paths share one basename — a flat `close/` directory would silently clobber one |
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 |
2675
+ | `close-import-unexported` | `exportBundle` | a close script imports a name from bareloop's own `src` that `src/index.js` does not export |
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 |
2682
+ | `bundle-missing` | `readBundle` | `dir` is not a directory at all |
2683
+ | `manifest-invalid` / `spec-invalid` | `readBundle` | `manifest.json`/`spec.json` could not be read or parsed |
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** |
2685
+ | `bundle-deps-missing` | `checkBundleDeps` (F128) | the bundle's own `close/` directory cannot resolve `require.resolve('bareloop')` — the bundle was installed the WRONG way (as someone else's dependency) instead of `cd <bundleDir> && npm install`. `bareloop run` calls this as literal step 1b, right after the tamper check, before the envelope check, the key, and any worktree |
2686
+ | `envelope-widen` | `checkEnvelope` | a given `budgetUsd`/`maxWallMs` exceeds the bundle's own signed ceiling — raising one is a re-export, never a runner flag |
2687
+ | `blessing-stale` | `verifyBlessing` | `blessing.json` exists but its `bundleHash` no longer matches the manifest's — the bundle was re-exported/edited since the run that blessed it; re-export and re-approve |
2688
+
2689
+ **N4 — why `readBundle`'s tamper check must run before anything else touches the bundle.**
2690
+ `jobSpecHash` covers `close[].cmd` as a *path*, not the script's bytes, so a swapped close
2691
+ script leaves the signed spec's own hash intact. `runJob`'s close-first precheck
2692
+ (`src/planrun.js`) would then read the swapped script's fake "already green" as a genuine
2693
+ one — a fake green, $0 spent, no work done, reported as success. `readBundle`'s
2694
+ `bundle-tampered` red is therefore not a courtesy check: it is the only thing standing
2695
+ between a swapped script and a fake green, so `bareloop run` calls it as literal step 1,
2696
+ before the envelope check, before the key check, before the worktree, before `runJob` is
2697
+ ever reached.
2698
+
2699
+ #### The CLI — `bareloop export | run | history`, `bin/bareloop.mjs`
2700
+
2701
+ `bin/bareloop.mjs` is a ~10-line adapter: it supplies the real `deps` (real `env`/`stdout`/
2702
+ `stderr`/`cwd`/`stdin`) and turns the returned number into `process.exitCode` — never
2703
+ `process.exit()`, which can discard queued stdout under a slow reader. All logic lives in
2704
+ `src/cli.js`'s `main(argv, deps)`, exported from the package root as **`cliMain`**
2705
+ (`export { main as cliMain } from './cli.js'` — renamed on export so it never collides with
2706
+ some other module's own generic `main`). `cliMain(argv, deps)` is the in-process test/
2707
+ integration seam: pass `deps.provider` (and optionally `providerFor`/`judgeProvider`) and the
2708
+ real `ANTHROPIC_API_KEY` check is skipped entirely — a scripted provider *is* the run, the
2709
+ same seam `tests/planrun.test.js` uses. `deps` also accepts `env`, `stdout`, `stderr`, `cwd`,
2710
+ `now`, `stdin` — omit any of them and the real `process.*` equivalent is used.
2711
+
2712
+ Bare `bareloop` (no sub-command) prints a numbered menu — `1 export 2 run 3 history q
2713
+ quit` — and asks each sub-command's arguments one line at a time over the SAME code paths
2714
+ below.
2715
+
2716
+ - **`bareloop export <jobs/x.json> --registry <dir> --out <dir>`** → resolves the spec's
2717
+ close-script paths against the spec file's own directory (never the process cwd), calls
2718
+ `exportBundle`. On success prints `bundleHash:`, the sorted file list, and "this bundle is
2719
+ unblessed until its first run greens on the importer's machine." Exit `1` on any red or
2720
+ missing flag, `0` on success.
2721
+
2722
+ - **`bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]`**
2723
+ — the exact order below; no step is ever reordered:
2724
+ 1. `readBundle(bundleDir)` — the tamper check (N4, above). Any red stops here, exit `1`.
2725
+ 2. `checkBundleDeps(bundleDir)` (F128) — can the bundle's own `close/` directory resolve
2726
+ `require.resolve('bareloop')`? A bundle installed the WRONG way (as someone else's
2727
+ dependency — see the corrected adopter flow below) has no `node_modules` of its own,
2728
+ which would otherwise crash the close deep inside the precheck with a bare
2729
+ `ERR_MODULE_NOT_FOUND`. A red here (`bundle-deps-missing`) prints the exact cure line
2730
+ (`cd <bundleDir> && npm install`) and stops, exit `1`, before the envelope check, the
2731
+ key, or any worktree.
2732
+ 3. `checkEnvelope(spec, { budgetUsd?, maxWallMs? })` — `--wall` is minutes, converted to ms.
2733
+ A red (an invalid number, or a widen) stops here, exit `1`.
2734
+ 4. The provider key: `deps.provider` if the caller injected one (the test seam), else
2735
+ `ANTHROPIC_API_KEY` from `env`. Absent and no injected provider: print the bundle's own
2736
+ `README.md` plus `bundleHash:`, spend nothing, **exit `0`** (this is a legitimate,
2737
+ non-error stop, not a red).
2738
+ 5. Blessing. No `blessing.json` yet: print the "first run" notice + the minting-run line
2739
+ (F128-corrected — see below), the `bundleHash`, and **require** `--approve <bundleHash>`
2740
+ to match exactly, or exit `1`. A `blessing.json` present: `verifyBlessing` must pass
2741
+ (`blessing-stale` stops here, exit `1`); a `--approve` flag is accepted but ignored with
2742
+ a printed note ("no-resign" — hamr's ruling).
2743
+ 6. The worktree: refuse (exit `1`) if `--repo` is not a git repo with ≥1 commit, or if
2744
+ `<repo>/.bareloop/wt/<runid>` already exists; otherwise `git worktree add --detach
2745
+ <repo>/.bareloop/wt/<runid> HEAD` — **always fresh, never reused** (a reused worktree
2746
+ would read a prior run's edits as "already-green" — a negative POC's exact finding).
2747
+ 7. `resolveBundleSpec` (the `$BARELOOP_BUNDLE` substitution) → tighten `budgetUsd`/
2748
+ `maxWallMs` on that resolved spec in memory if `--budget`/`--wall` were given → mint a
2749
+ **fresh** `approveHash = jobSpecHash(that tightened+resolved spec)` (the runner signs
2750
+ the spec it is actually about to run; the human-approved `bundleHash` never changes) →
2751
+ `runJob(runSpec, { approvals: [{ specHash: approveHash, signer: 'bundle', ts }], workdir:
2752
+ worktree, provider, providerFor, judgeProvider, emit, shellCapUsd: runSpec.budgetUsd,
2753
+ readShim: 'cap', scout: true })`, spine written to `<bundleDir>/runs/<runid>/spine.jsonl`.
2754
+ A thrown `runJob` crash is caught and reported, exit `1`.
2755
+ 8. The worktree's `gate-audit.jsonl` is moved to `<bundleDir>/runs/<runid>/gate-audit.jsonl`
2756
+ (same relocation `scripts/run-u.mjs` does for in-repo runs). `spentUsd`/`spendComplete`
2757
+ are read off this run's own `job-end` spine event — never fabricated as `0` when unknown.
2758
+ 9. `appendHistory` — one `history.jsonl` line: `{ runid, at, outcome, spentUsd,
2759
+ spendComplete, budgetUsd, maxWallMs, worktree, branch, bundleHash, approveHash }` (the
2760
+ `bundleHash` ↔ `approveHash` pairing, POC fact 2, so the human-signed hash and the
2761
+ hash actually enforced can never drift apart silently). A `green` outcome on a still-
2762
+ unblessed bundle also calls `bless(bundleDir, { bundleHash, runid, outcome, host })`.
2763
+ 10. The tail: `outcome`, `spent` (`≥$…` when `spendComplete === false`, `UNKNOWN` when
2764
+ `spentUsd` is `null`), `branch`, `worktree`, a `git merge <branch>` instruction
2765
+ ("merge stays human — this CLI never merges"), and "the worktree is kept until you
2766
+ remove it: `git worktree remove <worktree>`" (F110: never delete it for the user).
2767
+ **Exit `0` ONLY for `green`/`already-green` (F128) — every other outcome (close-red,
2768
+ plan-red, escalated, cap/wall-halted, provider-red, a crashed `runJob`, …) exits `1`,**
2769
+ so a caller scripting off the exit code can never mistake a red run for a green one.
2770
+ The tail's printed lines are unchanged either way.
2771
+
2772
+ **Adopter flow, corrected (F128, 2026-09-06 — hamr's paid fire found the frozen spec's
2773
+ validation step 2 named the wrong install shape):** `npm install <bundle dir>` run from a
2774
+ clean CONSUMER directory installs the bundle AS that consumer's own dependency — it never
2775
+ installs the bundle's OWN declared dependency (`package.json`'s `bareloop`), which its
2776
+ close scripts need at run time. The correct flow is **`cd <bundle> && npm install`** (reads
2777
+ the bundle's own `package.json`), then **`./node_modules/.bin/bareloop run . --repo <path>
2778
+ …`**. `checkBundleDeps` (above) is the fail-safe that catches the wrong-shape install
2779
+ before any close ever runs; see `docs/logs/FINDINGS.md` F128 for the full account
2780
+ (grounded in the real crashed spine) and `docs/product/EXPORT-BUILD.md`'s dated addendum.
2781
+
2782
+ **Minting-run line, corrected (F128).** The first-run notice used to print the shipped
2783
+ bridge's FIRST history row's `runid`, unconditionally. It now searches every bridge
2784
+ VERSION across all shipped bridges (base + shape fork) for the one whose `specHash` equals
2785
+ the resolved `$BARELOOP_BUNDLE` spec's `jobSpecHash`, printing `minting run: <runid> (spine
2786
+ not bundled in v1)` on a match or **`no version at this hash`** (never a guessed runid)
2787
+ when none matches — which, cross-machine, is the common case, since the historic mint's
2788
+ real close-script path and the importer's own bundle path essentially never coincide.
2789
+
2790
+ - **`bareloop history <bundleDir>`** → prints every `history.jsonl` line verbatim, then
2791
+ every bridge's `listingRow(b)` from `<bundleDir>/bridges`. Always exit `0`; "(no runs
2792
+ yet)" when `history.jsonl` doesn't exist.
2793
+
2794
+ **Tighten-only budget/wall.** `--budget`/`--wall` on `bareloop run` may only lower the
2795
+ bundle's own signed `budgetUsd`/`maxWallMs` — `checkEnvelope`'s `envelope-widen` red refuses
2796
+ anything higher. Raising a ceiling is a re-export (a new signed bundle), never a runner flag.
2797
+
2798
+ **First-run approval, blessing, no-resign.** The bundle's own signed `bundleHash` is what a
2799
+ human reads and accepts with `--approve` on the run that first blesses it. That first GREEN
2800
+ writes `blessing.json`; every later run on that machine, against that same unchanged bundle,
2801
+ needs no `--approve` at all ("no-resign", hamr's ruling) — it only has to pass
2802
+ `verifyBlessing`. Editing the bundle after blessing (or re-exporting it) changes
2803
+ `manifest.bundleHash`, so the stored blessing goes stale (`blessing-stale`) and the human
2804
+ has to approve again.
2805
+
2806
+ **Fresh worktree per run; merge stays human.** Every `bareloop run` creates a brand-new
2807
+ `git worktree add --detach <repo>/.bareloop/wt/<runid>` off `HEAD` and hands that path to
2808
+ `runJob` as `workdir` — the caller's own checkout is never touched, and no worktree is ever
2809
+ reused (reuse would let a prior run's uncommitted edits masquerade as this run's own green).
2810
+ The CLI never merges anything itself: the tail prints the exact `git merge <branch>` command
2811
+ and leaves the worktree in place until the human removes it.
2812
+
2813
+ **`history.jsonl` row shape** — one JSON object per line, one line per `bareloop run` on this
2814
+ machine: `{ runid, at, outcome, spentUsd, spendComplete, budgetUsd, maxWallMs, worktree,
2815
+ branch, bundleHash, approveHash }`. `bundleHash` is the bundle's own signed manifest hash
2816
+ (unchanged across tightened runs); `approveHash` is the hash of the actual, possibly-
2817
+ tightened, `$BARELOOP_BUNDLE`-resolved spec that run executed — the two are recorded side
2818
+ by side so a tightened run's real approval can never be confused with the bundle's headline
2819
+ signature.
2820
+
2821
+ **Vocabulary:** `close-red` means the close **instrument** itself broke — it could not run,
2822
+ never finished, was killed, or crashed without judging anything (`CLOSE_FAULTS`, above) —
2823
+ never a judgment about the work. `plan-red` means the close ran, judged, and rendered a real
2824
+ **no**. A `readBundle`/`checkEnvelope`/`checkBundleDeps` red is a bundle-shape, envelope, or
2825
+ dependency refusal (before any provider call); it is none of those three — call it what it
2826
+ is: a bundle-red, an envelope-red, or a deps-red, not a close verdict. (F128's live defect
2827
+ would, unfixed, have shown up as `close-red` at runtime for a reason that had nothing to do
2828
+ with the close itself — `checkBundleDeps` exists precisely so a wrong-shape install never
2829
+ reaches that far.)
2830
+
2831
+ **What is NOT in v1 (hamr Q7):** the review door at the run's tail, `soft-green`, any
2832
+ provider but `anthropic-api`, spine bundling for `formatReplay` on the minting run (only its
2833
+ `runid` is noted), and tarball packing — a bundle is a plain directory; use `npm pack` on it
2834
+ yourself if you want a tarball.
2514
2835
 
2515
2836
  ## Architecture
2516
2837
 
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // Export M2 — a thin adapter. ALL logic lives in `src/cli.js`; this file only
3
+ // supplies the real deps and turns the returned exit code into
4
+ // `process.exitCode` (never `process.exit()`, which can discard queued
5
+ // stdout under a slow reader).
6
+ import { main } from '../src/cli.js';
7
+
8
+ // A reader closing early (e.g. `| head`) makes a queued stdout write throw
9
+ // EPIPE — the one legitimate `process.exit()` in this file: the reader is
10
+ // gone, so there is no more queued output to lose by exiting immediately,
11
+ // and re-throwing would print a scary stack trace for ordinary pipe usage.
12
+ process.stdout.on('error', (e) => {
13
+ if (e.code === 'EPIPE') process.exit(0);
14
+ throw e;
15
+ });
16
+
17
+ const code = await main(process.argv.slice(2), {
18
+ env: process.env,
19
+ stdout: process.stdout,
20
+ stderr: process.stderr,
21
+ cwd: process.cwd(),
22
+ stdin: process.stdin,
23
+ });
24
+ process.exitCode = code;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.20.0",
4
- "description": "Workflows that earn their own design, with receipts \u2014 agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
3
+ "version": "0.22.0",
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",
7
7
  "types": "./types/index.d.ts",
@@ -11,13 +11,16 @@
11
11
  "default": "./src/index.js"
12
12
  }
13
13
  },
14
+ "bin": {
15
+ "bareloop": "bin/bareloop.mjs"
16
+ },
14
17
  "license": "Apache-2.0",
15
18
  "author": "hamr0",
16
19
  "engines": {
17
20
  "node": ">=20"
18
21
  },
19
22
  "scripts": {
20
- "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",
21
24
  "typecheck": "tsc --noEmit",
22
25
  "build:types": "tsc",
23
26
  "prepublishOnly": "npm run build:types"
@@ -38,6 +41,7 @@
38
41
  "homepage": "https://github.com/hamr0/bareloop#readme",
39
42
  "files": [
40
43
  "src/",
44
+ "bin/",
41
45
  "types/",
42
46
  "NOTICE",
43
47
  "bareloop.context.md",
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.