@smartmemory/compose 0.1.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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1014 -0
  3. package/bin/compose.js +1515 -0
  4. package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
  5. package/dist/assets/arc-SxJ2J1sh.js +1 -0
  6. package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
  7. package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
  8. package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
  9. package/dist/assets/channel-DGElom1e.js +1 -0
  10. package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
  11. package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
  12. package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
  13. package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
  14. package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
  15. package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
  16. package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
  17. package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
  18. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
  19. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
  20. package/dist/assets/clone-DUJKJXd7.js +1 -0
  21. package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
  22. package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
  23. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  24. package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
  25. package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
  26. package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
  27. package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
  28. package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
  29. package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
  30. package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
  31. package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
  32. package/dist/assets/graph-D0Cfv00Y.js +1 -0
  33. package/dist/assets/index-CUd6pFGF.css +1 -0
  34. package/dist/assets/index-DReRlzZI.js +1144 -0
  35. package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
  36. package/dist/assets/init-Gi6I4Gst.js +1 -0
  37. package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
  38. package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
  39. package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
  40. package/dist/assets/katex-DkKDou_j.js +257 -0
  41. package/dist/assets/layout-Bj72wOEB.js +1 -0
  42. package/dist/assets/linear-BRFo114D.js +1 -0
  43. package/dist/assets/min-GCHnKlJS.js +1 -0
  44. package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
  47. package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
  48. package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
  49. package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
  50. package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
  51. package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
  52. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
  53. package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
  54. package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
  55. package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
  56. package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
  57. package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
  58. package/dist/index.html +30 -0
  59. package/lib/agent-chains.js +65 -0
  60. package/lib/agent-string.js +86 -0
  61. package/lib/budget-ledger.js +86 -0
  62. package/lib/build-all.js +162 -0
  63. package/lib/build-dag.js +120 -0
  64. package/lib/build-stream-writer.js +190 -0
  65. package/lib/build.js +2997 -0
  66. package/lib/capability-checker.js +53 -0
  67. package/lib/cert-inject.js +38 -0
  68. package/lib/cli-progress.js +483 -0
  69. package/lib/constants.js +69 -0
  70. package/lib/cross-layer-audit.js +84 -0
  71. package/lib/debug-discipline.js +173 -0
  72. package/lib/feature-json.js +106 -0
  73. package/lib/gate-prompt.js +291 -0
  74. package/lib/gate-tiers.js +194 -0
  75. package/lib/health-history.js +119 -0
  76. package/lib/health-score.js +227 -0
  77. package/lib/ideabox.js +570 -0
  78. package/lib/import.js +244 -0
  79. package/lib/migrate-roadmap.js +94 -0
  80. package/lib/model-pricing.js +67 -0
  81. package/lib/new.js +413 -0
  82. package/lib/pipeline-cli.js +489 -0
  83. package/lib/plan-parser.js +103 -0
  84. package/lib/qa-scoping.js +474 -0
  85. package/lib/questionnaire.js +200 -0
  86. package/lib/resolve-port.js +7 -0
  87. package/lib/result-normalizer.js +349 -0
  88. package/lib/review-lenses.js +166 -0
  89. package/lib/roadmap-gen.js +210 -0
  90. package/lib/roadmap-parser.js +176 -0
  91. package/lib/server-probe.js +23 -0
  92. package/lib/staleness.js +87 -0
  93. package/lib/step-prompt.js +260 -0
  94. package/lib/step-validator.js +49 -0
  95. package/lib/stratum-mcp-client.js +365 -0
  96. package/lib/team-flag.js +46 -0
  97. package/lib/test-bootstrap.js +401 -0
  98. package/lib/triage.js +274 -0
  99. package/lib/vision-writer.js +391 -0
  100. package/package.json +111 -0
  101. package/pipelines/bug-fix.stratum.yaml +230 -0
  102. package/pipelines/build.stratum.yaml +498 -0
  103. package/pipelines/content.stratum.yaml +112 -0
  104. package/pipelines/coverage-sweep.stratum.yaml +52 -0
  105. package/pipelines/refactor.stratum.yaml +169 -0
  106. package/pipelines/research.stratum.yaml +88 -0
  107. package/pipelines/review-fix.stratum.yaml +109 -0
  108. package/presets/team-feature.stratum.yaml +105 -0
  109. package/presets/team-research.stratum.yaml +108 -0
  110. package/presets/team-review.stratum.yaml +106 -0
  111. package/scripts/agent-activity-hook.sh +31 -0
  112. package/scripts/agent-error-hook.sh +28 -0
  113. package/scripts/analyze-orphans.mjs +50 -0
  114. package/scripts/find-orphans.mjs +26 -0
  115. package/scripts/fix-phases.mjs +49 -0
  116. package/scripts/generate-stratum-spec.mjs +137 -0
  117. package/scripts/import-roadmap.mjs +116 -0
  118. package/scripts/phase-audit.mjs +33 -0
  119. package/scripts/run-pipeline.mjs +314 -0
  120. package/scripts/session-end-hook.sh +18 -0
  121. package/scripts/session-start-hook.sh +38 -0
  122. package/scripts/vision-hook.sh +104 -0
  123. package/scripts/vision-track.mjs +554 -0
  124. package/scripts/wire-all-orphans.mjs +108 -0
  125. package/scripts/wire-orphans.mjs +164 -0
  126. package/server/activity-routes.js +123 -0
  127. package/server/agent-health.js +197 -0
  128. package/server/agent-hooks.js +102 -0
  129. package/server/agent-mcp.js +10 -0
  130. package/server/agent-registry.js +95 -0
  131. package/server/agent-server.js +290 -0
  132. package/server/agent-spawn.js +251 -0
  133. package/server/agent-templates.js +77 -0
  134. package/server/artifact-manager.js +247 -0
  135. package/server/artifact-templates/architecture.md +28 -0
  136. package/server/artifact-templates/blueprint.md +21 -0
  137. package/server/artifact-templates/design.md +36 -0
  138. package/server/artifact-templates/plan.md +25 -0
  139. package/server/artifact-templates/prd.md +43 -0
  140. package/server/artifact-templates/report.md +40 -0
  141. package/server/block-tracker.js +90 -0
  142. package/server/build-stream-bridge.js +502 -0
  143. package/server/coalescing-buffer.js +46 -0
  144. package/server/compose-mcp-tools.js +479 -0
  145. package/server/compose-mcp.js +324 -0
  146. package/server/connectors/agent-connector.js +78 -0
  147. package/server/connectors/claude-sdk-connector.js +198 -0
  148. package/server/connectors/codex-connector.js +240 -0
  149. package/server/connectors/connector-discovery.js +18 -0
  150. package/server/connectors/connector-runtime.js +13 -0
  151. package/server/connectors/opencode-connector.js +200 -0
  152. package/server/design-routes.js +540 -0
  153. package/server/design-session.js +161 -0
  154. package/server/feature-scan.js +593 -0
  155. package/server/file-watcher.js +284 -0
  156. package/server/find-root.js +29 -0
  157. package/server/graph-export.js +343 -0
  158. package/server/ideabox-cache.js +77 -0
  159. package/server/ideabox-routes.js +294 -0
  160. package/server/index.js +156 -0
  161. package/server/model-tiers.js +49 -0
  162. package/server/pipeline-routes.js +288 -0
  163. package/server/policy-evaluator.js +36 -0
  164. package/server/project-root.js +122 -0
  165. package/server/security.js +23 -0
  166. package/server/session-manager.js +403 -0
  167. package/server/session-routes.js +190 -0
  168. package/server/session-store.js +107 -0
  169. package/server/settings-routes.js +35 -0
  170. package/server/settings-store.js +234 -0
  171. package/server/stratum-api.js +102 -0
  172. package/server/stratum-client.js +192 -0
  173. package/server/stratum-sync.js +193 -0
  174. package/server/summarizer.js +139 -0
  175. package/server/supervisor.js +196 -0
  176. package/server/vision-routes.js +668 -0
  177. package/server/vision-server.js +393 -0
  178. package/server/vision-store.js +360 -0
  179. package/server/vision-utils.js +179 -0
  180. package/server/worktree-gc.js +137 -0
  181. package/templates/ROADMAP.md +46 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * File Watcher Server
