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.
- package/CHANGELOG.md +132 -0
- package/bareloop.context.md +147 -4
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +28 -12
- package/src/panel/authorroutes.js +283 -0
- package/src/panel/authorsession.js +557 -0
- package/src/panel/index.html +1233 -64
- package/src/panel/server.js +487 -40
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providers.js +82 -0
- package/src/replay.js +128 -4
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/userrun.js +75 -8
- package/types/authorflow.d.ts +4 -21
- package/types/panel/authorroutes.d.ts +65 -0
- package/types/panel/authorsession.d.ts +61 -0
- package/types/panel/server.d.ts +33 -3
- package/types/providers.d.ts +34 -0
- package/types/replay.d.ts +31 -0
- package/types/replayio.d.ts +12 -1
- package/types/run.d.ts +26 -1
- package/types/userrun.d.ts +17 -0
|
@@ -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
|
+
}
|