spectoflow 0.24.0 → 0.27.1

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 (53) hide show
  1. package/README.md +28 -6
  2. package/bin/spectoflow.js +88 -6
  3. package/lib/dashboard/connector.js +193 -0
  4. package/lib/dashboard/handlers.js +2 -27
  5. package/lib/dashboard/hub-server.js +103 -11
  6. package/lib/dashboard/inject-design.js +41 -0
  7. package/lib/dashboard/meeting.js +116 -0
  8. package/lib/dashboard/ops.js +83 -2
  9. package/lib/dashboard/public/app.js +738 -118
  10. package/lib/dashboard/public/charts.js +7 -4
  11. package/lib/dashboard/public/commands.js +77 -0
  12. package/lib/dashboard/public/designs/console.css +7 -19
  13. package/lib/dashboard/public/designs/orbit.css +3 -4
  14. package/lib/dashboard/public/designs.js +2 -2
  15. package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
  16. package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
  17. package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
  18. package/lib/dashboard/public/hub.html +1 -1
  19. package/lib/dashboard/public/hub.js +27 -4
  20. package/lib/dashboard/public/i18n.js +60 -18
  21. package/lib/dashboard/public/icons.js +2 -0
  22. package/lib/dashboard/public/index.html +106 -13
  23. package/lib/dashboard/public/styles.css +161 -26
  24. package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
  25. package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
  26. package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
  27. package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
  28. package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
  29. package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
  30. package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
  31. package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
  32. package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
  33. package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
  34. package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
  35. package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
  36. package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
  37. package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
  38. package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
  39. package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
  40. package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
  41. package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
  42. package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
  43. package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
  44. package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
  45. package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
  46. package/lib/dashboard/routes.js +38 -0
  47. package/lib/dashboard/runner.js +7 -2
  48. package/lib/workspace.js +36 -1
  49. package/package.json +3 -3
  50. package/templates/config.json +23 -0
  51. package/lib/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  52. package/lib/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  53. package/lib/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
@@ -25,6 +25,9 @@ const registry = require('../registry');
25
25
  const workspace = require('../workspace');
26
26
  const { createHandlers } = require('./handlers');
27
27
  const store = require('../store');
28
+ const { createConnector } = require('./connector');
29
+ const { ops, OpError } = require('./ops');
30
+ const { injectDesign, DEFAULT_DESIGN } = require('./inject-design');
28
31
 
29
32
  const migrated = workspace.migrateLegacyHome();
30
33
  if (!workspace.exists()) workspace.init({});
@@ -40,18 +43,79 @@ function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c)
40
43
  // handlers.js for every project (D64): nothing is ever require()'d from a project, so a project that
41
44
  // has never run `spectoflow update` opens exactly like a fresh one.
42
45
  const projects = new Map();
