spectoflow 0.23.5 → 0.27.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 (90) hide show
  1. package/README.md +49 -16
  2. package/bin/postinstall.js +1 -0
  3. package/bin/spectoflow.js +226 -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/connector.js +193 -0
  8. package/lib/dashboard/handlers.js +53 -0
  9. package/lib/{hub-server.js → dashboard/hub-server.js} +134 -51
  10. package/lib/dashboard/inject-design.js +41 -0
  11. package/lib/dashboard/meeting.js +116 -0
  12. package/lib/dashboard/ops.js +257 -0
  13. package/{templates → lib}/dashboard/orchestrator.js +17 -8
  14. package/{templates → lib}/dashboard/public/app.js +743 -124
  15. package/{templates → lib}/dashboard/public/charts.js +3 -3
  16. package/lib/dashboard/public/commands.js +77 -0
  17. package/{templates → lib}/dashboard/public/designs/console.css +7 -19
  18. package/{templates → lib}/dashboard/public/designs/orbit.css +3 -4
  19. package/{templates → lib}/dashboard/public/designs.js +2 -2
  20. package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
  21. package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
  22. package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
  23. package/{templates → lib}/dashboard/public/hub.html +1 -1
  24. package/{templates → lib}/dashboard/public/hub.js +27 -4
  25. package/{templates → lib}/dashboard/public/i18n.js +60 -18
  26. package/{templates → lib}/dashboard/public/icons.js +2 -0
  27. package/{templates → lib}/dashboard/public/index.html +106 -13
  28. package/{templates → lib}/dashboard/public/styles.css +161 -26
  29. package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
  30. package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
  31. package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
  32. package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
  33. package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
  34. package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
  35. package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
  36. package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
  37. package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
  38. package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
  39. package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
  40. package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
  41. package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
  42. package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
  43. package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
  44. package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
  45. package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
  46. package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
  47. package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
  48. package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
  49. package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
  50. package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
  51. package/lib/dashboard/routes.js +38 -0
  52. package/{templates → lib}/dashboard/runner.js +13 -7
  53. package/{templates → lib}/dashboard/summarize.js +1 -1
  54. package/lib/detect.js +16 -1
  55. package/lib/global-config.js +65 -0
  56. package/lib/init.js +10 -4
  57. package/lib/registry.js +8 -8
  58. package/{templates/lib → lib}/store.js +16 -12
  59. package/lib/update.js +69 -1
  60. package/lib/workspace.js +119 -0
  61. package/package.json +3 -3
  62. package/templates/AGENTS.md +6 -6
  63. package/templates/README.md +4 -3
  64. package/templates/agents/framework-curator.md +11 -11
  65. package/templates/capabilities.md +1 -1
  66. package/templates/config.json +23 -0
  67. package/templates/dashboards/.gitkeep +0 -0
  68. package/templates/skills/generate-dashboard/SKILL.md +15 -13
  69. package/templates/dashboard/custom/.gitkeep +0 -3
  70. package/templates/dashboard/handlers.js +0 -251
  71. package/templates/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  72. package/templates/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  73. package/templates/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
  74. package/templates/dashboard/server.js +0 -73
  75. package/templates/lib/agents-registry.js +0 -65
  76. /package/{templates → lib}/dashboard/files.js +0 -0
  77. /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
  78. /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
  79. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
  80. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
  81. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
  82. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
  83. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
  84. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
  85. /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
  86. /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
  87. /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
  88. /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
  89. /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
  90. /package/{templates → lib}/dashboard/public/stats.js +0 -0
