bareloop 0.22.0 → 0.24.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,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
package/src/run.js CHANGED
@@ -95,9 +95,14 @@ async function primitiveSmoke(workdir) {
95
95
  * metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
96
96
  * @param {(type: string, data?: object) => object} opts.emit spine emitter
97
97
  * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier provider factory (forwarded to the plan flow)
98
+ * @param {string|null} [opts.judgeModel] SOFTGREEN — WHICH model the judge provider drives
99
+ * (PRD item 32.1), resolved by the CALLER (`resolveJudge`, src/judged.js) and passed
100
+ * through untouched. Travels with `judgeProvider`; absent, a judged stage stops as a
101
+ * wiring gap rather than grading under an identity nobody named.
98
102
  * @param {any} [opts.judgeProvider] SOFTGREEN — the provider a JUDGED close stage runs its
99
- * locate call through, wired by the operator and PINNED to `JUDGE_MODEL` (src/judged.js:
100
- * the only tier with established injection resistance upstream). Forwarded to the plan
103
+ * locate call through, built by the CALLER from the RESOLVED judge identity (PRD item
104
+ * 32.1: `resolveJudge`/`resolveJobJudge`, src/judged.js — the spec's signed `judge`
105
+ * override, else the job's own worker provider and model), never pinned. Forwarded to the plan
101
106
  * flow. It is its own seam and not `provider`/`providerFor`, because the judge tier is
102
107
  * never a step knob and never agent-selectable; absent, a judged stage instrument-stops as
103
108
  * a wiring gap rather than grading on whatever binding happened to be at hand. Its spend
@@ -228,8 +233,16 @@ async function primitiveSmoke(workdir) {
228
233
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
229
234
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
230
235
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
236
+ * NOTE: `job-end` can ALSO land with outcome 'runner-drained' (F140/PRD item
237
+ * 28(c)) — the `beforeExit` backstop's own terminal, emitted from OUTSIDE this
238
+ * function's normal control flow when the process drains with no `job-end` at
239
+ * all (a provider promise that never settled). It never appears as this
240
+ * function's own RETURN value (nothing is left to receive it), only as the
241
+ * spine's terminal record. Non-zero exit, `spendComplete:false` always, and
242
+ * deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
243
+ * unknown in-flight state is not a known-safe resume point.
231
244
  */
232
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
245
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
233
246
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
234
247
  // before the ledger, before the approval gate, before a byte of the spec is read.
235
248
  // An unrecognised spelling throws here at zero cost instead of being coerced by
@@ -345,6 +358,15 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
345
358
  // than guessing.
346
359
  verdictType: job.verdictType,
347
360
  ...(typeof provider?.model === 'string' ? { model: provider.model } : {}),
361
+ // PRD item 28 / F156 — WHICH provider actually ran, and against which
362
+ // endpoint. Report-only, but without it an archived spine cannot be
363
+ // segmented by provider at all: every cost, duration and outcome row from
364
+ // the day openai-api joined the menu would pool anthropic and non-anthropic
365
+ // runs into one contaminated aggregate, which is the reading error this repo
366
+ // has already paid for once. `baseUrl` rides along only when the job set one
367
+ // (an absent baseUrl is the vendor's own host, not an unknown).
368
+ provider: typeof job.provider === 'string' ? job.provider : null,
369
+ ...(typeof job.baseUrl === 'string' ? { baseUrl: job.baseUrl } : {}),
348
370
  // F118: replay reads this; report-only. `dirty` is deliberately omitted —
349
371
  // it can't be known without a shell, and null = unknown, never faked.
350
372
  code: (() => { const { version, sha } = codeVersion(); return { version, sha }; })(),
@@ -380,12 +402,46 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
380
402
  : {}),
381
403
  });
382
404
 
