bareloop 0.41.0 → 0.43.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 (75) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +97 -34
  4. package/package.json +4 -3
  5. package/src/authorflow.js +337 -29
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +84 -16
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +14 -5
  10. package/src/authorscout.js +108 -0
  11. package/src/declaredclose.js +25 -14
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/interviewrun.js +0 -1
  16. package/src/job.js +40 -12
  17. package/src/jobblock.js +7 -2
  18. package/src/kinds.js +84 -3
  19. package/src/labelscan.js +170 -0
  20. package/src/pagefetch.js +125 -0
  21. package/src/panel/authorroutes.js +121 -34
  22. package/src/panel/authorsession.js +154 -45
  23. package/src/panel/index.html +845 -490
  24. package/src/panel/jobplan.js +34 -11
  25. package/src/panel/runroutes.js +163 -5
  26. package/src/panel/scheduler.js +156 -0
  27. package/src/panel/schedules.js +667 -0
  28. package/src/panel/schedulestart.js +243 -0
  29. package/src/panel/server.js +467 -48
  30. package/src/planrun.js +42 -9
  31. package/src/providerrows.js +3 -3
  32. package/src/reviewdoor.js +37 -0
  33. package/src/rubricstep.js +804 -0
  34. package/src/runlist.js +35 -0
  35. package/src/schedulewords.js +296 -0
  36. package/src/source.js +166 -1
  37. package/src/trigger.js +48 -0
  38. package/src/triggerclock.js +127 -0
  39. package/src/triggerfiles.js +215 -0
  40. package/src/triggerledger.js +79 -0
  41. package/src/u-readout.js +1 -1
  42. package/src/userrun.js +20 -9
  43. package/types/authorflow.d.ts +267 -13
  44. package/types/authoring.d.ts +29 -1
  45. package/types/authorjob.d.ts +7 -5
  46. package/types/authorreadout.d.ts +27 -2
  47. package/types/authorscout.d.ts +70 -0
  48. package/types/declaredclose.d.ts +5 -3
  49. package/types/facts.d.ts +71 -0
  50. package/types/factwords.d.ts +42 -0
  51. package/types/inputlook.d.ts +238 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/jobblock.d.ts +8 -1
  54. package/types/labelscan.d.ts +38 -0
  55. package/types/pagefetch.d.ts +71 -0
  56. package/types/panel/authorroutes.d.ts +34 -1
  57. package/types/panel/authorsession.d.ts +55 -14
  58. package/types/panel/jobplan.d.ts +12 -4
  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 +145 -14
  64. package/types/planrun.d.ts +2 -0
  65. package/types/providerrows.d.ts +2 -2
  66. package/types/reviewdoor.d.ts +27 -0
  67. package/types/rubricstep.d.ts +286 -0
  68. package/types/runlist.d.ts +20 -0
  69. package/types/schedulewords.d.ts +135 -0
  70. package/types/source.d.ts +94 -0
  71. package/types/trigger.d.ts +37 -0
  72. package/types/triggerclock.d.ts +52 -0
  73. package/types/triggerfiles.d.ts +93 -0
  74. package/types/triggerledger.d.ts +35 -0
  75. package/types/userrun.d.ts +11 -0
@@ -30,7 +30,7 @@ import {
30
30
  dirname, join, basename, relative, isAbsolute, sep,
31
31
  } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
