@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,540 @@
1
+ /**
2
+ * design-routes.js — Design conversation REST + SSE routes.
3
+ *
4
+ * Routes:
5
+ * POST /api/design/start
6
+ * POST /api/design/message
7
+ * GET /api/design/session
8
+ * POST /api/design/complete
9
+ * POST /api/design/revise
10
+ * GET /api/design/stream
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { parseDecisionBlocks } from '../src/components/vision/designSessionState.js';
16
+
17
+ /** @type {Map<string, Set<import('node:http').ServerResponse>>} — key is `${scope}:${featureCode || ''}` */
18
+ export const designListeners = new Map();
19
+
20
+ /** In-flight guard — prevents overlapping agent runs for the same session. */
21
+ const _inFlight = new Set();
22
+
23
+ /**
24
+ * Build a session key for SSE listener scoping.
25
+ * @param {string} scope
26
+ * @param {string|null|undefined} featureCode
27
+ * @returns {string}
28
+ */
29
+ export function sessionKey(scope, featureCode, projectRoot) {
30
+ const project = projectRoot || '';
31
+ return `${project}:${scope || 'product'}:${featureCode || ''}`;
32
+ }
33
+
34
+ /**
35
+ * Broadcast an SSE event to listeners matching a session key.
36
+ * @param {string} key — session key from sessionKey()
37
+ * @param {string} type — event name
38
+ * @param {object} data — JSON-serialisable payload
39
+ */
40
+ export function broadcastDesignEvent(key, type, data) {
41
+ const listeners = designListeners.get(key);
42
+ if (!listeners) return;
43
+ const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
44
+ for (const res of listeners) {
45
+ try {
46
+ res.write(payload);
47
+ } catch {
48
+ listeners.delete(res);
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Dispatch the LLM agent after a human message. Fire-and-forget.
55
+ *
56
+ * Creates a fresh connector per dispatch so concurrent sessions
57
+ * (product + feature) don't block each other.
58
+ *
59
+ * @param {import('./design-session.js').DesignSessionManager} sessionManager
60
+ * @param {string} projectRoot — cwd for the new connector
61
+ * @param {string} scope
62
+ * @param {string|null} featureCode
63
+ */
64
+ async function dispatchDesignAgent(sessionManager, projectRoot, scope, featureCode) {
65
+ const key = sessionKey(scope, featureCode, projectRoot);
66
+ if (_inFlight.has(key)) return; // already running
67
+ _inFlight.add(key);
68
+ // Snapshot message count so we can detect new messages arriving during the run
69
+ let promptMessageCount = 0;
70
+ try {
71
+ const session = sessionManager.getSession(scope, featureCode);
72
+ if (!session) return;
73
+
74
+ promptMessageCount = session.messages.length;
75
+
76
+ // Build formatted conversation history
77
+ const formattedMessages = session.messages
78
+ .map(m => {
79
+ if (m.role === 'human' && m.type === 'text') {
80
+ return `Human: ${m.content}`;
81
+ }
82
+ if (m.role === 'human' && m.type === 'card_select') {
83
+ return `Human: [Selected option "${m.content?.cardId}"${m.content?.comment ? ` — ${m.content.comment}` : ''}]`;
84
+ }
85
+ if (m.role === 'assistant') {
86
+ return `Assistant: ${m.content}`;
87
+ }
88
+ return `${m.role}: ${JSON.stringify(m.content)}`;
89
+ })
90
+ .join('\n\n');
91
+
92
+ const systemPrompt = `You are a product design partner. Your job is to help the human design a product or feature through an interactive conversation.
93
+
94
+ Rules:
95
+ 1. Ask ONE question at a time. Never dump multiple questions.
96
+ 2. When presenting options, use a \`\`\`decision fenced block with this JSON format:
97
+ {"question": "...", "options": [{"id": "A", "title": "...", "bullets": ["...", "..."]}, ...], "recommendation": {"id": "A", "rationale": "..."}}
98
+ 3. Always include a recommendation after presenting options.
99
+ 4. Track the decisions made so far (provided in context) and build on them.
100
+ 5. When you have enough context (typically 5-10 decisions), offer to complete the design document.
101
+ 6. Research the codebase when relevant — announce what you're looking at.
102
+
103
+ Decisions made so far:
104
+ ${JSON.stringify(session.decisions, null, 2)}
105
+
106
+ Conversation history:
107
+ ${formattedMessages}`;
108
+
109
+ let fullContent = '';
110
+ let lastToolName = null;
111
+ let toolUseCounter = 0;
112
+ let lastToolUseId = null;
113
+
114
+ const { ClaudeSDKConnector } = await import('./connectors/claude-sdk-connector.js');
115
+ const connector = new ClaudeSDKConnector({ cwd: projectRoot });
116
+
117
+ for await (const event of connector.run(systemPrompt)) {
118
+ if (event.type === 'assistant' && event.content) {
119
+ fullContent += event.content;
120
+ broadcastDesignEvent(key, 'text', { content: event.content });
121
+ } else if (event.type === 'result' && event.content) {
122
+ // Final aggregated text — use it if we haven't accumulated anything
123
+ if (!fullContent) {
124
+ fullContent = event.content;
125
+ broadcastDesignEvent(key, 'text', { content: event.content });
126
+ }
127
+ } else if (event.type === 'error') {
128
+ broadcastDesignEvent(key, 'error', { message: event.message });
129
+ return;
130
+ } else if (event.type === 'tool_use') {
131
+ lastToolName = event.tool;
132
+ lastToolUseId = `tu-${++toolUseCounter}`;
133
+ broadcastDesignEvent(key, 'research', {
134
+ id: lastToolUseId,
135
+ tool: event.tool,
136
+ input: event.input,
137
+ timestamp: new Date().toISOString(),
138
+ });
139
+ } else if (event.type === 'tool_use_summary') {
140
+ broadcastDesignEvent(key, 'research_result', {
141
+ id: lastToolUseId,
142
+ tool: lastToolName,
143
+ summary: (event.summary || '').slice(0, 200),
144
+ timestamp: new Date().toISOString(),
145
+ });
146
+ }
147
+ // Ignore other system init/complete events
148
+ }
149
+
150
+ // Parse for decision blocks and broadcast them
151
+ if (fullContent) {
152
+ const { parts } = parseDecisionBlocks(fullContent);
153
+ for (const part of parts) {
154
+ if (part.type === 'decision') {
155
+ broadcastDesignEvent(key, 'decision', part.content);
156
+ }
157
+ }
158
+
159
+ // Append assistant message to session
160
+ sessionManager.appendMessage(scope, featureCode, {
161
+ role: 'assistant',
162
+ type: 'text',
163
+ content: fullContent,
164
+ timestamp: new Date().toISOString(),
165
+ });
166
+ }
167
+
168
+ broadcastDesignEvent(key, 'done', {});
169
+ } catch (err) {
170
+ broadcastDesignEvent(key, 'error', { message: err.message || String(err) });
171
+ } finally {
172
+ _inFlight.delete(key);
173
+ // Check for human messages that arrived during the run (after our prompt snapshot)
174
+ const updated = sessionManager.getSession(scope, featureCode);
175
+ if (updated) {
176
+ const newMessages = updated.messages.slice(promptMessageCount);
177
+ const hasNewHumanMessage = newMessages.some(m => m.role === 'human');
178
+ if (hasNewHumanMessage) {
179
+ // Re-dispatch to answer the queued message(s)
180
+ dispatchDesignAgent(sessionManager, projectRoot, scope, featureCode);
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Attach design conversation routes to an Express app.
188
+ *
189
+ * Accepts getter functions so that deps resolve dynamically per-request,
190
+ * surviving project switches via /api/project/switch.
191
+ *
192
+ * @param {object} app — Express app
193
+ * @param {{ getSessionManager: () => import('./design-session.js').DesignSessionManager, getConnector: () => import('./connectors/claude-sdk-connector.js').ClaudeSDKConnector|null, getProjectRoot: () => string }} deps
194
+ */
195
+ export function attachDesignRoutes(app, { getSessionManager, getConnector, getProjectRoot }) {
196
+ // POST /api/design/start
197
+ app.post('/api/design/start', (req, res) => {
198
+ const { scope, featureCode } = req.body || {};
199
+ if (!scope || !['product', 'feature'].includes(scope)) {
200
+ return res.status(400).json({ error: 'scope must be "product" or "feature"' });
201
+ }
202
+ if (scope === 'feature' && !featureCode) {
203
+ return res.status(400).json({ error: 'featureCode required for feature scope' });
204
+ }
205
+ try {
206
+ const sessionManager = getSessionManager();
207
+ const session = sessionManager.startSession(scope, featureCode);
208
+ res.json({ session });
209
+ } catch (err) {
210
+ if (err.message.includes('already active')) {
211
+ return res.status(409).json({ error: err.message });
212
+ }
213
+ res.status(400).json({ error: err.message });
214
+ }
215
+ });
216
+
217
+ // POST /api/design/message
218
+ app.post('/api/design/message', (req, res) => {
219
+ const { scope, featureCode, type, content, cardId, comment, messageIndex } = req.body || {};
220
+ if (!scope || !['product', 'feature'].includes(scope)) {
221
+ return res.status(400).json({ error: 'scope must be "product" or "feature"' });
222
+ }
223
+ if (!type || !['text', 'card_select'].includes(type)) {
224
+ return res.status(400).json({ error: 'type must be "text" or "card_select"' });
225
+ }
226
+ try {
227
+ const sessionManager = getSessionManager();
228
+ const projectRoot = getProjectRoot();
229
+ const existingSession = sessionManager.getSession(scope, featureCode);
230
+ if (!existingSession) {
231
+ return res.status(404).json({ error: 'No session found' });
232
+ }
233
+ if (existingSession.status === 'complete') {
234
+ return res.status(409).json({ error: 'Session is complete. Start a new session to continue.' });
235
+ }
236
+ const key = sessionKey(scope, featureCode, projectRoot);
237
+ const timestamp = new Date().toISOString();
238
+ let session;
239
+ if (type === 'text') {
240
+ session = sessionManager.appendMessage(scope, featureCode, {
241
+ role: 'human',
242
+ type: 'text',
243
+ content,
244
+ timestamp,
245
+ });
246
+ } else {
247
+ // card_select — extract question and full card from the originating decision block
248
+ let question = 'pending';
249
+ let card = { id: cardId };
250
+
251
+ const currentSession = sessionManager.getSession(scope, featureCode);
252
+ if (currentSession) {
253
+ // If messageIndex is provided, look up that exact message (avoids ID collisions)
254
+ // Otherwise fall back to scanning backward for the last decision block
255
+ const searchStart = typeof messageIndex === 'number' && messageIndex >= 0 && messageIndex < currentSession.messages.length
256
+ ? messageIndex
257
+ : null;
258
+
259
+ const scanMessages = searchStart !== null
260
+ ? [currentSession.messages[searchStart]]
261
+ : [...currentSession.messages].reverse();
262
+
263
+ for (const msg of scanMessages) {
264
+ if (msg.role === 'assistant' && typeof msg.content === 'string') {
265
+ const { parts } = parseDecisionBlocks(msg.content);
266
+ const decisionPart = parts.find(p => p.type === 'decision');
267
+ if (decisionPart) {
268
+ const block = decisionPart.content;
269
+ if (block.question) {
270
+ question = block.question;
271
+ }
272
+ if (Array.isArray(block.options)) {
273
+ const matched = block.options.find(o => o.id === cardId);
274
+ if (matched) {
275
+ card = matched;
276
+ }
277
+ }
278
+ break;
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ sessionManager.recordDecision(scope, featureCode, question, card, comment);
285
+ session = sessionManager.appendMessage(scope, featureCode, {
286
+ role: 'human',
287
+ type: 'card_select',
288
+ content: { cardId, comment },
289
+ timestamp,
290
+ });
291
+ }
292
+ broadcastDesignEvent(key, 'ack', { messageCount: session.messages.length });
293
+ res.json({ session });
294
+
295
+ // Fire-and-forget LLM dispatch (don't block the HTTP response)
296
+ if (projectRoot) {
297
+ dispatchDesignAgent(sessionManager, projectRoot, scope, featureCode);
298
+ }
299
+ } catch (err) {
300
+ if (err.message.includes('No session found')) {
301
+ return res.status(404).json({ error: err.message });
302
+ }
303
+ res.status(400).json({ error: err.message });
304
+ }
305
+ });
306
+
307
+ // GET /api/design/session
308
+ app.get('/api/design/session', (req, res) => {
309
+ const { scope, featureCode } = req.query;
310
+ try {
311
+ const sessionManager = getSessionManager();
312
+ const session = sessionManager.getSession(scope || 'product', featureCode);
313
+ res.json({ session });
314
+ } catch (err) {
315
+ res.status(400).json({ error: err.message });
316
+ }
317
+ });
318
+
319
+ // POST /api/design/complete — generate design doc and mark session complete
320
+ app.post('/api/design/complete', async (req, res) => {
321
+ const { scope, featureCode, draftDoc } = req.body || {};
322
+ try {
323
+ const sessionManager = getSessionManager();
324
+ const projectRoot = getProjectRoot();
325
+ const key = sessionKey(scope, featureCode, projectRoot);
326
+ const session = sessionManager.getSession(scope, featureCode);
327
+ if (!session) {
328
+ return res.status(404).json({ error: `No session found for ${scope}${featureCode ? `:${featureCode}` : ''}` });
329
+ }
330
+
331
+ // If no connector available or no projectRoot, mark complete and skip doc generation
332
+ if (!getConnector() || !projectRoot) {
333
+ const completedSession = sessionManager.completeSession(scope, featureCode);
334
+ res.json({ session: completedSession });
335
+ return;
336
+ }
337
+
338
+ // Build the design doc generation prompt
339
+ const activeDecisions = session.decisions.filter(d => !d.superseded);
340
+ const formattedMessages = session.messages
341
+ .map(m => {
342
+ if (m.role === 'human' && m.type === 'text') return `Human: ${m.content}`;
343
+ if (m.role === 'human' && m.type === 'card_select') {
344
+ return `Human: [Selected option "${m.content?.cardId}"${m.content?.comment ? ` — ${m.content.comment}` : ''}]`;
345
+ }
346
+ if (m.role === 'assistant') return `Assistant: ${m.content}`;
347
+ return `${m.role}: ${JSON.stringify(m.content)}`;
348
+ })
349
+ .join('\n\n');
350
+
351
+ const decisionsJson = JSON.stringify(activeDecisions, null, 2);
352
+ const docPrompt = draftDoc
353
+ ? `Polish and finalize this draft design document. Improve structure, add transitions, ensure completeness.
354
+
355
+ Draft:
356
+ ${draftDoc}
357
+
358
+ Decisions:
359
+ ${decisionsJson}
360
+
361
+ Conversation:
362
+ ${formattedMessages}
363
+
364
+ Output ONLY the Markdown content, no code fences.`
365
+ : `Based on the following design conversation, write a comprehensive design document.
366
+
367
+ Decisions made:
368
+ ${decisionsJson}
369
+
370
+ Conversation history:
371
+ ${formattedMessages}
372
+
373
+ Write the design document in Markdown format. Include:
374
+ - Problem statement (from early conversation)
375
+ - Key decisions with rationale
376
+ - Architecture approach
377
+ - Open questions (if any remain)
378
+ - Recommended next steps
379
+
380
+ Output ONLY the Markdown content, no code fences.`;
381
+
382
+ // Generate the design doc via a fresh connector
383
+ const { ClaudeSDKConnector } = await import('./connectors/claude-sdk-connector.js');
384
+ const connector = new ClaudeSDKConnector({ cwd: projectRoot });
385
+ let docContent = '';
386
+ try {
387
+ for await (const event of connector.run(docPrompt)) {
388
+ if (event.type === 'assistant' && event.content) {
389
+ docContent += event.content;
390
+ } else if (event.type === 'result' && event.content && !docContent) {
391
+ docContent = event.content;
392
+ }
393
+ }
394
+ } catch (err) {
395
+ // If generation fails, don't complete — let user retry
396
+ console.error('[design] Doc generation failed:', err.message);
397
+ res.status(500).json({ error: `Doc generation failed: ${err.message}` });
398
+ return;
399
+ }
400
+
401
+ // Determine the output path
402
+ let designDocPath;
403
+ if (scope === 'feature' && featureCode) {
404
+ designDocPath = path.join('docs', 'features', featureCode, 'design.md');
405
+ } else {
406
+ designDocPath = path.join('docs', 'design.md');
407
+ }
408
+
409
+ // Guard: never overwrite an existing doc with empty content
410
+ const absPath = path.join(projectRoot, designDocPath);
411
+ if (!docContent.trim()) {
412
+ console.error('[design] Generated doc is empty — refusing to overwrite');
413
+ res.status(500).json({ error: 'Doc generation produced empty content' });
414
+ return;
415
+ }
416
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
417
+ fs.writeFileSync(absPath, docContent, 'utf-8');
418
+
419
+ // Mark session complete only after doc successfully written
420
+ const completedSession = sessionManager.completeSession(scope, featureCode);
421
+
422
+ broadcastDesignEvent(key, 'complete', { designDocPath });
423
+
424
+ // COMP-UX-2b: Create lifecycle gate after design completion
425
+ if (scope === 'feature' && featureCode) {
426
+ try {
427
+ const apiBase = `http://127.0.0.1:${process.env.PORT || 4001}`;
428
+ // Look up the vision item for this featureCode
429
+ const itemsRes = await fetch(`${apiBase}/api/vision/items?keyword=${encodeURIComponent(featureCode)}`);
430
+ const itemsData = await itemsRes.json();
431
+ const featureItem = itemsData.items?.find(i =>
432
+ i.title?.startsWith(featureCode) || i.lifecycle?.featureCode === featureCode || i.featureCode === featureCode
433
+ );
434
+ await fetch(`${apiBase}/api/vision/gates`, {
435
+ method: 'POST',
436
+ headers: { 'Content-Type': 'application/json' },
437
+ body: JSON.stringify({
438
+ flowId: `design-${featureCode}`,
439
+ stepId: 'design_gate',
440
+ round: 1,
441
+ itemId: featureItem?.id || null,
442
+ artifact: designDocPath,
443
+ fromPhase: 'explore_design',
444
+ toPhase: 'prd',
445
+ summary: `Design doc generated for ${featureCode}. Approve to advance.`,
446
+ policyMode: 'gate',
447
+ }),
448
+ });
449
+ } catch (gateErr) {
450
+ console.warn('[design] Gate creation failed (non-fatal):', gateErr.message);
451
+ }
452
+ }
453
+
454
+ res.json({ session: completedSession, designDocPath });
455
+ } catch (err) {
456
+ if (err.message.includes('No session found')) {
457
+ return res.status(404).json({ error: err.message });
458
+ }
459
+ res.status(400).json({ error: err.message });
460
+ }
461
+ });
462
+
463
+ // POST /api/design/revise — mark a decision as superseded and re-ask
464
+ app.post('/api/design/revise', (req, res) => {
465
+ const { scope, featureCode, decisionIndex } = req.body || {};
466
+ if (!scope || !['product', 'feature'].includes(scope)) {
467
+ return res.status(400).json({ error: 'scope must be "product" or "feature"' });
468
+ }
469
+ if (typeof decisionIndex !== 'number' || decisionIndex < 0) {
470
+ return res.status(400).json({ error: 'decisionIndex must be a non-negative number' });
471
+ }
472
+ try {
473
+ const sessionManager = getSessionManager();
474
+ const existingSession = sessionManager.getSession(scope, featureCode);
475
+ if (!existingSession) {
476
+ return res.status(404).json({ error: 'No session found' });
477
+ }
478
+ if (existingSession.status === 'complete') {
479
+ return res.status(409).json({ error: 'Cannot revise a completed session' });
480
+ }
481
+ const projectRoot = getProjectRoot();
482
+ const key = sessionKey(scope, featureCode, projectRoot);
483
+ const decision = existingSession.decisions[decisionIndex];
484
+ const session = sessionManager.reviseDecision(scope, featureCode, decisionIndex);
485
+ // Atomically append the re-ask message so revision + message are one operation
486
+ const reaskContent = `I want to revise my decision on "${decision?.question || 'this question'}". I previously chose "${decision?.selectedOption?.title || decision?.selectedOption?.id || 'an option'}". Let me reconsider.`;
487
+ sessionManager.appendMessage(scope, featureCode, {
488
+ role: 'human',
489
+ type: 'text',
490
+ content: reaskContent,
491
+ timestamp: new Date().toISOString(),
492
+ });
493
+ broadcastDesignEvent(key, 'revision', { decisionIndex });
494
+ // Dispatch agent to re-ask the question
495
+ if (projectRoot) {
496
+ dispatchDesignAgent(sessionManager, projectRoot, scope, featureCode);
497
+ }
498
+ res.json({ session: sessionManager.getSession(scope, featureCode) });
499
+ } catch (err) {
500
+ if (err.message.includes('No session found')) {
501
+ return res.status(404).json({ error: err.message });
502
+ }
503
+ res.status(400).json({ error: err.message });
504
+ }
505
+ });
506
+
507
+ // GET /api/design/stream — SSE endpoint (scoped by session key)
508
+ app.get('/api/design/stream', (req, res) => {
509
+ const { scope, featureCode } = req.query;
510
+ const key = sessionKey(scope, featureCode, getProjectRoot());
511
+
512
+ res.setHeader('Content-Type', 'text/event-stream');
513
+ res.setHeader('Cache-Control', 'no-cache');
514
+ res.setHeader('Connection', 'keep-alive');
515
+ res.flushHeaders();
516
+
517
+ if (!designListeners.has(key)) {
518
+ designListeners.set(key, new Set());
519
+ }
520
+ designListeners.get(key).add(res);
521
+
522
+ const heartbeat = setInterval(() => {
523
+ try {
524
+ res.write(':keepalive\n\n');
525
+ } catch {
526
+ clearInterval(heartbeat);
527
+ designListeners.get(key)?.delete(res);
528
+ }
529
+ }, 30_000);
530
+
531
+ req.on('close', () => {
532
+ clearInterval(heartbeat);
533
+ const listeners = designListeners.get(key);
534
+ if (listeners) {
535
+ listeners.delete(res);
536
+ if (listeners.size === 0) designListeners.delete(key);
537
+ }
538
+ });
539
+ });
540
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * design-session.js — Manages design conversation sessions for product and feature scopes.
3
+ */
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+
8
+ const FILENAME = 'design-sessions.json';
9
+
10
+ export class DesignSessionManager {
11
+ constructor(dataDir) {
12
+ this._dataDir = dataDir;
13
+ this._saveTimer = null;
14
+ this._sessions = { product: null, features: Object.create(null) };
15
+
16
+ try {
17
+ const raw = readFileSync(join(dataDir, FILENAME), 'utf-8');
18
+ if (raw) {
19
+ const parsed = JSON.parse(raw);
20
+ if (parsed && typeof parsed === 'object') {
21
+ this._sessions = parsed;
22
+ // Ensure features map uses null prototype to prevent prototype pollution
23
+ if (parsed.features) {
24
+ this._sessions.features = Object.create(null);
25
+ for (const [k, v] of Object.entries(parsed.features)) {
26
+ this._sessions.features[k] = v;
27
+ }
28
+ }
29
+ }
30
+ }
31
+ } catch {
32
+ // No file or corrupt — start fresh
33
+ }
34
+ }
35
+
36
+ _validateFeatureCode(code) {
37
+ if (!code || typeof code !== 'string') throw new Error('featureCode is required');
38
+ if (/^(__proto__|constructor|prototype|toString|valueOf|hasOwnProperty)$/.test(code)) {
39
+ throw new Error(`Invalid featureCode: "${code}"`);
40
+ }
41
+ // Also validate format — should be alphanumeric with hyphens
42
+ if (!/^[A-Za-z0-9][\w-]*$/.test(code)) {
43
+ throw new Error(`Invalid featureCode format: "${code}"`);
44
+ }
45
+ }
46
+
47
+ getSession(scope, featureCode = null) {
48
+ if (scope === 'product') {
49
+ return this._sessions.product || null;
50
+ }
51
+ this._validateFeatureCode(featureCode);
52
+ return this._sessions.features[featureCode] || null;
53
+ }
54
+
55
+ startSession(scope, featureCode = null) {
56
+ const existing = this.getSession(scope, featureCode);
57
+ if (existing && existing.status === 'active') {
58
+ throw new Error(`Session already active for ${scope}${featureCode ? `:${featureCode}` : ''}`);
59
+ }
60
+
61
+ const session = {
62
+ id: randomUUID(),
63
+ scope,
64
+ featureCode: featureCode || null,
65
+ messages: [],
66
+ decisions: [],
67
+ status: 'active',
68
+ createdAt: new Date().toISOString(),
69
+ };
70
+
71
+ if (scope === 'product') {
72
+ this._sessions.product = session;
73
+ } else {
74
+ this._sessions.features[featureCode] = session;
75
+ }
76
+
77
+ this._saveNow();
78
+ return session;
79
+ }
80
+
81
+ appendMessage(scope, featureCode, message) {
82
+ const session = this._getSessionOrThrow(scope, featureCode);
83
+ session.messages.push({
84
+ ...message,
85
+ timestamp: message.timestamp || new Date().toISOString(),
86
+ });
87
+ this._scheduleSave();
88
+ return session;
89
+ }
90
+
91
+ recordDecision(scope, featureCode, question, card, comment = null) {
92
+ const session = this._getSessionOrThrow(scope, featureCode);
93
+ session.decisions.push({
94
+ question,
95
+ selectedOption: card,
96
+ comment,
97
+ timestamp: new Date().toISOString(),
98
+ superseded: false,
99
+ });
100
+ this._scheduleSave();
101
+ return session;
102
+ }
103
+
104
+ reviseDecision(scope, featureCode, decisionIndex) {
105
+ const session = this._getSessionOrThrow(scope, featureCode);
106
+ if (decisionIndex >= session.decisions.length) {
107
+ throw new Error(`decisionIndex ${decisionIndex} out of range (${session.decisions.length} decisions)`);
108
+ }
109
+ session.decisions[decisionIndex].superseded = true;
110
+ this._scheduleSave();
111
+ return session;
112
+ }
113
+
114
+ completeSession(scope, featureCode = null) {
115
+ const session = this._getSessionOrThrow(scope, featureCode);
116
+ session.status = 'complete';
117
+ this._saveNow();
118
+ return session;
119
+ }
120
+
121
+ _getSessionOrThrow(scope, featureCode) {
122
+ const session = this.getSession(scope, featureCode);
123
+ if (!session) {
124
+ throw new Error(`No session found for ${scope}${featureCode ? `:${featureCode}` : ''}`);
125
+ }
126
+ return session;
127
+ }
128
+
129
+ _scheduleSave() {
130
+ if (this._saveTimer) return;
131
+ this._saveTimer = setTimeout(() => {
132
+ this._saveTimer = null;
133
+ this._saveNow();
134
+ }, 500);
135
+ }
136
+
137
+ _saveNow() {
138
+ try {
139
+ mkdirSync(this._dataDir, { recursive: true });
140
+ const data = {
141
+ product: this._sessions.product,
142
+ features: { ...this._sessions.features },
143
+ };
144
+ writeFileSync(
145
+ join(this._dataDir, FILENAME),
146
+ JSON.stringify(data, null, 2),
147
+ 'utf-8',
148
+ );
149
+ } catch {
150
+ // Best-effort persistence
151
+ }
152
+ }
153
+
154
+ destroy() {
155
+ if (this._saveTimer) {
156
+ clearTimeout(this._saveTimer);
157
+ this._saveTimer = null;
158
+ this._saveNow();
159
+ }
160
+ }
161
+ }