@yemi33/minions 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +819 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -0
  4. package/agents/dallas/charter.md +56 -0
  5. package/agents/lambert/charter.md +67 -0
  6. package/agents/ralph/charter.md +45 -0
  7. package/agents/rebecca/charter.md +57 -0
  8. package/agents/ripley/charter.md +47 -0
  9. package/bin/minions.js +467 -0
  10. package/config.template.json +28 -0
  11. package/dashboard.html +4822 -0
  12. package/dashboard.js +2623 -0
  13. package/docs/auto-discovery.md +416 -0
  14. package/docs/blog-first-successful-dispatch.md +128 -0
  15. package/docs/command-center.md +156 -0
  16. package/docs/demo/01-dashboard-overview.gif +0 -0
  17. package/docs/demo/02-command-center.gif +0 -0
  18. package/docs/demo/03-work-items.gif +0 -0
  19. package/docs/demo/04-plan-docchat.gif +0 -0
  20. package/docs/demo/05-prd-progress.gif +0 -0
  21. package/docs/demo/06-inbox-metrics.gif +0 -0
  22. package/docs/deprecated.json +83 -0
  23. package/docs/distribution.md +96 -0
  24. package/docs/engine-restart.md +92 -0
  25. package/docs/human-vs-automated.md +108 -0
  26. package/docs/index.html +221 -0
  27. package/docs/plan-lifecycle.md +140 -0
  28. package/docs/self-improvement.md +344 -0
  29. package/engine/ado-mcp-wrapper.js +42 -0
  30. package/engine/ado.js +383 -0
  31. package/engine/check-status.js +23 -0
  32. package/engine/cli.js +754 -0
  33. package/engine/consolidation.js +417 -0
  34. package/engine/github.js +331 -0
  35. package/engine/lifecycle.js +1113 -0
  36. package/engine/llm.js +116 -0
  37. package/engine/queries.js +677 -0
  38. package/engine/shared.js +397 -0
  39. package/engine/spawn-agent.js +151 -0
  40. package/engine.js +3227 -0
  41. package/minions.js +556 -0
  42. package/package.json +48 -0
  43. package/playbooks/ask.md +49 -0
  44. package/playbooks/build-and-test.md +155 -0
  45. package/playbooks/explore.md +64 -0
  46. package/playbooks/fix.md +57 -0
  47. package/playbooks/implement-shared.md +68 -0
  48. package/playbooks/implement.md +95 -0
  49. package/playbooks/plan-to-prd.md +104 -0
  50. package/playbooks/plan.md +99 -0
  51. package/playbooks/review.md +68 -0
  52. package/playbooks/test.md +75 -0
  53. package/playbooks/verify.md +190 -0
  54. package/playbooks/work-item.md +74 -0
