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.
Files changed (56) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bareloop.context.md +263 -27
  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 +10 -9
  8. package/src/job.js +39 -2
  9. package/src/judged.js +25 -5
  10. package/src/kinds.js +2 -0
  11. package/src/ledger.js +104 -9
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +133 -87
  14. package/src/panel/authorroutes.js +95 -15
  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 +855 -81
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +63 -31
  23. package/src/providerrows.js +21 -2
  24. package/src/providers.js +4 -4
  25. package/src/ralph.js +3 -0
  26. package/src/replay.js +139 -19
  27. package/src/replayio.js +11 -4
  28. package/src/reuse.js +20 -3
  29. package/src/run.js +6 -3
  30. package/src/runlist.js +90 -7
  31. package/src/text.js +20 -0
  32. package/src/u-readout.js +9 -1
  33. package/src/u-watchdog.mjs +4 -2
  34. package/src/userrun.js +166 -65
  35. package/types/draftspend.d.ts +53 -0
  36. package/types/job.d.ts +25 -0
  37. package/types/judged.d.ts +5 -2
  38. package/types/ledger.d.ts +44 -0
  39. package/types/legs.d.ts +103 -0
  40. package/types/monthly.d.ts +39 -22
  41. package/types/panel/authorroutes.d.ts +38 -1
  42. package/types/panel/authorsession.d.ts +109 -2
  43. package/types/panel/importroutes.d.ts +129 -0
  44. package/types/panel/importrun.d.ts +77 -0
  45. package/types/panel/runroutes.d.ts +32 -0
  46. package/types/panel/server.d.ts +255 -2
  47. package/types/panel/status.d.ts +51 -0
  48. package/types/planrun.d.ts +13 -21
  49. package/types/providerrows.d.ts +14 -1
  50. package/types/providers.d.ts +1 -2
  51. package/types/replay.d.ts +25 -1
  52. package/types/replayio.d.ts +2 -2
  53. package/types/reuse.d.ts +5 -0
  54. package/types/run.d.ts +6 -2
  55. package/types/runlist.d.ts +66 -2
  56. package/types/text.d.ts +19 -0
@@ -0,0 +1,51 @@
1
+ // PANEL status words — ONE code-owned table (sign -> word) and ONE writer of "which word does this run wear".
2
+ // hamr's ruling 2026-10-04 (option A): every surface (run cards, expanded run rows, the right-side header, imported
3
+ // rows) reads `status` from the server; the page never maps an outcome to a word itself.
4
+ //
5
+ // [▶] running · [·] waiting · [✓] passed · [✗] failed · [✗] capped · [✗] stopped · [?] died
6
+ //
7
+ // Outcome -> word (every outcome the panel knows; anything not listed is `failed`):
8
+ // green, already-green, satisfied -> passed
9
+ // no job-end, runner alive (or starting) -> running
10
+ // no job-end, runner gone (died) -> died
11
+ // cap-halt, wall-halt -> capped (money cap / time cap)
12
+ // escalated + money-halt record, escalated + wall-halt -> capped
13
+ // stopped (the person pressed Stop) -> stopped
14
+ // plan-red, check-red, step-red, escalated (strikes / provider / other), close-red, provider-red, step-stalled,
15
+ // and every other outcome (refusals, instrument stops) -> failed
16
+ // `waiting` is the step-level sign for a step that has not started; no run outcome maps to it.
17
+
18
+ /** the sentence a passed run carries as its reason (the Ended block's `line`) */
19
+ export const GOAL_MET_LINE = 'goal met';
20
+
21
+ /** @typedef {'running'|'waiting'|'passed'|'failed'|'capped'|'stopped'|'died'} StatusKey */
22
+
23
+ /** the table: key -> sign + word */
24
+ export const STATUS = Object.freeze({
25
+ running: Object.freeze({ sign: '▶', word: 'running' }),
26
+ waiting: Object.freeze({ sign: '·', word: 'waiting' }),
27
+ passed: Object.freeze({ sign: '✓', word: 'passed' }),
28
+ failed: Object.freeze({ sign: '✗', word: 'failed' }),
29
+ capped: Object.freeze({ sign: '✗', word: 'capped' }),
30
+ stopped: Object.freeze({ sign: '✗', word: 'stopped' }),
31
+ died: Object.freeze({ sign: '?', word: 'died' }),
32
+ });
33
+
34
+ /**
35
+ * The status of one run.
36
+ * @param {{outcome: string|null|undefined, died?: boolean, category?: string|null, moneyHalt?: boolean}} o
37
+ * `category` is the last escalation's category; `moneyHalt` is whether the spine carries a `money-halt` record
38
+ * @returns {{key: StatusKey, sign: string, word: string}}
39
+ */
40
+ export function statusFor(o) {
41
+ /** @type {StatusKey} */
42
+ let key = 'failed';
43
+ const out = o.outcome;
44
+ if (o.died) key = 'died';
45
+ else if (out === null || out === undefined) key = 'running';
46
+ else if (out === 'green' || out === 'already-green' || out === 'satisfied') key = 'passed';
47
+ else if (out === 'cap-halt' || out === 'wall-halt') key = 'capped';
48
+ else if (out === 'stopped') key = 'stopped';
49
+ else if (out === 'escalated' && ((o.category === 'cap-halt' && o.moneyHalt) || o.category === 'wall-halt')) key = 'capped';
50
+ return { key, ...STATUS[key] };
51
+ }
package/src/planrun.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // which putting either in fs.deny would silently contradict.
22
22
 