405
+ // F140/PRD item 28 (c) — the backstop that fires on the ABSENCE of a
406
+ // `job-end`. A provider promise that never settles (BA-25's class; 0.42.0
407
+ // closed the known drop cases per F141 but the class itself is "an
408
+ // instrument fires on absence", not a specific bug) lets node drain with
409
+ // nothing else holding the event loop, and the process exits with no
410
+ // terminal record at all — no escalation, no outcome, no way to tell a
411
+ // silent drain from an operator kill. `ended` is the one flag every
412
+ // job-end site sets via the local `end()` helper below (never a second
413
+ // spelling of "did we finish" that could fall out of sync with the real
414
+ // emits). One `beforeExit` listener per call, removed in the `finally` so
415
+ // multiple runs in one process (tests included) never leak listeners.
416
+ let ended = false;
417
+ /** @type {(outcome: string, extra?: object) => object} */
418
+ const end = (outcome, extra = {}) => {
419
+ ended = true;
420
+ return emit('job-end', { outcome, ...extra });
421
+ };
422
+ const onBeforeExit = () => {
423
+ if (ended) return;
424
+ // A drained run's in-flight spend is unknowable — the floor already
425
+ // accumulated is honest, an exact-looking total would not be (F6).
426
+ end('runner-drained', {
427
+ ...spend(),
428
+ spendComplete: false,
429
+ detail: 'process drained with no job-end — a provider call never settled (F140)',
430
+ });
431
+ // Never process.exit() (can discard queued stdout) — set the code and
432
+ // let node exit on its own; preserve a more specific code if one is
433
+ // already set.
434
+ process.exitCode = process.exitCode || 1;
435
+ };
436
+ process.once('beforeExit', onBeforeExit);
437
+
438
+ try {
383
439
  // 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
384
440
  const smoke = await primitiveSmoke(workdir);
385
441
  emit('primitive-smoke', smoke);
386
442
  if (!smoke.ok) {
387
443
  emit('escalation', { category: 'smoke-red', decisionReady: true, decision: `The ${smoke.primitive} primitive failed its known-answer check — no run verdict is trustworthy on a degraded primitive.`, options: ['fix the primitive/store', 'abandon the run'], detail: smoke.detail });
388
- emit('job-end', { outcome: 'smoke-red', ...spend() });
444
+ end('smoke-red', spend());
389
445
  return 'smoke-red';
390
446
  }
391
447
 
@@ -426,7 +482,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
426
482
  };
427
483
  const pricingRed = () => {
428
484
  emit('escalation', { category: 'pricing-red', decisionReady: true, decision: 'A provider result carried no priced cost — the hard cap cannot govern spend it cannot see (unpriced is never free, F6).', options: ['bind a priced provider/model', 'abandon the run'], spentUsd });
429
- emit('job-end', { outcome: 'pricing-red', ...spend() });
485
+ end('pricing-red', spend());
430
486
  return 'pricing-red';
431
487
  };
432
488
 
@@ -436,14 +492,14 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
436
492
  // accounts it natively (F12) and the job-end money contract is unchanged.
437
493
  {
438
494
  const outcome = await runPlan(job, {
439
- workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
495
+ workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
440
496
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
441
497
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
442
498
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
443
499
  });
444
500
  if (unpriced) return pricingRed();
445
501
  if (outcome.startsWith('step-red:')) {
446
- emit('job-end', { outcome: 'step-red', step: outcome.slice('step-red:'.length), ...spend() });
502
+ end('step-red', { step: outcome.slice('step-red:'.length), ...spend() });
447
503
  } else if (outcome === 'provider-red' || outcome === 'step-stalled') {
448
504
  // F44: a transport-throw provider-red never returned a usage figure for the
449
505
  // failed call, so the priced sum is a FLOOR, not the total — spendComplete
@@ -453,10 +509,13 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
453
509
  // the known cost of self-heal — and an unbilled reissue is indistinguishable
454
510
  // from a billed one from here. Reporting the priced sum as exact would be F6
455
511
  // in a self-heal coat.
456
- emit('job-end', { outcome, ...spend(), spendComplete: false });
512
+ end(outcome, { ...spend(), spendComplete: false });
457
513
  } else {
458
- emit('job-end', { outcome, ...spend() });
514
+ end(outcome, spend());
459
515
  }
460
516
  return outcome;
461
517
  }
518
+ } finally {
519
+ process.removeListener('beforeExit', onBeforeExit);
520
+ }
462
521
  }
package/src/transport.js CHANGED
@@ -3,14 +3,18 @@
3
3
  //
4
4
  // A transport failure is fetch THROWING — the request never produced an HTTP
5
5
  // response at all (a TLS fault mid-record, a reset socket, a DNS/network
