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
@@ -20,14 +20,14 @@
20
20
  // `src/panel/server.js`'s POST routes). Nothing here changes what a step
21
21
  // means or what an answer does — only where the answer comes from.
22
22
 
23
- import { applyConfiguredKey, judgeRatesFor, keyNameFor, modelChoiceFor, ratesFor, rowsForHome } from '../providerrows.js';
23
+ import { judgePick, judgeRatesFor, judgeRowFor, keyValueOf, modelChoiceFor, ratesOfLine, rowsForHome } from '../providerrows.js';
24
24
  import { ConfigError } from '../config.js';
25
25
  import {
26
26
  mkdirSync, existsSync, writeFileSync, readdirSync,
27
27
  } from 'node:fs';
28
28
  import { join, resolve, dirname } from 'node:path';
29
29
  import {
30
- prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
30
+ prepareSource, proveDestination, proveRubricInputs, looksLikeRubricInput, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
31
31
  } from '../source.js';
32
32
  import {
33
33
  worktreePath, uncommittedCount, shortHead, removeWorktree,
@@ -45,14 +45,17 @@ import {
45
45
  questionsFor, requiredAnswersFor, makeLoopGenerate, CONFIRM_SYSTEM, SOFTGREEN_JUDGE_EXAMPLES_KEY,
46
46
  } from '../authorflow.js';
47
47
  import {
48
- resolveProvider, buildRunnerProviders, apiKeyProblem, assertDraftThinking, draftThinkingFor,
48
+ resolveProvider, buildRunnerProviders, apiKeyProblem,
49
49
  } from '../providers.js';
50
50
  import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
51
+ import { assertThinkingMode, thinkingPlanFor, thinkingRowText } from '../thinking.js';
51
52
  import { closeJudges } from '../kinds.js';
53
+ import { RUBRIC_LANG } from '../authoring.js';
54
+ import { rubricGoal, reconcileRubricPlan } from '../rubricstep.js';
52
55
  import { redactSecrets } from '../validate.js';
53
56
  import { tallyCalls } from '../text.js';
54
57
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
55
- import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, calibrationSummary } from '../authorreadout.js';
58
+ import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
56
59
  import { runNpmCi, NPM_CI_LOCKS } from '../npminstall.js';
