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.
@@ -0,0 +1,176 @@
1
+ // Provider factory (PRD item 28, shape (1)): ONE seam mapping a job spec's
2
+ // `provider` name to the bare-agent constructor it drives, the env var
3
+ // carrying its key, and the per-tier model table — including any
4
+ // provider-specific request-key gating. Before this module existed, both
5
+ // runners constructed `AnthropicProvider` BY NAME (`src/cli.js`'s
6
+ // `buildProviders`, `scripts/run-u.mjs`'s top-level wiring) and any second
7
+ // provider would have meant scattering `if (provider === …)` across both.
8
+ //
9
+ // hamr's ruling (PRD 30.7, 2026-09-09): `deepseek-chat` is THE secondary
10
+ // provider — "settle on one secondary/replacement for claude … don't
11
+ // pickpocket random models". This table is not a menu of half-tested
12
+ // models; it is exactly the two provider identities the PROVIDERS menu
13
+ // (`src/job.js`) admits.
14
+ //
15
+ // What this module is NOT: it never picks a judge (the judge stays pinned
16
+ // to `anthropic-api`/`JUDGE_MODEL` regardless of the job's worker provider —
17
+ // arbiter territory, PRD item 28), never prices a round (F113: rates
18
+ // passthrough is dead), and never decides a budget. It only constructs the
19
+ // provider objects `bare-agent`'s `Loop` calls.
20
+
21
+ import { createRequire } from 'node:module';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const { AnthropicProvider, OpenAIProvider } = require('bare-agent/providers');
25
+
26
+ /**
27
+ * Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
28
+ * runners hardcoded before this factory existed. Any change to these two
29
+ * values is a change to shipped anthropic-api behaviour, not a refactor.
30
+ */
31
+ export const ANTHROPIC_TIER_MODELS = Object.freeze({
32
+ sonnet: 'claude-sonnet-5',
33
+ haiku: 'claude-haiku-4-5-20251001',
34
+ });
35
+
36
+ /**
37
+ * DeepSeek (F149/F150) is a SINGLE model, not a tiered family — hamr's
38
+ * ruling above: one secondary provider, not a menu. Both tiers map to the
39
+ * same model id ON PURPOSE: `resolveWorkerModel`/a runner's `providerFor
40
+ * (tier)` only ever LOOK UP a tier's model id (src/job.js:818-827,
41
+ * scripts/run-u.mjs's `providerFor`) — neither assumes the tiers name
42
+ * different models — so repeating the one id is the honest representation
43
+ * of a single-model provider, not a second model masquerading as `haiku`.
44
+ * If DeepSeek (or a future openai-api entrant) ever ships a genuinely
45
+ * cheaper tier model, this is where it would earn its own `haiku` id.
46
+ */
47
+ export const OPENAI_TIER_MODELS = Object.freeze({
48
+ sonnet: 'deepseek-chat',
49
+ haiku: 'deepseek-chat',
50
+ });
51
+
52
+ /**
53
+ * Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
54
+ * silently IGNORES the modern `max_completion_tokens` key — asked for 64
55
+ * output tokens, it returned 665/608 — and only honours the legacy
56
+ * `max_tokens` key. `bare-agent`'s `OpenAIProvider` already exposes the
57
+ * switch (`legacyMaxTokens`, BA-24; no model-name sniffing inside
58
+ * bare-agent itself — "the caller declares the dialect"), so THIS table is
59
+ * bareloop's routing of it, keyed per model, never a global default. An
60
+ * output cap that does not bind is a money hazard: a reasoning model can
61
+ * run to its own ceiling on every round, silently, so getting this wrong
62
+ * for even one model defeats every budget that model's tier sets.
63
+ *
64
+ * A model with no entry here gets `{}` — bare-agent's own default
65
+ * (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
66
+ */
67
+ const OPENAI_MODEL_OPTIONS = Object.freeze({
68
+ 'deepseek-chat': Object.freeze({ legacyMaxTokens: true }),
69
+ });
70
+
71
+ /**
72
+ * @typedef {object} ProviderTableEntry
73
+ * @property {new (options: any) => any} ctor the bare-agent provider class
74
+ * @property {string} envKey the environment variable this provider reads its key from
75
+ * @property {Readonly<Record<string, string>>} tiers tier name -> model id
76
+ * @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, baseUrl?}
77
+ */
78
+
79
+ /** @type {Readonly<Record<string, ProviderTableEntry>>} */
80
+ const PROVIDER_TABLE = Object.freeze({
81
+ 'anthropic-api': Object.freeze({
82
+ ctor: AnthropicProvider,
83
+ envKey: 'ANTHROPIC_API_KEY',
84
+ tiers: ANTHROPIC_TIER_MODELS,
85
+ // Anthropic has only ever had one request key for the output cap
86
+ // (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
87
+ // backends, which fork on `max_tokens` vs `max_completion_tokens`).
88
+ paramsFor: () => ({}),
89
+ }),
90
+ 'openai-api': Object.freeze({
91
+ ctor: OpenAIProvider,
92
+ envKey: 'OPENAI_API_KEY',
93
+ tiers: OPENAI_TIER_MODELS,
94
+ paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
95
+ }),
96
+ });
97
+
98
+ /**
99
+ * Look up a provider's table entry. Throws a NAMED error on an unrecognized
100
+ * provider — never a silent default (the `resolveRates`-style-substring-
101
+ * match lesson: a forward hazard even at zero observed rounds, MEMORY.md).
102
+ * `src/job.js`'s `PROVIDERS` menu is the one place a spec's `provider`
103
+ * field is validated; this is the runtime construction seam it feeds.
104
+ * @param {string} providerName
105
+ * @returns {ProviderTableEntry}
106
+ */
107
+ export function resolveProvider(providerName) {
108
+ const entry = PROVIDER_TABLE[/** @type {keyof typeof PROVIDER_TABLE} */ (providerName)];
109
+ if (!entry) {
110
+ throw new Error(
111
+ `src/providers.js: unknown provider "${providerName}" — no silent default. `
112
+ + `Known providers: ${Object.keys(PROVIDER_TABLE).join(', ')}`,
113
+ );
114
+ }
115
+ return entry;
116
+ }
117
+
118
+ /**
119
+ * Construct ONE provider instance for `providerName`/`model`, applying
120
+ * `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
121
+ * and the model's own request-key gating (`paramsFor`). `baseUrl` (PRD item
122
+ * 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given —
123
+ * every provider constructor here defaults it on its own when absent.
124
+ * @param {string} providerName
125
+ * @param {{apiKey?: string, model?: string, baseUrl?: string}} [options]
126
+ */
127
+ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
128
+ const entry = resolveProvider(providerName);
129
+ return new entry.ctor({
130
+ exposeErrorBody: true,
131
+ apiKey,
132
+ model,
133
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
134
+ ...entry.paramsFor(model),
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Build the `{provider, providerFor, judgeProvider}` triple both runners
140
+ * need (`src/cli.js`'s `buildProviders`, `scripts/run-u.mjs`'s top-level
141
+ * wiring) — ONE seam instead of two copies of the same construction and
142
+ * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
143
+ * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
144
+ * before this factory existed — that decision stays where each runner's own
145
+ * `--model` flag/spec precedence lives, not duplicated in here).
146
+ *
147
+ * The JUDGE stays pinned to `anthropic-api`/`judgeModel` regardless of
148
+ * `providerName` — arbiter territory (PRD item 28: "the judge stays PINNED
149
+ * … regardless of the job's worker provider"). A signed `judge:{provider,
150
+ * model}` field with its own calibration record is PRD item 28 part (2),
151
+ * NOT built here; until it lands, the judge always needs its own
152
+ * `judgeApiKey` (today, always `ANTHROPIC_API_KEY`), independent of which
153
+ * key the worker used.
154
+ * @param {object} args
155
+ * @param {string} args.providerName spec.provider (or the anthropic-api default)
156
+ * @param {string|undefined} args.apiKey the WORKER's key, read from the provider's own `envKey`
157
+ * @param {string} args.model the already-resolved worker model id
158
+ * @param {Readonly<Record<string, string>>} args.tierModels the already-resolved tier -> model map (spec override folded in by the caller)
159
+ * @param {string|undefined} [args.baseUrl] spec.baseUrl, if any
160
+ * @param {string|undefined} args.judgeApiKey the judge's own key (today: `ANTHROPIC_API_KEY`)
161
+ * @param {string} args.judgeModel `JUDGE_MODEL`
162
+ */
163
+ export function buildRunnerProviders({
164
+ providerName, apiKey, model, tierModels, baseUrl, judgeApiKey, judgeModel,
165
+ }) {
166
+ const provider = makeProvider(providerName, { apiKey, model, baseUrl });
167
+ /** @type {Record<string, any>} */
168
+ const tierCache = {};
169
+ const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (
170
+ tierModels[tier] === model
171
+ ? provider
172
+ : makeProvider(providerName, { apiKey, model: tierModels[tier], baseUrl })
173
+ ));
174
+ const judgeProvider = makeProvider('anthropic-api', { apiKey: judgeApiKey, model: judgeModel });
175
+ return { provider, providerFor, judgeProvider };
176
+ }
package/src/ralph.js CHANGED
@@ -208,17 +208,24 @@ export const CLOSE_ENV_DENY = Object.freeze({
208
208
  *
209
209
  * @param {string[]} close argv, e.g. ['node', '--test', 'close/']
210
210
  * @param {(s: string) => string} [redact] source scrubber; identity by default
211
- * @param {{ timeoutMs?: number, cwd?: string, expect?: number, judged?: {pattern: string, min: number}, gapKeep?: string }} [opts]
211
+ * @param {{ timeoutMs?: number, cwd?: string, expect?: number, judged?: {pattern: string, min: number}, gapKeep?: string, closeDir?: string|null }} [opts]
212
212
  * close wall-clock cap (operator-set via the runner, never the config's — the
213
213
  * agent must not author its arbiter's clock), the directory the close runs in
214
214
  * (the workdir — the tree it is judging), the exit code the SIGNED spec calls
215
215
  * success, the judgment-rendered signal, and the kept-failures pattern. All
216
216
  * five are arbiter territory: the drafted workflow config cannot express any.
217
+ * `closeDir` (PRD item 27/M3 Part B) is the close's OWN books directory —
218
+ * pristine copies, calibration thresholds, logs — deliberately outside the
219
+ * patient tree (the worker must never read the arbiter's books). Handed to
220
+ * the child as `BARELOOP_CLOSE_DIR` (`CLOSE_DIR_ENV_VAR`,
221
+ * src/close-integrity.js); a close script that needs one and gets none
222
+ * instrument-stops itself, by the script's own contract — this seam only
223
+ * ever THREADS the value, it never invents one.
217
224
  * @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
218
225
  * gap?: string, exitCode?: number, signal?: string, detail?: string,
219
226
  * judgedCount?: number|null, unaudited?: boolean}>}
220
227
  */
221
- export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep } = {}) {
228
+ export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000, cwd, expect = 0, judged, gapKeep, closeDir = null } = {}) {
222
229
  const env = { ...process.env };
223
230
  delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
224
231
  // …and the operator's credentials go with it (CLOSE_ENV_DENY above). A COPY is
@@ -229,6 +236,13 @@ export async function runClose(close, redact = (s) => s, { timeoutMs = 120_000,
229
236
  || CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
230
237
  || CLOSE_ENV_DENY.shape.test(name)) delete env[name];
231
238
  }
239
+ // PRD item 27/M3 Part B — a `BARELOOP_` name, deliberately not matched by
240
+ // any CLOSE_ENV_DENY rule above (checked: no name/prefix/suffix entry there
241
+ // touches it), set AFTER the strip loop so it can never be stripped by a
242
+ // future denylist edit either. Only set when the runner actually has one —
243
+ // an empty/absent `closeDir` leaves the child's env exactly as it was
244
+ // before this rung, so a script that never reads the variable is unaffected.
245
+ if (typeof closeDir === 'string' && closeDir.length > 0) env.BARELOOP_CLOSE_DIR = closeDir;
232
246
  const r = await spawnClose(close[0], close.slice(1), { env, cwd, timeoutMs });
233
247
 
234
248
  // ── forbidden zone, before any exit code is believed ──────────────────────
@@ -322,17 +336,17 @@ export const stageGap = (name, gap) => `close stage "${name}" failed:\n${gap}`;
322
336
  *
323
337
  * @param {any[]} stages the stage list (already validated by validateJob)
324
338
  * @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
325
- * @param {{timeoutMs?: number, cwd?: string}} [opts]
339
+ * @param {{timeoutMs?: number, cwd?: string, closeDir?: string|null}} [opts]
326
340
  * @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
327
341
  */
328
- export async function runStages(stages, redact = (s) => s, /** @type {{timeoutMs?: number, cwd?: string}} */ { timeoutMs, cwd } = {}) {
342
+ export async function runStages(stages, redact = (s) => s, /** @type {{timeoutMs?: number, cwd?: string, closeDir?: string|null}} */ { timeoutMs, cwd, closeDir } = {}) {
329
343
  const ran = [];
330
344
  let last;
331
345
  for (const st of stages) {
332
346
  // Sequential by construction (F68): stages run one at a time under a single
333
347
  // await, so nothing here ever has two closes in flight against one tree.
334
348
  const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
335
- timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
349
+ timeoutMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep, closeDir,
336
350
  });
337
351
  ran.push({ name: st.name, verdict: r.verdict, ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}) });
338
352
  last = r;
@@ -443,6 +457,21 @@ export const CLOSE_FAULTS = Object.freeze({
443
457
  decision: 'The close exited without judging anything — it crashed rather than failed. Its exit code is NOT a verdict, in either direction.',
444
458
  options: ['fix what makes the close crash at startup', 'fix the close argv', 'lower the declared judgment floor if the suite legitimately shrank', 'abandon the task'],
445
459
  },
460
+ // PRD item 27/M2 (`docs/product/CLOSE-INTEGRITY-BUILD.md`) — a close
461
+ // script's bytes no longer match what was signed. Distinct from every row
462
+ // above: those are read off HOW THE CHILD PROCESS TERMINATED, and this one
463
+ // is decided BEFORE the process is even spawned (`runCloseStages`,
464
+ // `src/planrun.js`, re-verifies before every close run and short-circuits
465
+ // to this verdict on a mismatch, the same forbidden-zone shape as a
466
+ // crash). Never `close-red` (a judged "no" from a working close) and never
467
+ // `close-crashed` (an instrument fault the close itself hit) — this is a
468
+ // THIRD thing: the close that ran would not have been the one the operator
469
+ // approved.
470
+ 'close-tampered': {
471
+ category: 'close-tampered',
472
+ decision: "A close script's bytes no longer match its signed sha256 — it would run something other than what was approved, so no verdict from it can be trusted.",
473
+ options: ['restore the close script to its signed bytes', 're-sign the close if the change is intentional (a new spec hash needs re-approval)', 'abandon the task'],
474
+ },
446
475
  });
447
476
 
448
477
  /**
@@ -529,9 +558,13 @@ export const CLOSE_FAULTS = Object.freeze({
529
558
  * it to the gate audit's allow-decision writes. Injected like `redact` so the shell
530
559
  * stays stdlib-only and dumb; consulted ONLY at a crashed verdict. With no seam, or
531
560
  * with zero writes, a crash stays what it always was: an instrument stop.
561
+ * @param {string|null} [opts.closeDir] the close's own books directory
562
+ * (PRD item 27/M3 Part B), forwarded to the raw `close` path's `runClose`
563
+ * call unchanged; a caller supplying `judge` instead already closed over
564
+ * its own `closeDir` and this param does nothing for it.
532
565
  * @returns {Promise<'green'|'escalated'>}
533
566
  */
534
- export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }) {
567
+ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites, closeDir }) {
535
568
  // PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
536
569
  // thing this shell throws for; a WORKER's bad reach comes back as a refusal
537
570
  // RESULT instead, and that rule is about feedback, not about the API contract).
@@ -660,7 +693,7 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
660
693
  emit('middle-done', { iteration });
661
694
  const v = judge
662
695
  ? await judge()
663
- : await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep });
696
+ : await runClose(/** @type {string[]} */ (close), redact, { timeoutMs: closeTimeoutMs ?? 120_000, cwd, expect, judged, gapKeep, closeDir });
664
697
  // Worker-crash attribution (F32, measured in F31: 4 of 7 battery rows). A crash is
665
698
  // still not a verdict (F17) — but a crash that FOLLOWS worker writes, on a run whose
666
699
  // precheck proved the close judged at baseline (run.js escalates a crash-at-precheck
@@ -0,0 +1,116 @@
1
+ // PRD item 28's parked (a/b) ruling, hamr's verbatim call: "do a/b as a
2
+ // fallback, and verify/validate + no regression." A vendor HTTP 429 is a
3
+ // response — the provider answered, unlike src/transport.js's no-response
4
+ // class — but unlike every other status this repo already declines to retry
5
+ // (4xx/5xx generally, F115's own boundary), a 429 carries the ONE piece of
6
+ // information that turns "retry blindly" into "retry honestly": the vendor's
7
+ // own stated wait. This module is transport.js's SIBLING, not an extension
8
+ // of it — `isTransportFailure` must keep returning FALSE for anything
9
+ // carrying an HTTP status (429 included), and nothing here widens that.
10
+ //
11
+ // This is a FALLBACK, not the primary answer. The primary answer (F139,
12
+ // measured 2026-09-08) is picking a worker model with adequate rate
13
+ // headroom: gpt-4.1 = 30,000 TPM (429s deterministically on our workload,
14
+ // 2 of 2 runs), gpt-4.1-mini = 200,000 TPM, gpt-5-mini/gpt-5 = 500,000 TPM.
15
+ // One bounded retry only softens the failure mode when that primary answer
16
+ // was not followed (or headroom was still exceeded); it does not replace it.
17
+
18
+ /** The fixed retry budget: ONE extra attempt after a 429. Never read from a
19
+ * job spec or CLI flag — raising it is arbiter territory, mirroring
20
+ * transport.js's TRANSPORT_RETRIES exactly (tighten-only doctrine). */
21
+ export const RATE_LIMIT_RETRIES = 1;
22
+
23
+ /** bare-agent's `Retry.call` (if ever used) counts ATTEMPTS, not retries;
24
+ * this module's own hand-rolled ladder (src/planrun.js) does the same for
25
+ * symmetry with TRANSPORT_MAX_ATTEMPTS. */
26
+ export const RATE_LIMIT_MAX_ATTEMPTS = RATE_LIMIT_RETRIES + 1;
27
+
28
+ /** The hard cap on how long we will ever wait for a vendor-stated 429 delay.
29
+ * A vendor asking for longer than this is not honoured — we do not retry at
30
+ * all in that case. Waiting that long is the operator's decision, not ours
31
+ * (arbiter territory: nothing here escalates the wait past what hamr set). */
32
+ export const RATE_LIMIT_MAX_WAIT_MS = 60_000;
33
+
34
+ /** Used only when the vendor's message carries no parseable delay at all. */
35
+ export const RATE_LIMIT_DEFAULT_WAIT_MS = 5_000;
36
+
37
+ /** A parsed delay is the EARLIEST moment the vendor's own window resets, not
38
+ * a safe one to fire on the dot — this margin is added to any PARSED value
39
+ * (never to the default) so the retry lands just after the window, not on
40
+ * its leading edge. Never allowed to push a parsed value over the cap. */
41
+ const PARSED_DELAY_MARGIN_MS = 250;
42
+
43
+ /**
44
+ * Is `err` an HTTP 429 (rate-limited) response? True ONLY for an explicit
45
+ * status of 429 on the error itself. Nothing else qualifies — not message
46
+ * text (a message merely containing "429" is not this), not `retryable`,
47
+ * not any other status. Symmetric with `isTransportFailure`'s own status
48
+ * check (src/transport.js), just reading the opposite branch of it.
49
+ * @param {any} err
50
+ * @returns {boolean}
51
+ */
52
+ export function isRateLimited(err) {
53
+ if (err == null) return false;
54
+ const status = err.status ?? err.statusCode;
55
+ return status === 429;
56
+ }
57
+
58
+ /**
59
+ * The vendor's RAW stated wait, in milliseconds, parsed straight from the
60
+ * error message — no default, no margin, no cap. `null` when nothing in the
61
+ * message matches a known shape. Exported separately from `rateLimitWaitMs`
62
+ * so a caller that wants to report the vendor's own number on the spine
63
+ * (`statedMs`) never has to re-derive it from the margin-adjusted figure.
64
+ *
65
+ * bare-agent does not expose the vendor's `retry-after` /
66
+ * `x-ratelimit-reset-*` response headers on the thrown error (BA-26) — the
67
+ * delay exists only in the vendor's English message text, so this parses
68
+ * that text. Verified real shape (OpenAI, run `8ev2sdkn`, 2026-09-08):
69
+ * "Please try again in 11.996s." This also supports "try again in 1.5
70
+ * seconds", "try again in 20ms", "retry after 30s", and "retry after 30
71
+ * seconds" — case-insensitive.
72
+ * @param {any} err
73
+ * @returns {number|null}
74
+ */
75
+ export function statedWaitMs(err) {
76
+ const message = String(err?.message ?? err ?? '');
77
+ const match = message.match(/(?:try again in|retry after)\s*([\d.]+)\s*(ms|milliseconds?|s|seconds?)?/i);
78
+ if (!match) return null;
79
+
80
+ const value = Number(match[1]);
81
+ if (!Number.isFinite(value) || value < 0) return null;
82
+
83
+ const unit = (match[2] ?? 's').toLowerCase();
84
+ const isMs = unit === 'ms' || unit.startsWith('millisecond');
85
+ return isMs ? value : value * 1000;
86
+ }
87
+
88
+ /**
89
+ * How long to wait before retrying a 429, in milliseconds — or `null`
90
+ * meaning "do not retry at all" (either the parsed wait exceeds
91
+ * `RATE_LIMIT_MAX_WAIT_MS`, in which case waiting that long is the
92
+ * operator's decision, not ours).
93
+ *
94
+ * A parsed value gets `PARSED_DELAY_MARGIN_MS` added (the vendor's stated
95
+ * number is the earliest moment its window resets, not a safe one to fire
96
+ * on), capped so the margin itself can never push a parsed value over
97
+ * `RATE_LIMIT_MAX_WAIT_MS`. When nothing parses, `RATE_LIMIT_DEFAULT_WAIT_MS`
98
+ * is returned as-is, with no margin added (there is nothing to add margin
99
+ * to — it is already a conservative guess, not an earliest-moment reading).
100
+ * @param {any} err
101
+ * @returns {number|null}
102
+ */
103
+ export function rateLimitWaitMs(err) {
104
+ const parsedMs = statedWaitMs(err);
105
+ if (parsedMs === null) {
106
+ return RATE_LIMIT_DEFAULT_WAIT_MS <= RATE_LIMIT_MAX_WAIT_MS ? RATE_LIMIT_DEFAULT_WAIT_MS : null;
107
+ }
108
+
109
+ // The RAW parsed value is what is judged against the cap ("a vendor asking
110
+ // for longer than this is not honoured") — the margin is added only AFTER
111
+ // that gate, and clamped so it can never itself push the result past the
112
+ // cap, but it never turns an already-honoured wait into a refusal.
113
+ if (parsedMs > RATE_LIMIT_MAX_WAIT_MS) return null;
114
+ if (parsedMs === 0) return 0;
115
+ return Math.min(parsedMs + PARSED_DELAY_MARGIN_MS, RATE_LIMIT_MAX_WAIT_MS);
116
+ }
package/src/reuse.js CHANGED
@@ -60,6 +60,7 @@ import { readGrade } from './trend.js';
60
60
  import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
61
61
  import { extractArtifact, priceOf } from './text.js';
62
62
  import { runJob as shippedRunJob, ACCOUNTED_ROUND_TYPES } from './run.js';
63
+ import { PROVIDER_TIMEOUT_MS } from './clock.js';
63
64
 
64
65
  const require = createRequire(import.meta.url);
65
66
  const { Loop } = require('bare-agent');
@@ -416,7 +417,14 @@ export async function selectBridge({ registry, job, ask, provider, pinned = null
416
417
  let result;
417
418
  try {
418
419
  const loop = new Loop({ provider });
419
- result = await loop.run([{ role: 'user', content: prompt }], [], { maxTokens: SELECTION_MAX_TOKENS });
420
+ // F152/PRD 30.4 — the picker's call carried no time bound at all: a live
421
+ // endpoint that accepts and never answers hangs the process forever
422
+ // (measured elsewhere in this repo). This runs BETWEEN bridge tries, not
423
+ // inside any one try's own signed `perTryWallMs`, so there is no wall to
424
+ // derive a tighter number from — the same "no clock here" reasoning
425
+ // `authorscout.js` documents, and the same fallback: the idle-timeout
426
+ // default the run path itself uses when ITS OWN wall is unbounded.
427
+ result = await loop.run([{ role: 'user', content: prompt }], [], { maxTokens: SELECTION_MAX_TOKENS, timeoutMs: PROVIDER_TIMEOUT_MS });
420
428
  } catch (e) {
421
429
  // a throw out of the selection call is TRANSPORT — a casualty, and the run stops on
422
430
  // it rather than quietly drafting cold on a decision that was never made
@@ -1353,6 +1361,7 @@ export function resumeTreeGate({ head, seed, dirty }) {
1353
1361
  * @param {number} [opts.capRuns] forwarded
1354
1362
  * @param {number} [opts.strikeLimit] forwarded (a plan step's strike ceiling)
1355
1363
  * @param {number} [opts.closeTimeoutMs] forwarded
1364
+ * @param {string} [opts.closeDir] forwarded (PRD item 27/M3 Part B)
1356
1365
  * @param {boolean} [opts.layerRoot] forwarded
1357
1366
  * @param {boolean|'cap'|'diff'} [opts.readShim] the read shim's ARM (A0/A1/A2/A3), forwarded verbatim
1358
1367
  * @param {any} [opts.resume] RESUME (module C) — a `readResume` reading of the KILLED
@@ -1371,7 +1380,7 @@ export async function runReuse(opts) {
1371
1380
  const {
1372
1381
  job, approvals, registryDir, patient, workdir, provider, selectionProvider,
1373
1382
  emit, ask, pinned = null, shortlist = null, forceCold = false,
1374
- providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, layerRoot, readShim,
1383
+ providerFor, nativeProvider, capRuns, strikeLimit, closeTimeoutMs, closeDir, layerRoot, readShim,
1375
1384
  runJob = shippedRunJob, now = () => Date.now(),
1376
1385
  } = opts;
1377
1386
  const runid = opts.runid ?? Date.now().toString(36);
@@ -1538,6 +1547,7 @@ export async function runReuse(opts) {
1538
1547
  ...(capRuns !== undefined ? { capRuns } : {}),
1539
1548
  ...(strikeLimit !== undefined ? { strikeLimit } : {}),
1540
1549
  ...(closeTimeoutMs !== undefined ? { closeTimeoutMs } : {}),
1550
+ ...(closeDir !== undefined ? { closeDir } : {}),
1541
1551
  ...(layerRoot !== undefined ? { layerRoot } : {}),
1542
1552
  ...(readShim !== undefined ? { readShim } : {}),
1543
1553
  // the fold, and whether the fold is EXACT. `priorSpendComplete` used to stop at
package/src/reviewdoor.js CHANGED
@@ -38,6 +38,7 @@ import { isDeclaredClose, runDeclaredStages, HITL_DECISION_RED } from './declare
38
38
  import { runStages } from './ralph.js';
39
39
  import { redactSecrets } from './validate.js';
40
40
  import { applyDoorDecision, PAUSE_TTL_MS } from './reuse.js';
41
+ import { checkStageByteSignature } from './close-integrity.js';
41
42
 
42
43
  /** the door's own record, off the run's spine — the LAST one, because a run that
43
44
  * ends at a door writes exactly one and a reader that guesses which is which is a
@@ -92,6 +93,7 @@ const red = (code, detail, path = 'decision') => [{ code, path, detail }];
92
93
  * @param {string} opts.decision one of the three doors
93
94
  * @param {string|null} [opts.text] the rerun's words — the gap, and refused when empty
94
95
  * @param {number} [opts.closeTimeoutMs] the per-stage bound for the accept's re-run
96
+ * @param {string|null} [opts.closeDir] the close's own books directory (PRD item 27/M3 Part B), forwarded to the accept's re-run
95
97
  * @param {string|null} [opts.registryDir] the workflow registry, when the runner keeps one
96
98
  * @param {string|null} [opts.name] the workflow this run wrote its row against
97
99
  * @param {string|null} [opts.runid] the RUN's id, exactly as the bridge row records it
@@ -104,7 +106,7 @@ const red = (code, detail, path = 'decision') => [{ code, path, detail }];
104
106
  * options: string[], ttlMs: number, note: string|null}>}
105
107
  */
106
108
  export async function answerReviewDoor({
107
- job, workdir, events, decision, text = null, closeTimeoutMs,
109
+ job, workdir, events, decision, text = null, closeTimeoutMs, closeDir = null,
108
110
  registryDir = null, name = null, runid = null, at = null,
109
111
  now = Date.now, ttlMs = PAUSE_TTL_MS, emit = null,
110
112
  }) {
@@ -149,7 +151,7 @@ export async function answerReviewDoor({
149
151
  // this is the only branch that runs anything, and it runs the close's own
150
152
  // mechanical stages — never a second, weaker spelling of "is it still green".
151
153
  if (out.decision === 'accept') {
152
- const proof = await proveMechanically({ job, workdir, events, closeTimeoutMs });
154
+ const proof = await proveMechanically({ job, workdir, events, closeTimeoutMs, closeDir });
153
155
  out.mechanical = proof.reading;
154
156
  if (!proof.ok) {
155
157
  out.reds = red('door-accept-red', proof.detail, proof.reading.stage ? `stage.${proof.reading.stage}` : 'close');
@@ -200,16 +202,24 @@ export async function answerReviewDoor({
200
202
 
201
203
  /**
202
204
  * The accept's re-proof: the close's MECHANICAL stages, run against the tree as it
203
- * stands now, through the SAME executors the run itself was judged by.
205
+ * stands now, through the SAME executors the run itself was judged by — the
206
+ * scripts' own bytes are verified against their signed sha256 FIRST (F135).
204
207
  *
205
208
  * The declared path measures against the run's OWN seed (`close-decl.seedRef` off
206
209
  * the spine), never a fresh one: re-basing the baseline onto today's HEAD would
207
210
  * compare the tree to itself and turn "nothing has changed since the run" into a
208
211
  * green for a `files-changed` stage that means the opposite. A run whose seed is
209
212
  * not on its spine is refused rather than guessed at.
210
- * @param {{job: any, workdir: string, events: any[], closeTimeoutMs?: number}} o
213
+ *
214
+ * F135 — the script BYTES are verified (`checkStageByteSignature`, PRD item 27/M2)
215
+ * BEFORE any stage runs, exactly the re-verify every other close execution in this
216
+ * runner already goes through (`runCloseStages`, `src/planrun.js`). Without it, a
217
+ * close script swapped on disk after the run ended but before the signer's accept
218
+ * would be re-run unchecked — a mismatch here never reaches the executor, and
219
+ * reads the same `close-tampered` shape the run-start/mid-run checks already use.
220
+ * @param {{job: any, workdir: string, events: any[], closeTimeoutMs?: number, closeDir?: string|null}} o
211
221
  */
212
- async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
222
+ async function proveMechanically({ job, workdir, events, closeTimeoutMs, closeDir = null }) {
213
223
  const stages = mechanicalStages(closeStagesOf(job) ?? []);
214
224
  if (!stages.length) {
215
225
  // Honest, and named: a close with no mechanical stage has nothing a machine
@@ -217,6 +227,16 @@ async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
217
227
  // dressed as a re-run.
218
228
  return { ok: true, detail: '', reading: { ran: 0, verdict: null, stage: null, stages: [], note: 'this close has no mechanical stage — there is nothing a machine can re-prove, so the accept rests on the person' } };
219
229
  }
230
+ const sig = checkStageByteSignature(stages, workdir);
231
+ if (!sig.ok) {
232
+ const r = sig.reds[0];
233
+ const detail = sig.reds.map((rr) => `${rr.stage}: expected ${rr.expected.slice(0, 12)}… got ${rr.actual ? `${rr.actual.slice(0, 12)}…` : '<unreadable>'}`).join('; ');
234
+ return {
235
+ ok: false,
236
+ reading: { ran: 0, verdict: 'close-tampered', stage: r?.stage ?? null, stages: [] },
237
+ detail: `the close script's bytes no longer match its signed sha256 (close-tampered) — the accept is refused rather than re-run against a script nobody vouched for. ${detail}`,
238
+ };
239
+ }
220
240
  /** @type {any} */
221
241
  let v;
222
242
  if (isDeclaredClose(job)) {
@@ -226,7 +246,7 @@ async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
226
246
  }
227
247
  v = await runDeclaredStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir, seedRef });
228
248
  } else {
229
- v = await runStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir });
249
+ v = await runStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir, closeDir });
230
250
  }
231
251
  const reading = {
232
252
  ran: stages.length,