spectoflow 0.23.4 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +21 -10
  2. package/bin/postinstall.js +1 -0
  3. package/bin/spectoflow.js +144 -35
  4. package/lib/adapters.js +8 -2
  5. package/{templates/lib → lib}/custom-dashboard.js +2 -2
  6. package/{templates/lib → lib}/customize-prompts.js +1 -1
  7. package/lib/dashboard/handlers.js +78 -0
  8. package/lib/{hub-server.js → dashboard/hub-server.js} +35 -44
  9. package/lib/dashboard/ops.js +176 -0
  10. package/{templates → lib}/dashboard/orchestrator.js +17 -8
  11. package/{templates → lib}/dashboard/public/app.js +33 -13
  12. package/{templates → lib}/dashboard/public/styles.css +6 -1
  13. package/{templates → lib}/dashboard/runner.js +6 -5
  14. package/{templates → lib}/dashboard/summarize.js +1 -1
  15. package/lib/detect.js +16 -1
  16. package/lib/global-config.js +65 -0
  17. package/lib/init.js +10 -4
  18. package/lib/registry.js +8 -8
  19. package/{templates/lib → lib}/store.js +16 -12
  20. package/lib/update.js +69 -1
  21. package/lib/workspace.js +84 -0
  22. package/package.json +1 -1
  23. package/templates/AGENTS.md +6 -6
  24. package/templates/README.md +4 -3
  25. package/templates/agents/framework-curator.md +11 -11
  26. package/templates/capabilities.md +1 -1
  27. package/templates/dashboards/.gitkeep +0 -0
  28. package/templates/skills/generate-dashboard/SKILL.md +15 -13
  29. package/templates/dashboard/custom/.gitkeep +0 -3
  30. package/templates/dashboard/handlers.js +0 -251
  31. package/templates/dashboard/server.js +0 -73
  32. package/templates/lib/agents-registry.js +0 -65
  33. /package/{templates → lib}/dashboard/files.js +0 -0
  34. /package/{templates → lib}/dashboard/public/charts.js +0 -0
  35. /package/{templates → lib}/dashboard/public/designs/console.css +0 -0
  36. /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
  37. /package/{templates → lib}/dashboard/public/designs/orbit.css +0 -0
  38. /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
  39. /package/{templates → lib}/dashboard/public/designs.js +0 -0
  40. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
  41. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
  42. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
  43. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
  44. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
  45. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
  46. /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
  47. /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
  48. /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
  49. /package/{templates → lib}/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  50. /package/{templates → lib}/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  51. /package/{templates → lib}/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
  52. /package/{templates → lib}/dashboard/public/hub.html +0 -0
  53. /package/{templates → lib}/dashboard/public/hub.js +0 -0
  54. /package/{templates → lib}/dashboard/public/i18n.js +0 -0
  55. /package/{templates → lib}/dashboard/public/icons.js +0 -0
  56. /package/{templates → lib}/dashboard/public/index.html +0 -0
  57. /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
  58. /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
  59. /package/{templates → lib}/dashboard/public/stats.js +0 -0
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
  /*
3
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
4
+ * project's .spectoflow/). Registry-driven: resolves a project's root on demand from
5
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).
6
+ * The route logic is THIS package's own handlers.js (./handlers.js), shared by every project (D64):
7
+ * nothing is ever require()'d from a project, so a project that has never run `spectoflow update`
8
+ * opens exactly like a fresh one (see docs/multi-project-hub-design.md).
9
9
  *
10
10
  * URL scheme (settled in the design doc): pages use a path prefix (/p/<id>/board, bookmarkable on
11
11
  * their own); every /api/* call instead takes a ?p=<id> query param (smaller client diff, and
@@ -21,74 +21,65 @@ const http = require('http');
21
21
  const fs = require('fs');
22
22
  const os = require('os');
23
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;
24
+ const registry = require('../registry');
25
+ const workspace = require('../workspace');
26
+ const { createHandlers } = require('./handlers');
27
+ const store = require('../store');
28
+
29
+ const migrated = workspace.migrateLegacyHome();
30
+ if (!workspace.exists()) workspace.init({});
31
+ const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : workspace.settings().port;
32
+ const PUBLIC = path.join(__dirname, 'public');
33
+ const TEMPLATES = path.join(__dirname, '..', '..', 'templates');
34
+ const VERSION = require('../../package.json').version;
30
35
  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
36
  function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
32
37
  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
38
 
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).
39
+ // id -> { id, root, handlers, clients:Set, emit, watchers:[] }. The route logic is THIS package's
40
+ // handlers.js for every project (D64): nothing is ever require()'d from a project, so a project that
41
+ // has never run `spectoflow update` opens exactly like a fresh one.
36
42
  const projects = new Map();
37
43
  function getProject(id) {
38
44
  if (projects.has(id)) return projects.get(id);
39
45
  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
46
+ if (!entry || !fs.existsSync(entry.path)) return null;
45
47
  const handlers = createHandlers(entry.path);
46
48
  const clients = new Set();
47
49
  const emit = (obj) => { const line = 'data: ' + JSON.stringify(obj) + '\n\n'; for (const res of clients) res.write(line); };
48
50
  handlers.onBoot();
51
+ const watchers = [];
49
52
  handlers.watchDirs.forEach((d) => {
50
53
  const dir = path.join(entry.path, d);
51
- if (fs.existsSync(dir)) { try { fs.watch(dir, { recursive: false }, () => emit({ type: 'change' })); } catch (_) {} }
54
+ if (fs.existsSync(dir)) { try { watchers.push(fs.watch(dir, { recursive: false }, () => emit({ type: 'change' }))); } catch (_) {} }
52
55
  });
53
- const proj = { id, root: entry.path, handlers, clients, emit };
56
+ const proj = { id, root: entry.path, handlers, clients, emit, watchers };
54
57
  projects.set(id, proj);
55
58
  return proj;
56
59
  }
57
60
 
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.
61
+ // Only called after getProject(id) returned null. Two causes remain (D64 removed "needs an update").
62
62
  function projectErrorMessage(id) {
63
63
  const entry = registry.listProjects().find((p) => p.id === id);
64
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.`;
65
+ return `Project "${entry.name}" is registered, but its folder no longer exists at ${entry.path}.`;
69
66
  }
70
67
 
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.
68
+ // Re-opens a project: drops its cached entry and closes its watchers so the next request re-runs
69
+ // onBoot and re-watches (a project's dirs may have changed after `spectoflow update`). Returns false
70
+ // (a harmless no-op) if this id was never loaded.
76
71
  function reloadProject(id) {
77
72
  const proj = projects.get(id);
78
73
  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
- }
74
+ proj.watchers.forEach((w) => { try { w.close(); } catch (_) {} });
75
+ require('./orchestrator').clearPending(proj.root);
83
76
  projects.delete(id);
84
77
  return true;
85
78
  }
86
79
 
87
80
  // ---- hub API: list/add/remove registered projects, browse the filesystem to find one ----
88
81
  function projectStats(root) {
89
- // Best-effort — a moved/deleted/corrupt project must never break the whole listing.
90
82
  try {
91
- const store = require(path.join(root, '.spectoflow', 'lib', 'store.js'));
92
83
  const plans = store.readPlans(root);
93
84
  let total = 0, done = 0;
94
85
  for (const pl of plans) for (const ph of pl.phases) for (const t of ph.tasks) { total++; if (t.status === 'done') done++; }
@@ -130,10 +121,10 @@ function addHubProject(rawPath) {
130
121
  if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return { error: 'That folder does not exist.' };
131
122
  const hasSpectoflow = fs.existsSync(path.join(abs, '.spectoflow'));
132
123
  if (!hasSpectoflow) {
133
- const { runInit } = require('./init');
124
+ const { runInit } = require('../init');
134
125
  runInit({ target: abs, templatesDir: TEMPLATES, version: VERSION });
135
126
  }
136
- const entry = registry.addProject(abs);
127
+ const entry = workspace.registerProject(abs);
137
128
  return { entry, initialized: !hasSpectoflow };
138
129
  }
139
130
  async function handleHubApi(req, res, u) {
@@ -168,8 +159,8 @@ async function handleHubApi(req, res, u) {
168
159
  }
169
160
 
170
161
  // 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.
162
+ // shared, globally-installed PUBLIC dir — factored into a function since both the root-level and
163
+ // /p/<id>/-prefixed requests need it.
173
164
  function serveStatic(reqPath, req, res) {
174
165
  const file = reqPath === '/' ? '/index.html' : reqPath;
175
166
  const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
@@ -193,7 +184,7 @@ function serveStatic(reqPath, req, res) {
193
184
 
194
185
  const PROJECT_PREFIX = /^\/p\/([0-9a-f]{6})(\/.*)?$/;
195
186
 
196
- const LOCK = registry.hubLockPath();
187
+ const LOCK = workspace.lockPath();
197
188
  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
189
  function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
199
190
  process.on('exit', clearLock);
@@ -255,4 +246,4 @@ const server = http.createServer(async (req, res) => {
255
246
  } catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
256
247
  });
257
248
 
258
- server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}`); });
249
+ server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); });
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+ /*
3
+ * The dashboard's operations — one pure function per action, (root, args, ctx) → result, with no
4
+ * HTTP in sight. handlers.js maps HTTP routes onto this table; the online dashboard (sub-project C)
5
+ * will map WebSocket messages onto the very same table. ctx.emit broadcasts SSE events to every
6
+ * client of this project; ops call it themselves after a successful mutation so any caller gets
7
+ * the same live behaviour. Errors are OpError(status, message) — the transport turns status into
8
+ * its own vocabulary (HTTP status code today).
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const store = require('../store');
13
+ const files = require('./files');
14
+ const { startRun } = require('./runner');
15
+ const { runSummarize } = require('./summarize');
16
+ const orchestrator = require('./orchestrator');
17
+ const adapters = require('../adapters');
18
+ const detect = require('../detect');
19
+
20
+ const PKG_VERSION = require('../../package.json').version;
21
+
22
+ class OpError extends Error {
23
+ constructor(status, message) { super(message); this.status = status; }
24
+ }
25
+ const bad = (msg) => { throw new OpError(400, msg); };
26
+ const notFound = (msg) => { throw new OpError(404, msg); };
27
+ const text = (v, msg) => { if (!v || !String(v).trim()) bad(msg); return String(v).trim(); };
28
+
29
+ function frameworkVersion(root) {
30
+ try { return JSON.parse(fs.readFileSync(path.join(root, '.spectoflow', '.manifest.json'), 'utf8')).version || PKG_VERSION; } catch { return PKG_VERSION; }
31
+ }
32
+ function findPlanFileForTask(root, id) {
33
+ for (const pl of store.readPlans(root)) for (const ph of pl.phases) if (ph.tasks.find((t) => t.id === id)) return pl.file;
34
+ return null;
35
+ }
36
+ function readAgentFile(root, rel) {
37
+ const base = path.join(root, '.spectoflow');
38
+ const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
39
+ const abs = path.resolve(base, rel || '');
40
+ const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
41
+ if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory()) bad('not an agent/skill file');
42
+ let real; try { real = fs.realpathSync(abs); } catch { real = null; }
43
+ const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
44
+ const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
45
+ const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
46
+ if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory()) bad('not an agent/skill file');
47
+ return { content: fs.readFileSync(real, 'utf8') };
48
+ }
49
+ function writeConfig(root, patch, detectOpts) {
50
+ const cp = path.join(root, '.spectoflow', 'config.json');
51
+ 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
+ const known = adapters.knownAgents().find((a) => a.id === id);
58
+ // Never activate an agent whose CLI isn't actually there — it would just fail silently later.
59
+ if (!detect.isAgentInstalled(id, root, detectOpts)) bad(`${known ? known.label : id} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
60
+ cfg.agent = id;
61
+ if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
62
+ }
63
+ fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
64
+ return cfg;
65
+ }
66
+ const filesResult = (r) => { if (r.error) bad(r.error); return r; };
67
+ const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result; };
68
+
69
+ const ops = {
70
+ 'project.read': async (root) => {
71
+ const p = store.readProject(root);
72
+ p.version = frameworkVersion(root);
73
+ p.projectName = path.basename(root);
74
+ p.knownAgents = adapters.knownAgents().map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
75
+ p.installedAgents = detect.installedAgents(root);
76
+ return p;
77
+ },
78
+ 'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
79
+
80
+ 'files.tree': async (root) => ({ tree: files.tree(root) }),
81
+ 'files.read': async (root, { path: rel }) => filesResult(files.readFile(root, rel || '')),
82
+ 'files.write': async (root, { path: rel, content }, ctx) => changed(ctx, filesResult(files.writeFile(root, rel, content))),
83
+ 'files.mkdir': async (root, { path: rel }, ctx) => changed(ctx, filesResult(files.mkdir(root, rel))),
84
+
85
+ 'task.add': async (root, { title, phase, file, owner, level }, ctx) => {
86
+ const t = store.addTask(root, { title: text(title, 'A title is required.'), phase, file, owner, level });
87
+ return changed(ctx, { task: t });
88
+ },
89
+ 'task.update': async (root, { id, patch }, ctx) => {
90
+ const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
91
+ store.updateTaskLine(root, file, id, patch || {});
92
+ return changed(ctx, { ok: true });
93
+ },
94
+ 'task.comment': async (root, { id, text: body, action }, ctx) => {
95
+ const msg = text(body, 'Empty comment.');
96
+ const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
97
+ store.addTaskComment(root, file, id, msg, 'me');
98
+ if (action === 'analyze') store.updateTaskLine(root, file, id, { status: 'to_analyze' });
99
+ return changed(ctx, { ok: true });
100
+ },
101
+ 'workflow.toggle': async (root, { name }, ctx) => {
102
+ const wf = path.join(root, '.spectoflow', 'workflow.md');
103
+ const lines = fs.readFileSync(wf, 'utf8').split('\n');
104
+ // Strip the trailing {cap:... skill:... policy} annotation BEFORE "(optional)" — the same order as
105
+ // store.readWorkflow(), which is what the client's step names come from (D60).
106
+ const stepName = (rest) => { const ann = rest.match(/\{([^}]*)\}\s*$/); if (ann) rest = rest.slice(0, ann.index).trim(); return rest.replace(/\s*\(optional\)\s*$/i, '').trim(); };
107
+ for (let i = 0; i < lines.length; i++) {
108
+ const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
109
+ if (m && stepName(m[4]) === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4];
110
+ }
111
+ fs.writeFileSync(wf, lines.join('\n'));
112
+ return changed(ctx, { ok: true });
113
+ },
114
+
115
+ 'run.start': async (root, { prompt, agent }, ctx) => {
116
+ text(prompt, 'Empty request.');
117
+ const r = startRun(root, { prompt, agent }, ctx.emit);
118
+ if (r.error) bad(r.error);
119
+ return { runId: r.runId };
120
+ },
121
+ 'chat.summarize': async (root, { agent }, ctx) => {
122
+ const r = runSummarize(root, { agent }, ctx.emit);
123
+ if (r.error) bad(r.error);
124
+ return { ok: true };
125
+ },
126
+ 'chat.clear': async (root, _args, ctx) => {
127
+ const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
128
+ return changed(ctx, { ok: true });
129
+ },
130
+ 'orchestrate.start': async (root, { request }, ctx) => {
131
+ const req = text(request, 'Empty request.');
132
+ const active = store.readRuntime(root).orchestration;
133
+ if (active && ['running', 'awaiting_approval'].includes(active.status)) throw new OpError(409, 'An orchestration is already active.');
134
+ const mode = store.readConfig(root).mode || 'semi';
135
+ orchestrator.runOrchestration({ root, request: req, mode, runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, ctx.emit)
136
+ .catch((e) => ctx.emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
137
+ const o = store.readRuntime(root).orchestration;
138
+ return { orchestrationId: o && o.id };
139
+ },
140
+ 'orchestrate.approve': async (root, { decision, note }) => {
141
+ if (!orchestrator.submitDecision(decision, note, root)) throw new OpError(409, 'No pending approval.');
142
+ return { ok: true };
143
+ },
144
+
145
+ 'settings.save': async (root, patch, ctx) => changed(ctx, { config: writeConfig(root, patch || {}, ctx.env ? { env: ctx.env } : undefined) }),
146
+
147
+ 'attention.add': async (root, { text: body }, ctx) => {
148
+ const msg = text(body, 'Empty note.');
149
+ const rt = store.readRuntime(root); rt.attention = rt.attention || [];
150
+ const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: msg };
151
+ rt.attention.unshift(item); store.writeRuntime(root, rt);
152
+ return changed(ctx, { item });
153
+ },
154
+ 'attention.promote': async (root, { id }, ctx) => {
155
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
156
+ if (!it) notFound('Note not found.');
157
+ const t = store.addTask(root, { phase: 'Attention', title: it.text, owner: 'user' });
158
+ it.status = 'resolved'; it.promotedTo = t.id; store.writeRuntime(root, rt);
159
+ return changed(ctx, { task: t });
160
+ },
161
+ 'attention.update': async (root, { id, patch }, ctx) => {
162
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
163
+ if (!it) notFound('Note not found.');
164
+ const p = patch || {};
165
+ if (typeof p.text === 'string' && p.text.trim()) it.text = p.text.trim();
166
+ if (p.status && ['open', 'resolved'].includes(p.status)) it.status = p.status;
167
+ store.writeRuntime(root, rt);
168
+ return changed(ctx, { item: it });
169
+ },
170
+ 'attention.remove': async (root, { id }, ctx) => {
171
+ const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
172
+ return changed(ctx, { ok: true });
173
+ },
174
+ };
175
+
176
+ module.exports = { ops, OpError };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const store = require('../lib/store');
4
+ const store = require('../store');
5
5
  const { startRun } = require('./runner');
6
6
 
7
7
  // step (from store.readWorkflow) -> { agent, skill } or { error }
@@ -54,7 +54,7 @@ async function runOrchestration({ root, request, mode, runStep, confirm, resume
54
54
  if (needConfirm) {
55
55
  st.status = 'awaiting_approval'; o.status = 'awaiting_approval'; saveState(root, o, emit);
56
56
  post(root, 'orchestrator', 'question', `Approve step "${step.name}" (${r.agent})${policyGated ? ' — policy gate' : ''}?`, emit);
57
- const dec = await confirm(step, { policy: policyGated });
57
+ const dec = await confirm(step, { policy: policyGated, root });
58
58
  post(root, 'orchestrator', 'status', `decision: ${dec.decision}${dec.note ? ' — ' + dec.note : ''}`, emit);
59
59
  if (dec.decision === 'cancel') { st.status = 'skipped'; o.status = 'cancelled'; saveState(root, o, emit); return o; }
60
60
  o.status = 'running'; saveState(root, o, emit);
@@ -94,12 +94,21 @@ function defaultRunStep({ root, step, agent, skill, request }, emit) {
94
94
  });
95
95
  }
96
96
 
97
- let pending = null;
98
- function defaultConfirm(step, reason) { return new Promise((resolve) => { pending = { resolve }; }); }
99
- function submitDecision(decision, note) {
100
- if (!pending) return false;
101
- const p = pending; pending = null; p.resolve({ decision, note }); return true;
97
+ // Keyed by project root — before this hub shared one orchestrator.js instance across every project
98
+ // (D64), a single module-level `pending` here was a real cross-project leak: approving in one
99
+ // project could resolve (or silently overwrite) another project's pending gate.
100
+ const pending = new Map(); // root -> { resolve }
101
+ function defaultConfirm(step, reason) { return new Promise((resolve) => { pending.set(reason && reason.root, { resolve }); }); }
102
+ function submitDecision(decision, note, root) {
103
+ const p = pending.get(root);
104
+ if (!p) return false;
105
+ pending.delete(root);
106
+ p.resolve({ decision, note });
107
+ return true;
102
108
  }
109
+ // Drops any pending approval for `root` — called when a project is reloaded (its whole in-flight
110
+ // session is considered gone) so a stale resolver can never be sitting around after the fact.
111
+ function clearPending(root) { pending.delete(root); }
103
112
 
104
113
  // Boot-time reconcile: a process restart loses any in-flight orchestration (the runOrchestration
105
114
  // call stack, and the in-memory `pending` approval) even though runtime.json still records it as
@@ -116,4 +125,4 @@ function reconcileOnBoot(root) {
116
125
  return true;
117
126
  }
118
127
 
119
- module.exports = { resolveStep, runOrchestration, defaultRunStep, defaultConfirm, submitDecision, reconcileOnBoot };
128
+ module.exports = { resolveStep, runOrchestration, defaultRunStep, defaultConfirm, submitDecision, clearPending, reconcileOnBoot };
@@ -17,9 +17,8 @@ let attnFilter = 'open'; // attention tab filter — cl
17
17
 
18
18
  // The project this dashboard tab is showing — derived once from the URL's /p/<id>/... prefix. The
19
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.
20
+ // reaches this file directly; it 302s to a /p/<id>/... URL first. Null defensively (no prefix at
21
+ // all) — every helper below no-ops in that case rather than assuming the prefix is always present.
23
22
  const PROJECT_ID = (() => { const m = location.pathname.match(/^\/p\/([0-9a-f]{6})(?:\/|$)/); return m ? m[1] : null; })();
24
23
  // Every /api/* fetch/EventSource call funnels its URL through this — the one place a project id gets
25
24
  // attached, so no call site can forget it. Handles both "no query string yet" (?p=) and "already has
@@ -363,17 +362,26 @@ function renderOverview(){
363
362
  topRow.append(ocard(t('chart.scopeVsDelivered'), area));
364
363
  box.append(topRow);
365
364
 
366
- // Workflow-at-a-glance strip (reuses the wf-arrow flow animation)
365
+ // Workflow-at-a-glance strip (reuses the wf-arrow flow animation) — clicking a step opens the same
366
+ // enable/disable popover as the dedicated Workflow tab (openWfPop/renderWfPop), so a step can be
367
+ // toggled right from the Board without switching tabs.
367
368
  const strip=el('div','wf-strip');
368
369
  const steps=P.workflow||[];
369
370
  steps.forEach((st,i)=>{
370
- const node=el('div','wf-mini'+(st.enabled?'':' off'));
371
+ const node=el('div','wf-mini'+(st.enabled?'':' off')+(st.name===wfPopStep?' is-selected':''));
372
+ node.dataset.name=st.name;
373
+ node.tabIndex=0; node.setAttribute('role','button'); node.setAttribute('aria-expanded',String(st.name===wfPopStep));
374
+ node.title=st.name;
371
375
  node.append(el('span','dot')); node.append(el('span','nm',st.name));
376
+ const sel=(e)=>{ e.stopPropagation(); if(wfPopStep===st.name) closeWfPop(); else openWfPop(st.name); };
377
+ node.addEventListener('click',sel);
378
+ node.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); sel(e); } });
372
379
  strip.append(node);
373
380
  if(i<steps.length-1){ const a=el('div','wf-arrow'+(st.enabled&&steps[i+1].enabled?'':' off')); strip.append(a); }
374
381
  });
375
382
  if(!steps.length) strip.append(el('div','empty',t('board.noWorkflow')));
376
383
  box.append(ocard(t('board.workflowGlance'), strip));
384
+ if(wfPopStep) renderWfPop(); // re-anchor an open popover after a live re-render of this strip too
377
385
 
378
386
  // Per-phase progress bars — only phases that actually hold tasks (headings with no checkbox tasks
379
387
  // are noise, not phases), and cap the list height with an internal scroll so a big project with
@@ -675,16 +683,27 @@ function wfPopFill(pop, s, idx){
675
683
  const actions=el('div','wf-pop-actions'); actions.append(btn); pop.append(actions);
676
684
  }
677
685
  function openWfPop(name){ wfPopStep=name; renderWfPop(); }
678
- function closeWfPop(){ wfPopStep=null; const p=$('#wfPop'); if(p) p.hidden=true; $$('#wfDiagram .wf-step2.is-selected').forEach(x=>x.classList.remove('is-selected')); }
686
+ function closeWfPop(){ wfPopStep=null; const p=$('#wfPop'); if(p) p.hidden=true; $$('#wfDiagram .wf-step2.is-selected,.wf-mini.is-selected').forEach(x=>x.classList.remove('is-selected')); }
687
+ // The workflow popover has two possible triggers now: the dedicated Workflow tab's own pipeline
688
+ // (#wfDiagram .wf-step2), and the Board's "at a glance" strip (.wf-mini). Both stay mounted in the
689
+ // DOM at all times (every .panel renders every tick; only CSS hides the inactive ones), so anchoring
690
+ // always prefers whichever one sits on the currently VISIBLE panel — a hidden panel's element has a
691
+ // zero-size getBoundingClientRect() and would position the popover at the top-left corner.
692
+ function findWfAnchor(name){
693
+ const boardActive=document.querySelector('.panel[data-panel="board"].is-active');
694
+ if(boardActive){ const m=boardActive.querySelector('.wf-mini[data-name="'+CSS.escape(name)+'"]'); if(m) return m; }
695
+ const idx=(P.workflow||[]).findIndex(s=>s.name===name);
696
+ return $('#wfDiagram .wf-step2[data-idx="'+idx+'"]');
697
+ }
679
698
  function renderWfPop(){
680
699
  const p=$('#wfPop'); if(!p) return;
681
700
  const steps=P.workflow||[]; const idx=steps.findIndex(s=>s.name===wfPopStep);
682
701
  if(idx<0){ closeWfPop(); return; }
683
- const anchor=$('#wfDiagram .wf-step2[data-idx="'+idx+'"]');
702
+ const anchor=findWfAnchor(wfPopStep);
684
703
  if(!anchor){ p.hidden=true; return; }
685
- $$('#wfDiagram .wf-step2.is-selected').forEach(x=>x.classList.remove('is-selected')); anchor.classList.add('is-selected');
704
+ $$('#wfDiagram .wf-step2.is-selected,.wf-mini.is-selected').forEach(x=>x.classList.remove('is-selected')); anchor.classList.add('is-selected');
686
705
  wfPopFill(p, steps[idx], idx);
687
- positionWfPop(anchor.querySelector('.wf-circle')||anchor, p);
706
+ positionWfPop(anchor.classList.contains('wf-step2')?(anchor.querySelector('.wf-circle')||anchor):anchor, p);
688
707
  }
689
708
  function positionWfPop(anchorEl, pop){
690
709
  const r=anchorEl.getBoundingClientRect();
@@ -886,12 +905,12 @@ async function saveSettings(){
886
905
  }
887
906
 
888
907
  // ---- Custom dashboards (Customize page → generate-dashboard skill) ---------------------------
889
- // A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboard/custom/<id>.json, embedded
908
+ // A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboards/<id>.json, embedded
890
909
  // in P.customDashboards by the server) — never raw HTML/CSS/JS. Every block below reuses the exact
891
910
  // same components the built-in Board renders with (kpiCard/ocard/bars/donut/statTile/mdLite/el), so a
892
911
  // generated dashboard automatically matches the active design — and any design switched to later —
893
- // with zero page-specific styling. Mirrors the schema in .spectoflow/lib/custom-dashboard.js (the
894
- // Node-side validator); this is the independent browser-side reader for the same shape.
912
+ // with zero page-specific styling. The block schema is enforced by `spectoflow dashboard validate`
913
+ // (Node-side validator); this is the independent browser-side reader for the same shape.
895
914
  function resolveBind(s,bindPath,fallback){
896
915
  if(bindPath==null) return fallback;
897
916
  let v=s;
@@ -1070,6 +1089,7 @@ function tabFromPath(){
1070
1089
  }
1071
1090
  function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
1072
1091
  function navigateTab(tabId,push){
1092
+ closeWfPop(); // an open popover is anchored to whichever panel is currently active — never carry it across
1073
1093
  activeTab=tabId; try{ localStorage.setItem('spf-tab',tabId); }catch{}
1074
1094
  if(push!==false){
1075
1095
  const isCustom=tabId.indexOf('custom:')===0;
@@ -1711,7 +1731,7 @@ document.addEventListener('click',e=>{ if(!document.body.classList.contains('nav
1711
1731
  document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeNav(); });
1712
1732
  // workflow step popover — close on outside click / Esc / resize. (No scroll-to-close: a capture
1713
1733
  // scroll listener also fires on the popover's own internal scroll, which would slam it shut.)
1714
- document.addEventListener('click',e=>{ if(wfPopStep && !e.target.closest('#wfPop') && !e.target.closest('.wf-step2')) closeWfPop(); });
1734
+ document.addEventListener('click',e=>{ if(wfPopStep && !e.target.closest('#wfPop') && !e.target.closest('.wf-step2') && !e.target.closest('.wf-mini')) closeWfPop(); });
1715
1735
  document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeWfPop(); });
1716
1736
  window.addEventListener('resize',()=>{ if(wfPopStep) closeWfPop(); });
1717
1737
  // keep the URL and the path in sync when the user uses the browser back/forward buttons
@@ -142,7 +142,12 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
142
142
  .legend-count { font-family:var(--mono); color:var(--ink); font-weight:600; }
143
143
 
144
144
  .wf-strip { display:flex; flex-wrap:wrap; align-items:center; gap:0; }
145
- .wf-mini { display:flex; align-items:center; gap:6px; padding:6px 11px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); font-size:11.5px; font-weight:600; white-space:nowrap; }
145
+ /* Clickable opens the same enable/disable popover as the dedicated Workflow tab (see
146
+ findWfAnchor/openWfPop in app.js), so a step can be toggled right from this Board glance strip. */
147
+ .wf-mini { display:flex; align-items:center; gap:6px; padding:6px 11px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); font-size:11.5px; font-weight:600; white-space:nowrap; cursor:pointer; transition:border-color .15s,transform .15s,box-shadow .15s; }
148
+ .wf-mini:hover { border-color:var(--signal); transform:translateY(-1px); }
149
+ .wf-mini:focus-visible { outline:2px solid var(--signal); outline-offset:1px; }
150
+ .wf-mini.is-selected { border-color:var(--signal); border-style:solid; box-shadow:0 0 0 3px color-mix(in srgb,var(--signal) 20%,transparent); }
146
151
  .wf-mini .dot { width:6px; height:6px; border-radius:50%; background:var(--s-done); }
