@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
package/lib/triage.js ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * triage.js — Pre-flight feature triage.
3
+ *
4
+ * Analyzes the feature folder contents and assigns a complexity tier.
5
+ * Populates the build profile (needs_prd, needs_architecture, needs_verification,
6
+ * needs_report) in feature.json so subsequent builds can toggle skip_if on
7
+ * pipeline steps without requiring manual intervention.
8
+ *
9
+ * No LLM calls — pure file analysis and heuristics.
10
+ */
11
+
12
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Tier definitions
17
+ // ---------------------------------------------------------------------------
18
+ //
19
+ // Tier 0: Config-only — dotfiles, package.json tweaks, no design docs
20
+ // → skip prd, architecture, verification, report
21
+ // Tier 1: Single-concern — 1-2 files in plan, no security/core paths
22
+ // → skip prd, architecture, report (keep verification)
23
+ // Tier 2: Standard feature — multiple files, design doc present
24
+ // → skip prd, architecture (default — what most features need)
25
+ // Tier 3: Cross-component / security-sensitive
26
+ // → enable architecture, skip prd
27
+ // Tier 4: Architecture change / shared core code
28
+ // → enable prd and architecture
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const SECURITY_PATTERNS = [
32
+ /\bauth\b/i,
33
+ /\bcrypto\b/i,
34
+ /\bsession\b/i,
35
+ /\bmiddleware\b/i,
36
+ /\btoken\b/i,
37
+ /\bpermission\b/i,
38
+ /\bcredential\b/i,
39
+ /\bjwt\b/i,
40
+ /\boauth\b/i,
41
+ /\bpassword\b/i,
42
+ ];
43
+
44
+ const CORE_PATTERNS = [
45
+ /\blib\//,
46
+ /\bserver\/index\b/,
47
+ /connector.*base/i,
48
+ /\bbase.*connector/i,
49
+ /\bcore\//,
50
+ /\bshared\//,
51
+ /stratum-mcp/i,
52
+ ];
53
+
54
+ /**
55
+ * Extract file paths mentioned in a markdown string.
56
+ * Matches backtick-quoted paths that look like file paths (contain a dot or slash).
57
+ *
58
+ * @param {string} content
59
+ * @returns {string[]}
60
+ */
61
+ function extractFilePaths(content) {
62
+ const matches = [];
63
+ // Match backtick-quoted strings that look like paths
64
+ const backtickRe = /`([^`]+)`/g;
65
+ let m;
66
+ while ((m = backtickRe.exec(content)) !== null) {
67
+ const val = m[1];
68
+ if (val.includes('/') || (val.includes('.') && !val.includes(' '))) {
69
+ matches.push(val);
70
+ }
71
+ }
72
+ return matches;
73
+ }
74
+
75
+ /**
76
+ * Count markdown checkbox items in content.
77
+ *
78
+ * @param {string} content
79
+ * @returns {number}
80
+ */
81
+ function countTasks(content) {
82
+ const re = /^\s*-\s*\[[ xX]\]/gm;
83
+ return (content.match(re) ?? []).length;
84
+ }
85
+
86
+ /**
87
+ * Check whether any path in a list matches the given patterns.
88
+ *
89
+ * @param {string[]} paths
90
+ * @param {RegExp[]} patterns
91
+ * @returns {boolean}
92
+ */
93
+ function anyMatch(paths, patterns) {
94
+ return paths.some(p => patterns.some(re => re.test(p)));
95
+ }
96
+
97
+ /**
98
+ * Derive tier and profile from signal values.
99
+ *
100
+ * @param {{ fileCount: number, securityPaths: boolean, corePaths: boolean, taskCount: number, hasDesignDoc: boolean }} signals
101
+ * @returns {{ tier: number, profile: object, rationale: string }}
102
+ */
103
+ function deriveProfile(signals) {
104
+ const { fileCount, securityPaths, corePaths, taskCount, hasDesignDoc } = signals;
105
+
106
+ // Tier 4: core/shared code changes → needs full design review
107
+ if (corePaths) {
108
+ return {
109
+ tier: 4,
110
+ profile: {
111
+ needs_prd: true,
112
+ needs_architecture: true,
113
+ needs_verification: true,
114
+ needs_report: true,
115
+ },
116
+ rationale: 'Touches core/shared code — full design review required',
117
+ };
118
+ }
119
+
120
+ // Tier 3: security-sensitive → architecture required
121
+ if (securityPaths) {
122
+ return {
123
+ tier: 3,
124
+ profile: {
125
+ needs_prd: false,
126
+ needs_architecture: true,
127
+ needs_verification: true,
128
+ needs_report: false,
129
+ },
130
+ rationale: 'References security-sensitive paths — architecture review required',
131
+ };
132
+ }
133
+
134
+ // Tier 0: config-only — no design docs, at most 1 file path, very few tasks
135
+ if (!hasDesignDoc && fileCount <= 1 && taskCount <= 5) {
136
+ return {
137
+ tier: 0,
138
+ profile: {
139
+ needs_prd: false,
140
+ needs_architecture: false,
141
+ needs_verification: false,
142
+ needs_report: false,
143
+ },
144
+ rationale: 'Config-only change — minimal scope, no design docs',
145
+ };
146
+ }
147
+
148
+ // Tier 1: single-concern — few files, no special paths
149
+ if (fileCount <= 2 && taskCount <= 10) {
150
+ return {
151
+ tier: 1,
152
+ profile: {
153
+ needs_prd: false,
154
+ needs_architecture: false,
155
+ needs_verification: true,
156
+ needs_report: false,
157
+ },
158
+ rationale: 'Single-concern change — verification sufficient',
159
+ };
160
+ }
161
+
162
+ // Tier 2: standard feature (default)
163
+ return {
164
+ tier: 2,
165
+ profile: {
166
+ needs_prd: false,
167
+ needs_architecture: false,
168
+ needs_verification: true,
169
+ needs_report: false,
170
+ },
171
+ rationale: 'Standard feature — default build profile',
172
+ };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Public API
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Run triage on a feature folder.
181
+ *
182
+ * @param {string} featureCode - Feature code (e.g. 'FEAT-1')
183
+ * @param {{ cwd: string }} opts
184
+ * @returns {Promise<{ tier: number, profile: object, rationale: string, signals: object }>}
185
+ */
186
+ export async function runTriage(featureCode, opts = {}) {
187
+ const cwd = opts.cwd ?? process.cwd();
188
+ const featureDir = join(cwd, 'docs', 'features', featureCode);
189
+
190
+ // Collect content from key files
191
+ const candidateFiles = ['plan.md', 'blueprint.md', 'design.md', 'prd.md', 'architecture.md'];
192
+ let combinedContent = '';
193
+ let hasDesignDoc = false;
194
+
195
+ for (const fname of candidateFiles) {
196
+ const fpath = join(featureDir, fname);
197
+ if (existsSync(fpath)) {
198
+ if (['design.md', 'prd.md', 'architecture.md'].includes(fname)) {
199
+ hasDesignDoc = true;
200
+ }
201
+ try {
202
+ combinedContent += readFileSync(fpath, 'utf-8') + '\n';
203
+ } catch { /* skip unreadable */ }
204
+ }
205
+ }
206
+
207
+ const filePaths = extractFilePaths(combinedContent);
208
+ const taskCount = countTasks(combinedContent);
209
+ const securityPaths = anyMatch(filePaths, SECURITY_PATTERNS);
210
+ const corePaths = anyMatch(filePaths, CORE_PATTERNS);
211
+
212
+ // Deduplicate file paths for count
213
+ const uniquePaths = new Set(filePaths);
214
+ const fileCount = uniquePaths.size;
215
+
216
+ const signals = { fileCount, securityPaths, corePaths, taskCount, hasDesignDoc };
217
+ const { tier, profile, rationale } = deriveProfile(signals);
218
+
219
+ return {
220
+ tier,
221
+ profile,
222
+ rationale,
223
+ signals: { fileCount, securityPaths, corePaths, taskCount },
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Check whether cached triage results are stale.
229
+ *
230
+ * Returns true if:
231
+ * - feature.json has no triageTimestamp
232
+ * - any file in the feature folder has an mtime newer than triageTimestamp
233
+ *
234
+ * @param {string} cwd - Project root
235
+ * @param {string} featureCode - Feature code
236
+ * @param {string} [featuresDir] - Relative path to features dir (default: docs/features)
237
+ * @returns {boolean}
238
+ */
239
+ export function isTriageStale(cwd, featureCode, featuresDir = 'docs/features') {
240
+ const featureDir = join(cwd, featuresDir, featureCode);
241
+ const featureJsonPath = join(featureDir, 'feature.json');
242
+
243
+ if (!existsSync(featureJsonPath)) return true;
244
+
245
+ let feature;
246
+ try {
247
+ feature = JSON.parse(readFileSync(featureJsonPath, 'utf-8'));
248
+ } catch {
249
+ return true;
250
+ }
251
+
252
+ if (!feature.triageTimestamp) return true;
253
+
254
+ const triageTime = new Date(feature.triageTimestamp).getTime();
255
+ if (isNaN(triageTime)) return true;
256
+
257
+ // Check all files in the feature folder
258
+ if (!existsSync(featureDir)) return true;
259
+ try {
260
+ const entries = readdirSync(featureDir, { withFileTypes: true });
261
+ for (const entry of entries) {
262
+ if (!entry.isFile()) continue;
263
+ const filePath = join(featureDir, entry.name);
264
+ try {
265
+ const stat = statSync(filePath);
266
+ if (stat.mtimeMs > triageTime) return true;
267
+ } catch { /* skip */ }
268
+ }
269
+ } catch {
270
+ return true;
271
+ }
272
+
273
+ return false;
274
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * VisionWriter — Dual-dispatch read-modify-write for vision-state.json
3
+ *
4
+ * Routes mutations through REST when the Compose server is running,
5
+ * falls back to direct atomic file writes when it's down.
6
+ *
7
+ * Provides step-to-item mapping, feature item lookup (using lifecycle.featureCode),
8
+ * gate management with outcome normalization, and migration of legacy formats.
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import { resolvePort } from './resolve-port.js';
15
+ import { probeServer } from './server-probe.js';
16
+
17
+ const EMPTY_STATE = () => ({ items: [], connections: [], gates: [] });
18
+
19
+ /** Canonical outcome normalization — maps legacy past-tense to imperative */
20
+ function normalizeOutcome(outcome) {
21
+ const map = { approved: 'approve', killed: 'kill', revised: 'revise' };
22
+ return map[outcome] || outcome;
23
+ }
24
+
25
+ export class ServerUnreachableError extends Error {
26
+ constructor(message = 'Server is unreachable') {
27
+ super(message);
28
+ this.name = 'ServerUnreachableError';
29
+ }
30
+ }
31
+
32
+ export class VisionWriter {
33
+ /**
34
+ * @param {string} dataDir Path to the data directory (e.g. `.compose/data/`)
35
+ * @param {object} [opts]
36
+ * @param {number} [opts.port] Server port (default: resolvePort())
37
+ */
38
+ constructor(dataDir, opts = {}) {
39
+ this.filePath = path.join(dataDir, 'vision-state.json');
40
+ this._dataDir = dataDir;
41
+ this._port = opts.port ?? resolvePort();
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Internal helpers
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Read and parse vision-state.json, applying migration if needed.
50
+ * Returns empty state if the file doesn't exist or contains invalid JSON.
51
+ */
52
+ _load() {
53
+ try {
54
+ const raw = fs.readFileSync(this.filePath, 'utf-8');
55
+ const parsed = JSON.parse(raw);
56
+ parsed.items = parsed.items || [];
57
+ parsed.connections = parsed.connections || [];
58
+ parsed.gates = parsed.gates || [];
59
+
60
+ // Migration: legacy featureCode → lifecycle.featureCode
61
+ let migrated = false;
62
+ for (const item of parsed.items) {
63
+ if (item.featureCode && item.featureCode.startsWith('feature:') && !item.lifecycle?.featureCode) {
64
+ const bare = item.featureCode.replace(/^feature:/, '');
65
+ item.lifecycle = item.lifecycle || {};
66
+ item.lifecycle.featureCode = bare;
67
+ delete item.featureCode;
68
+ migrated = true;
69
+ }
70
+ }
71
+
72
+ // Dedup gates by ID (keep latest)
73
+ const gateMap = new Map();
74
+ for (const gate of parsed.gates) gateMap.set(gate.id, gate);
75
+ if (gateMap.size < parsed.gates.length) { migrated = true; }
76
+ parsed.gates = Array.from(gateMap.values());
77
+
78
+ // Migration: normalize legacy gate outcomes
79
+ for (const gate of parsed.gates) {
80
+ if (gate.outcome) {
81
+ const normalized = normalizeOutcome(gate.outcome);
82
+ if (normalized !== gate.outcome) {
83
+ gate.outcome = normalized;
84
+ migrated = true;
85
+ }
86
+ }
87
+ }
88
+
89
+ if (migrated) {
90
+ this._atomicWrite(parsed);
91
+ }
92
+
93
+ return parsed;
94
+ } catch {
95
+ return EMPTY_STATE();
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Atomically write state by writing to a temp file then renaming.
101
+ * @param {object} state
102
+ */
103
+ _atomicWrite(state) {
104
+ const dir = path.dirname(this.filePath);
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ const tmp = path.join(dir, `vision-state.json.tmp.${crypto.randomUUID()}`);
107
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
108
+ fs.renameSync(tmp, this.filePath);
109
+ }
110
+
111
+ /** Check if the Compose server is reachable */
112
+ async _serverAvailable() {
113
+ return probeServer(this._port);
114
+ }
115
+
116
+ /** Base URL for REST calls */
117
+ get _baseUrl() {
118
+ return `http://localhost:${this._port}`;
119
+ }
120
+
121
+ /** Fetch helper with timeout */
122
+ async _fetch(urlPath, opts = {}) {
123
+ const controller = new AbortController();
124
+ const timer = setTimeout(() => controller.abort(), 5000);
125
+ try {
126
+ const res = await fetch(`${this._baseUrl}${urlPath}`, {
127
+ ...opts,
128
+ signal: controller.signal,
129
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
130
+ });
131
+ if (!res.ok) {
132
+ const body = await res.text().catch(() => '');
133
+ throw new Error(`REST ${opts.method || 'GET'} ${urlPath} failed: ${res.status} ${body}`);
134
+ }
135
+ return res.json();
136
+ } finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // REST variants
143
+ // ---------------------------------------------------------------------------
144
+
145
+ async _restFindFeatureItem(featureCode) {
146
+ const state = await this._fetch('/api/vision/items');
147
+ const items = state.items || [];
148
+ return items.find(item => item.lifecycle?.featureCode === featureCode) || null;
149
+ }
150
+
151
+ async _restEnsureFeatureItem(featureCode, title) {
152
+ const existing = await this._restFindFeatureItem(featureCode);
153
+ if (existing) {
154
+ // Partial repair: item exists but no lifecycle — start lifecycle
155
+ if (!existing.lifecycle?.featureCode) {
156
+ await this._fetch(`/api/vision/items/${existing.id}/lifecycle/start`, {
157
+ method: 'POST',
158
+ body: JSON.stringify({ featureCode }),
159
+ });
160
+ }
161
+ return existing.id;
162
+ }
163
+ const item = await this._fetch('/api/vision/items', {
164
+ method: 'POST',
165
+ body: JSON.stringify({
166
+ type: 'feature',
167
+ title: title || featureCode,
168
+ description: '',
169
+ status: 'planned',
170
+ phase: 'planning',
171
+ }),
172
+ });
173
+ await this._fetch(`/api/vision/items/${item.id}/lifecycle/start`, {
174
+ method: 'POST',
175
+ body: JSON.stringify({ featureCode }),
176
+ });
177
+ return item.id;
178
+ }
179
+
180
+ async _restUpdateItemStatus(itemId, status) {
181
+ await this._fetch(`/api/vision/items/${itemId}`, {
182
+ method: 'PATCH',
183
+ body: JSON.stringify({ status }),
184
+ });
185
+ }
186
+
187
+ async _restUpdateItemPhase(itemId, stepId) {
188
+ await this._fetch(`/api/vision/items/${itemId}/lifecycle/advance`, {
189
+ method: 'POST',
190
+ body: JSON.stringify({ targetPhase: stepId }),
191
+ }).catch(() => {
192
+ // Lifecycle advance may reject for invalid transitions — fall through
193
+ });
194
+ }
195
+
196
+ async _restCreateGate(flowId, stepId, itemId, opts = {}) {
197
+ const round = opts.round ?? 1;
198
+ const gate = await this._fetch('/api/vision/gates', {
199
+ method: 'POST',
200
+ body: JSON.stringify({
201
+ flowId, stepId, round, itemId,
202
+ fromPhase: opts.fromPhase || null,
203
+ toPhase: opts.toPhase || null,
204
+ artifact: opts.artifact || null,
205
+ options: opts.options || null,
206
+ summary: opts.summary || null,
207
+ comment: opts.comment || null,
208
+ policyMode: opts.policyMode || undefined,
209
+ }),
210
+ });
211
+ return gate.id;
212
+ }
213
+
214
+ async _restGetGate(gateId) {
215
+ try {
216
+ return await this._fetch(`/api/vision/gates/${encodeURIComponent(gateId)}`);
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ async _restResolveGate(gateId, outcome, comment, resolvedBy) {
223
+ await this._fetch(`/api/vision/gates/${encodeURIComponent(gateId)}/resolve`, {
224
+ method: 'POST',
225
+ body: JSON.stringify({ outcome: normalizeOutcome(outcome), comment: comment || null, resolvedBy: resolvedBy || undefined }),
226
+ });
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Direct (file-based) variants
231
+ // ---------------------------------------------------------------------------
232
+
233
+ _directFindFeatureItem(featureCode) {
234
+ const state = this._load();
235
+ return state.items.find(item => item.lifecycle?.featureCode === featureCode) || null;
236
+ }
237
+
238
+ _directEnsureFeatureItem(featureCode, title) {
239
+ const existing = this._directFindFeatureItem(featureCode);
240
+ if (existing) return existing.id;
241
+
242
+ const state = this._load();
243
+ // Derive group from featureCode (same logic as vision-store.js deriveGroup)
244
+ const groupMatch = (title || featureCode).match(/^([A-Z]+-[A-Z]+|[A-Z]+)(?=-\d)/);
245
+ const group = groupMatch ? groupMatch[1] : featureCode;
246
+ const item = {
247
+ id: crypto.randomUUID(),
248
+ type: 'feature',
249
+ title: title || featureCode,
250
+ description: '',
251
+ status: 'planned',
252
+ phase: 'planning',
253
+ group,
254
+ lifecycle: { featureCode, currentPhase: 'explore_design' },
255
+ slug: featureCode.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
256
+ confidence: null,
257
+ createdAt: new Date().toISOString(),
258
+ };
259
+ state.items.push(item);
260
+ this._atomicWrite(state);
261
+ return item.id;
262
+ }
263
+
264
+ _directUpdateItemStatus(itemId, status) {
265
+ const state = this._load();
266
+ const item = state.items.find(i => i.id === itemId);
267
+ if (item) {
268
+ item.status = status;
269
+ this._atomicWrite(state);
270
+ }
271
+ }
272
+
273
+ _directUpdateItemPhase(itemId, stepId) {
274
+ const state = this._load();
275
+ const item = state.items.find(i => i.id === itemId);
276
+ if (item) {
277
+ item.lifecycle = item.lifecycle || {};
278
+ item.lifecycle.currentPhase = stepId;
279
+ this._atomicWrite(state);
280
+ }
281
+ }
282
+
283
+ _directCreateGate(flowId, stepId, itemId, opts = {}) {
284
+ const state = this._load();
285
+ state.gates = state.gates || [];
286
+ const round = opts.round ?? 1;
287
+ const gate = {
288
+ id: `${flowId}:${stepId}:${round}`,
289
+ flowId,
290
+ stepId,
291
+ round,
292
+ itemId,
293
+ fromPhase: opts.fromPhase || null,
294
+ toPhase: opts.toPhase || null,
295
+ artifact: opts.artifact || null,
296
+ options: opts.options || null,
297
+ summary: opts.summary || null,
298
+ comment: opts.comment || null,
299
+ policyMode: opts.policyMode ?? 'gate',
300
+ status: 'pending',
301
+ createdAt: new Date().toISOString(),
302
+ };
303
+ // Dedupe by full gate ID or by pending itemId+stepId to prevent duplicates
304
+ const existingIdx = state.gates.findIndex(g =>
305
+ g.id === gate.id ||
306
+ (g.itemId === gate.itemId && g.stepId === gate.stepId && g.status === 'pending')
307
+ );
308
+ if (existingIdx !== -1) {
309
+ return state.gates[existingIdx].id;
310
+ }
311
+ state.gates.push(gate);
312
+ this._atomicWrite(state);
313
+ return gate.id;
314
+ }
315
+
316
+ _directGetGate(gateId) {
317
+ const state = this._load();
318
+ return (state.gates || []).find(g => g.id === gateId) || null;
319
+ }
320
+
321
+ _directResolveGate(gateId, outcome, comment, resolvedBy) {
322
+ const state = this._load();
323
+ const gate = (state.gates || []).find(g => g.id === gateId);
324
+ if (gate) {
325
+ gate.status = 'resolved';
326
+ gate.outcome = normalizeOutcome(outcome);
327
+ gate.comment = comment || null;
328
+ gate.resolvedBy = resolvedBy ?? 'human';
329
+ gate.resolvedAt = new Date().toISOString();
330
+ this._atomicWrite(state);
331
+ }
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Public async methods — dual dispatch
336
+ // ---------------------------------------------------------------------------
337
+
338
+ async findFeatureItem(featureCode) {
339
+ if (await this._serverAvailable()) {
340
+ return this._restFindFeatureItem(featureCode);
341
+ }
342
+ return this._directFindFeatureItem(featureCode);
343
+ }
344
+
345
+ async ensureFeatureItem(featureCode, title) {
346
+ if (await this._serverAvailable()) {
347
+ return this._restEnsureFeatureItem(featureCode, title);
348
+ }
349
+ return this._directEnsureFeatureItem(featureCode, title);
350
+ }
351
+
352
+ async updateItemStatus(itemId, status) {
353
+ if (await this._serverAvailable()) {
354
+ return this._restUpdateItemStatus(itemId, status);
355
+ }
356
+ return this._directUpdateItemStatus(itemId, status);
357
+ }
358
+
359
+ async updateItemPhase(itemId, stepId) {
360
+ if (await this._serverAvailable()) {
361
+ return this._restUpdateItemPhase(itemId, stepId);
362
+ }
363
+ return this._directUpdateItemPhase(itemId, stepId);
364
+ }
365
+
366
+ async createGate(flowId, stepId, itemId, opts = {}) {
367
+ if (await this._serverAvailable()) {
368
+ return this._restCreateGate(flowId, stepId, itemId, opts);
369
+ }
370
+ return this._directCreateGate(flowId, stepId, itemId, opts);
371
+ }
372
+
373
+ async getGate(gateId, opts = {}) {
374
+ if (opts.requireServer) {
375
+ const up = await this._serverAvailable();
376
+ if (!up) throw new ServerUnreachableError();
377
+ return this._restGetGate(gateId);
378
+ }
379
+ if (await this._serverAvailable()) {
380
+ return this._restGetGate(gateId);
381
+ }
382
+ return this._directGetGate(gateId);
383
+ }
384
+
385
+ async resolveGate(gateId, outcome, comment, resolvedBy) {
386
+ if (await this._serverAvailable()) {
387
+ return this._restResolveGate(gateId, outcome, comment, resolvedBy);
388
+ }
389
+ return this._directResolveGate(gateId, outcome, comment, resolvedBy);
390
+ }
391
+ }