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
@@ -25,21 +25,35 @@
25
25
  // for every cap/threshold: a shell never widens what it was asked to do).
26
26
 
27
27
  import { createServer } from 'node:http';
28
- import { readFileSync, existsSync } from 'node:fs';
28
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
29
29
  import {
30
30
  dirname, join, basename, relative, isAbsolute, sep,
31
31
  } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
+ import { draftingPartFor } from '../draftspend.js';
34
35
  import {
35
36
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
36
37
  } from '../replayio.js';
37
- import { summarizeForAllLine, auditWindow } from '../replay.js';
38
+ import { summarizeForAllLine, auditWindowsOf } from '../replay.js';
39
+ import { legsOf, legsWallMs, stopFilePath } from '../legs.js';
40
+ import { chainSpend } from '../ledger.js';
38
41
  import { runBehaviour } from '../behaviour.js';
39
42
  import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
40
- import { jobSpecHash } from '../job.js';
43
+ import { jobSpecHash, workflowKey } from '../job.js';
41
44
  import { confirmProtections } from '../authorflow.js';
42
- import { createAuthorRoutes, mintToken, checkHostGuard } from './authorroutes.js';
45
+ import { createAuthorRoutes, mintToken, checkHostGuard, panelMoney2 } from './authorroutes.js';
46
+ import { createRunRoutes } from './runroutes.js';
47
+ import { createImportRoutes, readImports, bundleStatus, IMPORT_CHANGED_LINE } from './importroutes.js';
48
+ import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.js';
49
+ import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
50
+ import { checkCloseByteSignature } from '../close-integrity.js';
51
+ import { keysHome } from '../keysfile.js';
52
+ import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
53
+ import { resolveProvider } from '../providers.js';
54
+ import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
55
+ import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
56
+ import { statusFor, GOAL_MET_LINE } from './status.js';
43
57
  import { createSettingsRoutes } from './settingsroutes.js';
44
58
 
45
59
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -71,9 +85,7 @@ export const DEFAULT_PORT = 4700;
71
85
  * @returns {'✓'|'✗'|'▶'}
72
86
  */
73
87
  export function glyphForOutcome(outcome) {
74
- if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') return '✓';
75
- if (outcome === null || outcome === undefined) return '▶';
76
- return '✗';
88
+ return /** @type {'✓'|'✗'|'▶'} */ (statusFor({ outcome }).sign);
77
89
  }
78
90
 
79
91
  /**
@@ -234,11 +246,11 @@ export function formatTimestamp(ts) {
234
246
  * @param {import('../runlist.js').RunRow} row the run's listed row (its pid, else its spine's mtime, says whether it is still running)
235
247
  * @param {any[]} records raw parsed spine records (already read once by the caller)
236
248
  * @param {string|null} outcome `replayRun`'s own `summary.outcome`
237
- * @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
249
+ * @returns {{died: boolean, why: string|null, lastThing: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
238
250
  */
