@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,306 @@
1
+ /**
2
+ * experiment-report.js — Aggregation and Markdown rendering for COMP-MODEL-AB.
3
+ *
4
+ * Pure functions over run records — no LLM calls, no I/O.
5
+ *
6
+ * aggregate(runs, experimentId) → results (matches the contract's results.json shape)
7
+ * render(results) → Markdown string (configs×metrics table, winner-per-metric, caveats)
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Numeric utilities
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Compute median of a sorted (ascending) numeric array.
16
+ * @param {number[]} sorted
17
+ * @returns {number}
18
+ */
19
+ function medianSorted(sorted) {
20
+ const n = sorted.length;
21
+ if (n === 0) return 0;
22
+ const mid = Math.floor(n / 2);
23
+ return n % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
24
+ }
25
+
26
+ /**
27
+ * Compute spread stats (median, min, max) for a set of values.
28
+ * Non-finite (null/undefined/NaN) values are dropped.
29
+ *
30
+ * @param {(number|null|undefined)[]} values
31
+ * @returns {{ median: number, min: number, max: number, n: number } | null}
32
+ * null when there are no valid values.
33
+ */
34
+ export function computeSpread(values) {
35
+ const nums = values.filter(v => typeof v === 'number' && isFinite(v));
36
+ if (nums.length === 0) return null;
37
+ const sorted = [...nums].sort((a, b) => a - b);
38
+ return {
39
+ median: medianSorted(sorted),
40
+ min: sorted[0],
41
+ max: sorted[sorted.length - 1],
42
+ n: nums.length,
43
+ };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Metric path extraction (dotted paths into a run's metrics)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const METRIC_PATHS = [
51
+ 'cost.tokensIn',
52
+ 'cost.tokensOut',
53
+ 'cost.calls',
54
+ 'cost.wallMs',
55
+ 'cost.usd',
56
+ 'outcome.health',
57
+ 'outcome.testsPass',
58
+ 'outcome.testsTotal',
59
+ 'outcome.filesChanged',
60
+ 'outcome.linesChanged',
61
+ 'process.reviewIters',
62
+ 'process.gateFailures',
63
+ 'process.retries',
64
+ 'process.escalations',
65
+ ];
66
+
67
+ const JUDGE_PATHS = [
68
+ 'judge.correctness',
69
+ 'judge.clarity',
70
+ 'judge.idiomaticity',
71
+ ];
72
+
73
+ /**
74
+ * Extract a dotted-path value from a run record.
75
+ * @param {object} run
76
+ * @param {string} path e.g. 'cost.tokensIn', 'judge.correctness'
77
+ * @returns {number|null}
78
+ */
79
+ function extractValue(run, path) {
80
+ const parts = path.split('.');
81
+ let obj = run;
82
+ for (const part of parts) {
83
+ if (obj == null || typeof obj !== 'object') return null;
84
+ obj = obj[part];
85
+ }
86
+ return typeof obj === 'number' && isFinite(obj) ? obj : null;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Aggregate
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Aggregate runs into a results object (the contract's results.json shape).
95
+ *
96
+ * Only completed runs contribute to metric aggregations (so a crash never
97
+ * silently drags down the median with zeros). nCompleted/nTotal are always
98
+ * reported. Judge metrics include all runs where judge is non-null (judge
99
+ * can succeed even when the build failed).
100
+ *
101
+ * @param {object[]} runs Array of per-run records
102
+ * @param {string} experimentId
103
+ * @returns {object} The results object
104
+ */
105
+ export function aggregate(runs, experimentId = 'experiment') {
106
+ // Group by configLabel
107
+ const byConfig = new Map();
108
+ for (const run of runs) {
109
+ const label = run.configLabel ?? 'unknown';
110
+ if (!byConfig.has(label)) byConfig.set(label, []);
111
+ byConfig.get(label).push(run);
112
+ }
113
+
114
+ const configResults = [];
115
+ for (const [label, configRuns] of byConfig) {
116
+ const nTotal = configRuns.length;
117
+ const completed = configRuns.filter(r => r.metrics?.outcome?.completed);
118
+ const nCompleted = completed.length;
119
+
120
+ const metrics = {};
121
+
122
+ // Build metrics from completed runs
123
+ for (const path of METRIC_PATHS) {
124
+ const vals = completed.map(r => extractValue(r, `metrics.${path}`));
125
+ const spread = computeSpread(vals);
126
+ if (spread) metrics[path] = { median: spread.median, min: spread.min, max: spread.max };
127
+ }
128
+
129
+ // Judge metrics from all runs where judge is non-null
130
+ const judged = configRuns.filter(r => r.judge != null);
131
+ for (const path of JUDGE_PATHS) {
132
+ const vals = judged.map(r => extractValue(r, path));
133
+ const spread = computeSpread(vals);
134
+ if (spread) metrics[path] = { median: spread.median, min: spread.min, max: spread.max };
135
+ }
136
+
137
+ configResults.push({ label, nCompleted, nTotal, metrics });
138
+ }
139
+
140
+ return {
141
+ experimentId,
142
+ configs: configResults,
143
+ generatedAt: new Date().toISOString(),
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Render
149
+ // ---------------------------------------------------------------------------
150
+
151
+ // Columns to include in the comparison table (ordered)
152
+ const TABLE_COLUMNS = [
153
+ { key: 'nCompleted', label: 'Completed', fmt: (v) => String(v) },
154
+ { key: 'cost.usd', label: 'Cost $', fmt: (v) => v != null ? `$${v.toFixed(3)}` : '-' },
155
+ { key: 'cost.tokensIn', label: 'Tokens In', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
156
+ { key: 'cost.tokensOut', label: 'Tokens Out', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
157
+ { key: 'cost.wallMs', label: 'Wall ms', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
158
+ { key: 'outcome.health', label: 'Health', fmt: (v) => v != null ? v.toFixed(1) : '-' },
159
+ { key: 'outcome.testsPass', label: 'Tests Pass', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
160
+ { key: 'outcome.filesChanged', label: 'Files', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
161
+ { key: 'outcome.linesChanged', label: 'Lines', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
162
+ { key: 'process.reviewIters', label: 'Review Iters', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
163
+ { key: 'process.gateFailures', label: 'Gate Fails', fmt: (v) => v != null ? String(Math.round(v)) : '-' },
164
+ { key: 'judge.correctness', label: 'Correctness', fmt: (v) => v != null ? v.toFixed(1) : '-' },
165
+ { key: 'judge.clarity', label: 'Clarity', fmt: (v) => v != null ? v.toFixed(1) : '-' },
166
+ { key: 'judge.idiomaticity', label: 'Idiom.', fmt: (v) => v != null ? v.toFixed(1) : '-' },
167
+ ];
168
+
169
+ // Higher is better for these metrics; lower is better for the rest
170
+ const HIGHER_IS_BETTER = new Set([
171
+ 'nCompleted',
172
+ 'outcome.health',
173
+ 'outcome.testsPass',
174
+ 'judge.correctness',
175
+ 'judge.clarity',
176
+ 'judge.idiomaticity',
177
+ ]);
178
+
179
+ /**
180
+ * Determine the winner config label for each column.
181
+ * Returns a Map<columnKey, winnerLabel>.
182
+ */
183
+ function computeWinners(configResults) {
184
+ const winners = new Map();
185
+ for (const col of TABLE_COLUMNS) {
186
+ const key = col.key;
187
+ let best = null;
188
+ let bestVal = null;
189
+
190
+ for (const cfg of configResults) {
191
+ let val;
192
+ if (key === 'nCompleted') {
193
+ val = cfg.nCompleted;
194
+ } else {
195
+ val = cfg.metrics[key]?.median;
196
+ }
197
+ if (typeof val !== 'number' || !isFinite(val)) continue;
198
+
199
+ const isBetter = bestVal == null || (HIGHER_IS_BETTER.has(key) ? val > bestVal : val < bestVal);
200
+ if (isBetter) {
201
+ best = cfg.label;
202
+ bestVal = val;
203
+ }
204
+ }
205
+
206
+ if (best != null) winners.set(key, best);
207
+ }
208
+ return winners;
209
+ }
210
+
211
+ /**
212
+ * Render a Markdown comparison report.
213
+ *
214
+ * @param {object} results The aggregate results object
215
+ * @returns {string} Markdown
216
+ */
217
+ export function render(results) {
218
+ const { experimentId, configs, generatedAt } = results;
219
+ const lines = [];
220
+
221
+ lines.push(`# Experiment Report: ${experimentId}`);
222
+ lines.push('');
223
+ lines.push(`Generated: ${generatedAt}`);
224
+ lines.push('');
225
+
226
+ if (!configs || configs.length === 0) {
227
+ lines.push('_No runs found._');
228
+ return lines.join('\n');
229
+ }
230
+
231
+ // ---------- comparison table ----------
232
+
233
+ lines.push('## Results');
234
+ lines.push('');
235
+
236
+ const winners = computeWinners(configs);
237
+
238
+ // Table header
239
+ const headers = ['Config', ...TABLE_COLUMNS.map(c => c.label)];
240
+ lines.push(`| ${headers.join(' | ')} |`);
241
+ lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
242
+
243
+ // Table rows
244
+ for (const cfg of configs) {
245
+ const cells = [cfg.label];
246
+ for (const col of TABLE_COLUMNS) {
247
+ let val;
248
+ if (col.key === 'nCompleted') {
249
+ val = `${cfg.nCompleted}/${cfg.nTotal}`;
250
+ } else {
251
+ const med = cfg.metrics[col.key]?.median;
252
+ val = col.fmt(med ?? null);
253
+ }
254
+ const isWinner = winners.get(col.key) === cfg.label;
255
+ cells.push(isWinner ? `**${val}**` : val);
256
+ }
257
+ lines.push(`| ${cells.join(' | ')} |`);
258
+ }
259
+ lines.push('');
260
+
261
+ // ---------- winner summary ----------
262
+
263
+ lines.push('## Winner per Metric');
264
+ lines.push('');
265
+ for (const col of TABLE_COLUMNS) {
266
+ const winner = winners.get(col.key);
267
+ if (winner) {
268
+ lines.push(`- **${col.label}**: ${winner}`);
269
+ }
270
+ }
271
+ lines.push('');
272
+
273
+ // ---------- caveats ----------
274
+
275
+ const totalRuns = configs.reduce((s, c) => s + c.nTotal, 0);
276
+ const completedRuns = configs.reduce((s, c) => s + c.nCompleted, 0);
277
+ const hasJudge = configs.some(c => c.metrics['judge.correctness'] != null);
278
+ const maxVariance = (() => {
279
+ let maxRel = 0;
280
+ for (const cfg of configs) {
281
+ for (const path of [...METRIC_PATHS, ...JUDGE_PATHS]) {
282
+ const s = cfg.metrics[path];
283
+ if (!s || s.median === 0) continue;
284
+ const rel = (s.max - s.min) / s.median;
285
+ if (rel > maxRel) maxRel = rel;
286
+ }
287
+ }
288
+ return maxRel;
289
+ })();
290
+ const highVariance = maxVariance > 0.5;
291
+
292
+ lines.push('## Caveats');
293
+ lines.push('');
294
+ lines.push(`- N = ${totalRuns} total runs (${completedRuns} completed). ` +
295
+ (totalRuns < 5 ? 'Sample size is small; treat results as directional only.' : 'Medians are reported.'));
296
+ if (highVariance) {
297
+ lines.push(`- High variance observed (max relative spread ${(maxVariance * 100).toFixed(0)}%). ` +
298
+ 'Consider increasing reps for more stable estimates.');
299
+ }
300
+ if (hasJudge) {
301
+ lines.push('- Judge scores may reflect judge-model bias. The same held-constant judge model rated all configs.');
302
+ }
303
+ lines.push('');
304
+
305
+ return lines.join('\n');
306
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * experiment-sandbox.js — Provision an isolated sandbox workspace for COMP-MODEL-AB.
3
+ *
4
+ * Each sandbox is a fully isolated environment:
5
+ * workspace — a git-initialised temp dir (greenfield) or a clone/worktree at
6
+ * a pinned ref (seeded).
7
+ * data dir — <workspace>/.compose/data (COMPOSE_TARGET points here)
8
+ * env — COMPOSE_TARGET + COMPOSE_PORT(=unused) isolate both the data
9
+ * dir and the vision-state HTTP writes so the live :4001 server
10
+ * is never touched.
11
+ *
12
+ * CRITICAL isolation proof (design §"Sandbox isolation"):
13
+ * COMPOSE_TARGET=<workspace> → getDataDir() resolves to sandbox data dir.
14
+ * COMPOSE_PORT=<unused> → VisionWriter's _serverAvailable() probes that
15
+ * dead port, gets ECONNREFUSED, and falls back to
16
+ * direct file writes inside the sandbox — the live
17
+ * :4001 server is never contacted.
18
+ */
19
+
20
+ import { mkdirSync, writeFileSync, rmSync, existsSync, mkdtempSync } from 'node:fs';
21
+ import { join, resolve, dirname } from 'node:path';
22
+ import { execSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+
27
+ /**
28
+ * Port that will not have a server listening (the sandbox builds must not reach
29
+ * the live vision-state server). Picking a fixed high port that we never bind
30
+ * is simpler than dynamically finding a free port: ECONNREFUSED is instant.
31
+ */
32
+ const SANDBOX_VISION_PORT = 19997;
33
+
34
+ /**
35
+ * Derive the current Compose git SHA for provenance in manifest.json.
36
+ * Returns 'unknown' if the git call fails (e.g. not in a git repo).
37
+ *
38
+ * @param {string} composePath
39
+ * @returns {string}
40
+ */
41
+ function composeSha(composePath) {
42
+ try {
43
+ return execSync('git rev-parse HEAD', {
44
+ cwd: composePath,
45
+ encoding: 'utf-8',
46
+ timeout: 5_000,
47
+ }).trim();
48
+ } catch {
49
+ return 'unknown';
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Provision an isolated sandbox workspace for one experiment run.
55
+ *
56
+ * @param {object} args
57
+ * @param {{ goal: string, seedRepo: string|null, seedRef: string|null }} args.fixture
58
+ * Fixture definition — seedRepo=null means greenfield (git init empty dir).
59
+ * @param {string} args.runId Stable run identifier (e.g. "opus-impl-rep0")
60
+ * @param {string} args.expRoot Experiment root directory.
61
+ * @param {string} [args.composePath] Path to the Compose source for SHA lookup.
62
+ *
63
+ * @returns {Promise<{
64
+ * workspace: string,
65
+ * runDir: string,
66
+ * env: NodeJS.ProcessEnv,
67
+ * cleanup: (opts?: { pruneWorkspace?: boolean }) => void,
68
+ * }>}
69
+ *
70
+ * @throws {Error} if workspace provisioning fails (e.g. git operations error)
71
+ */
72
+ export async function provision({ fixture, runId, expRoot, composePath }) {
73
+ // Create the per-run output directory
74
+ const runDir = join(expRoot, 'runs', runId);
75
+ mkdirSync(runDir, { recursive: true });
76
+
77
+ // Create the workspace
78
+ const workspaceParent = join(runDir, 'workspace-tmp');
79
+ mkdirSync(workspaceParent, { recursive: true });
80
+ let workspace;
81
+
82
+ if (!fixture.seedRepo) {
83
+ // Greenfield: git init in a fresh temp dir
84
+ workspace = mkdtempSync(join(workspaceParent, 'ws-'));
85
+ try {
86
+ execSync('git init -q', { cwd: workspace, timeout: 10_000 });
87
+ execSync('git config user.email "compose-experiment@local"', { cwd: workspace, timeout: 5_000 });
88
+ execSync('git config user.name "Compose Experiment"', { cwd: workspace, timeout: 5_000 });
89
+ } catch (err) {
90
+ // Clean up the partially-initialised workspace before re-throwing so
91
+ // provision errors surface cleanly without leaving temp dirs behind.
92
+ try { rmSync(workspace, { recursive: true, force: true }); } catch { /* best-effort */ }
93
+ throw new Error(`Sandbox provision failed: git init in ${workspace}: ${err.message}`);
94
+ }
95
+ } else {
96
+ // Seeded: clone + checkout at pinned ref
97
+ const seedRef = fixture.seedRef ?? 'HEAD';
98
+ workspace = join(workspaceParent, `ws-${runId}`);
99
+ try {
100
+ execSync(`git clone --quiet "${fixture.seedRepo}" "${workspace}"`, { timeout: 60_000 });
101
+ execSync(`git checkout --quiet "${seedRef}"`, { cwd: workspace, timeout: 10_000 });
102
+ execSync('git config user.email "compose-experiment@local"', { cwd: workspace, timeout: 5_000 });
103
+ execSync('git config user.name "Compose Experiment"', { cwd: workspace, timeout: 5_000 });
104
+ } catch (err) {
105
+ try { rmSync(workspace, { recursive: true, force: true }); } catch { /* best-effort */ }
106
+ throw new Error(`Sandbox provision failed: seeded clone of ${fixture.seedRepo}@${seedRef}: ${err.message}`);
107
+ }
108
+ }
109
+
110
+ // Ensure the data dir exists (compose build will also create it, but having it
111
+ // pre-created makes it clearer what COMPOSE_TARGET points to).
112
+ const dataDir = join(workspace, '.compose', 'data');
113
+ mkdirSync(dataDir, { recursive: true });
114
+
115
+ // Build the env for the sandbox's child build process.
116
+ // COMPOSE_TARGET → data dir isolated to sandbox workspace.
117
+ // COMPOSE_PORT → dead port so VisionWriter degrades to direct file writes,
118
+ // never touching the live :4001 server.
119
+ const env = {
120
+ ...process.env,
121
+ COMPOSE_TARGET: workspace,
122
+ COMPOSE_PORT: String(SANDBOX_VISION_PORT),
123
+ };
124
+
125
+ // Write manifest.json for provenance
126
+ const manifest = {
127
+ composeSha: composeSha(composePath ?? resolve(__dirname, '..')),
128
+ fixtureGoal: fixture.goal ?? null,
129
+ seedRef: fixture.seedRef ?? null,
130
+ runId,
131
+ startedAt: new Date().toISOString(),
132
+ endedAt: null, // caller fills this in after the build
133
+ };
134
+ writeFileSync(join(runDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
135
+
136
+ /**
137
+ * Cleanup function.
138
+ * @param {{ pruneWorkspace?: boolean }} [opts]
139
+ */
140
+ function cleanup(opts = {}) {
141
+ if (opts.pruneWorkspace && existsSync(workspace)) {
142
+ try { rmSync(workspace, { recursive: true, force: true }); } catch { /* best-effort */ }
143
+ }
144
+ }
145
+
146
+ return { workspace, runDir, env, cleanup };
147
+ }