heyio 0.2.1 → 0.4.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 +127 -16
- package/dist/copilot/cron.js +136 -0
- package/dist/copilot/event-summary.js +286 -0
- package/dist/copilot/io-scheduler.js +132 -0
- package/dist/copilot/orchestrator.js +1 -0
- package/dist/copilot/review-backfill.js +57 -0
- package/dist/copilot/scheduler.js +171 -0
- package/dist/copilot/system-message.js +27 -2
- package/dist/copilot/tools.js +324 -6
- package/dist/daemon.js +31 -1
- package/dist/store/db.js +27 -0
- package/dist/store/io-schedules.js +63 -0
- package/dist/store/schedules.js +83 -0
- package/dist/store/squads.js +21 -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
package/dist/copilot/tools.js
CHANGED
|
@@ -5,6 +5,45 @@ 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 { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
|
|
10
|
+
import { runIoScheduleNow } from "./io-scheduler.js";
|
|
11
|
+
import { createSchedule, deleteSchedule, getSchedule, listSchedules, setScheduleEnabled, } from "../store/schedules.js";
|
|
12
|
+
import { runScheduleNow } from "./scheduler.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// QA / test coverage heuristics
|
|
15
|
+
//
|
|
16
|
+
// Every squad must have:
|
|
17
|
+
// 1. At least one agent designated as QA (is_qa === 1) - see squad_set_qa.
|
|
18
|
+
// 2. At least one agent whose role title implies a testing/quality focus.
|
|
19
|
+
//
|
|
20
|
+
// These are surfaced as warnings on squad_status, squad_agents, and
|
|
21
|
+
// squad_delegate so users can fix coverage gaps before promoting work.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const TEST_ROLE_KEYWORDS = ["test", "qa", "quality", "tester", "sdet", "qe"];
|
|
24
|
+
export function roleLooksLikeTesting(roleTitle) {
|
|
25
|
+
if (!roleTitle)
|
|
26
|
+
return false;
|
|
27
|
+
const lower = roleTitle.toLowerCase();
|
|
28
|
+
return TEST_ROLE_KEYWORDS.some((kw) => {
|
|
29
|
+
const re = new RegExp(`(^|[^a-z])${kw}([^a-z]|$)`);
|
|
30
|
+
return re.test(lower);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function assessSquadCoverage(agents) {
|
|
34
|
+
const hasQa = agents.some((a) => a.is_qa === 1);
|
|
35
|
+
const hasTestRole = agents.some((a) => roleLooksLikeTesting(a.role_title));
|
|
36
|
+
const missing = [];
|
|
37
|
+
if (!hasQa)
|
|
38
|
+
missing.push("QA reviewer (use squad_set_qa)");
|
|
39
|
+
if (!hasTestRole) {
|
|
40
|
+
missing.push("test/quality engineer (add an agent whose role_title contains 'test', 'qa', or 'quality')");
|
|
41
|
+
}
|
|
42
|
+
const warning = missing.length > 0
|
|
43
|
+
? `⚠️ Squad coverage gap: missing ${missing.join(" and ")}.`
|
|
44
|
+
: null;
|
|
45
|
+
return { hasQa, hasTestRole, missing, warning };
|
|
46
|
+
}
|
|
8
47
|
// Ensure child processes have HOME set (systemd services often don't)
|
|
9
48
|
function shellEnv() {
|
|
10
49
|
const env = { ...process.env };
|
|
@@ -118,7 +157,9 @@ export function createTools(deps) {
|
|
|
118
157
|
const agentList = agents.length > 0
|
|
119
158
|
? "\n Agents: " + agents.map((a) => `${a.character_name} (${a.role_title})`).join(", ")
|
|
120
159
|
: "\n Agents: none — use squad_add_agent to build the team";
|
|
121
|
-
|
|
160
|
+
const coverage = assessSquadCoverage(agents);
|
|
161
|
+
const coverageLine = coverage.warning ? `\n ${coverage.warning}` : "";
|
|
162
|
+
return `- **${s.name}** (\`${s.slug}\`) — ${s.status} — 🎬 ${universeName}${leadLine}${agentList}${coverageLine}\n 📁 ${s.projectPath}`;
|
|
122
163
|
})
|
|
123
164
|
.join("\n");
|
|
124
165
|
},
|
|
@@ -156,12 +197,17 @@ export function createTools(deps) {
|
|
|
156
197
|
}),
|
|
157
198
|
handler: async ({ slug, task, agent }) => {
|
|
158
199
|
console.error(`[io] squad_delegate called: ${slug}${agent ? ` → ${agent}` : ""} — ${task.slice(0, 100)}…`);
|
|
200
|
+
const roster = deps.listSquadAgents(slug);
|
|
201
|
+
const coverage = assessSquadCoverage(roster);
|
|
159
202
|
try {
|
|
160
203
|
const taskId = await deps.delegateToAgent(slug, task, (id, result) => {
|
|
161
204
|
console.error(`[io] Agent task ${id} completed for squad ${slug}`);
|
|
162
205
|
}, agent);
|
|
163
206
|
const agentLabel = agent ? `agent "${agent}" in squad "${slug}"` : `squad "${slug}"`;
|
|
164
|
-
|
|
207
|
+
const warningPrefix = coverage.warning
|
|
208
|
+
? `${coverage.warning} Reviews from this squad will not be vetoed by a designated QA agent until this is fixed.\n\n`
|
|
209
|
+
: "";
|
|
210
|
+
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
211
|
}
|
|
166
212
|
catch (err) {
|
|
167
213
|
return `Error delegating task: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -382,7 +428,9 @@ export function createTools(deps) {
|
|
|
382
428
|
const qaBadge = a.is_qa === 1 ? " 🛡️ [QA]" : "";
|
|
383
429
|
return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (${a.model_tier}) — ${a.status}${a.personality ? `\n _${a.personality}_` : ""}`;
|
|
384
430
|
});
|
|
385
|
-
|
|
431
|
+
const coverage = assessSquadCoverage(agents);
|
|
432
|
+
const coverageBlock = coverage.warning ? `\n\n${coverage.warning}` : "";
|
|
433
|
+
return `**${squad.name}** — 🎬 ${universeName}\n\n${lines.join("\n")}${coverageBlock}`;
|
|
386
434
|
},
|
|
387
435
|
});
|
|
388
436
|
// --- Squad remove agent ---
|
|
@@ -1022,11 +1070,46 @@ export function createTools(deps) {
|
|
|
1022
1070
|
if (reviews.length === 0) {
|
|
1023
1071
|
return `No reviews found for task ${task_id}.`;
|
|
1024
1072
|
}
|
|
1073
|
+
// Look up reviewer roles (lead/qa) so we can flag where each verdict
|
|
1074
|
+
// came from. Lead and QA reviewers both have veto power.
|
|
1075
|
+
const rolesBySquad = new Map();
|
|
1076
|
+
const rolesFor = (squadSlug, character) => {
|
|
1077
|
+
let squadMap = rolesBySquad.get(squadSlug);
|
|
1078
|
+
if (!squadMap) {
|
|
1079
|
+
squadMap = new Map();
|
|
1080
|
+
for (const a of deps.listSquadAgents(squadSlug)) {
|
|
1081
|
+
squadMap.set(a.character_name, {
|
|
1082
|
+
is_lead: a.is_lead === 1,
|
|
1083
|
+
is_qa: a.is_qa === 1,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
rolesBySquad.set(squadSlug, squadMap);
|
|
1087
|
+
}
|
|
1088
|
+
return squadMap.get(character) ?? { is_lead: false, is_qa: false };
|
|
1089
|
+
};
|
|
1025
1090
|
return reviews
|
|
1026
1091
|
.map((r) => {
|
|
1027
|
-
const
|
|
1092
|
+
const { is_lead, is_qa } = rolesFor(r.squad_slug, r.reviewer_character);
|
|
1093
|
+
const approved = r.approved === 1;
|
|
1094
|
+
let badge = "";
|
|
1095
|
+
if (is_lead && is_qa)
|
|
1096
|
+
badge = "⭐🛡️ ";
|
|
1097
|
+
else if (is_lead)
|
|
1098
|
+
badge = "⭐ ";
|
|
1099
|
+
else if (is_qa)
|
|
1100
|
+
badge = "🛡️ ";
|
|
1101
|
+
const verdict = approved
|
|
1102
|
+
? `${badge}✅ APPROVED`
|
|
1103
|
+
: `${badge}❌ REJECTED`;
|
|
1104
|
+
const tags = [];
|
|
1105
|
+
if (is_lead)
|
|
1106
|
+
tags.push("lead");
|
|
1107
|
+
if (is_qa)
|
|
1108
|
+
tags.push("QA");
|
|
1109
|
+
const tagSuffix = tags.length ? ` _(${tags.join(", ")})_` : "";
|
|
1110
|
+
const veto = !approved && (is_lead || is_qa) ? " — **veto**" : "";
|
|
1028
1111
|
const comments = r.comments ? `\n ${r.comments.replace(/\n/g, "\n ")}` : "";
|
|
1029
|
-
return `- **${r.reviewer_character}
|
|
1112
|
+
return `- **${r.reviewer_character}**${tagSuffix} — ${verdict}${veto}${comments}`;
|
|
1030
1113
|
})
|
|
1031
1114
|
.join("\n");
|
|
1032
1115
|
},
|
|
@@ -1058,7 +1141,242 @@ export function createTools(deps) {
|
|
|
1058
1141
|
}
|
|
1059
1142
|
},
|
|
1060
1143
|
});
|
|
1061
|
-
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
// Squad schedules — recurring stand-ups via cron-style expressions.
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
const KNOWN_AGENDA_ITEMS = ["triage", "prioritize", "ideation"];
|
|
1148
|
+
const squadScheduleCreate = defineTool("squad_schedule_create", {
|
|
1149
|
+
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.",
|
|
1150
|
+
skipPermission: true,
|
|
1151
|
+
parameters: z.object({
|
|
1152
|
+
slug: z.string().describe("Squad slug to schedule"),
|
|
1153
|
+
name: z
|
|
1154
|
+
.string()
|
|
1155
|
+
.describe("Human-friendly name for this schedule, e.g. 'Daily 5AM stand-up'"),
|
|
1156
|
+
cron: z
|
|
1157
|
+
.string()
|
|
1158
|
+
.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."),
|
|
1159
|
+
agenda: z
|
|
1160
|
+
.array(z.string())
|
|
1161
|
+
.min(1)
|
|
1162
|
+
.describe(`Ordered agenda. Built-in items: ${KNOWN_AGENDA_ITEMS.join(", ")}. You may include custom items; the team lead will improvise.`),
|
|
1163
|
+
notes: z
|
|
1164
|
+
.string()
|
|
1165
|
+
.optional()
|
|
1166
|
+
.describe("Optional operator notes appended to the stand-up prompt."),
|
|
1167
|
+
}),
|
|
1168
|
+
handler: async ({ slug, name, cron, agenda, notes }) => {
|
|
1169
|
+
const squad = deps.getSquad(slug);
|
|
1170
|
+
if (!squad)
|
|
1171
|
+
return `Squad not found: ${slug}`;
|
|
1172
|
+
const v = validateCron(cron);
|
|
1173
|
+
if (!v.ok)
|
|
1174
|
+
return `Invalid cron expression: ${v.error}`;
|
|
1175
|
+
const created = createSchedule({
|
|
1176
|
+
squadSlug: slug,
|
|
1177
|
+
name,
|
|
1178
|
+
cronExpr: cron,
|
|
1179
|
+
agenda,
|
|
1180
|
+
notes: notes ?? null,
|
|
1181
|
+
nextRunAt: v.next.toISOString(),
|
|
1182
|
+
});
|
|
1183
|
+
return `📅 Scheduled "${created.name}" for squad "${squad.name}" (id ${created.id}).\n- Cron: \`${cron}\`\n- Agenda: ${agenda.join(", ")}\n- Next run: ${v.next.toISOString()}`;
|
|
1184
|
+
},
|
|
1185
|
+
});
|
|
1186
|
+
const squadScheduleList = defineTool("squad_schedule_list", {
|
|
1187
|
+
description: "List squad stand-up schedules. Pass a slug to filter to one squad, or omit to list all.",
|
|
1188
|
+
skipPermission: true,
|
|
1189
|
+
parameters: z.object({
|
|
1190
|
+
slug: z.string().optional().describe("Optional squad slug to filter"),
|
|
1191
|
+
}),
|
|
1192
|
+
handler: async ({ slug }) => {
|
|
1193
|
+
const schedules = listSchedules(slug);
|
|
1194
|
+
if (schedules.length === 0) {
|
|
1195
|
+
return slug
|
|
1196
|
+
? `No schedules for squad "${slug}".`
|
|
1197
|
+
: "No squad schedules configured.";
|
|
1198
|
+
}
|
|
1199
|
+
return schedules
|
|
1200
|
+
.map((s) => {
|
|
1201
|
+
const enabled = s.enabled ? "▶️ enabled" : "⏸️ paused";
|
|
1202
|
+
const last = s.last_run_at ? `last ${s.last_run_at}` : "never run";
|
|
1203
|
+
const next = s.next_run_at ?? "—";
|
|
1204
|
+
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}` : ""}`;
|
|
1205
|
+
})
|
|
1206
|
+
.join("\n");
|
|
1207
|
+
},
|
|
1208
|
+
});
|
|
1209
|
+
const squadScheduleDelete = defineTool("squad_schedule_delete", {
|
|
1210
|
+
description: "Delete a squad schedule by id.",
|
|
1211
|
+
skipPermission: true,
|
|
1212
|
+
parameters: z.object({
|
|
1213
|
+
id: z.number().int().describe("Schedule id (from squad_schedule_list)"),
|
|
1214
|
+
}),
|
|
1215
|
+
handler: async ({ id }) => {
|
|
1216
|
+
const existing = getSchedule(id);
|
|
1217
|
+
if (!existing)
|
|
1218
|
+
return `Schedule ${id} not found.`;
|
|
1219
|
+
deleteSchedule(id);
|
|
1220
|
+
return `🗑️ Deleted schedule "${existing.name}" (id ${id}).`;
|
|
1221
|
+
},
|
|
1222
|
+
});
|
|
1223
|
+
const squadSchedulePause = defineTool("squad_schedule_pause", {
|
|
1224
|
+
description: "Pause a squad schedule so it stops firing (preserves config).",
|
|
1225
|
+
skipPermission: true,
|
|
1226
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1227
|
+
handler: async ({ id }) => {
|
|
1228
|
+
const existing = getSchedule(id);
|
|
1229
|
+
if (!existing)
|
|
1230
|
+
return `Schedule ${id} not found.`;
|
|
1231
|
+
setScheduleEnabled(id, false);
|
|
1232
|
+
return `⏸️ Paused schedule "${existing.name}" (id ${id}).`;
|
|
1233
|
+
},
|
|
1234
|
+
});
|
|
1235
|
+
const squadScheduleResume = defineTool("squad_schedule_resume", {
|
|
1236
|
+
description: "Resume a paused squad schedule. The next run is computed from now using the stored cron expression.",
|
|
1237
|
+
skipPermission: true,
|
|
1238
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1239
|
+
handler: async ({ id }) => {
|
|
1240
|
+
const existing = getSchedule(id);
|
|
1241
|
+
if (!existing)
|
|
1242
|
+
return `Schedule ${id} not found.`;
|
|
1243
|
+
setScheduleEnabled(id, true);
|
|
1244
|
+
try {
|
|
1245
|
+
const next = nextRun(existing.cron_expr);
|
|
1246
|
+
// Update next_run_at via the store's helper would be cleaner, but we
|
|
1247
|
+
// can also just re-run reconcile on next tick. Inline update:
|
|
1248
|
+
const { updateNextRun } = await import("../store/schedules.js");
|
|
1249
|
+
updateNextRun(id, next.toISOString());
|
|
1250
|
+
return `▶️ Resumed schedule "${existing.name}" (id ${id}). Next run: ${next.toISOString()}`;
|
|
1251
|
+
}
|
|
1252
|
+
catch (err) {
|
|
1253
|
+
return `Resumed schedule "${existing.name}" but failed to compute next run: ${err instanceof Error ? err.message : String(err)}`;
|
|
1254
|
+
}
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
const squadScheduleRunNow = defineTool("squad_schedule_run_now", {
|
|
1258
|
+
description: "Manually fire a squad schedule immediately (useful for testing). last_run_at and next_run_at are preserved so the regular schedule is untouched.",
|
|
1259
|
+
skipPermission: true,
|
|
1260
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1261
|
+
handler: async ({ id }) => {
|
|
1262
|
+
const result = await runScheduleNow(id);
|
|
1263
|
+
if (!result.ok)
|
|
1264
|
+
return `Failed: ${result.error}`;
|
|
1265
|
+
return `🚀 Fired schedule ${id} now. Use squad_task_status to follow the resulting stand-up.`;
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
// -------------------------------------------------------------------------
|
|
1269
|
+
// IO-level (squad-independent) schedules.
|
|
1270
|
+
// -------------------------------------------------------------------------
|
|
1271
|
+
const scheduleCreate = defineTool("schedule_create", {
|
|
1272
|
+
description: "Schedule a recurring task for IO itself (no squad required). At the scheduled time, the prompt is delivered to the orchestrator as a background message, just like any TUI/Telegram input. Use for daily digests, health checks, monitoring, or any automation that does not belong to a project squad.",
|
|
1273
|
+
skipPermission: true,
|
|
1274
|
+
parameters: z.object({
|
|
1275
|
+
name: z
|
|
1276
|
+
.string()
|
|
1277
|
+
.describe("Human-friendly name, e.g. 'Morning digest' or 'Hourly health check'"),
|
|
1278
|
+
cron: z
|
|
1279
|
+
.string()
|
|
1280
|
+
.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."),
|
|
1281
|
+
prompt: z
|
|
1282
|
+
.string()
|
|
1283
|
+
.describe("The prompt to send to the orchestrator each time the schedule fires. Treat it like the message a user would type — concrete, action-oriented."),
|
|
1284
|
+
notes: z
|
|
1285
|
+
.string()
|
|
1286
|
+
.optional()
|
|
1287
|
+
.describe("Optional operator notes appended to the prompt."),
|
|
1288
|
+
}),
|
|
1289
|
+
handler: async ({ name, cron, prompt, notes }) => {
|
|
1290
|
+
const v = validateCron(cron);
|
|
1291
|
+
if (!v.ok)
|
|
1292
|
+
return `Invalid cron expression: ${v.error}`;
|
|
1293
|
+
const created = createIoSchedule({
|
|
1294
|
+
name,
|
|
1295
|
+
cronExpr: cron,
|
|
1296
|
+
prompt,
|
|
1297
|
+
notes: notes ?? null,
|
|
1298
|
+
nextRunAt: v.next.toISOString(),
|
|
1299
|
+
});
|
|
1300
|
+
return `⏰ Scheduled IO task "${created.name}" (id ${created.id}).\n- Cron: \`${cron}\`\n- Next run: ${v.next.toISOString()}`;
|
|
1301
|
+
},
|
|
1302
|
+
});
|
|
1303
|
+
const scheduleList = defineTool("schedule_list", {
|
|
1304
|
+
description: "List all IO-level schedules (those not attached to a squad). For squad schedules, use squad_schedule_list.",
|
|
1305
|
+
skipPermission: true,
|
|
1306
|
+
parameters: z.object({}),
|
|
1307
|
+
handler: async () => {
|
|
1308
|
+
const schedules = listIoSchedules();
|
|
1309
|
+
if (schedules.length === 0) {
|
|
1310
|
+
return "No IO schedules configured.";
|
|
1311
|
+
}
|
|
1312
|
+
return schedules
|
|
1313
|
+
.map((s) => {
|
|
1314
|
+
const enabled = s.enabled ? "▶️ enabled" : "⏸️ paused";
|
|
1315
|
+
const last = s.last_run_at ? `last ${s.last_run_at}` : "never run";
|
|
1316
|
+
const next = s.next_run_at ?? "—";
|
|
1317
|
+
const promptPreview = s.prompt.length > 120 ? s.prompt.slice(0, 120) + "…" : s.prompt;
|
|
1318
|
+
return `- **${s.name}** (id ${s.id}) — ${enabled}\n cron: \`${s.cron_expr}\`\n next: ${next} — ${last}\n prompt: ${promptPreview.replace(/\n/g, " ")}${s.notes ? `\n notes: ${s.notes}` : ""}`;
|
|
1319
|
+
})
|
|
1320
|
+
.join("\n");
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
const scheduleDelete = defineTool("schedule_delete", {
|
|
1324
|
+
description: "Delete an IO schedule by id.",
|
|
1325
|
+
skipPermission: true,
|
|
1326
|
+
parameters: z.object({
|
|
1327
|
+
id: z.number().int().describe("Schedule id (from schedule_list)"),
|
|
1328
|
+
}),
|
|
1329
|
+
handler: async ({ id }) => {
|
|
1330
|
+
const existing = getIoSchedule(id);
|
|
1331
|
+
if (!existing)
|
|
1332
|
+
return `IO schedule ${id} not found.`;
|
|
1333
|
+
deleteIoSchedule(id);
|
|
1334
|
+
return `🗑️ Deleted IO schedule "${existing.name}" (id ${id}).`;
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
const schedulePause = defineTool("schedule_pause", {
|
|
1338
|
+
description: "Pause an IO schedule so it stops firing (preserves config — resume with schedule_resume).",
|
|
1339
|
+
skipPermission: true,
|
|
1340
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1341
|
+
handler: async ({ id }) => {
|
|
1342
|
+
const existing = getIoSchedule(id);
|
|
1343
|
+
if (!existing)
|
|
1344
|
+
return `IO schedule ${id} not found.`;
|
|
1345
|
+
setIoScheduleEnabled(id, false);
|
|
1346
|
+
return `⏸️ Paused IO schedule "${existing.name}" (id ${id}).`;
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
const scheduleResume = defineTool("schedule_resume", {
|
|
1350
|
+
description: "Resume a paused IO schedule. The next run is computed from now using the stored cron expression.",
|
|
1351
|
+
skipPermission: true,
|
|
1352
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1353
|
+
handler: async ({ id }) => {
|
|
1354
|
+
const existing = getIoSchedule(id);
|
|
1355
|
+
if (!existing)
|
|
1356
|
+
return `IO schedule ${id} not found.`;
|
|
1357
|
+
setIoScheduleEnabled(id, true);
|
|
1358
|
+
try {
|
|
1359
|
+
const next = nextRun(existing.cron_expr);
|
|
1360
|
+
updateIoScheduleNextRun(id, next.toISOString());
|
|
1361
|
+
return `▶️ Resumed IO schedule "${existing.name}" (id ${id}). Next run: ${next.toISOString()}`;
|
|
1362
|
+
}
|
|
1363
|
+
catch (err) {
|
|
1364
|
+
return `Resumed IO schedule "${existing.name}" but failed to compute next run: ${err instanceof Error ? err.message : String(err)}`;
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
1368
|
+
const scheduleRunNow = defineTool("schedule_run_now", {
|
|
1369
|
+
description: "Manually fire an IO schedule immediately (useful for testing). last_run_at and next_run_at are preserved so the regular schedule is untouched.",
|
|
1370
|
+
skipPermission: true,
|
|
1371
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1372
|
+
handler: async ({ id }) => {
|
|
1373
|
+
const ok = await runIoScheduleNow(id);
|
|
1374
|
+
if (!ok)
|
|
1375
|
+
return `IO schedule ${id} not found.`;
|
|
1376
|
+
return `🚀 Fired IO schedule ${id} now.`;
|
|
1377
|
+
},
|
|
1378
|
+
});
|
|
1379
|
+
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, scheduleCreate, scheduleList, scheduleDelete, schedulePause, scheduleResume, scheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github];
|
|
1062
1380
|
}
|
|
1063
1381
|
function walkDirectory(dir, maxDepth = 3, depth = 0) {
|
|
1064
1382
|
if (depth >= maxDepth)
|
package/dist/daemon.js
CHANGED
|
@@ -4,6 +4,10 @@ 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 { reconcileAgentStatuses, reconcileSquadStatuses } from "./store/squads.js";
|
|
8
|
+
import { backfillReviewVerdicts } from "./copilot/review-backfill.js";
|
|
9
|
+
import { startScheduler, stopScheduler } from "./copilot/scheduler.js";
|
|
10
|
+
import { startIoScheduler, stopIoScheduler } from "./copilot/io-scheduler.js";
|
|
7
11
|
import { config } from "./config.js";
|
|
8
12
|
import { ensureWikiStructure } from "./wiki/fs.js";
|
|
9
13
|
import { autoUpdate } from "./update.js";
|
|
@@ -61,8 +65,28 @@ export async function startDaemon() {
|
|
|
61
65
|
if (wikiIsNew) {
|
|
62
66
|
console.log("[io] Created wiki at ~/.io/wiki/");
|
|
63
67
|
}
|
|
64
|
-
// Clear stale tasks from previous run
|
|
68
|
+
// Clear stale tasks from previous run, and reset any agent/squad rows left
|
|
69
|
+
// in 'working' or 'error' state — the in-memory Copilot sessions backing
|
|
70
|
+
// those rows did not survive the restart, so the persisted status is lying.
|
|
65
71
|
clearStaleTasks();
|
|
72
|
+
const resetAgents = reconcileAgentStatuses();
|
|
73
|
+
const resetSquads = reconcileSquadStatuses();
|
|
74
|
+
if (resetAgents > 0 || resetSquads > 0) {
|
|
75
|
+
console.log(`[io] Reconciled stale statuses on startup: ${resetAgents} agent(s), ${resetSquads} squad(s) → idle`);
|
|
76
|
+
}
|
|
77
|
+
// Backfill any historical peer-review rows whose recorded verdict (approved
|
|
78
|
+
// 0/1) does not match what the current parser would extract from the prose.
|
|
79
|
+
// Earlier daemon builds had a brittle first-line-only parser that flipped
|
|
80
|
+
// many APPROVED reviews into REJECTED (issue #50).
|
|
81
|
+
try {
|
|
82
|
+
const fixed = backfillReviewVerdicts();
|
|
83
|
+
if (fixed > 0) {
|
|
84
|
+
console.log(`[io] Backfilled ${fixed} peer-review verdict(s) using current parser`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error("[io] Review-verdict backfill failed:", err instanceof Error ? err.message : err);
|
|
89
|
+
}
|
|
66
90
|
// Prune old sessions
|
|
67
91
|
pruneOldSessions();
|
|
68
92
|
// Start Copilot SDK client
|
|
@@ -90,6 +114,10 @@ export async function startDaemon() {
|
|
|
90
114
|
else {
|
|
91
115
|
console.log("[io] Telegram not configured — skipping bot. Set telegramBotToken in ~/.io/config.json");
|
|
92
116
|
}
|
|
117
|
+
// Start the squad scheduler (background cron-style stand-ups).
|
|
118
|
+
startScheduler();
|
|
119
|
+
// Start the IO-level scheduler (squad-independent recurring tasks).
|
|
120
|
+
startIoScheduler();
|
|
93
121
|
console.log("[io] IO is fully operational.");
|
|
94
122
|
// Notify Telegram if restarting
|
|
95
123
|
if (config.telegramEnabled && process.env.IO_RESTARTED === "1") {
|
|
@@ -117,6 +145,8 @@ async function shutdown() {
|
|
|
117
145
|
}
|
|
118
146
|
catch { /* best effort */ }
|
|
119
147
|
}
|
|
148
|
+
stopScheduler();
|
|
149
|
+
stopIoScheduler();
|
|
120
150
|
await shutdownOrchestrator();
|
|
121
151
|
try {
|
|
122
152
|
await stopClient();
|
package/dist/store/db.js
CHANGED
|
@@ -82,6 +82,33 @@ 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)`,
|
|
99
|
+
`CREATE TABLE IF NOT EXISTS io_schedules (
|
|
100
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
101
|
+
name TEXT NOT NULL,
|
|
102
|
+
cron_expr TEXT NOT NULL,
|
|
103
|
+
prompt TEXT NOT NULL,
|
|
104
|
+
notes TEXT,
|
|
105
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
106
|
+
last_run_at DATETIME,
|
|
107
|
+
next_run_at DATETIME,
|
|
108
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
109
|
+
)`,
|
|
110
|
+
`CREATE INDEX IF NOT EXISTS idx_io_schedules_due
|
|
111
|
+
ON io_schedules (enabled, next_run_at)`,
|
|
85
112
|
];
|
|
86
113
|
for (const migration of migrations) {
|
|
87
114
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
export function createIoSchedule(input) {
|
|
3
|
+
const db = getDb();
|
|
4
|
+
const info = db
|
|
5
|
+
.prepare(`INSERT INTO io_schedules
|
|
6
|
+
(name, cron_expr, prompt, notes, enabled, next_run_at)
|
|
7
|
+
VALUES (?, ?, ?, ?, 1, ?)`)
|
|
8
|
+
.run(input.name, input.cronExpr, input.prompt, input.notes ?? null, input.nextRunAt);
|
|
9
|
+
const id = Number(info.lastInsertRowid);
|
|
10
|
+
return getIoSchedule(id);
|
|
11
|
+
}
|
|
12
|
+
export function getIoSchedule(id) {
|
|
13
|
+
return getDb()
|
|
14
|
+
.prepare("SELECT * FROM io_schedules WHERE id = ?")
|
|
15
|
+
.get(id);
|
|
16
|
+
}
|
|
17
|
+
export function listIoSchedules() {
|
|
18
|
+
return getDb()
|
|
19
|
+
.prepare("SELECT * FROM io_schedules ORDER BY id ASC")
|
|
20
|
+
.all();
|
|
21
|
+
}
|
|
22
|
+
export function listDueIoSchedules(now) {
|
|
23
|
+
return getDb()
|
|
24
|
+
.prepare(`SELECT * FROM io_schedules
|
|
25
|
+
WHERE enabled = 1
|
|
26
|
+
AND next_run_at IS NOT NULL
|
|
27
|
+
AND next_run_at <= ?
|
|
28
|
+
ORDER BY next_run_at ASC`)
|
|
29
|
+
.all(now.toISOString());
|
|
30
|
+
}
|
|
31
|
+
export function deleteIoSchedule(id) {
|
|
32
|
+
const info = getDb()
|
|
33
|
+
.prepare("DELETE FROM io_schedules WHERE id = ?")
|
|
34
|
+
.run(id);
|
|
35
|
+
return info.changes > 0;
|
|
36
|
+
}
|
|
37
|
+
export function setIoScheduleEnabled(id, enabled) {
|
|
38
|
+
const info = getDb()
|
|
39
|
+
.prepare("UPDATE io_schedules SET enabled = ? WHERE id = ?")
|
|
40
|
+
.run(enabled ? 1 : 0, id);
|
|
41
|
+
return info.changes > 0;
|
|
42
|
+
}
|
|
43
|
+
export function recordIoScheduleRun(id, ranAt, nextRunAt) {
|
|
44
|
+
getDb()
|
|
45
|
+
.prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
46
|
+
.run(ranAt.toISOString(), nextRunAt, id);
|
|
47
|
+
}
|
|
48
|
+
export function updateIoScheduleNextRun(id, nextRunAt) {
|
|
49
|
+
getDb()
|
|
50
|
+
.prepare("UPDATE io_schedules SET next_run_at = ? WHERE id = ?")
|
|
51
|
+
.run(nextRunAt, id);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Overwrite both last_run_at and next_run_at directly. Unlike
|
|
55
|
+
* recordIoScheduleRun this accepts NULL for last_run_at, which is needed when
|
|
56
|
+
* restoring a schedule's "never run" state after a manual run_now.
|
|
57
|
+
*/
|
|
58
|
+
export function setIoScheduleTimestamps(id, lastRunAt, nextRunAt) {
|
|
59
|
+
getDb()
|
|
60
|
+
.prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
61
|
+
.run(lastRunAt, nextRunAt, id);
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=io-schedules.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
/**
|
|
74
|
+
* Overwrite both last_run_at and next_run_at directly. Unlike
|
|
75
|
+
* recordScheduleRun this accepts NULL for last_run_at, which is needed when
|
|
76
|
+
* restoring a schedule's "never run" state after a manual run_now.
|
|
77
|
+
*/
|
|
78
|
+
export function setScheduleTimestamps(id, lastRunAt, nextRunAt) {
|
|
79
|
+
getDb()
|
|
80
|
+
.prepare("UPDATE squad_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
81
|
+
.run(lastRunAt, nextRunAt, id);
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=schedules.js.map
|
package/dist/store/squads.js
CHANGED
|
@@ -93,6 +93,27 @@ export function updateAgentStatus(squadSlug, characterName, status) {
|
|
|
93
93
|
.prepare("UPDATE squad_agents SET status = ? WHERE squad_slug = ? AND character_name = ?")
|
|
94
94
|
.run(status, squadSlug, characterName);
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Reset any agent left in a non-idle status from a previous daemon run.
|
|
98
|
+
* The in-memory Copilot sessions don't survive a restart, so persisted
|
|
99
|
+
* "working" or "error" rows can never be accurate after startup. Returns
|
|
100
|
+
* the number of rows reset for logging.
|
|
101
|
+
*/
|
|
102
|
+
export function reconcileAgentStatuses() {
|
|
103
|
+
const info = getDb()
|
|
104
|
+
.prepare("UPDATE squad_agents SET status = 'idle' WHERE status IN ('working', 'error')")
|
|
105
|
+
.run();
|
|
106
|
+
return info.changes;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Mirror of reconcileAgentStatuses for squads themselves.
|
|
110
|
+
*/
|
|
111
|
+
export function reconcileSquadStatuses() {
|
|
112
|
+
const info = getDb()
|
|
113
|
+
.prepare("UPDATE squads SET status = 'idle' WHERE status IN ('working', 'error')")
|
|
114
|
+
.run();
|
|
115
|
+
return info.changes;
|
|
116
|
+
}
|
|
96
117
|
export function logDecision(squadSlug, decision, context) {
|
|
97
118
|
getDb()
|
|
98
119
|
.prepare("INSERT INTO squad_decisions (squad_slug, decision, context) VALUES (?, ?, ?)")
|