@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/cli.js ADDED
@@ -0,0 +1,754 @@
1
+ /**
2
+ * engine/cli.js — CLI command handlers for Minions engine.
3
+ * Extracted from engine.js to reduce monolith size.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const shared = require('./shared');
9
+ const { safeRead, safeJson, safeWrite } = shared;
10
+ const queries = require('./queries');
11
+ const { getConfig, getControl, getDispatch, getAgentStatus,
12
+ MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
13
+
14
+ // Lazy require — only for engine-specific functions (log, ts, tick, addToDispatch, etc.)
15
+ let _engine = null;
16
+ function engine() {
17
+ if (!_engine) _engine = require('../engine');
18
+ return _engine;
19
+ }
20
+
21
+ function handleCommand(cmd, args) {
22
+ if (!cmd) {
23
+ commands.start();
24
+ } else if (commands[cmd]) {
25
+ commands[cmd](...args);
26
+ } else {
27
+ console.log(`Unknown command: ${cmd}`);
28
+ console.log('Commands:');
29
+ console.log(' start Start engine daemon');
30
+ console.log(' stop Stop engine');
31
+ console.log(' pause / resume Pause/resume dispatching');
32
+ console.log(' status Show engine + agent state');
33
+ console.log(' queue Show dispatch queue');
34
+ console.log(' sources Show work source status');
35
+ console.log(' discover Dry-run work discovery');
36
+ console.log(' dispatch Force a dispatch cycle');
37
+ console.log(' spawn <a> <p> Manually spawn agent with prompt');
38
+ console.log(' work <title> [o] Add to work-items.json queue');
39
+ console.log(' plan <src> [p] Generate PRD from a plan (file or text)');
40
+ console.log(' complete <id> Mark dispatch as done');
41
+ console.log(' cleanup Clean temp files, worktrees, zombies');
42
+ console.log(' mcp-sync Sync MCP servers from ~/.claude.json');
43
+ process.exit(1);
44
+ }
45
+ }
46
+
47
+ const commands = {
48
+ start() {
49
+ const e = engine();
50
+ const control = getControl();
51
+ if (control.state === 'running') {
52
+ let alive = false;
53
+ if (control.pid) {
54
+ try { process.kill(control.pid, 0); alive = true; } catch {}
55
+ }
56
+ if (alive) {
57
+ console.log(`Engine is already running (PID ${control.pid}).`);
58
+ return;
59
+ }
60
+ console.log(`Engine was running (PID ${control.pid}) but process is dead — restarting.`);
61
+ }
62
+
63
+ safeWrite(CONTROL_PATH, { state: 'running', pid: process.pid, started_at: e.ts() });
64
+ e.log('info', 'Engine started');
65
+ console.log(`Engine started (PID: ${process.pid})`);
66
+
67
+ const config = getConfig();
68
+ const interval = config.engine?.tickInterval || shared.ENGINE_DEFAULTS.tickInterval;
69
+
70
+ const { getProjects } = require('./shared');
71
+ const projects = getProjects(config);
72
+ for (const p of projects) {
73
+ const root = p.localPath ? path.resolve(p.localPath) : null;
74
+ if (!root || !fs.existsSync(root)) {
75
+ e.log('warn', `Project "${p.name}" path not found: ${p.localPath} — skipping`);
76
+ console.log(` WARNING: ${p.name} path not found: ${p.localPath}`);
77
+ } else {
78
+ console.log(` Project: ${p.name} (${root})`);
79
+ }
80
+ }
81
+
82
+ e.validateConfig(config);
83
+ e.loadCooldowns();
84
+
85
+ // Re-attach to surviving agent processes from previous session
86
+ const { exec } = require('./shared');
87
+ const dispatch = getDispatch();
88
+ const activeOnStart = (dispatch.active || []);
89
+ if (activeOnStart.length > 0) {
90
+ let reattached = 0;
91
+ for (const item of activeOnStart) {
92
+ const agentId = item.agent;
93
+ let agentPid = null;
94
+
95
+ const pidFile = path.join(ENGINE_DIR, `pid-${item.id}.pid`);
96
+ try {
97
+ const pidStr = fs.readFileSync(pidFile, 'utf8').trim();
98
+ if (pidStr) agentPid = parseInt(pidStr);
99
+ } catch {}
100
+
101
+ if (!agentPid) {
102
+ const status = getAgentStatus(agentId);
103
+ if (status.dispatch_id === item.id) {
104
+ const liveLog = path.join(AGENTS_DIR, agentId, 'live-output.log');
105
+ try {
106
+ const stat = fs.statSync(liveLog);
107
+ const ageMs = Date.now() - stat.mtimeMs;
108
+ if (ageMs < 300000) {
109
+ agentPid = -1;
110
+ }
111
+ } catch {}
112
+ }
113
+ }
114
+
115
+ if (agentPid && agentPid > 0) {
116
+ try {
117
+ if (process.platform === 'win32') {
118
+ const out = exec(`tasklist /FI "PID eq ${agentPid}" /NH`, { encoding: 'utf8', timeout: 3000 });
119
+ if (!out.includes(String(agentPid))) agentPid = null;
120
+ } else {
121
+ process.kill(agentPid, 0);
122
+ }
123
+ } catch { agentPid = null; }
124
+ }
125
+
126
+ if (agentPid) {
127
+ e.activeProcesses.set(item.id, { proc: { pid: agentPid > 0 ? agentPid : null }, agentId, startedAt: item.created_at, reattached: true });
128
+ // Sync work item status to dispatched — direct file write to avoid lifecycle lazy init issues
129
+ if (item.meta?.item?.id && item.meta?.project?.localPath) {
130
+ try {
131
+ const wiPath = path.join(MINIONS_DIR, "projects", item.meta.project.name, "work-items.json");
132
+ const wiItems = safeJson(wiPath) || [];
133
+ const wi = wiItems.find(w => w.id === item.meta.item.id);
134
+ if (wi && wi.status !== 'dispatched') {
135
+ wi.status = 'dispatched';
136
+ wi.dispatched_to = wi.dispatched_to || agentId;
137
+ wi.dispatched_at = wi.dispatched_at || new Date().toISOString();
138
+ safeWrite(wiPath, wiItems);
139
+ }
140
+ } catch (err) { console.log(` Warning: failed to sync work item status: ${err.message}`); }
141
+ }
142
+ reattached++;
143
+ e.log('info', `Re-attached to ${agentId} (${item.id}) — PID ${agentPid > 0 ? agentPid : 'unknown (active output)'}`);
144
+ }
145
+ }
146
+
147
+ const unattached = activeOnStart.length - reattached;
148
+ if (unattached > 0) {
149
+ const gracePeriod = config.engine?.restartGracePeriod || shared.ENGINE_DEFAULTS.restartGracePeriod;
150
+ e.engineRestartGraceUntil = Date.now() + gracePeriod;
151
+ console.log(` ${unattached} unattached dispatch(es) — ${gracePeriod / 60000}min grace period`);
152
+ }
153
+ if (reattached > 0) {
154
+ console.log(` Re-attached to ${reattached} surviving agent(s)`);
155
+ }
156
+ for (const item of activeOnStart) {
157
+ const attached = e.activeProcesses.has(item.id);
158
+ console.log(` ${attached ? '\u2713' : '?'} ${item.agentName || item.agent}: ${(item.task || '').slice(0, 70)}`);
159
+ }
160
+ }
161
+
162
+ // Orphan completion detection: for dispatch entries that couldn't re-attach,
163
+ // check if the agent actually completed by scanning its output file.
164
+ // If it did, run the post-completion hooks now so work items get updated.
165
+ (function detectOrphanCompletions() {
166
+ const shared = require('./shared');
167
+ const lifecycle = require('./lifecycle');
168
+ let recovered = 0;
169
+
170
+ for (const item of activeOnStart) {
171
+ if (e.activeProcesses.has(item.id)) continue; // re-attached, skip
172
+
173
+ const agentId = item.agent;
174
+ const outputPath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
175
+ try {
176
+ const stat = fs.statSync(outputPath);
177
+ const output = fs.readFileSync(outputPath, 'utf8');
178
+
179
+ // Check for completion markers in output
180
+ const hasResult = output.includes('"type":"result"') || output.includes('"type": "result"');
181
+ const hasError = output.includes('"is_error":true') || output.includes('"is_error": true');
182
+ if (!hasResult && !hasError) continue;
183
+
184
+ const isSuccess = hasResult && !hasError;
185
+ const result = isSuccess ? 'success' : 'error';
186
+
187
+ e.log('info', `Orphan recovery: ${agentId} (${item.id}) completed while engine was down — result: ${result}`);
188
+
189
+ // Extract PRs from output
190
+ let prsCreated = 0;
191
+ try {
192
+ prsCreated = lifecycle.syncPrsFromOutput(output, agentId, item.meta, config);
193
+ } catch {}
194
+
195
+ // Update work item status
196
+ if (item.meta?.item?.id) {
197
+ const status = isSuccess ? 'done' : 'failed';
198
+ try {
199
+ lifecycle.updateWorkItemStatus(item.meta, status, isSuccess ? '' : 'Completed while engine was down');
200
+ } catch {
201
+ // Direct file write fallback
202
+ try {
203
+ const projName = item.meta.project?.name;
204
+ if (projName) {
205
+ const wiPath = path.join(MINIONS_DIR, 'projects', projName, 'work-items.json');
206
+ const items = safeJson(wiPath) || [];
207
+ const wi = items.find(w => w.id === item.meta.item.id);
208
+ if (wi) {
209
+ wi.status = status;
210
+ if (isSuccess) { wi.completedAt = new Date().toISOString(); delete wi.failReason; }
211
+ else { wi.failedAt = new Date().toISOString(); wi.failReason = 'Completed while engine was down'; }
212
+ safeWrite(wiPath, items);
213
+ }
214
+ }
215
+ } catch {}
216
+ }
217
+ }
218
+
219
+ // Move from active to completed in dispatch
220
+ try {
221
+ e.completeDispatch(
222
+ item.id,
223
+ result,
224
+ isSuccess ? 'Completed (orphan recovery)' : 'Failed (orphan recovery)',
225
+ '',
226
+ { processWorkItemFailure: false }
227
+ );
228
+ } catch {}
229
+
230
+ // Check plan completion
231
+ if (isSuccess && item.meta?.item?.sourcePlan) {
232
+ try { lifecycle.checkPlanCompletion(item.meta, config); } catch {}
233
+ }
234
+
235
+ recovered++;
236
+ console.log(` ✓ Recovered ${agentId}: ${(item.task || '').slice(0, 60)} → ${result}${prsCreated ? ' (' + prsCreated + ' PR)' : ''}`);
237
+ } catch {}
238
+ }
239
+ if (recovered > 0) {
240
+ e.log('info', `Orphan recovery: processed ${recovered} completion(s) from previous session`);
241
+ console.log(` Recovered ${recovered} orphaned completion(s)`);
242
+ }
243
+ })();
244
+
245
+ // Recovery sweep
246
+ (function recoverBrokenState() {
247
+ const shared = require('./shared');
248
+ const projects = shared.getProjects(config);
249
+ let fixes = 0;
250
+
251
+ const activeIds = new Set((dispatch.active || []).map(d => d.meta?.item?.id).filter(Boolean));
252
+ const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
253
+ for (const p of projects) {
254
+ allWiPaths.push(path.join(MINIONS_DIR, "projects", p.name, "work-items.json"));
255
+ }
256
+ for (const wiPath of allWiPaths) {
257
+ try {
258
+ const items = safeJson(wiPath) || [];
259
+ let changed = false;
260
+ for (const item of items) {
261
+ if (item.status === 'dispatched' && !activeIds.has(item.id)) {
262
+ item.status = 'pending';
263
+ delete item.dispatched_at;
264
+ delete item.dispatched_to;
265
+ changed = true;
266
+ fixes++;
267
+ e.log('info', `Recovery: reset stuck item ${item.id} from dispatched → pending`);
268
+ }
269
+ }
270
+ if (changed) safeWrite(wiPath, items);
271
+ } catch {}
272
+ }
273
+
274
+ // Plan chain recovery removed — plans require explicit user execution via dashboard
275
+
276
+ if (fixes > 0) {
277
+ console.log(` Recovery: fixed ${fixes} broken state issue(s)`);
278
+ }
279
+ })();
280
+
281
+ // Initial tick
282
+ e.tick();
283
+
284
+ // Start tick loop
285
+ setInterval(() => e.tick(), interval);
286
+ console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 3}`);
287
+ console.log('Press Ctrl+C to stop');
288
+ },
289
+
290
+ stop() {
291
+ const e = engine();
292
+ const dispatch = getDispatch();
293
+ const active = (dispatch.active || []);
294
+ if (active.length > 0) {
295
+ console.log(`\n WARNING: ${active.length} agent(s) are still working:`);
296
+ for (const item of active) {
297
+ console.log(` - ${item.agentName || item.agent}: ${(item.task || '').slice(0, 80)}`);
298
+ }
299
+ console.log('\n These agents will continue running but the engine won\'t monitor them.');
300
+ console.log(' On next start, they\'ll get a 20-min grace period before being marked as orphans.');
301
+ console.log(' To kill them now, run: node engine.js kill\n');
302
+ }
303
+ const control = getControl();
304
+ if (control.pid && control.pid !== process.pid) {
305
+ try { process.kill(control.pid); } catch {}
306
+ }
307
+ safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
308
+ e.log('info', 'Engine stopped');
309
+ console.log('Engine stopped.');
310
+ },
311
+
312
+ pause() {
313
+ const e = engine();
314
+ safeWrite(CONTROL_PATH, { state: 'paused', paused_at: e.ts() });
315
+ e.log('info', 'Engine paused');
316
+ console.log('Engine paused. Run `node .minions/engine.js resume` to resume.');
317
+ },
318
+
319
+ resume() {
320
+ const e = engine();
321
+ const control = getControl();
322
+ if (control.state === 'running') {
323
+ console.log('Engine is already running.');
324
+ return;
325
+ }
326
+ safeWrite(CONTROL_PATH, { state: 'running', resumed_at: e.ts() });
327
+ e.log('info', 'Engine resumed');
328
+ console.log('Engine resumed.');
329
+ },
330
+
331
+ status() {
332
+ const e = engine();
333
+ const config = getConfig();
334
+ const control = getControl();
335
+ const dispatch = getDispatch();
336
+ const agents = config.agents || {};
337
+
338
+ const { getProjects } = require('./shared');
339
+ const projects = getProjects(config);
340
+
341
+ console.log('\n=== Minions Engine ===\n');
342
+ console.log(`State: ${control.state}`);
343
+ console.log(`PID: ${control.pid || 'N/A'}`);
344
+ console.log(`Projects: ${projects.map(p => p.name || 'unnamed').join(', ')}`);
345
+ console.log('');
346
+
347
+ console.log('Agents:');
348
+ console.log(` ${'ID'.padEnd(12)} ${'Name (Role)'.padEnd(30)} ${'Status'.padEnd(10)} Task`);
349
+ console.log(' ' + '-'.repeat(70));
350
+ for (const [id, agent] of Object.entries(agents)) {
351
+ const status = getAgentStatus(id);
352
+ console.log(` ${id.padEnd(12)} ${`${agent.emoji} ${agent.name} (${agent.role})`.padEnd(30)} ${(status.status || 'idle').padEnd(10)} ${status.task || '-'}`);
353
+ }
354
+
355
+ console.log('');
356
+ console.log(`Dispatch: ${dispatch.pending.length} pending | ${(dispatch.active || []).length} active | ${(dispatch.completed || []).length} completed`);
357
+ console.log(`Active processes: ${e.activeProcesses.size}`);
358
+
359
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
360
+ const metrics = safeJson(metricsPath);
361
+ if (metrics && Object.keys(metrics).length > 0) {
362
+ console.log('\nMetrics:');
363
+ console.log(` ${'Agent'.padEnd(12)} ${'Done'.padEnd(6)} ${'Err'.padEnd(6)} ${'PRs'.padEnd(6)} ${'Appr'.padEnd(6)} ${'Rej'.padEnd(6)} ${'Reviews'.padEnd(8)} ${'Cost'.padEnd(8)}`);
364
+ console.log(' ' + '-'.repeat(64));
365
+ for (const [id, m] of Object.entries(metrics)) {
366
+ if (id.startsWith('_')) continue;
367
+ const cost = m.totalCostUsd ? '$' + m.totalCostUsd.toFixed(1) : '-';
368
+ console.log(` ${id.padEnd(12)} ${String(m.tasksCompleted || 0).padEnd(6)} ${String(m.tasksErrored || 0).padEnd(6)} ${String(m.prsCreated || 0).padEnd(6)} ${String(m.prsApproved || 0).padEnd(6)} ${String(m.prsRejected || 0).padEnd(6)} ${String(m.reviewsDone || 0).padEnd(8)} ${cost.padEnd(8)}`);
369
+ }
370
+ }
371
+ console.log('');
372
+ },
373
+
374
+ queue() {
375
+ const e = engine();
376
+ const dispatch = getDispatch();
377
+
378
+ console.log('\n=== Dispatch Queue ===\n');
379
+
380
+ if (dispatch.pending.length) {
381
+ console.log('PENDING:');
382
+ for (const d of dispatch.pending) {
383
+ console.log(` [${d.id}] ${d.type} → ${d.agent}: ${d.task}`);
384
+ }
385
+ } else {
386
+ console.log('No pending dispatches.');
387
+ }
388
+
389
+ if ((dispatch.active || []).length) {
390
+ console.log('\nACTIVE:');
391
+ for (const d of dispatch.active) {
392
+ console.log(` [${d.id}] ${d.type} → ${d.agent}: ${d.task} (since ${d.started_at})`);
393
+ }
394
+ }
395
+
396
+ if ((dispatch.completed || []).length) {
397
+ console.log(`\nCOMPLETED (last 5):`);
398
+ for (const d of dispatch.completed.slice(-5)) {
399
+ console.log(` [${d.id}] ${d.type} → ${d.agent}: ${d.result} (${d.completed_at})`);
400
+ }
401
+ }
402
+ console.log('');
403
+ },
404
+
405
+ complete(id) {
406
+ if (!id) {
407
+ console.log('Usage: node .minions/engine.js complete <dispatch-id>');
408
+ return;
409
+ }
410
+ engine().completeDispatch(id, 'success');
411
+ console.log(`Marked ${id} as completed.`);
412
+ },
413
+
414
+ dispatch() {
415
+ const e = engine();
416
+ console.log('Forcing dispatch cycle...');
417
+ const control = getControl();
418
+ const prevState = control.state;
419
+ safeWrite(CONTROL_PATH, { ...control, state: 'running' });
420
+ e.tick();
421
+ if (prevState !== 'running') {
422
+ safeWrite(CONTROL_PATH, { ...control, state: prevState });
423
+ }
424
+ console.log('Dispatch cycle complete.');
425
+ },
426
+
427
+ spawn(agentId, ...promptParts) {
428
+ const e = engine();
429
+ const prompt = promptParts.join(' ');
430
+ if (!agentId || !prompt) {
431
+ console.log('Usage: node .minions/engine.js spawn <agent-id> "<prompt>"');
432
+ return;
433
+ }
434
+
435
+ const config = getConfig();
436
+ if (!config.agents[agentId]) {
437
+ console.log(`Unknown agent: ${agentId}. Available: ${Object.keys(config.agents).join(', ')}`);
438
+ return;
439
+ }
440
+
441
+ const id = e.addToDispatch({
442
+ type: 'manual',
443
+ agent: agentId,
444
+ agentName: config.agents[agentId].name,
445
+ agentRole: config.agents[agentId].role,
446
+ task: prompt.substring(0, 100),
447
+ prompt: prompt,
448
+ meta: {}
449
+ });
450
+
451
+ const dispatch = getDispatch();
452
+ const item = dispatch.pending.find(d => d.id === id);
453
+ if (item) {
454
+ e.spawnAgent(item, config);
455
+ }
456
+ },
457
+
458
+ work(title, ...rest) {
459
+ const e = engine();
460
+ if (!title) {
461
+ console.log('Usage: node .minions/engine.js work "<title>" [options-json]');
462
+ console.log('Options: {"type":"implement","priority":"high","agent":"dallas","description":"...","branch":"feature/..."}');
463
+ return;
464
+ }
465
+
466
+ let opts = {};
467
+ const optStr = rest.join(' ');
468
+ if (optStr) {
469
+ try { opts = JSON.parse(optStr); } catch {
470
+ console.log('Warning: Could not parse options JSON, using defaults');
471
+ }
472
+ }
473
+
474
+ const config = getConfig();
475
+ const { getProjects, projectWorkItemsPath } = require('./shared');
476
+ const projects = getProjects(config);
477
+ const targetProject = opts.project
478
+ ? projects.find(p => p.name?.toLowerCase() === opts.project?.toLowerCase()) || projects[0]
479
+ : projects[0];
480
+ const wiPath = projectWorkItemsPath(targetProject);
481
+ const items = safeJson(wiPath) || [];
482
+
483
+ const item = {
484
+ id: `W${String(items.length + 1).padStart(3, '0')}`,
485
+ title: title,
486
+ type: opts.type || 'implement',
487
+ status: 'queued',
488
+ priority: opts.priority || 'medium',
489
+ complexity: opts.complexity || 'medium',
490
+ description: opts.description || title,
491
+ agent: opts.agent || null,
492
+ branch: opts.branch || null,
493
+ prompt: opts.prompt || null,
494
+ created_at: e.ts()
495
+ };
496
+
497
+ items.push(item);
498
+ safeWrite(wiPath, items);
499
+
500
+ console.log(`Queued work item: ${item.id} — ${item.title} (project: ${targetProject.name || 'default'})`);
501
+ console.log(` Type: ${item.type} | Priority: ${item.priority} | Agent: ${item.agent || 'auto'}`);
502
+ },
503
+
504
+ plan(source, projectName) {
505
+ const e = engine();
506
+ if (!source) {
507
+ console.log('Usage: node .minions/engine.js plan <source> [project]');
508
+ console.log('');
509
+ console.log('Source can be:');
510
+ console.log(' - A file path (markdown, txt, or json)');
511
+ console.log(' - Inline text wrapped in quotes');
512
+ console.log('');
513
+ console.log('Examples:');
514
+ console.log(' node engine.js plan ./my-plan.md');
515
+ console.log(' node engine.js plan ./my-plan.md MyProject');
516
+ console.log(' node engine.js plan "Add auth middleware with JWT tokens and role-based access"');
517
+ return;
518
+ }
519
+
520
+ const config = getConfig();
521
+ const { getProjects } = require('./shared');
522
+ const projects = getProjects(config);
523
+ const targetProject = projectName
524
+ ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || projects[0]
525
+ : projects[0];
526
+
527
+ if (!targetProject) {
528
+ console.log('No projects configured. Run: node minions.js add <dir>');
529
+ return;
530
+ }
531
+
532
+ let planContent;
533
+ let planSummary;
534
+ const sourcePath = path.resolve(source);
535
+ if (fs.existsSync(sourcePath)) {
536
+ planContent = fs.readFileSync(sourcePath, 'utf8');
537
+ planSummary = path.basename(sourcePath, path.extname(sourcePath));
538
+ console.log(`Reading plan from: ${sourcePath}`);
539
+ } else {
540
+ planContent = source;
541
+ planSummary = source.substring(0, 60).replace(/[^a-zA-Z0-9 -]/g, '').trim();
542
+ console.log('Using inline plan text.');
543
+ }
544
+
545
+ console.log(`Target project: ${targetProject.name}`);
546
+ console.log(`Plan summary: ${planSummary}`);
547
+ console.log('');
548
+
549
+ const agentId = e.resolveAgent('analyze', config) || e.resolveAgent('explore', config);
550
+ if (!agentId) {
551
+ console.log('No agents available. All agents are busy.');
552
+ return;
553
+ }
554
+
555
+ const vars = {
556
+ agent_id: agentId,
557
+ agent_name: config.agents[agentId]?.name,
558
+ agent_role: config.agents[agentId]?.role,
559
+ project_name: targetProject.name || 'Unknown',
560
+ project_path: targetProject.localPath || '',
561
+ main_branch: targetProject.mainBranch || 'main',
562
+ ado_org: targetProject.adoOrg || 'Unknown',
563
+ ado_project: targetProject.adoProject || 'Unknown',
564
+ repo_name: targetProject.repoName || 'Unknown',
565
+ team_root: MINIONS_DIR,
566
+ date: e.dateStamp(),
567
+ plan_content: planContent,
568
+ plan_summary: planSummary,
569
+ project_name_lower: (targetProject.name || 'project').toLowerCase()
570
+ };
571
+
572
+ if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
573
+
574
+ const prompt = e.renderPlaybook('plan-to-prd', vars);
575
+ if (!prompt) {
576
+ console.log('Error: Could not render plan-to-prd playbook.');
577
+ return;
578
+ }
579
+
580
+ const id = e.addToDispatch({
581
+ type: 'plan-to-prd',
582
+ agent: agentId,
583
+ agentName: config.agents[agentId]?.name,
584
+ agentRole: config.agents[agentId]?.role,
585
+ task: `[${targetProject.name}] Generate PRD from plan: ${planSummary}`,
586
+ prompt,
587
+ meta: {
588
+ source: 'plan',
589
+ project: { name: targetProject.name, localPath: targetProject.localPath },
590
+ planSummary
591
+ }
592
+ });
593
+
594
+ console.log(`Dispatched: ${id} → ${config.agents[agentId]?.name} (${agentId})`);
595
+ console.log('The agent will analyze your plan and generate a PRD in prd/.');
596
+
597
+ const control = getControl();
598
+ if (control.state === 'running') {
599
+ const dispatch = getDispatch();
600
+ const item = dispatch.pending.find(d => d.id === id);
601
+ if (item) {
602
+ e.spawnAgent(item, config);
603
+ console.log('Agent spawned immediately.');
604
+ }
605
+ } else {
606
+ console.log('Engine is not running — dispatch will happen on next tick after start.');
607
+ }
608
+ },
609
+
610
+ sources() {
611
+ const e = engine();
612
+ const config = getConfig();
613
+ const shared = require('./shared');
614
+ const projects = shared.getProjects(config);
615
+
616
+ console.log('\n=== Work Sources ===\n');
617
+
618
+ for (const project of projects) {
619
+ const root = shared.projectRoot(project);
620
+ console.log(`── ${project.name || 'Project'} (${root}) ──\n`);
621
+
622
+ const sources = project.workSources || config.workSources || {};
623
+ for (const [name, src] of Object.entries(sources)) {
624
+ const status = src.enabled ? 'ENABLED' : 'DISABLED';
625
+ console.log(` ${name}: ${status}`);
626
+
627
+ let filePath = null;
628
+ if (name === 'workItems') filePath = shared.projectWorkItemsPath(project);
629
+ else if (name === 'pullRequests') filePath = shared.projectPrPath(project);
630
+ else if (src.path) filePath = path.resolve(root, src.path);
631
+ const exists = filePath && fs.existsSync(filePath);
632
+ if (filePath) {
633
+ console.log(` Path: ${filePath} ${exists ? '(found)' : '(NOT FOUND)'}`);
634
+ }
635
+ console.log(` Cooldown: ${src.cooldownMinutes || 0}m`);
636
+
637
+ if (exists && name === 'prd') {
638
+ const prd = safeJson(filePath);
639
+ if (prd) {
640
+ const missing = (prd.missing_features || []).filter(f => ['missing', 'planned'].includes(f.status));
641
+ console.log(` Items: ${missing.length} missing/planned features`);
642
+ }
643
+ }
644
+ if (exists && name === 'pullRequests') {
645
+ const prs = safeJson(filePath) || [];
646
+ const pending = prs.filter(p => p.status === 'active' && (p.reviewStatus === 'pending' || p.reviewStatus === 'waiting'));
647
+ const needsFix = prs.filter(p => p.status === 'active' && p.reviewStatus === 'changes-requested');
648
+ console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
649
+ }
650
+ if (exists && name === 'workItems') {
651
+ const items = safeJson(filePath) || [];
652
+ const queued = items.filter(i => i.status === 'queued');
653
+ console.log(` Items: ${queued.length} queued`);
654
+ }
655
+ if (name === 'specs' || name === 'mergedDesignDocs') {
656
+ const trackerFile = path.join(shared.projectStateDir(project), 'spec-tracker.json');
657
+ const tracker = safeJson(trackerFile) || { processedPrs: {} };
658
+ const processed = Object.keys(tracker.processedPrs).length;
659
+ const matched = Object.values(tracker.processedPrs).filter(p => p.matched).length;
660
+ console.log(` Processed: ${processed} merged PRs (${matched} had specs)`);
661
+ }
662
+ console.log('');
663
+ }
664
+ }
665
+ },
666
+
667
+ kill() {
668
+ const e = engine();
669
+ console.log('\n=== Kill All Active Work ===\n');
670
+ const config = getConfig();
671
+ const dispatch = getDispatch();
672
+ const shared = require('./shared');
673
+
674
+ const pidFiles = fs.readdirSync(ENGINE_DIR).filter(f => f.startsWith('pid-'));
675
+ for (const f of pidFiles) {
676
+ const pid = safeRead(path.join(ENGINE_DIR, f)).trim();
677
+ try { process.kill(Number(pid)); console.log(`Killed process ${pid} (${f})`); } catch { console.log(`Process ${pid} already dead`); }
678
+ fs.unlinkSync(path.join(ENGINE_DIR, f));
679
+ }
680
+
681
+ const killed = dispatch.active || [];
682
+ for (const item of killed) {
683
+ if (item.meta) {
684
+ e.updateWorkItemStatus(item.meta, 'pending', '');
685
+ const itemId = item.meta.item?.id;
686
+ if (itemId) {
687
+ const wiPath = (item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout')
688
+ ? path.join(MINIONS_DIR, 'work-items.json')
689
+ : item.meta.project?.localPath
690
+ ? shared.projectWorkItemsPath({ localPath: item.meta.project.localPath, name: item.meta.project.name, workSources: config.projects?.find(p => p.name === item.meta.project.name)?.workSources })
691
+ : null;
692
+ if (wiPath) {
693
+ const items = safeJson(wiPath) || [];
694
+ const target = items.find(i => i.id === itemId);
695
+ if (target) {
696
+ target.status = 'pending';
697
+ delete target.dispatched_at;
698
+ delete target.dispatched_to;
699
+ delete target.failReason;
700
+ delete target.failedAt;
701
+ safeWrite(wiPath, items);
702
+ }
703
+ }
704
+ }
705
+ }
706
+
707
+ console.log(`Killed dispatch: ${item.id} (${item.agent}) — work item reset to pending`);
708
+ }
709
+ dispatch.active = [];
710
+ safeWrite(DISPATCH_PATH, dispatch);
711
+
712
+ // Agent status derived from dispatch.json — clearing dispatch.active is sufficient.
713
+ console.log('All agents reset to idle (dispatch cleared)');
714
+
715
+ console.log(`\nDone: ${killed.length} dispatches killed, agents reset.`);
716
+ },
717
+
718
+ cleanup() {
719
+ const e = engine();
720
+ const config = getConfig();
721
+ console.log('\n=== Cleanup ===\n');
722
+ const result = e.runCleanup(config, true);
723
+ console.log(`\nDone: ${result.tempFiles} temp files, ${result.liveOutputs} live outputs, ${result.worktrees} worktrees, ${result.zombies} zombies cleaned.`);
724
+ },
725
+
726
+ 'mcp-sync'() {
727
+ console.log('MCP servers are read directly from ~/.claude.json — no sync needed.');
728
+ },
729
+
730
+ discover() {
731
+ const e = engine();
732
+ const config = getConfig();
733
+ console.log('\n=== Work Discovery (dry run) ===\n');
734
+
735
+ e.materializePlansAsWorkItems(config);
736
+ const prWork = e.discoverFromPrs(config);
737
+ const workItemWork = e.discoverFromWorkItems(config);
738
+
739
+ const all = [...prWork, ...workItemWork];
740
+
741
+ if (all.length === 0) {
742
+ console.log('No new work discovered from any source.');
743
+ } else {
744
+ console.log(`Found ${all.length} items:\n`);
745
+ for (const w of all) {
746
+ console.log(` [${w.meta?.source}] ${w.type} → ${w.agent}: ${w.task}`);
747
+ }
748
+ }
749
+ console.log('');
750
+ }
751
+ };
752
+
753
+ module.exports = { handleCommand };
754
+