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.
- package/README.md +21 -10
- package/bin/postinstall.js +1 -0
- package/bin/spectoflow.js +144 -35
- package/lib/adapters.js +8 -2
- package/{templates/lib → lib}/custom-dashboard.js +2 -2
- package/{templates/lib → lib}/customize-prompts.js +1 -1
- package/lib/dashboard/handlers.js +78 -0
- package/lib/{hub-server.js → dashboard/hub-server.js} +35 -44
- package/lib/dashboard/ops.js +176 -0
- package/{templates → lib}/dashboard/orchestrator.js +17 -8
- package/{templates → lib}/dashboard/public/app.js +5 -6
- package/{templates → lib}/dashboard/runner.js +6 -5
- package/{templates → lib}/dashboard/summarize.js +1 -1
- package/lib/detect.js +16 -1
- package/lib/global-config.js +65 -0
- package/lib/init.js +10 -4
- package/lib/registry.js +8 -8
- package/{templates/lib → lib}/store.js +16 -12
- package/lib/update.js +69 -1
- package/lib/workspace.js +84 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +6 -6
- package/templates/README.md +4 -3
- package/templates/agents/framework-curator.md +11 -11
- package/templates/capabilities.md +1 -1
- package/templates/dashboards/.gitkeep +0 -0
- package/templates/skills/generate-dashboard/SKILL.md +15 -13
- package/templates/dashboard/custom/.gitkeep +0 -3
- package/templates/dashboard/handlers.js +0 -251
- package/templates/dashboard/server.js +0 -73
- package/templates/lib/agents-registry.js +0 -65
- /package/{templates → lib}/dashboard/files.js +0 -0
- /package/{templates → lib}/dashboard/public/charts.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
- /package/{templates → lib}/dashboard/public/designs.js +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/hub.html +0 -0
- /package/{templates → lib}/dashboard/public/hub.js +0 -0
- /package/{templates → lib}/dashboard/public/i18n.js +0 -0
- /package/{templates → lib}/dashboard/public/icons.js +0 -0
- /package/{templates → lib}/dashboard/public/index.html +0 -0
- /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
- /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
- /package/{templates → lib}/dashboard/public/stats.js +0 -0
- /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,
|
|
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,
|
|
123
|
-
|
|
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
|
|
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.
|
|
175
|
-
|
|
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
|
package/bin/postinstall.js
CHANGED
|
@@ -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('../
|
|
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('../
|
|
16
|
-
const { buildCustomizePrompt } = require('../
|
|
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 >
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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('
|
|
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,
|
|
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.
|
|
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
|
|
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: \`
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
|
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 };
|