heyio 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,8 @@ import { defineTool, approveAll } from "@github/copilot-sdk";
8
8
  import { z } from "zod";
9
9
  import { getClient } from "./client.js";
10
10
  import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
11
- import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
12
- import { createTask, completeTask, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
11
+ import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
12
+ import { createTask, completeTask, createReview, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
13
13
  import { SESSIONS_DIR } from "../paths.js";
14
14
  import { getUniverse } from "./universes.js";
15
15
  // Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
@@ -116,10 +116,17 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
116
116
  }
117
117
  }
118
118
  else {
119
- // If squad has named agents, pick the best match (first idle, or first one)
120
- const agents = listSquadAgents(squadSlug);
121
- if (agents.length > 0) {
122
- agent = agents.find((a) => a.status === "idle") ?? agents[0];
119
+ // Prefer the designated team lead if one exists; otherwise fall back to
120
+ // the first idle agent (or just the first agent on the roster).
121
+ const lead = getSquadLead(squadSlug);
122
+ if (lead) {
123
+ agent = lead;
124
+ }
125
+ else {
126
+ const agents = listSquadAgents(squadSlug);
127
+ if (agents.length > 0) {
128
+ agent = agents.find((a) => a.status === "idle") ?? agents[0];
129
+ }
123
130
  }
124
131
  }
125
132
  const session = agent
@@ -159,6 +166,19 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
159
166
  if (agent)
160
167
  updateAgentStatus(squadSlug, agent.character_name, "idle");
161
168
  recordTaskEvent(taskId, { ts: Date.now(), type: "task.done", data: { result } });
169
+ try {
170
+ await runPeerReview(squadSlug, agent?.character_name ?? "", taskId, task, result);
171
+ }
172
+ catch (reviewErr) {
173
+ console.error("[io] Peer review error:", reviewErr instanceof Error ? reviewErr.message : reviewErr);
174
+ recordTaskEvent(taskId, {
175
+ ts: Date.now(),
176
+ type: "task.review_error",
177
+ data: {
178
+ error: reviewErr instanceof Error ? reviewErr.message : String(reviewErr),
179
+ },
180
+ });
181
+ }
162
182
  onComplete(taskId, result);
163
183
  }
164
184
  catch (err) {
@@ -241,7 +261,34 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
241
261
  const universeName = squad.universe
242
262
  ? getUniverse(squad.universe)?.name ?? squad.universe
243
263
  : "Unknown";
244
- const agentTools = buildAgentTools(squadSlug);
264
+ const isLead = agent.is_lead === 1;
265
+ const agentTools = buildAgentTools(squadSlug, isLead);
266
+ let leadSection = "";
267
+ if (isLead) {
268
+ const teammates = listSquadAgents(squadSlug).filter((a) => a.character_name !== agent.character_name);
269
+ const roster = teammates.length > 0
270
+ ? teammates
271
+ .map((t) => {
272
+ const charter = t.charter
273
+ ? t.charter.length > 200
274
+ ? t.charter.slice(0, 200) + "…"
275
+ : t.charter
276
+ : "(no charter)";
277
+ return `- **${t.character_name}** — ${t.role_title}: ${charter}`;
278
+ })
279
+ .join("\n")
280
+ : "_(no other agents on this squad yet — ask IO to add some)_";
281
+ leadSection = `
282
+
283
+ ## Team Lead Role
284
+ You are the team lead for this squad. When you receive a task, your job is to:
285
+ 1. Break it down into concrete subtasks
286
+ 2. Assign each subtask to the most appropriate teammate using the \`delegate_to_teammate\` tool
287
+ 3. Collect results and synthesize a final summary
288
+
289
+ ## Your Team
290
+ ${roster}`;
291
+ }
245
292
  const systemMessage = `You are ${agent.character_name}, a specialist agent on the "${squad.name}" project team (${universeName} universe).
246
293
 
247
294
  ## Your Identity
@@ -256,7 +303,7 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
256
303
  - **Path**: ${squad.project_path}
257
304
 
258
305
  ## Past Decisions
259
- ${decisions}
306
+ ${decisions}${leadSection}
260
307
 
261
308
  ## Instructions
262
309
  You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
@@ -343,7 +390,7 @@ Log important decisions with squad_log_decision so they persist.`,
343
390
  agentSessions.set(squadSlug, session);
344
391
  return session;
345
392
  }
