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/replay.js CHANGED
@@ -103,7 +103,8 @@
103
103
 
104
104
  import { runBehaviour, formatBehaviour } from './behaviour.js';
105
105
  import { shortSha } from './codeversion.js';
106
- import { SPEND_RECORD_TYPES } from './ledger.js';
106
+ import { SPEND_RECORD_TYPES, chainSpend } from './ledger.js';
107
+ import { legsOf } from './legs.js';
107
108
 
108
109
  /**
109
110
  * @param {unknown} e
@@ -167,6 +168,24 @@ export function auditWindow(jobStart, jobEnd) {
167
168
  };
168
169
  }
169
170
 
171
+ /**
172
+ * One window PER LEG of a (possibly resumed) run (P5-R) — the ONE owner the panel and {@link replayRun} share. A
173
+ * run with no `leg-resume` marker is one leg and this is `[auditWindow(jobStart, jobEnd)]`, exactly as before. A
174
+ * resumed run's quiet time between two legs is nobody's, so a row another run wrote into a shared sidecar during it
175
+ * is outside every window; a leg that recorded no terminal (it died) ends where the next leg begins.
176
+ * @param {any[]} spineRecords
177
+ * @returns {{startTs: number, endTs: number}[]}
178
+ */
179
+ export function auditWindowsOf(spineRecords) {
180
+ const legs = legsOf(spineRecords);
181
+ if (legs.length <= 1) {
182
+ const jobStart = spineRecords.find((e) => e && e.type === 'job-start') ?? null;
183
+ const jobEnd = legs.at(-1)?.jobEnd ?? null;
184
+ return [auditWindow(jobStart, jobEnd)];
185
+ }
186
+ return legs.map((l, i) => auditWindow(l.jobStart ?? l.start, l.jobEnd ?? (legs[i + 1]?.start ? { ts: legs[i + 1].start.ts } : null)));
187
+ }
188
+
170
189
  /**
171
190
  * Trim a string to `max` chars with an explicit `…[+N chars]` marker —
172
191
  * NEVER a silent truncation, and never a trailing ellipsis alone
@@ -422,8 +441,14 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
422
441
  else skipped += 1;
423
442
  }
424
443
 
444
+ // P5-R: a resumed run is ONE spine of several legs (src/legs.js `legsOf`, the one owner of the boundaries).
445
+ // `jobStart` is the run's FIRST (its start, its drafting fold); `latestStart` the latest leg's (the caps it ran
446
+ // under now); `jobEnd` the run's terminal — the LAST leg's, never an earlier leg's. A spine with no marker is
447
+ // one leg and every figure below reads exactly as it always did.
448
+ const legs = legsOf(spine);
425
449
  const jobStart = spine.find((e) => e.type === 'job-start') ?? null;
426
- const jobEnd = spine.findLast((e) => e.type === 'job-end') ?? null;
450
+ const latestStart = legs.at(-1)?.jobStart ?? jobStart;
451
+ const jobEnd = legs.at(-1)?.jobEnd ?? null;
427
452
 
428
453
  // F195 fix: a gate-audit sidecar CAN be shared across several runs whose
429
454
  // spines happen to reuse the same filename (measured on a real archived
@@ -441,10 +466,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
441
466
  // (`src/panel/server.js`) calls this same {@link auditWindow} rather than
442
467
  // keeping its own copy.
443
468
  {
444
- const { startTs, endTs } = auditWindow(jobStart, jobEnd);
469
+ const windows = auditWindowsOf(spine);
445
470
  audit = audit.filter((a) => {
446
471
  const t = parseTs(a.ts);
447
- return t !== null && t >= startTs && t <= endTs;
472
+ return t !== null && windows.some((w) => t >= w.startTs && t <= w.endTs);
448
473
  });
449
474
  }
450
475
 
@@ -460,8 +485,12 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
460
485
  // and the wall-halt sites (src/planrun.js:2990,3208-3209).
461
486
  const stopReason = isGreen ? null : buildStopReason(lastEscalation, outcome);
462
487
 
463
- const spentUsd = jobEnd && typeof jobEnd.spentUsd === 'number' && Number.isFinite(jobEnd.spentUsd) ? jobEnd.spentUsd : null;
464
- const spendComplete = jobEnd ? jobEnd.spendComplete === true && spentUsd !== null : false;
488
+ // MONEY, one basis (P5-R): a one-leg spine reads its terminal's own total as it always did; a resumed run's
489
+ // legs are summed by each leg's own spend (`chainSpend`), never the last terminal's chain total on top of them
490
+ const chain = legs.length > 1 && jobEnd ? chainSpend(spine) : null;
491
+ const spentUsd = chain ? chain.usd
492
+ : (jobEnd && typeof jobEnd.spentUsd === 'number' && Number.isFinite(jobEnd.spentUsd) ? jobEnd.spentUsd : null);
493
+ const spendComplete = chain ? chain.complete : (jobEnd ? jobEnd.spendComplete === true && spentUsd !== null : false);
465
494
 
466
495
  // draftSpentUsd (hamr's ruling 2026-09-28, "one cap covers drafting +
467
496
  // run") — the AUTHORING pipeline's own spend on this job before it was
@@ -480,9 +509,28 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
480
509
  // exact then, the field simply didn't exist yet to say so.
481
510
  const draftSpendComplete = draftSpentUsd === null ? null
482
511
  : (jobStart.draftSpendComplete !== false);
483
- const floorReasonList = floorReasons(spine, jobStart, spendComplete);
484
-
485
- const wallMs = windowWallMs(parseTs(jobStart?.ts), parseTs(jobEnd?.ts));
512
+ const floorReasonList = floorReasons(spine, legs.map((l) => l.jobStart).find((j) => j?.priorSpendComplete === false) ?? jobStart, spendComplete);
513
+
514
+ // WALL: the sum of each leg's own working window, the gap between legs excluded (hamr 2026-10-02, 2A). A
515
+ // leg that died has no terminal, so its window ends at its last record (a floor); the LATEST leg without a
516
+ // terminal is unknown, as a one-leg run without one always was.
517
+ /** @param {import('./legs.js').Leg} l @param {boolean} isLast */
518
+ const legWallMs = (l, isLast) => {
519
+ const a = parseTs((l.jobStart ?? l.start)?.ts);
520
+ const b = l.jobEnd ? parseTs(l.jobEnd.ts) : (isLast ? null : parseTs(l.end?.ts));
521
+ return windowWallMs(a, b);
522
+ };
523
+ const wallMs = legs.length > 1
524
+ ? (() => {
525
+ let sum = 0;
526
+ for (let i = 0; i < legs.length; i += 1) {
527
+ const w = legWallMs(legs[i], i === legs.length - 1);
528
+ if (w === null) return null;
529
+ sum += w;
530
+ }
531
+ return sum;
532
+ })()
533
+ : windowWallMs(parseTs(jobStart?.ts), parseTs(jobEnd?.ts));
486
534
 
