spectoflow 0.22.4 → 0.23.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/lib/init.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ /*
3
+ * Scaffolds .spectoflow/ into a target folder — the logic behind `spectoflow init`, extracted from
4
+ * bin/spectoflow.js so server code (the hub's Add Project auto-init step, sub-project 4) can call it
5
+ * too, without any CLI argv/console.log coupling. bin/spectoflow.js's init() is now a thin wrapper:
6
+ * parse argv, call runInit(), print the result.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const detect = require('./detect');
11
+ const adapters = require('./adapters');
12
+ const ownership = require('./ownership');
13
+ const manifest = require('./manifest');
14
+ const mcp = require('./mcp');
15
+ const store = require('../templates/lib/store');
16
+
17
+ function copyDir(src, dst) {
18
+ fs.mkdirSync(dst, { recursive: true });
19
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
20
+ const s = path.join(src, e.name), d = path.join(dst, e.name);
21
+ if (e.isDirectory()) copyDir(s, d);
22
+ else if (!fs.existsSync(d)) fs.copyFileSync(s, d);
23
+ }
24
+ }
25
+
26
+ // Existing project: give id-less checkbox tasks a stable id, in place.
27
+ const ID_RE = /^[A-Za-z]{1,5}-?\d+[A-Za-z]?$/;
28
+ function normalizePlans(root, config) {
29
+ const dirName = store.resolvePlansDir(root, config || store.readConfig(root));
30
+ const dir = path.join(root, dirName);
31
+ if (!fs.existsSync(dir)) return 0;
32
+ let added = 0, seq = 1;
33
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
34
+ const fp = path.join(dir, f);
35
+ const lines = fs.readFileSync(fp, 'utf8').split('\n');
36
+ let touched = false;
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const m = lines[i].match(/^(\s*- \[[ xX]\]\s+)(\S+)(\s.*)?$/);
39
+ if (m && !ID_RE.test(m[2])) {
40
+ const id = 'T-' + String(seq++).padStart(3, '0');
41
+ lines[i] = `${m[1]}${id} ${m[2]}${m[3] || ''}`;
42
+ touched = true; added++;
43
+ } else if (m) { seq++; }
44
+ }
45
+ if (touched) fs.writeFileSync(fp, lines.join('\n'));
46
+ }
47
+ return added;
48
+ }
49
+
50
+ function runInit({ target, templatesDir, version, agentsArg }) {
51
+ fs.mkdirSync(target, { recursive: true });
52
+ const notes = [];
53
+
54
+ let agents, detected = [];
55
+ if (agentsArg) {
56
+ agents = agentsArg.split(',');
57
+ } else {
58
+ detected = detect.detectAgents(target);
59
+ agents = detected.length ? detected : ['claude', 'codex'];
60
+ notes.push(detected.length
61
+ ? `Detected agent(s): ${detected.join(', ')} — active: ${agents[0]}.`
62
+ : 'No agent CLI detected — defaulted to claude + codex.');
63
+ }
64
+
65
+ const claude = path.join(target, 'CLAUDE.md');
66
+ if (fs.existsSync(claude) && !fs.existsSync(claude + '.tomerge')) {
67
+ fs.renameSync(claude, claude + '.tomerge');
68
+ notes.push('Existing CLAUDE.md preserved as CLAUDE.md.tomerge — your agent merges it on first run.');
69
+ }
70
+
71
+ const spectoflowDir = path.join(target, '.spectoflow');
72
+ copyDir(templatesDir, spectoflowDir);
73
+
74
+ const frameworkFiles = ownership.listFrameworkFiles(templatesDir);
75
+ manifest.writeManifest(spectoflowDir, {
76
+ version,
77
+ files: manifest.hashFileMap(spectoflowDir, frameworkFiles),
78
+ });
79
+
80
+ const cfgPath = path.join(spectoflowDir, 'config.json');
81
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
82
+ cfg.agent = agents[0];
83
+ cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
84
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
85
+
86
+ const plansDirName = store.resolvePlansDir(target, cfg);
87
+ const specsDirName = store.resolveSpecsDir(target, cfg);
88
+ fs.mkdirSync(path.join(target, specsDirName), { recursive: true });
89
+ fs.mkdirSync(path.join(target, plansDirName), { recursive: true });
90
+ if (plansDirName !== 'plans') notes.push(`Using existing '${plansDirName}/' as the plans folder (set plansDir in config.json to override).`);
91
+ if (specsDirName !== 'specs') notes.push(`Using existing '${specsDirName}/' as the specs folder (set specsDir in config.json to override).`);
92
+
93
+ const added = normalizePlans(target, cfg);
94
+ if (added) notes.push(`Normalized ${added} existing task(s) with stable ids.`);
95
+
96
+ const written = adapters.generate(target, agents);
97
+
98
+ const mcpTargets = [path.join(target, '.mcp.json')];
99
+ if (agents.includes('cursor')) mcpTargets.push(path.join(target, '.cursor', 'mcp.json'));
100
+ for (const fp of mcpTargets) {
101
+ const rel = path.relative(target, fp).split(path.sep).join('/');
102
+ const r = mcp.mergeMcpServer(fp, 'playwright', mcp.PLAYWRIGHT_MCP);
103
+ if (r === 'created' || r === 'added') notes.push(`Wired Playwright MCP into ${rel} (npx @playwright/mcp — for the E2E agent; commit it to share).`);
104
+ else if (r === 'skipped') notes.push(`Left ${rel} as-is (couldn't parse it) — add a 'playwright' MCP server yourself for browser-driven E2E.`);
105
+ }
106
+
107
+ const gi = path.join(target, '.gitignore');
108
+ const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
109
+ for (const line of ['.spectoflow/runtime.json', '.spectoflow/.dashboard.lock']) {
110
+ if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
111
+ }
112
+
113
+ return { target, agents, detected, written, notes };
114
+ }
115
+
116
+ module.exports = { runInit };
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+ /*
3
+ * The project registry — ~/.spectoflow/projects.json. Tracks every project spectoflow has seen (via
4
+ * `spectoflow dashboard`, wired in a later sub-project), so the multi-project hub knows what to list
5
+ * and switch between. This module owns only the registry file itself; it has no opinion about
6
+ * dashboards, ports, or servers, and nothing else in the codebase calls it yet.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const crypto = require('crypto');
12
+
13
+ const REGISTRY_FILE = 'projects.json';
14
+
15
+ // Resolution order: an explicit baseDir (unit tests) > SPECTOFLOW_HOME (CLI-level test isolation,
16
+ // same convention as SPECTOFLOW_ROOT/SPECTOFLOW_PORT elsewhere in this codebase) > the real home dir.
17
+ function registryDir(baseDir) {
18
+ return baseDir || process.env.SPECTOFLOW_HOME || path.join(os.homedir(), '.spectoflow');
19
+ }
20
+ function registryPath(baseDir) {
21
+ return path.join(registryDir(baseDir), REGISTRY_FILE);
22
+ }
23
+ function hubLockPath(baseDir) {
24
+ return path.join(registryDir(baseDir), 'hub.lock');
25
+ }
26
+
27
+ function readRegistry(baseDir) {
28
+ try { return JSON.parse(fs.readFileSync(registryPath(baseDir), 'utf8')); }
29
+ catch { return { projects: [] }; }
30
+ }
31
+
32
+ function writeRegistry(baseDir, data) {
33
+ const dir = registryDir(baseDir);
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ fs.writeFileSync(registryPath(baseDir), JSON.stringify(data, null, 2) + '\n');
36
+ }
37
+
38
+ // 6 hex chars; regenerated on the rare collision against ids already in the registry. `randomFn` is
39
+ // injectable (defaults to crypto.randomBytes) so collision handling is testable without depending on
40
+ // genuine randomness to ever actually collide.
41
+ function genId(existingIds, randomFn) {
42
+ const rand = randomFn || ((n) => crypto.randomBytes(n));
43
+ let id;
44
+ do { id = rand(3).toString('hex'); } while (existingIds.includes(id));
45
+ return id;
46
+ }
47
+
48
+ function findByPath(projectPath, baseDir) {
49
+ const target = path.resolve(projectPath);
50
+ return readRegistry(baseDir).projects.find((p) => path.resolve(p.path) === target) || null;
51
+ }
52
+
53
+ // Registers `projectPath` if it isn't already known (matched by normalized path); either way stamps
54
+ // lastOpened to now and returns the entry. Never duplicates the same folder under a second id.
55
+ function addProject(projectPath, baseDir) {
56
+ const reg = readRegistry(baseDir);
57
+ const target = path.resolve(projectPath);
58
+ let entry = reg.projects.find((p) => path.resolve(p.path) === target);
59
+ if (!entry) {
60
+ entry = {
61
+ id: genId(reg.projects.map((p) => p.id)),
62
+ path: target,
63
+ name: path.basename(target),
64
+ lastOpened: new Date().toISOString(),
65
+ };
66
+ reg.projects.push(entry);
67
+ } else {
68
+ entry.lastOpened = new Date().toISOString();
69
+ }
70
+ writeRegistry(baseDir, reg);
71
+ return entry;
72
+ }
73
+
74
+ function removeProject(id, baseDir) {
75
+ const reg = readRegistry(baseDir);
76
+ const before = reg.projects.length;
77
+ reg.projects = reg.projects.filter((p) => p.id !== id);
78
+ writeRegistry(baseDir, reg);
79
+ return reg.projects.length < before;
80
+ }
81
+
82
+ function touchProject(id, baseDir) {
83
+ const reg = readRegistry(baseDir);
84
+ const entry = reg.projects.find((p) => p.id === id);
85
+ if (!entry) return false;
86
+ entry.lastOpened = new Date().toISOString();
87
+ writeRegistry(baseDir, reg);
88
+ return true;
89
+ }
90
+
91
+ // Newest-first — the natural "what did I touch most recently" order for both `spectoflow projects
92
+ // list` and (in a later sub-project) the hub landing page.
93
+ function listProjects(baseDir) {
94
+ return readRegistry(baseDir).projects.slice()
95
+ .sort((a, b) => (b.lastOpened || '').localeCompare(a.lastOpened || ''));
96
+ }
97
+
98
+ module.exports = {
99
+ readRegistry, writeRegistry, genId, addProject, removeProject, touchProject,
100
+ findByPath, listProjects, registryPath, hubLockPath,
101
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -0,0 +1,241 @@
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
+ for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
139
+ if (m && m[4].replace(/\s*\(optional\)\s*$/i, '').trim() === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4]; }
140
+ fs.writeFileSync(wf, lines.join('\n')); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
141
+ }
142
+
143
+ if (p === '/api/run' && req.method === 'POST') {
144
+ const { prompt, agent } = await body(req);
145
+ if (!prompt || !String(prompt).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
146
+ const r = startRun(root, { prompt, agent }, emit);
147
+ if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
148
+ sendJSON(res, 200, { runId: r.runId }); return true;
149
+ }
150
+
151
+ if (p === '/api/chat/summarize' && req.method === 'POST') {
152
+ const { agent } = await body(req);
153
+ const r = runSummarize(root, { agent }, emit);
154
+ if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
155
+ sendJSON(res, 200, { ok: true }); return true;
156
+ }
157
+ if (p === '/api/chat/clear' && req.method === 'POST') {
158
+ const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
159
+ emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
160
+ }
161
+
162
+ if (p === '/api/orchestrate' && req.method === 'POST') {
163
+ const { request } = await body(req);
164
+ if (!request || !String(request).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
165
+ const active = store.readRuntime(root).orchestration;
166
+ if (active && ['running', 'awaiting_approval'].includes(active.status))
167
+ { sendJSON(res, 409, { error: 'An orchestration is already active.' }); return true; }
168
+ const mode = store.readConfig(root).mode || 'semi';
169
+ orchestrator.runOrchestration({ root, request: String(request).trim(), mode,
170
+ runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
171
+ .catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
172
+ const o = store.readRuntime(root).orchestration;
173
+ sendJSON(res, 200, { orchestrationId: o && o.id }); return true;
174
+ }
175
+ if (p === '/api/orchestrate/approve' && req.method === 'POST') {
176
+ const { decision, note } = await body(req);
177
+ const ok = orchestrator.submitDecision(decision, note);
178
+ sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' }); return true;
179
+ }
180
+
181
+ if (p === '/api/settings' && req.method === 'POST') {
182
+ const patch = await body(req);
183
+ try { const cfg = writeConfig(patch); emit({ type: 'change' }); sendJSON(res, 200, { config: cfg }); }
184
+ catch (e) { sendJSON(res, 400, { error: String(e && e.message || e) }); }
185
+ return true;
186
+ }
187
+
188
+ if (p === '/api/attention' && req.method === 'POST') {
189
+ const { text } = await body(req);
190
+ if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty note.' }); return true; }
191
+ const rt = store.readRuntime(root); rt.attention = rt.attention || [];
192
+ const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
193
+ rt.attention.unshift(item); store.writeRuntime(root, rt); emit({ type: 'change' });
194
+ sendJSON(res, 200, { item }); return true;
195
+ }
196
+ if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
197
+ const id = decodeURIComponent(p.split('/')[3] || '');
198
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
199
+ if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
200
+ const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
201
+ store.writeRuntime(root, rt); emit({ type: 'change' });
202
+ sendJSON(res, 200, { task: t }); return true;
203
+ }
204
+ if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
205
+ const id = decodeURIComponent(p.split('/')[3] || '');
206
+ const patch = await body(req);
207
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
208
+ if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
209
+ if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
210
+ if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
211
+ store.writeRuntime(root, rt); emit({ type: 'change' });
212
+ sendJSON(res, 200, { item: it }); return true;
213
+ }
214
+ if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
215
+ const id = decodeURIComponent(p.split('/')[3] || '');
216
+ const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
217
+ store.writeRuntime(root, rt); emit({ type: 'change' });
218
+ sendJSON(res, 200, { ok: true }); return true;
219
+ }
220
+
221
+ return false;
222
+ }
223
+
224
+ function onBoot() {
225
+ // A project that hasn't used Customize yet won't have this dir on disk, and `spectoflow init` on
226
+ // an older install won't have created it either.
227
+ try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboard', 'custom'), { recursive: true }); } catch (_) {}
228
+ // A process restart loses any in-flight orchestration; without this, a stale 'running' or
229
+ // 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real resume —
230
+ // just clears the wedge so a fresh orchestration can start.
231
+ try { orchestrator.reconcileOnBoot(root); } catch (_) {}
232
+ }
233
+
234
+ return {
235
+ handleApi,
236
+ watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboard/custom'],
237
+ onBoot,
238
+ };
239
+ }
240
+
241
+ module.exports = { createHandlers };