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
@@ -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('../templates/lib/store');
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
- cfg.agent = agents[0];
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', '.spectoflow/.dashboard.lock']) {
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 — ~/.spectoflow/projects.json. Tracks every project spectoflow has seen (via
4
- * `spectoflow dashboard`, wired in a later sub-project), so the multi-project hub knows what to list
5
- * and switch between. This module owns only the registry file itself; it has no opinion about
6
- * dashboards, ports, or servers, and nothing else in the codebase calls it yet.
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
- // Resolution order: an explicit baseDir (unit tests) > SPECTOFLOW_HOME (CLI-level test isolation,
16
- // same convention as SPECTOFLOW_ROOT/SPECTOFLOW_PORT elsewhere in this codebase) > the real home dir.
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 || process.env.SPECTOFLOW_HOME || path.join(os.homedir(), '.spectoflow');
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 dashboards (.spectoflow/dashboard/custom/<id>.json) --------------
273
- // One JSON file per custom dashboard page (see lib/custom-dashboard.js for the block schema this
274
- // validates against). A malformed file is skipped, never thrown — one bad custom dashboard must
275
- // never take down the whole /api/project response.
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 dir = path.join(projectRoot, '.spectoflow', 'dashboard', 'custom');
278
- if (!fs.existsSync(dir)) return [];
279
- const out = [];
280
- for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) {
281
- try {
282
- const spec = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
283
- if (validateSpec(spec).valid) out.push(spec);
284
- } catch { /* skip malformed */ }
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 and no deletions in v1 — refresh the safe, offer the rest as .new.
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
  }
@@ -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.23.4",
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",
@@ -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/dashboard/custom/<id>.json`.
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, or `SPECTOFLOW_PORT` /
123
- `--port=NNNN`; falls back to `node .spectoflow/dashboard/server.js` if the CLI isn't on PATH). Zero
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`, or `node .spectoflow/dashboard/server.js`, unref'd/backgrounded so it doesn't block you),
130
- then share the URL. Always be able to answer "is the dashboard running?" — check, don't assume.
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.
@@ -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 (or: node .spectoflow/dashboard/server.js)
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
- | `dashboard/` | The zero-dependency control plane: `server.js` (SSE + file-watch), `runner.js`, `orchestrator.js`, `public/` (the UI, charts, designs, fonts), and `custom/` (your generated dashboards, one JSON spec per file). |
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
- (`.spectoflow/lib/custom-dashboard.js`), rendered by the exact same token-driven components the
33
- built-in Board uses. Why: this is what guarantees a generated dashboard matches the *active* design
34
- and every future one the user switches to, with zero page-specific CSS to keep in sync, and no
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 `templates/lib/store.js`'s flat parser can read
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/dashboard/custom/<id>.json`,
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
- `.spectoflow/lib/custom-dashboard.js`'s validation, or a skill/agent file whose front-matter the flat
75
- parser can't read, is not done — fix it before reporting completion, never leave a broken file for the
76
- dashboard to silently skip.
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
- - `.spectoflow/lib/custom-dashboard.js` — the declarative block vocabulary and its validator.
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 — see `templates/lib/custom-dashboard.js`),
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/dashboard/custom/<id>.json, live in the dashboard's nav on the next tick.
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 — see
71
- `.spectoflow/lib/custom-dashboard.js` for the enforced schema):
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/dashboard/custom/*.json` first) — this becomes the URL segment and the file name.
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/dashboard/custom/<id>.json` (pretty-printed, 2-space indent). Then
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
- node -e "console.log(JSON.stringify(require('./.spectoflow/lib/custom-dashboard').validateSpec(JSON.parse(require('fs').readFileSync('./.spectoflow/dashboard/custom/<id>.json','utf8')))))"
116
+ spectoflow dashboard validate .spectoflow/dashboards/<id>.json
117
117
  ```
118
- If `valid` is `false`, fix the reported errors and re-run before reporting done — a spec the
119
- dashboard's own validator rejects is never a finished deliverable, it would simply be skipped and the
120
- user would see nothing.
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/dashboard/custom/<id>.json`, valid against
125
- `.spectoflow/lib/custom-dashboard.js`'s `validateSpec` (verified per step 7, not assumed).
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
- - `.spectoflow/lib/custom-dashboard.js` — the enforced block schema and bind allow-list (source of
151
- truth; this document summarizes it, the code is authoritative).
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`.
@@ -1,3 +0,0 @@
1
- # This directory holds user-generated custom dashboard specs (created via the Customize page in
2
- # Settings, or written directly by the `generate-dashboard` skill). Empty on a fresh install.
3
- # Schema: templates/lib/custom-dashboard.js