6
- // failure). That is a DIFFERENT unknown from an HTTP response the provider
7
- // DID send (4xx/5xx/429): a response means the provider answered, so
8
- // bare-agent's own retry policy for those stays exactly what it is — this
9
- // module never widens that. Retrying only the no-response case is the whole
10
- // point: a call that dies mid-read may already have been billed (retry can
11
- // pay twice), so the retry budget stays as tight as hamr authorized it —
12
- // exactly one extra attempt, never configurable upward from a job spec or
13
- // argv (tighten-only doctrine).
6
+ // failure) — OR bare-agent's own BA-25 `guardResponseSettles` rejecting a
7
+ // response cut after headers but before the body completed (a peer
8
+ // `'aborted'`/`'error'`/premature `'close'`), tagged `context.bound:
9
+ // 'transport'` (bare-agent 0.42.0, node_modules/bare-agent/src/provider-http.js).
10
+ // That is a DIFFERENT unknown from an HTTP response the provider DID send
11
+ // (4xx/5xx/429): a response means the provider answered, so bare-agent's own
12
+ // retry policy for those stays exactly what it is — this module never widens
13
+ // that. Retrying only the no-response case is the whole point: a call that
14
+ // dies mid-read may already have been billed (retry can pay twice), so the
15
+ // retry budget stays as tight as hamr authorized it — exactly one extra
16
+ // attempt, never configurable upward from a job spec or argv (tighten-only
17
+ // doctrine).
14
18
  //
15
19
  // Scope: this module wires ONE seam — the worker Loop built in
16
20
  // src/planrun.js's `newLoop` (~line 2239), which is the ONE factory behind
@@ -31,11 +35,17 @@ export const TRANSPORT_MAX_ATTEMPTS = TRANSPORT_RETRIES + 1;
31
35
  * Is `err` a transport-class failure — the request never produced an HTTP
32
36
  * response? True for: a TLS fault mid-record ("bad record mac" and kin), a
