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
package/src/text.js CHANGED
@@ -308,3 +308,23 @@ export function stampRaw(raw, { cause = null, reason = null }) {
308
308
  raw.reason = reason === null || reason === undefined ? null : redactSecrets(reason);
309
309
  return raw;
310
310
  }
311
+
312
+ /**
313
+ * BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
314
+ * `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
315
+ * `pricing` on every metering payload; both metering callbacks below forward it onto the
316
+ * spine through THIS one helper, because two sites spelling one rule are two instruments
317
+ * (the ripgrep fix that landed in ci.yml but not publish.yml).
318
+ *
319
+ * Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
320
+ * the one place that decides what counts as a guess. And an ABSENT provenance stays
321
+ * absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
322
+ * in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
323
+ * invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
324
+ * priced", which is a different fact from "nobody told us".
325
+ * @param {any} arg an `onLlmResult` / `onTurn` payload
326
+ * @returns {{rateSource?: string|null}} the field to spread into the spine record
327
+ */
328
+ export function rateSourceFields(arg) {
329
+ return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
330
+ }
package/src/u-patient.js CHANGED
@@ -16,9 +16,10 @@
16
16
  // mechanical preparation of a tree, and both callers print what it did.
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import {
19
- rmSync, existsSync, renameSync,
19
+ rmSync, existsSync,
20
20
  } from 'node:fs';
21
21
  import { join } from 'node:path';
22
+ import { moveFile } from './movefile.js';
22
23
 
23
24
  /**
24
25
  * Reset a patient to its frozen seed and drop its `.litectx` store.
@@ -68,6 +69,6 @@ export function moveStaleGateAudit(wd, spineDir, runid) {
68
69
  const staleAudit = join(wd, 'gate-audit.jsonl');
69
70
  if (!existsSync(staleAudit)) return null;
70
71
  const preFile = join(spineDir, `pre-${runid}-gate-audit.jsonl`);
71
- renameSync(staleAudit, preFile);
72
+ moveFile(staleAudit, preFile);
72
73
  return preFile;
73
74
  }
package/src/u-readout.js CHANGED
@@ -16,6 +16,8 @@
16
16
  // leads; the leg is what this process actually bought, and dropping it would trade
17
17
  // one blindness for the other.
18
18
 
19
+ import { legsOf } from './legs.js';
20
+
19
21
  /** minutes, at the precision the readout has ever claimed (~90%, hamr's ruling) */
20
22
  const min = (/** @type {number} */ ms) => (ms / 60_000).toFixed(1);
21
23
 