487
535
  // chainClock: the SIGNED wall, read off the run's own clock record —
488
536
  // src/planrun.js:1203 (`wall-clock`) or `emitWallHalt` (src/planrun.js:1269)
@@ -556,7 +604,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
556
604
  // record at all (resumed before any plan reload was needed); keying
557
605
  // "resumed" on resume-seed alone would have printed `resumed: no` on
558
606
  // genuinely resumed runs and mis-explained their spend.
559
- const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
607
+ const resumed = legs.length > 1 || (!!jobStart && 'priorSpentUsd' in jobStart);
560
608
 
561
609
  // resumeSeed: the DETAIL fields (phase/completed/skipping/divergence) —
562
610
  // present only on the narrower resume-seed record; `resumed` above stays
@@ -582,7 +630,8 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
582
630
  const totalRoundsInFile = spendRecords.length;
583
631
  const unpricedRoundsInFile = spendRecords.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
584
632
  const summedRounds = spendRecords.reduce((acc, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? acc + r.costUsd : acc), 0);
585
- const hasEngagementField = jobEnd && typeof jobEnd.engagementSpentUsd === 'number' && Number.isFinite(jobEnd.engagementSpentUsd);
633
+ // (a resumed run's terminal carries only its LATEST leg's engagement — its file's figure is the sum of the rounds)
634
+ const hasEngagementField = legs.length <= 1 && jobEnd && typeof jobEnd.engagementSpentUsd === 'number' && Number.isFinite(jobEnd.engagementSpentUsd);
586
635
  const thisFileSpend = {
587
636
  value: unpricedRoundsInFile > 0 ? null : (hasEngagementField ? jobEnd.engagementSpentUsd : summedRounds),
588
637
  source: /** @type {'engagementSpentUsd'|'summed rounds'} */ (hasEngagementField ? 'engagementSpentUsd' : 'summed rounds'),
@@ -664,18 +713,38 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
664
713
 
665
714
  const usedEndSeqs = new Set();
666
715
  const idOccurrence = new Map();
716
+ /** @type {Map<string, {stopReason: string|null, legIdx: number, tryNumber: number}>} */
717
+ const prevOccurrence = new Map();
667
718
 
668
719
  const steps = stepStarts.map((ss) => {
669
720
  const id = ss.step;
670
721
  const startSeq = typeof ss.seq === 'number' ? ss.seq : -Infinity;
671
722
  const startTs = parseTs(ss.ts);
672
723
 
724
+ // P5-R: a step belongs to ONE leg. Its `step-end` is the first one inside ITS OWN leg (never a later leg's
725
+ // `step-end` for the same id), and a step its leg's halt cut off ends where that leg ends — otherwise its
726
+ // window runs across the idle gap to the next leg and counts the gap as its time.
727
+ const myLegIdx = legs.length > 1 ? legs.reduce((acc, l, i) => (typeof l.start?.seq === 'number' && startSeq >= l.start.seq ? i : acc), 0) : 0;
728
+ const myLeg = legs.length > 1 ? legs[myLegIdx] : null;
729
+ const legEndSeq = legs.length > 1 && myLegIdx < legs.length - 1 && typeof legs[myLegIdx + 1].start?.seq === 'number' ? legs[myLegIdx + 1].start.seq : Infinity;
673
730
  const end = stepEnds
674
- .filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && !usedEndSeqs.has(se.seq))
731
+ .filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && se.seq < legEndSeq && !usedEndSeqs.has(se.seq))
675
732
  .sort((a, b) => a.seq - b.seq)[0] ?? null;
676
733
  if (end) usedEndSeqs.add(end.seq);
677
- const endSeq = end && typeof end.seq === 'number' ? end.seq : Infinity;
678
- const endTs = end ? parseTs(end.ts) : null;
734
+ const cutByLeg = !end && myLeg !== null && Number.isFinite(legEndSeq);
735
+ const endSeq = end && typeof end.seq === 'number' ? end.seq : (cutByLeg ? legEndSeq : Infinity);
736
+ const endTs = end ? parseTs(end.ts) : (cutByLeg ? parseTs(myLeg?.end?.ts) : null);
737
+ // why this leg stopped the step (code-owned words); null for a step that ended itself or is still running
738
+ /** @type {string|null} */
739
+ let stopReason = null;
740
+ if (!end && myLeg !== null) {
741
+ const o = myLeg.outcome;
742
+ const isLastLeg = myLegIdx === legs.length - 1;
743
+ if (cutByLeg || (isLastLeg && o !== null && o !== 'green' && o !== 'already-green')) {
744
+ stopReason = o === 'cap-halt' ? 'money cap' : o === 'wall-halt' ? 'time cap' : o === 'provider-red' ? 'provider failed'
745
+ : o === 'step-stalled' ? 'step stalled' : o === 'stopped' ? 'you stopped it' : o === null ? 'died' : o;
746
+ }
747
+ }
679
748
 
680
749
  const phaseTag = `step:${id}`;
681
750
  // `judge-round` (a close's paid seam) carries no `.phase` at all
@@ -753,10 +822,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
753
822
  }