33
37
  * reset/broken/timed-out socket (ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED,
34
- * ENETUNREACH — by code on the error itself or on `err.cause`), and
35
- * `fetch failed` wrapping a network `cause`. False for anything carrying an
36
- * HTTP status (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
38
+ * ENETUNREACH — by code on the error itself or on `err.cause`), `fetch
39
+ * failed` wrapping a network `cause`, and — as of bare-agent 0.42.0 (BA-25)
40
+ * — a `ProviderError` whose `err.context.bound === 'transport'`: the
41
+ * response stream ended (`'aborted'`/`'error'`/premature `'close'`) after
42
+ * headers but before the body completed, which bare-agent itself rejects as
43
+ * retryable with no HTTP status. False for anything carrying an HTTP status
44
+ * (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
37
45
  * `err.retryable === false` — those are provider RESPONSES, not transport
38
- * casualties, and bare-agent's own policy governs them unchanged.
46
+ * casualties, and bare-agent's own policy governs them unchanged; both
47
+ * guards run BEFORE the `context.bound` check, so a status or an explicit
48
+ * `retryable:false` wins even if `bound` says 'transport'.
39
49
  * @param {any} err
40
50
  * @returns {boolean}
41
51
  */
@@ -47,6 +57,13 @@ export function isTransportFailure(err) {
47
57
  if (typeof status === 'number') return false;
48
58
  if (err.retryable === false) return false;
49
59
 
60
+ // BA-25 (bare-agent 0.42.0): bare-agent's own transport classification —
61
+ // `guardResponseSettles` tags a body-cut-after-headers rejection with
62
+ // `context.bound: 'transport'`. Only 'transport' qualifies here — a
63
+ // sibling bound like 'idle' (BA-19's deadline-idle guard) already
64
+ // qualifies via its ETIMEDOUT code below, never by bound alone.
65
+ if (err.context && err.context.bound === 'transport') return true;
66
+
50
67
  const codeOf = (e) => (e && typeof e.code === 'string' ? e.code : null);
51
68
  const TRANSPORT_CODES = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNREFUSED', 'ENETUNREACH', 'EAI_AGAIN']);
52
69
  const code = codeOf(err) ?? codeOf(err.cause);
package/src/validate.js CHANGED
@@ -147,6 +147,10 @@ export const SECRET_PATTERNS = [
147
147
  /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/,
148
148
  /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{16}/,
149
149
  /(?<![A-Za-z0-9_-])xox[bap]-[A-Za-z0-9-]{10,}/,
150
+ // Google/Gemini API key: `AIza` + 35 chars of [0-9A-Za-z_-] (39 total, fixed
151
+ // length by Google's own issuance format). Admitted with the gemini-api
152
+ // provider (PRD item 31.3, F160) — was missing on this branch until then.
153
+ /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}/,
150
154
  ];
151
155
  const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
152
156
 
@@ -328,6 +328,36 @@ export const VERDICT_CLASSES: readonly string[];
328
328
  export const LOCKED_CLASSES: readonly string[];
329
329
  /** the classes v1 actually builds */
330
330
  export const LIVE_CLASSES: readonly string[];
331
+ /** OFF THE MENU, but still built — a THIRD state, distinct from both lists above
332
+ * (PRD item 31.1, hamr 2026-09-09: *"retire hitl from list, keep its code"*, and
333
+ * *"if it's not on menu, you can't reach it, from menu"*).
334
+ *
335
+ * `hitl` was retired by ruling at v1.71 and the class moved to fwdloop (PRD item
336
+ * 29), but no constant was ever updated, so `runInterview` still returned
337
+ * `ok:true` for a hitl pick — an authorable job for a class this product no
338
+ * longer closes.
339
+ *
340
+ * Why this is NOT `LOCKED_CLASSES`. Locked means INEXPRESSIBLE: no guard battery
341
+ * exists, so `validateCloseDecl` cannot check the close and must refuse
342
+ * (`src/declaredclose.js:306`). hitl's battery, question set, composition rule and
343
+ * runtime all exist and are tested — locking it would make every one of them
344
+ * unreachable, which is not "keeping the code", it is abandoning it in place with
345
+ * its tests deleted. So the split is:
346
+ *
347
+ * LOCKED_CLASSES — no battery. Cannot be validated, cannot run. (empty today)
348
+ * UNLISTED_CLASSES — battery exists, validates and RUNS. Just never OFFERED.
349
+ * LIVE_CLASSES — everything the system can validate and run (locked removed).
350
+ *
351
+ * Read by the AUTHORING surface only (`runInterview`, `authorClose`'s precheck).
352
+ * The spec validator, the close validator and the runtime deliberately do NOT
353
+ * read it: a hitl spec that already exists still validates and still runs, which
354
+ * is what keeps the code alive and under test. Nothing can AUTHOR a new one.
355
+ * @type {readonly string[]} */
356
+ export const UNLISTED_CLASSES: readonly string[];
357
+ /** what the authoring interview actually OFFERS: live, minus unlisted. This is
358
+ * "the menu" in hamr's sentence, and it is the only list an authoring refusal
359
+ * should ever name. */
360
+ export const MENU_CLASSES: readonly string[];
331
361
  /**
332
362
  * The whole vocabulary, as DATA. `shape` and `asserts` are the strings the
333
363
  * authoring prompt renders (M3) — the prompt and this validator must judge the
@@ -81,7 +81,7 @@ export function refuseLockedKind(kind: string): Refusal;
81
81
  * @param {{answers: Record<string|number, any>, repoPath?: string|null, lang: string,
82
82
  * verdictType?: string|null,
83
83
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
84
- * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
84
+ * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null, judgeModel?: string|null,
85
85
  * onPhase?: (phase: string, data?: any) => void,
86
86
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
87
87
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
@@ -91,7 +91,7 @@ export function refuseLockedKind(kind: string): Refusal;
91
91
  * closeDecl: any, seedRef: string|null, authoring: any, judged: any, reds: Red[],
92
92
  * stop: string|null, cost: any, interview: any}>}
93
93
  */
94
- export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, generate, provider, seedRef, scout, listing, ceilingUsd, onPhase, onCall, seedFn, scoutFn, listingFn, authorFn, authorOpts, signerFix, proposeFn, compileOpts, }: {
94
+ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, generate, provider, seedRef, scout, listing, ceilingUsd, judgeModel, onPhase, onCall, seedFn, scoutFn, listingFn, authorFn, authorOpts, signerFix, proposeFn, compileOpts, }: {
95
95
  answers: Record<string | number, any>;
96
96
  repoPath?: string | null;
97
97
  lang: string;
@@ -103,6 +103,7 @@ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questi
103
103
  scout?: any;
104
104
  listing?: any;
105
105
  ceilingUsd?: number | null;
106
+ judgeModel?: string | null;
106
107
  onPhase?: (phase: string, data?: any) => void;
107
108
  onCall?: (call: {
108
109
  label: string;
@@ -207,12 +208,12 @@ export function assembleSpec(specDraft: any, { closeDecl, verdictType }: {
207
208
  *
208
209
  * @param {{spec: any, workdir: string, seedRef?: string|null, shellCapUsd?: number,
209
210
  * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function,
210
- * judgeLoop?: Function|null, onJudgeCost?: ((c: any) => void)|null, calibrateFn?: Function,
211
- * ceilingUsd?: number|null, priorCalls?: any[]}} o
211
+ * judgeLoop?: Function|null, judgeModel?: string|null, onJudgeCost?: ((c: any) => void)|null,
212
+ * calibrateFn?: Function, ceilingUsd?: number|null, priorCalls?: any[]}} o
212
213
  * @returns {Promise<{ok: boolean, specHash: string|null, seedRef: string|null,
213
214
  * gates: any, work: any[], guards: any[], stops: any[], reds: Red[], refusal: Refusal|null}>}
214
215
  */
215
- export function prepareSigning({ spec, workdir, seedRef, shellCapUsd, timeoutMs, seedFn, listingFn, seedReadFn, judgeLoop, onJudgeCost, calibrateFn, ceilingUsd, priorCalls, }: {
216
+ export function prepareSigning({ spec, workdir, seedRef, shellCapUsd, timeoutMs, seedFn, listingFn, seedReadFn, judgeLoop, judgeModel, onJudgeCost, calibrateFn, ceilingUsd, priorCalls, }: {
216
217
  spec: any;
217
218
  workdir: string;
218
219
  seedRef?: string | null;
@@ -222,6 +223,7 @@ export function prepareSigning({ spec, workdir, seedRef, shellCapUsd, timeoutMs,
222
223
  listingFn?: Function;
223
224
  seedReadFn?: Function;
224
225
  judgeLoop?: Function | null;
226
+ judgeModel?: string | null;
225
227
  onJudgeCost?: ((c: any) => void) | null;
226
228
  calibrateFn?: Function;
227
229
  ceilingUsd?: number | null;
@@ -277,4 +279,6 @@ import { requiredAnswersFor } from './authorflow.js';
277
279
  import { VERDICT_CLASSES } from './authoring.js';
278
280
  import { LOCKED_CLASSES } from './authoring.js';
279
281
  import { LIVE_CLASSES } from './authoring.js';
280
- export { QUESTION_SETS, questionsFor, requiredAnswersFor, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES };
282
+ import { UNLISTED_CLASSES } from './authoring.js';
283
+ import { MENU_CLASSES } from './authoring.js';
284
+ export { QUESTION_SETS, questionsFor, requiredAnswersFor, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES, UNLISTED_CLASSES, MENU_CLASSES };
@@ -91,7 +91,7 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
91
91
  *
92
92
  * @param {{workdir: string, provider?: any,
93
93
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
94
- * attempts?: number, ceilingUsd?: number|null,
94
+ * attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
95
95
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
96
96
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
97
97
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
@@ -99,7 +99,7 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
99
99
  * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
100
100
  * budgetStop: 'cap-halt'|'pricing-red'|null}>}
101
101
  */
102
- export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, onCall, createLoop, createSurveyor, }: {
102
+ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, callTimeoutMs, onCall, createLoop, createSurveyor, }: {
103
103
  workdir: string;
104
104
  provider?: any;
105
105
  rounds?: number;
@@ -109,6 +109,7 @@ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, m
109
109
  ctx?: boolean;
110
110
  attempts?: number;
111
111
  ceilingUsd?: number | null;
112
+ callTimeoutMs?: number;
112
113
  onCall?: (call: {
113
114
  label: string;
114
115
  costUsd: number | null;
@@ -205,6 +206,30 @@ export function buildSeedListing({ workdir, seedRef, sourcePaths, testPaths, lis
205
206
  tier: string;
206
207
  };
207
208
  }>;
209
+ /**
210
+ * F152/PRD 30.4 — the scout's per-call IDLE bound. Every `loop.run()` in this
211
+ * module carried NO time bound at all: a live endpoint that accepts and never
212
+ * answers hangs the process forever (measured) — an open socket is an active
213
+ * handle, so node never drains and no backstop can fire.
214
+ *
215
+ * UNLIKE the run path (`callBounds()`, src/planrun.js:1518), this pipeline has
216
+ * NO wall clock to derive a bound FROM — the authoring pipeline is genuinely
217
+ * clockless (`src/authorjob.js`/`src/authorflow.js`/`scripts/run-author.mjs`
218
+ * carry a money ceiling, `ceilingUsd`/`capStop`, and never a `maxWallMs`).
219
+ * Rather than inventing a fresh number, this reuses the EXACT ceiling the run
220
+ * path itself falls back to when ITS OWN wall is unbounded
221
+ * (`PROVIDER_TIMEOUT_MS`, `src/clock.js` — BA-18's idle timeout, already an
222
+ * approved "no wall present" default one layer up): an authoring-time call is
223
+ * the identical situation, one call earlier.
224
+ *
225
+ * It bounds SOCKET INACTIVITY only (reset by every byte, per BA-18) — a call
226
+ * that is merely slow keeps streaming and is untouched; only a truly silent
227
+ * endpoint trips it. TIGHTEN-ONLY, like every cap in this file: this is an
228
+ * operator-set ceiling fixed here, not a param a caller or a signed spec may
229
+ * widen (a defaulted cap is a silent second ceiling, and there is no clock
230
+ * whose narrower reading could ever tighten it further).
231
+ */
232
+ export const AUTHOR_CALL_TIMEOUT_MS: 600000;
208
233
  /** src/planrun.js:42 — the survey's hard round bound. */
209
234
  export const AUTHOR_SCOUT_ROUNDS: 8;
210
235
  /** src/planrun.js:44 — the survey is a PROMPT INGREDIENT for the authoring call. */
@@ -80,7 +80,13 @@ export function factsResist(facts: any, gold: any, artifactText: string): {
80
80
  * graded one: `ok` is false, the cases nobody bought are absent rather than recorded
81
81
  * as failures, and `casualty` stays null — the transport was fine, the operator's own
82
82
  * governance stopped the gate.
83
- * @param {{cases: any, card: any, judgeLoop: Function|null,
83
+ * `judgeModel` is REQUIRED and has no default (PRD item 32.1). It is the identity
84
+ * the caller resolved (`resolveJudge`, src/judged.js) and it is what the graded set
85
+ * is STAMPED with — `setHash` folds it in, so a set certified by one judge cannot be
86
+ * mistaken for the same set certified by another. A default here would be the silent
87
+ * fall-back the whole judge-identity change exists to remove: a gate that grades
88
+ * under a model nobody named certifies a floor nobody can attribute.
89
+ * @param {{cases: any, card: any, judgeLoop: Function|null, judgeModel: string,
84
90
  * onCost?: (c: any) => void, attempts?: number, battery?: any[], batteryCard?: any,
85
91
  * capStop?: () => 'cap-halt'|'pricing-red'|null}} o
86
92
  * @returns {Promise<{ok: boolean, stop: string|null, casualty: any,
@@ -90,10 +96,11 @@ export function factsResist(facts: any, gold: any, artifactText: string): {
90
96
  * cardHash: string|null, casesHash: string|null, setHash: string|null,
91
97
  * costUsd: number|null, knownUsd: number, spendComplete: boolean, calls: any[]}>}
92
98
  */
93
- export function runCalibration({ cases, card, judgeLoop, onCost, attempts, battery, batteryCard, capStop, }: {
99
+ export function runCalibration({ cases, card, judgeLoop, judgeModel, onCost, attempts, battery, batteryCard, capStop, }: {
94
100
  cases: any;
95
101
  card: any;
96
102
  judgeLoop: Function | null;
103
+ judgeModel: string;
97
104
  onCost?: (c: any) => void;
98
105
  attempts?: number;
99
106
  battery?: any[];
@@ -261,15 +261,16 @@ export function signJudgedArtifacts({ proposal, fix }: {
261
261
  * - THE CASES land beside the stages as `calibration.cases` — a close-level
262
262
  * artifact, because the set calibrates the RULER and not one stage's params,
263
263
  * and because module 5's gate reads it before any stage runs.
264
- * - THE JUDGE MODEL lands beside them as `calibration.judgeModel`, and it is the
265
- * PIN (`JUDGE_MODEL`) rather than anything a caller may name. A calibration set
266
- * is only worth the tier that graded it — §4.2's whole safety argument is worth
267
- * exactly as much as BA-20's injection evidence, which is haiku-4.5's alone — so
268
- * the set has to carry which tier certified it or nothing downstream can tell a
269
- * signed floor from a bumped one. Storing it HERE, inside the spec, is what makes
270
- * a bump flip `jobSpecHash`: the signature dies, the signer re-signs, and a
271
- * re-sign is a re-run of the calibration gate. That is the docstring on
272
- * `JUDGE_MODEL` finally given a detector instead of a promise (F45).
264
+ * - THE JUDGE MODEL lands beside them as `calibration.judgeModel`. Since PRD item
265
+ * 32.1 that is the RESOLVED judge identity the caller hands in (`resolveJudge`,
266
+ * src/judged.js — the spec's signed `judge` override, else the job's own worker
267
+ * model), not a library pin. A calibration set is only worth the judge that
268
+ * graded it, so the set has to carry which judge certified it or nothing
269
+ * downstream can tell a signed floor from a moved one. Storing it HERE, inside
270
+ * the spec, is what makes a judge change flip `jobSpecHash`: the signature dies,
271
+ * the signer re-signs, and a re-sign is a re-run of the calibration gate. It is
272
+ * REQUIRED, with no default — a set stamped with a model nobody named is exactly
273
+ * the unattributable floor this field exists to prevent (F45).
273
274
  *
274
275
  * A card with NOWHERE TO LAND THROWS. It is a caller's own broken input — a
275
276
  * signed rubric folded into a close with no judged ruler — and there is no safe
@@ -278,12 +279,13 @@ export function signJudgedArtifacts({ proposal, fix }: {
278
279
  * The input is never mutated: a closeDecl is a signed artefact and rewriting a
279
280
  * caller's copy in place is how two readers come to disagree about what was
280
281
  * signed.
281
- * @param {any} closeDecl @param {{card: any, cases: any[]}} o
282
+ * @param {any} closeDecl @param {{card: any, cases: any[], judgeModel: string}} o
282
283
  * @returns {any} a new closeDecl
283
284
  */
284
- export function foldJudgedArtifacts(closeDecl: any, { card, cases }: {
285
+ export function foldJudgedArtifacts(closeDecl: any, { card, cases, judgeModel }: {
285
286
  card: any;
286
287
  cases: any[];
288
+ judgeModel: string;
287
289
  }): any;
288
290
  /**
289
291
  * ONE compile call: the person's Q6 and Q7 in, a proposal out.
package/types/cli.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
4
4
  * queued stdout on its own (F-something: `process.exit()` can discard it).
5
5
  * @param {string[]} argv
6
- * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, now?: () => number, stdin?: any }} deps
6
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any }} deps
7
7
  * @returns {Promise<number>}
8
8
  */
9
9
  export function main(argv: string[], deps?: {
@@ -14,6 +14,7 @@ export function main(argv: string[], deps?: {
14
14
  provider?: any;
15
15
  providerFor?: any;
16
16
  judgeProvider?: any;
17
+ judgeModel?: string | null;
17
18
  now?: () => number;
18
19
  stdin?: any;
19
20
  }): Promise<number>;
@@ -110,7 +110,9 @@ export function guardNames(closeDecl: any, verdictType: string): string[];
110
110
  * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
111
111
  * humanRuling?: {decision: string, text?: string|null}|null,
112
112
  * judgeLoop?: ((o: {system: string}) => any)|null,
113
- * onJudgeCost?: ((c: any) => void)|null}} [opts]
113
+ * judgeModel?: string|null,
114
+ * onJudgeCost?: ((c: any) => void)|null,
115
+ * callBounds?: (() => {timeoutMs?: number, deadlineMs?: number})|null}} [opts]
114
116
  * @returns {Promise<any>}
115
117
  */
116
118
  export function runDeclaredStages(stages: any[], redact?: (s: string) => string, opts?: {
@@ -128,7 +130,12 @@ export function runDeclaredStages(stages: any[], redact?: (s: string) => string,
128
130
  judgeLoop?: ((o: {
129
131
  system: string;
130
132
  }) => any) | null;
133
+ judgeModel?: string | null;
131
134
  onJudgeCost?: ((c: any) => void) | null;
135
+ callBounds?: (() => {
136
+ timeoutMs?: number;
137
+ deadlineMs?: number;
138
+ }) | null;
132
139
  }): Promise<any>;
133
140
  /**
134
141
  * The trend reader's input for ONE close verdict, whichever executor produced