bareloop 0.43.0 → 0.44.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 (57) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bareloop.context.md +52 -13
  3. package/package.json +2 -2
  4. package/src/authorflow.js +26 -11
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +27 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +4 -1
  9. package/src/draftspend.js +20 -2
  10. package/src/facts.js +54 -9
  11. package/src/factwords.js +58 -6
  12. package/src/inputlook.js +38 -3
  13. package/src/job.js +1 -1
  14. package/src/kinds.js +5 -0
  15. package/src/pagedropwords.js +64 -0
  16. package/src/pagemap.js +264 -0
  17. package/src/pagerules.js +319 -0
  18. package/src/panel/authorroutes.js +34 -30
  19. package/src/panel/authorsession.js +29 -5
  20. package/src/panel/index.html +465 -65
  21. package/src/panel/jobplan.js +4 -2
  22. package/src/panel/runroutes.js +46 -22
  23. package/src/panel/server.js +791 -28
  24. package/src/panel/status.js +33 -0
  25. package/src/panelmoney.js +29 -0
  26. package/src/planrun.js +53 -4
  27. package/src/promptregisters.js +4 -0
  28. package/src/replay.js +118 -7
  29. package/src/rubricstep.js +123 -11
  30. package/src/runlist.js +2 -1
  31. package/src/schedulewords.js +6 -3
  32. package/src/source.js +18 -0
  33. package/src/thinking.js +63 -2
  34. package/src/userrun.js +28 -14
  35. package/types/authorflow.d.ts +42 -6
  36. package/types/authorjob.d.ts +10 -2
  37. package/types/declaredclose.d.ts +2 -0
  38. package/types/draftspend.d.ts +27 -1
  39. package/types/facts.d.ts +15 -0
  40. package/types/factwords.d.ts +26 -3
  41. package/types/inputlook.d.ts +15 -0
  42. package/types/kinds.d.ts +5 -0
  43. package/types/pagedropwords.d.ts +26 -0
  44. package/types/pagemap.d.ts +154 -0
  45. package/types/pagerules.d.ts +86 -0
  46. package/types/panel/authorroutes.d.ts +17 -14
  47. package/types/panel/runroutes.d.ts +2 -2
  48. package/types/panel/server.d.ts +155 -2
  49. package/types/panel/status.d.ts +51 -0
  50. package/types/panelmoney.d.ts +5 -0
  51. package/types/replay.d.ts +21 -1
  52. package/types/rubricstep.d.ts +57 -1
  53. package/types/runlist.d.ts +4 -1
  54. package/types/schedulewords.d.ts +4 -2
  55. package/types/source.d.ts +11 -0
  56. package/types/thinking.d.ts +39 -0
  57. package/types/userrun.d.ts +4 -0
@@ -72,3 +72,36 @@ export function partStatusFor(part) {
72
72
  }
73
73
  return statusFor({ outcome: 'green' });
74
74
  }
