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.
- package/CHANGELOG.md +58 -0
- package/bareloop.context.md +276 -20
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +17 -12
- package/src/draftspend.js +197 -0
- package/src/exits.js +10 -1
- package/src/index.js +3 -3
- package/src/interviewrun.js +7 -7
- package/src/job.js +37 -0
- package/src/judged.js +2 -2
- package/src/kinds.js +2 -1
- package/src/ledger.js +101 -5
- package/src/legs.js +171 -0
- package/src/monthly.js +111 -72
- package/src/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +84 -14
- package/src/panel/authorsession.js +490 -125
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1194 -213
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +964 -79
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- package/src/ralph.js +4 -1
- package/src/replay.js +139 -19
- package/src/replayio.js +11 -4
- package/src/reuse.js +20 -3
- package/src/run.js +6 -3
- package/src/runlist.js +75 -7
- package/src/source.js +43 -4
- package/src/text.js +20 -0
- package/src/u-patient.js +3 -2
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +192 -65
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +141 -0
- package/types/job.d.ts +25 -0
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +23 -18
- package/types/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +23 -1
- package/types/panel/authorsession.d.ts +110 -2
- package/types/panel/importroutes.d.ts +129 -0
- package/types/panel/importrun.d.ts +77 -0
- package/types/panel/runroutes.d.ts +32 -0
- package/types/panel/server.d.ts +266 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +12 -0
- package/types/ralph.d.ts +19 -0
- package/types/replay.d.ts +25 -1
- package/types/replayio.d.ts +2 -2
- package/types/reuse.d.ts +5 -0
- package/types/run.d.ts +6 -2
- package/types/runlist.d.ts +52 -2
- package/types/source.d.ts +13 -5
- package/types/text.d.ts +19 -0
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
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
|
+
}
|
|
@@ -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
|
/**
|
package/types/legs.d.ts
ADDED
|
@@ -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
|
+
};
|
package/types/monthly.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|