46
+
47
+ // ---- online dashboard (C1): the connector role ----
48
+ // One outbound connection to the online dashboard named in the workspace's remote.json (written by
49
+ // `spectoflow dashboard login`). Only published projects (meta.json → published:true, cached here as
50
+ // a Set so a chatty run-line stream never re-reads the file) are announced and relayed. Every project's
51
+ // emit is teed: SSE clients first, then the connector — the same emit an op called from the relay
52
+ // uses, so a local click and an online click hit one orchestrator, one pending gate, one state.
53
+ let connector = null;
54
+ let publishedIds = new Set();
55
+ function refreshPublished() { publishedIds = new Set(workspace.listPublished().map((p) => p.id)); }
56
+ const snapshotTimers = new Map();
57
+ function scheduleSnapshot(id) {
58
+ clearTimeout(snapshotTimers.get(id));
59
+ snapshotTimers.set(id, setTimeout(async () => {
60
+ snapshotTimers.delete(id);
61
+ if (!connector || !publishedIds.has(id)) return;
62
+ try { const project = await readSnapshot(id); if (project) connector.pushSnapshot(id, project); } catch (_) {}
63
+ }, 500));
64
+ }
65
+ async function readSnapshot(id) {
66
+ const proj = getProject(id);
67
+ return proj ? ops['project.read'](proj.root, {}, { emit() {} }) : null;
68
+ }
69
+ function listPublishedForHello() {
70
+ return workspace.listPublished().map((p) => ({ localId: p.id, name: p.name, kind: p.kind || 'spectoflow', stats: projectStats(p.path) }));
71
+ }
72
+ async function execOp(localId, op, args) {
73
+ if (!publishedIds.has(localId)) throw new OpError(403, 'This project is not published.');
74
+ const proj = getProject(localId);
75
+ if (!proj) throw new OpError(404, projectErrorMessage(localId));
76
+ if (!Object.prototype.hasOwnProperty.call(ops, op)) throw new OpError(404, `Unknown operation "${op}".`);
77
+ return ops[op](proj.root, args || {}, { emit: proj.emit });
78
+ }
79
+ function startConnector() {
80
+ if (connector) { connector.stop(); connector = null; }
81
+ refreshPublished();
82
+ const remote = workspace.readRemote();
83
+ if (!remote) return;
84
+ connector = createConnector({
85
+ url: remote.url, token: remote.token, machineName: remote.machineName || os.hostname(), transport: remote.transport, version: VERSION,
86
+ listPublished: listPublishedForHello, readSnapshot, execOp, log: (m) => console.log(`spectoflow · ${m}`),
87
+ });
88
+ connector.start();
89
+ }
90
+ function remoteStatus() {
91
+ const remote = workspace.readRemote();
92
+ if (!remote) return { configured: false, url: null, machineName: null, connected: false, transport: null, lastError: null, since: null };
93
+ const s = connector ? connector.status() : { connected: false, transport: remote.transport, lastError: null, since: null };
94
+ return { configured: true, url: remote.url, machineName: remote.machineName, connected: s.connected, transport: s.transport, lastError: s.lastError, since: s.since };
95
+ }
96
+
43
97
  function getProject(id) {
44
98
  if (projects.has(id)) return projects.get(id);
45
99
  const entry = registry.listProjects().find((p) => p.id === id);
46
100
  if (!entry || !fs.existsSync(entry.path)) return null;
47
101
  const handlers = createHandlers(entry.path);
48
102
  const clients = new Set();
49
- const emit = (obj) => { const line = 'data: ' + JSON.stringify(obj) + '\n\n'; for (const res of clients) res.write(line); };
103
+ const emit = (obj) => {
104
+ const line = 'data: ' + JSON.stringify(obj) + '\n\n';
105
+ for (const res of clients) res.write(line);
106
+ if (connector && publishedIds.has(id)) { connector.pushEvent(id, obj); if (obj.type === 'change') scheduleSnapshot(id); }
107
+ };
50
108
  handlers.onBoot();
51
109
  const watchers = [];
110
+ // A single logical write (e.g. store.js's write-then-rename) can fire an fs.watch directory
111
+ // listener several times in a row for one underlying change (worst on Windows, which also reports
112
+ // sibling-entry metadata churn) — coalesce any burst within 150ms into exactly one 'change' emit,
113
+ // so a published project's connector feed gets one 'event' frame per real change, not several.
114
+ let changeTimer = null;
115
+ const onDirChange = () => { clearTimeout(changeTimer); changeTimer = setTimeout(() => emit({ type: 'change' }), 150); };
52
116
  handlers.watchDirs.forEach((d) => {
53
117
  const dir = path.join(entry.path, d);
54
- if (fs.existsSync(dir)) { try { watchers.push(fs.watch(dir, { recursive: false }, () => emit({ type: 'change' }))); } catch (_) {} }
118
+ if (fs.existsSync(dir)) { try { watchers.push(fs.watch(dir, { recursive: false }, onDirChange)); } catch (_) {} }
55
119
  });
56
120
  const proj = { id, root: entry.path, handlers, clients, emit, watchers };
57
121
  projects.set(id, proj);
@@ -87,7 +151,7 @@ function projectStats(root) {
87
151
  } catch { return null; }
88
152
  }
89
153
  function listHubProjects() {
90
- return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path) }));
154
+ return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path), published: publishedIds.has(p.id) }));
91
155
  }
