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
@@ -25,13 +25,19 @@
25
25
  // for every cap/threshold: a shell never widens what it was asked to do).
26
26
 
27
27
  import { createServer } from 'node:http';
28
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
28
+ import {
29
+ readFileSync, existsSync, readdirSync, lstatSync, realpathSync,
30
+ } from 'node:fs';
31
+ import { createHash } from 'node:crypto';
29
32
  import {
30
33
  dirname, join, basename, relative, isAbsolute, sep,
31
34
  } from 'node:path';
32
35
  import { fileURLToPath } from 'node:url';
33
36
  import { readRunList, readNeedsRemovals, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
- import { draftingPartFor } from '../draftspend.js';
37
+ import { webFetchState } from '../source.js';
38
+ import { isWebUrl } from '../pagefetch.js';
39
+ import { draftingPartFor, sessionDirForRow, readDraftSteps } from '../draftspend.js';
40
+ import { readingOfQuestion, isAgree } from '../pagemap.js';
35
41
  import {
36
42
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
37
43
  } from '../replayio.js';
@@ -60,7 +66,12 @@ import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '.
60
66
  import { SHAPES } from '../providershapes.js';
61
67
  import { resolveProvider } from '../providers.js';
62
68
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
63
- import { statusFor, partStatusFor, GOAL_MET_LINE } from './status.js';
69
+ import {
70
+ statusFor, partStatusFor, GOAL_MET_LINE, doorViewOf, ACTOR_MARKER, DOOR_WORDS,
71
+ } from './status.js';
72
+ import { thinkingJobText } from '../thinking.js';
73
+ import { judgedFilesOf, lineRules, stepFileRel, proseNotCheckedItems } from '../rubricstep.js';
74
+ import { factPartOf, factSentence, ruleWords, moneyAs } from '../facts.js';
64
75
  import { createSettingsRoutes } from './settingsroutes.js';
65
76
  import { planFromSpec } from './jobplan.js';
66
77
 
@@ -199,6 +210,8 @@ function describeLastRecord(r) {
199
210
  'scout-start': () => 'starting the scout',
200
211
  'scout-result': () => 'the scout finishing',
201
212
  'check-run': () => 'a check run',
213
+ 'output-judged': () => 'recording the file a check read',
214
+ 'rubric-evidence': () => 'recording the words a check read',
202
215
  'check-preflight': () => 'a preflight check',
203
216
  'check-menu': () => 'building the check menu',
204
217
  'step-start': (rec) => `starting step ${rec.step ?? '?'}`,
@@ -687,11 +700,17 @@ function endedForRow(row, records, summary, death, withWorktree = false) {
687
700
  * exist YET: the runner appends the row first, then opens the spine. It is `starting`,
688
701
  * never `file missing`. Carries the same field names a normal row/detail does so every
689
702
  * client reader (filters, groups) sees a live `▶` run, with every figure honestly null.
690
- * @param {{ runid: string, job: string, at: string, via: string }} row
703
+ * @param {{ runid: string, job: string, at: string, via: string, spine: string }} row
691
704
  * @returns {any}
692
705
  */
693
706
  function startingStub(row) {
707
+ // PRD v1.106 Part 5 Q2 (moved after the listing 2026-10-19, run mvez8s54): while the run's own refetch of a web input is still going
708
+ // (source.json has a web input and no refrozenAt at or after the row), the words are the server's: "fetching the page…"
709
+ const near = sourceNearSpine(row.spine);
710
+ const fetching = near.sourceJsonPath !== null && webFetchState(near.sourceJsonPath, row.at).fetching;
694
711
  return {
712
+ startingWord: fetching ? 'fetching the page…' : 'starting…',
713
+ startingNote: fetching ? 'the run has not started yet' : 'the run has not written its log yet',
695
714
  runid: row.runid,
696
715
  job: row.job,
697
716
  at: row.at,
@@ -729,6 +748,28 @@ function startingStub(row) {
729
748
  };
730
749
  }
731
750
 
751
+ /**
752
+ * A run that REFUSED before it started (its refetch of a web input was refused, run mvez8s54 shape): listed, settled at $0, no spine.
753
+ * The status word is the table's `failed` (statusFor: "refusals, instrument stops -> failed"); the plain words ride on `endedLine`.
754
+ * @param {{ runid: string, job: string, at: string, via: string }} row @param {string} words
755
+ * @returns {any}
756
+ */
757
+ function refusedStub(row, words) {
758
+ return {
759
+ runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, refused: true, live: false, needs: null, stoppedAt: null,
760
+ died: false, glyph: statusFor({ outcome: 'refused-before-start' }).sign, status: statusFor({ outcome: 'refused-before-start' }),
761
+ outcome: 'refused-before-start', endedLine: words, ended: null, resume: null, checkType: 'unknown', checkTypeTitle: null, model: null,
762
+ spend: '$0', wall: '', date: typeof row.at === 'string' ? row.at.slice(0, 10) : null, spentUsd: 0, spendComplete: true,
763
+ spendFloorUsd: null, wallFloorMs: null, draftSpentUsd: null, draftSpendComplete: null, budgetUsd: null, steps: [], parts: [],
764
+ };
765
+ }
766
+
767
+ /** the plain words of a run that settled as "refused before start" (leg 1), or null @param {any[]} events @param {{runid: string}} row */
768
+ function refusalWordsOf(events, row) {
769
+ const e = events.find((x) => x && x.type === 'settled' && x.runid === row.runid && x.reason === 'refused before start' && !(x.leg > 1));
770
+ return e && typeof e.words === 'string' && e.words !== '' ? e.words : null;
771
+ }
772
+
732
773
  /**
733
774
  * RULED 2026-10-15 (Remove from Needs you): the ONE place a run's Needs-you state is decided. `Accept` (a live door) is never removable and never ages out.
734
775
  * A `Resume` tag leaves when the person removed it AFTER (or at) the stop it is for — a removal covers only the ending it was made on, so a later stop
@@ -754,10 +795,13 @@ export function needsStateFor(f) {
754
795
  * already applies to the CLI's own listing).
755
796
  * @param {import('../runlist.js').RunRow} row
756
797
  * @param {number} [nowMs] @param {Map<string, number>} [removals] `readNeedsRemovals`: when each run was taken out of Needs you
798
+ * @param {any[]} [events] the run list's settled/released events (a run that refused before it started has no spine; its words are here)
757
799
  * @returns {any}
758
800
  */
759
- function summarizeRow(row, nowMs = Date.now(), removals = undefined) {
801
+ function summarizeRow(row, nowMs = Date.now(), removals = undefined, events = []) {
760
802
  if (!existsSync(row.spine)) {
803
+ const refusal = refusalWordsOf(events, row);
804
+ if (refusal !== null) return refusedStub(row, refusal);
761
805
  if (runIsAlive(row)) return startingStub(row);
762
806
  return {
763
807
  runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true, live: false, needs: null, stoppedAt: null,
@@ -981,14 +1025,14 @@ export function summarizeJobs(rows, nowMs) {
981
1025
  * @returns {any[]}
982
1026
  */
983
1027
  export function listRuns(opts = {}) {
984
- const { rows } = readRunList(opts);
1028
+ const { rows, events } = readRunList(opts);
985
1029
  const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
986
1030
  const nos = runNumbers(rows);
987
1031
  const removals = readNeedsRemovals(opts);
988
1032
  return rows
989
1033
  .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
990
1034
  .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
991
- .map(({ row }) => ({ ...summarizeRow(row, nowMs, removals), runNo: nos.get(row.runid) }));
1035
+ .map(({ row }) => ({ ...summarizeRow(row, nowMs, removals, events), runNo: nos.get(row.runid) }));
992
1036
  }
993
1037
 
994
1038
  /**
@@ -1093,6 +1137,346 @@ export function getAcceptContext(runid, opts = {}) {
1093
1137
  return { row, live: runIsAlive(row), waiting: doorWaiting(records), specPath: signed ? signed.specPath : null, specHash: signed ? signed.specHash : null };
1094
1138
  }
1095
1139
 
1140
+ // ── PRD v1.107 Part 6: the Result tab (rubric jobs only) ─────────────────────────────────────────────────────────────────────────────
1141
+
1142
+ /**
1143
+ * The files a run delivered: its own `destination-written` spine records (userrun.js, on a minted green only), one per path, the last record of a
1144
+ * path winning. This is the ONLY source of a delivered path anywhere in the panel — no request ever names one.
1145
+ * @param {any[]} records
1146
+ * @returns {{path: string, bytes: number|null, sha256: string|null}[]}
1147
+ */
1148
+ export function deliveredOf(records) {
1149
+ /** @type {Map<string, {path: string, bytes: number|null, sha256: string|null}>} */
1150
+ const byPath = new Map();
1151
+ for (const r of records) {
1152
+ if (!r || r.type !== 'destination-written' || typeof r.path !== 'string' || !isAbsolute(r.path)) continue;
1153
+ byPath.set(r.path, { path: r.path, bytes: typeof r.bytes === 'number' ? r.bytes : null, sha256: typeof r.sha256 === 'string' ? r.sha256 : null });
1154
+ }
1155
+ return [...byPath.values()];
1156
+ }
1157
+
1158
+ /** @param {any} spec @returns {any[]} the signed close's rubric-step stages, in declared order */
1159
+ function rubricStagesOf(spec) {
1160
+ const stages = Array.isArray(spec?.closeDecl?.stages) ? spec.closeDecl.stages : [];
1161
+ return stages.filter((/** @type {any} */ s) => s && s.kind === 'rubric-step' && s.params && Number.isInteger(s.params.step));
1162
+ }
1163
+
1164
+ /** what the Result tab's open door asks, after the door's own head words */
1165
+ export const RESULT_DOOR_ASK = 'the checks passed; is this what you wanted?';
1166
+
1167
+ /** the two sentences for a delivered file that is no longer what the run wrote (code-owned, one spelling) */
1168
+ export const RESULT_FILE_GONE = 'the saved file is gone';
1169
+ export const RESULT_FILE_CHANGED = 'the saved file changed after the run';
1170
+ /** the same two states for a red run's file, which is the last one the check read (not a delivered file) */
1171
+ export const RESULT_JUDGED_GONE = 'the file is gone';
1172
+ export const RESULT_JUDGED_CHANGED = 'the file changed after the check';
1173
+ /** what a red run's rows are, said once above them */
1174
+ export const RESULT_JUDGED_NOTE = 'a red run delivers nothing; these rows are the last file the check read';
1175
+
1176
+ /**
1177
+ * One delivered file, read for the Result tab or a download: it must still be a regular file at the recorded path whose sha256 equals the
1178
+ * record's. A mismatch or a missing file is a state, never content — stale bytes are never returned.
1179
+ * @param {{path: string, sha256: string|null}} rec a record from {@link deliveredOf}
1180
+ * @returns {{state: 'ok', buf: Buffer}|{state: 'gone'|'changed', buf: null}}
1181
+ */
1182
+ function readDelivered(rec) {
1183
+ let st;
1184
+ try { st = lstatSync(rec.path); } catch { return { state: 'gone', buf: null }; }
1185
+ if (!st.isFile()) return { state: 'gone', buf: null };
1186
+ let buf;
1187
+ try { buf = readFileSync(rec.path); } catch { return { state: 'gone', buf: null }; }
1188
+ if (rec.sha256 === null || createHash('sha256').update(buf).digest('hex') !== rec.sha256) return { state: 'changed', buf: null };
1189
+ return { state: 'ok', buf };
1190
+ }
1191
+
1192
+ /**
1193
+ * A red run's rows: the file the LAST check judged for job line `n`, from the run's own `output-judged` spine record `{step, iteration, path,
1194
+ * bytes, sha256}` (src/planrun.js, one per check run, written before the next try overwrites the file). The spine step id is a step NAME, so the
1195
+ * line is matched by the path the signed close judges (`stepFileRel(n)`), never by a path taken from a record or a request. It is resolved against
1196
+ * the run's own tree root (`treeRootForRun`: `row.patient`, else the source-seed `tree/`), must stay inside it, and must still match the record's
1197
+ * sha256 — stale content is never returned. `none` = no such record (an old spine, or a try that never reached its check).
1198
+ * @param {any[]} records @param {number} n @param {string|null} root
1199
+ * @returns {{state: 'ok', buf: Buffer}|{state: 'gone'|'changed'|'none', buf: null}}
1200
+ */
1201
+ function readJudged(records, n, root) {
1202
+ const rel = stepFileRel(n);
1203
+ const rec = records.findLast((r) => r && r.type === 'output-judged' && r.path === rel && typeof r.sha256 === 'string');
1204
+ if (!rec) return { state: 'none', buf: null };
1205
+ if (root === null) return { state: 'gone', buf: null };
1206
+ let abs;
1207
+ try {
1208
+ abs = realpathSync(join(root, rel));
1209
+ const base = realpathSync(root);
1210
+ if (!abs.startsWith(base + sep)) return { state: 'gone', buf: null };
1211
+ } catch { return { state: 'gone', buf: null }; }
1212
+ const got = readDelivered({ path: abs, sha256: rec.sha256 });
1213
+ return got.state === 'ok' ? got : { state: got.state, buf: null };
1214
+ }
1215
+
1216
+ /** a delivered step file's job line: `steps/<n>-YYYY-MM-DD[-k].json` (copyOut's dated name of `output/steps/<n>.json`), else null */
1217
+ function stepOfDelivered(path) {
1218
+ const m = /^(\d+)-\d{4}-\d{2}-\d{2}(?:-\d+)?\.json$/.exec(basename(path));
1219
+ return m ? Number(m[1]) : null;
1220
+ }
1221
+
1222
+ /**
1223
+ * Did this stage's check pass in the run's own record? The latest `check-run` for the stage says `satisfied` AND the run's last job-end is green.
1224
+ * Read only — the Result tab never decides anything.
1225
+ * @param {any[]} records @param {string} stageName
1226
+ * @returns {boolean}
1227
+ */
1228
+ function stagePassedIn(records, stageName) {
1229
+ const end = records.findLast((r) => r && r.type === 'job-end');
1230
+ if (!end || end.outcome !== 'green') return false;
1231
+ const run = records.findLast((r) => r && r.type === 'check-run' && r.name === stageName);
1232
+ return !!run && run.verdict === 'satisfied';
1233
+ }
1234
+
1235
+ /** the words beside a Result-tab page link: the page is read again from the web, so it can differ from what the run read */
1236
+ export const RESULT_PAGE_MAY_CHANGE = 'The page may have changed since this run.';
1237
+
1238
+ /**
1239
+ * The web address each input LABEL was read from, off the run's own `source.json` (`inputs[].url`, written at drafting and rewritten at run start).
1240
+ * Only http(s) addresses; a file or repo input has no `url` and so no entry. No source.json beside the run = an empty map (no link, nothing guessed).
1241
+ * @param {string} spinePath @returns {Map<string, string>}
1242
+ */
1243
+ function pageUrlsByLabel(spinePath) {
1244
+ /** @type {Map<string, string>} */
1245
+ const out = new Map();
1246
+ const { sourceJsonPath } = sourceNearSpine(spinePath);
1247
+ if (sourceJsonPath === null) return out;
1248
+ try {
1249
+ const m = JSON.parse(readFileSync(sourceJsonPath, 'utf8'));
1250
+ for (const r of Array.isArray(m?.inputs) ? m.inputs : []) {
1251
+ if (r && typeof r.label === 'string' && typeof r.url === 'string' && isWebUrl(r.url)) out.set(r.label, r.url.trim());
1252
+ }
1253
+ } catch { /* unreadable record: no link */ }
1254
+ return out;
1255
+ }
1256
+
1257
+ /**
1258
+ * What a result's check read, from the run's own `rubric-evidence` record (report-only; bareguard 0.25 `full.evidence`), for the proof fold.
1259
+ * `null` = no evidence (an old run, a malformed claim, or a quote that is not the one the check read): the page shows the plain quote.
1260
+ * `text` is bareguard's normalized quote; `marks` are `[start, end]` UTF-16 offsets into it, sorted, overlaps merged. Which spans: every span the
1261
+ * check read EXCEPT a bind-only membership token that is not this claim's (`canon` present, `matched` absent) - hamr 2026-10-19, ruled A.
1262
+ * The offsets are bareguard's; nothing here searches the quote for anything.
1263
+ * @param {any} ev one `evidence` entry @param {unknown} quote the result's own quote as the file holds it
1264
+ * @returns {{text: string, marks: number[][]}|null}
1265
+ */
1266
+ function proofOf(ev, quote) {
1267
+ if (!ev || typeof ev.text !== 'string' || !Array.isArray(ev.spans) || typeof quote !== 'string') return null;
1268
+ // the entry must be about THIS quote (bareguard's normalization: marks removed, whitespace collapsed, trimmed), else it is another try's
1269
+ if (quote.replace(/\*\*|__/g, '').replace(/\s+/g, ' ').trim() !== ev.text) return null;
1270
+ const spans = ev.spans
1271
+ .filter((/** @type {any} */ sp) => sp && Number.isInteger(sp.start) && Number.isInteger(sp.end) && sp.start >= 0 && sp.end > sp.start && sp.end <= ev.text.length
1272
+ && !(sp.canon !== undefined && sp.matched !== true))
1273
+ .map((/** @type {any} */ sp) => [sp.start, sp.end])
1274
+ .sort((/** @type {number[]} */ x, /** @type {number[]} */ y) => x[0] - y[0] || x[1] - y[1]);
1275
+ /** @type {number[][]} */
1276
+ const marks = [];
1277
+ for (const sp of spans) {
1278
+ const last = marks[marks.length - 1];
1279
+ if (last && sp[0] < last[1]) last[1] = Math.max(last[1], sp[1]);
1280
+ else marks.push([sp[0], sp[1]]);
1281
+ }
1282
+ return { text: ev.text, marks };
1283
+ }
1284
+
1285
+ /**
1286
+ * A money fact's amount exactly as the page wrote it ("144 US dollars", "€344"): the quote's text from the first to the last of the value's own
1287
+ * currency/unit/value spans in bareguard's evidence for this claim (a membership token that is not the claim's own is left out). `null` = nothing
1288
+ * located, or more than a short run: the caller keeps today's text. Offsets are bareguard's; nothing is searched.
1289
+ * @param {any} ev one `evidence` entry (already known to be about this result's quote) @param {number} j the fact's index (bareguard's decide index)
1290
+ * @returns {string|null}
1291
+ */
1292
+ function moneyAsRead(ev, j) {
1293
+ const own = ev.spans.filter((/** @type {any} */ sp) => sp && sp.decide === j && ['value', 'currency', 'unit'].includes(sp.role)
1294
+ && Number.isInteger(sp.start) && Number.isInteger(sp.end) && sp.start >= 0 && sp.end > sp.start && sp.end <= ev.text.length
1295
+ && !(sp.canon !== undefined && sp.matched !== true));
1296
+ if (own.length === 0) return null;
1297
+ const t = ev.text.slice(Math.min(...own.map((/** @type {any} */ sp) => sp.start)), Math.max(...own.map((/** @type {any} */ sp) => sp.end))).trim();
1298
+ return t !== '' && t.length <= 40 ? t : null;
1299
+ }
1300
+
1301
+ /** a fact's cell: a money amount in the fact's own signed currency/unit (src/facts.js moneyAs), anything else as the file wrote it */
1302
+ function resultCell(f, v) {
1303
+ const t = cellText(v);
1304
+ return f.shape === 'money' && /^\d[\d.,]*$/.test(t.trim()) ? moneyAs(f, t.trim(), true) : t;
1305
+ }
1306
+
1307
+ /** the words after a fact whose limit the person agreed to in a Part 5b look ask */
1308
+ export const RESULT_YOU_CONFIRMED = '(you confirmed)';
1309
+
1310
+ /**
1311
+ * The facts of line `n` whose limit came from a Part 5b `agree` answer, read from the drafting session's `human` rows (source 'look', this line,
1312
+ * the answer `agree`): the ask proposed `<fact name> <ruleWords>`, and `agree` wrote exactly that as a `~` rule on the line. A fact is confirmed only
1313
+ * when that proposal equals what the SIGNED fact says now AND the line's signed `~` rules hold it word for word. No draft log (a Reuse run, a CLI
1314
+ * run, a deleted session) = nothing is confirmed; nothing is guessed.
1315
+ * @param {any} row @param {any} jobLine the signed job line @param {any} fp the signed fact part @param {{ home?: string, sessionsRoot?: string }} opts
1316
+ * @returns {Set<string>} fact names
1317
+ */
1318
+ function confirmedFactNames(row, jobLine, fp, opts) {
1319
+ const names = new Set();
1320
+ const dir = sessionDirForRow(row, opts);
1321
+ if (!dir || !jobLine) return names;
1322
+ const written = lineRules(jobLine);
1323
+ const humans = readDraftSteps(dir).flatMap((st) => st.humans);
1324
+ for (const h of humans) {
1325
+ if (h.source !== 'look' || h.line !== jobLine.n || !isAgree(h.answer)) continue;
1326
+ const reading = readingOfQuestion(h.question);
1327
+ if (reading === null || !written.includes(reading)) continue;
1328
+ for (const f of fp.facts) if (f.rule && reading === `${f.name} ${ruleWords(f)}`) names.add(f.name);
1329
+ }
1330
+ return names;
1331
+ }
1332
+
1333
+ /** a result value as the file wrote it, for a table cell */
1334
+ function cellText(v) {
1335
+ if (typeof v === 'string') return v;
1336
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
1337
+ return '';
1338
+ }
1339
+
1340
+ /**
1341
+ * `GET /api/runs/:runid/result` — the Result tab: for each rubric job line, its words and `~` rules from the signed spec, the facts it requires
1342
+ * (worded by {@link factSentence}), and — read from the run's own delivered step file, sha256-checked against its `destination-written`
1343
+ * record — one row per result with each fact's cell. `passed` is read from the run's own `check-run` record; the tab never re-decides. A red run
1344
+ * delivers nothing, so it has no rows: it carries the check's own first gap instead. `null` = no such run or no result tab for it.
1345
+ * @param {string} runid @param {{ home?: string }} opts
1346
+ */
1347
+ export function getRunResult(runid, /** @type {{ home?: string, sessionsRoot?: string }} */ opts = {}) {
1348
+ const row = findRunRow(runid, opts);
1349
+ if (!row || row.via === 'import' || !existsSync(row.spine)) return null;
1350
+ const records = parseJsonl(row.spine).records;
1351
+ const spec = resolveSpecForRow(row);
1352
+ const stages = rubricStagesOf(spec);
1353
+ if (stages.length === 0) return null;
1354
+ const delivered = deliveredOf(records);
1355
+ const read = delivered.map((d) => readDelivered(d));
1356
+ const jobLines = Array.isArray(spec?.jobLines) ? spec.jobLines : [];
1357
+ // Not checked, split by the line the SIGNED plan ties each item to: a refusal carries its line (closeDecl.refused[].line, planFromSpec's
1358
+ // `lines[].refused`), a prose step carries its stage's step. What the plan ties to no line stays in `notChecked` (the bottom). Together they are
1359
+ // exactly the Job tab's items (its per-line "not checked" rows plus its Not checked fold) — never a second spelling.
1360
+ const plan = planFromSpec(spec);
1361
+ const proseByLine = proseNotCheckedItems(spec?.closeDecl);
1362
+ const prosePool = proseByLine.map((i) => i.text);
1363
+ const tiedToLine = (/** @type {number} */ n) => [...(plan.lines.find((l) => l.n === n)?.refused ?? []), ...proseByLine.filter((i) => i.line === n).map((i) => i.text)];
1364
+ const bottom = [...plan.notChecked];
1365
+ for (const t of prosePool) { const k = bottom.indexOf(t); if (k >= 0) bottom.splice(k, 1); }
1366
+ const pageUrls = pageUrlsByLabel(row.spine);
1367
+ const greenEnd = records.findLast((r) => r && r.type === 'job-end')?.outcome === 'green';
1368
+ const lines = stages.map((/** @type {any} */ st) => {
1369
+ const n = st.params.step;
1370
+ const jl = jobLines.find((/** @type {any} */ l) => l && l.n === n) ?? null;
1371
+ const fp = factPartOf(st.params);
1372
+ const di = delivered.map((d, i) => ({ d, i })).filter(({ d }) => stepOfDelivered(d.path) === n).pop() ?? null;
1373
+ const passed = stagePassedIn(records, st.name);
1374
+ /** @type {any} */
1375
+ const out = {
1376
+ notChecked: tiedToLine(n),
1377
+ n, text: jl && typeof jl.text === 'string' ? jl.text : '', rules: jl ? lineRules(jl) : [], kind: fp ? 'facts' : 'text', passed,
1378
+ file: di ? { i: di.i, state: read[di.i].state, words: read[di.i].state === 'gone' ? RESULT_FILE_GONE : (read[di.i].state === 'changed' ? RESULT_FILE_CHANGED : null) } : null,
1379
+ };
1380
+ let obj = null;
1381
+ if (di && read[di.i].buf) { try { obj = JSON.parse(/** @type {Buffer} */ (read[di.i].buf).toString('utf8')); } catch { obj = null; } }
1382
+ if (!di && !greenEnd) {
1383
+ // a red run: the last file the check judged (never a ✓: `passed` is false for it)
1384
+ const j = readJudged(records, n, treeRootForRun(row));
1385
+ if (j.state !== 'none') {
1386
+ out.file = { i: null, state: j.state, words: j.state === 'gone' ? RESULT_JUDGED_GONE : (j.state === 'changed' ? RESULT_JUDGED_CHANGED : null), judged: true, note: RESULT_JUDGED_NOTE };
1387
+ if (j.buf) { try { obj = JSON.parse(j.buf.toString('utf8')); } catch { obj = null; } }
1388
+ }
1389
+ } else if (di && !read[di.i].buf) {
1390
+ // the delivered file is gone or changed: that is said ONCE, in the Saved card. The line shows the rows of the file the check read, if the
1391
+ // run's own tree still holds exactly it (sha256 against the output-judged record), else the page says there is no saved result to show
1392
+ const j = readJudged(records, n, treeRootForRun(row));
1393
+ if (j.buf) { try { obj = JSON.parse(j.buf.toString('utf8')); out.fromCheck = true; } catch { obj = null; } }
1394
+ }
1395
+ if (fp) {
1396
+ out.source = fp.source;
1397
+ out.page = pageUrls.get(fp.source) ?? null;
1398
+ const confirmed = confirmedFactNames(row, jl, fp, opts);
1399
+ out.facts = fp.facts.map((/** @type {any} */ f) => ({
1400
+ name: f.name, kind: f.shape === 'phrase' ? 'phrase' : 'value', sentence: factSentence(f, fp.source),
1401
+ ...(confirmed.has(f.name) ? { note: RESULT_YOU_CONFIRMED } : {}),
1402
+ }));
1403
+ const results = obj && Array.isArray(obj.results) ? obj.results : null;
1404
+ // what the check read, from the run's last `rubric-evidence` record for this line (the claim index IS the result's index in `results[]`)
1405
+ const evRec = records.findLast((r) => r && r.type === 'rubric-evidence' && r.step === n && Array.isArray(r.evidence));
1406
+ const evCheck = `p${st.params.parts.indexOf(fp) + 1}-facts`;
1407
+ out.rows = results === null ? null : results.map((/** @type {any} */ r, /** @type {number} */ k) => {
1408
+ const values = r && typeof r === 'object' && r.values && typeof r.values === 'object' ? r.values : {};
1409
+ const quote = r && typeof r === 'object' && typeof r.quote === 'string' ? r.quote : '';
1410
+ const ev = evRec ? evRec.evidence.find((/** @type {any} */ e) => e && e.check === evCheck && e.claim === k) : null;
1411
+ const proof = proofOf(ev, quote);
1412
+ return {
1413
+ n: k + 1,
1414
+ quote,
1415
+ ...(proof ? { proof } : {}),
1416
+ // a money amount is shown the way the check read it off the page; without evidence (or nothing located) it is the fact's own spelling
1417
+ cells: fp.facts.map((/** @type {any} */ f, /** @type {number} */ j) => (f.shape === 'phrase' ? String(f.phrase)
1418
+ : ((f.shape === 'money' && proof ? moneyAsRead(ev, j) : null) ?? resultCell(f, values[f.name])))),
1419
+ };
1420
+ });
1421
+ } else if (obj && typeof obj === 'object') {
1422
+ out.answer = typeof obj.text === 'string' ? obj.text : null;
1423
+ const cites = obj.cites && typeof obj.cites === 'object' ? obj.cites : {};
1424
+ out.cites = Object.entries(cites).filter(([, list]) => Array.isArray(list)).map(([label, list]) => ({
1425
+ label,
1426
+ items: /** @type {any[]} */ (list).map((c) => ({ claim: cellText(c?.claim), quote: cellText(c?.quote) })),
1427
+ }));
1428
+ }
1429
+ return out;
1430
+ });
1431
+ const end = records.findLast((r) => r && r.type === 'job-end');
1432
+ // a run that did not end green delivered nothing: say the check's own first gap (the Audit's try line words), never rows
1433
+ let gap = null;
1434
+ if (!end || end.outcome !== 'green') {
1435
+ const ev = records.findLast((r) => r && r.type === 'exit-eval' && Array.isArray(r.results) && r.results.some((/** @type {any} */ x) => x && x.type === 'check-passes' && x.pass === false));
1436
+ const bad = ev ? ev.results.find((/** @type {any} */ x) => x && x.type === 'check-passes' && x.pass === false) : null;
1437
+ const g = bad ? gapOneLine(bad.detail) : null;
1438
+ gap = g ? { line: g.line, more: g.more, moreText: g.more > 0 ? `(+${g.more} more)` : null } : null;
1439
+ }
1440
+ // the distinct web pages the results read, in line order: ONE = the page draws a single link at the top, 2+ = each line carries its own `page`
1441
+ const pages = [...new Set(lines.map((/** @type {any} */ l) => l.page).filter((/** @type {any} */ u) => typeof u === 'string'))];
1442
+ return {
1443
+ runid,
1444
+ lines,
1445
+ pages,
1446
+ pageNote: pages.length ? RESULT_PAGE_MAY_CHANGE : null,
1447
+ notChecked: bottom,
1448
+ // the door's words, from the one table (src/panel/status.js DOOR_WORDS); the page types none of them
1449
+ door: { head: DOOR_WORDS.waiting.head, ask: RESULT_DOOR_ASK },
1450
+ delivered: delivered.map((d, i) => ({
1451
+ i, path: d.path, name: basename(d.path), bytes: d.bytes, state: read[i].state,
1452
+ words: read[i].state === 'gone' ? RESULT_FILE_GONE : (read[i].state === 'changed' ? RESULT_FILE_CHANGED : null),
1453
+ })),
1454
+ gap,
1455
+ };
1456
+ }
1457
+
1458
+ /** download content types by extension; anything else is plain bytes */
1459
+ const DOWNLOAD_TYPES = { '.json': 'application/json', '.md': 'text/plain', '.txt': 'text/plain', '.csv': 'text/csv' };
1460
+
1461
+ /**
1462
+ * `GET /api/runs/:runid/download?i=<n>` — delivered file `n` (an index into the run's own `destination-written` list), or why not. The path is
1463
+ * the record's, never the request's; the sha256 must still match the record.
1464
+ * @param {string} runid @param {unknown} index @param {{ home?: string }} opts
1465
+ * @returns {{ok: true, name: string, type: string, buf: Buffer}|{ok: false, code: number, error: string}}
1466
+ */
1467
+ export function getRunDownload(runid, index, opts = {}) {
1468
+ const row = findRunRow(runid, opts);
1469
+ if (!row || row.via === 'import' || !existsSync(row.spine)) return { ok: false, code: 404, error: 'no such run' };
1470
+ const delivered = deliveredOf(parseJsonl(row.spine).records);
1471
+ const i = typeof index === 'string' && /^\d{1,6}$/.test(index) ? Number(index) : -1;
1472
+ if (i < 0 || i >= delivered.length) return { ok: false, code: 404, error: 'this run saved no such file' };
1473
+ const got = readDelivered(delivered[i]);
1474
+ if (got.state !== 'ok') return { ok: false, code: 409, error: got.state === 'gone' ? RESULT_FILE_GONE : RESULT_FILE_CHANGED };
1475
+ const name = basename(delivered[i].path);
1476
+ const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : '';
1477
+ return { ok: true, name, type: Object.hasOwn(DOWNLOAD_TYPES, ext) ? /** @type {Record<string,string>} */ (DOWNLOAD_TYPES)[ext] : 'application/octet-stream', buf: got.buf };
1478
+ }
1479
+
1096
1480
  /**
1097
1481
  * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
1098
1482
  * iterations), counters, summary-box fields. Looks `runid` up in the run
@@ -1164,14 +1548,14 @@ function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
1164
1548
  const originNo = nos.get(cur.originRunid) ?? null;
1165
1549
  const tries = door.skippedFor(job);
1166
1550
  const running = rows.some((r) => r && r.job === job && r.via !== 'import' && runIsAlive(r));
1167
- const { sign } = scheduleSignFor({ running, red: !!(view && view.problem) });
1551
+ const { sign, word } = scheduleSignFor({ running, red: !!(view && view.problem) });
1168
1552
  const words = scheduledWords(cur.trigger, door.userHome);
1169
1553
  const head = [lowerFirst(words ?? ''), originNo === null ? null : `from run-${originNo}`, triesTailWords(tries.length) || null].filter(Boolean).join(' · ');
1170
1554
  const when = row ? dateTimeWords(atMs(row)) : '';
1171
1555
  const thisRun = rec ? sectionThisRunWords(rec) : `by hand.${runid === cur.originRunid ? ' The schedule was set from this run.' : ''}`;
1172
1556
  const shown = tries.slice(0, 7).map((x, i) => ({ label: i === 0 ? 'tries' : '', text: x.line }));
1173
1557
  const scheduleSection = {
1174
- title: SCHEDULE_WORDS.scheduleGroup, sign, head,
1558
+ title: SCHEDULE_WORDS.scheduleGroup, sign, word, head,
1175
1559
  rows: [{ label: 'this run', text: `${when} ${thisRun}` }, ...(shown.length > 0 ? shown : [{ label: 'tries', text: 'none' }])],
1176
1560
  };
1177
1561
  return { ...(schedule ? { schedule } : {}), audit: { scheduleSection } };
@@ -1187,6 +1571,8 @@ function getRunDetailBody(runid, opts) {
1187
1571
  const row = findRunRow(runid, opts);
1188
1572
  if (!row) return null;
1189
1573
  if (!existsSync(row.spine)) {
1574
+ const refusal = refusalWordsOf(readRunList(opts).events, row);
1575
+ if (refusal !== null) return refusedStub(row, refusal);
1190
1576
  if (runIsAlive(row)) return startingStub(row);
1191
1577
  return {
1192
1578
  runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
@@ -1208,7 +1594,8 @@ function getRunDetailBody(runid, opts) {
1208
1594
  // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
1209
1595
  const draftingPartHere = draftingFor(row, summary, opts);
1210
1596
  const draftingShiftHere = draftingShift(row, summary, opts);
1211
- const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
1597
+ const specForParts = resolveSpecForRow(row);
1598
+ const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(specForParts), specForParts);
1212
1599
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
1213
1600
  const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
1214
1601
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
@@ -1461,9 +1848,16 @@ function getRunDetailBody(runid, opts) {
1461
1848
  // carries no contamination risk); `null` when the spine carries no
1462
1849
  // `memory-cache` record at all (never armed on this run).
1463
1850
  behaviour: scopedBehaviour(row, rawSpineRecords),
1851
+ // PRD v1.106 Part 5 F: the two Run summary rows, worded here (the page prints them)
1852
+ callsLine: callsLineOf(rawSpineRecords, scopedBehaviour(row, rawSpineRecords)),
1853
+ checksLine: checksLineOf(rawSpineRecords, enrichedParts),
1464
1854
  memoryCache: summary.memoryCache,
1465
1855
  // T Part 8: is this run waiting for the person's Accept? The SAME answer fills the runs list's Needs-you tag (`doorWaiting`, one owner)
1466
1856
  accept: !runIsAlive(row) && doorWaiting(rawSpineRecords),
1857
+ // PRD v1.107 Part 6: the Result tab shows for a rubric run whose signed close has a rubric-step stage, never otherwise; `delivered` is the
1858
+ // files the run's own `destination-written` records name (the Result tab's downloads index into this list, never a path from a request)
1859
+ hasResult: checkTypeLabel(summary.verdictType, row.at) === 'rubric' && rubricStagesOf(specForParts).length > 0,
1860
+ delivered: deliveredOf(rawSpineRecords).map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })),
1467
1861
  // T Part 7: why a SCHEDULED run started (the words `trigger.json` carried) and the fixed catch-up sentence; null on every hand run
1468
1862
  ...(() => {
1469
1863
  const trig = triggerRecordOf(row);
@@ -1900,7 +2294,7 @@ export const ROUNDS_PAGE_MAX = 200;
1900
2294
  * resolve to a real window, or the spine file is missing — the caller
1901
2295
  * renders 404, same posture as {@link getRunAudit}/{@link getRunJob}.
1902
2296
  * @param {string} runid
1903
- * @param {{ part?: number, attempt?: number, offset?: number, limit?: number }} query
2297
+ * @param {{ all?: boolean, part?: number, attempt?: number, offset?: number, limit?: number }} query
1904
2298
  * @param {{ home?: string }} [opts]
1905
2299
  * @returns {any|null}
1906
2300
  */
@@ -1922,6 +2316,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1922
2316
  if (shift && partIndex === 0) return null;
1923
2317
  const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
1924
2318
  if (!part) return null;
2319
+ // PRD v1.106 Part 5 (C): a plan step with graded tries shows ONE table for the whole step (a Try column, round numbers from 1 per step,
2320
+ // a check row after each try, 20 rows a page); `all=1` asks for it. Every other part keeps its per-attempt table.
2321
+ if (query.all === true && part.kind === 'step' && Array.isArray(part.attempts) && part.attempts.some((x) => Array.isArray(x.checks))) {
2322
+ return stepRoundsPage(runid, row, part, { partIndex, offset, limit: Number.isInteger(query.limit) && /** @type {number} */ (query.limit) > 0 ? Math.min(/** @type {number} */ (query.limit), ROUNDS_PAGE_MAX) : STEP_ROWS_PAGE }, opts);
2323
+ }
1925
2324
  const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
1926
2325
  if (!a) return null;
1927
2326
  /** @type {{startSeq: number, endSeq: number, outcome: string|null, detail: string|null}} */
@@ -1983,6 +2382,90 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1983
2382
  };
1984
2383
  }
1985
2384
 
2385
+ /** the Audit step table's page size (PRD v1.106 Part 5 choice 3): rows, not rounds */
2386
+ export const STEP_ROWS_PAGE = 20;
2387
+
2388
+ /** the Round table's check row: the sign and the plain words, ONE place (PRD v1.106 Part 5 C) @param {number} passed @param {number} failed */
2389
+ export function checkRowWords(passed, failed) {
2390
+ return failed > 0 ? { sign: '✗', text: `${failed} failed, ${passed} passed` } : { sign: '✓', text: `${passed} passed` };
2391
+ }
2392
+
2393
+ /**
2394
+ * One plan step's whole Round table (PRD v1.106 Part 5 C): every model call, every tool call and one check row per graded try, in order, each
2395
+ * with its Try; round numbers restart at 1 inside the step (the flat Audit view keeps the run-wide numbers). A page is `limit` ROWS.
2396
+ * @param {string} runid @param {any} row @param {any} part @param {{partIndex: number, offset: number, limit: number}} q @param {{ home?: string }} opts
2397
+ */
2398
+ function stepRoundsPage(runid, row, part, q, opts) {
2399
+ const attempts = part.attempts.filter((/** @type {any} */ x) => Array.isArray(x.checks));
2400
+ const cut = part.cutOffTry ?? null;
2401
+ /** @type {Array<{n: number, startSeq: number, endSeq: number, a: any}>} */
2402
+ const windows = attempts.map((/** @type {any} */ a) => ({ n: a.n, startSeq: a.startSeq, endSeq: a.endSeq, a }));
2403
+ if (cut) windows.push({ n: cut.n, startSeq: cut.startSeq, endSeq: cut.endSeq, a: null });
2404
+ const lo = windows[0].startSeq;
2405
+ const hi = windows[windows.length - 1].endSeq;
2406
+ const tryOfSeq = (/** @type {number} */ seq) => windows.find((w) => seq > w.startSeq && seq <= w.endSeq)?.n ?? windows[windows.length - 1].n;
2407
+ const { records: spineRecords } = parseJsonl(row.spine);
2408
+ const roundOf = makeRoundLookup(spineRecords);
2409
+ const spend = spineRecords
2410
+ .filter((/** @type {any} */ r) => r && SPEND_RECORD_TYPES.includes(r.type) && typeof r.seq === 'number' && r.seq > lo && r.seq <= hi)
2411
+ .sort((/** @type {any} */ a, /** @type {any} */ b) => a.seq - b.seq);
2412
+ const auditResult = getRunAudit(runid, opts);
2413
+ const toolLogSaved = !!auditResult && auditResult.reason !== 'no-sidecar';
2414
+ /** @type {Map<number, any[]>} */
2415
+ const toolsByRound = new Map();
2416
+ if (toolLogSaved && auditResult) {
2417
+ for (const r of auditResult.rows) {
2418
+ if (r.kind !== 'tool-call' || typeof r.round !== 'number') continue;
2419
+ const bucket = toolsByRound.get(r.round) ?? [];
2420
+ bucket.push(r);
2421
+ toolsByRound.set(r.round, bucket);
2422
+ }
2423
+ }
2424
+ const spec = resolveSpecForRow(row);
2425
+ const exitTs = new Map(spineRecords.filter((/** @type {any} */ r) => r && r.type === 'exit-eval' && typeof r.seq === 'number').map((/** @type {any} */ r) => [r.seq, r.ts]));
2426
+ /** @type {Array<{seq: number, rows: any[]}>} */
2427
+ const blocks = [];
2428
+ spend.forEach((/** @type {any} */ r, i) => {
2429
+ const runWide = roundOf(r.ts);
2430
+ const tryN = tryOfSeq(r.seq);
2431
+ const local = i + 1;
2432
+ const modelCall = toolLogSaved && auditResult ? auditResult.rows.find((x) => x.kind === 'model-call' && x.round === runWide) : null;
2433
+ const rows = [{
2434
+ kind: 'model', round: local, try: tryN, time: typeof r.ts === 'string' ? r.ts : null,
2435
+ costUsd: typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? r.costUsd : null,
2436
+ tokens: typeof r.tokens === 'number' && Number.isFinite(r.tokens) ? r.tokens : null,
2437
+ durationMs: modelCall ? modelCall.durationMs : null,
2438
+ }];
2439
+ for (const tc of (runWide === null ? [] : (toolsByRound.get(runWide) ?? []))) rows.push({ ...tc, kind: 'tool', round: local, try: tryN });
2440
+ blocks.push({ seq: r.seq, rows });
2441
+ });
2442
+ for (const w of windows) {
2443
+ if (!w.a) continue; // a try cut off before its checks ran has no check row
2444
+ const passed = w.a.checks.filter((/** @type {any} */ c) => c.pass).length;
2445
+ const failed = w.a.checks.length - passed;
2446
+ const named = w.a.checks.find((/** @type {any} */ c) => c.type === 'check-passes');
2447
+ const judgedPath = named && named.name ? judgedFilesOf(spec?.closeDecl, named.name)[0] ?? null : null;
2448
+ blocks.push({
2449
+ seq: w.endSeq,
2450
+ rows: [{ kind: 'check', round: null, try: w.n, time: exitTs.get(w.endSeq) ?? null, path: judgedPath, ...checkRowWords(passed, failed) }],
2451
+ });
2452
+ }
2453
+ blocks.sort((a, b) => a.seq - b.seq);
2454
+ const all = blocks.flatMap((b) => b.rows);
2455
+ return {
2456
+ runid,
2457
+ part: q.partIndex,
2458
+ partKind: 'step',
2459
+ mode: 'step',
2460
+ step: partDisplayLabel(part),
2461
+ rows: all.slice(q.offset, q.offset + q.limit),
2462
+ totalRows: all.length,
2463
+ offset: q.offset,
2464
+ limit: q.limit,
2465
+ toolLogSaved,
2466
+ };
2467
+ }
2468
+
1986
2469
  /**
1987
2470
  * The `↳ close:`-style text for one iteration's OWN close-verdict — same
1988
2471
  * shape as `src/replay.js`'s private `closeStageLine`, but that helper is
@@ -2236,7 +2719,7 @@ function stageQuestionText({
2236
2719
  * ran. `null` when the spec has no `closeDecl.stages` array at all (a
2237
2720
  * command-close spec, or no spec resolved).
2238
2721
  * @param {any} spec
2239
- * @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
2722
+ * @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>|null}
2240
2723
  */
2241
2724
  function stageKindMetaFromSpec(spec) {
2242
2725
  if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
@@ -2252,7 +2735,7 @@ function stageKindMetaFromSpec(spec) {
2252
2735
  if (key !== null) suiteCmdKeys.add(key);
2253
2736
  }
2254
2737
  }
2255
- /** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
2738
+ /** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>} */
2256
2739
  const map = new Map();
2257
2740
  for (const s of spec.closeDecl.stages) {
2258
2741
  if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
@@ -2264,7 +2747,7 @@ function stageKindMetaFromSpec(spec) {
2264
2747
  kind, params, genre, suiteCmdKeys,
2265
2748
  });
2266
2749
  map.set(s.name, {
2267
- kind, direction, baselineKind, question,
2750
+ kind, direction, baselineKind, question, fromLine: Array.isArray(s.fromLine) ? s.fromLine.filter(Number.isInteger) : [],
2268
2751
  });
2269
2752
  }
2270
2753
  return map;
@@ -2334,21 +2817,261 @@ function attachStageKind(stage, kindMeta) {
2334
2817
  * @param {Map<string, any>|null} kindMeta
2335
2818
  * @returns {any[]|null|undefined}
2336
2819
  */
2337
- function enrichPartsWithStageKind(parts, kindMeta) {
2820
+ function enrichPartsWithStageKind(parts, kindMeta, spec = null) {
2338
2821
  if (!Array.isArray(parts)) return parts;
2339
2822
  const declaredStagesTotal = kindMeta ? kindMeta.size : null;
2340
2823
  const declaredStages = kindMeta
2341
2824
  ? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
2342
2825
  : null;
2343
- return parts.map((part) => ({
2344
- ...part,
2345
- attempts: Array.isArray(part.attempts) ? part.attempts.map((a) => ({
2346
- ...a,
2347
- stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages,
2348
- ...(declaredStagesTotal ? { declaredStagesTotal } : {}),
2349
- ...(declaredStages ? { declaredStages } : {}),
2350
- })) : part.attempts,
2826
+ return parts.map((part) => {
2827
+ const attempts = Array.isArray(part.attempts) ? part.attempts.map((a) => {
2828
+ // PRD v1.106 Part 5 (D1): a plan step's attempt carries the checks its try RAN (replay), worded here; they also stand in as the attempt's
2829
+ // `stages`, which is what the page's headline and check list read (before this they were null, so every check read "not run")
2830
+ const checks = Array.isArray(a.checks) ? tryChecksOf(a.checks, kindMeta, spec) : null;
2831
+ return {
2832
+ ...a,
2833
+ ...(checks ? { checks, judged: (Array.isArray(a.outputJudged) ? a.outputJudged : []).map(judgedFileLine), stages: checks.map(stageOfCheck) } : { stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages }),
2834
+ ...(declaredStagesTotal ? { declaredStagesTotal } : {}),
2835
+ ...(declaredStages ? { declaredStages } : {}),
2836
+ };
2837
+ }) : part.attempts;
2838
+ return {
2839
+ ...part,
2840
+ attempts,
2841
+ ...(part.kind === 'step' ? (() => { const tries = triesOf(attempts, part.cutOffTry); return { tries, triesLine: triesLineOf(tries), marker: ACTOR_MARKER.model, tally: toolsTallyLine(part.byTool, part.blocked) }; })() : {}),
2842
+ ...(part.kind === 'door' && part.door ? { marker: ACTOR_MARKER.human, doorView: doorViewOf(part.door) } : {}),
2843
+ };
2844
+ });
2845
+ }
2846
+
2847
+ /** the words for a try the wall (or a cap, or a stop) ended before any of its checks ran (D2) */
2848
+ const CUT_OFF_WORDS = 'cut off before its checks ran';
2849
+
2850
+ /**
2851
+ * "71 model · 91 tool · 0 denied" — the Run summary's `calls:` row (PRD v1.106 Part 5 F). Model calls are the run's own spend records
2852
+ * (`worker-round`, `judge-round`; the attempt-level `worker-result` echoes are not rounds and drafting is not the run's), tool and denied come
2853
+ * from the gate-audit rows scoped to this run (the same figure `tools:` shows). No tool log saved says so, never a zero.
2854
+ * @param {any[]} records the run's raw spine @param {{totalCalls: number, denied: number}|null} behaviour
2855
+ * @returns {string}
2856
+ */
2857
+ export function callsLineOf(records, behaviour) {
2858
+ const model = records.filter((r) => r && (r.type === 'worker-round' || r.type === 'judge-round')).length;
2859
+ return behaviour
2860
+ ? `${model} model · ${behaviour.totalCalls} tool · ${behaviour.denied} denied`
2861
+ : `${model} model · tool calls: no log saved`;
2862
+ }
2863
+
2864
+ /** "1-5", "2", "1-2, 4" from try numbers @param {number[]} ns */
2865
+ function tryRanges(ns) {
2866
+ const out = [];
2867
+ for (let i = 0; i < ns.length; i += 1) {
2868
+ let j = i;
2869
+ while (j + 1 < ns.length && ns[j + 1] === ns[j] + 1) j += 1;
2870
+ out.push(j > i ? `${ns[i]}-${ns[j]}` : String(ns[i]));
2871
+ i = j;
2872
+ }
2873
+ return out.join(', ');
2874
+ }
2875
+
2876
+ /**
2877
+ * "16 run · 8 passed · 8 failed (write-flight-facts tries 1-5, then tries 1-3)" — counts of what ran, never a fraction (the "checks N/M"
2878
+ * headline stays withdrawn). Every `exit-eval` result plus every graded `outer-close` stage; the parenthesis names the steps and try ranges
2879
+ * that failed, from the same `tries[]` the Audit reads. A run with neither says `not recorded`.
2880
+ * @param {any[]} records the run's raw spine @param {any[]|null|undefined} parts the enriched parts
2881
+ * @returns {string}
2882
+ */
2883
+ export function checksLineOf(records, parts) {
2884
+ let run = 0;
2885
+ let passed = 0;
2886
+ let outerFailed = false;
2887
+ for (const r of records) {
2888
+ if (!r) continue;
2889
+ if (r.type === 'exit-eval' && Array.isArray(r.results)) {
2890
+ for (const x of r.results) { run += 1; if (x && x.pass === true) passed += 1; }
2891
+ } else if (r.type === 'outer-close' && Array.isArray(r.stages)) {
2892
+ for (const x of r.stages) { run += 1; if (x && x.verdict === 'satisfied') passed += 1; else outerFailed = true; }
2893
+ }
2894
+ }
2895
+ if (run === 0) return 'not recorded';
2896
+ /** @type {string[]} */
2897
+ const named = [];
2898
+ /** @type {Set<string>} */
2899
+ const seenLabel = new Set();
2900
+ for (const p of parts ?? []) {
2901
+ if (p.kind !== 'step' || !Array.isArray(p.tries)) continue;
2902
+ const bad = p.tries.filter((/** @type {any} */ t) => t.failed > 0).map((/** @type {any} */ t) => t.n);
2903
+ if (bad.length === 0) continue;
2904
+ const text = `tries ${tryRanges(bad)}`;
2905
+ named.push(seenLabel.has(p.label) ? `then ${text}` : `${p.label} ${text}`);
2906
+ seenLabel.add(p.label);
2907
+ }
2908
+ if (outerFailed) named.push('the outer close');
2909
+ const failed = run - passed;
2910
+ return `${run} run · ${passed} passed · ${failed} failed${named.length ? ` (${named.join(', ')})` : ''}`;
2911
+ }
2912
+
2913
+ /** 1 -> "1st", 4 -> "4th", 12 -> "12th" @param {number} n */
2914
+ function ordinal(n) {
2915
+ const v = n % 100;
2916
+ if (v >= 11 && v <= 13) return `${n}th`;
2917
+ return `${n}${({ 1: 'st', 2: 'nd', 3: 'rd' })[/** @type {1|2|3} */ (n % 10)] ?? 'th'}`;
2918
+ }
2919
+
2920
+ /**
2921
+ * The step card's extra line (PRD v1.106 Part 5 G): "5 tries · last try: 1 check passed, 1 failed", with the cut-off try named when there is one
2922
+ * ("4 tries (the 4th cut off before its checks ran) · last try: no checks ran"). Words for what passed and failed, never a fraction. `null`
2923
+ * when the step has no graded try (an old spine).
2924
+ * @param {Array<{n: number, cutOff: boolean, passed: number, failed: number, ran: number}>} tries
2925
+ * @returns {string|null}
2926
+ */
2927
+ export function triesLineOf(tries) {
2928
+ if (!Array.isArray(tries) || tries.length === 0) return null;
2929
+ const last = tries[tries.length - 1];
2930
+ const count = `${tries.length} ${tries.length === 1 ? 'try' : 'tries'}${last.cutOff ? ` (the ${ordinal(last.n)} ${CUT_OFF_WORDS})` : ''}`;
2931
+ const checks = (/** @type {number} */ n) => `${n} ${n === 1 ? 'check' : 'checks'}`;
2932
+ let lastWords;
2933
+ if (last.cutOff || last.ran === 0) lastWords = 'no checks ran';
2934
+ else if (last.failed === 0) lastWords = `${checks(last.passed)} passed`;
2935
+ else if (last.passed === 0) lastWords = `${checks(last.failed)} failed`;
2936
+ else lastWords = `${checks(last.passed)} passed, ${last.failed} failed`;
2937
+ return `${count} · last try: ${lastWords}`;
2938
+ }
2939
+
2940
+ /** tool kinds in the order the Audit tally lists them; anything else follows alphabetically */
2941
+ const TALLY_ORDER = ['read', 'grep', 'write', 'edit'];
2942
+
2943
+ /**
2944
+ * "tools: read 16 · grep 22 · write 5 · denied 0" — the step's tool calls by kind, then how many the gate denied (PRD v1.106 Part 5 C). The
2945
+ * `shell_`/`ctx_` prefixes are stripped like the rest of the page. `null` when no tool log was saved (nothing honest to count).
2946
+ * @param {Record<string, number>|null|undefined} byTool @param {number|null|undefined} blocked
2947
+ * @returns {string|null}
2948
+ */
2949
+ export function toolsTallyLine(byTool, blocked) {
2950
+ if (!byTool || typeof byTool !== 'object') return null;
2951
+ const named = Object.entries(byTool).map(([k, n]) => [k.replace(/^shell_/, '').replace(/^ctx_/, ''), n]);
2952
+ named.sort((a, b) => {
2953
+ const ia = TALLY_ORDER.indexOf(String(a[0]));
2954
+ const ib = TALLY_ORDER.indexOf(String(b[0]));
2955
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || String(a[0]).localeCompare(String(b[0]));
2956
+ });
2957
+ return `tools: ${[...named.map(([k, n]) => `${k} ${n}`), `denied ${typeof blocked === 'number' ? blocked : 0}`].join(' · ')}`;
2958
+ }
2959
+
2960
+ /**
2961
+ * A step's tries in order: each graded try (it ran checks) and, last, the one cut off before its checks ran (D2). `ran` is how many checks ran,
2962
+ * `passed`/`failed` how they went; a cut-off try ran none. The Audit, the step card and the Run summary all read this one list.
2963
+ * @param {any[]} attempts @param {any} cutOffTry
2964
+ * @returns {Array<{words?: string, n: number, cutOff: boolean, outcome: string|null, iteration: number|null, ran: number, passed: number, failed: number, thinking: string|null}>}
2965
+ */
2966
+ function triesOf(attempts, cutOffTry) {
2967
+ const graded = (Array.isArray(attempts) ? attempts : []).filter((a) => Array.isArray(a.checks)).map((a) => ({
2968
+ n: a.n,
2969
+ cutOff: false,
2970
+ outcome: a.outcome ?? null,
2971
+ iteration: typeof a.iteration === 'number' ? a.iteration : null,
2972
+ ran: a.checks.length,
2973
+ passed: a.checks.filter((/** @type {any} */ c) => c.pass).length,
2974
+ failed: a.checks.filter((/** @type {any} */ c) => !c.pass).length,
2975
+ thinking: typeof a.thinking === 'string' ? a.thinking : null,
2351
2976
  }));
2977
+ if (!cutOffTry) return graded;
2978
+ return [...graded, {
2979
+ words: CUT_OFF_WORDS, n: graded.length + 1, cutOff: true, outcome: null, iteration: typeof cutOffTry.iteration === 'number' ? cutOffTry.iteration : null, ran: 0, passed: 0, failed: 0, thinking: typeof cutOffTry.thinking === 'string' ? cutOffTry.thinking : null,
2980
+ }];
2981
+ }
2982
+
2983
+ /** the words for a `check-passes` result that no `check-run` named (an old spine), and the one tree-changed question, both code-owned here */
2984
+ const FILES_CHANGED_QUESTION = 'did it change any file?';
2985
+
2986
+ /**
2987
+ * A rule line's failing reasons, one per `close: <stage>: …` line of a check's detail, each with that prefix (and the `line N: ` the check row
2988
+ * already names) removed. A detail with no `close:` lines is one reason: its own first line.
2989
+ * @param {string|null|undefined} detail
2990
+ * @returns {string[]}
2991
+ */
2992
+ function reasonsOf(detail) {
2993
+ if (typeof detail !== 'string' || detail.trim() === '') return [];
2994
+ const lines = detail.split('\n').map((l) => l.trim()).filter((l) => l !== '');
2995
+ const closeLines = lines.filter((l) => l.startsWith('close: '));
2996
+ if (closeLines.length === 0) return [lines[0]];
2997
+ return closeLines.map((l) => l.replace(/^close: [^:]+: /, '').replace(/^line \d+: /, ''));
2998
+ }
2999
+
3000
+ /**
3001
+ * The try line's gap: the first failing reason, and how many more there are (PRD v1.106 Part 5 C). ONE function, beside {@link stageQuestionText}.
3002
+ * @param {string|null|undefined} detail
3003
+ * @returns {{ line: string, more: number }|null}
3004
+ */
3005
+ export function gapOneLine(detail) {
3006
+ const reasons = reasonsOf(detail);
3007
+ return reasons.length === 0 ? null : { line: reasons[0], more: reasons.length - 1 };
3008
+ }
3009
+
3010
+ /**
3011
+ * "from line 1 ~ under $450; not a red-eye" — the job line(s) a check serves and the `~` rules written under them (the signed `jobLines`).
3012
+ * `null` when the stage serves no job line (a mandatory guard).
3013
+ * @param {number[]} fromLine @param {any} spec
3014
+ * @returns {string|null}
3015
+ */
3016
+ export function fromLineText(fromLine, spec) {
3017
+ if (!Array.isArray(fromLine) || fromLine.length === 0) return null;
3018
+ const lines = Array.isArray(spec?.jobLines) ? spec.jobLines : [];
3019
+ return fromLine.map((n) => {
3020
+ const rule = lines.find((l) => l && l.n === n)?.rule;
3021
+ return `from line ${n}` + (typeof rule === 'string' && rule !== '' ? ` ~ ${rule}` : '');
3022
+ }).join(' · ');
3023
+ }
3024
+
3025
+ /**
3026
+ * One try's checks in the order they RAN (choice 6), worded: `{n, type, name, question, pass, detail, gap, more, reasons, from}`.
3027
+ * A `tree-changed` result is the declared `files-changed` stage; a `check-passes` result is the stage its `check-run` named.
3028
+ * @param {Array<{type: string, pass: boolean, detail: string|null, name: string|null}>} checks
3029
+ * @param {Map<string, any>|null} kindMeta @param {any} spec
3030
+ */
3031
+ function tryChecksOf(checks, kindMeta, spec) {
3032
+ const metas = kindMeta ? [...kindMeta] : [];
3033
+ const filesChanged = metas.find(([, m]) => m.kind === 'files-changed') ?? null;
3034
+ const others = metas.filter(([, m]) => m.kind !== 'files-changed');
3035
+ return checks.map((c, i) => {
3036
+ let name = c.name;
3037
+ let meta = name && kindMeta ? kindMeta.get(name) ?? null : null;
3038
+ if (c.type === 'tree-changed' && filesChanged) { name = filesChanged[0]; meta = filesChanged[1]; }
3039
+ if (c.type === 'check-passes' && !meta && others.length === 1) { name = others[0][0]; meta = others[0][1]; }
3040
+ const reasons = c.pass ? [] : reasonsOf(c.detail);
3041
+ const gap = c.pass ? null : gapOneLine(c.detail);
3042
+ return {
3043
+ n: i + 1,
3044
+ type: c.type,
3045
+ name: name ?? c.type,
3046
+ question: c.type === 'tree-changed' ? FILES_CHANGED_QUESTION : (meta?.question ?? null),
3047
+ pass: c.pass,
3048
+ // a files-changed result names its count; a failing check-passes names its reasons (folded on the page), a passing one nothing
3049
+ note: c.type === 'tree-changed' ? c.detail : (c.pass ? null : `${reasons.length} reason${reasons.length === 1 ? '' : 's'}`),
3050
+ gap: gap ? gap.line : null,
3051
+ more: gap ? gap.more : 0,
3052
+ moreText: gap && gap.more > 0 ? `(+${gap.more} more)` : null,
3053
+ reasonsLabel: reasons.length > 1 ? `the ${reasons.length} reasons` : null,
3054
+ reasons,
3055
+ from: meta ? fromLineText(meta.fromLine, spec) : null,
3056
+ };
3057
+ });
3058
+ }
3059
+
3060
+ /**
3061
+ * "output judged: output/steps/1.json · 2.1 KB · sha256 674b…30" — the file a try's check read (PRD v1.106 Part 5 choice 14). ONE place owns
3062
+ * these words. `size` is bytes under 1 KB, else KB with one decimal; the hash shows its first 4 and last 2 characters.
3063
+ * @param {{path: string|null, bytes: number|null, sha256: string|null}} o
3064
+ * @returns {string}
3065
+ */
3066
+ export function judgedFileLine(o) {
3067
+ const size = typeof o.bytes === 'number' ? (o.bytes < 1024 ? `${o.bytes} B` : `${(o.bytes / 1024).toFixed(1)} KB`) : null;
3068
+ const sha = typeof o.sha256 === 'string' && o.sha256.length >= 8 ? `sha256 ${o.sha256.slice(0, 4)}…${o.sha256.slice(-2)}` : null;
3069
+ return ['output judged:', [o.path ?? 'a file', size, sha].filter(Boolean).join(' · ')].join(' ');
3070
+ }
3071
+
3072
+ /** a worded check as the `{name, verdict, question}` stage shape the attempt headline reads */
3073
+ function stageOfCheck(c) {
3074
+ return { name: c.name, verdict: c.pass ? 'satisfied' : 'needs_revision', question: c.question };
2352
3075
  }
2353
3076
 
2354
3077
  /**
@@ -2504,7 +3227,7 @@ function jobsDir() {
2504
3227
  * @returns {any|null}
2505
3228
  */
2506
3229
  export function getRunJob(runid, opts = {}) {
2507
- const body = getRunJobBody(runid, opts);
3230
+ const body = withThinkingRow(getRunJobBody(runid, opts), runid, opts);
2508
3231
  if (!body || !opts.schedules) return body;
2509
3232
  try {
2510
3233
  const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
@@ -2525,6 +3248,23 @@ export function getRunJob(runid, opts = {}) {
2525
3248
  } catch { return body; }
2526
3249
  }
2527
3250
 
3251
+ /**
3252
+ * PRD v1.106 Part 3 — the Job tab's `Thinking` row, worded by src/thinking.js and ALWAYS present (hamr 2026-10-18). The rule shown is
3253
+ * the job's signed "Thinking on" tick for a run whose spine recorded thinking decisions, `—` for a run from before the thinking rule; a tick that cannot be read counts as not ticked (the default), never an empty row.
3254
+ * @param {any|null} body the Job tab body @param {string} runid @param {{ home?: string }} opts
3255
+ * @returns {any|null}
3256
+ */
3257
+ function withThinkingRow(body, runid, opts) {
3258
+ if (!body) return body;
3259
+ const { thinkingTick, ...rest } = body;
3260
+ let recorded = false;
3261
+ try {
3262
+ const row = findRunRow(runid, opts);
3263
+ recorded = !!row && existsSync(row.spine) && parseJsonl(row.spine).records.some((/** @type {any} */ r) => r?.type === 'thinking-decision');
3264
+ } catch { /* an unreadable spine has no records: the row reads — */ }
3265
+ return { ...rest, thinking: thinkingJobText(thinkingTick === true, recorded) };
3266
+ }
3267
+
2528
3268
  /** @param {string} runid @param {{ home?: string }} opts */
2529
3269
  function getRunJobBody(runid, opts) {
2530
3270
  const row = findRunRow(runid, opts);
@@ -2600,6 +3340,7 @@ function getRunJobBody(runid, opts) {
2600
3340
  guardrails: notRecorded(guardrailsFromSpec(spec)),
2601
3341
  tools: notRecorded(toolsFromSpec(spec)),
2602
3342
  toolsList: toolsListFromSpec(spec),
3343
+ thinkingTick: spec.thinking === true,
2603
3344
  note,
2604
3345
  });
2605
3346
  const none = () => ({
@@ -2621,6 +3362,7 @@ function getRunJobBody(runid, opts) {
2621
3362
  guardrails: 'not recorded',
2622
3363
  tools: 'not recorded',
2623
3364
  toolsList: null,
3365
+ thinkingTick: false,
2624
3366
  note: 'no resolvable spec for this run (only bareloop-run bundle-layout runs carry one; a run-u run has none on disk)',
2625
3367
  });
2626
3368
 
@@ -3088,7 +3830,7 @@ function sendText(res, code, text) {
3088
3830
  * only `createPanelServer` below always supplies one.
3089
3831
  * @param {import('node:http').IncomingMessage} req
3090
3832
  * @param {import('node:http').ServerResponse} res
3091
- * @param {{ home?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
3833
+ * @param {{ home?: string, sessionsRoot?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
3092
3834
  */
3093
3835
  export function handleRequest(req, res, opts) {
3094
3836
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -3097,7 +3839,7 @@ export function handleRequest(req, res, opts) {
3097
3839
  function routesFor(o, p) {
3098
3840
  if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
3099
3841
  if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
3100
- if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|needs-remove)$/.test(p)) return o.runRoutes;
3842
+ if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|rerun|pause|needs-remove)$/.test(p)) return o.runRoutes;
3101
3843
  if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
3102
3844
  return null;
3103
3845
  }
@@ -3204,7 +3946,7 @@ export function handleRequest(req, res, opts) {
3204
3946
 
3205
3947
  if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
3206
3948
 
3207
- const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
3949
+ const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds|result|download))?$/.exec(pathname);
3208
3950
  if (runMatch) {
3209
3951
  const rawRunid = runMatch[1];
3210
3952
  const sub = runMatch[3] ?? null;
@@ -3226,10 +3968,31 @@ export function handleRequest(req, res, opts) {
3226
3968
  send(200, result);
3227
3969
  return;
3228
3970
  }
3971
+ if (sub === 'result') {
3972
+ const result = getRunResult(runid, { home: opts.home, sessionsRoot: opts.sessionsRoot });
3973
+ if (!result) { sendText(res, 404, 'no such run, or it has no result tab'); return; }
3974
+ send(200, result);
3975
+ return;
3976
+ }
3977
+ if (sub === 'download') {
3978
+ const got = getRunDownload(runid, url.searchParams.get('i'), { home: opts.home });
3979
+ if (!got.ok) { sendText(res, got.code, got.error); return; }
3980
+ // the name is the delivered file's own basename; quotes and control characters never reach the header
3981
+ const plain = got.name.replace(/[^A-Za-z0-9._-]/g, '_');
3982
+ res.writeHead(200, {
3983
+ 'content-type': `${got.type}; charset=utf-8`,
3984
+ 'content-length': got.buf.length,
3985
+ 'content-disposition': `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(got.name)}`,
3986
+ 'x-content-type-options': 'nosniff',
3987
+ });
3988
+ if (method === 'HEAD') res.end(); else res.end(got.buf);
3989
+ return;
3990
+ }
3229
3991
  if (sub === 'rounds') {
3230
3992
  /** @param {string|null} v @returns {number|undefined} */
3231
3993
  const num = (v) => (v !== null && /^\d+$/.test(v) ? Number(v) : undefined);
3232
3994
  const query = {
3995
+ all: url.searchParams.get('all') === '1',
3233
3996
  part: num(url.searchParams.get('part')),
3234
3997
  attempt: num(url.searchParams.get('attempt')),
3235
3998
  offset: num(url.searchParams.get('offset')),
@@ -3298,7 +4061,7 @@ export function createPanelServer(opts = {}) {
3298
4061
  const server = createServer((req, res) => {
3299
4062
  try {
3300
4063
  handleRequest(req, res, {
3301
- home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
4064
+ home, sessionsRoot: opts.sessionsRoot, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
3302
4065
  });
3303
4066
  } catch (e) {
3304
4067
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);