bareloop 0.43.0 → 0.45.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 (64) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +53 -13
  3. package/package.json +2 -2
  4. package/src/authorflow.js +27 -12
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +52 -16
  7. package/src/authorreadout.js +12 -6
  8. package/src/authorrun.js +8 -2
  9. package/src/declaredclose.js +4 -1
  10. package/src/draftspend.js +20 -2
  11. package/src/facts.js +54 -9
  12. package/src/factwords.js +58 -6
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +38 -3
  15. package/src/job.js +1 -1
  16. package/src/kinds.js +5 -0
  17. package/src/pagedropwords.js +110 -0
  18. package/src/pagedry.js +125 -0
  19. package/src/pagemap.js +269 -0
  20. package/src/pagerules.js +318 -0
  21. package/src/panel/authorroutes.js +34 -30
  22. package/src/panel/authorsession.js +66 -11
  23. package/src/panel/index.html +469 -65
  24. package/src/panel/jobplan.js +14 -7
  25. package/src/panel/runroutes.js +46 -22
  26. package/src/panel/server.js +792 -28
  27. package/src/panel/status.js +33 -0
  28. package/src/panelmoney.js +29 -0
  29. package/src/planrun.js +53 -4
  30. package/src/promptregisters.js +8 -0
  31. package/src/replay.js +118 -7
  32. package/src/rubricstep.js +157 -15
  33. package/src/runlist.js +2 -1
  34. package/src/schedulewords.js +6 -3
  35. package/src/source.js +18 -0
  36. package/src/thinking.js +63 -2
  37. package/src/userrun.js +28 -14
  38. package/types/authorflow.d.ts +42 -6
  39. package/types/authorjob.d.ts +20 -4
  40. package/types/declaredclose.d.ts +2 -0
  41. package/types/draftspend.d.ts +27 -1
  42. package/types/facts.d.ts +15 -0
  43. package/types/factwords.d.ts +26 -3
  44. package/types/fitcheck.d.ts +113 -0
  45. package/types/inputlook.d.ts +15 -0
  46. package/types/kinds.d.ts +5 -0
  47. package/types/pagedropwords.d.ts +39 -0
  48. package/types/pagedry.d.ts +37 -0
  49. package/types/pagemap.d.ts +154 -0
  50. package/types/pagerules.d.ts +86 -0
  51. package/types/panel/authorroutes.d.ts +17 -14
  52. package/types/panel/authorsession.d.ts +4 -2
  53. package/types/panel/jobplan.d.ts +5 -2
  54. package/types/panel/runroutes.d.ts +2 -2
  55. package/types/panel/server.d.ts +156 -2
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/panelmoney.d.ts +5 -0
  58. package/types/replay.d.ts +21 -1
  59. package/types/rubricstep.d.ts +62 -1
  60. package/types/runlist.d.ts +4 -1
  61. package/types/schedulewords.d.ts +4 -2
  62. package/types/source.d.ts +11 -0
  63. package/types/thinking.d.ts +39 -0
  64. 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,347 @@ 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