92
156
  function listRoots() {
93
157
  const home = os.homedir();
@@ -129,7 +193,7 @@ function addHubProject(rawPath) {
129
193
  }
130
194
  async function handleHubApi(req, res, u) {
131
195
  const p = u.pathname;
132
- if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects() }); return true; }
196
+ if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects(), remote: remoteStatus() }); return true; }
133
197
  if (p === '/api/hub/projects' && req.method === 'POST') {
134
198
  const { path: rawPath } = await body(req);
135
199
  if (!rawPath || !String(rawPath).trim()) { sendJSON(res, 400, { error: 'A folder path is required.' }); return true; }
@@ -140,6 +204,7 @@ async function handleHubApi(req, res, u) {
140
204
  if (/^\/api\/hub\/projects\/[^/]+$/.test(p) && req.method === 'DELETE') {
141
205
  const id = decodeURIComponent(p.split('/')[4] || '');
142
206
  const ok = registry.removeProject(id);
207
+ if (ok) { refreshPublished(); if (connector) connector.announce(); }
143
208
  sendJSON(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'No project registered with that id.' });
144
209
  return true;
145
210
  }
@@ -155,13 +220,38 @@ async function handleHubApi(req, res, u) {
155
220
  sendJSON(res, 200, { ok: true, reloaded });
156
221
  return true;
157
222
  }
223
+ if (p === '/api/hub/remote' && req.method === 'GET') { sendJSON(res, 200, remoteStatus()); return true; }
224
+ if (p === '/api/hub/remote/reconnect' && req.method === 'POST') { startConnector(); sendJSON(res, 200, { ok: true, ...remoteStatus() }); return true; }
225
+ if (/^\/api\/hub\/projects\/[^/]+\/publish$/.test(p) && req.method === 'POST') {
226
+ const id = decodeURIComponent(p.split('/')[4] || '');
227
+ const { published } = await body(req);
228
+ const meta = workspace.setPublished(id, published !== false);
229
+ if (!meta) { sendJSON(res, 404, { error: 'No project registered with that id.' }); return true; }
230
+ refreshPublished();
231
+ if (connector) connector.announce();
232
+ sendJSON(res, 200, { ok: true, id, published: meta.published });
233
+ return true;
234
+ }
158
235
  return false;
159
236
  }
160
237
 
238
+ // A viewer's own localStorage choice (once one exists) always wins client-side (app.js's IIFE at the
239
+ // top of the file, and the reconciliation in renderSettings()) — this is only filling the gap for a
240
+ // visitor with nothing saved yet, so the very first paint already matches the project's own
241
+ // configured design instead of flashing whatever static default shipped in the HTML file.
242
+ function projectDesign(root) {
243
+ if (!root) return DEFAULT_DESIGN;
244
+ try { return (store.readConfig(root) || {}).design || DEFAULT_DESIGN; }
245
+ catch { return DEFAULT_DESIGN; }
246
+ }
247
+
161
248
  // Serves one static asset (or the SPA index.html fallback for an extensionless path) from the
162
249
  // shared, globally-installed PUBLIC dir — factored into a function since both the root-level and
163
- // /p/<id>/-prefixed requests need it.
164
- function serveStatic(reqPath, req, res) {
250
+ // /p/<id>/-prefixed requests need it. `root` (the specific project's folder), when known, is used to
251
+ // stamp the served HTML's <html data-design="..."> synchronously with that project's own
252
+ // config.design, before the file ever reaches the browser — avoiding a flash of the wrong theme on a
253
+ // viewer's very first-ever page load (no localStorage value saved yet to reconcile against).
254
+ function serveStatic(reqPath, req, res, root) {
165
255
  const file = reqPath === '/' ? '/index.html' : reqPath;
166
256
  const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
167
257
  if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('Forbidden'); }
@@ -171,14 +261,16 @@ function serveStatic(reqPath, req, res) {
171
261
  if (req.method === 'GET' && !path.extname(reqPath)) {
172
262
  return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
173
263
  if (e2) { res.writeHead(404); return res.end('Not found'); }
174
- res.writeHead(200, Object.assign({ 'Content-Type': MIME['.html'] }, noCache)); res.end(d2);
264
+ const html = root ? injectDesign(d2.toString('utf8'), projectDesign(root)) : d2;
265
+ res.writeHead(200, Object.assign({ 'Content-Type': MIME['.html'] }, noCache)); res.end(html);
175
266
  });
176
267
  }
