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.
package/src/userrun.js CHANGED
@@ -24,11 +24,13 @@
24
24
  // green ONE job end to end. That green GRADUATES the bridge: the plan the agent
25
25
  // authored is preserved from the spine as a reusable artifact, and the next run of
26
26
  // this shape reuses and fine-tunes it rather than starting cold.
27
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync, openSync, readSync, closeSync, fstatSync, unlinkSync } from 'node:fs';
27
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, readdirSync, openSync, readSync, closeSync, fstatSync, unlinkSync } from 'node:fs';
28
28
  import { createRequire } from 'node:module';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { execFileSync, spawn } from 'node:child_process';
31
31
  import { join, resolve, dirname, basename } from 'node:path';
32
+ import { commitWork, removeWorktree } from './worktree.js';
33
+ import { moveFile } from './movefile.js';
32
34
  import { runJob } from './run.js';
33
35
  import { moneyWithDraft } from './replay.js';
34
36
  import { jobSpecHash, resolveWorkerModel, validateJob } from './job.js';
@@ -268,6 +270,9 @@ class ExitSignal extends Error {
268
270
  * @property {string} [workdir] the patient's tree
269
271
  * @property {string} [seed] the patient's frozen seed commit
270
272
  * @property {string} [spineName] the spine directory name, beside `workdir`
273
+ * @property {string} [into] the source door's own directory (`source.json`, and the spine directory beside it).
274
+ * Default `dirname(workdir)` — the copied-tree layout, where the tree is `<into>/tree`. A repo job on a WORKTREE
275
+ * (P6 item 1) has its tree inside the person's repo, so its `into` is passed explicitly.
271
276
  * @property {string|null} [model] the raw `--model` tier name (`sonnet`/`haiku`), or null for the default
272
277
  * @property {string|null} [readShim] the raw `--read-shim` arm name, or null for the default
273
278
  * @property {string|null} [scout] the raw `--scout` value (`on`/`off`), or null for the default
@@ -299,6 +304,7 @@ class ExitSignal extends Error {
299
304
  * @property {string} workdir
300
305
  * @property {string} seed
301
306
  * @property {string} spineName
307
+ * @property {string} [into] see `RunOpts.into`
302
308
  * @property {string|null} model
303
309
  * @property {string|null} readShim
304
310
  * @property {string|null} scout
@@ -660,7 +666,10 @@ async function execute(ctx) {
660
666
  * both the RESUME reader below and the live run further down need them. Two spellings of
661
667
  * one path is how `--resume` comes to read a different directory than the run writes. */
662
668
  const wd = resolve(WORKDIR);
663
- const spineDir = ctx.bundle ? ctx.bundle.runDir : join(wd, '..', target.spine);
669
+ /** the source door's own directory: where `source.json` sits and the spine directory goes. The copied-tree layout
670
+ * has the tree at `<into>/tree`, so `dirname(wd)`; a worktree run passes it (its tree is inside the person's repo). */
671
+ const into = ctx.into !== undefined ? resolve(ctx.into) : dirname(wd);
672
+ const spineDir = ctx.bundle ? ctx.bundle.runDir : join(into, target.spine);
664
673
  /** `--resume` takes the runid (the conventional `<spine dir>/u-<runid>.jsonl`) or an
665
674
  * explicit path — the path form is what makes these gates testable without touching
666
675
  * an operator's real spine directory. */
@@ -1767,7 +1776,7 @@ async function execute(ctx) {
1767
1776
  // manifest — that absence IS "repo jobs untouched", never an invented
1768
1777
  // destination for a job that declared none. A manifest that EXISTS but
1769
1778
  // cannot be read or parsed is a named $0 stop, never a silent skip.
1770
- const sourceManifest = await readSourceManifest(dirname(wd));
1779
+ const sourceManifest = await readSourceManifest(into);
1771
1780
  if (sourceManifest.stop !== null) {
1772
1781
  emit('destination-refused', { code: sourceManifest.code, detail: sourceManifest.stop });
1773
1782
  emit('run-end', { outcome: 'escalated' });
@@ -1786,7 +1795,7 @@ async function execute(ctx) {
1786
1795
  // against `wd` alone let a destination at `<into>/profile.md` (inside the
1787
1796
  // run's own scratch area, outside the frozen tree, but still bareloop's
1788
1797
  // own scratch plumbing no destination should land in) slip through clean.
1789
- const dp = await proveDestination(frontDoor.destination, { into: dirname(wd) });
1798
+ const dp = await proveDestination(frontDoor.destination, { into });
1790
1799
  if (dp.stop !== null) {
1791
1800
  emit('destination-refused', { code: dp.code, detail: dp.stop });
1792
1801
  emit('run-end', { outcome: 'escalated' });
@@ -2028,7 +2037,36 @@ async function execute(ctx) {
2028
2037
  const prior = readFileSync(auditFile, 'utf8');
2029
2038
  appendFileSync(auditFile, `${prior.length > 0 && !prior.endsWith('\n') ? '\n' : ''}${legAuditText}`);
2030
2039
  unlinkSync(auditSrc);
2031
- } else renameSync(auditSrc, auditFile);
2040
+ } else moveFile(auditSrc, auditFile);
2041
+ }
2042
+ // ── P6 item 1: a repo job on a WORKTREE in the person's own repo. GREEN: the run's work is committed on its work
2043
+ // branch and the worktree folder removed (an already-green is a green: a cold one has nothing to commit, a resumed one may hold uncommitted work) — the branch stays, and the merge stays the person's. Anything else
2044
+ // (stopped, capped, died, red): the folder stays so Resume works, and the Ended block names it. A green that opened a
2045
+ // review door is committed too (the branch always holds the work) but keeps its folder: an accept at the door re-runs
2046
+ // the mechanical stages against that tree.
2047
+ // It runs HERE, right after the gate-audit move, so a later readout step that throws can never skip the commit.
2048
+ const doorHere = events.findLast((e) => e.type === 'review-door') ?? null;
2049
+ /** @type {string | null} */
2050
+ let worktreeLine = null;
2051
+ const wtManifest = sourceManifest.present ? sourceManifest.manifest : null;
2052
+ if (typeof wtManifest?.worktree === 'string' && typeof wtManifest?.repo === 'string') {
2053
+ if (outcome === 'green' || outcome === 'already-green') {
2054
+ const wb = events.findLast((e) => e.type === 'work-branch' && typeof e.branch === 'string') ?? null;
2055
+ try {
2056
+ // the branch ALWAYS holds a green's work (the panel tells the person to merge it); only the FOLDER waits on a door
2057
+ const fin = commitWork(wd, `bareloop: ${spec.job} (run ${runid})`);
2058
+ // an already-green COLD run never made a work branch (the close passed before any work): nothing to name
2059
+ const done = fin.committed ? `final commit ${fin.sha}` : (wb ? 'nothing left to commit' : 'nothing was changed (the close already passed)');
2060
+ const onBranch = wb?.branch ?? 'the work branch';
2061
+ if (doorHere) worktreeLine = `\nWORKTREE ${done}${wb || fin.committed ? ` on ${onBranch}` : ''}; the worktree folder is KEPT for the review door — ${wd}`;
2062
+ else {
2063
+ const gone = removeWorktree(wtManifest.repo, wd);
2064
+ worktreeLine = `\nWORKTREE ${done}${wb || fin.committed ? ` on ${onBranch}` : ''}; ${gone ? 'the worktree folder is removed' : `the worktree folder could not be removed — ${wd}`}`;
2065
+ }
2066
+ } catch (e) {
2067
+ worktreeLine = `\nWORKTREE the final commit failed, so the folder is KEPT: ${wd} (${redactSecrets(String(/** @type {Error} */ (e).message).split('\n')[0])})`;
2068
+ }
2069
+ }
2032
2070
  }
2033
2071
  const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
2034
2072
 
@@ -2178,7 +2216,7 @@ async function execute(ctx) {
2178
2216
  // as "contained". D3 rework: `destination` is a DIRECTORY now, and a job
2179
2217
  // may produce more than one file — `copyOut` delivers every non-empty file
2180
2218
  // under `output/`, each under its own dated name, and returns the list.
2181
- const co = await copyOut({ tree: wd, into: dirname(wd), destination: frontDoor.destination });
2219
+ const co = await copyOut({ tree: wd, into, destination: frontDoor.destination });
2182
2220
  if (co.stop === null) {
2183
2221
  // `f.path`, never `frontDoor.destination` — each file lands under its
2184
2222
  // OWN dated name (M2b fix 4: `profile-2026-09-12.md`, `-2` the same
@@ -2264,7 +2302,6 @@ async function execute(ctx) {
2264
2302
  // ENDED and its verdict is minted — this readout offers the three doors over it and
2265
2303
  // changes nothing about the outcome printed above. Read off the run's own record
2266
2304
  // (never off a flag), so a green that opened no door prints exactly what it always did.
2267
- const doorHere = events.findLast((e) => e.type === 'review-door') ?? null;
2268
2305
  if (doorHere) {
2269
2306
  out('');
2270
2307
  for (const l of reviewDoorPackage({
@@ -2364,6 +2401,7 @@ async function execute(ctx) {
2364
2401
  out('MEMORY-CACHE no record (run ended before its summary)');
2365
2402
  }
2366
2403
  }
2404
+ if (worktreeLine !== null) out(worktreeLine);
2367
2405
  out(`\nspine ${spineFile}`);
2368
2406
  out(`patient left AS THE RUN LEFT IT (read it before the next run resets to the seed)`);
2369
2407
 
@@ -2393,6 +2431,7 @@ function buildCtx(mode, opts) {
2393
2431
  workdir: /** @type {string} */ (opts.workdir),
2394
2432
  seed: /** @type {string} */ (opts.seed),
2395
2433
  spineName: /** @type {string} */ (opts.spineName),
2434
+ ...(opts.into === undefined ? {} : { into: opts.into }),
2396
2435
  model: opts.model ?? null,
2397
2436
  readShim: opts.readShim ?? null,
2398
2437
  scout: opts.scout ?? null,
@@ -2489,7 +2528,7 @@ export async function main(argv, deps = {}) {
2489
2528
  die(`give one of --job <key> (one of: ${Object.keys(JOBS).join(', ')}) or --spec <path to resolved-spec.json> `
2490
2529
  + '— a run has to name a job one of the two ways.');
2491
2530
  }
2492
- /** @type {{spec: string, workdir: string, spine: string, seed: string}} */
2531
+ /** @type {{spec: string, workdir: string, spine: string, seed: string, into?: string}} */
2493
2532
  let target;
2494
2533
  /** the spec's own path, wherever it was found — a `jobs/` file for `--job`,
2495
2534
  * an arbitrary `resolved-spec.json` for `--spec`. */
@@ -2588,9 +2627,14 @@ export async function main(argv, deps = {}) {
2588
2627
  if (typeof seed !== 'string' || !seed) {
2589
2628
  die(`--spec ${specPath}: ${into}/source.json carries no seed commit — not a prepared copy this runner can trust`);
2590
2629
  }
2591
- const specWorkdir = join(into, 'tree');
2630
+ // P6 item 1: a repo job's tree is a WORKTREE in the person's own repo, recorded in the manifest; every other prepared
2631
+ // copy keeps its tree at `<into>/tree`
2632
+ const worktreeRun = typeof manifestRead.manifest.worktree === 'string' && manifestRead.manifest.worktree !== '';
2633
+ const specWorkdir = worktreeRun ? manifestRead.manifest.worktree : join(into, 'tree');
2592
2634
  if (!existsSync(specWorkdir) || !existsSync(join(specWorkdir, '.git'))) {
2593
- die(`--spec ${specPath}: the prepared copy's tree is gone (${specWorkdir} has no .git) — nothing to run against; re-prepare the source`);
2635
+ die(worktreeRun
2636
+ ? `--spec ${specPath}: the job's worktree is gone (${specWorkdir} has no .git) — nothing to run against; start the job again from the page`
2637
+ : `--spec ${specPath}: the prepared copy's tree is gone (${specWorkdir} has no .git) — nothing to run against; re-prepare the source`);
2594
2638
  }
2595
2639
  target = {
2596
2640
  // display-only in --spec mode (SPEC_DESC below is what actually prints)
@@ -2601,6 +2645,7 @@ export async function main(argv, deps = {}) {
2601
2645
  // is, just computed instead of typed by a developer.
2602
2646
  spine: `${spec.job}-bareloop`,
2603
2647
  seed,
2648
+ into,
2604
2649
  };
2605
2650
  SELECTOR = `--spec ${specPath}`;
2606
2651
  SPEC_DESC = specPath;
@@ -2614,6 +2659,7 @@ export async function main(argv, deps = {}) {
2614
2659
  workdir: target.workdir,
2615
2660
  seed: target.seed,
2616
2661
  spineName: target.spine,
2662
+ ...(target.into === undefined ? {} : { into: target.into }),
2617
2663
  model: argFlag('model'),
2618
2664
  readShim: argFlag('read-shim'),
2619
2665
  scout: argFlag('scout'),
@@ -0,0 +1,120 @@
1
+ // The ONE spelling of "a run's tree is a git worktree inside the person's own repo"
2
+ // (`<repo>/.bareloop/wt/<id>`, detached at the repo's HEAD). Two doors make that tree: the bundle runner
3
+ // (`src/bundlerun.js`) and the panel's drafting session (`src/panel/authorsession.js`, through the source
4
+ // door's `worktree` option). Neither carries its own copy of the `git worktree add` call.
5
+ //
6
+ // Plain git, no dependency. Every helper takes the repo ROOT (the directory whose `.git` is the real
7
+ // repository), never a subfolder.
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import {
11
+ appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync,
12
+ } from 'node:fs';
13
+ import { dirname, join, resolve } from 'node:path';
14
+
15
+ /** the line that hides bareloop's own folder from the person's `git status` (anchored at the repo root) */
16
+ export const EXCLUDE_LINE = '/.bareloop/';
17
+
18
+ /** the line that hides installed packages at any depth */
19
+ export const NODE_MODULES_LINE = 'node_modules/';
20
+
21
+ /** the person's git hooks never run for anything bareloop does in their repo: one spelling, used by `addWorktree` and `commitWork` */
22
+ const NO_HOOKS = ['-c', 'core.hooksPath=/dev/null/bareloop-no-hooks'];
23
+
24
+ /** where a run's worktree lives in the person's repo @param {string} repo @param {string} id */
25
+ export function worktreePath(repo, id) {
26
+ return join(repo, '.bareloop', 'wt', id);
27
+ }
28
+
29
+ /**
30
+ * `git worktree add --detach <dir> HEAD` in `repo` (none of the person's hooks run), creating `dir`'s parent first. Throws
31
+ * `Error('git worktree add failed: …')` — a caller decides whether that is a stop or a refusal.
32
+ * @param {string} repo @param {string} dir
33
+ */
34
+ export function addWorktree(repo, dir) {
35
+ mkdirSync(dirname(dir), { recursive: true });
36
+ try {
37
+ execFileSync('git', [...NO_HOOKS, '-C', repo, 'worktree', 'add', '--detach', dir, 'HEAD'], { encoding: 'utf8' });
38
+ } catch (e) {
39
+ throw new Error(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * How many paths `git status --porcelain` lists in `repo` (tracked edits AND untracked files); 0 for a clean
45
+ * tree. A repo git cannot read counts as 0 — this feeds a notice, never a refusal.
46
+ * @param {string} repo @returns {number}
47
+ */
48
+ export function uncommittedCount(repo) {
49
+ try {
50
+ const out = execFileSync('git', ['-C', repo, 'status', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
51
+ return out.split('\n').filter((l) => l.trim() !== '').length;
52
+ } catch { return 0; }
53
+ }
54
+
55
+ /** the short sha of `repo`'s HEAD, or null @param {string} repo @returns {string|null} */
56
+ export function shortHead(repo) {
57
+ try {
58
+ return execFileSync('git', ['-C', repo, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
59
+ } catch { return null; }
60
+ }
61
+
62
+ /**
63
+ * Hide `.bareloop/` and `node_modules/` (packages installed in a worktree must not read as worker writes) from the person's own `git status` through the repo's PRIVATE exclude file
64
+ * (`<common-git-dir>/info/exclude`), never their tracked `.gitignore`. The common dir, not `.git`: in a linked
65
+ * worktree `.git` is a file and the shared `info/exclude` lives in the main repository's git dir. Idempotent —
66
+ * each line is checked separately and never written twice.
67
+ * @param {string} repo
68
+ * @returns {boolean} true when the line was added now
69
+ */
70
+ export function hideBareloopDir(repo) {
71
+ const common = execFileSync('git', ['-C', repo, 'rev-parse', '--git-common-dir'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
72
+ const exclude = join(resolve(repo, common), 'info', 'exclude');
73
+ mkdirSync(dirname(exclude), { recursive: true });
74
+ const text = existsSync(exclude) ? readFileSync(exclude, 'utf8') : '';
75
+ const have = new Set(text.split('\n').map((l) => l.trim()));
76
+ const missing = [EXCLUDE_LINE, NODE_MODULES_LINE].filter((l) => !have.has(l));
77
+ if (missing.length === 0) return false;
78
+ const add = `${missing.join('\n')}\n`;
79
+ if (text === '') writeFileSync(exclude, add);
80
+ else appendFileSync(exclude, `${text.endsWith('\n') ? '' : '\n'}${add}`);
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Remove a run's worktree folder from `repo` (`git worktree remove --force`: the folder may hold untracked
86
+ * scratch — a `.litectx/` index, the gate-audit file — and what matters is on the branch by now). Never throws.
87
+ * @param {string} repo @param {string} dir
88
+ * @returns {boolean} true when the folder is gone afterwards
89
+ */
90
+ export function removeWorktree(repo, dir) {
91
+ try {
92
+ execFileSync('git', ['-C', repo, 'worktree', 'remove', '--force', dir], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
93
+ } catch { /* fall through: report what is on disk */ }
94
+ return !existsSync(dir);
95
+ }
96
+
97
+ /** a commit bareloop makes in the person's repo is authored by bareloop (never their global config), signs nothing, runs none of their hooks */
98
+ const COMMIT_CONFIG = [
99
+ '-c', 'user.name=bareloop', '-c', 'user.email=bareloop@localhost', '-c', 'commit.gpgsign=false',
100
+ ...NO_HOOKS,
101
+ ];
102
+
103
+ /** what never goes into the work commit: the arbiter's own books and installed packages */
104
+ const NOT_WORK = [':(exclude).smoke', ':(exclude).litectx', ':(exclude)gate-audit.jsonl', ':(exclude,glob)**/node_modules/**'];
105
+
106
+ /**
107
+ * The run's final commit, on whatever branch the worktree stands on: everything the run changed except the
108
+ * arbiter's books and `node_modules`. Nothing changed = no commit (a green with nothing to commit is still a green).
109
+ * Throws on a git failure — the caller must not remove a worktree whose work is not safely on the branch.
110
+ * @param {string} dir the worktree
111
+ * @param {string} message
112
+ * @returns {{committed: boolean, sha: string}}
113
+ */
114
+ export function commitWork(dir, message) {
115
+ const run = (/** @type {string[]} */ args) => execFileSync('git', ['-C', dir, ...COMMIT_CONFIG, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
116
+ run(['add', '-A', '--', '.', ...NOT_WORK]);
117
+ const staged = run(['diff', '--cached', '--name-only']).trim();
118
+ if (staged !== '') run(['commit', '-q', '-m', message]);
119
+ return { committed: staged !== '', sha: run(['rev-parse', '--short', 'HEAD']).trim() };
120
+ }
@@ -1,3 +1,18 @@
1
+ /**
2
+ * One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
3
+ * - `step-start` {no, id, label, at}
4
+ * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
5
+ * - `call` {no, step, label, model, costUsd (null stays null), unpricedRounds, at}: one METERED model call
6
+ * (no token counts: the call path does not carry them without reworking the library, so none are recorded)
7
+ * @typedef {{kind: string, no: number, at: string, [k: string]: any}} DraftEvent
8
+ */
9
+ /**
10
+ * Append one event to `<dir>/draft-log.jsonl`. Best-effort like draft-spend.json: a disk fault never stops the draft.
11
+ * @param {string} dir the session's folder
12
+ * @param {DraftEvent} ev
13
+ * @returns {void}
14
+ */
15
+ export function appendDraftLog(dir: string, ev: DraftEvent): void;
1
16
  /**
2
17
  * @typedef {object} DraftSpend
3
18
  * @property {string} sessionId
@@ -25,7 +40,80 @@ export function readOrphanDraftSpends(opts?: {
25
40
  home?: string;
26
41
  sessionsRoot?: string;
27
42
  }): DraftSpend[];
43
+ /**
44
+ * The session folder a run-list row became a run in: the folder under `<home>/panel-sessions` that carries a drafting
45
+ * log and holds the row's `spine` or `patient` ("became a run", the same rule as {@link readOrphanDraftSpends}).
46
+ * A Reuse-workflow run, a CLI run and an imported run sit in no such folder.
47
+ * @param {{spine?: string, patient?: string|null}} row
48
+ * @param {{ home?: string, sessionsRoot?: string }} [opts]
49
+ * @returns {string|null}
50
+ */
51
+ export function sessionDirForRow(row: {
52
+ spine?: string;
53
+ patient?: string | null;
54
+ }, opts?: {
55
+ home?: string;
56
+ sessionsRoot?: string;
57
+ }): string | null;
58
+ /**
59
+ * Fold `<dir>/draft-log.jsonl` into one record per drafting step, each with its calls. A line that cannot be parsed is
60
+ * skipped (a torn last line must not hide the rest).
61
+ * @param {string} dir
62
+ * @returns {{no: number, id: string, label: string, status: 'running'|'done'|'failed', startedAt: string, endedAt: string|null, wallMs: number|null,
63
+ * calls: {label: string, model: string|null, costUsd: number|null, unpricedRounds: number, at: string}[]}[]}
64
+ */
65
+ export function readDraftSteps(dir: string): {
66
+ no: number;
67
+ id: string;
68
+ label: string;
69
+ status: "running" | "done" | "failed";
70
+ startedAt: string;
71
+ endedAt: string | null;
72
+ wallMs: number | null;
73
+ calls: {
74
+ label: string;
75
+ model: string | null;
76
+ costUsd: number | null;
77
+ unpricedRounds: number;
78
+ at: string;
79
+ }[];
80
+ }[];
81
+ /**
82
+ * The DRAFTING PART of a run (hamr 2026-10-06, option A): the first part of the run's views, built from the session's
83
+ * drafting log. `null` when the run did not come out of a panel session, or the log carries no metered call (a Reuse
84
+ * workflow run, a CLI run, a run older than the log). The part's money is the SPINE's figure (`job-start.draftSpentUsd`,
85
+ * passed in) — the log's calls are shown beside it, never summed into a second total; `null` spine figure = no part.
86
+ * @param {{spine?: string, patient?: string|null}} row
87
+ * @param {{draftSpentUsd: number|null, draftSpendComplete: boolean|null}} spine the run's own drafting figures
88
+ * @param {{ home?: string, sessionsRoot?: string }} [opts]
89
+ * @returns {any|null}
90
+ */
91
+ export function draftingPartFor(row: {
92
+ spine?: string;
93
+ patient?: string | null;
94
+ }, spine: {
95
+ draftSpentUsd: number | null;
96
+ draftSpendComplete: boolean | null;
97
+ }, opts?: {
98
+ home?: string;
99
+ sessionsRoot?: string;
100
+ }): any | null;
28
101
  export const DRAFT_SPEND_FILE: "draft-spend.json";
102
+ /** the session's drafting LOG: an append-only JSONL beside draft-spend.json (one owner: the session, src/panel/authorsession.js). Three event kinds, see {@link DraftEvent}. */
103
+ export const DRAFT_LOG_FILE: "draft-log.jsonl";
104
+ /**
105
+ * One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
106
+ * - `step-start` {no, id, label, at}
107
+ * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
108
+ * - `call` {no, step, label, model, costUsd (null stays null), unpricedRounds, at}: one METERED model call
109
+ * (no token counts: the call path does not carry them without reworking the library, so none are recorded)
110
+ */
111
+ export type DraftEvent = {
112
+ kind: string;
113
+ no: number;
114
+ at: string;
115
+ [k: string]: any;
116
+ };
29
117
  export type DraftSpend = {
30
118
  sessionId: string;
31
119
  /**
package/types/legs.d.ts CHANGED
@@ -49,7 +49,7 @@ export function parseSpineText(raw: string): {
49
49
  export function watchdogNotePath(spineFile: string, leg: number): string;
50
50
  /**
51
51
  * P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
52
- * the engine reads it at the between-steps seam (src/planrun.js) and consumes it. One spelling, here, so the route
52
+ * the engine reads it at the round seam of a step worker (src/planrun.js `metered`) and consumes it. One spelling, here, so the route
53
53
  * and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
54
54
  * when the leg ends, and again at the start of the next leg (src/userrun.js).
55
55
  * @param {string} spineFile
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @param {string} src
3
+ * @param {string} dst
4
+ * @param {{ rename?: (a: string, b: string) => void }} [deps] test seam: the rename to try first
5
+ * @returns {void}
6
+ */
7
+ export function moveFile(src: string, dst: string, deps?: {
8
+ rename?: (a: string, b: string) => void;
9
+ }): void;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The env npm sees: the panel's own env minus every credential `CLOSE_ENV_DENY` names (never re-spelled).
3
+ * `npm ci` needs no provider key; a private registry token (NPM_TOKEN) is stripped too, so such a repo
4
+ * simply falls back to the person's own install.
5
+ * @param {NodeJS.ProcessEnv} base
6
+ * @returns {NodeJS.ProcessEnv}
7
+ */
8
+ export function npmEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
9
+ /**
10
+ * Run `npm ci --ignore-scripts` in `cwd`. Async (never blocks the panel's event loop); argv array, no shell.
11
+ * @param {string} cwd the directory holding package.json + the lock file
12
+ * @param {{timeoutMs?: number, env?: NodeJS.ProcessEnv, run?: typeof spawnClose}} [o] `run` is the test seam
13
+ * @returns {Promise<{ok: true}|{ok: false, reason: string}>}
14
+ */
15
+ export function runNpmCi(cwd: string, { timeoutMs, env, run }?: {
16
+ timeoutMs?: number;
17
+ env?: NodeJS.ProcessEnv;
18
+ run?: typeof spawnClose;
19
+ }): Promise<{
20
+ ok: true;
21
+ } | {
22
+ ok: false;
23
+ reason: string;
24
+ }>;
25
+ /** Wall-clock cap for one `npm ci`: 10 minutes. A cold install of a large repo is minutes, never an hour;
26
+ * past this the person is better served by the printed command in their own terminal. Operator-set. */
27
+ export const NPM_CI_TIMEOUT_MS: 600000;
28
+ /** the lock files whose gap command is `npm ci` (the rule table in src/source.js, LOCKFILE_COMMANDS) */
29
+ export const NPM_CI_LOCKS: readonly string[];
30
+ import { spawnClose } from './ralph.js';
@@ -108,7 +108,7 @@ export function validateJobCard(card: any, opts?: {
108
108
  * @param {any} card the validated job card (see {@link validateJobCard})
109
109
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
110
110
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
111
- * prepareSigningFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
111
+ * prepareSigningFn?: Function, npmCiFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
112
112
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
113
113
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
114
114
  * @returns {any} the session object
@@ -123,6 +123,7 @@ export function createSession(card: any, deps?: {
123
123
  confirmGenerate?: Function;
124
124
  authorFn?: Function;
125
125
  prepareSigningFn?: Function;
126
+ npmCiFn?: Function;
126
127
  reuse?: {
127
128
  spec: any;
128
129
  workflowKey: string;
@@ -119,7 +119,9 @@ export function legDividersFor(spineRecords: any[], parts: any[]): {
119
119
  * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
120
120
  * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
121
121
  * @param {{died: boolean, lastThing: string|null}} death
122
- * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean}} [o]
122
+ * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
123
+ * worktree?: {folder: string, repo: string, branch: string|null, exists: boolean}|null}} [o]
124
+ * `worktree` (P6 item 1): the run's worktree in the person's repo, from {@link worktreeOfRun}; absent for every other run
123
125
  * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
124
126
  */
125
127
  export function endedFor(summary: {
@@ -138,6 +140,12 @@ export function endedFor(summary: {
138
140
  } | null;
139
141
  destinationRefused?: string | null;
140
142
  moneyHalt?: boolean;
143
+ worktree?: {
144
+ folder: string;
145
+ repo: string;
146
+ branch: string | null;
147
+ exists: boolean;
148
+ } | null;
141
149
  }): {
142
150
  reason: string;
143
151
  next: string;
@@ -487,7 +495,8 @@ export function handleRequest(req: import("node:http").IncomingMessage, res: imp
487
495
  * this package's own `bin/bareloop.mjs`).
488
496
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
489
497
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
490
- * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string }} [opts]
498
+ * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
499
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
491
500
  * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
492
501
  */
493
502
  export function createPanelServer(opts?: {
@@ -500,6 +509,8 @@ export function createPanelServer(opts?: {
500
509
  fetchImpl?: typeof fetch;
501
510
  settleMs?: number;
502
511
  userHome?: string;
512
+ openFolderSpawn?: (cmd: string, args: string[], o: object) => any;
513
+ platform?: string;
503
514
  }): Promise<{
504
515
  server: import("node:http").Server;
505
516
  port: number;
@@ -1,6 +1,9 @@
1
1
  /**
2
+ * `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
3
+ * neither: `child_process.spawn` and `process.platform`).
2
4
  * @param {{ port: number, token: string, home?: string, env?: Record<string,string|undefined>,
3
- * fetchImpl?: typeof fetch, now?: () => number }} opts
5
+ * fetchImpl?: typeof fetch, now?: () => number, openFolderSpawn?: (cmd: string, args: string[], o: object) => any,
6
+ * platform?: string }} opts
4
7
  */
5
8
  export function createSettingsRoutes(opts: {
6
9
  port: number;
@@ -9,6 +12,8 @@ export function createSettingsRoutes(opts: {
9
12
  env?: Record<string, string | undefined>;
10
13
  fetchImpl?: typeof fetch;
11
14
  now?: () => number;
15
+ openFolderSpawn?: (cmd: string, args: string[], o: object) => any;
16
+ platform?: string;
12
17
  }): {
13
18
  handle: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, pathname: string, body: any) => boolean;
14
19
  };
package/types/ralph.d.ts CHANGED
@@ -1,3 +1,22 @@
1
+ /**
2
+ * @param {string} cmd
3
+ * @param {string[]} args
4
+ * @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
5
+ * @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
6
+ */
7
+ export function spawnClose(cmd: string, args: string[], { env, cwd, timeoutMs }: {
8
+ env: NodeJS.ProcessEnv;
9
+ cwd?: string;
10
+ timeoutMs?: number;
11
+ }): Promise<{
12
+ error: (Error & {
13
+ code?: string;
14
+ }) | null;
15
+ status: number | null;
16
+ signal: string | null;
17
+ stdout: string;
18
+ stderr: string;
19
+ }>;
1
20
  /**
2
21
  * Run a close command; its exit code is the verdict (hard green, PRD §4).
3
22
  * `expect` → satisfied; any other exit → needs_revision (gap fed back).
package/types/source.d.ts CHANGED
@@ -71,19 +71,25 @@ export function looksLikeRepoSource(source: string): boolean;
71
71
  * as declared, unvalidated by `proveDestination`, and `frontDoorFromManifest`
72
72
  * never hands a repo source's destination to `copyOut` — wiring it into
73
73
  * `writeScope` is M3/M4's job, not this one.
74
- * @param {{source: string, into: string, destination?: string,
74
+ * `worktree` (P6 item 1, repo sources ONLY — ignored for every other kind): instead of COPYING the repo, make the
75
+ * run's tree a detached git worktree of the person's own repo at that directory (the shared
76
+ * `src/worktree.js`, the bundle door's own spelling), hide `.bareloop/` from their `git status`, and record the
77
+ * worktree and the repo root in the manifest. `into` then holds only `source.json`; the tree is `worktree`, at the
78
+ * repo's current commit (uncommitted edits are NOT in it). No seed commit is made — the seed IS the repo's HEAD.
79
+ * @param {{source: string, into: string, destination?: string, worktree?: string,
75
80
  * fetchTimeoutMs?: number, afterScan?: () => (void|Promise<void>)}} args
76
81
  * `fetchTimeoutMs` is test-only — production callers omit it and get
77
82
  * `PROVIDER_TIMEOUT_MS`. `afterScan` is a test seam too: called exactly once,
78
83
  * after the secret scan finished and before the freeze loop re-reads any
79
84
  * file's bytes, so a test can swap a file inside that window
80
85
  * deterministically. Production never sets it; absent, behaviour is unchanged.
81
- * @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: object}|SourceRefusal>}
86
+ * @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: Record<string, any>}|SourceRefusal>}
82
87
  */
83
- export function prepareSource({ source, into, destination, fetchTimeoutMs, afterScan }: {
88
+ export function prepareSource({ source, into, destination, worktree, fetchTimeoutMs, afterScan }: {
84
89
  source: string;
85
90
  into: string;
86
91
  destination?: string;
92
+ worktree?: string;
87
93
  fetchTimeoutMs?: number;
88
94
  afterScan?: () => (void | Promise<void>);
89
95
  }): Promise<{
@@ -91,7 +97,7 @@ export function prepareSource({ source, into, destination, fetchTimeoutMs, after
91
97
  into: string;
92
98
  tree: string;
93
99
  manifestPath: string;
94
- manifest: object;
100
+ manifest: Record<string, any>;
95
101
  } | SourceRefusal>;
96
102
  /**
97
103
  * THE INSTALL-GAP DETECTOR (PRD item 33 close-out, `docs/product/PRD.md:450-452`,
@@ -127,13 +133,15 @@ export function prepareSource({ source, into, destination, fetchTimeoutMs, after
127
133
  * manifest)
128
134
  * @param {string} [sourceSubdir] the manifest's own `sourceSubdir` ('' or
129
135
  * undefined when Source IS the repo root)
130
- * @returns {{manager: string, command: string, reason: string}|null} `null`
136
+ * @returns {{dir: string, lockFile: string|null, manager: string, command: string, reason: string}|null} `null`
131
137
  * when there is nothing to report: no JS/TS manifest found walking up from
132
138
  * `sourceSubdir` to `treeDir`, the nearest one found has no dependencies,
133
139
  * `node_modules` is already there, or the manifest cannot even be read as
134
140
  * JSON (an invalid `package.json` is "cannot tell", never a false alarm).
135
141
  */
136
142
  export function missingDependencies(treeDir: string, sourceSubdir?: string): {
143
+ dir: string;
144
+ lockFile: string | null;
137
145
  manager: string;
138
146
  command: string;
139
147
  reason: string;
@@ -129,6 +129,12 @@ export type RunOpts = {
129
129
  * the spine directory name, beside `workdir`
130
130
  */
131
131
  spineName?: string | undefined;
132
+ /**
133
+ * the source door's own directory (`source.json`, and the spine directory beside it).
134
+ * Default `dirname(workdir)` — the copied-tree layout, where the tree is `<into>/tree`. A repo job on a WORKTREE
135
+ * (P6 item 1) has its tree inside the person's repo, so its `into` is passed explicitly.
136
+ */
137
+ into?: string | undefined;
132
138
  /**
133
139
  * the raw `--model` tier name (`sonnet`/`haiku`), or null for the default
134
140
  */
@@ -194,6 +200,10 @@ export type RunCtx = {
194
200
  workdir: string;
195
201
  seed: string;
196
202
  spineName: string;
203
+ /**
204
+ * see `RunOpts.into`
205
+ */
206
+ into?: string | undefined;
197
207
  model: string | null;
198
208
  readShim: string | null;
199
209
  scout: string | null;