bareloop 0.40.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 (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bareloop.context.md +97 -124
  3. package/package.json +2 -2
  4. package/src/authorflow.js +9 -9
  5. package/src/authorjob.js +4 -4
  6. package/src/authorrun.js +25 -25
  7. package/src/authorscout.js +4 -4
  8. package/src/bundle.js +7 -4
  9. package/src/bundlerun.js +4 -1
  10. package/src/cli.js +1 -1
  11. package/src/config.js +129 -29
  12. package/src/draftspend.js +4 -4
  13. package/src/index.js +2 -4
  14. package/src/interviewrun.js +17 -25
  15. package/src/job.js +13 -7
  16. package/src/judged.js +11 -43
  17. package/src/keysfile.js +15 -7
  18. package/src/kinds.js +2 -2
  19. package/src/modellist.js +84 -0
  20. package/src/monthly.js +23 -43
  21. package/src/panel/authorroutes.js +19 -14
  22. package/src/panel/authorsession.js +62 -58
  23. package/src/panel/index.html +520 -145
  24. package/src/panel/runroutes.js +3 -0
  25. package/src/panel/server.js +35 -15
  26. package/src/panel/settingsroutes.js +232 -173
  27. package/src/planrun.js +2 -2
  28. package/src/providerrows.js +226 -141
  29. package/src/providers.js +58 -332
  30. package/src/providershapes.js +18 -0
  31. package/src/replay.js +1 -1
  32. package/src/thinking.js +130 -0
  33. package/src/userrun.js +50 -73
  34. package/types/authorflow.d.ts +5 -5
  35. package/types/authorjob.d.ts +2 -2
  36. package/types/authorscout.d.ts +2 -2
  37. package/types/config.d.ts +23 -6
  38. package/types/draftspend.d.ts +8 -8
  39. package/types/index.d.ts +1 -1
  40. package/types/job.d.ts +5 -5
  41. package/types/judged.d.ts +5 -36
  42. package/types/keysfile.d.ts +15 -4
  43. package/types/modellist.d.ts +50 -0
  44. package/types/monthly.d.ts +4 -3
  45. package/types/panel/authorsession.d.ts +2 -9
  46. package/types/panel/server.d.ts +14 -4
  47. package/types/providerrows.d.ts +192 -99
  48. package/types/providers.d.ts +34 -159
  49. package/types/providershapes.d.ts +10 -0
  50. package/types/thinking.d.ts +90 -0
  51. package/types/userrun.d.ts +6 -1
package/src/authorjob.js CHANGED
@@ -113,7 +113,7 @@ import {
113
113
  QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook,
114
114
  runConfirmTurn, LANGUAGE_PICK_FIELD,
115
115
  } from './authorflow.js';
116
- import { draftCallOptions, assertDraftThinking } from './providers.js';
116
+ import { thinkingCallOptions, assertThinkingMode } from './thinking.js';
117
117
  import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
118
118
  import {
119
119
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
@@ -423,7 +423,7 @@ function composerRefusal(reds) {
423
423
  * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null, judgeModel?: string|null,
424
424
  * rates?: {in: number, out: number}|null, draftThinking?: 'on'|'off',
425
425
  * onPhase?: (phase: string, data?: any) => void,
426
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
426
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
427
427
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
428
428
  * authorFn?: Function, authorOpts?: object, writeScope?: string[]|null,
429
429
  * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object,
@@ -496,7 +496,7 @@ export async function authorCloseForJob({
496
496
  answerQuestions = null,
497
497
  }) {
498
498
  // an operator-param guard: a typo never silently means "default" (throws before any work, even when the survey is supplied)
499
- assertDraftThinking(draftThinking);
499
+ assertThinkingMode(draftThinking);
500
500
  /** @type {any} */
501
501
  const base = {
502
502
  ok: false, refusal: null, verdictType: null, closeDecl: null, seedRef: null,
@@ -597,7 +597,7 @@ export async function authorCloseForJob({
597
597
  // real tree. `attempts` is the ceiling the scout enforces on itself, so the
598
598
  // reader knows how many the line could repeat for.
599
599
  onPhase('scout', { attempts: SCOUT_ATTEMPTS });
600
- const scoutCallOptions = draftCallOptions(provider, draftThinking);
600
+ const scoutCallOptions = thinkingCallOptions(provider, draftThinking);
601
601
  survey = await scoutFn({ workdir, provider, ceilingUsd, onCall, ...(rates ? { rates } : {}), ...(Object.keys(scoutCallOptions).length ? { callOptions: scoutCallOptions } : {}) });
602
602
  // …and the survey's own VERDICT, said the moment it lands. `state` is what
603
603
  // every downstream refusal keys on (F59: absent is never "no facts needed"),
package/src/authorrun.js CHANGED
@@ -101,7 +101,7 @@
101
101
  // in; a draft missing one, or naming one the provider factory
102
102
  // does not know, dies here loud, listing the known table.
103
103
  import { keysForDoor } from './keysfile.js';
104
- import { applyConfiguredKey, judgeRatesFor, keyNameFor, ratesFor, rowsForHome } from './providerrows.js';
104
+ import { judgeRatesFor, judgeRowFor, keyNameFor, keyValueOf, ratesFor, ratesOfLine, resolveWorker, rowsForHome } from './providerrows.js';
105
105
  import { ConfigError } from './config.js';
106
106
  import {
107
107
  readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, statSync,
@@ -466,13 +466,15 @@ export async function main(argv, deps = {}) {
466
466
  try { return resolveProvider(draft?.provider); } catch (e) { return die(/** @type {Error} */ (e).message); }
467
467
  })();
468
468
  const PROVIDER_NAME = /** @type {string} */ (draft.provider);
469
- // The same tier run-u drafts and works on. The declaration is authored by a
470
- // model, so the floor is the drafter floor (PRD v1.36: sonnet MINIMUM).
471
- const MODEL = /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).tiers.sonnet;
472
- // spec.baseUrl (PRD item 28, ruling (d)) — the same forwarding rule run-u
473
- // applies: forwarded only when the draft names one, every provider
474
- // constructor defaults it on its own when absent.
475
- const baseUrl = typeof draft?.baseUrl === 'string' ? draft.baseUrl : undefined;
469
+ // The Settings LINE this authoring run works on (P8): the draft's own `model` on a ticked, priced line of a key row on the
470
+ // draft's API shape (and Base URL, when it names one). The row is the key, the Base URL and the old-max_tokens tick. Nothing is
471
+ // built in: a draft naming no model, or a model no row ticks, stops here at $0.
472
+ const keyCfg = rowsForHome(keys.home, env);
473
+ const W = resolveWorker(keyCfg, draft);
474
+ if (!W.ok) { err(W.say.includes('Nothing was spent') ? W.say : `${W.say} Nothing was spent.`); throw new ExitSignal(2); }
475
+ const MODEL = W.model;
476
+ const baseUrl = W.baseUrl;
477
+ const AUTHOR_ROW = W.row;
476
478
 
477
479
  // THE JUDGE THIS RUN'S OWN WORKER RESOLVES TO (PRD item 32.1) — resolved ONCE,
478
480
  // here, from the DRAFT, and reused for BOTH the compose-time stamp (the
@@ -508,12 +510,9 @@ export async function main(argv, deps = {}) {
508
510
 
509
511
  // Secrets load from the environment; they never enter argv (a command line is
510
512
  // world-readable on /proc) and they are never printed.
511
- // P4a item 4 — the key variable the person picked in Settings (config.json) stands in for
512
- // the provider's built-in one; none picked = the built-in. Names only.
513
- const keyCfg = rowsForHome(keys.home);
514
- const authorEnv = applyConfiguredKey(env, PROVIDER_NAME, baseUrl, keyCfg);
515
- const AUTHOR_ENV_KEY = keyNameFor(PROVIDER_NAME, baseUrl, keyCfg).name;
516
- const apiKey = authorEnv[/** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey];
513
+ // The key is the chosen row's own variable (P8: no built-in name). Names only.
514
+ const AUTHOR_ENV_KEY = AUTHOR_ROW.envName;
515
+ const apiKey = keyValueOf(env, AUTHOR_ENV_KEY);
517
516
  if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
518
517
  // F181 — a key that carries a line break/control char/stray whitespace (a
519
518
  // two-line secret-store entry, e.g.) reads as "set" by the presence check
@@ -527,17 +526,17 @@ export async function main(argv, deps = {}) {
527
526
  // loop, confirm turn, calibration judge). A bad price refuses here; it never falls back to the guess.
528
527
  /** @type {ReturnType<typeof ratesFor>} */
529
528
  let authorPrice = null;
530
- try { authorPrice = ratesFor(PROVIDER_NAME, baseUrl, keyCfg); } catch (e) {
531
- if (!(e instanceof ConfigError)) throw e;
532
- err(`${e.message} — refusing to start rather than guess a price. Nothing spent.`);
533
- throw new ExitSignal(2);
529
+ {
530
+ const al = ratesOfLine(W.line);
531
+ authorPrice = al ? { ...al, envName: AUTHOR_ROW.envName } : null;
534
532
  }
535
533
  /** The judge's key follows the RESOLVED judge provider's own env var, with
536
534
  * `JUDGE_API_KEY` as the role-named override in front (PRD item 32.3) — the same
537
535
  * contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
538
536
  * judge IS the authoring provider this is the key already read above. */
537
+ const judgeRowOf = (/** @type {{provider: string, model?: string|null}} */ j) => judgeRowFor(keyCfg, j, PROVIDER_NAME, AUTHOR_ROW);
539
538
  const judgeKeyFor = (/** @type {string} */ providerName) => (
540
- env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? authorEnv : applyConfiguredKey(env, providerName, undefined, keyCfg))[resolveProvider(providerName).envKey]
539
+ env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? apiKey : keyValueOf(env, judgeRowFor(keyCfg, { provider: providerName }, PROVIDER_NAME, AUTHOR_ROW)?.envName))
541
540
  );
542
541
 
543
542
  /** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
@@ -579,7 +578,7 @@ export async function main(argv, deps = {}) {
579
578
  // one) — the real judge identity is not resolved until after `validateJob`,
580
579
  // further down, where a second call takes only its `.judgeProvider`.
581
580
  const { provider } = buildRunnerProviders({
582
- providerName: PROVIDER_NAME, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl,
581
+ providerName: PROVIDER_NAME, apiKey, model: MODEL, baseUrl, legacyMaxTokens: AUTHOR_ROW.legacyMaxTokens,
583
582
  judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: PROVIDER_NAME, judgeBaseUrl: baseUrl,
584
583
  });
585
584
  emit('author-start', { runid, source: SOURCE, lang: LANG, verdictType: VERDICT, provider: PROVIDER_NAME, model: MODEL, baseUrl: baseUrl ?? null, job: draft?.job ?? null, timeoutMs: TIMEOUT_MS, ceilingUsd: CEILING_USD });
@@ -1115,7 +1114,7 @@ export async function main(argv, deps = {}) {
1115
1114
  const judgeKeyValue = judgeKeyFor(judge.provider);
1116
1115
  const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
1117
1116
  if (judgeKeyProblem) {
1118
- const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : resolveProvider(judge.provider).envKey;
1117
+ const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : (judgeRowOf(judge)?.envName ?? judge.provider);
1119
1118
  err(`${judgeEnvName} ${judgeKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
1120
1119
  // F186 — the scout above already ran and may have left its own gate
1121
1120
  // audit in the tree. Under `process.exit()` this path used to skip
@@ -1149,9 +1148,10 @@ export async function main(argv, deps = {}) {
1149
1148
  let judgeProvider = null;
1150
1149
  if (judge) {
1151
1150
  judgeProvider = buildRunnerProviders({
1152
- providerName: PROVIDER_NAME, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl,
1151
+ providerName: PROVIDER_NAME, apiKey, model: MODEL, baseUrl, legacyMaxTokens: AUTHOR_ROW.legacyMaxTokens,
1153
1152
  judgeApiKey: judgeKeyFor(judge.provider), judgeModel: judge.model, judgeProviderName: judge.provider,
1154
- judgeBaseUrl: judge.provider === PROVIDER_NAME ? baseUrl : undefined,
1153
+ judgeBaseUrl: judge.provider === PROVIDER_NAME ? baseUrl : (judgeRowOf(judge)?.baseUrl.trim() || undefined),
1154
+ judgeLegacyMaxTokens: judge.provider === PROVIDER_NAME ? AUTHOR_ROW.legacyMaxTokens : (judgeRowOf(judge)?.legacyMaxTokens ?? false),
1155
1155
  }).judgeProvider;
1156
1156
  }
1157
1157
  if (judge) {
@@ -1170,7 +1170,7 @@ export async function main(argv, deps = {}) {
1170
1170
  // number would silently widen it.
1171
1171
  ceilingUsd: CEILING_USD,
1172
1172
  priorCalls: [...metered],
1173
- judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
1173
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null, thinking: spec.thinking === true }) : null,
1174
1174
  // the seam's other half (PRD item 32.1): the graded set is STAMPED with
1175
1175
  // this, and the close later refuses to grade under any other identity.
1176
1176
  judgeModel: judge?.model ?? null,
@@ -1297,7 +1297,7 @@ export async function main(argv, deps = {}) {
1297
1297
  + 'and run-u will enforce the remainder (Cap $ minus this) as ITS ceiling — pass --draft-spent-usd exactly as shown below, never a rounded or re-typed figure.');
1298
1298
  }
1299
1299
  out('\nTo run it (the same signature and gates as any other job — nothing here bypasses them):');
1300
- out(` ${keyNameFor(PROVIDER_NAME, baseUrl, keyCfg, spec.model).name}=... ${commandFor('run-u', deps.invokedAs)} --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
1300
+ out(` ${AUTHOR_ENV_KEY}=... ${commandFor('run-u', deps.invokedAs)} --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
1301
1301
  emit('author-end', { outcome: 'prepared', specHash: hash });
1302
1302
  }
1303
1303
  }
@@ -375,7 +375,7 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider,
375
375
  * @param {{workdir: string, provider?: any,
376
376
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
377
377
  * attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
378
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
378
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
379
379
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
380
380
  * rates?: {in: number, out: number}|null, callOptions?: Record<string, any>,
381
381
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
@@ -400,7 +400,7 @@ export async function runAuthorScout({
400
400
  onCall = () => {},
401
401
  createLoop = defaultLoop,
402
402
  rates = null,
403
- // the drafter's per-call model options (`draftCallOptions`, src/providers.js; e.g. thinking off) — {} = nothing extra, byte-identical
403
+ // the drafter's per-call model options (`thinkingCallOptions`, src/thinking.js; e.g. thinking off) — {} = nothing extra, byte-identical
404
404
  callOptions = {},
405
405
  createSurveyor = defaultSurveyor,
406
406
  }) {
@@ -436,8 +436,8 @@ export async function runAuthorScout({
436
436
  // metered without the report going out: exactly the pairing invariant the
437
437
  // raw already rides. It REPORTS and decides nothing; the ceiling still binds
438
438
  // through `capStop` between calls, untouched.
439
- // bare-agent's per-run `thinkingApplied` rides the reporting seam only, never the stored entry (see makeCostBook.add)
440
- onCall({ ...call, ...(r?.thinkingApplied === undefined ? {} : { thinkingApplied: r.thinkingApplied }) });
439
+ // bare-agent's per-run `thinkingStatus` rides the reporting seam only, never the stored entry (see makeCostBook.add)
440
+ onCall({ ...call, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
441
441
  return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
442
442
  };
443
443
  // TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage
package/src/bundle.js CHANGED
@@ -459,13 +459,16 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
459
459
  '',
460
460
  '## Running this bundle',
461
461
  '',
462
- ' ./node_modules/.bin/bareloop run . --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]',
462
+ ' ./node_modules/.bin/bareloop run . --repo <path> [--model <id>] [--budget N] [--wall MIN] [--approve <bundleHash>]',
463
463
  '',
464
464
  'Answer these, in the order `bareloop run` asks them:',
465
465
  '',
466
- '1. Provider key — set ANTHROPIC_API_KEY in your environment (never on the',
467
- ' command line, never in a file). Absent, `bareloop run` prints these',
468
- ' questions and the bundleHash again, and spends nothing.',
466
+ "1. Provider key — put your provider's API key in your keys file (the NAME your",
467
+ ' Settings row uses), never on the command line. A bundle signed without a',
468
+ ' model needs `--model <id>` of a line ticked in Settings > Providers (with',
469
+ ' both prices), plus `--provider <KEY>` when two keys tick it. With neither,',
470
+ ' `bareloop run` prints these questions and the bundleHash again, and spends',
471
+ ' nothing.',
469
472
  `2. Approve — the FIRST run on this machine requires --approve ${hash}`,
470
473
  " (this bundle's own signed hash, shown above) to prove a human read and",
471
474
  ' accepted it before anything runs. Once that first run GREENS, the',
package/src/bundlerun.js CHANGED
@@ -204,13 +204,16 @@ export async function bundleMain(args, {
204
204
  workdir: worktree,
205
205
  seed,
206
206
  approve: approveHash,
207
+ // an exported bundle follows the run-u rule: a spec that names no model takes `--model <id>` (+ `--provider <KEY>` when two keys tick it)
208
+ ...(typeof flags.model === 'string' ? { model: flags.model } : {}),
209
+ ...(typeof flags.provider === 'string' ? { providerKey: flags.provider } : {}),
207
210
  deps: { ...deps, env, out, err, ...(keysHome === undefined ? {} : { keysHome }) },
208
211
  bundle: {
209
212
  runid,
210
213
  runDir,
211
214
  // the bare re-invocation prefix; the leg's OWN tightened ceilings (only those the person passed) ride as
212
215
  // DATA so the engine, which knows the halt kind and the spend, spells the printed line. Text only.
213
- invoke: `bareloop run ${bundleDirArg} --repo ${repo}`,
216
+ invoke: `bareloop run ${bundleDirArg} --repo ${repo}${typeof flags.model === 'string' ? ` --model ${flags.model}` : ''}${typeof flags.provider === 'string' ? ` --provider ${flags.provider}` : ''}`,
214
217
  tightened: {
215
218
  ...(envelope.budgetUsd === undefined ? {} : { budgetUsd: envelope.budgetUsd }),
216
219
  ...(flags.wall === undefined ? {} : { wallMin: Number(flags.wall) }),
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // run-u` drives (`src/userrun.js`): providers, keys (the keys file / env, any provider the
8
8
  // bundle's spec names), judge, monthly limit and readout are that engine's. The test seam is
9
9
  // the engine's too: `deps.provider` (and `providerFor`/`judgeProvider`) supplies a scripted
10
- // provider and skips the real-key check (same as `tests/planrun.test.js`'s pattern).
10
+ // provider and skips the real-key check (same as `tests/planrun-*.test.js`'s pattern).
11
11
  //
12
12
  // Money/arbiter rules the bundle door keeps: `--budget`/`--wall` only TIGHTEN a bundle's
13
13
  // signed ceilings (`checkEnvelope`, M1); a bundle's `bundleHash` (the human-facing
package/src/config.js CHANGED
@@ -1,17 +1,22 @@
1
- // PANEL-BUILD.md P4a item 2 — the ONE reader/writer of `~/.config/bareloop/config.json`.
1
+ // rewritten-from: fwdloop src/config.js@f9449e8 (validators `modelsProblem`, `providerFieldProblem`, `cleanBaseUrl`)
2
+ // PANEL-BUILD.md P4a item 2 / P8 — the ONE reader/writer of `~/.config/bareloop/config.json`.
2
3
  //
3
- // Every panel/CLI setting lives in this one file (R5): the monthly $ limit, the
4
- // Anthropic balance note, and the Providers rows' Name / API shape / Base URL, per key variable.
4
+ // Every panel/CLI setting lives in this one file (R5): the monthly $ limit and the Providers
5
+ // rows' API shape / Base URL / old-max_tokens tick (`keys`) and each row's model lines (`models`).
5
6
  // Key VALUES never live here — they live in the keys file (`src/keysfile.js`) and the
6
7
  // shell env. The write path refuses a document that carries a secret-shaped string
7
8
  // (the ONE inventory: `sweepSecretLiterals`, src/validate.js).
8
9
  //
9
10
  // Shape (unknown fields are kept, never dropped):
10
- // { "monthlyLimitUsd": <number>, // absent = no limit, no check
11
- // "anthropicBalanceNote": <number>, // a typed note; no check ever reads it
12
- // "keys": { "<ENV NAME>": { "name": "<model id>", "shape": "<api shape>", "baseUrl": "<url or blank>" } } }
11
+ // { "monthlyLimitUsd": <number > 0>, // absent = no limit, no check
12
+ // "keys": { "<ENV NAME>": { "shape": "<shape id>", "baseUrl": "http(s)://…", "legacyMaxTokens": true|false } },
13
+ // "models": { "<ENV NAME>": [ { "model": "<id>", "use": true|false, "inPerM": n, "outPerM": n } ] } }
14
+ // An older file's `keys.<ENV>.{name,priceInPerM,priceOutPerM}` is tolerated and read as ONE model
15
+ // line (`effectiveModels`, src/providerrows.js); the first write of that row's `models` list removes them.
16
+ // `modelsProblem` is the ONE rule for a row's model lines: a price is a PAIR or neither, a ticked
17
+ // line needs both. Nothing here sniffs a host or a model id.
13
18
  //
14
- // A missing file = defaults. An UNREADABLE file is reported (`problem`), never
19
+ // A missing file = defaults. An UNREADABLE or INVALID file is reported (`problem`), never
15
20
  // papered over — the monthly check REFUSES on it rather than silently running with
16
21
  // no limit. Written atomically (tmp + rename), mode 600.
17
22
  import {
@@ -20,6 +25,7 @@ import {
20
25
  import { join, dirname } from 'node:path';
21
26
  import { homedir } from 'node:os';
22
27
  import { sweepSecretLiterals } from './validate.js';
28
+ import { SHAPE_IDS } from './providershapes.js';
23
29
 
24
30
  /** A plain-words config failure (bad value, secret-shaped string, unreadable file). */
25
31
  export class ConfigError extends Error {}
@@ -55,22 +61,106 @@ export function readConfig(opts = {}) {
55
61
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
56
62
  return { config: {}, exists: true, problem: `${file} must hold a JSON object — fix or remove it` };
57
63
  }
64
+ const bad = docProblem(parsed);
65
+ if (bad) return { config: {}, exists: true, problem: `${file}: ${bad} — fix or remove it` };
58
66
  return { config: parsed, exists: true, problem: null };
59
67
  }
60
68
 
61
69
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
62
- /** the API shapes a keys row may name (the `src/providers.js` table names) */
63
- const SHAPE_IDS = ['anthropic-api', 'openai-api', 'gemini-api'];
70
+
71
+ /** The fields of a key row this module validates (others are kept untouched). */
72
+ const KEY_FIELDS = Object.freeze(['shape', 'baseUrl', 'legacyMaxTokens']);
73
+
74
+ /**
75
+ * One key-row field's value problem in plain words, or null when fine.
76
+ * @param {string} field `model` | `shape` | `baseUrl` | `legacyMaxTokens`
77
+ * @param {unknown} v
78
+ * @returns {string|null}
79
+ */
80
+ export function providerFieldProblem(field, v) {
81
+ if (field === 'model') {
82
+ if (typeof v !== 'string' || v.length === 0 || v.length > 200 || /\s/.test(v)) return 'must be a model id: 1 to 200 characters, no spaces';
83
+ } else if (field === 'shape') {
84
+ if (typeof v !== 'string' || !SHAPE_IDS.includes(v)) return `must be one of ${SHAPE_IDS.join(', ')}`;
85
+ } else if (field === 'legacyMaxTokens') {
86
+ if (typeof v !== 'boolean') return 'must be true or false';
87
+ } else if (field === 'baseUrl') {
88
+ if (typeof v !== 'string' || !(v === '' || /^https?:\/\/\S+$/.test(v))) return 'must be empty or an http(s) address';
89
+ } else return 'is not a provider field';
90
+ return null;
91
+ }
92
+
93
+ /** A Base URL as stored: trailing slashes dropped. @param {string} v @returns {string} */
94
+ export const cleanBaseUrl = (v) => v.replace(/\/+$/, '');
95
+
96
+ /**
97
+ * THE one rule for a row's model lines, used by config validation and the Settings routes. A price
98
+ * is a PAIR (`inPerM` and `outPerM`, each a finite number >= 0 — 0 / 0 typed is a chosen price) or
99
+ * neither (null / absent). A ticked line (`use`) needs both. Plain words, or null.
100
+ * @param {unknown} list
101
+ * @returns {string|null}
102
+ */
103
+ export function modelsProblem(list) {
104
+ if (!Array.isArray(list)) return 'The model list must be a list of lines.';
105
+ const seen = new Set();
106
+ for (const line of list) {
107
+ if (line === null || typeof line !== 'object' || Array.isArray(line)) return 'Each model line must be an object.';
108
+ const l = /** @type {Record<string, any>} */ (line);
109
+ const bad = providerFieldProblem('model', l.model);
110
+ if (bad) return `The model name ${bad}.`;
111
+ if (seen.has(l.model)) return `The model "${l.model}" is listed twice.`;
112
+ seen.add(l.model);
113
+ if (typeof l.use !== 'boolean') return 'Use must be on or off.';
114
+ const has = (/** @type {string} */ f) => l[f] !== undefined && l[f] !== null;
115
+ const isNum = (/** @type {unknown} */ v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
116
+ if (has('inPerM') !== has('outPerM')) return 'Fill both price boxes or clear both.';
117
+ if (has('inPerM') && !(isNum(l.inPerM) && isNum(l.outPerM))) return 'A price must be a number, 0 or more.';
118
+ if (l.use && !has('inPerM')) return 'Fill both prices to tick this model.';
119
+ for (const k of Object.keys(l)) if (!['model', 'use', 'inPerM', 'outPerM'].includes(k)) return `A model line has no "${k}" field.`;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * The first problem in a document's known fields, in plain words, or null.
126
+ * @param {Record<string, any>} doc
127
+ * @returns {string|null}
128
+ */
129
+ function docProblem(doc) {
130
+ // monthlyLimitUsd is NOT judged here: `monthlyLimitOf` (src/monthly.js) owns its read-time sentence
131
+ if (doc.keys !== undefined) {
132
+ if (doc.keys === null || typeof doc.keys !== 'object' || Array.isArray(doc.keys)) return 'keys must be an object of key variable names';
133
+ for (const [env, row] of Object.entries(doc.keys)) {
134
+ if (!ENV_NAME_RE.test(env)) return `"${env}" is not a key variable name`;
135
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return `keys.${env} must be an object`;
136
+ for (const f of KEY_FIELDS) {
137
+ if (!Object.hasOwn(row, f)) continue;
138
+ const bad = providerFieldProblem(f, /** @type {Record<string, any>} */ (row)[f]);
139
+ if (bad) return `keys.${env}.${f} ${bad}`;
140
+ }
141
+ }
142
+ }
143
+ if (doc.models !== undefined) {
144
+ if (doc.models === null || typeof doc.models !== 'object' || Array.isArray(doc.models)) return 'models must be an object of key variable names';
145
+ for (const [env, list] of Object.entries(doc.models)) {
146
+ if (!ENV_NAME_RE.test(env)) return `models: "${env}" is not a key variable name`;
147
+ const bad = modelsProblem(list);
148
+ if (bad) return `models.${env}: ${bad}`;
149
+ }
150
+ }
151
+ return null;
152
+ }
64
153
 
65
154
  /**
66
155
  * Apply a patch and write. `null` deletes a field. Top-level fields shallow-merge;
67
- * `keys` merges per key variable name, then per field. Validated before anything is
68
- * written; nothing is written on a refusal.
156
+ * `keys` merges per key variable name, then per field; `models.<ENV>` is REPLACED WHOLE (`null`
157
+ * clears it). Validated before anything is written; nothing is written on a refusal.
69
158
  *
70
- * Patch fields understood: `monthlyLimitUsd` (finite > 0), `anthropicBalanceNote`
71
- * (finite >= 0), `keys.<ENV NAME>.{name,shape,baseUrl}` (Name = a model id, shape = a provider table name, baseUrl = blank or http(s)). Any other
72
- * field is passed through untouched (kept), but the whole document is swept for
73
- * secret-shaped strings first.
159
+ * Patch fields understood: `monthlyLimitUsd` (finite > 0), `keys.<ENV>.{shape,baseUrl,legacyMaxTokens}`,
160
+ * `models.<ENV>` (a list judged by {@link modelsProblem}). The first write of a row's `models` list
161
+ * removes that row's older `name` / `priceInPerM` / `priceOutPerM` in the SAME write (never two
162
+ * sources); a retired top-level `anthropicBalanceNote` is dropped on any write. Any other field is
163
+ * kept, but the whole document is swept for secret-shaped strings first.
74
164
  * @param {Record<string, any>} patch
75
165
  * @param {{ home?: string }} [opts]
76
166
  * @returns {Record<string, any>} the document as written
@@ -81,16 +171,14 @@ export function updateConfig(patch, opts = {}) {
81
171
  /** @type {Record<string, any>} */
82
172
  const next = { ...cur.config };
83
173
  for (const [k, v] of Object.entries(patch)) {
84
- if (k === 'keys') continue;
174
+ if (k === 'keys' || k === 'models') continue;
85
175
  if (v === null) { delete next[k]; continue; }
86
176
  if (k === 'monthlyLimitUsd' && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
87
177
  throw new ConfigError('monthlyLimitUsd must be a number above 0 (remove the limit to have none)');
88
178
  }
89
- if (k === 'anthropicBalanceNote' && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
90
- throw new ConfigError('anthropicBalanceNote must be a number, 0 or more');
91
- }
92
179
  next[k] = v;
93
180
  }
181
+ delete next.anthropicBalanceNote;
94
182
  if (patch.keys !== undefined) {
95
183
  if (patch.keys === null || typeof patch.keys !== 'object' || Array.isArray(patch.keys)) {
96
184
  throw new ConfigError('keys must be an object of key variable names');
@@ -104,21 +192,33 @@ export function updateConfig(patch, opts = {}) {
104
192
  const merged = { ...(keys[envName] && typeof keys[envName] === 'object' ? keys[envName] : {}) };
105
193
  for (const [fk, fv] of Object.entries(/** @type {Record<string, any>} */ (fields))) {
106
194
  if (fv === null) { delete merged[fk]; continue; }
107
- if (fk === 'name' && !(typeof fv === 'string' && fv.length <= 100 && !/\s/.test(fv))) {
108
- throw new ConfigError(`keys.${envName}.name must be a model id (no spaces, up to 100 characters)`);
109
- }
110
- if (fk === 'shape' && !SHAPE_IDS.includes(fv)) {
111
- throw new ConfigError(`keys.${envName}.shape must be one of ${SHAPE_IDS.join(', ')}`);
112
- }
113
- if (fk === 'baseUrl' && !(typeof fv === 'string' && (fv === '' || /^https?:\/\/\S+$/.test(fv)))) {
114
- throw new ConfigError(`keys.${envName}.baseUrl must be blank or an http(s) URL`);
115
- }
116
- merged[fk] = fv;
195
+ merged[fk] = fk === 'baseUrl' && typeof fv === 'string' ? cleanBaseUrl(fv) : fv;
117
196
  }
118
197
  keys[envName] = merged;
119
198
  }
120
199
  next.keys = keys;
121
200
  }
201
+ if (patch.models !== undefined) {
202
+ if (patch.models === null || typeof patch.models !== 'object' || Array.isArray(patch.models)) {
203
+ throw new ConfigError('models must be an object of key variable names');
204
+ }
205
+ /** @type {Record<string, any>} */
206
+ const models = { ...(next.models && typeof next.models === 'object' ? next.models : {}) };
207
+ for (const [envName, list] of Object.entries(patch.models)) {
208
+ if (!ENV_NAME_RE.test(envName)) throw new ConfigError(`"${envName}" is not a key variable name`);
209
+ if (list === null) { delete models[envName]; continue; }
210
+ models[envName] = list;
211
+ const row = next.keys?.[envName];
212
+ if (row && typeof row === 'object') {
213
+ // one source: the older single-model fields leave with the first saved list
214
+ const { name: _n, priceInPerM: _i, priceOutPerM: _o, ...rest } = row;
215
+ next.keys = { ...next.keys, [envName]: rest };
216
+ }
217
+ }
218
+ next.models = models;
219
+ }
220
+ const problem = docProblem(next);
221
+ if (problem) throw new ConfigError(problem);
122
222
  /** @type {string[]} */
123
223
  const secretPaths = [];
124
224
  sweepSecretLiterals(next, (_code, path) => { secretPaths.push(path); });
package/src/draftspend.js CHANGED
@@ -24,10 +24,10 @@ export const DRAFT_LOG_FILE = 'draft-log.jsonl';
24
24
  * One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
25
25
  * - `step-start` {no, id, label, at}
26
26
  * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
27
- * - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway")
28
- * - `call` {no, step, label, model, thinking, thinkingApplied?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
29
- * (`thinking` = what was asked and sent: off | on | not-switchable | unverified; `thinkingApplied` = bare-agent's own verdict for the
30
- * call's run (true | false | null), present only when thinkingMode was sent)
27
+ * - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway" / "can't change it" / "not confirmed")
28
+ * - `call` {no, step, label, model, thinking, thinkingSupport, thinkingStatus?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
29
+ * (`thinking` = the tick's mode: off | on; `thinkingSupport` = bare-agent's lookup for the model: true | false | 'unverified';
30
+ * `thinkingStatus` = bare-agent's own verdict for the call's run: applied | unsupported | ignored | unknown, present only when thinkingMode was sent)
31
31
  * (no token counts: the call path does not carry them without reworking the library, so none are recorded)
32
32
  * @typedef {{kind: string, no: number, at: string, [k: string]: any}} DraftEvent
33
33
  */
package/src/index.js CHANGED
@@ -148,16 +148,14 @@ export { answerReviewDoor } from './reviewdoor.js';
148
148
  // SOFTGREEN module 1 — the judged floor's core. The pieces an adopter (and an
149
149
  // integrating UI) genuinely needs: `resolveJudge` (PRD item 32.1 — WHICH model
150
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,
151
+ // model, and no library fall-back; the old pinned-model constant is gone), the rulebook a card SELECTS from,
154
152
  // the card gate, and the two halves themselves so a calibration harness can
155
153
  // grade the whole pipe without running a close.
156
154
  // `defaultJudgeLoop` (src/judged.js) is the one spelling of how this repo drives a
157
155
  // judge; the runner reaches it through `runPlan`'s `judgeProvider`. It is internal:
158
156
  // the package root does not export it.
159
157
  export {
160
- JUDGE_MODEL, resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
158
+ resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
161
159
  runLocate, decide,
162
160
  // SOFTGREEN module 4 — what a legal SIGNED calibration set is. The SIZE is
163
161
  // hamr's own ruling and a size change is a spec-level threshold change;
@@ -87,7 +87,7 @@
87
87
  // non-repo source gets the form up to here and then an honest named stop:
88
88
  // bareloop has no checks for that kind of job yet (ruling 7 → M4).
89
89
  import { keysForDoor } from './keysfile.js';
90
- import { applyConfiguredKey, rowsForHome } from './providerrows.js';
90
+ import { keyValueOf, namedRowsForHome, resolveWorker } from './providerrows.js';
91
91
  import {
92
92
  writeFileSync, mkdirSync, readFileSync, statSync,
93
93
  } from 'node:fs';
@@ -192,6 +192,9 @@ export async function main(argv, deps = {}) {
192
192
  // way an empty `--verdict`/`--provider` value already does elsewhere in this
193
193
  // file.
194
194
  const baseUrlArg = arg('base-url');
195
+ // `--model <id>`: the model id of a line ticked in Settings > Providers (with both prices). Written into the draft as `model`, so the
196
+ // job carries its own model; absent when never given (run-author then stops at $0 asking for one).
197
+ const modelArg = arg('model');
195
198
  // `--lang` IS GONE (PRD item 33 M3, ruling 3): language is a FACT of the
196
199
  // repository, read off its own manifest, never a flag a person sets. A
197
200
  // `--lang` on the command line now is an operator error, stopped loud rather
@@ -686,6 +689,7 @@ export async function main(argv, deps = {}) {
686
689
  // written as `null`/`''`; every provider constructor defaults its own
687
690
  // endpoint on its own in that case (PRD item 28 ruling (d)).
688
691
  ...(baseUrlArg === null ? {} : { baseUrl: baseUrlArg }),
692
+ ...(modelArg === null ? {} : { model: modelArg }),
689
693
  cadence: { unit: 'day', every: 1 },
690
694
  budgetUsd,
691
695
  ...(wallMin === null ? {} : { maxWallMs: Math.round(wallMin * 60_000) }),
@@ -782,29 +786,17 @@ export async function main(argv, deps = {}) {
782
786
  // anything this script itself let through; the try/catch is only for the
783
787
  // off-menu case `validateJob` reds but does not stop the write for (an
784
788
  // admitted-but-uncrafted table entry would otherwise crash this print).
785
- let providerEntry = null;
786
- try { providerEntry = resolveProvider(PROVIDER); } catch { providerEntry = null; }
787
- const providerEnvKey = providerEntry?.envKey ?? null;
788
- say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... ${commandFor('author', deps.invokedAs)} ${childArgs.join(' ')}`);
789
- // An `openai-api` + `baseUrl` draft (DeepSeek, today's one secondary, reached
790
- // this way) still reads its key from `OPENAI_API_KEY` — there is no separate
791
- // "DeepSeek key" env var, and a person pointed only at the command above could
792
- // reasonably miss that. Said once, plainly, never a key VALUE.
793
- if (draft.baseUrl !== undefined && providerEnvKey) {
794
- say(` (the endpoint above is reached through the "${PROVIDER}" table entry, so its key still goes in ${providerEnvKey} — there is no separate endpoint-specific key variable)`);
795
- }
796
- // NO KEY, NO OFFER. `run-author.mjs` refuses without one and exits 2 at its own
797
- // door, before a spine exists — so with the shell unkeyed this question has
798
- // exactly one possible outcome for the person, and putting it anyway spends
799
- // their attention on a choice they do not have. What is actionable instead is
800
- // the command above and the one line that says how to make it work.
801
- // F181 — a key that IS set can still carry a shape run-author will refuse
802
- // (a line break/control char/stray whitespace, e.g. a two-line secret-store
803
- // entry). A malformed key must not count as KEYED: this offer must read the
804
- // same "will it actually run" question run-author itself asks at its door.
805
- // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
806
- const keyEnv = keys.home !== undefined ? applyConfiguredKey(env, PROVIDER, draft.baseUrl, rowsForHome(keys.home)) : env;
807
- const rawKeyValue = providerEnvKey ? keyEnv[providerEnvKey] : undefined;
789
+ // The key is the Settings row's own variable (P8: no built-in name). The worker is the draft's API shape + Base URL + model line.
790
+ const keyRowsNow = keys.home !== undefined ? namedRowsForHome(keys.home) : [];
791
+ const worker = resolveWorker(keyRowsNow, { provider: PROVIDER, baseUrl: draft.baseUrl, model: modelArg });
792
+ const providerEnvKey = worker.ok ? worker.row.envName : null;
793
+ say(` ${providerEnvKey ?? 'YOUR_KEY_NAME'}=... ${commandFor('author', deps.invokedAs)} ${childArgs.join(' ')}`);
794
+ if (!worker.ok) say(` (${worker.say.replace(' Nothing was spent.', '')} — bareloop author stops at $0 until it is)`);
795
+ // NO KEY, NO OFFER. `run-author.mjs` refuses without one and exits 2 at its own door, before a spine exists — so with the shell
796
+ // unkeyed this question has exactly one possible outcome for the person, and putting it anyway spends their attention on a choice
797
+ // they do not have. F181 — a key that IS set can still carry a shape run-author will refuse (a line break/control char/stray
798
+ // whitespace). A malformed key must not count as KEYED: this offer reads the same "will it actually run" question run-author asks.
799
+ const rawKeyValue = providerEnvKey ? keyValueOf(env, providerEnvKey) : undefined;
808
800
  const keyProblem = rawKeyValue ? apiKeyProblem(rawKeyValue) : null;
809
801
  const KEYED = providerEnvKey !== null && Boolean(rawKeyValue) && !keyProblem;
810
802
  if (!KEYED) {
@@ -814,7 +806,7 @@ export async function main(argv, deps = {}) {
814
806
  } else {
815
807
  say(providerEnvKey
816
808
  ? ` (${providerEnvKey} is not set in this shell — bareloop author refuses without it; secrets load from the environment, never the tree)`
817
- : ` (provider "${PROVIDER}" is not in the runnable table — bareloop author will refuse it loud; this is not a key problem)`);
809
+ : ' (no key row serves this model yet — bareloop author refuses it at $0; this is not a key problem)');
818
810
  }
819
811
  // The repair, in plain words and WITHOUT a command: which secret store a person
820
812
  // keeps their key in is theirs, and printing one specific incantation would be