@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,668 @@
1
+ /**
2
+ * vision-routes.js — Vision CRUD + plan/parse routes.
3
+ *
4
+ * Routes:
5
+ * GET /api/vision/items
6
+ * POST /api/vision/items
7
+ * PATCH /api/vision/items/:id
8
+ * DELETE /api/vision/items/:id
9
+ * GET /api/vision/items/:id
10
+ * POST /api/vision/connections
11
+ * DELETE /api/vision/connections/:id
12
+ * GET /api/vision/summary
13
+ * GET /api/vision/blocked
14
+ * POST /api/vision/ui
15
+ * POST /api/plan/parse
16
+ * GET /api/vision/items/:id/lifecycle
17
+ * POST /api/vision/items/:id/lifecycle/start
18
+ * POST /api/vision/items/:id/lifecycle/advance
19
+ * POST /api/vision/items/:id/lifecycle/skip
20
+ * POST /api/vision/items/:id/lifecycle/kill
21
+ * POST /api/vision/items/:id/lifecycle/complete
22
+ * GET /api/vision/gates
23
+ * GET /api/vision/gates/:id
24
+ * POST /api/vision/gates/:id/resolve
25
+ * GET /api/vision/items/:id/artifacts
26
+ * POST /api/vision/items/:id/artifacts/scaffold
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { extractFilePaths } from './vision-utils.js';
33
+ import { ArtifactManager } from './artifact-manager.js';
34
+ import { recordIteration, checkCumulativeBudget } from '../lib/budget-ledger.js';
35
+
36
+ import { getTargetRoot, resolveProjectPath, loadProjectConfig } from './project-root.js';
37
+
38
+ const PROJECT_ROOT = getTargetRoot();
39
+
40
+ /**
41
+ * Attach vision CRUD and plan/parse REST routes to an Express app.
42
+ *
43
+ * @param {object} app — Express app
44
+ * @param {{ store: object, scheduleBroadcast: function, broadcastMessage: function, projectRoot: string }} deps
45
+ */
46
+ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMessage, projectRoot = PROJECT_ROOT, settingsStore }) {
47
+ // GET /api/vision/items — full state (optional ?group= filter)
48
+ app.get('/api/vision/items', (req, res) => {
49
+ let state = store.getState();
50
+ if (req.query.group) {
51
+ state = { ...state, items: state.items.filter(i => i.group === req.query.group) };
52
+ }
53
+ res.json(state);
54
+ });
55
+
56
+ // POST /api/vision/items — create item
57
+ app.post('/api/vision/items', (req, res) => {
58
+ try {
59
+ const item = store.createItem(req.body);
60
+ scheduleBroadcast();
61
+ res.status(201).json(item);
62
+ } catch (err) {
63
+ res.status(400).json({ error: err.message });
64
+ }
65
+ });
66
+
67
+ // PATCH /api/vision/items/:id — update item
68
+ app.patch('/api/vision/items/:id', (req, res) => {
69
+ try {
70
+ const item = store.updateItem(req.params.id, req.body);
71
+ scheduleBroadcast();
72
+ res.json(item);
73
+ } catch (err) {
74
+ const status = err.message.includes('not found') ? 404 : 400;
75
+ res.status(status).json({ error: err.message });
76
+ }
77
+ });
78
+
79
+ // DELETE /api/vision/items/:id — delete item + connections
80
+ app.delete('/api/vision/items/:id', (req, res) => {
81
+ try {
82
+ store.deleteItem(req.params.id);
83
+ scheduleBroadcast();
84
+ res.json({ ok: true });
85
+ } catch (err) {
86
+ res.status(404).json({ error: err.message });
87
+ }
88
+ });
89
+
90
+ // POST /api/vision/connections — create connection
91
+ app.post('/api/vision/connections', (req, res) => {
92
+ try {
93
+ const conn = store.createConnection(req.body);
94
+ scheduleBroadcast();
95
+ res.status(201).json(conn);
96
+ } catch (err) {
97
+ res.status(400).json({ error: err.message });
98
+ }
99
+ });
100
+
101
+ // DELETE /api/vision/connections/:id — delete connection
102
+ app.delete('/api/vision/connections/:id', (req, res) => {
103
+ try {
104
+ store.deleteConnection(req.params.id);
105
+ scheduleBroadcast();
106
+ res.json({ ok: true });
107
+ } catch (err) {
108
+ res.status(404).json({ error: err.message });
109
+ }
110
+ });
111
+
112
+ // GET /api/vision/items/:id — get single item by ID
113
+ app.get('/api/vision/items/:id', (req, res) => {
114
+ const items = store.getState().items;
115
+ const item = items.find(i => i.id === req.params.id);
116
+ if (!item) {
117
+ return res.status(404).json({ error: `Item not found: ${req.params.id}` });
118
+ }
119
+ const connections = store.getState().connections.filter(
120
+ c => c.fromId === req.params.id || c.toId === req.params.id
121
+ );
122
+ res.json({ ...item, connections });
123
+ });
124
+
125
+ // ── Lifecycle endpoints (simplified — no state machine) ──────────────
126
+ const featuresPath = projectRoot !== PROJECT_ROOT
127
+ ? path.join(projectRoot, loadProjectConfig().paths?.features || 'docs/features')
128
+ : resolveProjectPath('features');
129
+
130
+ const TRANSITIONS = {
131
+ explore_design: ['prd', 'architecture', 'blueprint'],
132
+ prd: ['architecture', 'blueprint'],
133
+ architecture: ['blueprint'],
134
+ blueprint: ['verification'],
135
+ verification: ['plan', 'blueprint'],
136
+ plan: ['execute'],
137
+ execute: ['report', 'docs'],
138
+ report: ['docs'],
139
+ docs: ['ship'],
140
+ ship: [],
141
+ };
142
+ const SKIPPABLE = new Set(['prd', 'architecture', 'report']);
143
+ const ITERATION_TYPES = new Set(['review', 'coverage']);
144
+ const TERMINAL = new Set(['complete', 'killed']);
145
+
146
+ app.get('/api/vision/items/:id/lifecycle', (req, res) => {
147
+ try {
148
+ const item = store.items.get(req.params.id);
149
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
150
+ if (!item.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
151
+ res.json(item.lifecycle);
152
+ } catch (err) {
153
+ res.status(500).json({ error: err.message });
154
+ }
155
+ });
156
+
157
+ app.post('/api/vision/items/:id/lifecycle/start', (req, res) => {
158
+ try {
159
+ const { featureCode } = req.body;
160
+ if (!featureCode) return res.status(400).json({ error: 'featureCode is required' });
161
+ const item = store.items.get(req.params.id);
162
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
163
+ if (item.lifecycle) return res.status(400).json({ error: `Item ${req.params.id} already has a lifecycle` });
164
+
165
+ const now = new Date().toISOString();
166
+ const lifecycle = {
167
+ currentPhase: 'explore_design',
168
+ featureCode,
169
+ startedAt: now,
170
+ completedAt: null,
171
+ killedAt: null,
172
+ killReason: null,
173
+ };
174
+ store.updateLifecycle(req.params.id, lifecycle);
175
+ scheduleBroadcast();
176
+ broadcastMessage({ type: 'lifecycleStarted', itemId: req.params.id, phase: 'explore_design', featureCode, timestamp: now });
177
+ res.json(lifecycle);
178
+ } catch (err) {
179
+ const status = err.message.includes('not found') ? 404 : 400;
180
+ res.status(status).json({ error: err.message });
181
+ }
182
+ });
183
+
184
+ app.post('/api/vision/items/:id/lifecycle/advance', (req, res) => {
185
+ try {
186
+ const { targetPhase, outcome } = req.body;
187
+ const item = store.items.get(req.params.id);
188
+ if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
189
+ const from = item.lifecycle.currentPhase;
190
+ if (TERMINAL.has(from)) return res.status(400).json({ error: `Cannot advance from terminal state: ${from}` });
191
+ const valid = TRANSITIONS[from];
192
+ if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
193
+
194
+ item.lifecycle.currentPhase = targetPhase;
195
+ store.updateLifecycle(req.params.id, item.lifecycle);
196
+ const now = new Date().toISOString();
197
+ scheduleBroadcast();
198
+ broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome, timestamp: now });
199
+ res.json({ from, to: targetPhase, outcome });
200
+ } catch (err) {
201
+ const status = err.message.includes('not found') ? 404 : 400;
202
+ res.status(status).json({ error: err.message });
203
+ }
204
+ });
205
+
206
+ app.post('/api/vision/items/:id/lifecycle/skip', (req, res) => {
207
+ try {
208
+ const { targetPhase, reason } = req.body;
209
+ const item = store.items.get(req.params.id);
210
+ if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
211
+ const from = item.lifecycle.currentPhase;
212
+ if (TERMINAL.has(from)) return res.status(400).json({ error: `Cannot skip from terminal state: ${from}` });
213
+ if (!SKIPPABLE.has(from)) return res.status(400).json({ error: `Phase ${from} is not skippable` });
214
+ const valid = TRANSITIONS[from];
215
+ if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
216
+
217
+ item.lifecycle.currentPhase = targetPhase;
218
+ store.updateLifecycle(req.params.id, item.lifecycle);
219
+ const now = new Date().toISOString();
220
+ scheduleBroadcast();
221
+ broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome: 'skipped', timestamp: now });
222
+ res.json({ from, to: targetPhase, outcome: 'skipped', reason });
223
+ } catch (err) {
224
+ const status = err.message.includes('not found') ? 404 : 400;
225
+ res.status(status).json({ error: err.message });
226
+ }
227
+ });
228
+
229
+ app.post('/api/vision/items/:id/lifecycle/kill', (req, res) => {
230
+ try {
231
+ const { reason } = req.body;
232
+ const item = store.items.get(req.params.id);
233
+ if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
234
+ const from = item.lifecycle.currentPhase;
235
+ if (TERMINAL.has(from)) return res.status(400).json({ error: `Cannot kill from terminal state: ${from}` });
236
+
237
+ const now = new Date().toISOString();
238
+ item.lifecycle.currentPhase = 'killed';
239
+ item.lifecycle.killedAt = now;
240
+ item.lifecycle.killReason = reason;
241
+ store.updateLifecycle(req.params.id, item.lifecycle);
242
+ store.updateItem(req.params.id, { status: 'killed' });
243
+ scheduleBroadcast();
244
+ broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: 'killed', outcome: 'killed', timestamp: now });
245
+ res.json({ phase: from, reason });
246
+ } catch (err) {
247
+ const status = err.message.includes('not found') ? 404 : 400;
248
+ res.status(status).json({ error: err.message });
249
+ }
250
+ });
251
+
252
+ app.post('/api/vision/items/:id/lifecycle/complete', (req, res) => {
253
+ try {
254
+ const item = store.items.get(req.params.id);
255
+ if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
256
+ if (item.lifecycle.currentPhase !== 'ship') {
257
+ return res.status(400).json({ error: `Can only complete from ship phase, currently in: ${item.lifecycle.currentPhase}` });
258
+ }
259
+
260
+ const now = new Date().toISOString();
261
+ item.lifecycle.currentPhase = 'complete';
262
+ item.lifecycle.completedAt = now;
263
+ store.updateLifecycle(req.params.id, item.lifecycle);
264
+ store.updateItem(req.params.id, { status: 'complete' });
265
+ scheduleBroadcast();
266
+ broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
267
+ res.json({ completedAt: now });
268
+ } catch (err) {
269
+ const status = err.message.includes('not found') ? 404 : 400;
270
+ res.status(status).json({ error: err.message });
271
+ }
272
+ });
273
+
274
+ // ── Iteration loop endpoints ──────────────────────────────────────────
275
+
276
+ app.post('/api/vision/items/:id/lifecycle/iteration/start', (req, res) => {
277
+ try {
278
+ const item = store.items.get(req.params.id);
279
+ if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
280
+ const { loopType, maxIterations, wallClockTimeout, maxActions } = req.body;
281
+ if (!ITERATION_TYPES.has(loopType)) {
282
+ return res.status(400).json({ error: `Invalid loopType: ${loopType}. Must be one of: ${[...ITERATION_TYPES].join(', ')}` });
283
+ }
284
+ if (item.lifecycle.iterationState?.status === 'running') {
285
+ return res.status(409).json({ error: 'An iteration loop is already running' });
286
+ }
287
+
288
+ // COMP-BUDGET: check cumulative budget before starting a new loop
289
+ const composeDir = path.join(projectRoot, '.compose');
290
+ const featureCode = item.lifecycle.featureCode;
291
+ if (featureCode) {
292
+ const settings = settingsStore?.get();
293
+ const maxTotal = settings?.iterations?.[loopType]?.maxTotal;
294
+ if (maxTotal != null) {
295
+ const check = checkCumulativeBudget(composeDir, featureCode, { maxTotalIterations: maxTotal });
296
+ if (check.exceeded) {
297
+ return res.status(429).json({ error: `Cumulative iteration budget exceeded for ${featureCode}: ${check.reason}`, usage: check.usage });
298
+ }
299
+ }
300
+ }
301
+
302
+ const settingsMax = settingsStore?.get()?.iterations?.[loopType]?.maxIterations;
303
+ const settingsTimeout = settingsStore?.get()?.iterations?.[loopType]?.timeout;
304
+ const max = maxIterations ?? settingsMax ?? 10;
305
+ // wallClockTimeout from body takes precedence; fall back to settings timeout (minutes), then no timeout
306
+ const timeoutMinutes = wallClockTimeout !== undefined ? wallClockTimeout : (settingsTimeout ?? null);
307
+ const now = new Date().toISOString();
308
+ item.lifecycle.iterationState = {
309
+ loopType, loopId: `iter-${Date.now()}`, status: 'running',
310
+ count: 0, maxIterations: max, startedAt: now,
311
+ completedAt: null, outcome: null, iterations: [],
312
+ wallClockTimeout: timeoutMinutes,
313
+ maxActions: maxActions ?? null,
314
+ totalActions: 0,
315
+ };
316
+ store.updateLifecycle(req.params.id, item.lifecycle);
317
+ scheduleBroadcast();
318
+ broadcastMessage({ type: 'iterationStarted', itemId: req.params.id, loopId: item.lifecycle.iterationState.loopId, loopType, maxIterations: max, timestamp: now, startedAt: now, wallClockTimeout: timeoutMinutes, maxActions: maxActions ?? null });
319
+ res.json(item.lifecycle.iterationState);
320
+ } catch (err) {
321
+ res.status(400).json({ error: err.message });
322
+ }
323
+ });
324
+
325
+ app.post('/api/vision/items/:id/lifecycle/iteration/report', (req, res) => {
326
+ try {
327
+ const item = store.items.get(req.params.id);
328
+ if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
329
+ const iter = item.lifecycle.iterationState;
330
+ if (iter.status !== 'running') return res.status(409).json({ error: `Loop is ${iter.status}, not running` });
331
+ const { result } = req.body;
332
+ if (!result || typeof result !== 'object') return res.status(400).json({ error: 'result object required' });
333
+ const now = new Date().toISOString();
334
+ iter.count++;
335
+ iter.iterations.push({ n: iter.count, startedAt: now, result });
336
+
337
+ // COMP-BUDGET: accumulate action count
338
+ iter.totalActions = (iter.totalActions ?? 0) + (result.actionCount ?? 0);
339
+
340
+ const exitMet = iter.loopType === 'review' ? result.clean === true
341
+ : iter.loopType === 'coverage' ? result.passing === true
342
+ : false;
343
+ let shouldContinue = true;
344
+ if (exitMet) {
345
+ iter.status = 'complete'; iter.outcome = 'clean'; iter.completedAt = now; shouldContinue = false;
346
+ } else if (iter.count >= iter.maxIterations) {
347
+ iter.status = 'complete'; iter.outcome = 'max_reached'; iter.completedAt = now; shouldContinue = false;
348
+ } else if (iter.maxActions != null && iter.totalActions >= iter.maxActions) {
349
+ // COMP-BUDGET: action count ceiling
350
+ iter.status = 'complete'; iter.outcome = 'action_limit'; iter.completedAt = now; shouldContinue = false;
351
+ } else if (iter.wallClockTimeout != null) {
352
+ // COMP-BUDGET: wall-clock timeout
353
+ const elapsed = Date.now() - new Date(iter.startedAt).getTime();
354
+ const timeoutMs = iter.wallClockTimeout * 60 * 1000;
355
+ if (elapsed > timeoutMs) {
356
+ const elapsedMinutes = Math.round(elapsed / 60000 * 10) / 10;
357
+ iter.status = 'complete'; iter.outcome = 'timeout'; iter.completedAt = now; iter.elapsedMinutes = elapsedMinutes; shouldContinue = false;
358
+ }
359
+ }
360
+
361
+ // COMP-BUDGET: record iteration in ledger when loop completes
362
+ if (iter.status === 'complete') {
363
+ const composeDir = path.join(projectRoot, '.compose');
364
+ const featureCode = item.lifecycle.featureCode;
365
+ if (featureCode) {
366
+ const startMs = new Date(iter.startedAt).getTime();
367
+ const timeMs = Date.now() - startMs;
368
+ recordIteration(composeDir, featureCode, { iterations: iter.count, actions: iter.totalActions ?? 0, timeMs });
369
+ }
370
+ }
371
+
372
+ store.updateLifecycle(req.params.id, item.lifecycle);
373
+ scheduleBroadcast();
374
+ if (iter.status === 'complete') {
375
+ const completeMsg = { type: 'iterationComplete', itemId: req.params.id, loopId: iter.loopId, loopType: iter.loopType, outcome: iter.outcome, finalCount: iter.count, timestamp: now };
376
+ if (iter.elapsedMinutes != null) completeMsg.elapsedMinutes = iter.elapsedMinutes;
377
+ broadcastMessage(completeMsg);
378
+ } else {
379
+ broadcastMessage({ type: 'iterationUpdate', itemId: req.params.id, loopId: iter.loopId, loopType: iter.loopType, count: iter.count, maxIterations: iter.maxIterations, exitCriteriaMet: false, findingsCount: result.findings?.length ?? 0, timestamp: now });
380
+ }
381
+ res.json({ continue: shouldContinue, count: iter.count, maxIterations: iter.maxIterations, outcome: iter.outcome });
382
+ } catch (err) {
383
+ res.status(400).json({ error: err.message });
384
+ }
385
+ });
386
+
387
+ app.post('/api/vision/items/:id/lifecycle/iteration/abort', (req, res) => {
388
+ try {
389
+ const item = store.items.get(req.params.id);
390
+ if (!item?.lifecycle?.iterationState) return res.status(404).json({ error: 'No iteration loop active' });
391
+ const iter = item.lifecycle.iterationState;
392
+ if (iter.status !== 'running') return res.status(409).json({ error: `Loop already ${iter.status}` });
393
+ const now = new Date().toISOString();
394
+ iter.status = 'complete'; iter.outcome = 'aborted'; iter.completedAt = now;
395
+
396
+ // COMP-BUDGET: record iteration in ledger on abort
397
+ const composeDir = path.join(projectRoot, '.compose');
398
+ const featureCode = item.lifecycle.featureCode;
399
+ if (featureCode) {
400
+ const startMs = new Date(iter.startedAt).getTime();
401
+ const timeMs = Date.now() - startMs;
402
+ recordIteration(composeDir, featureCode, { iterations: iter.count, actions: iter.totalActions ?? 0, timeMs });
403
+ }
404
+
405
+ store.updateLifecycle(req.params.id, item.lifecycle);
406
+ scheduleBroadcast();
407
+ broadcastMessage({ type: 'iterationComplete', itemId: req.params.id, loopId: iter.loopId, loopType: iter.loopType, outcome: 'aborted', finalCount: iter.count, timestamp: now });
408
+ res.json({ aborted: true });
409
+ } catch (err) {
410
+ res.status(400).json({ error: err.message });
411
+ }
412
+ });
413
+
414
+ // ── Artifact endpoints ───────────────────────────────────────────────
415
+ const artifactManager = new ArtifactManager(featuresPath);
416
+
417
+ app.get('/api/vision/items/:id/artifacts', (req, res) => {
418
+ try {
419
+ const item = store.items.get(req.params.id);
420
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
421
+ if (!item.lifecycle?.featureCode) {
422
+ return res.status(400).json({ error: 'Item has no lifecycle featureCode' });
423
+ }
424
+ const assessment = artifactManager.assess(item.lifecycle.featureCode);
425
+ res.json(assessment);
426
+ } catch (err) {
427
+ res.status(500).json({ error: err.message });
428
+ }
429
+ });
430
+
431
+ app.post('/api/vision/items/:id/artifacts/scaffold', (req, res) => {
432
+ try {
433
+ const item = store.items.get(req.params.id);
434
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
435
+ if (!item.lifecycle?.featureCode) {
436
+ return res.status(400).json({ error: 'Item has no lifecycle featureCode' });
437
+ }
438
+ const result = artifactManager.scaffold(item.lifecycle.featureCode, req.body);
439
+ res.json(result);
440
+ } catch (err) {
441
+ res.status(400).json({ error: err.message });
442
+ }
443
+ });
444
+
445
+ // ── Gate endpoints ─────────────────────────────────────────────────
446
+
447
+ app.get('/api/vision/gates', (req, res) => {
448
+ try {
449
+ const statusFilter = req.query.status;
450
+ if (statusFilter === 'all') {
451
+ res.json({ gates: store.getAllGates() });
452
+ } else if (statusFilter === 'resolved') {
453
+ res.json({ gates: store.getAllGates().filter(g => g.status === 'resolved') });
454
+ } else {
455
+ const itemId = req.query.itemId || undefined;
456
+ const gates = store.getPendingGates(itemId);
457
+ res.json({ gates });
458
+ }
459
+ } catch (err) {
460
+ res.status(500).json({ error: err.message });
461
+ }
462
+ });
463
+
464
+ // POST /api/vision/gates — create a gate (used by CLI dual-dispatch)
465
+ app.post('/api/vision/gates', (req, res) => {
466
+ try {
467
+ const { flowId, stepId, itemId, artifact, options, fromPhase, toPhase, summary, comment, policyMode } = req.body;
468
+ const round = req.body.round ?? 1;
469
+ let artifactSnapshot = null;
470
+ if (artifact) {
471
+ try {
472
+ const docsRoot = path.resolve(projectRoot, 'docs');
473
+ const fullPath = path.resolve(projectRoot, artifact);
474
+ if (fullPath.startsWith(docsRoot + path.sep) && fullPath.endsWith('.md') && fs.existsSync(fullPath)) {
475
+ artifactSnapshot = fs.readFileSync(fullPath, 'utf-8');
476
+ }
477
+ } catch (e) { /* snapshot is best-effort */ }
478
+ }
479
+ if (!flowId || !stepId) {
480
+ return res.status(400).json({ error: 'flowId and stepId are required' });
481
+ }
482
+ const id = `${flowId}:${stepId}:${round}`;
483
+ const existing = store.getGateById(id);
484
+ if (existing) {
485
+ return res.status(200).json(existing); // idempotent
486
+ }
487
+ // Dedup: if a pending gate already exists for the same item+step, reuse it
488
+ if (itemId) {
489
+ const dupGate = store.findPendingGate(itemId, stepId);
490
+ if (dupGate) {
491
+ return res.status(200).json(dupGate);
492
+ }
493
+ }
494
+ const gate = {
495
+ id,
496
+ flowId,
497
+ stepId,
498
+ round,
499
+ itemId: itemId || null,
500
+ artifact: artifact || null,
501
+ options: options || null,
502
+ fromPhase: fromPhase || null,
503
+ policyMode: policyMode ?? 'gate',
504
+ toPhase: toPhase || null,
505
+ summary: summary || null,
506
+ comment: comment || null,
507
+ artifactSnapshot: artifactSnapshot || null,
508
+ status: 'pending',
509
+ createdAt: new Date().toISOString(),
510
+ };
511
+ store.createGate(gate);
512
+ scheduleBroadcast();
513
+ broadcastMessage({ type: 'gateCreated', gateId: id, itemId: itemId || null, timestamp: gate.createdAt });
514
+ res.status(201).json(gate);
515
+ } catch (err) {
516
+ res.status(400).json({ error: err.message });
517
+ }
518
+ });
519
+
520
+ app.get('/api/vision/gates/:id', (req, res) => {
521
+ try {
522
+ const gate = store.gates.get(req.params.id);
523
+ if (!gate) return res.status(404).json({ error: `Gate not found: ${req.params.id}` });
524
+ // Lazy gate expiry
525
+ const gateTimeout = Number(process.env.COMPOSE_GATE_TIMEOUT) || 30 * 60 * 1000;
526
+ if (gate.status === 'pending' && (Date.now() - new Date(gate.createdAt).getTime()) > gateTimeout) {
527
+ gate.status = 'expired';
528
+ store._save();
529
+ }
530
+ res.json(gate);
531
+ } catch (err) {
532
+ res.status(500).json({ error: err.message });
533
+ }
534
+ });
535
+
536
+ app.post('/api/vision/gates/:id/resolve', (req, res) => {
537
+ try {
538
+ const { outcome: rawOutcome, comment, resolvedBy } = req.body;
539
+ if (!rawOutcome) return res.status(400).json({ error: 'outcome is required' });
540
+ // Normalize legacy outcome values
541
+ const outcomeMap = { approved: 'approve', killed: 'kill', revised: 'revise' };
542
+ const outcome = outcomeMap[rawOutcome] || rawOutcome;
543
+ const gate = store.gates.get(req.params.id);
544
+ if (!gate) return res.status(404).json({ error: `Gate not found: ${req.params.id}` });
545
+ // Idempotent: already-resolved gates return 200
546
+ if (gate.status !== 'pending') {
547
+ return res.status(200).json({ gateId: req.params.id, gateOutcome: gate.outcome });
548
+ }
549
+
550
+ // AD-4: Server only updates gate state. CLI owns lifecycle transitions.
551
+ store.resolveGate(req.params.id, { outcome, comment, resolvedBy });
552
+
553
+ scheduleBroadcast();
554
+ broadcastMessage({ type: 'gateResolved', gateId: req.params.id, itemId: gate.itemId, outcome, timestamp: new Date().toISOString() });
555
+ res.json({ gateId: req.params.id, gateOutcome: outcome });
556
+ } catch (err) {
557
+ const status = err.message.includes('not found') ? 404 : 400;
558
+ res.status(status).json({ error: err.message });
559
+ }
560
+ });
561
+
562
+ // GET /api/vision/summary — structured board summary
563
+ app.get('/api/vision/summary', (_req, res) => {
564
+ const { items, connections } = store.getState();
565
+ const byPhase = {};
566
+ const byStatus = {};
567
+ const byType = {};
568
+ let totalConfidence = 0;
569
+ let confidenceCount = 0;
570
+ let openQuestions = 0;
571
+ let blockedItems = 0;
572
+
573
+ for (const item of items) {
574
+ const phase = item.phase || 'unassigned';
575
+ byPhase[phase] = (byPhase[phase] || 0) + 1;
576
+
577
+ const status = item.status || 'planned';
578
+ byStatus[status] = (byStatus[status] || 0) + 1;
579
+
580
+ const type = item.type || 'artifact';
581
+ byType[type] = (byType[type] || 0) + 1;
582
+
583
+ if (typeof item.confidence === 'number') {
584
+ totalConfidence += item.confidence;
585
+ confidenceCount++;
586
+ }
587
+
588
+ if (item.type === 'question' && item.status !== 'complete' && item.status !== 'killed') {
589
+ openQuestions++;
590
+ }
591
+
592
+ if (item.status === 'blocked') {
593
+ blockedItems++;
594
+ }
595
+ }
596
+
597
+ res.json({
598
+ totalItems: items.length,
599
+ totalConnections: connections.length,
600
+ byPhase,
601
+ byStatus,
602
+ byType,
603
+ openQuestions,
604
+ blockedItems,
605
+ avgConfidence: confidenceCount > 0 ? Math.round((totalConfidence / confidenceCount) * 100) / 100 : 0,
606
+ });
607
+ });
608
+
609
+ // GET /api/vision/blocked — items blocked by non-complete items
610
+ app.get('/api/vision/blocked', (_req, res) => {
611
+ const { items, connections } = store.getState();
612
+ const itemMap = new Map(items.map(i => [i.id, i]));
613
+
614
+ const blocked = [];
615
+ for (const conn of connections) {
616
+ if (conn.type === 'blocks') {
617
+ const blocker = itemMap.get(conn.fromId);
618
+ const target = itemMap.get(conn.toId);
619
+ if (blocker && target && blocker.status !== 'complete' && blocker.status !== 'killed') {
620
+ blocked.push({
621
+ item: target,
622
+ blockedBy: blocker,
623
+ connectionId: conn.id,
624
+ });
625
+ }
626
+ }
627
+ }
628
+
629
+ res.json({ blocked, count: blocked.length });
630
+ });
631
+
632
+ // POST /api/vision/ui — push UI commands (lens, layout, phase)
633
+ app.post('/api/vision/ui', (req, res) => {
634
+ broadcastMessage({ type: 'visionUI', ...req.body });
635
+ res.json({ ok: true });
636
+ });
637
+
638
+ // POST /api/plan/parse — extract file paths from plan/spec markdown
639
+ app.post('/api/plan/parse', (req, res) => {
640
+ const { filePath, itemId } = req.body || {};
641
+ if (!filePath) return res.status(400).json({ error: 'filePath required' });
642
+
643
+ const fullPath = path.resolve(projectRoot, filePath);
644
+ if (!fullPath.startsWith(projectRoot)) {
645
+ return res.status(400).json({ error: 'Path must be within project' });
646
+ }
647
+ let content;
648
+ try {
649
+ content = fs.readFileSync(fullPath, 'utf-8');
650
+ } catch {
651
+ return res.status(404).json({ error: `File not found: ${filePath}` });
652
+ }
653
+
654
+ const extracted = extractFilePaths(content);
655
+
656
+ if (itemId) {
657
+ const item = store.items.get(itemId);
658
+ if (item) {
659
+ const existing = item.files || [];
660
+ const merged = [...new Set([...existing, ...extracted])];
661
+ store.updateItem(itemId, { files: merged });
662
+ scheduleBroadcast();
663
+ }
664
+ }
665
+
666
+ res.json({ files: extracted, itemId: itemId || null });
667
+ });
668
+ }