239
251
  function deriveDeath(row, records, outcome) {
240
252
  const notDied = {
241
- died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
253
+ died: false, why: null, lastThing: null, spendFloorUsd: null, wallFloorMs: null,
242
254
  };
243
255
  if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
244
256
  // no job-end yet — the floor derivation is identical whether this turns
@@ -250,10 +262,11 @@ function deriveDeath(row, records, outcome) {
250
262
  const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
251
263
  const last = withTs.length ? withTs[withTs.length - 1] : null;
252
264
  const when = last ? formatTimestamp(last.ts) : 'an unknown time';
253
- const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
265
+ const lastThing = `${describeLastRecord(last)} at ${when}`;
266
+ const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${lastThing}.`;
254
267
 
255
268
  return {
256
- died: true, why, ...floors,
269
+ died: true, why, lastThing, ...floors,
257
270
  };
258
271
  }
259
272
 
@@ -273,6 +286,375 @@ function formatDurationMs(ms) {
273
286
  return `${m}m${String(rem).padStart(2, '0')}s`;
274
287
  }
275
288
 
289
+ /**
290
+ * P5 item 2 — may this run be resumed, and under which spec file? The ONE owner of
291
+ * "would the engine accept `--resume` for this run" on the panel side: it asks the SAME
292
+ * library readers the engine's own refusals come from (`readResume` with
293
+ * `CHECKPOINT_OUTCOMES`, `checkpointAgeGate`), plus the two facts the panel needs to
294
+ * spawn it (a signed spec file beside the run, whose hash is the one the run was signed
295
+ * under). `ok:false` means the panel declines to offer a button the engine would refuse
296
+ * by design; it carries a plain `why`.
297
+ *
298
+ * The spec file is the run's own `resolved-spec.json` or one of its
299
+ * `resolved-spec-r<k>.json` siblings (a resume under raised caps writes one), picked by
300
+ * HASH against the run's `job-start.specHash` — a run resumed under raised caps must be
301
+ * resumed again under ITS caps, never the original's. No match = no resume offered.
302
+ * @param {{ spine: string, job: string, pid?: number }} row
303
+ * @param {any[]} records the run's raw spine records
304
+ * @returns {{ok: true, specPath: string, spec: any, specHash: string, budgetUsd: number|null,
305
+ * maxWallMin: number|null, spentUsd: number|null, spendComplete: boolean,
306
+ * draftSpentUsd: number|null, draftSpendComplete: boolean|null, wallUsedMs: number|null}|{ok: false, why: string}}
307
+ */
308
+ export function resumePlanFor(row, records) {
309
+ if (runIsAlive(row)) return { ok: false, why: 'it is still running' };
310
+ // P5-R: a resumed run is one file of several legs. What may be resumed again is the LATEST leg, under the spec
311
+ // THAT leg ran with (its `job-start.specHash` — a leg resumed under raised caps carries the re-signed hash), so
312
+ // resume #2 is offered under the latest signed caps, never the first leg's.
313
+ const legs = legsOf(records);
314
+ const jobStart = (legs.findLast((l) => l.jobStart !== null) ?? null)?.jobStart ?? null;
315
+ if (!jobStart) return { ok: false, why: 'its log has no start record' };
316
+ const dead = readResume(records, { direct: true, resumableOutcomes: CHECKPOINT_OUTCOMES });
317
+ if (!dead.started) return { ok: false, why: 'its log has no start record' };
318
+ if (dead.greened) return { ok: false, why: 'it already met its goal' };
319
+ if (dead.ended) return { ok: false, why: 'it ended with an answer, not a stop' };
320
+ if (!dead.restart) return { ok: false, why: 'it never opened an attempt to continue' };
321
+ const age = checkpointAgeGate(records);
322
+ if (!age.ok) return { ok: false, why: String(age.detail ?? 'its checkpoint has expired') };
323
+ const near = sourceNearSpine(row.spine);
324
+ if (!near.specPath) return { ok: false, why: 'the signed job file is not beside this run' };
325
+ // P6 item 1: a worktree run resumes IN its worktree — a folder that is gone is a resume the engine would refuse
326
+ const wt = worktreeOfRun(row, records);
327
+ if (wt && !wt.exists) return { ok: false, why: `its worktree folder ${wt.folder} is gone` };
328
+ const dir = dirname(near.specPath);
329
+ /** @type {string[]} */
330
+ let names = [];
331
+ try { names = readdirSync(dir).filter((n) => n === 'resolved-spec.json' || /^resolved-spec-r\d+\.json$/.test(n)); } catch { names = []; }
332
+ for (const n of names) {
333
+ let spec;
334
+ try { spec = JSON.parse(readFileSync(join(dir, n), 'utf8')); } catch { continue; }
335
+ if (!spec || typeof spec !== 'object') continue;
336
+ const specHash = jobSpecHash(spec);
337
+ if (specHash !== jobStart.specHash || spec.job !== dead.job) continue;
338
+ const draft = typeof jobStart.draftSpentUsd === 'number' && jobStart.draftSpentUsd > 0 ? jobStart.draftSpentUsd : null;
339
+ const spentKnown = typeof dead.spentUsd === 'number' && Number.isFinite(dead.spentUsd);
340
+ return {
341
+ ok: true,
342
+ specPath: join(dir, n),
343
+ spec,
344
+ specHash,
345
+ budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
346
+ maxWallMin: typeof spec.maxWallMs === 'number' ? spec.maxWallMs / 60000 : null,
347
+ spentUsd: spentKnown ? dead.spentUsd + (draft ?? 0) : null,
348
+ spendComplete: spentKnown && dead.spendComplete !== false && (draft === null || jobStart.draftSpendComplete !== false),
349
+ draftSpentUsd: draft,
350
+ draftSpendComplete: draft === null ? null : jobStart.draftSpendComplete !== false,
351
+ wallUsedMs: typeof dead.restart.priorWallMs === 'number' && Number.isFinite(dead.restart.priorWallMs) ? dead.restart.priorWallMs : null,
352
+ };
353
+ }
354
+ return { ok: false, why: 'no signed job file beside this run matches the hash it ran under' };
355
+ }
356
+
357
+ /**
358
+ * P5-R — the Audit tab's LEG DIVIDERS (hamr 2026-10-02: "clear audit mention resume"). One per resume, CODE-OWNED
359
+ * fixed sentences (ui-verdict-words: never model text) built from how the previous leg ended (`after`) and what the
360
+ * run had spent when it stopped:
361
+ * `── stopped: money cap reached ($8.00) · resumed 2026-10-03 14:10 ──`.
362
+ * `beforePart` is the index of the first part of the leg that picked up (the parts list length when that leg has not
363
+ * started a part yet). Money is the chain's own spend at that leg's end on the ONE basis ({@link chainSpend}).
364
+ * @param {any[]} spineRecords the run's raw records
365
+ * @param {any[]} parts `summary.parts` (each carries `leg` on a resumed run)
366
+ * @returns {{leg: number, beforePart: number, after: string|null, at: string|null, text: string}[]}
367
+ */
368
+ export function legDividersFor(spineRecords, parts) {
369
+ const legs = legsOf(spineRecords);
370
+ /** @type {{leg: number, beforePart: number, after: string|null, at: string|null, text: string}[]} */
371
+ const out = [];
372
+ for (let k = 1; k < legs.length; k += 1) {
373
+ const l = legs[k];
374
+ const first = parts.findIndex((p) => typeof p.leg === 'number' && p.leg >= l.leg);
375
+ const spent = chainSpend(legs.slice(0, k).flatMap((x) => x.records));
376
+ const money = `${spent.complete ? '' : 'at least '}${panelMoney2(spent.usd)}`;
377
+ const a = l.after;
378
+ let stopped;
379
+ if (a === 'cap-halt') stopped = `money cap reached (${money})`;
380
+ else if (a === 'wall-halt') stopped = 'time cap reached';
381
+ else if (a === 'provider-red') stopped = 'the model provider failed';
382
+ else if (a === 'step-stalled') stopped = 'a step stopped making progress';
383
+ else if (a === 'stopped') stopped = 'you stopped it';
384
+ else if (a === 'hitl-pause') stopped = 'paused for a decision';
385
+ else if (a === 'died' || a === null) stopped = 'no ending was recorded';
386
+ else stopped = String(a);
387
+ const at = typeof l.start?.at === 'string' ? l.start.at : (typeof l.start?.ts === 'string' ? l.start.ts : null);
388
+ out.push({
389
+ leg: l.leg,
390
+ beforePart: first === -1 ? parts.length : first,
391
+ after: a,
392
+ at,
393
+ text: `stopped: ${stopped} · resumed ${at ? formatTimestamp(at) : 'at an unknown time'}`,
394
+ });
395
+ }
396
+ return out;
397
+ }
398
+
399
+ /** outcomes whose ending is "the checks said no" (a graded answer from a working close) */
400
+ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated']);
401
+
402
+ /**
403
+ * P5 item 1 — the ENDED block: why a run ended, what to do next, and which buttons the
404
+ * engine would accept. CODE-OWNED FIXED SENTENCES (ui-verdict-words: never model text);
405
+ * the only slot-fills are numbers and the engine's own recorded detail. ONE owner,
406
+ * feeding both `getRunDetail` (`ended`) and `summarizeRow` (`endedLine`). `null` while
407
+ * a run is live (no Ended block while running).
408
+ *
409
+ * The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
410
+ * Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
411
+ * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
412
+ * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
413
+ * @param {{died: boolean, lastThing: string|null}} death
414
+ * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
415
+ * worktree?: {folder: string, repo: string, branch: string|null, exists: boolean}|null}} [o]
416
+ * `worktree` (P6 item 1): the run's worktree in the person's repo, from {@link worktreeOfRun}; absent for every other run
417
+ * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
418
+ */
419
+ export function endedFor(summary, death, o = {}) {
420
+ const base = endedBase(summary, death, o);
421
+ const wt = o.worktree;
422
+ if (!base || !wt) return base;
423
+ // P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
424
+ // removed by the engine) and the merge stays the person's — the two commands are shown as TEXT, never run by a button.
425
+ // Any other ending: the worktree folder stays (Resume needs it), so the block names it.
426
+ const green = !death.died && (summary.outcome === 'green' || summary.outcome === 'already-green' || summary.outcome === 'satisfied');
427
+ if (green) {
428
+ if (!wt.branch) return base;
429
+ const kept = wt.exists ? ` The worktree folder ${wt.folder} is still there.` : '';
430
+ return { ...base, next: `In ${wt.repo}: git merge ${wt.branch} — or throw the work away: git branch -D ${wt.branch}.${kept}` };
431
+ }
432
+ return wt.exists ? { ...base, next: `${base.next} Your work is in ${wt.folder}.` } : base;
433
+ }
434
+
435
+ /** @param {any} summary @param {any} death @param {any} o */
436
+ function endedBase(summary, death, o) {
437
+ const resumeOk = !!(o.resume && o.resume.ok);
438
+ /** @type {{id: string, label: string}[]} */
439
+ const RESUME = [{ id: 'resume', label: 'Resume' }];
440
+ /** Reuse workflow (replaces P5 item 3's Start from this, hamr 2026-10-03): the same signed job on a new source — a NEW
441
+ * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: Clear the card and draft a new one" instead. */
442
+ const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
443
+ const CHANGE = 'Change the job: Clear the card and draft a new one.';
444
+ const detailOf = (/** @type {string|null} */ s) => {
445
+ if (typeof s !== 'string' || s.length === 0) return '';
446
+ return s.length > 120 ? `${s.slice(0, 120)}…` : s;
447
+ };
448
+ /** a resumable ending whose Resume the engine would refuse: say so, never offer the button */
449
+ const resumeOr = (/** @type {string} */ okNext) => (resumeOk ? okNext : `Resume is not available for this run${o.resume && o.resume.why ? ` (${o.resume.why})` : ''}.`);
450
+
451
+ if (death.died) {
452
+ return {
453
+ reason: `Stopped with no ending recorded${death.lastThing ? ` (last thing it did: ${death.lastThing})` : ''}.`,
454
+ next: resumeOr('Resume.'),
455
+ line: resumeOk ? 'no ending recorded — resume' : 'no ending recorded',
456
+ actions: resumeOk ? RESUME : [],
457
+ };
458
+ }
459
+ const raw = summary.outcome;
460
+ if (raw === null || raw === undefined) return null;
461
+ const outcome = raw;
462
+ const esc = summary.lastEscalation;
463
+ const cat = typeof esc?.category === 'string' ? esc.category : null;
464
+
465
+ // `escalated` is a TERMINAL: its sentence may name the real governor, but it is never
466
+ // resumable. Two governors, never mixed (F198): the STRIKE ladder (the fix loop stopped
467
+ // improving) also files category `cap-halt` — it is the MONEY cap only when the spine
468
+ // says so (a `money-halt` record, or the job-end outcome `cap-halt` itself).
469
+ if (outcome === 'escalated') {
470
+ const strikes = esc?.spend?.strikes;
471
+ const limit = esc?.spend?.strikeLimit;
472
+ if (cat === 'cap-halt' && o.moneyHalt) {
473
+ const money = typeof summary.spentUsd === 'number' && typeof summary.budgetUsd === 'number'
474
+ ? ` (${panelMoney2(summary.spentUsd)} of ${panelMoney2(summary.budgetUsd)})` : '';
475
+ return {
476
+ reason: `Money cap reached${money}.`, next: CHANGE, line: 'money cap', actions: [],
477
+ };
478
+ }
479
+ if (cat === 'cap-halt' && typeof strikes === 'number' && typeof limit === 'number') {
480
+ return {
481
+ reason: `The fix loop stopped improving (${strikes} of ${limit} tries, no check got better).`,
482
+ next: CHANGE,
483
+ line: 'stopped improving',
484
+ actions: [],
485
+ };
486
+ }
487
+ if (cat === 'wall-halt') {
488
+ return { reason: 'Time cap reached.', next: CHANGE, line: 'time cap', actions: [] };
489
+ }
490
+ if (cat === 'provider-red') {
491
+ const d = detailOf(summary.stopReason);
492
+ return {
493
+ reason: `The model provider failed${d ? ` (${d})` : ''}.`, next: CHANGE, line: 'provider failed', actions: [],
494
+ };
495
+ }
496
+ }
497
+
498
+ if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
499
+ if (o.destinationRefused) {
500
+ return {
501
+ reason: 'Goal met, but the output could not be delivered.',
502
+ next: 'Fix the destination, then Reuse workflow.',
503
+ line: `${GOAL_MET_LINE} — not delivered`,
504
+ actions: REUSE,
505
+ };
506
+ }
507
+ return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
508
+ }
509
+ if (outcome === 'cap-halt') {
510
+ const money = typeof summary.spentUsd === 'number' && typeof summary.budgetUsd === 'number'
511
+ ? ` (${panelMoney2(summary.spentUsd)} of ${panelMoney2(summary.budgetUsd)})` : '';
512
+ return {
513
+ reason: `Money cap reached${money}.`,
514
+ next: resumeOr('Raise the cap, then Resume.'),
515
+ line: resumeOk ? 'money cap — resume' : 'money cap',
516
+ actions: resumeOk ? RESUME : [],
517
+ };
518
+ }
519
+ if (outcome === 'wall-halt') {
520
+ return {
521
+ reason: 'Time cap reached.',
522
+ next: resumeOr('Raise the time, then Resume.'),
523
+ line: resumeOk ? 'time cap — resume' : 'time cap',
524
+ actions: resumeOk ? RESUME : [],
525
+ };
526
+ }
527
+ if (outcome === 'stopped') {
528
+ // P5 item 5: the PERSON ended the leg (Stop). Nothing failed; the finished steps stand.
529
+ return {
530
+ reason: 'You stopped it.',
531
+ next: resumeOr('Resume.'),
532
+ line: resumeOk ? 'you pressed Stop — resume' : 'you pressed Stop',
533
+ actions: resumeOk ? RESUME : [],
534
+ };
535
+ }
536
+ if (outcome === 'provider-red') {
537
+ const d = detailOf(summary.stopReason);
538
+ return {
539
+ reason: `The model provider failed${d ? ` (${d})` : ''}.`,
540
+ next: resumeOr('Resume.'),
541
+ line: resumeOk ? 'provider failed — resume' : 'provider failed',
542
+ actions: resumeOk ? RESUME : [],
543
+ };
544
+ }
545
+ if (outcome === 'step-stalled') {
546
+ return {
547
+ reason: 'A step stopped making progress.',
548
+ next: resumeOr('Resume, or change the job: Clear the card and draft a new one.'),
549
+ line: resumeOk ? 'step stalled — resume' : 'step stalled',
550
+ actions: resumeOk ? RESUME : [],
551
+ };
552
+ }
553
+ if (GOAL_NOT_MET.has(outcome)) {
554
+ const d = detailOf(summary.stopReason);
555
+ return {
556
+ reason: `Goal not met — the checks said no${d ? ` (${d})` : ''}.`,
557
+ next: CHANGE,
558
+ line: 'checks said no',
559
+ actions: [],
560
+ };
561
+ }
562
+ if (outcome === 'close-red') {
563
+ return {
564
+ reason: 'The check itself broke (instrument fault), not your goal.',
565
+ next: CHANGE,
566
+ line: 'check broke',
567
+ actions: [],
568
+ };
569
+ }
570
+ // the raw engine detail is never shown here (it can name retired surfaces); the code is enough
571
+ return {
572
+ reason: `Stopped before or outside the work (code: ${outcome}).`,
573
+ next: CHANGE,
574
+ line: 'stopped before the work',
575
+ actions: [],
576
+ };
577
+ }
578
+
579
+ /**
580
+ * The Ended block for one listed run (P5 item 1): asks {@link resumePlanFor} only when
581
+ * the run's ending is one a resume could ever apply to, so a finished green never reads
582
+ * its spec files. Returns the block, the resume plan (for the confirm box's numbers) and
583
+ * nothing else — the sentences all live in {@link endedFor}.
584
+ * @param {{ spine: string, job: string, pid?: number }} row
585
+ * @param {any[]} records
586
+ * @param {any} summary `replayOne`'s summary
587
+ * @param {{died: boolean, lastThing: string|null}} death
588
+ */
589
+ /** the timestamp of the spine's LAST `job-end` record, or null (live, died, or no timestamp) */
590
+ function lastJobEndTs(/** @type {any[]} */ records) {
591
+ for (let i = records.length - 1; i >= 0; i -= 1) {
592
+ const r = records[i];
593
+ if (r && r.type === 'job-end') return typeof r.ts === 'string' ? r.ts : null;
594
+ }
595
+ return null;
596
+ }
597
+
598
+ function endedForRow(row, records, summary, death, withWorktree = false) {
599
+ const out = summary.outcome;
600
+ const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
601
+ const resume = maybeResumable ? resumePlanFor(row, records) : null;
602
+ const refused = [...records].reverse().find((r) => r && r.type === 'destination-refused') ?? null;
603
+ const ended = endedFor(summary, death, {
604
+ resume,
605
+ moneyHalt: records.some((r) => r && r.type === 'money-halt'),
606
+ destinationRefused: refused ? String(refused.detail ?? refused.code ?? 'the destination refused it') : null,
607
+ worktree: withWorktree ? worktreeOfRun(row, records) : null,
608
+ });
609
+ const esc = summary.lastEscalation;
610
+ const status = statusFor({
611
+ outcome: out, died: death.died, category: typeof esc?.category === 'string' ? esc.category : null,
612
+ moneyHalt: records.some((r) => r && r.type === 'money-halt'),
613
+ });
614
+ return { ended, resume, status };
615
+ }
616
+
617
+ /**
618
+ * P5 item 6 — a run whose row is listed (its runner is alive) but whose spine does not
619
+ * exist YET: the runner appends the row first, then opens the spine. It is `starting`,
620
+ * never `file missing`. Carries the same field names a normal row/detail does so every
621
+ * client reader (filters, groups) sees a live `▶` run, with every figure honestly null.
622
+ * @param {{ runid: string, job: string, at: string, via: string }} row
623
+ * @returns {any}
624
+ */
625
+ function startingStub(row) {
626
+ return {
627
+ runid: row.runid,
628
+ job: row.job,
629
+ at: row.at,
630
+ via: row.via,
631
+ fileMissing: false,
632
+ starting: true,
633
+ died: false,
634
+ glyph: '▶',
635
+ status: statusFor({ outcome: null }),
636
+ outcome: null,
637
+ endedLine: null,
638
+ ended: null,
639
+ resume: null,
640
+ checkType: 'unknown',
641
+ checkTypeTitle: null,
642
+ model: null,
643
+ spend: 'unknown',
644
+ wall: 'unknown',
645
+ date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
646
+ spentUsd: null,
647
+ spendComplete: false,
648
+ spendFloorUsd: null,
649
+ wallFloorMs: null,
650
+ draftSpentUsd: null,
651
+ draftSpendComplete: null,
652
+ budgetUsd: null,
653
+ steps: [],
654
+ parts: [],
655
+ };
656
+ }
657
+
276
658
  /**
277
659
  * One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
278
660
  * spine no longer exists on disk — never silently listed as if it were
@@ -283,6 +665,7 @@ function formatDurationMs(ms) {
283
665
  */
284
666
  function summarizeRow(row) {
285
667
  if (!existsSync(row.spine)) {
668
+ if (runIsAlive(row)) return startingStub(row);
286
669
  return {
287
670
  runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
288
671
  };
@@ -299,6 +682,7 @@ function summarizeRow(row) {
299
682
  }
300
683
  const line = summarizeForAllLine(summary);
301
684
  const death = deriveDeath(row, rawRecords, summary.outcome);
685
+ const { ended, status } = endedForRow(row, rawRecords, summary, death);
302
686
  return {
303
687
  runid: row.runid,
304
688
  job: row.job,
@@ -306,7 +690,11 @@ function summarizeRow(row) {
306
690
  via: row.via,
307
691
  fileMissing: false,
308
692
  died: death.died,
309
- glyph: death.died ? '?' : glyphForOutcome(summary.outcome),
693
+ endedLine: ended ? ended.line : null,
694
+ // P5-R: one card per run — how many times it was resumed (a resumed run is the same run, never a second card)
695
+ resumedCount: Math.max(0, summary.legs.length - 1),
696
+ glyph: status.sign,
697
+ status,
310
698
  checkType: checkTypeLabel(summary.verdictType, row.at),
311
699
  checkTypeTitle: checkTypeTitle(summary.verdictType, row.at),
312
700
  model: summary.model,
@@ -326,13 +714,81 @@ function summarizeRow(row) {
326
714
  // no drafting fold at all, exactly like `summary.draftSpentUsd` itself.
327
715
  spentUsd: death.died ? null : summary.spentUsd,
328
716
  spendComplete: death.died ? false : summary.spendComplete,
329
- spendFloorUsd: death.died ? death.spendFloorUsd : null,
717
+ // P5 item 6: the SAME floors the right pane reads (`deriveDeath`), for a died OR a
718
+ // still-running spine — so a live card and its pane show the same numbers. Both
719
+ // null once a job-end exists (the real figures above are complete then).
720
+ spendFloorUsd: death.spendFloorUsd,
721
+ wallFloorMs: death.wallFloorMs,
330
722
  draftSpentUsd: summary.draftSpentUsd,
331
723
  draftSpendComplete: summary.draftSpendComplete,
332
724
  budgetUsd: summary.budgetUsd,
333
725
  };
334
726
  }
335
727
 
728
+ /**
729
+ * The Resume route's reader (P5 item 2): the listed row plus the resume plan, from the
730
+ * ONE {@link resumePlanFor} the Ended block asks. `null` when the runid is not listed
731
+ * or its spine is gone.
732
+ * @param {string} runid
733
+ * @param {{ home?: string }} [opts]
734
+ * @returns {{row: any, plan: any}|null}
735
+ */
736
+ export function getResumeContext(runid, opts = {}) {
737
+ const { rows } = readRunList(opts);
738
+ const row = rows.find((r) => r && r.runid === runid);
739
+ if (!row || !existsSync(row.spine)) return null;
740
+ return { row, plan: resumePlanFor(row, parseJsonl(row.spine).records) };
741
+ }
742
+
743
+ /**
744
+ * The Stop route's reader (P5 item 5): the listed row, whether its LATEST leg is live (`runIsAlive`: the row's pid
745
+ * is the latest leg's, folded from the run list's `leg-start`), and whether its spine exists yet. `null` when the
746
+ * runid is not listed.
747
+ * @param {string} runid
748
+ * @param {{ home?: string }} [opts]
749
+ * @returns {{row: any, live: boolean, spineExists: boolean}|null}
750
+ */
751
+ export function getStopContext(runid, opts = {}) {
752
+ const { rows } = readRunList(opts);
753
+ const row = rows.find((r) => r && r.runid === runid);
754
+ if (!row) return null;
755
+ return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
756
+ }
757
+
758
+ /**
759
+ * @param {any} r
760
+ * @returns {number}
761
+ */
762
+ function atMs(r) {
763
+ const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
764
+ return Number.isFinite(t) ? t : 0;
765
+ }
766
+
767
+ /**
768
+ * The ONE owner of run names (P6 item 7): runs of the same job (`row.job`) are numbered oldest `at` first, 1-based
769
+ * (ties keep file order). A resume is one row, so it counts once; died and stopped runs are rows too. Every view
770
+ * reads this number — the page never counts on its own.
771
+ * @param {any[]} rows
772
+ * @returns {Map<string, number>} runid → N
773
+ */
774
+ function runNumbers(rows) {
775
+ /** @type {Map<string, any[]>} */
776
+ const byJob = new Map();
777
+ rows.forEach((row, index) => {
778
+ if (!row || typeof row.runid !== 'string') return;
779
+ const g = byJob.get(row.job) || [];
780
+ g.push({ row, index });
781
+ byJob.set(row.job, g);
782
+ });
783
+ /** @type {Map<string, number>} */
784
+ const out = new Map();
785
+ for (const g of byJob.values()) {
786
+ g.sort((a, b) => (atMs(a.row) - atMs(b.row)) || (a.index - b.index));
787
+ g.forEach(({ row }, i) => out.set(row.runid, i + 1));
788
+ }
789
+ return out;
790
+ }
791
+
336
792
  /**
337
793
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
338
794
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -351,14 +807,48 @@ function summarizeRow(row) {
351
807
  */
352
808
  export function listRuns(opts = {}) {
353
809
  const { rows } = readRunList(opts);
354
- const atMs = (r) => {
355
- const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
356
- return Number.isFinite(t) ? t : 0;
357
- };
810
+ const nos = runNumbers(rows);
358
811
  return rows
359
812
  .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
360
813
  .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
361
- .map(({ row }) => summarizeRow(row));
814
+ .map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
815
+ }
816
+
817
+ /**
818
+ * The run's DRAFTING PART (hamr 2026-10-06, option A), or null: it exists only when the run came out of a panel
819
+ * session whose drafting log carries a metered call (src/draftspend.js, `draftingPartFor`) and the spine carries the
820
+ * drafting figure. It is the FIRST element of `detail.parts`, so EVERY index a reader is handed — `detail.parts`,
821
+ * `legDividers.beforePart`, the flat Audit rows' `partIndex`, the rounds endpoint's `part` — lives in ONE index space
822
+ * (the shown list), shifted by {@link draftingShift} in exactly those four places and nowhere else.
823
+ * @param {any} row @param {any} summary a `replayOne` summary of the row's spine @param {{ home?: string }} opts
824
+ * @returns {any|null}
825
+ */
826
+ function draftingFor(row, summary, opts) {
827
+ return draftingPartFor(row, { draftSpentUsd: summary.draftSpentUsd, draftSpendComplete: summary.draftSpendComplete }, opts);
828
+ }
829
+
830
+ /** @param {any} row @param {any} summary @param {{ home?: string }} opts @returns {0|1} how far the drafting part pushes every other part's index */
831
+ function draftingShift(row, summary, opts) {
832
+ return draftingFor(row, summary, opts) ? 1 : 0;
833
+ }
834
+
835
+ /**
836
+ * The run-list row of a run, for the readers below. An IMPORTED run (`<importId>~<runid>`, see importrun.js) is not in
837
+ * the run list: its row is built from the imports file and a checked path inside that bundle's own `runs/` folder
838
+ * (read only, no pid — never live, never resumable from here). `undefined` when no such run.
839
+ * @param {string} runid
840
+ * @param {{ home?: string }} opts
841
+ * @returns {any}
842
+ */
843
+ function findRunRow(runid, opts) {
844
+ const imp = parseImportedRunId(runid);
845
+ if (imp) {
846
+ const bundle = readImports(opts.home).find((x) => x.id === imp.importId);
847
+ const spine = bundle ? safeSpinePath(bundle.dir, imp.runid) : null;
848
+ if (!bundle || !spine) return undefined;
849
+ return { runid, job: bundle.job, at: spineStartedAt(spine), via: 'import', spine, patient: null };
850
+ }
851
+ return readRunList(opts).rows.find((r) => r && r.runid === runid);
362
852
  }
363
853
 
364
854
  /**
@@ -372,28 +862,45 @@ export function listRuns(opts = {}) {
372
862
  * @returns {any|null}
373
863
  */
374
864
  export function getRunDetail(runid, opts = {}) {
375
- const { rows } = readRunList(opts);
376
- const row = rows.find((r) => r && r.runid === runid);
865
+ const d = getRunDetailBody(runid, opts);
866
+ if (!d) return d;
867
+ const runNo = runNumbers(readRunList(opts).rows).get(runid);
868
+ return runNo === undefined ? d : { ...d, runNo };
869
+ }
870
+
871
+ /**
872
+ * @param {string} runid
873
+ * @param {{ home?: string }} opts
874
+ * @returns {any|null}
875
+ */
876
+ function getRunDetailBody(runid, opts) {
877
+ const row = findRunRow(runid, opts);
377
878
  if (!row) return null;
378
879
  if (!existsSync(row.spine)) {
880
+ if (runIsAlive(row)) return startingStub(row);
379
881
  return {
380
882
  runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
381
883
  };
382
884
  }
383
885
  // build item 7 (2026-09-28): resolve the audit path through the ONE
384
- // shared resolver (`resolveAuditPathForRow`, already the sole owner for
886
+ // shared resolver (`resolveAuditPathsForRow`, already the sole owner for
385
887
  // `scopedBehaviour`/`getRunAudit`) BEFORE calling `replayOne`, and hand it
386
888
  // the result — otherwise `replayOne`'s own internal resolution only ever
387
889
  // finds the FINISHED-run convention, so a still-LIVE run's part-level
388
890
  // `byTool`/`toolCalls` (summary.parts, read from `replayRun`) read
389
891
  // "unknown" even though the SAME run's Run-tab tools/cache summary and
390
- // Audit tab (both driven through `resolveAuditPathForRow` already) can
892
+ // Audit tab (both driven through `resolveAuditPathsForRow` already) can
391
893
  // see the during-run sidecar just fine.
392
894
  const rawSpineRecords = parseJsonl(row.spine).records;
393
- const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
394
- const summary = replayOne(row.spine, { auditPathOverride: auditPath });
895
+ const auditPaths = resolveAuditPathsForRow(row, rawSpineRecords);
896
+ const summary = replayOne(row.spine, { auditPathOverride: auditPaths.length > 0 ? auditPaths : null });
395
897
  const timelineKind = summary.timelineKind;
898
+ // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
899
+ const draftingPartHere = draftingFor(row, summary, opts);
900
+ const draftingShiftHere = draftingPartHere ? 1 : 0;
901
+ const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
396
902
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
903
+ const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
397
904
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
398
905
  // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
399
906
  // close's own paid judge seam, distinct from the worker `model` above.
@@ -461,7 +968,7 @@ export function getRunDetail(runid, opts = {}) {
461
968
  id: u.id,
462
969
  occurrence: u.occurrence,
463
970
  outcome: u.outcome ?? null,
464
- state: stateFor(u.outcome, isLast),
971
+ state: u.stopReason ? 'stopped' : stateFor(u.outcome, isLast),
465
972
  rounds: u.rounds,
466
973
  toolCalls: u.toolCalls,
467
974
  wallMs: u.wallMs,
@@ -473,6 +980,29 @@ export function getRunDetail(runid, opts = {}) {
473
980
  attempts: u.attempts,
474
981
  };
475
982
  });
983
+ // P5-R: a step cut off by its leg's halt and CONTINUED by the next leg is ONE step — the summary counts steps, not
984
+ // parts. The continued part replaces the stopped one in this list (so it is done iff its continuation is), and the
985
+ // two parts' rounds/time/spend/attempts are added up (an unknown figure stays unknown).
986
+ /** @type {any[]} */
987
+ const merged = [];
988
+ summary.steps.forEach((u, idx) => {
989
+ const cur = steps[idx];
990
+ const prev = merged[merged.length - 1];
991
+ if (u.continued === true && prev && prev.id === cur.id) {
992
+ const add = (/** @type {number|null} */ a, /** @type {number|null} */ b) => (typeof a === 'number' && typeof b === 'number' ? a + b : null);
993
+ merged[merged.length - 1] = {
994
+ ...cur,
995
+ occurrence: prev.occurrence,
996
+ rounds: add(prev.rounds, cur.rounds),
997
+ toolCalls: add(prev.toolCalls, cur.toolCalls),
998
+ wallMs: add(prev.wallMs, cur.wallMs),
999
+ spentUsd: add(prev.spentUsd, cur.spentUsd),
1000
+ unpricedRounds: (prev.unpricedRounds ?? 0) + (cur.unpricedRounds ?? 0),
1001
+ attempts: [...(prev.attempts ?? []), ...(cur.attempts ?? [])].map((a, n) => ({ ...a, n: n + 1 })),
1002
+ };
1003
+ } else merged.push(cur);
1004
+ });
1005
+ steps = merged;
476
1006
  }
477
1007
  // died before any step/iteration ever started (steps empty — the run was
478
1008
  // still in scout/planning) — one placeholder box, never an empty map. Its
@@ -519,10 +1049,33 @@ export function getRunDetail(runid, opts = {}) {
519
1049
  provider: summary.provider,
520
1050
  judgeModel,
521
1051
  budgetUsd: summary.budgetUsd,
522
- glyph: death.died ? '?' : glyphForOutcome(summary.outcome),
1052
+ glyph: status.sign,
1053
+ status,
1054
+ // the header's times: when the run started (its list row) and when its last job-end was written (null while live / died)
1055
+ startedAt: typeof row.at === 'string' ? row.at : null,
1056
+ endedAt: lastJobEndTs(rawSpineRecords),
523
1057
  outcome: summary.outcome,
524
1058
  died: death.died,
525
1059
  stopReason: death.died ? death.why : summary.stopReason,
1060
+ // P5 item 1: the Ended block — `{reason, next, line, actions}` (code-owned fixed
1061
+ // sentences, {@link endedFor}), `null` while the run is live. `resume` carries the
1062
+ // numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
1063
+ // whenever Resume is not offered.
1064
+ ended,
1065
+ // P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
1066
+ // (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
1067
+ live: row.via !== 'import' && runIsAlive(row),
1068
+ stopping: row.via !== 'import' && runIsAlive(row) && existsSync(stopFilePath(row.spine)),
1069
+ // P5-R: the run's legs in order (`after` = how the leg before ended) and the resume count — the Audit tab's
1070
+ // dividers and the map's connectors read these; never a second derivation of where a leg starts
1071
+ legs: summary.legs,
1072
+ // drafting belongs to leg 1: a divider's `beforePart` moves with the parts list it indexes
1073
+ legDividers: legDividersFor(rawSpineRecords, summary.parts).map((d) => ({ ...d, beforePart: d.beforePart + draftingShiftHere })),
1074
+ resumedCount: Math.max(0, summary.legs.length - 1),
1075
+ resume: resume && resume.ok ? {
1076
+ budgetUsd: resume.budgetUsd, maxWallMin: resume.maxWallMin, spentUsd: resume.spentUsd, spendComplete: resume.spendComplete,
1077
+ wallUsedMs: resume.wallUsedMs,
1078
+ } : null,
526
1079
  spentUsd: summary.spentUsd,
527
1080
  // draftSpentUsd (hamr's ruling 2026-09-28) — the drafting share of this
528
1081
  // run's cap, or null when this run carried none. `src/replay.js`'s
@@ -564,7 +1117,7 @@ export function getRunDetail(runid, opts = {}) {
564
1117
  // {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
565
1118
  // passes through unchanged) whenever no spec resolves at all, so the two
566
1119
  // tabs still never disagree about order/counts/blocked-call figures.
567
- parts: enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row))),
1120
+ parts: draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts,
568
1121
  replans: summary.replans,
569
1122
  close: summary.close,
570
1123
  branch: summary.branch,
@@ -577,7 +1130,7 @@ export function getRunDetail(runid, opts = {}) {
577
1130
  // gate-audit rows the Audit tab now shows (this run's own job-start..
578
1131
  // job-end ts window, item 2's fix for a real measured defect: a
579
1132
  // gate-audit sidecar CAN carry other runs' rows when several spines
580
- // share one filename — see {@link runAuditWindow}'s doc) — never
1133
+ // share one filename — see {@link runAuditWindows}'s doc) — never
581
1134
  // `replayRun`'s own top-level `summary.behaviour`, which reads the
582
1135
  // WHOLE sidecar file unscoped and is contaminated on a real archived run
583
1136
  // (measured: pulselog-person-live-2's mu2p83go reads 142 unscoped tool
@@ -609,12 +1162,16 @@ export function getRunDetail(runid, opts = {}) {
609
1162
  * about which rows belong to a run) — kept here only to find `job-start`/
610
1163
  * `job-end` off the raw `spineRecords` array the panel already has in hand.
611
1164
  * @param {any[]} spineRecords
612
- * @returns {{startTs: number, endTs: number}}
1165
+ * @returns {{startTs: number, endTs: number}[]} one window per leg of the run (P5-R); one for a run nobody resumed
613
1166
  */
614
- function runAuditWindow(spineRecords) {
615
- const jobStart = spineRecords.find((r) => r && r.type === 'job-start') ?? null;
616
- const jobEnd = [...spineRecords].reverse().find((r) => r && r.type === 'job-end') ?? null;
617
- return auditWindow(jobStart, jobEnd);
1167
+ function runAuditWindows(spineRecords) {
1168
+ return auditWindowsOf(spineRecords);
1169
+ }
1170
+
1171
+ /** @param {string} ts @param {{startTs: number, endTs: number}[]} windows */
1172
+ function inAnyWindow(ts, windows) {
1173
+ const ms = Date.parse(ts);
1174
+ return Number.isFinite(ms) && windows.some((w) => ms >= w.startTs && ms <= w.endTs);
618
1175
  }
619
1176
 
620
1177
  /**
@@ -689,7 +1246,7 @@ function makeRoundLookup(spineRecords) {
689
1246
  * `backfill` row (never a live run) and on some pre-cutoff `run-u` rows
690
1247
  * — both correctly fall through to `null` here.
691
1248
  * Callers still scope the rows they read from this file by this run's own
692
- * job-start..job-end ts window ({@link runAuditWindow}) — a shared tree/
1249
+ * job-start..job-end ts window ({@link runAuditWindows}) — a shared tree/
693
1250
  * worktree gate-audit file can carry another run's rows too (this file's own
694
1251
  * header comment on `runAuditWindow`), live or finished.
695
1252
  *
@@ -703,42 +1260,44 @@ function makeRoundLookup(spineRecords) {
703
1260
  * rename target, not a panel-server read-path bug).
704
1261
  * @param {{spine: string, patient: string|null}} row
705
1262
  * @param {any[]} spineRecords
706
- * @returns {string|null}
1263
+ * @returns {string[]} every file the run's tool log is in, earliest leg first (empty: none was ever written)
707
1264
  */
708
- function resolveAuditPathForRow(row, spineRecords) {
1265
+ function resolveAuditPathsForRow(row, spineRecords) {
1266
+ /** @type {string[]} */
1267
+ const paths = [];
709
1268
  const { auditPath } = resolveSiblings(row.spine);
710
- if (auditPath) return auditPath;
711
- const hasJobEnd = spineRecords.some((r) => r && r.type === 'job-end');
712
- if (hasJobEnd) return null;
713
- if (typeof row.patient !== 'string' || row.patient.length === 0) return null;
1269
+ if (auditPath && existsSync(auditPath)) paths.push(auditPath);
1270
+ // P5-R: a resumed run's LIVE leg writes its tool log in the patient tree while the earlier legs' rows already sit in
1271
+ // the run's own file — both are the run's, in that order. "Live" = the LATEST leg has no job-end yet.
1272
+ const last = legsOf(spineRecords).at(-1);
1273
+ const hasJobEnd = last ? last.jobEnd !== null : spineRecords.some((r) => r && r.type === 'job-end');
1274
+ if (hasJobEnd) return paths;
1275
+ if (paths.length > 0 && legsOf(spineRecords).length <= 1) return paths; // a never-resumed run's sibling is the whole log
1276
+ if (typeof row.patient !== 'string' || row.patient.length === 0) return paths;
714
1277
  const live = join(row.patient, 'gate-audit.jsonl');
715
- return existsSync(live) ? live : null;
1278
+ if (existsSync(live) && !paths.includes(live)) paths.push(live);
1279
+ return paths;
716
1280
  }
717
1281
 
718
1282
  /**
719
1283
  * `runBehaviour`, fed only the gate-audit rows inside this run's own ts
720
- * window (see {@link runAuditWindow}) — the panel-side fix for the same
1284
+ * window (see {@link runAuditWindows}) — the panel-side fix for the same
721
1285
  * contamination `getRunAudit` fixes for the Audit tab, kept as ONE shared
722
1286
  * scoping function so the Audit tab and the Run tab's tools/cache summary
723
1287
  * can never disagree with each other about which rows belong to this run.
724
1288
  * `null` when no gate-audit sidecar exists at all (never a fake all-zero
725
1289
  * object — same rule `replayRun`'s own `auditAvailable` already follows).
726
1290
  * `auditPath` resolution (finished sibling, or the live during-run fallback)
727
- * is {@link resolveAuditPathForRow} — the one shared owner.
1291
+ * is {@link resolveAuditPathsForRow} — the one shared owner.
728
1292
  * @param {{spine: string, patient: string|null}} row
729
1293
  * @param {any[]} spineRecords
730
1294
  * @returns {ReturnType<typeof runBehaviour>|null}
731
1295
  */
732
1296
  function scopedBehaviour(row, spineRecords) {
733
- const auditPath = resolveAuditPathForRow(row, spineRecords);
734
- if (!auditPath || !existsSync(auditPath)) return null;
735
- const { records } = parseJsonl(auditPath);
736
- const { startTs, endTs } = runAuditWindow(spineRecords);
737
- const windowed = records.filter((r) => {
738
- if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
739
- const ms = Date.parse(r.ts);
740
- return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
741
- });
1297
+ const auditPaths = resolveAuditPathsForRow(row, spineRecords);
1298
+ if (auditPaths.length === 0) return null;
1299
+ const windows = runAuditWindows(spineRecords);
1300
+ const windowed = auditPaths.flatMap((ap) => parseJsonl(ap).records).filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
742
1301
  return runBehaviour(windowed);
743
1302
  }
744
1303
 
@@ -879,8 +1438,7 @@ function shortenPath(path, root) {
879
1438
  * @returns {{runid: string, rows: any[], raw: string, empty: boolean, reason: 'no-sidecar'|'sidecar-empty'|null}|null}
880
1439
  */
881
1440
  export function getRunAudit(runid, opts = {}) {
882
- const { rows } = readRunList(opts);
883
- const row = rows.find((r) => r && r.runid === runid);
1441
+ const row = findRunRow(runid, opts);
884
1442
  if (!row) return null;
885
1443
  if (!existsSync(row.spine)) {
886
1444
  return {
@@ -888,18 +1446,18 @@ export function getRunAudit(runid, opts = {}) {
888
1446
  };
889
1447
  }
890
1448
  const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
891
- // {@link resolveAuditPathForRow} needs `spineRecords` (to know whether this
1449
+ // {@link resolveAuditPathsForRow} needs `spineRecords` (to know whether this
892
1450
  // run has reached `job-end` yet) before it can decide whether the LIVE
893
1451
  // during-run fallback is even worth trying — so the spine is parsed once,
894
1452
  // here, before the sidecar path is resolved (moved up from right after the
895
1453
  // old `resolveSiblings`-only check this replaces).
896
- const auditPath = resolveAuditPathForRow(row, spineRecords);
897
- if (!auditPath || !existsSync(auditPath)) {
1454
+ const auditPaths = resolveAuditPathsForRow(row, spineRecords);
1455
+ if (auditPaths.length === 0) {
898
1456
  return {
899
1457
  runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
900
1458
  };
901
1459
  }
902
- const { startTs, endTs } = runAuditWindow(spineRecords);
1460
+ const windows = runAuditWindows(spineRecords);
903
1461
  const roundOf = makeRoundLookup(spineRecords);
904
1462
  const roundRecords = sortedRoundRecords(spineRecords);
905
1463
  // `preParsedSpine` avoids a second parse of the same spine file just read
@@ -911,31 +1469,27 @@ export function getRunAudit(runid, opts = {}) {
911
1469
  skipAudit: true,
912
1470
  });
913
1471
  const partOf = makePartLookup(summary);
1472
+ const shift = draftingShift(row, summary, opts); // the drafting part heads the shown list: every index moves with it
914
1473
 
915
- const { records } = parseJsonl(auditPath);
916
- const windowed = records.filter((r) => {
917
- if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
918
- const ms = Date.parse(r.ts);
919
- return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
920
- });
1474
+ const { records } = { records: auditPaths.flatMap((ap) => parseJsonl(ap).records) };
1475
+ const windowed = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
921
1476
  // item 1 (build item, 2026-09-26): "Raw log" must show only THIS run's own
922
1477
  // window, same rule as `windowed` above (a sidecar can carry other runs'
923
- // rows — see {@link runAuditWindow}'s doc). Filtered at the LINE level
1478
+ // rows — see {@link runAuditWindows}'s doc). Filtered at the LINE level
924
1479
  // (never the parsed-record level) so a matched line stays byte-for-byte —
925
1480
  // "raw" means raw, not a re-serialized JSON.stringify of the parsed
926
1481
  // object. A line whose `ts` can't be parsed (malformed JSON, or a
927
1482
  // well-formed row missing/mistyping `ts`) is dropped rather than guessed
928
1483
  // into the window, matching the same honesty rule `windowed` already
929
1484
  // follows for its own rows.
930
- const rawText = readFileSync(auditPath, 'utf8')
1485
+ const rawText = auditPaths.map((ap) => readFileSync(ap, 'utf8'))
1486
+ .join('\n')
931
1487
  .split('\n')
932
1488
  .filter((line) => line.trim() !== '')
933
1489
  .filter((line) => {
934
1490
  let r;
935
1491
  try { r = JSON.parse(line); } catch { return false; }
936
- if (!r || typeof r !== 'object' || typeof r.ts !== 'string') return false;
937
- const ms = Date.parse(r.ts);
938
- return Number.isFinite(ms) && ms >= startTs && ms <= endTs;
1492
+ return !!r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows);
939
1493
  })
940
1494
  .join('\n');
941
1495
  // item 2 (2026-09-26 build spec): every row's path shortened relative to
@@ -956,7 +1510,7 @@ export function getRunAudit(runid, opts = {}) {
956
1510
  path,
957
1511
  pathShort: shortenPath(path, treeRoot),
958
1512
  decision: typeof r.decision === 'string' ? r.decision : null,
959
- partIndex,
1513
+ partIndex: partIndex === null ? null : partIndex + shift,
960
1514
  partLabel,
961
1515
  attemptN,
962
1516
  reason,
@@ -1014,8 +1568,7 @@ export const ROUNDS_PAGE_MAX = 200;
1014
1568
  * @returns {any|null}
1015
1569
  */
1016
1570
  export function getRunRounds(runid, query = {}, opts = {}) {
1017
- const { rows } = readRunList(opts);
1018
- const row = rows.find((r) => r && r.runid === runid);
1571
+ const row = findRunRow(runid, opts);
1019
1572
  if (!row) return null;
1020
1573
  if (!existsSync(row.spine)) return null;
1021
1574
 
@@ -1026,7 +1579,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1026
1579
  ? Math.min(query.limit, ROUNDS_PAGE_MAX) : ROUNDS_PAGE_DEFAULT;
1027
1580
 
1028
1581
  const summary = replayOne(row.spine);
1029
- const part = Array.isArray(summary.parts) ? summary.parts[partIndex] : null;
1582
+ // `part` is an index into the SHOWN list (`detail.parts`): when the drafting part heads it, index 0 is the drafting
1583
+ // part (it has no rounds table) and the spine's own part k is shown at k + 1
1584
+ const shift = draftingShift(row, summary, opts);
1585
+ if (shift && partIndex === 0) return null;
1586
+ const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
1030
1587
  if (!part) return null;
1031
1588
  const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
1032
1589
  if (!a) return null;
@@ -1154,6 +1711,25 @@ function sourceNearSpine(spinePath) {
1154
1711
  };
1155
1712
  }
1156
1713
 
1714
+ /**
1715
+ * P6 item 1 — the worktree a run worked on in the person's own repo, read off the run's own `source.json` (the manifest
1716
+ * the source door wrote: `worktree` + `repo`) and its spine's `work-branch` record. `null` for every run that has none
1717
+ * (a copied-tree run, a bundle run, an import). `exists` is whether the folder is still on disk: a green run's folder is
1718
+ * removed by the engine, any other ending leaves it.
1719
+ * @param {{ spine: string }} row
1720
+ * @param {any[]} records the run's raw spine records
1721
+ * @returns {{folder: string, repo: string, branch: string|null, exists: boolean}|null}
1722
+ */
1723
+ function worktreeOfRun(row, records) {
1724
+ const near = sourceNearSpine(row.spine);
1725
+ if (!near.sourceJsonPath) return null;
1726
+ let m;
1727
+ try { m = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { return null; }
1728
+ if (typeof m?.worktree !== 'string' || m.worktree === '' || typeof m?.repo !== 'string') return null;
1729
+ const wb = records.findLast((r) => r && r.type === 'work-branch' && typeof r.branch === 'string') ?? null;
1730
+ return { folder: m.worktree, repo: m.repo, branch: wb ? wb.branch : null, exists: existsSync(m.worktree) };
1731
+ }
1732
+
1157
1733
  /**
1158
1734
  * The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
1159
1735
  * `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
@@ -1543,8 +2119,7 @@ function jobsDir() {
1543
2119
  * @returns {any|null}
1544
2120
  */
1545
2121
  export function getRunJob(runid, opts = {}) {
1546
- const { rows } = readRunList(opts);
1547
- const row = rows.find((r) => r && r.runid === runid);
2122
+ const row = findRunRow(runid, opts);
1548
2123
  if (!row) return null;
1549
2124
 
1550
2125
  const near = existsSync(row.spine) ? sourceNearSpine(row.spine) : { specPath: null, sourceJsonPath: null };
@@ -1693,6 +2268,290 @@ export function getRunJob(runid, opts = {}) {
1693
2268
  return none();
1694
2269
  }
1695
2270
 
2271
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
2272
+ // REUSE WORKFLOW (hamr 2026-10-03; replaces P5 item 3's "Start from this"). A GREEN run — or an imported job — can be
2273
+ // run again as the SAME signed workflow on a new source. This section is the ONE owner of (a) the prefill, (b) which
2274
+ // boxes are locked, (c) the track record. The page asks; it never decides. A reuse is a NEW run (a new runid).
2275
+ // Nothing here drafts: changing the goal, checks, guardrails, judge examples or model is Clear (then draft).
2276
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
2277
+
2278
+ /**
2279
+ * The signed spec a run's LATEST leg ran under — the `resolved-spec*.json` beside the run whose hash is the one its
2280
+ * latest `job-start` carries (the same lookup `resumePlanFor` does). `null` = no such file.
2281
+ * @param {{ spine: string }} row
2282
+ * @param {any[]} records
2283
+ * @returns {{spec: any, specPath: string, specHash: string}|null}
2284
+ */
2285
+ function signedSpecForRun(row, records) {
2286
+ const jobStart = (legsOf(records).findLast((l) => l.jobStart !== null) ?? null)?.jobStart ?? null;
2287
+ if (!jobStart || typeof jobStart.specHash !== 'string') return null;
2288
+ const near = sourceNearSpine(row.spine);
2289
+ if (!near.specPath) return null;
2290
+ const dir = dirname(near.specPath);
2291
+ /** @type {string[]} */
2292
+ let names = [];
2293
+ try { names = readdirSync(dir).filter((n) => n === 'resolved-spec.json' || /^resolved-spec-r\d+\.json$/.test(n)); } catch { names = []; }
2294
+ for (const n of names) {
2295
+ let spec;
2296
+ try { spec = JSON.parse(readFileSync(join(dir, n), 'utf8')); } catch { continue; }
2297
+ if (!spec || typeof spec !== 'object') continue;
2298
+ if (jobSpecHash(spec) === jobStart.specHash) return { spec, specPath: join(dir, n), specHash: jobStart.specHash };
2299
+ }
2300
+ return null;
2301
+ }
2302
+
2303
+ /**
2304
+ * The worker a signed spec ran: provider, baseUrl and model, the model falling back to the provider's default tier
2305
+ * when the spec names none (the same fallback `resolveWorkerModel` makes at run time).
2306
+ * @param {any} spec
2307
+ * @returns {{provider: string, baseUrl?: string, model: string}}
2308
+ */
2309
+ function workerOfSpec(spec) {
2310
+ const provider = typeof spec?.provider === 'string' ? spec.provider : '';
2311
+ let model = typeof spec?.model === 'string' ? spec.model : '';
2312
+ if (!model) { try { model = resolveProvider(provider).tiers.sonnet; } catch { model = ''; } }
2313
+ return { provider, ...(typeof spec?.baseUrl === 'string' && spec.baseUrl ? { baseUrl: spec.baseUrl } : {}), model };
2314
+ }
2315
+ /** @param {{provider: string, baseUrl?: string, model: string}} w @returns {string} one spelling of "the same worker" */
2316
+ const workerId = (w) => `${w.provider}\u0000${w.baseUrl ?? ''}\u0000${w.model}`;
2317
+ /**
2318
+ * The worker the reuse card's CURRENT Model box names: the Settings row's provider, baseUrl and Name. `null` when the
2319
+ * box is blank or names no row.
2320
+ * @param {string} model a Settings Name
2321
+ * @param {{ home?: string }} opts
2322
+ * @returns {{provider: string, baseUrl?: string, model: string}|null}
2323
+ */
2324
+ function workerOfChoice(model, opts) {
2325
+ const c = modelChoiceFor(rowsForHome(keysHome(opts.home)), model);
2326
+ return c ? { provider: c.provider, ...(c.baseUrl ? { baseUrl: c.baseUrl } : {}), model: c.name } : null;
2327
+ }
2328
+
2329
+ /**
2330
+ * The track record of a WORKFLOW: every LISTED run whose signed spec (the one its latest leg ran under) has this
2331
+ * `workflowKey` — the same goal, checks, guardrails, model and close, whatever source, destination or caps. One run =
2332
+ * ONE entry (a resumed run is one run, its FINAL outcome — `legsOf`), and a run that is still live is neither green
2333
+ * nor not-green yet. `avgSpendUsd` / `avgWallMs` average the FINISHED runs (a recorded terminal) whose figure is exact
2334
+ * (chain spend priced; working time from readable stamps, resume gaps excluded) — `null` when no run qualifies, never 0.
2335
+ * Read from the runs the panel already lists: no registry, no plan handover (hamr ruling A).
2336
+ * `opts.worker` narrows the record to runs on the same WORKER (provider + baseUrl + model — Model is open on a reuse, so
2337
+ * the key no longer separates models): `undefined` = every model, a worker = only runs whose signed spec ran that one,
2338
+ * `null` = no model chosen, so no run matches.
2339
+ * @param {string} key a {@link workflowKey}
2340
+ * @param {{ home?: string, worker?: {provider: string, baseUrl?: string, model: string}|null }} [opts]
2341
+ * @returns {{runs: number, green: number, notGreen: number, live: number, finished: number, avgSpendUsd: number|null, avgWallMs: number|null}}
2342
+ */
2343
+ export function trackRecordFor(key, opts = {}) {
2344
+ const { rows } = readRunList(opts);
2345
+ let green = 0;
2346
+ let notGreen = 0;
2347
+ let live = 0;
2348
+ let finished = 0;
2349
+ let spendSum = 0;
2350
+ let spendN = 0;
2351
+ let wallSum = 0;
2352
+ let wallN = 0;
2353
+ for (const row of rows) {
2354
+ if (!row || !existsSync(row.spine)) continue;
2355
+ let records;
2356
+ try { records = parseJsonl(row.spine).records; } catch { continue; }
2357
+ const legs = legsOf(records);
2358
+ const signed = signedSpecForRun(row, records);
2359
+ if (!signed || workflowKey(signed.spec) !== key) continue;
2360
+ if (opts.worker !== undefined && (opts.worker === null || workerId(workerOfSpec(signed.spec)) !== workerId(opts.worker))) continue;
2361
+ const outcome = legs.at(-1)?.outcome ?? null;
2362
+ if (outcome === null && runIsAlive(row)) { live += 1; continue; }
2363
+ if (outcome === 'green') green += 1; else notGreen += 1;
2364
+ // the averages read FINISHED runs only (a recorded terminal), and only figures that are exact: an unpriced run
2365
+ // or one with unreadable stamps is left out, never counted as $0 or 0 min (unknown is never zero)
2366
+ if (outcome === null) continue;
2367
+ finished += 1;
2368
+ const sp = chainSpend(records);
2369
+ if (sp.complete) { spendSum += sp.usd; spendN += 1; }
2370
+ const wall = legsWallMs(legs);
2371
+ if (wall.ms !== null && wall.complete) { wallSum += wall.ms; wallN += 1; }
2372
+ }
2373
+ return {
2374
+ runs: green + notGreen + live, green, notGreen, live, finished,
2375
+ avgSpendUsd: spendN > 0 ? spendSum / spendN : null,
2376
+ avgWallMs: wallN > 0 ? wallSum / wallN : null,
2377
+ };
2378
+ }
2379
+
2380
+ /**
2381
+ * The estimate line above a reuse card (code-owned fixed text): `Same job — G green · N not green · about $X and
2382
+ * M min a run`, from the workflow's own listed runs. What is unknown is said, never rendered as $0 or 0 min.
2383
+ * @param {{runs?: number, green: number, notGreen: number, finished?: number, avgSpendUsd: number|null, avgWallMs?: number|null}|null} record
2384
+ * @returns {string}
2385
+ */
2386
+ export function reuseLine(record) {
2387
+ if (!record) return 'Same job';
2388
+ if ('runs' in record && record.runs === 0) return 'Same job — no runs yet on this model';
2389
+ const head = `Same job — ${record.green} green · ${record.notGreen} not green`;
2390
+ const wallMs = record.avgWallMs ?? null;
2391
+ const cost = record.avgSpendUsd === null ? null : panelMoney2(record.avgSpendUsd);
2392
+ const mins = wallMs === null ? null : (wallMs < 60_000 ? '<1' : String(Math.round(wallMs / 60_000)));
2393
+ if (cost !== null && mins !== null) return `${head} · about ${cost} and ${mins} min a run`;
2394
+ if (cost !== null) return `${head} · about ${cost} a run, time not recorded`;
2395
+ if (mins !== null) return `${head} · about ${mins} min a run, cost not recorded`;
2396
+ return (record.finished ?? record.green + record.notGreen) === 0
2397
+ ? `${head} · no finished run yet to price or time`
2398
+ : `${head} · cost and time not recorded`;
2399
+ }
2400
+
2401
+ /**
2402
+ * `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
2403
+ * boxes are locked and which are open, and the track record. Locked boxes carry the signed values, read by the
2404
+ * SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
2405
+ * $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
2406
+ * to copy: change the job: Clear the card). `null` when the runid is not listed.
2407
+ * @param {string} runid
2408
+ * @param {{ home?: string, model?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
2409
+ * @returns {{ok: true, origin: {runid: string, job: string}, card: Record<string, any>, from: 'signed job',
2410
+ * locked: readonly string[], open: readonly string[], note: string|null,
2411
+ * specHash: string, workflowKey: string, spec: any, specPath: string,
2412
+ * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2413
+ */
2414
+ export function getStartFrom(runid, opts = {}) {
2415
+ const { rows } = readRunList(opts);
2416
+ const row = rows.find((r) => r && r.runid === runid);
2417
+ if (!row) return null;
2418
+ if (!existsSync(row.spine)) return { ok: false, error: 'this run has no log on disk — nothing to reuse' };
2419
+ const records = parseJsonl(row.spine).records;
2420
+ const signed = signedSpecForRun(row, records);
2421
+ if (!signed) return { ok: false, error: "this run's signed job is not on disk — nothing to reuse (change the job: Clear the card and draft a new one)" };
2422
+ const near = sourceNearSpine(row.spine);
2423
+ const spec = signed.spec;
2424
+ /** @type {Record<string, any>} */
2425
+ let saved = {};
2426
+ if (near.specPath) {
2427
+ try {
2428
+ const raw = JSON.parse(readFileSync(join(dirname(near.specPath), 'card.json'), 'utf8'));
2429
+ if (raw && typeof raw === 'object') saved = raw;
2430
+ } catch { saved = {}; }
2431
+ }
2432
+ let manifest = null;
2433
+ if (near.sourceJsonPath) { try { manifest = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { manifest = null; } }
2434
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
2435
+ const card = reuseCardFromSpec(spec, {
2436
+ model: typeof saved.model === 'string' && saved.model ? saved.model
2437
+ : (findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name
2438
+ ?? (typeof jobStart?.model === 'string' ? jobStart.model : '')),
2439
+ source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
2440
+ judgeExamples: typeof saved.judgeExamples === 'string' ? saved.judgeExamples : '',
2441
+ jobName: row.job,
2442
+ });
2443
+ const key = workflowKey(spec);
2444
+ const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
2445
+ return {
2446
+ ok: true,
2447
+ origin: { runid, job: row.job },
2448
+ card,
2449
+ from: 'signed job',
2450
+ locked: REUSE_LOCKED_FIELDS,
2451
+ open: REUSE_OPEN_FIELDS,
2452
+ note: card.checkType === 'rubric' && card.judgeExamples === '' ? 'judge examples were not saved for this run' : null,
2453
+ specHash: signed.specHash,
2454
+ workflowKey: key,
2455
+ spec,
2456
+ specPath: signed.specPath,
2457
+ trackRecord,
2458
+ line: reuseLine(trackRecord),
2459
+ };
2460
+ }
2461
+
2462
+ /**
2463
+ * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2464
+ * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
2465
+ * @param {any} spec
2466
+ * @param {{model: string, source: string, judgeExamples: string, jobName: string}} extra
2467
+ * @returns {Record<string, any>}
2468
+ */
2469
+ function reuseCardFromSpec(spec, extra) {
2470
+ const writeScope = Array.isArray(spec.writeScope) ? spec.writeScope.filter((/** @type {any} */ x) => typeof x === 'string') : [];
2471
+ return {
2472
+ jobName: typeof spec.job === 'string' && spec.job ? spec.job : extra.jobName,
2473
+ checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
2474
+ model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
2475
+ goal: typeof spec.goal === 'string' ? spec.goal : '',
2476
+ source: extra.source,
2477
+ destination: writeScope.join(', '),
2478
+ success: successFromSpec(spec) ?? '',
2479
+ guardrails: guardrailsFromSpec(spec) ?? '',
2480
+ judgeExamples: extra.judgeExamples,
2481
+ capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2482
+ maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
2483
+ };
2484
+ }
2485
+
2486
+ /**
2487
+ * P5 item 4 — the facts of a spec for an IMPORTED job's view, read by the SAME readers the Job tab uses for a run
2488
+ * (so an imported job and a run read alike): check type, goal, success checks, guardrails, model, caps.
2489
+ * @param {any} spec
2490
+ * @returns {{checkType: string, goal: string, success: string, guardrails: string, model: string, budgetUsd: number|null, maxWallMs: number|null}}
2491
+ */
2492
+ function describeSpec(spec) {
2493
+ const nr = (/** @type {string|null} */ v) => (typeof v === 'string' && v.length > 0 ? v : 'not recorded');
2494
+ return {
2495
+ checkType: checkTypeLabel(typeof spec.verdictType === 'string' ? spec.verdictType : null, null),
2496
+ goal: nr(typeof spec.goal === 'string' ? spec.goal : null),
2497
+ success: nr(successFromSpec(spec)),
2498
+ guardrails: nr(guardrailsFromSpec(spec)),
2499
+ model: nr(typeof spec.model === 'string' ? spec.model : null),
2500
+ budgetUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2501
+ maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : null,
2502
+ };
2503
+ }
2504
+
2505
+ /**
2506
+ * Reuse workflow, for an IMPORTED job: the bundle is read AGAIN now and must be clean (`readBundle` — its own hash
2507
+ * covers every close script, so a swapped script is `bundle-tampered` here), must resolve its own `bareloop`
2508
+ * dependency (`checkBundleDeps`), and every close stage's signed sha256 must match the script's bytes on disk
2509
+ * (`checkCloseByteSignature`, over the spec with `$BARELOOP_BUNDLE` resolved to this folder — the same in-memory
2510
+ * substitution `bareloop run <bundle>` makes). The reuse spec is that resolved spec; the close scripts stay where
2511
+ * they were just verified (the engine re-verifies their bytes at run start and before every close run). The person
2512
+ * signs it on this machine at [Sign & run]. Same card, same locked/open boxes as a run's reuse. `null` when no such
2513
+ * imported job.
2514
+ * @param {string} id
2515
+ * @param {{ home?: string, model?: string }} [opts] `model` as for {@link getStartFrom}
2516
+ * @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
2517
+ * locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
2518
+ * specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2519
+ */
2520
+ export function getStartFromImport(id, opts = {}) {
2521
+ const row = readImports(opts.home).find((r) => r.id === id);
2522
+ if (!row) return null;
2523
+ const st = bundleStatus(row);
2524
+ if (st.status === 'changed') return { ok: false, error: IMPORT_CHANGED_LINE };
2525
+ if (st.status !== 'ok') return { ok: false, error: `${st.statusText ?? 'this imported job cannot be read'} — re-import it before reusing it` };
2526
+ const deps = checkBundleDeps(row.dir);
2527
+ if (!deps.ok) return { ok: false, error: deps.reds[0]?.detail ?? 'the imported job cannot resolve its own dependency' };
2528
+ const { spec } = resolveBundleSpec(st.bundle, row.dir);
2529
+ const bytes = checkCloseByteSignature(spec, row.dir);
2530
+ if (!bytes.ok) return { ok: false, error: `a close script of this imported job does not match its signed bytes (${bytes.reds.map((r) => r.stage).join(', ')}) — refusing to reuse it` };
2531
+ // the bundle's provider is no longer forced: Model is open on a reuse, so the person's chosen Settings row is the worker.
2532
+ // The card opens on the row matching the bundle's own provider when there is one, else blank (the page's menu picks).
2533
+ const rows = rowsForHome(keysHome(opts.home));
2534
+ const named = findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })?.name ?? '';
2535
+ const card = reuseCardFromSpec(spec, { model: named, source: '', judgeExamples: '', jobName: row.job });
2536
+ const key = workflowKey(spec);
2537
+ const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
2538
+ return {
2539
+ ok: true,
2540
+ origin: { runid: null, job: row.job, importId: id },
2541
+ card,
2542
+ from: 'imported job',
2543
+ locked: REUSE_LOCKED_FIELDS,
2544
+ open: REUSE_OPEN_FIELDS,
2545
+ note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
2546
+ specHash: jobSpecHash(spec),
2547
+ workflowKey: key,
2548
+ spec,
2549
+ specPath: null,
2550
+ trackRecord,
2551
+ line: reuseLine(trackRecord),
2552
+ };
2553
+ }
2554
+
1696
2555
  /** @param {any} res @param {number} code @param {any} body */
1697
2556
  function sendJson(res, code, body) {
1698
2557
  const text = JSON.stringify(body);
@@ -1722,7 +2581,7 @@ function sendText(res, code, text) {
1722
2581
  * only `createPanelServer` below always supplies one.
1723
2582
  * @param {import('node:http').IncomingMessage} req
1724
2583
  * @param {import('node:http').ServerResponse} res
1725
- * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes> }} opts
2584
+ * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes> }} opts
1726
2585
  */
1727
2586
  export function handleRequest(req, res, opts) {
1728
2587
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -1731,6 +2590,8 @@ export function handleRequest(req, res, opts) {
1731
2590
  function routesFor(o, p) {
1732
2591
  if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
1733
2592
  if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
2593
+ if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop)$/.test(p)) return o.runRoutes;
2594
+ if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
1734
2595
  return null;
1735
2596
  }
1736
2597
  const method = req.method ?? 'GET';
@@ -1779,7 +2640,7 @@ export function handleRequest(req, res, opts) {
1779
2640
  });