23
23
  import { createRequire } from 'node:module';
24
- import { readFileSync, readdirSync } from 'node:fs';
24
+ import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs';
25
25
  import { createHash } from 'node:crypto';
26
26
  import { join, resolve, relative } from 'node:path';
27
27
  import { Gate } from 'bareguard';
@@ -39,8 +39,9 @@ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, str
39
39
  import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
40
40
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
41
41
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
42
- import { extractArtifact } from './text.js';
42
+ import { extractArtifact, rateSourceFields } from './text.js';
43
43
  import { defaultJudgeLoop } from './judged.js';
44
+ export { rateSourceFields };
44
45
  import { createClock, isWallTimeout } from './clock.js';
45
46
  import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
46
47
  import {
@@ -536,25 +537,8 @@ export const FORBIDDEN_WRITE_SEGMENT_PATTERN = /"path":"(?:(?:[^"\\]|\\.)*\/)?(?
536
537
  */
537
538
  export const BOUND_REASON_MAX = 200;
538
539
 
539
- /**
540
- * BA-21 — the WRITE side of the pricing-provenance signal (read side: `rateProvenance` /
541
- * `spendProvenance` in src/ledger.js). bare-agent >=0.37 rides `rateSource` beside
542
- * `pricing` on every metering payload; both metering callbacks below forward it onto the
543
- * spine through THIS one helper, because two sites spelling one rule are two instruments
544
- * (the ripgrep fix that landed in ci.yml but not publish.yml).
545
- *
546
- * Forwarded VERBATIM — the write side reports what upstream said, and src/ledger.js is
547
- * the one place that decides what counts as a guess. And an ABSENT provenance stays
548
- * absent: a payload carrying no `rateSource` (bare-agent <0.37, and every round already
549
- * in the archive) emits no field at all, so a reader sees UNKNOWN rather than a label we
550
- * invented. Never defaulted to `null` here — `null` is upstream saying "nothing was
551
- * priced", which is a different fact from "nobody told us".
552
- * @param {any} arg an `onLlmResult` / `onTurn` payload
553
- * @returns {{rateSource?: string|null}} the field to spread into the spine record
554
- */
555
- export function rateSourceFields(arg) {
556
- return arg && typeof arg === 'object' && 'rateSource' in arg ? { rateSource: arg.rateSource } : {};
557
- }
540
+ /** the recorded reason for a step attempt whose round was cut at the output cap (F122 2A b) */
541
+ export const TRUNCATED_REASON = 'output cut at the token limit (max_tokens)';
558
542
 
559
543
  /**
560
544
  * What the NEXT attempt is told about the bound that cut the previous one.
@@ -589,6 +573,9 @@ export function rateSourceFields(arg) {
589
573
  */
590
574
  export function boundedNote(bounded, rounds) {
591
575
  if (!bounded) return null;
576
+ if (bounded.cause === 'truncated') {
577
+ return `Your previous attempt was CUT OFF: ${TRUNCATED_REASON}. That response was discarded.`;
578
+ }
592
579
  if (bounded.cause !== 'denied') {
593
580
  return `Your previous attempt was CUT OFF after ${rounds} tool rounds. `
594
581
  + 'Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.';
@@ -982,12 +969,18 @@ ${scoutBlob || '(no scout notes)'}`;
982
969
  * passes `false` so the three `--resume`-naming readouts below (`MONEY_OPTIONS`,
983
970
  * the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
984
971
  * in v1" instead of naming a flag that would fail if typed.
972
+ * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
973
+ * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of a step worker
974
+ * (the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
975
+ * the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
976
+ * the way a mid-step cap-halt is; resume re-enters that step). Nothing is in flight at that
977
+ * point, so the spend is exact. `null` = no stop surface (the default).
985
978
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
986
979
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
987
- * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
980
+ * 'branch-red' | 'cap-halt' | 'wall-halt' | 'stopped' | 'provider-red' | 'interpreter-red' |
988
981
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
989
982
  */
990
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
983
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
991
984
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
992
985
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
993
986
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -1708,11 +1701,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1708
1701
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1709
1702
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1710
1703
  // BA-21 provenance, through the SAME helper the worker rounds use — this record
1711
- // is on `spendProvenance`'s read list (src/ledger.js), so a judge-round that
1712
- // never carried the field would read UNKNOWN forever inside the readout built to
1713
- // expose exactly this seam. NAMED, not papered over: the judge cost payload
1714
- // (src/kinds.js `onJudgeCost({...})`) does not carry `rateSource` today, so it
1715
- // still reads UNKNOWN — correctly — until that payload forwards it too.
1704
+ // is on `spendProvenance`'s read list (src/ledger.js). The judge cost payload
1705
+ // (src/kinds.js `onJudgeCost`) forwards the field when the judge Loop carried it
1706
+ // (`defaultJudgeLoop`); a loop that metered no rate leaves the key absent (UNKNOWN).
1716
1707
  ...rateSourceFields(c),
1717
1708
  }),
1718
1709
  })
@@ -2436,7 +2427,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2436
2427
  * `iteration` mirrors `roundIteration` exactly, undefined included: the bound
2437
2428
  * cannot know a label the run never set, and inventing one here would be the
2438
2429
  * only place in this file that claims to.
2439
- * @type {{iteration: number|string|undefined, cause: 'rounds'|'wall'|'denied', reason: string|null}|undefined}
2430
+ * @type {{iteration: number|string|undefined, cause: 'rounds'|'wall'|'denied'|'truncated', reason: string|null}|undefined}
2440
2431
  */
2441
2432
  let attemptBounded;
2442
2433
  /**
@@ -2660,6 +2651,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2660
2651
  // completes no round and returns from no step, which are the only two places
2661
2652
  // `clock.expired()` is otherwise consulted). Same clock object as every other
2662
2653
  // reader — never a second time source.
2654
+ /** P5 item 5 — set by the round seam when the person's stop request was honoured on this worker */
2655
+ let stopHit = false;
2656
+ const throwIfStopped = () => {
2657
+ if (!stopHit) return;
2658
+ const err = /** @type {CategorizedError} */ (new Error('stopped at the person\'s request (a stop request was honoured at a round boundary)'));
2659
+ err.category = 'stopped';
2660
+ throw err;
2661
+ };
2663
2662
  const stallWatch = createStallWatch({
2664
2663
  onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
2665
2664
  expired: () => clock.expired(),
@@ -2742,7 +2741,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2742
2741
  // step loop after a step returns (between steps), the head of a step's attempt
2743
2742
  // (a step that would BEGIN past the deadline is never funded), and the head of
2744
2743
  // a close-fix iteration (W-2 — the run stops on the verdict already minted).
2745
- if (clock.expired()) {
2744
+ // P5 item 5 — the person's STOP, read at THIS seam: the round boundary, where the money cap binds
2745
+ // (nothing in flight, the round just metered). It is the ONE owner of the stop request. The file is
2746
+ // consumed here; `self.stop()` ends the Loop after this round exactly as a bound does; `ask`/`askFrom`
2747
+ // then throw category `stopped`, which the step loop files as the checkpoint outcome `stopped` the way a
2748
+ // mid-step cap-halt is filed. Step workers only (scout/plan/fix never read it).
2749
+ if (stopFile !== null && !stopHit && phase.startsWith('step:') && existsSync(stopFile)) {
2750
+ try { unlinkSync(stopFile); } catch { /* already consumed */ }
2751
+ stopHit = true;
2752
+ emit('stop-requested', { phase, step: phase.slice('step:'.length), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume re-enters this step' });
2753
+ self.stop();
2754
+ } else if (clock.expired()) {
2746
2755
  attemptBounded = { iteration: roundIteration, cause: 'wall', reason: null };
2747
2756
  emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
2748
2757
  self.stop();
@@ -2779,7 +2788,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2779
2788
  // bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
2780
2789
  // message before re-running for exactly this reason; do the same here.
2781
2790
  const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
2782
- return await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2791
+ const r = await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2792
+ throwIfStopped();
2793
+ return r;
2783
2794
  } catch (e) {
2784
2795
  throw categorize(e).err;
2785
2796
  }
@@ -2788,6 +2799,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2788
2799
  let r;
2789
2800
  try {
2790
2801
  r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2802
+ throwIfStopped();
2791
2803
  } catch (e) {
2792
2804
  throw categorize(e).err;
2793
2805
  }
@@ -2805,6 +2817,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2805
2817
  emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason });
2806
2818
  return r;
2807
2819
  }
2820
+ // F122 "2A" half (b), hamr 2026-10-05: a STEP or FIX worker round cut at max_tokens
2821
+ // is a FAILED WORKER ATTEMPT, not transport. Same lane as the deny streak above: the
2822
+ // cut round's output is discarded (bare-agent already dropped it), its spend is booked
2823
+ // by the per-round meter, the tree is judged as it stands, and the step's strike
2824
+ // ladder (or the fix governor) applies unchanged (hamr ruling A extended it to `fix`
2825
+ // after run muux1x96). Scout, drafter, replan keep the transport route below.
2826
+ if (r.error && r.error.startsWith('truncated:') && (phase.startsWith('step:') || phase === 'fix')) {
2827
+ attemptBounded = { iteration: roundIteration, cause: 'truncated', reason: TRUNCATED_REASON };
2828
+ emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason: TRUNCATED_REASON, stopReason: r.stopReason ?? null });
2829
+ return r;
2830
+ }
2808
2831
  // the remaining error-return taxonomy (one map, same doctrine as native's):
2809
2832
  // halt → cap-halt, API truncation → provider-red, anything else is wiring
2810
2833
  if (r.error) {
@@ -3692,6 +3715,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3692
3715
  // (attempts used, steps done); its absence means the deadline landed inside a call
3693
3716
  // instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
3694
3717
  // on exactly that field, so conflating the two would report an unknown as exact.
3718
+ // P5 item 5 — the person stopped the leg at a round boundary inside this step: a checkpoint, not a verdict
3719
+ if (cat === 'stopped') {
3720
+ planExecuted();
3721
+ return 'stopped';
3722
+ }
3695
3723
  if (cat === 'wall-halt') {
3696
3724
  emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
3697
3725
  planExecuted();
@@ -4055,6 +4083,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4055
4083
  .filter((s) => s.values.length > 0)
4056
4084
  .map((s) => `${s.stage} ${s.values.join(' → ')}`);
4057
4085
  const factsLine = [checksFact, ...stageFacts].filter(Boolean).join(' · ');
4086
+ // same iteration numbering as the step loop: `w.setIteration(iteration)` above
4087
+ // stamps the bound, so "the attempt just before this one" is `iteration - 1`
4088
+ const bound = w.wasBounded();
4058
4089
  await w.ask([
4059
4090
  'The job\'s final verification is failing. Fix the repository so it passes.',
4060
4091
  `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
@@ -4062,6 +4093,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4062
4093
  !gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
4063
4094
  gap && `Previous attempt failed the verification:\n${gap}`,
4064
4095
  factsLine && `The close's own numbers so far, oldest first (facts only — no advice):\n${factsLine}`,
4096
+ bound?.iteration === iteration - 1 && boundedNote(bound, maxStepRounds),
4065
4097
  rootInj && rootInj.note,
4066
4098
  ].filter(Boolean).join('\n\n'));
4067
4099
  };