@@ -336,10 +338,16 @@ export function doorLines({ rerun, accept, pause }) {
336
338
  */
337
339
  export function deathAtOf({ watchdogAt, events } = {}) {
338
340
  const list = Array.isArray(events) ? events : [];
341
+ // P5-R: a resumed run is one spine of several legs. "Did this run end itself" is a question about its
342
+ // LATEST leg: an earlier leg's job-end says nothing about how the leg now being read stopped, and a
343
+ // watchdog note dated before this leg began is a different leg's note.
344
+ const last = legsOf(list).at(-1) ?? null;
345
+ const scope = last ? last.records : list;
339
346
  // a landed terminal is the run dating its own stop — nothing outside it is better
340
347
  // evidence about a run that was never killed
341
- if (list.some((e) => e !== null && typeof e === 'object' && /** @type {any} */ (e).type === 'job-end')) return null;
348
+ if (scope.some((e) => e !== null && typeof e === 'object' && /** @type {any} */ (e).type === 'job-end')) return null;
342
349
  const at = typeof watchdogAt === 'string' ? Date.parse(watchdogAt) : NaN;
350
+ if (last && last.leg > 1 && last.startMs !== null && Number.isFinite(at) && at < last.startMs) return null;
343
351
  // an unreadable stamp is UNKNOWN, never a NaN handed to a fold (F6): the caller's
344
352
  // own default is the honest fallback
345
353
  return Number.isFinite(at) ? at : null;
@@ -43,7 +43,7 @@
43
43
  //
44
44
  // Usage:
45
45
  // node src/u-watchdog.mjs --spine <path> --pid <n>
46
- // [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N]
46
+ // [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N] [--report <path>]
47
47
  import { statSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
48
48
 
49
49
  const arg = (/** @type {string} */ n, /** @type {string|null} */ dflt = null) => {
@@ -215,7 +215,9 @@ function stop(/** @type {string} */ reason, /** @type {string} */ judgement, /**
215
215
  // not a guard (F70: the instrument carrying the failure mode it exists to catch). The
216
216
  // kill below runs whether or not this succeeded; a failure is named on stderr so a
217
217
  // missing marker is never read as "the guard never fired".
218
- const reportPath = `${spine}.watchdog.json`;
218
+ // a RESUMED leg writes its own note beside the run's one spine (P5-R: `<spine>.leg<N>.watchdog.json`), so a
219
+ // later leg never overwrites the earlier leg's evidence; the default is the file every run has always had
220
+ const reportPath = arg('report') ?? `${spine}.watchdog.json`;
219
221
  const tmpPath = `${reportPath}.tmp`;
220
222
  try {
221
223
  writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);
package/src/userrun.js CHANGED
@@ -24,11 +24,13 @@
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, 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
+ import { commitWork, removeWorktree } from './worktree.js';
33
+ import { moveFile } from './movefile.js';
32
34
  import { runJob } from './run.js';
33
35
  import { moneyWithDraft } from './replay.js';
34
36
  import { jobSpecHash, resolveWorkerModel, validateJob } from './job.js';
@@ -72,10 +74,13 @@ import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
72
74
  // the cold reset, shared with the battery drivers so "cold" has one spelling
73
75
  import { coldReset, moveStaleGateAudit } from './u-patient.js';
74
76
  // PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
75
- import { appendRun, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
77
+ import { appendRun, appendLegStart, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
78
+ // P5-R — a resumed run is the SAME run: one id, one spine file, a `leg-resume` marker inside. `legsOf` is the
79
+ // one owner of where a leg starts and ends; every reader below asks it.
80
+ import { legsOf, parseSpineText, LEG_RESUME, watchdogNotePath, stopFilePath } from './legs.js';
76
81
  import { keysForDoor } from './keysfile.js';
77
82
  import { parseJsonl } from './replayio.js';
78
- import { claimRun, monthlyRefusalText, legSpend } from './monthly.js';
83
+ import { claimRun, monthlyRefusalText, legSpend, chainSpend } from './monthly.js';
79
84
  import { ConfigError } from './config.js';
80
85
  import { commandFor } from './invoke.js';
81
86
  import { applyConfiguredKey, judgeRatesFor, keyNameFor, priceReadout, ratesFor, rowsForHome } from './providerrows.js';
@@ -265,6 +270,9 @@ class ExitSignal extends Error {
265
270
  * @property {string} [workdir] the patient's tree
266
271
  * @property {string} [seed] the patient's frozen seed commit
267
272
  * @property {string} [spineName] the spine directory name, beside `workdir`
273
+ * @property {string} [into] the source door's own directory (`source.json`, and the spine directory beside it).
274
+ * Default `dirname(workdir)` — the copied-tree layout, where the tree is `<into>/tree`. A repo job on a WORKTREE
275
+ * (P6 item 1) has its tree inside the person's repo, so its `into` is passed explicitly.
268
276
  * @property {string|null} [model] the raw `--model` tier name (`sonnet`/`haiku`), or null for the default
269
277
  * @property {string|null} [readShim] the raw `--read-shim` arm name, or null for the default
270
278
  * @property {string|null} [scout] the raw `--scout` value (`on`/`off`), or null for the default
@@ -296,6 +304,7 @@ class ExitSignal extends Error {
296
304
  * @property {string} workdir
297
305
  * @property {string} seed
298
306
  * @property {string} spineName
307
+ * @property {string} [into] see `RunOpts.into`
299
308
  * @property {string|null} model
300
309
  * @property {string|null} readShim
301
310
  * @property {string|null} scout
@@ -360,14 +369,17 @@ async function execute(ctx) {
360
369
  // owner of "a claimed run that never began" — every exit between the claim and the spine's first
361
370
  // record (a refused resume patient, a throwing prepareTree, a git/reset failure...) passes there.
362
371
  // 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} */
372
+ /** @type {{ runid: string, spine: string, home: string|undefined, leg: number }|null} */
364
373
  let claimedRow = null;
374
+ // P5-R: has this leg written its first record (the `leg-resume` marker on a resume)? A resumed leg's spine
375
+ // file already exists, so "the file exists" no longer says the leg began.
376
+ let legBegun = false;
365
377
  // Release the claimed row (a `released` entry folds it out of the list, like a monthly refusal): the run
366
378
  // spent nothing and must not stay a ghost that keeps the month reading "at least". A failed release write
367
379
  // never masks the original exit.
368
380
  const releaseClaimedRow = () => {
369
381
  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 */ }
382
+ 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
383
  };
372
384
  try {
373
385
  const spec = ctx.spec;
@@ -654,7 +666,10 @@ async function execute(ctx) {
654
666
  * both the RESUME reader below and the live run further down need them. Two spellings of
655
667
  * one path is how `--resume` comes to read a different directory than the run writes. */
656
668
  const wd = resolve(WORKDIR);
657
- const spineDir = ctx.bundle ? ctx.bundle.runDir : join(wd, '..', target.spine);
669
+ /** the source door's own directory: where `source.json` sits and the spine directory goes. The copied-tree layout
670
+ * has the tree at `<into>/tree`, so `dirname(wd)`; a worktree run passes it (its tree is inside the person's repo). */
671
+ const into = ctx.into !== undefined ? resolve(ctx.into) : dirname(wd);
672
+ const spineDir = ctx.bundle ? ctx.bundle.runDir : join(into, target.spine);
658
673
  /** `--resume` takes the runid (the conventional `<spine dir>/u-<runid>.jsonl`) or an
659
674
  * explicit path — the path form is what makes these gates testable without touching
660
675
  * an operator's real spine directory. */
@@ -662,6 +677,13 @@ async function execute(ctx) {
662
677
  : (RESUME.includes('/') || RESUME.endsWith('.jsonl') ? resolve(RESUME) : join(spineDir, `u-${RESUME}.jsonl`));
663
678
  /** @type {any} */
664
679
  let dead = null;
680
+ /** 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[]} */
681
+ let deadLegs = [];
682
+ /** every record of the halted run's one file (all its legs) @type {any[]} */
683
+ let deadAllEvents = [];
684
+ let deadFromLegNo = 1;
685
+ /** @type {string} */
686
+ let deadAfter = 'died';
665
687
  /** F102 — the decision this checkpoint HOLDS: an answer the person already gave, on
666
688
  * a leg that stopped before a single round was bought for it. Null on every ordinary
667
689
  * resume. It is applied directly and the ask is never re-rendered — the whole of the
@@ -676,23 +698,34 @@ async function execute(ctx) {
676
698
  if (!existsSync(deadSpineFile)) die(`--resume: no spine at ${deadSpineFile} — a resume continues a run that happened, and this one left no log`);
677
699
  let raw = '';
678
700
  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
- });
701
+ // ONE parser for the spine's text (src/legs.js `parseSpineText`): a kill can land mid-append, so a broken
702
+ // LAST line is the failure being modelled; and a resumed run's own file carries exactly one torn line
703
+ // directly before each `leg-resume` marker (the resume never edits the torn bytes, it starts a fresh
704
+ // line). Both are tolerated and NAMED. A broken line anywhere else is a corrupt log, and reconstructing
705
+ // from one would invent a history.
706
+ const parsedDead = parseSpineText(raw);
707
+ for (const t of parsedDead.tolerated) {
708
+ err(t.why === 'tail'
709
+ ? `--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`
710
+ : `--resume: ignoring a truncated line ${t.line} in ${deadSpineFile} (a kill mid-append, left isolated by the next leg's marker)`);
711
+ }
712
+ 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`);
713
+ /** every record of the run's one file (all legs) */
714
+ deadAllEvents = parsedDead.records;
715
+ const allDeadEvents = deadAllEvents;
716
+ /** the legs already on the file, and the leg THIS resume will be (P5-R) */
717
+ deadLegs = legsOf(allDeadEvents);
718
+ // the leg a resume continues FROM is the latest leg that got as far as a `job-start`: a leg that died or
719
+ // was refused before runJob wrote one (a $0 stop) left no checkpoint, and the one behind it still stands
720
+ const fromLeg = deadLegs.findLast((l) => l.jobStart !== null) ?? deadLegs.at(-1);
721
+ /** the records `readResume` and every gate below read: that leg's own window, which carries its own declared fold */
722
+ const deadEvents = fromLeg ? fromLeg.records : allDeadEvents;
723
+ deadFromLegNo = fromLeg ? fromLeg.leg : 1;
692
724
  // is the dead run actually dead? Two processes on one patient is unrecoverable; a
693
725
  // false refusal costs one sentence. run-u's own pid is not on its spine, so the
694
726
  // 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`;
727
+ // the watchdog's note for the leg being continued from (a resumed leg has its own note file)
728
+ const wdFile = watchdogNotePath(deadSpineFile, deadFromLegNo);
696
729
  /** @type {any} */
697
730
  let watchdog = null;
698
731
  if (existsSync(wdFile)) { try { watchdog = JSON.parse(readFileSync(wdFile, 'utf8')); } catch { /* an unreadable report is not evidence of life */ } }
@@ -704,6 +737,7 @@ async function execute(ctx) {
704
737
  /** @type {number|undefined} */
705
738
  let rowPid;
706
739
  if (cfgOn) { try { rowPid = readRunList({ home: cfgHome }).rows.findLast((r) => r.spine === deadSpineFile)?.pid; } catch { /* an unreadable list is not evidence of life */ } }
740
+ // 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
741
  const alivePid = [watchdog?.pid, rowPid].find((p) => Number.isInteger(p) && isLiveRunner(p));
708
742
  if (alivePid !== undefined) {
709
743
  die(`--resume: pid ${alivePid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
@@ -715,6 +749,8 @@ async function execute(ctx) {
715
749
  // closes was measured, not imagined (a report dated after a pause bills the human's
716
750
  // deciding time to the run's wall and can zero the remainder outright).
717
751
  const deathAt = deathAtOf({ watchdogAt: watchdog?.at, events: deadEvents });
752
+ // how the leg being continued from ended, for the marker the new leg opens with (`'died'`: no terminal recorded)
753
+ deadAfter = fromLeg?.outcome ?? 'died';
718
754
  dead = readResume(deadEvents, {
719
755
  direct: true,
720
756
  resumableOutcomes: RESUMABLE_HALTS,
@@ -792,13 +828,10 @@ async function execute(ctx) {
792
828
  if (!existsSync(doorSpineFile)) die(`--door: no spine at ${doorSpineFile} — a door belongs to a run that happened, and this one left no log`);
793
829
  let raw = '';
794
830
  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
- });
831
+ // the same parser as the resume reader: a resumed run's file carries one torn line before each marker
832
+ const parsedDoor = parseSpineText(raw);
833
+ 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`);
834
+ doorEvents = parsedDoor.records;
802
835
  const start = doorEvents.find((/** @type {any} */ e) => e?.type === 'job-start');
803
836
  if (!start) die(`--door: ${doorSpineFile} carries no job-start — that is not a bareloop run's spine`);
804
837
  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 +853,11 @@ async function execute(ctx) {
820
853
  err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one.`);
821
854
  throw new ExitSignal(2);
822
855
  }
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
- };
856
+ // the chain's spend, on ONE basis per shape (`chainSpend`, src/monthly.js): a resumed run's several legs are
857
+ // summed by their own spend, never a later terminal's chain total on top of the earlier legs. A FLOOR stays a
858
+ // floor across the door (F6): an absent figure is never "complete at $0".
859
+ const chain = chainSpend(doorEvents);
860
+ doorPrior = { spentUsd: chain.usd, spendComplete: chain.complete };
831
861
  }
832
862
 
833
863
  /** is this resume the one a PERSON has to answer? Read off the recorded terminal,
@@ -1303,10 +1333,10 @@ async function execute(ctx) {
1303
1333
  // ── THE PAUSE DOOR (2026-08-17) — answered HERE, and it launches nothing.
1304
1334
  //
1305
1335
  // 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,
1336
+ // keep a checkpoint is to leave it alone. This runner appends one LEG per resume to the run's one spine file,
1307
1337
  // and a leg that returns before drafting emits no `plan-accepted` and no `step-end` —
1308
1338
  // 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
1339
+ // now" would add a LEG whose own checkpoint is empty, and an operator who
1310
1340
  // later resumed that runid would re-draft and re-pay for every step the paused leg
1311
1341
  // already finished. The checkpoint that matters is the one already on disk.
1312
1342
  //
@@ -1488,14 +1518,23 @@ async function execute(ctx) {
1488
1518
  // closed at its floor, with a note, once its process is gone). No limit set = no claim (the row is listed as
1489
1519
  // ever). An unreadable config.json, or a claim row that cannot be written, refuses too — a broken instrument
1490
1520
  // 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`);
1521
+ // P5-R (hamr 2026-10-02: "one run, one id, one file"): a RESUME is the same run — the runid and the spine file
1522
+ // are the halted run's own, and the new leg appends to it. Never a new id, never a second file.
1523
+ const resuming = dead !== null && deadSpineFile !== null;
1524
+ const runid = resuming
1525
+ ? (ctx.bundle?.runid ?? basename(/** @type {string} */ (deadSpineFile)).replace(/\.jsonl$/, '').replace(/^u-/, ''))
1526
+ : (ctx.bundle?.runid ?? Date.now().toString(36));
1527
+ const spineFile = resuming ? /** @type {string} */ (deadSpineFile)
1528
+ : (ctx.bundle ? join(spineDir, 'spine.jsonl') : join(spineDir, `u-${runid}.jsonl`));
1529
+ /** which leg of the run this process is (1 = a fresh run) */
1530
+ const LEG = resuming ? deadLegs.length + 1 : 1;
1493
1531
  const foldedUsd = dead ? dead.restart.priorSpentUsd : (doorPrior?.spentUsd ?? 0);
1494
1532
  const legCapUsd = Math.max(0, spec.budgetUsd - (typeof foldedUsd === 'number' && Number.isFinite(foldedUsd) ? foldedUsd : 0));
1495
1533
  // every row carries its runner's pid and the leg cap, limit set or not: a limit set LATER still holds it
1496
1534
  /** @type {import('./runlist.js').RunRow} */
1497
1535
  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,
1536
+ // 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)
1537
+ 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
1538
  };
1500
1539
  let claimed = false;
1501
1540
  {
@@ -1503,9 +1542,9 @@ async function execute(ctx) {
1503
1542
  let refusal = null;
1504
1543
  try {
1505
1544
  if (cfgOn) {
1506
- const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome });
1545
+ const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome, leg: LEG });
1507
1546
  claimed = got.claimed;
1508
- if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome };
1547
+ if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome, leg: LEG };
1509
1548
  refusal = monthlyRefusalText(got.room);
1510
1549
  }
1511
1550
  } catch (e) {
@@ -1531,11 +1570,10 @@ async function execute(ctx) {
1531
1570
 
1532
1571
  // PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
1533
1572
  // 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,
1573
+ // preceded this point already gated on). `runid` is fresh on a cold start
1574
+ // and the HALTED run's own on a resume (P5-R: a resume is the same run), so on a resume
1575
+ // `appendRun`'s runid-dedup is what keeps it ONE row and the leg's claim is the
1576
+ // `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
1577
  // already-signed run: caught and named loudly, never rethrown.
1540
1578
  // `deps.runlistHome` — the same injectable test seam `ctx.deps` already
1541
1579
  // carries everything else through (env/out/err/provider/…): a caller that
@@ -1544,10 +1582,13 @@ async function execute(ctx) {
1544
1582
  // When the monthly claim above already appended this run's row, there is nothing to add.
1545
1583
  try {
1546
1584
  if (!claimed) {
1585
+ // a RESUMED run is already listed: its row stays ONE row and this leg's claim is a `leg-start` beside it
1586
+ // (the row is added only when the list has none)
1547
1587
  appendRun(runRow, { home: deps.runlistHome });
1588
+ if (LEG > 1) appendLegStart({ type: 'leg-start', runid, leg: LEG, pid: process.pid, capUsd: legCapUsd, at: new Date().toISOString() }, { home: deps.runlistHome });
1548
1589
  // no limit set: the row is appended here, not by a claim — mark it so the execute catch releases it
1549
1590
  // 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 };
1591
+ claimedRow = { runid, spine: spineFile, home: deps.runlistHome, leg: LEG };
1551
1592
  }
1552
1593
  } catch (/** @type {any} */ e) {
1553
1594
  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 +1743,30 @@ async function execute(ctx) {
1702
1743
  // here (not inline at the `runJob` call below) so this pre-run reading lands
1703
1744
  // on the run's own spine in the SAME seq order everything else does, rather
1704
1745
  // than a second, disconnected spine writer.
1705
- const emit = makeSpine(spineFile);
1746
+ // P5-R: a RESUME continues the halted run's own file. `seq` is monotonic per SPINE, so the emitter starts
1747
+ // from the highest `seq` already in it; a kill can have left a torn line with no trailing newline, so ONE
1748
+ // `\n` is written first and the torn bytes stay exactly as they are, alone on their own line (the spine is
1749
+ // append-only forever: nothing here edits or truncates an existing byte).
1750
+ let startSeq = 0;
1751
+ if (resuming) {
1752
+ for (const e of deadAllEvents) if (typeof e?.seq === 'number' && Number.isFinite(e.seq) && e.seq > startSeq) startSeq = e.seq;
1753
+ const fd = openSync(spineFile, 'r');
1754
+ try {
1755
+ const size = fstatSync(fd).size;
1756
+ if (size > 0) {
1757
+ const last = Buffer.alloc(1);
1758
+ readSync(fd, last, 0, 1, size - 1);
1759
+ if (last[0] !== 0x0a) appendFileSync(spineFile, '\n');
1760
+ }
1761
+ } finally { closeSync(fd); }
1762
+ }
1763
+ const emit = makeSpine(spineFile, { startSeq });
1764
+ // …and the leg's FIRST record is its marker, written BEFORE the outside watchdog is spawned: the watchdog
1765
+ // judges a run dead by its spine file going quiet, and a file untouched since the last leg would read as stale.
1766
+ if (resuming) {
1767
+ emit(LEG_RESUME, { leg: LEG, after: deadAfter, at: new Date().toISOString() });
1768
+ legBegun = true;
1769
+ }
1706
1770
 
1707
1771
  // PRD item 33/M2, call site 3(a) — a source-door manifest, when this patient
1708
1772
  // was PREPARED through `prepareSource` (`scripts/prep-source.mjs`), sits
@@ -1712,7 +1776,7 @@ async function execute(ctx) {
1712
1776
  // manifest — that absence IS "repo jobs untouched", never an invented
1713
1777
  // destination for a job that declared none. A manifest that EXISTS but
1714
1778
  // cannot be read or parsed is a named $0 stop, never a silent skip.
1715
- const sourceManifest = await readSourceManifest(dirname(wd));
1779
+ const sourceManifest = await readSourceManifest(into);
1716
1780
  if (sourceManifest.stop !== null) {
1717
1781
  emit('destination-refused', { code: sourceManifest.code, detail: sourceManifest.stop });
1718
1782
  emit('run-end', { outcome: 'escalated' });
@@ -1731,7 +1795,7 @@ async function execute(ctx) {
1731
1795
  // against `wd` alone let a destination at `<into>/profile.md` (inside the
1732
1796
  // run's own scratch area, outside the frozen tree, but still bareloop's
1733
1797
  // own scratch plumbing no destination should land in) slip through clean.
1734
- const dp = await proveDestination(frontDoor.destination, { into: dirname(wd) });
1798
+ const dp = await proveDestination(frontDoor.destination, { into });
1735
1799
  if (dp.stop !== null) {
1736
1800
  emit('destination-refused', { code: dp.code, detail: dp.stop });
1737
1801
  emit('run-end', { outcome: 'escalated' });
@@ -1792,6 +1856,8 @@ async function execute(ctx) {
1792
1856
  // time on exactly the run it is watching (F70's shape).
1793
1857
  ...(RESUME_WALL_MS === null ? [] : ['--wall-ms', String(RESUME_WALL_MS)]),
1794
1858
  '--grace-ms', String(worstCloseSilenceMs),
1859
+ // this leg's own note file (leg 1 keeps the name every run has always had)
1860
+ '--report', watchdogNotePath(spineFile, LEG),
1795
1861
  ], { stdio: ['ignore', 'ignore', 'inherit'] });
1796
1862
  // A spawn failure arrives as an EVENT, and an unhandled 'error' on a ChildProcess is
1797
1863
  // an uncaught exception — i.e. a guard that could not start would END the paid run it
@@ -1828,9 +1894,16 @@ async function execute(ctx) {
1828
1894
  lagDue = now + LAG_POLL_MS;
1829
1895
  }, LAG_POLL_MS);
1830
1896
 
1897
+ // P5 item 5 — the STOP request file (`<spine>.stop`, src/legs.js). A request belongs to ONE leg: one left on
1898
+ // disk by an earlier leg (a click that raced that leg's end) is cleared HERE, before this leg's first step, so
1899
+ // a resume is never stopped at once by a request it did not receive. The engine also consumes the file when it
1900
+ // honours it, and the `finally` below clears whatever is left when the leg ends.
1901
+ const stopFile = stopFilePath(spineFile);
1902
+ try { rmSync(stopFile, { force: true }); } catch { /* best-effort — a stop surface must never kill the run */ }
1831
1903
  let outcome;
1832
1904
  try {
1833
1905
  outcome = await runJob(spec, {
1906
+ stopFile,
1834
1907
  // PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
1835
1908
  // used (never a second `makeSpine(spineFile)` here: two independent
1836
1909
  // emitters against one file would both start their seq counter at 0 and
@@ -1919,6 +1992,7 @@ async function execute(ctx) {
1919
1992
  // the guard outlives the run only by accident, never by design
1920
1993
  try { watchdog.kill('SIGKILL'); } catch { /* already gone */ }
1921
1994
  clearInterval(lagTimer);
1995
+ try { rmSync(stopFile, { force: true }); } catch { /* best-effort */ }
1922
1996
  }
1923
1997
  const legMs = Date.now() - started;
1924
1998
 
@@ -1926,7 +2000,11 @@ async function execute(ctx) {
1926
2000
  // honest. No classification into pass/fail buckets — one run classifies nothing.
1927
2001
  const raw = readFileSync(spineFile, 'utf8');
1928
2002
  // 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;
2003
+ const allEvents = parseJsonl(spineFile).records;
2004
+ // P5-R: the readout below is about THIS leg — its own rounds, its own halts, its own terminal. A resumed run's
2005
+ // earlier legs are on the same file, and reading them here would count their rounds, tokens and halts again
2006
+ // (the run's chain-wide figures are the folded ones the lines below already print).
2007
+ const events = legsOf(allEvents).at(-1)?.records ?? allEvents;
1930
2008
  const je = events.findLast((e) => e.type === 'job-end');
1931
2009
  // The claim's WRITE-BACK (hamr 2026-09-30): when the run ends it settles its own claim on the monthly
1932
2010
  // limit — its final spend, attributed to itself. Best-effort: a run that dies before here is settled
@@ -1934,7 +2012,7 @@ async function execute(ctx) {
1934
2012
  if (je) {
1935
2013
  try {
1936
2014
  const fin = legSpend(events);
1937
- appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString() }, { home: cfgHome });
2015
+ appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString(), ...(LEG > 1 ? { leg: LEG } : {}) }, { home: cfgHome });
1938
2016
  } 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
2017
  }
1940
2018
  // the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
@@ -1946,8 +2024,50 @@ async function execute(ctx) {
1946
2024
 
1947
2025
  const auditSrc = join(wd, 'gate-audit.jsonl');
1948
2026
  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)) : [];
2027
+ // THIS leg's own audit rows — the readout's `writes` and BEHAVIOUR lines are about this leg, read from the
2028
+ // leg's own tree file before it is filed
2029
+ /** @type {any[]} */
2030
+ let audit = [];
2031
+ if (existsSync(auditSrc)) {
2032
+ const legAuditText = readFileSync(auditSrc, 'utf8');
2033
+ audit = legAuditText.trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
2034
+ auditFile = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`);
2035
+ // P5-R: a resumed leg ADDS to the run's one audit file (a rename would overwrite the earlier legs' rows)
2036
+ if (LEG > 1 && existsSync(auditFile)) {
2037
+ const prior = readFileSync(auditFile, 'utf8');
2038
+ appendFileSync(auditFile, `${prior.length > 0 && !prior.endsWith('\n') ? '\n' : ''}${legAuditText}`);
2039
+ unlinkSync(auditSrc);
2040
+ } else moveFile(auditSrc, auditFile);
2041
+ }
2042
+ // ── P6 item 1: a repo job on a WORKTREE in the person's own repo. GREEN: the run's work is committed on its work
2043
+ // branch and the worktree folder removed (an already-green is a green: a cold one has nothing to commit, a resumed one may hold uncommitted work) — the branch stays, and the merge stays the person's. Anything else
2044
+ // (stopped, capped, died, red): the folder stays so Resume works, and the Ended block names it. A green that opened a
2045
+ // review door is committed too (the branch always holds the work) but keeps its folder: an accept at the door re-runs
2046
+ // the mechanical stages against that tree.
2047
+ // It runs HERE, right after the gate-audit move, so a later readout step that throws can never skip the commit.
2048
+ const doorHere = events.findLast((e) => e.type === 'review-door') ?? null;
2049
+ /** @type {string | null} */
2050
+ let worktreeLine = null;
2051
+ const wtManifest = sourceManifest.present ? sourceManifest.manifest : null;
2052
+ if (typeof wtManifest?.worktree === 'string' && typeof wtManifest?.repo === 'string') {
2053
+ if (outcome === 'green' || outcome === 'already-green') {
2054
+ const wb = events.findLast((e) => e.type === 'work-branch' && typeof e.branch === 'string') ?? null;
2055
+ try {
2056
+ // the branch ALWAYS holds a green's work (the panel tells the person to merge it); only the FOLDER waits on a door
2057
+ const fin = commitWork(wd, `bareloop: ${spec.job} (run ${runid})`);
2058
+ // an already-green COLD run never made a work branch (the close passed before any work): nothing to name
2059
+ const done = fin.committed ? `final commit ${fin.sha}` : (wb ? 'nothing left to commit' : 'nothing was changed (the close already passed)');
2060
+ const onBranch = wb?.branch ?? 'the work branch';
2061
+ if (doorHere) worktreeLine = `\nWORKTREE ${done}${wb || fin.committed ? ` on ${onBranch}` : ''}; the worktree folder is KEPT for the review door — ${wd}`;
2062
+ else {
2063
+ const gone = removeWorktree(wtManifest.repo, wd);
2064
+ worktreeLine = `\nWORKTREE ${done}${wb || fin.committed ? ` on ${onBranch}` : ''}; ${gone ? 'the worktree folder is removed' : `the worktree folder could not be removed — ${wd}`}`;
2065
+ }
2066
+ } catch (e) {
2067
+ worktreeLine = `\nWORKTREE the final commit failed, so the folder is KEPT: ${wd} (${redactSecrets(String(/** @type {Error} */ (e).message).split('\n')[0])})`;
2068
+ }
2069
+ }
2070
+ }
1951
2071
  const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
1952
2072
 
1953
2073
  out(`\noutcome ${outcome}`);
@@ -2096,7 +2216,7 @@ async function execute(ctx) {
2096
2216
  // as "contained". D3 rework: `destination` is a DIRECTORY now, and a job
2097
2217
  // may produce more than one file — `copyOut` delivers every non-empty file
2098
2218
  // under `output/`, each under its own dated name, and returns the list.
2099
- const co = await copyOut({ tree: wd, into: dirname(wd), destination: frontDoor.destination });
2219
+ const co = await copyOut({ tree: wd, into, destination: frontDoor.destination });
2100
2220
  if (co.stop === null) {
2101
2221
  // `f.path`, never `frontDoor.destination` — each file lands under its
2102
2222
  // OWN dated name (M2b fix 4: `profile-2026-09-12.md`, `-2` the same
@@ -2150,9 +2270,8 @@ async function execute(ctx) {
2150
2270
  // FOLDED across a resume, like every other money and time number this readout
2151
2271
  // prints: the row records what the RUN cost, never what this leg cost
2152
2272
  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
2273
+ // the ROUND count is not part of the declared fold — a resumed leg's `job-start` declares
2274
+ // 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
2275
  // reads as exact is dishonest; report the floor WITH its completeness)
2157
2276
  rounds: legRounds,
2158
2277
  roundsComplete: !dead,
@@ -2183,7 +2302,6 @@ async function execute(ctx) {
2183
2302
  // ENDED and its verdict is minted — this readout offers the three doors over it and
2184
2303
  // changes nothing about the outcome printed above. Read off the run's own record
2185
2304
  // (never off a flag), so a green that opened no door prints exactly what it always did.
2186
- const doorHere = events.findLast((e) => e.type === 'review-door') ?? null;
2187
2305
  if (doorHere) {
2188
2306
  out('');
2189
2307
  for (const l of reviewDoorPackage({
@@ -2283,6 +2401,7 @@ async function execute(ctx) {
2283
2401
  out('MEMORY-CACHE no record (run ended before its summary)');
2284
2402
  }
2285
2403
  }
2404
+ if (worktreeLine !== null) out(worktreeLine);
2286
2405
  out(`\nspine ${spineFile}`);
2287
2406
  out(`patient left AS THE RUN LEFT IT (read it before the next run resets to the seed)`);
2288
2407
 
@@ -2292,7 +2411,7 @@ async function execute(ctx) {
2292
2411
  // owns no spine, so its row is released (folded out of the list, like a monthly refusal) rather than
2293
2412
  // left as a ghost that keeps the month reading "at least". Once the spine exists the run is a real,
2294
2413
  // 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();
2414
+ if (claimedRow && (claimedRow.leg > 1 ? !legBegun : !existsSync(claimedRow.spine))) releaseClaimedRow();
2296
2415
  if (e instanceof ExitSignal) return e.code;
2297
2416
  throw e;
2298
2417
  }
@@ -2312,6 +2431,7 @@ function buildCtx(mode, opts) {
2312
2431
  workdir: /** @type {string} */ (opts.workdir),
2313
2432
  seed: /** @type {string} */ (opts.seed),
2314
2433
  spineName: /** @type {string} */ (opts.spineName),
2434
+ ...(opts.into === undefined ? {} : { into: opts.into }),
2315
2435
  model: opts.model ?? null,
2316
2436
  readShim: opts.readShim ?? null,
2317
2437
  scout: opts.scout ?? null,
@@ -2408,7 +2528,7 @@ export async function main(argv, deps = {}) {
2408
2528
  die(`give one of --job <key> (one of: ${Object.keys(JOBS).join(', ')}) or --spec <path to resolved-spec.json> `
2409
2529
  + '— a run has to name a job one of the two ways.');
2410
2530
  }
2411
- /** @type {{spec: string, workdir: string, spine: string, seed: string}} */
2531
+ /** @type {{spec: string, workdir: string, spine: string, seed: string, into?: string}} */
2412
2532
  let target;
2413
2533
  /** the spec's own path, wherever it was found — a `jobs/` file for `--job`,
2414
2534
  * an arbitrary `resolved-spec.json` for `--spec`. */
@@ -2507,9 +2627,14 @@ export async function main(argv, deps = {}) {
2507
2627
  if (typeof seed !== 'string' || !seed) {
2508
2628
  die(`--spec ${specPath}: ${into}/source.json carries no seed commit — not a prepared copy this runner can trust`);
2509
2629
  }
2510
- const specWorkdir = join(into, 'tree');
2630
+ // P6 item 1: a repo job's tree is a WORKTREE in the person's own repo, recorded in the manifest; every other prepared
2631
+ // copy keeps its tree at `<into>/tree`
2632
+ const worktreeRun = typeof manifestRead.manifest.worktree === 'string' && manifestRead.manifest.worktree !== '';
2633
+ const specWorkdir = worktreeRun ? manifestRead.manifest.worktree : join(into, 'tree');
2511
2634
  if (!existsSync(specWorkdir) || !existsSync(join(specWorkdir, '.git'))) {
2512
- die(`--spec ${specPath}: the prepared copy's tree is gone (${specWorkdir} has no .git) — nothing to run against; re-prepare the source`);
2635
+ die(worktreeRun
2636
+ ? `--spec ${specPath}: the job's worktree is gone (${specWorkdir} has no .git) — nothing to run against; start the job again from the page`
2637
+ : `--spec ${specPath}: the prepared copy's tree is gone (${specWorkdir} has no .git) — nothing to run against; re-prepare the source`);
2513
2638
  }
2514
2639
  target = {
2515
2640
  // display-only in --spec mode (SPEC_DESC below is what actually prints)
@@ -2520,6 +2645,7 @@ export async function main(argv, deps = {}) {
2520
2645
  // is, just computed instead of typed by a developer.
2521
2646
  spine: `${spec.job}-bareloop`,
2522
2647
  seed,
2648
+ into,
2523
2649
  };
2524
2650
  SELECTOR = `--spec ${specPath}`;
2525
2651
  SPEC_DESC = specPath;
@@ -2533,6 +2659,7 @@ export async function main(argv, deps = {}) {
2533
2659
  workdir: target.workdir,
2534
2660
  seed: target.seed,
2535
2661
  spineName: target.spine,
2662
+ ...(target.into === undefined ? {} : { into: target.into }),
2536
2663
  model: argFlag('model'),
2537
2664
  readShim: argFlag('read-shim'),
2538
2665
  scout: argFlag('scout'),