create-merlin-brain 5.3.8 → 5.4.1

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 (63) hide show
  1. package/bin/install-rtk.cjs +296 -0
  2. package/bin/install.cjs +9 -0
  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 +148 -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/hooks/pre-edit-sights-check.sh +40 -3
  38. package/files/hooks/security-scanner.sh +3 -4
  39. package/files/hooks/session-end.sh +45 -32
  40. package/files/hooks/smart-approve.sh +11 -3
  41. package/files/hooks/user-prompt-router.sh +30 -3
  42. package/files/merlin/VERSION +1 -1
  43. package/package.json +2 -2
  44. package/dist/server/tools/context.d.ts +0 -7
  45. package/dist/server/tools/context.d.ts.map +0 -1
  46. package/dist/server/tools/context.js +0 -614
  47. package/dist/server/tools/context.js.map +0 -1
  48. package/dist/server/tools/hud.d.ts +0 -13
  49. package/dist/server/tools/hud.d.ts.map +0 -1
  50. package/dist/server/tools/hud.js +0 -295
  51. package/dist/server/tools/hud.js.map +0 -1
  52. package/dist/server/tools/provider-ask.d.ts +0 -10
  53. package/dist/server/tools/provider-ask.d.ts.map +0 -1
  54. package/dist/server/tools/provider-ask.js +0 -234
  55. package/dist/server/tools/provider-ask.js.map +0 -1
  56. package/dist/server/tools/rate-limit.d.ts +0 -8
  57. package/dist/server/tools/rate-limit.d.ts.map +0 -1
  58. package/dist/server/tools/rate-limit.js +0 -184
  59. package/dist/server/tools/rate-limit.js.map +0 -1
  60. package/dist/server/tools/team-workers.d.ts +0 -7
  61. package/dist/server/tools/team-workers.d.ts.map +0 -1
  62. package/dist/server/tools/team-workers.js +0 -271
  63. 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,45 @@ 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 only when there is no text content to badge.
107
+ // NOTE: error results ARE badged too — "badge on EVERY action" is the
108
+ // brand contract (CLAUDE.md); Merlin must stay visible even on failures.
109
+ const r = result;
110
+ if (!r || !Array.isArray(r.content) || r.content.length === 0) {
111
+ return result;
112
+ }
113
+ const firstTextIdx = r.content.findIndex((b) => b.type === 'text' && typeof b.text === 'string');
114
+ if (firstTextIdx === -1) {
115
+ return result;
116
+ }
117
+ const firstBlock = r.content[firstTextIdx];
118
+ if (firstBlock.text && !firstBlock.text.includes('⟡🔮')) {
119
+ // Build a new content array — do NOT mutate in place to stay safe for
120
+ // structured/outputSchema responses that may share references
121
+ const newContent = r.content.map((b, i) => i === firstTextIdx
122
+ ? { ...b, text: `⟡🔮 MERLIN ›\n\n${b.text}` }
123
+ : b);
124
+ return { ...r, content: newContent };
79
125
  }
80
126
  return result;
81
127
  };
@@ -98,7 +144,11 @@ export function createServer() {
98
144
  // Cache for resolved repo IDs to avoid repeated git/API calls
99
145
  // Key: url or 'auto' for auto-detected, Value: { repoId, expiresAt }
100
146
  const repoIdCache = new Map();
101
- const REPO_ID_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - repo ID won't change mid-session
147
+ const REPO_ID_CACHE_TTL = 5 * 60 * 1000; // 5 minutes positive hits; repo ID won't change mid-session
148
+ const REPO_ID_NEGATIVE_TTL = 10 * 1000; // 10 seconds — negative hits; short so a freshly-connected repo isn't stuck
149
+ // In-flight dedup: coalesce concurrent resolveRepoId calls for the same key
150
+ // Prevents multiple parallel cold-cache lookups from all hitting the API simultaneously
151
+ const resolveRepoIdPending = new Map();
102
152
  // Cache the detected repo root path (full absolute path) for file operations
103
153
  // This is crucial for Claude Code's Glob tool to work with paths containing spaces
104
154
  let cachedRepoRootPath = null;
@@ -111,6 +161,27 @@ export function createServer() {
111
161
  // Helper to resolve repo ID from URL or use session-selected repo
112
162
  // Also caches the repo root path for use with file operations
113
163
  async function resolveRepoId(repoUrl) {
164
+ const dedupKey = repoUrl ?? 'auto';
165
+ // Fast-path: return cached result without acquiring the pending lock
166
+ if (!repoUrl && selectedRepoId)
167
+ return selectedRepoId;
168
+ const earlyHit = repoIdCache.get(dedupKey);
169
+ if (earlyHit && Date.now() < earlyHit.expiresAt)
170
+ return earlyHit.repoId;
171
+ // Coalesce concurrent in-flight requests for the same key
172
+ const inflight = resolveRepoIdPending.get(dedupKey);
173
+ if (inflight)
174
+ return inflight;
175
+ const promise = resolveRepoIdInner(repoUrl);
176
+ resolveRepoIdPending.set(dedupKey, promise);
177
+ try {
178
+ return await promise;
179
+ }
180
+ finally {
181
+ resolveRepoIdPending.delete(dedupKey);
182
+ }
183
+ }
184
+ async function resolveRepoIdInner(repoUrl) {
114
185
  // If explicit URL provided, use it
115
186
  if (repoUrl) {
116
187
  const cacheKey = repoUrl;
@@ -120,7 +191,8 @@ export function createServer() {
120
191
  }
121
192
  const repo = await client.findRepoByUrl(repoUrl);
122
193
  const repoId = repo?.id || null;
123
- repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + REPO_ID_CACHE_TTL });
194
+ const ttl = repoId ? REPO_ID_CACHE_TTL : REPO_ID_NEGATIVE_TTL;
195
+ repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + ttl });
124
196
  return repoId;