57
60
  import {
58
61
  parseJobBlock, parseInputs, jobBlockFitsCheck, renderJobLines, renderExamples, signedJobLines,
@@ -74,6 +77,7 @@ export const STEP_LABELS = Object.freeze({
74
77
  reuse: 'reusing signed workflow',
75
78
  read: 'reading repo',
76
79
  scout: 'scouting repo',
80
+ look: 'looking at your input',
77
81
  listing: 'listing files',
78
82
  confirm: 'confirming plan',
79
83
  draft: 'drafting',
@@ -83,6 +87,16 @@ export const STEP_LABELS = Object.freeze({
83
87
  signed: 'signed hash',
84
88
  });
85
89
 
90
+ /**
91
+ * The same steps, worded for a job on the person's own local FILES (Part 1a): there is no worktree and no repo to read.
92
+ * Code-owned, one table beside STEP_LABELS; a step with no entry keeps its STEP_LABELS word.
93
+ * @type {Readonly<Record<string, string>>}
94
+ */
95
+ export const FILE_JOB_STEP_LABELS = Object.freeze({
96
+ copy: 'copying input files',
97
+ read: 'reading input files',
98
+ });
99
+
86
100
  /**
87
101
  * The library's own `onPhase(name, ...)` calls (`src/authorjob.js`/`src/authorflow.js`) -> the step each one advances.
88
102
  * A phase with no entry leaves the current step as it was.
@@ -100,6 +114,8 @@ export const PHASE_STEP = Object.freeze({
100
114
  seed: 'read',
101
115
  scout: 'scout',
102
116
  'scout-done': 'scout',
117
+ look: 'look',
118
+ 'look-done': 'look',
103
119
  confirm: 'confirm',
104
120
  'confirm-turn-done': 'confirm',
105
121
  listing: 'listing',
@@ -110,19 +126,7 @@ export const PHASE_STEP = Object.freeze({
110
126
  'rubric-scrubbed': 'calibrate',
111
127
  });
112
128
 
113
- /**
114
- * The first drafting row's words (hamr 2026-10-11) — code-owned, one owner, ALWAYS starting "thinking on" or "thinking off".
115
- * `sent` is what the drafting calls carry (`draftThinkingFor`); `thoughtAnyway` = a call reported `thinkingApplied === false`
116
- * after 'off' was sent (bare-agent saw reasoning in the reply).
117
- * @param {'on'|'off'|'not-switchable'|'unverified'} sent @param {boolean} [thoughtAnyway]
118
- * @returns {string}
119
- */
120
- export function thinkingRowText(sent, thoughtAnyway = false) {
121
- if (sent === 'off') return thoughtAnyway ? 'thinking on (the model thought anyway)' : 'thinking off';
122
- if (sent === 'on') return 'thinking on';
123
- if (sent === 'not-switchable') return "thinking on (this model can't turn it off)";
124
- return 'thinking on (not verified for this model)';
125
- }
129
+ export { thinkingRowText };
126
130
 
127
131
  /** @typedef {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}} StepLine */
128
132
 
@@ -138,12 +142,13 @@ export function latestStep(steps, id) {
138
142
  * earlier line keeps its status and detail). Existing lines are never moved. Re-starting the step that is still the
139
143
  * LAST line just resumes that line.
140
144
  * @param {StepLine[]} steps @param {string} id @param {string} [detail] (absent = keep what the line has)
145
+ * @param {Readonly<Record<string, string>>} [labels] the label table (a file job passes its own)
141
146
  */
142
- export function advanceSteps(steps, id, detail) {
147
+ export function advanceSteps(steps, id, detail, labels = STEP_LABELS) {
143
148
  for (const x of steps) if (x.status === 'running' && x.id !== id) x.status = 'done';
144
149
  const last = steps.at(-1);
145
150
  if (last && last.id === id) { last.status = 'running'; if (detail !== undefined) last.detail = detail; return; }
146
- steps.push({ id, label: STEP_LABELS[id] ?? id, status: 'running', detail: detail ?? '' });
151
+ steps.push({ id, label: labels[id] ?? id, status: 'running', detail: detail ?? '' });
147
152
  }
148
153
 
149
154
  /** source-door refusals that come from CHECKING the copied source (the scan/freeze), not from reaching it */
@@ -194,7 +199,7 @@ export function jobNameTaken(name, opts = {}) {
194
199
  }
195
200
 
196
201
  /** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
197
- export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'model', 'jobText', 'inputs', 'destination', 'capUsd', 'maxWallMs']);
202
+ export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'provider', 'model', 'thinking', 'jobText', 'inputs', 'destination', 'capUsd', 'maxWallMs']);
198
203
 
199
204
  /**
200
205
  * The repo a card names: Inputs line 1 (P7 — the Source box is gone; the first input IS the repo). `''` when the Inputs
@@ -268,13 +273,13 @@ export function buildReuseSpec(originSpec, card, rows) {
268
273
  spec.budgetUsd = card.capUsd;
269
274
  if (typeof card.maxWallMs === 'number' && Number.isFinite(card.maxWallMs) && card.maxWallMs > 0) spec.maxWallMs = card.maxWallMs;
270
275
  else delete spec.maxWallMs;
271
- const choice = rows && typeof card.model === 'string' ? modelChoiceFor(rows, card.model) : null;
276
+ const choice = rows && typeof card.model === 'string' ? modelChoiceFor(rows, { provider: card.provider, model: card.model }) : null;
272
277
  if (choice) {
273
278
  if (spec.verdictType === 'soft-green' && !spec.judge) spec.judge = resolveJobJudge(originSpec, originSpec.provider ?? choice.provider, resolveWorkerModel);
274
- const entry = resolveProvider(choice.provider);
279
+ resolveProvider(choice.provider);
275
280
  spec.provider = choice.provider;
276
281
  if (choice.baseUrl) spec.baseUrl = choice.baseUrl; else delete spec.baseUrl;
277
- if (choice.name !== entry.tiers.sonnet) spec.model = choice.name; else delete spec.model;
282
+ spec.model = choice.name; // a job always carries its own model
278
283
  }
279
284
  return spec;
280
285
  }
@@ -289,7 +294,8 @@ export function buildReuseSpec(originSpec, card, rows) {
289
294
  */
290
295
  export function validateReuseCard(card, opts = {}) {
291
296
  if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
292
- if (!modelChoiceFor(opts.rows ?? [], card.model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
297
+ const pick = judgePick(opts.rows ?? [], { provider: card.provider, model: card.model });
298
+ if (!pick.ok) return { ok: false, error: pick.say };
293
299
  const ip = parseInputs(card.inputs);
294
300
  if (!ip.ok) return { ok: false, error: ip.error };
295
301
  if (typeof card.destination !== 'string' || card.destination.trim() === '') return { ok: false, error: 'Destination is required' };
@@ -332,13 +338,18 @@ export function validateJobCard(card, opts = {}) {
332
338
  return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
333
339
  }
334
340
  if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
335
- if (!modelChoiceFor(opts.rows ?? [], model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
341
+ const pick = judgePick(opts.rows ?? [], { provider: card.provider, model });
342
+ if (!pick.ok) return { ok: false, error: pick.say };
343
+ if (card.thinking !== undefined && typeof card.thinking !== 'boolean') return { ok: false, error: 'Thinking on must be on or off' };
336
344
  const jb = parseJobBlock(card.jobText);
337
345
  if (!jb.ok) return { ok: false, error: jb.error };
338
- const misfit = jobBlockFitsCheck(jb.lines, checkType === 'rubric');
339
- if (misfit) return { ok: false, error: misfit };
340
346
  const ib = parseInputs(card.inputs);
341
347
  if (!ib.ok) return { ok: false, error: ib.error };
348
+ // P1=B: PASS/FAIL examples are required of a rubric job only when it holds a judged part. A rubric on the person's own
349
+ // local files (first input not a repo) holds none in this build; a rubric on a repo composes the judged floor.
350
+ const rubricFiles = checkType === 'rubric' && looksLikeRubricInput(ib.inputs[0].value);
351
+ const misfit = jobBlockFitsCheck(jb.lines, checkType === 'rubric', { judged: !rubricFiles });
352
+ if (misfit) return { ok: false, error: misfit };
342
353
  if (typeof destination !== 'string' || destination.trim() === '') return { ok: false, error: 'Destination is required' };
343
354
  if (typeof capUsd !== 'number' || !Number.isFinite(capUsd) || capUsd <= 0) {
344
355
  return { ok: false, error: '$ cap is required and must be a positive number' };
@@ -354,16 +365,16 @@ export function validateJobCard(card, opts = {}) {
354
365
  * @param {any} card the validated job card (see {@link validateJobCard})
355
366
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
356
367
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
357
- * prepareSigningFn?: Function, npmCiFn?: Function, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
368
+ * prepareSigningFn?: Function, npmCiFn?: Function, lookFn?: Function|null, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
358
369
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
359
370
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
360
371
  * @returns {any} the session object
361
372
  */
362
373
  export function createSession(card, deps = {}) {
363
374
  const env = deps.env ?? process.env;
364
- // The drafter's THINKING knob ('on' | 'off'): an explicit operator option, never read from env and not in the panel UI. A bad
365
- // value throws here, before a session exists. Default 'off' (hamr ruling 2026-10-11, after the paid ON/OFF contrast).
366
- const draftThinking = assertDraftThinking(deps.draftThinking ?? 'off');
375
+ // The drafter's THINKING mode ('on' | 'off'): the card's "Thinking on" tick (unticked = 'off'), signed into the job only when ticked.
376
+ // `deps.draftThinking` is an operator override for tests/probes; a bad value throws here, before a session exists.
377
+ const draftThinking = assertThinkingMode(deps.draftThinking ?? (card.thinking === true ? 'on' : 'off'));
367
378
  // P7: the person's own words, parsed ONCE by the one parser (src/jobblock.js). The card validator refused a malformed
368
379
  // block before a session exists; a reuse card's job text is the origin's and is not drafted from.
369
380
  const jobParsed = parseJobBlock(card.jobText);
@@ -440,10 +451,13 @@ export function createSession(card, deps = {}) {
440
451
  });
441
452
  };
442
453
 
454
+ /** a rubric job on local files words its copy/read steps for files (set once the card is known) */
455
+ let fileJob = card.checkType === 'rubric' && looksLikeRubricInput(parseInputs(card.inputs).ok ? (/** @type {any} */ (parseInputs(card.inputs))).inputs[0].value : '');
443
456
  /** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
444
457
  const stepStart = (id, detail) => {
445
- advanceSteps(state.steps, id, detail);
446
- state.progressLabel = STEP_LABELS[id] ?? id;
458
+ const labels = fileJob ? { ...STEP_LABELS, ...FILE_JOB_STEP_LABELS } : STEP_LABELS;
459
+ advanceSteps(state.steps, id, detail, labels);
460
+ state.progressLabel = labels[id] ?? id;
447
461
  logSteps();
448
462
  };
449
463
  /** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
@@ -508,6 +522,7 @@ export function createSession(card, deps = {}) {
508
522
  return true;
509
523
  };
510
524
 
525
+ let lastTryRefused = false;
511
526
  const onPhase = (name, data = {}) => {
512
527
  // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
513
528
  // to also `say('system', `… ${name}`)`, one jargon chat bubble per
@@ -518,10 +533,24 @@ export function createSession(card, deps = {}) {
518
533
  // person needs to read; the step-by-step detail lives in one line.
519
534
  // ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
520
535
  if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
521
- // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is a RETRY: the line says so in plain words, so a
522
- // long silence on call 2 of 3 does not read as a hang. The same one line, relabelled — never a second line.
536
+ // WHY a try was refused: the drafting log keeps it beside the call that made it (it used to say only "retry 3 of 3"), and the
537
+ // progress line shows it briefly, in code's words
538
+ if (name === 'author-try-failed') {
539
+ lastTryRefused = true;
540
+ const no = state.steps.findLastIndex((/** @type {any} */ x) => x.id === 'draft');
541
+ const reason = tryRefusedText(data);
542
+ appendDraftLog(outDir, {
543
+ kind: 'try-refused', no: no === -1 ? state.steps.length - 1 : no, call: String(data.call ?? ''), reason,
544
+ ...(Array.isArray(data.reds) && data.reds.length ? { reds: redsRecord(data.reds) } : {}), ...declarationRecord(data.declaration), at: new Date().toISOString(),
545
+ });
546
+ stepDetail('draft', `${data.call}: refused — ${reason.length > 160 ? `${reason.slice(0, 160)}…` : reason}`);
547
+ }
548
+ // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is relabelled in plain words, so a long silence on call 2
549
+ // of 3 does not read as a hang. The loop drafts again after a try that PASSED too (the drafter sees its own seed-read), so
550
+ // "retry" is said only when the previous try was actually refused; otherwise it is "look". The same one line, never a second one.
523
551
  if (name === 'author-call' && Number.isInteger(data.i) && data.i >= 1 && Number.isInteger(data.of)) {
524
- const label = `${STEP_LABELS.draft} (retry ${data.i + 1} of ${data.of + 1})`;
552
+ const label = `${STEP_LABELS.draft} (${lastTryRefused ? 'retry' : 'look'} ${data.i + 1} of ${data.of + 1})`;
553
+ lastTryRefused = false;
525
554
  stepLabel('draft', label);
526
555
  state.progressLabel = label;
527
556
  }
@@ -557,16 +586,22 @@ export function createSession(card, deps = {}) {
557
586
  // the call's label, the drafting model, its cost (null stays null: unpriced is never $0). One owner: this onCall.
558
587
  const runningNo = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
559
588
  const callNo = runningNo === -1 ? state.steps.length - 1 : runningNo;
560
- // bare-agent's own verdict on the call's run (true | false | null), present only when thinkingMode was sent. 'off' sent but
561
- // reasoning seen (false) turns the first drafting row into "thinking on (the model thought anyway)" and stays so.
562
- if (draftThinkingLogged === 'off' && call.thinkingApplied === false && !thoughtAnyway) {
563
- thoughtAnyway = true;
589
+ // bare-agent's own verdict on the call's run — `thinkingStatus`: 'applied' | 'unsupported' | 'ignored' | 'unknown' — present only
590
+ // when thinkingMode was sent. The first drafting row then says what REALLY happened ("the model thought anyway", "can't change it",
591
+ // "not confirmed"); a row that moved to a worse word never moves back.
592
+ if (draftThinkingPlan !== null && call.thinkingStatus !== undefined && call.thinkingStatus !== thinkingStatusSeen
593
+ && !(thinkingStatusSeen !== null && thinkingStatusSeen !== 'applied' && call.thinkingStatus === 'applied')) {
594
+ thinkingStatusSeen = call.thinkingStatus;
564
595
  const row = latestStep(state.steps, 'thinking');
565
- if (row) { row.label = thinkingRowText('off', true); appendDraftLog(outDir, { kind: 'step-label', no: state.steps.indexOf(row), label: row.label, at: new Date().toISOString() }); }
596
+ if (row) {
597
+ row.label = thinkingRowText({ mode: draftThinkingPlan.mode, support: draftThinkingPlan.support, status: call.thinkingStatus });
598
+ appendDraftLog(outDir, { kind: 'step-label', no: state.steps.indexOf(row), label: row.label, at: new Date().toISOString() });
599
+ }
566
600
  }
567
601
  appendDraftLog(outDir, {
568
602
  kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
569
- thinking: draftThinkingLogged, ...(call.thinkingApplied === undefined ? {} : { thinkingApplied: call.thinkingApplied }),
603
+ thinking: draftThinkingPlan === null ? null : draftThinkingPlan.mode, thinkingSupport: draftThinkingPlan === null ? null : draftThinkingPlan.support,
604
+ ...(call.thinkingStatus === undefined ? {} : { thinkingStatus: call.thinkingStatus }),
570
605
  costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
571
606
  });
572
607
  try {
@@ -578,10 +613,11 @@ export function createSession(card, deps = {}) {
578
613
  };
579
614
  /** @type {string|null} */
580
615
  let draftStartedAt = null;
581
- /** what the drafting calls really carry for thinking ('on' | 'off' | 'not-switchable' | 'unverified'), set once the Model Name resolves; null before @type {string|null} */
582
- let draftThinkingLogged = null;
583
- /** a drafting call reported thinkingApplied === false after 'off' was sent */
584
- let thoughtAnyway = false;
616
+ /** what the drafting calls will carry for thinking — the tick's mode and bare-agent's support answer — set once the Model resolves; null before
617
+ * @type {ReturnType<typeof thinkingPlanFor>|null} */
618
+ let draftThinkingPlan = null;
619
+ /** the worst-so-far `thinkingStatus` a drafting call reported (null until one did) @type {string|null} */
620
+ let thinkingStatusSeen = null;
585
621
  /** P6 item 1: the worktree this session made in the person's repo (null until made, and again once removed) @type {{repo: string, dir: string}|null} */
586
622
  let madeWorktree = null;
587
623
  /** a session that ends WITHOUT ever running (refused, errored, abandoned) takes its worktree with it; a signed one keeps it for the run */
@@ -607,36 +643,37 @@ export function createSession(card, deps = {}) {
607
643
 
608
644
  async function run() {
609
645
  // ── keys, at $0, before anything is prepared ────────────────────────
610
- const keyRows = rowsForHome(configHome);
611
- const modelChoice = modelChoiceFor(keyRows, card.model);
646
+ const keyRows = rowsForHome(configHome, env);
647
+ const pick = judgePick(keyRows, { provider: card.provider, model: card.model });
648
+ const modelChoice = pick.ok ? pick.choice : null;
612
649
  // hamr 2026-10-11: the FIRST drafting row says whether the drafter thinks, before any other line ("checking setup" follows).
613
650
  // A Reuse session drafts nothing, so it has no row. A provider shape that cannot be built is refused below, in words.
614
651
  if (modelChoice && deps.reuse === undefined) {
615
652
  try {
616
- draftThinkingLogged = draftThinkingFor({ providerName: modelChoice.provider, model: modelChoice.name, baseUrl: modelChoice.baseUrl ?? null }, draftThinking);
617
- state.steps.push({ id: 'thinking', label: thinkingRowText(/** @type {any} */ (draftThinkingLogged)), status: 'done', detail: '' });
653
+ draftThinkingPlan = thinkingPlanFor({ providerName: modelChoice.provider, model: modelChoice.name, baseUrl: modelChoice.baseUrl ?? null }, draftThinking);
654
+ state.steps.push({ id: 'thinking', label: thinkingRowText({ mode: draftThinkingPlan.mode, support: draftThinkingPlan.support }), status: 'done', detail: '' });
618
655
  logSteps();
619
- } catch { draftThinkingLogged = null; }
656
+ } catch { draftThinkingPlan = null; }
620
657
  }
621
658
  stepStart('setup');
622
- if (!modelChoice) { refuse(`Model "${card.model}" is not a Name in Settings > Providers. Stopped — nothing spent.`); return; }
659
+ if (!modelChoice) { refuse(pick.ok ? 'Pick a model. Nothing was spent.' : pick.say); return; }
623
660
  draftIdentity = { provider: modelChoice.provider, baseUrl: modelChoice.baseUrl ?? null, model: modelChoice.name };
624
661
  let providerEntry;
625
662
  try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
626
- // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
663
+ // the key is the chosen row's own variable (P8: no built-in name)
627
664
  const keyCfg = keyRows;
628
- const draftEnv = applyConfiguredKey(env, modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name);
629
- const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
630
- const apiKey = draftEnv[providerEntry.envKey];
665
+ const draftKeyName = modelChoice.envName;
666
+ const apiKey = keyValueOf(env, draftKeyName);
631
667
  if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
632
668
  // the customer's own price on this row (USD per 1M in config.json), resolved once here at $0 and
633
669
  // handed to every model call this session makes; a bad price refuses, never falls back to the guess
634
- /** @type {ReturnType<typeof ratesFor>} */
670
+ /** @type {import('../providerrows.js').PriceResult} */
635
671
  let draftPrice = null;
636
- try { draftPrice = ratesFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name); } catch (e) {
637
- if (!(e instanceof ConfigError)) throw e;
638
- refuse(`${e.message}. Stopped — nothing spent.`);
639
- return;
672
+ {
673
+ const chosenRow = keyCfg.find((r) => r.envName === modelChoice.envName);
674
+ const dl = chosenRow?.models.find((l) => l.model === modelChoice.name);
675
+ const dp = dl ? ratesOfLine(dl) : null;
676
+ draftPrice = dp ? { ...dp, envName: modelChoice.envName } : null;
640
677
  }
641
678
  const keyProblem = apiKeyProblem(apiKey);
642
679
  if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
@@ -644,6 +681,9 @@ export function createSession(card, deps = {}) {
644
681
  const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
645
682
  const into = join(outDir, 'source-seed');
646
683
  const isRepoLike = looksLikeRepoSource(SOURCE);
684
+ // PART 1a: a RUBRIC job whose first input is not a repo works on the person's own local FILES — every numbered input
685
+ // line is one file, frozen by `proveRubricInputs`; there is no repo, no language and no scout (docs/product/ITEM33-BUILD.md)
686
+ const RUBRIC_FILES = card.checkType === 'rubric' && !isRepoLike;
647
687
  // hamr's ruling B (2026-10-05): ONE rule for both a fresh card and a reuse card, at $0, before the copy and any model call
648
688
  const destProblem = repoDestinationProblem(card.destination, isRepoLike);
649
689
  if (destProblem !== null) { refuse(destProblem); return; }
@@ -667,10 +707,21 @@ export function createSession(card, deps = {}) {
667
707
  // fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
668
708
  // (relative) Destination is therefore handed to the door only when the source is repo-like.
669
709
  const destIsDir = isAbsoluteDestination(card.destination);
670
- const prep = await prepareSource({
671
- source: SOURCE, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
672
- ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
673
- });
710
+ if (RUBRIC_FILES && !destIsDir) {
711
+ refuse('Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.');
712
+ return;
713
+ }
714
+ /** @type {any} */
715
+ let prep;
716
+ if (RUBRIC_FILES) {
717
+ const proved = await proveRubricInputs({ inputs: INPUTS, into, destination: card.destination, ...(deps.fetchUrl ? { fetchUrl: deps.fetchUrl } : {}) });
718
+ prep = proved.ok ? proved.prep : { stop: proved.error, code: proved.code };
719
+ } else {
720
+ prep = await prepareSource({
721
+ source: SOURCE, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
722
+ ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
723
+ });
724
+ }
674
725
  if (repoRoot !== null && prep.stop === null && prep.manifest?.worktree) madeWorktree = { repo: repoRoot, dir: prep.manifest.worktree };
675
726
  if (abandoned) dropWorktree();
676
727
  if (prep.stop !== null) {
@@ -682,7 +733,7 @@ export function createSession(card, deps = {}) {
682
733
  stepStart('check');
683
734
 
684
735
  const IS_REPO_SOURCE = prep.manifest.kind === 'repo';
685
- let LANG = 'none-detected';
736
+ let LANG = RUBRIC_FILES ? RUBRIC_LANG : 'none-detected';
686
737
  /** @type {any} */
687
738
  let langResult = null;
688
739
  if (IS_REPO_SOURCE) {
@@ -741,7 +792,7 @@ export function createSession(card, deps = {}) {
741
792
  state.phase = 'drafting';
742
793
  }
743
794
  }
744
- if (!IS_REPO_SOURCE) {
795
+ if (!IS_REPO_SOURCE && !RUBRIC_FILES) {
745
796
  refuse(nonRepoSourceMessage(prep.manifest.kind));
746
797
  return;
747
798
  }
@@ -791,9 +842,10 @@ export function createSession(card, deps = {}) {
791
842
  const judges = closeJudges(spec.closeDecl);
792
843
  const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
793
844
  let judgeProvider = null;
794
- /** @type {ReturnType<typeof ratesFor>} */
845
+ /** @type {import('../providerrows.js').PriceResult} */
795
846
  let judgePrice = null;
796
847
  if (judge) {
848
+ const judgeRow = judgeRowFor(keyCfg, judge, modelChoice.provider, keyCfg.find((r) => r.envName === modelChoice.envName) ?? null);
797
849
  try {
798
850
  judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
799
851
  } catch (e) {
@@ -801,22 +853,23 @@ export function createSession(card, deps = {}) {
801
853
  refuse(`${e.message} — refusing before gate 4 spends anything`);
802
854
  return;
803
855
  }
804
- const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
856
+ const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? apiKey : keyValueOf(env, judgeRow?.envName));
805
857
  const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
806
858
  if (!judgeKeyValue || judgeKeyProblem) {
807
859
  refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
808
860
  return;
809
861
  }
810
862
  judgeProvider = buildRunnerProviders({
811
- providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
863
+ providerName: modelChoice.provider, apiKey, model: MODEL, baseUrl: modelChoice.baseUrl, legacyMaxTokens: modelChoice.legacyMaxTokens,
812
864
  judgeApiKey: judgeKeyValue, judgeModel: judge.model, judgeProviderName: judge.provider,
813
- judgeBaseUrl: judge.provider === modelChoice.provider ? modelChoice.baseUrl : undefined,
865
+ judgeBaseUrl: judge.provider === modelChoice.provider ? modelChoice.baseUrl : (judgeRow?.baseUrl.trim() || undefined),
866
+ judgeLegacyMaxTokens: judge.provider === modelChoice.provider ? modelChoice.legacyMaxTokens : (judgeRow?.legacyMaxTokens ?? false),
814
867
  }).judgeProvider;
815
868
  }
816
869
  const signing = await prepareSigningFn({
817
870
  spec, workdir: prep.tree, seedRef: seedRef, timeoutMs,
818
871
  shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
819
- judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
872
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null, thinking: spec.thinking === true }) : null,
820
873
  judgeModel: judge?.model ?? null,
821
874
  onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
822
875
  });
@@ -872,7 +925,9 @@ export function createSession(card, deps = {}) {
872
925
  return;
873
926
  }
874
927
 
875
- const writeScope = card.destination.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
928
+ // a rubric job on files writes only inside the hidden tree's own `output/`; its Destination is a drop-off folder
929
+ // (proven above, per run), never a fence
930
+ const writeScope = RUBRIC_FILES ? ['output/'] : card.destination.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
876
931
  /** @type {any} the confirm turn (below) fills `goal` in once accepted —
877
932
  * declared loosely rather than typed field-by-field, since this object's
878
933
  * own shape is a `job-v1` draft `validateJob` itself is the real
@@ -886,8 +941,10 @@ export function createSession(card, deps = {}) {
886
941
  inputs: INPUTS,
887
942
  provider: modelChoice.provider,
888
943
  ...(modelChoice.baseUrl ? { baseUrl: modelChoice.baseUrl } : {}),
889
- // the Name is the model id: signed into the spec only when it is not the provider's own default
890
- ...(modelChoice.name !== providerEntry.tiers.sonnet ? { model: modelChoice.name } : {}),
944
+ // a job always carries its own model (P8: there is no provider default to omit it against)
945
+ model: modelChoice.name,
946
+ // the "Thinking on" tick is signed ONLY when ticked (unticked writes no field: the hash stays what it would have been)
947
+ ...(card.thinking === true && draftThinkingPlan?.support !== false ? { thinking: true } : {}),
891
948
  cadence: { unit: 'day', every: 1 },
892
949
  budgetUsd: card.capUsd,
893
950
  ...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
@@ -905,16 +962,17 @@ export function createSession(card, deps = {}) {
905
962
  /** @type {Record<string, string>} */
906
963
  const answers = { 1: renderJobLines(jobLines) };
907
964
  if (verdictType === 'soft-green') answers[SOFTGREEN_JUDGE_EXAMPLES_KEY] = renderExamples(jobLines);
908
- const required = requiredAnswersFor(verdictType);
965
+ // a rubric job on files holds no judged part in this build, so the PASS/FAIL examples are not required of it
966
+ const required = RUBRIC_FILES ? [1] : requiredAnswersFor(verdictType);
909
967
  for (const n of required) {
910
968
  if (!answers[n]) { refuse(`the ${verdictType} check type needs ${required.length} answers and one is missing (key ${n})`); return; }
911
969
  }
912
970
 
913
971
  const draftJudge = (() => {
914
- try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
972
+ try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: modelChoice.name }; }
915
973
  })();
916
974
  const { provider } = buildRunnerProviders({
917
- providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
975
+ providerName: modelChoice.provider, apiKey, model: MODEL, baseUrl: modelChoice.baseUrl, legacyMaxTokens: modelChoice.legacyMaxTokens,
918
976
  judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
919
977
  });
920
978
  // TEST SEAMS (see the constructor's own note): a test overrides
@@ -969,8 +1027,10 @@ export function createSession(card, deps = {}) {
969
1027
  rates: draftPrice?.rates ?? null,
970
1028
  draftThinking,
971
1029
  onPhase, onCall,
972
- ask, answerQuestions, confirmGenerate, isRepo: true, langResult,
1030
+ ask, answerQuestions, confirmGenerate, isRepo: !RUBRIC_FILES, langResult,
973
1031
  ...(deps.scout ? { scout: deps.scout } : {}),
1032
+ // PART 1c: a test that replaces the model boundary (`generate`) must not reach the real paid look; `deps.lookFn` brings its own
1033
+ ...(deps.lookFn !== undefined ? { lookFn: deps.lookFn } : (deps.generate ? { lookFn: null } : {})),
974
1034
  ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
975
1035
  });
976
1036
 
@@ -1004,13 +1064,34 @@ export function createSession(card, deps = {}) {
1004
1064
  : draftStopText({
1005
1065
  stop: String(authored.stop ?? 'authoring-failed'), reds: stopReds, refusal: authored.refusal,
1006
1066
  spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
1067
+ wholeJob: RUBRIC_FILES,
1007
1068
  }), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
1008
1069
  return;
1009
1070
  }
1010
1071
 
1011
- if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
1072
+ if (RUBRIC_FILES) draft.goal = redactSecrets(rubricGoal({ jobLines, files: prep.manifest.inputs, confirmedGoal: authored.confirmed?.goal ?? null, closeDecl: authored.closeDecl }));
1073
+ else if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
1012
1074
  const spec = assembleSpec(draft, { ...authored, verdictType: /** @type {string} */ (authored.verdictType) });
1075
+ // PART 1a (run mv5bk400): the plan the person confirmed named three checks for line 1; the composer wrote ONE stage and put
1076
+ // "lists 3 releases" in its notes, so the card promised a check the close never ran. The card AFTER drafting is now built
1077
+ // from the close itself, and every confirmed check no part carries out is moved to Not checked, in code, before signing.
1078
+ if (RUBRIC_FILES) {
1079
+ const rec = reconcileRubricPlan({
1080
+ confirmed: authored.confirmed?.checkItems ?? [], notChecked: authored.confirmed?.notChecked ?? [], closeDecl: spec.closeDecl, jobLines,
1081
+ });
1082
+ state.finalPlan = { goal: String(draft.goal ?? ''), checkItems: rec.checkItems, notChecked: rec.notChecked, protections: authored.confirmed?.protections ?? [], moved: rec.moved };
1083
+ }
1013
1084
  await finish(spec, authored.seedRef);
1085
+ if (state.finalPlan && state.phase === 'prepared') {
1086
+ // The confirm turn's plan bubble (and a try-3 "answers are in" one) is the DRAFTER's wording of the checks, written before
1087
+ // any close existed. Once the close is signed-ready its card is the only plan the person sees: the earlier ones are
1088
+ // dropped, so drafter prose can never sit above (and be read instead of) the code's sentences (run mv5e1ktb).
1089
+ state.messages = state.messages.filter((/** @type {any} */ m) => m.kind !== 'plan');
1090
+ state.messages.push({
1091
+ ...planMessage({ checkItems: state.finalPlan.checkItems, checks: state.finalPlan.checkItems.map((/** @type {any} */ c) => c.text), notChecked: state.finalPlan.notChecked, protections: state.finalPlan.protections, questions: [] }, jobLines),
1092
+ text: 'These are the checks the signed close will run, and what it will not check. Sign & run to accept them, or Revise to edit the boxes and draft again.',
1093
+ });
1094
+ }
1014
1095
  }
1015
1096
 
1016
1097
  // fire-and-forget — the caller (src/panel/server.js) never awaits this; it