@mulmoclaude/core 4.3.0 → 4.4.1

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 (33) hide show
  1. package/assets/helps/custom-view-remote.md +28 -11
  2. package/assets/helps/error-recovery.md +20 -3
  3. package/dist/{graph-BZJxiv1E.cjs → graph-CURotEkH.cjs} +88 -37
  4. package/dist/graph-CURotEkH.cjs.map +1 -0
  5. package/dist/{graph-Ce7xsDTt.js → graph-DBxSOx3v.js} +77 -38
  6. package/dist/graph-DBxSOx3v.js.map +1 -0
  7. package/dist/scheduler/index.cjs +116 -63
  8. package/dist/scheduler/index.cjs.map +1 -1
  9. package/dist/scheduler/index.js +117 -64
  10. package/dist/scheduler/index.js.map +1 -1
  11. package/dist/scheduler/schedule-window.d.ts +35 -0
  12. package/dist/scheduler/task-manager.d.ts +2 -8
  13. package/dist/{slug-CdN-pQX1.js → slug-BpjumQfe.js} +26 -5
  14. package/dist/{slug-CdN-pQX1.js.map → slug-BpjumQfe.js.map} +1 -1
  15. package/dist/{slug-DbwORN9z.cjs → slug-JxgB0ru9.cjs} +31 -4
  16. package/dist/{slug-DbwORN9z.cjs.map → slug-JxgB0ru9.cjs.map} +1 -1
  17. package/dist/wiki/graph.d.ts +0 -5
  18. package/dist/wiki/index.cjs +5 -2
  19. package/dist/wiki/index.d.ts +3 -2
  20. package/dist/wiki/index.js +3 -3
  21. package/dist/wiki/lint.d.ts +8 -9
  22. package/dist/wiki/paths.cjs +1 -1
  23. package/dist/wiki/paths.js +1 -1
  24. package/dist/wiki/resolve.d.ts +24 -0
  25. package/dist/wiki/server/engine.d.ts +12 -4
  26. package/dist/wiki/server/index.cjs +46 -34
  27. package/dist/wiki/server/index.cjs.map +1 -1
  28. package/dist/wiki/server/index.js +46 -34
  29. package/dist/wiki/server/index.js.map +1 -1
  30. package/dist/wiki/slug.d.ts +14 -3
  31. package/package.json +1 -1
  32. package/dist/graph-BZJxiv1E.cjs.map +0 -1
  33. package/dist/graph-Ce7xsDTt.js.map +0 -1
@@ -2,12 +2,87 @@ import { t as errorMessage } from "../dist-D8zokgGo.js";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { appendFile, mkdir, readFile } from "node:fs/promises";
5
- import { SCHEDULE_TYPES, TASK_RESULTS, TASK_TRIGGERS, TASK_TRIGGERS as TASK_TRIGGERS$1, appendLogEntry, computeCatchUpPlan, emptyState, loadState, nextWindowAfter, queryLog, updateAndSave } from "@receptron/task-scheduler";
6
- //#region src/scheduler/task-manager.ts
5
+ import { SCHEDULE_TYPES, TASK_RESULTS, TASK_TRIGGERS, TASK_TRIGGERS as TASK_TRIGGERS$1, appendLogEntry, computeCatchUpPlan, emptyState, isDueAt, loadState, nextWindowAfter, parseTimeToMs, queryLog, updateAndSave } from "@receptron/task-scheduler";
6
+ //#region src/scheduler/schedule-window.ts
7
7
  var ONE_SECOND_MS$1 = 1e3;
8
- var ONE_MINUTE_MS = 60 * ONE_SECOND_MS$1;
9
- var ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
10
- var DEFAULT_FIRING_STAGGER_MS = ONE_SECOND_MS$1;
8
+ var ONE_DAY_MS = 86400 * ONE_SECOND_MS$1;
9
+ /** An interval this engine can never turn into a window. Zero divides by zero
10
+ * inside the library's `Math.ceil(afterMs / intervalMs)`; a negative or
11
+ * non-finite one poisons the arithmetic with NaN, which compares false
12
+ * everywhere and so hides itself. */
13
+ function isUsableInterval(intervalMs) {
14
+ return Number.isFinite(intervalMs) && intervalMs > 0;
15
+ }
16
+ /** Milliseconds past UTC midnight for a daily `HH:MM`, or null when the string
17
+ * does not name a time of day. Out-of-range values are rejected, not clamped
18
+ * and not wrapped: `"24:00"` would otherwise land on the NEXT day's midnight
19
+ * and `"-1:00"` on an hour before it, so a typo that used to be inert would
20
+ * start firing — at 00:00 daily, or 61 times a day. */
21
+ function dailyOffsetMs(time) {
22
+ const offsetMs = parseTimeToMs(time);
23
+ return Number.isFinite(offsetMs) && offsetMs >= 0 && offsetMs < ONE_DAY_MS ? offsetMs : null;
24
+ }
25
+ /** Convert to the library's schedule shape. Milliseconds are divided, never
26
+ * rounded: rounding a sub-second interval to `intervalSec: 0` is what made
27
+ * `nextWindowAfter` answer NaN and `new Date(NaN).toISOString()` throw. */
28
+ function toLibrarySchedule(schedule) {
29
+ if (schedule.type === SCHEDULE_TYPES.interval) return {
30
+ type: SCHEDULE_TYPES.interval,
31
+ intervalSec: schedule.intervalMs / ONE_SECOND_MS$1
32
+ };
33
+ return {
34
+ type: SCHEDULE_TYPES.daily,
35
+ time: schedule.time
36
+ };
37
+ }
38
+ /** True when a scheduled window falls inside the tick ending at `nowMs`.
39
+ * Windows are anchored to the epoch, not to the current UTC day, so an
40
+ * interval longer than 24h keeps its real period. */
41
+ function isScheduleDueAt(schedule, nowMs, tickMs) {
42
+ if (unfireableScheduleReason(schedule) !== null) return false;
43
+ return isDueAt(toLibrarySchedule(schedule), nowMs, tickMs);
44
+ }
45
+ /** The furthest instant `Date` can represent. A window past it is finite
46
+ * arithmetic but not a date, and `toISOString()` answers that with a throw —
47
+ * which, inside the state writer, silently loses the whole run record. */
48
+ var MAX_DATE_MS = 864e13;
49
+ /** A window as an ISO timestamp, or null when the number is not a date. */
50
+ function windowToIso(windowMs) {
51
+ if (windowMs === null || !Number.isFinite(windowMs) || Math.abs(windowMs) > MAX_DATE_MS) return null;
52
+ return new Date(windowMs).toISOString();
53
+ }
54
+ /** The most recent window at or before `nowMs`, or null when the schedule has
55
+ * none. `nextWindowAfter` only ever looks FORWARD, so asking it from
56
+ * `nowMs - one period` answers the previous window whenever `nowMs` sits
57
+ * exactly on a boundary, and a daily schedule read after its minute answers
58
+ * TOMORROW. Both schedule kinds have a fixed period, so stepping one period
59
+ * back off the next window is exact. */
60
+ function latestWindowAtOrBefore(schedule, nowMs) {
61
+ if (unfireableScheduleReason(schedule) !== null) return null;
62
+ const next = nextWindowAfter(toLibrarySchedule(schedule), nowMs);
63
+ if (next === null || !Number.isFinite(next)) return null;
64
+ if (next <= nowMs) return next;
65
+ return next - (schedule.type === SCHEDULE_TYPES.interval ? schedule.intervalMs : ONE_DAY_MS);
66
+ }
67
+ /** Name the field that makes a schedule unfireable, or null when it is fine.
68
+ * Returns the offending value (not a boolean) so the caller can put it in the
69
+ * log — a task that silently never runs leaves no evidence to start from
70
+ * (#2765). */
71
+ function unfireableScheduleReason(schedule) {
72
+ if (schedule.type === SCHEDULE_TYPES.interval) return isUsableInterval(schedule.intervalMs) ? null : {
73
+ field: "intervalMs",
74
+ value: String(schedule.intervalMs)
75
+ };
76
+ return dailyOffsetMs(schedule.time) !== null ? null : {
77
+ field: "time",
78
+ value: schedule.time
79
+ };
80
+ }
81
+ //#endregion
82
+ //#region src/scheduler/task-manager.ts
83
+ var ONE_SECOND_MS = 1e3;
84
+ var ONE_MINUTE_MS = 60 * ONE_SECOND_MS;
85
+ var DEFAULT_FIRING_STAGGER_MS = ONE_SECOND_MS;
11
86
  var MAX_STAGGER_FRACTION_OF_TICK = .5;
12
87
  var realSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