75
+
76
+ /**
77
+ * PRD v1.106 Part 5 (C, choice 9): the review door's words, ONE table. The door is the only human step bareloop has; there is no "you
78
+ * cancelled" (cancel is deleted). `decision` is the spine's `door-decision.decision`, or null while nothing has been decided.
79
+ */
80
+ export const DOOR_WORDS = Object.freeze({
81
+ waiting: Object.freeze({ head: 'waiting for you', row: 'paused for you', last: 'none yet' }),
82
+ accept: Object.freeze({ head: 'you accepted', row: 'you accepted', last: 'you accepted' }),
83
+ rerun: Object.freeze({ head: 'you said rerun', row: 'you said rerun', last: 'you said rerun' }),
84
+ pause: Object.freeze({ head: 'paused', row: 'paused', last: 'paused' }),
85
+ });
86
+
87
+ /**
88
+ * The words one door wears: header phrase, the `last answer:` line (with the time `HH:MM` once decided) and the table rows.
89
+ * @param {{at: string|null, paths: string[], decision: string|null, text: string|null, decidedAt: string|null}} door
90
+ * @returns {{ head: string, last: string, rows: Array<{path: string|null, text: string, time: string|null}> }}
91
+ */
92
+ export function doorViewOf(door) {
93
+ const key = door.decision === 'accept' || door.decision === 'rerun' || door.decision === 'pause' ? door.decision : 'waiting';
94
+ const w = DOOR_WORDS[key];
95
+ const path = door.paths.length === 0 ? null : door.paths.join(', ');
96
+ const hhmm = (/** @type {string|null} */ iso) => (iso && /T(\d\d:\d\d)/.exec(iso)?.[1]) || null;
97
+ const said = key === 'rerun' && door.text ? `${w.row}: "${door.text}"` : w.row;
98
+ /** @type {Array<{path: string|null, text: string, time: string|null}>} */
99
+ const rows = [{ path, text: DOOR_WORDS.waiting.row, time: door.at }];
100
+ if (key !== 'waiting') rows.push({ path, text: said, time: door.decidedAt });
101
+ const when = hhmm(door.decidedAt);
102
+ const last = key === 'waiting' ? w.last : `${key === 'rerun' && door.text ? said : w.last}${when ? ` · ${when}` : ''}`;
103
+ return { head: w.head, last, rows };
104
+ }
105
+
106
+ /** the marker a part wears for who did its work (PRD v1.106 Part 5 C/G): the model, or a person asked */
107
+ export const ACTOR_MARKER = Object.freeze({ model: '[model]', human: '[human · ask]' });
@@ -0,0 +1,29 @@
1
+ // The panel's ONE money spelling (hamr's ruling 2026-10-19, PRD "Part 6 rulings" (3)):
2
+ // - an amount of $0.01 or more: 2 decimals, rounded half-up ("$0.82", "$3.71");
3
+ // - a REAL positive amount under a cent: 4 decimals ("$0.0054"), never "<$0.01" and never "$0.00"
4
+ // (one too small for 4 decimals reads "<$0.0001"; one that rounds up to a cent reads "$0.01");
5
+ // - exact zero: "$0.00"; null/undefined/non-finite: "unknown" (never a fabricated $0).
6
+ // `src/panel/index.html` carries a browser-script copy of this same function (`panelMoney`, it
7
+ // cannot import a module); tests/panel-page.test.js pins the two to identical output. The CLI's own
8
+ // 4-decimal `money()` (src/replay.js) is a different, unchanged spelling.
9
+
10
+ /**
11
+ * @param {number|null|undefined} n a dollar amount
12
+ * @returns {string}
13
+ */
14
+ export function panelMoney2(n) {
15
+ if (n === null || n === undefined) return 'unknown';
16
+ const v = Number(n);
17
+ if (!Number.isFinite(v)) return 'unknown';
18
+ // clean to 6-decimal precision first (the precision src/text.js sums to) so a float sitting just
19
+ // under an exact boundary is never mis-rounded
20
+ const clean = Math.round(v * 1e6) / 1e6;
21
+ if (clean > 0 && clean < 0.01) {
22
+ const units = Math.round(clean * 1e4 + 1e-6);
23
+ if (units <= 0) return '<$0.0001';
24
+ if (units >= 100) return '$0.01';
25
+ return `$${(units / 1e4).toFixed(4)}`;
26
+ }
27
+ const cents = Math.max(0, Math.round(clean * 100 + 1e-6));
28
+ return `$${(cents / 100).toFixed(2)}`;
29
+ }
package/src/planrun.js CHANGED
@@ -42,10 +42,11 @@ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
42
42
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
43
43
  import { extractArtifact, rateSourceFields } from './text.js';
44
44
  import { defaultJudgeLoop } from './judged.js';
45
+ import { workerThinkingMode, thinkingCallOptions } from './thinking.js';
45
46
  export { rateSourceFields };
46
47
  import { createClock, isWallTimeout } from './clock.js';
47
48
  import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
