@smartmemory/compose 0.3.7 → 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 (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. package/server/ideabox-cache.js +0 -77
@@ -251,6 +251,75 @@ function copyDispatchId(source, target) {
251
251
  * call-sites do not all need to be edited in a single sweep. New required opt:
252
252
  * `opts.stratum` — the StratumMcpClient instance.
253
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
+
254
323
  export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, opts = {}) {
255
324
  const progress = opts.progress;
256
325
  const streamWriter = opts.streamWriter;
@@ -267,6 +336,11 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
267
336
 
268
337
  const stepId = stepDispatch.step_id ?? 'unknown';
269
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 } : {};
270
344
  // D6: the engine ships only the bare provider literal (stepDispatch.agent), so
271
345
  // the full profile string (with tool restrictions + model tier) is supplied
272
346
  // compose-side via opts.profile, keyed off the compose-owned sidecar. It
@@ -285,11 +359,22 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
285
359
  primaryTelemetry.attempt = stepDispatch.attempt;
286
360
  }
287
361
  primaryTelemetry.effort_intended = cfg.effort ?? null;
288
- // A read-only profile (no Edit/Write/Bash) also maps to a read-only sandbox so
289
- // the restriction binds at the engine's connector, not just at this invocation.
290
- const readOnlyProfile = Array.isArray(cfg.disallowedTools)
291
- && ['Edit', 'Write'].every((tool) => cfg.disallowedTools.includes(tool));
292
- const sandboxMode = readOnlyProfile ? 'read-only' : undefined;
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
+ };
293
378
 
294
379
  const outputFields = stepDispatch.output_fields;
295
380
  const hasSchema = outputFields && typeof outputFields === 'object' && Object.keys(outputFields).length > 0;
@@ -325,6 +410,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
325
410
  cost_usd: 0,
326
411
  model: null,
327
412
  };
413
+ let primaryUsdSource = null;
328
414
 
329
415
  let timedOut = false;
330
416
  let userInterruptAction = null;
@@ -333,21 +419,19 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
333
419
  // the run mid-stream by returning a truthy reason from a tool event.
334
420
  let abortReason = null;
335
421
 
336
- // V2/V3: CONTROLLED claude executions (consumer items, review fanout) run via
337
- // the compose-LOCAL connector the only seam that can enforce claude tool
338
- // restrictions AND be interrupted (the sync engine agent_run can do neither;
339
- // its background mode is codex+read-only-only). Abort replaces cancelAgentRun
340
- // as the stop mechanism, and the connector streams real tool events so the
341
- // 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.
342
424
  const useLocalClaude = opts.localExecution === true && cfg.provider === 'claude';
343
- const abortController = useLocalClaude ? new AbortController() : null;
344
- const stopRun = () => {
345
- if (abortController) {
346
- try { abortController.abort(); } catch { /* already aborted */ }
347
- } else {
348
- stratum.cancelAgentRun(correlationId).catch(() => {});
349
- }
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);
350
432
  };
433
+ const abortController = new AbortController();
434
+ const stopRun = () => abortController.abort();
351
435
 
352
436
  // Subscribe BEFORE calling agentRun — events fire during the call.
