heyio 0.42.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +40 -52
  2. package/dist/api/auth.js +35 -38
  3. package/dist/api/server.js +157 -1139
  4. package/dist/config.js +49 -32
  5. package/dist/copilot/agents.js +72 -1055
  6. package/dist/copilot/client.js +6 -17
  7. package/dist/copilot/io-scheduler.js +55 -139
  8. package/dist/copilot/model-router.js +100 -72
  9. package/dist/copilot/orchestrator.js +91 -515
  10. package/dist/copilot/scheduler.js +67 -189
  11. package/dist/copilot/skills.js +41 -366
  12. package/dist/copilot/system-message.js +40 -200
  13. package/dist/copilot/tools.js +191 -2042
  14. package/dist/daemon.js +54 -201
  15. package/dist/index.js +15 -133
  16. package/dist/mcp/config.js +23 -31
  17. package/dist/mcp/index.js +2 -3
  18. package/dist/mcp/registry.js +33 -88
  19. package/dist/notify.js +18 -100
  20. package/dist/paths.js +13 -24
  21. package/dist/setup.js +35 -0
  22. package/dist/store/db.js +111 -297
  23. package/dist/store/feed.js +29 -97
  24. package/dist/store/instances.js +56 -121
  25. package/dist/store/schedules.js +21 -73
  26. package/dist/store/squads.js +35 -186
  27. package/dist/store/tasks.js +25 -168
  28. package/dist/telegram/bot.js +20 -312
  29. package/dist/telegram/handlers.js +39 -3
  30. package/dist/watchdog.js +31 -45
  31. package/dist/wiki/fs.js +38 -155
  32. package/dist/wiki/search.js +31 -44
  33. package/package.json +5 -8
  34. package/web-dist/assets/ChatView-EFFiln1H.js +11 -0
  35. package/web-dist/assets/FeedView-bN4NMOL7.js +6 -0
  36. package/web-dist/assets/LoginView-CNtasq3n.js +1 -0
  37. package/web-dist/assets/McpView-C2CHiwsi.js +1 -0
  38. package/web-dist/assets/SchedulesView-CyilLban.js +1 -0
  39. package/web-dist/assets/SettingsView-1wLXKEF4.js +1 -0
  40. package/web-dist/assets/SkillsView-BLsD-0u0.js +1 -0
  41. package/web-dist/assets/SquadDetailView-CsCw2ZLp.js +21 -0
  42. package/web-dist/assets/SquadsView-DQ3vFlyO.js +6 -0
  43. package/web-dist/assets/WikiView-19M3oqnq.js +21 -0
  44. package/web-dist/assets/api-WGvTsXaE.js +1 -0
  45. package/web-dist/assets/index-D7M5O-_l.css +1 -0
  46. package/web-dist/assets/index-DZOS9syn.js +95 -0
  47. package/web-dist/assets/plus-BOvyX1BC.js +6 -0
  48. package/web-dist/assets/trash-2-DHoetkC4.js +6 -0
  49. package/web-dist/favicon.svg +4 -1
  50. package/web-dist/index.html +7 -10
  51. package/dist/api/logout.test.js +0 -129
  52. package/dist/api/mcp.test.js +0 -285
  53. package/dist/api/wiki.test.js +0 -283
  54. package/dist/auth/session-logic.js +0 -79
  55. package/dist/auth/session-logic.test.js +0 -201
  56. package/dist/copilot/auto-complete-instance.test.js +0 -104
  57. package/dist/copilot/cron.js +0 -136
  58. package/dist/copilot/event-summary.js +0 -286
  59. package/dist/copilot/instance-deactivate.test.js +0 -119
  60. package/dist/copilot/model-router.test.js +0 -71
  61. package/dist/copilot/review-backfill.js +0 -57
  62. package/dist/copilot/session-timeout.js +0 -112
  63. package/dist/copilot/session-timeout.test.js +0 -372
  64. package/dist/copilot/skills.test.js +0 -55
  65. package/dist/copilot/universes.js +0 -469
  66. package/dist/instance-watchdog.js +0 -104
  67. package/dist/instance-watchdog.test.js +0 -183
  68. package/dist/mcp/client.js +0 -109
  69. package/dist/mcp/client.test.js +0 -99
  70. package/dist/mcp/config.test.js +0 -49
  71. package/dist/mcp/registry.test.js +0 -79
  72. package/dist/notify.test.js +0 -232
  73. package/dist/store/feed.test.js +0 -279
  74. package/dist/store/instances.test.js +0 -310
  75. package/dist/store/io-schedules.js +0 -63
  76. package/dist/store/notifications.js +0 -79
  77. package/dist/store/notifications.test.js +0 -197
  78. package/dist/store/schedule-runs.js +0 -46
  79. package/dist/store/squads.test.js +0 -405
  80. package/dist/store/tasks.test.js +0 -150
  81. package/dist/store/worktrees.js +0 -83
  82. package/dist/tui/index.js +0 -286
  83. package/dist/update.js +0 -81
  84. package/dist/watchdog.test.js +0 -83
  85. package/dist/wiki/wiki-squad.test.js +0 -54
  86. package/web-dist/assets/AgentActivityView-CedxxE6K.js +0 -1
  87. package/web-dist/assets/ChatView-DMkYQo_V.js +0 -4
  88. package/web-dist/assets/FeedView-BH4q-31V.js +0 -1
  89. package/web-dist/assets/InboxView-BVwVP4EW.js +0 -1
  90. package/web-dist/assets/LoginView-DRPDhnwu.js +0 -1
  91. package/web-dist/assets/McpView-D8yWz-lq.js +0 -1
  92. package/web-dist/assets/SchedulesView-BzzyncGF.js +0 -1
  93. package/web-dist/assets/SettingsTabs.vue_vue_type_script_setup_true_lang-oW3ySu7Y.js +0 -1
  94. package/web-dist/assets/SkillsView-oxpYuhx7.js +0 -1
  95. package/web-dist/assets/SquadsView-CaKUIKlq.js +0 -1
  96. package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-8U15Qp_Q.js +0 -1
  97. package/web-dist/assets/WikiView-C5jXUlfW.js +0 -1
  98. package/web-dist/assets/index-BrWzNw-N.css +0 -10
  99. package/web-dist/assets/index-f67odrrt.js +0 -81
  100. package/web-dist/icons.svg +0 -24
@@ -1,2046 +1,195 @@
1
- import { defineTool } from "@github/copilot-sdk";
2
1
  import { z } from "zod";
