@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.
- package/LICENSE +21 -0
- package/README.md +1014 -0
- package/bin/compose.js +1515 -0
- package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
- package/dist/assets/arc-SxJ2J1sh.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
- package/dist/assets/channel-DGElom1e.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
- package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
- package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
- package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
- package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
- package/dist/assets/clone-DUJKJXd7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
- package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
- package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
- package/dist/assets/graph-D0Cfv00Y.js +1 -0
- package/dist/assets/index-CUd6pFGF.css +1 -0
- package/dist/assets/index-DReRlzZI.js +1144 -0
- package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-Bj72wOEB.js +1 -0
- package/dist/assets/linear-BRFo114D.js +1 -0
- package/dist/assets/min-GCHnKlJS.js +1 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
- package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
- package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
- package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
- package/dist/index.html +30 -0
- package/lib/agent-chains.js +65 -0
- package/lib/agent-string.js +86 -0
- package/lib/budget-ledger.js +86 -0
- package/lib/build-all.js +162 -0
- package/lib/build-dag.js +120 -0
- package/lib/build-stream-writer.js +190 -0
- package/lib/build.js +2997 -0
- package/lib/capability-checker.js +53 -0
- package/lib/cert-inject.js +38 -0
- package/lib/cli-progress.js +483 -0
- package/lib/constants.js +69 -0
- package/lib/cross-layer-audit.js +84 -0
- package/lib/debug-discipline.js +173 -0
- package/lib/feature-json.js +106 -0
- package/lib/gate-prompt.js +291 -0
- package/lib/gate-tiers.js +194 -0
- package/lib/health-history.js +119 -0
- package/lib/health-score.js +227 -0
- package/lib/ideabox.js +570 -0
- package/lib/import.js +244 -0
- package/lib/migrate-roadmap.js +94 -0
- package/lib/model-pricing.js +67 -0
- package/lib/new.js +413 -0
- package/lib/pipeline-cli.js +489 -0
- package/lib/plan-parser.js +103 -0
- package/lib/qa-scoping.js +474 -0
- package/lib/questionnaire.js +200 -0
- package/lib/resolve-port.js +7 -0
- package/lib/result-normalizer.js +349 -0
- package/lib/review-lenses.js +166 -0
- package/lib/roadmap-gen.js +210 -0
- package/lib/roadmap-parser.js +176 -0
- package/lib/server-probe.js +23 -0
- package/lib/staleness.js +87 -0
- package/lib/step-prompt.js +260 -0
- package/lib/step-validator.js +49 -0
- package/lib/stratum-mcp-client.js +365 -0
- package/lib/team-flag.js +46 -0
- package/lib/test-bootstrap.js +401 -0
- package/lib/triage.js +274 -0
- package/lib/vision-writer.js +391 -0
- package/package.json +111 -0
- package/pipelines/bug-fix.stratum.yaml +230 -0
- package/pipelines/build.stratum.yaml +498 -0
- package/pipelines/content.stratum.yaml +112 -0
- package/pipelines/coverage-sweep.stratum.yaml +52 -0
- package/pipelines/refactor.stratum.yaml +169 -0
- package/pipelines/research.stratum.yaml +88 -0
- package/pipelines/review-fix.stratum.yaml +109 -0
- package/presets/team-feature.stratum.yaml +105 -0
- package/presets/team-research.stratum.yaml +108 -0
- package/presets/team-review.stratum.yaml +106 -0
- package/scripts/agent-activity-hook.sh +31 -0
- package/scripts/agent-error-hook.sh +28 -0
- package/scripts/analyze-orphans.mjs +50 -0
- package/scripts/find-orphans.mjs +26 -0
- package/scripts/fix-phases.mjs +49 -0
- package/scripts/generate-stratum-spec.mjs +137 -0
- package/scripts/import-roadmap.mjs +116 -0
- package/scripts/phase-audit.mjs +33 -0
- package/scripts/run-pipeline.mjs +314 -0
- package/scripts/session-end-hook.sh +18 -0
- package/scripts/session-start-hook.sh +38 -0
- package/scripts/vision-hook.sh +104 -0
- package/scripts/vision-track.mjs +554 -0
- package/scripts/wire-all-orphans.mjs +108 -0
- package/scripts/wire-orphans.mjs +164 -0
- package/server/activity-routes.js +123 -0
- package/server/agent-health.js +197 -0
- package/server/agent-hooks.js +102 -0
- package/server/agent-mcp.js +10 -0
- package/server/agent-registry.js +95 -0
- package/server/agent-server.js +290 -0
- package/server/agent-spawn.js +251 -0
- package/server/agent-templates.js +77 -0
- package/server/artifact-manager.js +247 -0
- package/server/artifact-templates/architecture.md +28 -0
- package/server/artifact-templates/blueprint.md +21 -0
- package/server/artifact-templates/design.md +36 -0
- package/server/artifact-templates/plan.md +25 -0
- package/server/artifact-templates/prd.md +43 -0
- package/server/artifact-templates/report.md +40 -0
- package/server/block-tracker.js +90 -0
- package/server/build-stream-bridge.js +502 -0
- package/server/coalescing-buffer.js +46 -0
- package/server/compose-mcp-tools.js +479 -0
- package/server/compose-mcp.js +324 -0
- package/server/connectors/agent-connector.js +78 -0
- package/server/connectors/claude-sdk-connector.js +198 -0
- package/server/connectors/codex-connector.js +240 -0
- package/server/connectors/connector-discovery.js +18 -0
- package/server/connectors/connector-runtime.js +13 -0
- package/server/connectors/opencode-connector.js +200 -0
- package/server/design-routes.js +540 -0
- package/server/design-session.js +161 -0
- package/server/feature-scan.js +593 -0
- package/server/file-watcher.js +284 -0
- package/server/find-root.js +29 -0
- package/server/graph-export.js +343 -0
- package/server/ideabox-cache.js +77 -0
- package/server/ideabox-routes.js +294 -0
- package/server/index.js +156 -0
- package/server/model-tiers.js +49 -0
- package/server/pipeline-routes.js +288 -0
- package/server/policy-evaluator.js +36 -0
- package/server/project-root.js +122 -0
- package/server/security.js +23 -0
- package/server/session-manager.js +403 -0
- package/server/session-routes.js +190 -0
- package/server/session-store.js +107 -0
- package/server/settings-routes.js +35 -0
- package/server/settings-store.js +234 -0
- package/server/stratum-api.js +102 -0
- package/server/stratum-client.js +192 -0
- package/server/stratum-sync.js +193 -0
- package/server/summarizer.js +139 -0
- package/server/supervisor.js +196 -0
- package/server/vision-routes.js +668 -0
- package/server/vision-server.js +393 -0
- package/server/vision-store.js +360 -0
- package/server/vision-utils.js +179 -0
- package/server/worktree-gc.js +137 -0
- 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
|
+
}
|