create-merlin-brain 5.3.7 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bin/install-rtk.cjs +282 -0
  2. package/bin/install.cjs +26 -4
  3. package/dist/server/api/client.d.ts.map +1 -1
  4. package/dist/server/api/client.js +35 -6
  5. package/dist/server/api/client.js.map +1 -1
  6. package/dist/server/server.d.ts.map +1 -1
  7. package/dist/server/server.js +146 -42
  8. package/dist/server/server.js.map +1 -1
  9. package/dist/server/session-coach.d.ts.map +1 -1
  10. package/dist/server/session-coach.js +12 -0
  11. package/dist/server/session-coach.js.map +1 -1
  12. package/dist/server/session-guardian.d.ts +8 -1
  13. package/dist/server/session-guardian.d.ts.map +1 -1
  14. package/dist/server/session-guardian.js +26 -14
  15. package/dist/server/session-guardian.js.map +1 -1
  16. package/dist/server/tools/challenge.d.ts.map +1 -1
  17. package/dist/server/tools/challenge.js +7 -1
  18. package/dist/server/tools/challenge.js.map +1 -1
  19. package/dist/server/tools/computer-use.d.ts.map +1 -1
  20. package/dist/server/tools/computer-use.js +13 -6
  21. package/dist/server/tools/computer-use.js.map +1 -1
  22. package/dist/server/tools/index.d.ts +0 -1
  23. package/dist/server/tools/index.d.ts.map +1 -1
  24. package/dist/server/tools/index.js +0 -1
  25. package/dist/server/tools/index.js.map +1 -1
  26. package/dist/server/tools/project.d.ts.map +1 -1
  27. package/dist/server/tools/project.js +14 -12
  28. package/dist/server/tools/project.js.map +1 -1
  29. package/dist/server/tools/verification-runner.d.ts +45 -0
  30. package/dist/server/tools/verification-runner.d.ts.map +1 -0
  31. package/dist/server/tools/verification-runner.js +264 -0
  32. package/dist/server/tools/verification-runner.js.map +1 -0
  33. package/dist/server/tools/verification.d.ts +3 -0
  34. package/dist/server/tools/verification.d.ts.map +1 -1
  35. package/dist/server/tools/verification.js +8 -265
  36. package/dist/server/tools/verification.js.map +1 -1
  37. package/files/CLAUDE.md +1 -0
  38. package/files/commands/merlin/check-size.md +152 -0
  39. package/files/hooks/check-file-size.sh +166 -58
  40. package/files/hooks/pre-edit-sights-check.sh +19 -3
  41. package/files/hooks/security-scanner.sh +3 -4
  42. package/files/hooks/session-end.sh +45 -32
  43. package/files/hooks/smart-approve.sh +11 -3
  44. package/files/hooks/user-prompt-router.sh +24 -3
  45. package/files/merlin/VERSION +1 -1
  46. package/files/merlin-system-prompt.txt +3 -1
  47. package/package.json +2 -2
  48. package/dist/server/tools/context.d.ts +0 -7
  49. package/dist/server/tools/context.d.ts.map +0 -1
  50. package/dist/server/tools/context.js +0 -614
  51. package/dist/server/tools/context.js.map +0 -1
  52. package/dist/server/tools/hud.d.ts +0 -13
  53. package/dist/server/tools/hud.d.ts.map +0 -1
  54. package/dist/server/tools/hud.js +0 -295
  55. package/dist/server/tools/hud.js.map +0 -1
  56. package/dist/server/tools/provider-ask.d.ts +0 -10
  57. package/dist/server/tools/provider-ask.d.ts.map +0 -1
  58. package/dist/server/tools/provider-ask.js +0 -234
  59. package/dist/server/tools/provider-ask.js.map +0 -1
  60. package/dist/server/tools/rate-limit.d.ts +0 -8
  61. package/dist/server/tools/rate-limit.d.ts.map +0 -1
  62. package/dist/server/tools/rate-limit.js +0 -184
  63. package/dist/server/tools/rate-limit.js.map +0 -1
  64. package/dist/server/tools/team-workers.d.ts +0 -7
  65. package/dist/server/tools/team-workers.d.ts.map +0 -1
  66. package/dist/server/tools/team-workers.js +0 -271
  67. package/dist/server/tools/team-workers.js.map +0 -1