48
- import { inputLabelsOf } from './rubricstep.js';
49
+ import { inputLabelsOf, outputJudgedRecords } from './rubricstep.js';
49
50
  import {
50
51
  seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
51
52
  HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling, resolveHumanRuling,
@@ -1665,6 +1666,8 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1665
1666
  * `null` for a command close, which has no baseline to measure against. */
1666
1667
  /** @type {{seedRef: string}|null} */
1667
1668
  let declaredCtx = null;
1669
+ /** @type {number|null} the step try whose checks are running right now (the `rubric-evidence` record's `iteration`); null outside a step's judge */
1670
+ let judgingIteration = null;
1668
1671
  /**
1669
1672
  * ONE close-execution seam, chosen once by which field the SIGNED spec carries.
1670
1673
  * Both executors return `runClose`'s verdict shape, so everything downstream —
@@ -1703,6 +1706,20 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1703
1706
  // handed through as the FUNCTION (not a snapshot) so a retried locate call
1704
1707
  // re-reads the remaining time rather than reusing the first attempt's.
1705
1708
  callBounds,
1709
+ // REPORT-ONLY: what a rubric-step stage's cited checks read (bareguard `full.evidence`), recorded as `rubric-evidence` for the Result
1710
+ // tab's highlights. No verdict, gap, governor or money path reads it. Masked HERE with the run's one `scrub`: the `text` is a quote from
1711
+ // the frozen page record, and the spine is append-only. Masking moves offsets, so an entry whose text changed keeps NO spans.
1712
+ onRubricEvidence: (/** @type {any} */ e) => emit('rubric-evidence', {
1713
+ step: e.step,
1714
+ ...(judgingIteration !== null ? { iteration: judgingIteration } : {}),
1715
+ evidence: e.evidence.map((/** @type {any} */ x) => {
1716
+ if (typeof x?.text !== 'string') return x;
1717
+ const text = scrub(x.text);
1718
+ const spans = text === x.text && Array.isArray(x.spans) ? x.spans.map((/** @type {any} */ sp) => (typeof sp?.canon === 'string' ? { ...sp, canon: scrub(sp.canon) } : sp)) : [];
1719
+ return { ...x, text, spans };
1720
+ }),
1721
+ ...(e.evidenceTotal !== undefined ? { evidenceTotal: e.evidenceTotal } : {}),
1722
+ }),
1706
1723
  onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
1707
1724
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1708
1725
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
@@ -2773,6 +2790,27 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2773
2790
  self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome, ...(rates ? { rates } : {}) });
2774
2791
  return self;
2775
2792
  };
2793
+ /**
2794
+ * PRD v1.106 Part 1 — what THIS call sends for thinking. The decision is src/thinking.js `workerThinkingMode` (the one owner: phase,
2795
+ * attempt, the job's tick); the per-call option is `thinkingCallOptions` (support-aware: a model bare-agent says cannot switch, a test
2796
+ * double or a clipipe is sent nothing). The decision is written to the spine BEFORE the call. A step/fix iteration that is not a whole
2797
+ * number throws here as a plain operator error, outside any category map. A transport/rate-limit retry inside the call reuses these
2798
+ * very options (the Proxy retries `generate` with the same args), so it carries the same decision.
2799
+ * @returns {{thinkingMode: 'on'|'off'} | {}}
2800
+ */
2801
+ const thinkingForCall = () => {
2802
+ const mode = workerThinkingMode({ phase, iteration: roundIteration, tick: job.thinking === true });
2803
+ emit('thinking-decision', { phase, iteration: roundIteration ?? null, mode });
2804
+ thinkingSent = mode;
2805
+ return thinkingCallOptions(workerProvider ?? provider, mode);
2806
+ };
2807
+ /** bare-agent's own per-call verdict (applied | unsupported | ignored | unknown), recorded as drafting rounds record it; absent when no switch was sent
2808
+ * @param {any} r a Loop.run() result */
2809
+ const noteThinkingStatus = (r) => {
2810
+ if (typeof r?.thinkingStatus === 'string') emit('thinking-status', { phase, iteration: roundIteration ?? null, mode: thinkingSent, thinkingStatus: r.thinkingStatus });
2811
+ };
2812
+ /** @type {'on'|'off'|null} */
2813
+ let thinkingSent = null;
2776
2814
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
2777
2815
  /**
2778
2816
  * F59 — continue an EXISTING conversation for exactly one TOOLLESS round.
@@ -2784,6 +2822,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2784
2822
  * @param {string} prompt
2785
2823
  */
