bareloop 0.23.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/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
@@ -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
@@ -1736,16 +1783,34 @@ async function runJudgedFloor(stage, ctx) {
1736
1783
  // model on it (`validateCloseDecl`), so the runtime question is only ever WHICH
1737
1784
  // model — and a stage descriptor handed straight to `runStage` by an adopter or a
1738
1785
  // 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 `
1786
+ //
1787
+ // PRD item 32.1: WHICH model grades is no longer this module's constant — it is
1788
+ // `ctx.judgeModel`, the identity the run resolved and wired (`resolveJudge`,
1789
+ // src/judged.js: the spec's signed `judge` override, else the job's own worker
1790
+ // model). The IDENTITY is checked before the seam and before the stamp, because
1791
+ // an unwired identity makes both of the messages below unwritable: neither
1792
+ // "would grade with X" nor "the judge is X" can be said by a stage that cannot
1793
+ // name X. There is deliberately NO fall-back to `JUDGE_MODEL` here — a run that
1794
+ // cannot say which model grades has not wired a judge, and grading under an
1795
+ // identity nobody chose is exactly the silent-ignore class this repo refuses
1796
+ // everywhere else.
1797
+ if (!isNonEmptyString(ctx.judgeModel)) {
1798
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" is a judged stage and this run wired no judge IDENTITY `
1799
+ + '(ctx.judgeModel) — an adopter wiring gap, never a silent fall-back to a library pin. A verdict whose grader '
1800
+ + 'cannot be named is a floor nobody can attribute, and the calibration record has nothing to compare against',
1801
+ {}, STOP_FAULTS.FAILED);
1802
+ }
1803
+ const judgeModel = /** @type {string} */ (ctx.judgeModel);
1804
+ if (isNonEmptyString(stage.calibrationJudgeModel) && stage.calibrationJudgeModel !== judgeModel) {
1805
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" would grade with ${judgeModel}, and the calibration `
1806
+ + `set signed with this close was graded by ${stage.calibrationJudgeModel} — the judge moved under the `
1742
1807
  + '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);
1808
+ + 'floor nobody calibrated: re-sign the spec with the new judge and re-run the calibration gate (recalibrate)',
1809
+ { signedJudgeModel: stage.calibrationJudgeModel, judgeModel }, STOP_FAULTS.FAILED);
1745
1810
  }
1746
1811
  if (typeof ctx.judgeLoop !== 'function') {
1747
1812
  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 `
1813
+ + `(ctx.judgeLoop) — an adopter wiring gap, never a silent fall-back: the judge is ${judgeModel} and a `
1749
1814
  + 'stage that quietly graded on whatever model was lying around would be a floor nobody calibrated',
1750
1815
  {}, STOP_FAULTS.FAILED);
1751
1816
  }
@@ -1797,7 +1862,7 @@ async function runJudgedFloor(stage, ctx) {
1797
1862
  if (typeof ctx.onJudgeCost !== 'function') return;
1798
1863
  ctx.onJudgeCost({
1799
1864
  stage: stage?.name ?? null, kind: stage?.kind ?? null, path: rel, attempt,
1800
- label: LOCATE_LABEL, model: JUDGE_MODEL, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1865
+ label: LOCATE_LABEL, model: judgeModel, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1801
1866
  });
1802
1867
  },
1803
1868
  });
@@ -1848,7 +1913,7 @@ async function runJudgedFloor(stage, ctx) {
1848
1913
  exitCode: red ? EXIT_RED : EXIT_GREEN,
1849
1914
  gapLines: gap.render(),
1850
1915
  detail: {
1851
- model: JUDGE_MODEL,
1916
+ model: judgeModel,
1852
1917
  card: p.card.items.map((/** @type {any} */ it) => it.rule),
1853
1918
  paths,
1854
1919
  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