bareloop 0.35.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.
@@ -31,6 +31,7 @@ import {
31
31
  } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
+ import { draftingPartFor } from '../draftspend.js';
34
35
  import {
35
36
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
36
37
  } from '../replayio.js';
@@ -321,6 +322,9 @@ export function resumePlanFor(row, records) {
321
322
  if (!age.ok) return { ok: false, why: String(age.detail ?? 'its checkpoint has expired') };
322
323
  const near = sourceNearSpine(row.spine);
323
324
  if (!near.specPath) return { ok: false, why: 'the signed job file is not beside this run' };
325
+ // P6 item 1: a worktree run resumes IN its worktree — a folder that is gone is a resume the engine would refuse
326
+ const wt = worktreeOfRun(row, records);
327
+ if (wt && !wt.exists) return { ok: false, why: `its worktree folder ${wt.folder} is gone` };
324
328
  const dir = dirname(near.specPath);
325
329
  /** @type {string[]} */
326
330
  let names = [];
@@ -407,10 +411,29 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
407
411
  * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
408
412
  * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
409
413
  * @param {{died: boolean, lastThing: string|null}} death
410
- * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean}} [o]
414
+ * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
415
+ * worktree?: {folder: string, repo: string, branch: string|null, exists: boolean}|null}} [o]
416
+ * `worktree` (P6 item 1): the run's worktree in the person's repo, from {@link worktreeOfRun}; absent for every other run
411
417
  * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
412
418
  */