package/engine.js ADDED
@@ -0,0 +1,3227 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minions 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 .minions/engine.js Start the engine (daemon mode)
11
+ * node .minions/engine.js status Show current state
12
+ * node .minions/engine.js pause Pause dispatching
13
+ * node .minions/engine.js resume Resume dispatching
14
+ * node .minions/engine.js stop Stop the engine
15
+ * node .minions/engine.js queue Show dispatch queue
16
+ * node .minions/engine.js dispatch Force a dispatch cycle
17
+ * node .minions/engine.js complete <id> Mark dispatch as done
18
+ * node .minions/engine.js spawn <agent> <prompt> Manually spawn an agent
19
+ * node .minions/engine.js work <title> [opts-json] Add to work queue
20
+ * node .minions/engine.js sources Show work source status
21
+ * node .minions/engine.js discover Dry-run work discovery
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const shared = require('./engine/shared');
27
+ const { exec, execSilent, runFile, ENGINE_DEFAULTS: DEFAULTS } = shared;
28
+ const queries = require('./engine/queries');
29
+
30
+ // ─── Paths ──────────────────────────────────────────────────────────────────
31
+
32
+ const MINIONS_DIR = __dirname;
33
+ const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
34
+ const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
35
+ const ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
36
+ const IDENTITY_DIR = path.join(MINIONS_DIR, 'identity');
37
+
38
+ // Re-export from queries for internal use (avoid changing every call site)
39
+ const { CONFIG_PATH, NOTES_PATH, AGENTS_DIR, ENGINE_DIR, CONTROL_PATH,
40
+ DISPATCH_PATH, LOG_PATH, INBOX_DIR, KNOWLEDGE_DIR, PLANS_DIR, PRD_DIR } = queries;
41
+
42
+ // ─── Multi-Project Support ──────────────────────────────────────────────────
43
+ // Config can have either:
44
+ // "project": { ... } — single project (legacy, .minions inside repo)
45
+ // "projects": [ { ... }, ... ] — multi-project (central .minions)
46
+ // Each project must have "localPath" pointing to the repo root.
47
+
48
+ function validateConfig(config) {
49
+ let errors = 0;
50
+ // Agents
51
+ if (!config.agents || Object.keys(config.agents).length === 0) {
52
+ console.error('FATAL: No agents defined in config.json');
53
+ errors++;
54
+ }
55
+ // Projects
56
+ const projects = getProjects(config);
57
+ if (projects.length === 0) {
58
+ console.error('FATAL: No projects configured');
59
+ errors++;
60
+ }
61
+ for (const p of projects) {
62
+ if (!p.localPath || !fs.existsSync(path.resolve(p.localPath))) {
63
+ console.error(`WARN: Project "${p.name}" path not found: ${p.localPath}`);
64
+ }
65
+ if (!p.repositoryId) {
66
+ console.warn(`WARN: Project "${p.name}" missing repositoryId — PR operations will fail`);
67
+ }
68
+ }
69
+ // Playbooks
70
+ const requiredPlaybooks = ['implement', 'review', 'fix', 'work-item'];
71
+ for (const pb of requiredPlaybooks) {
72
+ if (!fs.existsSync(path.join(PLAYBOOKS_DIR, `${pb}.md`))) {
73
+ console.error(`WARN: Missing playbook: playbooks/${pb}.md`);
74
+ }
75
+ }
76
+ // Routing
77
+ if (!fs.existsSync(ROUTING_PATH)) {
78
+ console.error('WARN: routing.md not found — agent routing will use fallbacks only');
79
+ }
80
+ if (errors > 0) {
81
+ console.error(`\n${errors} fatal config error(s) — exiting.`);
82
+ process.exit(1);
83
+ }
84
+ }
85
+
86
+ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, projectPrPath, getAdoOrgBase, sanitizeBranch, parseSkillFrontmatter, safeReadDir } = shared;
87
+
88
+ // ─── Utilities ──────────────────────────────────────────────────────────────
89
+
90
+ function ts() { return new Date().toISOString(); }
91
+ function logTs() { return new Date().toLocaleTimeString(); }
92
+ function dateStamp() { return new Date().toISOString().slice(0, 10); }
93
+
94
+ const safeJson = shared.safeJson;
95
+ const safeRead = shared.safeRead;
96
+ const safeWrite = shared.safeWrite;
97
+ const mutateJsonFileLocked = shared.mutateJsonFileLocked;
98
+
99
+ function log(level, msg, meta = {}) {
100
+ const entry = { timestamp: ts(), level, message: msg, ...meta };
101
+ console.log(`[${logTs()}] [${level}] ${msg}`);
102
+
103
+ let logData = safeJson(LOG_PATH) || [];
104
+ if (!Array.isArray(logData)) logData = logData.entries || [];
105
+ logData.push(entry);
106
+ if (logData.length > 500) logData.splice(0, logData.length - 500);
107
+ safeWrite(LOG_PATH, logData);
108
+ }
109
+
110
+ function mutateDispatch(mutator) {
111
+ const defaultDispatch = { pending: [], active: [], completed: [] };
112
+ return mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
113
+ dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
114
+ dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
115
+ dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
116
+ return mutator(dispatch) || dispatch;
117
+ }, { defaultValue: defaultDispatch });
118
+ }
119
+
120
+ // ─── State Readers (delegated to engine/queries.js) ─────────────────────────
121
+
122
+ const { getConfig, getControl, getDispatch, getNotes,
123
+ getAgentStatus, getAgentCharter, getInboxFiles,
124
+ collectSkillFiles, getSkillIndex, getKnowledgeBaseIndex,
125
+ getPrs, SKILLS_DIR } = queries;
126
+
127
+ function getRouting() {
128
+ return safeRead(ROUTING_PATH);
129
+ }
130
+
131
+ // ─── Routing Parser ─────────────────────────────────────────────────────────
132
+
133
+ let _routingCache = null;
134
+ let _routingCacheMtime = 0;
135
+
136
+ function parseRoutingTable() {
137
+ const content = getRouting();
138
+ const routes = {};
139
+ const lines = content.split('\n');
140
+ let inTable = false;
141
+
142
+ for (const line of lines) {
143
+ if (line.startsWith('| Work Type')) { inTable = true; continue; }
144
+ if (line.startsWith('|---')) continue;
145
+ if (!inTable || !line.startsWith('|')) {
146
+ if (inTable && !line.startsWith('|')) inTable = false;
147
+ continue;
148
+ }
149
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
150
+ if (cells.length >= 3) {
151
+ routes[cells[0].toLowerCase()] = {
152
+ preferred: cells[1].toLowerCase(),
153
+ fallback: cells[2].toLowerCase()
154
+ };
155
+ }
156
+ }
157
+ return routes;
158
+ }
159
+
160
+ function getRoutingTableCached() {
161
+ let mtime = 0;
162
+ try { mtime = fs.statSync(ROUTING_PATH).mtimeMs; } catch {}
163
+ if (_routingCache && _routingCacheMtime === mtime) return _routingCache;
164
+ _routingCache = parseRoutingTable();
165
+ _routingCacheMtime = mtime;
166
+ return _routingCache;
167
+ }
168
+
169
+ function getAgentErrorRate(agentId) {
170
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
171
+ const metrics = safeJson(metricsPath) || {};
172
+ const m = metrics[agentId];
173
+ if (!m) return 0;
174
+ const total = m.tasksCompleted + m.tasksErrored;
175
+ return total > 0 ? m.tasksErrored / total : 0;
176
+ }
177
+
178
+ function isAgentIdle(agentId) {
179
+ // Dispatch queue is the single source of truth for agent availability
180
+ const dispatch = safeJson(DISPATCH_PATH) || {};
181
+ return !(dispatch.active || []).some(d => d.agent === agentId);
182
+ }
183
+
184
+ // Track agents claimed during a single discovery pass to distribute work
185
+ const _claimedAgents = new Set();
186
+ function resetClaimedAgents() { _claimedAgents.clear(); }
187
+
188
+ function resolveAgent(workType, config, authorAgent = null) {
189
+ const routes = getRoutingTableCached();
190
+ const route = routes[workType] || routes['implement'];
191
+ const agents = config.agents || {};
192
+
193
+ // Resolve _author_ token
194
+ let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
195
+ let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
196
+
197
+ const isAvailable = (id) => agents[id] && isAgentIdle(id) && !_claimedAgents.has(id);
198
+
199
+ // Check preferred and fallback first (routing table order)
200
+ if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
201
+ if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
202
+
203
+ // Fall back to any idle agent, preferring lower error rates
204
+ const idle = Object.keys(agents)
205
+ .filter(id => id !== preferred && id !== fallback && isAvailable(id))
206
+ .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
207
+
208
+ if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
209
+
210
+ // No idle agent available — return null, item stays pending until next tick
211
+ return null;
212
+ }
213
+
214
+ // ─── Task Context Resolution ────────────────────────────────────────────────
215
+ // Resolves implicit references in task descriptions (e.g., "ripley's plan",
216
+ // "dallas's PR") to actual artifacts and injects their content.
217
+
218
+ function resolveTaskContext(item, config) {
219
+ const title = (item.title || '').toLowerCase();
220
+ const desc = (item.description || '').toLowerCase();
221
+ const text = title + ' ' + desc;
222
+ const agentNames = Object.entries(config.agents || {}).map(([id, a]) => ({
223
+ id,
224
+ name: (a.name || id).toLowerCase(),
225
+ }));
226
+ const resolved = { additionalContext: '', referencedFiles: [] };
227
+
228
+ // Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
229
+ for (const agent of agentNames) {
230
+ const patterns = [
231
+ new RegExp(`${agent.name}(?:'s|s)?\\s+plan`, 'i'),
232
+ new RegExp(`${agent.id}(?:'s|s)?\\s+plan`, 'i'),
233
+ new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.name}`, 'i'),
234
+ new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.id}`, 'i'),
235
+ ];
236
+ const matchesPlan = patterns.some(p => p.test(text));
237
+ if (matchesPlan) {
238
+ // Find plans created by this agent (check work items for plan tasks dispatched to this agent)
239
+ try {
240
+ const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans')).filter(f => f.endsWith('.md') || f.endsWith('.json'));
241
+ // Check work-items to find which plan file this agent created
242
+ const workItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
243
+ const agentPlanItems = workItems.filter(w =>
244
+ w.type === 'plan' && w.dispatched_to === agent.id && w.status === 'done' && w._planFileName
245
+ ).sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));
246
+
247
+ if (agentPlanItems.length > 0) {
248
+ const planFile = agentPlanItems[0]._planFileName;
249
+ const planPath = path.join(MINIONS_DIR, 'plans', planFile);
250
+ try {
251
+ const content = safeRead(planPath);
252
+ resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${content}`;
253
+ resolved.referencedFiles.push(planPath);
254
+ log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
255
+ } catch {}
256
+ } else if (plans.length > 0) {
257
+ // Fallback: try to find a plan file with the agent's name or ID in it
258
+ const match = plans.find(f => f.toLowerCase().includes(agent.id) || f.toLowerCase().includes(agent.name));
259
+ if (match) {
260
+ const planPath = path.join(MINIONS_DIR, 'plans', match);
261
+ try {
262
+ const content = safeRead(planPath);
263
+ resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${content}`;
264
+ resolved.referencedFiles.push(planPath);
265
+ log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
266
+ } catch {}
267
+ }
268
+ }
269
+ } catch {}
270
+ }
271
+
272
+ // Match agent output/notes references
273
+ const outputPatterns = [
274
+ new RegExp(`${agent.name}(?:'s|s)?\\s+(?:output|findings|notes|results)`, 'i'),
275
+ new RegExp(`(?:output|findings|notes|results)\\s+(?:from|by)\\s+${agent.name}`, 'i'),
276
+ ];
277
+ if (outputPatterns.some(p => p.test(text))) {
278
+ // Find the agent's latest inbox notes
279
+ try {
280
+ const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
281
+ const files = fs.readdirSync(inboxDir)
282
+ .filter(f => f.startsWith(agent.id + '-'))
283
+ .sort().reverse();
284
+ if (files.length > 0) {
285
+ const content = safeRead(path.join(inboxDir, files[0]));
286
+ resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${content.slice(0, 5000)}`;
287
+ resolved.referencedFiles.push(path.join(inboxDir, files[0]));
288
+ log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
289
+ }
290
+ } catch {}
291
+ }
292
+ }
293
+
294
+ // If no specific reference was resolved but the text mentions "the plan" or "latest plan",
295
+ // find the most recent plan
296
+ if (!resolved.additionalContext && /\b(the|latest|last|recent)\s+plan\b/i.test(text)) {
297
+ try {
298
+ const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans'))
299
+ .filter(f => f.endsWith('.md') || f.endsWith('.json'))
300
+ .sort().reverse();
301
+ if (plans.length > 0) {
302
+ const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
303
+ const content = safeRead(planPath);
304
+ resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${content}`;
305
+ resolved.referencedFiles.push(planPath);
306
+ log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
307
+ }
308
+ } catch {}
309
+ }
310
+
311
+ return resolved;
312
+ }
313
+
314
+ // ─── Playbook Renderer ──────────────────────────────────────────────────────
315
+
316
+ function renderPlaybook(type, vars) {
317
+ const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
318
+ let content;
319
+ try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
320
+ log('warn', `Playbook not found: ${type}`);
321
+ return null;
322
+ }
323
+
324
+ // Inject team notes context
325
+ const notes = getNotes();
326
+ if (notes) {
327
+ content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
328
+ }
329
+
330
+ // Inject KB guardrail
331
+ content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
332
+ content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
333
+
334
+ // Inject learnings requirement
335
+ content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
336
+ content += `After completing your task, you MUST write a findings/learnings file to:\n`;
337
+ content += `\`${MINIONS_DIR}/notes/inbox/${vars.agent_id || 'agent'}-${dateStamp()}.md\`\n\n`;
338
+ content += `Include:\n`;
339
+ content += `- What you learned about the codebase\n`;
340
+ content += `- Patterns you discovered or established\n`;
341
+ content += `- Gotchas or warnings for future agents\n`;
342
+ content += `- Conventions to follow\n`;
343
+ content += `- **SOURCE REFERENCES for every finding** — file paths with line numbers, PR URLs, API endpoints, config keys. Format: \`(source: path/to/file.ts:42)\` or \`(source: PR-12345)\`. Without references, findings cannot be verified.\n\n`;
344
+ content += `### Skill Extraction (IMPORTANT)\n\n`;
345
+ 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`;
346
+ content += `Format your skill as a fenced code block with the \`skill\` language tag:\n\n`;
347
+ content += '````\n```skill\n';
348
+ 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: minions\nproject: any\n---\n\n# Skill Title\n\n## Steps\n1. ...\n2. ...\n\n## Notes\n...\n`;
349
+ content += '```\n````\n\n';
350
+ content += `- Set \`scope: minions\` for cross-project skills (engine writes to ~/.claude/skills/ automatically)\n`;
351
+ content += `- Set \`scope: project\` + \`project: <name>\` for repo-specific skills (engine queues a PR to <project>/.claude/skills/)\n`;
352
+ content += `- Only output a skill block if you genuinely discovered something reusable — don't force it\n`;
353
+
354
+ // Inject project-level variables from config
355
+ const config = getConfig();
356
+ const projects = getProjects(config);
357
+ // Find the specific project being dispatched (match by repo_id or repo_name from vars)
358
+ const dispatchProject = (vars.repo_id && projects.find(p => p.repositoryId === vars.repo_id))
359
+ || (vars.repo_name && projects.find(p => p.repoName === vars.repo_name))
360
+ || projects[0] || {};
361
+ const projectVars = {
362
+ project_name: dispatchProject.name || 'Unknown Project',
363
+ ado_org: dispatchProject.adoOrg || 'Unknown',
364
+ ado_project: dispatchProject.adoProject || 'Unknown',
365
+ repo_name: dispatchProject.repoName || 'Unknown',
366
+ pr_create_instructions: getPrCreateInstructions(dispatchProject),
367
+ pr_comment_instructions: getPrCommentInstructions(dispatchProject),
368
+ pr_fetch_instructions: getPrFetchInstructions(dispatchProject),
369
+ pr_vote_instructions: getPrVoteInstructions(dispatchProject),
370
+ repo_host_label: getRepoHostLabel(dispatchProject),
371
+ };
372
+ const allVars = { ...projectVars, ...vars };
373
+
374
+ // Substitute variables
375
+ for (const [key, val] of Object.entries(allVars)) {
376
+ content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
377
+ }
378
+
379
+ return content;
380
+ }
381
+
382
+ // ─── Repo Host Helpers ──────────────────────────────────────────────────────
383
+
384
+ function getRepoHost(project) {
385
+ return project?.repoHost || 'ado';
386
+ }
387
+
388
+ function getPrCreateInstructions(project) {
389
+ const host = getRepoHost(project);
390
+ const repoId = project?.repositoryId || '';
391
+ if (host === 'github') {
392
+ return `Use \`gh pr create\` or the GitHub MCP tools to create a pull request.`;
393
+ }
394
+ // Default: Azure DevOps
395
+ return `Use \`mcp__azure-ado__repo_create_pull_request\`:\n- repositoryId: \`${repoId}\``;
396
+ }
397
+
398
+ function getPrCommentInstructions(project) {
399
+ const host = getRepoHost(project);
400
+ const repoId = project?.repositoryId || '';
401
+ if (host === 'github') {
402
+ return `Use \`gh pr comment\` or the GitHub MCP tools to post a comment on the PR.`;
403
+ }
404
+ return `Use \`mcp__azure-ado__repo_create_pull_request_thread\`:\n- repositoryId: \`${repoId}\``;
405
+ }
406
+
407
+ function getPrFetchInstructions(project) {
408
+ const host = getRepoHost(project);
409
+ if (host === 'github') {
410
+ return `Use \`gh pr view\` or the GitHub MCP tools to fetch PR status.`;
411
+ }
412
+ return `Use \`mcp__azure-ado__repo_get_pull_request_by_id\` to fetch PR status.`;
413
+ }
414
+
415
+ function getPrVoteInstructions(project) {
416
+ const host = getRepoHost(project);
417
+ const repoId = project?.repositoryId || '';
418
+ if (host === 'github') {
419
+ return `Use \`gh pr review\` to approve or request changes:\n- \`gh pr review <number> --approve\`\n- \`gh pr review <number> --request-changes\``;
420
+ }
421
+ 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)`;
422
+ }
423
+
424
+ function getRepoHostLabel(project) {
425
+ const host = getRepoHost(project);
426
+ if (host === 'github') return 'GitHub';
427
+ return 'Azure DevOps';
428
+ }
429
+
430
+ function getRepoHostToolRule(project) {
431
+ const host = getRepoHost(project);
432
+ if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
433
+ return 'Use Azure DevOps MCP tools (mcp__azure-ado__*) for PR operations — NEVER use gh CLI';
434
+ }
435
+
436
+ // ─── System Prompt Builder ──────────────────────────────────────────────────
437
+
438
+ // Lean system prompt: agent identity + rules only (~2-4KB, never grows)
439
+ function buildSystemPrompt(agentId, config, project) {
440
+ const agent = config.agents[agentId];
441
+ const charter = getAgentCharter(agentId);
442
+ project = project || getProjects(config)[0] || {};
443
+
444
+ let prompt = '';
445
+
446
+ // Agent identity
447
+ prompt += `# You are ${agent.name} (${agent.role})\n\n`;
448
+ prompt += `Agent ID: ${agentId}\n`;
449
+ prompt += `Skills: ${(agent.skills || []).join(', ')}\n\n`;
450
+
451
+ // Charter (detailed instructions — typically 1-2KB)
452
+ if (charter) {
453
+ prompt += `## Your Charter\n\n${charter}\n\n`;
454
+ }
455
+
456
+ // Project context (fixed size)
457
+ prompt += `## Project: ${project.name || 'Unknown Project'}\n\n`;
458
+ prompt += `- Repo: ${project.repoName || 'Unknown'} (${project.adoOrg || 'Unknown'}/${project.adoProject || 'Unknown'})\n`;
459
+ prompt += `- Repo ID: ${project.repositoryId || ''}\n`;
460
+ prompt += `- Repo host: ${getRepoHostLabel(project)}\n`;
461
+ prompt += `- Main branch: ${project.mainBranch || 'main'}\n\n`;
462
+
463
+ // Critical rules (fixed size)
464
+ prompt += `## Critical Rules\n\n`;
465
+ prompt += `1. Use git worktrees — NEVER checkout on main working tree\n`;
466
+ prompt += `2. ${getRepoHostToolRule(project)}\n`;
467
+ prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
468
+ prompt += `4. Write learnings to: ${MINIONS_DIR}/notes/inbox/${agentId}-${dateStamp()}.md\n`;
469
+ prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n`;
470
+ prompt += `6. If you discover a repeatable workflow, output it as a \\\`\\\`\\\`skill fenced block — the engine auto-extracts it to ~/.claude/skills/\n\n`;
471
+
472
+ return prompt;
473
+ }
474
+
475
+ // Bulk context: history, notes, conventions, skills — prepended to user/task prompt.
476
+ // This is the content that grows over time and would bloat the system prompt.
477
+ function buildAgentContext(agentId, config, project) {
478
+ project = project || getProjects(config)[0] || {};
479
+ let context = '';
480
+
481
+ // Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
482
+ const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
483
+ if (history && history.trim() !== '# Agent History') {
484
+ const entries = history.split(/(?=^### )/m);
485
+ const header = entries[0].startsWith('#') && !entries[0].startsWith('### ') ? entries.shift() : '';
486
+ const recent = entries.slice(-5);
487
+ const trimmed = (header ? header + '\n' : '') + recent.join('');
488
+ context += `## Your Recent History (last 5 tasks)\n\n${trimmed}\n\n`;
489
+ }
490
+
491
+ // Project conventions (from CLAUDE.md) — always relevant for code quality
492
+ if (project.localPath) {
493
+ const claudeMd = safeRead(path.join(project.localPath, 'CLAUDE.md'));
494
+ if (claudeMd && claudeMd.trim()) {
495
+ const truncated = claudeMd.length > 8192 ? claudeMd.slice(0, 8192) + '\n\n...(truncated)' : claudeMd;
496
+ context += `## Project Conventions (from CLAUDE.md)\n\n${truncated}\n\n`;
497
+ }
498
+ }
499
+
500
+ // KB and skills: NOT injected — agents can Glob/Read when needed
501
+ // This saves ~27KB per dispatch. Reference note so agents know they exist:
502
+ context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`. Skills are in \`skills/*.md\` and \`.claude/skills/\`. Use Glob/Read to browse when relevant.\n\n`;
503
+
504
+ // Minions awareness: what's in flight, who's doing what
505
+ const dispatch = getDispatch();
506
+ const activeItems = (dispatch.active || []).map(d =>
507
+ `- **${d.agent}**: ${d.type} — ${(d.task || '').slice(0, 100)}${d.agent === agentId ? ' ← (you)' : ''}`
508
+ );
509
+ if (activeItems.length > 0) {
510
+ context += `## Active Agents\n\n${activeItems.join('\n')}\n\n`;
511
+ }
512
+
513
+ // Recent completions (last 5, not 10)
514
+ const recentCompleted = (dispatch.completed || []).slice(-5).reverse().map(d =>
515
+ `- **${d.agent}** ${d.result === 'success' ? 'completed' : 'failed'}: ${(d.task || '').slice(0, 80)}${d.resultSummary ? ' — ' + d.resultSummary.slice(0, 100) : ''}`
516
+ );
517
+ if (recentCompleted.length > 0) {
518
+ context += `## Recently Completed\n\n${recentCompleted.join('\n')}\n\n`;
519
+ }
520
+
521
+ // Active PRs across projects — coordination awareness
522
+ const projects = getProjects(config);
523
+ const allPrs = [];
524
+ for (const p of projects) {
525
+ const prs = getPrs(p).filter(pr => pr.status === 'active');
526
+ for (const pr of prs) allPrs.push({ ...pr, _project: p.name });
527
+ }
528
+ if (allPrs.length > 0) {
529
+ const prLines = allPrs.map(pr =>
530
+ `- **${pr.id}** (${pr._project}): ${(pr.title || '').slice(0, 80)} [${pr.reviewStatus || 'pending'}${pr.buildStatus === 'failing' ? ', BUILD FAILING' : ''}]${pr.branch ? ' branch: `' + pr.branch + '`' : ''}`
531
+ );
532
+ context += `## Active Pull Requests\n\n${prLines.join('\n')}\n\n`;
533
+ }
534
+
535
+ // Pending work items
536
+ const pendingItems = (dispatch.pending || []).slice(0, 10).map(d =>
537
+ `- ${d.type}: ${(d.task || '').slice(0, 80)}`
538
+ );
539
+ if (pendingItems.length > 0) {
540
+ context += `## Pending Work Queue (${(dispatch.pending || []).length} items)\n\n${pendingItems.join('\n')}${(dispatch.pending || []).length > 10 ? '\n- ... and ' + ((dispatch.pending || []).length - 10) + ' more' : ''}\n\n`;
541
+ }
542
+
543
+ // Team notes — last 5 sections only (recent learnings, not the full history)
544
+ const notes = getNotes();
545
+ if (notes) {
546
+ const sections = notes.split(/(?=^### )/m);
547
+ const header = sections[0] && !sections[0].startsWith('### ') ? sections.shift() : '';
548
+ if (sections.length > 5) {
549
+ const recent = sections.slice(-5).join('');
550
+ context += `## Team Notes (last 5 of ${sections.length})\n\n${recent}\n\n_${sections.length - 5} older entries in \`notes.md\` — Read if needed._\n\n`;
551
+ } else {
552
+ context += `## Team Notes\n\n${notes}\n\n`;
553
+ }
554
+ }
555
+
556
+ return context;
557
+ }
558
+
559
+ // sanitizeBranch imported from shared.js
560
+
561
+ // ─── Lifecycle (extracted to engine/lifecycle.js) ────────────────────────────
562
+
563
+ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, handlePostMerge, checkPlanCompletion,
564
+ syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
565
+ updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs } = require('./engine/lifecycle');
566
+
567
+ // ─── Agent Spawner ──────────────────────────────────────────────────────────
568
+
569
+ const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt }
570
+ let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
571
+
572
+ // Resolve dependency plan item IDs to their PR branches
573
+ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
574
+ const results = []; // [{ branch, prId }]
575
+ if (!depIds?.length) return results;
576
+
577
+ const projects = shared.getProjects(config);
578
+
579
+ // Find work items for each dependency plan item
580
+ const depWorkItems = [];
581
+ for (const p of projects) {
582
+ const wiPath = shared.projectWorkItemsPath(p);
583
+ const items = safeJson(wiPath) || [];
584
+ for (const wi of items) {
585
+ if (depIds.includes(wi.id)) {
586
+ depWorkItems.push(wi);
587
+ }
588
+ }
589
+ }
590
+
591
+ // Find PR branches for each dependency work item
592
+ for (const p of projects) {
593
+ const prPath = shared.projectPrPath(p);
594
+ const prs = safeJson(prPath) || [];
595
+ for (const pr of prs) {
596
+ if (!pr.branch || pr.status !== 'active') continue;
597
+ const linked = (pr.prdItems || []).some(id =>
598
+ depWorkItems.find(w => w.id === id)
599
+ );
600
+ if (linked && !results.find(r => r.branch === pr.branch)) {
601
+ results.push({ branch: pr.branch, prId: pr.id });
602
+ }
603
+ }
604
+ }
605
+
606
+ return results;
607
+ }
608
+
609
+ // Find an existing worktree already checked out on a given branch
610
+ function findExistingWorktree(repoDir, branchName) {
611
+ try {
612
+ const out = exec(`git worktree list --porcelain`, { cwd: repoDir, stdio: 'pipe', timeout: 10000 }).toString();
613
+ const branchRef = `branch refs/heads/${branchName}`;
614
+ const lines = out.split('\n');
615
+ for (let i = 0; i < lines.length; i++) {
616
+ if (lines[i].trim() === branchRef) {
617
+ // Walk back to find the worktree path
618
+ for (let j = i - 1; j >= 0; j--) {
619
+ if (lines[j].startsWith('worktree ')) {
620
+ const wtPath = lines[j].slice('worktree '.length).trim();
621
+ if (fs.existsSync(wtPath)) return wtPath;
622
+ break;
623
+ }
624
+ }
625
+ }
626
+ }
627
+ } catch {}
628
+ return null;
629
+ }
630
+
631
+ function isWorktreeRetryableError(err) {
632
+ const msg = String(err?.message || '');
633
+ return msg.includes('ETIMEDOUT')
634
+ || msg.includes('index.lock')
635
+ || msg.includes('timed out')
636
+ || msg.includes('could not lock')
637
+ || msg.includes('resource busy')
638
+ || msg.includes('already exists');
639
+ }
640
+
641
+ function removeStaleIndexLock(rootDir) {
642
+ const lockFile = path.join(rootDir, '.git', 'index.lock');
643
+ try {
644
+ if (fs.existsSync(lockFile)) {
645
+ const age = Date.now() - fs.statSync(lockFile).mtimeMs;
646
+ if (age > 300000) {
647
+ fs.unlinkSync(lockFile);
648
+ log('warn', `Removed stale index.lock (${Math.round(age / 1000)}s old) in ${rootDir}`);
649
+ }
650
+ }
651
+ } catch {}
652
+ }
653
+
654
+ function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
655
+ let lastErr = null;
656
+ const retries = Math.max(0, Number(worktreeCreateRetries) || 0);
657
+ for (let attempt = 0; attempt <= retries; attempt++) {
658
+ try {
659
+ if (attempt > 0) {
660
+ try { exec('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch {}
661
+ removeStaleIndexLock(rootDir);
662
+ log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
663
+ }
664
+ exec(`git worktree add "${worktreePath}" ${args}`, { ...gitOpts, cwd: rootDir });
665
+ return;
666
+ } catch (err) {
667
+ lastErr = err;
668
+ if (attempt >= retries || !isWorktreeRetryableError(err)) throw err;
669
+ }
670
+ }
671
+ if (lastErr) throw lastErr;
672
+ }
673
+
674
+ function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
675
+ if (!branchName) return false;
676
+ const existingWt = findExistingWorktree(rootDir, branchName);
677
+ if (existingWt && fs.existsSync(existingWt)) return true;
678
+ if (!fs.existsSync(worktreePath)) return false;
679
+ try {
680
+ exec(`git -C "${worktreePath}" rev-parse --is-inside-work-tree`, { ...gitOpts, timeout: 10000 });
681
+ exec(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`, { ...gitOpts, timeout: 10000 });
682
+ log('warn', `Recovered partially-created worktree for ${branchName} at ${worktreePath}`);
683
+ return true;
684
+ } catch {
685
+ return false;
686
+ }
687
+ }
688
+
689
+ function spawnAgent(dispatchItem, config) {
690
+ const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
691
+ const claudeConfig = config.claude || {};
692
+ const engineConfig = config.engine || {};
693
+ const startedAt = ts();
694
+
695
+ // Resolve project context for this dispatch
696
+ const project = meta?.project || getProjects(config)[0] || {};
697
+ const rootDir = project.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
698
+
699
+ // Determine working directory
700
+ let cwd = rootDir;
701
+ let worktreePath = null;
702
+ let branchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
703
+ const worktreeCreateTimeout = Math.max(60000, Number(engineConfig.worktreeCreateTimeout) || DEFAULTS.worktreeCreateTimeout);
704
+ const worktreeCreateRetries = Math.max(0, Math.min(3, Number(engineConfig.worktreeCreateRetries) || DEFAULTS.worktreeCreateRetries));
705
+ const _gitOpts = { stdio: 'pipe', timeout: 30000, windowsHide: true, env: shared.gitEnv() };
706
+ const _worktreeGitOpts = { ..._gitOpts, timeout: worktreeCreateTimeout };
707
+
708
+ if (branchName) {
709
+ const wtSuffix = id ? id.split('-').pop() : shared.uid();
710
+ const projectSlug = (project.name || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
711
+ const wtDirName = `${projectSlug}-${branchName}-${wtSuffix}`;
712
+ worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
713
+
714
+ // If branch is already checked out in an existing worktree, reuse it
715
+ const existingWt = findExistingWorktree(rootDir, branchName);
716
+ if (existingWt) {
717
+ worktreePath = existingWt;
718
+ log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
719
+ try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
720
+ try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch {}
721
+ } else if (type !== 'implement') {
722
+ // Only implement tasks may create new worktrees.
723
+ // Other task types are reuse-only: if no existing worktree, run in rootDir.
724
+ log('info', `${type}: no existing worktree for ${branchName} — creation disabled for non-implement tasks, falling back to rootDir`);
725
+ branchName = null;
726
+ worktreePath = null;
727
+ } else {
728
+ try {
729
+ if (!fs.existsSync(worktreePath)) {
730
+ const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
731
+ // Prune stale worktree entries before creating (handles leftover entries from crashed runs)
732
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
733
+ // Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
734
+ removeStaleIndexLock(rootDir);
735
+
736
+ if (isSharedBranch) {
737
+ log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
738
+ try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
739
+ try {
740
+ runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
741
+ } catch (eShared) {
742
+ if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
743
+ const existingWtPath = findExistingWorktree(rootDir, branchName);
744
+ if (existingWtPath && fs.existsSync(existingWtPath)) {
745
+ log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
746
+ worktreePath = existingWtPath;
747
+ } else { throw eShared; }
748
+ } else { throw eShared; }
749
+ }
750
+ } else {
751
+ log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
752
+ try {
753
+ runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, _worktreeGitOpts, worktreeCreateRetries);
754
+ } catch (e1) {
755
+ // Branch already exists or checked out elsewhere — try without -b
756
+ try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
757
+ try {
758
+ runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
759
+ log('info', `Reusing existing branch: ${branchName}`);
760
+ } catch (e2) {
761
+ // "already checked out" or "already used by worktree" — find and reuse or recover
762
+ const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
763
+ || e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
764
+ if (alreadyUsed) {
765
+ const existingWtPath = findExistingWorktree(rootDir, branchName);
766
+ if (existingWtPath && fs.existsSync(existingWtPath)) {
767
+ // Directory exists — reuse it if no other active dispatch is using it
768
+ const dispatch = safeJson(DISPATCH_PATH) || {};
769
+ const activelyUsed = (dispatch.active || []).some(d => {
770
+ const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
771
+ return dBranch === branchName && d.id !== id;
772
+ });
773
+ if (activelyUsed) {
774
+ log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
775
+ throw e2;
776
+ }
777
+ // Reuse the existing worktree — update path so the rest of spawnAgent uses it
778
+ log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
779
+ worktreePath = existingWtPath;
780
+ } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
781
+ // Directory gone but git still tracks it — prune and recreate
782
+ log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
783
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
784
+ runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
785
+ log('info', `Recovered worktree for ${branchName} after stale entry prune`);
786
+ } else {
787
+ // Can't find the worktree at all — prune and retry
788
+ try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
789
+ runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
790
+ }
791
+ } else {
792
+ throw e2;
793
+ }
794
+ }
795
+ }
796
+ }
797
+ } else if (meta?.branchStrategy === 'shared-branch') {
798
+ log('info', `Pulling latest on shared branch ${branchName}`);
799
+ try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch {}
800
+ }
801
+ } catch (err) {
802
+ if (recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
803
+ cwd = worktreePath;
804
+ log('warn', `Proceeding with recovered worktree after add failure for ${branchName}`);
805
+ } else {
806
+ log('error', `Failed to create worktree for ${branchName}: ${err.message}${err.stderr ? '\n' + err.stderr.toString().slice(0, 500) : ''}`);
807
+ completeDispatch(id, 'error', 'Worktree creation failed: ' + (err.message || '').slice(0, 200));
808
+ return null;
809
+ }
810
+ }
811
+ }
812
+
813
+ // Merge dependency PR branches into worktree (applies to both reused and new worktrees)
814
+ if (worktreePath && fs.existsSync(worktreePath)) {
815
+ cwd = worktreePath;
816
+ const depIds = meta?.item?.depends_on || [];
817
+ if (depIds.length > 0) {
818
+ try {
819
+ const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
820
+ for (const { branch: depBranch, prId } of depBranches) {
821
+ try {
822
+ exec(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir });
823
+ exec(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
824
+ log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
825
+ } catch (mergeErr) {
826
+ log('warn', `Failed to merge dependency ${depBranch} into ${branchName}: ${mergeErr.message}`);
827
+ }
828
+ }
829
+ } catch (e) {
830
+ log('warn', `Could not resolve dependency branches for ${branchName}: ${e.message}`);
831
+ }
832
+ }
833
+ }
834
+ }
835
+
836
+ // Build lean system prompt (identity + rules, ~2-4KB) and bulk context (history, notes, skills)
837
+ const systemPrompt = buildSystemPrompt(agentId, config, project);
838
+ const agentContext = buildAgentContext(agentId, config, project);
839
+
840
+ // Prepend bulk context to task prompt — keeps system prompt small and stable
841
+ const fullTaskPrompt = agentContext
842
+ ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPrompt}`
843
+ : taskPrompt;
844
+
845
+ // Write prompt and system prompt to temp files (avoids shell escaping issues)
846
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
847
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
848
+ const promptPath = path.join(tmpDir, `prompt-${id}.md`);
849
+ safeWrite(promptPath, fullTaskPrompt);
850
+
851
+ const sysPromptPath = path.join(tmpDir, `sysprompt-${id}.md`);
852
+ safeWrite(sysPromptPath, systemPrompt);
853
+
854
+ // Build claude CLI args
855
+ const args = [
856
+ '--output-format', claudeConfig.outputFormat || 'stream-json',
857
+ '--max-turns', String(engineConfig.maxTurns || DEFAULTS.maxTurns),
858
+ '--verbose',
859
+ '--permission-mode', claudeConfig.permissionMode || 'bypassPermissions'
860
+ ];
861
+
862
+ if (claudeConfig.allowedTools) {
863
+ args.push('--allowedTools', claudeConfig.allowedTools);
864
+ }
865
+
866
+ // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
867
+ // No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
868
+
869
+ log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
870
+ log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
871
+
872
+ // Agent status is derived from dispatch.json — no setAgentStatus needed for working state.
873
+
874
+ // Spawn the claude process
875
+ const childEnv = shared.cleanChildEnv();
876
+
877
+ // Spawn via wrapper script — node directly (no bash intermediary)
878
+ // spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
879
+ const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
880
+ const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
881
+
882
+ const proc = runFile(process.execPath, spawnArgs, {
883
+ cwd,
884
+ stdio: ['pipe', 'pipe', 'pipe'],
885
+ env: childEnv,
886
+ });
887
+
888
+ const MAX_OUTPUT = 1024 * 1024; // 1MB
889
+ let stdout = '';
890
+ let stderr = '';
891
+ let lastOutputAt = Date.now();
892
+ let heartbeatTimer = null;
893
+
894
+ // Live output file — written as data arrives so dashboard can tail it
895
+ const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
896
+ safeWrite(liveOutputPath, `# Live output for ${agentId} — ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n\n`);
897
+
898
+ // Keep live log active even when the agent produces no stdout/stderr for long stretches.
899
+ // This makes "silent but running" states visible in the dashboard tail view.
900
+ heartbeatTimer = setInterval(() => {
901
+ const silentMs = Date.now() - lastOutputAt;
902
+ if (silentMs < 30000) return;
903
+ const silentSec = Math.round(silentMs / 1000);
904
+ try { fs.appendFileSync(liveOutputPath, `[heartbeat] running — no output for ${silentSec}s\n`); } catch {}
905
+ }, 30000);
906
+
907
+ proc.stdout.on('data', (data) => {
908
+ const chunk = data.toString();
909
+ lastOutputAt = Date.now();
910
+ if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
911
+ try { fs.appendFileSync(liveOutputPath, chunk); } catch {}
912
+ });
913
+
914
+ proc.stderr.on('data', (data) => {
915
+ const chunk = data.toString();
916
+ lastOutputAt = Date.now();
917
+ if (stderr.length < MAX_OUTPUT) stderr += chunk.slice(0, MAX_OUTPUT - stderr.length);
918
+ try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch {}
919
+ });
920
+
921
+ proc.on('close', (code) => {
922
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
923
+ log('info', `Agent ${agentId} (${id}) exited with code ${code}`);
924
+ activeProcesses.delete(id);
925
+
926
+ // If timeout checker already finalized this dispatch, don't overwrite work-item status again.
927
+ // This avoids races where close-handler marks an auto-retried item as failed.
928
+ const dispatchNow = getDispatch();
929
+ const stillActive = (dispatchNow.active || []).some(d => d.id === id);
930
+ if (!stillActive) {
931
+ log('info', `Agent ${agentId} (${id}) close event ignored — dispatch already completed elsewhere`);
932
+ try { fs.unlinkSync(sysPromptPath); } catch {}
933
+ try { fs.unlinkSync(promptPath); } catch {}
934
+ try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
935
+ return;
936
+ }
937
+
938
+ // Save output — per-dispatch archive + latest symlink
939
+ const outputContent = `# Output for dispatch ${id}\n# Exit code: ${code}\n# Completed: ${ts()}\n\n## stdout\n${stdout}\n\n## stderr\n${stderr}`;
940
+ const archivePath = path.join(AGENTS_DIR, agentId, `output-${id}.log`);
941
+ const latestPath = path.join(AGENTS_DIR, agentId, 'output.log');
942
+ safeWrite(archivePath, outputContent);
943
+ safeWrite(latestPath, outputContent); // overwrite latest for dashboard compat
944
+
945
+ // Parse output and run all post-completion hooks
946
+ const { resultSummary } = runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
947
+
948
+ // Move from active to completed in dispatch (single source of truth for agent status)
949
+ completeDispatch(id, code === 0 ? 'success' : 'error', '', resultSummary);
950
+
951
+ // Cleanup temp files (including PID file now that dispatch is complete)
952
+ try { fs.unlinkSync(sysPromptPath); } catch {}
953
+ try { fs.unlinkSync(promptPath); } catch {}
954
+ try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
955
+
956
+ log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
957
+ });
958
+
959
+ proc.on('error', (err) => {
960
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
961
+ log('error', `Failed to spawn agent ${agentId}: ${err.message}`);
962
+ activeProcesses.delete(id);
963
+ completeDispatch(id, 'error', `Spawn error: ${err.message}`);
964
+ });
965
+
966
+ // Safety: if process exits immediately (within 3s), log it
967
+ setTimeout(() => {
968
+ if (proc.exitCode !== null && !proc.killed) {
969
+ log('warn', `Agent ${agentId} (${id}) exited within 3s with code ${proc.exitCode}`);
970
+ }
971
+ }, 3000);
972
+
973
+ // Track process — even if PID isn't available yet (async on Windows)
974
+ activeProcesses.set(id, { proc, agentId, startedAt });
975
+
976
+ // Log PID and persist to registry
977
+ if (proc.pid) {
978
+ log('info', `Agent process started: PID ${proc.pid}`);
979
+ } else {
980
+ log('warn', `Agent spawn returned no PID initially — will verify via PID file`);
981
+ }
982
+
983
+ // Verify spawn after 5 seconds via PID file written by spawn-agent.js
984
+ // PID file is kept (not deleted) so engine can re-attach on restart
985
+ setTimeout(() => {
986
+ const pidFile = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
987
+ try {
988
+ const pidStr = fs.readFileSync(pidFile, 'utf8').trim();
989
+ if (pidStr) {
990
+ log('info', `Agent ${agentId} verified via PID file: ${pidStr}`);
991
+ }
992
+ // Don't delete — keep for re-attachment on engine restart
993
+ } catch {
994
+ if (!fs.existsSync(liveOutputPath) || fs.statSync(liveOutputPath).size <= 200) {
995
+ log('error', `Agent ${agentId} (${id}) — no PID file and no output after 5s. Spawn likely failed.`);
996
+ }
997
+ }
998
+ }, 5000);
999
+
1000
+ // Move pending -> active under a lock to avoid cross-process lost updates (engine/dashboard)
1001
+ mutateDispatch((dispatch) => {
1002
+ const idx = dispatch.pending.findIndex(d => d.id === id);
1003
+ if (idx < 0) return;
1004
+ const item = dispatch.pending.splice(idx, 1)[0];
1005
+ item.started_at = startedAt;
1006
+ if (!dispatch.active.some(d => d.id === id)) {
1007
+ dispatch.active.push(item);
1008
+ }
1009
+ });
1010
+
1011
+ return proc;
1012
+ }
1013
+
1014
+ // ─── Dispatch Management ────────────────────────────────────────────────────
1015
+
1016
+ function addToDispatch(item) {
1017
+ item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
1018
+ item.created_at = ts();
1019
+ mutateDispatch((dispatch) => {
1020
+ dispatch.pending.push(item);
1021
+ });
1022
+ log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
1023
+ return item.id;
1024
+ }
1025
+
1026
+ function isRetryableFailureReason(reason = '') {
1027
+ const r = String(reason || '').toLowerCase();
1028
+ if (!r) return true; // unknown error from tool exit — keep retryable
1029
+ const nonRetryable = [
1030
+ 'no playbook rendered',
1031
+ 'failed to render',
1032
+ 'no target project available',
1033
+ 'no plan files found',
1034
+ 'plan file not found',
1035
+ 'invalid filename',
1036
+ 'invalid file path',
1037
+ 'missing required',
1038
+ 'validation failed',
1039
+ ];
1040
+ return !nonRetryable.some(s => r.includes(s));
1041
+ }
1042
+
1043
+ function completeDispatch(id, result = 'success', reason = '', resultSummary = '', opts = {}) {
1044
+ const { processWorkItemFailure = true } = opts;
1045
+ let item = null;
1046
+
1047
+ mutateDispatch((dispatch) => {
1048
+ // Check active list first
1049
+ let idx = dispatch.active.findIndex(d => d.id === id);
1050
+ if (idx >= 0) {
1051
+ item = dispatch.active.splice(idx, 1)[0];
1052
+ } else {
1053
+ // Also check pending list (e.g., worktree failure before spawn)
1054
+ idx = dispatch.pending.findIndex(d => d.id === id);
1055
+ if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
1056
+ }
1057
+
1058
+ if (!item) return;
1059
+ item.completed_at = ts();
1060
+ item.result = result;
1061
+ if (reason) item.reason = reason;
1062
+ if (resultSummary) item.resultSummary = resultSummary;
1063
+ delete item.prompt;
1064
+ if (dispatch.completed.length >= 100) {
1065
+ dispatch.completed = dispatch.completed.slice(-99);
1066
+ }
1067
+ dispatch.completed.push(item);
1068
+ });
1069
+
1070
+ if (item) {
1071
+ log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
1072
+
1073
+ // Update source work item status on failure + auto-retry with backoff
1074
+ const retryableFailure = isRetryableFailureReason(reason);
1075
+ if (result === 'error' && item.meta?.dispatchKey && retryableFailure) setCooldownFailure(item.meta.dispatchKey);
1076
+
1077
+ if (processWorkItemFailure && result === 'error' && item.meta?.item?.id) {
1078
+ const retries = (item.meta.item._retryCount || 0);
1079
+ if (retryableFailure && retries < 3) {
1080
+ log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/3`);
1081
+ updateWorkItemStatus(item.meta, 'pending', '');
1082
+ // Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
1083
+ if (item.meta?.dispatchKey) {
1084
+ try {
1085
+ mutateDispatch((dp) => {
1086
+ const before = Array.isArray(dp.completed) ? dp.completed.length : 0;
1087
+ dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
1088
+ return dp.completed.length !== before ? dp : undefined;
1089
+ });
1090
+ } catch {}
1091
+ }
1092
+ // Increment retry counter on the source work item
1093
+ try {
1094
+ const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
1095
+ ? path.join(MINIONS_DIR, 'work-items.json')
1096
+ : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
1097
+ if (wiPath) {
1098
+ const items = safeJson(wiPath) || [];
1099
+ const wi = items.find(i => i.id === item.meta.item.id);
1100
+ if (wi && wi.status !== 'paused') {
1101
+ wi._retryCount = retries + 1;
1102
+ wi.status = 'pending';
1103
+ wi._lastRetryReason = reason || '';
1104
+ wi._lastRetryAt = ts();
1105
+ delete wi.failReason;
1106
+ delete wi.failedAt;
1107
+ delete wi.dispatched_at;
1108
+ delete wi.dispatched_to;
1109
+ safeWrite(wiPath, items);
1110
+ }
1111
+ }
1112
+ } catch {}
1113
+ } else {
1114
+ const finalReason = !retryableFailure
1115
+ ? `Non-retryable failure: ${reason || 'Unknown error'}`
1116
+ : (reason || 'Failed after 3 retries');
1117
+ updateWorkItemStatus(item.meta, 'failed', finalReason);
1118
+ // Alert: find items blocked by this failure and write inbox note
1119
+ try {
1120
+ const config = getConfig();
1121
+ const failedId = item.meta.item.id;
1122
+ const blockedItems = [];
1123
+ for (const p of getProjects(config)) {
1124
+ const items = safeJson(projectWorkItemsPath(p)) || [];
1125
+ items.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
1126
+ .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
1127
+ }
1128
+ const centralItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1129
+ centralItems.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
1130
+ .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
1131
+
1132
+ writeInboxAlert(`failed-${failedId}`,
1133
+ `# Work Item Failed — \`${failedId}\`\n\n` +
1134
+ `**Item:** ${item.meta.item.title || failedId}\n` +
1135
+ `**Reason:** ${finalReason}\n\n` +
1136
+ (blockedItems.length > 0
1137
+ ? `**Blocked dependents (${blockedItems.length}):**\n${blockedItems.join('\n')}\n\n` +
1138
+ `These items cannot dispatch until \`${failedId}\` is fixed and reset to \`pending\`.\n`
1139
+ : `No downstream items are blocked.\n`)
1140
+ );
1141
+ } catch {}
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ // ─── Dependency Gate ─────────────────────────────────────────────────────────
1148
+ // Returns: true (deps met), false (deps pending), 'failed' (dep failed — propagate)
1149
+ function areDependenciesMet(item, config) {
1150
+ const deps = item.depends_on;
1151
+ if (!deps || deps.length === 0) return true;
1152
+ const sourcePlan = item.sourcePlan;
1153
+ if (!sourcePlan) return true;
1154
+ const projects = getProjects(config);
1155
+
1156
+ // Collect work items from ALL projects (dependencies can be cross-project)
1157
+ let allWorkItems = [];
1158
+ for (const p of projects) {
1159
+ try {
1160
+ const wi = safeJson(projectWorkItemsPath(p)) || [];
1161
+ allWorkItems = allWorkItems.concat(wi);
1162
+ } catch {}
1163
+ }
1164
+ // PRD item statuses that count as "done" for dep resolution
1165
+ const PRD_MET_STATUSES = new Set(['done', 'in-pr', 'implemented', 'complete']);
1166
+
1167
+ for (const depId of deps) {
1168
+ const depItem = allWorkItems.find(w => w.id === depId);
1169
+ if (!depItem) {
1170
+ // Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done
1171
+ try {
1172
+ const plan = safeJson(path.join(PRD_DIR, sourcePlan));
1173
+ const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
1174
+ if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
1175
+ } catch {}
1176
+ log('warn', `Dependency ${depId} not found for ${item.id} (plan: ${sourcePlan}) — treating as unmet`);
1177
+ return false;
1178
+ }
1179
+ if (depItem.status === 'failed' && (depItem._retryCount || 0) >= 3) return 'failed'; // Only cascade after retries exhausted
1180
+ if (depItem.status !== 'done' && depItem.status !== 'in-pr') return false; // Pending, dispatched, or retrying — wait (in-pr accepted for backward compat)
1181
+ }
1182
+ return true;
1183
+ }
1184
+
1185
+ function detectDependencyCycles(items) {
1186
+ const graph = new Map();
1187
+ for (const item of items) graph.set(item.id, item.depends_on || []);
1188
+ const visited = new Set(), inStack = new Set(), cycleIds = new Set();
1189
+ function dfs(id) {
1190
+ if (inStack.has(id)) { cycleIds.add(id); return true; }
1191
+ if (visited.has(id)) return false;
1192
+ visited.add(id); inStack.add(id);
1193
+ for (const dep of (graph.get(id) || [])) { if (dfs(dep)) cycleIds.add(id); }
1194
+ inStack.delete(id); return false;
1195
+ }
1196
+ for (const id of graph.keys()) dfs(id);
1197
+ return [...cycleIds];
1198
+ }
1199
+
1200
+
1201
+ // ─── Shared helpers ───────────────────────────────────────────────────────────
1202
+
1203
+ // Write a one-off alert note to notes/inbox so the user sees it on next consolidation
1204
+ function writeInboxAlert(slug, content) {
1205
+ try {
1206
+ const file = path.join(INBOX_DIR, `engine-alert-${slug}-${dateStamp()}.md`);
1207
+ // Dedupe: don't write the same alert twice in the same day
1208
+ const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${slug}-${dateStamp()}`));
1209
+ if (existing) return;
1210
+ safeWrite(file, content);
1211
+ } catch {}
1212
+ }
1213
+
1214
+ // Reconciles work items against known PRs via exact prdItems match only.
1215
+ // reconcilePrs (ado.js) and syncPrsFromOutput (lifecycle.js) are responsible for
1216
+ // correctly populating prdItems on PRs — this function just reads that linkage.
1217
+ // onlyIds: if provided, only items whose ID is in this Set are eligible.
1218
+ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
1219
+ let reconciled = 0;
1220
+ for (const wi of items) {
1221
+ if (wi.status !== 'pending' || wi._pr) continue;
1222
+ if (onlyIds && !onlyIds.has(wi.id)) continue;
1223
+
1224
+ const exactPr = allPrs.find(pr => (pr.prdItems || []).includes(wi.id));
1225
+ if (exactPr) {
1226
+ wi.status = 'done';
1227
+ wi._pr = exactPr.id;
1228
+ reconciled++;
1229
+ }
1230
+ }
1231
+ return reconciled;
1232
+ }
1233
+
1234
+ // ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
1235
+
1236
+ const { consolidateInbox } = require('./engine/consolidation');
1237
+ const { pollPrStatus, pollPrHumanComments, reconcilePrs } = require('./engine/ado');
1238
+ const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs } = require('./engine/github');
1239
+
1240
+ // ─── State Snapshot ─────────────────────────────────────────────────────────
1241
+
1242
+ function updateSnapshot(config) {
1243
+ const dispatch = getDispatch();
1244
+ const agents = config.agents || {};
1245
+ const projects = getProjects(config);
1246
+
1247
+ let snapshot = `# Minions State — ${ts()}\n\n`;
1248
+ snapshot += `## Projects: ${projects.map(p => p.name).join(', ')}\n\n`;
1249
+
1250
+ snapshot += `## Agents\n\n`;
1251
+ snapshot += `| Agent | Role | Status | Task |\n`;
1252
+ snapshot += `|-------|------|--------|------|\n`;
1253
+ for (const [id, agent] of Object.entries(agents)) {
1254
+ const status = getAgentStatus(id);
1255
+ snapshot += `| ${agent.emoji} ${agent.name} | ${agent.role} | ${status.status} | ${status.task || '-'} |\n`;
1256
+ }
1257
+
1258
+ snapshot += `\n## Dispatch Queue\n\n`;
1259
+ snapshot += `- Pending: ${dispatch.pending.length}\n`;
1260
+ snapshot += `- Active: ${(dispatch.active || []).length}\n`;
1261
+ snapshot += `- Completed: ${(dispatch.completed || []).length}\n`;
1262
+
1263
+ if (dispatch.pending.length > 0) {
1264
+ snapshot += `\n### Pending\n`;
1265
+ for (const d of dispatch.pending) {
1266
+ snapshot += `- [${d.id}] ${d.type} → ${d.agent}: ${d.task}\n`;
1267
+ }
1268
+ }
1269
+ if ((dispatch.active || []).length > 0) {
1270
+ snapshot += `\n### Active\n`;
1271
+ for (const d of dispatch.active) {
1272
+ snapshot += `- [${d.id}] ${d.type} → ${d.agent}: ${d.task} (since ${d.started_at})\n`;
1273
+ }
1274
+ }
1275
+
1276
+ safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
1277
+ }
1278
+
1279
+ // ─── Idle Alert ─────────────────────────────────────────────────────────────
1280
+
1281
+ let _lastActivityTime = Date.now();
1282
+ let _idleAlertSent = false;
1283
+
1284
+ function checkIdleThreshold(config) {
1285
+ const thresholdMs = (config.engine?.idleAlertMinutes || 15) * 60 * 1000;
1286
+ const agents = Object.keys(config.agents || {});
1287
+ const allIdle = agents.every(id => isAgentIdle(id));
1288
+ const dispatch = getDispatch();
1289
+ const hasPending = (dispatch.pending || []).length > 0;
1290
+
1291
+ if (!allIdle || hasPending) {
1292
+ _lastActivityTime = Date.now();
1293
+ _idleAlertSent = false;
1294
+ return;
1295
+ }
1296
+
1297
+ const idleMs = Date.now() - _lastActivityTime;
1298
+ if (idleMs > thresholdMs && !_idleAlertSent) {
1299
+ const mins = Math.round(idleMs / 60000);
1300
+ log('warn', `All agents idle for ${mins} minutes — no work sources producing items`);
1301
+ _idleAlertSent = true;
1302
+ }
1303
+ }
1304
+
1305
+ // ─── Timeout Checker ────────────────────────────────────────────────────────
1306
+
1307
+ function checkTimeouts(config) {
1308
+ const timeout = config.engine?.agentTimeout || DEFAULTS.agentTimeout;
1309
+ const heartbeatTimeout = config.engine?.heartbeatTimeout || DEFAULTS.heartbeatTimeout;
1310
+
1311
+ // 1. Check tracked processes for hard timeout (supports per-item deadline from fan-out)
1312
+ for (const [id, info] of activeProcesses.entries()) {
1313
+ const itemTimeout = info.meta?.deadline ? Math.max(0, info.meta.deadline - new Date(info.startedAt).getTime()) : timeout;
1314
+ const elapsed = Date.now() - new Date(info.startedAt).getTime();
1315
+ if (elapsed > itemTimeout) {
1316
+ log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
1317
+ try { info.proc.kill('SIGTERM'); } catch {}
1318
+ setTimeout(() => {
1319
+ try { info.proc.kill('SIGKILL'); } catch {}
1320
+ }, 5000);
1321
+ }
1322
+ }
1323
+
1324
+ // 2. Heartbeat check — for ALL active dispatch items (catches orphans after engine restart)
1325
+ // Uses live-output.log mtime as heartbeat. If no output for heartbeatTimeout, agent is dead.
1326
+ const dispatch = getDispatch();
1327
+ const deadItems = [];
1328
+
1329
+ for (const item of (dispatch.active || [])) {
1330
+ if (!item.agent) continue;
1331
+
1332
+ const hasProcess = activeProcesses.has(item.id);
1333
+ const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
1334
+ let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;
1335
+
1336
+ // Check live-output.log mtime as heartbeat
1337
+ try {
1338
+ const stat = fs.statSync(liveLogPath);
1339
+ lastActivity = Math.max(lastActivity, stat.mtimeMs);
1340
+ } catch {}
1341
+
1342
+ const silentMs = Date.now() - lastActivity;
1343
+ const silentSec = Math.round(silentMs / 1000);
1344
+
1345
+ // Check if the agent actually completed (result event in live output)
1346
+ // Optimization: only read file if recent activity (avoids reading stale 1MB logs)
1347
+ let completedViaOutput = false;
1348
+ try {
1349
+ if (silentMs > 600000) throw 'skip'; // No point reading a file silent for >10min
1350
+ const liveLog = safeRead(liveLogPath);
1351
+ if (liveLog && liveLog.includes('"type":"result"')) {
1352
+ completedViaOutput = true;
1353
+ const isSuccess = liveLog.includes('"subtype":"success"');
1354
+ log('info', `Agent ${item.agent} (${item.id}) completed via output detection (${isSuccess ? 'success' : 'error'})`);
1355
+
1356
+ // Extract output text for the output.log
1357
+ const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
1358
+ try {
1359
+ const resultLine = liveLog.split('\n').find(l => l.includes('"type":"result"'));
1360
+ if (resultLine) {
1361
+ const result = JSON.parse(resultLine);
1362
+ safeWrite(outputLogPath, `# 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`);
1363
+ }
1364
+ } catch {}
1365
+
1366
+ completeDispatch(item.id, isSuccess ? 'success' : 'error', 'Completed (detected from output)');
1367
+
1368
+ // Run post-completion hooks via shared helper
1369
+ runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
1370
+
1371
+ if (hasProcess) {
1372
+ try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch {}
1373
+ activeProcesses.delete(item.id);
1374
+ }
1375
+ continue; // Skip orphan/hung detection — we handled it
1376
+ }
1377
+ } catch {}
1378
+
1379
+ // Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
1380
+ // These tools produce no stdout for extended periods — don't kill them prematurely
1381
+ // Check for BOTH tracked and untracked processes (orphan case after engine restart)
1382
+ let isBlocking = false;
1383
+ let blockingTimeout = heartbeatTimeout;
1384
+ if (silentMs > heartbeatTimeout) {
1385
+ try {
1386
+ const liveLog = safeRead(liveLogPath);
1387
+ if (liveLog) {
1388
+ // Find the last tool_use call in the output — check if it's a known blocking tool
1389
+ const lines = liveLog.split('\n');
1390
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
1391
+ const line = lines[i];
1392
+ if (!line.includes('"tool_use"')) continue;
1393
+ try {
1394
+ const parsed = JSON.parse(line);
1395
+ const toolUse = parsed?.message?.content?.find?.(c => c.type === 'tool_use');
1396
+ if (!toolUse) continue;
1397
+ const input = toolUse.input || {};
1398
+ const name = toolUse.name || '';
1399
+ // TaskOutput with block:true — waiting for a background task
1400
+ if (name === 'TaskOutput' && input.block === true) {
1401
+ const taskTimeout = input.timeout || 600000; // default 10min
1402
+ blockingTimeout = Math.max(heartbeatTimeout, taskTimeout + 60000); // task timeout + 1min grace
1403
+ isBlocking = true;
1404
+ }
1405
+ // Bash with explicit long timeout (>5min)
1406
+ if (name === 'Bash' && input.timeout && input.timeout > heartbeatTimeout) {
1407
+ blockingTimeout = Math.max(heartbeatTimeout, input.timeout + 60000);
1408
+ isBlocking = true;
1409
+ }
1410
+ break; // only check the most recent tool_use
1411
+ } catch {}
1412
+ }
1413
+ if (isBlocking) {
1414
+ 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)`);
1415
+ }
1416
+ }
1417
+ } catch {}
1418
+ }
1419
+
1420
+ const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
1421
+
1422
+ if (!hasProcess && silentMs > effectiveTimeout && Date.now() > engineRestartGraceUntil) {
1423
+ // No tracked process AND no recent output past effective timeout AND grace period expired → orphaned
1424
+ log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1425
+ deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
1426
+ } else if (hasProcess && silentMs > effectiveTimeout) {
1427
+ // Has process but no output past effective timeout → hung
1428
+ log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1429
+ const procInfo = activeProcesses.get(item.id);
1430
+ if (procInfo) {
1431
+ try { procInfo.proc.kill('SIGTERM'); } catch {}
1432
+ setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch {} }, 5000);
1433
+ activeProcesses.delete(item.id);
1434
+ }
1435
+ deadItems.push({ item, reason: `Hung — no output for ${silentSec}s` });
1436
+ }
1437
+ // If has process and recent output → healthy, let it run
1438
+ }
1439
+
1440
+ // Clean up dead items
1441
+ for (const { item, reason } of deadItems) {
1442
+ completeDispatch(item.id, 'error', reason);
1443
+ }
1444
+
1445
+ // Agent status is now derived from dispatch.json at read time (getAgentStatus).
1446
+ // No reconcile sweep needed — dispatch IS the source of truth.
1447
+
1448
+ // Reconcile: find work items stuck in "dispatched" with no matching active dispatch
1449
+ const activeKeys = new Set((dispatch.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
1450
+ const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1451
+ for (const project of getProjects(config)) {
1452
+ allWiPaths.push(projectWorkItemsPath(project));
1453
+ }
1454
+ for (const wiPath of allWiPaths) {
1455
+ const items = safeJson(wiPath);
1456
+ if (!items || !Array.isArray(items)) continue;
1457
+ let changed = false;
1458
+ for (const item of items) {
1459
+ if (item.status !== 'dispatched') continue;
1460
+ // Check if any active dispatch references this item
1461
+ // Dispatch keys include project name: work-{project}-{id} or central-work-{id}
1462
+ const projectNames = getProjects(config).map(p => p.name);
1463
+ const possibleKeys = [
1464
+ `central-work-${item.id}`,
1465
+ ...projectNames.map(p => `work-${p}-${item.id}`),
1466
+ ];
1467
+ const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
1468
+ (dispatch.active || []).some(d => d.meta?.item?.id === item.id);
1469
+ if (!isActive) {
1470
+ const retries = (item._retryCount || 0);
1471
+ if (retries < 3) {
1472
+ log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/3`);
1473
+ item.status = 'pending';
1474
+ item._retryCount = retries + 1;
1475
+ delete item.dispatched_at;
1476
+ delete item.dispatched_to;
1477
+ } else {
1478
+ log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
1479
+ item.status = 'failed';
1480
+ item.failReason = 'Agent died or was killed (3 retries exhausted)';
1481
+ item.failedAt = ts();
1482
+ }
1483
+ changed = true;
1484
+ }
1485
+ }
1486
+ if (changed) safeWrite(wiPath, items);
1487
+ }
1488
+ }
1489
+
1490
+ // ─── Cleanup ─────────────────────────────────────────────────────────────────
1491
+
1492
+ function runCleanup(config, verbose = false) {
1493
+ const projects = getProjects(config);
1494
+ let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
1495
+
1496
+ // 1. Clean stale temp prompt/sysprompt files (older than 1 hour)
1497
+ const oneHourAgo = Date.now() - 3600000;
1498
+ try {
1499
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
1500
+ const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
1501
+ for (const dir of scanDirs) {
1502
+ for (const f of fs.readdirSync(dir)) {
1503
+ if (f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-')) {
1504
+ const fp = path.join(dir, f);
1505
+ try {
1506
+ const stat = fs.statSync(fp);
1507
+ if (stat.mtimeMs < oneHourAgo) {
1508
+ fs.unlinkSync(fp);
1509
+ cleaned.tempFiles++;
1510
+ }
1511
+ } catch {}
1512
+ }
1513
+ }
1514
+ }
1515
+ } catch {}
1516
+
1517
+ // 2. Clean live-output.log for idle agents (not currently working)
1518
+ for (const [agentId] of Object.entries(config.agents || {})) {
1519
+ const status = getAgentStatus(agentId);
1520
+ if (status.status !== 'working') {
1521
+ const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
1522
+ if (fs.existsSync(livePath)) {
1523
+ try {
1524
+ const stat = fs.statSync(livePath);
1525
+ if (stat.mtimeMs < oneHourAgo) {
1526
+ fs.unlinkSync(livePath);
1527
+ cleaned.liveOutputs++;
1528
+ }
1529
+ } catch {}
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ // 3. Clean git worktrees for merged/abandoned PRs
1535
+ for (const project of projects) {
1536
+ const root = project.localPath ? path.resolve(project.localPath) : null;
1537
+ if (!root || !fs.existsSync(root)) continue;
1538
+
1539
+ const worktreeRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1540
+ if (!fs.existsSync(worktreeRoot)) continue;
1541
+
1542
+ // Get PRs for this project
1543
+ const prs = safeJson(projectPrPath(project)) || [];
1544
+ const mergedBranches = new Set();
1545
+ for (const pr of prs) {
1546
+ if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
1547
+ if (pr.branch) mergedBranches.add(pr.branch);
1548
+ }
1549
+ }
1550
+
1551
+ // List worktrees — collect info for age-based + cap-based cleanup
1552
+ const MAX_WORKTREES = 10;
1553
+ try {
1554
+ const dirs = fs.readdirSync(worktreeRoot);
1555
+ const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
1556
+ const dispatch = getDispatch();
1557
+
1558
+ for (const dir of dirs) {
1559
+ const wtPath = path.join(worktreeRoot, dir);
1560
+ try { if (!fs.statSync(wtPath).isDirectory()) continue; } catch { continue; }
1561
+
1562
+ let shouldClean = false;
1563
+ let isProtected = false;
1564
+
1565
+ // Check if this worktree's branch is merged/abandoned
1566
+ // Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
1567
+ const dirLower = dir.toLowerCase();
1568
+ for (const branch of mergedBranches) {
1569
+ const branchSlug = sanitizeBranch(branch).toLowerCase();
1570
+ if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
1571
+ shouldClean = true;
1572
+ break;
1573
+ }
1574
+ }
1575
+
1576
+ // Check if referenced by active/pending dispatch (use sanitized branch comparison)
1577
+ const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
1578
+ if (!d.meta?.branch) return false;
1579
+ const dispBranch = sanitizeBranch(d.meta.branch).toLowerCase();
1580
+ return dirLower.includes(dispBranch);
1581
+ });
1582
+ if (isReferenced) isProtected = true;
1583
+
1584
+ // Also clean worktrees older than 2 hours with no active dispatch referencing them
1585
+ let mtime = Date.now();
1586
+ if (!shouldClean) {
1587
+ try {
1588
+ const stat = fs.statSync(wtPath);
1589
+ mtime = stat.mtimeMs;
1590
+ const ageMs = Date.now() - mtime;
1591
+ if (ageMs > 7200000 && !isReferenced) { // 2 hours
1592
+ shouldClean = true;
1593
+ }
1594
+ } catch {}
1595
+ }
1596
+
1597
+ // Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
1598
+ if (shouldClean || !isProtected) {
1599
+ try {
1600
+ for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
1601
+ if (!fs.existsSync(checkDir)) continue;
1602
+ for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
1603
+ const plan = safeJson(path.join(checkDir, pf));
1604
+ if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
1605
+ const planBranch = sanitizeBranch(plan.feature_branch).toLowerCase();
1606
+ if (dirLower.includes(planBranch)) {
1607
+ isProtected = true;
1608
+ if (shouldClean) {
1609
+ shouldClean = false;
1610
+ if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
1611
+ }
1612
+ break;
1613
+ }
1614
+ }
1615
+ }
1616
+ if (isProtected) break;
1617
+ }
1618
+ } catch {}
1619
+ }
1620
+
1621
+ wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
1622
+ }
1623
+
1624
+ // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
1625
+ const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
1626
+ if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
1627
+ // Sort oldest first
1628
+ surviving.sort((a, b) => a.mtime - b.mtime);
1629
+ const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
1630
+ for (let i = 0; i < Math.min(excess, surviving.length); i++) {
1631
+ surviving[i].shouldClean = true;
1632
+ if (verbose) console.log(` Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
1633
+ }
1634
+ }
1635
+
1636
+ // Remove all marked worktrees
1637
+ for (const entry of wtEntries) {
1638
+ if (entry.shouldClean) {
1639
+ try {
1640
+ exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe' });
1641
+ cleaned.worktrees++;
1642
+ if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
1643
+ } catch (e) {
1644
+ if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}: ${e.message}`);
1645
+ }
1646
+ }
1647
+ }
1648
+ } catch {}
1649
+ }
1650
+
1651
+ // 4. Kill zombie claude processes not tracked by the engine
1652
+ // List all node processes, check if any are running spawn-agent.js for our minions
1653
+ try {
1654
+ const dispatch = getDispatch();
1655
+ const activePids = new Set();
1656
+ for (const [, info] of activeProcesses.entries()) {
1657
+ if (info.proc?.pid) activePids.add(info.proc.pid);
1658
+ }
1659
+
1660
+ // Clean individual orphaned processes — no matching active dispatch
1661
+ const activeIds = new Set((dispatch.active || []).map(d => d.id));
1662
+ for (const [id, info] of activeProcesses.entries()) {
1663
+ if (!activeIds.has(id)) {
1664
+ try { if (info.proc) info.proc.kill('SIGTERM'); } catch {}
1665
+ activeProcesses.delete(id);
1666
+ cleaned.zombies++;
1667
+ }
1668
+ }
1669
+ } catch {}
1670
+
1671
+ // 5. Clean spawn-debug.log
1672
+ try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch {}
1673
+
1674
+ // 6. Prune old output archive files (keep last 30 per agent)
1675
+ for (const agentId of Object.keys(config.agents || {})) {
1676
+ const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
1677
+ if (!fs.existsSync(agentDir)) continue;
1678
+ try {
1679
+ const outputFiles = fs.readdirSync(agentDir)
1680
+ .filter(f => f.startsWith('output-') && f.endsWith('.log') && f !== 'output.log')
1681
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
1682
+ .sort((a, b) => b.mtime - a.mtime);
1683
+ for (const old of outputFiles.slice(30)) {
1684
+ try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch {}
1685
+ }
1686
+ } catch {}
1687
+ }
1688
+
1689
+ // 7. Prune orphaned dispatch entries — items whose source work item no longer exists
1690
+ cleaned.orphanedDispatches = 0;
1691
+ try {
1692
+ const dispatch = getDispatch();
1693
+ // Collect all work item IDs across all sources
1694
+ const allWiIds = new Set();
1695
+ try {
1696
+ const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1697
+ central.forEach(w => allWiIds.add(w.id));
1698
+ } catch {}
1699
+ for (const project of projects) {
1700
+ try {
1701
+ const projItems = safeJson(projectWorkItemsPath(project)) || [];
1702
+ projItems.forEach(w => allWiIds.add(w.id));
1703
+ } catch {}
1704
+ }
1705
+
1706
+ let changed = false;
1707
+ for (const queue of ['pending', 'active']) {
1708
+ if (!dispatch[queue]) continue;
1709
+ const before = dispatch[queue].length;
1710
+ dispatch[queue] = dispatch[queue].filter(d => {
1711
+ const itemId = d.meta?.item?.id;
1712
+ if (!itemId) return true; // keep entries without item tracking
1713
+ return allWiIds.has(itemId);
1714
+ });
1715
+ const removed = before - dispatch[queue].length;
1716
+ if (removed > 0) {
1717
+ cleaned.orphanedDispatches += removed;
1718
+ changed = true;
1719
+ }
1720
+ }
1721
+ if (changed) {
1722
+ mutateDispatch((dp) => {
1723
+ for (const queue of ['pending', 'active']) {
1724
+ if (!dp[queue]) continue;
1725
+ dp[queue] = dp[queue].filter(d => {
1726
+ const itemId = d.meta?.item?.id;
1727
+ if (!itemId) return true;
1728
+ return allWiIds.has(itemId);
1729
+ });
1730
+ }
1731
+ });
1732
+ }
1733
+ } catch {}
1734
+
1735
+ if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
1736
+ log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
1737
+ }
1738
+
1739
+ // 8. Clean swept KB files older than 7 days
1740
+ try {
1741
+ const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
1742
+ if (fs.existsSync(sweptDir)) {
1743
+ const sevenDaysAgo = Date.now() - 7 * 86400000;
1744
+ for (const f of fs.readdirSync(sweptDir)) {
1745
+ try {
1746
+ const fp = path.join(sweptDir, f);
1747
+ if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
1748
+ fs.unlinkSync(fp);
1749
+ if (!cleaned.sweptKb) cleaned.sweptKb = 0;
1750
+ cleaned.sweptKb++;
1751
+ }
1752
+ } catch {}
1753
+ }
1754
+ }
1755
+ } catch {}
1756
+
1757
+ // 9. KB watchdog — restore deleted KB files from git if count dropped vs checkpoint
1758
+ try {
1759
+ const checkpoint = safeJson(path.join(ENGINE_DIR, 'kb-checkpoint.json'));
1760
+ if (checkpoint && checkpoint.count > 0) {
1761
+ const { KB_CATEGORIES: cats } = shared;
1762
+ const knowledgeDir = path.join(MINIONS_DIR, 'knowledge');
1763
+ let current = 0;
1764
+ for (const cat of cats) {
1765
+ const d = path.join(knowledgeDir, cat);
1766
+ if (fs.existsSync(d)) current += fs.readdirSync(d).length;
1767
+ }
1768
+ if (current < checkpoint.count) {
1769
+ log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
1770
+ try {
1771
+ const trackedCheck = execSilent('git ls-tree --name-only HEAD -- knowledge', { cwd: MINIONS_DIR }).toString().trim();
1772
+ if (!trackedCheck) {
1773
+ log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
1774
+ } else {
1775
+ execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
1776
+ log('info', 'KB watchdog: restored knowledge/ from git HEAD');
1777
+ }
1778
+ } catch (err) {
1779
+ log('error', `KB watchdog: git restore failed — ${err.message}`);
1780
+ }
1781
+ }
1782
+ }
1783
+ } catch {}
1784
+
1785
+ // 6. Migrate legacy work-item statuses to canonical values
1786
+ // in-pr, implemented, complete → done (one-time correction per item)
1787
+ const LEGACY_DONE_STATUSES = new Set(['in-pr', 'implemented', 'complete']);
1788
+ for (const project of projects) {
1789
+ try {
1790
+ const wiPath = projectWorkItemsPath(project);
1791
+ const items = safeJson(wiPath) || [];
1792
+ let migrated = 0;
1793
+ for (const item of items) {
1794
+ if (LEGACY_DONE_STATUSES.has(item.status)) {
1795
+ item.status = 'done';
1796
+ migrated++;
1797
+ }
1798
+ }
1799
+ if (migrated > 0) {
1800
+ safeWrite(wiPath, items);
1801
+ log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
1802
+ }
1803
+ } catch {}
1804
+ }
1805
+ // Central work items
1806
+ try {
1807
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1808
+ const centralItems = safeJson(centralPath) || [];
1809
+ let migrated = 0;
1810
+ for (const item of centralItems) {
1811
+ if (LEGACY_DONE_STATUSES.has(item.status)) {
1812
+ item.status = 'done';
1813
+ migrated++;
1814
+ }
1815
+ }
1816
+ if (migrated > 0) {
1817
+ safeWrite(centralPath, centralItems);
1818
+ log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
1819
+ }
1820
+ } catch {}
1821
+ // PRD items (missing_features[].status)
1822
+ try {
1823
+ const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
1824
+ for (const pf of prdFiles) {
1825
+ const prdPath = path.join(PRD_DIR, pf);
1826
+ const prd = safeJson(prdPath);
1827
+ if (!prd?.missing_features) continue;
1828
+ let migrated = 0;
1829
+ for (const feat of prd.missing_features) {
1830
+ if (LEGACY_DONE_STATUSES.has(feat.status)) {
1831
+ feat.status = 'done';
1832
+ migrated++;
1833
+ }
1834
+ }
1835
+ if (migrated > 0) {
1836
+ safeWrite(prdPath, prd);
1837
+ log('info', `Migrated ${migrated} legacy PRD item status(es) → done in ${pf}`);
1838
+ }
1839
+ }
1840
+ } catch {}
1841
+
1842
+ return cleaned;
1843
+ }
1844
+
1845
+ // ─── Work Discovery ─────────────────────────────────────────────────────────
1846
+
1847
+ const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
1848
+ const dispatchCooldowns = new Map(); // key → { timestamp, failures }
1849
+
1850
+ function loadCooldowns() {
1851
+ const saved = safeJson(COOLDOWN_PATH);
1852
+ if (!saved) return;
1853
+ const now = Date.now();
1854
+ for (const [k, v] of Object.entries(saved)) {
1855
+ // Prune entries older than 24 hours
1856
+ if (now - v.timestamp < 24 * 60 * 60 * 1000) {
1857
+ dispatchCooldowns.set(k, v);
1858
+ }
1859
+ }
1860
+ log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
1861
+ }
1862
+
1863
+ let _cooldownWriteTimer = null;
1864
+ function saveCooldowns() {
1865
+ // Debounce: reset timer on each call so latest state is always written
1866
+ if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
1867
+ _cooldownWriteTimer = setTimeout(() => {
1868
+ _cooldownWriteTimer = null;
1869
+ // Prune expired entries (>24h) before saving
1870
+ const now = Date.now();
1871
+ for (const [k, v] of dispatchCooldowns) {
1872
+ if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
1873
+ }
1874
+ const obj = Object.fromEntries(dispatchCooldowns);
1875
+ safeWrite(COOLDOWN_PATH, obj);
1876
+ }, 1000); // debounce — write at most once per second
1877
+ }
1878
+
1879
+ function isOnCooldown(key, cooldownMs) {
1880
+ const entry = dispatchCooldowns.get(key);
1881
+ if (!entry) return false;
1882
+ const backoff = Math.min(Math.pow(2, entry.failures || 0), 2);
1883
+ return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
1884
+ }
1885
+
1886
+ function setCooldown(key) {
1887
+ const existing = dispatchCooldowns.get(key);
1888
+ dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
1889
+ saveCooldowns();
1890
+ }
1891
+
1892
+ function setCooldownFailure(key) {
1893
+ const existing = dispatchCooldowns.get(key);
1894
+ const failures = (existing?.failures || 0) + 1;
1895
+ dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
1896
+ if (failures >= 3) {
1897
+ log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 2)}x`);
1898
+ }
1899
+ saveCooldowns();
1900
+ }
1901
+
1902
+ function isAlreadyDispatched(key) {
1903
+ const dispatch = getDispatch();
1904
+ // Check pending and active
1905
+ const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
1906
+ if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
1907
+ // Also check recently completed (last hour) to prevent re-dispatch
1908
+ const oneHourAgo = Date.now() - 3600000;
1909
+ const recentCompleted = (dispatch.completed || []).filter(d =>
1910
+ d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
1911
+ );
1912
+ return recentCompleted.some(d => d.meta?.dispatchKey === key);
1913
+ }
1914
+
1915
+
1916
+
1917
+ /**
1918
+ * Scan ~/.minions/plans/ for plan-generated PRD files → queue implement tasks.
1919
+ * Plans are project-scoped JSON files written by the plan-to-prd playbook.
1920
+ */
1921
+ /**
1922
+ * Convert plan files into project work items (side-effect, like specs).
1923
+ * Plans write to the target project's work-items.json — picked up by discoverFromWorkItems next tick.
1924
+ */
1925
+ // Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
1926
+ function autoCleanPrdWorkItems(prdFile, config) {
1927
+ const allProjects = getProjects(config);
1928
+ const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1929
+ for (const proj of allProjects) wiPaths.push(projectWorkItemsPath(proj));
1930
+ const deletedIds = [];
1931
+ for (const wiPath of wiPaths) {
1932
+ try {
1933
+ const items = safeJson(wiPath);
1934
+ if (!items) continue;
1935
+ const filtered = items.filter(w => {
1936
+ if (w.sourcePlan === prdFile && (w.status === 'pending' || w.status === 'failed')) {
1937
+ deletedIds.push(w.id); return false;
1938
+ }
1939
+ return true;
1940
+ });
1941
+ if (filtered.length < items.length) safeWrite(wiPath, filtered);
1942
+ } catch {}
1943
+ }
1944
+ if (deletedIds.length > 0) {
1945
+ const deletedSet = new Set(deletedIds);
1946
+ cleanDispatchEntries(d => deletedSet.has(d.meta?.item?.id) && d.meta?.item?.sourcePlan === prdFile);
1947
+ log('info', `Plan sync: cleared ${deletedIds.length} pending/failed work items for ${prdFile}`);
1948
+ }
1949
+ }
1950
+
1951
+ function materializePlansAsWorkItems(config) {
1952
+ if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch {} }
1953
+
1954
+ // Enforce: PRDs must be .json — auto-rename .md files that contain valid PRD JSON
1955
+ // Check both prd/ and plans/ (agents may still write JSON to plans/)
1956
+ for (const checkDir of [PRD_DIR, PLANS_DIR]) {
1957
+ if (!fs.existsSync(checkDir)) continue;
1958
+ try {
1959
+ const mdFiles = fs.readdirSync(checkDir).filter(f => f.endsWith('.md'));
1960
+ for (const mf of mdFiles) {
1961
+ try {
1962
+ const content = (safeRead(path.join(checkDir, mf)) || '').trim();
1963
+ // Strip markdown code fences if agent wrapped JSON in them
1964
+ const stripped = content.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
1965
+ const parsed = JSON.parse(stripped);
1966
+ if (parsed.missing_features) {
1967
+ const jsonName = mf.replace(/\.md$/, '.json');
1968
+ safeWrite(path.join(PRD_DIR, jsonName), parsed);
1969
+ try { fs.unlinkSync(path.join(checkDir, mf)); } catch {}
1970
+ log('info', `Plan enforcement: moved ${mf} → prd/${jsonName} (PRDs must be .json in prd/)`);
1971
+ }
1972
+ } catch {} // Not JSON — it's a proper plan .md, leave it
1973
+ }
1974
+ } catch {}
1975
+ // Also migrate any .json PRD files from plans/ to prd/
1976
+ if (checkDir === PLANS_DIR) {
1977
+ try {
1978
+ const jsonInPlans = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.json'));
1979
+ for (const jf of jsonInPlans) {
1980
+ try {
1981
+ const parsed = safeJson(path.join(PLANS_DIR, jf));
1982
+ if (parsed?.missing_features) {
1983
+ safeWrite(path.join(PRD_DIR, jf), parsed);
1984
+ try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch {}
1985
+ log('info', `Auto-migrated PRD ${jf} from plans/ to prd/`);
1986
+ }
1987
+ } catch {}
1988
+ }
1989
+ } catch {}
1990
+ }
1991
+ }
1992
+
1993
+ let planFiles;
1994
+ try { planFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); } catch { return; }
1995
+
1996
+ for (const file of planFiles) {
1997
+ const plan = safeJson(path.join(PRD_DIR, file));
1998
+ if (!plan?.missing_features) continue;
1999
+
2000
+ // Plan staleness: if source_plan .md was modified since last sync, auto-clean and re-sync
2001
+ if (plan.source_plan) {
2002
+ const sourcePlanPath = path.join(PLANS_DIR, plan.source_plan);
2003
+ try {
2004
+ const sourceMtime = Math.floor(fs.statSync(sourcePlanPath).mtimeMs); // floor to strip sub-ms Windows precision
2005
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
2006
+ if (!recorded) {
2007
+ // First time seeing this plan — record baseline mtime (no clean needed)
2008
+ plan.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
2009
+ safeWrite(path.join(PRD_DIR, file), plan);
2010
+ } else if (sourceMtime > recorded) {
2011
+ // Source plan changed — auto-clean pending/failed items so they re-materialize with updated data
2012
+ log('info', `Source plan ${plan.source_plan} updated — re-syncing PRD ${file}`);
2013
+ autoCleanPrdWorkItems(file, config);
2014
+ plan.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
2015
+ plan.lastSyncedFromPlan = new Date().toISOString();
2016
+
2017
+ // Handle PRD based on current status
2018
+ const prdStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
2019
+
2020
+ // Approved/executing PRDs: flag as stale but don't disrupt in-flight work
2021
+ if (prdStatus === 'approved' || prdStatus === 'completed') {
2022
+ plan.planStale = true;
2023
+ log('info', `PRD ${file} flagged as stale (plan revised while ${prdStatus}) — user can regenerate from dashboard`);
2024
+ }
2025
+
2026
+ // Awaiting-approval PRDs: invalidate, carry over completed items, delete old PRD, queue regeneration
2027
+ if (prdStatus === 'awaiting-approval') {
2028
+ log('info', `PRD ${file} invalidated (was awaiting-approval) — queuing regeneration from revised plan`);
2029
+
2030
+ // Collect completed items to carry over to new PRD
2031
+ const completedStatuses = new Set(['done', 'in-pr', 'implemented']); // in-pr kept for backward compat
2032
+ const completedItems = (plan.missing_features || [])
2033
+ .filter(f => completedStatuses.has(f.status))
2034
+ .map(f => ({ id: f.id, name: f.name, status: f.status }));
2035
+
2036
+ const completedContext = completedItems.length > 0
2037
+ ? `\nPreviously completed items (preserve their status in the new PRD):\n${completedItems.map(i => `- ${i.id}: ${i.name} [${i.status}]`).join('\n')}`
2038
+ : '';
2039
+
2040
+ // Delete old PRD — agent will write replacement at same path
2041
+ try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch {}
2042
+
2043
+ // Queue plan-to-prd regeneration
2044
+ const planContent = safeRead(path.join(PLANS_DIR, plan.source_plan));
2045
+ if (planContent) {
2046
+ const projectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
2047
+ const allProjects = getProjects(config);
2048
+ const targetProject = allProjects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || allProjects[0];
2049
+ if (targetProject) {
2050
+ const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
2051
+ const centralItems = safeJson(centralWiPath) || [];
2052
+ const alreadyQueued = centralItems.some(w =>
2053
+ w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
2054
+ );
2055
+ if (!alreadyQueued) {
2056
+ centralItems.push({
2057
+ id: 'W-' + shared.uid(),
2058
+ title: `Regenerate PRD from revised plan: ${plan.source_plan}`,
2059
+ type: 'plan-to-prd',
2060
+ priority: 'high',
2061
+ description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${file}\nSource plan was revised while PRD was awaiting approval — regenerating.${completedContext}`,
2062
+ status: 'pending',
2063
+ created: ts(),
2064
+ createdBy: 'engine:plan-revision',
2065
+ project: targetProject.name,
2066
+ planFile: plan.source_plan,
2067
+ _targetPrdFile: file,
2068
+ });
2069
+ safeWrite(centralWiPath, centralItems);
2070
+ log('info', `Queued plan-to-prd regeneration for revised plan ${plan.source_plan} (${completedItems.length} completed items to carry over)`);
2071
+ }
2072
+ }
2073
+ }
2074
+ continue; // Old PRD deleted — skip safeWrite below
2075
+ }
2076
+
2077
+ safeWrite(path.join(PRD_DIR, file), plan);
2078
+ }
2079
+ } catch {}
2080
+ }
2081
+
2082
+ // Human approval gate: plans start as 'awaiting-approval' and must be approved before work begins
2083
+ // Plans without a status (legacy) or with status 'approved' are allowed through
2084
+ const planStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
2085
+ if (planStatus === 'awaiting-approval' || planStatus === 'paused' || planStatus === 'rejected' || planStatus === 'revision-requested') {
2086
+ continue; // Skip — waiting for human approval, paused, or revision
2087
+ }
2088
+ // Stale PRDs: source plan was revised — don't materialize NEW items until user regenerates
2089
+ if (plan.planStale) {
2090
+ continue;
2091
+ }
2092
+
2093
+ const defaultProjectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
2094
+ const allProjects = getProjects(config);
2095
+ const defaultProject = allProjects.find(p => p.name?.toLowerCase() === defaultProjectName.toLowerCase());
2096
+ if (!defaultProject) continue;
2097
+
2098
+ const statusFilter = ['missing', 'planned'];
2099
+ // Also materialize in-pr/done items that never got a work item (race with PR status sync)
2100
+ const allExistingWiIds = new Set();
2101
+ for (const p of allProjects) {
2102
+ for (const w of (safeJson(projectWorkItemsPath(p)) || [])) {
2103
+ if (w.id) allExistingWiIds.add(w.id);
2104
+ }
2105
+ }
2106
+ const items = plan.missing_features.filter(f =>
2107
+ statusFilter.includes(f.status) ||
2108
+ ((f.status === 'in-pr' || f.status === 'done') && f.id && !allExistingWiIds.has(f.id))
2109
+ );
2110
+
2111
+ // Group items by target project (per-item project field overrides plan-level project)
2112
+ const itemsByProject = new Map(); // projectName -> { project, items: [] }
2113
+ for (const item of items) {
2114
+ const itemProjectName = item.project || defaultProjectName;
2115
+ const itemProject = allProjects.find(p => p.name?.toLowerCase() === itemProjectName.toLowerCase()) || defaultProject;
2116
+ if (!itemsByProject.has(itemProject.name)) {
2117
+ itemsByProject.set(itemProject.name, { project: itemProject, items: [] });
2118
+ }
2119
+ itemsByProject.get(itemProject.name).items.push(item);
2120
+ }
2121
+
2122
+ // Cycle detection BEFORE materialization — skip cyclic items
2123
+ const cycleSet = new Set();
2124
+ const planDerivedItems = plan.missing_features.filter(f => f.depends_on && f.depends_on.length > 0);
2125
+ if (planDerivedItems.length > 0) {
2126
+ const cycles = detectDependencyCycles(plan.missing_features);
2127
+ if (cycles.length > 0) {
2128
+ log('error', `Dependency cycle detected in plan ${file}: ${cycles.join(', ')} — skipping cyclic items`);
2129
+ cycles.forEach(c => cycleSet.add(c));
2130
+ writeInboxAlert(`cycle-${path.basename(file, '.json')}`,
2131
+ `# Dependency Cycle Detected — ${path.basename(file)}\n\n` +
2132
+ `The following PRD items form a cycle and were **skipped** (will never be dispatched):\n\n` +
2133
+ cycles.map(id => `- \`${id}\``).join('\n') + '\n\n' +
2134
+ `Fix by removing or reordering the \`depends_on\` relationships in \`prd/${path.basename(file)}\`.\n`
2135
+ );
2136
+ }
2137
+ }
2138
+
2139
+ let totalCreated = 0;
2140
+ for (const [projName, { project, items: projItems }] of itemsByProject) {
2141
+ const wiPath = projectWorkItemsPath(project);
2142
+ const existingItems = safeJson(wiPath) || [];
2143
+ let created = 0;
2144
+ const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
2145
+
2146
+ for (const item of projItems) {
2147
+ // Skip if already materialized — work item ID = PRD item ID, check all projects
2148
+ let alreadyExists = existingItems.some(w => w.id === item.id);
2149
+ if (!alreadyExists) {
2150
+ for (const p of allProjects) {
2151
+ if (p.name === projName) continue;
2152
+ const otherItems = safeJson(projectWorkItemsPath(p)) || [];
2153
+ if (otherItems.some(w => w.id === item.id)) { alreadyExists = true; break; }
2154
+ }
2155
+ }
2156
+ if (alreadyExists) continue;
2157
+ // Skip items involved in dependency cycles
2158
+ if (cycleSet.has(item.id)) continue;
2159
+
2160
+ const id = item.id; // Work item ID = PRD item ID — no indirection
2161
+ const complexity = item.estimated_complexity || 'medium';
2162
+ const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
2163
+
2164
+ const newItem = {
2165
+ id,
2166
+ title: `Implement: ${item.name}`,
2167
+ type: complexity === 'large' ? 'implement:large' : 'implement',
2168
+ priority: item.priority || 'medium',
2169
+ description: `${item.description || ''}\n\n**Plan:** ${file}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`,
2170
+ status: 'pending',
2171
+ created: ts(),
2172
+ createdBy: 'engine:plan-discovery',
2173
+ sourcePlan: file,
2174
+ depends_on: item.depends_on || [],
2175
+ branchStrategy: plan.branch_strategy || 'parallel',
2176
+ featureBranch: plan.feature_branch || null,
2177
+ project: item.project || plan.project || null,
2178
+ };
2179
+ existingItems.push(newItem);
2180
+ newlyCreatedIds.add(id);
2181
+ created++;
2182
+ }
2183
+
2184
+ if (created > 0) {
2185
+ // Reconciliation: exact prdItems match only, scoped to newly created items
2186
+ const allPrsForReconcile = allProjects.flatMap(p => safeJson(projectPrPath(p)) || []);
2187
+ const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
2188
+ if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
2189
+
2190
+ // PRD removal sync: cancel pending work items whose PRD item was removed from the plan
2191
+ const currentPrdIds = new Set(plan.missing_features.map(f => f.id));
2192
+ let cancelled = 0;
2193
+ for (const wi of existingItems) {
2194
+ if (wi.status !== 'pending' || wi.sourcePlan !== file) continue;
2195
+ if (!currentPrdIds.has(wi.id)) {
2196
+ wi.status = 'cancelled';
2197
+ wi.cancelledAt = ts();
2198
+ wi.cancelReason = `PRD item removed from ${file}`;
2199
+ cancelled++;
2200
+ }
2201
+ }
2202
+ if (cancelled > 0) log('info', `Plan sync: cancelled ${cancelled} item(s) removed from ${file} → ${projName}`);
2203
+
2204
+ safeWrite(wiPath, existingItems);
2205
+ log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${projName}`);
2206
+ }
2207
+ totalCreated += created;
2208
+ }
2209
+
2210
+ if (totalCreated > 0) {
2211
+ log('info', `Plan discovery: ${totalCreated} total item(s) from ${file} across ${itemsByProject.size} project(s)`);
2212
+
2213
+ // Pre-create shared feature branch if branch_strategy is shared-branch
2214
+ if (plan.branch_strategy === 'shared-branch' && plan.feature_branch) {
2215
+ try {
2216
+ const root = path.resolve(project.localPath);
2217
+ const mainBranch = project.mainBranch || 'main';
2218
+ const branch = sanitizeBranch(plan.feature_branch);
2219
+ // Create branch from main (idempotent — ignores if exists)
2220
+ exec(`git branch "${branch}" "${mainBranch}" 2>/dev/null || true`, { cwd: root, stdio: 'pipe' });
2221
+ exec(`git push -u origin "${branch}" 2>/dev/null || true`, { cwd: root, stdio: 'pipe' });
2222
+ log('info', `Shared branch pre-created: ${branch} for plan ${file}`);
2223
+ } catch (err) {
2224
+ log('warn', `Failed to pre-create shared branch for ${file}: ${err.message}`);
2225
+ }
2226
+ }
2227
+
2228
+ // (cycle detection moved before materialization loop)
2229
+ }
2230
+ }
2231
+ }
2232
+
2233
+ // ─── Work Discovery Helpers ──────────────────────────────────────────────────
2234
+
2235
+ function buildBaseVars(agentId, config, project) {
2236
+ return {
2237
+ agent_id: agentId,
2238
+ agent_name: config.agents[agentId]?.name || agentId,
2239
+ agent_role: config.agents[agentId]?.role || 'Agent',
2240
+ team_root: MINIONS_DIR,
2241
+ repo_id: project?.repositoryId || '',
2242
+ project_name: project?.name || 'Unknown Project',
2243
+ ado_org: project?.adoOrg || 'Unknown',
2244
+ ado_project: project?.adoProject || 'Unknown',
2245
+ repo_name: project?.repoName || 'Unknown',
2246
+ main_branch: project?.mainBranch || 'main',
2247
+ date: dateStamp(),
2248
+ };
2249
+ }
2250
+
2251
+ function selectPlaybook(workType, item) {
2252
+ if (item?.branchStrategy === 'shared-branch' && (workType === 'implement' || workType === 'implement:large')) {
2253
+ return 'implement-shared';
2254
+ }
2255
+ if (workType === 'review' && !item?._pr && !item?.pr_id) {
2256
+ return 'work-item';
2257
+ }
2258
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify'];
2259
+ return typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2260
+ }
2261
+
2262
+ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabel, meta) {
2263
+ const vars = { ...buildBaseVars(agentId, config, project), ...extraVars };
2264
+ const playbookName = type === 'test' ? 'build-and-test' : (type === 'review' ? 'review' : 'fix');
2265
+ const prompt = renderPlaybook(playbookName, vars);
2266
+ if (!prompt) return null;
2267
+ return {
2268
+ type,
2269
+ agent: agentId,
2270
+ agentName: config.agents[agentId]?.name,
2271
+ agentRole: config.agents[agentId]?.role,
2272
+ task: `[${project?.name || 'project'}] ${taskLabel}`,
2273
+ prompt,
2274
+ meta,
2275
+ };
2276
+ }
2277
+
2278
+ /**
2279
+ * Scan pull-requests.json for PRs needing review or fixes
2280
+ */
2281
+ function discoverFromPrs(config, project) {
2282
+ const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
2283
+ if (!src?.enabled) return [];
2284
+
2285
+ const prs = safeJson(projectPrPath(project)) || [];
2286
+ const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
2287
+ const newWork = [];
2288
+
2289
+ const projMeta = { name: project?.name, localPath: project?.localPath };
2290
+
2291
+ // Collect active PR dispatches to prevent simultaneous review+fix on same PR
2292
+ const dispatch = getDispatch();
2293
+ const activePrIds = new Set(
2294
+ (dispatch.active || []).filter(d => d.meta?.pr?.id).map(d => d.meta.pr.id)
2295
+ );
2296
+
2297
+ for (const pr of prs) {
2298
+ if (pr.status !== 'active') continue;
2299
+ if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
2300
+
2301
+ const prNumber = (pr.id || '').replace(/^PR-/, '');
2302
+ const minionsStatus = pr.minionsReview?.status;
2303
+
2304
+ // PRs needing review
2305
+ const needsReview = !minionsStatus || minionsStatus === 'waiting';
2306
+ if (needsReview) {
2307
+ const key = `review-${project?.name || 'default'}-${pr.id}`;
2308
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2309
+ // No self-review: exclude the PR author from review assignment
2310
+ const prAuthor = (pr.agent || '').toLowerCase();
2311
+ let agentId = resolveAgent('review', config);
2312
+ if (agentId && agentId === prAuthor) {
2313
+ agentId = resolveAgent('review', config); // retry — prAuthor now claimed, gets skipped
2314
+ }
2315
+ if (!agentId) continue;
2316
+
2317
+ const item = buildPrDispatch(agentId, config, project, pr, 'review', {
2318
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2319
+ pr_author: pr.agent || '', pr_url: pr.url || '',
2320
+ }, `Review PR ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2321
+ if (item) { newWork.push(item); setCooldown(key); }
2322
+ }
2323
+
2324
+ // PRs with changes requested → route back to author for fix
2325
+ if (minionsStatus === 'changes-requested') {
2326
+ const key = `fix-${project?.name || 'default'}-${pr.id}`;
2327
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2328
+ const agentId = resolveAgent('fix', config, pr.agent);
2329
+ if (!agentId) continue;
2330
+
2331
+ const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2332
+ pr_id: pr.id, pr_branch: pr.branch || '',
2333
+ review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
2334
+ }, `Fix PR ${pr.id} review feedback`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2335
+ if (item) { newWork.push(item); setCooldown(key); }
2336
+ }
2337
+
2338
+ // PRs with pending human feedback
2339
+ if (pr.humanFeedback?.pendingFix) {
2340
+ const key = `human-fix-${project?.name || 'default'}-${pr.id}`;
2341
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2342
+ const agentId = resolveAgent('fix', config, pr.agent);
2343
+ if (!agentId) continue;
2344
+
2345
+ const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2346
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2347
+ reviewer: 'Human Reviewer',
2348
+ review_note: pr.humanFeedback.feedbackContent || 'See PR thread comments',
2349
+ }, `Fix PR ${pr.id} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
2350
+ if (item) { newWork.push(item); pr.humanFeedback.pendingFix = false; setCooldown(key); }
2351
+ }
2352
+
2353
+ // PRs with build failures
2354
+ if (pr.status === 'active' && pr.buildStatus === 'failing') {
2355
+ const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
2356
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2357
+ const agentId = resolveAgent('fix', config);
2358
+ if (!agentId) continue;
2359
+
2360
+ const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2361
+ pr_id: pr.id, pr_branch: pr.branch || '',
2362
+ review_note: `Build is failing: ${pr.buildFailReason || 'Check CI pipeline for details'}. Fix the build errors and push.`,
2363
+ }, `Fix build failure on PR ${pr.id}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2364
+ if (item) { newWork.push(item); setCooldown(key); }
2365
+ }
2366
+
2367
+ }
2368
+ // Build & test now runs once at PRD completion (via verify task), not per-PR.
2369
+
2370
+ return newWork;
2371
+ }
2372
+
2373
+ /**
2374
+ * Scan work-items.json for manually queued tasks
2375
+ */
2376
+ function discoverFromWorkItems(config, project) {
2377
+ const src = project?.workSources?.workItems || config.workSources?.workItems;
2378
+ if (!src?.enabled) return [];
2379
+
2380
+ const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
2381
+ const items = safeJson(projectWorkItemsPath(project)) || [];
2382
+ const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
2383
+ const newWork = [];
2384
+ const skipped = { gated: 0, noAgent: 0 };
2385
+ let needsWrite = false;
2386
+
2387
+ for (const item of items) {
2388
+ // Re-evaluate failed items: if deps have recovered, reset to pending
2389
+ if (item.status === 'failed' && item.failReason === 'Dependency failed — cannot proceed') {
2390
+ const depStatus = areDependenciesMet(item, config);
2391
+ if (depStatus === true) {
2392
+ item.status = 'pending';
2393
+ delete item.failReason;
2394
+ log('info', `Recovered ${item.id} from dependency failure — deps now met`);
2395
+ needsWrite = true;
2396
+ }
2397
+ }
2398
+
2399
+ if (item.status !== 'queued' && item.status !== 'pending') continue;
2400
+
2401
+ // Dependency gate: skip items whose depends_on are not yet met; propagate failure
2402
+ if (item.depends_on && item.depends_on.length > 0) {
2403
+ const depStatus = areDependenciesMet(item, config);
2404
+ if (depStatus === 'failed') {
2405
+ item.status = 'failed';
2406
+ item.failReason = 'Dependency failed — cannot proceed';
2407
+ log('warn', `Marking ${item.id} as failed: dependency failed (plan: ${item.sourcePlan})`);
2408
+ continue;
2409
+ }
2410
+ if (!depStatus) continue;
2411
+ }
2412
+
2413
+ const key = `work-${project?.name || 'default'}-${item.id}`;
2414
+ // Self-heal: if an item is pending, stale completed/cooldown markers must not gate redispatch.
2415
+ // This protects against persisted state drift from old runtime versions.
2416
+ try {
2417
+ mutateDispatch((dp) => {
2418
+ const before = Array.isArray(dp.completed) ? dp.completed.length : 0;
2419
+ dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== key) : [];
2420
+ return dp.completed.length !== before ? dp : undefined;
2421
+ });
2422
+ dispatchCooldowns.delete(key);
2423
+ } catch {}
2424
+ // Cooldown bypass for resumed items — clear in-memory cooldown so they dispatch immediately
2425
+ if (item._resumedAt) {
2426
+ dispatchCooldowns.delete(key);
2427
+ delete item._resumedAt;
2428
+ safeWrite(projectWorkItemsPath(project), items);
2429
+ }
2430
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) { skipped.gated++; continue; }
2431
+
2432
+ let workType = item.type || 'implement';
2433
+ if (workType === 'implement' && (item.complexity === 'large' || item.estimated_complexity === 'large')) {
2434
+ workType = 'implement:large';
2435
+ }
2436
+ const agentId = item.agent || resolveAgent(workType, config);
2437
+ if (!agentId) { skipped.noAgent++; continue; }
2438
+
2439
+ const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
2440
+ const branchName = isShared ? item.featureBranch : (item.branch || `work/${item.id}`);
2441
+ const vars = {
2442
+ ...buildBaseVars(agentId, config, project),
2443
+ item_id: item.id,
2444
+ item_name: item.title || item.id,
2445
+ item_priority: item.priority || 'medium',
2446
+ item_description: item.description || '',
2447
+ item_complexity: item.complexity || item.estimated_complexity || 'medium',
2448
+ task_description: item.title + (item.description ? '\n\n' + item.description : ''),
2449
+ task_id: item.id,
2450
+ work_type: workType,
2451
+ additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2452
+ scope_section: `## Scope: Project — ${project?.name || 'default'}\n\nThis task is scoped to a single project.`,
2453
+ branch_name: branchName,
2454
+ project_path: root,
2455
+ worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`),
2456
+ commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
2457
+ notes_content: '',
2458
+ };
2459
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2460
+
2461
+ // Inject ask-specific variables for the ask playbook
2462
+ if (workType === 'ask') {
2463
+ vars.question = item.title + (item.description ? '\n\n' + item.description : '');
2464
+ vars.task_id = item.id;
2465
+ vars.notes_content = '';
2466
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2467
+ }
2468
+
2469
+ // Resolve implicit context references (e.g., "ripley's plan", "the latest plan")
2470
+ const resolvedCtx = resolveTaskContext(item, config);
2471
+ if (resolvedCtx.additionalContext) {
2472
+ vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
2473
+ vars.task_description = vars.task_description + resolvedCtx.additionalContext;
2474
+ }
2475
+
2476
+ const playbookName = selectPlaybook(workType, item);
2477
+ if (playbookName === 'work-item' && workType === 'review') {
2478
+ log('info', `Work item ${item.id} is type "review" but has no PR — using work-item playbook`);
2479
+ }
2480
+ const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
2481
+ if (!prompt) {
2482
+ log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
2483
+ continue;
2484
+ }
2485
+
2486
+ // Mark item as dispatched BEFORE adding to newWork (prevents race on next tick)
2487
+ item.status = 'dispatched';
2488
+ item.dispatched_at = ts();
2489
+ item.dispatched_to = agentId;
2490
+ syncPrdItemStatus(item.id, 'dispatched', item.sourcePlan);
2491
+
2492
+ newWork.push({
2493
+ type: workType,
2494
+ agent: agentId,
2495
+ agentName: config.agents[agentId]?.name,
2496
+ agentRole: config.agents[agentId]?.role,
2497
+ task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
2498
+ prompt,
2499
+ meta: { dispatchKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(item.branchStrategy === 'shared-branch' && item.featureBranch), item, project: { name: project?.name, localPath: project?.localPath } }
2500
+ });
2501
+
2502
+ setCooldown(key);
2503
+ }
2504
+
2505
+ // Write back updated statuses (always, since we mark items dispatched before newWork check)
2506
+ if (newWork.length > 0) {
2507
+ const workItemsPath = projectWorkItemsPath(project);
2508
+ safeWrite(workItemsPath, items);
2509
+ }
2510
+
2511
+ if (needsWrite) safeWrite(projectWorkItemsPath(project), items);
2512
+
2513
+ const skipTotal = skipped.gated + skipped.noAgent;
2514
+ if (skipTotal > 0) {
2515
+ log('debug', `Work item discovery (${project?.name}): skipped ${skipTotal} items (${skipped.gated} gated, ${skipped.noAgent} no agent)`);
2516
+ }
2517
+
2518
+ return newWork;
2519
+ }
2520
+
2521
+ /**
2522
+ * Build the multi-project context section for central work items.
2523
+ * Inserted into the playbook via {{scope_section}}.
2524
+ */
2525
+ function buildProjectContext(projects, assignedProject, isFanOut, agentName, agentRole) {
2526
+ const projectList = projects.map(p => {
2527
+ let line = `### ${p.name}\n`;
2528
+ line += `- **Path:** ${p.localPath}\n`;
2529
+ line += `- **Repo:** ${p.adoOrg}/${p.adoProject}/${p.repoName} (ID: ${p.repositoryId || 'unknown'}, host: ${getRepoHostLabel(p)})\n`;
2530
+ if (p.description) line += `- **What it is:** ${p.description}\n`;
2531
+ return line;
2532
+ }).join('\n');
2533
+
2534
+ let section = '';
2535
+
2536
+ if (isFanOut && assignedProject) {
2537
+ section += `## Scope: Fan-out (parallel multi-agent)\n\n`;
2538
+ section += `You are assigned to **${assignedProject.name}**. Other agents are handling the other projects.\n\n`;
2539
+ } else {
2540
+ section += `## Scope: Multi-project (you decide where to work)\n\n`;
2541
+ section += `Determine which project(s) this task applies to. It may span multiple repos.\n`;
2542
+ section += `If multi-repo, work on each sequentially (worktree + PR per repo).\n`;
2543
+ section += `Note cross-repo dependencies in PR descriptions.\n\n`;
2544
+ }
2545
+
2546
+ section += `## Available Projects\n\n${projectList}`;
2547
+ return section;
2548
+ }
2549
+
2550
+ /**
2551
+ * Detect merged PRs containing spec documents and create implement work items.
2552
+ * "Specs" = any markdown doc merged into the repo that describes work to build.
2553
+ * Writes work items as a side-effect; discoverFromWorkItems() picks them up next tick.
2554
+ *
2555
+ * Config key: workSources.specs
2556
+ * Only processes docs with frontmatter `type: spec` — regular docs are ignored.
2557
+ */
2558
+ function materializeSpecsAsWorkItems(config, project) {
2559
+ const src = project?.workSources?.specs;
2560
+ if (!src?.enabled) return;
2561
+
2562
+ const root = projectRoot(project);
2563
+ const filePatterns = src.filePatterns || ['docs/**/*.md'];
2564
+ const trackerPath = path.join(projectStateDir(project), 'spec-tracker.json');
2565
+ const tracker = safeJson(trackerPath) || { processedPrs: {} };
2566
+
2567
+ const prs = getPrs(project);
2568
+ const mergedPrs = prs.filter(pr =>
2569
+ (pr.status === 'merged' || pr.status === 'completed') &&
2570
+ !tracker.processedPrs[pr.id]
2571
+ );
2572
+
2573
+ if (mergedPrs.length === 0) return;
2574
+
2575
+ const sinceDate = src.lookbackDays ? `${src.lookbackDays} days ago` : '7 days ago';
2576
+ let recentSpecs = [];
2577
+ for (const pattern of filePatterns) {
2578
+ try {
2579
+ const result = exec(
2580
+ `git log --diff-filter=AM --name-only --pretty=format:"COMMIT:%H|%s" --since="${sinceDate}" -- "${pattern}"`,
2581
+ { cwd: root, encoding: 'utf8', timeout: 10000 }
2582
+ ).trim();
2583
+ if (!result) continue;
2584
+
2585
+ let currentCommit = null;
2586
+ for (const line of result.split('\n')) {
2587
+ if (line.startsWith('COMMIT:')) {
2588
+ const [hash, ...msgParts] = line.replace('COMMIT:', '').split('|');
2589
+ currentCommit = { hash: hash.trim(), message: msgParts.join('|').trim() };
2590
+ } else if (line.trim() && currentCommit) {
2591
+ recentSpecs.push({ file: line.trim(), ...currentCommit });
2592
+ }
2593
+ }
2594
+ } catch {}
2595
+ }
2596
+
2597
+ if (recentSpecs.length === 0) return;
2598
+
2599
+ const wiPath = projectWorkItemsPath(project);
2600
+ const existingItems = safeJson(wiPath) || [];
2601
+ let created = 0;
2602
+
2603
+ for (const pr of mergedPrs) {
2604
+ const prBranch = (pr.branch || '').toLowerCase();
2605
+ const matchedSpecs = recentSpecs.filter(doc => {
2606
+ const msg = doc.message.toLowerCase();
2607
+ // Match any doc whose commit message references this PR's branch
2608
+ return prBranch && msg.includes(prBranch.split('/').pop());
2609
+ });
2610
+
2611
+ if (matchedSpecs.length === 0) {
2612
+ tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
2613
+ continue;
2614
+ }
2615
+
2616
+ for (const doc of matchedSpecs) {
2617
+ if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
2618
+
2619
+ const info = extractSpecInfo(doc.file, root);
2620
+ if (!info) continue;
2621
+
2622
+ const newId = 'SP-' + shared.uid();
2623
+
2624
+ existingItems.push({
2625
+ id: newId,
2626
+ type: 'implement',
2627
+ title: `Implement: ${info.title}`,
2628
+ 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.`,
2629
+ priority: info.priority,
2630
+ status: 'queued',
2631
+ created: ts(),
2632
+ createdBy: 'engine:spec-discovery',
2633
+ sourceSpec: doc.file,
2634
+ sourcePr: pr.id
2635
+ });
2636
+ created++;
2637
+ log('info', `Spec discovery: created ${newId} "${info.title}" from PR ${pr.id} in ${project.name}`);
2638
+ }
2639
+
2640
+ tracker.processedPrs[pr.id] = { processedAt: ts(), matched: true, specs: matchedSpecs.map(d => d.file) };
2641
+ }
2642
+
2643
+ if (created > 0) {
2644
+ safeWrite(wiPath, existingItems);
2645
+ }
2646
+ safeWrite(trackerPath, tracker);
2647
+ }
2648
+
2649
+ /**
2650
+ * Extract title, summary, and priority from a spec markdown file.
2651
+ * Returns null if the file doesn't have `type: spec` in its frontmatter.
2652
+ */
2653
+ function extractSpecInfo(filePath, projectRoot_) {
2654
+ const fullPath = path.resolve(projectRoot_, filePath);
2655
+ const content = safeRead(fullPath);
2656
+ if (!content) return null;
2657
+
2658
+ // Require frontmatter with type: spec
2659
+ const fmBlock = content.match(/^---\n([\s\S]*?)\n---/);
2660
+ if (!fmBlock) return null;
2661
+ const frontmatter = fmBlock[1];
2662
+ if (!/type:\s*spec/i.test(frontmatter)) return null;
2663
+
2664
+ let title = '';
2665
+ const fmMatch = content.match(/^---\n[\s\S]*?title:\s*(.+)\n[\s\S]*?---/);
2666
+ const h1Match = content.match(/^#\s+(.+)$/m);
2667
+ title = fmMatch?.[1]?.trim() || h1Match?.[1]?.trim() || path.basename(filePath, '.md');
2668
+
2669
+ let summary = '';
2670
+ const summaryMatch = content.match(/##\s*Summary\n\n([\s\S]*?)(?:\n##|\n---|$)/);
2671
+ if (summaryMatch) {
2672
+ summary = summaryMatch[1].trim();
2673
+ } else {
2674
+ const lines = content.split('\n');
2675
+ let pastTitle = false;
2676
+ for (const line of lines) {
2677
+ if (line.startsWith('# ')) { pastTitle = true; continue; }
2678
+ if (pastTitle && line.trim() && !line.startsWith('#') && !line.startsWith('---')) {
2679
+ summary = line.trim();
2680
+ break;
2681
+ }
2682
+ }
2683
+ }
2684
+
2685
+ const priorityMatch = content.match(/priority:\s*(high|medium|low|critical)/i);
2686
+ const priority = priorityMatch?.[1]?.toLowerCase() || 'medium';
2687
+
2688
+ return { title, summary: summary.slice(0, 1500), priority };
2689
+ }
2690
+
2691
+ /**
2692
+ * Scan central ~/.minions/work-items.json for project-agnostic tasks.
2693
+ * Uses the shared work-item.md playbook with multi-project context injected.
2694
+ */
2695
+ function discoverCentralWorkItems(config) {
2696
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
2697
+ const items = safeJson(centralPath) || [];
2698
+ const projects = getProjects(config);
2699
+ const newWork = [];
2700
+
2701
+ for (const item of items) {
2702
+ if (item.status !== 'queued' && item.status !== 'pending') continue;
2703
+
2704
+ const key = `central-work-${item.id}`;
2705
+ if (isAlreadyDispatched(key) || isOnCooldown(key, 0)) continue;
2706
+
2707
+ const workType = item.type || 'implement';
2708
+ const isFanOut = item.scope === 'fan-out';
2709
+
2710
+ if (isFanOut) {
2711
+ // ─── Fan-out: dispatch to ALL idle agents ───────────────────────
2712
+ const idleAgents = Object.entries(config.agents)
2713
+ .filter(([id]) => {
2714
+ const s = getAgentStatus(id);
2715
+ return ['idle', 'done', 'completed'].includes(s.status);
2716
+ })
2717
+ .map(([id, info]) => ({ id, ...info }));
2718
+
2719
+ if (idleAgents.length === 0) {
2720
+ log('info', `Fan-out: all agents busy for ${item.id}, will retry next tick`);
2721
+ continue; // Item stays pending, retried next tick
2722
+ }
2723
+
2724
+ const assignments = idleAgents.map((agent, i) => ({
2725
+ agent,
2726
+ assignedProject: projects.length > 0 ? projects[i % projects.length] : null
2727
+ }));
2728
+
2729
+ for (const { agent, assignedProject } of assignments) {
2730
+ const fanKey = `${key}-${agent.id}`;
2731
+ if (isAlreadyDispatched(fanKey)) continue;
2732
+
2733
+ const ap = assignedProject || projects[0];
2734
+ const vars = {
2735
+ ...buildBaseVars(agent.id, config, ap),
2736
+ item_id: item.id,
2737
+ item_name: item.title || item.id,
2738
+ item_priority: item.priority || 'medium',
2739
+ item_description: item.description || '',
2740
+ work_type: workType,
2741
+ additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2742
+ scope_section: buildProjectContext(projects, assignedProject, true, agent.name, agent.role),
2743
+ project_path: ap?.localPath || '',
2744
+ };
2745
+
2746
+ if (workType === 'ask') {
2747
+ vars.question = item.title + (item.description ? '\n\n' + item.description : '');
2748
+ vars.task_id = item.id;
2749
+ vars.notes_content = '';
2750
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2751
+ }
2752
+
2753
+ const resolvedCtx = resolveTaskContext(item, config);
2754
+ if (resolvedCtx.additionalContext) {
2755
+ vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
2756
+ }
2757
+
2758
+ const playbookName = selectPlaybook(workType, item);
2759
+ const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2760
+ if (!prompt) {
2761
+ log('warn', `Fan-out: playbook '${playbookName}' failed to render for ${item.id} → ${agent.id}, skipping`);
2762
+ continue;
2763
+ }
2764
+
2765
+ newWork.push({
2766
+ type: workType,
2767
+ agent: agent.id,
2768
+ agentName: agent.name,
2769
+ agentRole: agent.role,
2770
+ task: `[fan-out] ${item.title} → ${agent.name}${assignedProject ? ' → ' + assignedProject.name : ''}`,
2771
+ prompt,
2772
+ meta: {
2773
+ dispatchKey: fanKey, source: 'central-work-item-fanout', item, parentKey: key,
2774
+ deadline: item.timeout ? Date.now() + item.timeout : Date.now() + (config.engine?.fanOutTimeout || config.engine?.agentTimeout || DEFAULTS.agentTimeout)
2775
+ }
2776
+ });
2777
+ }
2778
+
2779
+ item.status = 'dispatched';
2780
+ item.dispatched_at = ts();
2781
+ item.dispatched_to = idleAgents.map(a => a.id).join(', ');
2782
+ item.scope = 'fan-out';
2783
+ item.fanOutAgents = idleAgents.map(a => a.id);
2784
+ setCooldown(key);
2785
+ log('info', `Fan-out: ${item.id} dispatched to ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
2786
+
2787
+ } else {
2788
+ // ─── Normal: single agent dispatch ──────────────────────────────
2789
+ const agentId = item.agent || resolveAgent(workType, config);
2790
+ if (!agentId) continue;
2791
+
2792
+ const agentName = config.agents[agentId]?.name || agentId;
2793
+ const agentRole = config.agents[agentId]?.role || 'Agent';
2794
+ const firstProject = projects[0];
2795
+
2796
+ const vars = {
2797
+ ...buildBaseVars(agentId, config, firstProject),
2798
+ item_id: item.id,
2799
+ item_name: item.title || item.id,
2800
+ item_priority: item.priority || 'medium',
2801
+ item_description: item.description || '',
2802
+ item_complexity: item.complexity || item.estimated_complexity || 'medium',
2803
+ task_description: item.title + (item.description ? '\n\n' + item.description : ''),
2804
+ task_id: item.id,
2805
+ work_type: workType,
2806
+ additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2807
+ scope_section: buildProjectContext(projects, null, false, agentName, agentRole),
2808
+ project_path: firstProject?.localPath || '',
2809
+ notes_content: '',
2810
+ };
2811
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2812
+
2813
+ // Inject plan-specific variables for the plan playbook
2814
+ if (workType === 'plan') {
2815
+ // Ensure plans directory exists before agent tries to write
2816
+ if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
2817
+ const planFileName = `plan-${item.id.toLowerCase()}-${dateStamp()}.md`;
2818
+ vars.plan_content = item.title + (item.description ? '\n\n' + item.description : '');
2819
+ vars.plan_title = item.title;
2820
+ vars.plan_file = planFileName;
2821
+ vars.task_description = item.title;
2822
+ vars.notes_content = '';
2823
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2824
+ // Track expected plan filename in meta for chainPlanToPrd
2825
+ item._planFileName = planFileName;
2826
+ }
2827
+
2828
+ // Inject plan-to-prd variables — read the plan file content for the playbook
2829
+ if (workType === 'plan-to-prd' && item.planFile) {
2830
+ if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
2831
+ if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
2832
+ const planPath = path.join(PLANS_DIR, item.planFile);
2833
+ try {
2834
+ vars.plan_content = fs.readFileSync(planPath, 'utf8');
2835
+ } catch (e) {
2836
+ log('warn', `plan-to-prd: could not read plan file ${item.planFile} for ${item.id}: ${e.message}`);
2837
+ vars.plan_content = item.description || '';
2838
+ }
2839
+ vars.plan_summary = (item.title || item.planFile).substring(0, 80);
2840
+ vars.plan_file = item.planFile || '';
2841
+ vars.project_name_lower = (firstProject?.name || 'project').toLowerCase();
2842
+ vars.branch_strategy_hint = item.branchStrategy
2843
+ ? `The user requested **${item.branchStrategy}** strategy. Use this unless the analysis strongly suggests otherwise.`
2844
+ : 'Choose the best strategy based on your analysis of item dependencies.';
2845
+ }
2846
+
2847
+ // Inject ask-specific variables for the ask playbook
2848
+ if (workType === 'ask') {
2849
+ vars.question = item.title + (item.description ? '\n\n' + item.description : '');
2850
+ vars.task_id = item.id;
2851
+ vars.notes_content = '';
2852
+ try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2853
+ }
2854
+
2855
+ // Resolve implicit context references
2856
+ const resolvedCtx = resolveTaskContext(item, config);
2857
+ if (resolvedCtx.additionalContext) {
2858
+ vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
2859
+ vars.task_description = vars.task_description + resolvedCtx.additionalContext;
2860
+ }
2861
+
2862
+ const playbookName = selectPlaybook(workType, item);
2863
+ const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2864
+ if (!prompt) {
2865
+ log('warn', `Dispatch: playbook '${playbookName}' failed to render for ${item.id}, resetting to pending`);
2866
+ item.status = 'pending';
2867
+ continue;
2868
+ }
2869
+
2870
+ newWork.push({
2871
+ type: workType,
2872
+ agent: agentId,
2873
+ agentName,
2874
+ agentRole,
2875
+ task: item.title || item.description?.slice(0, 80) || item.id,
2876
+ prompt,
2877
+ meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || item._planFileName || null }
2878
+ });
2879
+
2880
+ item.status = 'dispatched';
2881
+ item.dispatched_at = ts();
2882
+ item.dispatched_to = agentId;
2883
+ setCooldown(key);
2884
+ }
2885
+ }
2886
+
2887
+ if (newWork.length > 0) safeWrite(centralPath, items);
2888
+ return newWork;
2889
+ }
2890
+
2891
+
2892
+ /**
2893
+ * Run all work discovery sources and queue new items
2894
+ * Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
2895
+ */
2896
+ function discoverWork(config) {
2897
+ resetClaimedAgents(); // Reset per-tick agent claims for fair distribution
2898
+ const projects = getProjects(config);
2899
+ let allFixes = [], allReviews = [], allWorkItems = [];
2900
+
2901
+ // Side-effect passes: materialize plans and design docs into work-items.json
2902
+ // These write to project work queues — picked up by discoverFromWorkItems below.
2903
+ materializePlansAsWorkItems(config);
2904
+
2905
+ for (const project of projects) {
2906
+ const root = project.localPath ? path.resolve(project.localPath) : null;
2907
+ if (!root || !fs.existsSync(root)) continue;
2908
+
2909
+ // Source 1: Pull Requests → fixes, reviews, build-test
2910
+ const prWork = discoverFromPrs(config, project);
2911
+ allFixes.push(...prWork.filter(w => w.type === 'fix'));
2912
+ allReviews.push(...prWork.filter(w => w.type === 'review'));
2913
+ allWorkItems.push(...prWork.filter(w => w.type === 'test'));
2914
+
2915
+ // Side-effect: specs → work items (picked up below)
2916
+ materializeSpecsAsWorkItems(config, project);
2917
+
2918
+ // Source 3: Work items (includes auto-filed from plans, design docs, build failures)
2919
+ allWorkItems.push(...discoverFromWorkItems(config, project));
2920
+ }
2921
+
2922
+ // Source 2: Minions-level PRD → implements (multi-project, called once outside project loop)
2923
+ // PRD items now flow through plans/*.json → materializePlansAsWorkItems → discoverFromWorkItems
2924
+
2925
+ // Central work items (project-agnostic — agent decides where to work)
2926
+ const centralWork = discoverCentralWorkItems(config);
2927
+
2928
+ // Gate reviews and fixes: do not dispatch until all implement items are complete
2929
+ const hasIncompleteImplements = projects.some(project => {
2930
+ const items = safeJson(projectWorkItemsPath(project)) || [];
2931
+ return items.some(i => ['queued', 'pending', 'dispatched'].includes(i.status) && (i.type || '').startsWith('implement'));
2932
+ });
2933
+ if (hasIncompleteImplements) {
2934
+ if (allReviews.length > 0) {
2935
+ log('info', `Gating ${allReviews.length} reviews — implement items still in progress`);
2936
+ allReviews = [];
2937
+ }
2938
+ if (allFixes.length > 0) {
2939
+ log('info', `Gating ${allFixes.length} fixes — implement items still in progress`);
2940
+ allFixes = [];
2941
+ }
2942
+ }
2943
+
2944
+ const allWork = [...allFixes, ...allReviews, ...allWorkItems, ...centralWork];
2945
+
2946
+ for (const item of allWork) {
2947
+ addToDispatch(item);
2948
+ }
2949
+
2950
+ if (allWork.length > 0) {
2951
+ log('info', `Discovered ${allWork.length} new work items: ${allFixes.length} fixes, ${allReviews.length} reviews, ${allWorkItems.length} work-items`);
2952
+ }
2953
+
2954
+ return allWork.length;
2955
+ }
2956
+
2957
+ // ─── Main Tick ──────────────────────────────────────────────────────────────
2958
+
2959
+ let tickCount = 0;
2960
+
2961
+ let tickRunning = false;
2962
+
2963
+ async function tick() {
2964
+ if (tickRunning) return; // prevent overlapping ticks
2965
+ tickRunning = true;
2966
+ try {
2967
+ await tickInner();
2968
+ } catch (e) {
2969
+ log('error', `Tick error: ${e.message}`);
2970
+ } finally {
2971
+ tickRunning = false;
2972
+ }
2973
+ }
2974
+
2975
+ async function tickInner() {
2976
+ const control = getControl();
2977
+ if (control.state !== 'running') {
2978
+ log('info', `Engine state is "${control.state}" — exiting process`);
2979
+ process.exit(0);
2980
+ }
2981
+
2982
+ // Write heartbeat so dashboard can detect stale engine
2983
+ try { safeWrite(CONTROL_PATH, { ...control, heartbeat: Date.now() }); } catch {}
2984
+
2985
+ const config = getConfig();
2986
+ tickCount++;
2987
+
2988
+ // 1. Check for timed-out agents and idle threshold
2989
+ checkTimeouts(config);
2990
+ checkIdleThreshold(config);
2991
+
2992
+ // 2. Consolidate inbox
2993
+ consolidateInbox(config);
2994
+
2995
+ // 2.5. Periodic cleanup + MCP sync (every 10 ticks = ~5 minutes)
2996
+ if (tickCount % 10 === 0) {
2997
+ runCleanup(config);
2998
+ }
2999
+
3000
+ // 2.6. Poll PR status: build, review, merge (every 6 ticks = ~3 minutes)
3001
+ // Awaited so PR state is consistent before discoverWork reads it
3002
+ if (tickCount % 6 === 0) {
3003
+ try { await pollPrStatus(config); } catch (err) { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3004
+ try { await ghPollPrStatus(config); } catch (err) { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3005
+ // Sync PR status back to PRD items (missing → done when active PR exists)
3006
+ try { syncPrdFromPrs(config); } catch (err) { log('warn', `PRD sync error: ${err?.message || err}`); }
3007
+ // Check if any plans can be marked completed (all features done/in-pr)
3008
+ try {
3009
+ const prdFiles = safeReadDir(PRD_DIR).filter(f => f.endsWith('.json'));
3010
+ for (const file of prdFiles) {
3011
+ const plan = safeJson(path.join(PRD_DIR, file));
3012
+ if (plan && plan.missing_features && plan.status !== 'completed') {
3013
+ checkPlanCompletion({ item: { sourcePlan: file } }, config);
3014
+ }
3015
+ }
3016
+ } catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
3017
+ }
3018
+
3019
+ // 2.7. Poll PR threads for human comments (every 12 ticks = ~6 minutes)
3020
+ if (tickCount % 12 === 0) {
3021
+ try { await pollPrHumanComments(config); } catch (err) { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3022
+ try { await ghPollPrHumanComments(config); } catch (err) { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3023
+ try { await reconcilePrs(config); } catch (err) { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3024
+ try { await ghReconcilePrs(config); } catch (err) { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3025
+ }
3026
+
3027
+ // 2.9. Stalled dispatch detection — auto-retry failed items blocking the graph (every 20 ticks = ~10 min)
3028
+ if (tickCount % 20 === 0) {
3029
+ try {
3030
+ const projects = getProjects(config);
3031
+ const dispatch = getDispatch();
3032
+ const activeCount = (dispatch.active || []).length;
3033
+ const allAgentsIdle = Object.keys(config.agents || {}).every(id => {
3034
+ const s = getAgentStatus(id);
3035
+ return !s || s.status === 'idle';
3036
+ });
3037
+
3038
+ if (allAgentsIdle && activeCount === 0) {
3039
+ // Check for failed items blocking pending items
3040
+ for (const project of projects) {
3041
+ try {
3042
+ const wiPath = projectWorkItemsPath(project);
3043
+ const items = safeJson(wiPath) || [];
3044
+ let changed = false;
3045
+ const failedIds = new Set(items.filter(w => w.status === 'failed').map(w => w.id));
3046
+ const pendingWithBlockedDeps = items.filter(w =>
3047
+ w.status === 'pending' && (w.depends_on || []).some(d => failedIds.has(d))
3048
+ );
3049
+
3050
+ if (pendingWithBlockedDeps.length > 0) {
3051
+ // Auto-retry failed items that are blocking others (transient errors)
3052
+ for (const item of items) {
3053
+ if (item.status !== 'failed') continue;
3054
+ // Only retry if something depends on this item
3055
+ const isBlocking = items.some(w => w.status === 'pending' && (w.depends_on || []).includes(item.id));
3056
+ if (!isBlocking) continue;
3057
+
3058
+ log('info', `Stall recovery: auto-retrying ${item.id} (blocking ${pendingWithBlockedDeps.filter(w => (w.depends_on || []).includes(item.id)).length} items)`);
3059
+ item.status = 'pending';
3060
+ item._retryCount = 0;
3061
+ delete item.failReason;
3062
+ delete item.failedAt;
3063
+ delete item.dispatched_at;
3064
+ delete item.dispatched_to;
3065
+ changed = true;
3066
+
3067
+ // Clear completed dispatch entries so isAlreadyDispatched doesn't block re-dispatch
3068
+ try {
3069
+ const key = `work-${project.name}-${item.id}`;
3070
+ mutateDispatch((dp) => {
3071
+ const before = dp.completed.length;
3072
+ dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
3073
+ if (dp.completed.length !== before) return dp;
3074
+ });
3075
+ } catch {}
3076
+
3077
+ // Clear cooldown so item isn't blocked by exponential backoff
3078
+ try {
3079
+ const key = `work-${project.name}-${item.id}`;
3080
+ if (dispatchCooldowns.has(key)) {
3081
+ dispatchCooldowns.delete(key);
3082
+ saveCooldowns();
3083
+ }
3084
+ } catch {}
3085
+ }
3086
+ }
3087
+
3088
+ // Un-fail dependent items that were cascade-failed
3089
+ if (changed) {
3090
+ const retriedIds = new Set(items.filter(w => w.status === 'pending' && w._retryCount === 0).map(w => w.id));
3091
+ for (const dep of items) {
3092
+ if (dep.status === 'failed' && dep.failReason === 'Dependency failed — cannot proceed') {
3093
+ const blockers = (dep.depends_on || []).filter(d => retriedIds.has(d));
3094
+ if (blockers.length > 0) {
3095
+ log('info', `Stall recovery: un-failing ${dep.id} (blocker ${blockers.join(',')} retried)`);
3096
+ dep.status = 'pending';
3097
+ dep._retryCount = 0;
3098
+ delete dep.failReason;
3099
+ delete dep.failedAt;
3100
+ delete dep.dispatched_at;
3101
+ delete dep.dispatched_to;
3102
+ // Clear dispatch entries for this dependent too
3103
+ try {
3104
+ const key = `work-${project.name}-${dep.id}`;
3105
+ mutateDispatch((dp) => {
3106
+ dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
3107
+ });
3108
+ } catch {}
3109
+ }
3110
+ }
3111
+ }
3112
+ }
3113
+
3114
+ if (changed) safeWrite(wiPath, items);
3115
+ } catch {}
3116
+ }
3117
+ }
3118
+ } catch (err) { log('warn', `Stall detection error: ${err?.message || err}`); }
3119
+ }
3120
+
3121
+ // 3. Discover new work from sources
3122
+ discoverWork(config);
3123
+
3124
+ // 4. Update snapshot
3125
+ updateSnapshot(config);
3126
+
3127
+ // 5. Process pending dispatches — auto-spawn agents
3128
+ const dispatch = getDispatch();
3129
+ const activeCount = (dispatch.active || []).length;
3130
+ const maxConcurrent = config.engine?.maxConcurrent || 3;
3131
+
3132
+ if (activeCount >= maxConcurrent) {
3133
+ log('info', `At max concurrency (${activeCount}/${maxConcurrent}) — skipping dispatch`);
3134
+ return;
3135
+ }
3136
+
3137
+ const slotsAvailable = maxConcurrent - activeCount;
3138
+
3139
+ // Priority dispatch: fixes > reviews > plan-to-prd > implement > verify > other
3140
+ const typePriority = { 'implement:large': 0, implement: 0, fix: 1, ask: 1, review: 2, test: 3, verify: 3, plan: 4, 'plan-to-prd': 4 };
3141
+ const itemPriority = { high: 0, medium: 1, low: 2 };
3142
+ dispatch.pending.sort((a, b) => {
3143
+ const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
3144
+ if (ta !== tb) return ta - tb;
3145
+ const pa = itemPriority[a.meta?.item?.priority] ?? 1, pb = itemPriority[b.meta?.item?.priority] ?? 1;
3146
+ return pa - pb;
3147
+ });
3148
+ mutateDispatch((dp) => {
3149
+ dp.pending = dispatch.pending;
3150
+ dp.active = dispatch.active || dp.active;
3151
+ });
3152
+
3153
+ // Only dispatch to agents that aren't already busy (one task per agent at a time).
3154
+ // Build set of agents currently active.
3155
+ const busyAgents = new Set((dispatch.active || []).map(d => d.agent));
3156
+ const toDispatch = [];
3157
+ for (const item of dispatch.pending) {
3158
+ if (toDispatch.length >= slotsAvailable) break;
3159
+ if (busyAgents.has(item.agent)) continue; // agent already has an active task
3160
+ toDispatch.push(item);
3161
+ busyAgents.add(item.agent); // mark busy for this dispatch round too
3162
+ }
3163
+
3164
+ // Dispatch items — spawnAgent moves each from pending→active on disk.
3165
+ // We use the already-loaded item objects; spawnAgent handles the state transition.
3166
+ const dispatched = new Set();
3167
+ for (const item of toDispatch) {
3168
+ if (!dispatched.has(item.id)) {
3169
+ spawnAgent(item, config);
3170
+ dispatched.add(item.id);
3171
+ }
3172
+ }
3173
+ }
3174
+
3175
+ // ─── Exports (for engine/cli.js and other modules) ──────────────────────────
3176
+
3177
+ module.exports = {
3178
+ // Paths
3179
+ MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLAYBOOKS_DIR, PLANS_DIR, PRD_DIR,
3180
+ CONTROL_PATH, DISPATCH_PATH, LOG_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
3181
+ IDENTITY_DIR, CONFIG_PATH, ROUTING_PATH, NOTES_PATH, SKILLS_DIR,
3182
+
3183
+ // Utilities
3184
+ ts, logTs, dateStamp, log,
3185
+ safeJson, safeRead, safeWrite,
3186
+
3187
+ // State readers/writers
3188
+ getConfig, getControl, getDispatch, getRouting, getNotes,
3189
+ getAgentStatus, getAgentCharter, getInboxFiles, getPrs,
3190
+ validateConfig,
3191
+
3192
+ // Dispatch management
3193
+ addToDispatch, completeDispatch,
3194
+ activeProcesses, get engineRestartGraceUntil() { return engineRestartGraceUntil; },
3195
+ set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
3196
+
3197
+ // Agent lifecycle
3198
+ spawnAgent, resolveAgent,
3199
+
3200
+ // Discovery
3201
+ discoverWork, discoverFromPrs, discoverFromWorkItems,
3202
+ materializePlansAsWorkItems,
3203
+
3204
+ // Shared helpers (used by lifecycle.js and tests)
3205
+ reconcileItemsWithPrs, writeInboxAlert, detectDependencyCycles,
3206
+
3207
+ // Playbooks
3208
+ renderPlaybook,
3209
+
3210
+ // Post-completion / lifecycle
3211
+ updateWorkItemStatus, runCleanup, handlePostMerge,
3212
+
3213
+ // Cooldowns
3214
+ loadCooldowns,
3215
+
3216
+ // Tick
3217
+ tick,
3218
+ };
3219
+
3220
+ // ─── Entrypoint ─────────────────────────────────────────────────────────────
3221
+
3222
+ if (require.main === module) {
3223
+ const { handleCommand } = require('./engine/cli');
3224
+ const [cmd, ...args] = process.argv.slice(2);
3225
+ handleCommand(cmd, args);
3226
+ }
3227
+