@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,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
|
+
}
|