346
- function buildAgentTools(squadSlug) {
393
+ function buildAgentTools(squadSlug, isLead = false) {
347
394
  const shell = defineTool("shell", {
348
395
  description: "Run a shell command. Use for git, build tools, file operations, etc.",
349
396
  skipPermission: true,
@@ -461,7 +508,50 @@ function buildAgentTools(squadSlug) {
461
508
  }
462
509
  },
463
510
  });
464
- return [shell, fileOps, squadLogDecision];
511
+ const tools = [shell, fileOps, squadLogDecision];
512
+ if (isLead) {
513
+ const delegateToTeammate = defineTool("delegate_to_teammate", {
514
+ description: "Delegate a subtask to a teammate on this squad. The teammate runs the task synchronously and returns its result. Use this to divvy work as the team lead.",
515
+ skipPermission: true,
516
+ parameters: z.object({
517
+ teammate: z
518
+ .string()
519
+ .describe("The teammate's character_name (e.g., 'Optimus Prime')"),
520
+ task: z
521
+ .string()
522
+ .describe("The concrete task or subtask the teammate should perform"),
523
+ }),
524
+ handler: async ({ teammate, task }) => {
525
+ try {
526
+ const teammateAgent = getSquadAgent(squadSlug, teammate);
527
+ if (!teammateAgent) {
528
+ return `Error: teammate "${teammate}" not found in squad "${squadSlug}". Use squad_agents to list the roster.`;
529
+ }
530
+ if (teammateAgent.is_lead === 1) {
531
+ return `Error: "${teammate}" is the team lead. Delegate to a non-lead teammate.`;
532
+ }
533
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "working");
534
+ try {
535
+ const session = await getOrCreateAgentSession(squadSlug, teammateAgent, task);
536
+ const response = await session.sendAndWait({ prompt: task }, 300_000);
537
+ const result = response?.data?.content ?? "(teammate returned no output)";
538
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "idle");
539
+ return result;
540
+ }
541
+ catch (err) {
542
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "error");
543
+ const message = err instanceof Error ? err.message : String(err);
544
+ return `Error from teammate "${teammate}": ${message}`;
545
+ }
546
+ }
547
+ catch (err) {
548
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
549
+ }
550
+ },
551
+ });
552
+ tools.push(delegateToTeammate);
553
+ }
554
+ return tools;
465
555
  }
466
556
  function walkDirectory(dir, maxDepth = 3, depth = 0) {
467
557
  if (depth >= maxDepth)
@@ -481,6 +571,116 @@ function walkDirectory(dir, maxDepth = 3, depth = 0) {
481
571
  }
482
572
  return results;
483
573
  }