2786
2824
  const askFrom = async (msgs, prompt) => {
2825
+ const thinkOpts = thinkingForCall();
2787
2826
  try {
2788
2827
  // `msgs` is a prior `loop.run()`'s returned transcript, which already has
2789
2828
  // the Loop's `system` PREPENDED (bare-agent Loop.run, loop.js ~482-486).
@@ -2795,8 +2834,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2795
2834
  // bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
2796
2835
  // message before re-running for exactly this reason; do the same here.
2797
2836
  const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
2798
- const r = await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2837
+ const r = await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds(), ...thinkOpts }));
2799
2838
  throwIfStopped();
2839
+ noteThinkingStatus(r);
2800
2840
  return r;
2801
2841
  } catch (e) {
2802
2842
  throw categorize(e).err;
@@ -2804,9 +2844,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2804
2844
  };
2805
2845
  const ask = async (prompt, defs = toolDefs) => {
2806
2846
  let r;
2847
+ const thinkOpts = thinkingForCall();
2807
2848
  try {
2808
- r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2849
+ r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds(), ...thinkOpts }));
2809
2850
  throwIfStopped();
2851
+ noteThinkingStatus(r);
2810
2852
  } catch (e) {
2811
2853
  throw categorize(e).err;
2812
2854
  }
@@ -3399,7 +3441,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3399
3441
  };
3400
3442
  let lastGap = '';
