bareloop 0.40.0 → 0.42.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 (77) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +141 -128
  4. package/package.json +5 -4
  5. package/src/authorflow.js +346 -38
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +82 -20
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +39 -30
  10. package/src/authorscout.js +112 -4
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/cli.js +1 -1
  14. package/src/config.js +129 -29
  15. package/src/declaredclose.js +23 -12
  16. package/src/draftspend.js +4 -4
  17. package/src/facts.js +317 -0
  18. package/src/factwords.js +129 -0
  19. package/src/index.js +2 -4
  20. package/src/inputlook.js +301 -0
  21. package/src/interviewrun.js +17 -25
  22. package/src/job.js +15 -8
  23. package/src/jobblock.js +7 -2
  24. package/src/judged.js +11 -43
  25. package/src/keysfile.js +15 -7
  26. package/src/kinds.js +86 -5
  27. package/src/labelscan.js +170 -0
  28. package/src/modellist.js +84 -0
  29. package/src/monthly.js +23 -43
  30. package/src/pagefetch.js +125 -0
  31. package/src/panel/authorroutes.js +34 -16
  32. package/src/panel/authorsession.js +161 -80
  33. package/src/panel/index.html +553 -152
  34. package/src/panel/jobplan.js +34 -11
  35. package/src/panel/runroutes.js +3 -0
  36. package/src/panel/server.js +54 -18
  37. package/src/panel/settingsroutes.js +232 -173
  38. package/src/planrun.js +5 -4
  39. package/src/providerrows.js +226 -141
  40. package/src/providers.js +58 -332
  41. package/src/providershapes.js +18 -0
  42. package/src/replay.js +1 -1
  43. package/src/rubricstep.js +804 -0
  44. package/src/source.js +166 -1
  45. package/src/thinking.js +130 -0
  46. package/src/u-readout.js +1 -1
  47. package/src/userrun.js +64 -75
  48. package/types/authorflow.d.ts +272 -18
  49. package/types/authoring.d.ts +29 -1
  50. package/types/authorjob.d.ts +9 -7
  51. package/types/authorreadout.d.ts +27 -2
  52. package/types/authorscout.d.ts +72 -2
  53. package/types/config.d.ts +23 -6
  54. package/types/declaredclose.d.ts +4 -2
  55. package/types/draftspend.d.ts +8 -8
  56. package/types/facts.d.ts +71 -0
  57. package/types/factwords.d.ts +42 -0
  58. package/types/index.d.ts +1 -1
  59. package/types/inputlook.d.ts +238 -0
  60. package/types/job.d.ts +5 -5
  61. package/types/jobblock.d.ts +8 -1
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/labelscan.d.ts +38 -0
  65. package/types/modellist.d.ts +50 -0
  66. package/types/monthly.d.ts +4 -3
  67. package/types/pagefetch.d.ts +71 -0
  68. package/types/panel/authorsession.d.ts +20 -11
  69. package/types/panel/jobplan.d.ts +12 -4
  70. package/types/panel/server.d.ts +18 -6
  71. package/types/providerrows.d.ts +192 -99
  72. package/types/providers.d.ts +34 -159
  73. package/types/providershapes.d.ts +10 -0
  74. package/types/rubricstep.d.ts +286 -0
  75. package/types/source.d.ts +94 -0
  76. package/types/thinking.d.ts +90 -0
  77. package/types/userrun.d.ts +17 -1
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,
@@ -120,11 +120,13 @@ import { defaultJudgeLoop, resolveJobJudge } from './judged.js';
120
120
  import { validateJob, jobSpecHash, resolveWorkerModel } from './job.js';
121
121
  import { scanSecrets, redactSecrets } from './validate.js';
122
122
  import { detectLanguage } from './detectlang.js';
123
+ import { RUBRIC_LANG } from './authoring.js';
124
+ import { rubricGoal } from './rubricstep.js';
123
125
  import { moveFile } from './movefile.js';
124
126
  import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
125
127
  import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
126
128
  import { commandFor } from './invoke.js';
