bareloop 0.34.0 → 0.36.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 +58 -0
- package/bareloop.context.md +276 -20
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +17 -12
- package/src/draftspend.js +197 -0
- package/src/exits.js +10 -1
- package/src/index.js +3 -3
- 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/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +84 -14
- package/src/panel/authorsession.js +490 -125
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1194 -213
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +964 -79
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- package/src/ralph.js +4 -1
- 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/source.js +43 -4
- package/src/text.js +20 -0
- package/src/u-patient.js +3 -2
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +192 -65
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +141 -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/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +23 -1
- package/types/panel/authorsession.d.ts +110 -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 +266 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +12 -0
- package/types/ralph.d.ts +19 -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/source.d.ts +13 -5
- package/types/text.d.ts +19 -0
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
|
@@ -27,16 +27,20 @@ import {
|
|
|
27
27
|
} from 'node:fs';
|
|
28
28
|
import { join, resolve, dirname } from 'node:path';
|
|
29
29
|
import {
|
|
30
|
-
prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
|
|
30
|
+
prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor,
|
|
31
31
|
} from '../source.js';
|
|
32
|
+
import {
|
|
33
|
+
worktreePath, uncommittedCount, shortHead, removeWorktree,
|
|
34
|
+
} from '../worktree.js';
|
|
32
35
|
import { detectLanguage } from '../detectlang.js';
|
|
33
36
|
import {
|
|
34
|
-
validateJob, jobSpecHash, resolveWorkerModel,
|
|
37
|
+
validateJob, jobSpecHash, workflowKey, resolveWorkerModel,
|
|
35
38
|
} from '../job.js';
|
|
36
39
|
import {
|
|
37
40
|
authorCloseForJob, assembleSpec, AUTHORED_SPEC_FIELDS, CONFIRM_AUTHORED_FIELDS, PLAIN_FOLDER_DEFERRED_FIELDS,
|
|
38
41
|
} from '../authorjob.js';
|
|
39
42
|
import { prepareSigning } from '../authorjob.js';
|
|
43
|
+
import { checkCloseByteSignature } from '../close-integrity.js';
|
|
40
44
|
import {
|
|
41
45
|
questionsFor, requiredAnswersFor, makeLoopGenerate, CONFIRM_SYSTEM,
|
|
42
46
|
} from '../authorflow.js';
|
|
@@ -47,37 +51,87 @@ import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
|
|
|
47
51
|
import { closeJudges } from '../kinds.js';
|
|
48
52
|
import { redactSecrets } from '../validate.js';
|
|
49
53
|
import { tallyCalls } from '../text.js';
|
|
54
|
+
import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
|
|
55
|
+
import { runNpmCi, NPM_CI_LOCKS } from '../npminstall.js';
|
|
50
56
|
|
|
51
57
|
/**
|
|
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).
|
|
58
|
+
* THE PROGRESS LIST's one table (hamr 2026-10-04, "one place showing all"): every pipeline step the left Chat panel
|
|
59
|
+
* lists, in the order they first appear, each with a stable id and a plain code-owned label. A step is one line that
|
|
60
|
+
* updates in place (running -> done or failed with its reason); the thread carries only chat turns.
|
|
57
61
|
* @type {Readonly<Record<string, string>>}
|
|
58
62
|
*/
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
'seed-read-done': 'reading repo',
|
|
67
|
-
stage: 'checking',
|
|
68
|
-
seed: 'reading repo',
|
|
63
|
+
export const STEP_LABELS = Object.freeze({
|
|
64
|
+
setup: 'checking setup',
|
|
65
|
+
copy: 'making worktree',
|
|
66
|
+
check: 'checking source',
|
|
67
|
+
install: 'waiting on install',
|
|
68
|
+
reuse: 'reusing signed workflow',
|
|
69
|
+
read: 'reading repo',
|
|
69
70
|
scout: 'scouting repo',
|
|
70
|
-
'scout-done': 'scouting repo',
|
|
71
|
-
confirm: 'confirming plan',
|
|
72
|
-
'confirm-turn-done': 'plan confirmed',
|
|
73
71
|
listing: 'listing files',
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
confirm: 'confirming plan',
|
|
73
|
+
draft: 'drafting',
|
|
74
|
+
calibrate: 'calibrating',
|
|
75
|
+
gates: 'checking signing gates',
|
|
76
|
+
hash: 'generating hash',
|
|
77
|
+
signed: 'signed hash',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The library's own `onPhase(name, ...)` calls (`src/authorjob.js`/`src/authorflow.js`) -> the step each one advances.
|
|
82
|
+
* A phase with no entry leaves the current step as it was.
|
|
83
|
+
* @type {Readonly<Record<string, keyof typeof STEP_LABELS>>}
|
|
84
|
+
*/
|
|
85
|
+
export const PHASE_STEP = Object.freeze({
|
|
86
|
+
'confirm-language-pick': 'confirm',
|
|
87
|
+
'confirm-round': 'confirm',
|
|
88
|
+
'confirm-done': 'confirm',
|
|
89
|
+
'author-call': 'draft',
|
|
90
|
+
'author-fallback': 'draft',
|
|
91
|
+
'seed-read': 'read',
|
|
92
|
+
'seed-read-done': 'read',
|
|
93
|
+
stage: 'gates',
|
|
94
|
+
seed: 'read',
|
|
95
|
+
scout: 'scout',
|
|
96
|
+
'scout-done': 'scout',
|
|
97
|
+
confirm: 'confirm',
|
|
98
|
+
'confirm-turn-done': 'confirm',
|
|
99
|
+
listing: 'listing',
|
|
100
|
+
'listing-done': 'listing',
|
|
101
|
+
author: 'draft',
|
|
102
|
+
rubric: 'calibrate',
|
|
103
|
+
'rubric-done': 'calibrate',
|
|
104
|
+
'rubric-scrubbed': 'calibrate',
|
|
79
105
|
});
|
|
80
106
|
|
|
107
|
+
/** @typedef {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}} StepLine */
|
|
108
|
+
|
|
109
|
+
/** @param {StepLine[]} steps @param {string} id the LATEST line carrying this id (the list is a time-ordered log; an id can repeat) */
|
|
110
|
+
export function latestStep(steps, id) {
|
|
111
|
+
for (let i = steps.length - 1; i >= 0; i -= 1) if (steps[i].id === id) return steps[i];
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The progress list is a chronological log of segments (hamr 2026-10-05): starting a different step closes the running
|
|
117
|
+
* line; a step that already has a line and starts again AFTER other steps gets a NEW line appended at the end (the
|
|
118
|
+
* earlier line keeps its status and detail). Existing lines are never moved. Re-starting the step that is still the
|
|
119
|
+
* LAST line just resumes that line.
|
|
120
|
+
* @param {StepLine[]} steps @param {string} id @param {string} [detail] (absent = keep what the line has)
|
|
121
|
+
*/
|
|
122
|
+
export function advanceSteps(steps, id, detail) {
|
|
123
|
+
for (const x of steps) if (x.status === 'running' && x.id !== id) x.status = 'done';
|
|
124
|
+
const last = steps.at(-1);
|
|
125
|
+
if (last && last.id === id) { last.status = 'running'; if (detail !== undefined) last.detail = detail; return; }
|
|
126
|
+
steps.push({ id, label: STEP_LABELS[id] ?? id, status: 'running', detail: detail ?? '' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** source-door refusals that come from CHECKING the copied source (the scan/freeze), not from reaching it */
|
|
130
|
+
const SOURCE_CHECK_CODES = new Set([
|
|
131
|
+
'source-carries-secret', 'source-env-file', 'source-symlink', 'source-nested-repo', 'source-not-text',
|
|
132
|
+
'source-file-oversize', 'source-changed-after-scan', 'source-untracked-in-repo',
|
|
133
|
+
]);
|
|
134
|
+
|
|
81
135
|
/** the confirm turn's own fixed round cap (`src/authorflow.js`'s
|
|
82
136
|
* `runConfirmTurn`: `for (let round = 1; round <= 2; round += 1)`) — named
|
|
83
137
|
* here, once, rather than re-guessed at every `onPhase('confirm-round')`
|
|
@@ -109,6 +163,125 @@ export function jobNameTaken(name, opts = {}) {
|
|
|
109
163
|
} catch { return false; }
|
|
110
164
|
}
|
|
111
165
|
|
|
166
|
+
/** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
|
|
167
|
+
export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'model', 'goal', 'source', 'destination', 'success', 'guardrails', 'judgeExamples', 'capUsd', 'maxWallMs']);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Only the form's own fields, nothing else a request body might carry (`startFrom`, stray keys).
|
|
171
|
+
* @param {any} card
|
|
172
|
+
* @returns {Record<string, any>}
|
|
173
|
+
*/
|
|
174
|
+
export function cardFields(card) {
|
|
175
|
+
/** @type {Record<string, any>} */
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const f of CARD_FIELDS) if (card?.[f] !== undefined) out[f] = card[f];
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* REUSE WORKFLOW (hamr 2026-10-03, replaces P5 item 3's same-job rule; Model opened 2026-10-04): a reuse card has exactly
|
|
183
|
+
* FIVE open boxes — Source, Destination, Model, $ cap, Time cap — and every other box is the signed workflow, shown greyed and never
|
|
184
|
+
* editable. The page greys them; THIS is what refuses (the server never trusts the page). Changing any locked box
|
|
185
|
+
* is a different job: Clear the card, which drafts.
|
|
186
|
+
*/
|
|
187
|
+
export const REUSE_OPEN_FIELDS = Object.freeze(['source', 'destination', 'model', 'capUsd', 'maxWallMs']);
|
|
188
|
+
/** every card field that is NOT open on a reuse card, in card order */
|
|
189
|
+
export const REUSE_LOCKED_FIELDS = Object.freeze(CARD_FIELDS.filter((f) => !REUSE_OPEN_FIELDS.includes(f)));
|
|
190
|
+
const REUSE_LABELS = Object.freeze(/** @type {Record<string, string>} */ ({
|
|
191
|
+
jobName: 'Job name', checkType: 'Check type', goal: 'Goal', success: 'Success', guardrails: 'Guardrails', judgeExamples: 'Judge examples',
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The first LOCKED box on `card` that differs from what the signed workflow prefilled (`origin`), or `null` when
|
|
196
|
+
* every locked box is untouched. Blank and absent read alike; whitespace is not compared.
|
|
197
|
+
* @param {Record<string, any>} card the form as it was submitted
|
|
198
|
+
* @param {Record<string, any>} origin the form as the server prefilled it
|
|
199
|
+
* @returns {string|null} the offending field's label
|
|
200
|
+
*/
|
|
201
|
+
export function lockedFieldChanged(card, origin) {
|
|
202
|
+
// whitespace is ignored: a one-line <input> drops a goal's newlines, and the signed copy never takes text from the card anyway
|
|
203
|
+
const norm = (/** @type {any} */ v) => (v === undefined || v === null ? '' : (typeof v === 'string' ? v.replace(/\s+/g, '') : v));
|
|
204
|
+
const bad = REUSE_LOCKED_FIELDS.find((f) => norm(card?.[f]) !== norm(origin?.[f]));
|
|
205
|
+
return bad === undefined ? null : (REUSE_LABELS[bad] ?? bad);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The spec a reuse signs: a COPY of the origin's signed spec with ONLY the open fields set from the card —
|
|
210
|
+
* `writeScope` from Destination, `budgetUsd` from the $ cap, `maxWallMs` from the Time cap (absent when blank), and the
|
|
211
|
+
* worker (`provider`/`baseUrl`/`model`) from the Model Name chosen in Settings > Providers, spelled exactly as normal
|
|
212
|
+
* authoring spells it (`baseUrl` only when the row has one; `model` only when the Name is not the provider's default
|
|
213
|
+
* tier). A RUBRIC (soft-green) origin with no explicit `judge` first has its judge pinned to the ORIGIN's resolved judge
|
|
214
|
+
* identity — the judge must not change because the worker did (`resolveJobJudge`, the one spelling); an explicit
|
|
215
|
+
* `judge` is kept; a deterministic job has no judge to move. Source is not in a spec (it lives beside the run). The copy
|
|
216
|
+
* hashes to a NEW `jobSpecHash` and the SAME `workflowKey` — the caller checks the second after building.
|
|
217
|
+
* @param {any} originSpec
|
|
218
|
+
* @param {Record<string, any>} card
|
|
219
|
+
* @param {readonly import('../providerrows.js').KeyRow[]} [rows] the Settings rows; without them (or without `card.model`) the worker is left as signed
|
|
220
|
+
* @returns {any}
|
|
221
|
+
*/
|
|
222
|
+
export function buildReuseSpec(originSpec, card, rows) {
|
|
223
|
+
const spec = JSON.parse(JSON.stringify(originSpec));
|
|
224
|
+
spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
|
|
225
|
+
spec.budgetUsd = card.capUsd;
|
|
226
|
+
if (typeof card.maxWallMs === 'number' && Number.isFinite(card.maxWallMs) && card.maxWallMs > 0) spec.maxWallMs = card.maxWallMs;
|
|
227
|
+
else delete spec.maxWallMs;
|
|
228
|
+
const choice = rows && typeof card.model === 'string' ? modelChoiceFor(rows, card.model) : null;
|
|
229
|
+
if (choice) {
|
|
230
|
+
if (spec.verdictType === 'soft-green' && !spec.judge) spec.judge = resolveJobJudge(originSpec, originSpec.provider ?? choice.provider, resolveWorkerModel);
|
|
231
|
+
const entry = resolveProvider(choice.provider);
|
|
232
|
+
spec.provider = choice.provider;
|
|
233
|
+
if (choice.baseUrl) spec.baseUrl = choice.baseUrl; else delete spec.baseUrl;
|
|
234
|
+
if (choice.name !== entry.tiers.sonnet) spec.model = choice.name; else delete spec.model;
|
|
235
|
+
}
|
|
236
|
+
return spec;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* `$0` validation for a REUSE start: the signed job carries the goal, checks and guardrails, so those boxes are not
|
|
241
|
+
* required here — only what the new session itself needs: a Source, a Destination, a Model that is a Settings Name,
|
|
242
|
+
* and the cap. (The spec built from the open boxes is validated by the route with `validateJob`.)
|
|
243
|
+
* @param {any} card
|
|
244
|
+
* @param {{rows?: readonly import('../providerrows.js').KeyRow[]}} [opts]
|
|
245
|
+
* @returns {{ok: true}|{ok: false, error: string}}
|
|
246
|
+
*/
|
|
247
|
+
export function validateReuseCard(card, opts = {}) {
|
|
248
|
+
if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
|
|
249
|
+
if (!modelChoiceFor(opts.rows ?? [], card.model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
|
|
250
|
+
if (typeof card.source !== 'string' || card.source.trim() === '') return { ok: false, error: 'Source is required' };
|
|
251
|
+
if (typeof card.destination !== 'string' || card.destination.trim() === '') return { ok: false, error: 'Destination is required' };
|
|
252
|
+
if (typeof card.capUsd !== 'number' || !Number.isFinite(card.capUsd) || card.capUsd <= 0) {
|
|
253
|
+
return { ok: false, error: '$ cap is required and must be a positive number' };
|
|
254
|
+
}
|
|
255
|
+
return { ok: true };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A Destination written as an absolute path (POSIX or a Windows drive path). @param {string} destination @returns {boolean} */
|
|
259
|
+
const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(destination);
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
|
|
263
|
+
* file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
|
|
264
|
+
* @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
|
|
265
|
+
* @returns {string}
|
|
266
|
+
*/
|
|
267
|
+
export function nonRepoSourceMessage(kind) {
|
|
268
|
+
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.';
|
|
269
|
+
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.";
|
|
270
|
+
return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
|
|
275
|
+
* relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
|
|
276
|
+
* source that does not peek as a repo) keeps today's behaviour: its Destination is an absolute output directory.
|
|
277
|
+
* @param {string} destination
|
|
278
|
+
* @param {boolean} isRepoLike `looksLikeRepoSource(card.source)`
|
|
279
|
+
* @returns {string|null} the plain refusal, or null when the Destination is fine
|
|
280
|
+
*/
|
|
281
|
+
export function repoDestinationProblem(destination, isRepoLike) {
|
|
282
|
+
return isRepoLike && isAbsoluteDestination(destination) ? 'Destination must be a path inside the repo, like src/digest.js' : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
112
285
|
/**
|
|
113
286
|
* `$0` validation of the job card fields the server must check BEFORE it
|
|
114
287
|
* creates a session or spends anything — never inside the async pipeline,
|
|
@@ -146,8 +319,9 @@ export function validateJobCard(card, opts = {}) {
|
|
|
146
319
|
* @param {any} card the validated job card (see {@link validateJobCard})
|
|
147
320
|
* @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
|
|
148
321
|
* scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
|
|
149
|
-
* prepareSigningFn?: Function
|
|
150
|
-
* just above where each is read, below.
|
|
322
|
+
* prepareSigningFn?: Function, npmCiFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
|
|
323
|
+
* TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
|
|
324
|
+
* caller (`/api/author/start`) sets it only when the server's own same-job rule held.
|
|
151
325
|
* @returns {any} the session object
|
|
152
326
|
*/
|
|
153
327
|
export function createSession(card, deps = {}) {
|
|
@@ -172,7 +346,9 @@ export function createSession(card, deps = {}) {
|
|
|
172
346
|
/** @type {any} */
|
|
173
347
|
const state = {
|
|
174
348
|
id,
|
|
175
|
-
|
|
349
|
+
// the form as submitted + whether it is a reuse session: a refreshed page re-attaches to a live session from these
|
|
350
|
+
card: cardFields(card),
|
|
351
|
+
reuse: deps.reuse !== undefined,
|
|
176
352
|
messages: /** @type {{role: string, text: string}[]} */ ([]),
|
|
177
353
|
pendingAsk: /** @type {any} */ (null),
|
|
178
354
|
cost: /** @type {any} */ (null),
|
|
@@ -202,6 +378,45 @@ export function createSession(card, deps = {}) {
|
|
|
202
378
|
// (build item 2: onPhase used to post one, and it read as jargon/
|
|
203
379
|
// duplicate of the refusal that often followed it in the same breath).
|
|
204
380
|
progressLabel: /** @type {string|null} */ (null),
|
|
381
|
+
// the progress list (STEP_LABELS): one entry per pipeline step, updated in place — see stepStart/stepEnd below
|
|
382
|
+
steps: /** @type {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}[]} */ ([]),
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// The drafting LOG's step events (src/draftspend.js, DRAFT_LOG_FILE): after every progress-list change, diff the list
|
|
386
|
+
// against what was already logged and append the start / end / re-open of each line — the ONE writer of step times.
|
|
387
|
+
/** @type {('running'|'ended')[]} */
|
|
388
|
+
const loggedSteps = [];
|
|
389
|
+
const logSteps = () => {
|
|
390
|
+
const at = new Date().toISOString();
|
|
391
|
+
state.steps.forEach((/** @type {any} */ x, no) => {
|
|
392
|
+
if (loggedSteps[no] === undefined) { appendDraftLog(outDir, { kind: 'step-start', no, id: x.id, label: x.label, at }); loggedSteps[no] = 'running'; }
|
|
393
|
+
if (x.status === 'running' && loggedSteps[no] === 'ended') { appendDraftLog(outDir, { kind: 'step-reopen', no, at }); loggedSteps[no] = 'running'; }
|
|
394
|
+
if (x.status !== 'running' && loggedSteps[no] === 'running') { appendDraftLog(outDir, { kind: 'step-end', no, status: x.status, label: x.label, at }); loggedSteps[no] = 'ended'; }
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
|
|
399
|
+
const stepStart = (id, detail) => {
|
|
400
|
+
advanceSteps(state.steps, id, detail);
|
|
401
|
+
state.progressLabel = STEP_LABELS[id] ?? id;
|
|
402
|
+
logSteps();
|
|
403
|
+
};
|
|
404
|
+
/** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
|
|
405
|
+
const stepDetail = (id, detail) => { const x = latestStep(state.steps, id); if (x) x.detail = detail; };
|
|
406
|
+
/** @param {string} id @param {string} label the latest line of `id` reads `label` instead of its table label (the install step: bareloop installing vs the person) */
|
|
407
|
+
const stepLabel = (id, label) => { const x = latestStep(state.steps, id); if (x) x.label = label; };
|
|
408
|
+
/** finish the running step as done (or the latest line of `id`, when given) */
|
|
409
|
+
const stepDone = (id) => { for (const x of state.steps) if (x.status === 'running' && (id === undefined || x.id === id)) x.status = 'done'; logSteps(); };
|
|
410
|
+
/** @param {string} reason the running step (else the last one) fails with the code-owned reason on its own line */
|
|
411
|
+
const stepFail = (reason) => {
|
|
412
|
+
let x = state.steps.find((/** @type {any} */ y) => y.status === 'running');
|
|
413
|
+
if (!x) {
|
|
414
|
+
x = state.steps.at(-1);
|
|
415
|
+
if (!x || x.status === 'failed') { x = { id: 'stopped', label: 'stopped', status: 'running', detail: '' }; state.steps.push(x); }
|
|
416
|
+
}
|
|
417
|
+
x.status = 'failed';
|
|
418
|
+
x.detail = reason;
|
|
419
|
+
logSteps();
|
|
205
420
|
};
|
|
206
421
|
|
|
207
422
|
/** @param {'bot'|'you'|'system'} role @param {string} text */
|
|
@@ -274,8 +489,18 @@ export function createSession(card, deps = {}) {
|
|
|
274
489
|
// `confirm-turn-done` in the very next line (build item 2's "the
|
|
275
490
|
// refusal line appears twice"). The thread now carries only what a
|
|
276
491
|
// person needs to read; the step-by-step detail lives in one line.
|
|
277
|
-
|
|
492
|
+
// ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
|
|
493
|
+
if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
|
|
278
494
|
};
|
|
495
|
+
// ABANDON (hamr's ruling 2026-10-05): once abandoned the phase is frozen at 'abandoned' — whatever the in-flight
|
|
496
|
+
// run() does afterwards (a late refuse, a phase write) never overwrites it, so the one-at-a-time lock stays released.
|
|
497
|
+
let abandoned = false;
|
|
498
|
+
let phaseValue = 'starting';
|
|
499
|
+
Object.defineProperty(state, 'phase', {
|
|
500
|
+
enumerable: true,
|
|
501
|
+
get: () => phaseValue,
|
|
502
|
+
set: (v) => { if (!abandoned) phaseValue = v; },
|
|
503
|
+
});
|
|
279
504
|
/** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
|
|
280
505
|
const metered = [];
|
|
281
506
|
const onCall = (call) => {
|
|
@@ -290,19 +515,57 @@ export function createSession(card, deps = {}) {
|
|
|
290
515
|
state.cost = t.spendComplete ? `$${t.knownUsd.toFixed(4)}` : `≥$${t.knownUsd.toFixed(4)} (unpriced calls present)`;
|
|
291
516
|
state.draftSpentUsd = t.knownUsd;
|
|
292
517
|
state.draftSpendComplete = t.spendComplete;
|
|
518
|
+
// self-review 2026-10-05 (hamr's ruling A): the figure reaches DISK after every metered call, from this same
|
|
519
|
+
// tally, so an abandoned / refused / restarted session's drafting spend still counts (src/draftspend.js)
|
|
520
|
+
const nowIso = new Date().toISOString();
|
|
521
|
+
draftStartedAt ??= nowIso;
|
|
522
|
+
// the same call, one line in the drafting LOG (the part a run's views show first): the step running right now,
|
|
523
|
+
// the call's label, the drafting model, its cost (null stays null: unpriced is never $0). One owner: this onCall.
|
|
524
|
+
const runningNo = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
|
|
525
|
+
const callNo = runningNo === -1 ? state.steps.length - 1 : runningNo;
|
|
526
|
+
appendDraftLog(outDir, {
|
|
527
|
+
kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
|
|
528
|
+
costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
|
|
529
|
+
});
|
|
530
|
+
try {
|
|
531
|
+
writeDraftSpend(outDir, {
|
|
532
|
+
sessionId: id, spentUsd: t.knownUsd, spendComplete: t.spendComplete, provider: draftIdentity?.provider ?? null,
|
|
533
|
+
baseUrl: draftIdentity?.baseUrl ?? null, model: draftIdentity?.model ?? null, startedAt: draftStartedAt, updatedAt: nowIso,
|
|
534
|
+
});
|
|
535
|
+
} catch { /* money already spent stays in memory for a signed run; a disk fault never stops the draft */ }
|
|
293
536
|
};
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
537
|
+
/** @type {string|null} */
|
|
538
|
+
let draftStartedAt = null;
|
|
539
|
+
/** P6 item 1: the worktree this session made in the person's repo (null until made, and again once removed) @type {{repo: string, dir: string}|null} */
|
|
540
|
+
let madeWorktree = null;
|
|
541
|
+
/** a session that ends WITHOUT ever running (refused, errored, abandoned) takes its worktree with it; a signed one keeps it for the run */
|
|
542
|
+
const dropWorktree = () => {
|
|
543
|
+
if (madeWorktree === null) return;
|
|
544
|
+
const w = madeWorktree;
|
|
545
|
+
madeWorktree = null;
|
|
546
|
+
removeWorktree(w.repo, w.dir);
|
|
547
|
+
};
|
|
548
|
+
/** @type {{provider: string, baseUrl: string|null, model: string}|null} the worker the drafting calls ran on, set once the Model Name resolves */
|
|
549
|
+
let draftIdentity = null;
|
|
550
|
+
|
|
551
|
+
// A terminal stop is recorded ONCE, on `state` (phase + error) — never also as a chat bubble: the page shows it in the
|
|
552
|
+
// one progress line, so a thread copy was the same refusal a second and third time (hamr's live reuse test 2026-10-04).
|
|
553
|
+
/** @param {string} message @param {string} [phase] */
|
|
554
|
+
const refuse = (message, phase = 'refused') => {
|
|
555
|
+
if (abandoned) return;
|
|
556
|
+
dropWorktree();
|
|
557
|
+
state.phase = phase;
|
|
297
558
|
state.error = message;
|
|
298
|
-
|
|
559
|
+
stepFail(message);
|
|
299
560
|
};
|
|
300
561
|
|
|
301
562
|
async function run() {
|
|
302
563
|
// ── keys, at $0, before anything is prepared ────────────────────────
|
|
564
|
+
stepStart('setup');
|
|
303
565
|
const keyRows = rowsForHome(configHome);
|
|
304
566
|
const modelChoice = modelChoiceFor(keyRows, card.model);
|
|
305
567
|
if (!modelChoice) { refuse(`Model "${card.model}" is not a Name in Settings > Providers. Stopped — nothing spent.`); return; }
|
|
568
|
+
draftIdentity = { provider: modelChoice.provider, baseUrl: modelChoice.baseUrl ?? null, model: modelChoice.name };
|
|
306
569
|
let providerEntry;
|
|
307
570
|
try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
|
|
308
571
|
// P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
|
|
@@ -326,14 +589,42 @@ export function createSession(card, deps = {}) {
|
|
|
326
589
|
const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
|
|
327
590
|
const into = join(outDir, 'source-seed');
|
|
328
591
|
const isRepoLike = looksLikeRepoSource(card.source);
|
|
592
|
+
// 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
|
|
593
|
+
const destProblem = repoDestinationProblem(card.destination, isRepoLike);
|
|
594
|
+
if (destProblem !== null) { refuse(destProblem); return; }
|
|
329
595
|
|
|
330
596
|
state.phase = 'preparing-source';
|
|
331
|
-
|
|
332
|
-
|
|
597
|
+
stepStart('copy');
|
|
598
|
+
// P6 item 1 (hamr 2026-10-05, Q1 = A / Q3 = A): a REPO source gets a worktree in the person's own repo, made now at
|
|
599
|
+
// drafting start — `.bareloop/wt/<session id>` at their current commit. Edits they have not committed are not in it,
|
|
600
|
+
// and the progress line says so (a notice, never a refusal).
|
|
601
|
+
const repoRoot = isRepoLike ? nearestGitAncestor(resolve(card.source))?.dir ?? null : null;
|
|
602
|
+
if (repoRoot !== null) {
|
|
603
|
+
const dirty = uncommittedCount(repoRoot);
|
|
604
|
+
if (dirty > 0) {
|
|
605
|
+
stepDetail('copy', `${dirty} uncommitted change(s) in your repo are not in this job — it starts from commit ${shortHead(repoRoot) ?? 'HEAD'}.`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// ONE rule for Destination (the same for a fresh card and a reuse card): for a REPO source it is the write fence
|
|
609
|
+
// (writeScope globs, relative to the repo — set into the spec below, never proven as a directory); for a FOLDER source
|
|
610
|
+
// it is an absolute output directory, proven by `prepareSource` -> `proveDestination`. The door routes on a peek at
|
|
611
|
+
// the source; a source that does not peek as a repo (missing path, typo, a linked worktree) would otherwise have its
|
|
612
|
+
// fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
|
|
613
|
+
// (relative) Destination is therefore handed to the door only when the source is repo-like.
|
|
614
|
+
const destIsDir = isAbsoluteDestination(card.destination);
|
|
333
615
|
const prep = await prepareSource({
|
|
334
|
-
source: card.source, into,
|
|
616
|
+
source: card.source, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
|
|
617
|
+
...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
|
|
335
618
|
});
|
|
336
|
-
if (prep.stop
|
|
619
|
+
if (repoRoot !== null && prep.stop === null && prep.manifest?.worktree) madeWorktree = { repo: repoRoot, dir: prep.manifest.worktree };
|
|
620
|
+
if (abandoned) dropWorktree();
|
|
621
|
+
if (prep.stop !== null) {
|
|
622
|
+
// reaching the source and checking it are two lines: a scan/freeze refusal means the copy itself worked
|
|
623
|
+
if (SOURCE_CHECK_CODES.has(/** @type {any} */ (prep).code)) stepStart('check');
|
|
624
|
+
refuse(prep.stop);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
stepStart('check');
|
|
337
628
|
|
|
338
629
|
const IS_REPO_SOURCE = prep.manifest.kind === 'repo';
|
|
339
630
|
let LANG = 'none-detected';
|
|
@@ -348,22 +639,38 @@ export function createSession(card, deps = {}) {
|
|
|
348
639
|
LANG = langResult.kind === 'resolved' ? langResult.lang : (langResult.kind === 'ambiguous' ? langResult.candidates[0] : 'none-detected');
|
|
349
640
|
|
|
350
641
|
// ── the install gap, WAITED FOR, never a dead end (build item 1,
|
|
351
|
-
// mirroring F182's fix in `src/interviewrun.js`) ── the
|
|
352
|
-
// session works from (`prep.tree`) is
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
// (
|
|
360
|
-
// `src/interviewrun.js` uses) on demand, via the "Check again" button
|
|
361
|
-
// (`checkDeps()`, below), instead of ending the session.
|
|
642
|
+
// mirroring F182's fix in `src/interviewrun.js`) ── the tree this
|
|
643
|
+
// session works from (`prep.tree`) is the job's own WORKTREE (P6 item 1:
|
|
644
|
+
// a detached checkout of the person's current commit, inside their
|
|
645
|
+
// repo), so a JS/TS repo's tree never carries `node_modules` until
|
|
646
|
+
// someone installs there. Refusing outright here was ITSELF the F182
|
|
647
|
+
// class of bug one layer up. bareloop still never runs an install
|
|
648
|
+
// itself — this only re-checks the SAME worktree on demand, via the
|
|
649
|
+
// "Check again" button (`checkDeps()`, below), instead of ending the
|
|
650
|
+
// session. (P6 item 3: for an npm lock file bareloop runs `npm ci` itself first, above.)
|
|
362
651
|
let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
652
|
+
// P6 item 3: a committed npm lock file -> bareloop runs `npm ci --ignore-scripts` itself, $0, before
|
|
653
|
+
// any model call. Any failure (or another lock file) falls through to the wait below, unchanged.
|
|
654
|
+
/** @type {string|null} */
|
|
655
|
+
let installFailed = null;
|
|
656
|
+
if (depsGap && depsGap.lockFile && NPM_CI_LOCKS.includes(depsGap.lockFile)) {
|
|
657
|
+
stepStart('install', 'Installing packages (npm ci)…');
|
|
658
|
+
stepLabel('install', 'installing packages');
|
|
659
|
+
state.progressLabel = 'installing packages';
|
|
660
|
+
const ci = await (deps.npmCiFn ?? runNpmCi)(depsGap.dir, {});
|
|
661
|
+
if (ci.ok) {
|
|
662
|
+
depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
663
|
+
if (!depsGap) {
|
|
664
|
+
stepDetail('install', 'Installed packages (npm ci).');
|
|
665
|
+
stepDone('install');
|
|
666
|
+
stepStart('check');
|
|
667
|
+
} else installFailed = 'it ran but node_modules is still missing';
|
|
668
|
+
} else installFailed = ci.reason;
|
|
669
|
+
}
|
|
363
670
|
if (depsGap) {
|
|
364
671
|
state.phase = 'install-needed';
|
|
365
|
-
|
|
366
|
-
|
|
672
|
+
stepLabel('install', STEP_LABELS.install); // the person is the one installing now
|
|
673
|
+
stepStart('install', `${installFailed ? `Installing packages (npm ci) failed: ${redactSecrets(installFailed)}. ` : ''}Packages missing in the job's worktree. Run: cd ${prep.tree} && ${depsGap.command}`);
|
|
367
674
|
for (;;) {
|
|
368
675
|
state.pendingAsk = { kind: 'install-needed', tree: prep.tree, command: depsGap.command, reason: depsGap.reason };
|
|
369
676
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -371,16 +678,122 @@ export function createSession(card, deps = {}) {
|
|
|
371
678
|
state.pendingAsk = null;
|
|
372
679
|
depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
373
680
|
if (!depsGap) break;
|
|
374
|
-
|
|
681
|
+
stepDetail('install', `Still missing (${depsGap.reason}). Install, then Check again. Run: cd ${prep.tree} && ${depsGap.command}`);
|
|
375
682
|
}
|
|
376
|
-
|
|
683
|
+
stepDone('install');
|
|
684
|
+
stepStart('check');
|
|
377
685
|
state.phase = 'drafting';
|
|
378
686
|
}
|
|
379
687
|
}
|
|
380
688
|
if (!IS_REPO_SOURCE) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
689
|
+
refuse(nonRepoSourceMessage(prep.manifest.kind));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* The signing half — shared by the drafted path and the same-job path: write the resolved spec, check it,
|
|
695
|
+
* run signing gates 1–3 (gate 4 only for a rubric), and land in `prepared`. `seedRef` null = the gates resolve
|
|
696
|
+
* the seed themselves.
|
|
697
|
+
* @param {any} spec @param {string|null} seedRef
|
|
698
|
+
*/
|
|
699
|
+
const finish = async (spec, seedRef) => {
|
|
700
|
+
if (abandoned) return;
|
|
701
|
+
const specFile = join(outDir, 'resolved-spec.json');
|
|
702
|
+
writeFileSync(specFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
703
|
+
state.resolvedSpecPath = specFile;
|
|
704
|
+
|
|
705
|
+
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
706
|
+
if (!jv.ok) {
|
|
707
|
+
refuse(`spec-invalid: ${jv.reds.map((r) => r.code).join(', ')}`);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// An operator-written COMMAND close (an imported job's) has no declaration to ground: `prepareSigning` refuses it
|
|
712
|
+
// by design ("its stages are signed as written"). Its gate here is the byte signature of every close script —
|
|
713
|
+
// checked now, and again by the engine at run start and before every close run — and the hash the person signs
|
|
714
|
+
// is the spec's own, exactly as `bareloop run <bundle>` signs the bundle's. Nothing about the close is judged.
|
|
715
|
+
if (!spec.closeDecl) {
|
|
716
|
+
const bytes = checkCloseByteSignature(spec, outDir);
|
|
717
|
+
if (!bytes.ok) {
|
|
718
|
+
refuse(`a close script does not match its signed bytes (${bytes.reds.map((r) => r.stage).join(', ')}) — refusing to sign`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const hash = jobSpecHash(spec);
|
|
722
|
+
writeFileSync(join(outDir, 'signing.json'), `${JSON.stringify({
|
|
723
|
+
ok: true, specHash: hash, note: 'command close — signed as written; close script bytes verified', gates: { closeBytes: { ok: true } },
|
|
724
|
+
}, null, 2)}\n`);
|
|
725
|
+
state.specHash = hash;
|
|
726
|
+
writeFileSync(join(outDir, 'card.json'), `${JSON.stringify(cardFields(card), null, 2)}\n`);
|
|
727
|
+
state.phase = 'prepared';
|
|
728
|
+
stepStart('hash', `spec hash ${hash}`);
|
|
729
|
+
stepDone();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
state.phase = 'signing-gates';
|
|
734
|
+
stepStart('gates', 'gates 1–3 cost $0; gate 4 only for a rubric');
|
|
735
|
+
const judges = closeJudges(spec.closeDecl);
|
|
736
|
+
const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
|
|
737
|
+
let judgeProvider = null;
|
|
738
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
739
|
+
let judgePrice = null;
|
|
740
|
+
if (judge) {
|
|
741
|
+
try {
|
|
742
|
+
judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
|
|
743
|
+
} catch (e) {
|
|
744
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
745
|
+
refuse(`${e.message} — refusing before gate 4 spends anything`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
|
|
749
|
+
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
750
|
+
if (!judgeKeyValue || judgeKeyProblem) {
|
|
751
|
+
refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
judgeProvider = buildRunnerProviders({
|
|
755
|
+
providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
|
|
756
|
+
judgeApiKey: judgeKeyValue, judgeModel: judge.model, judgeProviderName: judge.provider,
|
|
757
|
+
judgeBaseUrl: judge.provider === modelChoice.provider ? modelChoice.baseUrl : undefined,
|
|
758
|
+
}).judgeProvider;
|
|
759
|
+
}
|
|
760
|
+
const signing = await prepareSigningFn({
|
|
761
|
+
spec, workdir: prep.tree, seedRef: seedRef, timeoutMs,
|
|
762
|
+
shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
|
|
763
|
+
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
764
|
+
judgeModel: judge?.model ?? null,
|
|
765
|
+
onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
|
|
766
|
+
});
|
|
767
|
+
const signingFile = join(outDir, 'signing.json');
|
|
768
|
+
writeFileSync(signingFile, `${JSON.stringify(signing, null, 2)}\n`);
|
|
769
|
+
|
|
770
|
+
if (!signing.ok) {
|
|
771
|
+
refuse(`signing gates failed — ${signing.reds.map((r) => r.code).join(', ') || 'no work red at seed'}`);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
state.specHash = signing.specHash;
|
|
775
|
+
// P5 item 3 — the form text, VERBATIM, beside the resolved spec: what "Start from this" prefills from.
|
|
776
|
+
// Written at sign-prepare (the session reached `prepared`), only by sessions created from now on.
|
|
777
|
+
writeFileSync(join(outDir, 'card.json'), `${JSON.stringify(cardFields(card), null, 2)}\n`);
|
|
778
|
+
state.phase = 'prepared';
|
|
779
|
+
stepStart('hash', `spec hash ${signing.specHash}`);
|
|
780
|
+
stepDone();
|
|
781
|
+
};
|
|
782
|
+
// REUSE WORKFLOW: the route hands in a COPY of the origin's signed spec with only the four open boxes set (see
|
|
783
|
+
// `buildReuseSpec`). No scout, no draft, no confirm turn: $0 of drafting. The signing gates still run against THIS
|
|
784
|
+
// session's fresh copy of the source (gates 1-3 are $0; a rubric's gate 4 is the only spend). The jobSpecHash is
|
|
785
|
+
// NEW (the caps and fence are in it); the workflowKey must be the origin's — a spec that drifted past the open
|
|
786
|
+
// fields is refused, never signed. The person's click on Sign & run is the signature, as for any job.
|
|
787
|
+
const MODEL = modelChoice.name;
|
|
788
|
+
if (deps.reuse) {
|
|
789
|
+
state.phase = 'drafting';
|
|
790
|
+
stepStart('reuse', 'drafting skipped ($0)');
|
|
791
|
+
const reused = JSON.parse(JSON.stringify(deps.reuse.spec));
|
|
792
|
+
if (workflowKey(reused) !== deps.reuse.workflowKey) {
|
|
793
|
+
refuse('the reused spec is not the signed workflow (more than Source, Destination and the caps differ) — refusing');
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
await finish(reused, null);
|
|
384
797
|
return;
|
|
385
798
|
}
|
|
386
799
|
|
|
@@ -422,7 +835,6 @@ export function createSession(card, deps = {}) {
|
|
|
422
835
|
const draftJudge = (() => {
|
|
423
836
|
try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
|
|
424
837
|
})();
|
|
425
|
-
const MODEL = modelChoice.name;
|
|
426
838
|
const { provider } = buildRunnerProviders({
|
|
427
839
|
providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
|
|
428
840
|
judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
|
|
@@ -432,15 +844,16 @@ export function createSession(card, deps = {}) {
|
|
|
432
844
|
// `provider` for the model boundary) and/or `scout` (bypassing the real
|
|
433
845
|
// paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
|
|
434
846
|
// machinery below with a deterministic fake, never a live provider call.
|
|
435
|
-
|
|
436
|
-
const
|
|
847
|
+
// no NEW model call starts after an abandon; a call already in flight books its usage via onCall and is discarded
|
|
848
|
+
const noCallAfterAbandon = (fn) => (...a) => { if (abandoned) throw new Error('abandoned — no new model call'); return fn(...a); };
|
|
849
|
+
const generate = noCallAfterAbandon(deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null }));
|
|
850
|
+
const confirmGenerate = noCallAfterAbandon(deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null }));
|
|
437
851
|
|
|
438
852
|
state.phase = 'drafting';
|
|
439
|
-
|
|
853
|
+
// no early "drafting" line: the real author phase (PHASE_STEP) starts it, carrying draftDetail (see onPhase)
|
|
440
854
|
// build item 4 — the DISPLAY id (card.model, e.g. "deepseek-flash"), not
|
|
441
855
|
// the internal resolved tier model string (MODEL) or the raw provider
|
|
442
856
|
// name — the same id the Model field's own dropdown showed.
|
|
443
|
-
say('system', `Drafting with ${card.model}, $${card.capUsd.toFixed(2)} cap`);
|
|
444
857
|
const authored = await authorCloseForJob({
|
|
445
858
|
judgeModel: draftJudge.model,
|
|
446
859
|
answers, verdictType, repoPath: prep.tree, lang: LANG,
|
|
@@ -455,82 +868,20 @@ export function createSession(card, deps = {}) {
|
|
|
455
868
|
});
|
|
456
869
|
|
|
457
870
|
if (!authored.ok) {
|
|
458
|
-
|
|
459
|
-
state.error = authored.stop ?? 'authoring-failed';
|
|
460
|
-
say('system', `Stopped: ${state.error}${authored.refusal ? ` — ${authored.refusal.detail}` : ''}`);
|
|
871
|
+
refuse(`Stopped: ${authored.stop ?? 'authoring-failed'}${authored.refusal ? ` — ${authored.refusal.detail}` : ''}`, authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
|
|
461
872
|
return;
|
|
462
873
|
}
|
|
463
874
|
|
|
464
875
|
if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
|
|
465
876
|
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}`);
|
|
877
|
+
await finish(spec, authored.seedRef);
|
|
525
878
|
}
|
|
526
879
|
|
|
527
880
|
// fire-and-forget — the caller (src/panel/server.js) never awaits this; it
|
|
528
881
|
// polls `state` instead. A crash anywhere in `run()` is caught here so it
|
|
529
882
|
// can never take the panel server process down with it.
|
|
530
883
|
run().catch((e) => {
|
|
531
|
-
|
|
532
|
-
state.error = /** @type {Error} */ (e)?.message ?? String(e);
|
|
533
|
-
say('system', `internal error: ${state.error}`);
|
|
884
|
+
refuse(`internal error: ${/** @type {Error} */ (e)?.message ?? String(e)}`, 'error');
|
|
534
885
|
});
|
|
535
886
|
|
|
536
887
|
return {
|
|
@@ -545,6 +896,20 @@ export function createSession(card, deps = {}) {
|
|
|
545
896
|
answer(text);
|
|
546
897
|
return { ok: true };
|
|
547
898
|
},
|
|
899
|
+
/** The Abandon button (hamr's ruling 2026-10-05): ends a LIVE, unsigned session. Spend already booked stays booked
|
|
900
|
+
* (`draftSpentUsd`/`cost` are never touched); the phase freezes at 'abandoned' (a TERMINAL phase, so the
|
|
901
|
+
* one-at-a-time lock releases); a parked ask or install wait is released so the run unwinds. */
|
|
902
|
+
abandon: () => {
|
|
903
|
+
if (['refused', 'abandoned', 'error', 'signed', 'signing-failed'].includes(state.phase)) return { ok: false, error: 'this session is not live — nothing to abandon' };
|
|
904
|
+
abandoned = true;
|
|
905
|
+
phaseValue = 'abandoned';
|
|
906
|
+
dropWorktree();
|
|
907
|
+
state.error = 'Abandoned by you — money already spent stays booked.';
|
|
908
|
+
state.pendingAsk = null;
|
|
909
|
+
if (resolvePending) { const r = resolvePending; resolvePending = null; r(null); }
|
|
910
|
+
if (resolveDepsCheck) { const r = resolveDepsCheck; resolveDepsCheck = null; r(); }
|
|
911
|
+
return { ok: true };
|
|
912
|
+
},
|
|
548
913
|
/** the [Check again] button — re-runs the SAME `missingDependencies`
|
|
549
914
|
* check on the SAME copy, never a second install of its own. */
|
|
550
915
|
checkDeps: () => {
|