bareloop 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bareloop.context.md +241 -13
  3. package/package.json +1 -1
  4. package/src/bundlerun.js +13 -4
  5. package/src/draftspend.js +82 -0
  6. package/src/exits.js +10 -1
  7. package/src/interviewrun.js +7 -7
  8. package/src/job.js +37 -0
  9. package/src/judged.js +2 -2
  10. package/src/kinds.js +2 -1
  11. package/src/ledger.js +101 -5
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +111 -72
  14. package/src/panel/authorroutes.js +84 -14
  15. package/src/panel/authorsession.js +408 -113
  16. package/src/panel/importroutes.js +359 -0
  17. package/src/panel/importrun.js +203 -0
  18. package/src/panel/index.html +1071 -184
  19. package/src/panel/runroutes.js +219 -0
  20. package/src/panel/server.js +839 -70
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +60 -26
  23. package/src/providerrows.js +17 -0
  24. package/src/ralph.js +3 -0
  25. package/src/replay.js +139 -19
  26. package/src/replayio.js +11 -4
  27. package/src/reuse.js +20 -3
  28. package/src/run.js +6 -3
  29. package/src/runlist.js +75 -7
  30. package/src/text.js +20 -0
  31. package/src/u-readout.js +9 -1
  32. package/src/u-watchdog.mjs +4 -2
  33. package/src/userrun.js +138 -57
  34. package/types/draftspend.d.ts +53 -0
  35. package/types/job.d.ts +25 -0
  36. package/types/ledger.d.ts +44 -0
  37. package/types/legs.d.ts +103 -0
  38. package/types/monthly.d.ts +23 -18
  39. package/types/panel/authorroutes.d.ts +23 -1
  40. package/types/panel/authorsession.d.ts +109 -2
  41. package/types/panel/importroutes.d.ts +129 -0
  42. package/types/panel/importrun.d.ts +77 -0
  43. package/types/panel/runroutes.d.ts +32 -0
  44. package/types/panel/server.d.ts +255 -2
  45. package/types/panel/status.d.ts +51 -0
  46. package/types/planrun.d.ts +13 -21
  47. package/types/providerrows.d.ts +12 -0
  48. package/types/replay.d.ts +25 -1
  49. package/types/replayio.d.ts +2 -2
  50. package/types/reuse.d.ts +5 -0
  51. package/types/run.d.ts +6 -2
  52. package/types/runlist.d.ts +52 -2
  53. package/types/text.d.ts +19 -0
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, readRunList } 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,14 +363,17 @@ 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;
365
371
  // Release the claimed row (a `released` entry folds it out of the list, like a monthly refusal): the run
366
372
  // spent nothing and must not stay a ghost that keeps the month reading "at least". A failed release write
367
373
  // never masks the original exit.
368
374
  const releaseClaimedRow = () => {
369
375
  if (!claimedRow) return;
370
- 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 */ }
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 */ }
371
377
  };
