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
package/src/planrun.js CHANGED
@@ -541,6 +541,10 @@ export const BOUND_REASON_MAX = 200;
541
541
 
542
542
  /** the recorded reason for a step attempt whose round was cut at the output cap (F122 2A b) */
543
543
  export const TRUNCATED_REASON = 'output cut at the token limit (max_tokens)';
544
+ /** the red fed to the redraft when the first replan answer was cut off (plain words, from code) */
545
+ const REPLAN_CUT_RED = 'Your previous answer was cut off at its length limit before the plan was complete. Answer again with a shorter plan that fits.';
546
+ /** the plain sentence for a replan answer cut off at its length limit (hamr 2026-10-16, run mv9gm796) */
547
+ export const REPLAN_CUT_WORDS = 'The replan answer was cut off at its length limit, so no new plan came back.';
544
548
 
545
549
  /**
546
550
  * What the NEXT attempt is told about the bound that cut the previous one.
@@ -2232,7 +2236,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2232
2236
  * denied, the wallet as its budget), granted tools only (the menu IS the
2233
2237
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
2234
2238
  * with a phase label (F12).
2235
- * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
2239
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any, truncatedBounded?: boolean}} o
2236
2240
  * `root` (Layer R): when present, the worker's write-class actions are teed
2237
2241
  * for the within-run ratchet — staged before the gate decides, discarded on
2238
2242
  * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
@@ -2242,7 +2246,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2242
2246
  * STRIKE LADDER (src/ladder.js), where that count does not exist by design — see
2243
2247
  * the turn-allowance note below.
2244
2248
  */
2245
- async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
2249
+ async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null, truncatedBounded = false }) {
2246
2250
  // THE HARD RULE, ENFORCED STRUCTURALLY (PRD v1.57 §3). This is the ONE seam
2247
2251
  // that grants write-class verbs — `writeScope` is empty for every other
2248
2252
  // worker, so a read-only phase cannot reach a file whatever it intends — and
@@ -2825,8 +2829,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2825
2829
  // cut round's output is discarded (bare-agent already dropped it), its spend is booked
2826
2830
  // by the per-round meter, the tree is judged as it stands, and the step's strike
2827
2831
  // ladder (or the fix governor) applies unchanged (hamr ruling A extended it to `fix`
2828
- // after run muux1x96). Scout, drafter, replan keep the transport route below.
2829
- if (r.error && r.error.startsWith('truncated:') && (phase.startsWith('step:') || phase === 'fix')) {
2832
+ // after run muux1x96). hamr 2026-10-16 (run mv9gm796) extended it to the REPLAN call: the
2833
+ // replan drafter opts in with `truncatedBounded`. Scout and the first draft keep the
2834
+ // transport route below (provider-red).
2835
+ if (r.error && r.error.startsWith('truncated:') && (phase.startsWith('step:') || phase === 'fix' || truncatedBounded)) {
2830
2836
  attemptBounded = { iteration: roundIteration, cause: 'truncated', reason: TRUNCATED_REASON };
2831
2837
  emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason: TRUNCATED_REASON, stopReason: r.stopReason ?? null });
2832
2838
  return r;
@@ -3008,9 +3014,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3008
3014
  * the hard line) — a drafter built once at full budget would let the replan
3009
3015
  * draft spend against money the steps had already consumed.
3010
3016
  * @param {string} phase @param {string|null} failure
3011
- * @param {string} [progress] the meter's read, replan only (A's adaptation channel) */
3017
+ * @param {string} [progress] the meter's read, replan only (A's adaptation channel)
3018
+ * @returns {Promise<{ok: boolean, reds: any[], plan?: any, truncated?: boolean}>} */
3012
3019
  const obtainPlan = async (phase, failure, progress) => {
3013
- const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
3020
+ const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false, truncatedBounded: phase === 'replan' });
3021
+ /** the LAST draftPlan call was a replan answer cut off at max_tokens: a bounded failed attempt,
3022
+ * never provider-red (hamr 2026-10-16); reset at the start of every call */
3023
+ let replanCut = false;
3014
3024
  // The BALANCE, read live at each draft — never a snapshot and never a rate
3015
3025
  // (addendum 2). At draft it is the whole budget (scale); at replan it is what
3016
3026
  // is actually left, which is the adaptation channel.
@@ -3039,7 +3049,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3039
3049
  };
3040
3050
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
3041
3051
  drafter.setIteration(reds ? 'redraft' : 'draft');
3052
+ replanCut = false;
3042
3053
  const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null, readShim), []);
3054
+ if (phase === 'replan' && typeof r.error === 'string' && r.error.startsWith('truncated:')) {
3055
+ replanCut = true;
3056
+ return '';
3057
+ }
3043
3058
  return extractArtifact(r.text).code ?? '';
