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/bundlerun.js
CHANGED
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
import { hostname } from 'node:os';
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import {
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
|
|
23
23
|
} from './bundle.js';
|
|
24
24
|
import { jobSpecHash } from './job.js';
|
|
25
|
+
import { addWorktree, worktreePath } from './worktree.js';
|
|
25
26
|
import { parseJsonl } from './replayio.js';
|
|
27
|
+
import { legsOf } from './legs.js';
|
|
26
28
|
import { startRun, resumeRun } from './userrun.js';
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -117,7 +119,9 @@ export async function bundleMain(args, {
|
|
|
117
119
|
// after its own $0 refusals, so a refusal leaves no worktree behind. A RESUME is the one
|
|
118
120
|
// exception: it re-enters the SAME worktree its halted run left (recorded in that run's
|
|
119
121
|
// `run.json`), and a gone tree is a stop, never a silent fresh start.
|
|
120
|
-
|
|
122
|
+
// P5-R (hamr 2026-10-02: one run, one id, one file): a RESUME is the same run — same runid, same run dir, same
|
|
123
|
+
// spine; the engine appends the new leg to it. Only a fresh run mints an id.
|
|
124
|
+
const runid = resumeArg !== undefined ? resumeArg : now().toString(36);
|
|
121
125
|
/** @type {string} */
|
|
122
126
|
let repo;
|
|
123
127
|
/** @type {string} */
|
|
@@ -126,6 +130,8 @@ export async function bundleMain(args, {
|
|
|
126
130
|
let worktree;
|
|
127
131
|
/** @type {string|null} */
|
|
128
132
|
let deadSpine = null;
|
|
133
|
+
/** the run's own first-leg stamp, kept when a later leg rewrites `run.json` (it is the run's start, not the leg's) @type {string|null} */
|
|
134
|
+
let firstAt = null;
|
|
129
135
|
if (resumeArg !== undefined) {
|
|
130
136
|
if (!/^[a-z0-9]+$/.test(resumeArg)) { err(`--resume ${JSON.stringify(resumeArg)} is not a run id of this bundle`); return 1; }
|
|
131
137
|
const deadRunJson = join(bundleDir, 'runs', resumeArg, 'run.json');
|
|
@@ -150,6 +156,7 @@ export async function bundleMain(args, {
|
|
|
150
156
|
repo = rec.repo;
|
|
151
157
|
seed = rec.seed;
|
|
152
158
|
worktree = rec.worktree;
|
|
159
|
+
if (typeof rec.at === 'string') firstAt = rec.at;
|
|
153
160
|
deadSpine = join(bundleDir, 'runs', resumeArg, 'spine.jsonl');
|
|
154
161
|
} else {
|
|
155
162
|
repo = resolve(cwd, /** @type {string} */ (repoArg));
|
|
@@ -159,7 +166,7 @@ export async function bundleMain(args, {
|
|
|
159
166
|
err(`--repo ${repoArg} must be a git repository with at least one commit: ${/** @type {Error} */ (e).message}`);
|
|
160
167
|
return 1;
|
|
161
168
|
}
|
|
162
|
-
worktree =
|
|
169
|
+
worktree = worktreePath(repo, runid);
|
|
163
170
|
if (existsSync(worktree)) { err(`worktree already exists: ${worktree}`); return 1; }
|
|
164
171
|
}
|
|
165
172
|
|
|
@@ -180,16 +187,11 @@ export async function bundleMain(args, {
|
|
|
180
187
|
// at job-end (a killed run has none) and the run list is machine-global.
|
|
181
188
|
const prepareTree = () => {
|
|
182
189
|
if (deadSpine === null) {
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
execFileSync('git', ['-C', repo, 'worktree', 'add', '--detach', worktree, 'HEAD'], { encoding: 'utf8' });
|
|
186
|
-
} catch (e) {
|
|
187
|
-
throw new Error(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
|
|
188
|
-
}
|
|
190
|
+
addWorktree(repo, worktree);
|
|
189
191
|
}
|
|
190
192
|
mkdirSync(runDir, { recursive: true });
|
|
191
193
|
writeFileSync(join(runDir, 'run.json'), `${JSON.stringify({
|
|
192
|
-
runid, worktree, seed, repo, at: new Date(now()).toISOString(),
|
|
194
|
+
runid, worktree, seed, repo, at: firstAt ?? new Date(now()).toISOString(),
|
|
193
195
|
}, null, 2)}\n`);
|
|
194
196
|
};
|
|
195
197
|
|
|
@@ -227,7 +229,9 @@ export async function bundleMain(args, {
|
|
|
227
229
|
// 7. history + (on a first green) the blessing — off THIS run's own spine, tolerant of
|
|
228
230
|
// a torn line. No job-end (a $0 refusal, a crash) means no history row, as ever.
|
|
229
231
|
const events = existsSync(spineFile) ? parseJsonl(spineFile).records : [];
|
|
230
|
-
|
|
232
|
+
// the LATEST leg's terminal: an earlier leg's job-end on the same file is a row already written for it
|
|
233
|
+
const legs = legsOf(events);
|
|
234
|
+
const je = legs.at(-1)?.jobEnd ?? null;
|
|
231
235
|
if (!je) return engineCode;
|
|
232
236
|
const outcome = je.outcome;
|
|
233
237
|
const spentUsd = je.spentUsd ?? null;
|
|
@@ -246,7 +250,8 @@ export async function bundleMain(args, {
|
|
|
246
250
|
branch,
|
|
247
251
|
bundleHash: manifest.bundleHash,
|
|
248
252
|
approveHash,
|
|
249
|
-
|
|
253
|
+
// one row per LEG of the one run; the first leg's row keeps the shape it always had
|
|
254
|
+
...(legs.length > 1 ? { leg: legs.length } : {}),
|
|
250
255
|
});
|
|
251
256
|
if (outcome === 'green' && !bundle.blessing) {
|
|
252
257
|
bless(bundleDir, {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Drafting spend that never became a run (self-review 2026-10-05, hamr's ruling A).
|
|
2
|
+
//
|
|
3
|
+
// A panel authoring session pays for model calls (survey, declaration, confirm turn) BEFORE any run exists.
|
|
4
|
+
// That spend rode only in memory (`state.draftSpentUsd`) and reached disk when the session was signed and run
|
|
5
|
+
// (`--draft-spent-usd` -> the run's spine). Abandon, a refusal, or a panel restart lost it, while the monthly
|
|
6
|
+
// limit and the Money tab never counted it. So the session writes `<session dir>/draft-spend.json` after EVERY
|
|
7
|
+
// metered call (one owner: the session's `onCall`, src/panel/authorsession.js), and the money readers
|
|
8
|
+
// (`readLegs`, src/monthly.js) add every session folder that carries one and NEVER became a run.
|
|
9
|
+
//
|
|
10
|
+
// "Became a run" = some run-list row's `spine` or `patient` path sits inside that session's folder: a signed run
|
|
11
|
+
// already carries its drafting spend through `--draft-spent-usd`, so counting the file too would double it.
|
|
12
|
+
// Month attribution is the file's `startedAt` (the first metered call): fixed once written, so a session's
|
|
13
|
+
// figure never moves between months as spend continues, the same start-date rule a run's leg uses.
|
|
14
|
+
// `spendComplete: false` = "at least", never $0 (F6).
|
|
15
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join, resolve, sep } from 'node:path';
|
|
17
|
+
import { readRunList, runlistHome } from './runlist.js';
|
|
18
|
+
|
|
19
|
+
export const DRAFT_SPEND_FILE = 'draft-spend.json';
|
|
20
|
+
/** 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}. */
|
|
21
|
+
export const DRAFT_LOG_FILE = 'draft-log.jsonl';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
|
|
25
|
+
* - `step-start` {no, id, label, at}
|
|
26
|
+
* - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
|
|
27
|
+
* - `call` {no, step, label, model, costUsd (null stays null), unpricedRounds, at}: one METERED model call
|
|
28
|
+
* (no token counts: the call path does not carry them without reworking the library, so none are recorded)
|
|
29
|
+
* @typedef {{kind: string, no: number, at: string, [k: string]: any}} DraftEvent
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Append one event to `<dir>/draft-log.jsonl`. Best-effort like draft-spend.json: a disk fault never stops the draft.
|
|
34
|
+
* @param {string} dir the session's folder
|
|
35
|
+
* @param {DraftEvent} ev
|
|
36
|
+
* @returns {void}
|
|
37
|
+
*/
|
|
38
|
+
export function appendDraftLog(dir, ev) {
|
|
39
|
+
try { appendFileSync(join(dir, DRAFT_LOG_FILE), `${JSON.stringify(ev)}\n`); } catch { /* money already spent stays in memory */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} DraftSpend
|
|
44
|
+
* @property {string} sessionId
|
|
45
|
+
* @property {number} spentUsd the known floor of the session's drafting spend
|
|
46
|
+
* @property {boolean} spendComplete false = `spentUsd` is a floor ("at least")
|
|
47
|
+
* @property {string|null} provider the worker's provider shape (`anthropic-api` | `openai-api` | ...)
|
|
48
|
+
* @property {string|null} baseUrl
|
|
49
|
+
* @property {string|null} model
|
|
50
|
+
* @property {string} startedAt ISO, the first metered call
|
|
51
|
+
* @property {string} updatedAt ISO, the latest metered call
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* (Re)write `<dir>/draft-spend.json` atomically (tmp + rename).
|
|
56
|
+
* @param {string} dir the session's folder
|
|
57
|
+
* @param {DraftSpend} rec
|
|
58
|
+
* @returns {void}
|
|
59
|
+
*/
|
|
60
|
+
export function writeDraftSpend(dir, rec) {
|
|
61
|
+
const file = join(dir, DRAFT_SPEND_FILE);
|
|
62
|
+
const tmp = `${file}.tmp`;
|
|
63
|
+
writeFileSync(tmp, `${JSON.stringify(rec, null, 2)}\n`);
|
|
64
|
+
renameSync(tmp, file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {string} p @param {string} dir @returns {boolean} */
|
|
68
|
+
const inside = (p, dir) => typeof p === 'string' && (resolve(p) === dir || resolve(p).startsWith(dir + sep));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every session folder's drafting spend that never became a run.
|
|
72
|
+
* @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
|
|
73
|
+
* @returns {DraftSpend[]}
|
|
74
|
+
*/
|
|
75
|
+
export function readOrphanDraftSpends(opts = {}) {
|
|
76
|
+
const root = resolve(opts.sessionsRoot ?? join(runlistHome(opts.home), 'panel-sessions'));
|
|
77
|
+
if (!existsSync(root)) return [];
|
|
78
|
+
/** @type {string[]} */
|
|
79
|
+
let names;
|
|
80
|
+
try { names = readdirSync(root); } catch { return []; }
|
|
81
|
+
const { rows } = readRunList({ home: opts.home });
|
|
82
|
+
/** @type {DraftSpend[]} */
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const dir = join(root, name);
|
|
86
|
+
const file = join(dir, DRAFT_SPEND_FILE);
|
|
87
|
+
if (!existsSync(file)) continue;
|
|
88
|
+
if (rows.some((r) => inside(r.spine, dir) || (r.patient && inside(r.patient, dir)))) continue;
|
|
89
|
+
try {
|
|
90
|
+
const j = JSON.parse(readFileSync(file, 'utf8'));
|
|
91
|
+
if (!j || typeof j !== 'object' || !Number.isFinite(j.spentUsd)) throw new Error('unreadable');
|
|
92
|
+
out.push({
|
|
93
|
+
sessionId: String(j.sessionId ?? name), spentUsd: j.spentUsd, spendComplete: j.spendComplete === true,
|
|
94
|
+
provider: typeof j.provider === 'string' ? j.provider : null, baseUrl: typeof j.baseUrl === 'string' ? j.baseUrl : null,
|
|
95
|
+
model: typeof j.model === 'string' ? j.model : null, startedAt: String(j.startedAt), updatedAt: String(j.updatedAt),
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
// a file that names money but cannot be read is unknown spend: an "at least" of $0, dated unknown
|
|
99
|
+
out.push({ sessionId: name, spentUsd: 0, spendComplete: false, provider: null, baseUrl: null, model: null, startedAt: 'unreadable', updatedAt: 'unreadable' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The session folder a run-list row became a run in: the folder under `<home>/panel-sessions` that carries a drafting
|
|
107
|
+
* log and holds the row's `spine` or `patient` ("became a run", the same rule as {@link readOrphanDraftSpends}).
|
|
108
|
+
* A Reuse-workflow run, a CLI run and an imported run sit in no such folder.
|
|
109
|
+
* @param {{spine?: string, patient?: string|null}} row
|
|
110
|
+
* @param {{ home?: string, sessionsRoot?: string }} [opts]
|
|
111
|
+
* @returns {string|null}
|
|
112
|
+
*/
|
|
113
|
+
export function sessionDirForRow(row, opts = {}) {
|
|
114
|
+
const root = resolve(opts.sessionsRoot ?? join(runlistHome(opts.home), 'panel-sessions'));
|
|
115
|
+
/** @type {string[]} */
|
|
116
|
+
let names;
|
|
117
|
+
try { names = readdirSync(root); } catch { return null; }
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
const dir = join(root, name);
|
|
120
|
+
if (!existsSync(join(dir, DRAFT_LOG_FILE))) continue;
|
|
121
|
+
if (inside(/** @type {string} */ (row.spine), dir) || (row.patient && inside(row.patient, dir))) return dir;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fold `<dir>/draft-log.jsonl` into one record per drafting step, each with its calls. A line that cannot be parsed is
|
|
128
|
+
* skipped (a torn last line must not hide the rest).
|
|
129
|
+
* @param {string} dir
|
|
130
|
+
* @returns {{no: number, id: string, label: string, status: 'running'|'done'|'failed', startedAt: string, endedAt: string|null, wallMs: number|null,
|
|
131
|
+
* calls: {label: string, model: string|null, costUsd: number|null, unpricedRounds: number, at: string}[]}[]}
|
|
132
|
+
*/
|
|
133
|
+
export function readDraftSteps(dir) {
|
|
134
|
+
/** @type {string} */
|
|
135
|
+
let text;
|
|
136
|
+
try { text = readFileSync(join(dir, DRAFT_LOG_FILE), 'utf8'); } catch { return []; }
|
|
137
|
+
/** @type {Map<number, any>} */
|
|
138
|
+
const byNo = new Map();
|
|
139
|
+
/** @type {any[]} */
|
|
140
|
+
const orphanCalls = [];
|
|
141
|
+
for (const line of text.split('\n')) {
|
|
142
|
+
if (!line.trim()) continue;
|
|
143
|
+
let ev;
|
|
144
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
145
|
+
if (!ev || typeof ev !== 'object' || !Number.isInteger(ev.no)) continue;
|
|
146
|
+
if (ev.kind === 'step-start') {
|
|
147
|
+
byNo.set(ev.no, { no: ev.no, id: String(ev.id), label: String(ev.label), status: 'running', startedAt: ev.at, endedAt: null, wallMs: null, calls: [] });
|
|
148
|
+
} else if (ev.kind === 'step-end' && byNo.has(ev.no)) {
|
|
149
|
+
const x = byNo.get(ev.no);
|
|
150
|
+
x.status = ev.status === 'failed' ? 'failed' : 'done';
|
|
151
|
+
x.endedAt = ev.at;
|
|
152
|
+
if (typeof ev.label === 'string') x.label = ev.label;
|
|
153
|
+
} else if (ev.kind === 'step-reopen' && byNo.has(ev.no)) {
|
|
154
|
+
const x = byNo.get(ev.no);
|
|
155
|
+
x.status = 'running';
|
|
156
|
+
x.endedAt = null;
|
|
157
|
+
} else if (ev.kind === 'call') {
|
|
158
|
+
const c = { label: String(ev.label), model: typeof ev.model === 'string' ? ev.model : null, costUsd: typeof ev.costUsd === 'number' ? ev.costUsd : null, unpricedRounds: Number(ev.unpricedRounds) || 0, at: String(ev.at) };
|
|
159
|
+
(byNo.get(ev.no)?.calls ?? orphanCalls).push(c);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const steps = [...byNo.values()].sort((a, b) => a.no - b.no);
|
|
163
|
+
for (const x of steps) {
|
|
164
|
+
const a = Date.parse(x.startedAt);
|
|
165
|
+
const b = x.endedAt === null ? NaN : Date.parse(x.endedAt);
|
|
166
|
+
x.wallMs = Number.isFinite(a) && Number.isFinite(b) ? Math.max(0, b - a) : null;
|
|
167
|
+
}
|
|
168
|
+
return steps;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 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
|
|
173
|
+
* drafting log. `null` when the run did not come out of a panel session, or the log carries no metered call (a Reuse
|
|
174
|
+
* workflow run, a CLI run, a run older than the log). The part's money is the SPINE's figure (`job-start.draftSpentUsd`,
|
|
175
|
+
* passed in) — the log's calls are shown beside it, never summed into a second total; `null` spine figure = no part.
|
|
176
|
+
* @param {{spine?: string, patient?: string|null}} row
|
|
177
|
+
* @param {{draftSpentUsd: number|null, draftSpendComplete: boolean|null}} spine the run's own drafting figures
|
|
178
|
+
* @param {{ home?: string, sessionsRoot?: string }} [opts]
|
|
179
|
+
* @returns {any|null}
|
|
180
|
+
*/
|
|
181
|
+
export function draftingPartFor(row, spine, opts = {}) {
|
|
182
|
+
if (spine.draftSpentUsd === null) return null;
|
|
183
|
+
const dir = sessionDirForRow(row, opts);
|
|
184
|
+
if (!dir) return null;
|
|
185
|
+
const steps = readDraftSteps(dir);
|
|
186
|
+
const calls = steps.reduce((n, x) => n + x.calls.length, 0);
|
|
187
|
+
if (calls === 0) return null;
|
|
188
|
+
const starts = steps.map((x) => Date.parse(x.startedAt)).filter(Number.isFinite);
|
|
189
|
+
const ends = steps.map((x) => (x.endedAt === null ? NaN : Date.parse(x.endedAt))).filter(Number.isFinite);
|
|
190
|
+
return {
|
|
191
|
+
kind: 'drafting', id: 'drafting', label: 'drafting', occurrence: 1, leg: 1, outcome: null, blocked: 0,
|
|
192
|
+
attempts: [], rounds: calls, toolCalls: 0, byTool: null,
|
|
193
|
+
wallMs: starts.length && ends.length ? Math.max(0, Math.max(...ends) - Math.min(...starts)) : null,
|
|
194
|
+
spentUsd: spine.draftSpentUsd, spendComplete: spine.draftSpendComplete !== false, unpricedRounds: 0,
|
|
195
|
+
draftSteps: steps,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/exits.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// unrunnable check is a stop, never a silent pass).
|
|
24
24
|
|
|
25
25
|
import { createHash } from 'node:crypto';
|
|
26
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
26
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
import { globToPrefix } from './validate.js';
|
|
29
29
|
|
|
@@ -62,6 +62,15 @@ export async function snapshotScope(dir, scope) {
|
|
|
62
62
|
const prefix = globToPrefix(scope);
|
|
63
63
|
/** @type {Map<string, string>} */
|
|
64
64
|
const snap = new Map();
|
|
65
|
+
// A scope whose prefix names a single regular FILE (a file-valued writeScope
|
|
66
|
+
// becomes `src/x.js/**`) snapshots that one file, keyed by the prefix itself —
|
|
67
|
+
// readdir on a file throws ENOTDIR, which read as an empty tree forever (F212).
|
|
68
|
+
try {
|
|
69
|
+
if ((await stat(join(dir, prefix))).isFile()) {
|
|
70
|
+
snap.set(prefix, createHash('sha256').update(await readFile(join(dir, prefix))).digest('hex'));
|
|
71
|
+
return snap;
|
|
72
|
+
}
|
|
73
|
+
} catch { /* missing or unreadable: fall through to the directory path */ }
|
|
65
74
|
/** @type {string[]} */
|
|
66
75
|
let entries;
|
|
67
76
|
try {
|
package/src/index.js
CHANGED
|
@@ -153,9 +153,9 @@ export { answerReviewDoor } from './reviewdoor.js';
|
|
|
153
153
|
// but no longer read by any grading path), the rulebook a card SELECTS from,
|
|
154
154
|
// the card gate, and the two halves themselves so a calibration harness can
|
|
155
155
|
// grade the whole pipe without running a close.
|
|
156
|
-
// `defaultJudgeLoop` is the one spelling of how this repo drives a
|
|
157
|
-
// runner reaches it through `runPlan`'s `judgeProvider
|
|
158
|
-
//
|
|
156
|
+
// `defaultJudgeLoop` (src/judged.js) is the one spelling of how this repo drives a
|
|
157
|
+
// judge; the runner reaches it through `runPlan`'s `judgeProvider`. It is internal:
|
|
158
|
+
// the package root does not export it.
|
|
159
159
|
export {
|
|
160
160
|
JUDGE_MODEL, resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
|
|
161
161
|
runLocate, decide,
|
package/src/interviewrun.js
CHANGED
|
@@ -743,7 +743,7 @@ export async function main(argv, deps = {}) {
|
|
|
743
743
|
say(`It runs under the AUTHORING ceiling (${CEILING_USD === null ? 'UNBOUNDED — you gave no --budget' : `$${CEILING_USD}`}), which is not the job's $${draft.budgetUsd}.`);
|
|
744
744
|
say('It stops at prepareSigning: it never signs, and it never runs the job.');
|
|
745
745
|
} else {
|
|
746
|
-
say('NEXT — running
|
|
746
|
+
say('NEXT — running bareloop author on this source stops right away, at $0: no scout, no confirm turn, no model call.');
|
|
747
747
|
say('It never signs and never runs the job — bareloop has no checks for this kind of job yet.');
|
|
748
748
|
}
|
|
749
749
|
say('');
|
|
@@ -781,11 +781,11 @@ export async function main(argv, deps = {}) {
|
|
|
781
781
|
if (!KEYED) {
|
|
782
782
|
say('');
|
|
783
783
|
if (providerEnvKey && keyProblem) {
|
|
784
|
-
say(` (${providerEnvKey} is set but ${keyProblem} —
|
|
784
|
+
say(` (${providerEnvKey} is set but ${keyProblem} — bareloop author refuses without a clean key; secrets are never trimmed or repaired, only reported)`);
|
|
785
785
|
} else {
|
|
786
786
|
say(providerEnvKey
|
|
787
|
-
? ` (${providerEnvKey} is not set in this shell —
|
|
788
|
-
: ` (provider "${PROVIDER}" is not in the runnable table —
|
|
787
|
+
? ` (${providerEnvKey} is not set in this shell — bareloop author refuses without it; secrets load from the environment, never the tree)`
|
|
788
|
+
: ` (provider "${PROVIDER}" is not in the runnable table — bareloop author will refuse it loud; this is not a key problem)`);
|
|
789
789
|
}
|
|
790
790
|
// The repair, in plain words and WITHOUT a command: which secret store a person
|
|
791
791
|
// keeps their key in is theirs, and printing one specific incantation would be
|
|
@@ -802,9 +802,9 @@ export async function main(argv, deps = {}) {
|
|
|
802
802
|
: null;
|
|
803
803
|
if (depsGapAtHandoff) {
|
|
804
804
|
say('');
|
|
805
|
-
say(` The copy still has no installed packages (${depsGapAtHandoff.reason}) —
|
|
805
|
+
say(` The copy still has no installed packages (${depsGapAtHandoff.reason}) — bareloop author would only instrument-stop on it.`);
|
|
806
806
|
say(` cd ${prep.tree} && ${depsGapAtHandoff.command}`);
|
|
807
|
-
say(' then run
|
|
807
|
+
say(' then run bareloop author yourself with the command above.');
|
|
808
808
|
}
|
|
809
809
|
say('');
|
|
810
810
|
// The default is NO, and it is the same lean the pause's doors take: the answer that
|
|
@@ -835,7 +835,7 @@ export async function main(argv, deps = {}) {
|
|
|
835
835
|
say('');
|
|
836
836
|
const child = spawnSyncFn(process.execPath, [BARELOOP_BIN, 'author', ...childArgs], { stdio: 'inherit' });
|
|
837
837
|
if (child.error) {
|
|
838
|
-
say(`
|
|
838
|
+
say(`bareloop author could not be started (${child.error.message}) — the command above still stands.`);
|
|
839
839
|
exitCode = 2;
|
|
840
840
|
} else {
|
|
841
841
|
// the child's own exit is the answer: a refusal there is a RESULT, and flattening
|
package/src/job.js
CHANGED
|
@@ -884,6 +884,43 @@ export function jobSpecHash(job) {
|
|
|
884
884
|
return createHash('sha256').update(c).digest('hex');
|
|
885
885
|
}
|
|
886
886
|
|
|
887
|
+
/**
|
|
888
|
+
* The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
|
|
889
|
+
* `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
|
|
890
|
+
* 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
|
|
891
|
+
* reuse (so what the key ignores is exactly what the person may edit, never two spellings).
|
|
892
|
+
*/
|
|
893
|
+
export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs']);
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
|
|
897
|
+
* chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
|
|
898
|
+
* signs still covers the model the run will use. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS} on purpose: those are the
|
|
899
|
+
* fields the page's boxes edit directly; these three change together, as one Settings row.
|
|
900
|
+
*/
|
|
901
|
+
export const WORKFLOW_KEY_IDENTITY_FIELDS = Object.freeze(['provider', 'baseUrl', 'model']);
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* The identity of a WORKFLOW (Reuse workflow, hamr 2026-10-03): sha256 over the signed spec WITHOUT the fields a
|
|
905
|
+
* reuse may change ({@link REUSE_OPEN_SPEC_FIELDS}) and the worker identity ({@link WORKFLOW_KEY_IDENTITY_FIELDS}). Two runs
|
|
906
|
+
* 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.
|
|
907
|
+
* It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
|
|
908
|
+
* a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
|
|
909
|
+
* the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
|
|
910
|
+
* @param {object} job
|
|
911
|
+
* @returns {string} sha256 hex
|
|
912
|
+
*/
|
|
913
|
+
export function workflowKey(job) {
|
|
914
|
+
let c;
|
|
915
|
+
try {
|
|
916
|
+
/** @type {any} */
|
|
917
|
+
const rest = isObj(job) ? { ...job } : job;
|
|
918
|
+
if (isObj(rest)) for (const f of [...REUSE_OPEN_SPEC_FIELDS, ...WORKFLOW_KEY_IDENTITY_FIELDS]) delete rest[f];
|
|
919
|
+
c = canon(resolveSpec(rest));
|
|
920
|
+
} catch { c = '\u0000unhashable'; }
|
|
921
|
+
return createHash('sha256').update(`workflow-key-v1\n${c}`).digest('hex');
|
|
922
|
+
}
|
|
923
|
+
|
|
887
924
|
/**
|
|
888
925
|
* Does an approval record cover this exact spec version? Pure predicate — the
|
|
889
926
|
* N2 runner refuses to run without it; nothing here writes or prompts. The
|
package/src/judged.js
CHANGED
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
// redacts at its emission boundary, the same split `src/kinds.js` documents.
|
|
65
65
|
|
|
66
66
|
import { createRequire } from 'node:module';
|
|
67
|
-
import { extractArtifact, priceOf, scrubRaw } from './text.js';
|
|
67
|
+
import { extractArtifact, priceOf, scrubRaw, rateSourceFields } from './text.js';
|
|
68
68
|
import { resolveProvider } from './providers.js';
|
|
69
69
|
|
|
70
70
|
const require = createRequire(import.meta.url);
|
|
@@ -872,7 +872,7 @@ export async function runLocate({
|
|
|
872
872
|
const { costUsd, unpricedRounds } = r === null ? { costUsd: null, unpricedRounds: 0 } : priceOf(r);
|
|
873
873
|
// BA-21: the round's provenance rides beside the cost when the loop carried it (see
|
|
874
874
|
// `defaultJudgeLoop`); a loop that never metered a rate adds no key.
|
|
875
|
-
onCost({ costUsd, unpricedRounds, ...(r
|
|
875
|
+
onCost({ costUsd, unpricedRounds, ...rateSourceFields(r) });
|
|
876
876
|
return {
|
|
877
877
|
ok: red === null, facts, red, costUsd, unpricedRounds, truncated, parseError,
|
|
878
878
|
raw: scrubRaw({
|
package/src/kinds.js
CHANGED
|
@@ -67,6 +67,7 @@ import { join, sep } from 'node:path';
|
|
|
67
67
|
import { globToPrefix, isNonEmptyString } from './validate.js';
|
|
68
68
|
import { CLOSE_ENV_DENY } from './ralph.js';
|
|
69
69
|
import { runLocate, decide, validateCard, LOCATE_AXES, LOCATE_LABEL } from './judged.js';
|
|
70
|
+
import { rateSourceFields } from './text.js';
|
|
70
71
|
|
|
71
72
|
/** the close's two judgment exits — a stage's verdict, in the shape the
|
|
72
73
|
* hand-written closes already spoke (exit code is truth) */
|
|
@@ -1871,7 +1872,7 @@ async function runJudgedFloor(stage, ctx) {
|
|
|
1871
1872
|
ctx.onJudgeCost({
|
|
1872
1873
|
stage: stage?.name ?? null, kind: stage?.kind ?? null, path: rel, attempt,
|
|
1873
1874
|
label: LOCATE_LABEL, model: judgeModel, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
|
|
1874
|
-
...(
|
|
1875
|
+
...rateSourceFields(c),
|
|
1875
1876
|
});
|
|
1876
1877
|
},
|
|
1877
1878
|
});
|
package/src/ledger.js
CHANGED
|
@@ -23,6 +23,7 @@ import { readFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
|
23
23
|
import { basename } from 'node:path';
|
|
24
24
|
import { createHash } from 'node:crypto';
|
|
25
25
|
import { HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
|
|
26
|
+
import { legsOf } from './legs.js';
|
|
26
27
|
|
|
27
28
|
/** severity order, worst-first (the fold's display order; frequency ranks within) */
|
|
28
29
|
export const LEDGER_CLASSES = Object.freeze([
|
|
@@ -67,6 +68,7 @@ const QUOTED_VERB_RE = /"([a-z0-9-]+)"/;
|
|
|
67
68
|
const EXCLUDED_ESCALATIONS = new Set([
|
|
68
69
|
'cap-halt', // a budget story, not a lib bug
|
|
69
70
|
'wall-halt', // the TIME budget's version of the same story (T, PRD v1.27)
|
|
71
|
+
'stopped', // the PERSON's stop (P5 item 5) — a checkpoint, never a lib bug
|
|
70
72
|
// A stall is the ABSENCE of beats, not an observed provider failure: nothing
|
|
71
73
|
// saw the transport fail, so the typed-lib route rendered a live ask reading
|
|
72
74
|
// "bare-agent: the provider path failed — worker stalled…" and aimed a bug at
|
|
@@ -408,7 +410,7 @@ export function updateLedger({ ledgerFile, spineFiles }) {
|
|
|
408
410
|
// ── BA-21 pricing provenance: the READ side (REPORTING ONLY) ─────────────────
|
|
409
411
|
// bare-agent >=0.37 rides `rateSource` beside `pricing` on every metering payload,
|
|
410
412
|
// and the plan flow forwards it verbatim onto `worker-round`/`judge-round`/`worker-turn`
|
|
411
|
-
// (`rateSourceFields`, src/
|
|
413
|
+
// (`rateSourceFields`, src/text.js — the write side).
|
|
412
414
|
//
|
|
413
415
|
// `judge-round`'s payload (src/kinds.js's `onJudgeCost({...})`) forwards `rateSource` from the
|
|
414
416
|
// judge Loop's per-call metering (`defaultJudgeLoop`, src/judged.js); a judge round whose loop
|
|
@@ -525,9 +527,103 @@ export function floorsFromRecords(records) {
|
|
|
525
527
|
}
|
|
526
528
|
const spendFloorUsd = pricedCount > 0 ? spendSum : null;
|
|
527
529
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
530
|
+
// P5-R: a resumed run is one spine of several legs — the wall floor is the sum of each leg's own
|
|
531
|
+
// first -> last window, the quiet time between legs excluded (hamr 2026-10-02, 2A). One leg (every spine
|
|
532
|
+
// before P5-R) reads exactly as it always did.
|
|
533
|
+
const legs = legsOf(records.filter((r) => r && typeof r === 'object'));
|
|
534
|
+
let wallFloorMs = null;
|
|
535
|
+
let sum = 0;
|
|
536
|
+
let known = 0;
|
|
537
|
+
for (const l of legs) {
|
|
538
|
+
const withTs = l.records.filter((r) => typeof r.ts === 'string');
|
|
539
|
+
const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
|
|
540
|
+
const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
|
|
541
|
+
if (Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs) { sum += lastMs - firstMs; known += 1; }
|
|
542
|
+
}
|
|
543
|
+
if (known > 0) wallFloorMs = sum;
|
|
532
544
|
return { spendFloorUsd, wallFloorMs };
|
|
533
545
|
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* One run's own spend, off its spine records: this LEG's figure (never the chain fold),
|
|
549
|
+
* plus the drafting spend the run was signed with (counted once — on the first leg only).
|
|
550
|
+
* @param {any[]} records ONE leg's records
|
|
551
|
+
* @param {{ withDraft?: boolean }} [o] `withDraft: false` leaves the drafting spend out (a caller folding the CHAIN's own spend, which never held it)
|
|
552
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
553
|
+
*/
|
|
554
|
+
export function legSpend(records, { withDraft = true } = {}) {
|
|
555
|
+
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
556
|
+
const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
|
|
557
|
+
const rounds = records.filter((r) => r && SPEND_RECORD_TYPES.includes(r.type));
|
|
558
|
+
const pricedSum = rounds.reduce((a, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? a + r.costUsd : a), 0);
|
|
559
|
+
const unpriced = rounds.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
|
|
560
|
+
let usd = pricedSum;
|
|
561
|
+
let complete = false;
|
|
562
|
+
if (jobEnd) {
|
|
563
|
+
const eng = jobEnd.engagementSpentUsd;
|
|
564
|
+
usd = typeof eng === 'number' && Number.isFinite(eng) ? eng : pricedSum;
|
|
565
|
+
complete = jobEnd.spendComplete === true && unpriced === 0;
|
|
566
|
+
}
|
|
567
|
+
// drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
|
|
568
|
+
const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
|
|
569
|
+
if (withDraft && jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
|
|
570
|
+
usd += jobStart.draftSpentUsd;
|
|
571
|
+
if (jobStart.draftSpendComplete === false) complete = false;
|
|
572
|
+
}
|
|
573
|
+
return { usd, complete };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* A WHOLE RUN's own spend (P5-R: a resumed run is one spine of several legs): the sum of each leg's own
|
|
578
|
+
* figure ({@link legSpend}), never the declared chain fold. ONE basis — every leg's own rounds — so a leg is
|
|
579
|
+
* counted once: the last leg's `job-end.spentUsd` also carries the earlier legs, and adding it to the
|
|
580
|
+
* earlier legs' own figures would count them twice. A leg with no terminal (it died) is a floor, so the run
|
|
581
|
+
* is `complete: false`. A spine with no marker is one leg and reads exactly as {@link legSpend} always did.
|
|
582
|
+
* @param {any[]} records one spine's records
|
|
583
|
+
* @param {{ withDraft?: boolean }} [o]
|
|
584
|
+
* @returns {{ usd: number, complete: boolean, lastLegUsd: number }} `lastLegUsd`: the latest leg's own share (what a claim still held for it is measured against)
|
|
585
|
+
*/
|
|
586
|
+
export function runSpend(records, o = {}) {
|
|
587
|
+
const legs = legsOf(records);
|
|
588
|
+
if (legs.length === 0) return { usd: 0, complete: false, lastLegUsd: 0 };
|
|
589
|
+
let usd = 0;
|
|
590
|
+
let complete = true;
|
|
591
|
+
let lastLegUsd = 0;
|
|
592
|
+
for (const l of legs) {
|
|
593
|
+
const s = legSpend(l.records, o);
|
|
594
|
+
usd += s.usd;
|
|
595
|
+
if (!s.complete) complete = false;
|
|
596
|
+
lastLegUsd = s.usd;
|
|
597
|
+
}
|
|
598
|
+
return { usd, complete, lastLegUsd };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* What a FINISHED run's whole chain spent, for a caller that folds it forward (the review door's rerun opens a
|
|
603
|
+
* new run under the same signed ceiling). One basis per shape: a spine of ONE leg is read by its terminal's own
|
|
604
|
+
* chain total (`job-end.spentUsd`, which already carries whatever fold that leg inherited — what every door has
|
|
605
|
+
* always read); a resumed run's several legs are the first leg's own inherited fold (spend the file does not
|
|
606
|
+
* hold) plus each leg's OWN spend, so a later leg's terminal total — which also carries the earlier legs — is
|
|
607
|
+
* never added on top of them. Not finite / no terminal = `usd: 0, complete: false` (never an exact-looking 0).
|
|
608
|
+
* @param {any[]} records one spine's records
|
|
609
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
610
|
+
*/
|
|
611
|
+
export function chainSpend(records) {
|
|
612
|
+
const legs = legsOf(records);
|
|
613
|
+
const num = (/** @type {unknown} */ v) => typeof v === 'number' && Number.isFinite(v);
|
|
614
|
+
if (legs.length <= 1) {
|
|
615
|
+
const je = legs[0]?.jobEnd ?? null;
|
|
616
|
+
const known = num(je?.spentUsd);
|
|
617
|
+
return { usd: known ? je.spentUsd : 0, complete: known && je?.spendComplete !== false };
|
|
618
|
+
}
|
|
619
|
+
const head = legs[0].jobStart;
|
|
620
|
+
let usd = num(head?.priorSpentUsd) && head.priorSpentUsd > 0 ? head.priorSpentUsd : 0;
|
|
621
|
+
let complete = head?.priorSpendComplete !== false;
|
|
622
|
+
for (const l of legs) {
|
|
623
|
+
const s = legSpend(l.records, { withDraft: false });
|
|
624
|
+
usd += s.usd;
|
|
625
|
+
if (!s.complete) complete = false;
|
|
626
|
+
}
|
|
627
|
+
return { usd, complete };
|
|
628
|
+
}
|
|
629
|
+
|