- import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
33
+ import { readRunList, readNeedsRemovals, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
34
34
  import { draftingPartFor } from '../draftspend.js';
35
35
  import {
36
36
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
@@ -42,17 +42,23 @@ import { runBehaviour } from '../behaviour.js';
42
42
  import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
43
43
  import { jobSpecHash, workflowKey } from '../job.js';
44
44
  import { confirmProtections } from '../authorflow.js';
45
+ import { createScheduleDoor } from './schedules.js';
46
+ import { createStarter } from './schedulestart.js';
47
+ import { createScheduler } from './scheduler.js';
48
+ import {
49
+ catchUpWords, dateTimeWords, jobScheduleWords, lowerFirst, scheduleSignFor, scheduledWords, sectionThisRunWords, skippedWords, startedAuditWords, startedWords, triesTailWords, SCHEDULE_WORDS,
50
+ } from '../schedulewords.js';
51
+ import { doorWaiting, heldScheduled } from '../reviewdoor.js';
45
52
  import { createAuthorRoutes, mintToken, checkHostGuard, panelMoney2 } from './authorroutes.js';
46
53
  import { createRunRoutes } from './runroutes.js';
47
54
  import { createImportRoutes, readImports, bundleStatus, IMPORT_CHANGED_LINE } from './importroutes.js';
48
55
  import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.js';
49
56
  import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
50
57
  import { checkCloseByteSignature } from '../close-integrity.js';
51
- import { keysHome } from '../keysfile.js';
58
+ import { keysHome, keysForDoor } from '../keysfile.js';
52
59
  import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '../providerrows.js';
53
60
  import { SHAPES } from '../providershapes.js';
54
61
  import { resolveProvider } from '../providers.js';
55
- import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
56
62
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
57
63
  import { statusFor, partStatusFor, GOAL_MET_LINE } from './status.js';
58
64
  import { createSettingsRoutes } from './settingsroutes.js';
@@ -60,6 +66,16 @@ import { planFromSpec } from './jobplan.js';
60
66
 
61
67
  const HERE = dirname(fileURLToPath(import.meta.url));
62
68
 
69
+ /** bareloop's own version, from its package.json (read each time the page is served); 'unknown' if it cannot be read, never a guess
70
+ * @param {string} [file] test seam: another package.json
71
+ * @returns {string} */
72
+ function packageVersion(file = join(HERE, '..', '..', 'package.json')) {
73
+ try {
74
+ const v = JSON.parse(readFileSync(file, 'utf8')).version;
75
+ return typeof v === 'string' && v.length > 0 ? v : 'unknown';
76
+ } catch { return 'unknown'; }
77
+ }
78
+
63
79
  /** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
64
80
  const MAX_BODY_BYTES = 1024 * 1024;
65
81
 
@@ -410,7 +426,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
410
426
  *
411
427
  * The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
412
428
  * Resume button appears only when `o.resume` says the engine would accept it; "Reuse workflow"
413
- * (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
429
+ * (replaces P5 item 3, 2026-10-03; one door on every ending since 2026-10-15) starts a NEW run.
414
430
  * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
415
431
  * @param {{died: boolean, lastThing: string|null}} death
416
432
  * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
@@ -419,7 +435,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
419
435
  * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
420
436
  */
421
437
  export function endedFor(summary, death, o = {}) {
422
- const base = withEditAction(endedBase(summary, death, o));
438
+ const base = withReuseAction(endedBase(summary, death, o));
423
439
  const wt = o.worktree;
424
440
  if (!base || !wt) return base;
425
441
  // P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
@@ -435,13 +451,13 @@ export function endedFor(summary, death, o = {}) {
435
451
  }
436
452
 
437
453
  /**
438
- * "Edit in chat" (hamr 2026-10-07): offered on EVERY non-green ending (failed, capped, stopped, died, every other red),
439
- * after Resume when both show; a green ending keeps Reuse workflow only. ONE place, so no ending branch can forget it.
454
+ * ONE door (hamr 2026-10-15, v1.101): "Reuse workflow" is offered on EVERY ending (green, failed, capped, stopped, died, every other
455
+ * red), after Resume when both show. ONE place, so no ending branch can forget it.
440
456
  * @param {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null} ended
441
457
  */
442
- function withEditAction(ended) {
458
+ function withReuseAction(ended) {
443
459
  if (!ended || ended.actions.some((a) => a.id === 'reuse')) return ended;
444
- return { ...ended, actions: [...ended.actions, { id: 'edit', label: 'Edit in chat' }] };
460
+ return { ...ended, actions: [...ended.actions, { id: 'reuse', label: 'Reuse workflow' }] };
445
461
  }
446
462
 
447
463
  /** @param {any} summary @param {any} death @param {any} o */
@@ -450,9 +466,9 @@ function endedBase(summary, death, o) {
450
466
  /** @type {{id: string, label: string}[]} */
451
467
  const RESUME = [{ id: 'resume', label: 'Resume' }];
452
468
  /** Reuse workflow (replaces P5 item 3's Start from this, hamr 2026-10-03): the same signed job on a new source — a NEW
453
- * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press Edit in chat" instead. */
469
+ * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press Reuse workflow" instead. */
454
470
  const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
455
- const CHANGE = 'Change the job: press Edit in chat.';
471
+ const CHANGE = 'Change the job: press Reuse workflow.';
456
472
  const detailOf = (/** @type {string|null} */ s) => {
457
473
  if (typeof s !== 'string' || s.length === 0) return '';
458
474
  return s.length > 120 ? `${s.slice(0, 120)}…` : s;
@@ -509,8 +525,8 @@ function endedBase(summary, death, o) {
509
525
 
510
526
  if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
511
527
  if (o.destinationRefused) {
512
- // Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); the rest take Edit in chat
513
- // (withEditAction adds it) and are told to change the job, not to reuse it
528
+ // Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); every ending offers Reuse workflow
529
+ // (withReuseAction adds it) and is told to change the job
514
530
  const earned = outcome === 'green';
515
531
  return {
516
532
  reason: 'Goal met, but the output could not be delivered.',
@@ -520,7 +536,7 @@ function endedBase(summary, death, o) {
520
536
  };
521
537
  }
522
538
  // hamr 2026-10-08: already-green (the check passed before any work) is not a reusable job — only a run that
523
- // earned its green offers Reuse workflow; the rest take Edit in chat (withEditAction adds it). `satisfied` is a
539
+ // earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
524
540
  // close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
525
541
  if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
526
542
  return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
@@ -564,11 +580,21 @@ function endedBase(summary, death, o) {
564
580
  if (outcome === 'step-stalled') {
565
581
  return {
566
582
  reason: 'A step stopped making progress.',
567
- next: resumeOr('Resume, or change the job: press Edit in chat.'),
583
+ next: resumeOr('Resume, or change the job: press Reuse workflow.'),
568
584
  line: resumeOk ? 'step stalled — resume' : 'step stalled',
569
585
  actions: resumeOk ? RESUME : [],
570
586
  };
571
587
  }
588
+ // hamr 2026-10-16: a replan answer cut off at its length limit is a bounded failed attempt in plain words, not the
589
+ // provider's fault and not "the checks said no" (the engine files it as a plan-red escalation with cause 'truncated')
590
+ if (outcome === 'plan-red' && esc?.cause === 'truncated' && esc?.phase === 'replan') {
591
+ return {
592
+ reason: 'The replan answer was cut off at its length limit, so no new plan came back.',
593
+ next: CHANGE,
594
+ line: 'replan cut off',
595
+ actions: [],
596
+ };
597
+ }
572
598
  if (GOAL_NOT_MET.has(outcome)) {
573
599
  const d = detailOf(summary.stopReason);
574
600
  return {
@@ -614,6 +640,29 @@ function lastJobEndTs(/** @type {any[]} */ records) {
614
640
  return null;
615
641
  }
616
642
 
643
+ /** 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) */
644
+ function lastRecordTs(/** @type {any[]} */ records) {
645
+ for (let i = records.length - 1; i >= 0; i -= 1) {
646
+ const r = records[i];
647
+ if (r && typeof r === 'object' && typeof r.ts === 'string') return r.ts;
648
+ }
649
+ return null;
650
+ }
651
+
652
+ /**
653
+ * 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
654
+ * (the card then says only `running`). Same words the Run tab's header uses.
655
+ * @param {any[]} records @param {any} summary
656
+ * @returns {string|null}
657
+ */
658
+ function liveStepWhy(records, summary) {
659
+ const plan = records.findLast((r) => r && r.type === 'plan-accepted');
660
+ const total = Array.isArray(plan?.plan?.steps) ? plan.plan.steps.length : 0;
661
+ if (total === 0) return null;
662
+ 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;
663
+ return `step ${Math.min(done + 1, total)} of ${total}`;
664
+ }
665
+
617
666
  function endedForRow(row, records, summary, death, withWorktree = false) {
618
667
  const out = summary.outcome;
619
668
  const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
@@ -649,6 +698,12 @@ function startingStub(row) {
649
698
  via: row.via,
650
699
  fileMissing: false,
651
700
  starting: true,
701
+ live: true,
702
+ liveWhy: null,
703
+ needs: null,
704
+ stoppedAt: null,
705
+ liveSince: typeof row.at === 'string' ? row.at : null,
706
+ priorWallMs: 0,
652
707
  died: false,
653
708
  glyph: '▶',
654
709
  status: statusFor({ outcome: null }),
@@ -674,19 +729,38 @@ function startingStub(row) {
674
729
  };
675
730
  }
676
731
 
732
+ /**
733
+ * RULED 2026-10-15 (Remove from Needs you): the ONE place a run's Needs-you state is decided. `Accept` (a live door) is never removable and never ages out.
734
+ * 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
735
+ * brings the run back — or after `needsYouDays`. `removedMs` is the newest removal that covers the current ending (or null), `expired` is true when only
736
+ * the age rule took a Resume run out.
737
+ * @param {{live: boolean, records: any[], resume: {ok?: boolean}|null|undefined, stoppedAt: string|null, nowMs: number, removedMs?: number}} f
738
+ * @returns {{needs: 'Accept'|'Resume'|null, removedMs: number|null, expired: boolean}}
739
+ */
740
+ export function needsStateFor(f) {
741
+ if (!f.live && doorWaiting(f.records, { now: () => f.nowMs })) return { needs: 'Accept', removedMs: null, expired: false };
742
+ const stopMs = typeof f.stoppedAt === 'string' ? Date.parse(f.stoppedAt) : NaN;
743
+ const covers = typeof f.removedMs === 'number' && (!Number.isFinite(stopMs) || f.removedMs >= stopMs);
744
+ const tag = needsTag(f.resume, f.stoppedAt, f.nowMs);
745
+ const canResume = !f.live && !!f.resume && f.resume.ok === true;
746
+ if (covers && canResume) return { needs: null, removedMs: /** @type {number} */ (f.removedMs), expired: false };
747
+ return { needs: tag, removedMs: null, expired: canResume && tag === null };
748
+ }
749
+
677
750
  /**
678
751
  * One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
679
752
  * spine no longer exists on disk — never silently listed as if it were
680
753
  * still there (the same rule {@link import('../runlist.js').formatRunRow}
681
754
  * already applies to the CLI's own listing).
682
755
  * @param {import('../runlist.js').RunRow} row
756
+ * @param {number} [nowMs] @param {Map<string, number>} [removals] `readNeedsRemovals`: when each run was taken out of Needs you
683
757
  * @returns {any}
684
758
  */
685
- function summarizeRow(row) {
759
+ function summarizeRow(row, nowMs = Date.now(), removals = undefined) {
686
760
  if (!existsSync(row.spine)) {
687
761
  if (runIsAlive(row)) return startingStub(row);
688
762
  return {
689
- runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
763
+ runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true, live: false, needs: null, stoppedAt: null,
690
764
  };
691
765
  }
692
766
  let summary;
@@ -697,17 +771,33 @@ function summarizeRow(row) {
697
771
  } catch (e) {
698
772
  return {
699
773
  runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, readError: String(/** @type {Error} */ (e).message),
774
+ live: false, needs: null, stoppedAt: null,
700
775
  };
701
776
  }
702
777
  const line = summarizeForAllLine(summary);
703
778
  const death = deriveDeath(row, rawRecords, summary.outcome);
704
- const { ended, status } = endedForRow(row, rawRecords, summary, death);
779
+ const { ended, status, resume } = endedForRow(row, rawRecords, summary, death);
780
+ // P9 v2: the server decides every state the runs list draws (the page works out nothing).
781
+ const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
782
+ const stoppedAt = live ? null : (lastJobEndTs(rawRecords) ?? (death.died ? lastRecordTs(rawRecords) : null));
783
+ const legs = legsOf(rawRecords);
784
+ const priorWall = legs.length <= 1 ? { ms: 0, complete: true } : legsWallMs(legs.slice(0, -1));
785
+ const liveStart = live ? legs.at(-1)?.startMs ?? null : null;
705
786
  return {
706
787
  runid: row.runid,
707
788
  job: row.job,
708
789
  at: row.at,
709
790
  via: row.via,
710
791
  fileMissing: false,
792
+ live,
793
+ liveWhy: live ? liveStepWhy(rawRecords, summary) : null,
794
+ // a run waiting at its review door needs the person's Accept (never ages out, T10); else the Resume rule
795
+ needs: needsStateFor({ live, records: rawRecords, resume, stoppedAt, nowMs, removedMs: removals ? removals.get(row.runid) : undefined }).needs,
796
+ stoppedAt,
797
+ // 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).
798
+ // `priorWallMs` is null when the earlier legs' wall is not exact: the card then shows this leg's wall only, and says so.
799
+ liveSince: liveStart === null ? (live && typeof row.at === 'string' ? row.at : null) : new Date(liveStart).toISOString(),
800
+ priorWallMs: live ? (priorWall.complete ? priorWall.ms : null) : null,
711
801
  died: death.died,
712
802
  endedLine: ended ? ended.line : null,
713
803
  // P5-R: one card per run — how many times it was resumed (a resumed run is the same run, never a second card)
@@ -774,6 +864,21 @@ export function getStopContext(runid, opts = {}) {
774
864
  return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
775
865
  }
776
866
 
867
+ /**
868
+ * 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
869
+ * (`summarizeRow`, the one rule). `null` when the runid is not listed.
870
+ * @param {string} runid
871
+ * @param {{ home?: string, now?: number }} [opts]
872
+ * @returns {{row: any, needs: 'Accept'|'Resume'|null}|null}
873
+ */
874
+ export function getNeedsContext(runid, opts = {}) {
875
+ const { rows } = readRunList(opts);
876
+ const row = rows.find((r) => r && r.runid === runid);
877
+ if (!row) return null;
878
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
879
+ return { row, needs: summarizeRow(row, nowMs, readNeedsRemovals(opts)).needs ?? null };
880
+ }
881
+
777
882
  /**
778
883
  * @param {any} r
779
884
  * @returns {number}
@@ -808,6 +913,57 @@ function runNumbers(rows) {
808
913
  return out;
809
914
  }
810
915
 
916
+ /** 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. */
917
+ export const RUNS_LIST_LIMITS = Object.freeze({ quietDays: 30, olderRuns: 7, needsYouDays: 7 });
918
+
919
+ /**
920
+ * 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
921
+ * tab's Resume card shows, already asked once per row by `endedForRow`), until the run has been stopped `needsYouDays` (exactly that old is out;
922
+ * no readable stop time stays in). `Accept` comes from {@link doorWaiting} (T Part 8), asked in `summarizeRow`, never here.
923
+ * @param {{ok?: boolean}|null|undefined} resume @param {string|null} stoppedAt @param {number} nowMs
924
+ * @returns {'Accept'|'Resume'|null}
925
+ */
926
+ export function needsTag(resume, stoppedAt, nowMs) {
927
+ if (!resume || resume.ok !== true) return null;
928
+ const t = typeof stoppedAt === 'string' ? Date.parse(stoppedAt) : NaN;
929
+ if (Number.isFinite(t) && nowMs - t >= RUNS_LIST_LIMITS.needsYouDays * 86_400_000) return null;
930
+ return 'Resume';
931
+ }
932
+
933
+ /**
934
+ * 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
935
+ * 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
936
+ * run is never quiet; an unknown age is never guessed into quiet.
937
+ * @param {any[]} rows `listRuns` rows @param {number} nowMs
938
+ * @returns {{job: string, headRunid: string|null, quiet: boolean, activityMs: number|null}[]}
939
+ */
940
+ export function summarizeJobs(rows, nowMs) {
941
+ /** @type {Map<string, {head: any, headMs: number, act: number|null, live: boolean}>} */
942
+ const by = new Map();
943
+ const ms = (/** @type {any} */ x) => { const t = typeof x === 'string' ? Date.parse(x) : NaN; return Number.isFinite(t) ? t : null; };
944
+ for (const r of rows) {
945
+ if (!r || typeof r.job !== 'string') continue;
946
+ const e = by.get(r.job) ?? { head: null, headMs: -Infinity, act: null, live: false };
947
+ const at = ms(r.at);
948
+ if (e.head === null || (at !== null && at > e.headMs)) { e.head = r; e.headMs = at ?? -Infinity; }
949
+ const t = Math.max(at ?? -Infinity, ms(r.stoppedAt) ?? -Infinity);
950
+ if (t > -Infinity && (e.act === null || t > e.act)) e.act = t;
951
+ if (r.live === true) e.live = true;
952
+ by.set(r.job, e);
953
+ }
954
+ const quietMs = RUNS_LIST_LIMITS.quietDays * 86_400_000;
955
+ return [...by.entries()]
956
+ .map(([job, e]) => ({ job, headRunid: e.head ? e.head.runid : null, quiet: !e.live && e.act !== null && nowMs - e.act > quietMs, activityMs: e.act }))
957
+ .sort((a, b) => {
958
+ if (a.activityMs !== b.activityMs) {
959
+ if (a.activityMs === null) return 1;
960
+ if (b.activityMs === null) return -1;
961
+ return b.activityMs - a.activityMs;
962
+ }
963
+ return a.job < b.job ? -1 : a.job > b.job ? 1 : 0;
964
+ });
965
+ }
966
+
811
967
  /**
812
968
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
813
969
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -821,16 +977,38 @@ function runNumbers(rows) {
821
977
  * SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
822
978
  * than a second server-side endpoint, so the glyph/checkType mapping is
823
979
  * still computed in exactly one place.
824
- * @param {{ home?: string }} [opts]
980
+ * @param {{ home?: string, now?: number }} [opts] `now` (ms) is injectable for the Needs-you age rule
825
981
  * @returns {any[]}
826
982
  */
827
983
  export function listRuns(opts = {}) {
828
984
  const { rows } = readRunList(opts);
985
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
829
986
  const nos = runNumbers(rows);
987
+ const removals = readNeedsRemovals(opts);
830
988
  return rows
831
989
  .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
832
990
  .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
833
- .map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
991
+ .map(({ row }) => ({ ...summarizeRow(row, nowMs, removals), runNo: nos.get(row.runid) }));
992
+ }
993
+
994
+ /**
995
+ * `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
996
+ * quiet flag, the limits — and the page works out nothing. `scheduled` is the triggers piece (PRD v1.99, T Part 7).
997
+ * @param {{ home?: string, now?: number, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
998
+ */
999
+ export function runsListPayload(opts = {}) {
1000
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
1001
+ const runs = listRuns({ ...opts, now: nowMs });
1002
+ const jobs = summarizeJobs(runs, nowMs);
1003
+ // 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
1004
+ const scheduled0 = opts.schedules ? opts.schedules.listScheduled(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
1005
+ // 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)
1006
+ const gaveUp0 = opts.schedules ? opts.schedules.listGaveUp(new Map(jobs.map((j) => [j.job, j.headRunid]))) : [];
1007
+ // RULED 2026-10-15: both cards name the run they open (`job · run-N`), the number from the run list
1008
+ 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; };
1009
+ const gaveUp = gaveUp0.map(noOf);
1010
+ const scheduled = scheduled0.map(noOf);
1011
+ return { runs, jobs, scheduled, gaveUp, limits: RUNS_LIST_LIMITS };
834
1012
  }
835
1013
 
836
1014
  /**
@@ -870,6 +1048,51 @@ function findRunRow(runid, opts) {
870
1048
  return readRunList(opts).rows.find((r) => r && r.runid === runid);
871
1049
  }
872
1050
 
1051
+ /**
1052
+ * T Part 7 — `detail.schedule`: `{scheduled, canSchedule, words, text?, next?, nextRed?}` from the schedule door's ONE read of the
1053
+ * schedule files and ledger (A.12). `waitingRunNo` = the job's NEWEST run when it waits for the person's Accept: ONE spine parse,
1054
+ * never a run context per run. A job that cannot be read has no schedule block.
1055
+ * @param {string} job @param {any[]} rows the run list @param {Map<string, number>} nos @param {{ schedules?: ReturnType<typeof createScheduleDoor> }} opts
1056
+ */
1057
+ function scheduleDetailFor(job, rows, nos, opts) {
1058
+ try {
1059
+ const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
1060
+ let newest = null;
1061
+ for (const r of rows) if (r && r.job === job && (newest === null || atMs(r) >= atMs(newest))) newest = r;
1062
+ let waitingRunNo = null;
1063
+ if (newest && existsSync(newest.spine) && door.readSchedule(job) && !runIsAlive(newest)) {
1064
+ const records = parseJsonl(newest.spine).records;
1065
+ if (doorWaiting(records)) waitingRunNo = nos.get(newest.runid) ?? null;
1066
+ }
1067
+ return door.view(job, { waitingRunNo });
1068
+ } catch { return null; }
1069
+ }
1070
+
1071
+ /**
1072
+ * The record `trigger.json` a SCHEDULED start wrote beside its signed spec (why the run started), or null for every hand run.
1073
+ * @param {{ spine: string }} row @returns {any|null}
1074
+ */
1075
+ function triggerRecordOf(row) {
1076
+ const near = sourceNearSpine(row.spine);
1077
+ if (!near.specPath) return null;
1078
+ try { return JSON.parse(readFileSync(join(dirname(near.specPath), 'trigger.json'), 'utf8')); } catch { return null; }
1079
+ }
1080
+
1081
+ /**
1082
+ * T Part 8 — what the Accept route needs: the run's row, whether its runner is alive, whether it waits at its review door
1083
+ * ({@link doorWaiting}), and the signed spec it was judged by (the file and hash `run-u --door --approve` takes).
1084
+ * @param {string} runid @param {{ home?: string }} opts
1085
+ * @returns {{row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null}
1086
+ */
1087
+ export function getAcceptContext(runid, opts = {}) {
1088
+ const row = findRunRow(runid, opts);
1089
+ if (!row || row.via === 'import') return null;
1090
+ if (!existsSync(row.spine)) return { row, live: runIsAlive(row), waiting: false, specPath: null, specHash: null };
1091
+ const records = parseJsonl(row.spine).records;
1092
+ const signed = signedSpecForRun(row, records);
1093
+ return { row, live: runIsAlive(row), waiting: doorWaiting(records), specPath: signed ? signed.specPath : null, specHash: signed ? signed.specHash : null };
1094
+ }
1095
+
873
1096
  /**
874
1097
  * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
875
1098
  * iterations), counters, summary-box fields. Looks `runid` up in the run
@@ -877,19 +1100,87 @@ function findRunRow(runid, opts) {
877
1100
  * header); `null` when the runid is not in the list at all (caller renders
878
1101
  * 404).
879
1102
  * @param {string} runid
880
- * @param {{ home?: string }} [opts]
1103
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
881
1104
  * @returns {any|null}
882
1105
  */
883
1106
  export function getRunDetail(runid, opts = {}) {
884
1107
  const d = getRunDetailBody(runid, opts);
885
1108
  if (!d) return d;
886
- const runNo = runNumbers(readRunList(opts).rows).get(runid);
887
- return runNo === undefined ? d : { ...d, runNo };
1109
+ const { rows } = readRunList(opts);
1110
+ const nos = runNumbers(rows);
1111
+ const runNo = nos.get(runid);
1112
+ // 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)
1113
+ const schedule = opts.schedules && typeof d.job === 'string' ? scheduleDetailFor(d.job, rows, nos, opts) : null;
1114
+ const extra = opts.schedules && typeof d.job === 'string' ? scheduleExtrasFor(runid, d.job, rows, nos, opts, schedule) : {};
1115
+ return { ...d, ...(runNo === undefined ? {} : { runNo }), ...(schedule ? { schedule: extra.schedule ?? schedule } : {}), ...(extra.audit ?? {}) };
1116
+ }
1117
+
1118
+ /**
1119
+ * 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.
1120
+ * `schedule` = the door's view with `line2`, `problem` (and no raw ledger row); `audit` = `{scheduleSection}` or, for a run a cancelled schedule started, `{startedAudit}`.
1121
+ * @param {string} runid @param {string} job @param {any[]} rows @param {Map<string, number>} nos @param {any} opts @param {any} view
1122
+ * @returns {{schedule?: any, audit?: {startedAudit?: string, scheduleSection?: any}}}
1123
+ */
1124
+ function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
1125
+ try {
1126
+ const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
1127
+ /** @type {any} */
1128
+ let schedule;
1129
+ if (view) {
1130
+ const { lastRow, nextWords, accept, ...rest } = view;
1131
+ schedule = { ...rest, line2: null };
1132
+ if (view.scheduled) {
1133
+ let lastWords = null;
1134
+ if (lastRow) {
1135
+ if (lastRow.kind === 'started') {
1136
+ const mine = rows.filter((r) => r && r.job === job).sort((a, b) => atMs(b) - atMs(a)).slice(0, 25);
1137
+ let hit = null; let fallback = null;
1138
+ for (const r of mine) {
1139
+ const rec = triggerRecordOf(r);
1140
+ if (!rec) continue;
1141
+ if (fallback === null) fallback = { r, rec };
1142
+ if (rec.sessionId && rec.sessionId === lastRow.sessionId) { hit = { r, rec }; break; }
1143
+ }
1144
+ const got = hit ?? fallback;
1145
+ const no = got ? nos.get(got.r.runid) : undefined;
1146
+ const word = got ? summarizeRow(got.r).status?.word : undefined;
1147
+ lastWords = `last ${dateTimeWords(Number(lastRow.at))}${no ? ` · run-${no}` : ''}${word ? ` · ${word}` : ''}${got?.rec.catchUp === true ? ' · catch-up' : ''}`;
1148
+ } else {
1149
+ lastWords = `last ${dateTimeWords(Number(lastRow.at))} · ${skippedWords(lastRow, true)}`;
1150
+ }
1151
+ }
1152
+ schedule.line2 = [lastWords ?? 'no run yet', accept ? SCHEDULE_WORDS.acceptWaiting : null, nextWords].filter(Boolean).join(' · ');
1153
+ }
1154
+ }
1155
+ const row = rows.find((r) => r && r.runid === runid);
1156
+ const rec = row ? triggerRecordOf(row) : null;
1157
+ const cur = door.readSchedule(job);
1158
+ if (!cur) {
1159
+ // no schedule now: a run a cancelled schedule started keeps its one old line; a hand run has nothing
1160
+ const origin = rec ? (rec.originRunid ?? null) : null;
1161
+ return { ...(schedule ? { schedule } : {}), audit: rec ? { startedAudit: startedAuditWords(rec, origin ? (nos.get(origin) ?? null) : null) } : {} };
1162
+ }
1163
+ // 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
1164
+ const originNo = nos.get(cur.originRunid) ?? null;
1165
+ const tries = door.skippedFor(job);
1166
+ const running = rows.some((r) => r && r.job === job && r.via !== 'import' && runIsAlive(r));
1167
+ const { sign } = scheduleSignFor({ running, red: !!(view && view.problem) });
1168
+ const words = scheduledWords(cur.trigger, door.userHome);
1169
+ const head = [lowerFirst(words ?? ''), originNo === null ? null : `from run-${originNo}`, triesTailWords(tries.length) || null].filter(Boolean).join(' · ');
1170
+ const when = row ? dateTimeWords(atMs(row)) : '';
1171
+ const thisRun = rec ? sectionThisRunWords(rec) : `by hand.${runid === cur.originRunid ? ' The schedule was set from this run.' : ''}`;
1172
+ const shown = tries.slice(0, 7).map((x, i) => ({ label: i === 0 ? 'tries' : '', text: x.line }));
1173
+ const scheduleSection = {
1174
+ title: SCHEDULE_WORDS.scheduleGroup, sign, head,
1175
+ rows: [{ label: 'this run', text: `${when} ${thisRun}` }, ...(shown.length > 0 ? shown : [{ label: 'tries', text: 'none' }])],
1176
+ };
1177
+ return { ...(schedule ? { schedule } : {}), audit: { scheduleSection } };
1178
+ } catch { return {}; }
888
1179
  }
889
1180
 
890
1181
  /**
891
1182
  * @param {string} runid
892
- * @param {{ home?: string }} opts
1183
+ * @param {{ home?: string, now?: number }} opts
893
1184
  * @returns {any|null}
894
1185
  */
895
1186
  function getRunDetailBody(runid, opts) {
@@ -1081,6 +1372,12 @@ function getRunDetailBody(runid, opts) {
1081
1372
  // numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
1082
1373
  // whenever Resume is not offered.
1083
1374
  ended,
1375
+ // aligned with fwdloop M9 am7: `MM/DD HH:MM · <outcome word> · <reason>` (local end time); null while the run is live
1376
+ endedText: (() => {
1377
+ const ts = lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null);
1378
+ const ms = ts === null ? NaN : Date.parse(ts);
1379
+ return ended && Number.isFinite(ms) ? `${dateTimeWords(ms)} · ${status.word} · ${ended.reason}` : null;
1380
+ })(),
1084
1381
  // P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
1085
1382
  // (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
1086
1383
  live: row.via !== 'import' && runIsAlive(row),
@@ -1165,6 +1462,25 @@ function getRunDetailBody(runid, opts) {
1165
1462
  // `memory-cache` record at all (never armed on this run).
1166
1463
  behaviour: scopedBehaviour(row, rawSpineRecords),
1167
1464
  memoryCache: summary.memoryCache,
1465
+ // T Part 8: is this run waiting for the person's Accept? The SAME answer fills the runs list's Needs-you tag (`doorWaiting`, one owner)
1466
+ accept: !runIsAlive(row) && doorWaiting(rawSpineRecords),
1467
+ // T Part 7: why a SCHEDULED run started (the words `trigger.json` carried) and the fixed catch-up sentence; null on every hand run
1468
+ ...(() => {
1469
+ const trig = triggerRecordOf(row);
1470
+ return { startedBy: trig && typeof trig.reason === 'string' ? trig.reason : null, catchUp: catchUpWords(trig) };
1471
+ })(),
1472
+ // 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
1473
+ ...(() => {
1474
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
1475
+ const live = !death.died && (summary.outcome === null || summary.outcome === undefined) && runIsAlive(row);
1476
+ const stoppedAt = live ? null : (lastJobEndTs(rawSpineRecords) ?? (death.died ? lastRecordTs(rawSpineRecords) : null));
1477
+ const st = needsStateFor({ live, records: rawSpineRecords, resume, stoppedAt, nowMs, removedMs: readNeedsRemovals(opts).get(row.runid) });
1478
+ /** @type {string|null} */
1479
+ let needsAudit = null;
1480
+ if (st.removedMs !== null) needsAudit = `removed from Needs you by you · ${dateTimeWords(st.removedMs)}`;
1481
+ else if (st.expired) needsAudit = `removed from Needs you after ${RUNS_LIST_LIMITS.needsYouDays} days`;
1482
+ return { canRemoveFromNeeds: st.needs === 'Resume', needsAudit };
1483
+ })(),
1168
1484
  };
1169
1485
  }
1170
1486
 
@@ -1810,6 +2126,7 @@ function resolveSpecForRow(row) {
1810
2126
  * change any file?"
1811
2127
  * - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
1812
2128
  * casts or silencers added?"
2129
+ * - `rubric-step` (Part 1a) is one job line's output file: "line N — does its output pass the line's checks?"
1813
2130
  * - `command-exit` is the genre's `suite-green` stage (the only shipped use
1814
2131
  * of this kind): "does the test suite pass?"
1815
2132
  * - `count-not-worse` splits three ways on its own declared
@@ -1877,6 +2194,8 @@ function stageQuestionText({
1877
2194
  if (kind === 'files-changed') return 'did it change any file?';
1878
2195
  if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
1879
2196
  if (kind === 'command-exit') return 'does the test suite pass?';
2197
+ // Part 1a: one rubric-step stage = one job line's output file
2198
+ if (kind === 'rubric-step') return params && Number.isInteger(params.step) ? `line ${params.step} — does its output pass the line's checks?` : null;
1880
2199
  if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
1881
2200
  const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
1882
2201
  if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
@@ -1951,6 +2270,15 @@ function stageKindMetaFromSpec(spec) {
1951
2270
  return map;
1952
2271
  }
1953
2272
 
2273
+ /**
2274
+ * Stage name to the panel's plain question, off a signed spec — the SAME owner ({@link stageKindMetaFromSpec}) the step map and Audit use.
2275
+ * @param {any} spec
2276
+ * @returns {Map<string, string|null>}
2277
+ */
2278
+ function stageQuestionsOfSpec(spec) {
2279
+ return new Map([...(stageKindMetaFromSpec(spec) ?? new Map())].map(([n, m]) => [n, m.question]));
2280
+ }
2281
+
1954
2282
  /**
1955
2283
  * Attaches one stage's `{kind, direction, baselineKind, question}` (from
1956
2284
  * {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
@@ -2172,10 +2500,33 @@ function jobsDir() {
2172
2500
  * from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
2173
2501
  * carry no close/writeScope/tools at all, so those stay `'not recorded'`.
2174
2502
  * @param {string} runid
2175
- * @param {{ home?: string }} [opts]
2503
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
2176
2504
  * @returns {any|null}
2177
2505
  */
2178
2506
  export function getRunJob(runid, opts = {}) {
2507
+ const body = getRunJobBody(runid, opts);
2508
+ if (!body || !opts.schedules) return body;
2509
+ try {
2510
+ const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
2511
+ const { rows } = readRunList(opts);
2512
+ const row = rows.find((r) => r && r.runid === runid);
2513
+ const job = row ? row.job : body.job;
2514
+ const nos = runNumbers(rows);
2515
+ const cur = door.readSchedule(job);
2516
+ const started = startedWords(row ? triggerRecordOf(row) : null, row ? atMs(row) : null);
2517
+ const spec = cur ? cur.spec : null;
2518
+ const schedule = jobScheduleWords({
2519
+ trigger: cur ? cur.trigger : null, originNo: cur ? (nos.get(cur.originRunid) ?? null) : null,
2520
+ model: spec && typeof spec.model === 'string' ? spec.model : null,
2521
+ destination: cur ? (typeof cur.destination === 'string' && cur.destination !== '' ? cur.destination : (Array.isArray(spec?.writeScope) ? spec.writeScope.join(', ') : null)) : null,
2522
+ home: door.userHome,
2523
+ });
2524
+ return { ...body, started, schedule };
2525
+ } catch { return body; }
2526
+ }
2527
+
2528
+ /** @param {string} runid @param {{ home?: string }} opts */
2529
+ function getRunJobBody(runid, opts) {
2179
2530
  const row = findRunRow(runid, opts);
2180
2531
  if (!row) return null;
2181
2532
 
@@ -2484,14 +2835,14 @@ export function reuseLine(record) {
2484
2835
 
2485
2836
  /**
2486
2837
  * `start-from` (the route keeps its name) for one listed run: the card prefilled from the run's SIGNED job, which
2487
- * boxes are locked and which are open, and the track record. Locked boxes carry the signed values, read by the
2838
+ * the track record (v1.101: nothing is locked, every box is open). The job boxes carry the signed values, read by the
2488
2839
  * SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
2489
2840
  * $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
2490
2841
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
2491
2842
  * @param {string} runid
2492
2843
  * @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
2493
2844
  * @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
2494
- * locked: readonly string[], open: readonly string[], note: string|null,
2845
+ * note: string|null,
2495
2846
  * specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
2496
2847
  * specWorker: {provider: string, endpoint: string, label: string},
2497
2848
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -2524,6 +2875,9 @@ export function getStartFrom(runid, opts = {}) {
2524
2875
  source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
2525
2876
  // a card saved by P7 or later keeps the Inputs text the person typed
2526
2877
  inputs: typeof saved.inputs === 'string' && saved.inputs ? saved.inputs : '',
2878
+ // a plain-files job delivers to an absolute folder the SPEC does not hold (its fence is `output/` inside the run's tree): the
2879
+ // Destination opens with the folder the origin run was given, never with that fence
2880
+ destination: [saved.destination, manifest?.destination].find((d) => typeof d === 'string' && /^(\/|[a-zA-Z]:[\\/])/.test(d)) ?? '',
2527
2881
  jobName: row.job,
2528
2882
  });
2529
2883
  const key = workflowKey(spec);
@@ -2531,12 +2885,14 @@ export function getStartFrom(runid, opts = {}) {
2531
2885
  return {
2532
2886
  ok: true,
2533
2887
  origin: { runid, job: row.job },
2534
- // the run's terminal outcome — the Reuse start (POST /api/author/start) refuses anything but a real green
2535
- outcome: legsOf(records).at(-1)?.outcome ?? null,
2888
+ // the run's terminal outcome — the Reuse start (POST /api/author/start) reuses a green or already-green run only.
2889
+ // T D.2: a SCHEDULED run's green reads `green-held` until the person accepts it, so that same refusal holds it unchanged
2890
+ outcome: (() => {
2891
+ const o = legsOf(records).at(-1)?.outcome ?? null;
2892
+ return o === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
2893
+ })(),
2536
2894
  card,
2537
2895
  from: 'signed job',
2538
- locked: REUSE_LOCKED_FIELDS,
2539
- open: REUSE_OPEN_FIELDS,
2540
2896
  note: Array.isArray(spec.jobLines) && spec.jobLines.length ? null : 'older job — numbered lines were not saved',
2541
2897
  specHash: signed.specHash,
2542
2898
  workflowKey: key,
@@ -2555,6 +2911,29 @@ export function getStartFrom(runid, opts = {}) {
2555
2911
  };
2556
2912
  }
2557
2913
 
2914
+ /**
2915
+ * T Part 3: a job's listed runs, newest first, each with its signed spec (null when not on disk), its terminal outcome and
2916
+ * its parsed spine. The schedule door's gate (`greenFor`) reads this; nothing here builds a run context.
2917
+ * @param {string} job
2918
+ * @param {{ home?: string }} [opts]
2919
+ * @returns {{runid: string, outcome: string|null, spec: any, records: any[]}[]}
2920
+ */
2921
+ export function jobRunsOf(job, opts = {}) {
2922
+ const { rows } = readRunList(opts);
2923
+ /** @type {{runid: string, outcome: string|null, spec: any, records: any[]}[]} */
2924
+ const out = [];
2925
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
2926
+ const row = rows[i];
2927
+ if (!row || row.job !== job || !existsSync(row.spine)) continue;
2928
+ let records;
2929
+ try { records = parseJsonl(row.spine).records; } catch { continue; }
2930
+ const outcome = legsOf(records).at(-1)?.outcome ?? null;
2931
+ // T D.2: a scheduled run's green is HELD until accepted, so it is not a good run for the schedule gate either
2932
+ out.push({ runid: row.runid, outcome: outcome === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : outcome, spec: signedSpecForRun(row, records)?.spec ?? null, records });
2933
+ }
2934
+ return out;
2935
+ }
2936
+
2558
2937
  /**
2559
2938
  * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
2560
2939
  * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
@@ -2574,9 +2953,9 @@ export function listReuseJobs(opts = {}) {
2574
2953
  let records;
2575
2954
  try { records = parseJsonl(row.spine).records; } catch { continue; }
2576
2955
  const outcome = legsOf(records).at(-1)?.outcome ?? null;
2577
- // only a REAL green (hamr 2026-10-08): `already-green` means the check passed before any work, so there is nothing to reuse
2578
- // (src/reuse.js 'green-predates-run'). Narrower than the Run tab's Reuse button on purpose.
2579
- if (outcome !== 'green') continue;
2956
+ // 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)
2957
+ if (outcome !== 'green' && outcome !== 'already-green') continue;
2958
+ if (heldScheduled(records, triggerRecordOf(row))) continue; // T D.2: a scheduled green waits for the person's Accept
2580
2959
  const signed = signedSpecForRun(row, records);
2581
2960
  if (!signed) continue;
2582
2961
  const key = workflowKey(signed.spec);
@@ -2593,7 +2972,7 @@ export function listReuseJobs(opts = {}) {
2593
2972
  * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2594
2973
  * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
2595
2974
  * @param {any} spec
2596
- * @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>`)
2975
+ * @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>`)
2597
2976
  * @returns {Record<string, any>}
2598
2977
  */
2599
2978
  function reuseCardFromSpec(spec, extra) {
@@ -2603,8 +2982,9 @@ function reuseCardFromSpec(spec, extra) {
2603
2982
  checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
2604
2983
  model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
2605
2984
  jobText: jobTextFromSpec(spec),
2985
+ thinking: spec.thinking === true,
2606
2986
  inputs: extra.inputs || inputsTextFromSpec(spec, extra.source),
2607
- destination: writeScope.join(', '),
2987
+ destination: extra.destination || writeScope.join(', '),
2608
2988
  capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2609
2989
  maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
2610
2990
  };
@@ -2643,7 +3023,7 @@ function describeSpec(spec) {
2643
3023
  * @param {string} id
2644
3024
  * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
2645
3025
  * @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
2646
- * locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
3026
+ * note: string|null, specHash: string, workflowKey: string, spec: any,
2647
3027
  * specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2648
3028
  */
2649
3029
  export function getStartFromImport(id, opts = {}) {
@@ -2669,8 +3049,6 @@ export function getStartFromImport(id, opts = {}) {
2669
3049
  origin: { runid: null, job: row.job, importId: id },
2670
3050
  card,
2671
3051
  from: 'imported job',
2672
- locked: REUSE_LOCKED_FIELDS,
2673
- open: REUSE_OPEN_FIELDS,
2674
3052
  note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
2675
3053
  specHash: jobSpecHash(spec),
2676
3054
  workflowKey: key,
@@ -2710,7 +3088,7 @@ function sendText(res, code, text) {
2710
3088
  * only `createPanelServer` below always supplies one.
2711
3089
  * @param {import('node:http').IncomingMessage} req
2712
3090
  * @param {import('node:http').ServerResponse} res
2713
- * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes> }} opts
3091
+ * @param {{ home?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
2714
3092
  */
2715
3093
  export function handleRequest(req, res, opts) {
2716
3094
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -2719,7 +3097,7 @@ export function handleRequest(req, res, opts) {
2719
3097
  function routesFor(o, p) {
2720
3098
  if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
2721
3099
  if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
2722
- if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop)$/.test(p)) return o.runRoutes;
3100
+ if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|needs-remove)$/.test(p)) return o.runRoutes;
2723
3101
  if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
2724
3102
  return null;
2725
3103
  }
@@ -2806,6 +3184,9 @@ export function handleRequest(req, res, opts) {
2806
3184
  return;
2807
3185
  }
2808
3186
  html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
3187
+ // the topbar reads `bareloop · v<version> · ...`: the version is bareloop's OWN package.json, read when the page is served
3188
+ // and templated in exactly like the port — never written into the HTML
3189
+ html = html.replace(/__BARELOOP_PANEL_VERSION__/g, packageVersion(opts.packageJsonPath));
2809
3190
  // PANEL-BUILD.md P3's human-click guard: a per-server-start token,
2810
3191
  // templated into the page exactly like the port — absent on every P1/P2
2811
3192
  // caller (no `opts.token`), which is fine: the page's own JS only reads
@@ -2821,7 +3202,7 @@ export function handleRequest(req, res, opts) {
2821
3202
  return;
2822
3203
  }
2823
3204
 
2824
- if (pathname === '/api/runs') { send(200, { runs: listRuns({ home: opts.home }) }); return; }
3205
+ if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
2825
3206
 
2826
3207
  const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
2827
3208
  if (runMatch) {
@@ -2840,7 +3221,7 @@ export function handleRequest(req, res, opts) {
2840
3221
  return;
2841
3222
  }
2842
3223
  if (sub === 'job') {
2843
- const result = getRunJob(runid, { home: opts.home });
3224
+ const result = getRunJob(runid, { home: opts.home, schedules: opts.schedules });
2844
3225
  if (!result) { sendText(res, 404, 'no such run'); return; }
2845
3226
  send(200, result);
2846
3227
  return;
@@ -2859,7 +3240,7 @@ export function handleRequest(req, res, opts) {
2859
3240
  send(200, result);
2860
3241
  return;
2861
3242
  }
2862
- const result = getRunDetail(runid, { home: opts.home });
3243
+ const result = getRunDetail(runid, { home: opts.home, schedules: opts.schedules });
2863
3244
  if (!result) { sendText(res, 404, 'no such run'); return; }
2864
3245
  send(200, result);
2865
3246
  return;
@@ -2887,8 +3268,9 @@ export function handleRequest(req, res, opts) {
2887
3268
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
2888
3269
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
2889
3270
  * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
2890
- * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
2891
- * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
3271
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string,
3272
+ * 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)
3273
+ * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, schedules: ReturnType<typeof createScheduleDoor>, scheduler: ReturnType<typeof createScheduler>, close: () => Promise<void> }>}
2892
3274
  */
2893
3275
  export function createPanelServer(opts = {}) {
2894
3276
  const requestedPort = opts.port ?? DEFAULT_PORT;
@@ -2909,10 +3291,14 @@ export function createPanelServer(opts = {}) {
2909
3291
  let runRoutes;
2910
3292
  /** @type {ReturnType<typeof createImportRoutes>|undefined} */
2911
3293
  let importRoutes;
3294
+ /** @type {ReturnType<typeof createScheduler>|undefined} */
3295
+ let scheduler;
3296
+ /** @type {ReturnType<typeof createScheduleDoor>|undefined} */
3297
+ let schedulesRef;
2912
3298
  const server = createServer((req, res) => {
2913
3299
  try {
2914
3300
  handleRequest(req, res, {
2915
- home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes,
3301
+ home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
2916
3302
  });
2917
3303
  } catch (e) {
2918
3304
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
@@ -2926,7 +3312,22 @@ export function createPanelServer(opts = {}) {
2926
3312
  server.listen(requestedPort, '127.0.0.1', () => {
2927
3313
  const addr = server.address();
2928
3314
  boundPort = typeof addr === 'object' && addr !== null ? addr.port : requestedPort;
3315
+ const rowsFor = () => rowsForHome(keysHome(home), keysForDoor({ env: opts.env, keysHome: home }).env);
3316
+ const schedules = createScheduleDoor({
3317
+ home,
3318
+ ...(opts.userHome ? { userHome: opts.userHome } : {}),
3319
+ ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}),
3320
+ sessionsRoot: opts.sessionsRoot,
3321
+ jobRuns: (job) => jobRunsOf(job, { home }),
3322
+ startFrom: (runid) => getStartFrom(runid, { home }),
3323
+ jobOfRun: (runid) => readRunList({ home }).rows.find((r) => r && r.runid === runid)?.job ?? null,
3324
+ rowsFor,
3325
+ });
3326
+ schedulesRef = schedules;
2929
3327
  authorRoutes = createAuthorRoutes({
3328
+ schedules,
3329
+ runJobNames: () => readRunList({ home }).rows.flatMap((r) => (r && typeof r.job === 'string' ? [r.job] : [])),
3330
+ ...(opts.sessionDeps ? { sessionDeps: opts.sessionDeps } : {}),
2930
3331
  port: boundPort,
2931
3332
  token,
2932
3333
  env: opts.env,
@@ -2941,6 +3342,18 @@ export function createPanelServer(opts = {}) {
2941
3342
  listJobs: (o = {}) => listReuseJobs({ home, ...o }),
2942
3343
  },
2943
3344
  });
3345
+ schedules.attachStarter(createStarter({
3346
+ door: schedules, startSession: authorRoutes.startSession, envNow: authorRoutes.envNow, spawnFn: authorRoutes.spawnFn,
3347
+ bareloopBin: authorRoutes.bareloopBin, sessionsRoot: opts.sessionsRoot, home, rowsFor, startFrom: (runid) => getStartFrom(runid, { home }),
3348
+ forgetSession: authorRoutes.forgetHeadless,
3349
+ ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), // the SAME clock the scheduler reads, so lateMs and startedAt agree with the slot
3350
+ ranSince: (job, sinceMs) => readRunList({ home }).rows.some((r) => r && r.job === job && Date.parse(r.at) >= sinceMs - 1000),
3351
+ }));
3352
+ // T Part 5: the scheduler runs the clock and the folder watch while THIS server is up (panel only, T6); stopped on close
3353
+ scheduler = createScheduler({
3354
+ door: schedules, ...(opts.scheduleNow ? { now: opts.scheduleNow } : {}), ...(opts.schedulerIntervalMs !== undefined ? { intervalMs: opts.schedulerIntervalMs } : {}),
3355
+ });
3356
+ scheduler.start();
2944
3357
  settingsRoutes = createSettingsRoutes({
2945
3358
  port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
2946
3359
  openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
@@ -2955,6 +3368,10 @@ export function createPanelServer(opts = {}) {
2955
3368
  settleMs: opts.settleMs,
2956
3369
  getResumeContext: (runid) => getResumeContext(runid, { home }),
2957
3370
  getStopContext: (runid) => getStopContext(runid, { home }),
3371
+ getAcceptContext: (runid) => getAcceptContext(runid, { home }),
3372
+ stageQuestionsOfSpec,
3373
+ getNeedsContext: (runid) => getNeedsContext(runid, { home }),
3374
+ ...(opts.acceptWaitMs !== undefined ? { acceptWaitMs: opts.acceptWaitMs } : {}),
2958
3375
  });
2959
3376
  importRoutes = createImportRoutes({
2960
3377
  port: boundPort, token, home, userHome: opts.userHome, describeSpec,
@@ -2963,7 +3380,9 @@ export function createPanelServer(opts = {}) {
2963
3380
  server,
2964
3381
  port: boundPort,
2965
3382
  token,
2966
- close: () => new Promise((res2) => { server.close(() => res2(undefined)); }),
3383
+ schedules,
3384
+ scheduler: /** @type {ReturnType<typeof createScheduler>} */ (scheduler),
3385
+ close: () => new Promise((res2) => { scheduler?.stop(); server.close(() => res2(undefined)); }),
2967
3386
  });
2968
3387
  });
2969
3388
  });