heyio 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import { config } from "../config.js";
6
6
  import { listSkills } from "../copilot/skills.js";
7
7
  import { listSquads, createSquad, listSquadAgents } from "../store/squads.js";
8
8
  import { getAgentInfo, cancelAgentTask, getTaskEvents, subscribeToTaskEvents } from "../copilot/agents.js";
9
+ import { summarize, summarizeEvent } from "../copilot/event-summary.js";
9
10
  import { abortOrchestrator } from "../copilot/orchestrator.js";
10
11
  import { getActiveTasks, getTask, listRecentTasks } from "../store/tasks.js";
11
12
  import { IO_VERSION } from "../paths.js";
@@ -153,6 +154,18 @@ export async function startApiServer() {
153
154
  res.status(500).json({ error: "Failed to fetch task" });
154
155
  }
155
156
  });
157
+ api.get("/tasks/:taskId/activity", (req, res) => {
158
+ const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
159
+ try {
160
+ const events = getTaskEvents(taskId);
161
+ const activity = summarize(events);
162
+ res.json({ taskId, activity });
163
+ }
164
+ catch (e) {
165
+ console.error("Error building task activity:", e);
166
+ res.status(500).json({ error: "Failed to build task activity" });
167
+ }
168
+ });
156
169
  api.get("/tasks/:taskId/events", (req, res) => {
157
170
  const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
158
171
  res.setHeader("Content-Type", "text/event-stream");
@@ -162,7 +175,9 @@ export async function startApiServer() {
162
175
  res.flushHeaders();
163
176
  const send = (ev) => {
164
177
  try {
165
- res.write(`data: ${JSON.stringify(ev)}\n\n`);
178
+ const summary = summarizeEvent(ev);
179
+ const payload = { ...ev, summary };
180
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
166
181
  }
167
182
  catch {
168
183
  // client likely disconnected; cleanup happens on req.close
@@ -8,8 +8,8 @@ import { defineTool, approveAll } from "@github/copilot-sdk";
8
8
  import { z } from "zod";
9
9
  import { getClient } from "./client.js";
10
10
  import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
11
- import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
12
- import { createTask, completeTask, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
11
+ import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
12
+ import { createTask, completeTask, createReview, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
13
13
  import { SESSIONS_DIR } from "../paths.js";
14
14
  import { getUniverse } from "./universes.js";
15
15
  // Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
@@ -48,6 +48,7 @@ export function getAgentInfo() {
48
48
  status: agent?.status === "working" ? "working" : currentTask ? "working" : "idle",
49
49
  currentTask,
50
50
  currentTaskId,
51
+ model: agentSessionModels.get(key),
51
52
  });
52
53
  }
53
54
  else {
@@ -60,6 +61,7 @@ export function getAgentInfo() {
60
61
  status: currentTask ? "working" : squad?.status === "error" ? "error" : "idle",
61
62
  currentTask,
62
63
  currentTaskId,
64
+ model: agentSessionModels.get(key),
63
65
  });
64
66
  }
65
67
  }
@@ -116,10 +118,17 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
116
118
  }
117
119
  }
118
120
  else {
119
- // If squad has named agents, pick the best match (first idle, or first one)
120
- const agents = listSquadAgents(squadSlug);
121
- if (agents.length > 0) {
122
- agent = agents.find((a) => a.status === "idle") ?? agents[0];
121
+ // Prefer the designated team lead if one exists; otherwise fall back to
122
+ // the first idle agent (or just the first agent on the roster).
123
+ const lead = getSquadLead(squadSlug);
124
+ if (lead) {
125
+ agent = lead;
126
+ }
127
+ else {
128
+ const agents = listSquadAgents(squadSlug);
129
+ if (agents.length > 0) {
130
+ agent = agents.find((a) => a.status === "idle") ?? agents[0];
131
+ }
123
132
  }
124
133
  }
125
134
  const session = agent
@@ -159,6 +168,19 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
159
168
  if (agent)
160
169
  updateAgentStatus(squadSlug, agent.character_name, "idle");
161
170
  recordTaskEvent(taskId, { ts: Date.now(), type: "task.done", data: { result } });
171
+ try {
172
+ await runPeerReview(squadSlug, agent?.character_name ?? "", taskId, task, result);
173
+ }
174
+ catch (reviewErr) {
175
+ console.error("[io] Peer review error:", reviewErr instanceof Error ? reviewErr.message : reviewErr);
176
+ recordTaskEvent(taskId, {
177
+ ts: Date.now(),
178
+ type: "task.review_error",
179
+ data: {
180
+ error: reviewErr instanceof Error ? reviewErr.message : String(reviewErr),
181
+ },
182
+ });
183
+ }
162
184
  onComplete(taskId, result);
163
185
  }
164
186
  catch (err) {
@@ -241,7 +263,34 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
241
263
  const universeName = squad.universe
242
264
  ? getUniverse(squad.universe)?.name ?? squad.universe
243
265
  : "Unknown";
244
- const agentTools = buildAgentTools(squadSlug);
266
+ const isLead = agent.is_lead === 1;
267
+ const agentTools = buildAgentTools(squadSlug, isLead);
268
+ let leadSection = "";
269
+ if (isLead) {
270
+ const teammates = listSquadAgents(squadSlug).filter((a) => a.character_name !== agent.character_name);
271
+ const roster = teammates.length > 0
272
+ ? teammates
273
+ .map((t) => {
274
+ const charter = t.charter
275
+ ? t.charter.length > 200
276
+ ? t.charter.slice(0, 200) + "…"
277
+ : t.charter
278
+ : "(no charter)";
279
+ return `- **${t.character_name}** — ${t.role_title}: ${charter}`;
280
+ })
281
+ .join("\n")
282
+ : "_(no other agents on this squad yet — ask IO to add some)_";
283
+ leadSection = `
284
+
285
+ ## Team Lead Role
286
+ You are the team lead for this squad. When you receive a task, your job is to:
287
+ 1. Break it down into concrete subtasks
288
+ 2. Assign each subtask to the most appropriate teammate using the \`delegate_to_teammate\` tool
289
+ 3. Collect results and synthesize a final summary
290
+
291
+ ## Your Team
292
+ ${roster}`;
293
+ }
245
294
  const systemMessage = `You are ${agent.character_name}, a specialist agent on the "${squad.name}" project team (${universeName} universe).
246
295
 
247
296
  ## Your Identity
@@ -256,7 +305,7 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
256
305
  - **Path**: ${squad.project_path}
257
306
 
258
307
  ## Past Decisions
259
- ${decisions}
308
+ ${decisions}${leadSection}
260
309
 
261
310
  ## Instructions
262
311
  You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
@@ -343,7 +392,7 @@ Log important decisions with squad_log_decision so they persist.`,
343
392
  agentSessions.set(squadSlug, session);
344
393
  return session;
345
394
  }
346
- function buildAgentTools(squadSlug) {
395
+ function buildAgentTools(squadSlug, isLead = false) {
347
396
  const shell = defineTool("shell", {
348
397
  description: "Run a shell command. Use for git, build tools, file operations, etc.",
349
398
  skipPermission: true,
@@ -461,7 +510,50 @@ function buildAgentTools(squadSlug) {
461
510
  }
462
511
  },
463
512
  });
464
- return [shell, fileOps, squadLogDecision];
513
+ const tools = [shell, fileOps, squadLogDecision];
514
+ if (isLead) {
515
+ const delegateToTeammate = defineTool("delegate_to_teammate", {
516
+ description: "Delegate a subtask to a teammate on this squad. The teammate runs the task synchronously and returns its result. Use this to divvy work as the team lead.",
517
+ skipPermission: true,
518
+ parameters: z.object({
519
+ teammate: z
520
+ .string()
521
+ .describe("The teammate's character_name (e.g., 'Optimus Prime')"),
522
+ task: z
523
+ .string()
524
+ .describe("The concrete task or subtask the teammate should perform"),
525
+ }),
526
+ handler: async ({ teammate, task }) => {
527
+ try {
528
+ const teammateAgent = getSquadAgent(squadSlug, teammate);
529
+ if (!teammateAgent) {
530
+ return `Error: teammate "${teammate}" not found in squad "${squadSlug}". Use squad_agents to list the roster.`;
531
+ }
532
+ if (teammateAgent.is_lead === 1) {
533
+ return `Error: "${teammate}" is the team lead. Delegate to a non-lead teammate.`;
534
+ }
535
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "working");
536
+ try {
537
+ const session = await getOrCreateAgentSession(squadSlug, teammateAgent, task);
538
+ const response = await session.sendAndWait({ prompt: task }, 300_000);
539
+ const result = response?.data?.content ?? "(teammate returned no output)";
540
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "idle");
541
+ return result;
542
+ }
543
+ catch (err) {
544
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "error");
545
+ const message = err instanceof Error ? err.message : String(err);
546
+ return `Error from teammate "${teammate}": ${message}`;
547
+ }
548
+ }
549
+ catch (err) {
550
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
551
+ }
552
+ },
553
+ });
554
+ tools.push(delegateToTeammate);
555
+ }
556
+ return tools;
465
557
  }
