@smartmemory/compose 0.3.6-beta → 0.3.8

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 (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
@@ -224,6 +224,21 @@ export class AgentAbortedError extends Error {
224
224
  }
225
225
  }
226
226
 
227
+ function copyDispatchId(source, target) {
228
+ try {
229
+ if (!source || !target || typeof source.dispatchId !== 'string') return target;
230
+ Object.defineProperty(target, 'dispatchId', {
231
+ configurable: true,
232
+ enumerable: false,
233
+ value: source.dispatchId,
234
+ });
235
+ } catch {
236
+ // Error replacement must preserve the original control flow even if a
237
+ // third-party target is unexpectedly frozen.
238
+ }
239
+ return target;
240
+ }
241
+
227
242
  /**
228
243
  * STRAT-DEDUP-AGENTRUN-V3: `runAndNormalize` is now a thin wrapper around the
229
244
  * Python connector tier exposed through `stratum_agent_run`. Events arrive as
@@ -236,6 +251,75 @@ export class AgentAbortedError extends Error {
236
251
  * call-sites do not all need to be edited in a single sweep. New required opt:
237
252
  * `opts.stratum` — the StratumMcpClient instance.
238
253
  */
254
+ /**
255
+ * Sum two usage records into one, for a step whose work took more than one
256
+ * agent call (e.g. a COMP-POLICY-CHECK revision replacing the primary draft).
257
+ * Token/cost fields add; `model` keeps the later run's value when it has one.
258
+ * Either side may be null.
259
+ *
260
+ * @param {object|null} a
261
+ * @param {object|null} b
262
+ * @returns {object|null}
263
+ */
264
+ export function mergeUsage(a, b) {
265
+ if (!a) return b ?? null;
266
+ if (!b) return a;
267
+ return {
268
+ input_tokens: (a.input_tokens ?? 0) + (b.input_tokens ?? 0),
269
+ output_tokens: (a.output_tokens ?? 0) + (b.output_tokens ?? 0),
270
+ cache_creation_input_tokens: (a.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0),
271
+ cache_read_input_tokens: (a.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0),
272
+ cost_usd: (a.cost_usd ?? 0) + (b.cost_usd ?? 0),
273
+ model: b.model ?? a.model ?? null,
274
+ };
275
+ }
276
+
277
+ function hasReportedUsage(usage) {
278
+ if (!usage || typeof usage !== 'object') return false;
279
+ return [
280
+ usage.tokens, usage.input_tokens, usage.output_tokens,
281
+ usage.cache_read, usage.cache_read_input_tokens,
282
+ usage.cache_creation, usage.cache_creation_input_tokens,
283
+ usage.cost_usd, usage.usd, usage.ms, usage.duration_ms,
284
+ ].some((value) => typeof value === 'number' && Number.isFinite(value) && value !== 0);
285
+ }
286
+
287
+ function usageRecordFromRaw(usage, telemetry, dispatchId, fallback = {}, split = null) {
288
+ if (!hasReportedUsage(usage)) return null;
289
+ // STRAT-USAGE-SPLIT: prefer the connector-reported split over reconstruction
290
+ // (the reconstruction cannot tell input from output; it filed the aggregate
291
+ // as output on every record before surface 16).
292
+ const input = split?.input ?? usage.input_tokens ?? 0;
293
+ const output = split?.output
294
+ ?? usage.output_tokens
295
+ ?? (typeof usage.tokens === 'number' ? Math.max(0, usage.tokens - input) : 0);
296
+ const record = {
297
+ dispatch_id: dispatchId ?? randomUUID(),
298
+ model: telemetry?.model ?? usage.model ?? fallback.model ?? 'unknown',
299
+ ...(telemetry?.effort ?? fallback.effort
300
+ ? { effort: telemetry?.effort ?? fallback.effort }
301
+ : {}),
302
+ duration_ms: telemetry?.durationMs ?? usage.duration_ms ?? usage.ms ?? fallback.duration_ms ?? 0,
303
+ input_tokens: input,
304
+ output_tokens: output,
305
+ ...(typeof (split?.cacheRead ?? usage.cache_read ?? usage.cache_read_input_tokens) === 'number'
306
+ ? { cache_read: split?.cacheRead ?? usage.cache_read ?? usage.cache_read_input_tokens }
307
+ : {}),
308
+ ...(typeof (split?.cacheCreation ?? usage.cache_creation ?? usage.cache_creation_input_tokens) === 'number'
309
+ ? { cache_creation: split?.cacheCreation ?? usage.cache_creation ?? usage.cache_creation_input_tokens }
310
+ : {}),
311
+ };
312
+ const cost = usage.cost_usd ?? usage.usd;
313
+ const usdSource = ['reported', 'estimated'].includes(usage.usd_source)
314
+ ? usage.usd_source
315
+ : (['reported', 'estimated'].includes(fallback.usd_source) ? fallback.usd_source : null);
316
+ if (typeof cost === 'number' && Number.isFinite(cost) && cost > 0 && usdSource) {
317
+ record.cost_usd = cost;
318
+ record.usd_source = usdSource;
319
+ }
320
+ return record;
321
+ }
322
+
239
323
  export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, opts = {}) {
240
324
  const progress = opts.progress;
241
325
  const streamWriter = opts.streamWriter;
@@ -252,17 +336,45 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
252
336
 
253
337
  const stepId = stepDispatch.step_id ?? 'unknown';
254
338
  const agentType = stepDispatch.agent ?? 'claude';
339
+ // COMP-AGENT-LANES: when the caller (a parallel fanout item) supplies a lane
340
+ // envelope, stamp it on every relayed stream write so the cockpit can route
341
+ // this run's output to its worker lane. Absent the opt, writes stay
342
+ // byte-identical (single-step runs unchanged).
343
+ const laneStamp = (opts.lane && typeof opts.lane === 'object') ? { lane: opts.lane } : {};
255
344
  // D6: the engine ships only the bare provider literal (stepDispatch.agent), so
256
345
  // the full profile string (with tool restrictions + model tier) is supplied
257
346
  // compose-side via opts.profile, keyed off the compose-owned sidecar. It
258
347
  // overrides the bare literal for capability resolution; the provider is
259
348
  // unchanged. Absent → bare literal (no restrictions), preserving old behavior.
260
349
  const cfg = resolveAgentConfig(opts.profile || agentType);
261
- // A read-only profile (no Edit/Write/Bash) also maps to a read-only sandbox so
262
- // the restriction binds at the engine's connector, not just at this invocation.
263
- const readOnlyProfile = Array.isArray(cfg.disallowedTools)
264
- && ['Edit', 'Write'].every((tool) => cfg.disallowedTools.includes(tool));
265
- const sandboxMode = readOnlyProfile ? 'read-only' : undefined;
350
+ const callerTelemetry = opts.telemetry && typeof opts.telemetry === 'object'
351
+ ? opts.telemetry
352
+ : {};
353
+ const primaryTelemetry = {};
354
+ for (const field of ['project_cwd', 'site', 'build_id', 'feature_code']) {
355
+ if (callerTelemetry[field] !== undefined) primaryTelemetry[field] = callerTelemetry[field];
356
+ }
357
+ primaryTelemetry.step_id = stepId;
358
+ if (typeof stepDispatch.attempt === 'number' && Number.isFinite(stepDispatch.attempt)) {
359
+ primaryTelemetry.attempt = stepDispatch.attempt;
360
+ }
361
+ primaryTelemetry.effort_intended = cfg.effort ?? null;
362
+ // Claude profiles bind through SDK tool filters. Codex has an OS sandbox;
363
+ // write permission must be explicitly requested by the implementation caller.
364
+ const sandboxMode = cfg.provider === 'codex'
365
+ ? (opts.sandboxMode ?? 'read-only')
366
+ : undefined;
367
+ const executionOptions = {
368
+ modelID: cfg.modelID ?? undefined,
369
+ ...(cfg.provider === 'claude' ? {
370
+ allowedTools: cfg.allowedTools ?? undefined,
371
+ disallowedTools: cfg.disallowedTools ?? undefined,
372
+ thinking: cfg.thinking ?? undefined,
373
+ } : {}),
374
+ effort: cfg.effort ?? undefined,
375
+ sandboxMode,
376
+ cwd: opts.cwd ?? undefined,
377
+ };
266
378
 
267
379
  const outputFields = stepDispatch.output_fields;
268
380
  const hasSchema = outputFields && typeof outputFields === 'object' && Object.keys(outputFields).length > 0;
@@ -298,6 +410,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
298
410
  cost_usd: 0,
299
411
  model: null,
300
412
  };