413
419
  export function endedFor(summary, death, o = {}) {
420
+ const base = endedBase(summary, death, o);
421
+ const wt = o.worktree;
422
+ if (!base || !wt) return base;
423
+ // P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
424
+ // removed by the engine) and the merge stays the person's — the two commands are shown as TEXT, never run by a button.
425
+ // Any other ending: the worktree folder stays (Resume needs it), so the block names it.
426
+ const green = !death.died && (summary.outcome === 'green' || summary.outcome === 'already-green' || summary.outcome === 'satisfied');
427
+ if (green) {
428
+ if (!wt.branch) return base;
429
+ const kept = wt.exists ? ` The worktree folder ${wt.folder} is still there.` : '';
430
+ return { ...base, next: `In ${wt.repo}: git merge ${wt.branch} — or throw the work away: git branch -D ${wt.branch}.${kept}` };
431
+ }
432
+ return wt.exists ? { ...base, next: `${base.next} Your work is in ${wt.folder}.` } : base;
433
+ }
434
+
435
+ /** @param {any} summary @param {any} death @param {any} o */
436
+ function endedBase(summary, death, o) {
414
437
  const resumeOk = !!(o.resume && o.resume.ok);
415
438
  /** @type {{id: string, label: string}[]} */
416
439
  const RESUME = [{ id: 'resume', label: 'Resume' }];
@@ -572,7 +595,7 @@ function lastJobEndTs(/** @type {any[]} */ records) {
572
595
  return null;
573
596
  }
574
597
 
575
- function endedForRow(row, records, summary, death) {
598
+ function endedForRow(row, records, summary, death, withWorktree = false) {
576
599
  const out = summary.outcome;
577
600
  const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
578
601
  const resume = maybeResumable ? resumePlanFor(row, records) : null;
@@ -581,6 +604,7 @@ function endedForRow(row, records, summary, death) {
581
604
  resume,
582
605
  moneyHalt: records.some((r) => r && r.type === 'money-halt'),
583
606
  destinationRefused: refused ? String(refused.detail ?? refused.code ?? 'the destination refused it') : null,
607
+ worktree: withWorktree ? worktreeOfRun(row, records) : null,
584
608
  });
585
609
  const esc = summary.lastEscalation;
586
610
  const status = statusFor({
@@ -731,6 +755,40 @@ export function getStopContext(runid, opts = {}) {
731
755
  return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
732
756
  }
733
757
 
758
+ /**
759
+ * @param {any} r
760
+ * @returns {number}
761
+ */
762
+ function atMs(r) {
763
+ const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
764
+ return Number.isFinite(t) ? t : 0;
765
+ }
766
+
767
+ /**
768
+ * The ONE owner of run names (P6 item 7): runs of the same job (`row.job`) are numbered oldest `at` first, 1-based
769
+ * (ties keep file order). A resume is one row, so it counts once; died and stopped runs are rows too. Every view
770
+ * reads this number — the page never counts on its own.
771
+ * @param {any[]} rows
772
+ * @returns {Map<string, number>} runid → N
773
+ */
774
+ function runNumbers(rows) {
775
+ /** @type {Map<string, any[]>} */
776
+ const byJob = new Map();
777
+ rows.forEach((row, index) => {
778
+ if (!row || typeof row.runid !== 'string') return;
779
+ const g = byJob.get(row.job) || [];
780
+ g.push({ row, index });
781
+ byJob.set(row.job, g);
782
+ });
783
+ /** @type {Map<string, number>} */
784
+ const out = new Map();
785
+ for (const g of byJob.values()) {
786
+ g.sort((a, b) => (atMs(a.row) - atMs(b.row)) || (a.index - b.index));
787
+ g.forEach(({ row }, i) => out.set(row.runid, i + 1));
788
+ }
789
+ return out;
790
+ }
791
+
734
792
  /**
735
793
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
736
794
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -749,14 +807,29 @@ export function getStopContext(runid, opts = {}) {
749
807
  */
750
808
  export function listRuns(opts = {}) {
751
809
  const { rows } = readRunList(opts);
752
- const atMs = (r) => {
753
- const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
754
- return Number.isFinite(t) ? t : 0;
755
- };
810
+ const nos = runNumbers(rows);
756
811
  return rows
757
812
  .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
758
813
  .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
759
- .map(({ row }) => summarizeRow(row));
814
+ .map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
815
+ }
816
+
817
+ /**
818
+ * The run's DRAFTING PART (hamr 2026-10-06, option A), or null: it exists only when the run came out of a panel
819
+ * session whose drafting log carries a metered call (src/draftspend.js, `draftingPartFor`) and the spine carries the
820
+ * drafting figure. It is the FIRST element of `detail.parts`, so EVERY index a reader is handed — `detail.parts`,
821
+ * `legDividers.beforePart`, the flat Audit rows' `partIndex`, the rounds endpoint's `part` — lives in ONE index space
822
+ * (the shown list), shifted by {@link draftingShift} in exactly those four places and nowhere else.
823
+ * @param {any} row @param {any} summary a `replayOne` summary of the row's spine @param {{ home?: string }} opts
824
+ * @returns {any|null}
825
+ */
826
+ function draftingFor(row, summary, opts) {
827
+ return draftingPartFor(row, { draftSpentUsd: summary.draftSpentUsd, draftSpendComplete: summary.draftSpendComplete }, opts);
828
+ }
829
+
830
+ /** @param {any} row @param {any} summary @param {{ home?: string }} opts @returns {0|1} how far the drafting part pushes every other part's index */
831
+ function draftingShift(row, summary, opts) {
832
+ return draftingFor(row, summary, opts) ? 1 : 0;
760
833
  }
761
834
 
762
835
  /**
@@ -789,6 +862,18 @@ function findRunRow(runid, opts) {
789
862
  * @returns {any|null}
790
863
  */
791
864
  export function getRunDetail(runid, opts = {}) {
865
+ const d = getRunDetailBody(runid, opts);
866
+ if (!d) return d;
867
+ const runNo = runNumbers(readRunList(opts).rows).get(runid);
868
+ return runNo === undefined ? d : { ...d, runNo };
869
+ }
870
+
871
+ /**
872
+ * @param {string} runid
873
+ * @param {{ home?: string }} opts
874
+ * @returns {any|null}
875
+ */
876
+ function getRunDetailBody(runid, opts) {
792
877
  const row = findRunRow(runid, opts);
793
878
  if (!row) return null;
794
879
  if (!existsSync(row.spine)) {
@@ -810,8 +895,12 @@ export function getRunDetail(runid, opts = {}) {
810
895
  const auditPaths = resolveAuditPathsForRow(row, rawSpineRecords);
811
896
  const summary = replayOne(row.spine, { auditPathOverride: auditPaths.length > 0 ? auditPaths : null });
812
897
  const timelineKind = summary.timelineKind;
898
+ // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
899
+ const draftingPartHere = draftingFor(row, summary, opts);
900
+ const draftingShiftHere = draftingPartHere ? 1 : 0;
901
+ const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
813
902
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
814
- const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death);
903
+ const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
815
904
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
816
905
  // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
817
906
  // close's own paid judge seam, distinct from the worker `model` above.
@@ -980,7 +1069,8 @@ export function getRunDetail(runid, opts = {}) {
980
1069
  // P5-R: the run's legs in order (`after` = how the leg before ended) and the resume count — the Audit tab's
981
1070
  // dividers and the map's connectors read these; never a second derivation of where a leg starts
982
1071
  legs: summary.legs,
983
- legDividers: legDividersFor(rawSpineRecords, summary.parts),
1072
+ // drafting belongs to leg 1: a divider's `beforePart` moves with the parts list it indexes
1073
+ legDividers: legDividersFor(rawSpineRecords, summary.parts).map((d) => ({ ...d, beforePart: d.beforePart + draftingShiftHere })),
984
1074
  resumedCount: Math.max(0, summary.legs.length - 1),
985
1075
  resume: resume && resume.ok ? {
986
1076
  budgetUsd: resume.budgetUsd, maxWallMin: resume.maxWallMin, spentUsd: resume.spentUsd, spendComplete: resume.spendComplete,
@@ -1027,7 +1117,7 @@ export function getRunDetail(runid, opts = {}) {
1027
1117
  // {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
1028
1118
  // passes through unchanged) whenever no spec resolves at all, so the two
1029
1119
  // tabs still never disagree about order/counts/blocked-call figures.
1030
- parts: enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row))),
1120
+ parts: draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts,
1031
1121
  replans: summary.replans,
1032
1122
  close: summary.close,
1033
1123
  branch: summary.branch,
@@ -1379,6 +1469,7 @@ export function getRunAudit(runid, opts = {}) {
1379
1469
  skipAudit: true,
1380
1470
  });
1381
1471
  const partOf = makePartLookup(summary);
1472
+ const shift = draftingShift(row, summary, opts); // the drafting part heads the shown list: every index moves with it
1382
1473
 
1383
1474
  const { records } = { records: auditPaths.flatMap((ap) => parseJsonl(ap).records) };
1384
1475
  const windowed = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
@@ -1419,7 +1510,7 @@ export function getRunAudit(runid, opts = {}) {
1419
1510
  path,
1420
1511
  pathShort: shortenPath(path, treeRoot),
1421
1512
  decision: typeof r.decision === 'string' ? r.decision : null,
1422
- partIndex,
1513
+ partIndex: partIndex === null ? null : partIndex + shift,
1423
1514
  partLabel,
1424
1515
  attemptN,
1425
1516
  reason,
@@ -1488,7 +1579,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1488
1579
  ? Math.min(query.limit, ROUNDS_PAGE_MAX) : ROUNDS_PAGE_DEFAULT;
1489
1580
 
1490
1581
  const summary = replayOne(row.spine);
1491
- const part = Array.isArray(summary.parts) ? summary.parts[partIndex] : null;
1582
+ // `part` is an index into the SHOWN list (`detail.parts`): when the drafting part heads it, index 0 is the drafting
1583
+ // part (it has no rounds table) and the spine's own part k is shown at k + 1
1584
+ const shift = draftingShift(row, summary, opts);
1585
+ if (shift && partIndex === 0) return null;
1586
+ const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
1492
1587
  if (!part) return null;
1493
1588
  const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
1494
1589
  if (!a) return null;
@@ -1616,6 +1711,25 @@ function sourceNearSpine(spinePath) {
1616
1711
  };
1617
1712
  }
1618
1713
 
1714
+ /**
1715
+ * P6 item 1 — the worktree a run worked on in the person's own repo, read off the run's own `source.json` (the manifest
1716
+ * the source door wrote: `worktree` + `repo`) and its spine's `work-branch` record. `null` for every run that has none
1717
+ * (a copied-tree run, a bundle run, an import). `exists` is whether the folder is still on disk: a green run's folder is
1718
+ * removed by the engine, any other ending leaves it.
1719
+ * @param {{ spine: string }} row
1720
+ * @param {any[]} records the run's raw spine records
1721
+ * @returns {{folder: string, repo: string, branch: string|null, exists: boolean}|null}
1722
+ */
1723
+ function worktreeOfRun(row, records) {
1724
+ const near = sourceNearSpine(row.spine);
1725
+ if (!near.sourceJsonPath) return null;
1726
+ let m;
1727
+ try { m = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { return null; }
1728
+ if (typeof m?.worktree !== 'string' || m.worktree === '' || typeof m?.repo !== 'string') return null;
1729
+ const wb = records.findLast((r) => r && r.type === 'work-branch' && typeof r.branch === 'string') ?? null;
1730
+ return { folder: m.worktree, repo: m.repo, branch: wb ? wb.branch : null, exists: existsSync(m.worktree) };
1731
+ }
1732
+
1619
1733
  /**
1620
1734
  * The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
1621
1735
  * `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
@@ -2526,7 +2640,7 @@ export function handleRequest(req, res, opts) {
2526
2640
  });
2527
2641
  return;
2528
2642
  }
2529
- sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author and /api/settings (GET/HEAD only)');
2643
+ sendText(res, 405, 'method not allowed — this route does not accept this method (GET/HEAD only)');
2530
2644
  return;
2531
2645
  }
2532
2646
  const getRoutes = routesFor(opts, pathname);
@@ -2635,7 +2749,8 @@ export function handleRequest(req, res, opts) {
2635
2749
  * this package's own `bin/bareloop.mjs`).
2636
2750
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
2637
2751
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
2638
- * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string }} [opts]
2752
+ * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
2753
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
2639
2754
  * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
2640
2755
  */
2641
2756
  export function createPanelServer(opts = {}) {
@@ -2690,6 +2805,7 @@ export function createPanelServer(opts = {}) {
2690
2805
  });
2691
2806
  settingsRoutes = createSettingsRoutes({
2692
2807
  port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
2808
+ openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
2693
2809
  });
2694
2810
  runRoutes = createRunRoutes({
2695
2811
  port: boundPort,
@@ -14,7 +14,8 @@
14
14
  import { checkHumanGuard } from './authorroutes.js';
15
15
  import { readConfig, updateConfig, ConfigError } from '../config.js';
16
16
  import { spendSummary, monthlyLimitOf } from '../monthly.js';
17
- import { loadKeysEnv, keysFilePath, filledKeyNames, ensureKeysFile } from '../keysfile.js';
17
+ import { spawn as realSpawn } from 'node:child_process';
18
+ import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
18
19
  import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
19
20
  import { apiKeyProblem, checkProviderReachable } from '../providers.js';
20
21
 
@@ -22,8 +23,11 @@ import { apiKeyProblem, checkProviderReachable } from '../providers.js';
22
23
  const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
23
24
 
24
25
  /**
26
+ * `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
27
+ * neither: `child_process.spawn` and `process.platform`).
25
28
  * @param {{ port: number, token: string, home?: string, env?: Record<string,string|undefined>,
26
- * fetchImpl?: typeof fetch, now?: () => number }} opts
29
+ * fetchImpl?: typeof fetch, now?: () => number, openFolderSpawn?: (cmd: string, args: string[], o: object) => any,
30
+ * platform?: string }} opts
27
31
  */
28
32
  export function createSettingsRoutes(opts) {
29
33
  const fetchImpl = opts.fetchImpl ?? fetch;
@@ -134,6 +138,8 @@ export function createSettingsRoutes(opts) {
134
138
  placeholder: defaultUrlOf(r.provider),
135
139
  keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
136
140
  canTest: r.provider !== 'gemini-api',
141
+ priceInPerM: typeof r.priceInPerM === 'number' ? r.priceInPerM : null,
142
+ priceOutPerM: typeof r.priceOutPerM === 'number' ? r.priceOutPerM : null,
137
143
  tokens: s.tokensByRow[r.envName] ?? 0,
138
144
  balance: r.provider === 'anthropic-api'
139
145
  ? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
@@ -148,11 +154,32 @@ export function createSettingsRoutes(opts) {
148
154
  const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
149
155
  if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
150
156
  // only the three settings a row has; each is optional in the body, the whole triple is stored
157
+ /** @type {Record<string, string|number|null>} */
151
158
  const patch = {
152
159
  name: typeof body?.name === 'string' ? body.name.trim() : row.name,
153
160
  shape: typeof body?.shape === 'string' ? body.shape : row.provider,
154
161
  baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
155
162
  };
163
+ // the two prices (USD per 1M tokens): absent = unchanged; blank / null = not set (the field is removed,
164
+ // never written as 0); otherwise a finite number >= 0 — anything else is refused and nothing is saved
165
+ for (const f of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
166
+ if (!Object.hasOwn(body ?? {}, f)) continue;
167
+ const v = body[f];
168
+ const label = f === 'priceInPerM' ? 'In' : 'Out';
169
+ if (v === null || (typeof v === 'string' && v.trim() === '')) { patch[f] = null; continue; }
170
+ const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) ? Number(v.trim()) : NaN);
171
+ if (!(Number.isFinite(n) && n >= 0)) {
172
+ send(400, { ok: false, error: `the ${label} price must be a number, 0 or more (USD per 1M tokens), or blank for not set` });
173
+ return true;
174
+ }
175
+ patch[f] = n;
176
+ }
177
+ // a price is both fields or neither — ratesFor refuses a half-set one at run time, so never save half
178
+ const after = (/** @type {'priceInPerM'|'priceOutPerM'} */ f) => (Object.hasOwn(patch, f) ? patch[f] !== null : row[f] !== undefined);
179
+ if (after('priceInPerM') !== after('priceOutPerM')) {
180
+ send(400, { ok: false, error: 'set both prices (In and Out, USD per 1M tokens) or neither — a half-set price is refused' });
181
+ return true;
182
+ }
156
183
  try {
157
184
  updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
158
185
  } catch (e) {
@@ -164,6 +191,29 @@ export function createSettingsRoutes(opts) {
164
191
  return true;
165
192
  }
166
193
 
194
+ if (pathname === '/api/settings/open-keys-folder' && req.method === 'POST') {
195
+ // opens the FOLDER (never the file, never an editor) in the file manager; argv array, no shell, detached.
196
+ // Another OS, or a failed spawn: say so and hand back the path, which the page then shows as text.
197
+ const dir = keysHome(opts.home);
198
+ const platform = opts.platform ?? process.platform;
199
+ const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
200
+ const cannot = () => send(200, { ok: true, opened: false, path: dir });
201
+ if (!cmd) { cannot(); return true; }
202
+ let child;
203
+ try {
204
+ child = (opts.openFolderSpawn ?? realSpawn)(cmd, [dir], { detached: true, stdio: 'ignore' });
205
+ } catch { cannot(); return true; }
206
+ let settled = false;
207
+ child.once('error', () => { if (!settled) { settled = true; cannot(); } });
208
+ child.once('spawn', () => {
209
+ if (settled) return;
210
+ settled = true;
211
+ if (typeof child.unref === 'function') child.unref();
212
+ send(200, { ok: true, opened: true, path: dir });
213
+ });
214
+ return true;
215
+ }
216
+
167
217
  if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
168
218
  // the Anthropic balance is a NOTE the person types; no check ever reads it
169
219
  const raw = body?.usd;
package/src/ralph.js CHANGED
@@ -49,7 +49,7 @@ const CLOSE_KILL_GRACE_MS = 2000;
49
49
  * @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
50
50
  * @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
51
51
  */
52
- function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
52
+ export function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
53
53
  return new Promise((resolve) => {
54
54
  const child = spawn(cmd, args, { env, cwd });
55
55
  /** @type {Buffer[]} */ const outChunks = [];
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`,
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
  }