bareloop 0.34.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bareloop.context.md +276 -20
  3. package/package.json +1 -1
  4. package/src/authorrun.js +3 -2
  5. package/src/bundlerun.js +17 -12
  6. package/src/draftspend.js +197 -0
  7. package/src/exits.js +10 -1
  8. package/src/index.js +3 -3
  9. package/src/interviewrun.js +7 -7
  10. package/src/job.js +37 -0
  11. package/src/judged.js +2 -2
  12. package/src/kinds.js +2 -1
  13. package/src/ledger.js +101 -5
  14. package/src/legs.js +171 -0
  15. package/src/monthly.js +111 -72
  16. package/src/movefile.js +22 -0
  17. package/src/npminstall.js +55 -0
  18. package/src/panel/authorroutes.js +84 -14
  19. package/src/panel/authorsession.js +490 -125
  20. package/src/panel/importroutes.js +359 -0
  21. package/src/panel/importrun.js +203 -0
  22. package/src/panel/index.html +1194 -213
  23. package/src/panel/runroutes.js +219 -0
  24. package/src/panel/server.js +964 -79
  25. package/src/panel/settingsroutes.js +52 -2
  26. package/src/panel/status.js +51 -0
  27. package/src/planrun.js +60 -26
  28. package/src/providerrows.js +17 -0
  29. package/src/ralph.js +4 -1
  30. package/src/replay.js +139 -19
  31. package/src/replayio.js +11 -4
  32. package/src/reuse.js +20 -3
  33. package/src/run.js +6 -3
  34. package/src/runlist.js +75 -7
  35. package/src/source.js +43 -4
  36. package/src/text.js +20 -0
  37. package/src/u-patient.js +3 -2
  38. package/src/u-readout.js +9 -1
  39. package/src/u-watchdog.mjs +4 -2
  40. package/src/userrun.js +192 -65
  41. package/src/worktree.js +120 -0
  42. package/types/draftspend.d.ts +141 -0
  43. package/types/job.d.ts +25 -0
  44. package/types/ledger.d.ts +44 -0
  45. package/types/legs.d.ts +103 -0
  46. package/types/monthly.d.ts +23 -18
  47. package/types/movefile.d.ts +9 -0
  48. package/types/npminstall.d.ts +30 -0
  49. package/types/panel/authorroutes.d.ts +23 -1
  50. package/types/panel/authorsession.d.ts +110 -2
  51. package/types/panel/importroutes.d.ts +129 -0
  52. package/types/panel/importrun.d.ts +77 -0
  53. package/types/panel/runroutes.d.ts +32 -0
  54. package/types/panel/server.d.ts +266 -2
  55. package/types/panel/settingsroutes.d.ts +6 -1
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/planrun.d.ts +13 -21
  58. package/types/providerrows.d.ts +12 -0
  59. package/types/ralph.d.ts +19 -0
  60. package/types/replay.d.ts +25 -1
  61. package/types/replayio.d.ts +2 -2
  62. package/types/reuse.d.ts +5 -0
  63. package/types/run.d.ts +6 -2
  64. package/types/runlist.d.ts +52 -2
  65. package/types/source.d.ts +13 -5
  66. package/types/text.d.ts +19 -0
  67. package/types/userrun.d.ts +10 -0
  68. package/types/worktree.d.ts +48 -0