754
823
 
755
824
  idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
825
+ // the SAME step continuing across a leg boundary (the previous occurrence of this id was cut off by its leg's
826
+ // halt, and this one starts in a later leg) is "continued", never a retry; `tryNumber` counts real retries only
827
+ const prevOcc = prevOccurrence.get(id) ?? null;
828
+ const continued = myLeg !== null && prevOcc !== null && prevOcc.stopReason !== null && prevOcc.legIdx < myLegIdx;
829
+ const tryNumber = prevOcc === null ? 1 : (continued ? prevOcc.tryNumber : prevOcc.tryNumber + 1);
830
+ prevOccurrence.set(id, { stopReason, legIdx: myLegIdx, tryNumber });
756
831
 
757
832
  return {
758
833
  id,
759
834
  occurrence: idOccurrence.get(id),
835
+ ...(myLeg === null ? {} : { stopReason, continued, tryNumber }),
760
836
  outcome: end ? (end.outcome ?? null) : null,
761
837
  ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
762
838
  checks: { passed, failed },
@@ -1241,7 +1317,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1241
1317
  spentUsd: step.spentUsd,
1242
1318
  unpricedRounds: step.unpricedRounds,
1243
1319
  outcome: step.outcome,
1244
- attempts: stepAttempts,
1320
+ // P5-R (resumed runs only): why its leg's halt cut this step off, whether it is the same step continuing
1321
+ // from the leg before, and its retry count not counting continuations
1322
+ ...(step.stopReason === undefined ? {} : { stopReason: step.stopReason, continued: step.continued, tryNumber: step.tryNumber }),
1323
+ attempts: step.stopReason ? stepAttempts.map((a) => (a.outcome === null || a.outcome === undefined ? { ...a, outcome: 'stopped' } : a)) : stepAttempts,
1245
1324
  });
1246
1325
  }
1247
1326
 
@@ -1321,6 +1400,32 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1321
1400
  }
1322
1401
  }
1323
1402
 