353
437
  const unsub = stratum.onEvent(correlationId, subStepId, (env) => {
@@ -360,14 +444,14 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
360
444
  case 'agent_relay':
361
445
  if (m.role === 'assistant' && typeof m.text === 'string' && m.text.length > 0) {
362
446
  textParts.push(m.text);
363
- if (streamWriter) streamWriter.write({ type: 'assistant', content: m.text });
447
+ if (streamWriter) streamWriter.write({ type: 'assistant', content: m.text, ...laneStamp });
364
448
  }
365
449
  break;
366
450
  case 'tool_use_summary': {
367
451
  const tool = m.tool;
368
452
  if (tool) {
369
453
  if (streamWriter) {
370
- streamWriter.write({ type: 'tool_use', tool, input: m.input ?? {} });
454
+ streamWriter.write({ type: 'tool_use', tool, input: m.input ?? {}, ...laneStamp });
371
455
  }
372
456
  if (onToolUse) onToolUse({ tool, input: m.input ?? {}, timestamp: Date.now() });
373
457
  if (progress) {
@@ -377,7 +461,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
377
461
  }
378
462
  if (m.summary) {
379
463
  if (streamWriter) {
380
- 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 });
381
465
  }
382
466
  if (progress) progress.toolSummary(m.summary);
383
467
  }
@@ -396,6 +480,9 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
396
480
  const stepCost = m.cost_usd != null
397
481
  ? m.cost_usd
398
482
  : calculateCost(m.model, inTok, outTok, ccit, crit);
483
+ primaryUsdSource = m.cost_usd != null && primaryUsdSource !== 'estimated'
484
+ ? 'reported'
485
+ : 'estimated';
399
486
  usageTotals.cost_usd += stepCost;
400
487
  if (streamWriter) {
401
488
  streamWriter.write({
@@ -406,6 +493,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
406
493
  cache_read_input_tokens: crit,
407
494
  cost_usd: stepCost,
408
495
  model: m.model ?? null,
496
+ ...laneStamp,
409
497
  });
410
498
  }
411
499
  break;
@@ -447,7 +535,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
447
535
  // abort a spinning run.
448
536
  const localOnToolUse = ({ tool, input }) => {
449
537
  if (onToolUse) onToolUse({ tool, input, timestamp: Date.now() });
450
- if (streamWriter) streamWriter.write({ type: 'tool_use', tool, input: input ?? {} });
538
+ if (streamWriter) streamWriter.write({ type: 'tool_use', tool, input: input ?? {}, ...laneStamp });
451
539
  if (progress) progress.toolUse(tool, input?.command ?? input?.pattern ?? input?.file_path ?? '');
452
540
  if (opts.onAgentEvent && !abortReason) {
453
541
  const env = { schema_version: '0.2.6', kind: 'tool_use_summary', metadata: { tool, input: input ?? {}, summary: '', output: '' } };
@@ -456,207 +544,316 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
456
544
  }
457
545
  };
458
546
 
459
- let runResult;
460
- let primaryDispatchId = null;
461
- let repairDispatchId = null;
547
+ // Keep the deadline and interrupt controls active through review repair.
462
548
  try {
463
- if (useLocalClaude) {
464
- // Test seam: an installed factory shim exposes an SDK-shaped query adapter
465
- // so the goldens drive the local path without spawning a real claude.
466
- // Gated on NODE_ENV=test so production always uses the real SDK.
467
- const localQuery = opts.localQuery
468
- ?? (process.env.NODE_ENV === 'test' && stratum ? stratum._localQuery : undefined);
469
- runResult = await runLocalClaudeAgent(actualPrompt, {
470
- cwd: opts.cwd ?? undefined,
471
- model: cfg.modelID ?? undefined,
472
- allowedTools: cfg.allowedTools ?? undefined,
473
- disallowedTools: cfg.disallowedTools ?? undefined,
474
- thinking: cfg.thinking ?? undefined,
475
- effort: cfg.effort ?? undefined,
476
- abortController,
477
- onToolUse: localOnToolUse,
478
- telemetry: primaryTelemetry,
479
- ...(localQuery ? { query: localQuery } : {}),
480
- });
481
- } else {
482
- runResult = await stratum.agentRun(agentType, actualPrompt, {
483
- modelID: cfg.modelID ?? undefined,
484
- allowedTools: cfg.allowedTools ?? undefined,
485
- disallowedTools: cfg.disallowedTools ?? undefined,
486
- thinking: cfg.thinking ?? undefined,
487
- effort: cfg.effort ?? undefined,
488
- sandboxMode,
489
- cwd: opts.cwd ?? undefined,
490
- correlationId,
491
- telemetry: primaryTelemetry,
492
- });
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);
493
621
  }
494
- primaryDispatchId = typeof runResult?.dispatchId === 'string'
495
- ? runResult.dispatchId
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
496
631
  : null;
497
- } catch (err) {
498
- // F3/G3: preserve any billable usage the failed run reported (the local
499
- // connector attaches it on a non-success result / usage-bearing rejection) so
500
- // the consumer failure envelope can still debit the engine/GSD ledgers. G3:
501
- // the timeout/abort throws happen here too — attach the usage to THOSE errors
502
- // (not only the generic AgentError) so timeout/stuck attempts are billed.
503
- const errUsage = (err && typeof err === 'object' && err.usage) ? err.usage : null;
504
632
  if (timedOut) {
505
633
  const e = new AgentTimeoutError(stepId, Date.now() - startTime);
506
- if (errUsage) e.usage = errUsage;
507
- throw copyDispatchId(err, e);
634
+ if (lateUsage) e.usage = lateUsage;
635
+ throw primaryFailure(runResult, e);
508
636
  }
509
637
  if (userInterruptAction) {
510
- throw copyDispatchId(err, new UserInterruptError(stepId, userInterruptAction));
638
+ const e = new UserInterruptError(stepId, userInterruptAction);
639
+ if (lateUsage) e.usage = lateUsage;
640
+ throw primaryFailure(runResult, e);
511
641
  }
512
642
  if (abortReason) {
513
643
  const e = new AgentAbortedError(stepId, abortReason);
514
- if (errUsage) e.usage = errUsage;
515
- throw copyDispatchId(err, e);
644
+ if (lateUsage) e.usage = lateUsage;
645
+ throw primaryFailure(runResult, e);
516
646
  }
517
- const agentError = new AgentError(err?.message ?? 'Agent run failed');
518
- if (errUsage) agentError.usage = errUsage;
519
- throw copyDispatchId(err, agentError);
520
- } finally {
521
- if (timeoutHandle) clearTimeout(timeoutHandle);
522
- if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
523
- unsub();
524
- }
525
647
 
526
- // H2: when the underlying run RESOLVES late (rather than rejecting) after a
527
- // timeout/abort fired, its billable usage is on runResult.usage. Copy it onto
528
- // the thrown error the same single channel the rejection path (G3) uses so
529
- // the consumer timeout envelope / stuck-ledger accounting still bill the attempt.
530
- // Throwing here means usageTotals is never returned, so this is the ONLY channel
531
- // (no double count).
532
- const lateUsage = (runResult && typeof runResult === 'object' && runResult.usage && typeof runResult.usage === 'object')
533
- ? runResult.usage
534
- : null;
535
- if (timedOut) {
536
- const e = new AgentTimeoutError(stepId, Date.now() - startTime);
537
- if (lateUsage) e.usage = lateUsage;
538
- throw copyDispatchId(runResult, e);
539
- }
540
- if (userInterruptAction) {
541
- throw copyDispatchId(runResult, new UserInterruptError(stepId, userInterruptAction));
542
- }
543
- if (abortReason) {
544
- const e = new AgentAbortedError(stepId, abortReason);
545
- if (lateUsage) e.usage = lateUsage;
546
- throw copyDispatchId(runResult, e);
547
- }
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
+ }
548
681
 
