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.
package/src/calibrate.js CHANGED
@@ -46,7 +46,9 @@
46
46
  // calibration becomes a refusal, exactly as D9's other three gates work.
47
47
  // * IT PICKS NO NUMBER. The size is `CALIBRATION_SIZE` (hamr's), the floor is
48
48
  // all-of-them (hamr's), the ladder is `JUDGE_ATTEMPTS` (operator-confirmed),
49
- // and the model is `JUDGE_MODEL` (pinned). Nothing here invents a threshold.
49
+ // and the model is the run's RESOLVED judge identity, handed in (PRD item
50
+ // 32.1) rather than pinned in this library. Nothing here invents a threshold
51
+ // and nothing here picks a judge.
50
52
  // * IT OWNS NO PROVIDER. The judge seam arrives as `judgeLoop`; an absent one
51
53
  // is a WIRING GAP that stops the gate, never a silent skip — a judged close
52
54
  // signed off a calibration nobody ran is the exact state this gate exists to
@@ -58,11 +60,12 @@
58
60
 
59
61
  import { createHash } from 'node:crypto';
60
62
  import {
61
- JUDGE_MODEL, LOCATE_LABEL, LOCATE_AXES, CALIBRATION_SIZE,
63
+ LOCATE_LABEL, LOCATE_AXES, CALIBRATION_SIZE,
62
64
  runLocate, decide, expectedOf, validateCard, validateCalibrationSet,
63
65
  } from './judged.js';
64
66
  import { JUDGE_ATTEMPTS } from './kinds.js';
65
67
  import { tallyCalls } from './text.js';
68
+ import { PROVIDER_TIMEOUT_MS } from './clock.js';
66
69
 
67
70
  /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
68
71
 
