bareloop 0.35.0 → 0.37.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.
@@ -27,8 +27,11 @@ import {
27
27
  } from 'node:fs';
28
28
  import { join, resolve, dirname } from 'node:path';
29
29
  import {
30
- prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
30
+ prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
31
31
  } from '../source.js';
32
+ import {
33
+ worktreePath, uncommittedCount, shortHead, removeWorktree,
34
+ } from '../worktree.js';
32
35
  import { detectLanguage } from '../detectlang.js';
33
36
  import {
34
37
  validateJob, jobSpecHash, workflowKey, resolveWorkerModel,
@@ -48,7 +51,8 @@ import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
48
51
  import { closeJudges } from '../kinds.js';
49
52
  import { redactSecrets } from '../validate.js';
50
53
  import { tallyCalls } from '../text.js';
51
- import { writeDraftSpend } from '../draftspend.js';
54
+ import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
55
+ import { runNpmCi, NPM_CI_LOCKS } from '../npminstall.js';
52
56
 
53
57
  /**
54
58
  * THE PROGRESS LIST's one table (hamr 2026-10-04, "one place showing all"): every pipeline step the left Chat panel
@@ -58,7 +62,7 @@ import { writeDraftSpend } from '../draftspend.js';
58
62
  */
59
63
  export const STEP_LABELS = Object.freeze({
60
64
  setup: 'checking setup',
61
- copy: 'copying source',
65
+ copy: 'making worktree',
62
66
  check: 'checking source',
63
67
  install: 'waiting on install',
64
68
  reuse: 'reusing signed workflow',
@@ -254,17 +258,7 @@ export function validateReuseCard(card, opts = {}) {
254
258
  /** A Destination written as an absolute path (POSIX or a Windows drive path). @param {string} destination @returns {boolean} */
255
259
  const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(destination);
256
260
 
257
- /**
258
- * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
259
- * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
260
- * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
261
- * @returns {string}
262
- */
263
- export function nonRepoSourceMessage(kind) {
264
- if (kind === 'file') return 'Source is a single file. Source must be the repo folder — put the file in Destination, like src/digest.js. Nothing spent.';
265
- if (kind === 'folder') return "Source is a plain folder, not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
266
- return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
267
- }
261
+ export { nonRepoSourceMessage };
268
262
 
269
263
  /**
270
264
  * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
@@ -315,7 +309,7 @@ export function validateJobCard(card, opts = {}) {
315
309
  * @param {any} card the validated job card (see {@link validateJobCard})
316
310
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
317
311
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
318
- * prepareSigningFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
312
+ * prepareSigningFn?: Function, npmCiFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
319
313
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
320
314
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
321
315
  * @returns {any} the session object
@@ -378,15 +372,31 @@ export function createSession(card, deps = {}) {
378
372
  steps: /** @type {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}[]} */ ([]),
379
373
  };
380
374
 
375
+ // The drafting LOG's step events (src/draftspend.js, DRAFT_LOG_FILE): after every progress-list change, diff the list
376
+ // against what was already logged and append the start / end / re-open of each line — the ONE writer of step times.
377
+ /** @type {('running'|'ended')[]} */
378
+ const loggedSteps = [];
379
+ const logSteps = () => {
380
+ const at = new Date().toISOString();
381
+ state.steps.forEach((/** @type {any} */ x, no) => {
382
+ if (loggedSteps[no] === undefined) { appendDraftLog(outDir, { kind: 'step-start', no, id: x.id, label: x.label, at }); loggedSteps[no] = 'running'; }
383
+ if (x.status === 'running' && loggedSteps[no] === 'ended') { appendDraftLog(outDir, { kind: 'step-reopen', no, at }); loggedSteps[no] = 'running'; }
384
+ if (x.status !== 'running' && loggedSteps[no] === 'running') { appendDraftLog(outDir, { kind: 'step-end', no, status: x.status, label: x.label, at }); loggedSteps[no] = 'ended'; }
385
+ });
386
+ };
387
+
381
388
  /** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
382
389
  const stepStart = (id, detail) => {
383
390
  advanceSteps(state.steps, id, detail);
384
391
  state.progressLabel = STEP_LABELS[id] ?? id;
392
+ logSteps();
385
393
  };
386
394
  /** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
387
395
  const stepDetail = (id, detail) => { const x = latestStep(state.steps, id); if (x) x.detail = detail; };
396
+ /** @param {string} id @param {string} label the latest line of `id` reads `label` instead of its table label (the install step: bareloop installing vs the person) */
397
+ const stepLabel = (id, label) => { const x = latestStep(state.steps, id); if (x) x.label = label; };
388
398
  /** finish the running step as done (or the latest line of `id`, when given) */
389
- const stepDone = (id) => { for (const x of state.steps) if (x.status === 'running' && (id === undefined || x.id === id)) x.status = 'done'; };
399
+ const stepDone = (id) => { for (const x of state.steps) if (x.status === 'running' && (id === undefined || x.id === id)) x.status = 'done'; logSteps(); };
390
400
  /** @param {string} reason the running step (else the last one) fails with the code-owned reason on its own line */
391
401
  const stepFail = (reason) => {
392
402
  let x = state.steps.find((/** @type {any} */ y) => y.status === 'running');
@@ -396,6 +406,7 @@ export function createSession(card, deps = {}) {
396
406
  }
397
407
  x.status = 'failed';
398
408
  x.detail = reason;
409
+ logSteps();
399
410
  };
400
411
 
401
412
  /** @param {'bot'|'you'|'system'} role @param {string} text */
@@ -470,6 +481,13 @@ export function createSession(card, deps = {}) {
470
481
  // person needs to read; the step-by-step detail lives in one line.
471
482
  // ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
472
483
  if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
484
+ // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is a RETRY: the line says so in plain words, so a
485
+ // long silence on call 2 of 3 does not read as a hang. The same one line, relabelled — never a second line.
486
+ if (name === 'author-call' && Number.isInteger(data.i) && data.i >= 1 && Number.isInteger(data.of)) {
487
+ const label = `${STEP_LABELS.draft} (retry ${data.i + 1} of ${data.of + 1})`;
488
+ stepLabel('draft', label);
489
+ state.progressLabel = label;
490
+ }
473
491
  };
474
492
  // ABANDON (hamr's ruling 2026-10-05): once abandoned the phase is frozen at 'abandoned' — whatever the in-flight
475
493
  // run() does afterwards (a late refuse, a phase write) never overwrites it, so the one-at-a-time lock stays released.
@@ -498,6 +516,14 @@ export function createSession(card, deps = {}) {
498
516
  // tally, so an abandoned / refused / restarted session's drafting spend still counts (src/draftspend.js)
499
517
  const nowIso = new Date().toISOString();
500
518
  draftStartedAt ??= nowIso;
519
+ // the same call, one line in the drafting LOG (the part a run's views show first): the step running right now,
520
+ // the call's label, the drafting model, its cost (null stays null: unpriced is never $0). One owner: this onCall.
521
+ const runningNo = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
522
+ const callNo = runningNo === -1 ? state.steps.length - 1 : runningNo;
523
+ appendDraftLog(outDir, {
524
+ kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
525
+ costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
526
+ });
501
527
  try {
502
528
  writeDraftSpend(outDir, {
503
529
  sessionId: id, spentUsd: t.knownUsd, spendComplete: t.spendComplete, provider: draftIdentity?.provider ?? null,
@@ -507,6 +533,15 @@ export function createSession(card, deps = {}) {
507
533
  };
508
534
  /** @type {string|null} */
509
535
  let draftStartedAt = null;
536
+ /** P6 item 1: the worktree this session made in the person's repo (null until made, and again once removed) @type {{repo: string, dir: string}|null} */
537
+ let madeWorktree = null;
538
+ /** a session that ends WITHOUT ever running (refused, errored, abandoned) takes its worktree with it; a signed one keeps it for the run */
539
+ const dropWorktree = () => {
540
+ if (madeWorktree === null) return;
541
+ const w = madeWorktree;
542
+ madeWorktree = null;
543
+ removeWorktree(w.repo, w.dir);
544
+ };
510
545
  /** @type {{provider: string, baseUrl: string|null, model: string}|null} the worker the drafting calls ran on, set once the Model Name resolves */
511
546
  let draftIdentity = null;
512
547
 
@@ -515,6 +550,7 @@ export function createSession(card, deps = {}) {
515
550
  /** @param {string} message @param {string} [phase] */
516
551
  const refuse = (message, phase = 'refused') => {
517
552
  if (abandoned) return;
553
+ dropWorktree();
518
554
  state.phase = phase;
519
555
  state.error = message;
520
556
  stepFail(message);
@@ -556,6 +592,16 @@ export function createSession(card, deps = {}) {
556
592
 
557
593
  state.phase = 'preparing-source';
558
594
  stepStart('copy');
595
+ // P6 item 1 (hamr 2026-10-05, Q1 = A / Q3 = A): a REPO source gets a worktree in the person's own repo, made now at
596
+ // drafting start — `.bareloop/wt/<session id>` at their current commit. Edits they have not committed are not in it,
597
+ // and the progress line says so (a notice, never a refusal).
598
+ const repoRoot = isRepoLike ? nearestGitAncestor(resolve(card.source))?.dir ?? null : null;
599
+ if (repoRoot !== null) {
600
+ const dirty = uncommittedCount(repoRoot);
601
+ if (dirty > 0) {
602
+ stepDetail('copy', `${dirty} uncommitted change(s) in your repo are not in this job — it starts from commit ${shortHead(repoRoot) ?? 'HEAD'}.`);
603
+ }
604
+ }
559
605
  // ONE rule for Destination (the same for a fresh card and a reuse card): for a REPO source it is the write fence
560
606
  // (writeScope globs, relative to the repo — set into the spec below, never proven as a directory); for a FOLDER source
561
607
  // it is an absolute output directory, proven by `prepareSource` -> `proveDestination`. The door routes on a peek at
@@ -565,7 +611,10 @@ export function createSession(card, deps = {}) {
565
611
  const destIsDir = isAbsoluteDestination(card.destination);
566
612
  const prep = await prepareSource({
567
613
  source: card.source, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
614
+ ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
568
615
  });
616
+ if (repoRoot !== null && prep.stop === null && prep.manifest?.worktree) madeWorktree = { repo: repoRoot, dir: prep.manifest.worktree };
617
+ if (abandoned) dropWorktree();
569
618
  if (prep.stop !== null) {
570
619
  // reaching the source and checking it are two lines: a scan/freeze refusal means the copy itself worked
571
620
  if (SOURCE_CHECK_CODES.has(/** @type {any} */ (prep).code)) stepStart('check');
@@ -587,21 +636,38 @@ export function createSession(card, deps = {}) {
587
636
  LANG = langResult.kind === 'resolved' ? langResult.lang : (langResult.kind === 'ambiguous' ? langResult.candidates[0] : 'none-detected');
588
637
 
589
638
  // ── the install gap, WAITED FOR, never a dead end (build item 1,
590
- // mirroring F182's fix in `src/interviewrun.js`) ── the copy this
591
- // session works from (`prep.tree`) is `prepareSource`'s own hidden
592
- // seed, tracked-files-only, so a JS/TS repo's copy never carries
593
- // `node_modules`. Refusing outright here was ITSELF the F182 class of
594
- // bug one layer up: a person who ran the printed command and then
595
- // reloaded the page got a BRAND NEW seed with no `node_modules`
596
- // either, so the refusal could never be satisfied. bareloop still
597
- // never runs an install itself — this only re-checks the SAME copy
598
- // (`missingDependencies` over `prep.tree`, the identical rule
599
- // `src/interviewrun.js` uses) on demand, via the "Check again" button
600
- // (`checkDeps()`, below), instead of ending the session.
639
+ // mirroring F182's fix in `src/interviewrun.js`) ── the tree this
640
+ // session works from (`prep.tree`) is the job's own WORKTREE (P6 item 1:
641
+ // a detached checkout of the person's current commit, inside their
642
+ // repo), so a JS/TS repo's tree never carries `node_modules` until
643
+ // someone installs there. Refusing outright here was ITSELF the F182
644
+ // class of bug one layer up. bareloop still never runs an install
645
+ // itself — this only re-checks the SAME worktree on demand, via the
646
+ // "Check again" button (`checkDeps()`, below), instead of ending the
647
+ // session. (P6 item 3: for an npm lock file bareloop runs `npm ci` itself first, above.)
601
648
  let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
649
+ // P6 item 3: a committed npm lock file -> bareloop runs `npm ci --ignore-scripts` itself, $0, before
650
+ // any model call. Any failure (or another lock file) falls through to the wait below, unchanged.
651
+ /** @type {string|null} */
652
+ let installFailed = null;
653
+ if (depsGap && depsGap.lockFile && NPM_CI_LOCKS.includes(depsGap.lockFile)) {
654
+ stepStart('install', 'Installing packages (npm ci)…');
655
+ stepLabel('install', 'installing packages');
656
+ state.progressLabel = 'installing packages';
657
+ const ci = await (deps.npmCiFn ?? runNpmCi)(depsGap.dir, {});
658
+ if (ci.ok) {
659
+ depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
660
+ if (!depsGap) {
661
+ stepDetail('install', 'Installed packages (npm ci).');
662
+ stepDone('install');
663
+ stepStart('check');
664
+ } else installFailed = 'it ran but node_modules is still missing';
665
+ } else installFailed = ci.reason;
666
+ }
602
667
  if (depsGap) {
603
668
  state.phase = 'install-needed';
604
- stepStart('install', `Packages missing in the copy. Run: cd ${prep.tree} && ${depsGap.command}`);
669
+ stepLabel('install', STEP_LABELS.install); // the person is the one installing now
670
+ stepStart('install', `${installFailed ? `Installing packages (npm ci) failed: ${redactSecrets(installFailed)}. ` : ''}Packages missing in the job's worktree. Run: cd ${prep.tree} && ${depsGap.command}`);
605
671
  for (;;) {
606
672
  state.pendingAsk = { kind: 'install-needed', tree: prep.tree, command: depsGap.command, reason: depsGap.reason };
607
673
  // eslint-disable-next-line no-await-in-loop
@@ -834,6 +900,7 @@ export function createSession(card, deps = {}) {
834
900
  if (['refused', 'abandoned', 'error', 'signed', 'signing-failed'].includes(state.phase)) return { ok: false, error: 'this session is not live — nothing to abandon' };
835
901
  abandoned = true;
836
902
  phaseValue = 'abandoned';
903
+ dropWorktree();
837
904
  state.error = 'Abandoned by you — money already spent stays booked.';
838
905
  state.pendingAsk = null;
839
906
  if (resolvePending) { const r = resolvePending; resolvePending = null; r(null); }