3401
3443
  const judge = async () => {
3402
- const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
3444
+ judgingIteration = iterationNow;
3445
+ /** @type {any} */
3446
+ let evaled;
3447
+ try { evaled = await evalExits(step.exit, { dir: workdir, snapshot, runCheck }); } finally { judgingIteration = null; }
3448
+ const { pass, results: raw } = evaled;
3403
3449
  // Scrubbed HERE, once, before the results are read by anyone. An exit detail is
3404
3450
  // supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own
3405
3451
  // message and V8 quotes a window of the SOURCE inside it (a body of 20 chars or
@@ -3414,6 +3460,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3414
3460
  // results makes the leak inexpressible; scrubbing each consumer makes it
3415
3461
  // something the next consumer has to remember.
3416
3462
  const results = raw.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
3463
+ // PRD v1.106 Part 5: what the check just read (path, size, sha256 — never the content), recorded BEFORE the exit-eval because the next try
3464
+ // overwrites the file. The worker is idle between its turn and this judge, so these are the bytes the check read.
3465
+ for (const o of await outputJudgedRecords(job.closeDecl, workdir, step.exit)) emit('output-judged', { step: step.id, iteration: iterationNow, ...o });
3417
3466
  emit('exit-eval', { step: step.id, iteration: iterationNow, results });
3418
3467
  // an instrument fault rides out by its runClose verdict NAME: ralph
3419
3468
  // escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
@@ -61,6 +61,10 @@ export const PROMPT_REGISTERS = Object.freeze([
61
61
  Object.freeze({ file: 'src/cardauthor.js', name: 'COMPILE_SYSTEM' }),
62
62
  Object.freeze({ file: 'src/cardauthor.js', name: 'cardCasesPrompt() (inline template, not a const)' }),
63
63
 
64
+ // src/pagemap.js - the page-word map call (idea A core): the AI names the page's own words for the person's job
65
+ Object.freeze({ file: 'src/pagemap.js', name: 'MAP_SYSTEM' }),
66
+ Object.freeze({ file: 'src/pagemap.js', name: 'mapPrompt() (inline template, not a const)' }),
67
+
64
68
  // src/readshim.js — the capped/deduplicating read shim's worker-facing strategy
65
69
  Object.freeze({ file: 'src/readshim.js', name: 'READ_SHIM_STRATEGY' }),
66
70
  Object.freeze({ file: 'src/readshim.js', name: 'READ_SHIM_DIFF_STRATEGY' }),
package/src/replay.js CHANGED
@@ -35,6 +35,8 @@
35
35
  // `plan` / `fix` for the other worker phases — src/planrun.js:2735,2515,3359.
36
36
  // `costUsd` is `arg?.costUsd ?? null` — a null is an HONEST unpriced round
37
37
  // (F6), never summed as $0 by this module.
38
+ // rubric-evidence {step, iteration?, evidence, evidenceTotal?} REPORT-ONLY: what a rubric step's cited checks read (bareguard
39
+ // `full.evidence`); read by the Result tab only, by no verdict, governor or money path. src/planrun.js onRubricEvidence
38
40
  // exit-eval {step, iteration, results: [{type, pass, detail?}]}
39
41
  // src/planrun.js:2897
40
42
  // each `results[i].type` is one of plan.js's EXIT_TYPES; `check-passes`
@@ -105,6 +107,7 @@ import { runBehaviour, formatBehaviour } from './behaviour.js';
105
107
  import { shortSha } from './codeversion.js';
106
108
  import { SPEND_RECORD_TYPES, chainSpend } from './ledger.js';
107
109
  import { legsOf, capKindOf } from './legs.js';
110
+ import { thinkingWordsFrom } from './thinking.js';
108
111
 
109
112
  /**
110
113
  * @param {unknown} e
@@ -586,6 +589,12 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
586
589
  // re-spelled so the two can never drift apart.
587
590
  const spendRecords = spine.filter((e) => SPEND_RECORD_TYPES.includes(e.type));
588
591
  const exitEvals = spine.filter((e) => e.type === 'exit-eval');
592
+ // PRD v1.106 Part 5 (D1): which check each `check-passes` result was — the `check-run` record just before its exit-eval
593
+ const checkRuns = spine.filter((e) => e.type === 'check-run');
594
+ // the file each try's check read (new runs only; an old spine has none, and then no judged-file line is shown)
595
+ const outputJudgedRecs = spine.filter((e) => e.type === 'output-judged');
596
+ // D2: the iterations that began (a try with an iteration-start and no exit-eval was cut off before its checks ran)
597
+ const iterationStarts = spine.filter((e) => e.type === 'iteration-start');
589
598
 
590
599
  // F119: every `transport-retry` record on this spine, and the (derived,
591
600
  // never guessed) reason(s) behind a `spendComplete:false` job-end — see
@@ -800,7 +809,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
800
809
  // `/api/runs/:runid/rounds` endpoint (`src/panel/server.js`) to slice
801
810
  // this attempt's own rounds/tool-calls out of the full spine+gate-audit,
802
811
  // never recomputed a second way there either.
803
- /** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red', startSeq: number, endSeq: number, startTs: number|null, endTs: number|null, exitEvalDetail: string|null}>} */
812
+ /** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red', startSeq: number, endSeq: number, startTs: number|null, endTs: number|null, exitEvalDetail: string|null, checks: Array<{type: string, pass: boolean, detail: string|null, name: string|null}>, outputJudged: Array<{path: string|null, bytes: number|null, sha256: string|null}>}>} */
804
813
  const attemptWindows = [];
805
814
  const stepExitEvals = exitEvals
806
815
  .filter((ee) => ee.step === id && typeof ee.seq === 'number' && ee.seq > startSeq && ee.seq < endSeq)
@@ -827,6 +836,27 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
827
836
  iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
828
837
  outcome: allPass ? 'green' : 'red',
829
838
  });
839
+ // the try's checks AS THEY RAN (D1): each exit-eval result in order; a `check-passes` result is named by the `check-run`
840
+ // record just before this exit-eval (same window), taken in order. `detail` is already scrubbed at capture.
841
+ const lo = prevBoundSeq;
842
+ const namesHere = checkRuns
843
+ .filter((c) => typeof c.seq === 'number' && c.seq > lo && c.seq < eeSeq && typeof c.name === 'string')
844
+ .sort((a, b) => a.seq - b.seq);
845
+ let nameAt = 0;
846
+ const tryChecks = results.map((r) => {
847
+ const type = typeof r.type === 'string' ? r.type : 'unknown';
848
+ const name = type === 'check-passes' ? (namesHere[nameAt++]?.name ?? null) : null;
849
+ return {
850
+ type, pass: r.pass === true, detail: typeof r.detail === 'string' ? r.detail : null, name,
851
+ };
852
+ });
853
+ const judgedHere = outputJudgedRecs
854
+ .filter((o) => o.step === id && (typeof ee.iteration !== 'number' || o.iteration === ee.iteration) && typeof o.seq === 'number' && o.seq > lo && o.seq < eeSeq)
855
+ .map((o) => ({
856
+ path: typeof o.path === 'string' ? o.path : null,
857
+ bytes: typeof o.bytes === 'number' ? o.bytes : null,
858
+ sha256: typeof o.sha256 === 'string' ? o.sha256 : null,
859
+ }));
830
860
  attemptWindows.push({
831
861
  n: attemptWindows.length + 1,
832
862
  iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
@@ -836,11 +866,30 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
836
866
  startTs: prevBoundTs,
837
867
  endTs: eeTs,
838
868
  exitEvalDetail: detailSource && typeof detailSource.detail === 'string' ? detailSource.detail : null,
869
+ checks: tryChecks,
870
+ outputJudged: judgedHere,
839
871
  });
840
872
  prevBoundSeq = eeSeq;
841
873
  prevBoundTs = eeTs ?? prevBoundTs;
842
874
  }