372
378
  try {
373
379
  const spec = ctx.spec;
@@ -662,6 +668,13 @@ async function execute(ctx) {
662
668
  : (RESUME.includes('/') || RESUME.endsWith('.jsonl') ? resolve(RESUME) : join(spineDir, `u-${RESUME}.jsonl`));
663
669
  /** @type {any} */
664
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';
665
678
  /** F102 — the decision this checkpoint HOLDS: an answer the person already gave, on
666
679
  * a leg that stopped before a single round was bought for it. Null on every ordinary
667
680
  * resume. It is applied directly and the ask is never re-rendered — the whole of the
@@ -676,23 +689,34 @@ async function execute(ctx) {
676
689
  if (!existsSync(deadSpineFile)) die(`--resume: no spine at ${deadSpineFile} — a resume continues a run that happened, and this one left no log`);
677
690
  let raw = '';
678
691
  try { raw = readFileSync(deadSpineFile, 'utf8'); } catch (e) { die(`--resume: cannot read ${deadSpineFile}: ${e.message}`); }
679
- /** @type {any[]} */
680
- const deadEvents = [];
681
- const lines = raw.split('\n');
682
- lines.forEach((line, i) => {
683
- if (!line.trim()) return;
684
- try { deadEvents.push(JSON.parse(line)); } catch (e) {
685
- // a kill can land mid-append, so a broken LAST line is the failure being modelled
686
- // and is tolerated (named, never silently dropped). A broken line anywhere else is
687
- // a corrupt log, and reconstructing from one would invent a history.
688
- if (i >= lines.length - 2) err(`--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`);
689
- else die(`--resume: ${deadSpineFile} is corrupt at line ${i + 1} (${e.message}) — a reconstruction from a damaged log would invent a history`);
690
- }
691
- });
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;
692
715
  // is the dead run actually dead? Two processes on one patient is unrecoverable; a
693
716
  // false refusal costs one sentence. run-u's own pid is not on its spine, so the
694
717
  // watchdog's report or the run-list row is the pid there is — and its absence is not evidence of life.
695
- const wdFile = `${deadSpineFile}.watchdog.json`;
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);
696
720
  /** @type {any} */
697
721
  let watchdog = null;
698
722
  if (existsSync(wdFile)) { try { watchdog = JSON.parse(readFileSync(wdFile, 'utf8')); } catch { /* an unreadable report is not evidence of life */ } }
@@ -704,6 +728,7 @@ async function execute(ctx) {
704
728
  /** @type {number|undefined} */
705
729
  let rowPid;
706
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
707
732
  const alivePid = [watchdog?.pid, rowPid].find((p) => Number.isInteger(p) && isLiveRunner(p));
708
733
  if (alivePid !== undefined) {
709
734
  die(`--resume: pid ${alivePid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
@@ -715,6 +740,8 @@ async function execute(ctx) {
715
740
  // closes was measured, not imagined (a report dated after a pause bills the human's
716
741
  // deciding time to the run's wall and can zero the remainder outright).
717
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';
718
745
  dead = readResume(deadEvents, {
719
746
  direct: true,
720
747
  resumableOutcomes: RESUMABLE_HALTS,
@@ -792,13 +819,10 @@ async function execute(ctx) {
792
819
  if (!existsSync(doorSpineFile)) die(`--door: no spine at ${doorSpineFile} — a door belongs to a run that happened, and this one left no log`);
793
820
  let raw = '';
794
821
  try { raw = readFileSync(doorSpineFile, 'utf8'); } catch (e) { die(`--door: cannot read ${doorSpineFile}: ${e.message}`); }
795
- doorEvents = [];
796
- raw.split('\n').forEach((line, i) => {
797
- if (!line.trim()) return;
798
- try { /** @type {any[]} */ (doorEvents).push(JSON.parse(line)); } catch (e) {
799
- 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`);
800
- }
801
- });
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;
802
826
  const start = doorEvents.find((/** @type {any} */ e) => e?.type === 'job-start');
803
827
  if (!start) die(`--door: ${doorSpineFile} carries no job-start — that is not a bareloop run's spine`);
804
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`);
@@ -820,14 +844,11 @@ async function execute(ctx) {
820
844
  err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one.`);
821
845
  throw new ExitSignal(2);
822
846
  }
823
- const je = doorEvents.findLast((/** @type {any} */ e) => e?.type === 'job-end');
824
- const spentKnown = typeof je?.spentUsd === 'number' && Number.isFinite(je.spentUsd);
825
- doorPrior = {
826
- spentUsd: spentKnown ? je.spentUsd : 0,
827
- // a FLOOR stays a floor across the door (F6): an absent FIGURE is never "complete
828
- // at $0" — completeness is only trusted when the number it completes is known
829
- spendComplete: spentKnown && je?.spendComplete !== false,
830
- };
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 };
831
852
  }
832
853
 