@@ -134,6 +134,23 @@ export function endpointOf(provider, baseUrl) {
134
134
  } catch { return raw.replace(/\/+$/, ''); }
135
135
  }
136
136
 
137
+ /**
138
+ * A job's provider in plain words for a refusal line: the host its Base URL points at (else the
139
+ * shape's own default host, else the shape's label), plus the model when the spec names one.
140
+ * Never a key, a path or a status code.
141
+ * @param {{provider?: string, baseUrl?: string|null, model?: string|null}} spec
142
+ * @returns {string}
143
+ */
144
+ export function providerInWords(spec) {
145
+ const provider = String(spec?.provider ?? '');
146
+ const url = endpointOf(provider, spec?.baseUrl ?? null);
147
+ let host = '';
148
+ try { host = new URL(url).host; } catch { host = ''; }
149
+ if (host === '') host = SHAPES.find((s) => s.id === provider)?.label ?? provider;
150
+ const model = typeof spec?.model === 'string' ? spec.model.trim() : '';
151
+ return model !== '' ? `${host}, ${model}` : host;
152
+ }
153
+
137
154
  /**
138
155
  * The URL a row talks to, for display ('' = the vendor's own host, which has no URL to show).
139
156
  * @param {KeyRow} row
@@ -236,12 +253,14 @@ function perMoney(n) {
236
253
 
237
254
  /**
238
255
  * The one readout line for a resolved customer price, preview and run tail alike:
239
- * `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`.
256
+ * `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`. A 0 / 0 price is accepted (the price
257
+ * is the customer's) but the line says so: every round then books $0, so spend and limits read $0.
240
258
  * @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
241
259
  * @returns {string}
242
260
  */
