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
@@ -28,11 +28,10 @@ project's own team will use to build product features.
28
28
  ## Operating standards
29
29
 
30
30
  - **Declarative dashboards, never raw markup (see `generate-dashboard`).** A custom dashboard is
31
- produced as a block spec chosen from the framework's fixed vocabulary
32
- (`.spectoflow/lib/custom-dashboard.js`), rendered by the exact same token-driven components the
33
- built-in Board uses. Why: this is what guarantees a generated dashboard matches the *active* design
34
- and every future one the user switches to, with zero page-specific CSS to keep in sync, and no
35
- arbitrary generated code ever executing in the dashboard.
31
+ produced as a block spec chosen from the framework's fixed vocabulary, rendered by the exact same
32
+ token-driven components the built-in Board uses. Why: this is what guarantees a generated dashboard
33
+ matches the *active* design and every future one the user switches to, with zero page-specific CSS
34
+ to keep in sync, and no arbitrary generated code ever executing in the dashboard.
36
35
  - **Gold-standard shape for skills and agents (`docs/agents-skills-standard.md`).** A generated
37
36
  `SKILL.md` or agent `.md` follows the exact same front-matter and heading structure as every
38
37
  shipped one — `## When to use` / `## Method` / `## Output contract` / `## Quality bar` /
@@ -61,19 +60,19 @@ A generated dashboard renders correctly in every shipped design (light and dark)
61
60
  hardcoded color or manual style — verified by construction, since only the declarative block
62
61
  vocabulary was used. A generated skill or agent passes the same quality bar the framework's own
63
62
  shipped files are held to: real citations in `## References`, a checkable `## Quality bar` /
64
- `## Definition of done`, and front-matter that `templates/lib/store.js`'s flat parser can read
63
+ `## Definition of done`, and front-matter that the dashboard's flat parser can read
65
64
  unchanged. The new dashboard tab, skill, or agent is visible in the dashboard (Board's nav / Agents &
66
65
  Skills tab) on the very next SSE tick — no manual refresh, no extra registration step.
67
66
 
68
67
  ## Handoff
69
68
 