147
152
  .wf-mini.off { opacity:.42; border-style:dashed; }
148
153
  .wf-mini.off .dot { background:var(--faint); }
@@ -5,11 +5,12 @@
5
5
  * printing sentinel lines (::spectoflow role=… kind=… msg=…) which become structured messages;
6
6
  * any other output streams raw as run-line events. `emit` publishes SSE events to the dashboard.
7
7
  *
8
- * Kept separate from server.js so the pipeline is unit-testable without an HTTP server.
8
+ * Kept separate from the HTTP layer so the pipeline is unit-testable without a server.
9
9
  */
10
10
  const { spawn } = require('child_process');
11
- const store = require('../lib/store');
12
- const agentsRegistry = require('../lib/agents-registry');
11
+ const store = require('../store');
12
+ const adapters = require('../adapters');
13
+ const detect = require('../detect');
13
14
 
14
15
  // The command to run `which`: config.json's own runners map first (an explicit user choice always
15
16
  // wins), falling back to the registry's default for a known, headless-capable, genuinely-installed
@@ -17,8 +18,8 @@ const agentsRegistry = require('../lib/agents-registry');
17
18
  // the project's "active agent" and had a runner seeded into config.json for it.
18
19
  function resolveRunnerCommand(root, cfg, which, opts) {
19
20
  if (cfg.runners && cfg.runners[which]) return cfg.runners[which];
20
- const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === which);
21
- if (known && known.runner && agentsRegistry.isAgentInstalled(which, root, opts)) return known.runner;
21
+ const known = adapters.knownAgents().find((a) => a.id === which);
22
+ if (known && known.runner && detect.isAgentInstalled(which, root, opts)) return known.runner;
22
23
  return null;