1403
+ // P5-R: which LEG each part belongs to, and where a resume picked up — only for a resumed run (a spine with
1404
+ // no marker keeps its parts byte-for-byte). A step part is dated by its own step-start; a replan by its
1405
+ // marker; every other part by the end of its last attempt (its own window's far edge), falling back to the
1406
+ // near edge, then to leg 1. `resumedNext` marks the part a leg ENDED at: the next part is the leg after.
1407
+ if (legs.length > 1) {
1408
+ /** @param {number|null|undefined} seq */
1409
+ const legOfSeq = (seq) => {
1410
+ let n = 1;
1411
+ if (typeof seq !== 'number' || !Number.isFinite(seq)) return n;
1412
+ for (const l of legs) if (typeof l.start?.seq === 'number' && seq >= l.start.seq) n = l.leg;
1413
+ return n;
1414
+ };
1415
+ for (const p of parts) {
1416
+ const atts = Array.isArray(p.attempts) ? p.attempts : [];
1417
+ const finiteEnds = atts.map((a) => a.endSeq).filter((v) => typeof v === 'number' && Number.isFinite(v));
1418
+ const firstStart = atts.length ? atts[0].startSeq : null;
1419
+ if (p.kind === 'step') p.leg = legOfSeq(firstStart);
1420
+ else if (p.kind === 'replan' && typeof p.markerSeq === 'number') p.leg = legOfSeq(p.markerSeq);
1421
+ else if (finiteEnds.length) p.leg = legOfSeq(Math.max(...finiteEnds));
1422
+ else p.leg = legOfSeq(firstStart);
1423
+ }
1424
+ // legs never go backwards along the part list (a part is dated by file order)
1425
+ for (let i = 1; i < parts.length; i += 1) if (parts[i].leg < parts[i - 1].leg) parts[i].leg = parts[i - 1].leg;
1426
+ for (let i = 0; i < parts.length - 1; i += 1) if (parts[i + 1].leg > parts[i].leg) parts[i].resumedNext = true;
1427
+ }
1428
+
1324
1429
  return {
1325
1430
  runId,
1326
1431
  job: jobStart?.job ?? null,
@@ -1359,8 +1464,9 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1359
1464
  // escalation happen at all"). `summarizeForAllLine`'s `reason` column
1360
1465
  // needs the latter question answered unconditionally.
1361
1466
  lastEscalation,
1362
- budgetUsd: jobStart && typeof jobStart.budgetUsd === 'number' && Number.isFinite(jobStart.budgetUsd) ? jobStart.budgetUsd : null,
1363
- specHash: typeof jobStart?.specHash === 'string' ? jobStart.specHash : null,
1467
+ // the caps and the spec the run's LATEST leg ran under (a resume may run under a re-signed hash)
1468
+ budgetUsd: latestStart && typeof latestStart.budgetUsd === 'number' && Number.isFinite(latestStart.budgetUsd) ? latestStart.budgetUsd : null,
1469
+ specHash: typeof latestStart?.specHash === 'string' ? latestStart.specHash : null,
1364
1470
  branch,
1365
1471
  outcome,
1366
1472
  stopReason,
@@ -1375,6 +1481,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1375
1481
  chainClock,
1376
1482
  resumed,
1377
1483
  resumeSeed,
1484
+ // P5-R — the run's legs in order (always at least one for a spine with records): the panel's Audit divider and
1485
+ // map connector read these, never re-derive a boundary. `startSeq`/`endSeq` bound the leg on the one `seq` series.
1486
+ legs: legs.map((l) => ({
1487
+ leg: l.leg,
1488
+ after: l.after,
1489
+ outcome: l.outcome,
1490
+ startTs: typeof l.start?.ts === 'string' ? l.start.ts : null,
1491
+ endTs: typeof l.end?.ts === 'string' ? l.end.ts : null,
1492
+ startSeq: typeof l.start?.seq === 'number' ? l.start.seq : null,
1493
+ endSeq: typeof l.end?.seq === 'number' ? l.end.seq : null,
1494
+ })),
1378
1495
  thisFileSpend,
1379
1496
  // spendMismatch: a NON-resumed run's own `job-end.spentUsd` IS this
1380
1497
  // file's whole spend (no fold to explain a gap) — so on a cold run,
@@ -1684,7 +1801,10 @@ export function formatReplay(summary) {
1684
1801
  ? `this file unknown (${tf.unpricedRounds} of ${tf.totalRounds} rounds unpriced; ${tf.source})`
1685
1802
  : `this file ${money(tf.value)} (${tf.totalRounds} round${tf.totalRounds === 1 ? '' : 's'}, all priced; ${tf.source})`;
1686
1803
  let spentLine;
1687
- if (summary.resumed) {
1804
+ if (summary.legs.length > 1) {
1805
+ // one run, several legs on ONE file: one figure (each leg's own spend, summed), never two bases side by side
1806
+ spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} over ${summary.legs.length} legs of one run · of ${money2(summary.budgetUsd)} signed`;
1807
+ } else if (summary.resumed) {
1688
1808
  spentLine = `spent: ${money(summary.spentUsd)} chain total (job-end; prior legs folded) · ${thisFileText} · of ${money2(summary.budgetUsd)} signed`;
1689
1809
  } else {
1690
1810
  spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} of ${money2(summary.budgetUsd)} signed`;
@@ -1705,7 +1825,7 @@ export function formatReplay(summary) {
1705
1825
  ? ` · floor because: ${summary.floorReasons.join(', ')}`
1706
1826
  : ' · floor (reason not in spine)';
1707
1827
  }
1708
- spentLine += ` · wall ${duration(summary.wallMs)} (this file)`;
1828
+ spentLine += ` · wall ${duration(summary.wallMs)} (${summary.legs.length > 1 ? 'working time, gaps between legs excluded' : 'this file'})`;
1709
1829
  if (summary.chainClock) {
1710
1830
  const { elapsedMs, requestedMs, source } = summary.chainClock;
1711
1831
  const signed = typeof requestedMs === 'number' ? `${duration(requestedMs)} signed` : 'no wall signed';
package/src/replayio.js CHANGED
@@ -87,7 +87,10 @@ export function resolveSiblings(spinePath) {
87
87
  const base = basename(spinePath);
88
88
  const stem = base.replace(/\.jsonl$/, '');
89
89
  const runId = stem.startsWith('u-') ? stem.slice(2) : stem;
90
- const auditPath = join(dir, `${stem}-gate-audit.jsonl`);
90
+ let auditPath = join(dir, `${stem}-gate-audit.jsonl`);
91
+ // a bundle run (`<bundle>/runs/<runid>/spine.jsonl`) keeps its tool log beside the spine as plain `gate-audit.jsonl`
92
+ const bundleAudit = join(dir, 'gate-audit.jsonl');
93
+ if (base === 'spine.jsonl' && !existsSync(auditPath) && existsSync(bundleAudit)) auditPath = bundleAudit;
91
94
  return { runId, auditPath: existsSync(auditPath) ? auditPath : null };
92
95
  }
93
96
 
@@ -96,7 +99,7 @@ export function resolveSiblings(spinePath) {
96
99
  * hand both to {@link replayRun}. Read-only, $0, mints no verdict.
97
100
  * @param {string} spinePath absolute or cwd-relative path to a `.jsonl` spine
98
101
  * @param {{preParsedSpine?: {records: any[], skipped: number}, skipAudit?: boolean,
99
- * auditPathOverride?: string|null}} [opts]
102
+ * auditPathOverride?: string|string[]|null}} [opts]
100
103
  * `preParsedSpine`: avoids a second parse of the same file when the caller
101
104
  * ({@link listSpines}) already read it once to run {@link looksLikeSpine}.
102
105
  * `skipAudit` (PR #23 review item 5, 2026-08-26, carried over): a
@@ -120,13 +123,17 @@ export function replayOne(spinePath, opts = {}) {
120
123
  const { runId, auditPath: resolvedAuditPath } = resolveSiblings(spinePath);
121
124
  const auditPath = auditPathOverride !== undefined ? auditPathOverride : resolvedAuditPath;
122
125
  const spine = preParsedSpine ?? parseJsonl(spinePath);
123
- const audit = (!skipAudit && auditPath) ? parseJsonl(auditPath) : { records: [], skipped: 0 };
126
+ // P5-R: a RESUMED run still being written has TWO homes for its tool log — the finished legs' file and the
127
+ // live leg's file in the patient tree — so a caller may hand every path the run's log is in
128
+ const auditPaths = Array.isArray(auditPath) ? auditPath : (auditPath ? [auditPath] : []);
129
+ const audit = { records: /** @type {any[]} */ ([]), skipped: 0 };
130
+ if (!skipAudit) for (const ap of auditPaths) { const part = parseJsonl(ap); audit.records.push(...part.records); audit.skipped += part.skipped; }
124
131
  // `auditAvailable`: true only when a sidecar was actually FOUND AND READ.
125
132
  // `skipAudit` (a deliberate cheap directory listing) and "no sidecar on
126
133
  // disk" both mean this call never learned the real tool-call count, so
127
134
  // both report unknown, never a coincidental 0 (doctrine: unknown reported
128
135
  // as unknown, never rendered as zero — see `replayRun`'s own doc).
129
- const auditAvailable = !skipAudit && auditPath !== null;
136
+ const auditAvailable = !skipAudit && auditPaths.length > 0;
130
137
  const summary = replayRun(spine.records, audit.records, { runId, auditAvailable });
131
138
  summary.skipped += spine.skipped + audit.skipped;
132
139
  return summary;
package/src/reuse.js CHANGED
@@ -52,6 +52,7 @@ import { closeStagesOf } from './plan.js';
52
52
  import { MIN_WALL_MS, jobSpecHash } from './job.js';
53
53
  import { isObj, isNonEmptyString } from './validate.js';
54
54
  import { readGrade } from './trend.js';
55
+ import { legsOf } from './legs.js';
55
56
 
56
57
  /** the paused-run TERMINAL and its spine record share one name (`runPlan` returns
57
58
  * it and `runJob` writes it onto `job-end`), spelled once in `declaredclose.js`
@@ -117,6 +118,11 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
117
118
  * allowances the loop's remaining tries are meant to spend against, and the human
118
119
  * ones are the stop no further try can change (see `hardStop`).
119
120
  *
121
+ * `stopped` (PANEL-BUILD.md P5 item 5, hamr 2026-10-02): the PERSON ended the leg at a round
122
+ * boundary (a `<spine>.stop` request). Nothing was judged and nothing failed — finished steps stand on
123
+ * disk, the allowance is unspent, so it is a checkpoint exactly like a cap-halt. Arbiter-adjacent (it
124
+ * widens what resume may re-enter); ruled by hamr, not chosen by a builder.
125
+ *
120
126
  * `provider-red` folded in 2026-08-25 (PRD v1.80 TODO #4, F115 "Ruled: one transport
121
127
  * retry"; hamr's ruling verbatim: *"joins resume anyways and you decide but you are
122
128
  * given an honest readout and with 1 retry"*). It is a MACHINE halt, not a human one
@@ -139,7 +145,7 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
139
145
  * does not end up with two lists that can drift, which is the exact failure this
140
146
  * constant exists to end (see the paragraph above).
141
147
  */
142
- export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', ...HUMAN_CHECKPOINTS]);
148
+ export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', 'stopped', ...HUMAN_CHECKPOINTS]);
143
149
 
144
150
  /**
145
151
  * v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
@@ -815,7 +821,16 @@ function readGradeSeed(seen) {
815
821
  * spentUsd, spendComplete, carrySpentUsd, carrySpendComplete }`
816
822
  */
817
823
  export function readResume(events, { deathAt = null, direct = false, resumableOutcomes = [] } = {}) {
818
- const list = Array.isArray(events) ? events.filter(isObj) : [];
824
+ let list = Array.isArray(events) ? events.filter(isObj) : [];
825
+ // P5-R: a DIRECT spine may be a resumed run's one file of several legs (src/legs.js). The checkpoint a resume
826
+ // continues from is the latest leg that reached a `job-start`, and that leg's own `job-start` DECLARES the fold
827
+ // it inherited (`priorSpentUsd`/`priorWallMs`/…) — so reading that one window and adding its rounds is the whole
828
+ // chain exactly once, never the earlier legs' rounds on top of a fold that already carries them. A spine with
829
+ // no marker is one leg and reads as it always did.
830
+ if (direct) {
831
+ const legs = legsOf(list);
832
+ if (legs.length > 1) list = (legs.findLast((l) => l.jobStart !== null) ?? legs[legs.length - 1]).records;
833
+ }
819
834
  /** which landed terminals are a CHECKPOINT rather than a graded row */
820
835
  const resumableHalt = new Set(Array.isArray(resumableOutcomes) ? resumableOutcomes : []);
821
836
  // the FIRST reuse-start is the envelope this spine was opened under. A resumed spine
@@ -1228,7 +1243,9 @@ export const PAUSE_TTL_MS = 60 * 24 * 60 * 60_000;
1228
1243
  * @returns {{ok: boolean, applies: boolean, ageMs: number|null, ttlMs: number, pausedAt: string|null, detail: string|null}}
1229
1244
  */
1230
1245
  export function checkpointAgeGate(events, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
1231
- const list = Array.isArray(events) ? events.filter(isObj) : [];
1246
+ const all = Array.isArray(events) ? events.filter(isObj) : [];
1247
+ // P5-R: only the run's LATEST leg can be the one waiting — an earlier leg's pause was answered by the leg after it
1248
+ const list = legsOf(all).at(-1)?.records ?? all;
1232
1249
  const end = list.filter((e) => e.type === 'job-end').at(-1) ?? null;
1233
1250
  const base = { applies: false, ageMs: null, ttlMs, pausedAt: null, detail: null };
1234
1251
  if (end?.outcome !== HITL_PAUSE) return { ...base, ok: true };
package/src/run.js CHANGED
@@ -256,10 +256,13 @@ async function primitiveSmoke(workdir) {
256
256
  * passes `false` so the escalation tail's option strings say "resume is
257
257
  * `run-u`-only in v1" instead of naming a flag that would fail if typed. Forwarded
258
258
  * verbatim to the plan flow, which owns the three `--resume`-naming readouts.
259
+ * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
260
+ * (`stopFilePath(spine)`, src/legs.js), forwarded verbatim to the plan flow, which reads it at the
261
+ * round-boundary seam (where the money cap binds) and ends the leg `stopped`. `null` = no stop surface.
259
262
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
260
263
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
261
264
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
262
- * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
265
+ * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'stopped' | 'step-stalled' |
263
266
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
264
267
  * NOTE: `job-end` can ALSO land with outcome 'runner-drained' (F140/PRD item
265
268
  * 28(c)) — the `beforeExit` backstop's own terminal, emitted from OUTSIDE this
@@ -270,7 +273,7 @@ async function primitiveSmoke(workdir) {
270
273
  * deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
271
274
  * unknown in-flight state is not a known-safe resume point.
272
275
  */
273
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
276
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
274
277
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
275
278
  // before the ledger, before the approval gate, before a byte of the spec is read.
276
279
  // An unrecognised spelling throws here at zero cost instead of being coerced by
@@ -537,7 +540,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
537
540
  // accounts it natively (F12) and the job-end money contract is unchanged.
538
541
  {
539
542
  const outcome = await runPlan(job, {
540
- workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
543
+ workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable, stopFile,
541
544
  // hamr's ruling 2026-09-28 — ONE cap covers drafting + run: the run's
542
545
  // enforced ceiling is the signed `budgetUsd` (unchanged, still in the
543
546
  // spec hash) MINUS whatever drafting already spent on this job before
package/src/runlist.js CHANGED
@@ -58,9 +58,12 @@ export function runlistPath(home) {
58
58
  }
59
59
 
60
60
  /**
61
- * @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number }} RunRow
61
+ * @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number, leg?: number }} RunRow
62
62
  * `pid`/`capUsd` (optional; older and backfilled rows carry neither): the runner's process id and the
63
63
  * leg's $ cap — what the monthly limit holds while that pid is a live bareloop runner (src/monthly.js).
64
+ * `leg` (P5-R; only on a row {@link readRunList} FOLDED from `leg-start` entries, i.e. a RESUMED run):
65
+ * the number of the run's latest leg — a resumed run is the same run, one row, and its `pid`/`capUsd`
66
+ * are that latest leg's.
64
67
  */
65
68
 
66
69
  /**
@@ -101,7 +104,10 @@ export function appendRun(row, opts = {}) {
101
104
  }
102
105
 
103
106
  /**
104
- * @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean }} RunEvent
107
+ * @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean, leg?: number }} RunEvent
108
+ * `leg` (P5-R): which leg of the run the write-back is about; absent = leg 1 (every entry written before P5-R).
109
+ * A resumed run is ONE row, so `settled`/`released` are per leg: a later leg's `released` gives back THAT leg's
110
+ * claim and never removes the run.
105
111
  * A claim's write-back, appended (never rewritten) beside the run rows. `settled` = the run's claim on the
106
112
  * monthly limit is over (`spentUsd`/`spendComplete` are its final figure, `by` is WHO wrote it: the run itself
107
113
  * at its `job-end`, or the next run that found its process gone, `reason: 'process gone'`). `released` = the
@@ -122,6 +128,43 @@ export function appendRunEvent(event, opts = {}) {
122
128
  try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
123
129
  }
124
130
 
131
+ /**
132
+ * @typedef {{ type: 'leg-start', runid: string, leg: number, pid?: number, capUsd?: number, at: string }} LegStart
133
+ * A resumed run's NEW claim (P5-R, hamr 2026-10-02: one run, one id, one row): appended beside the run's row
134
+ * instead of a second row. {@link readRunList} folds it into the row.
135
+ */
136
+
137
+ /**
138
+ * Append one {@link LegStart}: a resumed run's leg claim. Not locked: one short line, one append. THROWS on IO failure.
139
+ * @param {LegStart} entry
140
+ * @param {{ home?: string }} [opts]
141
+ * @returns {void}
142
+ */
143
+ export function appendLegStart(entry, opts = {}) {
144
+ const path = runlistPath(opts.home);
145
+ mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
146
+ appendFileSync(path, `${JSON.stringify({ ...entry, type: 'leg-start' })}\n`);
147
+ try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
148
+ }
149
+
150
+ /**
151
+ * The leg a write-back names (absent = 1).
152
+ * @param {{ leg?: number }} e
153
+ * @returns {number}
154
+ */
155
+ const legOf = (e) => (Number.isInteger(e.leg) && /** @type {number} */ (e.leg) >= 1 ? /** @type {number} */ (e.leg) : 1);
156
+
157
+ /**
158
+ * Has this row's LATEST leg been settled? The ONE spelling (the monthly limit and the settle writer share it).
159
+ * @param {RunEvent[]} events {@link readRunList}'s `events`
160
+ * @param {{ runid: string, leg?: number }} row
161
+ * @returns {boolean}
162
+ */
163
+ export function isSettled(events, row) {
164
+ const leg = legOf(row);
165
+ return events.some((e) => e.type === 'settled' && e.runid === row.runid && legOf(e) === leg);
166
+ }
167
+
125
168
  /**
126
169
  * Tolerant reader — reuses {@link parseJsonl} (never a second hand-rolled
127
170
  * parser). An absent file reads as an empty list, not an error: a fresh
@@ -136,20 +179,45 @@ export function readRunList(opts = {}) {
136
179
  const path = runlistPath(opts.home);
137
180
  if (!existsSync(path)) return { rows: [], skipped: 0, events: [] };
138
181
  const { records, skipped } = parseJsonl(path);
139
- // the FIRST settle for a runid is authoritative: two runs may both close the same dead claim, and the
182
+ // the FIRST settle for a (runid, leg) is authoritative: two runs may both close the same dead claim, and the
140
183
  // second note is a duplicate (a `released` line likewise); file order decides, later copies are ignored
141
184
  const seen = new Set();
142
185
  /** @type {RunEvent[]} */
143
186
  const events = records.filter((r) => {
144
187
  if (!r || (r.type !== 'settled' && r.type !== 'released')) return false;
145
- const key = `${r.type}:${r.runid}`;
188
+ const key = `${r.type}:${r.runid}:${legOf(r)}`;
146
189
  if (seen.has(key)) return false;
147
190
  seen.add(key);
148
191
  return true;
149
192
  });
150
- const released = new Set(events.filter((e) => e.type === 'released').map((e) => e.runid));
151
- const rows = records.filter((r) => r && !r.type && !released.has(r.runid));
152
- return { rows, skipped, events };
193
+ // a leg-1 `released` = the run never started: not listed. A LATER leg's `released` only gives that leg's claim back.
194
+ const released = new Set(events.filter((e) => e.type === 'released' && legOf(e) === 1).map((e) => e.runid));
195
+ const voided = new Set(events.filter((e) => e.type === 'released' && legOf(e) > 1).map((e) => `${e.runid}:${legOf(e)}`));
196
+ /** @type {Map<string, RunRow & {claimAt: number}>} */
197
+ const byRunid = new Map();
198
+ /** @type {(RunRow & {claimAt: number})[]} */
199
+ const rows = [];
200
+ records.forEach((r, i) => {
201
+ if (!r) return;
202
+ if (!r.type) {
203
+ if (released.has(r.runid)) return;
204
+ const row = { ...r, claimAt: i };
205
+ rows.push(row);
206
+ if (!byRunid.has(r.runid)) byRunid.set(r.runid, row);
207
+ } else if (r.type === 'leg-start') {
208
+ const row = byRunid.get(r.runid);
209
+ if (!row || !Number.isInteger(r.leg) || r.leg < 2 || voided.has(`${r.runid}:${r.leg}`) || r.leg <= legOf(row)) return;
210
+ // the run's pid/cap are its LATEST leg's; the claim's place in the file (the monthly limit's "who is above
211
+ // whom") is that leg-start line's, since that is where the leg claimed
212
+ row.leg = r.leg;
213
+ row.pid = r.pid;
214
+ row.capUsd = r.capUsd;
215
+ row.claimAt = i;
216
+ }
217
+ });
218
+ // file order = claim order: a resumed run's latest claim sits where its latest leg-start line is
219
+ const ordered = rows.some((r) => r.leg !== undefined) ? [...rows].sort((a, b) => a.claimAt - b.claimAt) : rows;
220
+ return { rows: ordered.map(({ claimAt: _c, ...row }) => row), skipped, events };
153
221
  }
154
222
 
155
223
  /**
package/src/source.js CHANGED
@@ -27,6 +27,7 @@ import { existsSync, lstatSync, readFileSync } from 'node:fs';
27
27
  import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
28
  import { dirname, join, relative, resolve, sep, basename } from 'node:path';
29
29
  import { git } from './kinds.js';
30
+ import { addWorktree, hideBareloopDir } from './worktree.js';
30
31
  import { MAX_BUFFER } from './kinds.js';
31
32
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
32
33
  import { scanSecrets, SECRET_PATTERNS, SECRET_PATTERN_NAMES } from './validate.js';
@@ -440,16 +441,21 @@ async function fetchOnce(url, timeoutMs) {
440
441
  * as declared, unvalidated by `proveDestination`, and `frontDoorFromManifest`
441
442
  * never hands a repo source's destination to `copyOut` — wiring it into
442
443
  * `writeScope` is M3/M4's job, not this one.
443
- * @param {{source: string, into: string, destination?: string,
444
+ * `worktree` (P6 item 1, repo sources ONLY — ignored for every other kind): instead of COPYING the repo, make the
445
+ * run's tree a detached git worktree of the person's own repo at that directory (the shared
446
+ * `src/worktree.js`, the bundle door's own spelling), hide `.bareloop/` from their `git status`, and record the
447
+ * worktree and the repo root in the manifest. `into` then holds only `source.json`; the tree is `worktree`, at the
448
+ * repo's current commit (uncommitted edits are NOT in it). No seed commit is made — the seed IS the repo's HEAD.
449
+ * @param {{source: string, into: string, destination?: string, worktree?: string,
444
450
  * fetchTimeoutMs?: number, afterScan?: () => (void|Promise<void>)}} args
445
451
  * `fetchTimeoutMs` is test-only — production callers omit it and get
446
452
  * `PROVIDER_TIMEOUT_MS`. `afterScan` is a test seam too: called exactly once,
447
453
  * after the secret scan finished and before the freeze loop re-reads any
448
454
  * file's bytes, so a test can swap a file inside that window
449
455
  * deterministically. Production never sets it; absent, behaviour is unchanged.
450
- * @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: object}|SourceRefusal>}
456
+ * @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: Record<string, any>}|SourceRefusal>}
451
457
  */
452
- export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
458
+ export async function prepareSource({ source, into, destination, worktree, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
453
459
  const intoAbs = resolve(into);
454
460
  if (existsSync(intoAbs)) {
455
461
  return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
@@ -634,6 +640,9 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
634
640
  const buf = f.buf ?? (f.symlinkTarget !== undefined ? Buffer.from(f.symlinkTarget, 'utf8') : await readFile(/** @type {string} */ (f.abs)));
635
641
  fileMeta.push({ path: f.rel, bytes: buf.length, sha256: sha256Hex(buf) });
636
642
  const isBinary = f.symlinkTarget === undefined && hasNulByte(buf.subarray(0, 8192));
643
+ // P6 item 2 (hamr 2026-10-05 Q2=A): a repo going onto a WORKTREE is the person's OWN repo, left in place — a secret
644
+ // already in it is MASKED wherever bareloop records it (`redactSecrets`), not a refusal. Every other kind keeps the scan.
645
+ if (worktree !== undefined && frozen.kind === 'repo') continue;
637
646
  const names = secretPatternNames(buf.toString(isBinary ? 'latin1' : 'utf8'));
638
647
  if (names.length) secretHits.push({ rel: f.rel, names });
639
648
  }
@@ -642,6 +651,34 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
642
651
  return refuse('source-carries-secret', `${secretHits.length} file(s) carry a known secret shape — refused before anything was written (hard line #3, secrets never enter the tree): ${detail}`);
643
652
  }
644
653
 
654
+ if (worktree !== undefined && frozen.kind === 'repo') {
655
+ const repoRoot = /** @type {string} */ (frozen.root);
656
+ const wt = resolve(worktree);
657
+ try {
658
+ hideBareloopDir(repoRoot);
659
+ addWorktree(repoRoot, wt);
660
+ } catch (e) {
661
+ return refuse('source-worktree-failed', `could not make the job's worktree in ${repoRoot}: ${/** @type {Error} */ (e).message} (the repo needs at least one commit)`);
662
+ }
663
+ const headAt = await git(wt, ['rev-parse', 'HEAD']);
664
+ if (!headAt.ok) return refuse('source-git-failed', `git rev-parse HEAD failed in ${wt}: ${headAt.err}`);
665
+ const wtManifest = {
666
+ kind: 'repo',
667
+ source,
668
+ ...(frozen.sourceSubdir === undefined ? {} : { sourceSubdir: frozen.sourceSubdir }),
669
+ fetchedAt: new Date().toISOString(),
670
+ files: fileMeta,
671
+ seed: headAt.out.trim(),
672
+ destination: destination ?? null,
673
+ worktree: wt,
674
+ repo: repoRoot,
675
+ };
676
+ await mkdir(intoAbs, { recursive: true });
677
+ const wtManifestPath = join(intoAbs, 'source.json');
678
+ await writeFile(wtManifestPath, `${JSON.stringify(wtManifest, null, 2)}\n`);
679
+ return { stop: null, into: intoAbs, tree: wt, manifestPath: wtManifestPath, manifest: wtManifest };
680
+ }
681
+
645
682
  const treeDir = join(intoAbs, 'tree');
646
683
  const outputDir = join(treeDir, 'output');
647
684
  await mkdir(treeDir, { recursive: true });
@@ -809,7 +846,7 @@ const LOCKFILE_COMMANDS = Object.freeze([
809
846
  * manifest)
810
847
  * @param {string} [sourceSubdir] the manifest's own `sourceSubdir` ('' or
811
848
  * undefined when Source IS the repo root)
812
- * @returns {{manager: string, command: string, reason: string}|null} `null`
849
+ * @returns {{dir: string, lockFile: string|null, manager: string, command: string, reason: string}|null} `null`
813
850
  * when there is nothing to report: no JS/TS manifest found walking up from
814
851
  * `sourceSubdir` to `treeDir`, the nearest one found has no dependencies,
815
852
  * `node_modules` is already there, or the manifest cannot even be read as
@@ -846,6 +883,8 @@ export function missingDependencies(treeDir, sourceSubdir = '') {
846
883
  const installCmd = lock ? lock.command : 'npm install';
847
884
  const rel = relative(root, d).split(sep).join('/');
848
885
  return {
886
+ dir: d,
887
+ lockFile: lock ? lock.file : null,
849
888
  manager: installCmd.split(' ')[0],
850
889
  command: rel === '' ? installCmd : `cd ${rel} && ${installCmd}`,
851
890
  reason: `${rel === '' ? 'package.json' : `${rel}/package.json`} lists dependencies but the copy has no node_modules`,