243
261
  export function priceReadout(price) {
244
- return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)`;
262
+ const zero = price.inPerM === 0 && price.outPerM === 0 ? ' — WARNING: this price is $0, so spend and limits read $0' : '';
263
+ return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)${zero}`;
245
264
  }
246
265
 
247
266
  /**
package/src/providers.js CHANGED
@@ -2,8 +2,9 @@
2
2
  // `provider` name to the bare-agent constructor it drives, the env var
3
3
  // carrying its key, and the per-tier model table — including any
4
4
  // provider-specific request-key gating. Before this module existed, both
5
- // runners constructed `AnthropicProvider` BY NAME (`src/cli.js`'s
6
- // `buildProviders`, `scripts/run-u.mjs`'s top-level wiring) and any second
5
+ // runners constructed `AnthropicProvider` BY NAME (the old `buildProviders` in
6
+ // `src/cli.js` and `scripts/run-u.mjs`'s top-level wiring, both since replaced
7
+ // by `buildRunnerProviders` below) and any second
7
8
  // provider would have meant scattering `if (provider === …)` across both.
8
9
  //
9
10
  // hamr's ruling (PRD 30.7, 2026-09-09): `deepseek-chat` is THE secondary
@@ -265,8 +266,7 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
265
266
 
266
267
  /**
267
268
  * Build the `{provider, providerFor, judgeProvider}` triple both runners
268
- * need (`src/cli.js`'s `buildProviders`, `scripts/run-u.mjs`'s top-level
269
- * wiring) — ONE seam instead of two copies of the same construction and
269
+ * need (`src/authorrun.js` and `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction and
270
270
  * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
271
271
  * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
272
272
  * before this factory existed — that decision stays where each runner's own
package/src/ralph.js CHANGED
@@ -653,6 +653,9 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
653
653
  ['raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
654
654
  'revise the goal/spec so the work fits the time (same re-approval)',
655
655
  'abandon the task']],
656
+ // P5 item 5 — the PERSON asked to stop at a round boundary: nothing failed, nothing is discarded.
657
+ 'stopped': ['You stopped the run. Nothing is discarded: the work on disk stands, and a resume re-enters this step.',
658
+ ['resume (the same run continues)', 'abandon the task']],
656
659
  // F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
657
660
  // three times over, and reissuing the call did not recover it. It borrows both
658
661
  // siblings' rules. Like `step-variance`, the caller reads this category and
package/src/replay.js CHANGED
@@ -103,7 +103,8 @@
103
103
 
104
104
  import { runBehaviour, formatBehaviour } from './behaviour.js';
105
105
  import { shortSha } from './codeversion.js';
106
- import { SPEND_RECORD_TYPES } from './ledger.js';
106
+ import { SPEND_RECORD_TYPES, chainSpend } from './ledger.js';
107
+ import { legsOf } from './legs.js';
107
108
 
108
109
  /**
109
110
  * @param {unknown} e
@@ -167,6 +168,24 @@ export function auditWindow(jobStart, jobEnd) {
167
168
  };
168
169
  }
169
170
 
171
+ /**
172
+ * One window PER LEG of a (possibly resumed) run (P5-R) — the ONE owner the panel and {@link replayRun} share. A
173
+ * run with no `leg-resume` marker is one leg and this is `[auditWindow(jobStart, jobEnd)]`, exactly as before. A
174
+ * resumed run's quiet time between two legs is nobody's, so a row another run wrote into a shared sidecar during it
175
+ * is outside every window; a leg that recorded no terminal (it died) ends where the next leg begins.
176
+ * @param {any[]} spineRecords
177
+ * @returns {{startTs: number, endTs: number}[]}
178
+ */
179
+ export function auditWindowsOf(spineRecords) {
180
+ const legs = legsOf(spineRecords);
181
+ if (legs.length <= 1) {
182
+ const jobStart = spineRecords.find((e) => e && e.type === 'job-start') ?? null;
183
+ const jobEnd = legs.at(-1)?.jobEnd ?? null;
184
+ return [auditWindow(jobStart, jobEnd)];
185
+ }
186
+ return legs.map((l, i) => auditWindow(l.jobStart ?? l.start, l.jobEnd ?? (legs[i + 1]?.start ? { ts: legs[i + 1].start.ts } : null)));
187
+ }
188
+
170
189
  /**
171
190
  * Trim a string to `max` chars with an explicit `…[+N chars]` marker —
172
191
  * NEVER a silent truncation, and never a trailing ellipsis alone
@@ -422,8 +441,14 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
422
441
  else skipped += 1;
423
442
  }
424
443
 
444
+ // P5-R: a resumed run is ONE spine of several legs (src/legs.js `legsOf`, the one owner of the boundaries).
445
+ // `jobStart` is the run's FIRST (its start, its drafting fold); `latestStart` the latest leg's (the caps it ran
446
+ // under now); `jobEnd` the run's terminal — the LAST leg's, never an earlier leg's. A spine with no marker is
447
+ // one leg and every figure below reads exactly as it always did.
448
+ const legs = legsOf(spine);
425
449
  const jobStart = spine.find((e) => e.type === 'job-start') ?? null;
426
- const jobEnd = spine.findLast((e) => e.type === 'job-end') ?? null;
450
+ const latestStart = legs.at(-1)?.jobStart ?? jobStart;
451
+ const jobEnd = legs.at(-1)?.jobEnd ?? null;
427
452
 
428
453
  // F195 fix: a gate-audit sidecar CAN be shared across several runs whose
429
454
  // spines happen to reuse the same filename (measured on a real archived
@@ -441,10 +466,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
441
466
  // (`src/panel/server.js`) calls this same {@link auditWindow} rather than
442
467
  // keeping its own copy.
443
468
  {
444
- const { startTs, endTs } = auditWindow(jobStart, jobEnd);
469
+ const windows = auditWindowsOf(spine);
445
470
  audit = audit.filter((a) => {
446
471
  const t = parseTs(a.ts);
447
- return t !== null && t >= startTs && t <= endTs;
472
+ return t !== null && windows.some((w) => t >= w.startTs && t <= w.endTs);
448
473
  });
449
474
  }
450
475
 
@@ -460,8 +485,12 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
460
485
  // and the wall-halt sites (src/planrun.js:2990,3208-3209).
461
486
  const stopReason = isGreen ? null : buildStopReason(lastEscalation, outcome);
462
487
 
463
- const spentUsd = jobEnd && typeof jobEnd.spentUsd === 'number' && Number.isFinite(jobEnd.spentUsd) ? jobEnd.spentUsd : null;
464
- const spendComplete = jobEnd ? jobEnd.spendComplete === true && spentUsd !== null : false;
488
+ // MONEY, one basis (P5-R): a one-leg spine reads its terminal's own total as it always did; a resumed run's
489
+ // legs are summed by each leg's own spend (`chainSpend`), never the last terminal's chain total on top of them
490
+ const chain = legs.length > 1 && jobEnd ? chainSpend(spine) : null;
491
+ const spentUsd = chain ? chain.usd
492
+ : (jobEnd && typeof jobEnd.spentUsd === 'number' && Number.isFinite(jobEnd.spentUsd) ? jobEnd.spentUsd : null);
493
+ const spendComplete = chain ? chain.complete : (jobEnd ? jobEnd.spendComplete === true && spentUsd !== null : false);
465
494
 
466
495
  // draftSpentUsd (hamr's ruling 2026-09-28, "one cap covers drafting +
467
496
  // run") — the AUTHORING pipeline's own spend on this job before it was
@@ -480,9 +509,28 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
480
509
  // exact then, the field simply didn't exist yet to say so.
481
510
  const draftSpendComplete = draftSpentUsd === null ? null
482
511
  : (jobStart.draftSpendComplete !== false);
483
- const floorReasonList = floorReasons(spine, jobStart, spendComplete);
484
-
485
- const wallMs = windowWallMs(parseTs(jobStart?.ts), parseTs(jobEnd?.ts));
512
+ const floorReasonList = floorReasons(spine, legs.map((l) => l.jobStart).find((j) => j?.priorSpendComplete === false) ?? jobStart, spendComplete);
513
+
514
+ // WALL: the sum of each leg's own working window, the gap between legs excluded (hamr 2026-10-02, 2A). A
515
+ // leg that died has no terminal, so its window ends at its last record (a floor); the LATEST leg without a
516
+ // terminal is unknown, as a one-leg run without one always was.
517
+ /** @param {import('./legs.js').Leg} l @param {boolean} isLast */
518
+ const legWallMs = (l, isLast) => {
519
+ const a = parseTs((l.jobStart ?? l.start)?.ts);
520
+ const b = l.jobEnd ? parseTs(l.jobEnd.ts) : (isLast ? null : parseTs(l.end?.ts));
521
+ return windowWallMs(a, b);
522
+ };
523
+ const wallMs = legs.length > 1
524
+ ? (() => {
525
+ let sum = 0;
526
+ for (let i = 0; i < legs.length; i += 1) {
527
+ const w = legWallMs(legs[i], i === legs.length - 1);
528
+ if (w === null) return null;
529
+ sum += w;
530
+ }
531
+ return sum;
532
+ })()
533
+ : windowWallMs(parseTs(jobStart?.ts), parseTs(jobEnd?.ts));
486
534
 