23
24
  }
24
25
 
@@ -7,7 +7,7 @@
7
7
  * sitting right below it doesn't condense anything — it just adds noise on top of noise.
8
8
  */
9
9
  const { spawn } = require('child_process');
10
- const store = require('../lib/store');
10
+ const store = require('../store');
11
11
  const { resolveRunnerCommand } = require('./runner');
12
12
 
13
13
  const DEFAULT_LIMIT = 40;
package/lib/detect.js CHANGED
@@ -31,4 +31,19 @@ function detectAgents(projectRoot, opts = {}) {
31
31
  }).map((a) => a.id);
32
32
  }
33
33
 
34
- module.exports = { binOnPath, detectAgents };
34
+ // True if `id` looks genuinely installed: its bin resolves on PATH, or the project already has its
35
+ // config dir (a project can be set up for an agent whose bin isn't on THIS machine's PATH, e.g. a
36
+ // remote/CI runner). Unknown ids are never "installed".
37
+ function isAgentInstalled(id, projectRoot, opts) {
38
+ const a = REGISTRY.find((x) => x.id === id);
39
+ if (!a) return false;
40
+ if (a.detect.bin && binOnPath(a.detect.bin, opts)) return true;
41
+ return (a.detect.dirs || []).some((d) => fs.existsSync(path.join(projectRoot, d)));
42
+ }
43
+
44
+ // ids of every known agent actually installed for this project, in REGISTRY (priority) order.
45
+ function installedAgents(projectRoot, opts) {
46
+ return REGISTRY.filter((a) => isAgentInstalled(a.id, projectRoot, opts)).map((a) => a.id);
47
+ }
48
+
49
+ module.exports = { binOnPath, detectAgents, isAgentInstalled, installedAgents };