carto-md 2.0.8 → 2.1.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 (182) hide show
  1. package/CONTRIBUTING.md +19 -15
  2. package/README.md +203 -286
  3. package/docs/anci/v0.1-DRAFT.md +420 -0
  4. package/docs/api/README.md +99 -0
  5. package/docs/api/did_we_discuss_this.md +34 -0
  6. package/docs/api/dismiss_suggestion.md +34 -0
  7. package/docs/api/explain_change_in_natural_language.md +33 -0
  8. package/docs/api/find_consumers_of_api.md +34 -0
  9. package/docs/api/get_action_patterns.md +32 -0
  10. package/docs/api/get_active_drift.md +32 -0
  11. package/docs/api/get_active_suggestions.md +25 -0
  12. package/docs/api/get_ai_cost_attribution.md +32 -0
  13. package/docs/api/get_arch_events.md +42 -0
  14. package/docs/api/get_architectural_drift.md +37 -0
  15. package/docs/api/get_architecture.md +25 -0
  16. package/docs/api/get_blast_radius.md +34 -0
  17. package/docs/api/get_canonical_pattern.md +39 -0
  18. package/docs/api/get_change_plan.md +34 -0
  19. package/docs/api/get_change_velocity.md +32 -0
  20. package/docs/api/get_churn_vs_blast_radius.md +32 -0
  21. package/docs/api/get_complexity_trend.md +39 -0
  22. package/docs/api/get_context.md +34 -0
  23. package/docs/api/get_conventions.md +32 -0
  24. package/docs/api/get_cross_domain.md +25 -0
  25. package/docs/api/get_cross_language_call_graph.md +25 -0
  26. package/docs/api/get_cross_repo_blast_radius.md +34 -0
  27. package/docs/api/get_cross_team_coupling.md +25 -0
  28. package/docs/api/get_data_flow.md +33 -0
  29. package/docs/api/get_dead_code_with_confidence.md +32 -0
  30. package/docs/api/get_decision_log.md +32 -0
  31. package/docs/api/get_dependency_surface.md +25 -0
  32. package/docs/api/get_domain.md +34 -0
  33. package/docs/api/get_domain_evolution.md +39 -0
  34. package/docs/api/get_domain_health.md +32 -0
  35. package/docs/api/get_domains_list.md +25 -0
  36. package/docs/api/get_drift_digest.md +32 -0
  37. package/docs/api/get_env_vars.md +32 -0
  38. package/docs/api/get_evolution_delta.md +36 -0
  39. package/docs/api/get_file_ownership.md +33 -0
  40. package/docs/api/get_file_summary.md +34 -0
  41. package/docs/api/get_high_impact_files.md +32 -0
  42. package/docs/api/get_hot_in_prod_no_tests.md +34 -0
  43. package/docs/api/get_hotspot_files.md +37 -0
  44. package/docs/api/get_iac_resources.md +25 -0
  45. package/docs/api/get_interface_contract.md +33 -0
  46. package/docs/api/get_intervention_history.md +32 -0
  47. package/docs/api/get_invariants.md +37 -0
  48. package/docs/api/get_llm_enrichment.md +33 -0
  49. package/docs/api/get_microservice_cut_points.md +32 -0
  50. package/docs/api/get_microservices_migration_cut_points.md +25 -0
  51. package/docs/api/get_minimal_context_for_intent.md +39 -0
  52. package/docs/api/get_models.md +32 -0
  53. package/docs/api/get_neighbors.md +39 -0
  54. package/docs/api/get_org_architecture.md +25 -0
  55. package/docs/api/get_org_domain_mapping.md +25 -0
  56. package/docs/api/get_pending_decisions.md +32 -0
  57. package/docs/api/get_predictive_risk.md +32 -0
  58. package/docs/api/get_progressive_disclosure_tree.md +25 -0
  59. package/docs/api/get_recent_decisions.md +37 -0
  60. package/docs/api/get_risk_weighted_blast_radius.md +32 -0
  61. package/docs/api/get_routes.md +25 -0
  62. package/docs/api/get_safety_checklist.md +33 -0
  63. package/docs/api/get_semantic_diff.md +34 -0
  64. package/docs/api/get_service_boundary_violations.md +25 -0
  65. package/docs/api/get_service_dependency_graph.md +25 -0
  66. package/docs/api/get_session_context.md +32 -0
  67. package/docs/api/get_similar_patterns.md +39 -0
  68. package/docs/api/get_stale_docs.md +25 -0
  69. package/docs/api/get_structure.md +25 -0
  70. package/docs/api/get_temporal_context.md +34 -0
  71. package/docs/api/get_test_coverage_map.md +25 -0
  72. package/docs/api/get_token_budget_report.md +36 -0
  73. package/docs/api/get_upgrade_risk.md +25 -0
  74. package/docs/api/get_working_memory.md +25 -0
  75. package/docs/api/ingest_otlp_traces.md +34 -0
  76. package/docs/api/scaffold_for_intent.md +34 -0
  77. package/docs/api/search_routes.md +34 -0
  78. package/docs/api/simulate_change_impact.md +37 -0
  79. package/docs/api/validate_change.md +39 -0
  80. package/docs/api/validate_diff.md +39 -0
  81. package/docs/concepts/anci.md +87 -0
  82. package/docs/concepts/blast-radius.md +66 -0
  83. package/docs/concepts/domains.md +91 -0
  84. package/docs/concepts/import-graph.md +102 -0
  85. package/docs/concepts/mcp-integration.md +148 -0
  86. package/docs/guides/adding-feature-safely.md +101 -0
  87. package/docs/guides/ci-integration.md +175 -0
  88. package/docs/guides/monorepo-setup.md +121 -0
  89. package/docs/guides/onboarding-new-engineer.md +121 -0
  90. package/docs/guides/pre-merge-review.md +139 -0
  91. package/docs/migration/v1-to-v2.md +110 -0
  92. package/docs/quickstart.md +95 -0
  93. package/docs/scale.md +129 -0
  94. package/docs/troubleshooting.md +180 -0
  95. package/package.json +12 -5
  96. package/scripts/gen-api-docs.js +170 -0
  97. package/scripts/postinstall.js +391 -24
  98. package/src/acp/agent.js +83 -11
  99. package/src/acp/config.js +64 -0
  100. package/src/acp/persistence.js +146 -0
  101. package/src/acp/providers/anthropic.js +179 -27
  102. package/src/acp/providers/index.js +15 -2
  103. package/src/acp/providers/openai.js +164 -38
  104. package/src/acp/providers/sse.js +82 -0
  105. package/src/acp/safety.js +128 -0
  106. package/src/acp/session.js +73 -0
  107. package/src/adjacent/call-graph.js +170 -0
  108. package/src/adjacent/iac.js +167 -0
  109. package/src/adjacent/llm-enrich.js +35 -0
  110. package/src/adjacent/runtime.js +216 -0
  111. package/src/adjacent/semantic-diff.js +143 -0
  112. package/src/agents/leiden.js +4 -4
  113. package/src/agents/scan-structure.js +2 -2
  114. package/src/ai/context-builder.js +215 -0
  115. package/src/ai/retrieval/lexical.js +122 -0
  116. package/src/ai/retrieval/rrf.js +121 -0
  117. package/src/ai/retrieval/semantic.js +35 -0
  118. package/src/ai/retrieval/structural.js +82 -0
  119. package/src/ai/tools.js +423 -0
  120. package/src/anci/consumer.js +305 -0
  121. package/src/anci/deserialize.js +160 -0
  122. package/src/anci/emit.js +85 -0
  123. package/src/anci/serialize.js +264 -0
  124. package/src/anci/yaml.js +401 -0
  125. package/src/bitmap/index.js +1 -1
  126. package/src/bitmap/tools.js +2 -2
  127. package/src/brain/conventions/index.js +185 -0
  128. package/src/brain/index.js +31 -0
  129. package/src/brain/invariants/index.js +252 -0
  130. package/src/brain/procedural/index.js +181 -0
  131. package/src/brain/suggestions/index.js +153 -0
  132. package/src/brain/working/index.js +170 -0
  133. package/src/cli/anci.js +237 -0
  134. package/src/cli/check.js +47 -1
  135. package/src/cli/diff.js +83 -0
  136. package/src/cli/doctor.js +270 -0
  137. package/src/cli/explain.js +61 -0
  138. package/src/cli/index.js +115 -0
  139. package/src/cli/init.js +144 -6
  140. package/src/cli/inspect.js +1 -1
  141. package/src/cli/org.js +172 -0
  142. package/src/cli/pr-impact.js +554 -0
  143. package/src/cli/serve.js +1 -1
  144. package/src/cli/status.js +211 -0
  145. package/src/cli/sync.js +2 -2
  146. package/src/cli/temporal.js +188 -0
  147. package/src/cli/validate.js +201 -0
  148. package/src/cli/watch.js +4 -4
  149. package/src/cli/why.js +101 -0
  150. package/src/extractors/frameworks.js +236 -0
  151. package/src/extractors/languages/dart.js +138 -0
  152. package/src/extractors/languages/go.js +11 -1
  153. package/src/extractors/languages/javascript.js +16 -6
  154. package/src/extractors/languages/kotlin.js +169 -0
  155. package/src/extractors/languages/php.js +195 -0
  156. package/src/extractors/languages/python.js +12 -1
  157. package/src/extractors/languages/swift.js +140 -0
  158. package/src/extractors/languages/typescript.js +15 -2
  159. package/src/extractors/plugin-api.js +102 -0
  160. package/src/mcp/change-plan.js +8 -8
  161. package/src/mcp/files-without-tests.js +285 -0
  162. package/src/mcp/middleware/index.js +451 -0
  163. package/src/mcp/server.js +2292 -0
  164. package/src/mcp/validate.js +1 -1
  165. package/src/org/detect.js +262 -0
  166. package/src/org/queries.js +144 -0
  167. package/src/org/store.js +173 -0
  168. package/src/org/sync.js +106 -0
  169. package/src/predictive/cut-points.js +83 -0
  170. package/src/predictive/drift-digest.js +88 -0
  171. package/src/predictive/ownership.js +145 -0
  172. package/src/predictive/risk-score.js +121 -0
  173. package/src/predictive/validate-change.js +55 -0
  174. package/src/store/store-adapter.js +3 -3
  175. package/src/store/{sync-v2.js → sync.js} +105 -16
  176. package/src/temporal/backfill.js +211 -0
  177. package/src/temporal/delta.js +85 -0
  178. package/src/temporal/events.js +180 -0
  179. package/src/temporal/queries.js +358 -0
  180. package/src/temporal/snapshot.js +151 -0
  181. package/src/temporal/store.js +400 -0
  182. package/src/mcp/server-v2.js +0 -986
