@smartmemory/compose 0.3.6-beta → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
@@ -19,10 +19,50 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
19
19
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
20
20
  import YAML from 'yaml';
21
21
  import { validateBuildStreamEvent } from './build-stream-schema.js';
22
+ import { appendEvent, resolveDispatchLedgerCwd } from './dispatch-ledger.js';
22
23
  import { resolveStratumMcpConnection } from './stratum-engine.js';
24
+ import { resolveStratumPolicyEnv } from './smartmemory-config.js';
23
25
 
24
26
  const RUNTIME_INPUT_REF = /^\$\.input\.([A-Za-z_][A-Za-z0-9_]*)$/;
25
27
 
28
+ function finiteOrNull(value) {
29
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
30
+ }
31
+
32
+ function nonemptyStringOrNull(value) {
33
+ return typeof value === 'string' && value.length > 0 ? value : null;
34
+ }
35
+
36
+ function isBlockedDispatch(value) {
37
+ const candidates = [
38
+ value?.status,
39
+ value?.outcome,
40
+ value?.code,
41
+ value?.error?.status,
42
+ value?.error?.code,
43
+ ];
44
+ return candidates.some((candidate) => {
45
+ const normalized = typeof candidate === 'string' ? candidate.toLowerCase() : '';
46
+ return normalized === 'blocked'
47
+ || normalized === 'budget_exhausted'
48
+ || normalized === 'budget-exhausted';
49
+ });
50
+ }
51
+
52
+ function attachDispatchId(value, dispatchId) {
53
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return;
54
+ try {
55
+ Object.defineProperty(value, 'dispatchId', {
56
+ configurable: true,
57
+ enumerable: false,
58
+ value: dispatchId,
59
+ });
60
+ } catch {
61
+ // A frozen third-party value cannot carry metadata. Dispatch behavior still
62
+ // wins over observability.
63
+ }
64
+ }
65
+
26
66
  /**
27
67
  * Resolve producer-owned authoring values that TS v1 deliberately requires to
28
68
  * be literals. Compose knows these values at plan time, so mutate a cloned spec
@@ -113,12 +153,9 @@ export function resolveStepProfile(profiles, stepId) {
113
153
 
114
154
  /**
115
155
  * Build the TS `stratum_agent_run` request from an agent string + compose-side
116
- * options. The engine surface (contracts/mcp-surface.json) accepts only
117
- * {agent, prompt, cwd, model?, sandboxMode?, background?} and rejects any other
118
- * key, so the python-era knobs (allowed_tools/thinking/effort/correlation_id)
119
- * are NOT sent — they are compose-side concerns applied at the invocation
120
- * (resolveAgentConfig). `agent` is normalized to the bare provider literal the
121
- * 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.
122
159
  */
123
160
  export function buildAgentRunRequest(agentType, prompt, opts = {}) {
124
161
  const provider = String(agentType ?? 'claude').split(':', 1)[0] || 'claude';
@@ -128,6 +165,11 @@ export function buildAgentRunRequest(agentType, prompt, opts = {}) {
128
165
  cwd: opts.cwd ?? process.cwd(),
129
166
  ...(opts.modelID ? { model: opts.modelID } : {}),
130
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 } : {}),
131
173
  };
132
174
  }
133
175
 
