@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
@@ -0,0 +1,301 @@
1
+ /**
2
+ * dispatch-ledger.js — closed, append-only telemetry events for agent dispatch.
3
+ *
4
+ * Ledger rows are addressed by an explicit project root so callers running in a
5
+ * worktree cannot accidentally split telemetry away from the build project.
6
+ */
7
+
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join, resolve } from 'node:path';
11
+
12
+ import { normalizeSince } from './feature-events.js';
13
+
14
+ export const DISPATCH_LEDGER_RELATIVE_PATH = '.compose/data/dispatch-ledger.jsonl';
15
+
16
+ /**
17
+ * Resolve the project root a dispatch connector should address its ledger by.
18
+ *
19
+ * Two paths write telemetry into the live compose checkout during a test run:
20
+ * an unattributed dispatch with no project_cwd falls back to process.cwd(), and
21
+ * a test that explicitly roots a build at process.cwd() threads that same live
22
+ * path through. Under NODE_TEST_CONTEXT, any ledger write addressed at the
23
+ * process cwd (the live checkout) is redirected to a hermetic tmpdir so
24
+ * full-suite runs never accrete fixture rows in the real .compose/data ledger.
25
+ * A test that threads its own tmpdir project root is addressed there, unchanged.
26
+ * Production paths never set NODE_TEST_CONTEXT, so they are unaffected.
27
+ *
28
+ * @param {string} [explicitCwd] the caller's project_cwd, when known
29
+ * @returns {string}
30
+ */
31
+ export function resolveDispatchLedgerCwd(explicitCwd) {
32
+ const cwd = isString(explicitCwd) ? explicitCwd : process.cwd();
33
+ if (process.env.NODE_TEST_CONTEXT && resolve(cwd) === resolve(process.cwd())) {
34
+ return join(tmpdir(), 'compose-test-worktrees', 'dispatch-ledger');
35
+ }
36
+ return cwd;
37
+ }
38
+
39
+ export const DISPATCH_SITES = Object.freeze([
40
+ 'build-step', 'consumer', 'review', 'review-repair', 'policy-revision', 'gsd',
41
+ 'gate-qa', 'escalation', 'preflight', 'judge', 'design-chat', 'import',
42
+ 'validation', 'new-project', 'unattributed',
43
+ ]);
44
+ export const DISPATCH_OUTCOMES = Object.freeze(['ok', 'error', 'blocked']);
45
+ export const SETTLEMENT_FAILURE_CLASSES = Object.freeze([
46
+ 'ownership', 'vocabulary', 'normalization', 'agent', 'ensure-retry',
47
+ ]);
48
+ export const ESTIMATE_LANES = Object.freeze(['trivial', 'standard', 'complex']);
49
+ export const ESTIMATE_SOURCES = Object.freeze(['fresh', 'cached', 'escalated']);
50
+ export const TRIAGE_CONFIDENCES = Object.freeze(['high', 'medium', 'low']);
51
+ export const BUILD_TERMINAL_STATUSES = Object.freeze(['complete', 'failed', 'aborted']);
52
+ export const BUILD_FILES_SOURCES = Object.freeze(['ship', 'accumulated']);
53
+
54
+ const EVENT_FIELDS = {
55
+ dispatch: {
56
+ required: ['dispatch_id', 'site', 'agent', 'outcome'],
57
+ optional: [
58
+ 'build_id', 'feature_code', 'step_id', 'attempt', 'model',
59
+ 'effort_intended', 'effort_executed', 'tokens_in', 'tokens_out',
60
+ 'tokens_total', 'usd', 'duration_ms', 'note',
61
+ ],
62
+ },
63
+ settlement: {
64
+ required: ['dispatch_id', 'accepted'],
65
+ optional: ['build_id', 'step_id', 'failure_class'],
66
+ },
67
+ 'triage-estimate': {
68
+ required: ['build_id', 'feature_code', 'tier', 'lane', 'profile', 'estimate_source'],
69
+ optional: ['confidence'],
70
+ },
71
+ 'build-actuals': {
72
+ required: [
73
+ 'build_id', 'feature_code', 'terminal_status', 'files_changed_count',
74
+ 'files_source', 'review_iterations', 'escalations', 'tokens_total', 'usd',
75
+ ],
76
+ optional: ['test_count', 'pass_rate'],
77
+ },
78
+ };
79
+
80
+ const countersByPath = new Map();
81
+
82
+ function ledgerPath(projectCwd) {
83
+ return resolve(projectCwd, DISPATCH_LEDGER_RELATIVE_PATH);
84
+ }
85
+
86
+ function isPlainObject(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
88
+ }
89
+
90
+ function isString(value) {
91
+ return typeof value === 'string' && value.length > 0;
92
+ }
93
+
94
+ function isFiniteNumber(value) {
95
+ return typeof value === 'number' && Number.isFinite(value);
96
+ }
97
+
98
+ function isNullableFiniteNumber(value) {
99
+ return value === null || isFiniteNumber(value);
100
+ }
101
+
102
+ function isNullableString(value) {
103
+ return value === null || isString(value);
104
+ }
105
+
106
+ function isEnum(value, values) {
107
+ return values.includes(value);
108
+ }
109
+
110
+ function invalid(message) {
111
+ throw new Error(`dispatch-ledger.appendEvent: ${message}`);
112
+ }
113
+
114
+ function validateEvent(event, { allowExtra = false } = {}) {
115
+ if (!isPlainObject(event)) invalid('event must be an object');
116
+ if (!isString(event.kind)) invalid('kind is required');
117
+ const shape = EVENT_FIELDS[event.kind];
118
+ if (!shape) invalid(`unknown kind "${event.kind}"`);
119
+
120
+ const allowed = new Set(['kind', ...shape.required, ...shape.optional]);
121
+ if (!allowExtra) {
122
+ for (const field of Object.keys(event)) {
123
+ if (!allowed.has(field)) invalid(`unknown field "${field}" for ${event.kind}`);
124
+ }
125
+ }
126
+ for (const field of shape.required) {
127
+ if (!Object.hasOwn(event, field) || event[field] === undefined) {
128
+ invalid(`required field "${field}" is missing for ${event.kind}`);
129
+ }
130
+ }
131
+
132
+ switch (event.kind) {
133
+ case 'dispatch':
134
+ requireString(event, 'dispatch_id');
135
+ requireEnum(event, 'site', DISPATCH_SITES);
136
+ requireString(event, 'agent');
137
+ requireEnum(event, 'outcome', DISPATCH_OUTCOMES);
138
+ optionalString(event, 'build_id');
139
+ optionalString(event, 'feature_code');
140
+ optionalString(event, 'step_id');
141
+ optionalNumber(event, 'attempt');
142
+ optionalNullableString(event, 'model');
143
+ optionalNullableString(event, 'effort_intended');
144
+ optionalNullableString(event, 'effort_executed');
145
+ for (const field of ['tokens_in', 'tokens_out', 'tokens_total', 'usd', 'duration_ms']) {
146
+ optionalNullableNumber(event, field);
147
+ }
148
+ optionalString(event, 'note');
149
+ break;
150
+ case 'settlement':
151
+ requireString(event, 'dispatch_id');
152
+ if (typeof event.accepted !== 'boolean') invalid('accepted must be a boolean');
153
+ optionalString(event, 'build_id');
154
+ optionalString(event, 'step_id');
155
+ optionalEnum(event, 'failure_class', SETTLEMENT_FAILURE_CLASSES);
156
+ break;
157
+ case 'triage-estimate':
158
+ requireString(event, 'build_id');
159
+ requireString(event, 'feature_code');
160
+ if (!Number.isInteger(event.tier) || event.tier < 0 || event.tier > 4) {
161
+ invalid('tier must be an integer 0-4');
162
+ }
163
+ requireEnum(event, 'lane', ESTIMATE_LANES);
164
+ if (!isPlainObject(event.profile)) invalid('profile must be a non-array object');
165
+ requireEnum(event, 'estimate_source', ESTIMATE_SOURCES);
166
+ if (Object.hasOwn(event, 'confidence') && event.confidence !== null && !isEnum(event.confidence, TRIAGE_CONFIDENCES)) {
167
+ invalid(`confidence must be one of ${TRIAGE_CONFIDENCES.join(', ')} or null`);
168
+ }
169
+ break;
170
+ case 'build-actuals':
171
+ requireString(event, 'build_id');
172
+ requireString(event, 'feature_code');
173
+ requireEnum(event, 'terminal_status', BUILD_TERMINAL_STATUSES);
174
+ requireNumber(event, 'files_changed_count');
175
+ requireEnum(event, 'files_source', BUILD_FILES_SOURCES);
176
+ requireNumber(event, 'review_iterations');
177
+ requireNumber(event, 'escalations');
178
+ requireNumber(event, 'tokens_total');
179
+ requireNumber(event, 'usd');
180
+ optionalNullableNumber(event, 'test_count');
181
+ optionalNullableNumber(event, 'pass_rate');
182
+ break;
183
+ }
184
+ }
185
+
186
+ function requireString(event, field) {
187
+ if (!isString(event[field])) invalid(`${field} must be a non-empty string`);
188
+ }
189
+
190
+ function requireNumber(event, field) {
191
+ if (!isFiniteNumber(event[field])) invalid(`${field} must be a number`);
192
+ }
193
+
194
+ function requireEnum(event, field, values) {
195
+ if (!isEnum(event[field], values)) invalid(`${field} must be one of ${values.join(', ')}`);
196
+ }
197
+
198
+ function optionalString(event, field) {
199
+ if (Object.hasOwn(event, field) && !isString(event[field])) invalid(`${field} must be a non-empty string`);
200
+ }
201
+
202
+ function optionalNullableString(event, field) {
203
+ if (Object.hasOwn(event, field) && !isNullableString(event[field])) invalid(`${field} must be a string or null`);
204
+ }
205
+
206
+ function optionalNumber(event, field) {
207
+ if (Object.hasOwn(event, field) && !isFiniteNumber(event[field])) invalid(`${field} must be a number`);
208
+ }
209
+
210
+ function optionalNullableNumber(event, field) {
211
+ if (Object.hasOwn(event, field) && !isNullableFiniteNumber(event[field])) invalid(`${field} must be a number or null`);
212
+ }
213
+
214
+ function optionalEnum(event, field, values) {
215
+ if (Object.hasOwn(event, field) && !isEnum(event[field], values)) {
216
+ invalid(`${field} must be one of ${values.join(', ')}`);
217
+ }
218
+ }
219
+
220
+ function isValidStoredRow(row) {
221
+ if (!isPlainObject(row) || row.v !== 1 || !isIsoTimestamp(row.ts) || !Number.isInteger(row._seq) || row._seq < 0) {
222
+ return false;
223
+ }
224
+ try {
225
+ validateEvent(row, { allowExtra: true });
226
+ return true;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ function isIsoTimestamp(value) {
233
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) && !Number.isNaN(Date.parse(value));
234
+ }
235
+
236
+ function seedCounter(path) {
237
+ if (countersByPath.has(path)) return countersByPath.get(path);
238
+ let highest = 0;
239
+ if (existsSync(path)) {
240
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
241
+ if (!line.trim()) continue;
242
+ try {
243
+ const row = JSON.parse(line);
244
+ if (isValidStoredRow(row)) highest = Math.max(highest, row._seq);
245
+ } catch {
246
+ // A torn JSONL row must not prevent later rows from being read.
247
+ }
248
+ }
249
+ }
250
+ countersByPath.set(path, highest);
251
+ return highest;
252
+ }
253
+
254
+ /**
255
+ * Append one closed dispatch-ledger event, stamping its immutable envelope.
256
+ *
257
+ * @param {string} projectCwd
258
+ * @param {object} event caller-supplied kind and closed payload only
259
+ * @returns {object} the row written to disk
260
+ */
261
+ export function appendEvent(projectCwd, event) {
262
+ validateEvent(event);
263
+ const path = ledgerPath(projectCwd);
264
+ const nextSeq = seedCounter(path) + 1;
265
+ countersByPath.set(path, nextSeq);
266
+ const row = { v: 1, ts: new Date().toISOString(), _seq: nextSeq, ...event };
267
+ mkdirSync(dirname(path), { recursive: true });
268
+ appendFileSync(path, JSON.stringify(row) + '\n');
269
+ return row;
270
+ }
271
+
272
+ /**
273
+ * Tolerantly read valid, known ledger rows. Future fields are preserved.
274
+ *
275
+ * @param {string} projectCwd
276
+ * @param {object} [opts]
277
+ * @param {string} [opts.kind]
278
+ * @param {string|number|Date} [opts.since]
279
+ * @param {string} [opts.feature]
280
+ * @returns {Array<object>}
281
+ */
282
+ export function readEvents(projectCwd, { kind, since, feature } = {}) {
283
+ const path = ledgerPath(projectCwd);
284
+ if (!existsSync(path)) return [];
285
+ const sinceMs = normalizeSince(since);
286
+ const rows = [];
287
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
288
+ if (!line.trim()) continue;
289
+ try {
290
+ const row = JSON.parse(line);
291
+ if (!isValidStoredRow(row)) continue;
292
+ if (kind && row.kind !== kind) continue;
293
+ if (feature && row.feature_code !== feature) continue;
294
+ if (sinceMs !== null && Date.parse(row.ts) < sinceMs) continue;
295
+ rows.push(row);
296
+ } catch {
297
+ // Malformed rows are expected in an append-only JSONL file after a tear.
298
+ }
299
+ }
300
+ return rows;
301
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * dispatch-metrics.js — read-only aggregation over the dispatch ledger.
3
+ *
4
+ * This module deliberately keeps the ledger's append-only observations intact:
5
+ * settlements and terminal actuals are reduced at read time by their `_seq`.
6
+ */
7
+
8
+ import { readEvents } from './dispatch-ledger.js';
9
+
10
+ const RETRY_NOTE = 'known undercount: child flows';
11
+ const GSD_NOTE = 'not instrumented';
12
+
13
+ function isNumber(value) {
14
+ return typeof value === 'number' && Number.isFinite(value);
15
+ }
16
+
17
+ function upperMiddle(values) {
18
+ const sorted = values.filter(isNumber).sort((a, b) => a - b);
19
+ return sorted.length ? sorted[Math.floor(sorted.length / 2)] : null;
20
+ }
21
+
22
+ function rate(numerator, denominator) {
23
+ return denominator > 0 ? numerator / denominator : null;
24
+ }
25
+
26
+ function highestBy(rows, key) {
27
+ const result = new Map();
28
+ for (const row of rows) {
29
+ const current = result.get(row[key]);
30
+ if (!current || row._seq > current._seq) result.set(row[key], row);
31
+ }
32
+ return result;
33
+ }
34
+
35
+ function compareNullable(a, b) {
36
+ if (a === b) return 0;
37
+ if (a === null || a === undefined) return -1;
38
+ if (b === null || b === undefined) return 1;
39
+ return String(a).localeCompare(String(b));
40
+ }
41
+
42
+ function collectRowStats(dispatches, settlements, { gsd = false } = {}) {
43
+ const tokenValues = dispatches.map((row) => row.tokens_total).filter(isNumber);
44
+ const durationValues = dispatches.map((row) => row.duration_ms).filter(isNumber);
45
+ const settled = dispatches
46
+ .map((row) => settlements.get(row.dispatch_id))
47
+ .filter(Boolean);
48
+ const accepted = settled.filter((row) => row.accepted).length;
49
+ const retryEligible = dispatches.filter((row) => isNumber(row.attempt));
50
+ const retries = retryEligible.filter((row) => row.attempt > 1).length;
51
+
52
+ return {
53
+ token_sample_count: tokenValues.length,
54
+ null_usage_count: dispatches.filter((row) => !isNumber(row.tokens_total)).length,
55
+ median_tokens: upperMiddle(tokenValues),
56
+ duration_sample_count: durationValues.length,
57
+ median_duration_ms: upperMiddle(durationValues),
58
+ completion: {
59
+ ok: dispatches.filter((row) => row.outcome === 'ok').length,
60
+ total: dispatches.length,
61
+ rate: rate(dispatches.filter((row) => row.outcome === 'ok').length, dispatches.length),
62
+ },
63
+ acceptance: {
64
+ accepted,
65
+ settled: settled.length,
66
+ rate: rate(accepted, settled.length),
67
+ note: gsd ? GSD_NOTE : null,
68
+ },
69
+ retry: {
70
+ retries,
71
+ eligible: retryEligible.length,
72
+ rate: rate(retries, retryEligible.length),
73
+ note: RETRY_NOTE,
74
+ },
75
+ usd_total: dispatches.reduce((total, row) => total + (isNumber(row.usd) ? row.usd : 0), 0),
76
+ };
77
+ }
78
+
79
+ function realizedLane(actual) {
80
+ if (actual.escalations >= 1 || actual.review_iterations >= 3 || actual.files_changed_count >= 6) return 'complex';
81
+ if (actual.files_changed_count <= 2 && actual.review_iterations <= 1 && actual.escalations === 0) return 'trivial';
82
+ return 'standard';
83
+ }
84
+
85
+ function acrrSummary(rows) {
86
+ const total = rows.length;
87
+ const matched = rows.filter((row) => row.matched).length;
88
+ return { matched, total, rate: rate(matched, total) };
89
+ }
90
+
91
+ /**
92
+ * Aggregate dispatch, settlement, estimate, and terminal actual rows without
93
+ * mutating the ledger.
94
+ *
95
+ * @param {string} projectCwd
96
+ * @param {{ since?: string|number|Date, feature?: string }} [opts]
97
+ * @returns {object}
98
+ */
99
+ export function collectDispatchMetrics(projectCwd, { since, feature } = {}) {
100
+ // Settlements have no feature code. Read one since-filtered population first,
101
+ // then join them to the feature-filtered dispatches below.
102
+ const allRows = readEvents(projectCwd, { since });
103
+ const inFeature = (row) => !feature || row.feature_code === feature;
104
+ const dispatches = allRows.filter((row) => row.kind === 'dispatch' && inFeature(row));
105
+ const settlements = highestBy(allRows.filter((row) => row.kind === 'settlement'), 'dispatch_id');
106
+
107
+ const modelBuckets = new Map();
108
+ for (const row of dispatches) {
109
+ const executed = row.effort_executed ?? null;
110
+ const intendedWhenUnknown = executed === null ? (row.effort_intended ?? null) : null;
111
+ const values = [row.model ?? null, executed, intendedWhenUnknown];
112
+ const key = JSON.stringify(values);
113
+ if (!modelBuckets.has(key)) modelBuckets.set(key, { values, rows: [] });
114
+ modelBuckets.get(key).rows.push(row);
115
+ }
116
+ const model_effort = [...modelBuckets.values()]
117
+ .sort((a, b) => compareNullable(a.values[0], b.values[0]) || compareNullable(a.values[1], b.values[1]) || compareNullable(a.values[2], b.values[2]))
118
+ .map(({ values: [model, effort_executed, effort_intended_when_unknown], rows }) => {
119
+ const stats = collectRowStats(rows, settlements, { gsd: rows.every((row) => row.site === 'gsd') });
120
+ return {
121
+ model, effort_executed, effort_intended_when_unknown, dispatch_count: rows.length,
122
+ ...stats,
123
+ };
124
+ });
125
+
126
+ const siteBuckets = new Map();
127
+ for (const row of dispatches) {
128
+ if (!siteBuckets.has(row.site)) siteBuckets.set(row.site, []);
129
+ siteBuckets.get(row.site).push(row);
130
+ }
131
+ const sites = [...siteBuckets.entries()]
132
+ .sort(([a], [b]) => a.localeCompare(b))
133
+ .map(([site, rows]) => ({ site, dispatch_count: rows.length, ...collectRowStats(rows, settlements, { gsd: site === 'gsd' }) }));
134
+
135
+ const estimates = allRows.filter((row) => row.kind === 'triage-estimate' && inFeature(row));
136
+ const actualRows = allRows.filter((row) => row.kind === 'build-actuals' && inFeature(row));
137
+ const actuals = highestBy(actualRows, 'build_id');
138
+ const estimateBuildIds = new Set(estimates.map((row) => row.build_id));
139
+ const acrrRows = estimates
140
+ .map((estimate) => {
141
+ const actual = actuals.get(estimate.build_id) ?? null;
142
+ const eligible = actual?.terminal_status === 'complete';
143
+ const lane = eligible ? realizedLane(actual) : null;
144
+ return {
145
+ build_id: estimate.build_id,
146
+ feature_code: estimate.feature_code,
147
+ estimate_source: estimate.estimate_source,
148
+ confidence: estimate.confidence ?? null,
149
+ estimated_lane: estimate.lane,
150
+ terminal_status: actual?.terminal_status ?? null,
151
+ realized_lane: lane,
152
+ matched: eligible ? estimate.lane === lane : false,
153
+ files_changed_count: actual?.files_changed_count ?? null,
154
+ files_source: actual?.files_source ?? null,
155
+ review_iterations: actual?.review_iterations ?? null,
156
+ escalations: actual?.escalations ?? null,
157
+ };
158
+ })
159
+ .sort((a, b) => a.build_id.localeCompare(b.build_id));
160
+ const eligibleRows = acrrRows.filter((row) => row.terminal_status === 'complete' && row.estimate_source !== 'escalated');
161
+ const escalatedRows = acrrRows.filter((row) => row.terminal_status === 'complete' && row.estimate_source === 'escalated');
162
+ const failed_attempts = actualRows.filter((row) => row.terminal_status === 'failed').length;
163
+ const aborted_attempts = actualRows.filter((row) => row.terminal_status === 'aborted').length;
164
+
165
+ return {
166
+ v: 1,
167
+ filters: { since: since ?? null, feature: feature ?? null },
168
+ coverage: {
169
+ dispatch_count: dispatches.length,
170
+ unattributed_count: dispatches.filter((row) => row.site === 'unattributed').length,
171
+ null_usage_count: dispatches.filter((row) => !isNumber(row.tokens_total)).length,
172
+ },
173
+ model_effort,
174
+ sites,
175
+ acrr: {
176
+ eligible: acrrSummary(eligibleRows),
177
+ escalated: acrrSummary(escalatedRows),
178
+ attrition_count: failed_attempts + aborted_attempts,
179
+ failed_attempts,
180
+ aborted_attempts,
181
+ pending_count: acrrRows.filter((row) => row.terminal_status === null).length,
182
+ unpaired_count: [...actuals.keys()].filter((buildId) => !estimateBuildIds.has(buildId)).length,
183
+ rows: acrrRows,
184
+ },
185
+ known_limitations: { retry_rate: RETRY_NOTE, gsd_acceptance: GSD_NOTE },
186
+ };
187
+ }
188
+
189
+ function formatRate(value) {
190
+ return value === null ? 'n/a' : `${(value * 100).toFixed(1)}%`;
191
+ }
192
+
193
+ function formatMedian(value) {
194
+ return value === null ? 'n/a' : String(value);
195
+ }
196
+
197
+ function formatAcceptance(acceptance) {
198
+ return acceptance.note === GSD_NOTE ? 'n/a (not instrumented)' : formatRate(acceptance.rate);
199
+ }
200
+
201
+ /** Render a compact, stable human report for `compose metrics`. */
202
+ export function renderDispatchMetrics(report) {
203
+ const lines = [
204
+ 'Model × executed effort',
205
+ 'model | executed | intended when unknown | dispatches | median tokens | median ms | completion | acceptance | retry | usd',
206
+ ];
207
+ for (const row of report.model_effort) {
208
+ lines.push([
209
+ row.model === null ? '(model: unknown)' : row.model,
210
+ row.effort_executed ?? 'unknown',
211
+ row.effort_intended_when_unknown ?? '—',
212
+ row.dispatch_count,
213
+ formatMedian(row.median_tokens),
214
+ formatMedian(row.median_duration_ms),
215
+ formatRate(row.completion.rate),
216
+ formatAcceptance(row.acceptance),
217
+ formatRate(row.retry.rate),
218
+ row.usd_total,
219
+ ].join(' | '));
220
+ }
221
+ lines.push('', 'Sites', 'site | dispatches | median tokens | median ms | completion | acceptance | retry | usd');
222
+ for (const row of report.sites) {
223
+ lines.push([
224
+ row.site, row.dispatch_count, formatMedian(row.median_tokens), formatMedian(row.median_duration_ms),
225
+ formatRate(row.completion.rate), formatAcceptance(row.acceptance), formatRate(row.retry.rate), row.usd_total,
226
+ ].join(' | '));
227
+ }
228
+ lines.push('', 'ACRR');
229
+ lines.push(`eligible: ${report.acrr.eligible.matched}/${report.acrr.eligible.total} (${formatRate(report.acrr.eligible.rate)})`);
230
+ lines.push(`escalated: ${report.acrr.escalated.matched}/${report.acrr.escalated.total} (${formatRate(report.acrr.escalated.rate)})`);
231
+ lines.push(`attrition: ${report.acrr.attrition_count} (failed attempts: ${report.acrr.failed_attempts}, aborted attempts: ${report.acrr.aborted_attempts}, pending: ${report.acrr.pending_count}, unpaired: ${report.acrr.unpaired_count})`);
232
+ lines.push('', 'Known limitations');
233
+ lines.push(`retry rate: ${report.known_limitations.retry_rate}`);
234
+ lines.push(`GSD acceptance: ${report.known_limitations.gsd_acceptance}`);
235
+ return `${lines.join('\n')}\n`;
236
+ }
@@ -121,6 +121,7 @@ export async function judge({ diff, goal, judgeModel, stratum, cwd }) {
121
121
  try {
122
122
  const prompt = buildJudgePrompt(diff, goal);
123
123
  const { provider, modelID, thinking, effort } = resolveAgentConfig(judgeModel);
124
+ const telemetry = { site: 'judge', project_cwd: cwd };
124
125
 
125
126
  let text;
126
127
  if (typeof stratum.agentRun === 'function') {
@@ -130,11 +131,15 @@ export async function judge({ diff, goal, judgeModel, stratum, cwd }) {
130
131
  thinking: thinking ?? undefined,
131
132
  effort: effort ?? undefined,
132
133
  cwd: cwd ?? undefined,
134
+ telemetry,
133
135
  });
134
136
  text = result?.text ?? '';
135
137
  } else {
136
138
  // Fallback: runAgentText (no model pinning — test harnesses may use this).
137
- text = await stratum.runAgentText(provider, prompt, { cwd: cwd ?? undefined });
139
+ text = await stratum.runAgentText(provider, prompt, {
140
+ cwd: cwd ?? undefined,
141
+ telemetry,
142
+ });
138
143
  }
139
144
 
140
145
  return extractJudgeResult(text);
@@ -18,7 +18,7 @@ import { resolvePathValue } from './paths-core.js';
18
18
  * Identical to join(cwd, featuresDir) for plain relative dirs.
19
19
  * @returns {string} absolute features dir
20
20
  */
21
- function featuresBase(cwd, featuresDir) {
21
+ export function featuresBase(cwd, featuresDir) {
22
22
  return resolvePathValue(cwd, featuresDir, 'features');
23
23
  }
24
24
 
@@ -239,6 +239,14 @@ async function applyEntry(cwd, entry) {
239
239
  const writer = new VisionWriter(join(cwd, '.compose', 'data'));
240
240
  const item = await writer.findFeatureItem(entry.feature_code);
241
241
  if (!item) return { changed: false };
242
+ if (entry._visStatus === 'complete') {
243
+ // COMP-COMPLETION-GATE slice 3 (AC-16a): repair is a projection of
244
+ // canonical truth, so it routes through the self-verifying seam rather
245
+ // than a carve-out. completeItem re-reads feature.json (and the guard
246
+ // ledger when a resource exists) before writing, and stamps the tier.
247
+ await writer.completeItem(item.id, { featureCode: entry.feature_code, cwd });
248
+ return { changed: true };
249
+ }
242
250
  await writer.updateItemStatus(item.id, entry._visStatus);
243
251
  return { changed: true };
244
252
  }
@@ -24,6 +24,16 @@
24
24
  * - XREF_URL_UNCHECKED (info) url + reserved url-class providers
25
25
  * (jira|linear|notion|obsidian) — recorded,
26
26
  * not resolved
27
+ * Plus the 5 COMP-COVERAGE-GATE project-scope kinds, which describe Compose's
28
+ * OWN MCP tool surface rather than the workspace (see lib/coverage-gate.js):
29
+ * - MISSING_EFFECT (error) tool definition declares no `effect`
30
+ * - UNGATED_MUTATION (warning) mutating tool named by no profile list
31
+ * - ORPHAN_REGISTRY_TOOL (warning) canon entry names a tool that is gone
32
+ * - UNCOVERED_WRITE (info) declared canon write absent from that
33
+ * entry's tool list (deny-message honesty)
34
+ * - COVERAGE_CHECK_SKIPPED (warning) the check itself errored — never aborts
35
+ * These appear in `findings` AND, structured, under `result.coverage`.
36
+ *
27
37
  * Full catalog + trigger/degrade/gating contract:
28
38
  * docs/features/COMP-MCP-VALIDATE/design.md
29
39
  */
@@ -46,6 +56,14 @@ import { SchemaValidator } from '../server/schema-validator.js';
46
56
  import {
47
57
  resolveRoadmapPathFromConfig, resolveFeaturesPathFromConfig, resolveJournalPathFromConfig,
48
58
  } from './project-paths.js';
59
+ // COMP-COVERAGE-GATE slice 2. Imported from server/mcp-tool-defs.js, NEVER from
60
+ // server/compose-mcp.js — the latter connects a StdioServerTransport at module
61
+ // load and hangs any importer.
62
+ import { TOOLS as MCP_TOOL_DEFS } from '../server/mcp-tool-defs.js';
63
+ import { loadToolInventory } from './tool-inventory.js';
64
+ import { canonEntries } from './canon-registry.js';
65
+ import { PROFILE_POLICY, PHASE_REFINEMENT } from '../server/mcp-tool-policy.js';
66
+ import { checkAuthorizationCoverage } from './coverage-gate.js';
49
67
 
50
68
  const __filename = fileURLToPath(import.meta.url);
51
69
  const __dirname = path.dirname(__filename);
@@ -1130,6 +1148,44 @@ async function runExternalRefChecks(ctx, findings, options = {}) {
1130
1148
  }
1131
1149
  }
1132
1150
 
1151
+ /**
1152
+ * COMP-COVERAGE-GATE slice 2 — authorization coverage of Compose's OWN MCP tool
1153
+ * surface.
1154
+ *
1155
+ * Project-scoped, not feature-scoped: it is a property of the installation, not
1156
+ * of any one feature, so it is deliberately absent from validateFeature.
1157
+ *
1158
+ * Pushes its findings into the main `findings` array (so the CLI exit code,
1159
+ * --block-on and the REST severity rollup all pick them up with no fork) AND
1160
+ * returns the structured section for callers that want the codes and
1161
+ * remediations without re-parsing prose.
1162
+ *
1163
+ * Never throws: a coverage bug must not take down a validate run that is mostly
1164
+ * about the workspace.
1165
+ *
1166
+ * @param {Array} findings — mutated in place
1167
+ * @returns {{ findings: Array }}
1168
+ */
1169
+ function runCoverageCheck(findings) {
1170
+ try {
1171
+ const inventory = loadToolInventory(MCP_TOOL_DEFS);
1172
+ const result = checkAuthorizationCoverage({
1173
+ inventory,
1174
+ registry: canonEntries(),
1175
+ policy: { PROFILE_POLICY, PHASE_REFINEMENT },
1176
+ });
1177
+ for (const f of result.findings) {
1178
+ findings.push(finding(f.severity, f.code, null, f.remediation, 'coverage'));
1179
+ }
1180
+ return result;
1181
+ } catch (e) {
1182
+ findings.push(finding('warning', 'COVERAGE_CHECK_SKIPPED', null,
1183
+ `authorization coverage check skipped (unexpected error): ${e && e.message ? e.message : e}`,
1184
+ 'coverage'));
1185
+ return { findings: [] };
1186
+ }
1187
+ }
1188
+
1133
1189
  export async function validateProject(cwd, options = {}) {
1134
1190
  const ctx = loadValidationContext(cwd, options);
1135
1191
  const findings = [];
@@ -1234,5 +1290,12 @@ export async function validateProject(cwd, options = {}) {
1234
1290
  // false positive. Strip them in one place (robust against new such checks) and
1235
1291
  // record the skip as a single info finding. feature.json↔vision drift is left
1236
1292
  // intact — it doesn't involve the roadmap.
1237
- return { scope: 'project', validated_at: nowIso(), findings: applyNarrativeSuppression(findings, ctx) };
1293
+ const coverage = runCoverageCheck(findings);
1294
+
1295
+ return {
1296
+ scope: 'project',
1297
+ validated_at: nowIso(),
1298
+ findings: applyNarrativeSuppression(findings, ctx),
1299
+ coverage,
1300
+ };
1238
1301
  }