@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
@@ -0,0 +1,677 @@
1
+ /**
2
+ * engine/queries.js — Shared read-only state queries for Minions engine + dashboard.
3
+ * Single source of truth for all data reading/aggregation.
4
+ * Both engine.js and dashboard.js require() this module.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const shared = require('./shared');
10
+
11
+ const { safeRead, safeReadDir, safeJson, safeWrite, getProjects,
12
+ projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES } = shared;
13
+
14
+ // ── Paths ───────────────────────────────────────────────────────────────────
15
+
16
+ const MINIONS_DIR = shared.MINIONS_DIR;
17
+ const AGENTS_DIR = path.join(MINIONS_DIR, 'agents');
18
+ const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
19
+ const INBOX_DIR = path.join(MINIONS_DIR, 'notes', 'inbox');
20
+ const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
21
+ const PRD_DIR = path.join(MINIONS_DIR, 'prd');
22
+ const SKILLS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'skills');
23
+ const KNOWLEDGE_DIR = path.join(MINIONS_DIR, 'knowledge');
24
+ const ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
25
+
26
+ const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
27
+ const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
28
+ const DISPATCH_PATH = path.join(ENGINE_DIR, 'dispatch.json');
29
+ const LOG_PATH = path.join(ENGINE_DIR, 'log.json');
30
+ const NOTES_PATH = path.join(MINIONS_DIR, 'notes.md');
31
+
32
+ // ── Helpers ─────────────────────────────────────────────────────────────────
33
+
34
+ function timeSince(ms) {
35
+ const s = Math.floor((Date.now() - ms) / 1000);
36
+ if (s < 60) return `${s}s ago`;
37
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
38
+ return `${Math.floor(s / 3600)}h ago`;
39
+ }
40
+
41
+ // ── Core State Readers ──────────────────────────────────────────────────────
42
+
43
+ function getConfig() {
44
+ return safeJson(CONFIG_PATH) || {};
45
+ }
46
+
47
+ function getControl() {
48
+ return safeJson(CONTROL_PATH) || { state: 'stopped', pid: null };
49
+ }
50
+
51
+ function getDispatch() {
52
+ return safeJson(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
53
+ }
54
+
55
+ function getDispatchQueue() {
56
+ const d = getDispatch();
57
+ d.completed = (d.completed || []).slice(-20);
58
+ return d;
59
+ }
60
+
61
+ function getNotes() {
62
+ return safeRead(NOTES_PATH);
63
+ }
64
+
65
+ function getNotesWithMeta() {
66
+ const content = safeRead(NOTES_PATH) || '';
67
+ try {
68
+ const stat = fs.statSync(NOTES_PATH);
69
+ return { content, updatedAt: stat.mtimeMs };
70
+ } catch { return { content, updatedAt: null }; }
71
+ }
72
+
73
+ function getEngineLog() {
74
+ const logJson = safeRead(LOG_PATH);
75
+ if (!logJson) return [];
76
+ try {
77
+ const entries = JSON.parse(logJson);
78
+ const arr = Array.isArray(entries) ? entries : (entries.entries || []);
79
+ return arr.slice(-50);
80
+ } catch { return []; }
81
+ }
82
+
83
+ function getMetrics() {
84
+ return safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
85
+ }
86
+
87
+ // ── Inbox ───────────────────────────────────────────────────────────────────
88
+
89
+ function getInboxFiles() {
90
+ try { return fs.readdirSync(INBOX_DIR).filter(f => f.endsWith('.md')); } catch { return []; }
91
+ }
92
+
93
+ function getInbox() {
94
+ return safeReadDir(INBOX_DIR)
95
+ .filter(f => f.endsWith('.md'))
96
+ .map(f => {
97
+ const fullPath = path.join(INBOX_DIR, f);
98
+ try {
99
+ const stat = fs.statSync(fullPath);
100
+ const content = safeRead(fullPath) || '';
101
+ return { name: f, age: timeSince(stat.mtimeMs), mtime: stat.mtimeMs, content };
102
+ } catch { return null; }
103
+ })
104
+ .filter(Boolean)
105
+ .sort((a, b) => b.mtime - a.mtime);
106
+ }
107
+
108
+ // ── Agents ──────────────────────────────────────────────────────────────────
109
+
110
+ // Agent status is DERIVED from dispatch.json — single source of truth.
111
+ // dispatch.active entry for this agent → working
112
+ // dispatch.completed (most recent) → done/error
113
+ // neither → idle
114
+ // Metadata (resultSummary, verdict, pr) is carried on dispatch entries.
115
+ function getAgentStatus(agentId) {
116
+ const dispatch = getDispatch();
117
+
118
+ // Check active dispatch
119
+ const active = (dispatch.active || []).find(d => d.agent === agentId);
120
+ if (active) {
121
+ return {
122
+ status: 'working',
123
+ task: active.task || '',
124
+ dispatch_id: active.id,
125
+ type: active.type || '',
126
+ branch: active.meta?.branch || '',
127
+ started_at: active.started_at || active.created_at || null,
128
+ };
129
+ }
130
+
131
+ // Check most recent completed dispatch (within last 5 minutes → show done/error)
132
+ const completed = (dispatch.completed || [])
133
+ .filter(d => d.agent === agentId)
134
+ .sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''));
135
+ if (completed.length > 0) {
136
+ const latest = completed[0];
137
+ const ageMs = latest.completed_at ? Date.now() - new Date(latest.completed_at).getTime() : Infinity;
138
+ if (ageMs < 300000) { // 5 minutes
139
+ return {
140
+ status: latest.result === 'error' ? 'error' : 'done',
141
+ task: latest.task || '',
142
+ dispatch_id: latest.id,
143
+ type: latest.type || '',
144
+ completed_at: latest.completed_at,
145
+ resultSummary: latest.resultSummary || latest.reason || '',
146
+ };
147
+ }
148
+ }
149
+
150
+ // Fallback: derive active state from work-item markers.
151
+ // This protects UI status when dispatch.json briefly desyncs from work-item files.
152
+ try {
153
+ const config = getConfig();
154
+ const allItems = getWorkItems(config);
155
+ const latestInFlight = allItems
156
+ .filter(w =>
157
+ (w.dispatched_to || '').toLowerCase() === String(agentId).toLowerCase() &&
158
+ (w.status === 'dispatched' || w.status === 'in-progress')
159
+ )
160
+ .sort((a, b) => (b.dispatched_at || '').localeCompare(a.dispatched_at || ''))[0];
161
+ if (latestInFlight) {
162
+ return {
163
+ status: 'working',
164
+ task: latestInFlight.title || latestInFlight.id || '',
165
+ dispatch_id: null,
166
+ type: latestInFlight.type || '',
167
+ branch: latestInFlight.branch || '',
168
+ started_at: latestInFlight.dispatched_at || latestInFlight.created || null,
169
+ };
170
+ }
171
+ } catch {}
172
+
173
+ return { status: 'idle', task: null, started_at: null, completed_at: null };
174
+ }
175
+
176
+ // setAgentStatus removed — agent status is derived from dispatch.json.
177
+ // Status.json files no longer exist.
178
+
179
+ function getAgentCharter(agentId) {
180
+ return safeRead(path.join(AGENTS_DIR, agentId, 'charter.md'));
181
+ }
182
+
183
+ function getAgents(config) {
184
+ config = config || getConfig();
185
+ // Fall back to DEFAULT_AGENTS if config has no agents (uninitialized repo)
186
+ const agents = (config.agents && Object.keys(config.agents).length > 0)
187
+ ? config.agents
188
+ : shared.DEFAULT_AGENTS;
189
+ const roster = Object.entries(agents).map(([id, info]) => ({ id, ...info }));
190
+ const allInboxFiles = safeReadDir(INBOX_DIR);
191
+
192
+ return roster.map(a => {
193
+ const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
194
+ const s = getAgentStatus(a.id); // derives from dispatch.json
195
+
196
+ let lastAction = 'Waiting for assignment';
197
+ if (s.status === 'working') lastAction = `Working: ${s.task}`;
198
+ else if (s.status === 'done') lastAction = `Done: ${s.task}`;
199
+ else if (s.status === 'error') lastAction = `Error: ${s.task}`;
200
+ else if (inboxFiles.length > 0) {
201
+ const lastOutput = path.join(INBOX_DIR, inboxFiles[inboxFiles.length - 1]);
202
+ try { lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(fs.statSync(lastOutput).mtimeMs)})`; } catch {}
203
+ }
204
+
205
+ const chartered = fs.existsSync(path.join(AGENTS_DIR, a.id, 'charter.md'));
206
+ if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
207
+ return {
208
+ ...a, status: s.status, lastAction,
209
+ currentTask: (s.task || '').slice(0, 200),
210
+ resultSummary: (s.resultSummary || '').slice(0, 500),
211
+ chartered, inboxCount: inboxFiles.length
212
+ };
213
+ });
214
+ }
215
+
216
+ function getAgentDetail(id) {
217
+ const agentDir = path.join(AGENTS_DIR, id);
218
+ const charter = safeRead(path.join(agentDir, 'charter.md')) || 'No charter found.';
219
+ const history = safeRead(path.join(agentDir, 'history.md')) || 'No history yet.';
220
+ const outputLog = safeRead(path.join(agentDir, 'output.log')) || '';
221
+
222
+ const statusData = getAgentStatus(id); // derives from dispatch.json
223
+
224
+ const inboxContents = safeReadDir(INBOX_DIR)
225
+ .filter(f => f.includes(id))
226
+ .map(f => ({ name: f, content: safeRead(path.join(INBOX_DIR, f)) || '' }));
227
+
228
+ let recentDispatches = [];
229
+ try {
230
+ const dispatch = getDispatch();
231
+ recentDispatches = (dispatch.completed || [])
232
+ .filter(d => d.agent === id)
233
+ .slice(-10)
234
+ .reverse()
235
+ .map(d => ({
236
+ id: d.id, task: d.task || '', type: d.type || '',
237
+ result: d.result || '', reason: d.reason || '',
238
+ completed_at: d.completed_at || '',
239
+ }));
240
+ } catch {}
241
+
242
+ return { charter, history, statusData, outputLog, inboxContents, recentDispatches };
243
+ }
244
+
245
+ // ── Pull Requests ───────────────────────────────────────────────────────────
246
+
247
+ function getPrs(project) {
248
+ if (project) return safeJson(projectPrPath(project)) || [];
249
+ const config = getConfig();
250
+ const all = [];
251
+ for (const p of getProjects(config)) all.push(...getPrs(p));
252
+ return all;
253
+ }
254
+
255
+ function getPullRequests(config) {
256
+ config = config || getConfig();
257
+ const projects = getProjects(config);
258
+ const allPrs = [];
259
+ for (const project of projects) {
260
+ const prs = safeJson(projectPrPath(project));
261
+ if (!prs) continue;
262
+ const base = project.prUrlBase || '';
263
+ for (const pr of prs) {
264
+ if (!pr.url && base && pr.id) pr.url = base + String(pr.id).replace('PR-', '');
265
+ pr._project = project.name || 'Project';
266
+ allPrs.push(pr);
267
+ }
268
+ }
269
+ allPrs.sort((a, b) => (b.created || '').localeCompare(a.created || ''));
270
+ return allPrs;
271
+ }
272
+
273
+ // ── Skills ──────────────────────────────────────────────────────────────────
274
+
275
+ function collectSkillFiles(config) {
276
+ config = config || getConfig();
277
+ const skillFiles = [];
278
+ const seen = new Set(); // dedup by name
279
+
280
+ // 1. Claude Code native skills: ~/.claude/skills/<name>/SKILL.md
281
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
282
+ const claudeSkillsDir = path.join(homeDir, '.claude', 'skills');
283
+ try {
284
+ const dirs = fs.readdirSync(claudeSkillsDir).filter(d => {
285
+ try { return fs.statSync(path.join(claudeSkillsDir, d)).isDirectory(); } catch { return false; }
286
+ });
287
+ for (const d of dirs) {
288
+ const skillFile = path.join(claudeSkillsDir, d, 'SKILL.md');
289
+ if (fs.existsSync(skillFile)) {
290
+ skillFiles.push({ file: 'SKILL.md', dir: path.join(claudeSkillsDir, d), scope: 'claude-code', skillName: d });
291
+ seen.add(d);
292
+ }
293
+ }
294
+ } catch {}
295
+
296
+ // 1b. Installed plugin skills: ~/.claude/plugins/installed_plugins.json → cache/<marketplace>/<plugin>/<version>/commands/*.md
297
+ try {
298
+ const pluginsFile = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
299
+ const registry = JSON.parse(safeRead(pluginsFile) || '{}');
300
+ for (const [pluginKey, installs] of Object.entries(registry.plugins || {})) {
301
+ if (!Array.isArray(installs) || installs.length === 0) continue;
302
+ const install = installs[0];
303
+ if (!install.installPath) continue;
304
+ const commandsDir = path.join(install.installPath, 'commands');
305
+ try {
306
+ const commands = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
307
+ for (const cmd of commands) {
308
+ const name = pluginKey.split('@')[0] + ':' + cmd.replace('.md', '');
309
+ if (seen.has(name)) continue;
310
+ skillFiles.push({ file: cmd, dir: commandsDir, scope: 'plugin', skillName: name });
311
+ seen.add(name);
312
+ }
313
+ } catch {}
314
+ }
315
+ } catch {}
316
+
317
+ // 2. Project-specific skills: <project>/.claude/skills/<name>.md or <name>/SKILL.md
318
+ for (const project of getProjects(config)) {
319
+ const projectSkillsDir = path.resolve(project.localPath, '.claude', 'skills');
320
+ try {
321
+ const entries = fs.readdirSync(projectSkillsDir);
322
+ for (const entry of entries) {
323
+ if (entry === 'README.md') continue;
324
+ const entryPath = path.join(projectSkillsDir, entry);
325
+ const stat = fs.statSync(entryPath);
326
+ if (stat.isDirectory()) {
327
+ const skillFile = path.join(entryPath, 'SKILL.md');
328
+ if (fs.existsSync(skillFile)) {
329
+ skillFiles.push({ file: 'SKILL.md', dir: entryPath, scope: 'project', projectName: project.name, skillName: entry });
330
+ }
331
+ } else if (entry.endsWith('.md')) {
332
+ skillFiles.push({ file: entry, dir: projectSkillsDir, scope: 'project', projectName: project.name });
333
+ }
334
+ }
335
+ } catch {}
336
+ }
337
+ return skillFiles;
338
+ }
339
+
340
+ function getSkills(config) {
341
+ const all = [];
342
+ for (const { file: f, dir, scope, projectName, skillName } of collectSkillFiles(config)) {
343
+ try {
344
+ const content = safeRead(path.join(dir, f)) || '';
345
+ const meta = parseSkillFrontmatter(content, skillName || f);
346
+ if (scope === 'project' && meta.project === 'any') meta.project = projectName;
347
+ // Check if auto-generated by an agent
348
+ const isAutoGenerated = content.includes('Auto-extracted') || content.includes('author:') || content.includes('createdBy:');
349
+ all.push({
350
+ ...meta, file: f, dir: dir.replace(/\\/g, '/'),
351
+ source: scope === 'claude-code' ? 'claude-code' : scope === 'plugin' ? 'plugin' : scope === 'project' ? 'project:' + projectName : 'minions',
352
+ scope,
353
+ autoGenerated: isAutoGenerated,
354
+ });
355
+ } catch {}
356
+ }
357
+ return all;
358
+ }
359
+
360
+ function getSkillIndex(config) {
361
+ try {
362
+ const skillFiles = collectSkillFiles(config);
363
+ if (skillFiles.length === 0) return '';
364
+
365
+ let index = '## Available Minions Skills\n\n';
366
+ index += 'These are reusable workflows discovered by agents. Follow them when the trigger matches your task.\n\n';
367
+
368
+ for (const { file: f, dir, scope, projectName } of skillFiles) {
369
+ const content = safeRead(path.join(dir, f));
370
+ const meta = parseSkillFrontmatter(content, f);
371
+ index += `### ${meta.name}`;
372
+ if (scope === 'project') index += ` (${projectName})`;
373
+ index += '\n';
374
+ if (meta.description) index += `${meta.description}\n`;
375
+ if (meta.trigger) index += `**When:** ${meta.trigger}\n`;
376
+ if (meta.project !== 'any') index += `**Project:** ${meta.project}\n`;
377
+ index += `**File:** \`${dir}/${f}\`\n`;
378
+ index += `Read the full skill file before following the steps.\n\n`;
379
+ }
380
+ return index;
381
+ } catch { return ''; }
382
+ }
383
+
384
+ // ── Knowledge Base ──────────────────────────────────────────────────────────
385
+
386
+ let _kbCache = null;
387
+ let _kbCacheTs = 0;
388
+ const KB_CACHE_TTL = 30000; // 30s — KB changes infrequently
389
+
390
+ function getKnowledgeBaseEntries() {
391
+ const now = Date.now();
392
+ if (_kbCache && (now - _kbCacheTs) < KB_CACHE_TTL) return _kbCache;
393
+
394
+ const entries = [];
395
+ for (const cat of KB_CATEGORIES) {
396
+ const catDir = path.join(KNOWLEDGE_DIR, cat);
397
+ const files = safeReadDir(catDir).filter(f => f.endsWith('.md'));
398
+ for (const f of files) {
399
+ const content = safeRead(path.join(catDir, f)) || '';
400
+ const titleMatch = content.match(/^#\s+(.+)/m);
401
+ const title = titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, '');
402
+ const agentMatch = f.match(/^\d{4}-\d{2}-\d{2}-(\w+)-/);
403
+ const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/);
404
+ entries.push({
405
+ cat, file: f, title,
406
+ agent: agentMatch ? agentMatch[1] : '',
407
+ date: dateMatch ? dateMatch[1] : '',
408
+ preview: content.slice(0, 200),
409
+ size: content.length,
410
+ });
411
+ }
412
+ }
413
+ _kbCache = entries;
414
+ _kbCacheTs = now;
415
+ return entries;
416
+ }
417
+
418
+ function getKnowledgeBaseIndex() {
419
+ try {
420
+ const entries = getKnowledgeBaseEntries();
421
+ if (entries.length === 0) return '';
422
+ let index = '## Knowledge Base Reference\n\n';
423
+ index += 'Deep-reference docs from past work. Read the file if you need detail.\n\n';
424
+ for (const e of entries) {
425
+ index += `- \`knowledge/${e.cat}/${e.file}\` \u2014 ${e.title}\n`;
426
+ }
427
+ return index + '\n';
428
+ } catch { return ''; }
429
+ }
430
+
431
+ // ── Work Items ──────────────────────────────────────────────────────────────
432
+
433
+ function getWorkItems(config) {
434
+ config = config || getConfig();
435
+ const projects = getProjects(config);
436
+ const allItems = [];
437
+
438
+ // Central work items
439
+ const centralData = safeRead(path.join(MINIONS_DIR, 'work-items.json'));
440
+ if (centralData) {
441
+ try {
442
+ for (const item of JSON.parse(centralData)) {
443
+ item._source = 'central';
444
+ allItems.push(item);
445
+ }
446
+ } catch {}
447
+ }
448
+
449
+ // Per-project work items
450
+ for (const project of projects) {
451
+ const data = safeRead(projectWorkItemsPath(project));
452
+ if (data) {
453
+ try {
454
+ for (const item of JSON.parse(data)) {
455
+ item._source = project.name || 'project';
456
+ allItems.push(item);
457
+ }
458
+ } catch {}
459
+ }
460
+ }
461
+
462
+ // Cross-reference with PRs
463
+ const allPrs = getPullRequests(config);
464
+ const dispatch = getDispatch();
465
+ for (const item of allItems) {
466
+ if (item._pr && !item._prUrl) {
467
+ const prId = String(item._pr).replace('PR-', '');
468
+ const pr = allPrs.find(p => String(p.id).includes(prId));
469
+ if (pr) item._prUrl = pr.url;
470
+ }
471
+ if (!item._pr) {
472
+ const prLinks = shared.getPrLinks();
473
+ const linkedPrId = Object.entries(prLinks).find(([, v]) => v === item.id)?.[0];
474
+ if (linkedPrId) {
475
+ item._pr = linkedPrId;
476
+ const linkedPr = allPrs.find(p => p.id === linkedPrId);
477
+ if (linkedPr) item._prUrl = linkedPr.url;
478
+ } else {
479
+ // no further fallback — pr-links.json and wi._pr are the sources of truth
480
+ }
481
+ }
482
+ }
483
+
484
+ const statusOrder = {
485
+ pending: 0,
486
+ queued: 0,
487
+ dispatched: 1,
488
+ 'in-pr': 3, // backward compat — treated as done
489
+ done: 3,
490
+ implemented: 3,
491
+ failed: 4,
492
+ paused: 5,
493
+ };
494
+ allItems.sort((a, b) => {
495
+ const sa = statusOrder[a.status] ?? 1;
496
+ const sb = statusOrder[b.status] ?? 1;
497
+ if (sa !== sb) return sa - sb;
498
+ return (b.created || '').localeCompare(a.created || '');
499
+ });
500
+
501
+ return allItems;
502
+ }
503
+
504
+ // ── PRD Progress ────────────────────────────────────────────────────────────
505
+
506
+ function getPrdInfo(config) {
507
+ config = config || getConfig();
508
+ const projects = getProjects(config);
509
+ let allPrdItems = [];
510
+ let latestStat = null;
511
+
512
+ // Scan active PRDs and archived PRDs (completed PRDs still need to show progress)
513
+ const planDirs = [
514
+ { dir: PRD_DIR, archived: false },
515
+ { dir: path.join(PRD_DIR, 'archive'), archived: true },
516
+ ];
517
+ for (const { dir, archived } of planDirs) {
518
+ try {
519
+ const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
520
+ for (const pf of planFiles) {
521
+ try {
522
+ const plan = JSON.parse(fs.readFileSync(path.join(dir, pf), 'utf8'));
523
+ if (!plan.missing_features) continue;
524
+ const stat = fs.statSync(path.join(dir, pf));
525
+ if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
526
+ // Staleness: compare source plan mtime to recorded sourcePlanModifiedAt
527
+ let planStale = false;
528
+ if (!archived && plan.source_plan) {
529
+ try {
530
+ const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
531
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
532
+ if (recorded && sourceMtime > recorded) planStale = true;
533
+ } catch {}
534
+ }
535
+ for (const f of plan.missing_features) {
536
+ allPrdItems.push({
537
+ ...f, _source: pf, _planStatus: plan.status || 'active',
538
+ _planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
539
+ _archived: archived, _sourcePlan: plan.source_plan || '',
540
+ _planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
541
+ _prdUpdatedAt: new Date(stat.mtimeMs).toISOString(),
542
+ });
543
+ }
544
+ } catch {}
545
+ }
546
+ } catch {}
547
+ }
548
+
549
+ if (allPrdItems.length === 0) return { progress: null, status: null };
550
+
551
+ const items = allPrdItems;
552
+ const total = items.length;
553
+
554
+ // Build work item lookup — work item ID = PRD item ID
555
+ const wiById = {};
556
+ for (const project of projects) {
557
+ try {
558
+ const workItems = safeJson(projectWorkItemsPath(project)) || [];
559
+ for (const wi of workItems) { if (wi.sourcePlan) wiById[wi.id] = wi; }
560
+ } catch {}
561
+ }
562
+
563
+ // PR-to-PRD linking — primary source is pr-links.json (single-writer, never clobbered by polling)
564
+ const allPrs = getPullRequests(config);
565
+ const prById = {};
566
+ for (const pr of allPrs) prById[pr.id] = pr;
567
+
568
+ const prdToPr = {};
569
+ const prLinks = shared.getPrLinks(); // { "PR-xxxx": "P-xxxx" }
570
+ for (const [prId, itemId] of Object.entries(prLinks)) {
571
+ const pr = prById[prId];
572
+ const project = projects.find(p => p.name === pr?._project) || projects[0];
573
+ const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + prId.replace('PR-', '') : '');
574
+ if (!prdToPr[itemId]) prdToPr[itemId] = [];
575
+ prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
576
+ }
577
+ // Fallback: work item _pr field for anything still missing
578
+ for (const wi of Object.values(wiById)) {
579
+ if (!wi._pr || prdToPr[wi.id]?.length) continue;
580
+ const pr = prById[wi._pr];
581
+ const project = projects.find(p => p.name === wi.project || p.name === wi._source);
582
+ const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + wi._pr.replace('PR-', '') : '');
583
+ prdToPr[wi.id] = [{ id: wi._pr, url, title: pr?.title || '', status: pr?.status || 'active', _project: project?.name || '' }];
584
+ }
585
+
586
+ // PRD JSON status is the source of truth — kept in sync with work item by syncPrdItemStatus.
587
+ // Map from PRD JSON values to display values (dispatched → in-progress etc.)
588
+ // Augment each item with execution metadata from the work item.
589
+ const statusDisplay = { dispatched: 'in-progress', pending: 'missing' };
590
+ for (const item of items) {
591
+ const wi = wiById[item.id];
592
+ item.status = statusDisplay[item.status] || item.status || 'missing';
593
+ // Attach execution metadata for display (agent, PR link, fail reason)
594
+ if (wi) {
595
+ if (wi.dispatched_to) item._agent = wi.dispatched_to;
596
+ if (wi.failReason) item._failReason = wi.failReason;
597
+ }
598
+ }
599
+
600
+ const byStatus = {};
601
+ items.forEach(item => { const s = item.status || 'missing'; byStatus[s] = byStatus[s] || []; byStatus[s].push(item); });
602
+ const complete = (byStatus['done'] || []).length + (byStatus['in-pr'] || []).length; // in-pr counted as done for backward compat
603
+ const inProgress = (byStatus['in-progress'] || []).length;
604
+ const paused = (byStatus['paused'] || []).length;
605
+ const missing = (byStatus['missing'] || []).length;
606
+ const donePercent = total > 0 ? Math.round((complete / total) * 100) : 0;
607
+
608
+ // Plan timings
609
+ const planTimings = {};
610
+ for (const project of projects) {
611
+ try {
612
+ const workItems = safeJson(projectWorkItemsPath(project)) || [];
613
+ for (const wi of workItems) {
614
+ if (!wi.sourcePlan) continue;
615
+ if (!planTimings[wi.sourcePlan]) planTimings[wi.sourcePlan] = { firstDispatched: null, lastCompleted: null, allDone: true };
616
+ const t = planTimings[wi.sourcePlan];
617
+ if (wi.dispatched_at) { const d = new Date(wi.dispatched_at).getTime(); if (!t.firstDispatched || d < t.firstDispatched) t.firstDispatched = d; }
618
+ if (wi.completedAt) { const c = new Date(wi.completedAt).getTime(); if (!t.lastCompleted || c > t.lastCompleted) t.lastCompleted = c; }
619
+ if (wi.status !== 'done' && wi.status !== 'in-pr') t.allDone = false; // in-pr treated as done for backward compat
620
+ }
621
+ } catch {}
622
+ }
623
+
624
+ const progress = {
625
+ total, complete, inProgress, paused, missing, donePercent, planTimings,
626
+ items: items.map(i => ({
627
+ id: i.id, name: i.name || i.title, priority: i.priority,
628
+ complexity: i.estimated_complexity || i.size, status: i.status || 'missing',
629
+ description: (i.description || '').slice(0, 200), projects: i.projects || [],
630
+ prs: prdToPr[i.id] || [], depends_on: i.depends_on || [],
631
+ project: i.project || '', source: i._source || '', planSummary: i._planSummary || '', planProject: i._planProject || '', planStatus: i._planStatus || 'active', _archived: i._archived || false, sourcePlan: i._sourcePlan || '',
632
+ planStale: i._planStale || false, lastSyncedFromPlan: i._lastSyncedFromPlan || null, prdUpdatedAt: i._prdUpdatedAt || null,
633
+ })),
634
+ };
635
+
636
+ const status = {
637
+ exists: true, age: latestStat ? timeSince(latestStat.mtimeMs) : 'unknown',
638
+ existing: 0, missing: items.filter(i => i.status === 'missing').length, questions: 0, summary: '',
639
+ missingList: items.filter(i => i.status === 'missing').map(f => ({ id: f.id, name: f.name || f.title, priority: f.priority, complexity: f.estimated_complexity || f.size })),
640
+ };
641
+
642
+ return { progress, status };
643
+ }
644
+
645
+ // ── Exports ─────────────────────────────────────────────────────────────────
646
+
647
+ module.exports = {
648
+ // Paths (for modules that need direct access)
649
+ MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PLANS_DIR, PRD_DIR, SKILLS_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
650
+ CONFIG_PATH, CONTROL_PATH, DISPATCH_PATH, LOG_PATH, NOTES_PATH,
651
+
652
+ // Helpers
653
+ timeSince,
654
+
655
+ // Core state
656
+ getConfig, getControl, getDispatch, getDispatchQueue,
657
+ getNotes, getNotesWithMeta, getEngineLog, getMetrics,
658
+
659
+ // Inbox
660
+ getInboxFiles, getInbox,
661
+
662
+ // Agents
663
+ getAgentStatus, getAgentCharter, getAgents, getAgentDetail,
664
+
665
+ // Pull requests
666
+ getPrs, getPullRequests,
667
+
668
+ // Skills
669
+ collectSkillFiles, getSkills, getSkillIndex,
670
+
671
+ // Knowledge base
672
+ getKnowledgeBaseEntries, getKnowledgeBaseIndex,
673
+
674
+ // Work items & PRD
675
+ getWorkItems, getPrdInfo,
676
+ };
677
+