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.
- package/dist/api/server.js +202 -2
- package/dist/copilot/agents.js +172 -12
- package/dist/copilot/orchestrator.js +30 -3
- package/dist/copilot/session-timeout.js +112 -0
- package/dist/copilot/system-message.js +12 -8
- package/dist/copilot/tools.js +314 -18
- package/dist/store/db.js +6 -0
- package/dist/store/squads.js +10 -0
- package/dist/store/tasks.js +122 -0
- package/package.json +1 -1
- package/web-dist/assets/{index-BksyB2za.js → index-BlZDeDCS.js} +20 -20
- package/web-dist/assets/index-DMKRXYjX.css +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-BWGQix5_.css +0 -1
|
@@ -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
|
|
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\`,
|
|
97
|
-
- \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when
|
|
98
|
-
- **QA agents and the team lead have veto power**: if any
|
|
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.
|
|
107
|
-
3.
|
|
108
|
-
4. Designate
|
|
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
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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
|
-
//
|
|
14
|
+
// Squad coverage heuristics
|
|
15
15
|
//
|
|
16
16
|
// Every squad must have:
|
|
17
|
-
// 1.
|
|
18
|
-
//
|
|
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
|
-
|
|
30
|
-
|
|
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("
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 {
|
package/dist/store/squads.js
CHANGED
|
@@ -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
|
package/dist/store/tasks.js
CHANGED
|
@@ -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
|