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
package/src/job.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Job-spec validator — the OPERATOR-owned half of the config story (N1,
2
- // design record: docs/product/2026-07-12-n1-job-close-schema-design.md).
2
+ // design record: docs/logs/2026-07-12-n1-job-close-schema-design.md).
3
3
  // A job spec is the arbiter's rulebook: the close chain, the budget, the
4
4
  // outer write fence, the escalation route. It is pure declarative data —
5
5
  // no freeform code is expressible anywhere in it — and it validates
@@ -167,7 +167,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
167
167
  // `steps` stays in the field list ONLY so a retired spec reds by name
168
168
  // (`shape-retired`) instead of falling through to a generic unknown-field —
169
169
  // the operator gets told what happened, not just that something is wrong.
170
- const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
170
+ const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
171
171
  /** the exact fields of a signed `judge` override — nested objects red unknown
172
172
  * keys too, because no smuggling level exists in a signed spec */
173
173
  const JUDGE_FIELDS = ['provider', 'model'];
@@ -329,7 +329,12 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
329
329
  // on THAT model, not merely on the goal/close it runs against. The judge
330
330
  // model stays library-pinned pending recalibration — out of scope here.
331
331
  if (spec.model !== undefined && !isNonEmptyString(spec.model)) {
332
- red('invalid-value', 'model', 'non-empty string — exact provider model id (e.g. "claude-sonnet-5")');
332
+ red('invalid-value', 'model', 'non-empty string — the exact model id of a line ticked in Settings > Providers');
333
+ }
334
+ // THINKING (P8) — the job's "Thinking on" tick, OPTIONAL: absent means off (and an old spec keeps the hash it always had); present it is
335
+ // signed like `model`. It covers DRAFTING and the rubric JUDGE only — the worker is never handed a thinking option. A boolean, nothing else.
336
+ if (spec.thinking !== undefined && typeof spec.thinking !== 'boolean') {
337
+ red('invalid-value', 'thinking', 'true or false — the job\'s "Thinking on" tick (absent = off)');
333
338
  }
334
339
 
335
340
  // JUDGE — PRD item 32.1 (hamr, 2026-09-10: the judge "defaults to whatever
@@ -717,6 +722,39 @@ function validateStagedClose(stages, red) {
717
722
  * @param {Red[]} reds direct access for structured request-reds
718
723
  */
719
724
  function validatePlanShape(spec, red, reds) {
725
+ // P7: the person's own words, signed beside the model's goal sentence. Exact fields (no smuggling level), numbered 1..n
726
+ // in order. Optional: a spec that predates the job block has neither and keeps its hash.
727
+ if (spec.jobLines !== undefined) {
728
+ if (!Array.isArray(spec.jobLines) || spec.jobLines.length === 0) red('invalid-value', 'jobLines', 'a non-empty array of {n, text, rule}');
729
+ else {
730
+ spec.jobLines.forEach((/** @type {any} */ l, /** @type {number} */ i) => {
731
+ const at = `jobLines[${i}]`;
732
+ if (!isObj(l)) { red('invalid-value', at, '{n, text, rule}'); return; }
733
+ for (const key of Object.keys(l)) if (!['n', 'text', 'rule', 'rules'].includes(key)) red('unknown-field', `${at}.${key}`, 'job line fields: n, text, rule, rules');
734
+ if (l.n !== i + 1) red('invalid-value', `${at}.n`, `lines are numbered 1, 2, 3 in order — expected ${i + 1}`);
735
+ if (!isNonEmptyString(l.text)) red('invalid-value', `${at}.text`, 'non-empty text');
736
+ if (typeof l.rule !== 'string') red('invalid-value', `${at}.rule`, 'a string ("" when the line has no ~ rule)');
737
+ // optional, beside `rule`: the same `~` lines one by one; it must join back to `rule` exactly
738
+ if (l.rules !== undefined && (!Array.isArray(l.rules) || l.rules.some((/** @type {any} */ r) => !isNonEmptyString(r)) || l.rules.join('; ') !== l.rule)) {
739
+ red('invalid-value', `${at}.rules`, 'an array of the ~ lines that joins with "; " to exactly `rule`');
740
+ }
741
+ });
742
+ }
743
+ }
744
+ if (spec.inputs !== undefined) {
745
+ if (!Array.isArray(spec.inputs) || spec.inputs.length === 0) red('invalid-value', 'inputs', 'a non-empty array of {n, label, value}');
746
+ else {
747
+ spec.inputs.forEach((/** @type {any} */ x, /** @type {number} */ i) => {
748
+ const at = `inputs[${i}]`;
749
+ if (!isObj(x)) { red('invalid-value', at, '{n, label, value}'); return; }
750
+ for (const key of Object.keys(x)) if (!['n', 'label', 'value'].includes(key)) red('unknown-field', `${at}.${key}`, 'input fields: n, label, value');
751
+ if (x.n !== i + 1) red('invalid-value', `${at}.n`, `inputs are numbered 1, 2, 3 in order — expected ${i + 1}`);
752
+ if (!isNonEmptyString(x.label)) red('invalid-value', `${at}.label`, 'non-empty text');
753
+ if (!isNonEmptyString(x.value)) red('invalid-value', `${at}.value`, 'non-empty text');
754
+ });
755
+ }
756
+ }
757
+
720
758
  if (spec.goal === undefined) red('missing-required', 'goal');
721
759
  else if (!isNonEmptyString(spec.goal)) red('invalid-value', 'goal', 'non-empty text — the goal is what the agent plans against');
722
760
 
@@ -757,7 +795,7 @@ function validatePlanShape(spec, red, reds) {
757
795
  // class the user picked (PRD v1.57 §2), so the gate cannot check D5 without
758
796
  // it. An unknown or locked class is refused there, on its own axis, beside
759
797
  // the counted `request-red` above.
760
- const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope });
798
+ const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null });
761
799
  for (const r of cd.reds) reds.push(r);
