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/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 && typeof r === 'object' && 'rateSource' in r ? { rateSource: r.rateSource } : {}) });
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
- ...('rateSource' in c ? { rateSource: c.rateSource } : {}),
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/planrun.js — the write side).
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
- const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
529
- const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
530
- const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
531
- const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
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
+
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
+ }