spectoflow 0.22.4 → 0.22.5

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 CHANGED
@@ -9,6 +9,7 @@ const adapters = require('../lib/adapters');
9
9
  const detect = require('../lib/detect');
10
10
  const ownership = require('../lib/ownership');
11
11
  const manifest = require('../lib/manifest');
12
+ const registry = require('../lib/registry');
12
13
  const mcp = require('../lib/mcp');
13
14
  const { startRun } = require('../templates/dashboard/runner');
14
15
  const { buildCustomizePrompt } = require('../templates/lib/customize-prompts');
@@ -283,6 +284,28 @@ async function dashboard() {
283
284
  return startDashboard();
284
285
  }
285
286
 
287
+ // ---- projects: the multi-project registry's CLI surface (~/.spectoflow/projects.json) ----
288
+ function projectsCmd() {
289
+ const sub = argv[1];
290
+ if (sub === 'remove') return projectsRemove(argv[2]);
291
+ return projectsList();
292
+ }
293
+ function projectsList() {
294
+ console.log(wordmark());
295
+ const rows = registry.listProjects();
296
+ if (!rows.length) {
297
+ console.log(c.dim(' no projects registered yet — run `spectoflow dashboard` inside one'));
298
+ return;
299
+ }
300
+ const w = Math.max(4, ...rows.map((r) => r.name.length));
301
+ rows.forEach((r) => console.log(` ${c.g(r.id)} ${r.name.padEnd(w)} ${c.dim(r.path)}`));
302
+ }
303
+ function projectsRemove(id) {
304
+ if (!id) { console.log('Usage: spectoflow projects remove <id>'); return; }
305
+ const ok = registry.removeProject(id);
306
+ console.log(ok ? `${c.g('✓')} removed ${id}` : `${c.y('!')} no project registered with id ${id}`);
307
+ }
308
+
286
309
  // ---- Customize: `spectoflow skill/agent/dashboard create` — the CLI mirror of the dashboard's
287
310
  // Settings → Customize UI. Both surfaces build the same natural-language prompt (customize-prompts.js)
288
311
  // and post it through the same pipeline (runner.js's startRun — the function /api/run itself calls),
@@ -479,6 +502,7 @@ ${c.bold('Dashboard')}
479
502
  ${c.g('dashboard status')} is it running? (url + pid)
480
503
  ${c.g('dashboard stop')} stop it ${c.dim('(alias: stop)')}
481
504
  ${c.g('dashboard restart')} stop then start
505
+ ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far (~/.spectoflow/projects.json)
482
506
 