13
88
  function staggerStepMs(staggerMs, tickMs, count) {
@@ -21,34 +96,7 @@ var NOOP_LOG$1 = {
21
96
  error: () => {}
22
97
  };
23
98
  function isDue(now, schedule, tickMs) {
24
- if (schedule.type === SCHEDULE_TYPES.interval) {
25
- const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS$1;
26
- return Math.floor(msSinceMidnight / tickMs) * tickMs % schedule.intervalMs === 0;
27
- }
28
- if (schedule.type === SCHEDULE_TYPES.daily) {
29
- const targetMs = dailyTargetMs(schedule.time);
30
- if (targetMs === null) return false;
31
- const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS$1;
32
- return Math.floor(msSinceMidnight / tickMs) * tickMs === targetMs;
33
- }
34
- return false;
35
- }
36
- /** Milliseconds past UTC midnight for a `HH:MM` daily time, or null when the
37
- * string isn't that shape. `TaskSchedule.time` is a bare `string` on a
38
- * published package, so a host that skips its own validation must land on
39
- * "never due" rather than on a garbage firing time. */
40
- function dailyTargetMs(time) {
41
- const [hours, minutes] = time.split(":").map(Number);
42
- if (hours === void 0 || minutes === void 0) return null;
43
- if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
44
- return hours * ONE_HOUR_MS + minutes * ONE_MINUTE_MS;
45
- }
46
- /** The `time` of a daily schedule this manager can never fire, or null when the
47
- * schedule is fine. Returning the offending value (not a boolean) is what lets
48
- * the caller name it in the log. */
49
- function unfireableDailyTime(schedule) {
50
- if (schedule.type !== SCHEDULE_TYPES.daily) return null;
51
- return dailyTargetMs(schedule.time) === null ? schedule.time : null;
99
+ return isScheduleDueAt(schedule, now.getTime(), tickMs);
52
100
  }
53
101
  /** Say out loud that this schedule can never fire. The task is still accepted —
54
102
  * `isDue` answers false forever, which is the safe outcome — but silence here
@@ -56,11 +104,11 @@ function unfireableDailyTime(schedule) {
56
104
  * evidence anywhere to start from (#2765). Deliberately not a throw: a
57
105
  * consumer whose task has been quietly dead would get a boot crash instead. */
58
106
  function reportUnfireable(log, taskId, schedule) {
59
- const time = unfireableDailyTime(schedule);
60
- if (time === null) return;
61
- log.error("daily time is not HH:MM — this task will never run", {
107
+ const reason = unfireableScheduleReason(schedule);
108
+ if (reason === null) return;
109
+ log.error(`schedule has an unusable ${reason.field} — this task will never run`, {
62
110
  id: taskId,
63
- time
111
+ [reason.field]: reason.value
64
112
  });
65
113
  }
66
114
  /** Split the due tasks into those that may run immediately and those gated
@@ -203,7 +251,6 @@ function createTaskManager(options) {
203
251
  }
204
252
  //#endregion
205
253
  //#region src/scheduler/adapter.ts
206
- var ONE_SECOND_MS = 1e3;
207
254
  var SCHEDULER_CONFIG_DIR = "config/scheduler";
208
255
  var SCHEDULER_DATA_DIR = "data/scheduler/logs";
209
256
  var NOOP_LOG = {
@@ -256,14 +303,7 @@ async function initScheduler(taskManager, tasks) {
256
303
  systemTasks.length = 0;
257
304
  systemTasks.push(...tasks);
258
305
  taskManagerRef = taskManager;
259
- const catchUpTasks = tasks.map((taskDef) => ({
260
- id: taskDef.id,
261
- name: taskDef.name,
262
- schedule: toCoreSchedule(taskDef.schedule),
263
- missedRunPolicy: taskDef.missedRunPolicy,
264
- enabled: true
265
- }));
266
- const plan = computeCatchUpPlan(catchUpTasks, stateMap, Date.now());
306
+ const plan = computeCatchUpPlan(catchUpTasksFor(tasks), stateMap, Date.now());
267
307
  for (const skip of plan.skipped) {
268
308
  logger().info("catch-up skipped", {
269
309
  taskId: skip.taskId,
@@ -283,8 +323,8 @@ async function initScheduler(taskManager, tasks) {
283
323
  id: task.id,
284
324
  description: task.description,
285
325
  schedule: task.schedule,
286
- run: async () => {
287
- const windowIso = computeCurrentWindow(task);
326
+ run: async (ctx) => {
327
+ const windowIso = computeCurrentWindow(task, ctx.now.getTime());
288
328
  await executeAndLog(task, windowIso, TASK_TRIGGERS$1.scheduled);
289
329
  }
290
330
  });
@@ -293,6 +333,28 @@ async function initScheduler(taskManager, tasks) {
293
333
  stateEntries: stateMap.size
294
334
  });
295
335
  }
336
+ /** The catch-up input, with the tasks that can never fire left out. Their
337
+ * windows come back NaN rather than null, which `listMissedWindows` cannot
338
+ * see the end of — it fills to its cap and the first `new Date(NaN)` throws
339
+ * `RangeError`, taking every other task's catch-up down with it at startup.
340
+ * Registration still happens, so the task-manager reports the bad schedule. */
341
+ function catchUpTasksFor(tasks) {
342
+ return tasks.filter((taskDef) => {
343
+ const reason = unfireableScheduleReason(taskDef.schedule);
344
+ if (reason === null) return true;
345
+ logger().error(`schedule has an unusable ${reason.field} — skipping catch-up, this task will never run`, {
346
+ taskId: taskDef.id,
347
+ [reason.field]: reason.value
348
+ });
349
+ return false;
350
+ }).map((taskDef) => ({
351
+ id: taskDef.id,
352
+ name: taskDef.name,
353
+ schedule: toLibrarySchedule(taskDef.schedule),
354
+ missedRunPolicy: taskDef.missedRunPolicy,
355
+ enabled: true
356
+ }));
357
+ }
296
358
  /** Apply a schedule override to a running system task. Updates the
297
359
  * in-memory task definition, the task-manager, and recalculates
298
360
  * nextScheduledAt in persisted state. */
@@ -446,24 +508,15 @@ async function safeUpdateState(taskId, patch) {
446
508
  /** Compute the window boundary that the current tick belongs to. For
447
509
  * scheduled runs, this is the epoch-aligned window — not the wall-clock
448
510
  * time of execution. This keeps lastRunAt consistent with catch-up's
449
- * window-based accounting. */
450
- function computeCurrentWindow(task) {
451
- const coreSchedule = toCoreSchedule(task.schedule);
452
- const nowMs = Date.now();
453
- const windowMs = nextWindowAfter(coreSchedule, nowMs - (coreSchedule.type === SCHEDULE_TYPES.interval ? coreSchedule.intervalSec * ONE_SECOND_MS : 0));
454
- return windowMs !== null && windowMs <= nowMs ? new Date(windowMs).toISOString() : new Date(nowMs).toISOString();
511
+ * window-based accounting. `tickMs` is the tick's own clock, not
512
+ * `Date.now()`: the two drift apart by however long the tick took to reach
513
+ * this task, and under an injected clock they are unrelated. */
514
+ function computeCurrentWindow(task, tickMs) {
515
+ return windowToIso(latestWindowAtOrBefore(task.schedule, tickMs)) ?? new Date(tickMs).toISOString();
455
516
  }
456
517
  function computeNextScheduledFor(schedule) {
457
- const coreSchedule = toCoreSchedule(schedule);
458
- const next = nextWindowAfter(coreSchedule, Date.now() + 1);
459
- return next !== null ? new Date(next).toISOString() : null;
460
- }
461
- function toCoreSchedule(schedule) {
462
- if (schedule.type === SCHEDULE_TYPES.interval) return {
463
- type: SCHEDULE_TYPES.interval,
464
- intervalSec: Math.round(schedule.intervalMs / ONE_SECOND_MS)
465
- };
466
- return schedule;
518
+ if (unfireableScheduleReason(schedule) !== null) return null;
519
+ return windowToIso(nextWindowAfter(toLibrarySchedule(schedule), Date.now() + 1));
467
520
  }
468
521
  //#endregion
469
522
  export { TASK_TRIGGERS, applyScheduleOverride, configureScheduler, createTaskManager, getSchedulerLogs, getSchedulerTaskState, getSchedulerTasks, initScheduler, recordExternalRun, resetSchedulerForTesting };
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/scheduler/task-manager.ts","../../src/scheduler/adapter.ts"],"sourcesContent":["// Generic dependency-ordered cron tick engine. Host-agnostic: the only\n// host coupling (a logger) is injected via options. Schedules are either\n// fixed intervals or a daily UTC time; tasks may declare a `dependsOn`\n// edge so an ordering like \"news fetch → journal → memory extraction\"\n// runs in sequence within one tick.\n\nimport type { MinimalLogger } from \"@mulmoclaude/common\";\nimport { SCHEDULE_TYPES } from \"@receptron/task-scheduler\";\n\nconst ONE_SECOND_MS = 1000;\nconst ONE_MINUTE_MS = 60 * ONE_SECOND_MS;\nconst ONE_HOUR_MS = 60 * ONE_MINUTE_MS;\n\n// Gap between the START of each independently-due task within one tick. When\n// many tasks come due at the same minute (system journal + feed-refresh + a few\n// user tasks all at 20:00 UTC), firing every chat in the same event-loop turn\n// floods the machine; the mulmoclaude MCP broker each chat spawns then boots\n// under contention and can lose the startup race to the CLI's first tool call,\n// so that turn fails with `handlePermission not found` (#2057). Spacing the\n// launches by a second gives each broker room to connect. Far under one tick\n// (a handful of tasks spread over a few seconds), so nothing is delayed past\n// its window.\nconst DEFAULT_FIRING_STAGGER_MS = ONE_SECOND_MS;\n\n// The total stagger must stay well inside one tick, or the tail tasks start in a\n// LATER tick window and two ticks overlap. Cap all starts to this fraction of\n// `tickMs` regardless of task count or the configured gap — this keeps a debug\n// `tickMs === firingStaggerMs` (see server boot) from degenerating.\nconst MAX_STAGGER_FRACTION_OF_TICK = 0.5;\n\nconst realSleep = (delayMs: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, delayMs));\n\n// Per-task stagger step: the configured gap, shrunk so the last of `count` tasks\n// still starts within MAX_STAGGER_FRACTION_OF_TICK of the tick. 0 disables it.\nfunction staggerStepMs(staggerMs: number, tickMs: number, count: number): number {\n if (staggerMs <= 0 || count <= 1) return 0;\n const maxStepMs = (tickMs * MAX_STAGGER_FRACTION_OF_TICK) / (count - 1);\n return Math.min(staggerMs, maxStepMs);\n}\n\n/** Minimal logger the engine logs through. Absent one, runs silent. */\nexport type SchedulerLogger = MinimalLogger;\n\nconst NOOP_LOG: SchedulerLogger = { info: () => {}, warn: () => {}, error: () => {} };\n\nexport type TaskSchedule = { type: typeof SCHEDULE_TYPES.interval; intervalMs: number } | { type: typeof SCHEDULE_TYPES.daily; time: string }; // time: \"HH:MM\" in UTC\n\nexport interface TaskRunContext {\n taskId: string;\n now: Date;\n}\n\nexport interface TaskDefinition {\n id: string;\n description?: string;\n schedule: TaskSchedule;\n enabled?: boolean; // default: true\n /** If set, this task only fires after the named task has completed\n * successfully in the current tick cycle. Enforces ordering like\n * \"news fetch → journal → memory extraction\". */\n dependsOn?: string;\n run: (ctx: TaskRunContext) => Promise<void>;\n}\n\nexport interface TaskSummary {\n id: string;\n description?: string | undefined;\n schedule: TaskSchedule;\n dependsOn?: string | undefined;\n}\n\nexport interface ITaskManager {\n registerTask: (def: TaskDefinition) => void;\n removeTask: (taskId: string) => void;\n /** Update the schedule of an existing task. Returns false if not found. */\n updateSchedule: (taskId: string, schedule: TaskSchedule) => boolean;\n start: () => void;\n stop: () => void;\n /** Run one tick manually (for testing). */\n tick: () => Promise<void>;\n listTasks: () => TaskSummary[];\n}\n\nexport interface TaskManagerOptions {\n tickMs?: number; // default: ONE_MINUTE_MS\n now?: () => Date; // default: () => new Date()\n log?: SchedulerLogger; // default: noop\n /** Gap between the start of each independently-due task in a tick (#2057).\n * Default DEFAULT_FIRING_STAGGER_MS; 0 fires them all at once. */\n firingStaggerMs?: number;\n /** Injected so tests advance time without real timers. Default setTimeout. */\n sleep?: (delayMs: number) => Promise<void>;\n}\n\nfunction isDue(now: Date, schedule: TaskSchedule, tickMs: number): boolean {\n if (schedule.type === SCHEDULE_TYPES.interval) {\n const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS;\n // Round down to tick boundary, then check if it aligns with the interval\n const rounded = Math.floor(msSinceMidnight / tickMs) * tickMs;\n return rounded % schedule.intervalMs === 0;\n }\n\n if (schedule.type === SCHEDULE_TYPES.daily) {\n const targetMs = dailyTargetMs(schedule.time);\n if (targetMs === null) return false;\n const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS;\n const rounded = Math.floor(msSinceMidnight / tickMs) * tickMs;\n return rounded === targetMs;\n }\n\n return false;\n}\n\n/** Milliseconds past UTC midnight for a `HH:MM` daily time, or null when the\n * string isn't that shape. `TaskSchedule.time` is a bare `string` on a\n * published package, so a host that skips its own validation must land on\n * \"never due\" rather than on a garbage firing time. */\nfunction dailyTargetMs(time: string): number | null {\n const [hours, minutes] = time.split(\":\").map(Number);\n if (hours === undefined || minutes === undefined) return null;\n if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;\n return hours * ONE_HOUR_MS + minutes * ONE_MINUTE_MS;\n}\n\n/** The `time` of a daily schedule this manager can never fire, or null when the\n * schedule is fine. Returning the offending value (not a boolean) is what lets\n * the caller name it in the log. */\nfunction unfireableDailyTime(schedule: TaskSchedule): string | null {\n if (schedule.type !== SCHEDULE_TYPES.daily) return null;\n return dailyTargetMs(schedule.time) === null ? schedule.time : null;\n}\n\n/** Say out loud that this schedule can never fire. The task is still accepted —\n * `isDue` answers false forever, which is the safe outcome — but silence here\n * is what made \"the task I scheduled has never run once\" a bug with no\n * evidence anywhere to start from (#2765). Deliberately not a throw: a\n * consumer whose task has been quietly dead would get a boot crash instead. */\nfunction reportUnfireable(log: SchedulerLogger, taskId: string, schedule: TaskSchedule): void {\n const time = unfireableDailyTime(schedule);\n if (time === null) return;\n log.error(\"daily time is not HH:MM — this task will never run\", { id: taskId, time });\n}\n\n/** Split the due tasks into those that may run immediately and those gated\n * behind a `dependsOn` edge (resolved later in the same tick cycle). */\nexport function collectDueTasks(\n currentTime: Date,\n registry: Map<string, TaskDefinition>,\n tickMs: number,\n): { independent: TaskDefinition[]; dependent: TaskDefinition[] } {\n const independent: TaskDefinition[] = [];\n const dependent: TaskDefinition[] = [];\n for (const def of registry.values()) {\n if (def.enabled === false) continue;\n if (!isDue(currentTime, def.schedule, tickMs)) continue;\n if (def.dependsOn) {\n dependent.push(def);\n } else {\n independent.push(def);\n }\n }\n return { independent, dependent };\n}\n\nasync function runAndTrack(def: TaskDefinition, currentTime: Date, succeeded: Set<string>, log: SchedulerLogger): Promise<void> {\n try {\n await def.run({ taskId: def.id, now: currentTime });\n succeeded.add(def.id);\n } catch (err) {\n log.error(\"task failed\", {\n id: def.id,\n error: String(err),\n });\n }\n}\n\nasync function runDependentChain(dependent: TaskDefinition[], currentTime: Date, succeeded: Set<string>, log: SchedulerLogger): Promise<void> {\n let remaining = [...dependent];\n let progress = true;\n while (remaining.length > 0 && progress) {\n progress = false;\n const next: TaskDefinition[] = [];\n for (const def of remaining) {\n const dep = def.dependsOn;\n if (!dep || !succeeded.has(dep)) {\n next.push(def);\n continue;\n }\n await runAndTrack(def, currentTime, succeeded, log);\n progress = true;\n }\n remaining = next;\n }\n}\n\ninterface TickConfig {\n tickMs: number;\n staggerMs: number;\n sleep: (delayMs: number) => Promise<void>;\n log: SchedulerLogger;\n}\n\nasync function runTick(now: () => Date, registry: Map<string, TaskDefinition>, cfg: TickConfig): Promise<void> {\n const currentTime = now();\n const { independent, dependent } = collectDueTasks(currentTime, registry, cfg.tickMs);\n\n // Per-invocation set — success does not leak across tick() calls.\n const succeeded = new Set<string>();\n\n // Staggered start (#2057), still concurrent: each fires after its own capped\n // delay but the tick awaits them all, preserving the previous \"all due tasks\n // ran this tick\" contract.\n const stepMs = staggerStepMs(cfg.staggerMs, cfg.tickMs, independent.length);\n await Promise.all(\n independent.map(async (def, index) => {\n if (stepMs > 0 && index > 0) await cfg.sleep(index * stepMs);\n await runAndTrack(def, currentTime, succeeded, cfg.log);\n }),\n );\n\n await runDependentChain(dependent, currentTime, succeeded, cfg.log);\n}\n\nexport function listTaskSummaries(registry: Map<string, TaskDefinition>): TaskSummary[] {\n return [...registry.values()].map((taskDef) => ({\n id: taskDef.id,\n description: taskDef.description,\n schedule: taskDef.schedule,\n dependsOn: taskDef.dependsOn,\n }));\n}\n\n// A tick runner that skips re-entry: if the previous tick is still draining its\n// staggered starts, drop this one so a slow tick + short tickMs can never run\n// two ticks concurrently.\nfunction makeGuardedTick(now: () => Date, registry: Map<string, TaskDefinition>, cfg: TickConfig): () => Promise<void> {\n let ticking = false;\n return async () => {\n if (ticking) return;\n ticking = true;\n try {\n await runTick(now, registry, cfg);\n } finally {\n ticking = false;\n }\n };\n}\n\nfunction resolveTickConfig(options?: TaskManagerOptions): { tickMs: number; now: () => Date; cfg: TickConfig } {\n const tickMs = options?.tickMs ?? ONE_MINUTE_MS;\n return {\n tickMs,\n now: options?.now ?? (() => new Date()),\n cfg: {\n tickMs,\n staggerMs: options?.firingStaggerMs ?? DEFAULT_FIRING_STAGGER_MS,\n sleep: options?.sleep ?? realSleep,\n log: options?.log ?? NOOP_LOG,\n },\n };\n}\n\nexport function createTaskManager(options?: TaskManagerOptions): ITaskManager {\n const { tickMs, now, cfg } = resolveTickConfig(options);\n const { log } = cfg;\n const registry = new Map<string, TaskDefinition>();\n let timer: ReturnType<typeof setInterval> | null = null;\n const onTick = makeGuardedTick(now, registry, cfg);\n\n return {\n async tick() {\n await onTick();\n },\n\n registerTask(def: TaskDefinition) {\n if (registry.has(def.id)) {\n throw new Error(`[task-manager] Task \"${def.id}\" is already registered`);\n }\n reportUnfireable(log, def.id, def.schedule);\n registry.set(def.id, def);\n log.info(\"registered\", { id: def.id });\n },\n\n updateSchedule(taskId: string, schedule: TaskSchedule): boolean {\n const def = registry.get(taskId);\n if (!def) return false;\n // Checked here as well as at registration: an override applied at run\n // time (`applyScheduleOverride`) never passes through `registerTask`.\n reportUnfireable(log, taskId, schedule);\n def.schedule = schedule;\n log.info(\"schedule updated\", { id: taskId });\n return true;\n },\n\n removeTask(taskId: string) {\n if (registry.delete(taskId)) {\n log.info(\"removed\", { id: taskId });\n }\n },\n\n start() {\n if (timer) return;\n // `onTick` guards re-entry but not failure — it has a `finally`, no\n // `catch` — so handing it straight to setInterval let a rejected tick\n // escape as an unhandled rejection, which the host turns into a process\n // exit. The scheduler must outlive one bad tick.\n timer = setInterval(() => {\n onTick().catch((err: unknown) => log.error(\"tick failed\", { error: String(err) }));\n }, tickMs);\n log.info(\"started\", { tickMs });\n },\n\n stop() {\n if (timer) {\n clearInterval(timer);\n timer = null;\n log.info(\"stopped\");\n }\n },\n\n listTasks() {\n return listTaskSummaries(registry);\n },\n };\n}\n","// Adapter that wires the pure scheduler library (@receptron/task-scheduler)\n// to a host's task-manager + workspace. Registers system tasks, runs\n// catch-up on startup, and persists execution state + logs.\n//\n// Host-agnostic: the workspace root, the atomic file writer, and the\n// logger are injected via `configureScheduler`. The host supplies its OWN\n// system tasks (journal / feeds / user-cron in MulmoClaude) to\n// `initScheduler` — the package owns no task definitions. Deliberately\n// thin: all complex scheduling logic lives in @receptron/task-scheduler.\n\nimport { existsSync } from \"node:fs\";\nimport { readFile, appendFile, mkdir } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport {\n type TaskSchedule,\n type TaskExecutionState,\n type TaskLogEntry,\n type CatchUpTask,\n type TaskTrigger,\n emptyState,\n computeCatchUpPlan,\n nextWindowAfter,\n loadState,\n updateAndSave,\n appendLogEntry,\n queryLog,\n SCHEDULE_TYPES,\n TASK_RESULTS,\n TASK_TRIGGERS,\n type MISSED_RUN_POLICIES,\n type StateMap,\n type StateDeps,\n type LogDeps,\n} from \"@receptron/task-scheduler\";\nimport type { ITaskManager, TaskDefinition, SchedulerLogger } from \"./task-manager.js\";\nimport { errorMessage } from \"../utils/errors.js\";\n\nconst ONE_SECOND_MS = 1000;\nconst SCHEDULER_CONFIG_DIR = \"config/scheduler\";\nconst SCHEDULER_DATA_DIR = \"data/scheduler/logs\";\n\n// ── Host injection ────────────────────────────────────────────────\n\nexport interface SchedulerConfig {\n /** Absolute workspace root — state.json + logs hang off it. */\n workspaceRoot: string;\n /** Host atomic file writer (used with `uniqueTmp` for the state file). */\n writeFileAtomic: (filePath: string, content: string, opts: { uniqueTmp: boolean }) => Promise<void>;\n /** Optional logger. */\n log?: SchedulerLogger;\n}\n\nconst NOOP_LOG: SchedulerLogger = { info: () => {}, warn: () => {}, error: () => {} };\n\nlet config: SchedulerConfig | null = null;\n\n/** Wire the adapter to a host. Call once at startup, before `initScheduler`. */\nexport function configureScheduler(injected: SchedulerConfig): void {\n config = injected;\n}\n\nfunction requireConfig(): SchedulerConfig {\n if (!config) throw new Error(\"scheduler: configureScheduler() not called\");\n return config;\n}\n\nfunction logger(): SchedulerLogger {\n return config?.log ?? NOOP_LOG;\n}\n\n// ── Paths ─────────────────────────────────────────────────────────\n\nfunction stateFilePath(): string {\n return path.join(requireConfig().workspaceRoot, SCHEDULER_CONFIG_DIR, \"state.json\");\n}\n\nfunction logsDir(): string {\n return path.join(requireConfig().workspaceRoot, SCHEDULER_DATA_DIR);\n}\n\n// ── I/O deps (real filesystem) ────────────────────────────────────\n\nfunction stateDeps(): StateDeps {\n return {\n readFile: (filePath: string) => readFile(filePath, \"utf-8\"),\n writeFileAtomic: (filePath: string, content: string) => requireConfig().writeFileAtomic(filePath, content, { uniqueTmp: true }),\n exists: existsSync,\n };\n}\n\nconst logDeps: LogDeps = {\n appendFile: (filePath: string, content: string) => appendFile(filePath, content),\n readFile: (filePath: string) => readFile(filePath, \"utf-8\"),\n exists: existsSync,\n ensureDir: (directoryPath: string) => mkdir(directoryPath, { recursive: true }).then(() => {}),\n};\n\n// ── System task registry ──────────────────────────────────────────\n\nexport interface SystemTaskDef {\n id: string;\n name: string;\n description: string;\n schedule: TaskDefinition[\"schedule\"];\n missedRunPolicy: typeof MISSED_RUN_POLICIES.skip | typeof MISSED_RUN_POLICIES.runOnce | typeof MISSED_RUN_POLICIES.runAll;\n run: () => Promise<void>;\n}\n\n// ── Public API ────────────────────────────────────────────────────\n\nlet stateMap: StateMap = new Map();\nconst systemTasks: SystemTaskDef[] = [];\nlet taskManagerRef: ITaskManager | null = null;\n\n/**\n * Initialize the scheduler adapter. Call once at server startup AFTER the\n * task-manager is created but BEFORE `taskManager.start()`.\n */\nexport async function initScheduler(taskManager: ITaskManager, tasks: SystemTaskDef[]): Promise<void> {\n await mkdir(path.dirname(stateFilePath()), { recursive: true });\n await mkdir(logsDir(), { recursive: true });\n\n stateMap = await loadState(stateFilePath(), stateDeps());\n systemTasks.length = 0;\n systemTasks.push(...tasks);\n taskManagerRef = taskManager;\n\n // Run catch-up\n const catchUpTasks: CatchUpTask[] = tasks.map((taskDef) => ({\n id: taskDef.id,\n name: taskDef.name,\n schedule: toCoreSchedule(taskDef.schedule),\n missedRunPolicy: taskDef.missedRunPolicy,\n enabled: true,\n }));\n const plan = computeCatchUpPlan(catchUpTasks, stateMap, Date.now());\n\n for (const skip of plan.skipped) {\n logger().info(\"catch-up skipped\", { taskId: skip.taskId, windows: skip.windowCount });\n await safeUpdateState(skip.taskId, { lastRunAt: skip.lastWindow });\n }\n\n if (plan.runs.length > 0) {\n logger().info(\"catch-up enqueued\", { runs: plan.runs.length });\n for (const run of plan.runs) {\n const task = tasks.find((taskDef) => taskDef.id === run.taskId);\n if (!task) continue;\n await executeAndLog(task, run.context.scheduledFor, TASK_TRIGGERS.catchUp);\n }\n }\n\n // Register with task-manager for ongoing ticks\n for (const task of tasks) {\n taskManager.registerTask({\n id: task.id,\n description: task.description,\n schedule: task.schedule,\n run: async () => {\n const windowIso = computeCurrentWindow(task);\n await executeAndLog(task, windowIso, TASK_TRIGGERS.scheduled);\n },\n });\n }\n\n logger().info(\"initialized\", { tasks: tasks.map((taskDef) => taskDef.id), stateEntries: stateMap.size });\n}\n\n/** Apply a schedule override to a running system task. Updates the\n * in-memory task definition, the task-manager, and recalculates\n * nextScheduledAt in persisted state. */\nexport async function applyScheduleOverride(taskId: string, schedule: SystemTaskDef[\"schedule\"]): Promise<boolean> {\n const task = systemTasks.find((taskDef) => taskDef.id === taskId);\n if (!task || !taskManagerRef) return false;\n if (!taskManagerRef.updateSchedule(taskId, schedule)) return false;\n task.schedule = schedule;\n\n // Recalculate next window so the UI reflects the new schedule\n const nextScheduledAt = computeNextScheduledFor(task.schedule);\n await safeUpdateState(taskId, { nextScheduledAt });\n\n return true;\n}\n\n/** Query execution logs — used by API routes. */\nexport async function getSchedulerLogs(opts: { since?: string | undefined; taskId?: string | undefined; limit?: number | undefined }): Promise<TaskLogEntry[]> {\n return queryLog(logsDir(), opts, logDeps);\n}\n\n/** Get all task states — used by API routes. */\nexport function getSchedulerTasks(): {\n id: string;\n name: string;\n description: string;\n schedule: TaskDefinition[\"schedule\"];\n missedRunPolicy: string;\n state: TaskExecutionState;\n}[] {\n return systemTasks.map((taskDef) => ({\n id: taskDef.id,\n name: taskDef.name,\n description: taskDef.description,\n schedule: taskDef.schedule,\n missedRunPolicy: taskDef.missedRunPolicy,\n state: stateMap.get(taskDef.id) ?? emptyState(taskDef.id),\n }));\n}\n\n/** Read the persisted execution state for any task id (system, user, or\n * skill). Returns an empty state when the id has never run — used by the API\n * route to attach last-run / next-run / history state to user + skill tasks\n * it lists from other sources. */\nexport function getSchedulerTaskState(taskId: string): TaskExecutionState {\n return stateMap.get(taskId) ?? emptyState(taskId);\n}\n\n/** Record a run of an EXTERNAL (skill / user) task — one registered directly\n * on the task-manager rather than through `initScheduler`. Persists a log\n * entry + updates state so these tasks get the same history / last-run /\n * next-run as system tasks. Because they are fire-and-forget (`startChat`\n * spawns an async chat), `errorMessage` reflects whether the *dispatch*\n * succeeded, not the chat's eventual outcome; `chatSessionId` links the run\n * to the spawned session. */\nexport async function recordExternalRun(params: {\n id: string;\n name: string;\n schedule: TaskDefinition[\"schedule\"];\n scheduledFor: string;\n startedAt: string;\n durationMs: number;\n trigger: TaskTrigger;\n errorMessage: string | null;\n chatSessionId?: string | undefined;\n}): Promise<void> {\n await persistRun({\n meta: { id: params.id, name: params.name, schedule: params.schedule },\n scheduledFor: params.scheduledFor,\n startedAt: params.startedAt,\n durationMs: params.durationMs,\n trigger: params.trigger,\n errMsg: params.errorMessage,\n chatSessionId: params.chatSessionId,\n });\n}\n\n/** Test-only: clear config + in-memory state. */\nexport function resetSchedulerForTesting(): void {\n config = null;\n stateMap = new Map();\n systemTasks.length = 0;\n taskManagerRef = null;\n}\n\n// ── Internal ──────────────────────────────────────────────────────\n\nasync function executeAndLog(task: SystemTaskDef, scheduledFor: string, trigger: TaskTrigger): Promise<void> {\n const startedAt = new Date().toISOString();\n const startMs = Date.now();\n let errMsg: string | null = null;\n try {\n await task.run();\n } catch (err) {\n errMsg = errorMessage(err);\n logger().error(\"task failed\", { taskId: task.id, error: errMsg });\n }\n const durationMs = Date.now() - startMs;\n // Persistence is best-effort — never let disk failures propagate to the\n // tick loop or abort startup catch-up.\n await persistRun({ meta: { id: task.id, name: task.name, schedule: task.schedule }, scheduledFor, startedAt, durationMs, trigger, errMsg });\n}\n\n/** The minimum a run needs to persist state + a log entry — shared by system\n * tasks (`executeAndLog`) and external skill/user runs (`recordExternalRun`). */\ninterface TaskRunMeta {\n id: string;\n name: string;\n schedule: TaskDefinition[\"schedule\"];\n}\n\ninterface RunRecord {\n meta: TaskRunMeta;\n scheduledFor: string;\n startedAt: string;\n durationMs: number;\n trigger: TaskTrigger;\n errMsg: string | null;\n chatSessionId?: string | undefined;\n}\n\n/** Best-effort persistence — state and log are independent, so one failing\n * never blocks the other and neither propagates upward. */\nasync function persistRun(run: RunRecord): Promise<void> {\n await writeRunState(run);\n await writeRunLog(run);\n}\n\nasync function writeRunState(run: RunRecord): Promise<void> {\n const { meta, scheduledFor, durationMs, errMsg } = run;\n const isSuccess = errMsg === null;\n const currentState = stateMap.get(meta.id);\n try {\n await updateAndSave(\n stateFilePath(),\n stateMap,\n meta.id,\n {\n lastRunAt: scheduledFor,\n lastRunResult: isSuccess ? TASK_RESULTS.success : TASK_RESULTS.error,\n lastRunDurationMs: durationMs,\n lastErrorMessage: errMsg,\n consecutiveFailures: isSuccess ? 0 : (currentState?.consecutiveFailures ?? 0) + 1,\n totalRuns: (currentState?.totalRuns ?? 0) + 1,\n nextScheduledAt: computeNextScheduledFor(meta.schedule),\n },\n stateDeps(),\n );\n } catch (err) {\n logger().warn(\"state persistence failed\", { taskId: meta.id, error: String(err) });\n }\n}\n\nasync function writeRunLog(run: RunRecord): Promise<void> {\n const { meta, scheduledFor, startedAt, durationMs, trigger, errMsg, chatSessionId } = run;\n const isSuccess = errMsg === null;\n try {\n await appendLogEntry(\n logsDir(),\n {\n taskId: meta.id,\n taskName: meta.name,\n scheduledFor,\n startedAt,\n completedAt: new Date().toISOString(),\n result: isSuccess ? TASK_RESULTS.success : TASK_RESULTS.error,\n durationMs,\n trigger,\n ...(errMsg !== null && { errorMessage: errMsg }),\n ...(chatSessionId !== undefined && { chatSessionId }),\n },\n logDeps,\n );\n } catch (err) {\n logger().warn(\"log persistence failed\", { taskId: meta.id, error: String(err) });\n }\n}\n\n/** Safe state update — swallows errors. */\nasync function safeUpdateState(taskId: string, patch: Partial<TaskExecutionState>): Promise<void> {\n try {\n await updateAndSave(stateFilePath(), stateMap, taskId, patch, stateDeps());\n } catch (err) {\n logger().warn(\"state update failed\", { taskId, error: String(err) });\n }\n}\n\n/** Compute the window boundary that the current tick belongs to. For\n * scheduled runs, this is the epoch-aligned window — not the wall-clock\n * time of execution. This keeps lastRunAt consistent with catch-up's\n * window-based accounting. */\nfunction computeCurrentWindow(task: SystemTaskDef): string {\n const coreSchedule = toCoreSchedule(task.schedule);\n // The window that just fired is the latest one at or before now.\n const nowMs = Date.now();\n const windowMs = nextWindowAfter(coreSchedule, nowMs - (coreSchedule.type === SCHEDULE_TYPES.interval ? coreSchedule.intervalSec * ONE_SECOND_MS : 0));\n return windowMs !== null && windowMs <= nowMs ? new Date(windowMs).toISOString() : new Date(nowMs).toISOString();\n}\n\nfunction computeNextScheduledFor(schedule: TaskDefinition[\"schedule\"]): string | null {\n const coreSchedule = toCoreSchedule(schedule);\n const next = nextWindowAfter(coreSchedule, Date.now() + 1);\n return next !== null ? new Date(next).toISOString() : null;\n}\n\nfunction toCoreSchedule(schedule: TaskDefinition[\"schedule\"]): TaskSchedule {\n if (schedule.type === SCHEDULE_TYPES.interval) {\n return {\n type: SCHEDULE_TYPES.interval,\n intervalSec: Math.round(schedule.intervalMs / ONE_SECOND_MS),\n };\n }\n return schedule;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/scheduler/schedule-window.ts","../../src/scheduler/task-manager.ts","../../src/scheduler/adapter.ts"],"sourcesContent":["// One definition of \"when does this schedule fire\". The host expresses\n// intervals in milliseconds and the library (@receptron/task-scheduler) in\n// seconds, so every place that answers \"is it due now?\", \"when is the next\n// run?\" or \"which windows were missed?\" has to cross that boundary. Doing it\n// twice is what let the tick engine drift away from `nextWindowAfter`: its own\n// interval check counted from UTC midnight, so anything longer than a day\n// collapsed to \"every day at 00:00\" while the UI kept showing the real,\n// epoch-anchored next run (#2937).\n\nimport { SCHEDULE_TYPES, isDueAt, nextWindowAfter, parseTimeToMs, type TaskSchedule as LibrarySchedule } from \"@receptron/task-scheduler\";\n\nconst ONE_SECOND_MS = 1000;\nconst ONE_DAY_MS = 24 * 60 * 60 * ONE_SECOND_MS;\n\n/** A schedule as the host states it: intervals in milliseconds, or a daily\n * `HH:MM` UTC time. */\nexport type TaskSchedule = { type: typeof SCHEDULE_TYPES.interval; intervalMs: number } | { type: typeof SCHEDULE_TYPES.daily; time: string };\n\n/** An interval this engine can never turn into a window. Zero divides by zero\n * inside the library's `Math.ceil(afterMs / intervalMs)`; a negative or\n * non-finite one poisons the arithmetic with NaN, which compares false\n * everywhere and so hides itself. */\nfunction isUsableInterval(intervalMs: number): boolean {\n return Number.isFinite(intervalMs) && intervalMs > 0;\n}\n\n/** Milliseconds past UTC midnight for a daily `HH:MM`, or null when the string\n * does not name a time of day. Out-of-range values are rejected, not clamped\n * and not wrapped: `\"24:00\"` would otherwise land on the NEXT day's midnight\n * and `\"-1:00\"` on an hour before it, so a typo that used to be inert would\n * start firing — at 00:00 daily, or 61 times a day. */\nfunction dailyOffsetMs(time: string): number | null {\n const offsetMs = parseTimeToMs(time);\n return Number.isFinite(offsetMs) && offsetMs >= 0 && offsetMs < ONE_DAY_MS ? offsetMs : null;\n}\n\n/** Convert to the library's schedule shape. Milliseconds are divided, never\n * rounded: rounding a sub-second interval to `intervalSec: 0` is what made\n * `nextWindowAfter` answer NaN and `new Date(NaN).toISOString()` throw. */\nexport function toLibrarySchedule(schedule: TaskSchedule): LibrarySchedule {\n if (schedule.type === SCHEDULE_TYPES.interval) {\n return { type: SCHEDULE_TYPES.interval, intervalSec: schedule.intervalMs / ONE_SECOND_MS };\n }\n return { type: SCHEDULE_TYPES.daily, time: schedule.time };\n}\n\n/** True when a scheduled window falls inside the tick ending at `nowMs`.\n * Windows are anchored to the epoch, not to the current UTC day, so an\n * interval longer than 24h keeps its real period. */\nexport function isScheduleDueAt(schedule: TaskSchedule, nowMs: number, tickMs: number): boolean {\n if (unfireableScheduleReason(schedule) !== null) return false;\n return isDueAt(toLibrarySchedule(schedule), nowMs, tickMs);\n}\n\n/** The furthest instant `Date` can represent. A window past it is finite\n * arithmetic but not a date, and `toISOString()` answers that with a throw —\n * which, inside the state writer, silently loses the whole run record. */\nconst MAX_DATE_MS = 8.64e15;\n\n/** A window as an ISO timestamp, or null when the number is not a date. */\nexport function windowToIso(windowMs: number | null): string | null {\n if (windowMs === null || !Number.isFinite(windowMs) || Math.abs(windowMs) > MAX_DATE_MS) return null;\n return new Date(windowMs).toISOString();\n}\n\n/** The most recent window at or before `nowMs`, or null when the schedule has\n * none. `nextWindowAfter` only ever looks FORWARD, so asking it from\n * `nowMs - one period` answers the previous window whenever `nowMs` sits\n * exactly on a boundary, and a daily schedule read after its minute answers\n * TOMORROW. Both schedule kinds have a fixed period, so stepping one period\n * back off the next window is exact. */\nexport function latestWindowAtOrBefore(schedule: TaskSchedule, nowMs: number): number | null {\n if (unfireableScheduleReason(schedule) !== null) return null;\n const next = nextWindowAfter(toLibrarySchedule(schedule), nowMs);\n if (next === null || !Number.isFinite(next)) return null;\n if (next <= nowMs) return next;\n const periodMs = schedule.type === SCHEDULE_TYPES.interval ? schedule.intervalMs : ONE_DAY_MS;\n return next - periodMs;\n}\n\n/** Name the field that makes a schedule unfireable, or null when it is fine.\n * Returns the offending value (not a boolean) so the caller can put it in the\n * log — a task that silently never runs leaves no evidence to start from\n * (#2765). */\nexport function unfireableScheduleReason(schedule: TaskSchedule): { field: string; value: string } | null {\n if (schedule.type === SCHEDULE_TYPES.interval) {\n return isUsableInterval(schedule.intervalMs) ? null : { field: \"intervalMs\", value: String(schedule.intervalMs) };\n }\n return dailyOffsetMs(schedule.time) !== null ? null : { field: \"time\", value: schedule.time };\n}\n","// Generic dependency-ordered cron tick engine. Host-agnostic: the only\n// host coupling (a logger) is injected via options. Schedules are either\n// fixed intervals or a daily UTC time; tasks may declare a `dependsOn`\n// edge so an ordering like \"news fetch → journal → memory extraction\"\n// runs in sequence within one tick.\n\nimport type { MinimalLogger } from \"@mulmoclaude/common\";\nimport { isScheduleDueAt, unfireableScheduleReason, type TaskSchedule } from \"./schedule-window.js\";\n\nconst ONE_SECOND_MS = 1000;\nconst ONE_MINUTE_MS = 60 * ONE_SECOND_MS;\n\n// Gap between the START of each independently-due task within one tick. When\n// many tasks come due at the same minute (system journal + feed-refresh + a few\n// user tasks all at 20:00 UTC), firing every chat in the same event-loop turn\n// floods the machine; the mulmoclaude MCP broker each chat spawns then boots\n// under contention and can lose the startup race to the CLI's first tool call,\n// so that turn fails with `handlePermission not found` (#2057). Spacing the\n// launches by a second gives each broker room to connect. Far under one tick\n// (a handful of tasks spread over a few seconds), so nothing is delayed past\n// its window.\nconst DEFAULT_FIRING_STAGGER_MS = ONE_SECOND_MS;\n\n// The total stagger must stay well inside one tick, or the tail tasks start in a\n// LATER tick window and two ticks overlap. Cap all starts to this fraction of\n// `tickMs` regardless of task count or the configured gap — this keeps a debug\n// `tickMs === firingStaggerMs` (see server boot) from degenerating.\nconst MAX_STAGGER_FRACTION_OF_TICK = 0.5;\n\nconst realSleep = (delayMs: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, delayMs));\n\n// Per-task stagger step: the configured gap, shrunk so the last of `count` tasks\n// still starts within MAX_STAGGER_FRACTION_OF_TICK of the tick. 0 disables it.\nfunction staggerStepMs(staggerMs: number, tickMs: number, count: number): number {\n if (staggerMs <= 0 || count <= 1) return 0;\n const maxStepMs = (tickMs * MAX_STAGGER_FRACTION_OF_TICK) / (count - 1);\n return Math.min(staggerMs, maxStepMs);\n}\n\n/** Minimal logger the engine logs through. Absent one, runs silent. */\nexport type SchedulerLogger = MinimalLogger;\n\nconst NOOP_LOG: SchedulerLogger = { info: () => {}, warn: () => {}, error: () => {} };\n\n// Re-exported, not redefined: the schedule shape belongs with the window\n// arithmetic that interprets it, and hosts have always imported it from here.\nexport type { TaskSchedule } from \"./schedule-window.js\";\n\nexport interface TaskRunContext {\n taskId: string;\n now: Date;\n}\n\nexport interface TaskDefinition {\n id: string;\n description?: string;\n schedule: TaskSchedule;\n enabled?: boolean; // default: true\n /** If set, this task only fires after the named task has completed\n * successfully in the current tick cycle. Enforces ordering like\n * \"news fetch → journal → memory extraction\". */\n dependsOn?: string;\n run: (ctx: TaskRunContext) => Promise<void>;\n}\n\nexport interface TaskSummary {\n id: string;\n description?: string | undefined;\n schedule: TaskSchedule;\n dependsOn?: string | undefined;\n}\n\nexport interface ITaskManager {\n registerTask: (def: TaskDefinition) => void;\n removeTask: (taskId: string) => void;\n /** Update the schedule of an existing task. Returns false if not found. */\n updateSchedule: (taskId: string, schedule: TaskSchedule) => boolean;\n start: () => void;\n stop: () => void;\n /** Run one tick manually (for testing). */\n tick: () => Promise<void>;\n listTasks: () => TaskSummary[];\n}\n\nexport interface TaskManagerOptions {\n tickMs?: number; // default: ONE_MINUTE_MS\n now?: () => Date; // default: () => new Date()\n log?: SchedulerLogger; // default: noop\n /** Gap between the start of each independently-due task in a tick (#2057).\n * Default DEFAULT_FIRING_STAGGER_MS; 0 fires them all at once. */\n firingStaggerMs?: number;\n /** Injected so tests advance time without real timers. Default setTimeout. */\n sleep?: (delayMs: number) => Promise<void>;\n}\n\nfunction isDue(now: Date, schedule: TaskSchedule, tickMs: number): boolean {\n return isScheduleDueAt(schedule, now.getTime(), tickMs);\n}\n\n/** Say out loud that this schedule can never fire. The task is still accepted —\n * `isDue` answers false forever, which is the safe outcome — but silence here\n * is what made \"the task I scheduled has never run once\" a bug with no\n * evidence anywhere to start from (#2765). Deliberately not a throw: a\n * consumer whose task has been quietly dead would get a boot crash instead. */\nfunction reportUnfireable(log: SchedulerLogger, taskId: string, schedule: TaskSchedule): void {\n const reason = unfireableScheduleReason(schedule);\n if (reason === null) return;\n log.error(`schedule has an unusable ${reason.field} — this task will never run`, { id: taskId, [reason.field]: reason.value });\n}\n\n/** Split the due tasks into those that may run immediately and those gated\n * behind a `dependsOn` edge (resolved later in the same tick cycle). */\nexport function collectDueTasks(\n currentTime: Date,\n registry: Map<string, TaskDefinition>,\n tickMs: number,\n): { independent: TaskDefinition[]; dependent: TaskDefinition[] } {\n const independent: TaskDefinition[] = [];\n const dependent: TaskDefinition[] = [];\n for (const def of registry.values()) {\n if (def.enabled === false) continue;\n if (!isDue(currentTime, def.schedule, tickMs)) continue;\n if (def.dependsOn) {\n dependent.push(def);\n } else {\n independent.push(def);\n }\n }\n return { independent, dependent };\n}\n\nasync function runAndTrack(def: TaskDefinition, currentTime: Date, succeeded: Set<string>, log: SchedulerLogger): Promise<void> {\n try {\n await def.run({ taskId: def.id, now: currentTime });\n succeeded.add(def.id);\n } catch (err) {\n log.error(\"task failed\", {\n id: def.id,\n error: String(err),\n });\n }\n}\n\nasync function runDependentChain(dependent: TaskDefinition[], currentTime: Date, succeeded: Set<string>, log: SchedulerLogger): Promise<void> {\n let remaining = [...dependent];\n let progress = true;\n while (remaining.length > 0 && progress) {\n progress = false;\n const next: TaskDefinition[] = [];\n for (const def of remaining) {\n const dep = def.dependsOn;\n if (!dep || !succeeded.has(dep)) {\n next.push(def);\n continue;\n }\n await runAndTrack(def, currentTime, succeeded, log);\n progress = true;\n }\n remaining = next;\n }\n}\n\ninterface TickConfig {\n tickMs: number;\n staggerMs: number;\n sleep: (delayMs: number) => Promise<void>;\n log: SchedulerLogger;\n}\n\nasync function runTick(now: () => Date, registry: Map<string, TaskDefinition>, cfg: TickConfig): Promise<void> {\n const currentTime = now();\n const { independent, dependent } = collectDueTasks(currentTime, registry, cfg.tickMs);\n\n // Per-invocation set — success does not leak across tick() calls.\n const succeeded = new Set<string>();\n\n // Staggered start (#2057), still concurrent: each fires after its own capped\n // delay but the tick awaits them all, preserving the previous \"all due tasks\n // ran this tick\" contract.\n const stepMs = staggerStepMs(cfg.staggerMs, cfg.tickMs, independent.length);\n await Promise.all(\n independent.map(async (def, index) => {\n if (stepMs > 0 && index > 0) await cfg.sleep(index * stepMs);\n await runAndTrack(def, currentTime, succeeded, cfg.log);\n }),\n );\n\n await runDependentChain(dependent, currentTime, succeeded, cfg.log);\n}\n\nexport function listTaskSummaries(registry: Map<string, TaskDefinition>): TaskSummary[] {\n return [...registry.values()].map((taskDef) => ({\n id: taskDef.id,\n description: taskDef.description,\n schedule: taskDef.schedule,\n dependsOn: taskDef.dependsOn,\n }));\n}\n\n// A tick runner that skips re-entry: if the previous tick is still draining its\n// staggered starts, drop this one so a slow tick + short tickMs can never run\n// two ticks concurrently.\nfunction makeGuardedTick(now: () => Date, registry: Map<string, TaskDefinition>, cfg: TickConfig): () => Promise<void> {\n let ticking = false;\n return async () => {\n if (ticking) return;\n ticking = true;\n try {\n await runTick(now, registry, cfg);\n } finally {\n ticking = false;\n }\n };\n}\n\nfunction resolveTickConfig(options?: TaskManagerOptions): { tickMs: number; now: () => Date; cfg: TickConfig } {\n const tickMs = options?.tickMs ?? ONE_MINUTE_MS;\n return {\n tickMs,\n now: options?.now ?? (() => new Date()),\n cfg: {\n tickMs,\n staggerMs: options?.firingStaggerMs ?? DEFAULT_FIRING_STAGGER_MS,\n sleep: options?.sleep ?? realSleep,\n log: options?.log ?? NOOP_LOG,\n },\n };\n}\n\nexport function createTaskManager(options?: TaskManagerOptions): ITaskManager {\n const { tickMs, now, cfg } = resolveTickConfig(options);\n const { log } = cfg;\n const registry = new Map<string, TaskDefinition>();\n let timer: ReturnType<typeof setInterval> | null = null;\n const onTick = makeGuardedTick(now, registry, cfg);\n\n return {\n async tick() {\n await onTick();\n },\n\n registerTask(def: TaskDefinition) {\n if (registry.has(def.id)) {\n throw new Error(`[task-manager] Task \"${def.id}\" is already registered`);\n }\n reportUnfireable(log, def.id, def.schedule);\n registry.set(def.id, def);\n log.info(\"registered\", { id: def.id });\n },\n\n updateSchedule(taskId: string, schedule: TaskSchedule): boolean {\n const def = registry.get(taskId);\n if (!def) return false;\n // Checked here as well as at registration: an override applied at run\n // time (`applyScheduleOverride`) never passes through `registerTask`.\n reportUnfireable(log, taskId, schedule);\n def.schedule = schedule;\n log.info(\"schedule updated\", { id: taskId });\n return true;\n },\n\n removeTask(taskId: string) {\n if (registry.delete(taskId)) {\n log.info(\"removed\", { id: taskId });\n }\n },\n\n start() {\n if (timer) return;\n // `onTick` guards re-entry but not failure — it has a `finally`, no\n // `catch` — so handing it straight to setInterval let a rejected tick\n // escape as an unhandled rejection, which the host turns into a process\n // exit. The scheduler must outlive one bad tick.\n timer = setInterval(() => {\n onTick().catch((err: unknown) => log.error(\"tick failed\", { error: String(err) }));\n }, tickMs);\n log.info(\"started\", { tickMs });\n },\n\n stop() {\n if (timer) {\n clearInterval(timer);\n timer = null;\n log.info(\"stopped\");\n }\n },\n\n listTasks() {\n return listTaskSummaries(registry);\n },\n };\n}\n","// Adapter that wires the pure scheduler library (@receptron/task-scheduler)\n// to a host's task-manager + workspace. Registers system tasks, runs\n// catch-up on startup, and persists execution state + logs.\n//\n// Host-agnostic: the workspace root, the atomic file writer, and the\n// logger are injected via `configureScheduler`. The host supplies its OWN\n// system tasks (journal / feeds / user-cron in MulmoClaude) to\n// `initScheduler` — the package owns no task definitions. Deliberately\n// thin: all complex scheduling logic lives in @receptron/task-scheduler.\n\nimport { existsSync } from \"node:fs\";\nimport { readFile, appendFile, mkdir } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport {\n type TaskExecutionState,\n type TaskLogEntry,\n type CatchUpTask,\n type TaskTrigger,\n emptyState,\n computeCatchUpPlan,\n nextWindowAfter,\n loadState,\n updateAndSave,\n appendLogEntry,\n queryLog,\n TASK_RESULTS,\n TASK_TRIGGERS,\n type MISSED_RUN_POLICIES,\n type StateMap,\n type StateDeps,\n type LogDeps,\n} from \"@receptron/task-scheduler\";\nimport type { ITaskManager, TaskDefinition, SchedulerLogger } from \"./task-manager.js\";\nimport { latestWindowAtOrBefore, toLibrarySchedule, unfireableScheduleReason, windowToIso } from \"./schedule-window.js\";\nimport { errorMessage } from \"../utils/errors.js\";\n\nconst SCHEDULER_CONFIG_DIR = \"config/scheduler\";\nconst SCHEDULER_DATA_DIR = \"data/scheduler/logs\";\n\n// ── Host injection ────────────────────────────────────────────────\n\nexport interface SchedulerConfig {\n /** Absolute workspace root — state.json + logs hang off it. */\n workspaceRoot: string;\n /** Host atomic file writer (used with `uniqueTmp` for the state file). */\n writeFileAtomic: (filePath: string, content: string, opts: { uniqueTmp: boolean }) => Promise<void>;\n /** Optional logger. */\n log?: SchedulerLogger;\n}\n\nconst NOOP_LOG: SchedulerLogger = { info: () => {}, warn: () => {}, error: () => {} };\n\nlet config: SchedulerConfig | null = null;\n\n/** Wire the adapter to a host. Call once at startup, before `initScheduler`. */\nexport function configureScheduler(injected: SchedulerConfig): void {\n config = injected;\n}\n\nfunction requireConfig(): SchedulerConfig {\n if (!config) throw new Error(\"scheduler: configureScheduler() not called\");\n return config;\n}\n\nfunction logger(): SchedulerLogger {\n return config?.log ?? NOOP_LOG;\n}\n\n// ── Paths ─────────────────────────────────────────────────────────\n\nfunction stateFilePath(): string {\n return path.join(requireConfig().workspaceRoot, SCHEDULER_CONFIG_DIR, \"state.json\");\n}\n\nfunction logsDir(): string {\n return path.join(requireConfig().workspaceRoot, SCHEDULER_DATA_DIR);\n}\n\n// ── I/O deps (real filesystem) ────────────────────────────────────\n\nfunction stateDeps(): StateDeps {\n return {\n readFile: (filePath: string) => readFile(filePath, \"utf-8\"),\n writeFileAtomic: (filePath: string, content: string) => requireConfig().writeFileAtomic(filePath, content, { uniqueTmp: true }),\n exists: existsSync,\n };\n}\n\nconst logDeps: LogDeps = {\n appendFile: (filePath: string, content: string) => appendFile(filePath, content),\n readFile: (filePath: string) => readFile(filePath, \"utf-8\"),\n exists: existsSync,\n ensureDir: (directoryPath: string) => mkdir(directoryPath, { recursive: true }).then(() => {}),\n};\n\n// ── System task registry ──────────────────────────────────────────\n\nexport interface SystemTaskDef {\n id: string;\n name: string;\n description: string;\n schedule: TaskDefinition[\"schedule\"];\n missedRunPolicy: typeof MISSED_RUN_POLICIES.skip | typeof MISSED_RUN_POLICIES.runOnce | typeof MISSED_RUN_POLICIES.runAll;\n run: () => Promise<void>;\n}\n\n// ── Public API ────────────────────────────────────────────────────\n\nlet stateMap: StateMap = new Map();\nconst systemTasks: SystemTaskDef[] = [];\nlet taskManagerRef: ITaskManager | null = null;\n\n/**\n * Initialize the scheduler adapter. Call once at server startup AFTER the\n * task-manager is created but BEFORE `taskManager.start()`.\n */\nexport async function initScheduler(taskManager: ITaskManager, tasks: SystemTaskDef[]): Promise<void> {\n await mkdir(path.dirname(stateFilePath()), { recursive: true });\n await mkdir(logsDir(), { recursive: true });\n\n stateMap = await loadState(stateFilePath(), stateDeps());\n systemTasks.length = 0;\n systemTasks.push(...tasks);\n taskManagerRef = taskManager;\n\n const plan = computeCatchUpPlan(catchUpTasksFor(tasks), stateMap, Date.now());\n\n for (const skip of plan.skipped) {\n logger().info(\"catch-up skipped\", { taskId: skip.taskId, windows: skip.windowCount });\n await safeUpdateState(skip.taskId, { lastRunAt: skip.lastWindow });\n }\n\n if (plan.runs.length > 0) {\n logger().info(\"catch-up enqueued\", { runs: plan.runs.length });\n for (const run of plan.runs) {\n const task = tasks.find((taskDef) => taskDef.id === run.taskId);\n if (!task) continue;\n await executeAndLog(task, run.context.scheduledFor, TASK_TRIGGERS.catchUp);\n }\n }\n\n // Register with task-manager for ongoing ticks\n for (const task of tasks) {\n taskManager.registerTask({\n id: task.id,\n description: task.description,\n schedule: task.schedule,\n run: async (ctx) => {\n const windowIso = computeCurrentWindow(task, ctx.now.getTime());\n await executeAndLog(task, windowIso, TASK_TRIGGERS.scheduled);\n },\n });\n }\n\n logger().info(\"initialized\", { tasks: tasks.map((taskDef) => taskDef.id), stateEntries: stateMap.size });\n}\n\n/** The catch-up input, with the tasks that can never fire left out. Their\n * windows come back NaN rather than null, which `listMissedWindows` cannot\n * see the end of — it fills to its cap and the first `new Date(NaN)` throws\n * `RangeError`, taking every other task's catch-up down with it at startup.\n * Registration still happens, so the task-manager reports the bad schedule. */\nfunction catchUpTasksFor(tasks: SystemTaskDef[]): CatchUpTask[] {\n return tasks\n .filter((taskDef) => {\n const reason = unfireableScheduleReason(taskDef.schedule);\n if (reason === null) return true;\n logger().error(`schedule has an unusable ${reason.field} — skipping catch-up, this task will never run`, {\n taskId: taskDef.id,\n [reason.field]: reason.value,\n });\n return false;\n })\n .map((taskDef) => ({\n id: taskDef.id,\n name: taskDef.name,\n schedule: toLibrarySchedule(taskDef.schedule),\n missedRunPolicy: taskDef.missedRunPolicy,\n enabled: true,\n }));\n}\n\n/** Apply a schedule override to a running system task. Updates the\n * in-memory task definition, the task-manager, and recalculates\n * nextScheduledAt in persisted state. */\nexport async function applyScheduleOverride(taskId: string, schedule: SystemTaskDef[\"schedule\"]): Promise<boolean> {\n const task = systemTasks.find((taskDef) => taskDef.id === taskId);\n if (!task || !taskManagerRef) return false;\n if (!taskManagerRef.updateSchedule(taskId, schedule)) return false;\n task.schedule = schedule;\n\n // Recalculate next window so the UI reflects the new schedule\n const nextScheduledAt = computeNextScheduledFor(task.schedule);\n await safeUpdateState(taskId, { nextScheduledAt });\n\n return true;\n}\n\n/** Query execution logs — used by API routes. */\nexport async function getSchedulerLogs(opts: { since?: string | undefined; taskId?: string | undefined; limit?: number | undefined }): Promise<TaskLogEntry[]> {\n return queryLog(logsDir(), opts, logDeps);\n}\n\n/** Get all task states — used by API routes. */\nexport function getSchedulerTasks(): {\n id: string;\n name: string;\n description: string;\n schedule: TaskDefinition[\"schedule\"];\n missedRunPolicy: string;\n state: TaskExecutionState;\n}[] {\n return systemTasks.map((taskDef) => ({\n id: taskDef.id,\n name: taskDef.name,\n description: taskDef.description,\n schedule: taskDef.schedule,\n missedRunPolicy: taskDef.missedRunPolicy,\n state: stateMap.get(taskDef.id) ?? emptyState(taskDef.id),\n }));\n}\n\n/** Read the persisted execution state for any task id (system, user, or\n * skill). Returns an empty state when the id has never run — used by the API\n * route to attach last-run / next-run / history state to user + skill tasks\n * it lists from other sources. */\nexport function getSchedulerTaskState(taskId: string): TaskExecutionState {\n return stateMap.get(taskId) ?? emptyState(taskId);\n}\n\n/** Record a run of an EXTERNAL (skill / user) task — one registered directly\n * on the task-manager rather than through `initScheduler`. Persists a log\n * entry + updates state so these tasks get the same history / last-run /\n * next-run as system tasks. Because they are fire-and-forget (`startChat`\n * spawns an async chat), `errorMessage` reflects whether the *dispatch*\n * succeeded, not the chat's eventual outcome; `chatSessionId` links the run\n * to the spawned session. */\nexport async function recordExternalRun(params: {\n id: string;\n name: string;\n schedule: TaskDefinition[\"schedule\"];\n scheduledFor: string;\n startedAt: string;\n durationMs: number;\n trigger: TaskTrigger;\n errorMessage: string | null;\n chatSessionId?: string | undefined;\n}): Promise<void> {\n await persistRun({\n meta: { id: params.id, name: params.name, schedule: params.schedule },\n scheduledFor: params.scheduledFor,\n startedAt: params.startedAt,\n durationMs: params.durationMs,\n trigger: params.trigger,\n errMsg: params.errorMessage,\n chatSessionId: params.chatSessionId,\n });\n}\n\n/** Test-only: clear config + in-memory state. */\nexport function resetSchedulerForTesting(): void {\n config = null;\n stateMap = new Map();\n systemTasks.length = 0;\n taskManagerRef = null;\n}\n\n// ── Internal ──────────────────────────────────────────────────────\n\nasync function executeAndLog(task: SystemTaskDef, scheduledFor: string, trigger: TaskTrigger): Promise<void> {\n const startedAt = new Date().toISOString();\n const startMs = Date.now();\n let errMsg: string | null = null;\n try {\n await task.run();\n } catch (err) {\n errMsg = errorMessage(err);\n logger().error(\"task failed\", { taskId: task.id, error: errMsg });\n }\n const durationMs = Date.now() - startMs;\n // Persistence is best-effort — never let disk failures propagate to the\n // tick loop or abort startup catch-up.\n await persistRun({ meta: { id: task.id, name: task.name, schedule: task.schedule }, scheduledFor, startedAt, durationMs, trigger, errMsg });\n}\n\n/** The minimum a run needs to persist state + a log entry — shared by system\n * tasks (`executeAndLog`) and external skill/user runs (`recordExternalRun`). */\ninterface TaskRunMeta {\n id: string;\n name: string;\n schedule: TaskDefinition[\"schedule\"];\n}\n\ninterface RunRecord {\n meta: TaskRunMeta;\n scheduledFor: string;\n startedAt: string;\n durationMs: number;\n trigger: TaskTrigger;\n errMsg: string | null;\n chatSessionId?: string | undefined;\n}\n\n/** Best-effort persistence — state and log are independent, so one failing\n * never blocks the other and neither propagates upward. */\nasync function persistRun(run: RunRecord): Promise<void> {\n await writeRunState(run);\n await writeRunLog(run);\n}\n\nasync function writeRunState(run: RunRecord): Promise<void> {\n const { meta, scheduledFor, durationMs, errMsg } = run;\n const isSuccess = errMsg === null;\n const currentState = stateMap.get(meta.id);\n try {\n await updateAndSave(\n stateFilePath(),\n stateMap,\n meta.id,\n {\n lastRunAt: scheduledFor,\n lastRunResult: isSuccess ? TASK_RESULTS.success : TASK_RESULTS.error,\n lastRunDurationMs: durationMs,\n lastErrorMessage: errMsg,\n consecutiveFailures: isSuccess ? 0 : (currentState?.consecutiveFailures ?? 0) + 1,\n totalRuns: (currentState?.totalRuns ?? 0) + 1,\n nextScheduledAt: computeNextScheduledFor(meta.schedule),\n },\n stateDeps(),\n );\n } catch (err) {\n logger().warn(\"state persistence failed\", { taskId: meta.id, error: String(err) });\n }\n}\n\nasync function writeRunLog(run: RunRecord): Promise<void> {\n const { meta, scheduledFor, startedAt, durationMs, trigger, errMsg, chatSessionId } = run;\n const isSuccess = errMsg === null;\n try {\n await appendLogEntry(\n logsDir(),\n {\n taskId: meta.id,\n taskName: meta.name,\n scheduledFor,\n startedAt,\n completedAt: new Date().toISOString(),\n result: isSuccess ? TASK_RESULTS.success : TASK_RESULTS.error,\n durationMs,\n trigger,\n ...(errMsg !== null && { errorMessage: errMsg }),\n ...(chatSessionId !== undefined && { chatSessionId }),\n },\n logDeps,\n );\n } catch (err) {\n logger().warn(\"log persistence failed\", { taskId: meta.id, error: String(err) });\n }\n}\n\n/** Safe state update — swallows errors. */\nasync function safeUpdateState(taskId: string, patch: Partial<TaskExecutionState>): Promise<void> {\n try {\n await updateAndSave(stateFilePath(), stateMap, taskId, patch, stateDeps());\n } catch (err) {\n logger().warn(\"state update failed\", { taskId, error: String(err) });\n }\n}\n\n/** Compute the window boundary that the current tick belongs to. For\n * scheduled runs, this is the epoch-aligned window — not the wall-clock\n * time of execution. This keeps lastRunAt consistent with catch-up's\n * window-based accounting. `tickMs` is the tick's own clock, not\n * `Date.now()`: the two drift apart by however long the tick took to reach\n * this task, and under an injected clock they are unrelated. */\nfunction computeCurrentWindow(task: SystemTaskDef, tickMs: number): string {\n return windowToIso(latestWindowAtOrBefore(task.schedule, tickMs)) ?? new Date(tickMs).toISOString();\n}\n\nfunction computeNextScheduledFor(schedule: TaskDefinition[\"schedule\"]): string | null {\n // The engine will never fire this one, so promising a next run in the UI\n // would be a lie — and the library still hands back a window for some of\n // them (`\"24:00\"` resolves to tomorrow's midnight).\n if (unfireableScheduleReason(schedule) !== null) return null;\n // `windowToIso` is what keeps a window that is not a date (NaN from a\n // degenerate schedule, or an interval so large the next window is past the\n // end of `Date`) from throwing here and taking the whole state write down.\n return windowToIso(nextWindowAfter(toLibrarySchedule(schedule), Date.now() + 1));\n}\n"],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import { SCHEDULE_TYPES, TaskSchedule as LibrarySchedule } from '@receptron/task-scheduler';
2
+ /** A schedule as the host states it: intervals in milliseconds, or a daily
3
+ * `HH:MM` UTC time. */
4
+ export type TaskSchedule = {
5
+ type: typeof SCHEDULE_TYPES.interval;
6
+ intervalMs: number;
7
+ } | {
8
+ type: typeof SCHEDULE_TYPES.daily;
9
+ time: string;
10
+ };
11
+ /** Convert to the library's schedule shape. Milliseconds are divided, never
12
+ * rounded: rounding a sub-second interval to `intervalSec: 0` is what made
13
+ * `nextWindowAfter` answer NaN and `new Date(NaN).toISOString()` throw. */
14
+ export declare function toLibrarySchedule(schedule: TaskSchedule): LibrarySchedule;
15
+ /** True when a scheduled window falls inside the tick ending at `nowMs`.
16
+ * Windows are anchored to the epoch, not to the current UTC day, so an
17
+ * interval longer than 24h keeps its real period. */
18
+ export declare function isScheduleDueAt(schedule: TaskSchedule, nowMs: number, tickMs: number): boolean;
19
+ /** A window as an ISO timestamp, or null when the number is not a date. */
20
+ export declare function windowToIso(windowMs: number | null): string | null;
21
+ /** The most recent window at or before `nowMs`, or null when the schedule has
22
+ * none. `nextWindowAfter` only ever looks FORWARD, so asking it from
23
+ * `nowMs - one period` answers the previous window whenever `nowMs` sits
24
+ * exactly on a boundary, and a daily schedule read after its minute answers
25
+ * TOMORROW. Both schedule kinds have a fixed period, so stepping one period
26
+ * back off the next window is exact. */
27
+ export declare function latestWindowAtOrBefore(schedule: TaskSchedule, nowMs: number): number | null;
28
+ /** Name the field that makes a schedule unfireable, or null when it is fine.
29
+ * Returns the offending value (not a boolean) so the caller can put it in the
30
+ * log — a task that silently never runs leaves no evidence to start from
31
+ * (#2765). */
32
+ export declare function unfireableScheduleReason(schedule: TaskSchedule): {
33
+ field: string;
34
+ value: string;
35
+ } | null;
@@ -1,14 +1,8 @@
1
1
  import { MinimalLogger } from '@mulmoclaude/common';
2
- import { SCHEDULE_TYPES } from '@receptron/task-scheduler';
2
+ import { TaskSchedule } from './schedule-window.js';
3
3
  /** Minimal logger the engine logs through. Absent one, runs silent. */
4
4
  export type SchedulerLogger = MinimalLogger;
5
- export type TaskSchedule = {
6
- type: typeof SCHEDULE_TYPES.interval;
7
- intervalMs: number;
8
- } | {
9
- type: typeof SCHEDULE_TYPES.daily;
10
- time: string;
11
- };
5
+ export type { TaskSchedule } from './schedule-window.js';
12
6
  export interface TaskRunContext {
13
7
  taskId: string;
14
8
  now: Date;
@@ -25,13 +25,34 @@ function isSafeSlug(slug) {
25
25
  * Pure: no normalisation, no transliteration — this is the
26
26
  * same shape the index parser, page resolver, and frontend
27
27
  * renderer all need to agree on. Non-ASCII titles (e.g.
28
- * Japanese) collapse to an empty string here; callers fall back
29
- * to other strategies (title-match in the index, or the agent
30
- * pre-resolving to a slug-form target via `[[slug|display]]`). */
28
+ * Japanese) collapse to an ASCII remnant here, so a caller
29
+ * matching against real page files must go through
30
+ * `matchWikiSlug`, which tries the untouched name first. */
31
31
  function wikiSlugify(text) {
32
32
  return text.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
33
33
  }
34
+ var MAX_ASCII_CODE_POINT = 127;
35
+ function hasNonAscii(text) {
36
+ return [...text].some((char) => (char.codePointAt(0) ?? 0) > MAX_ASCII_CODE_POINT);
37
+ }
38
+ /** Filename stem to suggest when a page has to be created for `name`,
39
+ * or null when `name` cannot yield one.
40
+ *
41
+ * ASCII names keep the hyphenated slug convention. A non-ASCII name is
42
+ * suggested verbatim — the write chokepoint accepts any `isSafeSlug`
43
+ * filename, and slugifying it would propose a stripped remnant like
44
+ * `-4.md` (#2940). Null rather than a salvaged stem for a name the
45
+ * write guard would reject anyway: suggesting `wiki/pages/.md`, or
46
+ * `secrets.md` for `../secrets`, sends the caller to a file it did
47
+ * not ask for. */
48
+ function wikiPageStem(name) {
49
+ const trimmed = name.trim();
50
+ if (!isSafeSlug(trimmed)) return null;
51
+ if (hasNonAscii(trimmed)) return trimmed;
52
+ const slug = wikiSlugify(trimmed);
53
+ return slug.length > 0 ? slug : null;
54
+ }
34
55
  //#endregion
35
- export { wikiSlugify as n, isSafeSlug as t };
56
+ export { wikiPageStem as n, wikiSlugify as r, isSafeSlug as t };
36
57
 
37
- //# sourceMappingURL=slug-CdN-pQX1.js.map
58
+ //# sourceMappingURL=slug-BpjumQfe.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"slug-CdN-pQX1.js","names":[],"sources":["../src/wiki/slug.ts"],"sourcesContent":["// Pure wiki-page slug helpers — string-only, no Node / browser\n// dependencies, importable from any bundle.\n//\n// Used by:\n// - server/workspace/wiki-pages/io.ts (the write chokepoint)\n// - server/workspace/hooks/handlers/wikiSnapshot.ts (the\n// PostToolUse handler that fires on Write/Edit of a wiki page)\n// - server/api/routes/wiki.ts (page resolver + lint)\n// - server/api/routes/wiki/history.ts (history slug guard)\n// - src/plugins/wiki/route.ts (router slug guard — replaces the\n// local `isSafeWikiSlug` duplicate that existed before the\n// pure-lib refactor)\n// - src/plugins/wiki/helpers.ts (renderer slugify for [[…]] links)\n//\n// Any helper that needs `node:path` lives in `./paths.ts` so that\n// importing this file from frontend code never pulls in Node\n// builtins.\n\n/** Reject slugs that would escape `data/wiki/pages/` once\n * joined back into a path, or that are otherwise invalid as\n * page filenames. The chokepoint must defend itself even when\n * callers derive the slug from a trusted source — a typo or\n * future caller mistake should fail loud, not silently write\n * outside the wiki tree.\n *\n * The rule is intentionally narrow — separators / `..` / NUL /\n * empty — so it only rejects unambiguous violations. Aesthetic\n * concerns (e.g. dot-prefixed filenames) are out of scope: a\n * pre-existing `data/wiki/pages/.foo.md` should remain writable\n * through the chokepoint (codex review iter-2 #883). */\nexport function isSafeSlug(slug: string): boolean {\n if (slug.length === 0) return false;\n if (slug === \".\" || slug === \"..\") return false;\n if (slug.includes(\"/\") || slug.includes(\"\\\\\")) return false;\n if (slug.includes(\"\\0\")) return false;\n return true;\n}\n\n/** Slug rules for `[[wiki link]]` text → slug derivation:\n * lowercase, spaces collapsed to hyphens, every non-ASCII /\n * non-alphanumeric / non-hyphen character stripped.\n *\n * Pure: no normalisation, no transliteration — this is the\n * same shape the index parser, page resolver, and frontend\n * renderer all need to agree on. Non-ASCII titles (e.g.\n * Japanese) collapse to an empty string here; callers fall back\n * to other strategies (title-match in the index, or the agent\n * pre-resolving to a slug-form target via `[[slug|display]]`). */\nexport function wikiSlugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/\\s+/g, \"-\")\n .replace(/[^a-z0-9-]/g, \"\");\n}\n"],"mappings":";;;;;;;;;;;;;AA8BA,SAAgB,WAAW,MAAuB;CAChD,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,SAAS,OAAO,SAAS,MAAM,OAAO;CAC1C,IAAI,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,GAAG,OAAO;CACtD,IAAI,KAAK,SAAS,IAAI,GAAG,OAAO;CAChC,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,YAAY,MAAsB;CAChD,OAAO,KACJ,YAAY,CAAC,CACb,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,eAAe,EAAE;AAC9B"}
1
+ {"version":3,"file":"slug-BpjumQfe.js","names":[],"sources":["../src/wiki/slug.ts"],"sourcesContent":["// Pure wiki-page slug helpers — string-only, no Node / browser\n// dependencies, importable from any bundle.\n//\n// Used by:\n// - server/workspace/wiki-pages/io.ts (the write chokepoint)\n// - server/workspace/hooks/handlers/wikiSnapshot.ts (the\n// PostToolUse handler that fires on Write/Edit of a wiki page)\n// - server/api/routes/wiki.ts (page resolver + lint)\n// - server/api/routes/wiki/history.ts (history slug guard)\n// - src/plugins/wiki/route.ts (router slug guard — replaces the\n// local `isSafeWikiSlug` duplicate that existed before the\n// pure-lib refactor)\n// - src/plugins/wiki/helpers.ts (renderer slugify for [[…]] links)\n//\n// Any helper that needs `node:path` lives in `./paths.ts` so that\n// importing this file from frontend code never pulls in Node\n// builtins.\n\n/** Reject slugs that would escape `data/wiki/pages/` once\n * joined back into a path, or that are otherwise invalid as\n * page filenames. The chokepoint must defend itself even when\n * callers derive the slug from a trusted source — a typo or\n * future caller mistake should fail loud, not silently write\n * outside the wiki tree.\n *\n * The rule is intentionally narrow — separators / `..` / NUL /\n * empty — so it only rejects unambiguous violations. Aesthetic\n * concerns (e.g. dot-prefixed filenames) are out of scope: a\n * pre-existing `data/wiki/pages/.foo.md` should remain writable\n * through the chokepoint (codex review iter-2 #883). */\nexport function isSafeSlug(slug: string): boolean {\n if (slug.length === 0) return false;\n if (slug === \".\" || slug === \"..\") return false;\n if (slug.includes(\"/\") || slug.includes(\"\\\\\")) return false;\n if (slug.includes(\"\\0\")) return false;\n return true;\n}\n\n/** Slug rules for `[[wiki link]]` text → slug derivation:\n * lowercase, spaces collapsed to hyphens, every non-ASCII /\n * non-alphanumeric / non-hyphen character stripped.\n *\n * Pure: no normalisation, no transliteration — this is the\n * same shape the index parser, page resolver, and frontend\n * renderer all need to agree on. Non-ASCII titles (e.g.\n * Japanese) collapse to an ASCII remnant here, so a caller\n * matching against real page files must go through\n * `matchWikiSlug`, which tries the untouched name first. */\nexport function wikiSlugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/\\s+/g, \"-\")\n .replace(/[^a-z0-9-]/g, \"\");\n}\n\n// Code point above which `wikiSlugify` strips a character outright.\nconst MAX_ASCII_CODE_POINT = 127;\n\nfunction hasNonAscii(text: string): boolean {\n return [...text].some((char) => (char.codePointAt(0) ?? 0) > MAX_ASCII_CODE_POINT);\n}\n\n/** Filename stem to suggest when a page has to be created for `name`,\n * or null when `name` cannot yield one.\n *\n * ASCII names keep the hyphenated slug convention. A non-ASCII name is\n * suggested verbatim — the write chokepoint accepts any `isSafeSlug`\n * filename, and slugifying it would propose a stripped remnant like\n * `-4.md` (#2940). Null rather than a salvaged stem for a name the\n * write guard would reject anyway: suggesting `wiki/pages/.md`, or\n * `secrets.md` for `../secrets`, sends the caller to a file it did\n * not ask for. */\nexport function wikiPageStem(name: string): string | null {\n const trimmed = name.trim();\n if (!isSafeSlug(trimmed)) return null;\n if (hasNonAscii(trimmed)) return trimmed;\n const slug = wikiSlugify(trimmed);\n return slug.length > 0 ? slug : null;\n}\n"],"mappings":";;;;;;;;;;;;;AA8BA,SAAgB,WAAW,MAAuB;CAChD,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,SAAS,OAAO,SAAS,MAAM,OAAO;CAC1C,IAAI,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,GAAG,OAAO;CACtD,IAAI,KAAK,SAAS,IAAI,GAAG,OAAO;CAChC,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,YAAY,MAAsB;CAChD,OAAO,KACJ,YAAY,CAAC,CACb,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,eAAe,EAAE;AAC9B;AAGA,IAAM,uBAAuB;AAE7B,SAAS,YAAY,MAAuB;CAC1C,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,MAAM,UAAU,KAAK,YAAY,CAAC,KAAK,KAAK,oBAAoB;AACnF;;;;;;;;;;;AAYA,SAAgB,aAAa,MAA6B;CACxD,MAAM,UAAU,KAAK,KAAK;CAC1B,IAAI,CAAC,WAAW,OAAO,GAAG,OAAO;CACjC,IAAI,YAAY,OAAO,GAAG,OAAO;CACjC,MAAM,OAAO,YAAY,OAAO;CAChC,OAAO,KAAK,SAAS,IAAI,OAAO;AAClC"}
@@ -25,12 +25,33 @@ function isSafeSlug(slug) {
25
25
  * Pure: no normalisation, no transliteration — this is the
26
26
  * same shape the index parser, page resolver, and frontend
27
27
  * renderer all need to agree on. Non-ASCII titles (e.g.
28
- * Japanese) collapse to an empty string here; callers fall back
29
- * to other strategies (title-match in the index, or the agent
30
- * pre-resolving to a slug-form target via `[[slug|display]]`). */
28
+ * Japanese) collapse to an ASCII remnant here, so a caller
29
+ * matching against real page files must go through
30
+ * `matchWikiSlug`, which tries the untouched name first. */
31
31
  function wikiSlugify(text) {
32
32
  return text.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
33
33
  }
34
+ var MAX_ASCII_CODE_POINT = 127;
35
+ function hasNonAscii(text) {
36
+ return [...text].some((char) => (char.codePointAt(0) ?? 0) > MAX_ASCII_CODE_POINT);
37
+ }
38
+ /** Filename stem to suggest when a page has to be created for `name`,
39
+ * or null when `name` cannot yield one.
40
+ *
41
+ * ASCII names keep the hyphenated slug convention. A non-ASCII name is
42
+ * suggested verbatim — the write chokepoint accepts any `isSafeSlug`
43
+ * filename, and slugifying it would propose a stripped remnant like
44
+ * `-4.md` (#2940). Null rather than a salvaged stem for a name the
45
+ * write guard would reject anyway: suggesting `wiki/pages/.md`, or
46
+ * `secrets.md` for `../secrets`, sends the caller to a file it did
47
+ * not ask for. */
48
+ function wikiPageStem(name) {
49
+ const trimmed = name.trim();
50
+ if (!isSafeSlug(trimmed)) return null;
51
+ if (hasNonAscii(trimmed)) return trimmed;
52
+ const slug = wikiSlugify(trimmed);
53
+ return slug.length > 0 ? slug : null;
54
+ }
34
55
  //#endregion
35
56
  Object.defineProperty(exports, "isSafeSlug", {
36
57
  enumerable: true,
@@ -38,6 +59,12 @@ Object.defineProperty(exports, "isSafeSlug", {
38
59
  return isSafeSlug;
39
60
  }
40
61
  });
62
+ Object.defineProperty(exports, "wikiPageStem", {
63
+ enumerable: true,
64
+ get: function() {
65
+ return wikiPageStem;
66
+ }
67
+ });
41
68
  Object.defineProperty(exports, "wikiSlugify", {
42
69
  enumerable: true,
43
70
  get: function() {
@@ -45,4 +72,4 @@ Object.defineProperty(exports, "wikiSlugify", {
45
72
  }
46
73
  });
47
74
 
48
- //# sourceMappingURL=slug-DbwORN9z.cjs.map
75
+ //# sourceMappingURL=slug-JxgB0ru9.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"slug-DbwORN9z.cjs","names":[],"sources":["../src/wiki/slug.ts"],"sourcesContent":["// Pure wiki-page slug helpers — string-only, no Node / browser\n// dependencies, importable from any bundle.\n//\n// Used by:\n// - server/workspace/wiki-pages/io.ts (the write chokepoint)\n// - server/workspace/hooks/handlers/wikiSnapshot.ts (the\n// PostToolUse handler that fires on Write/Edit of a wiki page)\n// - server/api/routes/wiki.ts (page resolver + lint)\n// - server/api/routes/wiki/history.ts (history slug guard)\n// - src/plugins/wiki/route.ts (router slug guard — replaces the\n// local `isSafeWikiSlug` duplicate that existed before the\n// pure-lib refactor)\n// - src/plugins/wiki/helpers.ts (renderer slugify for [[…]] links)\n//\n// Any helper that needs `node:path` lives in `./paths.ts` so that\n// importing this file from frontend code never pulls in Node\n// builtins.\n\n/** Reject slugs that would escape `data/wiki/pages/` once\n * joined back into a path, or that are otherwise invalid as\n * page filenames. The chokepoint must defend itself even when\n * callers derive the slug from a trusted source — a typo or\n * future caller mistake should fail loud, not silently write\n * outside the wiki tree.\n *\n * The rule is intentionally narrow — separators / `..` / NUL /\n * empty — so it only rejects unambiguous violations. Aesthetic\n * concerns (e.g. dot-prefixed filenames) are out of scope: a\n * pre-existing `data/wiki/pages/.foo.md` should remain writable\n * through the chokepoint (codex review iter-2 #883). */\nexport function isSafeSlug(slug: string): boolean {\n if (slug.length === 0) return false;\n if (slug === \".\" || slug === \"..\") return false;\n if (slug.includes(\"/\") || slug.includes(\"\\\\\")) return false;\n if (slug.includes(\"\\0\")) return false;\n return true;\n}\n\n/** Slug rules for `[[wiki link]]` text → slug derivation:\n * lowercase, spaces collapsed to hyphens, every non-ASCII /\n * non-alphanumeric / non-hyphen character stripped.\n *\n * Pure: no normalisation, no transliteration — this is the\n * same shape the index parser, page resolver, and frontend\n * renderer all need to agree on. Non-ASCII titles (e.g.\n * Japanese) collapse to an empty string here; callers fall back\n * to other strategies (title-match in the index, or the agent\n * pre-resolving to a slug-form target via `[[slug|display]]`). */\nexport function wikiSlugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/\\s+/g, \"-\")\n .replace(/[^a-z0-9-]/g, \"\");\n}\n"],"mappings":";;;;;;;;;;;;;AA8BA,SAAgB,WAAW,MAAuB;CAChD,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,SAAS,OAAO,SAAS,MAAM,OAAO;CAC1C,IAAI,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,GAAG,OAAO;CACtD,IAAI,KAAK,SAAS,IAAI,GAAG,OAAO;CAChC,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,YAAY,MAAsB;CAChD,OAAO,KACJ,YAAY,CAAC,CACb,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,eAAe,EAAE;AAC9B"}
1
+ {"version":3,"file":"slug-JxgB0ru9.cjs","names":[],"sources":["../src/wiki/slug.ts"],"sourcesContent":["// Pure wiki-page slug helpers — string-only, no Node / browser\n// dependencies, importable from any bundle.\n//\n// Used by:\n// - server/workspace/wiki-pages/io.ts (the write chokepoint)\n// - server/workspace/hooks/handlers/wikiSnapshot.ts (the\n// PostToolUse handler that fires on Write/Edit of a wiki page)\n// - server/api/routes/wiki.ts (page resolver + lint)\n// - server/api/routes/wiki/history.ts (history slug guard)\n// - src/plugins/wiki/route.ts (router slug guard — replaces the\n// local `isSafeWikiSlug` duplicate that existed before the\n// pure-lib refactor)\n// - src/plugins/wiki/helpers.ts (renderer slugify for [[…]] links)\n//\n// Any helper that needs `node:path` lives in `./paths.ts` so that\n// importing this file from frontend code never pulls in Node\n// builtins.\n\n/** Reject slugs that would escape `data/wiki/pages/` once\n * joined back into a path, or that are otherwise invalid as\n * page filenames. The chokepoint must defend itself even when\n * callers derive the slug from a trusted source — a typo or\n * future caller mistake should fail loud, not silently write\n * outside the wiki tree.\n *\n * The rule is intentionally narrow — separators / `..` / NUL /\n * empty — so it only rejects unambiguous violations. Aesthetic\n * concerns (e.g. dot-prefixed filenames) are out of scope: a\n * pre-existing `data/wiki/pages/.foo.md` should remain writable\n * through the chokepoint (codex review iter-2 #883). */\nexport function isSafeSlug(slug: string): boolean {\n if (slug.length === 0) return false;\n if (slug === \".\" || slug === \"..\") return false;\n if (slug.includes(\"/\") || slug.includes(\"\\\\\")) return false;\n if (slug.includes(\"\\0\")) return false;\n return true;\n}\n\n/** Slug rules for `[[wiki link]]` text → slug derivation:\n * lowercase, spaces collapsed to hyphens, every non-ASCII /\n * non-alphanumeric / non-hyphen character stripped.\n *\n * Pure: no normalisation, no transliteration — this is the\n * same shape the index parser, page resolver, and frontend\n * renderer all need to agree on. Non-ASCII titles (e.g.\n * Japanese) collapse to an ASCII remnant here, so a caller\n * matching against real page files must go through\n * `matchWikiSlug`, which tries the untouched name first. */\nexport function wikiSlugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/\\s+/g, \"-\")\n .replace(/[^a-z0-9-]/g, \"\");\n}\n\n// Code point above which `wikiSlugify` strips a character outright.\nconst MAX_ASCII_CODE_POINT = 127;\n\nfunction hasNonAscii(text: string): boolean {\n return [...text].some((char) => (char.codePointAt(0) ?? 0) > MAX_ASCII_CODE_POINT);\n}\n\n/** Filename stem to suggest when a page has to be created for `name`,\n * or null when `name` cannot yield one.\n *\n * ASCII names keep the hyphenated slug convention. A non-ASCII name is\n * suggested verbatim — the write chokepoint accepts any `isSafeSlug`\n * filename, and slugifying it would propose a stripped remnant like\n * `-4.md` (#2940). Null rather than a salvaged stem for a name the\n * write guard would reject anyway: suggesting `wiki/pages/.md`, or\n * `secrets.md` for `../secrets`, sends the caller to a file it did\n * not ask for. */\nexport function wikiPageStem(name: string): string | null {\n const trimmed = name.trim();\n if (!isSafeSlug(trimmed)) return null;\n if (hasNonAscii(trimmed)) return trimmed;\n const slug = wikiSlugify(trimmed);\n return slug.length > 0 ? slug : null;\n}\n"],"mappings":";;;;;;;;;;;;;AA8BA,SAAgB,WAAW,MAAuB;CAChD,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,SAAS,OAAO,SAAS,MAAM,OAAO;CAC1C,IAAI,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,GAAG,OAAO;CACtD,IAAI,KAAK,SAAS,IAAI,GAAG,OAAO;CAChC,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,YAAY,MAAsB;CAChD,OAAO,KACJ,YAAY,CAAC,CACb,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,eAAe,EAAE;AAC9B;AAGA,IAAM,uBAAuB;AAE7B,SAAS,YAAY,MAAuB;CAC1C,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,MAAM,UAAU,KAAK,YAAY,CAAC,KAAK,KAAK,oBAAoB;AACnF;;;;;;;;;;;AAYA,SAAgB,aAAa,MAA6B;CACxD,MAAM,UAAU,KAAK,KAAK;CAC1B,IAAI,CAAC,WAAW,OAAO,GAAG,OAAO;CACjC,IAAI,YAAY,OAAO,GAAG,OAAO;CACjC,MAAM,OAAO,YAAY,OAAO;CAChC,OAAO,KAAK,SAAS,IAAI,OAAO;AAClC"}