bareloop 0.34.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bareloop.context.md +241 -13
  3. package/package.json +1 -1
  4. package/src/bundlerun.js +13 -4
  5. package/src/draftspend.js +82 -0
  6. package/src/exits.js +10 -1
  7. package/src/interviewrun.js +7 -7
  8. package/src/job.js +37 -0
  9. package/src/judged.js +2 -2
  10. package/src/kinds.js +2 -1
  11. package/src/ledger.js +101 -5
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +111 -72
  14. package/src/panel/authorroutes.js +84 -14
  15. package/src/panel/authorsession.js +408 -113
  16. package/src/panel/importroutes.js +359 -0
  17. package/src/panel/importrun.js +203 -0
  18. package/src/panel/index.html +1071 -184
  19. package/src/panel/runroutes.js +219 -0
  20. package/src/panel/server.js +839 -70
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +60 -26
  23. package/src/providerrows.js +17 -0
  24. package/src/ralph.js +3 -0
  25. package/src/replay.js +139 -19
  26. package/src/replayio.js +11 -4
  27. package/src/reuse.js +20 -3
  28. package/src/run.js +6 -3
  29. package/src/runlist.js +75 -7
  30. package/src/text.js +20 -0
  31. package/src/u-readout.js +9 -1
  32. package/src/u-watchdog.mjs +4 -2
  33. package/src/userrun.js +138 -57
  34. package/types/draftspend.d.ts +53 -0
  35. package/types/job.d.ts +25 -0
  36. package/types/ledger.d.ts +44 -0
  37. package/types/legs.d.ts +103 -0
  38. package/types/monthly.d.ts +23 -18
  39. package/types/panel/authorroutes.d.ts +23 -1
  40. package/types/panel/authorsession.d.ts +109 -2
  41. package/types/panel/importroutes.d.ts +129 -0
  42. package/types/panel/importrun.d.ts +77 -0
  43. package/types/panel/runroutes.d.ts +32 -0
  44. package/types/panel/server.d.ts +255 -2
  45. package/types/panel/status.d.ts +51 -0
  46. package/types/planrun.d.ts +13 -21
  47. package/types/providerrows.d.ts +12 -0
  48. package/types/replay.d.ts +25 -1
  49. package/types/replayio.d.ts +2 -2
  50. package/types/reuse.d.ts +5 -0
  51. package/types/run.d.ts +6 -2
  52. package/types/runlist.d.ts +52 -2
  53. package/types/text.d.ts +19 -0
package/src/reuse.js CHANGED
@@ -52,6 +52,7 @@ import { closeStagesOf } from './plan.js';
52
52
  import { MIN_WALL_MS, jobSpecHash } from './job.js';
53
53
  import { isObj, isNonEmptyString } from './validate.js';
54
54
  import { readGrade } from './trend.js';
55
+ import { legsOf } from './legs.js';
55
56
 
56
57
  /** the paused-run TERMINAL and its spine record share one name (`runPlan` returns
57
58
  * it and `runJob` writes it onto `job-end`), spelled once in `declaredclose.js`
@@ -117,6 +118,11 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
117
118
  * allowances the loop's remaining tries are meant to spend against, and the human
118
119
  * ones are the stop no further try can change (see `hardStop`).
119
120
  *
121
+ * `stopped` (PANEL-BUILD.md P5 item 5, hamr 2026-10-02): the PERSON ended the leg at a round
122
+ * boundary (a `<spine>.stop` request). Nothing was judged and nothing failed — finished steps stand on
123
+ * disk, the allowance is unspent, so it is a checkpoint exactly like a cap-halt. Arbiter-adjacent (it
124
+ * widens what resume may re-enter); ruled by hamr, not chosen by a builder.
125
+ *
120
126
  * `provider-red` folded in 2026-08-25 (PRD v1.80 TODO #4, F115 "Ruled: one transport
121
127
  * retry"; hamr's ruling verbatim: *"joins resume anyways and you decide but you are
122
128
  * given an honest readout and with 1 retry"*). It is a MACHINE halt, not a human one
@@ -139,7 +145,7 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
139
145
  * does not end up with two lists that can drift, which is the exact failure this
140
146
  * constant exists to end (see the paragraph above).
141
147
  */
