bareloop 0.42.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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bareloop.context.md +53 -30
  3. package/package.json +1 -1
  4. package/src/authorjob.js +6 -0
  5. package/src/declaredclose.js +2 -2
  6. package/src/interviewrun.js +0 -1
  7. package/src/job.js +38 -11
  8. package/src/panel/authorroutes.js +107 -33
  9. package/src/panel/authorsession.js +59 -27
  10. package/src/panel/index.html +813 -484
  11. package/src/panel/runroutes.js +163 -5
  12. package/src/panel/scheduler.js +156 -0
  13. package/src/panel/schedules.js +667 -0
  14. package/src/panel/schedulestart.js +243 -0
  15. package/src/panel/server.js +451 -48
  16. package/src/planrun.js +39 -7
  17. package/src/providerrows.js +3 -3
  18. package/src/reviewdoor.js +37 -0
  19. package/src/runlist.js +35 -0
  20. package/src/schedulewords.js +296 -0
  21. package/src/trigger.js +48 -0
  22. package/src/triggerclock.js +127 -0
  23. package/src/triggerfiles.js +215 -0
  24. package/src/triggerledger.js +79 -0
  25. package/src/userrun.js +6 -7
  26. package/types/declaredclose.d.ts +1 -1
  27. package/types/job.d.ts +8 -1
  28. package/types/panel/authorroutes.d.ts +34 -1
  29. package/types/panel/authorsession.d.ts +37 -12
  30. package/types/panel/runroutes.d.ts +32 -1
  31. package/types/panel/scheduler.d.ts +17 -0
  32. package/types/panel/schedules.d.ts +412 -0
  33. package/types/panel/schedulestart.d.ts +106 -0
  34. package/types/panel/server.d.ts +143 -14
  35. package/types/planrun.d.ts +2 -0
  36. package/types/providerrows.d.ts +2 -2
  37. package/types/reviewdoor.d.ts +27 -0
  38. package/types/runlist.d.ts +20 -0
  39. package/types/schedulewords.d.ts +135 -0
  40. package/types/trigger.d.ts +37 -0
  41. package/types/triggerclock.d.ts +52 -0
  42. package/types/triggerfiles.d.ts +93 -0
  43. package/types/triggerledger.d.ts +35 -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';
@@ -420,7 +426,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
420
426
  *
421
427
  * The table (outcome → reason / next / buttons) is PANEL-BUILD.md P5 item 1. The
422
428
  * 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.
429
+ * (replaces P5 item 3, 2026-10-03; one door on every ending since 2026-10-15) starts a NEW run.
424
430
  * @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
425
431
  * @param {{died: boolean, lastThing: string|null}} death
426
432
  * @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
@@ -429,7 +435,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
429
435
  * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
430
436
  */
