heyio 0.5.0 → 0.6.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.
@@ -11,38 +11,215 @@ import { runIoScheduleNow } from "./io-scheduler.js";
11
11
  import { createSchedule, deleteSchedule, getSchedule, listSchedules, setScheduleEnabled, } from "../store/schedules.js";
12
12
  import { runScheduleNow } from "./scheduler.js";
13
13
  // ---------------------------------------------------------------------------
14
- // QA / test coverage heuristics
14
+ // Squad coverage heuristics
15
15
  //
16
16
  // Every squad must have:
17
- // 1. At least one agent designated as QA (is_qa === 1) - see squad_set_qa.
18
- // 2. At least one agent whose role title implies a testing/quality focus.
17
+ // 1. A dedicated team lead a PM / Senior Engineer with no domain
18
+ // responsibility designated via squad_set_lead. The lead's job is
19
+ // coordination, delegation, and review only. Lead veto power on PR
20
+ // promotion is automatic (see runPeerReview in agents.ts).
21
+ // 2. At least one agent designated as QA (is_qa === 1) — see squad_set_qa.
22
+ // 3. At least one agent whose role title implies a testing/quality focus.
19
23
  //
20
24
  // These are surfaced as warnings on squad_status, squad_agents, and
21
25
  // squad_delegate so users can fix coverage gaps before promoting work.
22
26
  // ---------------------------------------------------------------------------
23
27
  const TEST_ROLE_KEYWORDS = ["test", "qa", "quality", "tester", "sdet", "qe"];
28
+ // Words in a role title that imply the agent owns a hands-on engineering
29
+ // domain (and therefore should NOT be the team lead).
30
+ const DOMAIN_ROLE_KEYWORDS = [
31
+ "frontend",
32
+ "backend",
33
+ "fullstack",
34
+ "full-stack",
35
+ "api",
36
+ "ui",
37
+ "ux",
38
+ "test",
39
+ "tester",
40
+ "qa",
41
+ "quality",
42
+ "sdet",
43
+ "qe",
44
+ "devops",
45
+ "sre",
46
+ "ops",
47
+ "infrastructure",
48
+ "platform",
49
+ "data",
50
+ "database",
51
+ "db",
52
+ "ml",
53
+ "ai",
54
+ "sdk",
55
+ "mobile",
56
+ "ios",
57
+ "android",
58
+ "web",
59
+ "security",
60
+ "embedded",
61
+ "integration",
62
+ "telegram",
63
+ "tui",
64
+ "vue",
65
+ "react",
66
+ "angular",
67
+ "express",
68
+ "sqlite",
69
+ "wiki",
70
+ ];
71
+ // Words that mark a role as coordination/leadership-focused.
72
+ const LEAD_ROLE_KEYWORDS = [
73
+ "lead",
74
+ "manager",
75
+ "pm",
76
+ "principal",
77
+ "coordinator",
78
+ "director",
79
+ ];
80
+ function containsWord(haystack, word) {
81
+ const re = new RegExp(`(^|[^a-z])${word}([^a-z]|$)`);
82
+ return re.test(haystack);
83
+ }
24
84
  export function roleLooksLikeTesting(roleTitle) {
25
85
  if (!roleTitle)
26
86
  return false;
27
87
  const lower = roleTitle.toLowerCase();
28
- return TEST_ROLE_KEYWORDS.some((kw) => {
29
- const re = new RegExp(`(^|[^a-z])${kw}([^a-z]|$)`);
30
- return re.test(lower);
31
- });
88
+ return TEST_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
89
+ }
90
+ /**
91
+ * A dedicated team lead has a role title that emphasises coordination/seniority
92
+ * and does NOT also claim a hands-on engineering domain. Examples:
93
+ * ✅ "Engineering Lead", "Project Manager", "Senior Engineering Lead",
94
+ * "Principal Engineer", "Tech Lead", "Senior Engineer"
95
+ * ❌ "Frontend Lead", "Test Manager", "QA Lead", "Backend Engineer",
96
+ * "Express API Engineer"
97
+ */
98
+ export function roleLooksLikeDedicatedLead(roleTitle) {
99
+ if (!roleTitle)
100
+ return false;
101
+ const lower = roleTitle.toLowerCase();
102
+ const hasDomainKw = DOMAIN_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
103
+ if (hasDomainKw)
104
+ return false;
105
+ const hasLeadKw = LEAD_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
106
+ if (hasLeadKw)
107
+ return true;
108
+ // "Senior Engineer" / "Sr. Engineer" with no domain qualifier also counts.
109
+ if (/(^|[^a-z])(senior|sr\.?)\s+engineer($|[^a-z])/.test(lower))
110
+ return true;
111
+ return false;
32
112
  }
