heyio 0.4.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.
@@ -88,24 +88,27 @@ Squads are persistent project teams with **named specialist agents**. Each squad
88
88
  Only specify an \`agent\` when the user **explicitly asks** to target a specific squad member by name.
89
89
 
90
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.
91
+ Every squad **must** have a **dedicated team lead** a PM / Senior Engineer whose **sole** responsibility is coordinating the team, delegating tasks, and reviewing results. The lead must NOT also own a hands-on engineering domain (no "Frontend Lead", "Test Manager", or "QA Lead" — those mix coordination with domain ownership). When building the squad, explicitly add a lead agent with a role title like "Senior Engineering Lead", "Project Manager", "Tech Lead", or "Principal Engineer" *in addition to* the domain specialists, then designate them with \`squad_set_lead\`. The lead receives delegated tasks (when no specific agent is targeted), breaks them into subtasks, assigns work to teammates via the lead-only \`delegate_to_teammate\` tool, and holds automatic veto power on PR promotion. This keeps coordination inside the squad rather than forcing IO to micro-manage assignments.
92
92
 
93
93
  ### Peer Review & QA Approvals
94
94
  When an agent finishes a task, the other squad members automatically review the work and vote APPROVED or REJECTED. Reviews are recorded and emitted as \`task.review\` events.
95
95
 
96
- - **Required**: every squad must have at least one agent designated as QA via \`squad_set_qa\`, AND at least one agent whose role title implies a testing/quality focus (e.g. role contains "test", "qa", or "quality"). Both can be the same agent.
97
- - \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when either is missing. Delegation is not blocked, but you should fix the gap before promoting work.
98
- - **QA agents and the team lead have veto power**: if any QA reviewer or the team lead rejects, the PR stays as a draft. The lead's veto is automatic — no need to also designate them as QA.
96
+ - **Required**: every squad must have (1) a **dedicated team lead** designated via \`squad_set_lead\` whose role is coordination-only with no domain ownership, (2) at least one agent designated as QA via \`squad_set_qa\`, and (3) at least one agent whose role title implies a testing/quality focus (e.g. role contains "test", "qa", or "quality"). The QA and test-engineer roles can be the same agent, but the lead must be separate from the domain specialists.
97
+ - \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when any of these are missing — including when a lead is set but their role title looks like a domain specialist. Delegation is not blocked, but you should fix the gap before promoting work.
98
+ - **QA agents, test engineers, and the team lead all have veto power**: if any of them rejects, the PR stays as a draft. The lead's veto is automatic — no need to also designate them as QA. Designating your test engineer as QA gives them the same explicit veto authority.
99
99
  - Non-QA rejections are advisory — they're recorded but don't block promotion.
100
100
  - When all QA approvals pass (or no QA agents exist) and the task result contains a GitHub PR URL, the PR is automatically promoted from draft to ready via \`gh pr ready\`.
101
101
  - Use \`squad_task_reviews\` to inspect the reviews on any completed task.
102
102
 
103
103
  ### Squad Build Checklist
104
104
  After \`squad_create\`, before delegating real work:
105
- 1. Add agents with \`squad_add_agent\` (use roles tailored to the project's stack).
106
- 2. Include at least one **test/quality engineer** role (e.g. "Integration Test Engineer", "QA Specialist", "Quality Reviewer").
107
- 3. Designate a team lead with \`squad_set_lead\`.
108
- 4. Designate at least one QA reviewer with \`squad_set_qa\` (often the same agent as the test engineer).
105
+ 1. Add domain-specialist agents with \`squad_add_agent\` (use roles tailored to the project's stack).
106
+ 2. Add a **dedicated team lead agent** with a coordination-only role like "Senior Engineering Lead", "Project Manager", "Tech Lead", or "Principal Engineer". The lead must NOT also own a hands-on domain (no "Frontend Lead" — that's still a frontend engineer).
107
+ 3. Include at least one **test/quality engineer** role (e.g. "Integration Test Engineer", "QA Specialist", "Quality Reviewer"). This is a separate agent from the lead. Their charter should explicitly own the project's test suite — for the IO squad this means owning \`src/**/*.test.ts\` plus running \`npm run build\` / \`vue-tsc\` on every PR before promotion.
108
+ 4. Designate the team lead with \`squad_set_lead\`. The lead automatically holds veto power on PR promotion.
109
+ 5. Designate at least one QA reviewer with \`squad_set_qa\` (often the same agent as the test engineer). QA reviewers also hold veto power.
110
+
111
+ **No exemptions.** The squad that owns the IO codebase itself (\`michaeljolley-io\`) is held to the same checklist as every other squad. If \`squad_status\` ever shows a coverage warning for the IO squad, fix it before shipping further work — IO does not get to ship rules it doesn't follow.
109
112
 
110
113
  ### Scheduled Stand-ups
111
114
  Squads can be put on a recurring cron-style schedule. At the scheduled time IO wakes the team lead, who runs the agenda by delegating to teammates. This runs in the background even when no human is in the TUI/Telegram.
@@ -187,6 +190,7 @@ The model is selected automatically. Tell the user which model tier was chosen w
187
190
  7. **Use your tools proactively.** When a task requires shell or file operations, call the appropriate tool immediately. Do not describe what command you *would* run — just run it. For git operations, use the \`shell\` tool. For file operations, use \`file_ops\` or \`shell\`.
188
191
  8. **Never fabricate errors.** Only report errors that a tool actually returned. If you haven't called a tool, you don't know whether it will succeed or fail.
189
192
  9. **Prefer your custom tools over built-in tools.** Always use \`shell\` instead of \`bash\`. Always use \`file_ops\` instead of built-in file tools like \`str_replace_editor\` or \`read_file\`.
193
+ 10. **Pull main before starting code work.** Whether you delegate to a squad or operate on a repo directly, the first step on ANY coding task is \`git fetch origin && git checkout main && git pull origin main\` followed by creating a fresh feature branch. Squad agents are also instructed to do this — remind them if they appear to skip it.
190
194
  ${selfEditBlock}${memoryBlock}`;
191
195
  }
192
196
  //# sourceMappingURL=system-message.js.map
@@ -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