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/CHANGELOG.md +27 -0
- package/bareloop.context.md +36 -8
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +4 -8
- package/src/draftspend.js +116 -1
- package/src/index.js +3 -3
- package/src/legs.js +1 -1
- package/src/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorsession.js +87 -17
- package/src/panel/index.html +137 -43
- package/src/panel/server.js +130 -14
- package/src/panel/settingsroutes.js +52 -2
- package/src/ralph.js +1 -1
- package/src/source.js +43 -4
- package/src/u-patient.js +3 -2
- package/src/userrun.js +56 -10
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +88 -0
- package/types/legs.d.ts +1 -1
- package/types/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorsession.d.ts +2 -1
- package/types/panel/server.d.ts +13 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/ralph.d.ts +19 -0
- package/types/source.d.ts +13 -5
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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'),
|
package/src/worktree.js
ADDED
|
@@ -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
|
+
}
|
package/types/draftspend.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/types/panel/server.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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:
|
|
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:
|
|
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;
|
package/types/userrun.d.ts
CHANGED
|
@@ -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;
|