33
113
  export function assessSquadCoverage(agents) {
114
+ const leadAgent = agents.find((a) => a.is_lead === 1);
115
+ const hasLead = !!leadAgent;
116
+ const hasDedicatedLead = !!leadAgent && roleLooksLikeDedicatedLead(leadAgent.role_title);
34
117
  const hasQa = agents.some((a) => a.is_qa === 1);
35
118
  const hasTestRole = agents.some((a) => roleLooksLikeTesting(a.role_title));
36
119
  const missing = [];
120
+ if (!hasLead) {
121
+ missing.push("dedicated team lead (use squad_set_lead with a PM/Senior Engineer who owns no domain)");
122
+ }
123
+ else if (!hasDedicatedLead) {
124
+ 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)`);
125
+ }
37
126
  if (!hasQa)
38
127
  missing.push("QA reviewer (use squad_set_qa)");
39
128
  if (!hasTestRole) {
40
129
  missing.push("test/quality engineer (add an agent whose role_title contains 'test', 'qa', or 'quality')");
41
130
  }
42
131
  const warning = missing.length > 0
43
- ? `⚠️ Squad coverage gap: missing ${missing.join(" and ")}.`
132
+ ? `⚠️ Squad coverage gap: missing ${missing.join("; ")}.`
44
133
  : null;
45
- return { hasQa, hasTestRole, missing, warning };
134
+ return { hasLead, hasDedicatedLead, hasQa, hasTestRole, missing, warning };
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Work-distribution diagnostics
138
+ //
139
+ // Squads can fall into an anti-pattern (#51) where the team lead handles
140
+ // every delegated task instead of fanning out to specialists. We surface a
141
+ // soft warning on squad_status when the lead handles more than this share
142
+ // of recent tasks.
143
+ // ---------------------------------------------------------------------------
144
+ const WORK_DISTRIBUTION_WINDOW = 20;
145
+ const LEAD_OVERLOAD_THRESHOLD = 0.8;
146
+ function formatWorkDistribution(squadSlug, lead, deps) {
147
+ const dist = deps.getSquadWorkDistribution(squadSlug, WORK_DISTRIBUTION_WINDOW);
148
+ if (dist.total === 0)
149
+ return "";
150
+ const friendly = (agentSlug) => {
151
+ if (agentSlug === squadSlug)
152
+ return "(unassigned)";
153
+ const idx = agentSlug.indexOf(":");
154
+ return idx >= 0 ? agentSlug.slice(idx + 1) : agentSlug;
155
+ };
156
+ const breakdown = dist.perAgent
157
+ .map((a) => `${friendly(a.agent_slug)} ${a.count} (${Math.round((a.count / dist.total) * 100)}%)`)
158
+ .join(", ");
159
+ const lines = [];
160
+ lines.push(`\n 📊 Work distribution (last ${dist.total} task${dist.total === 1 ? "" : "s"}): ${breakdown}`);
161
+ if (lead) {
162
+ const leadKey = `${squadSlug}:${lead.character_name}`;
163
+ const leadCount = dist.perAgent.find((a) => a.agent_slug === leadKey)?.count ?? 0;
164
+ const share = leadCount / dist.total;
165
+ if (share > LEAD_OVERLOAD_THRESHOLD) {
166
+ 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.`);
167
+ }
168
+ }
169
+ return lines.join("");
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Per-agent delegation-stat formatters (issue #61)
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Format an ISO timestamp as a short relative time string. SQLite emits
176
+ * naive UTC strings, so we suffix "Z" before parsing if it isn't already
177
+ * timezone-qualified.
178
+ */
179
+ function formatRelativeTime(iso) {
180
+ if (!iso)
181
+ return "never";
182
+ const tzQualified = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(iso);
183
+ const ts = new Date(tzQualified ? iso : iso + "Z").getTime();
184
+ if (Number.isNaN(ts))
185
+ return "never";
186
+ const deltaMs = Date.now() - ts;
187
+ if (deltaMs < 60_000)
188
+ return "just now";
189
+ if (deltaMs < 3_600_000) {
190
+ const m = Math.round(deltaMs / 60_000);
191
+ return `${m}m ago`;
192
+ }
193
+ if (deltaMs < 86_400_000) {
194
+ const h = Math.round(deltaMs / 3_600_000);
195
+ return `${h}h ago`;
196
+ }
197
+ const d = Math.round(deltaMs / 86_400_000);
198
+ return `${d}d ago`;
199
+ }
200
+ /**
201
+ * Format a stale-hours number as a short duration. >=24h rounds to days,
202
+ * smaller values stay in hours. Floors to keep behaviour consistent across
203
+ * the squad_agents and squad_task_status renderers.
204
+ */
205
+ function formatStaleDuration(staleHours) {
206
+ if (staleHours >= 24) {
207
+ const d = Math.floor(staleHours / 24);
208
+ return `${d}d`;
209
+ }
210
+ return `${Math.floor(staleHours)}h`;
211
+ }
212
+ /**
213
+ * Build the ⚠️ stalest-specialist hint string from a getStalestSpecialist
214
+ * result. Returns "" if the input is null (squad is healthy).
215
+ */
216
+ function formatStalestHint(stalest) {
217
+ if (!stalest)
218
+ return "";
219
+ if (stalest.staleHours == null) {
220
+ return `⚠️ ${stalest.character_name} has never been delegated to`;
221
+ }
222
+ return `⚠️ ${stalest.character_name} has not been delegated to in ${formatStaleDuration(stalest.staleHours)}`;
46
223
  }
47
224
  // Ensure child processes have HOME set (systemd services often don't)
48
225
  function shellEnv() {
@@ -159,7 +336,15 @@ export function createTools(deps) {
159
336
  : "\n Agents: none — use squad_add_agent to build the team";
160
337
  const coverage = assessSquadCoverage(agents);
161
338
  const coverageLine = coverage.warning ? `\n ${coverage.warning}` : "";
162
- return `- **${s.name}** (\`${s.slug}\`) ${s.status} — 🎬 ${universeName}${leadLine}${agentList}${coverageLine}\n 📁 ${s.projectPath}`;
339
+ const distLine = formatWorkDistribution(s.slug, lead, deps);
340
+ const recentDecisions = deps.getRecentDecisions(s.slug, 3);
341
+ const decisionsLine = recentDecisions.length === 0
342
+ ? "\n 📜 Recent decisions: _none recorded — squad is not capturing institutional knowledge_"
343
+ : "\n 📜 Recent decisions: " +
344
+ recentDecisions
345
+ .map((d) => `\"${d.decision.length > 80 ? d.decision.slice(0, 80) + "…" : d.decision}\"`)
346
+ .join("; ");
347
+ return `- **${s.name}** (\`${s.slug}\`) — ${s.status} — 🎬 ${universeName}${leadLine}${agentList}${coverageLine}${distLine}${decisionsLine}\n 📁 ${s.projectPath}`;
163
348
  })
164
349
  .join("\n");
165
350
  },
