@smartmemory/compose 0.1.1-beta → 0.1.3-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 (124) hide show
  1. package/.claude/skills/bug-fix/SKILL.md +143 -0
  2. package/.claude/skills/compose/SKILL.md +604 -0
  3. package/.compose-deps.json +89 -0
  4. package/README.md +47 -983
  5. package/bin/compose.js +473 -0
  6. package/contracts/comp-obs-contract.schema.json +362 -0
  7. package/contracts/cross-model-review-result.json +78 -0
  8. package/contracts/review-result.json +126 -0
  9. package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
  10. package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
  13. package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
  14. package/dist/assets/channel-LRG9kHqJ.js +1 -0
  15. package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
  16. package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
  17. package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
  18. package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
  19. package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
  20. package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
  21. package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
  22. package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
  25. package/dist/assets/clone-dRxgFrBv.js +1 -0
  26. package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
  27. package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
  28. package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
  29. package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
  30. package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
  31. package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
  32. package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
  33. package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
  34. package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
  36. package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
  37. package/dist/assets/index-DKBsEUJ-.css +1 -0
  38. package/dist/assets/index-DkRKLuNr.js +1144 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
  43. package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
  44. package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
  45. package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/lib/budget-ledger.js +45 -0
  61. package/lib/bug-bisect.js +292 -0
  62. package/lib/bug-checkpoint.js +191 -0
  63. package/lib/bug-escalation.js +306 -0
  64. package/lib/bug-index-gen.js +136 -0
  65. package/lib/bug-ledger.js +126 -0
  66. package/lib/build-stream-schema.js +176 -0
  67. package/lib/build-stream-writer.js +3 -1
  68. package/lib/build.js +854 -284
  69. package/lib/connector-factory-shim.js +167 -0
  70. package/lib/constants.js +18 -0
  71. package/lib/debug-discipline.js +176 -27
  72. package/lib/deps.js +205 -0
  73. package/lib/health-score.js +4 -4
  74. package/lib/import.js +26 -13
  75. package/lib/inject-schema.js +21 -0
  76. package/lib/new.js +27 -53
  77. package/lib/result-normalizer.js +160 -144
  78. package/lib/review-lenses.js +5 -5
  79. package/lib/review-normalize.js +413 -0
  80. package/lib/review-prompt.js +163 -0
  81. package/lib/sections.js +325 -0
  82. package/lib/step-prompt.js +21 -1
  83. package/lib/step-validator.js +5 -3
  84. package/lib/stratum-mcp-client.js +172 -7
  85. package/package.json +14 -3
  86. package/pipelines/bug-fix.stratum.yaml +39 -1
  87. package/pipelines/build.stratum.yaml +28 -45
  88. package/pipelines/review-fix.stratum.yaml +1 -1
  89. package/presets/team-review.stratum.yaml +21 -14
  90. package/server/build-stream-bridge.js +28 -0
  91. package/server/cc-session-feature-resolver.js +111 -0
  92. package/server/cc-session-reader.js +327 -0
  93. package/server/cc-session-watcher.js +318 -0
  94. package/server/compose-mcp-tools.js +0 -125
  95. package/server/compose-mcp.js +2 -4
  96. package/server/contract-diff.js +192 -0
  97. package/server/decision-event-emit.js +175 -0
  98. package/server/decision-event-id.js +64 -0
  99. package/server/decision-events-snapshot.js +166 -0
  100. package/server/design-routes.js +92 -49
  101. package/server/drift-axes.js +365 -0
  102. package/server/drift-emit.js +121 -0
  103. package/server/gate-log-store.js +102 -0
  104. package/server/lifecycle-phase-history.js +44 -0
  105. package/server/open-loops-store.js +102 -0
  106. package/server/schema-validator.js +49 -0
  107. package/server/status-emit.js +27 -0
  108. package/server/status-snapshot.js +218 -0
  109. package/server/vision-routes.js +332 -4
  110. package/server/vision-server.js +104 -12
  111. package/server/vision-store.js +21 -0
  112. package/dist/assets/channel-DGElom1e.js +0 -1
  113. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
  114. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
  115. package/dist/assets/clone-DUJKJXd7.js +0 -1
  116. package/dist/assets/index-CUd6pFGF.css +0 -1
  117. package/dist/assets/index-DReRlzZI.js +0 -1144
  118. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
  119. package/server/connectors/agent-connector.js +0 -78
  120. package/server/connectors/claude-sdk-connector.js +0 -198
  121. package/server/connectors/codex-connector.js +0 -240
  122. package/server/connectors/connector-discovery.js +0 -18
  123. package/server/connectors/connector-runtime.js +0 -13
  124. package/server/connectors/opencode-connector.js +0 -200