3044
3059
  };
3045
3060
  // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
@@ -3056,10 +3071,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3056
3071
  return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
3057
3072
  };
3058
3073
  let text = await draftPlan(null);
3059
- let pv = validate(text);
3074
+ // A cut-off replan answer is treated like a BAD one: its half text is never validated (judging
3075
+ // half an artifact would mint a plan-red the drafter never earned), it becomes one plain-words
3076
+ // red fed to the redraft, and a cut REDRAFT (whatever the first answer was) is the bounded
3077
+ // failed attempt the caller escalates in words.
3078
+ let pv = replanCut
3079
+ ? { ok: false, reds: [{ code: 'parse-error', path: '$', detail: REPLAN_CUT_RED }], plan: null }
3080
+ : validate(text);
3060
3081
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
3061
3082
  if (!pv.ok) {
3062
3083
  text = await draftPlan(pv.reds);
3084
+ if (replanCut) return { ok: false, reds: [], truncated: true };
3063
3085
  pv = validate(text);
3064
3086
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
3065
3087
  }
@@ -3659,6 +3681,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3659
3681
  planExecuted();
3660
3682
  return relay(e, 'replan');
3661
3683
  }
3684
+ if (pv.truncated) {
3685
+ // a bounded failed attempt in plain words, filed as plan-red (a judged no, not a casualty)
3686
+ // with the code-owned sentence on the escalation; the panel reads `cause` to say it
3687
+ emit('escalation', {
3688
+ category: 'plan-red', cause: 'truncated', decisionReady: true, phase: 'replan',
3689
+ decision: REPLAN_CUT_WORDS, options: ['change the job and run it again', 'abandon the run'], detail: TRUNCATED_REASON,
3690
+ });
3691
+ planExecuted();
3692
+ return 'plan-red';
3693
+ }
3662
3694
  if (!pv.ok) {
3663
3695
  for (const r of pv.reds) emit('plan-red', r);
3664
3696
  planExecuted();
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // This module is also THE one owner of "model -> key + shape + URL": Chat's Model menu,
11
11
  // the author doors and the run doors all resolve through `findRow` / `keyNameFor` /
12
- // `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
12
+ // `keyValueOf` / `modelChoiceFor` below — no second lookup exists. Key VALUES
13
13
  // never pass through here — only names.
14
14
  import { readConfig } from './config.js';
15
15
  import { filledKeyNames } from './keysfile.js';
@@ -388,8 +388,8 @@ export function judgeRowFor(rows, judge, workerProvider, workerRow) {
388
388
  * the worker's own provider it calls the worker's endpoint, so a row on that endpoint with a line
389
389
  * NAMED for the judge's model prices it ({@link ratesFor}); a judge model that is absent or names
390
390
  * no line there keeps the worker's price (the worker's row, or none = the guess). A different
391
- * provider resolves with no endpoint. The judge's KEY is chosen elsewhere (`applyConfiguredKey`,
392
- * by the worker's model), so the two can come from different rows.
391
+ * provider resolves with no endpoint. The judge's KEY is chosen elsewhere (`judgeRowFor`:
392
+ * the worker's own row when the judge shares its provider), so the two can come from different rows.
393
393
  * @param {{ provider: string, model?: string|null }} judge
394
394
  * @param {string|null|undefined} workerProvider
395
395
  * @param {string|null|undefined} workerBaseUrl
package/src/reviewdoor.js CHANGED
@@ -50,6 +50,43 @@ export function doorRecordOf(events) {
50
50
  return events.findLast?.((e) => e?.type === REVIEW_DOOR) ?? null;
51
51
  }
52
52
 
53
+ /**
54
+ * Was this run's door ACCEPTED, honoured? A `door-decision` record with `decision: 'accept'` and `honoured: true`
55
+ * (written by {@link answerReviewDoor}, whichever surface asked: the CLI, or the panel's Accept). Pure, off the
56
+ * run's spine. A refused accept (`honoured: false`) does not count.
57
+ * @param {any[]} events the run's spine, parsed
58
+ * @returns {boolean}
59
+ */
60
+ export function doorAccepted(events) {
61
+ return Array.isArray(events) && events.some((e) => e?.type === 'door-decision' && e.decision === 'accept' && e.honoured === true);
62
+ }
63
+
64
+ /**
65
+ * T D.2 — a SCHEDULED run's green is held until the person accepts it (T10): a run that carries a `trigger.json` (a scheduled
66
+ * start wrote it) and holds no honoured accept. One shared predicate: `getStartFrom` reads its outcome as `'green-held'` (so
67
+ * the Reuse start refuses it unchanged), `listReuseJobs` skips it, and the schedule gate does not count it. A hand run is never held.
68
+ * @param {any[]} events the run's spine, parsed @param {any} triggerRec the run's `trigger.json`, or null
69
+ * @returns {boolean}
70
+ */
71
+ export function heldScheduled(events, triggerRec) {
72
+ return !!triggerRec && typeof triggerRec === 'object' && !doorAccepted(events);
73
+ }
74
+
75
+ /**
76
+ * Is this run WAITING for the person's Accept? Its spine holds a door record, the door has not expired ({@link doorAgeGate}),
77
+ * and no honoured `accept` or `rerun` decision follows it (a `pause` keeps the same three doors open). One owner: the runs
78
+ * list's Needs-you `Accept` tag, the Run tab's Accept button and the scheduled NEXT line all read this.
79
+ * @param {any[]} events the run's spine, parsed
80
+ * @param {{now?: () => number}} [opts]
81
+ * @returns {boolean}
82
+ */
83
+ export function doorWaiting(events, opts = {}) {
84
+ const door = doorRecordOf(events);
85
+ if (!door || !doorAgeGate(door, opts.now ? { now: opts.now } : {}).ok) return false;
86
+ const at = events.lastIndexOf(door);
87
+ return !events.slice(at + 1).some((e) => e?.type === 'door-decision' && e.honoured === true && (e.decision === 'accept' || e.decision === 'rerun'));
88
+ }
89
+
53
90
  /**
54
91
  * IS THE DOOR STILL OPEN? The same 60-day TTL a hitl checkpoint keeps
55
92
  * (`PAUSE_TTL_MS`) and deliberately the same NUMBER — the door replaced `cancel`
package/src/runlist.js CHANGED
@@ -128,6 +128,41 @@ export function appendRunEvent(event, opts = {}) {
128
128
  try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
129
129
  }
130
130
 
131
+ /**
132
+ * RULED 2026-10-15 ("Remove from Needs you"): the person took a stopped run's card out of the Needs-you list. ONE row
133
+ * `{runid, type:'needs-removed', at}` appended beside the run rows; it is not a run, a claim or an event ({@link readRunList}
134
+ * ignores it, so the monthly limit, the run counter and the scheduler never see it). Nothing is rewritten.
135
+ * @param {string} runid
136
+ * @param {{ home?: string, now?: () => number }} [opts]
137
+ * @returns {string} the `at` written
138
+ */
139
+ export function appendNeedsRemoved(runid, opts = {}) {
140
+ const at = new Date((opts.now ?? Date.now)()).toISOString();
141
+ const path = runlistPath(opts.home);
142
+ mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
143
+ appendFileSync(path, `${JSON.stringify({ runid, type: 'needs-removed', at })}\n`);
144
+ try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
145
+ return at;
146
+ }
147
+
148
+ /**
149
+ * The newest `needs-removed` time (ms) per run id. An absent file is an empty map; a row with no readable time is not a removal.
150
+ * @param {{ home?: string }} [opts]
151
+ * @returns {Map<string, number>}
152
+ */
153
+ export function readNeedsRemovals(opts = {}) {
154
+ /** @type {Map<string, number>} */
155
+ const out = new Map();
156
+ const path = runlistPath(opts.home);
157
+ if (!existsSync(path)) return out;
158
+ for (const r of parseJsonl(path).records) {
159
+ if (!r || r.type !== 'needs-removed' || typeof r.runid !== 'string') continue;
160
+ const t = typeof r.at === 'string' ? Date.parse(r.at) : NaN;
161
+ if (Number.isFinite(t) && t >= (out.get(r.runid) ?? -Infinity)) out.set(r.runid, t);
162
+ }
163
+ return out;
164
+ }
165
+
131
166
  /**
132
167
  * @typedef {{ type: 'leg-start', runid: string, leg: number, pid?: number, capUsd?: number, at: string }} LegStart
133
168
  * A resumed run's NEW claim (P5-R, hamr 2026-10-02: one run, one id, one row): appended beside the run's row
@@ -0,0 +1,296 @@
1
+ // T Part 3 (PANEL-BUILD.md T addendum, C.3 / C.6): the WORDS of a schedule. ONE owner of these strings. Pure
2
+ // functions: typed facts in, a fixed sentence out. No model text ever reaches a sentence (the only slot-fills are
3
+ // numbers, a file or folder name and the person's own path). `home` is injected (never a default inside a test).
4
+ // Rewritten from fwdloop src/summary-words.js (`scheduledWords`, `slotWords`, `nextTimeWords`, `nextInWords`,
5
+ // `scheduledNextFor`, `catchUpWords`), minus `endedWords` and every ask (bareloop has neither:
6
+ // its ENDED block is `endedFor` in src/panel/server.js).
7
+ import { basename } from 'node:path';
8
+ import { panelMoney2 } from './panel/authorroutes.js';
9
+
10
+ /** @typedef {import('./trigger.js').Trigger} Trigger */
11
+
12
+ const DAY_PLURAL = { mon: 'Mondays', tue: 'Tuesdays', wed: 'Wednesdays', thu: 'Thursdays', fri: 'Fridays', sat: 'Saturdays', sun: 'Sundays' };
13
+ const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
14
+ const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
15
+
16
+ /** @param {number} n */
17
+ const two = (n) => String(n).padStart(2, '0');
18
+ /** @param {{hour: number, minute: number}} s */
19
+ const hhmm = (s) => `${two(s.hour)}:${two(s.minute)}`;
20
+
21
+ /** A path with the home folder shown as `~`. @param {string} p @param {string|undefined} home */
22
+ function tilde(p, home) {
23
+ if (home && (p === home || p.startsWith(`${home}/`))) return `~${p.slice(home.length)}`;
24
+ return p;
25
+ }
26
+
27
+ /**
28
+ * Every schedule button/word the page shows lives here, so a reword is ONE edit (hamr will reword on his walk).
29
+ * The server sends this table with the run detail and the schedule card; the page reads every label from it.
30
+ */
31
+ export const SCHEDULE_WORDS = Object.freeze({
32
+ byHand: 'By hand',
33
+ cancelSchedule: 'Cancel schedule',
34
+ cancelWords: 'Cancel schedule',
35
+ // the Run tab's buttons (never relabelled in place: a different state is a different button)
36
+ scheduleRun: 'Schedule run',
37
+ cancelModify: 'Cancel / modify schedule',
38
+ // the one main button of the schedule card
39
+ signSchedule: 'Sign schedule',
40
+ nothingChanged: 'Nothing changed yet. This card only sets or adjusts the schedule; it never runs the job.',
41
+ confirmPrefix: 'Confirm: ',
42
+ // the When field and the blocks
43
+ whenLabel: 'When',
44
+ nowPrefix: 'Now: ',
45
+ scheduledLabel: 'SCHEDULE',
46
+ nextLabel: 'NEXT',
47
+ options: Object.freeze({
48
+ hand: 'By hand',
49
+ daily: 'Every day at HH:MM',
50
+ weekdays: 'Weekdays at HH:MM',
51
+ weekly: 'Every <day> at HH:MM',
52
+ 'file-updated': 'When <file> is updated',
53
+ 'new-file': 'When a new file arrives in <folder>',
54
+ }),
55
+ // RULED 2026-10-15: the Needs-you pill of a schedule that gave up (hamr may reword)
56
+ gaveUpPill: 'Check',
57
+ // RULED 2026-10-15 (aligned with fwdloop M9 am7): the SCHEDULE row's second line when the last fire started nothing
58
+ problemNext: 'next: fix it in Cancel / modify schedule',
59
+ monthNext: 'next: raise the limit in Settings, or it starts on the 1st',
60
+ acceptWaiting: 'waiting for your Accept (the next run does not wait)',
61
+ scheduleGroup: 'schedule',
62
+ notGreen: 'This job has not had a good run yet. Run it by hand and get it right first; then you can set when it runs. A rubric job also needs your Accept once. Nothing was changed.',
63
+ stale: 'What you were shown is not what is on the card now. Nothing was signed. Look again, then sign.',
64
+ });
65
+
66
+ /**
67
+ * What starts the job, in words. `null` (by hand) has no sentence.
68
+ * @param {Trigger|null|undefined} t @param {string} [home]
69
+ * @returns {string|null}
70
+ */
71
+ export function scheduledWords(t, home) {
72
+ if (!t) return null;
73
+ if (t.kind === 'daily') return `Every day at ${hhmm(t)}`;
74
+ if (t.kind === 'weekdays') return `Weekdays at ${hhmm(t)}`;
75
+ if (t.kind === 'weekly') return `${DAY_PLURAL[/** @type {keyof typeof DAY_PLURAL} */ (t.day)] ?? t.day} at ${hhmm(t)}`;
76
+ if (t.kind === 'file-updated') return `When ${basename(t.path)} is updated`;
77
+ const folder = tilde(/** @type {{folder: string}} */ (t).folder, home);
78
+ return `When a new file arrives in ${folder.endsWith('/') ? folder : `${folder}/`}`;
79
+ }
80
+
81
+ /** "Mon 13 Oct, 09:00" — local time, fixed English names (never the machine's locale). @param {number} ms */
82
+ export function slotWords(ms) {
83
+ const d = new Date(ms);
84
+ return `${DAY_SHORT[d.getDay()]} ${d.getDate()} ${MONTH_SHORT[d.getMonth()]}, ${two(d.getHours())}:${two(d.getMinutes())}`;
85
+ }
86
+
87
+ /**
88
+ * The Scheduled card's first line: "today 17:00", "tomorrow 09:00", else the date the NEXT line already says
89
+ * ("Tue 20 Oct, 09:05"). Local time, by calendar day. @param {number} ms the next run @param {number} now
90
+ */
91
+ export function nextTimeWords(ms, now) {
92
+ const d = new Date(ms);
93
+ const day = (/** @type {Date} */ x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
94
+ const apart = Math.round((day(d) - day(new Date(now))) / 86_400_000);
95
+ if (apart === 0) return `today ${two(d.getHours())}:${two(d.getMinutes())}`;
96
+ if (apart === 1) return `tomorrow ${two(d.getHours())}:${two(d.getMinutes())}`;
97
+ return slotWords(ms);
98
+ }
99
+
100
+ /** "next in 20m" / "next in 3h" / "next in 3d" (whole units, rounded down, never "0m"). @param {number} ms @param {number} now */
101
+ export function nextInWords(ms, now) {
102
+ const mins = Math.max(1, Math.floor((ms - now) / 60_000));
103
+ if (mins < 60) return `next in ${mins}m`;
104
+ const hours = Math.floor(mins / 60);
105
+ return hours < 48 ? `next in ${hours}h` : `next in ${Math.floor(hours / 24)}d`;
106
+ }
107
+
108
+ /**
109
+ * NEXT of a scheduled job: one sentence, first match wins.
110
+ * 1. a run of this job is waiting for the person's Accept (the next scheduled run does NOT wait for it, T7);
111
+ * 2. the latest outcome this month is `monthly-exhausted` (amounts from the row), RED;
112
+ * 3. a clock kind with a next slot; 4. a file kind that is armed; 5. not armed yet.
113
+ * A last outcome of `refused` goes in front of 3-5, so a trigger that cannot start is never silent.
114
+ * `red` is the typed flag for "the person must act": the page paints it and never guesses from the text.
115
+ * @param {{trigger: Trigger, armed: boolean, nextSlotMs: number|null, lastOutcome: any, waitingRunNo: number|null, now: number, retryInMs?: number|null, gaveUp?: any}} f
116
+ * `lastOutcome` = the newest outcome row of the CURRENT signing (`kind`, `at` in ms, `why`, `leftUsd`, `needUsd`, `atLeast`)
117
+ * @returns {{text: string, red: boolean}}
118
+ */
119
+ export function scheduledNextFor(f) {
120
+ const { trigger, armed, nextSlotMs, lastOutcome, waitingRunNo, now, retryInMs = null, gaveUp = null } = f;
121
+ /** @param {string} text @param {boolean} [red] */
122
+ const say = (text, red = false) => ({ text, red });
123
+ if (waitingRunNo !== null) return say(`Run ${waitingRunNo} is waiting for your Accept. The next scheduled run does not wait for it.`);
124
+ if (gaveUp && typeof gaveUp === 'object') return say(gaveUpNextWords(gaveUp.why), true);
125
+ const last = lastOutcome && typeof lastOutcome === 'object' ? lastOutcome : null;
126
+ const sameMonth = last !== null && sameMonthAs(last.at, now);
127
+ if (last !== null && last.kind === 'monthly-exhausted' && sameMonth && typeof last.leftUsd === 'number' && typeof last.needUsd === 'number') {
128
+ const left = last.atLeast === true ? `at most ${panelMoney2(last.leftUsd)} left` : `${panelMoney2(last.leftUsd)} left`;
129
+ return say(`Paused: not enough money left this month (${left}, a run needs up to ${panelMoney2(last.needUsd)}). Raise the monthly limit in Settings, or it starts again on the 1st.`, true);
130
+ }
131
+ const lead = last !== null && last.kind === 'refused' && typeof last.why === 'string' && last.why !== '' ? `The last try did not start: ${last.why} ${retryWords(retryInMs)}` : '';
132
+ const red = lead !== '';
133
+ if (!armed) return say(`${lead}Not armed yet: the panel arms it on its next check.`, red);
134
+ if (trigger.kind === 'file-updated') return say(`${lead}Watching for it. A run starts when its contents change.`, red);
135
+ if (trigger.kind === 'new-file') return say(`${lead}Watching for it. A run starts for each new file.`, red);
136
+ if (nextSlotMs === null) return say(`${lead}Not armed yet: the panel arms it on its next check.`, red);
137
+ return say(`${lead}Next run ${slotWords(nextSlotMs)}.`, red);
138
+ }
139
+
140
+ /** Is `atMs` in the same local calendar month as `nowMs`? A non-number is never. @param {unknown} atMs @param {number} nowMs */
141
+ export function sameMonthAs(atMs, nowMs) {
142
+ if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return false;
143
+ const a = new Date(atMs); const n = new Date(nowMs);
144
+ return a.getFullYear() === n.getFullYear() && a.getMonth() === n.getMonth();
145
+ }
146
+
147
+ /** RULED 2026-10-15: the red NEXT of a schedule that gave up. @param {unknown} why the refusal's own words */
148
+ export function gaveUpNextWords(why) {
149
+ const w = typeof why === 'string' ? why.trim().replace(/\.$/, '') : '';
150
+ return `Stopped trying after 60 minutes: ${w}. Fix it, then Cancel / modify schedule to start again.`;
151
+ }
152
+
153
+ /** The give-up date on the Needs-you card, local `MM/DD HH:MM`. @param {number} ms */
154
+ export function gaveUpDateWords(ms) {
155
+ const d = new Date(ms);
156
+ return `${two(d.getMonth() + 1)}/${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
157
+ }
158
+
159
+ /** RULED 2026-10-15: after an install-needed refusal, the wait that is left, in whole minutes rounded up; '' when no wait. @param {number|null|undefined} ms */
160
+ export function retryWords(ms) {
161
+ if (typeof ms !== 'number' || !(ms > 0)) return '';
162
+ const m = Math.ceil(ms / 60_000);
163
+ return `Trying again in ${m} minute${m === 1 ? '' : 's'}. `;
164
+ }
165
+
166
+ /** The fixed catch-up sentence from a run's `trigger.json`. @param {any} rec */
167
+ export function catchUpWords(rec) {
168
+ return rec && typeof rec === 'object' && rec.catchUp === true ? 'This run was a catch-up: it started more than 2 minutes after its set time (the panel was off).' : null;
169
+ }
170
+
171
+ /**
172
+ * A spawn error AFTER the consuming row: `tries` releases so far, `max` the bound. Under the bound it will be tried again;
173
+ * at the bound it stays consumed and says so. @param {string} why @param {number} tries @param {number} max
174
+ */
175
+ export function lateRefusalWords(why, tries, max) {
176
+ return tries < max ? `${why} It will be tried again.` : `${why} It was tried ${max} times and is given up on.`;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------------------------------------------------------
180
+ // RULED 2026-10-15 (aligned with fwdloop M9 am7): the schedule on the Run, Job and Audit tabs. ONE owner of these sentences.
181
+
182
+ /** "10/15 13:40" — local time. @param {number} ms */
183
+ export const dateTimeWords = (ms) => gaveUpDateWords(ms);
184
+
185
+ /** @param {string} s */
186
+ export const lowerFirst = (s) => (s === '' ? s : s[0].toLowerCase() + s.slice(1));
187
+
188
+ /** "Thu 10/16 08:15" — local time, fixed English day names. @param {number} ms */
189
+ export function nextSlotShort(ms) {
190
+ const d = new Date(ms);
191
+ return `${DAY_SHORT[d.getDay()]} ${two(d.getMonth() + 1)}/${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
192
+ }
193
+
194
+ /** @param {any} rec a run's `trigger.json` @returns {boolean} */
195
+ function isNewFile(rec) {
196
+ if (rec.triggerKind) return rec.triggerKind === 'new-file';
197
+ return typeof rec.trigger === 'string' && rec.trigger.startsWith('When a new file arrives');
198
+ }
199
+
200
+ /**
201
+ * Job tab, "Started": how the run began, then its local start time. `rec` = the run's `trigger.json` (null on a hand run).
202
+ * @param {any} rec @param {number|null} atMs the run's start
203
+ */
204
+ export function startedWords(rec, atMs) {
205
+ const when = atMs === null ? '' : ` · ${dateTimeWords(atMs)}`;
206
+ if (!rec || typeof rec !== 'object') return `${SCHEDULE_WORDS.byHand}${when}`;
207
+ const trig = typeof rec.trigger === 'string' ? rec.trigger : '';
208
+ let how;
209
+ if (rec.kind === 'file') {
210
+ const name = basename(String(rec.file ?? ''));
211
+ how = isNewFile(rec) ? `By a new file: ${name}` : `By a file update: ${name}`;
212
+ } else {
213
+ how = `By the schedule: ${lowerFirst(trig)}`;
214
+ if (rec.catchUp === true) {
215
+ const min = typeof rec.lateMs === 'number' && rec.lateMs > 0 ? Math.round(rec.lateMs / 60_000) : null;
216
+ how += min === null ? ' · catch-up' : ` · catch-up, ${min} min late`;
217
+ }
218
+ }
219
+ return `${how}${when}`;
220
+ }
221
+
222
+ /**
223
+ * Audit, first row of a run a schedule started. @param {any} rec `trigger.json` @param {number|null} originNo the origin run's number
224
+ */
225
+ export function startedAuditWords(rec, originNo) {
226
+ const from = originNo === null ? '' : ` · from run-${originNo}`;
227
+ if (rec.kind === 'file') return `started by the schedule · ${isNewFile(rec) ? 'new file' : 'file update'} ${basename(String(rec.file ?? ''))}${from}`;
228
+ const slot = typeof rec.slot === 'number' ? ` · slot ${two(new Date(rec.slot).getHours())}:${two(new Date(rec.slot).getMinutes())}` : '';
229
+ return `started by the schedule · ${lowerFirst(String(rec.trigger ?? ''))}${slot}${from}${rec.catchUp === true ? ' · catch-up' : ''}`;
230
+ }
231
+
232
+ /** a refusal's own words without its trailing "Nothing started." @param {unknown} why */
233
+ function reasonOf(why) {
234
+ return String(why ?? '').replace(/\s*Nothing (started|spent)\.\s*$/, '').trim().replace(/\.$/, '');
235
+ }
236
+
237
+ /**
238
+ * A fire that started nothing, in plain words. `short` = the SCHEDULE row's `last` reason; else the Audit group's line.
239
+ * @param {any} row a ledger row (`busy` | `monthly-exhausted` | `refused` | `gave-up`) @param {boolean} [short]
240
+ */
241
+ export function skippedWords(row, short = false) {
242
+ if (row.kind === 'busy') return short ? 'busy: a run of this job was still working' : 'busy: a run of this job was still working. Nothing started.';
243
+ if (row.kind === 'monthly-exhausted') {
244
+ const left = typeof row.leftUsd === 'number' ? `${row.atLeast === true ? 'at most ' : ''}${panelMoney2(row.leftUsd)}` : 'little';
245
+ const need = typeof row.needUsd === 'number' ? panelMoney2(row.needUsd) : 'more';
246
+ return short ? `month used up: ${left} left, needs ${need}` : `month used up: ${left} left, needs ${need}. Nothing spent.`;
247
+ }
248
+ const r = lowerFirst(reasonOf(row.why));
249
+ return short ? `refused: ${r}` : `refused: ${r}. Nothing started.`;
250
+ }
251
+
252
+ /**
253
+ * Job tab, "Schedule": the job's schedule NOW and what it is bound to. The first part IS the Run tab's line ({@link scheduledWords}),
254
+ * first letter lowered, so the two can never disagree.
255
+ * @param {{trigger: Trigger|null, originNo: number|null, model: string|null, destination: string|null, home?: string}} f
256
+ */
257
+ export function jobScheduleWords(f) {
258
+ const w = f.trigger ? scheduledWords(f.trigger, f.home) : null;
259
+ if (w === null) return 'none';
260
+ const parts = [lowerFirst(w)];
261
+ if (f.originNo !== null) parts.push(`from run-${f.originNo}`);
262
+ if (f.model) parts.push(f.model);
263
+ if (f.destination) parts.push(tilde(f.destination, f.home));
264
+ return parts.join(' · ');
265
+ }
266
+
267
+ /** RULED 2026-10-15: the SCHEDULE row's `next` while an install-needed refusal is waiting to be tried again. @param {number} ms */
268
+ export function retryNextWords(ms) {
269
+ const m = Math.max(1, Math.ceil(ms / 60_000));
270
+ return `next: tries again in ${m} min`;
271
+ }
272
+
273
+ /**
274
+ * RULED 2026-10-15: the sign on the head of the Audit's `schedule` section. ONE small rule (hamr may still adjust it): [▶] only while a
275
+ * run of the job is running (as everywhere in the panel), [✗] when the SCHEDULE row is red, else [·] (on, waiting).
276
+ * @param {{running: boolean, red: boolean}} f @returns {{sign: string}}
277
+ */
278
+ export function scheduleSignFor(f) {
279
+ return { sign: f.running ? '▶' : f.red ? '✗' : '·' };
280
+ }
281
+
282
+ /** Audit schedule section, the `this run` words of a run the schedule started (the head already says which schedule and from where). @param {any} rec `trigger.json` */
283
+ export function sectionThisRunWords(rec) {
284
+ if (rec.kind === 'file') return `started by the schedule · ${isNewFile(rec) ? 'new file' : 'file update'} ${basename(String(rec.file ?? ''))}`;
285
+ let late = '';
286
+ if (rec.catchUp === true) {
287
+ const min = typeof rec.lateMs === 'number' && rec.lateMs > 0 ? Math.round(rec.lateMs / 60_000) : null;
288
+ late = min === null ? ' · catch-up' : ` · catch-up, ${min} min late`;
289
+ }
290
+ return `started by the schedule · ${lowerFirst(String(rec.trigger ?? ''))}${late}`;
291
+ }
292
+
293
+ /** Audit schedule section, folded tail: `1 try started nothing` / `N tries started nothing`; '' for none. @param {number} n */
294
+ export function triesTailWords(n) {
295
+ return n > 0 ? `${n} ${n === 1 ? 'try' : 'tries'} started nothing` : '';
296
+ }
package/src/trigger.js ADDED
@@ -0,0 +1,48 @@
1
+ // T Part 3 (docs/product/PANEL-BUILD.md, "T: triggers build spec", C.3): the `trigger` field of a signed spec,
2
+ // as typed data. Pure. Never cron text, never a model's word: the agent does not write this field (assembleSpec
3
+ // refuses a draft carrying it) and the interview never does either. By hand = the key is ABSENT (never null),
4
+ // so a by-hand spec hashes exactly as it did before triggers existed.
5
+ // {kind:'daily'|'weekdays', hour, minute}
6
+ // {kind:'weekly', day:'mon'..'sun', hour, minute}
7
+ // {kind:'new-file', folder} absolute path
8
+ // {kind:'file-updated', path} absolute path
9
+ // Rewritten from fwdloop src/trigger-line.js (its prose-line grammar is replaced: the signed thing is the JSON spec).
10
+ import { isAbsolute } from 'node:path';
11
+ import { validateSchedule } from './triggerclock.js';
12
+
13
+ /** @typedef {import('./triggerclock.js').Schedule | {kind:'new-file', folder:string} | {kind:'file-updated', path:string}} Trigger */
14
+
15
+ export const TRIGGER_KINDS = Object.freeze(['daily', 'weekdays', 'weekly', 'new-file', 'file-updated']);
16
+ export const CLOCK_KINDS = Object.freeze(['daily', 'weekdays', 'weekly']);
17
+ export const FILE_KINDS = Object.freeze(['new-file', 'file-updated']);
18
+
19
+ /** @param {unknown} t @returns {t is {kind:'daily'|'weekdays'|'weekly', hour:number, minute:number}} */
20
+ export const isClockTrigger = (t) => !!t && typeof t === 'object' && CLOCK_KINDS.includes(/** @type {any} */ (t).kind);
21
+ /** @param {unknown} t @returns {t is {kind:'new-file', folder:string} | {kind:'file-updated', path:string}} */
22
+ export const isFileTrigger = (t) => !!t && typeof t === 'object' && FILE_KINDS.includes(/** @type {any} */ (t).kind);
23
+
24
+ /**
25
+ * The only way a trigger enters the system. Returns the typed trigger, or says what to fix (and which field).
26
+ * @param {unknown} t
27
+ * @returns {{ok: true, trigger: Trigger} | {ok: false, say: string, code: 'invalid-value'|'unknown-field', path: string}}
28
+ */
29
+ export function validateTrigger(t) {
30
+ /** @param {string} say @param {string} [path] @param {'invalid-value'|'unknown-field'} [code] */
31
+ const no = (say, path = 'trigger', code = 'invalid-value') => ({ ok: /** @type {const} */ (false), say, code, path });
32
+ if (!t || typeof t !== 'object' || Array.isArray(t)) return no('a trigger is an object like {kind, hour, minute}, not text');
33
+ const o = /** @type {Record<string, unknown>} */ (t);
34
+ if (!TRIGGER_KINDS.includes(/** @type {string} */ (o.kind))) return no(`trigger kind "${o.kind}" is not one of ${TRIGGER_KINDS.join(', ')}`, 'trigger.kind');
35
+ if (CLOCK_KINDS.includes(/** @type {string} */ (o.kind))) {
36
+ const allowed = o.kind === 'weekly' ? ['kind', 'day', 'hour', 'minute'] : ['kind', 'hour', 'minute'];
37
+ const extra = Object.keys(o).find((k) => !allowed.includes(k));
38
+ if (extra !== undefined) return no(`trigger field "${extra}" is not allowed on a ${o.kind} trigger`, `trigger.${extra}`, 'unknown-field');
39
+ try { return { ok: true, trigger: validateSchedule(o) }; } catch (e) { return no(/** @type {Error} */ (e).message.replace(/^schedule/, 'trigger')); }
40
+ }
41
+ const where = o.kind === 'new-file' ? 'folder' : 'path';
42
+ const extra = Object.keys(o).find((k) => k !== 'kind' && k !== where);
43
+ if (extra !== undefined) return no(`trigger field "${extra}" is not allowed on a ${o.kind} trigger (it takes a ${where})`, `trigger.${extra}`, 'unknown-field');
44
+ const p = o[where];
45
+ // eslint-disable-next-line no-control-regex
46
+ if (typeof p !== 'string' || p === '' || !isAbsolute(p) || /[\x00-\x1f\x7f]/.test(p)) return no(`the ${where} must be a full path on one line, like /home/you/inbox`, `trigger.${where}`);
47
+ return { ok: true, trigger: /** @type {Trigger} */ (o.kind === 'new-file' ? { kind: 'new-file', folder: p } : { kind: 'file-updated', path: p }) };
48
+ }