@yemi33/squad 0.1.0 → 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.
- package/LICENSE +21 -21
- package/TODO.md +10 -10
- package/bin/squad.js +164 -164
- package/dashboard.js +901 -886
- package/engine/ado-mcp-wrapper.js +49 -49
- package/engine.js +194 -14
- package/package.json +46 -46
package/dashboard.js
CHANGED
|
@@ -1,886 +1,901 @@
|
|
|
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
|
-
|
|
797
|
-
if (
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
res.end(JSON.stringify(
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
+
// GET /api/health — lightweight health check for monitoring
|
|
797
|
+
if (req.method === 'GET' && req.url === '/api/health') {
|
|
798
|
+
const engine = getEngineState();
|
|
799
|
+
const agents = getAgents();
|
|
800
|
+
const health = {
|
|
801
|
+
status: engine.state === 'running' ? 'healthy' : engine.state === 'paused' ? 'degraded' : 'stopped',
|
|
802
|
+
engine: { state: engine.state, pid: engine.pid },
|
|
803
|
+
agents: agents.map(a => ({ id: a.id, name: a.name, status: a.status })),
|
|
804
|
+
projects: PROJECTS.map(p => ({ name: p.name, reachable: fs.existsSync(p.localPath) })),
|
|
805
|
+
uptime: process.uptime(),
|
|
806
|
+
timestamp: new Date().toISOString()
|
|
807
|
+
};
|
|
808
|
+
return jsonReply(res, 200, health);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const agentMatch = req.url.match(/^\/api\/agent\/([\w-]+)$/);
|
|
812
|
+
if (agentMatch) {
|
|
813
|
+
res.setHeader('Content-Type', 'application/json');
|
|
814
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
815
|
+
try {
|
|
816
|
+
res.end(JSON.stringify(getAgentDetail(agentMatch[1])));
|
|
817
|
+
} catch (e) {
|
|
818
|
+
res.statusCode = 500;
|
|
819
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
820
|
+
}
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (req.url === '/api/status') {
|
|
825
|
+
res.setHeader('Content-Type', 'application/json');
|
|
826
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
827
|
+
try {
|
|
828
|
+
res.end(JSON.stringify(getStatus()));
|
|
829
|
+
} catch (e) {
|
|
830
|
+
res.statusCode = 500;
|
|
831
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
832
|
+
}
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// GET /api/health — lightweight health check
|
|
837
|
+
if (req.method === 'GET' && req.url === '/api/health') {
|
|
838
|
+
try {
|
|
839
|
+
const engine = getEngineState();
|
|
840
|
+
const agents = getAgents().map(a => ({ id: a.id, status: a.status }));
|
|
841
|
+
const projects = PROJECTS.map(p => {
|
|
842
|
+
let reachable = false;
|
|
843
|
+
try { reachable = fs.existsSync(p.localPath); } catch {}
|
|
844
|
+
return { name: p.name || 'Project', reachable };
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const allIdle = agents.every(a => a.status === 'idle' || a.status === 'done');
|
|
848
|
+
const engineStopped = engine.state === 'stopped';
|
|
849
|
+
let status = 'healthy';
|
|
850
|
+
if (engineStopped && !allIdle) status = 'degraded';
|
|
851
|
+
if (engineStopped && projects.every(p => !p.reachable)) status = 'stopped';
|
|
852
|
+
|
|
853
|
+
return jsonReply(res, 200, {
|
|
854
|
+
status,
|
|
855
|
+
engine: { state: engine.state || 'stopped', pid: engine.pid || null },
|
|
856
|
+
agents,
|
|
857
|
+
projects,
|
|
858
|
+
uptime: Math.floor(process.uptime()),
|
|
859
|
+
timestamp: new Date().toISOString(),
|
|
860
|
+
});
|
|
861
|
+
} catch (e) {
|
|
862
|
+
return jsonReply(res, 500, { status: 'degraded', error: e.message, timestamp: new Date().toISOString() });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
867
|
+
res.end(HTML);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
871
|
+
console.log(`\n Squad Mission Control`);
|
|
872
|
+
console.log(` -----------------------------------`);
|
|
873
|
+
console.log(` http://localhost:${PORT}`);
|
|
874
|
+
console.log(`\n Watching:`);
|
|
875
|
+
console.log(` Squad dir: ${SQUAD_DIR}`);
|
|
876
|
+
console.log(` Projects: ${PROJECTS.map(p => `${p.name} (${p.localPath})`).join(', ')}`);
|
|
877
|
+
console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
|
|
878
|
+
|
|
879
|
+
const { exec } = require('child_process');
|
|
880
|
+
try {
|
|
881
|
+
if (process.platform === 'win32') {
|
|
882
|
+
exec(`start "" "http://localhost:${PORT}"`);
|
|
883
|
+
} else if (process.platform === 'darwin') {
|
|
884
|
+
exec(`open http://localhost:${PORT}`);
|
|
885
|
+
} else {
|
|
886
|
+
exec(`xdg-open http://localhost:${PORT}`);
|
|
887
|
+
}
|
|
888
|
+
} catch (e) {
|
|
889
|
+
console.log(` Could not auto-open browser: ${e.message}`);
|
|
890
|
+
console.log(` Please open http://localhost:${PORT} manually.`);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
server.on('error', e => {
|
|
895
|
+
if (e.code === 'EADDRINUSE') {
|
|
896
|
+
console.error(`\n Port ${PORT} already in use. Kill the existing process or change PORT.\n`);
|
|
897
|
+
} else {
|
|
898
|
+
console.error(e);
|
|
899
|
+
}
|
|
900
|
+
process.exit(1);
|
|
901
|
+
});
|