bareloop 0.42.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 (79) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/bareloop.context.md +103 -41
  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 +33 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +6 -3
  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/interviewrun.js +0 -1
  14. package/src/job.js +39 -12
  15. package/src/kinds.js +5 -0
  16. package/src/pagedropwords.js +64 -0
  17. package/src/pagemap.js +264 -0
  18. package/src/pagerules.js +319 -0
  19. package/src/panel/authorroutes.js +138 -60
  20. package/src/panel/authorsession.js +88 -32
  21. package/src/panel/index.html +1251 -522
  22. package/src/panel/jobplan.js +4 -2
  23. package/src/panel/runroutes.js +188 -6
  24. package/src/panel/scheduler.js +156 -0
  25. package/src/panel/schedules.js +667 -0
  26. package/src/panel/schedulestart.js +243 -0
  27. package/src/panel/server.js +1234 -68
  28. package/src/panel/status.js +33 -0
  29. package/src/panelmoney.js +29 -0
  30. package/src/planrun.js +92 -11
  31. package/src/promptregisters.js +4 -0
  32. package/src/providerrows.js +3 -3
  33. package/src/replay.js +118 -7
  34. package/src/reviewdoor.js +37 -0
  35. package/src/rubricstep.js +123 -11
  36. package/src/runlist.js +37 -1
  37. package/src/schedulewords.js +299 -0
  38. package/src/source.js +18 -0
  39. package/src/thinking.js +63 -2
  40. package/src/trigger.js +48 -0
  41. package/src/triggerclock.js +127 -0
  42. package/src/triggerfiles.js +215 -0
  43. package/src/triggerledger.js +79 -0
  44. package/src/userrun.js +34 -21
  45. package/types/authorflow.d.ts +42 -6
  46. package/types/authorjob.d.ts +10 -2
  47. package/types/declaredclose.d.ts +3 -1
  48. package/types/draftspend.d.ts +27 -1
  49. package/types/facts.d.ts +15 -0
  50. package/types/factwords.d.ts +26 -3
  51. package/types/inputlook.d.ts +15 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/kinds.d.ts +5 -0
  54. package/types/pagedropwords.d.ts +26 -0
  55. package/types/pagemap.d.ts +154 -0
  56. package/types/pagerules.d.ts +86 -0
  57. package/types/panel/authorroutes.d.ts +50 -14
  58. package/types/panel/authorsession.d.ts +37 -12
  59. package/types/panel/runroutes.d.ts +32 -1
  60. package/types/panel/scheduler.d.ts +17 -0
  61. package/types/panel/schedules.d.ts +412 -0
  62. package/types/panel/schedulestart.d.ts +106 -0
  63. package/types/panel/server.d.ts +297 -15
  64. package/types/panel/status.d.ts +51 -0
  65. package/types/panelmoney.d.ts +5 -0
  66. package/types/planrun.d.ts +2 -0
  67. package/types/providerrows.d.ts +2 -2
  68. package/types/replay.d.ts +21 -1
  69. package/types/reviewdoor.d.ts +27 -0
  70. package/types/rubricstep.d.ts +57 -1
  71. package/types/runlist.d.ts +24 -1
  72. package/types/schedulewords.d.ts +137 -0
  73. package/types/source.d.ts +11 -0
  74. package/types/thinking.d.ts +39 -0
  75. package/types/trigger.d.ts +37 -0
  76. package/types/triggerclock.d.ts +52 -0
  77. package/types/triggerfiles.d.ts +93 -0
  78. package/types/triggerledger.d.ts +35 -0
  79. 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
- import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
- import { draftingPartFor } from '../draftspend.js';
36
+ import { readRunList, readNeedsRemovals, DIED_MTIME_MS, runIsAlive } from '../runlist.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';
@@ -42,19 +48,30 @@ import { runBehaviour } from '../behaviour.js';
42
48
  import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
43
49
  import { jobSpecHash, workflowKey } from '../job.js';
44
50
  import { confirmProtections } from '../authorflow.js';
51
+ import { createScheduleDoor } from './schedules.js';
52
+ import { createStarter } from './schedulestart.js';
53
+ import { createScheduler } from './scheduler.js';
54
+ import {
55
+ catchUpWords, dateTimeWords, jobScheduleWords, lowerFirst, scheduleSignFor, scheduledWords, sectionThisRunWords, skippedWords, startedAuditWords, startedWords, triesTailWords, SCHEDULE_WORDS,
56
+ } from '../schedulewords.js';
57
+ import { doorWaiting, heldScheduled } from '../reviewdoor.js';
45
58
  import { createAuthorRoutes, mintToken, checkHostGuard, panelMoney2 } from './authorroutes.js';
46
59
  import { createRunRoutes } from './runroutes.js';
47
60
  import { createImportRoutes, readImports, bundleStatus, IMPORT_CHANGED_LINE } from './importroutes.js';
48
61
  import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.js';
49
62
  import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
50
63
  import { checkCloseByteSignature } from '../close-integrity.js';
51
- import { keysHome } from '../keysfile.js';
64
+ import { keysHome, keysForDoor } from '../keysfile.js';
52
65
  import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '../providerrows.js';
53
66
  import { SHAPES } from '../providershapes.js';
54
67
  import { resolveProvider } from '../providers.js';
55
- import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
56
68
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
57
- 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';
58
75
  import { createSettingsRoutes } from './settingsroutes.js';
59
76
  import { planFromSpec } from './jobplan.js';
60
77
 