413
+ let primaryUsdSource = null;
301
414
 
302
415
  let timedOut = false;
303
416
  let userInterruptAction = null;
@@ -306,21 +419,19 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
306
419
  // the run mid-stream by returning a truthy reason from a tool event.
307
420
  let abortReason = null;
308
421
 
309
- // V2/V3: CONTROLLED claude executions (consumer items, review fanout) run via
310
- // the compose-LOCAL connector the only seam that can enforce claude tool
311
- // restrictions AND be interrupted (the sync engine agent_run can do neither;
312
- // its background mode is codex+read-only-only). Abort replaces cancelAgentRun
313
- // as the stop mechanism, and the connector streams real tool events so the
314
- // stuck detector / timeout actually stop a runaway agent.
422
+ // Both local SDK and MCP executions own a cancellable handle. The MCP
423
+ // client translates this signal to an acknowledged foreground cancellation.
315
424
  const useLocalClaude = opts.localExecution === true && cfg.provider === 'claude';
316
- const abortController = useLocalClaude ? new AbortController() : null;
317
- const stopRun = () => {
318
- if (abortController) {
319
- try { abortController.abort(); } catch { /* already aborted */ }
320
- } else {
321
- stratum.cancelAgentRun(correlationId).catch(() => {});
322
- }
425
+ const primaryFailure = (source, target) => {
426
+ const record = usageRecordFromRaw(source?.usage, source?.telemetry, source?.dispatchId, {
427
+ model: cfg.modelID, effort: cfg.effort, duration_ms: Date.now() - startTime,
428
+ usd_source: source?.usdSource ?? (useLocalClaude ? 'reported' : undefined),
429
+ }, source?.split);
430
+ if (record) target.usages = [record];
431
+ return copyDispatchId(source, target);
323
432
  };
433
+ const abortController = new AbortController();
434
+ const stopRun = () => abortController.abort();
324
435
 
325
436
  // Subscribe BEFORE calling agentRun — events fire during the call.
326
437
  const unsub = stratum.onEvent(correlationId, subStepId, (env) => {
@@ -333,14 +444,14 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
333
444
  case 'agent_relay':
334
445
  if (m.role === 'assistant' && typeof m.text === 'string' && m.text.length > 0) {
335
446
  textParts.push(m.text);
336
- if (streamWriter) streamWriter.write({ type: 'assistant', content: m.text });
447
+ if (streamWriter) streamWriter.write({ type: 'assistant', content: m.text, ...laneStamp });
337
448
  }
338
449
  break;
339
450
  case 'tool_use_summary': {
340
451
  const tool = m.tool;
341
452
  if (tool) {
342
453
  if (streamWriter) {
343
- streamWriter.write({ type: 'tool_use', tool, input: m.input ?? {} });
454
+ streamWriter.write({ type: 'tool_use', tool, input: m.input ?? {}, ...laneStamp });
344
455
  }
345
456
  if (onToolUse) onToolUse({ tool, input: m.input ?? {}, timestamp: Date.now() });
346
457
  if (progress) {
@@ -350,7 +461,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
350
461
  }
351
462
  if (m.summary) {
352
463
  if (streamWriter) {
353
- streamWriter.write({ type: 'tool_use_summary', summary: m.summary, output: m.output ?? '' });
464
+ streamWriter.write({ type: 'tool_use_summary', summary: m.summary, output: m.output ?? '', ...laneStamp });
354
465
  }
355
466
  if (progress) progress.toolSummary(m.summary);
356
467
  }
@@ -369,6 +480,9 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
369
480
  const stepCost = m.cost_usd != null
370
481
  ? m.cost_usd
371
482
  : calculateCost(m.model, inTok, outTok, ccit, crit);
483
+ primaryUsdSource = m.cost_usd != null && primaryUsdSource !== 'estimated'
484
+ ? 'reported'
485
+ : 'estimated';
372
486
  usageTotals.cost_usd += stepCost;
373
487
  if (streamWriter) {
374
488
  streamWriter.write({
@@ -379,6 +493,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
379
493
  cache_read_input_tokens: crit,
380
494
  cost_usd: stepCost,
381
495
  model: m.model ?? null,
496
+ ...laneStamp,
382
497
  });
383
498
  }
384
499
  break;
@@ -420,7 +535,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
420
535
  // abort a spinning run.
421
536
  const localOnToolUse = ({ tool, input }) => {
422
537
  if (onToolUse) onToolUse({ tool, input, timestamp: Date.now() });
423
- if (streamWriter) streamWriter.write({ type: 'tool_use', tool, input: input ?? {} });
538
+ if (streamWriter) streamWriter.write({ type: 'tool_use', tool, input: input ?? {}, ...laneStamp });
424
539
  if (progress) progress.toolUse(tool, input?.command ?? input?.pattern ?? input?.file_path ?? '');
425
540
  if (opts.onAgentEvent && !abortReason) {
426
541
  const env = { schema_version: '0.2.6', kind: 'tool_use_summary', metadata: { tool, input: input ?? {}, summary: '', output: '' } };
@@ -429,156 +544,316 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
429
544
  }
430
545
  };
431
546
 
432
- let runResult;
547
+ // Keep the deadline and interrupt controls active through review repair.
433
548
  try {
434
- if (useLocalClaude) {
435
- // Test seam: an installed factory shim exposes an SDK-shaped query adapter
436
- // so the goldens drive the local path without spawning a real claude.
437
- // Gated on NODE_ENV=test so production always uses the real SDK.
438
- const localQuery = opts.localQuery
439
- ?? (process.env.NODE_ENV === 'test' && stratum ? stratum._localQuery : undefined);
440
- runResult = await runLocalClaudeAgent(actualPrompt, {
441
- cwd: opts.cwd ?? undefined,
442
- model: cfg.modelID ?? undefined,
443
- allowedTools: cfg.allowedTools ?? undefined,
444
- disallowedTools: cfg.disallowedTools ?? undefined,
445
- thinking: cfg.thinking ?? undefined,
446
- abortController,
447
- onToolUse: localOnToolUse,
448
- ...(localQuery ? { query: localQuery } : {}),
449
- });
450
- } else {
451
- runResult = await stratum.agentRun(agentType, actualPrompt, {
452
- modelID: cfg.modelID ?? undefined,
453
- allowedTools: cfg.allowedTools ?? undefined,
454
- disallowedTools: cfg.disallowedTools ?? undefined,
455
- thinking: cfg.thinking ?? undefined,
456
- effort: cfg.effort ?? undefined,
457
- sandboxMode,
458
- cwd: opts.cwd ?? undefined,
459
- correlationId,
460
- });
549
+ let runResult;
550
+ let primaryDispatchId = null;
551
+ let repairDispatchId = null;
552
+ try {
553
+ if (useLocalClaude) {
554
+ // Test seam: an installed factory shim exposes an SDK-shaped query adapter
555
+ // so the goldens drive the local path without spawning a real claude.
556
+ // Gated on NODE_ENV=test so production always uses the real SDK.
557
+ const localQuery = opts.localQuery
558
+ ?? (process.env.NODE_ENV === 'test' && stratum ? stratum._localQuery : undefined);
559
+ runResult = await runLocalClaudeAgent(actualPrompt, {
560
+ cwd: opts.cwd ?? undefined,
561
+ model: cfg.modelID ?? undefined,
562
+ allowedTools: cfg.allowedTools ?? undefined,
563
+ disallowedTools: cfg.disallowedTools ?? undefined,
564
+ thinking: cfg.thinking ?? undefined,
565
+ effort: cfg.effort ?? undefined,
566
+ abortController,
567
+ onToolUse: localOnToolUse,
568
+ // COMP-AGENT-LANES: only a lane-carrying run (parallel fanout item)
569
+ // relays local assistant text to the stream — engine-path parity for
570
+ // the lanes UI. Lane-less local runs keep their historical shape (no
571
+ // assistant stream writes).
572
+ ...(laneStamp.lane && streamWriter ? {
573
+ onAssistantText: (text) => {
574
+ streamWriter.write({ type: 'assistant', content: text, ...laneStamp });
575
+ },
576
+ } : {}),
577
+ telemetry: primaryTelemetry,
578
+ ...(localQuery ? { query: localQuery } : {}),
579
+ });
580
+ } else {
581
+ runResult = await stratum.agentRun(agentType, actualPrompt, {
582
+ ...executionOptions,
583
+ signal: abortController.signal,
584
+ correlationId,
585
+ telemetry: primaryTelemetry,
586
+ });
587
+ }
588
+ primaryDispatchId = typeof runResult?.dispatchId === 'string'
589
+ ? runResult.dispatchId
590
+ : null;
591
+ } catch (err) {
592
+ if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(err?.code)) throw primaryFailure(err, err);
593
+ // F3/G3: preserve any billable usage the failed run reported (the local
594
+ // connector attaches it on a non-success result / usage-bearing rejection) so
595
+ // the consumer failure envelope can still debit the engine/GSD ledgers. G3:
596
+ // the timeout/abort throws happen here too — attach the usage to THOSE errors
597
+ // (not only the generic AgentError) so timeout/stuck attempts are billed.
598
+ // The local Claude SDK's price is provider-reported on failures too; label it
599
+ // here so the receipt funnel does not drop it as unlabelled raw usd.
600
+ const errUsage = (err && typeof err === 'object' && err.usage)
601
+ ? (useLocalClaude && err.usage.usd_source === undefined ? { ...err.usage, usd_source: 'reported' } : err.usage)
602
+ : null;
603
+ if (timedOut) {
604
+ const e = new AgentTimeoutError(stepId, Date.now() - startTime);
605
+ if (errUsage) e.usage = errUsage;
606
+ throw primaryFailure(err, e);
607
+ }
608
+ if (userInterruptAction) {
609
+ const e = new UserInterruptError(stepId, userInterruptAction);
610
+ if (errUsage) e.usage = errUsage;
611
+ throw primaryFailure(err, e);
612
+ }
613
+ if (abortReason) {
614
+ const e = new AgentAbortedError(stepId, abortReason);
615
+ if (errUsage) e.usage = errUsage;
616
+ throw primaryFailure(err, e);
617
+ }
618
+ const agentError = new AgentError(err?.message ?? 'Agent run failed');
619
+ if (errUsage) agentError.usage = errUsage;
620
+ throw primaryFailure(err, agentError);
461
621
  }
462
- } catch (err) {
463
- // F3/G3: preserve any billable usage the failed run reported (the local
464
- // connector attaches it on a non-success result / usage-bearing rejection) so
465
- // the consumer failure envelope can still debit the engine/GSD ledgers. G3:
466
- // the timeout/abort throws happen here too attach the usage to THOSE errors
467
- // (not only the generic AgentError) so timeout/stuck attempts are billed.
468
- const errUsage = (err && typeof err === 'object' && err.usage) ? err.usage : null;
622
+
623
+ // H2: when the underlying run RESOLVES late (rather than rejecting) after a
624
+ // timeout/abort fired, its billable usage is on runResult.usage. Copy it onto
625
+ // the thrown error the same single channel the rejection path (G3) uses — so
626
+ // the consumer timeout envelope / stuck-ledger accounting still bill the attempt.
627
+ // Throwing here means usageTotals is never returned, so this is the ONLY channel
628
+ // (no double count).
629
+ const lateUsage = (runResult && typeof runResult === 'object' && runResult.usage && typeof runResult.usage === 'object')
630
+ ? runResult.usage
631
+ : null;
469
632
  if (timedOut) {
470
633
  const e = new AgentTimeoutError(stepId, Date.now() - startTime);
471
- if (errUsage) e.usage = errUsage;
472
- throw e;
634
+ if (lateUsage) e.usage = lateUsage;
635
+ throw primaryFailure(runResult, e);
636
+ }
637
+ if (userInterruptAction) {
638
+ const e = new UserInterruptError(stepId, userInterruptAction);
639
+ if (lateUsage) e.usage = lateUsage;
640
+ throw primaryFailure(runResult, e);
473
641
  }
474
- if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
475
642
  if (abortReason) {
476
643
  const e = new AgentAbortedError(stepId, abortReason);
477
- if (errUsage) e.usage = errUsage;
478
- throw e;
644
+ if (lateUsage) e.usage = lateUsage;
645
+ throw primaryFailure(runResult, e);
479
646
  }
480
- const agentError = new AgentError(err?.message ?? 'Agent run failed');
481
- if (errUsage) agentError.usage = errUsage;
482
- throw agentError;
483
- } finally {
484
- if (timeoutHandle) clearTimeout(timeoutHandle);
485
- if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
486
- unsub();
487
- }
488
647
 
489
- // H2: when the underlying run RESOLVES late (rather than rejecting) after a
490
- // timeout/abort fired, its billable usage is on runResult.usage. Copy it onto
491
- // the thrown error the same single channel the rejection path (G3) uses so
492
- // the consumer timeout envelope / stuck-ledger accounting still bill the attempt.
493
- // Throwing here means usageTotals is never returned, so this is the ONLY channel
494
- // (no double count).
495
- const lateUsage = (runResult && typeof runResult === 'object' && runResult.usage && typeof runResult.usage === 'object')
496
- ? runResult.usage
497
- : null;
498
- if (timedOut) {
499
- const e = new AgentTimeoutError(stepId, Date.now() - startTime);
500
- if (lateUsage) e.usage = lateUsage;
501
- throw e;
502
- }
503
- if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
504
- if (abortReason) {
505
- const e = new AgentAbortedError(stepId, abortReason);
506
- if (lateUsage) e.usage = lateUsage;
507
- throw e;
508
- }
648
+ // D2(b): the TS agent_run path returns a synchronous `complete` envelope with
649
+ // aggregate usage ({usd?, tokens, ms}) and streams NO step_usage progress
650
+ // eventswithout folding it in, budget accounting debits nothing on the TS
651
+ // route. The python / factory-shim path streams step_usage events (usageTotals
652
+ // already populated), so adopt runResult.usage only when the event stream
653
+ // contributed nothing (avoids double counting).
654
+ const runUsage = runResult && typeof runResult === 'object' ? runResult.usage : null;
655
+ const runSplit = runResult && typeof runResult === 'object' && runResult.split && typeof runResult.split === 'object'
656
+ ? runResult.split
657
+ : null;
658
+ const usageFromEvents = usageTotals.input_tokens || usageTotals.output_tokens || usageTotals.cost_usd;
659
+ if (runUsage && typeof runUsage === 'object' && !usageFromEvents) {
660
+ // STRAT-USAGE-SPLIT: the TS envelope now carries the true input/output
661
+ // detail beside its Budget-shaped usage. Adopt it; only a split-less
662
+ // (pre-surface-16) envelope falls back to the aggregate, which is filed
663
+ // as output for continuity — mislabeled, but the legacy column it always
664
+ // occupied. That path retires with surface 15.
665
+ if (runSplit) {
666
+ usageTotals.input_tokens += runSplit.input ?? 0;
667
+ usageTotals.output_tokens += runSplit.output ?? 0;
668
+ if (typeof runSplit.cacheRead === 'number') {
669
+ usageTotals.cache_read_input_tokens = (usageTotals.cache_read_input_tokens ?? 0) + runSplit.cacheRead;
670
+ }
671
+ if (typeof runSplit.cacheCreation === 'number') {
672
+ usageTotals.cache_creation_input_tokens = (usageTotals.cache_creation_input_tokens ?? 0) + runSplit.cacheCreation;
673
+ }
674
+ } else if (typeof runUsage.tokens === 'number') {
675
+ usageTotals.output_tokens += runUsage.tokens;
676
+ }
677
+ if (typeof runUsage.usd === 'number') usageTotals.cost_usd += runUsage.usd;
678
+ if (typeof runUsage.ms === 'number') usageTotals.duration_ms = (usageTotals.duration_ms ?? 0) + runUsage.ms;
679
+ if (!usageTotals.model && runResult.telemetry?.model) usageTotals.model = runResult.telemetry.model;
680
+ }
509
681
 
510
- // D2(b): the TS agent_run path returns a synchronous `complete` envelope with
511
- // aggregate usage ({usd?, tokens, ms}) and streams NO step_usage progress
512
- // events — without folding it in, budget accounting debits nothing on the TS
513
- // route. The python / factory-shim path streams step_usage events (usageTotals
514
- // already populated), so adopt runResult.usage only when the event stream
515
- // contributed nothing (avoids double counting).
516
- const runUsage = runResult && typeof runResult === 'object' ? runResult.usage : null;
517
- const usageFromEvents = usageTotals.input_tokens || usageTotals.output_tokens || usageTotals.cost_usd;
518
- if (runUsage && typeof runUsage === 'object' && !usageFromEvents) {
519
- if (typeof runUsage.tokens === 'number') usageTotals.output_tokens += runUsage.tokens;
520
- if (typeof runUsage.usd === 'number') usageTotals.cost_usd += runUsage.usd;
521
- if (typeof runUsage.ms === 'number') usageTotals.duration_ms = (usageTotals.duration_ms ?? 0) + runUsage.ms;
522
- if (!usageTotals.model && runResult.telemetry?.model) usageTotals.model = runResult.telemetry.model;
523
- }
682
+ const usages = [];
683
+ const primaryFromEvents = usageFromEvents || primaryUsdSource !== null;
684
+ if (primaryFromEvents) {
685
+ const primary = {
686
+ dispatch_id: primaryDispatchId ?? randomUUID(),
687
+ model: usageTotals.model ?? runResult?.telemetry?.model ?? cfg.modelID ?? 'unknown',
688
+ ...(runResult?.telemetry?.effort ?? cfg.effort
689
+ ? { effort: runResult?.telemetry?.effort ?? cfg.effort }
690
+ : {}),
691
+ duration_ms: runResult?.telemetry?.durationMs ?? usageTotals.duration_ms ?? (Date.now() - startTime),
692
+ input_tokens: usageTotals.input_tokens,
693
+ output_tokens: usageTotals.output_tokens,
694
+ ...(usageTotals.cache_read_input_tokens
695
+ ? { cache_read: usageTotals.cache_read_input_tokens }
696
+ : {}),
697
+ ...(usageTotals.cache_creation_input_tokens
698
+ ? { cache_creation: usageTotals.cache_creation_input_tokens }
699
+ : {}),
700
+ ...(usageTotals.cost_usd > 0
701
+ ? { cost_usd: usageTotals.cost_usd, usd_source: primaryUsdSource ?? 'estimated' }
702
+ : { usd_source: primaryUsdSource ?? 'estimated' }),
703
+ };
704
+ usages.push(primary);
705
+ } else {
706
+ const primary = usageRecordFromRaw(runUsage, runResult?.telemetry, primaryDispatchId, {
707
+ model: cfg.modelID,
708
+ effort: cfg.effort,
709
+ duration_ms: Date.now() - startTime,
710
+ usd_source: runResult?.usdSource ?? (useLocalClaude ? 'reported' : undefined),
711
+ }, runSplit);
712
+ if (primary) usages.push(primary);
713
+ }
524
714
 
525
- const text = (runResult && typeof runResult.text === 'string' && runResult.text.length > 0)
526
- ? runResult.text
527
- : textParts.join('');
715
+ const text = (runResult && typeof runResult.text === 'string' && runResult.text.length > 0)
716
+ ? runResult.text
717
+ : textParts.join('');
528
718
 
529
- if (progress) {
530
- progress.debug(`normalizer: textParts=${textParts.length}, text length=${text.length}`);
531
- if (text.length > 0) progress.debug(`text preview: ${text.slice(0, 300)}`);
532
- } else if (process.env.COMPOSE_DEBUG) {
533
- process.stderr.write(` [normalizer] textParts=${textParts.length}, text length=${text.length}\n`);
534
- }
719
+ if (progress) {
720
+ progress.debug(`normalizer: textParts=${textParts.length}, text length=${text.length}`);
721
+ if (text.length > 0) progress.debug(`text preview: ${text.slice(0, 300)}`);
722
+ } else if (process.env.COMPOSE_DEBUG) {
723
+ process.stderr.write(` [normalizer] textParts=${textParts.length}, text length=${text.length}\n`);
724
+ }
535
725
 
536
- // review_mode hook — MUST be before the !hasSchema early return (MF-3 in blueprint).
537
- // Parallel lens steps often have empty output_fields (hasSchema=false), but review
538
- // normalization must still run. The Stratum server validates the post-normalize result
539
- // via `ensure` expressions after stratum_step_done — not against raw text.
540
- if (opts.reviewMode === true) {
541
- const reviewAgentType = agentType; // already resolved from stepDispatch.agent at line 178
542
- const reviewModelId = usageTotals.model ?? cfg.modelID ?? null;
543
- const repairFn = stratum
544
- ? async (repairPrompt) => {
545
- const repairResult = await stratum.agentRun(reviewAgentType, repairPrompt, {
546
- modelID: cfg.modelID ?? undefined,
547
- cwd: opts.cwd ?? undefined,
548
- });
549
- return repairResult?.text ?? '';
550
- }
551
- : undefined;
552
- const reviewResult = await normalizeReviewResult(text, {
553
- agentType: reviewAgentType,
554
- modelId: reviewModelId,
555
- confidenceGate: opts.confidenceGate ?? 7,
556
- lens: opts.lens ?? 'general',
557
- repairFn,
558
- });
559
- return { text, result: reviewResult, usage: usageTotals };
560
- }
726
+ // review_mode hook — MUST be before the !hasSchema early return (MF-3 in blueprint).
727
+ // Parallel lens steps often have empty output_fields (hasSchema=false), but review
728
+ // normalization must still run. The Stratum server validates the post-normalize result
729
+ // via `ensure` expressions after stratum_step_done — not against raw text.
730
+ if (opts.reviewMode === true) {
731
+ const reviewAgentType = agentType; // already resolved from stepDispatch.agent at line 178
732
+ const reviewModelId = usageTotals.model ?? cfg.modelID ?? null;
733
+ // The repair dispatch is only CREDITED (dispatchIds.repair) when its output
734
+ // actually replaced the primary parse — a failed or unparseable repair still
735
+ // bills its usage but must not absorb the step's settlement.
736
+ let repairUsed = false;
737
+ let repairFailure = null;
738
+ let repairResultForControl = null;
739
+ const foldRepairUsage = (usage) => {
740
+ if (!usage || typeof usage !== 'object') return;
741
+ if (typeof usage.tokens === 'number') usageTotals.output_tokens += usage.tokens;
742
+ if (typeof usage.usd === 'number') usageTotals.cost_usd += usage.usd;
743
+ };
744
+ const repairFn = stratum
745
+ ? async (repairPrompt) => {
746
+ try {
747
+ const repairResult = await stratum.agentRun(reviewAgentType, repairPrompt, {
748
+ ...executionOptions,
749
+ signal: abortController.signal,
750
+ telemetry: { ...primaryTelemetry, site: 'review-repair' },
751
+ });
752
+ repairResultForControl = repairResult;
753
+ repairDispatchId = typeof repairResult?.dispatchId === 'string'
754
+ ? repairResult.dispatchId
755
+ : null;
756
+ foldRepairUsage(repairResult?.usage);
757
+ const record = usageRecordFromRaw(
758
+ repairResult?.usage,
759
+ repairResult?.telemetry,
760
+ repairDispatchId,
761
+ { model: cfg.modelID, effort: cfg.effort, usd_source: repairResult?.usdSource },
762
+ repairResult?.split && typeof repairResult.split === 'object' ? repairResult.split : null,
763
+ );
764
+ if (record) usages.push(record);
765
+ return repairResult?.text ?? '';
766
+ } catch (error) {
767
+ repairFailure = error;
768
+ repairDispatchId = typeof error?.dispatchId === 'string'
769
+ ? error.dispatchId
770
+ : null;
771
+ foldRepairUsage(error?.usage);
772
+ const record = usageRecordFromRaw(
773
+ error?.usage,
774
+ error?.telemetry,
775
+ repairDispatchId,
776
+ { model: cfg.modelID, effort: cfg.effort, usd_source: error?.usdSource },
777
+ error?.split,
778
+ );
779
+ if (record) usages.push(record);
780
+ throw error;
781
+ }
782
+ }
783
+ : undefined;
784
+ const reviewResult = await normalizeReviewResult(text, {
785
+ agentType: reviewAgentType,
786
+ modelId: reviewModelId,
787
+ confidenceGate: opts.confidenceGate ?? 7,
788
+ lens: opts.lens ?? 'general',
789
+ repairFn,
790
+ onRepairUsed: () => { repairUsed = true; },
791
+ });
792
+ // The tolerant text parser can recover ordinary repair failures, but it
793
+ // must never turn a cancelled or still-running repair into a clean review.
794
+ if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(repairFailure?.code)) {
795
+ repairFailure.usages = usages;
796
+ throw repairFailure;
797
+ }
798
+ const controlSource = repairFailure ?? repairResultForControl;
799
+ const controlError = timedOut
800
+ ? new AgentTimeoutError(stepId, Date.now() - startTime)
801
+ : userInterruptAction
802
+ ? new UserInterruptError(stepId, userInterruptAction)
803
+ : abortReason ? new AgentAbortedError(stepId, abortReason) : null;
804
+ if (controlError) {
805
+ if (controlSource?.usage) controlError.usage = controlSource.usage;
806
+ controlError.usages = usages;
807
+ throw copyDispatchId(controlSource, controlError);
808
+ }
809
+ return {
810
+ text,
811
+ result: reviewResult,
812
+ usage: usageTotals,
813
+ usages,
814
+ dispatchIds: { primary: primaryDispatchId, repair: repairUsed ? repairDispatchId : null },
815
+ };
816
+ }
561
817
 
562
- if (!hasStructuredOutput) {
563
- return { text, result: null, usage: usageTotals };
564
- }
818
+ if (!hasStructuredOutput) {
819
+ return {
820
+ text,
821
+ result: null,
822
+ usage: usageTotals,
823
+ usages,
824
+ dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
825
+ };
826
+ }
565
827
 
566
- const result = extractJson(text);
567
- if (result === null) {
568
- if (progress) {
569
- progress.warn('Could not extract JSON from agent output, using fallback');
570
- } else {
571
- process.stderr.write(' ⚠ Could not extract JSON from agent output, using fallback\n');
828
+ const result = extractJson(text);
829
+ if (result === null) {
830
+ if (progress) {
831
+ progress.warn('Could not extract JSON from agent output, using fallback');
832
+ } else {
833
+ process.stderr.write(' ⚠ Could not extract JSON from agent output, using fallback\n');
834
+ }
835
+ const summary = text.slice(0, 200).replace(/\n/g, ' ').trim();
836
+ const normalizationFailure = summary || 'Could not extract structured output';
837
+ return {
838
+ text,
839
+ result: { summary: normalizationFailure },
840
+ usage: usageTotals,
841
+ usages,
842
+ normalizationFailure,
843
+ dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
844
+ };
572
845
  }
573
- const summary = text.slice(0, 200).replace(/\n/g, ' ').trim();
574
- const normalizationFailure = summary || 'Could not extract structured output';
846
+
575
847
  return {
576
848
  text,
577
- result: { summary: normalizationFailure },
849
+ result,
578
850
  usage: usageTotals,
579
- normalizationFailure,
851
+ usages,
852
+ dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
580
853
  };
854
+ } finally {
855
+ if (timeoutHandle) clearTimeout(timeoutHandle);
856
+ if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
857
+ unsub();
581
858
  }
582
-
583
- return { text, result, usage: usageTotals };
584
859
  }