177
268
  res.writeHead(404); return res.end('Not found');
178
269
  }
179
270
  const ext = path.extname(full);
180
271
  const headers = ext === '.woff2' || ext === '.woff' ? { 'Cache-Control': 'public, max-age=604800' } : noCache;
181
- res.writeHead(200, Object.assign({ 'Content-Type': MIME[ext] || 'application/octet-stream' }, headers)); res.end(data);
272
+ const payload = (root && ext === '.html') ? injectDesign(data.toString('utf8'), projectDesign(root)) : data;
273
+ res.writeHead(200, Object.assign({ 'Content-Type': MIME[ext] || 'application/octet-stream' }, headers)); res.end(payload);
182
274
  });
183
275
  }
184
276
 
@@ -188,7 +280,7 @@ const LOCK = workspace.lockPath();
188
280
  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{} }
189
281
  function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
190
282
  process.on('exit', clearLock);
191
- ['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
283
+ ['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ if (connector) connector.stop(); clearLock(); process.exit(0); }));
192
284
 
193
285
  const server = http.createServer(async (req, res) => {
194
286
  const u = new URL(req.url, `http://localhost:${PORT}`);
@@ -226,7 +318,7 @@ const server = http.createServer(async (req, res) => {
226
318
  const proj = getProject(id);
227
319
  if (!proj) { res.writeHead(404); return res.end(projectErrorMessage(id)); }
228
320
  registry.touchProject(id);
229
- return serveStatic(m[2] || '/', req, res);
321
+ return serveStatic(m[2] || '/', req, res, proj.root);
230
322
  }
231
323
 
232
324
  if (p === '/') {
@@ -246,4 +338,4 @@ const server = http.createServer(async (req, res) => {
246
338
  } catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
247
339
  });
248
340
 
249
- server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); });
341
+ server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); startConnector(); });
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ /*
3
+ * Shared by both dashboard surfaces — the local hub (hub-server.js) and the online relay
4
+ * (server/src/app.js) — so a project's own configured design (config.design) is stamped onto the
5
+ * served index.html's <html data-design="..."> attribute BEFORE the file ever reaches the browser.
6
+ * Design is a shared, project-level setting (not a per-viewer choice, Sous-projet B) — this is the
7
+ * ONLY place the design attribute is ever set for first paint; app.js has no localStorage fallback
8
+ * or reconciliation for it any more, it only reflects P.config.design once the client boots.
9
+ */
10
+
11
+ // Mirrors app.js's currentDesign() own default ('console') when the attribute is absent — same
12
+ // fallback a project with no design saved yet (or an unresolvable project) would land on anyway.
13
+ const DEFAULT_DESIGN = 'console';
14
+
15
+ // The real, live registry of selectable designs (lib/dashboard/public/designs.js) — required, not
16
+ // re-declared here, so an id added/removed there is instantly reflected here with zero drift risk.
17
+ const DESIGNS = require('./public/designs.js');
18
+ const KNOWN_IDS = new Set(DESIGNS.map((d) => d.id));
19
+
20
+ // Only ever accept a value that is one of the real, currently-registered design ids — never trust an
21
+ // arbitrary stored/cached string into the HTML (that would be an injection risk), and never trust a
22
+ // shape-valid but unregistered id either (a stale/typo'd/removed design would silently produce an
23
+ // unstyled page instead of falling back to the same default a missing value gets).
24
+ function sanitizeDesign(d) {
25
+ return (typeof d === 'string' && KNOWN_IDS.has(d)) ? d : DEFAULT_DESIGN;
26
+ }
27
+
28
+ // Rewrites (or adds) the <html> tag's data-design attribute to the given value — leaves everything
29
+ // else in the file untouched, including data-theme (config.theme is applied client-side once app.js
30
+ // boots, same as before — this injector was only ever built to solve design's first-paint flash).
31
+ function injectDesign(html, design) {
32
+ const safe = sanitizeDesign(design);
33
+ return html.replace(/<html([^>]*)>/i, (full, attrs) => {
34
+ const newAttrs = /\bdata-design=/.test(attrs)
35
+ ? attrs.replace(/data-design="[^"]*"/, `data-design="${safe}"`)
36
+ : `${attrs} data-design="${safe}"`;
37
+ return `<html${newAttrs}>`;
38
+ });
39
+ }
40
+
41
+ module.exports = { injectDesign, sanitizeDesign, DEFAULT_DESIGN };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ /*
3
+ * Daily meeting — generates a dated standup-style note (.spectoflow/meetings/<date>.md) via the
4
+ * configured agent. Structurally parallel to summarize.js's runSummarize (read that first — this
5
+ * file mirrors its resolveRunnerCommand/spawn/emit shape exactly) but differs in two ways: what it
6
+ * feeds the agent (a digest of recent task activity + the chat log, not just the chat log alone) and
7
+ * where the result lands (a dated file under .spectoflow/meetings/, via files.js, never
8
+ * runtime.messages — a meeting note is a project artifact, not a chat message).
9
+ */
10
+ const { spawn } = require('child_process');
11
+ const store = require('../store');
12
+ const files = require('./files');
13
+ const { resolveRunnerCommand } = require('./runner');
14
+ const { formatLog } = require('./summarize'); // reused as-is rather than reimplemented — see report
15
+
16
+ const TASK_LIMIT = 20; // mirrors summarize.js's own DEFAULT_LIMIT philosophy (cap, most-recent-last)
17
+ const LOG_LIMIT = 40; // same DEFAULT_LIMIT summarize.js uses for the chat log
18
+
19
+ // YYYY-MM-DD in the machine running the dashboard's OWN local time zone. This is a deliberate,
20
+ // explicit choice (see the project report for the full reasoning): a meeting note's filename must be
21
+ // unambiguous, and this is a single-writer local tool — a viewer's own browser time zone must never
22
+ // decide which file "today" resolves to (it could differ from the server's, or from another viewer's
23
+ // browser, producing two different "today"s for the same project). The client reads this same value
24
+ // back from GET /api/project (ops.js's 'project.read' → p.todayDate), so both halves of this feature
25
+ // (manual edit and agent-generate) agree on exactly one "today".
26
+ function todayLocal() {
27
+ const d = new Date();
28
+ const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), day = String(d.getDate()).padStart(2, '0');
29
+ return `${y}-${m}-${day}`;
30
+ }
31
+
32
+ // "id: title (status)" lines for tasks that are done or in_progress — a cheap, already-available
33
+ // proxy for "what was built / is in flight" (no new aggregation invented), most-recent-last order
34
+ // (readPlans' own file/task order), capped to TASK_LIMIT.
35
+ function formatTasks(plans, limit = TASK_LIMIT) {
36
+ const tasks = [];
37
+ for (const pl of plans || []) {
38
+ for (const ph of pl.phases || []) {
39
+ for (const t of ph.tasks || []) {
40
+ if (t.status === 'done' || t.status === 'in_progress') tasks.push(t);
41
+ }
42
+ }
43
+ }
44
+ return tasks.slice(-limit).map((t) => `${t.id}: ${t.title} (${t.status})`).join('\n');
45
+ }
46
+
47
+ // Strict YYYY-MM-DD only. This is the security boundary for the meetings feature: meetingPath()
48
+ // concatenates `date` straight into a file path, and files.writeFile()'s own safePath() guard only
49
+ // rejects a path that resolves OUTSIDE the project root entirely — it does NOT confine a write to
50
+ // .spectoflow/meetings/, so an unvalidated date like "../../important" would happily write to
51
+ // <root>/important.md. Every caller of meetingPath()/runMeetingGenerate() with a date that did not
52
+ // come from todayLocal() MUST go through this check first (see runMeetingGenerate below).
53
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
54
+ function isValidDate(date) {
55
+ return DATE_RE.test(date);
56
+ }
57
+
58
+ function meetingPath(date) {
59
+ return `.spectoflow/meetings/${date}.md`;
60
+ }
61
+
62
+ // Same "reply with the text only" instruction line runSummarize already uses (already well-tuned),
63
+ // adapted for a daily-meeting-note framing instead of a chat-log-digest framing.
64
+ function buildPrompt(plans, messages) {
65
+ const taskDigest = formatTasks(plans) || '(no done / in-progress tasks yet)';
66
+ const logDigest = formatLog(messages, LOG_LIMIT) || '(no recent chat activity)';
67
+ return 'Write a short daily meeting note for this project, standup-style: what was done, '
68
+ + 'what\'s in progress or blocked, and suggested next steps — 3-6 concise bullet points or short '
69
+ + 'paragraphs. Reply with the note text only: no preamble, no sentinel lines, and do not read, '
70
+ + 'search or edit any files — answer using only the digest below.'
71
+ + '\n\nRecent tasks:\n' + taskDigest
72
+ + '\n\nRecent chat log:\n' + logDigest;
73
+ }
74
+
75
+ // Generates a daily meeting note and WRITES it to .spectoflow/meetings/<date>.md (default: today —
76
+ // see todayLocal()), OVERWRITING whatever is already there. Overwrite-SAFETY (not silently
77
+ // destroying a real existing note) is the CLIENT's responsibility — see app.js's meeting-generate
78
+ // confirm flow — this function trusts its caller the same way runSummarize trusts chat.summarize's
79
+ // caller. Returns { child } on success or { error }, mirroring runSummarize's own shape exactly.
80
+ function runMeetingGenerate(root, { agent, date } = {}, emit) {
81
+ const cfg = store.readConfig(root);
82
+ const which = agent || cfg.agent || 'claude';
83
+ const cmdStr = resolveRunnerCommand(root, cfg, which);
84
+ if (!cmdStr) return { error: `No runner configured for "${which}".` };
85
+
86
+ const day = date || todayLocal();
87
+ if (!isValidDate(day)) return { error: 'Invalid date.' };
88
+ const plans = store.readPlans(root);
89
+ const messages = store.readRuntime(root).messages || [];
90
+ const prompt = buildPrompt(plans, messages);
91
+
92
+ const parts = cmdStr.split(/\s+/).filter(Boolean);
93
+ const runId = 'meeting-' + Date.now().toString(36);
94
+ // Emitted before the spawn attempt (same order runner.js/summarize.js use) so the client's "agent
95
+ // running" indicator lights up immediately, and a spawn failure below still gets a matching run-end.
96
+ if (emit) emit({ type: 'run-start', run: { id: runId } });
97
+ let child;
98
+ // windowsHide: without it, spawning a .cmd-shimmed CLI on Windows pops up a real console window.
99
+ try { child = spawn(parts[0], [...parts.slice(1), prompt], { cwd: root, env: process.env, windowsHide: true }); }
100
+ catch (e) { if (emit) emit({ type: 'run-end', runId, code: 1 }); return { error: e.message }; }
101
+ try { child.stdin && child.stdin.end(); } catch {}
102
+
103
+ let out = '';
104
+ child.stdout && child.stdout.on('data', (d) => { out += d.toString(); });
105
+ child.stderr && child.stderr.on('data', (d) => { out += d.toString(); });
106
+ child.on('close', (code) => {
107
+ const text = out.trim() || (code === 0 ? '(no output)' : `meeting generate failed (exit ${code})`);
108
+ files.writeFile(root, meetingPath(day), text);
109
+ // run-end before change, same ordering summarize.js uses — the client must never see "running"
110
+ // linger past the point where the new content is actually ready to be re-fetched.
111
+ if (emit) { emit({ type: 'run-end', runId, code }); emit({ type: 'change' }); }
112
+ });
113
+ return { child };
114
+ }
115
+
116
+ module.exports = { runMeetingGenerate, formatTasks, todayLocal, meetingPath, buildPrompt, isValidDate };
@@ -13,6 +13,7 @@ const store = require('../store');
13
13
  const files = require('./files');
