bareloop 0.33.0 → 0.35.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 +47 -0
- package/bareloop.context.md +263 -27
- package/package.json +1 -1
- package/src/bundlerun.js +13 -4
- package/src/draftspend.js +82 -0
- package/src/exits.js +10 -1
- package/src/interviewrun.js +10 -9
- package/src/job.js +39 -2
- package/src/judged.js +25 -5
- package/src/kinds.js +2 -0
- package/src/ledger.js +104 -9
- package/src/legs.js +171 -0
- package/src/monthly.js +133 -87
- package/src/panel/authorroutes.js +95 -15
- package/src/panel/authorsession.js +408 -113
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1071 -184
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +855 -81
- package/src/panel/status.js +51 -0
- package/src/planrun.js +63 -31
- package/src/providerrows.js +21 -2
- package/src/providers.js +4 -4
- package/src/ralph.js +3 -0
- 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 +90 -7
- package/src/text.js +20 -0
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +166 -65
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/judged.d.ts +5 -2
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +39 -22
- package/types/panel/authorroutes.d.ts +38 -1
- package/types/panel/authorsession.d.ts +109 -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 +255 -2
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +14 -1
- package/types/providers.d.ts +1 -2
- 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 +66 -2
- package/types/text.d.ts +19 -0
|
@@ -0,0 +1,82 @@
|
|
|
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 { 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
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} DraftSpend
|
|
23
|
+
* @property {string} sessionId
|
|
24
|
+
* @property {number} spentUsd the known floor of the session's drafting spend
|
|
25
|
+
* @property {boolean} spendComplete false = `spentUsd` is a floor ("at least")
|
|
26
|
+
* @property {string|null} provider the worker's provider shape (`anthropic-api` | `openai-api` | ...)
|
|
27
|
+
* @property {string|null} baseUrl
|
|
28
|
+
* @property {string|null} model
|
|
29
|
+
* @property {string} startedAt ISO, the first metered call
|
|
30
|
+
* @property {string} updatedAt ISO, the latest metered call
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* (Re)write `<dir>/draft-spend.json` atomically (tmp + rename).
|
|
35
|
+
* @param {string} dir the session's folder
|
|
36
|
+
* @param {DraftSpend} rec
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
export function writeDraftSpend(dir, rec) {
|
|
40
|
+
const file = join(dir, DRAFT_SPEND_FILE);
|
|
41
|
+
const tmp = `${file}.tmp`;
|
|
42
|
+
writeFileSync(tmp, `${JSON.stringify(rec, null, 2)}\n`);
|
|
43
|
+
renameSync(tmp, file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {string} p @param {string} dir @returns {boolean} */
|
|
47
|
+
const inside = (p, dir) => typeof p === 'string' && (resolve(p) === dir || resolve(p).startsWith(dir + sep));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every session folder's drafting spend that never became a run.
|
|
51
|
+
* @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
|
|
52
|
+
* @returns {DraftSpend[]}
|
|
53
|
+
*/
|
|
54
|
+
export function readOrphanDraftSpends(opts = {}) {
|
|
55
|
+
const root = resolve(opts.sessionsRoot ?? join(runlistHome(opts.home), 'panel-sessions'));
|
|
56
|
+
if (!existsSync(root)) return [];
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
let names;
|
|
59
|
+
try { names = readdirSync(root); } catch { return []; }
|
|
60
|
+
const { rows } = readRunList({ home: opts.home });
|
|
61
|
+
/** @type {DraftSpend[]} */
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
const dir = join(root, name);
|
|
65
|
+
const file = join(dir, DRAFT_SPEND_FILE);
|
|
66
|
+
if (!existsSync(file)) continue;
|
|
67
|
+
if (rows.some((r) => inside(r.spine, dir) || (r.patient && inside(r.patient, dir)))) continue;
|
|
68
|
+
try {
|
|
69
|
+
const j = JSON.parse(readFileSync(file, 'utf8'));
|
|
70
|
+
if (!j || typeof j !== 'object' || !Number.isFinite(j.spentUsd)) throw new Error('unreadable');
|
|
71
|
+
out.push({
|
|
72
|
+
sessionId: String(j.sessionId ?? name), spentUsd: j.spentUsd, spendComplete: j.spendComplete === true,
|
|
73
|
+
provider: typeof j.provider === 'string' ? j.provider : null, baseUrl: typeof j.baseUrl === 'string' ? j.baseUrl : null,
|
|
74
|
+
model: typeof j.model === 'string' ? j.model : null, startedAt: String(j.startedAt), updatedAt: String(j.updatedAt),
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
// a file that names money but cannot be read is unknown spend: an "at least" of $0, dated unknown
|
|
78
|
+
out.push({ sessionId: name, spentUsd: 0, spendComplete: false, provider: null, baseUrl: null, model: null, startedAt: 'unreadable', updatedAt: 'unreadable' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
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/interviewrun.js
CHANGED
|
@@ -722,7 +722,8 @@ export async function main(argv, deps = {}) {
|
|
|
722
722
|
}
|
|
723
723
|
|
|
724
724
|
// ── 6. the paid step, which is a DIFFERENT process under a DIFFERENT ceiling ─
|
|
725
|
-
|
|
725
|
+
// the PACKAGED door (`bin/` ships; `scripts/` does not): `bareloop author` takes the same flags as run-author
|
|
726
|
+
const BARELOOP_BIN = fileURLToPath(new URL('../bin/bareloop.mjs', import.meta.url));
|
|
726
727
|
const childArgs = [
|
|
727
728
|
'--source', TREE, '--answers', answersFile, '--draft', draftFile,
|
|
728
729
|
'--verdict', VERDICT, '--out', OUT,
|
|
@@ -742,7 +743,7 @@ export async function main(argv, deps = {}) {
|
|
|
742
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}.`);
|
|
743
744
|
say('It stops at prepareSigning: it never signs, and it never runs the job.');
|
|
744
745
|
} else {
|
|
745
|
-
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.');
|
|
746
747
|
say('It never signs and never runs the job — bareloop has no checks for this kind of job yet.');
|
|
747
748
|
}
|
|
748
749
|
say('');
|
|
@@ -780,11 +781,11 @@ export async function main(argv, deps = {}) {
|
|
|
780
781
|
if (!KEYED) {
|
|
781
782
|
say('');
|
|
782
783
|
if (providerEnvKey && keyProblem) {
|
|
783
|
-
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)`);
|
|
784
785
|
} else {
|
|
785
786
|
say(providerEnvKey
|
|
786
|
-
? ` (${providerEnvKey} is not set in this shell —
|
|
787
|
-
: ` (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)`);
|
|
788
789
|
}
|
|
789
790
|
// The repair, in plain words and WITHOUT a command: which secret store a person
|
|
790
791
|
// keeps their key in is theirs, and printing one specific incantation would be
|
|
@@ -801,9 +802,9 @@ export async function main(argv, deps = {}) {
|
|
|
801
802
|
: null;
|
|
802
803
|
if (depsGapAtHandoff) {
|
|
803
804
|
say('');
|
|
804
|
-
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.`);
|
|
805
806
|
say(` cd ${prep.tree} && ${depsGapAtHandoff.command}`);
|
|
806
|
-
say(' then run
|
|
807
|
+
say(' then run bareloop author yourself with the command above.');
|
|
807
808
|
}
|
|
808
809
|
say('');
|
|
809
810
|
// The default is NO, and it is the same lean the pause's doors take: the answer that
|
|
@@ -832,9 +833,9 @@ export async function main(argv, deps = {}) {
|
|
|
832
833
|
exitCode = 0;
|
|
833
834
|
} else {
|
|
834
835
|
say('');
|
|
835
|
-
const child = spawnSyncFn(process.execPath, [
|
|
836
|
+
const child = spawnSyncFn(process.execPath, [BARELOOP_BIN, 'author', ...childArgs], { stdio: 'inherit' });
|
|
836
837
|
if (child.error) {
|
|
837
|
-
say(`
|
|
838
|
+
say(`bareloop author could not be started (${child.error.message}) — the command above still stands.`);
|
|
838
839
|
exitCode = 2;
|
|
839
840
|
} else {
|
|
840
841
|
// the child's own exit is the answer: a refusal there is a RESULT, and flattening
|
package/src/job.js
CHANGED
|
@@ -289,8 +289,8 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
|
|
292
|
-
else if (!(typeof spec.budgetUsd === 'number' && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
|
|
293
|
-
red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
|
|
292
|
+
else if (!(typeof spec.budgetUsd === 'number' && Number.isFinite(spec.budgetUsd) && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
|
|
293
|
+
red('bounds', 'budgetUsd', `a finite 0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
// T (PRD v1.27/v1.29) — TIME, with exactly budgetUsd's status: operator input,
|
|
@@ -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);
|
|
@@ -793,10 +793,28 @@ function topLevelFunctionNames(artifactText) {
|
|
|
793
793
|
* so the priced seam is the only legal one.
|
|
794
794
|
* `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
|
|
795
795
|
* `rates`); null/absent = bare-agent's built-in guess, exactly as before.
|
|
796
|
-
* @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o
|
|
796
|
+
* @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o
|
|
797
|
+
* @returns {{run: (msgs: any[], tools: any[], opts?: any) => Promise<any>}} */
|
|
797
798
|
export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
|
|
798
799
|
const { Loop } = require('bare-agent');
|
|
799
|
-
|
|
800
|
+
// BA-21: the Loop's run RESULT carries no `rateSource` — only the per-call metering event
|
|
801
|
+
// does (`onLlmResult`). Capture it there and ride it on the result, so `runLocate` can
|
|
802
|
+
// forward it to the judge-round record beside `costUsd`. Absent (no round metered) = no key.
|
|
803
|
+
/** @type {{rateSource: string|null}|null} */
|
|
804
|
+
let seen = null;
|
|
805
|
+
const loop = new Loop({
|
|
806
|
+
provider, system, ...(rates ? { rates } : {}),
|
|
807
|
+
onLlmResult: (/** @type {{rateSource?: string|null}} */ m) => {
|
|
808
|
+
if (m && typeof m === 'object' && 'rateSource' in m) seen = { rateSource: m.rateSource ?? null };
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
return {
|
|
812
|
+
run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
|
|
813
|
+
seen = null;
|
|
814
|
+
const res = await loop.run(msgs, tools, opts);
|
|
815
|
+
return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
|
|
816
|
+
},
|
|
817
|
+
};
|
|
800
818
|
};
|
|
801
819
|
|
|
802
820
|
// ── LOCATE: one attempt ─────────────────────────────────────────────────────
|
|
@@ -821,7 +839,7 @@ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
|
|
|
821
839
|
* On every route out, red or clean, `onCost` fires exactly once with the honest
|
|
822
840
|
* read: a paid call that leaves no meter record is F12 wearing a judge's coat.
|
|
823
841
|
* @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
|
|
824
|
-
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
|
|
842
|
+
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number, rateSource?: string|null}) => void,
|
|
825
843
|
* callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
|
|
826
844
|
* @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
|
|
827
845
|
* costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
|
|
@@ -852,7 +870,9 @@ export async function runLocate({
|
|
|
852
870
|
/** @param {{axis: string, detail: string}|null} red @param {any} facts @param {any} r @param {boolean} truncated @param {boolean} parseError */
|
|
853
871
|
const out = (red, facts, r, truncated, parseError) => {
|
|
854
872
|
const { costUsd, unpricedRounds } = r === null ? { costUsd: null, unpricedRounds: 0 } : priceOf(r);
|
|
855
|
-
|
|
873
|
+
// BA-21: the round's provenance rides beside the cost when the loop carried it (see
|
|
874
|
+
// `defaultJudgeLoop`); a loop that never metered a rate adds no key.
|
|
875
|
+
onCost({ costUsd, unpricedRounds, ...rateSourceFields(r) });
|
|
856
876
|
return {
|
|
857
877
|
ok: red === null, facts, red, costUsd, unpricedRounds, truncated, parseError,
|
|
858
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,6 +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,
|
|
1875
|
+
...rateSourceFields(c),
|
|
1874
1876
|
});
|
|
1875
1877
|
},
|
|
1876
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,12 +410,11 @@ 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
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
// the day that payload forwards the field the emit site already spreads it.
|
|
415
|
+
// `judge-round`'s payload (src/kinds.js's `onJudgeCost({...})`) forwards `rateSource` from the
|
|
416
|
+
// judge Loop's per-call metering (`defaultJudgeLoop`, src/judged.js); a judge round whose loop
|
|
417
|
+
// carried none — and every judge round archived before this — reads UNKNOWN, by the archive rule.
|
|
417
418
|
//
|
|
418
419
|
// `pricing` is UNCHANGED and still strictly two-valued ('priced'|'unpriced'). Nothing
|
|
419
420
|
// below reads it, nothing below decides anything, and no new escalation category exists:
|
|
@@ -526,9 +527,103 @@ export function floorsFromRecords(records) {
|
|
|
526
527
|
}
|
|
527
528
|
const spendFloorUsd = pricedCount > 0 ? spendSum : null;
|
|
528
529
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
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;
|
|
533
544
|
return { spendFloorUsd, wallFloorMs };
|
|
534
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
|
+
|
package/src/legs.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// P5-R (hamr 2026-10-02: "one run, one id, one file ... never two") — a RESUMED run is the SAME run.
|
|
2
|
+
// Its spine is one append-only file; each resume appends a `leg-resume` marker and then the new
|
|
3
|
+
// leg's own records. This module is the ONE owner of "where does a leg start and end": every reader
|
|
4
|
+
// that needs a leg (the resume reader, replay, the monthly limit, the panel, the readout) asks
|
|
5
|
+
// `legsOf` and never re-derives a boundary on its own — two spellings of one boundary is how a
|
|
6
|
+
// money figure counts leg 1 twice.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no imports, no file IO. The text-level torn-line rule lives in `parseSpineText` below, beside
|
|
9
|
+
// the boundary rule it depends on (a torn line is tolerated exactly where the writer leaves one).
|
|
10
|
+
//
|
|
11
|
+
// A spine with NO marker (every run written before P5-R, and every run nobody resumed) is ONE leg:
|
|
12
|
+
// `legsOf` over it returns that file's records, unchanged.
|
|
13
|
+
|
|
14
|
+
/** The marker record's `type`. */
|
|
15
|
+
export const LEG_RESUME = 'leg-resume';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Leg
|
|
19
|
+
* @property {number} leg 1-based position in file order (the marker's own `leg` field is never trusted over the order)
|
|
20
|
+
* @property {any|null} start the leg's first record (the `leg-resume` marker for leg 2+; the file's first record for leg 1)
|
|
21
|
+
* @property {any|null} end the leg's last record
|
|
22
|
+
* @property {any[]} records every record of the leg, in file order, marker included
|
|
23
|
+
* @property {any|null} jobStart the leg's own `job-start` (null: it ended before runJob wrote one)
|
|
24
|
+
* @property {any|null} jobEnd the leg's own LAST `job-end` (null: it died, or it is still running)
|
|
25
|
+
* @property {string|null} outcome the leg's terminal: its `job-end` outcome, else a `run-end` outcome (a $0 refusal), else null
|
|
26
|
+
* @property {string|null} after leg 1: null. Leg 2+: how the PREVIOUS leg ended, as the marker declares it
|
|
27
|
+
* (else derived from that leg's own terminal), `'died'` when it recorded none
|
|
28
|
+
* @property {number|null} startMs `Date.parse` of the start record's `ts`
|
|
29
|
+
* @property {number|null} endMs `Date.parse` of the end record's `ts`
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @param {any} r @returns {number|null} */
|
|
33
|
+
function tsOf(r) {
|
|
34
|
+
const t = typeof r?.ts === 'string' ? Date.parse(r.ts) : NaN;
|
|
35
|
+
return Number.isFinite(t) ? t : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Split one spine's parsed records into its legs. Pure.
|
|
40
|
+
* @param {any[]} events one spine's parsed records, in file order
|
|
41
|
+
* @returns {Leg[]} always at least one leg for a non-empty list; `[]` for an empty one
|
|
42
|
+
*/
|
|
43
|
+
export function legsOf(events) {
|
|
44
|
+
const list = Array.isArray(events) ? events.filter((e) => e && typeof e === 'object') : [];
|
|
45
|
+
if (list.length === 0) return [];
|
|
46
|
+
/** @type {any[][]} */
|
|
47
|
+
const groups = [[]];
|
|
48
|
+
for (const e of list) {
|
|
49
|
+
// a marker opens a new leg — but never an EMPTY leg 1: a file whose first record IS a marker
|
|
50
|
+
// (a spine somebody truncated) still reads as leg 1 = the marker, not an invented empty leg
|
|
51
|
+
if (e.type === LEG_RESUME && groups[groups.length - 1].length > 0) groups.push([]);
|
|
52
|
+
groups[groups.length - 1].push(e);
|
|
53
|
+
}
|
|
54
|
+
/** @type {Leg[]} */
|
|
55
|
+
const legs = [];
|
|
56
|
+
groups.forEach((records, i) => {
|
|
57
|
+
const jobStart = records.find((r) => r.type === 'job-start') ?? null;
|
|
58
|
+
const jobEnd = records.findLast((r) => r.type === 'job-end') ?? null;
|
|
59
|
+
const runEnd = records.findLast((r) => r.type === 'run-end') ?? null;
|
|
60
|
+
const outcome = typeof jobEnd?.outcome === 'string' ? jobEnd.outcome
|
|
61
|
+
: (typeof runEnd?.outcome === 'string' ? runEnd.outcome : null);
|
|
62
|
+
const marker = records[0].type === LEG_RESUME ? records[0] : null;
|
|
63
|
+
const prev = i > 0 ? legs[i - 1] : null;
|
|
64
|
+
/** @type {string|null} */
|
|
65
|
+
let after = null;
|
|
66
|
+
if (i > 0) after = typeof marker?.after === 'string' && marker.after !== '' ? marker.after : (prev?.outcome ?? 'died');
|
|
67
|
+
legs.push({
|
|
68
|
+
leg: i + 1,
|
|
69
|
+
start: records[0],
|
|
70
|
+
end: records[records.length - 1],
|
|
71
|
+
records,
|
|
72
|
+
jobStart,
|
|
73
|
+
jobEnd,
|
|
74
|
+
outcome,
|
|
75
|
+
after,
|
|
76
|
+
startMs: tsOf(records[0]),
|
|
77
|
+
endMs: tsOf(records[records.length - 1]),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return legs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The legs' WORKING time, gap excluded (hamr 2026-10-02, 2A: only working time charges the wall). Each leg
|
|
85
|
+
* counts first record → last record; the quiet time between one leg's end and the next leg's marker is
|
|
86
|
+
* nobody's. `null` = unknown (no leg carries two parseable stamps) — never 0. A leg whose stamps are
|
|
87
|
+
* unreadable adds nothing, so the figure is a FLOOR, `complete: false`.
|
|
88
|
+
* @param {Leg[]} legs
|
|
89
|
+
* @returns {{ ms: number|null, complete: boolean }}
|
|
90
|
+
*/
|
|
91
|
+
export function legsWallMs(legs) {
|
|
92
|
+
let ms = 0;
|
|
93
|
+
let known = 0;
|
|
94
|
+
let complete = legs.length > 0;
|
|
95
|
+
for (const l of legs) {
|
|
96
|
+
if (l.startMs !== null && l.endMs !== null && l.endMs >= l.startMs) { ms += l.endMs - l.startMs; known += 1; } else complete = false;
|
|
97
|
+
}
|
|
98
|
+
return { ms: known === 0 ? null : ms, complete: complete && known > 0 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a spine's TEXT, tolerant exactly where the writer leaves damage. A kill mid-append leaves a torn
|
|
103
|
+
* line; a resume never edits it (the spine is append-only forever) — it writes one `\n` and starts a fresh
|
|
104
|
+
* line, so the torn bytes end up alone on their own line, immediately BEFORE a `leg-resume` marker. That
|
|
105
|
+
* line, and the file's own last two lines (the older tail rule: a kill mid-append of the file's final
|
|
106
|
+
* record), are tolerated and named. An unparseable line anywhere else is `corrupt`: a reconstruction from
|
|
107
|
+
* it would invent a history.
|
|
108
|
+
* @param {string} raw
|
|
109
|
+
* @returns {{ records: any[], tolerated: {line: number, why: 'tail'|'before-leg-resume'}[], corrupt: {line: number, message: string}|null }}
|
|
110
|
+
* `line` is 1-based. Only the FIRST corrupt line is reported.
|
|
111
|
+
*/
|
|
112
|
+
export function parseSpineText(raw) {
|
|
113
|
+
const lines = String(raw).split('\n');
|
|
114
|
+
/** @type {any[]} */
|
|
115
|
+
const records = [];
|
|
116
|
+
/** @type {{line: number, why: 'tail'|'before-leg-resume'}[]} */
|
|
117
|
+
const tolerated = [];
|
|
118
|
+
/** @type {{line: number, message: string}|null} */
|
|
119
|
+
let corrupt = null;
|
|
120
|
+
const BAD = Symbol('torn');
|
|
121
|
+
/** @type {any[]} undefined = blank line, BAD = unparseable */
|
|
122
|
+
const parsed = lines.map((l) => {
|
|
123
|
+
if (!l.trim()) return undefined;
|
|
124
|
+
try { return JSON.parse(l); } catch { return BAD; }
|
|
125
|
+
});
|
|
126
|
+
/** the next non-blank line's parse result after index i, skipping blanks */
|
|
127
|
+
const nextOf = (/** @type {number} */ i) => {
|
|
128
|
+
for (let j = i + 1; j < lines.length; j += 1) if (parsed[j] !== undefined) return parsed[j];
|
|
129
|
+
return undefined;
|
|
130
|
+
};
|
|
131
|
+
lines.forEach((line, i) => {
|
|
132
|
+
const p = parsed[i];
|
|
133
|
+
if (p === undefined) return;
|
|
134
|
+
if (p !== BAD) { records.push(p); return; }
|
|
135
|
+
if (i >= lines.length - 2) { tolerated.push({ line: i + 1, why: 'tail' }); return; }
|
|
136
|
+
// EXACTLY one torn line directly before a marker: the shape a resume leaves. Two in a row is not that
|
|
137
|
+
// shape — the first of them has a corrupt line (not a marker) after it, and is reported.
|
|
138
|
+
const next = nextOf(i);
|
|
139
|
+
if (next && next.type === LEG_RESUME) { tolerated.push({ line: i + 1, why: 'before-leg-resume' }); return; }
|
|
140
|
+
if (corrupt === null) {
|
|
141
|
+
let message = 'unparseable';
|
|
142
|
+
try { JSON.parse(line); } catch (/** @type {any} */ e) { message = e.message; }
|
|
143
|
+
corrupt = { line: i + 1, message };
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return { records, tolerated, corrupt };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Where leg `leg`'s watchdog kill note lives. Leg 1 keeps the name every run has always had
|
|
151
|
+
* (`<spine>.watchdog.json`); a resumed leg's note sits beside it (`<spine>.leg<N>.watchdog.json`) so one
|
|
152
|
+
* leg never overwrites another's evidence of how it died.
|
|
153
|
+
* @param {string} spineFile
|
|
154
|
+
* @param {number} leg 1-based
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
export function watchdogNotePath(spineFile, leg) {
|
|
158
|
+
return leg <= 1 ? `${spineFile}.watchdog.json` : `${spineFile}.leg${leg}.watchdog.json`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
|
|
163
|
+
* the engine reads it at the between-steps seam (src/planrun.js) and consumes it. One spelling, here, so the route
|
|
164
|
+
* and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
|
|
165
|
+
* when the leg ends, and again at the start of the next leg (src/userrun.js).
|
|
166
|
+
* @param {string} spineFile
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function stopFilePath(spineFile) {
|
|
170
|
+
return `${spineFile}.stop`;
|
|
171
|
+
}
|