@@ -205,7 +390,7 @@ export function createTools(deps) {
205
390
  }, agent);
206
391
  const agentLabel = agent ? `agent "${agent}" in squad "${slug}"` : `squad "${slug}"`;
207
392
  const warningPrefix = coverage.warning
208
- ? `${coverage.warning} Reviews from this squad will not be vetoed by a designated QA agent until this is fixed.\n\n`
393
+ ? `${coverage.warning} A dedicated lead and a QA reviewer should both hold veto power on PR promotion fix gaps before promoting work.\n\n`
209
394
  : "";
210
395
  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.`;
211
396
  }
@@ -233,14 +418,58 @@ export function createTools(deps) {
233
418
  const result = task.result.length > 4000 ? task.result.slice(0, 4000) + "\n[…truncated]" : task.result;
234
419
  response += `\n\nResult:\n${result}`;
235
420
  }
421
+ // Stalest-specialist hint for the squad this task belongs to (#61).
422
+ try {
423
+ const squadSlug = task.agent_slug.split(":")[0];
424
+ if (squadSlug) {
425
+ const roster = deps.listSquadAgents(squadSlug);
426
+ const characterNames = roster.map((a) => a.character_name);
427
+ const lead = roster.find((a) => a.is_lead === 1);
428
+ const stalest = deps.getStalestSpecialist(squadSlug, characterNames, {
429
+ excludeCharacters: lead ? [lead.character_name] : [],
430
+ });
431
+ const hint = formatStalestHint(stalest);
432
+ if (hint)
433
+ response += `\n\n${hint}`;
434
+ }
435
+ }
436
+ catch (err) {
437
+ console.error("[io] squad_task_status: stalest-specialist hint failed:", err);
438
+ }
236
439
  return response;
237
440
  }
238
441
  const tasks = deps.getActiveAgentTasks();
239
442
  if (tasks.length === 0)
240
443
  return "No active tasks.";
241
- return tasks
444
+ const taskLines = tasks
242
445
  .map((t) => `- **${t.taskId}** (${t.agentSlug}) — ${t.status} — ${t.description}`)
243
446
  .join("\n");
447
+ // Per-squad stalest-specialist hint block (#61).
448
+ let hintsBlock = "";
449
+ try {
450
+ const uniqueSquadSlugs = Array.from(new Set(tasks.map((t) => t.agentSlug.split(":")[0]).filter((x) => !!x)));
451
+ const hintLines = [];
452
+ for (const squadSlug of uniqueSquadSlugs) {
453
+ const roster = deps.listSquadAgents(squadSlug);
454
+ if (roster.length === 0)
455
+ continue;
456
+ const characterNames = roster.map((a) => a.character_name);
457
+ const lead = roster.find((a) => a.is_lead === 1);
458
+ const stalest = deps.getStalestSpecialist(squadSlug, characterNames, {
459
+ excludeCharacters: lead ? [lead.character_name] : [],
460
+ });
461
+ const hint = formatStalestHint(stalest);
462
+ if (hint)
463
+ hintLines.push(`- ${squadSlug}: ${hint}`);
464
+ }
465
+ if (hintLines.length > 0) {
466
+ hintsBlock = `\n\n**Distribution hints:**\n${hintLines.join("\n")}`;
467
+ }
468
+ }
469
+ catch (err) {
470
+ console.error("[io] squad_task_status: distribution hints failed:", err);
471
+ }
472
+ return `${taskLines}${hintsBlock}`;
244
473
  },
245
474
  });
246
475
  // --- Squad analyze ---
@@ -423,14 +652,49 @@ export function createTools(deps) {
423
652
  const universeName = squad.universe
424
653
  ? UNIVERSES.find((u) => u.id === squad.universe)?.name ?? squad.universe
425
654
  : "none";
655
+ // Pull per-agent task stats once and key by character_name (issue #61).
656
+ // If the helper throws (e.g. brand-new DB before view migration), fall
657
+ // back to an empty map so rendering is unchanged rather than 500-ing.
658
+ const characterNames = agents.map((a) => a.character_name);
659
+ const statsByName = new Map();
660
+ try {
661
+ for (const st of deps.getAgentTaskStats(slug, characterNames)) {
662
+ statsByName.set(st.character_name, {
663
+ task_count: st.task_count,
664
+ last_delegated_at: st.last_delegated_at,
665
+ });
666
+ }
667
+ }
668
+ catch (err) {
669
+ console.error("[io] squad_agents: getAgentTaskStats failed:", err);
670
+ }
426
671
  const lines = agents.map((a) => {
427
672
  const leadBadge = a.is_lead === 1 ? " ⭐ [LEAD]" : "";
428
673
  const qaBadge = a.is_qa === 1 ? " 🛡️ [QA]" : "";
429
- return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (${a.model_tier}) ${a.status}${a.personality ? `\n _${a.personality}_` : ""}`;
674
+ const st = statsByName.get(a.character_name) ?? { task_count: 0, last_delegated_at: null };
675
+ const statsStr = st.task_count === 0
676
+ ? " — 📊 never delegated"
677
+ : ` — 📊 ${st.task_count} ${st.task_count === 1 ? "task" : "tasks"} · last ${formatRelativeTime(st.last_delegated_at)}`;
678
+ return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (${a.model_tier}) — ${a.status}${statsStr}${a.personality ? `\n _${a.personality}_` : ""}`;
430
679
  });
431
680
  const coverage = assessSquadCoverage(agents);
432
681
  const coverageBlock = coverage.warning ? `\n\n${coverage.warning}` : "";
433
- return `**${squad.name}** 🎬 ${universeName}\n\n${lines.join("\n")}${coverageBlock}`;
682
+ // Stalest-specialist hint (issue #61): exclude the lead so the hint
683
+ // points at an under-utilised teammate rather than the coordinator.
684
+ let stalestBlock = "";
685
+ try {
686
+ const lead = agents.find((a) => a.is_lead === 1);
687
+ const stalest = deps.getStalestSpecialist(slug, characterNames, {
688
+ excludeCharacters: lead ? [lead.character_name] : [],
689
+ });
690
+ const hint = formatStalestHint(stalest);
691
+ if (hint)
692
+ stalestBlock = `\n\n${hint}`;
693
+ }
694
+ catch (err) {
695
+ console.error("[io] squad_agents: getStalestSpecialist failed:", err);
696
+ }
697
+ return `**${squad.name}** — 🎬 ${universeName}\n\n${lines.join("\n")}${coverageBlock}${stalestBlock}`;
434
698
  },
435
699
  });
436
700
  // --- Squad remove agent ---
@@ -449,6 +713,33 @@ export function createTools(deps) {
449
713
  : `Agent "${character_name}" not found in squad "${slug}".`;
450
714
  },
451
715
  });
716
+ const squadResetAgent = defineTool("squad_reset_agent", {
717
+ 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).",
718
+ skipPermission: true,
719
+ parameters: z.object({
720
+ slug: z.string().describe("Squad slug"),
721
+ character_name: z.string().describe("Character name of the agent to reset"),
722
+ }),
723
+ handler: async ({ slug, character_name }) => {
724
+ console.error(`[io] squad_reset_agent called: ${slug} — ${character_name}`);
725
+ const squad = deps.getSquad(slug);
726
+ if (!squad)
727
+ return `Squad not found: ${slug}`;
728
+ const result = deps.resetSquadAgent(slug, character_name);
729
+ if (!result.found || !result.agent) {
730
+ return `Agent "${character_name}" not found in squad "${slug}".`;
731
+ }
732
+ const { previousStatus, agent } = result;
733
+ if (previousStatus === "error") {
734
+ return `🔄 ${agent.character_name} (${agent.role_title}) reset from 'error' → 'idle'. Charter and role preserved; next task will create a fresh Copilot session.`;
735
+ }
736
+ if (previousStatus === "idle") {
737
+ 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.`;
738
+ }
739
+ // working / unknown
740
+ 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).`;
741
+ },
742
+ });
452
743
  // --- Squad delete ---
453
744
  const squadDelete = defineTool("squad_delete", {
454
745
  description: "Delete a squad and all its agents and decisions. This is permanent.",
@@ -1115,13 +1406,13 @@ export function createTools(deps) {
1115
1406
  },
1116
1407
  });
1117
1408
  const squadSetLead = defineTool("squad_set_lead", {
1118
- 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.",
1409
+ 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.",
1119
1410
  skipPermission: true,
1120
1411
  parameters: z.object({
1121
1412
  slug: z.string().describe("Squad slug"),
1122
1413
  character_name: z
1123
1414
  .string()
1124
- .describe("Character name of the agent to make team lead"),
1415
+ .describe("Character name of the agent to make team lead. Choose a PM / Senior Engineer with no domain ownership."),
1125
1416
  }),
1126
1417
  handler: async ({ slug, character_name }) => {
1127
1418
  try {
@@ -1134,7 +1425,12 @@ export function createTools(deps) {
1134
1425
  return `Agent "${character_name}" not found in squad "${slug}". Use squad_agents to list the roster.`;
1135
1426
  }
1136
1427
  deps.setSquadLead(slug, character_name);
1137
- return `⭐ ${character_name} (${target.role_title}) is now the team lead for squad "${squad.name}".`;
1428
+ const dedicated = roleLooksLikeDedicatedLead(target.role_title);
1429
+ const base = `⭐ ${character_name} (${target.role_title}) is now the team lead for squad "${squad.name}". They have automatic veto power on PR promotion.`;
1430
+ if (!dedicated) {
1431
+ 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.`;
1432
+ }
1433
+ return base;
1138
1434
  }