762
800
  if (demanded !== undefined && !CLASS_BY_CLOSE.declared.includes(demanded)) {
763
801
  // the same laundering guard as the close-type hierarchy, one level up: a
@@ -886,11 +924,11 @@ export function jobSpecHash(job) {
886
924
 
887
925
  /**
888
926
  * The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
889
- * `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
927
+ * `destination`/`source` where a spec ever carries them), the `inputs` the person pointed at (P7: Inputs is open on a reuse) and the two ceilings. Everything else in a signed spec is
890
928
  * the WORKFLOW — goal, checks, guardrails, close (the worker model is the third kind, {@link WORKFLOW_KEY_IDENTITY_FIELDS}). ONE list, shared by {@link workflowKey} and by the panel's
891
929
  * reuse (so what the key ignores is exactly what the person may edit, never two spellings).
892
930
  */
893
- export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs']);
931
+ export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs', 'inputs']);
894
932
 
895
933
  /**
896
934
  * The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
@@ -951,15 +989,15 @@ export function checkApproval(job, approvals) {
951
989
  * mismatched flag is a mistake worth stopping on, never a silent swap); the
952
990
  * same id from both is not a conflict. Extracted from scripts/run-u.mjs so
953
991
  * the decision is testable without running the script or touching a provider.
954
- * @param {{ specModel?: string, flagModel?: string, defaultModel: string }} args
992
+ * @param {{ specModel?: string, flagModel?: string, suppliedModel?: string }} args
955
993
  * `specModel` — `job.model` if the signed spec carries one, else undefined.
956
994
  * `flagModel` — the model id an explicit `--model <tier>` resolved to, else
957
995
  * undefined (a flag that was never passed, not merely a default tier name).
958
- * `defaultModel` — the runner's own default when neither spec nor flag says.
996
+ * `suppliedModel` — a model the CALLER supplies when neither spec nor flag says (bareloop keeps none of its own).
959
997
  * @returns {{ model: string, source: 'spec'|'flag'|'default' }}
960
- * @throws {Error} when the spec names a model and the flag names a different one
998
+ * @throws {Error} when the spec names a model and the flag names a different one, or when no model is named at all
961
999
  */
962
- export function resolveWorkerModel({ specModel, flagModel, defaultModel }) {
1000
+ export function resolveWorkerModel({ specModel, flagModel, suppliedModel }) {
963
1001
  if (specModel !== undefined) {
964
1002
  if (flagModel !== undefined && flagModel !== specModel) {
965
1003
  throw new Error(`spec names model "${specModel}" but --model resolved to "${flagModel}" — the signed spec wins; a mismatched --model flag is refused, never silently overridden. Drop --model or point it at the same model.`);
@@ -967,5 +1005,6 @@ export function resolveWorkerModel({ specModel, flagModel, defaultModel }) {
967
1005
  return { model: specModel, source: 'spec' };
968
1006
  }
969
1007
  if (flagModel !== undefined) return { model: flagModel, source: 'flag' };
970
- return { model: defaultModel, source: 'default' };
1008
+ if (suppliedModel === undefined) throw new Error('no model is named — a job carries its own model, or the run is given --model <id> (one ticked in Settings > Providers with both prices)');
1009
+ return { model: suppliedModel, source: 'default' };
971
1010
  }
@@ -0,0 +1,140 @@
1
+ // The "job" block's parser (P7, docs/product/PANEL-BUILD.md "Addendum 2026-10-10"): ONE owner of how the person's
2
+ // own multi-line words become numbered job lines and numbered inputs. The panel server AND the CLI interview both
3
+ // call these two functions, so the two doors can never read the same text two ways.
4
+ //
5
+ // Both are pure and $0: a malformed block is a named red `{ok: false, error}`, never a throw and never a guess.
6
+
7
+ /** @typedef {{verdict: 'PASS'|'FAIL', text: string}} JobExample a `~ PASS:` / `~ FAIL:` line (rubric only), kept with its job line */
8
+ /** @typedef {{n: number, text: string, rule: string, rules: string[], examples: JobExample[]}} JobLine one numbered job line; `rule` = its `~` lines joined with '; ' ('' when none), `rules` = those same `~` lines one by one (so an example's own '; ' survives) */
9
+ /** @typedef {{n: number, label: string, value: string}} JobInput one numbered `label: value` input line */
10
+
11
+ /** the joiner for consecutive `~` lines (fwd's joinGuardrails) */
12
+ export const RULE_JOINER = '; ';
13
+
14
+ /**
15
+ * Parse "The job" box. A plain line is a numbered job line (1, 2, 3...). A line starting `~` has no number and belongs
16
+ * to the numbered line above; consecutive `~` lines become ONE rule joined with `'; '`. A `~` line starting `PASS:` or
17
+ * `FAIL:` is also a judge example (kept with its line number); any other `~` line is a guardrail. Empty lines are
18
+ * ignored. A `~` before line 1 is a red.
19
+ * @param {unknown} text the box's raw text
20
+ * @returns {{ok: true, lines: JobLine[]}|{ok: false, error: string}}
21
+ */
22
+ export function parseJobBlock(text) {
23
+ if (typeof text !== 'string') return { ok: false, error: 'The job is required' };
24
+ /** @type {JobLine[]} */
25
+ const lines = [];
26
+ /** @type {string[]} */
27
+ let rule = [];
28
+ const flush = () => { if (lines.length) { lines[lines.length - 1].rule = rule.join(RULE_JOINER); lines[lines.length - 1].rules = rule; } rule = []; };
29
+ let row = 0;
30
+ for (const raw of text.split(/\r?\n/)) {
31
+ const t = raw.trim();
32
+ if (t === '') continue;
33
+ row += 1;
34
+ if (t.startsWith('~')) {
35
+ if (lines.length === 0) return { ok: false, error: `The job, line ${row}: a "~" line belongs to the numbered line above it, and there is none yet` };
36
+ const body = t.slice(1).trim();
37
+ if (body === '') continue;
38
+ rule.push(body);
39
+ const m = /^(PASS|FAIL):\s*(.*)$/i.exec(body);
40
+ if (m) {
41
+ if (m[2].trim() === '') return { ok: false, error: `The job, line ${row}: "${m[1].toUpperCase()}:" needs an example after it` };
42
+ lines[lines.length - 1].examples.push({ verdict: /** @type {'PASS'|'FAIL'} */ (m[1].toUpperCase()), text: m[2].trim() });
43
+ }
44
+ continue;
45
+ }
46
+ flush();
47
+ lines.push({ n: lines.length + 1, text: t, rule: '', rules: [], examples: [] });
48
+ }
49
+ flush();
50
+ if (lines.length === 0) return { ok: false, error: 'The job is required' };
51
+ return { ok: true, lines };
52
+ }
53
+
54
+ /** a label: a word or two, no colon */
55
+ const LABEL_RE = /^[A-Za-z][A-Za-z0-9_ -]{0,39}$/;
56
+
57
+ /**
58
+ * Parse the "Inputs" box: one `label: value` per line, numbered 1, 2, 3 in the order written. Empty lines are ignored.
59
+ * This only checks the SHAPE — what a value must point at (line 1 an absolute repo, lines 2+ tracked files inside it)
60
+ * is proven by the panel at $0.
61
+ * @param {unknown} text
62
+ * @returns {{ok: true, inputs: JobInput[]}|{ok: false, error: string}}
63
+ */
64
+ export function parseInputs(text) {
65
+ if (typeof text !== 'string') return { ok: false, error: 'Inputs is required' };
66
+ /** @type {JobInput[]} */
67
+ const inputs = [];
68
+ for (const raw of text.split(/\r?\n/)) {
69
+ const t = raw.trim();
70
+ if (t === '') continue;
71
+ const n = inputs.length + 1;
72
+ const i = t.indexOf(':');
73
+ const label = i < 0 ? '' : t.slice(0, i).trim();
74
+ const value = i < 0 ? '' : t.slice(i + 1).trim();
75
+ if (!LABEL_RE.test(label) || value === '') return { ok: false, error: `Inputs, line ${n}: write it as "label: value"` };
76
+ inputs.push({ n, label, value });
77
+ }
78
+ if (inputs.length === 0) return { ok: false, error: 'Inputs is required' };
79
+ return { ok: true, inputs };
80
+ }
81
+
82
+ /**
83
+ * The person's job lines as the drafter reads them (answer 1 of the interview): each numbered line, then its rule
84
+ * (the `~` lines joined) indented under it. One spelling, so the number the person typed beside a line is the number a
85
+ * stage's `fromLine` names.
86
+ * @param {JobLine[]} lines
87
+ * @returns {string}
88
+ */
89
+ export function renderJobLines(lines) {
90
+ return lines.map((l) => `${l.n}. ${l.text}${l.rule ? `\n rule: ${l.rule}` : ''}`).join('\n');
91
+ }
92
+
93
+ /**
94
+ * The PASS:/FAIL: examples the person wrote, each kept with its line number (answer 2 of a rubric interview — what the
95
+ * calibration compile reads). Empty string when there are none.
96
+ * @param {JobLine[]} lines
97
+ * @returns {string}
98
+ */
99
+ export function renderExamples(lines) {
100
+ return lines.flatMap((l) => l.examples.map((e) => `line ${l.n} ${e.verdict}: ${e.text}`)).join('\n');
101
+ }
102
+
103
+ /**
104
+ * The inputs as the drafter is told about them: "the person pointed at these inputs: N label path".
105
+ * @param {JobInput[]} inputs
106
+ * @returns {string}
107
+ */
108
+ export function renderInputs(inputs) {
109
+ return `the person pointed at these inputs: ${inputs.map((i) => `${i.n} ${i.label} ${i.value}`).join(' · ')}`;
110
+ }
111
+
112
+ /**
113
+ * The two cross-checks between a parsed job block and the Check type: a RUBRIC job needs at least one `PASS:` and one
114
+ * `FAIL:` example (the calibration compile reads them); a DETERMINISTIC job has no judge, so an example line would be
115
+ * silently dropped — a red instead.
116
+ * @param {JobLine[]} lines
117
+ * @param {boolean} rubric
118
+ * @returns {string|null} the plain red, or null when the block fits the Check type
119
+ */
120
+ export function jobBlockFitsCheck(lines, rubric) {
121
+ const all = lines.flatMap((l) => l.examples.map((e) => ({ n: l.n, ...e })));
122
+ if (!rubric) {
123
+ return all.length ? `The job, line ${all[0].n}: PASS:/FAIL: examples are for a Rubric check — a Deterministic job has no judge to read them` : null;
124
+ }
125
+ if (!all.some((e) => e.verdict === 'PASS') || !all.some((e) => e.verdict === 'FAIL')) {
126
+ return 'The job: a Rubric check needs at least one "~ PASS:" line and one "~ FAIL:" line under a job line';
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * The signed `jobLines` field from parsed lines: the person's own words, `{n, text, rule, rules}` (examples stay inside the
133
+ * rule text, where the person wrote them). `rules` is the same `~` lines as an array, BESIDE the joined `rule` (never
134
+ * replacing it); it is omitted on a line with no `~` line, and an older spec lacks it entirely.
135
+ * @param {JobLine[]} lines
136
+ * @returns {{n: number, text: string, rule: string, rules?: string[]}[]}
137
+ */
138
+ export function signedJobLines(lines) {
139
+ return lines.map((l) => ({ n: l.n, text: l.text, rule: l.rule, ...(l.rules.length ? { rules: [...l.rules] } : {}) }));
140
+ }
package/src/judged.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // THE JUDGED FLOOR'S CORE — softgreen module 1 (design record
2
- // docs/product/2026-08-17-softgreen-review-door-design.md §4, plus the
2
+ // docs/logs/2026-08-17-softgreen-review-door-design.md §4, plus the
3
3
  // 2026-08-18 threshold addendum). POC: poc/softgreen-judge/poc.mjs — validated
4
4
  // logic, rewritten here, never copied (graduation is a rewrite).
5
5
  //
@@ -66,43 +66,11 @@
66
66
  import { quoteIn } from 'bareguard';
67
67
  import { createRequire } from 'node:module';
68
68
  import { extractArtifact, priceOf, scrubRaw, rateSourceFields } from './text.js';
69
- import { resolveProvider, judgeCallOptions } from './providers.js';
69
+ import { resolveProvider } from './providers.js';
70
+ import { thinkingCallOptions } from './thinking.js';
70
71
 
71
72
  const require = createRequire(import.meta.url);
72
73
 
73
- /**
74
- * THE JUDGE'S HISTORICAL PIN. Until PRD item 32 (2026-09-10) this constant WAS
75
- * the judge: every grading path imported it, so bareloop could run a worker on
76
- * any provider and still judge only on Claude.
77
- *
78
- * It is no longer read by any grading path. The judge identity is now RESOLVED
79
- * per job (`resolveJudge` below) and travels as data — `ctx.judgeModel` into
80
- * `runJudgedFloor`, an explicit argument into the calibration gate, and
81
- * `closeDecl.calibration.judgeModel` into the signed spec, exactly as before.
82
- * Keeping the export is deliberate: it is part of the public API
83
- * (`src/index.js`) and an adopter importing it should not break on a minor.
84
- * What it means now is "the judge bareloop pinned before item 32", which is
85
- * still the right DEFAULT for an anthropic-api job and still the only tier with
86
- * established injection resistance upstream (BA-20's `INJECTION_BATTERY` is
87
- * haiku-4.5 only).
88
- *
89
- * THE ENFORCEMENT IS UNCHANGED, and still lives in three wired places because a
90
- * rule with no detector is prose (F45) — only the value being compared moved
91
- * from this constant to the resolved identity:
92
- * 1. the identity is STORED with the set it graded — `foldJudgedArtifacts`
93
- * writes it as `closeDecl.calibration.judgeModel`, inside the spec, so
94
- * `jobSpecHash` covers it by construction. Change the judge and every
95
- * signed judged spec hashes differently: the signature dies, and re-signing
96
- * is what re-runs the calibration gate.
97
- * 2. `validateCloseDecl` REQUIRES it on any stored set — a set nobody can
98
- * attribute to a judge is a floor nobody can tell apart from a moved one.
99
- * 3. `runJudgedFloor` refuses to grade when the stored identity is not the one
100
- * about to grade, naming both. It does not degrade in either direction:
101
- * grading on the new judge judges against a floor nobody calibrated, and
102
- * the old judge is not on offer.
103
- */
104
- export const JUDGE_MODEL = 'claude-haiku-4-5';
105
-
106
74
  /**
107
75
  * RESOLVE WHICH MODEL GRADES THIS JOB (PRD item 32.1).
108
76
  *
@@ -122,7 +90,7 @@ export const JUDGE_MODEL = 'claude-haiku-4-5';
122
90
  * item 31.4 a green job runs on its own key alone, and this is what lets a
123
91
  * JUDGED one do the same.
124
92
  *
125
- * There is deliberately NO third fallback to `JUDGE_MODEL`. A caller that cannot
93
+ * There is deliberately NO third fallback to a library-pinned model. A caller that cannot
126
94
  * say which model will grade has not wired a judge, and the grading path stops
127
95
  * as a wiring gap rather than grading under an identity nobody chose — the same
128
96
  * answer an absent `judgeLoop` already gets. A silent default here would be the
@@ -198,15 +166,13 @@ export function resolveJudge({ specJudge, workerProvider, workerModel } = {}) {
198
166
  *
199
167
  * @param {{provider?: string, model?: string, judge?: any}} source draft or spec
200
168
  * @param {string} fallbackProviderName used only when `source.provider` is absent
201
- * @param {(o: {specModel?: string, flagModel?: string, defaultModel: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
169
+ * @param {(o: {specModel?: string, flagModel?: string, suppliedModel?: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
202
170
  * @returns {{provider: string, model: string}}
203
171
  */
204
172
  export function resolveJobJudge(source, fallbackProviderName, resolveWorkerModelFn) {
205
173
  const providerName = source?.provider ?? fallbackProviderName;
206
- const providerEntry = resolveProvider(providerName);
207
- const { model } = resolveWorkerModelFn({
208
- specModel: source?.model, flagModel: undefined, defaultModel: providerEntry.tiers.sonnet,
209
- });
174
+ resolveProvider(providerName); // an unknown shape is the named throw, never a silent default
175
+ const { model } = resolveWorkerModelFn({ specModel: source?.model, flagModel: undefined });
210
176
  return resolveJudge({ specJudge: source?.judge, workerProvider: providerName, workerModel: model });
211
177
  }
212
178
 
@@ -1089,9 +1055,11 @@ function topLevelFunctionNames(artifactText) {
1089
1055
  * so the priced seam is the only legal one.
1090
1056
  * `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
1091
1057
  * `rates`); null/absent = bare-agent's built-in guess, exactly as before.
1092
- * @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o
1058
+ * `thinking` is the job's SIGNED "Thinking on" tick (false/absent = off): the judge follows the signed value — so the calibration gate
1059
+ * (10/10) and the later grading run with the SAME tick — and bare-agent decides what each model can switch (src/thinking.js).
1060
+ * @param {{provider: any, system: string, rates?: {in: number, out: number}|null, thinking?: boolean}} o
1093
1061
  * @returns {{run: (msgs: any[], tools: any[], opts?: any) => Promise<any>}} */
1094
- export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
1062
+ export const defaultJudgeLoop = ({ provider, system, rates = null, thinking = false }) => {
1095
1063
  const { Loop } = require('bare-agent');
1096
1064
  // BA-21: the Loop's run RESULT carries no `rateSource` — only the per-call metering event
1097
1065
  // does (`onLlmResult`). Capture it there and ride it on the result, so `runLocate` can
@@ -1107,7 +1075,7 @@ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
1107
1075
  return {
1108
1076
  run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
1109
1077
  seen = null;
1110
- const res = await loop.run(msgs, tools, { ...judgeCallOptions(provider), ...opts });
1078
+ const res = await loop.run(msgs, tools, { ...thinkingCallOptions(provider, thinking === true ? 'on' : 'off'), ...opts });
1111
1079
  return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
1112
1080
  },
1113
1081
  };
package/src/keysfile.js CHANGED
@@ -76,18 +76,24 @@ export function filledKeyNames(home) {
76
76
  }
77
77
 
78
78
  /**
79
- * Create the keys file with the five empty preset lines when — and only when — it does not
80
- * exist (mode 600). An existing file is never touched.
81
- * @param {readonly string[]} presetNames the names to seed, in order
79
+ * The two comment lines a fresh keys file is created with — no provider name, no preset key.
80
+ * Each key the person types becomes a row in Settings > Providers.
81
+ */
82
+ export const KEYS_FILE_BODY = '# bareloop keys — type NAME=value by hand; this file is never shown or written by bareloop.\n'
83
+ + '# One line per provider key, e.g. MY_PROVIDER_API_KEY=... ; each key becomes a row in Settings > Providers.\n';
84
+
85
+ /**
86
+ * Create the keys file with {@link KEYS_FILE_BODY} when — and only when — it does not
87
+ * exist (mode 600, dir 700). An existing file is never touched.
82
88
  * @param {string} [home]
83
89
  * @returns {boolean} true if it was created
84
90
  */
85
- export function ensureKeysFile(presetNames, home) {
91
+ export function ensureKeysFile(home) {
86
92
  const file = keysFilePath(home);
87
93
  if (existsSync(file)) return false;
88
- mkdirSync(keysHome(home), { recursive: true });
94
+ mkdirSync(keysHome(home), { recursive: true, mode: 0o700 });
89
95
  try {
90
- writeFileSync(file, presetNames.map((n) => `${n}=\n`).join(''), { mode: 0o600, flag: 'wx' });
96
+ writeFileSync(file, KEYS_FILE_BODY, { mode: 0o600, flag: 'wx' });
91
97
  } catch (/** @type {any} */ e) {
92
98
  if (e?.code === 'EEXIST') return false; // lost a race: the file is there, untouched
93
99
  throw e;
@@ -101,6 +107,8 @@ export function ensureKeysFile(presetNames, home) {
101
107
  * @property {string[]} names the NAMES the file defines (sorted; never values)
102
108
  * @property {string|null} warning a plain-words warning (file readable by others, or
103
109
  * unreadable), else null — a warning, never a refusal
110
+ * @property {boolean} [unreadable] true only when the file is there but could not be read (the Settings row then
111
+ * reads "refused — see above"); absent otherwise
104
112
  * @property {Record<string,string|undefined>} env shell env with the file filling the
105
113
  * names the shell does not set. THE ONLY place values leave this module.
106
114
  */
@@ -127,7 +135,7 @@ export function loadKeysEnv(opts = {}) {
127
135
  try {
128
136
  parsed = parseKeysText(readFileSync(file, 'utf8'));
129
137
  } catch (/** @type {any} */ e) {
130
- return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, env: { ...base } };
138
+ return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, unreadable: true, env: { ...base } };
131
139
  }
132
140
  /** @type {Record<string,string|undefined>} */
133
141
  const env = { ...base };
package/src/kinds.js CHANGED
@@ -1776,7 +1776,7 @@ async function runJudgedFloor(stage, ctx) {
1776
1776
  + `${MAX_JUDGED_PATHS} — a judged stage buys one call per artifact, and a runaway list is a bill`, {}, STOP_FAULTS.CRASHED);
1777
1777
  }
1778
1778
  // 1b. THE TIER THE SIGNED SET WAS GRADED BY, against the tier this run would buy.
1779
- // `JUDGE_MODEL` has always SAID a bump forces a full recalibration; this is the
1779
+ // the judge model has always SAID a bump forces a full recalibration; this is the
1780
1780
  // detector, because a rule with no wired detector is prose (F45). The stamp comes
1781
1781
  // off the SIGNED calibration (`calibration.judgeModel`, carried across by
1782
1782
  // `declaredStages`), so a mismatch means exactly one thing: the pin moved under a
@@ -1799,7 +1799,7 @@ async function runJudgedFloor(stage, ctx) {
1799
1799
  // model). The IDENTITY is checked before the seam and before the stamp, because
1800
1800
  // an unwired identity makes both of the messages below unwritable: neither
1801
1801
  // "would grade with X" nor "the judge is X" can be said by a stage that cannot
1802
- // name X. There is deliberately NO fall-back to `JUDGE_MODEL` here — a run that
1802
+ // name X. There is deliberately NO library-pinned fall-back here — a run that
1803
1803
  // cannot say which model grades has not wired a judge, and grading under an
1804
1804
  // identity nobody chose is exactly the silent-ignore class this repo refuses
1805
1805
  // everywhere else.
package/src/ledger.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The upstream ledger (module 4) — reactive lib/primitive incident monitoring.
2
- // Design record: docs/product/2026-07-11-upstream-ledger-design.md (adaptlearn
2
+ // Design record: docs/logs/2026-07-11-upstream-ledger-design.md (adaptlearn
3
3
  // POC'd spec) + its 2026-07-13 addendum: the bareloop event mapping. One
4
4
  // append-only JSONL both the consumer (the panel, N6) and the maintainer read;
5
5
  // the A1/A2/A3 upstream-ask flow, mechanized — evidence in, human judgment out.
@@ -0,0 +1,84 @@
1
+ // rewritten-from: fwdloop src/panel/settings.js@f9449e8 (`authHeaders`, `getWithKey`, `idsOf`, `listModels`)
2
+ // PANEL-BUILD.md P8 — the ONE `<baseUrl>/models` GET. Settings > Providers' Test and Refresh and the
3
+ // New job card's model-readiness check (`checkProviderReachable`, src/providers.js) all call it; no
4
+ // second copy of the probe exists. It is a GET of a models LIST, never a completion, so it spends
5
+ // no token and writes no spend row. The key goes in a header only and is never returned or logged.
6
+ // A redirect is NOT followed (`redirect:'manual'`), so the key is never sent on to another host.
7
+ // There is no default host: a blank Base URL makes no call.
8
+
9
+ /** The default deadline for the one GET. */
10
+ export const LIST_TIMEOUT_MS = 4000;
11
+
12
+ /**
13
+ * The key goes only in a header, in the form each shape expects.
14
+ * @param {string} shape an API shape id (src/providershapes.js)
15
+ * @param {string} key
16
+ * @returns {Record<string, string>}
17
+ */
18
+ export function authHeaders(shape, key) {
19
+ if (shape === 'anthropic-api') return { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
20
+ if (shape === 'gemini-api') return { 'x-goog-api-key': key };
21
+ return { Authorization: `Bearer ${key}` };
22
+ }
23
+
24
+ /**
25
+ * The model ids a provider's list answer holds, per API shape (`data[].id` for two shapes;
26
+ * `models[].name` minus its `models/` prefix for the third). Sorted, de-duplicated.
27
+ * @param {string} shape
28
+ * @param {any} body
29
+ * @returns {string[]}
30
+ */
31
+ export function idsOf(shape, body) {
32
+ const list = shape === 'gemini-api' ? body?.models : body?.data;
33
+ const key = shape === 'gemini-api' ? 'name' : 'id';
34
+ const ids = (Array.isArray(list) ? list : [])
35
+ .map((/** @type {any} */ m) => (typeof m?.[key] === 'string' ? m[key].replace(/^models\//, '') : ''))
36
+ .filter((id) => id !== '');
37
+ return [...new Set(ids)].sort();
38
+ }
39
+
40
+ /**
41
+ * @typedef {{ ok: true, models: string[], ms: number }
42
+ * | { ok: false, why: string, status: string, detail?: string }} ListResult
43
+ */
44
+
45
+ /**
46
+ * One free GET of `<baseUrl>/models` with the key in the header only and a deadline.
47
+ * Failure is data, never a throw: `why` is a plain sentence, `status` a short machine word
48
+ * (`no-base-url`, `redirect`, `HTTP <n>`, `timeout`, `unreachable`) for callers that need it.
49
+ * @param {object} o
50
+ * @param {string} o.shape an API shape id
51
+ * @param {string} o.baseUrl the row's Base URL ('' = no call)
52
+ * @param {string} o.key the key value (header only)
53
+ * @param {typeof fetch} [o.fetchImpl] test seam; defaults to the global `fetch`
54
+ * @param {number} [o.timeoutMs]
55
+ * @returns {Promise<ListResult>}
56
+ */
57
+ export async function listModels({ shape, baseUrl, key, fetchImpl = fetch, timeoutMs = LIST_TIMEOUT_MS }) {
58
+ const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(/\/+$/, '') : '';
59
+ if (base === '') return { ok: false, why: 'Type a Base URL.', status: 'no-base-url' };
60
+ const url = `${base}/models`;
61
+ const started = Date.now();
62
+ const ctl = new AbortController();
63
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
64
+ try {
65
+ const res = await fetchImpl(url, { method: 'GET', headers: authHeaders(shape, key), signal: ctl.signal, redirect: 'manual' });
66
+ if (res.status >= 300 && res.status < 400) {
67
+ return { ok: false, why: 'The provider answered with a redirect; the key was not sent on. Check the Base URL.', status: 'redirect' };
68
+ }
69
+ if (res.status === 401 || res.status === 403) return { ok: false, why: 'The provider refused the key.', status: `HTTP ${res.status}` };
70
+ if (!res.ok) return { ok: false, why: 'The provider answered with an error.', status: `HTTP ${res.status}` };
71
+ /** @type {any} */
72
+ let body = null;
73
+ try { body = await res.json(); } catch { body = null; }
74
+ return { ok: true, models: idsOf(shape, body), ms: Date.now() - started };
75
+ } catch (/** @type {any} */ e) {
76
+ if (e?.name === 'AbortError') return { ok: false, why: 'The provider did not answer in 4 seconds.', status: 'timeout' };
77
+ const cls = e?.code || e?.name || 'network-error';
78
+ // connection refused surfaces as fetch's bare TypeError ("fetch failed")
79
+ const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
80
+ return { ok: false, why: 'Could not reach the provider.', status: String(cls), detail: refused ? `nothing answering at ${url}` : `request failed: ${cls}` };
81
+ } finally {
82
+ clearTimeout(timer);
83
+ }
84
+ }
package/src/monthly.js CHANGED
@@ -33,37 +33,10 @@ import { readOrphanDraftSpends } from './draftspend.js';
33
33
  // the per-leg / per-run spend readers live with the ledger (one home, no import cycle through the replay reader)
34
34
  export { legSpend, runSpend, chainSpend };
35
35
 
36
- // The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
37
- // the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
38
- // (an archived run keeps its provider whatever Settings holds today) — not the Providers
39
- // rows, which come from the keys file (src/providerrows.js).
40
- /** @type {readonly {id: string, name: string}[]} */
41
- const MONEY_LABELS = Object.freeze([
42
- { id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
43
- { id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
44
- ]);
45
-
46
- /**
47
- * Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
48
- * is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
49
- * never silently pooled into OpenAI's figures).
50
- * @param {string|null|undefined} provider
51
- * @param {string|null|undefined} baseUrl
52
- * @returns {string|null} a MONEY_LABELS id, or null
53
- */
54
- function rowIdFor(provider, baseUrl) {
55
- if (provider === 'anthropic-api') return 'anthropic';
56
- if (provider === 'gemini-api') return 'gemini';
57
- if (provider === 'openai-api') {
58
- if (!baseUrl) return 'openai';
59
- try {
60
- const host = new URL(baseUrl).hostname;
61
- if (host === 'api.deepseek.com') return 'deepseek';
62
- } catch { /* an unparseable override is no known vendor */ }
63
- return null;
64
- }
65
- return null;
66
- }
36
+ // The Money tab's per-provider breakdown groups a run by the Providers ROW that served it (its
37
+ // key variable name, `src/providerrows.js` `findRow`); a run no row claims lands under its own
38
+ // `<shape> @ <baseUrl>`. This is HISTORY labelling for spend already recorded (an archived run
39
+ // keeps its provider whatever Settings holds today). No vendor name or host is known here.
67
40
 
68
41
  /**
69
42
  * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
@@ -289,11 +262,11 @@ export function monthSpend(opts = {}) {
289
262
 
290
263
  /**
291
264
  * The Money tab's figures: all-time and this-month spend, and per-provider breakdown
292
- * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
293
- * pooled into another row). Any leg with unknown spend makes the figure it belongs to
265
+ * (grouped by the matched Providers row's key variable name; a run on no row lands under its
266
+ * own provider name, never pooled into another row). Any leg with unknown spend makes the figure it belongs to
294
267
  * an "at least".
295
268
  * @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
296
- * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
269
+ * @returns {{ tokensByRow: Record<string, number>, tokensByModel: Record<string, Record<string, number>>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
297
270
  * byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
298
271
  */
299
272
  export function spendSummary(opts = {}) {
@@ -307,22 +280,29 @@ export function spendSummary(opts = {}) {
307
280
  const wallSeen = {};
308
281
  /** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
309
282
  const tokensByRow = {};
283
+ /** tokens per model line of each row, from the spend rows that NAME the model; absent = no row names it (never 0) @type {Record<string, Record<string, number>>} */
284
+ const tokensByModel = {};
310
285
  for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
311
286
  let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
312
- // an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
313
- // equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
287
+ // an older spine records a model but no provider: it counts toward the row with a model line EXACTLY
288
+ // named that model. Two rows naming it make it ambiguous, so it counts toward neither.
314
289
  if (opts.rows && leg.provider === null && leg.model !== null) {
315
- const named = opts.rows.filter((r) => r.name === leg.model);
290
+ const named = opts.rows.filter((r) => r.models.some((l) => l.model === leg.model));
316
291
  served = named.length === 1 ? named[0] : null;
317
292
  }
318
- if (served) tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
293
+ if (served) {
294
+ tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
295
+ if (typeof leg.model === 'string' && leg.model !== '') {
296
+ const own = (tokensByModel[served.envName] ??= {});
297
+ own[leg.model] = (own[leg.model] ?? 0) + leg.tokens;
298
+ }
299
+ }
319
300
  const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
320
- const id = rowIdFor(leg.provider, leg.baseUrl);
321
301
  // an older spine carries a model but no provider: say so honestly, one row per model —
322
302
  // never a provider guessed from the model's name
323
- const notRecorded = id === null && leg.provider === null;
324
- const key = id ?? (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
325
- const label = id ? (MONEY_LABELS.find((r) => r.id === id)?.name ?? id)
303
+ const notRecorded = !served && leg.provider === null;
304
+ const key = served ? served.envName : (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
305
+ const label = served ? served.envName
326
306
  : (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
327
307
  const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
328
308
  const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
@@ -349,7 +329,7 @@ export function spendSummary(opts = {}) {
349
329
  if (w.totalKnown === 0) p.totalWallMs = null;
350
330
  if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
351
331
  }
352
- return { tokensByRow, total, month, byProvider };
332
+ return { tokensByRow, tokensByModel, total, month, byProvider };
353
333
  }
354
334
 
355
335
  /**