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
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  <p align="center">
13
13
  <a href="https://www.npmjs.com/package/spectoflow"><img src="https://img.shields.io/npm/v/spectoflow.svg?color=e6a54b" alt="npm version"></a>
14
- <img src="https://img.shields.io/badge/node-%3E%3D18-5fb2cc" alt="node >= 18">
14
+ <img src="https://img.shields.io/badge/node-%3E%3D22-5fb2cc" alt="node >= 22">
15
15
  <img src="https://img.shields.io/badge/dependencies-0-4caf72" alt="zero dependencies">
16
16
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
17
17
  </p>
@@ -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,35 +179,46 @@ 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.
188
+
189
+ Want your own hosted, online dashboard instead of (or alongside) the local one? `server/` is a
190
+ separate, self-hostable relay with a ready-made Docker deployment path — see
191
+ [`server/docs/deploy-vps.md`](server/docs/deploy-vps.md).
177
192
 
178
193
  The header bar always shows the brand, the **active agent**, autonomy mode, language, a global-progress
179
- meter, a sync dot, and a **Run** quick-action. Ten tabs:
194
+ meter, a sync dot, and a **Run** quick-action. Thirteen tabs — and **which ones you see, and in what
195
+ order, is up to you** (Personalize → *Navigation tabs*: enable / disable / reorder; two of them ship
196
+ off by default):
180
197
 
181
198
  - **Board** — the control-room Overview (compact KPI cards, a status donut, a **scope-vs-delivered
182
199
  area curve**, a workflow-at-a-glance strip, per-phase progress bars, filter chips + search) plus the
183
- phase board.
184
- - **Chat** — a full-height group-chat panel with **Summarize** / **Clear**.
200
+ phase board, as a **List** or a **Kanban** whose columns you can show/hide and that pages long
201
+ columns instead of scrolling them.
202
+ - **Chat** — a full-height group-chat panel with **Summarize** / **Clear** and **slash commands** (see
203
+ below).
185
204
  - **Requests** — tasks awaiting you (`to_validate` / `to_analyze`).
186
205
  - **Attention** — points the agent raised (a `::spectoflow attention msg=…` sentinel) or that you
187
206
  noted yourself — edit / resolve / delete, or **validate → task**.
188
207
  - **Backlog** — a flat sortable/filterable, paginated table of every task, defaulting to open work.
189
208
  - **Workflow** — the pipeline as step cards; click one to enable/disable it, which edits `workflow.md`.
190
209
  - **Agents & Skills** — enriched cards that open a full-body markdown drawer.
210
+ - **Files** — a project file tree with read / write / create, syntax-highlighted (self-hosted Prism.js).
211
+ - **Bloc note** *(off by default)* — a per-project post-it Markdown scratchpad.
212
+ - **Daily meeting** *(off by default)* — dated notes (`.spectoflow/meetings/<date>.md`), written by
213
+ hand or generated by the active agent.
191
214
  - **Info** — a project-at-a-glance summary.
192
215
  - **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
193
216
  command reference.
194
- - **Personalize** — autonomy mode, language, design, the active agent, and **Extend spectoflow** (see
195
- *Customize* below).
217
+ - **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
218
+ commands**, and **Extend spectoflow** (see *Customize* below).
196
219
 
197
220
  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