@@ -3,6 +3,7 @@
3
3
  * AI-powered codebase intelligence for coding agents
4
4
  * https://merlin.build
5
5
  */
6
+ // merlin:allow-large-file: MCP server single-entry-point — all tool registrations must live here for SDK compatibility; split deferred to a future refactor phase
6
7
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
9
  import { z } from 'zod';
@@ -13,6 +14,7 @@ import { existsSync, readFileSync } from 'fs';
13
14
  import { homedir } from 'os';
14
15
  import { join } from 'path';
15
16
  import { VERSION } from './version.js';
17
+ import { startGuardian, stopGuardian } from './session-guardian.js';
16
18
  import { registerProjectTools } from './tools/project.js';
17
19
  import { registerBehaviorTools } from './tools/behaviors.js';
18
20
  import { registerVerificationTools } from './tools/verification.js';
@@ -53,6 +55,23 @@ function loadSavedConfig() {
53
55
  }
54
56
  return null;
55
57
  }
58
+ /**
59
+ * Resolve the Merlin auth token for cloud sync.
60
+ * Prefers process.env.MERLIN_API_KEY (the real key source — client.ts uses it).
61
+ * Falls back to apiKey stored in ~/.merlin/config.json for callers that need
62
+ * an explicit token string (e.g. registerLiteTools, registerConfigSyncTools).
63
+ */
64
+ function getAuthToken() {
65
+ try {
66
+ if (process.env.MERLIN_API_KEY)
67
+ return process.env.MERLIN_API_KEY;
68
+ const c = loadSavedConfig();
69
+ return c?.apiKey ?? null;
70
+ }
71
+ catch {
72
+ return process.env.MERLIN_API_KEY ?? null;
73
+ }
74
+ }
56
75
  /** Create and configure the MCP server */