487
535
  // chainClock: the SIGNED wall, read off the run's own clock record —
488
536
  // src/planrun.js:1203 (`wall-clock`) or `emitWallHalt` (src/planrun.js:1269)
@@ -556,7 +604,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
556
604
  // record at all (resumed before any plan reload was needed); keying
557
605
  // "resumed" on resume-seed alone would have printed `resumed: no` on
558
606
  // genuinely resumed runs and mis-explained their spend.
559
- const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
607
+ const resumed = legs.length > 1 || (!!jobStart && 'priorSpentUsd' in jobStart);
560
608
 
561
609
  // resumeSeed: the DETAIL fields (phase/completed/skipping/divergence) —
562
610
  // present only on the narrower resume-seed record; `resumed` above stays
@@ -582,7 +630,8 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
582
630
  const totalRoundsInFile = spendRecords.length;
583
631
  const unpricedRoundsInFile = spendRecords.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
584
632
  const summedRounds = spendRecords.reduce((acc, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? acc + r.costUsd : acc), 0);
585
- const hasEngagementField = jobEnd && typeof jobEnd.engagementSpentUsd === 'number' && Number.isFinite(jobEnd.engagementSpentUsd);
633
+ // (a resumed run's terminal carries only its LATEST leg's engagement — its file's figure is the sum of the rounds)
634
+ const hasEngagementField = legs.length <= 1 && jobEnd && typeof jobEnd.engagementSpentUsd === 'number' && Number.isFinite(jobEnd.engagementSpentUsd);
586
635
  const thisFileSpend = {
587
636
  value: unpricedRoundsInFile > 0 ? null : (hasEngagementField ? jobEnd.engagementSpentUsd : summedRounds),
588
637
  source: /** @type {'engagementSpentUsd'|'summed rounds'} */ (hasEngagementField ? 'engagementSpentUsd' : 'summed rounds'),
@@ -664,18 +713,38 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
664
713
 
665
714
  const usedEndSeqs = new Set();
666
715
  const idOccurrence = new Map();
716
+ /** @type {Map<string, {stopReason: string|null, legIdx: number, tryNumber: number}>} */
717
+ const prevOccurrence = new Map();
667
718
 
668
719
  const steps = stepStarts.map((ss) => {
669
720
  const id = ss.step;
670
721
  const startSeq = typeof ss.seq === 'number' ? ss.seq : -Infinity;
671
722
  const startTs = parseTs(ss.ts);
672
723
 
724
+ // P5-R: a step belongs to ONE leg. Its `step-end` is the first one inside ITS OWN leg (never a later leg's
725
+ // `step-end` for the same id), and a step its leg's halt cut off ends where that leg ends — otherwise its
726
+ // window runs across the idle gap to the next leg and counts the gap as its time.
727
+ const myLegIdx = legs.length > 1 ? legs.reduce((acc, l, i) => (typeof l.start?.seq === 'number' && startSeq >= l.start.seq ? i : acc), 0) : 0;
728
+ const myLeg = legs.length > 1 ? legs[myLegIdx] : null;
729
+ const legEndSeq = legs.length > 1 && myLegIdx < legs.length - 1 && typeof legs[myLegIdx + 1].start?.seq === 'number' ? legs[myLegIdx + 1].start.seq : Infinity;
673
730
  const end = stepEnds
674
- .filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && !usedEndSeqs.has(se.seq))
731
+ .filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && se.seq < legEndSeq && !usedEndSeqs.has(se.seq))
675
732
  .sort((a, b) => a.seq - b.seq)[0] ?? null;
676
733
  if (end) usedEndSeqs.add(end.seq);
677
- const endSeq = end && typeof end.seq === 'number' ? end.seq : Infinity;
678
- const endTs = end ? parseTs(end.ts) : null;
734
+ const cutByLeg = !end && myLeg !== null && Number.isFinite(legEndSeq);
735
+ const endSeq = end && typeof end.seq === 'number' ? end.seq : (cutByLeg ? legEndSeq : Infinity);
736
+ const endTs = end ? parseTs(end.ts) : (cutByLeg ? parseTs(myLeg?.end?.ts) : null);
737
+ // why this leg stopped the step (code-owned words); null for a step that ended itself or is still running
738
+ /** @type {string|null} */
739
+ let stopReason = null;
740
+ if (!end && myLeg !== null) {
741
+ const o = myLeg.outcome;
742
+ const isLastLeg = myLegIdx === legs.length - 1;
743
+ if (cutByLeg || (isLastLeg && o !== null && o !== 'green' && o !== 'already-green')) {
744
+ stopReason = o === 'cap-halt' ? 'money cap' : o === 'wall-halt' ? 'time cap' : o === 'provider-red' ? 'provider failed'
745
+ : o === 'step-stalled' ? 'step stalled' : o === 'stopped' ? 'you stopped it' : o === null ? 'died' : o;
746
+ }
747
+ }
679
748
 
680
749
  const phaseTag = `step:${id}`;
681
750
  // `judge-round` (a close's paid seam) carries no `.phase` at all
@@ -753,10 +822,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
753
822
  }