483
507
  ${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
484
508
  ${c.g('skill create')} ${c.dim('"<description>" | --auto')} generate a project skill
@@ -522,6 +546,10 @@ const HELP = {
522
546
  ${c.g('stop')} stop it ${c.dim('(alias: spectoflow stop)')}
523
547
  ${c.g('restart')} stop then start
524
548
  ${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}`,
549
+ projects: `${c.bold('spectoflow projects')} ${c.dim('[remove <id>]')}\n
550
+ List every registered project in the global registry at ${c.dim('~/.spectoflow/projects.json')} (stored by
551
+ ${c.g('spectoflow dashboard')}) — id, name, path. ${c.g('remove <id>')} drops one (e.g. a project that moved
552
+ or was deleted) from this list only; it never touches that project's own files.`,
525
553
  skill: `${c.bold('spectoflow skill create')} ${c.dim('"<description>" [--agent=name]')}\n${c.bold('spectoflow skill create')} ${c.dim('--auto [--agent=name]')}\n
526
554
  Generate a project-specific skill — the CLI mirror of Settings → Customize → ${c.bold('Skills')} →
527
555
  ${c.bold('Add skill')} in the dashboard. Describe what it should do, or pass ${c.g('--auto')} to have
@@ -547,6 +575,7 @@ const showHelp = (name) => console.log('\n' + HELP[name].trim() + '\n');
547
575
  // ---- dispatch ---------------------------------------------------------------
548
576
  const fns = {
549
577
  init, update, dashboard, stop: stopDashboard, status, list: listAll, help, version,
578
+ projects: projectsCmd,
550
579
  agents: () => { console.log(wordmark()); printAgents(false); },
551
580
  skills: () => { console.log(wordmark()); printSkills(false); },
552
581
  workflow: () => { console.log(wordmark()); printWorkflow(false); },
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ /*
3
+ * The project registry — ~/.spectoflow/projects.json. Tracks every project spectoflow has seen (via
4
+ * `spectoflow dashboard`, wired in a later sub-project), so the multi-project hub knows what to list
5
+ * and switch between. This module owns only the registry file itself; it has no opinion about
6
+ * dashboards, ports, or servers, and nothing else in the codebase calls it yet.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const crypto = require('crypto');
12
+
13
+ const REGISTRY_FILE = 'projects.json';
14
+
15
+ // Resolution order: an explicit baseDir (unit tests) > SPECTOFLOW_HOME (CLI-level test isolation,
16
+ // same convention as SPECTOFLOW_ROOT/SPECTOFLOW_PORT elsewhere in this codebase) > the real home dir.
17
+ function registryDir(baseDir) {
18
+ return baseDir || process.env.SPECTOFLOW_HOME || path.join(os.homedir(), '.spectoflow');
19
+ }
20
+ function registryPath(baseDir) {
21
+ return path.join(registryDir(baseDir), REGISTRY_FILE);
22
+ }
23
+
24
+ function readRegistry(baseDir) {
25
+ try { return JSON.parse(fs.readFileSync(registryPath(baseDir), 'utf8')); }
26
+ catch { return { projects: [] }; }
27
+ }
28
+
29
+ function writeRegistry(baseDir, data) {
30
+ const dir = registryDir(baseDir);
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ fs.writeFileSync(registryPath(baseDir), JSON.stringify(data, null, 2) + '\n');
33
+ }
34
+
35
+ // 6 hex chars; regenerated on the rare collision against ids already in the registry. `randomFn` is
36
+ // injectable (defaults to crypto.randomBytes) so collision handling is testable without depending on
37
+ // genuine randomness to ever actually collide.
38
+ function genId(existingIds, randomFn) {
39
+ const rand = randomFn || ((n) => crypto.randomBytes(n));
40
+ let id;
41
+ do { id = rand(3).toString('hex'); } while (existingIds.includes(id));
42
+ return id;
43
+ }
44
+
45
+ function findByPath(projectPath, baseDir) {
46
+ const target = path.resolve(projectPath);
47
+ return readRegistry(baseDir).projects.find((p) => path.resolve(p.path) === target) || null;
48
+ }
49
+
50
+ // Registers `projectPath` if it isn't already known (matched by normalized path); either way stamps
51
+ // lastOpened to now and returns the entry. Never duplicates the same folder under a second id.
52
+ function addProject(projectPath, baseDir) {
53
+ const reg = readRegistry(baseDir);
54
+ const target = path.resolve(projectPath);
55
+ let entry = reg.projects.find((p) => path.resolve(p.path) === target);
56
+ if (!entry) {
57
+ entry = {
58
+ id: genId(reg.projects.map((p) => p.id)),
59
+ path: target,
60
+ name: path.basename(target),
61
+ lastOpened: new Date().toISOString(),
62
+ };
63
+ reg.projects.push(entry);
64
+ } else {
65
+ entry.lastOpened = new Date().toISOString();
66
+ }
67
+ writeRegistry(baseDir, reg);
68
+ return entry;
69
+ }
70
+
71
+ function removeProject(id, baseDir) {
72
+ const reg = readRegistry(baseDir);
73
+ const before = reg.projects.length;
74
+ reg.projects = reg.projects.filter((p) => p.id !== id);
75
+ writeRegistry(baseDir, reg);
76
+ return reg.projects.length < before;
77
+ }
78
+
79
+ function touchProject(id, baseDir) {
80
+ const reg = readRegistry(baseDir);
81
+ const entry = reg.projects.find((p) => p.id === id);
82
+ if (!entry) return false;
83
+ entry.lastOpened = new Date().toISOString();
84
+ writeRegistry(baseDir, reg);
85
+ return true;
86
+ }
87
+
88
+ // Newest-first — the natural "what did I touch most recently" order for both `spectoflow projects
89
+ // list` and (in a later sub-project) the hub landing page.
90
+ function listProjects(baseDir) {
91
+ return readRegistry(baseDir).projects.slice()
92
+ .sort((a, b) => (b.lastOpened || '').localeCompare(a.lastOpened || ''));
93
+ }
94
+
95
+ module.exports = {
96
+ readRegistry, writeRegistry, genId, addProject, removeProject, touchProject,
97
+ findByPath, listProjects, registryPath,
98
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.22.4",
3
+ "version": "0.22.5",
4
4
  "description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+ /*
3
+ * spectoflow dashboard — per-project route logic, vendored into every project's
4
+ * .spectoflow/dashboard/ (copied by init/update, exactly like server.js). Split out of server.js so
5
+ * a single global hub process (lib/hub-server.js) can load a different project's routes on demand —
6
+ * see docs/multi-project-hub-design.md's "the server must split in two" addendum.
7
+ *
8
+ * createHandlers(root) returns the per-project surface a listener-owning process needs:
9
+ * - handleApi(req, res, u, emit): Promise<boolean> — true if this request was an API route and was
10
+ * handled (caller should not also try static/SPA fallback); false otherwise. Deliberately excludes
11
+ * /api/events: SSE client registration stays owned by whichever file owns the HTTP listener.
12
+ * - watchDirs: string[] — dirs (relative to root) whose changes should emit {type:'change'}. The
13
+ * caller owns the actual fs.watch calls (it owns emit).
14
+ * - onBoot(): call once, the first time this project is opened in a server's lifetime (creates the
15
+ * custom-dashboards dir if missing, reconciles any stale in-flight orchestration).
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const store = require('../lib/store');
20
+ const { startRun } = require('./runner');
21
+ const { runSummarize } = require('./summarize');
22
+ const orchestrator = require('./orchestrator');
23
+ const agentsRegistry = require('../lib/agents-registry');
24
+ const files = require('./files');
25
+
26
+ function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
27
+ 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({}); } }); }); }
28
+
29
+ function createHandlers(root) {
30
+ // Installed framework version: the manifest records it at init/update time. Fallback to the kit's
31
+ // own package.json — only reachable (and only used) when the server is run straight from templates/
32
+ // (dev/preview), never from an installed project whose sibling package.json belongs to the user.
33
+ function frameworkVersion() {
34
+ try { return JSON.parse(fs.readFileSync(path.join(root, '.spectoflow', '.manifest.json'), 'utf8')).version; } catch {}
35
+ try { const pk = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); if (pk.name === 'spectoflow') return pk.version; } catch {}
36
+ return null;
37
+ }
38
+ function project() {
39
+ const p = store.readProject(root);
40
+ const v = frameworkVersion(); if (v) p.version = v;
41
+ p.projectName = path.basename(root);
42
+ p.knownAgents = agentsRegistry.KNOWN_AGENTS.map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
43
+ p.installedAgents = agentsRegistry.installedAgents(root);
44
+ return p;
45
+ }
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
+
48
+ const configPath = () => path.join(root, '.spectoflow', 'config.json');
49
+ function writeConfig(patch) {
50
+ const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
51
+ if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
52
+ if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
53
+ if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
54
+ if (typeof patch.agent === 'string' && patch.agent.trim()) {
55
+ const id = patch.agent.trim();
56
+ // Never activate an agent whose CLI isn't actually there — a picked-but-absent agent would just
57
+ // fail silently the next time something tries to run it.
58
+ if (!agentsRegistry.isAgentInstalled(id, root)) {
59
+ const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
60
+ const label = known ? known.label : id;
61
+ throw new Error(`${label} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
62
+ }
63
+ cfg.agent = id;
64
+ const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
65
+ if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
66
+ }
67
+ fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
68
+ return cfg;
69
+ }
70
+ function promoteAttention(item) {
71
+ return store.addTask(root, { phase: 'Attention', title: item.text, owner: 'user' });
72
+ }
73
+
74
+ async function handleApi(req, res, u, emit) {
75
+ const p = u.pathname;
76
+
77
+ if (p === '/api/project') { sendJSON(res, 200, project()); return true; }
78
+
79
+ if (p === '/api/agentfile' && req.method === 'GET') {
80
+ const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
81
+ const base = path.join(root, '.spectoflow');
82
+ const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
83
+ const abs = path.resolve(base, rel);
84
+ const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
85
+ if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
86
+ { sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
87
+ // Symlink guard: the resolved real path must stay within the (real) scope dirs.
88
+ let real; try { real = fs.realpathSync(abs); } catch { real = null; }
89
+ const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
90
+ const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
91
+ const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
92
+ if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory())
93
+ { sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
94
+ sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') }); return true;
95
+ }
96
+
97
+ if (p === '/api/files/tree' && req.method === 'GET') { sendJSON(res, 200, { tree: files.tree(root) }); return true; }
98
+ if (p === '/api/files/read' && req.method === 'GET') {
99
+ const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
100
+ const r = files.readFile(root, rel);
101
+ sendJSON(res, r.error ? 400 : 200, r); return true;
102
+ }
103
+ if (p === '/api/files/write' && req.method === 'POST') {
104
+ const { path: rel, content } = await body(req);
105
+ const r = files.writeFile(root, rel, content);
106
+ if (r.error) { sendJSON(res, 400, r); return true; }
107
+ emit({ type: 'change' }); sendJSON(res, 200, r); return true;
108
+ }
109
+ if (p === '/api/files/mkdir' && req.method === 'POST') {
110
+ const { path: rel } = await body(req);
111
+ const r = files.mkdir(root, rel);
112
+ if (r.error) { sendJSON(res, 400, r); return true; }
113
+ emit({ type: 'change' }); sendJSON(res, 200, r); return true;
114
+ }
115
+
116
+ if (p === '/api/task' && req.method === 'POST') {
117
+ const { title, phase, file, owner, level } = await body(req);
118
+ if (!title || !String(title).trim()) { sendJSON(res, 400, { error: 'A title is required.' }); return true; }
119
+ const t = store.addTask(root, { title: String(title).trim(), phase, file, owner, level });
120
+ emit({ type: 'change' }); sendJSON(res, 200, { task: t }); return true;
121
+ }
122
+ if (p.startsWith('/api/task/') && req.method === 'PATCH') {
123
+ const id = decodeURIComponent(p.split('/')[3] || ''); const patch = await body(req);
124
+ const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
125
+ store.updateTaskLine(root, file, id, patch); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
126
+ }
127
+ if (/^\/api\/task\/[^/]+\/comment$/.test(p) && req.method === 'POST') {
128
+ const id = decodeURIComponent(p.split('/')[3] || ''); const { text, action } = await body(req);
129
+ if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty comment.' }); return true; }
130
+ const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
131
+ store.addTaskComment(root, file, id, String(text).trim(), 'me');
132
+ if (action === 'analyze') store.updateTaskLine(root, file, id, { status: 'to_analyze' });
133
+ emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
134
+ }
135
+ if (p === '/api/workflow/toggle' && req.method === 'POST') {
136
+ const { name } = await body(req); const wf = path.join(root, '.spectoflow', 'workflow.md');
137
+ const lines = fs.readFileSync(wf, 'utf8').split('\n');
138
+ for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
139
+ if (m && m[4].replace(/\s*\(optional\)\s*$/i, '').trim() === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4]; }
140
+ fs.writeFileSync(wf, lines.join('\n')); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
141
+ }
142
+
143
+ if (p === '/api/run' && req.method === 'POST') {
144
+ const { prompt, agent } = await body(req);
145
+ if (!prompt || !String(prompt).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
146
+ const r = startRun(root, { prompt, agent }, emit);
147
+ if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
148
+ sendJSON(res, 200, { runId: r.runId }); return true;
149
+ }
150
+
151
+ if (p === '/api/chat/summarize' && req.method === 'POST') {
152
+ const { agent } = await body(req);
153
+ const r = runSummarize(root, { agent }, emit);
154
+ if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
155
+ sendJSON(res, 200, { ok: true }); return true;
156
+ }
157
+ if (p === '/api/chat/clear' && req.method === 'POST') {
158
+ const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
159
+ emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
160
+ }
161
+
162
+ if (p === '/api/orchestrate' && req.method === 'POST') {
163
+ const { request } = await body(req);
164
+ if (!request || !String(request).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
165
+ const active = store.readRuntime(root).orchestration;
166
+ if (active && ['running', 'awaiting_approval'].includes(active.status))
167
+ { sendJSON(res, 409, { error: 'An orchestration is already active.' }); return true; }
168
+ const mode = store.readConfig(root).mode || 'semi';
169
+ orchestrator.runOrchestration({ root, request: String(request).trim(), mode,
170
+ runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
171
+ .catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
172
+ const o = store.readRuntime(root).orchestration;
173
+ sendJSON(res, 200, { orchestrationId: o && o.id }); return true;
174
+ }
175
+ if (p === '/api/orchestrate/approve' && req.method === 'POST') {
176
+ const { decision, note } = await body(req);
177
+ const ok = orchestrator.submitDecision(decision, note);
178
+ sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' }); return true;
179
+ }
180
+
181
+ if (p === '/api/settings' && req.method === 'POST') {
182
+ const patch = await body(req);
183
+ try { const cfg = writeConfig(patch); emit({ type: 'change' }); sendJSON(res, 200, { config: cfg }); }
184
+ catch (e) { sendJSON(res, 400, { error: String(e && e.message || e) }); }
185
+ return true;
186
+ }
187
+
188
+ if (p === '/api/attention' && req.method === 'POST') {
189
+ const { text } = await body(req);
190
+ if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty note.' }); return true; }
191
+ const rt = store.readRuntime(root); rt.attention = rt.attention || [];
192
+ const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
193
+ rt.attention.unshift(item); store.writeRuntime(root, rt); emit({ type: 'change' });
194
+ sendJSON(res, 200, { item }); return true;
195
+ }
196
+ if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
197
+ const id = decodeURIComponent(p.split('/')[3] || '');
198
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
199
+ if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
200
+ const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
201
+ store.writeRuntime(root, rt); emit({ type: 'change' });
202
+ sendJSON(res, 200, { task: t }); return true;
203
+ }
204
+ if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
205
+ const id = decodeURIComponent(p.split('/')[3] || '');
206
+ const patch = await body(req);
207
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
208
+ if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
209
+ if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
210
+ if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
211
+ store.writeRuntime(root, rt); emit({ type: 'change' });
212
+ sendJSON(res, 200, { item: it }); return true;
213
+ }
214
+ if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
215
+ const id = decodeURIComponent(p.split('/')[3] || '');
216
+ const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
217
+ store.writeRuntime(root, rt); emit({ type: 'change' });
218
+ sendJSON(res, 200, { ok: true }); return true;
219
+ }
220
+
221
+ return false;
222
+ }
223
+
224
+ function onBoot() {
225
+ // A project that hasn't used Customize yet won't have this dir on disk, and `spectoflow init` on
226
+ // an older install won't have created it either.
227
+ try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboard', 'custom'), { recursive: true }); } catch (_) {}
228
+ // A process restart loses any in-flight orchestration; without this, a stale 'running' or
229
+ // 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real resume —
230
+ // just clears the wedge so a fresh orchestration can start.
231
+ try { orchestrator.reconcileOnBoot(root); } catch (_) {}
232
+ }
233
+
234
+ return {
235
+ handleApi,
236
+ watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboard/custom'],
237
+ onBoot,
238
+ };
239
+ }
240
+
241
+ module.exports = { createHandlers };
@@ -338,9 +338,12 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
338
338
  line up; the backdrop never scrolls on its own (overflow:hidden), its scroll position is just
339
339
  copied from the textarea on every scroll event. */
340
340
  .files-code-wrap { position:relative; flex:1; min-height:0; }
341
- .files-code-backdrop { position:absolute; inset:0; margin:0; overflow:hidden; pointer-events:none; white-space:pre-wrap; word-break:break-word; padding:16px 20px; font-family:var(--mono); font-size:12.5px; line-height:1.6; color:var(--ink); background:transparent; }
341
+ /* Ligatures (calt/liga e.g. "===", "!==", "=>" fused into one glyph, common in Cascadia Code and
342
+ JetBrains Mono) must be off on BOTH layers: this overlay depends on the backdrop and the textarea
343
+ laying out every character at an identical pixel width, and a fused ligature glyph breaks that. */
344
+ .files-code-backdrop { position:absolute; inset:0; margin:0; overflow:hidden; pointer-events:none; white-space:pre-wrap; word-break:break-word; padding:16px 20px; font-family:var(--mono); font-size:12.5px; line-height:1.6; color:var(--ink); background:transparent; font-variant-ligatures:none; font-feature-settings:"liga" 0,"calt" 0; }
342
345
  .files-code-backdrop code { font:inherit; background:none; }
343
- .files-code-wrap .files-code-input { position:absolute; inset:0; background:transparent; color:transparent; caret-color:var(--ink); white-space:pre-wrap; word-break:break-word; }
346
+ .files-code-wrap .files-code-input { position:absolute; inset:0; background:transparent; color:transparent; caret-color:var(--ink); white-space:pre-wrap; word-break:break-word; font-variant-ligatures:none; font-feature-settings:"liga" 0,"calt" 0; }
344
347
  .hl-comment { color:var(--faint); font-style:italic; }
345
348
  .hl-string { color:var(--s-done); }
346
349
  .hl-number { color:var(--cool); }
@@ -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)