+ readNotes: plan.readNotes,
1449
+ // the door's words, from the one table (src/panel/status.js DOOR_WORDS); the page types none of them
1450
+ door: { head: DOOR_WORDS.waiting.head, ask: RESULT_DOOR_ASK },
1451
+ delivered: delivered.map((d, i) => ({
1452
+ i, path: d.path, name: basename(d.path), bytes: d.bytes, state: read[i].state,
1453
+ words: read[i].state === 'gone' ? RESULT_FILE_GONE : (read[i].state === 'changed' ? RESULT_FILE_CHANGED : null),
1454
+ })),
1455
+ gap,
1456
+ };
1457
+ }
1458
+
1459
+ /** download content types by extension; anything else is plain bytes */
1460
+ const DOWNLOAD_TYPES = { '.json': 'application/json', '.md': 'text/plain', '.txt': 'text/plain', '.csv': 'text/csv' };
1461
+
1462
+ /**
1463
+ * `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
1464
+ * the record's, never the request's; the sha256 must still match the record.
1465
+ * @param {string} runid @param {unknown} index @param {{ home?: string }} opts
1466
+ * @returns {{ok: true, name: string, type: string, buf: Buffer}|{ok: false, code: number, error: string}}
1467
+ */
1468
+ export function getRunDownload(runid, index, opts = {}) {
1469
+ const row = findRunRow(runid, opts);
1470
+ if (!row || row.via === 'import' || !existsSync(row.spine)) return { ok: false, code: 404, error: 'no such run' };
1471
+ const delivered = deliveredOf(parseJsonl(row.spine).records);
1472
+ const i = typeof index === 'string' && /^\d{1,6}$/.test(index) ? Number(index) : -1;
1473
+ if (i < 0 || i >= delivered.length) return { ok: false, code: 404, error: 'this run saved no such file' };
1474
+ const got = readDelivered(delivered[i]);
1475
+ if (got.state !== 'ok') return { ok: false, code: 409, error: got.state === 'gone' ? RESULT_FILE_GONE : RESULT_FILE_CHANGED };
1476
+ const name = basename(delivered[i].path);
1477
+ const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : '';
1478
+ return { ok: true, name, type: Object.hasOwn(DOWNLOAD_TYPES, ext) ? /** @type {Record<string,string>} */ (DOWNLOAD_TYPES)[ext] : 'application/octet-stream', buf: got.buf };
1479
+ }
1480
+
1096
1481
  /**
1097
1482
  * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
1098
1483
  * iterations), counters, summary-box fields. Looks `runid` up in the run
@@ -1164,14 +1549,14 @@ function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
1164
1549
  const originNo = nos.get(cur.originRunid) ?? null;
1165
1550
  const tries = door.skippedFor(job);
1166
1551
  const running = rows.some((r) => r && r.job === job && r.via !== 'import' && runIsAlive(r));
1167
- const { sign } = scheduleSignFor({ running, red: !!(view && view.problem) });
1552
+ const { sign, word } = scheduleSignFor({ running, red: !!(view && view.problem) });
1168
1553
  const words = scheduledWords(cur.trigger, door.userHome);
1169
1554
  const head = [lowerFirst(words ?? ''), originNo === null ? null : `from run-${originNo}`, triesTailWords(tries.length) || null].filter(Boolean).join(' · ');
1170
1555
  const when = row ? dateTimeWords(atMs(row)) : '';
1171
1556
  const thisRun = rec ? sectionThisRunWords(rec) : `by hand.${runid === cur.originRunid ? ' The schedule was set from this run.' : ''}`;
1172
1557
  const shown = tries.slice(0, 7).map((x, i) => ({ label: i === 0 ? 'tries' : '', text: x.line }));
1173
1558
  const scheduleSection = {
1174
- title: SCHEDULE_WORDS.scheduleGroup, sign, head,
1559
+ title: SCHEDULE_WORDS.scheduleGroup, sign, word, head,
1175
1560
  rows: [{ label: 'this run', text: `${when} ${thisRun}` }, ...(shown.length > 0 ? shown : [{ label: 'tries', text: 'none' }])],
1176
1561
  };
1177
1562
  return { ...(schedule ? { schedule } : {}), audit: { scheduleSection } };
@@ -1187,6 +1572,8 @@ function getRunDetailBody(runid, opts) {
1187
1572
  const row = findRunRow(runid, opts);
1188
1573
  if (!row) return null;
1189
1574
  if (!existsSync(row.spine)) {
1575
+ const refusal = refusalWordsOf(readRunList(opts).events, row);
1576
+ if (refusal !== null) return refusedStub(row, refusal);
1190
1577
  if (runIsAlive(row)) return startingStub(row);
1191
1578
  return {
1192
1579
  runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
@@ -1208,7 +1595,8 @@ function getRunDetailBody(runid, opts) {
1208
1595
  // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
1209
1596
  const draftingPartHere = draftingFor(row, summary, opts);
1210
1597
  const draftingShiftHere = draftingShift(row, summary, opts);
1211
- const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
1598
+ const specForParts = resolveSpecForRow(row);
1599
+ const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(specForParts), specForParts);
1212
1600
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
1213
1601
  const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
1214
1602
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
@@ -1461,9 +1849,16 @@ function getRunDetailBody(runid, opts) {
1461
1849
  // carries no contamination risk); `null` when the spine carries no
1462
1850
  // `memory-cache` record at all (never armed on this run).
1463
1851
  behaviour: scopedBehaviour(row, rawSpineRecords),
1852
+ // PRD v1.106 Part 5 F: the two Run summary rows, worded here (the page prints them)
1853
+ callsLine: callsLineOf(rawSpineRecords, scopedBehaviour(row, rawSpineRecords)),
1854
+ checksLine: checksLineOf(rawSpineRecords, enrichedParts),
1464
1855
  memoryCache: summary.memoryCache,
1465
1856
  // 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
1857
  accept: !runIsAlive(row) && doorWaiting(rawSpineRecords),
1858
+ // 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
1859
+ // files the run's own `destination-written` records name (the Result tab's downloads index into this list, never a path from a request)
1860
+ hasResult: checkTypeLabel(summary.verdictType, row.at) === 'rubric' && rubricStagesOf(specForParts).length > 0,
1861
+ delivered: deliveredOf(rawSpineRecords).map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })),
1467
1862
  // 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
1863
  ...(() => {
1469
1864
  const trig = triggerRecordOf(row);
@@ -1900,7 +2295,7 @@ export const ROUNDS_PAGE_MAX = 200;
1900
2295
  * resolve to a real window, or the spine file is missing — the caller
1901
2296
  * renders 404, same posture as {@link getRunAudit}/{@link getRunJob}.
1902
2297
  * @param {string} runid
1903
- * @param {{ part?: number, attempt?: number, offset?: number, limit?: number }} query
2298
+ * @param {{ all?: boolean, part?: number, attempt?: number, offset?: number, limit?: number }} query
1904
2299
  * @param {{ home?: string }} [opts]
1905
2300
  * @returns {any|null}
1906
2301
  */