549
- // D2(b): the TS agent_run path returns a synchronous `complete` envelope with
550
- // aggregate usage ({usd?, tokens, ms}) and streams NO step_usage progress
551
- // events — without folding it in, budget accounting debits nothing on the TS
552
- // route. The python / factory-shim path streams step_usage events (usageTotals
553
- // already populated), so adopt runResult.usage only when the event stream
554
- // contributed nothing (avoids double counting).
555
- const runUsage = runResult && typeof runResult === 'object' ? runResult.usage : null;
556
- const usageFromEvents = usageTotals.input_tokens || usageTotals.output_tokens || usageTotals.cost_usd;
557
- if (runUsage && typeof runUsage === 'object' && !usageFromEvents) {
558
- if (typeof runUsage.tokens === 'number') usageTotals.output_tokens += runUsage.tokens;
559
- if (typeof runUsage.usd === 'number') usageTotals.cost_usd += runUsage.usd;
560
- if (typeof runUsage.ms === 'number') usageTotals.duration_ms = (usageTotals.duration_ms ?? 0) + runUsage.ms;
561
- if (!usageTotals.model && runResult.telemetry?.model) usageTotals.model = runResult.telemetry.model;
562
- }
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
+ }
563
714
 
564
- const text = (runResult && typeof runResult.text === 'string' && runResult.text.length > 0)
565
- ? runResult.text
566
- : textParts.join('');
715
+ const text = (runResult && typeof runResult.text === 'string' && runResult.text.length > 0)
716
+ ? runResult.text
717
+ : textParts.join('');
567
718
 
