spectoflow 0.22.5 → 0.23.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/bin/spectoflow.js CHANGED
@@ -10,6 +10,7 @@ const detect = require('../lib/detect');
10
10
  const ownership = require('../lib/ownership');
11
11
  const manifest = require('../lib/manifest');
12
12
  const registry = require('../lib/registry');
13
+ const initLib = require('../lib/init');
13
14
  const mcp = require('../lib/mcp');
14
15
  const { startRun } = require('../templates/dashboard/runner');
15
16
  const { buildCustomizePrompt } = require('../templates/lib/customize-prompts');
@@ -94,125 +95,16 @@ function probeDashboard(port, timeoutMs = 500) {
94
95
  });
95
96
  }
96
97
 
97
- function copyDir(src, dst) {
98
- fs.mkdirSync(dst, { recursive: true });
99
- for (const e of fs.readdirSync(src, { withFileTypes: true })) {
100
- const s = path.join(src, e.name), d = path.join(dst, e.name);
101
- if (e.isDirectory()) copyDir(s, d);
102
- else if (!fs.existsSync(d)) fs.copyFileSync(s, d);
103
- }
104
- }
105
-
106
- // Existing project: give id-less checkbox tasks a stable id, in place.
107
- const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
108
- function normalizePlans(root, config) {
109
- const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
110
- const dir = path.join(root, dirName);
111
- if (!fs.existsSync(dir)) return 0;
112
- let added = 0, seq = 1;
113
- for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
114
- const fp = path.join(dir, f);
115
- const lines = fs.readFileSync(fp, 'utf8').split('\n');
116
- let touched = false;
117
- for (let i = 0; i < lines.length; i++) {
118
- const m = lines[i].match(/^(\s*- \[[ xX]\]\s+)(\S+)(\s.*)?$/);
119
- if (m && !ID_RE.test(m[2])) {
120
- const id = 'T-' + String(seq++).padStart(3, '0');
121
- lines[i] = `${m[1]}${id} ${m[2]}${m[3] || ''}`;
122
- touched = true; added++;
123
- } else if (m) { seq++; }
124
- }
125
- if (touched) fs.writeFileSync(fp, lines.join('\n'));
126
- }
127
- return added;
128
- }
129
-
130
98
  function init() {
131
99
  const target = path.resolve(argv[1] && !argv[1].startsWith('--') ? argv[1] : '.');
132
100
  const agentsArg = (argv.find((a) => a.startsWith('--agent=')) || '').split('=')[1];
133
- fs.mkdirSync(target, { recursive: true });
134
- const notes = [];
135
-
136
- // explicit --agent wins; otherwise detect installed agents; otherwise fall back to claude + codex
137
- let agents, detected = [];
138
- if (agentsArg) {
139
- agents = agentsArg.split(',');
140
- } else {
141
- detected = detect.detectAgents(target);
142
- agents = detected.length ? detected : ['claude', 'codex'];
143
- notes.push(detected.length
144
- ? `Detected agent(s): ${detected.join(', ')} — active: ${agents[0]}.`
145
- : 'No agent CLI detected — defaulted to claude + codex.');
146
- }
147
-
148
- // preserve an existing CLAUDE.md
149
- const claude = path.join(target, 'CLAUDE.md');
150
- if (fs.existsSync(claude) && !fs.existsSync(claude + '.tomerge')) {
151
- fs.renameSync(claude, claude + '.tomerge');
152
- notes.push('Existing CLAUDE.md preserved as CLAUDE.md.tomerge — your agent merges it on first run.');
153
- }
154
-
155
- // canonical framework → .spectoflow/
156
- const spectoflowDir = path.join(target, '.spectoflow');
157
- copyDir(TPL, spectoflowDir);
158
-
159
- // record the install baseline so `update` can tell untouched framework files from user edits
160
- const frameworkFiles = ownership.listFrameworkFiles(TPL);
161
- manifest.writeManifest(spectoflowDir, {
162
- version: VERSION,
163
- files: manifest.hashFileMap(spectoflowDir, frameworkFiles),
164
- });
165
-
166
- // set the active agent and seed runner commands from the selected/detected agents
167
- const cfgPath = path.join(spectoflowDir, 'config.json');
168
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
169
- cfg.agent = agents[0];
170
- cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
171
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
172
-
173
- // artifact folders — reuse an existing differently-named folder (e.g. a project that already
174
- // keeps its plans in `plan/`, singular) instead of always forcing the plans/specs convention;
175
- // mkdir is a no-op when the resolved folder already exists.
176
- const plansDirName = store.resolvePlansDir(target, cfg);
177
- const specsDirName = store.resolveSpecsDir(target, cfg);
178
- fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
179
- fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
180
- if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
181
- if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
182
-
183
- // existing project: id-normalize any plans already there
184
- const added = normalizePlans(target, cfg);
185
- if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
186
-
187
- // per-agent shims
188
- const written = adapters.generate(target, agents);
189
-
190
- // wire Playwright MCP into the project's MCP config so the E2E agent can drive a real browser and
191
- // generate/run Playwright tests. Idempotent + non-destructive: never touches an existing entry.
192
- // npx fetches the server on first use, so this config IS the whole install — spectoflow stays
193
- // zero-dep (this writes into the user's project, never into spectoflow).
194
- const mcpTargets = [path.join(target, '.mcp.json')];
195
- if (agents.includes('cursor')) mcpTargets.push(path.join(target, '.cursor', 'mcp.json'));
196
- for (const fp of mcpTargets) {
197
- const rel = path.relative(target, fp).split(path.sep).join('/');
198
- const r = mcp.mergeMcpServer(fp, 'playwright', mcp.PLAYWRIGHT_MCP);
199
- if (r === 'created' || r === 'added') notes.push(`Wired Playwright MCP into ${rel} (npx @playwright/mcp — for the E2E agent; commit it to share).`);
200
- else if (r === 'skipped') notes.push(`Left ${rel} as-is (couldn't parse it) — add a 'playwright' MCP server yourself for browser-driven E2E.`);
201
- }
202
-
203
- // gitignore the volatile runtime
204
- const gi = path.join(target, '.gitignore');
205
- const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
206
- for (const line of ['.spectoflow/runtime.json', '.spectoflow/.dashboard.lock']) {
207
- if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
208
- }
209
-
101
+ const r = initLib.runInit({ target, templatesDir: TPL, version: VERSION, agentsArg });
210
102
  console.log(logo());
211
- console.log(`${c.g('✓')} installed in ${c.bold(target)}`);
103
+ console.log(`${c.g('✓')} installed in ${c.bold(r.target)}`);
212
104
  console.log(` ${c.dim('.spectoflow/')} framework — brain, workflow, agents, skills, policy, dashboard, config`);
213
105
  console.log(` ${c.dim('specs/ plans/')} markdown artifacts (your source of truth)`);
214
- written.forEach((w) => console.log(` ${c.cy('+')} ${w}`));
215
- notes.forEach((n) => console.log(` ${c.y('!')} ${c.dim(n)}`));
106
+ r.written.forEach((w) => console.log(` ${c.cy('+')} ${w}`));
107
+ r.notes.forEach((n) => console.log(` ${c.y('!')} ${c.dim(n)}`));
216
108
  const port = resolvePort(argv);
217
109
  console.log(`\n${c.bold('Next')}`);
218
110
  console.log(` ${c.dim('1)')} Open your agent here — or just say what you want to build.`);
@@ -254,21 +146,27 @@ async function update() {
254
146
  if (r.newSidecar.length && !dryRun) console.log(` ${c.y('→')} ${c.dim(`${r.newSidecar.length} *.new file(s) to review and merge — or re-run: spectoflow update --force`)}`);
255
147
  console.log('');
256
148
 
257
- // A running dashboard has the OLD framework code loaded into memory (Node caches `require()`d
258
- // modules at process start) — new bytes on disk change nothing until it restarts. Do that
259
- // automatically so an update always actually takes effect, instead of leaving a confusing
260
- // half-updated dashboard (new static files, stale server logic) until someone thinks to restart.
149
+ // A running hub has the OLD framework code loaded into memory (Node caches `require()`d modules
150
+ // per project on first open) — new bytes on disk change nothing until that project's cached code
151
+ // is invalidated. Do that via a surgical per-project reload so an update always actually takes
152
+ // effect, without restarting the whole hub (which would disturb every other project open in it).
261
153
  if (!dryRun && changed) {
262
- const lock = path.join(root, '.spectoflow', '.dashboard.lock');
154
+ const lockPath = registry.hubLockPath();
263
155
  let info = null;
264
- try { info = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
156
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
265
157
  if (info && info.port && (await probeDashboard(info.port, 2000))) {
266
- console.log(` ${c.dim('Dashboard is running — restarting it on port ' + info.port + ' to apply the update…')}`);
267
- // Restart on the SAME port it was already on, not resolvePort(argv)'s default — `update`
268
- // itself was never given a --port, so a naive restartDashboard() would silently move a
269
- // non-default-port dashboard back to 4319.
270
- argv.push(`--port=${info.port}`);
271
- await restartDashboard();
158
+ const entry = registry.findByPath(root);
159
+ if (entry) {
160
+ try {
161
+ const res = await fetch(`http://localhost:${info.port}/api/hub/reload/${entry.id}`, { method: 'POST' });
162
+ const body = await res.json().catch(() => ({}));
163
+ console.log(` ${c.dim(body.reloaded
164
+ ? 'Hub is running — reloaded this project\'s server code (other open projects unaffected).'
165
+ : 'Hub is running, but this project wasn\'t loaded in it yet — nothing to reload.')}`);
166
+ } catch {
167
+ console.log(` ${c.y('!')} Hub is running on port ${info.port} but the reload request failed — restart it yourself if changes don't seem to take effect: ${c.g('spectoflow dashboard restart')}`);
168
+ }
169
+ }
272
170
  }
273
171
  }
274
172
  }
@@ -358,26 +256,31 @@ async function runCustomize(kind) {
358
256
  process.exitCode = code;
359
257
  }
360
258
 
361
- // Start in the background and return control. Probes first so a second start just reports the running
362
- // one instead of spawning a duplicate (and never crashes on EADDRINUSE).
259
+ // Start in the background and return control. Registers (or touches) the current folder in the
260
+ // global registry first, then either joins an already-running hub or spawns a new one probing first
261
+ // so a second start just reports the running one instead of spawning a duplicate.
363
262
  async function startDashboard() {
364
- const port = resolvePort(argv);
365
- const url = `http://localhost:${port}`;
366
- if (await probeDashboard(port)) {
367
- console.log(`${c.g('●')} dashboard already running → ${c.bold(url)}`);
263
+ const root = process.cwd();
264
+ const entry = registry.addProject(root);
265
+ const boardUrl = (p) => `http://localhost:${p}/p/${entry.id}/board`;
266
+ const lockPath = registry.hubLockPath();
267
+ let info = null;
268
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
269
+ if (info && info.port && await probeDashboard(info.port)) {
270
+ console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
368
271
  return printDashboardCommands();
369
272
  }
370
- const local = path.resolve('.spectoflow', 'dashboard', 'server.js');
371
- const bundled = path.join(TPL, 'dashboard', 'server.js');
273
+ const port = resolvePort(argv);
274
+ const hubPath = path.join(KIT, 'lib', 'hub-server.js');
372
275
  const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
373
- const child = spawn('node', [fs.existsSync(local) ? local : bundled], { detached: true, stdio: 'ignore', env });
374
- child.unref(); // let this CLI exit while the server keeps running
276
+ const child = spawn('node', [hubPath], { detached: true, stdio: 'ignore', env });
277
+ child.unref(); // let this CLI exit while the hub keeps running
375
278
  // Confirm it actually came up (a still-releasing port from a just-stopped instance, or any other
376
279
  // startup error, would otherwise print a false "started" while the detached process silently died).
377
280
  let up = false;
378
281
  for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
379
- if (up) console.log(`${c.g('✓')} dashboard started → ${c.bold(url)} ${c.dim('(pid ' + child.pid + ')')}`);
380
- else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on ${url} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
282
+ if (up) console.log(`${c.g('✓')} hub started → ${c.bold(boardUrl(port))} ${c.dim('(pid ' + child.pid + ')')}`);
283
+ else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on http://localhost:${port} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
381
284
  printDashboardCommands();
382
285
  }
383
286
 
@@ -390,12 +293,13 @@ function printDashboardCommands() {
390
293
  }
391
294
 
392
295
  async function dashboardStatus() {
393
- const port = resolvePort(argv);
296
+ const lockPath = registry.hubLockPath();
297
+ let info = null;
298
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
299
+ const port = (info && info.port) || resolvePort(argv);
394
300
  const running = await probeDashboard(port);
395
- let pid = null;
396
- try { pid = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.spectoflow', '.dashboard.lock'), 'utf8')).pid; } catch {}
397
- if (running) console.log(`${c.g('●')} dashboard running → ${c.bold('http://localhost:' + port)}${pid ? c.dim(' (pid ' + pid + ')') : ''}`);
398
- else console.log(`${c.dim('○')} dashboard not running`);
301
+ if (running) console.log(`${c.g('●')} hub running → ${c.bold('http://localhost:' + port)}${info && info.pid ? c.dim(' (pid ' + info.pid + ')') : ''}`);
302
+ else console.log(`${c.dim('')} hub not running`);
399
303
  }
400
304
 
401
305
  async function restartDashboard() {
@@ -408,28 +312,27 @@ async function restartDashboard() {
408
312
  return startDashboard();
409
313
  }
410
314
 
411
- // Stop the running dashboard: read the pidfile it wrote, verify it's actually up, then terminate it
315
+ // Stop the running hub: read the global lock it wrote, verify it's actually up, then terminate it
412
316
  // and clear the lock. Safe against a stale lock (a recycled pid) because it only kills when the port
413
317
  // still responds.
414
318
  async function stopDashboard() {
415
- const root = process.cwd();
416
- const lock = path.join(root, '.spectoflow', '.dashboard.lock');
319
+ const lockPath = registry.hubLockPath();
417
320
  let info = null;
418
- try { info = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
321
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
419
322
  const port = (info && info.port) || resolvePort(argv);
420
323
  const running = await probeDashboard(port);
421
324
  if (!running) {
422
- if (info) { try { fs.unlinkSync(lock); } catch {} } // stale lock
423
- return console.log('No spectoflow dashboard is running.');
325
+ if (info) { try { fs.unlinkSync(lockPath); } catch {} } // stale lock
326
+ return console.log('No spectoflow hub is running.');
424
327
  }
425
328
  if (info && info.pid) {
426
329
  try {
427
- process.kill(info.pid); // SIGTERM → server clears its own lock (POSIX)
428
- try { fs.unlinkSync(lock); } catch {} // and we clear it too (Windows has no real signals)
429
- return console.log(`spectoflow dashboard stopped (pid ${info.pid}, was on http://localhost:${port}).`);
330
+ process.kill(info.pid); // SIGTERM → hub clears its own lock (POSIX)
331
+ try { fs.unlinkSync(lockPath); } catch {} // and we clear it too (Windows has no real signals)
332
+ return console.log(`spectoflow hub stopped (pid ${info.pid}, was on http://localhost:${port}).`);
430
333
  } catch {}
431
334
  }
432
- console.log(`A dashboard is responding on http://localhost:${port} but isn't stoppable via the lock file — stop it where you launched it (Ctrl+C).`);
335
+ console.log(`A hub is responding on http://localhost:${port} but isn't stoppable via the lock file — stop it where you launched it (Ctrl+C).`);
433
336
  }
434
337
 
435
338
  async function status() {
@@ -445,7 +348,10 @@ async function status() {
445
348
  console.log(`${(p.config && p.config.projectType) || 'project'} — mode ${p.config.mode} · lang ${p.config.language}`);
446
349
  console.log(`${done}/${tasks.length} tasks done · ${p.specs.length} spec(s) · ${p.agents.length} agents · ${p.skills.length} skills`);
447
350
  tasks.filter((t) => t.status === 'in_progress').forEach((t) => console.log(` > in progress: ${t.id} ${t.title}`));
448
- const port = resolvePort(argv);
351
+ const lockPath = registry.hubLockPath();
352
+ let info = null;
353
+ try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
354
+ const port = (info && info.port) || resolvePort(argv);
449
355
  const running = await probeDashboard(port);
450
356
  console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
451
357
  }
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+ /*
3
+ * The multi-project hub's server process — global (ships under lib/, never vendored into a
4
+ * project's .spectoflow/). Registry-driven: resolves a project's root + route logic on demand from
5
+ * ~/.spectoflow/projects.json (see lib/registry.js), keyed by the opaque id in /p/<id>/... URLs.
6
+ * Each project's own vendored handlers.js is require()'d dynamically by absolute path — Node's
7
+ * require cache keys by resolved path, so two different projects' identically-named handlers.js
8
+ * files are cached and run completely independently (see docs/multi-project-hub-design.md).
9
+ *
10
+ * URL scheme (settled in the design doc): pages use a path prefix (/p/<id>/board, bookmarkable on
11
+ * their own); every /api/* call instead takes a ?p=<id> query param (smaller client diff, and
12
+ * /api/events already needed a query-param shape for its per-project SSE subscription either way).
13
+ * A legacy no-prefix route (e.g. /board, a bookmark from before the hub existed) 302s to the
14
+ * most-recently-opened project, or to the hub root if none are registered yet.
15
+ *
16
+ * GET / serves hub.html — the landing page listing every registered project, with an "+ Add project"
17
+ * flow (browse the filesystem server-side, or paste a path; either way auto-inits a plain folder) —
18
+ * see /api/hub/* below and docs/multi-project-hub-design.md §3bis.
19
+ */
20
+ const http = require('http');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const registry = require('./registry');
25
+
26
+ const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
27
+ const PUBLIC = path.join(__dirname, '..', 'templates', 'dashboard', 'public');
28
+ const TEMPLATES = path.join(__dirname, '..', 'templates');
29
+ const VERSION = require('../package.json').version;
30
+ 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' };
31
+ function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
32
+ 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({}); } }); }); }
33
+
34
+ // id -> { id, root, handlers, clients:Set, emit }, populated lazily on first request for that id and
35
+ // kept alive for the process's life (registries are small; no need to tear down on last-tab-close).
36
+ const projects = new Map();
37
+ function getProject(id) {
38
+ if (projects.has(id)) return projects.get(id);
39
+ const entry = registry.listProjects().find((p) => p.id === id);
40
+ if (!entry) return null;
41
+ const handlersPath = path.join(entry.path, '.spectoflow', 'dashboard', 'handlers.js');
42
+ let createHandlers;
43
+ try { ({ createHandlers } = require(handlersPath)); }
44
+ catch { return null; } // project's folder moved/deleted, or predates the handlers.js split
45
+ const handlers = createHandlers(entry.path);
46
+ const clients = new Set();
47
+ const emit = (obj) => { const line = 'data: ' + JSON.stringify(obj) + '\n\n'; for (const res of clients) res.write(line); };
48
+ handlers.onBoot();
49
+ handlers.watchDirs.forEach((d) => {
50
+ const dir = path.join(entry.path, d);
51
+ if (fs.existsSync(dir)) { try { fs.watch(dir, { recursive: false }, () => emit({ type: 'change' })); } catch (_) {} }
52
+ });
53
+ const proj = { id, root: entry.path, handlers, clients, emit };
54
+ projects.set(id, proj);
55
+ return proj;
56
+ }
57
+
58
+ // Only called after getProject(id) already returned null — figures out WHY, so the 404 points the
59
+ // user at the right fix instead of a bare "unknown project". The two common real causes: never
60
+ // registered at all, vs. registered but this project predates handlers.js (needs `spectoflow
61
+ // update`) or its folder moved/was deleted.
62
+ function projectErrorMessage(id) {
63
+ const entry = registry.listProjects().find((p) => p.id === id);
64
+ if (!entry) return 'Unknown project.';
65
+ if (!fs.existsSync(entry.path)) return `Project "${entry.name}" is registered, but its folder no longer exists at ${entry.path}.`;
66
+ const handlersPath = path.join(entry.path, '.spectoflow', 'dashboard', 'handlers.js');
67
+ if (!fs.existsSync(handlersPath)) return `Project "${entry.name}" needs an update — run \`spectoflow update\` inside it, then reload this page.`;
68
+ return `Project "${entry.name}" is registered, but its dashboard code failed to load — check its .spectoflow/dashboard/handlers.js for errors.`;
69
+ }
70
+
71
+ // Clears every require.cache entry under this project's own .spectoflow/ tree (its vendored
72
+ // handlers.js and everything IT requires — orchestrator.js, runner.js, files.js, summarize.js,
73
+ // lib/store.js, lib/agents-registry.js) and drops its cached Map entry. The require cache keys by
74
+ // absolute path, so this can never touch another project's identically-named files. Returns false
75
+ // (a harmless no-op, not an error) if this id was never loaded — nothing to invalidate.
76
+ function reloadProject(id) {
77
+ const proj = projects.get(id);
78
+ if (!proj) return false;
79
+ const prefix = path.join(proj.root, '.spectoflow') + path.sep;
80
+ for (const key of Object.keys(require.cache)) {
81
+ if (key.startsWith(prefix)) delete require.cache[key];
82
+ }
83
+ projects.delete(id);
84
+ return true;
85
+ }
86
+
87
+ // ---- hub API: list/add/remove registered projects, browse the filesystem to find one ----
88
+ function projectStats(root) {
89
+ // Best-effort — a moved/deleted/corrupt project must never break the whole listing.
90
+ try {
91
+ const store = require(path.join(root, '.spectoflow', 'lib', 'store.js'));
92
+ const plans = store.readPlans(root);
93
+ let total = 0, done = 0;
94
+ for (const pl of plans) for (const ph of pl.phases) for (const t of ph.tasks) { total++; if (t.status === 'done') done++; }
95
+ return { total, done };
96
+ } catch { return null; }
97
+ }
98
+ function listHubProjects() {
99
+ return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path) }));
100
+ }
101
+ function listRoots() {
102
+ const home = os.homedir();
103
+ const roots = [{ name: path.basename(home) || home, path: home }];
104
+ if (process.platform === 'win32') {
105
+ for (const code of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
106
+ const drive = `${code}:\\`;
107
+ if (fs.existsSync(drive)) roots.push({ name: drive, path: drive });
108
+ }
109
+ } else if (home !== '/') {
110
+ roots.push({ name: '/', path: '/' });
111
+ }
112
+ return roots;
113
+ }
114
+ function browseDirs(reqPath) {
115
+ if (!reqPath) return { entries: listRoots(), parent: null, current: null };
116
+ let abs;
117
+ try { abs = path.resolve(reqPath); } catch { return { error: 'Invalid path.' }; }
118
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return { error: 'Not a folder.' };
119
+ let names;
120
+ try { names = fs.readdirSync(abs, { withFileTypes: true }); } catch { return { error: 'Cannot read this folder.' }; }
121
+ const entries = names.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
122
+ .map((e) => ({ name: e.name, path: path.join(abs, e.name) }))
123
+ .sort((a, b) => a.name.localeCompare(b.name));
124
+ const parent = path.dirname(abs) !== abs ? path.dirname(abs) : null;
125
+ return { entries, parent, current: abs };
126
+ }
127
+ function addHubProject(rawPath) {
128
+ let abs;
129
+ try { abs = path.resolve(rawPath); } catch { return { error: 'Invalid path.' }; }
130
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return { error: 'That folder does not exist.' };
131
+ const hasSpectoflow = fs.existsSync(path.join(abs, '.spectoflow'));
132
+ if (!hasSpectoflow) {
133
+ const { runInit } = require('./init');
134
+ runInit({ target: abs, templatesDir: TEMPLATES, version: VERSION });
135
+ }
136
+ const entry = registry.addProject(abs);
137
+ return { entry, initialized: !hasSpectoflow };
138
+ }
139
+ async function handleHubApi(req, res, u) {
140
+ const p = u.pathname;
141
+ if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects() }); return true; }
142
+ if (p === '/api/hub/projects' && req.method === 'POST') {
143
+ const { path: rawPath } = await body(req);
144
+ if (!rawPath || !String(rawPath).trim()) { sendJSON(res, 400, { error: 'A folder path is required.' }); return true; }
145
+ const r = addHubProject(String(rawPath).trim());
146
+ if (r.error) { sendJSON(res, 400, r); return true; }
147
+ sendJSON(res, 200, r); return true;
148
+ }
149
+ if (/^\/api\/hub\/projects\/[^/]+$/.test(p) && req.method === 'DELETE') {
150
+ const id = decodeURIComponent(p.split('/')[4] || '');
151
+ const ok = registry.removeProject(id);
152
+ sendJSON(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'No project registered with that id.' });
153
+ return true;
154
+ }
155
+ if (p === '/api/hub/browse' && req.method === 'GET') {
156
+ const reqPath = u.searchParams.get('path') || '';
157
+ const r = browseDirs(reqPath);
158
+ sendJSON(res, r.error ? 400 : 200, r);
159
+ return true;
160
+ }
161
+ if (/^\/api\/hub\/reload\/[^/]+$/.test(p) && req.method === 'POST') {
162
+ const id = decodeURIComponent(p.split('/')[4] || '');
163
+ const reloaded = reloadProject(id);
164
+ sendJSON(res, 200, { ok: true, reloaded });
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ // Serves one static asset (or the SPA index.html fallback for an extensionless path) from the
171
+ // shared, globally-installed PUBLIC dir — identical logic to templates/dashboard/server.js's own,
172
+ // just factored into a function since both the root-level and /p/<id>/-prefixed requests need it.
173
+ function serveStatic(reqPath, req, res) {
174
+ const file = reqPath === '/' ? '/index.html' : reqPath;
175
+ const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
176
+ if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('Forbidden'); }
177
+ const noCache = { 'Cache-Control': 'no-store, must-revalidate' };
178
+ fs.readFile(full, (err, data) => {
179
+ if (err) {
180
+ if (req.method === 'GET' && !path.extname(reqPath)) {
181
+ return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
182
+ if (e2) { res.writeHead(404); return res.end('Not found'); }
183
+ res.writeHead(200, Object.assign({ 'Content-Type': MIME['.html'] }, noCache)); res.end(d2);
184
+ });
185
+ }
186
+ res.writeHead(404); return res.end('Not found');
187
+ }
188
+ const ext = path.extname(full);
189
+ const headers = ext === '.woff2' || ext === '.woff' ? { 'Cache-Control': 'public, max-age=604800' } : noCache;
190
+ res.writeHead(200, Object.assign({ 'Content-Type': MIME[ext] || 'application/octet-stream' }, headers)); res.end(data);
191
+ });
192
+ }
193
+
194
+ const PROJECT_PREFIX = /^\/p\/([0-9a-f]{6})(\/.*)?$/;
195
+
196
+ const LOCK = registry.hubLockPath();
197
+ function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
198
+ function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
199
+ process.on('exit', clearLock);
200
+ ['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
201
+
202
+ const server = http.createServer(async (req, res) => {
203
+ const u = new URL(req.url, `http://localhost:${PORT}`);
204
+ const p = u.pathname;
205
+ try {
206
+ const m = p.match(PROJECT_PREFIX);
207
+
208
+ if (p === '/api/events') {
209
+ const id = u.searchParams.get('p');
210
+ const proj = id && getProject(id);
211
+ if (!proj) return sendJSON(res, 404, { error: projectErrorMessage(id) });
212
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
213
+ res.write('data: ' + JSON.stringify({ type: 'hello' }) + '\n\n');
214
+ proj.clients.add(res); req.on('close', () => proj.clients.delete(res));
215
+ return;
216
+ }
217
+
218
+ if (p.startsWith('/api/hub/')) {
219
+ const handled = await handleHubApi(req, res, u);
220
+ if (handled) return;
221
+ res.writeHead(404); return res.end('Not found');
222
+ }
223
+
224
+ if (p.startsWith('/api/')) {
225
+ const id = u.searchParams.get('p');
226
+ const proj = id && getProject(id);
227
+ if (!proj) return sendJSON(res, 404, { error: projectErrorMessage(id) });
228
+ const handled = await proj.handlers.handleApi(req, res, u, proj.emit);
229
+ if (handled) return;
230
+ res.writeHead(404); return res.end('Not found');
231
+ }
232
+
233
+ if (m) {
234
+ const id = m[1];
235
+ const proj = getProject(id);
236
+ if (!proj) { res.writeHead(404); return res.end(projectErrorMessage(id)); }
237
+ registry.touchProject(id);
238
+ return serveStatic(m[2] || '/', req, res);
239
+ }
240
+
241
+ if (p === '/') {
242
+ return serveStatic('/hub.html', req, res);
243
+ }
244
+
245
+ if (!path.extname(p)) {
246
+ // legacy no-prefix bookmark (e.g. /board from before the hub existed)
247
+ const rows = registry.listProjects();
248
+ const dest = rows.length ? `/p/${rows[0].id}/board` : '/';
249
+ res.writeHead(302, { Location: dest }); return res.end();
250
+ }
251
+
252
+ // a real static asset (styles.css, app.js, fonts) requested without a /p/<id> prefix — every
253
+ // page's own asset links are root-absolute, so this is the common case for every page load.
254
+ return serveStatic(p, req, res);
255
+ } catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
256
+ });
257
+
258
+ server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}`); });
package/lib/init.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ /*
3
+ * Scaffolds .spectoflow/ into a target folder — the logic behind `spectoflow init`, extracted from
4
+ * bin/spectoflow.js so server code (the hub's Add Project auto-init step, sub-project 4) can call it
5
+ * too, without any CLI argv/console.log coupling. bin/spectoflow.js's init() is now a thin wrapper:
6
+ * parse argv, call runInit(), print the result.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const detect = require('./detect');
11
+ const adapters = require('./adapters');
12
+ const ownership = require('./ownership');
13
+ const manifest = require('./manifest');
14
+ const mcp = require('./mcp');
15
+ const store = require('../templates/lib/store');
16
+
17
+ function copyDir(src, dst) {
18
+ fs.mkdirSync(dst, { recursive: true });
19
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
20
+ const s = path.join(src, e.name), d = path.join(dst, e.name);
21
+ if (e.isDirectory()) copyDir(s, d);
22
+ else if (!fs.existsSync(d)) fs.copyFileSync(s, d);
23
+ }
24
+ }
25
+
26
+ // Existing project: give id-less checkbox tasks a stable id, in place.
27
+ const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
28
+ function normalizePlans(root, config) {
29
+ const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
30
+ const dir = path.join(root, dirName);
31
+ if (!fs.existsSync(dir)) return 0;
32
+ let added = 0, seq = 1;
33
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
34
+ const fp = path.join(dir, f);
35
+ const lines = fs.readFileSync(fp, 'utf8').split('\n');
36
+ let touched = false;
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const m = lines[i].match(/^(\s*- \[[ xX]\]\s+)(\S+)(\s.*)?$/);
39
+ if (m && !ID_RE.test(m[2])) {
40
+ const id = 'T-' + String(seq++).padStart(3, '0');
41
+ lines[i] = `${m[1]}${id} ${m[2]}${m[3] || ''}`;
42
+ touched = true; added++;
43
+ } else if (m) { seq++; }
44
+ }
45
+ if (touched) fs.writeFileSync(fp, lines.join('\n'));
46
+ }
47
+ return added;
48
+ }
49
+
50
+ function runInit({ target, templatesDir, version, agentsArg }) {
51
+ fs.mkdirSync(target, { recursive: true });
52
+ const notes = [];
53
+
54
+ let agents, detected = [];
55
+ if (agentsArg) {
56
+ agents = agentsArg.split(',');
57
+ } else {
58
+ detected = detect.detectAgents(target);
59
+ agents = detected.length ? detected : ['claude', 'codex'];
60
+ notes.push(detected.length
61
+ ? `Detected agent(s): ${detected.join(', ')} — active: ${agents[0]}.`
62
+ : 'No agent CLI detected — defaulted to claude + codex.');
63
+ }
64
+
65
+ const claude = path.join(target, 'CLAUDE.md');
66
+ if (fs.existsSync(claude) && !fs.existsSync(claude + '.tomerge')) {
67
+ fs.renameSync(claude, claude + '.tomerge');
68
+ notes.push('Existing CLAUDE.md preserved as CLAUDE.md.tomerge — your agent merges it on first run.');
69
+ }
70
+
71
+ const spectoflowDir = path.join(target, '.spectoflow');
72
+ copyDir(templatesDir, spectoflowDir);
73
+
74
+ const frameworkFiles = ownership.listFrameworkFiles(templatesDir);
75
+ manifest.writeManifest(spectoflowDir, {
76
+ version,
77
+ files: manifest.hashFileMap(spectoflowDir, frameworkFiles),
78
+ });
79
+
80
+ const cfgPath = path.join(spectoflowDir, 'config.json');
81
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
82
+ cfg.agent = agents[0];
83
+ cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
84
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
85
+
86
+ const plansDirName = store.resolvePlansDir(target, cfg);
87
+ const specsDirName = store.resolveSpecsDir(target, cfg);
88
+ fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
89
+ fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
90
+ if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
91
+ if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
92
+
93
+ const added = normalizePlans(target, cfg);
94
+ if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
95
+
96
+ const written = adapters.generate(target, agents);
97
+
98
+ const mcpTargets = [path.join(target, '.mcp.json')];
99
+ if (agents.includes('cursor')) mcpTargets.push(path.join(target, '.cursor', 'mcp.json'));
100
+ for (const fp of mcpTargets) {
101
+ const rel = path.relative(target, fp).split(path.sep).join('/');
102
+ const r = mcp.mergeMcpServer(fp, 'playwright', mcp.PLAYWRIGHT_MCP);
103
+ if (r === 'created' || r === 'added') notes.push(`Wired Playwright MCP into ${rel} (npx @playwright/mcp — for the E2E agent; commit it to share).`);
104
+ else if (r === 'skipped') notes.push(`Left ${rel} as-is (couldn't parse it) — add a 'playwright' MCP server yourself for browser-driven E2E.`);
105
+ }
106
+
107
+ const gi = path.join(target, '.gitignore');
108
+ const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
109
+ for (const line of ['.spectoflow/runtime.json', '.spectoflow/.dashboard.lock']) {
110
+ if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
111
+ }
112
+
113
+ return { target, agents, detected, written, notes };
114
+ }
115
+
116
+ module.exports = { runInit };
package/lib/registry.js CHANGED
@@ -20,6 +20,9 @@ function registryDir(baseDir) {
20
20
  function registryPath(baseDir) {
21
21
  return path.join(registryDir(baseDir), REGISTRY_FILE);
22
22
  }
23
+ function hubLockPath(baseDir) {
24
+ return path.join(registryDir(baseDir), 'hub.lock');
25
+ }
23
26
 
24
27
  function readRegistry(baseDir) {
25
28
  try { return JSON.parse(fs.readFileSync(registryPath(baseDir), 'utf8')); }
@@ -94,5 +97,5 @@ function listProjects(baseDir) {
94
97
 
95
98
  module.exports = {
96
99
  readRegistry, writeRegistry, genId, addProject, removeProject, touchProject,
97
- findByPath, listProjects, registryPath,
100
+ findByPath, listProjects, registryPath, hubLockPath,
98
101
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.22.5",
3
+ "version": "0.23.1",
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",
@@ -15,6 +15,23 @@ let attnFilter = 'open'; // attention tab filter — cl
15
15
  // apply the persisted design skin as early as possible (before the first paint of app-driven DOM)
16
16
  (function(){ try{ const d=localStorage.getItem('spf-design'); if(d) document.documentElement.setAttribute('data-design',d); }catch{} })();
17
17
 
18
+ // The project this dashboard tab is showing — derived once from the URL's /p/<id>/... prefix. The
19
+ // hub-server's legacy-route redirect (sub-project 3) guarantees a bookmark without this prefix never
20
+ // reaches this file directly; it 302s to a /p/<id>/... URL first. Null when served by the older
21
+ // single-project templates/dashboard/server.js (no prefix at all) — every helper below no-ops in
22
+ // that case, preserving today's exact single-project behavior.
23
+ const PROJECT_ID = (() => { const m = location.pathname.match(/^\/p\/([0-9a-f]{6})(?:\/|$)/); return m ? m[1] : null; })();
24
+ // Every /api/* fetch/EventSource call funnels its URL through this — the one place a project id gets
25
+ // attached, so no call site can forget it. Handles both "no query string yet" (?p=) and "already has
26
+ // one" (&p=, e.g. '/api/agentfile?path=...').
27
+ function withProject(url) { if (!PROJECT_ID) return url; return url + (url.includes('?') ? '&' : '?') + 'p=' + encodeURIComponent(PROJECT_ID); }
28
+ // Prefixes an app-internal path (e.g. '/board', '/custom/x') with /p/<id> for history.pushState/
29
+ // replaceState — every page navigation this file performs stays within the current project.
30
+ function projectPath(rest) { return PROJECT_ID ? '/p/' + PROJECT_ID + rest : rest; }
31
+ // location.pathname's segments with a leading /p/<id> stripped, if present — the single place that
32
+ // strip happens, so tabFromPath()/taskFromPath() never have to know about the prefix twice.
33
+ function pathSegments() { const s = location.pathname.split('/').filter(Boolean); return (s[0] === 'p' && s[1]) ? s.slice(2) : s; }
34
+
18
35
  const $ = (s,r=document)=>r.querySelector(s);
19
36
  const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
20
37
  const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
@@ -22,7 +39,7 @@ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map
22
39
  const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
23
40
 
24
41
  async function load(){
25
- const r = await fetch('/api/project'); P = await r.json(); render();
42
+ const r = await fetch(withProject('/api/project')); P = await r.json(); render();
26
43
  if(openTaskId) openDrawer(openTaskId,true);
27
44
  }
28
45
  // Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
@@ -30,7 +47,7 @@ async function load(){
30
47
  let loadTimer=null;
31
48
  function scheduleLoad(){ clearTimeout(loadTimer); loadTimer=setTimeout(load,180); }
32
49
  function connect(){
33
- const es = new EventSource('/api/events');
50
+ const es = new EventSource(withProject('/api/events'));
34
51
  es.onopen = ()=>{ $('#sync').classList.remove('offline'); $('#syncLabel').textContent='live'; };
35
52
  es.onmessage = (ev)=>{
36
53
  let m; try{ m=JSON.parse(ev.data); }catch{ return; }
@@ -123,32 +140,32 @@ async function doRun(promptEl,agentEl){
123
140
  promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
124
141
  const prompt=promptEl.value.trim(); if(!prompt) return;
125
142
  const agent=agentEl.value;
126
- await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
143
+ await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
127
144
  promptEl.value=''; // the prompt renders as a bubble from the message log
128
145
  }
129
146
  async function doOrchestrate(promptEl){
130
147
  if(isChatBusy()) return;
131
148
  promptEl=promptEl||$('#runPrompt');
132
149
  const prompt=promptEl.value.trim(); if(!prompt) return;
133
- await fetch('/api/orchestrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
150
+ await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
134
151
  promptEl.value='';
135
152
  }
136
- async function approve(decision){ await fetch('/api/orchestrate/approve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
153
+ async function approve(decision){ await fetch(withProject('/api/orchestrate/approve'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
137
154
  // ---- chat context management: condense the log via the agent, or wipe it (Chat tab only — the
138
155
  // floating widget stays "quick access", full controls live where there's room to read them) ----
139
156
  async function summarizeChat(agentEl){
140
157
  if(isChatBusy()) return;
141
158
  const agent=(agentEl||$('#tabRunAgent'))?.value;
142
159
  flash();
143
- await fetch('/api/chat/summarize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent})});
160
+ await fetch(withProject('/api/chat/summarize'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent})});
144
161
  }
145
162
  async function clearChat(){
146
163
  flash();
147
- await fetch('/api/chat/clear',{method:'POST'});
164
+ await fetch(withProject('/api/chat/clear'),{method:'POST'});
148
165
  }
149
- async function patchTask(id,patch){ flash(); await fetch('/api/task/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
150
- async function addComment(id,text,action){ flash(); await fetch('/api/task/'+encodeURIComponent(id)+'/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,action})}); }
151
- async function toggleStep(name){ flash(); await fetch('/api/workflow/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); }
166
+ async function patchTask(id,patch){ flash(); await fetch(withProject('/api/task/'+encodeURIComponent(id)),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
167
+ async function addComment(id,text,action){ flash(); await fetch(withProject('/api/task/'+encodeURIComponent(id)+'/comment'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,action})}); }
168
+ async function toggleStep(name){ flash(); await fetch(withProject('/api/workflow/toggle'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); }
152
169
  function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel').textContent='writing…'; setTimeout(()=>{ s.classList.remove('saving'); $('#syncLabel').textContent='live'; },800); }
153
170
 
154
171
  // ---- "agent is running" state — no visible feedback used to exist between clicking Send/
@@ -731,7 +748,7 @@ function editAttn(it,txtNode){
731
748
  ta.addEventListener('blur',save);
732
749
  ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
733
750
  }
734
- async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
751
+ async function addAttn(text){ flash(); await fetch(withProject('/api/attention'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
735
752
 
736
753
  // ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
737
754
  function openBacklogAddForm(){
@@ -756,7 +773,7 @@ async function submitBacklogAdd(){
756
773
  const owner=($('#blAddOwner').value||'').trim();
757
774
  const level=$('#blAddLevel').value;
758
775
  flash();
759
- const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
776
+ const r=await fetch(withProject('/api/task'),{method:'POST',headers:{'Content-Type':'application/json'},
760
777
  body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
761
778
  if(!r.ok){
762
779
  const j=await r.json().catch(()=>({}));
@@ -765,9 +782,9 @@ async function submitBacklogAdd(){
765
782
  }
766
783
  closeBacklogAddForm();
767
784
  }
768
- async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
769
- async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
770
- async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
785
+ async function patchAttn(id,patch){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
786
+ async function deleteAttn(id){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)),{method:'DELETE'}); }
787
+ async function promoteAttn(id){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)+'/promote'),{method:'POST'}); }
771
788
 
772
789
  // ---- Settings tab + topbar quick-switch: change autonomy mode + output language (writes config.json) ----
773
790
  // Mode/language can be changed from two places — the Settings tab (#setMode/#setLang) and the
@@ -826,13 +843,13 @@ function showAgentError(msg){
826
843
  async function saveAgent(id){
827
844
  if(!id) return;
828
845
  flash();
829
- const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
846
+ const r=await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
830
847
  if(!r.ok){ const body=await r.json().catch(()=>({})); showAgentError(body.error||t('topbar.agent.none')); setAgentSelects(); return; }
831
848
  }
832
849
  // ---- design skins (data-design) — switchable, persisted per viewer + as the project default ----
833
850
  function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
834
851
  function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
835
- async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); try{ await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({design:id})}); }catch{} }
852
+ async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); try{ await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({design:id})}); }catch{} }
836
853
 
837
854
  function renderSettings(){
838
855
  const c=(P&&P.config)||{};
@@ -864,7 +881,7 @@ function renderSettings(){
864
881
  async function saveSettings(){
865
882
  flash();
866
883
  const mode=($('#setMode')||$('#topMode')).value, language=($('#setLang')||$('#topLang')).value;
867
- await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
884
+ await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
868
885
  const s=$('#settingsSaved'); if(s){ s.hidden=false; setTimeout(()=>{ s.hidden=true; },1500); }
869
886
  }
870
887
 
@@ -1032,7 +1049,7 @@ function renderCustomize(){
1032
1049
  async function czSubmit(kind,description,agent){
1033
1050
  const cfg=CZ_KINDS.find((c)=>c.kind===kind);
1034
1051
  const prompt=description?cfg.promptAdd(description):cfg.promptAuto;
1035
- await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
1052
+ await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
1036
1053
  const root=$('#czRoot'); if(root) root.dataset.open='';
1037
1054
  navigateTab('chat');
1038
1055
  }
@@ -1046,17 +1063,17 @@ const ROUTES=['board','requests','attention','backlog','workflow','team','files'
1046
1063
  // under that name still land on the Personalize tab instead of a blank panel.
1047
1064
  function normalizeTab(t){ return t==='settings'?'personalize':t; }
1048
1065
  function tabFromPath(){
1049
- const s=location.pathname.split('/').filter(Boolean);
1066
+ const s=pathSegments();
1050
1067
  if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
1051
1068
  const t=normalizeTab(s[0]);
1052
1069
  return ROUTES.includes(t)?t:null;
1053
1070
  }
1054
- function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
1071
+ function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
1055
1072
  function navigateTab(tabId,push){
1056
1073
  activeTab=tabId; try{ localStorage.setItem('spf-tab',tabId); }catch{}
1057
1074
  if(push!==false){
1058
1075
  const isCustom=tabId.indexOf('custom:')===0;
1059
- history.pushState(null,'', isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId);
1076
+ history.pushState(null,'', projectPath(isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId));
1060
1077
  }
1061
1078
  applyActiveTab();
1062
1079
  closeNav(); // a tab pick closes the mobile menu
@@ -1163,7 +1180,7 @@ async function openFileDrawer(kind,obj){
1163
1180
  sec.append(body); b.append(sec);
1164
1181
  $('#drawer').setAttribute('aria-hidden','false');
1165
1182
  try{
1166
- const r=await fetch('/api/agentfile?path='+encodeURIComponent(rel));
1183
+ const r=await fetch(withProject('/api/agentfile?path='+encodeURIComponent(rel)));
1167
1184
  const data=await r.json().catch(()=>({}));
1168
1185
  if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||t('drawer.loadError'))); return; }
1169
1186
  body.innerHTML=mdLite(data.content||'');
@@ -1325,7 +1342,7 @@ let filesSelectedDir=''; // '' = project root — the folder + File/+ Folder cre
1325
1342
  const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
1326
1343
  async function loadFilesTree(){
1327
1344
  try{
1328
- const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
1345
+ const r=await fetch(withProject('/api/files/tree')); const d=await r.json().catch(()=>({}));
1329
1346
  filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
1330
1347
  }catch{ filesTreeData=filesTreeData||[]; }
1331
1348
  renderFilesTree();
@@ -1488,7 +1505,7 @@ async function openFilesFile(relPath){
1488
1505
  const box=$('#filesContent'); box.innerHTML='';
1489
1506
  box.append(el('div','files-empty',t('drawer.loading')));
1490
1507
  let data;
1491
- try{ const r=await fetch('/api/files/read?'+new URLSearchParams({path:relPath})); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
1508
+ try{ const r=await fetch(withProject('/api/files/read?'+new URLSearchParams({path:relPath}))); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
1492
1509
  catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
1493
1510
  box.innerHTML='';
1494
1511
  const bar=el('div','files-toolbar-row');
@@ -1515,7 +1532,7 @@ function filesDiscardBtn(relPath){
1515
1532
  }
1516
1533
  async function filesSave(relPath,content,actions){
1517
1534
  try{
1518
- const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
1535
+ const r=await fetch(withProject('/api/files/write'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
1519
1536
  const d=await r.json().catch(()=>({}));
1520
1537
  if(!r.ok) throw new Error(d.error||'error');
1521
1538
  filesOpenDirty=false; filesSavedTip(actions);
@@ -1610,7 +1627,7 @@ async function submitFilesCreate(){
1610
1627
  const kind=filesCreateKind;
1611
1628
  const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
1612
1629
  const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
1613
- const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1630
+ const r=await fetch(withProject(endpoint),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1614
1631
  const d=await r.json().catch(()=>({}));
1615
1632
  if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
1616
1633
  closeFilesCreateForm();
@@ -1623,7 +1640,7 @@ function openDrawer(id,keep){
1623
1640
  // function calls it repeatedly below; shadowing it with a task variable would break every call.
1624
1641
  const task=allTasks().find(x=>x.id===id); if(!task) return;
1625
1642
  openTaskId=id;
1626
- if(!keep && taskFromPath()!==id) history.pushState(null,'','/'+activeTab+'/'+encodeURIComponent(id));
1643
+ if(!keep && taskFromPath()!==id) history.pushState(null,'',projectPath('/'+activeTab+'/'+encodeURIComponent(id)));
1627
1644
  const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
1628
1645
  b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
1629
1646
  b.append(el('div','d-title',task.title));
@@ -1657,7 +1674,7 @@ function openDrawer(id,keep){
1657
1674
  $('#drawer').setAttribute('aria-hidden','false');
1658
1675
  if(keep) $('.drawer-panel').scrollTop=prev;
1659
1676
  }
1660
- function closeDrawer(){ if(taskFromPath()) history.pushState(null,'','/'+activeTab); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
1677
+ function closeDrawer(){ if(taskFromPath()) history.pushState(null,'',projectPath('/'+activeTab)); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
1661
1678
  const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
1662
1679
 
1663
1680
  // tabs — activeTab is the single source of truth (persisted), so a click sets it and applies it,
@@ -1707,7 +1724,7 @@ const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('clic
1707
1724
  applyActiveTab(); // sync to the resolved tab before the first render
1708
1725
  // an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
1709
1726
  // route once resolved, so the visible URL matches the "Personalize" tab it landed on.
1710
- if(location.pathname.split('/').filter(Boolean)[0]==='settings') history.replaceState(null,'','/personalize');
1727
+ if(pathSegments()[0]==='settings') history.replaceState(null,'',projectPath('/personalize'));
1711
1728
  // filters (status chips + search) — client-side only, does not write anything
1712
1729
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
1713
1730
  $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
@@ -0,0 +1,69 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>spectoflow · projects</title>
7
+ <link rel="icon" type="image/png" href="/logo-dark.png" />
8
+ <link rel="icon" type="image/png" media="(prefers-color-scheme: dark)" href="/logo-white.png" />
9
+ <link rel="stylesheet" href="/styles.css" />
10
+ </head>
11
+ <body class="hub-body">
12
+ <header class="hub-header">
13
+ <div class="hub-brand">
14
+ <img class="brand-logo-img is-dark" src="/logo-white.png" alt="" />
15
+ <img class="brand-logo-img is-light" src="/logo-dark.png" alt="" />
16
+ <span class="hub-brand-name">spectoflow</span>
17
+ </div>
18
+ <button class="hub-theme-toggle" id="hubThemeToggle" aria-label="Toggle theme" title="Toggle theme">◐</button>
19
+ </header>
20
+
21
+ <main class="hub-main">
22
+ <div class="hub-titlebar">
23
+ <h1>Your projects</h1>
24
+ <button class="hub-add-btn" id="hubAddBtn">+ Add project</button>
25
+ </div>
26
+
27
+ <div id="hubEmpty" class="hub-empty" hidden>
28
+ <p class="hub-empty-title">No projects yet</p>
29
+ <p class="hub-empty-sub">Add your first project to get started — point spectoflow at any folder on your computer.</p>
30
+ <button class="hub-add-btn hub-add-btn-lg" id="hubAddBtnEmpty">+ Add your first project</button>
31
+ </div>
32
+
33
+ <div id="hubGrid" class="hub-grid"></div>
34
+ </main>
35
+
36
+ <div id="hubModal" class="hub-modal" hidden>
37
+ <div class="hub-modal-card">
38
+ <div class="hub-modal-head">
39
+ <h2>Add a project</h2>
40
+ <button class="hub-modal-close" id="hubModalClose" aria-label="Close">&times;</button>
41
+ </div>
42
+ <div class="hub-modal-tabs">
43
+ <button class="hub-modal-tab is-active" data-mode="browse">Browse</button>
44
+ <button class="hub-modal-tab" data-mode="paste">Paste a path</button>
45
+ </div>
46
+
47
+ <div id="hubBrowsePane" class="hub-modal-pane">
48
+ <div class="hub-browse-crumb" id="hubBrowseCrumb"></div>
49
+ <div class="hub-browse-list" id="hubBrowseList"></div>
50
+ <div class="hub-browse-footer">
51
+ <span class="hub-browse-current" id="hubBrowseCurrent"></span>
52
+ <button class="hub-add-btn" id="hubBrowseUse">Use this folder</button>
53
+ </div>
54
+ </div>
55
+
56
+ <div id="hubPastePane" class="hub-modal-pane" hidden>
57
+ <label class="hub-paste-label" for="hubPasteInput">Folder path</label>
58
+ <input class="hub-paste-input" id="hubPasteInput" type="text" placeholder="e.g. C:\Users\you\Projects\my-app" autocomplete="off" spellcheck="false" />
59
+ <button class="hub-add-btn" id="hubPasteUse">Use this path</button>
60
+ </div>
61
+
62
+ <p class="hub-modal-error" id="hubModalError" hidden></p>
63
+ <p class="hub-modal-status" id="hubModalStatus" hidden></p>
64
+ </div>
65
+ </div>
66
+
67
+ <script src="/hub.js"></script>
68
+ </body>
69
+ </html>
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+ (function () {
3
+ const s = localStorage.getItem('spf-theme');
4
+ if (s) document.documentElement.setAttribute('data-theme', s);
5
+ })();
6
+ (function () {
7
+ const grid = document.getElementById('hubGrid');
8
+ const empty = document.getElementById('hubEmpty');
9
+ const modal = document.getElementById('hubModal');
10
+ const modalError = document.getElementById('hubModalError');
11
+ const modalStatus = document.getElementById('hubModalStatus');
12
+ const browsePane = document.getElementById('hubBrowsePane');
13
+ const pastePane = document.getElementById('hubPastePane');
14
+ const browseCrumb = document.getElementById('hubBrowseCrumb');
15
+ const browseList = document.getElementById('hubBrowseList');
16
+ const browseCurrent = document.getElementById('hubBrowseCurrent');
17
+ let browsePath = null; // null = show starting points (home dir / drives)
18
+
19
+ function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
20
+ function timeAgo(iso) {
21
+ if (!iso) return '';
22
+ const ms = Date.now() - new Date(iso).getTime();
23
+ const m = Math.floor(ms / 60000);
24
+ if (m < 1) return 'just now';
25
+ if (m < 60) return m + 'm ago';
26
+ const h = Math.floor(m / 60);
27
+ if (h < 24) return h + 'h ago';
28
+ return Math.floor(h / 24) + 'd ago';
29
+ }
30
+
31
+ async function loadProjects() {
32
+ const r = await fetch('/api/hub/projects');
33
+ const data = await r.json();
34
+ const rows = data.projects || [];
35
+ empty.hidden = rows.length > 0;
36
+ grid.innerHTML = rows.map((p) => {
37
+ const pct = p.stats && p.stats.total ? Math.round(100 * p.stats.done / p.stats.total) : null;
38
+ return `<div class="hub-card" data-id="${p.id}">
39
+ <a class="hub-card-open" href="/p/${p.id}/board">
40
+ <div class="hub-card-name">${esc(p.name)}</div>
41
+ <div class="hub-card-path">${esc(p.path)}</div>
42
+ ${pct !== null ? `<div class="hub-card-progress"><div class="hub-card-progress-fill" style="width:${pct}%"></div></div><div class="hub-card-pct">${pct}% · ${p.stats.done}/${p.stats.total} tasks</div>` : ''}
43
+ <div class="hub-card-meta">Opened ${esc(timeAgo(p.lastOpened))}</div>
44
+ </a>
45
+ <button class="hub-card-remove" data-remove="${p.id}" title="Remove from this list" aria-label="Remove ${esc(p.name)}">&times;</button>
46
+ </div>`;
47
+ }).join('');
48
+ }
49
+
50
+ // No native confirm() — it blocks the tab (and this codebase never uses it, see D46). A second
51
+ // click within 3s on the same remove button confirms; the button flips to a checkmark meanwhile.
52
+ let pendingRemoveId = null;
53
+ function confirmRemove(id) {
54
+ if (pendingRemoveId === id) { pendingRemoveId = null; return true; }
55
+ pendingRemoveId = id;
56
+ const btn = grid.querySelector('[data-remove="' + id + '"]');
57
+ if (btn) {
58
+ const orig = btn.textContent;
59
+ btn.textContent = '✓'; btn.title = 'Click again to confirm';
60
+ setTimeout(() => { if (btn.textContent === '✓') btn.textContent = orig; }, 3000);
61
+ }
62
+ return false;
63
+ }
64
+ grid.addEventListener('click', async (e) => {
65
+ const btn = e.target.closest('[data-remove]');
66
+ if (!btn) return;
67
+ e.preventDefault();
68
+ const id = btn.getAttribute('data-remove');
69
+ if (!confirmRemove(id)) return;
70
+ await fetch('/api/hub/projects/' + encodeURIComponent(id), { method: 'DELETE' });
71
+ loadProjects();
72
+ });
73
+
74
+ function openModal() { modal.hidden = false; modalError.hidden = true; modalStatus.hidden = true; browsePath = null; loadBrowse(); }
75
+ function closeModal() { modal.hidden = true; }
76
+ document.getElementById('hubAddBtn').addEventListener('click', openModal);
77
+ document.getElementById('hubAddBtnEmpty').addEventListener('click', openModal);
78
+ document.getElementById('hubModalClose').addEventListener('click', closeModal);
79
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
80
+
81
+ document.querySelectorAll('.hub-modal-tab').forEach((tab) => {
82
+ tab.addEventListener('click', () => {
83
+ document.querySelectorAll('.hub-modal-tab').forEach((t) => t.classList.remove('is-active'));
84
+ tab.classList.add('is-active');
85
+ const mode = tab.getAttribute('data-mode');
86
+ browsePane.hidden = mode !== 'browse';
87
+ pastePane.hidden = mode !== 'paste';
88
+ });
89
+ });
90
+
91
+ async function loadBrowse() {
92
+ const q = browsePath ? ('?path=' + encodeURIComponent(browsePath)) : '';
93
+ const r = await fetch('/api/hub/browse' + q);
94
+ const data = await r.json();
95
+ if (data.error) { browseList.innerHTML = '<p class="hub-browse-empty">' + esc(data.error) + '</p>'; return; }
96
+ browsePath = data.current || null;
97
+ browseCurrent.textContent = browsePath || 'Choose a starting point';
98
+ // A drive root (D:\) or similar has no OS-level parent (data.parent is null) but the picker must
99
+ // never dead-end there — "Up" from any real folder always goes somewhere: its real parent, or
100
+ // back to the starting points list if there isn't one. Only hide it once we're AT that list.
101
+ const showUp = browsePath !== null;
102
+ browseCrumb.innerHTML = showUp ? `<button class="hub-crumb-up" id="hubCrumbUp">&larr; Up</button>` : '';
103
+ const up = document.getElementById('hubCrumbUp');
104
+ if (up) up.addEventListener('click', () => { browsePath = data.parent || null; loadBrowse(); });
105
+ browseList.innerHTML = (data.entries || []).map((e) =>
106
+ `<button class="hub-browse-item" data-path="${esc(e.path)}">${esc(e.name)}</button>`
107
+ ).join('') || '<p class="hub-browse-empty">No sub-folders here.</p>';
108
+ browseList.querySelectorAll('[data-path]').forEach((el) => {
109
+ el.addEventListener('click', () => { browsePath = el.getAttribute('data-path'); loadBrowse(); });
110
+ });
111
+ }
112
+
113
+ async function submitPath(p) {
114
+ modalError.hidden = true; modalStatus.hidden = false; modalStatus.textContent = 'Adding…';
115
+ const r = await fetch('/api/hub/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: p }) });
116
+ const data = await r.json();
117
+ if (!r.ok) { modalStatus.hidden = true; modalError.hidden = false; modalError.textContent = data.error || 'Could not add that folder.'; return; }
118
+ location.href = '/p/' + data.entry.id + '/board';
119
+ }
120
+ document.getElementById('hubBrowseUse').addEventListener('click', () => { if (browsePath) submitPath(browsePath); });
121
+ document.getElementById('hubPasteUse').addEventListener('click', () => {
122
+ const v = document.getElementById('hubPasteInput').value.trim();
123
+ if (v) submitPath(v);
124
+ });
125
+
126
+ document.getElementById('hubThemeToggle').addEventListener('click', () => {
127
+ const cur = document.documentElement.getAttribute('data-theme');
128
+ const next = cur === 'dark' ? 'light' : 'dark';
129
+ document.documentElement.setAttribute('data-theme', next);
130
+ localStorage.setItem('spf-theme', next);
131
+ });
132
+
133
+ loadProjects();
134
+ })();
@@ -18,6 +18,7 @@
18
18
  <img class="brand-logo-img is-dark" src="/logo-white.png" alt="spectoflow" />
19
19
  <img class="brand-logo-img is-light" src="/logo-dark.png" alt="spectoflow" />
20
20
  </a>
21
+ <a class="hub-back-link" href="/" title="Back to your projects" aria-label="Back to your projects">⌂</a>
21
22
  <div class="brand-text">
22
23
  <div class="brand-line">
23
24
  <span class="brand-name">spectoflow</span>
@@ -968,3 +968,62 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
968
968
 
969
969
  /* Phase progress: cap the list so a big project (many phases) doesn't dominate the overview */
970
970
  .bars-block.scroll-cap { max-height:340px; overflow-y:auto; padding-right:6px; }
971
+
972
+ /* ---- Hub landing page (multi-project) — reuses the same tokens as the per-project dashboard,
973
+ deliberately simpler: no per-design skins, no tabs, just a calm project picker. ---- */
974
+ .hub-body { min-height:100%; display:flex; flex-direction:column; }
975
+ .hub-header { display:flex; align-items:center; justify-content:space-between; padding:16px 24px; border-bottom:1px solid var(--line); }
976
+ .hub-brand { display:flex; align-items:center; gap:9px; font-weight:700; }
977
+ .hub-brand .brand-logo-img { width:22px; height:22px; }
978
+ .hub-brand-name { font-size:15px; }
979
+ .hub-theme-toggle { width:32px; height:32px; border-radius:8px; border:1px solid var(--line); background:var(--surface); color:var(--ink); cursor:pointer; font-size:14px; }
980
+ .hub-main { flex:1; max-width:1080px; width:100%; margin:0 auto; padding:32px 24px 60px; }
981
+ .hub-titlebar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:24px; flex-wrap:wrap; }
982
+ .hub-titlebar h1 { font-size:24px; font-weight:700; margin:0; }
983
+ .hub-add-btn { font-family:var(--sans); font-size:13.5px; font-weight:600; padding:9px 16px; border-radius:9px; border:1px solid transparent; background:var(--signal); color:var(--on-accent); cursor:pointer; transition:filter .15s; }
984
+ .hub-add-btn:hover { filter:brightness(1.08); }
985
+ .hub-add-btn-lg { padding:12px 22px; font-size:14.5px; margin-top:14px; }
986
+ .hub-empty { text-align:center; padding:60px 20px; border:1px dashed var(--line); border-radius:var(--radius); }
987
+ .hub-empty-title { font-size:18px; font-weight:700; margin:0 0 8px; }
988
+ .hub-empty-sub { color:var(--muted); font-size:13.5px; max-width:420px; margin:0 auto; }
989
+ .hub-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
990
+ .hub-card { position:relative; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); transition:border-color .15s,transform .15s; }
991
+ .hub-card:hover { border-color:var(--signal); transform:translateY(-1px); }
992
+ .hub-card-open { display:block; padding:16px; text-decoration:none; color:inherit; }
993
+ .hub-card-name { font-size:15px; font-weight:700; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
994
+ .hub-card-path { font-family:var(--mono); font-size:10.5px; color:var(--faint); margin-top:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
995
+ .hub-card-progress { height:5px; border-radius:999px; background:var(--surface-2); margin-top:12px; overflow:hidden; }
996
+ .hub-card-progress-fill { height:100%; background:var(--s-done); border-radius:999px; }
997
+ .hub-card-pct { font-family:var(--mono); font-size:10.5px; color:var(--muted); margin-top:6px; }
998
+ .hub-card-meta { font-size:11px; color:var(--faint); margin-top:10px; }
999
+ .hub-card-remove { position:absolute; top:8px; right:8px; width:22px; height:22px; border-radius:999px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); cursor:pointer; font-size:13px; line-height:1; }
1000
+ .hub-card-remove:hover { color:var(--s-blocked); border-color:var(--s-blocked); }
1001
+
1002
+ .hub-modal { position:fixed; inset:0; background:rgba(0,0,0,.5); display:flex; align-items:center; justify-content:center; z-index:20; padding:20px; }
1003
+ .hub-modal[hidden] { display:none; }
1004
+ .hub-modal-card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); width:100%; max-width:460px; max-height:86vh; display:flex; flex-direction:column; padding:20px; }
1005
+ .hub-modal-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
1006
+ .hub-modal-head h2 { font-size:16px; margin:0; }
1007
+ .hub-modal-close { width:28px; height:28px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); cursor:pointer; font-size:16px; line-height:1; }
1008
+ .hub-modal-tabs { display:flex; gap:6px; margin-bottom:14px; }
1009
+ .hub-modal-tab { flex:1; font-family:var(--sans); font-size:12.5px; font-weight:600; padding:7px 10px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); cursor:pointer; }
1010
+ .hub-modal-tab.is-active { background:var(--signal); border-color:var(--signal); color:var(--on-accent); }
1011
+ .hub-modal-pane { display:flex; flex-direction:column; gap:10px; min-height:0; }
1012
+ .hub-modal-pane[hidden] { display:none; }
1013
+ .hub-browse-crumb { min-height:20px; }
1014
+ .hub-crumb-up { font-family:var(--mono); font-size:11.5px; color:var(--cool); background:none; border:0; cursor:pointer; padding:0; }
1015
+ .hub-browse-list { display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; border:1px solid var(--line); border-radius:9px; padding:6px; }
1016
+ .hub-browse-item { text-align:left; font-family:var(--sans); font-size:13px; padding:7px 9px; border-radius:6px; border:0; background:none; color:var(--ink); cursor:pointer; }
1017
+ .hub-browse-item:hover { background:var(--surface-2); }
1018
+ .hub-browse-empty { color:var(--faint); font-size:12.5px; padding:6px 2px; margin:0; }
1019
+ .hub-browse-footer { display:flex; align-items:center; gap:10px; justify-content:space-between; }
1020
+ .hub-browse-current { font-family:var(--mono); font-size:10.5px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
1021
+ .hub-paste-label { font-size:12px; color:var(--muted); }
1022
+ .hub-paste-input { font-family:var(--mono); font-size:13px; padding:9px 11px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); }
1023
+ .hub-modal-error { color:var(--s-blocked); font-size:12.5px; margin:4px 0 0; }
1024
+ .hub-modal-status { color:var(--muted); font-size:12.5px; margin:4px 0 0; }
1025
+
1026
+ /* "back to hub" — plain link next to the brand logo on the per-project dashboard; a full navigation
1027
+ (not SPA), since leaving to the hub means leaving this project's dashboard entirely. */
1028
+ .hub-back-link { display:flex; align-items:center; justify-content:center; width:26px; height:26px; border-radius:7px; color:var(--muted); text-decoration:none; font-size:15px; flex-shrink:0; transition:background .15s,color .15s; }
1029
+ .hub-back-link:hover { background:var(--surface-2); color:var(--ink); }