@@ -1922,6 +2317,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1922
2317
  if (shift && partIndex === 0) return null;
1923
2318
  const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
1924
2319
  if (!part) return null;
2320
+ // 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,
2321
+ // a check row after each try, 20 rows a page); `all=1` asks for it. Every other part keeps its per-attempt table.
2322
+ if (query.all === true && part.kind === 'step' && Array.isArray(part.attempts) && part.attempts.some((x) => Array.isArray(x.checks))) {
2323
+ 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);
2324
+ }
1925
2325
  const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
1926
2326
  if (!a) return null;
1927
2327
  /** @type {{startSeq: number, endSeq: number, outcome: string|null, detail: string|null}} */
@@ -1983,6 +2383,90 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1983
2383
  };
1984
2384
  }
1985
2385
 
2386
+ /** the Audit step table's page size (PRD v1.106 Part 5 choice 3): rows, not rounds */
2387
+ export const STEP_ROWS_PAGE = 20;
2388
+
2389
+ /** 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 */
2390
+ export function checkRowWords(passed, failed) {
2391
+ return failed > 0 ? { sign: '✗', text: `${failed} failed, ${passed} passed` } : { sign: '✓', text: `${passed} passed` };
2392
+ }
2393
+
2394
+ /**
2395
+ * 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
2396
+ * 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.
2397
+ * @param {string} runid @param {any} row @param {any} part @param {{partIndex: number, offset: number, limit: number}} q @param {{ home?: string }} opts
2398
+ */
2399
+ function stepRoundsPage(runid, row, part, q, opts) {
2400
+ const attempts = part.attempts.filter((/** @type {any} */ x) => Array.isArray(x.checks));
2401
+ const cut = part.cutOffTry ?? null;
2402
+ /** @type {Array<{n: number, startSeq: number, endSeq: number, a: any}>} */
2403
+ const windows = attempts.map((/** @type {any} */ a) => ({ n: a.n, startSeq: a.startSeq, endSeq: a.endSeq, a }));
2404
+ if (cut) windows.push({ n: cut.n, startSeq: cut.startSeq, endSeq: cut.endSeq, a: null });
2405
+ const lo = windows[0].startSeq;
2406
+ const hi = windows[windows.length - 1].endSeq;
2407
+ const tryOfSeq = (/** @type {number} */ seq) => windows.find((w) => seq > w.startSeq && seq <= w.endSeq)?.n ?? windows[windows.length - 1].n;
2408
+ const { records: spineRecords } = parseJsonl(row.spine);
2409
+ const roundOf = makeRoundLookup(spineRecords);
2410
+ const spend = spineRecords
2411
+ .filter((/** @type {any} */ r) => r && SPEND_RECORD_TYPES.includes(r.type) && typeof r.seq === 'number' && r.seq > lo && r.seq <= hi)
2412
+ .sort((/** @type {any} */ a, /** @type {any} */ b) => a.seq - b.seq);
2413
+ const auditResult = getRunAudit(runid, opts);
2414
+ const toolLogSaved = !!auditResult && auditResult.reason !== 'no-sidecar';
2415
+ /** @type {Map<number, any[]>} */
2416
+ const toolsByRound = new Map();
2417
+ if (toolLogSaved && auditResult) {
2418
+ for (const r of auditResult.rows) {
2419
+ if (r.kind !== 'tool-call' || typeof r.round !== 'number') continue;
2420
+ const bucket = toolsByRound.get(r.round) ?? [];
2421
+ bucket.push(r);
2422
+ toolsByRound.set(r.round, bucket);
2423
+ }
2424
+ }
2425
+ const spec = resolveSpecForRow(row);
2426
+ 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]));
2427
+ /** @type {Array<{seq: number, rows: any[]}>} */
2428
+ const blocks = [];
2429
+ spend.forEach((/** @type {any} */ r, i) => {
2430
+ const runWide = roundOf(r.ts);
2431
+ const tryN = tryOfSeq(r.seq);
2432
+ const local = i + 1;
2433
+ const modelCall = toolLogSaved && auditResult ? auditResult.rows.find((x) => x.kind === 'model-call' && x.round === runWide) : null;
2434
+ const rows = [{
2435
+ kind: 'model', round: local, try: tryN, time: typeof r.ts === 'string' ? r.ts : null,
2436
+ costUsd: typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? r.costUsd : null,
2437
+ tokens: typeof r.tokens === 'number' && Number.isFinite(r.tokens) ? r.tokens : null,
2438
+ durationMs: modelCall ? modelCall.durationMs : null,
2439
+ }];
2440
+ for (const tc of (runWide === null ? [] : (toolsByRound.get(runWide) ?? []))) rows.push({ ...tc, kind: 'tool', round: local, try: tryN });
2441
+ blocks.push({ seq: r.seq, rows });
2442
+ });
2443
+ for (const w of windows) {
2444
+ if (!w.a) continue; // a try cut off before its checks ran has no check row
2445
+ const passed = w.a.checks.filter((/** @type {any} */ c) => c.pass).length;
2446
+ const failed = w.a.checks.length - passed;
2447
+ const named = w.a.checks.find((/** @type {any} */ c) => c.type === 'check-passes');
2448
+ const judgedPath = named && named.name ? judgedFilesOf(spec?.closeDecl, named.name)[0] ?? null : null;
2449
+ blocks.push({
2450
+ seq: w.endSeq,
2451
+ rows: [{ kind: 'check', round: null, try: w.n, time: exitTs.get(w.endSeq) ?? null, path: judgedPath, ...checkRowWords(passed, failed) }],
2452
+ });
2453
+ }
2454
+ blocks.sort((a, b) => a.seq - b.seq);
2455
+ const all = blocks.flatMap((b) => b.rows);
2456
+ return {
2457
+ runid,
2458
+ part: q.partIndex,
2459
+ partKind: 'step',
2460
+ mode: 'step',
2461
+ step: partDisplayLabel(part),
2462
+ rows: all.slice(q.offset, q.offset + q.limit),
2463
+ totalRows: all.length,
2464
+ offset: q.offset,
2465
+ limit: q.limit,
2466
+ toolLogSaved,
2467
+ };
2468
+ }
2469
+
1986
2470
  /**
1987
2471
  * The `↳ close:`-style text for one iteration's OWN close-verdict — same
1988
2472
  * shape as `src/replay.js`'s private `closeStageLine`, but that helper is
@@ -2236,7 +2720,7 @@ function stageQuestionText({
2236
2720
  * ran. `null` when the spec has no `closeDecl.stages` array at all (a
2237
2721
  * command-close spec, or no spec resolved).
2238
2722
  * @param {any} spec
2239
- * @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
2723
+ * @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>|null}
2240
2724
  */
