@smartmemory/compose 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -48,3 +48,57 @@ export function resolveProjectTag(cwd) {
48
48
  export function sourcePathFor(projectTag, repoRel) {
49
49
  return `compose/${projectTag}/${repoRel}`;
50
50
  }
51
+
52
+ /**
53
+ * GOV-COMPOSE-SEAM-1 step 0 (`plumbing`).
54
+ *
55
+ * Translate the `smartmemory` config block into the three env vars Stratum's
56
+ * policy client reads, so Compose's ingest events and Stratum's enforcement
57
+ * events land in ONE workspace instead of two (or, today, one and nowhere).
58
+ *
59
+ * Two traps this function exists to close:
60
+ *
61
+ * 1. `workspaceId` is two different things. Top-level `compose.json#workspaceId`
62
+ * is a Compose PROJECT SLUG (`^[a-z][a-z0-9-]{1,63}$`, see
63
+ * discover-workspaces.js) used for provenance tagging. The SmartMemory
64
+ * workspace id lives at `smartmemory.workspaceId` and looks like
65
+ * `team_26f0bbe60a4c`. Sending the slug as `X-Workspace-Id` scopes every
66
+ * event to a workspace that does not exist, and the API answers 200, so the
67
+ * failure is silent. This reads the block, never the top level.
68
+ * 2. Stratum's `SmartMemoryClient` reads `process.env` ONCE at construction
69
+ * (policy/smartmemory_client.ts). Setting these after the MCP subprocess
70
+ * starts is a no-op — they must be in the spawn env, which is why this
71
+ * returns a plain object for `connect()` to merge rather than mutating
72
+ * `process.env`.
73
+ *
74
+ * Fail-quiet by design, matching the existing fail-open ingest contract: an
75
+ * unconfigured, disabled, or half-configured workspace returns `{}` and Stratum
76
+ * simply runs without policy delivery (it warns once on its own). A partial env
77
+ * would be worse than none — it produces events addressed to nowhere.
78
+ *
79
+ * @param {string} cwd
80
+ * @returns {{SMARTMEMORY_API_URL?: string, SMARTMEMORY_API_KEY?: string, SMARTMEMORY_WORKSPACE_ID?: string}}
81
+ */
82
+ export function resolveStratumPolicyEnv(cwd) {
83
+ let cfg;
84
+ try {
85
+ cfg = getSmartmemoryConfig(cwd);
86
+ } catch {
87
+ return {};
88
+ }
89
+ if (cfg.enabled !== true) return {};
90
+
91
+ const baseUrl = typeof cfg.baseUrl === 'string' ? cfg.baseUrl.trim() : '';
92
+ // NEVER cfg.workspaceId's top-level namesake — see trap 1 above.
93
+ const workspaceId = typeof cfg.workspaceId === 'string' ? cfg.workspaceId.trim() : '';
94
+ const apiKey = cfg.apiKeyEnv ? process.env[cfg.apiKeyEnv] : undefined;
95
+
96
+ // All three or nothing.
97
+ if (!baseUrl || !workspaceId || !apiKey) return {};
98
+
99
+ return {
100
+ SMARTMEMORY_API_URL: baseUrl,
101
+ SMARTMEMORY_API_KEY: apiKey,
102
+ SMARTMEMORY_WORKSPACE_ID: workspaceId,
103
+ };
104
+ }
@@ -26,9 +26,23 @@ export function renderFeatureEventContent(row, projectTag) {
26
26
  return `[compose:${projectTag}] ${row.ts} ${row.tool} ${row.code ?? row.feature_code ?? '-'} by ${row.actor ?? 'mcp:agent'}`;
27
27
  }
28
28
 
29
- /** PURE. One deterministic line per gate-log entry. Shared with sync. */
29
+ /** PURE. One deterministic line per gate-log entry. Shared with sync.
30
+ *
31
+ * GOV-COMPOSE-SEAM-1 step 0 (F2): this is a **Vision UI product gate** — a
32
+ * human approving a feature in the Compose UI, keyed by the UI gate id and a
33
+ * feature code. It is NOT Stratum's `gate_resolution`, which is a flow-step
34
+ * gate keyed by `run_id`/`step_id` and emitted by the engine when a run carries
35
+ * a policy bundle. The design assumed these were two records of one event and
36
+ * called for deduping them by `run_id`; they are two events, and no Stratum
37
+ * run id exists at this call site (see gate-log-store.js). Deduping them would
38
+ * have destroyed a real record.
39
+ *
40
+ * So they are disambiguated instead of merged: this line is tagged
41
+ * `vision-gate` rather than the bare `gate`, so a workspace query can separate
42
+ * product approvals from enforcement gates without guessing from shape.
43
+ */
30
44
  export function renderGateLogContent(entry, projectTag) {
31
- return `[compose:${projectTag}] ${entry.timestamp} gate:${entry.decision} ${entry.feature_code ?? '-'} ${entry.id}`;
45
+ return `[compose:${projectTag}] ${entry.timestamp} vision-gate:${entry.decision} ${entry.feature_code ?? '-'} ${entry.id}`;
32
46
  }
33
47
 
34
48
  /** PURE. context for an event item (cli:compose regime — content-hash dedupe). */
@@ -45,6 +59,9 @@ export function buildGateLogContext(projectTag, entry) {
45
59
  return {
46
60
  origin: 'cli:compose',
47
61
  project: projectTag,
62
+ // Explicit record kind so a Compose product-gate approval is never mistaken
63
+ // for a Stratum enforcement `gate_resolution`. See renderGateLogContent.
64
+ record_kind: 'compose_vision_gate',
48
65
  source_path: sourcePathFor(projectTag, '.compose/data/gate-log.jsonl'),
49
66
  event: entry,
50
67
  };
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { checkStaleness } from './staleness.js';
7
+ import { findStaleArtifacts } from './lineage.js';
8
8
  import { readHypotheses, formatRejectedHypotheses } from './bug-ledger.js';
9
9
 
10
10
  // ---------------------------------------------------------------------------
@@ -242,13 +242,14 @@ export function buildGateContext(gateDispatch, context, gateExtras) {
242
242
  sections.push(`## Files Changed by This Feature\n${context.filesChanged.map(f => '- ' + f).join('\n')}`);
243
243
  }
244
244
 
245
- // Staleness warnings — flag artifacts that belong to an earlier phase
246
- if (context.featureDir && gateExtras?.toPhase) {
247
- const staleArtifacts = checkStaleness(context.featureDir, gateExtras.toPhase);
248
- const stale = staleArtifacts.filter(a => a.stale);
245
+ // Staleness warnings — derivation-based (COMP-PROV-LINEAGE): flag artifacts
246
+ // whose upstream (wasDerivedFrom) changed more recently than they did. Phase-
247
+ // independent, so it does not need gateExtras.toPhase.
248
+ if (context.featureDir) {
249
+ const stale = findStaleArtifacts(context.featureDir).filter(a => a.stale);
249
250
  if (stale.length > 0) {
250
251
  const lines = stale.map(a =>
251
- `- **${a.file}** was written in phase \`${a.writtenPhase}\` but feature is now in \`${a.currentPhase}\``
252
+ `- **${a.file}** may be outdated an upstream it was derived from (${a.staleAgainst.join(', ')}) changed more recently`
252
253
  );
253
254
  sections.push(`## Stale Artifacts\nThe following artifacts may be outdated:\n${lines.join('\n')}`);
254
255
  }
@@ -137,6 +137,46 @@ const BIN_CONFIG = {
137
137
  },
138
138
  };
139
139
 
140
+ // Sibling checkout's package.json — used only to tell the user when the
141
+ // installed dependency is shadowing a checkout at a different version.
142
+ export const LIVE_STRATUM_TS_PACKAGE_JSON = resolve(
143
+ __dirname, '..', '..', 'stratum', 'ts', 'package.json',
144
+ );
145
+
146
+ function readPackageVersion(packageJsonPath) {
147
+ try {
148
+ const version = JSON.parse(readFileSync(packageJsonPath, 'utf8'))?.version;
149
+ return typeof version === 'string' ? version : undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ // One warning per (kind, paths, versions) per process: the resolver runs on every connect.
156
+ const shadowWarned = new Set();
157
+
158
+ /**
159
+ * The installed dependency wins over the sibling checkout by design (CI has no
160
+ * sibling). In the monorepo that silently runs a STALE stratum after a local
161
+ * rebuild — found 2026-08-30 when the STRAT-LEARN-COST census ran against
162
+ * 0.3.3 (no `stratum_usage_report`) while `stratum/ts` was at 0.3.4. Say so
163
+ * once, with the escape hatch, whenever the two versions differ.
164
+ */
165
+ function warnIfSiblingShadowed(kind, envVar, installedPackageJsonPath, siblingPackageJsonPath, warn) {
166
+ if (!siblingPackageJsonPath || !existsSync(siblingPackageJsonPath)) return;
167
+ const installedVersion = readPackageVersion(installedPackageJsonPath);
168
+ const siblingVersion = readPackageVersion(siblingPackageJsonPath);
169
+ if (!installedVersion || !siblingVersion || installedVersion === siblingVersion) return;
170
+ const key = `${kind}|${installedPackageJsonPath}|${siblingPackageJsonPath}|${installedVersion}|${siblingVersion}`;
171
+ if (shadowWarned.has(key)) return;
172
+ shadowWarned.add(key);
173
+ warn(
174
+ `[stratum-engine] using installed @smartmemory/stratum ${installedVersion}; `
175
+ + `the sibling checkout at ${dirname(siblingPackageJsonPath)} is ${siblingVersion} and is NOT what compose runs. `
176
+ + `Set ${envVar} to run a rebuilt checkout (${kind === 'mcp' ? 'dist/mcp/main.js' : 'dist/cli/stratum.js'}).`,
177
+ );
178
+ }
179
+
140
180
  /** Resolve a bin from an installed @smartmemory/stratum package, if present. */
141
181
  function resolveInstalledBin(packageBin, requireResolve) {
142
182
  let packageJsonPath;
@@ -156,7 +196,7 @@ function resolveInstalledBin(packageBin, requireResolve) {
156
196
  ? packageJson.bin?.[packageBin]
157
197
  : undefined;
158
198
  if (typeof relativeBin !== 'string') return undefined;
159
- return resolve(dirname(packageJsonPath), relativeBin);
199
+ return { bin: resolve(dirname(packageJsonPath), relativeBin), packageJsonPath };
160
200
  } catch {
161
201
  return undefined;
162
202
  }
@@ -168,7 +208,7 @@ function resolveInstalledBin(packageBin, requireResolve) {
168
208
  *
169
209
  * @param {'mcp'|'cli'} kind
170
210
  * @param {string} [cwd]
171
- * @param {{env?: object, requireResolve?: Function, siblingBins?: object}} [deps]
211
+ * @param {{env?: object, requireResolve?: Function, siblingBins?: object, siblingPackageJson?: string, warn?: Function}} [deps]
172
212
  */
173
213
  export function resolveStratumBin(kind, cwd = process.cwd(), deps = {}) {
174
214
  const config = BIN_CONFIG[kind];
@@ -180,11 +220,20 @@ export function resolveStratumBin(kind, cwd = process.cwd(), deps = {}) {
180
220
  || (kind === 'cli' ? env.COMPOSE_STRATUM_TS_BIN : undefined);
181
221
  if (envCandidate && existsSync(envCandidate)) return envCandidate;
182
222
 
183
- const installedCandidate = resolveInstalledBin(
223
+ const installed = resolveInstalledBin(
184
224
  config.packageBin,
185
225
  deps.requireResolve ?? require.resolve.bind(require),
186
226
  );
187
- if (installedCandidate && existsSync(installedCandidate)) return installedCandidate;
227
+ if (installed && existsSync(installed.bin)) {
228
+ warnIfSiblingShadowed(
229
+ kind,
230
+ config.env,
231
+ installed.packageJsonPath,
232
+ deps.siblingPackageJson ?? LIVE_STRATUM_TS_PACKAGE_JSON,
233
+ deps.warn ?? ((message) => console.warn(message)),
234
+ );
235
+ return installed.bin;
236
+ }
188
237
 
189
238
  const siblingCandidate = deps.siblingBins?.[kind] ?? config.sibling;
190
239
  if (siblingCandidate && existsSync(siblingCandidate)) return siblingCandidate;
@@ -21,6 +21,7 @@ import YAML from 'yaml';
21
21
  import { validateBuildStreamEvent } from './build-stream-schema.js';
22
22
  import { appendEvent, resolveDispatchLedgerCwd } from './dispatch-ledger.js';
23
23
  import { resolveStratumMcpConnection } from './stratum-engine.js';
24
+ import { resolveStratumPolicyEnv } from './smartmemory-config.js';
24
25
 
25
26
  const RUNTIME_INPUT_REF = /^\$\.input\.([A-Za-z_][A-Za-z0-9_]*)$/;
26
27
 
@@ -152,12 +153,9 @@ export function resolveStepProfile(profiles, stepId) {
152
153
 
153
154
  /**
154
155
  * Build the TS `stratum_agent_run` request from an agent string + compose-side
155
- * options. The engine surface (contracts/mcp-surface.json) accepts only
156
- * {agent, prompt, cwd, model?, sandboxMode?, background?} and rejects any other
157
- * key, so the python-era knobs (allowed_tools/thinking/effort/correlation_id)
158
- * are NOT sent — they are compose-side concerns applied at the invocation
159
- * (resolveAgentConfig). `agent` is normalized to the bare provider literal the
160
- * engine requires; `cwd` is required and defaults to the process cwd.
156
+ * options. Capability restrictions and reasoning are execution requirements,
157
+ * not telemetry: preserve them on the wire. The server validates provider
158
+ * support, and refuses unsupported options instead of silently dropping them.
161
159
  */
162
160
  export function buildAgentRunRequest(agentType, prompt, opts = {}) {
163
161
  const provider = String(agentType ?? 'claude').split(':', 1)[0] || 'claude';
@@ -167,6 +165,11 @@ export function buildAgentRunRequest(agentType, prompt, opts = {}) {
167
165
  cwd: opts.cwd ?? process.cwd(),
168
166
  ...(opts.modelID ? { model: opts.modelID } : {}),
169
167
  ...(opts.sandboxMode ? { sandboxMode: opts.sandboxMode } : {}),
168
+ ...(opts.allowedTools !== undefined ? { allowedTools: opts.allowedTools } : {}),
169
+ ...(opts.disallowedTools !== undefined ? { disallowedTools: opts.disallowedTools } : {}),
170
+ ...(opts.thinking !== undefined ? { thinking: opts.thinking } : {}),
171
+ ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
172
+ ...(opts.cancellationId !== undefined ? { cancellationId: opts.cancellationId } : {}),
170
173
  };
171
174
  }
172
175
 
@@ -183,6 +186,8 @@ export class StratumMcpClient {
183
186
  #client = null;
184
187
  #transport = null;
185
188
  #connected = false;
189
+ #toolNames = null;
190
+ #agentFields = null;
186
191
  // STRAT-PAR-STREAM: subscribers keyed by `${flowId}::${stepId}` → Set<handler>
187
192
  #eventSubs = new Map();
188
193
 
@@ -237,11 +242,7 @@ export class StratumMcpClient {
237
242
  const dispatchId = randomUUID();
238
243
  const startedAt = Date.now();
239
244
  try {
240
- const result = await this.#callTool(
241
- 'stratum_agent_run',
242
- buildAgentRunRequest(agentType, prompt, opts),
243
- callOpts,
244
- );
245
+ const result = await this.#invokeAgentRun(agentType, prompt, opts, callOpts);
245
246
  this.#recordAgentDispatch(
246
247
  dispatchId,
247
248
  agentType,
@@ -266,6 +267,69 @@ export class StratumMcpClient {
266
267
  }
267
268
  }
268
269
 
270
+ async #invokeAgentRun(agentType, prompt, opts, callOpts) {
271
+ const signal = opts.signal;
272
+ const cancellationId = opts.cancellationId ?? (signal ? randomUUID() : undefined);
273
+ const request = buildAgentRunRequest(agentType, prompt, { ...opts, cancellationId });
274
+ signal?.throwIfAborted();
275
+ // Probe under the same deadline as execution. No run has been dispatched yet,
276
+ // so aborting a stalled probe requires no server cancellation acknowledgement.
277
+ if (this.#connected) {
278
+ if (!this.#agentFields) {
279
+ const listed = await abortable(this.#client.listTools(undefined, { signal }), signal);
280
+ const tool = listed.tools.find(entry => entry.name === 'stratum_agent_run');
281
+ this.#agentFields = new Set(Object.keys(tool?.inputSchema?.properties ?? {}));
282
+ }
283
+ const missing = Object.keys(request).filter(key => !this.#agentFields.has(key));
284
+ if (missing.length) {
285
+ const installed = this.#client.getServerVersion()?.version ?? 'unknown';
286
+ throw new StratumError('UNSUPPORTED_AGENT_OPTIONS',
287
+ `Installed Stratum ${installed} does not support ${missing.join(', ')} required by this call; ` +
288
+ 'required execution surface: 17 (@smartmemory/stratum >=0.4.0).', '');
289
+ }
290
+ }
291
+ signal?.throwIfAborted();
292
+ const teardownMs = opts.cancellationTimeoutMs ?? Number(process.env.COMPOSE_CANCEL_TIMEOUT_MS ?? 15000);
293
+ const rpc = this.#callTool('stratum_agent_run', request, callOpts);
294
+ // Keep the original rejection intact, including on a dead transport. Only an
295
+ // explicit abort initiates cancellation; a failed RPC alone must remain retryable.
296
+ const outcome = rpc.then(value => ({ value }), error => ({ error }));
297
+ let cancellation;
298
+ let rejectAbort;
299
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
300
+ const cancel = () => {
301
+ cancellation ??= (async () => {
302
+ let ack;
303
+ try {
304
+ ack = await cancellationDeadline(this.cancelAgentRun(cancellationId), teardownMs, 'Cancellation acknowledgement');
305
+ } catch (error) {
306
+ if (error?.code === 'CANCELLATION_TEARDOWN_TIMEOUT') throw error;
307
+ throw new StratumError('CANCELLATION_UNCONFIRMED', `Foreground cancellation could not be acknowledged: ${error.message}`, '');
308
+ }
309
+ if (!['cancelled', 'already_cancelled', 'already_complete', 'already_error', 'not_found'].includes(ack?.status)) {
310
+ throw new StratumError('CANCELLATION_UNCONFIRMED',
311
+ `Stratum did not confirm foreground termination (${ack?.status ?? 'missing status'})`, '');
312
+ }
313
+ const settled = await cancellationDeadline(outcome, teardownMs, 'Original agent RPC after cancellation acknowledgement');
314
+ const error = new Error('Agent execution cancelled after termination was acknowledged');
315
+ error.name = 'AbortError';
316
+ for (const key of ['usage', 'telemetry', 'split', 'usdSource']) {
317
+ if ((settled.error ?? settled.value)?.[key] !== undefined) error[key] = (settled.error ?? settled.value)[key];
318
+ }
319
+ throw error;
320
+ })();
321
+ cancellation.catch(rejectAbort);
322
+ };
323
+ signal?.addEventListener('abort', cancel, { once: true });
324
+ if (signal?.aborted) cancel();
325
+ try {
326
+ const settled = await Promise.race([outcome, aborted]);
327
+ if (cancellation) await cancellation;
328
+ if (settled.error) throw settled.error;
329
+ return settled.value;
330
+ } finally { signal?.removeEventListener('abort', cancel); }
331
+ }
332
+
269
333
  /**
270
334
  * Subscribe to BuildStreamEvent push notifications scoped to a (flowId, stepId).
271
335
  * Handler receives a parsed BuildStreamEvent envelope. Returns an unsubscribe fn.
@@ -385,7 +449,15 @@ export class StratumMcpClient {
385
449
  // agent step failed with `403 forbidden / "Request not allowed"`. Pass the
386
450
  // full env through; the stratum connector scrubs SENSITIVE_ENV_VARS
387
451
  // (API keys, CLAUDECODE) before it spawns the agent.
388
- transportOpts.env = opts.env ?? { ...process.env };
452
+ // GOV-COMPOSE-SEAM-1 step 0: hand Stratum the SmartMemory coordinates from
453
+ // `.compose/compose.json` so its enforcement events and Compose's ingest
454
+ // events land in one workspace. Resolved from config, not inherited from the
455
+ // ambient shell, and `{}` when the coupling is off — so a workspace without
456
+ // the `smartmemory` block spawns a byte-identical env to before this change.
457
+ // Must be in the SPAWN env: Stratum's policy client reads process.env once,
458
+ // at construction.
459
+ const policyEnv = resolveStratumPolicyEnv(opts.cwd ?? defaults.cwd ?? process.cwd());
460
+ transportOpts.env = { ...(opts.env ?? { ...process.env }), ...policyEnv };
389
461
  this.#transport = new StdioClientTransport(transportOpts);
390
462
 
391
463
  this.#client = new Client(
@@ -395,6 +467,8 @@ export class StratumMcpClient {
395
467
 
396
468
  await this.#client.connect(this.#transport);
397
469
  this.#connected = true;
470
+ this.#toolNames = null;
471
+ this.#agentFields = null;
398
472
  }
399
473
 
400
474
  /** Kill subprocess and clean up. */
@@ -408,6 +482,28 @@ export class StratumMcpClient {
408
482
  this.#client = null;
409
483
  this.#transport = null;
410
484
  this.#connected = false;
485
+ this.#toolNames = null;
486
+ this.#agentFields = null;
487
+ }
488
+
489
+ /** Feature-detect an MCP tool once per connection. Listing failures mean absent. */
490
+ async hasTool(name) {
491
+ if (this.#toolNames) return this.#toolNames.has(name);
492
+ const client = (process.env.NODE_ENV === 'test' && this._testClient) || this.#client;
493
+ if (!client || (!this.#connected && !(process.env.NODE_ENV === 'test' && this._testClient))) {
494
+ return false;
495
+ }
496
+ try {
497
+ const listed = await client.listTools();
498
+ this.#toolNames = new Set(
499
+ (Array.isArray(listed?.tools) ? listed.tools : [])
500
+ .map((tool) => tool?.name)
501
+ .filter((toolName) => typeof toolName === 'string'),
502
+ );
503
+ return this.#toolNames.has(name);
504
+ } catch {
505
+ return false;
506
+ }
411
507
  }
412
508
 
413
509
  /**
@@ -437,7 +533,19 @@ export class StratumMcpClient {
437
533
  requestOpts.maxTotalTimeout = 24 * 60 * 60 * 1000; // 24h hard cap
438
534
  }
439
535
 
440
- const result = await (client ?? this.#client).callTool(callArgs, undefined, requestOpts);
536
+ const result = await (client ?? this.#client).callTool(callArgs, undefined, requestOpts).catch(error => {
537
+ if (error?.data && typeof error.data === 'object') {
538
+ // C11: `error.code` arrives as the JSON-RPC numeric code and callers
539
+ // then overwrite it with the engine's string code. Keep the numeric one
540
+ // reachable instead of destroying it.
541
+ if (error.data.code !== undefined && typeof error.code === 'number') error.rpcCode = error.code;
542
+ for (const key of ['code', 'usage', 'split', 'usdSource', 'stderr', 'telemetry']) {
543
+ if (error.data[key] !== undefined) error[key] = error.data[key];
544
+ }
545
+ if (error.usage) error.usage = failureUsage(error);
546
+ }
547
+ throw error;
548
+ });
441
549
 
442
550
  // TS stratum returns the payload as native MCP structured content and also
443
551
  // mirrors it as JSON text. Prefer the native object, retaining text parsing
@@ -479,11 +587,12 @@ export class StratumMcpClient {
479
587
  // Check for Stratum error envelope
480
588
  if (parsed.status === 'error' || parsed.error) {
481
589
  const err = parsed.error ?? parsed;
482
- throw new StratumError(
483
- err.code ?? 'STRATUM_ERROR',
484
- err.message ?? 'Stratum tool call failed',
485
- err.detail ?? ''
486
- );
590
+ const error = new StratumError(err.code ?? 'STRATUM_ERROR', err.message ?? 'Stratum tool call failed', err.detail ?? '');
591
+ for (const key of ['usage', 'split', 'usdSource', 'stderr', 'telemetry']) {
592
+ if ((parsed[key] ?? err[key]) !== undefined) error[key] = parsed[key] ?? err[key];
593
+ }
594
+ if (error.usage) error.usage = failureUsage(error);
595
+ throw error;
487
596
  }
488
597
 
489
598
  return parsed;
@@ -510,6 +619,15 @@ export class StratumMcpClient {
510
619
  ...(typeof opts.workspaceRoot === 'string' && opts.workspaceRoot.length > 0
511
620
  ? { workspaceRoot: opts.workspaceRoot }
512
621
  : {}),
622
+ // GOV-COMPOSE-SEAM-1: the engine arg is `policy_step_selector`, NOT
623
+ // `step_selector` — the latter is the field inside a bundle rule, and a
624
+ // caller selector can only narrow a rule's own selector (intersection),
625
+ // never widen it. Nothing passes these yet; `consume-bundle` (step 2)
626
+ // is the consumer, and is gated on the canon step landing first.
627
+ ...(opts.policyBundle !== undefined ? { policy_bundle: opts.policyBundle } : {}),
628
+ ...(typeof opts.policyStepSelector === 'string' && opts.policyStepSelector.length > 0
629
+ ? { policy_step_selector: opts.policyStepSelector }
630
+ : {}),
513
631
  });
514
632
  }
515
633
 
@@ -541,6 +659,10 @@ export class StratumMcpClient {
541
659
  });
542
660
  }
543
661
 
662
+ async usageReport(runId, receipt) {
663
+ return this.#callTool('stratum_usage_report', { runId, receipt });
664
+ }
665
+
544
666
  /**
545
667
  * Resolve a gate step.
546
668
  * @param {string} flowId
@@ -550,6 +672,25 @@ export class StratumMcpClient {
550
672
  * @param {'human'|'agent'|'system'} resolvedBy
551
673
  * @param {string} [gateToken] - Audit-discovered waiting-gate issuance token to echo
552
674
  * @returns {Promise<object>}
675
+ *
676
+ * GOV-COMPOSE-SEAM-1 step 0 — why `rationale` and `resolvedBy` are accepted
677
+ * but deliberately NOT transmitted (they are used by this repo's own callers):
678
+ *
679
+ * - `rationale`: `stratum_gate_resolve` has no rationale parameter and the
680
+ * `gate_resolution` policy event has no field for it (engine.ts
681
+ * buildGateResolutionEvent). Sending it would be dropped on the floor.
682
+ * Carrying gate rationale into the audit chain needs a Stratum contract
683
+ * change; it is not step-0 plumbing.
684
+ * - `resolvedBy` is a ROLE (`human`/`agent`/`system`), not an identity, and
685
+ * Compose has no user identity at this call site at all. The engine's
686
+ * `user_id` populates `resolved_by_user_id`, an audit field that is
687
+ * supposed to name a person. Passing "human" into it would manufacture
688
+ * false provenance, which is worse than leaving it absent. Threading a
689
+ * real identity belongs with roles (Stratum P3 / GOV-ROLES-1).
690
+ *
691
+ * Note also that `gate_resolution` only fires when the run carries a
692
+ * `bundle_id`, i.e. only once a policy bundle is passed at plan time. Until
693
+ * `consume-bundle` lands there is no gate policy event to enrich either way.
553
694
  */
554
695
  async gateResolve(flowId, stepId, outcome, rationale, resolvedBy = 'human', gateToken) {
555
696
  return this.#callTool('stratum_gate_resolve', {
@@ -628,12 +769,8 @@ export class StratumMcpClient {
628
769
  */
629
770
  async agentRun(agentType, prompt, opts = {}) {
630
771
  const correlationId = opts.correlationId ?? randomUUID();
631
- // TS surface (contracts/mcp-surface.json stratum_agent_run): the request
632
- // is {agent, prompt, cwd, model?, sandboxMode?, background?} and rejects any
633
- // undeclared key. The python-era tier accepted {type, allowed_tools, ...};
634
- // those knobs are resolved compose-side (resolveAgentConfig) and applied at
635
- // this seam — the engine's connector honors model + sandboxMode. `cwd` is
636
- // required, so default to the process cwd when the caller omits it.
772
+ // The AbortSignal stays local; a cancellation ID crosses MCP so the caller
773
+ // waits for remote termination, unlike MCP's immediately rejecting abort.
637
774
  return this.#dispatchAgentRun(
638
775
  agentType,
639
776
  prompt,
@@ -653,22 +790,87 @@ export class StratumMcpClient {
653
790
  * @returns {Promise<string>}
654
791
  */
655
792
  async runAgentText(agentType, prompt, opts = {}) {
656
- const result = await this.#dispatchAgentRun(
657
- agentType,
658
- prompt,
659
- opts,
660
- { subscribeProgress: false },
661
- );
793
+ const toUsageRecords = (value) => {
794
+ const usage = value?.usage;
795
+ if (!usage || typeof usage !== 'object') return [];
796
+ const numeric = [
797
+ usage.tokens, usage.input_tokens, usage.output_tokens,
798
+ usage.cache_read, usage.cache_read_input_tokens,
799
+ usage.cache_creation, usage.cache_creation_input_tokens,
800
+ usage.cost_usd, usage.usd, usage.ms, usage.duration_ms,
801
+ ].some((item) => typeof item === 'number' && Number.isFinite(item) && item !== 0);
802
+ if (!numeric) return [];
803
+ const telemetry = value?.telemetry && typeof value.telemetry === 'object'
804
+ ? value.telemetry
805
+ : {};
806
+ // STRAT-USAGE-SPLIT: connectors report the true input/output detail
807
+ // BESIDE the Budget-shaped usage (same seam as usdSource). Prefer it —
808
+ // the legacy reconstruction below cannot tell input from output and
809
+ // filed the whole aggregate as output for every record ever written.
810
+ const split = value?.split && typeof value.split === 'object' ? value.split : null;
811
+ const input = split?.input ?? usage.input_tokens ?? 0;
812
+ const output = split?.output
813
+ ?? usage.output_tokens
814
+ ?? (typeof usage.tokens === 'number' ? Math.max(0, usage.tokens - input) : 0);
815
+ const record = {
816
+ dispatch_id: value?.dispatchId ?? randomUUID(),
817
+ model: telemetry.model ?? usage.model ?? opts.modelID ?? 'unknown',
818
+ ...(telemetry.effort ?? opts.effort ? { effort: telemetry.effort ?? opts.effort } : {}),
819
+ duration_ms: telemetry.durationMs ?? usage.duration_ms ?? usage.ms ?? 0,
820
+ input_tokens: input,
821
+ output_tokens: output,
822
+ ...(typeof (split?.cacheRead ?? usage.cache_read ?? usage.cache_read_input_tokens) === 'number'
823
+ ? { cache_read: split?.cacheRead ?? usage.cache_read ?? usage.cache_read_input_tokens }
824
+ : {}),
825
+ ...(typeof (split?.cacheCreation ?? usage.cache_creation ?? usage.cache_creation_input_tokens) === 'number'
826
+ ? { cache_creation: split?.cacheCreation ?? usage.cache_creation ?? usage.cache_creation_input_tokens }
827
+ : {}),
828
+ };
829
+ const cost = usage.cost_usd ?? usage.usd;
830
+ // Engine connectors label provenance as `usdSource` (camel, ConnectorUsage);
831
+ // compose-native usage records use `usd_source`. Either is acceptable;
832
+ // an unlabelled usd is still dropped (fail closed).
833
+ // The engine's ledger only admits budget keys inside `usage`, so the
834
+ // connector reports provenance BESIDE it (ConnectorResult.usdSource).
835
+ const rawUsdSource = usage.usd_source ?? value?.usdSource ?? usage.usdSource;
836
+ const usdSource = ['reported', 'estimated'].includes(rawUsdSource)
837
+ ? rawUsdSource
838
+ : null;
839
+ if (typeof cost === 'number' && Number.isFinite(cost) && cost > 0 && usdSource) {
840
+ record.cost_usd = cost;
841
+ record.usd_source = usdSource;
842
+ }
843
+ return [record];
844
+ };
845
+
846
+ const reportUsage = async (value) => {
847
+ if (typeof opts.onUsage !== 'function') return;
848
+ try {
849
+ await opts.onUsage(toUsageRecords(value));
850
+ } catch (error) {
851
+ console.warn(`[stratum-agent] onUsage hook failed: ${error?.message ?? error}`);
852
+ }
853
+ };
854
+
855
+ let result;
856
+ try {
857
+ result = await this.#dispatchAgentRun(
858
+ agentType,
859
+ prompt,
860
+ opts,
861
+ { subscribeProgress: false },
862
+ );
863
+ } catch (error) {
864
+ if (error?.usage) await reportUsage(error);
865
+ throw error;
866
+ }
867
+ await reportUsage(result);
662
868
  return result?.text ?? '';
663
869
  }
664
870
 
665
871
  /**
666
- * Cancel an in-flight agent run. V2: the TS surface
667
- * (contracts/mcp-surface.json stratum_cancel_agent_run) takes {runId}, not
668
- * the python-era {correlation_id} the engine now rejects. Only BACKGROUND runs
669
- * carry a runId to cancel; a synchronous agent_run returns no handle, so this
670
- * is a no-op (not_found) there — synchronous controlled executions are
671
- * interrupted via the local connector's AbortController instead.
872
+ * Cancel a background runId or a foreground cancellationId. A successful
873
+ * acknowledgement means connector cleanup has finished.
672
874
  *
673
875
  * @param {string} runId
674
876
  * @returns {Promise<object>}
@@ -677,3 +879,36 @@ export class StratumMcpClient {
677
879
  return this.#callTool('stratum_cancel_agent_run', { runId });
678
880
  }
679
881
  }
882
+
883
+ function failureUsage(error) {
884
+ const u = error.usage;
885
+ return { ...u,
886
+ ...(error.telemetry?.model ? { model: error.telemetry.model } : {}),
887
+ ...(error.split ? { input_tokens: error.split.input, output_tokens: error.split.output,
888
+ cache_read_input_tokens: error.split.cacheRead ?? 0, cache_creation_input_tokens: error.split.cacheCreation ?? 0 } : {}),
889
+ ...(u.usd !== undefined ? { cost_usd: u.usd } : {}),
890
+ ...(u.ms !== undefined ? { duration_ms: u.ms } : {}),
891
+ ...(error.usdSource ? { usd_source: error.usdSource } : {}),
892
+ };
893
+ }
894
+
895
+ async function abortable(operation, signal) {
896
+ if (!signal) return operation;
897
+ let abort;
898
+ try {
899
+ return await Promise.race([operation, new Promise((_, reject) => {
900
+ abort = () => reject(signal.reason ?? Object.assign(new Error('Aborted'), { name: 'AbortError' }));
901
+ signal.addEventListener('abort', abort, { once: true });
902
+ if (signal.aborted) abort();
903
+ })]);
904
+ } finally { signal.removeEventListener('abort', abort); }
905
+ }
906
+
907
+ async function cancellationDeadline(operation, ms, label) {
908
+ let timer;
909
+ try {
910
+ return await Promise.race([operation, new Promise((_, reject) => {
911
+ timer = setTimeout(() => reject(new StratumError('CANCELLATION_TEARDOWN_TIMEOUT', `${label} did not settle after ${ms}ms`, '')), ms);
912
+ })]);
913
+ } finally { clearTimeout(timer); }
914
+ }
@@ -562,3 +562,34 @@ export function deriveTestsPass(summary) {
562
562
  if (!summary || !summary.parsed) return true;
563
563
  return summary.test_count >= 1 && summary.pass_rate === 100;
564
564
  }
565
+
566
+ /**
567
+ * COMP-COMPLETION-GATE: the attestation the completion gate consumes.
568
+ *
569
+ * This is `deriveTestsPass` with its safety valve removed, as a TRI-state:
570
+ *
571
+ * 'passed' — the output parsed, at least one test ran, all passed
572
+ * 'failed' — the output parsed and something failed (or nothing ran)
573
+ * 'no-signal' — the output could not be read at all
574
+ *
575
+ * The distinction that matters is the third one. `deriveTestsPass` collapses
576
+ * "unreadable" into `true`, so a framework the parser does not understand
577
+ * (`ava`, `tap`, anything unknown) silently attests a passing test run that was
578
+ * never observed. That value then goes onto a permanent completion record and,
579
+ * under the gate, into a tamper-evident ledger — which is exactly the kind of
580
+ * claim the ledger exists to make trustworthy.
581
+ *
582
+ * ABSENCE OF SIGNAL IS NEVER ATTESTATION. The gate refuses 'no-signal' and tells
583
+ * the operator to configure `guard.testCommand`, whose real exit code can attest
584
+ * where the parser cannot.
585
+ *
586
+ * `deriveTestsPass` is retained for the ungated ship-step path and its existing
587
+ * degrade contract; it is not the gate's input.
588
+ *
589
+ * @param {{ test_count: number, pass_rate: number, parsed: boolean }|null} summary
590
+ * @returns {'passed'|'failed'|'no-signal'}
591
+ */
592
+ export function deriveTestsAttested(summary) {
593
+ if (!summary || !summary.parsed) return 'no-signal';
594
+ return (summary.test_count >= 1 && summary.pass_rate === 100) ? 'passed' : 'failed';
595
+ }