843
875
 
876
+ // D2: a try that BEGAN (an `iteration-start` after the last exit-eval) in a step that has ended, and never reached its checks, was cut off
877
+ // (the wall, a cap, a stop). A step still running has an in-flight try, not a cut-off one. Never a numbered attempt: it ran no check.
878
+ const endedHere = end !== null || stopReason !== null;
879
+ const lastIter = iterationStarts
880
+ .filter((it) => typeof it.seq === 'number' && it.seq > prevBoundSeq && it.seq < endSeq)
881
+ .sort((a, b) => b.seq - a.seq)[0] ?? null;
882
+ const cutOffTry = endedHere && lastIter
883
+ ? {
884
+ n: attemptWindows.length + 1,
885
+ iteration: typeof lastIter.iteration === 'number' ? lastIter.iteration : null,
886
+ startSeq: prevBoundSeq,
887
+ endSeq: end && typeof end.seq === 'number' ? end.seq : (Number.isFinite(endSeq) ? endSeq : prevBoundSeq),
888
+ startTs: prevBoundTs,
889
+ endTs,
890
+ }
891
+ : null;
892
+
844
893
  idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
845
894
  // the SAME step continuing across a leg boundary (the previous occurrence of this id was cut off by its leg's
846
895
  // halt, and this one starts in a later leg) is "continued", never a retry; `tryNumber` counts real retries only
@@ -859,6 +908,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
859
908
  treeChanged,
860
909
  attempts,
861
910
  attemptWindows,
911
+ cutOffTry,
862
912
  stepStartSeq: startSeq,
863
913
  stepEndSeq: endSeq,
864
914
  // `stepStartTs`/`stepEndTs` (panel item 2 follow-up, F195/F-audit-step):
@@ -877,7 +927,6 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
877
927
  // `phase` filter on the rounds: the seq window between one iteration-start
878
928
  // and the next close-verdict/run-end/escalation already isolates that
879
929
  // iteration's own rounds.
880
- const iterationStarts = spine.filter((e) => e.type === 'iteration-start');
881
930
  const timelineKind = /** @type {'steps'|'iterations'} */ (stepStarts.length > 0 ? 'steps' : (iterationStarts.length > 0 ? 'iterations' : 'steps'));
882
931
  const boundaries = spine.filter((e) => e.type === 'close-verdict' || e.type === 'run-end' || e.type === 'escalation');
883
932
 
@@ -1320,7 +1369,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1320
1369
  // same as a scout/plan/replan/judge/run part already does.
1321
1370
  const stepAttempts = step.attemptWindows.length > 0
1322
1371
  ? step.attemptWindows.map((aw) => ({
1323
- n: aw.n, outcome: aw.outcome, verdict: null, stages: null, detail: aw.exitEvalDetail, startSeq: aw.startSeq, endSeq: aw.endSeq, startTs: aw.startTs, endTs: aw.endTs,
1372
+ n: aw.n, outcome: aw.outcome, verdict: null, stages: null, detail: aw.exitEvalDetail, startSeq: aw.startSeq, endSeq: aw.endSeq, startTs: aw.startTs, endTs: aw.endTs, iteration: aw.iteration, checks: aw.checks, outputJudged: aw.outputJudged,
1324
1373
  }))
1325
1374
  : wholePartAttempt(step.stepStartSeq, step.stepStartTs, step.stepEndSeq, step.stepEndTs);