14
14
  const { startRun } = require('./runner');
15
15
  const { runSummarize } = require('./summarize');
16
+ const { runMeetingGenerate, todayLocal } = require('./meeting');
16
17
  const orchestrator = require('./orchestrator');
17
18
  const adapters = require('../adapters');
18
19
  const detect = require('../detect');
@@ -46,12 +47,83 @@ function readAgentFile(root, rel) {
46
47
  if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory()) bad('not an agent/skill file');
47
48
  return { content: fs.readFileSync(real, 'utf8') };
48
49
  }
50
+ // The 6 Kanban column ids, mirroring app.js's STATUS keys exactly (order doesn't matter here — the
51
+ // client always re-derives display order from its own STATUS object). Kept as the one server-side
52
+ // source of truth for "what's a real column id" — never trust the client's array blindly.
53
+ const KANBAN_STATUSES = ['todo', 'in_progress', 'to_validate', 'to_analyze', 'done', 'blocked'];
54
+ // The 13 native nav tab ids (Sous-projet C: Task 1 built the mechanism with the original 11; Task 2
55
+ // registered 'notes'; Task 3 registers 'meeting' — the Daily meeting tab — into it), mirroring
56
+ // app.js's ROUTES array exactly — the one server-side source of truth for "what's a real native tab
57
+ // id".
58
+ const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'personalize'];
49
59
  function writeConfig(root, patch, detectOpts) {
50
60
  const cp = path.join(root, '.spectoflow', 'config.json');
51
61
  const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
52
62
  if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
53
63
  if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
54
64
  if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
65
+ if (typeof patch.theme === 'string' && ['light', 'dark'].includes(patch.theme)) cfg.theme = patch.theme;
66
+ if (typeof patch.boardView === 'string' && ['list', 'kanban'].includes(patch.boardView)) cfg.boardView = patch.boardView;
67
+ if (typeof patch.sideHidden === 'boolean') cfg.sideHidden = patch.sideHidden;
68
+ if (Array.isArray(patch.expandedPhases)) cfg.expandedPhases = patch.expandedPhases.filter((v) => typeof v === 'string');
69
+ if (typeof patch.activeTab === 'string' && patch.activeTab.trim()) cfg.activeTab = patch.activeTab.trim();
70
+ if (typeof patch.chatOpen === 'boolean') cfg.chatOpen = patch.chatOpen;
71
+ // kanbanColumns: reject the whole patch (leave the current value untouched) rather than silently
72
+ // filtering out bad entries — an invalid/unknown status id here means the client sent something it
73
+ // shouldn't have, and a real product-safety rule (never persist zero visible columns) applies too.
74
+ if (Array.isArray(patch.kanbanColumns)) {
75
+ const allKnown = patch.kanbanColumns.every((v) => typeof v === 'string' && KANBAN_STATUSES.includes(v));
76
+ const uniq = [...new Set(patch.kanbanColumns)];
77
+ if (allKnown && uniq.length) cfg.kanbanColumns = uniq;
78
+ }
79
+ if (patch.kanbanPageSize === 10 || patch.kanbanPageSize === 20) cfg.kanbanPageSize = patch.kanbanPageSize;
80
+ // navTabs: same "reject the whole patch" philosophy as kanbanColumns above — plus a stricter safety
81
+ // rule than Kanban's "can't disable the last column": the stored list must always be a COMPLETE
82
+ // reordering/enable-state of the full native tab set (every known id exactly once, never a partial
83
+ // filter), and 'personalize' must always stay enabled — it's the only place the user can ever turn
84
+ // a tab back on, so letting it be disabled would permanently lock them out (short of hand-editing
85
+ // config.json on disk).
86
+ if (patch.navTabs !== undefined) {
87
+ const arr = patch.navTabs;
88
+ const valid = Array.isArray(arr)
89
+ && arr.every((v) => v && typeof v === 'object' && typeof v.id === 'string' && typeof v.enabled === 'boolean')
90
+ && arr.length === NATIVE_TABS.length
91
+ && new Set(arr.map((v) => v.id)).size === NATIVE_TABS.length
92
+ && arr.every((v) => NATIVE_TABS.includes(v.id));
93
+ const personalizeOk = valid && arr.find((v) => v.id === 'personalize').enabled === true;
94
+ if (personalizeOk) cfg.navTabs = arr.map((v) => ({ id: v.id, enabled: v.enabled }));
95
+ }
96
+ // commands: the dashboard's slash-command macros. Same "reject the whole patch" safety as
97
+ // kanbanColumns/navTabs above — if it isn't an array, or ANY entry is structurally malformed (a
98
+ // trigger that fails the format, or an empty instruction), the stored list is left untouched rather
99
+ // than partially applied. A valid array (including an empty one) replaces it: triggers are deduped
100
+ // case-insensitively (first wins), description/instruction are trimmed and clamped, enabled coerced.
101
+ if (patch.commands !== undefined) {
102
+ const arr = patch.commands;
103
+ // The format regex has no /i flag (the client is expected to normalize to lowercase before
104
+ // sending), but it is tested against the lowercased trigger here rather than the raw one: this
105
+ // still enforces the exact charset/length rule while letting an occasional mixed-case trigger
106
+ // (e.g. a client that didn't normalize) validate and dedupe correctly instead of taking down the
107
+ // whole patch over casing alone. The stored trigger is always the canonical lowercase form.
108
+ const okShape = Array.isArray(arr) && arr.every((c) => c && typeof c === 'object'
109
+ && typeof c.trigger === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(c.trigger.toLowerCase())
110
+ && typeof c.instruction === 'string' && c.instruction.trim().length > 0);
111
+ if (okShape) {
112
+ const seen = new Set(); const out = [];
113
+ for (const c of arr) {
114
+ const lc = c.trigger.toLowerCase();
115
+ if (seen.has(lc)) continue;
116
+ seen.add(lc);
117
+ out.push({
118
+ trigger: lc,
119
+ description: (typeof c.description === 'string' ? c.description.trim() : '').slice(0, 120),
120
+ instruction: c.instruction.trim().slice(0, 4000),
121
+ enabled: c.enabled !== false,
122
+ });
123
+ }
124
+ cfg.commands = out;
125
+ }
126
+ }
55
127
  if (typeof patch.agent === 'string' && patch.agent.trim()) {
56
128
  const id = patch.agent.trim();
57
129
  const known = adapters.knownAgents().find((a) => a.id === id);
@@ -73,6 +145,10 @@ const ops = {
73
145
  p.projectName = path.basename(root);
74
146
  p.knownAgents = adapters.knownAgents().map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
75
147
  p.installedAgents = detect.installedAgents(root);
148
+ // Daily meeting (Sous-projet C, Task 3): the SERVER's own local date — see meeting.js's
149
+ // todayLocal() header comment for why this, not the browser's date, is the one source of truth
150
+ // for which .spectoflow/meetings/<date>.md "today" resolves to.
151
+ p.todayDate = todayLocal();
76
152
  return p;
77
153
  },
78
154
  'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
@@ -112,9 +188,9 @@ const ops = {
112
188
  return changed(ctx, { ok: true });
113
189
  },
114
190
 
115
- 'run.start': async (root, { prompt, agent }, ctx) => {
191
+ 'run.start': async (root, { prompt, agent, display }, ctx) => {
116
192
  text(prompt, 'Empty request.');
117
- const r = startRun(root, { prompt, agent }, ctx.emit);
193
+ const r = startRun(root, { prompt, agent, display }, ctx.emit);
118
194
  if (r.error) bad(r.error);
119
195
  return { runId: r.runId };
120
196
  },
@@ -123,6 +199,11 @@ const ops = {
123
199
  if (r.error) bad(r.error);
124
200
  return { ok: true };
125
201
  },
202
+ 'meeting.generate': async (root, { agent, date }, ctx) => {
203
+ const r = runMeetingGenerate(root, { agent, date }, ctx.emit);
204
+ if (r.error) bad(r.error);
205
+ return { ok: true };
206
+ },
126
207
  'chat.clear': async (root, _args, ctx) => {
127
208
  const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
128
209
  return changed(ctx, { ok: true });