431
437
  export function endedFor(summary, death, o = {}) {
432
- const base = withEditAction(endedBase(summary, death, o));
438
+ const base = withReuseAction(endedBase(summary, death, o));
433
439
  const wt = o.worktree;
434
440
  if (!base || !wt) return base;
435
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
@@ -445,13 +451,13 @@ export function endedFor(summary, death, o = {}) {
445
451
  }
446
452
 
447
453
  /**
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.
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.
450
456
  * @param {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null} ended
451
457
  */
452
- function withEditAction(ended) {
458
+ function withReuseAction(ended) {
453
459
  if (!ended || ended.actions.some((a) => a.id === 'reuse')) return ended;
454
- return { ...ended, actions: [...ended.actions, { id: 'edit', label: 'Edit in chat' }] };
460
+ return { ...ended, actions: [...ended.actions, { id: 'reuse', label: 'Reuse workflow' }] };
455
461
  }
456
462
 
457
463
  /** @param {any} summary @param {any} death @param {any} o */
@@ -460,9 +466,9 @@ function endedBase(summary, death, o) {
460
466
  /** @type {{id: string, label: string}[]} */
461
467
  const RESUME = [{ id: 'resume', label: 'Resume' }];
462
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
463
- * 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. */
464
470
  const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
465
- const CHANGE = 'Change the job: press Edit in chat.';
471
+ const CHANGE = 'Change the job: press Reuse workflow.';
466
472
  const detailOf = (/** @type {string|null} */ s) => {
467
473
  if (typeof s !== 'string' || s.length === 0) return '';
468
474
  return s.length > 120 ? `${s.slice(0, 120)}…` : s;
@@ -519,8 +525,8 @@ function endedBase(summary, death, o) {
519
525
 
520
526
  if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
521
527
  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
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
524
530
  const earned = outcome === 'green';
525
531
  return {
526
532
  reason: 'Goal met, but the output could not be delivered.',
@@ -530,7 +536,7 @@ function endedBase(summary, death, o) {
530
536
  };
531
537
  }
532
538
  // 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
539
+ // earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
534
540
  // close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
535
541
  if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
536
542
  return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
@@ -574,11 +580,21 @@ function endedBase(summary, death, o) {
574
580
  if (outcome === 'step-stalled') {
575
581
  return {
576
582
  reason: 'A step stopped making progress.',
577
- next: resumeOr('Resume, or change the job: press Edit in chat.'),
583
+ next: resumeOr('Resume, or change the job: press Reuse workflow.'),
578
584
  line: resumeOk ? 'step stalled — resume' : 'step stalled',
579
585
  actions: resumeOk ? RESUME : [],
580
586
  };
581
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
+ }
582
598
  if (GOAL_NOT_MET.has(outcome)) {
583
599
  const d = detailOf(summary.stopReason);
584
600
  return {
@@ -624,6 +640,29 @@ function lastJobEndTs(/** @type {any[]} */ records) {
624
640
  return null;
625
641
  }
626
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
+
627
666
  function endedForRow(row, records, summary, death, withWorktree = false) {
628
667
  const out = summary.outcome;
629
668
  const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
@@ -659,6 +698,12 @@ function startingStub(row) {
659
698
  via: row.via,
660
699
  fileMissing: false,
661
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,
662
707
  died: false,
663
708
  glyph: '▶',
664
709
  status: statusFor({ outcome: null }),
@@ -684,19 +729,38 @@ function startingStub(row) {
684
729
  };
685
730
  }
686
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
+
687
750
  /**
688
751
  * One `/api/runs` row, or `{ ...row, fileMissing: true }` when the row's
689
752
  * spine no longer exists on disk — never silently listed as if it were
690
753
  * still there (the same rule {@link import('../runlist.js').formatRunRow}
691
754
  * already applies to the CLI's own listing).
692
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
693
757
  * @returns {any}
694
758
  */
695
- function summarizeRow(row) {
759
+ function summarizeRow(row, nowMs = Date.now(), removals = undefined) {
696
760
  if (!existsSync(row.spine)) {
697
761
  if (runIsAlive(row)) return startingStub(row);
698
762
  return {
699
- 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,
700
764
  };
701
765
  }
702
766
  let summary;
@@ -707,17 +771,33 @@ function summarizeRow(row) {
707
771
  } catch (e) {
708
772
  return {
709
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,
710
775
  };
711
776
  }
712
777
  const line = summarizeForAllLine(summary);
713
778
  const death = deriveDeath(row, rawRecords, summary.outcome);
714
- 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;
715
786
  return {
716
787
  runid: row.runid,
717
788
  job: row.job,
718
789
  at: row.at,
719
790
  via: row.via,
720
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,
721
801
  died: death.died,
722
802
  endedLine: ended ? ended.line : null,
723
803
  // 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 +864,21 @@ export function getStopContext(runid, opts = {}) {
784
864
  return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
785
865
  }
786
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
+
787
882
  /**
788
883
  * @param {any} r
789
884
  * @returns {number}
@@ -818,6 +913,57 @@ function runNumbers(rows) {
818
913
  return out;
819
914
  }
820
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
+
821
967
  /**
822
968
  * `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
823
969
  * append order — a backfill scan appends rows in sorted-PATH order, which
@@ -831,16 +977,38 @@ function runNumbers(rows) {
831
977
  * SAME payload by job client-side (`groupRunsByJob` in `index.html`) rather
832
978
  * than a second server-side endpoint, so the glyph/checkType mapping is
833
979
  * still computed in exactly one place.
834
- * @param {{ home?: string }} [opts]
980
+ * @param {{ home?: string, now?: number }} [opts] `now` (ms) is injectable for the Needs-you age rule
835
981
  * @returns {any[]}
836
982
  */
837
983
  export function listRuns(opts = {}) {
838
984
  const { rows } = readRunList(opts);
985
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
839
986
  const nos = runNumbers(rows);
987
+ const removals = readNeedsRemovals(opts);
840
988
  return rows
841
989
  .map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
842
990
  .sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
843
- .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 };
844
1012
  }
845
1013
 
846
1014
  /**
@@ -880,6 +1048,51 @@ function findRunRow(runid, opts) {
880
1048
  return readRunList(opts).rows.find((r) => r && r.runid === runid);
881
1049
  }
882
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
+
883
1096
  /**
884
1097
  * `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
885
1098
  * iterations), counters, summary-box fields. Looks `runid` up in the run
@@ -887,19 +1100,87 @@ function findRunRow(runid, opts) {
887
1100
  * header); `null` when the runid is not in the list at all (caller renders
888
1101
  * 404).
889
1102
  * @param {string} runid
890
- * @param {{ home?: string }} [opts]
1103
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
891
1104
  * @returns {any|null}
892
1105
  */
893
1106
  export function getRunDetail(runid, opts = {}) {
894
1107
  const d = getRunDetailBody(runid, opts);
895
1108
  if (!d) return d;
896
- const runNo = runNumbers(readRunList(opts).rows).get(runid);
897
- 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 {}; }
898
1179
  }
899
1180
 
900
1181
  /**
901
1182
  * @param {string} runid
902
- * @param {{ home?: string }} opts
1183
+ * @param {{ home?: string, now?: number }} opts
903
1184
  * @returns {any|null}
904
1185
  */
905
1186
  function getRunDetailBody(runid, opts) {
@@ -1091,6 +1372,12 @@ function getRunDetailBody(runid, opts) {
1091
1372
  // numbers the Resume confirm box prefills (the signed caps, spent so far); `null`
1092
1373
  // whenever Resume is not offered.
1093
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
+ })(),
1094
1381
  // P5 item 5: is the run's LATEST leg live (the page offers Stop only then), and has a stop been asked for
1095
1382
  // (the request file exists beside the spine) — the page reads "stopping after this turn…" until the leg ends
1096
1383
  live: row.via !== 'import' && runIsAlive(row),
@@ -1175,6 +1462,25 @@ function getRunDetailBody(runid, opts) {
1175
1462
  // `memory-cache` record at all (never armed on this run).
1176
1463
  behaviour: scopedBehaviour(row, rawSpineRecords),
1177
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
+ })(),
1178
1484
  };
1179
1485
  }
1180
1486
 
@@ -1964,6 +2270,15 @@ function stageKindMetaFromSpec(spec) {
1964
2270
  return map;
1965
2271
  }
1966
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
+
1967
2282
  /**
1968
2283
  * Attaches one stage's `{kind, direction, baselineKind, question}` (from
1969
2284
  * {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
@@ -2185,10 +2500,33 @@ function jobsDir() {
2185
2500
  * from the resolved SPEC only (a) (b) (c) — a job-start record and `none()`
2186
2501
  * carry no close/writeScope/tools at all, so those stay `'not recorded'`.
2187
2502
  * @param {string} runid
2188
- * @param {{ home?: string }} [opts]
2503
+ * @param {{ home?: string, schedules?: ReturnType<typeof createScheduleDoor> }} [opts]
2189
2504
  * @returns {any|null}
2190
2505
  */
2191
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) {
2192
2530
  const row = findRunRow(runid, opts);
2193
2531
  if (!row) return null;
2194
2532
 
@@ -2497,14 +2835,14 @@ export function reuseLine(record) {
2497
2835
 
2498
2836
  /**
2499
2837
  * `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
2838
+ * the track record (v1.101: nothing is locked, every box is open). The job boxes carry the signed values, read by the
2501
2839
  * SAME readers the Job tab uses (`successFromSpec`/`guardrailsFromSpec`); the open four — Source, Destination, the
2502
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
2503
2841
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
2504
2842
  * @param {string} runid
2505
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
2506
2844
  * @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,
2845
+ * note: string|null,
2508
2846
  * specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
2509
2847
  * specWorker: {provider: string, endpoint: string, label: string},
2510
2848
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -2537,6 +2875,9 @@ export function getStartFrom(runid, opts = {}) {
2537
2875
  source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
2538
2876
  // a card saved by P7 or later keeps the Inputs text the person typed
2539
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)) ?? '',
2540
2881
  jobName: row.job,
2541
2882
  });
2542
2883
  const key = workflowKey(spec);
@@ -2544,12 +2885,14 @@ export function getStartFrom(runid, opts = {}) {
2544
2885
  return {
2545
2886
  ok: true,
2546
2887
  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,
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
+ })(),
2549
2894
  card,
2550
2895
  from: 'signed job',
2551
- locked: REUSE_LOCKED_FIELDS,
2552
- open: REUSE_OPEN_FIELDS,
2553
2896
  note: Array.isArray(spec.jobLines) && spec.jobLines.length ? null : 'older job — numbered lines were not saved',
2554
2897
  specHash: signed.specHash,
2555
2898
  workflowKey: key,
@@ -2568,6 +2911,29 @@ export function getStartFrom(runid, opts = {}) {
2568
2911
  };
2569
2912
  }
2570
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
+
2571
2937
  /**
2572
2938
  * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
2573
2939
  * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
@@ -2587,9 +2953,9 @@ export function listReuseJobs(opts = {}) {
2587
2953
  let records;
2588
2954
  try { records = parseJsonl(row.spine).records; } catch { continue; }
2589
2955
  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;
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
2593
2959
  const signed = signedSpecForRun(row, records);
2594
2960
  if (!signed) continue;
2595
2961
  const key = workflowKey(signed.spec);
@@ -2606,7 +2972,7 @@ export function listReuseJobs(opts = {}) {
2606
2972
  * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2607
2973
  * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
2608
2974
  * @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>`)
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>`)
2610
2976
  * @returns {Record<string, any>}
2611
2977
  */
2612
2978
  function reuseCardFromSpec(spec, extra) {
@@ -2616,8 +2982,9 @@ function reuseCardFromSpec(spec, extra) {
2616
2982
  checkType: spec.verdictType === 'soft-green' ? 'rubric' : 'deterministic',
2617
2983
  model: typeof spec.model === 'string' && spec.model && !extra.model ? spec.model : extra.model,
2618
2984
  jobText: jobTextFromSpec(spec),
2985
+ thinking: spec.thinking === true,
2619
2986
  inputs: extra.inputs || inputsTextFromSpec(spec, extra.source),
2620
- destination: writeScope.join(', '),
2987
+ destination: extra.destination || writeScope.join(', '),
2621
2988
  capUsd: typeof spec.budgetUsd === 'number' ? spec.budgetUsd : null,
2622
2989
  maxWallMs: typeof spec.maxWallMs === 'number' ? spec.maxWallMs : undefined,
2623
2990
  };
@@ -2656,7 +3023,7 @@ function describeSpec(spec) {
2656
3023
  * @param {string} id
2657
3024
  * @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
2658
3025
  * @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,
3026
+ * note: string|null, specHash: string, workflowKey: string, spec: any,
2660
3027
  * specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
2661
3028
  */
2662
3029
  export function getStartFromImport(id, opts = {}) {
@@ -2682,8 +3049,6 @@ export function getStartFromImport(id, opts = {}) {
2682
3049
  origin: { runid: null, job: row.job, importId: id },
2683
3050
  card,
2684
3051
  from: 'imported job',
2685
- locked: REUSE_LOCKED_FIELDS,
2686
- open: REUSE_OPEN_FIELDS,
2687
3052
  note: 'a Source is needed — an exported job carries none; it is checked and signed on this machine',
2688
3053
  specHash: jobSpecHash(spec),
2689
3054
  workflowKey: key,
@@ -2723,7 +3088,7 @@ function sendText(res, code, text) {
2723
3088
  * only `createPanelServer` below always supplies one.
2724
3089
  * @param {import('node:http').IncomingMessage} req
2725
3090
  * @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
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
2727
3092
  */
2728
3093
  export function handleRequest(req, res, opts) {
2729
3094
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -2732,7 +3097,7 @@ export function handleRequest(req, res, opts) {
2732
3097
  function routesFor(o, p) {
2733
3098
  if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
2734
3099
  if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
2735
- 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;
2736
3101
  if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
2737
3102
  return null;
2738
3103
  }
@@ -2837,7 +3202,7 @@ export function handleRequest(req, res, opts) {
2837
3202
  return;
2838
3203
  }
2839
3204
 
2840
- 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; }
2841
3206
 
2842
3207
  const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
2843
3208
  if (runMatch) {
@@ -2856,7 +3221,7 @@ export function handleRequest(req, res, opts) {
2856
3221
  return;
2857
3222
  }
2858
3223
  if (sub === 'job') {
2859
- const result = getRunJob(runid, { home: opts.home });
3224
+ const result = getRunJob(runid, { home: opts.home, schedules: opts.schedules });
2860
3225
  if (!result) { sendText(res, 404, 'no such run'); return; }
2861
3226
  send(200, result);
2862
3227
  return;
@@ -2875,7 +3240,7 @@ export function handleRequest(req, res, opts) {
2875
3240
  send(200, result);
2876
3241
  return;
2877
3242
  }
2878
- const result = getRunDetail(runid, { home: opts.home });
3243
+ const result = getRunDetail(runid, { home: opts.home, schedules: opts.schedules });
2879
3244
  if (!result) { sendText(res, 404, 'no such run'); return; }
2880
3245
  send(200, result);
2881
3246
  return;
@@ -2903,8 +3268,9 @@ export function handleRequest(req, res, opts) {
2903
3268
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
2904
3269
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
2905
3270
  * 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> }>}
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> }>}
2908
3274
  */
2909
3275
  export function createPanelServer(opts = {}) {
2910
3276
  const requestedPort = opts.port ?? DEFAULT_PORT;
@@ -2925,10 +3291,14 @@ export function createPanelServer(opts = {}) {
2925
3291
  let runRoutes;
2926
3292
  /** @type {ReturnType<typeof createImportRoutes>|undefined} */
2927
3293
  let importRoutes;
3294
+ /** @type {ReturnType<typeof createScheduler>|undefined} */
3295
+ let scheduler;
3296
+ /** @type {ReturnType<typeof createScheduleDoor>|undefined} */
3297
+ let schedulesRef;
2928
3298
  const server = createServer((req, res) => {
2929
3299
  try {
2930
3300
  handleRequest(req, res, {
2931
- home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath,
3301
+ home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
2932
3302
  });
2933
3303
  } catch (e) {
2934
3304
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
@@ -2942,7 +3312,22 @@ export function createPanelServer(opts = {}) {
2942
3312
  server.listen(requestedPort, '127.0.0.1', () => {
2943
3313
  const addr = server.address();
2944
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;
2945
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 } : {}),
2946
3331
  port: boundPort,
2947
3332
  token,
2948
3333
  env: opts.env,
@@ -2957,6 +3342,18 @@ export function createPanelServer(opts = {}) {
2957
3342
  listJobs: (o = {}) => listReuseJobs({ home, ...o }),
2958
3343
  },
2959
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();
2960
3357
  settingsRoutes = createSettingsRoutes({
2961
3358
  port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
2962
3359
  openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
@@ -2971,6 +3368,10 @@ export function createPanelServer(opts = {}) {
2971
3368
  settleMs: opts.settleMs,
2972
3369
  getResumeContext: (runid) => getResumeContext(runid, { home }),
2973
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 } : {}),
2974
3375
  });
2975
3376
  importRoutes = createImportRoutes({
2976
3377
  port: boundPort, token, home, userHome: opts.userHome, describeSpec,
@@ -2979,7 +3380,9 @@ export function createPanelServer(opts = {}) {
2979
3380
  server,
2980
3381
  port: boundPort,
2981
3382
  token,
2982
- 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)); }),
2983
3386
  });
2984
3387
  });
2985
3388
  });