@smartmemory/compose 0.2.52-beta → 0.2.54-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.
- package/.claude/skills/compose/SKILL.md +0 -8
- package/README.md +39 -0
- package/bin/compose.js +378 -3
- package/dist/assets/App-D_tvTs_n.js +896 -0
- package/dist/assets/abnfDiagram-VRR7QNED-Dhb7O5rp.js +1 -0
- package/dist/assets/arc-C4oVdnk2.js +1 -0
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DqHrb5_F.js +36 -0
- package/dist/assets/blockDiagram-677ZJIJ3-DUyBayYK.js +132 -0
- package/dist/assets/{browser-CnKiSnlr.js → browser-Cr0recrN.js} +6 -6
- package/dist/assets/{c4Diagram-AHTNJAMY-DtDSNlOX.js → c4Diagram-LMCZKHZV-CxlkWMRK.js} +1 -1
- package/dist/assets/channel-xb4mQfuL.js +1 -0
- package/dist/assets/{chunk-QZHKN3VN-BMWFLZ8t.js → chunk-2Q5K7J3B-C6QJsyOQ.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-CqjNXooj.js → chunk-32BRIVSS-D9NgYu9W.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BAa7Edp6.js → chunk-5VM5RSS4-DbD86rhK.js} +1 -1
- package/dist/assets/chunk-EX3LRPZG-E4m4xNMw.js +231 -0
- package/dist/assets/{chunk-4BX2VUAB-oXU2npoL.js → chunk-JWPE2WC7-DHGlRWbw.js} +1 -1
- package/dist/assets/chunk-MOJQB5TN-BblYnFUq.js +88 -0
- package/dist/assets/chunk-RYQCIY6F-CGT-88vL.js +1 -0
- package/dist/assets/chunk-V7JOEXUC-BJJmX0mc.js +206 -0
- package/dist/assets/{chunk-EDXVE4YY-Bse2kdRh.js → chunk-VR4S4FIN-B4ygduCD.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-BEEk2FdH.js → chunk-XXDRQBXY-7APXuI2s.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-BIvgPTgh.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-BIvgPTgh.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-NYnH3mVW.js → cose-bilkent-JH36ORCC-DnVcT6R6.js} +1 -1
- package/dist/assets/cynefin-VYW2F7L2-C9qiUSfX.js +178 -0
- package/dist/assets/cynefinDiagram-TSTJHNR4-BowkQhEu.js +62 -0
- package/dist/assets/dagre-VKFMJZFB-Y5V-7U3p.js +4 -0
- package/dist/assets/diagram-FQU43EPY-I7wDE-Xp.js +3 -0
- package/dist/assets/diagram-G47NLZAW-DapGa1HI.js +24 -0
- package/dist/assets/diagram-NH7WQ7WH-CHO1JntO.js +24 -0
- package/dist/assets/diagram-OA4YK3LP-mBWP2D1G.js +30 -0
- package/dist/assets/diagram-WEI45ONY-C8KN8YnF.js +41 -0
- package/dist/assets/ebnfDiagram-CCIWWBDH-ZkJG9NOL.js +1 -0
- package/dist/assets/erDiagram-Q63AITRT-DGnL3kW1.js +85 -0
- package/dist/assets/flowDiagram-23GEKE2U-DHYupfOu.js +156 -0
- package/dist/assets/ganttDiagram-NO4QXBWP-B1B2zpwR.js +292 -0
- package/dist/assets/gitGraphDiagram-IHSO6WYX-B9_OP4Fs.js +106 -0
- package/dist/assets/graph-C9eacEi8.js +1 -0
- package/dist/assets/graph-xkel59g2.js +331 -0
- package/dist/assets/index-0Hq9436Q.js +119 -0
- package/dist/assets/index-LIwREYgH.css +1 -0
- package/dist/assets/infoDiagram-FWYZ7A6U-D-a34TqE.js +2 -0
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-CK2IFFAP.js → ishikawaDiagram-FXEZZL3T-h0bUvXGV.js} +5 -5
- package/dist/assets/{journeyDiagram-VCZTEJTY-DAH5Stkf.js → journeyDiagram-5HDEW3XC-B4lm0RBa.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bi0aCqkv.js → kanban-definition-HUTT4EX6-msvfouRX.js} +7 -7
- package/dist/assets/katex-C5jXJg4s.js +257 -0
- package/dist/assets/layout-DEXfKzaS.js +1 -0
- package/dist/assets/{linear-ClGEGlS2.js → linear-B0trv2xN.js} +1 -1
- package/dist/assets/map-Czzmt4hB.js +1 -0
- package/dist/assets/{mindmap-definition-QFDTVHPH-Wjr-PgC6.js → mindmap-definition-LN4V7U3C-CvswbRgd.js} +7 -7
- package/dist/assets/{mobile-Chw8RWyH.js → mobile-CNLMhdFP.js} +2 -2
- package/dist/assets/pegDiagram-2B236MQR-DZEmIYC7.js +1 -0
- package/dist/assets/pieDiagram-ENE6RG2P-1ZfqxHkL.js +39 -0
- package/dist/assets/quadrantDiagram-ABIIQ3AL-Owx49uiJ.js +7 -0
- package/dist/assets/railroadDiagram-RFXS5EU6-B7RiaIGf.js +1 -0
- package/dist/assets/{requirementDiagram-MS252O5E-CU9Rlyq5.js → requirementDiagram-TGXJPOKE-fqmHs_5o.js} +3 -3
- package/dist/assets/sankeyDiagram-HTMAVEWB-D0Q3FeKV.js +40 -0
- package/dist/assets/sequenceDiagram-DBY2YBRQ-CnPuB3fy.js +162 -0
- package/dist/assets/sizeCapture-X5ZJPWSS-CsHoR37V.js +1 -0
- package/dist/assets/stateDiagram-2N3HPSRC-GVwpflU5.js +1 -0
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DCGIHFu9.js +1 -0
- package/dist/assets/swimlanes-5IMT3BWC-DRR41dsQ.js +2 -0
- package/dist/assets/swimlanesDiagram-G3AALYLV-C4RhLkZo.js +8 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-C8SZmaLp.js → timeline-definition-FHXFAJF6-ePaEemL7.js} +3 -3
- package/dist/assets/vennDiagram-L72KCM5P-BAYP8dNF.js +34 -0
- package/dist/assets/wardleyDiagram-EHGQE667-jNVH5Adm.js +78 -0
- package/dist/assets/xychartDiagram-FW5EYKEG-BYDWGXbZ.js +7 -0
- package/dist/index.html +3 -3
- package/lib/agent-string.js +34 -0
- package/lib/build.js +542 -148
- package/lib/experiment-judge.js +145 -0
- package/lib/experiment-metrics.js +265 -0
- package/lib/experiment-pricing.js +60 -0
- package/lib/experiment-report.js +306 -0
- package/lib/experiment-sandbox.js +147 -0
- package/lib/experiment.js +539 -0
- package/lib/feature-events.js +10 -0
- package/lib/feature-json.js +1 -1
- package/lib/feature-writer.js +30 -0
- package/lib/flow-state.js +36 -0
- package/lib/gate-prompt.js +66 -6
- package/lib/lifecycle-modes.js +213 -0
- package/lib/new.js +6 -2
- package/lib/roadmap-graph/index.js +26 -28
- package/lib/roadmap-graph/vision-adapter.js +126 -0
- package/lib/smartmemory-client.js +139 -0
- package/lib/smartmemory-config.js +50 -0
- package/lib/smartmemory-ingest.js +115 -0
- package/lib/smartmemory-sync.js +275 -0
- package/lib/stratum-mcp-client.js +9 -0
- package/lib/triage.js +7 -1
- package/lib/vision-writer.js +30 -6
- package/package.json +1 -1
- package/pipelines/plan.stratum.yaml +161 -0
- package/server/artifact-manager.js +30 -3
- package/server/compose-mcp-tools.js +11 -9
- package/server/compose-mcp.js +4 -0
- package/server/feature-scan.js +87 -173
- package/server/gate-log-store.js +11 -1
- package/server/graph-export.js +0 -0
- package/server/index.js +3 -5
- package/server/lifecycle-guard.js +62 -25
- package/server/roadmap-graph-vision.js +138 -0
- package/server/smartmemory-routes.js +113 -0
- package/server/status-snapshot.js +8 -7
- package/server/vision-routes.js +64 -33
- package/server/vision-server.js +6 -0
- package/server/vision-store.js +13 -4
- package/.claude/skills/compose/references/hermes-tools.md +0 -80
- package/dist/assets/App-Ddhfx18X.js +0 -889
- package/dist/assets/_baseUniq-CoEVVYyC.js +0 -1
- package/dist/assets/arc-DFnGxuCB.js +0 -1
- package/dist/assets/architectureDiagram-Q4EWVU46-66rx01Tn.js +0 -36
- package/dist/assets/blockDiagram-DXYQGD6D-AE5PKSak.js +0 -132
- package/dist/assets/channel-CScyWprq.js +0 -1
- package/dist/assets/chunk-4TB4RGXK-5w2lZAjW.js +0 -206
- package/dist/assets/chunk-OYMX7WX6-CGr_YiAc.js +0 -231
- package/dist/assets/classDiagram-6PBFFD2Q-DibVYYdr.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DibVYYdr.js +0 -1
- package/dist/assets/clone-ZMALIFmw.js +0 -1
- package/dist/assets/dagre-KV5264BT--mrfaTHR.js +0 -4
- package/dist/assets/diagram-5BDNPKRD-DjXvyFzh.js +0 -10
- package/dist/assets/diagram-G4DWMVQ6-BrrexEvm.js +0 -24
- package/dist/assets/diagram-MMDJMWI5-D3Q0OT2M.js +0 -43
- package/dist/assets/diagram-TYMM5635-DKTntmxE.js +0 -24
- package/dist/assets/erDiagram-SMLLAGMA-BWbR0dPj.js +0 -85
- package/dist/assets/flowDiagram-DWJPFMVM-Dj5M5XaY.js +0 -162
- package/dist/assets/ganttDiagram-T4ZO3ILL-DKppFh_5.js +0 -292
- package/dist/assets/gitGraphDiagram-UUTBAWPF-9xQ9O25V.js +0 -106
- package/dist/assets/graph-Cgmhvu1T.js +0 -1
- package/dist/assets/graph-DZe55uk8.js +0 -331
- package/dist/assets/index-BxRamj_i.css +0 -1
- package/dist/assets/index-D8uDfn8y.js +0 -123
- package/dist/assets/infoDiagram-42DDH7IO-DQYWVzVN.js +0 -2
- package/dist/assets/katex-DkKDou_j.js +0 -257
- package/dist/assets/layout-DN13RkA1.js +0 -1
- package/dist/assets/min-CXr-fUFS.js +0 -1
- package/dist/assets/pieDiagram-DEJITSTG-CVipbIlr.js +0 -30
- package/dist/assets/quadrantDiagram-34T5L4WZ-CHJFyhm-.js +0 -7
- package/dist/assets/sankeyDiagram-XADWPNL6-BSULeqST.js +0 -10
- package/dist/assets/sequenceDiagram-FGHM5R23-CCOKXvjj.js +0 -157
- package/dist/assets/stateDiagram-FHFEXIEX-B91gKA7Q.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dc8ur9ta.js +0 -1
- package/dist/assets/vennDiagram-DHZGUBPP-YvEPDlsM.js +0 -34
- package/dist/assets/wardley-RL74JXVD-TMBUq_w1.js +0 -162
- package/dist/assets/wardleyDiagram-NUSXRM2D-DzEkPrri.js +0 -20
- package/dist/assets/xychartDiagram-5P7HB3ND-BIiCeiYk.js +0 -7
- 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
|
+
}
|
package/lib/feature-events.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
26
26
|
import { join, dirname } from 'path';
|
|
27
|
+
import { getSmartmemoryConfig } from './smartmemory-config.js';
|
|
27
28
|
|
|
28
29
|
function eventsFile(cwd) {
|
|
29
30
|
return join(cwd, '.compose', 'data', 'feature-events.jsonl');
|
|
@@ -58,6 +59,15 @@ export function appendEvent(cwd, event) {
|
|
|
58
59
|
...event,
|
|
59
60
|
};
|
|
60
61
|
appendFileSync(path, JSON.stringify(row) + '\n');
|
|
62
|
+
// COMP-SMARTMEMORY-INGEST: fail-open live emit after the durable local append.
|
|
63
|
+
// Flag-gated; emitter is lazy-loaded only when smartmemory.enabled === true.
|
|
64
|
+
try {
|
|
65
|
+
if (getSmartmemoryConfig(cwd).enabled === true) {
|
|
66
|
+
import('./smartmemory-ingest.js')
|
|
67
|
+
.then((m) => m.emitFeatureEvent(cwd, row))
|
|
68
|
+
.catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
} catch { /* fail-open: never let ingest affect the local write */ }
|
|
61
71
|
return row;
|
|
62
72
|
}
|
|
63
73
|
|
package/lib/feature-json.js
CHANGED
|
@@ -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] -
|
|
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
|
package/lib/feature-writer.js
CHANGED
|
@@ -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
|
+
}
|