bareloop 0.21.0 → 0.23.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.
@@ -70,6 +70,19 @@ inherited rule carries the green that minted it and the contrast that attributed
70
70
  with `anthropic-api` on cost, and budgets don't transfer between them.
71
71
  `job.provider === 'clipipe-subscription'` with no `nativeProvider` supplied is
72
72
  `interpreter-red`, never a silent fall-back to the metered API (`src/planrun.js:1073`).
73
+ `provider: 'openai-api'` (PRD item 28) takes the ordinary `Loop` path against an
74
+ OpenAI-shaped endpoint, reads `OPENAI_API_KEY`, and accepts an optional job-level
75
+ `baseUrl` (https, or http on loopback only; credentials in the URL are a red). Its one
76
+ admitted model is `deepseek-chat`, which earned the slot with a real paid green — no
77
+ endpoint or model enters the menu without its own clean paid probe. The provider table
78
+ lives in `src/providers.js` (`resolveProvider`/`makeProvider`/`buildRunnerProviders`);
79
+ an unknown provider name THROWS there, it never falls back to a default. Per-model
80
+ request-key gating lives in that table: `deepseek-chat` sets bare-agent's
81
+ `legacyMaxTokens`, because DeepSeek silently ignores `max_completion_tokens` and an
82
+ output cap that does not bind is a money hazard. The JUDGE stays pinned to
83
+ `anthropic-api` and `JUDGE_MODEL` whatever the worker's provider is. The bundle runner
84
+ (`bareloop run`) is `ANTHROPIC_API_KEY`-only and REFUSES at $0, naming the key it would
85
+ have needed, rather than constructing another provider with the wrong key.
73
86
  - **Reuse — where a workflow comes from:** a plain `runJob` always drafts cold. Passing