125
197
  }
126
198
  // If session has a selected repo, use it (takes precedence over auto-detect)
@@ -148,14 +220,15 @@ export function createServer() {
148
220
  }
149
221
  const detected = await detectRepository();
150
222
  if (!detected) {
151
- repoIdCache.set(cacheKey, { repoId: null, expiresAt: Date.now() + 60000 });
223
+ repoIdCache.set(cacheKey, { repoId: null, expiresAt: Date.now() + REPO_ID_NEGATIVE_TTL });
152
224
  return null;
153
225
  }
154
226
  // Cache the repo root path - CRITICAL for paths with spaces
155
227
  cachedRepoRootPath = detected.rootDir;
156
228
  const repo = await client.findRepoByUrl(detected.url);
157
229
  const repoId = repo?.id || null;
158
- repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + REPO_ID_CACHE_TTL });
230
+ const autoTtl = repoId ? REPO_ID_CACHE_TTL : REPO_ID_NEGATIVE_TTL;
231
+ repoIdCache.set(cacheKey, { repoId, expiresAt: Date.now() + autoTtl });
159
232
  return repoId;
160
233
  }
161
234
  // Helper to get the full absolute repo root path
@@ -458,10 +531,6 @@ export function createServer() {
458
531
  const liteClient = await getOrInitLiteClient(repoRoot);
459
532
  if (liteClient) {
460
533
  // 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
534
  const [howTo, files, conventions, upgradeMsg, liteExport] = await Promise.all([
466
535
  liteClient.getHowTo(task),
467
536
  liteClient.getFiles({ purpose: task }),
@@ -471,7 +540,7 @@ export function createServer() {
471
540
  ]);
472
541
  // Try cloud enhancement in parallel (non-blocking, 8s timeout)
473
542
  const detected = await detectRepository(repoRoot);
474
- const cloudResult = await enhanceFromCloud(task, { files: liteExport.files, conventions: liteExport.conventions, overview: liteExport.manifest }, detected?.url, getAuthToken);
543
+ const cloudResult = await enhanceFromCloud(task, { files: liteExport.files, conventions: liteExport.conventions, overview: liteExport.manifest }, detected?.url, async () => getAuthToken());
475
544
  let context = `# Context for: ${task}\n\n`;
476
545
  const modeLabel = cloudResult?.enhanced
477
546
  ? '⟡🔮 MERLIN Lite + Cloud Enhanced'
@@ -2588,14 +2657,26 @@ export function createServer() {
2588
2657
  description: 'List of all analyzed repositories',
2589
2658
  mimeType: 'application/json',
2590
2659
  }, 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
- };
2660
+ try {
2661
+ const repos = await client.getRepositories();
2662
+ return {
2663
+ contents: [{
2664
+ uri: 'merlin://repos',
2665
+ mimeType: 'application/json',
2666
+ text: JSON.stringify(repos, null, 2),
2667
+ }],
2668
+ };
2669
+ }
2670
+ catch (err) {
2671
+ console.error('[merlin] resource repos error:', err);
2672
+ return {
2673
+ contents: [{
2674
+ uri: 'merlin://repos',
2675
+ mimeType: 'application/json',
2676
+ text: JSON.stringify({ error: err instanceof Error ? err.message : 'Failed to fetch repositories' }),
2677
+ }],
2678
+ };
2679
+ }
2599
2680
  });
2600
2681
  // ============================================================
2601
2682
  // PROJECT MANAGEMENT TOOLS