3
+ * Watches docs/ for changes and broadcasts file content over WebSocket.
4
+ * Also serves file content via REST for initial loads.
5
+ */
6
+
7
+ import { WebSocketServer } from 'ws';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import { getTargetRoot, loadProjectConfig, ensureDataDir } from './project-root.js';
13
+
14
+ const PROJECT_ROOT = getTargetRoot();
15
+
16
+ export class FileWatcherServer {
17
+ constructor() {
18
+ this.clients = new Set();
19
+ this.wss = null;
20
+ this.watchers = [];
21
+ }
22
+
23
+ /** Resolve and validate a relative path stays within project root */
24
+ safePath(relativePath) {
25
+ const resolved = path.resolve(PROJECT_ROOT, relativePath);
26
+ if (!resolved.startsWith(PROJECT_ROOT + path.sep) && resolved !== PROJECT_ROOT) {
27
+ return null;
28
+ }
29
+ return resolved;
30
+ }
31
+
32
+ attach(httpServer, app) {
33
+ // REST endpoint: GET /api/file?path=docs/brainstorm.md
34
+ app.get('/api/file', (req, res) => {
35
+ const filePath = req.query.path;
36
+ if (!filePath) return res.status(400).json({ error: 'path required' });
37
+
38
+ const resolved = this.safePath(filePath);
39
+ if (!resolved) return res.status(403).json({ error: 'path outside project' });
40
+
41
+ try {
42
+ const content = fs.readFileSync(resolved, 'utf-8');
43
+ res.json({ path: filePath, content });
44
+ } catch (err) {
45
+ if (err.code === 'ENOENT') return res.status(404).json({ error: 'file not found' });
46
+ res.status(500).json({ error: err.message });
47
+ }
48
+ });
49
+
50
+ // REST endpoint: PUT /api/file — write content to a file
51
+ app.put('/api/file', (req, res) => {
52
+ const filePath = req.body.path;
53
+ const content = req.body.content;
54
+ if (!filePath) return res.status(400).json({ error: 'path required' });
55
+ if (typeof content !== 'string') return res.status(400).json({ error: 'content required (string)' });
56
+
57
+ const resolved = this.safePath(filePath);
58
+ if (!resolved) return res.status(403).json({ error: 'path outside project' });
59
+
60
+ try {
61
+ fs.writeFileSync(resolved, content, 'utf-8');
62
+ res.json({ ok: true, path: filePath });
63
+ } catch (err) {
64
+ res.status(500).json({ error: err.message });
65
+ }
66
+ });
67
+
68
+ // REST endpoint: GET /api/files — list markdown files in docs/
69
+ app.get('/api/files', (_req, res) => {
70
+ const config = loadProjectConfig();
71
+ const docsPrefix = config.paths?.docs || 'docs';
72
+ const docsDir = path.join(PROJECT_ROOT, docsPrefix);
73
+ try {
74
+ const files = this.listMarkdownFiles(docsDir, docsPrefix);
75
+ res.json({ files });
76
+ } catch (err) {
77
+ res.status(500).json({ error: err.message });
78
+ }
79
+ });
80
+
81
+ // REST endpoint: POST /api/canvas/open — agent can tell the canvas to load a file
82
+ // Optional: { path, anchor } — anchor scrolls to a heading after opening
83
+ // Special: vision://surface opens the vision surface tab
84
+ app.post('/api/canvas/open', (req, res) => {
85
+ const filePath = req.body.path;
86
+ const anchor = req.body.anchor;
87
+ if (!filePath) return res.status(400).json({ error: 'path required' });
88
+
89
+ // Handle vision:// scheme — no file read, just broadcast open
90
+ if (filePath.startsWith('vision://')) {
91
+ this.broadcast({ type: 'openFile', path: filePath, content: null, rendererType: 'vision' });
92
+ return res.json({ ok: true, path: filePath });
93
+ }
94
+
95
+ // Handle graph:// scheme — opens a named graph in the GraphRenderer
96
+ if (filePath.startsWith('graph://')) {
97
+ this.broadcast({ type: 'openFile', path: filePath, content: null, rendererType: 'graph' });
98
+ return res.json({ ok: true, path: filePath });
99
+ }
100
+
101
+ const resolved = this.safePath(filePath);
102
+ if (!resolved) return res.status(403).json({ error: 'path outside project' });
103
+
104
+ try {
105
+ const content = fs.readFileSync(resolved, 'utf-8');
106
+ this.broadcast({ type: 'openFile', path: filePath, content, anchor });
107
+ res.json({ ok: true, path: filePath, anchor });
108
+ } catch (err) {
109
+ if (err.code === 'ENOENT') return res.status(404).json({ error: 'file not found' });
110
+ res.status(500).json({ error: err.message });
111
+ }
112
+ });
113
+
114
+ // REST endpoint: POST /api/canvas/scroll — scroll to a heading in an open tab
115
+ // { anchor, path? } — path switches tab first (file must already be open)
116
+ app.post('/api/canvas/scroll', (req, res) => {
117
+ const { anchor, path: filePath } = req.body;
118
+ if (!anchor) return res.status(400).json({ error: 'anchor required' });
119
+ this.broadcast({ type: 'scrollTo', anchor, path: filePath });
120
+ res.json({ ok: true, anchor, path: filePath });
121
+ });
122
+
123
+ // REST endpoint: POST /api/canvas/close — close a tab (or all tabs)
124
+ // { path? } — close specific tab, or omit to close all
125
+ app.post('/api/canvas/close', (req, res) => {
126
+ const filePath = req.body.path;
127
+ this.broadcast({ type: 'closeFile', path: filePath || null });
128
+ res.json({ ok: true, path: filePath || 'all' });
129
+ });
130
+
131
+ // WebSocket endpoint: /ws/files
132
+ this.wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
133
+
134
+ this.wss.on('connection', (ws) => {
135
+ this.clients.add(ws);
136
+ console.log(`[file-watcher] Client connected (${this.clients.size} total)`);
137
+
138
+ ws.on('close', () => {
139
+ this.clients.delete(ws);
140
+ console.log(`[file-watcher] Client disconnected (${this.clients.size} total)`);
141
+ });
142
+
143
+ ws.on('error', (err) => {
144
+ console.error('[file-watcher] WebSocket error:', err.message);
145
+ this.clients.delete(ws);
146
+ });
147
+ });
148
+
149
+ // Watch docs/ directory
150
+ this.startWatching();
151
+ console.log('File watcher WebSocket server attached at /ws/files');
152
+ }
153
+
154
+ startWatching() {
155
+ const debounceMap = new Map();
156
+
157
+ const watchDir = (dir, prefix, onChanged, fileFilter = (f) => f.endsWith('.md')) => {
158
+ if (!fs.existsSync(dir)) {
159
+ console.warn(`[file-watcher] ${prefix}/ directory not found, skipping watch`);
160
+ return;
161
+ }
162
+ try {
163
+ const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
164
+ if (!filename || !fileFilter(filename)) return;
165
+
166
+ const relativePath = path.join(prefix, filename);
167
+ const fullPath = path.join(PROJECT_ROOT, relativePath);
168
+
169
+ // Debounce: ignore events within 100ms of each other for the same file
170
+ const now = Date.now();
171
+ const lastEvent = debounceMap.get(relativePath);
172
+ if (lastEvent && now - lastEvent < 100) return;
173
+ debounceMap.set(relativePath, now);
174
+
175
+ onChanged(relativePath, fullPath);
176
+ });
177
+ this.watchers.push(watcher);
178
+ } catch (err) {
179
+ console.error(`[file-watcher] Failed to watch ${prefix}/:`, err.message);
180
+ }
181
+ };
182
+
183
+ // Watch docs/ — broadcast fileChanged events
184
+ const config = loadProjectConfig();
185
+ const docsPrefix = config.paths?.docs || 'docs';
186
+ watchDir(path.join(PROJECT_ROOT, docsPrefix), docsPrefix, (relativePath, fullPath) => {
187
+ try {
188
+ if (!fs.existsSync(fullPath)) return;
189
+ const content = fs.readFileSync(fullPath, 'utf-8');
190
+ this.broadcast({ type: 'fileChanged', path: relativePath, content });
191
+ } catch (err) {
192
+ console.error(`[file-watcher] Error reading ${relativePath}:`, err.message);
193
+ }
194
+ });
195
+
196
+ // Watch features/ — notify for auto-reseed into vision store
197
+ const featuresPrefix = config.paths?.features || 'docs/features';
198
+ watchDir(path.join(PROJECT_ROOT, featuresPrefix), featuresPrefix, (relativePath) => {
199
+ // Also broadcast as fileChanged (features are docs)
200
+ const fullPath = path.join(PROJECT_ROOT, relativePath);
201
+ try {
202
+ if (fs.existsSync(fullPath)) {
203
+ const content = fs.readFileSync(fullPath, 'utf-8');
204
+ this.broadcast({ type: 'fileChanged', path: relativePath, content });
205
+ }
206
+ } catch { /* skip */ }
207
+ if (typeof this.onFeatureChanged === 'function') {
208
+ this.onFeatureChanged(relativePath);
209
+ }
210
+ });
211
+
212
+ // Watch .compose/data/ for active-build.json changes
213
+ const self = this;
214
+ const dataDir = path.join(PROJECT_ROOT, '.compose', 'data');
215
+ let dataDirWatcherRegistered = false;
216
+
217
+ // Guarantee .compose/data/ exists before registering the watcher
218
+ ensureDataDir();
219
+
220
+ const registerDataWatcher = () => {
221
+ if (dataDirWatcherRegistered) return;
222
+ if (!fs.existsSync(dataDir)) return;
223
+ dataDirWatcherRegistered = true;
224
+
225
+ watchDir(dataDir, '.compose/data', (relativePath, fullPath) => {
226
+ let state = null;
227
+ try {
228
+ if (fs.existsSync(fullPath)) {
229
+ state = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
230
+ }
231
+ } catch { /* parse error or ENOENT — state stays null */ }
232
+
233
+ if (typeof self.onBuildStateChanged === 'function') {
234
+ self.onBuildStateChanged(state);
235
+ }
236
+ }, (f) => f === 'active-build.json');
237
+ };
238
+
239
+ registerDataWatcher();
240
+ }
241
+
242
+ broadcast(message) {
243
+ const data = JSON.stringify(message);
244
+ for (const client of this.clients) {
245
+ if (client.readyState === 1) {
246
+ try {
247
+ client.send(data);
248
+ } catch (err) {
249
+ console.error('[file-watcher] Broadcast error:', err.message);
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ listMarkdownFiles(dir, prefix) {
256
+ const results = [];
257
+ try {
258
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
259
+ for (const entry of entries) {
260
+ const relativePath = path.join(prefix, entry.name);
261
+ if (entry.isDirectory()) {
262
+ results.push(...this.listMarkdownFiles(path.join(dir, entry.name), relativePath));
263
+ } else if (entry.name.endsWith('.md')) {
264
+ results.push(relativePath);
265
+ }
266
+ }
267
+ } catch {
268
+ // Directory might not exist or be readable
269
+ }
270
+ return results;
271
+ }
272
+
273
+ close() {
274
+ for (const watcher of this.watchers) {
275
+ watcher.close();
276
+ }
277
+ this.watchers = [];
278
+ for (const client of this.clients) {
279
+ client.close();
280
+ }
281
+ this.clients.clear();
282
+ if (this.wss) this.wss.close();
283
+ }
284
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * find-root.js — Pure project-root resolution utility.
3
+ *
4
+ * Side-effect-free at import time. Safe to import from CLI entry points
5
+ * (bin/compose.js) without triggering process.exit or other side effects.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import fs from 'node:fs';
10
+
11
+ /** Markers that indicate a project root, checked in priority order. */
12
+ export const MARKERS = ['.compose', '.stratum.yaml', '.git'];
13
+
14
+ /**
15
+ * Walk up from startDir looking for a directory containing any marker.
16
+ * @param {string} startDir
17
+ * @returns {string|null} — absolute path to project root, or null
18
+ */
19
+ export function findProjectRoot(startDir) {
20
+ let dir = path.resolve(startDir);
21
+ const { root } = path.parse(dir);
22
+ while (dir !== root) {
23
+ for (const marker of MARKERS) {
24
+ if (fs.existsSync(path.join(dir, marker))) return dir;
25
+ }
26
+ dir = path.dirname(dir);
27
+ }
28
+ return null;
29
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * graph-export.js — Generate roadmap-graph.html from vision store state.
3
+ *
4
+ * Exports the vision store's items and connections as a standalone Cytoscape
5
+ * dependency graph HTML file, compatible with the SmartMemory roadmap-graph format.
6
+ *
7
+ * Route: GET /api/export/roadmap-graph
8
+ */
9
+
10
+ import path from 'node:path';
11
+ import fs from 'node:fs';
12
+ import { getTargetRoot } from './project-root.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Data extraction
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const STATUS_MAP = {
19
+ planned: 'planned',
20
+ ready: 'planned',
21
+ in_progress: 'partial',
22
+ review: 'partial',
23
+ blocked: 'parked',
24
+ parked: 'parked',
25
+ complete: 'complete',
26
+ killed: 'complete',
27
+ };
28
+
29
+ const EDGE_TYPE_MAP = {
30
+ blocks: 'dep',
31
+ informs: 'dep',
32
+ implements: 'dep',
33
+ supports: 'concurrent',
34
+ contradicts: 'concurrent',
35
+ };
36
+
37
+ function extractGraphData(store) {
38
+ const items = Array.from(store.items.values());
39
+ const connections = Array.from(store.connections.values());
40
+
41
+ // Only include features (not tracks/tasks)
42
+ const features = items.filter(i => i.type === 'feature');
43
+
44
+ // Build nodes
45
+ const nodes = [];
46
+ const completed = [];
47
+ const itemIdToCode = new Map();
48
+
49
+ for (const item of features) {
50
+ const code = item.lifecycle?.featureCode || item.title;
51
+ itemIdToCode.set(item.id, code);
52
+ const graphStatus = STATUS_MAP[item.status] || 'planned';
53
+
54
+ // Extract track from description if available
55
+ const trackMatch = (item.description || '').match(/Track:\s*(\w+)/i);
56
+ const track = trackMatch ? trackMatch[1].toLowerCase() : 'standalone';
57
+
58
+ // Extract priority from description if available
59
+ const priorityMatch = (item.description || '').match(/Priority:\s*(\w+)/i);
60
+ const priority = priorityMatch ? priorityMatch[1].toLowerCase() : 'medium';
61
+
62
+ // Clean description — remove Track/Priority metadata lines
63
+ const desc = (item.description || '')
64
+ .split('\n')
65
+ .filter(l => !l.match(/^Track:/i) && !l.match(/^Priority:/i))
66
+ .join('\n')
67
+ .trim();
68
+
69
+ // First line is the full name, rest is description
70
+ const lines = desc.split('\n').filter(Boolean);
71
+ const name = lines[0] || code;
72
+ const descText = lines.slice(1).join(' ').substring(0, 300);
73
+
74
+ if (graphStatus === 'complete') {
75
+ completed.push({
76
+ group: track.charAt(0).toUpperCase() + track.slice(1),
77
+ id: code,
78
+ name,
79
+ date: item.updatedAt ? item.updatedAt.split('T')[0] : '',
80
+ });
81
+ } else {
82
+ nodes.push({
83
+ id: code,
84
+ label: `${code}\\n${name.substring(0, 30)}`,
85
+ name,
86
+ status: graphStatus,
87
+ priority,
88
+ track,
89
+ desc: descText,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Build edges
95
+ const edges = [];
96
+ for (const conn of connections) {
97
+ const source = itemIdToCode.get(conn.fromId);
98
+ const target = itemIdToCode.get(conn.toId);
99
+ if (!source || !target) continue;
100
+
101
+ // Only include edges where both endpoints are open (not complete)
102
+ const sourceNode = nodes.find(n => n.id === source);
103
+ const targetNode = nodes.find(n => n.id === target);
104
+ if (!sourceNode || !targetNode) continue;
105
+
106
+ edges.push({
107
+ source,
108
+ target,
109
+ type: EDGE_TYPE_MAP[conn.type] || 'dep',
110
+ });
111
+ }
112
+
113
+ return { nodes, edges, completed };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // HTML generation
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function generateHTML(store) {
121
+ const { nodes, edges, completed } = extractGraphData(store);
122
+ const projectName = path.basename(getTargetRoot());
123
+ const date = new Date().toISOString().split('T')[0];
124
+
125
+ // Collect unique tracks
126
+ const tracks = [...new Set(nodes.map(n => n.track))].sort();
127
+
128
+ return `<!DOCTYPE html>
129
+ <html lang="en">
130
+ <head>
131
+ <meta charset="UTF-8">
132
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
133
+ <title>${projectName} — Roadmap Dependency Graph</title>
134
+ <script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"><\/script>
135
+ <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"><\/script>
136
+ <script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"><\/script>
137
+ <style>
138
+ * { box-sizing: border-box; margin: 0; padding: 0; }
139
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
140
+ header { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; background: #1e293b; border-bottom: 1px solid #334155; flex-shrink: 0; }
141
+ header h1 { font-size: 15px; font-weight: 600; color: #f1f5f9; }
142
+ header .subtitle { font-size: 12px; color: #64748b; margin-top: 2px; }
143
+ .controls { display: flex; align-items: center; gap: 8px; }
144
+ .filter-group { display: flex; gap: 4px; align-items: center; }
145
+ .filter-label { font-size: 11px; color: #64748b; margin-right: 2px; }
146
+ button.filter-btn { font-size: 11px; padding: 3px 9px; border-radius: 4px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; transition: all 0.15s; }
147
+ button.filter-btn:hover { border-color: #64748b; color: #e2e8f0; }
148
+ button.filter-btn.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
149
+ .sep { width: 1px; height: 20px; background: #334155; }
150
+ .zoom-btn { font-size: 12px; padding: 3px 9px; border-radius: 4px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; }
151
+ .zoom-btn:hover { border-color: #64748b; color: #e2e8f0; }
152
+ #cy { flex: 1; }
153
+ #tooltip { display: none; position: fixed; background: #1e293b; border: 1px solid #475569; border-radius: 8px; padding: 10px 13px; font-size: 12px; pointer-events: none; z-index: 9999; max-width: 280px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
154
+ #tooltip .tt-id { font-weight: 700; font-size: 13px; color: #f8fafc; margin-bottom: 3px; }
155
+ #tooltip .tt-name { color: #94a3b8; margin-bottom: 6px; line-height: 1.4; }
156
+ #tooltip .tt-row { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
157
+ #tooltip .tt-badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; }
158
+ #tooltip .tt-deps { margin-top: 8px; color: #64748b; font-size: 11px; }
159
+ #tooltip .tt-deps strong { color: #94a3b8; }
160
+ #legend { position: fixed; bottom: 16px; left: 16px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px 13px; font-size: 11px; z-index: 100; }
161
+ #legend h4 { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 7px; }
162
+ .legend-row { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; color: #94a3b8; }
163
+ .legend-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
164
+ .legend-line { width: 22px; height: 2px; flex-shrink: 0; }
165
+ .legend-dashed { width: 22px; height: 0; border-top: 2px dashed; flex-shrink: 0; }
166
+ .legend-sep { height: 1px; background: #334155; margin: 5px 0; }
167
+ #track-panel { position: fixed; bottom: 16px; right: 16px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px 13px; font-size: 11px; z-index: 100; }
168
+ #track-panel h4 { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 7px; }
169
+ .track-row { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; color: #94a3b8; cursor: pointer; }
170
+ .track-row:hover { color: #e2e8f0; }
171
+ .track-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
172
+ .dimmed { opacity: 0.15; }
173
+ .highlighted { border-width: 3px; border-color: #60a5fa; }
174
+ </style>
175
+ </head>
176
+ <body>
177
+ <header>
178
+ <div>
179
+ <h1>${projectName} — Roadmap Dependency Graph</h1>
180
+ <div class="subtitle">Generated from Compose · ${date}</div>
181
+ </div>
182
+ <div class="controls">
183
+ <span class="filter-label">Status:</span>
184
+ <div class="filter-group">
185
+ <button class="filter-btn active" data-status="all">All</button>
186
+ <button class="filter-btn" data-status="planned">Planned</button>
187
+ <button class="filter-btn" data-status="parked">Parked</button>
188
+ <button class="filter-btn" data-status="partial">Partial</button>
189
+ </div>
190
+ <div class="sep"></div>
191
+ <button class="zoom-btn" id="btn-fit">⊡ Fit</button>
192
+ <button class="zoom-btn" id="btn-in">+</button>
193
+ <button class="zoom-btn" id="btn-out">−</button>
194
+ </div>
195
+ </header>
196
+ <div id="cy"></div>
197
+ <div id="tooltip"></div>
198
+ <div id="legend">
199
+ <h4>Legend</h4>
200
+ <div class="legend-row"><div class="legend-dot" style="background:#3b82f6"></div> Planned</div>
201
+ <div class="legend-row"><div class="legend-dot" style="background:#6b7280"></div> Parked</div>
202
+ <div class="legend-row"><div class="legend-dot" style="background:#f59e0b"></div> Partial</div>
203
+ <div class="legend-sep"></div>
204
+ <div class="legend-row"><div class="legend-line" style="background:#64748b"></div> Depends on</div>
205
+ <div class="legend-row"><div class="legend-dashed" style="border-color:#94a3b8"></div> Concurrent</div>
206
+ </div>
207
+ <div id="track-panel"><h4>Tracks</h4></div>
208
+ <script>
209
+ const TRACK_COLORS = {
210
+ knowledge: '#0ea5e9', distribution: '#10b981', governance: '#a855f7',
211
+ agent: '#f59e0b', worker: '#ef4444', platform: '#ec4899',
212
+ developer: '#f97316', async: '#6b7280', standalone: '#64748b',
213
+ };
214
+ const STATUS_COLORS = { planned: '#3b82f6', parked: '#6b7280', partial: '#f59e0b' };
215
+
216
+ const nodes = ${JSON.stringify(nodes, null, 2)};
217
+ const edges = ${JSON.stringify(edges, null, 2)};
218
+ const completed = ${JSON.stringify(completed, null, 2)};
219
+
220
+ cytoscape.use(cytoscapeDagre);
221
+ const elements = [];
222
+ const tracks = [...new Set(nodes.map(n => n.track))];
223
+ tracks.forEach(t => elements.push({ data: { id: 'track-'+t, label: t.charAt(0).toUpperCase()+t.slice(1), isTrack: true, track: t } }));
224
+ nodes.forEach(n => elements.push({ data: { ...n, parent: 'track-'+n.track } }));
225
+ edges.forEach((e,i) => elements.push({ data: { id: 'e'+i, source: e.source, target: e.target, type: e.type } }));
226
+
227
+ const cy = cytoscape({
228
+ container: document.getElementById('cy'),
229
+ elements,
230
+ style: [
231
+ { selector: '[?isTrack]', style: { label: 'data(label)', 'text-valign': 'top', 'text-halign': 'center', 'font-size': '10px', 'font-weight': '600', color: '#64748b', 'text-transform': 'uppercase', 'background-color': '#1a2537', 'border-width': 1, 'border-color': '#283548', padding: '18px' } },
232
+ { selector: 'node[status]', style: { label: 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': '9px', 'font-weight': '500', color: '#e2e8f0', 'text-wrap': 'wrap', 'text-max-width': '90px', width: '110px', height: '48px', shape: 'round-rectangle', 'background-color': '#1e293b', 'border-color': '#3b82f6', 'border-width': 2 } },
233
+ { selector: 'node[status="planned"]', style: { 'border-color': '#3b82f6' } },
234
+ { selector: 'node[status="parked"]', style: { 'border-color': '#6b7280', opacity: 0.75 } },
235
+ { selector: 'node[status="partial"]', style: { 'border-color': '#f59e0b' } },
236
+ { selector: 'node[priority="high"]', style: { 'border-width': 3 } },
237
+ { selector: 'node[priority="low"]', style: { 'border-width': 1 } },
238
+ { selector: 'node[track="knowledge"]', style: { 'background-color': '#0d2538' } },
239
+ { selector: 'node[track="distribution"]', style: { 'background-color': '#0d2620' } },
240
+ { selector: 'node[track="governance"]', style: { 'background-color': '#1e1630' } },
241
+ { selector: 'node[track="agent"]', style: { 'background-color': '#231d0a' } },
242
+ { selector: 'node[track="worker"]', style: { 'background-color': '#281515' } },
243
+ { selector: 'node[track="platform"]', style: { 'background-color': '#281020' } },
244
+ { selector: 'node[track="developer"]', style: { 'background-color': '#271a0d' } },
245
+ { selector: 'node[track="async"]', style: { 'background-color': '#1a1e24' } },
246
+ { selector: 'node[track="standalone"]', style: { 'background-color': '#1c2030' } },
247
+ { selector: 'edge[type="dep"]', style: { width: 1.5, 'line-color': '#475569', 'target-arrow-color': '#475569', 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9, 'curve-style': 'bezier' } },
248
+ { selector: 'edge[type="concurrent"]', style: { width: 1.5, 'line-color': '#64748b', 'line-style': 'dashed', 'target-arrow-shape': 'none', 'curve-style': 'bezier' } },
249
+ { selector: '.dimmed', style: { opacity: 0.15 } },
250
+ ],
251
+ layout: { name: 'dagre', rankDir: 'LR', nodeSep: 30, rankSep: 70, padding: 20, animate: false, fit: true }
252
+ });
253
+
254
+ // Track panel
255
+ const trackPanel = document.getElementById('track-panel');
256
+ Object.entries(TRACK_COLORS).forEach(([track, color]) => {
257
+ if (!nodes.find(n => n.track === track)) return;
258
+ const div = document.createElement('div');
259
+ div.className = 'track-row';
260
+ div.innerHTML = '<div class="track-swatch" style="background:'+color+'"></div>'+track.charAt(0).toUpperCase()+track.slice(1);
261
+ div.addEventListener('click', () => { cy.fit(cy.nodes('[track="'+track+'"]'), 80); });
262
+ trackPanel.appendChild(div);
263
+ });
264
+
265
+ // Tooltip
266
+ const tt = document.getElementById('tooltip');
267
+ cy.on('mouseover', 'node[status]', evt => {
268
+ const d = evt.target.data();
269
+ const inc = evt.target.incomers('node[status]').map(n=>n.data('id')).join(', ')||'—';
270
+ const out = evt.target.outgoers('node[status]').map(n=>n.data('id')).join(', ')||'—';
271
+ tt.innerHTML = '<div class="tt-id">'+d.id+'</div><div class="tt-name">'+d.name+'</div><div class="tt-deps"><strong>Depends on:</strong> '+inc+'</div><div class="tt-deps"><strong>Unblocks:</strong> '+out+'</div><div class="tt-deps" style="margin-top:6px;color:#94a3b8">'+d.desc+'</div>';
272
+ tt.style.display = 'block';
273
+ });
274
+ cy.on('mousemove', 'node[status]', evt => {
275
+ const pos = evt.renderedPosition || evt.position;
276
+ const off = cy.container().getBoundingClientRect();
277
+ tt.style.left = (off.left+pos.x+14)+'px';
278
+ tt.style.top = (off.top+pos.y-10)+'px';
279
+ });
280
+ cy.on('mouseout', 'node[status]', () => { tt.style.display = 'none'; });
281
+
282
+ // Click to highlight
283
+ cy.on('tap', 'node[status]', evt => {
284
+ cy.elements().removeClass('dimmed');
285
+ const n = evt.target;
286
+ const connected = n.union(n.predecessors('node[status]')).union(n.successors('node[status]'));
287
+ cy.nodes('[status]').not(connected).addClass('dimmed');
288
+ cy.edges().not(connected.edgesWith(connected)).addClass('dimmed');
289
+ });
290
+ cy.on('tap', evt => { if (evt.target === cy) cy.elements().removeClass('dimmed'); });
291
+
292
+ // Filters
293
+ let activeStatus = 'all';
294
+ document.querySelectorAll('[data-status]').forEach(btn => {
295
+ btn.addEventListener('click', () => {
296
+ document.querySelectorAll('[data-status]').forEach(b => b.classList.remove('active'));
297
+ btn.classList.add('active');
298
+ activeStatus = btn.dataset.status;
299
+ cy.nodes('[status]').forEach(n => {
300
+ n.style('display', (activeStatus === 'all' || n.data('status') === activeStatus) ? 'element' : 'none');
301
+ });
302
+ });
303
+ });
304
+
305
+ // Zoom
306
+ document.getElementById('btn-fit').addEventListener('click', () => cy.fit(undefined, 30));
307
+ document.getElementById('btn-in').addEventListener('click', () => cy.zoom({ level: cy.zoom()*1.3, renderedPosition: { x: cy.width()/2, y: cy.height()/2 } }));
308
+ document.getElementById('btn-out').addEventListener('click', () => cy.zoom({ level: cy.zoom()*0.77, renderedPosition: { x: cy.width()/2, y: cy.height()/2 } }));
309
+ setTimeout(() => cy.fit(undefined, 30), 100);
310
+ <\/script>
311
+ </body>
312
+ </html>`;
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Route
317
+ // ---------------------------------------------------------------------------
318
+
319
+ export function attachGraphExportRoutes(app, { store }) {
320
+ // GET /api/export/roadmap-graph — returns generated HTML
321
+ app.get('/api/export/roadmap-graph', (_req, res) => {
322
+ try {
323
+ const html = generateHTML(store);
324
+ res.type('html').send(html);
325
+ } catch (err) {
326
+ res.status(500).json({ error: err.message });
327
+ }
328
+ });
329
+
330
+ // POST /api/export/roadmap-graph/save — writes to project docs
331
+ app.post('/api/export/roadmap-graph/save', (_req, res) => {
332
+ try {
333
+ const html = generateHTML(store);
334
+ const docsDir = path.join(getTargetRoot(), 'docs');
335
+ fs.mkdirSync(docsDir, { recursive: true });
336
+ const outPath = path.join(docsDir, 'roadmap-graph.html');
337
+ fs.writeFileSync(outPath, html, 'utf-8');
338
+ res.json({ ok: true, path: outPath });
339
+ } catch (err) {
340
+ res.status(500).json({ error: err.message });
341
+ }
342
+ });
343
+ }