spectoflow 0.23.5 → 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 +5 -6
  12. package/{templates → lib}/dashboard/runner.js +6 -5
  13. package/{templates → lib}/dashboard/summarize.js +1 -1
  14. package/lib/detect.js +16 -1
  15. package/lib/global-config.js +65 -0
  16. package/lib/init.js +10 -4
  17. package/lib/registry.js +8 -8
  18. package/{templates/lib → lib}/store.js +16 -12
  19. package/lib/update.js +69 -1
  20. package/lib/workspace.js +84 -0
  21. package/package.json +1 -1
  22. package/templates/AGENTS.md +6 -6
  23. package/templates/README.md +4 -3
  24. package/templates/agents/framework-curator.md +11 -11
  25. package/templates/capabilities.md +1 -1
  26. package/templates/dashboards/.gitkeep +0 -0
  27. package/templates/skills/generate-dashboard/SKILL.md +15 -13
  28. package/templates/dashboard/custom/.gitkeep +0 -3
  29. package/templates/dashboard/handlers.js +0 -251
  30. package/templates/dashboard/server.js +0 -73
  31. package/templates/lib/agents-registry.js +0 -65
  32. /package/{templates → lib}/dashboard/files.js +0 -0
  33. /package/{templates → lib}/dashboard/public/charts.js +0 -0
  34. /package/{templates → lib}/dashboard/public/designs/console.css +0 -0
  35. /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
  36. /package/{templates → lib}/dashboard/public/designs/orbit.css +0 -0
  37. /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
  38. /package/{templates → lib}/dashboard/public/designs.js +0 -0
  39. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
  40. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
  41. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
  42. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
  43. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
  44. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
  45. /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
  46. /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
  47. /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
  48. /package/{templates → lib}/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  49. /package/{templates → lib}/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  50. /package/{templates → lib}/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
  51. /package/{templates → lib}/dashboard/public/hub.html +0 -0
  52. /package/{templates → lib}/dashboard/public/hub.js +0 -0
  53. /package/{templates → lib}/dashboard/public/i18n.js +0 -0
  54. /package/{templates → lib}/dashboard/public/icons.js +0 -0
  55. /package/{templates → lib}/dashboard/public/index.html +0 -0
  56. /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
  57. /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
  58. /package/{templates → lib}/dashboard/public/stats.js +0 -0
  59. /package/{templates → lib}/dashboard/public/styles.css +0 -0
package/README.md CHANGED
@@ -87,6 +87,9 @@ spectoflow dashboard status is it running? (url + pid)
87
87
  spectoflow dashboard stop (or: stop) stop the running dashboard
88
88
  spectoflow dashboard restart stop then start
89
89
  spectoflow dashboard create "..." | --auto generate a custom dashboard
90
+ spectoflow dashboard init [--path=<dir>] create/move the dashboard workspace (default ~/.spectoflow/dashboard)
91
+ spectoflow dashboard validate <file> check a custom-view JSON against the block schema
92
+ spectoflow config [get|set <key> [<value>]] global defaults + dashboard URL/path (~/.spectoflow/config.json)
90
93
 
91
94
  spectoflow skill create "..." | --auto generate a project skill
92
95
  spectoflow agent create "..." | --auto generate a project agent
