bareloop 0.39.0 → 0.41.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +137 -112
  3. package/package.json +2 -2
  4. package/src/authorflow.js +333 -58
  5. package/src/authoring.js +65 -2
  6. package/src/authorjob.js +57 -9
  7. package/src/authorreadout.js +50 -2
  8. package/src/authorrun.js +30 -27
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/calibrate.js +1 -1
  14. package/src/cardauthor.js +4 -1
  15. package/src/cli.js +1 -1
  16. package/src/config.js +129 -29
  17. package/src/declaredclose.js +4 -3
  18. package/src/draftspend.js +6 -1
  19. package/src/index.js +2 -4
  20. package/src/interviewrun.js +73 -52
  21. package/src/job.js +50 -11
  22. package/src/jobblock.js +140 -0
  23. package/src/judged.js +12 -44
  24. package/src/keysfile.js +15 -7
  25. package/src/kinds.js +2 -2
  26. package/src/ledger.js +1 -1
  27. package/src/modellist.js +84 -0
  28. package/src/monthly.js +23 -43
  29. package/src/panel/authorroutes.js +34 -19
  30. package/src/panel/authorsession.js +187 -72
  31. package/src/panel/index.html +699 -215
  32. package/src/panel/jobplan.js +107 -0
  33. package/src/panel/runroutes.js +3 -0
  34. package/src/panel/server.js +102 -28
  35. package/src/panel/settingsroutes.js +232 -173
  36. package/src/planrun.js +6 -6
  37. package/src/promptregisters.js +1 -1
  38. package/src/providerrows.js +226 -141
  39. package/src/providers.js +58 -250
  40. package/src/providershapes.js +18 -0
  41. package/src/replay.js +1 -1
  42. package/src/reuse.js +1 -1
  43. package/src/root.js +1 -1
  44. package/src/run.js +3 -3
  45. package/src/selection.js +1 -1
  46. package/src/source.js +64 -1
  47. package/src/text.js +13 -0
  48. package/src/thinking.js +130 -0
  49. package/src/transport.js +1 -1
  50. package/src/userrun.js +53 -76
  51. package/types/authorflow.d.ts +258 -39
  52. package/types/authoring.d.ts +7 -1
  53. package/types/authorjob.d.ts +35 -3
  54. package/types/authorreadout.d.ts +17 -1
  55. package/types/authorscout.d.ts +5 -3
  56. package/types/config.d.ts +23 -6
  57. package/types/declaredclose.d.ts +4 -1
  58. package/types/draftspend.d.ts +8 -2
  59. package/types/index.d.ts +1 -1
  60. package/types/job.d.ts +6 -6
  61. package/types/jobblock.d.ts +102 -0
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/modellist.d.ts +50 -0
  65. package/types/monthly.d.ts +4 -3
  66. package/types/panel/authorsession.d.ts +44 -15
  67. package/types/panel/jobplan.d.ts +58 -0
  68. package/types/panel/server.d.ts +34 -4
  69. package/types/planrun.d.ts +1 -1
  70. package/types/providerrows.d.ts +192 -99
  71. package/types/providers.d.ts +35 -116
  72. package/types/providershapes.d.ts +10 -0
  73. package/types/run.d.ts +1 -1
  74. package/types/source.d.ts +26 -0
  75. package/types/thinking.d.ts +90 -0
  76. package/types/userrun.d.ts +6 -1