574
+ /**
575
+ * Run a peer review phase after a task completes. Every other agent on the
576
+ * squad reviews the work and votes APPROVED / REJECTED. QA agents
577
+ * (is_qa === 1) have veto power: if any QA agent rejects, the PR is left as
578
+ * draft. Otherwise, any GitHub PR URL found in the task result is promoted
579
+ * from draft to ready via `gh pr ready`.
580
+ */
581
+ async function runPeerReview(squadSlug, originalAgentCharacter, taskId, taskDescription, taskResult) {
582
+ const reviewers = listSquadAgents(squadSlug).filter((a) => a.character_name !== originalAgentCharacter);
583
+ if (reviewers.length === 0) {
584
+ recordTaskEvent(taskId, {
585
+ ts: Date.now(),
586
+ type: "task.review_complete",
587
+ data: { promoted: false, reason: "No other agents to review" },
588
+ });
589
+ return;
590
+ }
591
+ const reviewPrompt = `You are reviewing the following completed task:
592
+
593
+ ## Task
594
+ ${taskDescription}
595
+
596
+ ## Work Done
597
+ ${taskResult}
598
+
599
+ Review the work. Respond with:
600
+ - First line: APPROVED or REJECTED
601
+ - Remaining lines: your review comments`;
602
+ const reviews = [];
603
+ for (const reviewer of reviewers) {
604
+ try {
605
+ const session = await getOrCreateAgentSession(squadSlug, reviewer, `Peer review of task ${taskId}`);
606
+ const response = await session.sendAndWait({ prompt: reviewPrompt }, 300_000);
607
+ const content = response?.data?.content ?? "";
608
+ const lines = content.split(/\r?\n/);
609
+ const firstLine = (lines[0] ?? "").trim().toUpperCase();
610
+ const approved = firstLine.includes("APPROVED") && !firstLine.includes("REJECTED");
611
+ const comments = lines.slice(1).join("\n").trim() || null;
612
+ createReview(taskId, squadSlug, reviewer.character_name, approved, comments ?? undefined);
613
+ recordTaskEvent(taskId, {
614
+ ts: Date.now(),
615
+ type: "task.review",
616
+ data: {
617
+ reviewer: reviewer.character_name,
618
+ is_qa: reviewer.is_qa === 1,
619
+ approved,
620
+ comments,
621
+ },
622
+ });
623
+ reviews.push({
624
+ reviewer: reviewer.character_name,
625
+ is_qa: reviewer.is_qa === 1,
626
+ approved,
627
+ comments: comments ?? "",
628
+ });
629
+ }
630
+ catch (err) {
631
+ const message = err instanceof Error ? err.message : String(err);
632
+ console.error(`[io] Reviewer ${reviewer.character_name} failed:`, message);
633
+ recordTaskEvent(taskId, {
634
+ ts: Date.now(),
635
+ type: "task.review_error",
636
+ data: { reviewer: reviewer.character_name, error: message },
637
+ });
638
+ }
639
+ }
640
+ const qaRejection = reviews.find((r) => r.is_qa && !r.approved);
641
+ const prMatch = taskResult.match(/https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/);
642
+ if (qaRejection) {
643
+ recordTaskEvent(taskId, {
644
+ ts: Date.now(),
645
+ type: "task.review_complete",
646
+ data: {
647
+ promoted: false,
648
+ reason: `QA veto from ${qaRejection.reviewer}`,
649
+ prUrl: prMatch ? prMatch[0] : null,
650
+ },
651
+ });
652
+ return;
653
+ }
654
+ if (!prMatch) {
655
+ recordTaskEvent(taskId, {
656
+ ts: Date.now(),
657
+ type: "task.review_complete",
658
+ data: { promoted: false, reason: "No PR URL found in task result" },
659
+ });
660
+ return;
661
+ }
662
+ const [prUrl, owner, repo, prNumber] = prMatch;
663
+ try {
664
+ execSync(`gh pr ready ${prNumber} --repo ${owner}/${repo}`, {
665
+ encoding: "utf-8",
666
+ timeout: 30_000,
667
+ env: { ...process.env, HOME: process.env.HOME || homedir() },
668
+ });
669
+ recordTaskEvent(taskId, {
670
+ ts: Date.now(),
671
+ type: "task.review_complete",
672
+ data: { promoted: true, prUrl },
673
+ });
674
+ }
675
+ catch (err) {
676
+ const message = err instanceof Error ? err.message : String(err);
677
+ recordTaskEvent(taskId, {
678
+ ts: Date.now(),
679
+ type: "task.review_complete",
680
+ data: { promoted: false, reason: `gh pr ready failed: ${message}`, prUrl },
681
+ });
682
+ }
683
+ }
484
684
  /**
485
685
  * Cancel a running agent task by aborting its session and marking the task
486
686
  * cancelled. Returns true if the task existed and was running.
@@ -3,8 +3,8 @@ import { approveAll, } from "@github/copilot-sdk";
3
3
  import { config } from "../config.js";
4
4
  import { SESSIONS_DIR, IO_VERSION } from "../paths.js";
5
5
  import { getState, setState, deleteState, logConversation } from "../store/db.js";
6
- import { clearStaleTasks, getTask } from "../store/tasks.js";
7
- import { getSquad, listSquads, createSquad, deleteSquad, logDecision, getDecisionsSummary, updateSquadStatus, addSquadAgent, listSquadAgents, removeSquadAgent, } from "../store/squads.js";
6
+ import { clearStaleTasks, getTask, getTaskReviews } from "../store/tasks.js";
7
+ import { getSquad, listSquads, createSquad, deleteSquad, logDecision, getDecisionsSummary, updateSquadStatus, addSquadAgent, listSquadAgents, removeSquadAgent, setSquadLead, getSquadLead, setSquadQA, } from "../store/squads.js";
8
8
  import { readPage, writePage, assertPagePath, deletePage, listPages } from "../wiki/fs.js";
9
9
  import { resolveModelTiers } from "./model-router.js";
10
10
  import { searchWiki, getWikiSummary } from "../wiki/search.js";
@@ -73,8 +73,23 @@ function getToolDeps() {
73
73
  model_tier: a.model_tier,
74
74
  personality: a.personality,
75
75
  status: a.status,
76
+ is_lead: a.is_lead,
77
+ is_qa: a.is_qa,
76
78
  })),
77
79
  removeSquadAgent,
80
+ setSquadLead,
81
+ getSquadLead: (slug) => {
82
+ const lead = getSquadLead(slug);
83
+ return lead
84
+ ? { character_name: lead.character_name, role_title: lead.role_title }
85
+ : undefined;
86
+ },
87
+ setSquadQA,
88
+ getTaskReviews: (taskId) => getTaskReviews(taskId).map((r) => ({
89
+ reviewer_character: r.reviewer_character,
90
+ approved: r.approved,
91
+ comments: r.comments,
92
+ })),
78
93
  listSkills,
79
94
  installSkill,
80
95
  removeSkill,
@@ -92,6 +107,30 @@ function toolFingerprint(tools) {
92
107
  const names = (tools ?? []).map((t) => t.name).sort().join(",");
93
108
  return crypto.createHash("sha256").update(`${IO_VERSION}:${names}`).digest("hex").slice(0, 16);
94
109
  }
110
+ function buildSquadRoster() {
111
+ const squads = listSquads();
112
+ if (squads.length === 0)
113
+ return "";
114
+ return squads
115
+ .map((s) => {
116
+ const agents = listSquadAgents(s.slug);
117
+ const lead = agents.find((a) => a.is_lead === 1);
118
+ const agentList = agents
119
+ .map((a) => {
120
+ const badges = [
121
+ a.is_lead === 1 ? "⭐ LEAD" : "",
122
+ a.is_qa === 1 ? "🛡️ QA" : "",
123
+ ]
124
+ .filter(Boolean)
125
+ .join(", ");
126
+ return ` - ${a.character_name} (${a.role_title})${badges ? ` [${badges}]` : ""}`;
127
+ })
128
+ .join("\n");
129
+ const leadLine = lead ? `\nTeam Lead: ${lead.character_name}` : "";
130
+ return `**${s.name}** (\`${s.slug}\`) — ${s.status}\n📁 ${s.project_path}${leadLine}\n${agentList || " _(no agents yet)_"}`;
131
+ })
132
+ .join("\n\n");
133
+ }
95
134
  function buildFullSessionConfig() {
96
135
  const { tools, skillDirectories } = getSessionConfig();
97
136
  return {
@@ -102,6 +141,7 @@ function buildFullSessionConfig() {
102
141
  content: getOrchestratorSystemMessage({
103
142
  selfEditEnabled: config.selfEditEnabled,
104
143
  memorySummary: getWikiSummary() || undefined,
144
+ squadRoster: buildSquadRoster() || undefined,
105
145
  }),
106
146
  },
107
147
  tools,
@@ -18,8 +18,8 @@ This restriction does NOT apply to:
18
18
  - Any files outside the IO installation directory
19
19
  `;
20
20
  const squadBlock = opts?.squadRoster
21
- ? `\n### Active Squads\n${opts.squadRoster}\n`
22
- : "";
21
+ ? `\n## Active Squads\nThe following squads are available. Route relevant coding requests directly to them.\n\n${opts.squadRoster}\n`
22
+ : `\n## Active Squads\nNo squads created yet. Use \`squad_create\` to set up a project squad.\n`;
23
23
  const osName = process.platform === "darwin" ? "macOS"
24
24
  : process.platform === "win32" ? "Windows"
25
25
  : "Linux";
@@ -47,12 +47,22 @@ When no source tag is present, assume TUI.
47
47
 
48
48
  ## Your Role
49
49
 
50
- You receive messages and decide how to handle them:
50
+ You receive messages and decide how to handle them based on a strict routing priority:
51
51
 
52
- - **Direct answer**: For simple questions, general knowledge, status checks — answer directly.
53
- - **Use tools**: For tasks requiring shell access, file operations, web lookupsuse your tools.
54
- - **Create/delegate to squad**: For coding projects that need persistent context — create a squad with specialized agents.
55
- - **Use a skill**: If you have a skill for the task, use it.
52
+ ### 1. Squad routing (highest priority)
53
+ If **active squads are listed below** and the request is clearly related to one of those projects (coding tasks, bugs, features, issues, PRs, architecture), **delegate immediately to that squad's team lead** using \`squad_delegate\` do NOT plan the work yourself, do NOT break it into subtasks. Just pass the full request to the lead and let the team handle it internally.
54
+
55
+ - The team lead will plan and delegate internally to teammates.
56
+ - You do not need to understand the full scope of the work — the lead does.
57
+ - Multiple squads? Pick the one whose \`project_path\` / name best matches the request.
58
+
59
+ ### 2. Direct tools (medium priority)
60
+ For tasks that require shell access, file operations, web lookups, or knowledge base updates — and that are NOT squad project work — use your tools directly. Use a skill if one is available for the task.
61
+
62
+ ### 3. Direct answer (lowest priority)
63
+ For general questions, conversation, status checks, or anything outside the scope of a squad's project — answer directly.
64
+
65
+ > **Rule**: If a squad exists that covers the topic, always delegate. Never plan or implement squad work yourself.
56
66
  ${squadBlock}
57
67
  ## Squad System
58
68
 
@@ -70,14 +80,24 @@ Squads are persistent project teams with **named specialist agents**. Each squad
70
80
  - Check overall status with \`squad_status\`.
71
81
 
72
82
  ### Delegating Work
73
- After planning tasks with the user, **use \`squad_delegate\` to send each task to the right agent**:
74
- 1. Plan the work with the user (break into concrete tasks).
75
- 2. Call \`squad_delegate\` with the squad slug and task. Optionally specify an \`agent\` (character name) to target a specific specialist. If omitted, the system picks the best available agent.
76
- 3. The agent works autonomously in the background with their specialized system prompt.
77
- 4. Use \`squad_task_status\` to check progress and retrieve results.
78
- 5. Report results back to the user, mentioning the character name.
79
-
80
- You can delegate multiple tasks to different agents in parallel.
83
+ **Do not plan squad work yourself.** When a squad-relevant request arrives:
84
+ 1. Call \`squad_delegate\` with the squad slug and the full request (as-is from the user). Do NOT specify an agent — let it route to the team lead automatically.
85
+ 2. The team lead breaks it down and delegates to teammates internally via \`delegate_to_teammate\`. If no lead is designated, the system falls back to the first idle agent.
86
+ 3. Use \`squad_task_status\` to monitor progress and report results back to the user.
87
+
88
+ Only specify an \`agent\` when the user **explicitly asks** to target a specific squad member by name.
89
+
90
+ ### Team Leads
91
+ Every squad should have a **team lead**. After building the team with \`squad_add_agent\`, designate one agent as the lead using \`squad_set_lead\`. The lead receives delegated tasks (when no specific agent is targeted), breaks them into subtasks, and assigns work to teammates via the lead-only \`delegate_to_teammate\` tool. This keeps coordination inside the squad rather than forcing IO to micro-manage assignments.
92
+
93
+ ### Peer Review & QA Approvals
94
+ When an agent finishes a task, the other squad members automatically review the work and vote APPROVED or REJECTED. Reviews are recorded and emitted as \`task.review\` events.
95
+
96
+ - Designate QA reviewers with \`squad_set_qa\` — typically agents focused on testing, security, or quality.
97
+ - **QA agents have veto power**: if any QA reviewer rejects, the PR stays as a draft.
98
+ - Non-QA rejections are advisory — they're recorded but don't block promotion.
99
+ - When all QA approvals pass (or no QA agents exist) and the task result contains a GitHub PR URL, the PR is automatically promoted from draft to ready via \`gh pr ready\`.
100
+ - Use \`squad_task_reviews\` to inspect the reviews on any completed task.
81
101
 
82
102
  ### Agent Roles Are Dynamic
83
103
  **Do NOT use generic roles** like "developer" or "tester". Analyze the project first and create roles that match its actual technology stack. Examples:
@@ -111,10 +111,14 @@ export function createTools(deps) {
111
111
  ? UNIVERSES.find((u) => u.id === s.universe)?.name ?? s.universe
112
112
  : "none";
113
113
  const agents = deps.listSquadAgents(s.slug);
114
+ const lead = deps.getSquadLead(s.slug);
115
+ const leadLine = lead
116
+ ? `\n ⭐ Team Lead: ${lead.character_name} (${lead.role_title})`
117
+ : "";
114
118
  const agentList = agents.length > 0
115
119
  ? "\n Agents: " + agents.map((a) => `${a.character_name} (${a.role_title})`).join(", ")
116
120
  : "\n Agents: none — use squad_add_agent to build the team";
117
- return `- **${s.name}** (\`${s.slug}\`) — ${s.status} — 🎬 ${universeName}${agentList}\n 📁 ${s.projectPath}`;
121
+ return `- **${s.name}** (\`${s.slug}\`) — ${s.status} — 🎬 ${universeName}${leadLine}${agentList}\n 📁 ${s.projectPath}`;
118
122
  })
119
123
  .join("\n");
120
124
  },
@@ -373,7 +377,11 @@ export function createTools(deps) {
373
377
  const universeName = squad.universe
374
378
  ? UNIVERSES.find((u) => u.id === squad.universe)?.name ?? squad.universe
375
379
  : "none";
376
- const lines = agents.map((a) => `- **${a.character_name}** — ${a.role_title} (${a.model_tier}) — ${a.status}${a.personality ? `\n _${a.personality}_` : ""}`);
380
+ const lines = agents.map((a) => {
381
+ const leadBadge = a.is_lead === 1 ? " ⭐ [LEAD]" : "";
382
+ const qaBadge = a.is_qa === 1 ? " 🛡️ [QA]" : "";
383
+ return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (${a.model_tier}) — ${a.status}${a.personality ? `\n _${a.personality}_` : ""}`;
384
+ });
377
385
  return `**${squad.name}** — 🎬 ${universeName}\n\n${lines.join("\n")}`;
378
386
  },
379
387
  });
@@ -973,7 +981,84 @@ export function createTools(deps) {
973
981
  }
974
982
  },
975
983
  });
976
- return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github];
984
+ const squadSetQA = defineTool("squad_set_qa", {
985
+ 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.",
986
+ skipPermission: true,
987
+ parameters: z.object({
988
+ slug: z.string().describe("Squad slug"),
989
+ character_name: z.string().describe("Character name of the agent"),
990
+ is_qa: z
991
+ .boolean()
992
+ .describe("Whether this agent is a QA reviewer (true) or not (false)"),
993
+ }),
994
+ handler: async ({ slug, character_name, is_qa }) => {
995
+ try {
996
+ const squad = deps.getSquad(slug);
997
+ if (!squad)
998
+ return `Squad not found: ${slug}`;
999
+ const agents = deps.listSquadAgents(slug);
1000
+ const target = agents.find((a) => a.character_name === character_name);
1001
+ if (!target) {
1002
+ return `Agent "${character_name}" not found in squad "${slug}".`;
1003
+ }
1004
+ deps.setSquadQA(slug, character_name, is_qa);
1005
+ return is_qa
1006
+ ? `🛡️ ${character_name} (${target.role_title}) is now a QA reviewer for squad "${squad.name}". They have veto power over PR promotion.`
1007
+ : `${character_name} is no longer a QA reviewer for squad "${squad.name}".`;
1008
+ }
1009
+ catch (err) {
1010
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
1011
+ }
1012
+ },
1013
+ });
1014
+ const squadTaskReviews = defineTool("squad_task_reviews", {
1015
+ description: "Get the peer reviews left on a completed task by the squad. Shows who approved or rejected and any comments.",
1016
+ skipPermission: true,
1017
+ parameters: z.object({
1018
+ task_id: z.string().describe("The task ID to fetch reviews for"),
1019
+ }),
1020
+ handler: async ({ task_id }) => {
1021
+ const reviews = deps.getTaskReviews(task_id);
1022
+ if (reviews.length === 0) {
1023
+ return `No reviews found for task ${task_id}.`;
1024
+ }
1025
+ return reviews
1026
+ .map((r) => {
1027
+ const verdict = r.approved === 1 ? "✅ APPROVED" : "❌ REJECTED";
1028
+ const comments = r.comments ? `\n ${r.comments.replace(/\n/g, "\n ")}` : "";
1029
+ return `- **${r.reviewer_character}** — ${verdict}${comments}`;
1030
+ })
1031
+ .join("\n");
1032
+ },
1033
+ });
1034
+ const squadSetLead = defineTool("squad_set_lead", {
1035
+ description: "Designate an agent as the team lead for their squad. The lead receives delegated tasks (when no specific agent is targeted) and orchestrates the team by divvying subtasks to teammates.",
1036
+ skipPermission: true,
1037
+ parameters: z.object({
1038
+ slug: z.string().describe("Squad slug"),
1039
+ character_name: z
1040
+ .string()
1041
+ .describe("Character name of the agent to make team lead"),
1042
+ }),
1043
+ handler: async ({ slug, character_name }) => {
1044
+ try {
1045
+ const squad = deps.getSquad(slug);
1046
+ if (!squad)
1047
+ return `Squad not found: ${slug}`;
1048
+ const agents = deps.listSquadAgents(slug);
1049
+ const target = agents.find((a) => a.character_name === character_name);
1050
+ if (!target) {
1051
+ return `Agent "${character_name}" not found in squad "${slug}". Use squad_agents to list the roster.`;
1052
+ }
1053
+ deps.setSquadLead(slug, character_name);
1054
+ return `⭐ ${character_name} (${target.role_title}) is now the team lead for squad "${squad.name}".`;
1055
+ }
1056
+ catch (err) {
1057
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
1058
+ }
1059
+ },
1060
+ });
1061
+ return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadSetLead, squadSetQA, squadTaskReviews, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github];
977
1062
  }
978
1063
  function walkDirectory(dir, maxDepth = 3, depth = 0) {
979
1064
  if (depth >= maxDepth)
package/dist/store/db.js CHANGED
@@ -70,6 +70,17 @@ export function getDb() {
70
70
  status TEXT NOT NULL DEFAULT 'idle',
71
71
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
72
72
  UNIQUE(squad_slug, character_name)
73
+ )`,
74
+ `ALTER TABLE squad_agents ADD COLUMN is_lead INTEGER NOT NULL DEFAULT 0`,
75
+ `ALTER TABLE squad_agents ADD COLUMN is_qa INTEGER NOT NULL DEFAULT 0`,
76
+ `CREATE TABLE IF NOT EXISTS squad_task_reviews (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ task_id TEXT NOT NULL,
79
+ squad_slug TEXT NOT NULL,
80
+ reviewer_character TEXT NOT NULL,
81
+ approved INTEGER NOT NULL DEFAULT 0,
82
+ comments TEXT,
83
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
73
84
  )`,
74
85
  ];
75
86
  for (const migration of migrations) {
@@ -115,4 +115,22 @@ export function getDecisionsSummary(squadSlug) {
115
115
  })
116
116
  .join("\n");
117
117
  }
118
+ export function setSquadLead(squadSlug, characterName) {
119
+ const db = getDb();
120
+ const tx = db.transaction(() => {
121
+ db.prepare("UPDATE squad_agents SET is_lead = 0 WHERE squad_slug = ?").run(squadSlug);
122
+ db.prepare("UPDATE squad_agents SET is_lead = 1 WHERE squad_slug = ? AND character_name = ?").run(squadSlug, characterName);
123
+ });
124
+ tx();
125
+ }
126
+ export function getSquadLead(squadSlug) {
127
+ return getDb()
128
+ .prepare("SELECT * FROM squad_agents WHERE squad_slug = ? AND is_lead = 1 LIMIT 1")
129
+ .get(squadSlug);
130
+ }
131
+ export function setSquadQA(squadSlug, characterName, isQA) {
132
+ getDb()
133
+ .prepare("UPDATE squad_agents SET is_qa = ? WHERE squad_slug = ? AND character_name = ?")
134
+ .run(isQA ? 1 : 0, squadSlug, characterName);
135
+ }
118
136
  //# sourceMappingURL=squads.js.map
@@ -39,4 +39,18 @@ export function listRecentTasks(limit = 50) {
39
39
  .prepare("SELECT * FROM agent_tasks ORDER BY datetime(started_at) DESC, task_id DESC LIMIT ?")
40
40
  .all(limit);
41
41
  }
42
+ export function createReview(taskId, squadSlug, reviewerCharacter, approved, comments) {
43
+ const db = getDb();
44
+ const info = db
45
+ .prepare("INSERT INTO squad_task_reviews (task_id, squad_slug, reviewer_character, approved, comments) VALUES (?, ?, ?, ?, ?)")
46
+ .run(taskId, squadSlug, reviewerCharacter, approved ? 1 : 0, comments ?? null);
47
+ return db
48
+ .prepare("SELECT * FROM squad_task_reviews WHERE id = ?")
49
+ .get(info.lastInsertRowid);
50
+ }
51
+ export function getTaskReviews(taskId) {
52
+ return getDb()
53
+ .prepare("SELECT * FROM squad_task_reviews WHERE task_id = ? ORDER BY created_at ASC, id ASC")
54
+ .all(taskId);
55
+ }
42
56
  //# sourceMappingURL=tasks.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"