heyio 0.2.1 → 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.
- package/dist/api/server.js +16 -1
- package/dist/copilot/agents.js +98 -4
- package/dist/copilot/cron.js +136 -0
- package/dist/copilot/event-summary.js +286 -0
- package/dist/copilot/orchestrator.js +1 -0
- package/dist/copilot/scheduler.js +155 -0
- package/dist/copilot/system-message.js +19 -2
- package/dist/copilot/tools.js +211 -6
- package/dist/daemon.js +4 -0
- package/dist/store/db.js +14 -0
- package/dist/store/schedules.js +73 -0
- package/dist/tui/index.js +62 -2
- package/package.json +1 -1
- package/web-dist/assets/index-BWGQix5_.css +1 -0
- package/web-dist/assets/index-BksyB2za.js +74 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-B6FXWKsy.js +0 -74
- package/web-dist/assets/index-CXTrW8OO.css +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Squad scheduler — fires recurring "stand-up" meetings on a cron schedule.
|
|
2
|
+
//
|
|
3
|
+
// Design:
|
|
4
|
+
// - Runs as a single setInterval on the daemon (TICK_MS).
|
|
5
|
+
// - Each tick: fetch schedules whose next_run_at is in the past.
|
|
6
|
+
// - For each due schedule: compose a stand-up prompt and delegate it to the
|
|
7
|
+
// squad lead via the existing delegateToAgent() pipeline. The lead then
|
|
8
|
+
// orchestrates the agenda by delegating subtasks to teammates internally.
|
|
9
|
+
// - After firing, advance next_run_at to the next cron occurrence.
|
|
10
|
+
//
|
|
11
|
+
// Schedules survive daemon restarts because next_run_at is persisted. On
|
|
12
|
+
// startup we backfill any next_run_at fields that became stale (or are NULL).
|
|
13
|
+
import { listSchedules, listDueSchedules, recordScheduleRun, updateNextRun } from "../store/schedules.js";
|
|
14
|
+
import { getSquad } from "../store/squads.js";
|
|
15
|
+
import { delegateToAgent } from "./agents.js";
|
|
16
|
+
import { nextRun } from "./cron.js";
|
|
17
|
+
const TICK_MS = 30_000;
|
|
18
|
+
const AGENDA_BLOCKS = {
|
|
19
|
+
triage: `**Triage**
|
|
20
|
+
- Use the GitHub CLI (\`gh issue list\`) to pull open issues with the \`needs-triage\` label.
|
|
21
|
+
- For each issue: read the body and decide on appropriate labels (priority, area, type, etc).
|
|
22
|
+
- Apply labels with \`gh issue edit <num> --add-label "..."\` and remove \`needs-triage\` once labelled.
|
|
23
|
+
- If the issue lacks information, post a clarifying comment with \`gh issue comment\` instead of labelling, and leave \`needs-triage\` on it.`,
|
|
24
|
+
prioritize: `**Prioritize**
|
|
25
|
+
- Identify open issues that are ready to be worked on: properly labelled, not blocked, no \`needs-triage\` or \`needs-review\` label, no open PR already addressing them.
|
|
26
|
+
- Rank by priority labels and surface the top candidate.
|
|
27
|
+
- After the stand-up, the team lead should immediately begin work on the highest-priority ready issue by delegating it to the right teammate.`,
|
|
28
|
+
ideation: `**Ideation**
|
|
29
|
+
- Brainstorm 1–3 concrete improvements or new features for the project.
|
|
30
|
+
- Discuss as a team (use \`delegate_to_teammate\` to gather input from members whose expertise fits the idea).
|
|
31
|
+
- For each idea the team agrees on, create a GitHub issue with \`gh issue create\` tagged with the \`needs-review\` label so the human can approve it before work begins.`,
|
|
32
|
+
};
|
|
33
|
+
function buildStandupPrompt(squad, schedule) {
|
|
34
|
+
const blocks = schedule.agenda
|
|
35
|
+
.map((item) => AGENDA_BLOCKS[item] ?? `**${item}** _(no built-in template — improvise)_`)
|
|
36
|
+
.join("\n\n");
|
|
37
|
+
const notes = schedule.notes ? `\n\n**Operator notes:** ${schedule.notes}` : "";
|
|
38
|
+
return `# Scheduled stand-up: ${schedule.name}
|
|
39
|
+
|
|
40
|
+
You are the team lead for the **${squad.name}** squad (\`${squad.slug}\`). Run a stand-up meeting now.
|
|
41
|
+
|
|
42
|
+
**Project path:** \`${squad.project_path}\` — \`cd\` here before invoking the GitHub CLI so it picks up the right repo.
|
|
43
|
+
|
|
44
|
+
**Agenda** (work through these in order; use \`delegate_to_teammate\` to pull in the right specialist for each item):
|
|
45
|
+
|
|
46
|
+
${blocks}
|
|
47
|
+
|
|
48
|
+
When you finish the agenda, summarise what was triaged, what was prioritised (and what work you've kicked off), and what new issues were filed during ideation.${notes}`;
|
|
49
|
+
}
|
|
50
|
+
let timer;
|
|
51
|
+
const inFlight = new Set();
|
|
52
|
+
async function fireSchedule(schedule) {
|
|
53
|
+
if (inFlight.has(schedule.id))
|
|
54
|
+
return;
|
|
55
|
+
const squad = getSquad(schedule.squad_slug);
|
|
56
|
+
if (!squad) {
|
|
57
|
+
console.error(`[io] scheduler: squad "${schedule.squad_slug}" missing for schedule ${schedule.id}; disabling next_run_at`);
|
|
58
|
+
updateNextRun(schedule.id, null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
inFlight.add(schedule.id);
|
|
62
|
+
const ranAt = new Date();
|
|
63
|
+
let nextIso = null;
|
|
64
|
+
try {
|
|
65
|
+
nextIso = nextRun(schedule.cron_expr, ranAt).toISOString();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error(`[io] scheduler: cron parse error for schedule ${schedule.id}:`, err instanceof Error ? err.message : err);
|
|
69
|
+
}
|
|
70
|
+
recordScheduleRun(schedule.id, ranAt, nextIso);
|
|
71
|
+
const prompt = buildStandupPrompt({ name: squad.name, slug: squad.slug, project_path: squad.project_path }, schedule);
|
|
72
|
+
console.log(`[io] scheduler: firing schedule "${schedule.name}" for squad "${squad.slug}" (next run: ${nextIso ?? "never"})`);
|
|
73
|
+
try {
|
|
74
|
+
await delegateToAgent(squad.slug, prompt, () => {
|
|
75
|
+
// No-op: result is recorded on the agent task; the standup is fire-and-forget.
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error(`[io] scheduler: failed to delegate stand-up for schedule ${schedule.id}:`, err instanceof Error ? err.message : err);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
inFlight.delete(schedule.id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function tick() {
|
|
86
|
+
let due;
|
|
87
|
+
try {
|
|
88
|
+
due = listDueSchedules(new Date());
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error("[io] scheduler tick failed:", err instanceof Error ? err.message : err);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const s of due) {
|
|
95
|
+
// Sequential — avoid stampeding multiple stand-ups simultaneously.
|
|
96
|
+
await fireSchedule(s);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Backfill next_run_at for any schedules where it's NULL or already in the past
|
|
101
|
+
* but should not have fired (e.g. daemon was offline). We deliberately advance
|
|
102
|
+
* to the next future occurrence rather than replaying missed runs.
|
|
103
|
+
*/
|
|
104
|
+
export function reconcileSchedules(now = new Date()) {
|
|
105
|
+
for (const s of listSchedules()) {
|
|
106
|
+
if (!s.enabled)
|
|
107
|
+
continue;
|
|
108
|
+
let needsUpdate = false;
|
|
109
|
+
if (!s.next_run_at) {
|
|
110
|
+
needsUpdate = true;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const next = new Date(s.next_run_at);
|
|
114
|
+
if (Number.isNaN(next.getTime()) || next <= now)
|
|
115
|
+
needsUpdate = true;
|
|
116
|
+
}
|
|
117
|
+
if (!needsUpdate)
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
const next = nextRun(s.cron_expr, now);
|
|
121
|
+
updateNextRun(s.id, next.toISOString());
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error(`[io] scheduler: invalid cron "${s.cron_expr}" on schedule ${s.id}; disabling next_run_at:`, err instanceof Error ? err.message : err);
|
|
125
|
+
updateNextRun(s.id, null);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function startScheduler() {
|
|
130
|
+
if (timer)
|
|
131
|
+
return;
|
|
132
|
+
reconcileSchedules();
|
|
133
|
+
timer = setInterval(() => {
|
|
134
|
+
void tick();
|
|
135
|
+
}, TICK_MS);
|
|
136
|
+
if (typeof timer.unref === "function")
|
|
137
|
+
timer.unref();
|
|
138
|
+
console.log(`[io] Scheduler started (tick every ${TICK_MS / 1000}s)`);
|
|
139
|
+
}
|
|
140
|
+
export function stopScheduler() {
|
|
141
|
+
if (!timer)
|
|
142
|
+
return;
|
|
143
|
+
clearInterval(timer);
|
|
144
|
+
timer = undefined;
|
|
145
|
+
}
|
|
146
|
+
/** Manually fire a schedule. Used by squad_schedule_run_now. */
|
|
147
|
+
export async function runScheduleNow(scheduleId) {
|
|
148
|
+
const all = listSchedules();
|
|
149
|
+
const s = all.find((x) => x.id === scheduleId);
|
|
150
|
+
if (!s)
|
|
151
|
+
return { ok: false, error: `Schedule ${scheduleId} not found` };
|
|
152
|
+
await fireSchedule(s);
|
|
153
|
+
return { ok: true };
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -93,12 +93,29 @@ Every squad should have a **team lead**. After building the team with \`squad_ad
|
|
|
93
93
|
### Peer Review & QA Approvals
|
|
94
94
|
When an agent finishes a task, the other squad members automatically review the work and vote APPROVED or REJECTED. Reviews are recorded and emitted as \`task.review\` events.
|
|
95
95
|
|
|
96
|
-
-
|
|
97
|
-
-
|
|
96
|
+
- **Required**: every squad must have at least one agent designated as QA via \`squad_set_qa\`, AND at least one agent whose role title implies a testing/quality focus (e.g. role contains "test", "qa", or "quality"). Both can be the same agent.
|
|
97
|
+
- \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when either is missing. Delegation is not blocked, but you should fix the gap before promoting work.
|
|
98
|
+
- **QA agents and the team lead have veto power**: if any QA reviewer or the team lead rejects, the PR stays as a draft. The lead's veto is automatic — no need to also designate them as QA.
|
|
98
99
|
- Non-QA rejections are advisory — they're recorded but don't block promotion.
|
|
99
100
|
- When all QA approvals pass (or no QA agents exist) and the task result contains a GitHub PR URL, the PR is automatically promoted from draft to ready via \`gh pr ready\`.
|
|
100
101
|
- Use \`squad_task_reviews\` to inspect the reviews on any completed task.
|
|
101
102
|
|
|
103
|
+
### Squad Build Checklist
|
|
104
|
+
After \`squad_create\`, before delegating real work:
|
|
105
|
+
1. Add agents with \`squad_add_agent\` (use roles tailored to the project's stack).
|
|
106
|
+
2. Include at least one **test/quality engineer** role (e.g. "Integration Test Engineer", "QA Specialist", "Quality Reviewer").
|
|
107
|
+
3. Designate a team lead with \`squad_set_lead\`.
|
|
108
|
+
4. Designate at least one QA reviewer with \`squad_set_qa\` (often the same agent as the test engineer).
|
|
109
|
+
|
|
110
|
+
### Scheduled Stand-ups
|
|
111
|
+
Squads can be put on a recurring cron-style schedule. At the scheduled time IO wakes the team lead, who runs the agenda by delegating to teammates. This runs in the background even when no human is in the TUI/Telegram.
|
|
112
|
+
|
|
113
|
+
- \`squad_schedule_create\` — create a recurring stand-up. Cron uses standard 5-field syntax: "minute hour day-of-month month day-of-week". Examples: \`0 5 * * *\` (daily 5AM), \`0 9 * * 1-5\` (9AM weekdays), \`30 14 * * 1\` (Mondays 14:30).
|
|
114
|
+
- Built-in agenda items: \`triage\` (process \`needs-triage\` issues), \`prioritize\` (pick highest-priority ready work and start on it), \`ideation\` (brainstorm and open \`needs-review\` issues for the human). Custom items are passed verbatim to the lead.
|
|
115
|
+
- \`squad_schedule_list\`, \`squad_schedule_pause\`, \`squad_schedule_resume\`, \`squad_schedule_delete\`, \`squad_schedule_run_now\` round out the lifecycle.
|
|
116
|
+
|
|
117
|
+
When a user asks something like "have the IO squad meet every weekday at 5AM to triage and prioritize", call \`squad_schedule_create\` with \`cron: "0 5 * * 1-5"\` and \`agenda: ["triage", "prioritize"]\`.
|
|
118
|
+
|
|
102
119
|
### Agent Roles Are Dynamic
|
|
103
120
|
**Do NOT use generic roles** like "developer" or "tester". Analyze the project first and create roles that match its actual technology stack. Examples:
|
|
104
121
|
- IO project → "Copilot SDK Specialist", "Vue.js Frontend Dev", "Express API Engineer"
|
package/dist/copilot/tools.js
CHANGED
|
@@ -5,6 +5,43 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSy
|
|
|
5
5
|
import { join, dirname, resolve } from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { UNIVERSES } from "./universes.js";
|
|
8
|
+
import { validateCron, nextRun } from "./cron.js";
|
|
9
|
+
import { createSchedule, deleteSchedule, getSchedule, listSchedules, setScheduleEnabled, } from "../store/schedules.js";
|
|
10
|
+
import { runScheduleNow } from "./scheduler.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// QA / test coverage heuristics
|
|
13
|
+
//
|
|
14
|
+
// Every squad must have:
|
|
15
|
+
// 1. At least one agent designated as QA (is_qa === 1) - see squad_set_qa.
|
|
16
|
+
// 2. At least one agent whose role title implies a testing/quality focus.
|
|
17
|
+
//
|
|
18
|
+
// These are surfaced as warnings on squad_status, squad_agents, and
|
|
19
|
+
// squad_delegate so users can fix coverage gaps before promoting work.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const TEST_ROLE_KEYWORDS = ["test", "qa", "quality", "tester", "sdet", "qe"];
|
|
22
|
+
export function roleLooksLikeTesting(roleTitle) {
|
|
23
|
+
if (!roleTitle)
|
|
24
|
+
return false;
|
|
25
|
+
const lower = roleTitle.toLowerCase();
|
|
26
|
+
return TEST_ROLE_KEYWORDS.some((kw) => {
|
|
27
|
+
const re = new RegExp(`(^|[^a-z])${kw}([^a-z]|$)`);
|
|
28
|
+
return re.test(lower);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function assessSquadCoverage(agents) {
|
|
32
|
+
const hasQa = agents.some((a) => a.is_qa === 1);
|
|
33
|
+
const hasTestRole = agents.some((a) => roleLooksLikeTesting(a.role_title));
|
|
34
|
+
const missing = [];
|
|
35
|
+
if (!hasQa)
|
|
36
|
+
missing.push("QA reviewer (use squad_set_qa)");
|
|
37
|
+
if (!hasTestRole) {
|
|
38
|
+
missing.push("test/quality engineer (add an agent whose role_title contains 'test', 'qa', or 'quality')");
|
|
39
|
+
}
|
|
40
|
+
const warning = missing.length > 0
|
|
41
|
+
? `⚠️ Squad coverage gap: missing ${missing.join(" and ")}.`
|
|
42
|
+
: null;
|
|
43
|
+
return { hasQa, hasTestRole, missing, warning };
|
|
44
|
+
}
|
|
8
45
|
// Ensure child processes have HOME set (systemd services often don't)
|
|
9
46
|
function shellEnv() {
|
|
10
47
|
const env = { ...process.env };
|
|
@@ -118,7 +155,9 @@ export function createTools(deps) {
|
|
|
118
155
|
const agentList = agents.length > 0
|
|
119
156
|
? "\n Agents: " + agents.map((a) => `${a.character_name} (${a.role_title})`).join(", ")
|
|
120
157
|
: "\n Agents: none — use squad_add_agent to build the team";
|
|
121
|
-
|
|
158
|
+
const coverage = assessSquadCoverage(agents);
|
|
159
|
+
const coverageLine = coverage.warning ? `\n ${coverage.warning}` : "";
|
|
160
|
+
return `- **${s.name}** (\`${s.slug}\`) — ${s.status} — 🎬 ${universeName}${leadLine}${agentList}${coverageLine}\n 📁 ${s.projectPath}`;
|
|
122
161
|
})
|
|
123
162
|
.join("\n");
|
|
124
163
|
},
|
|
@@ -156,12 +195,17 @@ export function createTools(deps) {
|
|
|
156
195
|
}),
|
|
157
196
|
handler: async ({ slug, task, agent }) => {
|
|
158
197
|
console.error(`[io] squad_delegate called: ${slug}${agent ? ` → ${agent}` : ""} — ${task.slice(0, 100)}…`);
|
|
198
|
+
const roster = deps.listSquadAgents(slug);
|
|
199
|
+
const coverage = assessSquadCoverage(roster);
|
|
159
200
|
try {
|
|
160
201
|
const taskId = await deps.delegateToAgent(slug, task, (id, result) => {
|
|
161
202
|
console.error(`[io] Agent task ${id} completed for squad ${slug}`);
|
|
162
203
|
}, agent);
|
|
163
204
|
const agentLabel = agent ? `agent "${agent}" in squad "${slug}"` : `squad "${slug}"`;
|
|
164
|
-
|
|
205
|
+
const warningPrefix = coverage.warning
|
|
206
|
+
? `${coverage.warning} Reviews from this squad will not be vetoed by a designated QA agent until this is fixed.\n\n`
|
|
207
|
+
: "";
|
|
208
|
+
return `${warningPrefix}Task delegated to ${agentLabel}. Task ID: ${taskId}\n\nThe agent is working on this in the background. Use squad_task_status to check progress.`;
|
|
165
209
|
}
|
|
166
210
|
catch (err) {
|
|
167
211
|
return `Error delegating task: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -382,7 +426,9 @@ export function createTools(deps) {
|
|
|
382
426
|
const qaBadge = a.is_qa === 1 ? " 🛡️ [QA]" : "";
|
|
383
427
|
return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (${a.model_tier}) — ${a.status}${a.personality ? `\n _${a.personality}_` : ""}`;
|
|
384
428
|
});
|
|
385
|
-
|
|
429
|
+
const coverage = assessSquadCoverage(agents);
|
|
430
|
+
const coverageBlock = coverage.warning ? `\n\n${coverage.warning}` : "";
|
|
431
|
+
return `**${squad.name}** — 🎬 ${universeName}\n\n${lines.join("\n")}${coverageBlock}`;
|
|
386
432
|
},
|
|
387
433
|
});
|
|
388
434
|
// --- Squad remove agent ---
|
|
@@ -1022,11 +1068,46 @@ export function createTools(deps) {
|
|
|
1022
1068
|
if (reviews.length === 0) {
|
|
1023
1069
|
return `No reviews found for task ${task_id}.`;
|
|
1024
1070
|
}
|
|
1071
|
+
// Look up reviewer roles (lead/qa) so we can flag where each verdict
|
|
1072
|
+
// came from. Lead and QA reviewers both have veto power.
|
|
1073
|
+
const rolesBySquad = new Map();
|
|
1074
|
+
const rolesFor = (squadSlug, character) => {
|
|
1075
|
+
let squadMap = rolesBySquad.get(squadSlug);
|
|
1076
|
+
if (!squadMap) {
|
|
1077
|
+
squadMap = new Map();
|
|
1078
|
+
for (const a of deps.listSquadAgents(squadSlug)) {
|
|
1079
|
+
squadMap.set(a.character_name, {
|
|
1080
|
+
is_lead: a.is_lead === 1,
|
|
1081
|
+
is_qa: a.is_qa === 1,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
rolesBySquad.set(squadSlug, squadMap);
|
|
1085
|
+
}
|
|
1086
|
+
return squadMap.get(character) ?? { is_lead: false, is_qa: false };
|
|
1087
|
+
};
|
|
1025
1088
|
return reviews
|
|
1026
1089
|
.map((r) => {
|
|
1027
|
-
const
|
|
1090
|
+
const { is_lead, is_qa } = rolesFor(r.squad_slug, r.reviewer_character);
|
|
1091
|
+
const approved = r.approved === 1;
|
|
1092
|
+
let badge = "";
|
|
1093
|
+
if (is_lead && is_qa)
|
|
1094
|
+
badge = "⭐🛡️ ";
|
|
1095
|
+
else if (is_lead)
|
|
1096
|
+
badge = "⭐ ";
|
|
1097
|
+
else if (is_qa)
|
|
1098
|
+
badge = "🛡️ ";
|
|
1099
|
+
const verdict = approved
|
|
1100
|
+
? `${badge}✅ APPROVED`
|
|
1101
|
+
: `${badge}❌ REJECTED`;
|
|
1102
|
+
const tags = [];
|
|
1103
|
+
if (is_lead)
|
|
1104
|
+
tags.push("lead");
|
|
1105
|
+
if (is_qa)
|
|
1106
|
+
tags.push("QA");
|
|
1107
|
+
const tagSuffix = tags.length ? ` _(${tags.join(", ")})_` : "";
|
|
1108
|
+
const veto = !approved && (is_lead || is_qa) ? " — **veto**" : "";
|
|
1028
1109
|
const comments = r.comments ? `\n ${r.comments.replace(/\n/g, "\n ")}` : "";
|
|
1029
|
-
return `- **${r.reviewer_character}
|
|
1110
|
+
return `- **${r.reviewer_character}**${tagSuffix} — ${verdict}${veto}${comments}`;
|
|
1030
1111
|
})
|
|
1031
1112
|
.join("\n");
|
|
1032
1113
|
},
|
|
@@ -1058,7 +1139,131 @@ export function createTools(deps) {
|
|
|
1058
1139
|
}
|
|
1059
1140
|
},
|
|
1060
1141
|
});
|
|
1061
|
-
|
|
1142
|
+
// ---------------------------------------------------------------------------
|
|
1143
|
+
// Squad schedules — recurring stand-ups via cron-style expressions.
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
const KNOWN_AGENDA_ITEMS = ["triage", "prioritize", "ideation"];
|
|
1146
|
+
const squadScheduleCreate = defineTool("squad_schedule_create", {
|
|
1147
|
+
description: "Schedule a recurring stand-up for a squad. The squad wakes on the cron schedule, the team lead runs the agenda, and teammates are pulled in via delegate_to_teammate. Built-in agenda items: triage (process needs-triage issues), prioritize (pick highest-priority ready work and start it), ideation (brainstorm + open needs-review issues). Custom agenda items are passed through to the lead verbatim.",
|
|
1148
|
+
skipPermission: true,
|
|
1149
|
+
parameters: z.object({
|
|
1150
|
+
slug: z.string().describe("Squad slug to schedule"),
|
|
1151
|
+
name: z
|
|
1152
|
+
.string()
|
|
1153
|
+
.describe("Human-friendly name for this schedule, e.g. 'Daily 5AM stand-up'"),
|
|
1154
|
+
cron: z
|
|
1155
|
+
.string()
|
|
1156
|
+
.describe("Standard 5-field cron expression: 'minute hour dom month dow'. Examples: '0 5 * * *' = daily at 5:00, '0 9 * * 1-5' = 9AM weekdays, '*/15 * * * *' = every 15 minutes."),
|
|
1157
|
+
agenda: z
|
|
1158
|
+
.array(z.string())
|
|
1159
|
+
.min(1)
|
|
1160
|
+
.describe(`Ordered agenda. Built-in items: ${KNOWN_AGENDA_ITEMS.join(", ")}. You may include custom items; the team lead will improvise.`),
|
|
1161
|
+
notes: z
|
|
1162
|
+
.string()
|
|
1163
|
+
.optional()
|
|
1164
|
+
.describe("Optional operator notes appended to the stand-up prompt."),
|
|
1165
|
+
}),
|
|
1166
|
+
handler: async ({ slug, name, cron, agenda, notes }) => {
|
|
1167
|
+
const squad = deps.getSquad(slug);
|
|
1168
|
+
if (!squad)
|
|
1169
|
+
return `Squad not found: ${slug}`;
|
|
1170
|
+
const v = validateCron(cron);
|
|
1171
|
+
if (!v.ok)
|
|
1172
|
+
return `Invalid cron expression: ${v.error}`;
|
|
1173
|
+
const created = createSchedule({
|
|
1174
|
+
squadSlug: slug,
|
|
1175
|
+
name,
|
|
1176
|
+
cronExpr: cron,
|
|
1177
|
+
agenda,
|
|
1178
|
+
notes: notes ?? null,
|
|
1179
|
+
nextRunAt: v.next.toISOString(),
|
|
1180
|
+
});
|
|
1181
|
+
return `📅 Scheduled "${created.name}" for squad "${squad.name}" (id ${created.id}).\n- Cron: \`${cron}\`\n- Agenda: ${agenda.join(", ")}\n- Next run: ${v.next.toISOString()}`;
|
|
1182
|
+
},
|
|
1183
|
+
});
|
|
1184
|
+
const squadScheduleList = defineTool("squad_schedule_list", {
|
|
1185
|
+
description: "List squad stand-up schedules. Pass a slug to filter to one squad, or omit to list all.",
|
|
1186
|
+
skipPermission: true,
|
|
1187
|
+
parameters: z.object({
|
|
1188
|
+
slug: z.string().optional().describe("Optional squad slug to filter"),
|
|
1189
|
+
}),
|
|
1190
|
+
handler: async ({ slug }) => {
|
|
1191
|
+
const schedules = listSchedules(slug);
|
|
1192
|
+
if (schedules.length === 0) {
|
|
1193
|
+
return slug
|
|
1194
|
+
? `No schedules for squad "${slug}".`
|
|
1195
|
+
: "No squad schedules configured.";
|
|
1196
|
+
}
|
|
1197
|
+
return schedules
|
|
1198
|
+
.map((s) => {
|
|
1199
|
+
const enabled = s.enabled ? "▶️ enabled" : "⏸️ paused";
|
|
1200
|
+
const last = s.last_run_at ? `last ${s.last_run_at}` : "never run";
|
|
1201
|
+
const next = s.next_run_at ?? "—";
|
|
1202
|
+
return `- **${s.name}** (id ${s.id}) — squad \`${s.squad_slug}\` — ${enabled}\n cron: \`${s.cron_expr}\` — agenda: ${s.agenda.join(", ")}\n next: ${next} — ${last}${s.notes ? `\n notes: ${s.notes}` : ""}`;
|
|
1203
|
+
})
|
|
1204
|
+
.join("\n");
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
const squadScheduleDelete = defineTool("squad_schedule_delete", {
|
|
1208
|
+
description: "Delete a squad schedule by id.",
|
|
1209
|
+
skipPermission: true,
|
|
1210
|
+
parameters: z.object({
|
|
1211
|
+
id: z.number().int().describe("Schedule id (from squad_schedule_list)"),
|
|
1212
|
+
}),
|
|
1213
|
+
handler: async ({ id }) => {
|
|
1214
|
+
const existing = getSchedule(id);
|
|
1215
|
+
if (!existing)
|
|
1216
|
+
return `Schedule ${id} not found.`;
|
|
1217
|
+
deleteSchedule(id);
|
|
1218
|
+
return `🗑️ Deleted schedule "${existing.name}" (id ${id}).`;
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
const squadSchedulePause = defineTool("squad_schedule_pause", {
|
|
1222
|
+
description: "Pause a squad schedule so it stops firing (preserves config).",
|
|
1223
|
+
skipPermission: true,
|
|
1224
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1225
|
+
handler: async ({ id }) => {
|
|
1226
|
+
const existing = getSchedule(id);
|
|
1227
|
+
if (!existing)
|
|
1228
|
+
return `Schedule ${id} not found.`;
|
|
1229
|
+
setScheduleEnabled(id, false);
|
|
1230
|
+
return `⏸️ Paused schedule "${existing.name}" (id ${id}).`;
|
|
1231
|
+
},
|
|
1232
|
+
});
|
|
1233
|
+
const squadScheduleResume = defineTool("squad_schedule_resume", {
|
|
1234
|
+
description: "Resume a paused squad schedule. The next run is computed from now using the stored cron expression.",
|
|
1235
|
+
skipPermission: true,
|
|
1236
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1237
|
+
handler: async ({ id }) => {
|
|
1238
|
+
const existing = getSchedule(id);
|
|
1239
|
+
if (!existing)
|
|
1240
|
+
return `Schedule ${id} not found.`;
|
|
1241
|
+
setScheduleEnabled(id, true);
|
|
1242
|
+
try {
|
|
1243
|
+
const next = nextRun(existing.cron_expr);
|
|
1244
|
+
// Update next_run_at via the store's helper would be cleaner, but we
|
|
1245
|
+
// can also just re-run reconcile on next tick. Inline update:
|
|
1246
|
+
const { updateNextRun } = await import("../store/schedules.js");
|
|
1247
|
+
updateNextRun(id, next.toISOString());
|
|
1248
|
+
return `▶️ Resumed schedule "${existing.name}" (id ${id}). Next run: ${next.toISOString()}`;
|
|
1249
|
+
}
|
|
1250
|
+
catch (err) {
|
|
1251
|
+
return `Resumed schedule "${existing.name}" but failed to compute next run: ${err instanceof Error ? err.message : String(err)}`;
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1255
|
+
const squadScheduleRunNow = defineTool("squad_schedule_run_now", {
|
|
1256
|
+
description: "Manually fire a squad schedule immediately (useful for testing). Does not affect the next scheduled occurrence.",
|
|
1257
|
+
skipPermission: true,
|
|
1258
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1259
|
+
handler: async ({ id }) => {
|
|
1260
|
+
const result = await runScheduleNow(id);
|
|
1261
|
+
if (!result.ok)
|
|
1262
|
+
return `Failed: ${result.error}`;
|
|
1263
|
+
return `🚀 Fired schedule ${id} now. Use squad_task_status to follow the resulting stand-up.`;
|
|
1264
|
+
},
|
|
1265
|
+
});
|
|
1266
|
+
return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadSetLead, squadSetQA, squadTaskReviews, squadScheduleCreate, squadScheduleList, squadScheduleDelete, squadSchedulePause, squadScheduleResume, squadScheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github];
|
|
1062
1267
|
}
|
|
1063
1268
|
function walkDirectory(dir, maxDepth = 3, depth = 0) {
|
|
1064
1269
|
if (depth >= maxDepth)
|
package/dist/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ import { startApiServer, setMessageHandler as setApiHandler } from "./api/server
|
|
|
4
4
|
import { createBot, startBot, stopBot, sendProactiveMessage, setMessageHandler as setTelegramHandler } from "./telegram/bot.js";
|
|
5
5
|
import { getDb, closeDb } from "./store/db.js";
|
|
6
6
|
import { clearStaleTasks } from "./store/tasks.js";
|
|
7
|
+
import { startScheduler, stopScheduler } from "./copilot/scheduler.js";
|
|
7
8
|
import { config } from "./config.js";
|
|
8
9
|
import { ensureWikiStructure } from "./wiki/fs.js";
|
|
9
10
|
import { autoUpdate } from "./update.js";
|
|
@@ -90,6 +91,8 @@ export async function startDaemon() {
|
|
|
90
91
|
else {
|
|
91
92
|
console.log("[io] Telegram not configured — skipping bot. Set telegramBotToken in ~/.io/config.json");
|
|
92
93
|
}
|
|
94
|
+
// Start the squad scheduler (background cron-style stand-ups).
|
|
95
|
+
startScheduler();
|
|
93
96
|
console.log("[io] IO is fully operational.");
|
|
94
97
|
// Notify Telegram if restarting
|
|
95
98
|
if (config.telegramEnabled && process.env.IO_RESTARTED === "1") {
|
|
@@ -117,6 +120,7 @@ async function shutdown() {
|
|
|
117
120
|
}
|
|
118
121
|
catch { /* best effort */ }
|
|
119
122
|
}
|
|
123
|
+
stopScheduler();
|
|
120
124
|
await shutdownOrchestrator();
|
|
121
125
|
try {
|
|
122
126
|
await stopClient();
|
package/dist/store/db.js
CHANGED
|
@@ -82,6 +82,20 @@ export function getDb() {
|
|
|
82
82
|
comments TEXT,
|
|
83
83
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
84
84
|
)`,
|
|
85
|
+
`CREATE TABLE IF NOT EXISTS squad_schedules (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
squad_slug TEXT NOT NULL,
|
|
88
|
+
name TEXT NOT NULL,
|
|
89
|
+
cron_expr TEXT NOT NULL,
|
|
90
|
+
agenda TEXT NOT NULL,
|
|
91
|
+
notes TEXT,
|
|
92
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
93
|
+
last_run_at DATETIME,
|
|
94
|
+
next_run_at DATETIME,
|
|
95
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
96
|
+
)`,
|
|
97
|
+
`CREATE INDEX IF NOT EXISTS idx_squad_schedules_due
|
|
98
|
+
ON squad_schedules (enabled, next_run_at)`,
|
|
85
99
|
];
|
|
86
100
|
for (const migration of migrations) {
|
|
87
101
|
try {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
function rowToSchedule(row) {
|
|
3
|
+
let agenda = [];
|
|
4
|
+
try {
|
|
5
|
+
agenda = JSON.parse(row.agenda);
|
|
6
|
+
if (!Array.isArray(agenda))
|
|
7
|
+
agenda = [];
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
agenda = [];
|
|
11
|
+
}
|
|
12
|
+
return { ...row, agenda };
|
|
13
|
+
}
|
|
14
|
+
export function createSchedule(input) {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
const info = db
|
|
17
|
+
.prepare(`INSERT INTO squad_schedules
|
|
18
|
+
(squad_slug, name, cron_expr, agenda, notes, enabled, next_run_at)
|
|
19
|
+
VALUES (?, ?, ?, ?, ?, 1, ?)`)
|
|
20
|
+
.run(input.squadSlug, input.name, input.cronExpr, JSON.stringify(input.agenda), input.notes ?? null, input.nextRunAt);
|
|
21
|
+
const id = Number(info.lastInsertRowid);
|
|
22
|
+
return getSchedule(id);
|
|
23
|
+
}
|
|
24
|
+
export function getSchedule(id) {
|
|
25
|
+
const row = getDb()
|
|
26
|
+
.prepare("SELECT * FROM squad_schedules WHERE id = ?")
|
|
27
|
+
.get(id);
|
|
28
|
+
return row ? rowToSchedule(row) : undefined;
|
|
29
|
+
}
|
|
30
|
+
export function listSchedules(squadSlug) {
|
|
31
|
+
const rows = squadSlug
|
|
32
|
+
? getDb()
|
|
33
|
+
.prepare("SELECT * FROM squad_schedules WHERE squad_slug = ? ORDER BY id ASC")
|
|
34
|
+
.all(squadSlug)
|
|
35
|
+
: getDb()
|
|
36
|
+
.prepare("SELECT * FROM squad_schedules ORDER BY squad_slug, id ASC")
|
|
37
|
+
.all();
|
|
38
|
+
return rows.map(rowToSchedule);
|
|
39
|
+
}
|
|
40
|
+
export function listDueSchedules(now) {
|
|
41
|
+
const iso = now.toISOString();
|
|
42
|
+
const rows = getDb()
|
|
43
|
+
.prepare(`SELECT * FROM squad_schedules
|
|
44
|
+
WHERE enabled = 1
|
|
45
|
+
AND next_run_at IS NOT NULL
|
|
46
|
+
AND next_run_at <= ?
|
|
47
|
+
ORDER BY next_run_at ASC`)
|
|
48
|
+
.all(iso);
|
|
49
|
+
return rows.map(rowToSchedule);
|
|
50
|
+
}
|
|
51
|
+
export function deleteSchedule(id) {
|
|
52
|
+
const info = getDb()
|
|
53
|
+
.prepare("DELETE FROM squad_schedules WHERE id = ?")
|
|
54
|
+
.run(id);
|
|
55
|
+
return info.changes > 0;
|
|
56
|
+
}
|
|
57
|
+
export function setScheduleEnabled(id, enabled) {
|
|
58
|
+
const info = getDb()
|
|
59
|
+
.prepare("UPDATE squad_schedules SET enabled = ? WHERE id = ?")
|
|
60
|
+
.run(enabled ? 1 : 0, id);
|
|
61
|
+
return info.changes > 0;
|
|
62
|
+
}
|
|
63
|
+
export function recordScheduleRun(id, ranAt, nextRunAt) {
|
|
64
|
+
getDb()
|
|
65
|
+
.prepare("UPDATE squad_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
66
|
+
.run(ranAt.toISOString(), nextRunAt, id);
|
|
67
|
+
}
|
|
68
|
+
export function updateNextRun(id, nextRunAt) {
|
|
69
|
+
getDb()
|
|
70
|
+
.prepare("UPDATE squad_schedules SET next_run_at = ? WHERE id = ?")
|
|
71
|
+
.run(nextRunAt, id);
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=schedules.js.map
|
package/dist/tui/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { createInterface } from "readline";
|
|
2
|
+
import { listRecentTasks, getTask } from "../store/tasks.js";
|
|
3
|
+
import { getTaskEvents } from "../copilot/agents.js";
|
|
4
|
+
import { summarize } from "../copilot/event-summary.js";
|
|
2
5
|
let messageHandler;
|
|
3
6
|
export function setMessageHandler(handler) {
|
|
4
7
|
messageHandler = handler;
|
|
@@ -8,9 +11,49 @@ const WELCOME_BANNER = `
|
|
|
8
11
|
║ IO — AI Assistant ║
|
|
9
12
|
╚══════════════════════════════════════╝
|
|
10
13
|
Type a message to chat. Commands:
|
|
11
|
-
/status
|
|
12
|
-
/
|
|
14
|
+
/status — show status
|
|
15
|
+
/activity [id|N] — show summarized activity for a task (default: most recent)
|
|
16
|
+
/verbose — toggle verbose mode (raw event detail in /activity)
|
|
17
|
+
/quit — exit
|
|
13
18
|
`;
|
|
19
|
+
let verbose = false;
|
|
20
|
+
function renderActivity(taskIdArg) {
|
|
21
|
+
const recent = listRecentTasks(20);
|
|
22
|
+
let task = undefined;
|
|
23
|
+
if (!taskIdArg) {
|
|
24
|
+
task = recent[0];
|
|
25
|
+
}
|
|
26
|
+
else if (/^\d+$/.test(taskIdArg)) {
|
|
27
|
+
task = recent[parseInt(taskIdArg, 10) - 1];
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
task = getTask(taskIdArg);
|
|
31
|
+
}
|
|
32
|
+
if (!task) {
|
|
33
|
+
console.log("[io] No task found for activity view.");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const events = getTaskEvents(task.task_id);
|
|
37
|
+
if (events.length === 0) {
|
|
38
|
+
console.log(`[io] No buffered activity for task ${task.task_id} (${task.status}).`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const activity = summarize(events);
|
|
42
|
+
console.log(`[io] Activity for task ${task.task_id} (${task.agent_slug}, ${task.status}) — ${activity.length} entries${verbose ? " (verbose)" : ""}`);
|
|
43
|
+
for (const e of activity) {
|
|
44
|
+
const ts = new Date(e.ts).toISOString().slice(11, 19);
|
|
45
|
+
console.log(` ${ts} ${e.icon} ${e.summary}`);
|
|
46
|
+
if (verbose) {
|
|
47
|
+
if (e.detail) {
|
|
48
|
+
for (const line of e.detail.split(/\r?\n/))
|
|
49
|
+
console.log(` ${line}`);
|
|
50
|
+
}
|
|
51
|
+
else if (e.raw && typeof e.raw === "object") {
|
|
52
|
+
console.log(" " + JSON.stringify(e.raw).slice(0, 400));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
14
57
|
function clearLine() {
|
|
15
58
|
process.stdout.write("\r\x1b[K");
|
|
16
59
|
}
|
|
@@ -38,6 +81,23 @@ export async function startTui() {
|
|
|
38
81
|
rl.prompt();
|
|
39
82
|
return;
|
|
40
83
|
}
|
|
84
|
+
if (trimmed === "/verbose") {
|
|
85
|
+
verbose = !verbose;
|
|
86
|
+
console.log(`[io] Verbose mode ${verbose ? "ON" : "OFF"}`);
|
|
87
|
+
rl.prompt();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (trimmed === "/activity" || trimmed.startsWith("/activity ")) {
|
|
91
|
+
const arg = trimmed.slice("/activity".length).trim() || undefined;
|
|
92
|
+
try {
|
|
93
|
+
renderActivity(arg);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error(`[io] /activity failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
rl.prompt();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
41
101
|
if (!messageHandler) {
|
|
42
102
|
console.log("[io] No message handler registered.");
|
|
43
103
|
rl.prompt();
|