bareloop 0.22.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,131 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.23.0] — 2026-09-09
9
+
10
+ ### Added
11
+
12
+ - **`openai-api` on the provider menu, behind a provider FACTORY** (PRD item 28,
13
+ F149/F150/F156/F157). `src/providers.js` holds one table — provider name →
14
+ constructor, env key, tier models, per-model request-key gating — and both
15
+ runners go through it instead of naming `AnthropicProvider` directly. An
16
+ unknown provider name THROWS; there is no silent default. `openai-api` takes
17
+ an optional validated job-level `baseUrl` (https, or http on loopback only)
18
+ and admits ONE model, `deepseek-chat`, which earned the slot with a real paid
19
+ green through the shipped runner (run `mtu12vks`: green, $1.64 of $4, 10.3 of
20
+ 30 min). DeepSeek silently ignores `max_completion_tokens`, so its table entry
21
+ sets bare-agent's `legacyMaxTokens` — an output cap that does not bind is a
22
+ money hazard. The judge stays PINNED to `anthropic-api` and `JUDGE_MODEL`
23
+ whatever the worker's provider is. `bareloop run` (the bundle runner) is
24
+ `ANTHROPIC_API_KEY`-only and REFUSES at $0, naming the key it would have
25
+ needed, rather than building another provider with the wrong key.
26
+ - **`provider` and `baseUrl` on the `job-start` spine record** (F157),
27
+ report-only. Without them an archive cannot separate anthropic rows from
28
+ `openai-api` rows, and every pooled cost or duration figure would be a
29
+ contaminated aggregate.
30
+ - **`stopReason` on every `worker-round`** (BA-13, report-only, null when
31
+ absent, never invented).
32
+
33
+ - **`runner-drained` — a `beforeExit` backstop for the absent-`job-end` class**
34
+ (PRD item 28's ruling (c); F140). A provider promise that never settles
35
+ (BA-25's class) used to let node drain and exit with NO terminal record at
36
+ all — no escalation, no outcome, no way to tell a silent drain from an
37
+ operator kill (F140: 10-of-235 archived spines with no `job-end`, one real
38
+ silent death). bare-agent 0.42 closed the known drop cases (F141), but the
39
+ class itself is "an instrument fires on absence," not a specific bug, so
40
+ the backstop stays needed. `runJob` (`src/run.js`) now registers ONE
41
+ `process.once('beforeExit', …)` right after `job-start`, tracked via a
42
+ local `ended` flag every `job-end` site sets; if the process still drains
43
+ with no `job-end`, it mints one with outcome `runner-drained`,
44
+ `spendComplete:false` (a drained run's in-flight spend is unknowable — the
45
+ floor already banked is honest, F6), and sets `process.exitCode` non-zero
46
+ (never `process.exit()`). The listener is removed in a `finally` so a
47
+ normal run never leaks it. `runner-drained` is a new terminal outcome,
48
+ documented in `bareloop.context.md`'s outcomes list — deliberately **not**
49
+ added to the resumable/checkpoint set (`CHECKPOINT_OUTCOMES`, `src/reuse.js`):
50
+ an unknown in-flight state is not a known-safe resume point.
51
+
52
+ - **Bounded 429 retry — a FALLBACK, not the primary answer** (PRD item 28's
53
+ parked a/b ruling, hamr verbatim: "do a/b as a fallback, and verify/validate
54
+ + no regression"; F143). A vendor HTTP 429 used to end the whole run as
55
+ `provider-red`, discarding all completed work, even though the vendor's own
56
+ response states exactly how long to wait. `src/ratelimit.js` (new,
57
+ `transport.js`'s sibling) adds ONE bounded retry that honours that stated
58
+ delay, capped at 60s (`RATE_LIMIT_MAX_WAIT_MS`) — a wait longer than that is
59
+ not honoured at all. Wired into `src/planrun.js`'s provider-retry seam
60
+ (`withTransportRetry` renamed `withProviderRetries`) as a SECOND,
61
+ independent one-shot budget alongside the existing F115 transport retry;
62
+ the wall always wins (no retry past the deadline); each retry emits a
63
+ report-only `rate-limit-retry` spine record and does NOT floor
64
+ `spendComplete` (a 429 is a refusal — nothing was billed — unlike a
65
+ transport throw). **The primary answer stays the primary answer**: picking
66
+ a worker model with adequate rate headroom (F139/F143's measured TPM
67
+ table) — this retry only softens the failure mode when that was not
68
+ followed or headroom was still exceeded.
69
+
70
+ ### Changed
71
+
72
+ - Bumped `bare-agent` pin from `^0.39.0` to `^0.42.0`. 0.42.0 brings: BA-24 —
73
+ `OpenAIProvider` now sends `max_completion_tokens` by default (GPT-5-safe),
74
+ with a new `legacyMaxTokens` option to send the legacy `max_tokens` key
75
+ instead; BA-25 — a response body cut after headers (peer `'aborted'`,
76
+ stream `'error'`, or a premature `'close'` before `'end'`) now REJECTS
77
+ `generate()` with a retryable `ProviderError` (`context.bound:'transport'`)
78
+ instead of leaving the promise pending forever; a new `loop:truncated`
79
+ stream event plus `stopReason` on the `onLlmResult` metering payload; and
80
+ `options.toolChoice` on `OpenAIProvider`.
81
+
82
+ ### Fixed
83
+
84
+ - **F154** — every provider call bareloop makes now carries a deadline. Seven
85
+ sites had none: the softgreen judge, the calibration gate, the authoring
86
+ scout's three rounds, the declaration/revise boundary, and the bridge picker.
87
+ A silent endpoint HANGS the process (an open socket is an active handle, so
88
+ the drain backstop cannot see it); a deadline is the only instrument that
89
+ fires on the absence of events. Tighten-only.
90
+ - **F155** — the judged floor graded whatever LOCATE reported, so a silently
91
+ omitted function passed on the subset. `decide()` now diffs the artifact's own
92
+ top-level functions against the reported set and reds, naming what was
93
+ missing. The declaration shapes live ONCE (`FN_SHAPES`), from which both the
94
+ locate prompt's prose and the detector are derived.
95
+ - **F153** — a provider's own error sentence now reaches the operator instead of
96
+ a bare status number, redacted through the one secret inventory and capped.
97
+ - **F151** — the bounded 429 retry, added last release, fired on a real vendor
98
+ 429 for the first time: it parsed the stated wait from prose, waited it, and
99
+ retried once. Mechanism proven; on a TPM-saturated model the retry is not
100
+ expected to recover, and did not.
101
+ - **F147** — the F59 scout summary round (and the authoring scout's recovery
102
+ round, the same shape in `src/authorscout.js`) re-fed a transcript bare-agent
103
+ had already system-prepended into a new `Loop.run()`, which prepended the
104
+ persona AGAIN: two identical system messages at index 0/1. Real OpenAI
105
+ tolerates that; vLLM-class OpenAI-compatible backends reject it (HTTP 400
106
+ "System message must be at the beginning" — the Qwen death F146 left
107
+ unexplained). Both sites now strip the leading system message before
108
+ continuing, as bare-agent's own `Loop.chat()` does. Proven live: the captured
109
+ failing request goes 400 → 200 with one line removed; deepseek-chat then
110
+ greened a full job (F149/F150).
111
+ - **F141** — `src/transport.js`'s `isTransportFailure` now also recognizes
112
+ bare-agent's own BA-25 transport classification
113
+ (`err.context.bound === 'transport'`), after the existing HTTP-status and
114
+ `retryable:false` guards. Before this fix, the exact class hamr's F115
115
+ one-retry ruling names — a call that dies mid-read, no full response —
116
+ would have ended provider-red with no retry once the 0.42.0 pin landed,
117
+ because the new rejection shape carried none of the signals the classifier
118
+ previously looked for (no `status`, `retryable:true`, no matching error
119
+ code/message). Retry budget unchanged: `TRANSPORT_RETRIES = 1`.
120
+ - **Review door refuses `close-timing-red` instead of defaulting the close timeout**
121
+ (F137, `docs/product/CLOSE-INTEGRITY-BUILD.md` "Ruled 2026-09-07", found by
122
+ orchestrator review of M3, run `mtqwmb9l`). `scripts/run-u.mjs`'s `accept` door path
123
+ resolved the mechanical re-run's close timeout via `resolveCloseTimeoutMs` and, when
124
+ that timing pass itself timed out, silently fell through to `closeTimeoutMs:
125
+ undefined` — the library's 120s default — substituting an unauthorized ceiling for a
126
+ door decision, the exact second silent-default class M3 was built to remove one door
127
+ up. A door whose own timing pass times out now REFUSES outright as a named
128
+ `close-timing-red` door stop (same wording/options as the in-run escalation, new pure
129
+ renderer `doorTimingRedLines` in `scripts/u-readout.mjs`): `answerReviewDoor` is never
130
+ called, nothing is recorded (no door record), nothing is released, nothing is spent,
131
+ and the process exits non-zero.
132
+
8
133
  ## [0.22.0] — 2026-09-07
9
134
 
10
135
  ### Added
package/README.md CHANGED
@@ -82,7 +82,7 @@ A signed job spec goes in. A verdict comes out.
82
82
 
83
83
  ```js
84
84
  import { runJob, jobSpecHash, makeSpine } from 'bareloop';
85
- import { AnthropicProvider } from 'bare-agent';
85
+ import { AnthropicProvider } from 'bare-agent/providers';
86
86
 
87
87
  const spec = {
88
88
  schema: 'job-v1',
@@ -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,7 +143,7 @@ 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 |
@@ -1008,7 +1021,18 @@ known-answer round-trip before tokens: `smoke-red` — a silent degradation thro
1008
1021
  Outcomes: `green | already-green | escalated | unapproved-spec | job-red | smoke-red |
1009
1022
  plan-red | check-red | close-red | close-unsupported | recipe-stale | branch-red | pricing-red |
1010
1023
  provider-red | interpreter-red | cap-halt | wall-halt | step-stalled | hitl-pause |
1011
- 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.
1012
1036
 
1013
1037
  **`readShim` (default `false`) — the capped read seam, per ARM.** The value names which levers
1014
1038
  run — the four arms of the frozen Phase 2 pre-registration:
@@ -1191,17 +1215,36 @@ round of this attempt being priced repairs nothing about the one before it); and
1191
1215
  reporting with the rest end of gate"). Every worker Loop (scout, drafter, each step, the fix
1192
1216
  worker — the ONE seam in `planrun.js`) gets exactly ONE extra attempt, and only for a
1193
1217
  transport-class throw — fetch itself throwing with no HTTP response at all (a TLS fault,
1194
- `ECONNRESET`/`EPIPE`/`ETIMEDOUT`, `fetch failed` wrapping a network cause); an HTTP response
1195
- (4xx/5xx/429) is never retried here and rides bare-agent's own unchanged policy. The budget is
1196
- fixed (`TRANSPORT_MAX_ATTEMPTS` in `src/transport.js`) — never a job-spec or CLI knob, tighten-only
1197
- doctrine. Each retry emits a report-only `transport-retry` spine record (`{phase, attempt, error,
1198
- recovered}`, no cost field — the ledger is unaffected); the FIRST attempt threw before any usage
1199
- figure came back, so **any** transport retry floors `spendComplete` for the rest of the run even
1200
- when the retry recovers and the run finishes green — the same one-way flag class as `stalled`
1201
- 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,
1202
1229
  so it stays exact. Both fields are present on all outcomes, so a consumer never branches on
1203
1230
  field presence and never has to launder a missing `spentUsd` into `$0`.
1204
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
+
1205
1248
  **The plan flow (Layer 2).** `job-start` carries `shape: 'plan'` + the goal; plan steps are
1206
1249
  tool-mode by construction. The flow (`runPlan`, also exported for direct callers who own
1207
1250
  their own ledger): **close precheck** (`already-green` is a
@@ -1645,7 +1688,11 @@ that moved (`door-accept-red`) and a missing registry all come back as named red
1645
1688
  a person) before it is honoured: hamr's ruling that an accept is not a rubber stamp, and the
1646
1689
  answer to a tree that can move in the 60 days a door keeps. A red REFUSES the accept with the
1647
1690
  stage named and records **nothing**. A pass records the disposition and, over a held judged
1648
- 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.
1649
1696
  - **`rerun`** carries the person's words back as a directive (`next: 'rerun'`) and re-proves
1650
1697
  nothing: the new run's close is what judges. Empty or whitespace text is refused at the same
1651
1698
  seam every other door is.
@@ -2677,7 +2724,7 @@ hash and needs re-approval, exactly like any other semantic edit.
2677
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` |
2678
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 |
2679
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 |
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) |
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 |
2681
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 |
2682
2729
  | `bundle-missing` | `readBundle` | `dir` is not a directory at all |
2683
2730
  | `manifest-invalid` / `spec-invalid` | `readBundle` | `manifest.json`/`spec.json` could not be read or parsed |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.22.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",
@@ -26,7 +26,7 @@
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
 
@@ -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;
package/src/calibrate.js CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  } from './judged.js';
64
64
  import { JUDGE_ATTEMPTS } from './kinds.js';
65
65
  import { tallyCalls } from './text.js';
66
+ import { PROVIDER_TIMEOUT_MS } from './clock.js';
66
67
 
67
68
  /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
68
69
 
@@ -330,6 +331,15 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
330
331
  card,
331
332
  loopFactory: /** @type {any} */ (judgeLoop),
332
333
  attempt,
334
+ // F152/PRD 30.4 — this module's own header already names the hazard ("a
335
+ // dead socket") as one of its three CASUALTY readings, but with no bound
336
+ // at all a truly silent judge endpoint never reaches that reading — it
337
+ // hangs the authoring process forever instead (measured). The gate runs
338
+ // at draft/sign time, which has no wall clock to derive a bound from (the
339
+ // authoring pipeline's only ceiling is money, `ceilingUsd`/`capStop`), so
340
+ // this reuses the same idle-timeout default the run path falls back to
341
+ // when ITS OWN wall is unbounded (`PROVIDER_TIMEOUT_MS`, src/clock.js).
342
+ callBounds: { timeoutMs: PROVIDER_TIMEOUT_MS },
333
343
  onCost: (c) => meter({
334
344
  id, kind, attempt, label: CALIBRATION_LABEL, model: JUDGE_MODEL,
335
345
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
package/src/cli.js CHANGED
@@ -19,7 +19,6 @@
19
19
  // run, never the unresolved bundleHash. `history.jsonl` records both hashes
20
20
  // side by side (POC fact 2) so the pairing can never drift silently.
21
21
 
22
- import { createRequire } from 'node:module';
23
22
  import { createInterface } from 'node:readline/promises';
24
23
  import { hostname } from 'node:os';
25
24
  import { execFileSync } from 'node:child_process';
@@ -34,15 +33,13 @@ import {
34
33
  exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
35
34
  runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, JUDGE_MODEL,
36
35
  } from './index.js';
36
+ import { resolveProvider, buildRunnerProviders } from './providers.js';
37
37
 
38
- const require = createRequire(import.meta.url);
39
-
40
- // The runner's own tier menu, the same spelling `scripts/run-u.mjs` uses (not
41
- // exported from `src/` — the tier->model mapping is runner territory, not the
42
- // library's). A `bareloop run` has no `--model` flag (not in the frozen
43
- // spec), so `flagModel` is always undefined and only the spec's own `model`
44
- // (if signed) or this default is ever in play.
45
- const DEFAULT_TIER_MODELS = { sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };
38
+ // The tier->model tables live in `src/providers.js` now (PRD item 28's
39
+ // factory) — one seam instead of a copy hardcoded in each runner. A
40
+ // `bareloop run` has no `--model` flag (not in the frozen spec), so
41
+ // `flagModel` is always undefined below and only the spec's own `model`
42
+ // (if signed) or the resolved provider's default tier is ever in play.
46
43
 
47
44
  /** @param {unknown} v @returns {v is Record<string, any>} */
48
45
  const isObj = (v) => typeof v === 'object' && v !== null;
@@ -58,20 +55,38 @@ const isObj = (v) => typeof v === 'object' && v !== null;
58
55
  * @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
59
56
  */
60
57
  function buildProviders(apiKey, spec) {
61
- const { AnthropicProvider } = require('bare-agent/providers');
58
+ // `bareloop run`'s frozen key contract (module header above) is
59
+ // ANTHROPIC_API_KEY only — this `apiKey` is always that key. A bundle
60
+ // whose spec names a provider that reads a DIFFERENT env key therefore
61
+ // has no seam into this flow yet, and the one thing this must never do is
62
+ // construct that provider with the Anthropic key: the run would fail at
63
+ // the first call with a vendor 401, which reads as a credential problem
64
+ // rather than as the unbuilt seam it actually is. So it REFUSES here, at
65
+ // $0, naming the key it would have needed. Widening the contract (a
66
+ // signed per-provider key story) is PRD item 28 part (2)/(3), the
67
+ // arbiter's, not this factory rewire's — the runner that actually fires
68
+ // openai-api jobs today is `scripts/run-u.mjs`, which IS key-aware.
69
+ const providerName = isObj(spec) && typeof spec.provider === 'string' ? spec.provider : 'anthropic-api';
70
+ const entry = resolveProvider(providerName);
71
+ if (entry.envKey !== 'ANTHROPIC_API_KEY') {
72
+ throw new Error(
73
+ `bareloop run: this bundle's spec names provider "${providerName}", which reads `
74
+ + `${entry.envKey}. The bundle runner's key contract is ANTHROPIC_API_KEY only, so it `
75
+ + 'refuses rather than calling that provider with the wrong key (PRD item 28 part 2 — '
76
+ + 'a signed per-provider key story is not built). Run this job through scripts/run-u.mjs.',
77
+ );
78
+ }
62
79
  const modelResolution = resolveWorkerModel({
63
80
  specModel: isObj(spec) && typeof spec.model === 'string' ? spec.model : undefined,
64
81
  flagModel: undefined,
65
- defaultModel: DEFAULT_TIER_MODELS.sonnet,
82
+ defaultModel: entry.tiers.sonnet,
66
83
  });
