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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Global config — ~/.spectoflow/config.json (or $SPECTOFLOW_HOME/config.json). Settings that apply
|
|
4
|
+
* to every project on this machine: where the dashboard workspace lives, which dashboard URL
|
|
5
|
+
* projects talk to, and the defaults `spectoflow init` seeds a new project's config.json with.
|
|
6
|
+
* Layering, lowest to highest: kit templates < these defaults < the project's own config.json.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { REGISTRY } = require('./adapters');
|
|
12
|
+
|
|
13
|
+
const KEYS = ['dashboard.url', 'dashboard.path', 'defaults.agent', 'defaults.language', 'defaults.mode', 'defaults.design'];
|
|
14
|
+
const MODES = ['autopilot', 'semi', 'manual'];
|
|
15
|
+
|
|
16
|
+
function homeDir() { return process.env.SPECTOFLOW_HOME || path.join(os.homedir(), '.spectoflow'); }
|
|
17
|
+
function configPath() { return path.join(homeDir(), 'config.json'); }
|
|
18
|
+
function defaultDashboardPath() { return path.join(homeDir(), 'dashboard'); }
|
|
19
|
+
function expandHome(p) { return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p; }
|
|
20
|
+
|
|
21
|
+
function defaults() {
|
|
22
|
+
return { dashboard: { url: 'http://localhost:4319', path: defaultDashboardPath() }, defaults: { agent: 'claude', language: 'en', mode: 'semi', design: 'console' } };
|
|
23
|
+
}
|
|
24
|
+
function readRaw() {
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(configPath(), 'utf8')) || {}; } catch { return {}; }
|
|
26
|
+
}
|
|
27
|
+
function writeRaw(obj) {
|
|
28
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
29
|
+
fs.writeFileSync(configPath(), JSON.stringify(obj, null, 2) + '\n');
|
|
30
|
+
}
|
|
31
|
+
const getPath = (obj, key) => key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
|
|
32
|
+
const setPath = (obj, key, value) => { const ks = key.split('.'); let o = obj; for (const k of ks.slice(0, -1)) o = (o[k] = o[k] || {}); o[ks[ks.length - 1]] = value; };
|
|
33
|
+
|
|
34
|
+
function read() {
|
|
35
|
+
const d = defaults(), raw = readRaw();
|
|
36
|
+
return { dashboard: { ...d.dashboard, ...(raw.dashboard || {}) }, defaults: { ...d.defaults, ...(raw.defaults || {}) } };
|
|
37
|
+
}
|
|
38
|
+
function get(key) {
|
|
39
|
+
if (!KEYS.includes(key)) throw new Error(`unknown key "${key}" — valid keys: ${KEYS.join(', ')}`);
|
|
40
|
+
const raw = getPath(readRaw(), key);
|
|
41
|
+
return raw !== undefined ? { value: raw, source: 'set' } : { value: getPath(defaults(), key), source: 'default' };
|
|
42
|
+
}
|
|
43
|
+
function list() { return KEYS.map((key) => ({ key, ...get(key) })); }
|
|
44
|
+
|
|
45
|
+
function validate(key, value) {
|
|
46
|
+
const v = String(value).trim();
|
|
47
|
+
switch (key) {
|
|
48
|
+
case 'dashboard.url': { let u; try { u = new URL(v); } catch { throw new Error('dashboard.url must be a URL, e.g. http://localhost:4319'); } if (!/^https?:$/.test(u.protocol)) throw new Error('dashboard.url must start with http:// or https://'); return u.origin; }
|
|
49
|
+
case 'dashboard.path': { if (!v) throw new Error('dashboard.path must be a folder path'); return path.resolve(expandHome(v)); }
|
|
50
|
+
case 'defaults.agent': { if (!REGISTRY.some((a) => a.id === v)) throw new Error(`unknown agent "${v}" — one of: ${REGISTRY.map((a) => a.id).join(', ')}`); return v; }
|
|
51
|
+
case 'defaults.language': { if (!/^[a-z]{2}$/.test(v)) throw new Error('defaults.language must be a 2-letter code (en, fr, es, de, pt, it…)'); return v; }
|
|
52
|
+
case 'defaults.mode': { if (!MODES.includes(v)) throw new Error(`defaults.mode must be one of: ${MODES.join(', ')}`); return v; }
|
|
53
|
+
case 'defaults.design': { if (!/^[a-z0-9-]{1,40}$/.test(v)) throw new Error('defaults.design must be a design id (console, orbit, …)'); return v; }
|
|
54
|
+
default: throw new Error(`unknown key "${key}" — valid keys: ${KEYS.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function set(key, value) {
|
|
58
|
+
const v = validate(key, value);
|
|
59
|
+
const raw = readRaw(); setPath(raw, key, v); writeRaw(raw);
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
// Creates the file (empty object) if it doesn't exist — never touches an existing one.
|
|
63
|
+
function ensure() { if (!fs.existsSync(configPath())) writeRaw({}); }
|
|
64
|
+
|
|
65
|
+
module.exports = { KEYS, homeDir, configPath, defaultDashboardPath, expandHome, read, get, set, list, ensure };
|
package/lib/init.js
CHANGED
|
@@ -12,7 +12,8 @@ const adapters = require('./adapters');
|
|
|
12
12
|
const ownership = require('./ownership');
|
|
13
13
|
const manifest = require('./manifest');
|
|
14
14
|
const mcp = require('./mcp');
|
|
15
|
-
const store = require('
|
|
15
|
+
const store = require('./store');
|
|
16
|
+
const globalConfig = require('./global-config');
|
|
16
17
|
|
|
17
18
|
function copyDir(src, dst) {
|
|
18
19
|
fs.mkdirSync(dst, { recursive: true });
|
|
@@ -47,7 +48,7 @@ function normalizePlans(root, config) {
|
|
|
47
48
|
return added;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function runInit({ target, templatesDir, version, agentsArg }) {
|
|
51
|
+
function runInit({ target, templatesDir, version, agentsArg, defaults }) {
|
|
51
52
|
fs.mkdirSync(target, { recursive: true });
|
|
52
53
|
const notes = [];
|
|
53
54
|
|
|
@@ -79,7 +80,12 @@ function runInit({ target, templatesDir, version, agentsArg }) {
|
|
|
79
80
|
|
|
80
81
|
const cfgPath = path.join(spectoflowDir, 'config.json');
|
|
81
82
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
82
|
-
|
|
83
|
+
const d = defaults || globalConfig.read().defaults;
|
|
84
|
+
cfg.mode = d.mode; cfg.language = d.language; cfg.design = d.design;
|
|
85
|
+
// The active agent: an explicit --agent wins; else the global default when it's actually detected
|
|
86
|
+
// here (or nothing is); else the first detected one.
|
|
87
|
+
cfg.agent = agentsArg ? agents[0] : ((detected.includes(d.agent) || !detected.length) ? d.agent : detected[0]);
|
|
88
|
+
if (!agents.includes(cfg.agent)) agents.unshift(cfg.agent);
|
|
83
89
|
cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
|
|
84
90
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
85
91
|
|
|
@@ -106,7 +112,7 @@ function runInit({ target, templatesDir, version, agentsArg }) {
|
|
|
106
112
|
|
|
107
113
|
const gi = path.join(target, '.gitignore');
|
|
108
114
|
const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
109
|
-
for (const line of ['.spectoflow/runtime.json'
|
|
115
|
+
for (const line of ['.spectoflow/runtime.json']) {
|
|
110
116
|
if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
|
|
111
117
|
}
|
|
112
118
|
|
package/lib/registry.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/*
|
|
3
|
-
* The project registry —
|
|
4
|
-
*
|
|
5
|
-
* and switch between. This module owns only the registry
|
|
6
|
-
*
|
|
3
|
+
* The project registry — projects.json inside the dashboard workspace (see lib/workspace.js; default
|
|
4
|
+
* ~/.spectoflow/dashboard/). Tracks every project spectoflow has seen (via `spectoflow dashboard`), so
|
|
5
|
+
* the multi-project hub knows what to list and switch between. This module owns only the registry
|
|
6
|
+
* file itself; it has no opinion about ports or servers.
|
|
7
7
|
*/
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
|
-
const os = require('os');
|
|
11
10
|
const crypto = require('crypto');
|
|
12
11
|
|
|
13
12
|
const REGISTRY_FILE = 'projects.json';
|
|
14
13
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// The registry lives inside the dashboard workspace (D64). An explicit baseDir (unit tests) wins;
|
|
15
|
+
// otherwise the workspace is wherever the global config says (default $SPECTOFLOW_HOME/dashboard).
|
|
17
16
|
function registryDir(baseDir) {
|
|
18
|
-
return baseDir ||
|
|
17
|
+
return baseDir || require('./global-config').read().dashboard.path;
|
|
19
18
|
}
|
|
20
19
|
function registryPath(baseDir) {
|
|
21
20
|
return path.join(registryDir(baseDir), REGISTRY_FILE);
|
|
@@ -62,6 +61,7 @@ function addProject(projectPath, baseDir) {
|
|
|
62
61
|
path: target,
|
|
63
62
|
name: path.basename(target),
|
|
64
63
|
lastOpened: new Date().toISOString(),
|
|
64
|
+
kind: 'spectoflow',
|
|
65
65
|
};
|
|
66
66
|
reg.projects.push(entry);
|
|
67
67
|
} else {
|
|
@@ -269,19 +269,23 @@ function readWorkflow(projectRoot) {
|
|
|
269
269
|
} catch { return []; }
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
// ---- user-generated custom
|
|
273
|
-
// One JSON file per custom dashboard page (
|
|
274
|
-
//
|
|
275
|
-
//
|
|
272
|
+
// ---- user-generated custom views (.spectoflow/dashboards/<id>.json) ---------------------------
|
|
273
|
+
// One JSON file per custom dashboard page (lib/custom-dashboard.js owns the block schema). The
|
|
274
|
+
// pre-0.24 location (.spectoflow/dashboard/custom/) is still read — a project that hasn't run
|
|
275
|
+
// `spectoflow update` yet must show its views — but the new folder wins on an id collision. A
|
|
276
|
+
// malformed file is skipped, never thrown.
|
|
277
|
+
const CUSTOM_VIEW_DIRS = [['.spectoflow', 'dashboards'], ['.spectoflow', 'dashboard', 'custom']];
|
|
276
278
|
function readCustomDashboards(projectRoot) {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
279
|
+
const out = []; const seen = new Set();
|
|
280
|
+
for (const parts of CUSTOM_VIEW_DIRS) {
|
|
281
|
+
const dir = path.join(projectRoot, ...parts);
|
|
282
|
+
if (!fs.existsSync(dir)) continue;
|
|
283
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) {
|
|
284
|
+
try {
|
|
285
|
+
const spec = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
286
|
+
if (validateSpec(spec).valid && !seen.has(spec.id)) { seen.add(spec.id); out.push(spec); }
|
|
287
|
+
} catch { /* skip malformed */ }
|
|
288
|
+
}
|
|
285
289
|
}
|
|
286
290
|
return out;
|
|
287
291
|
}
|
package/lib/update.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* otherwise (user-edited, or legacy-divergent) . preserve; write <file>.new for manual merge
|
|
10
10
|
*
|
|
11
11
|
* User-owned files (config.json, workflow.md) are not in the framework set, so they are never read
|
|
12
|
-
* for writing. No 3-way auto-merge
|
|
12
|
+
* for writing. No 3-way auto-merge for a diverged framework file — refresh the safe, offer the rest
|
|
13
|
+
* as .new. Files the kit no longer ships (retired since D64) get their own rule below: deleted only
|
|
14
|
+
* when untouched by the user, kept and reported otherwise — never via .new, and never via --force.
|
|
13
15
|
*/
|
|
14
16
|
const fs = require('fs');
|
|
15
17
|
const path = require('path');
|
|
@@ -20,6 +22,47 @@ function toDisk(sf, rel) {
|
|
|
20
22
|
return path.join(sf, rel.split('/').join(path.sep));
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
// Files the kit shipped before 0.24 and no longer does. Used ONLY for the no-manifest hint: with a
|
|
26
|
+
// manifest, retired files are computed from it, not from this list.
|
|
27
|
+
const LEGACY_LEFTOVERS = ['dashboard', 'lib/store.js', 'lib/agents-registry.js', 'lib/customize-prompts.js', 'lib/custom-dashboard.js'];
|
|
28
|
+
|
|
29
|
+
// Data migration (0.23 → 0.24): custom views out of the old dashboard folder, the per-project lock
|
|
30
|
+
// and its .gitignore line gone. Runs before any removal, is idempotent, and never overwrites.
|
|
31
|
+
function migrateProjectData(projectRoot, sf, dryRun) {
|
|
32
|
+
const r = { movedViews: [], conflicts: [], removedLock: false, gitignoreCleaned: false };
|
|
33
|
+
const oldDir = path.join(sf, 'dashboard', 'custom'), newDir = path.join(sf, 'dashboards');
|
|
34
|
+
if (fs.existsSync(oldDir)) {
|
|
35
|
+
for (const f of fs.readdirSync(oldDir).filter((x) => x.endsWith('.json')).sort()) {
|
|
36
|
+
if (fs.existsSync(path.join(newDir, f))) { r.conflicts.push(f); continue; }
|
|
37
|
+
r.movedViews.push(f);
|
|
38
|
+
if (!dryRun) { fs.mkdirSync(newDir, { recursive: true }); fs.renameSync(path.join(oldDir, f), path.join(newDir, f)); }
|
|
39
|
+
}
|
|
40
|
+
// Every view that could move did; if nothing was left behind (no conflict), the folder the
|
|
41
|
+
// retired-files loop can't see (it never tracked a data folder as a framework file) won't prune
|
|
42
|
+
// itself — do it here so an all-clear migration doesn't block dashboard/ from fully retiring.
|
|
43
|
+
if (!dryRun) { try { if (fs.readdirSync(oldDir).length === 0) fs.rmdirSync(oldDir); } catch { /* not empty, or already gone */ } }
|
|
44
|
+
}
|
|
45
|
+
const lock = path.join(sf, '.dashboard.lock');
|
|
46
|
+
if (fs.existsSync(lock)) { r.removedLock = true; if (!dryRun) fs.unlinkSync(lock); }
|
|
47
|
+
const gi = path.join(projectRoot, '.gitignore');
|
|
48
|
+
if (fs.existsSync(gi)) {
|
|
49
|
+
const lines = fs.readFileSync(gi, 'utf8').split('\n');
|
|
50
|
+
const kept = lines.filter((l) => l.trim() !== '.spectoflow/.dashboard.lock');
|
|
51
|
+
if (kept.length !== lines.length) { r.gitignoreCleaned = true; if (!dryRun) fs.writeFileSync(gi, kept.join('\n')); }
|
|
52
|
+
}
|
|
53
|
+
return r;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Remove `fp`, then every now-empty parent up to (not including) `stop`.
|
|
57
|
+
function removeAndPrune(fp, stop) {
|
|
58
|
+
fs.unlinkSync(fp);
|
|
59
|
+
let dir = path.dirname(fp);
|
|
60
|
+
while (dir.startsWith(stop + path.sep)) {
|
|
61
|
+
try { if (fs.readdirSync(dir).length) break; fs.rmdirSync(dir); } catch { break; }
|
|
62
|
+
dir = path.dirname(dir);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
function runUpdate({ projectRoot, templatesDir, version, dryRun = false, force = false }) {
|
|
24
67
|
const sf = path.join(projectRoot, '.spectoflow');
|
|
25
68
|
const prev = manifest.readManifest(sf);
|
|
@@ -34,9 +77,14 @@ function runUpdate({ projectRoot, templatesDir, version, dryRun = false, force =
|
|
|
34
77
|
adopted: [],
|
|
35
78
|
unchanged: [],
|
|
36
79
|
forced: [],
|
|
80
|
+
removed: [],
|
|
81
|
+
kept: [],
|
|
82
|
+
migration: null,
|
|
83
|
+
legacyLeftovers: [],
|
|
37
84
|
};
|
|
38
85
|
const baseline = (prev && prev.files) || {};
|
|
39
86
|
const nextFiles = {}; // manifest to write after this run
|
|
87
|
+
report.migration = migrateProjectData(projectRoot, sf, dryRun);
|
|
40
88
|
|
|
41
89
|
const write = (fp, buf) => {
|
|
42
90
|
if (dryRun) return;
|
|
@@ -77,6 +125,26 @@ function runUpdate({ projectRoot, templatesDir, version, dryRun = false, force =
|
|
|
77
125
|
}
|
|
78
126
|
}
|
|
79
127
|
|
|
128
|
+
// Retired files: in the previous manifest, no longer in the kit. Intact (hash == baseline) → gone;
|
|
129
|
+
// modified → kept and reported, and it stays in the manifest so the next run warns again. --force
|
|
130
|
+
// never applies here: there is no kit version to restore, so nothing legitimate to force.
|
|
131
|
+
const kit = new Set(ownership.listFrameworkFiles(templatesDir));
|
|
132
|
+
for (const rel of Object.keys(baseline)) {
|
|
133
|
+
if (kit.has(rel)) continue;
|
|
134
|
+
const diskPath = toDisk(sf, rel);
|
|
135
|
+
if (!fs.existsSync(diskPath)) continue; // already gone — just drop it from the manifest
|
|
136
|
+
if (manifest.sha256(fs.readFileSync(diskPath)) === baseline[rel]) {
|
|
137
|
+
if (!dryRun) removeAndPrune(diskPath, sf);
|
|
138
|
+
report.removed.push(rel);
|
|
139
|
+
} else {
|
|
140
|
+
report.kept.push(rel);
|
|
141
|
+
nextFiles[rel] = baseline[rel];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!prev) {
|
|
145
|
+
for (const rel of LEGACY_LEFTOVERS) if (fs.existsSync(toDisk(sf, rel))) report.legacyLeftovers.push(rel);
|
|
146
|
+
}
|
|
147
|
+
|
|
80
148
|
if (!dryRun) manifest.writeManifest(sf, { version, files: nextFiles });
|
|
81
149
|
return report;
|
|
82
150
|
}
|
package/lib/workspace.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The dashboard workspace — the dashboard's own state, outside every project: dashboard.json
|
|
4
|
+
* (name/port/design), projects.json (the registry), hub.lock, and projects/<id>/ for dashboard-side
|
|
5
|
+
* per-project data (meta.json today; B adds a scan cache, C adds members/tokens). Default location
|
|
6
|
+
* $SPECTOFLOW_HOME/dashboard (~/.spectoflow/dashboard); movable via global config dashboard.path.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const globalConfig = require('./global-config');
|
|
11
|
+
const registry = require('./registry');
|
|
12
|
+
|
|
13
|
+
const SETTINGS_DEFAULTS = { name: null, port: 4319, design: 'console' };
|
|
14
|
+
|
|
15
|
+
function dir(baseDir) { return baseDir || globalConfig.read().dashboard.path; }
|
|
16
|
+
function settingsPath(baseDir) { return path.join(dir(baseDir), 'dashboard.json'); }
|
|
17
|
+
function lockPath(baseDir) { return path.join(dir(baseDir), 'hub.lock'); }
|
|
18
|
+
function projectDir(id, baseDir) { return path.join(dir(baseDir), 'projects', id); }
|
|
19
|
+
|
|
20
|
+
function readJSON(fp) { try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; } }
|
|
21
|
+
function settings(baseDir) {
|
|
22
|
+
const raw = readJSON(settingsPath(baseDir)) || {};
|
|
23
|
+
const s = { ...SETTINGS_DEFAULTS, ...raw };
|
|
24
|
+
if (!s.name) s.name = path.basename(dir(baseDir));
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
function exists(baseDir) { return fs.existsSync(settingsPath(baseDir)); }
|
|
28
|
+
|
|
29
|
+
// Idempotent: creates what is missing, never deletes, updates dashboard.json only for fields passed.
|
|
30
|
+
// Moving the workspace (a new `path`) carries the registry over when the new one has no projects.
|
|
31
|
+
function init({ path: newPath, port, name, design } = {}) {
|
|
32
|
+
migrateLegacyHome(); // pre-0.24 registry/lock, if still stranded in the bare home dir
|
|
33
|
+
const prevDir = globalConfig.read().dashboard.path;
|
|
34
|
+
const target = newPath ? path.resolve(globalConfig.expandHome(newPath)) : prevDir;
|
|
35
|
+
const created = !exists(target);
|
|
36
|
+
fs.mkdirSync(path.join(target, 'projects'), { recursive: true });
|
|
37
|
+
const s = { ...SETTINGS_DEFAULTS, ...(readJSON(settingsPath(target)) || {}) };
|
|
38
|
+
if (!s.name) s.name = path.basename(target);
|
|
39
|
+
if (name !== undefined) s.name = String(name);
|
|
40
|
+
if (port !== undefined) s.port = Number(port);
|
|
41
|
+
if (design !== undefined) s.design = String(design);
|
|
42
|
+
fs.writeFileSync(settingsPath(target), JSON.stringify(s, null, 2) + '\n');
|
|
43
|
+
let registryCarried = false;
|
|
44
|
+
const targetReg = registry.readRegistry(target);
|
|
45
|
+
if (!fs.existsSync(registry.registryPath(target))) {
|
|
46
|
+
const prevReg = path.resolve(prevDir) !== path.resolve(target) ? registry.readRegistry(prevDir) : { projects: [] };
|
|
47
|
+
if (prevReg.projects.length && !targetReg.projects.length) { registry.writeRegistry(target, prevReg); registryCarried = true; }
|
|
48
|
+
else registry.writeRegistry(target, { projects: [] });
|
|
49
|
+
}
|
|
50
|
+
if (path.resolve(target) !== path.resolve(prevDir) || globalConfig.get('dashboard.path').source === 'default') globalConfig.set('dashboard.path', target);
|
|
51
|
+
return { dir: target, created, registryCarried };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function registerProject(projectPath, baseDir) {
|
|
55
|
+
const entry = registry.addProject(projectPath, baseDir);
|
|
56
|
+
const pd = projectDir(entry.id, baseDir);
|
|
57
|
+
fs.mkdirSync(pd, { recursive: true });
|
|
58
|
+
const metaPath = path.join(pd, 'meta.json');
|
|
59
|
+
if (!fs.existsSync(metaPath)) fs.writeFileSync(metaPath, JSON.stringify({ addedAt: new Date().toISOString(), lastOpened: entry.lastOpened, kind: entry.kind || 'spectoflow' }, null, 2) + '\n');
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pre-0.24 the registry and lock sat directly in ~/.spectoflow/. Move them into the workspace the
|
|
64
|
+
// first time the new code runs — one-time, and only when the workspace has none of its own.
|
|
65
|
+
function migrateLegacyHome(baseDir) {
|
|
66
|
+
const home = globalConfig.homeDir();
|
|
67
|
+
const target = dir(baseDir);
|
|
68
|
+
const r = { movedRegistry: false, movedLock: false };
|
|
69
|
+
if (path.resolve(home) === path.resolve(target)) return r;
|
|
70
|
+
fs.mkdirSync(target, { recursive: true });
|
|
71
|
+
const legacyReg = path.join(home, 'projects.json');
|
|
72
|
+
if (fs.existsSync(legacyReg) && !fs.existsSync(registry.registryPath(target))) { fs.renameSync(legacyReg, registry.registryPath(target)); r.movedRegistry = true; }
|
|
73
|
+
const legacyLock = path.join(home, 'hub.lock');
|
|
74
|
+
if (fs.existsSync(legacyLock) && !fs.existsSync(lockPath(target))) { fs.renameSync(legacyLock, lockPath(target)); r.movedLock = true; }
|
|
75
|
+
return r;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The hub's lock: the workspace's, else a legacy one still written by a pre-0.24 hub that may be
|
|
79
|
+
// running right now (so `dashboard status/stop` keep finding it across the upgrade).
|
|
80
|
+
function readLock(baseDir) {
|
|
81
|
+
return readJSON(lockPath(baseDir)) || readJSON(path.join(globalConfig.homeDir(), 'hub.lock'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { dir, exists, settings, settingsPath, lockPath, projectDir, init, registerProject, migrateLegacyHome, readLock };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectoflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
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",
|
package/templates/AGENTS.md
CHANGED
|
@@ -91,7 +91,7 @@ worth building" for the Auto mode). Hand it to the `framework-curator` agent (ca
|
|
|
91
91
|
|
|
92
92
|
- **`generate-dashboard`** — a declarative block-spec page (never raw HTML/CSS/JS — see
|
|
93
93
|
`.spectoflow/skills/generate-dashboard` for why), written to
|
|
94
|
-
`.spectoflow/
|
|
94
|
+
`.spectoflow/dashboards/<id>.json`.
|
|
95
95
|
- **`generate-skill`** — a new `.spectoflow/skills/<slug>/SKILL.md`, grounded in real, cited domain
|
|
96
96
|
standards, following the gold-standard shape.
|
|
97
97
|
- **`generate-agent`** — a new `.spectoflow/agents/<slug>.md` persona, same shape discipline.
|
|
@@ -119,12 +119,12 @@ destructive migration, security). Mode sets routine friction; policy is non-nego
|
|
|
119
119
|
|
|
120
120
|
## Dashboard
|
|
121
121
|
|
|
122
|
-
Launch it with `spectoflow dashboard` (default http://localhost:4319
|
|
123
|
-
|
|
124
|
-
deps, live via SSE.
|
|
122
|
+
Launch it with `spectoflow dashboard` (default http://localhost:4319 — or the workspace's port; `--port=NNNN`
|
|
123
|
+
overrides). The dashboard is part of the spectoflow package, not of this project: nothing under
|
|
124
|
+
`.spectoflow/` runs it. Zero deps, live via SSE.
|
|
125
125
|
|
|
126
126
|
**At the end of `init`, and on the first request in a session,** check whether the dashboard is
|
|
127
127
|
running — UNLESS the user said they don't want it, or `.spectoflow/config.json` →
|
|
128
128
|
`dashboard.autostart` is `false`. If it's not running, start it **detached** (spawn `spectoflow
|
|
129
|
-
dashboard`,
|
|
130
|
-
|
|
129
|
+
dashboard`, unref'd/backgrounded so it doesn't block you), then share the URL. Always be able to
|
|
130
|
+
answer "is the dashboard running?" — check (`spectoflow dashboard status`), don't assume.
|
package/templates/README.md
CHANGED
|
@@ -20,10 +20,12 @@ sit at the project root and just point back here.
|
|
|
20
20
|
(skill `clarify`, wired into the agent's memory in `AGENTS.md`).
|
|
21
21
|
- **Watch it live** in the dashboard (it starts in the background and hands the prompt back):
|
|
22
22
|
```
|
|
23
|
-
spectoflow dashboard # → http://localhost:4319
|
|
23
|
+
spectoflow dashboard # → http://localhost:4319
|
|
24
24
|
spectoflow dashboard status # is it running? (url + pid)
|
|
25
25
|
spectoflow dashboard stop # stop it (alias: spectoflow stop)
|
|
26
26
|
spectoflow dashboard restart # stop then start
|
|
27
|
+
spectoflow dashboard init --path <dir> # move the dashboard workspace (default ~/.spectoflow/dashboard)
|
|
28
|
+
spectoflow config # global defaults + dashboard URL/path
|
|
27
29
|
spectoflow status # progress + whether the dashboard is running
|
|
28
30
|
```
|
|
29
31
|
- **See what you got:** `spectoflow list` (agents, skills & workflow at a glance), or `spectoflow
|
|
@@ -63,11 +65,10 @@ Your **artifacts are markdown, and they live at the project root, not in here**:
|
|
|
63
65
|
| `config.json` | Your settings: `mode`, `language`, active `agent`, `runners`, `design`, plans/specs dir. **Yours to edit** — `update` never overwrites it. |
|
|
64
66
|
| `agents/` | **Stable team personas** (product-manager, developer, qa-engineer, code-reviewer, spec-source-guardian…) — the *who*. |
|
|
65
67
|
| `skills/` | **Evolving procedures** (clarify, brainstorm, write-spec, write-plan, implement, write-e2e-tests, code-review, audit-source…) — the *how*. A workflow step → a capability → its agent → runs a skill. |
|
|
66
|
-
| `
|
|
68
|
+
| `dashboards/` | Your generated custom dashboards, one JSON spec per file — the *only* dashboard-related thing that lives in a project; the dashboard's own code is part of the spectoflow package, not vendored here. |
|
|
67
69
|
| `lib/` | The markdown storage engine (`store.js`) and helpers (e.g. `spec-drift.js` for the spec-source-guardian). |
|
|
68
70
|
| `hooks/` | Optional Claude Code hooks you can wire in yourself (e.g. `spec-drift.js`, a `Stop` hook that surfaces source-of-truth drift to the Attention tab). |
|
|
69
71
|
| `runtime.json` | **Volatile execution state** (running agents, orchestration, group-chat messages, attention items, history). Gitignored — safe to delete; it's rebuilt. |
|
|
70
|
-
| `.dashboard.lock` | Ephemeral pidfile so `spectoflow stop` can find the running dashboard. Gitignored. |
|
|
71
72
|
| `.manifest.json` | Hashes of the framework files at install time, so `update` can tell an untouched file from one you edited. |
|
|
72
73
|
|
|
73
74
|
## Principles (why it's shaped this way)
|
|
@@ -28,11 +28,10 @@ project's own team will use to build product features.
|
|
|
28
28
|
## Operating standards
|
|
29
29
|
|
|
30
30
|
- **Declarative dashboards, never raw markup (see `generate-dashboard`).** A custom dashboard is
|
|
31
|
-
produced as a block spec chosen from the framework's fixed vocabulary
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
arbitrary generated code ever executing in the dashboard.
|
|
31
|
+
produced as a block spec chosen from the framework's fixed vocabulary, rendered by the exact same
|
|
32
|
+
token-driven components the built-in Board uses. Why: this is what guarantees a generated dashboard
|
|
33
|
+
matches the *active* design and every future one the user switches to, with zero page-specific CSS
|
|
34
|
+
to keep in sync, and no arbitrary generated code ever executing in the dashboard.
|
|
36
35
|
- **Gold-standard shape for skills and agents (`docs/agents-skills-standard.md`).** A generated
|
|
37
36
|
`SKILL.md` or agent `.md` follows the exact same front-matter and heading structure as every
|
|
38
37
|
shipped one — `## When to use` / `## Method` / `## Output contract` / `## Quality bar` /
|
|
@@ -61,19 +60,19 @@ A generated dashboard renders correctly in every shipped design (light and dark)
|
|
|
61
60
|
hardcoded color or manual style — verified by construction, since only the declarative block
|
|
62
61
|
vocabulary was used. A generated skill or agent passes the same quality bar the framework's own
|
|
63
62
|
shipped files are held to: real citations in `## References`, a checkable `## Quality bar` /
|
|
64
|
-
`## Definition of done`, and front-matter that
|
|
63
|
+
`## Definition of done`, and front-matter that the dashboard's flat parser can read
|
|
65
64
|
unchanged. The new dashboard tab, skill, or agent is visible in the dashboard (Board's nav / Agents &
|
|
66
65
|
Skills tab) on the very next SSE tick — no manual refresh, no extra registration step.
|
|
67
66
|
|
|
68
67
|
## Handoff
|
|
69
68
|
|
|
70
|
-
Writes the generated file(s) directly (`.spectoflow/
|
|
69
|
+
Writes the generated file(s) directly (`.spectoflow/dashboards/<id>.json`,
|
|
71
70
|
`.spectoflow/skills/<slug>/SKILL.md`, or `.spectoflow/agents/<slug>.md`) and reports through the
|
|
72
71
|
`::spectoflow` sentinel (see each skill's Output contract for its exact syntax) so the requester sees
|
|
73
72
|
it land in the group chat and the dashboard picks it up live. A dashboard spec that fails
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
`spectoflow dashboard validate`, or a skill/agent file whose front-matter the flat parser can't read,
|
|
74
|
+
is not done — fix it before reporting completion, never leave a broken file for the dashboard to
|
|
75
|
+
silently skip.
|
|
77
76
|
|
|
78
77
|
## Guardrails
|
|
79
78
|
|
|
@@ -90,5 +89,6 @@ dashboard to silently skip.
|
|
|
90
89
|
## References
|
|
91
90
|
|
|
92
91
|
- `docs/agents-skills-standard.md` — the gold-standard shape this role's output must match.
|
|
93
|
-
-
|
|
92
|
+
- `spectoflow dashboard validate <file>` — the declarative block vocabulary's validator (in the
|
|
93
|
+
spectoflow package).
|
|
94
94
|
- `.spectoflow/skills/clarify` — the reflex this role leans on before generating from an ambiguous ask.
|
|
@@ -17,7 +17,7 @@ the Clarify step in `AGENTS.md`.
|
|
|
17
17
|
`customization` is also **not a workflow step** — it is triggered explicitly, either from the
|
|
18
18
|
dashboard's Settings → Customize page or by a direct request ("add a dashboard for…", "create a skill
|
|
19
19
|
for…", "create an agent for…"). The `framework-curator` agent owns it, running one of four skills:
|
|
20
|
-
`generate-dashboard` (a declarative block-spec page —
|
|
20
|
+
`generate-dashboard` (a declarative block-spec page — validated by `spectoflow dashboard validate`),
|
|
21
21
|
`generate-skill`, `generate-agent` (both follow `docs/agents-skills-standard.md`'s gold-standard
|
|
22
22
|
shape, grounded in real, cited domain standards), and `propose-customizations` (the "Auto" mode:
|
|
23
23
|
analyzes the project and proposes candidates instead of taking a description). Still gated by mode
|
|
File without changes
|
|
@@ -3,7 +3,7 @@ name: generate-dashboard
|
|
|
3
3
|
description: Turn a description (or an auto-analysis) into a new custom dashboard page, as a declarative block spec that automatically matches every design the dashboard ships.
|
|
4
4
|
capability: customization
|
|
5
5
|
inputs: A description of what the dashboard should show (from the Customize page or chat), or a chosen candidate from propose-customizations; the project's specs/plans/code as source material.
|
|
6
|
-
outputs: A validated block-spec JSON file at .spectoflow/
|
|
6
|
+
outputs: A validated block-spec JSON file at .spectoflow/dashboards/<id>.json, live in the dashboard's nav on the next tick.
|
|
7
7
|
standard: declarative UI generation; Few's dashboard design principles
|
|
8
8
|
---
|
|
9
9
|
# Generate dashboard
|
|
@@ -67,8 +67,8 @@ computed stats the Board already uses (see step 5).
|
|
|
67
67
|
|
|
68
68
|
### 4. Choose blocks — the vocabulary
|
|
69
69
|
|
|
70
|
-
Pick from exactly these block types (anything else is invisible to the renderer —
|
|
71
|
-
|
|
70
|
+
Pick from exactly these block types (anything else is invisible to the renderer — the block schema
|
|
71
|
+
documented below is enforced by `spectoflow dashboard validate`):
|
|
72
72
|
|
|
73
73
|
| `type` | Shape | Use for |
|
|
74
74
|
|---|---|---|
|
|
@@ -102,7 +102,7 @@ in one screen — 4-8 blocks is a healthy page, not 20.
|
|
|
102
102
|
### 6. Pick an id, a title, an icon
|
|
103
103
|
|
|
104
104
|
- `id`: lowercase kebab-case, unique among existing custom dashboards (list
|
|
105
|
-
`.spectoflow/
|
|
105
|
+
`.spectoflow/dashboards/*.json` first) — this becomes the URL segment and the file name.
|
|
106
106
|
- `title`: short, a few words, shown as the nav tab label.
|
|
107
107
|
- `icon`: one of `board`, `requests`, `backlog`, `workflow`, `agents`, `chat`, `info`, `attention`,
|
|
108
108
|
`settings` (the same set the rest of the dashboard uses — pick the closest match; default to `info`
|
|
@@ -110,19 +110,21 @@ in one screen — 4-8 blocks is a healthy page, not 20.
|
|
|
110
110
|
|
|
111
111
|
### 7. Write and verify
|
|
112
112
|
|
|
113
|
-
Write the spec to `.spectoflow/
|
|
113
|
+
Write the spec to `.spectoflow/dashboards/<id>.json` (pretty-printed, 2-space indent). Then
|
|
114
114
|
**verify it, don't assume it's valid** — run:
|
|
115
115
|
```
|
|
116
|
-
|
|
116
|
+
spectoflow dashboard validate .spectoflow/dashboards/<id>.json
|
|
117
117
|
```
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
(use `npx spectoflow …` if spectoflow isn't on PATH)
|
|
119
|
+
|
|
120
|
+
If the output shows errors, fix them and re-run before reporting done — a spec the dashboard's own
|
|
121
|
+
validator rejects is never a finished deliverable, it would simply be skipped and the user would see
|
|
122
|
+
nothing.
|
|
121
123
|
|
|
122
124
|
## Output contract
|
|
123
125
|
|
|
124
|
-
- One file: `.spectoflow/
|
|
125
|
-
|
|
126
|
+
- One file: `.spectoflow/dashboards/<id>.json`, valid against the block schema
|
|
127
|
+
(verified per step 7 with `spectoflow dashboard validate`, not assumed).
|
|
126
128
|
- Progress and completion reported to the orchestrator and group chat:
|
|
127
129
|
|
|
128
130
|
```
|
|
@@ -147,6 +149,6 @@ user would see nothing.
|
|
|
147
149
|
- Stephen Few, *Information Dashboard Design* (O'Reilly/Analytics Press) — one purpose per dashboard,
|
|
148
150
|
the plainest chart that carries the point, single-screen legibility.
|
|
149
151
|
https://www.perceptualedge.com/library.php
|
|
150
|
-
-
|
|
151
|
-
|
|
152
|
+
- `spectoflow dashboard validate <file>` — the declarative block vocabulary's validator (in the
|
|
153
|
+
spectoflow package; enforces the block schema and bind allow-list).
|
|
152
154
|
- `dashboard/public/stats.js` — the exact shape of the live stats object bindable via `bind`.
|