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.
- package/README.md +21 -10
- package/bin/postinstall.js +1 -0
- package/bin/spectoflow.js +144 -35
- package/lib/adapters.js +8 -2
- package/{templates/lib → lib}/custom-dashboard.js +2 -2
- package/{templates/lib → lib}/customize-prompts.js +1 -1
- package/lib/dashboard/handlers.js +78 -0
- package/lib/{hub-server.js → dashboard/hub-server.js} +35 -44
- package/lib/dashboard/ops.js +176 -0
- package/{templates → lib}/dashboard/orchestrator.js +17 -8
- package/{templates → lib}/dashboard/public/app.js +33 -13
- package/{templates → lib}/dashboard/public/styles.css +6 -1
- package/{templates → lib}/dashboard/runner.js +6 -5
- package/{templates → lib}/dashboard/summarize.js +1 -1
- package/lib/detect.js +16 -1
- package/lib/global-config.js +65 -0
- package/lib/init.js +10 -4
- package/lib/registry.js +8 -8
- package/{templates/lib → lib}/store.js +16 -12
- package/lib/update.js +69 -1
- package/lib/workspace.js +84 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +6 -6
- package/templates/README.md +4 -3
- package/templates/agents/framework-curator.md +11 -11
- package/templates/capabilities.md +1 -1
- package/templates/dashboards/.gitkeep +0 -0
- package/templates/skills/generate-dashboard/SKILL.md +15 -13
- package/templates/dashboard/custom/.gitkeep +0 -3
- package/templates/dashboard/handlers.js +0 -251
- package/templates/dashboard/server.js +0 -73
- package/templates/lib/agents-registry.js +0 -65
- /package/{templates → lib}/dashboard/files.js +0 -0
- /package/{templates → lib}/dashboard/public/charts.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
- /package/{templates → lib}/dashboard/public/designs.js +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/hub.html +0 -0
- /package/{templates → lib}/dashboard/public/hub.js +0 -0
- /package/{templates → lib}/dashboard/public/i18n.js +0 -0
- /package/{templates → lib}/dashboard/public/icons.js +0 -0
- /package/{templates → lib}/dashboard/public/index.html +0 -0
- /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
- /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
- /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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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('
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
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
|
|
35
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
-
|
|
80
|
-
|
|
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('
|
|
124
|
+
const { runInit } = require('../init');
|
|
134
125
|
runInit({ target: abs, templatesDir: TEMPLATES, version: VERSION });
|
|
135
126
|
}
|
|
136
|
-
const entry =
|
|
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 —
|
|
172
|
-
//
|
|
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 =
|
|
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('../
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
21
|
-
//
|
|
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
|
|
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/
|
|
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.
|
|
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
|
-
|
|
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
|
|
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('../
|
|
12
|
-
const
|
|
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 =
|
|
21
|
-
if (known && 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('../
|
|
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
|
-
|
|
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 };
|