3
- import { execSync, execFileSync } from "child_process";
4
- import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
5
- import { join, dirname, resolve } from "path";
6
- import { homedir } from "os";
7
- import { UNIVERSES, getOrCreateUniverse, generateUniverseRoster } from "./universes.js";
8
- import { createFeedEntry } from "../store/feed.js";
9
- import { loadMcpConfig, saveMcpConfig } from "../mcp/config.js";
10
- import { validateCron, nextRun } from "./cron.js";
11
- import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
12
- import { runIoScheduleNow } from "./io-scheduler.js";
13
- import { createSchedule, deleteSchedule, getSchedule, listSchedules, setScheduleEnabled, } from "../store/schedules.js";
14
- import { runScheduleNow } from "./scheduler.js";
15
- // ---------------------------------------------------------------------------
16
- // Squad coverage heuristics
17
- //
18
- // Every squad must have:
19
- // 1. A dedicated team lead — a PM / Senior Engineer with no domain
20
- // responsibility — designated via squad_set_lead. The lead's job is
21
- // coordination, delegation, and review only. Lead veto power on PR
22
- // promotion is automatic (see runPeerReview in agents.ts).
23
- // 2. At least one agent designated as QA (is_qa === 1) see squad_set_qa.
24
- // 3. At least one agent whose role title implies a testing/quality focus.
25
- //
26
- // These are surfaced as warnings on squad_status, squad_agents, and
27
- // squad_delegate so users can fix coverage gaps before promoting work.
28
- // ---------------------------------------------------------------------------
29
- const TEST_ROLE_KEYWORDS = ["test", "qa", "quality", "tester", "sdet", "qe"];
30
- // Words in a role title that imply the agent owns a hands-on engineering
31
- // domain (and therefore should NOT be the team lead).
32
- const DOMAIN_ROLE_KEYWORDS = [
33
- "frontend",
34
- "backend",
35
- "fullstack",
36
- "full-stack",
37
- "api",
38
- "ui",
39
- "ux",
40
- "test",
41
- "tester",
42
- "qa",
43
- "quality",
44
- "sdet",
45
- "qe",
46
- "devops",
47
- "sre",
48
- "ops",
49
- "infrastructure",
50
- "platform",
51
- "data",
52
- "database",
53
- "db",
54
- "ml",
55
- "ai",
56
- "sdk",
57
- "mobile",
58
- "ios",
59
- "android",
60
- "web",
61
- "security",
62
- "embedded",
63
- "integration",
64
- "telegram",
65
- "tui",
66
- "vue",
67
- "react",
68
- "angular",
69
- "express",
70
- "sqlite",
71
- "wiki",
72
- ];
73
- // Words that mark a role as coordination/leadership-focused.
74
- const LEAD_ROLE_KEYWORDS = [
75
- "lead",
76
- "manager",
77
- "pm",
78
- "principal",
79
- "coordinator",
80
- "director",
81
- ];
82
- function containsWord(haystack, word) {
83
- const re = new RegExp(`(^|[^a-z])${word}([^a-z]|$)`);
84
- return re.test(haystack);
85
- }
86
- export function roleLooksLikeTesting(roleTitle) {
87
- if (!roleTitle)
88
- return false;
89
- const lower = roleTitle.toLowerCase();
90
- return TEST_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
91
- }
92
- /**
93
- * A dedicated team lead has a role title that emphasises coordination/seniority
94
- * and does NOT also claim a hands-on engineering domain. Examples:
95
- * ✅ "Engineering Lead", "Project Manager", "Senior Engineering Lead",
96
- * "Principal Engineer", "Tech Lead", "Senior Engineer"
97
- * ❌ "Frontend Lead", "Test Manager", "QA Lead", "Backend Engineer",
98
- * "Express API Engineer"
99
- */
100
- export function roleLooksLikeDedicatedLead(roleTitle) {
101
- if (!roleTitle)
102
- return false;
103
- const lower = roleTitle.toLowerCase();
104
- const hasDomainKw = DOMAIN_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
105
- if (hasDomainKw)
106
- return false;
107
- const hasLeadKw = LEAD_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
108
- if (hasLeadKw)
109
- return true;
110
- // "Senior Engineer" / "Sr. Engineer" with no domain qualifier also counts.
111
- if (/(^|[^a-z])(senior|sr\.?)\s+engineer($|[^a-z])/.test(lower))
112
- return true;
113
- return false;
114
- }
115
- export function assessSquadCoverage(agents) {
116
- const leadAgent = agents.find((a) => a.is_lead === 1);
117
- const hasLead = !!leadAgent;
118
- const hasDedicatedLead = !!leadAgent && roleLooksLikeDedicatedLead(leadAgent.role_title);
119
- const hasQa = agents.some((a) => a.is_qa === 1);
120
- const hasTestRole = agents.some((a) => roleLooksLikeTesting(a.role_title));
121
- const missing = [];
122
- if (!hasLead) {
123
- missing.push("dedicated team lead (use squad_set_lead with a PM/Senior Engineer who owns no domain)");
124
- }
125
- else if (!hasDedicatedLead) {
126
- missing.push(`dedicated lead role (current lead "${leadAgent.role_title}" looks like a domain specialist — team leads must be PM/Senior Engineer with no domain ownership)`);
127
- }
128
- if (!hasQa)
129
- missing.push("QA reviewer (use squad_set_qa)");
130
- if (!hasTestRole) {
131
- missing.push("test/quality engineer (add an agent whose role_title contains 'test', 'qa', or 'quality')");
132
- }
133
- const warning = missing.length > 0
134
- ? `⚠️ Squad coverage gap: missing ${missing.join("; ")}.`
135
- : null;
136
- return { hasLead, hasDedicatedLead, hasQa, hasTestRole, missing, warning };
137
- }
138
- // ---------------------------------------------------------------------------
139
- // Work-distribution diagnostics
140
- //
141
- // Squads can fall into an anti-pattern (#51) where the team lead handles
142
- // every delegated task instead of fanning out to specialists. We surface a
143
- // soft warning on squad_status when the lead handles more than this share
144
- // of recent tasks.
145
- // ---------------------------------------------------------------------------
146
- const WORK_DISTRIBUTION_WINDOW = 20;
147
- const LEAD_OVERLOAD_THRESHOLD = 0.8;
148
- function formatWorkDistribution(squadSlug, lead, deps) {
149
- const dist = deps.getSquadWorkDistribution(squadSlug, WORK_DISTRIBUTION_WINDOW);
150
- if (dist.total === 0)
151
- return "";
152
- const friendly = (agentSlug) => {
153
- if (agentSlug === squadSlug)
154
- return "(unassigned)";
155
- const idx = agentSlug.indexOf(":");
156
- return idx >= 0 ? agentSlug.slice(idx + 1) : agentSlug;
157
- };
158
- const breakdown = dist.perAgent
159
- .map((a) => `${friendly(a.agent_slug)} ${a.count} (${Math.round((a.count / dist.total) * 100)}%)`)
160
- .join(", ");
161
- const lines = [];
162
- lines.push(`\n 📊 Work distribution (last ${dist.total} task${dist.total === 1 ? "" : "s"}): ${breakdown}`);
163
- if (lead) {
164
- const leadKey = `${squadSlug}:${lead.character_name}`;
165
- const leadCount = dist.perAgent.find((a) => a.agent_slug === leadKey)?.count ?? 0;
166
- const share = leadCount / dist.total;
167
- if (share > LEAD_OVERLOAD_THRESHOLD) {
168
- lines.push(`\n ⚠️ Lead overload: ${lead.character_name} handled ${Math.round(share * 100)}% of recent tasks (threshold ${Math.round(LEAD_OVERLOAD_THRESHOLD * 100)}%). The lead should be delegating to specialists via delegate_to_teammate, not self-implementing — see issue #51.`);
169
- }
170
- }
171
- return lines.join("");
172
- }
173
- // ---------------------------------------------------------------------------
174
- // Per-agent delegation-stat formatters (issue #61)
175
- // ---------------------------------------------------------------------------
176
- /**
177
- * Format an ISO timestamp as a short relative time string. SQLite emits
178
- * naive UTC strings, so we suffix "Z" before parsing if it isn't already
179
- * timezone-qualified.
180
- */
181
- function formatRelativeTime(iso) {
182
- if (!iso)
183
- return "never";
184
- const tzQualified = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(iso);
185
- const ts = new Date(tzQualified ? iso : iso + "Z").getTime();
186
- if (Number.isNaN(ts))
187
- return "never";
188
- const deltaMs = Date.now() - ts;
189
- if (deltaMs < 60_000)
190
- return "just now";
191
- if (deltaMs < 3_600_000) {
192
- const m = Math.round(deltaMs / 60_000);
193
- return `${m}m ago`;
194
- }
195
- if (deltaMs < 86_400_000) {
196
- const h = Math.round(deltaMs / 3_600_000);
197
- return `${h}h ago`;
198
- }
199
- const d = Math.round(deltaMs / 86_400_000);
200
- return `${d}d ago`;
201
- }
202
- /**
203
- * Format a stale-hours number as a short duration. >=24h rounds to days,
204
- * smaller values stay in hours. Floors to keep behaviour consistent across
205
- * the squad_agents and squad_task_status renderers.
206
- */
207
- function formatStaleDuration(staleHours) {
208
- if (staleHours >= 24) {
209
- const d = Math.floor(staleHours / 24);
210
- return `${d}d`;
211
- }
212
- return `${Math.floor(staleHours)}h`;
213
- }
214
- /**
215
- * Build the ⚠️ stalest-specialist hint string from a getStalestSpecialist
216
- * result. Returns "" if the input is null (squad is healthy).
217
- */
218
- function formatStalestHint(stalest) {
219
- if (!stalest)
220
- return "";
221
- if (stalest.staleHours == null) {
222
- return `⚠️ ${stalest.character_name} has never been delegated to`;
223
- }
224
- return `⚠️ ${stalest.character_name} has not been delegated to in ${formatStaleDuration(stalest.staleHours)}`;
225
- }
226
- // Ensure child processes have HOME set (systemd services often don't)
227
- function shellEnv() {
228
- const env = { ...process.env };
229
- if (!env.HOME)
230
- env.HOME = homedir();
231
- return env;
232
- }
233
- export function shouldRouteToInbox(taskDescription) {
234
- const lower = taskDescription.toLowerCase();
235
- return (lower.includes("io inbox") ||
236
- lower.includes("io-inbox") ||
237
- lower.includes("send to inbox") ||
238
- lower.includes("to the inbox") ||
239
- lower.includes("result: \"io-inbox\"") ||
240
- lower.includes("not github or telegram") ||
241
- lower.includes("not telegram"));
242
- }
243
- export function createTools(deps) {
244
- const wikiRead = defineTool("wiki_read", {
245
- description: "Read a page from IO's knowledge base wiki. Path is relative to the wiki root (e.g., 'pages/preferences/editor.md').",
246
- skipPermission: true,
247
- parameters: z.object({
248
- path: z.string().describe("Relative path to the wiki page"),
249
- }),
250
- handler: async ({ path }) => {
251
- const content = deps.wikiRead(path);
252
- if (!content)
253
- return `Page not found: ${path}`;
254
- return content;
255
- },
256
- });
257
- const wikiWrite = defineTool("wiki_write", {
258
- description: "Write or update a page in IO's knowledge base. Use this to remember preferences, project details, and important facts. Path must be under pages/ and end in .md.",
259
- skipPermission: true,
260
- parameters: z.object({
261
- path: z.string().describe("Relative path under pages/ (e.g., 'pages/preferences/clone-location.md')"),
262
- content: z.string().describe("Markdown content to write"),
263
- }),
264
- handler: async ({ path, content }) => {
265
- try {
266
- deps.wikiAssertPagePath(path);
267
- deps.wikiWrite(path, content);
268
- return `Written: ${path}`;
269
- }
270
- catch (err) {
271
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
272
- }
273
- },
274
- });
275
- const wikiSearch = defineTool("wiki_search", {
276
- description: "Search IO's knowledge base for matching pages.",
277
- skipPermission: true,
278
- parameters: z.object({
279
- query: z.string().describe("Search query"),
280
- }),
281
- handler: async ({ query }) => {
282
- const results = deps.wikiSearch(query);
283
- if (results.length === 0)
284
- return "No matching pages found.";
285
- return results
286
- .map((r) => `**${r.title}** (${r.path})\n${r.snippet}`)
287
- .join("\n\n");
288
- },
289
- });
290
- const squadCreate = defineTool("squad_create", {
291
- description: "Create a persistent project squad with an 80s-themed team. A random universe (A-Team, Transformers, etc.) is assigned unless you specify one. After creating, use squad_analyze to examine the project, then squad_add_agent for each specialist needed.",
292
- skipPermission: true,
293
- parameters: z.object({
294
- slug: z.string().describe("Unique identifier (e.g., 'michaeljolley-io')"),
295
- name: z.string().describe("Display name (e.g., 'IO Assistant')"),
296
- project_path: z.string().describe("Path to the project directory"),
297
- universe: z
298
- .string()
299
- .optional()
300
- .describe("Universe theme for agent characters. Built-in: a-team, transformers, thundercats, gi-joe, aliens, ghostbusters, tmnt, star-wars, star-trek, lord-of-the-rings, the-office, parks-and-rec. Or provide any custom name. Random if omitted."),
301
- }),
302
- handler: async ({ slug, name, project_path, universe }) => {
303
- try {
304
- if (universe) {
305
- await generateUniverseRoster(universe);
306
- }
307
- deps.createSquad(slug, name, project_path, universe);
308
- const squad = deps.getSquad(slug);
309
- const universeName = squad?.universe ? getOrCreateUniverse(squad.universe).name : "random";
310
- return `Squad "${name}" created for ${project_path}\nUniverse: ${universeName}\n\nNext steps:\n1. Use \`squad_analyze\` to examine the project\n2. Use \`squad_add_agent\` to add specialists based on the analysis`;
311
- }
312
- catch (err) {
313
- return `Error creating squad: ${err instanceof Error ? err.message : String(err)}`;
314
- }
315
- },
316
- });
317
- const squadRecall = defineTool("squad_recall", {
318
- description: "Recall a squad's context and past decisions. Use this before working on a project to load relevant history.",
319
- skipPermission: true,
320
- parameters: z.object({
321
- slug: z.string().describe("Squad slug"),
322
- }),
323
- handler: async ({ slug }) => {
324
- const squad = deps.getSquad(slug);
325
- if (!squad)
326
- return `Squad not found: ${slug}`;
327
- const decisions = deps.getDecisionsSummary(slug);
328
- return `**Squad: ${squad.name}**\nProject: ${squad.projectPath}\nStatus: ${squad.status}\n\n${decisions}`;
329
- },
330
- });
331
- const squadStatus = defineTool("squad_status", {
332
- description: "List all squads with their universe theme and agent roster.",
333
- skipPermission: true,
334
- parameters: z.object({}),
335
- handler: async () => {
336
- const squads = deps.listSquads();
337
- if (squads.length === 0)
338
- return "No squads created yet.";
339
- return squads
340
- .map((s) => {
341
- const universeName = s.universe
342
- ? UNIVERSES.find((u) => u.id === s.universe)?.name ?? s.universe
343
- : "none";
344
- const agents = deps.listSquadAgents(s.slug);
345
- const lead = deps.getSquadLead(s.slug);
346
- const leadLine = lead
347
- ? `\n ⭐ Team Lead: ${lead.character_name} (${lead.role_title})`
348
- : "";
349
- const agentList = agents.length > 0
350
- ? "\n Agents: " + agents.map((a) => `${a.character_name} (${a.role_title})`).join(", ")
351
- : "\n Agents: none — use squad_add_agent to build the team";
352
- const coverage = assessSquadCoverage(agents);
353
- const coverageLine = coverage.warning ? `\n ${coverage.warning}` : "";
354
- const distLine = formatWorkDistribution(s.slug, lead, deps);
355
- const recentDecisions = deps.getRecentDecisions(s.slug, 3);
356
- const decisionsLine = recentDecisions.length === 0
357
- ? "\n 📜 Recent decisions: _none recorded — squad is not capturing institutional knowledge_"
358
- : "\n 📜 Recent decisions: " +
359
- recentDecisions
360
- .map((d) => `\"${d.decision.length > 80 ? d.decision.slice(0, 80) + "…" : d.decision}\"`)
361
- .join("; ");
362
- return `- **${s.name}** (\`${s.slug}\`) — ${s.status} — 🎬 ${universeName}${leadLine}${agentList}${coverageLine}${distLine}${decisionsLine}\n 📁 ${s.projectPath}`;
363
- })
364
- .join("\n");
365
- },
366
- });
367
- const squadLogDecision = defineTool("squad_log_decision", {
368
- description: "Log a decision for a squad. Use this to record important choices made during project work.",
369
- skipPermission: true,
370
- parameters: z.object({
371
- slug: z.string().describe("Squad slug"),
372
- decision: z.string().describe("The decision made"),
373
- context: z.string().optional().describe("Context or reasoning"),
374
- }),
375
- handler: async ({ slug, decision, context }) => {
376
- try {
377
- // If we're in an instance context, route to instance decisions
378
- if (deps.activeInstanceId) {
379
- deps.logInstanceDecision(deps.activeInstanceId, decision, context);
380
- return `Decision logged for instance ${deps.activeInstanceId} (squad ${slug})`;
381
- }
382
- deps.logDecision(slug, decision, context);
383
- return `Decision logged for squad ${slug}`;
384
- }
385
- catch (err) {
386
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
387
- }
388
- },
389
- });
390
- const squadDelegate = defineTool("squad_delegate", {
391
- description: "Delegate a task to a squad agent for autonomous execution. If the squad has named agents, you can target a specific one by character name, or let the system pick the best available agent. Returns a task ID immediately.",
392
- skipPermission: true,
393
- parameters: z.object({
394
- slug: z.string().describe("Squad slug to delegate to"),
395
- task: z
396
- .string()
397
- .describe("Detailed task description. Be specific — include file paths, expected behavior, acceptance criteria. The agent works autonomously with this as its only instruction."),
398
- agent: z
399
- .string()
400
- .optional()
401
- .describe("Character name of a specific agent to target (e.g., 'Hannibal', 'Optimus Prime'). If omitted, the system picks the best available agent."),
402
- }),
403
- handler: async ({ slug, task, agent }) => {
404
- console.error(`[io] squad_delegate called: ${slug}${agent ? ` → ${agent}` : ""} — ${task.slice(0, 100)}…`);
405
- const roster = deps.listSquadAgents(slug);
406
- const coverage = assessSquadCoverage(roster);
407
- try {
408
- const taskId = await deps.delegateToAgent(slug, task, (id, result) => {
409
- console.error(`[io] Agent task ${id} completed for squad ${slug}`);
410
- if (shouldRouteToInbox(task)) {
411
- createFeedEntry({ type: "inbox", title: `[${slug}] Task result`, body: result, squad_slug: slug });
412
- console.error(`[io] Task ${id} result routed to inbox`);
413
- }
414
- }, agent, deps.activeInstanceId);
415
- const agentLabel = agent ? `agent "${agent}" in squad "${slug}"` : `squad "${slug}"`;
416
- const warningPrefix = coverage.warning
417
- ? `${coverage.warning} A dedicated lead and a QA reviewer should both hold veto power on PR promotion — fix gaps before promoting work.\n\n`
418
- : "";
419
- 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.`;
420
- }
421
- catch (err) {
422
- return `Error delegating task: ${err instanceof Error ? err.message : String(err)}`;
423
- }
424
- },
425
- });
426
- const squadTaskStatus = defineTool("squad_task_status", {
427
- description: "Check the status of a delegated squad task, or list all active tasks. Returns status (running/done/failed) and result when complete.",
428
- skipPermission: true,
429
- parameters: z.object({
430
- task_id: z
431
- .string()
432
- .optional()
433
- .describe("Specific task ID to check. If omitted, lists all active tasks."),
434
- }),
435
- handler: async ({ task_id }) => {
436
- if (task_id) {
437
- const task = deps.getTask(task_id);
438
- if (!task)
439
- return `Task not found: ${task_id}`;
440
- let response = `**Task ${task.task_id}**\nSquad: ${task.agent_slug}\nStatus: ${task.status}\nDescription: ${task.description}`;
441
- if (task.result) {
442
- const result = task.result.length > 4000 ? task.result.slice(0, 4000) + "\n[…truncated]" : task.result;
443
- response += `\n\nResult:\n${result}`;
444
- }
445
- // Stalest-specialist hint for the squad this task belongs to (#61).
446
- try {
447
- const squadSlug = task.agent_slug.split(":")[0];
448
- if (squadSlug) {
449
- const roster = deps.listSquadAgents(squadSlug);
450
- const characterNames = roster.map((a) => a.character_name);
451
- const lead = roster.find((a) => a.is_lead === 1);
452
- const stalest = deps.getStalestSpecialist(squadSlug, characterNames, {
453
- excludeCharacters: lead ? [lead.character_name] : [],
454
- });
455
- const hint = formatStalestHint(stalest);
456
- if (hint)
457
- response += `\n\n${hint}`;
458
- }
459
- }
460
- catch (err) {
461
- console.error("[io] squad_task_status: stalest-specialist hint failed:", err);
462
- }
463
- return response;
464
- }
465
- const tasks = deps.getActiveAgentTasks();
466
- if (tasks.length === 0)
467
- return "No active tasks.";
468
- const taskLines = tasks
469
- .map((t) => `- **${t.taskId}** (${t.agentSlug}) — ${t.status} — ${t.description}`)
470
- .join("\n");
471
- // Per-squad stalest-specialist hint block (#61).
472
- let hintsBlock = "";
473
- try {
474
- const uniqueSquadSlugs = Array.from(new Set(tasks.map((t) => t.agentSlug.split(":")[0]).filter((x) => !!x)));
475
- const hintLines = [];
476
- for (const squadSlug of uniqueSquadSlugs) {
477
- const roster = deps.listSquadAgents(squadSlug);
478
- if (roster.length === 0)
479
- continue;
480
- const characterNames = roster.map((a) => a.character_name);
481
- const lead = roster.find((a) => a.is_lead === 1);
482
- const stalest = deps.getStalestSpecialist(squadSlug, characterNames, {
483
- excludeCharacters: lead ? [lead.character_name] : [],
484
- });
485
- const hint = formatStalestHint(stalest);
486
- if (hint)
487
- hintLines.push(`- ${squadSlug}: ${hint}`);
488
- }
489
- if (hintLines.length > 0) {
490
- hintsBlock = `\n\n**Distribution hints:**\n${hintLines.join("\n")}`;
491
- }
492
- }
493
- catch (err) {
494
- console.error("[io] squad_task_status: distribution hints failed:", err);
495
- }
496
- return `${taskLines}${hintsBlock}`;
497
- },
498
- });
499
- // --- Squad analyze ---
500
- const squadAnalyze = defineTool("squad_analyze", {
501
- description: "Analyze a project directory to determine what specialist agents the squad needs. Scans for languages, frameworks, test tools, CI/CD config, and project structure. Use the output to decide which agents to add with squad_add_agent.",
502
- skipPermission: true,
503
- parameters: z.object({
504
- project_path: z.string().describe("Path to the project directory to analyze"),
505
- }),
506
- handler: async ({ project_path }) => {
507
- console.error(`[io] squad_analyze called: ${project_path}`);
508
- try {
509
- const resolved = resolve(project_path);
510
- if (!existsSync(resolved))
511
- return `Directory not found: ${project_path}`;
512
- const analysis = [];
513
- analysis.push(`## Project Analysis: ${project_path}\n`);
514
- // Detect languages & frameworks by scanning for key files
515
- const indicators = [
516
- { file: "package.json", label: "Node.js/JavaScript/TypeScript" },
517
- { file: "tsconfig.json", label: "TypeScript" },
518
- { file: "Cargo.toml", label: "Rust" },
519
- { file: "go.mod", label: "Go" },
520
- { file: "requirements.txt", label: "Python" },
521
- { file: "pyproject.toml", label: "Python" },
522
- { file: "Gemfile", label: "Ruby" },
523
- { file: "pom.xml", label: "Java (Maven)" },
524
- { file: "build.gradle", label: "Java/Kotlin (Gradle)" },
525
- { file: "*.csproj", label: ".NET/C#" },
526
- { file: "*.fsproj", label: ".NET/F#" },
527
- { file: "*.sln", label: ".NET Solution" },
528
- { file: "Dockerfile", label: "Docker" },
529
- { file: "docker-compose.yml", label: "Docker Compose" },
530
- { file: "docker-compose.yaml", label: "Docker Compose" },
531
- { file: ".github/workflows", label: "GitHub Actions CI/CD" },
532
- { file: ".gitlab-ci.yml", label: "GitLab CI" },
533
- { file: "Jenkinsfile", label: "Jenkins CI" },
534
- { file: "azure-pipelines.yml", label: "Azure Pipelines" },
535
- { file: "vite.config.ts", label: "Vite" },
536
- { file: "vite.config.js", label: "Vite" },
537
- { file: "next.config.js", label: "Next.js" },
538
- { file: "next.config.mjs", label: "Next.js" },
539
- { file: "nuxt.config.ts", label: "Nuxt" },
540
- { file: "angular.json", label: "Angular" },
541
- { file: "tailwind.config.js", label: "Tailwind CSS" },
542
- { file: "tailwind.config.ts", label: "Tailwind CSS" },
543
- { file: "jest.config.js", label: "Jest testing" },
544
- { file: "jest.config.ts", label: "Jest testing" },
545
- { file: "vitest.config.ts", label: "Vitest testing" },
546
- { file: ".eslintrc.js", label: "ESLint" },
547
- { file: "eslint.config.js", label: "ESLint" },
548
- { file: "terraform", label: "Terraform" },
549
- { file: "serverless.yml", label: "Serverless Framework" },
550
- ];
551
- const detected = [];
552
- for (const { file, label } of indicators) {
553
- if (file.includes("*")) {
554
- // Glob-like check — just look for files ending with the pattern
555
- const ext = file.replace("*", "");
556
- try {
557
- const entries = readdirSync(resolved);
558
- if (entries.some((e) => e.endsWith(ext))) {
559
- detected.push(label);
560
- }
561
- }
562
- catch { /* skip */ }
563
- }
564
- else {
565
- if (existsSync(join(resolved, file))) {
566
- detected.push(label);
567
- }
568
- }
569
- }
570
- if (detected.length > 0) {
571
- analysis.push(`**Detected Technologies**: ${[...new Set(detected)].join(", ")}`);
572
- }
573
- // Read package.json for more detail
574
- const pkgPath = join(resolved, "package.json");
575
- if (existsSync(pkgPath)) {
576
- try {
577
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
578
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
579
- const frameworks = [];
580
- const depNames = Object.keys(allDeps);
581
- const frameworkMap = {
582
- react: "React", vue: "Vue.js", angular: "Angular", svelte: "Svelte",
583
- express: "Express.js", fastify: "Fastify", koa: "Koa", hono: "Hono",
584
- "next": "Next.js", "nuxt": "Nuxt", "@nestjs/core": "NestJS",
585
- prisma: "Prisma ORM", drizzle: "Drizzle ORM", sequelize: "Sequelize",
586
- mongoose: "Mongoose", typeorm: "TypeORM",
587
- jest: "Jest", vitest: "Vitest", mocha: "Mocha", playwright: "Playwright",
588
- cypress: "Cypress", "@testing-library/react": "React Testing Library",
589
- tailwindcss: "Tailwind CSS", "@mui/material": "MUI",
590
- electron: "Electron", tauri: "Tauri",
591
- "@github/copilot-sdk": "GitHub Copilot SDK",
592
- };
593
- for (const [dep, label] of Object.entries(frameworkMap)) {
594
- if (depNames.includes(dep))
595
- frameworks.push(label);
596
- }
597
- if (frameworks.length > 0) {
598
- analysis.push(`**Frameworks/Libraries**: ${frameworks.join(", ")}`);
599
- }
600
- if (pkg.scripts) {
601
- analysis.push(`**Scripts**: ${Object.keys(pkg.scripts).join(", ")}`);
602
- }
603
- }
604
- catch { /* skip */ }
605
- }
606
- // Detect directory structure (top-level)
607
- try {
608
- const entries = readdirSync(resolved);
609
- const dirs = entries.filter((e) => {
610
- try {
611
- return statSync(join(resolved, e)).isDirectory() && !e.startsWith(".");
612
- }
613
- catch {
614
- return false;
615
- }
616
- });
617
- if (dirs.length > 0) {
618
- analysis.push(`**Top-level directories**: ${dirs.join(", ")}`);
619
- }
620
- }
621
- catch { /* skip */ }
622
- analysis.push("\n**Recommendation**: Based on this analysis, use `squad_add_agent` to create specialists. " +
623
- "Choose role titles that match the project's technology stack (e.g., 'Express API Engineer', " +
624
- "'Vue.js Frontend Developer', 'Vitest Test Engineer'). Write a charter for each agent describing " +
625
- "their specific responsibilities within this project.");
626
- return analysis.join("\n");
627
- }
628
- catch (err) {
629
- return `Error analyzing project: ${err instanceof Error ? err.message : String(err)}`;
630
- }
631
- },
632
- });
633
- // --- Squad add agent ---
634
- const squadAddAgent = defineTool("squad_add_agent", {
635
- description: "Add a named specialist agent to a squad. The next character from the squad's 80s universe is automatically assigned. Use after squad_analyze to build the team.",
636
- skipPermission: true,
637
- parameters: z.object({
638
- slug: z.string().describe("Squad slug"),
639
- role_title: z
640
- .string()
641
- .describe("Free-form role title based on project needs (e.g., 'Express API Engineer', 'Vue.js Frontend Dev', 'Vitest Test Engineer', 'GitHub Actions CI/CD Specialist')"),
642
- charter: z
643
- .string()
644
- .describe("Detailed description of this agent's responsibilities, technologies they own, and quality standards. This becomes their persistent mission."),
645
- model_tier: z
646
- .enum(["high", "medium", "low"])
647
- .optional()
648
- .describe("Model tier for this agent. Defaults to 'medium'. Use 'high' for architecture/complex work, 'low' for simple tasks."),
649
- }),
650
- handler: async ({ slug, role_title, charter, model_tier }) => {
651
- console.error(`[io] squad_add_agent called: ${slug} — ${role_title}`);
652
- try {
653
- const agent = deps.addSquadAgent(slug, role_title, charter, model_tier);
654
- return `Agent added to squad "${slug}":\n- **${agent.character_name}** — ${agent.role_title}\n- Personality: ${agent.personality}\n- Model: dynamic (task-based)`;
655
- }
656
- catch (err) {
657
- return `Error adding agent: ${err instanceof Error ? err.message : String(err)}`;
658
- }
659
- },
660
- });
661
- // --- Squad agents (list roster) ---
662
- const squadAgents = defineTool("squad_agents", {
663
- description: "List all named agents in a squad's roster with their character names, roles, and status.",
664
- skipPermission: true,
665
- parameters: z.object({
666
- slug: z.string().describe("Squad slug"),
667
- }),
668
- handler: async ({ slug }) => {
669
- const squad = deps.getSquad(slug);
670
- if (!squad)
671
- return `Squad not found: ${slug}`;
672
- const agents = deps.listSquadAgents(slug);
673
- if (agents.length === 0) {
674
- return `Squad "${squad.name}" has no agents yet. Use squad_add_agent to build the team.`;
675
- }
676
- const universeName = squad.universe
677
- ? UNIVERSES.find((u) => u.id === squad.universe)?.name ?? squad.universe
678
- : "none";
679
- // Pull per-agent task stats once and key by character_name (issue #61).
680
- // If the helper throws (e.g. brand-new DB before view migration), fall
681
- // back to an empty map so rendering is unchanged rather than 500-ing.
682
- const characterNames = agents.map((a) => a.character_name);
683
- const statsByName = new Map();
684
- try {
685
- for (const st of deps.getAgentTaskStats(slug, characterNames)) {
686
- statsByName.set(st.character_name, {
687
- task_count: st.task_count,
688
- last_delegated_at: st.last_delegated_at,
689
- });
690
- }
691
- }
692
- catch (err) {
693
- console.error("[io] squad_agents: getAgentTaskStats failed:", err);
694
- }
695
- const lines = agents.map((a) => {
696
- const leadBadge = a.is_lead === 1 ? " ⭐ [LEAD]" : "";
697
- const qaBadge = a.is_qa === 1 ? " 🛡️ [QA]" : "";
698
- const st = statsByName.get(a.character_name) ?? { task_count: 0, last_delegated_at: null };
699
- const statsStr = st.task_count === 0
700
- ? " — 📊 never delegated"
701
- : ` — 📊 ${st.task_count} ${st.task_count === 1 ? "task" : "tasks"} · last ${formatRelativeTime(st.last_delegated_at)}`;
702
- return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (dynamic) — ${a.status}${statsStr}${a.personality ? `\n _${a.personality}_` : ""}`;
703
- });
704
- const coverage = assessSquadCoverage(agents);
705
- const coverageBlock = coverage.warning ? `\n\n${coverage.warning}` : "";
706
- // Stalest-specialist hint (issue #61): exclude the lead so the hint
707
- // points at an under-utilised teammate rather than the coordinator.
708
- let stalestBlock = "";
709
- try {
710
- const lead = agents.find((a) => a.is_lead === 1);
711
- const stalest = deps.getStalestSpecialist(slug, characterNames, {
712
- excludeCharacters: lead ? [lead.character_name] : [],
713
- });
714
- const hint = formatStalestHint(stalest);
715
- if (hint)
716
- stalestBlock = `\n\n${hint}`;
717
- }
718
- catch (err) {
719
- console.error("[io] squad_agents: getStalestSpecialist failed:", err);
720
- }
721
- return `**${squad.name}** — 🎬 ${universeName}\n\n${lines.join("\n")}${coverageBlock}${stalestBlock}`;
722
- },
723
- });
724
- // --- Squad remove agent ---
725
- const squadRemoveAgent = defineTool("squad_remove_agent", {
726
- description: "Remove a named agent from a squad's roster.",
727
- skipPermission: true,
728
- parameters: z.object({
729
- slug: z.string().describe("Squad slug"),
730
- character_name: z.string().describe("Character name of the agent to remove"),
731
- }),
732
- handler: async ({ slug, character_name }) => {
733
- console.error(`[io] squad_remove_agent called: ${slug} — ${character_name}`);
734
- const removed = deps.removeSquadAgent(slug, character_name);
735
- return removed
736
- ? `Agent "${character_name}" removed from squad "${slug}".`
737
- : `Agent "${character_name}" not found in squad "${slug}".`;
738
- },
739
- });
740
- const squadResetAgent = defineTool("squad_reset_agent", {
741
- description: "Clear a squad agent's error state and return them to idle without removing them. Preserves their charter, role title, character name, and is_lead/is_qa flags. Drops the agent's in-memory and persisted Copilot session so the next task starts fresh. Safe to call on a non-error agent (no-op with a clear message).",
742
- skipPermission: true,
743
- parameters: z.object({
744
- slug: z.string().describe("Squad slug"),
745
- character_name: z.string().describe("Character name of the agent to reset"),
746
- }),
747
- handler: async ({ slug, character_name }) => {
748
- console.error(`[io] squad_reset_agent called: ${slug} — ${character_name}`);
749
- const squad = deps.getSquad(slug);
750
- if (!squad)
751
- return `Squad not found: ${slug}`;
752
- const result = deps.resetSquadAgent(slug, character_name);
753
- if (!result.found || !result.agent) {
754
- return `Agent "${character_name}" not found in squad "${slug}".`;
755
- }
756
- const { previousStatus, agent } = result;
757
- if (previousStatus === "error") {
758
- return `🔄 ${agent.character_name} (${agent.role_title}) reset from 'error' → 'idle'. Charter and role preserved; next task will create a fresh Copilot session.`;
759
- }
760
- if (previousStatus === "idle") {
761
- return `${agent.character_name} (${agent.role_title}) is already 'idle'. No-op: in-memory session cache and persisted session id were cleared anyway so the next task starts fresh.`;
762
- }
763
- // working / unknown
764
- return `⚠️ ${agent.character_name} (${agent.role_title}) was in '${previousStatus}' (not 'error'). Forced to 'idle' and cleared session anyway — verify no task is actually still running for this agent (call squad_task_status).`;
765
- },
766
- });
767
- // --- Squad delete ---
768
- const squadDelete = defineTool("squad_delete", {
769
- description: "Delete a squad and all its agents and decisions. This is permanent.",
770
- skipPermission: true,
771
- parameters: z.object({
772
- slug: z.string().describe("Squad slug to delete"),
773
- }),
774
- handler: async ({ slug }) => {
775
- console.error(`[io] squad_delete called: ${slug}`);
776
- try {
777
- const squad = deps.getSquad(slug);
778
- if (!squad)
779
- return `Squad not found: ${slug}`;
780
- deps.deleteSquad(slug);
781
- return `Squad "${squad.name}" (${slug}) has been deleted.`;
782
- }
783
- catch (err) {
784
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
785
- }
786
- },
787
- });
788
- // --- Skill management ---
789
- const skillList = defineTool("skill_list", {
790
- description: "List all installed skills with their names, slugs, and descriptions.",
791
- skipPermission: true,
792
- parameters: z.object({}),
793
- handler: async () => {
794
- const skills = deps.listSkills();
795
- if (skills.length === 0)
796
- return "No skills installed.";
797
- return skills
798
- .map((s) => `- **${s.name}** (\`${s.slug}\`): ${s.description || "(no description)"}`)
799
- .join("\n");
800
- },
801
- });
802
- const skillInstall = defineTool("skill_install", {
803
- description: "Install a skill from a git repository URL or a direct SKILL.md file URL. Accepts full repo URLs (clones the repo) and GitHub blob/raw URLs pointing to a specific SKILL.md (fetches just that file).",
804
- skipPermission: true,
805
- parameters: z.object({
806
- repo_url: z.string().describe("Git repository URL (e.g., https://github.com/user/my-skill.git) or direct SKILL.md URL (e.g., https://github.com/user/repo/blob/main/skills/my-skill/SKILL.md)"),
807
- }),
808
- handler: async ({ repo_url }) => {
809
- console.error(`[io] skill_install called: ${repo_url}`);
810
- try {
811
- const result = await deps.installSkill(repo_url);
812
- const skills = Array.isArray(result) ? result : [result];
813
- return skills.map((skill) => `Skill "${skill.name}" installed successfully.\nSlug: ${skill.slug}\nDescription: ${skill.description || "(none)"}`).join("\n\n");
814
- }
815
- catch (err) {
816
- return `Error installing skill: ${err instanceof Error ? err.message : String(err)}`;
817
- }
818
- },
819
- });
820
- const skillRemove = defineTool("skill_remove", {
821
- description: "Remove an installed skill by its slug.",
822
- skipPermission: true,
823
- parameters: z.object({
824
- slug: z.string().describe("Skill slug to remove"),
825
- }),
826
- handler: async ({ slug }) => {
827
- console.error(`[io] skill_remove called: ${slug}`);
828
- const removed = deps.removeSkill(slug);
829
- return removed ? `Skill "${slug}" removed.` : `Skill not found: ${slug}`;
830
- },
831
- });
832
- const skillSearch = defineTool("skill_search", {
833
- description: "Search the skills.sh registry for skills matching a query.",
834
- skipPermission: true,
835
- parameters: z.object({
836
- query: z.string().describe("Search query"),
837
- }),
838
- handler: async ({ query }) => {
839
- console.error(`[io] skill_search called: ${query}`);
840
- try {
841
- const results = await deps.searchSkillsRegistry(query);
842
- if (results.length === 0)
843
- return `No skills found for "${query}".`;
844
- return results
845
- .map((r) => `- **${r.name}**: ${r.description}\n ${r.repoUrl}`)
846
- .join("\n");
847
- }
848
- catch (err) {
849
- return `Error searching registry: ${err instanceof Error ? err.message : String(err)}`;
850
- }
851
- },
852
- });
853
- // --- Wiki extras ---
854
- const wikiDelete = defineTool("wiki_delete", {
855
- description: "Delete a page from IO's knowledge base wiki.",
856
- skipPermission: true,
857
- parameters: z.object({
858
- path: z.string().describe("Relative path to the wiki page (e.g., 'pages/preferences/editor.md')"),
859
- }),
860
- handler: async ({ path: pagePath }) => {
861
- console.error(`[io] wiki_delete called: ${pagePath}`);
862
- try {
863
- const deleted = deps.wikiDelete(pagePath);
864
- return deleted ? `Deleted: ${pagePath}` : `Page not found: ${pagePath}`;
865
- }
866
- catch (err) {
867
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
868
- }
869
- },
870
- });
871
- const wikiList = defineTool("wiki_list", {
872
- description: "List all pages in IO's knowledge base wiki.",
873
- skipPermission: true,
874
- parameters: z.object({}),
875
- handler: async () => {
876
- const pages = deps.wikiList();
877
- if (pages.length === 0)
878
- return "Wiki is empty — no pages yet.";
879
- return pages.map((p) => `- ${p}`).join("\n");
880
- },
881
- });
882
- // --- Config update ---
883
- const configUpdate = defineTool("config_update", {
884
- description: "Update IO's configuration. Changes are saved to ~/.io/config.json and take effect on restart.",
885
- skipPermission: true,
886
- parameters: z.object({
887
- key: z
888
- .enum(["defaultModel", "telegramEnabled", "selfEditEnabled", "port", "authorizedEmail"])
889
- .describe("Config key to update"),
890
- value: z
891
- .union([z.string(), z.number(), z.boolean()])
892
- .describe("New value for the config key"),
893
- }),
894
- handler: async ({ key, value }) => {
895
- console.error(`[io] config_update called: ${key} = ${JSON.stringify(value)}`);
896
- try {
897
- deps.saveConfig({ [key]: value });
898
- return `Config updated: ${key} = ${JSON.stringify(value)}\nNote: Some changes require a restart to take effect.`;
899
- }
900
- catch (err) {
901
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
902
- }
903
- },
904
- });
905
- // --- Update checker ---
906
- const checkUpdate = defineTool("check_update", {
907
- description: "Check if a newer version of IO is available.",
908
- skipPermission: true,
909
- parameters: z.object({}),
910
- handler: async () => {
911
- try {
912
- const info = await deps.checkForUpdate();
913
- if (info.updateAvailable) {
914
- return `Update available! Current: v${info.current} → Latest: v${info.latest}\nRun: npm update -g heyio`;
915
- }
916
- return `IO is up to date (v${info.current}).`;
917
- }
918
- catch (err) {
919
- return `Error checking for updates: ${err instanceof Error ? err.message : String(err)}`;
920
- }
921
- },
922
- });
923
- const shell = defineTool("shell", {
924
- description: "Run a shell command on the host machine. You have FULL ROOT ACCESS. Use for git, apt, npm, build tools, system administration, or anything else. There are NO permission restrictions — all commands are allowed and pre-approved.",
925
- skipPermission: true,
926
- parameters: z.object({
927
- command: z.string().describe("The command to run"),
928
- timeout_secs: z.number().optional().describe("Timeout in seconds (default: 60)"),
929
- working_dir: z.string().optional().describe("Working directory for the command"),
930
- }),
931
- handler: async ({ command, timeout_secs, working_dir }) => {
932
- console.error(`[io] shell tool called: ${command}${working_dir ? ` (cwd: ${working_dir})` : ""}`);
933
- try {
934
- const result = execSync(command, {
935
- encoding: "utf-8",
936
- timeout: (timeout_secs ?? 60) * 1000,
937
- maxBuffer: 1024 * 1024,
938
- cwd: working_dir,
939
- env: shellEnv(),
940
- });
941
- const output = result.trim();
942
- if (output.length > 8000) {
943
- return output.slice(0, 8000) + "\n\n[…truncated]";
944
- }
945
- return output || "(no output)";
946
- }
947
- catch (err) {
948
- const execErr = err;
949
- const stderr = execErr.stderr?.trim() ?? "";
950
- const stdout = execErr.stdout?.trim() ?? "";
951
- const msg = stderr || stdout || execErr.message || "Command failed";
952
- if (msg.length > 4000) {
953
- return `Error:\n${msg.slice(0, 4000)}\n[…truncated]`;
954
- }
955
- return `Error:\n${msg}`;
956
- }
957
- },
958
- });
959
- const fileOps = defineTool("file_ops", {
960
- description: "Read, write, list, or mkdir on the local filesystem. Full access to all paths.",
961
- skipPermission: true,
962
- parameters: z.object({
963
- operation: z.enum(["read", "write", "list", "mkdir"]).describe("Operation to perform"),
964
- path: z.string().describe("File or directory path"),
965
- content: z.string().optional().describe("Content to write (for write operation)"),
966
- recursive: z.boolean().optional().describe("Recurse into subdirectories (for list)"),
967
- }),
968
- handler: async ({ operation, path: filePath, content, recursive }) => {
969
- console.error(`[io] file_ops tool called: ${operation} ${filePath}`);
970
- try {
971
- const resolved = resolve(filePath);
972
- if (operation === "read") {
973
- if (!existsSync(resolved))
974
- return `File not found: ${filePath}`;
975
- const text = readFileSync(resolved, "utf-8");
976
- if (text.length > 8000) {
977
- return text.slice(0, 8000) + "\n\n[…truncated]";
978
- }
979
- return text;
980
- }
981
- if (operation === "write") {
982
- if (!content)
983
- return "Error: content is required for write operation";
984
- mkdirSync(dirname(resolved), { recursive: true });
985
- writeFileSync(resolved, content, "utf-8");
986
- return `Written: ${filePath}`;
987
- }
988
- if (operation === "list") {
989
- if (!existsSync(resolved))
990
- return `Directory not found: ${filePath}`;
991
- if (recursive) {
992
- const files = walkDirectory(resolved);
993
- return files.join("\n") || "(empty directory)";
994
- }
995
- const entries = readdirSync(resolved);
996
- return entries
997
- .map((e) => {
998
- const full = join(resolved, e);
999
- const isDir = statSync(full).isDirectory();
1000
- return isDir ? `${e}/` : e;
1001
- })
1002
- .join("\n") || "(empty directory)";
1003
- }
1004
- if (operation === "mkdir") {
1005
- mkdirSync(resolved, { recursive: true });
1006
- return `Created directory: ${filePath}`;
1007
- }
1008
- return `Unknown operation: ${operation}`;
1009
- }
1010
- catch (err) {
1011
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1012
- }
1013
- },
1014
- });
1015
- // Override built-in bash tool so the model uses our implementation
1016
- const bash = defineTool("bash", {
1017
- description: "Run a bash command on the host machine with full root access.",
1018
- skipPermission: true,
1019
- overridesBuiltInTool: true,
1020
- parameters: z.object({
1021
- command: z.string().describe("The command to run"),
1022
- }),
1023
- handler: async ({ command }) => {
1024
- console.error(`[io] bash tool called: ${command}`);
1025
- try {
1026
- const result = execSync(command, {
1027
- encoding: "utf-8",
1028
- timeout: 60_000,
1029
- maxBuffer: 1024 * 1024,
1030
- env: shellEnv(),
1031
- });
1032
- const output = result.trim();
1033
- if (output.length > 8000) {
1034
- return output.slice(0, 8000) + "\n\n[…truncated]";
1035
- }
1036
- return output || "(no output)";
1037
- }
1038
- catch (err) {
1039
- const execErr = err;
1040
- const stderr = execErr.stderr?.trim() ?? "";
1041
- const stdout = execErr.stdout?.trim() ?? "";
1042
- const msg = stderr || stdout || execErr.message || "Command failed";
1043
- if (msg.length > 4000) {
1044
- return `Error:\n${msg.slice(0, 4000)}\n[…truncated]`;
1045
- }
1046
- return `Error:\n${msg}`;
1047
- }
1048
- },
1049
- });
1050
- // Override built-in read_file tool
1051
- const readFile = defineTool("read_file", {
1052
- description: "Read a file from the filesystem.",
1053
- skipPermission: true,
1054
- overridesBuiltInTool: true,
1055
- parameters: z.object({
1056
- file_path: z.string().describe("Path to the file to read"),
1057
- }),
1058
- handler: async ({ file_path }) => {
1059
- console.error(`[io] read_file tool called: ${file_path}`);
1060
- try {
1061
- const resolved = resolve(file_path);
1062
- if (!existsSync(resolved))
1063
- return `File not found: ${file_path}`;
1064
- const text = readFileSync(resolved, "utf-8");
1065
- if (text.length > 8000) {
1066
- return text.slice(0, 8000) + "\n\n[…truncated]";
1067
- }
1068
- return text;
1069
- }
1070
- catch (err) {
1071
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1072
- }
1073
- },
1074
- });
1075
- // Override built-in view tool
1076
- const viewTool = defineTool("view", {
1077
- description: "View a file's contents or list a directory.",
1078
- skipPermission: true,
1079
- overridesBuiltInTool: true,
1080
- parameters: z.object({
1081
- path: z.string().describe("Path to the file or directory"),
1082
- view_range: z.array(z.number()).optional().describe("Line range [start, end] to view"),
1083
- }),
1084
- handler: async ({ path: filePath, view_range }) => {
1085
- console.error(`[io] view tool called: ${filePath}`);
1086
- try {
1087
- const resolved = resolve(filePath);
1088
- if (!existsSync(resolved))
1089
- return `Not found: ${filePath}`;
1090
- const stat = statSync(resolved);
1091
- if (stat.isDirectory()) {
1092
- const entries = readdirSync(resolved);
1093
- return entries
1094
- .map((e) => {
1095
- const full = join(resolved, e);
1096
- try {
1097
- return statSync(full).isDirectory() ? `${e}/` : e;
1098
- }
1099
- catch {
1100
- return e;
1101
- }
1102
- })
1103
- .join("\n") || "(empty directory)";
1104
- }
1105
- const text = readFileSync(resolved, "utf-8");
1106
- if (view_range && view_range.length === 2) {
1107
- const lines = text.split("\n");
1108
- const start = Math.max(0, view_range[0] - 1);
1109
- const end = view_range[1] === -1 ? lines.length : Math.min(lines.length, view_range[1]);
1110
- return lines.slice(start, end).map((l, i) => `${start + i + 1}. ${l}`).join("\n");
1111
- }
1112
- if (text.length > 8000) {
1113
- return text.slice(0, 8000) + "\n\n[…truncated]";
1114
- }
1115
- return text;
1116
- }
1117
- catch (err) {
1118
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1119
- }
1120
- },
1121
- });
1122
- // Override built-in grep tool
1123
- const grepTool = defineTool("grep", {
1124
- description: "Search file contents using a pattern.",
1125
- skipPermission: true,
1126
- overridesBuiltInTool: true,
1127
- parameters: z.object({
1128
- pattern: z.string().describe("Search pattern (regex)"),
1129
- path: z.string().optional().describe("Directory or file to search"),
1130
- include: z.string().optional().describe("Glob pattern to filter files (e.g., '*.ts')"),
1131
- }),
1132
- handler: async ({ pattern, path: searchPath, include }) => {
1133
- console.error(`[io] grep tool called: ${pattern} in ${searchPath || "."}`);
1134
- try {
1135
- const args = ["-rn", pattern];
1136
- if (include)
1137
- args.push(`--include=${include}`);
1138
- args.push(searchPath || ".");
1139
- const result = execFileSync("grep", args, {
1140
- encoding: "utf-8",
1141
- timeout: 30_000,
1142
- maxBuffer: 1024 * 1024,
1143
- env: shellEnv(),
1144
- });
1145
- const output = result.trim();
1146
- if (output.length > 8000) {
1147
- return output.slice(0, 8000) + "\n\n[…truncated]";
1148
- }
1149
- return output || "(no matches)";
1150
- }
1151
- catch (err) {
1152
- const execErr = err;
1153
- if (execErr.status === 1)
1154
- return "(no matches)";
1155
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1156
- }
1157
- },
1158
- });
1159
- // Override built-in str_replace_editor tool
1160
- const strReplaceEditor = defineTool("str_replace_editor", {
1161
- description: "View, create, or edit files using string replacement.",
1162
- skipPermission: true,
1163
- overridesBuiltInTool: true,
1164
- parameters: z.object({
1165
- command: z.enum(["view", "create", "str_replace", "insert"]).describe("Command to execute"),
1166
- path: z.string().describe("File path"),
1167
- old_str: z.string().optional().describe("String to replace (for str_replace)"),
1168
- new_str: z.string().optional().describe("Replacement string"),
1169
- file_text: z.string().optional().describe("Content for create"),
1170
- insert_line: z.number().optional().describe("Line number for insert"),
1171
- view_range: z.array(z.number()).optional().describe("Line range [start, end]"),
1172
- }),
1173
- handler: async ({ command, path: filePath, old_str, new_str, file_text, insert_line, view_range }) => {
1174
- console.error(`[io] str_replace_editor tool called: ${command} ${filePath}`);
1175
- try {
1176
- const resolved = resolve(filePath);
1177
- if (command === "view") {
1178
- if (!existsSync(resolved))
1179
- return `File not found: ${filePath}`;
1180
- const text = readFileSync(resolved, "utf-8");
1181
- if (view_range && view_range.length === 2) {
1182
- const lines = text.split("\n");
1183
- const start = Math.max(0, view_range[0] - 1);
1184
- const end = view_range[1] === -1 ? lines.length : Math.min(lines.length, view_range[1]);
1185
- return lines.slice(start, end).map((l, i) => `${start + i + 1}. ${l}`).join("\n");
1186
- }
1187
- if (text.length > 8000)
1188
- return text.slice(0, 8000) + "\n\n[…truncated]";
1189
- return text;
1190
- }
1191
- if (command === "create") {
1192
- mkdirSync(dirname(resolved), { recursive: true });
1193
- writeFileSync(resolved, file_text || "", "utf-8");
1194
- return `Created: ${filePath}`;
1195
- }
1196
- if (command === "str_replace") {
1197
- if (!existsSync(resolved))
1198
- return `File not found: ${filePath}`;
1199
- const text = readFileSync(resolved, "utf-8");
1200
- if (old_str && !text.includes(old_str))
1201
- return `old_str not found in ${filePath}`;
1202
- const updated = old_str ? text.replace(old_str, new_str || "") : text;
1203
- writeFileSync(resolved, updated, "utf-8");
1204
- return `Updated: ${filePath}`;
1205
- }
1206
- if (command === "insert") {
1207
- if (!existsSync(resolved))
1208
- return `File not found: ${filePath}`;
1209
- const lines = readFileSync(resolved, "utf-8").split("\n");
1210
- const lineNum = insert_line ?? lines.length;
1211
- lines.splice(lineNum, 0, new_str || "");
1212
- writeFileSync(resolved, lines.join("\n"), "utf-8");
1213
- return `Inserted at line ${lineNum} in ${filePath}`;
1214
- }
1215
- return `Unknown command: ${command}`;
1216
- }
1217
- catch (err) {
1218
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1219
- }
1220
- },
1221
- });
1222
- // GitHub issue/PR management via gh CLI
1223
- const github = defineTool("github", {
1224
- description: "Manage GitHub issues and pull requests using the gh CLI. Supports creating, listing, viewing, commenting on, and reviewing issues and PRs.",
1225
- skipPermission: true,
1226
- parameters: z.object({
1227
- action: z
1228
- .enum([
1229
- "create_issue",
1230
- "list_issues",
1231
- "view_issue",
1232
- "comment_issue",
1233
- "close_issue",
1234
- "create_pr",
1235
- "list_prs",
1236
- "view_pr",
1237
- "comment_pr",
1238
- "review_pr",
1239
- ])
1240
- .describe("The GitHub action to perform"),
1241
- repo: z.string().describe("Repository in owner/repo format"),
1242
- title: z.string().optional().describe("Title (for create_issue, create_pr)"),
1243
- body: z.string().optional().describe("Body text (for create_issue, create_pr, comment_*)"),
1244
- labels: z.array(z.string()).optional().describe("Labels (for create_issue)"),
1245
- assignees: z.array(z.string()).optional().describe("Assignees (for create_issue)"),
1246
- number: z.number().optional().describe("Issue or PR number (for view, comment, close)"),
1247
- base: z.string().optional().describe("Base branch (for create_pr)"),
1248
- head: z.string().optional().describe("Head branch (for create_pr)"),
1249
- state: z.enum(["open", "closed", "all"]).optional().describe("Filter by state (for list_*)"),
1250
- limit: z.number().optional().describe("Max results (for list_*, default 10)"),
1251
- review_action: z.enum(["approve", "request-changes", "comment"]).optional()
1252
- .describe("Review action (for review_pr): approve, request-changes, or comment"),
1253
- }),
1254
- handler: async ({ action, repo, title, body, labels, assignees, number, base, head, state, limit, review_action }) => {
1255
- console.error(`[io] github tool called: ${action} on ${repo}`);
1256
- try {
1257
- let args;
1258
- switch (action) {
1259
- case "create_issue": {
1260
- if (!title)
1261
- return "Error: title is required for create_issue";
1262
- args = ["issue", "create", "--repo", repo, "--title", title];
1263
- if (body)
1264
- args.push("--body", body);
1265
- if (labels?.length)
1266
- args.push("--label", labels.join(","));
1267
- if (assignees?.length)
1268
- args.push("--assignee", assignees.join(","));
1269
- break;
1270
- }
1271
- case "list_issues": {
1272
- args = ["issue", "list", "--repo", repo, "--limit", String(limit ?? 10)];
1273
- if (state)
1274
- args.push("--state", state);
1275
- break;
1276
- }
1277
- case "view_issue": {
1278
- if (!number)
1279
- return "Error: number is required for view_issue";
1280
- args = ["issue", "view", String(number), "--repo", repo];
1281
- break;
1282
- }
1283
- case "comment_issue": {
1284
- if (!number)
1285
- return "Error: number is required for comment_issue";
1286
- if (!body)
1287
- return "Error: body is required for comment_issue";
1288
- args = ["issue", "comment", String(number), "--repo", repo, "--body", body];
1289
- break;
1290
- }
1291
- case "close_issue": {
1292
- if (!number)
1293
- return "Error: number is required for close_issue";
1294
- args = ["issue", "close", String(number), "--repo", repo];
1295
- break;
1296
- }
1297
- case "create_pr": {
1298
- if (!title)
1299
- return "Error: title is required for create_pr";
1300
- args = ["pr", "create", "--repo", repo, "--title", title];
1301
- if (body)
1302
- args.push("--body", body);
1303
- if (base)
1304
- args.push("--base", base);
1305
- if (head)
1306
- args.push("--head", head);
1307
- break;
1308
- }
1309
- case "list_prs": {
1310
- args = ["pr", "list", "--repo", repo, "--limit", String(limit ?? 10)];
1311
- if (state)
1312
- args.push("--state", state);
1313
- break;
1314
- }
1315
- case "view_pr": {
1316
- if (!number)
1317
- return "Error: number is required for view_pr";
1318
- args = ["pr", "view", String(number), "--repo", repo];
1319
- break;
1320
- }
1321
- case "comment_pr": {
1322
- if (!number)
1323
- return "Error: number is required for comment_pr";
1324
- if (!body)
1325
- return "Error: body is required for comment_pr";
1326
- args = ["pr", "comment", String(number), "--repo", repo, "--body", body];
1327
- break;
1328
- }
1329
- case "review_pr": {
1330
- if (!number)
1331
- return "Error: number is required for review_pr";
1332
- if (!review_action)
1333
- return "Error: review_action is required for review_pr (approve, request-changes, or comment)";
1334
- args = ["pr", "review", String(number), "--repo", repo, `--${review_action}`];
1335
- if (body && (review_action === "request-changes" || review_action === "comment")) {
1336
- args.push("--body", body);
1337
- }
1338
- break;
1339
- }
1340
- default:
1341
- return `Unknown action: ${action}`;
1342
- }
1343
- const result = execFileSync("gh", args, {
1344
- encoding: "utf-8",
1345
- timeout: 30_000,
1346
- maxBuffer: 1024 * 1024,
1347
- env: shellEnv(),
1348
- }).trim();
1349
- if (result.length > 8000) {
1350
- return result.slice(0, 8000) + "\n\n[…truncated]";
1351
- }
1352
- return result || "(success, no output)";
1353
- }
1354
- catch (err) {
1355
- const execErr = err;
1356
- const msg = execErr.stderr?.trim() || execErr.stdout?.trim() || execErr.message || "Command failed";
1357
- return `Error: ${msg.length > 4000 ? msg.slice(0, 4000) + "\n[…truncated]" : msg}`;
1358
- }
1359
- },
1360
- });
1361
- const squadSetQA = defineTool("squad_set_qa", {
1362
- description: "Mark a squad agent as a QA reviewer with veto power. QA agents must approve before a PR is promoted from draft to ready.",
1363
- skipPermission: true,
1364
- parameters: z.object({
1365
- slug: z.string().describe("Squad slug"),
1366
- character_name: z.string().describe("Character name of the agent"),
1367
- is_qa: z
1368
- .boolean()
1369
- .describe("Whether this agent is a QA reviewer (true) or not (false)"),
1370
- }),
1371
- handler: async ({ slug, character_name, is_qa }) => {
1372
- try {
1373
- const squad = deps.getSquad(slug);
1374
- if (!squad)
1375
- return `Squad not found: ${slug}`;
1376
- const agents = deps.listSquadAgents(slug);
1377
- const target = agents.find((a) => a.character_name === character_name);
1378
- if (!target) {
1379
- return `Agent "${character_name}" not found in squad "${slug}".`;
1380
- }
1381
- deps.setSquadQA(slug, character_name, is_qa);
1382
- return is_qa
1383
- ? `🛡️ ${character_name} (${target.role_title}) is now a QA reviewer for squad "${squad.name}". They have veto power over PR promotion.`
1384
- : `${character_name} is no longer a QA reviewer for squad "${squad.name}".`;
1385
- }
1386
- catch (err) {
1387
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1388
- }
1389
- },
1390
- });
1391
- const squadTaskReviews = defineTool("squad_task_reviews", {
1392
- description: "Get the peer reviews left on a completed task by the squad. Shows who approved or rejected and any comments.",
1393
- skipPermission: true,
1394
- parameters: z.object({
1395
- task_id: z.string().describe("The task ID to fetch reviews for"),
1396
- }),
1397
- handler: async ({ task_id }) => {
1398
- const reviews = deps.getTaskReviews(task_id);
1399
- if (reviews.length === 0) {
1400
- return `No reviews found for task ${task_id}.`;
1401
- }
1402
- // Look up reviewer roles (lead/qa) so we can flag where each verdict
1403
- // came from. Lead and QA reviewers both have veto power.
1404
- const rolesBySquad = new Map();
1405
- const rolesFor = (squadSlug, character) => {
1406
- let squadMap = rolesBySquad.get(squadSlug);
1407
- if (!squadMap) {
1408
- squadMap = new Map();
1409
- for (const a of deps.listSquadAgents(squadSlug)) {
1410
- squadMap.set(a.character_name, {
1411
- is_lead: a.is_lead === 1,
1412
- is_qa: a.is_qa === 1,
1413
- });
1414
- }
1415
- rolesBySquad.set(squadSlug, squadMap);
1416
- }
1417
- return squadMap.get(character) ?? { is_lead: false, is_qa: false };
1418
- };
1419
- return reviews
1420
- .map((r) => {
1421
- const { is_lead, is_qa } = rolesFor(r.squad_slug, r.reviewer_character);
1422
- const approved = r.approved === 1;
1423
- let badge = "";
1424
- if (is_lead && is_qa)
1425
- badge = "⭐🛡️ ";
1426
- else if (is_lead)
1427
- badge = "⭐ ";
1428
- else if (is_qa)
1429
- badge = "🛡️ ";
1430
- const verdict = approved
1431
- ? `${badge}✅ APPROVED`
1432
- : `${badge}❌ REJECTED`;
1433
- const tags = [];
1434
- if (is_lead)
1435
- tags.push("lead");
1436
- if (is_qa)
1437
- tags.push("QA");
1438
- const tagSuffix = tags.length ? ` _(${tags.join(", ")})_` : "";
1439
- const veto = !approved && (is_lead || is_qa) ? " — **veto**" : "";
1440
- const comments = r.comments ? `\n ${r.comments.replace(/\n/g, "\n ")}` : "";
1441
- return `- **${r.reviewer_character}**${tagSuffix} — ${verdict}${veto}${comments}`;
1442
- })
1443
- .join("\n");
1444
- },
1445
- });
1446
- const squadSetLead = defineTool("squad_set_lead", {
1447
- description: "Designate an agent as the team lead for their squad. The lead MUST be a dedicated PM / Senior Engineer with NO domain responsibility — their sole job is coordinating, delegating, and reviewing the team's work. Do not pick an agent who also owns the backend, frontend, tests, or any other implementation domain. The lead receives delegated tasks (when no specific agent is targeted), orchestrates the team via delegate_to_teammate, and holds automatic veto power on PR promotion.",
1448
- skipPermission: true,
1449
- parameters: z.object({
1450
- slug: z.string().describe("Squad slug"),
1451
- character_name: z
1452
- .string()
1453
- .describe("Character name of the agent to make team lead. Choose a PM / Senior Engineer with no domain ownership."),
1454
- }),
1455
- handler: async ({ slug, character_name }) => {
1456
- try {
1457
- const squad = deps.getSquad(slug);
1458
- if (!squad)
1459
- return `Squad not found: ${slug}`;
1460
- const agents = deps.listSquadAgents(slug);
1461
- const target = agents.find((a) => a.character_name === character_name);
1462
- if (!target) {
1463
- return `Agent "${character_name}" not found in squad "${slug}". Use squad_agents to list the roster.`;
1464
- }
1465
- deps.setSquadLead(slug, character_name);
1466
- const dedicated = roleLooksLikeDedicatedLead(target.role_title);
1467
- const base = `⭐ ${character_name} (${target.role_title}) is now the team lead for squad "${squad.name}". They have automatic veto power on PR promotion.`;
1468
- if (!dedicated) {
1469
- return `${base}\n\n⚠️ "${target.role_title}" looks like a domain specialist. Team leads should be a dedicated PM / Senior Engineer with no other domain responsibility — consider adding a dedicated lead agent (e.g. role "Senior Engineering Lead" or "Project Manager") and reassigning.`;
1470
- }
1471
- return base;
1472
- }
1473
- catch (err) {
1474
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
1475
- }
1476
- },
1477
- });
1478
- // ---------------------------------------------------------------------------
1479
- // Squad schedules — recurring stand-ups via cron-style expressions.
1480
- // ---------------------------------------------------------------------------
1481
- const KNOWN_AGENDA_ITEMS = ["triage", "prioritize", "ideation"];
1482
- const squadScheduleCreate = defineTool("squad_schedule_create", {
1483
- 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.",
1484
- skipPermission: true,
1485
- parameters: z.object({
1486
- slug: z.string().describe("Squad slug to schedule"),
1487
- name: z
1488
- .string()
1489
- .describe("Human-friendly name for this schedule, e.g. 'Daily 5AM stand-up'"),
1490
- cron: z
1491
- .string()
1492
- .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."),
1493
- agenda: z
1494
- .array(z.string())
1495
- .min(1)
1496
- .describe(`Ordered agenda. Built-in items: ${KNOWN_AGENDA_ITEMS.join(", ")}. You may include custom items; the team lead will improvise.`),
1497
- notes: z
1498
- .string()
1499
- .optional()
1500
- .describe("Optional operator notes appended to the stand-up prompt."),
1501
- }),
1502
- handler: async ({ slug, name, cron, agenda, notes }) => {
1503
- const squad = deps.getSquad(slug);
1504
- if (!squad)
1505
- return `Squad not found: ${slug}`;
1506
- const v = validateCron(cron);
1507
- if (!v.ok)
1508
- return `Invalid cron expression: ${v.error}`;
1509
- const created = createSchedule({
1510
- squadSlug: slug,
1511
- name,
1512
- cronExpr: cron,
1513
- agenda,
1514
- notes: notes ?? null,
1515
- nextRunAt: v.next.toISOString(),
1516
- });
1517
- return `📅 Scheduled "${created.name}" for squad "${squad.name}" (id ${created.id}).\n- Cron: \`${cron}\`\n- Agenda: ${agenda.join(", ")}\n- Next run: ${v.next.toISOString()}`;
1518
- },
1519
- });
1520
- const squadScheduleList = defineTool("squad_schedule_list", {
1521
- description: "List squad stand-up schedules. Pass a slug to filter to one squad, or omit to list all.",
1522
- skipPermission: true,
1523
- parameters: z.object({
1524
- slug: z.string().optional().describe("Optional squad slug to filter"),
1525
- }),
1526
- handler: async ({ slug }) => {
1527
- const schedules = listSchedules(slug);
1528
- if (schedules.length === 0) {
1529
- return slug
1530
- ? `No schedules for squad "${slug}".`
1531
- : "No squad schedules configured.";
1532
- }
1533
- return schedules
1534
- .map((s) => {
1535
- const enabled = s.enabled ? "▶️ enabled" : "⏸️ paused";
1536
- const last = s.last_run_at ? `last ${s.last_run_at}` : "never run";
1537
- const next = s.next_run_at ?? "—";
1538
- 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}` : ""}`;
1539
- })
1540
- .join("\n");
1541
- },
1542
- });
1543
- const squadScheduleDelete = defineTool("squad_schedule_delete", {
1544
- description: "Delete a squad schedule by id.",
1545
- skipPermission: true,
1546
- parameters: z.object({
1547
- id: z.number().int().describe("Schedule id (from squad_schedule_list)"),
1548
- }),
1549
- handler: async ({ id }) => {
1550
- const existing = getSchedule(id);
1551
- if (!existing)
1552
- return `Schedule ${id} not found.`;
1553
- deleteSchedule(id);
1554
- return `🗑️ Deleted schedule "${existing.name}" (id ${id}).`;
1555
- },
1556
- });
1557
- const squadSchedulePause = defineTool("squad_schedule_pause", {
1558
- description: "Pause a squad schedule so it stops firing (preserves config).",
1559
- skipPermission: true,
1560
- parameters: z.object({ id: z.number().int().describe("Schedule id") }),
1561
- handler: async ({ id }) => {
1562
- const existing = getSchedule(id);
1563
- if (!existing)
1564
- return `Schedule ${id} not found.`;
1565
- setScheduleEnabled(id, false);
1566
- return `⏸️ Paused schedule "${existing.name}" (id ${id}).`;
1567
- },
1568
- });
1569
- const squadScheduleResume = defineTool("squad_schedule_resume", {
1570
- description: "Resume a paused squad schedule. The next run is computed from now using the stored cron expression.",
1571
- skipPermission: true,
1572
- parameters: z.object({ id: z.number().int().describe("Schedule id") }),
1573
- handler: async ({ id }) => {
1574
- const existing = getSchedule(id);
1575
- if (!existing)
1576
- return `Schedule ${id} not found.`;
1577
- setScheduleEnabled(id, true);
1578
- try {
1579
- const next = nextRun(existing.cron_expr);
1580
- // Update next_run_at via the store's helper would be cleaner, but we
1581
- // can also just re-run reconcile on next tick. Inline update:
1582
- const { updateNextRun } = await import("../store/schedules.js");
1583
- updateNextRun(id, next.toISOString());
1584
- return `▶️ Resumed schedule "${existing.name}" (id ${id}). Next run: ${next.toISOString()}`;
1585
- }
1586
- catch (err) {
1587
- return `Resumed schedule "${existing.name}" but failed to compute next run: ${err instanceof Error ? err.message : String(err)}`;
1588
- }
1589
- },
1590
- });
1591
- const squadScheduleRunNow = defineTool("squad_schedule_run_now", {
1592
- 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.",
1593
- skipPermission: true,
1594
- parameters: z.object({ id: z.number().int().describe("Schedule id") }),
1595
- handler: async ({ id }) => {
1596
- const result = await runScheduleNow(id);
1597
- if (!result.ok)
1598
- return `Failed: ${result.error}`;
1599
- return `🚀 Fired schedule ${id} now. Use squad_task_status to follow the resulting stand-up.`;
1600
- },
1601
- });
1602
- // -------------------------------------------------------------------------
1603
- // IO-level (squad-independent) schedules.
1604
- // -------------------------------------------------------------------------
1605
- const scheduleCreate = defineTool("schedule_create", {
1606
- 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.",
1607
- skipPermission: true,
1608
- parameters: z.object({
1609
- name: z
1610
- .string()
1611
- .describe("Human-friendly name, e.g. 'Morning digest' or 'Hourly health check'"),
1612
- cron: z
1613
- .string()
1614
- .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."),
1615
- prompt: z
1616
- .string()
1617
- .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."),
1618
- notes: z
1619
- .string()
1620
- .optional()
1621
- .describe("Optional operator notes appended to the prompt."),
1622
- }),
1623
- handler: async ({ name, cron, prompt, notes }) => {
1624
- const v = validateCron(cron);
1625
- if (!v.ok)
1626
- return `Invalid cron expression: ${v.error}`;
1627
- const created = createIoSchedule({
1628
- name,
1629
- cronExpr: cron,
1630
- prompt,
1631
- notes: notes ?? null,
1632
- nextRunAt: v.next.toISOString(),
1633
- });
1634
- return `⏰ Scheduled IO task "${created.name}" (id ${created.id}).\n- Cron: \`${cron}\`\n- Next run: ${v.next.toISOString()}`;
1635
- },
1636
- });
1637
- const scheduleList = defineTool("schedule_list", {
1638
- description: "List all IO-level schedules (those not attached to a squad). For squad schedules, use squad_schedule_list.",
1639
- skipPermission: true,
1640
- parameters: z.object({}),
1641
- handler: async () => {
1642
- const schedules = listIoSchedules();
1643
- if (schedules.length === 0) {
1644
- return "No IO schedules configured.";
1645
- }
1646
- return schedules
1647
- .map((s) => {
1648
- const enabled = s.enabled ? "▶️ enabled" : "⏸️ paused";
1649
- const last = s.last_run_at ? `last ${s.last_run_at}` : "never run";
1650
- const next = s.next_run_at ?? "—";
1651
- const promptPreview = s.prompt.length > 120 ? s.prompt.slice(0, 120) + "…" : s.prompt;
1652
- 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}` : ""}`;
1653
- })
1654
- .join("\n");
1655
- },
1656
- });
1657
- const scheduleDelete = defineTool("schedule_delete", {
1658
- description: "Delete an IO schedule by id.",
1659
- skipPermission: true,
1660
- parameters: z.object({
1661
- id: z.number().int().describe("Schedule id (from schedule_list)"),
1662
- }),
1663
- handler: async ({ id }) => {
1664
- const existing = getIoSchedule(id);
1665
- if (!existing)
1666
- return `IO schedule ${id} not found.`;
1667
- deleteIoSchedule(id);
1668
- return `🗑️ Deleted IO schedule "${existing.name}" (id ${id}).`;
1669
- },
1670
- });
1671
- const schedulePause = defineTool("schedule_pause", {
1672
- description: "Pause an IO schedule so it stops firing (preserves config — resume with schedule_resume).",
1673
- skipPermission: true,
1674
- parameters: z.object({ id: z.number().int().describe("Schedule id") }),
1675
- handler: async ({ id }) => {
1676
- const existing = getIoSchedule(id);
1677
- if (!existing)
1678
- return `IO schedule ${id} not found.`;
1679
- setIoScheduleEnabled(id, false);
1680
- return `⏸️ Paused IO schedule "${existing.name}" (id ${id}).`;
1681
- },
1682
- });
1683
- const scheduleResume = defineTool("schedule_resume", {
1684
- description: "Resume a paused IO schedule. The next run is computed from now using the stored cron expression.",
1685
- skipPermission: true,
1686
- parameters: z.object({ id: z.number().int().describe("Schedule id") }),
1687
- handler: async ({ id }) => {
1688
- const existing = getIoSchedule(id);
1689
- if (!existing)
1690
- return `IO schedule ${id} not found.`;
1691
- setIoScheduleEnabled(id, true);
1692
- try {
1693
- const next = nextRun(existing.cron_expr);
1694
- updateIoScheduleNextRun(id, next.toISOString());
1695
- return `▶️ Resumed IO schedule "${existing.name}" (id ${id}). Next run: ${next.toISOString()}`;
1696
- }
1697
- catch (err) {
1698
- return `Resumed IO schedule "${existing.name}" but failed to compute next run: ${err instanceof Error ? err.message : String(err)}`;
1699
- }
1700
- },
1701
- });
1702
- const scheduleRunNow = defineTool("schedule_run_now", {
1703
- 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.",
1704
- skipPermission: true,
1705
- parameters: z.object({ id: z.number().int().describe("Schedule id") }),
1706
- handler: async ({ id }) => {
1707
- const ok = await runIoScheduleNow(id);
1708
- if (!ok)
1709
- return `IO schedule ${id} not found.`;
1710
- return `🚀 Fired IO schedule ${id} now.`;
1711
- },
1712
- });
1713
- // ---------------------------------------------------------------------------
1714
- // Squad Instance tools (#231)
1715
- // ---------------------------------------------------------------------------
1716
- const squadInstanceCreate = defineTool("squad_instance_create", {
1717
- description: "Spawn a parallel instance of a squad to work on a separate issue. Creates a git worktree for file isolation and snapshots the squad's current decisions as context.",
1718
- skipPermission: true,
1719
- parameters: z.object({
1720
- squad_slug: z.string().describe("The squad to create an instance of"),
1721
- issue_ref: z.string().describe("Issue reference or label (e.g. '#231', 'refactor-auth')"),
1722
- base_branch: z.string().optional().describe("Branch to base the worktree on (default: 'main')"),
1723
- }),
1724
- handler: async ({ squad_slug, issue_ref, base_branch }) => {
1725
- const squad = deps.getSquad(squad_slug);
1726
- if (!squad)
1727
- return `Squad not found: ${squad_slug}`;
1728
- // Deterministic ID: allows idempotent re-creation if an instance for
1729
- // the same issue_ref previously completed or failed.
1730
- const sanitizedRef = issue_ref.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
1731
- const instanceId = `${squad_slug}--${sanitizedRef}`;
1732
- const branchName = `${squad_slug}/instance/${sanitizedRef}`;
1733
- const existing = deps.getInstance(instanceId);
1734
- if (existing && existing.status !== "done" && existing.status !== "failed") {
1735
- return `Instance "${instanceId}" already exists (status: ${existing.status})`;
1736
- }
1737
- try {
1738
- const contextSnapshot = deps.buildContextSnapshot(squad_slug);
1739
- const worktreePath = deps.createWorktree(squad.projectPath, instanceId, branchName, base_branch ?? "main");
1740
- deps.createInstance({
1741
- id: instanceId,
1742
- masterSquadSlug: squad_slug,
1743
- issueRef: issue_ref,
1744
- worktreePath,
1745
- branchName,
1746
- contextSnapshot,
2
+ import { defineTool } from "@github/copilot-sdk";
3
+ export function createTools() {
4
+ return [
5
+ // --- Wiki Tools ---
6
+ defineTool("wiki_read", {
7
+ description: "Read a wiki page by path (relative to ~/.io/wiki/pages/)",
8
+ parameters: z.object({
9
+ path: z.string().describe("Page path relative to pages/ (e.g., 'notes/todo.md')"),
10
+ }),
11
+ handler: async ({ path }) => {
12
+ const { readPage } = await import("../wiki/fs.js");
13
+ return await readPage(path);
14
+ },
15
+ }),
16
+ defineTool("wiki_write", {
17
+ description: "Write or update a wiki page",
18
+ parameters: z.object({
19
+ path: z.string().describe("Page path relative to pages/"),
20
+ content: z.string().describe("Markdown content to write"),
21
+ }),
22
+ handler: async ({ path, content }) => {
23
+ const { writePage } = await import("../wiki/fs.js");
24
+ await writePage(path, content);
25
+ return `Page saved: ${path}`;
26
+ },
27
+ }),
28
+ defineTool("wiki_list", {
29
+ description: "List all wiki pages",
30
+ parameters: z.object({}),
31
+ handler: async () => {
32
+ const { listPages } = await import("../wiki/fs.js");
33
+ return await listPages();
34
+ },
35
+ }),
36
+ defineTool("wiki_search", {
37
+ description: "Search wiki pages by keyword",
38
+ parameters: z.object({
39
+ query: z.string().describe("Search query"),
40
+ }),
41
+ handler: async ({ query }) => {
42
+ const { searchPages } = await import("../wiki/search.js");
43
+ return await searchPages(query);
44
+ },
45
+ }),
46
+ defineTool("wiki_delete", {
47
+ description: "Delete a wiki page",
48
+ parameters: z.object({
49
+ path: z.string().describe("Page path relative to pages/"),
50
+ }),
51
+ handler: async ({ path }) => {
52
+ const { deletePage } = await import("../wiki/fs.js");
53
+ await deletePage(path);
54
+ return `Page deleted: ${path}`;
55
+ },
56
+ }),
57
+ // --- Squad Tools ---
58
+ defineTool("squad_create", {
59
+ description: "Create a new project squad. Research the chosen universe to assign character names — never hardcode.",
60
+ parameters: z.object({
61
+ name: z.string().describe("Squad name (e.g., 'Project Alpha')"),
62
+ universe: z
63
+ .string()
64
+ .describe("Pop culture universe theme (e.g., 'A-Team', 'Transformers', 'ThunderCats')"),
65
+ repo_url: z.string().optional().describe("Git repository URL for the project"),
66
+ }),
67
+ handler: async ({ name, universe, repo_url }) => {
68
+ const { createSquad } = await import("../store/squads.js");
69
+ const squad = createSquad(name, universe, repo_url);
70
+ return `Squad "${name}" created with universe "${universe}". ID: ${squad.id}`;
71
+ },
72
+ }),
73
+ defineTool("squad_add_agent", {
74
+ description: "Add a specialist agent to a squad",
75
+ parameters: z.object({
76
+ squad_id: z.string().describe("Squad ID"),
77
+ character_name: z.string().describe("Character name from the squad's universe"),
78
+ role_title: z.string().describe("Specialist role (e.g., 'Vue 3 Frontend Developer')"),
79
+ persona: z.string().optional().describe("Personality/work style description"),
80
+ is_lead: z.boolean().optional().describe("Is this the team lead?"),
81
+ is_qa: z.boolean().optional().describe("Is this a QA reviewer?"),
82
+ is_test: z.boolean().optional().describe("Is this a test/quality specialist?"),
83
+ }),
84
+ handler: async ({ squad_id, character_name, role_title, persona, is_lead, is_qa, is_test }) => {
85
+ const { addAgent } = await import("../store/squads.js");
86
+ const agent = addAgent(squad_id, {
87
+ character_name,
88
+ role_title,
89
+ persona: persona ?? "",
90
+ is_lead: is_lead ?? false,
91
+ is_qa: is_qa ?? false,
92
+ is_test: is_test ?? false,
1747
93
  });
1748
- deps.updateInstanceStatus(instanceId, "active");
1749
- const inherited = JSON.parse(contextSnapshot);
1750
- return `Instance "${instanceId}" created.\nWorktree: ${worktreePath}\nBranch: ${branchName}\nStatus: active\nContext: ${inherited.length} decisions inherited`;
1751
- }
1752
- catch (err) {
1753
- return `Error creating instance: ${err instanceof Error ? err.message : String(err)}`;
1754
- }
1755
- },
1756
- });
1757
- const squadInstanceList = defineTool("squad_instance_list", {
1758
- description: "List active (and optionally completed) instances for a squad.",
1759
- skipPermission: true,
1760
- parameters: z.object({
1761
- squad_slug: z.string().describe("Squad slug"),
1762
- include_completed: z.boolean().optional().describe("Include done/failed instances (default: false)"),
1763
- }),
1764
- handler: async ({ squad_slug, include_completed }) => {
1765
- const instances = deps.listInstances(squad_slug, { includeCompleted: include_completed ?? false });
1766
- if (instances.length === 0)
1767
- return `No instances for squad "${squad_slug}".`;
1768
- return instances.map(i => `- **${i.id}** [${i.status}] — ${i.issue_ref ?? "no issue"} (branch: ${i.branch_name}, created: ${i.created_at})`).join("\n");
1769
- },
1770
- });
1771
- const squadInstanceStatus = defineTool("squad_instance_status", {
1772
- description: "Get detailed status of a specific squad instance.",
1773
- skipPermission: true,
1774
- parameters: z.object({
1775
- instance_id: z.string().describe("Instance ID (e.g. 'my-squad--issue-42')"),
1776
- }),
1777
- handler: async ({ instance_id }) => {
1778
- const instance = deps.getInstance(instance_id);
1779
- if (!instance)
1780
- return `Instance not found: ${instance_id}`;
1781
- const decisions = deps.getInstanceDecisions(instance_id);
1782
- return [
1783
- `## Instance: ${instance.id}`,
1784
- `- Squad: ${instance.master_squad_slug}`,
1785
- `- Issue: ${instance.issue_ref ?? "none"}`,
1786
- `- Status: ${instance.status}`,
1787
- `- Branch: ${instance.branch_name}`,
1788
- `- Worktree: ${instance.worktree_path}`,
1789
- `- Created: ${instance.created_at}`,
1790
- instance.completed_at ? `- Completed: ${instance.completed_at}` : null,
1791
- `- Decisions: ${decisions.length} (${decisions.filter(d => d.merged_to_master).length} merged)`,
1792
- ].filter(Boolean).join("\n");
1793
- },
1794
- });
1795
- const squadInstanceComplete = defineTool("squad_instance_complete", {
1796
- description: "Complete a squad instance: merge its decisions back to the master squad and clean up the worktree.",
1797
- skipPermission: true,
1798
- parameters: z.object({
1799
- instance_id: z.string().describe("Instance ID to complete"),
1800
- }),
1801
- handler: async ({ instance_id }) => {
1802
- const instance = deps.getInstance(instance_id);
1803
- if (!instance)
1804
- return `Instance not found: ${instance_id}`;
1805
- if (instance.status === "done")
1806
- return `Instance already completed.`;
1807
- try {
1808
- deps.updateInstanceStatus(instance_id, "merging");
1809
- const merged = deps.mergeInstanceDecisions(instance_id, instance.master_squad_slug);
1810
- // Clean up worktree — use squad's project_path if available, fall back to stored path
1811
- const squad = deps.getSquad(instance.master_squad_slug);
1812
- const projectPath = squad?.projectPath ?? instance.worktree_path.replace(/\/\.io-worktrees\/.*$/, "");
1813
- deps.removeWorktree(projectPath, instance.worktree_path);
1814
- deps.updateInstanceStatus(instance_id, "done");
1815
- // Auto-deactivate if this was the active instance
1816
- if (deps.activeInstanceId === instance_id) {
1817
- deps.activeInstanceId = undefined;
1818
- }
1819
- return `Instance "${instance_id}" completed.\n- ${merged} decision(s) merged to master squad "${instance.master_squad_slug}"\n- Worktree cleaned up`;
1820
- }
1821
- catch (err) {
1822
- return `Error completing instance: ${err instanceof Error ? err.message : String(err)}`;
1823
- }
1824
- },
1825
- });
1826
- const squadInstanceAbort = defineTool("squad_instance_abort", {
1827
- description: "Abort a squad instance, marking it as failed. Worktree is preserved for debugging.",
1828
- skipPermission: true,
1829
- parameters: z.object({
1830
- instance_id: z.string().describe("Instance ID to abort"),
1831
- }),
1832
- handler: async ({ instance_id }) => {
1833
- const instance = deps.getInstance(instance_id);
1834
- if (!instance)
1835
- return `Instance not found: ${instance_id}`;
1836
- if (instance.status === "done" || instance.status === "failed") {
1837
- return `Instance already in terminal state: ${instance.status}`;
1838
- }
1839
- deps.updateInstanceStatus(instance_id, "failed");
1840
- // Auto-deactivate if this was the active instance
1841
- if (deps.activeInstanceId === instance_id) {
1842
- deps.activeInstanceId = undefined;
1843
- }
1844
- return `Instance "${instance_id}" aborted. Worktree preserved at: ${instance.worktree_path}\nUse squad_instance_cleanup to remove it.`;
1845
- },
1846
- });
1847
- const squadInstanceCleanup = defineTool("squad_instance_cleanup", {
1848
- description: "Force-remove a failed instance's worktree and delete the instance record.",
1849
- skipPermission: true,
1850
- parameters: z.object({
1851
- instance_id: z.string().describe("Instance ID to clean up"),
1852
- }),
1853
- handler: async ({ instance_id }) => {
1854
- const instance = deps.getInstance(instance_id);
1855
- if (!instance)
1856
- return `Instance not found: ${instance_id}`;
1857
- if (instance.status !== "done" && instance.status !== "failed") {
1858
- return `Cannot clean up instance in "${instance.status}" state. Abort it first.`;
1859
- }
1860
- const squad = deps.getSquad(instance.master_squad_slug);
1861
- if (squad) {
1862
- deps.removeWorktree(squad.projectPath, instance.worktree_path);
1863
- }
1864
- deps.deleteInstance(instance_id);
1865
- return `Instance "${instance_id}" cleaned up and removed.`;
1866
- },
1867
- });
1868
- // ---------------------------------------------------------------------------
1869
- // Squad Instance context tools (#231 Phase 2)
1870
- // ---------------------------------------------------------------------------
1871
- const squadInstanceActivate = defineTool("squad_instance_activate", {
1872
- description: "Activate an instance context. After activation, delegated tasks and decisions are scoped to this instance until deactivated.",
1873
- skipPermission: true,
1874
- parameters: z.object({
1875
- instance_id: z.string().describe("Instance ID to activate"),
1876
- }),
1877
- handler: async ({ instance_id }) => {
1878
- const instance = deps.getInstance(instance_id);
1879
- if (!instance)
1880
- return `Instance not found: ${instance_id}`;
1881
- if (instance.status !== "active")
1882
- return `Instance is not active (status: ${instance.status})`;
1883
- deps.activeInstanceId = instance_id;
1884
- return `Instance context activated: ${instance_id}. Tasks and decisions will be scoped to this instance.`;
1885
- },
1886
- });
1887
- const squadInstanceDeactivate = defineTool("squad_instance_deactivate", {
1888
- description: "Deactivate the current instance context, returning to master squad scope.",
1889
- skipPermission: true,
1890
- parameters: z.object({}),
1891
- handler: async () => {
1892
- const prev = deps.activeInstanceId;
1893
- deps.activeInstanceId = undefined;
1894
- return prev ? `Instance context deactivated (was: ${prev})` : `No instance context was active.`;
1895
- },
1896
- });
1897
- const sendToInbox = defineTool("send_to_inbox", {
1898
- description: "Send a message directly to Michael's IO inbox. Use this to deliver results, reports, summaries, or any content that should appear in the inbox feed.",
1899
- skipPermission: true,
1900
- parameters: z.object({
1901
- title: z.string().describe("Short title for the inbox item"),
1902
- body: z.string().describe("Full content/body of the message (supports markdown)"),
1903
- squad_slug: z.string().optional().describe("Squad slug to prefix the title with (e.g. 'io-assistant')"),
1904
- instance_id: z.string().optional().describe("Instance ID if this message is from a squad instance"),
1905
- task_id: z.string().optional().describe("Task ID associated with this message"),
1906
- }),
1907
- handler: async ({ title, body, squad_slug, instance_id, task_id }) => {
1908
- const prefix = squad_slug ? `[${squad_slug}] ` : "";
1909
- createFeedEntry({
1910
- type: "inbox",
1911
- title: `${prefix}${title}`,
1912
- body,
1913
- squad_slug: squad_slug ?? null,
1914
- instance_id: instance_id ?? null,
1915
- task_id: task_id ?? null,
1916
- });
1917
- return "Message sent to inbox successfully.";
1918
- },
1919
- });
1920
- const sendNotification = defineTool("send_notification", {
1921
- description: "Send a short status notification to the IO feed. Use for brief updates, alerts, and FYIs (one sentence). For longer content, use send_to_inbox instead.",
1922
- skipPermission: true,
1923
- parameters: z.object({
1924
- message: z.string().describe("Short notification message (one sentence)"),
1925
- squad_slug: z.string().optional().describe("Squad slug for context"),
1926
- }),
1927
- handler: async ({ message, squad_slug }) => {
1928
- const prefix = squad_slug ? `[${squad_slug}] ` : "";
1929
- createFeedEntry({
1930
- type: "notification",
1931
- title: `${prefix}${message}`,
1932
- body: message,
1933
- squad_slug: squad_slug ?? null,
1934
- });
1935
- return "Notification sent.";
1936
- },
1937
- });
1938
- const mcpServerList = defineTool("mcp_server_list", {
1939
- description: "List all configured MCP servers with their status (enabled/disabled, connected/disconnected).",
1940
- skipPermission: true,
1941
- parameters: z.object({}),
1942
- handler: async () => {
1943
- const config = loadMcpConfig();
1944
- if (config.servers.length === 0)
1945
- return "No MCP servers configured. Add one with mcp_server_add.";
1946
- return config.servers.map(s => {
1947
- const status = s.enabled === false ? "disabled" : "enabled";
1948
- const transport = s.url ? `SSE: ${s.url}` : `stdio: ${s.command} ${(s.args ?? []).join(" ")}`;
1949
- return `- **${s.name}** [${status}] — ${transport}`;
1950
- }).join("\n");
1951
- },
1952
- });
1953
- const mcpServerAdd = defineTool("mcp_server_add", {
1954
- description: "Add a new MCP server to the configuration. Provide either command+args (stdio transport) or url (SSE transport).",
1955
- skipPermission: true,
1956
- parameters: z.object({
1957
- name: z.string().describe("Unique name for the server (e.g., 'figma', 'postgres')"),
1958
- command: z.string().optional().describe("Executable command for stdio transport (e.g., 'npx')"),
1959
- args: z.array(z.string()).optional().describe("Command arguments (e.g., ['-y', '@anthropic/mcp-server-figma'])"),
1960
- url: z.string().optional().describe("URL for SSE transport (e.g., 'http://localhost:3001/sse')"),
1961
- env: z.record(z.string(), z.string()).optional().describe("Environment variables for the server process"),
1962
- }),
1963
- handler: async ({ name, command, args, url, env }) => {
1964
- if (!command && !url)
1965
- return "Error: provide either 'command' (stdio) or 'url' (SSE).";
1966
- const config = loadMcpConfig();
1967
- if (config.servers.some(s => s.name === name)) {
1968
- return `Error: server "${name}" already exists. Remove it first with mcp_server_remove.`;
1969
- }
1970
- config.servers.push({ name, command, args, url, env, enabled: true });
1971
- saveMcpConfig(config);
1972
- return `MCP server "${name}" added. Use mcp_server_reload to connect.`;
1973
- },
1974
- });
1975
- const mcpServerRemove = defineTool("mcp_server_remove", {
1976
- description: "Remove an MCP server from the configuration by name.",
1977
- skipPermission: true,
1978
- parameters: z.object({
1979
- name: z.string().describe("Name of the server to remove"),
1980
- }),
1981
- handler: async ({ name }) => {
1982
- const config = loadMcpConfig();
1983
- const idx = config.servers.findIndex(s => s.name === name);
1984
- if (idx === -1)
1985
- return `Server "${name}" not found.`;
1986
- config.servers.splice(idx, 1);
1987
- saveMcpConfig(config);
1988
- return `MCP server "${name}" removed. Use mcp_server_reload to apply.`;
1989
- },
1990
- });
1991
- const mcpServerToggle = defineTool("mcp_server_toggle", {
1992
- description: "Enable or disable an MCP server without removing it from the config.",
1993
- skipPermission: true,
1994
- parameters: z.object({
1995
- name: z.string().describe("Name of the server to toggle"),
1996
- enabled: z.boolean().describe("true to enable, false to disable"),
1997
- }),
1998
- handler: async ({ name, enabled }) => {
1999
- const config = loadMcpConfig();
2000
- const server = config.servers.find(s => s.name === name);
2001
- if (!server)
2002
- return `Server "${name}" not found.`;
2003
- server.enabled = enabled;
2004
- saveMcpConfig(config);
2005
- return `MCP server "${name}" ${enabled ? "enabled" : "disabled"}. Use mcp_server_reload to apply changes.`;
2006
- },
2007
- });
2008
- const mcpServerReload = defineTool("mcp_server_reload", {
2009
- description: "Reload MCP server connections and tools. Call after adding, removing, or toggling servers to apply changes without restarting IO.",
2010
- skipPermission: true,
2011
- parameters: z.object({}),
2012
- handler: async () => {
2013
- if (!deps.reloadMcpTools)
2014
- return "MCP reload not available in this context.";
2015
- try {
2016
- await deps.reloadMcpTools();
2017
- const config = loadMcpConfig();
2018
- const enabled = config.servers.filter(s => s.enabled !== false);
2019
- return `MCP tools reloaded. ${enabled.length} server(s) active.`;
2020
- }
2021
- catch (err) {
2022
- return `Error reloading MCP tools: ${err instanceof Error ? err.message : String(err)}`;
2023
- }
2024
- },
2025
- });
2026
- return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadResetAgent, 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, squadInstanceCreate, squadInstanceList, squadInstanceStatus, squadInstanceComplete, squadInstanceAbort, squadInstanceCleanup, squadInstanceActivate, squadInstanceDeactivate, sendToInbox, sendNotification, mcpServerList, mcpServerAdd, mcpServerRemove, mcpServerToggle, mcpServerReload];
2027
- }
2028
- function walkDirectory(dir, maxDepth = 3, depth = 0) {
2029
- if (depth >= maxDepth)
2030
- return [];
2031
- const results = [];
2032
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
2033
- if (entry.name.startsWith("."))
2034
- continue;
2035
- const full = join(dir, entry.name);
2036
- if (entry.isDirectory()) {
2037
- results.push(`${entry.name}/`);
2038
- results.push(...walkDirectory(full, maxDepth, depth + 1).map((f) => ` ${entry.name}/${f}`));
2039
- }
2040
- else {
2041
- results.push(entry.name);
2042
- }
2043
- }
2044
- return results;
94
+ return `Agent "${character_name}" (${role_title}) added to squad. ID: ${agent.id}`;
95
+ },
96
+ }),
97
+ defineTool("squad_list", {
98
+ description: "List all squads and their agents",
99
+ parameters: z.object({}),
100
+ handler: async () => {
101
+ const { listSquads } = await import("../store/squads.js");
102
+ return listSquads();
103
+ },
104
+ }),
105
+ defineTool("squad_delegate", {
106
+ description: "Delegate a task to a squad's team lead. The lead will break it down and route to specialists.",
107
+ parameters: z.object({
108
+ squad_id: z.string().describe("Squad ID"),
109
+ task: z.string().describe("Detailed task description"),
110
+ instance_id: z.string().optional().describe("Instance ID (for parallel work)"),
111
+ }),
112
+ handler: async ({ squad_id, task, instance_id }) => {
113
+ const { delegateTask } = await import("./agents.js");
114
+ const result = await delegateTask(squad_id, task, instance_id);
115
+ return result;
116
+ },
117
+ }),
118
+ defineTool("squad_task_status", {
119
+ description: "Check the status of tasks for a squad",
120
+ parameters: z.object({
121
+ squad_id: z.string().describe("Squad ID"),
122
+ }),
123
+ handler: async ({ squad_id }) => {
124
+ const { getTasksForSquad } = await import("../store/tasks.js");
125
+ return getTasksForSquad(squad_id);
126
+ },
127
+ }),
128
+ defineTool("squad_instance_create", {
129
+ description: "Create a new parallel instance (worktree) for a squad. Max 3 per squad.",
130
+ parameters: z.object({
131
+ squad_id: z.string().describe("Squad ID"),
132
+ branch: z.string().describe("Branch name for the worktree"),
133
+ }),
134
+ handler: async ({ squad_id, branch }) => {
135
+ const { createInstance } = await import("../store/instances.js");
136
+ const instance = await createInstance(squad_id, branch);
137
+ return `Instance created: ${instance.id} on branch ${branch}`;
138
+ },
139
+ }),
140
+ defineTool("squad_instance_destroy", {
141
+ description: "Destroy a squad instance and clean up its worktree",
142
+ parameters: z.object({
143
+ instance_id: z.string().describe("Instance ID"),
144
+ }),
145
+ handler: async ({ instance_id }) => {
146
+ const { destroyInstance } = await import("../store/instances.js");
147
+ await destroyInstance(instance_id);
148
+ return `Instance ${instance_id} destroyed.`;
149
+ },
150
+ }),
151
+ // --- Feed Tools ---
152
+ defineTool("feed_post", {
153
+ description: "Post a deliverable to the unified feed/inbox",
154
+ parameters: z.object({
155
+ source: z.string().describe("Source identifier (e.g., 'orchestrator', 'squad-alpha')"),
156
+ title: z.string().describe("Title of the deliverable"),
157
+ content: z.string().describe("Full content (markdown supported)"),
158
+ }),
159
+ handler: async ({ source, title, content }) => {
160
+ const { postFeedItem } = await import("../store/feed.js");
161
+ const item = postFeedItem(source, title, content);
162
+ return `Posted to feed: "${title}" (ID: ${item.id})`;
163
+ },
164
+ }),
165
+ // --- MCP Tools ---
166
+ defineTool("mcp_list_servers", {
167
+ description: "List configured MCP servers and their status",
168
+ parameters: z.object({}),
169
+ handler: async () => {
170
+ const { listServers } = await import("../mcp/registry.js");
171
+ return listServers();
172
+ },
173
+ }),
174
+ // --- Schedule Tools ---
175
+ defineTool("schedule_create", {
176
+ description: "Create a cron-based schedule",
177
+ parameters: z.object({
178
+ type: z.enum(["squad", "io"]).describe("Schedule type"),
179
+ cron: z.string().describe("Cron expression (e.g., '0 9 * * 1-5')"),
180
+ squad_id: z.string().optional().describe("Squad ID (required for squad schedules)"),
181
+ agenda: z
182
+ .string()
183
+ .optional()
184
+ .describe("Agenda type for squad schedules (triage, prioritize, ideation, or custom)"),
185
+ prompt: z.string().optional().describe("Prompt text for IO schedules"),
186
+ }),
187
+ handler: async ({ type, cron, squad_id, agenda, prompt }) => {
188
+ const { createSchedule } = await import("../store/schedules.js");
189
+ const schedule = createSchedule({ type, cron, squad_id, agenda, prompt });
190
+ return `Schedule created: ${schedule.id}`;
191
+ },
192
+ }),
193
+ ];
2045
194
  }
2046
195
  //# sourceMappingURL=tools.js.map