754
823
 
755
824
  idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
825
+ // the SAME step continuing across a leg boundary (the previous occurrence of this id was cut off by its leg's
826
+ // halt, and this one starts in a later leg) is "continued", never a retry; `tryNumber` counts real retries only
827
+ const prevOcc = prevOccurrence.get(id) ?? null;
828
+ const continued = myLeg !== null && prevOcc !== null && prevOcc.stopReason !== null && prevOcc.legIdx < myLegIdx;
829
+ const tryNumber = prevOcc === null ? 1 : (continued ? prevOcc.tryNumber : prevOcc.tryNumber + 1);
830
+ prevOccurrence.set(id, { stopReason, legIdx: myLegIdx, tryNumber });
756
831
 
757
832
  return {
758
833
  id,
759
834
  occurrence: idOccurrence.get(id),
835
+ ...(myLeg === null ? {} : { stopReason, continued, tryNumber }),
760
836
  outcome: end ? (end.outcome ?? null) : null,
761
837
  ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
762
838
  checks: { passed, failed },
@@ -1241,7 +1317,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1241
1317
  spentUsd: step.spentUsd,
1242
1318
  unpricedRounds: step.unpricedRounds,
1243
1319
  outcome: step.outcome,
1244
- attempts: stepAttempts,
1320
+ // P5-R (resumed runs only): why its leg's halt cut this step off, whether it is the same step continuing
1321
+ // from the leg before, and its retry count not counting continuations
1322
+ ...(step.stopReason === undefined ? {} : { stopReason: step.stopReason, continued: step.continued, tryNumber: step.tryNumber }),
1323
+ attempts: step.stopReason ? stepAttempts.map((a) => (a.outcome === null || a.outcome === undefined ? { ...a, outcome: 'stopped' } : a)) : stepAttempts,
1245
1324
  });