@@ -0,0 +1,130 @@
1
+ // rewritten-from: fwdloop src/thinking.js@f9449e8
2
+ // PANEL-BUILD.md P8 batch 5 — THE one owner of what a drafting or judging model call sends for thinking. bareloop keeps NO per-model
3
+ // thinking list and NO vendor regex: bare-agent (>=0.52) owns which request shape switches thinking for which model, and
4
+ // `thinkingSupport(kind, model, {baseUrl})` is its own pure lookup (no network): `true` (measured switchable), `false` (measured
5
+ // NOT switchable), `'unverified'` (not measured — the shape ladder is tried at runtime; if every shape fails with a client
6
+ // validation error ONE plain request decides, and the call still runs, reported `thinkingStatus: 'unsupported'`).
7
+ //
8
+ // The tick is the person's, per job ("Thinking on", the signed optional `thinking` field; absent = off). It covers DRAFTING and the
9
+ // rubric JUDGE; the WORKER is never handed a thinking option. PER-CALL options, never constructor options (the drafter's provider is
10
+ // the worker's instance, deliberately left as it was). The raw `thinking` option is never sent (bare-agent throws if both are set).
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { thinkingSupport: baseSupport, AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
15
+
16
+ /** The words under a greyed "Thinking on" box (the server's `thinking.canChange` decides; the page decides nothing). */
17
+ export const THINKING_FIXED_SAY = "can't change this for this model";
18
+
19
+ /** API shape id -> bare-agent's thinking-ladder kind. A missing shape (clipipe, a test double) has no kind. */
20
+ const KIND_OF_SHAPE = Object.freeze({ 'anthropic-api': 'anthropic', 'openai-api': 'openai', 'gemini-api': 'gemini' });
21
+
22
+ /**
23
+ * What bare-agent knows about switching this model's thinking. A missing kind, a non-function `impl`, or a throw all read
24
+ * `'unverified'` — never a guess in either direction.
25
+ * @param {string} shape an API shape id (src/providershapes.js)
26
+ * @param {string} model
27
+ * @param {{ baseUrl?: string|null, impl?: Function }} [o] `impl` is a test seam; production uses bare-agent's own
28
+ * @returns {true|false|'unverified'}
29
+ */
30
+ export function thinkingSupport(shape, model, { baseUrl, impl = baseSupport } = {}) {
31
+ const kind = /** @type {Record<string, string>} */ (KIND_OF_SHAPE)[shape];
32
+ if (kind === undefined || typeof impl !== 'function') return 'unverified';
33
+ try {
34
+ const r = impl(kind, model, baseUrl ? { baseUrl } : undefined);
35
+ return r?.switchable === true ? true : (r?.switchable === false ? false : 'unverified');
36
+ } catch { return 'unverified'; }
37
+ }
38
+
39
+ /**
40
+ * The per-call options for one support answer and one tick (`true` = "Thinking on"). The table (PANEL-BUILD.md P8, ruling Q1):
41
+ * tick on : true -> on | false -> {} (the box is greyed, cannot be on) | 'unverified' -> on
42
+ * tick off: true -> off | false -> {} (the model thinks as it would) | 'unverified' -> off (bare-agent decides; plain request on failure)
43
+ * @param {true|false|'unverified'} support
44
+ * @param {boolean} tick
45
+ * @returns {{ thinkingMode: 'on'|'off' } | {}}
46
+ */
47
+ export function thinkingOptions(support, tick) {
48
+ if (support === false) return {};
49
+ return { thinkingMode: tick ? 'on' : 'off' };
50
+ }
51
+
52
+ /** The enumerated operator knob a drafting call is built with: 'on' | 'off' (the tick, spelled as a word). */
53
+ export const THINKING_MODES = Object.freeze(['on', 'off']);
54
+
55
+ /**
56
+ * Throw unless `mode` is one of {@link THINKING_MODES} — an operator-param guard, so a typo never silently means a mode.
57
+ * @param {unknown} mode
58
+ * @returns {'on'|'off'}
59
+ */
60
+ export function assertThinkingMode(mode) {
61
+ if (mode === 'on' || mode === 'off') return mode;
62
+ throw new Error(`[thinking] draftThinking must be one of ${THINKING_MODES.join(' | ')}, got ${JSON.stringify(mode)}`);
63
+ }
64
+
65
+ /**
66
+ * The API shape of a constructed bare-agent provider (the three client classes only); `null` for a test double or a clipipe.
67
+ * @param {any} provider
68
+ * @returns {string|null}
69
+ */
70
+ export function shapeOfProvider(provider) {
71
+ if (provider instanceof AnthropicProvider) return 'anthropic-api';
72
+ if (provider instanceof OpenAIProvider) return 'openai-api';
73
+ if (provider instanceof GeminiProvider) return 'gemini-api';
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * The per-call options every DRAFTING and JUDGING model call on this provider instance carries for this mode. A provider that is
79
+ * not one of the three client classes (a test double, a clipipe) is never sent the switch.
80
+ * @param {any} provider a constructed bare-agent provider
81
+ * @param {unknown} [mode] 'on' | 'off' (default 'off')
82
+ * @returns {{ thinkingMode: 'on'|'off' } | {}}
83
+ */
84
+ export function thinkingCallOptions(provider, mode = 'off') {
85
+ const m = assertThinkingMode(mode);
86
+ const shape = shapeOfProvider(provider);
87
+ if (shape === null) return {};
88
+ return thinkingOptions(thinkingSupport(shape, String(provider.model), { baseUrl: provider.baseUrl }), m === 'on');
89
+ }
90
+
91
+ /**
92
+ * What the drafting calls will carry for a model the caller has only NAMED (shape, model id, Base URL) — no key, no network. The
93
+ * first drafting row is shown from this, before the key is read.
94
+ * @param {{providerName: string, model: string, baseUrl?: string|null}} o
95
+ * @param {unknown} [mode] 'on' | 'off'
96
+ * @returns {{ support: true|false|'unverified', mode: 'on'|'off', options: {thinkingMode?: 'on'|'off'} }}
97
+ */
98
+ export function thinkingPlanFor({ providerName, model, baseUrl }, mode = 'off') {
99
+ const m = assertThinkingMode(mode);
100
+ const support = thinkingSupport(providerName, model, { baseUrl });
101
+ return { support, mode: m, options: thinkingOptions(support, m === 'on') };
102
+ }
103
+
104
+ /**
105
+ * The server's answer for one Model-menu line: whether the "Thinking on" box can change and the words under it. `false` greys and
106
+ * unticks the box (the note reads {@link THINKING_FIXED_SAY}); `'unverified'` leaves it enabled with no note.
107
+ * @param {string} shape @param {string} model @param {string|null|undefined} baseUrl
108
+ * @returns {{ support: true|false|'unverified', canChange: boolean, say: string }}
109
+ */
110
+ export function thinkingForLine(shape, model, baseUrl) {
111
+ const support = thinkingSupport(shape, model, { baseUrl });
112
+ return { support, canChange: support !== false, say: support === false ? THINKING_FIXED_SAY : '' };
113
+ }
114
+
115
+ /**
116
+ * The first drafting row's words — code-owned, ALWAYS starting "thinking on" or "thinking off". `status` is bare-agent's own
117
+ * `thinkingStatus` for a call that has run ('applied' | 'unsupported' | 'ignored' | 'unknown'); undefined = before any call, read from
118
+ * the plan (`support`, `mode`).
119
+ * @param {{ mode: 'on'|'off', support?: true|false|'unverified', status?: string }} s
120
+ * @returns {string}
121
+ */
122
+ export function thinkingRowText({ mode, support, status }) {
123
+ if (status === 'applied') return `thinking ${mode}`;
124
+ if (status === 'unsupported') return "thinking on (this model can't change it)";
125
+ if (status === 'ignored') return 'thinking on (the model thought anyway)';
126
+ if (status === 'unknown') return `thinking ${mode} (not confirmed)`;
127
+ if (support === false) return "thinking on (this model can't change it)";
128
+ if (support === 'unverified') return `thinking ${mode} (not verified for this model)`;
129
+ return `thinking ${mode}`;
130
+ }
package/src/transport.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // hamr's ruling, verbatim: "one retry for transport layer failure and
2
- // reporting with the rest end of gate." (F115, docs/logs/FINDINGS.md.)
2
+ // reporting with the rest end of gate." (F115, docs/wiki/FINDINGS.md.)
3
3
  //
4
4
  // A transport failure is fetch THROWING — the request never produced an HTTP
5
5
  // response at all (a TLS fault mid-record, a reset socket, a DNS/network
package/src/userrun.js CHANGED
@@ -25,7 +25,6 @@
25
25
  // authored is preserved from the spine as a reusable artifact, and the next run of
26
26
  // this shape reuses and fine-tunes it rather than starting cold.
27
27
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, readdirSync, openSync, readSync, closeSync, fstatSync, unlinkSync } from 'node:fs';
28
- import { createRequire } from 'node:module';
29
28
  import { fileURLToPath } from 'node:url';
30
29
  import { execFileSync, spawn } from 'node:child_process';
31
30
  import { join, resolve, dirname, basename } from 'node:path';
@@ -83,14 +82,11 @@ import { parseJsonl } from './replayio.js';
83
82
  import { claimRun, monthlyRefusalText, legSpend, chainSpend } from './monthly.js';
84
83
  import { ConfigError } from './config.js';
85
84
  import { commandFor } from './invoke.js';
86
- import { applyConfiguredKey, judgeRatesFor, keyNameFor, priceReadout, ratesFor, rowsForHome } from './providerrows.js';
85
+ import { judgeRatesFor, keyNameFor, keyValueOf, priceReadout, ratesFor, ratesOfLine, resolveWorker, judgeRowFor, rowsForHome, REFUSE_NAME_MODEL } from './providerrows.js';
87
86
  // the banner's wall arithmetic, extracted so it is reachable by a test (F83): the
88
87
  // end-of-run readout sits past the approval gate, so nothing could ever drive it here
89
88
  import { wallLine, doomedResume, deathAtOf, evidencePackage, doorLines, resumeAtLines, reviewDoorPackage, runDoorLines, tokensLine, doorTimingRedLines } from './u-readout.js';
90
89
 
91
- const require = createRequire(import.meta.url);
92
- const { AnthropicProvider } = require('bare-agent/providers');
93
-
94
90
  const JOBS = {
95
91
  'aurora-spawner': {
96
92
  spec: 'aurora-u-spawner-types.json',
@@ -111,7 +107,7 @@ const JOBS = {
111
107
  seed: '92d71a7c1253f8f2430e2d308ecfef01c826b5c2',
112
108
  },
113
109
  // Bench row G3 (the NEGATIVE row) — the planted contract contradiction
114
- // (docs/product/G3-SCOPING.md). A copy of the pulselog-u patient (patients are
110
+ // (docs/logs/G3-SCOPING.md). A copy of the pulselog-u patient (patients are
115
111
  // always copies) with one extra commit: two test/** files that pin
116
112
  // assembleEmail's subject to two different strings, unsatisfiable together
117
113
  // inside writeScope: src/**. Not frozen, not signed, no run — BUILT ON BRANCH
@@ -153,7 +149,7 @@ const JOBS = {
153
149
  // Bench row G2 (the NEGATIVE row) — TESTGEN cold start in plan-v1. The legacy
154
150
  // steps[] config went 0-of-43 (F38); this config's rate is UNKNOWN until its
155
151
  // establishing run. Same patient/seed/grader as the l2accept battery
156
- // (TESTGEN-PREREG); the spec is the cold goal. See docs/product/G2-SCOPING.md.
152
+ // (TESTGEN-PREREG); the spec is the cold goal. See docs/logs/G2-SCOPING.md.
157
153
  'aurora-testgen-cold': {
158
154
  spec: 'aurora-testgen-cold.json',
159
155
  workdir: '/home/hamr/PycharmProjects/bareloop-patients/aurora-soar',
@@ -174,10 +170,10 @@ const JOBS = {
174
170
  },
175
171
  // PRD item 28 / 30.9 — the openai-api probe through THIS runner (the shipped
176
172
  // one), not poc-run-param.mjs. Same job shape, same seed, its own patient copy.
177
- 'bareguard-types-deepseek': {
178
- spec: 'bareguard-u-types-deepseek.json',
179
- workdir: '/home/hamr/PycharmProjects/bareloop-patients/bareguard-u-deepseek',
180
- spine: 'bareguard-u-deepseek-bareloop',
173
+ 'bareguard-types-gateway': {
174
+ spec: 'bareguard-u-types-gateway.json',
175
+ workdir: '/home/hamr/PycharmProjects/bareloop-patients/bareguard-u-gateway',
176
+ spine: 'bareguard-u-gateway-bareloop',
181
177
  seed: '2ae8fcd37041c186524a6eb5e953b9752cd602fa',
182
178
  },
183
179
  // N4's hitl PROVING job (build plan §Proving; hamr's ruling, 2026-08-13). The job
@@ -273,7 +269,8 @@ class ExitSignal extends Error {
273
269
  * @property {string} [into] the source door's own directory (`source.json`, and the spine directory beside it).
274
270
  * Default `dirname(workdir)` — the copied-tree layout, where the tree is `<into>/tree`. A repo job on a WORKTREE
275
271
  * (P6 item 1) has its tree inside the person's repo, so its `into` is passed explicitly.
276
- * @property {string|null} [model] the raw `--model` tier name (`sonnet`/`haiku`), or null for the default
272
+ * @property {string|null} [model] the raw `--model <id>` (a model id of a ticked Settings line), or null
273
+ * @property {string|null} [providerKey] the raw `--provider <KEY>` (a key's env name), or null
277
274
  * @property {string|null} [readShim] the raw `--read-shim` arm name, or null for the default
278
275
  * @property {string|null} [scout] the raw `--scout` value (`on`/`off`), or null for the default
279
276
  * @property {string|null} [draftSpentUsd] the raw `--draft-spent-usd` value (hamr's
@@ -306,6 +303,7 @@ class ExitSignal extends Error {
306
303
  * @property {string} spineName
307
304
  * @property {string} [into] see `RunOpts.into`
308
305
  * @property {string|null} model
306
+ * @property {string|null} providerKey
309
307
  * @property {string|null} readShim
310
308
  * @property {string|null} scout
311
309
  * @property {string|null} draftSpentUsd
@@ -389,25 +387,13 @@ async function execute(ctx) {
389
387
  /** @type {{workdir: string, seed: string, spine: string}} */
390
388
  const target = { workdir: ctx.workdir, seed: ctx.seed, spine: ctx.spineName };
391
389
 
392
- // --model picks the DEFAULT worker tier (runner territory — the spec names no model, so
393
- // the signed hash is unaffected). Tier names, not model ids. This map is the OPERATOR's
394
- // and is deliberately WIDER than the planner's menu: since 2026-08-06 `STEP_MODELS` is
395
- // sonnet-only (the haiku attribution probe — see src/plan.js), so a PLAN can no longer
396
- // say haiku while `--model haiku` still can. haiku takes no output_config.effort
397
- // (provider-gated, battery rule) — nothing to gate yet since neither tier sets effort.
398
- // PRD v1.36 floor: the drafter/default tier is sonnet MINIMUM — measured, a haiku
399
- // drafter died plan-red twice on the same rejection (ms7gne7s). `--model haiku` runs
400
- // BELOW the floor: an explicit operator probe whose rows are probes, never battery
401
- // evidence.
402
- const DEFAULT_TIER_MODELS = { sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };
403
- // `modelFlagArg` is null when --model was never passed at all — distinct from a
404
- // flag that happens to resolve to the default tier's own id, because the SPEC
405
- // (below, once loaded) needs to know whether a flag was actually given, not just
406
- // what it resolved to (build-list #3: spec wins, a mismatched flag is refused).
390
+ // --model <id> names the Settings line a run works on when the SPEC names no model (an older spec, signed before a job carried its
391
+ // own); a spec's own `model` still wins and a different --model is refused. --provider <KEY> narrows to one key when two keys tick the
392
+ // same id. Runner territory: the spec names neither, so the signed hash is unaffected. There are no tier aliases and no built-in model.
393
+ // `modelFlagArg` is null when --model was never passed — distinct from a flag that happens to equal the spec's model, because the SPEC
394
+ // (below, once loaded) needs to know whether a flag was actually given (build-list #3: spec wins, a mismatched flag is refused).
407
395
  const modelFlagArg = ctx.model ?? null;
408
- const tierArg = modelFlagArg ?? 'sonnet';
409
- const FLAG_TIER_MODEL = DEFAULT_TIER_MODELS[/** @type {keyof typeof DEFAULT_TIER_MODELS} */ (tierArg)];
410
- if (!FLAG_TIER_MODEL) { err(`unknown --model "${tierArg}" — one of: ${Object.keys(DEFAULT_TIER_MODELS).join(', ')}`); throw new ExitSignal(2); }
396
+ const providerFlagArg = ctx.providerKey ?? null;
411
397
  // --read-shim picks the READ SHIM's ARM (src/readshim.js, Phase 2 A0/A1/A2/A3). Runner
412
398
  // territory exactly like --model: the spec names no shim, so the signed hash is
413
399
  // unaffected. Phase 2 itself never flipped this default — its own "what this
@@ -445,7 +431,7 @@ async function execute(ctx) {
445
431
  const SHIM_TAIL = shimArg === null ? '' : ` --read-shim ${shimArg}`;
446
432
 
447
433
  // --scout picks whether the plan flow pays for the read-only survey at all
448
- // (docs/product/SCOUT-CONTRAST.md). Runner territory exactly like --read-shim
434
+ // (docs/logs/SCOUT-CONTRAST.md). Runner territory exactly like --read-shim
449
435
  // and --model: the spec names no scout, so the signed hash is unaffected. An
450
436
  // unrecognised value throws at argv, before the approval gate — the same
451
437
  // param-guard class as --read-shim above, for the same reason: a silently
@@ -545,11 +531,12 @@ async function execute(ctx) {
545
531
  try {
546
532
  modelResolution = resolveWorkerModel({
547
533
  specModel: spec.model,
548
- flagModel: modelFlagArg === null ? undefined : FLAG_TIER_MODEL,
549
- defaultModel: /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).tiers.sonnet,
534
+ flagModel: modelFlagArg === null ? undefined : modelFlagArg,
535
+ // a scripted provider handed in by a test IS the run: it names its own model; a real run has no default
536
+ suppliedModel: deps.provider ? String(/** @type {any} */ (deps.provider).model ?? 'scripted') : undefined,
550
537
  });
551
538
  } catch (e) {
552
- die(/** @type {Error} */ (e).message);
539
+ die(spec.model === undefined && modelFlagArg === null ? REFUSE_NAME_MODEL : /** @type {Error} */ (e).message);
553
540
  }
554
541
  const MODEL = modelResolution.model;
555
542
  const MODEL_LABEL = modelResolution.source === 'spec' ? `${MODEL} (spec)` : MODEL;
@@ -1022,16 +1009,20 @@ async function execute(ctx) {
1022
1009
  // spine, then handed down as data (`runJob`'s `rates`) — nothing deep in the run re-reads config. A bad
1023
1010
  // price refuses here; it never falls back to the built-in guess. No price set = `null` = no `rates` key
1024
1011
  // passed anywhere, byte-identical to before. The judge's own row is resolved below, beside its key.
1025
- const keyCfg = cfgOn ? rowsForHome(cfgHome) : [];
1012
+ const keyCfg = cfgOn ? rowsForHome(cfgHome, env) : [];
1026
1013
  /** @type {ReturnType<typeof ratesFor>} */
1027
1014
  let workerPrice = null;
1028
- try {
1029
- workerPrice = ratesFor(spec.provider, spec.baseUrl, keyCfg, spec.model);
1030
- } catch (e) {
1031
- if (!(e instanceof ConfigError)) throw e;
1032
- err(`${e.message} — refusing to start rather than guess a price. Nothing spent.`);
1033
- throw new ExitSignal(2);
1034
- }
1015
+ // WHICH SETTINGS LINE THIS RUN WORKS ON (P8): the row is the key, the Base URL and the old-max_tokens tick; the line is the model
1016
+ // and its price. A scripted provider handed in by a test IS the run (no row). A refusal here is $0, before anything else.
1017
+ const W = deps.provider ? null : resolveWorker(keyCfg, spec, { model: modelFlagArg, provider: providerFlagArg });
1018
+ const refuseWorker = () => { if (W && !W.ok) { err(W.say.includes('Nothing was spent') ? W.say : `${W.say} Nothing was spent.`); throw new ExitSignal(2); } };
1019
+ // the preview cannot name its key without a line: refuse it here. A SIGNED run meets its other gates first (pause door, wall) and
1020
+ // is refused at the key check below — still before anything is spent
1021
+ if ((ctx.approve ?? null) === null) refuseWorker();
1022
+ /** the endpoint the worker talks to: the chosen row's own Base URL (a spec signed before addresses were required carries none) */
1023
+ const baseUrl = W && W.ok ? W.baseUrl : (typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined);
1024
+ const wLine = W && W.ok ? ratesOfLine(W.line) : null;
1025
+ workerPrice = W ? (wLine && W.ok ? { ...wLine, envName: W.row.envName } : null) : ratesFor(spec.provider, spec.baseUrl, keyCfg, spec.model);
1035
1026
 
1036
1027
  if ((ctx.approve ?? null) !== specHash) {
1037
1028
  out(dead ? 'U — RESUME, continuing a halted run, REAL dollars' : 'U — user-mode e2e, ONE run, REAL dollars');
@@ -1192,7 +1183,7 @@ async function execute(ctx) {
1192
1183
  // right key only by accident of the runner's own generic error message,
1193
1184
  // never from this hint. `keyNameFor` is the same resolved name
1194
1185
  // the real key check at launch reads (the person's row variable, else the provider's built-in one).
1195
- const invoke = (/** @type {string} */ tail) => ` ${keyNameFor(spec.provider, spec.baseUrl, keyCfg, spec.model).name}=... ${INVOKE}${dead ? ` --resume ${RESUME}` : ''}${SHIM_TAIL}${SCOUT_TAIL}${DRAFT_TAIL}${REGISTRY_TAIL}${tail} --approve ${PRINT_APPROVE}`;
1186
+ const invoke = (/** @type {string} */ tail) => ` ${(W && W.ok ? W.row.envName : null) ?? keyNameFor(spec.provider, spec.baseUrl, keyCfg, spec.model).name ?? '<KEY>'}=... ${INVOKE}${dead ? ` --resume ${RESUME}` : ''}${SHIM_TAIL}${SCOUT_TAIL}${DRAFT_TAIL}${REGISTRY_TAIL}${tail} --approve ${PRINT_APPROVE}`;
1196
1187
  /** the door the operator has already picked, as flags — hoisted out of the else
1197
1188
  * below so the inhibitor line at the bottom can print the WHOLE command rather
1198
1189
  * than a shape the operator has to assemble. Empty on an ordinary run and on the
@@ -1446,13 +1437,15 @@ async function execute(ctx) {
1446
1437
  let judgePrice = null;
1447
1438
  if (JUDGES) {
1448
1439
  try {
1449
- judgePrice = judgeRatesFor(judge, spec.provider, spec.baseUrl, workerPrice, keyCfg);
1440
+ judgePrice = judgeRatesFor(judge, spec.provider, baseUrl, workerPrice, keyCfg);
1450
1441
  } catch (e) {
1451
1442
  if (!(e instanceof ConfigError)) throw e;
1452
1443
  err(`${e.message} — refusing to start rather than guess a price. Nothing spent.`);
1453
1444
  throw new ExitSignal(2);
1454
1445
  }
1455
1446
  }
1447
+ /** the row the judge's key (and endpoint) comes from: the worker's own row when the judge shares its provider, else the judge provider's row */
1448
+ const judgeRow = judgeRowFor(keyCfg, judge, spec.provider, W && W.ok ? W.row : null);
1456
1449
  // undefined, not null: both come straight off `env[...]` lookups below, which
1457
1450
  // (like process.env) never produce null for a missing key.
1458
1451
  /** @type {string|undefined} */
@@ -1468,13 +1461,10 @@ async function execute(ctx) {
1468
1461
  // row = the built-in. Names only — values come from the
1469
1462
  // env / keys file. The judge follows the worker's choice when it is the same provider
1470
1463
  // (the same rule that gives it the worker's baseUrl below).
1471
- const workerEnv = applyConfiguredKey(env, spec.provider, spec.baseUrl, keyCfg, spec.model);
1472
- const judgeEnv = judge.provider === spec.provider ? workerEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model);
1473
- /** the variable NAME this worker's key is read from (the person's pick, else built-in) — for messages only */
1474
- const workerKeyName = keyNameFor(spec.provider, spec.baseUrl, keyCfg, spec.model).name;
1475
- // A green anthropic-api job's behaviour is byte-identical to before: its
1476
- // worker key IS `ANTHROPIC_API_KEY`, demanded as ever.
1477
- workerApiKey = workerEnv[providerEntry.envKey];
1464
+ refuseWorker();
1465
+ const workerKeyName = /** @type {NonNullable<typeof W> & {ok: true}} */ (W).row.envName;
1466
+ // A job's worker key is its Settings row's own variable — there is no built-in name.
1467
+ workerApiKey = keyValueOf(env, workerKeyName);
1478
1468
  if (!workerApiKey) { err(`${workerKeyName} not set (secrets load from the environment — never the tree)`); throw new ExitSignal(2); }
1479
1469
  // F181 — a key that reads as "set" above can still carry a line break/
1480
1470
  // control char/stray whitespace (a two-line secret-store entry, e.g.) and
@@ -1487,10 +1477,10 @@ async function execute(ctx) {
1487
1477
  // override matters most in the case this item exists for: a job whose worker and
1488
1478
  // judge are the SAME provider but different accounts, and the anthropic case
1489
1479
  // where one variable used to do two jobs.
1490
- judgeApiKey = env.JUDGE_API_KEY ?? judgeEnv[judgeEntry.envKey];
1480
+ judgeApiKey = env.JUDGE_API_KEY ?? keyValueOf(env, judgeRow?.envName);
1491
1481
  if (JUDGES && !judgeApiKey) {
1492
1482
  err(`This job's close JUDGES (${judgedStages(spec.closeDecl).length} judged stage(s)) and its judge is ${judge.model} on ${judge.provider}${spec.judge ? ' (signed override)' : " (this job's own worker model — no `judge` override signed)"}.`);
1493
- err(`Neither JUDGE_API_KEY nor ${judgeEntry.envKey} is set — secrets load from the environment, never the tree.`);
1483
+ err(`Neither JUDGE_API_KEY nor ${judgeRow?.envName ?? `a key row for ${judge.provider}`} is set — secrets load from the environment, never the tree.`);
1494
1484
  err('Refused at $0, BEFORE the worker spends anything: a judged run that discovers this at the close has paid for a verdict it cannot render.');
1495
1485
  throw new ExitSignal(2);
1496
1486
  }
@@ -1500,7 +1490,7 @@ async function execute(ctx) {
1500
1490
  if (JUDGES) {
1501
1491
  const judgeKeyProblem = apiKeyProblem(/** @type {string} */ (judgeApiKey));
1502
1492
  if (judgeKeyProblem) {
1503
- const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : judgeEntry.envKey;
1493
+ const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : (judgeRow?.envName ?? judge.provider);
1504
1494
  err(`${judgeEnvName} ${judgeKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
1505
1495
  throw new ExitSignal(2);
1506
1496
  }
@@ -1640,7 +1630,7 @@ async function execute(ctx) {
1640
1630
  // OpenAI-compatible gateway/endpoint. Undefined for anthropic-api jobs and
1641
1631
  // for an openai-api job that names none (bare-agent's OpenAIProvider then
1642
1632
  // defaults to api.openai.com/v1 on its own).
1643
- const baseUrl = typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined;
1633
+ // (`baseUrl` — the worker row's Base URL — was resolved above with the Settings line)
1644
1634
  // TEST SEAM — same shape as the bundle door (src/bundlerun.js): a caller that supplies
1645
1635
  // `deps.provider` skips the real-key check above entirely and this
1646
1636
  // construction never runs (a scripted provider IS the run).
@@ -1648,26 +1638,11 @@ async function execute(ctx) {
1648
1638
  let providerFor = deps.providerFor;
1649
1639
  let judgeProvider = deps.judgeProvider ?? null;
1650
1640
  if (!provider) {
1651
- provider = makeProvider(spec.provider, { apiKey: /** @type {string} */ (workerApiKey), model: MODEL, baseUrl });
1652
- // P: the per-step model-tier factory. The TIER menu a PLAN may name is signed in the
1653
- // plan schema (STEP_MODELS — sonnet-only since the 2026-08-06 haiku attribution probe);
1654
- // the tier->model mapping is the RUNNER's territory, here, and keeps haiku for the
1655
- // operator's own --model knob. haiku takes no output_config.effort (provider-gated,
1656
- // battery rule) - nothing to gate yet since neither tier sets effort params.
1657
- // `sonnet` maps to the RESOLVED worker model (MODEL — spec/flag/default), not the
1658
- // bare provider-table id, so a spec-named model reaches every plan step's own
1659
- // provider (STEP_MODELS is sonnet-only), not just the top-level `provider` above.
1660
- // ONLY when the SPEC named it: a `--model haiku` probe keeps its old reach (the
1661
- // top-level provider), never silently widening into every step's tier. The tier
1662
- // table itself comes from `providerEntry` (src/providers.js) — for openai-api
1663
- // today that means BOTH tiers resolve to the same `deepseek-flash` id (hamr's
1664
- // ruling, PRD 30.7: one secondary provider, not a menu; swapped from the
1665
- // retired `deepseek-chat`, F171), so a `--model haiku` probe against an
1666
- // openai-api job harmlessly re-resolves to the same model.
1667
- const TIER_MODELS = modelResolution.source === 'spec' ? { ...providerEntry.tiers, sonnet: MODEL } : providerEntry.tiers;
1668
- /** @type {Record<string, any>} */
1669
- const tierCache = {};
1670
- providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] === MODEL ? provider : makeProvider(spec.provider, { apiKey: /** @type {string} */ (workerApiKey), model: TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)], baseUrl }));
1641
+ const legacyMaxTokens = W && W.ok ? W.row.legacyMaxTokens : false;
1642
+ provider = makeProvider(spec.provider, { apiKey: /** @type {string} */ (workerApiKey), model: MODEL, baseUrl, legacyMaxTokens });
1643
+ // P: the per-step model factory. The ONLY tier a plan may name is the schema token `sonnet` (STEP_MODELS, src/plan.js — a
1644
+ // documented schema token, not a model id); it means THIS job's worker model, so every step's provider is the one above.
1645
+ providerFor = () => provider;
1671
1646
  // SOFTGREEN — the JUDGED stage's own provider. Since PRD item 32.1 its identity is
1672
1647
  // RESOLVED (`judge` above), not pinned: the spec's signed `judge` override, else this
1673
1648
  // job's own worker model. It is still never a step knob and never agent-selectable —
@@ -1684,7 +1659,7 @@ async function execute(ctx) {
1684
1659
  // every live softgreen run would have done: run-author wired this seam and this
1685
1660
  // runner never did.
1686
1661
  judgeProvider = judgeApiKey
1687
- ? makeProvider(judge.provider, { apiKey: /** @type {string} */ (judgeApiKey), model: judge.model, baseUrl: judge.provider === spec.provider ? baseUrl : undefined })
1662
+ ? makeProvider(judge.provider, { apiKey: /** @type {string} */ (judgeApiKey), model: judge.model, baseUrl: judge.provider === spec.provider ? baseUrl : (judgeRow && judgeRow.baseUrl.trim() !== '' ? judgeRow.baseUrl.trim() : undefined), legacyMaxTokens: judgeRow ? judgeRow.legacyMaxTokens : false })
1688
1663
  : null;
1689
1664
  }
1690
1665
 
@@ -2433,6 +2408,7 @@ function buildCtx(mode, opts) {
2433
2408
  spineName: /** @type {string} */ (opts.spineName),
2434
2409
  ...(opts.into === undefined ? {} : { into: opts.into }),
2435
2410
  model: opts.model ?? null,
2411
+ providerKey: opts.providerKey ?? null,
2436
2412
  readShim: opts.readShim ?? null,
2437
2413
  scout: opts.scout ?? null,
2438
2414
  draftSpentUsd: opts.draftSpentUsd ?? null,
@@ -2661,6 +2637,7 @@ export async function main(argv, deps = {}) {
2661
2637
  spineName: target.spine,
2662
2638
  ...(target.into === undefined ? {} : { into: target.into }),
2663
2639
  model: argFlag('model'),
2640
+ providerKey: argFlag('provider'),
2664
2641
  readShim: argFlag('read-shim'),
2665
2642
  scout: argFlag('scout'),
2666
2643
  draftSpentUsd: argFlag('draft-spent-usd'),