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.
@@ -1,256 +1,43 @@
1
1
  'use strict';
2
2
  /*
3
- * spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch).
4
- * v0.4 adds an agent launcher: POST /api/run spawns the configured agent headless in the project
5
- * root (with project memory: CLAUDE.md AGENTS.md), streams its output over SSE, and records the
6
- * run in .spectoflow/runtime.json. As the agent edits plans/*.md, the board refreshes live.
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 store = require('../lib/store');
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
- // ---- helpers for settings + attention points -----------------------------
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
- // Custom dashboards (Customize page) live in their own subdirectory of .spectoflow, which the
82
- // top-level `.spectoflow` watch below does NOT cover — fs.watch here is non-recursive on purpose
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
- // ---- manual task creation: add a checkbox task straight to a plan, no agent involved ----
145
- if(p==='/api/task'&&req.method==='POST'){
146
- const { title, phase, file, owner, level } = await body(req);
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)