bareloop 0.34.0 → 0.35.0

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