package/lib/import.js CHANGED
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSy
9
9
  import { join, relative, extname, basename } from 'node:path';
10
10
 
11
11
  import { runAndNormalize } from './result-normalizer.js';
12
- import { ClaudeSDKConnector } from '../server/connectors/claude-sdk-connector.js';
12
+ import { StratumMcpClient } from './stratum-mcp-client.js';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // File tree scanner
@@ -116,12 +116,11 @@ function readKeyFiles(cwd, maxContentSize = 8000) {
116
116
  * Scan a project and generate a structured analysis.
117
117
  *
118
118
  * @param {object} opts
119
- * @param {string} [opts.cwd] - Working directory
120
- * @param {Function} [opts.connectorFactory] - Override connector (for testing)
119
+ * @param {string} [opts.cwd] - Working directory
120
+ * @param {StratumMcpClient} [opts.stratum] - Pre-connected client (testing)
121
121
  */
122
122
  export async function runImport(opts = {}) {
123
123
  const cwd = opts.cwd ?? process.cwd();
124
- const getConnector = opts.connectorFactory ?? (() => new ClaudeSDKConnector({ cwd }));
125
124
 
126
125
  console.log('Scanning project...\n');
127
126
 
@@ -205,15 +204,29 @@ Write the full analysis to \`docs/discovery/project-analysis.md\`.
205
204
 
206
205
  Return JSON: { "summary": string, "features": [{"code": string, "name": string, "status": string}], "artifact": "docs/discovery/project-analysis.md" }`;
207
206
 
208
- const connector = getConnector();
209
- const { result } = await runAndNormalize(connector, prompt, {
210
- output_contract: 'AnalysisResult',
211
- output_fields: [
212
- { name: 'summary', type: 'string' },
213
- { name: 'features', type: 'array' },
214
- { name: 'artifact', type: 'string' },
215
- ],
216
- });
207
+ let stratum = opts.stratum;
208
+ let ownsStratum = false;
209
+ if (!stratum) {
210
+ stratum = new StratumMcpClient();
211
+ await stratum.connect({ cwd });
212
+ ownsStratum = true;
213
+ }
214
+
215
+ let result;
216
+ try {
217
+ ({ result } = await runAndNormalize(null, prompt, {
218
+ step_id: 'import_analyze',
219
+ agent: 'claude',
220
+ output_contract: 'AnalysisResult',
221
+ output_fields: {
222
+ summary: 'string',
223
+ features: 'array',
224
+ artifact: 'string',
225
+ },
226
+ }, { stratum, cwd }));
227
+ } finally {
228
+ if (ownsStratum) await stratum.close();
229
+ }
217
230
 
218
231
  // 6. Verify the analysis was written
219
232
  const analysisPath = join(cwd, 'docs', 'discovery', 'project-analysis.md');
@@ -0,0 +1,21 @@
1
+ /**
2
+ * inject-schema.js — Append a JSON-Schema instruction block to an agent prompt
3
+ * so the response includes a structured JSON code-block at the end.
4
+ */
5
+
6
+ /**
7
+ * @param {string} prompt
8
+ * @param {object} schema JSON Schema object
9
+ * @returns {string}
10
+ */
11
+ export function injectSchema(prompt, schema) {
12
+ return (
13
+ `${prompt}\n\n` +
14
+ `IMPORTANT: After completing the task, include a JSON code block at the very end ` +
15
+ `of your response matching this schema:\n` +
16
+ '```json\n' +
17
+ `${JSON.stringify(schema, null, 2)}\n` +
18
+ '```\n' +
19
+ `The JSON block must be the last thing in your response.`
20
+ );
21
+ }
package/lib/new.js CHANGED
@@ -12,33 +12,15 @@ import { join, basename } from 'node:path';
12
12
 
13
13
  import { StratumMcpClient } from './stratum-mcp-client.js';
14
14
  import { runAndNormalize } from './result-normalizer.js';
15
+ import { installFactoryShim } from './connector-factory-shim.js';
15
16
  import { buildStepPrompt, buildRetryPrompt, buildGateContext } from './step-prompt.js';
16
17
  import { promptGate } from './gate-prompt.js';
17
18
  import { VisionWriter } from './vision-writer.js';
18
19
 
19
20
  import { validateStep } from './step-validator.js';
20
21
 
21
- import { ClaudeSDKConnector } from '../server/connectors/claude-sdk-connector.js';
22
- import { CodexConnector } from '../server/connectors/codex-connector.js';
23
-
24
- // ---------------------------------------------------------------------------
25
- // Agent registry (same as build.js)
26
- // ---------------------------------------------------------------------------
27
-
28
- const DEFAULT_AGENTS = new Map([
29
- ['claude', (opts) => new ClaudeSDKConnector(opts)],
30
- ['codex', (opts) => new CodexConnector(opts)],
31
- ]);
32
-
33
- function defaultConnectorFactory(agentType, opts) {
34
- const factory = DEFAULT_AGENTS.get(agentType);
35
- if (!factory) throw new Error(`Unknown agent type: ${agentType}`);
36
- return factory(opts);
37
- }
38
-
39
- // ---------------------------------------------------------------------------
40
- // Main
41
- // ---------------------------------------------------------------------------
22
+ // STRAT-DEDUP-AGENTRUN-V3: connectors live in stratum-mcp; runAndNormalize
23
+ // dispatches via stratum.agentRun(...) directly.
42
24
 
43
25
  /**
44
26
  * Run the product kickoff pipeline.
@@ -48,12 +30,10 @@ function defaultConnectorFactory(agentType, opts) {
48
30
  * @param {string} [opts.cwd] - Working directory (default: process.cwd())
49
31
  * @param {string} [opts.projectName] - Project name override
50
32
  * @param {boolean} [opts.skipResearch] - Skip the research step
51
- * @param {Function} [opts.connectorFactory] - Override agent connector creation (for testing)
52
33
  * @param {object} [opts.gateOpts] - Options for gate prompt (input/output streams)
53
34
  */
54
35
  export async function runNew(intent, opts = {}) {
55
36
  const cwd = opts.cwd ?? process.cwd();
56
- const getConnector = opts.connectorFactory ?? defaultConnectorFactory;
57
37
  const projectName = opts.projectName ?? basename(cwd);
58
38
  const skipResearch = opts.skipResearch ?? false;
59
39
 
@@ -116,9 +96,13 @@ export async function runNew(intent, opts = {}) {
116
96
  const visionWriter = new VisionWriter(dataDir);
117
97
  const itemId = await visionWriter.ensureFeatureItem(projectName, projectName);
118
98
 
119
- // Stratum MCP client
120
- const stratum = new StratumMcpClient();
121
- await stratum.connect({ cwd });
99
+ // Stratum MCP client (test override permitted via opts.stratum)
100
+ const stratum = opts.stratum ?? new StratumMcpClient();
101
+ if (!opts.stratum) await stratum.connect({ cwd });
102
+
103
+ if (opts.connectorFactory && !opts.stratum) {
104
+ installFactoryShim(stratum, opts.connectorFactory, cwd);
105
+ }
122
106
 
123
107
  try {
124
108
  console.log(`Starting product kickoff for "${projectName}"...`);
@@ -144,19 +128,18 @@ export async function runNew(intent, opts = {}) {
144
128
 
145
129
  const agentType = response.agent ?? 'claude';
146
130
  const prompt = buildStepPrompt(response, context);
147
- const connector = getConnector(agentType, { cwd });
148
- const { result } = await runAndNormalize(connector, prompt, response);
131
+ const { result } = await runAndNormalize(null, prompt, response, { stratum, cwd });
149
132
 
150
133
  // Agent-as-validator: if step has validate config, check the artifact
151
134
  const valConfig = validateConfigs.get(stepId);
152
135
  if (valConfig) {
153
136
  console.log(` ✓ Validating ${stepId}...`);
154
- const valConnector = getConnector('claude', { cwd });
155
137
  const { valid, issues } = await validateStep({
156
138
  artifact: valConfig.artifact,
157
139
  criteria: valConfig.criteria,
158
140
  stepId,
159
- connector: valConnector,
141
+ stratum,
142
+ cwd,
160
143
  });
161
144
  if (!valid) {
162
145
  console.log(` ✗ Validation failed:`);
@@ -167,8 +150,8 @@ export async function runNew(intent, opts = {}) {
167
150
  issues.map(i => `- ${i}`).join('\n') + '\n\n' +
168
151
  `Update the file in place. Do not skip any issue.\n\n` +
169
152
  `## Context\nWorking directory: ${cwd}\nProject: ${projectName}`;
170
- const fixConnector = getConnector('claude', { cwd });
171
- await runAndNormalize(fixConnector, fixPrompt, response);
153
+ const fixDispatch = { ...response, agent: 'claude' };
154
+ await runAndNormalize(null, fixPrompt, fixDispatch, { stratum, cwd });
172
155
  }
173
156
  }
174
157
 
@@ -224,7 +207,6 @@ export async function runNew(intent, opts = {}) {
224
207
  // Agent Q&A callback for interactive gate — with full workflow context
225
208
  const gatePreamble = buildGateContext(response, context, null);
226
209
  const askAgent = async (question, artifactPath) => {
227
- const connector = getConnector('claude', { cwd });
228
210
  const fileRef = artifactPath
229
211
  ? `Read the file "${artifactPath}" and answer`
230
212
  : `Look at the project files in the working directory and answer`;
@@ -233,12 +215,8 @@ export async function runNew(intent, opts = {}) {
233
215
  `${fileRef} this question concisely:\n\n` +
234
216
  `${question}\n\n` +
235
217
  `Keep your answer brief — 2-3 sentences max.`;
236
- const parts = [];
237
- for await (const event of connector.run(qaPrompt, {})) {
238
- if (event.type === 'assistant' && event.content) parts.push(event.content);
239
- if (event.type === 'result' && event.content && parts.length === 0) parts.push(event.content);
240
- }
241
- return parts.join('') || '(no answer)';
218
+ const text = await stratum.runAgentText('claude', qaPrompt, { cwd });
219
+ return text || '(no answer)';
242
220
  };
243
221
 
244
222
  const { outcome, rationale } = await promptGate(response, {
@@ -266,7 +244,7 @@ export async function runNew(intent, opts = {}) {
266
244
  console.log(`[sub-flow] ${childFlowName}...`);
267
245
 
268
246
  const childResult = await executeChildFlow(
269
- response, stratum, getConnector, context,
247
+ response, stratum, context,
270
248
  visionWriter, itemId, opts.gateOpts ?? {}
271
249
  );
272
250
 
@@ -285,13 +263,12 @@ export async function runNew(intent, opts = {}) {
285
263
  violations.map(v => `- ${v}`).join('\n') + '\n\n' +
286
264
  `Fix every issue. Do not skip any.\n\n` +
287
265
  `## Context\nWorking directory: ${cwd}\nProject: ${projectName}`;
288
- const fixConnector = getConnector(fixAgent, { cwd });
289
- await runAndNormalize(fixConnector, fixPrompt, response);
266
+ const fixDispatch = { ...response, agent: fixAgent };
267
+ await runAndNormalize(null, fixPrompt, fixDispatch, { stratum, cwd });
290
268
 
291
269
  console.log(` ↻ Retrying ${stepId} (${agentType})`);
292
270
  const prompt = buildRetryPrompt(response, violations, context);
293
- const connector = getConnector(agentType, { cwd });
294
- const { result } = await runAndNormalize(connector, prompt, response);
271
+ const { result } = await runAndNormalize(null, prompt, response, { stratum, cwd });
295
272
 
296
273
  response = await stratum.stepDone(
297
274
  response.flow_id, response.step_id,
@@ -341,7 +318,7 @@ export async function runNew(intent, opts = {}) {
341
318
  // ---------------------------------------------------------------------------
342
319
 
343
320
  async function executeChildFlow(
344
- flowDispatch, stratum, getConnector, context,
321
+ flowDispatch, stratum, context,
345
322
  visionWriter, itemId, gateOpts
346
323
  ) {
347
324
  let resp = flowDispatch.child_step;
@@ -353,10 +330,8 @@ async function executeChildFlow(
353
330
  console.log(` [${childFlowName}] ${resp.step_id}...`);
354
331
  await visionWriter.updateItemPhase(itemId, `${childFlowName}:${resp.step_id}`);
355
332
 
356
- const agentType = resp.agent ?? 'claude';
357
333
  const prompt = buildStepPrompt(resp, context);
358
- const connector = getConnector(agentType, { cwd: context.cwd });
359
- const { result } = await runAndNormalize(connector, prompt, resp);
334
+ const { result } = await runAndNormalize(null, prompt, resp, { stratum, cwd: context.cwd });
360
335
 
361
336
  resp = await stratum.stepDone(
362
337
  childFlowId, resp.step_id,
@@ -381,13 +356,12 @@ async function executeChildFlow(
381
356
  violations.map(v => `- ${v}`).join('\n') + '\n\n' +
382
357
  `Fix every issue.\n\n` +
383
358
  `## Context\nWorking directory: ${context.cwd}\nProject: ${context.projectName}`;
384
- const fixConnector = getConnector(fixAgent, { cwd: context.cwd });
385
- await runAndNormalize(fixConnector, fixPrompt, resp);
359
+ const fixDispatch = { ...resp, agent: fixAgent };
360
+ await runAndNormalize(null, fixPrompt, fixDispatch, { stratum, cwd: context.cwd });
386
361
 
387
362
  console.log(` [${childFlowName}] ↻ Retrying ${resp.step_id} (${stepAgent})`);
388
363
  const prompt = buildRetryPrompt(resp, violations, context);
389
- const connector = getConnector(stepAgent, { cwd: context.cwd });
390
- const { result } = await runAndNormalize(connector, prompt, resp);
364
+ const { result } = await runAndNormalize(null, prompt, resp, { stratum, cwd: context.cwd });
391
365
 
392
366
  resp = await stratum.stepDone(
393
367
  resp.flow_id ?? childFlowId, resp.step_id,
@@ -398,7 +372,7 @@ async function executeChildFlow(
398
372
  const nestedParentFlowId = resp.parent_flow_id;
399
373
  const nestedParentStepId = resp.parent_step_id;
400
374
  const nestedResult = await executeChildFlow(
401
- resp, stratum, getConnector, context,
375
+ resp, stratum, context,
402
376
  visionWriter, itemId, gateOpts
403
377
  );
404
378
  resp = await stratum.stepDone(nestedParentFlowId, nestedParentStepId, nestedResult);
@@ -6,9 +6,12 @@
6
6
  * accumulates text, and extracts structured JSON from the response.
7
7
  */
8
8
 
9
- import { injectSchema } from '../server/connectors/agent-connector.js';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { injectSchema } from './inject-schema.js';
10
11
  import { CliProgress } from './cli-progress.js';
11
12
  import { calculateCost } from './model-pricing.js';
13
+ import { resolveAgentConfig } from './agent-string.js';
14
+ import { normalizeReviewResult } from './review-normalize.js';
12
15
 
13
16
  // ---------------------------------------------------------------------------
14
17
  // Error classes
@@ -146,26 +149,50 @@ export class AgentTimeoutError extends Error {
146
149
  }
147
150
  }
148
151
 
149
- export async function runAndNormalize(connector, prompt, stepDispatch, opts = {}) {
152
+ /**
153
+ * STRAT-DEDUP-AGENTRUN-V3: `runAndNormalize` is now a thin wrapper around the
154
+ * Python connector tier exposed through `stratum_agent_run`. Events arrive as
155
+ * BuildStreamEvent envelopes via MCP progress notifications; we subscribe with
156
+ * `stratum.onEvent(correlationId, '_agent_run', handler)` and translate the
157
+ * envelopes back into the legacy stream-writer shape so downstream consumers
158
+ * (build-stream-writer, cockpit) keep working unchanged.
159
+ *
160
+ * The first `connector` arg is intentionally ignored — kept only so the 18
161
+ * call-sites do not all need to be edited in a single sweep. New required opt:
162
+ * `opts.stratum` — the StratumMcpClient instance.
163
+ */
164
+ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, opts = {}) {
150
165
  const progress = opts.progress;
151
166
  const streamWriter = opts.streamWriter;
152
- const onToolUse = opts.onToolUse ?? null; // passive tap — Item 193
153
- const maxDurationMs = opts.maxDurationMs ?? null; // null = no timeout
167
+ const onToolUse = opts.onToolUse ?? null;
168
+ const maxDurationMs = opts.maxDurationMs ?? null;
169
+ const stratum = opts.stratum;
170
+
171
+ if (!stratum || typeof stratum.agentRun !== 'function') {
172
+ throw new AgentError(
173
+ 'runAndNormalize requires opts.stratum (a connected StratumMcpClient). ' +
174
+ 'Pass stratum: stratumClient at the call-site.'
175
+ );
176
+ }
177
+
178
+ const stepId = stepDispatch.step_id ?? 'unknown';
179
+ const agentType = stepDispatch.agent ?? 'claude';
180
+ const cfg = resolveAgentConfig(agentType);
181
+
154
182
  const outputFields = stepDispatch.output_fields;
155
183
  const hasSchema = outputFields && typeof outputFields === 'object' && Object.keys(outputFields).length > 0;
156
-
157
184
  let actualPrompt = prompt;
158
185
  let schema = null;
159
-
160
186
  if (hasSchema) {
161
187
  schema = outputFieldsToJsonSchema(outputFields);
162
188
  actualPrompt = injectSchema(prompt, schema);
163
189
  }
164
190
 
165
- const textParts = [];
191
+ const correlationId = `${stepDispatch.flow_id ?? 'noflow'}:${stepId}:${randomUUID()}`;
192
+ const subStepId = '_agent_run';
166
193
  const startTime = Date.now();
167
194
 
168
- // Accumulate usage events across all connector events
195
+ const textParts = [];
169
196
  const usageTotals = {
170
197
  input_tokens: 0,
171
198
  output_tokens: 0,
@@ -175,157 +202,148 @@ export async function runAndNormalize(connector, prompt, stepDispatch, opts = {}
175
202
  model: null,
176
203
  };
177
204
 
178
- // Set up timeout timer if configured
179
- let timeoutTimer = null;
180
205
  let timedOut = false;
206
+ let userInterruptAction = null;
207
+ let timeoutHandle = null;
208
+
209
+ // Subscribe BEFORE calling agentRun — events fire during the call.
210
+ const unsub = stratum.onEvent(correlationId, subStepId, (env) => {
211
+ if (!env || env.schema_version !== '0.2.5') return;
212
+ const m = env.metadata ?? {};
213
+ switch (env.kind) {
214
+ case 'agent_relay':
215
+ if (m.role === 'assistant' && typeof m.text === 'string' && m.text.length > 0) {
216
+ textParts.push(m.text);
217
+ if (streamWriter) streamWriter.write({ type: 'assistant', content: m.text });
218
+ }
219
+ break;
220
+ case 'tool_use_summary': {
221
+ const tool = m.tool;
222
+ if (tool) {
223
+ if (streamWriter) {
224
+ streamWriter.write({ type: 'tool_use', tool, input: m.input ?? {} });
225
+ }
226
+ if (onToolUse) onToolUse({ tool, input: m.input ?? {}, timestamp: Date.now() });
227
+ if (progress) {
228
+ const detail = m.input?.command ?? m.input?.pattern ?? m.input?.query ?? m.input?.file_path ?? '';
229
+ progress.toolUse(tool, detail);
230
+ }
231
+ }
232
+ if (m.summary) {
233
+ if (streamWriter) {
234
+ streamWriter.write({ type: 'tool_use_summary', summary: m.summary, output: m.output ?? '' });
235
+ }
236
+ if (progress) progress.toolSummary(m.summary);
237
+ }
238
+ break;
239
+ }
240
+ case 'step_usage': {
241
+ const inTok = m.input_tokens ?? 0;
242
+ const outTok = m.output_tokens ?? 0;
243
+ const ccit = m.cache_creation_input_tokens ?? 0;
244
+ const crit = m.cache_read_input_tokens ?? 0;
245
+ usageTotals.input_tokens += inTok;
246
+ usageTotals.output_tokens += outTok;
247
+ usageTotals.cache_creation_input_tokens += ccit;
248
+ usageTotals.cache_read_input_tokens += crit;
249
+ if (m.model) usageTotals.model = m.model;
250
+ const stepCost = m.cost_usd != null
251
+ ? m.cost_usd
252
+ : calculateCost(m.model, inTok, outTok, ccit, crit);
253
+ usageTotals.cost_usd += stepCost;
254
+ if (streamWriter) {
255
+ streamWriter.write({
256
+ type: 'usage',
257
+ input_tokens: inTok,
258
+ output_tokens: outTok,
259
+ cache_creation_input_tokens: ccit,
260
+ cache_read_input_tokens: crit,
261
+ cost_usd: stepCost,
262
+ model: m.model ?? null,
263
+ });
264
+ }
265
+ break;
266
+ }
267
+ default:
268
+ break;
269
+ }
270
+ });
271
+
181
272
  if (maxDurationMs) {
182
- timeoutTimer = setTimeout(() => {
273
+ timeoutHandle = setTimeout(() => {
183
274
  timedOut = true;
184
- try { connector.interrupt(); } catch { /* best effort */ }
275
+ stratum.cancelAgentRun(correlationId).catch(() => {});
185
276
  }, maxDurationMs);
186
277
  }
187
278
 
188
- // Wire up skip/retry interrupt from CLI key press
189
- let userInterrupted = false;
190
- const onInterrupt = () => {
191
- userInterrupted = true;
192
- try { connector.interrupt(); } catch { /* best effort */ }
193
- };
194
- if (progress?.on) progress.on('interrupt', onInterrupt);
279
+ let onInterrupt = null;
280
+ if (progress?.on) {
281
+ onInterrupt = () => {
282
+ userInterruptAction = progress.consumeAction?.() ?? 'skip';
283
+ stratum.cancelAgentRun(correlationId).catch(() => {});
284
+ };
285
+ progress.on('interrupt', onInterrupt);
286
+ }
195
287
 
288
+ let runResult;
196
289
  try {
197
- for await (const event of connector.run(actualPrompt, {})) {
198
- // Check for timeout after each event
199
- if (timedOut) {
200
- const stepId = stepDispatch.step_id ?? 'unknown';
201
- throw new AgentTimeoutError(stepId, Date.now() - startTime);
202
- }
203
- // Check for user interrupt (skip/retry)
204
- if (userInterrupted) {
205
- const stepId = stepDispatch.step_id ?? 'unknown';
206
- throw new UserInterruptError(stepId, progress?.consumeAction() ?? 'skip');
207
- }
208
- if (progress) {
209
- progress.debug(`event type=${event.type} keys=${Object.keys(event).join(',')}`);
210
- } else if (process.env.COMPOSE_DEBUG) {
211
- process.stderr.write(` [event] type=${event.type} keys=${Object.keys(event).join(',')}\n`);
212
- }
213
- if (event.type === 'error') {
214
- // build_error is written by build.js catch blocks, not here — avoids duplicate events
215
- throw new AgentError(event.message);
216
- }
217
- if (event.type === 'assistant' && event.content) {
218
- textParts.push(event.content);
219
- if (streamWriter) {
220
- streamWriter.write({ type: 'assistant', content: event.content });
221
- }
222
- }
223
- if (event.type === 'result' && event.content) {
224
- // Result contains the final aggregated text — use it if we got nothing from blocks
225
- if (textParts.length === 0) {
226
- textParts.push(event.content);
227
- }
228
- }
229
- // Forward tool_use to build stream (before progress logging)
230
- if (streamWriter && event.type === 'tool_use' && event.tool) {
231
- streamWriter.write({ type: 'tool_use', tool: event.tool, input: event.input });
232
- }
233
- // Passive tap — notify caller of each tool_use without altering event flow (Item 193)
234
- if (onToolUse && event.type === 'tool_use' && event.tool) {
235
- onToolUse({ tool: event.tool, input: event.input, timestamp: Date.now() });
236
- }
237
- // Progress logging — show tool calls so the user sees activity
238
- if (event.type === 'tool_use' && event.tool) {
239
- const detail = event.input?.command
240
- ?? event.input?.pattern
241
- ?? event.input?.query
242
- ?? event.input?.file_path
243
- ?? '';
244
- if (progress) {
245
- progress.toolUse(event.tool, detail);
246
- } else {
247
- const short = typeof detail === 'string' && detail.length > 60
248
- ? detail.slice(0, 57) + '...'
249
- : detail;
250
- process.stderr.write(` ↳ ${event.tool}${short ? ': ' + short : ''}\n`);
251
- }
252
- }
253
- // Forward tool_use_summary to build stream (COMP-OBS-STREAM)
254
- if (streamWriter && event.type === 'tool_use_summary') {
255
- streamWriter.write({ type: 'tool_use_summary', summary: event.summary, output: event.output });
256
- }
257
- if (event.type === 'tool_use_summary' && event.summary) {
258
- if (progress) {
259
- progress.toolSummary(event.summary);
260
- } else {
261
- const short = event.summary.length > 80
262
- ? event.summary.slice(0, 77) + '...'
263
- : event.summary;
264
- process.stderr.write(` ↳ ${short}\n`);
265
- }
266
- }
267
- // Forward tool_progress to build stream (COMP-OBS-STREAM)
268
- if (streamWriter && event.type === 'tool_progress') {
269
- streamWriter.write({ type: 'tool_progress', tool: event.tool, elapsed: event.elapsed });
270
- }
271
- if (event.type === 'tool_progress' && event.tool) {
272
- if (progress) {
273
- progress.toolProgress(event.tool, event.elapsed);
274
- } else {
275
- process.stderr.write(` ↳ ${event.tool} (${Math.round(event.elapsed)}s)\n`);
276
- }
277
- }
278
- // Accumulate usage events (COMP-OBS-COST)
279
- if (event.type === 'usage') {
280
- usageTotals.input_tokens += event.input_tokens ?? 0;
281
- usageTotals.output_tokens += event.output_tokens ?? 0;
282
- usageTotals.cache_creation_input_tokens += event.cache_creation_input_tokens ?? 0;
283
- usageTotals.cache_read_input_tokens += event.cache_read_input_tokens ?? 0;
284
- if (event.model) usageTotals.model = event.model;
285
- // If the connector already computed cost_usd, use it; otherwise calculate
286
- const stepCost = event.cost_usd != null
287
- ? event.cost_usd
288
- : calculateCost(
289
- event.model,
290
- event.input_tokens ?? 0,
291
- event.output_tokens ?? 0,
292
- event.cache_creation_input_tokens ?? 0,
293
- event.cache_read_input_tokens ?? 0,
294
- );
295
- usageTotals.cost_usd += stepCost;
296
- // Forward per-step usage to build stream
297
- if (streamWriter) {
298
- streamWriter.write({
299
- type: 'usage',
300
- input_tokens: event.input_tokens ?? 0,
301
- output_tokens: event.output_tokens ?? 0,
302
- cache_creation_input_tokens: event.cache_creation_input_tokens ?? 0,
303
- cache_read_input_tokens: event.cache_read_input_tokens ?? 0,
304
- cost_usd: stepCost,
305
- model: event.model ?? null,
306
- });
307
- }
308
- }
309
- }
290
+ runResult = await stratum.agentRun(agentType, actualPrompt, {
291
+ modelID: cfg.modelID ?? undefined,
292
+ allowedTools: cfg.allowedTools ?? undefined,
293
+ disallowedTools: cfg.disallowedTools ?? undefined,
294
+ thinking: cfg.thinking ?? undefined,
295
+ effort: cfg.effort ?? undefined,
296
+ cwd: opts.cwd ?? undefined,
297
+ correlationId,
298
+ });
299
+ } catch (err) {
300
+ if (timedOut) throw new AgentTimeoutError(stepId, Date.now() - startTime);
301
+ if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
302
+ throw new AgentError(err?.message ?? 'Agent run failed');
310
303
  } finally {
311
- if (timeoutTimer) clearTimeout(timeoutTimer);
312
- if (progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
304
+ if (timeoutHandle) clearTimeout(timeoutHandle);
305
+ if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
306
+ unsub();
313
307
  }
314
308
 
315
- // If we broke out of the loop due to timeout, throw
316
- if (timedOut) {
317
- const stepId = stepDispatch.step_id ?? 'unknown';
318
- throw new AgentTimeoutError(stepId, Date.now() - startTime);
319
- }
309
+ if (timedOut) throw new AgentTimeoutError(stepId, Date.now() - startTime);
310
+ if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
320
311
 
321
- const text = textParts.join('');
312
+ const text = (runResult && typeof runResult.text === 'string' && runResult.text.length > 0)
313
+ ? runResult.text
314
+ : textParts.join('');
322
315
 
323
316
  if (progress) {
324
317
  progress.debug(`normalizer: textParts=${textParts.length}, text length=${text.length}`);
325
318
  if (text.length > 0) progress.debug(`text preview: ${text.slice(0, 300)}`);
326
319
  } else if (process.env.COMPOSE_DEBUG) {
327
- process.stderr.write(` [normalizer] textParts count: ${textParts.length}, text length: ${text.length}\n`);
328
- if (text.length > 0) process.stderr.write(` [normalizer] text preview: ${text.slice(0, 300)}\n`);
320
+ process.stderr.write(` [normalizer] textParts=${textParts.length}, text length=${text.length}\n`);
321
+ }
322
+
323
+ // review_mode hook — MUST be before the !hasSchema early return (MF-3 in blueprint).
324
+ // Parallel lens steps often have empty output_fields (hasSchema=false), but review
325
+ // normalization must still run. The Stratum server validates the post-normalize result
326
+ // via `ensure` expressions after stratum_step_done — not against raw text.
327
+ if (opts.reviewMode === true) {
328
+ const reviewAgentType = agentType; // already resolved from stepDispatch.agent at line 178
329
+ const reviewModelId = usageTotals.model ?? cfg.modelID ?? null;
330
+ const repairFn = stratum
331
+ ? async (repairPrompt) => {
332
+ const repairResult = await stratum.agentRun(reviewAgentType, repairPrompt, {
333
+ modelID: cfg.modelID ?? undefined,
334
+ cwd: opts.cwd ?? undefined,
335
+ });
336
+ return repairResult?.text ?? '';
337
+ }
338
+ : undefined;
339
+ const reviewResult = await normalizeReviewResult(text, {
340
+ agentType: reviewAgentType,
341
+ modelId: reviewModelId,
342
+ confidenceGate: opts.confidenceGate ?? 7,
343
+ lens: opts.lens ?? 'general',
344
+ repairFn,
345
+ });
346
+ return { text, result: reviewResult, usage: usageTotals };
329
347
  }
330
348
 
331
349
  if (!hasSchema) {
@@ -334,8 +352,6 @@ export async function runAndNormalize(connector, prompt, stepDispatch, opts = {}
334
352
 
335
353
  const result = extractJson(text);
336
354
  if (result === null) {
337
- // Agent did its work but didn't return structured JSON.
338
- // Log a warning and return a fallback — don't crash the pipeline.
339
355
  if (progress) {
340
356
  progress.warn('Could not extract JSON from agent output, using fallback');
341
357
  } else {