@@ -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
+ }
@@ -0,0 +1,141 @@
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;
16
+ /**
17
+ * @typedef {object} DraftSpend
18
+ * @property {string} sessionId
19
+ * @property {number} spentUsd the known floor of the session's drafting spend
20
+ * @property {boolean} spendComplete false = `spentUsd` is a floor ("at least")
21
+ * @property {string|null} provider the worker's provider shape (`anthropic-api` | `openai-api` | ...)
22
+ * @property {string|null} baseUrl
23
+ * @property {string|null} model
24
+ * @property {string} startedAt ISO, the first metered call
25
+ * @property {string} updatedAt ISO, the latest metered call
26
+ */
27
+ /**
28
+ * (Re)write `<dir>/draft-spend.json` atomically (tmp + rename).
29
+ * @param {string} dir the session's folder
30
+ * @param {DraftSpend} rec
31
+ * @returns {void}
32
+ */
33
+ export function writeDraftSpend(dir: string, rec: DraftSpend): void;
34
+ /**
35
+ * Every session folder's drafting spend that never became a run.
36
+ * @param {{ home?: string, sessionsRoot?: string }} [opts] `home` = the bareloop config dir (as everywhere in src/monthly.js); the sessions live in `<home>/panel-sessions` unless `sessionsRoot` names them
37
+ * @returns {DraftSpend[]}
38
+ */
39
+ export function readOrphanDraftSpends(opts?: {
40
+ home?: string;
41
+ sessionsRoot?: string;
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;
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
+ };
117
+ export type DraftSpend = {
118
+ sessionId: string;
119
+ /**
120
+ * the known floor of the session's drafting spend
121
+ */
122
+ spentUsd: number;
123
+ /**
124
+ * false = `spentUsd` is a floor ("at least")
125
+ */
126
+ spendComplete: boolean;
127
+ /**
128
+ * the worker's provider shape (`anthropic-api` | `openai-api` | ...)
129
+ */
130
+ provider: string | null;
131
+ baseUrl: string | null;
132
+ model: string | null;
133
+ /**
134
+ * ISO, the first metered call
135
+ */
136
+ startedAt: string;
137
+ /**
138
+ * ISO, the latest metered call
139
+ */
140
+ updatedAt: string;
141
+ };
package/types/job.d.ts CHANGED
@@ -93,6 +93,17 @@ export function checkMenu(close: any[]): {
93
93
  * @returns {string} sha256 hex
94
94
  */
95
95
  export function jobSpecHash(job: object): string;
96
+ /**
97
+ * The identity of a WORKFLOW (Reuse workflow, hamr 2026-10-03): sha256 over the signed spec WITHOUT the fields a
98
+ * reuse may change ({@link REUSE_OPEN_SPEC_FIELDS}) and the worker identity ({@link WORKFLOW_KEY_IDENTITY_FIELDS}). Two runs
99
+ * share a workflow key iff they ran the same goal, checks, guardrails and close — whatever source they ran on, wherever they wrote, whatever caps they had.
100
+ * It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
101
+ * a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
102
+ * the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
103
+ * @param {object} job
104
+ * @returns {string} sha256 hex
105
+ */
106
+ export function workflowKey(job: object): string;
96
107
  /**
97
108
  * Does an approval record cover this exact spec version? Pure predicate — the
98
109
  * N2 runner refuses to run without it; nothing here writes or prompts. The
@@ -259,6 +270,20 @@ export const PROVIDERS: readonly string[];
259
270
  * candidate at N3. `provider` is part of the key by definition (top-level,
260
271
  * not duplicated here). */
261
272
  export const CONDITION_KEYS: readonly string[];
273
+ /**
274
+ * The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
275
+ * `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
276
+ * the WORKFLOW — goal, checks, guardrails, close (the worker model is the third kind, {@link WORKFLOW_KEY_IDENTITY_FIELDS}). ONE list, shared by {@link workflowKey} and by the panel's
277
+ * reuse (so what the key ignores is exactly what the person may edit, never two spellings).
278
+ */
279
+ export const REUSE_OPEN_SPEC_FIELDS: readonly string[];
280
+ /**
281
+ * The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
282
+ * chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
283
+ * signs still covers the model the run will use. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS} on purpose: those are the
284
+ * fields the page's boxes edit directly; these three change together, as one Settings row.
285
+ */
286
+ export const WORKFLOW_KEY_IDENTITY_FIELDS: readonly string[];
262
287
  /**
263
288
  * — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
264
289
  * `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
package/types/ledger.d.ts CHANGED
@@ -95,6 +95,50 @@ export function floorsFromRecords(records: any[]): {
95
95
  spendFloorUsd: number | null;
96
96
  wallFloorMs: number | null;
97
97
  };
98
+ /**
99
+ * One run's own spend, off its spine records: this LEG's figure (never the chain fold),
100
+ * plus the drafting spend the run was signed with (counted once — on the first leg only).
101
+ * @param {any[]} records ONE leg's records
102
+ * @param {{ withDraft?: boolean }} [o] `withDraft: false` leaves the drafting spend out (a caller folding the CHAIN's own spend, which never held it)
103
+ * @returns {{ usd: number, complete: boolean }}
104
+ */
105
+ export function legSpend(records: any[], { withDraft }?: {
106
+ withDraft?: boolean;
107
+ }): {
108
+ usd: number;
109
+ complete: boolean;
110
+ };
111
+ /**
112
+ * A WHOLE RUN's own spend (P5-R: a resumed run is one spine of several legs): the sum of each leg's own
113
+ * figure ({@link legSpend}), never the declared chain fold. ONE basis — every leg's own rounds — so a leg is
114
+ * counted once: the last leg's `job-end.spentUsd` also carries the earlier legs, and adding it to the
115
+ * earlier legs' own figures would count them twice. A leg with no terminal (it died) is a floor, so the run
116
+ * is `complete: false`. A spine with no marker is one leg and reads exactly as {@link legSpend} always did.
117
+ * @param {any[]} records one spine's records
118
+ * @param {{ withDraft?: boolean }} [o]
119
+ * @returns {{ usd: number, complete: boolean, lastLegUsd: number }} `lastLegUsd`: the latest leg's own share (what a claim still held for it is measured against)
120
+ */
121
+ export function runSpend(records: any[], o?: {
122
+ withDraft?: boolean;
123
+ }): {
124
+ usd: number;
125
+ complete: boolean;
126
+ lastLegUsd: number;
127
+ };
128
+ /**
129
+ * What a FINISHED run's whole chain spent, for a caller that folds it forward (the review door's rerun opens a
130
+ * new run under the same signed ceiling). One basis per shape: a spine of ONE leg is read by its terminal's own
131
+ * chain total (`job-end.spentUsd`, which already carries whatever fold that leg inherited — what every door has
132
+ * always read); a resumed run's several legs are the first leg's own inherited fold (spend the file does not
133
+ * hold) plus each leg's OWN spend, so a later leg's terminal total — which also carries the earlier legs — is
134
+ * never added on top of them. Not finite / no terminal = `usd: 0, complete: false` (never an exact-looking 0).
135
+ * @param {any[]} records one spine's records
136
+ * @returns {{ usd: number, complete: boolean }}
137
+ */
138
+ export function chainSpend(records: any[]): {
139
+ usd: number;
140
+ complete: boolean;
141
+ };
98
142
  /** severity order, worst-first (the fold's display order; frequency ranks within) */
99
143
  export const LEDGER_CLASSES: readonly string[];
100
144
  /**
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Split one spine's parsed records into its legs. Pure.
3
+ * @param {any[]} events one spine's parsed records, in file order
4
+ * @returns {Leg[]} always at least one leg for a non-empty list; `[]` for an empty one
5
+ */
6
+ export function legsOf(events: any[]): Leg[];
7
+ /**
8
+ * The legs' WORKING time, gap excluded (hamr 2026-10-02, 2A: only working time charges the wall). Each leg
9
+ * counts first record → last record; the quiet time between one leg's end and the next leg's marker is
10
+ * nobody's. `null` = unknown (no leg carries two parseable stamps) — never 0. A leg whose stamps are
11
+ * unreadable adds nothing, so the figure is a FLOOR, `complete: false`.
12
+ * @param {Leg[]} legs
13
+ * @returns {{ ms: number|null, complete: boolean }}
14
+ */
15
+ export function legsWallMs(legs: Leg[]): {
16
+ ms: number | null;
17
+ complete: boolean;
18
+ };
19
+ /**
20
+ * Parse a spine's TEXT, tolerant exactly where the writer leaves damage. A kill mid-append leaves a torn
21
+ * line; a resume never edits it (the spine is append-only forever) — it writes one `\n` and starts a fresh
22
+ * line, so the torn bytes end up alone on their own line, immediately BEFORE a `leg-resume` marker. That
23
+ * line, and the file's own last two lines (the older tail rule: a kill mid-append of the file's final
24
+ * record), are tolerated and named. An unparseable line anywhere else is `corrupt`: a reconstruction from
25
+ * it would invent a history.
26
+ * @param {string} raw
27
+ * @returns {{ records: any[], tolerated: {line: number, why: 'tail'|'before-leg-resume'}[], corrupt: {line: number, message: string}|null }}
28
+ * `line` is 1-based. Only the FIRST corrupt line is reported.
29
+ */
30
+ export function parseSpineText(raw: string): {
31
+ records: any[];
32
+ tolerated: {
33
+ line: number;
34
+ why: "tail" | "before-leg-resume";
35
+ }[];
36
+ corrupt: {
37
+ line: number;
38
+ message: string;
39
+ } | null;
40
+ };
41
+ /**
42
+ * Where leg `leg`'s watchdog kill note lives. Leg 1 keeps the name every run has always had
43
+ * (`<spine>.watchdog.json`); a resumed leg's note sits beside it (`<spine>.leg<N>.watchdog.json`) so one
44
+ * leg never overwrites another's evidence of how it died.
45
+ * @param {string} spineFile
46
+ * @param {number} leg 1-based
47
+ * @returns {string}
48
+ */
49
+ export function watchdogNotePath(spineFile: string, leg: number): string;
50
+ /**
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 round seam of a step worker (src/planrun.js `metered`) and consumes it. One spelling, here, so the route
53
+ * and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
54
+ * when the leg ends, and again at the start of the next leg (src/userrun.js).
55
+ * @param {string} spineFile
56
+ * @returns {string}
57
+ */
58
+ export function stopFilePath(spineFile: string): string;
59
+ /** The marker record's `type`. */
60
+ export const LEG_RESUME: "leg-resume";
61
+ export type Leg = {
62
+ /**
63
+ * 1-based position in file order (the marker's own `leg` field is never trusted over the order)
64
+ */
65
+ leg: number;
66
+ /**
67
+ * the leg's first record (the `leg-resume` marker for leg 2+; the file's first record for leg 1)
68
+ */
69
+ start: any | null;
70
+ /**
71
+ * the leg's last record
72
+ */
73
+ end: any | null;
74
+ /**
75
+ * every record of the leg, in file order, marker included
76
+ */
77
+ records: any[];
78
+ /**
79
+ * the leg's own `job-start` (null: it ended before runJob wrote one)
80
+ */
81
+ jobStart: any | null;
82
+ /**
83
+ * the leg's own LAST `job-end` (null: it died, or it is still running)
84
+ */
85
+ jobEnd: any | null;
86
+ /**
87
+ * the leg's terminal: its `job-end` outcome, else a `run-end` outcome (a $0 refusal), else null
88
+ */
89
+ outcome: string | null;
90
+ /**
91
+ * leg 1: null. Leg 2+: how the PREVIOUS leg ended, as the marker declares it
92
+ * (else derived from that leg's own terminal), `'died'` when it recorded none
93
+ */
94
+ after: string | null;
95
+ /**
96
+ * `Date.parse` of the start record's `ts`
97
+ */
98
+ startMs: number | null;
99
+ /**
100
+ * `Date.parse` of the end record's `ts`
101
+ */
102
+ endMs: number | null;
103
+ };
@@ -1,18 +1,8 @@
1
- /**
2
- * One run's own spend, off its spine records: this LEG's figure (never the chain fold),
3
- * plus the drafting spend the run was signed with (counted once — on the first leg only).
4
- * @param {any[]} records
5
- * @returns {{ usd: number, complete: boolean }}
6
- */
7
- export function legSpend(records: any[]): {
8
- usd: number;
9
- complete: boolean;
10
- };
11
1
  /**
12
2
  * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
13
3
  * first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
14
4
  * `floorsFromRecords`). `ms` null = unknown — never 0.
15
- * @param {any[]} records
5
+ * @param {any[]} records one spine's records (one leg or several)
16
6
  * @returns {{ ms: number|null, complete: boolean }}
17
7
  */
18
8
  export function legWall(records: any[]): {
@@ -29,21 +19,23 @@ export function legTokens(records: any[]): number;
29
19
  /**
30
20
  * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
31
21
  * and the per-provider figures share.
32
- * @param {{ home?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
22
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `sessionsRoot` = where the panel's sessions live (default `<home>/panel-sessions`); `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
33
23
  * @returns {Leg[]}
34
24
  */
35
25
  export function readLegs(opts?: {
36
26
  home?: string;
27
+ sessionsRoot?: string;
37
28
  now?: () => number;
38
29
  aboveRunid?: string;
39
30
  thisMonthOnly?: boolean;
40
31
  }): Leg[];
41
32
  /**
42
- * @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
33
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`, `sessionsRoot`: see {@link readLegs}
43
34
  * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
44
35
  */
45
36
  export function monthSpend(opts?: {
46
37
  home?: string;
38
+ sessionsRoot?: string;
47
39
  now?: () => number;
48
40
  aboveRunid?: string;
49
41
  }): {
@@ -59,12 +51,13 @@ export function monthSpend(opts?: {
59
51
  * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
60
52
  * pooled into another row). Any leg with unknown spend makes the figure it belongs to
61
53
  * an "at least".
62
- * @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
54
+ * @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
63
55
  * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
64
56
  * byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
65
57
  */
66
58
  export function spendSummary(opts?: {
67
59
  home?: string;
60
+ sessionsRoot?: string;
68
61
  now?: () => number;
69
62
  rows?: readonly import("./providerrows.js").KeyRow[];
70
63
  }): {
@@ -117,12 +110,13 @@ export function monthlyLimitOf(config: Record<string, any>, home?: string): numb
117
110
  * number above 0 throws `ConfigError`.
118
111
  * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
119
112
  * whose own instrument is broken refuses; it never silently runs with no limit.
120
- * @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
113
+ * @param {{ capUsd: number, home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
121
114
  * @returns {MonthlyRoom}
122
115
  */
123
- export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }: {
116
+ export function checkMonthlyRoom({ capUsd, home, now, aboveRunid, sessionsRoot }: {
124
117
  capUsd: number;
125
118
  home?: string;
119
+ sessionsRoot?: string;
126
120
  now?: () => number;
127
121
  aboveRunid?: string;
128
122
  }): MonthlyRoom;
@@ -154,14 +148,17 @@ export function settleDeadClaims({ home, by, aboveRunid }: {
154
148
  * it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
155
149
  * no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
156
150
  * that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
157
- * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
151
+ * A RESUMED leg (`leg` 2+, P5-R) claims the same way but appends a `leg-start` beside the run's one row
152
+ * (the row itself is added only when the list has none), and a refusal gives back THAT leg's claim.
153
+ * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number, leg?: number }} args
158
154
  * @returns {{ room: MonthlyRoom, claimed: boolean }}
159
155
  */
160
- export function claimRun({ row, capUsd, home, now }: {
156
+ export function claimRun({ row, capUsd, home, now, leg }: {
161
157
  row: import("./runlist.js").RunRow;
162
158
  capUsd: number;
163
159
  home?: string;
164
160
  now?: () => number;
161
+ leg?: number;
165
162
  }): {
166
163
  room: MonthlyRoom;
167
164
  claimed: boolean;
@@ -213,6 +210,10 @@ export type Leg = {
213
210
  * the spine was missing/unreadable or the listed date was bad (spend unknown)
214
211
  */
215
212
  unreadable: boolean;
213
+ /**
214
+ * drafting spend of a panel session that never became a run (src/draftspend.js) — not a run: no wall, not counted in `runs`
215
+ */
216
+ draft?: boolean | undefined;
216
217
  };
217
218
  export type MonthlyRoom = {
218
219
  ok: boolean;
@@ -237,3 +238,7 @@ export type MonthlyRoom = {
237
238
  */
238
239
  heldRuns?: number | undefined;
239
240
  };
241
+ import { legSpend } from './ledger.js';
242
+ import { runSpend } from './ledger.js';
243
+ import { chainSpend } from './ledger.js';
244
+ export { legSpend, runSpend, chainSpend };
@@ -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';
@@ -40,7 +40,8 @@ export function checkHumanGuard(req: import("node:http").IncomingMessage, { toke
40
40
  * deliberately has none of).
41
41
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
42
42
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
43
- * jobsDir?: string, fetchImpl?: typeof fetch, home?: string }} opts
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
45
  */
45
46
  export function createAuthorRoutes(opts: {
46
47
  port: number;
@@ -52,10 +53,31 @@ export function createAuthorRoutes(opts: {
52
53
  jobsDir?: string;
53
54
  fetchImpl?: typeof fetch;
54
55
  home?: string;
56
+ startFrom?: {
57
+ get: (runid: string, o?: {
58
+ model?: string;
59
+ }) => any;
60
+ getImport: (id: string, o?: {
61
+ model?: string;
62
+ }) => any;
63
+ };
55
64
  }): {
56
65
  handle: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, pathname: string, body: any) => boolean;
57
66
  sessions: Map<string, any>;
58
67
  };
68
+ /**
69
+ * The panel's OWN 2-decimal money render for a chat line — hamr's ruling
70
+ * 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
71
+ * a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
72
+ * free). This is the server-side (Node, chat-text) sibling of
73
+ * `src/panel/index.html`'s own inline `panelMoney` — the two can't share
74
+ * code (one runs in the browser as inlined script, the other in Node), the
75
+ * same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
76
+ * already carry, so both apply the identical rule rather than drift.
77
+ * @param {number} n a non-negative dollar amount
78
+ * @returns {string}
79
+ */
80
+ export function panelMoney2(n: number): string;
59
81
  /**
60
82
  * THE ONLY function in this file (or anywhere in the panel) that spawns a
61
83
  * run. Refuses unless the session is `prepared` AND the hash the request