833
854
  /** is this resume the one a PERSON has to answer? Read off the recorded terminal,
@@ -1303,10 +1324,10 @@ async function execute(ctx) {
1303
1324
  // ── THE PAUSE DOOR (2026-08-17) — answered HERE, and it launches nothing.
1304
1325
  //
1305
1326
  // hamr's ruling replaced cancel with a pause that can resume, and the honest way to
1306
- // 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,
1307
1328
  // and a leg that returns before drafting emits no `plan-accepted` and no `step-end` —
1308
1329
  // which is exactly what `readStepCheckpoint` reads. So launching a run to say "not
1309
- // 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
1310
1331
  // later resumed that runid would re-draft and re-pay for every step the paused leg
1311
1332
  // already finished. The checkpoint that matters is the one already on disk.
1312
1333
  //
@@ -1488,14 +1509,23 @@ async function execute(ctx) {
1488
1509
  // closed at its floor, with a note, once its process is gone). No limit set = no claim (the row is listed as
1489
1510
  // ever). An unreadable config.json, or a claim row that cannot be written, refuses too — a broken instrument
1490
1511
  // never silently reads "no limit".
1491
- const runid = ctx.bundle?.runid ?? Date.now().toString(36);
1492
- 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;
1493
1522
  const foldedUsd = dead ? dead.restart.priorSpentUsd : (doorPrior?.spentUsd ?? 0);
1494
1523
  const legCapUsd = Math.max(0, spec.budgetUsd - (typeof foldedUsd === 'number' && Number.isFinite(foldedUsd) ? foldedUsd : 0));
1495
1524
  // every row carries its runner's pid and the leg cap, limit set or not: a limit set LATER still holds it
1496
1525
  /** @type {import('./runlist.js').RunRow} */
1497
1526
  const runRow = {
1498
- 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,
1499
1529
  };
1500
1530
  let claimed = false;
1501
1531
  {
@@ -1503,9 +1533,9 @@ async function execute(ctx) {
1503
1533
  let refusal = null;
1504
1534
  try {
1505
1535
  if (cfgOn) {
1506
- const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome });
1536
+ const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome, leg: LEG });
1507
1537
  claimed = got.claimed;
1508
- if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome };
1538
+ if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome, leg: LEG };
1509
1539
  refusal = monthlyRefusalText(got.room);
1510
1540
  }
1511
1541
  } catch (e) {
@@ -1531,11 +1561,10 @@ async function execute(ctx) {
1531
1561
 
1532
1562
  // PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
1533
1563
  // leg's own `runJob` further down, and BEFORE the provider-key checks that
1534
- // preceded this point already gated on). `runid` is fresh for THIS leg
1535
- // (cold start or resumed leg both mint one here), so `appendRun`'s own
1536
- // runid-dedup never fires on the ordinary path — it exists for a caller
1537
- // that legitimately re-enters with the same runid (e.g. a retried
1538
- // 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,
1539
1568
  // already-signed run: caught and named loudly, never rethrown.
1540
1569
  // `deps.runlistHome` — the same injectable test seam `ctx.deps` already
1541
1570
  // carries everything else through (env/out/err/provider/…): a caller that
@@ -1544,10 +1573,13 @@ async function execute(ctx) {
1544
1573
  // When the monthly claim above already appended this run's row, there is nothing to add.
1545
1574
  try {
1546
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)
1547
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 });
1548
1580
  // no limit set: the row is appended here, not by a claim — mark it so the execute catch releases it
1549
1581
  // the same way if the run exits before its spine has a first record (it lives in `deps.runlistHome`)
1550
- claimedRow = { runid, spine: spineFile, home: deps.runlistHome };
1582
+ claimedRow = { runid, spine: spineFile, home: deps.runlistHome, leg: LEG };
1551
1583
  }
