bareloop 0.23.0 → 0.25.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/cli.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
 
32
32
  import {
33
33
  exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
34
- runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, JUDGE_MODEL,
34
+ runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, resolveJudge,
35
35
  } from './index.js';
36
36
  import { resolveProvider, buildRunnerProviders } from './providers.js';
37
37
 
@@ -84,9 +84,34 @@ function buildProviders(apiKey, spec) {
84
84
  const MODEL = modelResolution.model;
85
85
  const tierModels = modelResolution.source === 'spec' ? { ...entry.tiers, sonnet: MODEL } : entry.tiers;
86
86
  const baseUrl = isObj(spec) && typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined;
87
- return buildRunnerProviders({
88
- providerName, apiKey, model: MODEL, tierModels, baseUrl, judgeApiKey: apiKey, judgeModel: JUDGE_MODEL,
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,
89
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
+ };
90
115
  }
91
116
 
92
117
  /** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
@@ -236,6 +261,11 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
236
261
  // provider (the test seam). Absent and no injected provider: print the
237
262
  // operator questions (the bundle's own README) and the hash, spend nothing.
238
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);
239
269
  if (!provider) {
240
270
  const apiKey = env.ANTHROPIC_API_KEY;
241
271
  if (!apiKey) {
@@ -246,7 +276,7 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
246
276
  return 0;
247
277
  }
248
278
  try {
249
- ({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
279
+ ({ provider, providerFor, judgeProvider, judgeModel } = buildProviders(apiKey, bundle.spec));
250
280
  } catch (e) {
251
281
  // a refusal from the key contract above: name it and spend nothing
252
282
  err(String(/** @type {any} */ (e)?.message ?? e));
@@ -338,6 +368,7 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
338
368
  provider,
339
369
  providerFor,
340
370
  judgeProvider,
371
+ judgeModel,
341
372
  emit,
342
373
  closeDir,
343
374
  shellCapUsd: runSpec.budgetUsd,
@@ -470,7 +501,7 @@ async function runMenu(deps, ctx) {
470
501
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
471
502
  * queued stdout on its own (F-something: `process.exit()` can discard it).
472
503
  * @param {string[]} argv
473
- * @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
474
505
  * @returns {Promise<number>}
475
506
  */
476
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,6 +566,7 @@ 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,
569
+ * judgeModel?: string|null,
570
570
  * onJudgeCost?: ((c: any) => void)|null,
571
571
  * callBounds?: (() => {timeoutMs?: number, deadlineMs?: number})|null}} [opts]
572
572
  * @returns {Promise<any>}