67
84
  const MODEL = modelResolution.model;
68
- const provider = new AnthropicProvider({ apiKey, model: MODEL });
69
- const TIER_MODELS = modelResolution.source === 'spec' ? { ...DEFAULT_TIER_MODELS, sonnet: MODEL } : DEFAULT_TIER_MODELS;
70
- /** @type {Record<string, any>} */
71
- const tierCache = {};
72
- const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] === MODEL ? provider : new AnthropicProvider({ apiKey, model: TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] })));
73
- const judgeProvider = new AnthropicProvider({ apiKey, model: JUDGE_MODEL });
74
- return { provider, providerFor, judgeProvider };
85
+ const tierModels = modelResolution.source === 'spec' ? { ...entry.tiers, sonnet: MODEL } : entry.tiers;
86
+ const baseUrl = isObj(spec) && typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined;
87
+ return buildRunnerProviders({
88
+ providerName, apiKey, model: MODEL, tierModels, baseUrl, judgeApiKey: apiKey, judgeModel: JUDGE_MODEL,
89
+ });
75
90
  }
76
91
 
77
92
  /** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
@@ -230,7 +245,13 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
230
245
  out(`bundleHash: ${bundle.manifest.bundleHash}`);
231
246
  return 0;
232
247
  }
233
- ({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
248
+ try {
249
+ ({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
250
+ } catch (e) {
251
+ // a refusal from the key contract above: name it and spend nothing
252
+ err(String(/** @type {any} */ (e)?.message ?? e));
253
+ return 1;
254
+ }
234
255
  }