1326
1375
  parts.push({
@@ -1340,6 +1389,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1340
1389
  // P5-R (resumed runs only): why its leg's halt cut this step off, whether it is the same step continuing
1341
1390
  // from the leg before, and its retry count not counting continuations
1342
1391
  ...(step.stopReason === undefined ? {} : { stopReason: step.stopReason, continued: step.continued, tryNumber: step.tryNumber }),
1392
+ ...(step.cutOffTry ? { cutOffTry: step.cutOffTry } : {}),
1343
1393
  attempts: step.stopReason ? stepAttempts.map((a) => (a.outcome === null || a.outcome === undefined ? { ...a, outcome: 'stopped' } : a)) : stepAttempts,
1344
1394
  });
1345
1395
  }
@@ -1420,6 +1470,63 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1420
1470
  }
1421
1471
  }
1422
1472
 
1473
+ // PRD v1.106 Part 5 (C, choice 9): a run that reached its review door gets one more part, the only human step bareloop has. `door` carries what
1474
+ // the spine recorded (when it opened, which files, what the person decided and when); the panel server words it (src/panel/status.js).
1475
+ const doorRec = spine.findLast((e) => e.type === 'review-door');
1476
+ if (doorRec) {
1477
+ const decided = spine.findLast((e) => e.type === 'door-decision' && e.honoured === true && typeof e.seq === 'number' && typeof doorRec.seq === 'number' && e.seq > doorRec.seq);
1478
+ const paths = Array.isArray(doorRec.changed?.paths) ? doorRec.changed.paths.filter((/** @type {any} */ x) => typeof x === 'string') : [];
1479
+ parts.push({
1480
+ id: 'door',
1481
+ kind: 'door',
1482
+ label: 'door',
1483
+ number: null,
1484
+ occurrence: null,
1485
+ rounds: 0,
1486
+ toolCalls: 0,
1487
+ byTool: null,
1488
+ blocked: 0,
1489
+ wallMs: null,
1490
+ spentUsd: 0,
1491
+ unpricedRounds: 0,
1492
+ outcome: null,
1493
+ attempts: [],
1494
+ door: {
1495
+ at: typeof doorRec.ts === 'string' ? doorRec.ts : null,
1496
+ paths,
1497
+ decision: decided && typeof decided.decision === 'string' ? decided.decision : null,
1498
+ text: decided && typeof decided.text === 'string' ? decided.text : null,
1499
+ decidedAt: decided && typeof decided.ts === 'string' ? decided.ts : null,
1500
+ },
1501
+ });
1502
+ }
1503
+
1504
+ // PRD v1.106 Part 3: the thinking each call was SENT, from the spine's own `thinking-decision` / `thinking-status` records, worded by
1505
+ // src/thinking.js. A part with one attempt (plan, scout, replan, a single-attempt step) carries it as `part.thinking`; an attempt that made a
1506
+ // worker call carries `attempt.thinking`. A spine with no such record gets no field at all (an older run shows no thinking word).
1507
+ if (spine.some((e) => e.type === 'thinking-decision')) {
1508
+ const firstStepSeq = stepStarts.reduce((m, ss) => (typeof ss.seq === 'number' && ss.seq < m ? ss.seq : m), Infinity);
1509
+ for (const p of parts) {
1510
+ const atts = Array.isArray(p.attempts) ? p.attempts : [];
1511
+ for (const a of atts) {
1512
+ let words = null;
1513
+ if (p.kind === 'step') words = thinkingWordsFrom(spine, { phase: `step:${p.label}`, lo: a.startSeq, hi: a.endSeq });
1514
+ else if (p.kind === 'fix') words = thinkingWordsFrom(spine, { phase: 'fix', lo: a.startSeq, hi: a.endSeq });
1515
+ if (words !== null) a.thinking = words;
1516
+ }
1517
+ if (p.kind === 'step' && p.cutOffTry) {
1518
+ const words = thinkingWordsFrom(spine, { phase: `step:${p.label}`, lo: p.cutOffTry.startSeq, hi: p.cutOffTry.endSeq });
1519
+ if (words !== null) p.cutOffTry.thinking = words;
1520
+ }
1521
+ let part = null;
1522
+ if (p.kind === 'scout') part = thinkingWordsFrom(spine, { phase: 'scout', hi: firstStepSeq });
1523
+ else if (p.kind === 'plan') part = thinkingWordsFrom(spine, { phase: 'plan', hi: firstStepSeq });
1524
+ else if (p.kind === 'replan' && atts[0]) part = thinkingWordsFrom(spine, { phase: 'plan', lo: atts[0].startSeq, hi: atts[0].endSeq });
1525
+ else if ((p.kind === 'step' || p.kind === 'fix') && atts.length === 1) part = atts[0].thinking ?? null;
1526
+ if (part !== null) p.thinking = part;
1527
+ }
1528
+ }
1529
+
1423
1530
  // P5-R: which LEG each part belongs to, and where a resume picked up — only for a resumed run (a spine with