1246
1325
  }
1247
1326
 
@@ -1321,6 +1400,32 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1321
1400
  }
1322
1401
  }
1323
1402
 
1403
+ // P5-R: which LEG each part belongs to, and where a resume picked up — only for a resumed run (a spine with
1404
+ // no marker keeps its parts byte-for-byte). A step part is dated by its own step-start; a replan by its
1405
+ // marker; every other part by the end of its last attempt (its own window's far edge), falling back to the
1406
+ // near edge, then to leg 1. `resumedNext` marks the part a leg ENDED at: the next part is the leg after.
1407
+ if (legs.length > 1) {
1408
+ /** @param {number|null|undefined} seq */
1409
+ const legOfSeq = (seq) => {
1410
+ let n = 1;
1411
+ if (typeof seq !== 'number' || !Number.isFinite(seq)) return n;
1412
+ for (const l of legs) if (typeof l.start?.seq === 'number' && seq >= l.start.seq) n = l.leg;
1413
+ return n;
1414
+ };
1415
+ for (const p of parts) {
1416
+ const atts = Array.isArray(p.attempts) ? p.attempts : [];
1417
+ const finiteEnds = atts.map((a) => a.endSeq).filter((v) => typeof v === 'number' && Number.isFinite(v));
1418
+ const firstStart = atts.length ? atts[0].startSeq : null;
1419
+ if (p.kind === 'step') p.leg = legOfSeq(firstStart);
1420
+ else if (p.kind === 'replan' && typeof p.markerSeq === 'number') p.leg = legOfSeq(p.markerSeq);
1421
+ else if (finiteEnds.length) p.leg = legOfSeq(Math.max(...finiteEnds));
1422
+ else p.leg = legOfSeq(firstStart);
1423
+ }
1424
+ // legs never go backwards along the part list (a part is dated by file order)
1425
+ for (let i = 1; i < parts.length; i += 1) if (parts[i].leg < parts[i - 1].leg) parts[i].leg = parts[i - 1].leg;
1426
+ for (let i = 0; i < parts.length - 1; i += 1) if (parts[i + 1].leg > parts[i].leg) parts[i].resumedNext = true;
1427
+ }
1428
+
1324
1429
  return {
1325
1430
  runId,
1326
1431
  job: jobStart?.job ?? null,
@@ -1359,8 +1464,9 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1359
1464
  // escalation happen at all"). `summarizeForAllLine`'s `reason` column
1360
1465
  // needs the latter question answered unconditionally.
1361
1466
  lastEscalation,
1362
- budgetUsd: jobStart && typeof jobStart.budgetUsd === 'number' && Number.isFinite(jobStart.budgetUsd) ? jobStart.budgetUsd : null,
1363
- specHash: typeof jobStart?.specHash === 'string' ? jobStart.specHash : null,
1467
+ // the caps and the spec the run's LATEST leg ran under (a resume may run under a re-signed hash)
1468
+ budgetUsd: latestStart && typeof latestStart.budgetUsd === 'number' && Number.isFinite(latestStart.budgetUsd) ? latestStart.budgetUsd : null,
1469
+ specHash: typeof latestStart?.specHash === 'string' ? latestStart.specHash : null,
1364
1470
  branch,
1365
1471
  outcome,
1366
1472
  stopReason,
@@ -1375,6 +1481,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1375
1481
  chainClock,
1376
1482
  resumed,
1377
1483
  resumeSeed,
1484
+ // P5-R — the run's legs in order (always at least one for a spine with records): the panel's Audit divider and
1485
+ // map connector read these, never re-derive a boundary. `startSeq`/`endSeq` bound the leg on the one `seq` series.
1486
+ legs: legs.map((l) => ({
1487
+ leg: l.leg,
1488
+ after: l.after,
1489
+ outcome: l.outcome,
1490
+ startTs: typeof l.start?.ts === 'string' ? l.start.ts : null,
1491
+ endTs: typeof l.end?.ts === 'string' ? l.end.ts : null,
1492
+ startSeq: typeof l.start?.seq === 'number' ? l.start.seq : null,
1493
+ endSeq: typeof l.end?.seq === 'number' ? l.end.seq : null,
1494
+ })),
1378
1495
  thisFileSpend,
1379
1496
  // spendMismatch: a NON-resumed run's own `job-end.spentUsd` IS this
1380
1497
  // file's whole spend (no fold to explain a gap) — so on a cold run,
@@ -1684,7 +1801,10 @@ export function formatReplay(summary) {
1684
1801
  ? `this file unknown (${tf.unpricedRounds} of ${tf.totalRounds} rounds unpriced; ${tf.source})`
1685
1802
  : `this file ${money(tf.value)} (${tf.totalRounds} round${tf.totalRounds === 1 ? '' : 's'}, all priced; ${tf.source})`;
1686
1803
  let spentLine;
1687
- if (summary.resumed) {
1804
+ if (summary.legs.length > 1) {
1805
+ // one run, several legs on ONE file: one figure (each leg's own spend, summed), never two bases side by side
1806
+ spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} over ${summary.legs.length} legs of one run · of ${money2(summary.budgetUsd)} signed`;
1807
+ } else if (summary.resumed) {
1688
1808
  spentLine = `spent: ${money(summary.spentUsd)} chain total (job-end; prior legs folded) · ${thisFileText} · of ${money2(summary.budgetUsd)} signed`;
1689
1809
  } else {
1690
1810
  spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} of ${money2(summary.budgetUsd)} signed`;
@@ -1705,7 +1825,7 @@ export function formatReplay(summary) {
1705
1825
  ? ` · floor because: ${summary.floorReasons.join(', ')}`
1706
1826
  : ' · floor (reason not in spine)';
1707
1827
  }
1708
- spentLine += ` · wall ${duration(summary.wallMs)} (this file)`;
1828
+ spentLine += ` · wall ${duration(summary.wallMs)} (${summary.legs.length > 1 ? 'working time, gaps between legs excluded' : 'this file'})`;
1709
1829
  if (summary.chainClock) {
1710
1830
  const { elapsedMs, requestedMs, source } = summary.chainClock;
1711
1831
  const signed = typeof requestedMs === 'number' ? `${duration(requestedMs)} signed` : 'no wall signed';