@@ -2750,11 +2831,7 @@ export function createServer() {
2750
2831
  registerLiteTools({
2751
2832
  server,
2752
2833
  getRepoRootPath,
2753
- getAuthToken: async () => {
2754
- // Get token from saved config
2755
- const config = loadSavedConfig();
2756
- return config?.apiKey || null;
2757
- },
2834
+ getAuthToken: async () => getAuthToken(),
2758
2835
  });
2759
2836
  } // end free: registerLiteTools
2760
2837
  // ============================================================
@@ -2771,10 +2848,7 @@ export function createServer() {
2771
2848
  // ============================================================
2772
2849
  registerConfigSyncTools({
2773
2850
  server,
2774
- getAuthToken: async () => {
2775
- const config = loadSavedConfig();
2776
- return config?.apiKey || null;
2777
- },
2851
+ getAuthToken: async () => getAuthToken(),
2778
2852
  });
2779
2853
  } // end cloud: registerConfigSyncTools
2780
2854
  // ============================================================
@@ -2856,16 +2930,31 @@ export async function startServer() {
2856
2930
  await server.connect(transport);
2857
2931
  // Log to stderr (stdout is used for MCP protocol)
2858
2932
  console.error('Merlin MCP server started');
2933
+ // ── Global error boundary — log and keep the server alive ─────────
2934
+ // Without these, an uncaught async error in a tool handler would silently
2935
+ // crash the process. These handlers log the problem and continue running
2936
+ // so the session is not destroyed by a single misbehaving tool.
2937
+ process.on('uncaughtException', (err) => {
2938
+ console.error('[merlin] uncaughtException', err);
2939
+ });
2940
+ process.on('unhandledRejection', (reason) => {
2941
+ console.error('[merlin] unhandledRejection', reason);
2942
+ });
2859
2943
  // ── Start Guardian HTTP sidecar (non-blocking) ────────────────────
2860
2944
  // Enables hooks to query MCP session state via localhost HTTP.
2861
2945
  // 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)');
2946
+ // The guardian's own SIGTERM/SIGHUP/exit self-registrations have been
2947
+ // removed; the exit paths below call stopGuardian() (statically imported,
2948
+ // synchronous, idempotent) so the port file is always cleaned up before exit.
2949
+ startGuardian().catch((err) => {
2950
+ console.error(`Merlin Guardian: failed to start (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
2868
2951
  });
2952
+ // Final safety net: clean up the guardian on ANY process exit path
2953
+ // (covers paths that call process.exit directly). Idempotent.
2954
+ process.on('exit', () => { try {
2955
+ stopGuardian();
2956
+ }
2957
+ catch { /* ignore */ } });
2869
2958
  // ── Lifecycle: exit when parent (Claude Code) disconnects ──────────
2870
2959
  // Without these handlers, merlin-brain becomes a zombie process when
2871
2960
  // Claude Code closes the session. Over time, dozens of orphaned
@@ -2874,11 +2963,24 @@ export async function startServer() {
2874
2963
  // When Claude Code exits, stdin gets closed → the 'end' event fires.
2875
2964
  process.stdin.on('end', () => {
2876
2965
  console.error('Merlin MCP server: stdin closed (parent disconnected), exiting.');
2966
+ try {
2967
+ stopGuardian();
2968
+ }
2969
+ catch { /* ignore */ }
2877
2970
  process.exit(0);
2878
2971
  });
2879
- // Handle SIGTERM/SIGHUP gracefully (sent by OS or Claude Code on shutdown)
2972
+ // Handle SIGTERM/SIGHUP gracefully (sent by OS or Claude Code on shutdown).
2973
+ // stopGuardian() is called first so the .guardian-port file is removed
2974
+ // before exit — a stale port file would confuse the next session startup.
2880
2975
  const gracefulExit = (signal) => {
2881
2976
  console.error(`Merlin MCP server: received ${signal}, exiting.`);
2977
+ // Synchronous guardian cleanup — stopGuardian is statically imported and
2978
+ // idempotent, so the .guardian-port file is always removed BEFORE exit.
2979
+ // (A dynamic import().then() here would never drain before process.exit.)
2980
+ try {
2981
+ stopGuardian();
2982
+ }
2983
+ catch { /* guardian may not have started — safe */ }
2882
2984
  process.exit(0);
2883
2985
  };
2884
2986
  process.on('SIGTERM', () => gracefulExit('SIGTERM'));
@@ -2887,6 +2989,10 @@ export async function startServer() {
2887
2989
  process.stdout.on('error', (err) => {
2888
2990
  if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
2889
2991
  console.error('Merlin MCP server: stdout broken pipe, exiting.');
2992
+ try {
2993
+ stopGuardian();
2994
+ }
2995
+ catch { /* ignore */ }
2890
2996
  process.exit(0);
2891
2997
  }
2892
2998
  });