@smartmemory/compose 0.2.51 → 0.2.53-beta

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 (143) hide show
  1. package/.claude/skills/compose/SKILL.md +0 -8
  2. package/README.md +11 -0
  3. package/bin/compose.js +335 -3
  4. package/dist/assets/App-Bu9KMtTa.js +891 -0
  5. package/dist/assets/abnfDiagram-VRR7QNED-6zp3w9rx.js +1 -0
  6. package/dist/assets/arc-CnLxxuah.js +1 -0
  7. package/dist/assets/architectureDiagram-ZJ3FMSHR-COM46S9k.js +36 -0
  8. package/dist/assets/blockDiagram-677ZJIJ3-wKzgOwF8.js +132 -0
  9. package/dist/assets/{browser-CnKiSnlr.js → browser-Cr0recrN.js} +6 -6
  10. package/dist/assets/{c4Diagram-AHTNJAMY-nBIRLJTv.js → c4Diagram-LMCZKHZV-DIXC_mR6.js} +1 -1
  11. package/dist/assets/channel-DQJPWkb_.js +1 -0
  12. package/dist/assets/{chunk-QZHKN3VN-B4Wfybww.js → chunk-2Q5K7J3B-BqLqZX6m.js} +1 -1
  13. package/dist/assets/{chunk-YZCP3GAM-DE6_5aqh.js → chunk-32BRIVSS-CarSmVlX.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-tYY8xcjr.js → chunk-5VM5RSS4-D-f_Jn3G.js} +1 -1
  15. package/dist/assets/chunk-EX3LRPZG-DT42xo8E.js +231 -0
  16. package/dist/assets/{chunk-4BX2VUAB-CNmGyhrp.js → chunk-JWPE2WC7-CwY1aZc4.js} +1 -1
  17. package/dist/assets/chunk-MOJQB5TN-DBbZU_KX.js +88 -0
  18. package/dist/assets/chunk-RYQCIY6F-DqxtcLJ3.js +1 -0
  19. package/dist/assets/chunk-V7JOEXUC-Cy4Ixww6.js +206 -0
  20. package/dist/assets/{chunk-EDXVE4YY-TPlt1bS2.js → chunk-VR4S4FIN-CFexfU_a.js} +1 -1
  21. package/dist/assets/{chunk-55IACEB6-D8KuKVLa.js → chunk-XXDRQBXY-uE-zN_vc.js} +1 -1
  22. package/dist/assets/classDiagram-OUVF2IWQ-CTLbAiUK.js +1 -0
  23. package/dist/assets/classDiagram-v2-EOCWNBFH-CTLbAiUK.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-DzzRyJtB.js → cose-bilkent-JH36ORCC-DIjpekos.js} +1 -1
  25. package/dist/assets/cynefin-VYW2F7L2-Ba2gbQow.js +178 -0
  26. package/dist/assets/cynefinDiagram-TSTJHNR4-njGzb7Tg.js +62 -0
  27. package/dist/assets/dagre-VKFMJZFB-DuyZREZ3.js +4 -0
  28. package/dist/assets/diagram-FQU43EPY-Npq-5o3a.js +3 -0
  29. package/dist/assets/diagram-G47NLZAW-fm4k3axC.js +24 -0
  30. package/dist/assets/diagram-NH7WQ7WH-B8EFGrTG.js +24 -0
  31. package/dist/assets/diagram-OA4YK3LP-BC7UHx9Q.js +30 -0
  32. package/dist/assets/diagram-WEI45ONY-BqjHLcCX.js +41 -0
  33. package/dist/assets/ebnfDiagram-CCIWWBDH-D5zXd4Qg.js +1 -0
  34. package/dist/assets/erDiagram-Q63AITRT-DC2FMcra.js +85 -0
  35. package/dist/assets/flowDiagram-23GEKE2U-D7Dx7JU4.js +156 -0
  36. package/dist/assets/ganttDiagram-NO4QXBWP-8sQH2y5K.js +292 -0
  37. package/dist/assets/gitGraphDiagram-IHSO6WYX-Ci_rhxur.js +106 -0
  38. package/dist/assets/graph-C9eacEi8.js +1 -0
  39. package/dist/assets/graph-xkel59g2.js +331 -0
  40. package/dist/assets/index-B7-HQenC.js +119 -0
  41. package/dist/assets/index-BxRamj_i.css +1 -0
  42. package/dist/assets/infoDiagram-FWYZ7A6U-B9FGQ0Cb.js +2 -0
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DWx-7BBy.js → ishikawaDiagram-FXEZZL3T-BEKLyH6A.js} +5 -5
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-C42hXNha.js → journeyDiagram-5HDEW3XC-BM-IDVRo.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-CEq930ew.js → kanban-definition-HUTT4EX6-Beb2k3pt.js} +7 -7
  46. package/dist/assets/katex-C5jXJg4s.js +257 -0
  47. package/dist/assets/layout-DEXfKzaS.js +1 -0
  48. package/dist/assets/{linear-D158F7OT.js → linear-Dwg7dTpz.js} +1 -1
  49. package/dist/assets/map-Czzmt4hB.js +1 -0
  50. package/dist/assets/{mindmap-definition-QFDTVHPH-d9SSh2nG.js → mindmap-definition-LN4V7U3C-CeaynfcE.js} +7 -7
  51. package/dist/assets/{mobile-Chw8RWyH.js → mobile-CNLMhdFP.js} +2 -2
  52. package/dist/assets/pegDiagram-2B236MQR-7SVdAVqj.js +1 -0
  53. package/dist/assets/pieDiagram-ENE6RG2P-CzqQ4TaE.js +39 -0
  54. package/dist/assets/quadrantDiagram-ABIIQ3AL-B9B6W80N.js +7 -0
  55. package/dist/assets/railroadDiagram-RFXS5EU6-BRNLawsr.js +1 -0
  56. package/dist/assets/{requirementDiagram-MS252O5E-B1ZFIotK.js → requirementDiagram-TGXJPOKE-CmASubeG.js} +3 -3
  57. package/dist/assets/sankeyDiagram-HTMAVEWB-DiReR0OB.js +40 -0
  58. package/dist/assets/sequenceDiagram-DBY2YBRQ-DVn5iZKR.js +162 -0
  59. package/dist/assets/sizeCapture-X5ZJPWSS-C8CuDOdp.js +1 -0
  60. package/dist/assets/stateDiagram-2N3HPSRC-BIdkHglY.js +1 -0
  61. package/dist/assets/stateDiagram-v2-6OUMAXLB-B9yje3Er.js +1 -0
  62. package/dist/assets/swimlanes-5IMT3BWC-BlVsYyyN.js +2 -0
  63. package/dist/assets/swimlanesDiagram-G3AALYLV-BTLpU45n.js +8 -0
  64. package/dist/assets/{timeline-definition-GMOUNBTQ-k_0vJQAK.js → timeline-definition-FHXFAJF6-CnTqJmQ2.js} +3 -3
  65. package/dist/assets/vennDiagram-L72KCM5P-D_qZxRhe.js +34 -0
  66. package/dist/assets/wardleyDiagram-EHGQE667-CT-WHFEO.js +78 -0
  67. package/dist/assets/xychartDiagram-FW5EYKEG-CVRFHwUQ.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +34 -0
  70. package/lib/build.js +542 -148
  71. package/lib/experiment-judge.js +145 -0
  72. package/lib/experiment-metrics.js +265 -0
  73. package/lib/experiment-pricing.js +60 -0
  74. package/lib/experiment-report.js +306 -0
  75. package/lib/experiment-sandbox.js +147 -0
  76. package/lib/experiment.js +539 -0
  77. package/lib/feature-json.js +1 -1
  78. package/lib/feature-writer.js +30 -0
  79. package/lib/flow-state.js +36 -0
  80. package/lib/gate-prompt.js +66 -6
  81. package/lib/lifecycle-modes.js +213 -0
  82. package/lib/new.js +6 -2
  83. package/lib/roadmap-graph/index.js +26 -28
  84. package/lib/roadmap-graph/vision-adapter.js +126 -0
  85. package/lib/stratum-mcp-client.js +9 -0
  86. package/lib/triage.js +7 -1
  87. package/lib/vision-writer.js +30 -6
  88. package/package.json +1 -1
  89. package/pipelines/plan.stratum.yaml +161 -0
  90. package/scripts/release-manual.sh +98 -0
  91. package/server/artifact-manager.js +30 -3
  92. package/server/compose-mcp-tools.js +11 -9
  93. package/server/compose-mcp.js +4 -0
  94. package/server/feature-scan.js +87 -173
  95. package/server/file-watcher.js +33 -0
  96. package/server/graph-export.js +0 -0
  97. package/server/index.js +10 -5
  98. package/server/lifecycle-guard.js +62 -25
  99. package/server/pipeline-routes.js +773 -3
  100. package/server/roadmap-graph-vision.js +138 -0
  101. package/server/status-snapshot.js +8 -7
  102. package/server/vision-routes.js +64 -33
  103. package/server/vision-store.js +13 -4
  104. package/.claude/skills/compose/references/hermes-tools.md +0 -80
  105. package/dist/assets/App-CT0vXPgd.js +0 -724
  106. package/dist/assets/_baseUniq-CsOHc_iS.js +0 -1
  107. package/dist/assets/arc-BXht3LyE.js +0 -1
  108. package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +0 -36
  109. package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +0 -132
  110. package/dist/assets/channel-Dd4XaiYv.js +0 -1
  111. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +0 -206
  112. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +0 -231
  113. package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +0 -1
  114. package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +0 -1
  115. package/dist/assets/clone-CBsbNGAa.js +0 -1
  116. package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +0 -4
  117. package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +0 -10
  118. package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +0 -24
  119. package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +0 -43
  120. package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +0 -24
  121. package/dist/assets/erDiagram-SMLLAGMA-CtxJi57K.js +0 -85
  122. package/dist/assets/flowDiagram-DWJPFMVM-BzzO4JYU.js +0 -162
  123. package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +0 -292
  124. package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +0 -106
  125. package/dist/assets/graph-DZe55uk8.js +0 -331
  126. package/dist/assets/graph-Tq_bs_r0.js +0 -1
  127. package/dist/assets/index-8UhRLbGq.js +0 -123
  128. package/dist/assets/index-CRqB9els.css +0 -1
  129. package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +0 -2
  130. package/dist/assets/katex-DkKDou_j.js +0 -257
  131. package/dist/assets/layout-qtUgN9BC.js +0 -1
  132. package/dist/assets/min-hESxN-c0.js +0 -1
  133. package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +0 -30
  134. package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +0 -7
  135. package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +0 -10
  136. package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +0 -157
  137. package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +0 -1
  138. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +0 -1
  139. package/dist/assets/vennDiagram-DHZGUBPP-TZAW60b0.js +0 -34
  140. package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +0 -162
  141. package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +0 -20
  142. package/dist/assets/xychartDiagram-5P7HB3ND-CynkZcUb.js +0 -7
  143. package/lib/roadmap-graph/collect.js +0 -178
