@yemi33/squad 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js ADDED
@@ -0,0 +1,886 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Squad Mission Control Dashboard
4
+ * Run: node .squad/dashboard.js
5
+ * Opens: http://localhost:7331
6
+ */
7
+
8
+ const http = require('http');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
14
+ const SQUAD_DIR = __dirname;
15
+ const CONFIG = JSON.parse(fs.readFileSync(path.join(SQUAD_DIR, 'config.json'), 'utf8'));
16
+
17
+ // Multi-project support
18
+ function getProjects() {
19
+ if (CONFIG.projects && Array.isArray(CONFIG.projects)) return CONFIG.projects;
20
+ const proj = CONFIG.project || {};
21
+ if (!proj.localPath) proj.localPath = path.resolve(SQUAD_DIR, '..');
22
+ if (!proj.workSources) proj.workSources = CONFIG.workSources || {};
23
+ return [proj];
24
+ }
25
+ const PROJECTS = getProjects();
26
+ const projectNames = PROJECTS.map(p => p.name || 'Project').join(' + ');
27
+
28
+ const HTML_RAW = fs.readFileSync(path.join(SQUAD_DIR, 'dashboard.html'), 'utf8');
29
+ const HTML = HTML_RAW.replace('Squad Mission Control', `Squad Mission Control — ${projectNames}`);
30
+
31
+ // -- Helpers --
32
+
33
+ function safeRead(filePath) {
34
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
35
+ }
36
+
37
+ function safeReadDir(dir) {
38
+ try { return fs.readdirSync(dir); } catch { return []; }
39
+ }
40
+
41
+ function timeSince(ms) {
42
+ const s = Math.floor((Date.now() - ms) / 1000);
43
+ if (s < 60) return `${s}s ago`;
44
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
45
+ return `${Math.floor(s / 3600)}h ago`;
46
+ }
47
+
48
+ // -- Data Collectors --
49
+
50
+ function getAgentDetail(id) {
51
+ const agentDir = path.join(SQUAD_DIR, 'agents', id);
52
+ const charter = safeRead(path.join(agentDir, 'charter.md')) || 'No charter found.';
53
+ const history = safeRead(path.join(agentDir, 'history.md')) || 'No history yet.';
54
+ const outputLog = safeRead(path.join(agentDir, 'output.md')) || '';
55
+
56
+ const statusJson = safeRead(path.join(agentDir, 'status.json'));
57
+ let statusData = null;
58
+ if (statusJson) { try { statusData = JSON.parse(statusJson); } catch {} }
59
+
60
+ const inboxDir = path.join(SQUAD_DIR, 'notes', 'inbox');
61
+ const inboxContents = safeReadDir(inboxDir)
62
+ .filter(f => f.includes(id))
63
+ .map(f => ({ name: f, content: safeRead(path.join(inboxDir, f)) || '' }));
64
+
65
+ return { charter, history, statusData, outputLog, inboxContents };
66
+ }
67
+
68
+ function getAgents() {
69
+ const roster = Object.entries(CONFIG.agents).map(([id, info]) => ({ id, ...info }));
70
+
71
+ return roster.map(a => {
72
+ const inboxFiles = safeReadDir(path.join(SQUAD_DIR, 'notes', 'inbox'))
73
+ .filter(f => f.includes(a.id));
74
+
75
+ let status = 'idle';
76
+ let lastAction = 'Waiting for assignment';
77
+ let currentTask = '';
78
+
79
+ const statusFile = safeRead(path.join(SQUAD_DIR, 'agents', a.id, 'status.json'));
80
+ if (statusFile) {
81
+ try {
82
+ const sj = JSON.parse(statusFile);
83
+ status = sj.status || 'idle';
84
+ currentTask = sj.task || '';
85
+ if (sj.status === 'working') {
86
+ lastAction = `Working: ${sj.task}`;
87
+ } else if (sj.status === 'done') {
88
+ lastAction = `Done: ${sj.task}`;
89
+ }
90
+ } catch {}
91
+ }
92
+
93
+ // Show recent inbox output as context, but don't override idle status
94
+ if (status === 'idle' && inboxFiles.length > 0) {
95
+ const lastOutput = path.join(SQUAD_DIR, 'notes', 'inbox', inboxFiles[inboxFiles.length - 1]);
96
+ try {
97
+ const stat = fs.statSync(lastOutput);
98
+ lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(stat.mtimeMs)})`;
99
+ } catch {}
100
+ }
101
+
102
+ const chartered = fs.existsSync(path.join(SQUAD_DIR, 'agents', a.id, 'charter.md'));
103
+ // Truncate lastAction to prevent UI overflow from corrupted data
104
+ if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
105
+ return { ...a, status, lastAction, currentTask: (currentTask || '').slice(0, 200), chartered, inboxCount: inboxFiles.length };
106
+ });
107
+ }
108
+
109
+ function getPrdInfo() {
110
+ // Aggregate PRD across all projects
111
+ const firstProject = PROJECTS[0];
112
+ const root = path.resolve(firstProject.localPath || path.resolve(SQUAD_DIR, '..'));
113
+ const prdSrc = firstProject.workSources?.prd || CONFIG.workSources?.prd || {};
114
+ const prdPath = path.resolve(root, prdSrc.path || 'docs/prd-gaps.json');
115
+ if (!fs.existsSync(prdPath)) return { progress: null, status: null };
116
+
117
+ try {
118
+ const stat = fs.statSync(prdPath);
119
+ const data = JSON.parse(fs.readFileSync(prdPath, 'utf8'));
120
+ const items = data.missing_features || [];
121
+
122
+ const byStatus = {};
123
+ items.forEach(item => {
124
+ const s = item.status || 'missing';
125
+ byStatus[s] = byStatus[s] || [];
126
+ byStatus[s].push({ id: item.id, name: item.name || item.title, priority: item.priority, complexity: item.estimated_complexity || item.size, status: s });
127
+ });
128
+
129
+ const complete = (byStatus['complete'] || []).length + (byStatus['completed'] || []).length + (byStatus['implemented'] || []).length;
130
+ const prCreated = (byStatus['pr-created'] || []).length;
131
+ const inProgress = (byStatus['in-progress'] || []).length + (byStatus['dispatched'] || []).length;
132
+ const planned = (byStatus['planned'] || []).length;
133
+ const missing = (byStatus['missing'] || []).length;
134
+ const total = items.length;
135
+ const donePercent = total > 0 ? Math.round(((complete + prCreated) / total) * 100) : 0;
136
+
137
+ // Build PRD item → PR lookup from all project PRs
138
+ const allPrs = getPullRequests();
139
+ const prdToPr = {};
140
+ for (const pr of allPrs) {
141
+ for (const itemId of (pr.prdItems || [])) {
142
+ if (!prdToPr[itemId]) prdToPr[itemId] = [];
143
+ prdToPr[itemId].push({ id: pr.id, url: pr.url, title: pr.title, status: pr.status });
144
+ }
145
+ }
146
+
147
+ const progress = {
148
+ total, complete, prCreated, inProgress, planned, missing, donePercent,
149
+ items: items.map(i => ({
150
+ id: i.id, name: i.name || i.title, priority: i.priority,
151
+ complexity: i.estimated_complexity || i.size, status: i.status || 'missing',
152
+ prs: prdToPr[i.id] || []
153
+ })),
154
+ };
155
+
156
+ const status = {
157
+ exists: true,
158
+ age: timeSince(stat.mtimeMs),
159
+ existing: data.existing_features?.length || 0,
160
+ missing: data.missing_features?.length || 0,
161
+ questions: data.open_questions?.length || 0,
162
+ summary: data.summary || '',
163
+ missingList: (data.missing_features || []).map(f => ({ id: f.id, name: f.name || f.title, priority: f.priority, complexity: f.estimated_complexity || f.size })),
164
+ };
165
+
166
+ return { progress, status };
167
+ } catch {
168
+ return { progress: null, status: { exists: true, age: 'unknown', error: true } };
169
+ }
170
+ }
171
+
172
+ function getInbox() {
173
+ const dir = path.join(SQUAD_DIR, 'notes', 'inbox');
174
+ return safeReadDir(dir)
175
+ .filter(f => f.endsWith('.md'))
176
+ .map(f => {
177
+ const fullPath = path.join(dir, f);
178
+ try {
179
+ const stat = fs.statSync(fullPath);
180
+ const content = safeRead(fullPath) || '';
181
+ return { name: f, age: timeSince(stat.mtimeMs), mtime: stat.mtimeMs, content };
182
+ } catch { return null; }
183
+ })
184
+ .filter(Boolean)
185
+ .sort((a, b) => b.mtime - a.mtime);
186
+ }
187
+
188
+ function getNotes() {
189
+ const content = safeRead(path.join(SQUAD_DIR, 'notes.md')) || '';
190
+ return content.split('\n').filter(l => l.startsWith('### ')).map(l => l.replace('### ', '').trim());
191
+ }
192
+
193
+ function getPullRequests() {
194
+ const allPrs = [];
195
+ for (const project of PROJECTS) {
196
+ const root = path.resolve(project.localPath || path.resolve(SQUAD_DIR, '..'));
197
+ const prSrc = project.workSources?.pullRequests || CONFIG.workSources?.pullRequests || {};
198
+ const prPath = path.resolve(root, prSrc.path || '.squad/pull-requests.json');
199
+ const prFile = safeRead(prPath);
200
+ if (!prFile) continue;
201
+ try {
202
+ const prs = JSON.parse(prFile);
203
+ const base = project.prUrlBase || CONFIG.prUrlBase || '';
204
+ for (const pr of prs) {
205
+ if (!pr.url && base && pr.id) {
206
+ pr.url = base + String(pr.id).replace('PR-', '');
207
+ }
208
+ pr._project = project.name || 'Project';
209
+ allPrs.push(pr);
210
+ }
211
+ } catch {}
212
+ }
213
+ // Sort by created date descending
214
+ allPrs.sort((a, b) => (b.created || '').localeCompare(a.created || ''));
215
+ return allPrs;
216
+ }
217
+
218
+ function getArchivedPrds() {
219
+ const firstProject = PROJECTS[0];
220
+ const root = path.resolve(firstProject.localPath || path.resolve(SQUAD_DIR, '..'));
221
+ const prdSrc = firstProject.workSources?.prd || CONFIG.workSources?.prd || {};
222
+ const prdDir = path.dirname(path.resolve(root, prdSrc.path || 'docs/prd-gaps.json'));
223
+ const archiveDir = path.join(prdDir, 'archive');
224
+ return safeReadDir(archiveDir)
225
+ .filter(f => f.startsWith('prd-gaps') && f.endsWith('.json'))
226
+ .map(f => {
227
+ try {
228
+ const data = JSON.parse(fs.readFileSync(path.join(archiveDir, f), 'utf8'));
229
+ const items = data.missing_features || [];
230
+ return {
231
+ file: f,
232
+ version: data.version || f.replace('prd-gaps-', '').replace('.json', ''),
233
+ summary: data.summary || '',
234
+ total: items.length,
235
+ existing_features: data.existing_features || [],
236
+ missing_features: items,
237
+ open_questions: data.open_questions || [],
238
+ };
239
+ } catch { return null; }
240
+ })
241
+ .filter(Boolean);
242
+ }
243
+
244
+ function getEngineState() {
245
+ const controlPath = path.join(SQUAD_DIR, 'engine', 'control.json');
246
+ const controlJson = safeRead(controlPath);
247
+ if (!controlJson) return { state: 'stopped' };
248
+ try { return JSON.parse(controlJson); } catch { return { state: 'stopped' }; }
249
+ }
250
+
251
+ function getDispatchQueue() {
252
+ const dispatchPath = path.join(SQUAD_DIR, 'engine', 'dispatch.json');
253
+ const dispatchJson = safeRead(dispatchPath);
254
+ if (!dispatchJson) return { pending: [], active: [], completed: [] };
255
+ try {
256
+ const d = JSON.parse(dispatchJson);
257
+ return {
258
+ pending: d.pending || [],
259
+ active: d.active || [],
260
+ completed: (d.completed || []).slice(-20)
261
+ };
262
+ } catch { return { pending: [], active: [], completed: [] }; }
263
+ }
264
+
265
+ function getEngineLog() {
266
+ const logPath = path.join(SQUAD_DIR, 'engine', 'log.json');
267
+ const logJson = safeRead(logPath);
268
+ if (!logJson) return [];
269
+ try {
270
+ const entries = JSON.parse(logJson);
271
+ // Handle both formats: array or { entries: [] }
272
+ const arr = Array.isArray(entries) ? entries : (entries.entries || []);
273
+ return arr.slice(-50);
274
+ } catch { return []; }
275
+ }
276
+
277
+ function getMetrics() {
278
+ const metricsPath = path.join(SQUAD_DIR, 'engine', 'metrics.json');
279
+ const data = safeRead(metricsPath);
280
+ if (!data) return {};
281
+ try { return JSON.parse(data); } catch { return {}; }
282
+ }
283
+
284
+ function getSkills() {
285
+ const skillsDirs = [
286
+ { dir: path.join(SQUAD_DIR, 'skills'), source: 'skills', scope: 'squad' },
287
+ ];
288
+ // Also scan project-level skills
289
+ for (const p of PROJECTS) {
290
+ const projectSkillsDir = path.resolve(p.localPath, '.claude', 'skills');
291
+ skillsDirs.push({ dir: projectSkillsDir, source: 'project:' + p.name, scope: 'project', projectName: p.name });
292
+ }
293
+ const all = [];
294
+ for (const { dir, source, scope, projectName } of skillsDirs) {
295
+ try {
296
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f !== 'README.md');
297
+ for (const f of files) {
298
+ const content = safeRead(path.join(dir, f)) || '';
299
+ let name = f.replace('.md', '');
300
+ let description = '', trigger = '', author = '', created = '', project = 'any', allowedTools = '';
301
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
302
+ if (fmMatch) {
303
+ const fm = fmMatch[1];
304
+ const m = (key) => { const r = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm')); return r ? r[1].trim() : ''; };
305
+ name = m('name') || name;
306
+ description = m('description');
307
+ trigger = m('trigger');
308
+ author = m('author');
309
+ created = m('created');
310
+ project = m('project') || (scope === 'project' ? projectName : 'any');
311
+ allowedTools = m('allowed-tools');
312
+ }
313
+ all.push({ name, description, trigger, author, created, project, allowedTools, file: f, source, scope });
314
+ }
315
+ } catch {}
316
+ }
317
+ return all;
318
+ }
319
+
320
+ function getWorkItems() {
321
+ const allItems = [];
322
+
323
+ // Central work items
324
+ const centralPath = path.join(SQUAD_DIR, 'work-items.json');
325
+ const centralData = safeRead(centralPath);
326
+ if (centralData) {
327
+ try {
328
+ const items = JSON.parse(centralData);
329
+ for (const item of items) {
330
+ item._source = 'central';
331
+ allItems.push(item);
332
+ }
333
+ } catch {}
334
+ }
335
+
336
+ // Per-project work items
337
+ for (const project of PROJECTS) {
338
+ const root = path.resolve(project.localPath || path.resolve(SQUAD_DIR, '..'));
339
+ const wiSrc = project.workSources?.workItems || CONFIG.workSources?.workItems || {};
340
+ const wiPath = path.resolve(root, wiSrc.path || '.squad/work-items.json');
341
+ const data = safeRead(wiPath);
342
+ if (data) {
343
+ try {
344
+ const items = JSON.parse(data);
345
+ for (const item of items) {
346
+ item._source = project.name || 'project';
347
+ allItems.push(item);
348
+ }
349
+ } catch {}
350
+ }
351
+ }
352
+
353
+ // Cross-reference with all PRs to find links — only for implement/fix types (not explore/review)
354
+ const allPrs = getPullRequests();
355
+ const prSpeculativeTypes = ['implement', 'fix', 'test', ''];
356
+ for (const item of allItems) {
357
+ // Check if item already has _pr from the JSON
358
+ if (item._pr && !item._prUrl) {
359
+ const prId = String(item._pr).replace('PR-', '');
360
+ const pr = allPrs.find(p => String(p.id).includes(prId));
361
+ if (pr) { item._prUrl = pr.url; }
362
+ }
363
+ // Always check PRs that explicitly reference this work item ID via prdItems
364
+ if (!item._pr) {
365
+ const linkedPr = allPrs.find(p => (p.prdItems || []).includes(item.id));
366
+ if (linkedPr) {
367
+ item._pr = linkedPr.id;
368
+ item._prUrl = linkedPr.url;
369
+ }
370
+ }
371
+ // Speculative fallback (agent status) — only for implement/fix/test types
372
+ if (!item._pr && prSpeculativeTypes.includes(item.type || '')) {
373
+ const dispatch = getDispatchQueue();
374
+ const match = (dispatch.completed || []).find(d => d.meta?.item?.id === item.id && d.meta?.source?.includes('work-item'));
375
+ if (match?.agent) {
376
+ const statusFile = safeRead(path.join(SQUAD_DIR, 'agents', match.agent, 'status.json'));
377
+ if (statusFile) {
378
+ try {
379
+ const s = JSON.parse(statusFile);
380
+ if (s.pr) {
381
+ item._pr = s.pr;
382
+ const prId = String(s.pr).replace('PR-', '');
383
+ const pr = allPrs.find(p => String(p.id).includes(prId));
384
+ if (pr) item._prUrl = pr.url;
385
+ }
386
+ } catch {}
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ // Sort: pending/queued first, then by created date desc
393
+ const statusOrder = { pending: 0, queued: 0, dispatched: 1, done: 2 };
394
+ allItems.sort((a, b) => {
395
+ const sa = statusOrder[a.status] ?? 1;
396
+ const sb = statusOrder[b.status] ?? 1;
397
+ if (sa !== sb) return sa - sb;
398
+ return (b.created || '').localeCompare(a.created || '');
399
+ });
400
+
401
+ return allItems;
402
+ }
403
+
404
+ function getStatus() {
405
+ const prdInfo = getPrdInfo();
406
+ return {
407
+ agents: getAgents(),
408
+ prdProgress: prdInfo.progress,
409
+ inbox: getInbox(),
410
+ notes: getNotes(),
411
+ prd: prdInfo.status,
412
+ pullRequests: getPullRequests(),
413
+ archivedPrds: getArchivedPrds(),
414
+ engine: getEngineState(),
415
+ dispatch: getDispatchQueue(),
416
+ engineLog: getEngineLog(),
417
+ metrics: getMetrics(),
418
+ workItems: getWorkItems(),
419
+ skills: getSkills(),
420
+ projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
421
+ timestamp: new Date().toISOString(),
422
+ };
423
+ }
424
+
425
+ // -- POST helpers --
426
+
427
+ function readBody(req) {
428
+ return new Promise((resolve, reject) => {
429
+ let body = '';
430
+ req.on('data', chunk => { body += chunk; if (body.length > 1e6) reject(new Error('Too large')); });
431
+ req.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
432
+ });
433
+ }
434
+
435
+ function jsonReply(res, code, data) {
436
+ res.setHeader('Content-Type', 'application/json');
437
+ res.setHeader('Access-Control-Allow-Origin', '*');
438
+ res.statusCode = code;
439
+ res.end(JSON.stringify(data));
440
+ }
441
+
442
+ // -- Server --
443
+
444
+ const server = http.createServer(async (req, res) => {
445
+ // CORS preflight
446
+ if (req.method === 'OPTIONS') {
447
+ res.setHeader('Access-Control-Allow-Origin', '*');
448
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
449
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
450
+ res.statusCode = 204;
451
+ res.end();
452
+ return;
453
+ }
454
+
455
+ // POST /api/work-items/retry — reset a failed/dispatched item to pending
456
+ if (req.method === 'POST' && req.url === '/api/work-items/retry') {
457
+ try {
458
+ const body = await readBody(req);
459
+ const { id, source } = body;
460
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
461
+
462
+ // Find the right file
463
+ let wiPath;
464
+ if (!source || source === 'central') {
465
+ wiPath = path.join(SQUAD_DIR, 'work-items.json');
466
+ } else {
467
+ const proj = PROJECTS.find(p => p.name === source);
468
+ if (proj) {
469
+ const root = path.resolve(proj.localPath || path.resolve(SQUAD_DIR, '..'));
470
+ const wiSrc = proj.workSources?.workItems || CONFIG.workSources?.workItems || {};
471
+ wiPath = path.resolve(root, wiSrc.path || '.squad/work-items.json');
472
+ }
473
+ }
474
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
475
+
476
+ const items = JSON.parse(safeRead(wiPath) || '[]');
477
+ const item = items.find(i => i.id === id);
478
+ if (!item) return jsonReply(res, 404, { error: 'item not found' });
479
+
480
+ item.status = 'pending';
481
+ delete item.dispatched_at;
482
+ delete item.dispatched_to;
483
+ delete item.failReason;
484
+ delete item.failedAt;
485
+ delete item.fanOutAgents;
486
+ fs.writeFileSync(wiPath, JSON.stringify(items, null, 2));
487
+
488
+ // Clear completed dispatch entries so the engine doesn't dedup this item
489
+ const dispatchPath = path.join(SQUAD_DIR, 'engine', 'dispatch.json');
490
+ try {
491
+ const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
492
+ if (dispatch.completed) {
493
+ const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
494
+ const dispatchKey = sourcePrefix + id;
495
+ const before = dispatch.completed.length;
496
+ dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
497
+ // Also clear fan-out entries
498
+ dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
499
+ if (dispatch.completed.length !== before) {
500
+ fs.writeFileSync(dispatchPath, JSON.stringify(dispatch, null, 2));
501
+ }
502
+ }
503
+ } catch {}
504
+
505
+ return jsonReply(res, 200, { ok: true, id });
506
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
507
+ }
508
+
509
+ // POST /api/work-items/delete — remove a work item, kill agent, clear dispatch
510
+ if (req.method === 'POST' && req.url === '/api/work-items/delete') {
511
+ try {
512
+ const body = await readBody(req);
513
+ const { id, source } = body;
514
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
515
+
516
+ // Find the right work-items file
517
+ let wiPath;
518
+ if (!source || source === 'central') {
519
+ wiPath = path.join(SQUAD_DIR, 'work-items.json');
520
+ } else {
521
+ const proj = PROJECTS.find(p => p.name === source);
522
+ if (proj) {
523
+ const root = path.resolve(proj.localPath || path.resolve(SQUAD_DIR, '..'));
524
+ const wiSrc = proj.workSources?.workItems || CONFIG.workSources?.workItems || {};
525
+ wiPath = path.resolve(root, wiSrc.path || '.squad/work-items.json');
526
+ }
527
+ }
528
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
529
+
530
+ const items = JSON.parse(safeRead(wiPath) || '[]');
531
+ const idx = items.findIndex(i => i.id === id);
532
+ if (idx === -1) return jsonReply(res, 404, { error: 'item not found' });
533
+
534
+ const item = items[idx];
535
+
536
+ // Kill running agent process if dispatched
537
+ if (item.dispatched_to) {
538
+ const agentDir = path.join(SQUAD_DIR, 'agents', item.dispatched_to);
539
+ const statusPath = path.join(agentDir, 'status.json');
540
+ try {
541
+ const status = JSON.parse(safeRead(statusPath) || '{}');
542
+ if (status.pid) {
543
+ try { process.kill(status.pid, 'SIGTERM'); } catch {}
544
+ }
545
+ // Reset agent to idle
546
+ status.status = 'idle';
547
+ delete status.currentTask;
548
+ delete status.dispatched;
549
+ fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
550
+ } catch {}
551
+ }
552
+
553
+ // Remove item from work-items file
554
+ items.splice(idx, 1);
555
+ fs.writeFileSync(wiPath, JSON.stringify(items, null, 2));
556
+
557
+ // Clear dispatch entries (pending, active, completed + fan-out)
558
+ const dispatchPath = path.join(SQUAD_DIR, 'engine', 'dispatch.json');
559
+ try {
560
+ const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
561
+ const sourcePrefix = (!source || source === 'central') ? 'central-work-' : `work-${source}-`;
562
+ const dispatchKey = sourcePrefix + id;
563
+ let changed = false;
564
+ for (const queue of ['pending', 'active', 'completed']) {
565
+ if (dispatch[queue]) {
566
+ const before = dispatch[queue].length;
567
+ dispatch[queue] = dispatch[queue].filter(d =>
568
+ d.meta?.dispatchKey !== dispatchKey &&
569
+ (!d.meta?.parentKey || d.meta.parentKey !== dispatchKey)
570
+ );
571
+ if (dispatch[queue].length !== before) changed = true;
572
+ }
573
+ }
574
+ if (changed) {
575
+ fs.writeFileSync(dispatchPath, JSON.stringify(dispatch, null, 2));
576
+ }
577
+ } catch {}
578
+
579
+ return jsonReply(res, 200, { ok: true, id });
580
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
581
+ }
582
+
583
+ // POST /api/work-items
584
+ if (req.method === 'POST' && req.url === '/api/work-items') {
585
+ try {
586
+ const body = await readBody(req);
587
+ let wiPath;
588
+ if (body.project) {
589
+ // Write to project-specific queue
590
+ const targetProject = PROJECTS.find(p => p.name === body.project) || PROJECTS[0];
591
+ const root = path.resolve(targetProject.localPath || path.resolve(SQUAD_DIR, '..'));
592
+ const wiSrc = targetProject.workSources?.workItems || CONFIG.workSources?.workItems || {};
593
+ wiPath = path.resolve(root, wiSrc.path || '.squad/work-items.json');
594
+ } else {
595
+ // Write to central queue — agent decides which project
596
+ wiPath = path.join(SQUAD_DIR, 'work-items.json');
597
+ }
598
+ let items = [];
599
+ const existing = safeRead(wiPath);
600
+ if (existing) { try { items = JSON.parse(existing); } catch {} }
601
+ // Generate unique ID with project prefix to avoid collisions across sources
602
+ const prefix = body.project ? body.project.slice(0, 3).toUpperCase() + '-' : '';
603
+ const maxNum = items.reduce(function(max, i) {
604
+ const m = (i.id || '').match(/(\d+)$/);
605
+ return m ? Math.max(max, parseInt(m[1])) : max;
606
+ }, 0);
607
+ const id = prefix + 'W' + String(maxNum + 1).padStart(3, '0');
608
+ const item = {
609
+ id, title: body.title, type: body.type || 'implement',
610
+ priority: body.priority || 'medium', description: body.description || '',
611
+ status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard',
612
+ };
613
+ if (body.scope) item.scope = body.scope;
614
+ if (body.agent) item.agent = body.agent;
615
+ if (body.agents) item.agents = body.agents;
616
+ items.push(item);
617
+ fs.writeFileSync(wiPath, JSON.stringify(items, null, 2));
618
+ return jsonReply(res, 200, { ok: true, id });
619
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
620
+ }
621
+
622
+ // POST /api/notes
623
+ if (req.method === 'POST' && req.url === '/api/notes') {
624
+ try {
625
+ const body = await readBody(req);
626
+ const decPath = path.join(SQUAD_DIR, 'notes.md');
627
+ let content = safeRead(decPath) || '# Squad Notes\n\n## Active Notes\n';
628
+ const today = new Date().toISOString().slice(0, 10);
629
+ const entry = `\n### ${today}: ${body.title}\n**By:** ${body.author || os.userInfo().username}\n**What:** ${body.what}\n${body.why ? '**Why:** ' + body.why + '\n' : ''}\n---\n`;
630
+ // Support both old and new marker formats
631
+ const marker = '## Active Notes';
632
+ const idx = content.indexOf(marker);
633
+ if (idx !== -1) {
634
+ const insertAt = idx + marker.length;
635
+ content = content.slice(0, insertAt) + '\n' + entry + content.slice(insertAt);
636
+ } else {
637
+ content += '\n' + entry;
638
+ }
639
+ fs.writeFileSync(decPath, content);
640
+ return jsonReply(res, 200, { ok: true });
641
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
642
+ }
643
+
644
+ // POST /api/prd-items
645
+ if (req.method === 'POST' && req.url === '/api/prd-items') {
646
+ try {
647
+ const body = await readBody(req);
648
+ const firstProject = PROJECTS[0];
649
+ const root = path.resolve(firstProject.localPath || path.resolve(SQUAD_DIR, '..'));
650
+ const prdSrc = firstProject.workSources?.prd || CONFIG.workSources?.prd || {};
651
+ const prdPath = path.resolve(root, prdSrc.path || 'docs/prd-gaps.json');
652
+ let data = { missing_features: [], existing_features: [], open_questions: [] };
653
+ const existing = safeRead(prdPath);
654
+ if (existing) { try { data = JSON.parse(existing); } catch {} }
655
+ if (!data.missing_features) data.missing_features = [];
656
+ data.missing_features.push({
657
+ id: body.id, name: body.name, description: body.description || '',
658
+ priority: body.priority || 'medium', estimated_complexity: body.estimated_complexity || 'medium',
659
+ rationale: body.rationale || '', status: 'missing', affected_areas: [],
660
+ });
661
+ fs.writeFileSync(prdPath, JSON.stringify(data, null, 2));
662
+ return jsonReply(res, 200, { ok: true, id: body.id });
663
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
664
+ }
665
+
666
+ // GET /api/agent/:id/live — tail live output for a working agent
667
+ const liveMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/);
668
+ if (liveMatch && req.method === 'GET') {
669
+ const agentId = liveMatch[1];
670
+ const livePath = path.join(SQUAD_DIR, 'agents', agentId, 'live-output.log');
671
+ const content = safeRead(livePath);
672
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
673
+ res.setHeader('Access-Control-Allow-Origin', '*');
674
+ if (!content) {
675
+ res.end('No live output. Agent may not be running.');
676
+ } else {
677
+ // Return last N bytes via ?tail=N param (default last 8KB)
678
+ const params = new URL(req.url, 'http://localhost').searchParams;
679
+ const tailBytes = parseInt(params.get('tail')) || 8192;
680
+ res.end(content.length > tailBytes ? content.slice(-tailBytes) : content);
681
+ }
682
+ return;
683
+ }
684
+
685
+ // GET /api/agent/:id/output — fetch final output.log for an agent
686
+ const outputMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/);
687
+ if (outputMatch && req.method === 'GET') {
688
+ const agentId = outputMatch[1];
689
+ const outputPath = path.join(SQUAD_DIR, 'agents', agentId, 'output.log');
690
+ const content = safeRead(outputPath);
691
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
692
+ res.setHeader('Access-Control-Allow-Origin', '*');
693
+ res.end(content || 'No output log found for this agent.');
694
+ return;
695
+ }
696
+
697
+ // GET /api/notes-full — return full notes.md content
698
+ if (req.method === 'GET' && req.url === '/api/notes-full') {
699
+ const content = safeRead(path.join(SQUAD_DIR, 'notes.md'));
700
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
701
+ res.setHeader('Access-Control-Allow-Origin', '*');
702
+ res.end(content || 'No notes file found.');
703
+ return;
704
+ }
705
+
706
+ // POST /api/inbox/persist — promote an inbox item to team notes
707
+ if (req.method === 'POST' && req.url === '/api/inbox/persist') {
708
+ try {
709
+ const body = await readBody(req);
710
+ const { name } = body;
711
+ if (!name) return jsonReply(res, 400, { error: 'name required' });
712
+
713
+ const inboxPath = path.join(SQUAD_DIR, 'notes', 'inbox', name);
714
+ const content = safeRead(inboxPath);
715
+ if (!content) return jsonReply(res, 404, { error: 'inbox item not found' });
716
+
717
+ // Extract a title from the first heading or first line
718
+ const titleMatch = content.match(/^#+ (.+)$/m);
719
+ const title = titleMatch ? titleMatch[1].trim() : name.replace('.md', '');
720
+
721
+ // Append to notes.md as a new team note
722
+ const notesPath = path.join(SQUAD_DIR, 'notes.md');
723
+ let notes = safeRead(notesPath) || '# Squad Notes\n\n## Active Notes\n';
724
+ const today = new Date().toISOString().slice(0, 10);
725
+ const entry = `\n### ${today}: ${title}\n**By:** Persisted from inbox (${name})\n**What:** ${content.slice(0, 500)}\n\n---\n`;
726
+
727
+ const marker = '## Active Notes';
728
+ const idx = notes.indexOf(marker);
729
+ if (idx !== -1) {
730
+ const insertAt = idx + marker.length;
731
+ notes = notes.slice(0, insertAt) + '\n' + entry + notes.slice(insertAt);
732
+ } else {
733
+ notes += '\n' + entry;
734
+ }
735
+ fs.writeFileSync(notesPath, notes);
736
+
737
+ // Move to archive
738
+ const archiveDir = path.join(SQUAD_DIR, 'notes', 'archive');
739
+ if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
740
+ try { fs.renameSync(inboxPath, path.join(archiveDir, `persisted-${name}`)); } catch {}
741
+
742
+ return jsonReply(res, 200, { ok: true, title });
743
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
744
+ }
745
+
746
+ // POST /api/inbox/open — open inbox file in Windows explorer
747
+ if (req.method === 'POST' && req.url === '/api/inbox/open') {
748
+ try {
749
+ const body = await readBody(req);
750
+ const { name } = body;
751
+ if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
752
+ return jsonReply(res, 400, { error: 'invalid name' });
753
+ }
754
+ const filePath = path.join(SQUAD_DIR, 'notes', 'inbox', name);
755
+ if (!fs.existsSync(filePath)) return jsonReply(res, 404, { error: 'file not found' });
756
+
757
+ const { exec } = require('child_process');
758
+ try {
759
+ if (process.platform === 'win32') {
760
+ exec(`explorer /select,"${filePath.replace(/\//g, '\\\\')}"`);
761
+ } else if (process.platform === 'darwin') {
762
+ exec(`open -R "${filePath}"`);
763
+ } else {
764
+ exec(`xdg-open "${path.dirname(filePath)}"`);
765
+ }
766
+ } catch (e) {
767
+ return jsonReply(res, 500, { error: 'Could not open file manager: ' + e.message });
768
+ }
769
+ return jsonReply(res, 200, { ok: true });
770
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
771
+ }
772
+
773
+ // GET /api/skill?file=<name>.md&source=skills|project:<name>
774
+ if (req.method === 'GET' && req.url.startsWith('/api/skill?')) {
775
+ const params = new URL(req.url, 'http://localhost').searchParams;
776
+ const file = params.get('file');
777
+ const source = params.get('source') || 'skills';
778
+ if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) {
779
+ res.statusCode = 400; res.end('Invalid file'); return;
780
+ }
781
+ let skillDir;
782
+ if (source.startsWith('project:')) {
783
+ const projName = source.replace('project:', '');
784
+ const proj = PROJECTS.find(p => p.name === projName);
785
+ skillDir = proj ? path.resolve(proj.localPath, '.claude', 'skills') : null;
786
+ } else {
787
+ skillDir = path.join(SQUAD_DIR, 'skills');
788
+ }
789
+ const content = skillDir ? safeRead(path.join(skillDir, file)) : '';
790
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
791
+ res.setHeader('Access-Control-Allow-Origin', '*');
792
+ res.end(content || 'Skill not found.');
793
+ return;
794
+ }
795
+
796
+ const agentMatch = req.url.match(/^\/api\/agent\/([\w-]+)$/);
797
+ if (agentMatch) {
798
+ res.setHeader('Content-Type', 'application/json');
799
+ res.setHeader('Access-Control-Allow-Origin', '*');
800
+ try {
801
+ res.end(JSON.stringify(getAgentDetail(agentMatch[1])));
802
+ } catch (e) {
803
+ res.statusCode = 500;
804
+ res.end(JSON.stringify({ error: e.message }));
805
+ }
806
+ return;
807
+ }
808
+
809
+ if (req.url === '/api/status') {
810
+ res.setHeader('Content-Type', 'application/json');
811
+ res.setHeader('Access-Control-Allow-Origin', '*');
812
+ try {
813
+ res.end(JSON.stringify(getStatus()));
814
+ } catch (e) {
815
+ res.statusCode = 500;
816
+ res.end(JSON.stringify({ error: e.message }));
817
+ }
818
+ return;
819
+ }
820
+
821
+ // GET /api/health — lightweight health check
822
+ if (req.method === 'GET' && req.url === '/api/health') {
823
+ try {
824
+ const engine = getEngineState();
825
+ const agents = getAgents().map(a => ({ id: a.id, status: a.status }));
826
+ const projects = PROJECTS.map(p => {
827
+ let reachable = false;
828
+ try { reachable = fs.existsSync(p.localPath); } catch {}
829
+ return { name: p.name || 'Project', reachable };
830
+ });
831
+
832
+ const allIdle = agents.every(a => a.status === 'idle' || a.status === 'done');
833
+ const engineStopped = engine.state === 'stopped';
834
+ let status = 'healthy';
835
+ if (engineStopped && !allIdle) status = 'degraded';
836
+ if (engineStopped && projects.every(p => !p.reachable)) status = 'stopped';
837
+
838
+ return jsonReply(res, 200, {
839
+ status,
840
+ engine: { state: engine.state || 'stopped', pid: engine.pid || null },
841
+ agents,
842
+ projects,
843
+ uptime: Math.floor(process.uptime()),
844
+ timestamp: new Date().toISOString(),
845
+ });
846
+ } catch (e) {
847
+ return jsonReply(res, 500, { status: 'degraded', error: e.message, timestamp: new Date().toISOString() });
848
+ }
849
+ }
850
+
851
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
852
+ res.end(HTML);
853
+ });
854
+
855
+ server.listen(PORT, '127.0.0.1', () => {
856
+ console.log(`\n Squad Mission Control`);
857
+ console.log(` -----------------------------------`);
858
+ console.log(` http://localhost:${PORT}`);
859
+ console.log(`\n Watching:`);
860
+ console.log(` Squad dir: ${SQUAD_DIR}`);
861
+ console.log(` Projects: ${PROJECTS.map(p => `${p.name} (${p.localPath})`).join(', ')}`);
862
+ console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
863
+
864
+ const { exec } = require('child_process');
865
+ try {
866
+ if (process.platform === 'win32') {
867
+ exec(`start "" "http://localhost:${PORT}"`);
868
+ } else if (process.platform === 'darwin') {
869
+ exec(`open http://localhost:${PORT}`);
870
+ } else {
871
+ exec(`xdg-open http://localhost:${PORT}`);
872
+ }
873
+ } catch (e) {
874
+ console.log(` Could not auto-open browser: ${e.message}`);
875
+ console.log(` Please open http://localhost:${PORT} manually.`);
876
+ }
877
+ });
878
+
879
+ server.on('error', e => {
880
+ if (e.code === 'EADDRINUSE') {
881
+ console.error(`\n Port ${PORT} already in use. Kill the existing process or change PORT.\n`);
882
+ } else {
883
+ console.error(e);
884
+ }
885
+ process.exit(1);
886
+ });