74
87
  `bridge` starts from one standalone bridge file (`src/reuse.js`'s envelope, `## The reuse
75
88
  ENVELOPE and runReuse` below). The CLI's `--registry <dir>` / library-level `registryDir`
@@ -85,7 +98,7 @@ inherited rule carries the green that minted it and the contrast that attributed
85
98
 
86
99
  ```js
87
100
  import { runJob, jobSpecHash, makeSpine } from 'bareloop';
88
- import { AnthropicProvider } from 'bare-agent';
101
+ import { AnthropicProvider } from 'bare-agent/providers';
89
102
 
90
103
  const spec = {
91
104
  schema: 'job-v1', job: 'my-maintainer',
@@ -130,11 +143,12 @@ minting claim, or the shell-owned retry cap — all unknown-field reds.
130
143
  |---|---|---|
131
144
  | `job` | kebab-case slug | |
132
145
  | `description` | non-empty string | |
133
- | `provider` | `anthropic-api` \| `clipipe-subscription` | menu (`PROVIDERS`); part of the lineage key by definition. Only `anthropic-api` is guaranteed (F48); `clipipe-subscription` drives the worker natively and needs `opts.nativeProvider` |
146
+ | `provider` | `anthropic-api` \| `openai-api` \| `clipipe-subscription` | menu (`PROVIDERS`); part of the lineage key by definition. Only `anthropic-api` is guaranteed (F48); `openai-api` (PRD item 28) goes through the provider factory (`src/providers.js`), reads `OPENAI_API_KEY`, and admits only `deepseek-chat` today — see "Worker surface" above for its `baseUrl` field and per-model gating; `clipipe-subscription` drives the worker natively and needs `opts.nativeProvider` |
134
147
  | `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
148
  | `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
136
149
  | `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 |
150
+ | `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 |
151
+ | `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
152
  | `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
153
  | `writeScope` | array of contained globs | the operator's outer fence; the plan's own scopes must fit inside it, same containment code |
140
154
  | `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 +405,7 @@ Append-only JSONL event emitter bound to one file. `seq` monotonic per spine, `t
391
405
  last. Consumers are pure listeners; nothing reads the file back. Returns each event as
392
406
  written.
393
407
 
394
- ### `ralph({ middle, close?, judge?, capRuns?, ladder?, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites? })` → `'green' | 'escalated'` — `src/ralph.js`
408
+ ### `ralph({ middle, close?, judge?, capRuns?, ladder?, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites?, closeDir? })` → `'green' | 'escalated'` — `src/ralph.js`
395
409
 
396
410
  The dumb outer shell: `while close-red and not exhausted: run the middle`. **Exhaustion has
397
411
  two alternative rules and you supply exactly one** — `capRuns` (a fixed number of middle runs)
@@ -422,11 +436,23 @@ set the close's `gapKeep` (job-spec, arbiter territory) to a regex like `"^not o
422
436
  matching failure lines are preserved in a capped kept-failures block regardless of where
423
437
  they print (F28: the first real firing delivered a gap with zero failure names). The gap
424
438
  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.
439
+ `closeTimeoutMs` caps the close's wall clock (default 120s when nothing else supplies a
440
+ value — `runClose`'s own bare default) — shell/operator territory, inexpressible in any
441
+ config. A faulted close is asked to leave with SIGTERM and, if it is still there 2s later,
442
+ ended with SIGKILL: SIGTERM is a request a close can decline (its own handler, a trapping
443
+ wrapper), and without a second deadline the wait never resolves. The verdict is untouched —
444
+ the FIRST fault named the outcome and the kill only enforces it. **As of PRD item 27/M3, a
445
+ direct `ralph`/`runClose`/`runStages` caller still owns this number outright (`closeTimeoutMs
446
+ ?? 120_000`); the AUTOSET/override resolution lives one level up, in `runPlan`/`runJob`
447
+ (see `resolveCloseTimeoutMs` below) — `ralph` itself never runs a timing pass.**
448
+
449
+ `closeDir` (PRD item 27/M3 Part B, optional) is the close's own BOOKS DIRECTORY — pristine
450
+ copies, calibration thresholds, logs — deliberately outside the patient tree (the worker
451
+ must never read the arbiter's books, v1.12). Forwarded to every close invocation as the
452
+ env var `BARELOOP_CLOSE_DIR` (set on a COPY of `process.env`, after `CLOSE_ENV_DENY`
453
+ stripping — a `BARELOOP_` name matches none of that denylist's rules and is never stripped).
454
+ A close script that reads this variable and gets none must instrument-stop itself; nothing
455
+ in the runner invents a fallback directory.
430
456
 
431
457
  **The close child's environment is stripped of credential-shaped variables before it runs**
432
458
  (`CLOSE_ENV_DENY` in `src/ralph.js`: provider keys, `AWS_*`, any name ending
@@ -958,13 +984,33 @@ Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
958
984
  `coordination-red` — a failure between units (scope contention, step order, store
959
985
  races), never to be folded into worker/interpreter reds.
960
986
 
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`
987
+ ### `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`
988
+
989
+ **`closeTimeoutMs` (PRD item 27/M3) is now OPTIONAL for every real caller.** Omit it (both
990
+ `src/cli.js`'s bundle runner and `scripts/run-u.mjs` do) and the run AUTOSETS the per-stage
991
+ close ceiling from a $0 seed timing pass, or reads the job spec's own signed override —
992
+ "guesstimate + loud sign + customer override", the same shape rates-passthrough already
993
+ uses (F113). Passing an explicit value here still wins outright over both (kept for
994
+ backward compatibility and test control) — see `resolveCloseTimeoutMs` further down.
995
+
996
+ **`closeDir` (PRD item 27/M3 Part B, optional but effectively required for any
997
+ hand-authored close that reads its own books)** — the close's own directory (pristine
998
+ copies, calibration thresholds, logs), outside the patient tree. `src/cli.js` sets it to
999
+ `<bundleDir>/runs/<runid>/close/`; `scripts/run-u.mjs` sets it to its existing `spineDir`. A
1000
+ close script whose source mentions `BARELOOP_CLOSE_DIR` and gets none here refuses the run
1001
+ at $0 (`close-dir-required`, before any provider call) — see `checkCloseDirRequired` below.
962
1002
 
963
1003
  The last seven are the RESUME fold and are documented under *Resuming a killed run* below; they
964
1004
  default to `0` / `true` / `0` / `null` / `[]` / `null` / `null`, so a fresh run passes none of them.
965
1005
  Three of them are folds of a bound the operator SIGNED (money, wall, replans) and one is a
966
1006
  readout seed (grades) — the distinction matters and is spelled out there.
967
1007
 
1008
+ `resumable` (default `true`, PRD item 27(c)/F130) says whether THIS runner supports
1009
+ `--resume` at all — `run-u.mjs` leaves it at the default (byte-identical to before this
1010
+ flag existed); the exported bundle CLI (`src/cli.js`) passes `false` so the run's
1011
+ escalation tail says "resume is `run-u`-only in v1" instead of naming a flag it does not
1012
+ implement.
1013
+
968
1014
  The runner — the shell's top layer, and the ONE entry. It composes everything below it and
969
1015
  interprets nothing itself. Sequence: **approval gate** (human-signs-always — refuses an
970
1016
  unapproved spec before ANY provider call: `unapproved-spec`) → **primitive smoke** (litectx
@@ -975,7 +1021,18 @@ known-answer round-trip before tokens: `smoke-red` — a silent degradation thro
975
1021
  Outcomes: `green | already-green | escalated | unapproved-spec | job-red | smoke-red |
976
1022
  plan-red | check-red | close-red | close-unsupported | recipe-stale | branch-red | pricing-red |
977
1023
  provider-red | interpreter-red | cap-halt | wall-halt | step-stalled | hitl-pause |
978
- hitl-decision-red | step-red:<id>`.
1024
+ hitl-decision-red | step-red:<id> | runner-drained`.
1025
+
1026
+ **`runner-drained` (F140/PRD item 28(c))** is a distinct class from every outcome above: it is
1027
+ NOT `runJob`'s own return value, it is a `beforeExit` backstop's terminal, emitted from outside
1028
+ the normal control flow the instant node drains with no `job-end` at all — a provider promise
1029
+ that never settled (BA-25's class; bare-agent 0.42.0 closed the known drop cases per F141, but
1030
+ "an instrument fires on absence" stays a permanent class, not a fixed bug). It means: exits
1031
+ non-zero, `spendComplete:false` always (a drained run's in-flight spend is unknowable — the
1032
+ floor already banked is honest, an exact-looking total would not be, F6). **It is deliberately
1033
+ NOT in the resumable/checkpoint set** (`CHECKPOINT_OUTCOMES`/`RESUMABLE_HALTS`, `src/reuse.js`)
1034
+ — an unknown in-flight state, unlike a clean `cap-halt`/`wall-halt`/`provider-red`, is not a
1035
+ known-safe resume point.
979
1036
 
980
1037
  **`readShim` (default `false`) — the capped read seam, per ARM.** The value names which levers
981
1038
  run — the four arms of the frozen Phase 2 pre-registration:
@@ -1158,17 +1215,36 @@ round of this attempt being priced repairs nothing about the one before it); and
1158
1215
  reporting with the rest end of gate"). Every worker Loop (scout, drafter, each step, the fix
1159
1216
  worker — the ONE seam in `planrun.js`) gets exactly ONE extra attempt, and only for a
1160
1217
  transport-class throw — fetch itself throwing with no HTTP response at all (a TLS fault,
1161
- `ECONNRESET`/`EPIPE`/`ETIMEDOUT`, `fetch failed` wrapping a network cause); an HTTP response
1162
- (4xx/5xx/429) is never retried here and rides bare-agent's own unchanged policy. The budget is
1163
- fixed (`TRANSPORT_MAX_ATTEMPTS` in `src/transport.js`) — never a job-spec or CLI knob, tighten-only
1164
- doctrine. Each retry emits a report-only `transport-retry` spine record (`{phase, attempt, error,
1165
- recovered}`, no cost field — the ledger is unaffected); the FIRST attempt threw before any usage
1166
- figure came back, so **any** transport retry floors `spendComplete` for the rest of the run even
1167
- when the retry recovers and the run finishes green — the same one-way flag class as `stalled`
1168
- and `cutMidCall`. A wall stop read BETWEEN iterations or steps has no call in flight,
1218
+ `ECONNRESET`/`EPIPE`/`ETIMEDOUT`, `fetch failed` wrapping a network cause), OR (bare-agent
1219
+ 0.42.0, BA-25) a response body cut after headers but before it completed — the provider's own
1220
+ `guardResponseSettles` rejects that as a retryable `ProviderError` tagged
1221
+ `context.bound:'transport'`, which `isTransportFailure` also recognizes (F141). An HTTP response
1222
+ (4xx/5xx generally) is never retried here and rides bare-agent's own unchanged policy. The budget
1223
+ is fixed (`TRANSPORT_MAX_ATTEMPTS` in `src/transport.js`) — never a job-spec or CLI knob,
1224
+ tighten-only doctrine. Each retry emits a report-only `transport-retry` spine record (`{phase,
1225
+ attempt, error, recovered}`, no cost field — the ledger is unaffected); the FIRST attempt threw
1226
+ before any usage figure came back, so **any** transport retry floors `spendComplete` for the rest
1227
+ of the run even when the retry recovers and the run finishes green — the same one-way flag class
1228
+ as `stalled` and `cutMidCall`. A wall stop read BETWEEN iterations or steps has no call in flight,
1169
1229
  so it stays exact. Both fields are present on all outcomes, so a consumer never branches on
1170
1230
  field presence and never has to launder a missing `spentUsd` into `$0`.
1171
1231
 
1232
+ A **rate-limit retry** (PRD item 28's parked a/b ruling, hamr verbatim: "do a/b as a fallback, and
1233
+ verify/validate + no regression"; F143/`src/ratelimit.js`) rides the SAME seam as a second,
1234
+ INDEPENDENT one-shot budget: an HTTP 429 gets ONE retry that honours the vendor's own stated
1235
+ wait, capped at 60s (`RATE_LIMIT_MAX_WAIT_MS` — a longer stated wait is not honoured at all, no
1236
+ retry). This is a FALLBACK, not the primary answer — the primary answer (F139) is picking a
1237
+ worker model with adequate rate headroom; this only softens the failure mode when that was not
1238
+ enough. The wall always wins here too: before sleeping, a retry that would cost more than
1239
+ `clock.remainingMs()` is refused and the error rides straight through, exactly like the transport
1240
+ ladder's own wall carve-out. Each retry emits a report-only `rate-limit-retry` spine record
1241
+ (`{phase, attempt, statedMs, waitedMs, error, recovered}`, no cost field). Unlike the transport
1242
+ retry, a rate-limit retry does **not** floor `spendComplete`: a 429 is a REFUSAL — the vendor
1243
+ rejected the request before processing it, so nothing can have been billed (a stated assumption,
1244
+ not independently measured against a real provider). The two budgets are independent — a
1245
+ transport throw and a 429 may both occur, and both retry, across one call's lifetime, but each at
1246
+ most once.
1247
+
1172
1248
  **The plan flow (Layer 2).** `job-start` carries `shape: 'plan'` + the goal; plan steps are
1173
1249
  tool-mode by construction. The flow (`runPlan`, also exported for direct callers who own
1174
1250
  their own ledger): **close precheck** (`already-green` is a
@@ -1601,7 +1677,7 @@ package makes). Which runs open one is `doorOpens(job, reviewDoor)`:
1601
1677
  territory.
1602
1678
 
1603
1679
  **Answering one — `answerReviewDoor({ job, workdir, events, decision, text?, closeTimeoutMs?,
1604
- registryDir?, name?, runid?, at?, now?, emit? })`** (`src/reviewdoor.js`). The answer arrives
1680
+ closeDir?, registryDir?, name?, runid?, at?, now?, emit? })`** (`src/reviewdoor.js`). The answer arrives
1605
1681
  after the run has ended, possibly days later and from another process, so it cannot ride the
1606
1682
  run's return path. This is that seam. It never throws and never conjures — a fourth door, an
1607
1683
  empty `rerun`, a run that opened no door (`no-door`), an expired one (`door-expired`), a tree
@@ -1612,7 +1688,11 @@ that moved (`door-accept-red`) and a missing registry all come back as named red
1612
1688
  a person) before it is honoured: hamr's ruling that an accept is not a rubber stamp, and the
1613
1689
  answer to a tree that can move in the 60 days a door keeps. A red REFUSES the accept with the
1614
1690
  stage named and records **nothing**. A pass records the disposition and, over a held judged
1615
- green, RELEASES the credit through module 6's `applyDoorDecision`.
1691
+ green, RELEASES the credit through module 6's `applyDoorDecision`. `scripts/run-u.mjs` resolves
1692
+ the re-run's ceiling itself via `resolveCloseTimeoutMs` before calling this, and refuses outright
1693
+ (F137) as a named `close-timing-red` door stop — `answerReviewDoor` never called, nothing
1694
+ recorded — if that timing pass itself times out, rather than falling through to `runClose`'s
1695
+ library default.
1616
1696
  - **`rerun`** carries the person's words back as a directive (`next: 'rerun'`) and re-proves
1617
1697
  nothing: the new run's close is what judges. Empty or whitespace text is refused at the same
1618
1698
  seam every other door is.
@@ -2481,7 +2561,9 @@ nothing here to check it against).
2481
2561
 
2482
2562
  Enforcement is **local only** — wired into `npm test` (see `package.json`'s `test`
2483
2563
  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
2564
+ enforced there without an ask-first CI edit). `npm test` itself now runs `node --test`
2565
+ through `scripts/hermetic.mjs`, which strips this machine's `HOME`/git identity so a
2566
+ test that leans on either fails locally instead of only on CI (F136). The check itself lives outside the
2485
2567
  published package, at `scripts/prompt-commit-check.mjs` (pure decision logic in
2486
2568
  `scripts/promptcommitlib.mjs`, `scripts/` is not in `package.json`'s `files`, so none of
2487
2569
  this ships):
@@ -2546,11 +2628,77 @@ written by `bareloop run`, step 6 below) is real and live but is not in the froz
2546
2628
  layout table at all. `history.jsonl`'s row also carries `bundleHash` and `approveHash`
2547
2629
  (the POC-fact correction, below) beyond the fields the original layout table named.
2548
2630
 
2631
+ #### `src/close-integrity.js` — close-absolute-path (M1) AND close-bytes signature (M2)
2632
+
2633
+ Shared by `src/bundle.js` (export time) and a $0 run-start precheck inside `runPlan`
2634
+ (`src/planrun.js`) — every job, not only exported bundles, refuses at $0 (before the
2635
+ close-first precheck, before any provider call) when a close script's CONTENT bakes in an
2636
+ absolute path that exists on disk. Two spellings of the same rule is exactly how F129
2637
+ shipped an export-only guard while every non-exported job stayed exposed.
2638
+
2639
+ **Scope (widened 2026-09-06, PRD item 27/M2, an orchestrator audit):** a close script is
2640
+ addressable — visible to `readCloseScripts`, `checkCloseAbsolutePaths`, the sha256
2641
+ fingerprint, and `signCloseScripts` — when its `cmd` is EITHER an interpreter
2642
+ (`node`/`sh`/`bash`/`python`/`python3`/`npx`) followed by a non-empty argv[1], OR a bare
2643
+ cmd whose argv[0] ITSELF is an absolute path (a `.sh`/other directly-executable close
2644
+ script, no interpreter prefix — e.g. `jobs/aurora-testgen-cold.json`'s close). A relative
2645
+ bare executable (`true`, `pytest`, `npm test`) names no file at all and stays out of
2646
+ scope, on both halves, exactly as before. The shape test itself
2647
+ (`closeScriptCandidateToken`, `src/validate.js`) is shared with `src/job.js`'s sha256
2648
+ demand so the demand and the detectors can never drift apart about which stages are in
2649
+ scope.
2650
+
2651
+ | function | args → returns | notes |
2652
+ |---|---|---|
2653
+ | `absolutePathLiteralsOf(source)` | close script text → `string[]` | the scan itself (moved here from `src/bundle.js`, which now imports it) |
2654
+ | `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` |
2655
+ | `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 |
2656
+ | `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 |
2657
+ | `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") |
2658
+ | `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` |
2659
+ | `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 |
2660
+ | `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 |
2661
+ | `CLOSE_DIR_ENV_VAR` | `'BARELOOP_CLOSE_DIR'` | the ONE spelling `checkCloseDirRequired`'s scan and `runClose`'s env-set (`src/ralph.js`) both use |
2662
+
2663
+ #### `src/closetimeout.js` — close timeout: autoset + signed override (M3)
2664
+
2665
+ hamr's ruling (2026-09-06): *"both … autoset and can be override, same like api pricing"*
2666
+ — the guesstimate-plus-loud-sign-plus-customer-override shape (F113), never a hidden
2667
+ defaulted knob. Arbiter constants, hamr 2026-09-07 verbatim: **floor = 120,000 ms; K = 5
2668
+ ("2 and 5 are fine")** — never agent- or adopter-settable.
2669
+
2670
+ | export | shape | notes |
2671
+ |---|---|---|
2672
+ | `CLOSE_TIMEOUT_FLOOR_MS` | `120_000` | the floor, hamr's arbiter constant |
2673
+ | `CLOSE_TIMEOUT_K` | `5` | the multiplier, hamr's arbiter constant |
2674
+ | `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 |
2675
+ | `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 |
2676
+ | `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` |
2677
+ | `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) |
2678
+ | `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 |
2679
+ | `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 |
2680
+
2681
+ **`scripts/sign-close.mjs`** — `node scripts/sign-close.mjs <jobs/x.json> [--write]` or
2682
+ `--all` for every `jobs/*.json`. Prints the old→new `jobSpecHash` and each stage's
2683
+ old→new sha256; NEVER writes unless `--write` is passed (a dry run is the default). The
2684
+ agent never runs this with `--write` on its own authority — minting a close-bytes
2685
+ signature is arbiter territory exactly like a budget or a wall clock, the same as the
2686
+ authoring pipeline's own refusal (`src/authorjob.js`'s `assembleSpec`, which refuses a
2687
+ draft carrying `sha256` the same way it refuses `close`/`closeDecl`/`verdictType`).
2688
+
2689
+ **`close[].sha256`** — a signed spec field (PRD item 27/M2): hex sha256 of the bytes of
2690
+ the script file the stage's `cmd` names (only for an addressable cmd, per the scope
2691
+ above). `validateJob` (`src/job.js`) demands it as `missing-required` on every addressable
2692
+ stage and shape-checks it (`invalid-value`, 64 lowercase hex chars) even where not
2693
+ demanded. It sits INSIDE the signed spec, so `jobSpecHash` covers it with no
2694
+ hash-function change — re-signing a job (minting or changing this field) moves the spec
2695
+ hash and needs re-approval, exactly like any other semantic edit.
2696
+
2549
2697
  #### `src/bundle.js` — pure, no provider/process calls
2550
2698
 
2551
2699
  | function | args → returns | notes |
2552
2700
  |---|---|---|
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 |
2701
+ | `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
2702
  | `bundleHash(dir)` | `dir` → sha256 hex | recomputes from what is on disk right now; throws only if `dir` itself is not a directory |
2555
2703
  | `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
2704
  | `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 +2720,12 @@ layout table at all. `history.jsonl`'s row also carries `bundleHash` and `approv
2572
2720
  | `close-script-collision` | `exportBundle` | two different close script paths share one basename — a flat `close/` directory would silently clobber one |
2573
2721
  | `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
2722
  | `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) |
2723
+ | `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 |
2724
+ | `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` |
2725
+ | `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 |
2726
+ | `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 |
2727
+ | `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). `scripts/run-u.mjs`'s review door hits the SAME red on its own `accept` timing pass (F137) — it refuses the door outright rather than calling `answerReviewDoor` at all, so nothing is recorded |
2728
+ | `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
2729
  | `bundle-missing` | `readBundle` | `dir` is not a directory at all |
2577
2730
  | `manifest-invalid` / `spec-invalid` | `readBundle` | `manifest.json`/`spec.json` could not be read or parsed |
2578
2731
  | `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.23.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,13 +20,13 @@
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"
27
27
  },
28
28
  "dependencies": {
29
- "bare-agent": "^0.39.0",
29
+ "bare-agent": "^0.42.0",
30
30
  "bareguard": "^0.13.0",
31
31
  "litectx": "^0.32.0"
32
32
  },
package/src/authorflow.js CHANGED
@@ -82,7 +82,7 @@ import {
82
82
  GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
83
83
  validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES,
84
84
  } from './authoring.js';
85
- import { buildSeedListing, cleanEntry, SURVEY_CAUSES } from './authorscout.js';
85
+ import { buildSeedListing, cleanEntry, SURVEY_CAUSES, AUTHOR_CALL_TIMEOUT_MS } from './authorscout.js';
86
86
  import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
87
87
  import { redactSecrets } from './validate.js';
88
88
 
@@ -1147,7 +1147,12 @@ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens =
1147
1147
  ...t,
1148
1148
  execute: async (/** @type {any} */ a) => { const r = await t.execute(a); loop.stop(); return r; },