@@ -0,0 +1,539 @@
1
+ /**
2
+ * experiment.js — Orchestrator for COMP-MODEL-AB sandboxed A/B experiments.
3
+ *
4
+ * Usage (programmatic):
5
+ * const { runExperiment } = await import('./experiment.js');
6
+ * await runExperiment('/path/to/spec.json');
7
+ *
8
+ * The build invocation is INJECTABLE via opts._runBuild so tests can pass a
9
+ * fake runner that writes canned artifacts without spawning a real build.
10
+ *
11
+ * // production default (spawns real compose build)
12
+ * runExperiment(specPath)
13
+ * // tests — zero real builds, zero LLM calls
14
+ * runExperiment(specPath, { _runBuild: fakeBuildRunner })
15
+ *
16
+ * SANDBOX ISOLATION CONTRACT (enforced, not assumed):
17
+ * - COMPOSE_TARGET=<sandbox-workspace> → build data dir is isolated.
18
+ * - COMPOSE_PORT=19997 (dead port) → VisionWriter falls back to direct file
19
+ * writes inside the sandbox; the live :4001 server is never contacted.
20
+ */
21
+
22
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'node:fs';
23
+ import { join, resolve, dirname } from 'node:path';
24
+ import { spawn } from 'node:child_process';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { provision } from './experiment-sandbox.js';
27
+ import { collect } from './experiment-metrics.js';
28
+ import { judge } from './experiment-judge.js';
29
+ import { validateAgentString } from './agent-string.js';
30
+ import { aggregate, render } from './experiment-report.js';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const COMPOSE_ROOT = resolve(__dirname, '..');
34
+ const COMPOSE_BIN = join(COMPOSE_ROOT, 'bin', 'compose.js');
35
+
36
+ // Fixed feature code in every sandbox — each run uses its own isolated
37
+ // workspace so reusing the same code never causes collisions.
38
+ const FEATURE_CODE = 'EXP-FIXTURE';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Validation
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Validate an experiment spec (fail-closed).
46
+ * Throws on any structural error; returns a validated spec with defaults filled.
47
+ *
48
+ * @param {object} raw
49
+ * @returns {object} validated spec
50
+ */
51
+ export function validateSpec(raw) {
52
+ if (!raw || typeof raw !== 'object') throw new Error('Spec must be a JSON object');
53
+
54
+ const id = raw.id;
55
+ if (typeof id !== 'string' || !id.trim()) throw new Error('spec.id must be a non-empty string');
56
+
57
+ const fixture = raw.fixture;
58
+ if (!fixture || typeof fixture !== 'object') throw new Error('spec.fixture must be an object');
59
+ if (typeof fixture.goal !== 'string' || !fixture.goal.trim()) {
60
+ throw new Error('spec.fixture.goal must be a non-empty string');
61
+ }
62
+
63
+ const configs = raw.configs;
64
+ if (!Array.isArray(configs) || configs.length === 0) {
65
+ throw new Error('spec.configs must be a non-empty array');
66
+ }
67
+ const labels = new Set();
68
+ // Fix #5: detect post-sanitization runId collisions. Distinct labels like
69
+ // "a/b" and "a?b" both sanitize to "a_b" and would overwrite each other's
70
+ // workspace directory. Fail-closed at validation rather than silently clobber.
71
+ const sanitizedToLabel = new Map();
72
+ for (let i = 0; i < configs.length; i++) {
73
+ const cfg = configs[i];
74
+ if (typeof cfg.label !== 'string' || !cfg.label.trim()) {
75
+ throw new Error(`spec.configs[${i}].label must be a non-empty string`);
76
+ }
77
+ if (labels.has(cfg.label)) throw new Error(`spec.configs: duplicate label "${cfg.label}"`);
78
+ labels.add(cfg.label);
79
+ const sanitized = cfg.label.replace(/[^a-zA-Z0-9_-]/g, '_');
80
+ if (sanitizedToLabel.has(sanitized)) {
81
+ throw new Error(
82
+ `spec.configs: labels "${sanitizedToLabel.get(sanitized)}" and "${cfg.label}" ` +
83
+ `both sanitize to "${sanitized}" — rename one to avoid runId collision`
84
+ );
85
+ }
86
+ sanitizedToLabel.set(sanitized, cfg.label);
87
+ try { validateAgentString(cfg.implementer); } catch (err) {
88
+ throw new Error(`spec.configs[${i}].implementer: ${err.message}`);
89
+ }
90
+ try { validateAgentString(cfg.reviewer); } catch (err) {
91
+ throw new Error(`spec.configs[${i}].reviewer: ${err.message}`);
92
+ }
93
+ }
94
+
95
+ const reps = raw.reps;
96
+ if (!Number.isInteger(reps) || reps < 1) throw new Error('spec.reps must be an integer >= 1');
97
+
98
+ const parallelism = raw.parallelism ?? 1;
99
+ if (!Number.isInteger(parallelism) || parallelism < 1) {
100
+ throw new Error('spec.parallelism must be an integer >= 1');
101
+ }
102
+
103
+ const buildTimeoutMs = raw.buildTimeoutMs ?? 1_800_000;
104
+ if (!Number.isInteger(buildTimeoutMs) || buildTimeoutMs < 1) {
105
+ throw new Error('spec.buildTimeoutMs must be a positive integer');
106
+ }
107
+
108
+ const judgeSpec = raw.judge ?? { enabled: false };
109
+ if (typeof judgeSpec.enabled !== 'boolean') {
110
+ throw new Error('spec.judge.enabled must be a boolean');
111
+ }
112
+ if (judgeSpec.enabled) {
113
+ if (typeof judgeSpec.model !== 'string' || !judgeSpec.model.trim()) {
114
+ throw new Error('spec.judge.model must be a non-empty string when judge.enabled=true');
115
+ }
116
+ try { validateAgentString(judgeSpec.model); } catch (err) {
117
+ throw new Error(`spec.judge.model: ${err.message}`);
118
+ }
119
+ // Bias guard: warn (not fail) when judge model is a config under test
120
+ for (const cfg of configs) {
121
+ if (cfg.implementer === judgeSpec.model || cfg.reviewer === judgeSpec.model) {
122
+ process.stderr.write(
123
+ `[experiment] WARN: judge model "${judgeSpec.model}" is used in config "${cfg.label}" ` +
124
+ `— this may introduce score bias.\n`
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ return {
131
+ id,
132
+ fixture: {
133
+ goal: fixture.goal,
134
+ seedRepo: fixture.seedRepo ?? null,
135
+ seedRef: fixture.seedRef ?? null,
136
+ },
137
+ configs,
138
+ reps,
139
+ parallelism,
140
+ buildTimeoutMs,
141
+ judge: judgeSpec,
142
+ };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Matrix expansion
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Expand the run matrix (configs × reps) into an ordered list of run descriptors.
151
+ * runId is stable: `${spec.id}_${config.label}_rep${rep}` with non-safe chars replaced.
152
+ *
153
+ * @param {object} spec Validated spec
154
+ * @returns {{ runId: string, config: object, rep: number }[]}
155
+ */
156
+ export function expandMatrix(spec) {
157
+ const runs = [];
158
+ for (const config of spec.configs) {
159
+ for (let rep = 1; rep <= spec.reps; rep++) {
160
+ const raw = `${spec.id}_${config.label}_rep${rep}`;
161
+ const runId = raw.replace(/[^a-zA-Z0-9_-]/g, '_');
162
+ runs.push({ runId, config, rep });
163
+ }
164
+ }
165
+ return runs;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Sandbox scaffolding
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Set up a minimal compose project in the sandbox workspace so that
174
+ * `compose build EXP-FIXTURE` can run inside it.
175
+ *
176
+ * @param {string} workspace Sandbox workspace root
177
+ * @param {string} goal The fixture's natural-language goal
178
+ */
179
+ export function _scaffoldSandboxProject(workspace, goal) {
180
+ // Clear any pre-existing .compose/ from the workspace FIRST. A seeded-fixture
181
+ // clone (fixture.seedRepo) may carry tracked .compose/data/* files that would:
182
+ // (a) appear in diff.patch/filesChanged because .gitignore only excludes
183
+ // UNTRACKED files — tracked ones still show in `git diff`.
184
+ // (b) cause collect() to read the SEED's stale build-history.jsonl and
185
+ // misreport this run's completed/cost/tests if the build exits before
186
+ // appending a fresh row.
187
+ // rmSync with force:true is safe for greenfield: provision() creates an empty
188
+ // .compose/data which we immediately re-create below.
189
+ rmSync(join(workspace, '.compose'), { recursive: true, force: true });
190
+
191
+ // Exclude Compose's own bookkeeping from git so it never contaminates the
192
+ // diff measurement, judge input, or filesChanged/linesChanged metrics.
193
+ // .compose/ holds build-history, build-stream, vision-state, active-build, etc.
194
+ // — all Compose infrastructure, not product changes. Written before the
195
+ // baseline commit so `git add -A` never stages any .compose/ file.
196
+ //
197
+ // Append-or-create: if the workspace already has a .gitignore (e.g. from a
198
+ // seeded fixture clone), preserve its existing rules and only add .compose/ if
199
+ // not already listed. Overwriting would silently delete node_modules/, dist/,
200
+ // and similar rules, causing those artifact trees to appear in the diff and
201
+ // pollute filesChanged/linesChanged/judge input.
202
+ const gitignorePath = join(workspace, '.gitignore');
203
+ let gitignoreContent = '';
204
+ try { gitignoreContent = readFileSync(gitignorePath, 'utf-8'); } catch { /* new file */ }
205
+ const existingLines = gitignoreContent.split('\n');
206
+ if (!existingLines.includes('.compose/')) {
207
+ const prefix = gitignoreContent && !gitignoreContent.endsWith('\n') ? '\n' : '';
208
+ writeFileSync(gitignorePath, gitignoreContent + prefix + '.compose/\n');
209
+ }
210
+
211
+ // capabilities.lifecycle=false avoids MCP server dependency during headless runs.
212
+ const composeDir = join(workspace, '.compose');
213
+ mkdirSync(join(composeDir, 'data'), { recursive: true });
214
+ writeFileSync(
215
+ join(composeDir, 'compose.json'),
216
+ JSON.stringify({
217
+ version: 2,
218
+ capabilities: { stratum: true, lifecycle: false, guard: false },
219
+ }, null, 2) + '\n'
220
+ );
221
+
222
+ // Copy the real build pipeline into the sandbox so the experiment measures
223
+ // the same pipeline that production uses.
224
+ const pipelinesDir = join(workspace, 'pipelines');
225
+ mkdirSync(pipelinesDir, { recursive: true });
226
+ const srcPipeline = join(COMPOSE_ROOT, 'pipelines', 'build.stratum.yaml');
227
+ if (existsSync(srcPipeline)) {
228
+ copyFileSync(srcPipeline, join(pipelinesDir, 'build.stratum.yaml'));
229
+ }
230
+
231
+ // docs/features/EXP-FIXTURE/
232
+ const featureDir = join(workspace, 'docs', 'features', FEATURE_CODE);
233
+ mkdirSync(featureDir, { recursive: true });
234
+ writeFileSync(join(featureDir, 'design.md'),
235
+ `# ${FEATURE_CODE} — Experiment Fixture\n\n${goal}\n`);
236
+ writeFileSync(join(featureDir, 'feature.json'),
237
+ JSON.stringify({ code: FEATURE_CODE, description: goal, status: 'PLANNED' }, null, 2) + '\n');
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Real (default) headless build runner
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Spawn a headless `compose build` and await completion.
246
+ *
247
+ * This is the PRODUCTION default. Tests inject a fake via opts._runBuild so
248
+ * no real LLM build is ever triggered during automated testing.
249
+ *
250
+ * @param {{ implementer: string, reviewer: string }} config
251
+ * @param {NodeJS.ProcessEnv} sandboxEnv
252
+ * @param {{ timeoutMs: number, cwd: string, featureCode: string }} opts
253
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
254
+ */
255
+ export async function realHeadlessBuild(config, sandboxEnv, { timeoutMs, cwd, featureCode }) {
256
+ return new Promise((resolve) => {
257
+ const startMs = Date.now(); // track wall time so crashed/timed-out runs still get a duration (fix D)
258
+
259
+ const args = [
260
+ COMPOSE_BIN, 'build', featureCode, '--skip-triage',
261
+ `--implementer=${config.implementer}`,
262
+ `--reviewer=${config.reviewer}`,
263
+ ];
264
+
265
+ const child = spawn(process.execPath, args, { cwd, env: sandboxEnv, stdio: 'pipe' });
266
+ const out = []; const err = [];
267
+ child.stdout.on('data', c => out.push(c));
268
+ child.stderr.on('data', c => err.push(c));
269
+
270
+ let timedOut = false;
271
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
272
+
273
+ child.on('close', code => {
274
+ clearTimeout(timer);
275
+ resolve({
276
+ stdout: Buffer.concat(out).toString('utf-8'),
277
+ stderr: Buffer.concat(err).toString('utf-8'),
278
+ exitCode: code ?? 1,
279
+ timedOut,
280
+ wallMs: Date.now() - startMs,
281
+ });
282
+ });
283
+ child.on('error', err2 => {
284
+ clearTimeout(timer);
285
+ resolve({ stdout: '', stderr: err2.message, exitCode: 1, timedOut: false, wallMs: Date.now() - startMs });
286
+ });
287
+ });
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Semaphore
292
+ // ---------------------------------------------------------------------------
293
+
294
+ function makeSemaphore(maxConcurrent) {
295
+ let running = 0;
296
+ const waiters = [];
297
+ return {
298
+ acquire() {
299
+ if (running < maxConcurrent) { running++; return Promise.resolve(); }
300
+ return new Promise(res => waiters.push(res));
301
+ },
302
+ release() {
303
+ running--;
304
+ if (waiters.length > 0) { running++; waiters.shift()(); }
305
+ },
306
+ };
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Single-run execution
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /**
314
+ * Execute one experiment run: provision → scaffold → build → collect → judge → write record.
315
+ *
316
+ * @param {{ runId: string, config: object, rep: number }} runDesc
317
+ * @param {object} spec
318
+ * @param {string} expRoot
319
+ * @param {object|null} stratum
320
+ * @param {Function} buildRunner Injected build function (default: realHeadlessBuild)
321
+ * @returns {Promise<object>} Per-run record
322
+ */
323
+ async function executeRun(runDesc, spec, expRoot, stratum, buildRunner) {
324
+ const { runId, config, rep } = runDesc;
325
+
326
+ process.stderr.write(`[experiment] ${runId}: provisioning...\n`);
327
+ const sandbox = await provision({
328
+ fixture: spec.fixture, runId, expRoot, composePath: COMPOSE_ROOT,
329
+ });
330
+
331
+ _scaffoldSandboxProject(sandbox.workspace, spec.fixture.goal);
332
+
333
+ // Fix #1: baseline commit — stage scaffolded files and commit so we have a
334
+ // well-defined SHA to diff against after the build. The real compose build
335
+ // commits during its ship step, leaving `git diff HEAD` empty on success.
336
+ // Diffing from this baseline captures all changes the build produced (whether
337
+ // committed in-process by ship or left as working-tree changes on failure).
338
+ // The --allow-empty flag handles the edge case where scaffoldSandboxProject
339
+ // wrote no files (unlikely but safe).
340
+ let baselineSha = null;
341
+ let baselineFailed = false;
342
+ try {
343
+ await new Promise((res, rej) => {
344
+ const p = spawn('git', ['add', '-A'], { cwd: sandbox.workspace, stdio: 'pipe' });
345
+ p.on('close', code => code === 0 ? res() : rej(new Error(`git add failed: ${code}`)));
346
+ p.on('error', rej);
347
+ });
348
+ await new Promise((res, rej) => {
349
+ const p = spawn('git', ['commit', '-q', '--allow-empty', '-m', 'baseline'],
350
+ { cwd: sandbox.workspace, stdio: 'pipe' });
351
+ p.on('close', code => code === 0 ? res() : rej(new Error(`baseline commit failed: ${code}`)));
352
+ p.on('error', rej);
353
+ });
354
+ baselineSha = await new Promise((res, rej) => {
355
+ const p = spawn('git', ['rev-parse', 'HEAD'], { cwd: sandbox.workspace, stdio: 'pipe' });
356
+ const chunks = [];
357
+ p.stdout.on('data', c => chunks.push(c));
358
+ p.on('close', () => res(Buffer.concat(chunks).toString('utf-8').trim()));
359
+ p.on('error', rej);
360
+ });
361
+ } catch {
362
+ // Baseline commit failed (no git, bad config, etc.) — diff/stat will fall
363
+ // back to `git diff HEAD` which may be wrong for in-process commits. Flag
364
+ // the run as suspect so consumers know metrics may undercount changes (fix E).
365
+ baselineFailed = true;
366
+ }
367
+
368
+ process.stderr.write(
369
+ `[experiment] ${runId}: building (impl=${config.implementer}, rev=${config.reviewer})...\n`
370
+ );
371
+ const buildResult = await buildRunner(config, sandbox.env, {
372
+ timeoutMs: spec.buildTimeoutMs,
373
+ cwd: sandbox.workspace,
374
+ featureCode: FEATURE_CODE,
375
+ });
376
+
377
+ // Save build log
378
+ const logPath = join(sandbox.runDir, 'build.log');
379
+ const diffPath = join(sandbox.runDir, 'diff.patch');
380
+ writeFileSync(logPath, buildResult.stdout + '\n---STDERR---\n' + buildResult.stderr);
381
+
382
+ // Capture diff against the baseline commit (not HEAD) so that changes
383
+ // committed in-process by the real build's ship step appear in the patch.
384
+ // `git add -A` stages any untracked files the build wrote (new source files,
385
+ // docs) so they appear in the diff even if the build did not stage them.
386
+ let diff = '';
387
+ try {
388
+ await new Promise((res, rej) => {
389
+ const p = spawn('git', ['add', '-A'], { cwd: sandbox.workspace, stdio: 'pipe' });
390
+ p.on('close', () => res()); p.on('error', rej);
391
+ });
392
+ const diffArgs = baselineSha ? ['diff', baselineSha] : ['diff', 'HEAD'];
393
+ diff = await new Promise((res, rej) => {
394
+ const p = spawn('git', diffArgs, { cwd: sandbox.workspace, stdio: 'pipe' });
395
+ const chunks = [];
396
+ p.stdout.on('data', c => chunks.push(c));
397
+ p.on('close', () => res(Buffer.concat(chunks).toString('utf-8')));
398
+ p.on('error', rej);
399
+ });
400
+ } catch { /* leave diff empty */ }
401
+ writeFileSync(diffPath, diff);
402
+
403
+ process.stderr.write(`[experiment] ${runId}: collecting metrics...\n`);
404
+ const metrics = collect({ sandbox, buildResult, baselineSha });
405
+
406
+ let judgeResult = null;
407
+ if (spec.judge.enabled && stratum) {
408
+ process.stderr.write(`[experiment] ${runId}: judging...\n`);
409
+ judgeResult = await judge({
410
+ diff, goal: spec.fixture.goal, judgeModel: spec.judge.model, stratum,
411
+ cwd: sandbox.workspace,
412
+ });
413
+ }
414
+
415
+ // Stamp endedAt into manifest
416
+ const manifest = {
417
+ composeSha: '', fixtureGoal: spec.fixture.goal,
418
+ seedRef: spec.fixture.seedRef, startedAt: '', endedAt: new Date().toISOString(),
419
+ };
420
+ try {
421
+ const m = JSON.parse(readFileSync(join(sandbox.runDir, 'manifest.json'), 'utf-8'));
422
+ manifest.composeSha = m.composeSha ?? '';
423
+ manifest.startedAt = m.startedAt ?? '';
424
+ writeFileSync(
425
+ join(sandbox.runDir, 'manifest.json'),
426
+ JSON.stringify({ ...m, endedAt: manifest.endedAt }, null, 2) + '\n'
427
+ );
428
+ } catch { /* best-effort */ }
429
+
430
+ const record = {
431
+ runId, configLabel: config.label, rep, metrics, judge: judgeResult,
432
+ artifacts: { diffPath, logPath }, manifest,
433
+ // baselineFailed=true signals that the pre-build baseline commit failed so
434
+ // diff.patch and filesChanged/linesChanged may undercount the build's changes.
435
+ ...(baselineFailed ? { baselineFailed: true } : {}),
436
+ };
437
+ writeFileSync(
438
+ join(sandbox.runDir, `${runId}.json`),
439
+ JSON.stringify(record, null, 2) + '\n'
440
+ );
441
+ process.stderr.write(`[experiment] ${runId}: done (completed=${metrics.outcome.completed})\n`);
442
+ return record;
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Public API
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /**
450
+ * Run a full A/B experiment from a spec file.
451
+ *
452
+ * @param {string} specPath Absolute path to the experiment spec JSON file.
453
+ * @param {object} [opts={}]
454
+ * @param {boolean} [opts.pruneWorkspaces=false] Remove sandbox workspaces after metrics.
455
+ * @param {object} [opts.stratum=null] Connected stratum client (for judge).
456
+ * @param {Function} [opts._runBuild]
457
+ * Override the build runner. Signature:
458
+ * `(config, sandboxEnv, { timeoutMs, cwd, featureCode }) → Promise<buildResult>`
459
+ * Default: `realHeadlessBuild` (spawns real compose build).
460
+ * In tests: pass a fake that writes canned artifacts to `cwd/.compose/` so the
461
+ * orchestrator is exercised end-to-end with NO real LLM calls.
462
+ *
463
+ * @returns {Promise<{ expRoot, resultsPath, reportPath, runs }>}
464
+ */
465
+ export async function runExperiment(specPath, opts = {}) {
466
+ const { pruneWorkspaces = false, stratum = null, _runBuild = realHeadlessBuild } = opts;
467
+
468
+ let raw;
469
+ try { raw = JSON.parse(readFileSync(specPath, 'utf-8')); }
470
+ catch (err) { throw new Error(`Failed to load spec from ${specPath}: ${err.message}`); }
471
+ const spec = validateSpec(raw);
472
+
473
+ const expRoot = resolve(dirname(specPath), `${spec.id}-exp`);
474
+ mkdirSync(join(expRoot, 'runs'), { recursive: true });
475
+
476
+ const runDescs = expandMatrix(spec);
477
+ process.stderr.write(
478
+ `[experiment] ${spec.id}: ${runDescs.length} runs ` +
479
+ `(${spec.configs.length} cfg × ${spec.reps} rep(s), parallelism=${spec.parallelism})\n`
480
+ );
481
+
482
+ const sem = makeSemaphore(spec.parallelism);
483
+
484
+ const runs = await Promise.all(
485
+ runDescs.map(runDesc => async () => {
486
+ await sem.acquire();
487
+ try {
488
+ return await executeRun(runDesc, spec, expRoot, stratum, _runBuild)
489
+ .catch(err => {
490
+ process.stderr.write(`[experiment] ${runDesc.runId}: FAILED — ${err.message}\n`);
491
+ const runDir = join(expRoot, 'runs', runDesc.runId);
492
+ mkdirSync(runDir, { recursive: true });
493
+ const record = {
494
+ runId: runDesc.runId, configLabel: runDesc.config.label, rep: runDesc.rep,
495
+ metrics: {
496
+ cost: { tokensIn: 0, tokensOut: 0, calls: 0, wallMs: 0, usd: null },
497
+ outcome: { completed: false, health: null, testsPass: null, testsTotal: null,
498
+ filesChanged: 0, linesChanged: 0 },
499
+ process: { reviewIters: 0, gateFailures: 0, retries: 0, escalations: 0 },
500
+ },
501
+ judge: null, artifacts: { diffPath: null, logPath: null },
502
+ manifest: {
503
+ composeSha: '', fixtureGoal: spec.fixture.goal, seedRef: spec.fixture.seedRef,
504
+ startedAt: new Date().toISOString(), endedAt: new Date().toISOString(),
505
+ },
506
+ _error: err.message,
507
+ };
508
+ writeFileSync(
509
+ join(runDir, `${runDesc.runId}.json`),
510
+ JSON.stringify(record, null, 2) + '\n'
511
+ );
512
+ return record;
513
+ });
514
+ } finally {
515
+ sem.release();
516
+ }
517
+ }).map(fn => fn())
518
+ );
519
+
520
+ if (pruneWorkspaces) {
521
+ const { rmSync } = await import('node:fs');
522
+ for (const rd of runDescs) {
523
+ try {
524
+ rmSync(join(expRoot, 'runs', rd.runId, 'workspace-tmp'), { recursive: true, force: true });
525
+ } catch { /* best-effort */ }
526
+ }
527
+ }
528
+
529
+ const results = aggregate(runs, spec.id);
530
+ const resultsPath = join(expRoot, 'results.json');
531
+ writeFileSync(resultsPath, JSON.stringify(results, null, 2) + '\n');
532
+
533
+ const reportMd = render(results);
534
+ const reportPath = join(expRoot, 'report.md');
535
+ writeFileSync(reportPath, reportMd);
536
+
537
+ process.stderr.write(`[experiment] ${spec.id}: complete → ${resultsPath}\n`);
538
+ return { expRoot, resultsPath, reportPath, runs };
539
+ }
@@ -30,7 +30,7 @@ function featuresBase(cwd, featuresDir) {
30
30
  * @property {string} [parent] - Parent feature/phase code (e.g., "STRAT-1", "Phase 6")
31
31
  * @property {string} [phase] - Phase heading for ROADMAP grouping
32
32
  * @property {number} [position] - Sort order within phase
33
- * @property {string} [complexity] - low | medium | high (from scope step)
33
+ * @property {string} [complexity] - S | M | L | XL (from scope step; enforced by COMPLEXITIES in feature-writer.js)
34
34
  * @property {object} [profile] - BuildProfile from scope step
35
35
  * @property {string} [created] - ISO date
36
36
  * @property {string} [updated] - ISO date
@@ -59,6 +59,10 @@ const TRANSITIONS = {
59
59
 
60
60
  const COMPLEXITIES = new Set(['S', 'M', 'L', 'XL']);
61
61
 
62
+ // COMP-ROADMAP-PLAN: `impact` carries the ideabox/plan estimate vocab onto the
63
+ // produced feature.json. Same vocab the ideabox uses (low|medium|high).
64
+ const IMPACTS = new Set(['low', 'medium', 'high']);
65
+
62
66
  // ---------------------------------------------------------------------------
63
67
  // Helpers
64
68
  // ---------------------------------------------------------------------------
@@ -93,6 +97,10 @@ function maybeIdempotent(args, fn) {
93
97
  * @param {number} [args.position]
94
98
  * @param {string} [args.parent]
95
99
  * @param {string[]} [args.tags]
100
+ * @param {object} [args.profile] COMP-ROADMAP-PLAN: triage build profile
101
+ * @param {string} [args.triageTimestamp] COMP-ROADMAP-PLAN: triage cache stamp
102
+ * @param {string} [args.plannedBy] COMP-ROADMAP-PLAN: originating plan session
103
+ * @param {string} [args.impact] COMP-ROADMAP-PLAN: low | medium | high
96
104
  * @param {boolean} [args.force]
97
105
  * @param {string} [args.idempotency_key]
98
106
  */
@@ -112,6 +120,20 @@ export async function addRoadmapEntry(cwd, args) {
112
120
  if (!STATUSES.has(status)) {
113
121
  throw new Error(`feature-writer: invalid status "${status}"`);
114
122
  }
123
+ // COMP-ROADMAP-PLAN: minimal type validation for the plan-handshake fields.
124
+ if (args.profile !== undefined &&
125
+ (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile))) {
126
+ throw new Error('feature-writer: invalid profile (must be an object)');
127
+ }
128
+ if (args.triageTimestamp !== undefined && typeof args.triageTimestamp !== 'string') {
129
+ throw new Error('feature-writer: invalid triageTimestamp (must be a string)');
130
+ }
131
+ if (args.plannedBy !== undefined && typeof args.plannedBy !== 'string') {
132
+ throw new Error('feature-writer: invalid plannedBy (must be a string)');
133
+ }
134
+ if (args.impact !== undefined && !IMPACTS.has(args.impact)) {
135
+ throw new Error(`feature-writer: invalid impact "${args.impact}"`);
136
+ }
115
137
 
116
138
  return maybeIdempotent({ ...args, cwd }, async () => {
117
139
  const provider = await getProvider(cwd);
@@ -136,6 +158,14 @@ export async function addRoadmapEntry(cwd, args) {
136
158
  : await nextPositionInPhase(provider, args.phase);
137
159
  if (args.parent) feature.parent = args.parent;
138
160
  if (args.tags && args.tags.length) feature.tags = args.tags;
161
+ // COMP-ROADMAP-PLAN: plan-handshake fields (provider-backed via createFeature
162
+ // below — one write that also regenerates ROADMAP). Triage reads
163
+ // profile + triageTimestamp to no-op re-triage on a plan-produced feature;
164
+ // plannedBy lets `build` ratify (not rewrite) the plan-authored design.
165
+ if (args.profile !== undefined) feature.profile = args.profile;
166
+ if (args.triageTimestamp !== undefined) feature.triageTimestamp = args.triageTimestamp;
167
+ if (args.plannedBy !== undefined) feature.plannedBy = args.plannedBy;
168
+ if (args.impact !== undefined) feature.impact = args.impact;
139
169
 
140
170
  let roundtrip = null;
141
171
  if (isLocalProvider(provider)) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * flow-state.js — small read-only helpers over the persisted Stratum flow state
3
+ * (`~/.stratum/flows/<flowId>.json`).
4
+ *
5
+ * Shared by the gate handlers in build.js and new.js so the gate id can be made
6
+ * round-aware (COMP-PLAN-GATE-LOOP).
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+
12
+ /**
13
+ * Read Stratum's current round for a flow from its persisted state file.
14
+ *
15
+ * The await_gate dispatch Stratum returns does not carry the round, but the
16
+ * persisted flow file does. Threading the round into the gate id
17
+ * (`<flowId>:<stepId>:<round>`) makes each gate re-entry after a `revise` a
18
+ * fresh, pending gate rather than colliding with the prior resolved gate and
19
+ * replaying its stale outcome.
20
+ *
21
+ * Fail-open: returns 1 (the legacy default) if the file is missing, unreadable,
22
+ * or lacks an integer round — a read failure must never block a gate.
23
+ *
24
+ * @param {string} flowId
25
+ * @returns {number}
26
+ */
27
+ export function readFlowRound(flowId) {
28
+ try {
29
+ const flowFile = join(homedir(), '.stratum', 'flows', `${flowId}.json`);
30
+ const state = JSON.parse(readFileSync(flowFile, 'utf-8'));
31
+ const r = state?.round;
32
+ return Number.isInteger(r) && r >= 0 ? r : 1;
33
+ } catch {
34
+ return 1;
35
+ }
36
+ }