bareloop 0.29.0 → 0.31.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.
@@ -0,0 +1,557 @@
1
+ // PANEL-BUILD.md P3 — the chat/authoring session engine. LAYERING LAW
2
+ // (PANEL-BUILD.md §2): this file is one more CALLER of the same `src/`
3
+ // library functions `bareloop interview`/`bareloop author` already call
4
+ // (`src/interviewrun.js`, `src/authorrun.js`) — it mirrors their library
5
+ // calls (the same functions, in the same order, with the same $0-before-paid
6
+ // discipline), it never drives either script's own readline loop, and it
7
+ // never reimplements anything arbiter-territory: `validateJob`,
8
+ // `jobSpecHash`, `prepareSigning`'s gates, `checkApproval` all stay in the
9
+ // library, called here exactly as `src/authorrun.js` calls them.
10
+ //
11
+ // WHAT THIS FILE DOES NOT DO: sign anything (`prepareSigning` only PREPARES —
12
+ // see its own doc, "it stops at prepareSigning"), spawn a run, or read a
13
+ // provider key anywhere but from the injected `env` (never logged, never
14
+ // sent to the page — `src/panel/server.js`'s own routes own that boundary).
15
+ //
16
+ // THE ASK CHANNEL: `runConfirmTurn` (via `authorCloseForJob`) takes one
17
+ // interactive seam, `ask(step) -> Promise<string|null>` — `src/authorrun.js`
18
+ // backs it with a readline loop over stdin; this backs the IDENTICAL seam
19
+ // with an HTTP-driven resolver queue of depth one (`answer()`, called from
20
+ // `src/panel/server.js`'s POST routes). Nothing here changes what a step
21
+ // means or what an answer does — only where the answer comes from.
22
+
23
+ import {
24
+ mkdirSync, existsSync, writeFileSync, readdirSync,
25
+ } from 'node:fs';
26
+ import { join, resolve } from 'node:path';
27
+ import {
28
+ prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
29
+ } from '../source.js';
30
+ import { detectLanguage } from '../detectlang.js';
31
+ import {
32
+ validateJob, jobSpecHash, resolveWorkerModel,
33
+ } from '../job.js';
34
+ import {
35
+ authorCloseForJob, assembleSpec, AUTHORED_SPEC_FIELDS, CONFIRM_AUTHORED_FIELDS, PLAIN_FOLDER_DEFERRED_FIELDS,
36
+ } from '../authorjob.js';
37
+ import { prepareSigning } from '../authorjob.js';
38
+ import {
39
+ questionsFor, requiredAnswersFor, makeLoopGenerate, CONFIRM_SYSTEM,
40
+ } from '../authorflow.js';
41
+ import {
42
+ resolveProvider, buildRunnerProviders, apiKeyProblem,
43
+ } from '../providers.js';
44
+ import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
45
+ import { closeJudges } from '../kinds.js';
46
+ import { redactSecrets } from '../validate.js';
47
+ import { tallyCalls } from '../text.js';
48
+
49
+ /**
50
+ * The job card's "Model" select — a fixed, small, source-grounded mapping
51
+ * from a display id to the `{provider, baseUrl?}` pair `src/interviewrun.js`
52
+ * itself documents (that script's own `--provider openai-api --base-url
53
+ * https://api.deepseek.com/v1` usage line for DeepSeek): the panel builds no
54
+ * provider table of its own, this is a UI convenience over the SAME two
55
+ * provider identities the CLI already resolves through `resolveProvider`.
56
+ * @type {Readonly<Record<string, {provider: string, baseUrl?: string}>>}
57
+ */
58
+ export const MODEL_OPTIONS = Object.freeze({
59
+ 'claude-sonnet-5': Object.freeze({ provider: 'anthropic-api' }),
60
+ 'deepseek-flash': Object.freeze({ provider: 'openai-api', baseUrl: 'https://api.deepseek.com/v1' }),
61
+ });
62
+
63
+ /**
64
+ * Plain-language labels for the library's own `onPhase(name, …)` calls
65
+ * (`src/authorjob.js`/`src/authorflow.js`) — build item 3's progress
66
+ * indicator shows one of these next to its animated glyph instead of a new
67
+ * chat bubble per phase. A phase with no entry here leaves the label as it
68
+ * was (never blanks it — a missing mapping should not flicker the line).
69
+ * @type {Readonly<Record<string, string>>}
70
+ */
71
+ const PROGRESS_LABELS = Object.freeze({
72
+ 'confirm-language-pick': 'confirming language',
73
+ 'confirm-round': 'confirming plan',
74
+ 'confirm-done': 'plan confirmed',
75
+ 'author-call': 'drafting',
76
+ 'author-fallback': 'drafting',
77
+ 'seed-read': 'reading repo',
78
+ 'seed-read-done': 'reading repo',
79
+ stage: 'checking',
80
+ seed: 'reading repo',
81
+ scout: 'scouting repo',
82
+ 'scout-done': 'scouting repo',
83
+ confirm: 'confirming plan',
84
+ 'confirm-turn-done': 'plan confirmed',
85
+ listing: 'listing files',
86
+ 'listing-done': 'listing files',
87
+ author: 'drafting',
88
+ rubric: 'calibrating',
89
+ 'rubric-done': 'calibrating',
90
+ 'rubric-scrubbed': 'calibrating',
91
+ });
92
+
93
+ /** the confirm turn's own fixed round cap (`src/authorflow.js`'s
94
+ * `runConfirmTurn`: `for (let round = 1; round <= 2; round += 1)`) — named
95
+ * here, once, rather than re-guessed at every `onPhase('confirm-round')`
96
+ * below. Not itself exported from the library as a named constant (it is
97
+ * the loop bound the confirm turn's own doc calls "D3" / "ruling 5"); this is
98
+ * this file's one place that would need editing if that cap ever changed. */
99
+ const CONFIRM_ROUND_CAP = 2;
100
+
101
+ /** @param {string} name @returns {boolean} kebab-case slug, same rule `job.js`'s `SLUG_RE` enforces */
102
+ function isSlug(name) {
103
+ return typeof name === 'string' && /^[a-z][a-z0-9-]*$/.test(name);
104
+ }
105
+
106
+ /**
107
+ * `true` when `jobs/<name>.json` already exists in the repo's own jobs/ dir
108
+ * (resolved from THIS module's own on-disk location, never `process.cwd()`,
109
+ * the same rule `src/panel/server.js`'s `jobsDir()` follows) — P3 is new-jobs-
110
+ * only (Q4=A), so a name collision refuses at $0 rather than silently
111
+ * overwriting or authoring a second spec under one name.
112
+ * @param {string} name
113
+ * @param {{jobsDir?: string}} [opts] test seam
114
+ * @returns {boolean}
115
+ */
116
+ export function jobNameTaken(name, opts = {}) {
117
+ const dir = opts.jobsDir ?? join(new URL('../../jobs', import.meta.url).pathname);
118
+ if (!existsSync(dir)) return false;
119
+ try {
120
+ return readdirSync(dir).includes(`${name}.json`);
121
+ } catch { return false; }
122
+ }
123
+
124
+ /**
125
+ * `$0` validation of the job card fields the server must check BEFORE it
126
+ * creates a session or spends anything — never inside the async pipeline,
127
+ * so a bad card refuses synchronously, in the same HTTP response.
128
+ * @param {any} card
129
+ * @returns {{ok: true}|{ok: false, error: string}}
130
+ */
131
+ export function validateJobCard(card) {
132
+ if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
133
+ const { jobName, checkType, model, goal, source, destination, success, guardrails, capUsd } = card;
134
+ if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
135
+ if (jobNameTaken(jobName, card.jobsDirOverride ? { jobsDir: card.jobsDirOverride } : {})) {
136
+ return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
137
+ }
138
+ if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
139
+ if (!MODEL_OPTIONS[model]) return { ok: false, error: `Model must be one of: ${Object.keys(MODEL_OPTIONS).join(', ')}` };
140
+ for (const [field, label] of [[goal, 'Goal'], [source, 'Source'], [destination, 'Destination'], [success, 'Success'], [guardrails, 'Guardrails']]) {
141
+ if (typeof field !== 'string' || field.trim() === '') return { ok: false, error: `${label} is required` };
142
+ }
143
+ if (checkType === 'rubric' && (typeof card.judgeExamples !== 'string' || card.judgeExamples.trim() === '')) {
144
+ return { ok: false, error: 'Judge examples is required for a rubric (soft-green) check type' };
145
+ }
146
+ if (typeof capUsd !== 'number' || !Number.isFinite(capUsd) || capUsd <= 0) {
147
+ return { ok: false, error: '$ cap is required and must be a positive number' };
148
+ }
149
+ return { ok: true };
150
+ }
151
+
152
+ /**
153
+ * ONE session's engine — created already RUNNING (the caller awaits nothing;
154
+ * the pipeline drives itself, reporting through `state`). `state.pendingAsk`
155
+ * is the ONE thing a person answers next; `answer()`/`send()`/`revise()`/
156
+ * `signPrepare()` are the only ways in.
157
+ * @param {any} card the validated job card (see {@link validateJobCard})
158
+ * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, timeoutMs?: number,
159
+ * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
160
+ * prepareSigningFn?: Function}} [deps] the last five are TEST SEAMS ONLY — see the note
161
+ * just above where each is read, below.
162
+ * @returns {any} the session object
163
+ */
164
+ export function createSession(card, deps = {}) {
165
+ const env = deps.env ?? process.env;
166
+ const sessionsRoot = deps.sessionsRoot ?? join(process.env.HOME ?? '/tmp', '.config', 'bareloop', 'panel-sessions');
167
+ const timeoutMs = deps.timeoutMs ?? 300_000;
168
+ // TEST SEAMS ONLY (never set by `src/panel/authorroutes.js`'s real caller):
169
+ // override the declaration composer and/or `prepareSigning` itself so a
170
+ // test can drive the REAL confirm turn / ask() channel / revise-round /
171
+ // hash-matching machinery this file owns, without also re-running (and
172
+ // re-proving) `authorClose`'s own composer ladder or `prepareSigning`'s own
173
+ // gates — both already have their own test suites. Absent, both default to
174
+ // the real library functions, exactly as production always runs.
175
+ const authorFnOverride = deps.authorFn ?? null;
176
+ const prepareSigningFn = deps.prepareSigningFn ?? prepareSigning;
177
+ const id = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
178
+ const outDir = join(sessionsRoot, id);
179
+ mkdirSync(outDir, { recursive: true });
180
+
181
+ /** @type {any} */
182
+ const state = {
183
+ id,
184
+ phase: 'starting',
185
+ messages: /** @type {{role: string, text: string}[]} */ ([]),
186
+ pendingAsk: /** @type {any} */ (null),
187
+ cost: /** @type {any} */ (null),
188
+ revisesLeft: CONFIRM_ROUND_CAP,
189
+ specHash: /** @type {string|null} */ (null),
190
+ resolvedSpecPath: /** @type {string|null} */ (null),
191
+ error: /** @type {string|null} */ (null),
192
+ outDir,
193
+ // draftSpentUsd (hamr's ruling 2026-09-28, "one cap covers drafting +
194
+ // run") — the KNOWN FLOOR of this session's own drafting spend, updated
195
+ // by `onCall` below off the SAME `metered` list `state.cost` already
196
+ // reads (never a second running total). Never $0 when a call came back
197
+ // unpriced (F6: unpriced is never free) — `known` below is the floor
198
+ // either way. `signRun` (src/panel/authorroutes.js) reads this figure
199
+ // straight off `state` to pass `--draft-spent-usd` to `run-u`.
200
+ draftSpentUsd: 0,
201
+ // draftSpendComplete (hamr's ruling 2026-09-28, 2nd addendum) — was the
202
+ // floor above EXACT? Updated alongside `draftSpentUsd` by `onCall` below,
203
+ // off the same `tallyCalls` read `costSoFar()` (src/authorrun.js) uses for
204
+ // this same pipeline's own drafting-spend line — an unpriced OR
205
+ // unpriced-rounds-carrying call flips this false and it never heals.
206
+ // `signRun` reads it to pass `--draft-spend-incomplete` to `run-u`.
207
+ draftSpendComplete: true,
208
+ // the short, human step label the progress indicator shows next to its
209
+ // animated glyph (build item 3) — updated by `onPhase` below and by this
210
+ // file's own top-level phase transitions; never a chat bubble of its own
211
+ // (build item 2: onPhase used to post one, and it read as jargon/
212
+ // duplicate of the refusal that often followed it in the same breath).
213
+ progressLabel: /** @type {string|null} */ (null),
214
+ };
215
+
216
+ /** @param {'bot'|'you'|'system'} role @param {string} text */
217
+ const say = (role, text) => { state.messages.push({ role, text }); };
218
+
219
+ /** @type {((value: string|null) => void)|null} */
220
+ let resolvePending = null;
221
+
222
+ /** @type {(() => void)|null} the F182-mirrored install-gap wait's own
223
+ * resolver — a SEPARATE channel from `resolvePending`/`ask()` above (the
224
+ * confirm turn hasn't started yet when this one is live), signalled by
225
+ * `checkDeps()`, below, itself called only from the "Check again" POST
226
+ * route (`src/panel/authorroutes.js`). */
227
+ let resolveDepsCheck = null;
228
+ /** @returns {Promise<void>} resolves once `checkDeps()` is called */
229
+ const waitForDepsCheck = () => new Promise((resolveFn) => { resolveDepsCheck = resolveFn; });
230
+
231
+ /**
232
+ * The confirm turn's own `ask` seam — HTTP-backed, depth one. `describe`
233
+ * renders the SAME plain-language prompts `src/authorrun.js`'s own `ask`
234
+ * writes to its terminal, adapted for the chat thread rather than stdout.
235
+ * @param {{kind: string, [k: string]: any}} step
236
+ * @returns {Promise<string|null>}
237
+ */
238
+ const ask = (step) => new Promise((resolveFn) => {
239
+ state.pendingAsk = step;
240
+ resolvePending = resolveFn;
241
+ if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
242
+ else if (step.kind === 'menu') {
243
+ const p = step.plan ?? {};
244
+ const lines = [`Plan: ${JSON.stringify(p.goal ?? '')}`, `Checks: ${(p.checks ?? []).join(' · ') || '(none)'}`];
245
+ if ((p.notChecked ?? []).length) lines.push(`Not checked: ${p.notChecked.join(' · ')}`);
246
+ if ((p.questions ?? []).length) lines.push(`Questions: ${p.questions.join(' · ')}`);
247
+ lines.push('Sign & run to confirm this plan, or Revise to describe a change.');
248
+ say('bot', lines.join('\n'));
249
+ } else if (step.kind === 'answer') say('bot', `A question the plan raised (${step.index} of ${step.total}): ${step.question}`);
250
+ else if (step.kind === 'goal') say('bot', 'Type the goal sentence yourself.');
251
+ else if (step.kind === 'fix') say('bot', 'What should change?');
252
+ });
253
+
254
+ /** @param {string|null} value @returns {boolean} */
255
+ const answer = (value) => {
256
+ if (!resolvePending) return false;
257
+ const r = resolvePending;
258
+ resolvePending = null;
259
+ state.pendingAsk = null;
260
+ r(value);
261
+ return true;
262
+ };
263
+
264
+ /** @param {'menu'|'fix'|'answer'|'language'|'goal'} kind @param {number} timeoutMsInner */
265
+ const waitForPendingKind = async (kind, timeoutMsInner = 2000) => {
266
+ const start = Date.now();
267
+ while (Date.now() - start < timeoutMsInner) {
268
+ if (state.pendingAsk?.kind === kind) return true;
269
+ // eslint-disable-next-line no-await-in-loop
270
+ await new Promise((r) => { setTimeout(r, 10); });
271
+ }
272
+ return state.pendingAsk?.kind === kind;
273
+ };
274
+
275
+ const onPhase = (name, data = {}) => {
276
+ if (name === 'confirm-round' && typeof data.round === 'number') {
277
+ state.revisesLeft = Math.max(0, CONFIRM_ROUND_CAP - (data.round - 1));
278
+ }
279
+ // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
280
+ // to also `say('system', `… ${name}`)`, one jargon chat bubble per
281
+ // library phase (`… confirm-turn-done`, `… seed-read`, …), read back to
282
+ // back with the plain-language refusal that often followed a phase like
283
+ // `confirm-turn-done` in the very next line (build item 2's "the
284
+ // refusal line appears twice"). The thread now carries only what a
285
+ // person needs to read; the step-by-step detail lives in one line.
286
+ state.progressLabel = PROGRESS_LABELS[name] ?? state.progressLabel;
287
+ };
288
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
289
+ const metered = [];
290
+ const onCall = (call) => {
291
+ metered.push({ label: call.label, costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0 });
292
+ // hamr's ruling 2026-09-28 (2nd addendum) — the SAME `tallyCalls` reader
293
+ // `src/authorrun.js`'s own `costSoFar()` uses over this same call-shape
294
+ // list, so this session's floor/complete read can never drift from the
295
+ // CLI author path's: `spendComplete` is false the moment ANY call came
296
+ // back with a null costUsd or a nonzero unpricedRounds (F6), never just
297
+ // costUsd===null alone as this used to read.
298
+ const t = tallyCalls(metered);
299
+ state.cost = t.spendComplete ? `$${t.knownUsd.toFixed(4)}` : `≥$${t.knownUsd.toFixed(4)} (unpriced calls present)`;
300
+ state.draftSpentUsd = t.knownUsd;
301
+ state.draftSpendComplete = t.spendComplete;
302
+ };
303
+
304
+ const refuse = (message) => {
305
+ state.phase = 'refused';
306
+ state.error = message;
307
+ say('system', message);
308
+ };
309
+
310
+ async function run() {
311
+ // ── keys, at $0, before anything is prepared ────────────────────────
312
+ const modelChoice = MODEL_OPTIONS[card.model];
313
+ let providerEntry;
314
+ try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
315
+ const apiKey = env[providerEntry.envKey];
316
+ if (!apiKey) { refuse(`${providerEntry.envKey} not set. Stopped — nothing spent.`); return; }
317
+ const keyProblem = apiKeyProblem(apiKey);
318
+ if (keyProblem) { refuse(`${providerEntry.envKey} ${keyProblem}. Stopped — nothing spent.`); return; }
319
+
320
+ const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
321
+ const into = join(outDir, 'source-seed');
322
+ const isRepoLike = looksLikeRepoSource(card.source);
323
+
324
+ state.phase = 'preparing-source';
325
+ state.progressLabel = 'copying source';
326
+ say('system', 'Copying source ($0)');
327
+ const prep = await prepareSource({
328
+ source: card.source, into, destination: isRepoLike ? card.destination : card.destination,
329
+ });
330
+ if (prep.stop !== null) { refuse(prep.stop); return; }
331
+
332
+ const IS_REPO_SOURCE = prep.manifest.kind === 'repo';
333
+ let LANG = 'none-detected';
334
+ /** @type {any} */
335
+ let langResult = null;
336
+ if (IS_REPO_SOURCE) {
337
+ langResult = detectLanguage(prep.tree);
338
+ if (langResult.kind === 'language-unsupported') {
339
+ refuse(`${langResult.refusal.detail}`);
340
+ return;
341
+ }
342
+ LANG = langResult.kind === 'resolved' ? langResult.lang : (langResult.kind === 'ambiguous' ? langResult.candidates[0] : 'none-detected');
343
+
344
+ // ── the install gap, WAITED FOR, never a dead end (build item 1,
345
+ // mirroring F182's fix in `src/interviewrun.js`) ── the copy this
346
+ // session works from (`prep.tree`) is `prepareSource`'s own hidden
347
+ // seed, tracked-files-only, so a JS/TS repo's copy never carries
348
+ // `node_modules`. Refusing outright here was ITSELF the F182 class of
349
+ // bug one layer up: a person who ran the printed command and then
350
+ // reloaded the page got a BRAND NEW seed with no `node_modules`
351
+ // either, so the refusal could never be satisfied. bareloop still
352
+ // never runs an install itself — this only re-checks the SAME copy
353
+ // (`missingDependencies` over `prep.tree`, the identical rule
354
+ // `src/interviewrun.js` uses) on demand, via the "Check again" button
355
+ // (`checkDeps()`, below), instead of ending the session.
356
+ let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
357
+ if (depsGap) {
358
+ state.phase = 'install-needed';
359
+ state.progressLabel = 'waiting on install';
360
+ say('system', `Packages missing in the copy. Run:\ncd ${prep.tree} && ${depsGap.command}`);
361
+ for (;;) {
362
+ state.pendingAsk = { kind: 'install-needed', tree: prep.tree, command: depsGap.command, reason: depsGap.reason };
363
+ // eslint-disable-next-line no-await-in-loop
364
+ await waitForDepsCheck();
365
+ state.pendingAsk = null;
366
+ depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
367
+ if (!depsGap) break;
368
+ say('system', `Still missing (${depsGap.reason}). Install, then Check again.`);
369
+ }
370
+ say('system', 'Packages found');
371
+ state.phase = 'drafting';
372
+ }
373
+ }
374
+ if (!IS_REPO_SOURCE) {
375
+ state.phase = 'refused';
376
+ state.error = 'non-code-source';
377
+ say('system', "Plain folder, not a code project — bareloop can't check this kind of job yet. Nothing spent.");
378
+ return;
379
+ }
380
+
381
+ const writeScope = card.destination.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
382
+ /** @type {any} the confirm turn (below) fills `goal` in once accepted —
383
+ * declared loosely rather than typed field-by-field, since this object's
384
+ * own shape is a `job-v1` draft `validateJob` itself is the real
385
+ * authority on, not a second hand-typed interface here. */
386
+ const draft = {
387
+ schema: 'job-v1',
388
+ job: card.jobName,
389
+ description: `${card.jobName} — authored through the bareloop panel (${verdictType}, ${LANG})`,
390
+ provider: modelChoice.provider,
391
+ ...(modelChoice.baseUrl ? { baseUrl: modelChoice.baseUrl } : {}),
392
+ cadence: { unit: 'day', every: 1 },
393
+ budgetUsd: card.capUsd,
394
+ ...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
395
+ writeScope,
396
+ escalation: { mode: 'decision-ready' },
397
+ };
398
+ const draftReds = validateJob(draft, { shellCapUsd: draft.budgetUsd }).reds
399
+ .filter((r) => ![...AUTHORED_SPEC_FIELDS, ...CONFIRM_AUTHORED_FIELDS, ...PLAIN_FOLDER_DEFERRED_FIELDS]
400
+ .some((f) => String(r.path) === f || String(r.path).startsWith(`${f}.`)));
401
+ if (draftReds.length) {
402
+ refuse(`the spec draft does not validate — ${draftReds.map((r) => `${r.code} at ${r.path}`).join('; ')}`);
403
+ return;
404
+ }
405
+
406
+ /** @type {Record<string, string>} */
407
+ const answers = { 1: card.goal, 2: card.success, 3: card.guardrails };
408
+ if (verdictType === 'soft-green') answers[4] = card.judgeExamples;
409
+ const required = requiredAnswersFor(verdictType);
410
+ for (const n of required) {
411
+ if (!answers[n]) { refuse(`the ${verdictType} check type needs ${required.length} answers and one is missing (key ${n})`); return; }
412
+ }
413
+
414
+ const draftJudge = (() => {
415
+ try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
416
+ })();
417
+ const MODEL = providerEntry.tiers.sonnet;
418
+ const { provider } = buildRunnerProviders({
419
+ providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
420
+ judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
421
+ });
422
+ // TEST SEAMS (see the constructor's own note): a test overrides
423
+ // `generate`/`confirmGenerate` directly (bypassing this real, constructed
424
+ // `provider` for the model boundary) and/or `scout` (bypassing the real
425
+ // paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
426
+ // machinery below with a deterministic fake, never a live provider call.
427
+ const generate = deps.generate ?? makeLoopGenerate(provider);
428
+ const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
429
+
430
+ state.phase = 'drafting';
431
+ state.progressLabel = 'drafting';
432
+ // build item 4 — the DISPLAY id (card.model, e.g. "deepseek-flash"), not
433
+ // the internal resolved tier model string (MODEL) or the raw provider
434
+ // name — the same id the Model field's own dropdown showed.
435
+ say('system', `Drafting with ${card.model}, $${card.capUsd.toFixed(2)} cap`);
436
+ const authored = await authorCloseForJob({
437
+ judgeModel: draftJudge.model,
438
+ answers, verdictType, repoPath: prep.tree, lang: LANG,
439
+ questions: questionsFor(verdictType),
440
+ writeScope, provider, generate,
441
+ ceilingUsd: card.capUsd,
442
+ onPhase, onCall,
443
+ ask, confirmGenerate, isRepo: true, langResult,
444
+ ...(deps.scout ? { scout: deps.scout } : {}),
445
+ ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
446
+ });
447
+
448
+ if (!authored.ok) {
449
+ state.phase = authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused';
450
+ state.error = authored.stop ?? 'authoring-failed';
451
+ say('system', `Stopped: ${state.error}${authored.refusal ? ` — ${authored.refusal.detail}` : ''}`);
452
+ return;
453
+ }
454
+
455
+ if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
456
+ const spec = assembleSpec(draft, { ...authored, verdictType: /** @type {string} */ (authored.verdictType) });
457
+ const specFile = join(outDir, 'resolved-spec.json');
458
+ writeFileSync(specFile, `${JSON.stringify(spec, null, 2)}\n`);
459
+ state.resolvedSpecPath = specFile;
460
+
461
+ const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
462
+ if (!jv.ok) {
463
+ state.phase = 'refused';
464
+ state.error = `spec-invalid: ${jv.reds.map((r) => r.code).join(', ')}`;
465
+ say('system', state.error);
466
+ return;
467
+ }
468
+
469
+ state.phase = 'signing-gates';
470
+ state.progressLabel = 'checking';
471
+ say('system', 'Checking (gates 1–3, $0; gate 4 only if rubric)…');
472
+ const judges = closeJudges(spec.closeDecl);
473
+ const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
474
+ let judgeProvider = null;
475
+ if (judge) {
476
+ const judgeKeyValue = env.JUDGE_API_KEY ?? env[resolveProvider(judge.provider).envKey];
477
+ const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
478
+ if (!judgeKeyValue || judgeKeyProblem) {
479
+ refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
480
+ return;
481
+ }
482
+ judgeProvider = buildRunnerProviders({
483
+ providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
484
+ judgeApiKey: judgeKeyValue, judgeModel: judge.model, judgeProviderName: judge.provider,
485
+ judgeBaseUrl: judge.provider === modelChoice.provider ? modelChoice.baseUrl : undefined,
486
+ }).judgeProvider;
487
+ }
488
+ const signing = await prepareSigningFn({
489
+ spec, workdir: prep.tree, seedRef: authored.seedRef, timeoutMs,
490
+ shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
491
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
492
+ judgeModel: judge?.model ?? null,
493
+ onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
494
+ });
495
+ const signingFile = join(outDir, 'signing.json');
496
+ writeFileSync(signingFile, `${JSON.stringify(signing, null, 2)}\n`);
497
+
498
+ if (!signing.ok) {
499
+ state.phase = 'refused';
500
+ state.error = `signing gates failed — ${signing.reds.map((r) => r.code).join(', ') || 'no work red at seed'}`;
501
+ say('system', state.error);
502
+ return;
503
+ }
504
+ state.specHash = signing.specHash;
505
+ state.phase = 'prepared';
506
+ say('bot', `SIGNING PREPARED — spec hash ${signing.specHash}`);
507
+ }
508
+
509
+ // fire-and-forget — the caller (src/panel/server.js) never awaits this; it
510
+ // polls `state` instead. A crash anywhere in `run()` is caught here so it
511
+ // can never take the panel server process down with it.
512
+ run().catch((e) => {
513
+ state.phase = 'error';
514
+ state.error = /** @type {Error} */ (e)?.message ?? String(e);
515
+ say('system', `internal error: ${state.error}`);
516
+ });
517
+
518
+ return {
519
+ id,
520
+ state,
521
+ /** @param {string} text */
522
+ send: (text) => {
523
+ if (!state.pendingAsk) return { ok: false, error: 'nothing is being asked right now' };
524
+ if (state.pendingAsk.kind === 'menu') return { ok: false, error: 'use Sign & run or Revise for a plan, never Send — the chat can never sign or pick a plan action' };
525
+ if (state.pendingAsk.kind === 'install-needed') return { ok: false, error: 'install the packages, then click Check again — nothing to send here' };
526
+ say('you', text);
527
+ answer(text);
528
+ return { ok: true };
529
+ },
530
+ /** the [Check again] button — re-runs the SAME `missingDependencies`
531
+ * check on the SAME copy, never a second install of its own. */
532
+ checkDeps: () => {
533
+ if (state.phase !== 'install-needed' || !resolveDepsCheck) return { ok: false, error: 'not waiting on an install check right now' };
534
+ const r = resolveDepsCheck;
535
+ resolveDepsCheck = null;
536
+ r();
537
+ return { ok: true };
538
+ },
539
+ /** @param {string} text */
540
+ revise: async (text) => {
541
+ if (!state.pendingAsk || state.pendingAsk.kind !== 'menu') return { ok: false, error: 'no plan is waiting for a Revise right now' };
542
+ if (state.revisesLeft <= 0) return { ok: false, error: 'no revises left (D3: max 2 rounds)' };
543
+ answer('fix');
544
+ const reached = await waitForPendingKind('fix', 3000);
545
+ if (!reached) return { ok: false, error: 'the confirm turn did not ask for a fix in time' };
546
+ say('you', text);
547
+ answer(text);
548
+ return { ok: true };
549
+ },
550
+ /** click 1 — the confirm pick. Never signs on its own. */
551
+ signPrepare: () => {
552
+ if (!state.pendingAsk || state.pendingAsk.kind !== 'menu') return { ok: false, error: 'no plan is waiting for a decision right now' };
553
+ answer('confirm');
554
+ return { ok: true };
555
+ },
556
+ };
557
+ }