1149
1149
  }));
1150
- return loop.run(messages, wired, { cacheMessages: true, maxTokens, ...opts });
1150
+ // F152/PRD 30.4 — the SAME idle bound `authorscout.js` reuses (this pipeline
1151
+ // has no wall clock of its own): a live endpoint that accepts and never
1152
+ // answers hangs the process forever otherwise. `...opts` still spreads AFTER
1153
+ // it, so a caller that ever needs a tighter number for one call may pass it —
1154
+ // tighten-only, and nothing does today.
1155
+ return loop.run(messages, wired, { cacheMessages: true, maxTokens, timeoutMs: AUTHOR_CALL_TIMEOUT_MS, ...opts });
1151
1156
  };
1152
1157
  }
1153
1158
 
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.
@@ -55,11 +55,37 @@ import { TOOL_MENU, WRITE_VERBS, STORE_VERBS } from './job.js';
55
55
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from './tools.js';
56
56
  import { redactSecrets, SECRET_PATTERNS } from './validate.js';
57
57
  import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop } from './text.js';
58
+ import { PROVIDER_TIMEOUT_MS } from './clock.js';
58
59
 
59
60
  const require = createRequire(import.meta.url);
60
61
  const { Loop, wireGate } = require('bare-agent');
61
62
  const { createShellTools } = require('bare-agent/tools');