57
76
  export function createServer() {
58
77
  const server = new McpServer({
@@ -64,18 +83,43 @@ export function createServer() {
64
83
  // Intercept every tool response to ensure the ⟡🔮 badge is present.
65
84
  // This guarantees Merlin's visual identity on EVERY touchpoint without
66
85
  // patching 80+ individual tool handlers.
86
+ //
87
+ // SDK-version note: this monkey-patches the deprecated server.tool() API.
88
+ // If the MCP SDK is upgraded to a version that changes how tool handlers are
89
+ // registered, this patch may silently stop working and must be revisited.
90
+ //
91
+ // IMPORTANT: _badgeWrap must NOT call recordToolCall(). Each tool handler
92
+ // already records exactly once via coachWrap(), wrapResponse(), or a direct
93
+ // call. Adding it here would double-count every tool invocation and corrupt
94
+ // drift detection.
67
95
  const _origTool = server.tool.bind(server);
68
96
  const _badgeWrap = (handler) => {
69
97
  return async function (...handlerArgs) {
70
- const result = await handler.apply(this, handlerArgs);
71
- if (result?.content) {
72
- for (const block of result.content) {
73
- if (block.type === 'text' && block.text && typeof block.text === 'string') {
74
- if (!block.text.includes('⟡🔮')) {
75
- block.text = `⟡🔮 MERLIN ›\n\n${block.text}`;
76
- }
77
- }
78
- }
98
+ let result;
99
+ try {
100
+ result = await handler.apply(this, handlerArgs);
101
+ }
102
+ catch (err) {
103
+ console.error('[merlin] tool handler threw:', err);
104
+ throw err;
105
+ }
106
+ // Guard: skip badge injection for error results or responses without text content
107
+ const r = result;
108
+ if (!r || r.isError || !Array.isArray(r.content) || r.content.length === 0) {
109
+ return result;
110
+ }
111
+ const firstTextIdx = r.content.findIndex((b) => b.type === 'text' && typeof b.text === 'string');
112
+ if (firstTextIdx === -1) {
113
+ return result;
114
+ }
115
+ const firstBlock = r.content[firstTextIdx];
116
+ if (firstBlock.text && !firstBlock.text.includes('⟡🔮')) {
117
+ // Build a new content array — do NOT mutate in place to stay safe for
118
+ // structured/outputSchema responses that may share references
119
+ const newContent = r.content.map((b, i) => i === firstTextIdx
120
+ ? { ...b, text: `⟡🔮 MERLIN ›\n\n${b.text}` }
121
+ : b);
122
+ return { ...r, content: newContent };
79
123
  }
80
124
  return result;
81
125
  };
@@ -98,7 +142,11 @@ export function createServer() {
98
142
  // Cache for resolved repo IDs to avoid repeated git/API calls
99
143
  // Key: url or 'auto' for auto-detected, Value: { repoId, expiresAt }
100
144
  const repoIdCache = new Map();
101
- const REPO_ID_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - repo ID won't change mid-session
145
+ const REPO_ID_CACHE_TTL = 5 * 60 * 1000; // 5 minutes positive hits; repo ID won't change mid-session
146
+ const REPO_ID_NEGATIVE_TTL = 10 * 1000; // 10 seconds — negative hits; short so a freshly-connected repo isn't stuck
147
+ // In-flight dedup: coalesce concurrent resolveRepoId calls for the same key
148
+ // Prevents multiple parallel cold-cache lookups from all hitting the API simultaneously
149
+ const resolveRepoIdPending = new Map();
102
150
  // Cache the detected repo root path (full absolute path) for file operations
103
151
  // This is crucial for Claude Code's Glob tool to work with paths containing spaces
104
152
  let cachedRepoRootPath = null;
@@ -111,6 +159,27 @@ export function createServer() {
111
159
  // Helper to resolve repo ID from URL or use session-selected repo
112
160
  // Also caches the repo root path for use with file operations
113
161
  async function resolveRepoId(repoUrl) {
162
+ const dedupKey = repoUrl ?? 'auto';
163
+ // Fast-path: return cached result without acquiring the pending lock
164
+ if (!repoUrl && selectedRepoId)
165
+ return selectedRepoId;
166
+ const earlyHit = repoIdCache.get(dedupKey);
167
+ if (earlyHit && Date.now() < earlyHit.expiresAt)
168
+ return earlyHit.repoId;
169
+ // Coalesce concurrent in-flight requests for the same key
170
+ const inflight = resolveRepoIdPending.get(dedupKey);
171
+ if (inflight)
172
+ return inflight;
173
+ const promise = resolveRepoIdInner(repoUrl);
174
+ resolveRepoIdPending.set(dedupKey, promise);
175
+ try {
176
+ return await promise;
177
+ }
178
+ finally {
179
+ resolveRepoIdPending.delete(dedupKey);
180
+ }
181
+ }
182
+ async function resolveRepoIdInner(repoUrl) {
114
183
  // If explicit URL provided, use it
115
184
  if (repoUrl) {
116
185
  const cacheKey = repoUrl;
@@ -120,7 +189,8 @@ export function createServer() {
120
189
  }
121
190
  const repo = await client.findRepoByUrl(repoUrl);
122
191
  const repoId = repo?.id || null;
123
- repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + REPO_ID_CACHE_TTL });
192
+ const ttl = repoId ? REPO_ID_CACHE_TTL : REPO_ID_NEGATIVE_TTL;
193
+ repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + ttl });
124
194
  return repoId;
125
195
  }
126
196
  // If session has a selected repo, use it (takes precedence over auto-detect)
@@ -148,14 +218,15 @@ export function createServer() {
148
218
  }
149
219
  const detected = await detectRepository();
150
220
  if (!detected) {
151
- repoIdCache.set(cacheKey, { repoId: null, expiresAt: Date.now() + 60000 });
221
+ repoIdCache.set(cacheKey, { repoId: null, expiresAt: Date.now() + REPO_ID_NEGATIVE_TTL });
152
222
  return null;
153
223
  }
154
224
  // Cache the repo root path - CRITICAL for paths with spaces
155
225
  cachedRepoRootPath = detected.rootDir;
156
226
  const repo = await client.findRepoByUrl(detected.url);
157
227
  const repoId = repo?.id || null;
158
- repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + REPO_ID_CACHE_TTL });
228
+ const autoTtl = repoId ? REPO_ID_CACHE_TTL : REPO_ID_NEGATIVE_TTL;
229
+ repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + autoTtl });
159
230
  return repoId;
160
231
  }