1424
1531
  // no marker keeps its parts byte-for-byte). A step part is dated by its own step-start; a replan by its
1425
1532
  // marker; every other part by the end of its last attempt (its own window's far edge), falling back to the
@@ -1587,16 +1694,20 @@ function money2(n) {
1587
1694
  * @param {boolean|null} [draftSpendComplete] whether the drafting fold above was EXACT;
1588
1695
  * `null`/`undefined`/`true` all read as exact (never a new enum value elsewhere) —
1589
1696
  * only an explicit `false` triggers the floor wording.
1697
+ * @param {number|null} [runUsd] the run's own spend as a number (excluding drafting), so the sum can be written
1590
1698
  * @returns {string}
1591
1699
  */
1592
- export function moneyWithDraft(spendCore, draftSpentUsd, budgetUsd, draftSpendComplete) {
1700
+ export function moneyWithDraft(spendCore, draftSpentUsd, budgetUsd, draftSpendComplete, runUsd) {
1593
1701
  const hasDraft = draftSpentUsd !== null && draftSpentUsd !== undefined;
1594
1702
  const draftFloor = hasDraft && draftSpendComplete === false;
1595
1703
  const coreAlreadyFloor = /^(at least |≥)/.test(spendCore);
1596
1704
  const core = draftFloor && !coreAlreadyFloor ? `at least ${spendCore}` : spendCore;
1597
- const draftPart = hasDraft ? ` (${draftFloor ? 'at least ' : ''}${money(draftSpentUsd)} drafting)` : '';
1598
1705
  const capPart = budgetUsd === undefined ? '' : ` of ${money2(budgetUsd)}`;
1599
- return `${core}${draftPart}${capPart}`;
1706
+ if (!hasDraft) return `${core}${capPart}`;
1707
+ // PRD v1.106 Part 5 Q4: the run's figure EXCLUDES drafting, so the line says so and adds the sum — "$0.0054 run + $0.0035 drafting =
1708
+ // $0.0089 of $1.00". A floor on either part makes the total a floor too ("at least"). Without the run's number the sum is left out.
1709
+ const total = typeof runUsd === 'number' && Number.isFinite(runUsd) ? ` = ${draftFloor || coreAlreadyFloor ? 'at least ' : ''}${money(runUsd + draftSpentUsd)}` : '';
1710
+ return `${core} run + ${draftFloor ? 'at least ' : ''}${money(draftSpentUsd)} drafting${total}${capPart}`;
1600
1711
  }
1601
1712
 
1602
1713
  /**
@@ -2045,7 +2156,7 @@ export function summarizeForAllLine(summary) {
2045
2156
  // shown on a run with zero retries.
2046
2157
  outcome: `${summary.outcome ?? 'unknown'}${summary.transportRetryCount > 0 ? ` ⟲${summary.transportRetryCount}` : ''}`,
2047
2158
  hadTransportRetries: summary.transportRetryCount > 0,
2048
- spend: moneyWithDraft(money(summary.spentUsd), summary.draftSpentUsd, undefined, summary.draftSpendComplete),
2159
+ spend: moneyWithDraft(money(summary.spentUsd), summary.draftSpentUsd, undefined, summary.draftSpendComplete, summary.spentUsd),
2049
2160
  wall: duration(summary.wallMs),
2050
2161
  steps: stepsCol,
2051
2162
  reason,