70
- Writes the generated file(s) directly (`.spectoflow/dashboard/custom/<id>.json`,
69
+ Writes the generated file(s) directly (`.spectoflow/dashboards/<id>.json`,
71
70
  `.spectoflow/skills/<slug>/SKILL.md`, or `.spectoflow/agents/<slug>.md`) and reports through the
72
71
  `::spectoflow` sentinel (see each skill's Output contract for its exact syntax) so the requester sees
73
72
  it land in the group chat and the dashboard picks it up live. A dashboard spec that fails
74
- `.spectoflow/lib/custom-dashboard.js`'s validation, or a skill/agent file whose front-matter the flat
75
- parser can't read, is not done — fix it before reporting completion, never leave a broken file for the
76
- dashboard to silently skip.
73
+ `spectoflow dashboard validate`, or a skill/agent file whose front-matter the flat parser can't read,
74
+ is not done — fix it before reporting completion, never leave a broken file for the dashboard to
75
+ silently skip.
77
76
 
78
77
  ## Guardrails
79
78
 
@@ -90,5 +89,6 @@ dashboard to silently skip.
90
89
  ## References
91
90
 
92
91
  - `docs/agents-skills-standard.md` — the gold-standard shape this role's output must match.
93
- - `.spectoflow/lib/custom-dashboard.js` — the declarative block vocabulary and its validator.
92
+ - `spectoflow dashboard validate <file>` — the declarative block vocabulary's validator (in the
93
+ spectoflow package).
94
94
  - `.spectoflow/skills/clarify` — the reflex this role leans on before generating from an ambiguous ask.
@@ -17,7 +17,7 @@ the Clarify step in `AGENTS.md`.
17
17
  `customization` is also **not a workflow step** — it is triggered explicitly, either from the
18
18
  dashboard's Settings → Customize page or by a direct request ("add a dashboard for…", "create a skill
19
19
  for…", "create an agent for…"). The `framework-curator` agent owns it, running one of four skills:
20
- `generate-dashboard` (a declarative block-spec page — see `templates/lib/custom-dashboard.js`),
20
+ `generate-dashboard` (a declarative block-spec page — validated by `spectoflow dashboard validate`),
21
21
  `generate-skill`, `generate-agent` (both follow `docs/agents-skills-standard.md`'s gold-standard
22
22
  shape, grounded in real, cited domain standards), and `propose-customizations` (the "Auto" mode:
23
23
  analyzes the project and proposes candidates instead of taking a description). Still gated by mode
@@ -7,6 +7,29 @@
7
7
  "specsDir": null,
8
8
  "dashboard": { "autostart": true },
9
9
  "design": "console",
10
+ "theme": "dark",
11
+ "boardView": "list",
12
+ "sideHidden": false,
13
+ "expandedPhases": [],
14
+ "activeTab": "board",
15
+ "chatOpen": false,
16
+ "kanbanColumns": ["todo", "in_progress", "to_validate", "to_analyze", "done", "blocked"],
17
+ "kanbanPageSize": 10,
18
+ "navTabs": [
19
+ {"id":"board","enabled":true},
20
+ {"id":"chat","enabled":true},
21
+ {"id":"requests","enabled":true},
22
+ {"id":"attention","enabled":true},
23
+ {"id":"backlog","enabled":true},
24
+ {"id":"workflow","enabled":true},
25
+ {"id":"team","enabled":true},
26
+ {"id":"files","enabled":true},
27
+ {"id":"notes","enabled":false},
28
+ {"id":"meeting","enabled":false},
29
+ {"id":"info","enabled":true},
30
+ {"id":"docs","enabled":true},
31
+ {"id":"personalize","enabled":true}
32
+ ],
10
33
  "runners": {
11
34
  "claude": "claude -p --permission-mode acceptEdits",
12
35
  "codex": "codex exec"
File without changes
@@ -3,7 +3,7 @@ name: generate-dashboard
3
3
  description: Turn a description (or an auto-analysis) into a new custom dashboard page, as a declarative block spec that automatically matches every design the dashboard ships.
4
4
  capability: customization
5
5
  inputs: A description of what the dashboard should show (from the Customize page or chat), or a chosen candidate from propose-customizations; the project's specs/plans/code as source material.
6
- outputs: A validated block-spec JSON file at .spectoflow/dashboard/custom/<id>.json, live in the dashboard's nav on the next tick.
6
+ outputs: A validated block-spec JSON file at .spectoflow/dashboards/<id>.json, live in the dashboard's nav on the next tick.
7
7
  standard: declarative UI generation; Few's dashboard design principles
8
8
  ---
9
9
  # Generate dashboard
@@ -67,8 +67,8 @@ computed stats the Board already uses (see step 5).
67
67
 
68
68
  ### 4. Choose blocks — the vocabulary
69
69
 
70
- Pick from exactly these block types (anything else is invisible to the renderer — see
71
- `.spectoflow/lib/custom-dashboard.js` for the enforced schema):
70
+ Pick from exactly these block types (anything else is invisible to the renderer — the block schema
71
+ documented below is enforced by `spectoflow dashboard validate`):
72
72
 
73
73
  | `type` | Shape | Use for |
74
74
  |---|---|---|
@@ -102,7 +102,7 @@ in one screen — 4-8 blocks is a healthy page, not 20.
102
102
  ### 6. Pick an id, a title, an icon
103
103
 
104
104
  - `id`: lowercase kebab-case, unique among existing custom dashboards (list
105
- `.spectoflow/dashboard/custom/*.json` first) — this becomes the URL segment and the file name.
105
+ `.spectoflow/dashboards/*.json` first) — this becomes the URL segment and the file name.
106
106
  - `title`: short, a few words, shown as the nav tab label.
107
107
  - `icon`: one of `board`, `requests`, `backlog`, `workflow`, `agents`, `chat`, `info`, `attention`,
108
108
  `settings` (the same set the rest of the dashboard uses — pick the closest match; default to `info`
@@ -110,19 +110,21 @@ in one screen — 4-8 blocks is a healthy page, not 20.
110
110
 
111
111
  ### 7. Write and verify
112
112
 
113
- Write the spec to `.spectoflow/dashboard/custom/<id>.json` (pretty-printed, 2-space indent). Then
113
+ Write the spec to `.spectoflow/dashboards/<id>.json` (pretty-printed, 2-space indent). Then
114
114
  **verify it, don't assume it's valid** — run:
115
115
  ```
116
- node -e "console.log(JSON.stringify(require('./.spectoflow/lib/custom-dashboard').validateSpec(JSON.parse(require('fs').readFileSync('./.spectoflow/dashboard/custom/<id>.json','utf8')))))"
116
+ spectoflow dashboard validate .spectoflow/dashboards/<id>.json
117
117
  ```
118
- If `valid` is `false`, fix the reported errors and re-run before reporting done — a spec the
119
- dashboard's own validator rejects is never a finished deliverable, it would simply be skipped and the
120
- user would see nothing.
118
+ (use `npx spectoflow …` if spectoflow isn't on PATH)
119
+
120
+ If the output shows errors, fix them and re-run before reporting done — a spec the dashboard's own
121
+ validator rejects is never a finished deliverable, it would simply be skipped and the user would see
122
+ nothing.
121
123
 
122
124
  ## Output contract
123
125
 
124
- - One file: `.spectoflow/dashboard/custom/<id>.json`, valid against
125
- `.spectoflow/lib/custom-dashboard.js`'s `validateSpec` (verified per step 7, not assumed).
126
+ - One file: `.spectoflow/dashboards/<id>.json`, valid against the block schema
127
+ (verified per step 7 with `spectoflow dashboard validate`, not assumed).
126
128
  - Progress and completion reported to the orchestrator and group chat:
127
129
 
128
130
  ```
@@ -147,6 +149,6 @@ user would see nothing.
147
149
  - Stephen Few, *Information Dashboard Design* (O'Reilly/Analytics Press) — one purpose per dashboard,
148
150
  the plainest chart that carries the point, single-screen legibility.
149
151
  https://www.perceptualedge.com/library.php
150
- - `.spectoflow/lib/custom-dashboard.js` — the enforced block schema and bind allow-list (source of
151
- truth; this document summarizes it, the code is authoritative).
152
+ - `spectoflow dashboard validate <file>` — the declarative block vocabulary's validator (in the
153
+ spectoflow package; enforces the block schema and bind allow-list).
152
154
  - `dashboard/public/stats.js` — the exact shape of the live stats object bindable via `bind`.
@@ -1,3 +0,0 @@
1
- # This directory holds user-generated custom dashboard specs (created via the Customize page in
2
- # Settings, or written directly by the `generate-dashboard` skill). Empty on a fresh install.
3
- # Schema: templates/lib/custom-dashboard.js
@@ -1,251 +0,0 @@
1
- 'use strict';
2
- /*
3
- * spectoflow dashboard — per-project route logic, vendored into every project's
4
- * .spectoflow/dashboard/ (copied by init/update, exactly like server.js). Split out of server.js so
5
- * a single global hub process (lib/hub-server.js) can load a different project's routes on demand —
6
- * see docs/multi-project-hub-design.md's "the server must split in two" addendum.
7
- *
8
- * createHandlers(root) returns the per-project surface a listener-owning process needs:
9
- * - handleApi(req, res, u, emit): Promise<boolean> — true if this request was an API route and was
10
- * handled (caller should not also try static/SPA fallback); false otherwise. Deliberately excludes
11
- * /api/events: SSE client registration stays owned by whichever file owns the HTTP listener.
12
- * - watchDirs: string[] — dirs (relative to root) whose changes should emit {type:'change'}. The
13
- * caller owns the actual fs.watch calls (it owns emit).
14
- * - onBoot(): call once, the first time this project is opened in a server's lifetime (creates the
15
- * custom-dashboards dir if missing, reconciles any stale in-flight orchestration).
16
- */
17
- const fs = require('fs');
18
- const path = require('path');
19
- const store = require('../lib/store');
20
- const { startRun } = require('./runner');
21
- const { runSummarize } = require('./summarize');
22
- const orchestrator = require('./orchestrator');
23
- const agentsRegistry = require('../lib/agents-registry');
24
- const files = require('./files');
25
-
26
- function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
27
- 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({}); } }); }); }
28
-
29
- function createHandlers(root) {
30
- // Installed framework version: the manifest records it at init/update time. Fallback to the kit's
31
- // own package.json — only reachable (and only used) when the server is run straight from templates/
32
- // (dev/preview), never from an installed project whose sibling package.json belongs to the user.
33
- function frameworkVersion() {
34
- try { return JSON.parse(fs.readFileSync(path.join(root, '.spectoflow', '.manifest.json'), 'utf8')).version; } catch {}
35
- try { const pk = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); if (pk.name === 'spectoflow') return pk.version; } catch {}
36
- return null;
37
- }
38
- function project() {
39
- const p = store.readProject(root);
40
- const v = frameworkVersion(); if (v) p.version = v;
41
- p.projectName = path.basename(root);
42
- p.knownAgents = agentsRegistry.KNOWN_AGENTS.map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
43
- p.installedAgents = agentsRegistry.installedAgents(root);
44
- return p;
45
- }
46
- function findPlanFileForTask(id) { for (const pl of store.readPlans(root)) for (const ph of pl.phases) if (ph.tasks.find((t) => t.id === id)) return pl.file; return null; }
47
-
48
- const configPath = () => path.join(root, '.spectoflow', 'config.json');
49
- function writeConfig(patch) {
50
- const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
51
- if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
52
- if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
53
- if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
54
- if (typeof patch.agent === 'string' && patch.agent.trim()) {
55
- const id = patch.agent.trim();
56
- // Never activate an agent whose CLI isn't actually there — a picked-but-absent agent would just
57
- // fail silently the next time something tries to run it.
58
- if (!agentsRegistry.isAgentInstalled(id, root)) {
59
- const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
60
- const label = known ? known.label : id;
61
- throw new Error(`${label} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
62
- }
63
- cfg.agent = id;
64
- const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
65
- if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
66
- }
67
- fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
68
- return cfg;
69
- }
70
- function promoteAttention(item) {
71
- return store.addTask(root, { phase: 'Attention', title: item.text, owner: 'user' });
72
- }
73
-
74
- async function handleApi(req, res, u, emit) {
75
- const p = u.pathname;
76
-
77
- if (p === '/api/project') { sendJSON(res, 200, project()); return true; }
78
-
79
- if (p === '/api/agentfile' && req.method === 'GET') {
80
- const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
81
- const base = path.join(root, '.spectoflow');
82
- const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
83
- const abs = path.resolve(base, rel);
84
- const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
85
- if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
86
- { sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
87
- // Symlink guard: the resolved real path must stay within the (real) scope dirs.
88
- let real; try { real = fs.realpathSync(abs); } catch { real = null; }
89
- const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
90
- const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
91
- const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
92
- if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory())
93
- { sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
94
- sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') }); return true;
95
- }
96
-
97
- if (p === '/api/files/tree' && req.method === 'GET') { sendJSON(res, 200, { tree: files.tree(root) }); return true; }
98
- if (p === '/api/files/read' && req.method === 'GET') {
99
- const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
100
- const r = files.readFile(root, rel);
101
- sendJSON(res, r.error ? 400 : 200, r); return true;
102
- }
103
- if (p === '/api/files/write' && req.method === 'POST') {
104
- const { path: rel, content } = await body(req);
105
- const r = files.writeFile(root, rel, content);
106
- if (r.error) { sendJSON(res, 400, r); return true; }
107
- emit({ type: 'change' }); sendJSON(res, 200, r); return true;
108
- }
109
- if (p === '/api/files/mkdir' && req.method === 'POST') {
110
- const { path: rel } = await body(req);
111
- const r = files.mkdir(root, rel);
112
- if (r.error) { sendJSON(res, 400, r); return true; }
113
- emit({ type: 'change' }); sendJSON(res, 200, r); return true;
114
- }
115
-
116
- if (p === '/api/task' && req.method === 'POST') {
117
- const { title, phase, file, owner, level } = await body(req);
118
- if (!title || !String(title).trim()) { sendJSON(res, 400, { error: 'A title is required.' }); return true; }
119
- const t = store.addTask(root, { title: String(title).trim(), phase, file, owner, level });
120
- emit({ type: 'change' }); sendJSON(res, 200, { task: t }); return true;
121
- }
122
- if (p.startsWith('/api/task/') && req.method === 'PATCH') {
123
- const id = decodeURIComponent(p.split('/')[3] || ''); const patch = await body(req);
124
- const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
125
- store.updateTaskLine(root, file, id, patch); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
126
- }
127
- if (/^\/api\/task\/[^/]+\/comment$/.test(p) && req.method === 'POST') {
128
- const id = decodeURIComponent(p.split('/')[3] || ''); const { text, action } = await body(req);
129
- if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty comment.' }); return true; }
130
- const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
131
- store.addTaskComment(root, file, id, String(text).trim(), 'me');
132
- if (action === 'analyze') store.updateTaskLine(root, file, id, { status: 'to_analyze' });
133
- emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
134
- }
135
- if (p === '/api/workflow/toggle' && req.method === 'POST') {
136
- const { name } = await body(req); const wf = path.join(root, '.spectoflow', 'workflow.md');
137
- const lines = fs.readFileSync(wf, 'utf8').split('\n');
138
- // Must strip a trailing {cap:... skill:... policy} annotation (added in D29) BEFORE stripping
139
- // "(optional)" — same order as store.js's readWorkflow(), which is what the client's own step
140
- // names (and so `name` here) are actually derived from. Every step in the default workflow.md
141
- // template carries one of these annotations, so getting this order wrong breaks toggling
142
- // every single step, not just an edge case.
143
- const stepName = (rest) => {
144
- const ann = rest.match(/\{([^}]*)\}\s*$/);
145
- if (ann) rest = rest.slice(0, ann.index).trim();
146
- return rest.replace(/\s*\(optional\)\s*$/i, '').trim();
147
- };
148
- for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
149
- if (m && stepName(m[4]) === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4]; }
150
- fs.writeFileSync(wf, lines.join('\n')); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
151
- }
152
-
153
- if (p === '/api/run' && req.method === 'POST') {
154
- const { prompt, agent } = await body(req);
155
- if (!prompt || !String(prompt).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
156
- const r = startRun(root, { prompt, agent }, emit);
157
- if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
158
- sendJSON(res, 200, { runId: r.runId }); return true;
159
- }
160
-
161
- if (p === '/api/chat/summarize' && req.method === 'POST') {
162
- const { agent } = await body(req);
163
- const r = runSummarize(root, { agent }, emit);
164
- if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
165
- sendJSON(res, 200, { ok: true }); return true;
166
- }
167
- if (p === '/api/chat/clear' && req.method === 'POST') {
168
- const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
169
- emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
170
- }
171
-
172
- if (p === '/api/orchestrate' && req.method === 'POST') {
173
- const { request } = await body(req);
174
- if (!request || !String(request).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
175
- const active = store.readRuntime(root).orchestration;
176
- if (active && ['running', 'awaiting_approval'].includes(active.status))
177
- { sendJSON(res, 409, { error: 'An orchestration is already active.' }); return true; }
178
- const mode = store.readConfig(root).mode || 'semi';
179
- orchestrator.runOrchestration({ root, request: String(request).trim(), mode,
180
- runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
181
- .catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
182
- const o = store.readRuntime(root).orchestration;
183
- sendJSON(res, 200, { orchestrationId: o && o.id }); return true;
184
- }
185
- if (p === '/api/orchestrate/approve' && req.method === 'POST') {
186
- const { decision, note } = await body(req);
187
- const ok = orchestrator.submitDecision(decision, note);
188
- sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' }); return true;
189
- }
190
-
191
- if (p === '/api/settings' && req.method === 'POST') {
192
- const patch = await body(req);
193
- try { const cfg = writeConfig(patch); emit({ type: 'change' }); sendJSON(res, 200, { config: cfg }); }
194
- catch (e) { sendJSON(res, 400, { error: String(e && e.message || e) }); }
195
- return true;
196
- }
197
-
198
- if (p === '/api/attention' && req.method === 'POST') {
199
- const { text } = await body(req);
200
- if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty note.' }); return true; }
201
- const rt = store.readRuntime(root); rt.attention = rt.attention || [];
202
- const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
203
- rt.attention.unshift(item); store.writeRuntime(root, rt); emit({ type: 'change' });
204
- sendJSON(res, 200, { item }); return true;
205
- }
206
- if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
207
- const id = decodeURIComponent(p.split('/')[3] || '');
208
- const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
209
- if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
210
- const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
211
- store.writeRuntime(root, rt); emit({ type: 'change' });
212
- sendJSON(res, 200, { task: t }); return true;
213
- }
214
- if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
215
- const id = decodeURIComponent(p.split('/')[3] || '');
216
- const patch = await body(req);
217
- const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
218
- if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
219
- if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
220
- if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
221
- store.writeRuntime(root, rt); emit({ type: 'change' });
222
- sendJSON(res, 200, { item: it }); return true;
223
- }
224
- if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
225
- const id = decodeURIComponent(p.split('/')[3] || '');
226
- const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
227
- store.writeRuntime(root, rt); emit({ type: 'change' });
228
- sendJSON(res, 200, { ok: true }); return true;
229
- }
230
-
231
- return false;
232
- }
233
-
234
- function onBoot() {
235
- // A project that hasn't used Customize yet won't have this dir on disk, and `spectoflow init` on
236
- // an older install won't have created it either.
237
- try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboard', 'custom'), { recursive: true }); } catch (_) {}
238
- // A process restart loses any in-flight orchestration; without this, a stale 'running' or
239
- // 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real resume —
240
- // just clears the wedge so a fresh orchestration can start.
241
- try { orchestrator.reconcileOnBoot(root); } catch (_) {}
242
- }
243
-
244
- return {
245
- handleApi,
246
- watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboard/custom'],
247
- onBoot,
248
- };
249
- }
250
-
251
- module.exports = { createHandlers };
@@ -1,73 +0,0 @@
1
- 'use strict';
2
- /*
3
- * spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch), single project.
4
- * The actual /api/* route behavior lives in ./handlers.js — split out so the future multi-project hub
5
- * (lib/hub-server.js) can load a different project's handlers.js on demand (see
6
- * docs/multi-project-hub-design.md's "the server must split in two" addendum). This file remains the
7
- * direct single-project entry point (`node .spectoflow/dashboard/server.js`, today's `spectoflow
8
- * dashboard`) — its own external behavior is unchanged by the split.
9
- */
10
- const http = require('http');
11
- const fs = require('fs');
12
- const path = require('path');
13
- const { createHandlers } = require('./handlers');
14
-
15
- const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
16
- const PUBLIC = path.join(__dirname, 'public');
17
- const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
18
- const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon', '.woff2':'font/woff2', '.woff':'font/woff' };
19
- const clients = new Set();
20
- function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
21
- function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
22
-
23
- const handlers = createHandlers(ROOT);
24
-
25
- function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
26
- handlers.onBoot();
27
- handlers.watchDirs.forEach((d)=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
28
-
29
- const server = http.createServer(async (req,res)=>{
30
- const u=new URL(req.url,`http://localhost:${PORT}`); const p=u.pathname;
31
- try{
32
- if(p==='/api/events'){
33
- res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
34
- res.write('data: '+JSON.stringify({type:'hello'})+'\n\n');
35
- clients.add(res); req.on('close',()=>clients.delete(res)); return;
36
- }
37
-
38
- if (p.startsWith('/api/')) {
39
- const handled = await handlers.handleApi(req, res, u, emit);
40
- if (handled) return;
41
- }
42
-
43
- // ---- static files, with SPA fallback: a route like /backlog (no file extension)
44
- // that isn't a real asset serves index.html so client-side routing can take over ----
45
- let file=p==='/'?'/index.html':p;
46
- const full=path.join(PUBLIC,path.normalize(file).replace(/^(\.\.[/\\])+/,''));
47
- if(!full.startsWith(PUBLIC)){ res.writeHead(403); return res.end('Forbidden'); }
48
- // Local tool: always serve the freshest asset — never let the browser cache a stale app.js/css.
49
- const noCache = { 'Cache-Control': 'no-store, must-revalidate' };
50
- fs.readFile(full,(err,data)=>{
51
- if(err){
52
- if(req.method==='GET' && !path.extname(p) && !p.startsWith('/api/')){
53
- return fs.readFile(path.join(PUBLIC,'index.html'),(e2,d2)=>{
54
- if(e2){ res.writeHead(404); return res.end('Not found'); }
55
- res.writeHead(200,Object.assign({'Content-Type':MIME['.html']},noCache)); res.end(d2);
56
- });
57
- }
58
- res.writeHead(404); return res.end('Not found');
59
- }
60
- const ext=path.extname(full);
61
- // fonts are content-hashed by name and safe to cache long-term; everything else is no-store
62
- const headers = ext==='.woff2'||ext==='.woff' ? { 'Cache-Control':'public, max-age=604800' } : noCache;
63
- res.writeHead(200,Object.assign({'Content-Type':MIME[ext]||'application/octet-stream'},headers)); res.end(data);
64
- });
65
- }catch(e){ sendJSON(res,500,{error:String(e&&e.message||e)}); }
66
- });
67
- // pidfile so `spectoflow dashboard stop` can find and stop this server; cleared on exit.
68
- const LOCK = path.join(ROOT, '.spectoflow', '.dashboard.lock');
69
- function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
70
- function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
71
- process.on('exit', clearLock);
72
- ['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
73
- server.listen(PORT,()=>{ writeLock(); console.log(`spectoflow · dashboard → http://localhost:${PORT}`); console.log(`project root: ${ROOT}`); });
@@ -1,65 +0,0 @@
1
- 'use strict';
2
- /*
3
- * The dashboard's own view of "which coding agents exist and is one actually installed" — a small,
4
- * self-contained subset of this package's lib/adapters.js (the richer install-time registry with
5
- * memory-file content). Duplicated rather than shared: .spectoflow/ must be self-contained (ships
6
- * into every project), while lib/adapters.js does not ship there. test/agents-registry.test.js
7
- * guards the two id/bin/runner sets from drifting apart.
8
- */
9
- const fs = require('fs');
10
- const path = require('path');
11
-
12
- // headless:false = detectable and selectable as the active agent, but spectoflow never spawns it
13
- // itself (no confirmed non-interactive one-shot mode) — Run/Orchestrate/Summarize stay disabled for
14
- // it client-side; runner/summarize.js also refuse server-side (defense in depth). `docsUrl` is that
15
- // agent's own official CLI docs, surfaced verbatim in the dashboard's Documentation tab. See the
16
- // longer rationale above lib/adapters.js's REGISTRY, the richer install-time twin of this list —
17
- // including why DeepSeek Harness isn't here at all, and why some runner strings order their flags
18
- // the way they do (the trailing prompt must land right after whichever flag takes a value).
19
- const KNOWN_AGENTS = [
20
- { id: 'claude', label: 'Claude Code', bin: 'claude', dirs: ['.claude'], runner: 'claude -p --permission-mode acceptEdits', headless: true, docsUrl: 'https://code.claude.com/docs/en/cli-reference' },
21
- { id: 'codex', label: 'Codex', bin: 'codex', dirs: ['.codex'], runner: 'codex exec', headless: true, docsUrl: 'https://developers.openai.com/codex/cli/reference' },
22
- { id: 'cursor', label: 'Cursor', bin: 'cursor-agent', dirs: ['.cursor'], runner: 'cursor-agent -p', headless: true, docsUrl: 'https://cursor.com/docs/cli/overview' },
23
- { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', dirs: ['.gemini'], runner: 'gemini -p', headless: true, docsUrl: 'https://github.com/google-gemini/gemini-cli' },
24
- { id: 'opencode', label: 'OpenCode', bin: 'opencode', dirs: ['.opencode'], runner: 'opencode run --quiet', headless: true, docsUrl: 'https://opencode.ai/docs/cli/' },
25
- { id: 'kiro', label: 'Kiro CLI', bin: 'kiro-cli', dirs: ['.kiro'], runner: 'kiro-cli chat --no-interactive --trust-all-tools', headless: true, docsUrl: 'https://kiro.dev/docs/cli/headless/' },
26
- { id: 'antigravity', label: 'Antigravity', bin: 'agy', dirs: [], runner: 'agy -p', headless: true, docsUrl: 'https://antigravity.google/docs/cli/headless/' },
27
- { id: 'kimi', label: 'Kimi CLI', bin: 'kimi', dirs: [], runner: null, headless: false, docsUrl: 'https://github.com/MoonshotAI/kimi-cli' },
28
- { id: 'copilot', label: 'GitHub Copilot CLI', bin: 'copilot', dirs: [], runner: 'copilot -s --allow-all-tools -p', headless: true, docsUrl: 'https://docs.github.com/copilot/concepts/agents/about-copilot-cli' },
29
- { id: 'amazon-q', label: 'Amazon Q Developer CLI', bin: 'q', dirs: ['.amazonq'], runner: 'q chat --no-interactive --trust-all-tools', headless: true, docsUrl: 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-chat.html' },
30
- { id: 'droid', label: 'Factory Droid CLI', bin: 'droid', dirs: ['.factory'], runner: 'droid exec', headless: true, docsUrl: 'https://docs.factory.ai/droid-exec/overview' },
31
- { id: 'auggie', label: 'Auggie CLI', bin: 'auggie', dirs: ['.augment'], runner: 'auggie --quiet --print', headless: true, docsUrl: 'https://docs.augmentcode.com/cli/overview' },
32
- { id: 'goose', label: 'Goose CLI', bin: 'goose', dirs: ['.goose'], runner: 'goose run -t', headless: true, docsUrl: 'https://block.github.io/goose/' },
33
- ];
34
-
35
- // Is `bin` an executable resolvable on PATH? On win32, an extension from PATHEXT is required, so we
36
- // try each; we also try the bare name (covers test fixtures and extensionless shims).
37
- function binOnPath(bin, { env = process.env, platform = process.platform } = {}) {
38
- const raw = env.PATH || env.Path || '';
39
- const dirs = raw.split(path.delimiter).filter(Boolean);
40
- const exts =
41
- platform === 'win32' ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] : [''];
42
- for (const d of dirs) {
43
- for (const e of exts) {
44
- if (fs.existsSync(path.join(d, bin + e))) return true;
45
- }
46
- }
47
- return false;
48
- }
49
-
50
- // True if `id` looks genuinely installed: its bin resolves on PATH, or the project already has its
51
- // config dir (a project can be set up for an agent whose bin isn't on THIS machine's PATH, e.g. a
52
- // remote/CI runner). Unknown ids are never "installed".
53
- function isAgentInstalled(id, projectRoot, opts) {
54
- const a = KNOWN_AGENTS.find((x) => x.id === id);
55
- if (!a) return false;
56
- if (a.bin && binOnPath(a.bin, opts)) return true;
57
- return (a.dirs || []).some((d) => fs.existsSync(path.join(projectRoot, d)));
58
- }
59
-
60
- // ids of every known agent actually installed for this project, in KNOWN_AGENTS (priority) order.
61
- function installedAgents(projectRoot, opts) {
62
- return KNOWN_AGENTS.filter((a) => isAgentInstalled(a.id, projectRoot, opts)).map((a) => a.id);
63
- }
64
-
65
- module.exports = { KNOWN_AGENTS, binOnPath, isAgentInstalled, installedAgents };
File without changes
File without changes