@@ -193,6 +210,8 @@ function describeLastRecord(r) {
193
210
  'scout-start': () => 'starting the scout',
194
211
  'scout-result': () => 'the scout finishing',
195
212
  'check-run': () => 'a check run',
213
+ 'output-judged': () => 'recording the file a check read',
214
+ 'rubric-evidence': () => 'recording the words a check read',
196
215
  'check-preflight': () => 'a preflight check',
197
216
  'check-menu': () => 'building the check menu',
198
217
  'step-start': (rec) => `starting step ${rec.step ?? '?'}`,
@@ -420,7 +439,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
420
439
  *
421
440
  * The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
422
441
  * Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
423
- * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
442
+ * (replaces P5 item 3, 2026-10-03; one door on every ending since 2026-10-15) starts a NEW run.
424
443
  * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
425
444
  * @param {{died: boolean, lastThing: string|null}} death
426
445
  * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
@@ -429,7 +448,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
429
448
  * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
430
449
  */
431
450
  export function endedFor(summary, death, o = {}) {
432
- const base = withEditAction(endedBase(summary, death, o));
451
+ const base = withReuseAction(endedBase(summary, death, o));
433
452
  const wt = o.worktree;
434
453
  if (!base || !wt) return base;
435
454
  // P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
@@ -445,13 +464,13 @@ export function endedFor(summary, death, o = {}) {
445
464
  }
446
465
 
447
466
  /**
448
- * "Edit in chat" (hamr 2026-10-07): offered on EVERY non-green ending (failed, capped, stopped, died, every other red),
449
- * after Resume when both show; a green ending keeps Reuse workflow only. ONE place, so no ending branch can forget it.
467
+ * ONE door (hamr 2026-10-15, v1.101): "Reuse workflow" is offered on EVERY ending (green, failed, capped, stopped, died, every other
468
+ * red), after Resume when both show. ONE place, so no ending branch can forget it.
450
469
  * @param {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null} ended
451
470
  */
452
- function withEditAction(ended) {
471
+ function withReuseAction(ended) {
453
472
  if (!ended || ended.actions.some((a) => a.id === 'reuse')) return ended;
454
- return { ...ended, actions: [...ended.actions, { id: 'edit', label: 'Edit in chat' }] };
473
+ return { ...ended, actions: [...ended.actions, { id: 'reuse', label: 'Reuse workflow' }] };
455
474
  }
456
475
 
457
476
  /** @param {any} summary @param {any} death @param {any} o */
@@ -460,9 +479,9 @@ function endedBase(summary, death, o) {
460
479
  /** @type {{id: string, label: string}[]} */
461
480
  const RESUME = [{ id: 'resume', label: 'Resume' }];
462
481
  /** Reuse workflow (replaces P5 item 3's Start from this, hamr 2026-10-03): the same signed job on a new source — a NEW
463
- * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press Edit in chat" instead. */
482
+ * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press Reuse workflow" instead. */
464
483
  const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
465
- const CHANGE = 'Change the job: press Edit in chat.';
484
+ const CHANGE = 'Change the job: press Reuse workflow.';
466
485
  const detailOf = (/** @type {string|null} */ s) => {
467
486
  if (typeof s !== 'string' || s.length === 0) return '';
468
487
  return s.length > 120 ? `${s.slice(0, 120)}…` : s;
@@ -519,8 +538,8 @@ function endedBase(summary, death, o) {
519
538
 
520
539
  if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
521
540
  if (o.destinationRefused) {
522
- // Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); the rest take Edit in chat
523
- // (withEditAction adds it) and are told to change the job, not to reuse it
541
+ // Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); every ending offers Reuse workflow
542
+ // (withReuseAction adds it) and is told to change the job
524
543
  const earned = outcome === 'green';
525
544
  return {
526
545
  reason: 'Goal met, but the output could not be delivered.',
@@ -530,7 +549,7 @@ function endedBase(summary, death, o) {
530
549
  };
531
550
  }
532
551
  // hamr 2026-10-08: already-green (the check passed before any work) is not a reusable job — only a run that
533
- // earned its green offers Reuse workflow; the rest take Edit in chat (withEditAction adds it). `satisfied` is a
552
+ // earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
534
553
  // close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
535
554
  if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
536
555
  return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
@@ -574,11 +593,21 @@ function endedBase(summary, death, o) {
574
593
  if (outcome === 'step-stalled') {
575
594
  return {
576
595
  reason: 'A step stopped making progress.',
577
- next: resumeOr('Resume, or change the job: press Edit in chat.'),
596
+ next: resumeOr('Resume, or change the job: press Reuse workflow.'),
578
597
  line: resumeOk ? 'step stalled — resume' : 'step stalled',
579
598
  actions: resumeOk ? RESUME : [],
580
599
  };
581
600
  }
601
+ // hamr 2026-10-16: a replan answer cut off at its length limit is a bounded failed attempt in plain words, not the
602
+ // provider's fault and not "the checks said no" (the engine files it as a plan-red escalation with cause 'truncated')
603
+ if (outcome === 'plan-red' && esc?.cause === 'truncated' && esc?.phase === 'replan') {
604
+ return {
605
+ reason: 'The replan answer was cut off at its length limit, so no new plan came back.',
606
+ next: CHANGE,
607
+ line: 'replan cut off',
608
+ actions: [],
609
+ };
610
+ }
582
611
  if (GOAL_NOT_MET.has(outcome)) {
583
612
  const d = detailOf(summary.stopReason);
584
613
  return {
@@ -624,6 +653,29 @@ function lastJobEndTs(/** @type {any[]} */ records) {
624
653
  return null;
625
654
  }
626
655
 
656
+ /** the timestamp of the spine's LAST record that carries one, or null (a died run has no job-end: this is when it went quiet) */
657
+ function lastRecordTs(/** @type {any[]} */ records) {
658
+ for (let i = records.length - 1; i >= 0; i -= 1) {
659
+ const r = records[i];
660
+ if (r && typeof r === 'object' && typeof r.ts === 'string') return r.ts;
661
+ }
662
+ return null;
663
+ }
664
+
665
+ /**
666
+ * A live run's plain "why" for the runs list: `step N of M` (the step it is on, of the plan's steps), or null while no plan is accepted yet
667
+ * (the card then says only `running`). Same words the Run tab's header uses.
668
+ * @param {any[]} records @param {any} summary
669
+ * @returns {string|null}
670
+ */
671
+ function liveStepWhy(records, summary) {
672
+ const plan = records.findLast((r) => r && r.type === 'plan-accepted');
673
+ const total = Array.isArray(plan?.plan?.steps) ? plan.plan.steps.length : 0;
674
+ if (total === 0) return null;
675
+ const done = new Set((summary.steps ?? []).filter((/** @type {any} */ u) => u && u.outcome !== null && u.outcome !== undefined && !u.stopReason).map((/** @type {any} */ u) => u.id)).size;
676
+ return `step ${Math.min(done + 1, total)} of ${total}`;
677
+ }
678
+
627
679
  function endedForRow(row, records, summary, death, withWorktree = false) {
628
680
  const out = summary.outcome;
629
681
  const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
@@ -648,17 +700,29 @@ function endedForRow(row, records, summary, death, withWorktree = false) {
648
700
  * exist YET: the runner appends the row first, then opens the spine. It is `starting`,
649
701
  * never `file missing`. Carries the same field names a normal row/detail does so every
650
702
  * client reader (filters, groups) sees a live `▶` run, with every figure honestly null.
651
- * @param {{ runid: string, job: string, at: string, via: string }} row
703
+ * @param {{ runid: string, job: string, at: string, via: string, spine: string }} row
652
704
  * @returns {any}
653
705
  */
654
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;
655
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',
656
714
  runid: row.runid,
657
715
  job: row.job,
658
716
  at: row.at,
659
717
  via: row.via,
660
718
  fileMissing: false,
661
719
  starting: true,
720
+ live: true,
721
+ liveWhy: null,
722
+ needs: null,
723
+ stoppedAt: null,
724
+ liveSince: typeof row.at === 'string' ? row.at : null,
725
+ priorWallMs: 0,
662
726
  died: false,
663
727
  glyph: '▶',
664
728
  status: statusFor({ outcome: null }),
@@ -684,19 +748,63 @@ function startingStub(row) {
684
748
  };
685
749
  }
686
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
+
773
+ /**
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.
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
776
+ * brings the run back — or after `needsYouDays`. `removedMs` is the newest removal that covers the current ending (or null), `expired` is true when only
777
+ * the age rule took a Resume run out.
778
+ * @param {{live: boolean, records: any[], resume: {ok?: boolean}|null|undefined, stoppedAt: string|null, nowMs: number, removedMs?: number}} f
779
+ * @returns {{needs: 'Accept'|'Resume'|null, removedMs: number|null, expired: boolean}}
780
+ */
781
+ export function needsStateFor(f) {
782
+ if (!f.live && doorWaiting(f.records, { now: () => f.nowMs })) return { needs: 'Accept', removedMs: null, expired: false };
783
+ const stopMs = typeof f.stoppedAt === 'string' ? Date.parse(f.stoppedAt) : NaN;
784
+ const covers = typeof f.removedMs === 'number' && (!Number.isFinite(stopMs) || f.removedMs >= stopMs);
785
+ const tag = needsTag(f.resume, f.stoppedAt, f.nowMs);
786
+ const canResume = !f.live && !!f.resume && f.resume.ok === true;
787
+ if (covers && canResume) return { needs: null, removedMs: /** @type {number} */ (f.removedMs), expired: false };
788
+ return { needs: tag, removedMs: null, expired: canResume && tag === null };
789
+ }
790
+
687
791
  /**
688
792
  * One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
689
793
  * spine no longer exists on disk — never silently listed as if it were
690
794
  * still there (the same rule {@link import('../runlist.js').formatRunRow}
691
795
  * already applies to the CLI's own listing).
692
796
  * @param {import('../runlist.js').RunRow} row
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)
693
799
  * @returns {any}
694
800
  */
695
- function summarizeRow(row) {
801
+ function summarizeRow(row, nowMs = Date.now(), removals = undefined, events = []) {
696
802
  if (!existsSync(row.spine)) {
803
+ const refusal = refusalWordsOf(events, row);
804
+ if (refusal !== null) return refusedStub(row, refusal);
697
805
  if (runIsAlive(row)) return startingStub(row);
698
806
  return {
699
- runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
807
+ runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true, live: false, needs: null, stoppedAt: null,
700
808
  };
701
809
  }
702
810
  let summary;
@@ -707,17 +815,33 @@ function summarizeRow(row) {
707
815
  } catch (e) {
708
816
  return {
709
817
  runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, readError: String(/** @type {Error} */ (e).message),
818
+ live: false, needs: null, stoppedAt: null,
710
819
  };
711
820
  }
712
821
  const line = summarizeForAllLine(summary);
713
822
  const death = deriveDeath(row, rawRecords, summary.outcome);
714
- const { ended, status } = endedForRow(row, rawRecords, summary, death);
823
+ const { ended, status, resume } = endedForRow(row, rawRecords, summary, death);
824
+ // P9 v2: the server decides every state the runs list draws (the page works out nothing).
825
+ const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
826
+ const stoppedAt = live ? null : (lastJobEndTs(rawRecords) ?? (death.died ? lastRecordTs(rawRecords) : null));
827
+ const legs = legsOf(rawRecords);
828
+ const priorWall = legs.length <= 1 ? { ms: 0, complete: true } : legsWallMs(legs.slice(0, -1));
829
+ const liveStart = live ? legs.at(-1)?.startMs ?? null : null;
715
830
  return {
716
831
  runid: row.runid,
717
832
  job: row.job,
718
833
  at: row.at,
719
834
  via: row.via,
720
835
  fileMissing: false,
836
+ live,
837
+ liveWhy: live ? liveStepWhy(rawRecords, summary) : null,
838
+ // a run waiting at its review door needs the person's Accept (never ages out, T10); else the Resume rule
839
+ needs: needsStateFor({ live, records: rawRecords, resume, stoppedAt, nowMs, removedMs: removals ? removals.get(row.runid) : undefined }).needs,
840
+ stoppedAt,
841
+ // a live run's wall counts up from the start of its CURRENT leg; earlier legs add `priorWallMs` (the gap between legs is nobody's).
842
+ // `priorWallMs` is null when the earlier legs' wall is not exact: the card then shows this leg's wall only, and says so.
843
+ liveSince: liveStart === null ? (live && typeof row.at === 'string' ? row.at : null) : new Date(liveStart).toISOString(),
844
+ priorWallMs: live ? (priorWall.complete ? priorWall.ms : null) : null,
721
845
  died: death.died,
722
846
  endedLine: ended ? ended.line : null,
723
847
  // P5-R: one card per run — how many times it was resumed (a resumed run is the same run, never a second card)
@@ -784,6 +908,21 @@ export function getStopContext(runid, opts = {}) {
784
908
  return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
785
909
  }
786
910
 
911
+ /**
912
+ * The Remove-from-Needs-you route's reader (RULED 2026-10-15): the listed row and the Needs-you tag the runs list shows for it right now
913
+ * (`summarizeRow`, the one rule). `null` when the runid is not listed.
914
+ * @param {string} runid
915
+ * @param {{ home?: string, now?: number }} [opts]
916
+ * @returns {{row: any, needs: 'Accept'|'Resume'|null}|null}
917
+ */
918
+ export function getNeedsContext(runid, opts = {}) {
919
+ const { rows } = readRunList(opts);
920
+ const row = rows.find((r) => r && r.runid === runid);
921
+ if (!row) return null;
922
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
923
+ return { row, needs: summarizeRow(row, nowMs, readNeedsRemovals(opts)).needs ?? null };
924
+ }
925
+
787
926
  /**
788
927
  * @param {any} r
789
928
  * @returns {number}
@@ -818,6 +957,57 @@ function runNumbers(rows) {
818
957
  return out;
819
958
  }
820
959
 
960
+ /** The named limits of the runs list, in ONE place (P9 v2 D.13): quiet after 30 days, 7 older runs shown, a Resume tag ages out after 7 days. Sent in `GET /api/runs`; the page reads them from there. */
961
+ export const RUNS_LIST_LIMITS = Object.freeze({ quietDays: 30, olderRuns: 7, needsYouDays: 7 });
962
+
963
+ /**
964
+ * The ONE rule for a run's Needs-you tag (P9 v2 E): `Resume` when the engine would accept a resume (the SAME `resumePlanFor` answer the Run
965
+ * tab's Resume card shows, already asked once per row by `endedForRow`), until the run has been stopped `needsYouDays` (exactly that old is out;
966
+ * no readable stop time stays in). `Accept` comes from {@link doorWaiting} (T Part 8), asked in `summarizeRow`, never here.
967
+ * @param {{ok?: boolean}|null|undefined} resume @param {string|null} stoppedAt @param {number} nowMs
968
+ * @returns {'Accept'|'Resume'|null}
969
+ */
970
+ export function needsTag(resume, stoppedAt, nowMs) {
971
+ if (!resume || resume.ok !== true) return null;
972
+ const t = typeof stoppedAt === 'string' ? Date.parse(stoppedAt) : NaN;
973
+ if (Number.isFinite(t) && nowMs - t >= RUNS_LIST_LIMITS.needsYouDays * 86_400_000) return null;
974
+ return 'Resume';
975
+ }
976
+
977
+ /**
978
+ * One entry per job, newest activity first: `headRunid` is the job's newest run BY TIME (never "the first row in server order"), `activityMs` the
979
+ * newest KNOWN time over its runs (`stoppedAt`, else `at`), `quiet` only when that time is known and older than the quiet limit. A job with a live
980
+ * run is never quiet; an unknown age is never guessed into quiet.
981
+ * @param {any[]} rows `listRuns` rows @param {number} nowMs
982
+ * @returns {{job: string, headRunid: string|null, quiet: boolean, activityMs: number|null}[]}
983
+ */
984
+ export function summarizeJobs(rows, nowMs) {
985
+ /** @type {Map<string, {head: any, headMs: number, act: number|null, live: boolean}>} */
986
+ const by = new Map();
987
+ const ms = (/** @type {any} */ x) => { const t = typeof x === 'string' ? Date.parse(x) : NaN; return Number.isFinite(t) ? t : null; };
988
+ for (const r of rows) {
989
+ if (!r || typeof r.job !== 'string') continue;
990
+ const e = by.get(r.job) ?? { head: null, headMs: -Infinity, act: null, live: false };
991
+ const at = ms(r.at);
992
+ if (e.head === null || (at !== null && at > e.headMs)) { e.head = r; e.headMs = at ?? -Infinity; }
993
+ const t = Math.max(at ?? -Infinity, ms(r.stoppedAt) ?? -Infinity);
994
+ if (t > -Infinity && (e.act === null || t > e.act)) e.act = t;
995
+ if (r.live === true) e.live = true;
996
+ by.set(r.job, e);
997
+ }
998
+ const quietMs = RUNS_LIST_LIMITS.quietDays * 86_400_000;
999
+ return [...by.entries()]
1000
+ .map(([job, e]) => ({ job, headRunid: e.head ? e.head.runid : null, quiet: !e.live && e.act !== null && nowMs - e.act > quietMs, activityMs: e.act }))
1001
+ .sort((a, b) => {
1002
+ if (a.activityMs !== b.activityMs) {
1003
+ if (a.activityMs === null) return 1;
1004
+ if (b.activityMs === null) return -1;
1005
+ return b.activityMs - a.activityMs;
1006
+ }
1007
+ return a.job < b.job ? -1 : a.job > b.job ? 1 : 0;
1008
+ });
1009
+ }
1010
+
821
1011
  /**
822
1012
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
823
1013
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -831,16 +1021,38 @@ function runNumbers(rows) {
831
1021
  * SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
832
1022
  * than a second server-side endpoint, so the glyph/checkType mapping is
833
1023
  * still computed in exactly one place.
834
- * @param {{ home?: string }} [opts]
1024
+ * @param {{ home?: string, now?: number }} [opts] `now` (ms) is injectable for the Needs-you age rule
835
1025
  * @returns {any[]}
836
1026
  */
837
1027
  export function listRuns(opts = {}) {
838
- const { rows } = readRunList(opts);
1028
+ const { rows, events } = readRunList(opts);
1029
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
839
1030
  const nos = runNumbers(rows);
1031
+ const removals = readNeedsRemovals(opts);
840
1032
  return rows
841
1033
  .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
842
1034
  .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
843
- .map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
1035
+ .map(({ row }) => ({ ...summarizeRow(row, nowMs, removals, events), runNo: nos.get(row.runid) }));
1036
+ }
1037
+
1038
+ /**
1039
+ * `GET /api/runs` (P9 v2 F): `{runs, jobs, limits}`. The server decides every state the left list draws — `live`, `needs`, each job's head run and
1040
+ * quiet flag, the limits — and the page works out nothing. `scheduled` is the triggers piece (PRD v1.99, T Part 7).
1041
+ * @param {{ home?: string, now?: number, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
1042
+ */
1043
+ export function runsListPayload(opts = {}) {
1044
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
1045
+ const runs = listRuns({ ...opts, now: nowMs });
1046
+ const jobs = summarizeJobs(runs, nowMs);
1047
+ // T Part 7: the Scheduled section, `[{job, at, in, words, runId}]`; runId = the job's HEAD run by time (summarizeJobs), never the first in server order
1048
+ const scheduled0 = opts.schedules ? opts.schedules.listScheduled(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
1049
+ // RULED 2026-10-15: schedules that gave up wait in Needs you; the server builds the cards (job, pill, words, date, the head run to open)
1050
+ const gaveUp0 = opts.schedules ? opts.schedules.listGaveUp(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
1051
+ // RULED 2026-10-15: both cards name the run they open (`job · run-N`), the number from the run list
1052
+ const noOf = (/** @type {any} */ x) => { const r = runs.find((q) => q.runid === x.runId); return r && typeof r.runNo === 'number' ? { ...x, runNo: r.runNo } : x; };
1053
+ const gaveUp = gaveUp0.map(noOf);
1054
+ const scheduled = scheduled0.map(noOf);
1055
+ return { runs, jobs, scheduled, gaveUp, limits: RUNS_LIST_LIMITS };
844
1056
  }
845
1057
 
846
1058
  /**
@@ -880,6 +1092,391 @@ function findRunRow(runid, opts) {
880
1092
  return readRunList(opts).rows.find((r) => r && r.runid === runid);
881
1093
  }
882
1094
 
1095
+ /**
1096
+ * T Part 7 — `detail.schedule`: `{scheduled, canSchedule, words, text?, next?, nextRed?}` from the schedule door's ONE read of the
1097
+ * schedule files and ledger (A.12). `waitingRunNo` = the job's NEWEST run when it waits for the person's Accept: ONE spine parse,
1098
+ * never a run context per run. A job that cannot be read has no schedule block.
1099
+ * @param {string} job @param {any[]} rows the run list @param {Map<string, number>} nos @param {{ schedules?: ReturnType<typeof createScheduleDoor> }} opts
1100
+ */
1101
+ function scheduleDetailFor(job, rows, nos, opts) {
1102
+ try {
1103
+ const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
1104
+ let newest = null;
1105
+ for (const r of rows) if (r && r.job === job && (newest === null || atMs(r) >= atMs(newest))) newest = r;
1106
+ let waitingRunNo = null;
1107
+ if (newest && existsSync(newest.spine) && door.readSchedule(job) && !runIsAlive(newest)) {
1108
+ const records = parseJsonl(newest.spine).records;
1109
+ if (doorWaiting(records)) waitingRunNo = nos.get(newest.runid) ?? null;
1110
+ }
1111
+ return door.view(job, { waitingRunNo });
1112
+ } catch { return null; }
1113
+ }
1114
+
1115
+ /**
1116
+ * The record `trigger.json` a SCHEDULED start wrote beside its signed spec (why the run started), or null for every hand run.
1117
+ * @param {{ spine: string }} row @returns {any|null}
1118
+ */
1119
+ function triggerRecordOf(row) {
1120
+ const near = sourceNearSpine(row.spine);
1121
+ if (!near.specPath) return null;
1122
+ try { return JSON.parse(readFileSync(join(dirname(near.specPath), 'trigger.json'), 'utf8')); } catch { return null; }
1123
+ }
1124
+
1125
+ /**
1126
+ * T Part 8 — what the Accept route needs: the run's row, whether its runner is alive, whether it waits at its review door
1127
+ * ({@link doorWaiting}), and the signed spec it was judged by (the file and hash `run-u --door --approve` takes).
1128
+ * @param {string} runid @param {{ home?: string }} opts
1129
+ * @returns {{row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null}
1130
+ */
1131
+ export function getAcceptContext(runid, opts = {}) {
1132
+ const row = findRunRow(runid, opts);
1133
+ if (!row || row.via === 'import') return null;
1134
+ if (!existsSync(row.spine)) return { row, live: runIsAlive(row), waiting: false, specPath: null, specHash: null };
1135
+ const records = parseJsonl(row.spine).records;
1136
+ const signed = signedSpecForRun(row, records);
1137
+ return { row, live: runIsAlive(row), waiting: doorWaiting(records), specPath: signed ? signed.specPath : null, specHash: signed ? signed.specHash : null };
1138
+ }
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
+
883
1480
  /**
884
1481
  * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
885
1482
  * iterations), counters, summary-box fields. Looks `runid` up in the run
@@ -887,25 +1484,95 @@ function findRunRow(runid, opts) {
887
1484
  * header); `null` when the runid is not in the list at all (caller renders
888
1485
  * 404).
889
1486
  * @param {string} runid
890
- * @param {{ home?: string }} [opts]
1487
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
891
1488
  * @returns {any|null}
892
1489
  */
893
1490
  export function getRunDetail(runid, opts = {}) {
894
1491
  const d = getRunDetailBody(runid, opts);
895
1492
  if (!d) return d;
896
- const runNo = runNumbers(readRunList(opts).rows).get(runid);
897
- return runNo === undefined ? d : { ...d, runNo };
1493
+ const { rows } = readRunList(opts);
1494
+ const nos = runNumbers(rows);
1495
+ const runNo = nos.get(runid);
1496
+ // T Part 7: what the Run tab shows of the job's schedule — the SERVER decides every state and word (the page works out nothing)
1497
+ const schedule = opts.schedules && typeof d.job === 'string' ? scheduleDetailFor(d.job, rows, nos, opts) : null;
1498
+ const extra = opts.schedules && typeof d.job === 'string' ? scheduleExtrasFor(runid, d.job, rows, nos, opts, schedule) : {};
1499
+ return { ...d, ...(runNo === undefined ? {} : { runNo }), ...(schedule ? { schedule: extra.schedule ?? schedule } : {}), ...(extra.audit ?? {}) };
1500
+ }
1501
+
1502
+ /**
1503
+ * Aligned with fwdloop M9 am7: the SCHEDULE row's second line and the Audit's schedule rows, made on the server from the door's data.
1504
+ * `schedule` = the door's view with `line2`, `problem` (and no raw ledger row); `audit` = `{scheduleSection}` or, for a run a cancelled schedule started, `{startedAudit}`.
1505
+ * @param {string} runid @param {string} job @param {any[]} rows @param {Map<string, number>} nos @param {any} opts @param {any} view
1506
+ * @returns {{schedule?: any, audit?: {startedAudit?: string, scheduleSection?: any}}}
1507
+ */
1508
+ function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
1509
+ try {
1510
+ const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
1511
+ /** @type {any} */
1512
+ let schedule;
1513
+ if (view) {
1514
+ const { lastRow, nextWords, accept, ...rest } = view;
1515
+ schedule = { ...rest, line2: null };
1516
+ if (view.scheduled) {
1517
+ let lastWords = null;
1518
+ if (lastRow) {
1519
+ if (lastRow.kind === 'started') {
1520
+ const mine = rows.filter((r) => r && r.job === job).sort((a, b) => atMs(b) - atMs(a)).slice(0, 25);
1521
+ let hit = null; let fallback = null;
1522
+ for (const r of mine) {
1523
+ const rec = triggerRecordOf(r);
1524
+ if (!rec) continue;
1525
+ if (fallback === null) fallback = { r, rec };
1526
+ if (rec.sessionId && rec.sessionId === lastRow.sessionId) { hit = { r, rec }; break; }
1527
+ }
1528
+ const got = hit ?? fallback;
1529
+ const no = got ? nos.get(got.r.runid) : undefined;
1530
+ const word = got ? summarizeRow(got.r).status?.word : undefined;
1531
+ lastWords = `last ${dateTimeWords(Number(lastRow.at))}${no ? ` · run-${no}` : ''}${word ? ` · ${word}` : ''}${got?.rec.catchUp === true ? ' · catch-up' : ''}`;
1532
+ } else {
1533
+ lastWords = `last ${dateTimeWords(Number(lastRow.at))} · ${skippedWords(lastRow, true)}`;
1534
+ }
1535
+ }
1536
+ schedule.line2 = [lastWords ?? 'no run yet', accept ? SCHEDULE_WORDS.acceptWaiting : null, nextWords].filter(Boolean).join(' · ');
1537
+ }
1538
+ }
1539
+ const row = rows.find((r) => r && r.runid === runid);
1540
+ const rec = row ? triggerRecordOf(row) : null;
1541
+ const cur = door.readSchedule(job);
1542
+ if (!cur) {
1543
+ // no schedule now: a run a cancelled schedule started keeps its one old line; a hand run has nothing
1544
+ const origin = rec ? (rec.originRunid ?? null) : null;
1545
+ return { ...(schedule ? { schedule } : {}), audit: rec ? { startedAudit: startedAuditWords(rec, origin ? (nos.get(origin) ?? null) : null) } : {} };
1546
+ }
1547
+ // RULED 2026-10-15 (v1.104): ONE `schedule` section on every run of a job that has a schedule now, folded when the page opens
1548
+ const originNo = nos.get(cur.originRunid) ?? null;
1549
+ const tries = door.skippedFor(job);
1550
+ const running = rows.some((r) => r && r.job === job && r.via !== 'import' && runIsAlive(r));
1551
+ const { sign, word } = scheduleSignFor({ running, red: !!(view && view.problem) });
1552
+ const words = scheduledWords(cur.trigger, door.userHome);
1553
+ const head = [lowerFirst(words ?? ''), originNo === null ? null : `from run-${originNo}`, triesTailWords(tries.length) || null].filter(Boolean).join(' · ');
1554
+ const when = row ? dateTimeWords(atMs(row)) : '';
1555
+ const thisRun = rec ? sectionThisRunWords(rec) : `by hand.${runid === cur.originRunid ? ' The schedule was set from this run.' : ''}`;
1556
+ const shown = tries.slice(0, 7).map((x, i) => ({ label: i === 0 ? 'tries' : '', text: x.line }));
1557
+ const scheduleSection = {
1558
+ title: SCHEDULE_WORDS.scheduleGroup, sign, word, head,
1559
+ rows: [{ label: 'this run', text: `${when} ${thisRun}` }, ...(shown.length > 0 ? shown : [{ label: 'tries', text: 'none' }])],
1560
+ };
1561
+ return { ...(schedule ? { schedule } : {}), audit: { scheduleSection } };
1562
+ } catch { return {}; }
898
1563
  }
899
1564
 
900
1565
  /**
901
1566
  * @param {string} runid
902
- * @param {{ home?: string }} opts
1567
+ * @param {{ home?: string, now?: number }} opts
903
1568
  * @returns {any|null}
904
1569
  */
905
1570
  function getRunDetailBody(runid, opts) {
906
1571
  const row = findRunRow(runid, opts);
907
1572
  if (!row) return null;
908
1573
  if (!existsSync(row.spine)) {
1574
+ const refusal = refusalWordsOf(readRunList(opts).events, row);
1575
+ if (refusal !== null) return refusedStub(row, refusal);
909
1576
  if (runIsAlive(row)) return startingStub(row);
910
1577
  return {
911
1578
  runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
@@ -927,7 +1594,8 @@ function getRunDetailBody(runid, opts) {
927
1594
  // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
928
1595
  const draftingPartHere = draftingFor(row, summary, opts);
929
1596
  const draftingShiftHere = draftingShift(row, summary, opts);
930
- const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
1597
+ const specForParts = resolveSpecForRow(row);
1598
+ const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(specForParts), specForParts);
931
1599
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
932
1600
  const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
933
1601
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
@@ -1091,6 +1759,12 @@ function getRunDetailBody(runid, opts) {
1091
1759
  // numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
1092
1760
  // whenever Resume is not offered.
1093
1761
  ended,
1762
+ // aligned with fwdloop M9 am7: `MM/DD HH:MM · <outcome word> · <reason>` (local end time); null while the run is live
1763
+ endedText: (() => {
1764
+ const ts = lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null);
1765
+ const ms = ts === null ? NaN : Date.parse(ts);
1766
+ return ended && Number.isFinite(ms) ? `${dateTimeWords(ms)} · ${status.word} · ${ended.reason}` : null;
1767
+ })(),
1094
1768
  // P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
1095
1769
  // (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
1096
1770
  live: row.via !== 'import' && runIsAlive(row),
@@ -1174,7 +1848,33 @@ function getRunDetailBody(runid, opts) {
1174
1848
  // carries no contamination risk); `null` when the spine carries no
1175
1849
  // `memory-cache` record at all (never armed on this run).
1176
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),
1177
1854
  memoryCache: summary.memoryCache,
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)
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 })),
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
1862
+ ...(() => {
1863
+ const trig = triggerRecordOf(row);
1864
+ return { startedBy: trig && typeof trig.reason === 'string' ? trig.reason : null, catchUp: catchUpWords(trig) };
1865
+ })(),
1866
+ // RULED 2026-10-15 (Remove from Needs you): the server says whether the button shows (a Resume card only, never a live Accept) and the Audit line
1867
+ ...(() => {
1868
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
1869
+ const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
1870
+ const stoppedAt = live ? null : (lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null));
1871
+ const st = needsStateFor({ live, records: rawSpineRecords, resume, stoppedAt, nowMs, removedMs: readNeedsRemovals(opts).get(row.runid) });
1872
+ /** @type {string|null} */
1873
+ let needsAudit = null;
1874
+ if (st.removedMs !== null) needsAudit = `removed from Needs you by you · ${dateTimeWords(st.removedMs)}`;
1875
+ else if (st.expired) needsAudit = `removed from Needs you after ${RUNS_LIST_LIMITS.needsYouDays} days`;
1876
+ return { canRemoveFromNeeds: st.needs === 'Resume', needsAudit };
1877
+ })(),
1178
1878
  };
1179
1879
  }
1180
1880
 
@@ -1594,7 +2294,7 @@ export const ROUNDS_PAGE_MAX = 200;
1594
2294
  * resolve to a real window, or the spine file is missing — the caller
1595
2295
  * renders 404, same posture as {@link getRunAudit}/{@link getRunJob}.
1596
2296
  * @param {string} runid
1597
- * @param {{ part?: number, attempt?: number, offset?: number, limit?: number }} query
2297
+ * @param {{ all?: boolean, part?: number, attempt?: number, offset?: number, limit?: number }} query
1598
2298
  * @param {{ home?: string }} [opts]
1599
2299
  * @returns {any|null}
1600
2300
  */
@@ -1616,6 +2316,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1616
2316
  if (shift && partIndex === 0) return null;
1617
2317
  const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
1618
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
+ }
1619
2324
  const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
1620
2325
  if (!a) return null;
1621
2326
  /** @type {{startSeq: number, endSeq: number, outcome: string|null, detail: string|null}} */
@@ -1677,6 +2382,90 @@ export function getRunRounds(runid, query = {}, opts = {}) {
1677
2382
  };
1678
2383
  }
1679
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
+
1680
2469
  /**
1681
2470
  * The `↳ close:`-style text for one iteration's OWN close-verdict — same
1682
2471
  * shape as `src/replay.js`'s private `closeStageLine`, but that helper is
@@ -1930,7 +2719,7 @@ function stageQuestionText({
1930
2719
  * ran. `null` when the spec has no `closeDecl.stages` array at all (a
1931
2720
  * command-close spec, or no spec resolved).
1932
2721
  * @param {any} spec
1933
- * @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}
1934
2723
  */
1935
2724
  function stageKindMetaFromSpec(spec) {
1936
2725
  if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
@@ -1946,7 +2735,7 @@ function stageKindMetaFromSpec(spec) {
1946
2735
  if (key !== null) suiteCmdKeys.add(key);
1947
2736
  }
1948
2737
  }
1949
- /** @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[]}>} */
1950
2739
  const map = new Map();
1951
2740
  for (const s of spec.closeDecl.stages) {
1952
2741
  if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
@@ -1958,12 +2747,21 @@ function stageKindMetaFromSpec(spec) {
1958
2747
  kind, params, genre, suiteCmdKeys,
1959
2748
  });
1960
2749
  map.set(s.name, {
1961
- kind, direction, baselineKind, question,
2750
+ kind, direction, baselineKind, question, fromLine: Array.isArray(s.fromLine) ? s.fromLine.filter(Number.isInteger) : [],
1962
2751
  });
1963
2752
  }
1964
2753
  return map;
1965
2754
  }
1966
2755
 
2756
+ /**
2757
+ * Stage name to the panel's plain question, off a signed spec — the SAME owner ({@link stageKindMetaFromSpec}) the step map and Audit use.
2758
+ * @param {any} spec
2759
+ * @returns {Map<string, string|null>}
2760
+ */
2761
+ function stageQuestionsOfSpec(spec) {
2762
+ return new Map([...(stageKindMetaFromSpec(spec) ?? new Map())].map(([n, m]) => [n, m.question]));
2763
+ }
2764
+
1967
2765
  /**
1968
2766
  * Attaches one stage's `{kind, direction, baselineKind, question}` (from
1969
2767
  * {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
@@ -2019,21 +2817,261 @@ function attachStageKind(stage, kindMeta) {
2019
2817
  * @param {Map<string, any>|null} kindMeta
2020
2818
  * @returns {any[]|null|undefined}
2021
2819
  */
2022
- function enrichPartsWithStageKind(parts, kindMeta) {
2820
+ function enrichPartsWithStageKind(parts, kindMeta, spec = null) {
2023
2821
  if (!Array.isArray(parts)) return parts;
2024
2822
  const declaredStagesTotal = kindMeta ? kindMeta.size : null;
2025
2823
  const declaredStages = kindMeta
2026
2824
  ? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
2027
2825
  : null;
2028
- return parts.map((part) => ({
2029
- ...part,
2030
- attempts: Array.isArray(part.attempts) ? part.attempts.map((a) => ({
2031
- ...a,
2032
- stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages,
2033
- ...(declaredStagesTotal ? { declaredStagesTotal } : {}),
2034
- ...(declaredStages ? { declaredStages } : {}),
2035
- })) : 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,
2036
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 };
2037
3075
  }
2038
3076
 
2039
3077
  /**
@@ -2185,10 +3223,50 @@ function jobsDir() {
2185
3223
  * from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
2186
3224
  * carry no close/writeScope/tools at all, so those stay `'not recorded'`.
2187
3225
  * @param {string} runid
2188
- * @param {{ home?: string }} [opts]
3226
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
2189
3227
  * @returns {any|null}
2190
3228
  */
2191
3229
  export function getRunJob(runid, opts = {}) {
3230
+ const body = withThinkingRow(getRunJobBody(runid, opts), runid, opts);
3231
+ if (!body || !opts.schedules) return body;
3232
+ try {
3233
+ const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
3234
+ const { rows } = readRunList(opts);
3235
+ const row = rows.find((r) => r && r.runid === runid);
3236
+ const job = row ? row.job : body.job;
3237
+ const nos = runNumbers(rows);
3238
+ const cur = door.readSchedule(job);
3239
+ const started = startedWords(row ? triggerRecordOf(row) : null, row ? atMs(row) : null);
3240
+ const spec = cur ? cur.spec : null;
3241
+ const schedule = jobScheduleWords({
3242
+ trigger: cur ? cur.trigger : null, originNo: cur ? (nos.get(cur.originRunid) ?? null) : null,
3243
+ model: spec && typeof spec.model === 'string' ? spec.model : null,
3244
+ destination: cur ? (typeof cur.destination === 'string' && cur.destination !== '' ? cur.destination : (Array.isArray(spec?.writeScope) ? spec.writeScope.join(', ') : null)) : null,
3245
+ home: door.userHome,
3246
+ });
3247
+ return { ...body, started, schedule };
3248
+ } catch { return body; }
3249
+ }
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
+
3268
+ /** @param {string} runid @param {{ home?: string }} opts */
3269
+ function getRunJobBody(runid, opts) {
2192
3270
  const row = findRunRow(runid, opts);
2193
3271
  if (!row) return null;
2194
3272
 
@@ -2262,6 +3340,7 @@ export function getRunJob(runid, opts = {}) {
2262
3340
  guardrails: notRecorded(guardrailsFromSpec(spec)),
2263
3341
  tools: notRecorded(toolsFromSpec(spec)),
2264
3342
  toolsList: toolsListFromSpec(spec),
3343
+ thinkingTick: spec.thinking === true,
2265
3344
  note,
2266
3345
  });
2267
3346
  const none = () => ({
@@ -2283,6 +3362,7 @@ export function getRunJob(runid, opts = {}) {
2283
3362
  guardrails: 'not recorded',
2284
3363
  tools: 'not recorded',
2285
3364
  toolsList: null,
3365
+ thinkingTick: false,
2286
3366
  note: 'no resolvable spec for this run (only bareloop-run bundle-layout runs carry one; a run-u run has none on disk)',
2287
3367
  });
2288
3368
 
@@ -2497,14 +3577,14 @@ export function reuseLine(record) {
2497
3577
 
2498
3578
  /**
2499
3579
  * `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
2500
- * boxes are locked and which are open, and the track record. Locked boxes carry the signed values, read by the
3580
+ * the track record (v1.101: nothing is locked, every box is open). The job boxes carry the signed values, read by the
2501
3581
  * SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
2502
3582
  * $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
2503
3583
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
2504
3584
  * @param {string} runid
2505
3585
  * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
2506
3586
  * @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
2507
- * locked: readonly string[], open: readonly string[], note: string|null,
3587
+ * note: string|null,
2508
3588
  * specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
2509
3589
  * specWorker: {provider: string, endpoint: string, label: string},
2510
3590
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -2537,6 +3617,9 @@ export function getStartFrom(runid, opts = {}) {
2537
3617
  source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
2538
3618
  // a card saved by P7 or later keeps the Inputs text the person typed
2539
3619
  inputs: typeof saved.inputs === 'string' && saved.inputs ? saved.inputs : '',
3620
+ // a plain-files job delivers to an absolute folder the SPEC does not hold (its fence is `output/` inside the run's tree): the
3621
+ // Destination opens with the folder the origin run was given, never with that fence
3622
+ destination: [saved.destination, manifest?.destination].find((d) => typeof d === 'string' && /^(\/|[a-zA-Z]:[\\/])/.test(d)) ?? '',
2540
3623
  jobName: row.job,
2541
3624
  });
2542
3625
  const key = workflowKey(spec);
@@ -2544,12 +3627,14 @@ export function getStartFrom(runid, opts = {}) {
2544
3627
  return {
2545
3628
  ok: true,
2546
3629
  origin: { runid, job: row.job },
2547
- // the run's terminal outcome — the Reuse start (POST /api/author/start) refuses anything but a real green
2548
- outcome: legsOf(records).at(-1)?.outcome ?? null,
3630
+ // the run's terminal outcome — the Reuse start (POST /api/author/start) reuses a green or already-green run only.
3631
+ // T D.2: a SCHEDULED run's green reads `green-held` until the person accepts it, so that same refusal holds it unchanged
3632
+ outcome: (() => {
3633
+ const o = legsOf(records).at(-1)?.outcome ?? null;
3634
+ return o === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
3635
+ })(),
2549
3636
  card,
2550
3637
  from: 'signed job',
2551
- locked: REUSE_LOCKED_FIELDS,
2552
- open: REUSE_OPEN_FIELDS,
2553
3638
  note: Array.isArray(spec.jobLines) && spec.jobLines.length ? null : 'older job — numbered lines were not saved',
2554
3639
  specHash: signed.specHash,
2555
3640
  workflowKey: key,
@@ -2568,6 +3653,29 @@ export function getStartFrom(runid, opts = {}) {
2568
3653
  };
2569
3654
  }
2570
3655
 
3656
+ /**
3657
+ * T Part 3: a job's listed runs, newest first, each with its signed spec (null when not on disk), its terminal outcome and
3658
+ * its parsed spine. The schedule door's gate (`greenFor`) reads this; nothing here builds a run context.
3659
+ * @param {string} job
3660
+ * @param {{ home?: string }} [opts]
3661
+ * @returns {{runid: string, outcome: string|null, spec: any, records: any[]}[]}
3662
+ */
3663
+ export function jobRunsOf(job, opts = {}) {
3664
+ const { rows } = readRunList(opts);
3665
+ /** @type {{runid: string, outcome: string|null, spec: any, records: any[]}[]} */
3666
+ const out = [];
3667
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
3668
+ const row = rows[i];
3669
+ if (!row || row.job !== job || !existsSync(row.spine)) continue;
3670
+ let records;
3671
+ try { records = parseJsonl(row.spine).records; } catch { continue; }
3672
+ const outcome = legsOf(records).at(-1)?.outcome ?? null;
3673
+ // T D.2: a scheduled run's green is HELD until accepted, so it is not a good run for the schedule gate either
3674
+ out.push({ runid: row.runid, outcome: outcome === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : outcome, spec: signedSpecForRun(row, records)?.spec ?? null, records });
3675
+ }
3676
+ return out;
3677
+ }
3678
+
2571
3679
  /**
2572
3680
  * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
2573
3681
  * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
@@ -2587,9 +3695,9 @@ export function listReuseJobs(opts = {}) {
2587
3695
  let records;
2588
3696
  try { records = parseJsonl(row.spine).records; } catch { continue; }
2589
3697
  const outcome = legsOf(records).at(-1)?.outcome ?? null;
2590
- // only a REAL green (hamr 2026-10-08): `already-green` means the check passed before any work, so there is nothing to reuse
2591
- // (src/reuse.js 'green-predates-run'). Narrower than the Run tab's Reuse button on purpose.
2592
- if (outcome !== 'green') continue;
3698
+ // a real green or an already-green (hamr 2026-10-15: if it is already fixed and nothing is to be done, it is green and can be reused)
3699
+ if (outcome !== 'green' && outcome !== 'already-green') continue;
3700
+ if (heldScheduled(records, triggerRecordOf(row))) continue; // T D.2: a scheduled green waits for the person's Accept
2593
3701
  const signed = signedSpecForRun(row, records);
2594
3702
  if (!signed) continue;
2595
3703
  const key = workflowKey(signed.spec);
@@ -2606,7 +3714,7 @@ export function listReuseJobs(opts = {}) {
2606
3714
  * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2607
3715
  * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
2608
3716
  * @param {any} spec
2609
- * @param {{model: string, source: string, inputs?: string, jobName: string}} extra `inputs` = the Inputs text the person saved (else built from the spec's inputs, else `repo: <source>`)
3717
+ * @param {{model: string, source: string, inputs?: string, jobName: string, destination?: string}} extra `destination` = an absolute per-run folder (a plain-files job), else the spec's fence; `inputs` = the Inputs text the person saved (else built from the spec's inputs, else `repo: <source>`)
2610
3718
  * @returns {Record<string, any>}
2611
3719
  */
2612
3720
  function reuseCardFromSpec(spec, extra) {
@@ -2616,8 +3724,9 @@ function reuseCardFromSpec(spec, extra) {
2616
3724
  checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
2617
3725
  model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
2618
3726
  jobText: jobTextFromSpec(spec),
3727
+ thinking: spec.thinking === true,
2619
3728
  inputs: extra.inputs || inputsTextFromSpec(spec, extra.source),
2620
- destination: writeScope.join(', '),
3729
+ destination: extra.destination || writeScope.join(', '),
2621
3730
  capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2622
3731
  maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
2623
3732
  };
@@ -2656,7 +3765,7 @@ function describeSpec(spec) {
2656
3765
  * @param {string} id
2657
3766
  * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
2658
3767
  * @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
2659
- * locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
3768
+ * note: string|null, specHash: string, workflowKey: string, spec: any,
2660
3769
  * specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2661
3770
  */
2662
3771
  export function getStartFromImport(id, opts = {}) {
@@ -2682,8 +3791,6 @@ export function getStartFromImport(id, opts = {}) {
2682
3791
  origin: { runid: null, job: row.job, importId: id },
2683
3792
  card,
2684
3793
  from: 'imported job',
2685
- locked: REUSE_LOCKED_FIELDS,
2686
- open: REUSE_OPEN_FIELDS,
2687
3794
  note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
2688
3795
  specHash: jobSpecHash(spec),
2689
3796
  workflowKey: key,
@@ -2723,7 +3830,7 @@ function sendText(res, code, text) {
2723
3830
  * only `createPanelServer` below always supplies one.
2724
3831
  * @param {import('node:http').IncomingMessage} req
2725
3832
  * @param {import('node:http').ServerResponse} res
2726
- * @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> }} 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
2727
3834
  */
2728
3835
  export function handleRequest(req, res, opts) {
2729
3836
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -2732,7 +3839,7 @@ export function handleRequest(req, res, opts) {
2732
3839
  function routesFor(o, p) {
2733
3840
  if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
2734
3841
  if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
2735
- if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop)$/.test(p)) return o.runRoutes;
3842
+ if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|rerun|pause|needs-remove)$/.test(p)) return o.runRoutes;
2736
3843
  if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
2737
3844
  return null;
2738
3845
  }
@@ -2837,9 +3944,9 @@ export function handleRequest(req, res, opts) {
2837
3944
  return;
2838
3945
  }
2839
3946
 
2840
- if (pathname === '/api/runs') { send(200, { runs: listRuns({ home: opts.home }) }); return; }
3947
+ if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
2841
3948
 
2842
- const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
3949
+ const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds|result|download))?$/.exec(pathname);
2843
3950
  if (runMatch) {
2844
3951
  const rawRunid = runMatch[1];
2845
3952
  const sub = runMatch[3] ?? null;
@@ -2856,15 +3963,36 @@ export function handleRequest(req, res, opts) {
2856
3963
  return;
2857
3964
  }
2858
3965
  if (sub === 'job') {
2859
- const result = getRunJob(runid, { home: opts.home });
3966
+ const result = getRunJob(runid, { home: opts.home, schedules: opts.schedules });
2860
3967
  if (!result) { sendText(res, 404, 'no such run'); return; }
2861
3968
  send(200, result);
2862
3969
  return;
2863
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
+ }
2864
3991
  if (sub === 'rounds') {
2865
3992
  /** @param {string|null} v @returns {number|undefined} */
2866
3993
  const num = (v) => (v !== null && /^\d+$/.test(v) ? Number(v) : undefined);
2867
3994
  const query = {
3995
+ all: url.searchParams.get('all') === '1',
2868
3996
  part: num(url.searchParams.get('part')),
2869
3997
  attempt: num(url.searchParams.get('attempt')),
2870
3998
  offset: num(url.searchParams.get('offset')),
@@ -2875,7 +4003,7 @@ export function handleRequest(req, res, opts) {
2875
4003
  send(200, result);
2876
4004
  return;
2877
4005
  }
2878
- const result = getRunDetail(runid, { home: opts.home });
4006
+ const result = getRunDetail(runid, { home: opts.home, schedules: opts.schedules });
2879
4007
  if (!result) { sendText(res, 404, 'no such run'); return; }
2880
4008
  send(200, result);
2881
4009
  return;
@@ -2903,8 +4031,9 @@ export function handleRequest(req, res, opts) {
2903
4031
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
2904
4032
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
2905
4033
  * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
2906
- * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string }} [opts]
2907
- * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
4034
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string,
4035
+ * scheduleNow?: () => number, sessionDeps?: Record<string, any>, schedulerIntervalMs?: number, acceptWaitMs?: number }} [opts] `scheduleNow`, `sessionDeps` and `schedulerIntervalMs` are TEST SEAMS (the schedule clock; createSession's stubs; the scheduler's timer, 0 = none)
4036
+ * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, schedules: ReturnType<typeof createScheduleDoor>, scheduler: ReturnType<typeof createScheduler>, close: () => Promise<void> }>}
2908
4037
  */
2909
4038
  export function createPanelServer(opts = {}) {
2910
4039
  const requestedPort = opts.port ?? DEFAULT_PORT;
@@ -2925,10 +4054,14 @@ export function createPanelServer(opts = {}) {
2925
4054
  let runRoutes;
2926
4055
  /** @type {ReturnType<typeof createImportRoutes>|undefined} */
2927
4056
  let importRoutes;
4057
+ /** @type {ReturnType<typeof createScheduler>|undefined} */
4058
+ let scheduler;
4059
+ /** @type {ReturnType<typeof createScheduleDoor>|undefined} */
4060
+ let schedulesRef;
2928
4061
  const server = createServer((req, res) => {
2929
4062
  try {
2930
4063
  handleRequest(req, res, {
2931
- home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath,
4064
+ home, sessionsRoot: opts.sessionsRoot, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
2932
4065
  });
2933
4066
  } catch (e) {
2934
4067
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
@@ -2942,7 +4075,22 @@ export function createPanelServer(opts = {}) {
2942
4075
  server.listen(requestedPort, '127.0.0.1', () => {
2943
4076
  const addr = server.address();
2944
4077
  boundPort = typeof addr === 'object' && addr !== null ? addr.port : requestedPort;
4078
+ const rowsFor = () => rowsForHome(keysHome(home), keysForDoor({ env: opts.env, keysHome: home }).env);
4079
+ const schedules = createScheduleDoor({
4080
+ home,
4081
+ ...(opts.userHome ? { userHome: opts.userHome } : {}),
4082
+ ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}),
4083
+ sessionsRoot: opts.sessionsRoot,
4084
+ jobRuns: (job) => jobRunsOf(job, { home }),
4085
+ startFrom: (runid) => getStartFrom(runid, { home }),
4086
+ jobOfRun: (runid) => readRunList({ home }).rows.find((r) => r && r.runid === runid)?.job ?? null,
4087
+ rowsFor,
4088
+ });
4089
+ schedulesRef = schedules;
2945
4090
  authorRoutes = createAuthorRoutes({
4091
+ schedules,
4092
+ runJobNames: () => readRunList({ home }).rows.flatMap((r) => (r && typeof r.job === 'string' ? [r.job] : [])),
4093
+ ...(opts.sessionDeps ? { sessionDeps: opts.sessionDeps } : {}),
2946
4094
  port: boundPort,
2947
4095
  token,
2948
4096
  env: opts.env,
@@ -2957,6 +4105,18 @@ export function createPanelServer(opts = {}) {
2957
4105
  listJobs: (o = {}) => listReuseJobs({ home, ...o }),
2958
4106
  },
2959
4107
  });
4108
+ schedules.attachStarter(createStarter({
4109
+ door: schedules, startSession: authorRoutes.startSession, envNow: authorRoutes.envNow, spawnFn: authorRoutes.spawnFn,
4110
+ bareloopBin: authorRoutes.bareloopBin, sessionsRoot: opts.sessionsRoot, home, rowsFor, startFrom: (runid) => getStartFrom(runid, { home }),
4111
+ forgetSession: authorRoutes.forgetHeadless,
4112
+ ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), // the SAME clock the scheduler reads, so lateMs and startedAt agree with the slot
4113
+ ranSince: (job, sinceMs) => readRunList({ home }).rows.some((r) => r && r.job === job && Date.parse(r.at) >= sinceMs - 1000),
4114
+ }));
4115
+ // T Part 5: the scheduler runs the clock and the folder watch while THIS server is up (panel only, T6); stopped on close
4116
+ scheduler = createScheduler({
4117
+ door: schedules, ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), ...(opts.schedulerIntervalMs !== undefined ? { intervalMs: opts.schedulerIntervalMs } : {}),
4118
+ });
4119
+ scheduler.start();
2960
4120
  settingsRoutes = createSettingsRoutes({
2961
4121
  port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
2962
4122
  openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
@@ -2971,6 +4131,10 @@ export function createPanelServer(opts = {}) {
2971
4131
  settleMs: opts.settleMs,
2972
4132
  getResumeContext: (runid) => getResumeContext(runid, { home }),
2973
4133
  getStopContext: (runid) => getStopContext(runid, { home }),
4134
+ getAcceptContext: (runid) => getAcceptContext(runid, { home }),
4135
+ stageQuestionsOfSpec,
4136
+ getNeedsContext: (runid) => getNeedsContext(runid, { home }),
4137
+ ...(opts.acceptWaitMs !== undefined ? { acceptWaitMs: opts.acceptWaitMs } : {}),
2974
4138
  });
2975
4139
  importRoutes = createImportRoutes({
2976
4140
  port: boundPort, token, home, userHome: opts.userHome, describeSpec,
@@ -2979,7 +4143,9 @@ export function createPanelServer(opts = {}) {
2979
4143
  server,
2980
4144
  port: boundPort,
2981
4145
  token,
2982
- close: () => new Promise((res2) => { server.close(() => res2(undefined)); }),
4146
+ schedules,
4147
+ scheduler: /** @type {ReturnType<typeof createScheduler>} */ (scheduler),
4148
+ close: () => new Promise((res2) => { scheduler?.stop(); server.close(() => res2(undefined)); }),
2983
4149
  });
2984
4150
  });
2985
4151
  });