2241
2725
  function stageKindMetaFromSpec(spec) {
2242
2726
  if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
@@ -2252,7 +2736,7 @@ function stageKindMetaFromSpec(spec) {
2252
2736
  if (key !== null) suiteCmdKeys.add(key);
2253
2737
  }
2254
2738
  }
2255
- /** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
2739
+ /** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>} */
2256
2740
  const map = new Map();
2257
2741
  for (const s of spec.closeDecl.stages) {
2258
2742
  if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
@@ -2264,7 +2748,7 @@ function stageKindMetaFromSpec(spec) {
2264
2748
  kind, params, genre, suiteCmdKeys,
2265
2749
  });
2266
2750
  map.set(s.name, {
2267
- kind, direction, baselineKind, question,
2751
+ kind, direction, baselineKind, question, fromLine: Array.isArray(s.fromLine) ? s.fromLine.filter(Number.isInteger) : [],
2268
2752
  });
2269
2753
  }
2270
2754
  return map;
@@ -2334,21 +2818,261 @@ function attachStageKind(stage, kindMeta) {
2334
2818
  * @param {Map<string, any>|null} kindMeta
2335
2819
  * @returns {any[]|null|undefined}
2336
2820
  */
2337
- function enrichPartsWithStageKind(parts, kindMeta) {
2821
+ function enrichPartsWithStageKind(parts, kindMeta, spec = null) {
2338
2822
  if (!Array.isArray(parts)) return parts;
2339
2823
  const declaredStagesTotal = kindMeta ? kindMeta.size : null;
2340
2824
  const declaredStages = kindMeta
2341
2825
  ? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
2342
2826
  : 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,
2827
+ return parts.map((part) => {
2828
+ const attempts = Array.isArray(part.attempts) ? part.attempts.map((a) => {
2829
+ // 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
2830
+ // `stages`, which is what the page's headline and check list read (before this they were null, so every check read "not run")
2831
+ const checks = Array.isArray(a.checks) ? tryChecksOf(a.checks, kindMeta, spec) : null;
2832
+ return {
2833
+ ...a,
2834
+ ...(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 }),
2835
+ ...(declaredStagesTotal ? { declaredStagesTotal } : {}),
2836
+ ...(declaredStages ? { declaredStages } : {}),
2837
+ };
2838
+ }) : part.attempts;
2839
+ return {
2840
+ ...part,
2841
+ attempts,
2842
+ ...(part.kind === 'step' ? (() => { const tries = triesOf(attempts, part.cutOffTry); return { tries, triesLine: triesLineOf(tries), marker: ACTOR_MARKER.model, tally: toolsTallyLine(part.byTool, part.blocked) }; })() : {}),
2843
+ ...(part.kind === 'door' && part.door ? { marker: ACTOR_MARKER.human, doorView: doorViewOf(part.door) } : {}),
2844
+ };
2845
+ });
2846
+ }
2847
+
2848
+ /** the words for a try the wall (or a cap, or a stop) ended before any of its checks ran (D2) */
2849
+ const CUT_OFF_WORDS = 'cut off before its checks ran';
2850
+
2851
+ /**
2852
+ * "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
2853
+ * (`worker-round`, `judge-round`; the attempt-level `worker-result` echoes are not rounds and drafting is not the run's), tool and denied come
2854
+ * from the gate-audit rows scoped to this run (the same figure `tools:` shows). No tool log saved says so, never a zero.
2855
+ * @param {any[]} records the run's raw spine @param {{totalCalls: number, denied: number}|null} behaviour
2856
+ * @returns {string}
2857
+ */
2858
+ export function callsLineOf(records, behaviour) {
2859
+ const model = records.filter((r) => r && (r.type === 'worker-round' || r.type === 'judge-round')).length;
2860
+ return behaviour
2861
+ ? `${model} model · ${behaviour.totalCalls} tool · ${behaviour.denied} denied`
2862
+ : `${model} model · tool calls: no log saved`;
2863
+ }
2864
+
2865
+ /** "1-5", "2", "1-2, 4" from try numbers @param {number[]} ns */
2866
+ function tryRanges(ns) {
2867
+ const out = [];
2868
+ for (let i = 0; i < ns.length; i += 1) {
2869
+ let j = i;
2870
+ while (j + 1 < ns.length && ns[j + 1] === ns[j] + 1) j += 1;
2871
+ out.push(j > i ? `${ns[i]}-${ns[j]}` : String(ns[i]));
2872
+ i = j;
2873
+ }
2874
+ return out.join(', ');
2875
+ }
2876
+
2877
+ /**
2878
+ * "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"
2879
+ * headline stays withdrawn). Every `exit-eval` result plus every graded `outer-close` stage; the parenthesis names the steps and try ranges
2880
+ * that failed, from the same `tries[]` the Audit reads. A run with neither says `not recorded`.
2881
+ * @param {any[]} records the run's raw spine @param {any[]|null|undefined} parts the enriched parts
2882
+ * @returns {string}
2883
+ */
2884
+ export function checksLineOf(records, parts) {
2885
+ let run = 0;
2886
+ let passed = 0;
2887
+ let outerFailed = false;
2888
+ for (const r of records) {
2889
+ if (!r) continue;
2890
+ if (r.type === 'exit-eval' && Array.isArray(r.results)) {
2891
+ for (const x of r.results) { run += 1; if (x && x.pass === true) passed += 1; }
2892
+ } else if (r.type === 'outer-close' && Array.isArray(r.stages)) {
2893
+ for (const x of r.stages) { run += 1; if (x && x.verdict === 'satisfied') passed += 1; else outerFailed = true; }
2894
+ }
2895
+ }
2896
+ if (run === 0) return 'not recorded';
2897
+ /** @type {string[]} */
2898
+ const named = [];
2899
+ /** @type {Set<string>} */
2900
+ const seenLabel = new Set();
2901
+ for (const p of parts ?? []) {
2902
+ if (p.kind !== 'step' || !Array.isArray(p.tries)) continue;
2903
+ const bad = p.tries.filter((/** @type {any} */ t) => t.failed > 0).map((/** @type {any} */ t) => t.n);
2904
+ if (bad.length === 0) continue;
2905
+ const text = `tries ${tryRanges(bad)}`;
2906
+ named.push(seenLabel.has(p.label) ? `then ${text}` : `${p.label} ${text}`);
2907
+ seenLabel.add(p.label);
2908
+ }
2909
+ if (outerFailed) named.push('the outer close');
2910
+ const failed = run - passed;
2911
+ return `${run} run · ${passed} passed · ${failed} failed${named.length ? ` (${named.join(', ')})` : ''}`;
2912
+ }
2913
+
2914
+ /** 1 -> "1st", 4 -> "4th", 12 -> "12th" @param {number} n */
2915
+ function ordinal(n) {
2916
+ const v = n % 100;
2917
+ if (v >= 11 && v <= 13) return `${n}th`;
2918
+ return `${n}${({ 1: 'st', 2: 'nd', 3: 'rd' })[/** @type {1|2|3} */ (n % 10)] ?? 'th'}`;
2919
+ }
2920
+
2921
+ /**
2922
+ * 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
2923
+ * ("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`
2924
+ * when the step has no graded try (an old spine).
2925
+ * @param {Array<{n: number, cutOff: boolean, passed: number, failed: number, ran: number}>} tries
2926
+ * @returns {string|null}
2927
+ */
2928
+ export function triesLineOf(tries) {
2929
+ if (!Array.isArray(tries) || tries.length === 0) return null;
2930
+ const last = tries[tries.length - 1];
2931
+ const count = `${tries.length} ${tries.length === 1 ? 'try' : 'tries'}${last.cutOff ? ` (the ${ordinal(last.n)} ${CUT_OFF_WORDS})` : ''}`;
2932
+ const checks = (/** @type {number} */ n) => `${n} ${n === 1 ? 'check' : 'checks'}`;
2933
+ let lastWords;
2934
+ if (last.cutOff || last.ran === 0) lastWords = 'no checks ran';
2935
+ else if (last.failed === 0) lastWords = `${checks(last.passed)} passed`;
2936
+ else if (last.passed === 0) lastWords = `${checks(last.failed)} failed`;
2937
+ else lastWords = `${checks(last.passed)} passed, ${last.failed} failed`;
2938
+ return `${count} · last try: ${lastWords}`;
2939
+ }
2940
+
2941
+ /** tool kinds in the order the Audit tally lists them; anything else follows alphabetically */
2942
+ const TALLY_ORDER = ['read', 'grep', 'write', 'edit'];
2943
+
2944
+ /**
2945
+ * "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
2946
+ * `shell_`/`ctx_` prefixes are stripped like the rest of the page. `null` when no tool log was saved (nothing honest to count).
2947
+ * @param {Record<string, number>|null|undefined} byTool @param {number|null|undefined} blocked
2948
+ * @returns {string|null}
2949
+ */
2950
+ export function toolsTallyLine(byTool, blocked) {
2951
+ if (!byTool || typeof byTool !== 'object') return null;
2952
+ const named = Object.entries(byTool).map(([k, n]) => [k.replace(/^shell_/, '').replace(/^ctx_/, ''), n]);
2953
+ named.sort((a, b) => {
2954
+ const ia = TALLY_ORDER.indexOf(String(a[0]));
2955
+ const ib = TALLY_ORDER.indexOf(String(b[0]));
2956
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || String(a[0]).localeCompare(String(b[0]));
2957
+ });
2958
+ return `tools: ${[...named.map(([k, n]) => `${k} ${n}`), `denied ${typeof blocked === 'number' ? blocked : 0}`].join(' · ')}`;
2959
+ }
2960
+
2961
+ /**
2962
+ * 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,
2963
+ * `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.
2964
+ * @param {any[]} attempts @param {any} cutOffTry
2965
+ * @returns {Array<{words?: string, n: number, cutOff: boolean, outcome: string|null, iteration: number|null, ran: number, passed: number, failed: number, thinking: string|null}>}
2966
+ */
2967
+ function triesOf(attempts, cutOffTry) {
2968
+ const graded = (Array.isArray(attempts) ? attempts : []).filter((a) => Array.isArray(a.checks)).map((a) => ({
2969
+ n: a.n,
2970
+ cutOff: false,
2971
+ outcome: a.outcome ?? null,
2972
+ iteration: typeof a.iteration === 'number' ? a.iteration : null,
2973
+ ran: a.checks.length,
2974
+ passed: a.checks.filter((/** @type {any} */ c) => c.pass).length,
2975
+ failed: a.checks.filter((/** @type {any} */ c) => !c.pass).length,
2976
+ thinking: typeof a.thinking === 'string' ? a.thinking : null,
2351
2977
  }));
2978
+ if (!cutOffTry) return graded;
2979
+ return [...graded, {
2980
+ 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,
2981
+ }];
2982
+ }
2983
+
2984
+ /** 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 */
2985
+ const FILES_CHANGED_QUESTION = 'did it change any file?';
2986
+
2987
+ /**
2988
+ * 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
2989
+ * already names) removed. A detail with no `close:` lines is one reason: its own first line.
2990
+ * @param {string|null|undefined} detail
2991
+ * @returns {string[]}
2992
+ */
2993
+ function reasonsOf(detail) {
2994
+ if (typeof detail !== 'string' || detail.trim() === '') return [];
2995
+ const lines = detail.split('\n').map((l) => l.trim()).filter((l) => l !== '');
2996
+ const closeLines = lines.filter((l) => l.startsWith('close: '));
2997
+ if (closeLines.length === 0) return [lines[0]];
2998
+ return closeLines.map((l) => l.replace(/^close: [^:]+: /, '').replace(/^line \d+: /, ''));
2999
+ }
3000
+
3001
+ /**
3002
+ * 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}.
3003
+ * @param {string|null|undefined} detail
3004
+ * @returns {{ line: string, more: number }|null}
3005
+ */
3006
+ export function gapOneLine(detail) {
3007
+ const reasons = reasonsOf(detail);
3008
+ return reasons.length === 0 ? null : { line: reasons[0], more: reasons.length - 1 };
3009
+ }
3010
+
3011
+ /**
3012
+ * "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`).
3013
+ * `null` when the stage serves no job line (a mandatory guard).
3014
+ * @param {number[]} fromLine @param {any} spec
3015
+ * @returns {string|null}
3016
+ */
3017
+ export function fromLineText(fromLine, spec) {
3018
+ if (!Array.isArray(fromLine) || fromLine.length === 0) return null;
3019
+ const lines = Array.isArray(spec?.jobLines) ? spec.jobLines : [];
3020
+ return fromLine.map((n) => {
3021
+ const rule = lines.find((l) => l && l.n === n)?.rule;
3022
+ return `from line ${n}` + (typeof rule === 'string' && rule !== '' ? ` ~ ${rule}` : '');
3023
+ }).join(' · ');
3024
+ }
3025
+
3026
+ /**
3027
+ * One try's checks in the order they RAN (choice 6), worded: `{n, type, name, question, pass, detail, gap, more, reasons, from}`.
3028
+ * A `tree-changed` result is the declared `files-changed` stage; a `check-passes` result is the stage its `check-run` named.
3029
+ * @param {Array<{type: string, pass: boolean, detail: string|null, name: string|null}>} checks
3030
+ * @param {Map<string, any>|null} kindMeta @param {any} spec
3031
+ */
3032
+ function tryChecksOf(checks, kindMeta, spec) {
3033
+ const metas = kindMeta ? [...kindMeta] : [];
3034
+ const filesChanged = metas.find(([, m]) => m.kind === 'files-changed') ?? null;
3035
+ const others = metas.filter(([, m]) => m.kind !== 'files-changed');
3036
+ return checks.map((c, i) => {
3037
+ let name = c.name;
3038
+ let meta = name && kindMeta ? kindMeta.get(name) ?? null : null;
3039
+ if (c.type === 'tree-changed' && filesChanged) { name = filesChanged[0]; meta = filesChanged[1]; }
3040
+ if (c.type === 'check-passes' && !meta && others.length === 1) { name = others[0][0]; meta = others[0][1]; }
3041
+ const reasons = c.pass ? [] : reasonsOf(c.detail);
3042
+ const gap = c.pass ? null : gapOneLine(c.detail);
3043
+ return {
3044
+ n: i + 1,
3045
+ type: c.type,
3046
+ name: name ?? c.type,
3047
+ question: c.type === 'tree-changed' ? FILES_CHANGED_QUESTION : (meta?.question ?? null),
3048
+ pass: c.pass,
3049
+ // a files-changed result names its count; a failing check-passes names its reasons (folded on the page), a passing one nothing
3050
+ note: c.type === 'tree-changed' ? c.detail : (c.pass ? null : `${reasons.length} reason${reasons.length === 1 ? '' : 's'}`),
3051
+ gap: gap ? gap.line : null,
3052
+ more: gap ? gap.more : 0,
3053
+ moreText: gap && gap.more > 0 ? `(+${gap.more} more)` : null,
3054
+ reasonsLabel: reasons.length > 1 ? `the ${reasons.length} reasons` : null,
3055
+ reasons,
3056
+ from: meta ? fromLineText(meta.fromLine, spec) : null,
3057
+ };
3058
+ });
3059
+ }
3060
+
3061
+ /**
3062
+ * "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
3063
+ * these words. `size` is bytes under 1 KB, else KB with one decimal; the hash shows its first 4 and last 2 characters.
3064
+ * @param {{path: string|null, bytes: number|null, sha256: string|null}} o
3065
+ * @returns {string}
3066
+ */
3067
+ export function judgedFileLine(o) {
3068
+ const size = typeof o.bytes === 'number' ? (o.bytes < 1024 ? `${o.bytes} B` : `${(o.bytes / 1024).toFixed(1)} KB`) : null;
3069
+ const sha = typeof o.sha256 === 'string' && o.sha256.length >= 8 ? `sha256 ${o.sha256.slice(0, 4)}…${o.sha256.slice(-2)}` : null;
3070
+ return ['output judged:', [o.path ?? 'a file', size, sha].filter(Boolean).join(' · ')].join(' ');
3071
+ }
3072
+
3073
+ /** a worded check as the `{name, verdict, question}` stage shape the attempt headline reads */
3074
+ function stageOfCheck(c) {
3075
+ return { name: c.name, verdict: c.pass ? 'satisfied' : 'needs_revision', question: c.question };
2352
3076
  }
2353
3077
 
2354
3078
  /**
@@ -2504,7 +3228,7 @@ function jobsDir() {
2504
3228
  * @returns {any|null}
2505
3229
  */
2506
3230
  export function getRunJob(runid, opts = {}) {
2507
- const body = getRunJobBody(runid, opts);
3231
+ const body = withThinkingRow(getRunJobBody(runid, opts), runid, opts);
2508
3232
  if (!body || !opts.schedules) return body;
2509
3233
  try {
2510
3234
  const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
@@ -2525,6 +3249,23 @@ export function getRunJob(runid, opts = {}) {
2525
3249
  } catch { return body; }
2526
3250
  }
2527
3251
 
3252
+ /**
3253
+ * 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
3254
+ * 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.
3255
+ * @param {any|null} body the Job tab body @param {string} runid @param {{ home?: string }} opts
3256
+ * @returns {any|null}
3257
+ */
3258
+ function withThinkingRow(body, runid, opts) {
3259
+ if (!body) return body;
3260
+ const { thinkingTick, ...rest } = body;
3261
+ let recorded = false;
3262
+ try {
3263
+ const row = findRunRow(runid, opts);
3264
+ recorded = !!row && existsSync(row.spine) && parseJsonl(row.spine).records.some((/** @type {any} */ r) => r?.type === 'thinking-decision');
3265
+ } catch { /* an unreadable spine has no records: the row reads — */ }
3266
+ return { ...rest, thinking: thinkingJobText(thinkingTick === true, recorded) };
3267
+ }
3268
+
2528
3269
  /** @param {string} runid @param {{ home?: string }} opts */
2529
3270
  function getRunJobBody(runid, opts) {
2530
3271
  const row = findRunRow(runid, opts);
@@ -2600,6 +3341,7 @@ function getRunJobBody(runid, opts) {
2600
3341
  guardrails: notRecorded(guardrailsFromSpec(spec)),
2601
3342
  tools: notRecorded(toolsFromSpec(spec)),
2602
3343
  toolsList: toolsListFromSpec(spec),
3344
+ thinkingTick: spec.thinking === true,
2603
3345
  note,
2604
3346
  });
2605
3347
  const none = () => ({
@@ -2621,6 +3363,7 @@ function getRunJobBody(runid, opts) {
2621
3363
  guardrails: 'not recorded',
2622
3364
  tools: 'not recorded',
2623
3365
  toolsList: null,
3366
+ thinkingTick: false,
2624
3367
  note: 'no resolvable spec for this run (only bareloop-run bundle-layout runs carry one; a run-u run has none on disk)',
2625
3368
  });
2626
3369
 
@@ -3088,7 +3831,7 @@ function sendText(res, code, text) {
3088
3831
  * only `createPanelServer` below always supplies one.
3089
3832
  * @param {import('node:http').IncomingMessage} req
3090
3833
  * @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
3834
+ * @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
3835
  */
3093
3836
  export function handleRequest(req, res, opts) {
3094
3837
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -3097,7 +3840,7 @@ export function handleRequest(req, res, opts) {
3097
3840
  function routesFor(o, p) {
3098
3841
  if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
3099
3842
  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;
3843
+ if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|rerun|pause|needs-remove)$/.test(p)) return o.runRoutes;
3101
3844
  if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
3102
3845
  return null;
3103
3846
  }
@@ -3204,7 +3947,7 @@ export function handleRequest(req, res, opts) {
3204
3947
 
3205
3948
  if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
3206
3949
 
3207
- const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
3950
+ const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds|result|download))?$/.exec(pathname);
3208
3951
  if (runMatch) {
3209
3952
  const rawRunid = runMatch[1];
3210
3953
  const sub = runMatch[3] ?? null;
@@ -3226,10 +3969,31 @@ export function handleRequest(req, res, opts) {
3226
3969
  send(200, result);
3227
3970
  return;
3228
3971
  }
3972
+ if (sub === 'result') {
3973
+ const result = getRunResult(runid, { home: opts.home, sessionsRoot: opts.sessionsRoot });
3974
+ if (!result) { sendText(res, 404, 'no such run, or it has no result tab'); return; }
3975
+ send(200, result);
3976
+ return;
3977
+ }
3978
+ if (sub === 'download') {
3979
+ const got = getRunDownload(runid, url.searchParams.get('i'), { home: opts.home });
3980
+ if (!got.ok) { sendText(res, got.code, got.error); return; }
3981
+ // the name is the delivered file's own basename; quotes and control characters never reach the header
3982
+ const plain = got.name.replace(/[^A-Za-z0-9._-]/g, '_');
3983
+ res.writeHead(200, {
3984
+ 'content-type': `${got.type}; charset=utf-8`,
3985
+ 'content-length': got.buf.length,
3986
+ 'content-disposition': `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(got.name)}`,
3987
+ 'x-content-type-options': 'nosniff',
3988
+ });
3989
+ if (method === 'HEAD') res.end(); else res.end(got.buf);
3990
+ return;
3991
+ }
3229
3992
  if (sub === 'rounds') {
3230
3993
  /** @param {string|null} v @returns {number|undefined} */
3231
3994
  const num = (v) => (v !== null && /^\d+$/.test(v) ? Number(v) : undefined);
3232
3995
  const query = {
3996
+ all: url.searchParams.get('all') === '1',
3233
3997
  part: num(url.searchParams.get('part')),
3234
3998
  attempt: num(url.searchParams.get('attempt')),
3235
3999
  offset: num(url.searchParams.get('offset')),
@@ -3298,7 +4062,7 @@ export function createPanelServer(opts = {}) {
3298
4062
  const server = createServer((req, res) => {
3299
4063
  try {
3300
4064
  handleRequest(req, res, {
3301
- home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
4065
+ home, sessionsRoot: opts.sessionsRoot, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
3302
4066
  });
3303
4067
  } catch (e) {
3304
4068
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);