1552
1584
  } catch (/** @type {any} */ e) {
1553
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.`);
@@ -1702,7 +1734,30 @@ async function execute(ctx) {
1702
1734
  // here (not inline at the `runJob` call below) so this pre-run reading lands
1703
1735
  // on the run's own spine in the SAME seq order everything else does, rather
1704
1736
  // than a second, disconnected spine writer.
1705
- 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
+ }
1706
1761
 
1707
1762
  // PRD item 33/M2, call site 3(a) — a source-door manifest, when this patient
1708
1763
  // was PREPARED through `prepareSource` (`scripts/prep-source.mjs`), sits
@@ -1792,6 +1847,8 @@ async function execute(ctx) {
1792
1847
  // time on exactly the run it is watching (F70's shape).
1793
1848
  ...(RESUME_WALL_MS === null ? [] : ['--wall-ms', String(RESUME_WALL_MS)]),
1794
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),
1795
1852
  ], { stdio: ['ignore', 'ignore', 'inherit'] });
1796
1853
  // A spawn failure arrives as an EVENT, and an unhandled 'error' on a ChildProcess is
1797
1854
  // an uncaught exception — i.e. a guard that could not start would END the paid run it
@@ -1828,9 +1885,16 @@ async function execute(ctx) {
1828
1885
  lagDue = now + LAG_POLL_MS;
1829
1886
  }, LAG_POLL_MS);
1830
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 */ }
1831
1894
  let outcome;
1832
1895
  try {
1833
1896
  outcome = await runJob(spec, {
1897
+ stopFile,
1834
1898
  // PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
1835
1899
  // used (never a second `makeSpine(spineFile)` here: two independent
1836
1900
  // emitters against one file would both start their seq counter at 0 and
@@ -1919,6 +1983,7 @@ async function execute(ctx) {
1919
1983
  // the guard outlives the run only by accident, never by design
1920
1984
  try { watchdog.kill('SIGKILL'); } catch { /* already gone */ }
1921
1985
  clearInterval(lagTimer);
1986
+ try { rmSync(stopFile, { force: true }); } catch { /* best-effort */ }
1922
1987
  }
1923
1988
  const legMs = Date.now() - started;
1924
1989
 
@@ -1926,7 +1991,11 @@ async function execute(ctx) {
1926
1991
  // honest. No classification into pass/fail buckets — one run classifies nothing.
1927
1992
  const raw = readFileSync(spineFile, 'utf8');
1928
1993
  // tolerant: a torn/garbled line is skipped, not a crash (the shipped bundle-run fix, now the one engine read)
1929
- 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;
1930
1999
  const je = events.findLast((e) => e.type === 'job-end');
1931
2000
  // The claim's WRITE-BACK (hamr 2026-09-30): when the run ends it settles its own claim on the monthly
1932
2001
  // limit — its final spend, attributed to itself. Best-effort: a run that dies before here is settled
@@ -1934,7 +2003,7 @@ async function execute(ctx) {
1934
2003
  if (je) {
1935
2004
  try {
1936
2005
  const fin = legSpend(events);
1937
- 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 });
1938
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.`); }
1939
2008
  }
1940
2009
  // the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