@@ -574,7 +574,7 @@ function translate(r, redact) {
574
574
  export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
575
575
  const {
576
576
  timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode, humanRuling = null,
577
- judgeLoop = null, onJudgeCost = null, callBounds = null,
577
+ judgeLoop = null, judgeModel = null, onJudgeCost = null, callBounds = null,
578
578
  } = opts;
579
579
  if (!isNonEmptyString(cwd) || !isNonEmptyString(seedRef)) {
580
580
  // Refuse rather than guess. A declared close measures against a SEED; a
@@ -604,13 +604,20 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
604
604
  // human stage reads the absence as "nobody has answered yet", which is the
605
605
  // whole of what a pause means.
606
606
  humanRuling,
607
- // THE PAID SEAM and THE METER, both operator territory and both absent on
608
- // every mechanical close. A judged stage with no seam STOPS as a wiring gap
609
- // rather than falling back to some other model (M1 owns no provider and the
610
- // judge tier is pinned), and every locate call it does buy is reported OUT
611
- // through the meter so the run's ONE ledger can see it — a close has cost $0
612
- // 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.
613
619
  judgeLoop,
620
+ judgeModel,
614
621
  onJudgeCost,
615
622
  // F152/PRD 30.4 — the SAME per-call time bounds every worker round already
616
623
  // carries (`callBounds()`, src/planrun.js:1518), so the judged stage's own
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;
@@ -282,3 +285,12 @@ export {
282
285
  // exists so an adopter driving `bareloop run` from their own script can do so
283
286
  // without spawning `bin/bareloop.mjs` as a child process.
284
287
  export { main as cliMain } from './cli.js';
288
+ // PRD item 33/M2 (`docs/product/ITEM33-BUILD.md`) — the source front door. A
289
+ // plain folder, a plain file, or one URL frozen into a hidden git tree before
290
+ // any token spends (H1's fix: the person never sees the repo), and the
291
+ // destination proven/copied at the two moments a runner needs them ($0
292
+ // preflight, and once on a minted green). Exported because `scripts/prep-
293
+ // source.mjs` and `scripts/run-u.mjs` are two separate call sites reaching
294
+ // for the SAME functions — an adopter building a third has to reach the same
295
+ // module, not a re-derivation of it.
296
+ export { prepareSource, proveDestination, copyOut, readSourceManifest, frontDoorFromManifest } from './source.js';
package/src/job.js CHANGED
@@ -136,8 +136,29 @@ export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
136
136
  /** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
137
137
  * constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
138
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
- export const PROVIDERS = Object.freeze(['anthropic-api', 'openai-api', 'clipipe-subscription']);
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']);
141
162
  /** V3 environment label: declared keys only — every field is a lineage-key
142
163
  * candidate at N3. `provider` is part of the key by definition (top-level,
143
164
  * not duplicated here). */
@@ -145,7 +166,10 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
145
166
  // `steps` stays in the field list ONLY so a retired spec reds by name
146
167
  // (`shape-retired`) instead of falling through to a generic unknown-field —
147
168
  // the operator gets told what happened, not just that something is wrong.
148
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', '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'];
149
173
  /** the four-field plan shape's core (decision 5) — presence of any of these
150
174
  * declares the shape; `tools` (the ceiling) rides the shape but alone does not
151
175
  * declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
@@ -271,6 +295,37 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
271
295
  red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
272
296
  }
273
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
+
274
329
  // BASE URL — PRD item 28, ruling (d), 2026-09-09: admitted in v1 of
275
330
  // openai-api (an OpenAI-compatible gateway/endpoint), OPTIONAL and part of
276
331
  // the signed hash like `model` above. This validator has no network
package/src/judged.js CHANGED
@@ -65,32 +65,150 @@
65
65
 
66
66
  import { createRequire } from 'node:module';
67
67
  import { extractArtifact, priceOf, scrubRaw } from './text.js';
68
+ import { resolveProvider } from './providers.js';
68
69
 
69
70
  const require = createRequire(import.meta.url);
70
71
 
71
72
  /**
72
- * THE JUDGE MODEL, PINNED. Never agent-selectable and never a step knob: it is
73
- * the only tier with established injection resistance upstream (BA-20's
74
- * INJECTION_BATTERY is haiku-4.5 only), and §4.2's whole safety argument is
75
- * worth exactly as much as that evidence. A judge-model bump forces a full
76
- * recalibration — mandatory, per the model-bump dead-weight replay rule.
73
+ * THE JUDGE'S HISTORICAL PIN. Until PRD item 32 (2026-09-10) this constant WAS
74
+ * the judge: every grading path imported it, so bareloop could run a worker on
75
+ * any provider and still judge only on Claude.
77
76
  *
78
- * THAT IS ENFORCED, not asked for, and it is enforced in three places because a
79
- * rule with no wired detector is prose (F45):
80
- * 1. the value here is STORED with the set it graded — `foldJudgedArtifacts`
77
+ * It is no longer read by any grading path. The judge identity is now RESOLVED
78
+ * per job (`resolveJudge` below) and travels as data — `ctx.judgeModel` into
79
+ * `runJudgedFloor`, an explicit argument into the calibration gate, and
80
+ * `closeDecl.calibration.judgeModel` into the signed spec, exactly as before.
81
+ * Keeping the export is deliberate: it is part of the public API
82
+ * (`src/index.js`) and an adopter importing it should not break on a minor.
83
+ * What it means now is "the judge bareloop pinned before item 32", which is
84
+ * still the right DEFAULT for an anthropic-api job and still the only tier with
85
+ * established injection resistance upstream (BA-20's `INJECTION_BATTERY` is
86
+ * haiku-4.5 only).
87
+ *
88
+ * THE ENFORCEMENT IS UNCHANGED, and still lives in three wired places because a
89
+ * rule with no detector is prose (F45) — only the value being compared moved
90
+ * from this constant to the resolved identity:
91
+ * 1. the identity is STORED with the set it graded — `foldJudgedArtifacts`
81
92
  * writes it as `closeDecl.calibration.judgeModel`, inside the spec, so
82
- * `jobSpecHash` covers it by construction. Bump this constant and every
93
+ * `jobSpecHash` covers it by construction. Change the judge and every
83
94
  * signed judged spec hashes differently: the signature dies, and re-signing
84
95
  * is what re-runs the calibration gate.
85
96
  * 2. `validateCloseDecl` REQUIRES it on any stored set — a set nobody can
86
- * attribute to a tier is a floor nobody can tell apart from a bumped one.
87
- * 3. `runJudgedFloor` refuses to grade when the stored tier is not this one,
88
- * naming both models. It does not degrade in either direction: grading on
89
- * the new tier judges against a floor nobody calibrated, and the old tier is
90
- * not on offer.
97
+ * attribute to a judge is a floor nobody can tell apart from a moved one.
98
+ * 3. `runJudgedFloor` refuses to grade when the stored identity is not the one
99
+ * about to grade, naming both. It does not degrade in either direction:
100
+ * grading on the new judge judges against a floor nobody calibrated, and
101
+ * the old judge is not on offer.
91
102
  */
92
103
  export const JUDGE_MODEL = 'claude-haiku-4-5';
93
104
 
105
+ /**
106
+ * RESOLVE WHICH MODEL GRADES THIS JOB (PRD item 32.1).
107
+ *
108
+ * hamr, 2026-09-10: *"what i care about is that judge becomes llm agnostic and
109
+ * not set to one model or provider"* — and the shape he named: *"defaults to
110
+ * whatever llm is used and can be overriden"*.
111
+ *
112
+ * So, in precedence order:
113
+ * 1. `spec.judge` — an explicit, SIGNED `{provider, model}`. This is how a run
114
+ * pins a judge deliberately different from its worker (an independent
115
+ * reader, or a judge whose calibration a job wants to keep while the worker
116
+ * moves). It is a spec field the HUMAN signs, part of `jobSpecHash` like
117
+ * every other; the drafting agent cannot express it, because naming your
118
+ * own examiner is arbiter territory.
119
+ * 2. otherwise the job's OWN worker provider and model. A DeepSeek job judges
120
+ * on DeepSeek and needs no second account — which is the whole point: after
121
+ * item 31.4 a green job runs on its own key alone, and this is what lets a
122
+ * JUDGED one do the same.
123
+ *
124
+ * There is deliberately NO third fallback to `JUDGE_MODEL`. A caller that cannot
125
+ * say which model will grade has not wired a judge, and the grading path stops
126
+ * as a wiring gap rather than grading under an identity nobody chose — the same
127
+ * answer an absent `judgeLoop` already gets. A silent default here would be the
128
+ * exact silent-ignore class that `endpointKey` and F149 exist to prevent, except
129
+ * pointed at the arbiter.
130
+ *
131
+ * SELF-GRADING is licensed by measurement, not by this function: the judge
132
+ * renders no verdict (it locates facts; `decide()` is deterministic), and the
133
+ * calibration gate — 10 cases, a 10-of-10 floor, no partial credit, cases that
134
+ * MUST fail — is exactly the instrument that catches a judge which cannot see
135
+ * failures. If a worker-family judge cannot pass its own calibration, that is
136
+ * the finding, and the gate refuses to sign.
137
+ *
138
+ * @param {{specJudge?: any, workerProvider?: string, workerModel?: string}} o
139
+ * @returns {{provider: string, model: string}}
140
+ */
141
+ export function resolveJudge({ specJudge, workerProvider, workerModel } = {}) {
142
+ const nonEmpty = (/** @type {any} */ v) => typeof v === 'string' && v.trim() !== '';
143
+ if (specJudge !== null && typeof specJudge === 'object'
144
+ && nonEmpty(specJudge.provider) && nonEmpty(specJudge.model)) {
145
+ return { provider: specJudge.provider, model: specJudge.model };
146
+ }
147
+ if (specJudge !== undefined && specJudge !== null) {
148
+ // A malformed `judge` is never quietly ignored into the worker default: the
149
+ // spec VALIDATOR reds it (src/job.js), and reaching here with one means a
150
+ // caller skipped validation. Throwing beats grading under an identity the
151
+ // signer did not write.
152
+ throw new Error('[judged] spec.judge, when present, is {provider, model} with both non-empty — '
153
+ + `got ${JSON.stringify(specJudge)}. A malformed judge is not a request to use the worker's.`);
154
+ }
155
+ if (!nonEmpty(workerProvider) || !nonEmpty(workerModel)) {
156
+ throw new Error('[judged] resolveJudge needs the job\'s worker provider and model to default the judge to them '
157
+ + '(PRD item 32.1) — a caller that can name neither a judge nor a worker has not wired a judge, and there is '
158
+ + 'no library-pinned fall-back any more.');
159
+ }
160
+ return { provider: /** @type {string} */ (workerProvider), model: /** @type {string} */ (workerModel) };
161
+ }
162
+
163
+ /**
164
+ * `resolveJudge` needs a worker MODEL already resolved, and an authoring
165
+ * pipeline has to compute one identity at TWO different moments — once at
166
+ * compose time, to STAMP `closeDecl.calibration.judgeModel` into the spec it
167
+ * is about to sign, and again at the calibration gate that grades against
168
+ * that stamp — from a value that is not yet a validated spec (the operator's
169
+ * draft) the first time and is one (the assembled spec) the second. Two
170
+ * hand-spelled compositions of `resolveWorkerModel` + `resolveJudge` is
171
+ * exactly the kind of pair that drifts (PRD F45's "a rule with no detector is
172
+ * prose" applies to a rule spelled TWICE just as much as to one spelled
173
+ * nowhere) — this is the one spelling both calls make, and it mirrors the
174
+ * SAME composition `scripts/run-u.mjs` applies at launch time: the named
175
+ * provider's own `tiers.sonnet` defaults an absent model, and a signed
176
+ * `judge` override wins over the worker default.
177
+ *
178
+ * `source` is read for `provider`/`model`/`judge` only, so a draft and the
179
+ * spec `assembleSpec` folds it into resolve identically — `assembleSpec`
180
+ * spreads the draft into the spec untouched on those three fields — and any
181
+ * draft that goes on to validate and sign therefore stamps and grades under
182
+ * the exact same identity `scripts/run-u.mjs` will resolve at run time.
183
+ *
184
+ * `source.provider` absent, or unresolvable, or `source.judge` malformed all
185
+ * THROW here exactly as `resolveProvider`/`resolveJudge` already do on their
186
+ * own — a caller composing this from a pre-validation draft is expected to
187
+ * catch that and use a placeholder, because a draft that cannot resolve here
188
+ * is a draft `validateJob` reds a few lines later regardless, before anything
189
+ * gets signed under the placeholder.
190
+ *
191
+ * `resolveWorkerModelFn` is taken as a PARAMETER rather than imported — this
192
+ * module cannot import `./job.js` directly: `job.js` imports
193
+ * `./declaredclose.js`, which imports `resolveJudge` from THIS module, and a
194
+ * `judged.js` → `job.js` edge would close that into a cycle. `resolveProvider`
195
+ * has no such conflict (`./providers.js` imports nothing local) and is
196
+ * imported directly.
197
+ *
198
+ * @param {{provider?: string, model?: string, judge?: any}} source draft or spec
199
+ * @param {string} fallbackProviderName used only when `source.provider` is absent
200
+ * @param {(o: {specModel?: string, flagModel?: string, defaultModel: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
201
+ * @returns {{provider: string, model: string}}
202
+ */
203
+ export function resolveJobJudge(source, fallbackProviderName, resolveWorkerModelFn) {
204
+ const providerName = source?.provider ?? fallbackProviderName;
205
+ const providerEntry = resolveProvider(providerName);
206
+ const { model } = resolveWorkerModelFn({
207
+ specModel: source?.model, flagModel: undefined, defaultModel: providerEntry.tiers.sonnet,
208
+ });
209
+ return resolveJudge({ specJudge: source?.judge, workerProvider: providerName, workerModel: model });
210
+ }
211
+
94
212
  /**
95
213
  * The locate call's token ceiling. The POC's two real artifacts came back well
96
214
  * inside 2000; this doubles that for headroom on a longer artifact, and the
package/src/kinds.js CHANGED
@@ -66,7 +66,7 @@ import { tmpdir } from 'node:os';
66
66
  import { join, sep } from 'node:path';
67
67
  import { globToPrefix, isNonEmptyString } from './validate.js';
68
68
  import { CLOSE_ENV_DENY } from './ralph.js';
69
- import { runLocate, decide, validateCard, LOCATE_AXES, LOCATE_LABEL, JUDGE_MODEL } from './judged.js';
69
+ import { runLocate, decide, validateCard, LOCATE_AXES, LOCATE_LABEL } from './judged.js';
70
70
 
71
71
  /** the close's two judgment exits — a stage's verdict, in the shape the
72
72
  * hand-written closes already spoke (exit code is truth) */
@@ -173,6 +173,46 @@ export const MAX_JUDGED_PATHS = 8;
173
173
  * decision on it, and five literals is how they come to disagree. */
174
174
  export const JUDGED_FLOOR_KIND = 'judged-floor';
175
175
  export const SEED_EXEMPT_KINDS = Object.freeze([JUDGED_FLOOR_KIND, 'human-confirms']);
176
+
177
+ /**
178
+ * The judged stages of a close declaration, and whether it has any — the ONE
179
+ * spelling of "does this close ask a model to render anything?".
180
+ *
181
+ * The comment above `JUDGED_FLOOR_KIND` says five literals is how the readers
182
+ * come to disagree, and by PRD item 31.4 there were SIX open-coded
183
+ * `stages.filter(s => s.kind === JUDGED_FLOOR_KIND)` across the tree
184
+ * (`src/authorjob.js` twice, `src/cardauthor.js`, `src/declaredclose.js`,
185
+ * `scripts/run-author.mjs`, `scripts/author-readout.mjs`). 31.4 needed a
186
+ * seventh, in the runner, to decide whether a judge KEY is demanded — and a
187
+ * seventh spelling deciding a REFUSAL is the drift `scripts/run-u.mjs`'s own
188
+ * judge-provider comment warns about ("a second reading of the declaration
189
+ * that can drift from the one the runner actually executes"). So there is one
190
+ * reading now, and every caller shares it.
191
+ *
192
+ * Defensive on shape ON PURPOSE: callers pass raw specs read off disk and
193
+ * half-built drafts, not validated declarations. An absent/!object `closeDecl`
194
+ * or `stages` is "no judged stages", never a throw — the close VALIDATOR is
195
+ * what rejects a malformed declaration, and this predicate answering a
196
+ * question about one is not the place to re-litigate it.
197
+ * @param {any} closeDecl a close declaration (or anything, including undefined)
198
+ * @returns {any[]} the judged-floor stages, in declaration order
199
+ */
200
+ export function judgedStages(closeDecl) {
201
+ const stages = closeDecl && typeof closeDecl === 'object' ? closeDecl.stages : undefined;
202
+ if (!Array.isArray(stages)) return [];
203
+ return stages.filter((s) => s !== null && typeof s === 'object' && s.kind === JUDGED_FLOOR_KIND);
204
+ }
205
+
206
+ /**
207
+ * Does this close declaration judge — i.e. will a judge model be CALLED?
208
+ * The question `scripts/run-u.mjs` asks before demanding a judge key (PRD item
209
+ * 31.4): a job that never judges must run on its own provider's key alone.
210
+ * @param {any} closeDecl
211
+ * @returns {boolean}
212
+ */
213
+ export function closeJudges(closeDecl) {
214
+ return judgedStages(closeDecl).length > 0;
215
+ }
176
216
  /** the one kind a PERSON renders — named once, because the runner keys three
177
217
  * decisions on it (is there a door to answer, which stage paused, whose ruling
178
218
  * this is) and three literals is how they come to disagree */
@@ -258,6 +298,7 @@ export function mechanicalStages(stages) {
258
298
  * gapCap?: number, maxBuffer?: number, seedTrees?: SeedTrees,
259
299
  * humanRuling?: {decision: string, text?: string|null}|null,
260
300
  * judgeLoop?: (o: {system: string}) => any,
301
+ * judgeModel?: string,
261
302
  * onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
262
303
  * label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
263
304
  * callBounds?: () => {timeoutMs?: number, deadlineMs?: number},
@@ -268,10 +309,16 @@ export function mechanicalStages(stages) {
268
309
  * runner — never authored, never defaulted, and absent on every ordinary run.
269
310
  * `judgeLoop` is the PAID seam the judged stage runs its locate call through, and
270
311
  * it is operator territory for the same reason the worker's provider is: this
271
- * module owns no provider and never picks one, and the judge tier is PINNED
272
- * (`JUDGE_MODEL`) rather than chosen. Absent is not a fall-back to some other
273
- * model — the stage stops as a wiring gap, the same answer `runPlan` gives a
312
+ * module owns no provider and never picks one. Absent is not a fall-back to some
313
+ * other model — the stage stops as a wiring gap, the same answer `runPlan` gives a
274
314
  * native job with no native factory.
315
+ * `judgeModel` is the seam's other half (PRD item 32.1): WHICH model that loop
316
+ * drives, resolved by the runner (`resolveJudge`, src/judged.js — the spec's signed
317
+ * `judge` override, else the job's own worker model) rather than pinned in this
318
+ * library. It is required whenever `judgeLoop` is, and for the same reason: a
319
+ * verdict whose grader cannot be named is a floor nobody can attribute, and the
320
+ * signed calibration record has nothing to compare against. The two travel
321
+ * together or the stage stops.
275
322
  * `onJudgeCost` is the METER, and it is the only reason a judged stage can be
276
323
  * funded honestly: this executor cannot reach the run's ledger, so every locate
277
324
  * call's cost is reported OUT through it, once per call, on every route including
@@ -438,9 +485,15 @@ async function sh(cmd, args, { cwd, env: declared = {}, timeoutMs = DEFAULT_TIME
438
485
  /**
439
486
  * git, read-only, in a repository. Faults come back as `ok:false` with the
440
487
  * stderr — a caller decides whether that is a stop.
488
+ *
489
+ * Exported (PRD item 33/M2, `src/source.js`) so the hidden-git seed a plain
490
+ * folder/file/URL job runs against goes through the SAME spawn primitive as
491
+ * every other git read in this file — a second `spawn('git', …)` for the
492
+ * source front door would be a second instrument with its own timeout and
493
+ * buffer ceiling to keep in sync with this one.
441
494
  * @param {string} cwd @param {string[]} args
442
495
  */
443
- async function git(cwd, args) {
496
+ export async function git(cwd, args) {
444
497
  const r = await sh('git', args, { cwd, timeoutMs: 120_000 });
445
498
  // git refusing IS "the close cannot run": the changed-set primitive is not an
446
499
  // optional enrichment, so a non-zero git carries FAILED rather than inheriting
@@ -1736,16 +1789,34 @@ async function runJudgedFloor(stage, ctx) {
1736
1789
  // model on it (`validateCloseDecl`), so the runtime question is only ever WHICH
1737
1790
  // model — and a stage descriptor handed straight to `runStage` by an adopter or a
1738
1791
  // test is not a signed spec making a claim about a tier.
1739
- if (isNonEmptyString(stage.calibrationJudgeModel) && stage.calibrationJudgeModel !== JUDGE_MODEL) {
1740
- return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" would grade with ${JUDGE_MODEL}, and the calibration `
1741
- + `set signed with this close was graded by ${stage.calibrationJudgeModel} — the judge tier moved under the `
1792
+ //
1793
+ // PRD item 32.1: WHICH model grades is no longer this module's constant — it is
1794
+ // `ctx.judgeModel`, the identity the run resolved and wired (`resolveJudge`,
1795
+ // src/judged.js: the spec's signed `judge` override, else the job's own worker
1796
+ // model). The IDENTITY is checked before the seam and before the stamp, because
1797
+ // an unwired identity makes both of the messages below unwritable: neither
1798
+ // "would grade with X" nor "the judge is X" can be said by a stage that cannot
1799
+ // name X. There is deliberately NO fall-back to `JUDGE_MODEL` here — a run that
1800
+ // cannot say which model grades has not wired a judge, and grading under an
1801
+ // identity nobody chose is exactly the silent-ignore class this repo refuses
1802
+ // everywhere else.
1803
+ if (!isNonEmptyString(ctx.judgeModel)) {
1804
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" is a judged stage and this run wired no judge IDENTITY `
1805
+ + '(ctx.judgeModel) — an adopter wiring gap, never a silent fall-back to a library pin. A verdict whose grader '
1806
+ + 'cannot be named is a floor nobody can attribute, and the calibration record has nothing to compare against',
1807
+ {}, STOP_FAULTS.FAILED);
1808
+ }
1809
+ const judgeModel = /** @type {string} */ (ctx.judgeModel);
1810
+ if (isNonEmptyString(stage.calibrationJudgeModel) && stage.calibrationJudgeModel !== judgeModel) {
1811
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" would grade with ${judgeModel}, and the calibration `
1812
+ + `set signed with this close was graded by ${stage.calibrationJudgeModel} — the judge moved under the `
1742
1813
  + 'signature. A floor is worth exactly the judge that certified it, so this stops rather than grading against a '
1743
- + 'floor nobody calibrated: re-sign the spec with the new tier and re-run the calibration gate (recalibrate)',
1744
- { signedJudgeModel: stage.calibrationJudgeModel, judgeModel: JUDGE_MODEL }, STOP_FAULTS.FAILED);
1814
+ + 'floor nobody calibrated: re-sign the spec with the new judge and re-run the calibration gate (recalibrate)',
1815
+ { signedJudgeModel: stage.calibrationJudgeModel, judgeModel }, STOP_FAULTS.FAILED);
1745
1816
  }
1746
1817
  if (typeof ctx.judgeLoop !== 'function') {
1747
1818
  return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" is a judged stage and this run wired no judge seam `
1748
- + `(ctx.judgeLoop) — an adopter wiring gap, never a silent fall-back: the judge is ${JUDGE_MODEL}, pinned, and a `
1819
+ + `(ctx.judgeLoop) — an adopter wiring gap, never a silent fall-back: the judge is ${judgeModel} and a `
1749
1820
  + 'stage that quietly graded on whatever model was lying around would be a floor nobody calibrated',
1750
1821
  {}, STOP_FAULTS.FAILED);
1751
1822
  }
@@ -1797,7 +1868,7 @@ async function runJudgedFloor(stage, ctx) {
1797
1868
  if (typeof ctx.onJudgeCost !== 'function') return;
1798
1869
  ctx.onJudgeCost({
1799
1870
  stage: stage?.name ?? null, kind: stage?.kind ?? null, path: rel, attempt,
1800
- label: LOCATE_LABEL, model: JUDGE_MODEL, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1871
+ label: LOCATE_LABEL, model: judgeModel, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1801
1872
  });
1802
1873
  },
1803
1874
  });
@@ -1848,7 +1919,7 @@ async function runJudgedFloor(stage, ctx) {
1848
1919
  exitCode: red ? EXIT_RED : EXIT_GREEN,
1849
1920
  gapLines: gap.render(),
1850
1921
  detail: {
1851
- model: JUDGE_MODEL,
1922
+ model: judgeModel,
1852
1923
  card: p.card.items.map((/** @type {any} */ it) => it.rule),
1853
1924
  paths,
1854
1925
  perPath,
package/src/planrun.js CHANGED
@@ -714,8 +714,15 @@ ${scoutBlob || '(no scout notes)'}`;
714
714
  * `false` → the drafter has no tools, so a native session would report NO cost — return a
715
715
  * metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
716
716
  * so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
717
+ * @param {string|null} [opts.judgeModel] softgreen — WHICH model that judge provider drives
718
+ * (PRD item 32.1). Resolved by the CALLER (`resolveJudge`, src/judged.js: the spec's signed
719
+ * `judge` override, else the job's own worker model), never pinned in this library. It
720
+ * travels with `judgeProvider` or the judged stage stops as a wiring gap: a verdict whose
721
+ * grader cannot be named is a floor nobody can attribute.
717
722
  * @param {any} [opts.judgeProvider] softgreen — the provider a JUDGED close stage runs its
718
- * locate call through, pinned by the operator to `JUDGE_MODEL` (src/judged.js). Separate
723
+ * locate call through, built by the CALLER from the RESOLVED judge identity (PRD item
724
+ * 32.1: `resolveJudge`, src/judged.js — the spec's signed `judge` override, else the job's
725
+ * own worker provider and model), never pinned in this library. Separate
719
726
  * from `provider`/`providerFor` on purpose: the judge tier is not a step knob and never
720
727
  * agent-selectable, and the worker's binding is not a legal stand-in for it. Absent is not
721
728
  * a fall-back — a judged stage with no seam instrument-STOPS as a wiring gap, the same
@@ -928,7 +935,7 @@ ${scoutBlob || '(no scout notes)'}`;
928
935
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
929
936
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
930
937
  */
931
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
938
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
932
939
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
933
940
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
934
941
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -1633,6 +1640,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1633
1640
  // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1634
1641
  // the same F6 pricing halt rather than being laundered into $0.
1635
1642
  judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
1643
+ // the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
1644
+ // through untouched: this module resolves no judge, exactly as it constructs no
1645
+ // provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
1646
+ // falling back to a library pin, so a null here is a stop and never a default.
1647
+ ...(judgeModel === null ? {} : { judgeModel }),
1636
1648
  // F152/PRD 30.4 — the judge's locate call carried NO time bound at all: a
1637
1649
  // live endpoint that accepts and never answers hung the process forever
1638
1650
  // (measured), because an open socket is an active handle and no backstop