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.
package/src/u-patient.js CHANGED
@@ -16,9 +16,10 @@
16
16
  // mechanical preparation of a tree, and both callers print what it did.
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import {
19
- rmSync, existsSync, renameSync,
19
+ rmSync, existsSync,
20
20
  } from 'node:fs';
21
21
  import { join } from 'node:path';
22
+ import { moveFile } from './movefile.js';
22
23
 
23
24
  /**
24
25
  * Reset a patient to its frozen seed and drop its `.litectx` store.
@@ -68,6 +69,6 @@ export function moveStaleGateAudit(wd, spineDir, runid) {
68
69
  const staleAudit = join(wd, 'gate-audit.jsonl');
69
70
  if (!existsSync(staleAudit)) return null;
70
71
  const preFile = join(spineDir, `pre-${runid}-gate-audit.jsonl`);
71
- renameSync(staleAudit, preFile);
72
+ moveFile(staleAudit, preFile);
72
73
  return preFile;
73
74
  }
package/src/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';
@@ -41,7 +41,7 @@ export function checkHumanGuard(req: import("node:http").IncomingMessage, { toke
41
41
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
42
42
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
43
43
  * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
44
- * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any} }} opts
44
+ * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
45
45
  */
46
46
  export function createAuthorRoutes(opts: {
47
47
  port: number;
@@ -60,6 +60,9 @@ export function createAuthorRoutes(opts: {
60
60
  getImport: (id: string, o?: {
61
61
  model?: string;
62
62
  }) => any;
63
+ listJobs?: (o?: {
64
+ model?: string;
65
+ }) => any[];
63
66
  };
64
67
  }): {
65
68
  handle: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, pathname: string, body: any) => boolean;
@@ -67,13 +67,6 @@ export function validateReuseCard(card: any, opts?: {
67
67
  ok: false;
68
68
  error: string;
69
69
  };
70
- /**
71
- * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
72
- * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
73
- * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
74
- * @returns {string}
75
- */
76
- export function nonRepoSourceMessage(kind: string): string;
77
70
  /**
78
71
  * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
79
72
  * relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
@@ -108,7 +101,7 @@ export function validateJobCard(card: any, opts?: {
108
101
  * @param {any} card the validated job card (see {@link validateJobCard})
109
102
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
110
103
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
111
- * prepareSigningFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
104
+ * prepareSigningFn?: Function, npmCiFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
112
105
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
113
106
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
114
107
  * @returns {any} the session object
@@ -123,6 +116,7 @@ export function createSession(card: any, deps?: {
123
116
  confirmGenerate?: Function;
124
117
  authorFn?: Function;
125
118
  prepareSigningFn?: Function;
119
+ npmCiFn?: Function;
126
120
  reuse?: {
127
121
  spec: any;
128
122
  workflowKey: string;
@@ -152,9 +146,11 @@ export const CARD_FIELDS: readonly string[];
152
146
  export const REUSE_OPEN_FIELDS: readonly string[];
153
147
  /** every card field that is NOT open on a reuse card, in card order */
154
148
  export const REUSE_LOCKED_FIELDS: readonly string[];
149
+ export { nonRepoSourceMessage };
155
150
  export type StepLine = {
156
151
  id: string;
157
152
  label: string;
158
153
  status: "running" | "done" | "failed";
159
154
  detail: string;
160
155
  };
156
+ import { nonRepoSourceMessage } from '../source.js';
@@ -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;
@@ -402,6 +410,23 @@ export function getStartFrom(runid: string, opts?: {
402
410
  ok: false;
403
411
  error: string;
404
412
  } | null;
413
+ /**
414
+ * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
415
+ * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
416
+ * it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
417
+ * spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
418
+ * @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
419
+ * @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
420
+ */
421
+ export function listReuseJobs(opts?: {
422
+ home?: string;
423
+ model?: string;
424
+ }): {
425
+ job: string;
426
+ runid: string;
427
+ checkType: "deterministic" | "rubric";
428
+ line: string;
429
+ }[];
405
430
  /**
406
431
  * Reuse workflow, for an IMPORTED job: the bundle is read AGAIN now and must be clean (`readBundle` — its own hash
407
432
  * covers every close script, so a swapped script is `bundle-tampered` here), must resolve its own `bareloop`
@@ -487,7 +512,8 @@ export function handleRequest(req: import("node:http").IncomingMessage, res: imp
487
512
  * this package's own `bin/bareloop.mjs`).
488
513
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
489
514
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
490
- * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string }} [opts]
515
+ * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
516
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
491
517
  * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
492
518
  */
493
519
  export function createPanelServer(opts?: {
@@ -500,6 +526,8 @@ export function createPanelServer(opts?: {
500
526
  fetchImpl?: typeof fetch;
501
527
  settleMs?: number;
502
528
  userHome?: string;
529
+ openFolderSpawn?: (cmd: string, args: string[], o: object) => any;
530
+ platform?: string;
503
531
  }): Promise<{
504
532
  server: import("node:http").Server;
505
533
  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
  };
@@ -14,6 +14,26 @@ export function statusFor(o: {
14
14
  sign: string;
15
15
  word: string;
16
16
  };
17
+ /**
18
+ * The word one PART of a run wears on the step map, the step cards and the Audit rows (hamr 2026-10-07: `[✓] passed` /
19
+ * `[✗] failed`, never "done"). Reads the same table; `running` is the page's call (it knows which part is live), so a
20
+ * part with no verdict and no cut-off is `passed` here.
21
+ * drafting, green/satisfied/already-green outcome, or a part that simply happened (scout/plan/replan/judge) -> passed
22
+ * a red outcome -> its run-outcome word (failed; a cap/stop outcome keeps capped/stopped)
23
+ * a part its leg's halt cut off (`stopReason`): 'money cap' / 'time cap' -> capped, 'you stopped it' -> stopped,
24
+ * 'died' -> died, anything else (provider failed, step stalled, a raw outcome) -> failed
25
+ * @param {{kind?: string, outcome?: string|null, stopReason?: string|null}} part
26
+ * @returns {{key: StatusKey, sign: string, word: string}}
27
+ */
28
+ export function partStatusFor(part: {
29
+ kind?: string;
30
+ outcome?: string | null;
31
+ stopReason?: string | null;
32
+ }): {
33
+ key: StatusKey;
34
+ sign: string;
35
+ word: string;
36
+ };
17
37
  /** the sentence a passed run carries as its reason (the Ended block's `line`) */
18
38
  export const GOAL_MET_LINE: "goal met";
19
39
  /** @typedef {'running'|'waiting'|'passed'|'failed'|'capped'|'stopped'|'died'} StatusKey */