@@ -101,7 +104,7 @@ spectoflow --help (-h) show help (append -h to any com
101
104
  ```
102
105
 
103
106
  `init` scaffolds:
104
- - `.spectoflow/` — the framework (brain, `workflow.md`, `agents/`, `skills/`, `policy.md`, `config.json`, dashboard, engine).
107
+ - `.spectoflow/` — the framework (brain, `workflow.md`, `agents/`, `skills/`, `policy.md`, `config.json`, engine). The dashboard itself ships in the spectoflow package, not in your project.
105
108
  - `specs/` and `plans/` — **markdown artifacts**, your versioned source of truth.
106
109
  - per-agent shims: `CLAUDE.md` (Claude Code), `AGENTS.md` (Codex/Cursor), `GEMINI.md` (Gemini),
107
110
  `.claude/commands/spectoflow.md`.
@@ -119,8 +122,9 @@ existing `plans/*.md` tasks are given stable ids.
119
122
  ## Update
120
123
 
121
124
  `init` is idempotent (it never overwrites), so it can't refresh an installed project. `spectoflow
122
- update` refreshes **framework-owned** files (engine, dashboard, `AGENTS.md`, `capabilities.md`,
123
- `policy.md`, default agents & skills) to the CLI's version, while **preserving your work**
125
+ update` refreshes **framework-owned** files (engine, `AGENTS.md`, `capabilities.md`, `policy.md`,
126
+ default agents & skills) to the CLI's version retiring the project's own vendored dashboard folder
127
+ along the way for anyone updating from before v0.24 — while **preserving your work** —
124
128
  `config.json`, `workflow.md`, `specs/`, `plans/`, and any agent/skill you created or edited are never
125
129
  touched. A file you edited is preserved and its new version is written next to it as `<file>.new`
126
130
  for you to merge by hand. Add `--dry-run` to preview, or `--force`/`-f` to overwrite a diverged file
@@ -128,6 +132,14 @@ in place instead of dropping a `.new` — use it once you're sure you have no lo
128
132
  in that file (e.g. it's been stuck diverged since an earlier update); it still never touches
129
133
  `config.json`, `workflow.md`, `specs/` or `plans/`.
130
134
 
135
+ **Coming from before v0.24?** The dashboard used to be vendored into every project; now it ships once
136
+ in the package. `npm install -g spectoflow@0.24`, then `spectoflow dashboard` anywhere — the dashboard
137
+ workspace is created automatically and any project you'd already registered is carried over. Your
138
+ project already opens in the new dashboard at this point, whether or not you've run `update` in it
139
+ yet; running `spectoflow update` inside it retires its now-obsolete `.spectoflow/dashboard/` folder
140
+ (only if you never edited anything in it — an edited file is kept and reported, never deleted) and
141
+ moves any custom dashboard views you generated to `.spectoflow/dashboards/`.
142
+
131
143
  First refresh the kit, then run update from your project — the flow is the same whichever way you
132
144
  installed:
133
145
 
@@ -167,13 +179,12 @@ agent never clobber each other.
167
179
 
168
180
  ```bash
169
181
  spectoflow dashboard # → http://localhost:4319 (or --port=NNNN)
170
- # manual (no global install): node .spectoflow/dashboard/server.js
182
+ # manual (no global install): node /path/to/spectoflow/bin/spectoflow.js dashboard
171
183
  ```
172
184
 
173
185
  `spectoflow dashboard` is the simple way — it prints the URL and won't double-start (it detects a
174
- dashboard already running on the port). `spectoflow status` tells you whether one is up. Running the
175
- `server.js` directly still works for a manual/embedded setup. Zero dependencies, updates live via SSE
176
- + file watching.
186
+ dashboard already running on the port). `spectoflow status` tells you whether one is up. Zero
187
+ dependencies, updates live via SSE + file watching.
177
188
 
178
189
  The header bar always shows the brand, the **active agent**, autonomy mode, language, a global-progress
179
190
  meter, a sync dot, and a **Run** quick-action. Ten tabs:
@@ -195,7 +206,7 @@ meter, a sync dot, and a **Run** quick-action. Ten tabs:
195
206
  *Customize* below).
196
207
 
197
208
  URLs are real routes (`/board`, `/backlog/T-012`, …). Charts are zero-dep, hand-rolled SVG in
198
- `dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
209
+ `lib/dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
199
210
  every aggregate is computed client-side.
200
211
 
201
212
  ### Designs & theme
@@ -205,8 +216,8 @@ The dashboard ships **switchable designs** (Personalize → *Dashboard design*):
205
216
  (violet), **Obsidian Ops** (near-black lime/cyan, mono), **Neon Command** (glassmorphism aurora), and
206
217
  **Mission Control** (indigo control panel). Each works in light and dark (the moon toggle). A design
207
218
  is a `data-design` skin — a scoped CSS token block plus a one-line entry in
208
- `dashboard/public/designs.js`, so adding one is trivial. Fonts are **self-hosted**
209
- (`dashboard/public/fonts/*.woff2`), keeping the dashboard fully offline and dependency-free. Your
219
+ `lib/dashboard/public/designs.js`, so adding one is trivial. Fonts are **self-hosted**
220
+ (`lib/dashboard/public/fonts/*.woff2`), keeping the dashboard fully offline and dependency-free. Your
210
221
  choice persists per viewer (localStorage) and as the project default (`config.design`).
211
222
 
212
223
  ### Chat
@@ -8,6 +8,7 @@
8
8
  */
9
9
  try {
10
10
  if (process.env.npm_config_global === 'true' && process.stdout.isTTY) {
11
+ try { require('../lib/global-config').ensure(); } catch {}
11
12
  const brand = require('../lib/brand');
12
13
  const version = require('../package.json').version;
13
14
  const useColor = !process.env.NO_COLOR;
package/bin/spectoflow.js CHANGED
@@ -3,23 +3,29 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const http = require('http');
6
+ const readline = require('readline');
6
7
  const { spawn } = require('child_process');
7
- const store = require('../templates/lib/store');
8
+ const store = require('../lib/store');
8
9
  const adapters = require('../lib/adapters');
9
10
  const detect = require('../lib/detect');
10
11
  const ownership = require('../lib/ownership');
11
12
  const manifest = require('../lib/manifest');
12
13
  const registry = require('../lib/registry');
14
+ const workspace = require('../lib/workspace');
13
15
  const initLib = require('../lib/init');
16
+ const globalConfig = require('../lib/global-config');
14
17
  const mcp = require('../lib/mcp');
15
- const { startRun } = require('../templates/dashboard/runner');
16
- const { buildCustomizePrompt } = require('../templates/lib/customize-prompts');
18
+ const { startRun } = require('../lib/dashboard/runner');
19
+ const { buildCustomizePrompt } = require('../lib/customize-prompts');
17
20
 
18
21
  const KIT = path.resolve(__dirname, '..');
19
22
  const TPL = path.join(KIT, 'templates');
20
23
  const VERSION = require('../package.json').version;
21
24
  const argv = process.argv.slice(2);
22
25
  const cmd = argv[0] || 'help';
26
+ // `--name=value` flag reader — joins remaining `=`-split parts back (a URL's own query string may
27
+ // contain one), returns undefined when absent so callers can tell "not passed" from an empty string.
28
+ const flag = (name) => (argv.find((a) => a.startsWith(`--${name}=`)) || '').split('=').slice(1).join('=') || undefined;
23
29
 
24
30
  // Tiny ANSI colouriser — no dependency; disabled when not a TTY or NO_COLOR is set.
25
31
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
@@ -77,10 +83,13 @@ function readWorkflowSteps(dir) {
77
83
  }
78
84
 
79
85
  // ---- dashboard port + running-state probe ------------------------------------
80
- // Precedence: --port=NNNN > SPECTOFLOW_PORT env > 4319 (matches templates/dashboard/server.js).
86
+ // Precedence: --port=NNNN > SPECTOFLOW_PORT env > the workspace's own port (matches
87
+ // lib/dashboard/hub-server.js).
81
88
  function resolvePort(args) {
82
89
  const arg = (args.find((a) => a.startsWith('--port=')) || '').split('=')[1];
83
- return Number(arg || process.env.SPECTOFLOW_PORT || 4319);
90
+ if (arg) return Number(arg);
91
+ if (process.env.SPECTOFLOW_PORT) return Number(process.env.SPECTOFLOW_PORT);
92
+ try { return workspace.settings().port; } catch { return 4319; }
84
93
  }
85
94
 
86
95
  // Native http probe, ~500ms timeout, never throws — resolves true/false.
@@ -101,7 +110,7 @@ function init() {
101
110
  const r = initLib.runInit({ target, templatesDir: TPL, version: VERSION, agentsArg });
102
111
  console.log(logo());
103
112
  console.log(`${c.g('✓')} installed in ${c.bold(r.target)}`);
104
- console.log(` ${c.dim('.spectoflow/')} framework — brain, workflow, agents, skills, policy, dashboard, config`);
113
+ console.log(` ${c.dim('.spectoflow/')} framework — brain, workflow, agents, skills, policy, config`);
105
114
  console.log(` ${c.dim('specs/ plans/')} markdown artifacts (your source of truth)`);
106
115
  r.written.forEach((w) => console.log(` ${c.cy('+')} ${w}`));
107
116
  r.notes.forEach((n) => console.log(` ${c.y('!')} ${c.dim(n)}`));
@@ -123,7 +132,7 @@ async function update() {
123
132
  const r = require('../lib/update').runUpdate({ projectRoot: root, templatesDir: TPL, version: VERSION, dryRun, force });
124
133
 
125
134
  const from = r.fromVersion || 'unknown';
126
- const changed = r.refreshed.length + r.created.length + r.adopted.length + r.newSidecar.length + r.forced.length;
135
+ const changed = r.refreshed.length + r.created.length + r.adopted.length + r.newSidecar.length + r.forced.length + r.removed.length + r.migration.movedViews.length;
127
136
  const row = (sym, label, list, painter, note) => {
128
137
  if (!list.length) return;
129
138
  const n = c.dim(String(list.length).padStart(2));
@@ -138,6 +147,11 @@ async function update() {
138
147
  row(c.b('~'), 'adopted', r.adopted, c.b);
139
148
  row(c.y('!'), 'forced', r.forced, c.y, 'overwrote a diverged file — its previous content is gone');
140
149
  row(c.y('!'), '.new', r.newSidecar, c.y, 'you edited these — new version saved as *.new, merge by hand (or re-run with --force)');
150
+ row(c.dim('−'), 'removed', r.removed, c.dim, 'no longer part of the kit (the dashboard lives in the spectoflow package now)');
151
+ row(c.y('!'), 'kept', r.kept, c.y, 'you modified these and they are no longer part of the kit — delete them yourself when ready');
152
+ if (r.migration.movedViews.length) console.log(` ${c.cy('→')} ${c.cy('views'.padEnd(9))} ${c.dim(String(r.migration.movedViews.length).padStart(2))} ${c.dim('custom views moved to .spectoflow/dashboards/')}`);
153
+ r.migration.conflicts.forEach((f) => console.log(` ${c.y('!')} ${c.y('conflict'.padEnd(9))} ${c.dim(`dashboards/${f} already exists — the old copy stays in dashboard/custom/ for you to merge`)}`));
154
+ if (r.legacyLeftovers.length) console.log(` ${c.y('!')} ${c.dim('this project has no install manifest, so nothing was deleted. Safe to remove by hand: ' + r.legacyLeftovers.map((p) => '.spectoflow/' + p).join(', '))}`);
141
155
  if (r.unchanged.length) console.log(` ${c.dim('·')} ${c.dim('unchanged'.padEnd(9))} ${c.dim(String(r.unchanged.length).padStart(2))}`);
142
156
  console.log(` ${c.dim('=')} ${c.dim('preserved'.padEnd(9))} ${c.dim('config.json · workflow.md · specs/ · plans/ · your custom agents & skills')}`);
143
157
  console.log('');
@@ -151,9 +165,7 @@ async function update() {
151
165
  // is invalidated. Do that via a surgical per-project reload so an update always actually takes
152
166
  // effect, without restarting the whole hub (which would disturb every other project open in it).
153
167
  if (!dryRun && changed) {
154
- const lockPath = registry.hubLockPath();
155
- let info = null;
156
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
168
+ const info = workspace.readLock();
157
169
  if (info && info.port && (await probeDashboard(info.port, 2000))) {
158
170
  const entry = registry.findByPath(root);
159
171
  if (entry) {
@@ -161,7 +173,7 @@ async function update() {
161
173
  const res = await fetch(`http://localhost:${info.port}/api/hub/reload/${entry.id}`, { method: 'POST' });
162
174
  const body = await res.json().catch(() => ({}));
163
175
  console.log(` ${c.dim(body.reloaded
164
- ? 'Hub is running — reloaded this project\'s server code (other open projects unaffected).'
176
+ ? 'Hub is running — reloaded this project (other open projects unaffected).'
165
177
  : 'Hub is running, but this project wasn\'t loaded in it yet — nothing to reload.')}`);
166
178
  } catch {
167
179
  console.log(` ${c.y('!')} Hub is running on port ${info.port} but the reload request failed — restart it yourself if changes don't seem to take effect: ${c.g('spectoflow dashboard restart')}`);
@@ -171,18 +183,83 @@ async function update() {
171
183
  }
172
184
  }
173
185
 
186
+ // `spectoflow dashboard validate <file>` — the generate-dashboard skill's verification step (it used
187
+ // to require() a lib file vendored in the project; the validator lives in this package now).
188
+ function validateDashboardFile(file) {
189
+ if (!file) { console.log('Usage: spectoflow dashboard validate <file.json>'); process.exitCode = 1; return; }
190
+ let spec;
191
+ try { spec = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
192
+ catch (e) { console.log(`${c.y('invalid')} — cannot read/parse ${file}: ${e.message}`); process.exitCode = 1; return; }
193
+ const r = require('../lib/custom-dashboard').validateSpec(spec);
194
+ if (r.valid) { console.log(`${c.g('valid')} — ${spec.id} (${(spec.blocks || []).length} block(s))`); return; }
195
+ console.log(`${c.y('invalid')} — ${file}`);
196
+ (r.errors || []).forEach((e) => console.log(` ${c.y('!')} ${e}`));
197
+ process.exitCode = 1;
198
+ }
199
+
200
+ // `spectoflow dashboard init [--path <dir>] [--port N] [--name "…"] [--design <id>]` — create (or
201
+ // move) the dashboard workspace from the command line, mirroring what `startDashboard()` does lazily
202
+ // on first `spectoflow dashboard`, but explicit and inspectable.
203
+ function dashboardInit() {
204
+ try {
205
+ const r = workspace.init({ path: flag('path'), port: flag('port'), name: flag('name'), design: flag('design') });
206
+ console.log(logo());
207
+ console.log(`${c.g('✓')} dashboard workspace ${r.created ? 'created' : 'updated'} at ${c.bold(r.dir)}`);
208
+ if (r.registryCarried) console.log(` ${c.dim('your project list was carried over from the previous workspace')}`);
209
+ const s = workspace.settings();
210
+ console.log(` ${c.dim('name')} ${s.name} ${c.dim('port')} ${s.port} ${c.dim('design')} ${s.design}`);
211
+ console.log(`\n start it: ${c.g('spectoflow dashboard')}\n`);
212
+ } catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
213
+ }
214
+
215
+ const REMOTE_NOTE = 'This version manages local dashboards. Remote dashboards (login with a token) come in a later release — continuing with your local dashboard.';
216
+ function isLocalUrl(u) { try { return ['localhost', '127.0.0.1', '::1'].includes(new URL(u).hostname); } catch { return true; } }
217
+ // The one-time question every dashboard-starting command answers before doing anything else: which
218
+ // dashboard should this project talk to? `--url` answers it without prompting. Otherwise, only when
219
+ // dashboard.url has never been set AND both stdin/stdout are a real TTY do we ask (Enter keeps the
220
+ // local default) — any non-interactive invocation (tests spawn with stdio:'pipe', CI, scripts) must
221
+ // never block waiting on stdin that will never arrive, so it silently keeps the local default instead.
222
+ // The answer (typed, defaulted, or flagged) is saved via globalConfig.set so it is asked at most once.
223
+ // Returns the resolved URL on success, or null on validation error (error message already printed).
224
+ async function resolveDashboardUrl() {
225
+ const fromFlag = flag('url');
226
+ if (fromFlag) {
227
+ try {
228
+ globalConfig.set('dashboard.url', fromFlag);
229
+ } catch (e) {
230
+ console.log(`${c.y('!')} ${e.message}`);
231
+ process.exitCode = 1;
232
+ return null;
233
+ }
234
+ }
235
+ else if (globalConfig.get('dashboard.url').source === 'default') {
236
+ let answer = '';
237
+ if (process.stdin.isTTY && process.stdout.isTTY) {
238
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
239
+ answer = await new Promise((r) => rl.question(`Dashboard URL [${globalConfig.get('dashboard.url').value}]: `, (a) => { rl.close(); r(a.trim()); }));
240
+ }
241
+ globalConfig.set('dashboard.url', answer || globalConfig.get('dashboard.url').value);
242
+ }
243
+ const url = globalConfig.get('dashboard.url').value;
244
+ if (!isLocalUrl(url)) console.log(`${c.y('!')} ${REMOTE_NOTE}`);
245
+ return url;
246
+ }
247
+
174
248
  // THE launch command — routes the subcommands, then starts. Starting spawns the server DETACHED and
175
249
  // hands the prompt straight back (no foreground blocking), then prints the commands to drive it.
176
250
  async function dashboard() {
177
251
  const sub = argv[1];
252
+ if (sub === 'init') return dashboardInit();
253
+ if (sub === 'login') { console.log(REMOTE_NOTE); return; }
178
254
  if (sub === 'stop') return stopDashboard();
179
255
  if (sub === 'status') return dashboardStatus();
180
256
  if (sub === 'restart') return restartDashboard();
181
257
  if (sub === 'create') return runCustomize('dashboard');
258
+ if (sub === 'validate') return validateDashboardFile(argv[2]);
182
259
  return startDashboard();
183
260
  }
184
261
 
185
- // ---- projects: the multi-project registry's CLI surface (~/.spectoflow/projects.json) ----
262
+ // ---- projects: the multi-project registry's CLI surface (the workspace's projects.json) ----
186
263
  function projectsCmd() {
187
264
  const sub = argv[1];
188
265
  if (sub === 'remove') return projectsRemove(argv[2]);
@@ -204,6 +281,20 @@ function projectsRemove(id) {
204
281
  console.log(ok ? `${c.g('✓')} removed ${id}` : `${c.y('!')} no project registered with id ${id}`);
205
282
  }
206
283
 
284
+ // ---- config: global settings (~/.spectoflow/config.json), editable from anywhere ----
285
+ function configCmd() {
286
+ const sub = argv[1];
287
+ try {
288
+ if (sub === 'get') { if (!argv[2]) throw new Error('Usage: spectoflow config get <key>'); console.log(globalConfig.get(argv[2]).value); return; }
289
+ if (sub === 'set') { if (!argv[2] || argv[3] === undefined) throw new Error('Usage: spectoflow config set <key> <value>'); const v = globalConfig.set(argv[2], argv[3]); console.log(`${c.g('✓')} ${argv[2]} = ${v}`); return; }
290
+ console.log(wordmark());
291
+ const rows = globalConfig.list();
292
+ const w = Math.max(...rows.map((r) => r.key.length));
293
+ rows.forEach((r) => console.log(` ${c.g(r.key.padEnd(w))} ${String(r.value).padEnd(28)} ${c.dim('(' + r.source + ')')}`));
294
+ console.log(c.dim(`\n file: ${globalConfig.configPath()} · spectoflow config set <key> <value>`));
295
+ } catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
296
+ }
297
+
207
298
  // ---- Customize: `spectoflow skill/agent/dashboard create` — the CLI mirror of the dashboard's
208
299
  // Settings → Customize UI. Both surfaces build the same natural-language prompt (customize-prompts.js)
209
300
  // and post it through the same pipeline (runner.js's startRun — the function /api/run itself calls),
@@ -260,18 +351,20 @@ async function runCustomize(kind) {
260
351
  // global registry first, then either joins an already-running hub or spawns a new one — probing first
261
352
  // so a second start just reports the running one instead of spawning a duplicate.
262
353
  async function startDashboard() {
354
+ if (!(await resolveDashboardUrl())) return;
263
355
  const root = process.cwd();
264
- const entry = registry.addProject(root);
265
- const boardUrl = (p) => `http://localhost:${p}/p/${entry.id}/board`;
266
- const lockPath = registry.hubLockPath();
267
- let info = null;
268
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
356
+ workspace.migrateLegacyHome();
357
+ if (!workspace.exists()) workspace.init({});
358
+ const hasProject = fs.existsSync(path.join(root, '.spectoflow'));
359
+ const entry = hasProject ? workspace.registerProject(root) : null;
360
+ const boardUrl = (p) => (entry ? `http://localhost:${p}/p/${entry.id}/board` : `http://localhost:${p}/`);
361
+ const info = workspace.readLock();
269
362
  if (info && info.port && await probeDashboard(info.port)) {
270
363
  console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
271
364
  return printDashboardCommands();
272
365
  }
273
366
  const port = resolvePort(argv);
274
- const hubPath = path.join(KIT, 'lib', 'hub-server.js');
367
+ const hubPath = path.join(KIT, 'lib', 'dashboard', 'hub-server.js');
275
368
  const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
276
369
  const child = spawn('node', [hubPath], { detached: true, stdio: 'ignore', env });
277
370
  child.unref(); // let this CLI exit while the hub keeps running
@@ -293,9 +386,8 @@ function printDashboardCommands() {
293
386
  }
294
387
 
295
388
  async function dashboardStatus() {
296
- const lockPath = registry.hubLockPath();
297
- let info = null;
298
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
389
+ if (!(await resolveDashboardUrl())) return;
390
+ const info = workspace.readLock();
299
391
  const port = (info && info.port) || resolvePort(argv);
300
392
  const running = await probeDashboard(port);
301
393
  if (running) console.log(`${c.g('●')} hub running → ${c.bold('http://localhost:' + port)}${info && info.pid ? c.dim(' (pid ' + info.pid + ')') : ''}`);
@@ -303,6 +395,7 @@ async function dashboardStatus() {
303
395
  }
304
396
 
305
397
  async function restartDashboard() {
398
+ if (!(await resolveDashboardUrl())) return;
306
399
  await stopDashboard();
307
400
  // Windows doesn't deliver real signals — process.kill() returns once the request is issued, not
308
401
  // once the process (and the port it held) is actually gone. A short gap here, plus startDashboard()
@@ -315,20 +408,23 @@ async function restartDashboard() {
315
408
  // Stop the running hub: read the global lock it wrote, verify it's actually up, then terminate it
316
409
  // and clear the lock. Safe against a stale lock (a recycled pid) because it only kills when the port
317
410
  // still responds.
411
+ function unlinkLocks() {
412
+ try { fs.unlinkSync(workspace.lockPath()); } catch {}
413
+ try { fs.unlinkSync(path.join(globalConfig.homeDir(), 'hub.lock')); } catch {}
414
+ }
318
415
  async function stopDashboard() {
319
- const lockPath = registry.hubLockPath();
320
- let info = null;
321
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
416
+ if (!(await resolveDashboardUrl())) return;
417
+ const info = workspace.readLock();
322
418
  const port = (info && info.port) || resolvePort(argv);
323
419
  const running = await probeDashboard(port);
324
420
  if (!running) {
325
- if (info) { try { fs.unlinkSync(lockPath); } catch {} } // stale lock
421
+ if (info) unlinkLocks(); // stale lock
326
422
  return console.log('No spectoflow hub is running.');
327
423
  }
328
424
  if (info && info.pid) {
329
425
  try {
330
426
  process.kill(info.pid); // SIGTERM → hub clears its own lock (POSIX)
331
- try { fs.unlinkSync(lockPath); } catch {} // and we clear it too (Windows has no real signals)
427
+ unlinkLocks(); // and we clear it too (Windows has no real signals)
332
428
  return console.log(`spectoflow hub stopped (pid ${info.pid}, was on http://localhost:${port}).`);
333
429
  } catch {}
334
430
  }
@@ -348,9 +444,7 @@ async function status() {
348
444
  console.log(`${(p.config && p.config.projectType) || 'project'} — mode ${p.config.mode} · lang ${p.config.language}`);
349
445
  console.log(`${done}/${tasks.length} tasks done · ${p.specs.length} spec(s) · ${p.agents.length} agents · ${p.skills.length} skills`);
350
446
  tasks.filter((t) => t.status === 'in_progress').forEach((t) => console.log(` > in progress: ${t.id} ${t.title}`));
351
- const lockPath = registry.hubLockPath();
352
- let info = null;
353
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
447
+ const info = workspace.readLock();
354
448
  const port = (info && info.port) || resolvePort(argv);
355
449
  const running = await probeDashboard(port);
356
450
  console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
@@ -402,13 +496,16 @@ ${c.bold('Project')}
402
496
  ${c.g('init')} ${c.dim('[dir] [--agent=a,b]')} scaffold a project (auto-detects agents; wires Playwright MCP)
403
497
  ${c.g('update')} ${c.dim('[--dry-run|--force]')} refresh framework files to this kit version
404
498
  ${c.g('status')} progress + whether the dashboard is running
499
+ ${c.g('config')} ${c.dim('[get <key>|set <key> <value>]')} global settings for every project (~/.spectoflow/config.json)
405
500
 
406
501
  ${c.bold('Dashboard')}
407
502
  ${c.g('dashboard')} ${c.dim('[--port=NNNN]')} start the control plane in the background (default 4319)
503
+ ${c.g('dashboard init')} ${c.dim('[--path=<dir>] [--port=N] [--name=…]')} create/move the dashboard workspace ${c.dim('(default ~/.spectoflow/dashboard)')}
408
504
  ${c.g('dashboard status')} is it running? (url + pid)
409
505
  ${c.g('dashboard stop')} stop it ${c.dim('(alias: stop)')}
410
506
  ${c.g('dashboard restart')} stop then start
411
- ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far (~/.spectoflow/projects.json)
507
+ ${c.g('dashboard login')} connect to a remote dashboard ${c.dim('(coming in a later release)')}
508
+ ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far
412
509
 
413
510
  ${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
414
511
  ${c.g('skill create')} ${c.dim('"<description>" | --auto')} generate a project skill
@@ -438,22 +535,27 @@ const HELP = {
438
535
  ${c.dim('An existing CLAUDE.md is preserved as CLAUDE.md.tomerge for you to merge on first run.')}
439
536
  ${c.dim('Full list with docs links: the dashboard\'s Documentation tab, or the README.')}`,
440
537
  update: `${c.bold('spectoflow update')} ${c.dim('[--dry-run] [--force|-f]')}\n
441
- Refresh framework-owned files (engine, dashboard, default agents & skills, AGENTS.md, policy…)
538
+ Refresh framework-owned files (engine, default agents & skills, AGENTS.md, policy…)
442
539
  to this CLI's version, ${c.bold('preserving your work')}: config.json, workflow.md, specs/, plans/
443
540
  and any agent/skill you edited are never overwritten (an edited file's new version lands as
444
541
  ${c.dim('*.new')} for you to merge). ${c.g('--dry-run')} previews without writing.
445
542
  ${c.g('--force')} (${c.g('-f')}) overwrites a diverged file in place instead of dropping a ${c.dim('*.new')}
446
543
  — use it when you know you have no local edits worth keeping (e.g. a file stuck diverged from an
447
544
  earlier update). It never touches config.json, workflow.md, specs/ or plans/.`,
448
- dashboard: `${c.bold('spectoflow dashboard')} ${c.dim('[--port=NNNN] [status|stop|restart|create]')}\n
545
+ dashboard: `${c.bold('spectoflow dashboard')} ${c.dim('[--port=NNNN] [--url=<u>] [init|status|stop|restart|create|validate|login]')}\n
449
546
  Start the local control plane in the ${c.bold('background')} (default ${c.dim('4319')} or
450
- ${c.dim('$SPECTOFLOW_PORT')}) and hand the prompt back. Subcommands:
547
+ ${c.dim('$SPECTOFLOW_PORT')}) and hand the prompt back. ${c.g('--url=<u>')} sets which dashboard this
548
+ project talks to (${c.dim('~/.spectoflow/config.json → dashboard.url')}) — asked once, interactively,
549
+ the first time you ever run a dashboard command; answer it up front with this flag instead. Subcommands:
550
+ ${c.g('init')} create/move the dashboard workspace: ${c.dim('[--path=<dir>] [--port=N] [--name=…] [--design=<id>]')}
451
551
  ${c.g('status')} is it running? (url + pid)
452
552
  ${c.g('stop')} stop it ${c.dim('(alias: spectoflow stop)')}
453
553
  ${c.g('restart')} stop then start
454
- ${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}`,
554
+ ${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}
555
+ ${c.g('validate <file>')} check a custom-view JSON against the block schema
556
+ ${c.g('login')} connect to a remote dashboard ${c.dim('(coming in a later release)')}`,
455
557
  projects: `${c.bold('spectoflow projects')} ${c.dim('[remove <id>]')}\n
456
- List every registered project in the global registry at ${c.dim('~/.spectoflow/projects.json')} (stored by
558
+ List every registered project in the global registry (the dashboard workspace's projects.json) (stored by
457
559
  ${c.g('spectoflow dashboard')}) — id, name, path. ${c.g('remove <id>')} drops one (e.g. a project that moved
458
560
  or was deleted) from this list only; it never touches that project's own files.`,
459
561
  skill: `${c.bold('spectoflow skill create')} ${c.dim('"<description>" [--agent=name]')}\n${c.bold('spectoflow skill create')} ${c.dim('--auto [--agent=name]')}\n
@@ -475,6 +577,12 @@ const HELP = {
475
577
  skills: `${c.bold('spectoflow skills')}\n List the evolving procedures (name · capability · what it does).`,
476
578
  workflow: `${c.bold('spectoflow workflow')}\n Show the pipeline steps, marking which are enabled (●) or disabled (○).`,
477
579
  stop: `${c.bold('spectoflow stop')}\n Stop the running dashboard (alias for ${c.g('spectoflow dashboard stop')}).`,
580
+ config: `${c.bold('spectoflow config')} ${c.dim('[get <key> | set <key> <value>]')}\n
581
+ Global settings that apply to every project on this machine, stored in ${c.dim('~/.spectoflow/config.json')}:
582
+ ${c.g('dashboard.url')} the dashboard projects talk to (default http://localhost:4319)
583
+ ${c.g('dashboard.path')} where the dashboard workspace lives (default ~/.spectoflow/dashboard)
584
+ ${c.g('defaults.agent')} ${c.g('defaults.language')} ${c.g('defaults.mode')} ${c.g('defaults.design')} seeds for ${c.g('spectoflow init')}
585
+ A project's own .spectoflow/config.json always wins over these defaults.`,
478
586
  };
479
587
  const showHelp = (name) => console.log('\n' + HELP[name].trim() + '\n');
480
588
 
@@ -482,6 +590,7 @@ const showHelp = (name) => console.log('\n' + HELP[name].trim() + '\n');
482
590
  const fns = {
483
591
  init, update, dashboard, stop: stopDashboard, status, list: listAll, help, version,
484
592
  projects: projectsCmd,
593
+ config: configCmd,
485
594
  agents: () => { console.log(wordmark()); printAgents(false); },
486
595
  skills: () => { console.log(wordmark()); printSkills(false); },
487
596
  workflow: () => { console.log(wordmark()); printWorkflow(false); },
package/lib/adapters.js CHANGED
@@ -26,7 +26,7 @@ reflect it back and ask **one targeted question at a time** (each with a recomme
26
26
  is clear — then execute. See the Clarify reflex in \`.spectoflow/AGENTS.md\`.
27
27
 
28
28
  - Command: \`/spectoflow\` (\`init\` / \`status\` / or just a request).
29
- - Dashboard: \`node .spectoflow/dashboard/server.js\` → http://localhost:4319
29
+ - Dashboard: \`spectoflow dashboard\` → http://localhost:4319
30
30
  - Artifacts are markdown in \`specs/\` and \`plans/\`; volatile state in \`.spectoflow/runtime.json\`.
31
31
  `;
32
32
 
@@ -254,4 +254,10 @@ function defaultRunners(agents) {
254
254
  return out;
255
255
  }
256
256
 
257
- module.exports = { generate, defaultRunners, REGISTRY };
257
+ // The dashboard's flat view of REGISTRY (id/label/bin/dirs/runner/headless/docsUrl) — one roster for
258
+ // the CLI and the dashboard, now that the dashboard ships in this package (D64).
259
+ function knownAgents() {
260
+ return REGISTRY.map((a) => ({ id: a.id, label: a.label, bin: a.detect.bin, dirs: a.detect.dirs || [], runner: a.runner, headless: a.headless, docsUrl: a.docsUrl }));
261
+ }
262
+
263
+ module.exports = { generate, defaultRunners, REGISTRY, knownAgents };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  /*
3
- * Pure helpers for user-generated custom dashboards (.spectoflow/dashboard/custom/<id>.json).
3
+ * Pure helpers for user-generated custom dashboards (.spectoflow/dashboards/<id>.json).
4
4
  *
5
5
  * A custom dashboard is a DECLARATIVE block spec, never raw HTML/CSS/JS: the generating agent picks
6
6
  * blocks from a fixed vocabulary (BLOCK_TYPES) that the dashboard already knows how to render, using
@@ -9,7 +9,7 @@
9
9
  * the user switches to later — with zero per-dashboard styling to keep in sync, and no arbitrary code
10
10
  * ever running in the dashboard.
11
11
  *
12
- * Zero dependency; consumed by templates/dashboard/server.js (Node) via readCustomDashboards() in
12
+ * Zero dependency; consumed by lib/dashboard/handlers.js (Node) via readCustomDashboards() in
13
13
  * store.js. The browser-side renderer (dashboard/public/app.js) re-implements the tiny `resolveBind`
14
14
  * walk independently — sharing code across the Node/browser boundary would need a build step, which
15
15
  * this project avoids on purpose (see CLAUDE.md's zero-runtime-dependency invariant).
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  // Builds the exact natural-language prompts the dashboard's Settings → Customize UI posts to
3
- // /api/run (see templates/dashboard/public/app.js's CZ_KINDS) — the single source of truth so the
3
+ // /api/run (see lib/dashboard/public/app.js's CZ_KINDS) — the single source of truth so the
4
4
  // CLI (`spectoflow skill/agent/dashboard create`) and the dashboard button never drift apart. The
5
5
  // browser side can't require this Node module (no build step), so its literal strings are mirrored
6
6
  // there by hand; test/customize-prompts.test.js guards against the two falling out of sync.
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ /*
3
+ * HTTP glue for one project: parse the request, pick the op, call it, serialize. Every operation
4
+ * lives in ops.js (pure, transport-agnostic) — nothing here decides anything about the project.
5
+ *
6
+ * createHandlers(root) returns what a listener-owning process (hub-server.js) needs:
7
+ * - handleApi(req, res, u, emit): Promise<boolean> — true if this was an API route (handled).
8
+ * Excludes /api/events: SSE registration stays with whoever owns the HTTP listener.
9
+ * - watchDirs: dirs (relative to root) whose changes should emit {type:'change'}.
10
+ * - onBoot(): once per project per process (creates the custom-views dir, clears a stale
11
+ * in-flight orchestration).
12
+ */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { ops, OpError } = require('./ops');
16
+ const orchestrator = require('./orchestrator');
17
+
18
+ function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
19
+ function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c) => b += c); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
20
+
21
+ const seg = (p, i) => decodeURIComponent(p.split('/')[i] || '');
22
+ const q = (u, k) => u.searchParams.get(k) || '';
23
+ // [method, matcher, op, args(u, body, pathname)]
24
+ const ROUTES = [
25
+ ['GET', '/api/project', 'project.read', () => ({})],
26
+ ['GET', '/api/agentfile', 'agentfile.read', (u) => ({ path: q(u, 'path') })],
27
+ ['GET', '/api/files/tree', 'files.tree', () => ({})],
28
+ ['GET', '/api/files/read', 'files.read', (u) => ({ path: q(u, 'path') })],
29
+ ['POST', '/api/files/write', 'files.write', (_u, b) => b],
30
+ ['POST', '/api/files/mkdir', 'files.mkdir', (_u, b) => b],
31
+ ['POST', '/api/task', 'task.add', (_u, b) => b],
32
+ ['PATCH', /^\/api\/task\/[^/]+$/, 'task.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
33
+ ['POST', /^\/api\/task\/[^/]+\/comment$/, 'task.comment', (_u, b, p) => ({ id: seg(p, 3), text: b.text, action: b.action })],
34
+ ['POST', '/api/workflow/toggle', 'workflow.toggle', (_u, b) => b],
35
+ ['POST', '/api/run', 'run.start', (_u, b) => b],
36
+ ['POST', '/api/chat/summarize', 'chat.summarize', (_u, b) => b],
37
+ ['POST', '/api/chat/clear', 'chat.clear', () => ({})],
38
+ ['POST', '/api/orchestrate', 'orchestrate.start', (_u, b) => b],
39
+ ['POST', '/api/orchestrate/approve', 'orchestrate.approve', (_u, b) => b],
40
+ ['POST', '/api/settings', 'settings.save', (_u, b) => b],
41
+ ['POST', '/api/attention', 'attention.add', (_u, b) => b],
42
+ ['POST', /^\/api\/attention\/[^/]+\/promote$/, 'attention.promote', (_u, _b, p) => ({ id: seg(p, 3) })],
43
+ ['PATCH', /^\/api\/attention\/[^/]+$/, 'attention.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
44
+ ['DELETE', /^\/api\/attention\/[^/]+$/, 'attention.remove', (_u, _b, p) => ({ id: seg(p, 3) })],
45
+ ];
46
+ const matches = (m, p) => (typeof m === 'string' ? m === p : m.test(p));
47
+
48
+ function createHandlers(root) {
49
+ async function handleApi(req, res, u, emit) {
50
+ const p = u.pathname;
51
+ const route = ROUTES.find(([method, m]) => method === req.method && matches(m, p));
52
+ if (!route) return false;
53
+ const [, , opName, args] = route;
54
+ const b = req.method === 'GET' ? {} : await body(req);
55
+ try {
56
+ const result = await ops[opName](root, args(u, b, p), { emit });
57
+ sendJSON(res, 200, result);
58
+ } catch (e) {
59
+ if (e instanceof OpError) sendJSON(res, e.status, { error: e.message });
60
+ else sendJSON(res, 500, { error: String(e && e.message || e) });
61
+ }
62
+ return true;
63
+ }
64
+ function onBoot() {
65
+ try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboards'), { recursive: true }); } catch (_) {}
66
+ // A process restart loses any in-flight orchestration; clear a stale 'running'/'awaiting_approval'
67
+ // so the 409 guard in orchestrate.start can't wedge forever. Not a resume — just un-wedging.
68
+ try { orchestrator.reconcileOnBoot(root); } catch (_) {}
69
+ }
70
+ return {
71
+ handleApi,
72
+ // The legacy custom-views dir is watched too, until `spectoflow update` migrates it (Task 4/8).
73
+ watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboards', '.spectoflow/dashboard/custom'],
74
+ onBoot,
75
+ };
76
+ }
77
+
78
+ module.exports = { createHandlers, ROUTES };