@yemi33/squad 0.1.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/engine.js ADDED
@@ -0,0 +1,3416 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Squad Engine — Auto-assigning orchestrator
4
+ *
5
+ * Discovers work from configurable sources (PRD gaps, PR tracker, manual queue),
6
+ * renders playbook templates with agent charters as system prompts, and spawns
7
+ * isolated `claude` CLI processes in git worktrees.
8
+ *
9
+ * Usage:
10
+ * node .squad/engine.js Start the engine (daemon mode)
11
+ * node .squad/engine.js status Show current state
12
+ * node .squad/engine.js pause Pause dispatching
13
+ * node .squad/engine.js resume Resume dispatching
14
+ * node .squad/engine.js stop Stop the engine
15
+ * node .squad/engine.js queue Show dispatch queue
16
+ * node .squad/engine.js dispatch Force a dispatch cycle
17
+ * node .squad/engine.js complete <id> Mark dispatch as done
18
+ * node .squad/engine.js spawn <agent> <prompt> Manually spawn an agent
19
+ * node .squad/engine.js work <title> [opts-json] Add to work queue
20
+ * node .squad/engine.js sources Show work source status
21
+ * node .squad/engine.js discover Dry-run work discovery
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { spawn, execSync } = require('child_process');
27
+
28
+ // ─── Paths ──────────────────────────────────────────────────────────────────
29
+
30
+ const SQUAD_DIR = __dirname;
31
+ const CONFIG_PATH = path.join(SQUAD_DIR, 'config.json');
32
+ const ROUTING_PATH = path.join(SQUAD_DIR, 'routing.md');
33
+ const NOTES_PATH = path.join(SQUAD_DIR, 'notes.md');
34
+ const AGENTS_DIR = path.join(SQUAD_DIR, 'agents');
35
+ const PLAYBOOKS_DIR = path.join(SQUAD_DIR, 'playbooks');
36
+ const ENGINE_DIR = path.join(SQUAD_DIR, 'engine');
37
+ const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
38
+ const DISPATCH_PATH = path.join(ENGINE_DIR, 'dispatch.json');
39
+ const LOG_PATH = path.join(ENGINE_DIR, 'log.json');
40
+ const INBOX_DIR = path.join(SQUAD_DIR, 'notes', 'inbox');
41
+ const ARCHIVE_DIR = path.join(SQUAD_DIR, 'notes', 'archive');
42
+ const PLANS_DIR = path.join(SQUAD_DIR, 'plans');
43
+ const IDENTITY_DIR = path.join(SQUAD_DIR, 'identity');
44
+
45
+ // ─── Multi-Project Support ──────────────────────────────────────────────────
46
+ // Config can have either:
47
+ // "project": { ... } — single project (legacy, .squad inside repo)
48
+ // "projects": [ { ... }, ... ] — multi-project (central .squad)
49
+ // Each project must have "localPath" pointing to the repo root.
50
+
51
+ function getProjects(config) {
52
+ if (config.projects && Array.isArray(config.projects)) {
53
+ return config.projects;
54
+ }
55
+ // Legacy single-project: derive localPath from .squad parent
56
+ const proj = config.project || {};
57
+ if (!proj.localPath) proj.localPath = path.resolve(SQUAD_DIR, '..');
58
+ if (!proj.workSources) proj.workSources = config.workSources || {};
59
+ return [proj];
60
+ }
61
+
62
+ function projectRoot(project) {
63
+ return path.resolve(project.localPath);
64
+ }
65
+
66
+ function projectWorkItemsPath(project) {
67
+ const wiSrc = project.workSources?.workItems;
68
+ if (wiSrc?.path) return path.resolve(projectRoot(project), wiSrc.path);
69
+ return path.join(projectRoot(project), '.squad', 'work-items.json');
70
+ }
71
+
72
+ // ─── Utilities ──────────────────────────────────────────────────────────────
73
+
74
+ function ts() { return new Date().toISOString(); }
75
+ function logTs() { return new Date().toLocaleTimeString(); }
76
+ function dateStamp() { return new Date().toISOString().slice(0, 10); }
77
+
78
+ function safeJson(p) {
79
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
80
+ }
81
+
82
+ function safeRead(p) {
83
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
84
+ }
85
+
86
+ function safeWrite(p, data) {
87
+ const dir = path.dirname(p);
88
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
89
+ const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
90
+ const tmp = p + '.tmp.' + process.pid;
91
+ try {
92
+ fs.writeFileSync(tmp, content);
93
+ fs.renameSync(tmp, p);
94
+ } catch (e) {
95
+ try { fs.unlinkSync(tmp); } catch {}
96
+ throw e;
97
+ }
98
+ }
99
+
100
+ function log(level, msg, meta = {}) {
101
+ const entry = { timestamp: ts(), level, message: msg, ...meta };
102
+ console.log(`[${logTs()}] [${level}] ${msg}`);
103
+
104
+ let logData = safeJson(LOG_PATH) || [];
105
+ if (!Array.isArray(logData)) logData = logData.entries || [];
106
+ logData.push(entry);
107
+ if (logData.length > 500) logData.splice(0, logData.length - 500);
108
+ safeWrite(LOG_PATH, logData);
109
+ }
110
+
111
+ // ─── State Readers ──────────────────────────────────────────────────────────
112
+
113
+ function getConfig() {
114
+ return safeJson(CONFIG_PATH) || {};
115
+ }
116
+
117
+ function getControl() {
118
+ return safeJson(CONTROL_PATH) || { state: 'stopped', pid: null };
119
+ }
120
+
121
+ function getDispatch() {
122
+ return safeJson(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
123
+ }
124
+
125
+ function getRouting() {
126
+ return safeRead(ROUTING_PATH);
127
+ }
128
+
129
+ function getNotes() {
130
+ return safeRead(NOTES_PATH);
131
+ }
132
+
133
+ function getAgentStatus(agentId) {
134
+ return safeJson(path.join(AGENTS_DIR, agentId, 'status.json')) || {
135
+ status: 'idle', task: null, started_at: null, completed_at: null
136
+ };
137
+ }
138
+
139
+ function setAgentStatus(agentId, status) {
140
+ // Sanitize: truncate task field and reject stream-json fragments
141
+ if (status.task && typeof status.task === 'string') {
142
+ if (status.task.includes('"session_id"') || status.task.includes('"is_error"') || status.task.includes('"uuid"')) {
143
+ // Corrupted — extract just the beginning before the JSON garbage
144
+ const cleanEnd = status.task.search(/[{"\[].*session_id|[{"\[].*is_error|[{"\[].*uuid/);
145
+ status.task = cleanEnd > 10 ? status.task.slice(0, cleanEnd).trim() : status.task.slice(0, 80);
146
+ }
147
+ if (status.task.length > 200) status.task = status.task.slice(0, 200);
148
+ }
149
+ safeWrite(path.join(AGENTS_DIR, agentId, 'status.json'), status);
150
+ }
151
+
152
+ function getAgentCharter(agentId) {
153
+ return safeRead(path.join(AGENTS_DIR, agentId, 'charter.md'));
154
+ }
155
+
156
+ function getInboxFiles() {
157
+ try { return fs.readdirSync(INBOX_DIR).filter(f => f.endsWith('.md')); } catch { return []; }
158
+ }
159
+
160
+ // ─── MCP Server Sync ─────────────────────────────────────────────────────────
161
+
162
+ const MCP_SERVERS_PATH = path.join(SQUAD_DIR, 'mcp-servers.json');
163
+
164
+ function syncMcpServers() {
165
+ // Sync MCP servers from ~/.claude.json into squad's mcp-servers.json
166
+ const home = process.env.USERPROFILE || process.env.HOME || '';
167
+ const claudeJsonPath = path.join(home, '.claude.json');
168
+
169
+ if (!fs.existsSync(claudeJsonPath)) {
170
+ console.log(' ~/.claude.json not found — skipping MCP sync');
171
+ return false;
172
+ }
173
+
174
+ try {
175
+ const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
176
+ const servers = claudeJson.mcpServers;
177
+ if (!servers || Object.keys(servers).length === 0) {
178
+ console.log(' No MCP servers found in ~/.claude.json');
179
+ return false;
180
+ }
181
+
182
+ safeWrite(MCP_SERVERS_PATH, { mcpServers: servers });
183
+ const names = Object.keys(servers);
184
+ console.log(` MCP servers synced (${names.length}): ${names.join(', ')}`);
185
+ log('info', `Synced ${names.length} MCP servers from ~/.claude.json: ${names.join(', ')}`);
186
+ return true;
187
+ } catch (e) {
188
+ console.log(` MCP sync failed: ${e.message}`);
189
+ return false;
190
+ }
191
+ }
192
+
193
+ // ─── Skills ──────────────────────────────────────────────────────────────────
194
+
195
+ const SKILLS_DIR = path.join(SQUAD_DIR, 'skills');
196
+ function collectSkillFiles() {
197
+ const skillFiles = [];
198
+ // Squad-level skills (shared across all agents)
199
+ for (const dir of [SKILLS_DIR]) {
200
+ try {
201
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== 'README.md');
202
+ for (const f of files) skillFiles.push({ file: f, dir, scope: 'squad' });
203
+ } catch {}
204
+ }
205
+ // Project-level skills (<project>/.claude/skills/)
206
+ const config = getConfig();
207
+ for (const project of getProjects(config)) {
208
+ const projectSkillsDir = path.resolve(project.localPath, '.claude', 'skills');
209
+ try {
210
+ const files = fs.readdirSync(projectSkillsDir).filter(f => f.endsWith('.md') && f !== 'README.md');
211
+ for (const f of files) skillFiles.push({ file: f, dir: projectSkillsDir, scope: 'project', projectName: project.name });
212
+ } catch {}
213
+ }
214
+ return skillFiles;
215
+ }
216
+
217
+ function parseSkillFrontmatter(content, filename) {
218
+ let name = filename.replace('.md', '');
219
+ let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '';
220
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
221
+ if (fmMatch) {
222
+ const fm = fmMatch[1];
223
+ const m = (key) => { const r = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm')); return r ? r[1].trim() : ''; };
224
+ name = m('name') || name;
225
+ trigger = m('trigger');
226
+ description = m('description');
227
+ project = m('project') || 'any';
228
+ author = m('author');
229
+ created = m('created');
230
+ allowedTools = m('allowed-tools');
231
+ }
232
+ return { name, trigger, description, project, author, created, allowedTools };
233
+ }
234
+
235
+ function getSkillIndex() {
236
+ try {
237
+ const skillFiles = collectSkillFiles();
238
+ if (skillFiles.length === 0) return '';
239
+
240
+ let index = '## Available Squad Skills\n\n';
241
+ index += 'These are reusable workflows discovered by agents. Follow them when the trigger matches your task.\n\n';
242
+
243
+ for (const { file: f, dir, scope, projectName } of skillFiles) {
244
+ const content = safeRead(path.join(dir, f));
245
+ const meta = parseSkillFrontmatter(content, f);
246
+
247
+ index += `### ${meta.name}`;
248
+ if (scope === 'project') index += ` (${projectName})`;
249
+ index += '\n';
250
+ if (meta.description) index += `${meta.description}\n`;
251
+ if (meta.trigger) index += `**When:** ${meta.trigger}\n`;
252
+ if (meta.project !== 'any') index += `**Project:** ${meta.project}\n`;
253
+ index += `**File:** \`${dir}/${f}\`\n`;
254
+ index += `Read the full skill file before following the steps.\n\n`;
255
+ }
256
+
257
+ return index;
258
+ } catch { return ''; }
259
+ }
260
+
261
+ function getPrs(project) {
262
+ if (project) {
263
+ const prPath = project.workSources?.pullRequests?.path;
264
+ if (prPath) return safeJson(path.resolve(projectRoot(project), prPath)) || [];
265
+ return safeJson(path.join(projectRoot(project), '.squad', 'pull-requests.json')) || [];
266
+ }
267
+ // Fallback: try all projects
268
+ const config = getConfig();
269
+ const projects = getProjects(config);
270
+ const all = [];
271
+ for (const p of projects) all.push(...getPrs(p));
272
+ return all;
273
+ }
274
+
275
+ // ─── Routing Parser ─────────────────────────────────────────────────────────
276
+
277
+ function parseRoutingTable() {
278
+ const content = getRouting();
279
+ const routes = {};
280
+ const lines = content.split('\n');
281
+ let inTable = false;
282
+
283
+ for (const line of lines) {
284
+ if (line.startsWith('| Work Type')) { inTable = true; continue; }
285
+ if (line.startsWith('|---')) continue;
286
+ if (!inTable || !line.startsWith('|')) {
287
+ if (inTable && !line.startsWith('|')) inTable = false;
288
+ continue;
289
+ }
290
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
291
+ if (cells.length >= 3) {
292
+ routes[cells[0].toLowerCase()] = {
293
+ preferred: cells[1].toLowerCase(),
294
+ fallback: cells[2].toLowerCase()
295
+ };
296
+ }
297
+ }
298
+ return routes;
299
+ }
300
+
301
+ function getAgentErrorRate(agentId) {
302
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
303
+ const metrics = safeJson(metricsPath) || {};
304
+ const m = metrics[agentId];
305
+ if (!m) return 0;
306
+ const total = m.tasksCompleted + m.tasksErrored;
307
+ return total > 0 ? m.tasksErrored / total : 0;
308
+ }
309
+
310
+ function isAgentIdle(agentId) {
311
+ const status = getAgentStatus(agentId);
312
+ return ['idle', 'done', 'completed'].includes(status.status);
313
+ }
314
+
315
+ function resolveAgent(workType, config, authorAgent = null) {
316
+ const routes = parseRoutingTable();
317
+ const route = routes[workType] || routes['implement'];
318
+ const agents = config.agents || {};
319
+
320
+ // Resolve _author_ token
321
+ let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
322
+ let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
323
+
324
+ // Check preferred and fallback first (routing table order)
325
+ if (preferred && agents[preferred] && isAgentIdle(preferred)) return preferred;
326
+ if (fallback && agents[fallback] && isAgentIdle(fallback)) return fallback;
327
+
328
+ // Fall back to any idle agent, preferring lower error rates
329
+ const idle = Object.keys(agents)
330
+ .filter(id => id !== preferred && id !== fallback && isAgentIdle(id))
331
+ .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
332
+
333
+ return idle[0] || null;
334
+ }
335
+
336
+ // ─── Playbook Renderer ──────────────────────────────────────────────────────
337
+
338
+ function renderPlaybook(type, vars) {
339
+ const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
340
+ let content;
341
+ try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
342
+ log('warn', `Playbook not found: ${type}`);
343
+ return null;
344
+ }
345
+
346
+ // Inject team notes context
347
+ const notes = getNotes();
348
+ if (notes) {
349
+ content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
350
+ }
351
+
352
+ // Inject learnings requirement
353
+ content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
354
+ content += `After completing your task, you MUST write a findings/learnings file to:\n`;
355
+ content += `\`${SQUAD_DIR}/notes/inbox/${vars.agent_id || 'agent'}-${dateStamp()}.md\`\n\n`;
356
+ content += `Include:\n`;
357
+ content += `- What you learned about the codebase\n`;
358
+ content += `- Patterns you discovered or established\n`;
359
+ content += `- Gotchas or warnings for future agents\n`;
360
+ content += `- Conventions to follow\n\n`;
361
+ content += `### Skill Extraction (IMPORTANT)\n\n`;
362
+ content += `If during this task you discovered a **repeatable workflow** — a multi-step procedure, workaround, build process, or pattern that other agents should follow in similar situations — output it as a fenced skill block. The engine will automatically extract it.\n\n`;
363
+ content += `Format your skill as a fenced code block with the \`skill\` language tag:\n\n`;
364
+ content += '````\n```skill\n';
365
+ content += `---\nname: short-descriptive-name\ndescription: One-line description of what this skill does\nallowed-tools: Bash, Read, Edit\ntrigger: when should an agent use this\nscope: squad\nproject: any\n---\n\n# Skill Title\n\n## Steps\n1. ...\n2. ...\n\n## Notes\n...\n`;
366
+ content += '```\n````\n\n';
367
+ content += `- Set \`scope: squad\` for cross-project skills (engine writes to \`${SKILLS_DIR}/\` automatically)\n`;
368
+ content += `- Set \`scope: project\` + \`project: <name>\` for repo-specific skills (engine queues a PR)\n`;
369
+ content += `- Only output a skill block if you genuinely discovered something reusable — don't force it\n`;
370
+
371
+ // Inject project-level variables from config
372
+ const config = getConfig();
373
+ const project = config.project || {};
374
+ // Find the specific project being dispatched (match by repo_id or repo_name from vars)
375
+ const dispatchProject = (vars.repo_id && config.projects?.find(p => p.repositoryId === vars.repo_id))
376
+ || (vars.repo_name && config.projects?.find(p => p.repoName === vars.repo_name))
377
+ || project;
378
+ const projectVars = {
379
+ project_name: project.name || 'Unknown Project',
380
+ ado_org: project.adoOrg || 'Unknown',
381
+ ado_project: project.adoProject || 'Unknown',
382
+ repo_name: project.repoName || 'Unknown',
383
+ pr_create_instructions: getPrCreateInstructions(dispatchProject),
384
+ pr_comment_instructions: getPrCommentInstructions(dispatchProject),
385
+ pr_fetch_instructions: getPrFetchInstructions(dispatchProject),
386
+ pr_vote_instructions: getPrVoteInstructions(dispatchProject),
387
+ repo_host_label: getRepoHostLabel(dispatchProject),
388
+ };
389
+ const allVars = { ...projectVars, ...vars };
390
+
391
+ // Substitute variables
392
+ for (const [key, val] of Object.entries(allVars)) {
393
+ content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
394
+ }
395
+
396
+ return content;
397
+ }
398
+
399
+ // ─── Repo Host Helpers ──────────────────────────────────────────────────────
400
+
401
+ function getRepoHost(project) {
402
+ return project?.repoHost || 'ado';
403
+ }
404
+
405
+ function getPrCreateInstructions(project) {
406
+ const host = getRepoHost(project);
407
+ const repoId = project?.repositoryId || '';
408
+ if (host === 'github') {
409
+ return `Use \`gh pr create\` or the GitHub MCP tools to create a pull request.`;
410
+ }
411
+ // Default: Azure DevOps
412
+ return `Use \`mcp__azure-ado__repo_create_pull_request\`:\n- repositoryId: \`${repoId}\``;
413
+ }
414
+
415
+ function getPrCommentInstructions(project) {
416
+ const host = getRepoHost(project);
417
+ const repoId = project?.repositoryId || '';
418
+ if (host === 'github') {
419
+ return `Use \`gh pr comment\` or the GitHub MCP tools to post a comment on the PR.`;
420
+ }
421
+ return `Use \`mcp__azure-ado__repo_create_pull_request_thread\`:\n- repositoryId: \`${repoId}\``;
422
+ }
423
+
424
+ function getPrFetchInstructions(project) {
425
+ const host = getRepoHost(project);
426
+ if (host === 'github') {
427
+ return `Use \`gh pr view\` or the GitHub MCP tools to fetch PR status.`;
428
+ }
429
+ return `Use \`mcp__azure-ado__repo_get_pull_request_by_id\` to fetch PR status.`;
430
+ }
431
+
432
+ function getPrVoteInstructions(project) {
433
+ const host = getRepoHost(project);
434
+ const repoId = project?.repositoryId || '';
435
+ if (host === 'github') {
436
+ return `Use \`gh pr review\` to approve or request changes:\n- \`gh pr review <number> --approve\`\n- \`gh pr review <number> --request-changes\``;
437
+ }
438
+ return `Use \`mcp__azure-ado__repo_update_pull_request_reviewers\`:\n- repositoryId: \`${repoId}\`\n- Set your reviewer vote on the PR (10=approve, 5=approve-with-suggestions, -10=reject)`;
439
+ }
440
+
441
+ function getRepoHostLabel(project) {
442
+ const host = getRepoHost(project);
443
+ if (host === 'github') return 'GitHub';
444
+ return 'Azure DevOps';
445
+ }
446
+
447
+ function getRepoHostToolRule(project) {
448
+ const host = getRepoHost(project);
449
+ if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
450
+ return 'Use Azure DevOps MCP tools (mcp__azure-ado__*) for PR operations — NEVER use gh CLI';
451
+ }
452
+
453
+ // ─── System Prompt Builder ──────────────────────────────────────────────────
454
+
455
+ function buildSystemPrompt(agentId, config, project) {
456
+ const agent = config.agents[agentId];
457
+ const charter = getAgentCharter(agentId);
458
+ const notes = getNotes();
459
+ project = project || config.project || {};
460
+
461
+ let prompt = '';
462
+
463
+ // Agent identity
464
+ prompt += `# You are ${agent.name} (${agent.role})\n\n`;
465
+ prompt += `Agent ID: ${agentId}\n`;
466
+ prompt += `Skills: ${(agent.skills || []).join(', ')}\n\n`;
467
+
468
+ // Charter (detailed instructions)
469
+ if (charter) {
470
+ prompt += `## Your Charter\n\n${charter}\n\n`;
471
+ }
472
+
473
+ // Agent history (past tasks)
474
+ const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
475
+ if (history && history.trim() !== '# Agent History') {
476
+ prompt += `## Your Recent History\n\n${history}\n\n`;
477
+ }
478
+
479
+ // Project context
480
+ prompt += `## Project: ${project.name || 'Unknown Project'}\n\n`;
481
+ prompt += `- Repo: ${project.repoName || 'Unknown'} (${project.adoOrg || 'Unknown'}/${project.adoProject || 'Unknown'})\n`;
482
+ prompt += `- Repo ID: ${project.repositoryId || ''}\n`;
483
+ prompt += `- Repo host: ${getRepoHostLabel(project)}\n`;
484
+ prompt += `- Main branch: ${project.mainBranch || 'main'}\n\n`;
485
+
486
+ // Project conventions (from CLAUDE.md)
487
+ if (project.localPath) {
488
+ const claudeMd = safeRead(path.join(project.localPath, 'CLAUDE.md'));
489
+ if (claudeMd && claudeMd.trim()) {
490
+ // Truncate to 4KB to avoid bloating the system prompt
491
+ const truncated = claudeMd.length > 4096 ? claudeMd.slice(0, 4096) + '\n\n...(truncated)' : claudeMd;
492
+ prompt += `## Project Conventions (from CLAUDE.md)\n\n${truncated}\n\n`;
493
+ }
494
+ }
495
+
496
+ // Critical rules
497
+ prompt += `## Critical Rules\n\n`;
498
+ prompt += `1. Use git worktrees — NEVER checkout on main working tree\n`;
499
+ prompt += `2. ${getRepoHostToolRule(project)}\n`;
500
+ prompt += `3. Follow the project conventions above (from CLAUDE.md) if present\n`;
501
+ prompt += `4. Write learnings to: ${SQUAD_DIR}/notes/inbox/${agentId}-${dateStamp()}.md\n`;
502
+ prompt += `5. Do NOT write to agents/*/status.json — the engine manages agent status automatically\n`;
503
+ prompt += `6. If you discover a repeatable workflow, save it as a skill:\n`;
504
+ prompt += ` - Squad-wide: \`${SKILLS_DIR}/<name>.md\` (no PR needed)\n`;
505
+ prompt += ` - Project-specific: \`<project>/.claude/skills/<name>.md\` (requires a PR since it modifies the repo)\n\n`;
506
+
507
+ // Skills
508
+ const skillIndex = getSkillIndex();
509
+ if (skillIndex) {
510
+ prompt += skillIndex + '\n';
511
+ }
512
+
513
+ // Team notes
514
+ if (notes) {
515
+ prompt += `## Team Notes\n\n${notes}\n\n`;
516
+ }
517
+
518
+ return prompt;
519
+ }
520
+
521
+ function sanitizeBranch(name) {
522
+ return String(name).replace(/[^a-zA-Z0-9._\-\/]/g, '-').slice(0, 200);
523
+ }
524
+
525
+ // ─── Agent Spawner ──────────────────────────────────────────────────────────
526
+
527
+ const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt }
528
+
529
+ function spawnAgent(dispatchItem, config) {
530
+ const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
531
+ const claudeConfig = config.claude || {};
532
+ const engineConfig = config.engine || {};
533
+ const startedAt = ts();
534
+
535
+ // Resolve project context for this dispatch
536
+ const project = meta?.project || config.project || {};
537
+ const rootDir = project.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
538
+
539
+ // Determine working directory
540
+ let cwd = rootDir;
541
+ let worktreePath = null;
542
+ let branchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
543
+
544
+ if (branchName) {
545
+ worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', branchName);
546
+ try {
547
+ if (!fs.existsSync(worktreePath)) {
548
+ log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
549
+ execSync(`git worktree add "${worktreePath}" -b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, {
550
+ cwd: rootDir, stdio: 'pipe'
551
+ });
552
+ }
553
+ cwd = worktreePath;
554
+ } catch (err) {
555
+ log('error', `Failed to create worktree for ${branchName}: ${err.message}`);
556
+ // Fall back to main directory for non-writing tasks
557
+ if (type === 'review' || type === 'analyze' || type === 'plan-to-prd') {
558
+ cwd = rootDir;
559
+ } else {
560
+ completeDispatch(id, 'error', 'Worktree creation failed');
561
+ return null;
562
+ }
563
+ }
564
+ }
565
+
566
+ // Build the system prompt
567
+ const systemPrompt = buildSystemPrompt(agentId, config, project);
568
+
569
+ // Write prompt and system prompt to temp files (avoids shell escaping issues)
570
+ const promptPath = path.join(ENGINE_DIR, `prompt-${id}.md`);
571
+ safeWrite(promptPath, taskPrompt);
572
+
573
+ const sysPromptPath = path.join(ENGINE_DIR, `sysprompt-${id}.md`);
574
+ safeWrite(sysPromptPath, systemPrompt);
575
+
576
+ // Build claude CLI args
577
+ const args = [
578
+ '--output-format', claudeConfig.outputFormat || 'stream-json',
579
+ '--max-turns', String(engineConfig.maxTurns || 100),
580
+ '--verbose',
581
+ '--permission-mode', claudeConfig.permissionMode || 'bypassPermissions'
582
+ ];
583
+
584
+ if (claudeConfig.allowedTools) {
585
+ args.push('--allowedTools', claudeConfig.allowedTools);
586
+ }
587
+
588
+ // MCP servers — pass config file if it exists
589
+ const mcpConfigPath = claudeConfig.mcpConfig || path.join(SQUAD_DIR, 'mcp-servers.json');
590
+ if (fs.existsSync(mcpConfigPath)) {
591
+ args.push('--mcp-config', mcpConfigPath);
592
+ }
593
+
594
+ log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
595
+ log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
596
+
597
+ // Update agent status
598
+ setAgentStatus(agentId, {
599
+ status: 'working',
600
+ task: dispatchItem.task,
601
+ dispatch_id: id,
602
+ type: type,
603
+ branch: branchName,
604
+ started_at: startedAt
605
+ });
606
+
607
+ // Spawn the claude process
608
+ // Unset CLAUDECODE to allow nested sessions (engine runs inside Claude Code)
609
+ const childEnv = { ...process.env };
610
+ for (const key of Object.keys(childEnv)) {
611
+ if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) {
612
+ delete childEnv[key];
613
+ }
614
+ }
615
+
616
+ // Spawn via wrapper script — node directly (no bash intermediary)
617
+ // spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
618
+ const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
619
+ const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
620
+
621
+ const proc = spawn(process.execPath, spawnArgs, {
622
+ cwd,
623
+ stdio: ['pipe', 'pipe', 'pipe'],
624
+ env: childEnv
625
+ });
626
+
627
+ const MAX_OUTPUT = 1024 * 1024; // 1MB
628
+ let stdout = '';
629
+ let stderr = '';
630
+
631
+ // Live output file — written as data arrives so dashboard can tail it
632
+ const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
633
+ safeWrite(liveOutputPath, `# Live output for ${agentId} — ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n\n`);
634
+
635
+ proc.stdout.on('data', (data) => {
636
+ const chunk = data.toString();
637
+ if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
638
+ try { fs.appendFileSync(liveOutputPath, chunk); } catch {}
639
+ });
640
+
641
+ proc.stderr.on('data', (data) => {
642
+ const chunk = data.toString();
643
+ if (stderr.length < MAX_OUTPUT) stderr += chunk.slice(0, MAX_OUTPUT - stderr.length);
644
+ try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch {}
645
+ });
646
+
647
+ proc.on('close', (code) => {
648
+ log('info', `Agent ${agentId} (${id}) exited with code ${code}`);
649
+ activeProcesses.delete(id);
650
+
651
+ // Save output — per-dispatch archive + latest symlink
652
+ const outputContent = `# Output for dispatch ${id}\n# Exit code: ${code}\n# Completed: ${ts()}\n\n## stdout\n${stdout}\n\n## stderr\n${stderr}`;
653
+ const archivePath = path.join(AGENTS_DIR, agentId, `output-${id}.log`);
654
+ const latestPath = path.join(AGENTS_DIR, agentId, 'output.log');
655
+ safeWrite(archivePath, outputContent);
656
+ safeWrite(latestPath, outputContent); // overwrite latest for dashboard compat
657
+
658
+ // Update agent status
659
+ setAgentStatus(agentId, {
660
+ status: code === 0 ? 'done' : 'error',
661
+ task: dispatchItem.task,
662
+ dispatch_id: id,
663
+ type: type,
664
+ branch: branchName,
665
+ exit_code: code,
666
+ started_at: startedAt,
667
+ completed_at: ts()
668
+ });
669
+
670
+ // Move from active to completed in dispatch
671
+ completeDispatch(id, code === 0 ? 'success' : 'error');
672
+
673
+ // Post-completion: update work item status on success
674
+ if (code === 0 && meta?.item?.id) {
675
+ updateWorkItemStatus(meta, 'done', '');
676
+ }
677
+
678
+ // Post-completion: scan output for PRs and sync to pull-requests.json
679
+ if (code === 0) {
680
+ syncPrsFromOutput(stdout, agentId, meta, config);
681
+ }
682
+
683
+ // Post-completion: update PR status if relevant
684
+ if (type === 'review') updatePrAfterReview(agentId, meta?.pr, meta?.project);
685
+ if (type === 'fix') updatePrAfterFix(meta?.pr, meta?.project);
686
+
687
+ // Check for learnings
688
+ checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
689
+
690
+ // Extract skills from output
691
+ if (code === 0) {
692
+ extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
693
+ }
694
+
695
+ // Update agent history
696
+ updateAgentHistory(agentId, dispatchItem, code === 0 ? 'success' : 'error');
697
+
698
+ // Update quality metrics
699
+ updateMetrics(agentId, dispatchItem, code === 0 ? 'success' : 'error');
700
+
701
+ // Cleanup temp files
702
+ try { fs.unlinkSync(sysPromptPath); } catch {}
703
+ try { fs.unlinkSync(promptPath); } catch {}
704
+
705
+ log('info', `Agent ${agentId} completed. Output saved to ${outputPath}`);
706
+ });
707
+
708
+ proc.on('error', (err) => {
709
+ log('error', `Failed to spawn agent ${agentId}: ${err.message}`);
710
+ activeProcesses.delete(id);
711
+ completeDispatch(id, 'error', `Spawn error: ${err.message}`);
712
+ setAgentStatus(agentId, {
713
+ status: 'error',
714
+ task: dispatchItem.task,
715
+ error: err.message,
716
+ completed_at: ts()
717
+ });
718
+ });
719
+
720
+ // Safety: if process exits immediately (within 3s), log it
721
+ setTimeout(() => {
722
+ if (proc.exitCode !== null && !proc.killed) {
723
+ log('warn', `Agent ${agentId} (${id}) exited within 3s with code ${proc.exitCode}`);
724
+ }
725
+ }, 3000);
726
+
727
+ // Track process — even if PID isn't available yet (async on Windows)
728
+ activeProcesses.set(id, { proc, agentId, startedAt });
729
+
730
+ // Log PID and persist to registry
731
+ if (proc.pid) {
732
+ log('info', `Agent process started: PID ${proc.pid}`);
733
+ } else {
734
+ log('warn', `Agent spawn returned no PID initially — will verify via PID file`);
735
+ }
736
+
737
+ // Verify spawn after 5 seconds via PID file written by spawn-agent.js
738
+ setTimeout(() => {
739
+ const pidFile = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
740
+ try {
741
+ const pidStr = fs.readFileSync(pidFile, 'utf8').trim();
742
+ if (pidStr) {
743
+ log('info', `Agent ${agentId} verified via PID file: ${pidStr}`);
744
+ }
745
+ try { fs.unlinkSync(pidFile); } catch {}
746
+ } catch {
747
+ // No PID file — check if live output exists (spawn-agent.js may have written it)
748
+ if (!fs.existsSync(liveOutputPath) || fs.statSync(liveOutputPath).size <= 200) {
749
+ log('error', `Agent ${agentId} (${id}) — no PID file and no output after 5s. Spawn likely failed.`);
750
+ // Don't mark as error yet — heartbeat will catch it at 5min
751
+ }
752
+ }
753
+ }, 5000);
754
+
755
+ // Move dispatch item to active
756
+ const dispatch = getDispatch();
757
+ const idx = dispatch.pending.findIndex(d => d.id === id);
758
+ if (idx >= 0) {
759
+ const item = dispatch.pending.splice(idx, 1)[0];
760
+ item.started_at = startedAt;
761
+ dispatch.active = dispatch.active || [];
762
+ dispatch.active.push(item);
763
+ safeWrite(DISPATCH_PATH, dispatch);
764
+ }
765
+
766
+ return proc;
767
+ }
768
+
769
+ // ─── Dispatch Management ────────────────────────────────────────────────────
770
+
771
+ function addToDispatch(item) {
772
+ const dispatch = getDispatch();
773
+ item.id = item.id || `${item.agent}-${item.type}-${Date.now()}`;
774
+ item.created_at = ts();
775
+ dispatch.pending.push(item);
776
+ safeWrite(DISPATCH_PATH, dispatch);
777
+ log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
778
+ return item.id;
779
+ }
780
+
781
+ function completeDispatch(id, result = 'success', reason = '') {
782
+ const dispatch = getDispatch();
783
+
784
+ // Check active list first
785
+ let idx = (dispatch.active || []).findIndex(d => d.id === id);
786
+ let item;
787
+ if (idx >= 0) {
788
+ item = dispatch.active.splice(idx, 1)[0];
789
+ } else {
790
+ // Also check pending list (e.g., worktree failure before spawn)
791
+ idx = dispatch.pending.findIndex(d => d.id === id);
792
+ if (idx >= 0) {
793
+ item = dispatch.pending.splice(idx, 1)[0];
794
+ }
795
+ }
796
+
797
+ if (item) {
798
+ item.completed_at = ts();
799
+ item.result = result;
800
+ if (reason) item.reason = reason;
801
+ dispatch.completed = dispatch.completed || [];
802
+ dispatch.completed.push(item);
803
+ // Keep last 100 completed
804
+ if (dispatch.completed.length > 100) {
805
+ dispatch.completed.splice(0, dispatch.completed.length - 100);
806
+ }
807
+ safeWrite(DISPATCH_PATH, dispatch);
808
+ log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
809
+
810
+ // Update source work item status on failure + apply backoff cooldown
811
+ if (result === 'error') {
812
+ if (item.meta?.dispatchKey) setCooldownFailure(item.meta.dispatchKey);
813
+ if (item.meta?.item?.id) updateWorkItemStatus(item.meta, 'failed', reason);
814
+ }
815
+ }
816
+ }
817
+
818
+ function updateWorkItemStatus(meta, status, reason) {
819
+ const itemId = meta.item?.id;
820
+ if (!itemId) return;
821
+
822
+ // Handle plan-sourced items — update status in the plan JSON file
823
+ if (meta.source === 'plan' && meta.planFile) {
824
+ const planPath = path.join(PLANS_DIR, meta.planFile);
825
+ const plan = safeJson(planPath);
826
+ if (plan?.missing_features) {
827
+ const feature = plan.missing_features.find(f => f.id === itemId);
828
+ if (feature) {
829
+ feature.status = status === 'done' ? 'implemented' : status === 'failed' ? 'failed' : feature.status;
830
+ if (status === 'done') feature.implementedAt = ts();
831
+ if (status === 'failed' && reason) feature.failReason = reason;
832
+ safeWrite(planPath, plan);
833
+ log('info', `Plan item ${itemId} (${meta.planFile}) → ${feature.status}`);
834
+ }
835
+ }
836
+ return;
837
+ }
838
+
839
+ // Determine which work-items file to update
840
+ let wiPath;
841
+ if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
842
+ wiPath = path.join(SQUAD_DIR, 'work-items.json');
843
+ } else if (meta.source === 'work-item' && meta.project?.localPath) {
844
+ const root = path.resolve(meta.project.localPath);
845
+ const config = getConfig();
846
+ const proj = (config.projects || []).find(p => p.name === meta.project.name);
847
+ const wiSrc = proj?.workSources?.workItems || config.workSources?.workItems || {};
848
+ wiPath = path.resolve(root, wiSrc.path || '.squad/work-items.json');
849
+ }
850
+ if (!wiPath) return;
851
+
852
+ const items = safeJson(wiPath);
853
+ if (!items || !Array.isArray(items)) return;
854
+
855
+ const target = items.find(i => i.id === itemId);
856
+ if (target) {
857
+ // For fan-out items, track per-agent results instead of overwriting the whole status
858
+ if (meta.source === 'central-work-item-fanout') {
859
+ if (!target.agentResults) target.agentResults = {};
860
+ const agentId = meta.dispatchKey?.split('-')[0] || 'unknown';
861
+ // Extract agent name from dispatch key: "central-work-W002-dallas" → "dallas"
862
+ const parts = (meta.dispatchKey || '').split('-');
863
+ const agent = parts[parts.length - 1] || 'unknown';
864
+ target.agentResults[agent] = { status, completedAt: ts(), reason: reason || undefined };
865
+
866
+ // Fan-out is "done" if ANY agent succeeded, "failed" only if ALL failed
867
+ const results = Object.values(target.agentResults);
868
+ const anySuccess = results.some(r => r.status === 'done');
869
+ const allDone = target.fanOutAgents ? results.length >= target.fanOutAgents.length : false;
870
+
871
+ if (anySuccess) {
872
+ target.status = 'done';
873
+ delete target.failReason;
874
+ delete target.failedAt;
875
+ target.completedAgents = Object.entries(target.agentResults)
876
+ .filter(([, r]) => r.status === 'done')
877
+ .map(([a]) => a);
878
+ } else if (allDone) {
879
+ target.status = 'failed';
880
+ target.failReason = 'All fan-out agents failed';
881
+ target.failedAt = ts();
882
+ }
883
+ } else {
884
+ target.status = status;
885
+ if (status === 'done') {
886
+ // Clean up error fields on success
887
+ delete target.failReason;
888
+ delete target.failedAt;
889
+ target.completedAt = ts();
890
+ } else if (status === 'failed') {
891
+ if (reason) target.failReason = reason;
892
+ target.failedAt = ts();
893
+ }
894
+ }
895
+
896
+ safeWrite(wiPath, items);
897
+ log('info', `Work item ${itemId} → ${status}${reason ? ': ' + reason : ''}`);
898
+ }
899
+ }
900
+
901
+ // ─── PR Sync from Output ─────────────────────────────────────────────────────
902
+
903
+ function syncPrsFromOutput(output, agentId, meta, config) {
904
+ // Scan agent output for PR URLs — ONLY match full ADO PR URLs to avoid false positives
905
+ // Stream-json output contains many numbers that could look like PR IDs, so we
906
+ // only trust the full URL pattern: .../pullrequest/NNNNN
907
+ const prMatches = new Set();
908
+ const urlPattern = /(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+)/g;
909
+ let match;
910
+
911
+ // Strategy: only capture PRs the agent CREATED, not referenced.
912
+ // 1. Scan stream-json for mcp__azure-ado__repo_create_pull_request tool results
913
+ // 2. Scan for gh pr create output
914
+ // 3. As fallback, look for "Created PR" / "PR created" patterns in the result text
915
+ try {
916
+ const lines = output.split('\n');
917
+ for (const line of lines) {
918
+ try {
919
+ if (!line.includes('"type":"assistant"') && !line.includes('"type":"result"')) continue;
920
+ const parsed = JSON.parse(line);
921
+
922
+ // Check tool use results for PR creation MCP calls
923
+ const content = parsed.message?.content || [];
924
+ for (const block of content) {
925
+ if (block.type === 'tool_result' && block.content) {
926
+ const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
927
+ if (text.includes('pullRequestId') || text.includes('create_pull_request')) {
928
+ while ((match = urlPattern.exec(text)) !== null) prMatches.add(match[1] || match[2]);
929
+ }
930
+ }
931
+ }
932
+
933
+ // Check result text for "created PR" patterns (not just any PR mention)
934
+ if (parsed.type === 'result' && parsed.result) {
935
+ const resultText = parsed.result;
936
+ // Match PR URLs only near creation-indicating words
937
+ const createdPattern = /(?:created|opened|submitted|new PR|PR created)[^\n]*?(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
938
+ while ((match = createdPattern.exec(resultText)) !== null) prMatches.add(match[1] || match[2]);
939
+
940
+ // Also match "PR #NNNN" or "PR-NNNN" only when preceded by created/opened
941
+ const createdIdPattern = /(?:created|opened|submitted|new)\s+PR[# -]*(\d{5,})/gi;
942
+ while ((match = createdIdPattern.exec(resultText)) !== null) prMatches.add(match[1]);
943
+ }
944
+ } catch {}
945
+ }
946
+ } catch {}
947
+
948
+ // Also scan inbox files — look for "PR:" or "PR created" lines (agent's own summary)
949
+ const today = dateStamp();
950
+ const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
951
+ for (const f of inboxFiles) {
952
+ const content = safeRead(path.join(INBOX_DIR, f));
953
+ // Only match PR URLs near "PR:" header lines (agent's structured output)
954
+ const prHeaderPattern = /\*\*PR[:\*]*\*?\s*[#-]*\s*(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
955
+ while ((match = prHeaderPattern.exec(content)) !== null) prMatches.add(match[1] || match[2]);
956
+ }
957
+
958
+ if (prMatches.size === 0) return;
959
+
960
+ // Determine which project to add PRs to
961
+ const projects = getProjects(config);
962
+ let targetProject = null;
963
+ if (meta?.project?.name) {
964
+ targetProject = projects.find(p => p.name === meta.project.name);
965
+ }
966
+ // Try to detect project from PR URL patterns in output
967
+ if (!targetProject) {
968
+ for (const p of projects) {
969
+ if (p.prUrlBase && output.includes(p.prUrlBase.replace(/pullrequest\/$/, ''))) {
970
+ targetProject = p;
971
+ break;
972
+ }
973
+ if (p.repoName && output.includes(`_git/${p.repoName}`)) {
974
+ targetProject = p;
975
+ break;
976
+ }
977
+ }
978
+ }
979
+ if (!targetProject) targetProject = projects[0];
980
+ if (!targetProject) return;
981
+
982
+ const root = path.resolve(targetProject.localPath);
983
+ const prSrc = targetProject.workSources?.pullRequests || {};
984
+ const prPath = path.resolve(root, prSrc.path || '.squad/pull-requests.json');
985
+ const prs = safeJson(prPath) || [];
986
+
987
+ const agentName = config.agents?.[agentId]?.name || agentId;
988
+ let added = 0;
989
+
990
+ for (const prId of prMatches) {
991
+ const fullId = `PR-${prId}`;
992
+ if (prs.some(p => p.id === fullId || String(p.id).includes(prId))) continue;
993
+
994
+ // Extract title from output if possible — reject if it looks like stream-json garbage
995
+ let title = meta?.item?.title || '';
996
+ const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
997
+ if (titleMatch) title = titleMatch[1].trim();
998
+ // Reject corrupted titles
999
+ if (title.includes('session_id') || title.includes('is_error') || title.includes('uuid') || title.length > 120) {
1000
+ title = meta?.item?.title || '';
1001
+ }
1002
+
1003
+ prs.push({
1004
+ id: fullId,
1005
+ title: (title || `PR created by ${agentName}`).slice(0, 120),
1006
+ agent: agentName,
1007
+ branch: meta?.branch || '',
1008
+ reviewStatus: 'pending',
1009
+ status: 'active',
1010
+ created: dateStamp(),
1011
+ url: targetProject.prUrlBase ? targetProject.prUrlBase + prId : '',
1012
+ prdItems: meta?.item?.id ? [meta.item.id] : []
1013
+ });
1014
+ added++;
1015
+ }
1016
+
1017
+ if (added > 0) {
1018
+ safeWrite(prPath, prs);
1019
+ log('info', `Synced ${added} PR(s) from ${agentName}'s output to ${targetProject.name}/pull-requests.json`);
1020
+ }
1021
+ }
1022
+
1023
+ // ─── Post-Completion Hooks ──────────────────────────────────────────────────
1024
+
1025
+ function updatePrAfterReview(agentId, pr, project) {
1026
+ if (!pr?.id) return;
1027
+ const prs = getPrs(project);
1028
+ const target = prs.find(p => p.id === pr.id);
1029
+ if (!target) return;
1030
+
1031
+ const agentStatus = getAgentStatus(agentId);
1032
+ const verdict = agentStatus.verdict || 'reviewed';
1033
+ const config = getConfig();
1034
+ const reviewerName = config.agents[agentId]?.name || agentId;
1035
+
1036
+ const isChangesRequested = verdict.toLowerCase().includes('request') || verdict.toLowerCase().includes('change');
1037
+ const squadVerdict = isChangesRequested ? 'changes-requested' : 'approved';
1038
+
1039
+ // Store squad review separately from ADO review state
1040
+ target.squadReview = {
1041
+ status: squadVerdict,
1042
+ reviewer: reviewerName,
1043
+ reviewedAt: ts(),
1044
+ note: agentStatus.task || ''
1045
+ };
1046
+
1047
+ // Update author metrics
1048
+ const authorAgentId = (pr.agent || '').toLowerCase();
1049
+ if (authorAgentId && config.agents?.[authorAgentId]) {
1050
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
1051
+ const metrics = safeJson(metricsPath) || {};
1052
+ if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
1053
+ if (squadVerdict === 'approved') metrics[authorAgentId].prsApproved++;
1054
+ else if (squadVerdict === 'changes-requested') metrics[authorAgentId].prsRejected++;
1055
+ safeWrite(metricsPath, metrics);
1056
+ }
1057
+
1058
+ const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
1059
+ const prPath = project?.workSources?.pullRequests?.path || '.squad/pull-requests.json';
1060
+ safeWrite(path.resolve(root, prPath), prs);
1061
+ log('info', `Updated ${pr.id} → squad review: ${squadVerdict} by ${reviewerName}`);
1062
+
1063
+ // Create feedback for the PR author so they learn from the review
1064
+ createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
1065
+ }
1066
+
1067
+ function updatePrAfterFix(pr, project) {
1068
+ if (!pr?.id) return;
1069
+ const prs = getPrs(project);
1070
+ const target = prs.find(p => p.id === pr.id);
1071
+ if (!target) return;
1072
+
1073
+ // Reset squad review so it gets re-reviewed
1074
+ target.squadReview = {
1075
+ ...target.squadReview,
1076
+ status: 'waiting',
1077
+ note: 'Fixed, awaiting re-review',
1078
+ fixedAt: ts()
1079
+ };
1080
+
1081
+ const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
1082
+ const prPath = project?.workSources?.pullRequests?.path || '.squad/pull-requests.json';
1083
+ safeWrite(path.resolve(root, prPath), prs);
1084
+ log('info', `Updated ${pr.id} → squad review: waiting (fix pushed)`);
1085
+ }
1086
+
1087
+ // ─── ADO Build Status Polling ────────────────────────────────────────────────
1088
+
1089
+ let _adoTokenCache = { token: null, expiresAt: 0 };
1090
+
1091
+ function getAdoToken() {
1092
+ // Cache token for 30 minutes (they typically last 1hr)
1093
+ if (_adoTokenCache.token && Date.now() < _adoTokenCache.expiresAt) {
1094
+ return _adoTokenCache.token;
1095
+ }
1096
+ try {
1097
+ const token = execSync('azureauth ado token --output token', {
1098
+ timeout: 15000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
1099
+ }).trim();
1100
+ if (token && token.startsWith('eyJ')) {
1101
+ _adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
1102
+ return token;
1103
+ }
1104
+ } catch (e) {
1105
+ log('warn', `Failed to get ADO token: ${e.message}`);
1106
+ }
1107
+ return null;
1108
+ }
1109
+
1110
+ async function adoFetch(url, token) {
1111
+ const res = await fetch(url, {
1112
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
1113
+ });
1114
+ if (!res.ok) throw new Error(`ADO API ${res.status}: ${res.statusText}`);
1115
+ const text = await res.text();
1116
+ if (!text || text.trimStart().startsWith('<')) {
1117
+ throw new Error(`ADO returned HTML instead of JSON (likely auth redirect) for ${url.split('?')[0]}`);
1118
+ }
1119
+ return JSON.parse(text);
1120
+ }
1121
+
1122
+ /**
1123
+ * Poll ADO for full PR status: build/CI, review votes, and merge/completion state.
1124
+ * Replaces the agent-based pr-sync — does everything in direct API calls.
1125
+ *
1126
+ * Per PR, makes 2 calls:
1127
+ * 1. GET pullrequests/{id} → status, mergeStatus, reviewers[].vote
1128
+ * 2. GET pullrequests/{id}/statuses → CI pipeline results
1129
+ */
1130
+ async function pollPrStatus(config) {
1131
+ const token = getAdoToken();
1132
+ if (!token) {
1133
+ log('warn', 'Skipping PR status poll — no ADO token available');
1134
+ return;
1135
+ }
1136
+
1137
+ const projects = getProjects(config);
1138
+ let totalUpdated = 0;
1139
+
1140
+ for (const project of projects) {
1141
+ if (!project.adoOrg || !project.adoProject || !project.repositoryId) continue;
1142
+
1143
+ const prs = getPrs(project);
1144
+ const activePrs = prs.filter(pr => pr.status === 'active');
1145
+ if (activePrs.length === 0) continue;
1146
+
1147
+ let projectUpdated = 0;
1148
+
1149
+ for (const pr of activePrs) {
1150
+ const prNum = (pr.id || '').replace('PR-', '');
1151
+ if (!prNum) continue;
1152
+
1153
+ try {
1154
+ let orgBase;
1155
+ if (project.prUrlBase) {
1156
+ const m = project.prUrlBase.match(/^(https?:\/\/[^/]+(?:\/DefaultCollection)?)/);
1157
+ if (m) orgBase = m[1];
1158
+ }
1159
+ if (!orgBase) {
1160
+ orgBase = project.adoOrg.includes('.')
1161
+ ? `https://${project.adoOrg}`
1162
+ : `https://dev.azure.com/${project.adoOrg}`;
1163
+ }
1164
+
1165
+ const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}`;
1166
+
1167
+ // ── Call 1: PR details (status, mergeStatus, reviewers) ──
1168
+ const prData = await adoFetch(`${repoBase}?api-version=7.1`, token);
1169
+
1170
+ // Status: active / completed / abandoned
1171
+ let newStatus = pr.status;
1172
+ if (prData.status === 'completed') newStatus = 'merged';
1173
+ else if (prData.status === 'abandoned') newStatus = 'abandoned';
1174
+ else if (prData.status === 'active') newStatus = 'active';
1175
+
1176
+ if (pr.status !== newStatus) {
1177
+ log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
1178
+ pr.status = newStatus;
1179
+ projectUpdated++;
1180
+
1181
+ // Post-merge / post-close hooks
1182
+ if (newStatus === 'merged' || newStatus === 'abandoned') {
1183
+ await handlePostMerge(pr, project, config, newStatus);
1184
+ }
1185
+ }
1186
+
1187
+ // Review status from human reviewers (ADO votes)
1188
+ // 10=approved, 5=approved-with-suggestions, 0=no-vote, -5=waiting, -10=rejected
1189
+ const reviewers = prData.reviewers || [];
1190
+ const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
1191
+ let newReviewStatus = pr.reviewStatus || 'pending';
1192
+ if (votes.length > 0) {
1193
+ if (votes.some(v => v === -10)) newReviewStatus = 'changes-requested';
1194
+ else if (votes.some(v => v >= 5)) newReviewStatus = 'approved';
1195
+ else if (votes.some(v => v === -5)) newReviewStatus = 'waiting';
1196
+ else newReviewStatus = 'pending';
1197
+ }
1198
+
1199
+ if (pr.reviewStatus !== newReviewStatus) {
1200
+ log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
1201
+ pr.reviewStatus = newReviewStatus;
1202
+ projectUpdated++;
1203
+ }
1204
+
1205
+ // Skip build status poll for non-active PRs
1206
+ if (newStatus !== 'active') continue;
1207
+
1208
+ // ── Call 2: CI/build statuses ──
1209
+ const statusData = await adoFetch(`${repoBase}/statuses?api-version=7.1`, token);
1210
+
1211
+ // Dedupe: latest status per context (API returns newest first)
1212
+ const latest = new Map();
1213
+ for (const s of statusData.value || []) {
1214
+ const key = (s.context?.genre || '') + '/' + (s.context?.name || '');
1215
+ if (!latest.has(key)) latest.set(key, s);
1216
+ }
1217
+
1218
+ // Filter to build/CI contexts
1219
+ const buildStatuses = [...latest.values()].filter(s => {
1220
+ const ctx = ((s.context?.genre || '') + '/' + (s.context?.name || '')).toLowerCase();
1221
+ return ctx.includes('codecoverage') || ctx.includes('build') ||
1222
+ ctx.includes('deploy') || ctx.includes('ci/');
1223
+ });
1224
+
1225
+ let buildStatus = 'none';
1226
+ let buildFailReason = '';
1227
+
1228
+ if (buildStatuses.length > 0) {
1229
+ const states = buildStatuses.map(s => s.state).filter(Boolean);
1230
+ const hasFailed = states.some(s => s === 'failed' || s === 'error');
1231
+ const allDone = states.every(s => s === 'succeeded' || s === 'notApplicable');
1232
+ const hasQueued = buildStatuses.some(s => !s.state);
1233
+
1234
+ if (hasFailed) {
1235
+ buildStatus = 'failing';
1236
+ const failed = buildStatuses.find(s => s.state === 'failed' || s.state === 'error');
1237
+ buildFailReason = failed?.description || failed?.context?.name || 'Build failed';
1238
+ } else if (allDone && !hasQueued) {
1239
+ buildStatus = 'passing';
1240
+ } else {
1241
+ buildStatus = 'running';
1242
+ }
1243
+ }
1244
+
1245
+ if (pr.buildStatus !== buildStatus) {
1246
+ log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
1247
+ pr.buildStatus = buildStatus;
1248
+ if (buildFailReason) pr.buildFailReason = buildFailReason;
1249
+ else delete pr.buildFailReason;
1250
+ projectUpdated++;
1251
+ }
1252
+ } catch (e) {
1253
+ log('warn', `Failed to poll status for ${pr.id}: ${e.message}`);
1254
+ }
1255
+ }
1256
+
1257
+ if (projectUpdated > 0) {
1258
+ const root = path.resolve(project.localPath);
1259
+ const prSrc = project.workSources?.pullRequests || {};
1260
+ const prPath = path.resolve(root, prSrc.path || '.squad/pull-requests.json');
1261
+ safeWrite(prPath, prs);
1262
+ totalUpdated += projectUpdated;
1263
+ }
1264
+ }
1265
+
1266
+ if (totalUpdated > 0) {
1267
+ log('info', `PR status poll: updated ${totalUpdated} PR(s)`);
1268
+ }
1269
+ }
1270
+
1271
+ function checkForLearnings(agentId, agentInfo, taskDesc) {
1272
+ const today = dateStamp();
1273
+ const inboxFiles = getInboxFiles();
1274
+ const agentFiles = inboxFiles.filter(f => f.includes(agentId) && f.includes(today));
1275
+
1276
+ if (agentFiles.length > 0) {
1277
+ log('info', `${agentInfo?.name || agentId} wrote ${agentFiles.length} finding(s) to inbox`);
1278
+ return;
1279
+ }
1280
+
1281
+ log('warn', `${agentInfo?.name || agentId} didn't write learnings — no follow-up queued`);
1282
+ }
1283
+
1284
+ /**
1285
+ * Extract skill candidates from agent output.
1286
+ * Agents are prompted to output a ```skill block when they discover a reusable workflow.
1287
+ * The engine parses it out and writes the skill file automatically.
1288
+ */
1289
+ function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
1290
+ if (!output) return;
1291
+
1292
+ // Parse stream-json output to extract assistant text
1293
+ let fullText = '';
1294
+ for (const line of output.split('\n')) {
1295
+ try {
1296
+ const j = JSON.parse(line);
1297
+ if (j.type === 'assistant' && j.message?.content) {
1298
+ for (const c of j.message.content) {
1299
+ if (c.type === 'text') fullText += c.text + '\n';
1300
+ }
1301
+ }
1302
+ } catch {}
1303
+ }
1304
+ if (!fullText) fullText = output; // fallback for non-json output
1305
+
1306
+ // Look for ```skill blocks
1307
+ const skillBlocks = [];
1308
+ const skillRegex = /```skill\s*\n([\s\S]*?)```/g;
1309
+ let match;
1310
+ while ((match = skillRegex.exec(fullText)) !== null) {
1311
+ skillBlocks.push(match[1].trim());
1312
+ }
1313
+
1314
+ if (skillBlocks.length === 0) return;
1315
+
1316
+ const agentName = config.agents[agentId]?.name || agentId;
1317
+
1318
+ for (const block of skillBlocks) {
1319
+ // Parse the skill content — expect frontmatter
1320
+ const fmMatch = block.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1321
+ if (!fmMatch) {
1322
+ log('warn', `Skill block from ${agentName} has no frontmatter, skipping`);
1323
+ continue;
1324
+ }
1325
+
1326
+ const fm = fmMatch[1];
1327
+ const body = fmMatch[2];
1328
+ const m = (key) => { const r = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm')); return r ? r[1].trim() : ''; };
1329
+ const name = m('name');
1330
+ if (!name) {
1331
+ log('warn', `Skill block from ${agentName} has no name, skipping`);
1332
+ continue;
1333
+ }
1334
+
1335
+ const scope = m('scope') || 'squad';
1336
+ const project = m('project');
1337
+
1338
+ // Ensure author and created are set
1339
+ let enrichedBlock = block;
1340
+ if (!m('author')) {
1341
+ enrichedBlock = enrichedBlock.replace('---\n', `---\nauthor: ${agentName}\n`);
1342
+ }
1343
+ if (!m('created')) {
1344
+ enrichedBlock = enrichedBlock.replace('---\n', `---\ncreated: ${dateStamp()}\n`);
1345
+ }
1346
+
1347
+ const filename = name.replace(/[^a-z0-9-]/g, '-') + '.md';
1348
+
1349
+ if (scope === 'project' && project) {
1350
+ // Project-level skill — don't write directly, queue a work item to PR it
1351
+ const proj = getProjects(config).find(p => p.name === project);
1352
+ if (proj) {
1353
+ const centralPath = path.join(SQUAD_DIR, 'work-items.json');
1354
+ const items = safeJson(centralPath) || [];
1355
+ const alreadyExists = items.some(i => i.title === `Add skill: ${name}` && i.status !== 'failed');
1356
+ if (!alreadyExists) {
1357
+ const skillId = `SK${String(items.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
1358
+ items.push({
1359
+ id: skillId,
1360
+ type: 'implement',
1361
+ title: `Add skill: ${name}`,
1362
+ description: `Create project-level skill \`${filename}\` in ${project}.\n\nWrite this file to \`${proj.localPath}/.claude/skills/${filename}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
1363
+ priority: 'low',
1364
+ status: 'queued',
1365
+ created: ts(),
1366
+ createdBy: `engine:skill-extraction:${agentName}`
1367
+ });
1368
+ safeWrite(centralPath, items);
1369
+ log('info', `Queued work item ${skillId} to PR project skill "${name}" into ${project}`);
1370
+ }
1371
+ }
1372
+ } else {
1373
+ // Squad-level skill — write directly
1374
+ const skillPath = path.join(SKILLS_DIR, filename);
1375
+ if (!fs.existsSync(skillPath)) {
1376
+ safeWrite(skillPath, enrichedBlock);
1377
+ log('info', `Extracted squad skill "${name}" from ${agentName} → ${filename}`);
1378
+ } else {
1379
+ log('info', `Squad skill "${name}" already exists, skipping`);
1380
+ }
1381
+ }
1382
+ }
1383
+ }
1384
+
1385
+ function updateAgentHistory(agentId, dispatchItem, result) {
1386
+ const historyPath = path.join(AGENTS_DIR, agentId, 'history.md');
1387
+ let history = safeRead(historyPath) || '# Agent History\n\n';
1388
+
1389
+ const entry = `### ${ts()} — ${result}\n` +
1390
+ `- **Task:** ${dispatchItem.task}\n` +
1391
+ `- **Type:** ${dispatchItem.type}\n` +
1392
+ `- **Project:** ${dispatchItem.meta?.project?.name || 'central'}\n` +
1393
+ `- **Branch:** ${dispatchItem.meta?.branch || 'none'}\n` +
1394
+ `- **Dispatch ID:** ${dispatchItem.id}\n\n`;
1395
+
1396
+ // Insert after header
1397
+ const headerEnd = history.indexOf('\n\n');
1398
+ if (headerEnd >= 0) {
1399
+ history = history.slice(0, headerEnd + 2) + entry + history.slice(headerEnd + 2);
1400
+ } else {
1401
+ history += entry;
1402
+ }
1403
+
1404
+ // Keep last 20 entries
1405
+ const entries = history.split('### ').filter(Boolean);
1406
+ const header = entries[0].startsWith('#') ? entries.shift() : '# Agent History\n\n';
1407
+ const trimmed = entries.slice(0, 20);
1408
+ history = header + trimmed.map(e => '### ' + e).join('');
1409
+
1410
+ safeWrite(historyPath, history);
1411
+ log('info', `Updated history for ${agentId}`);
1412
+ }
1413
+
1414
+ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
1415
+ if (!pr?.id || !pr?.agent) return;
1416
+
1417
+ const authorAgentId = pr.agent.toLowerCase();
1418
+ if (!config.agents[authorAgentId]) return;
1419
+
1420
+ // Find reviewer's inbox files from today about this PR
1421
+ const today = dateStamp();
1422
+ const inboxFiles = getInboxFiles();
1423
+ const reviewFiles = inboxFiles.filter(f =>
1424
+ f.includes(reviewerAgentId) && f.includes(today)
1425
+ );
1426
+
1427
+ if (reviewFiles.length === 0) return;
1428
+
1429
+ // Read review content
1430
+ const reviewContent = reviewFiles.map(f =>
1431
+ safeRead(path.join(INBOX_DIR, f))
1432
+ ).join('\n\n');
1433
+
1434
+ // Create a feedback file for the author
1435
+ const feedbackFile = `feedback-${authorAgentId}-from-${reviewerAgentId}-${pr.id}-${today}.md`;
1436
+ const feedbackPath = path.join(INBOX_DIR, feedbackFile);
1437
+
1438
+ const content = `# Review Feedback for ${config.agents[authorAgentId]?.name || authorAgentId}\n\n` +
1439
+ `**PR:** ${pr.id} — ${pr.title || ''}\n` +
1440
+ `**Reviewer:** ${config.agents[reviewerAgentId]?.name || reviewerAgentId}\n` +
1441
+ `**Date:** ${today}\n\n` +
1442
+ `## What the reviewer found\n\n${reviewContent}\n\n` +
1443
+ `## Action Required\n\n` +
1444
+ `Read this feedback carefully. When you work on similar tasks in the future, ` +
1445
+ `avoid the patterns flagged here. If you are assigned to fix this PR, ` +
1446
+ `address every point raised above.\n`;
1447
+
1448
+ safeWrite(feedbackPath, content);
1449
+ log('info', `Created review feedback for ${authorAgentId} from ${reviewerAgentId} on ${pr.id}`);
1450
+ }
1451
+
1452
+ function updateMetrics(agentId, dispatchItem, result) {
1453
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
1454
+ const metrics = safeJson(metricsPath) || {};
1455
+
1456
+ if (!metrics[agentId]) {
1457
+ metrics[agentId] = {
1458
+ tasksCompleted: 0,
1459
+ tasksErrored: 0,
1460
+ prsCreated: 0,
1461
+ prsApproved: 0,
1462
+ prsRejected: 0,
1463
+ reviewsDone: 0,
1464
+ lastTask: null,
1465
+ lastCompleted: null
1466
+ };
1467
+ }
1468
+
1469
+ const m = metrics[agentId];
1470
+ m.lastTask = dispatchItem.task;
1471
+ m.lastCompleted = ts();
1472
+
1473
+ if (result === 'success') {
1474
+ m.tasksCompleted++;
1475
+ if (dispatchItem.type === 'implement') m.prsCreated++;
1476
+ if (dispatchItem.type === 'review') m.reviewsDone++;
1477
+ } else {
1478
+ m.tasksErrored++;
1479
+ }
1480
+
1481
+ safeWrite(metricsPath, metrics);
1482
+ }
1483
+
1484
+ // ─── Inbox Consolidation ────────────────────────────────────────────────────
1485
+
1486
+ function consolidateInbox(config) {
1487
+ const threshold = config.engine?.inboxConsolidateThreshold || 5;
1488
+ const files = getInboxFiles();
1489
+ if (files.length < threshold) return;
1490
+
1491
+ log('info', `Consolidating ${files.length} inbox items into notes.md`);
1492
+
1493
+ const items = files.map(f => ({
1494
+ name: f,
1495
+ content: safeRead(path.join(INBOX_DIR, f))
1496
+ }));
1497
+
1498
+ // Categorize by type based on filename patterns
1499
+ const categories = {
1500
+ reviews: [],
1501
+ feedback: [],
1502
+ learnings: [],
1503
+ other: []
1504
+ };
1505
+
1506
+ for (const item of items) {
1507
+ const name = item.name.toLowerCase();
1508
+ if (name.includes('review') || name.includes('pr-') || name.includes('pr4')) {
1509
+ categories.reviews.push(item);
1510
+ } else if (name.includes('feedback')) {
1511
+ categories.feedback.push(item);
1512
+ } else if (name.includes('build') || name.includes('explore') || name.includes('m0') || name.includes('m1')) {
1513
+ categories.learnings.push(item);
1514
+ } else {
1515
+ categories.other.push(item);
1516
+ }
1517
+ }
1518
+
1519
+ let entry = `\n\n---\n\n### ${dateStamp()}: Consolidated from ${items.length} inbox items\n`;
1520
+ entry += `**By:** Engine (auto-consolidation)\n\n`;
1521
+
1522
+ if (categories.reviews.length > 0) {
1523
+ entry += `#### Review Findings (${categories.reviews.length})\n`;
1524
+ for (const item of categories.reviews) {
1525
+ // Extract first meaningful line as summary
1526
+ const firstLine = item.content.split('\n').find(l => l.trim() && !l.startsWith('#')) || item.name;
1527
+ entry += `- **${item.name}**: ${firstLine.trim().slice(0, 150)}\n`;
1528
+ }
1529
+ entry += '\n';
1530
+ }
1531
+
1532
+ if (categories.feedback.length > 0) {
1533
+ entry += `#### Review Feedback (${categories.feedback.length})\n`;
1534
+ for (const item of categories.feedback) {
1535
+ const firstLine = item.content.split('\n').find(l => l.trim() && !l.startsWith('#')) || item.name;
1536
+ entry += `- **${item.name}**: ${firstLine.trim().slice(0, 150)}\n`;
1537
+ }
1538
+ entry += '\n';
1539
+ }
1540
+
1541
+ if (categories.learnings.length > 0) {
1542
+ entry += `#### Learnings & Conventions (${categories.learnings.length})\n`;
1543
+ for (const item of categories.learnings) {
1544
+ const firstLine = item.content.split('\n').find(l => l.trim() && !l.startsWith('#')) || item.name;
1545
+ entry += `- **${item.name}**: ${firstLine.trim().slice(0, 150)}\n`;
1546
+ }
1547
+ entry += '\n';
1548
+ }
1549
+
1550
+ if (categories.other.length > 0) {
1551
+ entry += `#### Other (${categories.other.length})\n`;
1552
+ for (const item of categories.other) {
1553
+ const firstLine = item.content.split('\n').find(l => l.trim() && !l.startsWith('#')) || item.name;
1554
+ entry += `- **${item.name}**: ${firstLine.trim().slice(0, 150)}\n`;
1555
+ }
1556
+ entry += '\n';
1557
+ }
1558
+
1559
+ const current = getNotes();
1560
+
1561
+ // Prune old consolidations if notes.md is getting too large (>50KB)
1562
+ let newContent = current + entry;
1563
+ if (newContent.length > 50000) {
1564
+ const sections = newContent.split('\n---\n\n### ');
1565
+ if (sections.length > 10) {
1566
+ // Keep header + last 8 consolidation sections
1567
+ const header = sections[0];
1568
+ const recent = sections.slice(-8);
1569
+ newContent = header + '\n---\n\n### ' + recent.join('\n---\n\n### ');
1570
+ log('info', `Pruned notes.md: removed ${sections.length - 9} old sections`);
1571
+ }
1572
+ }
1573
+
1574
+ safeWrite(NOTES_PATH, newContent);
1575
+
1576
+ // Archive
1577
+ if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
1578
+ for (const f of files) {
1579
+ try { fs.renameSync(path.join(INBOX_DIR, f), path.join(ARCHIVE_DIR, `${dateStamp()}-${f}`)); } catch {}
1580
+ }
1581
+
1582
+ log('info', `Consolidated ${files.length} items into notes.md (${Object.entries(categories).map(([k,v]) => `${v.length} ${k}`).join(', ')})`);
1583
+ }
1584
+
1585
+ // ─── State Snapshot ─────────────────────────────────────────────────────────
1586
+
1587
+ function updateSnapshot(config) {
1588
+ const dispatch = getDispatch();
1589
+ const agents = config.agents || {};
1590
+ const projects = getProjects(config);
1591
+
1592
+ let snapshot = `# Squad State — ${ts()}\n\n`;
1593
+ snapshot += `## Projects: ${projects.map(p => p.name).join(', ')}\n\n`;
1594
+
1595
+ snapshot += `## Agents\n\n`;
1596
+ snapshot += `| Agent | Role | Status | Task |\n`;
1597
+ snapshot += `|-------|------|--------|------|\n`;
1598
+ for (const [id, agent] of Object.entries(agents)) {
1599
+ const status = getAgentStatus(id);
1600
+ snapshot += `| ${agent.emoji} ${agent.name} | ${agent.role} | ${status.status} | ${status.task || '-'} |\n`;
1601
+ }
1602
+
1603
+ snapshot += `\n## Dispatch Queue\n\n`;
1604
+ snapshot += `- Pending: ${dispatch.pending.length}\n`;
1605
+ snapshot += `- Active: ${(dispatch.active || []).length}\n`;
1606
+ snapshot += `- Completed: ${(dispatch.completed || []).length}\n`;
1607
+
1608
+ if (dispatch.pending.length > 0) {
1609
+ snapshot += `\n### Pending\n`;
1610
+ for (const d of dispatch.pending) {
1611
+ snapshot += `- [${d.id}] ${d.type} → ${d.agent}: ${d.task}\n`;
1612
+ }
1613
+ }
1614
+ if ((dispatch.active || []).length > 0) {
1615
+ snapshot += `\n### Active\n`;
1616
+ for (const d of dispatch.active) {
1617
+ snapshot += `- [${d.id}] ${d.type} → ${d.agent}: ${d.task} (since ${d.started_at})\n`;
1618
+ }
1619
+ }
1620
+
1621
+ safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
1622
+ }
1623
+
1624
+ // ─── Timeout Checker ────────────────────────────────────────────────────────
1625
+
1626
+ function checkTimeouts(config) {
1627
+ const timeout = config.engine?.agentTimeout || 18000000; // 5h default
1628
+ const heartbeatTimeout = config.engine?.heartbeatTimeout || 300000; // 5min — no output = dead
1629
+
1630
+ // 1. Check tracked processes for hard timeout
1631
+ for (const [id, info] of activeProcesses.entries()) {
1632
+ const elapsed = Date.now() - new Date(info.startedAt).getTime();
1633
+ if (elapsed > timeout) {
1634
+ log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
1635
+ try { info.proc.kill('SIGTERM'); } catch {}
1636
+ setTimeout(() => {
1637
+ try { info.proc.kill('SIGKILL'); } catch {}
1638
+ }, 5000);
1639
+ }
1640
+ }
1641
+
1642
+ // 2. Heartbeat check — for ALL active dispatch items (catches orphans after engine restart)
1643
+ // Uses live-output.log mtime as heartbeat. If no output for heartbeatTimeout, agent is dead.
1644
+ const dispatch = getDispatch();
1645
+ const deadItems = [];
1646
+
1647
+ for (const item of (dispatch.active || [])) {
1648
+ if (!item.agent) continue;
1649
+
1650
+ const hasProcess = activeProcesses.has(item.id);
1651
+ const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
1652
+ let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;
1653
+
1654
+ // Check live-output.log mtime as heartbeat
1655
+ try {
1656
+ const stat = fs.statSync(liveLogPath);
1657
+ lastActivity = Math.max(lastActivity, stat.mtimeMs);
1658
+ } catch {}
1659
+
1660
+ const silentMs = Date.now() - lastActivity;
1661
+ const silentSec = Math.round(silentMs / 1000);
1662
+
1663
+ // Check if the agent actually completed (result event in live output)
1664
+ let completedViaOutput = false;
1665
+ try {
1666
+ const liveLog = safeRead(liveLogPath);
1667
+ if (liveLog && liveLog.includes('"type":"result"')) {
1668
+ completedViaOutput = true;
1669
+ const isSuccess = liveLog.includes('"subtype":"success"');
1670
+ log('info', `Agent ${item.agent} (${item.id}) completed via output detection (${isSuccess ? 'success' : 'error'})`);
1671
+
1672
+ // Extract output text for the output.log
1673
+ const outputPath = path.join(AGENTS_DIR, item.agent, 'output.log');
1674
+ try {
1675
+ const resultLine = liveLog.split('\n').find(l => l.includes('"type":"result"'));
1676
+ if (resultLine) {
1677
+ const result = JSON.parse(resultLine);
1678
+ safeWrite(outputPath, `# Output for dispatch ${item.id}\n# Exit code: ${isSuccess ? 0 : 1}\n# Completed: ${ts()}\n# Detected via output scan\n\n## Result\n${result.result || '(no text)'}\n`);
1679
+ }
1680
+ } catch {}
1681
+
1682
+ completeDispatch(item.id, isSuccess ? 'success' : 'error', 'Completed (detected from output)');
1683
+ if (item.meta?.item?.id) updateWorkItemStatus(item.meta, isSuccess ? 'done' : 'failed', '');
1684
+ setAgentStatus(item.agent, {
1685
+ status: 'done',
1686
+ task: item.task || '',
1687
+ dispatch_id: item.id,
1688
+ completed_at: ts()
1689
+ });
1690
+
1691
+ // Run post-completion hooks
1692
+ if (isSuccess) {
1693
+ const config = getConfig();
1694
+ syncPrsFromOutput(liveLog, item.agent, item.meta, config);
1695
+ checkForLearnings(item.agent, config.agents?.[item.agent], item.task);
1696
+ updateAgentHistory(item.agent, item, isSuccess ? 'success' : 'error');
1697
+ updateMetrics(item.agent, item, isSuccess ? 'success' : 'error');
1698
+ }
1699
+
1700
+ if (hasProcess) {
1701
+ try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch {}
1702
+ activeProcesses.delete(item.id);
1703
+ }
1704
+ continue; // Skip orphan/hung detection — we handled it
1705
+ }
1706
+ } catch {}
1707
+
1708
+ // Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
1709
+ // These tools produce no stdout for extended periods — don't kill them prematurely
1710
+ let isBlocking = false;
1711
+ let blockingTimeout = heartbeatTimeout;
1712
+ if (hasProcess && silentMs > heartbeatTimeout) {
1713
+ try {
1714
+ const liveLog = safeRead(liveLogPath);
1715
+ if (liveLog) {
1716
+ // Find the last tool_use call in the output — check if it's a known blocking tool
1717
+ const lines = liveLog.split('\n');
1718
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
1719
+ const line = lines[i];
1720
+ if (!line.includes('"tool_use"')) continue;
1721
+ try {
1722
+ const parsed = JSON.parse(line);
1723
+ const toolUse = parsed?.message?.content?.find?.(c => c.type === 'tool_use');
1724
+ if (!toolUse) continue;
1725
+ const input = toolUse.input || {};
1726
+ const name = toolUse.name || '';
1727
+ // TaskOutput with block:true — waiting for a background task
1728
+ if (name === 'TaskOutput' && input.block === true) {
1729
+ const taskTimeout = input.timeout || 600000; // default 10min
1730
+ blockingTimeout = Math.max(heartbeatTimeout, taskTimeout + 60000); // task timeout + 1min grace
1731
+ isBlocking = true;
1732
+ }
1733
+ // Bash with explicit long timeout (>5min)
1734
+ if (name === 'Bash' && input.timeout && input.timeout > heartbeatTimeout) {
1735
+ blockingTimeout = Math.max(heartbeatTimeout, input.timeout + 60000);
1736
+ isBlocking = true;
1737
+ }
1738
+ break; // only check the most recent tool_use
1739
+ } catch {}
1740
+ }
1741
+ if (isBlocking) {
1742
+ log('info', `Agent ${item.agent} (${item.id}) is in a blocking tool call — extended timeout to ${Math.round(blockingTimeout / 1000)}s (silent for ${silentSec}s)`);
1743
+ }
1744
+ }
1745
+ } catch {}
1746
+ }
1747
+
1748
+ const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
1749
+
1750
+ if (!hasProcess && silentMs > heartbeatTimeout) {
1751
+ // No tracked process AND no recent output → orphaned
1752
+ log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, no output for ${silentSec}s`);
1753
+ deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
1754
+ } else if (hasProcess && silentMs > effectiveTimeout) {
1755
+ // Has process but no output past effective timeout → hung
1756
+ log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1757
+ const procInfo = activeProcesses.get(item.id);
1758
+ if (procInfo) {
1759
+ try { procInfo.proc.kill('SIGTERM'); } catch {}
1760
+ setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch {} }, 5000);
1761
+ activeProcesses.delete(item.id);
1762
+ }
1763
+ deadItems.push({ item, reason: `Hung — no output for ${silentSec}s` });
1764
+ }
1765
+ // If has process and recent output → healthy, let it run
1766
+ }
1767
+
1768
+ // Clean up dead items
1769
+ for (const { item, reason } of deadItems) {
1770
+ completeDispatch(item.id, 'error', reason);
1771
+ if (item.meta?.item?.id) updateWorkItemStatus(item.meta, 'failed', reason);
1772
+ setAgentStatus(item.agent, {
1773
+ status: 'idle',
1774
+ task: null,
1775
+ started_at: null,
1776
+ completed_at: ts()
1777
+ });
1778
+ }
1779
+
1780
+ // Reset "done" agents to "idle" after 1 minute (visual cleanup)
1781
+ // Also catch stranded "working" agents with no active dispatch (e.g. after engine restart)
1782
+ for (const [agentId] of Object.entries(config.agents || {})) {
1783
+ const status = getAgentStatus(agentId);
1784
+ if ((status.status === 'done' || status.status === 'error') && status.completed_at) {
1785
+ const elapsed = Date.now() - new Date(status.completed_at).getTime();
1786
+ if (elapsed > 60000) {
1787
+ setAgentStatus(agentId, { status: 'idle', task: null, started_at: null, completed_at: status.completed_at });
1788
+ }
1789
+ }
1790
+ // Reconcile: if status says "working" but no active dispatch exists, reset to idle
1791
+ if (status.status === 'working') {
1792
+ const hasActiveDispatch = (dispatch.active || []).some(d => d.agent === agentId);
1793
+ if (!hasActiveDispatch) {
1794
+ log('warn', `Reconcile: ${agentId} status is "working" but no active dispatch found — resetting to idle`);
1795
+ setAgentStatus(agentId, { status: 'idle', task: null, started_at: null, completed_at: ts() });
1796
+ }
1797
+ }
1798
+ }
1799
+
1800
+ // Reconcile: find work items stuck in "dispatched" with no matching active dispatch
1801
+ const activeKeys = new Set((dispatch.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
1802
+ const allWiPaths = [path.join(SQUAD_DIR, 'work-items.json')];
1803
+ for (const project of getProjects(config)) {
1804
+ allWiPaths.push(projectWorkItemsPath(project));
1805
+ }
1806
+ for (const wiPath of allWiPaths) {
1807
+ const items = safeJson(wiPath);
1808
+ if (!items || !Array.isArray(items)) continue;
1809
+ let changed = false;
1810
+ for (const item of items) {
1811
+ if (item.status !== 'dispatched') continue;
1812
+ // Check if any active dispatch references this item
1813
+ const possibleKeys = [`central-work-${item.id}`, `work-${item.id}`];
1814
+ const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
1815
+ (dispatch.active || []).some(d => d.meta?.item?.id === item.id);
1816
+ if (!isActive) {
1817
+ log('warn', `Reconcile: work item ${item.id} is "dispatched" but no active dispatch found — resetting to failed`);
1818
+ item.status = 'failed';
1819
+ item.failReason = 'Agent died or was killed';
1820
+ item.failedAt = ts();
1821
+ changed = true;
1822
+ }
1823
+ }
1824
+ if (changed) safeWrite(wiPath, items);
1825
+ }
1826
+ }
1827
+
1828
+ // ─── Cleanup ─────────────────────────────────────────────────────────────────
1829
+
1830
+ function runCleanup(config, verbose = false) {
1831
+ const projects = getProjects(config);
1832
+ let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
1833
+
1834
+ // 1. Clean stale temp prompt/sysprompt files (older than 1 hour)
1835
+ const oneHourAgo = Date.now() - 3600000;
1836
+ try {
1837
+ const engineFiles = fs.readdirSync(ENGINE_DIR);
1838
+ for (const f of engineFiles) {
1839
+ if (f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-')) {
1840
+ const fp = path.join(ENGINE_DIR, f);
1841
+ try {
1842
+ const stat = fs.statSync(fp);
1843
+ if (stat.mtimeMs < oneHourAgo) {
1844
+ fs.unlinkSync(fp);
1845
+ cleaned.tempFiles++;
1846
+ }
1847
+ } catch {}
1848
+ }
1849
+ }
1850
+ } catch {}
1851
+
1852
+ // 2. Clean live-output.log for idle agents (not currently working)
1853
+ for (const [agentId] of Object.entries(config.agents || {})) {
1854
+ const status = getAgentStatus(agentId);
1855
+ if (status.status !== 'working') {
1856
+ const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
1857
+ if (fs.existsSync(livePath)) {
1858
+ try {
1859
+ const stat = fs.statSync(livePath);
1860
+ if (stat.mtimeMs < oneHourAgo) {
1861
+ fs.unlinkSync(livePath);
1862
+ cleaned.liveOutputs++;
1863
+ }
1864
+ } catch {}
1865
+ }
1866
+ }
1867
+ }
1868
+
1869
+ // 3. Clean git worktrees for merged/abandoned PRs
1870
+ for (const project of projects) {
1871
+ const root = project.localPath ? path.resolve(project.localPath) : null;
1872
+ if (!root || !fs.existsSync(root)) continue;
1873
+
1874
+ const worktreeRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1875
+ if (!fs.existsSync(worktreeRoot)) continue;
1876
+
1877
+ // Get PRs for this project
1878
+ const prSrc = project.workSources?.pullRequests || {};
1879
+ const prs = safeJson(path.resolve(root, prSrc.path || '.squad/pull-requests.json')) || [];
1880
+ const mergedBranches = new Set();
1881
+ for (const pr of prs) {
1882
+ if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
1883
+ if (pr.branch) mergedBranches.add(pr.branch);
1884
+ }
1885
+ }
1886
+
1887
+ // List worktrees
1888
+ try {
1889
+ const dirs = fs.readdirSync(worktreeRoot);
1890
+ for (const dir of dirs) {
1891
+ const wtPath = path.join(worktreeRoot, dir);
1892
+ if (!fs.statSync(wtPath).isDirectory()) continue;
1893
+
1894
+ // Check if this worktree's branch is merged
1895
+ let shouldClean = false;
1896
+
1897
+ // Match by directory name to branch (worktrees are named after branches)
1898
+ for (const branch of mergedBranches) {
1899
+ const branchSlug = branch.replace(/[^a-zA-Z0-9._\-\/]/g, '-');
1900
+ if (dir === branchSlug || dir === branch || branch.endsWith(dir)) {
1901
+ shouldClean = true;
1902
+ break;
1903
+ }
1904
+ }
1905
+
1906
+ // Also clean worktrees older than 24 hours with no active dispatch referencing them
1907
+ if (!shouldClean) {
1908
+ try {
1909
+ const stat = fs.statSync(wtPath);
1910
+ const ageMs = Date.now() - stat.mtimeMs;
1911
+ if (ageMs > 86400000) { // 24 hours
1912
+ const dispatch = getDispatch();
1913
+ const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d =>
1914
+ d.meta?.branch && (dir.includes(d.meta.branch) || d.meta.branch.includes(dir))
1915
+ );
1916
+ if (!isReferenced) shouldClean = true;
1917
+ }
1918
+ } catch {}
1919
+ }
1920
+
1921
+ if (shouldClean) {
1922
+ try {
1923
+ execSync(`git worktree remove "${wtPath}" --force`, { cwd: root, stdio: 'pipe' });
1924
+ cleaned.worktrees++;
1925
+ if (verbose) console.log(` Removed worktree: ${wtPath}`);
1926
+ } catch (e) {
1927
+ if (verbose) console.log(` Failed to remove worktree ${wtPath}: ${e.message}`);
1928
+ }
1929
+ }
1930
+ }
1931
+ } catch {}
1932
+ }
1933
+
1934
+ // 4. Kill zombie claude processes not tracked by the engine
1935
+ // List all node processes, check if any are running spawn-agent.js for our squad
1936
+ try {
1937
+ const dispatch = getDispatch();
1938
+ const activePids = new Set();
1939
+ for (const [, info] of activeProcesses.entries()) {
1940
+ if (info.proc?.pid) activePids.add(info.proc.pid);
1941
+ }
1942
+
1943
+ // If no active dispatches but active processes exist in the Map, clean the Map
1944
+ if ((dispatch.active || []).length === 0 && activeProcesses.size > 0) {
1945
+ for (const [id, info] of activeProcesses.entries()) {
1946
+ try { info.proc.kill('SIGTERM'); } catch {}
1947
+ activeProcesses.delete(id);
1948
+ cleaned.zombies++;
1949
+ }
1950
+ }
1951
+ } catch {}
1952
+
1953
+ // 5. Clean spawn-debug.log
1954
+ try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch {}
1955
+
1956
+ if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies > 0) {
1957
+ log('info', `Cleanup: ${cleaned.tempFiles} temp files, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies`);
1958
+ }
1959
+
1960
+ return cleaned;
1961
+ }
1962
+
1963
+ // ─── Work Discovery ─────────────────────────────────────────────────────────
1964
+
1965
+ const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
1966
+ const dispatchCooldowns = new Map(); // key → { timestamp, failures }
1967
+
1968
+ function loadCooldowns() {
1969
+ const saved = safeJson(COOLDOWN_PATH);
1970
+ if (!saved) return;
1971
+ const now = Date.now();
1972
+ for (const [k, v] of Object.entries(saved)) {
1973
+ // Prune entries older than 24 hours
1974
+ if (now - v.timestamp < 24 * 60 * 60 * 1000) {
1975
+ dispatchCooldowns.set(k, v);
1976
+ }
1977
+ }
1978
+ log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
1979
+ }
1980
+
1981
+ let _cooldownWritePending = false;
1982
+ function saveCooldowns() {
1983
+ if (_cooldownWritePending) return;
1984
+ _cooldownWritePending = true;
1985
+ setTimeout(() => {
1986
+ const obj = Object.fromEntries(dispatchCooldowns);
1987
+ safeWrite(COOLDOWN_PATH, obj);
1988
+ _cooldownWritePending = false;
1989
+ }, 1000); // debounce — write at most once per second
1990
+ }
1991
+
1992
+ function isOnCooldown(key, cooldownMs) {
1993
+ const entry = dispatchCooldowns.get(key);
1994
+ if (!entry) return false;
1995
+ const backoff = Math.min(Math.pow(2, entry.failures || 0), 8);
1996
+ return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
1997
+ }
1998
+
1999
+ function setCooldown(key) {
2000
+ const existing = dispatchCooldowns.get(key);
2001
+ dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
2002
+ saveCooldowns();
2003
+ }
2004
+
2005
+ function setCooldownFailure(key) {
2006
+ const existing = dispatchCooldowns.get(key);
2007
+ const failures = (existing?.failures || 0) + 1;
2008
+ dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
2009
+ if (failures >= 3) {
2010
+ log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
2011
+ }
2012
+ saveCooldowns();
2013
+ }
2014
+
2015
+ function isAlreadyDispatched(key) {
2016
+ const dispatch = getDispatch();
2017
+ // Check pending and active
2018
+ const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
2019
+ if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
2020
+ // Also check recently completed (last hour) to prevent re-dispatch
2021
+ const oneHourAgo = Date.now() - 3600000;
2022
+ const recentCompleted = (dispatch.completed || []).filter(d =>
2023
+ d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
2024
+ );
2025
+ return recentCompleted.some(d => d.meta?.dispatchKey === key);
2026
+ }
2027
+
2028
+ /**
2029
+ * Scan PRD (docs/prd-gaps.json) for missing/planned items → queue implement tasks
2030
+ */
2031
+ function discoverFromPrd(config, project) {
2032
+ const src = project?.workSources?.prd || config.workSources?.prd;
2033
+ if (!src?.enabled) return [];
2034
+
2035
+ const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
2036
+ const prdPath = path.resolve(root, src.path);
2037
+ const prd = safeJson(prdPath);
2038
+ if (!prd) return [];
2039
+
2040
+ const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
2041
+ const statusFilter = src.itemFilter?.status || ['missing', 'planned'];
2042
+ const items = (prd.missing_features || []).filter(f => statusFilter.includes(f.status));
2043
+ const newWork = [];
2044
+
2045
+ for (const item of items) {
2046
+ const key = `prd-${project?.name || 'default'}-${item.id}`;
2047
+ if (isAlreadyDispatched(key)) continue;
2048
+ if (isOnCooldown(key, cooldownMs)) continue;
2049
+
2050
+ const workType = item.estimated_complexity === 'large' ? 'implement:large' : 'implement';
2051
+ const agentId = resolveAgent(workType, config);
2052
+ if (!agentId) continue;
2053
+
2054
+ const branchName = `feature/${item.id.toLowerCase()}-${item.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40)}`;
2055
+ const vars = {
2056
+ agent_id: agentId,
2057
+ agent_name: config.agents[agentId]?.name || agentId,
2058
+ agent_role: config.agents[agentId]?.role || 'Agent',
2059
+ item_id: item.id,
2060
+ item_name: item.name,
2061
+ item_priority: item.priority || 'medium',
2062
+ item_complexity: item.estimated_complexity || 'medium',
2063
+ item_description: item.description || '',
2064
+ branch_name: branchName,
2065
+ project_path: root,
2066
+ main_branch: project?.mainBranch || 'main',
2067
+ worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', branchName),
2068
+ commit_message: `feat(${item.id.toLowerCase()}): ${item.name}`,
2069
+ team_root: SQUAD_DIR,
2070
+ repo_id: project?.repositoryId || config.project?.repositoryId || '',
2071
+ project_name: project?.name || config.project?.name || 'Unknown Project',
2072
+ ado_org: project?.adoOrg || config.project?.adoOrg || 'Unknown',
2073
+ ado_project: project?.adoProject || config.project?.adoProject || 'Unknown',
2074
+ repo_name: project?.repoName || config.project?.repoName || 'Unknown'
2075
+ };
2076
+
2077
+ const prompt = renderPlaybook('implement', vars);
2078
+ if (!prompt) continue;
2079
+
2080
+ newWork.push({
2081
+ type: workType,
2082
+ agent: agentId,
2083
+ agentName: config.agents[agentId]?.name,
2084
+ agentRole: config.agents[agentId]?.role,
2085
+ task: `[${project?.name || 'project'}] Implement ${item.id}: ${item.name}`,
2086
+ prompt,
2087
+ meta: { dispatchKey: key, source: 'prd', branch: branchName, item, project: { name: project?.name, localPath: project?.localPath } }
2088
+ });
2089
+
2090
+ setCooldown(key);
2091
+ }
2092
+
2093
+ return newWork;
2094
+ }
2095
+
2096
+ /**
2097
+ * Scan ~/.squad/plans/ for plan-generated PRD files → queue implement tasks.
2098
+ * Plans are project-scoped JSON files written by the plan-to-prd playbook.
2099
+ */
2100
+ /**
2101
+ * Convert plan files into project work items (side-effect, like specs).
2102
+ * Plans write to the target project's work-items.json — picked up by discoverFromWorkItems next tick.
2103
+ */
2104
+ function materializePlansAsWorkItems(config) {
2105
+ if (!fs.existsSync(PLANS_DIR)) return;
2106
+
2107
+ let planFiles;
2108
+ try { planFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.json')); } catch { return; }
2109
+
2110
+ for (const file of planFiles) {
2111
+ const plan = safeJson(path.join(PLANS_DIR, file));
2112
+ if (!plan?.missing_features) continue;
2113
+
2114
+ const projectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
2115
+ const project = getProjects(config).find(p => p.name?.toLowerCase() === projectName.toLowerCase());
2116
+ if (!project) continue;
2117
+
2118
+ const wiPath = projectWorkItemsPath(project);
2119
+ const existingItems = safeJson(wiPath) || [];
2120
+
2121
+ let created = 0;
2122
+ const statusFilter = ['missing', 'planned'];
2123
+ const items = plan.missing_features.filter(f => statusFilter.includes(f.status));
2124
+
2125
+ for (const item of items) {
2126
+ // Skip if already materialized
2127
+ if (existingItems.some(w => w.sourcePlan === file && w.sourcePlanItem === item.id)) continue;
2128
+
2129
+ const maxNum = existingItems.reduce((max, i) => {
2130
+ const m = (i.id || '').match(/(\d+)$/);
2131
+ return m ? Math.max(max, parseInt(m[1])) : max;
2132
+ }, 0);
2133
+ const id = 'PL-W' + String(maxNum + 1).padStart(3, '0');
2134
+
2135
+ const complexity = item.estimated_complexity || 'medium';
2136
+ const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
2137
+
2138
+ existingItems.push({
2139
+ id,
2140
+ title: `Implement: ${item.name}`,
2141
+ type: complexity === 'large' ? 'implement:large' : 'implement',
2142
+ priority: item.priority || 'medium',
2143
+ description: `${item.description || ''}\n\n**Plan:** ${file}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`,
2144
+ status: 'pending',
2145
+ created: ts(),
2146
+ createdBy: 'engine:plan-discovery',
2147
+ sourcePlan: file,
2148
+ sourcePlanItem: item.id
2149
+ });
2150
+ created++;
2151
+ }
2152
+
2153
+ if (created > 0) {
2154
+ safeWrite(wiPath, existingItems);
2155
+ log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${project.name}`);
2156
+ }
2157
+ }
2158
+ }
2159
+
2160
+ /**
2161
+ * Scan pull-requests.json for PRs needing review or fixes
2162
+ */
2163
+ function discoverFromPrs(config, project) {
2164
+ const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
2165
+ if (!src?.enabled) return [];
2166
+
2167
+ const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
2168
+ const prs = safeJson(path.resolve(root, src.path)) || [];
2169
+ const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
2170
+ const newWork = [];
2171
+
2172
+ for (const pr of prs) {
2173
+ if (pr.status !== 'active') continue;
2174
+
2175
+ // PRs needing review — use squad review state (not ADO reviewStatus which pollPrStatus manages)
2176
+ const squadStatus = pr.squadReview?.status;
2177
+ const needsReview = !squadStatus || squadStatus === 'waiting';
2178
+ if (needsReview) {
2179
+ const key = `review-${project?.name || 'default'}-${pr.id}`;
2180
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2181
+
2182
+ const agentId = resolveAgent('review', config);
2183
+ if (!agentId) continue;
2184
+
2185
+ // Extract numeric PR ID from "PR-NNNNN" format
2186
+ const prNumber = (pr.id || '').replace(/^PR-/, '');
2187
+ const vars = {
2188
+ agent_id: agentId,
2189
+ agent_name: config.agents[agentId]?.name || agentId,
2190
+ agent_role: config.agents[agentId]?.role || 'Agent',
2191
+ pr_id: pr.id,
2192
+ pr_number: prNumber,
2193
+ pr_title: pr.title || '',
2194
+ pr_branch: pr.branch || '',
2195
+ pr_author: pr.agent || '',
2196
+ pr_url: pr.url || '',
2197
+ main_branch: project?.mainBranch || 'main',
2198
+ team_root: SQUAD_DIR,
2199
+ repo_id: project?.repositoryId || config.project?.repositoryId || '',
2200
+ project_name: project?.name || 'Unknown Project',
2201
+ ado_org: project?.adoOrg || 'Unknown',
2202
+ ado_project: project?.adoProject || 'Unknown',
2203
+ repo_name: project?.repoName || 'Unknown'
2204
+ };
2205
+
2206
+ const prompt = renderPlaybook('review', vars);
2207
+ if (!prompt) continue;
2208
+
2209
+ newWork.push({
2210
+ type: 'review',
2211
+ agent: agentId,
2212
+ agentName: config.agents[agentId]?.name,
2213
+ agentRole: config.agents[agentId]?.role,
2214
+ task: `[${project?.name || 'project'}] Review PR ${pr.id}: ${pr.title}`,
2215
+ prompt,
2216
+ meta: { dispatchKey: key, source: 'pr', pr, project: { name: project?.name, localPath: project?.localPath } }
2217
+ });
2218
+
2219
+ setCooldown(key);
2220
+ }
2221
+
2222
+ // PRs with changes requested (by squad review) → route back to author for fix
2223
+ if (squadStatus === 'changes-requested') {
2224
+ const key = `fix-${project?.name || 'default'}-${pr.id}`;
2225
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2226
+
2227
+ const agentId = resolveAgent('fix', config, pr.agent);
2228
+ if (!agentId) continue;
2229
+
2230
+ const vars = {
2231
+ agent_id: agentId,
2232
+ agent_name: config.agents[agentId]?.name || agentId,
2233
+ agent_role: config.agents[agentId]?.role || 'Agent',
2234
+ pr_id: pr.id,
2235
+ pr_branch: pr.branch || '',
2236
+ review_note: pr.squadReview?.note || pr.reviewNote || 'See PR thread comments',
2237
+ team_root: SQUAD_DIR,
2238
+ repo_id: project?.repositoryId || config.project?.repositoryId || '',
2239
+ project_name: project?.name || 'Unknown Project',
2240
+ ado_org: project?.adoOrg || 'Unknown',
2241
+ ado_project: project?.adoProject || 'Unknown',
2242
+ repo_name: project?.repoName || 'Unknown'
2243
+ };
2244
+
2245
+ const prompt = renderPlaybook('fix', vars);
2246
+ if (!prompt) continue;
2247
+
2248
+ newWork.push({
2249
+ type: 'fix',
2250
+ agent: agentId,
2251
+ agentName: config.agents[agentId]?.name,
2252
+ agentRole: config.agents[agentId]?.role,
2253
+ task: `[${project?.name || 'project'}] Fix PR ${pr.id} review feedback`,
2254
+ prompt,
2255
+ meta: { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: { name: project?.name, localPath: project?.localPath } }
2256
+ });
2257
+
2258
+ setCooldown(key);
2259
+ }
2260
+
2261
+ // PRs with build failures → route to any idle agent
2262
+ if (pr.status === 'active' && pr.buildStatus === 'failing') {
2263
+ const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
2264
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2265
+
2266
+ const agentId = resolveAgent('fix', config);
2267
+ if (!agentId) continue;
2268
+
2269
+ const vars = {
2270
+ agent_id: agentId,
2271
+ agent_name: config.agents[agentId]?.name || agentId,
2272
+ agent_role: config.agents[agentId]?.role || 'Agent',
2273
+ pr_id: pr.id,
2274
+ pr_branch: pr.branch || '',
2275
+ review_note: `Build is failing: ${pr.buildFailReason || 'Check CI pipeline for details'}. Fix the build errors and push.`,
2276
+ team_root: SQUAD_DIR,
2277
+ repo_id: project?.repositoryId || config.project?.repositoryId || '',
2278
+ project_name: project?.name || 'Unknown Project',
2279
+ ado_org: project?.adoOrg || 'Unknown',
2280
+ ado_project: project?.adoProject || 'Unknown',
2281
+ repo_name: project?.repoName || 'Unknown'
2282
+ };
2283
+
2284
+ const prompt = renderPlaybook('fix', vars);
2285
+ if (!prompt) continue;
2286
+
2287
+ newWork.push({
2288
+ type: 'fix',
2289
+ agent: agentId,
2290
+ agentName: config.agents[agentId]?.name,
2291
+ agentRole: config.agents[agentId]?.role,
2292
+ task: `[${project?.name || 'project'}] Fix build failure on PR ${pr.id}`,
2293
+ prompt,
2294
+ meta: { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: { name: project?.name, localPath: project?.localPath } }
2295
+ });
2296
+
2297
+ setCooldown(key);
2298
+ }
2299
+
2300
+ // Newly created PRs needing build & test verification
2301
+ if (!pr.buildTested) {
2302
+ const key = `build-test-${project?.name || 'default'}-${pr.id}`;
2303
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2304
+
2305
+ const agentId = resolveAgent('test', config);
2306
+ if (!agentId) continue;
2307
+
2308
+ const prNumber = (pr.id || '').replace(/^PR-/, '');
2309
+ const vars = {
2310
+ agent_id: agentId,
2311
+ agent_name: config.agents[agentId]?.name || agentId,
2312
+ agent_role: config.agents[agentId]?.role || 'Agent',
2313
+ pr_id: pr.id,
2314
+ pr_number: prNumber,
2315
+ pr_title: pr.title || '',
2316
+ pr_branch: pr.branch || '',
2317
+ pr_author: pr.agent || '',
2318
+ pr_url: pr.url || '',
2319
+ main_branch: project?.mainBranch || 'main',
2320
+ team_root: SQUAD_DIR,
2321
+ repo_id: project?.repositoryId || '',
2322
+ project_name: project?.name || 'Unknown Project',
2323
+ project_path: project?.localPath ? path.resolve(project.localPath) : '',
2324
+ ado_org: project?.adoOrg || 'Unknown',
2325
+ ado_project: project?.adoProject || 'Unknown',
2326
+ repo_name: project?.repoName || 'Unknown',
2327
+ date: dateStamp()
2328
+ };
2329
+
2330
+ const prompt = renderPlaybook('build-and-test', vars);
2331
+ if (!prompt) continue;
2332
+
2333
+ // Mark PR so we don't re-dispatch (written after loop)
2334
+ pr.buildTested = 'dispatched';
2335
+
2336
+ newWork.push({
2337
+ type: 'test',
2338
+ agent: agentId,
2339
+ agentName: config.agents[agentId]?.name,
2340
+ agentRole: config.agents[agentId]?.role,
2341
+ task: `[${project?.name || 'project'}] Build & test PR ${pr.id}: ${pr.title}`,
2342
+ prompt,
2343
+ meta: { dispatchKey: key, source: 'pr-build-test', pr, branch: pr.branch, project: { name: project?.name, localPath: project?.localPath } }
2344
+ });
2345
+
2346
+ setCooldown(key);
2347
+ }
2348
+ }
2349
+
2350
+ // Batch-write PR state changes (buildTested flags) once after the loop
2351
+ if (newWork.some(w => w.meta?.source === 'pr-build-test')) {
2352
+ const prRoot = path.resolve(project.localPath);
2353
+ const prSrc = project.workSources?.pullRequests || {};
2354
+ const prPath = path.resolve(prRoot, prSrc.path || '.squad/pull-requests.json');
2355
+ safeWrite(prPath, prs);
2356
+ }
2357
+
2358
+ return newWork;
2359
+ }
2360
+
2361
+ /**
2362
+ * Scan work-items.json for manually queued tasks
2363
+ */
2364
+ function discoverFromWorkItems(config, project) {
2365
+ const src = project?.workSources?.workItems || config.workSources?.workItems;
2366
+ if (!src?.enabled) return [];
2367
+
2368
+ const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
2369
+ const items = safeJson(path.resolve(root, src.path)) || [];
2370
+ const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
2371
+ const newWork = [];
2372
+
2373
+ for (const item of items) {
2374
+ if (item.status !== 'queued' && item.status !== 'pending') continue;
2375
+
2376
+ const key = `work-${project?.name || 'default'}-${item.id}`;
2377
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2378
+
2379
+ let workType = item.type || 'implement';
2380
+ // Route large items to architecture agents, matching PRD/plan behavior
2381
+ if (workType === 'implement' && (item.complexity === 'large' || item.estimated_complexity === 'large')) {
2382
+ workType = 'implement:large';
2383
+ }
2384
+ const agentId = item.agent || resolveAgent(workType, config);
2385
+ if (!agentId) continue;
2386
+
2387
+ const branchName = item.branch || `work/${item.id}`;
2388
+ const vars = {
2389
+ agent_id: agentId,
2390
+ agent_name: config.agents[agentId]?.name || agentId,
2391
+ agent_role: config.agents[agentId]?.role || 'Agent',
2392
+ item_id: item.id,
2393
+ item_name: item.title || item.id,
2394
+ item_priority: item.priority || 'medium',
2395
+ item_description: item.description || '',
2396
+ work_type: workType,
2397
+ additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2398
+ scope_section: `## Scope: Project — ${project?.name || 'default'}\n\nThis task is scoped to a single project.`,
2399
+ branch_name: branchName,
2400
+ project_path: root,
2401
+ main_branch: project?.mainBranch || 'main',
2402
+ worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', branchName),
2403
+ commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
2404
+ team_root: SQUAD_DIR,
2405
+ repo_id: project?.repositoryId || config.project?.repositoryId || '',
2406
+ project_name: project?.name || 'Unknown Project',
2407
+ ado_org: project?.adoOrg || 'Unknown',
2408
+ ado_project: project?.adoProject || 'Unknown',
2409
+ repo_name: project?.repoName || 'Unknown',
2410
+ date: dateStamp()
2411
+ };
2412
+
2413
+ // Use type-specific playbook if it exists (e.g., explore.md, review.md), fall back to work-item.md
2414
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd'];
2415
+ const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2416
+ const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
2417
+ if (!prompt) {
2418
+ log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
2419
+ continue;
2420
+ }
2421
+
2422
+ // Mark item as dispatched BEFORE adding to newWork (prevents race on next tick)
2423
+ item.status = 'dispatched';
2424
+ item.dispatched_at = ts();
2425
+ item.dispatched_to = agentId;
2426
+
2427
+ newWork.push({
2428
+ type: workType,
2429
+ agent: agentId,
2430
+ agentName: config.agents[agentId]?.name,
2431
+ agentRole: config.agents[agentId]?.role,
2432
+ task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
2433
+ prompt,
2434
+ meta: { dispatchKey: key, source: 'work-item', branch: branchName, item, project: { name: project?.name, localPath: project?.localPath } }
2435
+ });
2436
+
2437
+ setCooldown(key);
2438
+ }
2439
+
2440
+ // Write back updated statuses (always, since we mark items dispatched before newWork check)
2441
+ if (newWork.length > 0) {
2442
+ const workItemsPath = path.resolve(root, src.path);
2443
+ safeWrite(workItemsPath, items);
2444
+ }
2445
+
2446
+ return newWork;
2447
+ }
2448
+
2449
+ /**
2450
+ * Build the multi-project context section for central work items.
2451
+ * Inserted into the playbook via {{scope_section}}.
2452
+ */
2453
+ function buildProjectContext(projects, assignedProject, isFanOut, agentName, agentRole) {
2454
+ const projectList = projects.map(p => {
2455
+ let line = `### ${p.name}\n`;
2456
+ line += `- **Path:** ${p.localPath}\n`;
2457
+ line += `- **Repo:** ${p.adoOrg}/${p.adoProject}/${p.repoName} (ID: ${p.repositoryId || 'unknown'}, host: ${getRepoHostLabel(p)})\n`;
2458
+ if (p.description) line += `- **What it is:** ${p.description}\n`;
2459
+ return line;
2460
+ }).join('\n');
2461
+
2462
+ let section = '';
2463
+
2464
+ if (isFanOut && assignedProject) {
2465
+ section += `## Scope: Fan-out (parallel multi-agent)\n\n`;
2466
+ section += `You are assigned to **${assignedProject.name}**. Other agents are handling the other projects.\n\n`;
2467
+ } else {
2468
+ section += `## Scope: Multi-project (you decide where to work)\n\n`;
2469
+ section += `Determine which project(s) this task applies to. It may span multiple repos.\n`;
2470
+ section += `If multi-repo, work on each sequentially (worktree + PR per repo).\n`;
2471
+ section += `Note cross-repo dependencies in PR descriptions.\n\n`;
2472
+ }
2473
+
2474
+ section += `## Available Projects\n\n${projectList}`;
2475
+ return section;
2476
+ }
2477
+
2478
+ /**
2479
+ * Detect merged PRs containing spec documents and create implement work items.
2480
+ * "Specs" = any markdown doc merged into the repo that describes work to build.
2481
+ * Writes work items as a side-effect; discoverFromWorkItems() picks them up next tick.
2482
+ *
2483
+ * Config key: workSources.specs
2484
+ * Only processes docs with frontmatter `type: spec` — regular docs are ignored.
2485
+ */
2486
+ function materializeSpecsAsWorkItems(config, project) {
2487
+ const src = project?.workSources?.specs;
2488
+ if (!src?.enabled) return;
2489
+
2490
+ const root = projectRoot(project);
2491
+ const filePatterns = src.filePatterns || ['docs/**/*.md'];
2492
+ const trackerPath = path.resolve(root, src.statePath || '.squad/spec-tracker.json');
2493
+ const tracker = safeJson(trackerPath) || { processedPrs: {} };
2494
+
2495
+ const prs = getPrs(project);
2496
+ const mergedPrs = prs.filter(pr =>
2497
+ (pr.status === 'merged' || pr.status === 'completed') &&
2498
+ !tracker.processedPrs[pr.id]
2499
+ );
2500
+
2501
+ if (mergedPrs.length === 0) return;
2502
+
2503
+ const sinceDate = src.lookbackDays ? `${src.lookbackDays} days ago` : '7 days ago';
2504
+ let recentSpecs = [];
2505
+ for (const pattern of filePatterns) {
2506
+ try {
2507
+ const result = execSync(
2508
+ `git log --diff-filter=AM --name-only --pretty=format:"COMMIT:%H|%s" --since="${sinceDate}" -- "${pattern}"`,
2509
+ { cwd: root, encoding: 'utf8', timeout: 10000 }
2510
+ ).trim();
2511
+ if (!result) continue;
2512
+
2513
+ let currentCommit = null;
2514
+ for (const line of result.split('\n')) {
2515
+ if (line.startsWith('COMMIT:')) {
2516
+ const [hash, ...msgParts] = line.replace('COMMIT:', '').split('|');
2517
+ currentCommit = { hash: hash.trim(), message: msgParts.join('|').trim() };
2518
+ } else if (line.trim() && currentCommit) {
2519
+ recentSpecs.push({ file: line.trim(), ...currentCommit });
2520
+ }
2521
+ }
2522
+ } catch {}
2523
+ }
2524
+
2525
+ if (recentSpecs.length === 0) return;
2526
+
2527
+ const wiPath = projectWorkItemsPath(project);
2528
+ const existingItems = safeJson(wiPath) || [];
2529
+ let created = 0;
2530
+
2531
+ for (const pr of mergedPrs) {
2532
+ const prBranch = (pr.branch || '').toLowerCase();
2533
+ const matchedSpecs = recentSpecs.filter(doc => {
2534
+ const msg = doc.message.toLowerCase();
2535
+ // Match any doc whose commit message references this PR's branch
2536
+ return prBranch && msg.includes(prBranch.split('/').pop());
2537
+ });
2538
+
2539
+ if (matchedSpecs.length === 0) {
2540
+ tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
2541
+ continue;
2542
+ }
2543
+
2544
+ for (const doc of matchedSpecs) {
2545
+ if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
2546
+
2547
+ const info = extractSpecInfo(doc.file, root);
2548
+ if (!info) continue;
2549
+
2550
+ const spItems = existingItems.filter(i => i.id?.startsWith('SP'));
2551
+ const maxNum = spItems.reduce((max, i) => {
2552
+ const n = parseInt(i.id.replace('SP', ''), 10);
2553
+ return isNaN(n) ? max : Math.max(max, n);
2554
+ }, 0);
2555
+ const newId = `SP${String(maxNum + 1).padStart(3, '0')}`;
2556
+
2557
+ existingItems.push({
2558
+ id: newId,
2559
+ type: 'implement',
2560
+ title: `Implement: ${info.title}`,
2561
+ description: `Implementation work from merged spec.\n\n**Spec:** \`${doc.file}\`\n**Source PR:** ${pr.id} — ${pr.title || ''}\n**PR URL:** ${pr.url || 'N/A'}\n\n## Summary\n\n${info.summary}\n\nRead the full spec at \`${doc.file}\` before starting.`,
2562
+ priority: info.priority,
2563
+ status: 'queued',
2564
+ created: ts(),
2565
+ createdBy: 'engine:spec-discovery',
2566
+ sourceSpec: doc.file,
2567
+ sourcePr: pr.id
2568
+ });
2569
+ created++;
2570
+ log('info', `Spec discovery: created ${newId} "${info.title}" from PR ${pr.id} in ${project.name}`);
2571
+ }
2572
+
2573
+ tracker.processedPrs[pr.id] = { processedAt: ts(), matched: true, specs: matchedSpecs.map(d => d.file) };
2574
+ }
2575
+
2576
+ if (created > 0) {
2577
+ safeWrite(wiPath, existingItems);
2578
+ }
2579
+ safeWrite(trackerPath, tracker);
2580
+ }
2581
+
2582
+ /**
2583
+ * Extract title, summary, and priority from a spec markdown file.
2584
+ * Returns null if the file doesn't have `type: spec` in its frontmatter.
2585
+ */
2586
+ function extractSpecInfo(filePath, projectRoot_) {
2587
+ const fullPath = path.resolve(projectRoot_, filePath);
2588
+ const content = safeRead(fullPath);
2589
+ if (!content) return null;
2590
+
2591
+ // Require frontmatter with type: spec
2592
+ const fmBlock = content.match(/^---\n([\s\S]*?)\n---/);
2593
+ if (!fmBlock) return null;
2594
+ const frontmatter = fmBlock[1];
2595
+ if (!/type:\s*spec/i.test(frontmatter)) return null;
2596
+
2597
+ let title = '';
2598
+ const fmMatch = content.match(/^---\n[\s\S]*?title:\s*(.+)\n[\s\S]*?---/);
2599
+ const h1Match = content.match(/^#\s+(.+)$/m);
2600
+ title = fmMatch?.[1]?.trim() || h1Match?.[1]?.trim() || path.basename(filePath, '.md');
2601
+
2602
+ let summary = '';
2603
+ const summaryMatch = content.match(/##\s*Summary\n\n([\s\S]*?)(?:\n##|\n---|$)/);
2604
+ if (summaryMatch) {
2605
+ summary = summaryMatch[1].trim();
2606
+ } else {
2607
+ const lines = content.split('\n');
2608
+ let pastTitle = false;
2609
+ for (const line of lines) {
2610
+ if (line.startsWith('# ')) { pastTitle = true; continue; }
2611
+ if (pastTitle && line.trim() && !line.startsWith('#') && !line.startsWith('---')) {
2612
+ summary = line.trim();
2613
+ break;
2614
+ }
2615
+ }
2616
+ }
2617
+
2618
+ const priorityMatch = content.match(/priority:\s*(high|medium|low|critical)/i);
2619
+ const priority = priorityMatch?.[1]?.toLowerCase() || 'medium';
2620
+
2621
+ return { title, summary: summary.slice(0, 1500), priority };
2622
+ }
2623
+
2624
+ /**
2625
+ * Scan central ~/.squad/work-items.json for project-agnostic tasks.
2626
+ * Uses the shared work-item.md playbook with multi-project context injected.
2627
+ */
2628
+ function discoverCentralWorkItems(config) {
2629
+ const centralPath = path.join(SQUAD_DIR, 'work-items.json');
2630
+ const items = safeJson(centralPath) || [];
2631
+ const projects = getProjects(config);
2632
+ const newWork = [];
2633
+
2634
+ for (const item of items) {
2635
+ if (item.status !== 'queued' && item.status !== 'pending') continue;
2636
+
2637
+ const key = `central-work-${item.id}`;
2638
+ if (isAlreadyDispatched(key) || isOnCooldown(key, 0)) continue;
2639
+
2640
+ const workType = item.type || 'implement';
2641
+ const isFanOut = item.scope === 'fan-out';
2642
+
2643
+ if (isFanOut) {
2644
+ // ─── Fan-out: dispatch to ALL idle agents ───────────────────────
2645
+ const idleAgents = Object.entries(config.agents)
2646
+ .filter(([id]) => {
2647
+ const s = getAgentStatus(id);
2648
+ return ['idle', 'done', 'completed'].includes(s.status);
2649
+ })
2650
+ .map(([id, info]) => ({ id, ...info }));
2651
+
2652
+ if (idleAgents.length === 0) continue;
2653
+
2654
+ const assignments = idleAgents.map((agent, i) => ({
2655
+ agent,
2656
+ assignedProject: projects.length > 0 ? projects[i % projects.length] : null
2657
+ }));
2658
+
2659
+ for (const { agent, assignedProject } of assignments) {
2660
+ const fanKey = `${key}-${agent.id}`;
2661
+ if (isAlreadyDispatched(fanKey)) continue;
2662
+
2663
+ const ap = assignedProject || projects[0];
2664
+ const vars = {
2665
+ agent_id: agent.id,
2666
+ agent_name: agent.name,
2667
+ agent_role: agent.role,
2668
+ item_id: item.id,
2669
+ item_name: item.title || item.id,
2670
+ item_priority: item.priority || 'medium',
2671
+ item_description: item.description || '',
2672
+ work_type: workType,
2673
+ additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2674
+ scope_section: buildProjectContext(projects, assignedProject, true, agent.name, agent.role),
2675
+ project_path: ap?.localPath || '',
2676
+ main_branch: ap?.mainBranch || 'main',
2677
+ repo_id: ap?.repositoryId || '',
2678
+ project_name: ap?.name || 'Unknown',
2679
+ ado_org: ap?.adoOrg || 'Unknown',
2680
+ ado_project: ap?.adoProject || 'Unknown',
2681
+ repo_name: ap?.repoName || 'Unknown',
2682
+ team_root: SQUAD_DIR,
2683
+ date: dateStamp()
2684
+ };
2685
+
2686
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd'];
2687
+ const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2688
+ const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2689
+ if (!prompt) continue;
2690
+
2691
+ newWork.push({
2692
+ type: workType,
2693
+ agent: agent.id,
2694
+ agentName: agent.name,
2695
+ agentRole: agent.role,
2696
+ task: `[fan-out] ${item.title} → ${agent.name}${assignedProject ? ' → ' + assignedProject.name : ''}`,
2697
+ prompt,
2698
+ meta: { dispatchKey: fanKey, source: 'central-work-item-fanout', item, parentKey: key }
2699
+ });
2700
+ }
2701
+
2702
+ item.status = 'dispatched';
2703
+ item.dispatched_at = ts();
2704
+ item.dispatched_to = idleAgents.map(a => a.id).join(', ');
2705
+ item.scope = 'fan-out';
2706
+ item.fanOutAgents = idleAgents.map(a => a.id);
2707
+ setCooldown(key);
2708
+ log('info', `Fan-out: ${item.id} dispatched to ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
2709
+
2710
+ } else {
2711
+ // ─── Normal: single agent dispatch ──────────────────────────────
2712
+ const agentId = item.agent || resolveAgent(workType, config);
2713
+ if (!agentId) continue;
2714
+
2715
+ const agentName = config.agents[agentId]?.name || agentId;
2716
+ const agentRole = config.agents[agentId]?.role || 'Agent';
2717
+ const firstProject = projects[0];
2718
+
2719
+ const vars = {
2720
+ agent_id: agentId,
2721
+ agent_name: agentName,
2722
+ agent_role: agentRole,
2723
+ item_id: item.id,
2724
+ item_name: item.title || item.id,
2725
+ item_priority: item.priority || 'medium',
2726
+ item_description: item.description || '',
2727
+ work_type: workType,
2728
+ additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2729
+ scope_section: buildProjectContext(projects, null, false, agentName, agentRole),
2730
+ project_path: firstProject?.localPath || '',
2731
+ main_branch: firstProject?.mainBranch || 'main',
2732
+ repo_id: firstProject?.repositoryId || '',
2733
+ project_name: firstProject?.name || 'Unknown',
2734
+ ado_org: firstProject?.adoOrg || 'Unknown',
2735
+ ado_project: firstProject?.adoProject || 'Unknown',
2736
+ repo_name: firstProject?.repoName || 'Unknown',
2737
+ team_root: SQUAD_DIR,
2738
+ date: dateStamp()
2739
+ };
2740
+
2741
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd'];
2742
+ const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2743
+ const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2744
+ if (!prompt) continue;
2745
+
2746
+ newWork.push({
2747
+ type: workType,
2748
+ agent: agentId,
2749
+ agentName,
2750
+ agentRole,
2751
+ task: item.title || item.description?.slice(0, 80) || item.id,
2752
+ prompt,
2753
+ meta: { dispatchKey: key, source: 'central-work-item', item }
2754
+ });
2755
+
2756
+ item.status = 'dispatched';
2757
+ item.dispatched_at = ts();
2758
+ item.dispatched_to = agentId;
2759
+ setCooldown(key);
2760
+ }
2761
+ }
2762
+
2763
+ if (newWork.length > 0) safeWrite(centralPath, items);
2764
+ return newWork;
2765
+ }
2766
+
2767
+
2768
+ /**
2769
+ * Run all work discovery sources and queue new items
2770
+ * Priority: fix (0) > review (1) > implement (2) > work-items (3) > central (4)
2771
+ */
2772
+ function discoverWork(config) {
2773
+ const projects = getProjects(config);
2774
+ let allFixes = [], allReviews = [], allImplements = [], allWorkItems = [];
2775
+
2776
+ // Side-effect passes: materialize plans and design docs into work-items.json
2777
+ // These write to project work queues — picked up by discoverFromWorkItems below.
2778
+ materializePlansAsWorkItems(config);
2779
+
2780
+ for (const project of projects) {
2781
+ const root = project.localPath ? path.resolve(project.localPath) : null;
2782
+ if (!root || !fs.existsSync(root)) continue;
2783
+
2784
+ // Source 1: Pull Requests → fixes, reviews, build-test
2785
+ const prWork = discoverFromPrs(config, project);
2786
+ allFixes.push(...prWork.filter(w => w.type === 'fix'));
2787
+ allReviews.push(...prWork.filter(w => w.type === 'review'));
2788
+ allWorkItems.push(...prWork.filter(w => w.type === 'test'));
2789
+
2790
+ // Source 2: PRD gaps → implements
2791
+ allImplements.push(...discoverFromPrd(config, project));
2792
+
2793
+ // Side-effect: specs → work items (picked up below)
2794
+ materializeSpecsAsWorkItems(config, project);
2795
+
2796
+ // Source 3: Work items (includes auto-filed from plans, design docs, build failures)
2797
+ allWorkItems.push(...discoverFromWorkItems(config, project));
2798
+ }
2799
+
2800
+ // Central work items (project-agnostic — agent decides where to work)
2801
+ const centralWork = discoverCentralWorkItems(config);
2802
+
2803
+ const allWork = [...allFixes, ...allReviews, ...allImplements, ...allWorkItems, ...centralWork];
2804
+
2805
+ for (const item of allWork) {
2806
+ addToDispatch(item);
2807
+ }
2808
+
2809
+ if (allWork.length > 0) {
2810
+ log('info', `Discovered ${allWork.length} new work items: ${allFixes.length} fixes, ${allReviews.length} reviews, ${allImplements.length} implements, ${allWorkItems.length} work-items`);
2811
+ }
2812
+
2813
+ return allWork.length;
2814
+ }
2815
+
2816
+ // ─── Main Tick ──────────────────────────────────────────────────────────────
2817
+
2818
+ let tickCount = 0;
2819
+
2820
+ let tickRunning = false;
2821
+
2822
+ async function tick() {
2823
+ if (tickRunning) return; // prevent overlapping ticks
2824
+ tickRunning = true;
2825
+ try {
2826
+ await tickInner();
2827
+ } catch (e) {
2828
+ log('error', `Tick error: ${e.message}`);
2829
+ } finally {
2830
+ tickRunning = false;
2831
+ }
2832
+ }
2833
+
2834
+ async function tickInner() {
2835
+ const control = getControl();
2836
+ if (control.state !== 'running') return;
2837
+
2838
+ const config = getConfig();
2839
+ tickCount++;
2840
+
2841
+ // 1. Check for timed-out agents
2842
+ checkTimeouts(config);
2843
+
2844
+ // 2. Consolidate inbox
2845
+ consolidateInbox(config);
2846
+
2847
+ // 2.5. Periodic cleanup (every 10 ticks = ~5 minutes)
2848
+ if (tickCount % 10 === 0) {
2849
+ runCleanup(config);
2850
+ }
2851
+
2852
+ // 2.6. Poll ADO for full PR status: build, review, merge (every 6 ticks = ~3 minutes)
2853
+ // Awaited so PR state is consistent before discoverWork reads it
2854
+ if (tickCount % 6 === 0) {
2855
+ try { await pollPrStatus(config); } catch (e) { log('warn', `PR status poll error: ${e.message}`); }
2856
+ }
2857
+
2858
+ // 3. Discover new work from sources
2859
+ discoverWork(config);
2860
+
2861
+ // 4. Update snapshot
2862
+ updateSnapshot(config);
2863
+
2864
+ // 5. Process pending dispatches — auto-spawn agents
2865
+ const dispatch = getDispatch();
2866
+ const activeCount = (dispatch.active || []).length;
2867
+ const maxConcurrent = config.engine?.maxConcurrent || 3;
2868
+
2869
+ if (activeCount >= maxConcurrent) {
2870
+ log('info', `At max concurrency (${activeCount}/${maxConcurrent}) — skipping dispatch`);
2871
+ return;
2872
+ }
2873
+
2874
+ const slotsAvailable = maxConcurrent - activeCount;
2875
+
2876
+ // Priority dispatch: fixes > reviews > plan-to-prd > implement > other
2877
+ const typePriority = { fix: 0, review: 1, test: 2, 'plan-to-prd': 3, 'implement:large': 4, implement: 5 };
2878
+ const itemPriority = { high: 0, medium: 1, low: 2 };
2879
+ dispatch.pending.sort((a, b) => {
2880
+ const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
2881
+ if (ta !== tb) return ta - tb;
2882
+ const pa = itemPriority[a.meta?.item?.priority] ?? 1, pb = itemPriority[b.meta?.item?.priority] ?? 1;
2883
+ return pa - pb;
2884
+ });
2885
+ safeWrite(DISPATCH_PATH, dispatch);
2886
+
2887
+ const toDispatch = dispatch.pending.slice(0, slotsAvailable);
2888
+
2889
+ // Collect IDs to dispatch, then spawn — spawnAgent mutates dispatch.pending
2890
+ // so we snapshot the items first and re-read dispatch state is handled per-spawn
2891
+ const idsToDispatch = toDispatch.map(item => item.id);
2892
+ for (const dispatchId of idsToDispatch) {
2893
+ // Re-read dispatch fresh each iteration since spawnAgent modifies it
2894
+ const freshDispatch = getDispatch();
2895
+ const item = freshDispatch.pending.find(d => d.id === dispatchId);
2896
+ if (item) {
2897
+ spawnAgent(item, config);
2898
+ }
2899
+ }
2900
+ }
2901
+
2902
+ // ─── CLI Commands ───────────────────────────────────────────────────────────
2903
+
2904
+ const commands = {
2905
+ start() {
2906
+ const control = getControl();
2907
+ if (control.state === 'running') {
2908
+ console.log('Engine is already running.');
2909
+ return;
2910
+ }
2911
+
2912
+ safeWrite(CONTROL_PATH, { state: 'running', pid: process.pid, started_at: ts() });
2913
+ log('info', 'Engine started');
2914
+ console.log(`Engine started (PID: ${process.pid})`);
2915
+
2916
+ const config = getConfig();
2917
+ const interval = config.engine?.tickInterval || 60000;
2918
+
2919
+ // Sync MCP servers from Claude Code
2920
+ syncMcpServers();
2921
+
2922
+ // Validate project paths
2923
+ const projects = getProjects(config);
2924
+ for (const p of projects) {
2925
+ const root = p.localPath ? path.resolve(p.localPath) : null;
2926
+ if (!root || !fs.existsSync(root)) {
2927
+ log('warn', `Project "${p.name}" path not found: ${p.localPath} — skipping`);
2928
+ console.log(` WARNING: ${p.name} path not found: ${p.localPath}`);
2929
+ } else {
2930
+ console.log(` Project: ${p.name} (${root})`);
2931
+ }
2932
+ }
2933
+
2934
+ // Load persistent state
2935
+ loadCooldowns();
2936
+
2937
+ // Initial tick
2938
+ tick();
2939
+
2940
+ // Start tick loop
2941
+ setInterval(tick, interval);
2942
+ console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 3}`);
2943
+ console.log('Press Ctrl+C to stop');
2944
+ },
2945
+
2946
+ stop() {
2947
+ safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: ts() });
2948
+ log('info', 'Engine stopped');
2949
+ console.log('Engine stopped.');
2950
+ },
2951
+
2952
+ pause() {
2953
+ safeWrite(CONTROL_PATH, { state: 'paused', paused_at: ts() });
2954
+ log('info', 'Engine paused');
2955
+ console.log('Engine paused. Run `node .squad/engine.js resume` to resume.');
2956
+ },
2957
+
2958
+ resume() {
2959
+ const control = getControl();
2960
+ if (control.state === 'running') {
2961
+ console.log('Engine is already running.');
2962
+ return;
2963
+ }
2964
+ safeWrite(CONTROL_PATH, { state: 'running', resumed_at: ts() });
2965
+ log('info', 'Engine resumed');
2966
+ console.log('Engine resumed.');
2967
+ },
2968
+
2969
+ status() {
2970
+ const config = getConfig();
2971
+ const control = getControl();
2972
+ const dispatch = getDispatch();
2973
+ const agents = config.agents || {};
2974
+
2975
+ const projects = getProjects(config);
2976
+
2977
+ console.log('\n=== Squad Engine ===\n');
2978
+ console.log(`State: ${control.state}`);
2979
+ console.log(`PID: ${control.pid || 'N/A'}`);
2980
+ console.log(`Projects: ${projects.map(p => p.name || 'unnamed').join(', ')}`);
2981
+ console.log('');
2982
+
2983
+ console.log('Agents:');
2984
+ console.log(` ${'ID'.padEnd(12)} ${'Name (Role)'.padEnd(30)} ${'Status'.padEnd(10)} Task`);
2985
+ console.log(' ' + '-'.repeat(70));
2986
+ for (const [id, agent] of Object.entries(agents)) {
2987
+ const status = getAgentStatus(id);
2988
+ console.log(` ${id.padEnd(12)} ${`${agent.emoji} ${agent.name} (${agent.role})`.padEnd(30)} ${(status.status || 'idle').padEnd(10)} ${status.task || '-'}`);
2989
+ }
2990
+
2991
+ console.log('');
2992
+ console.log(`Dispatch: ${dispatch.pending.length} pending | ${(dispatch.active || []).length} active | ${(dispatch.completed || []).length} completed`);
2993
+ console.log(`Active processes: ${activeProcesses.size}`);
2994
+
2995
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
2996
+ const metrics = safeJson(metricsPath);
2997
+ if (metrics && Object.keys(metrics).length > 0) {
2998
+ console.log('\nMetrics:');
2999
+ console.log(` ${'Agent'.padEnd(12)} ${'Done'.padEnd(6)} ${'Err'.padEnd(6)} ${'PRs'.padEnd(6)} ${'Approved'.padEnd(10)} ${'Rejected'.padEnd(10)} ${'Reviews'.padEnd(8)}`);
3000
+ console.log(' ' + '-'.repeat(58));
3001
+ for (const [id, m] of Object.entries(metrics)) {
3002
+ const approvalRate = m.prsCreated > 0 ? Math.round((m.prsApproved / m.prsCreated) * 100) + '%' : '-';
3003
+ console.log(` ${id.padEnd(12)} ${String(m.tasksCompleted).padEnd(6)} ${String(m.tasksErrored).padEnd(6)} ${String(m.prsCreated).padEnd(6)} ${String(m.prsApproved + ' (' + approvalRate + ')').padEnd(10)} ${String(m.prsRejected).padEnd(10)} ${String(m.reviewsDone).padEnd(8)}`);
3004
+ }
3005
+ }
3006
+ console.log('');
3007
+ },
3008
+
3009
+ queue() {
3010
+ const dispatch = getDispatch();
3011
+
3012
+ console.log('\n=== Dispatch Queue ===\n');
3013
+
3014
+ if (dispatch.pending.length) {
3015
+ console.log('PENDING:');
3016
+ for (const d of dispatch.pending) {
3017
+ console.log(` [${d.id}] ${d.type} → ${d.agent}: ${d.task}`);
3018
+ }
3019
+ } else {
3020
+ console.log('No pending dispatches.');
3021
+ }
3022
+
3023
+ if ((dispatch.active || []).length) {
3024
+ console.log('\nACTIVE:');
3025
+ for (const d of dispatch.active) {
3026
+ console.log(` [${d.id}] ${d.type} → ${d.agent}: ${d.task} (since ${d.started_at})`);
3027
+ }
3028
+ }
3029
+
3030
+ if ((dispatch.completed || []).length) {
3031
+ console.log(`\nCOMPLETED (last 5):`);
3032
+ for (const d of dispatch.completed.slice(-5)) {
3033
+ console.log(` [${d.id}] ${d.type} → ${d.agent}: ${d.result} (${d.completed_at})`);
3034
+ }
3035
+ }
3036
+ console.log('');
3037
+ },
3038
+
3039
+ complete(id) {
3040
+ if (!id) {
3041
+ console.log('Usage: node .squad/engine.js complete <dispatch-id>');
3042
+ return;
3043
+ }
3044
+ completeDispatch(id, 'success');
3045
+ console.log(`Marked ${id} as completed.`);
3046
+ },
3047
+
3048
+ dispatch() {
3049
+ console.log('Forcing dispatch cycle...');
3050
+ const control = getControl();
3051
+ const prevState = control.state;
3052
+ safeWrite(CONTROL_PATH, { ...control, state: 'running' });
3053
+ tick();
3054
+ if (prevState !== 'running') {
3055
+ safeWrite(CONTROL_PATH, { ...control, state: prevState });
3056
+ }
3057
+ console.log('Dispatch cycle complete.');
3058
+ },
3059
+
3060
+ spawn(agentId, ...promptParts) {
3061
+ const prompt = promptParts.join(' ');
3062
+ if (!agentId || !prompt) {
3063
+ console.log('Usage: node .squad/engine.js spawn <agent-id> "<prompt>"');
3064
+ return;
3065
+ }
3066
+
3067
+ const config = getConfig();
3068
+ if (!config.agents[agentId]) {
3069
+ console.log(`Unknown agent: ${agentId}. Available: ${Object.keys(config.agents).join(', ')}`);
3070
+ return;
3071
+ }
3072
+
3073
+ const id = addToDispatch({
3074
+ type: 'manual',
3075
+ agent: agentId,
3076
+ agentName: config.agents[agentId].name,
3077
+ agentRole: config.agents[agentId].role,
3078
+ task: prompt.substring(0, 100),
3079
+ prompt: prompt,
3080
+ meta: {}
3081
+ });
3082
+
3083
+ // Immediately dispatch
3084
+ const dispatch = getDispatch();
3085
+ const item = dispatch.pending.find(d => d.id === id);
3086
+ if (item) {
3087
+ spawnAgent(item, config);
3088
+ }
3089
+ },
3090
+
3091
+ work(title, ...rest) {
3092
+ if (!title) {
3093
+ console.log('Usage: node .squad/engine.js work "<title>" [options-json]');
3094
+ console.log('Options: {"type":"implement","priority":"high","agent":"dallas","description":"...","branch":"feature/..."}');
3095
+ return;
3096
+ }
3097
+
3098
+ let opts = {};
3099
+ const optStr = rest.join(' ');
3100
+ if (optStr) {
3101
+ try { opts = JSON.parse(optStr); } catch {
3102
+ console.log('Warning: Could not parse options JSON, using defaults');
3103
+ }
3104
+ }
3105
+
3106
+ // Resolve which project to add work to
3107
+ const projects = getProjects(config);
3108
+ const targetProject = opts.project
3109
+ ? projects.find(p => p.name?.toLowerCase() === opts.project?.toLowerCase()) || projects[0]
3110
+ : projects[0];
3111
+ const wiPath = projectWorkItemsPath(targetProject);
3112
+ const items = safeJson(wiPath) || [];
3113
+
3114
+ const item = {
3115
+ id: `W${String(items.length + 1).padStart(3, '0')}`,
3116
+ title: title,
3117
+ type: opts.type || 'implement',
3118
+ status: 'queued',
3119
+ priority: opts.priority || 'medium',
3120
+ complexity: opts.complexity || 'medium',
3121
+ description: opts.description || title,
3122
+ agent: opts.agent || null,
3123
+ branch: opts.branch || null,
3124
+ prompt: opts.prompt || null,
3125
+ created_at: ts()
3126
+ };
3127
+
3128
+ items.push(item);
3129
+ safeWrite(wiPath, items);
3130
+
3131
+ console.log(`Queued work item: ${item.id} — ${item.title} (project: ${targetProject.name || 'default'})`);
3132
+ console.log(` Type: ${item.type} | Priority: ${item.priority} | Agent: ${item.agent || 'auto'}`);
3133
+ },
3134
+
3135
+ plan(source, projectName) {
3136
+ if (!source) {
3137
+ console.log('Usage: node .squad/engine.js plan <source> [project]');
3138
+ console.log('');
3139
+ console.log('Source can be:');
3140
+ console.log(' - A file path (markdown, txt, or json)');
3141
+ console.log(' - Inline text wrapped in quotes');
3142
+ console.log('');
3143
+ console.log('Examples:');
3144
+ console.log(' node engine.js plan ./my-plan.md');
3145
+ console.log(' node engine.js plan ./my-plan.md MyProject');
3146
+ console.log(' node engine.js plan "Add auth middleware with JWT tokens and role-based access"');
3147
+ return;
3148
+ }
3149
+
3150
+ const config = getConfig();
3151
+ const projects = getProjects(config);
3152
+ const targetProject = projectName
3153
+ ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || projects[0]
3154
+ : projects[0];
3155
+
3156
+ if (!targetProject) {
3157
+ console.log('No projects configured. Run: node squad.js add <dir>');
3158
+ return;
3159
+ }
3160
+
3161
+ // Read plan content from file or use inline text
3162
+ let planContent;
3163
+ let planSummary;
3164
+ const sourcePath = path.resolve(source);
3165
+ if (fs.existsSync(sourcePath)) {
3166
+ planContent = fs.readFileSync(sourcePath, 'utf8');
3167
+ planSummary = path.basename(sourcePath, path.extname(sourcePath));
3168
+ console.log(`Reading plan from: ${sourcePath}`);
3169
+ } else {
3170
+ planContent = source;
3171
+ planSummary = source.substring(0, 60).replace(/[^a-zA-Z0-9 -]/g, '').trim();
3172
+ console.log('Using inline plan text.');
3173
+ }
3174
+
3175
+ console.log(`Target project: ${targetProject.name}`);
3176
+ console.log(`Plan summary: ${planSummary}`);
3177
+ console.log('');
3178
+
3179
+ // Dispatch as a plan-to-prd work item
3180
+ const agentId = resolveAgent('analyze', config) || resolveAgent('explore', config);
3181
+ if (!agentId) {
3182
+ console.log('No agents available. All agents are busy.');
3183
+ return;
3184
+ }
3185
+
3186
+ const vars = {
3187
+ agent_id: agentId,
3188
+ agent_name: config.agents[agentId]?.name,
3189
+ agent_role: config.agents[agentId]?.role,
3190
+ project_name: targetProject.name || 'Unknown',
3191
+ project_path: targetProject.localPath || '',
3192
+ main_branch: targetProject.mainBranch || 'main',
3193
+ ado_org: targetProject.adoOrg || config.project?.adoOrg || 'Unknown',
3194
+ ado_project: targetProject.adoProject || config.project?.adoProject || 'Unknown',
3195
+ repo_name: targetProject.repoName || config.project?.repoName || 'Unknown',
3196
+ team_root: SQUAD_DIR,
3197
+ date: dateStamp(),
3198
+ plan_content: planContent,
3199
+ plan_summary: planSummary,
3200
+ project_name_lower: (targetProject.name || 'project').toLowerCase()
3201
+ };
3202
+
3203
+ // Ensure plans directory exists
3204
+ if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
3205
+
3206
+ const prompt = renderPlaybook('plan-to-prd', vars);
3207
+ if (!prompt) {
3208
+ console.log('Error: Could not render plan-to-prd playbook.');
3209
+ return;
3210
+ }
3211
+
3212
+ const id = addToDispatch({
3213
+ type: 'plan-to-prd',
3214
+ agent: agentId,
3215
+ agentName: config.agents[agentId]?.name,
3216
+ agentRole: config.agents[agentId]?.role,
3217
+ task: `[${targetProject.name}] Generate PRD from plan: ${planSummary}`,
3218
+ prompt,
3219
+ meta: {
3220
+ source: 'plan',
3221
+ project: { name: targetProject.name, localPath: targetProject.localPath },
3222
+ planSummary
3223
+ }
3224
+ });
3225
+
3226
+ console.log(`Dispatched: ${id} → ${config.agents[agentId]?.name} (${agentId})`);
3227
+ console.log('The agent will analyze your plan and generate docs/prd-gaps.json as a PR.');
3228
+
3229
+ // Immediately dispatch if engine is running
3230
+ const control = getControl();
3231
+ if (control.state === 'running') {
3232
+ const dispatch = getDispatch();
3233
+ const item = dispatch.pending.find(d => d.id === id);
3234
+ if (item) {
3235
+ spawnAgent(item, config);
3236
+ console.log('Agent spawned immediately.');
3237
+ }
3238
+ } else {
3239
+ console.log('Engine is not running — dispatch will happen on next tick after start.');
3240
+ }
3241
+ },
3242
+
3243
+ sources() {
3244
+ const config = getConfig();
3245
+ const projects = getProjects(config);
3246
+
3247
+ console.log('\n=== Work Sources ===\n');
3248
+
3249
+ for (const project of projects) {
3250
+ const root = projectRoot(project);
3251
+ console.log(`── ${project.name || 'Project'} (${root}) ──\n`);
3252
+
3253
+ const sources = project.workSources || config.workSources || {};
3254
+ for (const [name, src] of Object.entries(sources)) {
3255
+ const status = src.enabled ? 'ENABLED' : 'DISABLED';
3256
+ console.log(` ${name}: ${status}`);
3257
+
3258
+ const filePath = src.path ? path.resolve(root, src.path) : null;
3259
+ const exists = filePath && fs.existsSync(filePath);
3260
+ if (filePath) {
3261
+ console.log(` Path: ${src.path} ${exists ? '(found)' : '(NOT FOUND)'}`);
3262
+ }
3263
+ console.log(` Cooldown: ${src.cooldownMinutes || 0}m`);
3264
+
3265
+ if (exists && name === 'prd') {
3266
+ const prd = safeJson(filePath);
3267
+ if (prd) {
3268
+ const missing = (prd.missing_features || []).filter(f => ['missing', 'planned'].includes(f.status));
3269
+ console.log(` Items: ${missing.length} missing/planned features`);
3270
+ }
3271
+ }
3272
+ if (exists && name === 'pullRequests') {
3273
+ const prs = safeJson(filePath) || [];
3274
+ const pending = prs.filter(p => p.status === 'active' && (p.reviewStatus === 'pending' || p.reviewStatus === 'waiting'));
3275
+ const needsFix = prs.filter(p => p.status === 'active' && p.reviewStatus === 'changes-requested');
3276
+ console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
3277
+ }
3278
+ if (exists && name === 'workItems') {
3279
+ const items = safeJson(filePath) || [];
3280
+ const queued = items.filter(i => i.status === 'queued');
3281
+ console.log(` Items: ${queued.length} queued`);
3282
+ }
3283
+ if (name === 'specs' || name === 'mergedDesignDocs') {
3284
+ const trackerFile = path.resolve(root, src.statePath || '.squad/spec-tracker.json');
3285
+ const tracker = safeJson(trackerFile) || { processedPrs: {} };
3286
+ const processed = Object.keys(tracker.processedPrs).length;
3287
+ const matched = Object.values(tracker.processedPrs).filter(p => p.matched).length;
3288
+ console.log(` Processed: ${processed} merged PRs (${matched} had specs)`);
3289
+ }
3290
+ console.log('');
3291
+ }
3292
+ }
3293
+ },
3294
+
3295
+ kill() {
3296
+ console.log('\n=== Kill All Active Work ===\n');
3297
+ const config = getConfig();
3298
+ const dispatch = getDispatch();
3299
+
3300
+ // 1. Kill processes
3301
+ const pidFiles = fs.readdirSync(ENGINE_DIR).filter(f => f.startsWith('pid-'));
3302
+ for (const f of pidFiles) {
3303
+ const pid = safeRead(path.join(ENGINE_DIR, f)).trim();
3304
+ try { process.kill(Number(pid)); console.log(`Killed process ${pid} (${f})`); } catch { console.log(`Process ${pid} already dead`); }
3305
+ fs.unlinkSync(path.join(ENGINE_DIR, f));
3306
+ }
3307
+
3308
+ // 2. Clear active dispatches and reset their work items to pending
3309
+ // NOTE: we do NOT add killed items to completed — that would block re-dispatch via dedup
3310
+ const killed = dispatch.active || [];
3311
+ for (const item of killed) {
3312
+
3313
+ // Reset the source work item to pending
3314
+ if (item.meta) {
3315
+ updateWorkItemStatus(item.meta, 'pending', '');
3316
+ // Undo the 'failed' status that updateWorkItemStatus may set — we want 'pending'
3317
+ const itemId = item.meta.item?.id;
3318
+ if (itemId) {
3319
+ const wiPath = (item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout')
3320
+ ? path.join(SQUAD_DIR, 'work-items.json')
3321
+ : item.meta.project?.localPath
3322
+ ? projectWorkItemsPath({ localPath: item.meta.project.localPath, name: item.meta.project.name, workSources: config.projects?.find(p => p.name === item.meta.project.name)?.workSources })
3323
+ : null;
3324
+ if (wiPath) {
3325
+ const items = safeJson(wiPath) || [];
3326
+ const target = items.find(i => i.id === itemId);
3327
+ if (target) {
3328
+ target.status = 'pending';
3329
+ delete target.dispatched_at;
3330
+ delete target.dispatched_to;
3331
+ delete target.failReason;
3332
+ delete target.failedAt;
3333
+ safeWrite(wiPath, items);
3334
+ }
3335
+ }
3336
+ }
3337
+ }
3338
+
3339
+ console.log(`Killed dispatch: ${item.id} (${item.agent}) — work item reset to pending`);
3340
+ }
3341
+ dispatch.active = [];
3342
+ safeWrite(DISPATCH_PATH, dispatch);
3343
+
3344
+ // 3. Reset all agents to idle
3345
+ for (const [agentId] of Object.entries(config.agents || {})) {
3346
+ const status = getAgentStatus(agentId);
3347
+ if (status.status === 'working' || status.status === 'error') {
3348
+ setAgentStatus(agentId, { status: 'idle', task: null, started_at: null, completed_at: ts() });
3349
+ console.log(`Reset ${agentId} to idle`);
3350
+ }
3351
+ }
3352
+
3353
+ console.log(`\nDone: ${killed.length} dispatches killed, agents reset.`);
3354
+ },
3355
+
3356
+ cleanup() {
3357
+ const config = getConfig();
3358
+ console.log('\n=== Cleanup ===\n');
3359
+ const result = runCleanup(config, true);
3360
+ console.log(`\nDone: ${result.tempFiles} temp files, ${result.liveOutputs} live outputs, ${result.worktrees} worktrees, ${result.zombies} zombies cleaned.`);
3361
+ },
3362
+
3363
+ 'mcp-sync'() {
3364
+ syncMcpServers();
3365
+ },
3366
+
3367
+ discover() {
3368
+ const config = getConfig();
3369
+ console.log('\n=== Work Discovery (dry run) ===\n');
3370
+
3371
+ materializePlansAsWorkItems(config);
3372
+ const prdWork = discoverFromPrd(config);
3373
+ const prWork = discoverFromPrs(config);
3374
+ const workItemWork = discoverFromWorkItems(config);
3375
+
3376
+ const all = [...prdWork, ...prWork, ...workItemWork];
3377
+
3378
+ if (all.length === 0) {
3379
+ console.log('No new work discovered from any source.');
3380
+ } else {
3381
+ console.log(`Found ${all.length} items:\n`);
3382
+ for (const w of all) {
3383
+ console.log(` [${w.meta?.source}] ${w.type} → ${w.agent}: ${w.task}`);
3384
+ }
3385
+ }
3386
+ console.log('');
3387
+ }
3388
+ };
3389
+
3390
+ // ─── Entrypoint ─────────────────────────────────────────────────────────────
3391
+
3392
+ const [cmd, ...args] = process.argv.slice(2);
3393
+
3394
+ if (!cmd) {
3395
+ commands.start();
3396
+ } else if (commands[cmd]) {
3397
+ commands[cmd](...args);
3398
+ } else {
3399
+ console.log(`Unknown command: ${cmd}`);
3400
+ console.log('Commands:');
3401
+ console.log(' start Start engine daemon');
3402
+ console.log(' stop Stop engine');
3403
+ console.log(' pause / resume Pause/resume dispatching');
3404
+ console.log(' status Show engine + agent state');
3405
+ console.log(' queue Show dispatch queue');
3406
+ console.log(' sources Show work source status');
3407
+ console.log(' discover Dry-run work discovery');
3408
+ console.log(' dispatch Force a dispatch cycle');
3409
+ console.log(' spawn <a> <p> Manually spawn agent with prompt');
3410
+ console.log(' work <title> [o] Add to work-items.json queue');
3411
+ console.log(' plan <src> [p] Generate PRD from a plan (file or text)');
3412
+ console.log(' complete <id> Mark dispatch as done');
3413
+ console.log(' cleanup Clean temp files, worktrees, zombies');
3414
+ console.log(' mcp-sync Sync MCP servers from ~/.claude.json');
3415
+ process.exit(1);
3416
+ }