127
- import { readSourceManifest, missingDependencies, nonRepoSourceMessage } from './source.js';
129
+ import { readSourceManifest, missingDependencies, nonRepoSourceMessage, isRubricFilesManifest } from './source.js';
128
130
  import { tallyCalls } from './text.js';
129
131
  import {
130
132
  declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
@@ -327,6 +329,9 @@ export async function main(argv, deps = {}) {
327
329
  // folder has no genre to detect) and the branch further down that decides
328
330
  // which of the two paths this run actually takes.
329
331
  const IS_REPO_SOURCE = manifestRead.manifest.kind === 'repo';
332
+ // PART 1a: a soft-green job whose source froze the person's own local FILES (`proveRubricInputs` left `inputs` in the
333
+ // manifest) is NOT the plain-folder stop below: it is authored against the hidden tree with the text genre, no scout.
334
+ const RUBRIC_FILES = !IS_REPO_SOURCE && VERDICT === 'soft-green' && isRubricFilesManifest(manifestRead.manifest);
330
335
 
331
336
  // ── LANGUAGE, DETECTED — never asked (PRD item 33 M3, ruling 3) ──────────────
332
337
  // $0, no provider, run BEFORE the API key is even read — this script must
@@ -361,7 +366,7 @@ export async function main(argv, deps = {}) {
361
366
  // the language pick (the old repo-only "worse than before" ask is retired
362
367
  // entirely as of 2026-09-28 — see src/authorflow.js's own retirement note).
363
368
  let langResult = /** @type {ReturnType<typeof detectLanguage>|null} */ (null);
364
- let LANG = 'none-detected';
369
+ let LANG = RUBRIC_FILES ? RUBRIC_LANG : 'none-detected';
365
370
  if (IS_REPO_SOURCE) {
366
371
  langResult = detectLanguage(SOURCE);
367
372
  // AMBIGUOUS NO LONGER DIES (PRD item 33 M3 piece 4, step S4): the confirm
@@ -466,13 +471,15 @@ export async function main(argv, deps = {}) {
466
471
  try { return resolveProvider(draft?.provider); } catch (e) { return die(/** @type {Error} */ (e).message); }
467
472
  })();
468
473
  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;
474
+ // 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
475
+ // 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
476
+ // built in: a draft naming no model, or a model no row ticks, stops here at $0.
477
+ const keyCfg = rowsForHome(keys.home, env);
478
+ const W = resolveWorker(keyCfg, draft);
479
+ if (!W.ok) { err(W.say.includes('Nothing was spent') ? W.say : `${W.say} Nothing was spent.`); throw new ExitSignal(2); }
480
+ const MODEL = W.model;
481
+ const baseUrl = W.baseUrl;
482
+ const AUTHOR_ROW = W.row;
476
483
 
477
484
  // THE JUDGE THIS RUN'S OWN WORKER RESOLVES TO (PRD item 32.1) — resolved ONCE,
478
485
  // here, from the DRAFT, and reused for BOTH the compose-time stamp (the
@@ -508,12 +515,9 @@ export async function main(argv, deps = {}) {
508
515
 
509
516
  // Secrets load from the environment; they never enter argv (a command line is
510
517
  // 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];
518
+ // The key is the chosen row's own variable (P8: no built-in name). Names only.
519
+ const AUTHOR_ENV_KEY = AUTHOR_ROW.envName;
520
+ const apiKey = keyValueOf(env, AUTHOR_ENV_KEY);
517
521
  if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
518
522
  // F181 — a key that carries a line break/control char/stray whitespace (a
519
523
  // two-line secret-store entry, e.g.) reads as "set" by the presence check
@@ -527,17 +531,17 @@ export async function main(argv, deps = {}) {
527
531
  // loop, confirm turn, calibration judge). A bad price refuses here; it never falls back to the guess.
528
532
  /** @type {ReturnType<typeof ratesFor>} */
529
533
  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);
534
+ {
535
+ const al = ratesOfLine(W.line);
536
+ authorPrice = al ? { ...al, envName: AUTHOR_ROW.envName } : null;
534
537
  }
535
538
  /** The judge's key follows the RESOLVED judge provider's own env var, with
536
539
  * `JUDGE_API_KEY` as the role-named override in front (PRD item 32.3) — the same
537
540
  * contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
538
541
  * judge IS the authoring provider this is the key already read above. */
542
+ const judgeRowOf = (/** @type {{provider: string, model?: string|null}} */ j) => judgeRowFor(keyCfg, j, PROVIDER_NAME, AUTHOR_ROW);
539
543
  const judgeKeyFor = (/** @type {string} */ providerName) => (
540
- env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? authorEnv : applyConfiguredKey(env, providerName, undefined, keyCfg))[resolveProvider(providerName).envKey]
544
+ env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? apiKey : keyValueOf(env, judgeRowFor(keyCfg, { provider: providerName }, PROVIDER_NAME, AUTHOR_ROW)?.envName))
541
545
  );
542
546
 
543
547
  /** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
@@ -579,7 +583,7 @@ export async function main(argv, deps = {}) {
579
583
  // one) — the real judge identity is not resolved until after `validateJob`,
580
584
  // further down, where a second call takes only its `.judgeProvider`.
581
585
  const { provider } = buildRunnerProviders({
582
- providerName: PROVIDER_NAME, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl,
586
+ providerName: PROVIDER_NAME, apiKey, model: MODEL, baseUrl, legacyMaxTokens: AUTHOR_ROW.legacyMaxTokens,
583
587
  judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: PROVIDER_NAME, judgeBaseUrl: baseUrl,
584
588
  });
585
589
  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 });
@@ -835,7 +839,7 @@ export async function main(argv, deps = {}) {
835
839
  // build can close. The stop fires HERE, immediately, at $0, no scout, no
836
840
  // confirm turn, no model call — a plain folder's spine is exactly
837
841
  // `author-start` → `author-end{outcome:'not-authored', stop:'non-code-source'}`.
838
- if (!IS_REPO_SOURCE) {
842
+ if (!IS_REPO_SOURCE && !RUBRIC_FILES) {
839
843
  const message = nonRepoSourceMessage(manifestRead.manifest.kind);
840
844
  out(`\n${message}`);
841
845
  const red = {
@@ -897,7 +901,7 @@ export async function main(argv, deps = {}) {
897
901
  // so every path that reaches this call is a repo job. `langResult`
898
902
  // travels through unchanged — its `ambiguous` shape is what the confirm
899
903
  // turn's own $0 half (D7) asks about, before the scout.
900
- ask, confirmGenerate, isRepo: true, langResult,
904
+ ask, confirmGenerate, isRepo: !RUBRIC_FILES, langResult,
901
905
  });
902
906
  const authoredFile = writeOut('authored.json', authored);
903
907
  emit('authored', { ok: authored.ok, stop: authored.stop, seedRef: authored.seedRef, cost: authored.cost, reds: authored.reds });
@@ -1009,7 +1013,11 @@ export async function main(argv, deps = {}) {
1009
1013
  // predates the confirm turn's own `ask` wiring (`authored.confirmed` is
1010
1014
  // `null` for every caller that runs no confirm turn) — the draft's own
1011
1015
  // goal (if any) then stands untouched.
1012
- if (authored.confirmed?.goal) {
1016
+ if (RUBRIC_FILES && Array.isArray(draft.jobLines) && draft.jobLines.length > 0) {
1017
+ // PART 1a: the worker's goal for a rubric job on files is written by code from the person's own numbered lines and the
1018
+ // frozen input files the manifest recorded (src/rubricstep.js); the confirmed goal sentence rides as the plan line
1019
+ draft.goal = redactSecrets(rubricGoal({ jobLines: draft.jobLines, files: manifestRead.manifest?.inputs ?? [], confirmedGoal: authored.confirmed?.goal ?? null, closeDecl: authored.closeDecl }));
1020
+ } else if (authored.confirmed?.goal) {
1013
1021
  draft.goal = redactSecrets(String(authored.confirmed.goal));
1014
1022
  }
1015
1023
  // ── 2. the operator's half + the authored half → one spec ──────────────────
@@ -1115,7 +1123,7 @@ export async function main(argv, deps = {}) {
1115
1123
  const judgeKeyValue = judgeKeyFor(judge.provider);
1116
1124
  const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
1117
1125
  if (judgeKeyProblem) {
1118
- const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : resolveProvider(judge.provider).envKey;
1126
+ const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : (judgeRowOf(judge)?.envName ?? judge.provider);
1119
1127
  err(`${judgeEnvName} ${judgeKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
1120
1128
  // F186 — the scout above already ran and may have left its own gate
1121
1129
  // audit in the tree. Under `process.exit()` this path used to skip
@@ -1149,9 +1157,10 @@ export async function main(argv, deps = {}) {
1149
1157
  let judgeProvider = null;
1150
1158
  if (judge) {
1151
1159
  judgeProvider = buildRunnerProviders({
1152
- providerName: PROVIDER_NAME, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl,
1160
+ providerName: PROVIDER_NAME, apiKey, model: MODEL, baseUrl, legacyMaxTokens: AUTHOR_ROW.legacyMaxTokens,
1153
1161
  judgeApiKey: judgeKeyFor(judge.provider), judgeModel: judge.model, judgeProviderName: judge.provider,
1154
- judgeBaseUrl: judge.provider === PROVIDER_NAME ? baseUrl : undefined,
1162
+ judgeBaseUrl: judge.provider === PROVIDER_NAME ? baseUrl : (judgeRowOf(judge)?.baseUrl.trim() || undefined),
1163
+ judgeLegacyMaxTokens: judge.provider === PROVIDER_NAME ? AUTHOR_ROW.legacyMaxTokens : (judgeRowOf(judge)?.legacyMaxTokens ?? false),
1155
1164
  }).judgeProvider;
1156
1165
  }
1157
1166
  if (judge) {
@@ -1170,7 +1179,7 @@ export async function main(argv, deps = {}) {
1170
1179
  // number would silently widen it.
1171
1180
  ceilingUsd: CEILING_USD,
1172
1181
  priorCalls: [...metered],
1173
- judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
1182
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null, thinking: spec.thinking === true }) : null,
1174
1183
  // the seam's other half (PRD item 32.1): the graded set is STAMPED with
1175
1184
  // this, and the close later refuses to grade under any other identity.
1176
1185
  judgeModel: judge?.model ?? null,
@@ -1297,7 +1306,7 @@ export async function main(argv, deps = {}) {
1297
1306
  + '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
1307
  }
1299
1308
  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' : ''}` : ''}`);
1309
+ out(` ${AUTHOR_ENV_KEY}=... ${commandFor('run-u', deps.invokedAs)} --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
1301
1310
  emit('author-end', { outcome: 'prepared', specHash: hash });
1302
1311
  }
1303
1312
  }
@@ -56,6 +56,7 @@ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from
56
56
  import { redactSecrets, SECRET_PATTERNS } from './validate.js';
57
57
  import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop, callCasualty } from './text.js';
58
58
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
59
+ import { lookReadTools, lookReportSchema, lookPrompt, verifyLookReport, labelListsOf, LOOK_REPORT_TOOL } from './inputlook.js';
59
60
 
60
61
  const require = createRequire(import.meta.url);
61
62
  const { Loop, wireGate } = require('bare-agent');
@@ -375,7 +376,7 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider,
375
376
  * @param {{workdir: string, provider?: any,
376
377
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
377
378
  * attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
378
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
379
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
379
380
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
380
381
  * rates?: {in: number, out: number}|null, callOptions?: Record<string, any>,
381
382
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
@@ -400,7 +401,7 @@ export async function runAuthorScout({
400
401
  onCall = () => {},
401
402
  createLoop = defaultLoop,
402
403
  rates = null,
403
- // the drafter's per-call model options (`draftCallOptions`, src/providers.js; e.g. thinking off) — {} = nothing extra, byte-identical
404
+ // the drafter's per-call model options (`thinkingCallOptions`, src/thinking.js; e.g. thinking off) — {} = nothing extra, byte-identical
404
405
  callOptions = {},
405
406
  createSurveyor = defaultSurveyor,
406
407
  }) {
@@ -436,8 +437,8 @@ export async function runAuthorScout({
436
437
  // metered without the report going out: exactly the pairing invariant the
437
438
  // raw already rides. It REPORTS and decides nothing; the ceiling still binds
438
439
  // 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 }) });
440
+ // bare-agent's per-run `thinkingStatus` rides the reporting seam only, never the stored entry (see makeCostBook.add)
441
+ onCall({ ...call, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
441
442
  return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
442
443
  };
443
444
  // TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage
@@ -656,6 +657,113 @@ export async function runAuthorScout({
656
657
  }
657
658
  }
658
659
 
660
+ // ── THE LOOK AT A RUBRIC JOB'S INPUTS (PART 1c, docs/product/ITEM33-BUILD.md "3. The look") ─────────────────────────────────────
661
+ //
662
+ // The same pattern as the repo survey above, aimed at the person's own input text instead of a repository: a FIXED read-only grant
663
+ // (search the input, read a slice around a hit - closures over in-memory text, so there is nothing to fence), a hard round bound, a
664
+ // reserved final round whose ONLY tool is the answer form (no more looking), the spend metered per call into the same `calls` the
665
+ // drafting book absorbs, and the answer through a strict tool call (never free prose with JSON inside). The result is shaped like a
666
+ // survey (`state`, `facts`, `calls`, `raws`, `budgetStop`) so the drafting pipeline absorbs it exactly as it absorbs the repo scout.
667
+
668
+ /** the look's hard round bound */
669
+ export const LOOK_ROUNDS = AUTHOR_SCOUT_ROUNDS;
670
+
671
+ export const LOOK_SYSTEM = 'You are a careful analyst setting up a checkable job. You are given the person\'s job and the text of their inputs. '
672
+ + 'You can only search and read that text - you cannot write, run anything or change anything. You have a hard, small number of turns. '
673
+ + 'Your single deliverable is a report of which facts the job asks for can be CHECKED in the input, with the label that sits right before each value (picked from the list you are shown), and what you could not find. '
674
+ + 'You do not judge whether a result is good and you do not decide what is done with the job.';
675
+
676
+ export const LOOK_RECOVERY_PROMPT = 'You are out of looking turns. Report NOW, from what you have already read: call the report tool once with everything you can check and everything you could not find.';
677
+
678
+ /**
679
+ * Run the look. `createLoop` is the same seam the repo scout has; nothing else here needs a live process.
680
+ * @param {{texts: Record<string, string>, inputs: {n: number, label: string}[], jobLines: {n: number, text: string, rule?: string, rules?: string[]}[],
681
+ * provider?: any, rounds?: number, maxTokens?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
682
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
683
+ * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
684
+ * rates?: {in: number, out: number}|null, callOptions?: Record<string, any>,
685
+ * prove?: (source: string, phrase: string) => number}} o
686
+ * @returns {Promise<{state: 'PRESENT'|'ABSENT', facts: any, reason: string|null, cause: string|null, meta: any, raw: string,
687
+ * raws: ReturnType<typeof scrubRaw>[], calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
688
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
689
+ */
690
+ export async function runInputLook({
691
+ texts, inputs, jobLines, provider = null, rounds = LOOK_ROUNDS, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null,
692
+ callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS, onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {}, prove,
693
+ }) {
694
+ const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
695
+ ? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
696
+ const priced = rates ? { rates } : {};
697
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
698
+ const calls = [];
699
+ /** @type {ReturnType<typeof scrubRaw>[]} */
700
+ const raws = [];
701
+ const record = (/** @type {string} */ label, /** @type {any} */ r) => {
702
+ const call = { label, ...priceOf(r) };
703
+ calls.push(call);
704
+ onCall({ ...call, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
705
+ raws.push(scrubRaw({ label, attempt: 1, text: r?.text }));
706
+ };
707
+ const settled = async (/** @type {Promise<any>} */ p) => {
708
+ try { return await p; } catch (e) {
709
+ const reason = callCasualty(e);
710
+ if (reason === null) throw e;
711
+ return { error: reason };
712
+ }
713
+ };
714
+ const { scans, lists } = labelListsOf(texts);
715
+ /** @type {{calls: any[]}} */
716
+ const box = { calls: [] };
717
+ /** @type {any} */
718
+ let loop = null;
719
+ const reportTool = {
720
+ name: LOOK_REPORT_TOOL,
721
+ description: 'Deliver the report. Call this exactly once, with the whole report as its arguments.',
722
+ parameters: lookReportSchema(),
723
+ execute: async (/** @type {any} */ args) => { box.calls.push(args); if (loop) loop.stop(); return 'received'; },
724
+ };
725
+ const tools = [...lookReadTools(texts), reportTool];
726
+ const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
727
+ state: /** @type {'ABSENT'} */ ('ABSENT'), facts: null, reason, cause, meta: { rounds: seen, bounded }, raw: '', raws, calls, budgetStop,
728
+ });
729
+ let seen = 0;
730
+ let bounded = false;
731
+ const halt0 = capStop({ ceilingUsd, ...tallyCalls(calls) });
732
+ if (halt0) return absent(`the look was never asked for: the $${ceilingUsd} ceiling was already spent`, SURVEY_CAUSES.NOT_FUNDED, halt0);
733
+
734
+ const metered = async (/** @type {any} */ arg) => {
735
+ if ((arg?.kind ?? 'turn') === 'turn') {
736
+ seen += 1;
737
+ if (seen >= rounds) { bounded = true; loop.stop(); }
738
+ }
739
+ };
740
+ loop = createLoop({ system: LOOK_SYSTEM, policy: undefined, onLlmResult: metered, provider, ...priced });
741
+ const r = await settled(loop.run([{ role: 'user', content: lookPrompt({ jobLines, labels: Object.keys(texts), lists }) }], tools,
742
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
743
+ record('input-look', r);
744
+ /** @type {any} */
745
+ let last = r;
746
+ if (box.calls.length === 0 && !r?.error) {
747
+ // THE RESERVED FINAL ROUND: no more looking - the only tool is the answer form
748
+ const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
749
+ if (halt) return absent(`the look stopped on the cost ceiling before it reported: ${halt}`, SURVEY_CAUSES.NOT_FUNDED, halt);
750
+ const recovery = createLoop({ system: LOOK_SYSTEM, policy: undefined, onLlmResult: undefined, provider, ...priced });
751
+ const rMsgs = Array.isArray(r?.msgs) && r.msgs.length ? (r.msgs[0]?.role === 'system' ? r.msgs.slice(1) : r.msgs) : [];
752
+ const only = { ...reportTool, execute: async (/** @type {any} */ args) => { box.calls.push(args); recovery.stop(); return 'received'; } };
753
+ last = await settled(recovery.run([...rMsgs, { role: 'user', content: LOOK_RECOVERY_PROMPT }], [only],
754
+ { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
755
+ record('input-look-recovery', last);
756
+ }
757
+ if (box.calls.length === 0) {
758
+ return absent(last?.error ? `the look call failed: ${redactSecrets(String(last.error))}` : 'the look ended without a report', last?.error ? SURVEY_CAUSES.CALL_FAILED : SURVEY_CAUSES.EMPTY);
759
+ }
760
+ const report = verifyLookReport(box.calls[0], { scans, jobLines, ...(prove ? { prove } : {}) });
761
+ return {
762
+ state: 'PRESENT', reason: null, cause: null, meta: { rounds: seen, bounded }, raw: '', raws, calls, budgetStop: null,
763
+ facts: { rubricFiles: true, inputs: inputs.map((i) => ({ n: i.n, label: i.label })), look: report },
764
+ };
765
+ }
766
+
659
767
  // ── L1: THE MECHANICAL SEED LISTING ──────────────────────────────────────────
660
768
 
661
769
  /** per-path full-listing cap; over it, names give way to counted directories */
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); });