bareloop 0.34.0 → 0.35.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 +31 -0
- package/bareloop.context.md +241 -13
- package/package.json +1 -1
- package/src/bundlerun.js +13 -4
- package/src/draftspend.js +82 -0
- package/src/exits.js +10 -1
- package/src/interviewrun.js +7 -7
- package/src/job.js +37 -0
- package/src/judged.js +2 -2
- package/src/kinds.js +2 -1
- package/src/ledger.js +101 -5
- package/src/legs.js +171 -0
- package/src/monthly.js +111 -72
- package/src/panel/authorroutes.js +84 -14
- package/src/panel/authorsession.js +408 -113
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1071 -184
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +839 -70
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- package/src/ralph.js +3 -0
- package/src/replay.js +139 -19
- package/src/replayio.js +11 -4
- package/src/reuse.js +20 -3
- package/src/run.js +6 -3
- package/src/runlist.js +75 -7
- package/src/text.js +20 -0
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +138 -57
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +23 -18
- package/types/panel/authorroutes.d.ts +23 -1
- package/types/panel/authorsession.d.ts +109 -2
- package/types/panel/importroutes.d.ts +129 -0
- package/types/panel/importrun.d.ts +77 -0
- package/types/panel/runroutes.d.ts +32 -0
- package/types/panel/server.d.ts +255 -2
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +12 -0
- package/types/replay.d.ts +25 -1
- package/types/replayio.d.ts +2 -2
- package/types/reuse.d.ts +5 -0
- package/types/run.d.ts +6 -2
- package/types/runlist.d.ts +52 -2
- package/types/text.d.ts +19 -0
|
@@ -31,12 +31,13 @@ import {
|
|
|
31
31
|
} from '../source.js';
|
|
32
32
|
import { detectLanguage } from '../detectlang.js';
|
|
33
33
|
import {
|
|
34
|
-
validateJob, jobSpecHash, resolveWorkerModel,
|
|
34
|
+
validateJob, jobSpecHash, workflowKey, resolveWorkerModel,
|
|
35
35
|
} from '../job.js';
|
|
36
36
|
import {
|
|
37
37
|
authorCloseForJob, assembleSpec, AUTHORED_SPEC_FIELDS, CONFIRM_AUTHORED_FIELDS, PLAIN_FOLDER_DEFERRED_FIELDS,
|
|
38
38
|
} from '../authorjob.js';
|
|
39
39
|
import { prepareSigning } from '../authorjob.js';
|
|
40
|
+
import { checkCloseByteSignature } from '../close-integrity.js';
|
|
40
41
|
import {
|
|
41
42
|
questionsFor, requiredAnswersFor, makeLoopGenerate, CONFIRM_SYSTEM,
|
|
42
43
|
} from '../authorflow.js';
|
|
@@ -47,37 +48,86 @@ import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
|
|
|
47
48
|
import { closeJudges } from '../kinds.js';
|
|
48
49
|
import { redactSecrets } from '../validate.js';
|
|
49
50
|
import { tallyCalls } from '../text.js';
|
|
51
|
+
import { writeDraftSpend } from '../draftspend.js';
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* chat bubble per phase. A phase with no entry here leaves the label as it
|
|
56
|
-
* was (never blanks it — a missing mapping should not flicker the line).
|
|
54
|
+
* THE PROGRESS LIST's one table (hamr 2026-10-04, "one place showing all"): every pipeline step the left Chat panel
|
|
55
|
+
* lists, in the order they first appear, each with a stable id and a plain code-owned label. A step is one line that
|
|
56
|
+
* updates in place (running -> done or failed with its reason); the thread carries only chat turns.
|
|
57
57
|
* @type {Readonly<Record<string, string>>}
|
|
58
58
|
*/
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
'seed-read-done': 'reading repo',
|
|
67
|
-
stage: 'checking',
|
|
68
|
-
seed: 'reading repo',
|
|
59
|
+
export const STEP_LABELS = Object.freeze({
|
|
60
|
+
setup: 'checking setup',
|
|
61
|
+
copy: 'copying source',
|
|
62
|
+
check: 'checking source',
|
|
63
|
+
install: 'waiting on install',
|
|
64
|
+
reuse: 'reusing signed workflow',
|
|
65
|
+
read: 'reading repo',
|
|
69
66
|
scout: 'scouting repo',
|
|
70
|
-
'scout-done': 'scouting repo',
|
|
71
|
-
confirm: 'confirming plan',
|
|
72
|
-
'confirm-turn-done': 'plan confirmed',
|
|
73
67
|
listing: 'listing files',
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
68
|
+
confirm: 'confirming plan',
|
|
69
|
+
draft: 'drafting',
|
|
70
|
+
calibrate: 'calibrating',
|
|
71
|
+
gates: 'checking signing gates',
|
|
72
|
+
hash: 'generating hash',
|
|
73
|
+
signed: 'signed hash',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The library's own `onPhase(name, ...)` calls (`src/authorjob.js`/`src/authorflow.js`) -> the step each one advances.
|
|
78
|
+
* A phase with no entry leaves the current step as it was.
|
|
79
|
+
* @type {Readonly<Record<string, keyof typeof STEP_LABELS>>}
|
|
80
|
+
*/
|
|
81
|
+
export const PHASE_STEP = Object.freeze({
|
|
82
|
+
'confirm-language-pick': 'confirm',
|
|
83
|
+
'confirm-round': 'confirm',
|
|
84
|
+
'confirm-done': 'confirm',
|
|
85
|
+
'author-call': 'draft',
|
|
86
|
+
'author-fallback': 'draft',
|
|
87
|
+
'seed-read': 'read',
|
|
88
|
+
'seed-read-done': 'read',
|
|
89
|
+
stage: 'gates',
|
|
90
|
+
seed: 'read',
|
|
91
|
+
scout: 'scout',
|
|
92
|
+
'scout-done': 'scout',
|
|
93
|
+
confirm: 'confirm',
|
|
94
|
+
'confirm-turn-done': 'confirm',
|
|
95
|
+
listing: 'listing',
|
|
96
|
+
'listing-done': 'listing',
|
|
97
|
+
author: 'draft',
|
|
98
|
+
rubric: 'calibrate',
|
|
99
|
+
'rubric-done': 'calibrate',
|
|
100
|
+
'rubric-scrubbed': 'calibrate',
|
|
79
101
|
});
|
|
80
102
|
|
|
103
|
+
/** @typedef {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}} StepLine */
|
|
104
|
+
|
|
105
|
+
/** @param {StepLine[]} steps @param {string} id the LATEST line carrying this id (the list is a time-ordered log; an id can repeat) */
|
|
106
|
+
export function latestStep(steps, id) {
|
|
107
|
+
for (let i = steps.length - 1; i >= 0; i -= 1) if (steps[i].id === id) return steps[i];
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The progress list is a chronological log of segments (hamr 2026-10-05): starting a different step closes the running
|
|
113
|
+
* line; a step that already has a line and starts again AFTER other steps gets a NEW line appended at the end (the
|
|
114
|
+
* earlier line keeps its status and detail). Existing lines are never moved. Re-starting the step that is still the
|
|
115
|
+
* LAST line just resumes that line.
|
|
116
|
+
* @param {StepLine[]} steps @param {string} id @param {string} [detail] (absent = keep what the line has)
|
|
117
|
+
*/
|
|
118
|
+
export function advanceSteps(steps, id, detail) {
|
|
119
|
+
for (const x of steps) if (x.status === 'running' && x.id !== id) x.status = 'done';
|
|
120
|
+
const last = steps.at(-1);
|
|
121
|
+
if (last && last.id === id) { last.status = 'running'; if (detail !== undefined) last.detail = detail; return; }
|
|
122
|
+
steps.push({ id, label: STEP_LABELS[id] ?? id, status: 'running', detail: detail ?? '' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** source-door refusals that come from CHECKING the copied source (the scan/freeze), not from reaching it */
|
|
126
|
+
const SOURCE_CHECK_CODES = new Set([
|
|
127
|
+
'source-carries-secret', 'source-env-file', 'source-symlink', 'source-nested-repo', 'source-not-text',
|
|
128
|
+
'source-file-oversize', 'source-changed-after-scan', 'source-untracked-in-repo',
|
|
129
|
+
]);
|
|
130
|
+
|
|
81
131
|
/** the confirm turn's own fixed round cap (`src/authorflow.js`'s
|
|
82
132
|
* `runConfirmTurn`: `for (let round = 1; round <= 2; round += 1)`) — named
|
|
83
133
|
* here, once, rather than re-guessed at every `onPhase('confirm-round')`
|
|
@@ -109,6 +159,125 @@ export function jobNameTaken(name, opts = {}) {
|
|
|
109
159
|
} catch { return false; }
|
|
110
160
|
}
|
|
111
161
|
|
|
162
|
+
/** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
|
|
163
|
+
export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'model', 'goal', 'source', 'destination', 'success', 'guardrails', 'judgeExamples', 'capUsd', 'maxWallMs']);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Only the form's own fields, nothing else a request body might carry (`startFrom`, stray keys).
|
|
167
|
+
* @param {any} card
|
|
168
|
+
* @returns {Record<string, any>}
|
|
169
|
+
*/
|
|
170
|
+
export function cardFields(card) {
|
|
171
|
+
/** @type {Record<string, any>} */
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const f of CARD_FIELDS) if (card?.[f] !== undefined) out[f] = card[f];
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* REUSE WORKFLOW (hamr 2026-10-03, replaces P5 item 3's same-job rule; Model opened 2026-10-04): a reuse card has exactly
|
|
179
|
+
* FIVE open boxes — Source, Destination, Model, $ cap, Time cap — and every other box is the signed workflow, shown greyed and never
|
|
180
|
+
* editable. The page greys them; THIS is what refuses (the server never trusts the page). Changing any locked box
|
|
181
|
+
* is a different job: Clear the card, which drafts.
|
|
182
|
+
*/
|
|
183
|
+
export const REUSE_OPEN_FIELDS = Object.freeze(['source', 'destination', 'model', 'capUsd', 'maxWallMs']);
|
|
184
|
+
/** every card field that is NOT open on a reuse card, in card order */
|
|
185
|
+
export const REUSE_LOCKED_FIELDS = Object.freeze(CARD_FIELDS.filter((f) => !REUSE_OPEN_FIELDS.includes(f)));
|
|
186
|
+
const REUSE_LABELS = Object.freeze(/** @type {Record<string, string>} */ ({
|
|
187
|
+
jobName: 'Job name', checkType: 'Check type', goal: 'Goal', success: 'Success', guardrails: 'Guardrails', judgeExamples: 'Judge examples',
|
|
188
|
+
}));
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The first LOCKED box on `card` that differs from what the signed workflow prefilled (`origin`), or `null` when
|
|
192
|
+
* every locked box is untouched. Blank and absent read alike; whitespace is not compared.
|
|
193
|
+
* @param {Record<string, any>} card the form as it was submitted
|
|
194
|
+
* @param {Record<string, any>} origin the form as the server prefilled it
|
|
195
|
+
* @returns {string|null} the offending field's label
|
|
196
|
+
*/
|
|
197
|
+
export function lockedFieldChanged(card, origin) {
|
|
198
|
+
// whitespace is ignored: a one-line <input> drops a goal's newlines, and the signed copy never takes text from the card anyway
|
|
199
|
+
const norm = (/** @type {any} */ v) => (v === undefined || v === null ? '' : (typeof v === 'string' ? v.replace(/\s+/g, '') : v));
|
|
200
|
+
const bad = REUSE_LOCKED_FIELDS.find((f) => norm(card?.[f]) !== norm(origin?.[f]));
|
|
201
|
+
return bad === undefined ? null : (REUSE_LABELS[bad] ?? bad);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The spec a reuse signs: a COPY of the origin's signed spec with ONLY the open fields set from the card —
|
|
206
|
+
* `writeScope` from Destination, `budgetUsd` from the $ cap, `maxWallMs` from the Time cap (absent when blank), and the
|
|
207
|
+
* worker (`provider`/`baseUrl`/`model`) from the Model Name chosen in Settings > Providers, spelled exactly as normal
|
|
208
|
+
* authoring spells it (`baseUrl` only when the row has one; `model` only when the Name is not the provider's default
|
|
209
|
+
* tier). A RUBRIC (soft-green) origin with no explicit `judge` first has its judge pinned to the ORIGIN's resolved judge
|
|
210
|
+
* identity — the judge must not change because the worker did (`resolveJobJudge`, the one spelling); an explicit
|
|
211
|
+
* `judge` is kept; a deterministic job has no judge to move. Source is not in a spec (it lives beside the run). The copy
|
|
212
|
+
* hashes to a NEW `jobSpecHash` and the SAME `workflowKey` — the caller checks the second after building.
|
|
213
|
+
* @param {any} originSpec
|
|
214
|
+
* @param {Record<string, any>} card
|
|
215
|
+
* @param {readonly import('../providerrows.js').KeyRow[]} [rows] the Settings rows; without them (or without `card.model`) the worker is left as signed
|
|
216
|
+
* @returns {any}
|
|
217
|
+
*/
|
|
218
|
+
export function buildReuseSpec(originSpec, card, rows) {
|
|
219
|
+
const spec = JSON.parse(JSON.stringify(originSpec));
|
|
220
|
+
spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
|
|
221
|
+
spec.budgetUsd = card.capUsd;
|
|
222
|
+
if (typeof card.maxWallMs === 'number' && Number.isFinite(card.maxWallMs) && card.maxWallMs > 0) spec.maxWallMs = card.maxWallMs;
|
|
223
|
+
else delete spec.maxWallMs;
|
|
224
|
+
const choice = rows && typeof card.model === 'string' ? modelChoiceFor(rows, card.model) : null;
|
|
225
|
+
if (choice) {
|
|
226
|
+
if (spec.verdictType === 'soft-green' && !spec.judge) spec.judge = resolveJobJudge(originSpec, originSpec.provider ?? choice.provider, resolveWorkerModel);
|
|
227
|
+
const entry = resolveProvider(choice.provider);
|
|
228
|
+
spec.provider = choice.provider;
|
|
229
|
+
if (choice.baseUrl) spec.baseUrl = choice.baseUrl; else delete spec.baseUrl;
|
|
230
|
+
if (choice.name !== entry.tiers.sonnet) spec.model = choice.name; else delete spec.model;
|
|
231
|
+
}
|
|
232
|
+
return spec;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `$0` validation for a REUSE start: the signed job carries the goal, checks and guardrails, so those boxes are not
|
|
237
|
+
* required here — only what the new session itself needs: a Source, a Destination, a Model that is a Settings Name,
|
|
238
|
+
* and the cap. (The spec built from the open boxes is validated by the route with `validateJob`.)
|
|
239
|
+
* @param {any} card
|
|
240
|
+
* @param {{rows?: readonly import('../providerrows.js').KeyRow[]}} [opts]
|
|
241
|
+
* @returns {{ok: true}|{ok: false, error: string}}
|
|
242
|
+
*/
|
|
243
|
+
export function validateReuseCard(card, opts = {}) {
|
|
244
|
+
if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
|
|
245
|
+
if (!modelChoiceFor(opts.rows ?? [], card.model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
|
|
246
|
+
if (typeof card.source !== 'string' || card.source.trim() === '') return { ok: false, error: 'Source is required' };
|
|
247
|
+
if (typeof card.destination !== 'string' || card.destination.trim() === '') return { ok: false, error: 'Destination is required' };
|
|
248
|
+
if (typeof card.capUsd !== 'number' || !Number.isFinite(card.capUsd) || card.capUsd <= 0) {
|
|
249
|
+
return { ok: false, error: '$ cap is required and must be a positive number' };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A Destination written as an absolute path (POSIX or a Windows drive path). @param {string} destination @returns {boolean} */
|
|
255
|
+
const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(destination);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
|
|
259
|
+
* file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
|
|
260
|
+
* @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
263
|
+
export function nonRepoSourceMessage(kind) {
|
|
264
|
+
if (kind === 'file') return 'Source is a single file. Source must be the repo folder — put the file in Destination, like src/digest.js. Nothing spent.';
|
|
265
|
+
if (kind === 'folder') return "Source is a plain folder, not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
266
|
+
return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
|
|
271
|
+
* relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
|
|
272
|
+
* source that does not peek as a repo) keeps today's behaviour: its Destination is an absolute output directory.
|
|
273
|
+
* @param {string} destination
|
|
274
|
+
* @param {boolean} isRepoLike `looksLikeRepoSource(card.source)`
|
|
275
|
+
* @returns {string|null} the plain refusal, or null when the Destination is fine
|
|
276
|
+
*/
|
|
277
|
+
export function repoDestinationProblem(destination, isRepoLike) {
|
|
278
|
+
return isRepoLike && isAbsoluteDestination(destination) ? 'Destination must be a path inside the repo, like src/digest.js' : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
112
281
|
/**
|
|
113
282
|
* `$0` validation of the job card fields the server must check BEFORE it
|
|
114
283
|
* creates a session or spends anything — never inside the async pipeline,
|
|
@@ -146,8 +315,9 @@ export function validateJobCard(card, opts = {}) {
|
|
|
146
315
|
* @param {any} card the validated job card (see {@link validateJobCard})
|
|
147
316
|
* @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
|
|
148
317
|
* scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
|
|
149
|
-
* prepareSigningFn?: Function}} [deps]
|
|
150
|
-
* just above where each is read, below.
|
|
318
|
+
* prepareSigningFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
|
|
319
|
+
* TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
|
|
320
|
+
* caller (`/api/author/start`) sets it only when the server's own same-job rule held.
|
|
151
321
|
* @returns {any} the session object
|
|
152
322
|
*/
|
|
153
323
|
export function createSession(card, deps = {}) {
|
|
@@ -172,7 +342,9 @@ export function createSession(card, deps = {}) {
|
|
|
172
342
|
/** @type {any} */
|
|
173
343
|
const state = {
|
|
174
344
|
id,
|
|
175
|
-
|
|
345
|
+
// the form as submitted + whether it is a reuse session: a refreshed page re-attaches to a live session from these
|
|
346
|
+
card: cardFields(card),
|
|
347
|
+
reuse: deps.reuse !== undefined,
|
|
176
348
|
messages: /** @type {{role: string, text: string}[]} */ ([]),
|
|
177
349
|
pendingAsk: /** @type {any} */ (null),
|
|
178
350
|
cost: /** @type {any} */ (null),
|
|
@@ -202,6 +374,28 @@ export function createSession(card, deps = {}) {
|
|
|
202
374
|
// (build item 2: onPhase used to post one, and it read as jargon/
|
|
203
375
|
// duplicate of the refusal that often followed it in the same breath).
|
|
204
376
|
progressLabel: /** @type {string|null} */ (null),
|
|
377
|
+
// the progress list (STEP_LABELS): one entry per pipeline step, updated in place — see stepStart/stepEnd below
|
|
378
|
+
steps: /** @type {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}[]} */ ([]),
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
|
|
382
|
+
const stepStart = (id, detail) => {
|
|
383
|
+
advanceSteps(state.steps, id, detail);
|
|
384
|
+
state.progressLabel = STEP_LABELS[id] ?? id;
|
|
385
|
+
};
|
|
386
|
+
/** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
|
|
387
|
+
const stepDetail = (id, detail) => { const x = latestStep(state.steps, id); if (x) x.detail = detail; };
|
|
388
|
+
/** finish the running step as done (or the latest line of `id`, when given) */
|
|
389
|
+
const stepDone = (id) => { for (const x of state.steps) if (x.status === 'running' && (id === undefined || x.id === id)) x.status = 'done'; };
|
|
390
|
+
/** @param {string} reason the running step (else the last one) fails with the code-owned reason on its own line */
|
|
391
|
+
const stepFail = (reason) => {
|
|
392
|
+
let x = state.steps.find((/** @type {any} */ y) => y.status === 'running');
|
|
393
|
+
if (!x) {
|
|
394
|
+
x = state.steps.at(-1);
|
|
395
|
+
if (!x || x.status === 'failed') { x = { id: 'stopped', label: 'stopped', status: 'running', detail: '' }; state.steps.push(x); }
|
|
396
|
+
}
|
|
397
|
+
x.status = 'failed';
|
|
398
|
+
x.detail = reason;
|
|
205
399
|
};
|
|
206
400
|
|
|
207
401
|
/** @param {'bot'|'you'|'system'} role @param {string} text */
|
|
@@ -274,8 +468,18 @@ export function createSession(card, deps = {}) {
|
|
|
274
468
|
// `confirm-turn-done` in the very next line (build item 2's "the
|
|
275
469
|
// refusal line appears twice"). The thread now carries only what a
|
|
276
470
|
// person needs to read; the step-by-step detail lives in one line.
|
|
277
|
-
|
|
471
|
+
// ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
|
|
472
|
+
if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
|
|
278
473
|
};
|
|
474
|
+
// ABANDON (hamr's ruling 2026-10-05): once abandoned the phase is frozen at 'abandoned' — whatever the in-flight
|
|
475
|
+
// run() does afterwards (a late refuse, a phase write) never overwrites it, so the one-at-a-time lock stays released.
|
|
476
|
+
let abandoned = false;
|
|
477
|
+
let phaseValue = 'starting';
|
|
478
|
+
Object.defineProperty(state, 'phase', {
|
|
479
|
+
enumerable: true,
|
|
480
|
+
get: () => phaseValue,
|
|
481
|
+
set: (v) => { if (!abandoned) phaseValue = v; },
|
|
482
|
+
});
|
|
279
483
|
/** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
|
|
280
484
|
const metered = [];
|
|
281
485
|
const onCall = (call) => {
|
|
@@ -290,19 +494,39 @@ export function createSession(card, deps = {}) {
|
|
|
290
494
|
state.cost = t.spendComplete ? `$${t.knownUsd.toFixed(4)}` : `≥$${t.knownUsd.toFixed(4)} (unpriced calls present)`;
|
|
291
495
|
state.draftSpentUsd = t.knownUsd;
|
|
292
496
|
state.draftSpendComplete = t.spendComplete;
|
|
497
|
+
// self-review 2026-10-05 (hamr's ruling A): the figure reaches DISK after every metered call, from this same
|
|
498
|
+
// tally, so an abandoned / refused / restarted session's drafting spend still counts (src/draftspend.js)
|
|
499
|
+
const nowIso = new Date().toISOString();
|
|
500
|
+
draftStartedAt ??= nowIso;
|
|
501
|
+
try {
|
|
502
|
+
writeDraftSpend(outDir, {
|
|
503
|
+
sessionId: id, spentUsd: t.knownUsd, spendComplete: t.spendComplete, provider: draftIdentity?.provider ?? null,
|
|
504
|
+
baseUrl: draftIdentity?.baseUrl ?? null, model: draftIdentity?.model ?? null, startedAt: draftStartedAt, updatedAt: nowIso,
|
|
505
|
+
});
|
|
506
|
+
} catch { /* money already spent stays in memory for a signed run; a disk fault never stops the draft */ }
|
|
293
507
|
};
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
508
|
+
/** @type {string|null} */
|
|
509
|
+
let draftStartedAt = null;
|
|
510
|
+
/** @type {{provider: string, baseUrl: string|null, model: string}|null} the worker the drafting calls ran on, set once the Model Name resolves */
|
|
511
|
+
let draftIdentity = null;
|
|
512
|
+
|
|
513
|
+
// A terminal stop is recorded ONCE, on `state` (phase + error) — never also as a chat bubble: the page shows it in the
|
|
514
|
+
// one progress line, so a thread copy was the same refusal a second and third time (hamr's live reuse test 2026-10-04).
|
|
515
|
+
/** @param {string} message @param {string} [phase] */
|
|
516
|
+
const refuse = (message, phase = 'refused') => {
|
|
517
|
+
if (abandoned) return;
|
|
518
|
+
state.phase = phase;
|
|
297
519
|
state.error = message;
|
|
298
|
-
|
|
520
|
+
stepFail(message);
|
|
299
521
|
};
|
|
300
522
|
|
|
301
523
|
async function run() {
|
|
302
524
|
// ── keys, at $0, before anything is prepared ────────────────────────
|
|
525
|
+
stepStart('setup');
|
|
303
526
|
const keyRows = rowsForHome(configHome);
|
|
304
527
|
const modelChoice = modelChoiceFor(keyRows, card.model);
|
|
305
528
|
if (!modelChoice) { refuse(`Model "${card.model}" is not a Name in Settings > Providers. Stopped — nothing spent.`); return; }
|
|
529
|
+
draftIdentity = { provider: modelChoice.provider, baseUrl: modelChoice.baseUrl ?? null, model: modelChoice.name };
|
|
306
530
|
let providerEntry;
|
|
307
531
|
try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
|
|
308
532
|
// P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
|
|
@@ -326,14 +550,29 @@ export function createSession(card, deps = {}) {
|
|
|
326
550
|
const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
|
|
327
551
|
const into = join(outDir, 'source-seed');
|
|
328
552
|
const isRepoLike = looksLikeRepoSource(card.source);
|
|
553
|
+
// hamr's ruling B (2026-10-05): ONE rule for both a fresh card and a reuse card, at $0, before the copy and any model call
|
|
554
|
+
const destProblem = repoDestinationProblem(card.destination, isRepoLike);
|
|
555
|
+
if (destProblem !== null) { refuse(destProblem); return; }
|
|
329
556
|
|
|
330
557
|
state.phase = 'preparing-source';
|
|
331
|
-
|
|
332
|
-
|
|
558
|
+
stepStart('copy');
|
|
559
|
+
// ONE rule for Destination (the same for a fresh card and a reuse card): for a REPO source it is the write fence
|
|
560
|
+
// (writeScope globs, relative to the repo — set into the spec below, never proven as a directory); for a FOLDER source
|
|
561
|
+
// it is an absolute output directory, proven by `prepareSource` -> `proveDestination`. The door routes on a peek at
|
|
562
|
+
// the source; a source that does not peek as a repo (missing path, typo, a linked worktree) would otherwise have its
|
|
563
|
+
// fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
|
|
564
|
+
// (relative) Destination is therefore handed to the door only when the source is repo-like.
|
|
565
|
+
const destIsDir = isAbsoluteDestination(card.destination);
|
|
333
566
|
const prep = await prepareSource({
|
|
334
|
-
source: card.source, into,
|
|
567
|
+
source: card.source, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
|
|
335
568
|
});
|
|
336
|
-
if (prep.stop !== null) {
|
|
569
|
+
if (prep.stop !== null) {
|
|
570
|
+
// reaching the source and checking it are two lines: a scan/freeze refusal means the copy itself worked
|
|
571
|
+
if (SOURCE_CHECK_CODES.has(/** @type {any} */ (prep).code)) stepStart('check');
|
|
572
|
+
refuse(prep.stop);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
stepStart('check');
|
|
337
576
|
|
|
338
577
|
const IS_REPO_SOURCE = prep.manifest.kind === 'repo';
|
|
339
578
|
let LANG = 'none-detected';
|
|
@@ -362,8 +601,7 @@ export function createSession(card, deps = {}) {
|
|
|
362
601
|
let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
363
602
|
if (depsGap) {
|
|
364
603
|
state.phase = 'install-needed';
|
|
365
|
-
|
|
366
|
-
say('system', `Packages missing in the copy. Run:\ncd ${prep.tree} && ${depsGap.command}`);
|
|
604
|
+
stepStart('install', `Packages missing in the copy. Run: cd ${prep.tree} && ${depsGap.command}`);
|
|
367
605
|
for (;;) {
|
|
368
606
|
state.pendingAsk = { kind: 'install-needed', tree: prep.tree, command: depsGap.command, reason: depsGap.reason };
|
|
369
607
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -371,16 +609,122 @@ export function createSession(card, deps = {}) {
|
|
|
371
609
|
state.pendingAsk = null;
|
|
372
610
|
depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
373
611
|
if (!depsGap) break;
|
|
374
|
-
|
|
612
|
+
stepDetail('install', `Still missing (${depsGap.reason}). Install, then Check again. Run: cd ${prep.tree} && ${depsGap.command}`);
|
|
375
613
|
}
|
|
376
|
-
|
|
614
|
+
stepDone('install');
|
|
615
|
+
stepStart('check');
|
|
377
616
|
state.phase = 'drafting';
|
|
378
617
|
}
|
|
379
618
|
}
|
|
380
619
|
if (!IS_REPO_SOURCE) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
620
|
+
refuse(nonRepoSourceMessage(prep.manifest.kind));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The signing half — shared by the drafted path and the same-job path: write the resolved spec, check it,
|
|
626
|
+
* run signing gates 1–3 (gate 4 only for a rubric), and land in `prepared`. `seedRef` null = the gates resolve
|
|
627
|
+
* the seed themselves.
|
|
628
|
+
* @param {any} spec @param {string|null} seedRef
|
|
629
|
+
*/
|
|
630
|
+
const finish = async (spec, seedRef) => {
|
|
631
|
+
if (abandoned) return;
|
|
632
|
+
const specFile = join(outDir, 'resolved-spec.json');
|
|
633
|
+
writeFileSync(specFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
634
|
+
state.resolvedSpecPath = specFile;
|
|
635
|
+
|
|
636
|
+
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
637
|
+
if (!jv.ok) {
|
|
638
|
+
refuse(`spec-invalid: ${jv.reds.map((r) => r.code).join(', ')}`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// An operator-written COMMAND close (an imported job's) has no declaration to ground: `prepareSigning` refuses it
|
|
643
|
+
// by design ("its stages are signed as written"). Its gate here is the byte signature of every close script —
|
|
644
|
+
// checked now, and again by the engine at run start and before every close run — and the hash the person signs
|
|
645
|
+
// is the spec's own, exactly as `bareloop run <bundle>` signs the bundle's. Nothing about the close is judged.
|
|
646
|
+
if (!spec.closeDecl) {
|
|
647
|
+
const bytes = checkCloseByteSignature(spec, outDir);
|
|
648
|
+
if (!bytes.ok) {
|
|
649
|
+
refuse(`a close script does not match its signed bytes (${bytes.reds.map((r) => r.stage).join(', ')}) — refusing to sign`);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const hash = jobSpecHash(spec);
|
|
653
|
+
writeFileSync(join(outDir, 'signing.json'), `${JSON.stringify({
|
|
654
|
+
ok: true, specHash: hash, note: 'command close — signed as written; close script bytes verified', gates: { closeBytes: { ok: true } },
|
|
655
|
+
}, null, 2)}\n`);
|
|
656
|
+
state.specHash = hash;
|
|
657
|
+
writeFileSync(join(outDir, 'card.json'), `${JSON.stringify(cardFields(card), null, 2)}\n`);
|
|
658
|
+
state.phase = 'prepared';
|
|
659
|
+
stepStart('hash', `spec hash ${hash}`);
|
|
660
|
+
stepDone();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
state.phase = 'signing-gates';
|
|
665
|
+
stepStart('gates', 'gates 1–3 cost $0; gate 4 only for a rubric');
|
|
666
|
+
const judges = closeJudges(spec.closeDecl);
|
|
667
|
+
const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
|
|
668
|
+
let judgeProvider = null;
|
|
669
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
670
|
+
let judgePrice = null;
|
|
671
|
+
if (judge) {
|
|
672
|
+
try {
|
|
673
|
+
judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
676
|
+
refuse(`${e.message} — refusing before gate 4 spends anything`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
|
|
680
|
+
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
681
|
+
if (!judgeKeyValue || judgeKeyProblem) {
|
|
682
|
+
refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
judgeProvider = buildRunnerProviders({
|
|
686
|
+
providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
|
|
687
|
+
judgeApiKey: judgeKeyValue, judgeModel: judge.model, judgeProviderName: judge.provider,
|
|
688
|
+
judgeBaseUrl: judge.provider === modelChoice.provider ? modelChoice.baseUrl : undefined,
|
|
689
|
+
}).judgeProvider;
|
|
690
|
+
}
|
|
691
|
+
const signing = await prepareSigningFn({
|
|
692
|
+
spec, workdir: prep.tree, seedRef: seedRef, timeoutMs,
|
|
693
|
+
shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
|
|
694
|
+
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
695
|
+
judgeModel: judge?.model ?? null,
|
|
696
|
+
onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
|
|
697
|
+
});
|
|
698
|
+
const signingFile = join(outDir, 'signing.json');
|
|
699
|
+
writeFileSync(signingFile, `${JSON.stringify(signing, null, 2)}\n`);
|
|
700
|
+
|
|
701
|
+
if (!signing.ok) {
|
|
702
|
+
refuse(`signing gates failed — ${signing.reds.map((r) => r.code).join(', ') || 'no work red at seed'}`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
state.specHash = signing.specHash;
|
|
706
|
+
// P5 item 3 — the form text, VERBATIM, beside the resolved spec: what "Start from this" prefills from.
|
|
707
|
+
// Written at sign-prepare (the session reached `prepared`), only by sessions created from now on.
|
|
708
|
+
writeFileSync(join(outDir, 'card.json'), `${JSON.stringify(cardFields(card), null, 2)}\n`);
|
|
709
|
+
state.phase = 'prepared';
|
|
710
|
+
stepStart('hash', `spec hash ${signing.specHash}`);
|
|
711
|
+
stepDone();
|
|
712
|
+
};
|
|
713
|
+
// REUSE WORKFLOW: the route hands in a COPY of the origin's signed spec with only the four open boxes set (see
|
|
714
|
+
// `buildReuseSpec`). No scout, no draft, no confirm turn: $0 of drafting. The signing gates still run against THIS
|
|
715
|
+
// session's fresh copy of the source (gates 1-3 are $0; a rubric's gate 4 is the only spend). The jobSpecHash is
|
|
716
|
+
// NEW (the caps and fence are in it); the workflowKey must be the origin's — a spec that drifted past the open
|
|
717
|
+
// fields is refused, never signed. The person's click on Sign & run is the signature, as for any job.
|
|
718
|
+
const MODEL = modelChoice.name;
|
|
719
|
+
if (deps.reuse) {
|
|
720
|
+
state.phase = 'drafting';
|
|
721
|
+
stepStart('reuse', 'drafting skipped ($0)');
|
|
722
|
+
const reused = JSON.parse(JSON.stringify(deps.reuse.spec));
|
|
723
|
+
if (workflowKey(reused) !== deps.reuse.workflowKey) {
|
|
724
|
+
refuse('the reused spec is not the signed workflow (more than Source, Destination and the caps differ) — refusing');
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
await finish(reused, null);
|
|
384
728
|
return;
|
|
385
729
|
}
|
|
386
730
|
|
|
@@ -422,7 +766,6 @@ export function createSession(card, deps = {}) {
|
|
|
422
766
|
const draftJudge = (() => {
|
|
423
767
|
try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
|
|
424
768
|
})();
|
|
425
|
-
const MODEL = modelChoice.name;
|
|
426
769
|
const { provider } = buildRunnerProviders({
|
|
427
770
|
providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
|
|
428
771
|
judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
|
|
@@ -432,15 +775,16 @@ export function createSession(card, deps = {}) {
|
|
|
432
775
|
// `provider` for the model boundary) and/or `scout` (bypassing the real
|
|
433
776
|
// paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
|
|
434
777
|
// machinery below with a deterministic fake, never a live provider call.
|
|
435
|
-
|
|
436
|
-
const
|
|
778
|
+
// no NEW model call starts after an abandon; a call already in flight books its usage via onCall and is discarded
|
|
779
|
+
const noCallAfterAbandon = (fn) => (...a) => { if (abandoned) throw new Error('abandoned — no new model call'); return fn(...a); };
|
|
780
|
+
const generate = noCallAfterAbandon(deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null }));
|
|
781
|
+
const confirmGenerate = noCallAfterAbandon(deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null }));
|
|
437
782
|
|
|
438
783
|
state.phase = 'drafting';
|
|
439
|
-
|
|
784
|
+
// no early "drafting" line: the real author phase (PHASE_STEP) starts it, carrying draftDetail (see onPhase)
|
|
440
785
|
// build item 4 — the DISPLAY id (card.model, e.g. "deepseek-flash"), not
|
|
441
786
|
// the internal resolved tier model string (MODEL) or the raw provider
|
|
442
787
|
// name — the same id the Model field's own dropdown showed.
|
|
443
|
-
say('system', `Drafting with ${card.model}, $${card.capUsd.toFixed(2)} cap`);
|
|
444
788
|
const authored = await authorCloseForJob({
|
|
445
789
|
judgeModel: draftJudge.model,
|
|
446
790
|
answers, verdictType, repoPath: prep.tree, lang: LANG,
|
|
@@ -455,82 +799,20 @@ export function createSession(card, deps = {}) {
|
|
|
455
799
|
});
|
|
456
800
|
|
|
457
801
|
if (!authored.ok) {
|
|
458
|
-
|
|
459
|
-
state.error = authored.stop ?? 'authoring-failed';
|
|
460
|
-
say('system', `Stopped: ${state.error}${authored.refusal ? ` — ${authored.refusal.detail}` : ''}`);
|
|
802
|
+
refuse(`Stopped: ${authored.stop ?? 'authoring-failed'}${authored.refusal ? ` — ${authored.refusal.detail}` : ''}`, authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
|
|
461
803
|
return;
|
|
462
804
|
}
|
|
463
805
|
|
|
464
806
|
if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
|
|
465
807
|
const spec = assembleSpec(draft, { ...authored, verdictType: /** @type {string} */ (authored.verdictType) });
|
|
466
|
-
|
|
467
|
-
writeFileSync(specFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
468
|
-
state.resolvedSpecPath = specFile;
|
|
469
|
-
|
|
470
|
-
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
471
|
-
if (!jv.ok) {
|
|
472
|
-
state.phase = 'refused';
|
|
473
|
-
state.error = `spec-invalid: ${jv.reds.map((r) => r.code).join(', ')}`;
|
|
474
|
-
say('system', state.error);
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
state.phase = 'signing-gates';
|
|
479
|
-
state.progressLabel = 'checking';
|
|
480
|
-
say('system', 'Checking (gates 1–3, $0; gate 4 only if rubric)…');
|
|
481
|
-
const judges = closeJudges(spec.closeDecl);
|
|
482
|
-
const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
|
|
483
|
-
let judgeProvider = null;
|
|
484
|
-
/** @type {ReturnType<typeof ratesFor>} */
|
|
485
|
-
let judgePrice = null;
|
|
486
|
-
if (judge) {
|
|
487
|
-
try {
|
|
488
|
-
judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
|
|
489
|
-
} catch (e) {
|
|
490
|
-
if (!(e instanceof ConfigError)) throw e;
|
|
491
|
-
refuse(`${e.message} — refusing before gate 4 spends anything`);
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
|
|
495
|
-
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
496
|
-
if (!judgeKeyValue || judgeKeyProblem) {
|
|
497
|
-
refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
judgeProvider = buildRunnerProviders({
|
|
501
|
-
providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
|
|
502
|
-
judgeApiKey: judgeKeyValue, judgeModel: judge.model, judgeProviderName: judge.provider,
|
|
503
|
-
judgeBaseUrl: judge.provider === modelChoice.provider ? modelChoice.baseUrl : undefined,
|
|
504
|
-
}).judgeProvider;
|
|
505
|
-
}
|
|
506
|
-
const signing = await prepareSigningFn({
|
|
507
|
-
spec, workdir: prep.tree, seedRef: authored.seedRef, timeoutMs,
|
|
508
|
-
shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
|
|
509
|
-
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
510
|
-
judgeModel: judge?.model ?? null,
|
|
511
|
-
onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
|
|
512
|
-
});
|
|
513
|
-
const signingFile = join(outDir, 'signing.json');
|
|
514
|
-
writeFileSync(signingFile, `${JSON.stringify(signing, null, 2)}\n`);
|
|
515
|
-
|
|
516
|
-
if (!signing.ok) {
|
|
517
|
-
state.phase = 'refused';
|
|
518
|
-
state.error = `signing gates failed — ${signing.reds.map((r) => r.code).join(', ') || 'no work red at seed'}`;
|
|
519
|
-
say('system', state.error);
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
state.specHash = signing.specHash;
|
|
523
|
-
state.phase = 'prepared';
|
|
524
|
-
say('bot', `SIGNING PREPARED — spec hash ${signing.specHash}`);
|
|
808
|
+
await finish(spec, authored.seedRef);
|
|
525
809
|
}
|
|
526
810
|
|
|
527
811
|
// fire-and-forget — the caller (src/panel/server.js) never awaits this; it
|
|
528
812
|
// polls `state` instead. A crash anywhere in `run()` is caught here so it
|
|
529
813
|
// can never take the panel server process down with it.
|
|
530
814
|
run().catch((e) => {
|
|
531
|
-
|
|
532
|
-
state.error = /** @type {Error} */ (e)?.message ?? String(e);
|
|
533
|
-
say('system', `internal error: ${state.error}`);
|
|
815
|
+
refuse(`internal error: ${/** @type {Error} */ (e)?.message ?? String(e)}`, 'error');
|
|
534
816
|
});
|
|
535
817
|
|
|
536
818
|
return {
|
|
@@ -545,6 +827,19 @@ export function createSession(card, deps = {}) {
|
|
|
545
827
|
answer(text);
|
|
546
828
|
return { ok: true };
|
|
547
829
|
},
|
|
830
|
+
/** The Abandon button (hamr's ruling 2026-10-05): ends a LIVE, unsigned session. Spend already booked stays booked
|
|
831
|
+
* (`draftSpentUsd`/`cost` are never touched); the phase freezes at 'abandoned' (a TERMINAL phase, so the
|
|
832
|
+
* one-at-a-time lock releases); a parked ask or install wait is released so the run unwinds. */
|
|
833
|
+
abandon: () => {
|
|
834
|
+
if (['refused', 'abandoned', 'error', 'signed', 'signing-failed'].includes(state.phase)) return { ok: false, error: 'this session is not live — nothing to abandon' };
|
|
835
|
+
abandoned = true;
|
|
836
|
+
phaseValue = 'abandoned';
|
|
837
|
+
state.error = 'Abandoned by you — money already spent stays booked.';
|
|
838
|
+
state.pendingAsk = null;
|
|
839
|
+
if (resolvePending) { const r = resolvePending; resolvePending = null; r(null); }
|
|
840
|
+
if (resolveDepsCheck) { const r = resolveDepsCheck; resolveDepsCheck = null; r(); }
|
|
841
|
+
return { ok: true };
|
|
842
|
+
},
|
|
548
843
|
/** the [Check again] button — re-runs the SAME `missingDependencies`
|
|
549
844
|
* check on the SAME copy, never a second install of its own. */
|
|
550
845
|
checkDeps: () => {
|