1139
1435
  catch (err) {
1140
1436
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
@@ -1376,7 +1672,7 @@ export function createTools(deps) {
1376
1672
  return `🚀 Fired IO schedule ${id} now.`;
1377
1673
  },
1378
1674
  });
1379
- return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadSetLead, squadSetQA, squadTaskReviews, squadScheduleCreate, squadScheduleList, squadScheduleDelete, squadSchedulePause, squadScheduleResume, squadScheduleRunNow, scheduleCreate, scheduleList, scheduleDelete, schedulePause, scheduleResume, scheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github];
1675
+ 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];
1380
1676
  }
1381
1677
  function walkDirectory(dir, maxDepth = 3, depth = 0) {
1382
1678
  if (depth >= maxDepth)
package/dist/store/db.js CHANGED
@@ -109,6 +109,12 @@ export function getDb() {
109
109
  )`,
110
110
  `CREATE INDEX IF NOT EXISTS idx_io_schedules_due
111
111
  ON io_schedules (enabled, next_run_at)`,
112
+ `CREATE VIEW IF NOT EXISTS agent_stats AS
113
+ SELECT agent_slug,
114
+ COUNT(*) AS task_count,
115
+ MAX(started_at) AS last_delegated_at
116
+ FROM agent_tasks
117
+ GROUP BY agent_slug`,
112
118
  ];
113
119
  for (const migration of migrations) {
114
120
  try {
@@ -93,6 +93,16 @@ export function updateAgentStatus(squadSlug, characterName, status) {
93
93
  .prepare("UPDATE squad_agents SET status = ? WHERE squad_slug = ? AND character_name = ?")
94
94
  .run(status, squadSlug, characterName);
95
95
  }
96
+ /**
97
+ * Clear an agent's persisted copilot_session_id. Used during error recovery
98
+ * so the next task creates a fresh session instead of trying to resume a
99
+ * poisoned one.
100
+ */
101
+ export function clearAgentSession(squadSlug, characterName) {
102
+ getDb()
103
+ .prepare("UPDATE squad_agents SET copilot_session_id = NULL WHERE squad_slug = ? AND character_name = ?")
104
+ .run(squadSlug, characterName);
105
+ }
96
106
  /**
97
107
  * Reset any agent left in a non-idle status from a previous daemon run.
98
108
  * The in-memory Copilot sessions don't survive a restart, so persisted
@@ -39,6 +39,28 @@ 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
+ /**
43
+ * Per-agent task count for the most recent `limit` tasks belonging to a
44
+ * squad. Matches tasks routed to the squad itself (`agent_slug = squadSlug`)
45
+ * AND tasks routed to a named agent on the squad (`agent_slug LIKE 'squadSlug:%'`).
46
+ * Used by squad_status to surface fan-out imbalance.
47
+ */
48
+ export function getSquadWorkDistribution(squadSlug, limit = 20) {
49
+ const rows = getDb()
50
+ .prepare(`SELECT agent_slug FROM agent_tasks
51
+ WHERE agent_slug = ? OR agent_slug LIKE ?
52
+ ORDER BY datetime(started_at) DESC, task_id DESC
53
+ LIMIT ?`)
54
+ .all(squadSlug, `${squadSlug}:%`, limit);
55
+ const counts = new Map();
56
+ for (const row of rows) {
57
+ counts.set(row.agent_slug, (counts.get(row.agent_slug) ?? 0) + 1);
58
+ }
59
+ const perAgent = Array.from(counts.entries())
60
+ .map(([agent_slug, count]) => ({ agent_slug, count }))
61
+ .sort((a, b) => b.count - a.count);
62
+ return { total: rows.length, perAgent };
63
+ }
42
64
  export function createReview(taskId, squadSlug, reviewerCharacter, approved, comments) {
43
65
  const db = getDb();
44
66
  const info = db
@@ -53,4 +75,104 @@ export function getTaskReviews(taskId) {
53
75
  .prepare("SELECT * FROM squad_task_reviews WHERE task_id = ? ORDER BY created_at ASC, id ASC")
54
76
  .all(taskId);
55
77
  }
78
+ /**
79
+ * Per-character delegation stats for a squad.
80
+ *
81
+ * Returns one row PER CHARACTER NAME passed in `characterNames`, plus an
82
+ * extra row with character_name="" for any tasks routed to the bare squad
83
+ * slug (legacy lead tasks). Always returns a row for every requested
84
+ * character, even if they have never been delegated to (task_count: 0,
85
+ * last_delegated_at: null).
86
+ *
87
+ * Reads from the agent_stats view. Filters with `agent_slug = ?`
88
+ * (for the bare slug) and `agent_slug = ?` for each `<slug>:<char>`.
89
+ */
90
+ export function getAgentTaskStats(squadSlug, characterNames) {
91
+ // Build the full set of agent_slug values we care about
92
+ const bareSlug = squadSlug;
93
+ const namedSlugs = characterNames.map((c) => `${squadSlug}:${c}`);
94
+ const allSlugs = [bareSlug, ...namedSlugs];
95
+ const placeholders = allSlugs.map(() => "?").join(", ");
96
+ const rows = getDb()
97
+ .prepare(`SELECT agent_slug, task_count, last_delegated_at FROM agent_stats WHERE agent_slug IN (${placeholders})`)
98
+ .all(...allSlugs);
99
+ const bySlug = new Map();
100
+ for (const row of rows)
101
+ bySlug.set(row.agent_slug, row);
102
+ const results = [];
103
+ // Bare slug row (legacy lead tasks routed without a named agent)
104
+ const bareRow = bySlug.get(bareSlug);
105
+ results.push({
106
+ character_name: "",
107
+ agent_slug: bareSlug,
108
+ task_count: bareRow?.task_count ?? 0,
109
+ last_delegated_at: bareRow?.last_delegated_at ?? null,
110
+ });
111
+ // One row per requested character
112
+ for (const char of characterNames) {
113
+ const slug = `${squadSlug}:${char}`;
114
+ const row = bySlug.get(slug);
115
+ results.push({
116
+ character_name: char,
117
+ agent_slug: slug,
118
+ task_count: row?.task_count ?? 0,
119
+ last_delegated_at: row?.last_delegated_at ?? null,
120
+ });
121
+ }
122
+ return results;
123
+ }
124
+ /**
125
+ * Pick the stalest specialist in a squad. "Stalest" = the character who
126
+ * has been delegated to least recently (oldest last_delegated_at), with
127
+ * never-delegated agents considered staler than any delegated agent.
128
+ *
129
+ * Excludes character names listed in `excludeCharacters` (use this to
130
+ * skip the lead). Returns null if the squad has no eligible agents OR if
131
+ * all eligible agents have been delegated to within `freshIfWithinHours`
132
+ * (default 48). The threshold is meant to suppress the hint when the
133
+ * squad is already distributing well.
134
+ *
135
+ * On tie (e.g. two agents have never been delegated), returns the one
136
+ * that sorts first by character_name (deterministic).
137
+ */
138
+ export function getStalestSpecialist(squadSlug, characterNames, options) {
139
+ const exclude = new Set(options?.excludeCharacters ?? []);
140
+ const freshThresholdHours = options?.freshIfWithinHours ?? 48;
141
+ const stats = getAgentTaskStats(squadSlug, characterNames);
142
+ // Filter: named agents only (skip the bare-slug "" row), skip excluded
143
+ const eligible = stats.filter((s) => s.character_name !== "" && !exclude.has(s.character_name));
144
+ if (eligible.length === 0)
145
+ return null;
146
+ const now = Date.now();
147
+ // Sort: never-delegated (null) first, then ascending by last_delegated_at
148
+ eligible.sort((a, b) => {
149
+ if (a.last_delegated_at === null && b.last_delegated_at === null) {
150
+ return a.character_name.localeCompare(b.character_name);
151
+ }
152
+ if (a.last_delegated_at === null)
153
+ return -1;
154
+ if (b.last_delegated_at === null)
155
+ return 1;
156
+ const tA = new Date(a.last_delegated_at + "Z").getTime();
157
+ const tB = new Date(b.last_delegated_at + "Z").getTime();
158
+ if (tA !== tB)
159
+ return tA - tB;
160
+ return a.character_name.localeCompare(b.character_name);
161
+ });
162
+ const stalest = eligible[0];
163
+ let staleHours = null;
164
+ if (stalest.last_delegated_at !== null) {
165
+ const delegatedAt = new Date(stalest.last_delegated_at + "Z").getTime();
166
+ staleHours = Math.round((now - delegatedAt) / 3_600_000);
167
+ // Squad is distributing well — suppress the hint
168
+ if (staleHours < freshThresholdHours)
169
+ return null;
170
+ }
171
+ // null last_delegated_at means never-delegated: always considered stale
172
+ return {
173
+ character_name: stalest.character_name,
174
+ last_delegated_at: stalest.last_delegated_at,
175
+ staleHours,
176
+ };
177
+ }
56
178
  //# sourceMappingURL=tasks.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"