@@ -0,0 +1,2292 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
7
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
8
+ const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
9
+ const { SQLiteStore } = require('../store/sqlite-store');
10
+ const { normalizeFileArg } = require('../store/path-utils');
11
+ const { syncFiles } = require('../store/sync');
12
+ const bitmapTools = require('../bitmap/tools');
13
+ const { ensureBitmapFresh, invalidate: invalidateBitmap } = require('../bitmap/index');
14
+ const { validateDiff, recordSideEffects } = require('./validate');
15
+
16
+ const projectRoot = process.cwd();
17
+
18
+ // Process-level safety nets. Without these, any error that
19
+ // escapes the request handler (very rare, but possible from native bindings
20
+ // or async stack frames) takes the whole MCP server down, and Claude Code /
21
+ // Kiro surface `-32000 Failed to reconnect`. We log to stderr (which the
22
+ // host logs but never terminates the JSON-RPC channel) and stay alive.
23
+ process.on('uncaughtException', (err) => {
24
+ process.stderr.write(`[CARTO MCP] Uncaught exception: ${err && err.stack ? err.stack : err}\n`);
25
+ });
26
+ process.on('unhandledRejection', (reason) => {
27
+ process.stderr.write(`[CARTO MCP] Unhandled rejection: ${reason && reason.stack ? reason.stack : reason}\n`);
28
+ });
29
+
30
+ // Open SQLite directly — no re-indexing, instant startup
31
+ let store = null;
32
+
33
+ function getStore() {
34
+ if (store) return store;
35
+ const dbPath = path.join(projectRoot, '.carto', 'carto.db');
36
+ if (!fs.existsSync(dbPath)) return null;
37
+ // Open BEFORE assigning to the module-scoped `store` so that if open()
38
+ // throws (corrupt DB, locked file, schema mismatch) we don't poison the
39
+ // cache with a broken instance — every subsequent call would otherwise
40
+ // return the broken object and never recover.
41
+ const s = new SQLiteStore(projectRoot);
42
+ s.open({ readonly: true }); // Defense in depth: MCP tools never write
43
+ store = s;
44
+ return store;
45
+ }
46
+
47
+ /**
48
+ * getSidecar() — bitmap engine entry point.
49
+ *
50
+ * Lazily loads (or rebuilds) the in-memory bitmap sidecar for the
51
+ * bitmap-eligible MCP tools. Returns null on any failure so callers can
52
+ * fall back to the SQLite query path silently — bitmap is a *speedup*,
53
+ * never a behavior change. Stale-disk and corrupt-disk are handled
54
+ * inside `ensureBitmapFresh` (rebuilds from the SQLite source of truth).
55
+ *
56
+ * Failure modes that drop us back to SQLite:
57
+ * - SQLite store unavailable (no `.carto/carto.db`).
58
+ * - DB row read fails mid-build (race with a concurrent writer).
59
+ * - Disk write to bitmap.bin fails (read-only FS, disk full) — the
60
+ * in-memory sidecar is still returned, but if even build threw we
61
+ * surface the error and use SQLite.
62
+ */
63
+ function getSidecar() {
64
+ const s = getStore();
65
+ if (!s) return null;
66
+ const cartoDir = path.join(projectRoot, '.carto');
67
+ try {
68
+ return ensureBitmapFresh(cartoDir, s);
69
+ } catch (err) {
70
+ process.stderr.write(
71
+ `[CARTO MCP] bitmap load failed, falling back to SQLite: ` +
72
+ `${err && err.message ? err.message : err}\n`
73
+ );
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * lazyReparseFile(file) — MCP-side freshness check.
80
+ *
81
+ * Before answering a file-aware tool call, mtime+size check the requested
82
+ * file against the indexed row. If the file is stale (user edited it
83
+ * between the last `carto sync` and this MCP query — e.g. uncommitted
84
+ * work), re-parse it inline so the answer reflects current code.
85
+ *
86
+ * Three states:
87
+ * - File missing on disk → leave the index alone. The user may have
88
+ * deleted-and-recreated mid-session; we don't want to drop a row that
89
+ * a sync would re-add seconds later.
90
+ * - File present, in index, mtime+size match → fast path, no work.
91
+ * - File present, stale or unknown → call syncFiles() with a transient
92
+ * writable connection. syncFiles opens, writes, closes its own
93
+ * connection so the cached read-only `store` keeps the readonly
94
+ * guarantee that the MCP read path itself can never write.
95
+ *
96
+ * Best-effort: any failure (stat, parse, write contention) falls through
97
+ * to "answer with whatever the index has." Stale data beats a crash.
98
+ */
99
+ function lazyReparseFile(file) {
100
+ if (!file || typeof file !== 'string') return;
101
+
102
+ const fullPath = path.resolve(projectRoot, file);
103
+ let stat;
104
+ try {
105
+ stat = fs.statSync(fullPath);
106
+ } catch {
107
+ return; // File doesn't exist on disk — leave index alone.
108
+ }
109
+
110
+ const s = getStore();
111
+ if (!s) return;
112
+
113
+ const existing = s.getFileByPath(file);
114
+ const mtime = Math.floor(stat.mtimeMs);
115
+ const size = stat.size;
116
+
117
+ // Fresh row → nothing to do.
118
+ if (existing && existing.mtime === mtime && existing.size === size) return;
119
+
120
+ // Stale or unknown — reparse just this file. syncFiles() opens its own
121
+ // writable connection and closes it, so the readonly `store` stays
122
+ // readonly. Costs ~5-50ms per stale file.
123
+ try {
124
+ syncFiles(projectRoot, [file]);
125
+ } catch (err) {
126
+ process.stderr.write(`[CARTO MCP] Lazy reparse failed for ${file}: ${err && err.message ? err.message : err}\n`);
127
+ }
128
+ }
129
+
130
+ function text(str) {
131
+ return { content: [{ type: 'text', text: str }] };
132
+ }
133
+
134
+ /**
135
+ * withWriter(fn) — run `fn(writer)` against a brief writable connection
136
+ * scoped to a single MCP call. The cached MCP `store` is opened readonly
137
+ * (so a buggy tool path can never write through SQLite); episodic-memory
138
+ * tools that need to record decisions/interventions go through this
139
+ * helper instead. The writer opens, writes, closes within this function
140
+ * — the readonly `store` is untouched. Failures are swallowed (returned
141
+ * as null) because validation should always degrade gracefully — the
142
+ * read result matters more than the audit log row.
143
+ */
144
+ function withWriter(fn) {
145
+ let writer = null;
146
+ try {
147
+ writer = new SQLiteStore(projectRoot);
148
+ writer.open();
149
+ return fn(writer);
150
+ } catch (err) {
151
+ process.stderr.write(
152
+ `[CARTO MCP] writer connection failed: ${err && err.message ? err.message : err}\n`
153
+ );
154
+ return null;
155
+ } finally {
156
+ if (writer) {
157
+ try { writer.close(); } catch {}
158
+ }
159
+ }
160
+ }
161
+
162
+ function notIndexed() {
163
+ return text('No .carto/carto.db found. Run `carto sync` first.');
164
+ }
165
+
166
+ /**
167
+ * runTemporalTool(name, args) — dispatch the 8 temporal MCP tools.
168
+ *
169
+ * Lazy-requires the temporal modules so a Carto install with no
170
+ * temporal DB doesn't pay the require() cost on every MCP call.
171
+ * Opens the temporal store readonly; returns a friendly markdown
172
+ * stub if the DB doesn't exist yet.
173
+ */
174
+ function runTemporalTool(name, args) {
175
+ const { TemporalStore } = require('../temporal/store');
176
+ const temporal = TemporalStore.openIfExists(projectRoot, { readonly: true });
177
+ if (!temporal) {
178
+ return text(
179
+ `Temporal database not initialized. Run \`carto temporal init\` to ` +
180
+ `backfill from git history, then \`carto sync\` to capture the current ` +
181
+ `snapshot. The temporal layer powers ${name} and 7 other tools.`
182
+ );
183
+ }
184
+ try {
185
+ const q = require('../temporal/queries');
186
+ if (name === 'get_architectural_drift') {
187
+ const r = q.getArchitecturalDrift(temporal, { domain: args.domain || null, timeRange: args.time_range || '30d' });
188
+ return text(formatDrift(r));
189
+ }
190
+ if (name === 'get_domain_evolution') {
191
+ const r = q.getDomainEvolution(temporal, { domain: args.domain, timeRange: args.time_range || '90d' });
192
+ return text(formatEvolution(r));
193
+ }
194
+ if (name === 'get_hotspot_files') {
195
+ const r = q.getHotspotFiles(temporal, { timeRange: args.time_range || '90d', limit: args.limit || 20 });
196
+ return text(formatHotspots(r));
197
+ }
198
+ if (name === 'get_complexity_trend') {
199
+ const r = q.getComplexityTrend(temporal, { file: args.file, timeRange: args.time_range || '90d' });
200
+ return text(formatComplexity(r));
201
+ }
202
+ if (name === 'get_churn_vs_blast_radius') {
203
+ const r = q.getChurnVsBlastRadius(temporal, { timeRange: args.time_range || '90d' });
204
+ return text(formatChurnVsBlast(r));
205
+ }
206
+ if (name === 'get_arch_events') {
207
+ const r = q.getArchEvents(temporal, {
208
+ severity: args.severity || null,
209
+ kind: args.kind || null,
210
+ timeRange: args.time_range || '90d',
211
+ limit: 100,
212
+ });
213
+ return text(formatEvents(r));
214
+ }
215
+ if (name === 'get_domain_health') {
216
+ const r = q.getDomainHealth(temporal, { domain: args.domain || null });
217
+ return text(formatHealth(r));
218
+ }
219
+ if (name === 'get_temporal_context') {
220
+ const r = q.getTemporalContext(temporal, { file: args.file });
221
+ return text(formatTemporalContext(r));
222
+ }
223
+ return text(`Unknown temporal tool: ${name}`);
224
+ } finally {
225
+ temporal.close();
226
+ }
227
+ }
228
+
229
+ function formatDrift(r) {
230
+ const lines = [`# Architectural Drift (${r.window || 'all time'})`];
231
+ lines.push(`\n**Trend:** ${r.trend}`);
232
+ lines.push(`**Snapshots:** ${r.totals.snapshots}`);
233
+ lines.push(`**Files:** ${r.totals.fileCountBefore} → ${r.totals.fileCountAfter}\n`);
234
+ if (r.byDomain && r.byDomain.length > 0) {
235
+ lines.push('| Domain | Before | After | Δ | Events |');
236
+ lines.push('|--------|--------|-------|---|--------|');
237
+ for (const d of r.byDomain) {
238
+ const arrow = d.delta > 0 ? `+${d.delta}` : `${d.delta}`;
239
+ lines.push(`| ${d.domain} | ${d.before} | ${d.after} | ${arrow} | ${d.eventCount} |`);
240
+ }
241
+ } else if (r.reason === 'insufficient_data') {
242
+ lines.push('_Not enough snapshots in the window. Run `carto temporal init` and re-sync._');
243
+ }
244
+ return lines.join('\n');
245
+ }
246
+
247
+ function formatEvolution(r) {
248
+ const lines = [`# Domain Evolution: ${r.domain}`];
249
+ if (!r.points || r.points.length === 0) {
250
+ lines.push('\n_No history for this domain._');
251
+ return lines.join('\n');
252
+ }
253
+ lines.push(`\n${r.points.length} snapshot${r.points.length === 1 ? '' : 's'}.\n`);
254
+ lines.push('| When | Snapshot | File count |');
255
+ lines.push('|------|----------|-----------:|');
256
+ for (const p of r.points) {
257
+ const when = new Date(p.ts).toISOString();
258
+ lines.push(`| ${when} | ${p.snapshot_id} | ${p.fileCount} |`);
259
+ }
260
+ return lines.join('\n');
261
+ }
262
+
263
+ function formatHotspots(r) {
264
+ const lines = [`# Hotspot Files (${r.window})`];
265
+ if (!r.hotspots || r.hotspots.length === 0) {
266
+ lines.push('\n_No hotspot data yet. Run `carto temporal init`._');
267
+ return lines.join('\n');
268
+ }
269
+ lines.push(`\nTop ${r.hotspots.length} files by churn × blast_radius.\n`);
270
+ lines.push('| File | Commits | Blast | Score |');
271
+ lines.push('|------|--------:|------:|------:|');
272
+ for (const h of r.hotspots) {
273
+ lines.push(`| ${h.file_path} | ${h.commit_count} | ${h.blast_radius} | ${h.score} |`);
274
+ }
275
+ return lines.join('\n');
276
+ }
277
+
278
+ function formatComplexity(r) {
279
+ const lines = [`# Complexity Trend: ${r.file}`];
280
+ if (!r.points || r.points.length === 0) {
281
+ lines.push('\n_File not present in the temporal index._');
282
+ return lines.join('\n');
283
+ }
284
+ lines.push(`\n- Commits: ${r.commit_count}`);
285
+ lines.push(`- Snapshots present: ${r.snapshots_present}`);
286
+ lines.push(`- Last modified: ${r.last_modified_ts ? new Date(r.last_modified_ts).toISOString() : '—'}`);
287
+ lines.push(`- Trend: ${r.trend}`);
288
+ return lines.join('\n');
289
+ }
290
+
291
+ function formatChurnVsBlast(r) {
292
+ const lines = [`# Churn vs Blast Radius (${r.window})`];
293
+ if (!r.files || r.files.length === 0) {
294
+ lines.push('\n_No churn data yet. Run `carto temporal init`._');
295
+ return lines.join('\n');
296
+ }
297
+ lines.push(`\n${r.files.length} files.\n`);
298
+ lines.push('| File | Commits | Blast |');
299
+ lines.push('|------|--------:|------:|');
300
+ for (const f of r.files.slice(0, 50)) {
301
+ lines.push(`| ${f.file_path} | ${f.commit_count} | ${f.blast_radius || 0} |`);
302
+ }
303
+ if (r.files.length > 50) lines.push(`\n_…${r.files.length - 50} more files._`);
304
+ return lines.join('\n');
305
+ }
306
+
307
+ function formatEvents(r) {
308
+ const lines = [`# Architectural Events (${r.window})`];
309
+ if (!r.events || r.events.length === 0) {
310
+ lines.push('\n_No events in this window._');
311
+ return lines.join('\n');
312
+ }
313
+ lines.push(`\n${r.events.length} event${r.events.length === 1 ? '' : 's'}.\n`);
314
+ lines.push('| When | Severity | Kind | Target |');
315
+ lines.push('|------|----------|------|--------|');
316
+ for (const e of r.events.slice(0, 50)) {
317
+ const when = new Date(e.ts).toISOString();
318
+ const target = e.domain || e.file_path || '';
319
+ lines.push(`| ${when} | ${e.severity} | ${e.kind} | ${target} |`);
320
+ }
321
+ return lines.join('\n');
322
+ }
323
+
324
+ function formatHealth(r) {
325
+ const lines = ['# Domain Health'];
326
+ if (!r.domains || r.domains.length === 0) {
327
+ lines.push('\n_No health data yet. Need at least 2 snapshots._');
328
+ return lines.join('\n');
329
+ }
330
+ lines.push('\n| Domain | Current | Prior | Growth | Instability | Events | Hot files |');
331
+ lines.push('|--------|--------:|------:|-------:|------------:|-------:|----------:|');
332
+ for (const d of r.domains) {
333
+ lines.push(`| ${d.domain} | ${d.current_size} | ${d.prior_size} | ${d.growth} | ${(d.instability * 100).toFixed(0)}% | ${d.events} | ${d.hotspots.length} |`);
334
+ }
335
+ return lines.join('\n');
336
+ }
337
+
338
+ function formatTemporalContext(r) {
339
+ const lines = [`# Temporal Context: ${r.file}`];
340
+ if (!r.present) {
341
+ lines.push('\n_File not present in temporal index._');
342
+ return lines.join('\n');
343
+ }
344
+ lines.push(`\n- **Commits:** ${r.commit_count}`);
345
+ lines.push(`- **Blast radius:** ${r.blast_radius}`);
346
+ lines.push(`- **First seen:** ${r.first_seen_ts ? new Date(r.first_seen_ts).toISOString() : '—'}`);
347
+ lines.push(`- **Last modified:** ${r.last_modified_ts ? new Date(r.last_modified_ts).toISOString() : '—'}`);
348
+ lines.push(`- **Age:** ${r.age_days != null ? `${r.age_days} days` : '—'}`);
349
+ lines.push(`- **Snapshots present in:** ${r.snapshots_present}`);
350
+ if (r.recent_events && r.recent_events.length > 0) {
351
+ lines.push(`\n## Recent events`);
352
+ lines.push('| When | Severity | Kind |');
353
+ lines.push('|------|----------|------|');
354
+ for (const e of r.recent_events.slice(0, 10)) {
355
+ const when = new Date(e.ts).toISOString();
356
+ lines.push(`| ${when} | ${e.severity} | ${e.kind} |`);
357
+ }
358
+ }
359
+ return lines.join('\n');
360
+ }
361
+
362
+ /**
363
+ * runBrainTool(name, args) — dispatch the 10 brain MCP tools.
364
+ *
365
+ * Each brain tool composes data from store + temporal store. Both are
366
+ * opened readonly; missing temporal DB is degraded gracefully (we return
367
+ * partial results rather than refuse).
368
+ */
369
+ function runBrainTool(name, args) {
370
+ const s = getStore();
371
+ if (!s) return notIndexed();
372
+ const { TemporalStore } = require('../temporal/store');
373
+ const temporalStore = TemporalStore.openIfExists(projectRoot, { readonly: true });
374
+ try {
375
+ const brain = require('../brain');
376
+
377
+ if (name === 'get_invariants') {
378
+ const rules = brain.invariants.inferInvariants(s, { domain: args.domain || null, threshold: args.threshold });
379
+ return text(formatInvariants(rules));
380
+ }
381
+ if (name === 'get_canonical_pattern') {
382
+ const r = brain.invariants.getCanonicalPattern(s, { pattern_type: args.pattern_type, domain: args.domain || null });
383
+ return text(formatCanonical(r, args.pattern_type));
384
+ }
385
+ if (name === 'get_conventions') {
386
+ let convs;
387
+ if (args.file) {
388
+ convs = brain.conventions.conventionsForFile(s, args.file);
389
+ } else {
390
+ convs = brain.conventions.mineConventions(s);
391
+ }
392
+ return text(formatConventions(convs, args.file));
393
+ }
394
+ if (name === 'get_action_patterns') {
395
+ if (!temporalStore) {
396
+ return text('Action patterns require the temporal layer. Run `carto temporal init`.');
397
+ }
398
+ const patterns = brain.procedural.actionPatternsForIntent(temporalStore, s, args.intent || '');
399
+ return text(formatActionPatterns(patterns, args.intent));
400
+ }
401
+ if (name === 'scaffold_for_intent') {
402
+ if (!temporalStore) {
403
+ return text('Scaffolding requires the temporal layer. Run `carto temporal init`.');
404
+ }
405
+ const r = brain.procedural.scaffoldForIntent(temporalStore, s, args.intent);
406
+ return text(formatScaffold(r));
407
+ }
408
+ if (name === 'get_working_memory') {
409
+ const r = brain.working.getWorkingMemory({ store: s, temporalStore, projectRoot });
410
+ return text(formatWorkingMemory(r));
411
+ }
412
+ if (name === 'get_pending_decisions') {
413
+ const r = brain.working.getPendingDecisions(s, { hours: args.hours || 6 });
414
+ return text(formatPendingDecisions(r));
415
+ }
416
+ if (name === 'get_active_drift') {
417
+ const r = brain.working.getActiveDrift(temporalStore, { threshold: args.threshold });
418
+ return text(formatActiveDrift(r));
419
+ }
420
+ if (name === 'get_active_suggestions') {
421
+ const suggestions = brain.suggestions.getActiveSuggestions({ store: s, temporalStore, projectRoot });
422
+ return text(formatActiveSuggestions(suggestions));
423
+ }
424
+ if (name === 'dismiss_suggestion') {
425
+ if (!args.id) return text('Missing required argument: id');
426
+ // Acknowledgment-only. Persistence is in the episodic memory:
427
+ // we write an "interventions" row marking the suggestion accepted.
428
+ withWriter((writer) => {
429
+ try {
430
+ writer.db.prepare(`
431
+ INSERT INTO interventions (session_id, ts, kind, file, severity, message, accepted)
432
+ VALUES (NULL, ?, 'suggestion_dismissed', NULL, 'minor', ?, 1)
433
+ `).run(Date.now(), `Dismissed suggestion ${args.id}`);
434
+ } catch {}
435
+ });
436
+ return text(`Dismissed suggestion: ${args.id}`);
437
+ }
438
+ return text(`Unknown brain tool: ${name}`);
439
+ } finally {
440
+ if (temporalStore) temporalStore.close();
441
+ }
442
+ }
443
+
444
+ function formatInvariants(rules) {
445
+ const lines = ['# Architectural Invariants'];
446
+ if (rules.length === 0) {
447
+ lines.push('\n_No high-confidence invariants found yet._');
448
+ return lines.join('\n');
449
+ }
450
+ lines.push(`\n${rules.length} invariant${rules.length === 1 ? '' : 's'}.\n`);
451
+ lines.push('| Confidence | Kind | Scope | Rule |');
452
+ lines.push('|-----------:|------|-------|------|');
453
+ for (const r of rules.slice(0, 50)) {
454
+ lines.push(`| ${r.confidence.toFixed(2)} | ${r.kind} | ${r.scope} | ${r.rule} |`);
455
+ }
456
+ if (rules.length > 50) lines.push(`\n_…${rules.length - 50} more rules._`);
457
+ return lines.join('\n');
458
+ }
459
+
460
+ function formatCanonical(r, patternType) {
461
+ if (!r) return `No canonical pattern found for: ${patternType}`;
462
+ const lines = [`# Canonical Pattern: ${patternType}`];
463
+ lines.push(`\n- **File:** ${r.file}`);
464
+ if (r.route_count != null) lines.push(`- **Routes:** ${r.route_count}`);
465
+ if (r.model_count != null) lines.push(`- **Models:** ${r.model_count}`);
466
+ lines.push(`- **Blast radius:** ${r.blast_radius}`);
467
+ lines.push(`- **Confidence:** ${r.confidence.toFixed(2)}`);
468
+ return lines.join('\n');
469
+ }
470
+
471
+ function formatConventions(convs, file) {
472
+ const lines = [`# Conventions${file ? `: ${file}` : ''}`];
473
+ if (!convs || convs.length === 0) {
474
+ lines.push('\n_No conventions detected._');
475
+ return lines.join('\n');
476
+ }
477
+ lines.push(`\n${convs.length} convention${convs.length === 1 ? '' : 's'}.\n`);
478
+ lines.push('| Confidence | Kind | Scope | Rule |');
479
+ lines.push('|-----------:|------|-------|------|');
480
+ for (const c of convs.slice(0, 50)) {
481
+ lines.push(`| ${c.confidence.toFixed(2)} | ${c.kind} | ${c.scope} | ${c.rule} |`);
482
+ }
483
+ return lines.join('\n');
484
+ }
485
+
486
+ function formatActionPatterns(patterns, intent) {
487
+ const lines = [`# Action Patterns${intent ? `: ${intent}` : ''}`];
488
+ if (!patterns || patterns.length === 0) {
489
+ lines.push('\n_No co-change patterns matched._');
490
+ return lines.join('\n');
491
+ }
492
+ for (const p of patterns) {
493
+ lines.push(`\n## ${p.anchor}`);
494
+ lines.push(`Co-change confidence ${p.confidence.toFixed(2)} · ${p.evidence_count} historical commits`);
495
+ lines.push('| Partner file | Co-occurrence | Commits |');
496
+ lines.push('|--------------|--------------:|--------:|');
497
+ for (const partner of p.partners) {
498
+ lines.push(`| ${partner.file} | ${partner.co_occurrence.toFixed(2)} | ${partner.commits} |`);
499
+ }
500
+ }
501
+ return lines.join('\n');
502
+ }
503
+
504
+ function formatScaffold(r) {
505
+ const lines = [`# Scaffold for: ${r.intent}`];
506
+ if (r.suggestions && r.suggestions.length > 0) {
507
+ lines.push('\n## Files typically changed together');
508
+ for (const s of r.suggestions) {
509
+ lines.push(`\n- **Anchor:** ${s.anchor_file} (${s.evidence}, confidence ${s.confidence.toFixed(2)})`);
510
+ for (const f of s.co_changed_files) lines.push(` - ${f}`);
511
+ }
512
+ } else {
513
+ lines.push('\n_No relevant action patterns found._');
514
+ }
515
+ if (r.canonical && r.canonical.length > 0) {
516
+ lines.push('\n## Canonical examples');
517
+ for (const c of r.canonical) {
518
+ lines.push(`- ${c.type}: ${c.file} (blast ${c.blast_radius})`);
519
+ }
520
+ }
521
+ return lines.join('\n');
522
+ }
523
+
524
+ function formatWorkingMemory(r) {
525
+ const lines = ['# Working Memory'];
526
+ lines.push(`\n- **Branch:** ${r.branch || '—'}`);
527
+ lines.push(`- **HEAD:** ${r.head_sha || '—'}`);
528
+ lines.push(`- **Uncommitted files:** ${r.uncommitted_files.length}`);
529
+ if (r.uncommitted_files.length > 0) {
530
+ lines.push('| Path | Kind |');
531
+ lines.push('|------|------|');
532
+ for (const f of r.uncommitted_files.slice(0, 20)) {
533
+ lines.push(`| ${f.path} | ${f.change_kind} |`);
534
+ }
535
+ }
536
+ lines.push(`\n- **Recent decisions (24h):** ${r.recent_decisions_count}`);
537
+ lines.push(`- **Open HIGH-severity warnings:** ${r.open_warnings.length}`);
538
+ if (r.recent_drift) {
539
+ const d = r.recent_drift;
540
+ lines.push(`\n## Recent drift (7d)`);
541
+ lines.push(`- **${d.domain}:** ${d.before} → ${d.after} (Δ ${d.delta}, trend ${d.trend})`);
542
+ }
543
+ return lines.join('\n');
544
+ }
545
+
546
+ function formatPendingDecisions(r) {
547
+ const lines = ['# Pending Decisions'];
548
+ if (!r || r.length === 0) {
549
+ lines.push('\n_No pending decisions._');
550
+ return lines.join('\n');
551
+ }
552
+ lines.push(`\n${r.length} pending decision${r.length === 1 ? '' : 's'}.\n`);
553
+ lines.push('| When | Kind | File | Risk |');
554
+ lines.push('|------|------|------|------|');
555
+ for (const d of r.slice(0, 30)) {
556
+ const when = new Date(d.ts).toISOString();
557
+ const risk = d.payload && d.payload.risk ? d.payload.risk : '—';
558
+ lines.push(`| ${when} | ${d.kind} | ${d.file || '—'} | ${risk} |`);
559
+ }
560
+ return lines.join('\n');
561
+ }
562
+
563
+ function formatActiveDrift(r) {
564
+ const lines = ['# Active Drift (7d)'];
565
+ if (!r.domains || r.domains.length === 0) {
566
+ lines.push('\n_No drift data yet._');
567
+ return lines.join('\n');
568
+ }
569
+ if (r.threshold_breaches.length > 0) {
570
+ lines.push('\n## Threshold breaches');
571
+ lines.push('| Domain | Before | After | Δ |');
572
+ lines.push('|--------|-------:|------:|---|');
573
+ for (const d of r.threshold_breaches) {
574
+ lines.push(`| ${d.domain} | ${d.before} | ${d.after} | ${d.delta > 0 ? '+' + d.delta : d.delta} |`);
575
+ }
576
+ }
577
+ lines.push('\n## All domains');
578
+ lines.push('| Domain | Before | After | Δ |');
579
+ lines.push('|--------|-------:|------:|---|');
580
+ for (const d of r.domains) {
581
+ lines.push(`| ${d.domain} | ${d.before} | ${d.after} | ${d.delta > 0 ? '+' + d.delta : d.delta} |`);
582
+ }
583
+ return lines.join('\n');
584
+ }
585
+
586
+ function formatActiveSuggestions(suggestions) {
587
+ const lines = ['# Active Suggestions'];
588
+ if (!suggestions || suggestions.length === 0) {
589
+ lines.push('\n_No active suggestions._');
590
+ return lines.join('\n');
591
+ }
592
+ lines.push(`\n${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'}.\n`);
593
+ lines.push('| Severity | Trigger | Summary |');
594
+ lines.push('|----------|---------|---------|');
595
+ for (const s of suggestions) {
596
+ lines.push(`| ${s.severity} | ${s.trigger} | ${s.summary} |`);
597
+ }
598
+ return lines.join('\n');
599
+ }
600
+
601
+ /**
602
+ * runAiTool(name, args) — dispatcher for the 14 AI-native primitives.
603
+ *
604
+ * Lazily opens the temporal store readonly; tools degrade gracefully when
605
+ * it's missing. All formatters are pure-functional (no I/O).
606
+ */
607
+ function runAiTool(name, args) {
608
+ const s = getStore();
609
+ if (!s) return notIndexed();
610
+ const { TemporalStore } = require('../temporal/store');
611
+ const temporalStore = TemporalStore.openIfExists(projectRoot, { readonly: true });
612
+ try {
613
+ const ai = require('../ai/tools');
614
+ const ctx = { store: s, projectRoot, temporalStore };
615
+
616
+ if (name === 'get_minimal_context_for_intent') {
617
+ const r = ai.minimalContext(args, ctx);
618
+ return text(r.markdown || JSON.stringify(r, null, 2));
619
+ }
620
+ if (name === 'get_progressive_disclosure_tree') {
621
+ const r = ai.progressiveDisclosure(args, ctx);
622
+ return text(formatProgressiveTree(r));
623
+ }
624
+ if (name === 'get_token_budget_report') {
625
+ const r = ai.tokenBudget(args, ctx);
626
+ return text(formatTokenBudget(r));
627
+ }
628
+ if (name === 'get_decision_log') {
629
+ const r = ai.decisionLog(args, ctx);
630
+ return text(formatDecisionLog(r));
631
+ }
632
+ if (name === 'get_evolution_delta') {
633
+ const r = ai.evolutionDelta(args, ctx);
634
+ return text(formatEvolutionDelta(r));
635
+ }
636
+ if (name === 'get_change_velocity') {
637
+ const r = ai.changeVelocity(args, ctx);
638
+ return text(formatChangeVelocity(r));
639
+ }
640
+ if (name === 'get_test_coverage_map') {
641
+ const r = ai.testCoverageMap(args, ctx);
642
+ return text(formatTestCoverage(r));
643
+ }
644
+ if (name === 'get_safety_checklist') {
645
+ const r = ai.safetyChecklist(args, ctx);
646
+ return text(formatSafetyChecklist(r));
647
+ }
648
+ if (name === 'get_data_flow') {
649
+ const r = ai.dataFlow(args, ctx);
650
+ return text(formatDataFlow(r));
651
+ }
652
+ if (name === 'get_interface_contract') {
653
+ const r = ai.interfaceContract(args, ctx);
654
+ return text(formatInterfaceContract(r));
655
+ }
656
+ if (name === 'explain_change_in_natural_language') {
657
+ const r = ai.explainChange(args, { ...ctx, sidecar: getSidecar() });
658
+ return text(formatExplainChange(r));
659
+ }
660
+ if (name === 'get_stale_docs') {
661
+ const r = ai.staleDocs(args, ctx);
662
+ return text(formatStaleDocs(r));
663
+ }
664
+ if (name === 'get_dependency_surface') {
665
+ const r = ai.dependencySurface(args, ctx);
666
+ return text(formatDependencySurface(r));
667
+ }
668
+ if (name === 'get_upgrade_risk') {
669
+ const r = ai.upgradeRisk(args, ctx);
670
+ return text(formatUpgradeRisk(r));
671
+ }
672
+ return text(`Unknown AI tool: ${name}`);
673
+ } finally {
674
+ if (temporalStore) temporalStore.close();
675
+ }
676
+ }
677
+
678
+ function formatProgressiveTree(r) {
679
+ const lines = ['# Progressive Disclosure Tree'];
680
+ if (!r.domains || r.domains.length === 0) {
681
+ lines.push('\n_No domains yet._');
682
+ return lines.join('\n');
683
+ }
684
+ for (const d of r.domains) {
685
+ lines.push(`\n## ${d.name} · ${d.file_count} files · ${d.route_count} routes`);
686
+ if (d.top_files && d.top_files.length > 0) {
687
+ lines.push('| File | Blast | Exports |');
688
+ lines.push('|------|------:|---------|');
689
+ for (const f of d.top_files) {
690
+ lines.push(`| ${f.path} | ${f.blast_radius} | ${(f.exports || []).join(', ') || '—'} |`);
691
+ }
692
+ }
693
+ }
694
+ return lines.join('\n');
695
+ }
696
+
697
+ function formatTokenBudget(r) {
698
+ const lines = ['# Token Budget Report'];
699
+ lines.push(`\n- **Intent:** ${r.intent || '(none)'}`);
700
+ lines.push(`- **Budget:** ${r.budget_tokens} tokens`);
701
+ lines.push(`- **Used:** ${r.used_tokens} tokens (${r.files_included} files)`);
702
+ lines.push(`- **Dropped:** ${r.files_dropped} files`);
703
+ lines.push(`- **Repo total (approx):** ${r.repo_tokens_approx} tokens across ${r.total_files_in_repo} files`);
704
+ lines.push(`- **Efficiency:** ${r.efficiency}% of repo fits the budget`);
705
+ return lines.join('\n');
706
+ }
707
+
708
+ function formatDecisionLog(r) {
709
+ const lines = [`# Decision Log (${r.hours}h)`];
710
+ if (!r.decisions || r.decisions.length === 0) {
711
+ lines.push('\n_No decisions in this window._');
712
+ return lines.join('\n');
713
+ }
714
+ lines.push(`\n${r.decisions.length} decision${r.decisions.length === 1 ? '' : 's'}.\n`);
715
+ lines.push('| When | Kind | File |');
716
+ lines.push('|------|------|------|');
717
+ for (const d of r.decisions.slice(0, 50)) {
718
+ const when = new Date(d.ts).toISOString();
719
+ lines.push(`| ${when} | ${d.kind} | ${d.file || '—'} |`);
720
+ }
721
+ if (r.events && r.events.length > 0) {
722
+ lines.push(`\n## Architectural events in the same window`);
723
+ lines.push(`${r.events.length} event(s).`);
724
+ }
725
+ return lines.join('\n');
726
+ }
727
+
728
+ function formatEvolutionDelta(r) {
729
+ if (!r.delta || r.reason === 'no_temporal') {
730
+ return '# Evolution Delta\n\n_Requires temporal layer. Run `carto temporal init`._';
731
+ }
732
+ return formatDrift(r.delta); // reuse existing formatter
733
+ }
734
+
735
+ function formatChangeVelocity(r) {
736
+ if (r.reason === 'no_temporal') {
737
+ return '# Change Velocity\n\n_Requires temporal layer. Run `carto temporal init`._';
738
+ }
739
+ const lines = ['# Change Velocity'];
740
+ lines.push(`\n- **Days observed:** ${r.days_observed}`);
741
+ lines.push(`- **Total commits:** ${r.total_commits}`);
742
+ lines.push(`- **Avg commits / day:** ${r.avg_commits_per_day}`);
743
+ if (r.daily && r.daily.length > 0) {
744
+ lines.push('\n| Day | Commits |');
745
+ lines.push('|-----|--------:|');
746
+ for (const d of r.daily.slice(-14)) lines.push(`| ${d.day} | ${d.commits} |`);
747
+ }
748
+ return lines.join('\n');
749
+ }
750
+
751
+ function formatTestCoverage(r) {
752
+ const lines = ['# Test Coverage Map'];
753
+ lines.push(`\nConsidered ${r.considered} files; ${r.untested ? r.untested.length : 0} have no detected test.`);
754
+ if (r.by_blast_radius && r.by_blast_radius.length > 0) {
755
+ lines.push('\n## Top untested files by blast radius');
756
+ lines.push('| File | Blast |');
757
+ lines.push('|------|------:|');
758
+ for (const f of r.by_blast_radius.slice(0, 30)) {
759
+ lines.push(`| ${f.path} | ${f.blast_radius} |`);
760
+ }
761
+ }
762
+ return lines.join('\n');
763
+ }
764
+
765
+ function formatSafetyChecklist(r) {
766
+ const lines = [`# Safety Checklist: ${r.file || ''}`];
767
+ if (!r.items || r.items.length === 0) {
768
+ lines.push('\n_No checks ran._');
769
+ return lines.join('\n');
770
+ }
771
+ for (const item of r.items) {
772
+ const icon = item.severity === 'safe' ? '✅' :
773
+ item.severity === 'minor' ? '⚠️' :
774
+ item.severity === 'major' ? '🟡' : '🔴';
775
+ lines.push(`- ${icon} **${item.severity}** — ${item.message}`);
776
+ }
777
+ return lines.join('\n');
778
+ }
779
+
780
+ function formatDataFlow(r) {
781
+ const lines = [`# Data Flow: ${r.source || ''}`];
782
+ lines.push(`\n- **Domain:** ${r.domain || '—'}`);
783
+ if (r.imports && r.imports.length > 0) {
784
+ lines.push('\n## Upstream (imports)');
785
+ for (const i of r.imports.slice(0, 20)) lines.push(`- ${i.path || i}`);
786
+ }
787
+ if (r.imported_by && r.imported_by.length > 0) {
788
+ lines.push('\n## Downstream (imported by)');
789
+ for (const i of r.imported_by.slice(0, 20)) lines.push(`- ${i.path || i}`);
790
+ }
791
+ if (r.routes_in_file && r.routes_in_file.length > 0) {
792
+ lines.push('\n## Routes in file');
793
+ for (const rt of r.routes_in_file) lines.push(`- ${rt.method} ${rt.path}`);
794
+ }
795
+ if (r.env_vars && r.env_vars.length > 0) {
796
+ lines.push('\n## Env vars');
797
+ lines.push(r.env_vars.slice(0, 20).join(', '));
798
+ }
799
+ return lines.join('\n');
800
+ }
801
+
802
+ function formatInterfaceContract(r) {
803
+ const lines = [`# Interface Contract: ${r.file || ''}`];
804
+ lines.push(`\n- **Domain:** ${r.domain || '—'}`);
805
+ if (r.exports && r.exports.length > 0) {
806
+ lines.push('\n## Exports');
807
+ lines.push('| Name | Kind | Default? |');
808
+ lines.push('|------|------|---------:|');
809
+ for (const e of r.exports) {
810
+ lines.push(`| ${e.name} | ${e.kind} | ${e.is_default_export ? 'yes' : 'no'} |`);
811
+ }
812
+ }
813
+ if (r.routes && r.routes.length > 0) {
814
+ lines.push('\n## Routes');
815
+ for (const rt of r.routes) lines.push(`- ${rt.method} ${rt.path}`);
816
+ }
817
+ if (r.models && r.models.length > 0) {
818
+ lines.push('\n## Models');
819
+ for (const m of r.models) lines.push(`- ${m.name} (${m.kind})`);
820
+ }
821
+ return lines.join('\n');
822
+ }
823
+
824
+ function formatExplainChange(r) {
825
+ return `# Diff Explanation\n\n${r.summary || '(no summary)'}\n`;
826
+ }
827
+
828
+ function formatStaleDocs(r) {
829
+ const lines = ['# Stale Docs'];
830
+ if (!r.stale || r.stale.length === 0) {
831
+ lines.push('\n_All docs are fresh (none older than 30 days)._');
832
+ return lines.join('\n');
833
+ }
834
+ lines.push(`\n${r.stale.length} doc(s) older than 30 days.\n`);
835
+ lines.push('| Path | Age (days) |');
836
+ lines.push('|------|----------:|');
837
+ for (const d of r.stale.slice(0, 30)) lines.push(`| ${d.path} | ${d.age_days} |`);
838
+ return lines.join('\n');
839
+ }
840
+
841
+ function formatDependencySurface(r) {
842
+ const lines = ['# Dependency Surface'];
843
+ lines.push(`\n${r.count || (r.deps ? r.deps.length : 0)} dependencies detected.\n`);
844
+ if (!r.deps || r.deps.length === 0) {
845
+ lines.push('_No dependency manifest found._');
846
+ return lines.join('\n');
847
+ }
848
+ lines.push('| Ecosystem | Name | Version | Kind |');
849
+ lines.push('|-----------|------|---------|------|');
850
+ for (const d of r.deps.slice(0, 50)) {
851
+ lines.push(`| ${d.ecosystem} | ${d.name} | ${d.version} | ${d.kind} |`);
852
+ }
853
+ if (r.deps.length > 50) lines.push(`\n_…${r.deps.length - 50} more._`);
854
+ return lines.join('\n');
855
+ }
856
+
857
+ function formatUpgradeRisk(r) {
858
+ const lines = ['# Upgrade Risk'];
859
+ if (!r.risks || r.risks.length === 0) {
860
+ lines.push('\n_No usage data — likely no imports map to declared deps._');
861
+ return lines.join('\n');
862
+ }
863
+ lines.push(`\nUsage counts across the import graph.\n`);
864
+ lines.push('| Risk | Name | Version | Usages | Domains |');
865
+ lines.push('|------|------|---------|-------:|--------:|');
866
+ for (const x of r.risks.slice(0, 40)) {
867
+ lines.push(`| ${x.risk} | ${x.name} | ${x.version} | ${x.count} | ${x.domains} |`);
868
+ }
869
+ return lines.join('\n');
870
+ }
871
+
872
+ /**
873
+ * runAdjacentTool(name, args) — dispatcher for the adjacent-positioning tools.
874
+ *
875
+ * Handles cross-language call graph, IaC scan, runtime fusion, semantic
876
+ * diff, and LLM enrichment stub. Runtime tools accept an `otlp_path`
877
+ * argument; when missing, they degrade to the static-only signal.
878
+ */
879
+ function runAdjacentTool(name, args) {
880
+ const s = getStore();
881
+ if (!s) return notIndexed();
882
+ try {
883
+ if (name === 'get_cross_language_call_graph') {
884
+ const { buildCallGraph } = require('../adjacent/call-graph');
885
+ const r = buildCallGraph({ store: s, projectRoot });
886
+ return text(formatCallGraph(r));
887
+ }
888
+ if (name === 'get_iac_resources') {
889
+ const { scanIacResources } = require('../adjacent/iac');
890
+ const resources = scanIacResources(projectRoot);
891
+ return text(formatIacResources(resources));
892
+ }
893
+ if (name === 'ingest_otlp_traces') {
894
+ if (!args.path) return text('Missing required argument: path');
895
+ const { parseOtlpFile } = require('../adjacent/runtime');
896
+ const counts = parseOtlpFile(args.path);
897
+ const lines = [`# OTLP Trace Ingest: ${args.path}`];
898
+ lines.push(`\n${counts.length} unique (method, route) tuples observed.\n`);
899
+ if (counts.length > 0) {
900
+ lines.push('| Method | Route | Count |');
901
+ lines.push('|--------|-------|------:|');
902
+ counts.slice(0, 50).forEach(c => lines.push(`| ${c.method} | ${c.path} | ${c.count} |`));
903
+ }
904
+ return text(lines.join('\n'));
905
+ }
906
+ if (name === 'get_risk_weighted_blast_radius') {
907
+ const { parseOtlpFile, riskWeightedBlastRadius } = require('../adjacent/runtime');
908
+ const runtime = args.otlp_path ? parseOtlpFile(args.otlp_path) : [];
909
+ const r = riskWeightedBlastRadius({ store: s, runtimeCounts: runtime });
910
+ return text(formatRiskBlast(r));
911
+ }
912
+ if (name === 'get_dead_code_with_confidence') {
913
+ const { parseOtlpFile, deadCodeWithConfidence } = require('../adjacent/runtime');
914
+ const runtime = args.otlp_path ? parseOtlpFile(args.otlp_path) : null;
915
+ const r = deadCodeWithConfidence({ store: s, runtimeCounts: runtime });
916
+ return text(formatDeadCode(r, runtime != null));
917
+ }
918
+ if (name === 'get_hot_in_prod_no_tests') {
919
+ const { parseOtlpFile, hotInProdNoTests } = require('../adjacent/runtime');
920
+ const runtime = parseOtlpFile(args.otlp_path);
921
+ const r = hotInProdNoTests({ store: s, projectRoot, runtimeCounts: runtime });
922
+ return text(formatHotNoTests(r));
923
+ }
924
+ if (name === 'get_semantic_diff') {
925
+ const { semanticDiff } = require('../adjacent/semantic-diff');
926
+ const r = semanticDiff({ store: s, diff: args.diff });
927
+ return text(formatSemanticDiff(r));
928
+ }
929
+ if (name === 'get_llm_enrichment') {
930
+ const llm = require('../adjacent/llm-enrich');
931
+ if (!llm.isAvailable(projectRoot)) {
932
+ return text(`# LLM Enrichment\n\n_Disabled. Opt in via \`carto.config.json\` → \`ai.llm\`. Currently a stub._`);
933
+ }
934
+ const r = llm.enrichNode(args.file);
935
+ return text(`# LLM Enrichment: ${args.file}\n\n${r ? JSON.stringify(r, null, 2) : '_no summary_'}`);
936
+ }
937
+ return text(`Unknown adjacent tool: ${name}`);
938
+ } catch (err) {
939
+ return text(`Error in ${name}: ${err.message || err}`);
940
+ }
941
+ }
942
+
943
+ function formatCallGraph(r) {
944
+ const lines = ['# Cross-Language Call Graph'];
945
+ lines.push(`\n- **Total fetches seen:** ${r.total_fetches_seen}`);
946
+ lines.push(`- **Matched callers ↔ routes:** ${r.matches.length}`);
947
+ lines.push(`- **Unmatched fetches:** ${r.unmatched_fetches.length}\n`);
948
+ if (r.matches.length > 0) {
949
+ lines.push('| Caller | Method | Route | Handler file |');
950
+ lines.push('|--------|--------|-------|--------------|');
951
+ for (const m of r.matches.slice(0, 50)) {
952
+ lines.push(`| ${m.caller_file} | ${m.method} | ${m.route_path} | ${m.callee_file} |`);
953
+ }
954
+ if (r.matches.length > 50) lines.push(`\n_…${r.matches.length - 50} more matches._`);
955
+ }
956
+ return lines.join('\n');
957
+ }
958
+
959
+ function formatIacResources(resources) {
960
+ const lines = ['# IaC Resources'];
961
+ lines.push(`\n${resources.length} resource(s) detected.\n`);
962
+ if (resources.length === 0) {
963
+ lines.push('_No Terraform / Helm / Pulumi / CDK files found._');
964
+ return lines.join('\n');
965
+ }
966
+ const byKind = new Map();
967
+ for (const r of resources) {
968
+ if (!byKind.has(r.kind)) byKind.set(r.kind, []);
969
+ byKind.get(r.kind).push(r);
970
+ }
971
+ for (const [kind, items] of byKind) {
972
+ lines.push(`\n## ${kind} (${items.length})`);
973
+ for (const x of items.slice(0, 30)) {
974
+ const type = x.tf_type ? ` · ${x.tf_type}` : '';
975
+ lines.push(`- **${x.name}**${type} (${x.file})${x.dependencies && x.dependencies.length > 0 ? ` — deps: ${x.dependencies.slice(0, 3).join(', ')}` : ''}`);
976
+ }
977
+ if (items.length > 30) lines.push(` _…${items.length - 30} more._`);
978
+ }
979
+ return lines.join('\n');
980
+ }
981
+
982
+ function formatRiskBlast(rows) {
983
+ const lines = ['# Risk-Weighted Blast Radius'];
984
+ if (!rows || rows.length === 0) {
985
+ lines.push('\n_No routes found._');
986
+ return lines.join('\n');
987
+ }
988
+ lines.push(`\n${rows.length} route(s); sorted by risk_score = dependents × runtime_calls + dependents.\n`);
989
+ lines.push('| Method | Route | File | Dependents | Runtime hits | Score |');
990
+ lines.push('|--------|-------|------|----------:|-------------:|------:|');
991
+ for (const r of rows.slice(0, 30)) {
992
+ lines.push(`| ${r.method} | ${r.path} | ${r.file} | ${r.dependents} | ${r.runtime_calls} | ${r.risk_score} |`);
993
+ }
994
+ return lines.join('\n');
995
+ }
996
+
997
+ function formatDeadCode(rows, hasRuntime) {
998
+ const lines = ['# Dead Code (with confidence)'];
999
+ if (!rows || rows.length === 0) {
1000
+ lines.push('\n_No orphaned files detected._');
1001
+ return lines.join('\n');
1002
+ }
1003
+ lines.push(`\n${rows.length} file(s) with no static dependents${hasRuntime ? ' AND no runtime hits' : ''}.\n`);
1004
+ lines.push('| File | Runtime hit |');
1005
+ lines.push('|------|------------:|');
1006
+ for (const r of rows.slice(0, 40)) {
1007
+ lines.push(`| ${r.path} | ${r.runtime_hit === null ? '—' : r.runtime_hit} |`);
1008
+ }
1009
+ return lines.join('\n');
1010
+ }
1011
+
1012
+ function formatHotNoTests(rows) {
1013
+ const lines = ['# Hot in Prod, No Tests'];
1014
+ if (!rows || rows.length === 0) {
1015
+ lines.push('\n_No untested files with runtime hits found._');
1016
+ return lines.join('\n');
1017
+ }
1018
+ lines.push(`\n${rows.length} file(s).\n`);
1019
+ for (const r of rows.slice(0, 40)) lines.push(`- ${r.path}`);
1020
+ return lines.join('\n');
1021
+ }
1022
+
1023
+ function formatSemanticDiff(r) {
1024
+ const lines = ['# Semantic Diff'];
1025
+ lines.push(`\n- **Files changed:** ${r.files_changed}`);
1026
+ lines.push(`- **New files:** ${r.new_files.length}`);
1027
+ lines.push(`- **Deleted files:** ${r.deleted_files.length}`);
1028
+ if (r.renames && r.renames.length > 0) {
1029
+ lines.push('\n## Renames detected');
1030
+ for (const x of r.renames) lines.push(`- ${x.file}: \`${x.from}\` → \`${x.to}\``);
1031
+ }
1032
+ if (r.relocations && r.relocations.length > 0) {
1033
+ lines.push('\n## Symbol relocations');
1034
+ for (const x of r.relocations) lines.push(`- \`${x.symbol}\`: ${x.from_file} → ${x.to_file}`);
1035
+ }
1036
+ if (r.new_domains && r.new_domains.length > 0) {
1037
+ lines.push('\n## New domain prefixes');
1038
+ for (const x of r.new_domains) lines.push(`- ${x}/`);
1039
+ }
1040
+ return lines.join('\n');
1041
+ }
1042
+
1043
+ /**
1044
+ * runPredictiveTool(name, args) — dispatcher for the predictive tools.
1045
+ */
1046
+ function runPredictiveTool(name, args) {
1047
+ const s = getStore();
1048
+ if (!s) return notIndexed();
1049
+ const { TemporalStore } = require('../temporal/store');
1050
+ const temporalStore = TemporalStore.openIfExists(projectRoot, { readonly: true });
1051
+ try {
1052
+ if (name === 'get_predictive_risk') {
1053
+ const { scoreFiles } = require('../predictive/risk-score');
1054
+ const filesArg = args.file ? [args.file] : null;
1055
+ const r = scoreFiles({ store: s, temporalStore, projectRoot, files: filesArg });
1056
+ return text(formatRiskScores(r));
1057
+ }
1058
+ if (name === 'get_microservice_cut_points') {
1059
+ const { findCutPoints } = require('../predictive/cut-points');
1060
+ const r = findCutPoints({ store: s, threshold: args.threshold || 0.7 });
1061
+ return text(formatCutPoints(r));
1062
+ }
1063
+ if (name === 'validate_change') {
1064
+ const { validateChange } = require('../predictive/validate-change');
1065
+ const r = validateChange({ store: s, projectRoot, file: args.file, content: args.content });
1066
+ return text(formatValidateChange(r));
1067
+ }
1068
+ if (name === 'get_file_ownership') {
1069
+ const { ownersForFile } = require('../predictive/ownership');
1070
+ const r = ownersForFile({ projectRoot, file: args.file });
1071
+ return text(formatOwnership(r));
1072
+ }
1073
+ if (name === 'get_cross_team_coupling') {
1074
+ const { crossTeamCoupling } = require('../predictive/ownership');
1075
+ const r = crossTeamCoupling({ store: s, projectRoot });
1076
+ return text(formatCrossTeamCoupling(r));
1077
+ }
1078
+ if (name === 'get_drift_digest') {
1079
+ const { renderDriftDigest } = require('../predictive/drift-digest');
1080
+ return text(renderDriftDigest({ store: s, temporalStore, projectRoot, timeRange: args.time_range || '7d' }));
1081
+ }
1082
+ if (name === 'get_ai_cost_attribution') {
1083
+ const { aiCostAttribution } = require('../predictive/ownership');
1084
+ const r = aiCostAttribution({ store: s, hours: args.hours || 168 });
1085
+ return text(formatAiCost(r));
1086
+ }
1087
+ return text(`Unknown predictive tool: ${name}`);
1088
+ } finally {
1089
+ if (temporalStore) temporalStore.close();
1090
+ }
1091
+ }
1092
+
1093
+ function formatRiskScores(rows) {
1094
+ const lines = ['# Predictive Risk'];
1095
+ if (!rows || rows.length === 0) {
1096
+ lines.push('\n_No files scored._');
1097
+ return lines.join('\n');
1098
+ }
1099
+ lines.push(`\nTop ${Math.min(rows.length, 30)} of ${rows.length} files by P(incident) score.\n`);
1100
+ lines.push('| File | Score | Blast | Churn | Cross | IV | NoTest |');
1101
+ lines.push('|------|------:|------:|------:|------:|---:|-------:|');
1102
+ for (const r of rows.slice(0, 30)) {
1103
+ const c = r.components || {};
1104
+ lines.push(`| ${r.path} | ${r.score} | ${(c.blast || 0).toFixed(2)} | ${(c.churn || 0).toFixed(2)} | ${c.cross || 0} | ${(c.intervention || 0).toFixed(2)} | ${c.no_test || 0} |`);
1105
+ }
1106
+ return lines.join('\n');
1107
+ }
1108
+
1109
+ function formatCutPoints(r) {
1110
+ const lines = ['# Microservice Cut-Points'];
1111
+ if (!r.all_domains || r.all_domains.length === 0) {
1112
+ lines.push('\n_No domain data._');
1113
+ return lines.join('\n');
1114
+ }
1115
+ if (r.cut_points && r.cut_points.length > 0) {
1116
+ lines.push(`\n## Candidates (${r.cut_points.length})`);
1117
+ lines.push('| Domain | Files | Cohesion | Intra | Out | In |');
1118
+ lines.push('|--------|------:|---------:|------:|----:|---:|');
1119
+ for (const d of r.cut_points) {
1120
+ lines.push(`| ${d.domain} | ${d.files} | ${d.cohesion} | ${d.intra_edges} | ${d.outbound_edges} | ${d.inbound_edges} |`);
1121
+ }
1122
+ } else {
1123
+ lines.push('\n_No high-cohesion domains pass the candidate threshold._');
1124
+ }
1125
+ lines.push('\n## All domains');
1126
+ lines.push('| Domain | Files | Cohesion | Candidate? |');
1127
+ lines.push('|--------|------:|---------:|:----------:|');
1128
+ for (const d of r.all_domains) {
1129
+ lines.push(`| ${d.domain} | ${d.files} | ${d.cohesion} | ${d.candidate ? '✅' : '—'} |`);
1130
+ }
1131
+ return lines.join('\n');
1132
+ }
1133
+
1134
+ function formatValidateChange(r) {
1135
+ const lines = ['# validate_change'];
1136
+ lines.push(`\n- **Risk:** ${r.risk}`);
1137
+ if (r.reason) lines.push(`- **Reason:** ${r.reason}`);
1138
+ if (r.files_changed && r.files_changed.length > 0) {
1139
+ lines.push(`- **Files:** ${r.files_changed.join(', ')}`);
1140
+ }
1141
+ if (r.violations && r.violations.length > 0) {
1142
+ lines.push('\n## Violations');
1143
+ for (const v of r.violations.slice(0, 10)) {
1144
+ lines.push(`- **${v.severity}** ${v.kind}: ${v.detail || v.file || ''}`);
1145
+ }
1146
+ }
1147
+ if (r.suggestions && r.suggestions.length > 0) {
1148
+ lines.push('\n## Suggestions');
1149
+ for (const s of r.suggestions) lines.push(`- ${s}`);
1150
+ }
1151
+ return lines.join('\n');
1152
+ }
1153
+
1154
+ function formatOwnership(r) {
1155
+ const lines = [`# Ownership: ${r.file || ''}`];
1156
+ lines.push(`\n- **Top author:** ${r.top_author || '—'}`);
1157
+ if (r.authors && r.authors.length > 0) {
1158
+ lines.push('| Author | Lines |');
1159
+ lines.push('|--------|------:|');
1160
+ for (const a of r.authors.slice(0, 20)) lines.push(`| ${a.name} | ${a.lines} |`);
1161
+ } else {
1162
+ lines.push('\n_No blame data available (git missing or file not tracked)._');
1163
+ }
1164
+ return lines.join('\n');
1165
+ }
1166
+
1167
+ function formatCrossTeamCoupling(r) {
1168
+ const lines = ['# Cross-Team Coupling'];
1169
+ if (!r.warnings || r.warnings.length === 0) {
1170
+ lines.push('\n_No cross-team coupling warnings._');
1171
+ return lines.join('\n');
1172
+ }
1173
+ lines.push(`\n${r.warnings.length} coordination warning(s).\n`);
1174
+ lines.push('| From file | From owner | To file | To owner |');
1175
+ lines.push('|-----------|------------|---------|----------|');
1176
+ for (const w of r.warnings.slice(0, 30)) {
1177
+ lines.push(`| ${w.from_file} | ${w.from_owner} | ${w.to_file} | ${w.to_owner} |`);
1178
+ }
1179
+ return lines.join('\n');
1180
+ }
1181
+
1182
+ function formatAiCost(r) {
1183
+ const lines = [`# AI Cost Attribution (${r.hours}h)`];
1184
+ if (!r.clients || r.clients.length === 0) {
1185
+ lines.push('\n_No AI session data in window._');
1186
+ return lines.join('\n');
1187
+ }
1188
+ lines.push(`\n${r.clients.length} client(s).\n`);
1189
+ lines.push('| Client | Decisions | Violations |');
1190
+ lines.push('|--------|----------:|----------:|');
1191
+ for (const c of r.clients) {
1192
+ lines.push(`| ${c.client} | ${c.decisions} | ${c.violations} |`);
1193
+ }
1194
+ return lines.join('\n');
1195
+ }
1196
+
1197
+ /**
1198
+ * runOrgTool(name, args) — dispatcher for the cross-repo / org-wide tools.
1199
+ *
1200
+ * Opens `~/.carto/org.db` read+write so any future tool that needs to
1201
+ * persist (none currently) doesn't have to switch modes. Returns a
1202
+ * graceful "no org" message when the org store is missing.
1203
+ */
1204
+ function runOrgTool(name, args) {
1205
+ const { OrgStore } = require('../org/store');
1206
+ const orgStore = OrgStore.openIfExists();
1207
+ if (!orgStore) {
1208
+ return text(`# Org tool: ${name}\n\nNo org store yet. Run \`carto org init\` then \`carto org add <name> <path>\` then \`carto org sync\` to register repos.`);
1209
+ }
1210
+ try {
1211
+ const q = require('../org/queries');
1212
+ if (name === 'get_org_architecture') {
1213
+ return text(formatOrgArchitecture(q.orgArchitectureOverview(orgStore)));
1214
+ }
1215
+ if (name === 'get_service_dependency_graph') {
1216
+ return text(formatServiceGraph(q.serviceDependencyGraph(orgStore)));
1217
+ }
1218
+ if (name === 'get_cross_repo_blast_radius') {
1219
+ return text(formatCrossRepoBlast(q.crossRepoBlastRadius(orgStore, args.repo)));
1220
+ }
1221
+ if (name === 'find_consumers_of_api') {
1222
+ return text(formatConsumers(q.findConsumersOfApi(orgStore, args.target), args.target));
1223
+ }
1224
+ if (name === 'get_org_domain_mapping') {
1225
+ return text(formatOrgDomainMapping(q.orgDomainMapping(orgStore)));
1226
+ }
1227
+ if (name === 'get_service_boundary_violations') {
1228
+ return text(formatBoundaryViolations(q.serviceBoundaryViolations(orgStore)));
1229
+ }
1230
+ if (name === 'get_microservices_migration_cut_points') {
1231
+ return text(formatMigrationCutPoints(q.microservicesMigrationCutPoints(orgStore)));
1232
+ }
1233
+ return text(`Unknown org tool: ${name}`);
1234
+ } finally {
1235
+ orgStore.close();
1236
+ }
1237
+ }
1238
+
1239
+ function formatOrgArchitecture(o) {
1240
+ const lines = ['# Org Architecture'];
1241
+ lines.push(`\n- **Repos:** ${o.summary.total_repos}`);
1242
+ lines.push(`- **Cross-repo edges:** ${o.summary.total_edges}`);
1243
+ if (o.summary.edges_by_kind && o.summary.edges_by_kind.length > 0) {
1244
+ lines.push('\n## Edges by kind');
1245
+ for (const e of o.summary.edges_by_kind) lines.push(`- ${e.edge_kind}: ${e.c}`);
1246
+ }
1247
+ if (o.repos && o.repos.length > 0) {
1248
+ lines.push('\n## Repos');
1249
+ lines.push('| Name | Root | Last sync |');
1250
+ lines.push('|------|------|-----------|');
1251
+ for (const r of o.repos) {
1252
+ const ls = r.last_sync_at ? new Date(r.last_sync_at).toISOString() : '—';
1253
+ lines.push(`| ${r.name} | ${r.root_path} | ${ls} |`);
1254
+ }
1255
+ }
1256
+ return lines.join('\n');
1257
+ }
1258
+
1259
+ function formatServiceGraph(g) {
1260
+ const lines = ['# Service Dependency Graph'];
1261
+ lines.push(`\n${g.nodes.length} repo(s); ${g.edges.length} aggregated edge(s).`);
1262
+ if (g.edges.length === 0) {
1263
+ lines.push('\n_No resolved cross-repo edges yet._');
1264
+ return lines.join('\n');
1265
+ }
1266
+ lines.push('\n| From | To | Kind | Count |');
1267
+ lines.push('|------|----|------|------:|');
1268
+ for (const e of g.edges) lines.push(`| ${e.from_repo} | ${e.to_repo} | ${e.edge_kind} | ${e.count} |`);
1269
+ return lines.join('\n');
1270
+ }
1271
+
1272
+ function formatCrossRepoBlast(r) {
1273
+ const lines = ['# Cross-Repo Blast Radius'];
1274
+ if (!r.downstream_repos || r.downstream_repos.length === 0) {
1275
+ lines.push('\n_No downstream consumers._');
1276
+ return lines.join('\n');
1277
+ }
1278
+ lines.push(`\nDownstream consumers (${r.downstream_repos.length}):`);
1279
+ for (const r2 of r.downstream_repos) lines.push(`- ${r2}`);
1280
+ if (r.paths && r.paths.length > 0) {
1281
+ lines.push('\n## Edge breakdown');
1282
+ lines.push('| Consumer | Kind | Count |');
1283
+ lines.push('|----------|------|------:|');
1284
+ for (const p of r.paths) lines.push(`| ${p.from_repo} | ${p.edge_kind} | ${p.count} |`);
1285
+ }
1286
+ return lines.join('\n');
1287
+ }
1288
+
1289
+ function formatConsumers(rows, target) {
1290
+ const lines = [`# Consumers of ${target || ''}`];
1291
+ if (!rows || rows.length === 0) { lines.push('\n_No consumers found._'); return lines.join('\n'); }
1292
+ lines.push(`\n${rows.length} consuming file(s).\n`);
1293
+ lines.push('| Repo | Kind | File |');
1294
+ lines.push('|------|------|------|');
1295
+ for (const r of rows.slice(0, 50)) lines.push(`| ${r.from_repo} | ${r.edge_kind} | ${r.from_file || '—'} |`);
1296
+ return lines.join('\n');
1297
+ }
1298
+
1299
+ function formatOrgDomainMapping(o) {
1300
+ const lines = ['# Org Domain Mapping'];
1301
+ if (!o.domains || o.domains.length === 0) {
1302
+ lines.push('\n_No domains found across org repos. Ensure each repo has been `carto sync`-ed first._');
1303
+ return lines.join('\n');
1304
+ }
1305
+ lines.push(`\n${o.domains.length} repo×domain entries.\n`);
1306
+ lines.push('| Repo | Domain | Files |');
1307
+ lines.push('|------|--------|------:|');
1308
+ for (const d of o.domains) lines.push(`| ${d.repo} | ${d.domain} | ${d.file_count} |`);
1309
+ return lines.join('\n');
1310
+ }
1311
+
1312
+ function formatBoundaryViolations(o) {
1313
+ const lines = ['# Service Boundary Violations'];
1314
+ if (!o.violations || o.violations.length === 0) {
1315
+ lines.push('\n_No boundary violations detected._');
1316
+ return lines.join('\n');
1317
+ }
1318
+ lines.push(`\n${o.violations.length} edge(s) reach into private surface.\n`);
1319
+ lines.push('| From repo | Target | Kind | File |');
1320
+ lines.push('|-----------|--------|------|------|');
1321
+ for (const v of o.violations.slice(0, 50)) lines.push(`| ${v.from_repo} | ${v.target} | ${v.edge_kind} | ${v.from_file || '—'} |`);
1322
+ return lines.join('\n');
1323
+ }
1324
+
1325
+ function formatMigrationCutPoints(o) {
1326
+ const lines = ['# Microservices Migration Cut-Points'];
1327
+ if (!o.order || o.order.length === 0) {
1328
+ lines.push('\n_No repos registered._');
1329
+ return lines.join('\n');
1330
+ }
1331
+ lines.push(`\nExtraction priority (high stability first; the producer repos to extract before their consumers):\n`);
1332
+ lines.push('| Repo | Incoming | Outgoing | Stability |');
1333
+ lines.push('|------|---------:|---------:|----------:|');
1334
+ for (const r of o.order) lines.push(`| ${r.repo} | ${r.incoming} | ${r.outgoing} | ${r.stability} |`);
1335
+ return lines.join('\n');
1336
+ }
1337
+
1338
+ /**
1339
+ * parseTimeRange("7d" | "24h" | "1h" | "30m" | "60s") → ms | null
1340
+ *
1341
+ * Small parser for the `get_recent_decisions` time_range arg.
1342
+ * Returns null on malformed input so the caller can surface a clear
1343
+ * error message instead of silently treating "auth" as 0ms.
1344
+ */
1345
+ function parseTimeRange(s) {
1346
+ if (typeof s !== 'string' || s.length === 0) return null;
1347
+ const m = s.trim().match(/^(\d+)\s*([smhdw])?$/i);
1348
+ if (!m) return null;
1349
+ const n = parseInt(m[1], 10);
1350
+ if (!Number.isFinite(n) || n < 0) return null;
1351
+ const unit = (m[2] || 'd').toLowerCase();
1352
+ const mult = unit === 's' ? 1000 :
1353
+ unit === 'm' ? 60_000 :
1354
+ unit === 'h' ? 3_600_000 :
1355
+ unit === 'd' ? 86_400_000 :
1356
+ unit === 'w' ? 604_800_000 : null;
1357
+ if (mult === null) return null;
1358
+ return n * mult;
1359
+ }
1360
+
1361
+ /**
1362
+ * summarizeDecisionPayload(json) → short string
1363
+ *
1364
+ * Renders a one-line summary of a `decisions.payload_json` row for the
1365
+ * Markdown tables. Defensive against missing/malformed JSON — never
1366
+ * throws, never echoes raw payload bytes.
1367
+ */
1368
+ function summarizeDecisionPayload(json) {
1369
+ if (!json) return '—';
1370
+ let obj;
1371
+ try { obj = JSON.parse(json); } catch { return '_(unparseable payload)_'; }
1372
+ if (!obj || typeof obj !== 'object') return '—';
1373
+ const parts = [];
1374
+ if (obj.risk) parts.push(`risk=${obj.risk}`);
1375
+ if (typeof obj.violationCount === 'number') parts.push(`violations=${obj.violationCount}`);
1376
+ if (typeof obj.blastUnion === 'number') parts.push(`blast=${obj.blastUnion}`);
1377
+ if (Array.isArray(obj.files) && obj.files.length > 0) {
1378
+ parts.push(`files=${obj.files.length === 1 ? obj.files[0] : `${obj.files.length}`}`);
1379
+ }
1380
+ return parts.length === 0 ? '—' : parts.join(', ');
1381
+ }
1382
+
1383
+ // ─── Tool definitions ───────────────────────────────────────────────────────
1384
+
1385
+ const TOOLS = [
1386
+ { name: 'get_routes', description: 'Get all API routes in this project including REST, tRPC, and webhooks.', inputSchema: { type: 'object', properties: {}, required: [] } },
1387
+ { name: 'get_blast_radius', description: 'Get all files, routes, and domains affected by changing a specific file. Includes risk level per route.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' } }, required: ['file'] } },
1388
+ { name: 'get_structure', description: 'Get project structure: import graph, entry points, high impact files, tech stack, and domains.', inputSchema: { type: 'object', properties: {}, required: [] } },
1389
+ { name: 'get_domain', description: 'Get all routes, models, functions, and context for a specific domain (AUTH, PAYMENTS, TRPC, DATABASE, EVENTS, NOTIFICATIONS, CORE).', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Domain name e.g. AUTH, PAYMENTS, DATABASE' } }, required: ['domain'] } },
1390
+ { name: 'get_neighbors', description: 'Get import graph neighbors of a file — files it imports and files that import it. Returns nodes and edges for visualization.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' }, hops: { type: 'number', description: 'How many hops to traverse (default 1, max 3)' } }, required: ['file'] } },
1391
+ { name: 'get_cross_domain', description: 'Get all import edges that cross domain boundaries — e.g. AUTH importing PAYMENTS. Use to detect unexpected coupling.', inputSchema: { type: 'object', properties: {}, required: [] } },
1392
+ { name: 'get_context', description: 'Get full structural context for a file: domain, blast radius, import neighbors, routes, models, env vars, and cross-domain dependencies. Single call for everything.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' } }, required: ['file'] } },
1393
+ { name: 'search_routes', description: 'Search API routes by path or method. Case-insensitive.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query e.g. "auth", "POST", "/api/users"' } }, required: ['query'] } },
1394
+ { name: 'get_models', description: 'Get all data models (Prisma, Pydantic, TypeScript interfaces, Zod schemas) across the project, optionally filtered by domain.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Optional domain filter e.g. AUTH, DATABASE' } }, required: [] } },
1395
+ { name: 'get_high_impact_files', description: 'Get the files with the highest blast radius — most other files depend on them. Changing these files is highest risk.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of files to return (default 10)' } }, required: [] } },
1396
+ { name: 'get_env_vars', description: 'Get all environment variables used in this project, with which files use them and which domains they belong to.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Optional domain filter e.g. AUTH, PAYMENTS' } }, required: [] } },
1397
+ { name: 'get_domains_list', description: 'Get all detected domains with file counts, route counts, and model counts.', inputSchema: { type: 'object', properties: {}, required: [] } },
1398
+ { name: 'get_architecture', description: 'Get a 500-word markdown summary of the project: domains, entry points, tech stack, key patterns, and size metrics. Use this as your first call when entering a new repo.', inputSchema: { type: 'object', properties: {}, required: [] } },
1399
+ { name: 'get_file_summary', description: 'Get a 3-sentence description of what a file does, its role in the project, and its key dependencies and dependents.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' } }, required: ['file'] } },
1400
+ { name: 'get_change_plan', description: 'Given a natural-language intent (e.g. "add rate limiting to /api/users"), returns: files to touch, domains affected, blast radius, and similar patterns in the codebase.', inputSchema: { type: 'object', properties: { intent: { type: 'string', description: 'Natural language description of the change you want to make' } }, required: ['intent'] } },
1401
+ { name: 'get_similar_patterns', description: 'Given a file, find structurally similar files — same import pattern, same route shape, or same domain. Use to find conventions to follow before writing new code.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' }, limit: { type: 'number', description: 'Max results to return (default 5)' } }, required: ['file'] } },
1402
+ { name: 'simulate_change_impact', description: 'Given a list of files, returns all files transitively affected by changing them simultaneously, with hop distance. Powered by the bitmap engine — only feasible at this speed (sub-millisecond) with bitmap OR-aggregation. Use when planning a refactor that touches multiple files.', inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'Array of relative file paths from project root' } }, required: ['files'] } },
1403
+ // ─── Validation API + Episodic Memory ─────────────────────────────
1404
+ { name: 'validate_diff', description: 'Given a unified diff, returns: violations (cross-domain imports, high-blast files), blast radius per file, risk level (SAFE/LOW/MEDIUM/HIGH), and suggestions. Sub-15ms p99 on a 7K-file repo. Each call is recorded in the episodic memory log so other tools can ask "did we discuss this?".', inputSchema: { type: 'object', properties: { diff: { type: 'string', description: 'Unified diff text (output of `git diff` / GitHub PR patch).' }, session_id: { type: 'number', description: 'Optional session id. Defaults to the most recent active session, or a fresh one.' } }, required: ['diff'] } },
1405
+ { name: 'get_recent_decisions', description: 'List recent validation decisions and architectural choices the AI has made in this project. Returns time-descending rows.', inputSchema: { type: 'object', properties: { time_range: { type: 'string', description: 'Time window like "7d", "24h", "1h" (default "7d").' }, kind: { type: 'string', description: 'Optional filter — e.g. "validation".' } }, required: [] } },
1406
+ { name: 'get_session_context', description: 'Full context for an AI session: every decision and every intervention, ordered chronologically. Use to recap what happened in a long-running session.', inputSchema: { type: 'object', properties: { session_id: { type: 'number', description: 'Session id. Defaults to the most recent active session.' } }, required: [] } },
1407
+ { name: 'did_we_discuss_this', description: 'Substring search over the episodic memory log (decisions + interventions) for prior discussions of a topic. Use to avoid re-deciding settled questions.', inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Topic to search for, e.g. "auth", "snake_case", "blast radius".' } }, required: ['topic'] } },
1408
+ { name: 'get_intervention_history', description: 'List interventions (Carto-issued violations and suggestions) optionally filtered by file. Use to see prior warnings on a file before editing it.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Optional file filter (relative path from project root).' } }, required: [] } },
1409
+
1410
+ // ─── Temporal layer ─────────────────────────────────────────────
1411
+ { name: 'get_architectural_drift', description: 'Per-domain growth/shrink and event count over a time window. Run `carto temporal init` first to backfill from git history.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Optional domain filter (e.g. AUTH).' }, time_range: { type: 'string', description: 'Window like "30d", "90d", "1y" (default "30d").' } }, required: [] } },
1412
+ { name: 'get_domain_evolution', description: 'Time-series of a single domain\'s file count, by snapshot. Use to chart a domain\'s growth over the last quarter.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Domain name (e.g. AUTH).' }, time_range: { type: 'string', description: 'Window like "30d", "90d" (default "90d").' } }, required: ['domain'] } },
1413
+ { name: 'get_hotspot_files', description: 'Top files by churn × blast_radius score over a window. The CodeHealth heuristic: high-churn files in high-blast-radius positions are where bugs cluster.', inputSchema: { type: 'object', properties: { time_range: { type: 'string', description: 'Window like "30d", "90d" (default "90d").' }, limit: { type: 'number', description: 'Max rows (default 20).' } }, required: [] } },
1414
+ { name: 'get_complexity_trend', description: 'A single file\'s presence across snapshots + commit count + current blast_radius. Use to track how a file\'s footprint evolved.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path.' }, time_range: { type: 'string', description: 'Window like "90d" (default "90d").' } }, required: ['file'] } },
1415
+ { name: 'get_churn_vs_blast_radius', description: 'Scatter data of churn vs blast_radius for every changed file in a window. Use to find risk hotspots.', inputSchema: { type: 'object', properties: { time_range: { type: 'string', description: 'Window like "90d" (default "90d").' } }, required: [] } },
1416
+ { name: 'get_arch_events', description: 'Architectural events (domain split, merge, growth, hotspot emergence). Severity filter: minor | major | critical.', inputSchema: { type: 'object', properties: { severity: { type: 'string', description: 'Filter: minor | major | critical.' }, kind: { type: 'string', description: 'Optional kind filter (e.g. domain_growth, hotspot_active).' }, time_range: { type: 'string', description: 'Window like "90d" (default "90d").' } }, required: [] } },
1417
+ { name: 'get_domain_health', description: 'Per-domain growth rate, instability, recent events, and hotspot files. Use to spot domains drifting out of bounds.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Optional domain filter.' } }, required: [] } },
1418
+ { name: 'get_temporal_context', description: 'A file\'s full temporal context: first_seen_ts, last_modified_ts, commit_count, blast_radius, recent events, age in days.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path.' } }, required: ['file'] } },
1419
+
1420
+ // ─── Brain: semantic + procedural + working memory ──────────────
1421
+ { name: 'get_invariants', description: 'Architectural invariants mined from the import graph: "Domain X never imports from Y", "Files in Z always export N symbols", etc. Confidence-scored.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Optional domain filter.' }, threshold: { type: 'number', description: 'Confidence threshold 0-1 (default 0.85).' } }, required: [] } },
1422
+ { name: 'get_canonical_pattern', description: 'Highest-quality example of a pattern in the codebase (e.g. canonical route handler). Use as a copy-paste template before writing similar code.', inputSchema: { type: 'object', properties: { pattern_type: { type: 'string', description: 'route_handler | model_definition' }, domain: { type: 'string', description: 'Optional domain filter.' } }, required: ['pattern_type'] } },
1423
+ { name: 'get_conventions', description: 'Naming + export + directory conventions that apply to a given file or directory. Confidence-scored. Use before writing new code in this location.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path or directory.' } }, required: [] } },
1424
+ { name: 'get_action_patterns', description: 'Procedural patterns mined from git history: "when developers add X, they also touch Y". Filter by natural-language intent.', inputSchema: { type: 'object', properties: { intent: { type: 'string', description: 'Optional intent filter (e.g. "add route").' } }, required: [] } },
1425
+ { name: 'scaffold_for_intent', description: 'For a natural-language intent ("add a payment route"), returns: anchor file + co-changed files + canonical pattern + conventions to follow. Combines invariants, conventions, and procedural memory.', inputSchema: { type: 'object', properties: { intent: { type: 'string', description: 'Natural-language description of the change.' } }, required: ['intent'] } },
1426
+ { name: 'get_working_memory', description: 'Live state snapshot: branch, HEAD, uncommitted files, recent decision count, open HIGH-severity warnings, recent drift. Read this at the start of every AI session.', inputSchema: { type: 'object', properties: {}, required: [] } },
1427
+ { name: 'get_pending_decisions', description: 'Recent decisions with pending/unresolved/HIGH-risk flags in their payload. Surfaces unfinished AI work from the episodic log.', inputSchema: { type: 'object', properties: { hours: { type: 'number', description: 'Lookback window in hours (default 6).' } }, required: [] } },
1428
+ { name: 'get_active_drift', description: 'Domains with active drift in the last 7d: growth, threshold breaches. Use to spot domains drifting before they reach a critical event.', inputSchema: { type: 'object', properties: { threshold: { type: 'number', description: 'Drift threshold 0-1 (default 0.2 = 20%).' } }, required: [] } },
1429
+ { name: 'get_active_suggestions', description: 'Active Suggestion Engine output. 4 triggers: cross-domain coupling jump, AI session conflict, convention violation mid-session, hotspot threshold crossed. Read periodically.', inputSchema: { type: 'object', properties: {}, required: [] } },
1430
+ { name: 'dismiss_suggestion', description: 'Mark a suggestion ID as dismissed for the current session. Acknowledgment-only; the underlying signal still exists.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Suggestion id from get_active_suggestions.' } }, required: ['id'] } },
1431
+
1432
+ // ─── AI-native primitives — 14 tools ────────────────────────────
1433
+ { name: 'get_minimal_context_for_intent', description: 'Token-budgeted context picker. Given a natural-language intent + a budget (default 4000 tokens), returns the minimum file set needed via hybrid retrieval (structural + lexical + semantic) with RRF fusion and high-blast / same-domain / recent-changes boosts. Reports per-file token cost.', inputSchema: { type: 'object', properties: { intent: { type: 'string', description: 'Natural-language description of the change.' }, budget_tokens: { type: 'number', description: 'Token budget (default 4000).' } }, required: ['intent'] } },
1434
+ { name: 'get_progressive_disclosure_tree', description: 'Pre-computed hierarchy: domain → top files per domain → per-file exports. Use as a structured table-of-contents for the codebase before drilling in.', inputSchema: { type: 'object', properties: {}, required: [] } },
1435
+ { name: 'get_token_budget_report', description: 'Diagnostic complement to get_minimal_context_for_intent. Returns context efficiency as a fraction of repo size (used / total tokens approx).', inputSchema: { type: 'object', properties: { intent: { type: 'string', description: 'Intent to budget for.' }, budget_tokens: { type: 'number' } }, required: [] } },
1436
+ { name: 'get_decision_log', description: 'Recent decisions from the episodic-memory log, optionally annotated with concurrent architectural events from the temporal store.', inputSchema: { type: 'object', properties: { hours: { type: 'number', description: 'Lookback hours (default 168 = 7d).' } }, required: [] } },
1437
+ { name: 'get_evolution_delta', description: 'Architectural delta across a time window (requires temporal store). Returns per-domain before/after file counts + event count.', inputSchema: { type: 'object', properties: { domain: { type: 'string' }, time_range: { type: 'string', description: 'Window like "30d", "90d" (default "30d").' } }, required: [] } },
1438
+ { name: 'get_change_velocity', description: 'Commits-per-day over a window (requires temporal store). Useful for spotting development tempo shifts.', inputSchema: { type: 'object', properties: { days: { type: 'number', description: 'Lookback days (default 30).' } }, required: [] } },
1439
+ { name: 'get_test_coverage_map', description: 'Surfaces files with no detected test alongside their blast radius. High-blast untested files are the riskiest.', inputSchema: { type: 'object', properties: {}, required: [] } },
1440
+ { name: 'get_safety_checklist', description: 'Per-file safety checklist: blast radius, cross-domain coupling, missing tests, temporal hotspot, unresolved interventions. Run before writing a high-impact change.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
1441
+ { name: 'get_data_flow', description: 'Per-file data-flow snapshot: upstream imports + downstream importers + routes + models + env vars in the file. The AI-friendly view, not full taint analysis.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
1442
+ { name: 'get_interface_contract', description: 'Exported symbols + models + routes the file exposes. Use to understand a module\'s public API before consuming it.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
1443
+ { name: 'explain_change_in_natural_language', description: 'Given a unified diff, returns a plain-language summary + risk + violation list + suggestions. Powered by validate_diff.', inputSchema: { type: 'object', properties: { diff: { type: 'string' } }, required: ['diff'] } },
1444
+ { name: 'get_stale_docs', description: 'Docs/markdown files older than 30 days. Heuristic surface for documentation that probably needs refresh.', inputSchema: { type: 'object', properties: {}, required: [] } },
1445
+ { name: 'get_dependency_surface', description: 'Deduped external dependencies + pinned versions across package.json, pyproject.toml, go.mod, etc. The "what does this project depend on" view.', inputSchema: { type: 'object', properties: {}, required: [] } },
1446
+ { name: 'get_upgrade_risk', description: 'Cross-references each external dep against the import graph. Returns usage count + domain count + LOW/MEDIUM/HIGH risk per dep. Use before bumping a dep version.', inputSchema: { type: 'object', properties: {}, required: [] } },
1447
+
1448
+ // ─── Adjacent positioning — 8 tools ─────────────────────────────
1449
+ { name: 'get_cross_language_call_graph', description: 'Match frontend HTTP fetches (fetch / axios / jQuery) to backend route handlers. Returns caller→callee pairs across language boundaries.', inputSchema: { type: 'object', properties: {}, required: [] } },
1450
+ { name: 'get_iac_resources', description: 'Surface Terraform / Helm / Pulumi / AWS CDK resources discovered in the repo. Returns kind, name, file, dependencies.', inputSchema: { type: 'object', properties: {}, required: [] } },
1451
+ { name: 'ingest_otlp_traces', description: 'Parse an OpenTelemetry OTLP JSON/JSONL trace file and aggregate per-route hit counts. Use the resulting counts with get_risk_weighted_blast_radius.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to OTLP file' } }, required: ['path'] } },
1452
+ { name: 'get_risk_weighted_blast_radius', description: 'Combine static dependents with runtime call counts (from ingest_otlp_traces or similar) to rank routes by real-world risk. `risk = dependents × runtime_calls + dependents`.', inputSchema: { type: 'object', properties: { otlp_path: { type: 'string', description: 'Optional OTLP file for runtime data.' } }, required: [] } },
1453
+ { name: 'get_dead_code_with_confidence', description: 'Files with zero static dependents AND (when runtime data is supplied) zero observed runtime hits. The "safe to delete" list.', inputSchema: { type: 'object', properties: { otlp_path: { type: 'string', description: 'Optional OTLP file for runtime confirmation.' } }, required: [] } },
1454
+ { name: 'get_hot_in_prod_no_tests', description: 'Files whose routes receive >0 runtime hits but have no detected test file. The "ship a test here first" list.', inputSchema: { type: 'object', properties: { otlp_path: { type: 'string', description: 'Path to OTLP file (required).' } }, required: ['otlp_path'] } },
1455
+ { name: 'get_semantic_diff', description: 'Beyond line-by-line: detect renames, symbol relocations across files, and new-domain introductions from a unified diff.', inputSchema: { type: 'object', properties: { diff: { type: 'string', description: 'Unified diff text.' } }, required: ['diff'] } },
1456
+ { name: 'get_llm_enrichment', description: 'Per-node summary via a local LLM. Opt-in only; returns disabled stub until `ai.llm` is wired in carto.config.json.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
1457
+
1458
+ // ─── Predictive — 7 tools ───────────────────────────────────────
1459
+ { name: 'get_predictive_risk', description: 'Predictive risk score per file: P(this file causes the next incident). Combines blast radius, churn, cross-domain coupling, intervention history, test presence into a 0-1 score.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Optional single-file filter; otherwise scores all files.' } }, required: [] } },
1460
+ { name: 'get_microservice_cut_points', description: 'Natural microservice cut-points: domains with high cohesion (intra-edges) AND low external coupling. Use to plan extraction-style refactors.', inputSchema: { type: 'object', properties: { threshold: { type: 'number', description: 'Cohesion threshold 0-1 (default 0.7).' } }, required: [] } },
1461
+ { name: 'validate_change', description: 'Pre-write governance: given a file + proposed full content, synthesizes a diff vs disk and runs validate_diff. Use in IDE onWillSaveTextDocument hooks.', inputSchema: { type: 'object', properties: { file: { type: 'string' }, content: { type: 'string' } }, required: ['file', 'content'] } },
1462
+ { name: 'get_file_ownership', description: 'Implicit ownership detection via `git blame`. Returns top author + per-author line counts. Fails soft if git is unavailable.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
1463
+ { name: 'get_cross_team_coupling', description: 'Cross-domain edges where the source-file owner differs from the target-file owner — surface for coordination warnings.', inputSchema: { type: 'object', properties: {}, required: [] } },
1464
+ { name: 'get_drift_digest', description: 'Weekly architectural digest: domain drift, hotspots, events, predicted-risk top 10. CLI-renderable markdown.', inputSchema: { type: 'object', properties: { time_range: { type: 'string', description: 'Window like "7d", "30d" (default "7d").' } }, required: [] } },
1465
+ { name: 'get_ai_cost_attribution', description: 'Per-AI-client decision counts + violation counts. Use to attribute cross-domain coupling cost to individual AI sessions / developers.', inputSchema: { type: 'object', properties: { hours: { type: 'number', description: 'Lookback hours (default 168 = 7d).' } }, required: [] } },
1466
+
1467
+ // ─── Cross-repo / Org-wide — 7 tools ────────────────────────────
1468
+ { name: 'get_org_architecture', description: 'Org-wide summary: registered repos + total cross-repo edge count + edges by kind. Requires `carto org init` + `carto org sync`.', inputSchema: { type: 'object', properties: {}, required: [] } },
1469
+ { name: 'get_service_dependency_graph', description: 'Aggregated cross-repo graph: each repo is a node, edges grouped by (from_repo, to_repo, edge_kind).', inputSchema: { type: 'object', properties: {}, required: [] } },
1470
+ { name: 'get_cross_repo_blast_radius', description: 'Direct downstream consumers of a producer repo. "If I break repo X, who notices?"', inputSchema: { type: 'object', properties: { repo: { type: 'string', description: 'Producer repo name' } }, required: ['repo'] } },
1471
+ { name: 'find_consumers_of_api', description: 'Across all org repos, find every file importing a given npm/pypi/go/maven target.', inputSchema: { type: 'object', properties: { target: { type: 'string', description: 'Target package or module name' } }, required: ['target'] } },
1472
+ { name: 'get_org_domain_mapping', description: 'Per-repo domain list across all org repos (reads each repo\'s carto.db if registered).', inputSchema: { type: 'object', properties: {}, required: [] } },
1473
+ { name: 'get_service_boundary_violations', description: 'Cross-repo edges that import private/internal surface (heuristic: target path contains internal / private / _lib).', inputSchema: { type: 'object', properties: {}, required: [] } },
1474
+ { name: 'get_microservices_migration_cut_points', description: 'Suggested microservices extraction order. Repos with high stability (more incoming than outgoing edges) extract first.', inputSchema: { type: 'object', properties: {}, required: [] } },
1475
+ ];
1476
+
1477
+ // ─── Server setup ─────────────────────────────────────────────────────────────
1478
+
1479
+ const server = new Server(
1480
+ { name: 'carto', version: '2.0.0' },
1481
+ { capabilities: { tools: {} } }
1482
+ );
1483
+
1484
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1485
+
1486
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1487
+ const { name, arguments: args } = request.params;
1488
+ // Normalize file-arg tools to the canonical SQLite-stored form so
1489
+ // `./lib/x.js`, absolute paths, and Windows separators all resolve.
1490
+ // Tools that don't take a `file` arg are unaffected.
1491
+ if (args && typeof args.file === 'string') {
1492
+ args.file = normalizeFileArg(projectRoot, args.file);
1493
+ // Lazy mtime+size check. Re-parse the requested file inline
1494
+ // if it's stale on disk (user edited but didn't commit). Best-effort:
1495
+ // failures here never block the answer.
1496
+ lazyReparseFile(args.file);
1497
+ }
1498
+ // Wrap entire handler body so any tool error (SQLite, null deref, bad
1499
+ // input) returns a structured error response instead of crashing the
1500
+ // MCP transport. An unhandled throw here would kill the stdio
1501
+ // connection and Claude Code/Kiro would surface
1502
+ // `-32000 Failed to reconnect`.
1503
+ try {
1504
+ const s = getStore();
1505
+ if (!s) return notIndexed();
1506
+
1507
+ if (name === 'get_routes') {
1508
+ const routes = s.getRoutes();
1509
+ if (routes.length === 0) return text('No routes found.');
1510
+ const lines = ['# All Routes\n', '| Method | Path | File |', '|--------|------|------|'];
1511
+ for (const r of routes) lines.push(`| ${r.method} | ${r.path} | ${r.file} |`);
1512
+ return text(lines.join('\n'));
1513
+ }
1514
+
1515
+ if (name === 'get_blast_radius') {
1516
+ // Bitmap path with SQLite fallback. Output shape and
1517
+ // formatting are identical between the two paths.
1518
+ const sidecar = getSidecar();
1519
+ const deps = sidecar
1520
+ ? bitmapTools.blastRadius(sidecar, args.file)
1521
+ : s.getBlastRadius(args.file);
1522
+ if (!deps) return text(`File not found in index: ${args.file}`);
1523
+ if (deps.length === 0) return text(`No dependents found for: ${args.file}`);
1524
+ const lines = [`# Blast Radius: ${args.file}\n`, `**Affected files:** ${deps.length}\n`];
1525
+ lines.push('| File | Hops |');
1526
+ lines.push('|------|------|');
1527
+ for (const d of deps) lines.push(`| ${d.file} | ${d.hop_distance} |`);
1528
+ return text(lines.join('\n'));
1529
+ }
1530
+
1531
+ if (name === 'get_structure') {
1532
+ const st = s.getStructure();
1533
+ const lines = ['# Project Structure\n'];
1534
+ if (st.stack.length > 0) lines.push(`**Stack:** ${st.stack.join(', ')}\n`);
1535
+ lines.push(`**Meta:** ${st.meta.totalFiles} files, ${st.meta.totalRoutes} routes, ${st.meta.totalImportEdges} import edges\n`);
1536
+ if (st.domains.length > 0) lines.push(`**Domains:** ${st.domains.join(', ')}\n`);
1537
+ if (st.entryPoints.length > 0) {
1538
+ lines.push('## Entry Points');
1539
+ for (const e of st.entryPoints) lines.push(`- ${e}`);
1540
+ lines.push('');
1541
+ }
1542
+ if (st.highImpact.length > 0) {
1543
+ lines.push('## High Impact Files');
1544
+ lines.push('| File | Dependents |');
1545
+ lines.push('|------|------------|');
1546
+ for (const h of st.highImpact) lines.push(`| ${h.file} | ${h.dependents} |`);
1547
+ }
1548
+ return text(lines.join('\n'));
1549
+ }
1550
+
1551
+ if (name === 'get_domain') {
1552
+ // Guard: AI clients sometimes call this with no/empty `domain` arg.
1553
+ // Without the guard we'd call args.domain.toUpperCase() on undefined and crash.
1554
+ if (!args.domain || typeof args.domain !== 'string') {
1555
+ return text('Missing required argument: domain. Use get_domains_list to see available domains.');
1556
+ }
1557
+ const domain = s.getDomain(args.domain);
1558
+ if (!domain) return text(`Domain not found: ${args.domain}. Use get_domains_list to see available domains.`);
1559
+
1560
+ const ctxPath = path.join(projectRoot, '.carto', 'context', `${args.domain.toUpperCase()}.md`);
1561
+ const lastSync = s.getMeta('last_full_sync');
1562
+
1563
+ // Check if cached context file is fresh
1564
+ let cacheIsFresh = false;
1565
+ if (fs.existsSync(ctxPath) && lastSync) {
1566
+ try {
1567
+ const fileMtime = fs.statSync(ctxPath).mtimeMs;
1568
+ const syncTime = new Date(lastSync).getTime();
1569
+ cacheIsFresh = fileMtime >= syncTime;
1570
+ } catch {}
1571
+ }
1572
+
1573
+ if (cacheIsFresh) {
1574
+ try { return text(fs.readFileSync(ctxPath, 'utf-8')); } catch {}
1575
+ }
1576
+
1577
+ // Regenerate lazily from DB
1578
+ const { formatDomainFile } = require('../agents/formatter');
1579
+ const cluster = {
1580
+ files: domain.files,
1581
+ routes: domain.routes.map(r => ({ ...r, functionName: r.handler_name || '' })),
1582
+ models: domain.models.map(m => ({
1583
+ ...m,
1584
+ className: m.name,
1585
+ // Wrap parse so one corrupt row doesn't take down the whole tool call.
1586
+ fields: (() => {
1587
+ if (!m.fields_json) return [];
1588
+ try { return JSON.parse(m.fields_json); } catch { return []; }
1589
+ })()
1590
+ })),
1591
+ functions: {},
1592
+ envVars: [],
1593
+ dbTables: [],
1594
+ fileMap: []
1595
+ };
1596
+ const content = formatDomainFile(args.domain.toUpperCase(), cluster);
1597
+
1598
+ // Cache to disk for next call
1599
+ try {
1600
+ fs.mkdirSync(path.dirname(ctxPath), { recursive: true });
1601
+ fs.writeFileSync(ctxPath, content, 'utf-8');
1602
+ } catch {}
1603
+
1604
+ return text(content);
1605
+ }
1606
+
1607
+ if (name === 'get_neighbors') {
1608
+ const hops = Math.min(args.hops || 1, 3);
1609
+ const nb = s.getNeighbors(args.file, hops);
1610
+ if (nb.nodes.length === 0) return text(`File not found or no neighbors: ${args.file}`);
1611
+ const lines = [`# Import Neighbors: ${args.file} (${hops} hop${hops > 1 ? 's' : ''})\n`];
1612
+ lines.push('| File | Domain | Root |');
1613
+ lines.push('|------|--------|------|');
1614
+ for (const n of nb.nodes) lines.push(`| ${n.id} | ${n.domain} | ${n.isRoot ? '✓' : ''} |`);
1615
+ lines.push('');
1616
+ lines.push(`## Edges (${nb.edges.length})`);
1617
+ for (const e of nb.edges.slice(0, 50)) lines.push(`- ${e.source} → ${e.target}`);
1618
+ if (nb.edges.length > 50) lines.push(`_...and ${nb.edges.length - 50} more_`);
1619
+ return text(lines.join('\n'));
1620
+ }
1621
+
1622
+ if (name === 'get_cross_domain') {
1623
+ // Bitmap path with SQLite fallback.
1624
+ const sidecar = getSidecar();
1625
+ const xd = sidecar ? bitmapTools.crossDomain(sidecar) : s.getCrossDomainDeps();
1626
+ if (xd.length === 0) return text('No cross-domain dependencies found.');
1627
+ const lines = [`# Cross-Domain Dependencies (${xd.length})\n`];
1628
+ lines.push('| From | From Domain | To | To Domain |');
1629
+ lines.push('|------|------------|-----|-----------|');
1630
+ for (const d of xd.slice(0, 100)) lines.push(`| ${d.from} | ${d.fromDomain} | ${d.to} | ${d.toDomain} |`);
1631
+ if (xd.length > 100) lines.push(`\n_...and ${xd.length - 100} more_`);
1632
+ return text(lines.join('\n'));
1633
+ }
1634
+
1635
+ if (name === 'get_context') {
1636
+ const file = s.getFileByPath(args.file);
1637
+ if (!file) return text(`File not found: ${args.file}`);
1638
+ const domain = s.getDomainForFile(args.file);
1639
+ const blastDeps = s.getBlastRadius(args.file) || [];
1640
+ const nb = s.getNeighbors(args.file, 2);
1641
+ const lines = [
1642
+ `# Context: ${args.file}\n`,
1643
+ `**Domain:** ${domain || 'CORE'}`,
1644
+ `**Blast radius:** ${blastDeps.length} dependent files\n`,
1645
+ ];
1646
+ if (nb.nodes.length > 1) {
1647
+ lines.push(`## Neighbors (2 hops): ${nb.nodes.length - 1} files`);
1648
+ for (const n of nb.nodes.filter(n => !n.isRoot).slice(0, 15)) {
1649
+ lines.push(`- ${n.id} [${n.domain}]`);
1650
+ }
1651
+ if (nb.nodes.length > 16) lines.push(`_...and ${nb.nodes.length - 16} more_`);
1652
+ }
1653
+ return text(lines.join('\n'));
1654
+ }
1655
+
1656
+ if (name === 'search_routes') {
1657
+ const results = s.searchRoutes(args.query);
1658
+ if (results.length === 0) return text(`No routes matching: ${args.query}`);
1659
+ const lines = [`# Routes matching "${args.query}" (${results.length})\n`];
1660
+ lines.push('| Method | Path | File |');
1661
+ lines.push('|--------|------|------|');
1662
+ for (const r of results) lines.push(`| ${r.method} | ${r.path} | ${r.file} |`);
1663
+ return text(lines.join('\n'));
1664
+ }
1665
+
1666
+ if (name === 'get_models') {
1667
+ const models = s.getModels(args.domain);
1668
+ if (models.length === 0) return text(args.domain ? `No models in domain: ${args.domain}` : 'No models found.');
1669
+ const lines = [`# Models${args.domain ? ` — ${args.domain}` : ''} (${models.length})\n`];
1670
+ lines.push('| Model | Kind | File |');
1671
+ lines.push('|-------|------|------|');
1672
+ for (const m of models) lines.push(`| ${m.name} | ${m.kind} | ${m.file} |`);
1673
+ return text(lines.join('\n'));
1674
+ }
1675
+
1676
+ if (name === 'get_high_impact_files') {
1677
+ // Bitmap path with SQLite fallback. The bitmap layer
1678
+ // pre-builds `popcountIndex` (sorted DESC by direct-dependent count)
1679
+ // at sidecar-build time so this becomes an O(1) array slice — much
1680
+ // faster than walking the reverse bitmaps + popcount per file at
1681
+ // query time.
1682
+ const sidecar = getSidecar();
1683
+ const limit = args.limit || 10;
1684
+ const files = sidecar
1685
+ ? bitmapTools.highImpactFiles(sidecar, limit)
1686
+ : s.getHighImpactFiles(limit);
1687
+ if (files.length === 0) return text('No high impact files found.');
1688
+ const lines = [`# High Impact Files\n`];
1689
+ lines.push('| File | Dependents |');
1690
+ lines.push('|------|------------|');
1691
+ for (const f of files) lines.push(`| ${f.file} | ${f.dependents} |`);
1692
+ return text(lines.join('\n'));
1693
+ }
1694
+
1695
+ if (name === 'get_env_vars') {
1696
+ const vars = s.getEnvVars(args.domain);
1697
+ if (vars.length === 0) return text('No env vars found.');
1698
+ const lines = [`# Environment Variables (${vars.length})\n`];
1699
+ lines.push('| Variable | File |');
1700
+ lines.push('|----------|------|');
1701
+ for (const v of vars) lines.push(`| ${v.name} | ${v.file} |`);
1702
+ return text(lines.join('\n'));
1703
+ }
1704
+
1705
+ if (name === 'get_domains_list') {
1706
+ const domains = s.getDomainsList();
1707
+ if (domains.length === 0) return text('No domains detected.');
1708
+ const lines = [`# Domains (${domains.length})\n`];
1709
+ lines.push('| Domain | Files | Routes | Models |');
1710
+ lines.push('|--------|-------|--------|--------|');
1711
+ for (const d of domains) lines.push(`| ${d.name} | ${d.fileCount} | ${d.routeCount} | ${d.modelCount} |`);
1712
+ return text(lines.join('\n'));
1713
+ }
1714
+
1715
+ if (name === 'get_architecture') {
1716
+ const st = s.getStructure();
1717
+ const domains = s.getDomainsList();
1718
+ const lines = ['# Project Architecture\n'];
1719
+
1720
+ // Stack + size
1721
+ if (st.stack.length > 0) lines.push(`**Stack:** ${st.stack.join(', ')}\n`);
1722
+ lines.push(`**Size:** ${st.meta.totalFiles} files · ${st.meta.totalRoutes} routes · ${st.meta.totalImportEdges} import edges\n`);
1723
+
1724
+ // Surface extractor failures so the agent knows the index
1725
+ // is partial (and which routes/models may be missing).
1726
+ const errCountRaw = s.getMeta('extraction_error_count');
1727
+ const errCount = errCountRaw ? parseInt(errCountRaw, 10) : 0;
1728
+ if (errCount > 0) {
1729
+ lines.push(`> ⚠️ **${errCount} extraction error${errCount === 1 ? '' : 's'}** — some files failed to parse and their routes/models are missing. Run \`carto check\` for details.\n`);
1730
+ }
1731
+
1732
+ // Surface unavailable grammars so the agent knows which
1733
+ // languages have reduced extraction accuracy.
1734
+ const unavailRaw = s.getMeta('unavailable_languages_json');
1735
+ const unavailLangs = unavailRaw ? (() => { try { return JSON.parse(unavailRaw); } catch { return []; } })() : [];
1736
+ if (unavailLangs.length > 0) {
1737
+ lines.push(`> ⚠️ **${unavailLangs.length} language grammar${unavailLangs.length === 1 ? '' : 's'} unavailable** (${unavailLangs.join(', ')}) — these languages use regex-only extraction with reduced accuracy.\n`);
1738
+ }
1739
+
1740
+ // Domains
1741
+ if (domains.length > 0) {
1742
+ lines.push('## Domains\n');
1743
+ for (const d of domains) {
1744
+ if (d.fileCount === 0) continue;
1745
+ lines.push(`**${d.name}** — ${d.fileCount} files${d.routeCount > 0 ? `, ${d.routeCount} routes` : ''}${d.modelCount > 0 ? `, ${d.modelCount} models` : ''}`);
1746
+ }
1747
+ lines.push('');
1748
+ }
1749
+
1750
+ // Entry points
1751
+ if (st.entryPoints.length > 0) {
1752
+ lines.push('## Entry Points\n');
1753
+ for (const e of st.entryPoints.slice(0, 10)) lines.push(`- \`${e}\``);
1754
+ lines.push('');
1755
+ }
1756
+
1757
+ // High impact files
1758
+ if (st.highImpact.length > 0) {
1759
+ lines.push('## Highest Impact Files\n');
1760
+ lines.push('These files have the most dependents — changes here carry the highest risk:\n');
1761
+ for (const h of st.highImpact.slice(0, 10)) {
1762
+ lines.push(`- \`${h.file}\` (${h.dependents} dependents)`);
1763
+ }
1764
+ lines.push('');
1765
+ }
1766
+
1767
+ // Routes summary
1768
+ if (st.meta.totalRoutes > 0) {
1769
+ const routes = s.getRoutes().slice(0, 8);
1770
+ lines.push('## Sample Routes\n');
1771
+ for (const r of routes) lines.push(`- \`${r.method} ${r.path}\` → \`${r.file}\``);
1772
+ if (st.meta.totalRoutes > 8) lines.push(`_...and ${st.meta.totalRoutes - 8} more_`);
1773
+ lines.push('');
1774
+ }
1775
+
1776
+ lines.push(`_Last indexed: ${st.meta.lastIndexed || 'unknown'}_`);
1777
+ return text(lines.join('\n'));
1778
+ }
1779
+
1780
+ if (name === 'get_file_summary') {
1781
+ const file = s.getFileByPath(args.file);
1782
+ if (!file) return text(`File not found in index: ${args.file}`);
1783
+
1784
+ const domain = s.getDomainForFile(args.file) || 'CORE';
1785
+ const blastDeps = s.getBlastRadius(args.file) || [];
1786
+ const nb = s.getNeighbors(args.file, 1);
1787
+
1788
+ // Gather symbols
1789
+ const symbols = s.db.prepare(
1790
+ 'SELECT name, kind FROM symbols WHERE file_id = ? AND exported = 1 LIMIT 5'
1791
+ ).all(file.id);
1792
+
1793
+ // Outgoing imports (what this file depends on)
1794
+ const outgoing = nb.edges
1795
+ .filter(e => e.source === args.file)
1796
+ .map(e => e.target)
1797
+ .slice(0, 4);
1798
+
1799
+ // Incoming imports (what depends on this file)
1800
+ const incoming = nb.edges
1801
+ .filter(e => e.target === args.file)
1802
+ .map(e => e.source)
1803
+ .slice(0, 4);
1804
+
1805
+ const lines = [`# File: ${args.file}\n`];
1806
+ lines.push(`**Domain:** ${domain} · **Dependents:** ${blastDeps.length} files\n`);
1807
+
1808
+ if (symbols.length > 0) {
1809
+ lines.push(`**Exports:** ${symbols.map(s => `\`${s.name}\` (${s.kind})`).join(', ')}\n`);
1810
+ }
1811
+
1812
+ if (outgoing.length > 0) {
1813
+ lines.push(`**Imports:** ${outgoing.map(f => `\`${f}\``).join(', ')}`);
1814
+ }
1815
+ if (incoming.length > 0) {
1816
+ lines.push(`**Imported by:** ${incoming.map(f => `\`${f}\``).join(', ')}`);
1817
+ }
1818
+
1819
+ return text(lines.join('\n'));
1820
+ }
1821
+
1822
+ if (name === 'get_change_plan') {
1823
+ const { planChange, formatPlanMarkdown } = require('./change-plan');
1824
+ return text(formatPlanMarkdown(planChange(s, args.intent || '')));
1825
+ }
1826
+
1827
+ if (name === 'get_similar_patterns') {
1828
+ // Bitmap path: Jaccard similarity over forward-import sets.
1829
+ // Different semantics from the legacy 3-strategy SQL (same domain /
1830
+ // same routes / shared imports) but the standard graph similarity
1831
+ // metric and ~100× faster on large repos. Falls back to SQLite when
1832
+ // bitmap is unavailable.
1833
+ const sidecar = getSidecar();
1834
+ if (sidecar) {
1835
+ const limit = Math.min(args.limit || 5, 20);
1836
+ const results = bitmapTools.similarPatterns(sidecar, args.file, limit);
1837
+ if (results === null) return text(`File not found in index: ${args.file}`);
1838
+ const lines = [`# Similar Patterns to: ${args.file}\n`];
1839
+ if (results.length === 0) {
1840
+ lines.push('_No similar files found — this file has no resolved imports to compare against._');
1841
+ return text(lines.join('\n'));
1842
+ }
1843
+ lines.push('Files ranked by Jaccard similarity over their import sets:\n');
1844
+ lines.push('| File | Score | Shared Imports |');
1845
+ lines.push('|------|-------|----------------|');
1846
+ for (const r of results) {
1847
+ lines.push(`| \`${r.file}\` | ${r.score.toFixed(2)} | ${r.shared} |`);
1848
+ }
1849
+ return text(lines.join('\n'));
1850
+ }
1851
+
1852
+ // SQLite fallback path — kept for the (rare) case bitmap load fails.
1853
+ const file = s.getFileByPath(args.file);
1854
+ if (!file) return text(`File not found in index: ${args.file}`);
1855
+
1856
+ const limit = Math.min(args.limit || 5, 20);
1857
+ const domain = s.getDomainForFile(args.file);
1858
+
1859
+ // Get this file's imports and routes for comparison
1860
+ const fileImports = s.db.prepare(
1861
+ 'SELECT to_path FROM imports WHERE from_file_id = ?'
1862
+ ).all(file.id).map(r => r.to_path);
1863
+
1864
+ const fileRoutes = s.db.prepare(
1865
+ 'SELECT method, path FROM routes WHERE file_id = ?'
1866
+ ).all(file.id);
1867
+
1868
+ const fileSymbols = s.db.prepare(
1869
+ 'SELECT name, kind FROM symbols WHERE file_id = ? AND exported = 1 LIMIT 10'
1870
+ ).all(file.id);
1871
+
1872
+ const lines = [`# Similar Patterns to: ${args.file}\n`];
1873
+
1874
+ // Strategy 1: Files in same domain with similar import count
1875
+ if (domain) {
1876
+ const domainFiles = s.db.prepare(`
1877
+ SELECT f.path, f.language,
1878
+ (SELECT COUNT(*) FROM imports WHERE from_file_id = f.id) as import_count,
1879
+ (SELECT COUNT(*) FROM routes WHERE file_id = f.id) as route_count
1880
+ FROM files f
1881
+ JOIN domain_assignments da ON da.file_id = f.id
1882
+ JOIN domains d ON da.domain_id = d.id
1883
+ WHERE d.name = ? AND f.path != ?
1884
+ ORDER BY ABS(import_count - ?) ASC
1885
+ LIMIT ?
1886
+ `).all(domain, args.file, fileImports.length, limit);
1887
+
1888
+ if (domainFiles.length > 0) {
1889
+ lines.push(`## Files in same domain (${domain}) with similar structure\n`);
1890
+ lines.push('| File | Language | Imports | Routes |');
1891
+ lines.push('|------|----------|---------|--------|');
1892
+ for (const f of domainFiles) {
1893
+ lines.push(`| \`${f.path}\` | ${f.language} | ${f.import_count} | ${f.route_count} |`);
1894
+ }
1895
+ lines.push('');
1896
+ }
1897
+ }
1898
+
1899
+ // Strategy 2: Files with same route patterns (same HTTP methods)
1900
+ if (fileRoutes.length > 0) {
1901
+ const methods = [...new Set(fileRoutes.map(r => r.method))];
1902
+ const methodPlaceholders = methods.map(() => '?').join(',');
1903
+ const similarRouteFiles = s.db.prepare(`
1904
+ SELECT DISTINCT f.path, COUNT(r.id) as route_count
1905
+ FROM files f
1906
+ JOIN routes r ON r.file_id = f.id
1907
+ WHERE r.method IN (${methodPlaceholders}) AND f.path != ?
1908
+ GROUP BY f.id
1909
+ ORDER BY ABS(route_count - ?) ASC
1910
+ LIMIT ?
1911
+ `).all(...methods, args.file, fileRoutes.length, limit);
1912
+
1913
+ if (similarRouteFiles.length > 0) {
1914
+ lines.push(`## Files with similar route patterns (${methods.join(', ')})\n`);
1915
+ for (const f of similarRouteFiles) {
1916
+ lines.push(`- \`${f.path}\` (${f.route_count} routes)`);
1917
+ }
1918
+ lines.push('');
1919
+ }
1920
+ }
1921
+
1922
+ // Strategy 3: Files with overlapping imports (shared dependencies)
1923
+ if (fileImports.length > 0) {
1924
+ const importPaths = fileImports.slice(0, 5);
1925
+ const placeholders = importPaths.map(() => '?').join(',');
1926
+ const sharedImportFiles = s.db.prepare(`
1927
+ SELECT f.path, COUNT(DISTINCT i.to_path) as shared_count
1928
+ FROM files f
1929
+ JOIN imports i ON i.from_file_id = f.id
1930
+ WHERE i.to_path IN (${placeholders}) AND f.path != ?
1931
+ GROUP BY f.id
1932
+ HAVING shared_count >= 2
1933
+ ORDER BY shared_count DESC
1934
+ LIMIT ?
1935
+ `).all(...importPaths, args.file, limit);
1936
+
1937
+ if (sharedImportFiles.length > 0) {
1938
+ lines.push('## Files sharing common dependencies\n');
1939
+ for (const f of sharedImportFiles) {
1940
+ lines.push(`- \`${f.path}\` (${f.shared_count} shared imports)`);
1941
+ }
1942
+ lines.push('');
1943
+ }
1944
+ }
1945
+
1946
+ if (lines.length === 1) {
1947
+ lines.push('_No similar patterns found. The file may be unique in the codebase._');
1948
+ }
1949
+
1950
+ return text(lines.join('\n'));
1951
+ }
1952
+
1953
+ if (name === 'simulate_change_impact') {
1954
+ // Returns the union of every transitively
1955
+ // affected file when a *set* of files changes simultaneously. Only
1956
+ // feasible with bitmaps: an N×SQL `getBlastRadius` approach takes
1957
+ // hundreds of milliseconds on large repos; bitmap OR-aggregate runs
1958
+ // in microseconds. If the bitmap engine is unavailable for any
1959
+ // reason, surface a clear "unsupported" error rather than an
1960
+ // O(N×F×E) SQL fallback that would block the agent.
1961
+ if (!Array.isArray(args.files) || args.files.length === 0) {
1962
+ return text('Missing or empty argument: files (array of relative paths from project root).');
1963
+ }
1964
+
1965
+ // Normalize each input path the same way the single-file tools do,
1966
+ // and run lazy mtime check so any locally-edited input file is
1967
+ // re-parsed before we read the index. The lazy reparse will
1968
+ // invalidate the bitmap singleton if it triggers — getSidecar()
1969
+ // below picks that up.
1970
+ const normalizedFiles = [];
1971
+ for (const f of args.files) {
1972
+ if (typeof f !== 'string' || f.length === 0) continue;
1973
+ const norm = normalizeFileArg(projectRoot, f);
1974
+ normalizedFiles.push(norm);
1975
+ lazyReparseFile(norm);
1976
+ }
1977
+ if (normalizedFiles.length === 0) {
1978
+ return text('No valid file paths in `files` argument.');
1979
+ }
1980
+
1981
+ const sidecar = getSidecar();
1982
+ if (!sidecar) {
1983
+ return text(
1984
+ '`simulate_change_impact` requires the bitmap engine, which failed to load. ' +
1985
+ 'Run `carto sync` to rebuild `.carto/bitmap.bin`.'
1986
+ );
1987
+ }
1988
+
1989
+ const result = bitmapTools.simulateChangeImpact(sidecar, normalizedFiles);
1990
+ const lines = [
1991
+ `# Simulate Change Impact\n`,
1992
+ `Changing **${normalizedFiles.length}** file${normalizedFiles.length === 1 ? '' : 's'} ` +
1993
+ `simultaneously affects **${result.count}** transitive dependent` +
1994
+ `${result.count === 1 ? '' : 's'}.\n`,
1995
+ ];
1996
+ lines.push('## Input files\n');
1997
+ for (const f of normalizedFiles) lines.push(`- \`${f}\``);
1998
+ lines.push('');
1999
+ if (result.count === 0) {
2000
+ lines.push('_No additional files would be affected. None of the input files have dependents in the index._');
2001
+ } else {
2002
+ lines.push('## Affected files\n');
2003
+ lines.push('| File | Min Hop |');
2004
+ lines.push('|------|---------|');
2005
+ for (const r of result.files.slice(0, 200)) {
2006
+ lines.push(`| \`${r.file}\` | ${r.hop_distance} |`);
2007
+ }
2008
+ if (result.count > 200) lines.push(`\n_...and ${result.count - 200} more._`);
2009
+ }
2010
+ return text(lines.join('\n'));
2011
+ }
2012
+
2013
+ // ─── Validation API + Episodic Memory ─────────────────────────────
2014
+
2015
+ if (name === 'validate_diff') {
2016
+ if (!args || typeof args.diff !== 'string' || args.diff.length === 0) {
2017
+ return text('Missing required argument: diff (unified diff text).');
2018
+ }
2019
+ const sidecar = getSidecar();
2020
+ const result = validateDiff(s, sidecar, args.diff);
2021
+
2022
+ // Persist via brief writer connection. Don't fail the
2023
+ // user-facing response if the audit log write fails (read-only FS,
2024
+ // disk full, schema migration in flight). Per-call `session_id`
2025
+ // override falls through to "create a session if none exists".
2026
+ withWriter((writer) => {
2027
+ let sessionId = args.session_id;
2028
+ if (typeof sessionId !== 'number' || sessionId <= 0) {
2029
+ const session = writer.getOrCreateActiveSession('mcp');
2030
+ sessionId = session.id;
2031
+ }
2032
+ recordSideEffects(writer, sessionId, args.diff, result);
2033
+ });
2034
+
2035
+ // Render a markdown response. The shape is the visible artifact —
2036
+ // every AI tool the user runs will see this output.
2037
+ const lines = ['# Diff Validation\n'];
2038
+ const riskBadge = {
2039
+ SAFE: '🟢 SAFE',
2040
+ LOW: '🟡 LOW',
2041
+ MEDIUM: '🟠 MEDIUM',
2042
+ HIGH: '🔴 HIGH',
2043
+ }[result.risk] || result.risk;
2044
+ lines.push(`**Risk:** ${riskBadge}`);
2045
+ lines.push(`**Files changed:** ${result.diff.length}`);
2046
+ lines.push(`**Union blast radius:** ${result.blast_radius.union} transitive dependents\n`);
2047
+
2048
+ if (result.diff.length > 0) {
2049
+ lines.push('## Files\n');
2050
+ lines.push('| File | Kind | +Lines | -Lines | Blast |');
2051
+ lines.push('|------|------|-------:|-------:|------:|');
2052
+ for (const d of result.diff) {
2053
+ const blast = result.blast_radius.perFile[d.path] || 0;
2054
+ lines.push(`| \`${d.path}\` | ${d.kind} | ${d.addedCount} | ${d.removedCount} | ${blast} |`);
2055
+ }
2056
+ lines.push('');
2057
+ }
2058
+
2059
+ if (result.violations.length > 0) {
2060
+ lines.push(`## Violations (${result.violations.length})\n`);
2061
+ lines.push('| Severity | Kind | File | Detail |');
2062
+ lines.push('|----------|------|------|--------|');
2063
+ for (const v of result.violations) {
2064
+ lines.push(`| ${v.severity} | ${v.kind} | \`${v.file}\` | ${v.message} |`);
2065
+ }
2066
+ lines.push('');
2067
+ } else {
2068
+ lines.push('_No violations detected._\n');
2069
+ }
2070
+
2071
+ if (result.suggestions.length > 0) {
2072
+ lines.push(`## Suggestions (${result.suggestions.length})\n`);
2073
+ for (const sug of result.suggestions) {
2074
+ lines.push(`- **${sug.kind}** on \`${sug.file}\`: ${sug.message}`);
2075
+ }
2076
+ lines.push('');
2077
+ }
2078
+
2079
+ return text(lines.join('\n'));
2080
+ }
2081
+
2082
+ if (name === 'get_recent_decisions') {
2083
+ const range = (args && args.time_range) || '7d';
2084
+ const ms = parseTimeRange(range);
2085
+ if (ms === null) {
2086
+ return text(`Invalid time_range: "${range}". Use formats like "7d", "24h", "1h".`);
2087
+ }
2088
+ const kind = args && args.kind ? String(args.kind) : null;
2089
+ const rows = s.getRecentDecisions(ms, kind);
2090
+ if (rows.length === 0) {
2091
+ return text(`No decisions in the last ${range}${kind ? ` (kind=${kind})` : ''}.`);
2092
+ }
2093
+ const lines = [`# Recent Decisions (last ${range}${kind ? `, kind=${kind}` : ''})\n`];
2094
+ lines.push(`**${rows.length}** decision${rows.length === 1 ? '' : 's'} found.\n`);
2095
+ lines.push('| When | Kind | File | Summary |');
2096
+ lines.push('|------|------|------|---------|');
2097
+ for (const r of rows.slice(0, 50)) {
2098
+ const when = new Date(r.ts).toISOString();
2099
+ const summary = summarizeDecisionPayload(r.payload_json);
2100
+ lines.push(`| ${when} | ${r.kind} | ${r.file ? `\`${r.file}\`` : '—'} | ${summary} |`);
2101
+ }
2102
+ if (rows.length > 50) lines.push(`\n_...and ${rows.length - 50} more._`);
2103
+ return text(lines.join('\n'));
2104
+ }
2105
+
2106
+ if (name === 'get_session_context') {
2107
+ let sessionId = args && args.session_id;
2108
+ if (typeof sessionId !== 'number' || sessionId <= 0) {
2109
+ const cur = s.getCurrentSession();
2110
+ if (!cur) return text('No active session found. Run a tool that creates one (e.g. `validate_diff`) first.');
2111
+ sessionId = cur.id;
2112
+ }
2113
+ const ctx = s.getSessionContext(sessionId);
2114
+ if (!ctx) return text(`Session not found: ${sessionId}`);
2115
+ const lines = [`# Session ${ctx.session.id}\n`];
2116
+ lines.push(`**Started:** ${new Date(ctx.session.started_at).toISOString()}`);
2117
+ if (ctx.session.ended_at) {
2118
+ lines.push(`**Ended:** ${new Date(ctx.session.ended_at).toISOString()}`);
2119
+ } else {
2120
+ lines.push('**Ended:** _(active)_');
2121
+ }
2122
+ if (ctx.session.client_name) lines.push(`**Client:** ${ctx.session.client_name}`);
2123
+ lines.push('');
2124
+ lines.push(`## Decisions (${ctx.decisions.length})\n`);
2125
+ if (ctx.decisions.length === 0) {
2126
+ lines.push('_None._\n');
2127
+ } else {
2128
+ lines.push('| When | Kind | File | Summary |');
2129
+ lines.push('|------|------|------|---------|');
2130
+ for (const d of ctx.decisions.slice(0, 50)) {
2131
+ const when = new Date(d.ts).toISOString();
2132
+ const summary = summarizeDecisionPayload(d.payload_json);
2133
+ lines.push(`| ${when} | ${d.kind} | ${d.file ? `\`${d.file}\`` : '—'} | ${summary} |`);
2134
+ }
2135
+ if (ctx.decisions.length > 50) lines.push(`\n_...and ${ctx.decisions.length - 50} more._`);
2136
+ lines.push('');
2137
+ }
2138
+ lines.push(`## Interventions (${ctx.interventions.length})\n`);
2139
+ if (ctx.interventions.length === 0) {
2140
+ lines.push('_None._');
2141
+ } else {
2142
+ lines.push('| When | Severity | Kind | File | Message |');
2143
+ lines.push('|------|----------|------|------|---------|');
2144
+ for (const iv of ctx.interventions.slice(0, 50)) {
2145
+ const when = new Date(iv.ts).toISOString();
2146
+ lines.push(`| ${when} | ${iv.severity || '—'} | ${iv.kind} | ${iv.file ? `\`${iv.file}\`` : '—'} | ${iv.message || ''} |`);
2147
+ }
2148
+ if (ctx.interventions.length > 50) lines.push(`\n_...and ${ctx.interventions.length - 50} more._`);
2149
+ }
2150
+ return text(lines.join('\n'));
2151
+ }
2152
+
2153
+ if (name === 'did_we_discuss_this') {
2154
+ if (!args || typeof args.topic !== 'string' || args.topic.length === 0) {
2155
+ return text('Missing required argument: topic (string).');
2156
+ }
2157
+ const decisions = s.searchDecisions(args.topic);
2158
+ const interventions = s.searchInterventions(args.topic);
2159
+ if (decisions.length === 0 && interventions.length === 0) {
2160
+ return text(`No prior discussion of "${args.topic}" found in the episodic memory log.`);
2161
+ }
2162
+ const lines = [`# Prior discussions of "${args.topic}"\n`];
2163
+ if (decisions.length > 0) {
2164
+ lines.push(`## Decisions (${decisions.length})\n`);
2165
+ lines.push('| When | Session | Kind | File | Summary |');
2166
+ lines.push('|------|---------|------|------|---------|');
2167
+ for (const d of decisions.slice(0, 25)) {
2168
+ const when = new Date(d.ts).toISOString();
2169
+ const summary = summarizeDecisionPayload(d.payload_json);
2170
+ lines.push(`| ${when} | ${d.session_id || '—'} | ${d.kind} | ${d.file ? `\`${d.file}\`` : '—'} | ${summary} |`);
2171
+ }
2172
+ if (decisions.length > 25) lines.push(`\n_...and ${decisions.length - 25} more._`);
2173
+ lines.push('');
2174
+ }
2175
+ if (interventions.length > 0) {
2176
+ lines.push(`## Interventions (${interventions.length})\n`);
2177
+ lines.push('| When | Session | Severity | Kind | File | Message |');
2178
+ lines.push('|------|---------|----------|------|------|---------|');
2179
+ for (const iv of interventions.slice(0, 25)) {
2180
+ const when = new Date(iv.ts).toISOString();
2181
+ lines.push(`| ${when} | ${iv.session_id || '—'} | ${iv.severity || '—'} | ${iv.kind} | ${iv.file ? `\`${iv.file}\`` : '—'} | ${iv.message || ''} |`);
2182
+ }
2183
+ if (interventions.length > 25) lines.push(`\n_...and ${interventions.length - 25} more._`);
2184
+ }
2185
+ return text(lines.join('\n'));
2186
+ }
2187
+
2188
+ if (name === 'get_intervention_history') {
2189
+ const file = args && args.file ? args.file : null;
2190
+ const rows = s.getInterventionsForFile(file);
2191
+ if (rows.length === 0) {
2192
+ return text(file ? `No interventions for \`${file}\`.` : 'No interventions in the log.');
2193
+ }
2194
+ const lines = [`# Intervention History${file ? `: \`${file}\`` : ''}\n`];
2195
+ lines.push(`**${rows.length}** intervention${rows.length === 1 ? '' : 's'} found.\n`);
2196
+ lines.push('| When | Severity | Kind | File | Message |');
2197
+ lines.push('|------|----------|------|------|---------|');
2198
+ for (const iv of rows.slice(0, 100)) {
2199
+ const when = new Date(iv.ts).toISOString();
2200
+ lines.push(`| ${when} | ${iv.severity || '—'} | ${iv.kind} | ${iv.file ? `\`${iv.file}\`` : '—'} | ${iv.message || ''} |`);
2201
+ }
2202
+ if (rows.length > 100) lines.push(`\n_...and ${rows.length - 100} more._`);
2203
+ return text(lines.join('\n'));
2204
+ }
2205
+
2206
+ // ─── Temporal layer tool dispatch ─────────────────────────────────
2207
+ // All temporal tools share the same shape: open the temporal store
2208
+ // readonly, run the query, format markdown. Missing temporal DB is
2209
+ // graceful — we tell the user to run `carto temporal init`.
2210
+ const TEMPORAL_TOOLS = new Set([
2211
+ 'get_architectural_drift', 'get_domain_evolution', 'get_hotspot_files',
2212
+ 'get_complexity_trend', 'get_churn_vs_blast_radius', 'get_arch_events',
2213
+ 'get_domain_health', 'get_temporal_context',
2214
+ ]);
2215
+ if (TEMPORAL_TOOLS.has(name)) {
2216
+ return runTemporalTool(name, args || {});
2217
+ }
2218
+
2219
+ // ─── Brain tool dispatch (semantic + procedural + working) ───────
2220
+ const BRAIN_TOOLS = new Set([
2221
+ 'get_invariants', 'get_canonical_pattern', 'get_conventions',
2222
+ 'get_action_patterns', 'scaffold_for_intent', 'get_working_memory',
2223
+ 'get_pending_decisions', 'get_active_drift', 'get_active_suggestions',
2224
+ 'dismiss_suggestion',
2225
+ ]);
2226
+ if (BRAIN_TOOLS.has(name)) {
2227
+ return runBrainTool(name, args || {});
2228
+ }
2229
+
2230
+ // ─── AI-native primitives ────────────────────────────────────────
2231
+ const AI_TOOLS = new Set([
2232
+ 'get_minimal_context_for_intent', 'get_progressive_disclosure_tree',
2233
+ 'get_token_budget_report', 'get_decision_log', 'get_evolution_delta',
2234
+ 'get_change_velocity', 'get_test_coverage_map', 'get_safety_checklist',
2235
+ 'get_data_flow', 'get_interface_contract', 'explain_change_in_natural_language',
2236
+ 'get_stale_docs', 'get_dependency_surface', 'get_upgrade_risk',
2237
+ ]);
2238
+ if (AI_TOOLS.has(name)) {
2239
+ return runAiTool(name, args || {});
2240
+ }
2241
+
2242
+ // ─── Adjacent positioning ────────────────────────────────────────
2243
+ const ADJ_TOOLS = new Set([
2244
+ 'get_cross_language_call_graph', 'get_iac_resources',
2245
+ 'ingest_otlp_traces', 'get_risk_weighted_blast_radius',
2246
+ 'get_dead_code_with_confidence', 'get_hot_in_prod_no_tests',
2247
+ 'get_semantic_diff', 'get_llm_enrichment',
2248
+ ]);
2249
+ if (ADJ_TOOLS.has(name)) {
2250
+ return runAdjacentTool(name, args || {});
2251
+ }
2252
+
2253
+ // ─── Predictive ──────────────────────────────────────────────────
2254
+ const PRED_TOOLS = new Set([
2255
+ 'get_predictive_risk', 'get_microservice_cut_points', 'validate_change',
2256
+ 'get_file_ownership', 'get_cross_team_coupling', 'get_drift_digest',
2257
+ 'get_ai_cost_attribution',
2258
+ ]);
2259
+ if (PRED_TOOLS.has(name)) {
2260
+ return runPredictiveTool(name, args || {});
2261
+ }
2262
+
2263
+ // ─── Cross-repo / Org-wide ───────────────────────────────────────
2264
+ const ORG_TOOLS = new Set([
2265
+ 'get_org_architecture', 'get_service_dependency_graph',
2266
+ 'get_cross_repo_blast_radius', 'find_consumers_of_api',
2267
+ 'get_org_domain_mapping', 'get_service_boundary_violations',
2268
+ 'get_microservices_migration_cut_points',
2269
+ ]);
2270
+ if (ORG_TOOLS.has(name)) {
2271
+ return runOrgTool(name, args || {});
2272
+ }
2273
+
2274
+ return text(`Unknown tool: ${name}`);
2275
+ } catch (err) {
2276
+ process.stderr.write(`[CARTO MCP] Tool "${name}" error: ${err.stack || err.message || err}\n`);
2277
+ return {
2278
+ content: [{ type: 'text', text: `Error in ${name}: ${err.message || String(err)}` }],
2279
+ isError: true,
2280
+ };
2281
+ }
2282
+ });
2283
+
2284
+ async function main() {
2285
+ const transport = new StdioServerTransport();
2286
+ await server.connect(transport);
2287
+ }
2288
+
2289
+ main().catch(err => {
2290
+ console.error('[CARTO MCP] Fatal:', err.message);
2291
+ process.exit(1);
2292
+ });