62
63
 
64
+ /**
65
+ * F152/PRD 30.4 — the scout's per-call IDLE bound. Every `loop.run()` in this
66
+ * module carried NO time bound at all: a live endpoint that accepts and never
67
+ * answers hangs the process forever (measured) — an open socket is an active
68
+ * handle, so node never drains and no backstop can fire.
69
+ *
70
+ * UNLIKE the run path (`callBounds()`, src/planrun.js:1518), this pipeline has
71
+ * NO wall clock to derive a bound FROM — the authoring pipeline is genuinely
72
+ * clockless (`src/authorjob.js`/`src/authorflow.js`/`scripts/run-author.mjs`
73
+ * carry a money ceiling, `ceilingUsd`/`capStop`, and never a `maxWallMs`).
74
+ * Rather than inventing a fresh number, this reuses the EXACT ceiling the run
75
+ * path itself falls back to when ITS OWN wall is unbounded
76
+ * (`PROVIDER_TIMEOUT_MS`, `src/clock.js` — BA-18's idle timeout, already an
77
+ * approved "no wall present" default one layer up): an authoring-time call is
78
+ * the identical situation, one call earlier.
79
+ *
80
+ * It bounds SOCKET INACTIVITY only (reset by every byte, per BA-18) — a call
81
+ * that is merely slow keeps streaming and is untouched; only a truly silent
82
+ * endpoint trips it. TIGHTEN-ONLY, like every cap in this file: this is an
83
+ * operator-set ceiling fixed here, not a param a caller or a signed spec may
84
+ * widen (a defaulted cap is a silent second ceiling, and there is no clock
85
+ * whose narrower reading could ever tighten it further).
86
+ */
87
+ export const AUTHOR_CALL_TIMEOUT_MS = PROVIDER_TIMEOUT_MS;
88
+
63
89
  /** src/planrun.js:42 — the survey's hard round bound. */
