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
@@ -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 };
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+ /*
3
+ * The dashboard's operations — one pure function per action, (root, args, ctx) → result, with no
4
+ * HTTP in sight. handlers.js maps HTTP routes onto this table; the online dashboard (sub-project C)
5
+ * will map WebSocket messages onto the very same table. ctx.emit broadcasts SSE events to every
6
+ * client of this project; ops call it themselves after a successful mutation so any caller gets
7
+ * the same live behaviour. Errors are OpError(status, message) — the transport turns status into
8
+ * its own vocabulary (HTTP status code today).
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const store = require('../store');
13
+ const files = require('./files');
14
+ const { startRun } = require('./runner');
15
+ const { runSummarize } = require('./summarize');
16
+ const { runMeetingGenerate, todayLocal } = require('./meeting');
17
+ const orchestrator = require('./orchestrator');
18
+ const adapters = require('../adapters');
19
+ const detect = require('../detect');
20
+
21
+ const PKG_VERSION = require('../../package.json').version;
22
+
23
+ class OpError extends Error {
24
+ constructor(status, message) { super(message); this.status = status; }
25
+ }
26
+ const bad = (msg) => { throw new OpError(400, msg); };
27
+ const notFound = (msg) => { throw new OpError(404, msg); };
28
+ const text = (v, msg) => { if (!v || !String(v).trim()) bad(msg); return String(v).trim(); };
29
+
30
+ function frameworkVersion(root) {
31
+ try { return JSON.parse(fs.readFileSync(path.join(root, '.spectoflow', '.manifest.json'), 'utf8')).version || PKG_VERSION; } catch { return PKG_VERSION; }
32
+ }
33
+ function findPlanFileForTask(root, id) {
34
+ for (const pl of store.readPlans(root)) for (const ph of pl.phases) if (ph.tasks.find((t) => t.id === id)) return pl.file;
35
+ return null;
36
+ }
37
+ function readAgentFile(root, rel) {
38
+ const base = path.join(root, '.spectoflow');
39
+ const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
40
+ const abs = path.resolve(base, rel || '');
41
+ const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
42
+ if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory()) bad('not an agent/skill file');
43
+ let real; try { real = fs.realpathSync(abs); } catch { real = null; }
44
+ const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
45
+ const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
46
+ const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
47
+ if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory()) bad('not an agent/skill file');
48
+ return { content: fs.readFileSync(real, 'utf8') };
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'];
59
+ function writeConfig(root, patch, detectOpts) {
60
+ const cp = path.join(root, '.spectoflow', 'config.json');
61
+ const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
62
+ if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
63
+ if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
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
+ }
127
+ if (typeof patch.agent === 'string' && patch.agent.trim()) {
128
+ const id = patch.agent.trim();
129
+ const known = adapters.knownAgents().find((a) => a.id === id);
130
+ // Never activate an agent whose CLI isn't actually there — it would just fail silently later.
131
+ if (!detect.isAgentInstalled(id, root, detectOpts)) bad(`${known ? known.label : id} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
132
+ cfg.agent = id;
133
+ if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
134
+ }
135
+ fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
136
+ return cfg;
137
+ }
138
+ const filesResult = (r) => { if (r.error) bad(r.error); return r; };
139
+ const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result; };
140
+
141
+ const ops = {
142
+ 'project.read': async (root) => {
143
+ const p = store.readProject(root);
144
+ p.version = frameworkVersion(root);
145
+ p.projectName = path.basename(root);
146
+ p.knownAgents = adapters.knownAgents().map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
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();
152
+ return p;
153
+ },
154
+ 'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
155
+
156
+ 'files.tree': async (root) => ({ tree: files.tree(root) }),
157
+ 'files.read': async (root, { path: rel }) => filesResult(files.readFile(root, rel || '')),
158
+ 'files.write': async (root, { path: rel, content }, ctx) => changed(ctx, filesResult(files.writeFile(root, rel, content))),
159
+ 'files.mkdir': async (root, { path: rel }, ctx) => changed(ctx, filesResult(files.mkdir(root, rel))),
160
+
161
+ 'task.add': async (root, { title, phase, file, owner, level }, ctx) => {
162
+ const t = store.addTask(root, { title: text(title, 'A title is required.'), phase, file, owner, level });
163
+ return changed(ctx, { task: t });
164
+ },
165
+ 'task.update': async (root, { id, patch }, ctx) => {
166
+ const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
167
+ store.updateTaskLine(root, file, id, patch || {});
168
+ return changed(ctx, { ok: true });
169
+ },
170
+ 'task.comment': async (root, { id, text: body, action }, ctx) => {
171
+ const msg = text(body, 'Empty comment.');
172
+ const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
173
+ store.addTaskComment(root, file, id, msg, 'me');
174
+ if (action === 'analyze') store.updateTaskLine(root, file, id, { status: 'to_analyze' });
175
+ return changed(ctx, { ok: true });
176
+ },
177
+ 'workflow.toggle': async (root, { name }, ctx) => {
178
+ const wf = path.join(root, '.spectoflow', 'workflow.md');
179
+ const lines = fs.readFileSync(wf, 'utf8').split('\n');
180
+ // Strip the trailing {cap:... skill:... policy} annotation BEFORE "(optional)" — the same order as
181
+ // store.readWorkflow(), which is what the client's step names come from (D60).
182
+ const stepName = (rest) => { const ann = rest.match(/\{([^}]*)\}\s*$/); if (ann) rest = rest.slice(0, ann.index).trim(); return rest.replace(/\s*\(optional\)\s*$/i, '').trim(); };
183
+ for (let i = 0; i < lines.length; i++) {
184
+ const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
185
+ if (m && stepName(m[4]) === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4];
186
+ }
187
+ fs.writeFileSync(wf, lines.join('\n'));
188
+ return changed(ctx, { ok: true });
189
+ },
190
+
191
+ 'run.start': async (root, { prompt, agent, display }, ctx) => {
192
+ text(prompt, 'Empty request.');
193
+ const r = startRun(root, { prompt, agent, display }, ctx.emit);
194
+ if (r.error) bad(r.error);
195
+ return { runId: r.runId };
196
+ },
197
+ 'chat.summarize': async (root, { agent }, ctx) => {
198
+ const r = runSummarize(root, { agent }, ctx.emit);
199
+ if (r.error) bad(r.error);
200
+ return { ok: true };
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
+ },
207
+ 'chat.clear': async (root, _args, ctx) => {
208
+ const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
209
+ return changed(ctx, { ok: true });
210
+ },
211
+ 'orchestrate.start': async (root, { request }, ctx) => {
212
+ const req = text(request, 'Empty request.');
213
+ const active = store.readRuntime(root).orchestration;
214
+ if (active && ['running', 'awaiting_approval'].includes(active.status)) throw new OpError(409, 'An orchestration is already active.');
215
+ const mode = store.readConfig(root).mode || 'semi';
216
+ orchestrator.runOrchestration({ root, request: req, mode, runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, ctx.emit)
217
+ .catch((e) => ctx.emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
218
+ const o = store.readRuntime(root).orchestration;
219
+ return { orchestrationId: o && o.id };
220
+ },
221
+ 'orchestrate.approve': async (root, { decision, note }) => {
222
+ if (!orchestrator.submitDecision(decision, note, root)) throw new OpError(409, 'No pending approval.');
223
+ return { ok: true };
224
+ },
225
+
226
+ 'settings.save': async (root, patch, ctx) => changed(ctx, { config: writeConfig(root, patch || {}, ctx.env ? { env: ctx.env } : undefined) }),
227
+
228
+ 'attention.add': async (root, { text: body }, ctx) => {
229
+ const msg = text(body, 'Empty note.');
230
+ const rt = store.readRuntime(root); rt.attention = rt.attention || [];
231
+ const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: msg };
232
+ rt.attention.unshift(item); store.writeRuntime(root, rt);
233
+ return changed(ctx, { item });
234
+ },
235
+ 'attention.promote': async (root, { id }, ctx) => {
236
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
237
+ if (!it) notFound('Note not found.');
238
+ const t = store.addTask(root, { phase: 'Attention', title: it.text, owner: 'user' });
239
+ it.status = 'resolved'; it.promotedTo = t.id; store.writeRuntime(root, rt);
240
+ return changed(ctx, { task: t });
241
+ },
242
+ 'attention.update': async (root, { id, patch }, ctx) => {
243
+ const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
244
+ if (!it) notFound('Note not found.');
245
+ const p = patch || {};
246
+ if (typeof p.text === 'string' && p.text.trim()) it.text = p.text.trim();
247
+ if (p.status && ['open', 'resolved'].includes(p.status)) it.status = p.status;
248
+ store.writeRuntime(root, rt);
249
+ return changed(ctx, { item: it });
250
+ },
251
+ 'attention.remove': async (root, { id }, ctx) => {
252
+ const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
253
+ return changed(ctx, { ok: true });
254
+ },
255
+ };
256
+
257
+ module.exports = { ops, OpError };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const store = require('../lib/store');
4
+ const store = require('../store');
5
5
  const { startRun } = require('./runner');
6
6
 
7
7
  // step (from store.readWorkflow) -> { agent, skill } or { error }
@@ -54,7 +54,7 @@ async function runOrchestration({ root, request, mode, runStep, confirm, resume
54
54
  if (needConfirm) {
55
55
  st.status = 'awaiting_approval'; o.status = 'awaiting_approval'; saveState(root, o, emit);
56
56
  post(root, 'orchestrator', 'question', `Approve step "${step.name}" (${r.agent})${policyGated ? ' — policy gate' : ''}?`, emit);
57
- const dec = await confirm(step, { policy: policyGated });
57
+ const dec = await confirm(step, { policy: policyGated, root });
58
58
  post(root, 'orchestrator', 'status', `decision: ${dec.decision}${dec.note ? ' — ' + dec.note : ''}`, emit);
59
59
  if (dec.decision === 'cancel') { st.status = 'skipped'; o.status = 'cancelled'; saveState(root, o, emit); return o; }
60
60
  o.status = 'running'; saveState(root, o, emit);
@@ -94,12 +94,21 @@ function defaultRunStep({ root, step, agent, skill, request }, emit) {
94
94
  });
95
95
  }
96
96
 
97
- let pending = null;
98
- function defaultConfirm(step, reason) { return new Promise((resolve) => { pending = { resolve }; }); }
99
- function submitDecision(decision, note) {
100
- if (!pending) return false;
101
- const p = pending; pending = null; p.resolve({ decision, note }); return true;
97
+ // Keyed by project root — before this hub shared one orchestrator.js instance across every project
98
+ // (D64), a single module-level `pending` here was a real cross-project leak: approving in one
99
+ // project could resolve (or silently overwrite) another project's pending gate.
100
+ const pending = new Map(); // root -> { resolve }
101
+ function defaultConfirm(step, reason) { return new Promise((resolve) => { pending.set(reason && reason.root, { resolve }); }); }
102
+ function submitDecision(decision, note, root) {
103
+ const p = pending.get(root);
104
+ if (!p) return false;
105
+ pending.delete(root);
106
+ p.resolve({ decision, note });
107
+ return true;
102
108
  }
109
+ // Drops any pending approval for `root` — called when a project is reloaded (its whole in-flight
110
+ // session is considered gone) so a stale resolver can never be sitting around after the fact.
111
+ function clearPending(root) { pending.delete(root); }
103
112
 
104
113
  // Boot-time reconcile: a process restart loses any in-flight orchestration (the runOrchestration
105
114
  // call stack, and the in-memory `pending` approval) even though runtime.json still records it as
@@ -116,4 +125,4 @@ function reconcileOnBoot(root) {
116
125
  return true;
117
126
  }
118
127
 
119
- module.exports = { resolveStep, runOrchestration, defaultRunStep, defaultConfirm, submitDecision, reconcileOnBoot };
128
+ module.exports = { resolveStep, runOrchestration, defaultRunStep, defaultConfirm, submitDecision, clearPending, reconcileOnBoot };