@@ -0,0 +1 @@
1
+ !function(e){var n=/[*&][^\s[\]{},]+/,r=/!(?:<[\w\-%#;/?:@&=+$,.!~*'()[\]]+>|(?:[a-zA-Z\d-]*!)?[\w\-%#;/?:@&=+$.~*'()]+)?/,t="(?:"+r.source+"(?:[ \t]+"+n.source+")?|"+n.source+"(?:[ \t]+"+r.source+")?)",a="(?:[^\\s\\x00-\\x08\\x0e-\\x1f!\"#%&'*,\\-:>?@[\\]`{|}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]|[?:-]<PLAIN>)(?:[ \t]*(?:(?![#:])<PLAIN>|:<PLAIN>))*".replace(/<PLAIN>/g,(function(){return"[^\\s\\x00-\\x08\\x0e-\\x1f,[\\]{}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]"})),d="\"(?:[^\"\\\\\r\n]|\\\\.)*\"|'(?:[^'\\\\\r\n]|\\\\.)*'";function o(e,n){n=(n||"").replace(/m/g,"")+"m";var r="([:\\-,[{]\\s*(?:\\s<<prop>>[ \t]+)?)(?:<<value>>)(?=[ \t]*(?:$|,|\\]|\\}|(?:[\r\n]\\s*)?#))".replace(/<<prop>>/g,(function(){return t})).replace(/<<value>>/g,(function(){return e}));return RegExp(r,n)}e.languages.yaml={scalar:{pattern:RegExp("([\\-:]\\s*(?:\\s<<prop>>[ \t]+)?[|>])[ \t]*(?:((?:\r?\n|\r)[ \t]+)\\S[^\r\n]*(?:\\2[^\r\n]+)*)".replace(/<<prop>>/g,(function(){return t}))),lookbehind:!0,alias:"string"},comment:/#.*/,key:{pattern:RegExp("((?:^|[:\\-,[{\r\n?])[ \t]*(?:<<prop>>[ \t]+)?)<<key>>(?=\\s*:\\s)".replace(/<<prop>>/g,(function(){return t})).replace(/<<key>>/g,(function(){return"(?:"+a+"|"+d+")"}))),lookbehind:!0,greedy:!0,alias:"atrule"},directive:{pattern:/(^[ \t]*)%.+/m,lookbehind:!0,alias:"important"},datetime:{pattern:o("\\d{4}-\\d\\d?-\\d\\d?(?:[tT]|[ \t]+)\\d\\d?:\\d{2}:\\d{2}(?:\\.\\d*)?(?:[ \t]*(?:Z|[-+]\\d\\d?(?::\\d{2})?))?|\\d{4}-\\d{2}-\\d{2}|\\d\\d?:\\d{2}(?::\\d{2}(?:\\.\\d*)?)?"),lookbehind:!0,alias:"number"},boolean:{pattern:o("false|true","i"),lookbehind:!0,alias:"important"},null:{pattern:o("null|~","i"),lookbehind:!0,alias:"important"},string:{pattern:o(d),lookbehind:!0,greedy:!0},number:{pattern:o("[+-]?(?:0x[\\da-f]+|0o[0-7]+|(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?|\\.inf|\\.nan)","i"),lookbehind:!0},tag:r,important:n,punctuation:/---|[:[\]{}\-,|>?]|\.\.\./},e.languages.yml=e.languages.yaml}(Prism);
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+ /*
3
+ * The dashboard's HTTP route table — method + path → op name + args extraction — with NO reference
4
+ * to ops.js. Two consumers share it: handlers.js (local hub, calls ops directly) and the online
5
+ * relay server (server/src/relay.js), which turns the same rows into `op` frames sent to the machine.
6
+ * Keeping it dependency-free is what lets the server require it without loading ops.js (which
7
+ * would drag in the runner, the orchestrator, agent detection… none of which exist server-side).
8
+ */
9
+ const seg = (p, i) => decodeURIComponent(p.split('/')[i] || '');
10
+ const q = (u, k) => u.searchParams.get(k) || '';
11
+ // [method, matcher, op, args(u, body, pathname)]
12
+ const ROUTES = [
13
+ ['GET', '/api/project', 'project.read', () => ({})],
14
+ ['GET', '/api/agentfile', 'agentfile.read', (u) => ({ path: q(u, 'path') })],
15
+ ['GET', '/api/files/tree', 'files.tree', () => ({})],
16
+ ['GET', '/api/files/read', 'files.read', (u) => ({ path: q(u, 'path') })],
17
+ ['POST', '/api/files/write', 'files.write', (_u, b) => b],
18
+ ['POST', '/api/files/mkdir', 'files.mkdir', (_u, b) => b],
19
+ ['POST', '/api/task', 'task.add', (_u, b) => b],
20
+ ['PATCH', /^\/api\/task\/[^/]+$/, 'task.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
21
+ ['POST', /^\/api\/task\/[^/]+\/comment$/, 'task.comment', (_u, b, p) => ({ id: seg(p, 3), text: b.text, action: b.action })],
22
+ ['POST', '/api/workflow/toggle', 'workflow.toggle', (_u, b) => b],
23
+ ['POST', '/api/run', 'run.start', (_u, b) => b],
24
+ ['POST', '/api/chat/summarize', 'chat.summarize', (_u, b) => b],
25
+ ['POST', '/api/meeting/generate', 'meeting.generate', (_u, b) => b],
26
+ ['POST', '/api/chat/clear', 'chat.clear', () => ({})],
27
+ ['POST', '/api/orchestrate', 'orchestrate.start', (_u, b) => b],
28
+ ['POST', '/api/orchestrate/approve', 'orchestrate.approve', (_u, b) => b],
29
+ ['POST', '/api/settings', 'settings.save', (_u, b) => b],
30
+ ['POST', '/api/attention', 'attention.add', (_u, b) => b],
31
+ ['POST', /^\/api\/attention\/[^/]+\/promote$/, 'attention.promote', (_u, _b, p) => ({ id: seg(p, 3) })],
32
+ ['PATCH', /^\/api\/attention\/[^/]+$/, 'attention.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
33
+ ['DELETE', /^\/api\/attention\/[^/]+$/, 'attention.remove', (_u, _b, p) => ({ id: seg(p, 3) })],
34
+ ];
35
+ const matches = (m, p) => (typeof m === 'string' ? m === p : m.test(p));
36
+ const findRoute = (method, pathname) => ROUTES.find(([m, matcher]) => m === method && matches(matcher, pathname));
37
+
38
+ module.exports = { ROUTES, seg, q, matches, findRoute };
@@ -5,11 +5,12 @@
5
5
  * printing sentinel lines (::spectoflow role=… kind=… msg=…) which become structured messages;
6
6
  * any other output streams raw as run-line events. `emit` publishes SSE events to the dashboard.
7
7
  *
8
- * Kept separate from server.js so the pipeline is unit-testable without an HTTP server.
8
+ * Kept separate from the HTTP layer so the pipeline is unit-testable without a server.
9
9
  */
10
10
  const { spawn } = require('child_process');
11
- const store = require('../lib/store');
12
- const agentsRegistry = require('../lib/agents-registry');
11
+ const store = require('../store');
12
+ const adapters = require('../adapters');
13
+ const detect = require('../detect');
13
14
 
14
15
  // The command to run `which`: config.json's own runners map first (an explicit user choice always
15
16
  // wins), falling back to the registry's default for a known, headless-capable, genuinely-installed
@@ -17,8 +18,8 @@ const agentsRegistry = require('../lib/agents-registry');
17
18
  // the project's "active agent" and had a runner seeded into config.json for it.
18
19
  function resolveRunnerCommand(root, cfg, which, opts) {
19
20
  if (cfg.runners && cfg.runners[which]) return cfg.runners[which];
20
- const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === which);
21
- if (known && known.runner && agentsRegistry.isAgentInstalled(which, root, opts)) return known.runner;
21
+ const known = adapters.knownAgents().find((a) => a.id === which);
22
+ if (known && known.runner && detect.isAgentInstalled(which, root, opts)) return known.runner;
22
23
  return null;
23
24
  }
24
25
 
@@ -59,7 +60,8 @@ function pushAttention(root, text, by) {
59
60
  // Start an agent run. Returns { runId, child } or { error } if no runner is configured.
60
61
  // logPrompt:false suppresses echoing the prompt as a user bubble — used by the orchestrator,
61
62
  // whose priming prompt ("You are the …") is machinery the user shouldn't have to read.
62
- function startRun(root, { prompt, agent, logPrompt = true }, emit) {
63
+ // display: when a non-empty string, the chat bubble shows this while the child still receives prompt.
64
+ function startRun(root, { prompt, agent, logPrompt = true, display }, emit) {
63
65
  const cfg = store.readConfig(root);
64
66
  const which = agent || cfg.agent || 'claude';
65
67
  const cmdStr = resolveRunnerCommand(root, cfg, which);
@@ -67,9 +69,13 @@ function startRun(root, { prompt, agent, logPrompt = true }, emit) {
67
69
  const parts = cmdStr.split(/\s+/).filter(Boolean);
68
70
  const runId = 'r' + Date.now().toString(36);
69
71
  const p = String(prompt).trim();
72
+ // The chat bubble can show a shorter, friendlier text than what the agent actually receives — e.g.
73
+ // a slash-command invocation "/rapport_jour focus bugs" instead of the full expanded instruction.
74
+ // The child (line ~84) always spawns with `p`, the real prompt.
75
+ const shown = (typeof display === 'string' && display.trim()) ? display.trim() : p;
70
76
 
71
77
  if (logPrompt) {
72
- const um = store.appendMessage(root, { role: 'user', kind: 'message', text: p, agent: which, runId });
78
+ const um = store.appendMessage(root, { role: 'user', kind: 'message', text: shown, agent: which, runId });
73
79
  emit({ type: 'message', message: um });
74
80
  }
75
81
 
@@ -7,7 +7,7 @@
7
7
  * sitting right below it doesn't condense anything — it just adds noise on top of noise.
8
8
  */
9
9
  const { spawn } = require('child_process');
10
- const store = require('../lib/store');
10
+ const store = require('../store');
11
11
  const { resolveRunnerCommand } = require('./runner');
12
12
 
13
13
  const DEFAULT_LIMIT = 40;
package/lib/detect.js CHANGED
@@ -31,4 +31,19 @@ function detectAgents(projectRoot, opts = {}) {
31
31
  }).map((a) => a.id);
32
32
  }
33
33
 
34
- module.exports = { binOnPath, detectAgents };
34
+ // True if `id` looks genuinely installed: its bin resolves on PATH, or the project already has its
35
+ // config dir (a project can be set up for an agent whose bin isn't on THIS machine's PATH, e.g. a
36
+ // remote/CI runner). Unknown ids are never "installed".
37
+ function isAgentInstalled(id, projectRoot, opts) {
38
+ const a = REGISTRY.find((x) => x.id === id);
39
+ if (!a) return false;
40
+ if (a.detect.bin && binOnPath(a.detect.bin, opts)) return true;
41
+ return (a.detect.dirs || []).some((d) => fs.existsSync(path.join(projectRoot, d)));
42
+ }
43
+
44
+ // ids of every known agent actually installed for this project, in REGISTRY (priority) order.
45
+ function installedAgents(projectRoot, opts) {
46
+ return REGISTRY.filter((a) => isAgentInstalled(a.id, projectRoot, opts)).map((a) => a.id);
47
+ }
48
+
49
+ module.exports = { binOnPath, detectAgents, isAgentInstalled, installedAgents };
@@ -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,119 @@
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
+ // ---- online dashboard (sub-project C1): remote.json + the per-project `published` flag ----
85
+ // remote.json = { url, token, machineName, transport } written by `spectoflow dashboard login`; the
86
+ // hub reads it at boot to start the connector. It holds a secret, hence mode 0600 (no-op on Windows).
87
+ function remotePath(baseDir) { return path.join(dir(baseDir), 'remote.json'); }
88
+ function readRemote(baseDir) {
89
+ const r = readJSON(remotePath(baseDir));
90
+ return r && r.url && r.token ? r : null;
91
+ }
92
+ function writeRemote({ url, token, machineName, transport }, baseDir) {
93
+ const obj = { url: String(url).replace(/\/+$/, ''), token: String(token), machineName: String(machineName || ''), transport: transport === 'http' ? 'http' : 'ws' };
94
+ fs.mkdirSync(dir(baseDir), { recursive: true });
95
+ fs.writeFileSync(remotePath(baseDir), JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
96
+ try { fs.chmodSync(remotePath(baseDir), 0o600); } catch (_) {}
97
+ return obj;
98
+ }
99
+ function clearRemote(baseDir) {
100
+ try { fs.unlinkSync(remotePath(baseDir)); return true; } catch { return false; }
101
+ }
102
+
103
+ function metaPath(id, baseDir) { return path.join(projectDir(id, baseDir), 'meta.json'); }
104
+ function readMeta(id, baseDir) { return readJSON(metaPath(id, baseDir)); }
105
+ // `published: true` means "announce this project to the online dashboard". Only registered projects
106
+ // can carry it; null tells the caller the id is unknown (a 404, not a silent write).
107
+ function setPublished(id, published, baseDir) {
108
+ const entry = registry.listProjects(baseDir).find((p) => p.id === id);
109
+ if (!entry) return null;
110
+ fs.mkdirSync(projectDir(id, baseDir), { recursive: true });
111
+ const meta = { addedAt: new Date().toISOString(), lastOpened: entry.lastOpened, kind: entry.kind || 'spectoflow', ...(readMeta(id, baseDir) || {}), published: !!published };
112
+ fs.writeFileSync(metaPath(id, baseDir), JSON.stringify(meta, null, 2) + '\n');
113
+ return meta;
114
+ }
115
+ function isPublished(id, baseDir) { const m = readMeta(id, baseDir); return !!(m && m.published); }
116
+ function listPublished(baseDir) { return registry.listProjects(baseDir).filter((p) => isPublished(p.id, baseDir)); }
117
+
118
+ module.exports = { dir, exists, settings, settingsPath, lockPath, projectDir, init, registerProject, migrateLegacyHome, readLock,
119
+ remotePath, readRemote, writeRemote, clearRemote, readMeta, setPublished, isPublished, listPublished };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.23.5",
3
+ "version": "0.27.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",
@@ -41,9 +41,9 @@
41
41
  "scripts": {
42
42
  "status": "node bin/spectoflow.js status",
43
43
  "postinstall": "node bin/postinstall.js",
44
- "test": "node --test"
44
+ "test": "node --test \"test/**/*.test.js\""
45
45
  },
46
46
  "engines": {
47
- "node": ">=18"
47
+ "node": ">=22"
48
48
  }
49
49
  }
@@ -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)