568
- if (progress) {
569
- progress.debug(`normalizer: textParts=${textParts.length}, text length=${text.length}`);
570
- if (text.length > 0) progress.debug(`text preview: ${text.slice(0, 300)}`);
571
- } else if (process.env.COMPOSE_DEBUG) {
572
- process.stderr.write(` [normalizer] textParts=${textParts.length}, text length=${text.length}\n`);
573
- }
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
+ }
574
725
 
575
- // review_mode hook — MUST be before the !hasSchema early return (MF-3 in blueprint).
576
- // Parallel lens steps often have empty output_fields (hasSchema=false), but review
577
- // normalization must still run. The Stratum server validates the post-normalize result
578
- // via `ensure` expressions after stratum_step_done — not against raw text.
579
- if (opts.reviewMode === true) {
580
- const reviewAgentType = agentType; // already resolved from stepDispatch.agent at line 178
581
- const reviewModelId = usageTotals.model ?? cfg.modelID ?? null;
582
- // The repair dispatch is only CREDITED (dispatchIds.repair) when its output
583
- // actually replaced the primary parse — a failed or unparseable repair still
584
- // bills its usage but must not absorb the step's settlement.
585
- let repairUsed = false;
586
- const foldRepairUsage = (usage) => {
587
- if (!usage || typeof usage !== 'object') return;
588
- if (typeof usage.tokens === 'number') usageTotals.output_tokens += usage.tokens;
589
- if (typeof usage.usd === 'number') usageTotals.cost_usd += usage.usd;
590
- };
591
- const repairFn = stratum
592
- ? async (repairPrompt) => {
593
- try {
594
- const repairResult = await stratum.agentRun(reviewAgentType, repairPrompt, {
595
- modelID: cfg.modelID ?? undefined,
596
- cwd: opts.cwd ?? undefined,
597
- telemetry: { ...primaryTelemetry, site: 'review-repair' },
598
- });
599
- repairDispatchId = typeof repairResult?.dispatchId === 'string'
600
- ? repairResult.dispatchId
601
- : null;
602
- foldRepairUsage(repairResult?.usage);
603
- return repairResult?.text ?? '';
604
- } catch (error) {
605
- repairDispatchId = typeof error?.dispatchId === 'string'
606
- ? error.dispatchId
607
- : null;
608
- foldRepairUsage(error?.usage);
609
- throw error;
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
+ }
610
782
  }
611
- }
612
- : undefined;
613
- const reviewResult = await normalizeReviewResult(text, {
614
- agentType: reviewAgentType,
615
- modelId: reviewModelId,
616
- confidenceGate: opts.confidenceGate ?? 7,
617
- lens: opts.lens ?? 'general',
618
- repairFn,
619
- onRepairUsed: () => { repairUsed = true; },
620
- });
621
- return {
622
- text,
623
- result: reviewResult,
624
- usage: usageTotals,
625
- dispatchIds: { primary: primaryDispatchId, repair: repairUsed ? repairDispatchId : null },
626
- };
627
- }
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
+ }
628
817
 
629
- if (!hasStructuredOutput) {
630
- return {
631
- text,
632
- result: null,
633
- usage: usageTotals,
634
- dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
635
- };
636
- }
818
+ if (!hasStructuredOutput) {
819
+ return {
820
+ text,
821
+ result: null,
822
+ usage: usageTotals,
823
+ usages,
824
+ dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
825
+ };
826
+ }
637
827
 
638
- const result = extractJson(text);
639
- if (result === null) {
640
- if (progress) {
641
- progress.warn('Could not extract JSON from agent output, using fallback');
642
- } else {
643
- 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
+ };
644
845
  }
645
- const summary = text.slice(0, 200).replace(/\n/g, ' ').trim();
646
- const normalizationFailure = summary || 'Could not extract structured output';
846
+
647
847
  return {
648
848
  text,
649
- result: { summary: normalizationFailure },
849
+ result,
650
850
  usage: usageTotals,
651
- normalizationFailure,
851
+ usages,
652
852
  dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
653
853
  };
854
+ } finally {
855
+ if (timeoutHandle) clearTimeout(timeoutHandle);
856
+ if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
857
+ unsub();
654
858
  }
655
-
656
- return {
657
- text,
658
- result,
659
- usage: usageTotals,
660
- dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
661
- };
662
859
  }