bareloop 0.33.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 (56) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bareloop.context.md +263 -27
  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 +10 -9
  8. package/src/job.js +39 -2
  9. package/src/judged.js +25 -5
  10. package/src/kinds.js +2 -0
  11. package/src/ledger.js +104 -9
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +133 -87
  14. package/src/panel/authorroutes.js +95 -15
  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 +855 -81
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +63 -31
  23. package/src/providerrows.js +21 -2
  24. package/src/providers.js +4 -4
  25. package/src/ralph.js +3 -0
  26. package/src/replay.js +139 -19
  27. package/src/replayio.js +11 -4
  28. package/src/reuse.js +20 -3
  29. package/src/run.js +6 -3
  30. package/src/runlist.js +90 -7
  31. package/src/text.js +20 -0
  32. package/src/u-readout.js +9 -1
  33. package/src/u-watchdog.mjs +4 -2
  34. package/src/userrun.js +166 -65
  35. package/types/draftspend.d.ts +53 -0
  36. package/types/job.d.ts +25 -0
  37. package/types/judged.d.ts +5 -2
  38. package/types/ledger.d.ts +44 -0
  39. package/types/legs.d.ts +103 -0
  40. package/types/monthly.d.ts +39 -22
  41. package/types/panel/authorroutes.d.ts +38 -1
  42. package/types/panel/authorsession.d.ts +109 -2
  43. package/types/panel/importroutes.d.ts +129 -0
  44. package/types/panel/importrun.d.ts +77 -0
  45. package/types/panel/runroutes.d.ts +32 -0
  46. package/types/panel/server.d.ts +255 -2
  47. package/types/panel/status.d.ts +51 -0
  48. package/types/planrun.d.ts +13 -21
  49. package/types/providerrows.d.ts +14 -1
  50. package/types/providers.d.ts +1 -2
  51. package/types/replay.d.ts +25 -1
  52. package/types/replayio.d.ts +2 -2
  53. package/types/reuse.d.ts +5 -0
  54. package/types/run.d.ts +6 -2
  55. package/types/runlist.d.ts +66 -2
  56. package/types/text.d.ts +19 -0
package/src/userrun.js CHANGED
@@ -24,11 +24,11 @@
24
24
  // green ONE job end to end. That green GRADUATES the bridge: the plan the agent
25
25
  // authored is preserved from the spine as a reusable artifact, and the next run of
26
26
  // this shape reuses and fine-tunes it rather than starting cold.
27
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync } from 'node:fs';
27
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync, openSync, readSync, closeSync, fstatSync, unlinkSync } from 'node:fs';
28
28
  import { createRequire } from 'node:module';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { execFileSync, spawn } from 'node:child_process';
31
- import { join, resolve, dirname } from 'node:path';
31
+ import { join, resolve, dirname, basename } from 'node:path';
32
32
  import { runJob } from './run.js';
33
33
  import { moneyWithDraft } from './replay.js';
34
34
  import { jobSpecHash, resolveWorkerModel, validateJob } from './job.js';
@@ -72,10 +72,13 @@ import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
72
72
  // the cold reset, shared with the battery drivers so "cold" has one spelling
73
73
  import { coldReset, moveStaleGateAudit } from './u-patient.js';
74
74
  // PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
