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