1780
2641
  return;
1781
2642
  }
1782
- sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author and /api/settings (GET/HEAD only)');
2643
+ sendText(res, 405, 'method not allowed — this route does not accept this method (GET/HEAD only)');
1783
2644
  return;
1784
2645
  }
1785
2646
  const getRoutes = routesFor(opts, pathname);
@@ -1888,7 +2749,8 @@ export function handleRequest(req, res, opts) {
1888
2749
  * this package's own `bin/bareloop.mjs`).
1889
2750
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
1890
2751
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
1891
- * fetchImpl?: typeof fetch }} [opts]
2752
+ * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
2753
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
1892
2754
  * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
1893
2755
  */
1894
2756
  export function createPanelServer(opts = {}) {
@@ -1906,10 +2768,14 @@ export function createPanelServer(opts = {}) {
1906
2768
  let authorRoutes;
1907
2769
  /** @type {ReturnType<typeof createSettingsRoutes>|undefined} */
1908
2770
  let settingsRoutes;
2771
+ /** @type {ReturnType<typeof createRunRoutes>|undefined} */
2772
+ let runRoutes;
2773
+ /** @type {ReturnType<typeof createImportRoutes>|undefined} */
2774
+ let importRoutes;
1909
2775
  const server = createServer((req, res) => {
1910
2776
  try {
1911
2777
  handleRequest(req, res, {
1912
- home, port: boundPort, token, authorRoutes, settingsRoutes,
2778
+ home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes,
1913
2779
  });
1914
2780
  } catch (e) {
1915
2781
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
@@ -1932,9 +2798,28 @@ export function createPanelServer(opts = {}) {
1932
2798
  bareloopBin: opts.bareloopBin,
1933
2799
  fetchImpl: opts.fetchImpl,
1934
2800
  home,
2801
+ startFrom: {
2802
+ get: (runid, o = {}) => getStartFrom(runid, { home, ...o }),
2803
+ getImport: (id, o = {}) => getStartFromImport(id, { home, ...o }),
2804
+ },
1935
2805
  });
1936
2806
  settingsRoutes = createSettingsRoutes({
1937
2807
  port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
2808
+ openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
2809
+ });
2810
+ runRoutes = createRunRoutes({
2811
+ port: boundPort,
2812
+ token,
2813
+ home,
2814
+ env: opts.env,
2815
+ spawnFn: opts.spawnFn,
2816
+ bareloopBin: opts.bareloopBin,
2817
+ settleMs: opts.settleMs,
2818
+ getResumeContext: (runid) => getResumeContext(runid, { home }),
2819
+ getStopContext: (runid) => getStopContext(runid, { home }),
2820
+ });
2821
+ importRoutes = createImportRoutes({
2822
+ port: boundPort, token, home, userHome: opts.userHome, describeSpec,
1938
2823
  });
1939
2824
  resolve({
1940
2825
  server,