161
232
  // Helper to get the full absolute repo root path
@@ -458,10 +529,6 @@ export function createServer() {
458
529
  const liteClient = await getOrInitLiteClient(repoRoot);
459
530
  if (liteClient) {
460
531
  // Use Lite mode! Get local data + try cloud enhancement in parallel
461
- const getAuthToken = async () => {
462
- const config = loadSavedConfig();
463
- return config?.apiKey || null;
464
- };
465
532
  const [howTo, files, conventions, upgradeMsg, liteExport] = await Promise.all([
466
533
  liteClient.getHowTo(task),
467
534
  liteClient.getFiles({ purpose: task }),
@@ -471,7 +538,7 @@ export function createServer() {
471
538
  ]);
472
539
  // Try cloud enhancement in parallel (non-blocking, 8s timeout)
473
540
  const detected = await detectRepository(repoRoot);
474
- const cloudResult = await enhanceFromCloud(task, { files: liteExport.files, conventions: liteExport.conventions, overview: liteExport.manifest }, detected?.url, getAuthToken);
541
+ const cloudResult = await enhanceFromCloud(task, { files: liteExport.files, conventions: liteExport.conventions, overview: liteExport.manifest }, detected?.url, async () => getAuthToken());
475
542
  let context = `# Context for: ${task}\n\n`;
476
543
  const modeLabel = cloudResult?.enhanced
477
544
  ? '⟡🔮 MERLIN Lite + Cloud Enhanced'
@@ -2588,14 +2655,26 @@ export function createServer() {
2588
2655
  description: 'List of all analyzed repositories',
2589
2656
  mimeType: 'application/json',
2590
2657
  }, async () => {
2591
- const repos = await client.getRepositories();
2592
- return {
2593
- contents: [{
2594
- uri: 'merlin://repos',
2595
- mimeType: 'application/json',
2596
- text: JSON.stringify(repos, null, 2),
2597
- }],
2598
- };
2658
+ try {
2659
+ const repos = await client.getRepositories();
2660
+ return {
2661
+ contents: [{
2662
+ uri: 'merlin://repos',
2663
+ mimeType: 'application/json',
2664
+ text: JSON.stringify(repos, null, 2),
2665
+ }],
2666
+ };
2667
+ }
2668
+ catch (err) {
2669
+ console.error('[merlin] resource repos error:', err);
2670
+ return {
2671
+ contents: [{
2672
+ uri: 'merlin://repos',
2673
+ mimeType: 'application/json',
2674
+ text: JSON.stringify({ error: err instanceof Error ? err.message : 'Failed to fetch repositories' }),
2675
+ }],
2676
+ };
2677
+ }
2599
2678
  });
2600
2679
  // ============================================================
2601
2680
  // PROJECT MANAGEMENT TOOLS
@@ -2750,11 +2829,7 @@ export function createServer() {
2750
2829
  registerLiteTools({
2751
2830
  server,
2752
2831
  getRepoRootPath,
2753
- getAuthToken: async () => {
2754
- // Get token from saved config
2755
- const config = loadSavedConfig();
2756
- return config?.apiKey || null;
2757
- },
2832
+ getAuthToken: async () => getAuthToken(),
2758
2833
  });
2759
2834
  } // end free: registerLiteTools
2760
2835
  // ============================================================
@@ -2771,10 +2846,7 @@ export function createServer() {
2771
2846
  // ============================================================
2772
2847
  registerConfigSyncTools({
2773
2848
  server,
2774
- getAuthToken: async () => {
2775
- const config = loadSavedConfig();
2776
- return config?.apiKey || null;
2777
- },
2849
+ getAuthToken: async () => getAuthToken(),
2778
2850
  });
2779
2851
  } // end cloud: registerConfigSyncTools
2780
2852
  // ============================================================
@@ -2856,16 +2928,31 @@ export async function startServer() {
2856
2928
  await server.connect(transport);
2857
2929
  // Log to stderr (stdout is used for MCP protocol)
2858
2930
  console.error('Merlin MCP server started');
2931
+ // ── Global error boundary — log and keep the server alive ─────────
2932
+ // Without these, an uncaught async error in a tool handler would silently
2933
+ // crash the process. These handlers log the problem and continue running
2934
+ // so the session is not destroyed by a single misbehaving tool.
2935
+ process.on('uncaughtException', (err) => {
2936
+ console.error('[merlin] uncaughtException', err);
2937
+ });
2938
+ process.on('unhandledRejection', (reason) => {
2939
+ console.error('[merlin] unhandledRejection', reason);
2940
+ });
2859
2941
  // ── Start Guardian HTTP sidecar (non-blocking) ────────────────────
2860
2942
  // Enables hooks to query MCP session state via localhost HTTP.
2861
2943
  // Starts on a random port, writes port file for hooks to discover.
2862
- import('./session-guardian.js').then(({ startGuardian }) => {
2863
- startGuardian().catch(err => {
2864
- console.error(`Merlin Guardian: failed to start (non-fatal): ${err.message}`);
2865
- });
2866
- }).catch(() => {
2867
- console.error('Merlin Guardian: module load failed (non-fatal)');
2944
+ // The guardian's own SIGTERM/SIGHUP/exit self-registrations have been
2945
+ // removed; the exit paths below call stopGuardian() (statically imported,
2946
+ // synchronous, idempotent) so the port file is always cleaned up before exit.
2947
+ startGuardian().catch((err) => {
2948
+ console.error(`Merlin Guardian: failed to start (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
2868
2949
  });
2950
+ // Final safety net: clean up the guardian on ANY process exit path
2951
+ // (covers paths that call process.exit directly). Idempotent.
2952
+ process.on('exit', () => { try {
2953
+ stopGuardian();
2954
+ }
2955
+ catch { /* ignore */ } });
2869
2956
  // ── Lifecycle: exit when parent (Claude Code) disconnects ──────────
2870
2957
  // Without these handlers, merlin-brain becomes a zombie process when
2871
2958
  // Claude Code closes the session. Over time, dozens of orphaned
@@ -2874,11 +2961,24 @@ export async function startServer() {
2874
2961
  // When Claude Code exits, stdin gets closed → the 'end' event fires.
2875
2962
  process.stdin.on('end', () => {
2876
2963
  console.error('Merlin MCP server: stdin closed (parent disconnected), exiting.');
2964
+ try {
2965
+ stopGuardian();
2966
+ }
2967
+ catch { /* ignore */ }
2877
2968
  process.exit(0);
2878
2969
  });
2879
- // Handle SIGTERM/SIGHUP gracefully (sent by OS or Claude Code on shutdown)
2970
+ // Handle SIGTERM/SIGHUP gracefully (sent by OS or Claude Code on shutdown).
2971
+ // stopGuardian() is called first so the .guardian-port file is removed
2972
+ // before exit — a stale port file would confuse the next session startup.
2880
2973
  const gracefulExit = (signal) => {
2881
2974
  console.error(`Merlin MCP server: received ${signal}, exiting.`);
2975
+ // Synchronous guardian cleanup — stopGuardian is statically imported and
2976
+ // idempotent, so the .guardian-port file is always removed BEFORE exit.
2977
+ // (A dynamic import().then() here would never drain before process.exit.)
2978
+ try {
2979
+ stopGuardian();
2980
+ }
2981
+ catch { /* guardian may not have started — safe */ }
2882
2982
  process.exit(0);
2883
2983
  };
2884
2984
  process.on('SIGTERM', () => gracefulExit('SIGTERM'));
@@ -2887,6 +2987,10 @@ export async function startServer() {
2887
2987
  process.stdout.on('error', (err) => {
2888
2988
  if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
2889
2989
  console.error('Merlin MCP server: stdout broken pipe, exiting.');
2990
+ try {
2991
+ stopGuardian();
2992
+ }
2993
+ catch { /* ignore */ }
2890
2994
  process.exit(0);
2891
2995
  }
2892
2996
  });