142
- export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', ...HUMAN_CHECKPOINTS]);
148
+ export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', 'stopped', ...HUMAN_CHECKPOINTS]);
143
149
 
144
150
  /**
145
151
  * v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
@@ -815,7 +821,16 @@ function readGradeSeed(seen) {
815
821
  * spentUsd, spendComplete, carrySpentUsd, carrySpendComplete }`
816
822
  */
817
823
  export function readResume(events, { deathAt = null, direct = false, resumableOutcomes = [] } = {}) {
818
- const list = Array.isArray(events) ? events.filter(isObj) : [];
824
+ let list = Array.isArray(events) ? events.filter(isObj) : [];
825
+ // P5-R: a DIRECT spine may be a resumed run's one file of several legs (src/legs.js). The checkpoint a resume
826
+ // continues from is the latest leg that reached a `job-start`, and that leg's own `job-start` DECLARES the fold
827
+ // it inherited (`priorSpentUsd`/`priorWallMs`/…) — so reading that one window and adding its rounds is the whole
828
+ // chain exactly once, never the earlier legs' rounds on top of a fold that already carries them. A spine with
829
+ // no marker is one leg and reads as it always did.
830
+ if (direct) {
831
+ const legs = legsOf(list);
832
+ if (legs.length > 1) list = (legs.findLast((l) => l.jobStart !== null) ?? legs[legs.length - 1]).records;
833
+ }
819
834
  /** which landed terminals are a CHECKPOINT rather than a graded row */
820
835
  const resumableHalt = new Set(Array.isArray(resumableOutcomes) ? resumableOutcomes : []);
821
836
  // the FIRST reuse-start is the envelope this spine was opened under. A resumed spine
@@ -1228,7 +1243,9 @@ export const PAUSE_TTL_MS = 60 * 24 * 60 * 60_000;
1228
1243
  * @returns {{ok: boolean, applies: boolean, ageMs: number|null, ttlMs: number, pausedAt: string|null, detail: string|null}}
1229
1244
  */
1230
1245
  export function checkpointAgeGate(events, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
1231
- const list = Array.isArray(events) ? events.filter(isObj) : [];
1246
+ const all = Array.isArray(events) ? events.filter(isObj) : [];
1247
+ // P5-R: only the run's LATEST leg can be the one waiting — an earlier leg's pause was answered by the leg after it
1248
+ const list = legsOf(all).at(-1)?.records ?? all;
1232
1249
  const end = list.filter((e) => e.type === 'job-end').at(-1) ?? null;
1233
1250
  const base = { applies: false, ageMs: null, ttlMs, pausedAt: null, detail: null };
1234
1251
  if (end?.outcome !== HITL_PAUSE) return { ...base, ok: true };
package/src/run.js CHANGED
@@ -256,10 +256,13 @@ async function primitiveSmoke(workdir) {
256
256
  * passes `false` so the escalation tail's option strings say "resume is
257
257
  * `run-u`-only in v1" instead of naming a flag that would fail if typed. Forwarded
258
258
  * verbatim to the plan flow, which owns the three `--resume`-naming readouts.
259
+ * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
260
+ * (`stopFilePath(spine)`, src/legs.js), forwarded verbatim to the plan flow, which reads it at the
261
+ * round-boundary seam (where the money cap binds) and ends the leg `stopped`. `null` = no stop surface.
259
262
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
260
263
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
261
264
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
262
- * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
265
+ * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'stopped' | 'step-stalled' |
263
266
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
264
267
  * NOTE: `job-end` can ALSO land with outcome 'runner-drained' (F140/PRD item
265
268
  * 28(c)) — the `beforeExit` backstop's own terminal, emitted from OUTSIDE this
@@ -270,7 +273,7 @@ async function primitiveSmoke(workdir) {
270
273
  * deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
271
274
  * unknown in-flight state is not a known-safe resume point.
272
275
  */
273
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
276
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
274
277
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
275
278
  // before the ledger, before the approval gate, before a byte of the spec is read.
276
279
  // An unrecognised spelling throws here at zero cost instead of being coerced by
@@ -537,7 +540,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
537
540
  // accounts it natively (F12) and the job-end money contract is unchanged.
538
541
  {
539
542
  const outcome = await runPlan(job, {
540
- workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
543
+ workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable, stopFile,
541
544
  // hamr's ruling 2026-09-28 — ONE cap covers drafting + run: the run's
542
545
  // enforced ceiling is the signed `budgetUsd` (unchanged, still in the
543
546
  // spec hash) MINUS whatever drafting already spent on this job before
package/src/runlist.js CHANGED
@@ -58,9 +58,12 @@ export function runlistPath(home) {
58
58
  }
59
59
 
60
60
  /**
61
- * @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number }} RunRow
61
+ * @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number, leg?: number }} RunRow
62
62
  * `pid`/`capUsd` (optional; older and backfilled rows carry neither): the runner's process id and the
63
63
  * leg's $ cap — what the monthly limit holds while that pid is a live bareloop runner (src/monthly.js).
64
+ * `leg` (P5-R; only on a row {@link readRunList} FOLDED from `leg-start` entries, i.e. a RESUMED run):
65
+ * the number of the run's latest leg — a resumed run is the same run, one row, and its `pid`/`capUsd`
66
+ * are that latest leg's.
64
67
  */
65
68
 
66
69
  /**
@@ -101,7 +104,10 @@ export function appendRun(row, opts = {}) {
101
104
  }
102
105
 
103
106
  /**
104
- * @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean }} RunEvent
107
+ * @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean, leg?: number }} RunEvent
108
+ * `leg` (P5-R): which leg of the run the write-back is about; absent = leg 1 (every entry written before P5-R).
109
+ * A resumed run is ONE row, so `settled`/`released` are per leg: a later leg's `released` gives back THAT leg's
110
+ * claim and never removes the run.
105
111
  * A claim's write-back, appended (never rewritten) beside the run rows. `settled` = the run's claim on the
106
112
  * monthly limit is over (`spentUsd`/`spendComplete` are its final figure, `by` is WHO wrote it: the run itself
107
113
  * at its `job-end`, or the next run that found its process gone, `reason: 'process gone'`). `released` = the
@@ -122,6 +128,43 @@ export function appendRunEvent(event, opts = {}) {
122
128
  try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
123
129
  }
124
130
 
131
+ /**
132
+ * @typedef {{ type: 'leg-start', runid: string, leg: number, pid?: number, capUsd?: number, at: string }} LegStart
133
+ * A resumed run's NEW claim (P5-R, hamr 2026-10-02: one run, one id, one row): appended beside the run's row
134
+ * instead of a second row. {@link readRunList} folds it into the row.
135
+ */
136
+
137
+ /**
138
+ * Append one {@link LegStart}: a resumed run's leg claim. Not locked: one short line, one append. THROWS on IO failure.
139
+ * @param {LegStart} entry
140
+ * @param {{ home?: string }} [opts]
141
+ * @returns {void}
142
+ */
143
+ export function appendLegStart(entry, opts = {}) {
144
+ const path = runlistPath(opts.home);
145
+ mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
146
+ appendFileSync(path, `${JSON.stringify({ ...entry, type: 'leg-start' })}\n`);
147
+ try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
148
+ }
149
+
150
+ /**
151
+ * The leg a write-back names (absent = 1).
152
+ * @param {{ leg?: number }} e
153
+ * @returns {number}
154
+ */
155
+ const legOf = (e) => (Number.isInteger(e.leg) && /** @type {number} */ (e.leg) >= 1 ? /** @type {number} */ (e.leg) : 1);
156
+
157
+ /**
158
+ * Has this row's LATEST leg been settled? The ONE spelling (the monthly limit and the settle writer share it).
159
+ * @param {RunEvent[]} events {@link readRunList}'s `events`
160
+ * @param {{ runid: string, leg?: number }} row
161
+ * @returns {boolean}
162
+ */
163
+ export function isSettled(events, row) {
164
+ const leg = legOf(row);
165
+ return events.some((e) => e.type === 'settled' && e.runid === row.runid && legOf(e) === leg);
166
+ }
167
+
125
168
  /**
126
169
  * Tolerant reader — reuses {@link parseJsonl} (never a second hand-rolled
127
170
  * parser). An absent file reads as an empty list, not an error: a fresh
@@ -136,20 +179,45 @@ export function readRunList(opts = {}) {
136
179
  const path = runlistPath(opts.home);
137
180
  if (!existsSync(path)) return { rows: [], skipped: 0, events: [] };
138
181
  const { records, skipped } = parseJsonl(path);
139
- // the FIRST settle for a runid is authoritative: two runs may both close the same dead claim, and the
182
+ // the FIRST settle for a (runid, leg) is authoritative: two runs may both close the same dead claim, and the
140
183
  // second note is a duplicate (a `released` line likewise); file order decides, later copies are ignored
141
184
  const seen = new Set();
142
185
  /** @type {RunEvent[]} */
143
186
  const events = records.filter((r) => {
144
187
  if (!r || (r.type !== 'settled' && r.type !== 'released')) return false;
145
- const key = `${r.type}:${r.runid}`;
188
+ const key = `${r.type}:${r.runid}:${legOf(r)}`;
146
189
  if (seen.has(key)) return false;
147
190
  seen.add(key);
148
191
  return true;
149
192
  });
150
- const released = new Set(events.filter((e) => e.type === 'released').map((e) => e.runid));
151
- const rows = records.filter((r) => r && !r.type && !released.has(r.runid));
152
- return { rows, skipped, events };
193
+ // a leg-1 `released` = the run never started: not listed. A LATER leg's `released` only gives that leg's claim back.
194
+ const released = new Set(events.filter((e) => e.type === 'released' && legOf(e) === 1).map((e) => e.runid));
195
+ const voided = new Set(events.filter((e) => e.type === 'released' && legOf(e) > 1).map((e) => `${e.runid}:${legOf(e)}`));
196
+ /** @type {Map<string, RunRow & {claimAt: number}>} */
197
+ const byRunid = new Map();
198
+ /** @type {(RunRow & {claimAt: number})[]} */
199
+ const rows = [];
200
+ records.forEach((r, i) => {
201
+ if (!r) return;
202
+ if (!r.type) {
203
+ if (released.has(r.runid)) return;
204
+ const row = { ...r, claimAt: i };
205
+ rows.push(row);
206
+ if (!byRunid.has(r.runid)) byRunid.set(r.runid, row);
207
+ } else if (r.type === 'leg-start') {
208
+ const row = byRunid.get(r.runid);
209
+ if (!row || !Number.isInteger(r.leg) || r.leg < 2 || voided.has(`${r.runid}:${r.leg}`) || r.leg <= legOf(row)) return;
210
+ // the run's pid/cap are its LATEST leg's; the claim's place in the file (the monthly limit's "who is above
211
+ // whom") is that leg-start line's, since that is where the leg claimed
212
+ row.leg = r.leg;
213
+ row.pid = r.pid;
214
+ row.capUsd = r.capUsd;
215
+ row.claimAt = i;
216
+ }
217
+ });
218
+ // file order = claim order: a resumed run's latest claim sits where its latest leg-start line is
219
+ const ordered = rows.some((r) => r.leg !== undefined) ? [...rows].sort((a, b) => a.claimAt - b.claimAt) : rows;
220
+ return { rows: ordered.map(({ claimAt: _c, ...row }) => row), skipped, events };
153
221
  }
154
222
 
155
223
  /**
package/src/text.js CHANGED
@@ -308,3 +308,23 @@ export function stampRaw(raw, { cause = null, reason = null }) {
308
308
  raw.reason = reason === null || reason === undefined ? null : redactSecrets(reason);
309
309
  return raw;
310
310
  }
311
+
312
+ /**
313
+ * BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
314
+ * `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
315
+ * `pricing` on every metering payload; both metering callbacks below forward it onto the
316
+ * spine through THIS one helper, because two sites spelling one rule are two instruments
317
+ * (the ripgrep fix that landed in ci.yml but not publish.yml).
318
+ *
319
+ * Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
320
+ * the one place that decides what counts as a guess. And an ABSENT provenance stays
321
+ * absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
322
+ * in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
323
+ * invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
324
+ * priced", which is a different fact from "nobody told us".
325
+ * @param {any} arg an `onLlmResult` / `onTurn` payload
326
+ * @returns {{rateSource?: string|null}} the field to spread into the spine record
327
+ */
328
+ export function rateSourceFields(arg) {
329
+ return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
330
+ }
package/src/u-readout.js CHANGED
@@ -16,6 +16,8 @@
16
16
  // leads; the leg is what this process actually bought, and dropping it would trade
17
17
  // one blindness for the other.
18
18
 
19
+ import { legsOf } from './legs.js';
20
+
19
21
  /** minutes, at the precision the readout has ever claimed (~90%, hamr's ruling) */
20
22
  const min = (/** @type {number} */ ms) => (ms / 60_000).toFixed(1);
21
23
 
@@ -336,10 +338,16 @@ export function doorLines({ rerun, accept, pause }) {
336
338
  */
337
339
  export function deathAtOf({ watchdogAt, events } = {}) {
338
340
  const list = Array.isArray(events) ? events : [];
341
+ // P5-R: a resumed run is one spine of several legs. "Did this run end itself" is a question about its
342
+ // LATEST leg: an earlier leg's job-end says nothing about how the leg now being read stopped, and a
343
+ // watchdog note dated before this leg began is a different leg's note.
344
+ const last = legsOf(list).at(-1) ?? null;
345
+ const scope = last ? last.records : list;
339
346
  // a landed terminal is the run dating its own stop — nothing outside it is better
340
347
  // evidence about a run that was never killed
341
- if (list.some((e) => e !== null && typeof e === 'object' && /** @type {any} */ (e).type === 'job-end')) return null;
348
+ if (scope.some((e) => e !== null && typeof e === 'object' && /** @type {any} */ (e).type === 'job-end')) return null;
342
349
  const at = typeof watchdogAt === 'string' ? Date.parse(watchdogAt) : NaN;
350
+ if (last && last.leg > 1 && last.startMs !== null && Number.isFinite(at) && at < last.startMs) return null;
343
351
  // an unreadable stamp is UNKNOWN, never a NaN handed to a fold (F6): the caller's
344
352
  // own default is the honest fallback
345
353
  return Number.isFinite(at) ? at : null;
@@ -43,7 +43,7 @@
43
43
  //
44
44
  // Usage:
45
45
  // node src/u-watchdog.mjs --spine <path> --pid <n>
46
- // [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N]
46
+ // [--stale-ms N] [--wall-ms N] [--grace-ms N] [--dead-ms N] [--poll-ms N] [--term-grace-ms N] [--report <path>]
47
47
  import { statSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
48
48
 
49
49
  const arg = (/** @type {string} */ n, /** @type {string|null} */ dflt = null) => {
@@ -215,7 +215,9 @@ function stop(/** @type {string} */ reason, /** @type {string} */ judgement, /**
215
215
  // not a guard (F70: the instrument carrying the failure mode it exists to catch). The
216
216
  // kill below runs whether or not this succeeded; a failure is named on stderr so a
217
217
  // missing marker is never read as "the guard never fired".
218
- const reportPath = `${spine}.watchdog.json`;
218
+ // a RESUMED leg writes its own note beside the run's one spine (P5-R: `<spine>.leg<N>.watchdog.json`), so a
219
+ // later leg never overwrites the earlier leg's evidence; the default is the file every run has always had
220
+ const reportPath = arg('report') ?? `${spine}.watchdog.json`;
219
221
  const tmpPath = `${reportPath}.tmp`;
220
222
  try {
221
223
  writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);