@smartmemory/compose 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1014 -0
  3. package/bin/compose.js +1515 -0
  4. package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
  5. package/dist/assets/arc-SxJ2J1sh.js +1 -0
  6. package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
  7. package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
  8. package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
  9. package/dist/assets/channel-DGElom1e.js +1 -0
  10. package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
  11. package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
  12. package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
  13. package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
  14. package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
  15. package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
  16. package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
  17. package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
  18. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
  19. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
  20. package/dist/assets/clone-DUJKJXd7.js +1 -0
  21. package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
  22. package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
  23. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  24. package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
  25. package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
  26. package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
  27. package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
  28. package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
  29. package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
  30. package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
  31. package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
  32. package/dist/assets/graph-D0Cfv00Y.js +1 -0
  33. package/dist/assets/index-CUd6pFGF.css +1 -0
  34. package/dist/assets/index-DReRlzZI.js +1144 -0
  35. package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
  36. package/dist/assets/init-Gi6I4Gst.js +1 -0
  37. package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
  38. package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
  39. package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
  40. package/dist/assets/katex-DkKDou_j.js +257 -0
  41. package/dist/assets/layout-Bj72wOEB.js +1 -0
  42. package/dist/assets/linear-BRFo114D.js +1 -0
  43. package/dist/assets/min-GCHnKlJS.js +1 -0
  44. package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
  47. package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
  48. package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
  49. package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
  50. package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
  51. package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
  52. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
  53. package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
  54. package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
  55. package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
  56. package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
  57. package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
  58. package/dist/index.html +30 -0
  59. package/lib/agent-chains.js +65 -0
  60. package/lib/agent-string.js +86 -0
  61. package/lib/budget-ledger.js +86 -0
  62. package/lib/build-all.js +162 -0
  63. package/lib/build-dag.js +120 -0
  64. package/lib/build-stream-writer.js +190 -0
  65. package/lib/build.js +2997 -0
  66. package/lib/capability-checker.js +53 -0
  67. package/lib/cert-inject.js +38 -0
  68. package/lib/cli-progress.js +483 -0
  69. package/lib/constants.js +69 -0
  70. package/lib/cross-layer-audit.js +84 -0
  71. package/lib/debug-discipline.js +173 -0
  72. package/lib/feature-json.js +106 -0
  73. package/lib/gate-prompt.js +291 -0
  74. package/lib/gate-tiers.js +194 -0
  75. package/lib/health-history.js +119 -0
  76. package/lib/health-score.js +227 -0
  77. package/lib/ideabox.js +570 -0
  78. package/lib/import.js +244 -0
  79. package/lib/migrate-roadmap.js +94 -0
  80. package/lib/model-pricing.js +67 -0
  81. package/lib/new.js +413 -0
  82. package/lib/pipeline-cli.js +489 -0
  83. package/lib/plan-parser.js +103 -0
  84. package/lib/qa-scoping.js +474 -0
  85. package/lib/questionnaire.js +200 -0
  86. package/lib/resolve-port.js +7 -0
  87. package/lib/result-normalizer.js +349 -0
  88. package/lib/review-lenses.js +166 -0
  89. package/lib/roadmap-gen.js +210 -0
  90. package/lib/roadmap-parser.js +176 -0
  91. package/lib/server-probe.js +23 -0
  92. package/lib/staleness.js +87 -0
  93. package/lib/step-prompt.js +260 -0
  94. package/lib/step-validator.js +49 -0
  95. package/lib/stratum-mcp-client.js +365 -0
  96. package/lib/team-flag.js +46 -0
  97. package/lib/test-bootstrap.js +401 -0
  98. package/lib/triage.js +274 -0
  99. package/lib/vision-writer.js +391 -0
  100. package/package.json +111 -0
  101. package/pipelines/bug-fix.stratum.yaml +230 -0
  102. package/pipelines/build.stratum.yaml +498 -0
  103. package/pipelines/content.stratum.yaml +112 -0
  104. package/pipelines/coverage-sweep.stratum.yaml +52 -0
  105. package/pipelines/refactor.stratum.yaml +169 -0
  106. package/pipelines/research.stratum.yaml +88 -0
  107. package/pipelines/review-fix.stratum.yaml +109 -0
  108. package/presets/team-feature.stratum.yaml +105 -0
  109. package/presets/team-research.stratum.yaml +108 -0
  110. package/presets/team-review.stratum.yaml +106 -0
  111. package/scripts/agent-activity-hook.sh +31 -0
  112. package/scripts/agent-error-hook.sh +28 -0
  113. package/scripts/analyze-orphans.mjs +50 -0
  114. package/scripts/find-orphans.mjs +26 -0
  115. package/scripts/fix-phases.mjs +49 -0
  116. package/scripts/generate-stratum-spec.mjs +137 -0
  117. package/scripts/import-roadmap.mjs +116 -0
  118. package/scripts/phase-audit.mjs +33 -0
  119. package/scripts/run-pipeline.mjs +314 -0
  120. package/scripts/session-end-hook.sh +18 -0
  121. package/scripts/session-start-hook.sh +38 -0
  122. package/scripts/vision-hook.sh +104 -0
  123. package/scripts/vision-track.mjs +554 -0
  124. package/scripts/wire-all-orphans.mjs +108 -0
  125. package/scripts/wire-orphans.mjs +164 -0
  126. package/server/activity-routes.js +123 -0
  127. package/server/agent-health.js +197 -0
  128. package/server/agent-hooks.js +102 -0
  129. package/server/agent-mcp.js +10 -0
  130. package/server/agent-registry.js +95 -0
  131. package/server/agent-server.js +290 -0
  132. package/server/agent-spawn.js +251 -0
  133. package/server/agent-templates.js +77 -0
  134. package/server/artifact-manager.js +247 -0
  135. package/server/artifact-templates/architecture.md +28 -0
  136. package/server/artifact-templates/blueprint.md +21 -0
  137. package/server/artifact-templates/design.md +36 -0
  138. package/server/artifact-templates/plan.md +25 -0
  139. package/server/artifact-templates/prd.md +43 -0
  140. package/server/artifact-templates/report.md +40 -0
  141. package/server/block-tracker.js +90 -0
  142. package/server/build-stream-bridge.js +502 -0
  143. package/server/coalescing-buffer.js +46 -0
  144. package/server/compose-mcp-tools.js +479 -0
  145. package/server/compose-mcp.js +324 -0
  146. package/server/connectors/agent-connector.js +78 -0
  147. package/server/connectors/claude-sdk-connector.js +198 -0
  148. package/server/connectors/codex-connector.js +240 -0
  149. package/server/connectors/connector-discovery.js +18 -0
  150. package/server/connectors/connector-runtime.js +13 -0
  151. package/server/connectors/opencode-connector.js +200 -0
  152. package/server/design-routes.js +540 -0
  153. package/server/design-session.js +161 -0
  154. package/server/feature-scan.js +593 -0
  155. package/server/file-watcher.js +284 -0
  156. package/server/find-root.js +29 -0
  157. package/server/graph-export.js +343 -0
  158. package/server/ideabox-cache.js +77 -0
  159. package/server/ideabox-routes.js +294 -0
  160. package/server/index.js +156 -0
  161. package/server/model-tiers.js +49 -0
  162. package/server/pipeline-routes.js +288 -0
  163. package/server/policy-evaluator.js +36 -0
  164. package/server/project-root.js +122 -0
  165. package/server/security.js +23 -0
  166. package/server/session-manager.js +403 -0
  167. package/server/session-routes.js +190 -0
  168. package/server/session-store.js +107 -0
  169. package/server/settings-routes.js +35 -0
  170. package/server/settings-store.js +234 -0
  171. package/server/stratum-api.js +102 -0
  172. package/server/stratum-client.js +192 -0
  173. package/server/stratum-sync.js +193 -0
  174. package/server/summarizer.js +139 -0
  175. package/server/supervisor.js +196 -0
  176. package/server/vision-routes.js +668 -0
  177. package/server/vision-server.js +393 -0
  178. package/server/vision-store.js +360 -0
  179. package/server/vision-utils.js +179 -0
  180. package/server/worktree-gc.js +137 -0
  181. package/templates/ROADMAP.md +46 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Step Prompt Builder — constructs agent prompts from Stratum step dispatch responses.