@@ -308,11 +311,11 @@ export function factsResist(facts, gold, artifactText) {
308
311
  * when the model is malforming, which is the worst moment for a paid ladder to be
309
312
  * unbounded. A stop comes back as its own field rather than as a `red`: a ceiling
310
313
  * is the OPERATOR's governance, never the transport's casualty.
311
- * @param {{artifactText: string, card: any, judgeLoop: Function, attempts: number,
314
+ * @param {{artifactText: string, card: any, judgeLoop: Function, judgeModel: string, attempts: number,
312
315
  * meter: (c: any) => void, id: string, kind: string,
313
316
  * capStop: () => 'cap-halt'|'pricing-red'|null}} o
314
317
  */
315
- async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, kind, capStop }) {
318
+ async function pipeOnce({ artifactText, card, judgeLoop, judgeModel, attempts, meter, id, kind, capStop }) {
316
319
  /** @type {any} */
317
320
  let last = null;
318
321
  /** @type {any[]} */
@@ -330,8 +333,17 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
330
333
  card,
331
334
  loopFactory: /** @type {any} */ (judgeLoop),
332
335
  attempt,
336
+ // F152/PRD 30.4 — this module's own header already names the hazard ("a
337
+ // dead socket") as one of its three CASUALTY readings, but with no bound
338
+ // at all a truly silent judge endpoint never reaches that reading — it
339
+ // hangs the authoring process forever instead (measured). The gate runs
340
+ // at draft/sign time, which has no wall clock to derive a bound from (the
341
+ // authoring pipeline's only ceiling is money, `ceilingUsd`/`capStop`), so
342
+ // this reuses the same idle-timeout default the run path falls back to
343
+ // when ITS OWN wall is unbounded (`PROVIDER_TIMEOUT_MS`, src/clock.js).
344
+ callBounds: { timeoutMs: PROVIDER_TIMEOUT_MS },
333
345
  onCost: (c) => meter({
334
- id, kind, attempt, label: CALIBRATION_LABEL, model: JUDGE_MODEL,
346
+ id, kind, attempt, label: CALIBRATION_LABEL, model: judgeModel,
335
347
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
336
348
  }),
337
349
  });
@@ -371,7 +383,13 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
371
383
  * graded one: `ok` is false, the cases nobody bought are absent rather than recorded
372
384
  * as failures, and `casualty` stays null — the transport was fine, the operator's own
373
385
  * governance stopped the gate.
374
- * @param {{cases: any, card: any, judgeLoop: Function|null,
386
+ * `judgeModel` is REQUIRED and has no default (PRD item 32.1). It is the identity
387
+ * the caller resolved (`resolveJudge`, src/judged.js) and it is what the graded set
388
+ * is STAMPED with — `setHash` folds it in, so a set certified by one judge cannot be
389
+ * mistaken for the same set certified by another. A default here would be the silent
390
+ * fall-back the whole judge-identity change exists to remove: a gate that grades
391
+ * under a model nobody named certifies a floor nobody can attribute.
392
+ * @param {{cases: any, card: any, judgeLoop: Function|null, judgeModel: string,
375
393
  * onCost?: (c: any) => void, attempts?: number, battery?: any[], batteryCard?: any,
376
394
  * capStop?: () => 'cap-halt'|'pricing-red'|null}} o
377
395
  * @returns {Promise<{ok: boolean, stop: string|null, casualty: any,
@@ -382,12 +400,25 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
382
400
  * costUsd: number|null, knownUsd: number, spendComplete: boolean, calls: any[]}>}
383
401
  */
384
402
  export async function runCalibration({
385
- cases, card, judgeLoop, onCost = () => {},
403
+ cases, card, judgeLoop, judgeModel, onCost = () => {},
386
404
  attempts = JUDGE_ATTEMPTS,
387
405
  battery = /** @type {any[]} */ (/** @type {unknown} */ (INJECTION_LOCATE_BATTERY)),
388
406
  batteryCard = INJECTION_CARD,
389
407
  capStop = () => null,
390
408
  }) {
409
+ // The mandatory-identity throw guards a REAL judge seam graded under nobody's
410
+ // name — it does not apply when there is no seam at all. A caller wiring no
411
+ // `judgeLoop` (an adopter who has not connected a judge yet) legitimately hands
412
+ // in no `judgeModel` either, and that case is the WIRING-GAP `no-judge` stop
413
+ // below, returned as a red rather than thrown. Throwing here ahead of that
414
+ // check would make the wiring-gap stop unreachable through its own documented
415
+ // path — `prepareSigning` (src/authorjob.js) draws exactly this same line with
416
+ // its own `judgeLoop !== null` guard, and this function has to agree with it.
417
+ if (typeof judgeLoop === 'function' && (typeof judgeModel !== 'string' || judgeModel.trim() === '')) {
418
+ throw new Error('[calibrate] runCalibration needs the resolved judgeModel (PRD item 32.1): the graded set is '
419
+ + 'STAMPED with the judge that certified it, and a set nobody can attribute is a floor nobody can tell apart '
420
+ + 'from one graded by a different model. There is no library pin to fall back to.');
421
+ }
391
422
  /** @type {any[]} */
392
423
  const calls = [];
393
424
  /** @param {any} c */
@@ -403,7 +434,7 @@ export async function runCalibration({
403
434
  return /** @type {any} */ ({
404
435
  ok: false, stop: null, casualty: null, graded: [], failures: [],
405
436
  injection: { styles: [], allResisted: false, leaks: [] },
406
- reds: [], judgeModel: JUDGE_MODEL,
437
+ reds: [], judgeModel,
407
438
  cardHash: null, casesHash: null, setHash: null,
408
439
  costUsd: t.costUsd, knownUsd: t.knownUsd, spendComplete: t.spendComplete, calls: calls.map((c) => ({ ...c })),
409
440
  ...extra,
@@ -454,7 +485,7 @@ export async function runCalibration({
454
485
  code: 'no-judge-seam',
455
486
  path: 'judgeLoop',
456
487
  detail: `the calibration gate runs ${CALIBRATION_SIZE} real locate calls plus ${battery.length} injection `
457
- + `artifacts through ${JUDGE_MODEL}, and no judge seam was wired. An absent seam is an adopter wiring gap and `
488
+ + `artifacts through ${judgeModel}, and no judge seam was wired. An absent seam is an adopter wiring gap and `
458
489
  + 'never a fall-back: a gate that grades nothing would certify every close it was asked about',
459
490
  }],
460
491
  });
@@ -462,7 +493,7 @@ export async function runCalibration({
462
493
 
463
494
  const cardHash = artifactHash(card);
464
495
  const casesHash = artifactHash(cases);
465
- const setHash = artifactHash({ card, cases, judgeModel: JUDGE_MODEL });
496
+ const setHash = artifactHash({ card, cases, judgeModel });
466
497
  const stamped = { cardHash, casesHash, setHash };
467
498
 
468
499
  // ── the signed ten ────────────────────────────────────────────────────────
@@ -470,7 +501,7 @@ export async function runCalibration({
470
501
  const graded = [];
471
502
  for (const c of cases) {
472
503
  const r = await pipeOnce({
473
- artifactText: String(c.artifact), card, judgeLoop: /** @type {Function} */ (judgeLoop),
504
+ artifactText: String(c.artifact), card, judgeLoop: /** @type {Function} */ (judgeLoop), judgeModel,
474
505
  attempts, meter, id: String(c.id), kind: 'case', capStop,
475
506
  });
476
507
  // the ceiling, ahead of the casualty branch: a call nobody funded is not a call
@@ -517,7 +548,7 @@ export async function runCalibration({
517
548
  const styles = [];
518
549
  for (const b of battery) {
519
550
  const r = await pipeOnce({
520
- artifactText: b.artifact, card: batteryCard, judgeLoop: /** @type {Function} */ (judgeLoop),
551
+ artifactText: b.artifact, card: batteryCard, judgeLoop: /** @type {Function} */ (judgeLoop), judgeModel,
521
552
  attempts, meter, id: b.id, kind: 'injection', capStop,
522
553
  });
523
554
  if (r.budget) {
package/src/cardauthor.js CHANGED
@@ -60,10 +60,10 @@
60
60
  // pipe produces are one shape read twice (module 5 compares them mechanically).
61
61
 
62
62
  import {
63
- JUDGE_RULE_IDS, JUDGE_RULES, CALIBRATION_SIZE, CASE_VERDICTS, validateJudgedArtifacts, JUDGE_MODEL,
63
+ JUDGE_RULE_IDS, JUDGE_RULES, CALIBRATION_SIZE, CASE_VERDICTS, validateJudgedArtifacts,
64
64
  } from './judged.js';
65
65
  import { askStructured, makeCostBook } from './authorflow.js';
66
- import { JUDGED_FLOOR_KIND } from './kinds.js';
66
+ import { judgedStages } from './kinds.js';
67
67
  import { isObj, redactSecrets } from './validate.js';
68
68
 
69
69
  /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
@@ -363,15 +363,16 @@ export function signJudgedArtifacts({ proposal, fix = null }) {
363
363
  * - THE CASES land beside the stages as `calibration.cases` — a close-level
364
364
  * artifact, because the set calibrates the RULER and not one stage's params,
365
365
  * and because module 5's gate reads it before any stage runs.
366
- * - THE JUDGE MODEL lands beside them as `calibration.judgeModel`, and it is the
367
- * PIN (`JUDGE_MODEL`) rather than anything a caller may name. A calibration set
368
- * is only worth the tier that graded it — §4.2's whole safety argument is worth
369
- * exactly as much as BA-20's injection evidence, which is haiku-4.5's alone — so
370
- * the set has to carry which tier certified it or nothing downstream can tell a
371
- * signed floor from a bumped one. Storing it HERE, inside the spec, is what makes
372
- * a bump flip `jobSpecHash`: the signature dies, the signer re-signs, and a
373
- * re-sign is a re-run of the calibration gate. That is the docstring on
374
- * `JUDGE_MODEL` finally given a detector instead of a promise (F45).
366
+ * - THE JUDGE MODEL lands beside them as `calibration.judgeModel`. Since PRD item
367
+ * 32.1 that is the RESOLVED judge identity the caller hands in (`resolveJudge`,
368
+ * src/judged.js — the spec's signed `judge` override, else the job's own worker
369
+ * model), not a library pin. A calibration set is only worth the judge that
370
+ * graded it, so the set has to carry which judge certified it or nothing
371
+ * downstream can tell a signed floor from a moved one. Storing it HERE, inside
372
+ * the spec, is what makes a judge change flip `jobSpecHash`: the signature dies,
373
+ * the signer re-signs, and a re-sign is a re-run of the calibration gate. It is
374
+ * REQUIRED, with no default — a set stamped with a model nobody named is exactly
375
+ * the unattributable floor this field exists to prevent (F45).
375
376
  *
376
377
  * A card with NOWHERE TO LAND THROWS. It is a caller's own broken input — a
377
378
  * signed rubric folded into a close with no judged ruler — and there is no safe
@@ -380,20 +381,26 @@ export function signJudgedArtifacts({ proposal, fix = null }) {
380
381
  * The input is never mutated: a closeDecl is a signed artefact and rewriting a
381
382
  * caller's copy in place is how two readers come to disagree about what was
382
383
  * signed.
383
- * @param {any} closeDecl @param {{card: any, cases: any[]}} o
384
+ * @param {any} closeDecl @param {{card: any, cases: any[], judgeModel: string}} o
384
385
  * @returns {any} a new closeDecl
385
386
  */
386
- export function foldJudgedArtifacts(closeDecl, { card, cases }) {
387
+ export function foldJudgedArtifacts(closeDecl, { card, cases, judgeModel }) {
388
+ if (typeof judgeModel !== 'string' || judgeModel.trim() === '') {
389
+ throw new Error('[cardauthor] foldJudgedArtifacts needs the resolved judgeModel (PRD item 32.1): the stored set '
390
+ + 'carries the judge that certified it, and there is no library pin to fall back to.');
391
+ }
387
392
  const out = structuredClone(closeDecl);
388
- const stages = Array.isArray(out?.stages) ? out.stages : [];
389
- const judged = stages.filter((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND);
393
+ // `judgedStages` filters `out`'s OWN stage objects, so the card assignment
394
+ // below mutates the clone in place — the reason this reads `out`, not the
395
+ // caller's `closeDecl`.
396
+ const judged = judgedStages(out);
390
397
  if (judged.length !== 1) {
391
398
  throw new Error(`[cardauthor] the signed card folds into the close's ONE judged-floor stage, and this close has `
392
399
  + `${judged.length} — a rubric with nowhere to land is signed evidence nothing reads, and a second judged stage `
393
400
  + 'is a second bar for one verdict');
394
401
  }
395
402
  judged[0].params = { ...(isObj(judged[0].params) ? judged[0].params : {}), card: structuredClone(card) };
396
- out.calibration = { cases: structuredClone(cases), judgeModel: JUDGE_MODEL };
403
+ out.calibration = { cases: structuredClone(cases), judgeModel };
397
404
  return out;
398
405
  }
399
406
 
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';
@@ -32,17 +31,15 @@ import {
32
31
 
33
32
  import {
34
33
  exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
35
- runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, JUDGE_MODEL,
34
+ runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, resolveJudge,
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,63 @@ 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
+ // THE JUDGE, RESOLVED RATHER THAN PINNED (PRD item 32.1/32.2). The bundle
88
+ // runner's key contract is `ANTHROPIC_API_KEY`-only and the refusal above has
89
+ // already established that the WORKER provider reads that key — so a judge
90
+ // defaulting to the worker's own identity reads the same key by construction,
91
+ // and a signed `judge` naming a different provider would need a key this
92
+ // runner does not have. That case refuses here, at $0, by name — the same
93
+ // shape and the same reason as the worker refusal above, never a call made
94
+ // with the wrong vendor's key.
95
+ const judge = resolveJudge({
96
+ specJudge: isObj(spec) ? spec.judge : undefined,
97
+ workerProvider: providerName,
98
+ workerModel: MODEL,
99
+ });
100
+ const judgeEntry = resolveProvider(judge.provider);
101
+ if (judgeEntry.envKey !== 'ANTHROPIC_API_KEY') {
102
+ throw new Error(
103
+ `bareloop run: this bundle's spec names judge provider "${judge.provider}", which reads `
104
+ + `${judgeEntry.envKey}. The bundle runner's key contract is ANTHROPIC_API_KEY only, so it `
105
+ + 'refuses rather than calling that provider with the wrong key. Run this job through scripts/run-u.mjs.',
106
+ );
107
+ }
108
+ return {
109
+ ...buildRunnerProviders({
110
+ providerName, apiKey, model: MODEL, tierModels, baseUrl,
111
+ judgeApiKey: apiKey, judgeModel: judge.model, judgeProviderName: judge.provider, judgeBaseUrl: baseUrl,
112
+ }),
113
+ judgeModel: judge.model,
114
+ };
75
115
  }
76
116
 
77
117
  /** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
@@ -221,6 +261,11 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
221
261
  // provider (the test seam). Absent and no injected provider: print the
222
262
  // operator questions (the bundle's own README) and the hash, spend nothing.
223
263
  let { provider, providerFor, judgeProvider } = deps;
264
+ // the judge IDENTITY travels with the judge PROVIDER (PRD item 32.1). A test
265
+ // injecting `deps.judgeProvider` may name it too; absent, it is resolved
266
+ // alongside the real providers below, and a judged stage with neither stops as
267
+ // a wiring gap rather than grading under a name nobody wrote.
268
+ let judgeModel = /** @type {string|null} */ (deps.judgeModel ?? null);
224
269
  if (!provider) {
225
270
  const apiKey = env.ANTHROPIC_API_KEY;
226
271
  if (!apiKey) {
@@ -230,7 +275,13 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
230
275
  out(`bundleHash: ${bundle.manifest.bundleHash}`);
231
276
  return 0;
232
277
  }
233
- ({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
278
+ try {
279
+ ({ provider, providerFor, judgeProvider, judgeModel } = buildProviders(apiKey, bundle.spec));
280
+ } catch (e) {
281
+ // a refusal from the key contract above: name it and spend nothing
282
+ err(String(/** @type {any} */ (e)?.message ?? e));
283
+ return 1;
284
+ }
234
285
  }
235
286
 
236
287
  // 4. blessing.
@@ -317,6 +368,7 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
317
368
  provider,
318
369
  providerFor,
319
370
  judgeProvider,
371
+ judgeModel,
320
372
  emit,
321
373
  closeDir,
322
374
  shellCapUsd: runSpec.budgetUsd,
@@ -449,7 +501,7 @@ async function runMenu(deps, ctx) {
449
501
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
450
502
  * queued stdout on its own (F-something: `process.exit()` can discard it).
451
503
  * @param {string[]} argv
452
- * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, now?: () => number, stdin?: any }} deps
504
+ * @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
453
505
  * @returns {Promise<number>}
454
506
  */
455
507
  export async function main(argv, deps = {}) {
@@ -68,7 +68,7 @@
68
68
  // once instead of once-per-invocation, but `runPlan` has a dozen return paths and
69
69
  // a leaked worktree outlives the process that made it.
70
70
 
71
- import { runStage, makeSeedTrees, STOP_FAULTS, EXIT_RED, JUDGED_FLOOR_KIND } from './kinds.js';
71
+ import { runStage, makeSeedTrees, STOP_FAULTS, EXIT_RED, JUDGED_FLOOR_KIND, judgedStages } from './kinds.js';
72
72
  import {
73
73
  validateDeclaration, classGuards, genreOwnedEnvNames, ungroundedGenreEnv, GENRE_LANGUAGES, TYPES_GENRE,
74
74
  VERDICT_CLASSES, LIVE_CLASSES, NEVER_OFFERED_KINDS,
@@ -424,8 +424,7 @@ export function validateCloseDecl(closeDecl, opts = {}) {
424
424
  // redded here — making the set MANDATORY is the signing gate's call, and
425
425
  // module 5 owns it.
426
426
  if (closeDecl.calibration !== undefined) {
427
- const judged = (Array.isArray(closeDecl.stages) ? closeDecl.stages : [])
428
- .filter((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND);
427
+ const judged = judgedStages(closeDecl);
429
428
  if (!isObj(closeDecl.calibration)) {
430
429
  red('invalid-value', `${at}.calibration`, 'the frozen calibration set, as an object carrying `cases`');
431
430
  } else if (judged.length !== 1) {
@@ -567,13 +566,15 @@ function translate(r, redact) {
567
566
  * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
568
567
  * humanRuling?: {decision: string, text?: string|null}|null,
569
568
  * judgeLoop?: ((o: {system: string}) => any)|null,
570
- * onJudgeCost?: ((c: any) => void)|null}} [opts]
569
+ * judgeModel?: string|null,
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, judgeModel = 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
@@ -603,14 +604,27 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
603
604
  // human stage reads the absence as "nobody has answered yet", which is the
604
605
  // whole of what a pause means.
605
606
  humanRuling,
606
- // THE PAID SEAM and THE METER, both operator territory and both absent on
607
- // every mechanical close. A judged stage with no seam STOPS as a wiring gap
608
- // rather than falling back to some other model (M1 owns no provider and the
609
- // judge tier is pinned), and every locate call it does buy is reported OUT
610
- // through the meter so the run's ONE ledger can see it — a close has cost $0
611
- // until this kind, and a budget must fund the attempt PLUS its close.
607
+ // THE PAID SEAM, THE IDENTITY and THE METER, all operator territory and all
608
+ // absent on every mechanical close. A judged stage with no seam STOPS as a
609
+ // wiring gap rather than falling back to some other model (M1 owns no
610
+ // provider), and `judgeModel` is the seam's OTHER half (PRD item 32.1) —
611
+ // WHICH model the seam drives, resolved by the caller (`resolveJudge`,
612
+ // src/judged.js) and passed through untouched here, exactly as `judgeLoop`
613
+ // is. `runStage` checks the identity before the seam, so a judged stage with
614
+ // neither wired reports the identity gap, and one with only the seam missing
615
+ // still names the judge it would have graded with. Every locate call the
616
+ // seam buys is reported OUT through the meter so the run's ONE ledger can
617
+ // see it — a close has cost $0 until this kind, and a budget must fund the
618
+ // attempt PLUS its close.
612
619
  judgeLoop,
620
+ judgeModel,
613
621
  onJudgeCost,
622
+ // F152/PRD 30.4 — the SAME per-call time bounds every worker round already
623
+ // carries (`callBounds()`, src/planrun.js:1518), so the judged stage's own
624
+ // locate call cannot hang the process on a live silent endpoint. `null` is
625
+ // the honest absence (an adopter/test that wires no clock through), never a
626
+ // defaulted number invented here.
627
+ callBounds,
614
628
  seedTrees,
615
629
  };
616
630
  try {
package/src/index.js CHANGED
@@ -146,15 +146,18 @@ export {
146
146
  // hitl checkpoint keeps. It never returns, writes or implies a verdict.
147
147
  export { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
148
148
  // SOFTGREEN module 1 — the judged floor's core. The pieces an adopter (and an
149
- // integrating UI) genuinely needs: the PIN (`JUDGE_MODEL` — what a wired judge
150
- // provider must be bound to, and a bump of which forces recalibration), the
151
- // rulebook a card SELECTS from, the card gate, and the two halves themselves so a
152
- // calibration harness can grade the whole pipe without running a close.
149
+ // integrating UI) genuinely needs: `resolveJudge` (PRD item 32.1 — WHICH model
150
+ // grades a job: the spec's signed `judge` override, else the job's own worker
151
+ // model, and no library fall-back), `JUDGE_MODEL` (the pre-item-32 pin, kept
152
+ // exported for adopters and still the right default for an anthropic-api job,
153
+ // but no longer read by any grading path), the rulebook a card SELECTS from,
154
+ // the card gate, and the two halves themselves so a calibration harness can
155
+ // grade the whole pipe without running a close.
153
156
  // `defaultJudgeLoop` is the one spelling of how this repo drives a judge; the
154
157
  // runner reaches it through `runPlan`'s `judgeProvider`, and it is exported so a
155
158
  // caller building its own harness does not spell a second one.
156
159
  export {
157
- JUDGE_MODEL, JUDGE_MAX_TOKENS, JUDGE_RULES, JUDGE_RULE_IDS, LOCATE_AXES, LOCATE_LABEL,
160
+ JUDGE_MODEL, resolveJudge, JUDGE_MAX_TOKENS, JUDGE_RULES, JUDGE_RULE_IDS, LOCATE_AXES, LOCATE_LABEL,
158
161
  validateCard, validateFacts, locatePrompt, runLocate, decide, defaultJudgeLoop,
159
162
  // SOFTGREEN module 4 — what a legal SIGNED calibration set is. The SIZE is
160
163
  // hamr's own ruling and a size change is a spec-level threshold change;
package/src/job.js CHANGED
@@ -133,7 +133,32 @@ export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
133
133
  * subscription — its costUsd axis is NOTIONAL (API-equivalent value, not
134
134
  * billed), so it is a DISTINCT condition: rows never pool with anthropic-api
135
135
  * rows on the cost axis, and budgets sized for one do not transfer. */
136
- export const PROVIDERS = Object.freeze(['anthropic-api', 'clipipe-subscription']);
136
+ /** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
137
+ * constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
138
+ * `deepseek-chat` is THE secondary provider (one, not a menu of half-tested
139
+ * models); the factory's tier table is where that lives, not this menu.
140
+ *
141
+ * PRD item 31.3 (hamr, 2026-09-09: *"anthropic, openai, gemini drop ollama for
142
+ * now"*): `gemini-api` joins. That is bareloop matching bare-agent's own
143
+ * adapter surface rather than curating a shorter list — bare-agent exports four
144
+ * real HTTP backends (Anthropic, OpenAI, Gemini, Ollama) plus CLIPipe and the
145
+ * FallbackProvider composite; `provider-http.js`, `provider-stop-reason.js`,
146
+ * `provider-temperature.js` and `provider-usage.js` are shared HELPERS, not
147
+ * backends, and were miscounted as providers once — the export list, never the
148
+ * filenames, is the source of truth.
149
+ *
150
+ * Ollama is deliberately NOT here. Beyond hamr's word, it is the one entry
151
+ * whose economics differ in kind: it takes no API key and bills nothing, so
152
+ * every round would price at $0 through machinery that treats $0 as a real
153
+ * price. An unpriced round reported as free is the exact honesty violation the
154
+ * `?? 0` rule exists to stop, and admitting it needs that question answered
155
+ * first, not a menu entry.
156
+ *
157
+ * `gemini-api` is ADMITTED-PENDING-PROBE: the probe rule stands (item 28
158
+ * ruling (d)) and gemini has zero runs. See `PROBE_STATUS` in
159
+ * `src/providers.js` — a spec may name it, and the runner says out loud that
160
+ * nothing has proven it. */
161
+ export const PROVIDERS = Object.freeze(['anthropic-api', 'openai-api', 'gemini-api', 'clipipe-subscription']);
137
162
  /** V3 environment label: declared keys only — every field is a lineage-key
138
163
  * candidate at N3. `provider` is part of the key by definition (top-level,
139
164
  * not duplicated here). */
@@ -141,7 +166,10 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
141
166
  // `steps` stays in the field list ONLY so a retired spec reds by name
142
167
  // (`shape-retired`) instead of falling through to a generic unknown-field —
143
168
  // the operator gets told what happened, not just that something is wrong.
144
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
169
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
170
+ /** the exact fields of a signed `judge` override — nested objects red unknown
171
+ * keys too, because no smuggling level exists in a signed spec */
172
+ const JUDGE_FIELDS = ['provider', 'model'];
145
173
  /** the four-field plan shape's core (decision 5) — presence of any of these
146
174
  * declares the shape; `tools` (the ceiling) rides the shape but alone does not
147
175
  * declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
@@ -267,6 +295,68 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
267
295
  red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
268
296
  }
269
297
 
298
+ // JUDGE — PRD item 32.1 (hamr, 2026-09-10: the judge "defaults to whatever
299
+ // llm is used and can be overriden"). OPTIONAL, and the OVERRIDE half: absent,
300
+ // the judge resolves to this job's own worker provider and model
301
+ // (`resolveJudge`, src/judged.js), which is what lets a DeepSeek or Gemini job
302
+ // judge without a second account. Present, it pins a judge deliberately
303
+ // different from the worker — an independent reader.
304
+ //
305
+ // It is signed like `model`/`baseUrl` above: `jobSpecHash` canonizes the whole
306
+ // resolved spec, so naming a judge is the OPERATOR signing off on who examines
307
+ // this work. The drafting agent cannot express it — naming your own examiner is
308
+ // arbiter territory, and the authoring schema has no such field.
309
+ //
310
+ // BOTH halves are required together, deliberately. A `judge` carrying only a
311
+ // provider would have to invent a model, and a judge identity nobody wrote is
312
+ // the state the calibration record exists to make impossible.
313
+ if (spec.judge !== undefined) {
314
+ if (!isObj(spec.judge)) {
315
+ red('invalid-value', 'judge', 'an object {provider, model} — the signed judge override; omit it to judge on this job\'s own worker model');
316
+ } else {
317
+ for (const key of Object.keys(spec.judge)) {
318
+ if (!JUDGE_FIELDS.includes(key)) red('unknown-field', `judge.${key}`, `declared keys only: ${JUDGE_FIELDS.join('|')}`);
319
+ }
320
+ if (!PROVIDERS.includes(spec.judge.provider)) {
321
+ red('invalid-value', 'judge.provider', `menu: ${PROVIDERS.join('|')} — the judge runs through the same provider factory the worker does, so an off-menu judge is a provider nothing can construct`);
322
+ }
323
+ if (!isNonEmptyString(spec.judge.model)) {
324
+ red('invalid-value', 'judge.model', 'non-empty string — exact provider model id; a judge with a provider and no model is an identity nobody signed');
325
+ }
326
+ }
327
+ }
328
+
329
+ // BASE URL — PRD item 28, ruling (d), 2026-09-09: admitted in v1 of
330
+ // openai-api (an OpenAI-compatible gateway/endpoint), OPTIONAL and part of
331
+ // the signed hash like `model` above. This validator has no network
332
+ // access, so it checks SHAPE only: `https://` for a real endpoint, or
333
+ // `http://` restricted to a loopback host (a local OpenAI-compatible
334
+ // server, the only legitimate reason to skip TLS) — and never a
335
+ // credential embedded IN the URL itself. A URL travels into logs and
336
+ // spine records far more casually than a header ever would, and secrets
337
+ // never enter the tree/spine (hard line #3) — a bareUrl carrying
338
+ // `user:pass@host` is exactly that leak, just spelled differently.
339
+ if (spec.baseUrl !== undefined) {
340
+ if (!isNonEmptyString(spec.baseUrl)) {
341
+ red('invalid-value', 'baseUrl', 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)');
342
+ } else {
343
+ /** @type {URL|null} */
344
+ let u = null;
345
+ try { u = new URL(spec.baseUrl); } catch { /* u stays null — caught below */ }
346
+ if (!u) {
347
+ red('invalid-value', 'baseUrl', 'must be a parseable URL');
348
+ } else if (u.username !== '' || u.password !== '') {
349
+ red('invalid-value', 'baseUrl', 'no embedded credentials (user:pass@host) — secrets never enter a signed spec');
350
+ } else if (u.protocol === 'https:') {
351
+ // admitted
352
+ } else if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
353
+ // admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
354
+ } else {
355
+ red('invalid-value', 'baseUrl', 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)');
356
+ }
357
+ }
358
+ }
359
+
270
360
  // 3. the outer write fence — operator law (interview decision #4), same
271
361
  // containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
272
362
  // transforms would let a fence validate green and mean something else).