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.
Files changed (68) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bareloop.context.md +276 -20
  3. package/package.json +1 -1
  4. package/src/authorrun.js +3 -2
  5. package/src/bundlerun.js +17 -12
  6. package/src/draftspend.js +197 -0
  7. package/src/exits.js +10 -1
  8. package/src/index.js +3 -3
  9. package/src/interviewrun.js +7 -7
  10. package/src/job.js +37 -0
  11. package/src/judged.js +2 -2
  12. package/src/kinds.js +2 -1
  13. package/src/ledger.js +101 -5
  14. package/src/legs.js +171 -0
  15. package/src/monthly.js +111 -72
  16. package/src/movefile.js +22 -0
  17. package/src/npminstall.js +55 -0
  18. package/src/panel/authorroutes.js +84 -14
  19. package/src/panel/authorsession.js +490 -125
  20. package/src/panel/importroutes.js +359 -0
  21. package/src/panel/importrun.js +203 -0
  22. package/src/panel/index.html +1194 -213
  23. package/src/panel/runroutes.js +219 -0
  24. package/src/panel/server.js +964 -79
  25. package/src/panel/settingsroutes.js +52 -2
  26. package/src/panel/status.js +51 -0
  27. package/src/planrun.js +60 -26
  28. package/src/providerrows.js +17 -0
  29. package/src/ralph.js +4 -1
  30. package/src/replay.js +139 -19
  31. package/src/replayio.js +11 -4
  32. package/src/reuse.js +20 -3
  33. package/src/run.js +6 -3
  34. package/src/runlist.js +75 -7
  35. package/src/source.js +43 -4
  36. package/src/text.js +20 -0
  37. package/src/u-patient.js +3 -2
  38. package/src/u-readout.js +9 -1
  39. package/src/u-watchdog.mjs +4 -2
  40. package/src/userrun.js +192 -65
  41. package/src/worktree.js +120 -0
  42. package/types/draftspend.d.ts +141 -0
  43. package/types/job.d.ts +25 -0
  44. package/types/ledger.d.ts +44 -0
  45. package/types/legs.d.ts +103 -0
  46. package/types/monthly.d.ts +23 -18
  47. package/types/movefile.d.ts +9 -0
  48. package/types/npminstall.d.ts +30 -0
  49. package/types/panel/authorroutes.d.ts +23 -1
  50. package/types/panel/authorsession.d.ts +110 -2
  51. package/types/panel/importroutes.d.ts +129 -0
  52. package/types/panel/importrun.d.ts +77 -0
  53. package/types/panel/runroutes.d.ts +32 -0
  54. package/types/panel/server.d.ts +266 -2
  55. package/types/panel/settingsroutes.d.ts +6 -1
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/planrun.d.ts +13 -21
  58. package/types/providerrows.d.ts +12 -0
  59. package/types/ralph.d.ts +19 -0
  60. package/types/replay.d.ts +25 -1
  61. package/types/replayio.d.ts +2 -2
  62. package/types/reuse.d.ts +5 -0
  63. package/types/run.d.ts +6 -2
  64. package/types/runlist.d.ts +52 -2
  65. package/types/source.d.ts +13 -5
  66. package/types/text.d.ts +19 -0
  67. package/types/userrun.d.ts +10 -0
  68. 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
- * Plain-language labels for the library's own `onPhase(name, …)` calls
53
- * (`src/authorjob.js`/`src/authorflow.js`) — build item 3's progress
54
- * indicator shows one of these next to its animated glyph instead of a new
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 PROGRESS_LABELS = Object.freeze({
60
- 'confirm-language-pick': 'confirming language',
61
- 'confirm-round': 'confirming plan',
62
- 'confirm-done': 'plan confirmed',
63
- 'author-call': 'drafting',
64
- 'author-fallback': 'drafting',
65
- 'seed-read': 'reading repo',
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
- 'listing-done': 'listing files',
75
- author: 'drafting',
76
- rubric: 'calibrating',
77
- 'rubric-done': 'calibrating',
78
- 'rubric-scrubbed': 'calibrating',
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}} [deps] the last five are TEST SEAMS ONLY — see the note
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
- phase: 'starting',
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
- state.progressLabel = PROGRESS_LABELS[name] ?? state.progressLabel;
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
- const refuse = (message) => {
296
- state.phase = 'refused';
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
- say('system', message);
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
- state.progressLabel = 'copying source';
332
- say('system', 'Copying source ($0)');
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, destination: isRepoLike ? card.destination : card.destination,
616
+ source: card.source, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
617
+ ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
335
618
  });
336
- if (prep.stop !== null) { refuse(prep.stop); return; }
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 copy this
352
- // session works from (`prep.tree`) is `prepareSource`'s own hidden
353
- // seed, tracked-files-only, so a JS/TS repo's copy never carries
354
- // `node_modules`. Refusing outright here was ITSELF the F182 class of
355
- // bug one layer up: a person who ran the printed command and then
356
- // reloaded the page got a BRAND NEW seed with no `node_modules`
357
- // either, so the refusal could never be satisfied. bareloop still
358
- // never runs an install itself — this only re-checks the SAME copy
359
- // (`missingDependencies` over `prep.tree`, the identical rule
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
- state.progressLabel = 'waiting on install';
366
- say('system', `Packages missing in the copy. Run:\ncd ${prep.tree} && ${depsGap.command}`);
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
- say('system', `Still missing (${depsGap.reason}). Install, then Check again.`);
681
+ stepDetail('install', `Still missing (${depsGap.reason}). Install, then Check again. Run: cd ${prep.tree} && ${depsGap.command}`);
375
682
  }
376
- say('system', 'Packages found');
683
+ stepDone('install');
684
+ stepStart('check');
377
685
  state.phase = 'drafting';
378
686
  }
379
687
  }
380
688
  if (!IS_REPO_SOURCE) {
381
- state.phase = 'refused';
382
- state.error = 'non-code-source';
383
- say('system', "Plain folder, not a code project — bareloop can't check this kind of job yet. Nothing spent.");
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
- const generate = deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null });
436
- const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null });
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
- state.progressLabel = 'drafting';
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
- state.phase = authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused';
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
- const specFile = join(outDir, 'resolved-spec.json');
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
- state.phase = 'error';
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: () => {