spectoflow 0.22.4 → 0.23.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/bin/spectoflow.js +88 -153
- package/lib/hub-server.js +245 -0
- package/lib/init.js +116 -0
- package/lib/registry.js +101 -0
- package/package.json +1 -1
- package/templates/dashboard/handlers.js +241 -0
- package/templates/dashboard/public/app.js +47 -30
- package/templates/dashboard/public/hub.html +69 -0
- package/templates/dashboard/public/hub.js +134 -0
- package/templates/dashboard/public/index.html +1 -0
- package/templates/dashboard/public/styles.css +64 -2
- package/templates/dashboard/server.js +13 -226
|
@@ -1,256 +1,43 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/*
|
|
3
|
-
* spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch), single project.
|
|
4
|
+
* The actual /api/* route behavior lives in ./handlers.js — split out so the future multi-project hub
|
|
5
|
+
* (lib/hub-server.js) can load a different project's handlers.js on demand (see
|
|
6
|
+
* docs/multi-project-hub-design.md's "the server must split in two" addendum). This file remains the
|
|
7
|
+
* direct single-project entry point (`node .spectoflow/dashboard/server.js`, today's `spectoflow
|
|
8
|
+
* dashboard`) — its own external behavior is unchanged by the split.
|
|
7
9
|
*/
|
|
8
10
|
const http = require('http');
|
|
9
11
|
const fs = require('fs');
|
|
10
12
|
const path = require('path');
|
|
11
|
-
const
|
|
12
|
-
const { startRun } = require('./runner');
|
|
13
|
-
const { runSummarize } = require('./summarize');
|
|
14
|
-
const orchestrator = require('./orchestrator');
|
|
15
|
-
const agentsRegistry = require('../lib/agents-registry');
|
|
16
|
-
const files = require('./files');
|
|
13
|
+
const { createHandlers } = require('./handlers');
|
|
17
14
|
|
|
18
15
|
const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
|
|
19
16
|
const PUBLIC = path.join(__dirname, 'public');
|
|
20
17
|
const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
|
|
21
18
|
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon', '.woff2':'font/woff2', '.woff':'font/woff' };
|
|
22
19
|
const clients = new Set();
|
|
23
|
-
|
|
24
|
-
// Installed framework version: the manifest records it at init/update time. Fallback to the kit's
|
|
25
|
-
// own package.json — only reachable (and only used) when the server is run straight from templates/
|
|
26
|
-
// (dev/preview), never from an installed project whose sibling package.json belongs to the user.
|
|
27
|
-
function frameworkVersion(){
|
|
28
|
-
try { return JSON.parse(fs.readFileSync(path.join(ROOT, '.spectoflow', '.manifest.json'), 'utf8')).version; } catch {}
|
|
29
|
-
try { const pk = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); if (pk.name === 'spectoflow') return pk.version; } catch {}
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
function project(){
|
|
33
|
-
const p = store.readProject(ROOT);
|
|
34
|
-
const v = frameworkVersion(); if (v) p.version = v;
|
|
35
|
-
p.projectName = path.basename(ROOT); // the actual project folder, always shown in the topbar
|
|
36
|
-
// Known vs. actually-installed agents — the topbar switcher needs both: the full list to offer,
|
|
37
|
-
// and which ones are real (bin on PATH, or the project already has that agent's config dir) so it
|
|
38
|
-
// can refuse to activate one that isn't there.
|
|
39
|
-
p.knownAgents = agentsRegistry.KNOWN_AGENTS.map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
|
|
40
|
-
p.installedAgents = agentsRegistry.installedAgents(ROOT);
|
|
41
|
-
return p;
|
|
42
|
-
}
|
|
43
20
|
function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
|
|
44
|
-
function body(req){ return new Promise(r=>{ let b=''; req.on('data',c=>b+=c); req.on('end',()=>{ try{r(JSON.parse(b||'{}'));}catch{r({});} }); }); }
|
|
45
21
|
function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
|
|
46
|
-
function findPlanFileForTask(id){ for(const pl of store.readPlans(ROOT)) for(const ph of pl.phases) if(ph.tasks.find(t=>t.id===id)) return pl.file; return null; }
|
|
47
22
|
|
|
48
|
-
|
|
49
|
-
const configPath = () => path.join(ROOT, '.spectoflow', 'config.json');
|
|
50
|
-
function writeConfig(patch){
|
|
51
|
-
const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
|
|
52
|
-
if (patch.mode && ['autopilot','semi','manual'].includes(patch.mode)) cfg.mode = patch.mode;
|
|
53
|
-
if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
|
|
54
|
-
if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
|
|
55
|
-
if (typeof patch.agent === 'string' && patch.agent.trim()) {
|
|
56
|
-
const id = patch.agent.trim();
|
|
57
|
-
// Never activate an agent whose CLI isn't actually there — a picked-but-absent agent would just
|
|
58
|
-
// fail silently the next time something tries to run it.
|
|
59
|
-
if (!agentsRegistry.isAgentInstalled(id, ROOT)) {
|
|
60
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
61
|
-
const label = known ? known.label : id;
|
|
62
|
-
throw new Error(`${label} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
|
|
63
|
-
}
|
|
64
|
-
cfg.agent = id;
|
|
65
|
-
// Seed a default runner if this agent was never configured (e.g. installed after init/update).
|
|
66
|
-
// A headless:false agent (e.g. kimi) has no runner to seed — it can still be the active agent,
|
|
67
|
-
// it just can't be spawned by Run/Orchestrate/Summarize (disabled client-side; runner.js and
|
|
68
|
-
// summarize.js also refuse server-side either way).
|
|
69
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
70
|
-
if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
|
|
71
|
-
}
|
|
72
|
-
fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
|
|
73
|
-
return cfg;
|
|
74
|
-
}
|
|
75
|
-
// Promote an attention item into a real checkbox task under an `## Attention` phase.
|
|
76
|
-
function promoteAttention(item){
|
|
77
|
-
return store.addTask(ROOT, { phase: 'Attention', title: item.text, owner: 'user' });
|
|
78
|
-
}
|
|
23
|
+
const handlers = createHandlers(ROOT);
|
|
79
24
|
|
|
80
25
|
function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// (a recursive watch on the whole .spectoflow tree would also fire on every runtime.json write).
|
|
84
|
-
// Ensure the directory exists before watching it: a project that hasn't used Customize yet won't
|
|
85
|
-
// have it on disk, and `spectoflow init` on an older install won't have created it either.
|
|
86
|
-
try { fs.mkdirSync(path.join(ROOT,'.spectoflow','dashboard','custom'), { recursive: true }); } catch (_) {}
|
|
87
|
-
['plans','specs','.spectoflow','.spectoflow/dashboard/custom'].forEach(d=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
|
|
88
|
-
|
|
89
|
-
// A process restart loses any in-flight orchestration; without this, a stale 'running' or
|
|
90
|
-
// 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real
|
|
91
|
-
// resume — just clears the wedge so a fresh orchestration can start.
|
|
92
|
-
try { orchestrator.reconcileOnBoot(ROOT); } catch {}
|
|
26
|
+
handlers.onBoot();
|
|
27
|
+
handlers.watchDirs.forEach((d)=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
|
|
93
28
|
|
|
94
29
|
const server = http.createServer(async (req,res)=>{
|
|
95
30
|
const u=new URL(req.url,`http://localhost:${PORT}`); const p=u.pathname;
|
|
96
31
|
try{
|
|
97
|
-
if(p==='/api/project') return sendJSON(res,200,project());
|
|
98
|
-
|
|
99
|
-
// ---- read-only agent/skill file viewer (scoped to .spectoflow/{agents,skills}/**) ----
|
|
100
|
-
if (p === '/api/agentfile' && req.method === 'GET') {
|
|
101
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
102
|
-
const base = path.join(ROOT, '.spectoflow');
|
|
103
|
-
const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
|
|
104
|
-
const abs = path.resolve(base, rel);
|
|
105
|
-
const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
|
|
106
|
-
if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
|
|
107
|
-
return sendJSON(res, 400, { error: 'not an agent/skill file' });
|
|
108
|
-
// Symlink guard: the resolved real path must stay within the (real) scope dirs.
|
|
109
|
-
let real; try { real = fs.realpathSync(abs); } catch { real = null; }
|
|
110
|
-
const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
|
|
111
|
-
const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
|
|
112
|
-
const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
|
|
113
|
-
if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory())
|
|
114
|
-
return sendJSON(res, 400, { error: 'not an agent/skill file' });
|
|
115
|
-
return sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ---- File Explorer: browse/read/write/create anywhere under the project root ----
|
|
119
|
-
if (p === '/api/files/tree' && req.method === 'GET') return sendJSON(res, 200, { tree: files.tree(ROOT) });
|
|
120
|
-
if (p === '/api/files/read' && req.method === 'GET') {
|
|
121
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
122
|
-
const r = files.readFile(ROOT, rel);
|
|
123
|
-
return sendJSON(res, r.error ? 400 : 200, r);
|
|
124
|
-
}
|
|
125
|
-
if (p === '/api/files/write' && req.method === 'POST') {
|
|
126
|
-
const { path: rel, content } = await body(req);
|
|
127
|
-
const r = files.writeFile(ROOT, rel, content);
|
|
128
|
-
if (r.error) return sendJSON(res, 400, r);
|
|
129
|
-
emit({ type: 'change' }); return sendJSON(res, 200, r);
|
|
130
|
-
}
|
|
131
|
-
if (p === '/api/files/mkdir' && req.method === 'POST') {
|
|
132
|
-
const { path: rel } = await body(req);
|
|
133
|
-
const r = files.mkdir(ROOT, rel);
|
|
134
|
-
if (r.error) return sendJSON(res, 400, r);
|
|
135
|
-
emit({ type: 'change' }); return sendJSON(res, 200, r);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
32
|
if(p==='/api/events'){
|
|
139
33
|
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
|
|
140
34
|
res.write('data: '+JSON.stringify({type:'hello'})+'\n\n');
|
|
141
35
|
clients.add(res); req.on('close',()=>clients.delete(res)); return;
|
|
142
36
|
}
|
|
143
37
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if(!title||!String(title).trim()) return sendJSON(res,400,{error:'A title is required.'});
|
|
148
|
-
const t = store.addTask(ROOT,{ title:String(title).trim(), phase, file, owner, level });
|
|
149
|
-
emit({type:'change'}); return sendJSON(res,200,{task:t});
|
|
150
|
-
}
|
|
151
|
-
if(p.startsWith('/api/task/')&&req.method==='PATCH'){
|
|
152
|
-
const id=decodeURIComponent(p.split('/')[3]||''); const patch=await body(req);
|
|
153
|
-
const file=findPlanFileForTask(id); if(!file) return sendJSON(res,404,{error:`Task ${id} not found.`});
|
|
154
|
-
store.updateTaskLine(ROOT,file,id,patch); emit({type:'change'}); return sendJSON(res,200,{ok:true});
|
|
155
|
-
}
|
|
156
|
-
if(/^\/api\/task\/[^/]+\/comment$/.test(p)&&req.method==='POST'){
|
|
157
|
-
const id=decodeURIComponent(p.split('/')[3]||''); const {text,action}=await body(req);
|
|
158
|
-
if(!text||!String(text).trim()) return sendJSON(res,400,{error:'Empty comment.'});
|
|
159
|
-
const file=findPlanFileForTask(id); if(!file) return sendJSON(res,404,{error:`Task ${id} not found.`});
|
|
160
|
-
store.addTaskComment(ROOT,file,id,String(text).trim(),'me');
|
|
161
|
-
if(action==='analyze') store.updateTaskLine(ROOT,file,id,{status:'to_analyze'});
|
|
162
|
-
emit({type:'change'}); return sendJSON(res,200,{ok:true});
|
|
163
|
-
}
|
|
164
|
-
if(p==='/api/workflow/toggle'&&req.method==='POST'){
|
|
165
|
-
const {name}=await body(req); const wf=path.join(ROOT,'.spectoflow','workflow.md');
|
|
166
|
-
const lines=fs.readFileSync(wf,'utf8').split('\n');
|
|
167
|
-
for(let i=0;i<lines.length;i++){ const m=lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
|
|
168
|
-
if(m&&m[4].replace(/\s*\(optional\)\s*$/i,'').trim()===name) lines[i]=m[1]+(m[2].trim()?' ':'x')+m[3]+m[4]; }
|
|
169
|
-
fs.writeFileSync(wf,lines.join('\n')); emit({type:'change'}); return sendJSON(res,200,{ok:true});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ---- agent launcher (pipeline lives in runner.js; posts to the group-chat log) ----
|
|
173
|
-
if(p==='/api/run'&&req.method==='POST'){
|
|
174
|
-
const {prompt,agent}=await body(req);
|
|
175
|
-
if(!prompt||!String(prompt).trim()) return sendJSON(res,400,{error:'Empty request.'});
|
|
176
|
-
const r=startRun(ROOT,{prompt,agent},emit);
|
|
177
|
-
if(r.error) return sendJSON(res,400,{error:r.error});
|
|
178
|
-
return sendJSON(res,200,{runId:r.runId});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ---- chat context management: condense the log via the agent, or wipe it ----
|
|
182
|
-
if (p === '/api/chat/summarize' && req.method === 'POST') {
|
|
183
|
-
const { agent } = await body(req);
|
|
184
|
-
const r = runSummarize(ROOT, { agent }, emit);
|
|
185
|
-
if (r.error) return sendJSON(res, 400, { error: r.error });
|
|
186
|
-
return sendJSON(res, 200, { ok: true });
|
|
187
|
-
}
|
|
188
|
-
if (p === '/api/chat/clear' && req.method === 'POST') {
|
|
189
|
-
const rt = store.readRuntime(ROOT); rt.messages = []; store.writeRuntime(ROOT, rt);
|
|
190
|
-
emit({ type: 'change' });
|
|
191
|
-
return sendJSON(res, 200, { ok: true });
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ---- orchestrator ----
|
|
195
|
-
if (p === '/api/orchestrate' && req.method === 'POST') {
|
|
196
|
-
const { request } = await body(req);
|
|
197
|
-
if (!request || !String(request).trim()) return sendJSON(res, 400, { error: 'Empty request.' });
|
|
198
|
-
const active = store.readRuntime(ROOT).orchestration;
|
|
199
|
-
if (active && ['running', 'awaiting_approval'].includes(active.status))
|
|
200
|
-
return sendJSON(res, 409, { error: 'An orchestration is already active.' });
|
|
201
|
-
const mode = store.readConfig(ROOT).mode || 'semi';
|
|
202
|
-
// fire and forget; state + messages stream over SSE
|
|
203
|
-
orchestrator.runOrchestration({ root: ROOT, request: String(request).trim(), mode,
|
|
204
|
-
runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
|
|
205
|
-
.catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
|
|
206
|
-
const o = store.readRuntime(ROOT).orchestration;
|
|
207
|
-
return sendJSON(res, 200, { orchestrationId: o && o.id });
|
|
208
|
-
}
|
|
209
|
-
if (p === '/api/orchestrate/approve' && req.method === 'POST') {
|
|
210
|
-
const { decision, note } = await body(req);
|
|
211
|
-
const ok = orchestrator.submitDecision(decision, note);
|
|
212
|
-
return sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' });
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ---- settings: change autonomy mode + output language (writes config.json) ----
|
|
216
|
-
if (p === '/api/settings' && req.method === 'POST') {
|
|
217
|
-
const patch = await body(req);
|
|
218
|
-
try { const cfg = writeConfig(patch); emit({ type: 'change' }); return sendJSON(res, 200, { config: cfg }); }
|
|
219
|
-
catch (e) { return sendJSON(res, 400, { error: String(e && e.message || e) }); }
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ---- attention points: agent- or user-raised notes; validate → real task ----
|
|
223
|
-
if (p === '/api/attention' && req.method === 'POST') {
|
|
224
|
-
const { text } = await body(req);
|
|
225
|
-
if (!text || !String(text).trim()) return sendJSON(res, 400, { error: 'Empty note.' });
|
|
226
|
-
const rt = store.readRuntime(ROOT); rt.attention = rt.attention || [];
|
|
227
|
-
const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
|
|
228
|
-
rt.attention.unshift(item); store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
229
|
-
return sendJSON(res, 200, { item });
|
|
230
|
-
}
|
|
231
|
-
if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
|
|
232
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
233
|
-
const rt = store.readRuntime(ROOT); const it = (rt.attention || []).find((x) => x.id === id);
|
|
234
|
-
if (!it) return sendJSON(res, 404, { error: 'Note not found.' });
|
|
235
|
-
const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
|
|
236
|
-
store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
237
|
-
return sendJSON(res, 200, { task: t });
|
|
238
|
-
}
|
|
239
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
|
|
240
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
241
|
-
const patch = await body(req);
|
|
242
|
-
const rt = store.readRuntime(ROOT); const it = (rt.attention || []).find((x) => x.id === id);
|
|
243
|
-
if (!it) return sendJSON(res, 404, { error: 'Note not found.' });
|
|
244
|
-
if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
|
|
245
|
-
if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
|
|
246
|
-
store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
247
|
-
return sendJSON(res, 200, { item: it });
|
|
248
|
-
}
|
|
249
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
|
|
250
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
251
|
-
const rt = store.readRuntime(ROOT); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
|
|
252
|
-
store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
253
|
-
return sendJSON(res, 200, { ok: true });
|
|
38
|
+
if (p.startsWith('/api/')) {
|
|
39
|
+
const handled = await handlers.handleApi(req, res, u, emit);
|
|
40
|
+
if (handled) return;
|
|
254
41
|
}
|
|
255
42
|
|
|
256
43
|
// ---- static files, with SPA fallback: a route like /backlog (no file extension)
|