@@ -1946,8 +2015,21 @@ async function execute(ctx) {
1946
2015
 
1947
2016
  const auditSrc = join(wd, 'gate-audit.jsonl');
1948
2017
  let auditFile = null;
1949
- if (existsSync(auditSrc)) { auditFile = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`); renameSync(auditSrc, auditFile); }
1950
- 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
+ }
1951
2033
  const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
1952
2034
 
1953
2035
  out(`\noutcome ${outcome}`);
@@ -2150,9 +2232,8 @@ async function execute(ctx) {
2150
2232
  // FOLDED across a resume, like every other money and time number this readout
2151
2233
  // prints: the row records what the RUN cost, never what this leg cost
2152
2234
  wallMs: legMs + (dead ? dead.restart.priorWallMs : 0),
2153
- // the ROUND count CANNOT fold — a resume leg writes a fresh spine and the halted
2154
- // leg's restart record declares money and wall but never rounds — so on a resume
2155
- // 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
2156
2237
  // reads as exact is dishonest; report the floor WITH its completeness)
2157
2238
  rounds: legRounds,
2158
2239
  roundsComplete: !dead,
@@ -2292,7 +2373,7 @@ async function execute(ctx) {
2292
2373
  // owns no spine, so its row is released (folded out of the list, like a monthly refusal) rather than
2293
2374
  // left as a ghost that keeps the month reading "at least". Once the spine exists the run is a real,
2294
2375
  // listed run and ends by its own job-end. A failed release write never masks the original exit.
2295
- if (claimedRow && !existsSync(claimedRow.spine)) releaseClaimedRow();
2376
+ if (claimedRow && (claimedRow.leg > 1 ? !legBegun : !existsSync(claimedRow.spine))) releaseClaimedRow();
2296
2377
  if (e instanceof ExitSignal) return e.code;
2297
2378
  throw e;
2298
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/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
  /**
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Split one spine's parsed records into its legs. Pure.
3
+ * @param {any[]} events one spine's parsed records, in file order
4
+ * @returns {Leg[]} always at least one leg for a non-empty list; `[]` for an empty one
5
+ */
6
+ export function legsOf(events: any[]): Leg[];
7
+ /**
8
+ * The legs' WORKING time, gap excluded (hamr 2026-10-02, 2A: only working time charges the wall). Each leg
9
+ * counts first record → last record; the quiet time between one leg's end and the next leg's marker is
10
+ * nobody's. `null` = unknown (no leg carries two parseable stamps) — never 0. A leg whose stamps are
11
+ * unreadable adds nothing, so the figure is a FLOOR, `complete: false`.
12
+ * @param {Leg[]} legs
13
+ * @returns {{ ms: number|null, complete: boolean }}
14
+ */
15
+ export function legsWallMs(legs: Leg[]): {
16
+ ms: number | null;
17
+ complete: boolean;
18
+ };
19
+ /**
20
+ * Parse a spine's TEXT, tolerant exactly where the writer leaves damage. A kill mid-append leaves a torn
21
+ * line; a resume never edits it (the spine is append-only forever) — it writes one `\n` and starts a fresh
22
+ * line, so the torn bytes end up alone on their own line, immediately BEFORE a `leg-resume` marker. That
23
+ * line, and the file's own last two lines (the older tail rule: a kill mid-append of the file's final
24
+ * record), are tolerated and named. An unparseable line anywhere else is `corrupt`: a reconstruction from
25
+ * it would invent a history.
26
+ * @param {string} raw
27
+ * @returns {{ records: any[], tolerated: {line: number, why: 'tail'|'before-leg-resume'}[], corrupt: {line: number, message: string}|null }}
28
+ * `line` is 1-based. Only the FIRST corrupt line is reported.
29
+ */
30
+ export function parseSpineText(raw: string): {
31
+ records: any[];
32
+ tolerated: {
33
+ line: number;
34
+ why: "tail" | "before-leg-resume";
35
+ }[];
36
+ corrupt: {
37
+ line: number;
38
+ message: string;
39
+ } | null;
40
+ };
41
+ /**
42
+ * Where leg `leg`'s watchdog kill note lives. Leg 1 keeps the name every run has always had
43
+ * (`<spine>.watchdog.json`); a resumed leg's note sits beside it (`<spine>.leg<N>.watchdog.json`) so one
44
+ * leg never overwrites another's evidence of how it died.
45
+ * @param {string} spineFile
46
+ * @param {number} leg 1-based
47
+ * @returns {string}
48
+ */
49
+ export function watchdogNotePath(spineFile: string, leg: number): string;
50
+ /**
51
+ * P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
52
+ * the engine reads it at the between-steps seam (src/planrun.js) and consumes it. One spelling, here, so the route
53
+ * and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
54
+ * when the leg ends, and again at the start of the next leg (src/userrun.js).
55
+ * @param {string} spineFile
56
+ * @returns {string}
57
+ */
58
+ export function stopFilePath(spineFile: string): string;
59
+ /** The marker record's `type`. */
60
+ export const LEG_RESUME: "leg-resume";
61
+ export type Leg = {
62
+ /**
63
+ * 1-based position in file order (the marker's own `leg` field is never trusted over the order)
64
+ */
65
+ leg: number;
66
+ /**
67
+ * the leg's first record (the `leg-resume` marker for leg 2+; the file's first record for leg 1)
68
+ */
69
+ start: any | null;
70
+ /**
71
+ * the leg's last record
72
+ */
73
+ end: any | null;
74
+ /**
75
+ * every record of the leg, in file order, marker included
76
+ */
77
+ records: any[];
78
+ /**
79
+ * the leg's own `job-start` (null: it ended before runJob wrote one)
80
+ */
81
+ jobStart: any | null;
82
+ /**
83
+ * the leg's own LAST `job-end` (null: it died, or it is still running)
84
+ */
85
+ jobEnd: any | null;
86
+ /**
87
+ * the leg's terminal: its `job-end` outcome, else a `run-end` outcome (a $0 refusal), else null
88
+ */
89
+ outcome: string | null;
90
+ /**
91
+ * leg 1: null. Leg 2+: how the PREVIOUS leg ended, as the marker declares it
92
+ * (else derived from that leg's own terminal), `'died'` when it recorded none
93
+ */
94
+ after: string | null;
95
+ /**
96
+ * `Date.parse` of the start record's `ts`
97
+ */
98
+ startMs: number | null;
99
+ /**
100
+ * `Date.parse` of the end record's `ts`
101
+ */
102
+ endMs: number | null;
103
+ };