221
+ `lib/dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
199
222
  every aggregate is computed client-side.
200
223
 
201
224
  ### Designs & theme
@@ -205,8 +228,8 @@ The dashboard ships **switchable designs** (Personalize → *Dashboard design*):
205
228
  (violet), **Obsidian Ops** (near-black lime/cyan, mono), **Neon Command** (glassmorphism aurora), and
206
229
  **Mission Control** (indigo control panel). Each works in light and dark (the moon toggle). A design
207
230
  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
231
+ `lib/dashboard/public/designs.js`, so adding one is trivial. Fonts are **self-hosted**
232
+ (`lib/dashboard/public/fonts/*.woff2`), keeping the dashboard fully offline and dependency-free. Your
210
233
  choice persists per viewer (localStorage) and as the project default (`config.design`).
211
234
 
212
235
  ### Chat
@@ -221,6 +244,16 @@ drawer is served by the one read-only endpoint, `GET /api/agentfile?path=` (scop
221
244
  `.spectoflow/agents/**` + `.spectoflow/skills/**`, path-traversal-safe) — the framework's only other
222
245
  server surface is unchanged.
223
246
 
247
+ ### Slash commands
248
+
249
+ Type `/` in either chat surface to open an autocomplete of reusable **prompt macros** — pick one,
250
+ type your text, send. Five ship built-in (`/spec`, `/plan`, `/revue`, `/resume`, `/rapport_jour`);
251
+ add your own from **Personalize → Commands** (a trigger, a short description, and the instruction the
252
+ agent receives — put `{{input}}` where your text should land, or it's appended). Expansion happens
253
+ **client-side**, so it's fully agent-agnostic: the agent never sees the `/`, it gets the full
254
+ instruction, while the chat log keeps the short `/rapport_jour focus bugs` you typed. Commands persist
255
+ in `config.json` (the DB online), so they survive a refresh and travel with the project.
256
+
224
257
  ### Customize
225
258
 
226
259
  Personalize → **Extend spectoflow** lets you extend the project's own spectoflow install: add a
@@ -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,30 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const http = require('http');
6
+ const os = require('os');
7
+ const readline = require('readline');
6
8
  const { spawn } = require('child_process');
7
- const store = require('../templates/lib/store');
9
+ const store = require('../lib/store');
8
10
  const adapters = require('../lib/adapters');
9
11
  const detect = require('../lib/detect');
10
12
  const ownership = require('../lib/ownership');
11
13
  const manifest = require('../lib/manifest');
12
14
  const registry = require('../lib/registry');
15
+ const workspace = require('../lib/workspace');
13
16
  const initLib = require('../lib/init');
17
+ const globalConfig = require('../lib/global-config');
14
18
  const mcp = require('../lib/mcp');
15
- const { startRun } = require('../templates/dashboard/runner');
16
- const { buildCustomizePrompt } = require('../templates/lib/customize-prompts');
19
+ const { startRun } = require('../lib/dashboard/runner');
20
+ const { buildCustomizePrompt } = require('../lib/customize-prompts');
17
21
 
18
22
  const KIT = path.resolve(__dirname, '..');
19
23
  const TPL = path.join(KIT, 'templates');
20
24
  const VERSION = require('../package.json').version;
21
25
  const argv = process.argv.slice(2);
22
26
  const cmd = argv[0] || 'help';
27
+ // `--name=value` flag reader — joins remaining `=`-split parts back (a URL's own query string may
28
+ // contain one), returns undefined when absent so callers can tell "not passed" from an empty string.
29
+ const flag = (name) => (argv.find((a) => a.startsWith(`--${name}=`)) || '').split('=').slice(1).join('=') || undefined;
23
30
 
24
31
  // Tiny ANSI colouriser — no dependency; disabled when not a TTY or NO_COLOR is set.
25
32
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
@@ -77,10 +84,13 @@ function readWorkflowSteps(dir) {
77
84
  }
78
85
 
79
86
  // ---- dashboard port + running-state probe ------------------------------------
80
- // Precedence: --port=NNNN > SPECTOFLOW_PORT env > 4319 (matches templates/dashboard/server.js).
87
+ // Precedence: --port=NNNN > SPECTOFLOW_PORT env > the workspace's own port (matches
88
+ // lib/dashboard/hub-server.js).
81
89
  function resolvePort(args) {
82
90
  const arg = (args.find((a) => a.startsWith('--port=')) || '').split('=')[1];
83
- return Number(arg || process.env.SPECTOFLOW_PORT || 4319);
91
+ if (arg) return Number(arg);
92
+ if (process.env.SPECTOFLOW_PORT) return Number(process.env.SPECTOFLOW_PORT);
93
+ try { return workspace.settings().port; } catch { return 4319; }
84
94
  }
85
95
 
86
96
  // Native http probe, ~500ms timeout, never throws — resolves true/false.
@@ -101,7 +111,7 @@ function init() {
101
111
  const r = initLib.runInit({ target, templatesDir: TPL, version: VERSION, agentsArg });
102
112
  console.log(logo());
103
113
  console.log(`${c.g('✓')} installed in ${c.bold(r.target)}`);
104
- console.log(` ${c.dim('.spectoflow/')} framework — brain, workflow, agents, skills, policy, dashboard, config`);
114
+ console.log(` ${c.dim('.spectoflow/')} framework — brain, workflow, agents, skills, policy, config`);
105
115
  console.log(` ${c.dim('specs/ plans/')} markdown artifacts (your source of truth)`);
106
116
  r.written.forEach((w) => console.log(` ${c.cy('+')} ${w}`));
107
117
  r.notes.forEach((n) => console.log(` ${c.y('!')} ${c.dim(n)}`));
@@ -123,7 +133,7 @@ async function update() {
123
133
  const r = require('../lib/update').runUpdate({ projectRoot: root, templatesDir: TPL, version: VERSION, dryRun, force });
124
134
 
125
135
  const from = r.fromVersion || 'unknown';
126
- const changed = r.refreshed.length + r.created.length + r.adopted.length + r.newSidecar.length + r.forced.length;
136
+ 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
137
  const row = (sym, label, list, painter, note) => {
128
138
  if (!list.length) return;
129
139
  const n = c.dim(String(list.length).padStart(2));
@@ -138,6 +148,11 @@ async function update() {
138
148
  row(c.b('~'), 'adopted', r.adopted, c.b);
139
149
  row(c.y('!'), 'forced', r.forced, c.y, 'overwrote a diverged file — its previous content is gone');
140
150
  row(c.y('!'), '.new', r.newSidecar, c.y, 'you edited these — new version saved as *.new, merge by hand (or re-run with --force)');
151
+ row(c.dim('−'), 'removed', r.removed, c.dim, 'no longer part of the kit (the dashboard lives in the spectoflow package now)');
152
+ 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');
153
+ 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/')}`);
154
+ 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`)}`));
155
+ 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
156
  if (r.unchanged.length) console.log(` ${c.dim('·')} ${c.dim('unchanged'.padEnd(9))} ${c.dim(String(r.unchanged.length).padStart(2))}`);
142
157
  console.log(` ${c.dim('=')} ${c.dim('preserved'.padEnd(9))} ${c.dim('config.json · workflow.md · specs/ · plans/ · your custom agents & skills')}`);
143
158
  console.log('');
@@ -151,9 +166,7 @@ async function update() {
151
166
  // is invalidated. Do that via a surgical per-project reload so an update always actually takes
152
167
  // effect, without restarting the whole hub (which would disturb every other project open in it).
153
168
  if (!dryRun && changed) {
154
- const lockPath = registry.hubLockPath();
155
- let info = null;
156
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
169
+ const info = workspace.readLock();
157
170
  if (info && info.port && (await probeDashboard(info.port, 2000))) {
158
171
  const entry = registry.findByPath(root);
159
172
  if (entry) {
@@ -161,7 +174,7 @@ async function update() {
161
174
  const res = await fetch(`http://localhost:${info.port}/api/hub/reload/${entry.id}`, { method: 'POST' });
162
175
  const body = await res.json().catch(() => ({}));
163
176
  console.log(` ${c.dim(body.reloaded
164
- ? 'Hub is running — reloaded this project\'s server code (other open projects unaffected).'
177
+ ? 'Hub is running — reloaded this project (other open projects unaffected).'
165
178
  : 'Hub is running, but this project wasn\'t loaded in it yet — nothing to reload.')}`);
166
179
  } catch {
167
180
  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 +184,158 @@ async function update() {
171
184
  }
172
185
  }
173
186
 
187
+ // `spectoflow dashboard validate <file>` — the generate-dashboard skill's verification step (it used
188
+ // to require() a lib file vendored in the project; the validator lives in this package now).
189
+ function validateDashboardFile(file) {
190
+ if (!file) { console.log('Usage: spectoflow dashboard validate <file.json>'); process.exitCode = 1; return; }
191
+ let spec;
192
+ try { spec = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
193
+ catch (e) { console.log(`${c.y('invalid')} — cannot read/parse ${file}: ${e.message}`); process.exitCode = 1; return; }
194
+ const r = require('../lib/custom-dashboard').validateSpec(spec);
195
+ if (r.valid) { console.log(`${c.g('valid')} — ${spec.id} (${(spec.blocks || []).length} block(s))`); return; }
196
+ console.log(`${c.y('invalid')} — ${file}`);
197
+ (r.errors || []).forEach((e) => console.log(` ${c.y('!')} ${e}`));
198
+ process.exitCode = 1;
199
+ }
200
+
201
+ // `spectoflow dashboard init [--path <dir>] [--port N] [--name "…"] [--design <id>]` — create (or
202
+ // move) the dashboard workspace from the command line, mirroring what `startDashboard()` does lazily
203
+ // on first `spectoflow dashboard`, but explicit and inspectable.
204
+ function dashboardInit() {
205
+ try {
206
+ const r = workspace.init({ path: flag('path'), port: flag('port'), name: flag('name'), design: flag('design') });
207
+ console.log(logo());
208
+ console.log(`${c.g('✓')} dashboard workspace ${r.created ? 'created' : 'updated'} at ${c.bold(r.dir)}`);
209
+ if (r.registryCarried) console.log(` ${c.dim('your project list was carried over from the previous workspace')}`);
210
+ const s = workspace.settings();
211
+ console.log(` ${c.dim('name')} ${s.name} ${c.dim('port')} ${s.port} ${c.dim('design')} ${s.design}`);
212
+ console.log(`\n start it: ${c.g('spectoflow dashboard')}\n`);
213
+ } catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
214
+ }
215
+
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
+ return url;
245
+ }
246
+
174
247
  // THE launch command — routes the subcommands, then starts. Starting spawns the server DETACHED and
175
248
  // hands the prompt straight back (no foreground blocking), then prints the commands to drive it.
176
249
  async function dashboard() {
177
250
  const sub = argv[1];
251
+ if (sub === 'init') return dashboardInit();
252
+ if (sub === 'login') return dashboardLogin();
253
+ if (sub === 'logout') return dashboardLogout();
254
+ if (sub === 'publish') return dashboardPublish(true);
255
+ if (sub === 'unpublish') return dashboardPublish(false);
178
256
  if (sub === 'stop') return stopDashboard();
179
257
  if (sub === 'status') return dashboardStatus();
180
258
  if (sub === 'restart') return restartDashboard();
181
259
  if (sub === 'create') return runCustomize('dashboard');
260
+ if (sub === 'validate') return validateDashboardFile(argv[2]);
182
261
  return startDashboard();
183
262
  }
184
263
 
185
- // ---- projects: the multi-project registry's CLI surface (~/.spectoflow/projects.json) ----
264
+ // ---- online dashboard (C1): login / logout / publish / unpublish ----
265
+ // The machine token authenticates this machine to a hosted dashboard (server/). `login` checks it
266
+ // against POST /connector/whoami, stores it in the workspace's remote.json (0600) and points
267
+ // dashboard.url at the server; the hub — running or next started — opens the outbound connection.
268
+ async function fetchJSON(url, init = {}, timeoutMs = 10000) {
269
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
270
+ let data = {}; try { data = await res.json(); } catch {}
271
+ return { status: res.status, ok: res.ok, data };
272
+ }
273
+ // Best-effort call on the running local hub; null when no hub is up (the caller says "next start will").
274
+ async function hubCall(pathname, payload) {
275
+ const info = workspace.readLock();
276
+ if (!info || !info.port || !(await probeDashboard(info.port))) return null;
277
+ try { return await fetchJSON(`http://localhost:${info.port}${pathname}`, payload === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }
278
+ catch { return null; }
279
+ }
280
+ function fail(msg) { console.log(`${c.y('!')} ${msg}`); process.exitCode = 1; }
281
+ async function dashboardLogin() {
282
+ const url = flag('url'), token = flag('token'), transport = flag('transport') || 'ws';
283
+ if (!url || !token) { console.log(`Usage: spectoflow dashboard login --url=<https://…> --token=<spf_…> ${c.dim('[--name=<machine name>] [--transport=ws|http]')}`); process.exitCode = 1; return; }
284
+ let base;
285
+ try { base = new URL(url); if (!/^https?:$/.test(base.protocol)) throw new Error(); } catch { return fail('--url must be an http(s) URL, e.g. https://dashboard.example.com'); }
286
+ if (!['ws', 'http'].includes(transport)) return fail('--transport must be ws or http');
287
+ const clean = base.toString().replace(/\/+$/, '');
288
+ let who;
289
+ try { who = await fetchJSON(`${clean}/connector/whoami`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); }
290
+ catch (e) { return fail(`could not reach ${clean} (${e.cause && e.cause.code ? e.cause.code : e.name === 'TimeoutError' ? 'timeout' : e.message})`); }
291
+ if (who.status === 401) return fail(`${clean} rejected this token — create one on the server: node cli.js token create --name="${os.hostname()}"`);
292
+ if (!who.ok) return fail(`${clean} answered HTTP ${who.status} — is that the spectoflow server?`);
293
+ const machineName = flag('name') || os.hostname();
294
+ workspace.migrateLegacyHome(); if (!workspace.exists()) workspace.init({});
295
+ workspace.writeRemote({ url: clean, token, machineName, transport });
296
+ globalConfig.set('dashboard.url', clean);
297
+ console.log(`${c.g('✓')} logged in to ${c.bold(clean)} as ${c.bold(machineName)} ${c.dim(`(machine ${who.data.machineId || '?'}, ${transport})`)}`);
298
+ const r = await hubCall('/api/hub/remote/reconnect', {});
299
+ console.log(` ${c.dim(r ? 'the running hub is connecting now' : 'the next `spectoflow dashboard` will connect')}`);
300
+ console.log(` publish a project from inside it: ${c.g('spectoflow dashboard publish')}`);
301
+ }
302
+ async function dashboardLogout() {
303
+ const had = workspace.clearRemote();
304
+ globalConfig.set('dashboard.url', `http://localhost:${resolvePort(argv)}`);
305
+ await hubCall('/api/hub/remote/reconnect', {});
306
+ console.log(had ? `${c.g('✓')} logged out — this machine no longer connects to an online dashboard` : `${c.dim('○')} not logged in to any online dashboard`);
307
+ }
308
+ async function dashboardPublish(published) {
309
+ let id = flag('id');
310
+ if (!id) {
311
+ const entry = registry.findByPath(process.cwd());
312
+ if (!entry) return fail(`this folder isn't registered in the dashboard yet — run ${c.g('spectoflow dashboard')} here first, or pass --id=<id> (see ${c.g('spectoflow projects')})`);
313
+ id = entry.id;
314
+ }
315
+ const meta = workspace.setPublished(id, published);
316
+ if (!meta) return fail(`no project registered with id ${id}`);
317
+ await hubCall(`/api/hub/projects/${encodeURIComponent(id)}/publish`, { published });
318
+ if (!published) return console.log(`${c.g('✓')} ${id} is no longer published`);
319
+ console.log(`${c.g('✓')} ${id} is published`);
320
+ const remote = workspace.readRemote();
321
+ if (remote) console.log(` online: ${c.bold(remote.url)}`);
322
+ else console.log(` ${c.dim('not logged in yet —')} ${c.g('spectoflow dashboard login --url=… --token=…')}`);
323
+ }
324
+ // The one-line "online" status under the local one — used by `dashboard` and `dashboard status`.
325
+ async function printOnlineLine(port, running) {
326
+ const remote = workspace.readRemote();
327
+ if (remote) {
328
+ let s = null;
329
+ if (running) { try { s = (await fetchJSON(`http://localhost:${port}/api/hub/remote`)).data; } catch {} }
330
+ if (s && s.connected) return console.log(`${c.g('●')} online → ${c.bold(remote.url)} ${c.dim(`(connected via ${s.transport}, machine "${remote.machineName}")`)}`);
331
+ const why = !running ? 'hub not running' : s && s.lastError ? `not connected: ${s.lastError}` : 'connecting…';
332
+ return console.log(`${c.dim('○')} online → ${c.bold(remote.url)} ${c.dim(`(${why})`)}`);
333
+ }
334
+ const url = globalConfig.get('dashboard.url').value;
335
+ if (!isLocalUrl(url)) console.log(`${c.dim('○')} online → ${url} ${c.dim('— not logged in:')} ${c.g('spectoflow dashboard login --url=… --token=…')}`);
336
+ }
337
+
338
+ // ---- projects: the multi-project registry's CLI surface (the workspace's projects.json) ----
186
339
  function projectsCmd() {
187
340
  const sub = argv[1];
188
341
  if (sub === 'remove') return projectsRemove(argv[2]);
@@ -204,6 +357,20 @@ function projectsRemove(id) {
204
357
  console.log(ok ? `${c.g('✓')} removed ${id}` : `${c.y('!')} no project registered with id ${id}`);
205
358
  }
206
359
 
360
+ // ---- config: global settings (~/.spectoflow/config.json), editable from anywhere ----
361
+ function configCmd() {
362
+ const sub = argv[1];
363
+ try {
364
+ if (sub === 'get') { if (!argv[2]) throw new Error('Usage: spectoflow config get <key>'); console.log(globalConfig.get(argv[2]).value); return; }
365
+ 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; }
366
+ console.log(wordmark());
367
+ const rows = globalConfig.list();
368
+ const w = Math.max(...rows.map((r) => r.key.length));
369
+ rows.forEach((r) => console.log(` ${c.g(r.key.padEnd(w))} ${String(r.value).padEnd(28)} ${c.dim('(' + r.source + ')')}`));
370
+ console.log(c.dim(`\n file: ${globalConfig.configPath()} · spectoflow config set <key> <value>`));
371
+ } catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
372
+ }
373
+
207
374
  // ---- Customize: `spectoflow skill/agent/dashboard create` — the CLI mirror of the dashboard's
208
375
  // Settings → Customize UI. Both surfaces build the same natural-language prompt (customize-prompts.js)
209
376
  // and post it through the same pipeline (runner.js's startRun — the function /api/run itself calls),
@@ -260,18 +427,21 @@ async function runCustomize(kind) {
260
427
  // global registry first, then either joins an already-running hub or spawns a new one — probing first
261
428
  // so a second start just reports the running one instead of spawning a duplicate.
262
429
  async function startDashboard() {
430
+ if (!(await resolveDashboardUrl())) return;
263
431
  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 {}
432
+ workspace.migrateLegacyHome();
433
+ if (!workspace.exists()) workspace.init({});
434
+ const hasProject = fs.existsSync(path.join(root, '.spectoflow'));
435
+ const entry = hasProject ? workspace.registerProject(root) : null;
436
+ const boardUrl = (p) => (entry ? `http://localhost:${p}/p/${entry.id}/board` : `http://localhost:${p}/`);
437
+ const info = workspace.readLock();
269
438
  if (info && info.port && await probeDashboard(info.port)) {
270
439
  console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
440
+ await printOnlineLine(info.port, true);
271
441
  return printDashboardCommands();
272
442
  }
273
443
  const port = resolvePort(argv);
274
- const hubPath = path.join(KIT, 'lib', 'hub-server.js');
444
+ const hubPath = path.join(KIT, 'lib', 'dashboard', 'hub-server.js');
275
445
  const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
276
446
  const child = spawn('node', [hubPath], { detached: true, stdio: 'ignore', env });
277
447
  child.unref(); // let this CLI exit while the hub keeps running
@@ -281,6 +451,7 @@ async function startDashboard() {
281
451
  for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
282
452
  if (up) console.log(`${c.g('✓')} hub started → ${c.bold(boardUrl(port))} ${c.dim('(pid ' + child.pid + ')')}`);
283
453
  else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on http://localhost:${port} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
454
+ await printOnlineLine(port, up);
284
455
  printDashboardCommands();
285
456
  }
286
457
 
@@ -293,16 +464,17 @@ function printDashboardCommands() {
293
464
  }
294
465
 
295
466
  async function dashboardStatus() {
296
- const lockPath = registry.hubLockPath();
297
- let info = null;
298
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
467
+ if (!(await resolveDashboardUrl())) return;
468
+ const info = workspace.readLock();
299
469
  const port = (info && info.port) || resolvePort(argv);
300
470
  const running = await probeDashboard(port);
301
471
  if (running) console.log(`${c.g('●')} hub running → ${c.bold('http://localhost:' + port)}${info && info.pid ? c.dim(' (pid ' + info.pid + ')') : ''}`);
302
472
  else console.log(`${c.dim('○')} hub not running`);
473
+ await printOnlineLine(port, running);
303
474
  }
304
475
 
305
476
  async function restartDashboard() {
477
+ if (!(await resolveDashboardUrl())) return;
306
478
  await stopDashboard();
307
479
  // Windows doesn't deliver real signals — process.kill() returns once the request is issued, not
308
480
  // once the process (and the port it held) is actually gone. A short gap here, plus startDashboard()
@@ -315,20 +487,23 @@ async function restartDashboard() {
315
487
  // Stop the running hub: read the global lock it wrote, verify it's actually up, then terminate it
316
488
  // and clear the lock. Safe against a stale lock (a recycled pid) because it only kills when the port
317
489
  // still responds.
490
+ function unlinkLocks() {
491
+ try { fs.unlinkSync(workspace.lockPath()); } catch {}
492
+ try { fs.unlinkSync(path.join(globalConfig.homeDir(), 'hub.lock')); } catch {}
493
+ }
318
494
  async function stopDashboard() {
319
- const lockPath = registry.hubLockPath();
320
- let info = null;
321
- try { info = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {}
495
+ if (!(await resolveDashboardUrl())) return;
496
+ const info = workspace.readLock();
322
497
  const port = (info && info.port) || resolvePort(argv);
323
498
  const running = await probeDashboard(port);
324
499
  if (!running) {
325
- if (info) { try { fs.unlinkSync(lockPath); } catch {} } // stale lock
500
+ if (info) unlinkLocks(); // stale lock
326
501
  return console.log('No spectoflow hub is running.');
327
502
  }
328
503
  if (info && info.pid) {
329
504
  try {
330
505
  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)
506
+ unlinkLocks(); // and we clear it too (Windows has no real signals)
332
507
  return console.log(`spectoflow hub stopped (pid ${info.pid}, was on http://localhost:${port}).`);
333
508
  } catch {}
334
509
  }
@@ -348,9 +523,7 @@ async function status() {
348
523
  console.log(`${(p.config && p.config.projectType) || 'project'} — mode ${p.config.mode} · lang ${p.config.language}`);
349
524
  console.log(`${done}/${tasks.length} tasks done · ${p.specs.length} spec(s) · ${p.agents.length} agents · ${p.skills.length} skills`);
350
525
  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 {}
526
+ const info = workspace.readLock();
354
527
  const port = (info && info.port) || resolvePort(argv);
355
528
  const running = await probeDashboard(port);
356
529
  console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
@@ -402,13 +575,16 @@ ${c.bold('Project')}
402
575
  ${c.g('init')} ${c.dim('[dir] [--agent=a,b]')} scaffold a project (auto-detects agents; wires Playwright MCP)
403
576
  ${c.g('update')} ${c.dim('[--dry-run|--force]')} refresh framework files to this kit version
404
577
  ${c.g('status')} progress + whether the dashboard is running
578
+ ${c.g('config')} ${c.dim('[get <key>|set <key> <value>]')} global settings for every project (~/.spectoflow/config.json)
405
579
 
406
580
  ${c.bold('Dashboard')}
407
581
  ${c.g('dashboard')} ${c.dim('[--port=NNNN]')} start the control plane in the background (default 4319)
582
+ ${c.g('dashboard init')} ${c.dim('[--path=<dir>] [--port=N] [--name=…]')} create/move the dashboard workspace ${c.dim('(default ~/.spectoflow/dashboard)')}
408
583
  ${c.g('dashboard status')} is it running? (url + pid)
409
584
  ${c.g('dashboard stop')} stop it ${c.dim('(alias: stop)')}
410
585
  ${c.g('dashboard restart')} stop then start
411
- ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far (~/.spectoflow/projects.json)
586
+ ${c.g('dashboard login')} ${c.dim('--url=<u> --token=<t>')} connect this machine to an online dashboard ${c.dim('(logout · publish · unpublish)')}
587
+ ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far
412
588
 
413
589
  ${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
414
590
  ${c.g('skill create')} ${c.dim('"<description>" | --auto')} generate a project skill
@@ -438,22 +614,30 @@ const HELP = {
438
614
  ${c.dim('An existing CLAUDE.md is preserved as CLAUDE.md.tomerge for you to merge on first run.')}
439
615
  ${c.dim('Full list with docs links: the dashboard\'s Documentation tab, or the README.')}`,
440
616
  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…)
617
+ Refresh framework-owned files (engine, default agents & skills, AGENTS.md, policy…)
442
618
  to this CLI's version, ${c.bold('preserving your work')}: config.json, workflow.md, specs/, plans/
443
619
  and any agent/skill you edited are never overwritten (an edited file's new version lands as
444
620
  ${c.dim('*.new')} for you to merge). ${c.g('--dry-run')} previews without writing.
445
621
  ${c.g('--force')} (${c.g('-f')}) overwrites a diverged file in place instead of dropping a ${c.dim('*.new')}
446
622
  — use it when you know you have no local edits worth keeping (e.g. a file stuck diverged from an
447
623
  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
624
+ dashboard: `${c.bold('spectoflow dashboard')} ${c.dim('[--port=NNNN] [--url=<u>] [init|status|stop|restart|create|validate|login|logout|publish|unpublish]')}\n
449
625
  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:
626
+ ${c.dim('$SPECTOFLOW_PORT')}) and hand the prompt back. ${c.g('--url=<u>')} sets which dashboard this
627
+ project talks to (${c.dim('~/.spectoflow/config.json → dashboard.url')}) — asked once, interactively,
628
+ the first time you ever run a dashboard command; answer it up front with this flag instead. Subcommands:
629
+ ${c.g('init')} create/move the dashboard workspace: ${c.dim('[--path=<dir>] [--port=N] [--name=…] [--design=<id>]')}
451
630
  ${c.g('status')} is it running? (url + pid)
452
631
  ${c.g('stop')} stop it ${c.dim('(alias: spectoflow stop)')}
453
632
  ${c.g('restart')} stop then start
454
- ${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}`,
633
+ ${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}
634
+ ${c.g('validate <file>')} check a custom-view JSON against the block schema
635
+ ${c.g('login')} connect this machine to an online dashboard: ${c.dim('--url=<https://…> --token=<spf_…> [--name=<machine>] [--transport=ws|http]')}
636
+ ${c.g('logout')} forget it (remote.json removed, dashboard.url back to local)
637
+ ${c.g('publish')} make the current project (or ${c.dim('--id=<id>')}) visible online — nothing is published by default
638
+ ${c.g('unpublish')} take it back offline`,
455
639
  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
640
+ List every registered project in the global registry (the dashboard workspace's projects.json) (stored by
457
641
  ${c.g('spectoflow dashboard')}) — id, name, path. ${c.g('remove <id>')} drops one (e.g. a project that moved
458
642
  or was deleted) from this list only; it never touches that project's own files.`,
459
643
  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 +659,12 @@ const HELP = {
475
659
  skills: `${c.bold('spectoflow skills')}\n List the evolving procedures (name · capability · what it does).`,
476
660
  workflow: `${c.bold('spectoflow workflow')}\n Show the pipeline steps, marking which are enabled (●) or disabled (○).`,
477
661
  stop: `${c.bold('spectoflow stop')}\n Stop the running dashboard (alias for ${c.g('spectoflow dashboard stop')}).`,
662
+ config: `${c.bold('spectoflow config')} ${c.dim('[get <key> | set <key> <value>]')}\n
663
+ Global settings that apply to every project on this machine, stored in ${c.dim('~/.spectoflow/config.json')}:
664
+ ${c.g('dashboard.url')} the dashboard projects talk to (default http://localhost:4319)
665
+ ${c.g('dashboard.path')} where the dashboard workspace lives (default ~/.spectoflow/dashboard)
666
+ ${c.g('defaults.agent')} ${c.g('defaults.language')} ${c.g('defaults.mode')} ${c.g('defaults.design')} seeds for ${c.g('spectoflow init')}
667
+ A project's own .spectoflow/config.json always wins over these defaults.`,
478
668
  };
479
669
  const showHelp = (name) => console.log('\n' + HELP[name].trim() + '\n');
480
670
 
@@ -482,6 +672,7 @@ const showHelp = (name) => console.log('\n' + HELP[name].trim() + '\n');
482
672
  const fns = {
483
673
  init, update, dashboard, stop: stopDashboard, status, list: listAll, help, version,
484
674
  projects: projectsCmd,
675
+ config: configCmd,
485
676
  agents: () => { console.log(wordmark()); printAgents(false); },
486
677
  skills: () => { console.log(wordmark()); printSkills(false); },
487
678
  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 };