64
90
  export const AUTHOR_SCOUT_ROUNDS = 8;
65
91
  /** src/planrun.js:44 — the survey is a PROMPT INGREDIENT for the authoring call. */
@@ -348,7 +374,7 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider
348
374
  *
349
375
  * @param {{workdir: string, provider?: any,
350
376
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
351
- * attempts?: number, ceilingUsd?: number|null,
377
+ * attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
352
378
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
353
379
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
354
380
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
@@ -365,10 +391,18 @@ export async function runAuthorScout({
365
391
  ctx = true,
366
392
  attempts = SCOUT_ATTEMPTS,
367
393
  ceilingUsd = null,
394
+ // F152/PRD 30.4 — TIGHTEN-ONLY, the same clamp shape `attempts` carries just
395
+ // below: an operator (a test included) may buy a SHORTER idle bound and never
396
+ // a longer one. A garbage/absent value floors at the module ceiling
397
+ // (`AUTHOR_CALL_TIMEOUT_MS`), never at "no bound at all".
398
+ callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS,
368
399
  onCall = () => {},
369
400
  createLoop = defaultLoop,
370
401
  createSurveyor = defaultSurveyor,
371
402
  }) {
403
+ const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
404
+ ? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS)
405
+ : AUTHOR_CALL_TIMEOUT_MS;
372
406
  const granted = AUTHOR_SCOUT_VERBS;
373
407
  const system = SCOUT_SYSTEM + strategyFor([...granted]);
374
408
  const surveyor = await createSurveyor({ workdir, granted, ctx });
@@ -404,6 +438,33 @@ export async function runAuthorScout({
404
438
  // an ABSENT nobody can act on.
405
439
  const allowed = Math.max(1, Math.min(Math.trunc(Number(attempts)) || 1, SCOUT_ATTEMPTS));
406
440
 
441
+ /**
442
+ * F152/PRD 30.4 — ONE call seam for every `loop.run()` below, so a REJECTED
443
+ * call (a `TimeoutError` off `AUTHOR_CALL_TIMEOUT_MS` included — bare-agent
444
+ * 0.42 rejects every known transport/idle-timeout casualty, F141) lands in the
445
+ * SAME place a call that merely RESOLVED with an error already did: as an
446
+ * `{error}` shape, read by `record()` (cost null, same as a `provider-red`
447
+ * locate call — a rejected call has no knowable cost) and by `classifySurvey`
448
+ * under the EXISTING `call-failed` cause. Without this seam a rejection would
449
+ * escape uncaught to `run-author.mjs`'s top-level crash handler instead of the
450
+ * honest, already-typed terminal this module was built to report through.
451
+ * @param {Promise<any>} p @returns {Promise<any>}
452
+ */
453
+ const settled = async (p) => {
454
+ try { return await p; } catch (e) {
455
+ // ONLY the call's own deadline lands here. Swallowing every throw would
456
+ // launder a budget HaltError, a programming bug, or any other named
457
+ // terminal into "the survey call failed" — the exact class of dishonesty
458
+ // this repo refuses (an unknown reported as a known). A timeout is the one
459
+ // throw this seam exists for: it IS a call that produced nothing, which is
460
+ // what `call-failed` already means. Everything else re-raises and is
461
+ // relayed after cleanup, as it was before this seam existed.
462
+ const err = /** @type {any} */ (e);
463
+ if (err?.code !== 'ETIMEDOUT' && err?.name !== 'TimeoutError') throw e;
464
+ return { error: String(err?.message ?? err) };
465
+ }
466
+ };
467
+
407
468
  try {
408
469
  /** @type {Survey|null} */
409
470
  let survey = null;
@@ -455,8 +516,8 @@ export async function runAuthorScout({
455
516
  };
456
517
  loop = createLoop({ system, policy: surveyor.policy, onLlmResult: metered, provider });
457
518
 
458
- const r = await loop.run([{ role: 'user', content: scoutPrompt(workdir) }], surveyor.tools,
459
- { cacheMessages: true, maxTokens });
519
+ const r = await settled(loop.run([{ role: 'user', content: scoutPrompt(workdir) }], surveyor.tools,
520
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout }));
460
521
  verdictAt = record(label, attempt, r);
461
522
  blob = redactSecrets(String(r?.text ?? '')).slice(0, blobMax);
462
523
  lastMsgs = Array.isArray(r?.msgs) && r.msgs.length ? r.msgs : null;
@@ -493,8 +554,16 @@ export async function runAuthorScout({
493
554
  if (recoveryHalt) budgetStop = recoveryHalt;
494
555
  if (recoveryWanted && !recoveryHalt) {
495
556
  const recovery = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
496
- s2 = await recovery.run([...r.msgs, { role: 'user', content: SCOUT_RECOVERY_PROMPT }], [],
497
- { cacheMessages: true, maxTokens });
557
+ // F147 (second instance; sibling fix: src/planrun.js `askFrom`, commit
558
+ // 031ffe1): `r.msgs` is a prior `loop.run()`'s returned transcript,
559
+ // which bare-agent already prepended `system` to (Loop.run, loop.js
560
+ // ~482-486). `recovery` below is built with the same `system` and
561
+ // would prepend it again, leaving two identical system messages at
562
+ // index 0/1 — real OpenAI tolerates it, vLLM-class OpenAI-compatible
563
+ // backends 400 on it. Strip the leading one before re-running.
564
+ const rMsgs = r.msgs[0]?.role === 'system' ? r.msgs.slice(1) : r.msgs;
565
+ s2 = await settled(recovery.run([...rMsgs, { role: 'user', content: SCOUT_RECOVERY_PROMPT }], [],
566
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout }));
498
567
  const recoveryAt = record('author-scout-recovery', attempt, s2);
499
568
  const t = redactSecrets(String(s2?.text ?? '')).slice(0, blobMax);
500
569
  // the verdict follows the blob that WON, never the call that came last
@@ -518,10 +587,15 @@ export async function runAuthorScout({
518
587
  // THE RE-ASK. Toolless by construction, over the survey's own
519
588
  // conversation, naming only what failed mechanically.
520
589
  const reask = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
521
- const rr = await reask.run(
522
- [...lastMsgs, { role: 'user', content: scoutReaskTurn(/** @type {Survey} */ (survey).reason ?? '') }], [],
523
- { cacheMessages: true, maxTokens },
524
- );
590
+ // F147 (second instance; sibling fix: src/planrun.js `askFrom`, commit
591
+ // 031ffe1): `lastMsgs` is a prior `loop.run()`'s returned transcript
592
+ // (system already prepended by bare-agent); `reask` is built with the
593
+ // same `system` and would prepend it again. Strip the leading one.
594
+ const askMsgs = lastMsgs[0]?.role === 'system' ? lastMsgs.slice(1) : lastMsgs;
595
+ const rr = await settled(reask.run(
596
+ [...askMsgs, { role: 'user', content: scoutReaskTurn(/** @type {Survey} */ (survey).reason ?? '') }], [],
597
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout },
598
+ ));
525
599
  verdictAt = record(label, attempt, rr);
526
600
  blob = redactSecrets(String(rr?.text ?? '')).slice(0, blobMax);
527
601
  if (Array.isArray(rr?.msgs) && rr.msgs.length) lastMsgs = rr.msgs;