@@ -144,9 +186,150 @@ export class StratumMcpClient {
144
186
  #client = null;
145
187
  #transport = null;
146
188
  #connected = false;
189
+ #toolNames = null;
190
+ #agentFields = null;
147
191
  // STRAT-PAR-STREAM: subscribers keyed by `${flowId}::${stepId}` → Set<handler>
148
192
  #eventSubs = new Map();
149
193
 
194
+ #recordAgentDispatch(dispatchId, agentType, opts, resultOrError, outcome, elapsedMs) {
195
+ try {
196
+ const context = opts.telemetry && typeof opts.telemetry === 'object'
197
+ ? opts.telemetry
198
+ : {};
199
+ const returnedTelemetry = resultOrError?.telemetry
200
+ && typeof resultOrError.telemetry === 'object'
201
+ ? resultOrError.telemetry
202
+ : {};
203
+ const usage = resultOrError?.usage && typeof resultOrError.usage === 'object'
204
+ ? resultOrError.usage
205
+ : {};
206
+ const effortIntended = Object.hasOwn(context, 'effort_intended')
207
+ ? nonemptyStringOrNull(context.effort_intended)
208
+ : nonemptyStringOrNull(opts.effort);
209
+ const event = {
210
+ kind: 'dispatch',
211
+ dispatch_id: dispatchId,
212
+ site: nonemptyStringOrNull(context.site) ?? 'unattributed',
213
+ agent: String(agentType ?? 'claude').split(':', 1)[0] || 'claude',
214
+ outcome,
215
+ model: nonemptyStringOrNull(returnedTelemetry.model)
216
+ ?? nonemptyStringOrNull(usage.model),
217
+ effort_intended: effortIntended,
218
+ effort_executed: nonemptyStringOrNull(returnedTelemetry.effort),
219
+ tokens_in: null,
220
+ tokens_out: null,
221
+ tokens_total: finiteOrNull(usage.tokens),
222
+ usd: finiteOrNull(usage.usd),
223
+ duration_ms: finiteOrNull(usage.ms)
224
+ ?? finiteOrNull(returnedTelemetry.durationMs)
225
+ ?? finiteOrNull(elapsedMs),
226
+ };
227
+ for (const [field, value] of [
228
+ ['build_id', context.build_id],
229
+ ['feature_code', context.feature_code],
230
+ ['step_id', context.step_id],
231
+ ]) {
232
+ if (nonemptyStringOrNull(value) !== null) event[field] = value;
233
+ }
234
+ if (finiteOrNull(context.attempt) !== null) event.attempt = context.attempt;
235
+ appendEvent(resolveDispatchLedgerCwd(context.project_cwd), event);
236
+ } catch {
237
+ // Dispatch capture is fail-open by contract.
238
+ }
239
+ }
240
+
241
+ async #dispatchAgentRun(agentType, prompt, opts, callOpts) {
242
+ const dispatchId = randomUUID();
243
+ const startedAt = Date.now();
244
+ try {
245
+ const result = await this.#invokeAgentRun(agentType, prompt, opts, callOpts);
246
+ this.#recordAgentDispatch(
247
+ dispatchId,
248
+ agentType,
249
+ opts,
250
+ result,
251
+ isBlockedDispatch(result) ? 'blocked' : 'ok',
252
+ Date.now() - startedAt,
253
+ );
254
+ attachDispatchId(result, dispatchId);
255
+ return result;
256
+ } catch (error) {
257
+ this.#recordAgentDispatch(
258
+ dispatchId,
259
+ agentType,
260
+ opts,
261
+ error,
262
+ isBlockedDispatch(error) ? 'blocked' : 'error',
263
+ Date.now() - startedAt,
264
+ );
265
+ attachDispatchId(error, dispatchId);
266
+ throw error;
267
+ }
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
+
150
333
  /**
151
334
  * Subscribe to BuildStreamEvent push notifications scoped to a (flowId, stepId).
152
335
  * Handler receives a parsed BuildStreamEvent envelope. Returns an unsubscribe fn.
@@ -266,7 +449,15 @@ export class StratumMcpClient {
266
449
  // agent step failed with `403 forbidden / "Request not allowed"`. Pass the
267
450
  // full env through; the stratum connector scrubs SENSITIVE_ENV_VARS
268
451
  // (API keys, CLAUDECODE) before it spawns the agent.
269
- 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 };
270
461
  this.#transport = new StdioClientTransport(transportOpts);
271
462
 
272
463
  this.#client = new Client(
@@ -276,6 +467,8 @@ export class StratumMcpClient {
276
467
 
277
468
  await this.#client.connect(this.#transport);
278
469
  this.#connected = true;
470
+ this.#toolNames = null;
471
+ this.#agentFields = null;
279
472
  }
280
473
 
281
474
  /** Kill subprocess and clean up. */
@@ -289,6 +482,28 @@ export class StratumMcpClient {
289
482
  this.#client = null;
290
483
  this.#transport = null;
291
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
+ }
292
507
  }
293
508
 