235
256
 
236
257
  // 4. blessing.
@@ -567,13 +567,14 @@ function translate(r, redact) {
567
567
  * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
568
568
  * humanRuling?: {decision: string, text?: string|null}|null,
569
569
  * judgeLoop?: ((o: {system: string}) => any)|null,
570
- * onJudgeCost?: ((c: any) => void)|null}} [opts]
570
+ * onJudgeCost?: ((c: any) => void)|null,
571
+ * callBounds?: (() => {timeoutMs?: number, deadlineMs?: number})|null}} [opts]
571
572
  * @returns {Promise<any>}
572
573
  */
573
574
  export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
574
575
  const {
575
576
  timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode, humanRuling = null,
576
- judgeLoop = null, onJudgeCost = null,
577
+ judgeLoop = null, onJudgeCost = null, callBounds = null,
577
578
  } = opts;
578
579
  if (!isNonEmptyString(cwd) || !isNonEmptyString(seedRef)) {
579
580
  // Refuse rather than guess. A declared close measures against a SEED; a
@@ -611,6 +612,12 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
611
612
  // until this kind, and a budget must fund the attempt PLUS its close.
612
613
  judgeLoop,
613
614
  onJudgeCost,
615
+ // F152/PRD 30.4 — the SAME per-call time bounds every worker round already
616
+ // carries (`callBounds()`, src/planrun.js:1518), so the judged stage's own
617
+ // locate call cannot hang the process on a live silent endpoint. `null` is
618
+ // the honest absence (an adopter/test that wires no clock through), never a
619
+ // defaulted number invented here.
620
+ callBounds,
614
621
  seedTrees,
615
622
  };
616
623
  try {