466
558
  function walkDirectory(dir, maxDepth = 3, depth = 0) {
467
559
  if (depth >= maxDepth)
@@ -481,6 +573,208 @@ function walkDirectory(dir, maxDepth = 3, depth = 0) {
481
573
  }
482
574
  return results;
483
575
  }
576
+ /**
577
+ * Parse APPROVED/REJECTED verdict from a reviewer's free-form response.
578
+ *
579
+ * Robust to common formatting variants:
580
+ * - Leading blank lines or markdown headers (e.g. "## Review\n\nAPPROVED")
581
+ * - Markdown emphasis (e.g. "**APPROVED**")
582
+ * - Verdict appearing only later in the response
583
+ * - Both tokens appearing in the same line ("I almost said REJECTED but APPROVED")
584
+ *
585
+ * Strategy:
586
+ * 1. Strip markdown noise.
587
+ * 2. Look at the first 10 non-empty lines for a *line-leading* verdict.
588
+ * 3. Fall back to the first occurrence of either token anywhere in the body.
589
+ * 4. If neither token appears, treat as REJECTED (conservative).
590
+ */
591
+ export function parseReviewVerdict(content) {
592
+ if (!content)
593
+ return false;
594
+ const stripped = content.replace(/[*_`#>]/g, "");
595
+ const lines = stripped
596
+ .split(/\r?\n/)
597
+ .map((l) => l.trim())
598
+ .filter(Boolean)
599
+ .slice(0, 10);
600
+ for (const line of lines) {
601
+ const lead = line
602
+ .toUpperCase()
603
+ .match(/^[^A-Z]*\b(APPROVED|REJECTED)\b/);
604
+ if (lead)
605
+ return lead[1] === "APPROVED";
606
+ }
607
+ const upper = stripped.toUpperCase();
608
+ const a = upper.search(/\bAPPROVED\b/);
609
+ const r = upper.search(/\bREJECTED\b/);
610
+ if (a === -1 && r === -1)
611
+ return false;
612
+ if (a === -1)
613
+ return false;
614
+ if (r === -1)
615
+ return true;
616
+ return a < r;
617
+ }
618
+ /**
619
+ * Return the reviewer's prose comments with any leading verdict line stripped.
620
+ * Preserves the original formatting (no upper-casing, no markdown stripping).
621
+ */
622
+ export function stripLeadingVerdictLine(content) {
623
+ if (!content)
624
+ return "";
625
+ const lines = content.split(/\r?\n/);
626
+ let i = 0;
627
+ while (i < lines.length && lines[i].trim() === "")
628
+ i++;
629
+ if (i < lines.length) {
630
+ const probe = lines[i]
631
+ .replace(/[*_`#>]/g, "")
632
+ .trim()
633
+ .toUpperCase();
634
+ if (/^(APPROVED|REJECTED)\b/.test(probe))
635
+ i++;
636
+ }
637
+ return lines.slice(i).join("\n").trim();
638
+ }
639
+ /**
640
+ * Run a peer review phase after a task completes. Every other agent on the
641
+ * squad reviews the work and votes APPROVED / REJECTED. QA agents
642
+ * (is_qa === 1) have veto power: if any QA agent rejects, the PR is left as
643
+ * draft. Otherwise, any GitHub PR URL found in the task result is promoted
644
+ * from draft to ready via `gh pr ready`.
645
+ */
646
+ async function runPeerReview(squadSlug, originalAgentCharacter, taskId, taskDescription, taskResult) {
647
+ const reviewers = listSquadAgents(squadSlug).filter((a) => a.character_name !== originalAgentCharacter);
648
+ if (reviewers.length === 0) {
649
+ recordTaskEvent(taskId, {
650
+ ts: Date.now(),
651
+ type: "task.review_complete",
652
+ data: { promoted: false, reason: "No other agents to review" },
653
+ });
654
+ return;
655
+ }
656
+ const reviewPrompt = `You are reviewing the following completed task:
657
+
658
+ ## Task
659
+ ${taskDescription}
660
+
661
+ ## Work Done
662
+ ${taskResult}
663
+
664
+ Review the work. Respond with:
665
+ - First line: APPROVED or REJECTED
666
+ - Remaining lines: your review comments`;
667
+ const reviews = [];
668
+ for (const reviewer of reviewers) {
669
+ try {
670
+ const session = await getOrCreateAgentSession(squadSlug, reviewer, `Peer review of task ${taskId}`);
671
+ const response = await session.sendAndWait({ prompt: reviewPrompt }, 300_000);
672
+ const content = response?.data?.content ?? "";
673
+ const approved = parseReviewVerdict(content);
674
+ const comments = stripLeadingVerdictLine(content) || null;
675
+ createReview(taskId, squadSlug, reviewer.character_name, approved, comments ?? undefined);
676
+ recordTaskEvent(taskId, {
677
+ ts: Date.now(),
678
+ type: "task.review",
679
+ data: {
680
+ reviewer: reviewer.character_name,
681
+ is_qa: reviewer.is_qa === 1,
682
+ is_lead: reviewer.is_lead === 1,
683
+ approved,
684
+ comments,
685
+ },
686
+ });
687
+ reviews.push({
688
+ reviewer: reviewer.character_name,
689
+ is_qa: reviewer.is_qa === 1,
690
+ is_lead: reviewer.is_lead === 1,
691
+ approved,
692
+ comments: comments ?? "",
693
+ });
694
+ }
695
+ catch (err) {
696
+ const message = err instanceof Error ? err.message : String(err);
697
+ console.error(`[io] Reviewer ${reviewer.character_name} failed:`, message);
698
+ recordTaskEvent(taskId, {
699
+ ts: Date.now(),
700
+ type: "task.review_error",
701
+ data: { reviewer: reviewer.character_name, error: message },
702
+ });
703
+ }
704
+ }
705
+ const hasQaReviewers = reviews.some((r) => r.is_qa);
706
+ const hasLeadReviewer = reviews.some((r) => r.is_lead);
707
+ const qaRejection = reviews.find((r) => r.is_qa && !r.approved);
708
+ // Team lead has implicit veto power equivalent to a QA reviewer. If the lead
709
+ // is also a QA agent the qaRejection branch already covers it; this catches
710
+ // the lead-but-not-QA case.
711
+ const leadRejection = reviews.find((r) => r.is_lead && !r.is_qa && !r.approved);
712
+ const advisoryRejections = reviews.filter((r) => !r.is_qa && !r.is_lead && !r.approved);
713
+ if (!hasQaReviewers && !hasLeadReviewer && advisoryRejections.length > 0) {
714
+ recordTaskEvent(taskId, {
715
+ ts: Date.now(),
716
+ type: "task.review_advisory",
717
+ data: {
718
+ reason: "No QA reviewers or team lead designated; rejections are advisory and do not block promotion.",
719
+ rejectedBy: advisoryRejections.map((r) => r.reviewer),
720
+ },
721
+ });
722
+ }
723
+ const prMatch = taskResult.match(/https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/);
724
+ if (qaRejection) {
725
+ recordTaskEvent(taskId, {
726
+ ts: Date.now(),
727
+ type: "task.review_complete",
728
+ data: {
729
+ promoted: false,
730
+ reason: `QA veto from ${qaRejection.reviewer}`,
731
+ prUrl: prMatch ? prMatch[0] : null,
732
+ },
733
+ });
734
+ return;
735
+ }
736
+ if (leadRejection) {
737
+ recordTaskEvent(taskId, {
738
+ ts: Date.now(),
739
+ type: "task.review_complete",
740
+ data: {
741
+ promoted: false,
742
+ reason: `Lead veto from ${leadRejection.reviewer}`,
743
+ prUrl: prMatch ? prMatch[0] : null,
744
+ },
745
+ });
746
+ return;
747
+ }
748
+ if (!prMatch) {
749
+ recordTaskEvent(taskId, {
750
+ ts: Date.now(),
751
+ type: "task.review_complete",
752
+ data: { promoted: false, reason: "No PR URL found in task result" },
753
+ });
754
+ return;
755
+ }
756
+ const [prUrl, owner, repo, prNumber] = prMatch;
757
+ try {
758
+ execSync(`gh pr ready ${prNumber} --repo ${owner}/${repo}`, {
759
+ encoding: "utf-8",
760
+ timeout: 30_000,
761
+ env: { ...process.env, HOME: process.env.HOME || homedir() },
762
+ });
763
+ recordTaskEvent(taskId, {
764
+ ts: Date.now(),
765
+ type: "task.review_complete",
766
+ data: { promoted: true, prUrl },
767
+ });
768
+ }
769
+ catch (err) {
770
+ const message = err instanceof Error ? err.message : String(err);
771
+ recordTaskEvent(taskId, {
772
+ ts: Date.now(),
773
+ type: "task.review_complete",
774
+ data: { promoted: false, reason: `gh pr ready failed: ${message}`, prUrl },
775
+ });
776
+ }
777
+ }
484
778
  /**
485
779
  * Cancel a running agent task by aborting its session and marking the task
486
780
  * cancelled. Returns true if the task existed and was running.
@@ -0,0 +1,136 @@
1
+ // Minimal standard 5-field cron parser + next-run calculator.
2
+ //
3
+ // Fields (in order): minute, hour, day-of-month, month, day-of-week.
4
+ // Supported syntax per field:
5
+ // * — all values
6
+ // N — single value
7
+ // A,B,C — list
8
+ // A-B — range
9
+ // A-B/N or */N — step (A-B/N restricts to range; */N applies to full range)
10
+ //
11
+ // Day-of-week: 0 or 7 = Sunday, 1 = Monday … (standard Unix cron).
12
+ //
13
+ // Matching semantics: when both day-of-month and day-of-week are restricted
14
+ // (i.e. neither is "*"), Vixie cron uses an OR between them — we follow that.
15
+ const FIELD_RANGES = [
16
+ [0, 59],
17
+ [0, 23],
18
+ [1, 31],
19
+ [1, 12],
20
+ [0, 7],
21
+ ];
22
+ function parseField(raw, [min, max]) {
23
+ const out = new Set();
24
+ for (const part of raw.split(",")) {
25
+ const stepMatch = part.match(/^(.+?)\/(\d+)$/);
26
+ const body = stepMatch ? stepMatch[1] : part;
27
+ const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
28
+ if (!(step >= 1))
29
+ throw new Error(`Invalid step in cron field: "${part}"`);
30
+ let lo, hi;
31
+ if (body === "*") {
32
+ lo = min;
33
+ hi = max;
34
+ }
35
+ else if (body.includes("-")) {
36
+ const [a, b] = body.split("-").map((n) => parseInt(n, 10));
37
+ if (Number.isNaN(a) || Number.isNaN(b)) {
38
+ throw new Error(`Invalid range in cron field: "${part}"`);
39
+ }
40
+ lo = a;
41
+ hi = b;
42
+ }
43
+ else {
44
+ const v = parseInt(body, 10);
45
+ if (Number.isNaN(v))
46
+ throw new Error(`Invalid cron field value: "${part}"`);
47
+ lo = v;
48
+ hi = v;
49
+ }
50
+ if (lo < min || hi > max || lo > hi) {
51
+ throw new Error(`Cron field out of range [${min}-${max}]: "${part}"`);
52
+ }
53
+ for (let v = lo; v <= hi; v += step)
54
+ out.add(v);
55
+ }
56
+ return out;
57
+ }
58
+ export function parseCron(expr) {
59
+ const trimmed = expr.trim().replace(/\s+/g, " ");
60
+ const parts = trimmed.split(" ");
61
+ if (parts.length !== 5) {
62
+ throw new Error(`Cron expression must have 5 fields (minute hour dom month dow), got ${parts.length}: "${expr}"`);
63
+ }
64
+ const [mRaw, hRaw, domRaw, monRaw, dowRaw] = parts;
65
+ const minutes = parseField(mRaw, FIELD_RANGES[0]);
66
+ const hours = parseField(hRaw, FIELD_RANGES[1]);
67
+ const doms = parseField(domRaw, FIELD_RANGES[2]);
68
+ const months = parseField(monRaw, FIELD_RANGES[3]);
69
+ const dowsRaw = parseField(dowRaw, FIELD_RANGES[4]);
70
+ const dows = new Set();
71
+ for (const v of dowsRaw)
72
+ dows.add(v === 7 ? 0 : v);
73
+ return {
74
+ minutes,
75
+ hours,
76
+ doms,
77
+ months,
78
+ dows,
79
+ domStar: domRaw === "*",
80
+ dowStar: dowRaw === "*",
81
+ };
82
+ }
83
+ /**
84
+ * Return the next Date strictly after `after` that matches the cron expression.
85
+ * Iterates minute-by-minute with month/day fast-forwarding. Capped at ~5
86
+ * years lookahead to guard against unsatisfiable expressions.
87
+ */
88
+ export function nextRun(expr, after = new Date()) {
89
+ const c = typeof expr === "string" ? parseCron(expr) : expr;
90
+ const cursor = new Date(after.getTime());
91
+ cursor.setSeconds(0, 0);
92
+ cursor.setMinutes(cursor.getMinutes() + 1);
93
+ const limit = new Date(after.getTime() + 5 * 366 * 24 * 60 * 60 * 1000);
94
+ while (cursor <= limit) {
95
+ const month = cursor.getMonth() + 1;
96
+ if (!c.months.has(month)) {
97
+ cursor.setMonth(cursor.getMonth() + 1, 1);
98
+ cursor.setHours(0, 0, 0, 0);
99
+ continue;
100
+ }
101
+ const dom = cursor.getDate();
102
+ const dow = cursor.getDay();
103
+ const dayOk = c.domStar && c.dowStar
104
+ ? true
105
+ : c.domStar
106
+ ? c.dows.has(dow)
107
+ : c.dowStar
108
+ ? c.doms.has(dom)
109
+ : c.doms.has(dom) || c.dows.has(dow);
110
+ if (!dayOk) {
111
+ cursor.setDate(cursor.getDate() + 1);
112
+ cursor.setHours(0, 0, 0, 0);
113
+ continue;
114
+ }
115
+ if (!c.hours.has(cursor.getHours())) {
116
+ cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
117
+ continue;
118
+ }
119
+ if (!c.minutes.has(cursor.getMinutes())) {
120
+ cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
121
+ continue;
122
+ }
123
+ return cursor;
124
+ }
125
+ throw new Error(`No next run found within 5 years for cron expression`);
126
+ }
127
+ export function validateCron(expr) {
128
+ try {
129
+ const next = nextRun(expr);
130
+ return { ok: true, next };
131
+ }
132
+ catch (err) {
133
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
134
+ }
135
+ }
136
+ //# sourceMappingURL=cron.js.map