294
509
  /**
@@ -318,7 +533,19 @@ export class StratumMcpClient {
318
533
  requestOpts.maxTotalTimeout = 24 * 60 * 60 * 1000; // 24h hard cap
319
534
  }
320
535
 
321
- 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
+ });
322
549
 
323
550
  // TS stratum returns the payload as native MCP structured content and also
324
551
  // mirrors it as JSON text. Prefer the native object, retaining text parsing
@@ -360,11 +587,12 @@ export class StratumMcpClient {
360
587
  // Check for Stratum error envelope
361
588
  if (parsed.status === 'error' || parsed.error) {
362
589
  const err = parsed.error ?? parsed;
363
- throw new StratumError(
364
- err.code ?? 'STRATUM_ERROR',
365
- err.message ?? 'Stratum tool call failed',
366
- err.detail ?? ''
367
- );
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;
368
596
  }
369
597
 
370
598
  return parsed;
@@ -391,6 +619,15 @@ export class StratumMcpClient {
391
619
  ...(typeof opts.workspaceRoot === 'string' && opts.workspaceRoot.length > 0
392
620
  ? { workspaceRoot: opts.workspaceRoot }
393
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
+ : {}),
394
631
  });
395
632
  }
396
633
 
@@ -422,6 +659,10 @@ export class StratumMcpClient {
422
659
  });
423
660
  }
424
661
 
662
+ async usageReport(runId, receipt) {
663
+ return this.#callTool('stratum_usage_report', { runId, receipt });
664
+ }
665
+
425
666
  /**
426
667
  * Resolve a gate step.
427
668
  * @param {string} flowId
@@ -431,6 +672,25 @@ export class StratumMcpClient {
431
672
  * @param {'human'|'agent'|'system'} resolvedBy
432
673
  * @param {string} [gateToken] - Audit-discovered waiting-gate issuance token to echo
433
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.
434
694
  */
435
695
  async gateResolve(flowId, stepId, outcome, rationale, resolvedBy = 'human', gateToken) {
436
696
  return this.#callTool('stratum_gate_resolve', {
@@ -509,15 +769,14 @@ export class StratumMcpClient {
509
769
  */
510
770
  async agentRun(agentType, prompt, opts = {}) {
511
771
  const correlationId = opts.correlationId ?? randomUUID();
512
- // TS surface (contracts/mcp-surface.json stratum_agent_run): the request
513
- // is {agent, prompt, cwd, model?, sandboxMode?, background?} and rejects any
514
- // undeclared key. The python-era tier accepted {type, allowed_tools, ...};
515
- // those knobs are resolved compose-side (resolveAgentConfig) and applied at
516
- // this seam — the engine's connector honors model + sandboxMode. `cwd` is
517
- // required, so default to the process cwd when the caller omits it.
518
- return this.#callTool('stratum_agent_run',
519
- buildAgentRunRequest(agentType, prompt, opts),
520
- { subscribeProgress: true, correlationId });
772
+ // The AbortSignal stays local; a cancellation ID crosses MCP so the caller
773
+ // waits for remote termination, unlike MCP's immediately rejecting abort.
774
+ return this.#dispatchAgentRun(
775
+ agentType,
776
+ prompt,
777
+ opts,
778
+ { subscribeProgress: true, correlationId },
779
+ );
521
780
  }
522
781
 
523
782
  /**
@@ -531,19 +790,87 @@ export class StratumMcpClient {
531
790
  * @returns {Promise<string>}
532
791
  */
533
792
  async runAgentText(agentType, prompt, opts = {}) {
534
- const result = await this.#callTool('stratum_agent_run',
535
- buildAgentRunRequest(agentType, prompt, opts),
536
- { subscribeProgress: false });
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);
537
868
  return result?.text ?? '';
538
869
  }
539
870
 
540
871
  /**
541
- * Cancel an in-flight agent run. V2: the TS surface
542
- * (contracts/mcp-surface.json stratum_cancel_agent_run) takes {runId}, not
543
- * the python-era {correlation_id} the engine now rejects. Only BACKGROUND runs
544
- * carry a runId to cancel; a synchronous agent_run returns no handle, so this
545
- * is a no-op (not_found) there — synchronous controlled executions are
546
- * 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.
547
874
  *
548
875
  * @param {string} runId
549
876
  * @returns {Promise<object>}
@@ -552,3 +879,36 @@ export class StratumMcpClient {
552
879
  return this.#callTool('stratum_cancel_agent_run', { runId });
553
880
  }
554
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
+ }