3
+ */
4
+
5
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { checkStaleness } from './staleness.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Ambient context cache — loaded once per build, keyed by contextDir path.
11
+ // Cleared between builds by passing context.contextDir on first call.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const _contextCache = new Map();
15
+
16
+ /**
17
+ * Load and concatenate all .md files from docs/context/ (or the configured
18
+ * contextDir). Returns the combined text or null if the directory is absent.
19
+ * Results are cached so disk reads happen once per build context dir.
20
+ *
21
+ * @param {string} contextDir - Absolute path to the context directory
22
+ * @returns {string|null}
23
+ */
24
+ export function loadAmbientContext(contextDir) {
25
+ if (!contextDir || !existsSync(contextDir)) return null;
26
+ if (_contextCache.has(contextDir)) return _contextCache.get(contextDir);
27
+
28
+ let files;
29
+ try {
30
+ files = readdirSync(contextDir).filter(f => f.endsWith('.md')).sort();
31
+ } catch {
32
+ return null;
33
+ }
34
+
35
+ const parts = [];
36
+ for (const filename of files) {
37
+ try {
38
+ const content = readFileSync(join(contextDir, filename), 'utf-8').trimEnd();
39
+ if (content) parts.push(content);
40
+ } catch {
41
+ // skip unreadable files
42
+ }
43
+ }
44
+
45
+ const combined = parts.length > 0 ? parts.join('\n\n') : null;
46
+ _contextCache.set(contextDir, combined);
47
+ return combined;
48
+ }
49
+
50
+ /**
51
+ * Clear the ambient context cache for a given contextDir (call at build start).
52
+ *
53
+ * @param {string} contextDir
54
+ */
55
+ export function clearAmbientContextCache(contextDir) {
56
+ if (contextDir) _contextCache.delete(contextDir);
57
+ }
58
+
59
+ /**
60
+ * Build an agent prompt from a step dispatch and execution context.
61
+ *
62
+ * @param {object} stepDispatch - Stratum step dispatch (step_id, intent, inputs, output_fields, ensure)
63
+ * @param {object} context - Execution context (cwd, featureCode, contextDir?)
64
+ * @returns {string}
65
+ */
66
+ export function buildStepPrompt(stepDispatch, context) {
67
+ const sections = [];
68
+
69
+ sections.push(`You are executing step "${stepDispatch.step_id}" in a Stratum workflow.`);
70
+
71
+ sections.push(`## Intent\n${stepDispatch.intent}`);
72
+
73
+ sections.push(`## Inputs\n${JSON.stringify(stepDispatch.inputs, null, 2)}`);
74
+
75
+ if (Array.isArray(stepDispatch.output_fields) && stepDispatch.output_fields.length > 0) {
76
+ const fieldLines = stepDispatch.output_fields
77
+ .map(f => `- ${f.name} (${f.type})`)
78
+ .join('\n');
79
+ sections.push(`## Expected Output\nReturn a JSON object with these fields:\n${fieldLines}`);
80
+ }
81
+
82
+ if (Array.isArray(stepDispatch.ensure) && stepDispatch.ensure.length > 0) {
83
+ const ensureLines = stepDispatch.ensure.map(e => `- ${e}`).join('\n');
84
+ sections.push(`## Postconditions\nYour result must satisfy:\n${ensureLines}`);
85
+ }
86
+
87
+ // Inject ambient project context (docs/context/*.md) — cached per build
88
+ if (context.contextDir) {
89
+ const ambient = loadAmbientContext(context.contextDir);
90
+ if (ambient) {
91
+ sections.push(`## Project Context\n${ambient}`);
92
+ }
93
+ }
94
+
95
+ const ctxLines = [
96
+ `Working directory: ${context.cwd}`,
97
+ `Feature: ${context.featureCode}`,
98
+ ];
99
+ if (context.featureDir) {
100
+ ctxLines.push(`Feature docs: ${context.featureDir}`);
101
+ }
102
+ sections.push(`## Context\n${ctxLines.join('\n')}`);
103
+
104
+ // Inject prior step results so the agent doesn't re-explore from scratch
105
+ if (Array.isArray(context.stepHistory) && context.stepHistory.length > 0) {
106
+ const historyLines = context.stepHistory.map(h => {
107
+ let line = `- **${h.stepId}**: ${h.summary}`;
108
+ if (h.artifact) line += ` → \`${h.artifact}\``;
109
+ return line;
110
+ });
111
+ sections.push(`## Prior Steps\n${historyLines.join('\n')}`);
112
+
113
+ // If any prior step captured a file manifest, include it for downstream steps
114
+ // (context.filesChanged is maintained as a pre-deduplicated array in build.js)
115
+ if (context.filesChanged?.length > 0) {
116
+ sections.push(`## Files Changed by This Feature\n${context.filesChanged.map(f => '- ' + f).join('\n')}`);
117
+ }
118
+ }
119
+
120
+ return sections.join('\n\n');
121
+ }
122
+
123
+ /**
124
+ * Build a "File Ownership Conflicts" section for decompose-step retry prompts.
125
+ *
126
+ * @param {Array<{task_a: string, task_b: string, files: string[]}>} conflicts
127
+ * @returns {string}
128
+ */
129
+ function buildConflictSection(conflicts) {
130
+ const lines = [
131
+ '## File Ownership Conflicts — Resolution Required',
132
+ '',
133
+ 'The following task pairs share `files_owned` entries but have no `depends_on`',
134
+ 'relationship. Independent tasks may not both claim the same file.',
135
+ 'Add a `depends_on` edge from the later task to the earlier task to resolve each conflict:',
136
+ '',
137
+ ];
138
+
139
+ for (const { task_a, task_b, files } of conflicts) {
140
+ lines.push(`- **${task_a}** and **${task_b}** both own:`);
141
+ for (const f of files) lines.push(` - \`${f}\``);
142
+ lines.push(` → Add \`depends_on: [${task_a}]\` to \`${task_b}\` (or vice versa).`);
143
+ lines.push('');
144
+ }
145
+
146
+ return lines.join('\n');
147
+ }
148
+
149
+ /**
150
+ * Build a retry prompt when postconditions failed.
151
+ *
152
+ * @param {object} stepDispatch - Original step dispatch
153
+ * @param {string[]} violations - List of postcondition violations
154
+ * @param {object} context - Execution context
155
+ * @param {Array<{task_a, task_b, files}>} [conflicts] - Structured file conflicts (optional)
156
+ * @returns {string}
157
+ */
158
+ export function buildRetryPrompt(stepDispatch, violations, context, conflicts) {
159
+ const violationLines = violations.map(v => `- ${v}`).join('\n');
160
+ const header = `RETRY — Previous attempt failed postconditions:\n${violationLines}\n\nFix these issues and try again.`;
161
+
162
+ const sections = [header, buildStepPrompt(stepDispatch, context)];
163
+
164
+ if (conflicts && conflicts.length > 0) {
165
+ sections.push(buildConflictSection(conflicts));
166
+ }
167
+
168
+ return sections.join('\n\n');
169
+ }
170
+
171
+ /**
172
+ * Build a prompt for a child flow step within a larger workflow.
173
+ *
174
+ * @param {object} flowDispatch - Flow dispatch (child_flow_name, child_step)
175
+ * @param {object} context - Execution context
176
+ * @returns {string}
177
+ */
178
+ export function buildFlowStepPrompt(flowDispatch, context) {
179
+ const header = `You are executing a sub-workflow "${flowDispatch.child_flow_name}" as part of a larger workflow.`;
180
+ return `${header}\n\n${buildStepPrompt(flowDispatch.child_step, context)}`;
181
+ }
182
+
183
+ /**
184
+ * Build context preamble for a gate Q&A agent.
185
+ *
186
+ * Assembles the same execution context that regular steps get so the agent
187
+ * answering gate questions knows what feature is being built, what just
188
+ * completed, what files were touched, and what the gate controls.
189
+ *
190
+ * @param {object} gateDispatch - Stratum gate dispatch (step_id, on_approve, on_revise, on_kill)
191
+ * @param {object} context - Execution context (cwd, featureCode, featureDir, stepHistory, filesChanged)
192
+ * @param {object} [gateExtras] - Optional enrichment (fromPhase, toPhase, summary)
193
+ * @returns {string}
194
+ */
195
+ export function buildGateContext(gateDispatch, context, gateExtras) {
196
+ const sections = [];
197
+
198
+ sections.push(
199
+ `You are answering questions about a gate review in a Compose build workflow.\n` +
200
+ `Gate: "${gateDispatch.step_id}"`,
201
+ );
202
+
203
+ // Feature identity
204
+ const ctxLines = [
205
+ `Working directory: ${context.cwd}`,
206
+ `Feature: ${context.featureCode}`,
207
+ ];
208
+ if (context.featureDir) {
209
+ ctxLines.push(`Feature docs: ${context.featureDir}`);
210
+ }
211
+ sections.push(`## Feature\n${ctxLines.join('\n')}`);
212
+
213
+ // Phase transition
214
+ if (gateExtras?.fromPhase || gateExtras?.toPhase) {
215
+ const from = gateExtras.fromPhase ?? '(unknown)';
216
+ const to = gateExtras.toPhase ?? '(unknown)';
217
+ sections.push(`## Phase Transition\n${from} → ${to}`);
218
+ }
219
+
220
+ // Gate summary (from stratum dispatch enrichment)
221
+ if (gateExtras?.summary) {
222
+ sections.push(`## Gate Summary\n${gateExtras.summary}`);
223
+ }
224
+
225
+ // Routing — what happens on each decision
226
+ const routing = [];
227
+ routing.push(`- **Approve** → ${gateDispatch.on_approve ?? '(complete flow)'}`);
228
+ routing.push(`- **Revise** → re-run from \`${gateDispatch.on_revise ?? '(kill)'}\``);
229
+ routing.push(`- **Kill** → ${gateDispatch.on_kill ?? '(terminate flow)'}`);
230
+ sections.push(`## Gate Routing\n${routing.join('\n')}`);
231
+
232
+ // Prior step history
233
+ if (Array.isArray(context.stepHistory) && context.stepHistory.length > 0) {
234
+ const historyLines = context.stepHistory.map(h => {
235
+ let line = `- **${h.stepId}**: ${h.summary}`;
236
+ if (h.artifact) line += ` → \`${h.artifact}\``;
237
+ return line;
238
+ });
239
+ sections.push(`## Prior Steps\n${historyLines.join('\n')}`);
240
+ }
241
+
242
+ // Files changed
243
+ if (context.filesChanged?.length > 0) {
244
+ sections.push(`## Files Changed by This Feature\n${context.filesChanged.map(f => '- ' + f).join('\n')}`);
245
+ }
246
+
247
+ // Staleness warnings — flag artifacts that belong to an earlier phase
248
+ if (context.featureDir && gateExtras?.toPhase) {
249
+ const staleArtifacts = checkStaleness(context.featureDir, gateExtras.toPhase);
250
+ const stale = staleArtifacts.filter(a => a.stale);
251
+ if (stale.length > 0) {
252
+ const lines = stale.map(a =>
253
+ `- **${a.file}** was written in phase \`${a.writtenPhase}\` but feature is now in \`${a.currentPhase}\``
254
+ );
255
+ sections.push(`## Stale Artifacts\nThe following artifacts may be outdated:\n${lines.join('\n')}`);
256
+ }
257
+ }
258
+
259
+ return sections.join('\n\n');
260
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * step-validator.js — Agent-as-validator for pipeline step outputs.
3
+ *
4
+ * After a step writes its artifact, dispatches a lightweight agent call
5
+ * to read the artifact and validate it against criteria. Returns
6
+ * { valid, issues } so the dispatch loop can retry if needed.
7
+ */
8
+
9
+ import { runAndNormalize } from './result-normalizer.js';
10
+
11
+ /**
12
+ * Run a validation agent call for a completed step.
13
+ *
14
+ * @param {object} opts
15
+ * @param {string} opts.artifact - File path to validate (relative to cwd)
16
+ * @param {string[]} opts.criteria - List of things to check
17
+ * @param {string} opts.stepId - Step ID (for logging)
18
+ * @param {object} opts.connector - Agent connector to use
19
+ * @returns {Promise<{ valid: boolean, issues: string[] }>}
20
+ */
21
+ export async function validateStep({ artifact, criteria, stepId, connector }) {
22
+ const prompt =
23
+ `You are a validator. Read the file "${artifact}" and check the following criteria:\n\n` +
24
+ criteria.map((c, i) => `${i + 1}. ${c}`).join('\n') + '\n\n' +
25
+ `Return ONLY a JSON code block — no other text:\n` +
26
+ '```json\n' +
27
+ '{ "valid": true, "issues": [] }\n' +
28
+ '```\n' +
29
+ `Set valid to false and list issues if any criterion is not met.`;
30
+
31
+ // Minimal dispatch descriptor — only output_fields needed for JSON extraction
32
+ const dispatch = {
33
+ step_id: `validate_${stepId}`,
34
+ output_fields: {
35
+ valid: 'boolean',
36
+ issues: 'array',
37
+ },
38
+ };
39
+
40
+ const { result } = await runAndNormalize(connector, prompt, dispatch);
41
+
42
+ if (!result || typeof result.valid !== 'boolean') {
43
+ // Extraction failed — assume valid (optimistic fallback)
44
+ process.stderr.write(` ⚠ Validator returned no structured result for ${stepId}, assuming valid\n`);
45
+ return { valid: true, issues: [] };
46
+ }
47
+
48
+ return { valid: result.valid, issues: result.issues ?? [] };
49
+ }
@@ -0,0 +1,365 @@
1
+ /**
2
+ * stratum-mcp-client.js — MCP protocol client for stratum-mcp.
3
+ *
4
+ * Spawns `stratum-mcp` (no subcommand) as a child process and communicates
5
+ * via the MCP SDK over stdio. This is for the build runner's plan/step_done
6
+ * loop — distinct from server/stratum-client.js which uses CLI subcommands.
7
+ *
8
+ * Usage:
9
+ * const client = new StratumMcpClient();
10
+ * await client.connect();
11
+ * const dispatch = await client.plan(specPath, 'build', { featureCode: 'FEAT-1' });
12
+ * const next = await client.stepDone(dispatch.flow_id, 'step1', { phase: 'design' });
13
+ * await client.close();
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
18
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
19
+
20
+ export class StratumError extends Error {
21
+ constructor(code, message, detail) {
22
+ super(message);
23
+ this.name = 'StratumError';
24
+ this.code = code;
25
+ this.detail = detail;
26
+ }
27
+ }
28
+
29
+ export class StratumMcpClient {
30
+ #client = null;
31
+ #transport = null;
32
+ #connected = false;
33
+
34
+ /**
35
+ * Spawn stratum-mcp and establish MCP connection.
36
+ * @param {object} [opts]
37
+ * @param {string} [opts.command] - Override binary (for testing)
38
+ * @param {string[]} [opts.args] - Override args
39
+ * @param {string} [opts.cwd] - Working directory for the subprocess
40
+ */
41
+ async connect(opts = {}) {
42
+ if (this.#connected) return;
43
+
44
+ const command = opts.command ?? 'stratum-mcp';
45
+ const args = opts.args ?? [];
46
+
47
+ // Pre-flight: verify binary exists on $PATH (skip for test overrides)
48
+ if (command === 'stratum-mcp') {
49
+ try {
50
+ execFileSync('which', [command], { stdio: 'pipe', timeout: 3000 });
51
+ } catch {
52
+ throw new Error(
53
+ 'stratum-mcp not found on $PATH. Install with: pip install stratum-mcp'
54
+ );
55
+ }
56
+ }
57
+
58
+ const transportOpts = { command, args, stderr: 'pipe' };
59
+ if (opts.cwd) transportOpts.cwd = opts.cwd;
60
+ this.#transport = new StdioClientTransport(transportOpts);
61
+
62
+ this.#client = new Client(
63
+ { name: 'compose-build', version: '1.0.0' },
64
+ { capabilities: {} }
65
+ );
66
+
67
+ await this.#client.connect(this.#transport);
68
+ this.#connected = true;
69
+ }
70
+
71
+ /** Kill subprocess and clean up. */
72
+ async close() {
73
+ if (!this.#connected) return;
74
+ try {
75
+ await this.#client.close();
76
+ } catch {
77
+ // Ignore close errors — process may already be dead
78
+ }
79
+ this.#client = null;
80
+ this.#transport = null;
81
+ this.#connected = false;
82
+ }
83
+
84
+ /**
85
+ * Call an MCP tool and return the parsed JSON result.
86
+ * @param {string} toolName
87
+ * @param {object} args
88
+ * @returns {Promise<any>}
89
+ */
90
+ async #callTool(toolName, args) {
91
+ // Allow test-injected client to bypass real connection requirement.
92
+ // Gated on NODE_ENV=test so production code cannot accidentally redirect calls.
93
+ const client = (process.env.NODE_ENV === 'test' && this._testClient) || null;
94
+ if (!client && !this.#connected) {
95
+ throw new Error('StratumMcpClient not connected. Call connect() first.');
96
+ }
97
+
98
+ const result = await (client ?? this.#client).callTool({
99
+ name: toolName,
100
+ arguments: args,
101
+ });
102
+
103
+ // MCP tool results come back as content array; extract text content
104
+ const textContent = result.content?.find(c => c.type === 'text');
105
+ if (!textContent) {
106
+ throw new StratumError('EMPTY_RESPONSE', `Tool ${toolName} returned no text content`, '');
107
+ }
108
+
109
+ // MCP isError flag indicates tool-level failure
110
+ if (result.isError) {
111
+ throw new StratumError('TOOL_ERROR', textContent.text, '');
112
+ }
113
+
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(textContent.text);
117
+ } catch {
118
+ // Try to extract JSON from text that may have surrounding prose
119
+ const jsonMatch = textContent.text.match(/\{[\s\S]*\}/);
120
+ if (jsonMatch) {
121
+ try {
122
+ parsed = JSON.parse(jsonMatch[0]);
123
+ } catch {
124
+ throw new StratumError('PARSE_ERROR', `Tool ${toolName} returned invalid JSON`, textContent.text);
125
+ }
126
+ } else {
127
+ throw new StratumError('PARSE_ERROR', `Tool ${toolName} returned invalid JSON`, textContent.text);
128
+ }
129
+ }
130
+
131
+ // Check for Stratum error envelope
132
+ if (parsed.status === 'error' || parsed.error) {
133
+ const err = parsed.error ?? parsed;
134
+ throw new StratumError(
135
+ err.code ?? 'STRATUM_ERROR',
136
+ err.message ?? 'Stratum tool call failed',
137
+ err.detail ?? ''
138
+ );
139
+ }
140
+
141
+ return parsed;
142
+ }
143
+
144
+ /**
145
+ * Start a flow. Returns the first step dispatch.
146
+ * @param {string} spec - Inline YAML spec content (not a file path)
147
+ * @param {string} flow - Flow name within the spec
148
+ * @param {object} inputs - Flow input values
149
+ * @returns {Promise<object>} Step dispatch response
150
+ */
151
+ async plan(spec, flow, inputs) {
152
+ return this.#callTool('stratum_plan', { spec, flow, inputs });
153
+ }
154
+
155
+ /**
156
+ * Resume an in-progress flow. Returns the current step dispatch.
157
+ * @param {string} flowId
158
+ * @returns {Promise<object>} Step dispatch response (same format as plan/stepDone)
159
+ */
160
+ async resume(flowId) {
161
+ return this.#callTool('stratum_resume', { flow_id: flowId });
162
+ }
163
+
164
+ /**
165
+ * Report step completion. Returns next step dispatch or completion.
166
+ * @param {string} flowId
167
+ * @param {string} stepId
168
+ * @param {object} result - Step result (must match output_contract)
169
+ * @returns {Promise<object>}
170
+ */
171
+ async stepDone(flowId, stepId, result) {
172
+ return this.#callTool('stratum_step_done', {
173
+ flow_id: flowId,
174
+ step_id: stepId,
175
+ result,
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Resolve a gate step.
181
+ * @param {string} flowId
182
+ * @param {string} stepId
183
+ * @param {'approve'|'revise'|'kill'} outcome
184
+ * @param {string} rationale
185
+ * @param {'human'|'agent'|'system'} resolvedBy
186
+ * @returns {Promise<object>}
187
+ */
188
+ async gateResolve(flowId, stepId, outcome, rationale, resolvedBy = 'human') {
189
+ return this.#callTool('stratum_gate_resolve', {
190
+ flow_id: flowId,
191
+ step_id: stepId,
192
+ outcome,
193
+ rationale,
194
+ resolved_by: resolvedBy,
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Skip the current step with a recorded reason.
200
+ * @param {string} flowId
201
+ * @param {string} stepId
202
+ * @param {string} reason
203
+ * @returns {Promise<object>}
204
+ */
205
+ async skipStep(flowId, stepId, reason) {
206
+ return this.#callTool('stratum_skip_step', {
207
+ flow_id: flowId,
208
+ step_id: stepId,
209
+ reason,
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Get the full execution trace.
215
+ * @param {string} flowId
216
+ * @returns {Promise<object>}
217
+ */
218
+ async audit(flowId) {
219
+ return this.#callTool('stratum_audit', { flow_id: flowId });
220
+ }
221
+
222
+ /**
223
+ * Start a counted iteration loop on a step.
224
+ * @param {string} flowId
225
+ * @param {string} stepId
226
+ * @returns {Promise<object>}
227
+ */
228
+ async iterationStart(flowId, stepId) {
229
+ return this.#callTool('stratum_iteration_start', {
230
+ flow_id: flowId,
231
+ step_id: stepId,
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Report one iteration result.
237
+ * @param {string} flowId
238
+ * @param {string} stepId
239
+ * @param {object} result
240
+ * @returns {Promise<object>}
241
+ */
242
+ async iterationReport(flowId, stepId, result) {
243
+ return this.#callTool('stratum_iteration_report', {
244
+ flow_id: flowId,
245
+ step_id: stepId,
246
+ result,
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Abort an iteration loop early.
252
+ * @param {string} flowId
253
+ * @param {string} stepId
254
+ * @param {string} reason
255
+ * @returns {Promise<object>}
256
+ */
257
+ async iterationAbort(flowId, stepId, reason) {
258
+ return this.#callTool('stratum_iteration_abort', {
259
+ flow_id: flowId,
260
+ step_id: stepId,
261
+ reason,
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Validate a spec without executing.
267
+ * @param {string} spec - Inline YAML spec content
268
+ * @returns {Promise<{valid: boolean, errors?: string[]}>}
269
+ */
270
+ async validate(spec) {
271
+ return this.#callTool('stratum_validate', { spec });
272
+ }
273
+
274
+ /**
275
+ * Create a named checkpoint.
276
+ * @param {string} flowId
277
+ * @param {string} label
278
+ * @returns {Promise<object>}
279
+ */
280
+ async commit(flowId, label) {
281
+ return this.#callTool('stratum_commit', {
282
+ flow_id: flowId,
283
+ label,
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Roll back to a checkpoint.
289
+ * @param {string} flowId
290
+ * @param {string} label
291
+ * @returns {Promise<object>}
292
+ */
293
+ async revert(flowId, label) {
294
+ return this.#callTool('stratum_revert', {
295
+ flow_id: flowId,
296
+ label,
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Report batch task results for a parallel_dispatch step.
302
+ * @param {string} flowId
303
+ * @param {string} stepId
304
+ * @param {Array<{task_id: string, status: string, result?: object, error?: string}>} taskResults
305
+ * @param {'clean'|'conflict'|'fallback'|'manual_required'} mergeStatus
306
+ * @returns {Promise<object>} Next dispatch response
307
+ */
308
+ async parallelDone(flowId, stepId, taskResults, mergeStatus) {
309
+ return this.#callTool('stratum_parallel_done', {
310
+ flow_id: flowId,
311
+ step_id: stepId,
312
+ task_results: taskResults,
313
+ merge_status: mergeStatus,
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Start server-side execution of a parallel_dispatch step (T2-F5-COMPOSE-MIGRATE).
319
+ * Returns {status: 'started', ...} on success or {error, message} on known error.
320
+ * @param {string} flowId
321
+ * @param {string} stepId
322
+ * @returns {Promise<object>}
323
+ */
324
+ async parallelStart(flowId, stepId) {
325
+ return this.#callTool('stratum_parallel_start', {
326
+ flow_id: flowId,
327
+ step_id: stepId,
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Poll state of a server-dispatched parallel_dispatch step (T2-F5-COMPOSE-MIGRATE).
333
+ * Returns {summary, tasks, require_satisfied, can_advance, outcome}.
334
+ * Break on `outcome != null`, not `can_advance` — see design doc §3.
335
+ * @param {string} flowId
336
+ * @param {string} stepId
337
+ * @returns {Promise<object>}
338
+ */
339
+ async parallelPoll(flowId, stepId) {
340
+ return this.#callTool('stratum_parallel_poll', {
341
+ flow_id: flowId,
342
+ step_id: stepId,
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Consumer-driven advance for parallel_dispatch steps with defer_advance:true.
348
+ * Call after observing outcome.status === 'awaiting_consumer_advance' from
349
+ * parallelPoll. Feeds merge_status back to Stratum which runs
350
+ * _evaluate_parallel_results + _advance_after_parallel and returns the real
351
+ * advance outcome.
352
+ *
353
+ * @param {string} flowId
354
+ * @param {string} stepId
355
+ * @param {'clean'|'conflict'} mergeStatus
356
+ * @returns {Promise<object>}
357
+ */
358
+ async parallelAdvance(flowId, stepId, mergeStatus) {
359
+ return this.#callTool('stratum_parallel_advance', {
360
+ flow_id: flowId,
361
+ step_id: stepId,
362
+ merge_status: mergeStatus,
363
+ });
364
+ }
365
+ }