75
- import { appendRun, appendRunEvent, isLiveRunner } from './runlist.js';
75
+ import { appendRun, appendLegStart, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
76
+ // P5-R — a resumed run is the SAME run: one id, one spine file, a `leg-resume` marker inside. `legsOf` is the
77
+ // one owner of where a leg starts and ends; every reader below asks it.
78
+ import { legsOf, parseSpineText, LEG_RESUME, watchdogNotePath, stopFilePath } from './legs.js';
76
79
  import { keysForDoor } from './keysfile.js';
77
80
  import { parseJsonl } from './replayio.js';
78
- import { claimRun, monthlyRefusalText, legSpend } from './monthly.js';
81
+ import { claimRun, monthlyRefusalText, legSpend, chainSpend } from './monthly.js';
79
82
  import { ConfigError } from './config.js';
80
83
  import { commandFor } from './invoke.js';
81
84
  import { applyConfiguredKey, judgeRatesFor, keyNameFor, priceReadout, ratesFor, rowsForHome } from './providerrows.js';
@@ -360,8 +363,18 @@ async function execute(ctx) {
360
363
  // owner of "a claimed run that never began" — every exit between the claim and the spine's first
361
364
  // record (a refused resume patient, a throwing prepareTree, a git/reset failure...) passes there.
362
365
  // Set on the no-limit path too, once the row is appended there (the same "listed, no spine yet" state).
363
- /** @type {{ runid: string, spine: string, home: string|undefined }|null} */
366
+ /** @type {{ runid: string, spine: string, home: string|undefined, leg: number }|null} */
364
367
  let claimedRow = null;
368
+ // P5-R: has this leg written its first record (the `leg-resume` marker on a resume)? A resumed leg's spine
369
+ // file already exists, so "the file exists" no longer says the leg began.
370
+ let legBegun = false;
371
+ // Release the claimed row (a `released` entry folds it out of the list, like a monthly refusal): the run
372
+ // spent nothing and must not stay a ghost that keeps the month reading "at least". A failed release write
373
+ // never masks the original exit.
374
+ const releaseClaimedRow = () => {
375
+ if (!claimedRow) return;
376
+ try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString(), ...(claimedRow.leg > 1 ? { leg: claimedRow.leg } : {}) }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
377
+ };
365
378
  try {
366
379
  const spec = ctx.spec;
367
380
  const specPath = ctx.specPath;
@@ -655,6 +668,13 @@ async function execute(ctx) {
655
668
  : (RESUME.includes('/') || RESUME.endsWith('.jsonl') ? resolve(RESUME) : join(spineDir, `u-${RESUME}.jsonl`));
656
669
  /** @type {any} */
657
670
  let dead = null;
671
+ /** P5-R — the legs the halted run's file already holds, the leg a resume continues from, and how that leg ended @type {import('./legs.js').Leg[]} */
672
+ let deadLegs = [];
673
+ /** every record of the halted run's one file (all its legs) @type {any[]} */
674
+ let deadAllEvents = [];
675
+ let deadFromLegNo = 1;
676
+ /** @type {string} */
677
+ let deadAfter = 'died';
658
678
  /** F102 — the decision this checkpoint HOLDS: an answer the person already gave, on
659
679
  * a leg that stopped before a single round was bought for it. Null on every ordinary
660
680
  * resume. It is applied directly and the ask is never re-rendered — the whole of the
@@ -669,31 +689,49 @@ async function execute(ctx) {
669
689
  if (!existsSync(deadSpineFile)) die(`--resume: no spine at ${deadSpineFile} — a resume continues a run that happened, and this one left no log`);
670
690
  let raw = '';
671
691
  try { raw = readFileSync(deadSpineFile, 'utf8'); } catch (e) { die(`--resume: cannot read ${deadSpineFile}: ${e.message}`); }
672
- /** @type {any[]} */
673
- const deadEvents = [];
674
- const lines = raw.split('\n');
675
- lines.forEach((line, i) => {
676
- if (!line.trim()) return;
677
- try { deadEvents.push(JSON.parse(line)); } catch (e) {
678
- // a kill can land mid-append, so a broken LAST line is the failure being modelled
679
- // and is tolerated (named, never silently dropped). A broken line anywhere else is
680
- // a corrupt log, and reconstructing from one would invent a history.
681
- if (i >= lines.length - 2) err(`--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`);
682
- else die(`--resume: ${deadSpineFile} is corrupt at line ${i + 1} (${e.message}) — a reconstruction from a damaged log would invent a history`);
683
- }
684
- });
692
+ // ONE parser for the spine's text (src/legs.js `parseSpineText`): a kill can land mid-append, so a broken
693
+ // LAST line is the failure being modelled; and a resumed run's own file carries exactly one torn line
694
+ // directly before each `leg-resume` marker (the resume never edits the torn bytes, it starts a fresh
695
+ // line). Both are tolerated and NAMED. A broken line anywhere else is a corrupt log, and reconstructing
696
+ // from one would invent a history.
697
+ const parsedDead = parseSpineText(raw);
698
+ for (const t of parsedDead.tolerated) {
699
+ err(t.why === 'tail'
700
+ ? `--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`
701
+ : `--resume: ignoring a truncated line ${t.line} in ${deadSpineFile} (a kill mid-append, left isolated by the next leg's marker)`);
702
+ }
703
+ if (parsedDead.corrupt) die(`--resume: ${deadSpineFile} is corrupt at line ${parsedDead.corrupt.line} (${parsedDead.corrupt.message}) — a reconstruction from a damaged log would invent a history`);
704
+ /** every record of the run's one file (all legs) */
705
+ deadAllEvents = parsedDead.records;
706
+ const allDeadEvents = deadAllEvents;
707
+ /** the legs already on the file, and the leg THIS resume will be (P5-R) */
708
+ deadLegs = legsOf(allDeadEvents);
709
+ // the leg a resume continues FROM is the latest leg that got as far as a `job-start`: a leg that died or
710
+ // was refused before runJob wrote one (a $0 stop) left no checkpoint, and the one behind it still stands
711
+ const fromLeg = deadLegs.findLast((l) => l.jobStart !== null) ?? deadLegs.at(-1);
712
+ /** the records `readResume` and every gate below read: that leg's own window, which carries its own declared fold */
713
+ const deadEvents = fromLeg ? fromLeg.records : allDeadEvents;
714
+ deadFromLegNo = fromLeg ? fromLeg.leg : 1;
685
715
  // is the dead run actually dead? Two processes on one patient is unrecoverable; a
686
716
  // false refusal costs one sentence. run-u's own pid is not on its spine, so the
687
- // watchdog's report is the pid there is — and its absence is not evidence of life.
688
- const wdFile = `${deadSpineFile}.watchdog.json`;
717
+ // watchdog's report or the run-list row is the pid there is — and its absence is not evidence of life.
718
+ // the watchdog's note for the leg being continued from (a resumed leg has its own note file)
719
+ const wdFile = watchdogNotePath(deadSpineFile, deadFromLegNo);
689
720
  /** @type {any} */
690
721
  let watchdog = null;
691
722
  if (existsSync(wdFile)) { try { watchdog = JSON.parse(readFileSync(wdFile, 'utf8')); } catch { /* an unreadable report is not evidence of life */ } }
692
723
  // ONE owner of "is that pid a live bareloop runner" (src/runlist.js `isLiveRunner`, shared with the
693
724
  // monthly limit's holds): alive AND a bareloop runner refuses; a recycled pid that now runs some
694
725
  // other program does not. Where /proc is unreadable an existing pid is assumed to be ours.
695
- if (Number.isInteger(watchdog?.pid) && isLiveRunner(watchdog.pid)) {
696
- die(`--resume: pid ${watchdog.pid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
726
+ // The pid is the watchdog's kill record's, or the predecessor's OWN run-list row's (every row carries
727
+ // its runner's pid): a run never killed by the watchdog has no record but still has its row.
728
+ /** @type {number|undefined} */
729
+ let rowPid;
730
+ if (cfgOn) { try { rowPid = readRunList({ home: cfgHome }).rows.findLast((r) => r.spine === deadSpineFile)?.pid; } catch { /* an unreadable list is not evidence of life */ } }
731
+ // a run's row carries its LATEST leg's pid (the run list folds `leg-start`); any earlier leg's note pid is checked too
732
+ const alivePid = [watchdog?.pid, rowPid].find((p) => Number.isInteger(p) && isLiveRunner(p));
733
+ if (alivePid !== undefined) {
734
+ die(`--resume: pid ${alivePid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
697
735
  }
698
736
  // WHEN did the dead leg stop? The watchdog's kill record is later, better evidence
699
737
  // than the last spine event for a run that was KILLED — and worse evidence for one
@@ -702,6 +740,8 @@ async function execute(ctx) {
702
740
  // closes was measured, not imagined (a report dated after a pause bills the human's
703
741
  // deciding time to the run's wall and can zero the remainder outright).
704
742
  const deathAt = deathAtOf({ watchdogAt: watchdog?.at, events: deadEvents });
743
+ // how the leg being continued from ended, for the marker the new leg opens with (`'died'`: no terminal recorded)
744
+ deadAfter = fromLeg?.outcome ?? 'died';
705
745
  dead = readResume(deadEvents, {
706
746
  direct: true,
707
747
  resumableOutcomes: RESUMABLE_HALTS,
@@ -779,13 +819,10 @@ async function execute(ctx) {
779
819
  if (!existsSync(doorSpineFile)) die(`--door: no spine at ${doorSpineFile} — a door belongs to a run that happened, and this one left no log`);
780
820
  let raw = '';
781
821
  try { raw = readFileSync(doorSpineFile, 'utf8'); } catch (e) { die(`--door: cannot read ${doorSpineFile}: ${e.message}`); }
782
- doorEvents = [];
783
- raw.split('\n').forEach((line, i) => {
784
- if (!line.trim()) return;
785
- try { /** @type {any[]} */ (doorEvents).push(JSON.parse(line)); } catch (e) {
786
- die(`--door: ${doorSpineFile} is corrupt at line ${i + 1} (${e.message}) — a decision made on a damaged record is a decision about a run nobody can read`);
787
- }
788
- });
822
+ // the same parser as the resume reader: a resumed run's file carries one torn line before each marker
823
+ const parsedDoor = parseSpineText(raw);
824
+ if (parsedDoor.corrupt) die(`--door: ${doorSpineFile} is corrupt at line ${parsedDoor.corrupt.line} (${parsedDoor.corrupt.message}) — a decision made on a damaged record is a decision about a run nobody can read`);
825
+ doorEvents = parsedDoor.records;
789
826
  const start = doorEvents.find((/** @type {any} */ e) => e?.type === 'job-start');
790
827
  if (!start) die(`--door: ${doorSpineFile} carries no job-start — that is not a bareloop run's spine`);
791
828
  if (start.job !== spec.job) die(`--door: that spine is job "${start.job}", not "${spec.job}" — a door is answered against the spec that was signed for it`);
@@ -807,14 +844,11 @@ async function execute(ctx) {
807
844
  err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one.`);
808
845
  throw new ExitSignal(2);
809
846
  }
810
- const je = doorEvents.findLast((/** @type {any} */ e) => e?.type === 'job-end');
811
- const spentKnown = typeof je?.spentUsd === 'number' && Number.isFinite(je.spentUsd);
812
- doorPrior = {
813
- spentUsd: spentKnown ? je.spentUsd : 0,
814
- // a FLOOR stays a floor across the door (F6): an absent FIGURE is never "complete
815
- // at $0" — completeness is only trusted when the number it completes is known
816
- spendComplete: spentKnown && je?.spendComplete !== false,
817
- };
847
+ // the chain's spend, on ONE basis per shape (`chainSpend`, src/monthly.js): a resumed run's several legs are
848
+ // summed by their own spend, never a later terminal's chain total on top of the earlier legs. A FLOOR stays a
849
+ // floor across the door (F6): an absent figure is never "complete at $0".
850
+ const chain = chainSpend(doorEvents);
851
+ doorPrior = { spentUsd: chain.usd, spendComplete: chain.complete };
818
852
  }
819
853
 
820
854
  /** is this resume the one a PERSON has to answer? Read off the recorded terminal,
@@ -1290,10 +1324,10 @@ async function execute(ctx) {
1290
1324
  // ── THE PAUSE DOOR (2026-08-17) — answered HERE, and it launches nothing.
1291
1325
  //
1292
1326
  // hamr's ruling replaced cancel with a pause that can resume, and the honest way to
1293
- // keep a checkpoint is to leave it alone. This runner writes one spine file per LEG,
1327
+ // keep a checkpoint is to leave it alone. This runner appends one LEG per resume to the run's one spine file,
1294
1328
  // and a leg that returns before drafting emits no `plan-accepted` and no `step-end` —
1295
1329
  // which is exactly what `readStepCheckpoint` reads. So launching a run to say "not
1296
- // now" would mint a NEW runid whose own checkpoint is empty, and an operator who
1330
+ // now" would add a LEG whose own checkpoint is empty, and an operator who
1297
1331
  // later resumed that runid would re-draft and re-pay for every step the paused leg
1298
1332
  // already finished. The checkpoint that matters is the one already on disk.
1299
1333
  //
@@ -1475,14 +1509,23 @@ async function execute(ctx) {
1475
1509
  // closed at its floor, with a note, once its process is gone). No limit set = no claim (the row is listed as
1476
1510
  // ever). An unreadable config.json, or a claim row that cannot be written, refuses too — a broken instrument
1477
1511
  // never silently reads "no limit".
1478
- const runid = ctx.bundle?.runid ?? Date.now().toString(36);
1479
- const spineFile = ctx.bundle ? join(spineDir, 'spine.jsonl') : join(spineDir, `u-${runid}.jsonl`);
1512
+ // P5-R (hamr 2026-10-02: "one run, one id, one file"): a RESUME is the same run — the runid and the spine file
1513
+ // are the halted run's own, and the new leg appends to it. Never a new id, never a second file.
1514
+ const resuming = dead !== null && deadSpineFile !== null;
1515
+ const runid = resuming
1516
+ ? (ctx.bundle?.runid ?? basename(/** @type {string} */ (deadSpineFile)).replace(/\.jsonl$/, '').replace(/^u-/, ''))
1517
+ : (ctx.bundle?.runid ?? Date.now().toString(36));
1518
+ const spineFile = resuming ? /** @type {string} */ (deadSpineFile)
1519
+ : (ctx.bundle ? join(spineDir, 'spine.jsonl') : join(spineDir, `u-${runid}.jsonl`));
1520
+ /** which leg of the run this process is (1 = a fresh run) */
1521
+ const LEG = resuming ? deadLegs.length + 1 : 1;
1480
1522
  const foldedUsd = dead ? dead.restart.priorSpentUsd : (doorPrior?.spentUsd ?? 0);
1481
1523
  const legCapUsd = Math.max(0, spec.budgetUsd - (typeof foldedUsd === 'number' && Number.isFinite(foldedUsd) ? foldedUsd : 0));
1482
1524
  // every row carries its runner's pid and the leg cap, limit set or not: a limit set LATER still holds it
1483
1525
  /** @type {import('./runlist.js').RunRow} */
1484
1526
  const runRow = {
1485
- at: new Date().toISOString(), runid, job: spec.job, spine: spineFile, patient: wd, via: ctx.bundle ? 'bundle' : 'run-u', pid: process.pid, capUsd: legCapUsd,
1527
+ // a RESUMED run keeps its own start stamp (the row is listed only if the list has none; it is the run's, not the leg's)
1528
+ at: resuming ? (deadLegs[0]?.start?.ts ?? new Date().toISOString()) : new Date().toISOString(), runid, job: spec.job, spine: spineFile, patient: wd, via: ctx.bundle ? 'bundle' : 'run-u', pid: process.pid, capUsd: legCapUsd,
1486
1529
  };
1487
1530
  let claimed = false;
1488
1531
  {
@@ -1490,9 +1533,9 @@ async function execute(ctx) {
1490
1533
  let refusal = null;
1491
1534
  try {
1492
1535
  if (cfgOn) {
1493
- const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome });
1536
+ const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome, leg: LEG });
1494
1537
  claimed = got.claimed;
1495
- if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome };
1538
+ if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome, leg: LEG };
1496
1539
  refusal = monthlyRefusalText(got.room);
1497
1540
  }
1498
1541
  } catch (e) {
@@ -1518,11 +1561,10 @@ async function execute(ctx) {
1518
1561
 
1519
1562
  // PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
1520
1563
  // leg's own `runJob` further down, and BEFORE the provider-key checks that
1521
- // preceded this point already gated on). `runid` is fresh for THIS leg
1522
- // (cold start or resumed leg both mint one here), so `appendRun`'s own
1523
- // runid-dedup never fires on the ordinary path — it exists for a caller
1524
- // that legitimately re-enters with the same runid (e.g. a retried
1525
- // in-process test). A list-append failure must never block a real,
1564
+ // preceded this point already gated on). `runid` is fresh on a cold start
1565
+ // and the HALTED run's own on a resume (P5-R: a resume is the same run), so on a resume
1566
+ // `appendRun`'s runid-dedup is what keeps it ONE row and the leg's claim is the
1567
+ // `leg-start` beside it; on a cold start it never fires (a retried in-process test). A list-append failure must never block a real,
1526
1568
  // already-signed run: caught and named loudly, never rethrown.
1527
1569
  // `deps.runlistHome` — the same injectable test seam `ctx.deps` already
1528
1570
  // carries everything else through (env/out/err/provider/…): a caller that
@@ -1531,10 +1573,13 @@ async function execute(ctx) {
1531
1573
  // When the monthly claim above already appended this run's row, there is nothing to add.
1532
1574
  try {
1533
1575
  if (!claimed) {
1576
+ // a RESUMED run is already listed: its row stays ONE row and this leg's claim is a `leg-start` beside it
1577
+ // (the row is added only when the list has none)
1534
1578
  appendRun(runRow, { home: deps.runlistHome });
1579
+ if (LEG > 1) appendLegStart({ type: 'leg-start', runid, leg: LEG, pid: process.pid, capUsd: legCapUsd, at: new Date().toISOString() }, { home: deps.runlistHome });
1535
1580
  // no limit set: the row is appended here, not by a claim — mark it so the execute catch releases it
1536
1581
  // the same way if the run exits before its spine has a first record (it lives in `deps.runlistHome`)
1537
- claimedRow = { runid, spine: spineFile, home: deps.runlistHome };
1582
+ claimedRow = { runid, spine: spineFile, home: deps.runlistHome, leg: LEG };
1538
1583
  }
1539
1584
  } catch (/** @type {any} */ e) {
1540
1585
  err(`WARNING: could not add this run to ~/.config/bareloop/runs.jsonl (${e.message}) — the run continues; the panel's list will be missing this row.`);
@@ -1689,7 +1734,30 @@ async function execute(ctx) {
1689
1734
  // here (not inline at the `runJob` call below) so this pre-run reading lands
1690
1735
  // on the run's own spine in the SAME seq order everything else does, rather
1691
1736
  // than a second, disconnected spine writer.
1692
- const emit = makeSpine(spineFile);
1737
+ // P5-R: a RESUME continues the halted run's own file. `seq` is monotonic per SPINE, so the emitter starts
1738
+ // from the highest `seq` already in it; a kill can have left a torn line with no trailing newline, so ONE
1739
+ // `\n` is written first and the torn bytes stay exactly as they are, alone on their own line (the spine is
1740
+ // append-only forever: nothing here edits or truncates an existing byte).
1741
+ let startSeq = 0;
1742
+ if (resuming) {
1743
+ for (const e of deadAllEvents) if (typeof e?.seq === 'number' && Number.isFinite(e.seq) && e.seq > startSeq) startSeq = e.seq;
1744
+ const fd = openSync(spineFile, 'r');
1745
+ try {
1746
+ const size = fstatSync(fd).size;
1747
+ if (size > 0) {
1748
+ const last = Buffer.alloc(1);
1749
+ readSync(fd, last, 0, 1, size - 1);
1750
+ if (last[0] !== 0x0a) appendFileSync(spineFile, '\n');
1751
+ }
1752
+ } finally { closeSync(fd); }
1753
+ }
1754
+ const emit = makeSpine(spineFile, { startSeq });
1755
+ // …and the leg's FIRST record is its marker, written BEFORE the outside watchdog is spawned: the watchdog
1756
+ // judges a run dead by its spine file going quiet, and a file untouched since the last leg would read as stale.
1757
+ if (resuming) {
1758
+ emit(LEG_RESUME, { leg: LEG, after: deadAfter, at: new Date().toISOString() });
1759
+ legBegun = true;
1760
+ }
1693
1761
 
1694
1762
  // PRD item 33/M2, call site 3(a) — a source-door manifest, when this patient
1695
1763
  // was PREPARED through `prepareSource` (`scripts/prep-source.mjs`), sits
@@ -1704,6 +1772,7 @@ async function execute(ctx) {
1704
1772
  emit('destination-refused', { code: sourceManifest.code, detail: sourceManifest.stop });
1705
1773
  emit('run-end', { outcome: 'escalated' });
1706
1774
  err(`SOURCE-MANIFEST-RED — ${sourceManifest.stop}. See ${spineFile}`);
1775
+ releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
1707
1776
  throw new ExitSignal(1);
1708
1777
  }
1709
1778
  /** the front door's per-run instance, or null when there is none (no
@@ -1722,6 +1791,7 @@ async function execute(ctx) {
1722
1791
  emit('destination-refused', { code: dp.code, detail: dp.stop });
1723
1792
  emit('run-end', { outcome: 'escalated' });
1724
1793
  err(`DESTINATION-RED — ${dp.stop}. See ${spineFile}`);
1794
+ releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
1725
1795
  throw new ExitSignal(1);
1726
1796
  }
1727
1797
  }
@@ -1740,6 +1810,7 @@ async function execute(ctx) {
1740
1810
  });
1741
1811
  emit('run-end', { outcome: 'escalated' });
1742
1812
  err(`CLOSE-TIMING-RED — stage(s) never finished the timing preflight: ${names}. See ${spineFile}`);
1813
+ releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
1743
1814
  throw new ExitSignal(1);
1744
1815
  }
1745
1816
  const RESOLVED_CLOSE_TIMEOUT_MS = /** @type {number} */ (closeTimingResolved.closeTimeoutMs);
@@ -1776,6 +1847,8 @@ async function execute(ctx) {
1776
1847
  // time on exactly the run it is watching (F70's shape).
1777
1848
  ...(RESUME_WALL_MS === null ? [] : ['--wall-ms', String(RESUME_WALL_MS)]),
1778
1849
  '--grace-ms', String(worstCloseSilenceMs),
1850
+ // this leg's own note file (leg 1 keeps the name every run has always had)
1851
+ '--report', watchdogNotePath(spineFile, LEG),
1779
1852
  ], { stdio: ['ignore', 'ignore', 'inherit'] });
1780
1853
  // A spawn failure arrives as an EVENT, and an unhandled 'error' on a ChildProcess is
1781
1854
  // an uncaught exception — i.e. a guard that could not start would END the paid run it
@@ -1812,9 +1885,16 @@ async function execute(ctx) {
1812
1885
  lagDue = now + LAG_POLL_MS;
1813
1886
  }, LAG_POLL_MS);
1814
1887
 
1888
+ // P5 item 5 — the STOP request file (`<spine>.stop`, src/legs.js). A request belongs to ONE leg: one left on
1889
+ // disk by an earlier leg (a click that raced that leg's end) is cleared HERE, before this leg's first step, so
1890
+ // a resume is never stopped at once by a request it did not receive. The engine also consumes the file when it
1891
+ // honours it, and the `finally` below clears whatever is left when the leg ends.
1892
+ const stopFile = stopFilePath(spineFile);
1893
+ try { rmSync(stopFile, { force: true }); } catch { /* best-effort — a stop surface must never kill the run */ }
1815
1894
  let outcome;
1816
1895
  try {
1817
1896
  outcome = await runJob(spec, {
1897
+ stopFile,
1818
1898
  // PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
1819
1899
  // used (never a second `makeSpine(spineFile)` here: two independent
1820
1900
  // emitters against one file would both start their seq counter at 0 and
@@ -1903,6 +1983,7 @@ async function execute(ctx) {
1903
1983
  // the guard outlives the run only by accident, never by design
1904
1984
  try { watchdog.kill('SIGKILL'); } catch { /* already gone */ }
1905
1985
  clearInterval(lagTimer);
1986
+ try { rmSync(stopFile, { force: true }); } catch { /* best-effort */ }
1906
1987
  }
1907
1988
  const legMs = Date.now() - started;
1908
1989
 
@@ -1910,7 +1991,11 @@ async function execute(ctx) {
1910
1991
  // honest. No classification into pass/fail buckets — one run classifies nothing.
1911
1992
  const raw = readFileSync(spineFile, 'utf8');
1912
1993
  // tolerant: a torn/garbled line is skipped, not a crash (the shipped bundle-run fix, now the one engine read)
1913
- const events = parseJsonl(spineFile).records;
1994
+ const allEvents = parseJsonl(spineFile).records;
1995
+ // P5-R: the readout below is about THIS leg — its own rounds, its own halts, its own terminal. A resumed run's
1996
+ // earlier legs are on the same file, and reading them here would count their rounds, tokens and halts again
1997
+ // (the run's chain-wide figures are the folded ones the lines below already print).
1998
+ const events = legsOf(allEvents).at(-1)?.records ?? allEvents;
1914
1999
  const je = events.findLast((e) => e.type === 'job-end');
1915
2000
  // The claim's WRITE-BACK (hamr 2026-09-30): when the run ends it settles its own claim on the monthly
1916
2001
  // limit — its final spend, attributed to itself. Best-effort: a run that dies before here is settled
@@ -1918,7 +2003,7 @@ async function execute(ctx) {
1918
2003
  if (je) {
1919
2004
  try {
1920
2005
  const fin = legSpend(events);
1921
- appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString() }, { home: cfgHome });
2006
+ appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString(), ...(LEG > 1 ? { leg: LEG } : {}) }, { home: cfgHome });
1922
2007
  } catch (/** @type {any} */ e) { err(`WARNING: could not settle this run's claim in ~/.config/bareloop/runs.jsonl (${e.message}) — the next run settles it once this process is gone.`); }
1923
2008
  }
1924
2009
  // the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
@@ -1930,8 +2015,21 @@ async function execute(ctx) {
1930
2015
 
1931
2016
  const auditSrc = join(wd, 'gate-audit.jsonl');
1932
2017
  let auditFile = null;
1933
- if (existsSync(auditSrc)) { auditFile = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`); renameSync(auditSrc, auditFile); }
1934
- const audit = auditFile ? readFileSync(auditFile, 'utf8').trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : [];
2018
+ // THIS leg's own audit rows — the readout's `writes` and BEHAVIOUR lines are about this leg, read from the
2019
+ // leg's own tree file before it is filed
2020
+ /** @type {any[]} */
2021
+ let audit = [];
2022
+ if (existsSync(auditSrc)) {
2023
+ const legAuditText = readFileSync(auditSrc, 'utf8');
2024
+ audit = legAuditText.trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
2025
+ auditFile = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`);
2026
+ // P5-R: a resumed leg ADDS to the run's one audit file (a rename would overwrite the earlier legs' rows)
2027
+ if (LEG > 1 && existsSync(auditFile)) {
2028
+ const prior = readFileSync(auditFile, 'utf8');
2029
+ appendFileSync(auditFile, `${prior.length > 0 && !prior.endsWith('\n') ? '\n' : ''}${legAuditText}`);
2030
+ unlinkSync(auditSrc);
2031
+ } else renameSync(auditSrc, auditFile);
2032
+ }
1935
2033
  const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
1936
2034
 
1937
2035
  out(`\noutcome ${outcome}`);
@@ -1991,10 +2089,16 @@ async function execute(ctx) {
1991
2089
  : mh.trend === 'flat' ? 'more money is unlikely to help; this bundle\'s goal is signed and cannot be revised here'
1992
2090
  : 'read the last close output before deciding whether more money is worth it';
1993
2091
  out(` lever ${lever}`);
1994
- out(` · choose a larger --budget on the resume line below (the room left is the signed $${signed}; it cannot be raised past that)`);
1995
- out(' · abandon the task');
1996
- out(` resume ${ctx.bundle.invoke}${WALL_FLAG} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL}${DRAFT_TAIL}${REGISTRY_TAIL} --budget <more than ${spentTxt}, at most $${signed}> --approve ${PRINT_APPROVE}`);
1997
- out(' (type the --budget number yourself — nothing here fills it in, so this line does not run as printed; nothing in the run may widen its own budget)');
2092
+ if (spentUsd !== null && spentUsd >= signed) {
2093
+ // the whole signed ceiling is spent: "more than spent, at most signed" is unsatisfiable, so no resume line is printed
2094
+ out(` · no room left under the signed ceiling: this run spent all of the signed $${signed}, and a bundle's ceiling cannot be raised`);
2095
+ out(' · abandon the task');
2096
+ } else {
2097
+ out(` · choose a larger --budget on the resume line below (the room left is the signed $${signed}; it cannot be raised past that)`);
2098
+ out(' · abandon the task');
2099
+ out(` resume ${ctx.bundle.invoke}${WALL_FLAG} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL}${DRAFT_TAIL}${REGISTRY_TAIL} --budget <more than ${spentTxt}, at most $${signed}> --approve ${PRINT_APPROVE}`);
2100
+ out(' (type the --budget number yourself — nothing here fills it in, so this line does not run as printed; nothing in the run may widen its own budget)');
2101
+ }
1998
2102
  } else {
1999
2103
  out(` lever ${mh.lever}`);
2000
2104
  for (const o of mh.options ?? []) out(` · ${o}`);
@@ -2128,9 +2232,8 @@ async function execute(ctx) {
2128
2232
  // FOLDED across a resume, like every other money and time number this readout
2129
2233
  // prints: the row records what the RUN cost, never what this leg cost
2130
2234
  wallMs: legMs + (dead ? dead.restart.priorWallMs : 0),
2131
- // the ROUND count CANNOT fold — a resume leg writes a fresh spine and the halted
2132
- // leg's restart record declares money and wall but never rounds — so on a resume
2133
- // this is a FLOOR, and it says so the way the money above does (F6: a floor that
2235
+ // the ROUND count is not part of the declared fold — a resumed leg's `job-start` declares
2236
+ // money and wall but never rounds — so on a resume this is THIS LEG's count, a FLOOR, and it says so the way the money above does (F6: a floor that
2134
2237
  // reads as exact is dishonest; report the floor WITH its completeness)
2135
2238
  rounds: legRounds,
2136
2239
  roundsComplete: !dead,
@@ -2270,9 +2373,7 @@ async function execute(ctx) {
2270
2373
  // owns no spine, so its row is released (folded out of the list, like a monthly refusal) rather than
2271
2374
  // left as a ghost that keeps the month reading "at least". Once the spine exists the run is a real,
2272
2375
  // listed run and ends by its own job-end. A failed release write never masks the original exit.
2273
- if (claimedRow && !existsSync(claimedRow.spine)) {
2274
- try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString() }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
2275
- }
2376
+ if (claimedRow && (claimedRow.leg > 1 ? !legBegun : !existsSync(claimedRow.spine))) releaseClaimedRow();
2276
2377
  if (e instanceof ExitSignal) return e.code;
2277
2378
  throw e;
2278
2379
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @typedef {object} DraftSpend
3
+ * @property {string} sessionId
4
+ * @property {number} spentUsd the known floor of the session's drafting spend
5
+ * @property {boolean} spendComplete false = `spentUsd` is a floor ("at least")
6
+ * @property {string|null} provider the worker's provider shape (`anthropic-api` | `openai-api` | ...)
7
+ * @property {string|null} baseUrl
8
+ * @property {string|null} model
9
+ * @property {string} startedAt ISO, the first metered call
10
+ * @property {string} updatedAt ISO, the latest metered call
11
+ */
12
+ /**
13
+ * (Re)write `<dir>/draft-spend.json` atomically (tmp + rename).
14
+ * @param {string} dir the session's folder
15
+ * @param {DraftSpend} rec
16
+ * @returns {void}
17
+ */
18
+ export function writeDraftSpend(dir: string, rec: DraftSpend): void;
19
+ /**
20
+ * Every session folder's drafting spend that never became a run.
21
+ * @param {{ home?: string, sessionsRoot?: string }} [opts] `home` = the bareloop config dir (as everywhere in src/monthly.js); the sessions live in `<home>/panel-sessions` unless `sessionsRoot` names them
22
+ * @returns {DraftSpend[]}
23
+ */
24
+ export function readOrphanDraftSpends(opts?: {
25
+ home?: string;
26
+ sessionsRoot?: string;
27
+ }): DraftSpend[];
28
+ export const DRAFT_SPEND_FILE: "draft-spend.json";
29
+ export type DraftSpend = {
30
+ sessionId: string;
31
+ /**
32
+ * the known floor of the session's drafting spend
33
+ */
34
+ spentUsd: number;
35
+ /**
36
+ * false = `spentUsd` is a floor ("at least")
37
+ */
38
+ spendComplete: boolean;
39
+ /**
40
+ * the worker's provider shape (`anthropic-api` | `openai-api` | ...)
41
+ */
42
+ provider: string | null;
43
+ baseUrl: string | null;
44
+ model: string | null;
45
+ /**
46
+ * ISO, the first metered call
47
+ */
48
+ startedAt: string;
49
+ /**
50
+ * ISO, the latest metered call
51
+ */
52
+ updatedAt: string;
53
+ };
package/types/job.d.ts CHANGED
@@ -93,6 +93,17 @@ export function checkMenu(close: any[]): {
93
93
  * @returns {string} sha256 hex
94
94
  */
95
95
  export function jobSpecHash(job: object): string;
96
+ /**
97
+ * The identity of a WORKFLOW (Reuse workflow, hamr 2026-10-03): sha256 over the signed spec WITHOUT the fields a
98
+ * reuse may change ({@link REUSE_OPEN_SPEC_FIELDS}) and the worker identity ({@link WORKFLOW_KEY_IDENTITY_FIELDS}). Two runs
99
+ * share a workflow key iff they ran the same goal, checks, guardrails and close — whatever source they ran on, wherever they wrote, whatever caps they had.
100
+ * It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
101
+ * a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
102
+ * the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
103
+ * @param {object} job
104
+ * @returns {string} sha256 hex
105
+ */
106
+ export function workflowKey(job: object): string;
96
107
  /**
97
108
  * Does an approval record cover this exact spec version? Pure predicate — the
98
109
  * N2 runner refuses to run without it; nothing here writes or prompts. The
@@ -259,6 +270,20 @@ export const PROVIDERS: readonly string[];
259
270
  * candidate at N3. `provider` is part of the key by definition (top-level,
260
271
  * not duplicated here). */
261
272
  export const CONDITION_KEYS: readonly string[];
273
+ /**
274
+ * The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
275
+ * `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
276
+ * the WORKFLOW — goal, checks, guardrails, close (the worker model is the third kind, {@link WORKFLOW_KEY_IDENTITY_FIELDS}). ONE list, shared by {@link workflowKey} and by the panel's
277
+ * reuse (so what the key ignores is exactly what the person may edit, never two spellings).
278
+ */
279
+ export const REUSE_OPEN_SPEC_FIELDS: readonly string[];
280
+ /**
281
+ * The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
282
+ * chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
283
+ * signs still covers the model the run will use. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS} on purpose: those are the
284
+ * fields the page's boxes edit directly; these three change together, as one Settings row.
285
+ */
286
+ export const WORKFLOW_KEY_IDENTITY_FIELDS: readonly string[];
262
287
  /**
263
288
  * — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
264
289
  * `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
package/types/judged.d.ts CHANGED
@@ -229,7 +229,7 @@ export function locatePrompt(card: any): string;
229
229
  * On every route out, red or clean, `onCost` fires exactly once with the honest
230
230
  * read: a paid call that leaves no meter record is F12 wearing a judge's coat.
231
231
  * @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
232
- * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
232
+ * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number, rateSource?: string|null}) => void,
233
233
  * callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
234
234
  * @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
235
235
  * costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
@@ -247,6 +247,7 @@ export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt,
247
247
  onCost?: (c: {
248
248
  costUsd: number | null;
249
249
  unpricedRounds: number;
250
+ rateSource?: string | null;
250
251
  }) => void;
251
252
  callBounds?: {
252
253
  timeoutMs?: number;
@@ -389,7 +390,9 @@ export function defaultJudgeLoop({ provider, system, rates }: {
389
390
  in: number;
390
391
  out: number;
391
392
  } | null;
392
- }): import("bare-agent").Loop;
393
+ }): {
394
+ run: (msgs: any[], tools: any[], opts?: any) => Promise<any>;
395
+ };
393
396
  export type RuleRed = {
394
397
  fn: string;
395
398
  why: string;
package/types/ledger.d.ts CHANGED
@@ -95,6 +95,50 @@ export function floorsFromRecords(records: any[]): {
95
95
  spendFloorUsd: number | null;
96
96
  wallFloorMs: number | null;
97
97
  };
98
+ /**
99
+ * One run's own spend, off its spine records: this LEG's figure (never the chain fold),
100
+ * plus the drafting spend the run was signed with (counted once — on the first leg only).
101
+ * @param {any[]} records ONE leg's records
102
+ * @param {{ withDraft?: boolean }} [o] `withDraft: false` leaves the drafting spend out (a caller folding the CHAIN's own spend, which never held it)
103
+ * @returns {{ usd: number, complete: boolean }}
104
+ */
105
+ export function legSpend(records: any[], { withDraft }?: {
106
+ withDraft?: boolean;
107
+ }): {
108
+ usd: number;
109
+ complete: boolean;
110
+ };
111
+ /**
112
+ * A WHOLE RUN's own spend (P5-R: a resumed run is one spine of several legs): the sum of each leg's own
113
+ * figure ({@link legSpend}), never the declared chain fold. ONE basis — every leg's own rounds — so a leg is
114
+ * counted once: the last leg's `job-end.spentUsd` also carries the earlier legs, and adding it to the
115
+ * earlier legs' own figures would count them twice. A leg with no terminal (it died) is a floor, so the run
116
+ * is `complete: false`. A spine with no marker is one leg and reads exactly as {@link legSpend} always did.
117
+ * @param {any[]} records one spine's records
118
+ * @param {{ withDraft?: boolean }} [o]
119
+ * @returns {{ usd: number, complete: boolean, lastLegUsd: number }} `lastLegUsd`: the latest leg's own share (what a claim still held for it is measured against)
120
+ */
121
+ export function runSpend(records: any[], o?: {
122
+ withDraft?: boolean;
123
+ }): {
124
+ usd: number;
125
+ complete: boolean;
126
+ lastLegUsd: number;
127
+ };
128
+ /**
129
+ * What a FINISHED run's whole chain spent, for a caller that folds it forward (the review door's rerun opens a
130
+ * new run under the same signed ceiling). One basis per shape: a spine of ONE leg is read by its terminal's own
131
+ * chain total (`job-end.spentUsd`, which already carries whatever fold that leg inherited — what every door has
132
+ * always read); a resumed run's several legs are the first leg's own inherited fold (spend the file does not
133
+ * hold) plus each leg's OWN spend, so a later leg's terminal total — which also carries the earlier legs — is
134
+ * never added on top of them. Not finite / no terminal = `usd: 0, complete: false` (never an exact-looking 0).
135
+ * @param {any[]} records one spine's records
136
+ * @returns {{ usd: number, complete: boolean }}
137
+ */
138
+ export function chainSpend(records: any[]): {
139
+ usd: number;
140
+ complete: boolean;
141
+ };
98
142
  /** severity order, worst-first (the fold's display order; frequency ranks within) */
99
143
  export const LEDGER_CLASSES: readonly string[];
100
144
  /**